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