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
This commit is contained in:
2026-10-20 16:47:10 -04:00
parent b02c13f7d3
commit 10f86772ee
35 changed files with 752 additions and 14 deletions
+54 -1
View File
@@ -739,6 +739,43 @@ Responsibilities, one line each:
`severity` verdict the API sent
(`tests/test_api.py` fails if a page script names it again). A value the API should rate
gets a row in `severity.py`, never a bound in JS.
**The tab nav collapses into a hamburger at ≤640px** (phase 18): the seven tabbed pages each
carry `#nav-toggle` (`class="ghost nav-toggle"`, `type="button"`, `aria-controls="nav-tabs"`,
`aria-label="Toggle navigation"`, `☰`) between `.brand` and `<nav class="tabs" id="nav-tabs">`,
and `/login` carries neither — it renders the brand link and the theme toggle only. Its base
rule is `#nav-toggle { display: var(--nav-toggle-display) }` with `--nav-toggle-display: none`
in `:root`, and that token is a specificity ladder, not decoration: the button also carries
`button.ghost { display: inline-flex }`, so a `.nav-toggle { display: none }` loses and ships a
visible button into the desktop masthead. The `@media (max-width: 640px)` block reveals it
through the token, hides `nav.tabs`, wraps `.masthead` and `.masthead-right` into rows (hiding a
control to make the width work is not the deal), and pads the bar down by
`env(safe-area-inset-top, 0px)` (the block's `padding-block` value restated on the top side so
the two cannot drift), because a real device's top-edge zone — Android Chrome is edge-to-edge
by default, and iOS does the same once the page opts into `viewport-fit=cover` — does not
deliver taps to the page: the first-row button in it is *unreachable*, not unbound, and no
headless emulation has such a zone, so a fully green emulated pass still ships a dead button
(the same symptom brain of reese fixed in production; `env()` resolves to 0 on every non-notch
surface, which render byte-identical, and the dropdown's `top: 100%` follows the taller bar);
it renders `nav.tabs.is-open` as the dropdown:
the `nav` element itself, `position: absolute; top: 100%; z-index: 41` under the sticky bar — no
second menu element, no `body` marker class, no colour outside the tokens. One binding in
`app.js`'s `initChrome()` owns the state through a `setNavMenu(open)` funnel (`.is-open` on the
nav and `aria-expanded` on the button, always together) with **four close paths**: the button
again, a tab click delegated on the nav, `Esc` with focus returned to the button, and
`matchMedia` turning false on resize back above the breakpoint — which drops a stale `.is-open`
so the next shrink starts closed and `aria-expanded` never claims a menu that is not on screen.
The `if (navToggle && appNav)` guard is the whole `/login` story: the shared script loads there
and does nothing. The `640px` literal lives in two files — the stylesheet block and that media
query — and has to move in both, or the menu opens into a `display:none` nav. The contract is
pinned on the **served** bytes by three `tests/test_api.py` pins — the button on all seven pages
and nowhere on `/login`, and one test that **compares** the CSS media query with the JS
`matchMedia` instead of grepping for `640px` twice, so a drift fails with both numbers in the
message — and proven in a browser at 360px by `data/logs/nav-task04-verify.cjs` (the `/` +
`/system` interaction, 24 checks) and `data/logs/nav-final-pass.cjs` (the phase-completion
sweep: all seven tabbed pages at 360px and at 1280px, `/login` at both, 45 checks). The
safe-area line is pinned in the same test's rule loop; it was verified on the operator's
mobile Chrome rather than in an emulator, because CDP's `safeAreaInsets` override does not
feed `env(safe-area-inset-top)` — the one half of this contract a headless pass cannot prove.
Metric naming: dotted (`cpu.usage_pct`, `mem.used_pct`, `disk.used_pct@/srv`,
`gpu.busy_pct@0`, `llm.decode_tokens_per_second`, `ups.charge_pct`), `@stream` for per-device
@@ -960,7 +997,7 @@ set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no
./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health
./scripts/dev-server.sh logs
uv run pytest # hermetic: 1451 passed + 15 skipped (11 live,
uv run pytest # hermetic: 1454 passed + 15 skipped (11 live,
# 4 store_perf), no network
uv run pytest --cov # coverage gate (98.94%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
@@ -1883,6 +1920,22 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
which is the one route that must not be taken until that snapshot is re-staged from this repo:
the Oct-18 copy predates phase 17's template *and* its re-run guard, so it would rewrite that
unit silently — and no live change was made while pinning phase 16.
12. **The nav collapses on a phone; the page content below it does not** (phase 18's verification
pass, 2026-10-20). At 360px `/`'s fleet table measures 450-510 px inside a 320 px
`section.card`, and `.card { overflow: hidden }` clips the tail columns (`WORST DISK`,
`GPU BUSY`, `TEMP`, `UP`) with no scroll container to reach them — `table.data` only scrolls
with its `.scroll` modifier, which that table does not carry. Measured as pre-existing by
serving the `git show HEAD:` bytes of both `dash.css` and `pages/index.html` over a live
session: same clipping (a clipped card never un-clips when the document scrolls sideways).
What phase 18 did change is that the document no longer scrolls sideways at a phone width at
all — 360 px where it was 750 px — because the bar that overflowed its fixed 58 px row now
wraps, so nothing is lost and nothing gained: the masthead is complete at 360 px and the
table tail is still out of reach. The fix is a responsive-table pass (`.scroll` on the wide
tables, or a per-host card layout at ≤640px) and it touches pinned page shape on `/`, so it is
a phase rather than a cleanup. Two operator TODO items are unphased alongside it: never
accepting a non-mTLS agent (fleet gap 11's rollout, half of it), and keeping both
`⚙ Account` / `⚷ Certs` masthead links visible on the admin pages that today do not link to
themselves (phase 15's deliberate rule, now questioned).
## Locked decisions (and why)