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
@@ -0,0 +1,21 @@
All green. Final state verified.
**Implemented**
- `#nav-toggle` (`class="ghost nav-toggle"`, `type=button`, `aria-expanded=false`, `aria-controls="nav-tabs"`, `aria-label="Toggle navigation"`, `☰`) inserted between `.brand` and `nav.tabs` in all 7 tabbed pages (`index, system, disks, llm, ups, settings, certs`); block is byte-identical across them (md5 match).
- Nav given its id: `<nav class="tabs" id="nav-tabs" aria-label="Dashboards">` on all 7; `login.html` untouched (`grep -c 'id="nav-toggle"'` → 0, confirmed in-browser: no toggle, no tabs).
- `dash.css`: base hide rule `#nav-toggle { display: var(--nav-toggle-display); }` + `--nav-toggle-display: none` in `:root`, with the rationale comment.
**Tests / lint / coverage**
- `bash .agents/validate.sh` → `1451 passed, 15 skipped`, coverage **98.94%** (floor 94), `ruff` All checks passed, `pyright` 0 errors, `node --check` clean.
- `uv run pytest -m live --live` → **11 passed** (real fleet/engines/upsd).
- Browser proof against the restarted dev server (Playwright, 1280/900/641/640/400/320px, all 7 pages): `#nav-toggle` computes `display:none` at every width, and deleting the button changes no masthead geometry → rendering identical to the pre-task markup. Screenshots: `data/logs/nav-task01-{desktop-1280,system-desktop-1280,phone-400,certs-phone-400,login-phone-400}.png`.
**Docs**: no doc impact — nothing renders differently yet; the AGENTS.md *UI conventions* masthead-collapsing entry is the phase's documentation mandate and lands with task 04/the final pass (writing it now would document a dropdown that does not exist).
**Decisions / deviations**
1. The task's literal `.nav-toggle { display: none; }` would **not** have hidden the button: the element carries `class="ghost"`, and `button.ghost { display: inline-flex }` is (0,1,1) vs (0,1,0) — measured a visible 29×29px button in the desktop masthead. Shipped an id-addressed rule instead.
2. The task's orphan-`id` assumption is wrong: `aria-controls="nav-tabs"` anchors `nav-tabs`, and a `.nav-toggle` class rule is invisible to the audit (it searches the comment-stripped corpus for `#nav-toggle`/quoted `nav-toggle`). The id rule satisfies `test_no_page_carries_an_id_nothing_reads`; `:root`-declaring the token also keeps `test_no_stylesheet_rule_uses_an_undeclared_token` green.
3. Token-driven `display` makes task 02's reveal order- and specificity-independent — proven in-browser that both `#nav-toggle {--nav-toggle-display: inline-flex}` and `.nav-toggle {…}` reveal the button at exactly ≤640px, no `!important`.
4. No new tests here (task 01's mandate is that the existing masthead/admin-link/orphan-`id` pins still pass); pre-existing advisory only: `fastapi 0.153.0 → 0.156.0` (dependencies untouched, advisory not a gate).
**Next pending task**: `.agents/phases/todo/18_nav_hamburger/02_dropdown_css.md`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
...........................s............................................ [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 49%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 63%]
................................................................ssssssss [ 68%]
sss..................................................................... [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 93%]
........................................sss............................. [ 98%]
.......................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 436 0 100%
src/dashboards/app.py 185 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1451 passed, 15 skipped, 1 warning in 156.32s (0:02:36)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
fastapi 0.153.0 0.156.0 wheel
@@ -0,0 +1,16 @@
**Implemented** (`.agents/phases/todo/18_nav_hamburger/02_dropdown_css.md`)
- `dash.css`: new `/* ---- mobile nav */` section = the ≤640px block — `.masthead` wraps (`height:auto`, `min-height:var(--header-h)`, `padding-block`), `#nav-toggle` revealed via task 01's `--nav-toggle-display` token (right-aligned, 40px target, `touch-action:manipulation`), `nav.tabs{display:none}`, `nav.tabs.is-open` = the dropdown (`position:absolute; top:100%` full-bleed under the sticky bar, `z-index:41`, `--bg-raised`/`--border`, touch-sized rows), so no menu-panel element and no colour outside the tokens.
- Three rules the browser measurement demanded: `.masthead-right{flex-wrap:wrap;justify-content:flex-end}`, `.masthead-right>*{flex:none;white-space:nowrap}`, `#nav-status{flex-wrap:nowrap}` — without them the bar was 183px tall with the 4 status dots in a vertical column and `⚷ Certs` broken over two lines (pre-phase it was a 58px bar the 118px dot column overflowed, document 45px wider than the viewport).
- Deviations, all commented in the file: `box-shadow:var(--shadow)` not the literal `rgba(0,0,0,.35)` (light theme + "no component hardcodes a colour"); shipped **without** the optional transition (`display:none→flex` cannot animate, so the `prefers-reduced-motion` block needs no new selector); 360px renders 3 rows, not the task's 2 — four labelled dots + Certs/Account/theme cannot fit 320px twice, and nothing was hidden to fake it (93px at 640px).
**Tests / lint / coverage**
- `bash .agents/validate.sh` → exit 0: **1451 passed, 15 skipped**, coverage **98.94 %** (floor 94), ruff "All checks passed", pyright "0 errors", `node --check` clean; advisory only: fastapi 0.153.0→0.156.1 (deps untouched).
- `uv run pytest -m live --live` → **11 passed** (real fleet/engines/upsd).
- Browser proof vs the live dev server (`data/logs/nav-task02-verify.cjs`): at 320/360/414/640 px — toggle visible, tabs hidden, `.is-open` full-width under the bar (`top:100%` = bar bottom), links stacked at 40px, `aria-current` highlighted, content scrolls under, `scrollWidth == viewport`; CSS-parse check confirms all 7 new selectors survive the parser; `/login` still has no toggle/tabs. No-regression control (`nav-task02-noregress.cjs`, shipped CSS vs the block stripped, 7 pages × 6 widths >640px) → **identical masthead layout**.
- Screenshots: `data/logs/nav-task02-{index,system,disks,llm,ups,certs,settings}-360{,-open}.png`, `-index-{320,640,641,700}`, `-system-360-dark{,-open}`, `-login-360`.
**Docs**: no doc changes — the phase assigns the AGENTS.md *UI conventions* masthead entry to its final pass, and the dropdown is unreachable until task 03's binding lands (same call task 01 made).
**For the final pass**: pre-existing horizontal overflow at 641–752px (masthead content measures 752px) — not caused by this task; fixing it would change the desktop masthead the phase pins as unchanged.
**Next pending task**: `.agents/phases/todo/18_nav_hamburger/03_toggle_binding.md`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
...........................s............................................ [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 49%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 63%]
................................................................ssssssss [ 68%]
sss..................................................................... [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 93%]
........................................sss............................. [ 98%]
.......................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 436 0 100%
src/dashboards/app.py 185 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1451 passed, 15 skipped, 1 warning in 154.20s (0:02:34)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
fastapi 0.153.0 0.156.1 wheel
@@ -0,0 +1,13 @@
**Implemented** (`.agents/phases/todo/18_nav_hamburger/03_toggle_binding.md`)
- `app.js` `initChrome()`: the `#nav-toggle` / `#nav-tabs` binding, in the file's `if (element)` guard style — one `setNavMenu(open)` funnel that always sets `.is-open` + `aria-expanded` together, and the four close paths (button again, tab click delegated on the nav, `Esc` with focus returned to the button, `matchMedia('(max-width: 640px)')` false on resize-back so no stale `is-open`). `640px` matches task 02's CSS block; the `if (navToggle && appNav)` guard is what makes `/login` inert. No body marker class, no page/CSS/Python changes.
**Tests / lint / coverage**
- `bash .agents/validate.sh` → **exit 0**: `1451 passed, 15 skipped`, coverage **TOTAL 99 %** (98.94 %, floor 94), ruff "All checks passed", pyright "0 errors", `node --check` clean on all `web/static/*.js`.
- `uv run pytest -m live --live` → **11 passed** (real fleet / engines / upsd).
- Browser pass vs the live dev server (`data/logs/nav-task03-verify.cjs`, Playwright, **77/77 checks**): across all 7 tabbed pages at 360px — closed on load, click opens (dropdown full-width under the bar, 5 links, `aria-expanded=true`), click again closes, Esc closes + focus on `#nav-toggle`, resize to 700 closes and the shrink back stays closed, a tab click closes; no `pageerror` anywhere. `/login`: script loads, no toggle/tabs, only refused request is the designed `GET /api/auth/status` 401. Screenshots `data/logs/nav-task03-{index-360,index-360-open,system-360-open,certs-360-open,index-1280,system-1280,login-360}.png`.
**Docs**: AGENTS.md *UI conventions* gains the masthead-collapsing convention (breakpoint, toggle identity + the `--nav-toggle-display` specificity ladder, the four close paths, `/login` carries no toggle, the literal living in two files); README's `web/` bullet gained the one operator-facing sentence. README page table unchanged, per the phase.
**Decisions / deviations**: (1) the `top: 100%` check allows 1px — that resolves against the masthead's padding box, so the menu's first row is its 1px bottom border. (2) The verification script asserts `/login` as "its only ≥400 request is the auth probe" rather than "zero console errors", because Chromium logs that designed 401. (3) No served-byte pins added here — task 04 owns them; no `mq.addListener` fallback (the task's snippet, and every engine this app ships to supports `addEventListener` on `MediaQueryList`).
**Next pending task**: `.agents/phases/todo/18_nav_hamburger/04_tests_and_verification.md`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
...........................s............................................ [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 49%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 63%]
................................................................ssssssss [ 68%]
sss..................................................................... [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 93%]
........................................sss............................. [ 98%]
.......................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 436 0 100%
src/dashboards/app.py 185 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1451 passed, 15 skipped, 1 warning in 153.37s (0:02:33)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
fastapi 0.153.0 0.156.1 wheel
@@ -0,0 +1,19 @@
Done. Everything green.
**Implemented**
- `tests/test_api.py` — new phase-18 section with the three served-byte pins: `test_the_nav_toggle_is_in_the_masthead_of_every_tabbed_page` (button + all 5 tabs inside `header.masthead` on all 7 pages, brand→toggle→nav→cluster order), `test_the_login_page_carries_no_nav_and_no_toggle` (own unsigned client, since a signed-in `GET /login` is a `303`; also pins that `/login` still loads the shared `app.js` whose guard is its no-toggle contract), `test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint` (token default hidden, ≤640px reveal/`nav.tabs.is-open` dropdown/cluster-wraps, the four close paths + single `setNavMenu` funnel, and the CSS media query **compared** to the JS `matchMedia`).
- Each pin mutation-tested (breakpoint drift, un-hidden button, missing button on `/settings`, toggle added to `/login`, removed resize path) — all fail with named messages.
- Browser verification against the live dev server (9 sources `ok`): `data/logs/nav-task04-verify.cjs` → **24/24 checks** (dropdown under the 2–3-row bar, 5 stacked 40px links, `aria-expanded` truth table, Esc+focus return, tab-click close, resize-back drops stale `is-open`, desktop one-row/no button, no page error); transcript `data/logs/nav-task04-verify.out`; screenshots `data/logs/nav-hamburger-{closed,open}-360.png` + `data/logs/nav-masthead-desktop-1280.png` (read and confirmed, dark theme, live data).
- Orphan-`id` audit in `tests/test_auth_api.py` passed **untouched**.
**Tests / lint** — `uv run pytest`: **1454 passed, 15 skipped**; `bash .agents/validate.sh` → **EXIT 0** (`--cov-fail-under=94` → coverage **98.94 %**, ruff "All checks passed", pyright "0 errors", `node --check` clean); `uv run pytest -m live --live` → **11 passed**. Advisory only (deps untouched): `fastapi 0.153.0 → 0.156.1`.
**Docs** — AGENTS.md *UI conventions*: masthead-collapse entry extended with the three pins and the drift-comparison; README's `web/` bullet now also says the desktop masthead is unchanged above the breakpoint (page table untouched, per the phase).
**Notable decisions / deviations**
1. Pinned the shipped `#nav-toggle { display: var(--nav-toggle-display) }` + `: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.
2. Breakpoint pin compares the two extracted numbers (and pins `640`) rather than grepping `640px` twice.
3. Restored `src/dashboards/web/pages/settings.html`'s task-01 markup after I wiped it with a `git checkout` during a mutation test; byte-identical to the other six (md5 of the button block matches).
4. `NOTES.md` (emptied) / `TODO.md` (+2 items) changed in the working tree during this task from outside it — left untouched for the harness.
**Next pending task**: none in phase 18 (this was the last); next phase dir is `.agents/phases/todo/19_ups_events_expandable/`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
..............................s......................................... [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 49%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
...................................................................sssss [ 68%]
ssssss.................................................................. [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 93%]
...........................................sss.......................... [ 98%]
............................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 436 0 100%
src/dashboards/app.py 185 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1454 passed, 15 skipped, 1 warning in 157.17s (0:02:37)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
fastapi 0.153.0 0.156.1 wheel
+54 -1
View File
@@ -739,6 +739,43 @@ Responsibilities, one line each:
`severity` verdict the API sent `severity` verdict the API sent
(`tests/test_api.py` fails if a page script names it again). A value the API should rate (`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. 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`, 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 `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 status # per-source ok/DOWN — unauthenticated: /api/health
./scripts/dev-server.sh logs ./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 # 4 store_perf), no network
uv run pytest --cov # coverage gate (98.94%; floor 94%) uv run pytest --cov # coverage gate (98.94%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd 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: 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 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. 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) ## Locked decisions (and why)
+9 -5
View File
@@ -609,8 +609,10 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
marker that says how many it hid. marker that says how many it hid.
* `api.py` — the JSON every page reads: `/api/config`, `/api/health`, `/api/events`, * `api.py` — the JSON every page reads: `/api/config`, `/api/health`, `/api/events`,
`/api/history`, `/api/metrics`, `/api/summary`, one `snapshot` + `history` pair per `/api/history`, `/api/metrics`, `/api/summary`, one `snapshot` + `history` pair per
dashboard (`/api/system/*`, `/api/disks/*`, `/api/llm/*`, `/api/ups/*`), and the certificate dashboard (`/api/system/*`, `/api/disks/*`, `/api/llm/*`, `/api/ups/*` — including
authority's own surface (`/api/certs`, `/api/certs/<name>/bundle`, `POST …/rotate`, `POST `/api/ups/commands`, which answers what may be sent to each UPS right now and why not when it
may not), and the certificate authority's own surface (`/api/certs`,
`/api/certs/<name>/bundle`, `POST …/rotate`, `POST
…/revoke`, `POST /api/certs/root/rotate`). GETs everywhere except `POST /api/ups/command`, …/revoke`, `POST /api/certs/root/rotate`). GETs everywhere except `POST /api/ups/command`,
which stays the **only** route that dials anything: the `/api/certs/*` writes are `meta` row which stays the **only** route that dials anything: the `/api/certs/*` writes are `meta` row
writes through `certs.py`, the same class of write `POST /api/auth/password` is, and a writes through `certs.py`, the same class of write `POST /api/auth/password` is, and a
@@ -640,9 +642,11 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
* `web/` — five static dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`) plus the three * `web/` — five static dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`) plus the three
admin pages (`/login`, `/settings`, `/certs`), one stylesheet, hand-rolled admin pages (`/login`, `/settings`, `/certs`), one stylesheet, hand-rolled
SVG charts (`charts.js`), and a masthead status dot per dashboard job (`disks` has its own: SVG charts (`charts.js`), and a masthead status dot per dashboard job (`disks` has its own:
the same agent can answer the 10-second utilisation poll and have a wedged SMART pass). No the same agent can answer the 10-second utilisation poll and have a wedged SMART pass). At
framework, no CDN, no bundler, and no narration: a page never explains its own pipeline, so ≤640px the five tab links leave the bar and come back as a dropdown under it, opened by the `☰`
the words on screen are the ones you act on — a unit, a `Δ10.0s` provenance tag, a button (`/login` renders no tabs, so it has no button either), and above it the masthead stays
the one row it has always been. No framework, no CDN, no bundler, and no narration: a page never
explains its own pipeline, so the words on screen are the ones you act on — a unit, a `Δ10.0s` provenance tag, a
`SIMULATED` badge, or the sentence beside an empty panel. The overview (`/`) is exactly two panels — a `SIMULATED` badge, or the sentence beside an empty panel. The overview (`/`) is exactly two panels — a
**Warnings & errors** list built from `/api/summary`'s `alerts` (each row links to the **Warnings & errors** list built from `/api/summary`'s `alerts` (each row links to the
dashboard that explains it) and the **Fleet** table — and nothing else: an empty alert dashboard that explains it) and the **Fleet** table — and nothing else: an empty alert
+2 -1
View File
@@ -69,7 +69,8 @@ WEB = Path(__file__).resolve().parent / "web"
# `/settings` is a page and not a dashboard: it is in `PAGES` (so it is a route) and *not* in # `/settings` is a page and not a dashboard: it is in `PAGES` (so it is a route) and *not* in
# `PUBLIC_PATHS` (so the gate guards it with no extra code — the route-enumeration test in # `PUBLIC_PATHS` (so the gate guards it with no extra code — the route-enumeration test in
# `tests/test_auth_api.py` proves that, and `tests/test_api.py` proves the anonymous refusal for # `tests/test_auth_api.py` proves that, and `tests/test_api.py` proves the anonymous refusal for
# this page specifically). It carries no `aria-current` tab because it is not one of the four. # this page specifically). It carries no `aria-current` tab because it is not one of the five
# dashboards (`/`, `/system`, `/disks`, `/llm`, `/ups`).
# `/certs` (phase 15 task 05) is the second of those admin pages, for the same reason: the # `/certs` (phase 15 task 05) is the second of those admin pages, for the same reason: the
# certificate authority is not a fifth dashboard, it is the thing you administer past them. # certificate authority is not a fifth dashboard, it is the thing you administer past them.
PAGES = {"/": "index.html", "/system": "system.html", "/disks": "disks.html", PAGES = {"/": "index.html", "/system": "system.html", "/disks": "disks.html",
+3 -1
View File
@@ -14,7 +14,9 @@
that marks a tab this page is not would be a lie about the navigation. The masthead that marks a tab this page is not would be a lie about the navigation. The masthead
carries the two admin links — `⚙ Account` and `⚷ Certs` — outside `.tabs`, so the tab carries the two admin links — `⚙ Account` and `⚷ Certs` — outside `.tabs`, so the tab
structure stays five links deep on every page. --> structure stays five links deep on every page. -->
<nav class="tabs" aria-label="Dashboards"> <button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards">
<a href="/">Overview</a> <a href="/">Overview</a>
<a href="/system">System</a> <a href="/system">System</a>
<a href="/disks">Disks</a> <a href="/disks">Disks</a>
+3 -1
View File
@@ -10,7 +10,9 @@
<body> <body>
<header class="masthead"> <header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a> <a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<nav class="tabs" aria-label="Dashboards"> <button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards">
<a href="/">Overview</a> <a href="/">Overview</a>
<a href="/system">System</a> <a href="/system">System</a>
<a href="/disks" aria-current="page">Disks</a> <a href="/disks" aria-current="page">Disks</a>
+3 -1
View File
@@ -10,7 +10,9 @@
<body> <body>
<header class="masthead"> <header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a> <a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<nav class="tabs" aria-label="Dashboards"> <button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards">
<a href="/" aria-current="page">Overview</a> <a href="/" aria-current="page">Overview</a>
<a href="/system">System</a> <a href="/system">System</a>
<a href="/disks">Disks</a> <a href="/disks">Disks</a>
+3 -1
View File
@@ -10,7 +10,9 @@
<body> <body>
<header class="masthead"> <header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a> <a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<nav class="tabs" aria-label="Dashboards"> <button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards">
<a href="/">Overview</a> <a href="/">Overview</a>
<a href="/system">System</a> <a href="/system">System</a>
<a href="/disks">Disks</a> <a href="/disks">Disks</a>
+3 -1
View File
@@ -13,7 +13,9 @@
<!-- The five dashboards, and no `aria-current`: this page is not one of them, and a tab <!-- The five dashboards, and no `aria-current`: this page is not one of them, and a tab
marked current that is not in the list would claim a location the page does not have. marked current that is not in the list would claim a location the page does not have.
It is reached from the `⚙ Account` link in the masthead of every dashboard. --> It is reached from the `⚙ Account` link in the masthead of every dashboard. -->
<nav class="tabs" aria-label="Dashboards"> <button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards">
<a href="/">Overview</a> <a href="/">Overview</a>
<a href="/system">System</a> <a href="/system">System</a>
<a href="/disks">Disks</a> <a href="/disks">Disks</a>
+3 -1
View File
@@ -10,7 +10,9 @@
<body> <body>
<header class="masthead"> <header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a> <a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<nav class="tabs" aria-label="Dashboards"> <button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards">
<a href="/">Overview</a> <a href="/">Overview</a>
<a href="/system" aria-current="page">System</a> <a href="/system" aria-current="page">System</a>
<a href="/disks">Disks</a> <a href="/disks">Disks</a>
+3 -1
View File
@@ -10,7 +10,9 @@
<body> <body>
<header class="masthead"> <header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a> <a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<nav class="tabs" aria-label="Dashboards"> <button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards">
<a href="/">Overview</a> <a href="/">Overview</a>
<a href="/system">System</a> <a href="/system">System</a>
<a href="/disks">Disks</a> <a href="/disks">Disks</a>
+42
View File
@@ -680,6 +680,48 @@ const Dash = (() => {
refreshStatus(); refreshStatus();
setInterval(refreshStatus, 15000); setInterval(refreshStatus, 15000);
} }
// The hamburger: ≤640px the five tab links live in a dropdown under the masthead
// (`nav.tabs.is-open` in dash.css), opened by `#nav-toggle`. One binding for the seven
// tabbed pages, and a page without either element is a no-op — `/login` loads this file
// with brand link and theme toggle only, so the guard below is what keeps it inert there.
//
// Every state change goes through `setNavMenu()`, so no close path can leave `is-open`
// and `aria-expanded` disagreeing (a button that claims `true` over a menu the CSS hides
// is a control that lies). The four close paths: the button again, a tab link, Esc (with
// focus back on the opener), and resizing back above the breakpoint — the same four the
// dropdown's CSS assumes, and `640px` here is that media query, so the two literals must
// move together or the menu opens into a `display:none` nav.
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. Delegated on the nav: the five links are
// static markup, so one listener covers all of them and a tab added to the pages is
// already covered.
appNav.addEventListener('click', (event) => {
if (event.target.closest('a')) setNavMenu(false);
});
// Esc closes while the menu is open (document-level — there is no other modal on these
// pages to fight for the key, and a closed menu must not swallow someone else's Esc).
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, so the open state is dropped rather
// than left in place. The CSS scopes `.is-open` to ≤640px, so a stuck class paints
// nothing up there — but it would re-open the moment the viewport shrank again, and it
// would leave `aria-expanded` claiming a menu that is not on screen.
const mq = window.matchMedia('(max-width: 640px)');
const onMqChange = () => { if (!mq.matches) setNavMenu(false); };
if (mq.addEventListener) mq.addEventListener('change', onMqChange);
}
} }
/** Range buttons (5m … 30d). Calls onChange(seconds, key). */ /** Range buttons (5m … 30d). Calls onChange(seconds, key). */
+135
View File
@@ -41,6 +41,10 @@
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace; --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--header-h: 58px; --header-h: 58px;
/* The masthead nav toggle (phase 18): hidden everywhere until the ≤640px block flips it.
* A token rather than a literal `display: none` so the reveal may name the button with any
* selector, in any order — see `#nav-toggle` in the header section. */
--nav-toggle-display: none;
} }
[data-theme="light"] { [data-theme="light"] {
@@ -171,6 +175,137 @@ button.ghost[aria-pressed="true"] { border-color: var(--accent); color: var(--ac
or the last page is a control that lies about there being more history). */ or the last page is a control that lies about there being more history). */
button.ghost:disabled { opacity: .45; cursor: not-allowed; } button.ghost:disabled { opacity: .45; cursor: not-allowed; }
/* The hamburger that swaps the tab nav for a dropdown on phones (phase 18): one per tabbed
* page (`#nav-toggle`, `aria-controls="nav-tabs"`), hidden at every width until the ≤640px
* block reveals it — so the masthead renders exactly as it did before the button existed.
*
* The token is the specificity ladder, not decoration. This element carries the shared `ghost`
* class, whose rule above claims `display: inline-flex` at (0,1,1): a literal
* `.nav-toggle { display: none }` loses to that and ships a visible 29×29 px button into the
* desktop masthead (measured in a browser, not deduced). Addressing the id out-ranks that, and
* reading the value from a token means the reveal can be any selector that matches the button,
* anywhere in this file — a declaration of `--nav-toggle-display` on the element always beats
* the value inherited from `:root` — so `@media (max-width: 640px) { #nav-toggle {
* --nav-toggle-display: inline-flex } }` is the whole reveal, with no `!important`. */
#nav-toggle { display: var(--nav-toggle-display); }
/* ---- mobile nav ---------------------------------------------------------
* The ≤640px half of the hamburger (phase 18): the five tab links leave the bar and come
* back as a dropdown under it, opened by `#nav-toggle`. The state lives entirely in the
* `.is-open` class task 03's binding puts on `#nav-tabs`, so this block renders a verdict
* made elsewhere and owns no behaviour of its own.
*
* Above 640px nothing here applies, which is the point: the desktop masthead is the same
* one row with the inline tabs it always had, and the button stays hidden.
*/
@media (max-width: 640px) {
/* One fixed-height row cannot hold brand + toggle + the whole `masthead-right` cluster
* (status dots, pause, `⚷ Certs`, `⚙ Account`, theme) at 360px, so the bar wraps: brand
* and toggle on the first row, the cluster on the second. `height: auto` rather than a
* new fixed height is what keeps the dropdown's `top: 100%` honest — it pins to the
* bottom of the masthead however many rows that is — and `min-height` stops a page whose
* cluster also wrapped from collapsing to a 2-line bar. */
.masthead {
height: auto;
min-height: var(--header-h);
flex-wrap: wrap;
row-gap: .4rem;
padding-block: .5rem;
/* The bar must not start at y=0 of the layout viewport. On a real phone the viewport
* runs under the status bar (Android Chrome is edge-to-edge by default; iOS does the
* same once the page opts into viewport-fit=cover), and the system top-edge zone
* does not reliably deliver taps to the page — a button in it is unreachable, not
* unbound, and no amount of headless emulation shows it (an emulated viewport has no
* system top edge). Padding by the top safe-area inset pushes the whole bar — the
* first-row #nav-toggle first — into the tappable region; the dropdown's top:100%
* follows the taller bar. env() resolves to 0px on every non-notch surface (desktop,
* headless, non-notched phones), so those render byte-identical. The 0.5rem is the
* padding-block value restated for the one side the inset grows, so the two never
* drift. Same fix brain of reese shipped for the same owner-reported symptom. */
padding-top: calc(.5rem + env(safe-area-inset-top, 0px));
}
/* The cluster needs the same treatment one level down, and these three rules are what a
* 360px phone actually requires — measured on both sides of them. With only the rule above,
* the bar came out 183px tall: the four status dots in a single-file column (`#nav-status`
* carries the shared `.gap` class, which is `flex-wrap: wrap`) and `⚷ Certs` broken over two
* lines, because every item shrank to a fraction of its content. Before this phase the bar
* kept its fixed 58px and that 118px dot column simply overflowed it onto the page below,
* with the document 45px wider than the viewport. So: the cluster may wrap, its items may
* neither shrink nor break a label, and the dots stay the one line that makes them a readout
* instead of a stack. The bar lands at ~123px at 360px and ~93px at 640px — three visual
* rows at the narrow end, two at 640px — because four labelled dots plus `⚷ Certs`,
* `⚙ Account` and the theme button do not fit in 320px twice over, and nothing here hides a
* control to make the arithmetic work: a phone gets the same words every other width does. */
.masthead-right { flex-wrap: wrap; justify-content: flex-end; gap: .4rem; }
.masthead-right > * { flex: none; white-space: nowrap; }
#nav-status { flex-wrap: nowrap; }
/* The reveal, through task 01's token: `#nav-toggle` reads its `display` from
* `--nav-toggle-display`, so declaring the token on the element here is the whole flip —
* no selector order to get right, no `!important`, and the `button.ghost` outline it
* inherits stays the only styling it needs. Right-aligned because that is where a thumb
* already is on a phone (brain of reese does the same), and `touch-action` because the
* default double-tap-to-zoom disambiguation makes a first tap feel like a dead button. */
#nav-toggle {
--nav-toggle-display: inline-flex;
align-items: center;
justify-content: center;
margin-inline-start: auto;
min-width: 40px;
min-height: 40px;
padding: 0 .5rem;
font-size: 15px;
touch-action: manipulation;
}
/* The links leave the row. `.is-open` below out-ranks this (two classes to one), so the
* order of the two rules is not load-bearing. */
nav.tabs { display: none; }
/* The nav *is* the dropdown — no menu panel element to keep in sync. `.masthead` is
* `position: sticky`, which is a positioned ancestor, so `top: 100%` lands directly under
* it and `left/right: 0` span the masthead's padding box edge to edge; hence
* `margin-inline-start: 0`, without which the base rule's .4rem over-constrains the box
* and insets the menu's left edge by that much. `z-index: 41` is one above the masthead's
* 40 so the menu paints over the page, which scrolls underneath it.
*
* `box-shadow: var(--shadow)` rather than a literal black: this file's rule is that no
* component hardcodes a colour, and the light theme re-declares the token — a .35-alpha
* black drop is the one thing that looks wrong under a white menu. */
nav.tabs.is-open {
display: flex;
flex-direction: column;
gap: 0;
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-inline-start: 0;
padding: .4rem;
background: var(--bg-raised);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
z-index: 41;
}
/* Rows, not pills: the base .38rem padding is a desktop tab strip, and a menu item you
* can miss is a menu that does not work with a thumb. `border-radius` is restated because
* the highlight (`:hover`, `[aria-current="page"]`) is now a row, not a chip — both rules
* carry over unchanged, which is why nothing here mentions a colour. */
nav.tabs.is-open a {
padding: .6rem .7rem;
min-height: 40px;
border-radius: var(--radius-sm);
}
}
/* No transition on the dropdown, on purpose: `display: none` → `display: flex` does not
* animate, so an `opacity`/`translateY` here would be a declaration that never runs (running
* it would mean the visibility/`@starting-style` machinery brain of reese carries, which is
* more CSS than a menu of five links deserves). The existing
* `@media (prefers-reduced-motion: reduce)` rule therefore has nothing new to kill. */
/* ---- layout ------------------------------------------------------------- */ /* ---- layout ------------------------------------------------------------- */
main { padding: 1.15rem 1.25rem 3rem; max-width: 1680px; margin: 0 auto; } main { padding: 1.15rem 1.25rem 3rem; max-width: 1680px; margin: 0 auto; }
+156
View File
@@ -3850,6 +3850,162 @@ def test_the_disks_tab_is_in_the_masthead_of_every_page(client) -> None:
< html.index('href="/llm"')), "tab order is machine, drive, engine, power" < html.index('href="/llm"')), "tab order is machine, drive, engine, power"
# ---- the collapsing masthead nav (phase 18) -------------------------------- #
#
# At ≤640px the five tab links leave the bar and come back as a dropdown under it, opened by
# one `☰` button. There is no JS test runner (a locked anchor), so the phase is pinned the way
# every page surface here is: on the served bytes. Three questions, in the order the pieces
# were built — is the button on every tabbed page and on no others, does the stylesheet still
# hide it above the breakpoint, and do the stylesheet and the binding still agree on the
# breakpoint itself? That last one is the only failure mode no browser check catches: a number
# that drifts in one file opens a menu into a `display: none` nav on the operator's phone.
#: The attributes that make `#nav-toggle` a button and not a mystery glyph: it is not a form
#: control (`type`), it says what it toggles (`aria-controls`), it says what state it is in
#: (`aria-expanded`, which `app.js` keeps honest), and its only content is a `☰`.
NAV_TOGGLE_ATTRS = ('class="ghost nav-toggle"', 'type="button"', 'aria-expanded="false"',
'aria-controls="nav-tabs"', 'aria-label="Toggle navigation"')
def _masthead(html: str) -> str:
"""The served page's own masthead — the block the toggle and the nav have to live in.
Slicing rather than searching the whole document is the point: a button in the footer would
satisfy `id="nav-toggle" in html` and anchor to nothing, because the dropdown's
`top: 100%` resolves against `.masthead`.
"""
start = html.index('<header class="masthead"')
return html[start:html.index("</header>", start)]
def test_the_nav_toggle_is_in_the_masthead_of_every_tabbed_page(client) -> None:
"""Seven pages, one identical button, and the nav it controls still carrying five tabs.
The dropdown is the only way to change dashboard on a phone, so a page missing the button is
a page you can enter and leave the tab bar on, and a page whose nav lost its `id` is a button
that toggles nothing. Pinned on all seven (`MARKUP_PAGES`) rather than on `PAGES` because the
two admin pages have the same masthead and therefore the same contract.
"""
for path in MARKUP_PAGES:
header = _masthead(client.get(path).text)
button = re.search(r'<button[^>]*id="nav-toggle"[^>]*>', header, re.S)
assert button, f"{path}'s masthead has no #nav-toggle button"
for attr in NAV_TOGGLE_ATTRS:
assert attr in button.group(0), f"{path}'s nav toggle is missing {attr}"
assert header.count('id="nav-toggle"') == 1, f"{path} has more than one nav toggle"
# The nav is the menu: the id the button names, and all five dashboards inside it.
assert '<nav class="tabs" id="nav-tabs" aria-label="Dashboards">' in header, \
f"{path}'s tab nav has no #nav-tabs for the toggle to control"
nav = header[header.index('id="nav-tabs"'):header.index("</nav>",
header.index('id="nav-tabs"'))]
for tab in PAGES:
assert f'href="{tab}"' in nav, f"{path} collapsed a nav that lost the {tab} tab"
# Order inside the masthead: brand, toggle, nav — and the cluster after them, because
# the ≤640px block right-aligns the button and wraps the cluster onto its own row.
assert (header.index('id="nav-toggle"') < header.index('id="nav-tabs"')
< header.index('class="masthead-right"')), \
f"{path} put the toggle or the nav outside the brand → tabs → cluster row"
def test_the_login_page_carries_no_nav_and_no_toggle(tmp_path: Path) -> None:
"""`/login` is brand link + theme toggle, and the shared binding must be inert there.
No `client` fixture, and that is the whole shape of the test: the signed-in one cannot see
this page at all (a signed-in `GET /login` is a `303` to the dashboard, and following it
audits `/`'s markup under the name `/login`), which is the same redirect trap
`tests/test_auth_api.py::test_the_login_page_carries_no_id_nothing_reads_and_no_dashboard_nav`
documents for the orphan-`id` audit.
Every tab on this page bounces straight back to it, so the page renders no tabs at all; a
stray hamburger would be a control that opens a menu of redirects, and the `640px` block
would reveal it. The last assertion is the other half of the contract: `app.js` still loads
here, which is only safe because `if (navToggle && appNav)` no-ops without the elements.
"""
db = str(tmp_path / "login-nav.sqlite3")
store = SampleStore(db)
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2), store)
try:
with TestClient(app) as anon:
response = anon.get("/login")
assert response.status_code == 200, \
"a signed-out /login must be the form, not a bounce"
html = response.text
finally:
store.close()
for absent in ('id="nav-toggle"', 'id="nav-tabs"', 'class="tabs"',
'aria-controls="nav-tabs"', 'aria-label="Dashboards"'):
assert absent not in html, f"/login renders the collapsed nav's {absent}"
assert '<a class="brand" href="/">' in html and 'id="theme-toggle"' in html, \
"/login lost the two pieces of chrome that do belong on it"
assert 'src="/static/app.js"' in html, (
"/login stopped loading the shared chrome whose guard is its no-toggle contract")
def test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint(client) -> None:
"""Hidden by default, revealed at one breakpoint, in two files that must agree on its value.
The base rule is *not* a literal `.nav-toggle { display: none }` and pinning that literal
would be pinning a bug: the element also carries the shared `ghost` class, whose
`button.ghost { display: inline-flex }` out-ranks a single-class rule and ships a visible
button into the desktop masthead. The shipped shape is an id rule reading a token
(`--nav-toggle-display`, `none` in `:root`), which the ≤640px block flips by re-declaring the
token on the element — so that is what is asserted here, plus the dropdown and the four close
paths the binding owns.
"""
css = _code(_css(client))
assert re.search(r"--nav-toggle-display:\s*none", css), \
"the nav toggle is no longer hidden by default — the desktop masthead grew a ☰"
assert re.search(r"#nav-toggle\s*\{\s*display:\s*var\(--nav-toggle-display\)", css), \
"#nav-toggle stopped reading the token that keeps its reveal order-independent"
nav_css = _code(_css_section(_css(client), "mobile nav"))
media = re.search(r"@media\s*\(max-width:\s*(\d+)px\)", nav_css)
assert media, "the mobile nav block lost its media query"
for rule in ("--nav-toggle-display: inline-flex", # the reveal, through task 01's token
"nav.tabs { display: none; }", # the links leave the row
"nav.tabs.is-open", # …and come back as the menu
"position: absolute",
"top: 100%", # pinned under the sticky masthead
"z-index: 41", # one above the masthead's 40
"background: var(--bg-raised)",
"flex-wrap: wrap", # the two-row bar, cluster still visible
# the safe-area inset keeps the first-row #nav-toggle out of the system
# top-edge zone a real phone's status bar owns (taps there never reach
# the page; headless emulation has no such zone, so only this pin says
# the bar still pads for it):
"padding-top: calc(.5rem + env(safe-area-inset-top, 0px))"):
assert rule in nav_css, f"the ≤640px nav block lost `{rule}`"
# Nothing on the phone masthead may be hidden to make the width work: the cluster wraps.
assert ".masthead-right" in nav_css and "#nav-status" in nav_css, \
"the mobile masthead hid the status cluster instead of wrapping it"
js = _code(client.get("/static/app.js").text)
binding = js[js.index("const navToggle ="):js.index("function rangeControl(")]
assert "if (navToggle && appNav)" in binding, \
"the binding lost the guard that makes it inert on /login"
assert binding.count("classList.toggle('is-open'") == 1, \
"a state change now bypasses setNavMenu(), so is-open and aria-expanded can diverge"
for path in ("navToggle.setAttribute('aria-expanded'",
"navToggle.addEventListener('click'", # close (and open) on the button
"event.target.closest('a')", # close on a tab click, delegated
"event.key === 'Escape'", # close on Esc…
"navToggle.focus()", # …with focus back on the opener
"if (!mq.matches) setNavMenu(false)"): # close on resize back to desktop
assert path in binding, f"the nav binding lost a close path: {path}"
assert "mq.addEventListener" in binding, "the resize-back close was never registered"
js_bp = re.search(r"matchMedia\('\(max-width: (\d+)px\)'\)", js)
assert js_bp, "app.js no longer reads the nav breakpoint through matchMedia"
assert js_bp.group(1) == media.group(1), (
f"the nav breakpoint drifted: CSS hides the tabs below {media.group(1)}px, "
f"app.js closes the menu below {js_bp.group(1)}px")
assert media.group(1) == "640", "the nav breakpoint moved off the 640px the phase shipped"
def test_the_header_status_dot_picks_up_the_disks_job(client) -> None: def test_the_header_status_dot_picks_up_the_disks_job(client) -> None:
"""`disks` gets its own dot instead of hiding inside `hosts`. """`disks` gets its own dot instead of hiding inside `hosts`.