From d5c69631ec7a2ecbf1a416eab9f8a8178b0bbb9b Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 23 Jul 2026 11:57:28 +0300 Subject: [PATCH 01/12] docs: P6.2 live-ops plan --- .../plans/2026-07-23-panel-p6.2-live-ops.md | 459 ++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-23-panel-p6.2-live-ops.md diff --git a/docs/superpowers/plans/2026-07-23-panel-p6.2-live-ops.md b/docs/superpowers/plans/2026-07-23-panel-p6.2-live-ops.md new file mode 100644 index 00000000..7bc66430 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-panel-p6.2-live-ops.md @@ -0,0 +1,459 @@ +# Panel P6.2 — Live Ops (Monitor Phone Layout + Staleness Vocabulary) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the live monitor a first-class phone surface — board 8f layout below `md`, the board 8p staleness vocabulary (numbers dim when the stream degrades, polite live-region announcements), a LiveStrip phone polish, and the TabBar Monitor attention dot — plus the STEP_LABEL_KEYS→shared refactor promised in PR #106's review thread. + +**Architecture:** All changes are phone-first responsive classes + small state additions inside the existing monitor feature (spec §5: Tailwind reflow first, no new routes, no new endpoints). The tab-bar dot reads the monitor snapshot from the TanStack Query cache ONLY (`enabled: false`) — the workspace tab bar must never generate monitor traffic by itself. Desktop (≥ `md`) keeps board 7e's layout untouched except where the boards agree (percent right-aligned, spread rate row). + +**Tech Stack:** React 19, TanStack Query (`$api` generated client), Tailwind v4 tokens, `@idento/ui` (`StatusPill`, `TabBarItem` — `badge?: string` sr-only API landed in P6.1), react-i18next EN/RU, Vitest + Testing Library + MSW. + +**Spec:** [2026-07-20-panel-p6-mobile-companion-design.md](../specs/2026-07-20-panel-p6-mobile-companion-design.md) §6 P6.2. Boards: `Idento Panel Mobile.dc.html` frames 8f (monitor phone stack), 8p (staleness vocabulary), 8d (home hero), 8a (tab-bar attention dot). + +**Branch:** `feature/panel-p6.2-live-ops` (worktree from origin/main; PR to `main`; direct push blocked). + +## Global Constraints + +- UI primitives only from `@idento/ui`; colors only via theme tokens — never hex literals. +- Every user-facing string is an i18n key added to BOTH `panel/src/shared/i18n/en.json` and `ru.json` in the same change (`keyParity.test.ts` enforces). New keys use the owning surface's prefix (`tabBar*`, `monitor*`). +- `md` (768px) is the ONLY desktop/phone cutover; phone-first classes with `md:` restoring desktop; reference frame 390×844 (panel/AGENTS.md "Adaptive layout"). +- Zero backend/OpenAPI changes — do not touch `backend/openapi.yaml` or `schema.d.ts`. +- Retain-last-known-good: degraded stream/refetch states must dim or badge stale data, never blank it (P4.2 Finding C6 discipline). +- No polling fallback for the monitor — SSE invalidation only (P4.2 global constraint); the tab-bar dot is cache-read only, no new fetching. +- Verify before finishing any panel change: `npm test -w panel && npm run typecheck -w panel && npm run lint -w panel && npm run build -w panel` from the repo root (bare `tsc` is NOT the panel typecheck). +- Known hazards: the RTK CLI-proxy can mask lint output as a fake "missing config" error (`rtk proxy ` to verify); the panel suite has pre-existing load flakes (AttendeeDrawer / BadgeEditorPage / StaffZonesDialog timing tests) — isolate-rerun before believing a failure. +- Ledger conflict rule: `.superpowers/sdd/progress.md` is tracked and append-only — if main moves underneath this branch, resolve the append-append conflict by keeping BOTH blocks (main's first). + +--- + +### Task 1: STEP_LABEL_KEYS → `shared/lib/readinessLabels.ts` + +Promised in the PR #106 review thread (CodeRabbit nitpick): the readiness label vocabulary is cross-cutting (used by home + workspace features) and belongs in `src/shared`. + +**Files:** +- Create: `panel/src/shared/lib/readinessLabels.ts` +- Modify: `panel/src/features/home/ReadinessCell.tsx` (remove the exported const, import instead) +- Modify: `panel/src/features/workspace/WorkspaceRail.tsx:5` +- Modify: `panel/src/features/workspace/WorkspaceOverview.tsx:8` +- Modify: `panel/src/features/workspace/ReadinessStrip.tsx:4` + +**Interfaces:** +- Produces: `STEP_LABEL_KEYS: Record` exported from `panel/src/shared/lib/readinessLabels.ts` — same values as today (`readinessStepAttendees` … `readinessStepEquipment`). + +- [ ] **Step 1: Create the shared module** + +```ts +// panel/src/shared/lib/readinessLabels.ts +import type { components } from "../api/schema"; + +type ReadinessStep = components["schemas"]["ReadinessStep"]; + +// The readiness pipeline's step-label vocabulary — one i18n key per step +// key, shared by home (ReadinessCell) and workspace (rail, overview, +// strip). Cross-cutting per panel/AGENTS.md's feature-sliced layout, hence +// src/shared (moved here from features/home/ReadinessCell in P6.2 after +// PR #106 review). +export const STEP_LABEL_KEYS: Record = { + attendees: "readinessStepAttendees", + badge: "readinessStepBadge", + zones: "readinessStepZones", + staff: "readinessStepStaff", + equipment: "readinessStepEquipment", +}; +``` + +- [ ] **Step 2: Update the four consumers** + +- `ReadinessCell.tsx`: delete the local `export const STEP_LABEL_KEYS ... };` block (lines 10-16) and add `import { STEP_LABEL_KEYS } from "../../shared/lib/readinessLabels";` (keep the file's existing `ReadinessStep`-typed imports if still used elsewhere in the file; remove any now-unused type import only if the linter flags it). +- `WorkspaceRail.tsx`, `WorkspaceOverview.tsx`, `ReadinessStrip.tsx`: change `import { STEP_LABEL_KEYS } from "../home/ReadinessCell";` to `import { STEP_LABEL_KEYS } from "../../shared/lib/readinessLabels";`. +- Verify nothing else imported it from ReadinessCell: `grep -rn "STEP_LABEL_KEYS.*ReadinessCell" panel/src` → expect zero hits after the change. + +- [ ] **Step 3: Run the affected suites to verify no regression** + +Run: `npm test -w panel -- ReadinessCell WorkspaceRail WorkspaceOverview ReadinessStrip HomePage EventWorkspaceLayout` +Expected: PASS (pure move — no behavior change). + +- [ ] **Step 4: Commit** + +```bash +git add panel/src/shared/lib/readinessLabels.ts panel/src/features/home/ReadinessCell.tsx panel/src/features/workspace/WorkspaceRail.tsx panel/src/features/workspace/WorkspaceOverview.tsx panel/src/features/workspace/ReadinessStrip.tsx +git commit -m "panel: move STEP_LABEL_KEYS to shared/lib (PR #106 follow-up, P6.2 T1)" +``` + +--- + +### Task 2: staleness vocabulary on the monitor (board 8p) + +Three legible states with honest numbers: LIVE (full opacity), Reconnecting (amber badge — already exists — now ALSO dims the body to 60%), stale counter in amber with a clock icon when the stream is not live. Plus a polite live region announcing stream-state changes. + +**Files:** +- Modify: `panel/src/features/monitor/MonitorPage.tsx:100-165` +- Test: `panel/src/features/monitor/MonitorPage.test.tsx` (extend) + +**Interfaces:** +- Consumes: `stream.status: "live" | "reconnecting" | "error"` from `useMonitorStream`, `updatedSeconds` (existing derived value in MonitorPage). +- Produces: body wrapper `data-testid="monitor-body"` whose class list contains `opacity-60` iff `stream.status !== "live"`; sr-only `aria-live="polite"` announcer whose text is the current stream-state label. Task 3 edits the same header block — Task 3's diff builds on this task's shape. + +- [ ] **Step 1: Write the failing tests** + +MonitorPage.test.tsx already has a harness controlling the stream status and snapshot (P4.2). Read its existing "reconnecting badge" / "stream error" tests first and add these three tests USING THE SAME harness idiom (mock/control mechanism, render helper, fixtures). The assertions to make, verbatim: + +```tsx + it("dims the body to 60% while the stream is reconnecting, restoring it when live", async () => { + // harness: stream status = "reconnecting", snapshot loaded + // (reuse the exact setup the existing reconnecting-badge test uses) + expect(await screen.findByTestId("monitor-body")).toHaveClass("opacity-60"); + }); + + it("keeps the body at full opacity while the stream is live", async () => { + // harness: stream status = "live", snapshot loaded + expect(await screen.findByTestId("monitor-body")).not.toHaveClass("opacity-60"); + }); + + it("announces stream-state changes via a polite live region", async () => { + // harness: stream status = "reconnecting", snapshot loaded + const region = await screen.findByTestId("monitor-stream-announcer"); + expect(region).toHaveAttribute("aria-live", "polite"); + expect(region).toHaveClass("sr-only"); + expect(region).toHaveTextContent("Reconnecting"); // adjust to the real monitorReconnecting EN copy — grep en.json first + }); +``` + +Also extend ONE existing stale-label test (or add one) to assert the amber degradation of the counter: + +```tsx + it("renders the updated-ago counter in warning tone while the stream is degraded", async () => { + // harness: stream status = "reconnecting", snapshot loaded + expect(await screen.findByTestId("monitor-updated-ago")).toHaveClass("text-warning"); + }); +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `npm test -w panel -- MonitorPage` +Expected: the new tests FAIL (no `monitor-body`/`monitor-stream-announcer` testids yet). + +- [ ] **Step 3: Implement in MonitorPage.tsx** + +1. Add `Clock` to the lucide-react import. +2. Updated-ago span (currently `text-caption text-muted-foreground`) becomes state-aware — replace the existing block with: + +```tsx + {updatedSeconds !== null ? ( + // Board 8p — the staleness counter is part of the stream-state + // vocabulary: muted mono while live (data is provably fresh), + // warning tone + clock icon while degraded (the counter is the + // "how stale" answer the amber badge alone can't give). Icon + + // text + color, never color alone (WCAG 1.4.1). + + {live ? null : } + {t("monitorUpdatedAgo", { seconds: updatedSeconds })} + + ) : null} +``` + +(`cn` comes from `@idento/ui` — add to that import if absent.) + +3. Wrap the body grid contents' opacity: the grid div (the one with `md:[grid-template-columns:1.15fr_1fr]`) gains `data-testid="monitor-body"` and the dim class: + +```tsx +
+``` + +4. Add the announcer as a sibling directly after the header div (before the body grid): + +```tsx + {/* Board 8p — aria-live announces stream-state changes; content + change (live → reconnecting → error) is what triggers the + announcement, so this renders the current state's label. */} + + {stream.status === "live" + ? t("monitorLive") + : stream.status === "reconnecting" + ? t("monitorReconnecting") + : t("monitorStreamError")} + +``` + +No new i18n keys — all three labels exist (`monitorLive`, `monitorReconnecting`, `monitorStreamError`). + +- [ ] **Step 4: Run to verify they pass** + +Run: `npm test -w panel -- MonitorPage` +Expected: PASS — all pre-existing tests plus the four new ones (if a pre-existing test asserted the updated-ago span's exact classes, update it for the mono/state-aware form). + +- [ ] **Step 5: Commit** + +```bash +git add panel/src/features/monitor/MonitorPage.tsx panel/src/features/monitor/MonitorPage.test.tsx +git commit -m "panel: monitor staleness vocabulary — dim stale body, amber counter, live-region announcer (board 8p, P6.2 T2)" +``` + +--- + +### Task 3: monitor phone header + TotalsCard 8f typography + +**Files:** +- Modify: `panel/src/features/monitor/MonitorPage.tsx:110-157` (header block) +- Modify: `panel/src/features/monitor/TotalsCard.tsx:58-73` +- Test: `panel/src/features/monitor/TotalsCard.test.tsx` (extend; create if absent — check `ls panel/src/features/monitor/*.test.tsx` first), `panel/src/features/monitor/MonitorPage.test.tsx` + +**Interfaces:** +- Consumes: Task 2's header shape (state-aware updated-ago span). +- Produces: phone-compact header (Exit hidden below `md`); TotalsCard with XXL phone number and a spread three-stat row (`data-testid="monitor-rate-row"` with one `` per part). + +- [ ] **Step 1: Write the failing tests** + +TotalsCard (extend the existing test file, or create with the monitor tests' fixture idiom — a `MonitorTotals` object; copy a totals fixture from MonitorPage.test.tsx): + +```tsx + it("renders the rate parts as separate spans in the rate row", () => { + // totals fixture with rate_per_min, peak and est_done_at all present + render(); + const row = screen.getByTestId("monitor-rate-row"); + expect(row.children.length).toBe(3); + expect(within(row).getByText(/min/)).toBeInTheDocument(); + }); + + it("keeps the XXL phone sizing classes on the headline number", () => { + render(); + const headline = screen.getByTestId("monitor-totals-headline"); + expect(headline).toHaveClass("text-5xl", "md:text-2xl"); + }); +``` + +MonitorPage: add one test asserting the Exit control is desktop-only: + +```tsx + it("hides the Exit button below md (the tab bar owns phone navigation)", async () => { + // harness: default live render + const exit = await screen.findByTestId("monitor-exit"); + expect(exit).toHaveClass("hidden", "md:block"); + }); +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `npm test -w panel -- MonitorPage TotalsCard` +Expected: new tests FAIL (no testids/classes yet). + +- [ ] **Step 3: Implement the header compaction (MonitorPage.tsx)** + +Board 8f: compact phone header — pills + truncating name + counter right-aligned; Exit is desktop-only (board 8t: "Exit button → tab bar handles it"). Change the header block: + +1. Header div: `className="flex h-14 flex-none items-center gap-3 border-b border-border px-4"` → `className="flex h-12 flex-none items-center gap-2 border-b border-border px-3 md:h-14 md:gap-3 md:px-4"`. +2. Event name h1: `className="text-page-title"` → `className="min-w-0 flex-1 truncate text-body font-bold md:flex-none md:text-page-title"` (flex-1 pushes the counter right on phone; md restores the desktop rank where Exit's `ml-auto` owns the right edge). +3. Exit wrapper: `
` → `
`. + +- [ ] **Step 4: Implement the TotalsCard typography (TotalsCard.tsx)** + +Replace the CardContent block: + +```tsx + +
+ {/* Board 8f — glanceable-from-arm's-length on a phone (XXL, + tight leading); board 7e's desktop scale returns at `md`. */} + + {numberFmt.format(totals.checked_in)} / {numberFmt.format(totals.total)} + + {/* Boards 7e/8f both right-align the percent. */} + {percent}% +
+ + {/* Board 8f — the rate stats spread edge-to-edge as separate spans + (was one "·"-joined string); flex-wrap keeps long locales safe. */} +
+ {rateParts.map((part) => ( + {part} + ))} +
+
+``` + +(`rateParts` already exists; when peak/est are absent the row simply has fewer spans — adjust the `row.children.length` assertion fixture to include all three parts.) If an existing TotalsCard/MonitorPage test asserted the joined "·" string, update it to per-part assertions. + +- [ ] **Step 5: Run to verify they pass** + +Run: `npm test -w panel -- MonitorPage TotalsCard` +Expected: PASS, including updated pre-existing assertions. + +- [ ] **Step 6: Commit** + +```bash +git add panel/src/features/monitor/MonitorPage.tsx panel/src/features/monitor/TotalsCard.tsx panel/src/features/monitor/TotalsCard.test.tsx panel/src/features/monitor/MonitorPage.test.tsx +git commit -m "panel: monitor phone header + XXL totals typography (board 8f, P6.2 T3)" +``` + +--- + +### Task 4: TabBar Monitor attention dot (board 8a) — cache-only, with sr-only label + +**Files:** +- Modify: `panel/src/features/workspace/EventTabBar.tsx` +- Modify: `panel/src/features/workspace/EventTabBar.test.tsx` (QueryClientProvider + new tests) +- Modify: `panel/src/shared/i18n/en.json`, `panel/src/shared/i18n/ru.json` + +**Interfaces:** +- Consumes: `TabBarItem`'s `badge?: string` (sr-only label API from P6.1), `stationStaleness(lastSeenAt: string, now: number): { stale: boolean; seconds: number }` and `STATION_STALE_MS` from `panel/src/features/monitor/liveness.ts`, `$api` from `shared/api/query`, `MONITOR_SNAPSHOT_KEY(eventId)` from `panel/src/features/monitor/hooks.ts` (test seeding). +- Produces: the Monitor tab shows the warning dot + sr-only label whenever the CACHED monitor snapshot reports a stale station; no network traffic is ever initiated by the tab bar. + +- [ ] **Step 1: Add the i18n keys** + +`en.json` (next to the other `tabBar*` keys): `"tabBarMonitorAttention": "A station needs attention",` +`ru.json` (same position): `"tabBarMonitorAttention": "Станция требует внимания",` + +- [ ] **Step 2: Write the failing tests** + +`EventTabBar.test.tsx` currently renders without a QueryClientProvider — the new cache read requires one. Update the harness: + +1. Add imports: `import { QueryClient, QueryClientProvider } from "@tanstack/react-query";` and `import { MONITOR_SNAPSHOT_KEY } from "../monitor/hooks";`. +2. Change `renderAt(path)` to `renderAt(path, seedSnapshot?: (queryClient: QueryClient) => void)`: create `const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });`, call `seedSnapshot?.(queryClient)`, and wrap the existing `` in `...`. Existing tests keep calling `renderAt(path)` unchanged. +3. Add a typed stale-station fixture and two tests: + +```tsx +const STALE_SNAPSHOT = { + totals: { checked_in: 10, total: 20, rate_per_min: 0 }, + zones: [], + unattributed: 0, + stations: [ + // last_seen_at far past STATION_STALE_MS (45s) relative to any test run. + { station_id: "st-1", name: "Kiosk A", checkin_count: 10, last_seen_at: "2020-01-01T00:00:00Z" }, + ], + recent: [], +}; + + it("shows the attention dot with an sr-only label when the cached snapshot has a stale station", async () => { + renderAt("/events/evt-1", (queryClient) => { + queryClient.setQueryData(MONITOR_SNAPSHOT_KEY("evt-1"), STALE_SNAPSHOT); + }); + const bar = await screen.findByRole("navigation", { name: "Event sections" }); + expect(within(bar).getByTestId("tab-bar-badge")).toBeInTheDocument(); + expect(within(bar).getByText("A station needs attention")).toHaveClass("sr-only"); + }); + + it("shows no dot when nothing is cached (and never fetches on its own)", async () => { + renderAt("/events/evt-1"); + const bar = await screen.findByRole("navigation", { name: "Event sections" }); + expect(within(bar).queryByTestId("tab-bar-badge")).not.toBeInTheDocument(); + }); +``` + +(If the `STALE_SNAPSHOT` fixture fails the typecheck against the generated `MonitorSnapshot` schema — extra/missing fields — align it with `components["schemas"]["MonitorSnapshot"]` from `shared/api/schema` and type it explicitly; report the final shape.) + +- [ ] **Step 3: Run to verify they fail** + +Run: `npm test -w panel -- EventTabBar` +Expected: new tests FAIL (no badge rendering; possibly a missing-QueryClient error until the harness update lands together with the implementation). + +- [ ] **Step 4: Implement in EventTabBar.tsx** + +1. Add imports: `import { $api } from "../../shared/api/query";` and `import { stationStaleness } from "../monitor/liveness";`. +2. Inside `EventTabBar`, before the return: + +```tsx + // Board 8a — the Monitor tab's attention dot ("needs a look", never a + // count): lights when the monitor snapshot ALREADY IN CACHE reports a + // stale station. Cache-only by design (`enabled: false`): the tab bar + // renders on every workspace page and must never generate monitor + // traffic itself — the dot updates whenever the monitor page or Home's + // LiveStrip refreshes the shared snapshot. `Date.now()` per render (no + // ticker): a passive indicator that re-evaluates on cache updates is + // enough; MonitorPage owns the live-ticking presentation. + const snapshot = $api.useQuery( + "get", + "/api/events/{event_id}/monitor", + { params: { path: { event_id: eventId } } }, + { enabled: false }, + ); + const now = Date.now(); + const hasStaleStation = (snapshot.data?.stations ?? []).some( + (station) => station.last_seen_at && stationStaleness(station.last_seen_at, now).stale, + ); +``` + +3. The Monitor `TabBarItem` gains the badge prop: + +```tsx + +``` + +(If `station.last_seen_at` is non-nullable per the generated type, the truthiness guard is still harmless — keep it.) + +- [ ] **Step 5: Run to verify everything passes** + +Run: `npm test -w panel -- EventTabBar EventWorkspaceLayout MonitorPage keyParity` +Expected: PASS — including EventWorkspaceLayout/MonitorPage suites, which mount EventTabBar inside providers that already include a QueryClient. + +- [ ] **Step 6: Commit** + +```bash +git add panel/src/features/workspace/EventTabBar.tsx panel/src/features/workspace/EventTabBar.test.tsx panel/src/shared/i18n/en.json panel/src/shared/i18n/ru.json +git commit -m "panel: Monitor tab attention dot from cached station staleness (board 8a, P6.2 T4)" +``` + +--- + +### Task 5: LiveStrip phone polish + final sweep + +**Files:** +- Modify: `panel/src/features/home/LiveStrip.tsx:117,126` +- Test: `panel/src/features/home/LiveStrip.test.tsx` (verify only; update only if a class assertion breaks) +- Modify: `.superpowers/sdd/progress.md` (append execution record) + +- [ ] **Step 1: LiveStrip phone classes (board 8d)** + +In `RunningCard`: +1. The headline count span `className="text-2xl font-extrabold text-foreground"` → `className="text-3xl font-extrabold tracking-tight text-foreground md:text-2xl md:tracking-normal"` (board 8d's 34px hero number on phone; desktop unchanged). +2. The progress bar `` → `className="w-full md:w-56"` (board 8d full-width bar on phone). + +- [ ] **Step 2: Run the strip's tests** + +Run: `npm test -w panel -- LiveStrip HomePage` +Expected: PASS (class-only change; if a test asserted `w-56` verbatim, update it to the responsive form). + +- [ ] **Step 3: Full verification gates** + +Run: `npm test -w panel && npm run typecheck -w panel && npm run lint -w panel && npm run build -w panel && npm test -w packages/ui` +Expected: all PASS (lint via `rtk proxy` if the masking artifact appears; isolate-rerun known flakes). + +- [ ] **Step 4: Live degraded-stream walk (controller-executed, browser preview)** + +Same stack as the P6.1 walk (docker compose db + Go backend from the worktree with the `.env` recipe + `panel-dev`-style launch entry for the worktree). At 390×844 on the monitor page: confirm the 8f layout (compact header, XXL totals, spread rate row, no Exit button, tab bar); then KILL the backend process and confirm within ~30s: Reconnecting badge appears, body dims to 60%, updated-ago counter turns amber with the clock icon and keeps climbing; restart the backend and confirm recovery to full opacity + LIVE. Also confirm the Monitor tab's attention dot appears on the Overview tab after visiting the monitor of an event with a stale station (the seeded E2E station is permanently stale — ideal). Capture a 390px screenshot of the degraded state as proof. Check both themes. + +- [ ] **Step 5: Append the execution record to `.superpowers/sdd/progress.md`** (per-task commits, gate results, walk findings, deviations) **and commit** + +```bash +git add panel/src/features/home/LiveStrip.tsx panel/src/features/home/LiveStrip.test.tsx .superpowers/sdd/progress.md +git commit -m "panel: LiveStrip phone polish + P6.2 ledger (board 8d, P6.2 T5)" +``` + +--- + +## Self-Review Notes (spec coverage) + +- Spec §6 P6.2 "Monitor phone layout (stacked, glanceable typography per board)" → Tasks 2-3 (stack itself landed in P6.1; this adds the 8f header/typography). "LiveStrip phone polish" → Task 5. "SSE reconnect states legible on mobile" → Task 2 (8p vocabulary). Acceptance "monitor fully readable at 390px light+dark" → Task 5 walk; "stream badge states verified with throttled network" → Task 5's kill-the-backend walk step. +- Queued P6.1 follow-ups covered: STEP_LABEL_KEYS→shared (T1, promised in PR thread), sr-only badge wiring with the monitor as first consumer (T4). NOT in scope (deliberate): MoreSheet row dedupe (P6.3 restructures that sheet), gate h2-without-h1 + Lighthouse (P6.4), DesktopOnlyGate clipboard `.catch` (already landed in PR #106 round 1). +- Desktop deltas are limited to the two board-sanctioned ones: percent `ml-auto`, spread rate row (noted in Task 3 comments). From f5e2bc909fbac2bfaab0761b41a3d9f2e568d364 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 23 Jul 2026 11:59:56 +0300 Subject: [PATCH 02/12] panel: move STEP_LABEL_KEYS to shared/lib (PR #106 follow-up, P6.2 T1) --- panel/src/features/home/ReadinessCell.tsx | 10 +--------- panel/src/features/workspace/ReadinessStrip.tsx | 2 +- .../src/features/workspace/WorkspaceOverview.tsx | 2 +- panel/src/features/workspace/WorkspaceRail.tsx | 2 +- panel/src/shared/lib/readinessLabels.ts | 16 ++++++++++++++++ 5 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 panel/src/shared/lib/readinessLabels.ts diff --git a/panel/src/features/home/ReadinessCell.tsx b/panel/src/features/home/ReadinessCell.tsx index bcbb056e..47cfeb07 100644 --- a/panel/src/features/home/ReadinessCell.tsx +++ b/panel/src/features/home/ReadinessCell.tsx @@ -2,19 +2,11 @@ import { cn, Skeleton, StatusPill, Tooltip, TooltipContent, TooltipProvider, Too import { CheckCircle2, Circle, MinusCircle } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { components } from "../../shared/api/schema"; +import { STEP_LABEL_KEYS } from "../../shared/lib/readinessLabels"; type EventReadinessResponse = components["schemas"]["EventReadinessResponse"]; type ReadinessStep = components["schemas"]["ReadinessStep"]; -// eslint-disable-next-line react-refresh/only-export-components -- Shared step-label lookup belongs with the readiness pipeline it describes; not a real Fast Refresh issue for this pattern. -export const STEP_LABEL_KEYS: Record = { - attendees: "readinessStepAttendees", - badge: "readinessStepBadge", - zones: "readinessStepZones", - staff: "readinessStepStaff", - equipment: "readinessStepEquipment", -}; - export interface ReadinessCellProps { readiness: EventReadinessResponse | undefined; } diff --git a/panel/src/features/workspace/ReadinessStrip.tsx b/panel/src/features/workspace/ReadinessStrip.tsx index 9fd16f9a..d49daa77 100644 --- a/panel/src/features/workspace/ReadinessStrip.tsx +++ b/panel/src/features/workspace/ReadinessStrip.tsx @@ -1,7 +1,7 @@ import { cn } from "@idento/ui"; import { Check, Circle, MinusCircle } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { STEP_LABEL_KEYS } from "../home/ReadinessCell"; +import { STEP_LABEL_KEYS } from "../../shared/lib/readinessLabels"; import type { components } from "../../shared/api/schema"; type ReadinessStep = components["schemas"]["ReadinessStep"]; diff --git a/panel/src/features/workspace/WorkspaceOverview.tsx b/panel/src/features/workspace/WorkspaceOverview.tsx index d535872f..22574721 100644 --- a/panel/src/features/workspace/WorkspaceOverview.tsx +++ b/panel/src/features/workspace/WorkspaceOverview.tsx @@ -5,7 +5,7 @@ import { getRouteApi, Link } from "@tanstack/react-router"; import { Circle, Lock } from "lucide-react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { STEP_LABEL_KEYS } from "../home/ReadinessCell"; +import { STEP_LABEL_KEYS } from "../../shared/lib/readinessLabels"; import { ReadinessStrip } from "./ReadinessStrip"; import { useEventReadiness, useEventStats } from "../events/hooks"; import { $api } from "../../shared/api/query"; diff --git a/panel/src/features/workspace/WorkspaceRail.tsx b/panel/src/features/workspace/WorkspaceRail.tsx index c1e99dd9..8c251693 100644 --- a/panel/src/features/workspace/WorkspaceRail.tsx +++ b/panel/src/features/workspace/WorkspaceRail.tsx @@ -2,7 +2,7 @@ import { cn, Separator, Skeleton } from "@idento/ui"; import { Link } from "@tanstack/react-router"; import { CheckCircle2, Circle, Lock, MinusCircle } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { STEP_LABEL_KEYS } from "../home/ReadinessCell"; +import { STEP_LABEL_KEYS } from "../../shared/lib/readinessLabels"; import type { components } from "../../shared/api/schema"; type EventReadinessResponse = components["schemas"]["EventReadinessResponse"]; diff --git a/panel/src/shared/lib/readinessLabels.ts b/panel/src/shared/lib/readinessLabels.ts new file mode 100644 index 00000000..82ce7fad --- /dev/null +++ b/panel/src/shared/lib/readinessLabels.ts @@ -0,0 +1,16 @@ +import type { components } from "../api/schema"; + +type ReadinessStep = components["schemas"]["ReadinessStep"]; + +// The readiness pipeline's step-label vocabulary — one i18n key per step +// key, shared by home (ReadinessCell) and workspace (rail, overview, +// strip). Cross-cutting per panel/AGENTS.md's feature-sliced layout, hence +// src/shared (moved here from features/home/ReadinessCell in P6.2 after +// PR #106 review). +export const STEP_LABEL_KEYS: Record = { + attendees: "readinessStepAttendees", + badge: "readinessStepBadge", + zones: "readinessStepZones", + staff: "readinessStepStaff", + equipment: "readinessStepEquipment", +}; From 4a95ed333ca094fa4bfec6d58327a53367fac770 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 23 Jul 2026 12:14:38 +0300 Subject: [PATCH 03/12] panel: remove dead ReadinessStep type alias left by STEP_LABEL_KEYS move (P6.2 T1 fix) --- panel/src/features/home/ReadinessCell.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/panel/src/features/home/ReadinessCell.tsx b/panel/src/features/home/ReadinessCell.tsx index 47cfeb07..199bb1e8 100644 --- a/panel/src/features/home/ReadinessCell.tsx +++ b/panel/src/features/home/ReadinessCell.tsx @@ -5,7 +5,6 @@ import type { components } from "../../shared/api/schema"; import { STEP_LABEL_KEYS } from "../../shared/lib/readinessLabels"; type EventReadinessResponse = components["schemas"]["EventReadinessResponse"]; -type ReadinessStep = components["schemas"]["ReadinessStep"]; export interface ReadinessCellProps { readiness: EventReadinessResponse | undefined; From aad50f122a24ef91fd56ad30f2072bad832856b5 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 23 Jul 2026 12:36:23 +0300 Subject: [PATCH 04/12] =?UTF-8?q?panel:=20monitor=20staleness=20vocabulary?= =?UTF-8?q?=20=E2=80=94=20dim=20stale=20body,=20amber=20counter,=20live-re?= =?UTF-8?q?gion=20announcer=20(board=208p,=20P6.2=20T2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/monitor/MonitorPage.test.tsx | 76 +++++++++++++++++++ panel/src/features/monitor/MonitorPage.tsx | 39 +++++++++- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/panel/src/features/monitor/MonitorPage.test.tsx b/panel/src/features/monitor/MonitorPage.test.tsx index 0e43b300..c97073f2 100644 --- a/panel/src/features/monitor/MonitorPage.test.tsx +++ b/panel/src/features/monitor/MonitorPage.test.tsx @@ -712,6 +712,82 @@ describe("MonitorPage -- stream status (connecting/live/reconnecting/error)", () expect(streamConnections.length).toBe(0); expect(screen.getByTestId("monitor-stream-error-badge")).toBeInTheDocument(); }, 5000); + + // P6.2 Task 2 -- board 8p's staleness vocabulary: a degraded stream never + // lets stale numbers masquerade as live. Reuses this block's own + // hello/close-driven `controlledMonitorStreamHandler()` harness exactly as + // the reconnecting-badge test above does, so "reconnecting" here is the + // real derived `stream.status`, not a hand-set prop. + it( + "dims the body to 60% while the stream is reconnecting, restoring it when live", + async () => { + renderCorrectAt("/events/evt-1/monitor"); + + await screen.findByText("1,284 / 2,410"); + await waitFor(() => expect(streamConnections.length).toBe(1)); + streamConnections[0].push("event: hello\ndata: {}\n\n"); + await waitFor(() => expect(liveRing()).toBeInTheDocument()); + expect(screen.getByTestId("monitor-body")).not.toHaveClass("opacity-60"); + + streamConnections[0].close(); + await waitFor(() => expect(screen.getByTestId("monitor-reconnecting-badge")).toBeInTheDocument()); + expect(screen.getByTestId("monitor-body")).toHaveClass("opacity-60"); + + // Backoff is 1s base +/-25% jitter (max 1250ms) -- bounded wait for + // the retried connect() to land as a brand-new request, same as the + // reconnecting-badge test above. + await waitFor(() => expect(streamConnections.length).toBe(2), { timeout: 3000 }); + streamConnections[1].push("event: hello\ndata: {}\n\n"); + await waitFor(() => expect(screen.queryByTestId("monitor-reconnecting-badge")).not.toBeInTheDocument()); + expect(screen.getByTestId("monitor-body")).not.toHaveClass("opacity-60"); + }, + 8000, + ); + + it("keeps the body at full opacity while the stream is live", async () => { + renderCorrectAt("/events/evt-1/monitor"); + + await screen.findByText("1,284 / 2,410"); + await waitFor(() => expect(streamConnections.length).toBe(1)); + streamConnections[0].push("event: hello\ndata: {}\n\n"); + await waitFor(() => expect(liveRing()).toBeInTheDocument()); + + expect(screen.getByTestId("monitor-body")).not.toHaveClass("opacity-60"); + }); + + it("announces stream-state changes via a polite live region", async () => { + renderCorrectAt("/events/evt-1/monitor"); + + await screen.findByText("1,284 / 2,410"); + await waitFor(() => expect(streamConnections.length).toBe(1)); + streamConnections[0].push("event: hello\ndata: {}\n\n"); + await waitFor(() => expect(liveRing()).toBeInTheDocument()); + streamConnections[0].close(); + await waitFor(() => expect(screen.getByTestId("monitor-reconnecting-badge")).toBeInTheDocument()); + + const region = screen.getByTestId("monitor-stream-announcer"); + expect(region).toHaveAttribute("aria-live", "polite"); + expect(region).toHaveClass("sr-only"); + expect(region).toHaveTextContent("Reconnecting"); + }); + + it("renders the updated-ago counter in warning tone with a clock icon while the stream is degraded", async () => { + renderCorrectAt("/events/evt-1/monitor"); + + await screen.findByText("1,284 / 2,410"); + await waitFor(() => expect(streamConnections.length).toBe(1)); + streamConnections[0].push("event: hello\ndata: {}\n\n"); + await waitFor(() => expect(liveRing()).toBeInTheDocument()); + streamConnections[0].close(); + await waitFor(() => expect(screen.getByTestId("monitor-reconnecting-badge")).toBeInTheDocument()); + + const counter = await screen.findByTestId("monitor-updated-ago"); + expect(counter).toHaveClass("text-warning"); + expect(counter).not.toHaveClass("text-muted-foreground"); + // WCAG 1.4.1 -- color is never the only channel: a clock icon + // accompanies the amber tone, alongside the counter's own numeric text. + expect(counter.querySelector("svg")).toBeInTheDocument(); + }); }); // PR #81 bot round Finding C6: retain-last-known-good. A single failed diff --git a/panel/src/features/monitor/MonitorPage.tsx b/panel/src/features/monitor/MonitorPage.tsx index 18af1941..8a614609 100644 --- a/panel/src/features/monitor/MonitorPage.tsx +++ b/panel/src/features/monitor/MonitorPage.tsx @@ -30,9 +30,9 @@ // C6 -- retain-last-known-good, gated on `!snapshot` rather than // `isError`, so a single failed background refetch never blanks the page). import * as React from "react"; -import { Button, Card, CardContent, Skeleton, StatusPill } from "@idento/ui"; +import { Button, Card, CardContent, Skeleton, StatusPill, cn } from "@idento/ui"; import { Link, getRouteApi } from "@tanstack/react-router"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, Clock } from "lucide-react"; import { useTranslation } from "react-i18next"; import { $api } from "../../shared/api/query"; import { EventTabBar } from "../workspace/EventTabBar"; @@ -142,7 +142,19 @@ export function MonitorPage() { )}

{event.name}

{updatedSeconds !== null ? ( - + // Board 8p -- the staleness counter is part of the stream-state + // vocabulary: muted mono while live (data is provably fresh), + // warning tone + clock icon while degraded (the counter is the + // "how stale" answer the amber badge alone can't give). Icon + + // text + color, never color alone (WCAG 1.4.1). + + {live ? null : } {t("monitorUpdatedAgo", { seconds: updatedSeconds })} ) : null} @@ -156,13 +168,32 @@ export function MonitorPage() {
+ {/* Board 8p -- aria-live announces stream-state changes; content + change (live -> reconnecting -> error) is what triggers the + announcement, so this renders the current state's label. */} + + {stream.status === "live" + ? t("monitorLive") + : stream.status === "reconnecting" + ? t("monitorReconnecting") + : t("monitorStreamError")} + + {/* Body -- #fafafa background (theme.css's --background token is already that exact value), 2-column grid (1.15fr 1fr) per board 7e. */} {/* INTERIM phone stack (P6.1): single column below `md` so nothing overflows at 390px; the real glanceable phone layout is P6.2 (board 8f). Desktop/tablet keeps board 7e's two-column grid. */} -
+
{snapshotQuery.isLoading ? ( <>
From 4079db6c38bd628f46944c4669f1a3b4f6dffa9a Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 23 Jul 2026 12:46:48 +0300 Subject: [PATCH 05/12] panel: monitor announcer stays silent while connecting instead of falsely claiming stream error (P6.2 T2 fix) --- .../src/features/monitor/MonitorPage.test.tsx | 18 ++++++++++++++++++ panel/src/features/monitor/MonitorPage.tsx | 14 ++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/panel/src/features/monitor/MonitorPage.test.tsx b/panel/src/features/monitor/MonitorPage.test.tsx index c97073f2..a4bd4f6e 100644 --- a/panel/src/features/monitor/MonitorPage.test.tsx +++ b/panel/src/features/monitor/MonitorPage.test.tsx @@ -771,6 +771,24 @@ describe("MonitorPage -- stream status (connecting/live/reconnecting/error)", () expect(region).toHaveTextContent("Reconnecting"); }); + // Fix round 1 (P6.2 T2 review finding): before the SSE hello frame + // arrives, `stream.status` is genuinely "connecting" -- reusing the exact + // same harness as the "shows no reconnecting badge and no live ring while + // still connecting" test above -- and must not be announced as a stream + // error. Previously the announcer's ternary chain used `error` as a + // catch-all `else`, so this ordinary pre-hello moment on every mount was + // mislabeled "Live updates unavailable". + it("announces nothing while the stream is still connecting (not yet live, not yet a real error)", async () => { + renderCorrectAt("/events/evt-1/monitor"); + + await screen.findByText("1,284 / 2,410"); + expect(liveRing()).not.toBeInTheDocument(); + expect(screen.queryByTestId("monitor-reconnecting-badge")).not.toBeInTheDocument(); + + const announcer = await screen.findByTestId("monitor-stream-announcer"); + expect(announcer).toHaveTextContent(""); + }); + it("renders the updated-ago counter in warning tone with a clock icon while the stream is degraded", async () => { renderCorrectAt("/events/evt-1/monitor"); diff --git a/panel/src/features/monitor/MonitorPage.tsx b/panel/src/features/monitor/MonitorPage.tsx index 8a614609..a9135594 100644 --- a/panel/src/features/monitor/MonitorPage.tsx +++ b/panel/src/features/monitor/MonitorPage.tsx @@ -170,13 +170,23 @@ export function MonitorPage() { {/* Board 8p -- aria-live announces stream-state changes; content change (live -> reconnecting -> error) is what triggers the - announcement, so this renders the current state's label. */} + announcement, so this renders the current state's label. Fix round + 1: explicit 4-branch match rather than a ternary chain whose + `else` silently caught BOTH "connecting" (every ordinary mount, + before the SSE handshake completes) and "error" -- collapsing them + both onto monitorStreamError falsely announced "Live updates + unavailable" on every normal page load. "connecting" now announces + nothing: there is no existing i18n copy for that transient state, + and the header itself shows no badge during it either (see the + "still connecting" test above), so silence here matches that. */} {stream.status === "live" ? t("monitorLive") : stream.status === "reconnecting" ? t("monitorReconnecting") - : t("monitorStreamError")} + : stream.status === "error" + ? t("monitorStreamError") + : ""} {/* Body -- #fafafa background (theme.css's --background token is From 95278558a647c71ec9097cdbf9ebfe12ec7a8117 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 23 Jul 2026 12:52:38 +0300 Subject: [PATCH 06/12] panel: monitor phone header + XXL totals typography (board 8f, P6.2 T3) --- .../src/features/monitor/MonitorPage.test.tsx | 11 +++++ panel/src/features/monitor/MonitorPage.tsx | 13 +++--- .../src/features/monitor/TotalsCard.test.tsx | 42 +++++++++++++++++++ panel/src/features/monitor/TotalsCard.tsx | 21 ++++++++-- 4 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 panel/src/features/monitor/TotalsCard.test.tsx diff --git a/panel/src/features/monitor/MonitorPage.test.tsx b/panel/src/features/monitor/MonitorPage.test.tsx index a4bd4f6e..d9027466 100644 --- a/panel/src/features/monitor/MonitorPage.test.tsx +++ b/panel/src/features/monitor/MonitorPage.test.tsx @@ -342,6 +342,17 @@ describe("MonitorPage", () => { expect(screen.queryByText(/0 \/ 0/)).not.toBeInTheDocument(); expect(screen.queryByTestId("monitor-totals-card")).not.toBeInTheDocument(); }); + + // Board 8f / 8t: the phone tab bar (EventTabBar, mounted at the bottom of + // this very page) owns phone navigation, so the header's Exit control is + // desktop-only chrome -- hidden below `md`, restored at `md` alongside the + // rest of board 7e's desktop header. + it("hides the Exit button below md (the tab bar owns phone navigation)", async () => { + renderCorrectAt("/events/evt-1/monitor"); + + const exit = await screen.findByTestId("monitor-exit"); + expect(exit).toHaveClass("hidden", "md:block"); + }); }); // P4.2 Task 8 -- Stations card: board 7e's own answer to "how stale is diff --git a/panel/src/features/monitor/MonitorPage.tsx b/panel/src/features/monitor/MonitorPage.tsx index a9135594..a5a7bc84 100644 --- a/panel/src/features/monitor/MonitorPage.tsx +++ b/panel/src/features/monitor/MonitorPage.tsx @@ -104,10 +104,11 @@ export function MonitorPage() { return (
- {/* Header (56px per the board) -- LIVE pill · event name · "Updated - Ns ago" staleness label · (reconnecting badge, when the stream is - down) · Exit. */} -
+ {/* Header (48px on phone, 56px at `md`+ per board 8f/7e) -- LIVE pill + · truncating event name · "Updated Ns ago" staleness label · + (reconnecting badge, when the stream is down) · Exit (board 8t: + desktop-only, the phone tab bar below owns phone navigation). */} +
{stream.status === "error" ? ( // Finding C3: a terminal stream failure (401/403 tenant_suspended/ // documented 4xx) has already stopped reconnecting for good -- @@ -140,7 +141,7 @@ export function MonitorPage() { ) : null} )} -

{event.name}

+

{event.name}

{updatedSeconds !== null ? ( // Board 8p -- the staleness counter is part of the stream-state // vocabulary: muted mono while live (data is provably fresh), @@ -158,7 +159,7 @@ export function MonitorPage() { {t("monitorUpdatedAgo", { seconds: updatedSeconds })} ) : null} -
+