"""Unit: the phase-88 mobile hamburger cold-boot contract (source-level pins). Owner bug report 2026-09-08 (continuation of the phase-85 report): after the phase-76 SPA migration, the mobile hamburger (``#nav-toggle``) is DEAD on the chat page — only on a fresh load / refresh of ``/`` (the cold boot with the chat view visible from the first frame) — on two real Android phones (cache cleared, production). A fresh ``/sources.html`` boot, any client-side switch into chat, and the login page all work; rotation, pinch-zoom, and scrolling do not heal the dead state. No spec-compliant Chromium repro exists (real-touch Playwright probes at 360–412px, both auth states: the toggle is always hit-testable and a touch tap always opens the menu) — the failure lives in the real devices' touch→click / compositor pipeline, and an exhaustive code audit found nothing in the app that can swallow the click (no touch listeners, no click-eating document handlers, no ``touch-action`` anywhere). So the phase removes ALL candidate mechanisms with standard, safe changes. Locked decision A1 (belt-and-suspenders, not a single mechanism — the internal device path is not provable from the dev machine): the independent removals ship TOGETHER — task 01 ``touch-action: manipulation`` on the mobile toggle, task 02 the composer cluster's sticky layer deferred out of the first layout commit (the ``.chat-booted`` gate), task 03 the cluster hidden while the menu is open (``body.nav-menu-open``) — plus task 04's router boot-refresh contract repair. Each is byte-identical at rest or off-mobile (A2); the owner's on-device re-verification (A4) is the phase's final gate. This module pins the source-level contract, task by task (house source-level pattern — the asset files are read as text, no browser; the live behavior is E2E-gated by ``tests/e2e/test_mobile_chat_hamburger_boot.py``, task 05). Task 01 pins the toggle's ``touch-action`` and task 02 the deferred sticky handover below; tasks 03/04 extend this module (the shared asset readers are already in place). """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" ASSETS = FRONTEND / "assets" STYLES_CSS = ASSETS / "styles.css" APP_JS = ASSETS / "app.js" HEADER_JS = ASSETS / "header.js" ROUTER_JS = ASSETS / "router.js" #: The phase's mobile breakpoint — the same query the hamburger's #: display rule and the menu's dropdown rules live in. MOBILE_QUERY = "@media (max-width: 640px)" def _text(path: Path) -> str: assert path.is_file(), f"missing frontend file: {path}" return path.read_text(encoding="utf-8") def _styles() -> str: """styles.css, raw (declaration counts are pinned against the raw file — the contract is "exactly N occurrences in the shipped file", comments included).""" return _text(STYLES_CSS) def _app_js() -> str: return _text(APP_JS) def _header_js() -> str: return _text(HEADER_JS) def _router_js() -> str: return _text(ROUTER_JS) def _css() -> str: """styles.css with comments stripped (a comment may legally carry braces — the brace-matching helpers below must never see them; house pattern, cf. test_hamburger_nav.py).""" return re.sub(r"/\*.*?\*/", "", _styles(), 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 _global_css(css: str) -> str: """The rules OUTSIDE any @media block (the desktop baseline), in file order (house pattern, cf. test_hamburger_nav.py).""" out: list[str] = [] pos = 0 while True: m = re.search(r"@media[^{]*\{", css[pos:]) if not m: out.append(css[pos:]) break start = pos + m.end() - 1 # the @media's own opening brace depth = 0 i = start while i < len(css): if css[i] == "{": depth += 1 elif css[i] == "}": depth -= 1 if depth == 0: break i += 1 out.append(css[pos:start]) pos = i + 1 return "".join(out) def _rule_block(css: str, selector: str) -> str: """The first rule body for ``selector`` (e.g. ``.nav-toggle``).""" m = re.search(re.escape(selector) + r"[^{}]*\{([^}]*)\}", css) assert m, f"missing rule for {selector!r}" return m.group(1) # ---------- task 01: touch-action on the mobile nav toggle ---------- def test_mobile_nav_toggle_carries_touch_action_manipulation() -> None: """Phase 88 A1, removal (1): the ≤640px ``.nav-toggle`` rule (the 44px button — the base rule is ``display: none`` on desktop) gains the standard dead-mobile-button fix, ``touch-action: manipulation``. The viewport meta keeps zoom allowed (``initial-scale=1``, no ``maximum-scale`` lock — WCAG), so every touch on the page goes through the browser's tap/zoom disambiguation window; on this control the window is what the device data points at (a tap that never resolves to a click on a cold-booted chat page). ``manipulation`` removes double-tap-to-zoom, pinch, and the disambiguation delay from THIS control's touch pipeline only — a (drifting) tap resolves to a click fastest. Desktop is untouched (the toggle does not exist outside the ≤640px block).""" mobile = _media_block(_css(), MOBILE_QUERY) assert re.search( r"\.nav-toggle\s*\{[^}]*touch-action:\s*manipulation[^}]*\}", mobile, re.S ), ( "the ≤640px .nav-toggle rule must carry 'touch-action: manipulation' " "(phase 88 task 01 — the standard dead-mobile-button fix)" ) def test_touch_action_is_mobile_only_on_the_toggle() -> None: """A2 — the mobile-only surface contract: the fix is scoped to the ONE control the bug report names. The ENTIRE styles.css carries exactly ONE ``touch-action`` declaration (no page-wide touch-action — the rest of the page keeps the browser's normal touch/zoom pipeline, and desktop is byte-identical at rest), and the BASE ``.nav-toggle`` rule (the ``display: none`` desktop baseline outside any media query) carries none of it.""" raw = _styles() assert raw.count("touch-action") == 1, ( f"exactly ONE 'touch-action' declaration in styles.css — " f"the mobile toggle's fix (found {raw.count('touch-action')})" ) base = _rule_block(_global_css(_css()), ".nav-toggle") assert "touch-action" not in base, ( "the BASE .nav-toggle rule (desktop, display:none) must NOT " "gain touch-action (A2 — the fix is mobile-only)" ) # ---------- task 02: defer the cluster's sticky until boot ---------- def test_chat_booted_gate_rule_statics_the_cluster_pre_boot() -> None: """Phase 88 A1, removal (2): the first-commit sticky layer. The dead state is EXACTLY "shell cold boot with the chat view visible from the first frame" — the only element present there and absent from EVERY working state (fresh ``/sources.html`` boot, any client-side switch into chat, login) is the sticky ``.chat-bottom`` cluster (and the doubly-sticky ``#composer`` inside it) committed in the first layout. A ``position: sticky`` element is promoted to a compositor layer at commit; the owner's phone accepts the identical layer when it is born on a SETTLED page. The gate rule keeps the cluster static until app.js adds ``.chat-booted`` two frames after boot settles — the id-scoped selector outranks the two class rules, and at rest (``.chat-booted`` present) it matches nothing, so both computed styles are byte-identical to today (A2).""" css = _css() assert re.search( r"#view-chat:not\(\.chat-booted\)\s*\.chat-bottom,\s*" r"#view-chat:not\(\.chat-booted\)\s*\.composer\s*\{\s*" r"position:\s*static\s*;?\s*\}", css, re.S, ), ( "styles.css must carry the phase-88 boot gate — " "#view-chat:not(.chat-booted) .chat-bottom AND .composer are " "static until app.js adds .chat-booted (the sticky compositor " "layer is born after the boot paint, not in the first layout " "commit)" ) def test_original_cluster_sticky_rules_are_untouched() -> None: """The gate is ADDITIVE (A2): the two original rules — ``.chat-bottom`` and ``.composer``, independently ``position: sticky`` since the phase-65 sticky-composer contract — keep their sticky pair; the at-rest layout is byte-identical, pinned here and by test_pinned_composer.py (+ its E2E).""" css = _css() # LINE-ANCHORED: the gate rule (placed between the two original rules) # carries `.chat-bottom` / `.composer` in its OWN selector list — only # the original rules start their selector line with the bare class # (house pattern, cf. test_pinned_composer.py::_rule). bottom = re.search(r"^\.chat-bottom \{\n([\s\S]*?)\n\}", css, re.MULTILINE) composer = re.search(r"^\.composer \{\n([\s\S]*?)\n\}", css, re.MULTILINE) assert bottom and composer, ( "the original .chat-bottom / .composer rule blocks must exist" ) assert "position: sticky;" in bottom.group(1), ( "the .chat-bottom wrapper must KEEP its phase-65 sticky pair " "(the gate defers it pre-boot only — the at-rest rule is " "untouched)" ) assert "position: sticky;" in composer.group(1), ( "the .composer form must KEEP its own sticky pair (redundant " "inside the wrapper, pinned for the computed-style contract)" ) def test_chat_booted_flag_lands_exactly_once_after_boot_settles() -> None: """The handover: app.js's boot IIFE adds ``chat-booted`` EXACTLY once — the double ``requestAnimationFrame`` sitting AFTER ``loadHealth();`` (still inside the IIFE). Two frames: frame 1 paints the settled boot (the empty state, or the synchronously re-rendered restored conversation) with the cluster static; frame 2 pins it — one frame would fold the sticky back into the first layout commit for the restore case (the rAF callback runs before that frame's layout). A pre-settle throw leaves the cluster static — a degraded boot is already degraded (the gate/header above it), and the hamburger binding lives in header.js's explicit init (bindSharedHeaderControls), so it is unaffected either way (documented in the house comment, do not "fix").""" js = _app_js() assert js.count("chat-booted") == 1, ( f"app.js must reference chat-booted EXACTLY once — the single " f"double-rAF boot handover (found {js.count('chat-booted')})" ) assert re.search( r"requestAnimationFrame\(\(\)\s*=>\s*requestAnimationFrame\(\(\)\s*=>\s*\{?\s*" r'document\.getElementById\("view-chat"\)\?\.classList\.add\("chat-booted"\)', js, re.S, ), ( "the boot handover must be the double requestAnimationFrame " "pattern (frame 1 paints the settled boot with the cluster " "static, frame 2 pins it)" ) assert js.index("chat-booted") > js.index("loadHealth();"), ( "the flag must land AFTER loadHealth(); inside the boot IIFE — " "the settled boot (restore + suggestions + health) is what " "frame 1 paints" ) # ---------- task 03: hide the cluster while the menu is open ---------- def _function_body(js: str, signature: str) -> str: """The full text of a top-level function — from ``signature`` to its brace-matched closing ``}`` (setNavMenu's body carries no nested braces, so the brace count is exact for it).""" start = js.index(signature) i = js.index("{", start) depth = 0 for j in range(i, len(js)): if js[j] == "{": depth += 1 elif js[j] == "}": depth -= 1 if depth == 0: return js[start : j + 1] raise AssertionError(f"unbalanced braces after {signature!r}") def test_nav_menu_open_marker_is_set_exactly_once_in_set_nav_menu() -> None: """Phase 88 A1, removal (3): the chat's sticky bottom cluster is the OTHER positioned/layered element on the page — while the menu is open it competes for taps, and on short viewports it overlaps the menu's lower rows (measured: menu y58→417 vs cluster top y395 at 390×600). ``setNavMenu`` is the single choke point for EVERY open/close path (the toggle click, the link click, Esc, outside-click, the 640px-media close), so the ``body.nav-menu-open`` marker rides on it and can never stick. Exactly one occurrence in header.js — and it sits inside the setNavMenu function body.""" js = _header_js() assert js.count("nav-menu-open") == 1, ( f"header.js must mention nav-menu-open EXACTLY once — the single " f"body-class toggle inside setNavMenu (found {js.count('nav-menu-open')})" ) body = _function_body(js, "function setNavMenu") assert "nav-menu-open" in body, ( "the body.nav-menu-open marker must be set INSIDE setNavMenu — " "the single choke point every open/close path funnels through " "(click / link / Esc / outside-click / media)" ) def test_nav_menu_open_hides_the_cluster_in_the_mobile_block_only() -> None: """The CSS half: ``body.nav-menu-open .chat-bottom { visibility: hidden }`` lives INSIDE the ``@media (max-width: 640px)`` block, next to the .app-nav dropdown rules. ``visibility`` (NOT ``display``): layout is preserved, so closing the menu never reflows the chat column and the sticky pin's position is stable for the moment the menu closes. Scoped to the mobile block — at >640px the toggle is ``display: none``, setNavMenu never opens the menu, and even if the marker were set the rule does not exist there (A2 — desktop and the resting state are untouched). Exactly one occurrence in styles.css (the rule only — no stray coupling). (The task's spec regex gains ``;?`` — house CSS style ends the declaration with a semicolon, cf. task 02's ``position: static;`` pin.)""" raw = _styles() assert raw.count("nav-menu-open") == 1, ( f"styles.css must mention nav-menu-open EXACTLY once — the single " f"≤640px rule (found {raw.count('nav-menu-open')})" ) mobile = _media_block(_css(), MOBILE_QUERY) assert re.search( r"body\.nav-menu-open\s*\.chat-bottom\s*\{\s*visibility:\s*hidden;?\s*\}", mobile, re.S, ), ( "the ≤640px block must hide .chat-bottom while body.nav-menu-open " "(visibility: hidden — layout preserved, no reflow on close)" ) # ---------- task 04: the router's boot-refresh contract ---------- def test_boot_show_guard_excludes_the_boot_flag() -> None: """Phase 88 A3 (the boot contract is code, not comment): the documented router contract says the first show (the mount) AND boot never fire ``bor:view-refresh`` — but ``mounted.chat`` starts true (app.js pre-mounts the chat view), so a cold boot's ``switchTo("chat")`` was hitting the ``wasMounted`` branch and dispatching the refresh on ``#view-chat`` at boot. Harmless today (no view listens on the chat root — the phase-77 exclusion — and no lazy module mounts on a ``/`` boot), but a future listener added to the chat view would fire at boot and could resurrect exactly this bug class. The code now matches the contract: the dispatch guard is ``wasMounted && !opts.boot`` — the explicit ``boot`` flag (A3: no module-level flag state, no race with an in-flight boot import) is the ONLY thing that exempts a show, and it rides the opts object already carried by every call site (every non-boot site has ``opts.boot === undefined`` → dispatches exactly as before — the signature pin below keeps it an opts parameter, not a destructured shorthand or a module flag). The old unconditional ``if (wasMounted) {`` guard is gone — its exact text is what the pin excludes (the new guard reads ``if (wasMounted && !opts.boot) {``).""" js = _router_js() assert re.search( r"if\s*\(\s*wasMounted\s*&&\s*!opts\.boot\s*\)\s*\{\s*" r"root\.dispatchEvent\(\s*new\s+CustomEvent\(\"bor:view-refresh\"\)", js, re.S, ), ( "the refresh-dispatch guard must be 'wasMounted && !opts.boot' " "(phase 88 — the boot show never fires the refresh; a re-show " "still does)" ) assert "async function switchTo(name, opts = {})" in js, ( "switchTo must take the opts object (defaulting to {}) — the " "guard reads opts.boot, so a module-level flag or a " "destructured shorthand would break the contract (A3)" ) assert "if (wasMounted) {" not in js, ( "the old UNCONDITIONAL wasMounted guard must be gone — it is " "what let the cold boot dispatch the refresh on the " "pre-mounted chat view" ) def test_boot_flag_is_carried_only_by_the_boot_call_site() -> None: """The flag's scope (the pin the task spec states as "the popstate/ nav paths never get the flag"): ``boot: true`` is carried by the boot call site ONLY — ``switchTo(bootName, { userInitiated: false, boot: true })`` at the bottom of the module — and by NOTHING else. The two user-initiated call sites (the nav-click handler and the popstate listener) keep the bare ``{ userInitiated: true }`` opts, and the flag's occurrence sits AFTER the popstate listener (i.e. in the boot section — not smuggled into an earlier path). DEVIATION FROM THE TASK SPEC, documented per house pattern (cf. tasks 01–03's ``;?`` pins): the task pins ``js.count("boot: true") == 2`` "the two boot call sites" (the overview names a ``switchTo(history.state.view, { push: false })`` branch beside ``switchTo(bootName, { push: false })``). Those two call sites do not exist in this codebase — the boot section is the two-liner ``const bootName = VIEW[window.location.pathname] ?? "chat"; switchTo(bootName, { userInitiated: false });`` (verified against git HEAD; nothing in the module reads ``history.state``, and switchTo performs NO pushState — that is the click handler's job, pinned in test_frontend_router.py, so a ``push`` option would be a dead no-op). A second flag occurrence would require fabricating dead code — the opposite of A3's "no behavior change for any later show" — so the count pin is 1: the flag rides the ONE boot call site, never the popstate/nav paths (their bare-opts count is pinned too).""" js = _router_js() assert js.count("boot: true") == 1, ( f"router.js must carry 'boot: true' EXACTLY once — the single " f"boot call site; popstate and nav-click never get the flag " f"(found {js.count('boot: true')})" ) assert 'switchTo(bootName, { userInitiated: false, boot: true })' in js, ( "the boot call site must carry the flag inline — " "switchTo(bootName, { userInitiated: false, boot: true })" ) # The flag sits in the BOOT section — after the popstate listener # (an earlier occurrence would mean a user-initiated path carries # it, which would silently skip its refresh). assert js.index("boot: true") > js.index('window.addEventListener("popstate"'), ( "the boot flag must sit in the boot section (after the " "popstate listener) — never in the nav-click or popstate path" ) assert js.count("switchTo(name, { userInitiated: true })") == 2, ( "the nav-click and popstate call sites must keep the bare " "{ userInitiated: true } opts — no boot flag on any " "user-initiated path (exactly two such call sites)" )