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:
@@ -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 @@
|
|||||||
|
ok
|
||||||
@@ -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
|
||||||
+21
@@ -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`
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+65
@@ -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
|
||||||
+14
@@ -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`).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+65
@@ -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
|
||||||
+15
@@ -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`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+65
@@ -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
|
||||||
+15
@@ -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).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+65
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user