"""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 `` 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"(? 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)" )