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:
@@ -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