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
+11
View File
@@ -2491,4 +2491,15 @@ window.addEventListener("pagehide", () => {
if (!openedSaved) restoreConversation();
loadSuggestions();
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;
appNav.classList.toggle("is-open", open);
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) {
+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
switches: only those focus the target view (its tabindex="-1") and
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];
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
never dispatch. A listening view re-runs its load; the chat view
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"));
}
@@ -230,7 +237,7 @@ async function switchTo(name, { userInitiated }) {
click / popstate) — never on initial boot (no focus steal on
load). The top landing mirrors what the old per-view page loads
did (user intent, not a streaming-frame autoscroll). */
if (userInitiated) {
if (opts.userInitiated) {
window.scrollTo(0, 0);
root.focus({ preventScroll: true });
}
@@ -279,4 +286,4 @@ if (nav) {
falls back to chat (the shell's default view). userInitiated:false
— boot never focuses (no focus steal on load). */
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;
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 {
display: flex;
align-items: flex-end;
@@ -3550,6 +3563,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
border: 0;
border-radius: var(--radius-sm);
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); }
/* 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;
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)
and readable text — replaces the old .nav-link pill squeeze. */
.app-nav .nav-link { padding: 0.75rem 1.25rem; font-size: 1rem; }