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
@@ -0,0 +1,9 @@
**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).
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
........................................s............................... [ 29%]
........................................................................ [ 34%]
........................................................................ [ 38%]
........................................................................ [ 43%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
........................................................................ [ 68%]
.....sssssssssss........................................................ [ 73%]
........................................................................ [ 77%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 92%]
.....................................................sss................ [ 97%]
....................................... [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%
1464 passed, 15 skipped, 1 warning in 156.47s (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.4 wheel
@@ -0,0 +1,21 @@
All green. Final state:
**Implemented**
- `src/dashboards/web/static/dash.css` — the masthead is now **three left-justified rows at every width** (row 1 brand + `#nav-toggle` + tabs, row 2 `#nav-status` dots, row 3 the `.masthead-right` controls), CSS-only, no markup change: `.masthead` gained `flex-wrap: wrap` + `gap: .35rem 1.1rem` and `height: var(--header-h)` → `min-height`; `#nav-status` and `.masthead-right` each got `flex-basis: 100%` + `justify-content: flex-start`; `.masthead-right` lost `margin-inline-start: auto`. (The task's `flex-direction: column` first option cannot ship — it splits brand and tabs onto separate rows — so the file's own named `flex-wrap` fallback shipped, measured in a browser per the phase-18 rule.)
- ≤640px block re-cut: dropped its `height: auto` / `min-height` / `flex-wrap` machinery (one declaration now, no drift), kept the token reveal, `#nav-status { flex-wrap: nowrap }`, `.masthead-right > * { flex: none }`, the `env(safe-area-inset-top)` line and the dropdown; comments rewritten to the three-row reality. `position: sticky`/`z-index: 40` untouched (task 02's job).
- `tests/test_api.py` — `test_the_masthead_is_three_left_justified_rows_at_every_width` (+ `_rule_body`/`_declarations` helpers) and `test_the_mobile_masthead_block_declares_no_second_bar_height`, both on **served** bytes; mutation-tested (re-adding `height: var(--header-h)`, dropping `#nav-status`'s `flex-basis`, or re-declaring `min-height` in the mobile block each fails the matching test).
- Browser evidence: `data/logs/20-task01-rows.cjs` (7 pages × 1280 px/360 px: row order, left justification, no clipping, no overflow, dropdown under the **whole** bar) — ALL CHECKS PASS; screenshots `data/logs/masthead-rows{1280,360}-*.png` (+ dropdown, login, 768 px). No horizontal overflow at 360/480/641/768/900/1024/1440.
**Tests / lint / coverage**
- `uv run pytest` → **1460 passed, 15 skipped**; `uv run pytest --cov` → **98.94%** (floor 94%, unchanged); `uv run ruff check src tests agent` → clean; `uv run pyright` → 0 errors; `bash .agents/validate.sh` → **EXIT=0** (incl. `node --check`); `uv run pytest -m live --live` → **11 passed**; `data/logs/nav-final-pass.cjs` → **45/45**.
- Pre-existing advisory from validate: `fastapi 0.153.0 → 0.156.4`. Not touched — this task moved no dependencies (AGENTS rule 8 checks apply after dependency changes).
**Docs**
- `AGENTS.md`: new "The masthead is three left-justified rows at every width" paragraph (mechanism, why not `column`, the single `min-height`), and the phase-18 hamburger paragraph rewritten (block now tightens gaps instead of wrapping the bar; dropdown sits under the whole bar; nav-final-pass desktop leg noted).
- `README.md`: the `web/` bullet's "above it the masthead stays the one row it has always been" replaced with the three-row description.
**Decisions/deviations**
- Shipped the task's `flex-wrap` fallback (measured) rather than `flex-direction: column`; `/login` (theme toggle only) now renders a 2-row bar — unavoidable without markup changes, verified clean at both widths.
- Updated the gitignored `data/logs/nav-final-pass.cjs` desktop bound (`<80` → `80–140 px`) since the one-row bar it asserted no longer exists; nav behaviour assertions unchanged and passing.
**Next pending task:** `.agents/phases/todo/20_masthead_nav_polish/02_unsticky_masthead.md`
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
....................................s................................... [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
........................................................................ [ 68%]
.sssssssssss............................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 92%]
.................................................sss.................... [ 97%]
................................... [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%
1460 passed, 15 skipped, 1 warning in 153.92s (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.4 wheel
@@ -0,0 +1,14 @@
**Task 02 complete — masthead is no longer sticky.**
- `dash.css` `.masthead`: `position: sticky; top: 0` → `position: relative` (kept `z-index: 40`, so the dropdown's `41` still means "one above the bar"); rewrote the two comments that called the bar sticky (header block + the `nav.tabs.is-open` anchor note). Table-head `sticky; top: 0` (now line 848) untouched.
- `tests/test_api.py`: new `test_the_masthead_scrolls_away_and_stays_the_dropdown_anchor` in the phase-20 layout-pin section — served CSS asserts `position: relative`, no `position: sticky` value, **no `top`**, `z-index: 40`, and that the ≤640px dropdown is still `absolute; top: 100%` stacking above the bar. No pre-existing pin named the declaration, so this is the added one.
- Browser proof (invariant 10): new `data/logs/20-task02-unsticky.cjs` → **80 checks PASS / 0 FAIL** — 7 pages × 1280 px & 360 px, bar scrolls off the top (plus a squeezed-height leg for pages that fit a tall viewport), nothing pinned to the viewport but a `thead`, 360 px dropdown still opens flush under the whole three-row bar, is anchored to `.masthead`, and still hangs off it after a scroll. Screenshots `data/logs/masthead-unsticky*.png` + log.
- Fixed task 01's stale verification script (`data/logs/20-task01-rows.cjs` still demanded `position === "sticky"`) and a dangling "At" task 01 left in README's `web/` bullet.
**Results:** `uv run pytest` → **1461 passed, 15 skipped**; `bash .agents/validate.sh` → **EXIT=0** (coverage **98.94%** vs 94% floor, `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` of `web/static/*.js` clean); `uv run pytest -m live --live` → **11 passed**.
**Docs:** AGENTS.md masthead paragraph now records the un-stick (why `relative` not `static`, the menu scrolls with the bar, the only remaining sticky is a table head, the "Chrome computes `top: 0px` either way" note explaining why the offset is a CSS pin), and cites three phase-20 tests + the new script. README's `web/` bullet says the bar is not sticky and why the dropdown stays anchored.
**Decisions/deviations:** `uv pip list --outdated` still lists `fastapi 0.153.0 → 0.156.4` — pre-existing, deps untouched by this task, so validate treats it as advisory; deliberately not bumped mid-phase.
**Next pending task:** `20_masthead_nav_polish/03_hamburger_slide.md` (180 ms dropdown slide, stilled under `prefers-reduced-motion`).
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
.....................................s.................................. [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
........................................................................ [ 68%]
..sssssssssss........................................................... [ 73%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 92%]
..................................................sss................... [ 97%]
.................................... [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%
1461 passed, 15 skipped, 1 warning in 155.60s (0:02:35)
== 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.4 wheel
@@ -0,0 +1,15 @@
**Task 03 complete — the ≤640px menu slides out and back in.**
- `dash.css`: `nav.tabs` is now the **closed** dropdown (geometry + `visibility: hidden; opacity: 0; transform: translateY(-8px); pointer-events: none` + `transition: opacity 180ms ease, transform 180ms ease, visibility 0s linear 180ms`); `.is-open` flips only those four to their visible counterparts with the `visibility` delay dropped — the `brain_of_reese` `.app-nav` pair. `display: none` had to go (an unrendered box has no intermediate state).
- Merged `nav.tabs, nav.tabs.is-open { transition: none; }` into the file's **only** `prefers-reduced-motion` block (both states named, phase-46 lesson); `app.js` unchanged except one stale comment.
- Tests: 3 new served-CSS pins (closed start / visible end / both-states reduced motion + "no `display: none` may come back") and a `_cascade` helper; two stale pins (phase-18 `display:none`, task-02 dropdown geometry) updated to where the declarations now live.
- Browser (invariant 10): new `data/logs/20-task03-slide.cjs` → **all checks pass** (computed closed state, mid-transition frame at ≈60 ms in *both* directions, 4 close paths + Esc focus, reduced-motion snaps `prop=none`, desktop tabs untouched, hidden menu adds **no** scroll area); screenshots `data/logs/slide-*.png`.
- Also repaired four stale verification probes the change invalidated: `nav-final-pass.cjs` 45/45, `nav-task03-verify.cjs` 77, `nav-task04-verify.cjs` 24, `20-task01-rows.cjs` all pass.
**Results:** `uv run pytest` → **1464 passed, 15 skipped**; `bash .agents/validate.sh` → **EXIT=0** (coverage **98.94%** vs 94% floor, `ruff check src tests agent` clean, `pyright` 0 errors, `node --check web/static/*.js` clean); `uv run pytest -m live --live` → **11 passed**.
**Docs:** AGENTS.md nav paragraph rewritten (one box / two states, why `visibility` not `display`, the delayed-then-undelayed `visibility`, both-states reduced motion, the new pins + script; test count 1458→1464); README's `web/` bullet now says the menu slides 180 ms and stills under `prefers-reduced-motion`.
**Decisions/deviations:** followed the task's prescribed visibility pattern rather than `@starting-style`/`allow-discrete` (wider support, it is the referenced implementation); the explicit reduced-motion pair is redundant with the universal `* …!important` rule and kept deliberately — the task mandates naming both states. `uv pip list --outdated` still lists `fastapi 0.153.0 → 0.156.4`: pre-existing, deps untouched, not bumped mid-phase.
**Next pending task:** `20_masthead_nav_polish/04_admin_links_visible.md`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
........................................s............................... [ 29%]
........................................................................ [ 34%]
........................................................................ [ 38%]
........................................................................ [ 43%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
........................................................................ [ 68%]
.....sssssssssss........................................................ [ 73%]
........................................................................ [ 77%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 92%]
.....................................................sss................ [ 97%]
....................................... [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%
1464 passed, 15 skipped, 1 warning in 156.99s (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.4 wheel
@@ -0,0 +1,15 @@
**Task 04 complete — both admin links stay visible on the admin pages.**
- `pages/certs.html`: added `⚷ Certs` (order is clock → `⚷ Certs` → `⚙ Account` → theme) and rewrote the comment that justified its absence; also gave its theme toggle the `aria-pressed="false"` the other seven pages carry (pre-existing markup drift, now pinned).
- `pages/settings.html`: added `⚙ Account` between Certs and the theme toggle; comment now says "this one included".
- `tests/test_api.py::test_every_page_links_to_both_admin_pages` rewritten: **one** loop over all eight signed-in pages asserting both full tags, both before `id="theme-toggle"`, Certs-before-Account, neither inside `.tabs`, and an identical theme-toggle tag; the two "links to itself" assertions deleted; added the `/login`-renders-neither assertion via an unsigned client (mutation-checked: removing the link fails with "no route to the certificate authority").
- Browser (invariant 10): new `data/logs/20-task04-admin-links.cjs` → **86/86 PASS** (cluster rendered + in order at 1280 px and 360 px, self-link click = exactly one `200` document, no redirect, page/cluster unchanged, cross-links still navigate, `/login` neither link); screenshots `data/logs/admin-links-{1280,360}-{certs,settings}.png` (the pair is byte-identical — the clusters now are) + `admin-links-login.png`.
- Fixed a stale probe from task 02 (`20-task02-unsticky.cjs`): its fixed 700 ms sleep raced first paint and reported "max scroll 0"; it now settles on document height and asserts the bar rides the scroll by exactly the scrolled distance — 3 consecutive green runs. `20-task01/03`, `nav-final-pass` (45/45), `nav-task04-verify` (24/24) also re-run green.
**Results:** `uv run pytest` → **1464 passed, 15 skipped**; coverage **98.94 %** (floor 94 %); `ruff check src tests agent` clean; `pyright` 0 errors; `bash .agents/validate.sh` → **EXIT=0** (incl. `node --check`); `uv run pytest -m live --live` → **11 passed**; dev server restarted, `/api/health` all nine sources `ok`.
**Docs:** AGENTS.md — both admin-page paragraphs rewritten to the new rule (self-link is a no-op; one loop over eight; `/login` the exception), gap 11's `/certs` line and gap 12's "which that phase is the decision on" resolved. README — intro, *The account page*, and the `web/` masthead bullet now say both links are on all eight signed-in pages.
**Deviations:** touched the `/certs` theme toggle's `aria-pressed` (defect fix, in service of "identical controls"; no behaviour change); `uv pip list --outdated` still lists `fastapi 0.13 → 0.156` — pre-existing advisory, deps untouched, not bumped mid-phase.
**Next pending task:** `21_page_cleanups/01_remove_pause_button.md` (phase 20 is now complete).
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
........................................s............................... [ 29%]
........................................................................ [ 34%]
........................................................................ [ 38%]
........................................................................ [ 43%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
........................................................................ [ 68%]
.....sssssssssss........................................................ [ 73%]
........................................................................ [ 77%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 92%]
.....................................................sss................ [ 97%]
....................................... [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%
1464 passed, 15 skipped, 1 warning in 155.86s (0:02:35)
== 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.4 wheel
+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 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 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 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 `.masthead-right` of every dashboard **including itself** — outside `.tabs`, so the pinned tab
(`tests/test_api.py::test_every_page_links_to_both_admin_pages`). It prints 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 `/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 `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 — checking locally what it can judge itself (non-empty current, ≥ 8 new characters —
@@ -705,8 +705,15 @@ Responsibilities, one line each:
any `401` bounce. any `401` bounce.
`/certs` (`pages/certs.html` + `static/page-certs.js`) is the second of those admin pages, and it `/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`, 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 and both masthead links — `⚷ Certs` beside `⚙ Account` — exactly as the five dashboards render
why the pinned two-link audit loops over two different tuples). Everything on it is 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 `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, 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`, 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 `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 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 **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"`, 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">`, `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 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 `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 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 through the token, hides `nav.tabs`, and tightens the bar's `row-gap` plus the cluster's gap (the
control to make the width work is not the deal), and pads the bar down by 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 `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 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 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 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 (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); surface, which render byte-identical, and the dropdown's `top: 100%` follows the taller bar);
it renders `nav.tabs.is-open` as the dropdown: it renders the dropdown as the `nav` element itself — since phase 20 task 03 that element is
the `nav` element itself, `position: absolute; top: 100%; z-index: 41` under the sticky bar — no **one box in two states**: `nav.tabs` carries the geometry (`position: absolute; top: 100%;
second menu element, no `body` marker class, no colour outside the tokens. One binding in 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 `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 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 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. 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 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 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 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 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 `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 `/` + 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 `/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 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 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. 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 status # per-source ok/DOWN — unauthenticated: /api/health
./scripts/dev-server.sh logs ./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 # 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
@@ -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 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 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: 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 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 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 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). 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 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 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 breaks into rows, so nothing is lost and nothing gained: the masthead is complete at 360 px and
table tail is still out of reach. The fix is a responsive-table pass (`.scroll` on the wide 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 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 a phase rather than a cleanup — `21_page_cleanups`' `.scroll` on the `/certs` table is the same
now, in `todo/`: `24_dev_mtls_rollout` (never accepting a non-mTLS agent — fleet gap 11's shape applied to one table and does not reach this one. Of the two operator TODO items that sat
rollout, half of it) and `20_masthead_nav_polish` (keeping both `⚙ Account` / `⚷ Certs` beside it, `20_masthead_nav_polish` has landed in full (now complete): the bar is three
masthead links visible on the admin pages that today do not link to themselves — phase 15's left-justified rows at every width, it is no longer sticky, the phone menu slides, and both
deliberate rule, which that phase is the decision on). `⚙ 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) ## Locked decisions (and why)
+18 -7
View File
@@ -14,7 +14,7 @@ No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the in
runtime; every asset is served by this app. runtime; every asset is served by this app.
Three more pages are not dashboards: `/login` (the one thing an anonymous visitor sees), Three more pages are not dashboards: `/login` (the one thing an anonymous visitor sees),
`/settings` — `⚙ Account` in the masthead of every dashboard — which shows this browser's `/settings` — `⚙ Account`, in the masthead of every signed-in page — which shows this browser's
session, signs it out, and changes the admin password, and `/certs` — `⚷ Certs`, beside it — session, signs it out, and changes the admin password, and `/certs` — `⚷ Certs`, beside it —
which is the certificate authority the agents are dialled with: the root CA, this dashboard's which is the certificate authority the agents are dialled with: the root CA, this dashboard's
own client certificate, and one row per host with its rotate, revoke and bundle download. own client certificate, and one row per host with its rotate, revoke and bundle download.
@@ -356,8 +356,8 @@ in-app path (`/ups`, not `//evil.example`), judged on the server and again in th
### The account page ### The account page
`/settings` — reached by `⚙ Account` in the masthead of every dashboard — is the session facts and `/settings` — reached by `⚙ Account`, which sits in the masthead of every signed-in page including
the one form that changes the password. this one — is the session facts and the one form that changes the password.
| Panel | What it says, and what the button does | | Panel | What it says, and what the button does |
| ----- | ------------------------------------------------------------------------ | | ----- | ------------------------------------------------------------------------ |
@@ -642,10 +642,21 @@ 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). At the same agent can answer the 10-second utilisation poll and have a wedged SMART pass).
≤640px the five tab links leave the bar and come back as a dropdown under it, opened by the `☰` The masthead is **three left-justified rows at every width**: the brand and the five dashboard
button (`/login` renders no tabs, so it has no button either), and above it the masthead stays tabs, then the per-source indicators on their own row, then the clock / `⏸ Pause` (on the four
the one row it has always been. No framework, no CDN, no bundler, and no narration: a page never pages that poll) / `⚷ Certs` / `⚙ Account` / `☾ Dark` controls on a row under those (a page with no
indicators — `/login`, `/settings`, `/certs` — renders two, and a control is never hidden to make a
narrower width work). Both admin links are on all eight
signed-in pages, the two pages they name included: the cluster is the same wherever you are, and a
link to the page you are already on is a harmless no-op rather than chrome that vanishes. The bar is **not sticky**: it scrolls away
with the page, so every pixel of height goes to the data (it stays a positioned element, which is
what keeps the mobile dropdown anchored under it). At ≤640px the five tab links leave the bar
and come back as a dropdown under the whole of it, opened by the `☰` button (`/login` renders no
tabs, so it has no button either), and that menu **slides**: 180 ms of fade plus an 8 px rise on
the way out and the same on the way back in, with nothing hidden from a keyboard or a screen
reader while it is shut, and no motion at all when the system asks for none
(`prefers-reduced-motion`). 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 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
+7 -3
View File
@@ -12,8 +12,11 @@
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a> <a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<!-- The five dashboards, and no `aria-current`: `/certs` is not one of them, and a tab list <!-- The five dashboards, and no `aria-current`: `/certs` is not one of them, and a tab list
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 both admin links — `⚷ Certs` and `⚙ Account` — outside `.tabs`, so the tab
structure stays five links deep on every page. --> structure stays five links deep, and it carries them **here too** (`TODO.md:4`): the
controls are the same on all eight signed-in pages, and a link to the page you are on is
a no-op the operator can press without being surprised, which beats chrome that
disappears when it is most being used. -->
<button class="ghost nav-toggle" id="nav-toggle" type="button" <button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button> aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards"> <nav class="tabs" id="nav-tabs" aria-label="Dashboards">
@@ -25,8 +28,9 @@
</nav> </nav>
<div class="masthead-right"> <div class="masthead-right">
<span class="clock" id="clock">--:--:--</span> <span class="clock" id="clock">--:--:--</span>
<a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a> <a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button">☾ Dark</button> <button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div> </div>
</header> </header>
+4 -1
View File
@@ -12,7 +12,9 @@
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a> <a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<!-- 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 — this one
included (`TODO.md:4`): the two admin links are the same on all eight signed-in pages,
and chrome that vanishes on the page it names is the thing the operator filed. -->
<button class="ghost nav-toggle" id="nav-toggle" type="button" <button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button> aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards"> <nav class="tabs" id="nav-tabs" aria-label="Dashboards">
@@ -27,6 +29,7 @@
`Dash.initChrome()` builds the dots only when the container exists. --> `Dash.initChrome()` builds the dots only when the container exists. -->
<span class="clock" id="clock">--:--:--</span> <span class="clock" id="clock">--:--:--</span>
<a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a> <a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button> <button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div> </div>
</header> </header>
+2 -1
View File
@@ -691,7 +691,8 @@ const Dash = (() => {
// is a control that lies). The four close paths: the button again, a tab link, Esc (with // 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 // 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 // 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. // move together or the menu opens into a hidden nav (the CSS closes it with
// `visibility: hidden`, since a `display: none` box could not slide — phase 20 task 03).
const navToggle = $('#nav-toggle'); const navToggle = $('#nav-toggle');
const appNav = $('#nav-tabs'); const appNav = $('#nav-tabs');
if (navToggle && appNav) { if (navToggle && appNav) {
+135 -49
View File
@@ -96,15 +96,39 @@ a:hover { text-decoration: underline; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
/* ---- header ------------------------------------------------------------- */ /* ---- header ------------------------------------------------------------- */
/* The masthead is **three left-justified rows at every width** (phase 20 task 01, `TODO.md:5-6`
* — "the hosts disks engines and UPS indicators … left justified in their own row", "the
* ⚷ Certs ⚙ Account ☾ Dark buttons … in their own row under" them): row 1 the brand,
* `#nav-toggle` and the five tabs; row 2 the `#nav-status` reachability dots; row 3 the
* `.masthead-right` controls (clock, `⏸ Pause`, `⚷ Certs`, `⚙ Account`, theme).
*
* No markup changed to get them, and that decides the mechanism. `#nav-status` is the first
* child of `.masthead-right`, so there is no wrapper to promote: in a `flex-wrap: wrap`
* container an item whose *flex base size* fills the line is alone on it — line breaking uses
* the base size, before shrink — so `flex-basis: 100%` on the div starts a new row of the bar
* and the same declaration on the dots starts a row inside it, leaving the controls that
* follow to wrap onto the third. `align-items` centres each row's content against its own line
* box; `height` became `min-height` because three rows do not fit in `--header-h` and a fixed
* height clipped the controls (the ≤640px block no longer restates it, so there is one
* declaration to keep honest).
*
* 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"): it scrolls away with the
* page, and the "sticky part way down" quirk on a phone was the fixed-height bar fighting the
* wrapped rows, so removing the stick removed the quirk. What it does *not* lose is its role as
* a positioned ancestor: `position: relative` keeps the ≤640px dropdown's `top: 100%` anchored
* under it (the default `static` would hand that to the initial containing block and the menu
* would open at the top of the document), and `z-index: 40` stays so the menu's `41` still
* means "one above the masthead". */
.masthead { .masthead {
position: sticky; position: relative;
top: 0;
z-index: 40; z-index: 40;
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 1.1rem; gap: .35rem 1.1rem;
height: var(--header-h); min-height: var(--header-h);
padding: 0 1.25rem; padding: .45rem 1.25rem;
background: color-mix(in srgb, var(--bg-raised) 92%, transparent); background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@@ -145,7 +169,33 @@ nav.tabs a {
nav.tabs a:hover { background: var(--bg-sunken); color: var(--text); text-decoration: none; } nav.tabs a:hover { background: var(--bg-sunken); color: var(--text); text-decoration: none; }
nav.tabs a[aria-current="page"] { background: var(--accent-soft); color: var(--accent); } nav.tabs a[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
.masthead-right { margin-inline-start: auto; display: flex; align-items: center; gap: .55rem; } /* Row 3: the controls, left-justified under the indicators. `margin-inline-start: auto` is
* what pinned this cluster to the right edge of the old single-row bar; in a row of its own it
* pushes against nothing, and `justify-content: flex-start` is the TODO's word for where the
* buttons go. `flex-basis: 100%` is the row break (see `.masthead` above) and `flex-wrap` is
* what a phone gets when clock + pause + two links + theme will not share a line — phase 18's
* rule, and no control is ever hidden to make the arithmetic work. */
.masthead-right {
flex-basis: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: .35rem .55rem;
}
/* Row 2: the per-source indicators, left-justified on a line of their own, so a readout is
* never mistaken for one more button beside it. `.gap` already makes this a flex row; the
* `flex-basis` is the break inside `.masthead-right`, `justify-content` the alignment, and the
* gap is restated at .55rem so the dots read as a row of their own rather than as the tail of
* the cluster above. */
#nav-status {
flex-basis: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
gap: .55rem;
}
.clock { font-family: var(--mono); font-size: 12.5px; color: var(--text-muted); } .clock { font-family: var(--mono); font-size: 12.5px; color: var(--text-muted); }
@media (max-width: 900px) { .clock { display: none; } } @media (max-width: 900px) { .clock { display: none; } }
@@ -190,25 +240,25 @@ button.ghost:disabled { opacity: .45; cursor: not-allowed; }
#nav-toggle { display: var(--nav-toggle-display); } #nav-toggle { display: var(--nav-toggle-display); }
/* ---- mobile nav --------------------------------------------------------- /* ---- mobile nav ---------------------------------------------------------
* The ≤640px half of the hamburger (phase 18): the five tab links leave the bar and come * The ≤640px half of the hamburger (phase 18), now living inside the three-row bar of task 01:
* back as a dropdown under it, opened by `#nav-toggle`. The state lives entirely in the * the five tab links leave the bar and come back as a dropdown under it, opened by
* `.is-open` class task 03's binding puts on `#nav-tabs`, so this block renders a verdict * `#nav-toggle`. The state lives entirely in the `.is-open` class task 03's binding puts on
* made elsewhere and owns no behaviour of its own. * `#nav-tabs`, so this block renders a verdict made elsewhere and owns no behaviour of its own —
* and since task 03, it owns the slide too: `nav.tabs` is the closed menu and `.is-open` its
* visible counterpart, with a 180 ms `opacity`/`transform` transition on both.
* *
* Above 640px nothing here applies, which is the point: the desktop masthead is the same * Above 640px nothing here applies, which is the point: the same three left-justified rows —
* one row with the inline tabs it always had, and the button stays hidden. * brand + toggle, indicators, controls — with the tabs inline on the first one and the button
* hidden. What a phone changes is only that the tabs become a menu and the gaps tighten.
*/ */
@media (max-width: 640px) { @media (max-width: 640px) {
/* One fixed-height row cannot hold brand + toggle + the whole `masthead-right` cluster /* The bar's vertical rhythm, and nothing structural. The three rows come from the base
* (status dots, pause, `⚷ Certs`, `⚙ Account`, theme) at 360px, so the bar wraps: brand * rules now, so the one-row wrap this block used to own (`height: auto` plus the
* and toggle on the first row, the cluster on the second. `height: auto` rather than a * `flex-wrap` that made it) is gone rather than restated — `min-height: var(--header-h)` is
* new fixed height is what keeps the dropdown's `top: 100%` honest — it pins to the * declared once, on `.masthead`, which is what stops the two drifting apart. The dropdown's
* bottom of the masthead however many rows that is — and `min-height` stops a page whose * `top: 100%` is unaffected: it pins to the bottom of the masthead however many rows that
* cluster also wrapped from collapsing to a 2-line bar. */ * is, which on a phone is the whole three-row bar rather than wedged between rows. */
.masthead { .masthead {
height: auto;
min-height: var(--header-h);
flex-wrap: wrap;
row-gap: .4rem; row-gap: .4rem;
padding-block: .5rem; padding-block: .5rem;
/* The bar must not start at y=0 of the layout viewport. On a real phone the viewport /* The bar must not start at y=0 of the layout viewport. On a real phone the viewport
@@ -225,19 +275,18 @@ button.ghost:disabled { opacity: .45; cursor: not-allowed; }
padding-top: calc(.5rem + env(safe-area-inset-top, 0px)); 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 /* The same treatment one level down, and these three rules are still what a 360px phone
* 360px phone actually requires — measured on both sides of them. With only the rule above, * actually requires — phase 18 measured both sides of them: without them the four status
* the bar came out 183px tall: the four status dots in a single-file column (`#nav-status` * dots fell into a single-file column (`#nav-status` carries the shared `.gap` class, which
* carries the shared `.gap` class, which is `flex-wrap: wrap`) and `⚷ Certs` broken over two * is `flex-wrap: wrap`) and `⚷ Certs` broke over two lines, because every item shrank to a
* lines, because every item shrank to a fraction of its content. Before this phase the bar * fraction of its content. So the cluster may wrap, its items may neither shrink nor break a
* kept its fixed 58px and that 118px dot column simply overflowed it onto the page below, * label, and the dots stay the one line that makes them a readout instead of a stack. Row 3
* with the document 45px wider than the viewport. So: the cluster may wrap, its items may * still wraps *within itself*: `⏸ Pause`, `⚷ Certs`, `⚙ Account` and the theme button do not
* neither shrink nor break a label, and the dots stay the one line that makes them a readout * fit in 320px once over on the narrowest phones, and nothing here hides a control to make
* instead of a stack. The bar lands at ~123px at 360px and ~93px at 640px — three visual * the arithmetic work — a phone gets the same words every other width does. The row
* rows at the narrow end, two at 640px — because four labelled dots plus `⚷ Certs`, * alignment is the base rule's `flex-start`, restated nowhere: phase 20 replaced the
* `⚙ Account` and the theme button do not fit in 320px twice over, and nothing here hides a * right-justified cluster, so this block only tightens the gap. */
* control to make the arithmetic work: a phone gets the same words every other width does. */ .masthead-right { flex-wrap: wrap; gap: .4rem; }
.masthead-right { flex-wrap: wrap; justify-content: flex-end; gap: .4rem; }
.masthead-right > * { flex: none; white-space: nowrap; } .masthead-right > * { flex: none; white-space: nowrap; }
#nav-status { flex-wrap: nowrap; } #nav-status { flex-wrap: nowrap; }
@@ -259,21 +308,37 @@ button.ghost:disabled { opacity: .45; cursor: not-allowed; }
touch-action: manipulation; 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 /* 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 * `position: relative` (task 02 un-pinned the bar from the viewport top and kept it a
* it and `left/right: 0` span the masthead's padding box edge to edge; hence * positioned ancestor on purpose), so `top: 100%` lands directly under
* it — under the **whole** three-row bar since task 01, which is the intended look: a menu
* below the masthead rather than wedged between its rows — 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 * `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 * 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. * 40 so the menu paints over the content it overlaps. Since the bar itself now scrolls,
* the menu scrolls with it: opening the menu and then scrolling the page moves both, which
* is what a bar that is not pinned to the viewport means.
* *
* `box-shadow: var(--shadow)` rather than a literal black: this file's rule is that no * `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 * 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. */ * black drop is the one thing that looks wrong under a white menu.
nav.tabs.is-open { *
* **This rule is also the closed state** (task 03, `TODO.md:11` — "a nice animation for the
* hamburger menu — a slide out and slide back in"). It used to be `nav.tabs { display: none }
* with the geometry on `.is-open`, and that is precisely why the menu snapped: a `display`
* flip has no animatable intermediate, so an `opacity`/`transform` transition there would
* never have run. The geometry therefore lives here now — the box the transition animates is
* the same box that exists while closed — and `.is-open` below flips only the four properties
* that say *visible*. `visibility: hidden` keeps the closed menu out of the accessibility
* tree and its five links out of the tab order, exactly as `display: none` did, and
* `pointer-events: none` makes it unhittable while it is off-screen mid-slide. On the way
* shut `visibility` carries a delay equal to the animation, so the box stays visible for the
* 180 ms it takes to fade and rise out and only then leaves the tree; on the way open
* `.is-open` drops that delay, or the menu would be invisible for the first 180 ms of its own
* opening. `transition` on both states is the reference pattern (brain of reese's `.app-nav`),
* and `prefers-reduced-motion` at the foot of this file stills both of them. */
nav.tabs {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
@@ -287,6 +352,23 @@ button.ghost:disabled { opacity: .45; cursor: not-allowed; }
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
box-shadow: var(--shadow); box-shadow: var(--shadow);
z-index: 41; z-index: 41;
visibility: hidden;
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
transition: opacity 180ms ease, transform 180ms ease, visibility 0s linear 180ms;
}
/* The open state, and the only thing the class flip changes. `transform: none` is the other
* half of the slide: the 8 px rise from the closed rule resolves to the menu's own position,
* so the row of links travels 8 px while it fades in, and back the other way when
* `setNavMenu(false)` drops the class — out and back in, which is what was asked for. */
nav.tabs.is-open {
visibility: visible;
opacity: 1;
transform: none;
pointer-events: auto;
transition: opacity 180ms ease, transform 180ms ease, visibility 0s;
} }
/* Rows, not pills: the base .38rem padding is a desktop tab strip, and a menu item you /* Rows, not pills: the base .38rem padding is a desktop tab strip, and a menu item you
@@ -300,12 +382,6 @@ button.ghost:disabled { opacity: .45; cursor: not-allowed; }
} }
} }
/* 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; }
@@ -1103,6 +1179,16 @@ footer.pagefoot {
color: var(--text-faint); font-size: 11.5px; display: flex; gap: 1rem; flex-wrap: wrap; color: var(--text-faint); font-size: 11.5px; display: flex; gap: 1rem; flex-wrap: wrap;
} }
/* Motion, stilled. The universal rule is the app-wide answer (the bar fill, the pager's
* opacity and the ≤640px menu all fall under it). The menu's own pair is named as well,
* **both states**, and that is not decoration: `nav.tabs.is-open` is a higher-priority rule
* than `nav.tabs`, so a reduced-motion block that named only the closed state would leave the
* OPEN transition — the one that carries `visibility 0s` — free to animate if the universal
* rule were ever narrowed. Naming both states is brain of reese's phase-46 lesson, quoted here
* so the next reader does not "simplify" one of the two selectors away. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; } * { transition: none !important; animation: none !important; }
nav.tabs,
nav.tabs.is-open { transition: none; }
} }
+299 -25
View File
@@ -996,34 +996,62 @@ def test_the_settings_page_refuses_a_caller_with_no_session(tmp_path: Path) -> N
store.close() store.close()
def test_every_page_links_to_both_admin_pages(client) -> None: def test_every_page_links_to_both_admin_pages(client, tmp_path: Path) -> None:
"""`⚙ Account` and `⚷ Certs` are the only way to the two admin pages, so they are everywhere. """`⚙ Account` and `⚷ Certs` are the only way to the two admin pages, so they are everywhere.
They sit in `.masthead-right` before the theme toggle and **outside** `.tabs`, because the tab One loop over all eight signed-in pages, because the controls are now the *same* on all of
strip's structure — five dashboards, one of them current — is pinned by them: clock → `⚷ Certs` → `⚙ Account` → theme toggle, in `.masthead-right` and **outside**
`test_the_disks_tab_is_in_the_masthead_of_every_page` and a sixth entry with no `.tabs` (the tab strip's structure — five dashboards, one of them current — is pinned by
`aria-current` would break what that marker means. `test_the_disks_tab_is_in_the_masthead_of_every_page`, and a sixth entry with no
`aria-current` would break what that marker means).
Neither page links to itself, which is the `/settings` precedent from phase 11 and the reason Both links are on the pages they name, which **inverts** phase 15's "no self-link"
the two loops below are over different tuples: an `⚷ Certs` link on `/certs` is a no-op the precedent on the operator's decision (`TODO.md:4` — "clicking the certs tab causes the certs
operator learns to ignore, and ignored chrome is worse than none. button to disappear … These should remain visible"): masthead chrome that changes shape
depending on where you already are reads as a broken control, while a link to the page you
are on is a no-op that answers with the page you were looking at.
""" """
account = '<a class="ghost account" href="/settings"' account_open = '<a class="ghost account" href="/settings"'
certs_link = '<a class="ghost account" href="/certs"' certs_open = '<a class="ghost account" href="/certs"'
for path in (*PAGES, SETTINGS_PAGE): # the opening tag **and** its label: the claim is the cluster does not change shape between
# pages, and `/certs` shipped a theme toggle missing its `aria-pressed` for exactly the
# reason nothing ever compared the eight markups
account = f'{account_open} title="Session and password">⚙ Account</a>'
certs_link = f'{certs_open} title="Agent certificates">⚷ Certs</a>'
theme = '<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">'
for path in MARKUP_PAGES:
html = client.get(path).text html = client.get(path).text
assert certs_link in html, f"{path} has no route to the certificate authority" assert certs_link in html, f"{path} has no route to the certificate authority"
assert certs_link in html[:html.index('id="theme-toggle"')], \ assert account in html, f"{path} has no route to the account page"
f"{path} put the Certs link after the theme toggle" before_theme = html[:html.index('id="theme-toggle"')]
assert certs_link in before_theme, f"{path} put the Certs link after the theme toggle"
assert account in before_theme, f"{path} put the Account link after the theme toggle"
assert before_theme.index(certs_link) < before_theme.index(account), \
f"{path} orders the two admin controls differently from the dashboards"
tabs = html[html.index('<nav class="tabs"'):html.index("</nav>")] tabs = html[html.index('<nav class="tabs"'):html.index("</nav>")]
assert "/certs" not in tabs, f"{path} made Certs a dashboard tab" assert "/certs" not in tabs, f"{path} made Certs a dashboard tab"
for path in (*PAGES, CERTS_PAGE): assert "/settings" not in tabs, f"{path} made Account a dashboard tab"
html = client.get(path).text assert theme in html, f"{path}'s theme toggle no longer matches the other seven"
assert account in html, f"{path} has no route to the account page"
assert account in html[:html.index('id="theme-toggle"')], \ # The one page that renders neither, and its own client: `client` is signed in, and a
f"{path} put the Account link after the theme toggle" # signed-in `GET /login` is a `303` to the dashboard, which would audit `/` and call it
assert account not in client.get(SETTINGS_PAGE).text, "/settings links to itself" # `/login` (the trap documented at `test_the_login_page_carries_no_nav_and_no_toggle`).
assert certs_link not in client.get(CERTS_PAGE).text, "/certs links to itself" # Pre-auth there is nothing to administer, so the masthead stays brand link + theme toggle.
db = str(tmp_path / "admin-links-login.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"
login_html = response.text
finally:
store.close()
# the opening tag, so an `href` under any other class or label still fails here
for link in (certs_open, account_open):
assert link not in login_html, f"/login offers {link} to a caller who cannot use it"
def test_the_certs_page_is_a_page_and_not_a_sixth_dashboard(client) -> None: def test_the_certs_page_is_a_page_and_not_a_sixth_dashboard(client) -> None:
@@ -4037,7 +4065,7 @@ def test_the_disks_tab_is_in_the_masthead_of_every_page(client) -> None:
# were built — is the button on every tabbed page and on no others, does the stylesheet still # 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 # 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 # 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. # that drifts in one file opens a menu into a hidden nav on the operator's phone.
#: The attributes that make `#nav-toggle` a button and not a mystery glyph: it is not a form #: 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 #: control (`type`), it says what it toggles (`aria-controls`), it says what state it is in
@@ -4145,13 +4173,12 @@ def test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint(client) -> Non
media = re.search(r"@media\s*\(max-width:\s*(\d+)px\)", nav_css) media = re.search(r"@media\s*\(max-width:\s*(\d+)px\)", nav_css)
assert media, "the mobile nav block lost its media query" 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 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", # the links leave the row as the menu
"nav.tabs.is-open", # …and come back as the menu
"position: absolute", "position: absolute",
"top: 100%", # pinned under the sticky masthead "top: 100%", # pinned under the whole masthead
"z-index: 41", # one above the masthead's 40 "z-index: 41", # one above the masthead's 40
"background: var(--bg-raised)", "background: var(--bg-raised)",
"flex-wrap: wrap", # the two-row bar, cluster still visible "flex-wrap: wrap", # the controls wrap, none is hidden
# the safe-area inset keeps the first-row #nav-toggle out of the system # 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 # 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 page; headless emulation has no such zone, so only this pin says
@@ -4185,6 +4212,253 @@ def test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint(client) -> Non
assert media.group(1) == "640", "the nav breakpoint moved off the 640px the phase shipped" assert media.group(1) == "640", "the nav breakpoint moved off the 640px the phase shipped"
# ---- the three-row masthead (phase 20 task 01) ------------------------------ #
#
# `TODO.md:5-6` asked for the per-source indicators and the `⚷ Certs ⚙ Account ☾ Dark` cluster
# each on their own left-justified line. The markup could not change — `#nav-status` is a child
# of `.masthead-right`, and the tab structure is pinned by
# `test_the_disks_tab_is_in_the_masthead_of_every_page` — so the rows are made of line breaks:
# in a `flex-wrap: wrap` container an item whose *base* size fills the line is alone on it (the
# break happens before shrink), so `flex-basis: 100%` on `.masthead-right` starts row 2 of the
# bar and the same declaration on the dots starts row 2 *inside* it, pushing the controls to
# row 3. The alternative the task allowed — `flex-direction: column` — would have put the brand
# and the tabs on separate lines, which is why the fallback shipped. A browser proves the
# geometry at 1280 px and 360 px (`data/logs/20-task01-rows.cjs`, `masthead-rows*.png`); these
# pins keep a later re-flow from putting the buttons back on the dots' line in silence.
def _rule_body(css: str, selector: str) -> str:
"""One rule's declaration block, with the comments stripped.
Anchored on the selector itself rather than a section slice: `.masthead-right {` must not
satisfy a lookup for `.masthead {`, and the ≤640px block restates both selectors.
"""
match = re.search(rf"{re.escape(selector)}\s*\{{([^}}]*)\}}", _code(css))
assert match, f"dash.css no longer has a `{selector} {{ … }}` rule"
return match.group(1)
def _declarations(css: str, selector: str) -> dict[str, str]:
"""That block as `{property: value}`, so a pin can ask whether a property exists at all."""
found: dict[str, str] = {}
for declaration in _rule_body(css, selector).split(";"):
prop, sep, value = declaration.partition(":")
if sep:
found[prop.strip()] = value.strip()
return found
def _cascade(css: str, *selectors: str) -> dict[str, str]:
"""The declarations of several rules merged in cascade order, later winning.
Needed for the ≤640px menu: since phase 20 task 03 the dropdown's *geometry* lives on the
closed `nav.tabs` rule and `.is-open` carries only the visible counterpart, because a box
that does not exist cannot animate. A pin that asks "does the open menu sit at `top: 100%`
and paint above the bar?" is asking about the element, so it has to read both rules the way
the browser does — one duplicated `position` in two rules is the drift this avoids.
"""
merged: dict[str, str] = {}
for selector in selectors:
merged.update(_declarations(css, selector))
return merged
def test_the_masthead_is_three_left_justified_rows_at_every_width(client) -> None:
"""Brand + tabs, then the indicators, then the controls — three rows, two of them flush left.
Every assertion here is one declaration the row layout depends on, read out of the
**served** stylesheet: `flex-wrap` plus the two `flex-basis: 100%` rules are the rows, the
two `justify-content` values are the left justification, and the absence of
`margin-inline-start` on the cluster is the end of the right-pinned single-row bar.
"""
css = _css(client)
bar = _declarations(css, ".masthead")
assert bar.get("flex-wrap") == "wrap" or bar.get("flex-direction") == "column", \
"the masthead can no longer break into rows: indicators and controls share a line again"
assert bar.get("flex-direction", "row") == "row", \
"flex-direction: column puts the brand and the tabs on separate rows"
assert "height" not in bar, \
".masthead has a fixed height again — three rows do not fit in --header-h and clip"
assert bar.get("min-height") == "var(--header-h)", \
".masthead lost the min-height that keeps a dots-less page from collapsing to one line"
assert bar.get("gap", "").startswith(".35rem"), \
"the row gap the three rows need is gone from the masthead's shorthand gap"
cluster = _declarations(css, ".masthead-right")
assert cluster.get("flex-basis") == "100%", \
"the control cluster no longer starts its own row under the indicators"
assert cluster.get("justify-content") == "flex-start", \
"the controls are no longer left-justified in their own row"
assert "margin-inline-start" not in cluster, \
"margin-inline-start: auto pushes the cluster back to the right edge of its own row"
dots = _declarations(css, "#nav-status")
assert dots.get("flex-basis") == "100%", \
"the status indicators no longer get a row to themselves"
assert dots.get("justify-content") == "flex-start", \
"the indicators are no longer left-justified"
def test_the_mobile_masthead_block_declares_no_second_bar_height(client) -> None:
"""One `min-height` in the file, and the safe-area padding still on the phone bar.
The ≤640px block used to own `height: auto` + `min-height` because the base rule was a
fixed `--header-h`; task 01 moved the growth to the base rule, and a mobile restatement is
exactly the pair that drifts (one says `auto`, the other a number, and the bar clips on a
phone only). The safe-area line and the wrapping cluster are the phase-18 contract and stay.
"""
css = _css(client)
nav_css = _code(_css_section(css, "mobile nav"))
block = re.search(r"\.masthead\s*\{([^}]*)\}", nav_css)
assert block, "the ≤640px block no longer styles the masthead at all"
for prop in ("height", "min-height", "flex-wrap"):
assert not re.search(rf"(^|;)\s*{prop}\s*:", block.group(1)), \
f"the ≤640px masthead re-declares `{prop}` — two rules now decide the bar's height"
assert "row-gap: .4rem" in block.group(1), "the phone lost its tighter row gap"
assert "padding-top: calc(.5rem + env(safe-area-inset-top, 0px))" in block.group(1), \
"the phone bar stopped padding for the system top-edge zone the toggle sits in"
# The dots stay one line and the cluster still wraps rather than hiding a control.
assert "#nav-status { flex-wrap: nowrap; }" in nav_css
assert re.search(r"\.masthead-right\s*\{[^}]*flex-wrap: wrap", nav_css)
# ---- the masthead that scrolls away (phase 20 task 02) ---------------------- #
#
# `TODO.md:10` — "The navbar is only sticky part way down certain pages on mobile? It shouldn't
# be sticky at all. We want to maximize the amount of screen space for data viewing." The quirk
# and the footprint had the same cause, so both went with the stick. What has to stay is the
# bar's second job: it is the containing block the ≤640px dropdown's `top: 100%` resolves
# against, which is why the declaration is `relative` and not the default `static`. These pins
# read the **served** stylesheet, so a re-stick or a `static` regression fails here rather than
# in a phone's hands. `data/logs/20-task02-unsticky.cjs` is the browser half: scrolled bars at
# 1280 px and 360 px, and the menu still landing under the bar.
def test_the_masthead_scrolls_away_and_stays_the_dropdown_anchor(client) -> None:
"""Not `sticky`, not `static` either: a relative bar scrolls and still anchors the menu.
Every assertion is one half of that trade. `sticky` (with its `top: 0`) is the pinning the
task removes and a `top` on a relative bar would shift the whole masthead down instead;
`position: relative` is the positioned ancestor the dropdown needs; the two `z-index`es are
the stacking sentence "the menu paints one above the bar", which survives the un-stick.
"""
css = _css(client)
bar = _declarations(css, ".masthead")
assert bar.get("position") == "relative", (
"the masthead stopped being the positioned ancestor the ≤640px dropdown resolves "
"against — `sticky` pins the bar to the viewport, `static` moves the menu to the top "
"of the document")
assert "sticky" not in bar.values(), \
".masthead is sticky again: the bar must scroll away for data viewing (TODO.md:10)"
assert "top" not in bar, \
"`top` belongs to a sticky bar; on a relative one it offsets the whole masthead"
assert bar.get("z-index") == "40", \
".masthead lost its z-index, so the dropdown's 41 no longer means one above the bar"
# The mobile half of the contract: the menu still opens directly under the bar and still
# paints above the content it overlaps — with the bar itself now free to scroll. Read as a
# cascade: task 03 moved the box onto the closed rule so the slide has something to animate,
# and `.is-open` is the visibility flip on top of it.
nav_css = _code(_css_section(css, "mobile nav"))
dropdown = _cascade(nav_css, "nav.tabs", "nav.tabs.is-open")
assert dropdown.get("position") == "absolute" and dropdown.get("top") == "100%", \
"the ≤640px menu no longer opens directly under the masthead"
assert int(dropdown["z-index"]) > int(bar["z-index"]), \
"the dropdown no longer stacks above the masthead it hangs from"
# ---- the menu that slides (phase 20 task 03) -------------------------------- #
#
# `TODO.md:11` — "I want a nice animation for the hamburger menu - a slide out and slide back in
# would be perfect. See ~/Project/Personal/brain-of-reese for an example." (The project is
# `brain_of_reese`; the pattern is its `.app-nav` closed/open transition pair plus the phase-46
# reduced-motion block.) It is pure CSS over the class flip `setNavMenu()` already performs,
# which is why `app.js` and its four close paths are untouched. What that pattern *does* force
# is a re-cut of the two rules: a `display: none` box has no intermediate state, so the closed
# menu is `visibility: hidden` now and carries the dropdown geometry the transition animates,
# while `.is-open` carries only the visible counterpart. Three questions are pinned here, in the
# order that makes the pair readable: does the closed state name the invisible, non-interactive
# start of the slide; does the open state name its visible end with the `visibility` delay
# dropped; and does `prefers-reduced-motion` name **both** states, because an open rule left out
# of that block keeps animating. `data/logs/20-task03-slide.cjs` is the browser half: the
# computed closed state, a mid-transition frame, the four close paths, the no-phantom-scroll
# check, and the snap under `prefers-reduced-motion: reduce`.
#: The one duration the slide uses, quoted into both states so a pin can demand they agree.
NAV_SLIDE_MS = "180ms ease"
def test_the_closed_phone_menu_is_the_start_of_the_slide(client) -> None:
"""Invisible, 8 px high, unhittable — and a box that exists, so it can animate.
Every declaration is one half of the closed state the reference pattern needs. `display:
none` is the one thing the block must **not** contain: it removes the box, and a transition
on a box that is not rendered never runs — which is exactly why the menu used to snap, and
why the last assertion is a regression guard and not a style preference.
"""
nav_css = _code(_css_section(_css(client), "mobile nav"))
closed = _declarations(nav_css, "nav.tabs")
assert closed.get("visibility") == "hidden", \
"the closed menu is not hidden by a property that can animate"
assert closed.get("opacity") == "0", "the slide lost its fade: the menu opens at full ink"
assert closed.get("transform") == "translateY(-8px)", \
"the slide lost its 8 px rise (`TODO.md:11`): the menu appears in place"
assert closed.get("pointer-events") == "none", \
"an invisible menu that takes clicks steals taps from the content under it"
assert closed.get("transition") == (
f"opacity {NAV_SLIDE_MS}, transform {NAV_SLIDE_MS}, visibility 0s linear 180ms"), (
"the closed state's transition is what runs on the way OUT; `visibility` must be "
f"delayed by exactly the {NAV_SLIDE_MS} slide or the box vanishes before it fades")
assert "display: none" not in nav_css, (
"a `display: none` in the ≤640px nav block is a box that cannot animate — the closed "
"menu became `visibility: hidden` in phase 20 task 03 for exactly this reason")
def test_the_open_phone_menu_is_the_visible_end_of_the_slide(client) -> None:
"""`transform: none`, and no `visibility` delay — or the menu is invisible for its own 180 ms.
`.is-open` out-ranks `nav.tabs` (two classes to one), so these four declarations are what a
click on `#nav-toggle` actually applies, and the transition line is restated rather than
inherited: the open state needs the same two animated properties with the delay gone.
"""
nav_css = _code(_css_section(_css(client), "mobile nav"))
open_state = _declarations(nav_css, "nav.tabs.is-open")
assert open_state.get("visibility") == "visible", "`.is-open` no longer reveals the menu"
assert open_state.get("opacity") == "1", "the open menu is still half-faded"
assert open_state.get("transform") == "none", \
"the open menu never came down the 8 px, so the slide has no end"
assert open_state.get("pointer-events") == "auto", \
"the open menu's five links would not take a tap"
assert open_state.get("transition") == (
f"opacity {NAV_SLIDE_MS}, transform {NAV_SLIDE_MS}, visibility 0s"), (
"the open state must animate the same two properties with the `visibility` delay "
"dropped — the same duration, in the other direction")
def test_the_menu_snaps_in_both_directions_under_reduced_motion(client) -> None:
"""One reduced-motion block, and it names **both** menu states.
The reference project's phase-46 comment is the reason: `.nav.tabs.is-open` out-specifies
`.nav.tabs`, so stilling only the closed state leaves the open transition running and the
menu still animates for someone who asked not to. The block is the file's only one, merged
with the pre-existing universal rule rather than a second block that would have to be kept
in step with it.
"""
code = _code(_css(client))
blocks = re.findall(r"@media\s*\(prefers-reduced-motion:\s*reduce\)", code)
assert len(blocks) == 1, (
f"dash.css carries {len(blocks)} reduced-motion blocks; the menu's rules belong in the "
"one universal rule, or the two drift")
block = re.search(r"@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{(.*?)\n\}", code, re.S)
assert block, "the reduced-motion block no longer has a body to read"
selector = re.search(r"(nav\.tabs[^{]*)\{", block.group(1))
assert selector, "the reduced-motion block stopped naming the menu at all"
named = {s.strip() for s in selector.group(1).split(",")}
assert named == {"nav.tabs", "nav.tabs.is-open"}, (
"the reduced-motion block must name both menu states: naming only the closed one "
f"leaves the open transition animating — got {sorted(named)}")
assert _declarations(block.group(1), "nav.tabs.is-open").get("transition") == "none", \
"the named open rule stopped stilling the transition"
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`.