phase: 88_mobile_chat_hamburger_boot
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:
@@ -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`).
|
||||
+40
@@ -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.
|
||||
+40
@@ -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.
|
||||
+32
@@ -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).
|
||||
Reference in New Issue
Block a user