chore(agent): phase roadmap from TODO.md, 2 phases

Convert the two TODO items into append-mode phases (Protocol B):
18_nav_hamburger (TODO L3) and 19_ups_events_expandable (TODO L4),
numbered after the existing todo phases. TODO.md is cleared to an
empty list; its items now live in .agents/phases/todo/.

LLM-Generated: true
This commit is contained in:
2026-10-20 10:37:30 -04:00
parent cffbac5771
commit 4b0f53c499
11 changed files with 381 additions and 3 deletions
@@ -0,0 +1,36 @@
# Phase 18 — Collapse the nav bar into a hamburger
**Source:** `TODO.md` L3 — "The nav bar needs to collapse into a hamburger like brain of reese does"
**Story:** n/a
**Context:** the seven mastheads that carry the tab nav — `src/dashboards/web/pages/{index,system,disks,llm,ups,settings,certs}.html` (each has `nav class="tabs" aria-label="Dashboards"` with the five dashboard links; `login.html` deliberately has no tabs — it renders only the brand link and the theme toggle, and stays untouched), `src/dashboards/web/static/app.js` (`Dash.initChrome()`, called from the one `DOMContentLoaded` at line 786 — the `themeButton`/`clock`/`nav-status` blocks there are the per-element `if (element)` no-op pattern this phase's binding follows), `src/dashboards/web/static/dash.css` (`.masthead` at line 95 — `position: sticky; z-index: 40; height: var(--header-h)`, `nav.tabs` at line 131, the existing `@media (max-width: 640px)` block at line 286, the `prefers-reduced-motion` block at line 938), and the reference implementation the TODO names — `brain_of_reese` (sibling repo, read-only): `frontend/assets/header.js` lines 326–366 (the phase-46 binding: `#nav-toggle` shown ≤640px, `setNavMenu(open)` funnel, close on link click / Esc with focus return / `matchMedia` resize-back). Pinned test neighbours: `tests/test_api.py::test_the_disks_tab_is_in_the_masthead_of_every_page` (line 3837) and the served-page orphan-`id` audit in `tests/test_auth_api.py` (line 1654)
## Objective
On viewports ≤640px the masthead's five tab links collapse into a dropdown opened by a hamburger button, mirroring brain of reese's phase-46 behaviour: one shared binding in `app.js` (a no-op on any page without the elements — `/login`), the inline nav hidden and the dropdown absolutely positioned under the sticky masthead, and the desktop masthead byte-identical in behaviour above the breakpoint. The `masthead-right` cluster stays visible on phones; only the tab nav collapses.
## Dependencies
- — (none; builds on the shipped masthead, `initChrome`, and the 640px media block)
## Tasks
1. `01_hamburger_markup.md` — the `#nav-toggle` button and the `nav` id, identical across the seven tabbed pages, absent from `/login`
2. `02_dropdown_css.md` — the ≤640px block: toggle visible, inline tabs hidden, `.is-open` dropdown under the masthead, two-row masthead
3. `03_toggle_binding.md` — the `initChrome` binding: toggle + `aria-expanded`, close on link click / Esc / resize-back
4. `04_tests_and_verification.md` — the pins (present on all seven, absent on `/login`, CSS and binding), the orphan-`id` audit, and the narrow-viewport browser verification with screenshots
## Testing & Quality
- Unit/integration: `tests/test_api.py` — the served-page pins (see task 04); the existing masthead/tab pins (`test_the_disks_tab_is_in_the_masthead_of_every_page`, `test_every_page_links_to_both_admin_pages`) and the `tests/test_auth_api.py` orphan-`id` audit pass untouched — the new `id`s are referenced by `aria-controls`, the stylesheet, and `app.js`, so the audit must still clear
- Coverage: **>90%** on new/modified code (the phase is markup + CSS + one small JS binding; the coverage gate applies to the changed Python test files)
- The gate (`bash .agents/validate.sh`) includes `node --check` of `web/static` — the modified `app.js` must pass it
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**.
- AGENTS.md's `UI conventions` section gains the masthead-collapsing convention (the 640px breakpoint, the toggle's identity, the close paths, and that `/login` carries no toggle) so a future masthead edit knows the contract. The README's page table is unchanged — the nav is the same five links at every width.
## Completion Criteria
- [ ] at ≤640px: the tab links are hidden, the hamburger is visible, and clicking it opens a dropdown containing the five links; above 640px the desktop masthead is unchanged
- [ ] the dropdown closes on link click, on Esc (focus returns to the button), and on resize back above 640px; `aria-expanded` tracks the state
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green (including `node --check` of the modified `app.js`)
- [ ] narrow-viewport screenshots (closed, open, and one desktop no-regression shot) in `data/logs/` against the running dev server (invariant 10)
- [ ] no behavior change in completed work: the desktop masthead and every page render identically at widths >640px
@@ -0,0 +1,31 @@
# Task 01 — The hamburger button markup
**Phase:** `18_nav_hamburger` · **Source:** `TODO.md:3` — "The nav bar needs to collapse into a hamburger like brain of reese does"
**Story:** n/a
## Objective
The seven pages that carry the tab nav each gain the hamburger button and the `nav` id, so the (later task's) CSS and binding have stable anchors to work on. The markup is identical on every page, and `/login` — which deliberately renders only the brand link and the theme toggle — stays untouched.
## Work
1. `src/dashboards/web/pages/index.html` · `system.html` · `disks.html` · `llm.html` · `ups.html` · `settings.html` · `certs.html` — in each masthead, immediately before `nav class="tabs"`, add the toggle:
```html
<button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
```
and give the nav its id: `<nav class="tabs" id="nav-tabs" aria-label="Dashboards">`. The button sits between `.brand` and `nav.tabs`, inside the same `<header class="masthead">` — one element per page, no script changes in this task.
2. `src/dashboards/web/static/dash.css` — add the one base rule beside the other `button.ghost, a.ghost` family (the masthead section): `.nav-toggle { display: none; }` — the button is invisible on every width until task 02's ≤640px block shows it, so the project stays exactly as it looks today after this task.
3. `src/dashboards/web/pages/login.html` — **no change.** Verify by `grep -c 'id="nav-toggle"'` returning 0 (the pin lands in task 04).
4. `ASSUMPTION:` the TODO named brain of reese's behaviour, not its breakpoint or glyph. This task takes the 640px breakpoint (brain's `(max-width: 640px)` and `dash.css`'s existing 640px block) and the `☰` glyph with `aria-label="Toggle navigation"`; the CSS task owns the visual.
5. `ASSUMPTION:` only the tab nav collapses on phones. The `masthead-right` cluster (`#nav-status` dots, pause, `⚷ Certs`, `⚙ Account`, theme toggle) stays visible; the masthead wraps to two rows at ≤640px (task 02).
## Testing & Quality
- Integration: the served pages still pass the untouched masthead pins (`test_the_disks_tab_is_in_the_masthead_of_every_page`, `test_every_page_links_to_both_admin_pages`) and the orphan-`id` audit — `#nav-toggle` is referenced by the (task 02) stylesheet rule and `aria-controls`, so the audit must not flag it
- Coverage: **>90%** on modified code (HTML only — the gate's `node --check` and pytest suite are the checks)
## Completion Criteria
- [ ] `grep -l 'id="nav-toggle"' src/dashboards/web/pages/*.html` lists exactly the seven tabbed pages, never `login.html`
- [ ] every one of the seven carries the identical button block and the `nav` id (diff of the blocks across pages is empty)
- [ ] `uv run pytest -q` green; `bash .agents/validate.sh` green
- [ ] no behavior change: at every width the pages render exactly as before after this task (the button is `display:none` from the base rule shipped with it)
@@ -0,0 +1,29 @@
# Task 02 — The ≤640px dropdown CSS
**Phase:** `18_nav_hamburger` · **Source:** `TODO.md:3` — "The nav bar needs to collapse into a hamburger like brain of reese does"
**Story:** n/a
## Objective
The CSS that makes the hamburger real at ≤640px: the toggle button visible, the inline tab nav hidden, the `.is-open` nav rendered as a dropdown under the sticky masthead, and the masthead wrapping to two rows so the `masthead-right` cluster does not overflow a phone. Above 640px nothing changes.
## Work
1. `src/dashboards/web/static/dash.css` — the base `.nav-toggle { display: none; }` rule already landed with task 01's markup (the button is invisible on every width until this task's block). Extend the existing `@media (max-width: 640px)` block at line 286 (or add a sibling block — the file's convention is one block per concern; a `/* mobile nav */`-commented block beside the masthead section is the cleaner home):
- `.masthead { height: auto; min-height: var(--header-h); flex-wrap: wrap; }` — the single-row fixed-height masthead cannot hold brand + toggle + the whole `masthead-right` cluster at 360px; two rows, first row brand + toggle, second row the cluster.
- `.nav-toggle { display: inline-flex; align-items: center; }` (it is a `button.ghost`, so the outlined style is inherited — no second palette, the file's rule).
- `nav.tabs { display: none; }` and `nav.tabs.is-open { display: flex; flex-direction: column; gap: 0; position: absolute; top: 100%; left: 0; right: 0; background: var(--bg-raised); border-bottom: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0, 0, 0, .35); padding: .4rem; z-index: 41; }` — the dropdown. The masthead is `position: sticky`, which is a non-static containing block for its absolute descendants, so `top: 100%` pins it directly under the (wrapped, two-row) masthead. `z-index: 41` is one above the masthead's 40 so the dropdown sits on the page content.
- `nav.tabs.is-open a { padding: .6rem .7rem; border-radius: var(--radius-sm); }` — touch-sized rows; the existing `:hover`/`[aria-current="page"]` rules carry over unchanged.
- Optional at most: a 120ms opacity/translateY transition on `nav.tabs.is-open`, **disabled inside the existing `@media (prefers-reduced-motion: reduce)` block at line 938** (add the dropdown selector to that block's list of motion-killers if used). If in doubt, ship without the transition.
2. `ASSUMPTION:` the dropdown is a single full-width column under the masthead (brain's shape, adapted: brain hides the nav in a `#app-nav` container; here the `nav` element itself is the container and gets `id="nav-tabs"` + `.is-open`). No separate menu panel element.
3. `ASSUMPTION:` no `body`-level marker class (brain's `nav-menu-open` exists for its sticky bottom chat cluster, which the dashboards do not have — the dropdown competes with nothing; do not copy a side effect nobody needs).
## Testing & Quality
- Integration: the served-stylesheet pins land in task 04 (base rule present, ≤640px block contains the toggle-visible + tabs-hidden + `.is-open` rules); the untouched `node --check` gate passes (CSS is not checked, but confirm no syntax error by loading the page in the browser — task 04's verification)
- Coverage: **>90%** on modified code (CSS only; the gate's pytest/ruff/pyright must stay green)
## Completion Criteria
- [ ] at a 360px-wide browser window: the tab links are gone from the row, the `☰` button is visible, and toggling `.is-open` (by hand in devtools) shows the full-width dropdown under the masthead with the five links stacked and the `aria-current` tab highlighted
- [ ] at 700px and 1200px the masthead is byte-identical in layout to before (one row, inline tabs, no button)
- [ ] the dropdown does not trap or cover the `masthead-right` cluster, and the page content scrolls under it
- [ ] `bash .agents/validate.sh` green
@@ -0,0 +1,55 @@
# Task 03 — The toggle binding in `initChrome`
**Phase:** `18_nav_hamburger` · **Source:** `TODO.md:3` — "The nav bar needs to collapse into a hamburger like brain of reese does"
**Story:** n/a
## Objective
One shared binding in `app.js` (the file every page loads, including `/login`) that makes the button work on all seven tabbed pages and is a no-op where the elements are absent. The behaviour mirrors brain of reese's phase-46 binding: a `setNavMenu(open)` funnel so no close path can leave the state stuck, `aria-expanded` honest at all times, close on link click, on Esc (focus returns to the button), and on resize back above the breakpoint.
## Work
1. `src/dashboards/web/static/app.js` — inside `initChrome()` (line 620), after the `nav-status` block and before the `return`, add a block in the file's per-element guard style (the `themeButton` pattern at line 626 is the shape: `if (element) { … }`):
```js
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 (event-delegated: the five
// links are static markup, so one listener on the nav covers all of them).
appNav.addEventListener('click', (event) => {
if (event.target.closest('a')) setNavMenu(false);
});
// Esc closes while open; focus returns to the opener.
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 — drop the open state
// so aria-expanded stays honest (the CSS hides the dropdown above 640px
// regardless, but a stuck `is-open` would re-open on the next shrink).
const mq = window.matchMedia('(max-width: 640px)');
const onMqChange = () => { if (!mq.matches) setNavMenu(false); };
if (mq.addEventListener) mq.addEventListener('change', onMqChange);
}
```
Notes: the breakpoint literal must match task 02's CSS block — if one drifts, the menu opens into a display:none nav. `ASSUMPTION:` that match is pinned by task 04's served-asset test (assert the `640px` literal appears in both the served stylesheet's nav block and the served `app.js`), not by a shared constant — the file's convention is per-file literals (the 640px in CSS, 900px for the clock, and the page scripts' own numbers do not share a constant either).
2. No `body`-level marker class (task 02's assumption carries: nothing on this app competes with the dropdown for taps, so brain's `nav-menu-open` marker is not copied).
3. No change to the login page: `initChrome` already ships to it, and the `if (navToggle && appNav)` guard is the no-op — verify in task 04 that `/login`'s served script is the same shared file (it is: every page loads `/static/app.js`) and the guard makes the binding inert there.
## Testing & Quality
- Integration: the binding's behaviour is verified in the browser (task 04 — the repo has no JS test runner; the pins are structural on the served bytes: the block is present in the served `app.js`, the four close paths named in a comment, `node --check` passes); the untouched Python suite stays green
- Coverage: **>90%** on modified code (one JS function; the Python coverage gate applies to the test files task 04 adds)
## Completion Criteria
- [ ] at 360px: clicking `☰` opens the dropdown and `aria-expanded` reads `true`; clicking again closes it; clicking a tab link closes it as the page navigates
- [ ] with the menu open: Esc closes it and focus is on the `☰` button; resizing to 700px closes it and the next shrink back to 360px leaves it closed (no stale `is-open`)
- [ ] on `/login` the script loads and the guard no-ops (no console error, `node --check` clean)
- [ ] `uv run pytest -q` green; `bash .agents/validate.sh` green (includes `node --check` of the modified `app.js`)
@@ -0,0 +1,32 @@
# Task 04 — Pins and narrow-viewport verification
**Phase:** `18_nav_hamburger` · **Source:** `TODO.md:3` — "The nav bar needs to collapse into a hamburger like brain of reese does"
**Story:** n/a
## Objective
The phase is pinned on the served bytes the way this repo pins every page surface: the button is on all seven tabbed pages and **absent** from `/login`, the CSS and the binding carry the matching 640px literal, and the orphan-`id` audit still clears. Then the designed degradation is proven in a real browser at a phone width — the invariant-10 screenshot pass.
## Work
1. `tests/test_api.py` — new tests, in the style of `test_the_disks_tab_is_in_the_masthead_of_every_page` (line 3837; it iterates the served pages and checks the masthead):
- `test_the_nav_toggle_is_in_the_masthead_of_every_tabbed_page`: for each of the seven served pages (`/`, `/system`, `/disks`, `/llm`, `/ups`, `/settings`, `/certs`) — a `GET` (signed, like the other served-page pins) finds `button#nav-toggle` with `aria-expanded="false"`, `aria-controls="nav-tabs"`, `aria-label="Toggle navigation"`, and `type="button"`, and `nav#nav-tabs` with its five tab links intact; the button precedes the nav in the markup (the dropdown's `top: 100%` anchor needs the nav inside the masthead, which the pin asserts via the `header.masthead` containing both).
- `test_the_login_page_carries_no_nav_and_no_toggle`: `/login` (fetched the way the audit fetches it — unsigned, because a signed-in `GET /login` is a `303`) has no `nav.tabs`, no `#nav-toggle`, and no `#nav-tabs` — the page's contract is brand + theme toggle only, and a stray hamburger there is the bug this test exists to fail.
- `test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint`: the served `dash.css` contains the base `.nav-toggle` `display: none` rule, a `@media (max-width: 640px)` (or the 640px block) with the toggle-visible, `nav.tabs`-hidden, and `.is-open` rules; the served `app.js` contains the binding block (the `is-open` toggle, the `Escape` close, the `matchMedia('(max-width: 640px)')` call) — and the `640px` literal appears in both, so a drift between CSS and JS fails here instead of opening a menu into a `display:none` nav.
2. `tests/test_auth_api.py` — **no change expected**: the served-page orphan-`id` audit (line 1654) must pass untouched, because `#nav-toggle` and `#nav-tabs` are referenced (the stylesheet rule, `aria-controls`, the `app.js` selectors). If the audit flags either id, the fix is in the CSS/JS references, not in the audit.
3. Browser verification (invariant 10): `./scripts/dev-server.sh restart`, sign in with `testpass`, and with a ≤640px viewport (Playwright or a devtools device width — the repo's screenshots land in `data/logs/`):
- `data/logs/nav-hamburger-closed-360.png` — the phone-width masthead: no tab links, the `☰` visible, the `masthead-right` cluster intact.
- `data/logs/nav-hamburger-open-360.png` — the dropdown open: five stacked links, the `aria-current` tab highlighted, the cluster not covered.
- `data/logs/nav-masthead-desktop-1280.png` — the no-regression shot: one-row masthead, inline tabs, no button.
4. `AGENTS.md` — in the `UI conventions` section (where the `/login` masthead contract and the `app.js` ownership are documented), add the collapsing contract in the section's voice: the 640px breakpoint, the toggle's identity and its seven-page scope, the three close paths (link click, Esc with focus return, resize-back), the `aria-expanded` rule, and that `/login` carries no toggle. `README.md` is checked in the phase's final pass (its page table — "power events" etc. — is unchanged by this phase).
## Testing & Quality
- Integration: the three new served-page tests above + the untouched orphan-`id` audit + `test_the_disks_tab_is_in_the_masthead_of_every_page` (the nav's id addition must not break it)
- Coverage: **>90%** on the new test code; the full gate green
## Completion Criteria
- [ ] `uv run pytest tests/test_api.py tests/test_auth_api.py -q` green, including the three new tests and the untouched audit
- [ ] `grep -c 'nav-toggle' data/logs/…` — the three screenshots exist and show the closed / open / desktop states as named
- [ ] `bash .agents/validate.sh` green (pytest --cov --cov-fail-under=94, ruff, pyright, node --check)
- [ ] AGENTS.md's UI conventions name the contract; README.md reviewed and consistent
- [ ] no behavior change in completed work: the desktop pages and the login page are pixel-identical in structure (the pins prove it)
@@ -0,0 +1,36 @@
# Phase 19 — Expandable UPS power-events list
**Source:** `TODO.md` L4 — "The UPS tab should have an expandable, clear list of last incidents with dates/times"
**Story:** n/a
**Context:** `src/dashboards/web/pages/ups.html` lines 72–76 (the `Power events` card: `<header><h3>` + a `ul.events#events` holding a `loading…` placeholder), `src/dashboards/web/static/page-ups.js` (`refreshEvents()` at line 430 — fetches `/api/events?limit=200&source=ups` and hands the rows to `Dash.eventsPager` with the date/time + severity-chip + host row shape; called unconditionally from `refresh()` at line 427, which the page's `poll(refresh, state.pollMs)` loop drives, `state.pollMs` from `config.ups.poll_ms || 5000`), `src/dashboards/web/static/app.js` (`Dash.eventsPager` — 50 rows a page, paging state in a `WeakMap` keyed by the `<ul>`, rows re-written only when their markup changed), `src/dashboards/web/static/dash.css` (`details.charts-box` at lines 714–727 — the summary marker `▸`/`▾`, hidden webkit marker, hover; the `.events` bounded 320px scroll and `.events-pager` strip), and the pattern this phase mirrors — `/system`'s per-host Charts disclosure (`system.html` line 81, `page-system.js` line 315: "a `<details>` renders nothing, so … nothing is built or drawn while the stack is shut") and its pin `test_system_history_charts_are_collapsed_behind_a_toggle`
## Objective
The `/ups` power-events list becomes what the TODO asks for — *expandable*: the card is collapsed by default behind a `<details>` disclosure in the repo's own `charts-box` style, nothing is fetched or drawn while it is shut (the `/system` rule), the first expand fetches and builds, a re-expand paints from the data in hand unless it is stale, and the page's poll loop refreshes the list only while the disclosure is open. The "clear" half already ships and stays: every row carries its date/time, the severity chip, and the host, and the 50-per-page pager bounds the list. No new API surface — the existing `/api/events?source=ups` is the whole backend.
## Dependencies
- `18_nav_hamburger` (todo) — both phases touch `ups.html` (the masthead, the events card); landing 18 first keeps this phase's diff confined to the events section. No shared code between them.
## Tasks
1. `01_events_disclosure.md` — the `ups.html` markup: the card body becomes a collapsed-by-default `<details class="events-box">`
2. `02_events_lazy_loading.md` — `page-ups.js`: no fetch while closed, first-expand fetch, re-expand stale rule, poll-while-open
3. `03_disclosure_css.md` — `dash.css`: the `details.events-box` summary styling in the `charts-box` shape, pager and bounded list inside
4. `04_tests_and_verification.md` — the served-page pins (disclosure present and unopened, the lazy rule, the unchanged row shape) and the closed/open browser verification
## Testing & Quality
- Unit/integration: `tests/test_api.py` — the served-page pins (see task 04), alongside the untouched `Dash.eventsPager` behaviour (a re-paint with the same data keeps the reader's page — the `WeakMap` rule, pinned by the existing event-feed tests)
- Coverage: **>90%** on new/modified code (the phase is markup + JS + CSS; the gate applies to the changed Python test files)
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's)
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**.
- AGENTS.md's `UI conventions` section already documents the event pager ("An event feed is a window, not a printout") and the `/system` Charts disclosure; it gains the `/ups` disclosure's sentence (collapsed by default, lazy-built, poll-while-open, the stale rule) in the section's voice. The README's `/ups` table row ("power events") stays true — the list is on the page, now behind a summary.
## Completion Criteria
- [ ] `/ups` loads with the Power events card collapsed (a summary line, no list visible, no `/api/events` request in the page's first poll) and the expand shows the bounded, paged list with date/time rows
- [ ] closing and re-opening within one poll interval paints the cached rows without a refetch; a stale cache refetches on open; while open, the poll loop keeps the list current
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green (including `node --check` of the modified `page-ups.js`)
- [ ] closed- and open-state screenshots in `data/logs/` against the running dev server (invariant 10)
- [ ] no behavior change in completed work: every other element on `/ups` (cards, charts, dialog, pager behaviour) is unchanged
@@ -0,0 +1,40 @@
# Task 01 — The disclosure markup
**Phase:** `19_ups_events_expandable` · **Source:** `TODO.md:4` — "The UPS tab should have an expandable, clear list of last incidents with dates/times"
**Story:** n/a
## Objective
The `Power events` card on `/ups` becomes a collapsed-by-default `<details>` disclosure, in the markup shape the repo already owns for `/system`'s Charts stack. The card stays a card; only its body moves inside the disclosure. The `loading…` placeholder stays in the `ul` — it is the honest state between the first expand and the first successful fetch (the pager's output replaces it on arrival; an empty feed then prints the pager's own `empty` sentence, which is a different fact).
## Work
1. `src/dashboards/web/pages/ups.html` — restructure the section at lines 72–76 from
```html
<section class="card" style="margin-top:.85rem">
<header><h3>Power events</h3></header>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</section>
```
to
```html
<section class="card" style="margin-top:.85rem">
<details class="events-box">
<summary>Power events</summary>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</details>
</section>
```
— no `open` attribute (collapsed by default; `ASSUMPTION:` confirmed by the operator — "expandable" means the list is shut until the operator opens it, the `/system` Charts pattern). The `#events` id, the `ul.events` class (the bounded 320px scroll + `.events-pager` strip target it), the `loading…` placeholder, and the pager's contract are all the page-script's, so nothing else in the file moves.
2. `ASSUMPTION:` the summary is the plain word `Power events` (the old `<h3>` text) — no count chip on the summary. A count that has not been fetched would be an invented number (rule 2), and the pager under the list already says `1–50 of N` once data is in hand.
3. `ASSUMPTION:` the disclosure is a plain `details` (no `data-k` attribute) — `/system`'s `data-k="charts"` exists for the per-host JS that rebuilds the stack; here the single page-level `page-ups.js` queries it by class, and the pin in task 04 names the class, not an attribute.
## Testing & Quality
- Integration: the served-page pin lands in task 04 (`details.events-box` present on the served `/ups`, no `open` attribute, the `summary` label, the `#events` ul inside, no `loading…` placeholder); the untouched page renders (the browser verification is task 04)
- Coverage: **>90%** on modified code (HTML only; the gate's suite must stay green)
## Completion Criteria
- [ ] the served `/ups` contains the restructured section exactly as named (a `GET` pin in task 04: `details.events-box`, no `open` attribute, the `summary` label, the `#events` ul with its `loading…` placeholder inside)
- [ ] the page loads in a browser with the card collapsed: a `Power events` summary line, no list visible (task 04's screenshot)
- [ ] `uv run pytest -q` green; `bash .agents/validate.sh` green
- [ ] no behavior change in completed work: the rest of the page (cards, charts, dialog) is byte-identical in markup
@@ -0,0 +1,49 @@
# Task 02 — The lazy loading in `page-ups.js`
**Phase:** `19_ups_events_expandable` · **Source:** `TODO.md:4` — "The UPS tab should have an expandable, clear list of last incidents with dates/times"
**Story:** n/a
## Objective
Nothing is fetched or drawn while the disclosure is shut (the `/system` rule: "nothing is built or drawn while the stack is shut"), the first expand fetches and builds, a re-expand paints from the data in hand unless it is stale, and the page's poll loop refreshes the list only while the disclosure is open. The row shape, the pager, and the `loading…`-then-rows sequence are unchanged.
## Work
1. `src/dashboards/web/static/page-ups.js` — `state` (line 17) gains one key, in the object's existing style: `events: { open: false, painted: false, fetchedAt: 0 }`.
2. Same file, the `refresh()` function (the `await refreshEvents();` at line 427 is its last statement) — the unconditional call becomes the open-gate:
```js
if (state.events.open) await refreshEvents();
```
— the poll loop (the `state.poller = poll(refresh, state.pollMs)` at line 464) keeps driving the list **only while the disclosure is open**; closed, the page pays nothing for a feed nobody can see.
3. Same file, `refreshEvents()` (line 430) — on success, before/after the existing `Dash.eventsPager(…)` call, stamp the state: `state.events.fetchedAt = Date.now(); state.events.painted = true;`. The existing `catch (err) { /* next poll */ }` stays untouched — a failed fetch leaves `painted`/`fetchedAt` where they were, so the next open re-fetches, and a failed poll while open keeps the last good rows (the invariant-4 shape, already the page's behaviour for everything else).
4. Same file, inside the `DOMContentLoaded` handler, before `state.poller = poll(…)`:
```js
const eventsBox = $('.events-box');
if (eventsBox) {
state.events.open = eventsBox.open; // false — task 01 ships no `open` attribute
eventsBox.addEventListener('toggle', () => {
state.events.open = eventsBox.open;
if (!eventsBox.open) return;
// First expand: `painted` is false — fetch. Re-expand: paint from the data
// in hand only while it is fresh; a cache older than one poll interval is
// stale and re-fetches. A fresh, already-painted list needs nothing: the
// `<details>` keeps its children, so the rows (and the pager's page, in its
// WeakMap) are already where the operator left them.
if (!state.events.painted || Date.now() - state.events.fetchedAt > state.pollMs) {
refreshEvents();
}
});
}
```
`ASSUMPTION:` the stale bound is **one poll interval** (`state.pollMs`, from `config.ups.poll_ms || 5000`) — the `/system` Charts rule re-fetches a cached response "from another window"; here the window is the page's own, so one interval is the honest bound. A re-open within the interval paints the cache; a re-open after it re-fetches.
5. `ASSUMPTION:` no `loading…` fiddling — the placeholder from task 01 stays in the `ul` until the first successful fetch's pager output replaces it (a `<details>` does not destroy its children, so the placeholder, the rows, and the pager strip all survive collapse/re-expand untouched).
## Testing & Quality
- Integration: the served-script pins land in task 04 (the `state.events` shape, the `refresh()` open-gate, the `toggle` listener with its stale rule — asserted on the served bytes, the repo's way for JS behaviour since it has no JS test runner); `node --check` of the modified file passes (the gate); the untouched `Dash.eventsPager` tests stay green
- Coverage: **>90%** on modified code (JS; the gate's Python checks apply to task 04's test files)
## Completion Criteria
- [ ] in a browser: the page's first poll makes **no** `/api/events` request (devtools network panel) while the card is collapsed; the first expand makes exactly one; the list then updates on the poll cadence while open and stops when closed
- [ ] close → re-open within one poll interval: no request, the rows are exactly as left (page, scroll); close → wait longer than the interval → open: one request, fresh rows
- [ ] a failed fetch (devtools offline) while open keeps the last good rows and the next poll retries; the next open after a failure re-fetches
- [ ] `uv run pytest -q` green; `bash .agents/validate.sh` green (includes `node --check`)
@@ -0,0 +1,42 @@
# Task 03 — The disclosure CSS
**Phase:** `19_ups_events_expandable` · **Source:** `TODO.md:4` — "The UPS tab should have an expandable, clear list of last incidents with dates/times"
**Story:** n/a
## Objective
The `details.events-box` summary renders in the shape the repo already owns for `/system`'s Charts disclosure (`details.charts-box`, `dash.css` lines 714–727): the `▸`/`▾` marker, the hidden webkit marker, the hover, and the open-state de-emphasis — so a second opinion about the drive's word is never the bug, and a page that ships the summary without the style is the drift this task's pin catches.
## Work
1. `src/dashboards/web/static/dash.css` — beside the existing `details.charts-box` rules (lines 714–727), add the `events-box` rules in the same block's voice (one selector per property, the file's style):
```css
details.events-box { min-width: 0; }
details.events-box > summary {
cursor: pointer; list-style: none;
font-weight: 600; color: var(--text-muted);
padding: .4rem .8rem .4rem 1.4rem;
position: relative;
}
details.events-box > summary::-webkit-details-marker { display: none; }
details.events-box > summary::before {
content: "\25B8"; position: absolute; left: .6rem;
font-size: 9px; color: var(--text-faint);
}
details.events-box[open] > summary::before { content: "\25BE"; }
details.events-box > summary:hover { color: var(--text-muted); }
details.events-box[open] > summary { color: var(--text-muted); margin-bottom: .55rem; }
```
(match the `charts-box` block's actual property set line-for-line — the snippet is the shape, the neighbouring block is the ground truth; do not invent tokens `:root` does not declare — the file's rule, pinned by `tests/test_api.py`).
2. The card's existing `.body` wrapper (task 01's markup) carries the `ul.events` bounded scroll (the existing 320px rule) and the `.events-pager` strip — **no new rules** for those; verify they resolve inside the disclosure (the `.body` is a child of `details`, and the existing selectors are class-based, so they do — confirm in the browser, task 04).
3. `ASSUMPTION:` no open/close animation on the disclosure — `charts-box` has none, and the file's `prefers-reduced-motion` block exists for the things that do move; a summary that snaps is the house style.
## Testing & Quality
- Integration: the served-stylesheet pin lands in task 04 (the `details.events-box` rule family present, the `::before` markers, no token a `:root` does not declare — the existing `test` that fails if a CSS rule reaches for an undeclared token must pass untouched); the `node --check` gate is unaffected (CSS), and the page loads in the browser (task 04)
- Coverage: **>90%** on modified code (CSS only; the gate's suite must stay green)
## Completion Criteria
- [ ] collapsed: the `Power events` summary line shows the `▸` marker in the card header area, muted, with the card's existing padding rhythm
- [ ] open: the marker flips to `▾`, the summary de-emphasises, and the bounded scroll list + pager strip render exactly as the other feeds' (the 320px scroll, the `1–50 of N` strip with its arrows)
- [ ] the page's other cards (the battery charts' `details`-free section, the `#ups-cards` grid) are pixel-identical
- [ ] `bash .agents/validate.sh` green
@@ -0,0 +1,31 @@
# Task 04 — Pins and browser verification
**Phase:** `19_ups_events_expandable` · **Source:** `TODO.md:4` — "The UPS tab should have an expandable, clear list of last incidents with dates/times"
**Story:** n/a
## Objective
The phase is pinned on the served bytes the way this repo pins every page surface: the disclosure is on the served `/ups` and **unopened**, the lazy-load rule is in the served script, and the row shape — the "clear" half of the TODO (date/time, severity chip, host) — is unchanged. Then the designed degradation is proven in a real browser: the closed state, the open state, and the no-request-while-closed fact.
## Work
1. `tests/test_api.py` — new tests, in the file's served-page style (the `self_test_app` harness and the `test_system_history_charts_are_collapsed_behind_a_toggle` pin at the `/system` disclosure are the models):
- `test_the_ups_power_events_list_is_a_collapsed_disclosure`: a signed `GET /ups` finds `details.events-box` with **no** `open` attribute, a `summary` reading `Power events`, and the `ul.events#events` (with its `loading…` placeholder) inside it — and the old always-open shape is gone (no bare `header > h3` directly above the `ul` at the section level).
- `test_the_ups_events_fetch_is_gated_on_the_disclosure`: the served `page-ups.js` carries the lazy rule — the `state.events` shape (`open`, `painted`, `fetchedAt`), the `refresh()` open-gate (`state.events.open` guard on the `refreshEvents()` call), and the `toggle` listener with the stale bound (`state.pollMs`) — asserted on the served script text, the repo's established device for JS behaviour (no JS test runner; the `node --check` in the gate covers syntax).
- `test_the_ups_event_rows_keep_the_date_time_and_severity_shape`: the served `page-ups.js` row template still renders the `<time>` with the date/time `toLocaleString` options, the `sev ${event.severity}` chip, and the host — the "clear list with dates/times" the TODO asks for, pinned so a "cleanup" that drops the timestamp fails here instead of shipping.
2. The existing event-feed pins (`tests/test_api.py`'s `Dash.eventsPager`-behaviour tests, the `EVENT_FEED_LIMIT`/`EVENT_PAGE_SIZE` pairing) pass **untouched** — the phase adds no API surface and no pagination vocabulary.
3. Browser verification (invariant 10): `./scripts/dev-server.sh restart`, sign in with `testpass`, real upsd in the loop (the dev config dials the fleet's `upsd` on 3051 — two real UPSes, no `NUT_SIMULATE`):
- `data/logs/ups-events-closed.png` — the page with the card collapsed: the `Power events` summary line, no list, and the devtools network panel showing **no** `/api/events?source=ups` request on the first poll (screenshot the panel or note the request count in the filename's companion note).
- `data/logs/ups-events-open.png` — the expanded card: the bounded scroll list with date/time rows, the severity chips, and the `1–50 of N` pager strip (or the empty sentence, if the event table is genuinely empty — either is the correct state; the pin is the *shape*, not the data).
4. `AGENTS.md` — in the `UI conventions` section, beside the event-pager paragraph ("An event feed is a window, not a printout") and the `/system` Charts-disclosure paragraph, add the `/ups` sentence in the section's voice: the list is a collapsed-by-default `details` disclosure, nothing is fetched while it is shut, the first expand fetches and builds, a re-expand paints the cache while it is fresher than one poll interval (stale re-fetches), and the poll loop refreshes only while open. `README.md` is checked in the phase's final pass (its `/ups` row — "power events" — stays true; the list is on the page, behind a summary).
## Testing & Quality
- Integration: the three new served-page tests above + the untouched pager/limit pairing tests + `test_system_history_charts_are_collapsed_behind_a_toggle` (the pattern pin, untouched)
- Coverage: **>90%** on the new test code; the full gate green
## Completion Criteria
- [ ] `uv run pytest tests/test_api.py -q` green, including the three new tests and the untouched event-feed pins
- [ ] `data/logs/ups-events-closed.png` and `data/logs/ups-events-open.png` exist and show the states as named (closed: summary only; open: bounded list + pager; the closed shot's note records the zero `/api/events` requests on the first poll)
- [ ] `bash .agents/validate.sh` green (pytest --cov --cov-fail-under=94, ruff, pyright, node --check)
- [ ] AGENTS.md's UI conventions carry the disclosure sentence; README.md reviewed and consistent
- [ ] no behavior change in completed work: the rest of `/ups` (cards, charts, the confirm dialog, the banners) renders exactly as before