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:
@@ -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", (
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
Reference in New Issue
Block a user