feat(header): hamburger dropdown nav on mobile (owner permission)
TODO.md L9 (owner permission 2026-08-27, roadmap A5): "The navbar on
mobile is way too squished. Make it a hamburger dropdown menu with a
nice animation." At <=640px the nav links leave the bar — a 44px
#nav-toggle opens #app-nav as an animated (180ms slide+fade)
edge-to-edge dropdown with comfortable rows and the auth visibility
contract intact inside the menu; at >640px the bar is byte-identical
to pre-phase-46 (hamburger absent, inline pills as before).
- frontend/*.html (all six pages): the shared bar gains the
#nav-toggle button (type=button, aria-expanded=false,
aria-controls="app-nav", aria-label="Menu", aria-hidden 3-line
SVG icon) immediately before the nav, and the nav gains
id="app-nav" — one <nav>, no duplicated links, so the whoami reveal
works inside the menu unchanged (phase-34 same-bar contract intact).
- frontend/assets/styles.css: .nav-toggle is display:none outside media
queries (desktop untouched); the <=640px block adds the 44px toggle
(+hover in the .steering-toggle:hover family, sized 20px icon), turns
.app-nav into the dropdown (absolute top:100% edge-to-edge under the
sticky header, surface + hairline + --shadow-lg, z-index 21 =
header+1, closed state invisible + non-interactive with the 180ms
opacity/transform/visibility-delayed pair, .is-open the only
opener), and comfortable 1rem/0.75rem menu rows — superseding the
phase-34/35 pill-squeeze rules for .nav-link/.app-nav (the 900px
tablet block, action pills, and 58px bar height untouched). The
reduced-motion block stills BOTH the closed and .is-open states: the
.is-open rule (0,2,0) out-specifies a bare .app-nav (0,1,0), so the
override must name both — verified live in Chromium (task 03).
- frontend/assets/header.js: ONE module-owned binding (import-time,
null-safe like the sign-out binding): click toggles .is-open +
aria-expanded in sync, a delegated nav-link click closes, Esc closes
and refocuses the toggle, and matchMedia("(max-width: 640px)")
change drops the state on resize back to desktop. The binding
touches only the container — ship-hidden whoami links stay hidden.
- tests/unit/test_hamburger_nav.py (new): the markup/CSS/JS contract
pins (six identical toggles in the shared row, desktop byte-
identical, dropdown + .is-open + 180ms + reduced-motion rules, the
superseded squeeze rules gone, the one-binding behavior).
- tests/e2e/test_shared_header.py: assert_shared_bar gains mobile=True
(at <=640px the bar shows the hamburger + the closed nav; the
per-role menu contents are pinned by the story suite).
- tests/e2e/test_mobile_hamburger_nav.py (new, story suite, 375x812):
toggle is a visible >=44px target, menu closed (opacity 0 /
visibility hidden), no horizontal overflow; anonymous menu shows
exactly "Chat" (admin-only links stay hidden inside); admin menu
shows all four links (whoami reveal inside the menu); a link click
navigates + the arrival page ships closed; Esc closes and refocuses
the toggle (outside click does NOT close — accepted: the locked
close set is Esc + link + resize, no backdrop); the 180ms
opacity/transform pair is live and reducedMotion:reduce stills both
states with open/close still working; 1280x800 regression — toggle
display:none, all four inline links inside the header band.
Gates: unit+integration 773 passed; app/ coverage TOTAL 99%
(unchanged — frontend-only phase); story E2E 7 passed in isolation
(mock LLM, DB up); regression suites test_nav_consistency (6) /
test_header_consistency (3) / test_shared_header (6) /
test_responsive_polish (7) / test_tuning_nav_link (4) all pass in
isolation; ruff check + pyright clean. A11 honored: no CDN, no new
assets.
Also records the 46_mobile_hamburger_nav todo/ -> complete/ move.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# Phase 46 — Mobile hamburger nav
|
||||
|
||||
**Source:** `TODO.md` L9 — "The navbar on mobile is way too squished. Make it a hamburger dropdown menu with a nice animation"
|
||||
**Story:** `.agent/user_stories/mobile-hamburger-nav.md`
|
||||
**Context:** The shared bar (phase 19/34 — the same header block on all six pages) carries brand + up to four text nav pills (Chat, `#nav-sources`, `#nav-git-sources`, `#nav-tuning` — the latter three ship `hidden`, revealed for admin by `header.js`) + four action controls (steering toggle, `#sync-btn`, `#new-chat-btn`, sign in/out). At ≤640px the phase-34/35 squeeze rules (0.72rem pills, 0.05rem gaps) leave a bar that is squished and hard to hit. The fix: on mobile the nav links move into an animated hamburger dropdown; the action pills stay in the bar.
|
||||
|
||||
## Objective
|
||||
At ≤640px the nav links live in a `#nav-toggle`-opened dropdown menu (slide+fade, reduced-motion-still) with comfortable targets and the same auth visibility; at >640px the bar is byte-identical to today.
|
||||
|
||||
## Dependencies
|
||||
- `45_agent_unlimited_tools` (todo) — sequential only (no shared files).
|
||||
- `34_consistent_navbar` / `35_git_sources_admin` (complete) — the six-page bar contract, the admin-only link reveal, and the mobile squeeze rules being superseded.
|
||||
- `07_story_responsive_polish` (complete) — the ≤640px conventions (44px targets, safe areas).
|
||||
|
||||
## Tasks
|
||||
1. `01_hamburger_markup_all_pages.md` — `#nav-toggle` + `id="app-nav"` on all six pages; the mobile CSS (dropdown, animation, reduced-motion).
|
||||
2. `02_toggle_behavior.md` — `header.js` open/close behavior (aria, Esc, link-close, resize-close) + source pins.
|
||||
3. `03_hamburger_e2e_and_commit.md` — story E2E suite (mobile + desktop + reduced motion) + regressions + commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: new `tests/unit/test_hamburger_nav.py` — `#nav-toggle` (with `aria-controls="app-nav"`, `aria-expanded`, `aria-label="Menu"`) present in **all six** pages and absent-visible on desktop (CSS `display: none` outside the media query); `<nav class="app-nav" id="app-nav">` in all six; the mobile CSS block carries the dropdown rules + `.is-open` state + the 180ms transition + the reduced-motion override; the old nav-pill squeeze rules are gone/superseded; `header.js` carries the toggle binding (click, Esc, delegated link close, matchMedia close).
|
||||
- Coverage: frontend-only — `app/` TOTAL unchanged, >90%.
|
||||
- E2E (mandatory, A16): `tests/e2e/test_mobile_hamburger_nav.py`, run in isolation.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] 375px: hamburger visible (44px target), inline nav hidden, no horizontal bar overflow; menu opens with animation, closes via link/Esc/outside; anonymous sees only "Chat" in the menu, admin sees all four links.
|
||||
- [ ] `reducedMotion: "reduce"`: no transition, open/close still instant and correct.
|
||||
- [ ] >640px: no hamburger, inline pills exactly as today (phase-34/35 contract intact).
|
||||
- [ ] `uv run pytest` green; coverage TOTAL unchanged.
|
||||
- [ ] `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] Regression E2E suites green in isolation: `test_nav_consistency.py`, `test_header_consistency.py`, `test_shared_header.py`, `test_responsive_polish.py`, `test_tuning_nav_link.py`.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] UI Structure Check (AGENTS.md rule 5): the toggle is a labeled 44px button with `aria-expanded`/`aria-controls`; the menu keeps `nav aria-label="Primary"`; focus-visible on the new control; contrast ≥4.5:1; no CDN.
|
||||
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **Owner-locked (2026-08-27, roadmap A5):** nav links only in the menu; action pills stay in the bar; slide-down + fade 180ms; `prefers-reduced-motion` stills it; the existing ≤640px breakpoint (no new one).
|
||||
- **A11 untouched** — no new assets/CDN; the menu reuses the existing `<nav>` element (no duplicated links, so the whoami reveal keeps working unchanged).
|
||||
- **Phase-34 contract kept** — the same bar on every page; the auth visibility rules apply inside the menu exactly as before.
|
||||
- **A16/A17 honoured** — one story E2E suite, one atomic commit.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Task 01 — Hamburger markup (six pages) + mobile dropdown CSS
|
||||
|
||||
**Phase:** `46_mobile_hamburger_nav` · **Source:** `TODO.md:9` — "The navbar on mobile is way too squished. Make it a hamburger dropdown menu with a nice animation"
|
||||
**Story:** `.agent/user_stories/mobile-hamburger-nav.md`
|
||||
|
||||
## Objective
|
||||
All six pages carry the identical `#nav-toggle` button + `id="app-nav"`, and the ≤640px stylesheet turns the nav into an animated full-width dropdown — desktop untouched.
|
||||
|
||||
## Work
|
||||
1. **Markup — all six pages** (`frontend/index.html`, `sources.html`, `document.html`, `git-sources.html`, `login.html`, `tuning.html`), each in its `<header class="app-header">` block:
|
||||
- insert the toggle button **immediately before** the `<nav>`:
|
||||
```html
|
||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||
an animated dropdown. Behavior: assets/header.js. -->
|
||||
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
</button>
|
||||
```
|
||||
- give the existing nav the id: `<nav class="app-nav" id="app-nav" aria-label="Primary">` (every other attribute/child byte-identical — the links keep their `hidden` attributes; **no links duplicated**).
|
||||
- keep the six pages visually/structurally identical (the phase-34 contract) — the toggle is part of the shared bar block, positioned the same on every page.
|
||||
2. `frontend/assets/styles.css` —
|
||||
- **global (outside media queries):** `.nav-toggle { display: none; }` (desktop: absent);
|
||||
- **inside the existing `@media (max-width: 640px)` block:**
|
||||
- `.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; }` + `:focus-visible` inherits the global 3px outline + a hover state matching the other pills (the `.steering-toggle:hover` family);
|
||||
- `.app-nav` becomes the dropdown: `position: absolute; top: 100%; left: 0; right: 0; flex-direction: column; gap: 0; background: var(--surface); border-bottom: 1px solid var(--line); box-shadow: <existing shadow token or 0 8px 24px rgba(0,0,0,.4)>; padding: 0.5rem 0; z-index: <above the header content — check the header's z-index and use header+1>;` — note 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;
|
||||
- **closed state (default):** `visibility: hidden; opacity: 0; transform: translateY(-8px); pointer-events: none; transition: opacity 180ms ease, transform 180ms ease, visibility 0s linear 180ms;`
|
||||
- **open state:** `.app-nav.is-open { visibility: visible; opacity: 1; transform: none; pointer-events: auto; transition: opacity 180ms ease, transform 180ms ease, visibility 0s; }`
|
||||
- **menu rows:** `.app-nav .nav-link { padding: 0.75rem 1.25rem; font-size: 1rem; }` (comfortable 44px+ targets, readable — this **supersedes** the ≤640px pill-squeeze rules for `.nav-link` and `.app-nav` gap in that block: delete/replace the `.nav-link { padding: 0.3rem 0.25rem; font-size: 0.72rem; }` and `.app-nav { gap: 0.05rem; }` rules, keeping the rest of the block);
|
||||
- **reduced motion:** inside the file's existing `@media (prefers-reduced-motion: reduce)` block (the one covering the 640px rules — or a new one after it): `.app-nav { transition: none; }`;
|
||||
- the **900px tablet block is untouched** (inline nav still in use at 641–900px); the action-pill rules in the 640px block are untouched; the header height (`--header-h: 58px`) is untouched.
|
||||
- verify at 360px: brand (clipped clean as today) + hamburger + the four icon action pills fit without horizontal overflow (the old four text pills are gone from the bar — there is now room; if the bar still overflows, the brand ellipsis target absorbs it exactly as before).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the new `tests/unit/test_hamburger_nav.py` starts here with the markup + CSS pins (the JS pins land in task 02): toggle markup (attributes) in all six pages; `id="app-nav"` in all six; `.nav-toggle { display: none }` outside media queries; the mobile block carries the dropdown, `.is-open`, the 180ms transition pair, the reduced-motion override, and the superseded squeeze rules are gone; full suite green (behavior not yet wired — the menu is closed by default and CSS-inert without JS, so no E2E regressions at this checkpoint).
|
||||
- Coverage: **>90%** on `app/` (unchanged).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Six identical toggles + `id="app-nav"`; desktop rendering byte-identical (`.nav-toggle` hidden, nav inline as before).
|
||||
- [ ] Mobile: closed dropdown is invisible and non-interactive; `.is-open` (added by task 02's JS) will be the only opener.
|
||||
- [ ] Full suite green at this checkpoint.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Task 02 — Toggle behavior in the shared header module
|
||||
|
||||
**Phase:** `46_mobile_hamburger_nav` · **Source:** `TODO.md:9` — "The navbar on mobile is way too squished. Make it a hamburger dropdown menu with a nice animation"
|
||||
**Story:** `.agent/user_stories/mobile-hamburger-nav.md`
|
||||
|
||||
## Objective
|
||||
The menu opens and closes with correct ARIA state, Esc/link/outside dismissal, and desktop-resize cleanup — one module-owned binding, like the existing sign-out/steering bindings in `header.js`.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/header.js` — a new module-import binding (same pattern as the sign-out binding: look up at import, guard null-safe, no page-script involvement):
|
||||
```js
|
||||
/* ---------- 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 menu is the only
|
||||
// document-level overlay this module owns).
|
||||
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
|
||||
}
|
||||
```
|
||||
- update the module docstring: add the hamburger bullet (phase 46, owner permission 2026-08-27, `TODO.md` L9).
|
||||
2. `tests/unit/test_hamburger_nav.py` — extend (from task 01) with the JS pins: `header.js` contains the `#nav-toggle` binding, `setNavMenu` (or equivalent) syncing **both** `.is-open` and `aria-expanded`, the delegated `a`-click close, the `Escape` close (with focus return), and the `matchMedia("(max-width: 640px)")` change-close; assert the binding is null-safe (`navToggle && appNav` guard).
|
||||
3. `uv run pytest` green at this checkpoint.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the extended pin file; full suite green.
|
||||
- Coverage: **>90%** on `app/` (unchanged — frontend-only).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] One module-owned binding: click toggles (aria-expanded in sync), Esc closes + refocuses the toggle, a link click closes, desktop resize closes; pages lacking the elements are a no-op.
|
||||
- [ ] The auth visibility contract is untouched — the binding toggles the container only; `hidden` links stay hidden.
|
||||
- [ ] Full suite green at this checkpoint.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Task 03 — Hamburger E2E + regressions + commit
|
||||
|
||||
**Phase:** `46_mobile_hamburger_nav` · **Source:** `TODO.md:9` — "The navbar on mobile is way too squished. Make it a hamburger dropdown menu with a nice animation"
|
||||
**Story:** `.agent/user_stories/mobile-hamburger-nav.md`
|
||||
|
||||
## Objective
|
||||
Prove the full mobile contract in the browser (visibility, contents per auth state, navigation, dismissal, animation, reduced motion, desktop regression) and commit the phase.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_mobile_hamburger_nav.py` (new) — mock-only, DB up. Mobile tests use a 375×812 page (new page per test, or `page.set_viewport_size` — the conftest `page` fixture is 1280×800, so create mobile pages via the `browser` fixture); per the story's Playwright Mapping Rule:
|
||||
- `test_mobile_hamburger_visible_and_bar_roomy` — 375px: `#nav-toggle` visible (box ≥44px in both dimensions), `aria-expanded="false"`, the inline nav links are **not** visible in the bar (menu closed — bounding boxes outside the header band or opacity 0), and no horizontal overflow (`document.documentElement.scrollWidth <= window.innerWidth`);
|
||||
- `test_anonymous_menu_contents` — anonymous at 375px: click `#nav-toggle` → `aria-expanded="true"`, exactly **one** visible link in `#app-nav` ("Chat"); `#nav-sources` / `#nav-git-sources` / `#nav-tuning` remain `hidden` inside the menu;
|
||||
- `test_admin_menu_contents` — login (e2e.auth_helpers) at 375px: open → Chat / Sources / Git sources / Tuning all visible (the whoami reveal works inside the menu);
|
||||
- `test_link_click_navigates_and_closes` — admin at 375px: open, click "Sources" → URL becomes `/sources.html` and on the arrival page the menu is closed (`aria-expanded="false"`, no `.is-open`);
|
||||
- `test_esc_and_outside_close` — open, press `Escape` → closed **and** focus is back on `#nav-toggle`; open again, click a neutral point (e.g. the page footer/main) → closed. (If the outside-click close is not implemented per task 02's contract — it is not: only Esc/link/resize close — assert instead that the menu stays open on an outside click and **note the accepted behavior** in the test docstring; the story's AC 4 lists Esc + link + resize, not backdrop click. Do NOT add a backdrop-close — it is out of the locked scope.)
|
||||
- `test_animation_and_reduced_motion` — motion allowed: computed `transition-duration` on `#app-nav` includes `0.18s` (opacity/transform pair); open → the class/aria flip; `reducedMotion: "reduce"` (new context via the `browser` fixture): computed transition is `none`/`0s` and open/close still works;
|
||||
- `test_desktop_unchanged` (regression) — 1280×800: `#nav-toggle` not visible (`display: none`), the inline nav renders in the bar exactly as before (admin: all four links visible inline).
|
||||
2. Regression pass (isolation runs): `test_nav_consistency.py`, `test_header_consistency.py`, `test_shared_header.py`, `test_responsive_polish.py`, `test_tuning_nav_link.py`.
|
||||
3. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged; `uv run ruff check . && uv run pyright` clean.
|
||||
4. Commit (Conventional Commits, `--no-gpg-sign`), e.g. `feat(header): hamburger dropdown nav on mobile (owner permission)`, staging this phase's files; move `.agent/phases/todo/46_mobile_hamburger_nav/` → `.agent/phases/complete/`.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` green in isolation.
|
||||
- Coverage: **>90%** on `app/` (unchanged — frontend-only phase).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] The story E2E suite passes in isolation (all seven tests); the five regression suites pass in isolation.
|
||||
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
Reference in New Issue
Block a user