phase: 25_masthead_control_placement

**Phase 25 final verification pass** (all 3 tasks were already in `complete/`, so this was the phase-level verify + docs pass)

**Verified**
- `#nav-status` first child of `.masthead-right` on all 7 signed-in templates (incl. `/certs`, `/settings`); `app.js`/`charts.js` untouched
- `#nav-status` + `.masthead-right` both `justify-content: flex-end`, `margin-inline-start` still absent, row-break (`flex-wrap` + two `flex-basis: 100%`), non-sticky bar, dropdown slide + 4 close paths, `setInterval(refreshStatus, 15000)` all intact (dash.css diff = comments + the two alignment values)
- Clock is the last control on all 7 templates; no `order:` anywhere in the bar

**Fixed (defects found in this pass)**
- `tests/test_api.py::test_every_page_links_to_both_admin_pages` — duplicated `tabs = html[...]` statement (task 03 edit artifact)
- `certs.html` / `settings.html` comments said "all **eight** signed-in pages"; the signed-in set is seven (`/login` is the eighth, anonymous, and has no links/clock)
- `AGENTS.md` — suite tally `1542 → 1543 passed`; "the three phase-20 tests" re-worded, since task 02 renamed one to `test_the_masthead_is_three_rows_right_justified_at_every_width`

**Results**
- `uv run pytest` → **1543 passed, 15 skipped**, 0 failed
- `bash .agents/validate.sh` → **exit 0**: coverage **98.94 %** (floor 94 %), `ruff` All checks passed, `pyright` 0 errors, `node --check` clean, hygiene clean
- `uv run pytest -m live --live` (`.env.dev` sourced) → **11 passed**; `./scripts/dev-server.sh status` → 9/9 sources `ok`
- Browser: `node data/logs/25-task03-verify.cjs` → **235 checks, 0 FAIL**, exit 0 (`data/logs/25-task03-verify.out`, 16 `masthead-25-*.png`)

**Completion criteria**: badges pinned+measured (green `system/disks/llm/ups` on both admin pages, 1280 px and 360 px) ✅ · both rows flush right, three rows + dropdown unchanged (pinned + measured) ✅ · clock farthest-right (pinned markup; measured right edge 1260.0 at 1280 px, ≤900 px hidden by the long-standing readout rule with source order asserted) ✅ · pytest/coverage/ruff/pyright ✅ · validate.sh ✅ · no behaviour change in completed work ✅

**Docs**: README reviewed end-to-end against the code (served-route audit vs documented routes, env table vs `config.py`, commands vs `scripts/`, public-path table, `web/` masthead bullet) — consistent, no changes needed beyond the phase's own edits. AGENTS.md — two fixes above; spot-checked every doc-named test, function, script, threshold, `install-agent.sh` sha256 (`ffbcd91f…` matches) and agent version (1.1.0).

**Deviations**: left the pre-existing `fastapi 0.153.0 → 0.159.0` advisory (validate prints it as advisory; no dependency work in scope, AGENTS rule 8 triggers on touching deps). No live-infra changes. Overview's "all eight signed-in pages" for the clock is verified against seven templates + `/login` (which has no clock, pinned).

