phase: 85_mobile_menu_gate_overlap

All verification complete. Final report:

**Phase 85 final verification pass — all green** (tasks 01–03 already complete; independently re-verified from scratch)
- Verified fix in tree: `styles.css` `.auth-gate` z-index 500→15 + rewritten stacking comment (no `z-index: 500` left); `index.html`/`document.html` comment-only; `token-gate.js` docstring-only (logic byte-identical); `test_mobile_hamburger_nav.py` real-click conversion + new `test_anonymous_toggle_tappable_with_gate_up`; new `tests/unit/test_gate_header_stacking.py` (3 pins); `test_api_tokens.py` untouched
- `uv run pytest` → 1717 passed, 1 warning (exit 0)
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%)
- E2E in isolation: `test_mobile_hamburger_nav.py` **8 passed**; `test_api_tokens.py` **9 passed** (unchanged); `test_smoke.py` **3 passed**; `test_document_viewer.py` **7 passed**
- `uv run ruff check . && uv run pyright` → clean / 0 errors
- Live probe (375×812, anonymous, fresh server): on `/` and `/history.html` `elementFromPoint` at toggle → SVG `path`, never `#auth-gate`; real click opens menu (`aria-expanded=true`); exactly one visible nav link (Chat) + sign-in copy; Chat link topmost over gate; `#main` inert — criteria 1, 2, 3 confirmed directly
- Criteria: (1) real-click menu on / + /history ✅ (2) dropdown above gate ✅ (3) anonymous contents + #main inert ✅ (4) admin byte-identical (phase-46 tests green) ✅ (5) doc-viewer gate under bar (CSS pin + doc-viewer suite) ✅ (6) full gate green ✅ (7) diff scoped to the 5 code files + new unit test + phase files, nothing in `app/` ✅ (8) commit + todo→complete move left to harness per executor rules (task files already in `complete/`)
- Note: `.agents/remediation_plan.md` (untracked) is a pre-existing artifact of the earlier security audit — left untouched; a missing "N passed" line seen mid-pass was my own `-q`+addopts `-q` = `-qq` quirk, not a defect
- Next pending phase: `86_history_page_width`
This commit is contained in:
2026-09-08 01:56:37 -04:00
parent f4150421bb
commit 412a560348
24 changed files with 692 additions and 57 deletions
+133 -39
View File
@@ -15,6 +15,14 @@ visibility contract intact INSIDE the menu; at >640px the bar is
byte-identical to pre-phase-46 (hamburger absent, inline pills). No
document is ever needed — the suite exercises the shared header only.
Phase 85 (TODO.md L3): the anonymous visitor meets the token gate —
but the gate (z-index 15) now sits BELOW the sticky header (z 20), so
a REAL tap on ``#nav-toggle`` reaches the toggle for anonymous
visitors exactly as for admin: every test in this suite drives the
menu with real clicks (the phase-79 JS-dispatched workaround for the
gate-overlay interception is retired — the gate covers only ``#main``,
which stays ``inert``; it no longer covers the header).
The conftest ``page`` fixture is 1280×800, so the mobile tests create
fresh 375×812 pages via the session ``browser`` fixture (one page per
test; the reduced-motion test gets its own context).
@@ -29,20 +37,24 @@ Test → story mapping (Playwright Mapping Rule):
2. ``test_anonymous_menu_contents`` — anonymous at 375px: the menu
shows EXACTLY one visible link ("Chat"); the three admin-only links
stay ``hidden`` inside the menu; the open flips ``aria-expanded``.
3. ``test_admin_menu_contents`` — admin at 375px: the menu shows all
3. ``test_anonymous_toggle_tappable_with_gate_up`` — the TODO.md L3
regression pin: anonymous toggle tappable with the gate up —
``elementFromPoint`` at the toggle center never ``#auth-gate``,
real click opens the menu above the gate.
4. ``test_admin_menu_contents`` — admin at 375px: the menu shows all
four links (the whoami reveal works inside the menu).
4. ``test_link_click_navigates_and_closes`` — admin at 375px: clicking
5. ``test_link_click_navigates_and_closes`` — admin at 375px: clicking
"RAG" navigates to /sources.html and the menu on the arrival
page ships closed.
5. ``test_esc_and_outside_close`` — Esc closes AND returns focus to the
6. ``test_esc_and_outside_close`` — Esc closes AND returns focus to the
toggle; an outside click does NOT close (accepted — see the test
docstring for why).
6. ``test_animation_and_reduced_motion`` — motion allowed: the
7. ``test_animation_and_reduced_motion`` — motion allowed: the
180ms opacity/transform transition pair is live and the open flips
class + aria; ``reducedMotion: "reduce"``: no transition in EITHER
state (the .is-open state included — the specificity trap) and
open/close still works.
7. ``test_desktop_unchanged`` — 1280×800 regression: the hamburger is
8. ``test_desktop_unchanged`` — 1280×800 regression: the hamburger is
``display: none`` and the inline nav renders in the bar exactly as
before (admin: all four links, all inside the header band).
"""
@@ -105,6 +117,13 @@ def _visible_nav_links(page: Page) -> list[str]:
def _open_menu(page: Page) -> None:
"""Open the menu with a REAL click on the toggle.
Phase 85 (TODO.md L3): the gate (z-index 15) sits BELOW the sticky
header (z 20), so a real tap reaches the toggle for anonymous
visitors too — the phase-79 JS-dispatched workaround (the gate then
covered the header at z 500 and intercepted the click) is retired,
and every test in this suite uses this one real-click helper."""
page.click("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
# to_have_class(string) is an EXACT match on the class attribute —
@@ -113,20 +132,6 @@ def _open_menu(page: Page) -> None:
expect(page.locator("#app-nav")).to_have_css("opacity", "1")
def _js_open_menu(page: Page) -> None:
"""Phase 79 (task 05): the in-app token gate is a full-viewport
overlay for ANONYMOUS visitors — it physically covers the header,
so a real click on #nav-toggle is intercepted by the gate (the gate
is the only interactive surface; the header is locked out with the
rest of the page). The binding is identical, so the menu contract
is driven programmatically: a JS-dispatched click runs the exact
same listener a real click would."""
page.evaluate("() => document.querySelector('#nav-toggle').click()")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
expect(page.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b"))
expect(page.locator("#app-nav")).to_have_css("opacity", "1")
def _assert_menu_closed(page: Page) -> None:
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
assert "is-open" not in (page.locator("#app-nav").get_attribute("class") or ""), (
@@ -205,17 +210,17 @@ def test_anonymous_menu_contents(
preserved by reusing the same <nav> element); opening flips
aria-expanded true.
Phase 79 (task 05): the anonymous visitor meets the token gate — a
full-viewport overlay that covers the header — so the toggle is
driven programmatically (the binding is identical; see
_js_open_menu)."""
Phase 85 (TODO.md L3): the anonymous visitor meets the token gate
— but the gate (z 15) now sits BELOW the header (z 20), so the
toggle is a real, tappable target and the menu is driven with a
real click exactly as for admin."""
page = _mobile_page(browser)
try:
page.goto(app_url)
_wait_settled_anonymous(page)
_assert_menu_closed(page)
_js_open_menu(page)
_open_menu(page)
assert _visible_nav_links(page) == ["Chat"], (
"anonymous: the menu must show exactly one visible link (Chat)"
)
@@ -223,7 +228,96 @@ def test_anonymous_menu_contents(
expect(page.locator(sel)).to_be_hidden()
# A second click closes it again — aria-expanded round-trips.
page.evaluate("() => document.querySelector('#nav-toggle').click()")
page.click("#nav-toggle")
_assert_menu_closed(page)
finally:
page.close()
def test_anonymous_toggle_tappable_with_gate_up(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""TODO.md L3 regression pin (phase 85): the anonymous visitor meets
the token gate, but the gate (z-index 15) sits BELOW the sticky
header (z 20) — so the toggle is a REAL, tappable target:
``document.elementFromPoint`` at the toggle center resolves to the
toggle itself (or its SVG child), NEVER ``#auth-gate`` (the exact
probe that returned the gate in the bug reproduction), and a real
click opens the menu ABOVE the gate — the "Chat" link visible with
the gate still up (the dropdown's z context, 21 inside the
header's 20, paints over the gate's 15). The lock is untouched:
``#main`` stays ``inert``. Leaves the page clean — Esc settles the
menu closed."""
page = _mobile_page(browser)
try:
page.goto(app_url)
_wait_settled_anonymous(page)
# The gate is up and the app is locked (the phase-79 contract)…
expect(page.locator("#auth-gate")).to_be_visible(timeout=30_000)
assert page.evaluate("() => document.getElementById('main').inert === true"), (
"the gate must keep #main inert while it is up"
)
# …but the probe at the toggle center no longer resolves to the
# gate: it hits the toggle itself or a descendant of it (the
# SVG path) — the exact probe that returned #auth-gate in the
# bug reproduction (TODO.md L3).
probe = page.evaluate(
"""() => {
const t = document.querySelector('#nav-toggle');
const r = t.getBoundingClientRect();
const el = document.elementFromPoint(
r.left + r.width / 2, r.top + r.height / 2);
return {
id: el ? el.id : null,
tag: el ? el.tagName.toLowerCase() : null,
toggle_or_descendant: el !== null && t.contains(el),
};
}"""
)
assert probe["id"] != "auth-gate", (
f"the toggle center must not resolve to the gate, "
f"got <{probe['tag']} id={probe['id']!r}> (the phase-79 bug)"
)
assert probe["toggle_or_descendant"], (
f"the toggle center must resolve to the toggle or its SVG "
f"child, got <{probe['tag']} id={probe['id']!r}>"
)
# A REAL click opens the menu (the owner's exact scenario —
# "clicking it … does not expand the menu" is fixed), and the
# menu is ABOVE the gate: with the gate still up the
# dropdown's "Chat" link is visible, and the topmost element at
# its center is the link (or a descendant), never #auth-gate.
_open_menu(page)
expect(page.locator("#auth-gate")).to_be_visible()
chat = page.locator("#app-nav a[href='/']")
expect(chat).to_be_visible()
link_probe = page.evaluate(
"""() => {
const a = document.querySelector("#app-nav a[href='/']");
const r = a.getBoundingClientRect();
const el = document.elementFromPoint(
r.left + r.width / 2, r.top + r.height / 2);
return {
id: el ? el.id : null,
tag: el ? el.tagName.toLowerCase() : null,
link_or_descendant: el !== null && a.contains(el),
};
}"""
)
assert link_probe["id"] != "auth-gate", (
f"the open dropdown must paint ABOVE the gate, the Chat "
f"link center resolved to the gate ({link_probe!r})"
)
assert link_probe["link_or_descendant"], (
f"the Chat link must be the topmost target at its center, "
f"got <{link_probe['tag']} id={link_probe['id']!r}>"
)
# Leave the page clean: Esc settles it closed.
page.keyboard.press("Escape")
_assert_menu_closed(page)
finally:
page.close()
@@ -303,11 +397,10 @@ def test_esc_and_outside_close(
page.goto(app_url)
_wait_settled_anonymous(page)
# Phase 79 (task 05): the anonymous visitor's toggle click is
# intercepted by the gate overlay — drive the identical binding
# programmatically (see _js_open_menu).
# Phase 85 (TODO.md L3): the gate (z 15) sits below the header
# (z 20) — the real click reaches the anonymous toggle too.
# Esc closes + refocuses the opener.
_js_open_menu(page)
_open_menu(page)
page.keyboard.press("Escape")
_assert_menu_closed(page)
assert page.evaluate("() => document.activeElement.id") == "nav-toggle", (
@@ -316,10 +409,11 @@ def test_esc_and_outside_close(
# Outside click: the menu STAYS open (accepted behavior — the
# locked close set is Esc + link + resize, not backdrop click).
# Phase 79 (task 05): for the anonymous visitor the "outside"
# point is the gate overlay itself — a REAL mouse click below
# the centered card (outside the nav, intercepted by the gate).
_js_open_menu(page)
# Phase 85: for the anonymous visitor the "outside" point is
# the gate overlay itself — a REAL mouse click below the
# centered card, outside the nav (the gate covers the content
# but no longer the header; it carries no close listener).
_open_menu(page)
page.mouse.click(10, 780) # the gate overlay — a neutral, non-nav point
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
assert "is-open" in (page.locator("#app-nav").get_attribute("class") or ""), (
@@ -364,9 +458,9 @@ def test_animation_and_reduced_motion(
f"got {report['property']!r}"
)
# Opening flips class + aria together (the animated state).
# Phase 79 (task 05): the anonymous toggle click is intercepted
# by the gate overlay — programmatic drive, same binding.
_js_open_menu(page)
# Phase 85: the gate sits below the header — the real click
# reaches the anonymous toggle.
_open_menu(page)
page.keyboard.press("Escape")
_assert_menu_closed(page)
finally:
@@ -389,9 +483,9 @@ def test_animation_and_reduced_motion(
assert _stilled("#app-nav") == "0s", (
f"reduced motion: closed state must not transition, got {_stilled('#app-nav')!r}"
)
# Phase 79 (task 05): programmatic drive (the gate overlay
# intercepts the anonymous real click — same binding).
_js_open_menu(rpage)
# Phase 85: real drive (the gate no longer intercepts the
# anonymous click — it sits below the header).
_open_menu(rpage)
expect(rpage.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
expect(rpage.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b"))
assert _stilled("#app-nav") == "0s", (