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

Append phases 25-27 (masthead control placement, UPS event split, mobile
tables + full mobile audit) derived from TODO.md L3-L7; the TODO file is
cleared, its items now live in .agents/phases/todo/. Protocol B: PLAN.md
and AGENTS.md untouched.

LLM-Generated: true
This commit is contained in:
2026-10-20 23:24:27 -04:00
parent 6444a158f2
commit 973b7299fe
12 changed files with 369 additions and 0 deletions
@@ -0,0 +1,34 @@
# Phase 25 — Masthead control placement: badges everywhere, right-justified, clock rightmost
**Source:** `TODO.md` L3–L5 — "hosts disks engines UPS disappear when in the certs/account page" · "I made a mistake earlier, I did want hosts disks engines UPS badges and certs/account/dark right justified don the navbar" · "The time should show up as the farthest right item on the navbar"
**Story:** n/a
**Context:** phase 20 (complete) — the masthead is three rows at every width (row 1 brand + `#nav-toggle` + the five tabs, row 2 the `#nav-status` per-source dots, row 3 the `.masthead-right` controls), not sticky (`position: relative`, `z-index: 40`), with the ≤640px dropdown sliding on `nav.tabs.is-open`; phase 18 — the hamburger reveal through `--nav-toggle-display`. The dots are built by `Dash.initChrome()` in `src/dashboards/web/static/app.js` (the `if (nav)` block on `#nav-status`, one chip per `system`/`disks`/`llm`/`ups` dashboard from `/api/health`, refreshed every 15 s), and exist in the markup only of the five dashboard pages — `certs.html` and `settings.html` ship no `#nav-status` (the comment in `settings.html` says a session page "has no fleet reachability to report"), which is what L3 names. The served-CSS pins for the bar are in `tests/test_api.py`: `test_the_masthead_is_three_left_justified_rows_at_every_width` (the two `justify-content: flex-start` declarations, the two `flex-basis: 100%` row breaks, the `min-height`/`gap`), `test_the_mobile_masthead_block_declares_no_second_bar_height`, `test_the_masthead_scrolls_away_and_stays_the_dropdown_anchor`; the one-loop control-order audit is `test_every_page_links_to_both_admin_pages` (clock → `⚷ Certs` → `⚙ Account` → theme toggle on all eight signed-in pages), and the two pins that assert the admin pages have **no** dots are in `test_the_settings_page_is_not_one_of_the_dashboards` and `test_the_certs_page_is_a_page_and_not_a_sixth_dashboard`. `tests/test_auth_api.py` asserts `/login` carries no `#nav-status` — unchanged by this phase.
## Objective
The four per-source badges (hosts · disks · engines · UPS) render on **every** page that renders the masthead — including `/certs` and `/settings` — and the two right-hand rows (the dots, the controls) are right-justified, with the clock the farthest-right item on the bar. The three-row shape at every width is preserved; only the alignment and the control order move, and the clock's position is what L5 asks for.
## Dependencies
- — (none) — the phase builds on complete phase 20 and phase 18; it is scheduled first of the three appended phases on purpose, because the mobile audit phase (27) measures the masthead in the shape this phase leaves it in.
## Tasks
1. `01_status_dots_on_admin_pages.md` — `#nav-status` on `/certs` and `/settings` (markup only — `initChrome` already builds the dots when the container exists); the two no-dots pins flip to dots-present
2. `02_right_justify_masthead_rows.md` — `justify-content: flex-end` on `#nav-status` and `.masthead-right`, the comment block re-worded, the three-rows pin re-pinned to the new alignment
3. `03_clock_rightmost.md` — the clock moves to the end of the control row on all eight signed-in pages; the one-loop order pin updated; the phase's real-browser verification (badges on the admin pages, both rows right-justified, clock rightmost, at 1280 px and 360 px)
## Testing & Quality
- Unit/integration: the served-byte pins in `tests/test_api.py` — the flipped admin-page pins (task 01), the re-pinned row-alignment pin (task 02), the updated control-order pin (task 03); `tests/test_auth_api.py`'s `/login`-has-no-dots pin must stay green untouched
- Coverage: **>90%** on new/modified code (the new code is the pins themselves; the change is CSS and template markup)
- Real-browser check (invariant 10): a `data/logs/25-task03-*.cjs` script in the `20-task01-rows.cjs` convention, run against the restarted dev server at 1280 px and 360 px on all seven tabbed pages plus `/login` (unsigned), with screenshots in `data/logs/`
## Documentation & Consistency
- The final pass updates the now-stale sentences in `AGENTS.md`: "The masthead is three **left-justified** rows at every width" (alignment), "A page with no dots (`/certs`, `/settings`) renders two rows" (gone — all masthead pages carry the dots), "the two links therefore sit on all eight signed-in pages in one order (clock → `⚷ Certs` → `⚙ Account` → theme toggle)" (new order: `⚷ Certs` → `⚙ Account` → theme toggle → clock), and the "left-justified" wording in the `dash.css` header comment. `README.md` is checked for the same sentences; none is expected.
## Completion Criteria
- [ ] `/certs` and `/settings` render the four badges, populated from `/api/health` (pinned + verified in a browser against the live dev fleet)
- [ ] the dots row and the controls row are right-justified at every width; the three-row shape and the ≤640px dropdown are unchanged (pinned + measured in a browser)
- [ ] the clock is the farthest-right item on the bar on all eight signed-in pages (pinned + measured)
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] no behavior change in completed work: the row-break mechanism (`flex-wrap` + the two `flex-basis: 100%`), the non-sticky bar, the dropdown's slide and its four close paths, and the dots' refresh semantics are all untouched
@@ -0,0 +1,27 @@
# Task 01 — The badges render on the admin pages too
**Phase:** `25_masthead_control_placement` · **Source:** `TODO.md:3–4` — "hosts disks engines UPS disappear when in the certs/account page" · "I made a mistake earlier, I did want hosts disks engines UPS badges and certs/account/dark right justified don the navbar"
**Story:** n/a
## Objective
`#nav-status` exists in the `.masthead-right` of `/certs` and `/settings`, exactly as it does in the five dashboard pages, so `Dash.initChrome()` builds the four badges (`hosts`/`disks`/`engines`/`UPS`) there without any change to `app.js` — the "disappear when in the certs/account page" symptom is the missing container, and the fix is the container.
## Work
1. `src/dashboards/web/pages/certs.html` — add `<div id="nav-status" class="gap" aria-live="polite"></div>` as the **first** child of `<div class="masthead-right">` (before the `<span class="clock">`), the same position the five dashboard pages use (`index.html:23` is the reference).
2. `src/dashboards/web/pages/settings.html` — the same addition, and delete the `<!-- No `#nav-status`: a session page has no fleet reachability to report, and `Dash.initChrome()` builds the dots only when the container exists. -->` comment — the premise it states is what the operator overruled (the badges report the fleet, and the fleet is the point of the site, on every page).
3. `src/dashboards/web/static/app.js` — **no change** (stated so the executor does not "improve" it): `initChrome()`'s `if (nav)` block already builds the four chips and refreshes them from `/api/health` every 15 s whenever `#nav-status` exists; `setDot`'s `idle` state already covers a dashboard the page's config does not run.
4. `tests/test_api.py` — flip the two pins, one assertion each: in `test_the_settings_page_is_not_one_of_the_dashboards`, `assert 'id="nav-status"' not in html` becomes `in html` (message re-worded: the session page grew the badges the operator asked for); in `test_the_certs_page_is_a_page_and_not_a_sixth_dashboard`, the same flip, and the docstring sentence "no `#nav-status` (a CA page has no fleet reachability to report)" is re-worded to the new fact. `test_the_certs_page_is_a_page_and_not_a_sixth_dashboard`'s other assertions (zero `aria-current`, the tab strip, the head) are untouched, and `tests/test_auth_api.py`'s assertion that **`/login`** has no `#nav-status` is untouched — `/login` still renders the brand link and the theme toggle only.
## Testing & Quality
- Unit/integration: the two flipped pins above; `test_the_header_status_dot_picks_up_the_disks_job` and `test_the_disks_tab_is_in_the_masthead_of_every_page` stay green unchanged
- Coverage: **>90%** on this task's new/modified code (markup + pins)
- The badges populating on the two admin pages is the phase's real-browser check (task 03), run against the live dev fleet so the dots are `ok` green, not grey `idle`
## Completion Criteria
- [ ] `GET /certs` and `GET /settings` (signed in) both contain `<div id="nav-status" class="gap" aria-live="polite">` as the first child of `.masthead-right` (pinned)
- [ ] in a browser, both admin pages show the four badges and they answer the same `ok`/`warn`/`fail` state the dashboard pages show (measured, screenshots in `data/logs/`)
- [ ] `/login` (unsigned) still carries no `#nav-status` (`tests/test_auth_api.py` green)
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] no behavior change in completed work: `initChrome` is byte-identical, and the badge refresh cadence and `idle` semantics are untouched
@@ -0,0 +1,29 @@
# Task 02 — The dots row and the controls row are right-justified
**Phase:** `25_masthead_control_placement` · **Source:** `TODO.md:4` — "I made a mistake earlier, I did want hosts disks engines UPS badges and certs/account/dark right justified don the navbar"
**Story:** n/a
## Objective
Phase 20 task 01 made the two right-hand rows of the masthead flush left; this task moves them flush **right**. The mechanism is two declarations — `justify-content: flex-end` on `#nav-status` and on `.masthead-right` — and nothing else: the row breaks (the two `flex-basis: 100%` items inside the `flex-wrap: wrap` bar), the `min-height`, the gaps and the ≤640px block all stay exactly as they are, so the bar is still three rows at every width and the phone's wrapped control row still wraps *within itself*.
- ASSUMPTION: the three-row shape is preserved at every width and only the alignment changes. A return to the old single-row bar is not the ask — the row breaks are what make the bar fit at 360 px (phase 18/20 measured that), and the operator's word is "right justified in the navbar", not "one row".
## Work
1. `src/dashboards/web/static/dash.css` — two declarations: `#nav-status`'s `justify-content: flex-start` becomes `flex-end`, and `.masthead-right`'s `justify-content: flex-start` becomes `flex-end`. `margin-inline-start: auto` stays **absent** from `.masthead-right` (the old single-row right-pinning is gone; `flex-end` on a full-basis row is the alignment now).
2. `src/dashboards/web/static/dash.css` — re-word the comments that state the old alignment, so a future reader is not sent to a left-justified bar that no longer exists: the header comment block (the "three left-justified rows at every width" sentence, and its row-2/row-3 descriptions), the `Row 3:` comment above `.masthead-right` (which currently cites the old `TODO.md` word for `flex-start` — the new sentence states the operator overruled it: the badges and the cluster are right-justified), the `Row 2:` comment above `#nav-status`, and the ≤640px block's comment "Row 3 alignment is the base rule's `flex-start`, restated nowhere" (now `flex-end`). The `#nav-toggle { margin-inline-start: auto }` line in the mobile block is **unchanged**: the hamburger sits at the right of row 1 (brand + toggle + tabs), which is where a phone's thumb already is — it was never part of the right-justified cluster.
3. `tests/test_api.py` — rename `test_the_masthead_is_three_left_justified_rows_at_every_width` to `test_the_masthead_is_three_rows_right_justified_at_every_width` and change its two alignment assertions from `== "flex-start"` to `== "flex-end"` (the dots line and the cluster line), re-wording the assertion messages and the docstring ("two of them flush left" → "two of them flush right"). Every other assertion in the test stays: `flex-wrap: wrap`, the `flex-direction: row` check, the no-`height`/`min-height: var(--header-h)` pair, the gap, the two `flex-basis: 100%` row breaks, and the "no `margin-inline-start` on the cluster" guard — the row-break mechanism is exactly what must survive the re-justify.
4. `tests/test_api.py` — `test_the_mobile_masthead_block_declares_no_second_bar_height` and `test_the_masthead_scrolls_away_and_stays_the_dropdown_anchor` stay green **unchanged** (neither reads `justify-content`); confirm they do not need a word change in their docstrings before moving on.
## Testing & Quality
- Unit/integration: the re-pinned alignment pin above; the two untouched mobile/sticky pins as the guard that the row mechanism survived
- Coverage: **>90%** on this task's new/modified code (CSS + pin)
- The measured half (the rows actually render right-justified) is the phase's real-browser check (task 03)
## Completion Criteria
- [ ] the served stylesheet reads `justify-content: flex-end` on both `#nav-status` and `.masthead-right`, with `margin-inline-start` still absent from the cluster (pinned)
- [ ] the ≤640px block still declares `#nav-status { flex-wrap: nowrap; }` and the wrapping cluster, and still declares no second bar height (existing pins green)
- [ ] in a browser at 1280 px and 360 px, the dots row and the controls row are flush with the bar's right padding edge and the bar is still three rows (measured, screenshots in `data/logs/`)
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] no behavior change in completed work: the dropdown slide, the hamburger reveal and the dots' content are untouched
@@ -0,0 +1,30 @@
# Task 03 — The clock is the farthest-right item, and the phase's browser proof
**Phase:** `25_masthead_control_placement` · **Source:** `TODO.md:5` — "The time should show up as the farthest right item on the navbar"
**Story:** n/a
## Objective
On a right-justified control row, the farthest-right item is the **last** one in markup order, so the clock moves from the first control (before `⚷ Certs`) to the last control (after the theme toggle). The new order on all eight signed-in pages is `⚷ Certs` → `⚙ Account` → `☾ Dark` → clock. This is the phase's verification task as well: the layout change is CSS + markup, so the served-byte pins cannot say the rows actually render right-justified — this task measures them in a real browser (invariant 10).
- ASSUMPTION: "farthest right" is achieved by markup order (the clock is the last child of `.masthead-right`), not by a CSS `order` declaration — the one-loop order pin compares markup, and a `order:` property the pin cannot see is a drift waiting to happen.
## Work
1. The seven page templates that render `.masthead-right` — `src/dashboards/web/pages/index.html`, `system.html`, `disks.html`, `llm.html`, `ups.html`, `settings.html`, `certs.html` — move the line `<span class="clock" id="clock">--:--:--</span>` from its current position (first control, before the Certs link) to **after** the `<button … id="theme-toggle">` element, so the control row reads `⚷ Certs` → `⚙ Account` → `☾ Dark` → clock. The `<div id="nav-status">` stays the first child (it breaks onto row 2 before the controls). All seven templates must end byte-identical in the control-row region apart from the page-specific comment — `test_every_page_links_to_both_admin_pages` is a one-loop audit precisely because the eight markups are supposed to be the same.
2. `src/dashboards/web/static/dash.css` — re-word the header comment's row-3 line ("row 3 the `.masthead-right` controls (clock, `⚷ Certs`, `⚙ Account`, theme)") to the new order. No declaration changes.
3. `tests/test_api.py` — in `test_every_page_links_to_both_admin_pages`: update the docstring's order sentence (clock → Certs → Account → theme toggle ⇒ `⚷ Certs` → `⚙ Account` → theme toggle → clock); keep the two existing `before_theme` assertions (Certs and Account still sit before the theme toggle, Certs before Account — both true in the new order); add the new one: `html.index('id="clock"') > html.index('id="theme-toggle")` on every page (the clock is the last control — on a `flex-end` row that is the farthest-right item). The `/login` half of the test (no links, no toggle) is untouched.
4. `data/logs/25-task03-verify.cjs` — the browser proof, in the `20-task01-rows.cjs` convention (`NODE_PATH=<playwright node_modules> node data/logs/25-task03-verify.cjs`, `DASH_BASE`/`DASH_PW` env, `check()` accumulator, non-zero exit on any failure): sign in, then on all seven tabbed pages at **1280 px** and **360 px** — (a) group the bar's children into rows by bounding box and assert the three-row shape; (b) the `#nav-status` row and the controls row are flush with the bar's right padding edge (their right edges equal the bar's content right edge within 1 px, and they are **not** flush left — the old layout's right edge was far from the bar's); (c) within the controls row, the clock's right edge is the rightmost of the four controls and the order reads Certs, Account, theme, clock; (d) `#nav-status` on `/certs` and `/settings` is populated (four chips, none `idle`-grey against the live dev fleet — the task 01 check). `/login` (unsigned) at both widths: brand + theme toggle only, no dots, no clock, no horizontal overflow. Screenshots `data/logs/masthead-25-<page>-<width>.png` (the seven tabbed pages at both widths; the script names them, the pass line is every `check()` green).
## Testing & Quality
- Unit/integration: the extended `test_every_page_links_to_both_admin_pages` (the order pin), with `test_the_masthead_is_three_rows_right_justified_at_every_width` (task 02) and the two task-01 flipped pins as the surrounding contract
- Coverage: **>90%** on this task's new/modified code (markup + pin + script)
- Real-browser check: the `.cjs` pass above against the restarted dev server (`./scripts/dev-server.sh restart` first, per invariant 10), the dev fleet answering so the badges are green
## Completion Criteria
- [ ] on all eight signed-in pages the control row reads `⚷ Certs` → `⚙ Account` → `☾ Dark` → clock in the markup, with the clock after the theme toggle (pinned)
- [ ] the `.cjs` pass is all-green at 1280 px and 360 px: three rows, both right-hand rows flush right, clock rightmost, badges populated on `/certs` and `/settings` (screenshots in `data/logs/`)
- [ ] `/login` (unsigned) is unchanged: no badges, no clock, no links (existing `tests/test_auth_api.py` pin green)
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] `AGENTS.md`'s now-stale masthead sentences are updated (the phase overview's Documentation & Consistency list) and `README.md` checked
@@ -0,0 +1,34 @@
# Phase 26 — UPS events: power events collapsible, connection errors always visible
**Source:** `TODO.md` L6 — "The phase I ran earlier collapsed all the events in the UPS tab. That wasn't the point. I wanted the power loss events in a collapsible list, not events like \"NUT server NutError: cannot reach upsd at 10.1.0.126:3051 (timed out after 4s)\" in a collapsible list."
**Story:** n/a
**Context:** phase 19 (complete) put the **whole** `/ups` event feed behind one collapsed `details.events-box` (no `open` attribute, lazy: `refresh()` fetches the feed only while `state.events.open`, the first expand fetches, a fresh re-expand paints from the `<ul>`'s kept children, `painted`/`fetchedAt` stamped on the success path alone, the `Dash.eventsPager` window of 50 inside the bounded 320px scroll). The operator's complaint is that the classification is wrong, not that a collapse exists: a poll failure like `NUT server NutError: cannot reach upsd … (timed out after 4s)` is hidden behind a summary nobody opens, while the power events it was meant to tuck away are the only ones the operator actually wants collapsed. The feed's **kinds** are fixed and known: the poller (`collectors.py:490` — `Job("nut", "ups", "NUT server", …)`, events under the dashboard name `ups`) writes `unreachable` / `reachable` / `recording` / `store-error` (`poller.py:127/157/164/190` — the quoted `NutError` line is the `unreachable` message); the `NutMonitor` transition bookkeeping writes `status` (a state change lands at `collectors.py:393`) and `test` (the self-test verdict, `collectors.py:409`); `api.py` writes `action` for every command sent through `POST /api/ups/command` (the fixed-text `action` events, `api.py:999/1014`). The served-byte pins for the current shape are in `tests/test_api.py`: `test_ups_power_events_feed_sits_behind_a_collapsed_disclosure` (the `details.events-box`, no `open`, `#events` inside), `test_ups_events_are_fetched_only_while_the_disclosure_is_open` (the lazy gate — **this phase inverts it on purpose**), `test_the_ups_event_rows_keep_the_date_time_and_severity_shape` (the row shape), `test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component` (the `details.events-box` CSS shape — unchanged by this phase). The page is `src/dashboards/web/static/page-ups.js` (`refresh()` around line 439, `refreshEvents()` around line 445, the disclosure's toggle handler in the `DOMContentLoaded` block) over the card in `src/dashboards/web/pages/ups.html:75-78`.
## Objective
`/ups` renders **two** lists from the one unchanged feed fetch: the power events — state transitions (including the power-loss transitions), self-test verdicts and sent commands — stay behind the collapsed `details.events-box` disclosure, and the connection/error events (`unreachable`/`reachable`/`recording`/`store-error`) are a card that is **always visible** (hidden only while it has no rows to show). The split is page-side, keyed on the event's `kind`; `/api/events` gains no vocabulary.
## Dependencies
- — (none) — the phase builds on complete phase 19; it is scheduled before `27_mobile_tables_and_audit` on purpose, because that phase's mobile audit captures `/ups` in the shape this phase leaves it in (two cards, one of them hidden-when-empty).
## Tasks
1. `01_page_side_event_split.md` — the `kind` split in `page-ups.js` (power kinds `status`/`test`/`action`, everything else errors) and the removal of the phase-19 lazy gate: the feed is fetched on every poll, because a visible list that ages is worse than one more 200-event fetch per interval
2. `02_render_both_lists_paged.md` — the always-visible `#events-errors` card (above the disclosure, hidden while empty) and both lists paged through the shared `Dash.eventsPager`
3. `03_repin_and_browser_verify.md` — the pin re-pins (the disclosure stays, the lazy gate inverts, the row shape extends to the error list, the kind set is pinned so a silent upstream rename breaks a test) and the phase's real-browser verification
## Testing & Quality
- Unit/integration: the re-pinned and new pins in `tests/test_api.py` (task 03); the split itself is pinned on the served script (the kind set) and the served markup (the error card, `hidden`, not inside a `<details>`)
- Coverage: **>90%** on new/modified code
- Real-browser check (invariant 10): a `data/logs/26-task03-*.cjs` script in the `20-task01-rows.cjs` convention, run against the restarted dev server: the disclosure shut by default, the error card's visibility matching the feed's error-kind rows, both lists' rows matching a direct `/api/events?source=ups&limit=200` split, screenshots in `data/logs/`
## Documentation & Consistency
- The final pass updates the now-stale sentences in `AGENTS.md`: the event-feed paragraph's lazy-fetch block ("`refresh()` fetches the feed only while `state.events.open` is true, so a card nobody is looking at costs the poll loop nothing" and the first-expand/fresh-re-expand sentences) is re-written to the new fact (the one fetch serves two lists, always; the disclosure gates only the power list's visibility), and the pin description of `test_ups_events_are_fetched_only_while_the_disclosure_is_open` is re-pointed at its inverted successor. `README.md` is checked for the same sentence; none is expected.
## Completion Criteria
- [ ] a poll failure (e.g. `NUT server NutError: cannot reach upsd …`) is visible on `/ups` without opening anything, and the power events stay behind the collapsed disclosure (pinned + verified in a browser)
- [ ] the error card is hidden while it has no rows and appears with its first error row; both lists share the one `/api/events?source=ups` fetch and the shared pager (pinned)
- [ ] a failed fetch leaves both lists on their last good rows (the stale-last-good rule) — pinned where phase 19 pinned its
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] no behavior change in completed work: the `details.events-box` CSS shape, the pager's page/scroll semantics, and the row's time/host/severity shape are untouched
@@ -0,0 +1,28 @@
# Task 01 — The feed splits on `kind`, and the lazy gate comes out
**Phase:** `26_ups_event_split` · **Source:** `TODO.md:6` — "I wanted the power loss events in a collapsible list, not events like \"NUT server NutError: cannot reach upsd at 10.1.0.126:3051 (timed out after 4s)\" in a collapsible list."
**Story:** n/a
## Objective
`page-ups.js` splits the one unchanged feed fetch into two lists: the **power** events — `status` (the state transitions, including the power-loss transitions the operator names), `test` (the self-test verdicts) and `action` (the commands sent through the confirm dialog) — and **everything else** (the poller's `unreachable`/`reachable`/`recording`/`store-error`, the quoted `NutError` line included), which is the always-visible error list. The phase-19 lazy gate goes out with the split: a visible list that ages is worse than one more 200-event fetch per interval, so `refresh()` fetches on every poll.
- ASSUMPTION: "power events" is the **known** set of three kinds (`status`/`test`/`action`); any other kind is an error-row by default, because the operator's complaint is that errors must never be the hidden list, and a new kind landing in the hidden list would be the same bug re-shipped.
## Work
1. `src/dashboards/web/static/page-ups.js` — at the top of the file, `const POWER_EVENT_KINDS = new Set(["status", "test", "action"]);` with a comment naming the three writers (`collectors.py:393` `status`, `collectors.py:409` `test`, `api.py` `action` — so a rename on the source side is visible at the site of the drift). In `refreshEvents()`: after the one `fetchJSON(\`/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=ups\`)` (the call itself is **unchanged** — no `page`/`limit`/`kind` vocabulary, the phase-19 rule), `const power = data.events.filter((e) => POWER_EVENT_KINDS.has(e.kind));` and `const errors = data.events.filter((e) => !POWER_EVENT_KINDS.has(e.kind));`.
2. `src/dashboards/web/static/page-ups.js` — in `refresh()`, replace `if (state.events.open) await refreshEvents();` with `await refreshEvents();` and re-word the comment above it (the "a shut `<details>` must cost the poll loop nothing" rationale is what the operator overruled: the error list is visible, so the feed is needed whether or not the disclosure is open). In the `DOMContentLoaded` block, delete the `eventsBox` toggle handler's fetch logic — with an always-fresh list the `toggle` event no longer needs to do anything, and `state.events` (with its `open`/`painted`/`fetchedAt` fields) is deleted entirely: the disclosure now gates only the power list's **visibility** (the native `<details>` behaviour), and a failed fetch leaves both lists on their last good rows through `refreshEvents()`'s existing `try { … } catch (err) { /* next poll */ }` — the success-path stamps go away with the state they stamped, and the stale-last-good rule is the `catch` block, which stays.
3. `src/dashboards/web/static/page-ups.js` — the split is the **only** change to the fetch path: the `row` renderer, `eventText` handling and the `runtime_seconds` footnote stay byte-identical (task 02 points them at both lists), and the `state` object loses `events` with no other field touched.
## Testing & Quality
- Unit/integration: the split and the gate removal are pinned on the served script in task 03 (`tests/test_api.py`) — the kind set, the unconditional `refreshEvents()` call, the deleted `state.events`
- Coverage: **>90%** on this task's new/modified code
- The behavioural proof (a real `unreachable` row visible without opening the disclosure) is the phase's real-browser check (task 03)
## Completion Criteria
- [ ] `page-ups.js` defines `POWER_EVENT_KINDS = new Set(["status", "test", "action"])` and `refresh()` calls `refreshEvents()` unconditionally (pinned on the served script)
- [ ] `state.events` and the toggle handler's fetch logic are gone; a failed fetch leaves both lists on their last good rows (pinned where phase 19 pinned its)
- [ ] the `/api/events` call is byte-identical to the phase-19 one (no new query vocabulary — pinned)
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] no behavior change in completed work: the poll cadence, the confirm dialog, the hero row and the charts are untouched
@@ -0,0 +1,31 @@
# Task 02 — The error card: always visible, hidden only when empty
**Phase:** `26_ups_event_split` · **Source:** `TODO.md:6` — "I wanted the power loss events in a collapsible list, not events like \"NUT server NutError: cannot reach upsd at 10.1.0.126:3051 (timed out after 4s)\" in a collapsible list."
**Story:** n/a
## Objective
The error list is a card of its own, **above** the power-events card, built from the existing vocabulary (`section.card`, `ul.events`, the pager's strip) with no new CSS component: visible from first paint while it has rows, `hidden` while it has none, and paged by the same `Dash.eventsPager` the power list uses (its `WeakMap` state is keyed by the `<ul>`, so the two lists page independently — the pager's existing contract).
- ASSUMPTION: the error section sits **above** the Power events disclosure — the visibility complaint is about *seeing* the errors, and the collapsible list keeps its current position in the card stack.
- ASSUMPTION: the error card is `hidden` while it has zero rows — a permanent empty "Connection & errors" card is the noise the page's empty-state rules exist to avoid, and its first `unreachable` row is what makes it appear.
## Work
1. `src/dashboards/web/pages/ups.html` — above the existing `<section class="card" style="margin-top:.85rem">` (the disclosure card, `ups.html:74-79`), add the error card: `<section class="card" id="ups-errors-card" hidden>` with `<header><h3>Connection &amp; errors</h3><span class="spacer"></span><span class="faint">upsd reachability and this process's own failures</span></header>` and `<div class="body"><ul class="events" id="events-errors"><li class="muted">loading…</li></ul></div>`. The `hidden` attribute is the resting state (before the first successful fetch there are no rows); `style="margin-top:.85rem"` moves to the **error** card's position only in the sense that the disclosure card keeps its existing attribute — a `hidden` card is `display: none`, so the disclosure card's own `margin-top` still gives the gap to whatever is above it when the error card is absent, and no rule is needed for the two-cards case beyond the disclosure card's existing attribute (verify in the browser that the gap is `.85rem` in both states; if the hidden card's own margin would double it, drop the error card's `margin-top` and let the stack's own gap rule do the work — the page's sections carry the shared `.85rem` gap).
2. `src/dashboards/web/static/page-ups.js` — in `refreshEvents()`, after the split (task 01): render the power list exactly as today — `Dash.eventsPager($('#events'), power, { label: 'Power events', … })` with its existing `empty` sentence and `row` renderer — and the error list — `Dash.eventsPager($('#events-errors'), errors, { label: 'Connection & errors', empty: '<li class="muted" style="grid-template-columns:1fr">No connection errors — upsd is answering and this process is recording.</li>', row: <the same `row` function as the power list, extracted to one const so the two lists cannot drift> })`; then `$('#ups-errors-card').hidden = errors.length === 0;`. The `loading…` seed row in the error card is overwritten on the first successful fetch and never shows, because the card is `hidden` until that fetch lands with rows (a first fetch with zero error rows keeps it hidden — no flash).
3. `src/dashboards/web/static/dash.css` — **no new rules**: the card, the `ul.events` bounded 320px scroll and the `.events-pager` strip are all existing vocabulary (the phase-19 rule a second opinion about the driver's word is the bug — the same applies to a second style for a second list). If step 1's gap verification needs a rule, the only allowed one is a margin on `#ups-errors-card` itself, pinned in task 03.
4. The `row` renderer's `runtime_seconds` footnote stays on both lists (an `action` row's runtime is the power list's; an error row simply has no `event.data.runtime_seconds` and prints none — no branch added).
## Testing & Quality
- Unit/integration: task 03's pins — the error card in the served markup (`hidden`, not inside a `<details>`), the kind set, the single `row` const, the `hidden = errors.length === 0` line
- Coverage: **>90%** on this task's new/modified code
- The two-cards layout, the gap in both states and the error card's first-appear are the phase's real-browser check (task 03)
## Completion Criteria
- [ ] `/ups` shows the `Connection & errors` card above the Power events disclosure, paged, sharing the row shape (pinned + measured in a browser)
- [ ] the card is `hidden` while the feed has no non-power kinds and appears with its first error row, the gap to the disclosure card being `.85rem` in both states (measured)
- [ ] both lists page independently (pager state per `<ul>`) and a failed fetch leaves both on their last good rows (pinned)
- [ ] no new CSS selector is introduced for the error card (pinned)
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] no behavior change in completed work: the disclosure's CSS shape and the power list's empty sentence are untouched
@@ -0,0 +1,28 @@
# Task 03 — The pins re-pin, and the browser proves the split
**Phase:** `26_ups_event_split` · **Source:** `TODO.md:6`
**Story:** n/a
## Objective
The phase's pins: the disclosure shape stays pinned (the power list is still behind the collapsed `details.events-box`), the lazy-gate pin inverts to its replacement (the fetch is no longer gated on the disclosure), the row-shape pin extends to the error list, and the kind set is pinned on the served script so a silent rename on the source side breaks a test before an operator sees a misclassified row. Then the real-browser verification (invariant 10).
## Work
1. `tests/test_api.py` — `test_ups_power_events_feed_sits_behind_a_collapsed_disclosure`: keep every existing assertion (one `details.events-box`, no `open` attribute, `#events` inside it) and extend: `id="events-errors"` is present in the served page and is **not** inside any `<details>` (the error list must not be collapsible — the operator's whole complaint), and the error card carries the `hidden` attribute in the template.
2. `tests/test_api.py` — `test_ups_events_are_fetched_only_while_the_disclosure_is_open` becomes its inversion, renamed `test_ups_events_are_fetched_on_every_poll` (the phase-19 lazy gate is what the operator overruled; the old test failing is the point of this phase): the served script's `refresh()` region (the same `boot.index("…")` slice the old test uses) shows `refreshEvents()` called **unconditionally**, and the served script carries no `state.events` and no `toggle`-handler fetch. The stale-last-good pin (a failed fetch leaves the rows on screen) moves onto the `refreshEvents()` `catch` block, the way phase 19 pinned it.
3. `tests/test_api.py` — `test_the_ups_event_rows_keep_the_date_time_and_severity_shape`: the existing `#events` assertions stay, and the same shape (the `<time>` element, the host chip with its `sev` class, the `eventText`-cleaned message, the optional runtime footnote) is asserted for `#events-errors` through the shared `row` const. New pin: the served script defines `POWER_EVENT_KINDS` as exactly `status`, `test`, `action` (a set literal read out of the script text — a kind added to or removed from the set is a classification the operator did not make). New pin: `$('#ups-errors-card').hidden = errors.length === 0` (or its exact equivalent line) — the hidden-when-empty behaviour is the one state this page may claim about a list it cannot see.
4. `tests/test_api.py` — `test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component`: unchanged, and extended with one guard — no new CSS selector for the error card in the served stylesheet (the card is `section.card` + `ul.events` + `.events-pager` vocabulary only; a second style for a second list is the phase-19 anti-pattern).
5. `data/logs/26-task03-verify.cjs` — the browser proof, in the `20-task01-rows.cjs` convention (`DASH_BASE`/`DASH_PW`, `check()` accumulator, non-zero exit on failure), against the restarted dev server (`./scripts/dev-server.sh restart` first): sign in; fetch `/api/events?source=ups&limit=200` **directly** and split by the three power kinds; load `/ups` and assert — (a) the disclosure is shut by default and the power list renders only on expand; (b) the error card is visible if and only if the direct fetch has non-power-kind rows; (c) each list's DOM row count and first row's text match the direct-fetch split (the `eventText` cleaning applied); (d) a failed-fetch leg is not needed against live hardware — the stale-last-good rule is pinned in step 2's pin instead. Screenshots `data/logs/ups-event-split-<state>.png` (the error card visible with rows, and hidden when the feed has none — if the live feed has no error rows, the hidden state is the one measured, and the visible state is measured by the DOM from the direct-fetch split, not by staging rows).
## Testing & Quality
- Unit/integration: the pins of steps 1–4
- Coverage: **>90%** on this task's new/modified code
- Real-browser check: step 5's `.cjs` pass, screenshots in `data/logs/`
## Completion Criteria
- [ ] the four pin groups above are green, with `test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component` passing unchanged in its original assertions
- [ ] the `.cjs` pass is all-green against the live dev server (screenshots in `data/logs/`)
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] `AGENTS.md`'s now-stale lazy-fetch sentences are updated (the phase overview's Documentation & Consistency list) and `README.md` checked
@@ -0,0 +1,33 @@
# Phase 27 — Mobile: every table scrolls, and the whole app is audited at phone width
**Source:** `TODO.md` L7 — "None of the tables are horizontally scrollable on mobile. Fix that and make sure everything works on mobile. Do a full mobile compatible audit."
**Story:** n/a
**Context:** AGENTS.md gap 12 (measured on 2026-10-20): at 360 px, `/`'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 (`dash.css:884`: `display: block; overflow: auto; max-height: 320px`), which **no page currently uses** (phase 21's `.scroll` on the `/certs` table is still in `todo/` and has not landed). Gap 12 names the fix as "a responsive-table pass (`.scroll` on the wide tables, or a per-host card layout at ≤640px)" and records it as a phase, not a cleanup, because it touches pinned page shape on `/`. The table inventory is fixed and small: two tables in static markup (`index.html:61` `#fleet-table`, `certs.html:85` `#agents-table`) and five in JS-built markup (`page-system.js:194` the GTt table, `:257` the filesystems table, `:269`/`:274` the two top-process tables, all inside the "More detail" disclosure; `page-ups.js:278` the raw NUT-variables table, inside its disclosure). Phase 18's standing guarantee — the document no longer scrolls sideways at a phone width (360 px where it was 750 px) — must survive this phase, so the fix is **inside** the tables, never the document. The ≤640px breakpoint is the repo's mobile boundary (the hamburger, the bar's row-breaks). The browser-proof convention is the `data/logs/*.cjs` scripts (`20-task01-rows.cjs` is the template: `NODE_PATH=<playwright node_modules> node …`, `DASH_BASE`/`DASH_PW`, a `check()` accumulator, non-zero exit on failure, screenshots in `data/logs/`). Phase 26 (scheduled before this one) is what the audit will capture on `/ups`: two cards, the error one hidden-when-empty.
## Objective
Every `table.data` on every page scrolls **horizontally inside its own container** when it is wider than the card (a phone's 320 px), with no new document-level sideways scroll, and the whole app — all eight signed-in pages plus `/login` — is audited at 360 px (1280 px as the control) by a re-runnable script whose pass is the phase's proof.
## Dependencies
- `26_ups_event_split` (todo) — the audit captures `/ups` in the shape 26 leaves it in (the two cards and the hidden-when-empty error card are what the 360 px pass must not clip); running 27's audit before 26 lands would measure the pre-split single card.
## Tasks
1. `01_scrollable_tables_on_mobile.md` — the one ≤640px rule that makes every `table.data` horizontally scrollable (horizontal-only, no height cap), pinned on the served CSS
2. `02_mobile_audit_script.md` — `data/logs/27-task02-mobile-audit.cjs`: the re-runnable sweep of all nine pages at 360 px and 1280 px — no horizontal document scroll, no clipped table (the gap-12 failure mode), the masthead intact, screenshots
3. `03_fix_mobile_issues_found.md` — run the audit, fix every finding, re-run until every page passes both widths; the final screenshot set is the phase's proof
## Testing & Quality
- Unit/integration: the served-CSS pin (task 01); the audit script itself is the integration evidence (tasks 02–03) — the repo has no JS test runner, so a `.cjs` pass with screenshots is the established browser-proof shape (invariant 10)
- Coverage: **>90%** on new/modified code (the new code is the pin; the change is one CSS rule)
- Real-browser check (invariant 10): the task-02 script, run against the restarted dev server, is the phase's verification — not an add-on to it
## Documentation & Consistency
- The final pass resolves AGENTS.md **gap 12**: the clipping paragraph becomes history ("the gap-12 clipping is closed by `27_mobile_tables_and_audit`: every `table.data` scrolls horizontally inside its own container at ≤640px, measured by `data/logs/27-task02-mobile-audit.cjs`"), the "a phase rather than a cleanup" sentence goes with it, and the mobile-nav paragraphs that quote the 360 px document width are checked for consistency with the audit's no-document-scroll assertion. `README.md` is checked for the same sentence; none is expected.
## Completion Criteria
- [ ] at 360 px, every `table.data` wider than its container scrolls horizontally inside the container — the gap-12 fleet-table clipping is gone (pinned + measured by the audit)
- [ ] the audit pass is all-green on all nine pages at 360 px and 1280 px: no horizontal document scroll (phase 18's guarantee intact), masthead intact, screenshots in `data/logs/`
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] no behavior change in completed work: at 1280 px the tables render exactly as before (no scrollbar where the table fit, no layout shift)
@@ -0,0 +1,30 @@
# Task 01 — Every table scrolls horizontally at the mobile breakpoint
**Phase:** `27_mobile_tables_and_audit` · **Source:** `TODO.md:7` — "None of the tables are horizontally scrollable on mobile. Fix that…"
**Story:** n/a
## Objective
One ≤640px rule makes **every** `table.data` horizontally scrollable inside its own card when it is wider than the card, with no per-table markup change: the five JS-built tables and the two static ones all carry the class already, so a class-level rule reaches all seven at once. The mechanism is the one the existing `.scroll` modifier proves — `display: block` keeps the table's internal layout in an anonymous box, `overflow-x: auto` gives a scrollbar only where the table overflows.
- ASSUMPTION: the rule is **horizontal-only** (`overflow-x`, not `overflow: auto`) and carries **no** `max-height` — the `.scroll` modifier's 320px height cap is its vertical-use contract, and a phone getting a height-capped table on top of a horizontal one is two new failure modes bought to fix one.
- ASSUMPTION: the rule lives in the existing `@media (max-width: 640px)` block (the repo's mobile boundary, the same block the hamburger and the bar's gap live in), applied to `table.data` generally rather than adding the `.scroll` class per table — the per-table class path is what gap 21 does for one table, and seven hand-placed classes are the drift this phase exists to avoid. The all-widths `table.data.scroll` rule (`.css:884`) is untouched.
- ASSUMPTION: the `.scroll`-shape pass is taken over gap 12's alternative (a per-host card layout at ≤640px) — a re-layout of `/`'s fleet table into cards touches pinned page shape far more than an overflow rule does, and the operator's word is "horizontally scrollable", which is the `.scroll` shape.
## Work
1. `src/dashboards/web/static/dash.css` — inside the existing `@media (max-width: 640px)` block (the "mobile nav" section, beside the `.masthead`/`#nav-status` mobile rules), add: `table.data { display: block; overflow-x: auto; }` with a comment stating the contract: horizontal-only, no height cap, every `table.data` (static or JS-built) at the phone breakpoint, the document itself never scrolling sideways (phase 18's guarantee) and the all-widths `table.data.scroll` rule untouched.
2. `src/dashboards/web/static/dash.css` — verify the rule reaches the tables inside disclosures: the "More detail" tables (`page-system.js:194/257/269/274`) and the NUT-variables table (`page-ups.js:278`) sit in `display: none` containers until expanded; `overflow-x: auto` on a `display: block` table inside a revealed `details` is the same box model (no extra rule needed — but the audit's task-02 expanded-disclosure leg is what proves it, so do not skip expanding those two disclosures in the script).
3. `tests/test_api.py` — pin the served CSS the way `test_the_mobile_masthead_block_declares_no_second_bar_height` reads its block: in the ≤640px block, `table.data` declares `display: block` **and** `overflow-x: auto`, and declares **no** `max-height` (the height cap must not migrate into the mobile rule). The all-widths `table.data.scroll` rule's declarations are asserted unchanged in the same test.
## Testing & Quality
- Unit/integration: the served-CSS pin above
- Coverage: **>90%** on this task's new/modified code (one rule + pin)
- The behavioural proof (the gap-12 table actually scrolls at 360 px) is the task-02 audit
## Completion Criteria
- [ ] the ≤640px block declares `table.data { display: block; overflow-x: auto }` with no `max-height`, pinned on the served stylesheet
- [ ] the `table.data.scroll` rule (`.css:884`) is byte-identical to before
- [ ] at 360 px in a browser, `/`'s fleet table scrolls horizontally inside its card (the tail columns `WORST DISK`/`GPU BUSY`/`TEMP`/`UP` reachable) and the document does not scroll sideways (measured by the task-02 audit)
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] no behavior change in completed work: at 1280 px no table shows a scrollbar and no layout shifts (measured by the audit's control width)
@@ -0,0 +1,32 @@
# Task 02 — The re-runnable mobile audit script
**Phase:** `27_mobile_tables_and_audit` · **Source:** `TODO.md:7` — "…make sure everything works on mobile. Do a full mobile compatible audit."
**Story:** n/a
## Objective
`data/logs/27-task02-mobile-audit.cjs` — the full mobile compatibility audit as a re-runnable script in the repo's established convention (`20-task01-rows.cjs` is the template: `NODE_PATH=<playwright node_modules> node data/logs/27-task02-mobile-audit.cjs`, `DASH_BASE`/`DASH_PW` env with the dev defaults, a `check(name, ok, detail)` accumulator, non-zero exit on any failure). It sweeps **all nine pages** — the eight signed-in pages (`/`, `/system`, `/disks`, `/llm`, `/ups`, `/settings`, `/certs`) plus `/login` (unsigned, the only anonymous page) — at **360 px** (the phone width gap 12 measured at) with **1280 px** as the control, and its all-green pass is the phase's proof.
## Work
1. `data/logs/27-task02-mobile-audit.cjs` — setup: sign in at `DASH_BASE` (the `20-task01-rows.cjs` sign-in pattern, `testpass` default), then per page × width, the check set:
- **No horizontal document scroll** (phase 18's standing guarantee — this phase must not break it): `document.documentElement.scrollWidth <= window.innerWidth + 1` and `document.body.scrollWidth <= window.innerWidth + 1`.
- **No clipped table** (the gap-12 failure mode, the one this phase exists for): for every `table.data`, if `el.scrollWidth > el.clientWidth + 1` then the computed `overflow-x` is `auto` or `scroll` (the table scrolls **inside** its own container); a wide table whose `overflow-x` is `visible` (clipped by the card's `overflow: hidden`) fails with the table's id. At 1280 px the same check runs as the control (a desktop table that overflows — none should — would fail there too).
- **Every element fits the viewport**: a generic overflow scan — no element with `getBoundingClientRect().right > window.innerWidth + 1` other than a `table.data` inner box already accounted for (a hero row, chart or card spilling off the phone is the "make sure everything works on mobile" half the table check does not name).
- **Masthead intact** (360 px leg): the three-row shape by bounding-box grouping (the `20-task01-rows.cjs` `PROBE()`), every control's box inside the viewport, and the ≤640px dropdown still opening directly under the bar (open `#nav-toggle`, measure `nav.tabs`'s top against the bar's bottom, close it).
- **Expanded-disclosure leg** (360 px): on `/system` expand one host's "More detail" `details` and on `/ups` open the NUT-variables disclosure, re-run the table check on the newly revealed tables (the `display: none` containers of task 01's step 2), then close them.
- **`/ups` two-card leg** (360 px, phase 26's shape): the error card (when present) and the disclosure card both fit, the error card's `hidden` state matching the feed as task 26 pinned.
- Screenshots: `data/logs/mobile-27-<page>-<width>.png` per page × width (nine × two = eighteen, plus the two expanded-disclosure states), each `check()` line printed and accumulated.
2. `data/logs/27-task02-mobile-audit.cjs` — the 1280 px control leg doubles as the "no behavior change" proof: the same element-fits and table checks at desktop width, so a rule that shifts a desktop table (a spurious `display: block` at 1280 px, a layout shift) fails the control as loudly as the phone leg fails the phone.
3. The script exits non-zero on any failure and prints the failure lines (the `20-task01-rows.cjs` convention); it is **run twice** as part of this task: once as written (expecting the pre-task-03 findings, if any, to surface) and once after task 03's fixes — the first run's output is the findings list task 03 works from, and both runs' output is the phase's evidence. (The script is in gitignored `data/logs/` — evidence, not a tracked pin; the tracked pin is task 01's served-CSS one.)
## Testing & Quality
- Unit/integration: the script's check set is the integration evidence; task 01's served-CSS pin is the tracked half
- Coverage: **>90%** on new/modified code (the script is evidence, not shipped code — no coverage line of its own, the pin carries the gate)
- Real-browser check: this **is** the real-browser check (invariant 10), run against the restarted dev server (`./scripts/dev-server.sh restart` first) with the live fleet answering so the tables have real rows
## Completion Criteria
- [ ] the script exists, runs in the documented one-liner, signs in, sweeps nine pages × two widths, and exits non-zero on any failure
- [ ] its check set covers: document scroll, per-table clipping (the gap-12 mode), the generic element-fits scan, the masthead + dropdown at 360 px, the two expanded-disclosure legs, and the `/ups` two-card leg
- [ ] the first run's output is the findings list (task 03's input) and its screenshots are in `data/logs/`
- [ ] `uv run pytest` green, `ruff` + `pyright` clean (the script is `node --check`-clean — `bash .agents/validate.sh`'s node check covers the `web/static` scripts, and this one is checked by hand as part of the run)
@@ -0,0 +1,33 @@
# Task 03 — Fix every finding until the audit is all-green at both widths
**Phase:** `27_mobile_tables_and_audit` · **Source:** `TODO.md:7` — "…make sure everything works on mobile. Do a full mobile compatible audit."
**Story:** n/a
## Objective
The audit's first run (task 02) is the findings list; this task closes it. Every finding is a CSS/markup fix — the data layer, the API and the page scripts' logic are out of scope (a table that does not fit is a layout fact, and the fix is the layout, never the data) — and the phase is done when the audit passes **every page at both widths** with the screenshots in `data/logs/`.
## Work
1. Run the task-02 script against the restarted dev server and work the findings list top down. The expected findings, in the order the audit surfaces them:
- **`/` fleet table** (gap 12, the one the phase exists for): task 01's rule is the fix — verify the tail columns (`WORST DISK`, `GPU BUSY`, `TEMP`, `UP`) are reachable by scrolling the table, not the document. If the rule is already in place from task 01 and the table still clips, the card's `overflow: hidden` is clipping the table's **scrollbar track**: the fix is `table.data` keeping `display: block; overflow-x: auto` (task 01's pin) — a `card`-level change is the fallback, and it needs a pin of its own if used.
- **`/certs` agents table**: same rule, same check; note phase 21 (still `todo/`) is scheduled to add the `.scroll` class to this table — this task's rule makes the class redundant at ≤640px but harmless at all widths, so 21 landing later is a no-op collision, not a conflict (state that in the fix note if the table is already covered).
- **`/system` "More detail" tables** (filesystems, top processes, GTt): the expanded-disclosure leg of the audit; a table inside the `details` that still clips gets the same class-level fix (no per-table markup — the class is already there).
- **`/ups` raw NUT-variables table**: same, through its disclosure.
- **Anything the generic element-fits scan names that is not a table** (a hero row, a chart, a card spilling off the phone): fix the specific element — the fix is named per element in the findings note, and a fix that hides a control to make the width work is a refusal (the phase-18 rule: nothing is hidden to make the arithmetic work).
2. `tests/test_api.py` — for every finding whose fix is a **markup** change (an `id` added, a wrapper introduced, a class added to a static table), add the served-byte pin in the same pass; a CSS-only fix is covered by task 01's pin. No pin is added for a fix that only touches `data/logs/` evidence.
3. Re-run the task-02 script until **every check on all nine pages passes at 360 px and 1280 px**; the final run's output and its eighteen-plus screenshots are the phase's proof (the final-run screenshot set supersedes the first run's in `data/logs/`).
## Testing & Quality
- Unit/integration: task 01's served-CSS pin + any new markup pin from step 2
- Coverage: **>90%** on new/modified code
- Real-browser check: the final all-green audit run (invariant 10), the screenshots in `data/logs/`
## Completion Criteria
- [ ] the audit is all-green on all nine pages at 360 px and 1280 px, with the final-run screenshots in `data/logs/`
- [ ] every finding from the first run is closed by a named fix (CSS/markup only) or a stated refusal (a control the operator needs, not hidden)
- [ ] the gap-12 fleet-table clipping is gone: the tail columns reachable by the table's own scrollbar, the document not scrolling sideways (measured)
- [ ] `AGENTS.md` gap 12 is resolved per the phase overview's Documentation & Consistency list, and `README.md` checked
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] no behavior change in completed work: the 1280 px control leg is all-green (no desktop layout shift, no spurious scrollbars)