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

Everything is verified. All gates green on the current working tree. Final report:

**Phase 156 — final verification pass (all 4 task files present in `complete/` — no outstanding work)**

- **Verified (not re-implemented):** bounded-panel `.table-wrap` (`max-height: min(70vh, 64rem)`, `overflow: auto`, opaque sticky `th` via `var(--brand-soft)`), `.cell-clip` ellipsis rule + the five label families in the three renderers, the added aria-labels, unit pins (incl. `.md-table-wrap` byte-unchanged scope guard, `#git-sources-table-wrap` harmony — no divergent rule), dedicated E2E suite (sticky ×3 tables, horizontal contract, computed-style ellipsis + reachable full text, mobile hamburger + scroll + pin)
- **Tests/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **3570 passed, 99% on `app/`** (gate >90%); `uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov` (DB up) → **6/6 passed in isolation**; `uv run ruff check . && uv run pyright` → **clean** (0 errors)
- **Completion criteria:** sticky header all 3 tables (exact-0 offset while `scrollTop > 0`) ✅ · horizontal scroll unchanged ✅ · ellipsis computed style + actual clip + title/aria reachable ✅ · mobile ≤640px contract ✅ · full suite/E2E/lint/coverage ✅ · atomic commit: **harness commits this phase after the pass** (per pipeline protocol — no git history commands run by me)
- **Documentation:** AGENTS.md phase-156 wording (bounded panels, sticky headers, `.cell-clip` + aria-label idiom) matches the implementation exactly — verified consistent; README.md reviewed in full (pages/URLs vs registered routes, all `.env.example` vars documented, Quick Start, eval/llm scripts, compose prod profile, E2E examples) — verified consistent, no changes needed (README describes no table layout/scroll, so no phase-156 mention required)
- **Deviations:** none — all work was already in the working tree; this pass re-verified every gate and the docs
- **Next pending phase:** `157_chat_chip_cap`
This commit is contained in:
2026-10-04 20:46:57 -04:00
parent 742eef955b
commit 1513981c1c
33 changed files with 1790 additions and 58 deletions
+621
View File
@@ -0,0 +1,621 @@
"""Phase 156 E2E (Playwright): the catalogue tables' sticky headers +
the ellipsis hint on clipped labels — the owner's UX audit, pinned.
Source: the owner UX audit 2026-10-03 (live interactive-browser pass):
"sticky table headers (RAG/Sources/History — the tables are long; the
header scrolls away)" + "At ~1,009px window width the rightmost table
columns start clipped with no ellipsis hint on the 'Hidden'/'Edit'
labels (the wrapper scrolls fine — just subtle)".
Story: n/a (audit-derived phase; the table machinery is phase 97 / 86 /
63, all complete).
Run in isolation (mock LLM; DB up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov
The phase design (phase 156, owner-locked A1/A3): the three catalogue
tables (the RAG folder table ``#folders-table`` in ``#folders-wrap``,
the Sources table ``#git-sources-table`` in ``#git-sources-table-wrap``,
the History table in ``#history-table-wrap``) all ride the ONE
``.table-wrap`` card, which is now the bounded vertical scroll region
(``max-height: min(70vh, 64rem)`` + ``overflow: auto``) with the
OPAQUE sticky ``th`` (``position: sticky; top: 0; z-index: 1``). A
clipped action label (the five ``.cell-clip`` families) shows ``…``
instead of a hard cut; the full text stays reachable through the
control's ``aria-label``/``title`` naming the row's identity (the
source URL / the chat title / the source name — the phase-89/105,
66, and 97 idioms).
Test → contract mapping (Playwright Mapping Rule):
1. ``test_sticky_header_on_vertical_scroll`` (×3 — RAG folders /
Sources / History, parameterized) — THE audit's sticky pin: at
1280×800 (the panel's ``max-height`` resolves to 560px) a 16-row
fixture overflows the panel (precondition asserted: the table's
``scrollHeight`` > the panel's ``clientHeight``); after
``scrollTop = 400`` (clamped to the max), ``scrollTop > 0`` AND the
``th``'s top edge is EXACTLY at the panel's content top — the
``boundingBox`` delta (``thTop − wrapTop − borderTop``) is 0 within
sub-pixel precision (the standard sticky assertion; WITHOUT the
phase-156 rule the ``th`` would sit ~400px above the panel top —
the counterfactual guard asserts the ``th`` never goes above the
panel top).
2. ``test_horizontal_scroll_contract_unchanged`` — the phase-97/86
contract the ``overflow: auto`` change must keep: at 375×812 each
table's ``min-width`` (480 / 560 / 640) still triggers the in-card
horizontal scroll (``wrap.scrollWidth > wrap.clientWidth`` with the
table wider than the 346px card).
3. ``test_ellipsis_on_clipped_label`` — the audit's ~1,009px case: at
the 1,009px viewport the clip is FORCED on the row's host controls
(the inline width below the label's natural size — see the "Why
the clip is forced at the control" note below), and for EVERY
clipped ``.cell-clip`` span: the COMPUTED ``text-overflow`` is
``"ellipsis"``, ``scrollWidth > clientWidth`` (the label is
actually clipped, not merely styled), and the control's
``aria-label`` names the ROW's identity (the source URL / the chat
title / the source name — the audit's additions). At least one
span per table must clip (the determinism guard).
4. ``test_mobile_contract`` — the PLAN §7.1 mobile ≤640px regression
guard (the phase-86 suite's idiom): at 375×812 a REAL
``#nav-toggle`` click opens the hamburger
(``aria-expanded="true"``), a menu row navigates (the menu closes
on navigation — the phase-85/88 contract), and in EVERY catalogue
view the table still scrolls horizontally inside the bounded panel
(test 2's contract) while the ``thead`` still pins (test 1's
contract — the ``max-height`` must not break the mobile scroll
model).
Why the clip is forced at the control (test 3): Chromium's auto table
layout renders the table at its MAX-content width whenever the
min-content sum exceeds the panel (measured: at the 1,009px 967px
card, with this suite's 45-char source URLs, 45-char chat titles, and
8-char folder names, the tables keep 950–968px of columns and
hard-cut nothing), so a viewport alone cannot deterministically
engage the ``.cell-clip`` clamp (the engagement width is
font-metric dependent). The test instead narrows the row's host
controls to below the label's natural size (40px ``.git-source-hidden``
label, 60px ``.git-source-ignore`` / ``.git-source-remove``, 30px
``.kb-summary-edit``, 60px ``.history-share-create`` /
``.history-unshare``, 50px ``.history-delete``): the clamp chain a
squeezed cell engages is the EXACT same one (the control's
``max-width: 100%`` + ``min-width: 0`` — the phase-156 task-02
support rules — plus the span's ``min-width: 0`` + ``overflow:
hidden`` + ``text-overflow: ellipsis`` + ``white-space: nowrap``), so
the assertion is deterministic and metric-independent (measured clip
margins 16–53px under the E2E Chromium).
The five ``.cell-clip`` families under test (phase 156 task 02): the
Sources ``Hidden`` checkbox label + ``Ignore paths`` + ``Remove``, the
RAG folder table's ``Edit``, the History ``Create link`` (the
``Copy``/``Unshare`` shared-state variants share the class family and
aria idiom) + ``Delete``.
House conventions: a fresh page per test via the session ``browser``
fixture (the conftest ``page`` is 1280×800), ``e2e.auth_helpers.login``
for the real form login, ``_wait_settled_admin`` copied from
``test_mobile_hamburger_nav.py`` (suites stay self-contained —
helpers copied, only ``auth_helpers`` imported), ``_cookies`` copied
from ``test_api_tokens.py``.
Data: each test seeds a FIXED 16-row fixture (16 git sources with
deterministic ``e2e.example.com`` URLs — the folder table lists
registered sources even with 0 documents, the tree endpoint's
"always present" contract — plus 16 saved chats with deterministic
45-char titles) and deletes it in ``finally`` (the idempotent
seed/delete keeps re-runs green; no KB documents are imported, so no
house KB reset is needed). The 45-char chat title and the source's
deterministic URL are what the aria assertions read back.
"""
from __future__ import annotations
import httpx
import pytest
from playwright.sync_api import Browser, Page, ViewportSize, expect
from e2e.auth_helpers import login
DESKTOP: ViewportSize = {"width": 1280, "height": 800} # the conftest page size
AUDIT: ViewportSize = {"width": 1009, "height": 800} # the audit's ~1,009px case
MOBILE: ViewportSize = {"width": 375, "height": 812} # the phase-86 phone viewport
#: The fixed fixture size — 16 rows overflow the 560px desktop panel
#: (measured table heights 835–1178px) and the 568px mobile panel.
N_ROWS = 16
SOURCE_PREFIX = "https://e2e.example.com/table-polish/"
CHAT_PREFIX = "e2e polish chat "
#: The three catalogue tables: view path, the ``.table-wrap`` card,
#: the row signal (admin settle + render), the table ``min-width``,
#: and the mobile nav link (PLAN §7.1).
TABLES = {
"rag": {
"path": "/sources.html",
"wrap": "#folders-wrap",
"row": "#folders-tbody tr",
"min_width": 480, # .kb-folders-table (the phase-97 override of the 640px .docs-table base)
"nav": "#nav-sources",
},
"sources": {
"path": "/git-sources.html",
"wrap": "#git-sources-table-wrap",
"row": "#git-sources-tbody tr",
"min_width": 560,
"nav": "#nav-git-sources",
},
"history": {
"path": "/history.html",
"wrap": "#history-table-wrap",
"row": "#history-table-wrap .history-title-link",
"min_width": 640,
"nav": "#nav-history",
},
}
def _chat_title(i: int) -> str:
"""The deterministic 45-char chat title (16 prefix + 2 index + 27 pad)."""
return (CHAT_PREFIX + f"{i:02d}" + "y" * 27)[:45]
def _source_url(i: int) -> str:
return f"{SOURCE_PREFIX}{i:02d}-repo.git"
def _cookies(page: Page) -> dict[str, str]:
"""The session cookies the browser context holds (copied from
``test_api_tokens.py``)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _wait_settled_admin(page: Page) -> None:
"""Wait until whoami has resolved for the admin: the whoami reveal
has un-hidden the admin-only nav links (copied from
``test_mobile_hamburger_nav.py``)."""
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
timeout=10_000,
)
def _seed(page: Page, app_url: str) -> dict[str, dict[str, str]]:
"""The fixed 16-row fixture (idempotent — a half-finished previous
run's rows are reused, not duplicated): 16 git sources (the
Sources table rows + the RAG folder-table rows — the tree lists
registered sources even with 0 documents) + 16 saved chats (the
History rows)."""
ck = _cookies(page)
sources: dict[str, str] = {
s["url"]: s["id"]
for s in httpx.get(f"{app_url}/api/git-sources", cookies=ck, timeout=10).json()["sources"]
if s["url"].startswith(SOURCE_PREFIX)
}
for i in range(1, N_ROWS + 1):
url = _source_url(i)
if url not in sources:
r = httpx.post(
f"{app_url}/api/git-sources",
json={"kind": "git", "url": url},
cookies=ck, timeout=10,
)
assert r.status_code == 201, r.text
sources[url] = r.json()["id"]
chats: dict[str, str] = {
c["title"]: c["id"]
for c in httpx.get(f"{app_url}/api/chats", cookies=ck, timeout=10).json()["chats"]
if c["title"].startswith(CHAT_PREFIX)
}
for i in range(1, N_ROWS + 1):
title = _chat_title(i)
if title not in chats:
r = httpx.post(
f"{app_url}/api/chats",
json={
"title": title,
"messages": [
{"who": "user", "text": f"question {i}"},
{"who": "brain", "text": f"answer {i}"},
],
},
cookies=ck, timeout=10,
)
assert r.status_code == 201, r.text
chats[title] = r.json()["id"]
return {"sources": sources, "chats": chats}
def _cleanup(page: Page, app_url: str, data: dict[str, dict[str, str]] | None) -> None:
"""Delete the fixture rows (a row another cleanup pass already
removed is a 404, not a failure)."""
if data is None:
return
ck = _cookies(page)
for sid in data["sources"].values():
r = httpx.delete(f"{app_url}/api/git-sources/{sid}", cookies=ck, timeout=30)
assert r.status_code in (204, 404), r.text
for cid in data["chats"].values():
r = httpx.delete(f"{app_url}/api/chats/{cid}", cookies=ck, timeout=10)
assert r.status_code in (204, 404), r.text
def _assert_sticky(page: Page, wrap_sel: str, label: str) -> None:
"""THE sticky pin: the panel is overflowed (precondition), the
scroll moved (``scrollTop > 0``), and the ``th``'s top edge is
EXACTLY at the panel's content top — the boundingBox delta
(``thTop − wrapTop − borderTop``) is 0 within sub-pixel
precision. The counterfactual guard: without the sticky rule the
``th`` would sit the whole ``scrollTop`` above the panel top — a
delta below −0.5 IS "scrolled away"."""
rep = page.evaluate(
"""(ws) => {
const w = document.querySelector(ws);
const t = w.querySelector('table');
const th = w.querySelector('table thead th');
w.scrollTop = 400;
const thTop = th.getBoundingClientRect().top;
const wTop = w.getBoundingClientRect().top;
const border = parseFloat(getComputedStyle(w).borderTopWidth);
return {top: w.scrollTop, clientH: w.clientHeight,
tableH: t.scrollHeight, delta: thTop - wTop - border};
}""",
wrap_sel,
)
assert rep["tableH"] > rep["clientH"], (
f"{label}: the 16-row fixture must overflow the bounded panel "
f"(table {rep['tableH']}px in a {rep['clientH']}px panel) — "
"the scroll precondition does not hold"
)
assert rep["top"] > 0, (
f"{label}: the panel's vertical scroll did not move "
f"(scrollTop {rep['top']}) — the bounded-panel contract is not in effect"
)
assert abs(rep["delta"]) < 0.5, (
f"{label}: the sticky header must sit EXACTLY at the panel's content top "
f"(th top − panel content top = {rep['delta']}px while scrollTop is "
f"{rep['top']}px) — phase 156 task 01's sticky rule; a large negative "
"delta means the header scrolled away with the page (the audit's defect)"
)
def _assert_hscroll(page: Page, wrap_sel: str, min_width: int, label: str) -> None:
"""The in-card horizontal contract: the table keeps its ``min-width``
and scrolls INSIDE the (bounded) card (``overflow: auto``)."""
rep = page.evaluate(
"""(ws) => {
const w = document.querySelector(ws);
return {scroll: w.scrollWidth, client: w.clientWidth,
table: w.querySelector('table').scrollWidth};
}""",
wrap_sel,
)
assert rep["table"] >= min_width, (
f"{label}: the table must keep its {min_width}px min-width, "
f"got {rep['table']}px"
)
assert rep["scroll"] > rep["client"], (
f"{label}: the {min_width}px table must scroll INSIDE the card "
f"(card {rep['client']}px < table {rep['table']}px)"
)
@pytest.mark.parametrize("view", ["rag", "sources", "history"])
def test_sticky_header_on_vertical_scroll(
view: str, browser: Browser, app_url: str, db_ready: None
) -> None:
"""THE audit's sticky pin, per catalogue table (1280×800 — the
panel's ``max-height: min(70vh, 64rem)`` resolves to 560px): the
16-row fixture overflows the panel (asserted), the scroll moves
(``scrollTop > 0`` after a 400px request — clamped to the max),
and the ``thead th``'s top edge is EXACTLY at the panel's content
top (the boundingBox delta is 0 within sub-pixel precision —
WITHOUT phase 156's rule it would be ~−400px: the header scrolls
away with the page, the audit's defect)."""
spec = TABLES[view]
page = browser.new_page(viewport=DESKTOP)
data: dict[str, dict[str, str]] | None = None
try:
login(page, app_url, next="/")
_wait_settled_admin(page)
# Seed BEFORE the view loads: the views fetch once on load and
# do not refetch — arriving before the fixture would render
# the pre-seed state (the rows the pins need never appear).
data = _seed(page, app_url)
page.goto(app_url + spec["path"])
_wait_settled_admin(page)
page.wait_for_selector(spec["row"], timeout=15_000)
_assert_sticky(page, spec["wrap"], f"{spec['path']} (desktop)")
finally:
if data is not None:
_cleanup(page, app_url, data)
page.close()
def test_horizontal_scroll_contract_unchanged(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The phase-97/86 contract (the ``min-width`` → in-card scroll)
survives phase 156's ``overflow: auto``: at 375×812 (the 346px
card) every catalogue table's ``min-width`` (480 / 560 / 640) is
wider than the card and scrolls INSIDE it (``wrap.scrollWidth >
wrap.clientWidth``) — the ``overflow: auto`` change (superseding
``overflow-x: auto``) must keep the horizontal axis intact."""
page = browser.new_page(viewport=MOBILE)
data: dict[str, dict[str, str]] | None = None
try:
login(page, app_url, next="/")
_wait_settled_admin(page)
data = _seed(page, app_url)
page.goto(app_url + TABLES["history"]["path"])
_wait_settled_admin(page)
page.wait_for_selector(TABLES["history"]["row"], timeout=15_000)
_assert_hscroll(page, TABLES["history"]["wrap"], 640, "History (375px)")
page.goto(app_url + TABLES["sources"]["path"])
_wait_settled_admin(page)
page.wait_for_selector(TABLES["sources"]["row"], timeout=15_000)
_assert_hscroll(page, TABLES["sources"]["wrap"], 560, "Sources (375px)")
page.goto(app_url + TABLES["rag"]["path"])
_wait_settled_admin(page)
page.wait_for_selector(TABLES["rag"]["row"], timeout=15_000)
_assert_hscroll(page, TABLES["rag"]["wrap"], 480, "RAG (375px)")
finally:
if data is not None:
_cleanup(page, app_url, data)
page.close()
# ---------------------------------------------------------------------------
# 3. The ellipsis hint: computed style + the actual clip + the full
# text reachable (the audit's ~1,009px case)
# ---------------------------------------------------------------------------
#: Force the clip: every row's host controls narrowed to below the
#: label's natural size (the measured E2E-Chromium naturals: Hidden
#: 44 / Ignore paths 82 / Remove 51+icon / Edit 24+padding / Create
#: link 69 / Delete 42 — the forced widths leave 16–53px of clip
#: margin, so the clip is deterministic; the exact chain a squeezed
#: cell engages — the module docstring's "Why the clip is forced at
#: the control" note).
FORCE_CLIP_JS = {
"sources": """
() => {
for (const row of document.querySelectorAll('#git-sources-tbody tr')) {
const h = row.querySelector('.git-source-hidden');
const i = row.querySelector('.git-source-ignore');
const r = row.querySelector('.git-source-remove');
if (h) h.style.width = '40px';
if (i) i.style.width = '60px';
if (r) r.style.width = '60px';
}
}
""",
"rag": """
() => {
for (const row of document.querySelectorAll('#folders-tbody tr')) {
const b = row.querySelector('.kb-summary-edit');
if (b) b.style.width = '30px';
}
}
""",
"history": """
() => {
for (const row of document.querySelectorAll('#history-table-wrap tbody tr')) {
const c = row.querySelector('.history-share-create');
const u = row.querySelector('.history-unshare');
const d = row.querySelector('.history-delete');
if (c) c.style.width = '60px';
if (u) u.style.width = '60px';
if (d) d.style.width = '50px';
}
}
""",
}
#: The per-table span audits — (the clipped span, the row-identity
#: element whose text the aria-label must contain, the control
#: carrying the aria-label). The sources' "Hidden" label's identity
#: rides the CHECKBOX's aria-label (the phase-105 idiom — the label
#: itself carries the explanatory ``title``, the phase-156 audit
#: note); the RAG's identity is the row's folder link (the phase-97
#: idiom); the History's identity is the chat title (the phase-66
#: idiom).
SPAN_AUDITS: dict[str, list[tuple[str, str, str]]] = {
"sources": [
(
".git-source-hidden .cell-clip",
".git-source-url-cell code",
".git-source-hidden input",
),
(
".git-source-ignore .cell-clip",
".git-source-url-cell code",
".git-source-ignore",
),
(
".git-source-remove .cell-clip",
".git-source-url-cell code",
".git-source-remove",
),
],
"rag": [
(
".kb-summary-edit .cell-clip",
"td .folder-link",
".kb-summary-edit",
),
],
"history": [
(
".history-share-create .cell-clip",
".history-title-link",
".history-share-create",
),
(
".history-delete .cell-clip",
".history-title-link",
".history-delete",
),
],
}
def test_ellipsis_on_clipped_label(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The audit's ~1,009px case, made deterministic (the module
docstring's "Why the clip is forced at the control"): at
1,009×800 the row's host controls are narrowed below the label's
natural size, and for EVERY clipped ``.cell-clip`` span: (a) the
COMPUTED ``text-overflow`` is ``"ellipsis"`` (the deterministic
style assertion — never a pixel probe), (b) ``scrollWidth >
clientWidth`` (the label is actually clipped), and (c) the
control's ``aria-label`` contains the ROW's identity (the source
URL / the chat title / the source name — the audit's additions).
At least one span per table must clip (the guard that the
fixture engages the mechanism). The five ``.cell-clip`` families
are all covered (the module docstring's family list)."""
page = browser.new_page(viewport=AUDIT)
data: dict[str, dict[str, str]] | None = None
try:
login(page, app_url, next="/git-sources.html")
_wait_settled_admin(page)
data = _seed(page, app_url)
page.goto(app_url + "/git-sources.html")
_wait_settled_admin(page)
page.wait_for_selector("#git-sources-tbody tr", timeout=15_000)
for view in ("sources", "rag", "history"):
spec = TABLES[view]
page.goto(app_url + spec["path"])
_wait_settled_admin(page)
page.wait_for_selector(spec["row"], timeout=15_000)
page.evaluate(FORCE_CLIP_JS[view])
rep = page.evaluate(
"""(audits) => {
const rowsSel = audits.rowsSel;
const out = [];
for (const row of document.querySelectorAll(rowsSel)) {
for (const a of audits.items) {
const span = row.querySelector(a.spanSel);
if (!span) continue;
const ident = row.querySelector(a.identitySel);
const ariaEl = row.querySelector(a.ariaSel);
out.push({
span: span.textContent,
clip: span.scrollWidth > span.clientWidth,
sw: span.scrollWidth, cw: span.clientWidth,
to: getComputedStyle(span).textOverflow,
identity: ident ? ident.textContent : '',
aria: ariaEl ? (ariaEl.getAttribute('aria-label') || '') : '',
});
}
}
return out;
}""",
{
"rowsSel": (
"#history-table-wrap tbody tr:not([hidden])"
if view == "history"
else TABLES[view]["row"]
),
"items": [
{"spanSel": s, "identitySel": i, "ariaSel": a}
for (s, i, a) in SPAN_AUDITS[view]
],
},
)
clips = [r for r in rep if r["clip"]]
assert clips, (
f"{spec['path']}: the forced-narrow fixture must clip at "
f"least one {view} label (the determinism guard) — "
f"got {[(r['span'], r['sw'], r['cw']) for r in rep]}"
)
for r in rep:
if not r["clip"]:
continue
assert r["to"] == "ellipsis", (
f"{spec['path']}: the clipped '{r['span']}' label must compute "
f"text-overflow: ellipsis (got '{r['to']}') — phase 156 task 02"
)
assert r["identity"], (
f"{spec['path']}: the row identity could not be read "
f"(the aria assertion is vacuous without it)"
)
assert r["identity"] in r["aria"], (
f"{spec['path']}: the clipped '{r['span']}' label's full text "
f"must stay reachable — its control's aria-label must name the "
f"row's identity ({r['identity']!r}), got {r['aria']!r}"
)
finally:
if data is not None:
_cleanup(page, app_url, data)
page.close()
# ---------------------------------------------------------------------------
# 4. The mobile ≤640px contract: the hamburger + the table scroll
# survive the bounded panel
# ---------------------------------------------------------------------------
def test_mobile_contract(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The PLAN §7.1 mobile regression guard (the phase-86 suite's
idiom): at 375×812 a REAL ``#nav-toggle`` click opens the
hamburger (``aria-expanded="true"``), a menu row navigates (the
menu closes on navigation — the phase-85/88 contract), and in
EVERY catalogue view the table still scrolls horizontally inside
the bounded panel (``min-width`` vs the 346px card) while the
``thead`` still pins — phase 156's ``max-height`` must not break
the mobile scroll model."""
page = browser.new_page(viewport=MOBILE)
data: dict[str, dict[str, str]] | None = None
try:
login(page, app_url, next="/")
_wait_settled_admin(page)
data = _seed(page, app_url)
page.goto(app_url + TABLES["history"]["path"])
_wait_settled_admin(page)
page.wait_for_selector(TABLES["history"]["row"], timeout=15_000)
# The hamburger opens (the ≤640px nav contract)…
page.click("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
# …and a menu row navigates, closing the menu.
page.click(TABLES["sources"]["nav"])
expect(page).to_have_url(app_url + TABLES["sources"]["path"], timeout=15_000)
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
page.wait_for_selector(TABLES["sources"]["row"], timeout=15_000)
_assert_hscroll(page, TABLES["sources"]["wrap"], 560, "Sources (mobile)")
_assert_sticky(page, TABLES["sources"]["wrap"], "Sources (mobile)")
page.click("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
page.click(TABLES["rag"]["nav"])
expect(page).to_have_url(app_url + TABLES["rag"]["path"], timeout=15_000)
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
page.wait_for_selector(TABLES["rag"]["row"], timeout=15_000)
_assert_hscroll(page, TABLES["rag"]["wrap"], 480, "RAG (mobile)")
_assert_sticky(page, TABLES["rag"]["wrap"], "RAG (mobile)")
page.click("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
page.click(TABLES["history"]["nav"])
expect(page).to_have_url(app_url + TABLES["history"]["path"], timeout=15_000)
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
page.wait_for_selector(TABLES["history"]["row"], timeout=15_000)
_assert_hscroll(page, TABLES["history"]["wrap"], 640, "History (mobile)")
_assert_sticky(page, TABLES["history"]["wrap"], "History (mobile)")
finally:
if data is not None:
_cleanup(page, app_url, data)
page.close()