phase: 156_table_ux_polish
Build and Push Containers / build-and-push-app (push) Successful in 2m15s
Build and Push Containers / build-and-push-db (push) Successful in 11s

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`
This commit is contained in:
2026-10-04 20:46:57 -04:00
parent 742eef955b
commit 1513981c1c
33 changed files with 1790 additions and 58 deletions
@@ -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`
@@ -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
@@ -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`
@@ -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
@@ -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`
@@ -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
@@ -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).
@@ -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
@@ -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).
@@ -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
+6 -1
View File
@@ -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).
+22 -3
View File
@@ -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 + "<span>Remove</span>";
/* 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 + '<span class="cell-clip">Remove</span>';
// 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
+31 -4
View File
@@ -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
+14 -2
View File
@@ -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 <td> always keeps it). */
* wrapper — the <td> 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);
+103 -22
View File
@@ -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 <code> 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;
+621
View File
@@ -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()
+9 -2
View File
@@ -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)]
+54 -5
View File
@@ -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"
+9 -1
View File
@@ -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"
+11
View File
@@ -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 "'<span class=\"cell-clip\">Remove</span>'" 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"
+8 -1
View File
@@ -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)]
+38 -15
View File
@@ -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)
block = _rule_block(css, ".md-table-wrap")
assert not re.search(r"position\s*:", block), (
f"{sel} must not acquire a position declaration (the fix is "
"the ONE .table-wrap rule)"
".md-table-wrap must not acquire a position declaration (the "
"fix is the ONE .table-wrap rule)"
)
assert not re.search(r"(?<![\w-])#git-sources-table-wrap\s*\{", css), (
"the #git-sources-table-wrap id override must be harmonized "
"into the base .table-wrap (phase 156 — no divergent rule)"
)
+183
View File
@@ -19,6 +19,16 @@ DB-over-built-in resolver shared by ``/api/ui-settings`` and
(the resolver treats "" as unset, B1). House DB-test pattern (the
``test_tokens`` precedent): the real compose Postgres, skipped with
clear instructions when the stack is not up.
* Phase 156 (task 03): the catalogue-table CSS contract pins, 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 bounded ``.table-wrap``
panel, the sticky opaque ``thead th``, the
``#git-sources-table-wrap`` one-rule harmony, the
``.md-table-wrap`` byte-unchanged scope guard, the ``.cell-clip``
ellipsis. The :root 17-variable set itself stays pinned by
``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this
phase adds no palette variable, B3).
"""
from __future__ import annotations
@@ -389,3 +399,176 @@ def test_docstring_pair_table_matches_theme_js_pairs() -> 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"
)