phase: 88_mobile_chat_hamburger_boot
Build and Push Containers / build-and-push-app (push) Successful in 2m16s
Build and Push Containers / build-and-push-db (push) Successful in 11s

All completion criteria verified green. Final state confirmed: phase stays in `todo/` per A4 (owner device gate), task files in `complete/`, no `app/` changes, no commits made (harness commits).

## Phase 88 — final verification pass report

**Verified (all 6 tasks already complete):** `touch-action: manipulation` on mobile `#nav-toggle`; `#view-chat.chat-booted` double-rAF sticky handover (CSS gate + app.js boot IIFE); `body.nav-menu-open` marker in `setNavMenu` + ≤640px `visibility: hidden` rule; router boot contract (`wasMounted && !opts.boot` + `boot: true`); new unit module (10 pins) + 4-test real-touch E2E story.

**Test / lint / coverage:**
- `uv run pytest --cov=app --cov-report=term-missing` → 1741 passed, **99%** on `app/` (>90% ✓)
- `tests/e2e/test_mobile_chat_hamburger_boot.py` 4/4, `test_mobile_hamburger_nav.py` 8/8, `test_pinned_composer.py` 4/4, `test_smoke.py` 3/3 — each in isolation, `--no-cov` ✓
- `uv run ruff check .` clean; `uv run pyright` 0 errors ✓

**Completion criteria:** (1) 360px cold-boot touch-action + sticky handover + tap-opens-menu-with-cluster-hidden (E2E 1–2) ✓; (2) boot fires no `bor:view-refresh`, re-show fires exactly one, `/sources.html` regression (E2E 3–4) ✓; (3) full gate green ✓; (4) diff limited to 4 assets + 2 new tests + phase files, 0 changes in `app/` ✓; (5) commit deferred to harness per executor rules ✓; (6) owner device re-verification **pending** (A4 — gates the archive; no Owner report recorded yet).

**Deviations (both documented in-tree):** unit pins updated in `test_frontend_router.py`/`test_hamburger_nav.py` (their exact-text pins collided with the mandated new guard/marker text — without them the suite goes red); `boot: true` count pinned at 1 not 2 (codebase has one boot call site, no `history.state` branch — verified against git HEAD).

**Next pending phase:** none in `todo/` — pipeline awaits the owner's on-device report (archive, or `?dbg=nav` instrumentation follow-up if the menu is still dead).
This commit is contained in:
2026-09-08 16:02:45 -04:00
parent 10fd367962
commit 4d287155c0
36 changed files with 2000 additions and 12 deletions
@@ -0,0 +1,382 @@
"""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()
+11 -4
View File
@@ -286,11 +286,14 @@ def test_focus_only_on_user_initiated_switches() -> None:
"""The target view is focused ONLY when the switch is
user-initiated (navbar click / popstate) — the boot switch passes
userInitiated:false, so a page load never steals focus. The top
landing (scrollTo 0,0) rides the same flag."""
landing (scrollTo 0,0) rides the same flag. (Phase 88: the opts
object also carries the boot flag — the boot call site passes
``boot: true`` so the boot show never fires the view refresh; the
focus gate reads ``opts.userInitiated``.)"""
js = _js()
fn = js.find("async function switchTo")
body = js[fn : js.find("\n}", fn)]
flag = body.rfind("if (userInitiated)")
flag = body.rfind("if (opts.userInitiated)")
focus = body.find("root.focus(")
scroll = body.find("window.scrollTo(0, 0)")
assert 0 <= flag < scroll < focus, "focus + top landing sit inside the flag"
@@ -422,7 +425,11 @@ def test_reshow_dispatches_view_refresh_gated_on_pre_mount_capture() -> None:
(``mounted[name] = true``) — so the first show (the mount) and boot
never dispatch: the mount's own load is the first fetch. Event
order: the view is visible and the head/nav state is written
BEFORE the refresh fires, and the focus/scroll tail runs after."""
BEFORE the refresh fires, and the focus/scroll tail runs after.
(Phase 88: the guard is ``if (wasMounted && !opts.boot)`` — the
boot show is the view's first display, so the code now matches the
documented contract; only the boot call site passes ``boot: true``,
pinned in test_mobile_chat_hamburger_boot.py.)"""
js = _js()
assert '"bor:view-refresh"' in js, "the refresh event literal must exist"
fn = js.find("async function switchTo")
@@ -434,7 +441,7 @@ def test_reshow_dispatches_view_refresh_gated_on_pre_mount_capture() -> None:
"the wasMounted capture must precede the mount-once set "
"(first show is exempt from the refresh)"
)
gate = body.find("if (wasMounted)")
gate = body.find("if (wasMounted && !opts.boot)")
dispatch = body.find('root.dispatchEvent(new CustomEvent("bor:view-refresh"))')
assert 0 <= gate < dispatch < gate + 120, (
"the dispatch must sit inside the wasMounted guard"
+10 -4
View File
@@ -525,7 +525,11 @@ def test_the_binding_toggles_only_the_container() -> None:
"""The auth visibility contract is untouched: the binding never
assigns ``.hidden`` and never touches the whoami links — hidden
links stay hidden inside the menu, exactly as on the inline bar.
Only the .is-open container class + aria-expanded move."""
Only the .is-open container class + aria-expanded move — plus,
since phase 88, the body-level ``nav-menu-open`` marker (task 03:
it rides on setNavMenu — the same single choke point — so the
marker can never stick while the ≤640px CSS hides the chat
cluster behind the open menu). No OTHER class may move."""
section = _js_clean(_hamburger_section(_js()))
assert ".hidden" not in section, (
"the hamburger binding must not hide/reveal any element"
@@ -534,8 +538,10 @@ def test_the_binding_toggles_only_the_container() -> None:
assert link not in section, (
f"the hamburger binding must not touch the {link!r} link"
)
# The only class the section manipulates is the container's state.
# The only classes the section manipulates are the container's
# state (is-open) and the phase-88 body marker (nav-menu-open).
class_toggles = re.findall(r"classList\.(?:add|remove|toggle)\(\s*\"([^\"]+)\"", section)
assert class_toggles == ["is-open"], (
f"only the container .is-open class may move (found {class_toggles})"
assert class_toggles == ["is-open", "nav-menu-open"], (
f"only the container .is-open class + the phase-88 "
f"body.nav-menu-open marker may move (found {class_toggles})"
)
@@ -0,0 +1,442 @@
"""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 <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 _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 module body, 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)"
)