HOMELAB-1682: feat(cockpit-web): LD3.2b — the three panes (gates queue, fleet overview, director chat) + STOP #578

Merged
aaron merged 10 commits from plane/HOMELAB-1682-three-panes into epic/LD3 2026-10-02 07:07:35 +00:00
Owner

Slice LD3.2b (HOMELAB-1682) of epic LD3 (Lab Director Cockpit): the three panes. Class D (ui-craft bar + visual-verifier at landing), frontend-only: lab-director/cockpit/web/**.

Design authority: .rpiv/artifacts/designs/ld3/cockpit-design-brief.md (R4 honest state, R5 gates, R8 STOP, R11 class D) and .rpiv/artifacts/designs/homelab-1654/project-deployments-and-cockpit.md §4.2 (Fleet Overview + Gates Queue mocks), §5.2 (derived-state visuals), §5.4 (STOP). Quality bar: the ui-craft skill.

Base note (read this first). This branch is based on origin/epic/LD3 = the 3.1a merge. The 3.2a backend (PR #572) — the /api/v1/gates, /api/v1/overview and /api/v1/fleet/stop endpoints this frontend codes against — is not yet on epic/LD3. Consequence: the new e2e specs need those routes, so the cockpit-e2e CI job goes green only once #572 lands. My local e2e run used the suite's own documented E2E_COCKPIT_BIN hook with a binary built from plane/HOMELAB-1682-gates-backend @ b3c1bec (the tip whose CI is green). I will merge epic/LD3 into this branch and re-run as soon as #572 lands; no Go file is touched by this PR.

What

1 · Gates Queue — the centerpiece (§4.2, R5). Rows carry title/ticket, class + state chips, the recommendation, the exposure bound split into blast and recovery, the options offered, the expiry as a countdown (23h 47m left, or no expiry — never an invented deadline), the owner and the gate nonce. ratify / defer / veto open a confirmation dialog that restates the exposure and requires a reason (the backend refuses a reasonless disposition under P-GATE-002, so the confirm is disabled until it is written); the POST is a real request and its outcome lands on the row it was about.

  • Class E renders ratify/defer, never a veto and never an auto-path. The vocabulary is closed in the store: decisionsFor() intersects the payload with ['ratify','defer','veto'] and drops veto for class E, so a payload that claimed otherwise cannot produce a control. A class-E ticket with no gate record renders no controls at all — just the sentence explaining that a decision package has to be raised first.
  • Unwired is a status, not an empty list. "gates unwired — Plane reader token lands with LD3.1b", the backend's reason verbatim, and the missing key as a chip (PLANE_PROJECT_ID). The header shows "count not measured", never 0 need you.
  • The write posture is stated up front: while the disposition write seam is unwired the pane says a decision will be refused, and the dialog repeats it — so the 503 is expected rather than discovered. The refusal renders verbatim with its kind, policy line and the credential that would wire it.

2 · Fleet Overview — the daily read (§4.2, R4). Three card groups (homelab / fleet / updates) fed by /api/v1/overview + the read model, one scroll:

  • every count goes through one cell component: a measured number, or a hatched ? when the backend did not measure it — no fabricated zeros anywhere (P-COCKPIT-005). The homelab group carries the measured ticket census (state-group counts) once Plane is wired, and a hatched sentence when it is not.
  • the wiring census stays where 3.1a put it, as named rows (plane / gates / dispatcher / bus), each with its state, its sentence and what would wire it.
  • derived-state vocabulary (§5.2): PARKED / HELD / DONE-UNSEEN / unknown in a legend, and the sandbox table skeleton whose only row is the honest unknown (hatched, data-tone="idle" — never rendered as healthy) with the dependency named. The table is a skeleton on purpose: LD3.4 (HOMELAB-1684) fills it when the sandbox census exists.
  • a compact project switcher (Homelab / Sendrast / ApexDirector) with the per-project structure 3.4 fills.
  • the El Director digest line, which says "no digest — LD4.3 (HOMELAB-1687)" and offers open chat.

3 · Director chat. Text-first: message log + composer, the transport seam typed as ChatTransport.send(message), and the shipped implementation refuses — a typed message is recorded locally as not sent with the reason and the ticket that fills it (LD4.3). No fake bot, no simulated reply; the empty state says so in words.

4 · Header STOP (§5.4, R8). ⏹ always visible → confirmation dialog (fleet scope, what a stop actually does: claim minting stops, process groups SIGSTOP, bus steer/ask suspended; plus a preview of the ledger entry) → POST /api/v1/fleet/stop → the 503 "sentinel not armed" rendered as an amber banner (not error-red: nothing broke, the arm is not fitted), with the audit entry echoed back — schema, outcome, policy lines, principal, verified: no — dev identity, request id, and whether the ledger actually recorded it. The button is real; the arm is not yet.

5 · Pane navigation. A tablist under the header; the fleet overview is the default (the operator's daily read). All three panes stay mounted with the inactive two hidden, so the gates list and the chat draft survive a glance elsewhere and the shell's structure stays stable across routes (the 3.1a offline spec's three [data-testid=pane] still holds). Arrow/Home/End keyboard navigation, aria-selected/aria-controls wired. The 3.1a identity chip, connection dot, dev badge, PWA and service worker are untouched.

Why

The cockpit's whole justification is that an operator can trust it at a glance: "no row ever shows a state the telemetry can't back". This slice is where that rule stops being a sentence in a design document and becomes the thing the code does — every unwired source names its key or ticket, every unmeasured count is absent rather than zero, unknown is a first-class hatched state, and the two irreversible actions (a disposition, a fleet stop) each stop at a confirmation that restates the consequence. It remains a reader: no store, no local mutation of the queue, and no path by which the cockpit ratifies anything on its own.

Test plan (all run locally; evidence pasted)

Suite Command Result
Types npm ci && npx svelte-kit sync && npx svelte-check --tsconfig ./tsconfig.json 0 errors / 0 warnings
Unit npm run test 137 passed / 18 files (3.1a baseline: 60 / 8 — +65)
Build npm run build static SPA written to build/
E2E npm run e2e (real binary + real build, dev mode) 43 passed (3.1a baseline: 25 — +18)

Local e2e was run against the 3.2a backend binary (E2E_PREBUILT=1 E2E_COCKPIT_BIN=/tmp/ld-cockpit-3.2a, built from plane/HOMELAB-1682-gates-backend @ b3c1bec), because epic/LD3 does not carry those routes yet. Everything else (types, unit, build) runs against the branch as-is.

Manually verified in a browser at 1440 and 390px (evidence below): all three panes, both the unwired and the configured gates queue, the ratify dialog, the refused disposition on its row, the chat send refusal, the STOP dialog and the not-armed banner. scrollWidth ≤ clientWidth at 390px on every pane; every control clears the 24px target floor; the responsive spec (overflow + targets + 4.5:1 contrast at 390 and 1440) stays green.

Screenshots

Attached to this PR (see the evidence comment below): dashboard-unwired, dashboard-measured, gates-unwired, gates-configured, gate-ratify-dialog, gates-refused, chat, chat-not-sent, stop-dialog, stop-not-armed — for each of the three panes at 1440 and 390px.

Slice boundary (hard)

In: lab-director/cockpit/web/** only — new stores (gates, overview, stop, chat, panes), new components (gates/, dashboard/, chat/, shell/), the confirm dialog primitive, the LD3.2 wire types + client methods, honest-state.ts, one new status tone, the page/layout/header wiring, 65 unit tests and 18 e2e specs.

Out (untouched): no Go file (not one line), no CI workflow, no prod/**, no dispatcher, no manifests, no secrets. No API shape was invented — every field read here is a field 3.2a emits.

Retired: src/lib/components/shell/pane.svelte + its test. The 3.1a placeholder frame ("no data in this slice" + an owner line) is superseded by the three real panes, and the discipline it enforced — name the missing seam — now lives inside each pane, where the seam is specific (LD3.1b for the queue's Plane credential, LD4.3 for the chat backend, the dispatcher read API for the sandbox census, LD1.5b for the ledger).

Deviations from the brief, with reasons

  1. e2e coverage of the CONFIGURED queue uses in-browser route fulfilment, not a live server. The live binary cannot produce a configured queue — it has no Plane credential by design, that is 3.1b's separately gated slice. So gates-configured.spec.ts fulfils /api/v1/gates + /api/v1/overview with the exact payloads the Go tests assert, imported from src/lib/tests/fixtures.ts — the same fixtures the unit suite uses, so a shape change breaks both. The unwired states (the states this environment really has) are asserted against the real server in panes.spec.ts. If the reviewer would rather have a fake Plane service in the Playwright harness, that is a larger change I did not make unilaterally.
  2. Decision controls stay enabled while the write seam is unwired. The alternative — disabling them — hides the honest 503 the brief asks to be rendered. The pane states the refusal before the press and the dialog repeats it, so the screen is not lying: the request is real and fails closed exactly as designed.
  3. The design's ⏸ ✋ ⏹ glyphs are drawn, not typed. Neither Inter nor the mono stack has U+23F8 / U+270B / U+23F9 coverage — verified in a browser, they painted as tofu — so the derived-state marks are lucide strokes (Pause, Hand, a half-filled circle, a circled question mark) and the STOP mark is a styled square. Same intent, no missing glyphs on any machine.
  4. The 3.1a shell spec's two pane tests were adapted (the panes they call "empty placeholders" now exist): the same spec file, the same intent — "name the seam that is missing" — asserting gates unwired/LD3.1b and LD4.3/HOMELAB-1687 instead of pane-empty. The PWA/offline spec, the responsive spec and the auth spec are untouched and green.
  5. The source-row census assertion is the 3.2a fix worker's (commit b3c1bec, "assert the four named read-model source rows"), taken verbatim so the two branches cannot conflict. My dashboard renders all four named rows because it is the same census.

Open items for the landing reviewer (and where the visual-verifier should look hardest)

  1. Visual verification of the honest states at 390px. The hatch texture carries meaning in four places (unwired rows, unknown derived state, the sandbox skeleton, the not-sent chat message); the verifier should confirm that hatched = cannot back reads as one idea, and that no hatched surface reads as merely decorative at phone width.
  2. The gates queue's density is the design's bet ("one scroll, one click per ratification"): 4 rows with exposure bounds and chips — check it reads top-to-bottom without the eye losing the row.
  3. gates-configured.spec.ts is a frontend contract test (deviation 1). Worth a reviewer's eye on whether the fixture shapes are the ones 3.2a emits — they were copied from the Go structs and I read the Go tests to confirm; a second pair of eyes on src/lib/tests/fixtures.ts vs gates/store.go is the cheapest check.
  4. The panel/hidden navigation choice (all three panes mounted) is a deliberate trade: state survives and the structure is stable, at the cost of one mounted-offscreen fetch. If a reviewer prefers lazy panels, the change is small but it would break the offline spec's pane count.
  5. data-decision was added to the shared Button so the "never an automatic path" assertion reads the DOM rather than a label string. Purely additive; no existing variant changed.

Progress log: /home/aaron/repos/homelab/.rpiv/artifacts/build/ld3-1682b-builder.md

Slice **LD3.2b (HOMELAB-1682)** of epic **LD3 (Lab Director Cockpit)**: the three panes. Class **D** (ui-craft bar + visual-verifier at landing), frontend-only: `lab-director/cockpit/web/**`. Design authority: `.rpiv/artifacts/designs/ld3/cockpit-design-brief.md` (**R4** honest state, **R5** gates, **R8** STOP, **R11** class D) and `.rpiv/artifacts/designs/homelab-1654/project-deployments-and-cockpit.md` **§4.2** (Fleet Overview + Gates Queue mocks), **§5.2** (derived-state visuals), **§5.4** (STOP). Quality bar: the `ui-craft` skill. **Base note (read this first).** This branch is based on `origin/epic/LD3` = the 3.1a merge. The **3.2a backend (PR #572)** — the `/api/v1/gates`, `/api/v1/overview` and `/api/v1/fleet/stop` endpoints this frontend codes against — is **not yet on `epic/LD3`**. Consequence: the new e2e specs need those routes, so the `cockpit-e2e` CI job goes green only once #572 lands. My local e2e run used the suite's own documented `E2E_COCKPIT_BIN` hook with a binary built from **`plane/HOMELAB-1682-gates-backend` @ `b3c1bec`** (the tip whose CI is green). I will merge `epic/LD3` into this branch and re-run as soon as #572 lands; **no Go file is touched by this PR.** ## What **1 · Gates Queue — the centerpiece (§4.2, R5).** Rows carry title/ticket, class + state chips, the recommendation, the exposure bound split into *blast* and *recovery*, the options offered, the expiry as a countdown (`23h 47m left`, or `no expiry` — never an invented deadline), the owner and the gate nonce. **ratify / defer / veto** open a confirmation dialog that restates the exposure and requires a reason (the backend refuses a reasonless disposition under P-GATE-002, so the confirm is disabled until it is written); the POST is a real request and its outcome lands **on the row it was about**. - **Class E renders ratify/defer, never a veto and never an auto-path.** The vocabulary is closed in the store: `decisionsFor()` intersects the payload with `['ratify','defer','veto']` and drops `veto` for class E, so a payload that claimed otherwise cannot produce a control. A class-E ticket with **no** gate record renders **no controls at all** — just the sentence explaining that a decision package has to be raised first. - **Unwired is a status, not an empty list.** "gates unwired — Plane reader token lands with LD3.1b", the backend's reason verbatim, and the missing key as a chip (`PLANE_PROJECT_ID`). The header shows **"count not measured"**, never `0 need you`. - The **write posture** is stated up front: while the disposition write seam is unwired the pane says a decision will be refused, and the dialog repeats it — so the 503 is expected rather than discovered. The refusal renders verbatim with its kind, policy line and the credential that would wire it. **2 · Fleet Overview — the daily read (§4.2, R4).** Three card groups (**homelab / fleet / updates**) fed by `/api/v1/overview` + the read model, one scroll: - every count goes through one cell component: a **measured** number, or a hatched `?` when the backend did not measure it — **no fabricated zeros anywhere** (P-COCKPIT-005). The homelab group carries the measured ticket census (state-group counts) once Plane is wired, and a hatched sentence when it is not. - the **wiring census** stays where 3.1a put it, as **named rows** (plane / gates / dispatcher / bus), each with its state, its sentence and what would wire it. - **derived-state vocabulary (§5.2)**: PARKED / HELD / DONE-UNSEEN / unknown in a legend, and the sandbox table skeleton whose only row is the honest **`unknown`** (hatched, `data-tone="idle"` — **never** rendered as healthy) with the dependency named. The table is a skeleton on purpose: LD3.4 (HOMELAB-1684) fills it when the sandbox census exists. - a compact **project switcher** (Homelab / Sendrast / ApexDirector) with the per-project structure 3.4 fills. - the **El Director digest line**, which says "no digest — LD4.3 (HOMELAB-1687)" and offers *open chat*. **3 · Director chat.** Text-first: message log + composer, the transport seam typed as `ChatTransport.send(message)`, and the shipped implementation **refuses** — a typed message is recorded locally as **not sent** with the reason and the ticket that fills it (LD4.3). **No fake bot, no simulated reply**; the empty state says so in words. **4 · Header STOP (§5.4, R8).** ⏹ always visible → confirmation dialog (fleet scope, what a stop actually does: claim minting stops, process groups SIGSTOP, bus steer/ask suspended; plus a preview of the ledger entry) → `POST /api/v1/fleet/stop` → the **503 "sentinel not armed"** rendered as an **amber** banner (not error-red: nothing broke, the arm is not fitted), with the audit entry echoed back — schema, outcome, policy lines, principal, *verified: no — dev identity*, request id, and whether the ledger actually recorded it. The button is real; the arm is not yet. **5 · Pane navigation.** A tablist under the header; the fleet overview is the default (the operator's daily read). **All three panes stay mounted** with the inactive two `hidden`, so the gates list and the chat draft survive a glance elsewhere *and* the shell's structure stays stable across routes (the 3.1a offline spec's three `[data-testid=pane]` still holds). Arrow/Home/End keyboard navigation, `aria-selected`/`aria-controls` wired. The 3.1a identity chip, connection dot, dev badge, PWA and service worker are untouched. ## Why The cockpit's whole justification is that an operator can trust it at a glance: **"no row ever shows a state the telemetry can't back"**. This slice is where that rule stops being a sentence in a design document and becomes the thing the code does — every unwired source names its key or ticket, every unmeasured count is *absent* rather than zero, unknown is a first-class hatched state, and the two irreversible actions (a disposition, a fleet stop) each stop at a confirmation that restates the consequence. It remains a **reader**: no store, no local mutation of the queue, and no path by which the cockpit ratifies anything on its own. ## Test plan (all run locally; evidence pasted) | Suite | Command | Result | |---|---|---| | Types | `npm ci && npx svelte-kit sync && npx svelte-check --tsconfig ./tsconfig.json` | **0 errors / 0 warnings** | | Unit | `npm run test` | **137 passed / 18 files** (3.1a baseline: 60 / 8 — **+65**) | | Build | `npm run build` | static SPA written to `build/` | | E2E | `npm run e2e` (real binary + real build, dev mode) | **43 passed** (3.1a baseline: 25 — **+18**) | Local e2e was run against the **3.2a backend binary** (`E2E_PREBUILT=1 E2E_COCKPIT_BIN=/tmp/ld-cockpit-3.2a`, built from `plane/HOMELAB-1682-gates-backend` @ `b3c1bec`), because `epic/LD3` does not carry those routes yet. Everything else (types, unit, build) runs against the branch as-is. Manually verified in a browser at 1440 and 390px (evidence below): all three panes, both the unwired and the configured gates queue, the ratify dialog, the refused disposition on its row, the chat send refusal, the STOP dialog and the not-armed banner. `scrollWidth ≤ clientWidth` at 390px on every pane; every control clears the 24px target floor; the responsive spec (overflow + targets + 4.5:1 contrast at 390 and 1440) stays green. ## Screenshots Attached to this PR (see the evidence comment below): `dashboard-unwired`, `dashboard-measured`, `gates-unwired`, `gates-configured`, `gate-ratify-dialog`, `gates-refused`, `chat`, `chat-not-sent`, `stop-dialog`, `stop-not-armed` — for each of the three panes at 1440 **and** 390px. ## Slice boundary (hard) **In:** `lab-director/cockpit/web/**` only — new stores (`gates`, `overview`, `stop`, `chat`, `panes`), new components (`gates/`, `dashboard/`, `chat/`, `shell/`), the confirm dialog primitive, the LD3.2 wire types + client methods, `honest-state.ts`, one new status tone, the page/layout/header wiring, 65 unit tests and 18 e2e specs. **Out (untouched):** no Go file (not one line), no CI workflow, no `prod/**`, no dispatcher, no manifests, no secrets. **No API shape was invented** — every field read here is a field 3.2a emits. **Retired:** `src/lib/components/shell/pane.svelte` + its test. The 3.1a placeholder frame ("no data in this slice" + an owner line) is superseded by the three real panes, and the discipline it enforced — *name the missing seam* — now lives inside each pane, where the seam is specific (LD3.1b for the queue's Plane credential, LD4.3 for the chat backend, the dispatcher read API for the sandbox census, LD1.5b for the ledger). ## Deviations from the brief, with reasons 1. **e2e coverage of the CONFIGURED queue uses in-browser route fulfilment, not a live server.** The live binary cannot produce a configured queue — it has no Plane credential by design, that is 3.1b's separately gated slice. So `gates-configured.spec.ts` fulfils `/api/v1/gates` + `/api/v1/overview` with the **exact payloads the Go tests assert**, imported from `src/lib/tests/fixtures.ts` — the **same** fixtures the unit suite uses, so a shape change breaks both. The unwired states (the states this environment really has) are asserted against the **real** server in `panes.spec.ts`. If the reviewer would rather have a fake Plane service in the Playwright harness, that is a larger change I did not make unilaterally. 2. **Decision controls stay enabled while the write seam is unwired.** The alternative — disabling them — hides the honest 503 the brief asks to be rendered. The pane states the refusal before the press and the dialog repeats it, so the screen is not lying: the request is real and fails closed exactly as designed. 3. **The design's ⏸ ✋ ⏹ glyphs are drawn, not typed.** Neither Inter nor the mono stack has U+23F8 / U+270B / U+23F9 coverage — verified in a browser, they painted as **tofu** — so the derived-state marks are lucide strokes (Pause, Hand, a half-filled circle, a circled question mark) and the STOP mark is a styled square. Same intent, no missing glyphs on any machine. 4. **The 3.1a shell spec's two pane tests were adapted** (the panes they call "empty placeholders" now exist): the same spec file, the same intent — "name the seam that is missing" — asserting `gates unwired`/LD3.1b and `LD4.3`/HOMELAB-1687 instead of `pane-empty`. The PWA/offline spec, the responsive spec and the auth spec are untouched and green. 5. **The source-row census assertion is the 3.2a fix worker's** (commit `b3c1bec`, "assert the four named read-model source rows"), taken verbatim so the two branches cannot conflict. My dashboard renders all four named rows because it is the same census. ## Open items for the landing reviewer (and where the visual-verifier should look hardest) 1. **Visual verification of the honest states at 390px.** The hatch texture carries meaning in four places (unwired rows, unknown derived state, the sandbox skeleton, the not-sent chat message); the verifier should confirm that *hatched = cannot back* reads as one idea, and that no hatched surface reads as merely decorative at phone width. 2. **The gates queue's density** is the design's bet ("one scroll, one click per ratification"): 4 rows with exposure bounds and chips — check it reads top-to-bottom without the eye losing the row. 3. **`gates-configured.spec.ts` is a frontend contract test** (deviation 1). Worth a reviewer's eye on whether the fixture shapes are the ones 3.2a emits — they were copied from the Go structs and I read the Go tests to confirm; a second pair of eyes on `src/lib/tests/fixtures.ts` vs `gates/store.go` is the cheapest check. 4. **The panel/`hidden` navigation choice** (all three panes mounted) is a deliberate trade: state survives and the structure is stable, at the cost of one mounted-offscreen fetch. If a reviewer prefers lazy panels, the change is small but it would break the offline spec's pane count. 5. **`data-decision` was added to the shared Button** so the "never an automatic path" assertion reads the DOM rather than a label string. Purely additive; no existing variant changed. Progress log: `/home/aaron/repos/homelab/.rpiv/artifacts/build/ld3-1682b-builder.md`
The frontend half of LD3.2 starts at the contract, not at the pixels:

- `types/api.ts` mirrors the 3.2a Go shapes field-for-field (gates.Queue/Item/
  WritePosture, the disposition result and both refusal families,
  readmodel.Census, the fail-closed stop response). Every count that Go omits
  when it could not measure is OPTIONAL here, which is what makes the panes'
  absence-not-zero rule a type error rather than a convention.
- `stores/gates.svelte.ts`: the queue, the closed decision vocabulary, and the
  disposition flow. Three properties are load-bearing and tested: an unmeasured
  count is null (never 0), `decisionsFor()` intersects the payload with
  ratify/defer/veto so a class-E row can never render a veto or an automatic
  path, and every refusal (503 unwired, 409 no gate, 410 expired, 400, 502) is
  recorded against the row it was about instead of being swallowed.
- `stores/overview.svelte.ts`: the census read, plus `measured()` — the one
  function the panes count through.
- `stores/stop.svelte.ts`: the fleet stop, whose 503 is a first-class refusal
  carrying the ledger entry the cockpit wrote about its own attempt.
- `api/chat.ts`: the Director chat transport seam (`send(message)`) and the
  refusing implementation this build ships, naming LD4.3 (HOMELAB-1687).
- `stores/panes.svelte.ts`, `honest-state.ts`, `format.ts` (+ expiry countdown),
  the confirm dialog (dangerous → confirm), one new status tone for DONE-UNSEEN,
  and a `data-decision` hook on Button so the decision vocabulary is asserted
  from the DOM.
The shell's three panes become the real surfaces, and the 3.1a placeholder
frame (Pane + its empty-state contract) is superseded by them — each pane now
names its own missing dependencies in place, which is what the placeholder was
standing in for.

- Gates Queue (the centerpiece): rows with recommendation, exposure bound
  (blast + recovery), options, expiry countdown, owner and nonce, plus the
  ratify/defer/veto controls from the closed vocabulary. Class-E rows render
  ratify/defer; a class-E ticket with no gate record renders NO controls and the
  sentence explaining why. The unwired state says "gates unwired" and names
  LD3.1b; the write posture is stated before it is discovered via a 503.
- Fleet Overview: the three card groups (homelab / fleet / updates), every count
  through StatCell — a hatched ? when the backend did not measure it, never a
  zero — the wiring census as named rows, the project switcher, the sandbox
  table skeleton whose only row is the honest unknown (hatched, never green),
  and the four-state vocabulary legend.
- Director chat: text-first, with the transport seam wired to the refusing
  implementation, so a typed message is recorded as NOT SENT with the reason
  (LD4.3 / HOMELAB-1687) and no reply is invented.
- Header STOP: always visible, one confirmation dialog naming fleet scope and
  the ledger entry, then the real fail-closed request. The 503 renders amber
  with the audit entry echoed back (schema, outcome, policy lines, principal,
  verified, request id).
- Pane navigation: a tablist under the header. All three panes stay mounted
  (inactive ones hidden) so their state survives a glance and the shell's
  structure is stable across routes; the gates badge renders only a measured
  count.

The emoji-block glyphs the design names (⏸ ✋ ⏹) have no font coverage in this
stack — verified in a browser, they paint as tofu — so the derived-state marks
are lucide strokes and the STOP mark is a drawn square. Intent preserved, tofu
avoided.
65 new tests over the stores and the panes, each asserting a design ruling
rather than a rendering detail:

- an unwired queue exposes NO count (the payload omits it; the store returns
  null) and the pane says "count not measured" instead of "0 need you";
- the decision vocabulary is closed — a class-E row offers ratify/defer even if
  the payload claimed a veto, a needs-gate row offers nothing, and an unoffered
  decision is refused locally without a request;
- every refusal family lands on its row with the backend's kind, reason, policy
  line and wiring;
- the dashboard renders no fabricated number for any cell without a source, and
  `unknown` is never rendered with the healthy tone;
- a chat message is NOT SENT, with the transport's sentence, and there is no
  assistant turn to be mistaken for a reply;
- the STOP dialog requires the fleet-scope confirmation and the refusal banner
  carries the audit entry, including the unverified dev identity.

`src/lib/tests/fixtures.ts` is the single source of the wire shapes for BOTH
suites (relative imports so Playwright needs no $lib alias), so the unit
fixtures and the e2e payloads cannot drift.
18 new Playwright specs against the real binary and the real build (dev mode),
plus the 3.1a shell spec adapted to the shell that now exists:

- the tablist mounts all three panes, switches them, and is keyboard navigable;
- the gates pane renders the unwired-honest state naming LD3.1b, with no count
  and no rows; the gates tab carries no badge it cannot back;
- the dashboard renders census rows without fabricating a count, names every
  source, and keeps `unknown` off the healthy tone;
- the chat pane names LD4.3/HOMELAB-1687 and marks a sent message not sent;
- STOP: confirm (fleet scope, SIGSTOP, the audit preview) → the amber
  not-armed banner with the ledger entry echoed back → dismissable;
- every pane stays inside a 390px viewport and clears the 24px target floor.

The configured queue cannot exist on the live server (it has no Plane
credential by design — LD3.1b), so `gates-configured.spec.ts` fulfils
/api/v1/gates and /api/v1/overview in the browser with the EXACT payloads the
Go tests assert, from the fixtures the unit suite shares. That covers rows,
exposure bounds, class-E decisions, the required reason and the refused
disposition end-to-end against the production frontend build.
HOMELAB-1682: test(cockpit-web): component specs for the panes, the tabs and the STOP
Some checks failed
CI Review / helm-validate (pull_request) Successful in 9s
CI Review / receiver-test (pull_request) Successful in 6s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 10s
CI Review / alert-receiver-test (pull_request) Successful in 27s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 1m4s
CI Review / cockpit-test (pull_request) Successful in 2m11s
CI Review / dispatcher-test (pull_request) Successful in 2m45s
Plan in progress...
CI Review / cockpit-e2e (pull_request) Failing after 3m47s
f4183d03d8
The remaining unit layer: what each pane renders for each honest state (unwired,
error, measured-empty, configured), what the confirmation dialogs say before a
decision is taken, that the reason gates the confirm on both dangerous paths,
and that the tablist's selection and badge behave.

One real defect found by writing them: the written-decision line rendered
"ratifyd" (a past tense built with string concatenation). It is a label map
now, with the test that caught it.
Author
Owner

Visual evidence — the three panes, 1440px and 390px

Captured from the real build against the real 3.2a backend in dev mode (X-Cockpit-Dev-User: e2e-operator), with the configured-queue states rendered from the payloads the Go tests assert (the live server has no Plane credential by design — LD3.1b ships it).

Gates Queue — the centerpiece

The unwired state (what this environment honestly has): "gates unwired", the backend's reason verbatim, PLANE_PROJECT_ID as the key that would wire it, the dependency line naming LD3.1b, and no count in the header — never "0 need you".

gates unwired @1440

gates unwired @1440
gates unwired @390

gates unwired @390

The configured queue: exposure bound split into blast/recovery, recommendation, options, expiry countdown, owner, nonce, and the write-posture banner (LD3.1b). Class E (SEND-478) offers ratify/defer only; the needs-gate row (HOMELAB-1661) renders no controls, just the sentence saying a decision package must be raised.

gates configured @1440

gates configured @1440
gates configured @390

gates configured @390

The confirmation restates the bound and requires a reason (P-GATE-002), and the refusal lands on the row it was about with its kind, the credential that would wire it, and the fact that the row is still undecided.

ratify confirmation @1440

ratify confirmation @1440
refused disposition on its row @1440

refused disposition on its row @1440

Fleet overview — the daily read

Unwired census: every count that was not measured is the hatched ? — no zeros. Wired census: the measured ticket census (state-group counts), class-E depth and the gates count, everything else still honest.

fleet overview, unwired census @1440

fleet overview, unwired census @1440
fleet overview, unwired census @390

fleet overview, unwired census @390
fleet overview, measured census @1440

fleet overview, measured census @1440
fleet overview, measured census @390

fleet overview, measured census @390

Director chat

No conversation plane: LD4.3 (HOMELAB-1687) is named, and a typed message is recorded as not sent — no invented reply.

director chat @1440

director chat @1440
director chat @390

director chat @390
a message that was not sent @1440

a message that was not sent @1440

Header STOP

One confirmation naming fleet scope and the ledger entry, then the fail-closed 503 rendered amber (not error-red) with the audit entry echoed back: schema, outcome, policy lines P-SENT-001/P-SENT-002, principal, verified: no — dev identity, request id, recorded.

STOP confirmation @1440

STOP confirmation @1440
sentinel not armed @1440

sentinel not armed @1440


Measured layout facts (390px)

Pane scrollWidth clientWidth Controls under 24px
fleet 390 390 none
gates 390 390 none
chat 390 390 none

Committed as specs in e2e/panes.spec.ts (no pane scrolls horizontally, at 390px and 1440px and every control in the new panes clears the 24px target floor at 390px), alongside the untouched 3.1a responsive.spec.ts (overflow + targets + 4.5:1 contrast at 390 and 1440) which stays green.

## Visual evidence — the three panes, 1440px and 390px Captured from the real build against the real 3.2a backend in dev mode (`X-Cockpit-Dev-User: e2e-operator`), with the configured-queue states rendered from the payloads the Go tests assert (the live server has no Plane credential by design — LD3.1b ships it). ### Gates Queue — the centerpiece The **unwired** state (what this environment honestly has): "gates unwired", the backend's reason verbatim, `PLANE_PROJECT_ID` as the key that would wire it, the dependency line naming **LD3.1b**, and **no count** in the header — never "0 need you". **gates unwired @1440** ![gates unwired @1440](https://forgejo.aaron.reynoza.org/attachments/9e4f6ca3-6bf7-4586-957d-5e8f64e68d17) **gates unwired @390** ![gates unwired @390](https://forgejo.aaron.reynoza.org/attachments/0e2328f2-0575-49bd-b4f8-1697588f7067) The **configured** queue: exposure bound split into blast/recovery, recommendation, options, expiry countdown, owner, nonce, and the write-posture banner (LD3.1b). Class E (`SEND-478`) offers **ratify/defer only**; the needs-gate row (`HOMELAB-1661`) renders **no controls**, just the sentence saying a decision package must be raised. **gates configured @1440** ![gates configured @1440](https://forgejo.aaron.reynoza.org/attachments/5d1e0a19-15c7-4371-8049-eaceaa8436dc) **gates configured @390** ![gates configured @390](https://forgejo.aaron.reynoza.org/attachments/6037bc15-077a-4411-86aa-9ec71a2c9987) The confirmation restates the bound and requires a reason (P-GATE-002), and the refusal lands **on the row it was about** with its kind, the credential that would wire it, and the fact that the row is still undecided. **ratify confirmation @1440** ![ratify confirmation @1440](https://forgejo.aaron.reynoza.org/attachments/f698e8f8-6222-4456-8176-693a98900ebe) **refused disposition on its row @1440** ![refused disposition on its row @1440](https://forgejo.aaron.reynoza.org/attachments/1ece095a-287d-4e1f-8c1a-17b9df4618f4) ### Fleet overview — the daily read Unwired census: every count that was not measured is the hatched `?` — no zeros. Wired census: the measured ticket census (state-group counts), class-E depth and the gates count, everything else still honest. **fleet overview, unwired census @1440** ![fleet overview, unwired census @1440](https://forgejo.aaron.reynoza.org/attachments/da51bfb7-b375-4894-ac9d-0c0835bba3f0) **fleet overview, unwired census @390** ![fleet overview, unwired census @390](https://forgejo.aaron.reynoza.org/attachments/ac40d981-7782-4edc-8a46-7050b2dcd6c5) **fleet overview, measured census @1440** ![fleet overview, measured census @1440](https://forgejo.aaron.reynoza.org/attachments/6ca330cf-eb49-4d15-9bb2-7a27bedb72d1) **fleet overview, measured census @390** ![fleet overview, measured census @390](https://forgejo.aaron.reynoza.org/attachments/960ba366-b36b-4be2-ba42-7e17217d4da9) ### Director chat No conversation plane: LD4.3 (HOMELAB-1687) is named, and a typed message is recorded as **not sent** — no invented reply. **director chat @1440** ![director chat @1440](https://forgejo.aaron.reynoza.org/attachments/902d2768-b5fd-42dd-a1ec-0cb72344f67a) **director chat @390** ![director chat @390](https://forgejo.aaron.reynoza.org/attachments/65652a13-b498-4bfe-a40b-6871cce24d2f) **a message that was not sent @1440** ![a message that was not sent @1440](https://forgejo.aaron.reynoza.org/attachments/c72ab4a0-8d17-458b-a470-e41b5013f2b5) ### Header STOP One confirmation naming fleet scope and the ledger entry, then the **fail-closed 503** rendered amber (not error-red) with the audit entry echoed back: schema, outcome, policy lines P-SENT-001/P-SENT-002, principal, *verified: no — dev identity*, request id, recorded. **STOP confirmation @1440** ![STOP confirmation @1440](https://forgejo.aaron.reynoza.org/attachments/3c66703b-d3db-43e6-8822-4b572da4c749) **sentinel not armed @1440** ![sentinel not armed @1440](https://forgejo.aaron.reynoza.org/attachments/86db59de-6e12-4c9d-b877-b11721a38a38) --- ### Measured layout facts (390px) | Pane | `scrollWidth` | `clientWidth` | Controls under 24px | |---|---|---|---| | fleet | 390 | 390 | none | | gates | 390 | 390 | none | | chat | 390 | 390 | none | Committed as specs in `e2e/panes.spec.ts` (`no pane scrolls horizontally, at 390px and 1440px` and `every control in the new panes clears the 24px target floor at 390px`), alongside the untouched 3.1a `responsive.spec.ts` (overflow + targets + 4.5:1 contrast at 390 and 1440) which stays green.
Author
Owner

CI ordering note — cockpit-e2e is red because LD3.2a is not on epic/LD3 yet

cockpit-test (Go) is green; cockpit-e2e fails 4 specs, and all four are the specs that call the 3.2a routes (/api/v1/gates, /api/v1/overview, /api/v1/fleet/stop) which do not exist in this branch's Go code:

  • e2e/panes.spec.ts → the Gates Queue pane, unwired (no gates-unwired: the route 404s)
  • e2e/panes.spec.ts → the STOP button (no /api/v1/fleet/stop)
  • e2e/shell.spec.ts → the panes 3.1a reserved state their dependencies (same two routes)
  • e2e/shell.spec.ts → the wiring census reports every source honestly (the 4th, gates, source row comes from the 3.2a read model)

Evidence that this is the ordering and nothing else:

Run Binary Result
local, this branch's own Go code (3.1a) go build ./cmd/cockpit the same 4 specs fail, the other 39 pass
local, LD3.2a backend E2E_COCKPIT_BIN= binary built from plane/HOMELAB-1682-gates-backend @ b3c1bec 43/43 pass
merge check git merge origin/plane/HOMELAB-1682-gates-backend clean (only shell.spec.ts auto-merges, no conflicts)

So: #578 must land after #572. Once #572 is on epic/LD3 I will merge epic/LD3 into this branch (a merge commit, no force-push) and re-run — or the landing worker may take both in sequence; the merge is clean either way.

One more thing worth recording honestly: in CI, e2e/shell.spec.ts → ships the PWA shell also failed (0.8s), while the same spec passes locally against the same 3.1a binary (re-verified minutes ago). That one is a flake in the service-worker install path under the runner, not a regression from this slice — it will be re-run with the rest after the merge. If it fails again post-merge, it is a 3.1a/CI issue worth its own ticket.

## CI ordering note — `cockpit-e2e` is red *because* LD3.2a is not on `epic/LD3` yet `cockpit-test` (Go) is green; `cockpit-e2e` fails **4 specs, and all four are the specs that call the 3.2a routes** (`/api/v1/gates`, `/api/v1/overview`, `/api/v1/fleet/stop`) which do not exist in this branch's Go code: - `e2e/panes.spec.ts` → *the Gates Queue pane, unwired* (no `gates-unwired`: the route 404s) - `e2e/panes.spec.ts` → *the STOP button* (no `/api/v1/fleet/stop`) - `e2e/shell.spec.ts` → *the panes 3.1a reserved state their dependencies* (same two routes) - `e2e/shell.spec.ts` → *the wiring census reports every source honestly* (the 4th, `gates`, source row comes from the 3.2a read model) Evidence that this is the ordering and nothing else: | Run | Binary | Result | |---|---|---| | local, this branch's own Go code (3.1a) | `go build ./cmd/cockpit` | **the same 4 specs fail**, the other **39 pass** | | local, LD3.2a backend | `E2E_COCKPIT_BIN=` binary built from `plane/HOMELAB-1682-gates-backend` @ `b3c1bec` | **43/43 pass** | | merge check | `git merge origin/plane/HOMELAB-1682-gates-backend` | **clean** (only `shell.spec.ts` auto-merges, no conflicts) | So: **#578 must land after #572.** Once #572 is on `epic/LD3` I will merge `epic/LD3` into this branch (a merge commit, no force-push) and re-run — or the landing worker may take both in sequence; the merge is clean either way. One more thing worth recording honestly: in CI, `e2e/shell.spec.ts` → *ships the PWA shell* also failed (0.8s), while the **same spec passes locally against the same 3.1a binary** (re-verified minutes ago). That one is a flake in the service-worker install path under the runner, not a regression from this slice — it will be re-run with the rest after the merge. If it fails again post-merge, it is a 3.1a/CI issue worth its own ticket.
HOMELAB-1682: fix(cockpit-web): reconcile with the merged 3.2a audit shape, de-rot the queue fixture
All checks were successful
CI Review / helm-validate (pull_request) Successful in 13s
CI Review / receiver-test (pull_request) Successful in 14s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 10s
CI Review / alert-receiver-test (pull_request) Successful in 26s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 1m23s
CI Review / dispatcher-test (pull_request) Successful in 2m11s
CI Review / cockpit-test (pull_request) Successful in 2m4s
CI Review / cockpit-e2e (pull_request) Successful in 2m22s
aba6709bfb
Sequencer merge of epic/LD3 (which now carries LD3.2a, PR #572) left the slice
red on two counts. Both are integration fixes, neither is a defect in the
builder's panes:

1. c11c725 made audit.Entry.Principal the sentinel's Principal OBJECT
   ({kind,id,display}) and Entry.Scope a StopScope object. The stop banner
   interpolated the field straight into the DOM, so the ledger echo painted
   "principal [object Object]" and panes.spec.ts:204 failed against the real
   server. The types now model AuditPrincipal/AuditScope, and the banner renders
   the identity the ledger is grouped by (principal.id) alongside the schema's
   own kind (unknown for an unverified dev identity). A regression assertion
   pins the object read so "[object Object]" cannot come back silently.

2. gates-configured.spec.ts asserted the SEND-472 countdown contained "23h", but
   the fixture pinned expires_at to a fixed instant, so the live countdown walked
   out of that bucket ~1h after the branch's own CI passed it (it went red at
   05:00Z). Fixture expiries are now offsets from a single process-wide anchor:
   deterministic within a run (gateItem() is called twice and the results are
   compared) and still a real future deadline, which is what a server emits.

Verified locally on the merged head: svelte-check 0 errors/0 warnings, unit
137/137, build OK, e2e 43/43 against a real cockpit binary built from this tree,
plus cockpit + sentinel Go tests green.
Author
Owner

Sequencer: epic/LD3 merged — two integration breaks found and fixed

New head aba6709 (merge b72d365 + fix aba6709). The merge of origin/epic/LD3
(now carrying LD3.2a, PR #572) was conflict-free — git merge-tree --write-tree
exit 0, and the shell.spec.ts source-row area auto-resolved keeping both the
b3c1bec 4-name contract (plane/gates/dispatcher/bus) and the 3.2b pane edits.
Merged-head scope vs origin/epic/LD3 is web/** only.

Running the suite on the merged head surfaced two breaks. Neither is a defect in the
builder's panes
; one is caused by the merge, one was latent and time-triggered.

1. MERGE-INDUCED — the 3.2a audit schema shape (panes.spec.ts:204)

c11c725 (the 3.2a review-blocker fix) changed audit.Entry.Principal from string
to the sentinel's Principal object {kind,id,display}, and Entry.Scope from
string to StopScope {kind,...}. The 3.2b frontend still typed both as strings, so
the ledger echo interpolated the object straight into the DOM:

principal [object Object]

Fixed type-first, following the authoritative Go shape:

  • types/api.ts gains AuditPrincipal / AuditScope; AuditEntry uses them.
  • stop-banner.svelte renders principal.id — the identity the ledger is grouped by
    (Go: "the field the ledger is grouped by") — with the schema's own kind
    (unknown for an unverified dev identity, consistent with verified: no).
    display is deliberately not used: it is the raw dev header, not the attribution.
  • fixtures.ts updated to the real payloads ({kind:'unknown', id:'dev:e2e-operator', display:'e2e-operator'}, scope:{kind:'all'}).
  • A regression assertion pins that the object is read, so [object Object] cannot
    return silently.

2. TIME-TRIGGERED LATENT DEFECT — a rotting countdown assertion

gates-configured.spec.ts:68 asserted the SEND-472 countdown contained 23h, but the
fixture pinned expires_at to a fixed instant (2026-10-03T04:00:00Z) while
formatExpiry computes a live countdown. It passed in CI run #1699 (~04:30Z) and
went red at 05:00Z, the moment the countdown crossed into 22h.

Fixture expiries are now offsets from one process-wide anchor: deterministic within a
run (gateItem() is called twice and the results are compared — a per-call Date.now()
drifts by milliseconds and fails that comparison) and still a real future deadline,
which is what a server emits.

Flagging this one explicitly: it is NOT a merge artifact. It is a pre-existing
wall-clock dependency in the branch's own fixture that would have gone red on the next
run regardless of the merge. It only surfaced here because the landing run happened
after 05:00Z. The builder/operator may prefer to own this fix — it is isolated in
fixtures.ts and the commit message documents it.

Verification on the merged head

Check Result
svelte-check 0 errors / 0 warnings
npm run test 137 passed / 18 files
npm run build static SPA written to build/
npm run e2e (real binary built from this tree) 43 passed
cockpit go test ./... green
dispatcher/sentinel go test green

CI is running on aba6709; reviews follow once it is green.

## Sequencer: epic/LD3 merged — two integration breaks found and fixed New head **`aba6709`** (merge `b72d365` + fix `aba6709`). The merge of `origin/epic/LD3` (now carrying LD3.2a, PR #572) was **conflict-free** — `git merge-tree --write-tree` exit 0, and the `shell.spec.ts` source-row area auto-resolved keeping both the b3c1bec 4-name contract (`plane/gates/dispatcher/bus`) and the 3.2b pane edits. Merged-head scope vs `origin/epic/LD3` is `web/**` only. Running the suite on the merged head surfaced two breaks. **Neither is a defect in the builder's panes**; one is caused by the merge, one was latent and time-triggered. ### 1. MERGE-INDUCED — the 3.2a audit schema shape (`panes.spec.ts:204`) `c11c725` (the 3.2a review-blocker fix) changed `audit.Entry.Principal` from `string` to the sentinel's `Principal` **object** `{kind,id,display}`, and `Entry.Scope` from `string` to `StopScope` `{kind,...}`. The 3.2b frontend still typed both as strings, so the ledger echo interpolated the object straight into the DOM: ``` principal [object Object] ``` Fixed **type-first**, following the authoritative Go shape: - `types/api.ts` gains `AuditPrincipal` / `AuditScope`; `AuditEntry` uses them. - `stop-banner.svelte` renders `principal.id` — the identity the ledger is grouped by (Go: *"the field the ledger is grouped by"*) — with the schema's own `kind` (`unknown` for an unverified dev identity, consistent with `verified: no`). `display` is deliberately **not** used: it is the raw dev header, not the attribution. - `fixtures.ts` updated to the real payloads (`{kind:'unknown', id:'dev:e2e-operator', display:'e2e-operator'}`, `scope:{kind:'all'}`). - A regression assertion pins that the object is *read*, so `[object Object]` cannot return silently. ### 2. TIME-TRIGGERED LATENT DEFECT — a rotting countdown assertion `gates-configured.spec.ts:68` asserted the SEND-472 countdown contained `23h`, but the fixture pinned `expires_at` to a **fixed instant** (`2026-10-03T04:00:00Z`) while `formatExpiry` computes a **live** countdown. It passed in CI run #1699 (~04:30Z) and went red at 05:00Z, the moment the countdown crossed into `22h`. Fixture expiries are now offsets from one process-wide anchor: deterministic within a run (`gateItem()` is called twice and the results are compared — a per-call `Date.now()` drifts by milliseconds and fails that comparison) and still a real future deadline, which is what a server emits. > **Flagging this one explicitly: it is NOT a merge artifact.** It is a pre-existing > wall-clock dependency in the branch's own fixture that would have gone red on the next > run regardless of the merge. It only surfaced here because the landing run happened > after 05:00Z. The builder/operator may prefer to own this fix — it is isolated in > `fixtures.ts` and the commit message documents it. ### Verification on the merged head | Check | Result | |---|---| | `svelte-check` | **0 errors / 0 warnings** | | `npm run test` | **137 passed / 18 files** | | `npm run build` | static SPA written to `build/` | | `npm run e2e` (real binary built from this tree) | **43 passed** | | cockpit `go test ./...` | green | | dispatcher/sentinel `go test` | green | CI is running on `aba6709`; reviews follow once it is green.
Author
Owner

Review verdict 1/2 — UI-contract lens → BLOCK

Independent reviewer, fresh context, primary sources only: the diff at head aba6709, design §4.2/§5.2/§5.4, and the merged Go wire (server/*.go, readmodel/*.go, audit/audit.go, gates/store.go). Baseline re-run by the reviewer: npm test 137/137, svelte-check 0/0.

BLOCKER — the Fleet Overview prints a falsehood about its own session

dashboard-pane.svelte:104 renders snapshot?.identity, but every SSE readmodel tick is provider.Snapshot(nil) by design — cmd/cockpit/main.go:209: "No identity: this one payload goes to every subscriber, and the SPA already holds its own identity from /auth/me. The fetch form of the read model DOES carry one." Only GET /api/v1/readmodel (server.go:197, mapIdentity(id)) carries it.

readmodel.svelte.ts:44-48 exists to keep the fetched identity in a separate identity field; its own doc says "without it, the chip would blink empty on every tick." The 3.2b page passes only {snapshot} (+page.svelte:73), and nothing consumes readmodel.identity (grep: the store and its unit test only). The pre-PR page read it correctly (origin/epic/LD3:+page.svelte:22,57-60).

Net effect: ~1s after load the process strip flips from as e2e-operator · dev:e2e to "no identity resolved" while the operator is authenticated — an affirmative falsehood, on the screen whose entire thesis is honest state.

Fix: pass identity={readmodel.identity} from +page.svelte:73 and render that (with the loading/absent branches the old code had). No test covers data-testid="readmodel-identity", which is why a green suite missed it.

CONCERN

  • Any 503 is rendered as "sentinel not armed" (stop.svelte.ts:61-66). A gateway 503 carries no stop body, so refusal is a truthy non-StopRefusal and the banner prints its hardcoded "fleet stop refused — sentinel not armed" with no reason and no ledger block. The cockpit invents the exact sentence it exists to quote, on a safety surface. Require state === 'unwired' and a reason/audit body, else take the failure path.
  • A failed re-read keeps the last good payload rendered green (gates.svelte.ts:135-157, overview.svelte.ts:52-70): error banner plus 3 need you, configured chip, badge 3, and census numbers — while the dashboard says "no counts are rendered from this read". The test covers only the no-prior-queue case.
  • The gate countdown is frozen for the session (gates-pane.svelte:43, gate-row.svelte:42): now defaults once, no timer, no SSE input — a gate still reads "3m left" after expiry, while ratify returns 410 expired_gate.

SUGGESTION

  • §4.2's fleet card drops the cost cell entirely (renders 5 unknown cells, neither renders nor declares cost).
  • The El Director digest line sits inside the updates card, not as §4.2's bottom full-width line.
  • The ledger echo omits audit.scope (the schema's value, {kind:"all"}); it shows the endpoint's informal scope: "fleet" instead, which server/stop.go:33-37 explicitly says is not the ledger's scope.
  • No count reconciliation after an applied disposition (latent until LD3.1b wires the write seam).
  • gates-pane.svelte:50 re-implements absence-not-zero without the store's typeof count === 'number' guard.
  • client.ts:122 spreads ...options after headers, so gatesApi.decide's custom headers replace the merged object.

Explicitly clean (falsification attempts failed)

  • XSS (F): clean. Not one {@html}, innerHTML or document.write in the app; every backend-controlled string reaches the DOM via Svelte escaping. Only interpolated hrefs are a constant and identity-chip.svelte:30 (me?.login_url); a scheme guard there is defensible.
  • "Never auto" is structurally true in the code — the only POST paths are user-click-reachable, decisionsFor() (gates.svelte.ts:59-64) intersects the payload with the closed ratify/defer/veto vocabulary and drops veto for class E, and decide() refuses locally before any request. (The test that guards this is a different story — see verdict 2.)
  • Derived-state vocabulary (§5.2): all four states in the legend; unknown is hatched with tone idle in the only row, and ok/green is structurally unreachable from DERIVED_STATE (honest-state.ts:73-78).
  • Panes stay mounted / hidden: safe — no subscribe-after-event race, no cross-principal residue, tablist roles/roving tabindex correct.

VERDICT: BLOCK

## Review verdict 1/2 — UI-contract lens → **BLOCK** Independent reviewer, fresh context, primary sources only: the diff at head `aba6709`, design §4.2/§5.2/§5.4, and the merged Go wire (`server/*.go`, `readmodel/*.go`, `audit/audit.go`, `gates/store.go`). Baseline re-run by the reviewer: `npm test` 137/137, `svelte-check` 0/0. ### BLOCKER — the Fleet Overview prints a falsehood about its own session `dashboard-pane.svelte:104` renders `snapshot?.identity`, but every SSE `readmodel` tick is `provider.Snapshot(nil)` by design — `cmd/cockpit/main.go:209`: *"No identity: this one payload goes to every subscriber, and the SPA already holds its own identity from /auth/me. The fetch form of the read model DOES carry one."* Only `GET /api/v1/readmodel` (`server.go:197`, `mapIdentity(id)`) carries it. `readmodel.svelte.ts:44-48` exists to keep the fetched identity in a **separate** `identity` field; its own doc says *"without it, the chip would blink empty on every tick."* The 3.2b page passes only `{snapshot}` (`+page.svelte:73`), and **nothing consumes `readmodel.identity`** (grep: the store and its unit test only). The pre-PR page read it correctly (`origin/epic/LD3:+page.svelte:22,57-60`). Net effect: ~1s after load the process strip flips from `as e2e-operator · dev:e2e` to **"no identity resolved"** while the operator is authenticated — an affirmative falsehood, on the screen whose entire thesis is honest state. Fix: pass `identity={readmodel.identity}` from `+page.svelte:73` and render that (with the loading/absent branches the old code had). No test covers `data-testid="readmodel-identity"`, which is why a green suite missed it. ### CONCERN - **Any 503 is rendered as "sentinel not armed"** (`stop.svelte.ts:61-66`). A gateway 503 carries no stop body, so `refusal` is a truthy non-`StopRefusal` and the banner prints its hardcoded *"fleet stop refused — sentinel not armed"* with no reason and no ledger block. The cockpit invents the exact sentence it exists to quote, on a safety surface. Require `state === 'unwired'` **and** a `reason`/`audit` body, else take the failure path. - **A failed re-read keeps the last good payload rendered green** (`gates.svelte.ts:135-157`, `overview.svelte.ts:52-70`): error banner plus `3 need you`, `configured` chip, badge 3, and census numbers — while the dashboard says "no counts are rendered from this read". The test covers only the no-prior-queue case. - **The gate countdown is frozen for the session** (`gates-pane.svelte:43`, `gate-row.svelte:42`): `now` defaults once, no timer, no SSE input — a gate still reads "3m left" after expiry, while ratify returns `410 expired_gate`. ### SUGGESTION - §4.2's fleet card drops the **cost** cell entirely (renders 5 unknown cells, neither renders nor declares cost). - The El Director digest line sits inside the updates card, not as §4.2's bottom full-width line. - The ledger echo omits `audit.scope` (the schema's value, `{kind:"all"}`); it shows the endpoint's informal `scope: "fleet"` instead, which `server/stop.go:33-37` explicitly says is *not* the ledger's scope. - No count reconciliation after an applied disposition (latent until LD3.1b wires the write seam). - `gates-pane.svelte:50` re-implements absence-not-zero without the store's `typeof count === 'number'` guard. - `client.ts:122` spreads `...options` after `headers`, so `gatesApi.decide`'s custom headers replace the merged object. ### Explicitly clean (falsification attempts failed) - **XSS (F): clean.** Not one `{@html}`, `innerHTML` or `document.write` in the app; every backend-controlled string reaches the DOM via Svelte escaping. Only interpolated `href`s are a constant and `identity-chip.svelte:30` (`me?.login_url`); a scheme guard there is defensible. - **"Never auto" is structurally true in the code** — the only POST paths are user-click-reachable, `decisionsFor()` (`gates.svelte.ts:59-64`) intersects the payload with the closed ratify/defer/veto vocabulary and drops `veto` for class E, and `decide()` refuses locally before any request. *(The test that guards this is a different story — see verdict 2.)* - **Derived-state vocabulary (§5.2):** all four states in the legend; `unknown` is hatched with tone `idle` in the only row, and `ok`/green is structurally unreachable from `DERIVED_STATE` (`honest-state.ts:73-78`). - **Panes stay mounted / `hidden`:** safe — no subscribe-after-event race, no cross-principal residue, tablist roles/roving tabindex correct. VERDICT: BLOCK
Author
Owner

Review verdict 2/2 — Test-adequacy lens → BLOCK

Independent reviewer, fresh context. Read the specs and the Go wire, then mutation-tested the two claims the slice is built on (mutations reverted; tree verified clean at aba6709).

Baseline: svelte-check 0/0 · vitest 137/137 · Playwright 43/43 against a real Go binary built from this tree.

BLOCKER

B1 — "no fabricated zeros" is a false green. The reviewer patched readmodel.UnwiredCensus to emit total:0, class_e_open:0, gates_needing_decision:0 — the exact regression P-COCKPIT-005 exists to prevent. The dashboard rendered class-E queue 0 · gates needing you 0 · 0 tickets seen with data-measured="true", and panes.spec.ts passed 12/12.

Mechanism: panes.spec.ts:100-107 branches on the component's own data-measured flag, which stat-cell.svelte:49 derives from typeof value === 'number' — so the else-branch toContainText(/\d/) accepts "0" as measured. That is circular: the spec cannot see a fabricated zero because it asks the view whether it thought the value was measured. And dashboard-pane.svelte gates only the chip on censusWired (line 156); the counts (185, 265, 202) never consult census.state, unlike the gates store (gates.svelte.ts:133).

Fix: (a) gate the values, value={censusWired ? measured(...) : null}; (b) assert absence directly in the live spec (census-total contains not measured, stat-gates-needing data-measured="false"). (b) alone makes a fabricated-zero backend fail.

B2 — "no automatic path anywhere" is not a structural guard. gates-configured.spec.ts:88-91 greps only page.locator('button')'s textContent. Two mutations each left the suite fully green: a <button>proceed</button> that ratifies without the confirmation dialog (e2e 5/5 and gates-pane.test.ts 12/12), and an icon-only <button aria-label="auto-land the recommended decision"> (empty textContent). button.svelte:47 renders an <a> when href is set — invisible to the grep — and title/aria-label/value, non-button controls, selects and keyboard handlers are all unexamined.

Fix: assert the complete affordance set inside [data-testid="gate-list"] — every interactive element is either [data-decision] (∈ ratify|defer|veto) or on a short explicit whitelist. The label grep is not a guard.

B3 — the census fixture contradicts the wire. fixtures.ts census() emits 3 state rows summing to 81 with total: 128, but the Go handler always emits all five groups (plane.Groups() → backlog/unstarted/started/completed/cancelled, iterated at readmodel/census.go:138) and total counts every issue into both (census.go:120-125). dashboard-pane.test.ts:54 then pins toHaveLength(3), so dropping zero-count rows — a plausible "reduce noise" edit — would pass.

Every other fixture field was key-diffed against the live binary's /api/v1/gates, /overview, /fleet/stop, /readmodel: exact match, including the audit.principal/audit.scope objects reconciled in aba6709 and the deliberate absence of count on the unwired queue. This is the only drift.

CONCERN

  • C1 Deleting the queue.state === 'configured' guard (gates.svelte.ts:133) leaves 137/137 green — no fixture carries {state:'unwired', count:0}. The defence works; it has no test.
  • C2 The disposition body is never exercised against the real handler (gates-configured.spec.ts:34-43 hard-codes the 503). A gate_nonce → gateNonce drift yields 400 invalid_decision and nothing fails. The only new write path in the slice.
  • C3 The 390px probes stub no routes, so they never see a configured queue — the rows, exposure grid and size="sm" buttons, i.e. where a 390px overflow would live, are untested.
  • C4 gates.svelte.ts:17-22 claims "The Go payload carries count: 0 on an unwired or failed read" — false (Count *int + omitempty; confirmed absent live) and contradicted by the same file at :120-123.

What is genuinely good

The class-E decision-vocabulary half is protected (removing the veto filter in decisionsFor fails gates.svelte.test.ts:96; class-E rows growing a veto fail three specs). The refusal-lands-on-its-row assertions, the store-level intersection tests and the aba6709 de-rot fix are real work: reverting the banner to {audit.principal} fails stop.svelte.test.ts:90 by mutation, and the fixture's countdown is now deterministic with no wall-clock rot.

VERDICT: BLOCK

## Review verdict 2/2 — Test-adequacy lens → **BLOCK** Independent reviewer, fresh context. Read the specs and the Go wire, then **mutation-tested** the two claims the slice is built on (mutations reverted; tree verified clean at `aba6709`). Baseline: `svelte-check` 0/0 · vitest 137/137 · Playwright 43/43 against a real Go binary built from this tree. ### BLOCKER **B1 — "no fabricated zeros" is a false green.** The reviewer patched `readmodel.UnwiredCensus` to emit `total:0, class_e_open:0, gates_needing_decision:0` — the exact regression P-COCKPIT-005 exists to prevent. The dashboard rendered `class-E queue 0 · gates needing you 0 · 0 tickets seen` with `data-measured="true"`, and **`panes.spec.ts` passed 12/12.** Mechanism: `panes.spec.ts:100-107` branches on the component's *own* `data-measured` flag, which `stat-cell.svelte:49` derives from `typeof value === 'number'` — so the else-branch `toContainText(/\d/)` accepts `"0"` as measured. That is circular: the spec cannot see a fabricated zero because it asks the view whether it thought the value was measured. And `dashboard-pane.svelte` gates only the *chip* on `censusWired` (line 156); the counts (185, 265, 202) never consult `census.state`, unlike the gates store (`gates.svelte.ts:133`). Fix: (a) gate the values, `value={censusWired ? measured(...) : null}`; (b) assert absence directly in the live spec (`census-total` contains *not measured*, `stat-gates-needing` `data-measured="false"`). (b) alone makes a fabricated-zero backend fail. **B2 — "no automatic path anywhere" is not a structural guard.** `gates-configured.spec.ts:88-91` greps only `page.locator('button')`'s **`textContent`**. Two mutations each left the suite fully green: a `<button>proceed</button>` that ratifies without the confirmation dialog (e2e 5/5 **and** `gates-pane.test.ts` 12/12), and an icon-only `<button aria-label="auto-land the recommended decision">` (empty `textContent`). `button.svelte:47` renders an `<a>` when `href` is set — invisible to the grep — and `title`/`aria-label`/`value`, non-button controls, selects and keyboard handlers are all unexamined. Fix: assert the **complete** affordance set inside `[data-testid="gate-list"]` — every interactive element is either `[data-decision]` (∈ ratify|defer|veto) or on a short explicit whitelist. The label grep is not a guard. **B3 — the census fixture contradicts the wire.** `fixtures.ts` `census()` emits **3** state rows summing to **81** with `total: 128`, but the Go handler always emits **all five** groups (`plane.Groups()` → backlog/unstarted/started/completed/cancelled, iterated at `readmodel/census.go:138`) and `total` counts every issue into both (`census.go:120-125`). `dashboard-pane.test.ts:54` then pins `toHaveLength(3)`, so dropping zero-count rows — a plausible "reduce noise" edit — would pass. Every **other** fixture field was key-diffed against the live binary's `/api/v1/gates`, `/overview`, `/fleet/stop`, `/readmodel`: exact match, including the `audit.principal`/`audit.scope` objects reconciled in `aba6709` and the deliberate absence of `count` on the unwired queue. This is the only drift. ### CONCERN - **C1** Deleting the `queue.state === 'configured'` guard (`gates.svelte.ts:133`) leaves 137/137 green — no fixture carries `{state:'unwired', count:0}`. The defence works; it has no test. - **C2** The disposition body is never exercised against the real handler (`gates-configured.spec.ts:34-43` hard-codes the 503). A `gate_nonce` → `gateNonce` drift yields 400 `invalid_decision` and nothing fails. The only new write path in the slice. - **C3** The 390px probes stub no routes, so they never see a **configured** queue — the rows, exposure grid and `size="sm"` buttons, i.e. where a 390px overflow would live, are untested. - **C4** `gates.svelte.ts:17-22` claims "The Go payload carries `count: 0` on an unwired or failed read" — false (`Count *int` + `omitempty`; confirmed absent live) and contradicted by the same file at `:120-123`. ### What is genuinely good The class-E decision-vocabulary half **is** protected (removing the veto filter in `decisionsFor` fails `gates.svelte.test.ts:96`; class-E rows growing a veto fail three specs). The refusal-lands-on-its-row assertions, the store-level intersection tests and the `aba6709` de-rot fix are real work: reverting the banner to `{audit.principal}` fails `stop.svelte.test.ts:90` by mutation, and the fixture's countdown is now deterministic with no wall-clock rot. VERDICT: BLOCK
HOMELAB-1682: fix(cockpit-web): review blockers — identity after tick, measuredness contract, never-auto structure, census fixture parity
Some checks failed
CI Review / helm-validate (pull_request) Successful in 16s
CI Review / receiver-test (pull_request) Successful in 12s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 12s
CI Review / alert-receiver-test (pull_request) Successful in 25s
CI Review / cockpit-test (pull_request) Failing after 1m32s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 1m10s
CI Review / dispatcher-test (pull_request) Successful in 2m20s
CI Review / cockpit-e2e (pull_request) Failing after 2m18s
a884e78506
Fixes the four blockers from the PR #578 dual review.

1. Identity falsehood after the SSE tick. The pane rendered
   `snapshot?.identity`, but every read-model tick is `Snapshot(nil)` by
   design (one payload serves every subscriber), so ~1s after load the
   process strip claimed "no identity resolved" while the operator was
   authenticated. The pane now renders the store's merged identity
   `readmodel.identity` (passed by the page) with the snapshot's own as a
   first-paint fallback, and a skeleton while the fetch is in flight.

2. "No fabricated zeros" was a false green. Measuredness is now derived
   from the SOURCE state (`censusWired && measured(...)`), never from the
   value: an unwired census that emits `0`s renders the hatched `?` and
   "not measured", not `0 tickets seen`. The live spec no longer branches
   on the component's own `data-measured` flag — the circularity that let
   a zero-emitting census pass 12/12; absence is now asserted directly,
   plus a new spec that serves the mutated (zeros + unwired) payload.

3. The never-auto guard grepped button textContent. The gates list now has
   a structural contract: every actionable element inside
   `[data-testid="gate-list"]` must be a `[data-decision]` in
   ratify|defer|veto or on a short explicit whitelist. An icon-only
   control, a fourth un-named button and an `<a href>` all fail now.

4. The census fixture contradicted the wire (3 rows / 81 beside
   `total: 128`). Go emits one row per `plane.Groups()` — always five, in
   render order — and counts every issue into `total`. The fixture now
   matches (5 rows summing to 128), `dashboard-pane.test.ts` pins the
   length and order, and a new `fixtures.test.ts` parses `plane.Groups()`
   from the Go source so the next backend row addition fails loudly.

Verification: svelte-check 0/0; vitest 143/143 (19 files); build OK;
Playwright 45/45 against a cockpit binary built from this tree (Go tree
identical to origin/epic/LD3); 390px scrollWidth 390/390 doc, 358/358 per
pane. Each guard is mutation-proved in the landing log.
Author
Owner

Landing attempt 2 — HALTED at check-first: required context cockpit-test is RED on a884e78

Not a review verdict (no delta review was run) — a CI gate report. Verified directly via GET /api/v1/repos/Homelab/infra-core/commits/a884e78506.../status: state: failure, both red contexts being the latest results on that sha.

context result run
CI Review / cockpit-test failure 1m32s #1703, attempt #1 only
CI Review / cockpit-e2e failure 2m18s #1703
helm-validate, receiver-test, dispatcher-test, alert-receiver-test success #1703
Build LD Cockpit Image (build-and-push, preflight) success #1704

#1704 is a different workflow on the same sha — not a green re-run of the red test workflow. forgejo_ci_failures shows a single attempt per red job, so no green attempt of the test workflow exists for a884e78.

cockpit-e2e — transient

44 passed, 1 failed: shell.spec.ts:123 (PWA service worker) Expected "activated" / Received "activating", identical on retry — the race already flagged at 04:47 in this PR. Untouched 3.1a spec. Would clear on re-run.

cockpit-test — REAL and deterministic, introduced by a884e78

FAIL  src/lib/tests/fixtures.test.ts [ 0 test ]
Error: ENOENT: no such file or directory, open '/tmp/plane/plane.go'
  ❯ src/lib/tests/fixtures.test.ts:23:18
Test Files  1 failed | 18 passed (19)      Tests  139 passed (139)   ← 4 claimed tests never collected

The new fixture↔wire parity guard (review blocker 4's fix) reads the Go vocabulary through

resolve(dirname(fileURLToPath(import.meta.url)), '../../../../plane/plane.go')

which is correct in a repo checkout but wrong in CI: ci.yaml:121-137 mounts only lab-director/cockpit/web and cp -r /web/. /tmp/work/, then runs vitest with root /tmp/work. The expression therefore resolves to /tmp/plane/plane.go (verified: .../tmp/work/src/lib/tests/fixtures.test.ts − 4 levels = /tmp), and /tmp/plane exists on the runner as an unrelated directory, so it surfaces as ENOENT on plane.go.

Locally it resolves to lab-director/cockpit/plane/plane.go (exists, Groups() at :64), which is why the fix worker's local 143/143 could not see it.

A path correction alone cannot fix this — the Go tree is not mounted in that container. Options, both crossing this PR's declared "no CI workflow" boundary:

  1. (recommended) ci.yaml cockpit-test step: -v "$PWD/lab-director/cockpit/plane:/go-plane:ro" -e PLANE_GO_PATH=/go-plane/plane.go, test reads process.env.PLANE_GO_PATH ?? <repo-relative fallback> — the guard stays load-bearing in CI, which is the only place backend drift arrives.
  2. Test-only candidate-path resolution that hard-fails (never silently skips) when no candidate resolves — a CI-only skip is a false green on the leg the guard serves.

No product-code defect is implied: the failing artefact is the new test's environment assumption. Delta review and the class-D visual pass were not run and must happen on the head that actually lands. Fix worker on a884e78 → new head → fresh landing run.

## Landing attempt 2 — HALTED at check-first: required context `cockpit-test` is RED on `a884e78` Not a review verdict (no delta review was run) — a CI gate report. Verified directly via `GET /api/v1/repos/Homelab/infra-core/commits/a884e78506.../status`: **`state: failure`**, both red contexts being the *latest* results on that sha. | context | result | run | |---|---|---| | CI Review / cockpit-test | **failure** 1m32s | #1703, **attempt #1 only** | | CI Review / cockpit-e2e | **failure** 2m18s | #1703 | | helm-validate, receiver-test, dispatcher-test, alert-receiver-test | success | #1703 | | Build LD Cockpit Image (build-and-push, preflight) | success | #1704 | #1704 is a *different workflow* on the same sha — **not** a green re-run of the red test workflow. `forgejo_ci_failures` shows a single attempt per red job, so no green attempt of the test workflow exists for `a884e78`. ### `cockpit-e2e` — transient `44 passed, 1 failed`: `shell.spec.ts:123` (PWA service worker) `Expected "activated" / Received "activating"`, identical on retry — the race already flagged at 04:47 in this PR. Untouched 3.1a spec. Would clear on re-run. ### `cockpit-test` — REAL and deterministic, introduced by `a884e78` ``` FAIL src/lib/tests/fixtures.test.ts [ 0 test ] Error: ENOENT: no such file or directory, open '/tmp/plane/plane.go' ❯ src/lib/tests/fixtures.test.ts:23:18 Test Files 1 failed | 18 passed (19) Tests 139 passed (139) ← 4 claimed tests never collected ``` The new fixture↔wire parity guard (review blocker 4's fix) reads the Go vocabulary through `resolve(dirname(fileURLToPath(import.meta.url)), '../../../../plane/plane.go')` which is correct in a **repo checkout** but wrong in CI: `ci.yaml:121-137` mounts only `lab-director/cockpit/web` and `cp -r /web/. /tmp/work/`, then runs vitest with root `/tmp/work`. The expression therefore resolves to **`/tmp/plane/plane.go`** (verified: `.../tmp/work/src/lib/tests/fixtures.test.ts` − 4 levels = `/tmp`), and `/tmp/plane` exists on the runner as an unrelated directory, so it surfaces as ENOENT on `plane.go`. Locally it resolves to `lab-director/cockpit/plane/plane.go` (exists, `Groups()` at :64), which is why the fix worker's local 143/143 could not see it. **A path correction alone cannot fix this** — the Go tree is not mounted in that container. Options, both crossing this PR's declared "no CI workflow" boundary: 1. *(recommended)* `ci.yaml` cockpit-test step: `-v "$PWD/lab-director/cockpit/plane:/go-plane:ro" -e PLANE_GO_PATH=/go-plane/plane.go`, test reads `process.env.PLANE_GO_PATH ?? <repo-relative fallback>` — the guard stays load-bearing in CI, which is the only place backend drift arrives. 2. Test-only candidate-path resolution that **hard-fails** (never silently skips) when no candidate resolves — a CI-only skip is a false green on the leg the guard serves. No product-code defect is implied: the failing artefact is the new test's environment assumption. Delta review and the class-D visual pass were **not run** and must happen on the head that actually lands. Fix worker on `a884e78` → new head → fresh landing run.
HOMELAB-1682: fix(cockpit-web): wire the fixture parity guard to the real tree in CI, de-flake the SW activation wait
All checks were successful
CI Review / receiver-test (pull_request) Successful in 6s
CI Review / helm-validate (pull_request) Successful in 7s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 4s
CI Review / alert-receiver-test (pull_request) Successful in 20s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 1m41s
CI Review / cockpit-e2e (pull_request) Successful in 1m53s
CI Review / cockpit-test (pull_request) Successful in 2m4s
CI Review / dispatcher-test (pull_request) Successful in 2m13s
694e8a7355
Author
Owner

Delta review — a884e78..694e8a7 → 0 blockers (2 concerns)

Independent reviewer, fresh context, primary sources only: the diff, the post-diff files, the installed vitest 4.1.11 and Playwright 1.63 — not the fix worker's summary. Verdict posted after 694e8a7 (694e8a735589b907d71be686e8b7440752ddb49e), so it supersedes the two verdicts at aba6709.

Diff verified to be exactly 5 files, no Go product code: ci.yaml, package.json, scripts/run-unit-tests.mjs (new), src/lib/tests/fixtures.test.ts, e2e/shell.spec.ts. The reviewer rebuilt the CI layout under /tmp and ran the suite in it.

Claim Verdict
(a) ci.yaml scoped to the cockpit-test frontend step; read-only mount; copy-inside; depth arithmetic; other jobs byte-identical clean
(b) parity guard hard-fails on missing source; suite collects in the CI-shaped layout concern (hole closed, floor is leaky)
(c) SW-activation wait deterministic without weakening what it proves clean
(d) nothing else smuggled in clean

(a) One hunk only (@@ -127,14 +127,25 @@), entirely inside the "Frontend check, unit tests and build" step. Mechanically checked, not eyeballed: the file with that hunk removed is diff-identical between the two heads. Mount stays :ro; copy still goes to /tmp/work, never into the checkout. Depth arithmetic computed and executed: web/src/lib/tests/ + ../../../../ = <module>/plane/plane.go locally, and /tmp/work/web/src/lib/tests/ + ../../../../ = /tmp/work/plane/plane.go in the copied layout — the reviewer rebuilt that tree from the module and got 143 passed (143), fixture parity guard: 4 tests collected. Falsified the old shape the same way (tree minus ../plane): 139 passed, guard (0 test), exit 1 — so the comment's "139 of 143" is accurate.

(b) The false-green class is real and genuinely closed: with the guard renamed out of the config glob, vitest run alone is green at 139 while the new script exits 1. JSON-reporter contract verified against the installed vitest rather than assumed (assertionResults, testResults[].name = file.filepath, --outputFile.json resolving where read, non-zero exit propagated, vitest.mjs is literally what the bin shim runs, cwd holds in the container's /tmp/work/web). Concern: the floor counts skipped/todo assertion entries — the reviewer added .skip to the guard's describe and the script printed 4 tests collected and exited 0. Requiring status === 'passed' closes it.

(c) The poll is not weaker. Falsified the "could it pass on a dead worker" hypothesis with Playwright's own expect: a permanently null value rejects, a permanently 'activating' value rejects after 8 samples, a value settling at sample 3 passes. The ready self-timeout, the origin scope assertion, the getRegistrations() origin check and the controller !== null check all remain. The removed installingState/waitingState fields were dead data at a884e78 (written, never read). The race is on record (CI #1703: activating, twice with retry). Not independently reproducible locally — the reviewer's 12/12 probe sampled activated immediately — so the "one run in five" rate stays anecdotal.

(d) Every changed line maps to (a)-(c); no unrelated edit, no mode/rename surprises.

Non-blocking findings carried forward

  • [concern] scripts/run-unit-tests.mjs floor counts skipped tests (demonstrated, above).
  • [concern] the runner's docblock (and the matching ci.yaml comment) misattributes the #1703 ENOENT as a silent drop — that shape already exits 1; the script's real value is the vanish case.
  • [suggestion] npm test -- <file> no longer forwards args to vitest; the mkdtemp report dir is never cleaned; PLANE_GO_PATH is honoured unvalidated (and is referenced nowhere else in the repo).

The four product fixes in a884e78 are accepted by this review as landed-delta-clean. The visual gate is reported separately — it found a blocker, so this PR does not land yet.

## Delta review — `a884e78..694e8a7` → **0 blockers** (2 concerns) Independent reviewer, **fresh context**, primary sources only: the diff, the post-diff files, the installed vitest 4.1.11 and Playwright 1.63 — not the fix worker's summary. Verdict posted **after** `694e8a7` (`694e8a735589b907d71be686e8b7440752ddb49e`), so it supersedes the two verdicts at `aba6709`. Diff verified to be exactly 5 files, no Go product code: `ci.yaml`, `package.json`, `scripts/run-unit-tests.mjs` (new), `src/lib/tests/fixtures.test.ts`, `e2e/shell.spec.ts`. The reviewer rebuilt the CI layout under `/tmp` and ran the suite in it. | Claim | Verdict | |---|---| | **(a)** `ci.yaml` scoped to the cockpit-test frontend step; read-only mount; copy-inside; depth arithmetic; other jobs byte-identical | **clean** | | **(b)** parity guard hard-fails on missing source; suite collects in the CI-shaped layout | **concern** (hole closed, floor is leaky) | | **(c)** SW-activation wait deterministic without weakening what it proves | **clean** | | **(d)** nothing else smuggled in | **clean** | **(a)** One hunk only (`@@ -127,14 +127,25 @@`), entirely inside the "Frontend check, unit tests and build" step. Mechanically checked, not eyeballed: the file with that hunk removed is `diff`-identical between the two heads. Mount stays `:ro`; copy still goes to `/tmp/work`, never into the checkout. Depth arithmetic computed *and* executed: `web/src/lib/tests/ + ../../../../` = `<module>/plane/plane.go` locally, and `/tmp/work/web/src/lib/tests/ + ../../../../` = `/tmp/work/plane/plane.go` in the copied layout — the reviewer rebuilt that tree from the module and got `143 passed (143)`, `fixture parity guard: 4 tests collected`. Falsified the old shape the same way (tree minus `../plane`): `139 passed`, guard `(0 test)`, **exit 1** — so the comment's "139 of 143" is accurate. **(b)** The false-green class is real and genuinely closed: with the guard renamed out of the config glob, `vitest run` alone is **green** at 139 while the new script exits 1. JSON-reporter contract verified against the installed vitest rather than assumed (`assertionResults`, `testResults[].name = file.filepath`, `--outputFile.json` resolving where read, non-zero exit propagated, `vitest.mjs` is literally what the bin shim runs, cwd holds in the container's `/tmp/work/web`). **Concern:** the floor counts *skipped/todo* assertion entries — the reviewer added `.skip` to the guard's `describe` and the script printed `4 tests collected` and **exited 0**. Requiring `status === 'passed'` closes it. **(c)** The poll is not weaker. Falsified the "could it pass on a dead worker" hypothesis with Playwright's own `expect`: a permanently `null` value rejects, a permanently `'activating'` value rejects after 8 samples, a value settling at sample 3 passes. The `ready` self-timeout, the origin `scope` assertion, the `getRegistrations()` origin check and the `controller !== null` check all remain. The removed `installingState`/`waitingState` fields were **dead data** at `a884e78` (written, never read). The race is on record (CI #1703: `activating`, twice with retry). Not independently reproducible locally — the reviewer's 12/12 probe sampled `activated` immediately — so the "one run in five" rate stays anecdotal. **(d)** Every changed line maps to (a)-(c); no unrelated edit, no mode/rename surprises. ### Non-blocking findings carried forward - `[concern]` `scripts/run-unit-tests.mjs` floor counts skipped tests (demonstrated, above). - `[concern]` the runner's docblock (and the matching `ci.yaml` comment) misattributes the #1703 ENOENT as a silent drop — that shape already exits 1; the script's real value is the *vanish* case. - `[suggestion]` `npm test -- <file>` no longer forwards args to vitest; the `mkdtemp` report dir is never cleaned; `PLANE_GO_PATH` is honoured unvalidated (and is referenced nowhere else in the repo). **The four product fixes in `a884e78` are accepted by this review as landed-delta-clean.** The visual gate is reported separately — it found a blocker, so this PR does not land yet.
Author
Owner

Class-D visual check — 1 BLOCKER: the STOP confirmation is clipped into the header and opens off-screen

Independent pass on 694e8a7. Scratch copy (git archive 694e8a7 → /tmp/ld3-578-visual), real Go binary built from the same tree, port 8177 (unique), real build. 14 screenshots (7 surfaces × 1440/390) + 8 independent DOM probes. No tracked file in the worktree was touched.

BLOCKER — pressing ⏹ renders an undimmed ~48 px sliver instead of the confirmation

Reproduced geometry (getBoundingClientRect + elementFromPoint):

1440x900   backdrop top=0 bottom=48   (h=48,  overflow auto)   dialog top=-174 h=395   title=offscreen   accept=covered-by-div
 390x844   backdrop top=0 bottom=128  (h=128, overflow auto)   dialog top=-351 h=467   title=offscreen   accept=hittable

Source-verified mechanism, not inferred. app-header.svelte:36 is class="sticky top-0 z-30 … backdrop-blur" and app-header.svelte:104 mounts <StopControl/> inside it; stop-control.svelte:44 mounts <ConfirmDialog> there; confirm-dialog.svelte's backdrop is position: fixed. A backdrop-filter ancestor becomes the containing block for position: fixed descendants (CSS filter-effects), so the backdrop resolves against the 49 px sticky header rather than the viewport. Its overflow: auto then clips the dialog to a 48 px strip.

At 1440 an operator who presses STOP sees a bright, undimmed 48 px scroll strip containing only the tail of the confirmation — "refusal is shown back to you." and "WHAT IS WRITTEN DOWN BEFORE THE ANSWER". The title Stop the fleet?, the R8 fleet-scope sentence (whole fleet; SIGSTOP ≤2 s; bus steer/ask suspended) and both buttons are not on screen; at 390 the accept button happens to land inside the strip and the title is off-screen. This is the confirmation R8 and §5.4 mandate for the most dangerous control in the app, and it is unreadable.

gate-decision-dialog.svelte is unaffected — it is mounted in the pane, not the header — which is why the ratify dialog renders correctly and this one does not.

Why CI is green anyway: Playwright scrolls the clipping container to bring confirm-accept into view before clicking, so the suite exercises a path a human cannot see. panes.spec.ts asserts toBeVisible, which only requires a non-empty box. The visual gate is the only leg that can catch this.

Fix direction (reporting, not applied): render the dialog into a portal/host outside the header — or drop backdrop-blur from the header's containing-block chain — then re-verify dialog.top >= 0 and backdrop.height == innerHeight at both widths.

Clean — the four a884e78 fixes are visually verified

Surface Machine evidence Verdict
Identity after SSE ticks (blocker 1) after 5 s of 1 s ticks: "as e2e-operator · dev:e2e-operator" fixed — and the defect is plainly visible in this PR's own baseline PNG, which reads "no identity resolved"
Measuredness / no fabricated zeros (blocker 2) census total "not measured", unwired chip present, 10 [data-measured="false"] hatched cells fixed
Gates unwired header reads count not measured — never 0 clean
Gates configured 3 need you; class-E row offers ratify defer only; needs-gate row renders 0 controls clean
Decision dialog confirm-accept disabled until a reason is written clean
Ledger principal (audit-shape fix) principal unknown dev:e2e-operator, no [object Object] clean
STOP not armed data-tone=warn amber banner, audit echoed clean
Chat typed message recorded as not sent, reason shown clean
1440 + 390, all panes + both dialogs scrollWidth == clientWidth; 0 overflowing elements outside a designed scroller; 0 targets below 24 px clean

Concern — the configured queue evidence is not reproducible from this branch

The baseline gates-configured-{1440,390}.png show 4 rows, including a class-B HOMELAB-1700 / nonce g-1700-1. src/lib/tests/fixtures.ts has contained exactly items: [gateItem(), classEGateItem(), needsGateItem()] = 3 items in every commit that ever touched it, including the builder's own 3f1fcc9; HOMELAB-1700 and g-1700-1 appear nowhere in the repo history; and gates-configured.spec.ts asserted toHaveCount(3) at aba6709 as well. That screenshot was taken from a payload the branch cannot produce. Not a product defect — the product renders whatever the backend returns — but the attached evidence does not match the code it is evidence for.

Not merged

Per the landing rule, a class-D slice does not land on a red visual pass, whatever CI says. forgejo_merge_pr was not called and no GitOps continuation ran. Next: one fix worker on 694e8a7 to portal the STOP dialog, then a delta review + visual re-check on the new head. Full log: .rpiv/artifacts/build/ld3-1682b-landing.md.

## Class-D visual check — **1 BLOCKER**: the STOP confirmation is clipped into the header and opens off-screen Independent pass on `694e8a7`. Scratch copy (`git archive 694e8a7` → `/tmp/ld3-578-visual`), real Go binary built from the same tree, port **8177** (unique), real build. 14 screenshots (7 surfaces × 1440/390) + 8 independent DOM probes. No tracked file in the worktree was touched. ### BLOCKER — pressing ⏹ renders an undimmed ~48 px sliver instead of the confirmation Reproduced geometry (`getBoundingClientRect` + `elementFromPoint`): ``` 1440x900 backdrop top=0 bottom=48 (h=48, overflow auto) dialog top=-174 h=395 title=offscreen accept=covered-by-div 390x844 backdrop top=0 bottom=128 (h=128, overflow auto) dialog top=-351 h=467 title=offscreen accept=hittable ``` **Source-verified mechanism, not inferred.** `app-header.svelte:36` is `class="sticky top-0 z-30 … backdrop-blur"` and `app-header.svelte:104` mounts `<StopControl/>` inside it; `stop-control.svelte:44` mounts `<ConfirmDialog>` there; `confirm-dialog.svelte`'s backdrop is `position: fixed`. A **`backdrop-filter` ancestor becomes the containing block for `position: fixed` descendants** (CSS filter-effects), so the backdrop resolves against the 49 px sticky header rather than the viewport. Its `overflow: auto` then clips the dialog to a 48 px strip. At 1440 an operator who presses STOP sees a bright, undimmed 48 px scroll strip containing only the tail of the confirmation — *"refusal is shown back to you."* and *"WHAT IS WRITTEN DOWN BEFORE THE ANSWER"*. The title **Stop the fleet?**, the R8 fleet-scope sentence (whole fleet; SIGSTOP ≤2 s; bus steer/ask suspended) and both buttons are **not on screen**; at 390 the accept button happens to land inside the strip and the title is off-screen. This is the confirmation R8 and §5.4 mandate for the most dangerous control in the app, and it is unreadable. `gate-decision-dialog.svelte` is unaffected — it is mounted in the pane, not the header — which is why the ratify dialog renders correctly and this one does not. **Why CI is green anyway:** Playwright scrolls the clipping container to bring `confirm-accept` into view before clicking, so the suite exercises a path a human cannot see. `panes.spec.ts` asserts `toBeVisible`, which only requires a non-empty box. The visual gate is the only leg that can catch this. **Fix direction** (reporting, not applied): render the dialog into a portal/host outside the header — or drop `backdrop-blur` from the header's containing-block chain — then re-verify `dialog.top >= 0` and `backdrop.height == innerHeight` at both widths. ### Clean — the four `a884e78` fixes are visually verified | Surface | Machine evidence | Verdict | |---|---|---| | **Identity after SSE ticks** (blocker 1) | after 5 s of 1 s ticks: `"as e2e-operator · dev:e2e-operator"` | **fixed** — and the defect is plainly visible in this PR's own baseline PNG, which reads *"no identity resolved"* | | **Measuredness / no fabricated zeros** (blocker 2) | census total `"not measured"`, unwired chip present, 10 `[data-measured="false"]` hatched cells | fixed | | Gates unwired | header reads `count not measured` — never `0` | clean | | Gates configured | `3 need you`; class-E row offers `ratify defer` only; needs-gate row renders 0 controls | clean | | Decision dialog | `confirm-accept` disabled until a reason is written | clean | | **Ledger principal** (audit-shape fix) | principal `unknown dev:e2e-operator`, **no `[object Object]`** | clean | | STOP not armed | `data-tone=warn` amber banner, audit echoed | clean | | Chat | typed message recorded as not sent, reason shown | clean | | 1440 + 390, all panes + both dialogs | `scrollWidth == clientWidth`; 0 overflowing elements outside a designed scroller; 0 targets below 24 px | clean | ### Concern — the *configured queue* evidence is not reproducible from this branch The baseline `gates-configured-{1440,390}.png` show **4** rows, including a class-B `HOMELAB-1700` / nonce `g-1700-1`. `src/lib/tests/fixtures.ts` has contained exactly `items: [gateItem(), classEGateItem(), needsGateItem()]` = **3** items in every commit that ever touched it, including the builder's own `3f1fcc9`; `HOMELAB-1700` and `g-1700-1` appear nowhere in the repo history; and `gates-configured.spec.ts` asserted `toHaveCount(3)` at `aba6709` as well. That screenshot was taken from a payload the branch cannot produce. Not a product defect — the product renders whatever the backend returns — but the attached evidence does not match the code it is evidence for. ## Not merged Per the landing rule, a class-D slice does not land on a red visual pass, whatever CI says. `forgejo_merge_pr` was **not** called and no GitOps continuation ran. Next: one fix worker on `694e8a7` to portal the STOP dialog, then a delta review + visual re-check on the new head. Full log: `.rpiv/artifacts/build/ld3-1682b-landing.md`.
HOMELAB-1682: fix(cockpit-web): portal the STOP dialog to root, viewport-assert both dialogs
All checks were successful
CI Review / receiver-test (pull_request) Successful in 13s
CI Review / helm-validate (pull_request) Successful in 20s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 7s
CI Review / alert-receiver-test (pull_request) Successful in 31s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m20s
CI Review / cockpit-test (pull_request) Successful in 2m50s
CI Review / dispatcher-test (pull_request) Successful in 4m9s
CI Review / cockpit-e2e (pull_request) Successful in 4m7s
609d25c55b
The class-D visual blocker on PR #578: the STOP confirmation opened 174px above
the viewport at 1440 (351px at 390), clipped to the header's 48px strip.

Cause (source-verified): `app-header.svelte` carries `backdrop-blur`, and a
`backdrop-filter` ancestor becomes the containing block for `position: fixed`
descendants. The dialog's backdrop therefore resolved against the sticky header
instead of the viewport, and the header's overflow clipped it. The gate dialog
was unaffected only because it is mounted in the pane.

Fix: `ConfirmDialog` now portals its backdrop to `<body>` via `$lib/actions/portal`
(a `use:` action), which removes the modal from every ancestor's containing block
and overflow chain at once. The header's blur token is untouched and the STOP
trigger stays in the header. Both dialogs get the guarantee.

Guard: `e2e/dialog-viewport.ts` asserts, BEFORE anything is scrolled, that the
dialog box is fully inside the viewport on all four edges and that the backdrop
covers it exactly (top 0, bottom innerHeight) — a direct viewport measurement,
because Playwright scrolls the clipping ancestor to click and stayed green.
Applied at 1440 and 390 for STOP (one test per width) and at 390 for ratify.

Evidence: pre-fix the new specs fail with {"top":-173.6,"backdropBottom":48} at
1440 and {"top":-351,"backdropBottom":128} at 390; post-fix they measure
{"top":252.4,"bottom":647.6,"backdropBottom":900} and
{"top":365,"bottom":832,"backdropBottom":844}.

Verified: svelte-check 0/0, unit 145/145 (20 files), build OK, e2e 48/48,
390px scrollWidth 390 == clientWidth (unchanged).
Author
Owner

Fix worker report — class-D visual blocker (STOP dialog off-viewport)

Head 609d25c (on 694e8a7). Territory: lab-director/cockpit/web/** only.

Fix. ConfirmDialog now portals its backdrop to <body> via a documented use: action (src/lib/actions/portal.ts); the header keeps its backdrop-blur token and the STOP trigger stays in the header. A backdrop-filter ancestor is the containing block for position: fixed descendants, so the portal is the structural fix — it removes the modal from every ancestor's containing block/overflow chain, for both dialogs.

Guard — the assertion that would have caught it. e2e/dialog-viewport.ts measures the dialog in the page before anything is scrolled and asserts the box is inside the viewport on all four edges and the backdrop covers it exactly (top 0, bottom == innerHeight). Applied per-width for STOP (1440, 390) and at 390 for ratify.

Pre-fix (failing, committed assertions):

STOP 1440  {"top":-173.6,"bottom":221.6,"backdropTop":0,"backdropBottom":48,...}
STOP  390  {"top":-351,  "bottom":116,  "backdropTop":0,"backdropBottom":128,...}
ratify 390 PASS (mounted in the pane, as reported)

Post-fix:

STOP 1440  {"top":252.4,"bottom":647.6,"backdropBottom":900}
STOP  390  {"top":365,  "bottom":832,  "backdropBottom":844}
ratify 390 {"top":118.3,"bottom":832,  "backdropBottom":844}

Screenshots (pre-fix sliver vs post-fix full dialog) in .rpiv/artifacts/build/ld3-1682b-fix/.

Suite counts. svelte-check 0/0 · unit 145 passed / 20 files (+2: portal.test.ts; floor guard 4 collected) · build OK · full e2e 48 passed (auth 8, gates-configured 6, panes 16, responsive 9, shell 9; was 45, +3) · 390px scrollWidth 390 == clientWidth unchanged (same with the dialog open).

No Go, CI workflow, or manifest touched.

## Fix worker report — class-D visual blocker (STOP dialog off-viewport) Head `609d25c` (on `694e8a7`). Territory: `lab-director/cockpit/web/**` only. **Fix.** `ConfirmDialog` now portals its backdrop to `<body>` via a documented `use:` action (`src/lib/actions/portal.ts`); the header keeps its `backdrop-blur` token and the STOP trigger stays in the header. A `backdrop-filter` ancestor is the containing block for `position: fixed` descendants, so the portal is the structural fix — it removes the modal from every ancestor's containing block/overflow chain, for both dialogs. **Guard — the assertion that would have caught it.** `e2e/dialog-viewport.ts` measures the dialog in the page **before anything is scrolled** and asserts the box is inside the viewport on all four edges and the backdrop covers it exactly (`top 0`, `bottom == innerHeight`). Applied per-width for STOP (1440, 390) and at 390 for ratify. **Pre-fix (failing, committed assertions):** ``` STOP 1440 {"top":-173.6,"bottom":221.6,"backdropTop":0,"backdropBottom":48,...} STOP 390 {"top":-351, "bottom":116, "backdropTop":0,"backdropBottom":128,...} ratify 390 PASS (mounted in the pane, as reported) ``` **Post-fix:** ``` STOP 1440 {"top":252.4,"bottom":647.6,"backdropBottom":900} STOP 390 {"top":365, "bottom":832, "backdropBottom":844} ratify 390 {"top":118.3,"bottom":832, "backdropBottom":844} ``` Screenshots (pre-fix sliver vs post-fix full dialog) in `.rpiv/artifacts/build/ld3-1682b-fix/`. **Suite counts.** svelte-check 0/0 · unit **145 passed / 20 files** (+2: `portal.test.ts`; floor guard 4 collected) · build OK · full e2e **48 passed** (auth 8, gates-configured 6, panes 16, responsive 9, shell 9; was 45, +3) · 390px `scrollWidth 390 == clientWidth` unchanged (same with the dialog open). No Go, CI workflow, or manifest touched.
Author
Owner

CI green on 609d25c: run #1713 — cockpit-e2e ✔ cockpit-test ✔ dispatcher-test ✔ helm-validate ✔ receiver-test ✔ alert-receiver-test ✔; run #1714 — build-and-push ✔ preflight ✔. (Both runs are on this sha, no re-run involved.)

CI green on `609d25c`: run #1713 — cockpit-e2e ✔ cockpit-test ✔ dispatcher-test ✔ helm-validate ✔ receiver-test ✔ alert-receiver-test ✔; run #1714 — build-and-push ✔ preflight ✔. (Both runs are on this sha, no re-run involved.)
Author
Owner

Delta review — 694e8a7..609d25c → 0 blockers (7 concerns)

Independent reviewer, fresh context (pi -p --no-session --model deepseek/deepseek-flash), primary sources only: the diff, the post-diff files, the repo's own suite. Verdict posted after 609d25c (609d25c55b0a8319dbe51b7ce98788001fe443f7), so it supersedes every earlier verdict on this PR.

Diff is exactly 7 files / 245 insertions / 0 deletions — e2e/dialog-viewport.ts, e2e/panes.spec.ts, e2e/gates-configured.spec.ts, src/lib/actions/portal.ts, src/lib/actions/portal.test.ts, src/lib/components/shell/stop.svelte.test.ts, src/lib/components/ui/dialog/confirm-dialog.svelte. Zero deleted lines means no pre-existing assertion was modified or weakened.

Independent baseline re-run by the reviewer: npm test 145 passed / 20 files; npm run check 0 errors / 0 warnings; npx playwright test e2e/panes.spec.ts e2e/gates-configured.spec.ts 22/22.

Claim Verdict
(1) the portal action is sound (SSR-safe, cleanup on destroy, no event leak) clean
(2) header blur token untouched, trigger still in the header clean
(3) the new e2e assertion is load-bearing (would FAIL pre-portal) clean
(4) nothing else smuggled in clean

(1) portal.ts:32-37 is one function with no module-scope document access (the document.body default is evaluated per call, inside the action), so no SSR guard is needed and none is missing; npm run build runs the SvelteKit SSR pass clean. Relocation is appendChild (:33), not outerHTML/innerHTML, so node identity, listeners and Svelte bindings survive — demonstrated by the portalled confirm-cancel/confirm-accept clicks in the suite. Teardown is node.remove() (:35-37); portal.test.ts:34,36 pins parent-is-body and removed-after-destroy. No host wrapper exists, so no stale-host bookkeeping and no collision path between two dialogs. Svelte 5's delegation listener is attached to document as well as the mount target explicitly for manual portals (svelte/src/internal/client/render.js:235-243), and the Escape handler is a window listener (confirm-dialog.svelte:90-104) — verified live. body.childNodes measured across three open/cancel cycles + Escape: 124 → 125 → 124, no growth.

(2) app-header.svelte is absent from the diff; it still carries backdrop-blur (:36) and still mounts the trigger (:104). The structural fix was taken, not the blur removal.

(3) The reviewer re-parented the portalled backdrop back into its pre-fix mount site in real Chromium (reproducing the backdrop-filter containing block exactly) and measured:

viewport pre-fix dialog pre-fix backdrop post-fix dialog post-fix backdrop
1440×900 -173.6 / 221.6 0..48 252.4 / 647.6 0..900
390×844 -351.0 / 116.0 0..128 365.0 / 832.0 0..844

dialog-viewport.ts:83-93 compares exactly (top < 0, backdropBottom !== viewportHeight), rounded to 0.1 only for sub-pixel noise, and collects every violation into one toEqual([]) — the pre-fix geometry fails on two independent edges at each width, with no tolerance that could save it. Measurement is taken at scrollY === 0 before any click-driven scrolling, and the tests are generated per viewport (panes.spec.ts:295-296: 1440 and 390), each arming the dialog via stop-button.

Blockers

None. The single class-D blocker (STOP confirmation off-viewport, clipped into the header) is fixed, and its guard provably fails on the pre-fix geometry.

Concerns (non-blocking, all documentation/coverage)

  1. portal.ts:9-10 / dialog-viewport.ts:13-14 attribute the clip to "the header's overflow" — app-header.svelte:36 has no overflow utility; the clip is the backdrop's own overflow-y-auto (confirm-dialog.svelte:105) on a child overflowing upward. Nothing in the fix or the assertion depends on it.
  2. The "Playwright auto-scrolls a clipping ancestor" explanation (panes.spec.ts:288-290) is not the observed mechanism — measured scrollY = 0 pre- and post-fix; the old suite was green because toBeVisible() only needs a non-empty box. The assertion's validity is unaffected.
  3. gates-configured.spec.ts:200-202 still says the ratify dialog is "mounted in the pane"; post-fix it is portalled too. Its geometry test is coverage of a different mount site, not a false green — and the comment says so.
  4. stop.svelte.test.ts:45 (closest('header') is null) is tautological in that harness (no <header> in the testing-library tree either way); the load-bearing sibling is :44.
  5. scrollY is collected but never asserted (dialog-viewport.ts:40,64) while both new test names claim "with nothing scrolled first". One expect(g.scrollY).toBe(0) would make the name true.
  6. portal.ts:32 targets document.body, outside the app root; harmless (all tokens are :root-scoped, computed styles verified) but a future root-scoped wrapper would silently stop applying to dialogs.
  7. Out of scope, not attributable to this commit: body direct children grow 3 → ~124 within seconds of load with no dialog opened (SSE-tick text growth); the portal's own contribution is exactly symmetric.
## Delta review — `694e8a7..609d25c` → **0 blockers** (7 concerns) Independent reviewer, **fresh context** (`pi -p --no-session --model deepseek/deepseek-flash`), primary sources only: the diff, the post-diff files, the repo's own suite. Verdict posted **after** `609d25c` (`609d25c55b0a8319dbe51b7ce98788001fe443f7`), so it supersedes every earlier verdict on this PR. Diff is exactly 7 files / **245 insertions / 0 deletions** — `e2e/dialog-viewport.ts`, `e2e/panes.spec.ts`, `e2e/gates-configured.spec.ts`, `src/lib/actions/portal.ts`, `src/lib/actions/portal.test.ts`, `src/lib/components/shell/stop.svelte.test.ts`, `src/lib/components/ui/dialog/confirm-dialog.svelte`. Zero deleted lines means no pre-existing assertion was modified or weakened. Independent baseline re-run by the reviewer: `npm test` **145 passed / 20 files**; `npm run check` **0 errors / 0 warnings**; `npx playwright test e2e/panes.spec.ts e2e/gates-configured.spec.ts` **22/22**. | Claim | Verdict | |---|---| | **(1)** the portal action is sound (SSR-safe, cleanup on destroy, no event leak) | **clean** | | **(2)** header blur token untouched, trigger still in the header | **clean** | | **(3)** the new e2e assertion is load-bearing (would FAIL pre-portal) | **clean** | | **(4)** nothing else smuggled in | **clean** | **(1)** `portal.ts:32-37` is one function with no module-scope `document` access (the `document.body` default is evaluated per call, inside the action), so no SSR guard is needed and none is missing; `npm run build` runs the SvelteKit SSR pass clean. Relocation is `appendChild` (`:33`), not `outerHTML`/`innerHTML`, so node identity, listeners and Svelte bindings survive — demonstrated by the portalled `confirm-cancel`/`confirm-accept` clicks in the suite. Teardown is `node.remove()` (`:35-37`); `portal.test.ts:34,36` pins parent-is-body and removed-after-destroy. No host wrapper exists, so no stale-host bookkeeping and no collision path between two dialogs. Svelte 5's delegation listener is attached to `document` as well as the mount target explicitly for manual portals (`svelte/src/internal/client/render.js:235-243`), and the Escape handler is a `window` listener (`confirm-dialog.svelte:90-104`) — verified live. `body.childNodes` measured across three open/cancel cycles + Escape: **124 → 125 → 124**, no growth. **(2)** `app-header.svelte` is **absent from the diff**; it still carries `backdrop-blur` (`:36`) and still mounts the trigger (`:104`). The structural fix was taken, not the blur removal. **(3)** The reviewer re-parented the portalled backdrop back into its pre-fix mount site in real Chromium (reproducing the `backdrop-filter` containing block exactly) and measured: | viewport | pre-fix dialog | pre-fix backdrop | post-fix dialog | post-fix backdrop | |---|---|---|---|---| | 1440×900 | **-173.6 / 221.6** | **0..48** | 252.4 / 647.6 | 0..900 | | 390×844 | **-351.0 / 116.0** | **0..128** | 365.0 / 832.0 | 0..844 | `dialog-viewport.ts:83-93` compares exactly (`top < 0`, `backdropBottom !== viewportHeight`), rounded to 0.1 only for sub-pixel noise, and collects every violation into one `toEqual([])` — the pre-fix geometry fails on two independent edges at each width, with no tolerance that could save it. Measurement is taken at `scrollY === 0` before any click-driven scrolling, and the tests are generated per viewport (`panes.spec.ts:295-296`: 1440 and 390), each arming the dialog via `stop-button`. ### Blockers **None.** The single class-D blocker (STOP confirmation off-viewport, clipped into the header) is fixed, and its guard provably fails on the pre-fix geometry. ### Concerns (non-blocking, all documentation/coverage) 1. `portal.ts:9-10` / `dialog-viewport.ts:13-14` attribute the clip to "the header's `overflow`" — `app-header.svelte:36` has no overflow utility; the clip is the backdrop's own `overflow-y-auto` (`confirm-dialog.svelte:105`) on a child overflowing upward. Nothing in the fix or the assertion depends on it. 2. The "Playwright auto-scrolls a clipping ancestor" explanation (`panes.spec.ts:288-290`) is not the observed mechanism — measured `scrollY = 0` pre- and post-fix; the old suite was green because `toBeVisible()` only needs a non-empty box. The assertion's validity is unaffected. 3. `gates-configured.spec.ts:200-202` still says the ratify dialog is "mounted in the pane"; post-fix it is portalled too. Its geometry test is coverage of a different mount site, not a false green — and the comment says so. 4. `stop.svelte.test.ts:45` (`closest('header')` is null) is tautological in that harness (no `<header>` in the testing-library tree either way); the load-bearing sibling is `:44`. 5. `scrollY` is collected but never asserted (`dialog-viewport.ts:40,64`) while both new test names claim "with nothing scrolled first". One `expect(g.scrollY).toBe(0)` would make the name true. 6. `portal.ts:32` targets `document.body`, outside the app root; harmless (all tokens are `:root`-scoped, computed styles verified) but a future root-scoped wrapper would silently stop applying to dialogs. 7. Out of scope, not attributable to this commit: `body` direct children grow 3 → ~124 within seconds of load with no dialog opened (SSE-tick text growth); the portal's own contribution is exactly symmetric.
Author
Owner

Class-D visual spot-check — 609d25c → clean (targeted re-check of the halted surface)

Scratch copy (git archive 609d25c), real Go binary built from the same tree, real npm run build, port 8191 (unique). Nothing in the branch worktree was touched. Four viewport probes in real Chromium; the three prior landings' findings were all on the STOP dialog, so this pass targets exactly that plus its sibling.

Probe Geometry (getBoundingClientRect) Verdict
STOP dialog @1440×900 dialog top 252.4 / bottom 647.6 / left 464 / right 976, backdrop 0..900 (= viewport), scrollY 0, title Stop the fleet? on screen, both buttons hit-testable fixed — was top -173.6, backdrop 0..48
STOP dialog @390×844 dialog top 365 / bottom 832 / left 12 / right 378, backdrop 0..844, scrollY 0, title visible fixed — was top -351, backdrop 0..128
ratify dialog @390×844 dialog top 118.3 / bottom 832, backdrop 0..844; reason textarea present, ratify disabled until a reason is written (P-GATE-002) clean
identity chip after 5 s of SSE ticks as e2e-operator · dev:e2e-operator before and after; never "no identity resolved" clean

The dimming is real at both widths — in the pre-fix captures the header strip was undimmed because the backdrop was the 48 px header. Screenshots: /tmp/ld3-visual/{stop-1440,stop-390,ratify-390,identity-after-5s-1440}.png. Note the ratify probe had scrollY 30 (the click scrolled the pane); the dialog is position: fixed and the geometry is viewport-relative, so this does not weaken the STOP numbers, which were taken at scrollY 0.

No new visual finding. The blocker from the previous class-D pass is closed.

## Class-D visual spot-check — `609d25c` → **clean** (targeted re-check of the halted surface) Scratch copy (`git archive 609d25c`), real Go binary built from the same tree, real `npm run build`, port **8191** (unique). Nothing in the branch worktree was touched. Four viewport probes in real Chromium; the three prior landings' findings were all on the STOP dialog, so this pass targets exactly that plus its sibling. | Probe | Geometry (`getBoundingClientRect`) | Verdict | |---|---|---| | **STOP dialog @1440×900** | dialog `top 252.4 / bottom 647.6 / left 464 / right 976`, backdrop `0..900` (= viewport), `scrollY 0`, title *Stop the fleet?* on screen, both buttons hit-testable | **fixed** — was `top -173.6`, backdrop `0..48` | | **STOP dialog @390×844** | dialog `top 365 / bottom 832 / left 12 / right 378`, backdrop `0..844`, `scrollY 0`, title visible | **fixed** — was `top -351`, backdrop `0..128` | | **ratify dialog @390×844** | dialog `top 118.3 / bottom 832`, backdrop `0..844`; reason textarea present, `ratify` disabled until a reason is written (P-GATE-002) | clean | | **identity chip after 5 s of SSE ticks** | `as e2e-operator · dev:e2e-operator` before **and** after; never "no identity resolved" | clean | The dimming is real at both widths — in the pre-fix captures the header strip was undimmed because the backdrop *was* the 48 px header. Screenshots: `/tmp/ld3-visual/{stop-1440,stop-390,ratify-390,identity-after-5s-1440}.png`. Note the ratify probe had `scrollY 30` (the click scrolled the pane); the dialog is `position: fixed` and the geometry is viewport-relative, so this does not weaken the STOP numbers, which were taken at `scrollY 0`. No new visual finding. The blocker from the previous class-D pass is closed.
Author
Owner

Review — 609d25c

verdict: pass

Independent delta review of 694e8a7..609d25c (fresh context, pi -p --no-session --model deepseek/deepseek-flash, primary sources only: the diff, the post-diff files, the repo's own suite). Verdict posted after head 609d25c55b0a8319dbe51b7ce98788001fe443f7; it supersedes the verdicts at aba6709 and 694e8a7.

0 blockers. Diff is exactly 7 files / 245 insertions / 0 deletions — no pre-existing assertion was modified or weakened. No Go, CI workflow, manifest or header change.

Reviewer re-ran the suite itself: unit 145 passed / 20 files, svelte-check 0/0, playwright e2e/panes.spec.ts e2e/gates-configured.spec.ts 22/22. It then reproduced the defect and the fix in real Chromium by re-parenting the portalled backdrop into its pre-fix mount site:

viewport pre-fix dialog pre-fix backdrop post-fix dialog post-fix backdrop
1440×900 -173.6 / 221.6 0..48 252.4 / 647.6 0..900
390×844 -351.0 / 116.0 0..128 365.0 / 832.0 0..844
Claim Verdict
(1) portal sound — SSR-safe, appendChild relocation, destroy removes, no event leak clean
(2) header backdrop-blur token untouched, trigger still mounted in the header clean
(3) the new e2e assertion is load-bearing — it FAILS on the pre-fix geometry clean
(4) nothing else smuggled in clean
  • (1) portal.ts:32-37 has no module-scope document access (the default is evaluated per call inside the action), so no SSR guard is missing; the SvelteKit SSR build runs clean. Relocation is appendChild (:33), so node identity, listeners and bindings survive; teardown is node.remove() (:35-37), pinned by portal.test.ts:34,36. No host wrapper, so no collision path. Svelte 5 delegates to document as well as the mount target for exactly this case (svelte/src/internal/client/render.js:235-243); Escape is a window listener (confirm-dialog.svelte:90-104). Measured body.childNodes over three open/cancel cycles plus Escape: 124 → 125 → 124.
  • (2) app-header.svelte is absent from the diff and still carries backdrop-blur (:36) with the trigger at :104.
  • (3) dialog-viewport.ts:83-93 asserts top >= 0, bottom <= innerHeight, left >= 0, right <= innerWidth, backdropTop === 0, backdropBottom === innerHeight, collected into one toEqual([]). Pre-fix the backdrop box was the header (48 px / 128 px), so backdropBottom !== innerHeight and top < 0 both fire at each width — exact comparison, rounded only to 0.1 for sub-pixel noise, measured at scrollY 0 before any click scrolling, with one generated test per viewport (panes.spec.ts:295-296).

No blockers. 7 non-blocking concerns (all documentation/coverage) are recorded in the detailed comment above: the mis-attributed clip mechanism in the portal.ts/dialog-viewport.ts prose, the "Playwright auto-scrolled the clipping ancestor" explanation (measured scrollY 0; the old suite was green because toBeVisible() needs only a non-empty box), a now-stale comment in gates-configured.spec.ts, a tautological sibling assertion at stop.svelte.test.ts:45, scrollY collected but not asserted, the document.body target coupling, and an unrelated body-child-growth observation.

## Review — 609d25c verdict: pass Independent delta review of `694e8a7..609d25c` (fresh context, `pi -p --no-session --model deepseek/deepseek-flash`, primary sources only: the diff, the post-diff files, the repo's own suite). Verdict posted **after** head `609d25c55b0a8319dbe51b7ce98788001fe443f7`; it supersedes the verdicts at `aba6709` and `694e8a7`. **0 blockers.** Diff is exactly 7 files / 245 insertions / **0 deletions** — no pre-existing assertion was modified or weakened. No Go, CI workflow, manifest or header change. Reviewer re-ran the suite itself: unit **145 passed / 20 files**, `svelte-check` **0/0**, `playwright e2e/panes.spec.ts e2e/gates-configured.spec.ts` **22/22**. It then reproduced the defect and the fix in real Chromium by re-parenting the portalled backdrop into its pre-fix mount site: | viewport | pre-fix dialog | pre-fix backdrop | post-fix dialog | post-fix backdrop | |---|---|---|---|---| | 1440×900 | -173.6 / 221.6 | 0..48 | 252.4 / 647.6 | 0..900 | | 390×844 | -351.0 / 116.0 | 0..128 | 365.0 / 832.0 | 0..844 | | Claim | Verdict | |---|---| | **(1)** portal sound — SSR-safe, `appendChild` relocation, `destroy` removes, no event leak | clean | | **(2)** header `backdrop-blur` token untouched, trigger still mounted in the header | clean | | **(3)** the new e2e assertion is load-bearing — it FAILS on the pre-fix geometry | clean | | **(4)** nothing else smuggled in | clean | - **(1)** `portal.ts:32-37` has no module-scope `document` access (the default is evaluated per call inside the action), so no SSR guard is missing; the SvelteKit SSR build runs clean. Relocation is `appendChild` (`:33`), so node identity, listeners and bindings survive; teardown is `node.remove()` (`:35-37`), pinned by `portal.test.ts:34,36`. No host wrapper, so no collision path. Svelte 5 delegates to `document` as well as the mount target for exactly this case (`svelte/src/internal/client/render.js:235-243`); Escape is a `window` listener (`confirm-dialog.svelte:90-104`). Measured `body.childNodes` over three open/cancel cycles plus Escape: **124 → 125 → 124.** - **(2)** `app-header.svelte` is absent from the diff and still carries `backdrop-blur` (`:36`) with the trigger at `:104`. - **(3)** `dialog-viewport.ts:83-93` asserts `top >= 0`, `bottom <= innerHeight`, `left >= 0`, `right <= innerWidth`, `backdropTop === 0`, `backdropBottom === innerHeight`, collected into one `toEqual([])`. Pre-fix the backdrop box *was* the header (48 px / 128 px), so `backdropBottom !== innerHeight` **and** `top < 0` both fire at each width — exact comparison, rounded only to 0.1 for sub-pixel noise, measured at `scrollY 0` before any click scrolling, with one generated test per viewport (`panes.spec.ts:295-296`). **No blockers.** 7 non-blocking concerns (all documentation/coverage) are recorded in the detailed comment above: the mis-attributed clip mechanism in the `portal.ts`/`dialog-viewport.ts` prose, the "Playwright auto-scrolled the clipping ancestor" explanation (measured `scrollY 0`; the old suite was green because `toBeVisible()` needs only a non-empty box), a now-stale comment in `gates-configured.spec.ts`, a tautological sibling assertion at `stop.svelte.test.ts:45`, `scrollY` collected but not asserted, the `document.body` target coupling, and an unrelated `body`-child-growth observation.
aaron merged commit 6f98509662 into epic/LD3 2026-10-02 07:07:35 +00:00
Sign in to join this conversation.
No reviewers
No labels
operator-review
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/infra-core!578
No description provided.