"""Phase 88 E2E (Playwright): the mobile hamburger on the chat page's COLD BOOT — the state the phase-46 suite never covers. Owner bug report 2026-09-08 (continuation of the phase-85 report; the phase overview's Bug basis is the trace): after the phase-76 SPA migration, the mobile hamburger (``#nav-toggle``) is DEAD on the chat page on two real Android phones (cache cleared, production) — only on a FRESH LOAD / REFRESH of ``/``: the shell cold boot with the chat view visible from the FIRST frame (in the empty state the document is exactly 100dvh). A fresh load of ``/sources.html`` works, any client-side switch into chat works, and the login page works; rotation, pinch-zoom, and scrolling do not heal the dead state. The failure lives in the real devices' touch→click / compositor pipeline — no spec-compliant Chromium repro exists (real-touch probes at 360–412px, both auth states: the toggle is always hit-testable and a tap opens the menu). The phase removes EVERY surviving candidate mechanism (owner decision A1 — belt-and-suspenders; the owner's on-device re-verification, A4, gates the archive): * (1) ``touch-action: manipulation`` on the toggle (task 01) — the standard dead-mobile-button fix: removes the double-tap-zoom / pinch / tap-disambiguation window from THIS control's touch pipeline; * (2) the composer cluster's sticky compositor layer deferred OUT of the first layout commit behind ``#view-chat.chat-booted`` (task 02) — app.js adds the class two frames after the boot settles, so the layer is born AFTER the boot paint (the "born on a settled page" condition the owner's phone already accepts); * (3) the cluster hidden while the menu is open — the ``body.nav-menu-open`` marker from setNavMenu (task 03) + the ≤640px ``visibility: hidden`` rule; * (4) the router's boot-refresh contract (task 04) — a cold boot must dispatch NO ``bor:view-refresh`` (``mounted.chat`` starts true, so the boot show hit the wasMounted branch and refreshed the pre-mounted chat view; the phase-77 contract says the first show and boot never fire it). Two differences from the phase-46 suite (``tests/e2e/test_mobile_hamburger_nav.py``) are the whole point of this one: * **Real touch.** The phase-46 suite drives the menu with ``page.click`` (mouse). THIS suite creates every page in a ``has_touch=True`` 360×800 context (the narrower edge of the owner's devices; 1 page per test, closed in ``finally``) and drives the toggle and the menu rows with ``page.tap`` — the touch→click pipeline the bug lives in. * **The cold-boot state.** Every test starts from a FRESH navigation (the form-login redirect to ``next`` is a real document load) — never a client-side switch — so the chat view (or, in test 4, the RAG view) is visible from the first frame: the dead state. Run in isolation (mock LLM; DB up: ``podman compose up -d db``): uv run pytest tests/e2e/test_mobile_chat_hamburger_boot.py -v --no-cov Test → story mapping (Playwright Mapping Rule): 1. ``test_chat_boot_touch_action_and_sticky_handover`` — fresh admin load of ``/``: the toggle's computed ``touch-action`` is ``manipulation`` (removal 1), and ``#view-chat`` carries ``chat-booted`` with ``.chat-bottom`` AND ``#composer`` computed ``position`` ``sticky`` (removal 2 — the handover landed at rest; if the flag never lands, the gate rule keeps the cluster ``static`` and this fails). 2. ``test_hamburger_tap_opens_menu_and_hides_cluster`` — a real touch tap on the toggle opens the menu (``aria-expanded="true"``, ``.is-open``) and, while open, ``.chat-bottom`` computed ``visibility`` is ``hidden`` (removal 3, end-to-end); a touch tap on the "Tuning" row navigates (``/tuning.html``, ``#view-tuning`` visible) and the menu closes again (``aria-expanded="false"``, no ``.is-open``, the ``nav-menu-open`` marker gone from the body) with ``.chat-bottom`` back to ``visibility: visible``. 3. ``test_boot_does_not_fire_view_refresh`` — a pre-navigation hook on ``Element.prototype.dispatchEvent`` counts every ``bor:view-refresh`` dispatch (per document — the login hop and the ``/`` redirect each re-init the counter, while the pushState view switches are same-document): the cold boot of ``/`` fires NONE (removal 4 — pre-phase code fired one on the pre-mounted chat view), and the RE-SHOW of chat (History → Chat) fires EXACTLY ONE (the phase-77 contract is preserved, not deleted). 4. ``test_sources_boot_regression`` — a fresh 360px load of ``/sources.html`` (the rag boot the cluster change must NOT disturb — ``#view-rag`` mounts lazily via ``sources.js``): the touch tap still opens the menu, and the "Chat" row lands on the chat view with ``.chat-bottom`` computed ``position: sticky`` (the handover class lands in this session too — app.js adds it at shell boot regardless of the boot view) and ``visibility: visible``. """ from __future__ import annotations import re from playwright.sync_api import Browser, BrowserContext, Page, ViewportSize, expect from e2e.auth_helpers import login #: The narrower edge of the owner's devices (the Bug basis traced the #: dead state at 360–412px); 800px tall keeps every menu row clear of #: the bottom cluster. MOBILE: ViewportSize = {"width": 360, "height": 800} #: Test 3's pre-navigation counter (Playwright ``add_init_script``): #: hooks ``Element.prototype.dispatchEvent`` and increments #: ``window.__borRefreshFired`` for every ``bor:view-refresh``. The #: script re-runs on EVERY document (the counter is per-document — #: the login.html hop and the ``/`` / ``/sources.html`` redirects each #: start at 0), while the pushState view switches are same-document, #: so it accumulates exactly across the re-shows under test. REFRESH_COUNTER_HOOK = """ (() => { window.__borRefreshFired = 0; const orig = Element.prototype.dispatchEvent; Element.prototype.dispatchEvent = function (ev) { if (ev && ev.type === "bor:view-refresh") window.__borRefreshFired++; return orig.call(this, ev); }; })(); """ def _touch_page(browser: Browser) -> tuple[BrowserContext, Page]: """A fresh 360×800 REAL-TOUCH page (context + page). The conftest ``page`` fixture is a 1280×800 mouse page, and the phase-46 suite's 375×812 pages are mouse-driven too — this suite's whole point is the touch→click pipeline, so every page is created in a ``has_touch=True`` context (1 page per test; the caller closes page + context in ``finally``).""" context = browser.new_context(has_touch=True, viewport=MOBILE) return context, context.new_page() def _wait_settled_admin(page: Page) -> None: """Wait until whoami has resolved for the admin (copied from tests/e2e/test_mobile_hamburger_nav.py): the whoami reveal has un-hidden the admin-only nav links — the nav link is the viewport-independent settled signal (the sign-out control is the bar copy on desktop but the #sign-out-btn-mobile dropdown copy at ≤640px, so it is not a cross-viewport probe).""" page.wait_for_function( "() => !document.querySelector('#nav-sources').hasAttribute('hidden')", timeout=10_000, ) def _chat_bottom_style(page: Page, prop: str) -> str: """A computed style of the chat's sticky bottom cluster (``.chat-bottom`` — the element the phase-88 CSS rules target).""" return page.evaluate( f"() => getComputedStyle(document.querySelector('.chat-bottom')).{prop}" ) # --------------------------------------------------------------------------- # 1. Removals 1 + 2 at rest, in the dead state # --------------------------------------------------------------------------- def test_chat_boot_touch_action_and_sticky_handover( browser: Browser, app_url: str, db_ready: None ) -> None: """The dead state, fixed at rest: a FRESH admin load of ``/`` (the form-login redirect IS the fresh document load — the chat view is visible from the first frame) carries the toggle's ``touch-action: manipulation`` fix (removal 1), and the sticky handover has LANDED (removal 2): ``#view-chat`` carries ``chat-booted`` and ``.chat-bottom`` AND ``#composer`` computed ``position`` is ``sticky`` — if the boot flag never lands (a throw before the double-rAF pair), the gate rule keeps the cluster ``static`` and the position asserts fail.""" context, page = _touch_page(browser) try: login(page, app_url, next="/") # the redirect IS the fresh load of / _wait_settled_admin(page) # The handover landed: #view-chat carries chat-booted (app.js # adds it two frames after the boot settles). page.wait_for_function( "() => document.getElementById('view-chat')?." "classList.contains('chat-booted')", timeout=10_000, ) expect(page.locator("#view-chat")).to_have_class(re.compile(r"\bchat-booted\b")) # Removal 1: the toggle's touch fix is live at 360px (the # ≤640px rule; the base rule is display:none on desktop). assert page.evaluate( "() => getComputedStyle(document.querySelector('#nav-toggle')).touchAction" ) == "manipulation", ( "the mobile toggle must carry touch-action: manipulation " "(the standard dead-mobile-button fix)" ) # Removal 2 at rest: the cluster is pinned — the gate matches # nothing once .chat-booted is present, so both sticky pairs # compute exactly as pre-phase. assert _chat_bottom_style(page, "position") == "sticky", ( ".chat-bottom must be sticky at rest (the sticky handover " "landed — pre-boot it is static behind .chat-booted)" ) assert page.evaluate( "() => getComputedStyle(document.querySelector('#composer')).position" ) == "sticky", ( "#composer must keep its own sticky pair at rest (task 02 " "gates both selectors)" ) finally: page.close() context.close() # --------------------------------------------------------------------------- # 2. Removal 3 end-to-end: a touch tap opens the menu, the cluster # hides behind it, a touch tap on a row navigates # --------------------------------------------------------------------------- def test_hamburger_tap_opens_menu_and_hides_cluster( browser: Browser, app_url: str, db_ready: None ) -> None: """In the dead state, a REAL touch tap on the toggle opens the menu (``aria-expanded="true"``, ``.is-open``) and, while open, ``.chat-bottom`` computed ``visibility`` is ``hidden`` (removal 3 end-to-end — the body marker + the ≤640px rule); a touch tap on the "Tuning" row navigates client-side (``/tuning.html``, ``#view-tuning`` visible) and the menu closes again — ``aria-expanded="false"``, no ``.is-open``, the ``nav-menu-open`` marker gone from the body — with ``.chat-bottom`` back to ``visibility: visible`` (visibility, not display: closing never reflows the chat column).""" context, page = _touch_page(browser) try: login(page, app_url, next="/") _wait_settled_admin(page) # A REAL touch tap opens the menu... page.tap("#nav-toggle") expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") # to_have_class(string) is an EXACT match — the nav is # "app-nav is-open", so match the token with a regex. expect(page.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b")) # ...and the cluster is hidden behind it (removal 3). assert _chat_bottom_style(page, "visibility") == "hidden", ( "while the mobile menu is open, .chat-bottom must compute " "visibility:hidden (the body.nav-menu-open marker + the " "≤640px rule)" ) # ...and a touch tap on the "Tuning" row navigates (the router # intercepts the same-shell link — a view switch, never a # document load)... page.tap("#app-nav a[href='/tuning.html']") expect(page).to_have_url(app_url + "/tuning.html", timeout=15_000) expect(page.locator("#view-tuning")).to_be_visible(timeout=15_000) # ...and the menu closed with the marker gone; the cluster is # visible again. expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false") assert "is-open" not in (page.locator("#app-nav").get_attribute("class") or ""), ( "the closed menu must not carry the .is-open state" ) assert "nav-menu-open" not in (page.locator("body").get_attribute("class") or ""), ( "the body.nav-menu-open marker must be gone when the menu closes" ) assert _chat_bottom_style(page, "visibility") == "visible", ( "closing the menu must bring .chat-bottom back to visible " "(visibility, not display — no reflow)" ) finally: page.close() context.close() # --------------------------------------------------------------------------- # 3. Removal 4: the router's boot contract — no refresh at boot, # exactly one on a re-show # --------------------------------------------------------------------------- def test_boot_does_not_fire_view_refresh( browser: Browser, app_url: str, db_ready: None ) -> None: """The boot-refresh contract (A3), live: a hook installed BEFORE any navigation counts every ``bor:view-refresh`` dispatch (per document — the login.html hop and the ``/`` redirect each re-init the counter; the pushState view switches are same-document, so it accumulates exactly across them). The cold boot of ``/`` fires NONE — pre-phase code fired one on the pre-mounted chat view (``mounted.chat`` starts true, so the boot show hit the wasMounted branch); a first show (the History mount) fires none either (the mount's own load is the first fetch); and the RE-SHOW of chat (History → Chat) fires EXACTLY ONE — the phase-77 contract is preserved, not deleted.""" context, page = _touch_page(browser) try: # BEFORE the login navigation: the hook re-runs on every # document it precedes; the counter is per-document. page.add_init_script(REFRESH_COUNTER_HOOK) login(page, app_url, next="/") _wait_settled_admin(page) # THE PIN: the cold boot dispatched no refresh at all (with # pre-phase router.js the pre-mounted chat view got one). assert page.evaluate("() => window.__borRefreshFired") == 0, ( f"the cold boot of / must fire NO bor:view-refresh (the " f"phase-77 contract: the first show and boot never do), " f"got {page.evaluate('() => window.__borRefreshFired')}" ) # Chat → History (a FIRST show — the mount's own load is the # first fetch, never a refresh). #view-history un-hides only # AFTER its mount (and load) resolves — the settle. page.tap("#nav-toggle") expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") page.tap("#app-nav a[href='/history.html']") expect(page).to_have_url(app_url + "/history.html", timeout=15_000) expect(page.locator("#view-history")).to_be_visible(timeout=15_000) assert page.evaluate("() => window.__borRefreshFired") == 0, ( "a first show (the mount) must not fire the refresh either" ) # ...and back to Chat — the RE-SHOW of the already-mounted # view fires the refresh exactly once. page.tap("#nav-toggle") expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") page.tap("#app-nav a[href='/']") expect(page).to_have_url(app_url + "/", timeout=15_000) expect(page.locator("#view-chat")).to_be_visible() assert page.evaluate("() => window.__borRefreshFired") == 1, ( "a RE-SHOW of an already-mounted view must fire the " "refresh EXACTLY once (the phase-77 contract is " "preserved, not deleted)" ) finally: page.close() context.close() # --------------------------------------------------------------------------- # 4. Regression: the fresh /sources.html (rag) boot is untouched # --------------------------------------------------------------------------- def test_sources_boot_regression( browser: Browser, app_url: str, db_ready: None ) -> None: """The rag boot, in the dead state's sibling: a fresh 360px load of ``/sources.html`` — the one view state the cluster change must NOT disturb (``#view-rag`` mounts lazily via ``sources.js``): the touch tap still opens the menu (``.is-open``), and the "Chat" row lands on the chat view with ``.chat-bottom`` computed ``position: sticky`` (the handover class lands in this session too — app.js adds it at shell boot regardless of the boot view) and ``visibility: visible`` (the menu's close cleared the marker).""" context, page = _touch_page(browser) try: login(page, app_url, next="/sources.html") # the fresh rag boot _wait_settled_admin(page) expect(page.locator("#view-rag")).to_be_visible(timeout=15_000) # The touch tap still opens the menu on the rag boot... page.tap("#nav-toggle") 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")) # ...and the "Chat" row lands on a pinned, visible cluster. page.tap("#app-nav a[href='/']") expect(page).to_have_url(app_url + "/", timeout=15_000) expect(page.locator("#view-chat")).to_be_visible() assert _chat_bottom_style(page, "position") == "sticky", ( "after the rag-boot switch into chat, .chat-bottom must be " "sticky (the handover class lands at shell boot regardless " "of the boot view)" ) assert _chat_bottom_style(page, "visibility") == "visible", ( "the cluster must be visible once the menu closes" ) finally: page.close() context.close()