Files
ducoterra 10f86772ee phase: 18_nav_hamburger
**Phase 18 — collapse the nav bar into a hamburger: complete, all green.**

Verified (criteria):
- ≤640px: the tab links leave the bar, the ☰ is visible, and it opens a dropdown
  under the sticky masthead (`top: 100%`, `z-index: 41`, viewport-wide) with the
  five stacked 40px links; above 640px the one-row desktop masthead is unchanged
  and the button hidden — `data/logs/nav-task04-verify.cjs` 24/24 and
  `data/logs/nav-final-pass.cjs` 45/45; screenshots
  `data/logs/nav-hamburger-{closed,open}-360.png`, `data/logs/nav-masthead-desktop-1280.png` ✓
- The four close paths: the button again, a tab click (navigates and closes),
  Esc (focus back on the opener), and `matchMedia` resize back above the
  breakpoint (drops a stale `.is-open`; `aria-expanded` tracks state on every
  path through the single `setNavMenu()` funnel) ✓
- `uv run pytest` → 1454 passed, 15 skipped; `bash .agents/validate.sh` → exit 0
  (coverage 98.94% ≥ 94, ruff clean, pyright 0 errors, `node --check` + hygiene
  clean); served-page pins: button on all seven tabbed pages and nowhere on
  /login, CSS and binding compared on the 640px literal ✓

Real-device finding (mobile Chrome): the emulated 360px pass was blind to the
system top-edge zone — Android Chrome is edge-to-edge by default, so the sticky
bar started under the status bar and the first-row ☰ sat in a region the device
does not deliver taps to (unreachable, not unbound; the same symptom brain of
reese fixed in production). The ≤640px block now pads the bar by
`env(safe-area-inset-top, 0px)` (the block's `padding-block` value restated on
the top side), verified on the operator's phone; `env()` resolves to 0 on every
non-notch surface, so all other widths render byte-identical. The served-byte
pin's rule loop carries the line; CDP's `safeAreaInsets` override does not feed
`env()`, which is the honest limit of the emulated proof.

Docs: AGENTS.md *UI conventions* masthead entry extended with the three pins,
the drift-comparison, the safe-area rule, and the real-device verification
caveat; README's `web/` bullet names the ≤640px collapse with the desktop
masthead unchanged above the breakpoint.

Deviations: pinned the shipped token shape (`#nav-toggle { display:
var(--nav-toggle-display) }` + the `:root` token) instead of the task file's
literal `.nav-toggle { display: none }` — that literal loses to
`button.ghost { display: inline-flex }` (task 01 measured it); the test
docstring says so, so nobody "restores" the broken shape. `uv pip list
--outdated` still advisory (fastapi 0.153.0 → 0.156.2); this phase moved no
dependency. NOTES.md (emptied) / TODO.md (operator items added) are
harness-side changes — left in the working tree, as in phase 17.

Next pending phase: `.agents/phases/todo/19_ups_events_expandable/`.

LLM-Generated: true
2026-10-20 16:47:10 -04:00

4.1 KiB

Task 03 — The toggle binding in initChrome

Phase: 18_nav_hamburger · Source: TODO.md:3 — "The nav bar needs to collapse into a hamburger like brain of reese does" Story: n/a

Objective

One shared binding in app.js (the file every page loads, including /login) that makes the button work on all seven tabbed pages and is a no-op where the elements are absent. The behaviour mirrors brain of reese's phase-46 binding: a setNavMenu(open) funnel so no close path can leave the state stuck, aria-expanded honest at all times, close on link click, on Esc (focus returns to the button), and on resize back above the breakpoint.

Work

  1. src/dashboards/web/static/app.js — inside initChrome() (line 620), after the nav-status block and before the return, add a block in the file's per-element guard style (the themeButton pattern at line 626 is the shape: if (element) { … }):
    const navToggle = $('#nav-toggle');
    const appNav = $('#nav-tabs');
    if (navToggle && appNav) {
      const setNavMenu = (open) => {
        appNav.classList.toggle('is-open', open);
        navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
      };
      navToggle.addEventListener('click', () =>
        setNavMenu(!appNav.classList.contains('is-open')));
      // A link click navigates — shut the menu (event-delegated: the five
      // links are static markup, so one listener on the nav covers all of them).
      appNav.addEventListener('click', (event) => {
        if (event.target.closest('a')) setNavMenu(false);
      });
      // Esc closes while open; focus returns to the opener.
      document.addEventListener('keydown', (event) => {
        if (event.key === 'Escape' && appNav.classList.contains('is-open')) {
          setNavMenu(false);
          navToggle.focus();
        }
      });
      // Resize back to desktop: the inline nav reappears — drop the open state
      // so aria-expanded stays honest (the CSS hides the dropdown above 640px
      // regardless, but a stuck `is-open` would re-open on the next shrink).
      const mq = window.matchMedia('(max-width: 640px)');
      const onMqChange = () => { if (!mq.matches) setNavMenu(false); };
      if (mq.addEventListener) mq.addEventListener('change', onMqChange);
    }
    
    Notes: the breakpoint literal must match task 02's CSS block — if one drifts, the menu opens into a display:none nav. ASSUMPTION: that match is pinned by task 04's served-asset test (assert the 640px literal appears in both the served stylesheet's nav block and the served app.js), not by a shared constant — the file's convention is per-file literals (the 640px in CSS, 900px for the clock, and the page scripts' own numbers do not share a constant either).
  2. No body-level marker class (task 02's assumption carries: nothing on this app competes with the dropdown for taps, so brain's nav-menu-open marker is not copied).
  3. No change to the login page: initChrome already ships to it, and the if (navToggle && appNav) guard is the no-op — verify in task 04 that /login's served script is the same shared file (it is: every page loads /static/app.js) and the guard makes the binding inert there.

Testing & Quality

  • Integration: the binding's behaviour is verified in the browser (task 04 — the repo has no JS test runner; the pins are structural on the served bytes: the block is present in the served app.js, the four close paths named in a comment, node --check passes); the untouched Python suite stays green
  • Coverage: >90% on modified code (one JS function; the Python coverage gate applies to the test files task 04 adds)

Completion Criteria

  • at 360px: clicking ☰ opens the dropdown and aria-expanded reads true; clicking again closes it; clicking a tab link closes it as the page navigates
  • with the menu open: Esc closes it and focus is on the ☰ button; resizing to 700px closes it and the next shrink back to 360px leaves it closed (no stale is-open)
  • on /login the script loads and the guard no-ops (no console error, node --check clean)
  • uv run pytest -q green; bash .agents/validate.sh green (includes node --check of the modified app.js)