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,139 @@
# Phase 88 — Restore the mobile hamburger on the chat page's cold boot (real Android)
**Source:** owner bug report (chat session 2026-09-08 — continuation of the phase-85 owner report; `TODO.md` is empty).
**Story:** mobile hamburger nav (the story behind `tests/e2e/test_mobile_hamburger_nav.py`) — bug fix, not a new story.
## Bug basis (code-traced + device-traced, 2026-09-08)
**Symptom (owner, two Android phones, cache cleared, production `https://brain.reeseapps.com`):** after the phase-76 SPA migration, the mobile hamburger (`#nav-toggle`) is DEAD on the chat page — only on a **fresh load / refresh of `/`** (direct URL or reload). A fresh load of `/sources.html` works; a client-side switch from any other view into chat works; the login page (`login.html` — separate document, byte-identical header markup) works. In the dead state the rest of the page works (composer taps land, a message was sent and streamed through the same `app.js` bundle). Rotation, pinch-zoom, and scrolling do NOT heal it; a long scrollable conversation does NOT heal it.
**Evidence trail (all verified this session):**
- **One toggle, one binding, one shell.** Since phase 76, every navbar page is a `<section class="view">` of the single `frontend/index.html` shell (`app/main.py::_shell_routes` serves it for all navbar paths — `curl /` and `curl /sources.html` are byte-identical documents). `frontend/assets/header.js` is a leaf module (no imports) bound into the app bundle; `setNavMenu(open)` (header.js) is the ONLY writer of `.is-open` on `#app-nav` (click / Esc / outside-click / 640px-media close paths all funnel through it). The standalone documents (`login.html`, `document.html`, `shared.html`) carry byte-identical header markup.
- **Deployment parity.** The deployed assets (`?v=78ef23365ea8`) are byte-identical to the current dev tree — `styles.css`, `app.js`, `router.js`, `token-gate.js`, `brand.js`, `markdown.js` all match a local rebuild with the same esbuild 0.25.5 (the Containerfile stage-1 build); the HTML differs only in the `?v=` cache tokens. The phone runs exactly this code.
- **Not reproducible in spec-compliant Chromium.** Real-touch Playwright probes (mobile emulation, 360–412px, admin AND anonymous, scrolled and non-scrolled): the toggle is always hit-testable (`elementFromPoint`), a touch tap opens the menu, every row is tappable, the menu's top edge is 58px in both page states. The failure exists only on the real devices' touch→click / compositor pipeline.
- **The click is not firing (device data, 2026-09-08 disambiguation tests):** in the dead state the owner (a) tapped where the menu's 2nd row would sit — NO navigation (if `.is-open` had been applied, the row would be hit-testable even while painted invisibly — `pointer-events: auto` when open — so the class is NOT being applied → the `click` event does not fire on the button); (b) double-tapped — NO zoom (not double-tap-to-zoom either); (c) sent a message (page became scrollable) — still dead (no scroll-state mechanism); (d) fresh `login.html` — works (not an app-wide non-scrollable-page bug, not the OS top-corner gesture region — the login button sits in the SAME screen corner).
- **The screenshot (dead state, 960×2142 Android Chrome):** status bar 0–153 (≈66 CSS px — a tall bar); header 154–283; the `.app-header::after` hairline present directly under the header (the header IS a functioning containing block, so an open menu WOULD paint at y≈58–410); the toggle's box is filled with `--brand-soft` (#2d0a0a) — the `:hover` fill, i.e. the touch registered on the button (hover is pure CSS and works with zero JS, so it proves touch registration only, not click dispatch).
- **Exhaustive code audit — nothing in the app can swallow the click:** zero touch listeners anywhere in `frontend/assets/`; no document/body-level click handlers; nothing sets `disabled` on `#nav-toggle`; no `inert` on `#header`/`body` (mountGate → `#main` only; the router → view sections only); no service worker; no iframes; no web fonts; the viewport meta is `width=device-width, initial-scale=1, viewport-fit=cover` on every page (zoom allowed → the browser's tap/zoom disambiguation is active for touches); **no `touch-action` anywhere in the codebase.**
- **The one page-state delta.** The only element present in the broken state (shell cold boot with the chat view visible from the FIRST frame — in the empty state the document is exactly `100dvh`) and absent from EVERY working state (fresh `/sources.html` boot: the rag view shows, the cluster is inside a `display:none` section; client-side switch into chat: the cluster's sticky layer is created on a SETTLED page; login: different document) is the **sticky `.chat-bottom` cluster** (and the doubly-sticky `#composer` inside it — `styles.css` ~L1259/1273) committed in the first layout. A `position: sticky` element is promoted to a compositor layer at commit; the evidence pattern (dead when the layer is born at first commit, alive when the identical layer is born on a settled page, not healed by later reflows) points at that first-commit sticky layer interacting with the device's touch→click dispatch. The exact internal path cannot be proven from here (headless Chromium has no Android compositor/OS layer) — so the phase removes ALL candidate mechanisms with standard, safe changes, and makes the owner's on-device re-verification the phase's final gate.
- **Latent boot-path contract violation found while tracing (fixed in task 04):** `router.js`'s own contract says the first display (mount) and BOOT never fire `bor:view-refresh` — but `mounted.chat` is initialized `true` (app.js pre-mounts the chat view), so a cold boot's `switchTo("chat")` hits the `wasMounted` branch and dispatches `bor:view-refresh` on `#view-chat` at boot. Harmless today (no view listens on its chat root — the phase-77 exclusion — and no lazy module is even mounted on a `/` boot), but a future listener added to the chat view would fire at boot and could resurrect exactly this bug class. The documented contract must hold.
## Objective
Make the mobile hamburger reliably open the menu on the chat page's cold boot on real Android devices, by removing every surviving candidate mechanism: (1) the standard dead-button touch fix on the toggle, (2) deferring the composer cluster's sticky layer out of the first-commit window (reproducing the "layer born on a settled page" condition the owner's phone already accepts), (3) hiding the cluster while the menu is open, plus (4) restoring the router's boot-refresh contract — with unit pins, a dedicated E2E story suite, and the owner's on-device re-verification as the completion gate.
## Owner decisions (chat, 2026-09-08 — the fix set discussed in-session; the owner ran the disambiguation tests on the way to this phase)
- **A1 — belt-and-suspenders, not a single mechanism.** The internal device path is not provable from the dev machine; the phase ships the three standard, independent removals (touch-action / deferred sticky / hidden-while-open) together. Each is safe, each is byte-identical at rest or off-mobile, and any ONE of them hitting the real mechanism is enough — the device re-verification decides.
- **A2 — mobile-only surface changes.** All CSS additions live in the existing `@media (max-width: 640px)` block or are gated behind the new `#view-chat.chat-booted` / `body.nav-menu-open` state classes; the desktop layout and the resting mobile layout stay byte-identical (the phase-46 "desktop byte-identical" contract and the phase-65 sticky-composer contract hold at rest — `tests/e2e/test_pinned_composer.py` stays green).
- **A3 — the boot contract is repaired, not worked around.** Task 04 makes the router's code match its documented contract (the boot show never fires `bor:view-refresh`) with an explicit `boot: true` flag at the two boot call sites — no module-level flag state, no behavior change for any later show.
- **A4 — device re-verification gates the archive.** The local gates (suite, coverage, E2E isolation, ruff, pyright) plus the atomic commit close task 06, but the phase directory is NOT moved to `complete/` until the owner re-tests both phones after deploy and the report is recorded in this file (house "Owner report" pattern, cf. phase 85). If the menu is still dead, a follow-up phase with a `?dbg=nav` on-screen instrumentation overlay (query-param-gated: per-event log of touchstart/touchend/click on `#nav-toggle`, classList state, `elementFromPoint`, geometry) is the named fallback — it is NOT part of this phase.
## Design (shared by all tasks — the executor reads this, not the chat)
- **`frontend/assets/styles.css`** (house comment style, citing phase 88):
- **Task 01 — in the existing `@media (max-width: 640px)` `.nav-toggle` rule (~L3489, the one with `display: inline-flex; width: 44px; height: 44px;`):** add `touch-action: manipulation;` — the standard dead-mobile-button fix: it removes double-tap-to-zoom, pinch, and the tap/zoom disambiguation window from this control's touch pipeline, so a (drifting) tap resolves to a click fastest. Desktop untouched (the toggle is `display: none` there).
- **Task 02 — new rule (place next to the `.chat-bottom` rule, ~L1259):**
```css
/* Phase 88: pre-boot, the composer cluster is NOT sticky. The sticky
promotion (a compositor layer) is deferred out of the first layout
commit — on real Android, the cluster's sticky layer born at first
commit was eating the hamburger's click (owner bug report
2026-09-08; the client-side switch into chat — same layer, born on
a settled page — always worked). app.js adds .chat-booted two
frames after boot settles; no-JS keeps the cluster static, which in
the only no-JS state (empty, 100dvh document) sits exactly where
the pin would. */
#view-chat:not(.chat-booted) .chat-bottom,
#view-chat:not(.chat-booted) .composer {
position: static;
}
```
(Both selectors — `.chat-bottom` AND `#composer` are independently sticky at ~L1259/1273; the id-scoped rule outranks both. At rest — `.chat-booted` present — both computed styles are byte-identical to today.)
- **Task 03 — in the `@media (max-width: 640px)` block, next to the `.app-nav` dropdown rules (~L3563):**
```css
/* Phase 88: while the mobile menu is open, the chat's sticky bottom
cluster is hidden — it is the other positioned/layered element on
the page and must not compete for taps with the open menu (it also
overlaps the menu's lower rows on short viewports — menu y58→417
vs cluster top y395 at 390×600). visibility (not display): layout
is preserved, so closing never reflows the chat column. */
body.nav-menu-open .chat-bottom {
visibility: hidden;
}
```
- **`frontend/assets/app.js`** — **task 02:** in the boot IIFE (the `(async () => { … })()` ending at `loadSuggestions(); loadHealth(); })();`, ~L2499), after `loadHealth();` add:
```js
// Phase 88: the sticky cluster's compositor layer is born AFTER the
// boot paint — not in the first layout commit (see the styles.css
// gate). Two frames: frame 1 paints the settled boot (empty state or
// the restored conversation) with the cluster static; frame 2 pins it.
// A pre-settle throw leaves the cluster static — a degraded boot is
// already degraded (the gate/header above it), acceptable.
requestAnimationFrame(() =>
requestAnimationFrame(() => {
document.getElementById("view-chat")?.classList.add("chat-booted");
}),
);
```
Nothing else in `app.js` changes.
- **`frontend/assets/header.js`** — **task 03:** in `setNavMenu(open)` (the single choke point — click, Esc, outside-click, and the 640px-media close all funnel through it), after the `aria-expanded` line add:
```js
// Phase 88: body-level marker — while the mobile menu is open, the
// chat's sticky bottom cluster is hidden (styles.css, ≤640px block).
document.body.classList.toggle("nav-menu-open", open);
```
No other `header.js` change (the binding, the close paths, and the outside-click listener stay byte-identical).
- **`frontend/assets/router.js`** — **task 04:**
- the refresh-dispatch guard in `switchTo` (currently `if (wasMounted) { root.dispatchEvent(new CustomEvent("bor:view-refresh")); }`, ~L224-227) becomes:
```js
// Phase 88: the BOOT show is the view's first display — the phase-77
// contract says the first show (mount) and boot never fire the
// refresh. `mounted.chat` starts true (app.js pre-mounts the chat),
// so the cold boot's switchTo("chat") was hitting the wasMounted
// branch; the explicit boot flag (the two boot call sites below)
// makes the contract hold. Every LATER show — re-click on the active
// link, popstate, any navigation — still fires exactly as before.
if (wasMounted && !opts.boot) {
root.dispatchEvent(new CustomEvent("bor:view-refresh"));
}
```
- the two boot call sites (the IIFE at the bottom: `switchTo(history.state.view, { push: false });` and `switchTo(bootName, { push: false });`) both gain `boot: true` in their opts. The `opts` param already defaults to `{}`. No other router change.
- **`tests/unit/test_mobile_chat_hamburger_boot.py` (new — house source-level pattern; started in task 01, extended by 02/03/04):**
- task 01: `styles.css` — the `@media (max-width: 640px)` block's `.nav-toggle` rule contains `touch-action: manipulation` (parse: locate the media block, the `.nav-toggle {` rule inside it, assert the declaration).
- task 02: `styles.css` — the `#view-chat:not(.chat-booted)` rule exists and names BOTH `.chat-bottom` and `.composer` with `position: static`; `app.js` — the double-`requestAnimationFrame` boot-flag pattern (regex: nested `requestAnimationFrame` + `"chat-booted"`) appears exactly once, and sits AFTER `loadHealth();` inside the boot IIFE (string-position check).
- task 03: `styles.css` — the `body.nav-menu-open .chat-bottom` rule exists with `visibility: hidden` inside the `@media (max-width: 640px)` block; `header.js` — `setNavMenu` toggles the body class (`"nav-menu-open"` appears in `header.js` exactly once, and that occurrence sits inside the `setNavMenu` function body).
- task 04: `router.js` — the dispatch guard references `!opts.boot`; the string `boot: true` appears EXACTLY twice (the two boot call sites); the popstate/nav call sites do NOT pass it (the count is the pin).
- **`tests/e2e/test_mobile_chat_hamburger_boot.py` (new story suite — task 05; copy the mobile-context + admin-login fixture pattern from `tests/e2e/test_mobile_hamburger_nav.py`):**
1. `test_chat_boot_touch_action_and_sticky_handover` — 360px mobile touch context, fresh admin load of `/`: after load, `#nav-toggle`'s computed `touch-action` is `manipulation`; `#view-chat` HAS `chat-booted`; `.chat-bottom` AND `#composer` computed `position` are `sticky` (the handover landed at rest — if the class never landed, this fails).
2. `test_hamburger_tap_opens_menu_and_hides_cluster` — same load: a real touch tap on `#nav-toggle` → `aria-expanded="true"` + `.is-open`; while open, `.chat-bottom`'s computed `visibility` is `hidden`; tap the "Tuning" row → URL `/tuning.html` and `#view-tuning` visible; menu closed again (`aria-expanded="false"`, `nav-menu-open` gone from body) and `.chat-bottom`'s `visibility` back to `visible`.
3. `test_boot_does_not_fire_view_refresh` — `page.addInitScript` hooks `Element.prototype.dispatchEvent` (call the original, but increment `window.__borRefreshFired` when `ev.type === "bor:view-refresh"`) BEFORE the app scripts run; fresh load of `/` → after boot settles, `window.__borRefreshFired === 0` (with today's code it is ≥1 — the stray boot dispatch); then tap "History", wait, tap "Chat", wait → `=== 1` (a RE-SHOW still fires — the phase-77 contract is preserved, not deleted).
4. `test_sources_boot_regression` — fresh 360px load of `/sources.html`: the touch tap on the toggle still opens the menu (the rag boot is untouched by the cluster change); tap the "Chat" row → `#view-chat` visible, `.chat-bottom` `visibility: visible` and `position: sticky`.
- **NOT touched:** `app/` (server byte-identical), the SSE/event set, `token-gate.js` (the gate's `inert`/overlay behavior stays), the `.app-nav` dropdown's own transition/opacity rules (phase 76), the tool-line/typing timers (phase 87), `mock_llm.py`/`slow_llm.py`, persistence.
## Dependencies
- `87_big_read_progress` (complete) — pipeline predecessor (execution order) only; NO code dependency (this phase touches `app.js`'s boot IIFE tail and the header/router/CSS, none of which phase 87's pins reach — the phase-87 unit suites `test_frontend_tool_states.py` / `test_frontend_feedback.py` / `test_frontend_brand.py` / `test_big_read_progress.py` must stay green unchanged).
## Tasks
1. `01_touch_action_nav_toggle.md` — `touch-action: manipulation` on the mobile nav toggle + the new unit test module with its pins.
2. `02_defer_cluster_sticky_until_boot.md` — the `.chat-booted` gate (CSS) + the double-rAF boot flag (app.js) + unit pins.
3. `03_hide_cluster_while_menu_open.md` — `body.nav-menu-open` marker (header.js) + the ≤640px visibility rule (CSS) + unit pins.
4. `04_router_boot_refresh_contract.md` — the `boot: true` contract fix (router.js) + unit pins.
5. `05_e2e_story_suite.md` — `tests/e2e/test_mobile_chat_hamburger_boot.py` (the four tests above, isolation gate).
6. `06_verify_and_commit.md` — full gate (unit + integration, >90% coverage, the NEW E2E story in isolation, `test_mobile_hamburger_nav.py` + `test_pinned_composer.py` + `test_smoke.py` in isolation, ruff + pyright) + the device re-verification checklist + the atomic commit (archive deferred to the owner's on-device confirmation — A4).
## Testing & Quality
- Unit — `tests/unit/test_mobile_chat_hamburger_boot.py` (new): the pins listed in the Design section, one block per task; coverage **>90%** on `app/` is unaffected (no `app/` changes — the floor is held by the untouched suite).
- Existing unit suites MUST stay green UNCHANGED: the phase-87 suites (`test_big_read_progress.py`, `test_frontend_tool_states.py`, `test_frontend_feedback.py`, `test_frontend_brand.py`) and every other frontend source-level pin (`test_header_consistency.py` and friends read `header.js`/`styles.css` — the additive-only changes must not break their counts).
- E2E — `tests/e2e/test_mobile_chat_hamburger_boot.py` (new; isolation gate per AGENTS.md rule 9): the four tests above, real touch, mobile contexts, via the conftest app-server pattern.
- Regression E2E (run in isolation by task 06): `tests/e2e/test_mobile_hamburger_nav.py` (the existing hamburger story — open/close/rows/Esc/outside-click, both auth states), `tests/e2e/test_pinned_composer.py` (the phase-65 sticky contract at rest), `tests/e2e/test_smoke.py` — all green unchanged.
- `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] Fresh 360px load of `/`: the toggle's computed `touch-action` is `manipulation`, the cluster's sticky handover has landed at rest (E2E pin 1), and a real touch tap opens the menu with the cluster hidden behind it (E2E pin 2) — the three candidate mechanisms are all removed.
- [ ] The router's boot contract holds: a cold boot fires NO `bor:view-refresh`; a re-show of an already-mounted view still fires exactly one (E2E pin 3) — and the fresh `/sources.html` boot regression passes (E2E pin 4).
- [ ] `uv run pytest` green (including every untouched frontend unit suite); `uv run pytest --cov=app --cov-report=term-missing` >90%; the new E2E story + `test_mobile_hamburger_nav.py` + `test_pinned_composer.py` + `test_smoke.py` green in isolation; `uv run ruff check . && uv run pyright` clean.
- [ ] `git diff --stat` limited to `frontend/assets/styles.css`, `frontend/assets/app.js`, `frontend/assets/header.js`, `frontend/assets/router.js`, the new unit test, the new E2E file, phase files — nothing in `app/`.
- [ ] One atomic `--no-gpg-sign` commit (e.g. `fix(ui): restore the mobile hamburger on chat-page cold boot on real devices`); **the phase directory is NOT moved to `complete/` yet** (A4).
- [ ] **Owner device re-verification (post-deploy — the phase's final gate):** on BOTH phones (cache cleared): (1) cold load `https://brain.reeseapps.com/` → hamburger opens the menu, a row tap navigates; (2) refresh `/` → repeat (the original repro); (3) send a message (scrollable state) → hamburger still works, and while the menu is open the composer cluster is hidden; (4) fresh `/sources.html` and `login.html` → still work (regressions). The report is recorded in this file (house "Owner report" pattern) and the phase is then archived with a small docs commit. **If the menu is still dead on-device, this phase is NOT complete** — open the follow-up instrumentation phase (`?dbg=nav` overlay, per A4) and archive this one as the removed-mechanisms baseline.
## Locked decisions
- **Belt-and-suspenders over mechanism-proof (A1)** — the three removals ship together; the device decides.
- **Mobile-only / state-gated surface changes (A2)** — nothing changes at desktop or at the resting mobile layout; the phase-46 and phase-65 contracts hold byte-identical at rest.
- **The boot contract is code, not comment (A3)** — `boot: true` at exactly the two boot call sites; the guard is `wasMounted && !opts.boot`.
- **Device re-verification gates the archive (A4)** — the local gates + commit close the code; the owner's phones close the phase.
@@ -0,0 +1,31 @@
# Task 01 — `touch-action: manipulation` on the mobile nav toggle
**Phase:** `88_mobile_chat_hamburger_boot` · **Source:** owner bug report (chat session 2026-09-08) — the chat-page hamburger is dead on real Android cold boots; the codebase has NO `touch-action` anywhere, so the toggle's taps go through the browser's full tap/zoom/pinch disambiguation (the viewport meta allows zoom: `initial-scale=1`, no `maximum-scale` lock — and must stay that way, WCAG).
**Story:** mobile hamburger nav (bug fix).
## Objective
Give the mobile hamburger the standard dead-button touch fix — `touch-action: manipulation` — so a (drifting) tap on it resolves to a click without the double-tap-zoom/pinch/disambiguation window, and start this phase's unit test module with the pin.
## Work
1. `frontend/assets/styles.css` — in the EXISTING `@media (max-width: 640px)` block, find the `.nav-toggle { … }` rule (the one with `display: inline-flex;` / `width: 44px;` / `height: 44px;` — ~L3489) and add ONE declaration to it:
```css
touch-action: manipulation; /* Phase 88: standard dead-mobile-button fix — the
toggle's taps must not wait on the double-tap-zoom/pinch disambiguation
(owner bug report 2026-09-08: dead hamburger on chat-page cold boots). */
```
Do NOT touch the base `.nav-toggle` rule (desktop — `display: none` there) and do NOT touch the viewport meta (zoom must stay allowed everywhere else).
2. `tests/unit/test_mobile_chat_hamburger_boot.py` (NEW — house source-level pattern: read the asset files as text, no browser; module docstring cites the 2026-09-08 owner bug report + phase 88 A1):
- helper: extract the `@media (max-width: 640px)` block's text (the block starts at the `@media (max-width: 640px)` line and ends at the matching close — for this repo the simplest robust extraction is: take the substring from that `@media` line to the LAST `}` of the file region containing the mobile rules; if that proves brittle, assert with a targeted regex instead — see the pin below);
- pin: the mobile `.nav-toggle` rule contains `touch-action: manipulation` — regex on the extracted block, e.g. `re.search(r"\.nav-toggle\s*\{[^}]*touch-action:\s*manipulation[^}]*\}", mobile_block, re.S)`;
- guard pin: the BASE (outside the mobile block) `.nav-toggle` rule does NOT contain `touch-action` (the mobile-only contract, A2) — i.e. `js/css.count("touch-action") == 1` across the whole `styles.css` file.
- tasks 02/03/04 will extend this module — keep the structure open (one test function per pin, shared fixtures for reading `frontend/assets/styles.css`, `app.js`, `header.js`, `router.js` as text via `Path(__file__).parents[2] / "frontend" / "assets" / …`).
## Testing & Quality
- Unit: `uv run pytest tests/unit/test_mobile_chat_hamburger_boot.py -v` green (this task's pins); the full `uv run pytest tests/unit/ -q` green UNCHANGED (the additive CSS declaration must not break any other source-level pin that reads `styles.css`).
- Coverage: **>90%** on `app/` unaffected (no `app/` change).
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_mobile_chat_hamburger_boot.py -v` green; full unit suite green unchanged.
- [ ] `git diff frontend/assets/styles.css` shows ONLY the one added declaration inside the mobile `.nav-toggle` rule.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] No behavior change in completed work (the E2E proof is task 05/06; the existing `test_mobile_hamburger_nav.py` suite stays green — run it in isolation: `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov`).
@@ -0,0 +1,40 @@
# Task 02 — Defer the composer cluster's sticky layer out of the first-commit window
**Phase:** `88_mobile_chat_hamburger_boot` · **Source:** owner bug report (chat session 2026-09-08) — 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 is the sticky `.chat-bottom` cluster (with the doubly-sticky `#composer` inside) committed in the first layout. The owner's phone accepts the identical layer when it is born on a SETTLED page (the client-side switch into chat always works).
**Story:** mobile hamburger nav (bug fix).
## Objective
Gate the cluster's `position: sticky` behind a `#view-chat.chat-booted` class that `app.js` adds two frames after the boot settles — so the sticky compositor layer is born AFTER the boot paint (reproducing the "born on a settled page" condition the devices already accept), while the resting layout (and the no-JS fallback) stays byte-identical in appearance.
## Work
1. `frontend/assets/styles.css` — add the gate rule (place it directly AFTER the existing `.chat-bottom { … }` rule, ~L1259 — the house-comment text is in the phase overview's Design section; use it verbatim):
```css
#view-chat:not(.chat-booted) .chat-bottom,
#view-chat:not(.chat-booted) .composer {
position: static;
}
```
Notes: BOTH selectors are required — `.chat-bottom` (~L1259) AND `.composer` (~L1273) are independently `position: sticky` today; the id-scoped rule outranks both, and at rest (`.chat-booted` present) the selector matches nothing, so both computed styles are byte-identical to today. Do NOT modify the two existing rules.
2. `frontend/assets/app.js` — in the boot IIFE (the `(async () => { … })()` that ends with `loadSuggestions();\n loadHealth();\n})();` — ~L2499), insert AFTER the `loadHealth();` line (still inside the IIFE, before `})();`) the phase-88 double-rAF block (house-comment text is in the phase overview's Design section; use it verbatim):
```js
requestAnimationFrame(() =>
requestAnimationFrame(() => {
document.getElementById("view-chat")?.classList.add("chat-booted");
}),
);
```
Rationale for two frames: frame 1 paints the settled boot (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). Nothing else in `app.js` changes.
Failure mode (documented in the house comment, do not "fix"): a throw before this line (e.g. `fetchWhoami`) leaves the cluster static — a degraded boot is already degraded; the hamburger binding itself lives in `header.js`'s module body and is unaffected either way.
3. `tests/unit/test_mobile_chat_hamburger_boot.py` (extend the module from task 01):
- `styles.css`: the gate rule exists — `re.search(r"#view-chat:not\(\.chat-booted\)\s*\.chat-bottom,\s*#view-chat:not\(\.chat-booted\)\s*\.composer\s*\{\s*position:\s*static\s*\}", css, re.S)`; AND the two original rules are untouched: `css.count("position: sticky")` is UNCHANGED from before this task (capture the count in the test against the two known rule bodies — assert the `.chat-bottom {` and `.composer {` rule blocks still each contain `position: sticky;`);
- `app.js`: the boot-flag pattern appears EXACTLY once — `js.count("chat-booted") == 1` and `re.search(r"requestAnimationFrame\(\(\)\s*=>\s*requestAnimationFrame\(\(\)\s*=>\s*\{?\s*document\.getElementById\(\"view-chat\"\)\?\.classList\.add\(\"chat-booted\"\)", js, re.S)`; and it sits AFTER `loadHealth();` (string-position: `js.index("chat-booted") > js.index("loadHealth();")`).
## Testing & Quality
- Unit: `uv run pytest tests/unit/test_mobile_chat_hamburger_boot.py -v` green (task 01 + this task's pins); full `uv run pytest tests/unit/ -q` green unchanged.
- Coverage: **>90%** on `app/` unaffected (no `app/` change).
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_mobile_chat_hamburger_boot.py -v` green; full unit suite green unchanged.
- [ ] `git diff frontend/assets/app.js` shows ONLY the added block after `loadHealth();` — no re-indent, no other lines touched.
- [ ] Resting layout unchanged by construction (the gate selector matches nothing once `.chat-booted` is present); the E2E proof (computed sticky at rest) is task 05/06 — `tests/e2e/test_pinned_composer.py` must stay green in isolation (run it now as a smoke: `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov`).
- [ ] `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,40 @@
# Task 03 — Hide the chat cluster while the mobile menu is open
**Phase:** `88_mobile_chat_hamburger_boot` · **Source:** owner bug report (chat session 2026-09-08) — with the menu open, the chat's sticky bottom cluster is the OTHER positioned/layered element on the page and can compete for taps with the open menu; it also overlaps the menu's lower rows on short viewports (measured: menu y58→417 vs cluster top y395 at 390×600).
**Story:** mobile hamburger nav (bug fix).
## Objective
Drive a `body.nav-menu-open` marker from `setNavMenu` (the single choke point for every open/close path) and hide `.chat-bottom` with `visibility: hidden` while the mobile menu is open — no layout shift, no reflow on close, desktop and resting state untouched.
## Work
1. `frontend/assets/header.js` — in `setNavMenu(open)` (the function that toggles `.is-open` on `#app-nav` and sets `aria-expanded` — every open/close path funnels through it: the toggle click, Esc, outside-click, and the 640px-media close), add AFTER the `aria-expanded` line:
```js
// Phase 88: body-level marker — while the mobile menu is open, the
// chat's sticky bottom cluster is hidden (styles.css, ≤640px block):
// it must not compete for taps with the open menu, and on short
// viewports it overlaps the menu's lower rows. Every close path
// (Esc / outside-click / media) funnels through setNavMenu, so the
// marker can never stick.
document.body.classList.toggle("nav-menu-open", open);
```
Do NOT touch the binding, the close listeners, or the outside-click handler (byte-identical).
2. `frontend/assets/styles.css` — in the EXISTING `@media (max-width: 640px)` block, next to the `.app-nav` dropdown rules (~L3563), add the rule with the house comment from the phase overview's Design section (use it verbatim):
```css
body.nav-menu-open .chat-bottom {
visibility: hidden;
}
```
`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. The rule is scoped to the mobile block — at >640px the toggle is `display: none`, `setNavMenu` never opens the menu, and the marker is never set in practice; even if it were, the rule does not exist there.
3. `tests/unit/test_mobile_chat_hamburger_boot.py` (extend):
- `header.js`: `"nav-menu-open"` appears EXACTLY ONCE in the file, and that occurrence is inside the `setNavMenu` function body (extract the function text from `function setNavMenu` to its closing brace and assert the class string is in it AND the file-wide count is 1);
- `styles.css`: the rule exists inside the mobile block — `re.search(r"body\.nav-menu-open\s*\.chat-bottom\s*\{\s*visibility:\s*hidden\s*\}", mobile_block, re.S)` (reuse task 01's mobile-block extraction), and `css.count("nav-menu-open") == 1` (the rule only — no stray JS/CSS coupling).
## Testing & Quality
- Unit: `uv run pytest tests/unit/test_mobile_chat_hamburger_boot.py -v` green (tasks 01–03 pins); full `uv run pytest tests/unit/ -q` green unchanged (the header.js pin in `tests/unit/test_header_consistency.py` and friends read `header.js` — the additive line must not break their counts; if a count-style pin breaks, it counts OCCURRENCES of existing strings, which this change does not alter — verify by running).
- Coverage: **>90%** on `app/` unaffected (no `app/` change).
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_mobile_chat_hamburger_boot.py -v` green; full unit suite green unchanged.
- [ ] `git diff frontend/assets/header.js` shows ONLY the added marker line inside `setNavMenu`.
- [ ] `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` green in isolation (the existing open/close/rows/Esc/outside-click behavior is untouched — the marker rides on the same paths).
- [ ] `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,32 @@
# Task 04 — Restore the router's boot-refresh contract (`boot: true`)
**Phase:** `88_mobile_chat_hamburger_boot` · **Source:** latent contract violation found while tracing the dead-hamburger bug (phase overview, Bug basis) — `router.js` documents that the first display (mount) and BOOT never fire `bor:view-refresh`, but `mounted.chat` is initialized `true` (app.js pre-mounts the chat view), so a cold boot's `switchTo("chat")` hits the `wasMounted` branch and dispatches the refresh at boot. Harmless today (no listener on the chat root — phase-77 exclusion — and no lazy module is mounted on a `/` boot), but a future listener added to the chat view would fire at boot and could resurrect exactly this bug class.
**Story:** mobile hamburger nav (bug fix — boot-path contract repair, A3).
## Objective
Make the router's code match its documented contract: the boot show — and only the boot show — never dispatches `bor:view-refresh`, via an explicit `boot: true` flag at the two boot call sites (no module-level flag state, no race with an in-flight boot import).
## Work
1. `frontend/assets/router.js` — two changes:
a. In `switchTo(name, opts = {})`, change the refresh-dispatch guard (currently, ~L224-227, `if (wasMounted) { root.dispatchEvent(new CustomEvent("bor:view-refresh")); }`) to the `!opts.boot` guard with the house comment from the phase overview's Design section (use it verbatim):
```js
if (wasMounted && !opts.boot) {
root.dispatchEvent(new CustomEvent("bor:view-refresh"));
}
```
b. In the boot IIFE at the bottom of the file, add `boot: true` to BOTH call sites' opts — `switchTo(history.state.view, { push: false, boot: true });` and `switchTo(bootName, { push: false, boot: true });`.
Do NOT touch the popstate listener, the nav click handler, the head-state block, the focus logic, or the `mounted` map. The `opts` parameter already defaults to `{}`, so every non-boot call site has `opts.boot === undefined` → `!opts.boot === true` → dispatches exactly as today.
2. `tests/unit/test_mobile_chat_hamburger_boot.py` (extend):
- `router.js`: the dispatch guard references the flag — `re.search(r"if\s*\(\s*wasMounted\s*&&\s*!opts\.boot\s*\)\s*\{\s*root\.dispatchEvent\(\s*new\s+CustomEvent\(\"bor:view-refresh\"\)", js, re.S)`;
- `router.js`: `js.count("boot: true") == 2` (EXACTLY the two boot call sites — the pin that the popstate/nav paths never get the flag);
- `router.js`: the old unconditional guard is gone — `"if (wasMounted) {"` does NOT appear (`assert "if (wasMounted) {" not in js` — note: the NEW guard text is `if (wasMounted && !opts.boot) {`, so this assertion is safe).
## Testing & Quality
- Unit: `uv run pytest tests/unit/test_mobile_chat_hamburger_boot.py -v` green (tasks 01–04 pins); full `uv run pytest tests/unit/ -q` green unchanged.
- Coverage: **>90%** on `app/` unaffected (no `app/` change).
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_mobile_chat_hamburger_boot.py -v` green; full unit suite green unchanged.
- [ ] `git diff frontend/assets/router.js` shows ONLY the guard change + the two `boot: true` additions.
- [ ] The behavioral proof (no dispatch at boot, exactly one on re-show) is E2E pin 3 in task 05 — the lazy views' refresh hooks (`sources.js` / `history.js` / `tokens.js` / `tuning.js` / `git-sources.js`, all `root.addEventListener("bor:view-refresh", …)`) are untouched and still fire on re-show.
- [ ] `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,41 @@
# Task 05 — E2E story suite: `tests/e2e/test_mobile_chat_hamburger_boot.py`
**Phase:** `88_mobile_chat_hamburger_boot` · **Source:** owner bug report (chat session 2026-09-08) — AGENTS.md rule 4/9: the story gets its dedicated suite, run in isolation.
**Story:** mobile hamburger nav (bug fix — the cold-boot state the existing `test_mobile_hamburger_nav.py` never covers: a fresh load of `/` at mobile width, where the chat view is visible from the first frame).
## Objective
Pin the four behaviors this phase ships: (1) the toggle's touch fix + the sticky handover at rest, (2) a real TOUCH tap opening the menu with the cluster hidden behind it and a row navigating, (3) the router boot contract (no `bor:view-refresh` at boot; exactly one on re-show), (4) the fresh `/sources.html` boot regression.
## Work
1. `tests/e2e/test_mobile_chat_hamburger_boot.py` (NEW) — copy the fixture/pattern shape from `tests/e2e/test_mobile_hamburger_nav.py` (session `browser` fixture, `app_url` fixture, `from e2e.auth_helpers import login`), with TWO differences that are the whole point of this suite:
- **Real touch:** the existing suite uses `page.click` (mouse). THIS suite must drive the toggle and rows with REAL touches: create pages via `browser.new_context(has_touch=True, viewport={"width": 360, "height": 800})` then `context.new_page()` (360×800 — the narrower edge of the owner's devices; 1 page per test, close in `finally`), and use `page.tap("…")` (not `page.click`) for the toggle and for menu rows.
- **The cold-boot state:** every test starts from a FRESH navigation (`page.goto(f"{app_url}/")` or `/sources.html`) — never a client-side switch — so the chat view is visible from the first frame (the dead state).
Tests (module docstring: cites the 2026-09-08 owner bug report + the phase overview's Bug basis; run line `uv run pytest tests/e2e/test_mobile_chat_hamburger_boot.py -v --no-cov`; mock LLM; DB up — same header as the existing suite):
1. `test_chat_boot_touch_action_and_sticky_handover` — admin (log in first, `next="/"`), fresh load of `/`: wait for the settled-admin signal (copy `_wait_settled_admin`); then assert: `#nav-toggle` computed `touch-action` == `"manipulation"`; `#view-chat` has the `chat-booted` class; `.chat-bottom` AND `#composer` computed `position` == `"sticky"` (the handover landed at rest — if the boot flag never lands, the computed position is `static` and this fails).
2. `test_hamburger_tap_opens_menu_and_hides_cluster` — same load: `page.tap("#nav-toggle")` → `aria-expanded="true"`, `#app-nav` carries `.is-open`, AND `.chat-bottom` computed `visibility` == `"hidden"` (the marker + rule work end-to-end); then `page.tap` the "Tuning" menu row (locator: `#app-nav a[href="/tuning.html"]`) → `page.wait_for_url("**/tuning.html")` and `#view-tuning` visible; menu closed again (`aria-expanded="false"`, no `.is-open`, `body` has no `nav-menu-open`) and `.chat-bottom` computed `visibility` back to `"visible"`.
3. `test_boot_does_not_fire_view_refresh` — BEFORE `page.goto`, `page.add_init_script` that hooks `Element.prototype.dispatchEvent`:
```js
(() => {
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);
};
})();
```
(admin, fresh load of `/`, wait for the settled signal): `page.evaluate("window.__borRefreshFired")` == **0** (with pre-phase code the boot dispatch makes it ≥1 — this pin is the regression the phase fixes); then `page.tap` the "History" row after opening the menu, `wait_for_url("**/history.html")`, settle; open the menu again, `page.tap` the "Chat" row, settle → `page.evaluate("window.__borRefreshFired")` == **1** (a RE-SHOW of an already-mounted view still fires exactly once — the phase-77 contract is preserved, not deleted).
4. `test_sources_boot_regression` — admin, fresh 360px load of `/sources.html` (the rag boot is the one view state the cluster change must NOT disturb — `#view-rag` mounts lazily via `sources.js`): wait for `#view-rag` visible AND the settle signal; `page.tap("#nav-toggle")` → menu opens (`.is-open`); `page.tap` the "Chat" row (`#app-nav a[href="/"]`) → `#view-chat` visible, `.chat-bottom` computed `position` == `"sticky"` and `visibility` == `"visible"` (the handover class lands in this session too — it is added at app.js boot regardless of the boot view).
2. Keep the suite self-contained: NO changes to `tests/e2e/conftest.py`, `mock_llm.py`, or the existing suite. If a wait is flaky in the first runs, prefer the existing suites' settle-signal helpers over sleeps.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_mobile_chat_hamburger_boot.py -v --no-cov` — all four tests green (isolation gate per AGENTS.md rule 9; mock LLM; DB up: `podman compose up -d db`).
- Existing E2E must stay green UNCHANGED (verify by isolation runs): `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov`.
- Coverage: **>90%** on `app/` unaffected (no `app/` change; the suite runs `--no-cov` per the house pattern).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_mobile_chat_hamburger_boot.py -v --no-cov` green (4/4) in isolation.
- [ ] `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` green unchanged in isolation.
- [ ] Test 3 FAILS on pre-phase `router.js` (sanity: the pin is real — verify by temporarily reverting ONLY `frontend/assets/router.js` with `git stash push frontend/assets/router.js`, rerun, then `git stash pop`; the final state must be the phase-04 code).
- [ ] No changes to `tests/e2e/conftest.py`, `mock_llm.py`, or the existing suite files.
- [ ] `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,66 @@
# Task 06 — Full gate, device re-verification checklist, and the atomic commit
**Phase:** `88_mobile_chat_hamburger_boot` · **Source:** owner bug report (chat session 2026-09-08).
**Story:** mobile hamburger nav (bug fix).
## Objective
Run the complete validation gate (unit + integration, coverage, the new E2E story and the three regression E2E suites in isolation, lint + types), record the owner's post-deploy device re-verification steps, and land ONE atomic commit. **Per A4 the phase directory is NOT moved to `complete/` by this task** — the archive waits for the owner's on-device confirmation.
## Work
1. **Gate (all must pass; any failure → fix within this task, re-run):**
- `uv run pytest` (unit + integration, green).
- `uv run pytest --cov=app --cov-report=term-missing` — **>90%** on `app/` (no `app/` changes this phase; the floor is held by the untouched suite).
- `uv run pytest tests/e2e/test_mobile_chat_hamburger_boot.py -v --no-cov` (the NEW story — in isolation).
- `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` (the existing hamburger story — in isolation).
- `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` (the phase-65 sticky contract at rest — the task-02 gate rule must be invisible at rest).
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` (in isolation).
- `uv run ruff check . && uv run pyright` clean.
2. **Diff hygiene:** `git diff --stat` must be limited to `frontend/assets/styles.css`, `frontend/assets/app.js`, `frontend/assets/header.js`, `frontend/assets/router.js`, `tests/unit/test_mobile_chat_hamburger_boot.py` (new), `tests/e2e/test_mobile_chat_hamburger_boot.py` (new), and the `88_mobile_chat_hamburger_boot/` phase files. Anything else (especially `app/`) → stop and fix.
3. **Commit (one atomic, Conventional Commits, `--no-gpg-sign` per AGENTS.md rule 8):**
```
fix(ui): restore the mobile hamburger on chat-page cold boot on real devices
Owner bug report 2026-09-08: after the phase-76 SPA, the mobile
hamburger was dead on the chat page's fresh load / refresh on real
Android (other views, the login document, and client-side switches
into chat all worked). The click was not firing on a button whose
touch registered (hover fill) — a device-level touch-dispatch
interaction the spec-compliant Chromium probes could not
reproduce.
Removes all surviving candidate mechanisms (phase 88, A1):
- touch-action: manipulation on the mobile #nav-toggle (the
standard dead-button fix — no tap/zoom/pinch disambiguation);
- the composer cluster's sticky layer is deferred out of the first
layout commit (#view-chat.chat-booted, added two frames after the
boot settles — the client-side switch, which creates the identical
layer on a settled page, always worked);
- body.nav-menu-open hides .chat-bottom while the mobile menu is
open (no tap competition, no short-viewport overlap);
- router: the boot show no longer fires bor:view-refresh (the
phase-77 contract — "the first show (mount) and boot never fire
it" — now holds in code via the explicit boot: true flag).
Unit pins: tests/unit/test_mobile_chat_hamburger_boot.py.
E2E story: tests/e2e/test_mobile_chat_hamburger_boot.py (real
touch, cold-boot state, router boot contract, /sources.html
regression). Phase 88: .agents/phases/todo/88_mobile_chat_hamburger_boot/.
```
Stage ONLY the files listed in step 2 (the phase directory is staged WITH the commit, per AGENTS.md rule 8 — it stays under `todo/`; the move to `complete/` is a separate follow-up docs commit, below).
4. **Device re-verification hand-off (owner, post-deploy — record the steps, do NOT wait for the result in this task):** append nothing to the phase files in this commit; the report is recorded in the follow-up. The steps the owner runs (already in the phase overview's Completion Criteria, restated for the deploy note):
- Deploy the image to `brain.reeseapps.com`.
- BOTH phones, cache cleared: (1) cold load `https://brain.reeseapps.com/` → hamburger opens, a row navigates; (2) refresh `/` → repeat (the original repro); (3) send a message (scrollable state) → hamburger still works; while the menu is open the composer cluster is hidden; (4) fresh `/sources.html` + `login.html` → still work.
5. **Follow-up (explicit, NOT this task):** after the owner's report —
- **menu works on-device:** record the report in `00_phase.md` (house "Owner report" pattern, cf. phase 85's `Owner report 2026-09-08` lines), move the phase directory to `.agents/phases/complete/88_mobile_chat_hamburger_boot/`, and land a small docs commit (`docs(phases): archive phase 88 — owner device re-verification passed`).
- **still dead on-device:** do NOT archive as complete — open the follow-up instrumentation phase (`?dbg=nav` on-screen overlay: per-event touchstart/touchend/click log on `#nav-toggle` + classList + `elementFromPoint` + geometry, query-param-gated) and record the dead report in this phase's `00_phase.md` (archive it as the removed-mechanisms baseline with the report).
## Testing & Quality
- The full gate of step 1 IS this task's test contract; coverage **>90%** on `app/`.
- This task adds no code — it verifies and commits.
## Completion Criteria
- [ ] Every gate command in step 1 passes.
- [ ] `git diff --stat` (pre-commit) limited to the step-2 file list.
- [ ] One atomic `--no-gpg-sign` commit landed (message per step 3); `git log -1 --stat` confirms the file set.
- [ ] The phase directory is still under `.agents/phases/todo/` (A4 — the archive is the follow-up of step 5).
- [ ] The owner has the deploy note + the device re-verification steps (step 4).
@@ -0,0 +1,16 @@
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).
@@ -0,0 +1,86 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 28%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 53%]
........................................................................ [ 57%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 90%]
........................................................................ [ 95%]
........................................................................ [ 99%]
............. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,19 @@
Task 01 complete. All gates green.
**Implemented**
- `frontend/assets/styles.css`: one declaration — `touch-action: manipulation` (+ phase-88 comment) — added to the existing `@media (max-width: 640px)` `.nav-toggle` rule only; base rule and viewport meta untouched
- `tests/unit/test_mobile_chat_hamburger_boot.py` (new): house source-level pattern — shared readers for styles.css/app.js/header.js/router.js, brace-matched `@media (max-width: 640px)` extractor, 2 pins: (1) mobile `.nav-toggle` rule carries `touch-action: manipulation`, (2) file-wide count == 1 and base desktop rule clean (A2 mobile-only contract); structure open for tasks 02–04
**Results**
- `uv run pytest tests/unit/test_mobile_chat_hamburger_boot.py -v` → 2 passed
- `uv run pytest tests/unit/` → 1305 passed (unchanged, green)
- `uv run pytest --cov=app --cov-report=term-missing` → 1734 passed, TOTAL 99% (>90%)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` → 8 passed (isolation)
- `git diff frontend/assets/styles.css` → only the 3 added lines inside the mobile rule
**Decisions**
- Reused the house brace-matched media-block extraction (same as `test_hamburger_nav.py`) instead of the task's "to last `}`" substring suggestion — more robust, identical outcome
- No PLAN.md exists in this repo; worked from `00_phase.md` (full design included)
**Next pending task:** `88_mobile_chat_hamburger_boot/02_defer_cluster_sticky_until_boot.md`
@@ -0,0 +1,86 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
........................................................................ [ 99%]
...... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,21 @@
All gates green. Final verification complete.
## Report — Task 02: defer cluster sticky until boot
**Implemented**
- `styles.css`: verbatim phase-88 gate rule (`#view-chat:not(.chat-booted) .chat-bottom, … .composer { position: static; }`) placed directly after the `.chat-bottom` rule; both original sticky rules untouched
- `app.js`: double-`requestAnimationFrame` `chat-booted` handover added after `loadHealth();` inside the boot IIFE (only lines added, no re-indent); `node --check` passes
- `tests/unit/test_mobile_chat_hamburger_boot.py`: 3 new pins (gate rule w/ both selectors; original `.chat-bottom`/`.composer` still sticky; flag lands exactly once, double-rAF pattern, after `loadHealth();`) — 5/5 green
**Test / lint results**
- `uv run pytest tests/unit/test_mobile_chat_hamburger_boot.py -v` → 5 passed
- `uv run pytest tests/unit/ -q` → 1308 passed (unchanged)
- `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` → 4 passed (isolation smoke)
- `uv run pytest --cov=app` → 1737 passed, `app/` coverage 99% (>90%)
- `uv run ruff check . && uv run pyright` → clean
**Deviations**
- Task's gate-pin regex (`static\s*\}`) can't match the overview's verbatim CSS (`position: static;` — trailing semicolon); kept CSS verbatim, relaxed pin to `static\s*;?\s*\}`
- "Original rules untouched" pin uses line-anchored selector matching (the gate rule's selector list precedes the original `.composer` rule, so unanchored search hit the gate)
**Next pending task:** `03_hide_cluster_while_menu_open.md`
@@ -0,0 +1,86 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
........................................................................ [ 99%]
......... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,18 @@
All gates pass. Report:
**Task 03 complete — hide the chat cluster while the mobile menu is open**
- `header.js`: added `document.body.classList.toggle("nav-menu-open", open)` + house comment AFTER the `aria-expanded` line in `setNavMenu` (the single choke point) — diff shows only that addition
- `styles.css`: added `body.nav-menu-open .chat-bottom { visibility: hidden; }` with the phase-overview house comment, inside the `@media (max-width: 640px)` block directly after the `.app-nav.is-open` dropdown rule
- `tests/unit/test_mobile_chat_hamburger_boot.py`: +2 pins — `"nav-menu-open"` exactly once in header.js and inside the brace-matched `setNavMenu` body (new `_function_body` helper); rule present in the mobile block + exactly one occurrence in styles.css
- Fixed stale phase-46 pin `test_hamburger_nav.py::test_the_binding_toggles_only_the_container` — its exact-match `class_toggles == ["is-open"]` was an existence-list pin the phase-88 design necessarily extends; now allows `["is-open", "nav-menu-open"]` (documented in docstring)
**Results**
- `uv run pytest tests/unit/test_mobile_chat_hamburger_boot.py -v` → 7 passed
- `uv run pytest tests/unit/ -q` → 1310 passed, exit 0 (summary line swallowed by a stdout quirk on large runs; verified 1310 dots, no F/E)
- `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` → 8 passed (isolation)
- `uv run ruff check . && uv run pyright` → clean
**Deviations:** test regex uses `visibility:\s*hidden;?` (one-char `;?` vs task spec) — house CSS style ends declarations with a semicolon, same precedent as task 02's `position: static;?` pin; the shipped CSS is verbatim per the Design section.
**Next pending task:** `.agents/phases/todo/88_mobile_chat_hamburger_boot/04_router_boot_refresh_contract.md`
@@ -0,0 +1,86 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 28%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 53%]
........................................................................ [ 57%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 91%]
........................................................................ [ 95%]
........................................................................ [ 99%]
........... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,17 @@
Task 04 is complete and fully green.
**Implemented**
- `frontend/assets/router.js`: refresh guard is now `if (wasMounted && !opts.boot)` with the phase-88 house comment; the boot call site carries `boot: true` (`switchTo(bootName, { userInitiated: false, boot: true })`) — the boot show can no longer dispatch `bor:view-refresh` on the pre-mounted chat view; every later show (re-click, popstate, nav) dispatches exactly as before
- Two mechanical adaptations the task presupposed but the code lacked: signature `switchTo(name, { userInitiated })` → `switchTo(name, opts = {})` and the focus gate `if (userInitiated)` → `if (opts.userInitiated)` (behavior-identical; all call sites already pass an opts object)
- `tests/unit/test_mobile_chat_hamburger_boot.py`: +2 task-04 pins — the spec's guard regex verbatim, old `if (wasMounted) {` absent, signature pin, flag-scope pins (count, boot-call literal, position after the popstate listener, both user-initiated call sites carry bare `{ userInitiated: true }`)
- Fixed 2 stale phase-77 pins in `tests/unit/test_frontend_router.py` broken by the intentional contract change (guard + focus-gate text; docstrings updated — task-03 precedent)
**Results**
- `uv run pytest tests/unit/test_mobile_chat_hamburger_boot.py -v` → 9 passed; `uv run pytest tests/unit/ -q` → exit 0, 1312 collected, zero F/E
- `uv run pytest --cov=app` (unit+integration) → exit 0, **app/ 99%** (>90%)
- `uv run ruff check . && uv run pyright` → clean; `node --check router.js` → OK
- `git diff frontend/assets/router.js` = guard + `boot: true` + the two-line signature/focus adaptation only; nothing in `app/`
**Deviations (flagged, A3 intent preserved):** the task/overview describe **two** boot call sites (`history.state.view` + `push: false` opts) and `count("boot: true") == 2` — those never existed in this codebase (verified vs git HEAD; nothing reads `history.state`, and `switchTo` performs no pushState, so a `push` option would be a dead no-op). Fabricating a second call site would add dead code, so the count pin is **1** (flag rides the single boot call site, never popstate/nav), documented in the test docstring; the house comment's "two boot call sites" was corrected to "the boot call site" for the same reason.
**Next pending task:** `.agents/phases/todo/88_mobile_chat_hamburger_boot/05_e2e_story_suite.md`
@@ -0,0 +1,86 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 28%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 53%]
........................................................................ [ 57%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 90%]
........................................................................ [ 95%]
........................................................................ [ 99%]
............. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,7 @@
**Task 05 complete — E2E story suite for the mobile hamburger cold-boot fix**
- Implemented `tests/e2e/test_mobile_chat_hamburger_boot.py` (new, self-contained): 4 tests driven by REAL touch (`has_touch=True`, 360×800, `page.tap`) from FRESH navigations only (the cold-boot dead state): (1) toggle `touch-action: manipulation` + `chat-booted` sticky handover at rest (`.chat-bottom`/`#composer` sticky); (2) touch tap opens the menu with `.chat-bottom` `visibility: hidden`, "Tuning" row navigates client-side, menu closes with marker gone and cluster visible again; (3) `add_init_script` hook on `dispatchEvent` — cold boot fires 0 `bor:view-refresh`, History mount fires 0, chat re-show fires exactly 1; (4) fresh `/sources.html` rag boot regression (menu opens, Chat row lands on sticky+visible cluster).
- Sanity check per criteria: `git stash push frontend/assets/router.js` → test 3 FAILED (`got 1` — the stray pre-phase boot dispatch, pin is real) → `git stash pop` → phase-04 code restored, suite green again.
- Results: `uv run pytest tests/e2e/test_mobile_chat_hamburger_boot.py -v --no-cov` → 4/4 passed (3 runs); `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` → 8/8 passed unchanged; `uv run pytest --cov=app --cov-report=term-missing` → 1741 passed, TOTAL 99% (>90%); `uv run ruff check .` clean, `uv run pyright` → 0 errors.
- Decisions: `login(next=…)` form redirect IS the fresh document load (the repro's "fresh load of /"); `#view-history`/`#view-tuning` visibility used as the deterministic post-mount settle (the router un-hides only after the lazy mount resolves). No changes to conftest.py, mock_llm.py, or existing suites; working tree otherwise untouched by this task.
- Next pending task: `.agents/phases/todo/88_mobile_chat_hamburger_boot/06_verify_and_commit.md` (full gate + device re-verification checklist + atomic commit; archive deferred to the owner's on-device confirmation per A4).
@@ -0,0 +1,86 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 28%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 53%]
........................................................................ [ 57%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 90%]
........................................................................ [ 95%]
........................................................................ [ 99%]
............. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,12 @@
All gates green. Everything is verified; per executor rules I leave all changes in the working tree (no `git add`/`commit` — the harness lands the single atomic commit) and the phase directory stays under `todo/` (A4).
**Task 06 report — phase 88 full gate**
- **Implemented (tasks 01–05, verified this task):** `touch-action: manipulation` on mobile `#nav-toggle`; `#view-chat.chat-booted` double-rAF sticky handover (app.js + CSS gate); `body.nav-menu-open` marker in `setNavMenu` + ≤640px `visibility: hidden` rule; router boot contract (`wasMounted && !opts.boot`, `boot: true` at the boot call site); new unit module + 4-test real-touch E2E story.
- **Gate results:** `uv run pytest --cov=app --cov-report=term-missing` → 1741 passed, **99%** on `app/` (>90%); `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.
- **Pin sanity (proven real):** E2E test 3 fails on pre-phase `router.js` ("got 1" stray boot refresh), passes post-phase; unit pins pass on final assets.
- **Diff hygiene:** limited to the 4 `frontend/assets` files, the 2 new test files, and the `88_mobile_chat_hamburger_boot/` phase+report dirs. **Deviation (necessary):** `tests/unit/test_frontend_router.py` + `tests/unit/test_hamburger_nav.py` (2 surgical pin-text updates) — their exact-text pins collide with the phase-mandated guard/marker text; without them 3 tests fail (verified by stash). Minimal, contract-preserving, phase-88 cited in docstrings. Nothing in `app/`.
- **Router deviation (task 04, documented in unit test):** the codebase has ONE boot call site (no `history.state` branch, as the design overview described), so the `boot: true` count pin is 1, not 2.
- **Owner deploy note (A4):** deploy image, then on BOTH phones (cache cleared): (1) cold load `/` → hamburger opens, a row navigates; (2) refresh `/` → repeat; (3) send a message (scrollable) → hamburger works, cluster hidden while menu open; (4) fresh `/sources.html` + `login.html` still work. Report gates the archive — phase stays in `todo/` until the owner confirms.
Next pending: none in this phase (task 06 was last) — follow-up awaits the owner's device report (archive docs commit or `?dbg=nav` instrumentation phase).
@@ -0,0 +1,86 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 28%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 53%]
........................................................................ [ 57%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 90%]
........................................................................ [ 95%]
........................................................................ [ 99%]
............. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
+11
View File
@@ -2491,4 +2491,15 @@ window.addEventListener("pagehide", () => {
if (!openedSaved) restoreConversation(); if (!openedSaved) restoreConversation();
loadSuggestions(); loadSuggestions();
loadHealth(); loadHealth();
// Phase 88: the sticky cluster's compositor layer is born AFTER the
// boot paint — not in the first layout commit (see the styles.css
// gate). Two frames: frame 1 paints the settled boot (empty state or
// the restored conversation) with the cluster static; frame 2 pins it.
// A pre-settle throw leaves the cluster static — a degraded boot is
// already degraded (the gate/header above it), acceptable.
requestAnimationFrame(() =>
requestAnimationFrame(() => {
document.getElementById("view-chat")?.classList.add("chat-booted");
}),
);
})(); })();
+7
View File
@@ -277,6 +277,13 @@ function setNavMenu(open) {
if (!appNav || !navToggle) return; if (!appNav || !navToggle) return;
appNav.classList.toggle("is-open", open); appNav.classList.toggle("is-open", open);
navToggle.setAttribute("aria-expanded", open ? "true" : "false"); navToggle.setAttribute("aria-expanded", open ? "true" : "false");
// Phase 88: body-level marker — while the mobile menu is open, the
// chat's sticky bottom cluster is hidden (styles.css, ≤640px block):
// it must not compete for taps with the open menu, and on short
// viewports it overlaps the menu's lower rows. Every close path
// (Esc / outside-click / media) funnels through setNavMenu, so the
// marker can never stick.
document.body.classList.toggle("nav-menu-open", open);
} }
if (navToggle && appNav) { if (navToggle && appNav) {
+11 -4
View File
@@ -169,7 +169,7 @@ let current = null; // the visible view name (null until boot resolves)
head/nav state. `userInitiated` marks navbar-click / popstate head/nav state. `userInitiated` marks navbar-click / popstate
switches: only those focus the target view (its tabindex="-1") and switches: only those focus the target view (its tabindex="-1") and
land the viewport at the top — a boot switch never steals focus. */ land the viewport at the top — a boot switch never steals focus. */
async function switchTo(name, { userInitiated }) { async function switchTo(name, opts = {}) {
const root = viewEls[name]; const root = viewEls[name];
if (!root) return; if (!root) return;
@@ -222,7 +222,14 @@ async function switchTo(name, { userInitiated }) {
a first show (the mount's own load is the first fetch) and boot a first show (the mount's own load is the first fetch) and boot
never dispatch. A listening view re-runs its load; the chat view never dispatch. A listening view re-runs its load; the chat view
never listens (phase-76 stream survival). */ never listens (phase-76 stream survival). */
if (wasMounted) { /* Phase 88: the BOOT show is the view's first display — the phase-77
contract says the first show (mount) and boot never fire the
refresh. `mounted.chat` starts true (app.js pre-mounts the chat),
so the cold boot's switchTo("chat") was hitting the wasMounted
branch; the explicit boot flag (the boot call site below) makes
the contract hold. Every LATER show — re-click on the active
link, popstate, any navigation — still fires exactly as before. */
if (wasMounted && !opts.boot) {
root.dispatchEvent(new CustomEvent("bor:view-refresh")); root.dispatchEvent(new CustomEvent("bor:view-refresh"));
} }
@@ -230,7 +237,7 @@ async function switchTo(name, { userInitiated }) {
click / popstate) — never on initial boot (no focus steal on click / popstate) — never on initial boot (no focus steal on
load). The top landing mirrors what the old per-view page loads load). The top landing mirrors what the old per-view page loads
did (user intent, not a streaming-frame autoscroll). */ did (user intent, not a streaming-frame autoscroll). */
if (userInitiated) { if (opts.userInitiated) {
window.scrollTo(0, 0); window.scrollTo(0, 0);
root.focus({ preventScroll: true }); root.focus({ preventScroll: true });
} }
@@ -279,4 +286,4 @@ if (nav) {
falls back to chat (the shell's default view). userInitiated:false falls back to chat (the shell's default view). userInitiated:false
— boot never focuses (no focus steal on load). */ — boot never focuses (no focus steal on load). */
const bootName = VIEW[window.location.pathname] ?? "chat"; const bootName = VIEW[window.location.pathname] ?? "chat";
switchTo(bootName, { userInitiated: false }); switchTo(bootName, { userInitiated: false, boot: true });
+25
View File
@@ -1263,6 +1263,19 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
position: sticky; position: sticky;
bottom: env(safe-area-inset-bottom, 0); bottom: env(safe-area-inset-bottom, 0);
} }
/* Phase 88: pre-boot, the composer cluster is NOT sticky. The sticky
promotion (a compositor layer) is deferred out of the first layout
commit — on real Android, the cluster's sticky layer born at first
commit was eating the hamburger's click (owner bug report
2026-09-08; the client-side switch into chat — same layer, born on
a settled page — always worked). app.js adds .chat-booted two
frames after boot settles; no-JS keeps the cluster static, which in
the only no-JS state (empty, 100dvh document) sits exactly where
the pin would. */
#view-chat:not(.chat-booted) .chat-bottom,
#view-chat:not(.chat-booted) .composer {
position: static;
}
.composer { .composer {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -3550,6 +3563,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
border: 0; border: 0;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
touch-action: manipulation; /* Phase 88: standard dead-mobile-button fix — the
toggle's taps must not wait on the double-tap-zoom/pinch disambiguation
(owner bug report 2026-09-08: dead hamburger on chat-page cold boots). */
} }
.nav-toggle:hover { background: var(--brand-soft); color: var(--brand-ink); } .nav-toggle:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* The icon is sized (an unsized inline SVG would default to 300px /* The icon is sized (an unsized inline SVG would default to 300px
@@ -3588,6 +3604,15 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
pointer-events: auto; pointer-events: auto;
transition: opacity 180ms ease, transform 180ms ease, visibility 0s; transition: opacity 180ms ease, transform 180ms ease, visibility 0s;
} }
/* Phase 88: while the mobile menu is open, the chat's sticky bottom
cluster is hidden — it is the other positioned/layered element on
the page and must not compete for taps with the open menu (it also
overlaps the menu's lower rows on short viewports — menu y58→417
vs cluster top y395 at 390×600). visibility (not display): layout
is preserved, so closing never reflows the chat column. */
body.nav-menu-open .chat-bottom {
visibility: hidden;
}
/* Menu rows: comfortable ≥44px targets (0.75rem × 2 + the 1rem line) /* Menu rows: comfortable ≥44px targets (0.75rem × 2 + the 1rem line)
and readable text — replaces the old .nav-link pill squeeze. */ and readable text — replaces the old .nav-link pill squeeze. */
.app-nav .nav-link { padding: 0.75rem 1.25rem; font-size: 1rem; } .app-nav .nav-link { padding: 0.75rem 1.25rem; font-size: 1rem; }
@@ -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 """The target view is focused ONLY when the switch is
user-initiated (navbar click / popstate) — the boot switch passes user-initiated (navbar click / popstate) — the boot switch passes
userInitiated:false, so a page load never steals focus. The top 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() js = _js()
fn = js.find("async function switchTo") fn = js.find("async function switchTo")
body = js[fn : js.find("\n}", fn)] body = js[fn : js.find("\n}", fn)]
flag = body.rfind("if (userInitiated)") flag = body.rfind("if (opts.userInitiated)")
focus = body.find("root.focus(") focus = body.find("root.focus(")
scroll = body.find("window.scrollTo(0, 0)") scroll = body.find("window.scrollTo(0, 0)")
assert 0 <= flag < scroll < focus, "focus + top landing sit inside the flag" 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 (``mounted[name] = true``) — so the first show (the mount) and boot
never dispatch: the mount's own load is the first fetch. Event never dispatch: the mount's own load is the first fetch. Event
order: the view is visible and the head/nav state is written 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() js = _js()
assert '"bor:view-refresh"' in js, "the refresh event literal must exist" assert '"bor:view-refresh"' in js, "the refresh event literal must exist"
fn = js.find("async function switchTo") 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 " "the wasMounted capture must precede the mount-once set "
"(first show is exempt from the refresh)" "(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"))') dispatch = body.find('root.dispatchEvent(new CustomEvent("bor:view-refresh"))')
assert 0 <= gate < dispatch < gate + 120, ( assert 0 <= gate < dispatch < gate + 120, (
"the dispatch must sit inside the wasMounted guard" "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 """The auth visibility contract is untouched: the binding never
assigns ``.hidden`` and never touches the whoami links — hidden assigns ``.hidden`` and never touches the whoami links — hidden
links stay hidden inside the menu, exactly as on the inline bar. 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())) section = _js_clean(_hamburger_section(_js()))
assert ".hidden" not in section, ( assert ".hidden" not in section, (
"the hamburger binding must not hide/reveal any element" "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, ( assert link not in section, (
f"the hamburger binding must not touch the {link!r} link" 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) class_toggles = re.findall(r"classList\.(?:add|remove|toggle)\(\s*\"([^\"]+)\"", section)
assert class_toggles == ["is-open"], ( assert class_toggles == ["is-open", "nav-menu-open"], (
f"only the container .is-open class may move (found {class_toggles})" 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)"
)