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", (
+126
View File
@@ -0,0 +1,126 @@
"""Unit: the gate-below-header z-order contract (phase 85, task 01).
TODO.md L3 (owner bug report, 2026-09-07): "Only on the chat page, and
only when navigating there directly, does the hamburger menu on mobile
not work. … This makes the menu inaccessible on mobile."
Bug basis (confirmed by live reproduction): the phase-79 token gate
ships as a full-viewport overlay at ``z-index: 500`` — ABOVE the sticky
``.app-header`` (20) — so for an unauthenticated visitor
``document.elementFromPoint`` at the ``#nav-toggle`` center returned
``#auth-gate`` in every shell view: a real tap on the hamburger was
intercepted by the overlay and the menu could never open. (The chat
page was just the entry point; the gate broke the menu on every view.
``login.html`` has no gate, which is why the owner saw it work "when
logging in".)
The fix is ONE CSS value (owner decision A1): ``.auth-gate`` moves to
``z-index: 15`` — above all app content (static) but BELOW the sticky
header (20) and its mobile dropdown (21, inside the header's stacking
context). The gate still covers and locks the app content (``#main``
stays ``inert`` — the lock is JS, not z-order — phase 79); only the
header's VISUAL lock is lifted. This module pins the three z-order
values at source level (the house pattern of
``tests/unit/test_hamburger_nav.py``: read ``styles.css`` as text, no
browser) so the regression that put the gate at 500 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 helpers below must never see them)."""
return re.sub(r"/\*.*?\*/", "", _text(STYLES_CSS), flags=re.S)
def _media_block(css: str, query: str) -> str:
"""The full text of the FIRST ``@media <query>`` block (brace-matched,
nested rules included verbatim)."""
m = re.search(re.escape(query) + r"[^{]*\{", css)
assert m, f"missing {query!r} media query in styles.css"
depth = 0
for i in range(m.end() - 1, len(css)):
if css[i] == "{":
depth += 1
elif css[i] == "}":
depth -= 1
if depth == 0:
return css[m.start() : i + 1]
raise AssertionError(f"unbalanced braces in {query!r} media block")
def _rule_block(css: str, selector: str) -> str:
"""The first rule body for ``selector`` (e.g. ``.auth-gate``). The
``\\s*\\{`` requires the opening brace right after the selector, so a
decorated variant (``.auth-gate[hidden]``) is never matched."""
m = re.search(r"(?<![\w-])" + re.escape(selector) + r"\s*\{([^}]*)\}", css)
assert m, f"missing rule for {selector!r}"
return m.group(1)
def _z_index(block: str) -> int:
m = re.search(r"z-index:\s*(\d+)", block)
assert m, "the rule must carry an explicit z-index"
return int(m.group(1))
def test_auth_gate_sits_below_the_sticky_header() -> None:
"""THE regression pin: the .auth-gate overlay carries z-index 15 —
below the sticky .app-header (20), so taps on the bar (and the
hamburger) reach the bar for the unauthenticated visitor instead of
the overlay (TODO.md L3). The old 500 (above the header) must not
return."""
css = _css()
gate = _z_index(_rule_block(css, ".auth-gate"))
header = _z_index(_rule_block(css, ".app-header"))
assert gate == 15, f".auth-gate must be pinned at z-index 15 (found {gate})"
assert header == 20, f".app-header must keep z-index 20 (found {header})"
assert gate < header, (
"the gate must sit BELOW the header — otherwise the overlay "
"intercepts the #nav-toggle again (the phase-79 bug)"
)
def test_auth_gate_still_covers_the_app_content() -> None:
"""The lock is JS, not z-order (phase 79): the rest of the overlay
contract is untouched — body-level fixed full-viewport cover
(position:fixed + inset:0), and the [hidden] state stays
display:none (the ship-hidden skeleton contract)."""
css = _css()
block = _rule_block(css, ".auth-gate")
assert "position: fixed" in block, ".auth-gate must stay position:fixed"
assert "inset: 0" in block, ".auth-gate must still cover the full viewport"
hidden = _rule_block(css, ".auth-gate[hidden]")
assert "display: none" in hidden, (
".auth-gate[hidden] must stay display:none (ship-hidden)"
)
def test_mobile_dropdown_stays_above_the_gate() -> None:
"""The mobile dropdown (.app-nav, ≤640px — the z-index lives on the
base rule, shared by the closed and the .is-open states) keeps
z-index 21 = header + 1 (the phase-46 relationship pinned in
test_hamburger_nav): inside the header's stacking context the open
menu paints above the bar content AND above the gate (15)."""
css = _css()
mobile = _media_block(css, "@media (max-width: 640px)")
assert _z_index(_rule_block(mobile, ".app-nav")) == 21, (
"the mobile .app-nav must keep z-index 21 (header 20 + 1)"
)
# The header (20) itself is global (outside media queries) — the
# ≤640px block restyles it but never changes its stacking level,
# so the dropdown's 21 stays the header's own +1 on mobile too.
assert _z_index(_rule_block(mobile, ".app-nav")) > 15, (
"the open menu must paint above the gate (21 > 15)"
)