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
+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)"
)