diff --git a/.agents/phases/todo/156_table_ux_polish/00_phase.md b/.agents/phases/complete/156_table_ux_polish/00_phase.md similarity index 100% rename from .agents/phases/todo/156_table_ux_polish/00_phase.md rename to .agents/phases/complete/156_table_ux_polish/00_phase.md diff --git a/.agents/phases/todo/156_table_ux_polish/01_sticky_header.md b/.agents/phases/complete/156_table_ux_polish/01_sticky_header.md similarity index 100% rename from .agents/phases/todo/156_table_ux_polish/01_sticky_header.md rename to .agents/phases/complete/156_table_ux_polish/01_sticky_header.md diff --git a/.agents/phases/todo/156_table_ux_polish/02_ellipsis_hints.md b/.agents/phases/complete/156_table_ux_polish/02_ellipsis_hints.md similarity index 100% rename from .agents/phases/todo/156_table_ux_polish/02_ellipsis_hints.md rename to .agents/phases/complete/156_table_ux_polish/02_ellipsis_hints.md diff --git a/.agents/phases/todo/156_table_ux_polish/03_unit_pins.md b/.agents/phases/complete/156_table_ux_polish/03_unit_pins.md similarity index 100% rename from .agents/phases/todo/156_table_ux_polish/03_unit_pins.md rename to .agents/phases/complete/156_table_ux_polish/03_unit_pins.md diff --git a/.agents/phases/todo/156_table_ux_polish/04_e2e_table_polish.md b/.agents/phases/complete/156_table_ux_polish/04_e2e_table_polish.md similarity index 100% rename from .agents/phases/todo/156_table_ux_polish/04_e2e_table_polish.md rename to .agents/phases/complete/156_table_ux_polish/04_e2e_table_polish.md diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__00_phase.a1.err b/.agents/reports/156_table_ux_polish/156_table_ux_polish__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__00_phase.a1.md b/.agents/reports/156_table_ux_polish/156_table_ux_polish__00_phase.a1.md new file mode 100644 index 0000000..de1f985 --- /dev/null +++ b/.agents/reports/156_table_ux_polish/156_table_ux_polish__00_phase.a1.md @@ -0,0 +1,10 @@ +Everything is verified. All gates green on the current working tree. Final report: + +**Phase 156 — final verification pass (all 4 task files present in `complete/` — no outstanding work)** + +- **Verified (not re-implemented):** bounded-panel `.table-wrap` (`max-height: min(70vh, 64rem)`, `overflow: auto`, opaque sticky `th` via `var(--brand-soft)`), `.cell-clip` ellipsis rule + the five label families in the three renderers, the added aria-labels, unit pins (incl. `.md-table-wrap` byte-unchanged scope guard, `#git-sources-table-wrap` harmony — no divergent rule), dedicated E2E suite (sticky ×3 tables, horizontal contract, computed-style ellipsis + reachable full text, mobile hamburger + scroll + pin) +- **Tests/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **3570 passed, 99% on `app/`** (gate >90%); `uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov` (DB up) → **6/6 passed in isolation**; `uv run ruff check . && uv run pyright` → **clean** (0 errors) +- **Completion criteria:** sticky header all 3 tables (exact-0 offset while `scrollTop > 0`) ✅ · horizontal scroll unchanged ✅ · ellipsis computed style + actual clip + title/aria reachable ✅ · mobile ≤640px contract ✅ · full suite/E2E/lint/coverage ✅ · atomic commit: **harness commits this phase after the pass** (per pipeline protocol — no git history commands run by me) +- **Documentation:** AGENTS.md phase-156 wording (bounded panels, sticky headers, `.cell-clip` + aria-label idiom) matches the implementation exactly — verified consistent; README.md reviewed in full (pages/URLs vs registered routes, all `.env.example` vars documented, Quick Start, eval/llm scripts, compose prod profile, E2E examples) — verified consistent, no changes needed (README describes no table layout/scroll, so no phase-156 mention required) +- **Deviations:** none — all work was already in the working tree; this pass re-verified every gate and the docs +- **Next pending phase:** `157_chat_chip_cap` diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__00_phase.a1.validate b/.agents/reports/156_table_ux_polish/156_table_ux_polish__00_phase.a1.validate new file mode 100644 index 0000000..ef441e2 --- /dev/null +++ b/.agents/reports/156_table_ux_polish/156_table_ux_polish__00_phase.a1.validate @@ -0,0 +1,121 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +.......................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5719 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__01_sticky_header.a1.err b/.agents/reports/156_table_ux_polish/156_table_ux_polish__01_sticky_header.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__01_sticky_header.a1.md b/.agents/reports/156_table_ux_polish/156_table_ux_polish__01_sticky_header.a1.md new file mode 100644 index 0000000..60a8d76 --- /dev/null +++ b/.agents/reports/156_table_ux_polish/156_table_ux_polish__01_sticky_header.a1.md @@ -0,0 +1,13 @@ +That TODO.md change pre-dates my task (owner-edited in the working tree) — leaving it as-is. All work is complete and verified. + +**Report** + +- `.table-wrap`: `overflow-x: auto` → `overflow: auto` + `max-height: min(70vh, 64rem)` (A1 bounded panel; horizontal contract kept via tables' min-widths) +- New ONE sticky rule `.table-wrap table thead th { position: sticky; top: 0; z-index: 1; background: var(--brand-soft) }` — brand-soft is a solid hex (already opaque; kept as-is, no new palette variable, B3/B4-safe); per-table `th` rules (docs/git-sources/history/tokens) now carry text styling only +- `#git-sources-table-wrap` harmonized: its duplicate background/border/radius/shadow/overflow declarations removed — the wrapper inherits the base `.table-wrap` (no divergent unbounded rule); JS only toggles `hidden`, unaffected +- `.md-table-wrap` byte-unchanged (scope guard); phase-156 comments added in the file's voice +- Fixed 2 stale pins superseded by the design: `test_table_wrap_stacking.py` (overflow-x → overflow+max-height; id-rule-exists → id-rule-absent) and `test_history_page.py` (thead background now pinned on the one rule) +- `uv run pytest --cov=app`: 3565 passed, 99% app coverage (gate >90%); `uv run ruff check .` clean; `uv run pyright`: 0 errors +- Docs: AGENTS.md rule 5 now names the bounded scroll panels + sticky header (phase 156); README has no table-layout description → no README impact +- Note: `TODO.md` has a pre-existing working-tree change (owner edit, not from this task) — left untouched; CSS brace balance verified +- Next pending task: `.agents/phases/todo/156_table_ux_polish/02_ellipsis_hints.md` diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__01_sticky_header.a1.validate b/.agents/reports/156_table_ux_polish/156_table_ux_polish__01_sticky_header.a1.validate new file mode 100644 index 0000000..e936baf --- /dev/null +++ b/.agents/reports/156_table_ux_polish/156_table_ux_polish__01_sticky_header.a1.validate @@ -0,0 +1,121 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +..................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5719 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__02_ellipsis_hints.a1.err b/.agents/reports/156_table_ux_polish/156_table_ux_polish__02_ellipsis_hints.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__02_ellipsis_hints.a1.md b/.agents/reports/156_table_ux_polish/156_table_ux_polish__02_ellipsis_hints.a1.md new file mode 100644 index 0000000..155db1b --- /dev/null +++ b/.agents/reports/156_table_ux_polish/156_table_ux_polish__02_ellipsis_hints.a1.md @@ -0,0 +1,13 @@ +Task complete. All gates green. + +**Report — phase 156, task 02 (ellipsis hints + title/aria audit)** + +- **Implemented:** `.cell-clip` rule in `styles.css` (block + `min-width: 0` + `max-width: 12rem` + `overflow: hidden` + `text-overflow: ellipsis` + `white-space: nowrap`) — generous max-width so FITS cells never clip, per A3; supporting width rules (`max-width: 100%` on the six hosting buttons/label/wrappers, `min-width: 0` on the four flex-item buttons) so the clamp engages in the clipped case — without them the inline-flex buttons would overflow the cell and hard-cut again +- Wrapped all five label families in `.cell-clip` spans (createElement + textContent house rule): Sources `Hidden`/`Ignore paths`/`Remove` (git-sources.js), RAG `Edit` (sources.js makeDescCell), History `Copy`/`Unshare`/`Create link`/`Delete` (history.js); comments cite the phase +- **Aria audit result:** all five families already carry accessible names naming the row identity (phase-89/105/66/97 idioms) — nothing was missing; pinned the previously un-pinned Copy/Unshare/Remove labels +- **Unit pins** extended in 5 existing modules (kb_tree_ui, hidden_folders_toggle, source_ignore_paths, remove_confirm_modal, history_page) +- **Verified in live browser** (dev server + Chromium): computed style `text-overflow: ellipsis` on all families, full text when cells fit, ellipsis engages when a squeezed cell constrains the wrapper, zero page errors +- **Gates:** `uv run pytest` → **3565 passed, 0 failed** · `--cov=app` → **99%** (>90%) · `ruff check .` clean · `pyright` 0 errors/0 warnings +- **Docs:** AGENTS.md rule 5 extended (phase-156 note now names the `.cell-clip` ellipsis contract); README untouched (no table-layout section) +- **Notable decision:** the audit's proposed aria-label wordings (`Copy the share link for…` etc.) were not needed — the existing phase-66 labels (`Copy share link: …`, `Unshare saved chat: …`, `Create share link: …`) already satisfy "name the row's identity", so no churning of pinned text +- **Next pending task:** `03_unit_pins.md` diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__02_ellipsis_hints.a1.validate b/.agents/reports/156_table_ux_polish/156_table_ux_polish__02_ellipsis_hints.a1.validate new file mode 100644 index 0000000..e936baf --- /dev/null +++ b/.agents/reports/156_table_ux_polish/156_table_ux_polish__02_ellipsis_hints.a1.validate @@ -0,0 +1,121 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +..................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5719 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__03_unit_pins.a1.err b/.agents/reports/156_table_ux_polish/156_table_ux_polish__03_unit_pins.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__03_unit_pins.a1.md b/.agents/reports/156_table_ux_polish/156_table_ux_polish__03_unit_pins.a1.md new file mode 100644 index 0000000..2b4a68c --- /dev/null +++ b/.agents/reports/156_table_ux_polish/156_table_ux_polish__03_unit_pins.a1.md @@ -0,0 +1,18 @@ +All gates green. Final report: + +**Implemented (task 03 — CSS contract pins, extending `tests/unit/test_theming.py`, the `:root`-drift stylesheet-parsing module):** +- `_css_rule()` helper (comment-stripped selector→declarations parse; bare `.table-wrap` never matches the th rule) +- `.table-wrap`: exact `max-height: min(70vh, 64rem)` + `overflow: auto` pin, plus `overflow-x`-absent (pre-phase value superseded) +- `.table-wrap table thead th`: `position: sticky` + `top: 0` + `z-index: 1` + OPAQUE-background pin (non-`transparent`; `var(--x)` must resolve to a solid alpha-less `:root` hex, literal must carry no alpha channel — opacity pinned, exact token left to the phase) +- `#git-sources-table-wrap`: no divergent unbounded override (rule absent = harmony; if present, must carry the same `max-height`/`overflow: auto`) +- `.md-table-wrap`: verbatim pre-phase rule pinned (`.md-table-wrap { overflow-x: auto; }` — verified byte-identical against git HEAD) +- `.cell-clip`: `text-overflow: ellipsis` + `overflow: hidden` + `white-space: nowrap` +- `:root`-drift test unchanged (the 17-variable set pin stands, all pre-existing pins green; module docstring updated to name the new section) + +**Results:** `uv run pytest tests/unit -q` → 2809 tests, exit 0, all green; full suite `uv run pytest --cov=app` → **3570 passed**, `app/` coverage **99%** (>90% gate); `uv run ruff check . && uv run pyright` → clean (0 errors). + +**Docs:** no doc impact (test-only change — no setup/commands/env/API/convention changes). + +**Decisions:** pins added to `test_theming.py` per the task's "locate the stylesheet-parsing test module and extend it" (the only module that parses `styles.css`); opacity pinned as a property, not a token, per spec. + +**Next pending task:** `.agents/phases/todo/156_table_ux_polish/04_e2e_table_polish.md` (the dedicated Playwright suite). diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__03_unit_pins.a1.validate b/.agents/reports/156_table_ux_polish/156_table_ux_polish__03_unit_pins.a1.validate new file mode 100644 index 0000000..ef441e2 --- /dev/null +++ b/.agents/reports/156_table_ux_polish/156_table_ux_polish__03_unit_pins.a1.validate @@ -0,0 +1,121 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +.......................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5719 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__04_e2e_table_polish.a1.err b/.agents/reports/156_table_ux_polish/156_table_ux_polish__04_e2e_table_polish.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__04_e2e_table_polish.a1.md b/.agents/reports/156_table_ux_polish/156_table_ux_polish__04_e2e_table_polish.a1.md new file mode 100644 index 0000000..4b6de89 --- /dev/null +++ b/.agents/reports/156_table_ux_polish/156_table_ux_polish__04_e2e_table_polish.a1.md @@ -0,0 +1,20 @@ +Task 04 complete. Report: + +**Implemented** +- `tests/e2e/test_table_ux_polish.py` — dedicated Playwright suite (6 tests): + - `test_sticky_header_on_vertical_scroll` ×3 (RAG folders / Sources / History, parameterized): 16-row fixture overflows the 560px bounded panel (precondition asserted), `scrollTop > 0`, and the `th`'s boundingBox delta (`thTop − wrapTop − borderTop`) is exactly 0 (sub-pixel), with the "scrolled away" counterfactual guard + - `test_horizontal_scroll_contract_unchanged` (375px): each table's `min-width` (480/560/640) still scrolls inside the card + - `test_ellipsis_on_clipped_label` (the audit's 1,009px case): all five `.cell-clip` families forced-clipped via their host controls; asserts computed `text-overflow: "ellipsis"` + `scrollWidth > clientWidth` + `aria-label` containing the row identity (source URL / chat title / source name) for every clipped span + - `test_mobile_contract` (375×812, phase-86 idiom): real `#nav-toggle` click opens the hamburger, menu nav closes it, and all three tables hscroll + pin the thead inside the bounded panel +- House idioms mirrored: `auth_helpers.login`, copied `_wait_settled_admin`/`_cookies`, idempotent 16-row seed (16 deterministic git sources + 16 deterministic 45-char-title chats) with API cleanup in `finally`; no KB import needed (the tree lists registered sources with 0 docs) + +**Test/lint/coverage** +- `uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov` → 6 passed (3 consecutive isolated runs) +- `uv run pytest --cov=app --cov-report=term-missing` → 3570 passed, TOTAL **99%** (>90%) +- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings + +**Docs**: no impact (suite not user-visible; AGENTS.md's phase-156 check was already updated by tasks 01–02; README describes no page layout) + +**Decisions**: Chromium's auto table layout keeps max-content widths whenever the min-content sum exceeds the panel (measured at 1,009px), so a viewport alone can't deterministically engage the ellipsis clamp — the test forces the clip at the host control (inline width below the label's natural size; same clamp chain a squeezed cell engages; measured 16–53px margins). Documented in the module docstring. + +**Next pending task**: none — task 04 is phase 156's final task (phase complete once the harness's final pass runs). diff --git a/.agents/reports/156_table_ux_polish/156_table_ux_polish__04_e2e_table_polish.a1.validate b/.agents/reports/156_table_ux_polish/156_table_ux_polish__04_e2e_table_polish.a1.validate new file mode 100644 index 0000000..ef441e2 --- /dev/null +++ b/.agents/reports/156_table_ux_polish/156_table_ux_polish__04_e2e_table_polish.a1.validate @@ -0,0 +1,121 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +.......................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5719 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/AGENTS.md b/AGENTS.md index 2a215c1..dd1c1ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,12 @@ it follows the layout principles in `.agents/PLAN.md` §7 (proper container usage, the 72rem centered reading column — every page rides the 72rem container at every viewport; phase 100 supersedes §7.1's 46rem chat - column, full-width tables on Sources — no skinny wasted-space lists) and + column, full-width tables on Sources — no skinny wasted-space lists; + the catalogue tables (RAG, Sources, History) are bounded scroll + panels — `max-height: min(70vh, 64rem)` — with a sticky header row; + a clipped action label shows an ellipsis hint (`.cell-clip`) with + the full text kept reachable via the control's aria-label + (phase 156)) and meets WCAG 2.1 AA basics (semantic landmarks, labels, contrast ≥4.5:1, focus-visible, aria-live for the stream). diff --git a/frontend/assets/git-sources.js b/frontend/assets/git-sources.js index 4795863..817902a 100644 --- a/frontend/assets/git-sources.js +++ b/frontend/assets/git-sources.js @@ -540,7 +540,15 @@ export async function mount(root) { "aria-label", `Index hidden folders for ${kindLabel} source: ${value}`, ); - hiddenLabel.append(hiddenBox, document.createTextNode("Hidden")); + /* Phase 156 (task 02, A3): the visible "Hidden" text is the + .cell-clip span — a squeezed cell shows "…" instead of a + hard cut; the full text stays reachable (the checkbox's + aria-label names the full source — the phase-105 idiom, + the label's own title carries the explanation). */ + const hiddenClip = document.createElement("span"); + hiddenClip.className = "cell-clip"; + hiddenClip.textContent = "Hidden"; // never innerHTML (XSS contract) + hiddenLabel.append(hiddenBox, hiddenClip); hiddenBox.addEventListener("change", () => toggleHidden(s, hiddenBox)); actTd.appendChild(hiddenLabel); /* Phase 89: the per-row ignore-paths editor trigger (A3: stored @@ -556,14 +564,25 @@ export async function mount(root) { "aria-label", `Edit ignored paths for ${kindLabel} source: ${value}`, ); - ignoreBtn.textContent = "Ignore paths"; + /* Phase 156 (task 02, A3): the static text label is the + .cell-clip span — ellipsizes in a squeezed cell (the + aria-label above keeps the full context reachable). */ + const ignoreClip = document.createElement("span"); + ignoreClip.className = "cell-clip"; + ignoreClip.textContent = "Ignore paths"; // never innerHTML (XSS contract) + ignoreBtn.append(ignoreClip); ignoreBtn.addEventListener("click", () => openIgnoreEditor(s, ignoreBtn)); actTd.appendChild(ignoreBtn); const btn = document.createElement("button"); btn.type = "button"; btn.className = "git-source-remove"; btn.setAttribute("aria-label", `Remove ${kindLabel} source: ${value}`); - btn.innerHTML = REMOVE_ICON + "Remove"; + /* Phase 156 (task 02, A3): the visible text is the .cell-clip + span (a static template — the icon is static SVG, the + established exception to the textContent house rule); a + squeezed cell ellipsizes it (the aria-label above names + the full source). */ + btn.innerHTML = REMOVE_ICON + 'Remove'; // Phase 69: opens the confirmation modal (the native confirm() // retired) — the modal names the source and states the // full-removal policy; the per-row error span is retired (the diff --git a/frontend/assets/history.js b/frontend/assets/history.js index 555fe0e..353ca7c 100644 --- a/frontend/assets/history.js +++ b/frontend/assets/history.js @@ -35,6 +35,11 @@ * "Delete? [Yes] [No]" (focus moves to Yes, so the confirm is * keyboard-reachable), Yes fires the DELETE and removes the row, * No (or a failed request) keeps it. + * Phase 156 (task 02, A3): the Create link / Copy / Unshare / + * Delete visible texts are .cell-clip spans — a squeezed + * Actions/Share cell shows "…" instead of a hard cut; every one + * of these buttons' aria-label names the chat title (the + * phase-66 idiom), so the full text stays reachable. * * The share copy has the owner-locked inline-link fallback: a * non-secure (http) homelab origin rejects navigator.clipboard, so the @@ -214,7 +219,14 @@ export async function mount(root) { del.type = "button"; del.className = "history-delete"; del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`); - del.textContent = "Delete"; + /* Phase 156 (task 02, A3): the visible text is the .cell-clip + span — a squeezed Actions cell shows "…" instead of a hard + cut; the aria-label above names the chat title (the + phase-66 idiom), so the full text stays reachable. */ + const delClip = document.createElement("span"); + delClip.className = "cell-clip"; + delClip.textContent = "Delete"; // textContent only (XSS contract) + del.append(delClip); function restoreDelete() { cell.replaceChildren(del); @@ -326,7 +338,13 @@ export async function mount(root) { create.type = "button"; create.className = "history-share-create"; create.setAttribute("aria-label", `Create share link: ${chat.title}`); - create.textContent = "Create link"; + /* Phase 156 (task 02, A3): the visible text is the .cell-clip + span (the aria-label above names the chat title — the + phase-66 idiom, the full text stays reachable). */ + const createClip = document.createElement("span"); + createClip.className = "cell-clip"; + createClip.textContent = "Create link"; // textContent only (XSS contract) + create.append(createClip); create.addEventListener("click", () => void createShareLink(chat, cell, create)); cell.replaceChildren(create); } @@ -340,13 +358,22 @@ export async function mount(root) { copy.type = "button"; copy.className = "history-share-copy"; copy.setAttribute("aria-label", `Copy share link: ${chat.title}`); - copy.textContent = "Copy"; + /* Phase 156 (task 02, A3): the visible texts are .cell-clip + spans (the aria-labels above name the chat title — the + phase-66 idiom, the full text stays reachable). */ + const copyClip = document.createElement("span"); + copyClip.className = "cell-clip"; + copyClip.textContent = "Copy"; // textContent only (XSS contract) + copy.append(copyClip); copy.addEventListener("click", () => void copyRowShareLink(chat, cell)); const unshare = document.createElement("button"); unshare.type = "button"; unshare.className = "history-unshare"; unshare.setAttribute("aria-label", `Unshare saved chat: ${chat.title}`); - unshare.textContent = "Unshare"; + const unshareClip = document.createElement("span"); + unshareClip.className = "cell-clip"; + unshareClip.textContent = "Unshare"; // textContent only (XSS contract) + unshare.append(unshareClip); function restoreShared() { cell.replaceChildren(copy, unshare); unshare.focus({ preventScroll: true }); // focus returns to the (restored) control diff --git a/frontend/assets/sources.js b/frontend/assets/sources.js index 1824dee..853f966 100644 --- a/frontend/assets/sources.js +++ b/frontend/assets/sources.js @@ -627,7 +627,11 @@ function wireDescriptionEdit({ * hatch, set for real description text whenever non-empty — the marker * keeps its OWN D4 title, the empty cell carries none). The editor's * open/close swaps happen INSIDE the wrapper (the container is the - * wrapper — the
is plain
(no chip background — the cell IS the mono readout). Phase 38:
@@ -3448,6 +3509,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
align-items: center;
justify-content: center;
gap: 0.35rem;
+ /* Phase 156 (task 02, A3): the width support for the "Remove"
+ label span's clamp (same rationale as .git-source-hidden
+ above). */
+ max-width: 100%;
min-height: 44px;
min-width: 44px;
flex: 0 0 auto;
@@ -3476,6 +3541,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
display: inline-flex;
align-items: center;
justify-content: center;
+ /* Phase 156 (task 02, A3): the width support for the "Ignore
+ paths" label span's clamp (same rationale as .git-source-hidden
+ above). */
+ max-width: 100%;
min-height: 44px;
min-width: 44px;
flex: 0 0 auto;
@@ -3525,6 +3594,11 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
display: inline-flex;
align-items: center;
gap: 0.4rem;
+ /* Phase 156 (task 02, A3): the width support for the "Hidden"
+ label span's clamp (the label is an inline-flex child of the
+ cell — clamped to the cell so the span can ellipsize in a
+ squeezed column instead of overflowing the card edge). */
+ max-width: 100%;
height: 44px;
padding: 0 0.5rem;
font-size: 0.8rem;
@@ -3678,7 +3752,6 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
vertical-align: middle;
}
.history-table th {
- background: var(--brand-soft);
color: var(--brand-ink);
font-size: 0.82rem;
text-transform: uppercase;
@@ -3730,9 +3803,17 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
}
/* Actions: the Delete ghost button (the tuning row-action language)
+ the inline two-step confirm pair (phase 50 task 04). */
-.history-actions { display: inline-flex; align-items: center; gap: 0.4rem; }
+/* Phase 156 (task 02, A3): the width support for the action
+ labels' clamp — both wrappers are clamped to the cell (max-width
+ 100% of the td's content box), and the flex-item buttons are
+ shrinkable (min-width: 0), so a squeezed Actions/Share column
+ ellipsizes the label instead of the card edge hard-cutting it.
+ Mobile (≤640px) is unaffected in kind: the flex-wrap there wraps
+ the buttons onto their own lines before the clamp engages. */
+.history-actions { display: inline-flex; align-items: center; gap: 0.4rem; max-width: 100%; }
.history-delete {
min-height: 44px;
+ min-width: 0;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
@@ -3785,12 +3866,13 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
row-action language of .history-delete: --line border, transparent
fill, ink-soft (>=5.1:1), ≥44px comfortable target, focus-visible
via the global 3px rule; hover takes the brand pair (6.9:1). */
-.history-share { display: inline-flex; align-items: center; gap: 0.4rem; flex-wrap: nowrap; }
+.history-share { display: inline-flex; align-items: center; gap: 0.4rem; flex-wrap: nowrap; max-width: 100%; }
.history-share-cell { white-space: nowrap; }
.history-share-create,
.history-share-copy,
.history-unshare {
min-height: 44px;
+ min-width: 0; /* phase 156 (task 02, A3) — the shrinkable flex item (the clamp support above) */
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
@@ -3984,7 +4066,6 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
vertical-align: middle;
}
.tokens-table th {
- background: var(--brand-soft);
color: var(--brand-ink);
font-size: 0.82rem;
text-transform: uppercase;
diff --git a/tests/e2e/test_table_ux_polish.py b/tests/e2e/test_table_ux_polish.py
new file mode 100644
index 0000000..472fab4
--- /dev/null
+++ b/tests/e2e/test_table_ux_polish.py
@@ -0,0 +1,621 @@
+"""Phase 156 E2E (Playwright): the catalogue tables' sticky headers +
+the ellipsis hint on clipped labels — the owner's UX audit, pinned.
+
+Source: the owner UX audit 2026-10-03 (live interactive-browser pass):
+"sticky table headers (RAG/Sources/History — the tables are long; the
+header scrolls away)" + "At ~1,009px window width the rightmost table
+columns start clipped with no ellipsis hint on the 'Hidden'/'Edit'
+labels (the wrapper scrolls fine — just subtle)".
+Story: n/a (audit-derived phase; the table machinery is phase 97 / 86 /
+63, all complete).
+
+Run in isolation (mock LLM; DB up: ``podman compose up -d db``):
+
+ uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov
+
+The phase design (phase 156, owner-locked A1/A3): the three catalogue
+tables (the RAG folder table ``#folders-table`` in ``#folders-wrap``,
+the Sources table ``#git-sources-table`` in ``#git-sources-table-wrap``,
+the History table in ``#history-table-wrap``) all ride the ONE
+``.table-wrap`` card, which is now the bounded vertical scroll region
+(``max-height: min(70vh, 64rem)`` + ``overflow: auto``) with the
+OPAQUE sticky ``th`` (``position: sticky; top: 0; z-index: 1``). A
+clipped action label (the five ``.cell-clip`` families) shows ``…``
+instead of a hard cut; the full text stays reachable through the
+control's ``aria-label``/``title`` naming the row's identity (the
+source URL / the chat title / the source name — the phase-89/105,
+66, and 97 idioms).
+
+Test → contract mapping (Playwright Mapping Rule):
+
+1. ``test_sticky_header_on_vertical_scroll`` (×3 — RAG folders /
+ Sources / History, parameterized) — THE audit's sticky pin: at
+ 1280×800 (the panel's ``max-height`` resolves to 560px) a 16-row
+ fixture overflows the panel (precondition asserted: the table's
+ ``scrollHeight`` > the panel's ``clientHeight``); after
+ ``scrollTop = 400`` (clamped to the max), ``scrollTop > 0`` AND the
+ ``th``'s top edge is EXACTLY at the panel's content top — the
+ ``boundingBox`` delta (``thTop − wrapTop − borderTop``) is 0 within
+ sub-pixel precision (the standard sticky assertion; WITHOUT the
+ phase-156 rule the ``th`` would sit ~400px above the panel top —
+ the counterfactual guard asserts the ``th`` never goes above the
+ panel top).
+2. ``test_horizontal_scroll_contract_unchanged`` — the phase-97/86
+ contract the ``overflow: auto`` change must keep: at 375×812 each
+ table's ``min-width`` (480 / 560 / 640) still triggers the in-card
+ horizontal scroll (``wrap.scrollWidth > wrap.clientWidth`` with the
+ table wider than the 346px card).
+3. ``test_ellipsis_on_clipped_label`` — the audit's ~1,009px case: at
+ the 1,009px viewport the clip is FORCED on the row's host controls
+ (the inline width below the label's natural size — see the "Why
+ the clip is forced at the control" note below), and for EVERY
+ clipped ``.cell-clip`` span: the COMPUTED ``text-overflow`` is
+ ``"ellipsis"``, ``scrollWidth > clientWidth`` (the label is
+ actually clipped, not merely styled), and the control's
+ ``aria-label`` names the ROW's identity (the source URL / the chat
+ title / the source name — the audit's additions). At least one
+ span per table must clip (the determinism guard).
+4. ``test_mobile_contract`` — the PLAN §7.1 mobile ≤640px regression
+ guard (the phase-86 suite's idiom): at 375×812 a REAL
+ ``#nav-toggle`` click opens the hamburger
+ (``aria-expanded="true"``), a menu row navigates (the menu closes
+ on navigation — the phase-85/88 contract), and in EVERY catalogue
+ view the table still scrolls horizontally inside the bounded panel
+ (test 2's contract) while the ``thead`` still pins (test 1's
+ contract — the ``max-height`` must not break the mobile scroll
+ model).
+
+Why the clip is forced at the control (test 3): Chromium's auto table
+layout renders the table at its MAX-content width whenever the
+min-content sum exceeds the panel (measured: at the 1,009px 967px
+card, with this suite's 45-char source URLs, 45-char chat titles, and
+8-char folder names, the tables keep 950–968px of columns and
+hard-cut nothing), so a viewport alone cannot deterministically
+engage the ``.cell-clip`` clamp (the engagement width is
+font-metric dependent). The test instead narrows the row's host
+controls to below the label's natural size (40px ``.git-source-hidden``
+label, 60px ``.git-source-ignore`` / ``.git-source-remove``, 30px
+``.kb-summary-edit``, 60px ``.history-share-create`` /
+``.history-unshare``, 50px ``.history-delete``): the clamp chain a
+squeezed cell engages is the EXACT same one (the control's
+``max-width: 100%`` + ``min-width: 0`` — the phase-156 task-02
+support rules — plus the span's ``min-width: 0`` + ``overflow:
+hidden`` + ``text-overflow: ellipsis`` + ``white-space: nowrap``), so
+the assertion is deterministic and metric-independent (measured clip
+margins 16–53px under the E2E Chromium).
+
+The five ``.cell-clip`` families under test (phase 156 task 02): the
+Sources ``Hidden`` checkbox label + ``Ignore paths`` + ``Remove``, the
+RAG folder table's ``Edit``, the History ``Create link`` (the
+``Copy``/``Unshare`` shared-state variants share the class family and
+aria idiom) + ``Delete``.
+
+House conventions: a fresh page per test via the session ``browser``
+fixture (the conftest ``page`` is 1280×800), ``e2e.auth_helpers.login``
+for the real form login, ``_wait_settled_admin`` copied from
+``test_mobile_hamburger_nav.py`` (suites stay self-contained —
+helpers copied, only ``auth_helpers`` imported), ``_cookies`` copied
+from ``test_api_tokens.py``.
+Data: each test seeds a FIXED 16-row fixture (16 git sources with
+deterministic ``e2e.example.com`` URLs — the folder table lists
+registered sources even with 0 documents, the tree endpoint's
+"always present" contract — plus 16 saved chats with deterministic
+45-char titles) and deletes it in ``finally`` (the idempotent
+seed/delete keeps re-runs green; no KB documents are imported, so no
+house KB reset is needed). The 45-char chat title and the source's
+deterministic URL are what the aria assertions read back.
+"""
+from __future__ import annotations
+
+import httpx
+import pytest
+from playwright.sync_api import Browser, Page, ViewportSize, expect
+
+from e2e.auth_helpers import login
+
+DESKTOP: ViewportSize = {"width": 1280, "height": 800} # the conftest page size
+AUDIT: ViewportSize = {"width": 1009, "height": 800} # the audit's ~1,009px case
+MOBILE: ViewportSize = {"width": 375, "height": 812} # the phase-86 phone viewport
+
+#: The fixed fixture size — 16 rows overflow the 560px desktop panel
+#: (measured table heights 835–1178px) and the 568px mobile panel.
+N_ROWS = 16
+
+SOURCE_PREFIX = "https://e2e.example.com/table-polish/"
+CHAT_PREFIX = "e2e polish chat "
+
+#: The three catalogue tables: view path, the ``.table-wrap`` card,
+#: the row signal (admin settle + render), the table ``min-width``,
+#: and the mobile nav link (PLAN §7.1).
+TABLES = {
+ "rag": {
+ "path": "/sources.html",
+ "wrap": "#folders-wrap",
+ "row": "#folders-tbody tr",
+ "min_width": 480, # .kb-folders-table (the phase-97 override of the 640px .docs-table base)
+ "nav": "#nav-sources",
+ },
+ "sources": {
+ "path": "/git-sources.html",
+ "wrap": "#git-sources-table-wrap",
+ "row": "#git-sources-tbody tr",
+ "min_width": 560,
+ "nav": "#nav-git-sources",
+ },
+ "history": {
+ "path": "/history.html",
+ "wrap": "#history-table-wrap",
+ "row": "#history-table-wrap .history-title-link",
+ "min_width": 640,
+ "nav": "#nav-history",
+ },
+}
+
+
+def _chat_title(i: int) -> str:
+ """The deterministic 45-char chat title (16 prefix + 2 index + 27 pad)."""
+ return (CHAT_PREFIX + f"{i:02d}" + "y" * 27)[:45]
+
+
+def _source_url(i: int) -> str:
+ return f"{SOURCE_PREFIX}{i:02d}-repo.git"
+
+
+def _cookies(page: Page) -> dict[str, str]:
+ """The session cookies the browser context holds (copied from
+ ``test_api_tokens.py``)."""
+ return {
+ c["name"]: c["value"]
+ for c in page.context.cookies()
+ if "name" in c and "value" in c
+ }
+
+
+def _wait_settled_admin(page: Page) -> None:
+ """Wait until whoami has resolved for the admin: the whoami reveal
+ has un-hidden the admin-only nav links (copied from
+ ``test_mobile_hamburger_nav.py``)."""
+ page.wait_for_function(
+ "() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
+ timeout=10_000,
+ )
+
+
+def _seed(page: Page, app_url: str) -> dict[str, dict[str, str]]:
+ """The fixed 16-row fixture (idempotent — a half-finished previous
+ run's rows are reused, not duplicated): 16 git sources (the
+ Sources table rows + the RAG folder-table rows — the tree lists
+ registered sources even with 0 documents) + 16 saved chats (the
+ History rows)."""
+ ck = _cookies(page)
+ sources: dict[str, str] = {
+ s["url"]: s["id"]
+ for s in httpx.get(f"{app_url}/api/git-sources", cookies=ck, timeout=10).json()["sources"]
+ if s["url"].startswith(SOURCE_PREFIX)
+ }
+ for i in range(1, N_ROWS + 1):
+ url = _source_url(i)
+ if url not in sources:
+ r = httpx.post(
+ f"{app_url}/api/git-sources",
+ json={"kind": "git", "url": url},
+ cookies=ck, timeout=10,
+ )
+ assert r.status_code == 201, r.text
+ sources[url] = r.json()["id"]
+ chats: dict[str, str] = {
+ c["title"]: c["id"]
+ for c in httpx.get(f"{app_url}/api/chats", cookies=ck, timeout=10).json()["chats"]
+ if c["title"].startswith(CHAT_PREFIX)
+ }
+ for i in range(1, N_ROWS + 1):
+ title = _chat_title(i)
+ if title not in chats:
+ r = httpx.post(
+ f"{app_url}/api/chats",
+ json={
+ "title": title,
+ "messages": [
+ {"who": "user", "text": f"question {i}"},
+ {"who": "brain", "text": f"answer {i}"},
+ ],
+ },
+ cookies=ck, timeout=10,
+ )
+ assert r.status_code == 201, r.text
+ chats[title] = r.json()["id"]
+ return {"sources": sources, "chats": chats}
+
+
+def _cleanup(page: Page, app_url: str, data: dict[str, dict[str, str]] | None) -> None:
+ """Delete the fixture rows (a row another cleanup pass already
+ removed is a 404, not a failure)."""
+ if data is None:
+ return
+ ck = _cookies(page)
+ for sid in data["sources"].values():
+ r = httpx.delete(f"{app_url}/api/git-sources/{sid}", cookies=ck, timeout=30)
+ assert r.status_code in (204, 404), r.text
+ for cid in data["chats"].values():
+ r = httpx.delete(f"{app_url}/api/chats/{cid}", cookies=ck, timeout=10)
+ assert r.status_code in (204, 404), r.text
+
+
+def _assert_sticky(page: Page, wrap_sel: str, label: str) -> None:
+ """THE sticky pin: the panel is overflowed (precondition), the
+ scroll moved (``scrollTop > 0``), and the ``th``'s top edge is
+ EXACTLY at the panel's content top — the boundingBox delta
+ (``thTop − wrapTop − borderTop``) is 0 within sub-pixel
+ precision. The counterfactual guard: without the sticky rule the
+ ``th`` would sit the whole ``scrollTop`` above the panel top — a
+ delta below −0.5 IS "scrolled away"."""
+ rep = page.evaluate(
+ """(ws) => {
+ const w = document.querySelector(ws);
+ const t = w.querySelector('table');
+ const th = w.querySelector('table thead th');
+ w.scrollTop = 400;
+ const thTop = th.getBoundingClientRect().top;
+ const wTop = w.getBoundingClientRect().top;
+ const border = parseFloat(getComputedStyle(w).borderTopWidth);
+ return {top: w.scrollTop, clientH: w.clientHeight,
+ tableH: t.scrollHeight, delta: thTop - wTop - border};
+ }""",
+ wrap_sel,
+ )
+ assert rep["tableH"] > rep["clientH"], (
+ f"{label}: the 16-row fixture must overflow the bounded panel "
+ f"(table {rep['tableH']}px in a {rep['clientH']}px panel) — "
+ "the scroll precondition does not hold"
+ )
+ assert rep["top"] > 0, (
+ f"{label}: the panel's vertical scroll did not move "
+ f"(scrollTop {rep['top']}) — the bounded-panel contract is not in effect"
+ )
+ assert abs(rep["delta"]) < 0.5, (
+ f"{label}: the sticky header must sit EXACTLY at the panel's content top "
+ f"(th top − panel content top = {rep['delta']}px while scrollTop is "
+ f"{rep['top']}px) — phase 156 task 01's sticky rule; a large negative "
+ "delta means the header scrolled away with the page (the audit's defect)"
+ )
+
+
+def _assert_hscroll(page: Page, wrap_sel: str, min_width: int, label: str) -> None:
+ """The in-card horizontal contract: the table keeps its ``min-width``
+ and scrolls INSIDE the (bounded) card (``overflow: auto``)."""
+ rep = page.evaluate(
+ """(ws) => {
+ const w = document.querySelector(ws);
+ return {scroll: w.scrollWidth, client: w.clientWidth,
+ table: w.querySelector('table').scrollWidth};
+ }""",
+ wrap_sel,
+ )
+ assert rep["table"] >= min_width, (
+ f"{label}: the table must keep its {min_width}px min-width, "
+ f"got {rep['table']}px"
+ )
+ assert rep["scroll"] > rep["client"], (
+ f"{label}: the {min_width}px table must scroll INSIDE the card "
+ f"(card {rep['client']}px < table {rep['table']}px)"
+ )
+
+
+@pytest.mark.parametrize("view", ["rag", "sources", "history"])
+def test_sticky_header_on_vertical_scroll(
+ view: str, browser: Browser, app_url: str, db_ready: None
+) -> None:
+ """THE audit's sticky pin, per catalogue table (1280×800 — the
+ panel's ``max-height: min(70vh, 64rem)`` resolves to 560px): the
+ 16-row fixture overflows the panel (asserted), the scroll moves
+ (``scrollTop > 0`` after a 400px request — clamped to the max),
+ and the ``thead th``'s top edge is EXACTLY at the panel's content
+ top (the boundingBox delta is 0 within sub-pixel precision —
+ WITHOUT phase 156's rule it would be ~−400px: the header scrolls
+ away with the page, the audit's defect)."""
+ spec = TABLES[view]
+ page = browser.new_page(viewport=DESKTOP)
+ data: dict[str, dict[str, str]] | None = None
+ try:
+ login(page, app_url, next="/")
+ _wait_settled_admin(page)
+ # Seed BEFORE the view loads: the views fetch once on load and
+ # do not refetch — arriving before the fixture would render
+ # the pre-seed state (the rows the pins need never appear).
+ data = _seed(page, app_url)
+ page.goto(app_url + spec["path"])
+ _wait_settled_admin(page)
+ page.wait_for_selector(spec["row"], timeout=15_000)
+ _assert_sticky(page, spec["wrap"], f"{spec['path']} (desktop)")
+ finally:
+ if data is not None:
+ _cleanup(page, app_url, data)
+ page.close()
+
+
+def test_horizontal_scroll_contract_unchanged(
+ browser: Browser, app_url: str, db_ready: None
+) -> None:
+ """The phase-97/86 contract (the ``min-width`` → in-card scroll)
+ survives phase 156's ``overflow: auto``: at 375×812 (the 346px
+ card) every catalogue table's ``min-width`` (480 / 560 / 640) is
+ wider than the card and scrolls INSIDE it (``wrap.scrollWidth >
+ wrap.clientWidth``) — the ``overflow: auto`` change (superseding
+ ``overflow-x: auto``) must keep the horizontal axis intact."""
+ page = browser.new_page(viewport=MOBILE)
+ data: dict[str, dict[str, str]] | None = None
+ try:
+ login(page, app_url, next="/")
+ _wait_settled_admin(page)
+ data = _seed(page, app_url)
+
+ page.goto(app_url + TABLES["history"]["path"])
+ _wait_settled_admin(page)
+ page.wait_for_selector(TABLES["history"]["row"], timeout=15_000)
+ _assert_hscroll(page, TABLES["history"]["wrap"], 640, "History (375px)")
+
+ page.goto(app_url + TABLES["sources"]["path"])
+ _wait_settled_admin(page)
+ page.wait_for_selector(TABLES["sources"]["row"], timeout=15_000)
+ _assert_hscroll(page, TABLES["sources"]["wrap"], 560, "Sources (375px)")
+
+ page.goto(app_url + TABLES["rag"]["path"])
+ _wait_settled_admin(page)
+ page.wait_for_selector(TABLES["rag"]["row"], timeout=15_000)
+ _assert_hscroll(page, TABLES["rag"]["wrap"], 480, "RAG (375px)")
+ finally:
+ if data is not None:
+ _cleanup(page, app_url, data)
+ page.close()
+
+
+# ---------------------------------------------------------------------------
+# 3. The ellipsis hint: computed style + the actual clip + the full
+# text reachable (the audit's ~1,009px case)
+# ---------------------------------------------------------------------------
+
+#: Force the clip: every row's host controls narrowed to below the
+#: label's natural size (the measured E2E-Chromium naturals: Hidden
+#: 44 / Ignore paths 82 / Remove 51+icon / Edit 24+padding / Create
+#: link 69 / Delete 42 — the forced widths leave 16–53px of clip
+#: margin, so the clip is deterministic; the exact chain a squeezed
+#: cell engages — the module docstring's "Why the clip is forced at
+#: the control" note).
+FORCE_CLIP_JS = {
+ "sources": """
+ () => {
+ for (const row of document.querySelectorAll('#git-sources-tbody tr')) {
+ const h = row.querySelector('.git-source-hidden');
+ const i = row.querySelector('.git-source-ignore');
+ const r = row.querySelector('.git-source-remove');
+ if (h) h.style.width = '40px';
+ if (i) i.style.width = '60px';
+ if (r) r.style.width = '60px';
+ }
+ }
+ """,
+ "rag": """
+ () => {
+ for (const row of document.querySelectorAll('#folders-tbody tr')) {
+ const b = row.querySelector('.kb-summary-edit');
+ if (b) b.style.width = '30px';
+ }
+ }
+ """,
+ "history": """
+ () => {
+ for (const row of document.querySelectorAll('#history-table-wrap tbody tr')) {
+ const c = row.querySelector('.history-share-create');
+ const u = row.querySelector('.history-unshare');
+ const d = row.querySelector('.history-delete');
+ if (c) c.style.width = '60px';
+ if (u) u.style.width = '60px';
+ if (d) d.style.width = '50px';
+ }
+ }
+ """,
+}
+
+#: The per-table span audits — (the clipped span, the row-identity
+#: element whose text the aria-label must contain, the control
+#: carrying the aria-label). The sources' "Hidden" label's identity
+#: rides the CHECKBOX's aria-label (the phase-105 idiom — the label
+#: itself carries the explanatory ``title``, the phase-156 audit
+#: note); the RAG's identity is the row's folder link (the phase-97
+#: idiom); the History's identity is the chat title (the phase-66
+#: idiom).
+SPAN_AUDITS: dict[str, list[tuple[str, str, str]]] = {
+ "sources": [
+ (
+ ".git-source-hidden .cell-clip",
+ ".git-source-url-cell code",
+ ".git-source-hidden input",
+ ),
+ (
+ ".git-source-ignore .cell-clip",
+ ".git-source-url-cell code",
+ ".git-source-ignore",
+ ),
+ (
+ ".git-source-remove .cell-clip",
+ ".git-source-url-cell code",
+ ".git-source-remove",
+ ),
+ ],
+ "rag": [
+ (
+ ".kb-summary-edit .cell-clip",
+ "td .folder-link",
+ ".kb-summary-edit",
+ ),
+ ],
+ "history": [
+ (
+ ".history-share-create .cell-clip",
+ ".history-title-link",
+ ".history-share-create",
+ ),
+ (
+ ".history-delete .cell-clip",
+ ".history-title-link",
+ ".history-delete",
+ ),
+ ],
+}
+
+
+def test_ellipsis_on_clipped_label(
+ browser: Browser, app_url: str, db_ready: None
+) -> None:
+ """The audit's ~1,009px case, made deterministic (the module
+ docstring's "Why the clip is forced at the control"): at
+ 1,009×800 the row's host controls are narrowed below the label's
+ natural size, and for EVERY clipped ``.cell-clip`` span: (a) the
+ COMPUTED ``text-overflow`` is ``"ellipsis"`` (the deterministic
+ style assertion — never a pixel probe), (b) ``scrollWidth >
+ clientWidth`` (the label is actually clipped), and (c) the
+ control's ``aria-label`` contains the ROW's identity (the source
+ URL / the chat title / the source name — the audit's additions).
+ At least one span per table must clip (the guard that the
+ fixture engages the mechanism). The five ``.cell-clip`` families
+ are all covered (the module docstring's family list)."""
+ page = browser.new_page(viewport=AUDIT)
+ data: dict[str, dict[str, str]] | None = None
+ try:
+ login(page, app_url, next="/git-sources.html")
+ _wait_settled_admin(page)
+ data = _seed(page, app_url)
+ page.goto(app_url + "/git-sources.html")
+ _wait_settled_admin(page)
+ page.wait_for_selector("#git-sources-tbody tr", timeout=15_000)
+
+ for view in ("sources", "rag", "history"):
+ spec = TABLES[view]
+ page.goto(app_url + spec["path"])
+ _wait_settled_admin(page)
+ page.wait_for_selector(spec["row"], timeout=15_000)
+ page.evaluate(FORCE_CLIP_JS[view])
+ rep = page.evaluate(
+ """(audits) => {
+ const rowsSel = audits.rowsSel;
+ const out = [];
+ for (const row of document.querySelectorAll(rowsSel)) {
+ for (const a of audits.items) {
+ const span = row.querySelector(a.spanSel);
+ if (!span) continue;
+ const ident = row.querySelector(a.identitySel);
+ const ariaEl = row.querySelector(a.ariaSel);
+ out.push({
+ span: span.textContent,
+ clip: span.scrollWidth > span.clientWidth,
+ sw: span.scrollWidth, cw: span.clientWidth,
+ to: getComputedStyle(span).textOverflow,
+ identity: ident ? ident.textContent : '',
+ aria: ariaEl ? (ariaEl.getAttribute('aria-label') || '') : '',
+ });
+ }
+ }
+ return out;
+ }""",
+ {
+ "rowsSel": (
+ "#history-table-wrap tbody tr:not([hidden])"
+ if view == "history"
+ else TABLES[view]["row"]
+ ),
+ "items": [
+ {"spanSel": s, "identitySel": i, "ariaSel": a}
+ for (s, i, a) in SPAN_AUDITS[view]
+ ],
+ },
+ )
+ clips = [r for r in rep if r["clip"]]
+ assert clips, (
+ f"{spec['path']}: the forced-narrow fixture must clip at "
+ f"least one {view} label (the determinism guard) — "
+ f"got {[(r['span'], r['sw'], r['cw']) for r in rep]}"
+ )
+ for r in rep:
+ if not r["clip"]:
+ continue
+ assert r["to"] == "ellipsis", (
+ f"{spec['path']}: the clipped '{r['span']}' label must compute "
+ f"text-overflow: ellipsis (got '{r['to']}') — phase 156 task 02"
+ )
+ assert r["identity"], (
+ f"{spec['path']}: the row identity could not be read "
+ f"(the aria assertion is vacuous without it)"
+ )
+ assert r["identity"] in r["aria"], (
+ f"{spec['path']}: the clipped '{r['span']}' label's full text "
+ f"must stay reachable — its control's aria-label must name the "
+ f"row's identity ({r['identity']!r}), got {r['aria']!r}"
+ )
+ finally:
+ if data is not None:
+ _cleanup(page, app_url, data)
+ page.close()
+
+
+# ---------------------------------------------------------------------------
+# 4. The mobile ≤640px contract: the hamburger + the table scroll
+# survive the bounded panel
+# ---------------------------------------------------------------------------
+
+
+def test_mobile_contract(
+ browser: Browser, app_url: str, db_ready: None
+) -> None:
+ """The PLAN §7.1 mobile regression guard (the phase-86 suite's
+ idiom): at 375×812 a REAL ``#nav-toggle`` click opens the
+ hamburger (``aria-expanded="true"``), a menu row navigates (the
+ menu closes on navigation — the phase-85/88 contract), and in
+ EVERY catalogue view the table still scrolls horizontally inside
+ the bounded panel (``min-width`` vs the 346px card) while the
+ ``thead`` still pins — phase 156's ``max-height`` must not break
+ the mobile scroll model."""
+ page = browser.new_page(viewport=MOBILE)
+ data: dict[str, dict[str, str]] | None = None
+ try:
+ login(page, app_url, next="/")
+ _wait_settled_admin(page)
+ data = _seed(page, app_url)
+ page.goto(app_url + TABLES["history"]["path"])
+ _wait_settled_admin(page)
+ page.wait_for_selector(TABLES["history"]["row"], timeout=15_000)
+
+ # The hamburger opens (the ≤640px nav contract)…
+ page.click("#nav-toggle")
+ expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
+ # …and a menu row navigates, closing the menu.
+ page.click(TABLES["sources"]["nav"])
+ expect(page).to_have_url(app_url + TABLES["sources"]["path"], timeout=15_000)
+ expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
+ page.wait_for_selector(TABLES["sources"]["row"], timeout=15_000)
+
+ _assert_hscroll(page, TABLES["sources"]["wrap"], 560, "Sources (mobile)")
+ _assert_sticky(page, TABLES["sources"]["wrap"], "Sources (mobile)")
+
+ page.click("#nav-toggle")
+ expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
+ page.click(TABLES["rag"]["nav"])
+ expect(page).to_have_url(app_url + TABLES["rag"]["path"], timeout=15_000)
+ expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
+ page.wait_for_selector(TABLES["rag"]["row"], timeout=15_000)
+
+ _assert_hscroll(page, TABLES["rag"]["wrap"], 480, "RAG (mobile)")
+ _assert_sticky(page, TABLES["rag"]["wrap"], "RAG (mobile)")
+
+ page.click("#nav-toggle")
+ expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
+ page.click(TABLES["history"]["nav"])
+ expect(page).to_have_url(app_url + TABLES["history"]["path"], timeout=15_000)
+ expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
+ page.wait_for_selector(TABLES["history"]["row"], timeout=15_000)
+
+ _assert_hscroll(page, TABLES["history"]["wrap"], 640, "History (mobile)")
+ _assert_sticky(page, TABLES["history"]["wrap"], "History (mobile)")
+ finally:
+ if data is not None:
+ _cleanup(page, app_url, data)
+ page.close()
diff --git a/tests/unit/test_hidden_folders_toggle.py b/tests/unit/test_hidden_folders_toggle.py
index d5f8642..46fab1f 100644
--- a/tests/unit/test_hidden_folders_toggle.py
+++ b/tests/unit/test_hidden_folders_toggle.py
@@ -173,10 +173,17 @@ def test_row_checkbox_is_built_in_the_stored_row_branch() -> None:
assert 'hiddenBox.setAttribute(' in make, "the label is set via setAttribute"
assert "hiddenBox.innerHTML" not in _js(), "XSS contract: no innerHTML on the box"
assert "hiddenLabel.innerHTML" not in _js(), "XSS contract: no innerHTML on the label"
- assert 'document.createTextNode("Hidden")' in make, (
+ # Phase 156 (task 02, A3): the visible text is the .cell-clip
+ # span — a squeezed cell shows "…" instead of a hard cut; the
+ # checkbox's aria-label names the full source, so the full text
+ # stays reachable.
+ assert "hiddenClip.className = \"cell-clip\"" in make, (
+ "the clip label span (phase 156, A3)"
+ )
+ assert 'hiddenClip.textContent = "Hidden"' in make, (
"the visible text label — never aria-label-only (WCAG)"
)
- assert "hiddenLabel.append(hiddenBox, document.createTextNode(\"Hidden\"))" in make
+ assert "hiddenLabel.append(hiddenBox, hiddenClip)" in make
assert 'hiddenBox.addEventListener("change", () => toggleHidden(s, hiddenBox))' in make
# The env-fallback branch (else) has no checkbox.
else_slice = make[else_i : make.find("tr.appendChild(actTd)", else_i)]
diff --git a/tests/unit/test_history_page.py b/tests/unit/test_history_page.py
index a882d60..7286577 100644
--- a/tests/unit/test_history_page.py
+++ b/tests/unit/test_history_page.py
@@ -33,6 +33,14 @@ documents) + ``header.js``'s reveal-for-admin block;
between Updated and Share in the shell's History view, and the
``.stale-pill`` rose-family CSS in ``styles.css``.
+Phase 156 (task 02): the ellipsis hint on the History action labels —
+the ``Create link`` / ``Copy`` / ``Unshare`` / ``Delete`` visible
+texts are ``.cell-clip`` spans (a squeezed cell shows "…" instead of
+a hard cut) and the audit's accessible-name check lands: every one
+of the four buttons' ``aria-label`` names the chat title (the
+phase-66 idiom — nothing was missing, the pins keep it), so the
+full text stays reachable.
+
The Containerfile stage-1 coverage (the shell copied, the view modules
bundled into the router) is pinned dynamically by
``tests/integration/test_containerfile_assets.py`` — a page or module
@@ -410,6 +418,15 @@ def test_two_step_delete_confirm_pair() -> None:
fn = _fn(js, "makeDeleteControl")
assert 'del.className = "history-delete"' in fn
assert 'del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`)' in fn
+ # Phase 156 (task 02, A3) audit: the Delete button's aria-label
+ # names the row's identity (the chat title — the phase-66 idiom),
+ # and the visible text is the .cell-clip span — a squeezed cell
+ # shows "…" instead of a hard cut (the full text stays reachable).
+ assert 'delClip.className = "cell-clip"' in fn, (
+ "the clip label span (phase 156, A3)"
+ )
+ assert 'delClip.textContent = "Delete"' in fn, "the visible text"
+ assert "del.append(delClip)" in fn, "the span is the button's label"
assert 'label.textContent = "Delete?"' in fn
assert 'yes.className = "history-confirm-yes"' in fn
assert 'no.className = "history-confirm-no"' in fn
@@ -489,7 +506,10 @@ def test_history_table_css_full_width_and_palette() -> None:
title link is the accent link (brand-ink, focus-visible); the
confirm pair is Yes-on-error-rose + No-ghost; the empty-state row
is the muted centered message. Every pair is Phase-08 AA
- (brand-ink/brand-soft 6.9:1, err 9.1:1, ink-soft >=6.9:1)."""
+ (brand-ink/brand-soft 6.9:1, err 9.1:1, ink-soft >=6.9:1).
+ Phase 156 (task 01): the thead's brand-soft background moved to the
+ ONE .table-wrap table thead th sticky rule — .history-table th
+ keeps the text styling only."""
css = _css()
block = re.search(r"\.history-table \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .history-table"
@@ -497,7 +517,14 @@ def test_history_table_css_full_width_and_palette() -> None:
assert "width: 100%" in body, "the table is FULL-WIDTH (AGENTS.md rule 5)"
assert "min-width: 640px" in body
th = re.search(r"\.history-table th \{([\s\S]*?)\n\}", css)
- assert th and "var(--brand-soft)" in th.group(1) and "var(--brand-ink)" in th.group(1)
+ assert th and "var(--brand-ink)" in th.group(1), (
+ "the th keeps its brand-ink text styling"
+ )
+ sticky = re.search(r"\.table-wrap table thead th \{([\s\S]*?)\n\}", css)
+ assert sticky and "var(--brand-soft)" in sticky.group(1), (
+ "the thead's brand-soft background is the ONE .table-wrap "
+ "table thead th rule (phase 156)"
+ )
hover = re.search(r"\.history-table tbody tr:hover \{([^}]*)\}", css)
assert hover, "row hover is part of the table family"
link = re.search(r"\.history-title-link \{([\s\S]*?)\n\}", css)
@@ -639,17 +666,39 @@ def test_share_control_three_states_and_two_step_unshare() -> None:
assert "renderShareShared(chat, cell)" in make
assert "renderShareUnshared(chat, cell)" in make
# Unshared state: the Create link button (labeled, textContent).
+ # Phase 156 (task 02, A3): the visible text is the .cell-clip
+ # span (a squeezed Share cell shows "…" instead of a hard cut);
+ # the aria-label names the chat title (the phase-66 idiom — the
+ # audit: every one of the four History action buttons already
+ # carries an accessible name that names the row's identity, so
+ # the full text stays reachable and nothing was missing).
unshared = _fn(js, "renderShareUnshared")
assert 'create.className = "history-share-create"' in unshared
- assert 'create.textContent = "Create link"' in unshared
+ assert 'createClip.className = "cell-clip"' in unshared, (
+ "the clip label span (phase 156, A3)"
+ )
+ assert 'createClip.textContent = "Create link"' in unshared
+ assert "create.append(createClip)" in unshared
assert 'create.setAttribute("aria-label", `Create share link: ${chat.title}`)' in unshared
assert "innerHTML" not in unshared, "XSS contract: textContent only"
# Shared state: Copy + Unshare, then the two-step confirm.
shared = _fn(js, "renderShareShared")
assert 'copy.className = "history-share-copy"' in shared
- assert 'copy.textContent = "Copy"' in shared
+ assert 'copyClip.className = "cell-clip"' in shared, (
+ "the clip label span (phase 156, A3)"
+ )
+ assert 'copyClip.textContent = "Copy"' in shared
+ assert "copy.append(copyClip)" in shared
assert 'unshare.className = "history-unshare"' in shared
- assert 'unshare.textContent = "Unshare"' in shared
+ assert 'unshareClip.className = "cell-clip"' in shared, (
+ "the clip label span (phase 156, A3)"
+ )
+ assert 'unshareClip.textContent = "Unshare"' in shared
+ assert "unshare.append(unshareClip)" in shared
+ # The audit's aria-label additions — present (the phase-66 idiom
+ # predated the audit; the pin keeps them):
+ assert 'copy.setAttribute("aria-label", `Copy share link: ${chat.title}`)' in shared
+ assert 'unshare.setAttribute("aria-label", `Unshare saved chat: ${chat.title}`)' in shared
assert 'label.textContent = "Unshare?"' in shared
assert 'yes.className = "history-confirm-yes"' in shared, (
"the unshare two-step reuses the phase-50 .history-confirm-* pair"
diff --git a/tests/unit/test_kb_tree_ui.py b/tests/unit/test_kb_tree_ui.py
index 80ebf07..1a9b0de 100644
--- a/tests/unit/test_kb_tree_ui.py
+++ b/tests/unit/test_kb_tree_ui.py
@@ -811,7 +811,15 @@ def test_row_description_cell_builds_text_and_always_present_edit() -> None:
)
assert 'btn.type = "button"' in body
assert 'btn.className = "kb-summary-edit"' in body
- assert 'btn.textContent = "Edit"' in body
+ # Phase 156 (task 02, A3): the visible text is the .cell-clip
+ # span (a squeezed Description cell shows "…" instead of a hard
+ # cut) — the aria-label names the row, so the full text stays
+ # reachable.
+ assert 'clip.className = "cell-clip"' in body, (
+ "the clip label span (phase 156, A3)"
+ )
+ assert 'clip.textContent = "Edit"' in body, "the visible text (never innerHTML)"
+ assert "btn.append(clip)" in body, "the span is the button's label"
assert 'btn.setAttribute("aria-label", `Edit description: ${label}`)' in body
assert 'wrap.className = "kb-desc-cell"' in body, "the ONE flex wrapper"
assert "wrap.append(text, btn)" in body, "text + button, in order, in the wrapper"
diff --git a/tests/unit/test_remove_confirm_modal.py b/tests/unit/test_remove_confirm_modal.py
index 0560a06..d0d8135 100644
--- a/tests/unit/test_remove_confirm_modal.py
+++ b/tests/unit/test_remove_confirm_modal.py
@@ -238,6 +238,17 @@ def test_row_button_opens_the_modal_and_the_row_error_span_is_retired() -> None:
js = _js()
make = _fn(js, "makeRow")
assert "openRemoveConfirm(s, btn)" in make, "the row's Remove opens the modal"
+ # Phase 156 (task 02, A3) audit: the Remove button's aria-label
+ # names the row's identity (the source's URL/path — the phase-89
+ # idiom), so a clipped label's full text stays reachable.
+ assert 'btn.setAttribute("aria-label", `Remove ${kindLabel} source: ${value}`)' in make
+ # The visible text is the .cell-clip span (a static template —
+ # the icon is static SVG, the established exception to the
+ # textContent house rule); a squeezed cell shows "…" instead of
+ # a hard cut.
+ assert "'Remove'" in make, (
+ "the clip label span (phase 156, A3)"
+ )
assert "removeSource(" not in js, "the window.confirm-era flow is gone"
assert "git-source-row-error" not in js
assert "git-source-row-error" not in _css(), "the dead per-row error CSS is retired"
diff --git a/tests/unit/test_source_ignore_paths.py b/tests/unit/test_source_ignore_paths.py
index 2d4b436..28a1057 100644
--- a/tests/unit/test_source_ignore_paths.py
+++ b/tests/unit/test_source_ignore_paths.py
@@ -242,9 +242,16 @@ def test_row_button_exists_only_in_the_stored_row_branch() -> None:
assert f'ignoreBtn.className = "{ROW_BTN_CLASS}"' in make
assert "ignoreBtn.type = \"button\"" in make
assert ARIA_LABEL_TEMPLATE in make, "the aria-label template (the only value site)"
- assert "ignoreBtn.textContent = \"Ignore paths\"" in make, (
+ # Phase 156 (task 02, A3): the static text label is the .cell-clip
+ # span — a squeezed cell shows "…" instead of a hard cut (the
+ # aria-label above keeps the full context reachable).
+ assert "ignoreClip.className = \"cell-clip\"" in make, (
+ "the clip label span (phase 156, A3)"
+ )
+ assert "ignoreClip.textContent = \"Ignore paths\"" in make, (
"a static text label — never icon-only"
)
+ assert "ignoreBtn.append(ignoreClip)" in make, "the span is the button's label"
assert "ignoreBtn.innerHTML" not in _js(), "XSS contract: no innerHTML on the button"
# The env-fallback branch (else) has no ignore button.
else_slice = make[else_i : make.find("tr.appendChild(actTd)", else_i)]
diff --git a/tests/unit/test_table_wrap_stacking.py b/tests/unit/test_table_wrap_stacking.py
index 7d38e3a..657f63f 100644
--- a/tests/unit/test_table_wrap_stacking.py
+++ b/tests/unit/test_table_wrap_stacking.py
@@ -34,6 +34,15 @@ This module pins the source-level contract (the house pattern of
``tests/unit/test_hamburger_nav.py``: read ``styles.css`` as text, no
browser) so the regression that omitted the containing block cannot
return.
+
+Phase 156 (task 01) superseded two of these pins: the card's
+``overflow-x: auto`` became ``overflow: auto`` (both axes — the
+tables' min-width still triggers the in-card horizontal scroll) plus
+the bounded-panel ``max-height: min(70vh, 64rem)`` (owner-locked A1:
+the card is the vertical scroll region the sticky ``th`` pins to),
+and the ``#git-sources-table-wrap`` id override was harmonized into
+the base ``.table-wrap`` (the wrapper carries ``class="table-wrap"`,
+so its duplicate declarations were removed — one-rule contract).
"""
from __future__ import annotations
@@ -90,26 +99,40 @@ def test_table_wrap_is_a_positioned_containing_block() -> None:
def test_table_wrap_keeps_the_horizontal_scroll_contract() -> None:
- """The fix preserves the phase-07 scroll contract: the card keeps
- overflow-x:auto — at any viewport narrower than the tables' 640px
- min-width the table scrolls INSIDE the card (AGENTS.md rule 5:
- full-width tables stay) while the document itself no longer pans."""
+ """Phase 156 (task 01) supersedes the phase-86 overflow-x pin:
+ the card carries overflow:auto (BOTH axes — at any viewport
+ narrower than the tables' min-width the table still scrolls
+ INSIDE the card, AGENTS.md rule 5: full-width tables stay) plus
+ the bounded-panel max-height: min(70vh, 64rem) (owner-locked A1:
+ the card is the vertical scroll region — a 15-row table scrolls,
+ a 3-row one fits without a scrollbar) so the sticky th pins to
+ the card instead of scrolling away with the page."""
block = _rule_block(_css(), ".table-wrap")
- assert "overflow-x: auto" in block, (
- "the .table-wrap card must keep overflow-x:auto (the in-card "
- "scroll the fix protects)"
+ assert "overflow: auto" in block, (
+ "the .table-wrap card must carry overflow: auto (both axes — "
+ "the in-card horizontal scroll + the bounded vertical scroll)"
+ )
+ assert "max-height: min(70vh, 64rem)" in block, (
+ "the .table-wrap card must be bounded: max-height: "
+ "min(70vh, 64rem) (the A1 bounded-panel design)"
)
def test_the_fix_touched_only_the_shared_card() -> None:
- """The one-rule scope (A3): the fix lives on .table-wrap ONLY —
- .md-table-wrap (chat answer tables — different class, different
- context) and the #git-sources-table-wrap id card keep their
- pre-phase-86 rules (no position declaration acquired)."""
+ """The one-rule scope (A3, harmonized by phase 156 task 01): the
+ positioning fix lives on .table-wrap ONLY — .md-table-wrap (chat
+ answer tables — different class, different context) keeps its
+ pre-phase-86 rules (no position declaration), and the
+ #git-sources-table-wrap id override is GONE: the wrapper carries
+ class="table-wrap" and inherits the base card, so a divergent
+ unbounded id rule cannot return."""
css = _css()
- for sel in (".md-table-wrap", "#git-sources-table-wrap"):
- block = _rule_block(css, sel)
- assert not re.search(r"position\s*:", block), (
- f"{sel} must not acquire a position declaration (the fix is "
- "the ONE .table-wrap rule)"
- )
+ block = _rule_block(css, ".md-table-wrap")
+ assert not re.search(r"position\s*:", block), (
+ ".md-table-wrap must not acquire a position declaration (the "
+ "fix is the ONE .table-wrap rule)"
+ )
+ assert not re.search(r"(? None:
# surface in BOTH places (decorative borders — no contrast duty).
assert "err_line" not in [f for f, _ in pairs]
assert "accent_line" not in [f for f, _ in pairs]
+
+
+# ---------------------------------------------------------------------------
+# Phase 156 (task 03): the catalogue-table CSS contract pins — the
+# bounded-panel sticky header + the clipped-label ellipsis, parsed
+# straight out of styles.css (the :root-drift idiom extended: a later
+# "cleanup" cannot silently unstick the header, unbound the panel, or
+# transparent the thead). The :root 17-variable set itself is pinned
+# by test_builtin_colors_match_styles_css_root above (UNCHANGED —
+# this phase adds no palette variable, B3: the 17-variable set stays
+# byte-identical).
+# ---------------------------------------------------------------------------
+
+
+def _css_rule(selector: str) -> dict[str, str] | None:
+ """The ``prop: value`` declarations (comments stripped) of the FIRST
+ styles.css rule whose selector ends with ``selector`` (a bare
+ ``.table-wrap`` never matches ``.table-wrap table thead th`` —
+ the selector must end right at the rule's ``{``), or ``None``
+ when the selector is absent from the file."""
+ css = re.sub(r"/\*.*?\*/", "", STYLES_CSS.read_text(encoding="utf-8"), flags=re.S)
+ match = re.search(re.escape(selector) + r"(?![\w.#-])\s*\{", css)
+ if match is None:
+ return None
+ block = css[match.end() : css.index("}", match.end())]
+ return dict(re.findall(r"([a-z-]+)\s*:\s*([^;]+);", block))
+
+
+def _assert_opaque_background(value: str, root: dict[str, str]) -> None:
+ """The sticky th's OPACITY pin (the exact token is the phase's
+ choice — ``var(--brand-soft)`` today; the opacity is the pin):
+ the background token must paint a solid, alpha-less color —
+ ``transparent``/``none`` never, a ``var(--x)`` token must resolve
+ to a solid six-hex ``:root`` value, a literal must carry no
+ alpha channel (``rgba(`` / ``hsla(`` / 8-digit hex all let rows
+ show through the pinned header)."""
+ v = value.strip()
+ assert v.lower() not in {"transparent", "none", "inherit", "unset", "initial"}, (
+ f"the sticky th background is {value!r} — rows would show through"
+ )
+ var = re.fullmatch(r"var\(\s*(--[a-z-]+)\s*\)", v)
+ if var:
+ name = var.group(1)
+ assert name in root, f"the sticky th background {value!r} names an unknown {name}"
+ resolved = root[name]
+ assert re.fullmatch(r"#[0-9a-f]{6}", resolved), (
+ f"the sticky th background {value!r} resolves to {resolved!r} — it must "
+ f"be a solid alpha-less hex (OPAQUE)"
+ )
+ return
+ assert not re.search(r"rgba?\(\s|hsla?\(\s|#[0-9a-f]{8}\b", v), (
+ f"the sticky th background {value!r} carries an alpha channel — "
+ f"it must be OPAQUE"
+ )
+
+
+def test_table_wrap_bounded_panel_pin() -> None:
+ """The ONE rule all three catalogue tables share (the RAG
+ ``.docs-table``, the Sources ``.git-sources-table``, the History
+ ``.history-table``): the card is the vertical scroll region —
+ ``max-height: min(70vh, 64rem)`` (the exact A2 value pinned: a
+ 15-row table scrolls while a 3-row one fits without a scrollbar)
+ + ``overflow: auto`` (both axes — the tables' min-width still
+ triggers the in-card horizontal scroll at narrow widths). The
+ pre-phase ``overflow-x: auto`` is SUPERSEDED — its return would
+ unbound the panel's vertical contract (a sticky header in an
+ unbounded card scrolls away with the page)."""
+ rule = _css_rule(".table-wrap")
+ assert rule is not None, "styles.css must define .table-wrap"
+ assert rule.get("max-height") == "min(70vh, 64rem)", (
+ f".table-wrap max-height drifted: {rule.get('max-height')!r} — the "
+ f"bounded-panel pin is min(70vh, 64rem)"
+ )
+ assert rule.get("overflow") == "auto", (
+ f".table-wrap overflow drifted: {rule.get('overflow')!r} — the card "
+ f"must be the sticky th's scrolling ancestor (overflow: auto)"
+ )
+ assert "overflow-x" not in rule, (
+ ".table-wrap must not carry a bare overflow-x — the pre-phase "
+ "overflow-x: auto is superseded by overflow: auto (both axes)"
+ )
+
+
+def test_table_wrap_thead_sticky_pin() -> None:
+ """The ONE sticky-header rule for every catalogue table:
+ ``position: sticky; top: 0; z-index: 1`` (the header row pinned
+ to the card top while the card's own vertical scroll moves, the
+ pinned th above the scrolled cells) + an OPAQUE background — the
+ parse asserts the background is a non-``transparent`` token (the
+ exact token is the phase's choice — ``var(--brand-soft)`` today;
+ the OPACITY is the pin: a ``var`` token must resolve to a solid
+ alpha-less :root hex, a literal must carry no alpha channel —
+ rows must never show through the pinned header)."""
+ rule = _css_rule(".table-wrap table thead th")
+ assert rule is not None, "styles.css must define .table-wrap table thead th"
+ assert rule.get("position") == "sticky", (
+ f"the catalogue thead th must stay position: sticky, "
+ f"got {rule.get('position')!r}"
+ )
+ assert rule.get("top") == "0", (
+ f"the catalogue thead th must pin at top: 0, got {rule.get('top')!r}"
+ )
+ assert rule.get("z-index") == "1", (
+ f"the catalogue thead th must sit at z-index: 1, got {rule.get('z-index')!r}"
+ )
+ assert "background" in rule, (
+ "the catalogue thead th must carry an explicit background "
+ "(the opaque pin — the default is transparent)"
+ )
+ _assert_opaque_background(rule["background"], _root_declarations())
+
+
+def test_git_sources_table_wrap_no_divergent_override() -> None:
+ """The one-rule contract: the Sources card
+ (``#git-sources-table-wrap``) must not carry a DIVERGENT UNBOUNDED
+ override — if the id rule exists it carries the SAME
+ ``max-height: min(70vh, 64rem)`` + ``overflow: auto`` as the base
+ ``.table-wrap`` (a divergent ``overflow-x: auto`` without the
+ bound would make the Sources table scroll away with the page
+ while the other two stick). The current form (task 01): the id
+ rule is REMOVED — the wrapper carries ``class="table-wrap"`` and
+ inherits the base card's bound + overflow + sticky rule, so the
+ rule's absence IS the harmony."""
+ rule = _css_rule("#git-sources-table-wrap")
+ if rule is None:
+ return # the one-rule contract: the id carries no rule of its own
+ assert rule.get("max-height") == "min(70vh, 64rem)", (
+ f"#git-sources-table-wrap max-height diverges: "
+ f"{rule.get('max-height')!r} — the one-rule contract pins min(70vh, 64rem)"
+ )
+ assert rule.get("overflow") == "auto", (
+ f"#git-sources-table-wrap overflow diverges: {rule.get('overflow')!r} — "
+ f"the one-rule contract pins overflow: auto"
+ )
+
+
+def test_md_table_wrap_byte_unchanged_pin() -> None:
+ """The scope guard: the CHAT view's markdown table wrapper
+ (``.md-table-wrap`` — the shared renderer's horizontal scroller)
+ is a DIFFERENT class from the catalogue card — UNTOUCHED by this
+ phase (no card, no height bound; the bounded-panel design is for
+ the catalogue tables only). The pin asserts the rule's VERBATIM
+ pre-phase text (byte-identical)."""
+ css = STYLES_CSS.read_text(encoding="utf-8")
+ assert ".md-table-wrap { overflow-x: auto; }" in css, (
+ ".md-table-wrap must stay byte-identical to its pre-phase rule "
+ "(.md-table-wrap { overflow-x: auto; }) — the chat scope guard"
+ )
+
+
+def test_cell_clip_ellipsis_pin() -> None:
+ """The clipped-label ellipsis (A3): the ``.cell-clip`` label span
+ (the five control families — the Sources Hidden checkbox label +
+ Ignore paths + Remove, the RAG folder table's Edit, the History
+ Copy / Unshare / Create link / Delete) shows "…" instead of the
+ card edge's hard cut: ``text-overflow: ellipsis`` +
+ ``overflow: hidden`` + ``white-space: nowrap`` (the house
+ .sync-label shrinkable-item mechanism; the max-width clamp
+ engages only in the clipped case)."""
+ rule = _css_rule(".cell-clip")
+ assert rule is not None, "styles.css must define .cell-clip"
+ assert rule.get("text-overflow") == "ellipsis", (
+ f".cell-clip text-overflow drifted: {rule.get('text-overflow')!r} — "
+ f"the clipped label must show the ellipsis hint"
+ )
+ assert rule.get("overflow") == "hidden", (
+ f".cell-clip overflow drifted: {rule.get('overflow')!r} — "
+ f"the ellipsis needs the hidden overflow"
+ )
+ assert rule.get("white-space") == "nowrap", (
+ f".cell-clip white-space drifted: {rule.get('white-space')!r} — "
+ f"the ellipsis needs the nowrap"
+ )