diff --git a/.agent/phases/todo/46_mobile_hamburger_nav/00_phase.md b/.agent/phases/complete/46_mobile_hamburger_nav/00_phase.md
similarity index 100%
rename from .agent/phases/todo/46_mobile_hamburger_nav/00_phase.md
rename to .agent/phases/complete/46_mobile_hamburger_nav/00_phase.md
diff --git a/.agent/phases/todo/46_mobile_hamburger_nav/01_hamburger_markup_all_pages.md b/.agent/phases/complete/46_mobile_hamburger_nav/01_hamburger_markup_all_pages.md
similarity index 100%
rename from .agent/phases/todo/46_mobile_hamburger_nav/01_hamburger_markup_all_pages.md
rename to .agent/phases/complete/46_mobile_hamburger_nav/01_hamburger_markup_all_pages.md
diff --git a/.agent/phases/todo/46_mobile_hamburger_nav/02_toggle_behavior.md b/.agent/phases/complete/46_mobile_hamburger_nav/02_toggle_behavior.md
similarity index 100%
rename from .agent/phases/todo/46_mobile_hamburger_nav/02_toggle_behavior.md
rename to .agent/phases/complete/46_mobile_hamburger_nav/02_toggle_behavior.md
diff --git a/.agent/phases/todo/46_mobile_hamburger_nav/03_hamburger_e2e_and_commit.md b/.agent/phases/complete/46_mobile_hamburger_nav/03_hamburger_e2e_and_commit.md
similarity index 100%
rename from .agent/phases/todo/46_mobile_hamburger_nav/03_hamburger_e2e_and_commit.md
rename to .agent/phases/complete/46_mobile_hamburger_nav/03_hamburger_e2e_and_commit.md
diff --git a/frontend/assets/header.js b/frontend/assets/header.js
index d39d6b4..4204875 100644
--- a/frontend/assets/header.js
+++ b/frontend/assets/header.js
@@ -21,6 +21,18 @@
* contract on the SAME cached whoami (one fetch, no extra request);
* • the sign-out click binding (POST /api/logout → reload) — moved
* here from app.js so there is exactly one implementation;
+ * • the mobile hamburger binding (phase 46, owner permission
+ * 2026-08-27, TODO.md L9) — at ≤640px (CSS hides the button
+ * elsewhere) the #nav-toggle button opens the nav as an animated
+ * dropdown (#app-nav .is-open — the 180ms slide+fade state from
+ * task 01's CSS): a click toggles it with aria-expanded kept in
+ * sync, a nav link click shuts it (the navigation happens anyway),
+ * Esc shuts it and returns focus to the toggle, and resizing back
+ * to >640px drops the open state (matchMedia change) so
+ * aria-expanded stays honest. One binding for all six pages; a
+ * page without either element is a no-op. The binding toggles
+ * ONLY the container — the nav links keep their ship-hidden
+ * whoami contract (hidden links stay hidden inside the menu);
* • the steering-notes controls (phase 15, moved here from app.js in
* phase 34) — the #steering-toggle open/close + the #steering-panel
* list (newest-first, textContent-rendered, per-note delete, count
@@ -181,6 +193,46 @@ if (signOutBtn) {
});
}
+/* ---------- mobile hamburger (phase 46; module-owned) ----------
+ * ≤640px only (CSS hides the button elsewhere): #nav-toggle opens the
+ * nav as a dropdown (#app-nav .is-open — the animated state, task 01
+ * CSS). One binding for all six pages; a page without either element
+ * is a no-op, like the rest of this module. The nav LINKS keep their
+ * ship-hidden whoami contract (hidden links stay hidden inside the
+ * menu) — this binding only toggles the container. */
+const navToggle = document.querySelector("#nav-toggle");
+const appNav = document.querySelector("#app-nav");
+
+function setNavMenu(open) {
+ if (!appNav || !navToggle) return;
+ appNav.classList.toggle("is-open", open);
+ navToggle.setAttribute("aria-expanded", open ? "true" : "false");
+}
+
+if (navToggle && appNav) {
+ navToggle.addEventListener("click", () =>
+ setNavMenu(!appNav.classList.contains("is-open")));
+ // A link click navigates (or closes same-page) — shut the menu.
+ appNav.addEventListener("click", (e) => {
+ if (e.target.closest("a")) setNavMenu(false);
+ });
+ // Esc closes while open (document-level; the sync failure modal's
+ // Esc acts only while IT is open — the two never fight for a key).
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && appNav.classList.contains("is-open")) {
+ setNavMenu(false);
+ navToggle.focus(); // focus returns to the opener
+ }
+ });
+ // Resize back to desktop: the inline nav reappears — no stale open
+ // state (the .is-open class is scoped by the ≤640px CSS anyway, but
+ // dropping it keeps aria-expanded honest).
+ const mq = window.matchMedia("(max-width: 640px)");
+ const onMqChange = () => { if (!mq.matches) setNavMenu(false); };
+ if (mq.addEventListener) mq.addEventListener("change", onMqChange);
+ else mq.addListener(onMqChange); // older engines, defensive
+}
+
/* ---------- steering notes (phase 15; module-owned from phase 34) ----------
*
* The owner's tuning notes steer every future answer: they live in
diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css
index 6f9ed68..0a861eb 100644
--- a/frontend/assets/styles.css
+++ b/frontend/assets/styles.css
@@ -274,6 +274,14 @@ html::after {
.nav-link:hover { background: var(--brand-soft); color: var(--brand-ink); }
.nav-link.is-active { background: var(--brand); color: var(--bg); }
+/* Phase 46 (owner permission 2026-08-27, TODO.md L9): the mobile
+ hamburger button — desktop is byte-identical to before (the control
+ is absent outside the ≤640px block, which re-displays it and turns
+ the nav into the dropdown). :focus-visible inherits the global 3px
+ outline rule; the 44px target + the rest of the look live in the
+ ≤640px block below. */
+.nav-toggle { display: none; }
+
/* "New chat" reset (phase 14): ghost pill in the chat header, hover like
a nav link. ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft
≈6.9:1 — both WCAG AA. Icon-only below 640px (aria-label keeps the
@@ -2163,8 +2171,66 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
text-overflow: ellipsis;
white-space: nowrap;
}
- .nav-link { padding: 0.3rem 0.25rem; font-size: 0.72rem; }
- .app-nav { gap: 0.05rem; }
+ /* Phase 46 (owner permission 2026-08-27, TODO.md L9): the nav links
+ LEAVE the bar at phone widths — the old pill-squeeze rules for
+ .nav-link / .app-nav (0.72rem pills, 0.05rem gap, in place of this
+ comment) are superseded by the #nav-toggle dropdown below. The
+ action pills' squeeze rules further down are untouched, and the
+ 900px tablet block keeps squeezing the INLINE nav at 641–900px
+ (the hamburger is absent there). */
+ .nav-toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ height: 44px;
+ padding: 0;
+ color: var(--ink);
+ background: none;
+ border: 0;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ }
+ .nav-toggle:hover { background: var(--brand-soft); color: var(--brand-ink); }
+ /* The icon is sized (an unsized inline SVG would default to 300px
+ and blow the bar out); 20px reads as a proper hamburger inside
+ the 44px target. */
+ .nav-toggle svg { width: 20px; height: 20px; display: block; }
+ /* The nav becomes the dropdown. The containing block is the sticky
+ .app-header (.header-inner is not positioned), so the menu spans
+ the header's full width — edge to edge — intended on mobile;
+ z-index 21 = header (20) + 1, above the bar content. */
+ .app-nav {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ margin-left: 0;
+ flex-direction: column;
+ gap: 0;
+ background: var(--surface);
+ border-bottom: 1px solid var(--line);
+ box-shadow: var(--shadow-lg);
+ padding: 0.5rem 0;
+ z-index: 21;
+ /* Closed state (default): invisible and non-interactive — task 02's
+ header.js is the only opener (.is-open + aria-expanded). */
+ visibility: hidden;
+ opacity: 0;
+ transform: translateY(-8px);
+ pointer-events: none;
+ transition: opacity 180ms ease, transform 180ms ease, visibility 0s linear 180ms;
+ }
+ .app-nav.is-open {
+ visibility: visible;
+ opacity: 1;
+ transform: none;
+ pointer-events: auto;
+ transition: opacity 180ms ease, transform 180ms ease, visibility 0s;
+ }
+ /* 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; }
.new-chat-btn { padding: 0.4rem 0.3rem; }
.new-chat-label { display: none; }
.new-chat-btn svg { display: block; }
@@ -2248,3 +2314,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
main { padding-bottom: env(safe-area-inset-bottom, 0); }
}
+
+/* Phase 46: prefers-reduced-motion stills the mobile menu — no
+ 180ms slide+fade; open/close snaps (the visibility/opacity flip
+ applies instantly) and stays correct. BOTH states are named: the
+ .is-open rule (0,2,0) would otherwise out-specify a bare .app-nav
+ (0,1,0) and the OPEN transition would still animate. */
+@media (prefers-reduced-motion: reduce) {
+ .app-nav,
+ .app-nav.is-open { transition: none; }
+}
diff --git a/frontend/document.html b/frontend/document.html
index 77eed78..0167ea8 100644
--- a/frontend/document.html
+++ b/frontend/document.html
@@ -27,7 +27,14 @@
Brain of Reese
-