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 always keeps it). */ + * wrapper — the always keeps it). + * Phase 156 (task 02, A3): the Edit button's visible text is the + * .cell-clip span — a squeezed cell shows "…" instead of a hard cut; + * the aria-label names the row (the source / the source/folder path + * — the phase-97 idiom), so the full text stays reachable. */ function makeDescCell(node, source, folder, label) { const td = document.createElement("td"); /* Phase 99 (task 01, D1): the ONE flex wrapper — the text flexes + @@ -651,7 +655,15 @@ function makeDescCell(node, source, folder, label) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "kb-summary-edit"; - btn.textContent = "Edit"; + /* 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 below names the row (the source / the + source/folder path — the phase-97 idiom), so the full text + stays reachable. */ + const clip = document.createElement("span"); + clip.className = "cell-clip"; + clip.textContent = "Edit"; // never innerHTML (XSS contract) + btn.append(clip); btn.setAttribute("aria-label", `Edit description: ${label}`); wrap.append(text, btn); td.append(wrap); diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 3b4d3f4..32effc5 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -2524,14 +2524,74 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und Tokens view carried the identical defect). Zero-offset positioning changes no layout: the spans stay clipped by their own clip: rect(0 0 0 0) + 1px box, and cards without such spans (the - RAG .docs-table, the git-sources table) render unchanged. */ + RAG .docs-table, the git-sources table) render unchanged. + Phase 156 (task 01, owner-locked A1): the card is ALSO the + vertical scroll region — max-height: min(70vh, 64rem) bounds it + (a 15-row table scrolls, a 3-row one fits without a scrollbar) + and overflow: auto (superseding the phase-86 overflow-x pin — + the tables' min-width still triggers the in-card horizontal + scroll at narrow widths) makes the card the sticky th's + scrolling ancestor; an unbounded card never scrolls vertically, + so a sticky header in it would scroll away with the page. */ .table-wrap { position: relative; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); - overflow-x: auto; + overflow: auto; + max-height: min(70vh, 64rem); +} +/* Phase 156 (task 01): the ONE sticky-header rule for every + catalogue table inside a .table-wrap card (the RAG .docs-table, + the .git-sources-table, the .history-table, the .tokens-table — + the per-table th rules keep the text styling only). top: 0 pins + the header row to the card top while the card's own vertical + scroll moves; z-index: 1 keeps the pinned th above the scrolled + cells. OPAQUE background: --brand-soft is a solid hex (no alpha), + so rows never show through the pinned header (B3: no new palette + variable; a themed page's #bor-theme override of the token + handles the color — same behavior, B4). */ +.table-wrap table thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--brand-soft); +} +/* Phase 156 (task 02, owner-locked A3): the ellipsis hint on the + catalogue tables' CLIPPED action labels. The class sits on the + LABEL SPAN, never the cell (the docs-table cell padding is + untouched). The five families: the Sources "Hidden" checkbox + text + "Ignore paths" + "Remove" (git-sources.js makeRow), the + RAG folder table's "Edit" (sources.js makeDescCell), the + History "Copy" / "Unshare" / "Create link" / "Delete" (history.js). + max-width: 12rem is GENEROUS — every one of the five labels + ("Edit" ~2.4rem … "Ignore paths" ~6.3rem at 0.78–0.82rem) + fits it, so a cell that FITS shows its full text (the clamp + engages only in the CLIPPED case — the audit's ~1,009px + rightmost columns), where the label shows "…" instead of the + card edge's hard cut. Mechanism: the block container's + overflow hidden + text-overflow ellipsis + nowrap is the + house .sync-label shrinkable-item pattern; the SPAN's + min-width: 0 lets the flex item shrink below its text width — + but a flex item shrinks only when its FLEX PARENT is + constrained, so the hosting controls carry the supporting width + rules (the max-width: 100% + min-width: 0 added to each block + below): without them the inline-flex buttons keep their content + size, overflow the squeezed cell, and the card edge hard-cuts + the label again (the pre-phase defect). The FULL text stays + reachable — every one of these controls' aria-label names the + row's identity (the source's URL/path — the phase-89/105 idiom; + the chat title — the phase-66 idiom; the source/folder name — + the phase-97 idiom), so the clip is visual only. B5 idiom + intact: the labels are text (text + color, never color alone). */ +.cell-clip { + display: block; + min-width: 0; + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .docs-table { width: 100%; @@ -2546,13 +2606,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und white-space: nowrap; } .docs-table th { - background: var(--brand-soft); color: var(--brand-ink); font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.04em; - position: sticky; - top: 0; } .docs-table td:nth-child(2) { font-family: var(--mono); font-size: 0.82rem; max-width: 30rem; overflow: hidden; text-overflow: ellipsis; } .docs-table tbody tr:hover { background: var(--bg); } @@ -2720,6 +2777,14 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und flex: 0 0 auto; display: inline-flex; align-items: center; + /* Phase 156 (task 02, A3): the width support for the label span's + clamp — the flex item is clamped to the cell + shrinkable + (min-width: 0), so a squeezed Description cell ellipsizes the + "Edit" label instead of overflowing the card edge. The level + block's use (the non-flex .kb-level-body) is unaffected: its + natural width never reaches the clamp. */ + max-width: 100%; + min-width: 0; min-height: 24px; padding: 0.15rem 0.7rem; border: 1px solid var(--line); @@ -3384,16 +3449,15 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und .ignore-editor-save:hover:not(:disabled) { background: var(--brand-hover); } /* The list: the Sources page's table pattern — full width in the - 72rem frame, surface card, horizontally scrollable wrapper (the - URL column never wraps or ellipsizes: long URLs, credentials - included, scroll the wrapper instead of truncating). */ -#git-sources-table-wrap { - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--radius); - box-shadow: var(--shadow); - overflow-x: auto; -} + 72rem frame. The URL column never wraps or ellipsizes: long + URLs, credentials included, scroll the wrapper instead of + truncating (the .git-sources-table 560px min-width keeps the + in-wrapper horizontal scroll). Phase 156 (task 01): the id + card's duplicate background/border/radius/shadow/overflow + declarations were REMOVED — the wrapper carries + class="table-wrap", so it inherits the base card + the + bounded-panel max-height + the sticky rule (the one-rule + contract: no divergent unbounded override). */ .git-sources-table { width: 100%; border-collapse: collapse; @@ -3407,13 +3471,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und white-space: nowrap; } .git-sources-table th { - background: var(--brand-soft); color: var(--brand-ink); font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.04em; - position: sticky; - top: 0; } /* Location cell: mono at the Sources-table size; 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" + )