**Next pending phase**: `.agents/phases/todo/26_ups_event_split/` (3 tasks).
This commit is contained in:
2026-10-21 16:42:14 -04:00
parent 558a83d0be
commit 2bf236fd92
31 changed files with 559 additions and 75 deletions
@@ -0,0 +1,25 @@
**Phase 25 final verification pass** (all 3 tasks were already in `complete/`, so this was the phase-level verify + docs pass)
**Verified**
- `#nav-status` first child of `.masthead-right` on all 7 signed-in templates (incl. `/certs`, `/settings`); `app.js`/`charts.js` untouched
- `#nav-status` + `.masthead-right` both `justify-content: flex-end`, `margin-inline-start` still absent, row-break (`flex-wrap` + two `flex-basis: 100%`), non-sticky bar, dropdown slide + 4 close paths, `setInterval(refreshStatus, 15000)` all intact (dash.css diff = comments + the two alignment values)
- Clock is the last control on all 7 templates; no `order:` anywhere in the bar
**Fixed (defects found in this pass)**
- `tests/test_api.py::test_every_page_links_to_both_admin_pages` — duplicated `tabs = html[...]` statement (task 03 edit artifact)
- `certs.html` / `settings.html` comments said "all **eight** signed-in pages"; the signed-in set is seven (`/login` is the eighth, anonymous, and has no links/clock)
- `AGENTS.md` — suite tally `1542 → 1543 passed`; "the three phase-20 tests" re-worded, since task 02 renamed one to `test_the_masthead_is_three_rows_right_justified_at_every_width`
**Results**
- `uv run pytest` → **1543 passed, 15 skipped**, 0 failed
- `bash .agents/validate.sh` → **exit 0**: coverage **98.94 %** (floor 94 %), `ruff` All checks passed, `pyright` 0 errors, `node --check` clean, hygiene clean
- `uv run pytest -m live --live` (`.env.dev` sourced) → **11 passed**; `./scripts/dev-server.sh status` → 9/9 sources `ok`
- Browser: `node data/logs/25-task03-verify.cjs` → **235 checks, 0 FAIL**, exit 0 (`data/logs/25-task03-verify.out`, 16 `masthead-25-*.png`)
**Completion criteria**: badges pinned+measured (green `system/disks/llm/ups` on both admin pages, 1280 px and 360 px) ✅ · both rows flush right, three rows + dropdown unchanged (pinned + measured) ✅ · clock farthest-right (pinned markup; measured right edge 1260.0 at 1280 px, ≤900 px hidden by the long-standing readout rule with source order asserted) ✅ · pytest/coverage/ruff/pyright ✅ · validate.sh ✅ · no behaviour change in completed work ✅
**Docs**: README reviewed end-to-end against the code (served-route audit vs documented routes, env table vs `config.py`, commands vs `scripts/`, public-path table, `web/` masthead bullet) — consistent, no changes needed beyond the phase's own edits. AGENTS.md — two fixes above; spot-checked every doc-named test, function, script, threshold, `install-agent.sh` sha256 (`ffbcd91f…` matches) and agent version (1.1.0).
**Deviations**: left the pre-existing `fastapi 0.153.0 → 0.159.0` advisory (validate prints it as advisory; no dependency work in scope, AGENTS rule 8 triggers on touching deps). No live-infra changes. Overview's "all eight signed-in pages" for the clock is verified against seven templates + `/login` (which has no clock, pinned).
**Next pending phase**: `.agents/phases/todo/26_ups_event_split/` (3 tasks).
@@ -0,0 +1,66 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 18%]
........................................................................ [ 23%]
..........................................................s............. [ 27%]
........................................................................ [ 32%]
........................................................................ [ 36%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 55%]
........................................................................ [ 60%]
........................................................................ [ 64%]
........................................................................ [ 69%]
.sssssssssss............................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 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 440 0 100%
src/dashboards/app.py 186 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 3495 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1543 passed, 15 skipped, 1 warning in 163.67s (0:02:43)
== 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.159.0 wheel
@@ -0,0 +1,25 @@
All green. Report:
**Implemented (phase 25 task 01)**
- `src/dashboards/web/pages/certs.html` + `settings.html`: added `<div id="nav-status" class="gap" aria-live="polite"></div>` as the first child of `.masthead-right` (same position as the five dashboards); deleted settings.html's "No `#nav-status`…" comment whose premise the operator overruled.
- `app.js`: **unchanged** — `initChrome()`'s `if (nav)` block builds the four badges from `/api/health` whenever the container exists.
- `tests/test_api.py`: flipped the two no-dots pins (`/settings`, `/certs`) to dots-present + re-worded docstrings/messages; added `test_every_signed_in_page_carries_the_fleet_badges` (one `MARKUP_PAGES` loop pinning the exact element as first child of `.masthead-right`, plus the `app.js` container guard, the four badge labels and the untouched 15 s refresh).
- Browser proof: `data/logs/25-task01-badges.cjs` (headless, `20-task01-rows.cjs` convention) → **ALL PASS (90 checks)**, transcript `data/logs/25-task01-badges.out`, screenshots `data/logs/25-task01-{overview,settings,certs}-badges.png` — badges populate `system=ok disks=ok llm=ok ups=ok` on `/certs` and `/settings`, identical to `/`, at 1280 px and 360 px; `/login` (unsigned) still has none.
- Dev server restarted; `./scripts/dev-server.sh status` = 9 sources `ok`.
**Tests / lint / coverage**
- `uv run pytest` → `1543 passed, 15 skipped` (was 1542+15; +1 new test), exit 0
- `uv run pytest --cov` → `TOTAL 3495 37 99%`, "Required test coverage of 94% reached. Total coverage: 98.94%" (no Python source lines added)
- `uv run ruff check src tests agent` → All checks passed · `uv run pyright` → 0 errors
- `bash .agents/validate.sh` → exit 0 (pytest+cov, ruff, pyright, `node --check`); only advisory: `fastapi 0.153.0 → 0.158.1` outdated — dependencies untouched by this task, so not bumped
**Docs**
- `AGENTS.md`: `/settings` "no status dots" → the four badges (with the TODO/`initChrome` reason); `/certs` "no `#nav-status`" → badges like every masthead; the three-rows paragraph now says every masthead page renders three rows and `/login` remains the only two-row page.
- `README.md`: status dots are on **every** signed-in page; the "a page with no indicators — `/login`, `/settings`, `/certs`" clause narrowed to `/login` only. Left-alignment wording deliberately left for task 02.
**Notes / deviations**
- Kept task 01 strictly placement-of-container: no CSS, no `app.js`, no order change (tasks 02/03 own right-justification and clock-rightmost). New pin asserts "first child", which survives task 03's clock move.
- Pre-existing working-tree change I did not make and left alone: `NOTES.md` is emptied (the two items consumed by phases 20/24).
- The interactive-browser hook fired but no visible session was needed — this phase's mandate is the repo's own headless `data/logs/*.cjs` verification, which ran.
**Next pending task:** `25_masthead_control_placement/02_right_justify_masthead_rows.md`
@@ -0,0 +1,66 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 18%]
........................................................................ [ 23%]
..........................................................s............. [ 27%]
........................................................................ [ 32%]
........................................................................ [ 36%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 55%]
........................................................................ [ 60%]
........................................................................ [ 64%]
........................................................................ [ 69%]
.sssssssssss............................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 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 440 0 100%
src/dashboards/app.py 186 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 3495 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1543 passed, 15 skipped, 1 warning in 161.44s (0:02:41)
== 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.158.1 wheel
@@ -0,0 +1,15 @@
**Implemented (phase 25 task 02)**
- `src/dashboards/web/static/dash.css`: `#nav-status` and `.masthead-right` `justify-content: flex-start` → `flex-end`; `margin-inline-start` still absent from the cluster; row-break (`flex-wrap` + both `flex-basis: 100%`), `min-height`, gaps and the whole ≤640px block (incl. `#nav-toggle { margin-inline-start: auto }`) untouched.
- Re-worded every comment that stated the old alignment: the header block, `Row 3:`, `Row 2:`, the mobile block intro, and the mobile "row alignment is the base rule's …" note.
- `tests/test_api.py`: renamed the pin to `test_the_masthead_is_three_rows_right_justified_at_every_width`, flipped only the two alignment assertions to `flex-end`, re-worded docstring/messages; all row-break/height/gap/no-`margin-inline-start` guards kept. The mobile-height and unsticky pins are unchanged and green.
- Measured half: `data/logs/25-task02-justify.cjs` (7 signed-in pages × 1280/360 px + unsigned `/login`) — both rows compute `flex-end` and sit flush with the bar's right padding edge (0.00 px gap), 4 badges per row, still three rows, dropdown still under the whole bar, no sideways scroll: **ALL CHECKS PASSED**; screenshots `data/logs/25-task02-{index,certs,settings}-{1280,360}.png`, `25-task02-login-1280.png`, transcript `25-task02-justify.out`.
**Tests / lint / coverage**
- `bash .agents/validate.sh` → EXIT=0: `1543 passed, 15 skipped`, **coverage 98.94 %** (floor 94 %), `ruff check src tests agent` All checks passed, `pyright` 0 errors, `node --check web/static/*.js` clean.
- `uv run pytest -m live --live` (with `.env.dev`) → **11 passed**; `./scripts/dev-server.sh status` → 9/9 sources `ok`, served `/static/dash.css` carries the two `flex-end` declarations.
**Docs**: `AGENTS.md` masthead paragraph now reads "three rows … the two control rows flush right", gained a phase-25-task-02 paragraph naming the renamed pin + the browser script, and gap 12's "three left-justified rows" wording fixed; `README.md`'s masthead sentence likewise. Clock-order sentences left for task 03.
**Notes/deviations**: no scope change (clock move not started). Pre-existing, untouched: `validate.sh` advisory `fastapi 0.153.0 → 0.158.1` (no dependency work in this task) and `NOTES.md` already emptied by earlier tasks in the working tree.
**Next pending task**: `.agents/phases/todo/25_masthead_control_placement/03_clock_rightmost.md`.
@@ -0,0 +1,66 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 18%]
........................................................................ [ 23%]
..........................................................s............. [ 27%]
........................................................................ [ 32%]
........................................................................ [ 36%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 55%]
........................................................................ [ 60%]
........................................................................ [ 64%]
........................................................................ [ 69%]
.sssssssssss............................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 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 440 0 100%
src/dashboards/app.py 186 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 3495 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1543 passed, 15 skipped, 1 warning in 162.55s (0:02:42)
== 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.158.1 wheel
@@ -0,0 +1,16 @@
**Implemented (phase 25 task 03)**
- All 7 templates (`index/system/disks/llm/ups/settings/certs.html`): `<span class="clock" id="clock">` moved from first control to **last** child of `.masthead-right`, so the row reads `⚷ Certs` → `⚙ Account` → `☾ Dark` → clock; the seven control-row blocks are byte-identical (md5-verified), `#nav-status` stays first child.
- `dash.css`: header block + `Row 3:` comment re-worded to the new order and to say the clock's place is markup, not a CSS `order:`. No declaration changed.
- `tests/test_api.py::test_every_page_links_to_both_admin_pages`: docstring order sentence updated, both `before_theme` assertions kept, added `html.index('id="clock"') > html.index('id="theme-toggle"')` per page, plus a guard that `.masthead`/`.masthead-right`/`#nav-status`/`.clock` carry **no** `order:` declaration (the task's ASSUMPTION, made enforceable). `/login` half untouched.
- `data/logs/25-task03-verify.cjs` + `.out` + 16 screenshots `data/logs/masthead-25-*.png`: 7 pages × 1280/360 px + `/login` unsigned at both — three rows, both right-hand rows flush with the bar's right padding edge (measured on the chips' content, not the full-basis row box, and explicitly *not* flush left), clock the rightmost painted thing on the bar, cluster source order, four populated badges on `/certs`/`/settings`, 360 px dropdown still under the whole bar, no sideways scroll.
**Tests / lint / coverage**
- `bash .agents/validate.sh` → **1543 passed, 15 skipped**, coverage **98.94 %** (floor 94 %), `ruff check src tests agent` All checks passed, `pyright` 0 errors, `node --check web/static/*.js` clean, hygiene clean (only the pre-existing advisory `fastapi 0.153.0 → 0.158.2`; no dependency work in this task).
- `uv run pytest -m live --live` (`.env.dev` sourced) → **11 passed** (first attempt had one transient `deskwork … /snapshot timed out`, clean on re-run); `./scripts/dev-server.sh status` → 9/9 sources `ok`.
- Browser pass → **ALL CHECKS PASSED** (235 checks, 0 failures).
**Docs**: `AGENTS.md` — control-order sentence in the `/certs` paragraph, the row-3 description, a new phase-25-task-03 paragraph (markup-vs-`order:` rationale, the pin, the ≤900 px clock note, the script/screenshots); `README.md` — the `web/` masthead bullet now reads `⚷ Certs` / `⚙ Account` / `☾ Dark` / clock with "the time is the farthest-right thing on the bar".
**Decisions/deviations**: the long-standing `@media (max-width: 900px){.clock{display:none}}` stays (task said no declaration changes; it's a readout, not a control), so the clock's pixel claim is measured at 1280 px and the 360 px leg asserts the clock is hidden *by that rule* and the source order still ends with it — if the operator wants the time on the phone too, that hide is the one line to drop. No live-infra changes; `TODO.md`/`NOTES.md` working-tree edits are pre-existing, not mine.
**Next pending task**: `.agents/phases/todo/26_ups_event_split/01_page_side_event_split.md` (this task file is the last of phase 25).
@@ -0,0 +1,66 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 18%]
........................................................................ [ 23%]
..........................................................s............. [ 27%]
........................................................................ [ 32%]
........................................................................ [ 36%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 55%]
........................................................................ [ 60%]
........................................................................ [ 64%]
........................................................................ [ 69%]
.sssssssssss............................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 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 440 0 100%
src/dashboards/app.py 186 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 3495 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1543 passed, 15 skipped, 1 warning in 163.74s (0:02:43)
== 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.159.0 wheel
+63 -19
View File
@@ -750,7 +750,10 @@ Responsibilities, one line each:
unsigned because a signed-in `GET /login` is a `303` and would audit `/`'s markup under the wrong unsigned because a signed-in `GET /login` is a `303` and would audit `/`'s markup under the wrong
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), the same four `#nav-status` badges as every dashboard (phase 25 task 01 —
`TODO.md:3`'s "hosts disks engines UPS disappear when in the certs/account page" was the
missing container, and `Dash.initChrome()` fills it from `/api/health` with no JS change), and
it is reached from the `⚙ Account` link in
`.masthead-right` of every dashboard **including itself** — outside `.tabs`, so the pinned tab `.masthead-right` of every dashboard **including itself** — outside `.tabs`, so the pinned tab
structure is untouched (`tests/test_api.py::test_every_page_links_to_both_admin_pages`). It prints 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
@@ -766,15 +769,17 @@ Responsibilities, one line each:
`csrf` is now invalid — every later write, the UPS buttons included, 403s until a reload) and on `csrf` is now invalid — every later write, the UPS buttons included, 403s until a reload) and on
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`, the four fleet
and both masthead links — `⚷ Certs` beside `⚙ Account` — exactly as the five dashboards render badges in `#nav-status` like every other masthead (phase 25 task 01, same reasoning as
them. **Both links are on both admin pages** (phase 20 task 04, `TODO.md:4`), which retires phase `/settings` above), and both masthead links — `⚷ Certs` beside `⚙ Account` — exactly as the five
15's "no self-link" precedent: chrome that changes shape depending on where you already are reads dashboards render them. **Both links are on both admin pages** (phase 20 task 04, `TODO.md:4`),
which retires phase 15's "no self-link" precedent: chrome that changes shape depending on where you already are reads
as a broken button, while a link to the page you are on is a no-op that answers with the page you 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 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 seven response, no redirect, the same cluster afterwards). The two links therefore sit on all seven
signed-in pages (the five dashboards plus `/settings` and `/certs`) in one order signed-in pages (the five dashboards plus `/settings` and `/certs`) in one order
(clock → `⚷ Certs` → `⚙ Account` → theme toggle), which is what lets (`⚷ Certs` → `⚙ Account` → theme toggle → clock — phase 25 task 03 moved the clock to the end so
it is the farthest-right item on a `flex-end` row), which is what lets
the pinned audit be one loop over `MARKUP_PAGES` instead of two over different tuples; `/login` the pinned audit be one loop over `MARKUP_PAGES` instead of two over different tuples; `/login`
is the eighth page and the one with neither. Everything on it is is the eighth page and the one 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
@@ -814,17 +819,21 @@ 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`): **The masthead is three rows at every width, the two control rows flush right** (phase 20 task
row 1 the brand, `#nav-toggle` and the five tabs; row 2 the `#nav-status` per-source dots; row 3 01 made the rows, `TODO.md:5-6`; phase 25 task 02 re-justified them, `TODO.md:4` — "I made a
the `.masthead-right` controls (clock, `⚷ Certs`, `⚙ Account`, theme — phase 21 task 01 deleted mistake earlier, I did want hosts disks engines UPS badges and certs/account/dark right
the `⏸ Pause` that used to sit between the clock and the two links, button, `Space` shortcut and justified"): row 1 the brand, `#nav-toggle` and the five tabs; row 2 the `#nav-status`
per-source dots; row 3
the `.masthead-right` controls (`⚷ Certs`, `⚙ Account`, theme, then the clock — phase 25 task 03
moved the clock last for `TODO.md:5`'s "the time should show up as the farthest right item";
phase 21 task 01 deleted the `⏸ Pause` that used to sit beside the links, button, `Space` shortcut and
`setPaused`/`_paused` plumbing alike: it froze the numbers on screen without growing a `stale` `setPaused`/`_paused` plumbing alike: it froze the numbers on screen without growing a `stale`
label, and a backgrounded tab already idles its polls, which is the only stop this app needs). label, and a backgrounded tab already idles its polls, which is the only stop this app needs).
That is CSS and That was CSS and
no markup change, which decides the mechanism: `#nav-status` is a **child** of 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` `.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 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 `justify-content: flex-end` (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 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 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. `margin-inline-start: auto` that pinned the cluster to the right edge of the one-row bar.
@@ -832,12 +841,46 @@ Responsibilities, one line each:
brand and the tabs on separate rows. `height: var(--header-h)` became **`min-height`** (three rows 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 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 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, would clip the bar on a phone only. Every page that renders the bar now renders three rows —
`/login` two (brand, then the theme toggle). Pinned on the served bytes by the three phase-20 `/certs` and `/settings` grew the dots row in phase 25 task 01, where `#nav-status` is pinned as
tests in `tests/test_api.py` (the three rules' declarations, the mobile block's silence about the **first child** of `.masthead-right` on all seven signed-in pages (`app.js` is untouched:
the bar's height, and the bar's `position`), and measured in a browser at 1280 px and 360 px by the container's existence is what makes `initChrome` build the four badges, which is why the fix
was markup and not JS) — and `/login` remains the one page with no dots, rendering two rows
(brand, then the theme toggle). Pinned on the served bytes by phase 20's three tests in
`tests/test_api.py` — the row declarations (task 02 renamed that one to
`test_the_masthead_is_three_rows_right_justified_at_every_width`), 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/20-task01-rows.cjs` (seven pages × two widths plus the dropdown; screenshots
`data/logs/masthead-rows*.png`). `data/logs/masthead-rows*.png`). **Phase 25 task 02 moved the alignment of those two rows to
the other edge** (`TODO.md:4` — "I made a mistake earlier, I did want hosts disks engines UPS
badges and certs/account/dark right justified"): the two `justify-content` declarations are
`flex-end`, `margin-inline-start` is still absent from the cluster (a full-basis row aligned by
`flex-end` is the mechanism, not the old single-row right-pinning), and the ≤640px block still
restates neither the alignment nor the bar's height, so the phone's wrapped control row follows
the base rule. The pin is `tests/test_api.py::test_the_masthead_is_three_rows_right_justified_at_every_width`
(the row-break assertions are the same ones phase 20 wrote, deliberately: the re-justify must
not cost the rows), and the measured half is `data/logs/25-task02-justify.cjs` — seven pages ×
two widths, each demanding both rows compute `flex-end` and sit flush with the bar's right
padding edge, the four badges present, three rows intact, the dropdown still under the whole
bar and no sideways scroll, plus `/login`'s two-row bar with no badges
(`data/logs/25-task02-justify.out`, screenshots `data/logs/25-task02-*.png`). **Phase 25 task 03
then put the clock at the end of the control row** (`TODO.md:5` — "The time should show up as the
farthest right item on the navbar"): the `<span class="clock" id="clock">` moved from the first
control to the last one on all seven templates, so the row reads `⚷ Certs` → `⚙ Account` → theme
toggle → clock. It is a **markup** move and deliberately not a CSS `order:` declaration, because
on a `flex-end` row the last child *is* the farthest-right item and an `order:` override would be
something the served-byte pin cannot see — `test_every_page_links_to_both_admin_pages` therefore
asserts `html.index('id="clock"') > html.index('id="theme-toggle"')` on every page (the two
Certs/Account-before-theme assertions stay, and the `/login` half of the test is untouched).
The clock keeps its long-standing `@media (max-width: 900px) { .clock { display: none } }` hide:
it is a readout and not a control, so this is not the "no control is hidden to make the width
work" rule the masthead keeps. Measured by `data/logs/25-task03-verify.cjs` — seven pages × two
widths, each demanding three rows, both right-hand rows flush with the bar's right padding edge
and the chips' content (not the full-basis row box) measured to prove it is not flush left, the
clock the rightmost painted thing on the bar at 1280 px, the cluster's source order unchanged at
360 px where the clock is hidden, and the four badges populated on `/certs` and `/settings` as on
the dashboards; plus the 360 px dropdown and `/login` unsigned at both widths
(`data/logs/25-task03-verify.out`, screenshots `data/logs/masthead-25-*.png`).
**The bar is not sticky** (phase 20 task 02, `TODO.md:10` — "It shouldn't be sticky at all. We **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 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 **`position: relative`** with no offset, so the whole masthead scrolls away with the page and the
@@ -1186,7 +1229,7 @@ uv run python scripts/dev-certs-export.py --url http://127.0.0.1:8090 \
# GET /api/certs/<name>/bundle and writes # GET /api/certs/<name>/bundle and writes
# data/certs/<name>.bundle at 0600 — mints nothing # data/certs/<name>.bundle at 0600 — mints nothing
uv run pytest # hermetic: 1542 passed + 15 skipped (11 live, uv run pytest # hermetic: 1543 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
@@ -2362,7 +2405,8 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
shape applied to one table and does not reach this one; `27_mobile_tables_and_audit` is that shape applied to one table and does not reach this one; `27_mobile_tables_and_audit` is that
pass, still in `todo/`. Of the two operator TODO items that sat pass, still in `todo/`. Of the two operator TODO items that sat
beside it, `20_masthead_nav_polish` has landed in full (now complete): the bar is three beside it, `20_masthead_nav_polish` has landed in full (now complete): the bar is three
left-justified rows at every width, it is no longer sticky, the phone menu slides, and both rows at every width (flush right on the two control rows since phase 25 task 02), it is no
longer sticky, the phone menu slides, and both
`⚙ Account` and `⚷ Certs` render on all seven signed-in pages including the two they name `⚙ Account` and `⚷ Certs` render on all seven 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, (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) is closed: its fleet half landed on `24_dev_mtls_rollout` (never accepting a non-mTLS agent) is closed: its fleet half landed on
+11 -6
View File
@@ -751,13 +751,18 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
that row count, so `/` and `/disks` can never describe two different fleets. that row count, so `/` and `/disks` can never describe two different fleets.
* `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 on **every** signed-in
page — the two admin pages included (`disks` has its own:
the same agent can answer the 10-second utilisation poll and have a wedged SMART pass). the same agent can answer the 10-second utilisation poll and have a wedged SMART pass).
The masthead is **three left-justified rows at every width**: the brand and the five dashboard The masthead is **three rows at every width, the two control rows flush right**: the brand and the five dashboard
tabs, then the per-source indicators on their own row, then the clock / `⚷ Certs` / `⚙ Account` tabs, then the per-source indicators on their own row, then the `⚷ Certs` / `⚙ Account` /
/ `☾ Dark` controls on a row under those (a page with no `☾ Dark` / clock controls on a row under those, in that order, so the **time is the farthest-right
indicators — `/login`, `/settings`, `/certs` — renders two, and a control is never hidden to make a thing on the bar** (phase 25: the badges and the cluster were re-justified right, then the clock
narrower width work). There is no control that stops the polling (phase 21): the bar used to carry moved to the end of the markup — `flex-end` makes the last child the rightmost item, and it stays
a markup fact rather than a CSS `order:` so the pin can read it; the clock is hidden below 900 px
as it has always been, being a readout and not a control). Only `/login` renders no
indicators, and so two rows, and a control is never hidden to make a
narrower width work. There is no control that stops the polling (phase 21): the bar used to carry
a `⏸ Pause` button with a `Space` shortcut, and it left the numbers on screen looking current with a `⏸ Pause` button with a `Space` shortcut, and it left the numbers on screen looking current with
no `stale` label on them. Both admin links are on all seven no `stale` label on them. Both admin links are on all seven
signed-in pages — the five dashboards plus `/settings` and `/certs`, the two they name included — signed-in pages — the five dashboards plus `/settings` and `/certs`, the two they name included —
+3 -2
View File
@@ -14,7 +14,7 @@
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 both admin links — `⚷ Certs` and `⚙ Account` — outside `.tabs`, so the tab carries both admin links — `⚷ Certs` and `⚙ Account` — outside `.tabs`, so the tab
structure stays five links deep, and it carries them **here too** (`TODO.md:4`): the 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 controls are the same on all seven 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 a no-op the operator can press without being surprised, which beats chrome that
disappears when it is most being used. --> 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"
@@ -27,10 +27,11 @@
<a href="/ups">UPS</a> <a href="/ups">UPS</a>
</nav> </nav>
<div class="masthead-right"> <div class="masthead-right">
<span class="clock" id="clock">--:--:--</span> <div id="nav-status" class="gap" aria-live="polite"></div>
<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> <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>
<span class="clock" id="clock">--:--:--</span>
</div> </div>
</header> </header>
+1 -1
View File
@@ -21,10 +21,10 @@
</nav> </nav>
<div class="masthead-right"> <div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div> <div id="nav-status" class="gap" aria-live="polite"></div>
<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> <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>
<span class="clock" id="clock">--:--:--</span>
</div> </div>
</header> </header>
+1 -1
View File
@@ -21,10 +21,10 @@
</nav> </nav>
<div class="masthead-right"> <div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div> <div id="nav-status" class="gap" aria-live="polite"></div>
<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> <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>
<span class="clock" id="clock">--:--:--</span>
</div> </div>
</header> </header>
+1 -1
View File
@@ -21,10 +21,10 @@
</nav> </nav>
<div class="masthead-right"> <div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div> <div id="nav-status" class="gap" aria-live="polite"></div>
<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> <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>
<span class="clock" id="clock">--:--:--</span>
</div> </div>
</header> </header>
+3 -4
View File
@@ -13,7 +13,7 @@
<!-- 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 — this one 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, included (`TODO.md:4`): the two admin links are the same on all seven signed-in pages,
and chrome that vanishes on the page it names is the thing the operator filed. --> 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>
@@ -25,12 +25,11 @@
<a href="/ups">UPS</a> <a href="/ups">UPS</a>
</nav> </nav>
<div class="masthead-right"> <div class="masthead-right">
<!-- No `#nav-status`: a session page has no fleet reachability to report, and <div id="nav-status" class="gap" aria-live="polite"></div>
`Dash.initChrome()` builds the dots only when the container exists. -->
<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> <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>
<span class="clock" id="clock">--:--:--</span>
</div> </div>
</header> </header>
+1 -1
View File
@@ -21,10 +21,10 @@
</nav> </nav>
<div class="masthead-right"> <div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div> <div id="nav-status" class="gap" aria-live="polite"></div>
<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> <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>
<span class="clock" id="clock">--:--:--</span>
</div> </div>
</header> </header>
+1 -1
View File
@@ -21,10 +21,10 @@
</nav> </nav>
<div class="masthead-right"> <div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div> <div id="nav-status" class="gap" aria-live="polite"></div>
<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> <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>
<span class="clock" id="clock">--:--:--</span>
</div> </div>
</header> </header>
+31 -22
View File
@@ -96,11 +96,16 @@ 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 masthead is **three rows at every width, the two control rows flush right** (phase 20
* — "the hosts disks engines and UPS indicators … left justified in their own row", "the * task 01 made the rows, `TODO.md:5-6` — "the hosts disks engines and UPS indicators … in
* ⚷ Certs ⚙ Account ☾ Dark buttons … in their own row under" them): row 1 the brand, * their own row", "the ⚷ Certs ⚙ Account ☾ Dark buttons … in their own row under" them; phase
* 25 task 02 re-justified them right, `TODO.md:4` — "I made a mistake earlier, I did want
* hosts disks engines UPS badges and certs/account/dark right justified"): row 1 the brand,
* `#nav-toggle` and the five tabs; row 2 the `#nav-status` reachability dots; row 3 the * `#nav-toggle` and the five tabs; row 2 the `#nav-status` reachability dots; row 3 the
* `.masthead-right` controls (clock, `⚷ Certs`, `⚙ Account`, theme). * `.masthead-right` controls — `⚷ Certs`, `⚙ Account`, the theme toggle, then the clock, in
* that markup order because on a `flex-end` row the **last** child is the farthest-right item
* (phase 25 task 03, `TODO.md:5` — "The time should show up as the farthest right item on the
* navbar"; nothing is reordered in CSS, so the pin can read the markup).
* *
* No markup changed to get them, and that decides the mechanism. `#nav-status` is the first * 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` * child of `.masthead-right`, so there is no wrapper to promote: in a `flex-wrap: wrap`
@@ -169,31 +174,34 @@ 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); }
/* Row 3: the controls, left-justified under the indicators. `margin-inline-start: auto` is /* Row 3: the controls, flush right under the indicators. `margin-inline-start: auto` is what
* what pinned this cluster to the right edge of the old single-row bar; in a row of its own it * pinned this cluster to the right edge of the old single-row bar and stays **absent**: on a
* pushes against nothing, and `justify-content: flex-start` is the TODO's word for where the * row of its own it pushes against nothing, so the alignment is `justify-content: flex-end`
* buttons go. `flex-basis: 100%` is the row break (see `.masthead` above) and `flex-wrap` is * (phase 25 task 02 — the operator overruled phase 20's `flex-start`; the badges and the
* what a phone gets when clock + two links + theme will not share a line — phase 18's * cluster are right-justified). `flex-basis: 100%` is the row break (see `.masthead` above)
* rule, and no control is ever hidden to make the arithmetic work. */ * and `flex-wrap` is what a phone gets when `⚷ Certs` + `⚙ Account` + theme will not share a line
* (the clock is the last child and hides below 900px on its own — a readout, not a control, so
* that is not the "no control is hidden to fit the width" rule) — phase 18's rule, and nothing in
* this block hides a button to make the arithmetic work. */
.masthead-right { .masthead-right {
flex-basis: 100%; flex-basis: 100%;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-end;
gap: .35rem .55rem; gap: .35rem .55rem;
} }
/* Row 2: the per-source indicators, left-justified on a line of their own, so a readout is /* Row 2: the per-source indicators, flush right on a line of their own, so a readout lines up
* never mistaken for one more button beside it. `.gap` already makes this a flex row; the * with the controls under it and is never mistaken for one more button beside them. `.gap`
* `flex-basis` is the break inside `.masthead-right`, `justify-content` the alignment, and the * already makes this a flex row; the `flex-basis` is the break inside `.masthead-right`,
* gap is restated at .55rem so the dots read as a row of their own rather than as the tail of * `justify-content` the alignment, and the gap is restated at .55rem so the dots read as a row
* the cluster above. */ * of their own rather than as the tail of the cluster below. */
#nav-status { #nav-status {
flex-basis: 100%; flex-basis: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-end;
gap: .55rem; gap: .55rem;
} }
@@ -247,9 +255,10 @@ button.ghost:disabled { opacity: .45; cursor: not-allowed; }
* and since task 03, it owns the slide too: `nav.tabs` is the closed menu and `.is-open` its * 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. * visible counterpart, with a 180 ms `opacity`/`transform` transition on both.
* *
* Above 640px nothing here applies, which is the point: the same three left-justified rows — * Above 640px nothing here applies, which is the point: the same three rows — brand + toggle,
* brand + toggle, indicators, controls — with the tabs inline on the first one and the button * indicators, controls, the last two flush right — with the tabs inline on the first one and
* hidden. What a phone changes is only that the tabs become a menu and the gaps tighten. * 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) {
/* The bar's vertical rhythm, and nothing structural. The three rows come from the base /* The bar's vertical rhythm, and nothing structural. The three rows come from the base
@@ -284,8 +293,8 @@ button.ghost:disabled { opacity: .45; cursor: not-allowed; }
* still wraps *within itself*: `⚷ Certs`, `⚙ Account` and the theme button do not fit in * still wraps *within itself*: `⚷ Certs`, `⚙ Account` and the theme button do not fit in
* 320px once over on the narrowest phones, and nothing here hides a control to make * 320px once over on the narrowest phones, and nothing here hides a control to make
* the arithmetic work — a phone gets the same words every other width does. The row * the arithmetic work — a phone gets the same words every other width does. The row
* alignment is the base rule's `flex-start`, restated nowhere: phase 20 replaced the * alignment is the base rule's `flex-end` (phase 25 task 02), restated nowhere: this block
* right-justified cluster, so this block only tightens the gap. */ * only tightens the gap, so the two rows cannot disagree about which edge they sit on. */
.masthead-right { flex-wrap: wrap; gap: .4rem; } .masthead-right { flex-wrap: wrap; 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; }
+94 -17
View File
@@ -1046,7 +1046,10 @@ def test_the_settings_page_is_not_one_of_the_dashboards(client) -> None:
assert 'aria-current="page"' not in html assert 'aria-current="page"' not in html
for path in PAGES: for path in PAGES:
assert f'href="{path}"' in html, f"/settings cannot navigate to {path}" assert f'href="{path}"' in html, f"/settings cannot navigate to {path}"
assert 'id="nav-status"' not in html, "/settings grew fleet dots it has nothing to fill" # The fleet badges belong here too (`TODO.md:3`): the container is what made them appear on
# the five dashboards, and a page that renders the masthead without it is the page where they
# "disappear". `Dash.initChrome()` fills it from `/api/health` exactly as it fills the others.
assert 'id="nav-status"' in html, "/settings lost the fleet badges the operator asked for"
def test_the_settings_page_refuses_a_caller_with_no_session(tmp_path: Path) -> None: def test_the_settings_page_refuses_a_caller_with_no_session(tmp_path: Path) -> None:
@@ -1075,10 +1078,9 @@ def test_the_settings_page_refuses_a_caller_with_no_session(tmp_path: Path) -> N
def test_every_page_links_to_both_admin_pages(client, tmp_path: Path) -> 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.
One loop over all seven signed-in pages (`MARKUP_PAGES`), because the controls are now the *same* One loop over all seven signed-in pages (`MARKUP_PAGES`), because the control row is the *same*
on all of on all of them: `⚷ Certs` → `⚙ Account` → theme toggle → clock, in `.masthead-right` and
them: clock → `⚷ Certs` → `⚙ Account` → theme toggle, in `.masthead-right` and **outside** **outside** `.tabs` (the tab strip's structure — five dashboards, one of them current — is pinned by
`.tabs` (the tab strip's structure — five dashboards, one of them current — is pinned by
`test_the_disks_tab_is_in_the_masthead_of_every_page`, and a sixth entry with no `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). `aria-current` would break what that marker means).
@@ -1087,6 +1089,15 @@ def test_every_page_links_to_both_admin_pages(client, tmp_path: Path) -> None:
button to disappear … These should remain visible"): masthead chrome that changes shape 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 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. are on is a no-op that answers with the page you were looking at.
The clock last is phase 25 task 03 (`TODO.md:5` — "The time should show up as the farthest
right item on the navbar"). It is pinned as **markup**, not as a CSS `order` property, because
that is what makes the claim true: task 02 left the control row `justify-content: flex-end`, so
the last child of `.masthead-right` *is* the farthest-right item, and an `order:` override the
pin could not see would leave this test green over a bar that says something else. (Below 900 px
the clock is hidden by the long-standing `.clock` media rule — a readout, not a control, so
hiding it is not the "no control is hidden to fit the width" rule this app keeps — and the
markup order this test reads is width-independent.)
""" """
account_open = '<a class="ghost account" href="/settings"' account_open = '<a class="ghost account" href="/settings"'
certs_open = '<a class="ghost account" href="/certs"' certs_open = '<a class="ghost account" href="/certs"'
@@ -1096,6 +1107,14 @@ def test_every_page_links_to_both_admin_pages(client, tmp_path: Path) -> None:
account = f'{account_open} title="Session and password">⚙ Account</a>' account = f'{account_open} title="Session and password">⚙ Account</a>'
certs_link = f'{certs_open} title="Agent certificates">⚷ Certs</a>' certs_link = f'{certs_open} title="Agent certificates">⚷ Certs</a>'
theme = '<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">' theme = '<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">'
# The other half of the clock claim, from the stylesheet side: no `order:` on anything in the
# bar, because a re-ordering property would leave this markup audit describing a bar that
# paints in some other order (`data/logs/25-task03-verify.cjs` measures the painted order at
# both widths; this is the served-byte guard under that measurement).
css = _css(client)
for selector in (".masthead", ".masthead-right", "#nav-status", ".clock"):
assert "order" not in _declarations(css, selector), \
f"{selector} grew an `order:` declaration — the clock's place in the row is markup"
for path in MARKUP_PAGES: 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"
@@ -1105,6 +1124,10 @@ def test_every_page_links_to_both_admin_pages(client, tmp_path: Path) -> None:
assert account in before_theme, f"{path} put the Account 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), \ assert before_theme.index(certs_link) < before_theme.index(account), \
f"{path} orders the two admin controls differently from the dashboards" f"{path} orders the two admin controls differently from the dashboards"
# …and the clock after the toggle, i.e. last in `.masthead-right`: on the `flex-end`
# control row that is the farthest-right item on the bar (TODO.md:5).
assert html.index('id="clock"') > html.index('id="theme-toggle"'), \
f"{path} does not end its control row with the clock"
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"
assert "/settings" not in tabs, f"{path} made Account a dashboard tab" assert "/settings" not in tabs, f"{path} made Account a dashboard tab"
@@ -1134,8 +1157,9 @@ def test_every_page_links_to_both_admin_pages(client, tmp_path: Path) -> None:
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:
"""`/certs` borrows the dashboard chrome and none of the dashboard meaning (task 15.05). """`/certs` borrows the dashboard chrome and none of the dashboard meaning (task 15.05).
Five tabs with nothing current, no `#nav-status` (a CA page has no fleet reachability to Five tabs with nothing current, the four fleet badges like every other masthead (phase 25:
report), and the same head and stylesheet as every other page — this is the the badges report the fleet, and that report is the point of the site on this page too), and
the same head and stylesheet as every other page — this is the
`test_the_settings_page_is_not_one_of_the_dashboards` assertion for the second admin page, `test_the_settings_page_is_not_one_of_the_dashboards` assertion for the second admin page,
and it is what a sixth tab in the strip would fail on. and it is what a sixth tab in the strip would fail on.
""" """
@@ -1143,7 +1167,7 @@ def test_the_certs_page_is_a_page_and_not_a_sixth_dashboard(client) -> None:
assert html.count('aria-current="page"') == 0 assert html.count('aria-current="page"') == 0
tabs = re.findall(r'<a href="([^"]+)"[^>]*aria-current="page"', html) tabs = re.findall(r'<a href="([^"]+)"[^>]*aria-current="page"', html)
assert tabs == [], f"/certs marked a tab current: {tabs}" assert tabs == [], f"/certs marked a tab current: {tabs}"
assert 'id="nav-status"' not in html, "/certs grew fleet dots it has nothing to fill" assert 'id="nav-status"' in html, "/certs lost the fleet badges the operator asked for"
for path in PAGES: for path in PAGES:
assert f'href="{path}"' in html, f"/certs cannot navigate to {path}" assert f'href="{path}"' in html, f"/certs cannot navigate to {path}"
assert '/static/dash.css' in html assert '/static/dash.css' in html
@@ -4619,6 +4643,57 @@ def test_the_login_page_carries_no_nav_and_no_toggle(tmp_path: Path) -> None:
"/login stopped loading the shared chrome whose guard is its no-toggle contract") "/login stopped loading the shared chrome whose guard is its no-toggle contract")
# ---- the fleet badges on every masthead (phase 25 task 01) ------------------ #
#
# `TODO.md:3-4` — "hosts disks engines UPS disappear when in the certs/account page" and the
# operator's correction: "I did want hosts disks engines UPS badges". `Dash.initChrome()`
# builds the four badges whenever `#nav-status` exists, so the **container** is the whole
# difference between a page that has them and a page that does not — the two admin pages shipped
# without one, which is why they read as the badges vanishing rather than as two odd pages. The
# fix is the container and nothing else, so the pin is the container: on every page that renders
# the masthead, in the position the five dashboards already used (first child of
# `.masthead-right`, which is what puts it on its own row — task 02 re-justifies that row and this
# pin deliberately says nothing about which way the badges face). `tests/test_auth_api.py` keeps
# its own assertion that `/login` has none: a stranger has no fleet to be told about.
#: The container exactly as all seven signed-in pages write it (`index.html` is the reference).
NAV_STATUS_ELEMENT = '<div id="nav-status" class="gap" aria-live="polite"></div>'
#: The four badges `initChrome()` renders, in its own order.
NAV_STATUS_BADGES = ("hosts", "disks", "engines", "UPS")
def test_every_signed_in_page_carries_the_fleet_badges(client) -> None:
"""`#nav-status` is the first child of `.masthead-right` on all seven signed-in pages.
One loop over `MARKUP_PAGES`, because the control is the same on all of them: the five
dashboards had it, `/certs` and `/settings` grew it. The `app.js` half of the assertion is
what makes the markup mean something — the badges are built from the container's existence,
so a script that stopped reading `#nav-status` would turn seven `<div>`s into dead markup and
the orphan-`id` audit alone would not say so (it also matches `#nav-status` inside the CSS).
"""
js = _code(client.get("/static/app.js").text)
assert "const nav = $('#nav-status');" in js, \
"initChrome() no longer builds the badges from #nav-status, so the container is inert"
assert "if (nav) {" in js, \
"the container guard is gone — a page without #nav-status (only /login) would throw"
for label in NAV_STATUS_BADGES:
assert f"label: '{label}'" in js, f"the badge table lost {label}"
assert "setInterval(refreshStatus, 15000)" in js, \
"the badges' refresh cadence moved — this phase changes placement, not polling"
for path in MARKUP_PAGES:
header = _masthead(client.get(path).text)
assert NAV_STATUS_ELEMENT in header, \
f"{path} has no fleet badges in its masthead (TODO.md:3)"
after_cluster = header[header.index('<div class="masthead-right">')
+ len('<div class="masthead-right">'):]
assert after_cluster.lstrip().startswith(NAV_STATUS_ELEMENT), \
f"{path} does not render #nav-status as the first child of .masthead-right "\
"(the five dashboards' position is what puts the badges on their own row)"
assert header.count('id="nav-status"') == 1, f"{path} renders the badge container twice"
def test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint(client) -> None: def test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint(client) -> None:
"""Hidden by default, revealed at one breakpoint, in two files that must agree on its value. """Hidden by default, revealed at one breakpoint, in two files that must agree on its value.
@@ -4682,7 +4757,8 @@ def test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint(client) -> Non
# ---- the three-row masthead (phase 20 task 01) ------------------------------ # # ---- the three-row masthead (phase 20 task 01) ------------------------------ #
# #
# `TODO.md:5-6` asked for the per-source indicators and the `⚷ Certs ⚙ Account ☾ Dark` cluster # `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 # each on their own line (phase 25 task 02 moved both of those lines flush right; the *rows* and
# the mechanism below are phase 20's). The markup could not change — `#nav-status` is a child
# of `.masthead-right`, and the tab structure is pinned by # 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: # `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 # in a `flex-wrap: wrap` container an item whose *base* size fills the line is alone on it (the
@@ -4729,13 +4805,14 @@ def _cascade(css: str, *selectors: str) -> dict[str, str]:
return merged return merged
def test_the_masthead_is_three_left_justified_rows_at_every_width(client) -> None: def test_the_masthead_is_three_rows_right_justified_at_every_width(client) -> None:
"""Brand + tabs, then the indicators, then the controls — three rows, two of them flush left. """Brand + tabs, then the indicators, then the controls — three rows, two of them flush right.
Every assertion here is one declaration the row layout depends on, read out of the 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 **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 two `justify-content` values are the right justification (phase 25 task 02, after the
`margin-inline-start` on the cluster is the end of the right-pinned single-row bar. operator overruled phase 20's `flex-start`), and the absence of `margin-inline-start` on the
cluster keeps the *mechanism* the alignment rather than the old single-row right-pinning.
""" """
css = _css(client) css = _css(client)
bar = _declarations(css, ".masthead") bar = _declarations(css, ".masthead")
@@ -4753,16 +4830,16 @@ def test_the_masthead_is_three_left_justified_rows_at_every_width(client) -> Non
cluster = _declarations(css, ".masthead-right") cluster = _declarations(css, ".masthead-right")
assert cluster.get("flex-basis") == "100%", \ assert cluster.get("flex-basis") == "100%", \
"the control cluster no longer starts its own row under the indicators" "the control cluster no longer starts its own row under the indicators"
assert cluster.get("justify-content") == "flex-start", \ assert cluster.get("justify-content") == "flex-end", \
"the controls are no longer left-justified in their own row" "the controls are no longer right-justified in their own row"
assert "margin-inline-start" not in cluster, \ assert "margin-inline-start" not in cluster, \
"margin-inline-start: auto pushes the cluster back to the right edge of its own row" "margin-inline-start: auto pushes the cluster back to the right edge of its own row"
dots = _declarations(css, "#nav-status") dots = _declarations(css, "#nav-status")
assert dots.get("flex-basis") == "100%", \ assert dots.get("flex-basis") == "100%", \
"the status indicators no longer get a row to themselves" "the status indicators no longer get a row to themselves"
assert dots.get("justify-content") == "flex-start", \ assert dots.get("justify-content") == "flex-end", \
"the indicators are no longer left-justified" "the indicators are no longer right-justified"
def test_the_mobile_masthead_block_declares_no_second_bar_height(client) -> None: def test_the_mobile_masthead_block_declares_no_second_bar_height(client) -> None: