phase: 20_masthead_nav_polish

**Phase 20 final verification pass — all four tasks already complete; verified in full.**

- Verified: three-row masthead CSS (`.masthead` flex-wrap + two `flex-basis: 100%`, `min-height`, no `margin-inline-start`), un-sticky `position: relative` + `z-index: 40`, 180 ms `visibility/opacity/transform` slide (closed `nav.tabs` / open `.is-open`, both states in the file's single reduced-motion block), both admin links on all 8 signed-in pages, `/login` neither.
- `uv run pytest` → **1464 passed, 15 skipped** · `uv run pytest --cov --cov-fail-under=94` → **98.94%** (floor 94, unchanged) · `ruff check src tests agent` clean · `pyright` 0 errors · **`bash .agents/validate.sh` EXIT=0** (incl. `node --check` + repo hygiene) · `uv run pytest -m live --live` → **11 passed**.
- Browser (live dev server, invariant 10): `20-task01-rows.cjs` 121 PASS/0 FAIL · `20-task02-unsticky.cjs` 81/0 · `20-task03-slide.cjs` 32/0 · `20-task04-admin-links.cjs` 87/0 · phase-18 `nav-final-pass.cjs` 45/45, `nav-task03-verify` 77, `nav-task04-verify` 24/24 · phase-19 scripts 11/22/24/29 all pass. Screenshots refreshed in `data/logs/` (`masthead-rows*`, `masthead-unsticky*`, `slide-*`, `admin-links-*`). Dev server restarted; `/api/health` all nine sources `ok`.
- Completion criteria: 3-row bar @1280/360 ✓ · scroll leaves bar behind + dropdown under whole bar ✓ · slide out/in 180 ms, reduced-motion snap ✓ · `/certs`→`⚷ Certs`, `/settings`→`⚙ Account` before theme toggle, outside `.tabs` ✓ · pytest/coverage/ruff/pyright ✓ · validate ✓ · no regression in tab structure, token reveal, safe-area padding, four close paths, status dots, theme toggle ✓.
- Docs: **AGENTS.md** gap 12 still said phase 20 was "in `todo/`… admin-link half has landed" and attributed the wrap to the old fixed 58 px row — rewritten to the completed state (three rows/un-sticky/slide/both links; 24 still todo; 21's `/certs` `.scroll` noted as not reaching `/`'s table). **README.md** `web/` bullet enumerated the controls row without `⏸ Pause` and omitted `/login` from the two-row list — both fixed. Everything else (setup, commands, env table vs `config.py`, routes vs `api.py`, agent/installer flags, testing policy) verified consistent.
- Deviations: no dependency bump — `uv pip list --outdated` still reports the pre-existing advisory `fastapi 0.153.0 → 0.156.4` (deps untouched this phase, validate treats it as advisory); `NOTES.md` is empty in the working tree from an operator edit outside these tasks (both items live in phases 20/24) — left as-is; no live-infra changes made or needed.
- Next pending phase: `21_page_cleanups` (pause button removal, `/system` GPU values, `/certs` table scroll).
This commit is contained in:
2026-10-20 22:49:37 -04:00
parent 95df8b695b
commit 6444a158f2
32 changed files with 960 additions and 106 deletions
+91 -20
View File
@@ -689,8 +689,8 @@ Responsibilities, one line each:
name. `/settings` (`pages/settings.html` + `static/page-settings.js`) is a **page and not a
sixth dashboard**: same `<head>` and masthead, the five tabs with **no** `aria-current` (it is
not one of them), no status dots, and it is reached from the `⚙ Account` link in
`.masthead-right` of every dashboard — outside `.tabs`, so the pinned tab structure is untouched
(`tests/test_api.py::test_every_page_links_to_both_admin_pages`). It prints
`.masthead-right` of every dashboard **including itself** — outside `.tabs`, so the pinned tab
structure is untouched (`tests/test_api.py::test_every_page_links_to_both_admin_pages`). It prints
`/api/auth/status` (name, expiry as a local timestamp *and* as time left), signs out through
`POST /api/auth/logout`, and changes the password through `POST /api/auth/password` after
checking locally what it can judge itself (non-empty current, ≥ 8 new characters —
@@ -705,8 +705,15 @@ Responsibilities, one line each:
any `401` bounce.
`/certs` (`pages/certs.html` + `static/page-certs.js`) is the second of those admin pages, and it
is the same shape for the same reasons: five tabs with **no** `aria-current`, no `#nav-status`,
and the `⚷ Certs` masthead link beside `⚙ Account` (neither admin page links to itself, which is
why the pinned two-link audit loops over two different tuples). Everything on it is
and both masthead links — `⚷ Certs` beside `⚙ Account` — exactly as the five dashboards render
them. **Both links are on both admin pages** (phase 20 task 04, `TODO.md:4`), which retires phase
15's "no self-link" precedent: chrome that changes shape depending on where you already are reads
as a broken button, while a link to the page you are on is a no-op that answers with the page you
were looking at (`data/logs/20-task04-admin-links.cjs` measures exactly that — one document
response, no redirect, the same cluster afterwards). The two links therefore sit on all eight
signed-in pages in one order (clock → `⚷ Certs` → `⚙ Account` → theme toggle), which is what lets
the pinned audit be one loop over all eight instead of two over different tuples; `/login` stays
the one page with neither. Everything on it is
`GET /api/certs`: the Root CA block, the display-only Dashboard client line (that certificate is
minted with the root and has no route of its own), and one row per host with its status chip,
serial, expiry (local timestamp *and* `Dash.duration` left), issued-vs-deployed `bundle_rev`,
@@ -739,6 +746,41 @@ 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 masthead is three left-justified rows at every width** (phase 20 task 01, `TODO.md:5-6`):
row 1 the brand, `#nav-toggle` and the five tabs; row 2 the `#nav-status` per-source dots; row 3
the `.masthead-right` controls (clock, `⏸ Pause`, `⚷ Certs`, `⚙ Account`, theme). That is CSS and
no markup change, which decides the mechanism: `#nav-status` is a **child** of
`.masthead-right`, so there is no wrapper to promote, and the rows are line breaks — `.masthead`
is `flex-wrap: wrap`, and both `#nav-status` and `.masthead-right` carry `flex-basis: 100%` with
`justify-content: flex-start` (line breaking uses a flex item's **base** size, before shrink, so
a 100 % item is alone on its line: the div's break starts row 2 of the bar and the dots' starts
row 2 inside it, leaving the controls to wrap to row 3). `.masthead-right` lost the
`margin-inline-start: auto` that pinned the cluster to the right edge of the one-row bar.
`flex-direction: column` was the other option the task allowed and is the wrong one: it puts the
brand and the tabs on separate rows. `height: var(--header-h)` became **`min-height`** (three rows
do not fit in 58 px and a fixed height clips the controls) and is declared **once** — the ≤640px
block no longer restates `height`/`min-height`/`flex-wrap`, and that pair drifting is exactly what
would clip the bar on a phone only. A page with no dots (`/certs`, `/settings`) renders two rows,
`/login` two (brand, then the theme toggle). Pinned on the served bytes by the three phase-20
tests in `tests/test_api.py` (the three rules' declarations, the mobile block's silence about
the bar's height, and the bar's `position`), and measured in a browser at 1280 px and 360 px by
`data/logs/20-task01-rows.cjs` (seven pages × two widths plus the dropdown; screenshots
`data/logs/masthead-rows*.png`).
**The bar is not sticky** (phase 20 task 02, `TODO.md:10` — "It shouldn't be sticky at all. We
want to maximize the amount of screen space for data viewing"): `position: sticky; top: 0` became
**`position: relative`** with no offset, so the whole masthead scrolls away with the page and the
phone's "sticky only part way down" quirk — the fixed-height sticky bar fighting the wrapped
rows — went with it. `relative` rather than the default `static` because `.masthead` is still the
positioned ancestor the ≤640px dropdown's `top: 100%` resolves against (a `static` bar moves the
menu to the top of the document), and `z-index: 40` stays so the menu's `41` still means one
above the bar; the consequence is that the open menu scrolls with the bar, which is what an
unpinned bar means. The only `position: sticky` left in the sheet is a table's own `thead`, inside
its own scroll container, and this task is not it. Proven by `data/logs/20-task02-unsticky.cjs`
(seven pages scrolled at 1280 px and 360 px — with a squeezed-height leg for the pages that fit
a tall viewport — plus the 360 px dropdown still landing directly under the bar and still hanging
off it after a scroll; screenshots `data/logs/masthead-unsticky*.png`). Note that a browser cannot
tell `relative` from `relative; top: 0` — Chrome computes `top: 0px` for either — so the absence
of the offset is a served-CSS pin, not a measurement.
**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">`,
@@ -747,8 +789,9 @@ Responsibilities, one line each:
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
through the token, hides `nav.tabs`, and tightens the bar's `row-gap` plus the cluster's gap (the
cluster still wraps inside its own row — 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
@@ -756,9 +799,24 @@ Responsibilities, one line each:
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
it renders the dropdown as the `nav` element itself — since phase 20 task 03 that element is
**one box in two states**: `nav.tabs` carries the geometry (`position: absolute; top: 100%;
z-index: 41`) under the **whole** bar — three rows since task 01, which is the intended look: a
menu below the masthead, not wedged between its rows — *and* the closed state
(`visibility: hidden; opacity: 0; transform: translateY(-8px); pointer-events: none`), while
`nav.tabs.is-open` flips only those four properties to their visible counterparts. That shape is
what the slide needs, not a stylistic choice: the block used to close the menu with
`display: none`, and a box that is not rendered has no intermediate state, so the 180 ms
`opacity`/`transform` transition the operator asked for (`TODO.md:11`, "a slide out and slide
back in", after `brain_of_reese`'s `.app-nav`) could never have run. `visibility` is delayed by
exactly the slide on the closed rule (so the box fades out before leaving the tree) and
undelayed on `.is-open` (or the menu would be invisible for the first 180 ms of its own
opening); `visibility: hidden` + `pointer-events: none` keep the closed menu out of the a11y
tree, out of the tab order and unhittable, exactly as `display: none` did, and the closed state
costs no scroll area (measured). With no second menu element, no `body` marker class, no colour
outside the tokens and **no JS change at all**, both states are named in the file's one
`@media (prefers-reduced-motion: reduce)` block — naming only the closed one would leave the
higher-specificity open transition animating (brain of reese's phase-46 lesson). 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
@@ -766,13 +824,22 @@ Responsibilities, one line each:
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
query — and has to move in both, or the menu opens into a hidden 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
sweep: all seven tabbed pages at 360px and at 1280px, `/login` at both, 45 checks — its desktop
leg now expects the three-row bar's height, not the old fixed 58 px, and still demands inline
tabs, no button and a dropped `.is-open`). Phase 20 task 03 adds three served-CSS pins (the
closed state's `visibility`/`opacity`/`transform`/`pointer-events` and its delayed-`visibility`
transition, the open state's visible counterparts with the delay dropped, and the both-states
reduced-motion rule inside the file's only such block) plus the guard that no `display: none`
comes back to that block, and the browser half is `data/logs/20-task03-slide.cjs` (the computed
closed state, a mid-transition frame at ≈60 ms in both directions, the four close paths with
`aria-expanded`, the snap under emulated `prefers-reduced-motion: reduce`, the untouched desktop
tabs and the no-phantom-scroll measurement; screenshots `data/logs/slide-*.png`). 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.
@@ -997,7 +1064,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: 1458 passed + 15 skipped (11 live,
uv run pytest # hermetic: 1464 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
@@ -1894,7 +1961,8 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
and the three writes rotate, revoke, and rotate the root with `restart_required: true` said out
loud — each leaving one fixed-text `certs`-source event and no PEM outside `meta` (pinned by a
grep of the stored rows and of every payload a browser reads). Task 05 put the page on top of it:
`/certs` (`⚷ Certs` in every masthead, including `/settings`) renders that document — the root
`/certs` (`⚷ Certs`, in every signed-in masthead — its own included, since phase 20 task 04)
renders that document — the root
CN/serial/expiry, the display-only client line, one row per host with its status chip and
issued-vs-deployed bundle rev and its **Rotate** / **Revoke** / **bundle** controls — and keeps
two rules the API already keeps: no colour the payload did not send (no `severity` token on the
@@ -1950,14 +2018,17 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
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
breaks into rows, 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. The two operator TODO items that sat alongside it are phased
now, in `todo/`: `24_dev_mtls_rollout` (never accepting a non-mTLS agent — fleet gap 11's
rollout, half of it) and `20_masthead_nav_polish` (keeping both `⚙ Account` / `⚷ Certs`
masthead links visible on the admin pages that today do not link to themselves — phase 15's
deliberate rule, which that phase is the decision on).
a phase rather than a cleanup — `21_page_cleanups`' `.scroll` on the `/certs` table is the same
shape applied to one table and does not reach this one. Of the two operator TODO items that sat
beside it, `20_masthead_nav_polish` has landed in full (now complete): the bar is three
left-justified rows at every width, it is no longer sticky, the phone menu slides, and both
`⚙ Account` and `⚷ Certs` render on all eight signed-in pages including the two they name
(task 04, reversing phase 15's "no self-link" rule at the operator's request). The other,
`24_dev_mtls_rollout` (never accepting a non-mTLS agent — fleet gap 11's rollout, half of it),
is still in `todo/`.
## Locked decisions (and why)