phase: 86_history_page_width

All criteria verified. Final report:

**Phase 86 final verification pass — all green, no defects found.**

- **Verified (previously implemented):** `position: relative` on shared `.table-wrap` (the one-rule A3 fix, +12 lines in `frontend/assets/styles.css`); unit pin `tests/unit/test_table_wrap_stacking.py` (3 tests); E2E story `tests/e2e/test_history_page_width.py` (5 tests: History 626→≤375, Tokens 618→≤375, SPA switch with window-sentinel, RAG regression, desktop 1280×800)
- **Tests/lint (this pass):** `uv run pytest` → 1720 passed · `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90% floor) · E2E in isolation: `test_history_page_width.py` 5 passed, `test_chat_history.py` 5 passed, `test_api_tokens.py` 9 passed, `test_smoke.py` 3 passed · `uv run ruff check . && uv run pyright` → clean, 0 errors
- **Completion criteria:** ① 375px direct+SPA `scrollWidth ≤ innerWidth` on History/Tokens — PASS (E2E pins) ② in-card scroll preserved + full-width + a11y spans in DOM — PASS ③ Sources + desktop regression — PASS ④ full suite/coverage/E2E/lint gates — PASS ⑤ `git diff --stat` limited to `styles.css` (+12) + new test files + phase files, no `app/`/markup/JS — PASS ⑥ commit/move — left to harness per executor rules (working tree intact, `todo/` dir removal already reflected)
- **Notable:** red→green (CSS reverted → 626px failure) and manual live check already recorded in `.agents/reports/86_history_page_width/`; pre-existing untracked `.agents/remediation_plan.md` (Sep 7 security audit) untouched
- **Next pending phase:** `87_big_read_progress`
This commit is contained in:
2026-09-08 02:38:45 -04:00
parent 412a560348
commit 0f6b9ff7e6
19 changed files with 951 additions and 0 deletions
@@ -0,0 +1,9 @@
All criteria verified. Final report:
**Phase 86 final verification pass — all green, no defects found.**
- **Verified (previously implemented):** `position: relative` on shared `.table-wrap` (the one-rule A3 fix, +12 lines in `frontend/assets/styles.css`); unit pin `tests/unit/test_table_wrap_stacking.py` (3 tests); E2E story `tests/e2e/test_history_page_width.py` (5 tests: History 626→≤375, Tokens 618→≤375, SPA switch with window-sentinel, RAG regression, desktop 1280×800)
- **Tests/lint (this pass):** `uv run pytest` → 1720 passed · `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90% floor) · E2E in isolation: `test_history_page_width.py` 5 passed, `test_chat_history.py` 5 passed, `test_api_tokens.py` 9 passed, `test_smoke.py` 3 passed · `uv run ruff check . && uv run pyright` → clean, 0 errors
- **Completion criteria:** ① 375px direct+SPA `scrollWidth ≤ innerWidth` on History/Tokens — PASS (E2E pins) ② in-card scroll preserved + full-width + a11y spans in DOM — PASS ③ Sources + desktop regression — PASS ④ full suite/coverage/E2E/lint gates — PASS ⑤ `git diff --stat` limited to `styles.css` (+12) + new test files + phase files, no `app/`/markup/JS — PASS ⑥ commit/move — left to harness per executor rules (working tree intact, `todo/` dir removal already reflected)
- **Notable:** red→green (CSS reverted → 626px failure) and manual live check already recorded in `.agents/reports/86_history_page_width/`; pre-existing untracked `.agents/remediation_plan.md` (Sep 7 security audit) untouched
- **Next pending phase:** `87_big_read_progress`
@@ -0,0 +1,85 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 71%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 92%]
........................................................................ [ 96%]
................................................................ [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 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 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 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,13 @@
All gates green. Final state verified:
**Report — Phase 86, Task 01 (table-wrap containing-block fix)**
- Added `position: relative;` (first declaration) to the shared `.table-wrap` rule in `frontend/assets/styles.css`, with a root-cause comment (positioned `.visually-hidden` spans → initial containing block → document scroll leak; TODO.md L4) — the one-rule A3 fix
- New unit pin `tests/unit/test_table_wrap_stacking.py` (house CSS-source pattern): `position: relative` present + first, `overflow-x: auto` preserved, and the fix scoped to `.table-wrap` only (`.md-table-wrap` / `#git-sources-table-wrap` untouched); docstring carries the measured bug basis (626/618/375 at 375px, 2026-09-07)
- Live browser verification (375px, throwaway script, deleted after): History scrollWidth 626→375, Tokens 618→375, Sources 375 (unchanged); in-card scroll intact (wraps 688/640 > 344 client); a11y spans still in DOM
- `uv run pytest tests/unit/test_table_wrap_stacking.py -v` → 3 passed
- `uv run pytest --cov=app --cov-report=term-missing` → 1720 passed, TOTAL **99%** (>90%)
- `uv run ruff check . && uv run pyright` → All checks passed / 0 errors
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` → 3 passed (sanity, app functional)
- `git diff --stat -- frontend/` → only `frontend/assets/styles.css` (+12: comment + 1 property); no markup/JS changes; no commit made (harness commits)
- Next pending task: `02_e2e_story_suite.md` (the `tests/e2e/test_history_page_width.py` story suite)
@@ -0,0 +1,85 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 71%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 92%]
........................................................................ [ 96%]
................................................................ [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 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 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 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,17 @@
Task 02 complete. Report:
**Implemented**
- `tests/e2e/test_history_page_width.py` (new) — 5 isolated Playwright tests: History viewport-width at 375px (THE 626→≤375 pin + in-card scroll + a11y-span presence), Tokens (618 fold-in), SPA switch history→tokens (real `#nav-toggle` click + phase-76 window-sentinel same-document proof), RAG regression (viewport-width + full-width docs table), desktop 1280×800 no-overflow / container-width tables.
- RAG + desktop tests seed the KB house-way (truncate + 13 fixture docs) — the RAG view hides its table on an empty KB; History/Tokens pins hold empty, as the task assumed.
**Results**
- `uv run pytest tests/e2e/test_history_page_width.py -v --no-cov` → **5 passed** (in isolation, DB up)
- Red→green verified: with task-01 CSS reverted, `test_history_page_is_viewport_width` fails at the scrollWidth pin (**626px at a 375px viewport** — the exact reported defect); restored → green (recorded in the session above)
- `uv run pytest --cov=app --cov-report=term-missing` → **1720 passed, 99% coverage** (>90%)
- `uv run ruff check .` → clean; `uv run pyright` → **0 errors**
**Decisions**
- Reused the phase-76 window-sentinel pattern verbatim for the same-document proof (per the task's ASSUMPTION) instead of a navigation-timeline count.
- Card-width pin computes container content width minus the card's own 1px borders (clientWidth excludes borders) — a ±1px tolerance caught a first-draft miscalculation.
**Next pending task:** `03_verify_and_commit.md` (full gate + atomic commit).
@@ -0,0 +1,85 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 71%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 92%]
........................................................................ [ 96%]
................................................................ [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 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 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 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,18 @@
Task 03 (full gate) complete — all green. Per my harness rules I did **not** commit or move the phase dir (the task's steps 3–4 are the harness's job on success).
**Gate results (exact commands):**
- `uv run pytest` → 1720 passed
- `uv run pytest --cov=app --cov-report=term-missing` → 1720 passed, **99%** coverage (>90% floor held, no `app/` change)
- `uv run pytest tests/e2e/test_history_page_width.py -v --no-cov` → 5 passed (in isolation)
- `uv run pytest tests/e2e/test_chat_history.py -v --no-cov` → 5 passed; `test_api_tokens.py` → 9 passed; `test_smoke.py` → 3 passed
- `uv run ruff check . && uv run pyright` → All checks passed / 0 errors
**Manual live check (throwaway Playwright script, E2E-style app boot; output kept in this session log):** 375px — `/history.html` scrollWidth **375** (626 pre-fix), `/tokens.html` 375 (618 pre-fix), `/sources.html` 375; dragging right at the edge moved nothing (scrollLeft stayed 0); swiping the card scrolled its columns internally (296px) while the document stayed put. 1280px — all three cards at 1110px container width, 0px internal scroll. **ALL CHECKS PASSED.**
**Scope:** working tree holds exactly `frontend/assets/styles.css` (+12: `position: relative` + root-cause comment), the two new test files, and phase files (01/02 already in `complete/`; `00_phase.md` + `03` still in `todo/` for the harness). No markup/JS/`app/` changes.
**Deviations/flags:**
- No `git commit` / no `mv` of the phase dir — harness owns both (overrides task steps 3–4).
- `.agents/remediation_plan.md` (untracked, 2026-09-07 security-audit artifact) is **not** part of this phase — exclude it from the phase-86 commit.
**Next pending:** `87_big_read_progress` (separate phase).
@@ -0,0 +1,85 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 71%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 92%]
........................................................................ [ 96%]
................................................................ [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 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 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 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
+12
View File
@@ -1741,7 +1741,19 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
other door: the admin password login (the static ?next=/ is the other door: the admin password login (the static ?next=/ is the
no-JS fallback). */ no-JS fallback). */
/* Phase 86: the card is the containing block for the
position:absolute .visually-hidden elements it hosts (the table
<caption> + the Actions header span in #view-history /
#view-tokens). Without a positioned ancestor their 1px boxes lay
out against the INITIAL containing block — at the 640px table's
right edge — and leak into the DOCUMENT's scrollable overflow
(the page appeared "the width of the table", TODO.md L4; the
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. */
.table-wrap { .table-wrap {
position: relative;
background: var(--surface); background: var(--surface);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
+427
View File
@@ -0,0 +1,427 @@
"""Phase 86 E2E (Playwright): the table pages are viewport-width — the
owner's "the page is the width of the table" bug, pinned.
Source: ``TODO.md`` L4 (owner bug report, 2026-09-07): "The history page
appears to be the width of the table despite the table being scrollable.
On mobile this results in half the page being blank and awkwardly
scrollable."
Story: n/a (owner bug report — ``TODO.md`` L4 is the story; no
``.agents/user_stories/`` file).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_history_page_width.py -v --no-cov
The bug (confirmed by live reproduction, 375×812, signed-in admin):
the History and Tokens tables carry ``position: absolute``
``.visually-hidden`` elements (the ``<caption>`` + the Actions column
header span). No ancestor in the chain was positioned, so their 1px
boxes were laid out against the INITIAL containing block — the Actions
span at the 640px table's right edge — and leaked into the DOCUMENT's
scrollable overflow, bypassing the card's own ``overflow-x: auto``
scrolling. Measured ``documentElement.scrollWidth`` at 375px:
``/history.html`` **626**, ``/tokens.html`` **618** (the byte-identical
defect, folded in per owner decision A3), ``/sources.html`` **375**
(clean — the RAG table's headers are visible text). The fix is ONE CSS
property (phase 86, task 01): ``position: relative`` on the shared
``.table-wrap`` card, which becomes the containing block for those
spans. This suite pins the FIXED contract: the document never pans
past the viewport, the 640px tables still scroll INSIDE their cards
(the phase-07 / AGENTS.md-rule-5 full-width contract), the a11y spans
stay in the DOM, and the RAG view + the desktop layout are unchanged.
Counterfactual (verified red→green in the phase session): with the
task-01 rule reverted (no ``position: relative`` on ``.table-wrap``),
``test_history_page_is_viewport_width`` fails at its
``scrollWidth <= innerWidth`` pin (626 > 375); restored, it is green.
Test → contract mapping (Playwright Mapping Rule):
1. ``test_history_page_is_viewport_width`` — THE TODO.md L4 pin: admin
at 375×812, direct ``/history.html`` —
``documentElement.scrollWidth <= innerWidth`` (626 → ≤375), the
table still scrolls INSIDE ``#history-table-wrap``
(``scrollWidth > clientWidth`` — the 640px ``min-width``), the card
is the container's content width (≤ the viewport), and the
visually-hidden Actions header + caption survived the fix (the a11y
names are intact).
2. ``test_tokens_page_is_viewport_width`` — the folded-in identical
defect (618 pre-fix): the same two pins on direct ``/tokens.html``
for ``#tokens-table-wrap``.
3. ``test_spa_switch_keeps_page_width`` — the invariant survives the
router, not just direct loads: at 375px on ``/history.html`` the
mobile menu (a REAL ``#nav-toggle`` click — the phase-85 contract)
opens, the Tokens link switches the view IN THE SAME DOCUMENT
(the phase-76 window-sentinel proof — a real navigation would wipe
``window`` globals), and the Tokens page is still viewport-width
with its table still scrolling in-card.
4. ``test_rag_view_regression`` — ``/sources.html`` at 375px: no
overflow (it was already clean at 375 pre-fix — pinned so the
shared-rule change cannot regress it) AND the RAG table is still
full-width inside its card (in-card scroll — the 640px
``min-width`` still engages).
5. ``test_desktop_unchanged`` — 1280×800 regression on ALL THREE
views: no document overflow at any width, and the tables render at
container width (the cards need no internal scroll —
``scrollWidth <= clientWidth + 1``, the card is far wider than the
mobile 346px).
House conventions: a fresh 375×812 page per mobile 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).
Data: the History/Tokens contract holds with EMPTY tables (static
thead + the 640px ``min-width`` — no rows needed, and the suite never
depends on saved chats or issued tokens). The RAG view, by contrast,
HIDES its table and shows the "Nothing indexed yet" empty state on an
empty KB, so tests 4 and 5 seed the KB the house way (truncate +
import the 13 fixture docs, deterministic mock embeddings) and pin
nothing about the rows themselves — only that the table is up and
full-width.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import Browser, JSHandle, Page, ViewportSize, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
MOBILE: ViewportSize = {"width": 375, "height": 812} # the bug-report phone viewport
DESKTOP: ViewportSize = {"width": 1280, "height": 800} # the conftest page size
async def _import_fixtures(mock_port: int) -> ImportSummary:
"""The house KB seed (copied from ``test_api_tokens.py``): import
the fixture docs against the mock LLM's embeddings."""
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test
thread, so ``asyncio.run`` cannot be called directly from a test
body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _seed_kb(mock_port: int) -> ImportSummary:
"""House reset (truncate the KB + the prompt-shaping tables so the
mock answers stay deterministic) + the fixture re-import — the
RAG view hides its table on an empty KB, so the two tests that
pin it need at least the fixture docs present. ``saved_chats`` and
``api_tokens`` are deliberately NOT touched (the house pattern)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
db.commit()
return _run_in_thread(_import_fixtures(mock_port))
def _mobile_page(browser: Browser) -> Page:
"""A fresh 375×812 page (the conftest ``page`` is 1280×800)."""
return browser.new_page(viewport=MOBILE)
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 — the viewport-independent
settled signal (copied from ``test_mobile_hamburger_nav.py``; the
sign-out control is viewport-dependent and not a cross-viewport
probe)."""
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
timeout=10_000,
)
def _open_menu(page: Page) -> None:
"""Open the mobile menu with a REAL click on the toggle (the
phase-85 contract: the gate sits below the header, so a real tap
reaches the toggle; for a signed-in admin the gate is hidden
anyway)."""
page.click("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
def _wrap_handle(page: Page, view: str) -> JSHandle:
"""The table card (``.table-wrap``) of ``view``: the id'd cards for
history/tokens, the RAG card found through its ``.docs-table``
(that card is the only one without an id)."""
if view == "sources":
return page.evaluate_handle(
"() => document.querySelector('.docs-table').closest('.table-wrap')"
)
return page.evaluate_handle(f"() => document.querySelector('#{view}-table-wrap')")
def _assert_viewport_width(page: Page, label: str) -> None:
"""THE pin (TODO.md L4): the document itself never pans past the
viewport — pre-fix, /history.html measured 626 and /tokens.html
618 at this 375px viewport."""
report = page.evaluate(
"() => ({ doc: document.documentElement.scrollWidth, inner: window.innerWidth })"
)
assert report["doc"] <= report["inner"], (
f"{label}: the document panned to {report['doc']}px at a "
f"{report['inner']}px viewport — the TODO.md L4 defect "
'(the page is "the width of the table"); the .table-wrap card '
"must be the containing block for the positioned "
".visually-hidden spans (position: relative, phase 86)"
)
def _assert_in_card_scroll(page: Page, wrap: JSHandle, label: str) -> None:
"""The phase-07 / AGENTS.md-rule-5 contract the fix preserves: the
640px table scrolls INSIDE the card (``overflow-x: auto``), the
card is the container's content width (≤ the viewport), and the
table's ``min-width: 640px`` is what the in-card scroll is over."""
report = wrap.evaluate(
"""el => {
const c = el.closest('.container');
const cs = getComputedStyle(c);
const ws = getComputedStyle(el);
return {
scroll: el.scrollWidth,
client: el.clientWidth,
// The container's content width minus the card's own
// borders (clientWidth excludes them) — the card fills
// the row edge to edge.
content: c.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)
- parseFloat(ws.borderLeftWidth) - parseFloat(ws.borderRightWidth),
inner: window.innerWidth,
table: el.querySelector('table').scrollWidth,
};
}"""
)
assert report["table"] >= 640, (
f"{label}: the table must keep its 640px min-width, got {report['table']}px"
)
assert report["scroll"] > report["client"], (
f"{label}: the 640px table must still scroll INSIDE the card "
f"(card {report['client']}px < table {report['table']}px)"
)
assert report["client"] <= report["inner"], (
f"{label}: the card must not exceed the viewport "
f"({report['client']}px > {report['inner']}px)"
)
assert abs(report["client"] - report["content"]) <= 1, (
f"{label}: the card must fill the container's content width "
f"(card {report['client']}px vs container {report['content']}px)"
)
def _assert_hidden_a11y_spans(page: Page, wrap_sel: str) -> None:
"""The a11y names survived the fix: the visually-hidden Actions
column header and the table caption are still in the DOM (the fix
repositioned their CONTAINING BLOCK — it did not remove them)."""
expect(page.locator(f"{wrap_sel} th .visually-hidden")).to_have_count(1)
expect(page.locator(f"{wrap_sel} th .visually-hidden")).to_have_text("Actions")
expect(page.locator(f"{wrap_sel} caption.visually-hidden")).to_have_count(1)
# ---------------------------------------------------------------------------
# 1. THE TODO.md L4 pin: /history.html is viewport-width at 375px
# ---------------------------------------------------------------------------
def test_history_page_is_viewport_width(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""THE TODO.md L4 pin: admin at 375×812, direct ``/history.html`` —
the document is viewport-width (pre-fix ``scrollWidth`` 626 — the
page panned ~250px into a blank region, "half the page being
blank"), the 640px table still scrolls INSIDE
``#history-table-wrap`` (the phase-07 / AGENTS.md-rule-5
full-width contract), and the visually-hidden Actions header +
caption are still in the DOM (the a11y names survived the fix)."""
page = _mobile_page(browser)
try:
login(page, app_url, next="/history.html")
_wait_settled_admin(page)
expect(page.locator("#history-table-wrap")).to_be_visible(timeout=15_000)
_assert_viewport_width(page, "/history.html (direct)")
_assert_in_card_scroll(page, _wrap_handle(page, "history"), "/history.html")
_assert_hidden_a11y_spans(page, "#history-table-wrap")
finally:
page.close()
# ---------------------------------------------------------------------------
# 2. The folded-in identical defect: /tokens.html (618 pre-fix)
# ---------------------------------------------------------------------------
def test_tokens_page_is_viewport_width(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The owner-confirmed A3 fold-in: the Tokens view carries the
byte-identical defect (measured ``scrollWidth`` 618 pre-fix — the
same visually-hidden Actions header + caption, no positioned
ancestor) and is fixed by the SAME one-rule ``.table-wrap`` fix.
Same two pins as test 1, on direct ``/tokens.html`` for
``#tokens-table-wrap``."""
page = _mobile_page(browser)
try:
login(page, app_url, next="/tokens.html")
_wait_settled_admin(page)
expect(page.locator("#tokens-table-wrap")).to_be_visible(timeout=15_000)
_assert_viewport_width(page, "/tokens.html (direct)")
_assert_in_card_scroll(page, _wrap_handle(page, "tokens"), "/tokens.html")
_assert_hidden_a11y_spans(page, "#tokens-table-wrap")
finally:
page.close()
# ---------------------------------------------------------------------------
# 3. The invariant survives the router (not just direct loads)
# ---------------------------------------------------------------------------
def test_spa_switch_keeps_page_width(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""At 375px on ``/history.html``: open the mobile menu (a REAL
``#nav-toggle`` click — the phase-85 contract; at this width the
nav links live in the dropdown), click the Tokens link — a
CLIENT-SIDE view switch in the phase-76 shell, so the same-document
proof is the phase-76 canonical one: the window sentinel set
before the click is still readable after it (a real document load
would wipe ``window`` globals; the navigation-timeline count is
deliberately NOT used — a real load resets it, so it cannot
distinguish pushState from a reload). On arrival the Tokens page
keeps BOTH pins: viewport-width AND the table still scrolling
inside its card."""
page = _mobile_page(browser)
try:
login(page, app_url, next="/history.html")
_wait_settled_admin(page)
expect(page.locator("#history-table-wrap")).to_be_visible(timeout=15_000)
_assert_viewport_width(page, "/history.html (pre-switch)")
# THE SWITCH (mobile menu → Tokens link), same document:
page.evaluate("() => { window.__phase86_width = 'phase86'; }")
_open_menu(page)
page.click("#nav-tokens")
expect(page).to_have_url(app_url + "/tokens.html", timeout=15_000)
assert page.evaluate("() => window.__phase86_width") == "phase86", (
"a real document load would have wiped the window sentinel — "
"the view switch must be same-document (the phase-76 router)"
)
expect(page.locator("#tokens-table-wrap")).to_be_visible(timeout=15_000)
_assert_viewport_width(page, "/tokens.html (SPA switch)")
_assert_in_card_scroll(page, _wrap_handle(page, "tokens"), "/tokens.html (SPA)")
finally:
page.close()
# ---------------------------------------------------------------------------
# 4. Regression: the RAG view (already clean) stays clean + full-width
# ---------------------------------------------------------------------------
def test_rag_view_regression(
browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""``/sources.html`` at 375px (admin): the RAG view measured clean
pre-fix (``scrollWidth`` 375 — the ``.docs-table`` headers are
visible text, no positioned hidden spans at the table's right
edge). Pinned so the shared ``.table-wrap`` rule change cannot
regress it — AND its table is still full-width inside the card:
the 640px ``min-width`` still engages, so the card keeps its
in-card scroll (the shared rule added a containing block, not a
width)."""
summary = _seed_kb(mock_llm)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page = _mobile_page(browser)
try:
login(page, app_url, next="/sources.html")
_wait_settled_admin(page)
expect(page.locator(".docs-table")).to_be_visible(timeout=15_000)
_assert_viewport_width(page, "/sources.html (direct)")
_assert_in_card_scroll(page, _wrap_handle(page, "sources"), "/sources.html")
finally:
page.close()
# ---------------------------------------------------------------------------
# 5. Desktop regression: no overflow anywhere, tables at container width
# ---------------------------------------------------------------------------
def test_desktop_unchanged(
browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""1280×800 regression on ALL THREE table views: no document
overflow at any width, and each table renders at container width —
the cards need no internal scroll
(``wrap.scrollWidth <= wrap.clientWidth + 1``) and are far wider
than the mobile 346px card. The zero-offset
``position: relative`` changed no layout, so desktop is
byte-identical in behavior. The KB is seeded the house way for
the RAG view's table (it hides itself on an empty KB)."""
summary = _seed_kb(mock_llm)
assert summary is not None and summary.added == 13
page = browser.new_page(viewport=DESKTOP)
try:
login(page, app_url, next="/history.html")
_wait_settled_admin(page)
views = (
("history", "/history.html"),
("tokens", "/tokens.html"),
("sources", "/sources.html"),
)
for i, (view, path) in enumerate(views):
if i: # the first view is the login landing
page.goto(app_url + path)
_wait_settled_admin(page)
marker = ".docs-table" if view == "sources" else f"#{view}-table-wrap"
expect(page.locator(marker)).to_be_visible(timeout=15_000)
_assert_viewport_width(page, f"{path} (desktop)")
report = _wrap_handle(page, view).evaluate(
"el => ({ scroll: el.scrollWidth, client: el.clientWidth })"
)
assert report["client"] > 346, (
f"{path} (desktop): the table card must render at container "
f"width, got {report['client']}px (the mobile card is 346px)"
)
assert report["scroll"] <= report["client"] + 1, (
f"{path} (desktop): no in-card scroll is needed at this width "
f"(table {report['scroll']}px in a {report['client']}px card)"
)
finally:
page.close()
+115
View File
@@ -0,0 +1,115 @@
"""Unit: the .table-wrap containing-block contract (phase 86, task 01).
TODO.md L4 (owner bug report, 2026-09-07): "The history page appears to
be the width of the table despite the table being scrollable. On mobile
this results in half the page being blank and awkwardly scrollable."
Bug basis (confirmed by live reproduction, 2026-09-07 — headless
Chromium, 375×812, signed-in admin): the History and Tokens tables
carry ``class="visually-hidden"`` on their ``<caption>`` and their
Actions column header — a ``position: absolute`` 1px clipped box. No
ancestor in the chain (th → table → .table-wrap → the view shell →
#main → body) was positioned, so the span's containing block was the
INITIAL containing block: its 1px box sat at the 640px table's right
edge and contributed to the DOCUMENT's scrollable overflow, bypassing
the card's own scroll clipping. Measured ``documentElement.scrollWidth``
at a 375px viewport: /history.html **626** (the page panned ~250px
into a blank region — the owner's "half the page being blank"),
/tokens.html **618** (the byte-identical defect, folded in per the
owner-confirmed A3 scope), /sources.html **375** (the RAG view is
clean — its table headers are visible text, no positioned hidden
spans). Hiding the span, OR making the card ``position: relative``,
both bring the scrollWidth back to 375.
The fix is ONE CSS property (owner decision A3): ``position: relative``
on the shared ``.table-wrap`` card — it becomes the containing block
for the ``.visually-hidden`` elements it hosts, so History AND Tokens
are fixed by the same rule with no markup change (the accessible
column name / caption stay) and no JS change. 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.
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.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
def _css() -> str:
"""styles.css with comments stripped (a comment may legally carry
braces — the brace-matching helper below must never see them)."""
return re.sub(r"/\*.*?\*/", "", _text(STYLES_CSS), flags=re.S)
def _rule_block(css: str, selector: str) -> str:
"""The first rule body for the EXACT selector ``selector`` (e.g.
``.table-wrap``). The lookbehind before the selector rejects
decorated variants: ``.table-wrap`` must NOT match ``.md-table-wrap`` (the
chat-answer markdown tables) nor ``.history-table-wrap`` /
``.tokens-table-wrap`` (the cards' extra classes), and an id rule
like ``#git-sources-table-wrap`` is never a match for the class
selector."""
m = re.search(r"(?<![\w-])" + re.escape(selector) + r"\s*\{([^}]*)\}", css)
assert m, f"missing rule for {selector!r}"
return m.group(1)
def test_table_wrap_is_a_positioned_containing_block() -> None:
"""THE regression pin: the shared .table-wrap card carries
position:relative — as its FIRST declaration — making it the
containing block for the position:absolute .visually-hidden
elements it hosts (the table caption + the Actions header span in
#view-history / #view-tokens). Without a positioned ancestor their
1px boxes are laid out against the initial containing block at the
640px table's right edge and leak into the document's scrollable
overflow (TODO.md L4 — the page appeared "the width of the
table": 626px on History, 618px on Tokens, at 375px)."""
block = _rule_block(_css(), ".table-wrap")
assert re.search(r"position:\s*relative", block), (
"the .table-wrap card must be position:relative (the "
"containing block for the .visually-hidden spans)"
)
assert block.lstrip().startswith("position: relative"), (
"position:relative must be the FIRST declaration of .table-wrap "
"(the containing-block contract precedes the card look)"
)
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."""
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)"
)
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)."""
css = _css()
for sel in (".md-table-wrap", "#git-sources-table-wrap"):
block = _rule_block(css, sel)
assert not re.search(r"position\s*:", block), (
f"{sel} must not acquire a position declaration (the fix is "
"the ONE .table-wrap rule)"
)