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 -