phase: 156_table_ux_polish
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:
@@ -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`
|
||||
+121
@@ -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`
|
||||
+121
@@ -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).
|
||||
+121
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user