Observatory Dashboard — IBM Carbon Reskin Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development (fresh subagent per task, review between tasks). Each frontend task's implementer MUST invoke
ui-ux-pro-max:ui-ux-pro-maxfor layout, anddatavizfor the chart tasks. Verify every Carbon component/chart API against the INSTALLED package version (Carbon churned across v10→v11) — readnode_modules/@carbon/reactand@carbon/charts-reacttypes; do not guess props. Steps use- [ ]checkboxes.
Goal: Migrate the live observatory dashboard from shadcn/Tailwind/Recharts to a full IBM Carbon Design System implementation (Carbon components + @carbon/charts + @carbon/ibm-products Datagrid + IBM Plex + the 2× grid and design tokens), with working light AND dark modes, preserving every behavior and every hard-won correctness/honesty fix. Same features, same live data, Carbon skin.
Architecture: The data/logic layer is framework-agnostic and is REUSED AS-IS: src/lib/api.ts, src/lib/executive/* (metrics, date-range incl. the rate-label fix, upstream-class), src/lib/operator/* (filters, format, status-tone, use-calls-query keyset hook), src/lib/auditor/* (body-format, errors), src/lib/nav.ts, and the auth token/role/401 logic. The presentation layer (components, views, theming, charts, styles) is rebuilt in Carbon. nginx same-origin proxy + Docker deploy are unchanged.
Tech Stack: React 19 · TypeScript · Vite · @carbon/react (components) · @carbon/styles (Sass tokens/grid; needs sass) · @carbon/charts + @carbon/charts-react (D3 charts) · @carbon/ibm-products (Datagrid + patterns) · IBM Plex (@ibm/plex or Carbon's font tokens) · TanStack Query (kept) · react-router (kept). REMOVED: tailwindcss, shadcn, all src/components/ui/*, recharts, src/lib/viz/palette.*, src/lib/viz/entity-slots.ts.
Global Constraints
- Repo:
/home/efran/remote-development/poc-ahu-ai/ahu-observatory-dashboard, new branchfeat/carbon-reskinoff master (Task 1 creates it). Merge to master at the end (this replaces the current dash-p1 UI). - Consumes ONLY the observatory
:8300API as it exists (verify against../ahu-ai-observatory/internal/api/server.go):/api/summary,/api/series(group_by ∈ {engine,upstream,traffic_class}; fields calls/tokens_in/tokens_out/avg_total_ms/max_total_ms/avg_queue_ms),/api/calls(keyset before_ts/before_id),/api/calls/{id},/api/calls/{id}/body(auditor, server-logged),/api/verify-chain({ok, bad_chain_pos}),/api/access-log,/healthz. Error shape{"error":{"code","message"}}.
Light + dark (explicit requirement — a per-task reviewer gate)
- Carbon themes: light =
g10, dark =g90(data-dashboard-appropriate; one-line change towhite/g100if preferred). Wrap the app in Carbon's<Theme theme={...}>; a persisted toggle (localStorage) flips it. - THE "SET BOTH" RULE (this migration's version of the old .dark/data-theme gotcha): the theme toggle MUST set the Carbon
<Theme>AND pass the matchingthemeto EVERY@carbon/chartschart (charts take atheme: 'g10'|'g90'|...option and will NOT follow the Carbon theme automatically). A drift = charts light while the app is dark. Every chart reads the current theme from one shared source. - Both modes must be visually verified (Task 8 captures screenshots in BOTH).
Carry-forward invariants (hard-won in the prior build — the reskin MUST NOT regress these; each is a reviewer gate)
- Stat tiles show an explicit "Unavailable" state on query
isError— NEVER a fabricated0/0.0%/ "Good". The external-calls proof tile especially must never read "Good"/"0" on a failed query. Distinguish loading / error / empty / real-zero. - Rate labels derive from the active bucket ("Calls per minute" for 1m buckets, "per hour" for 1h/7d) — never hardcoded "/min". (The 7d 60×-overstatement fix.)
- Keyset pagination: the next page's cursor is the LAST row's (ts,event_id); no dup/skip; a filter change resets pagination. (Reuse
use-calls-query— do not reimplement.) - Auditor view: a PROMINENT "this view is recorded" notice shown before/with body content; chain-verify uses
bad_chain_pos(NOT first_bad); access-log actor is labeled a hashed token (not a name); a non-auditor reaching the view gets a clear "requires auditor role" state, not a crash. - Role is UI-affordance only; the server is the real gate. Any 401 clears the session and returns to the login gate; 403 degrades gracefully with a specific message.
- Token lives only in sessionStorage — never logged, never committed, never in a report.
- Scope honesty: only endpoints that exist. NO fleet/GPU/queue panels, NO p50/p95/p99, NO per-bucket ok/error series — these are labeled "Available when …" placeholders, never faked. Latency panels use avg/max with the "p95/p99 pending an API addition" note.
-@carbon/chartsprovides built-in accessibility (keyboard nav + screen-reader data tables + a "show as table"/CSV export toolbar) — this satisfies the dataviz table-view + a11y requirements natively; do NOT hand-build a separate table toggle. Note in the report that the prior CVD-validated custom palette is intentionally replaced by Carbon's own audited chart palette (per the design decision).
- TDD; commit per task; messages end withClaude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT.pnpm test+pnpm build+pnpm lintgreen before each commit. Suite must stay green on any Node (the Storage polyfill fromsrc/test/setup.tsstays).
- Every created/modified.mduploaded tohttps://x056.think.val.id/upload.
- The observatory/gateway/chatbot repos are read-only context — never modified.
File map
SURVIVES untouched (logic + its tests): src/lib/api.ts, src/lib/executive/{metrics,date-range,upstream-class}.ts, src/lib/operator/{filters,format,status-tone,use-calls-query}.ts, src/lib/auditor/{body-format,errors}.ts, src/lib/nav.ts, and all *.test.ts for those. Auth token/role/401 logic in src/lib/auth.tsx (the provider/hook logic stays; only any JSX it renders is Carbon-ized).
REWRITTEN for Carbon: src/lib/theme.ts (Carbon themes + chart-theme source), src/main.tsx/src/App.tsx (Theme provider + Carbon routing shell), all src/views/*, src/components/app-shell.tsx, health-pill.tsx, theme-toggle.tsx, LoginGate.
DELETED: tailwind.config.*, Tailwind directives in src/index.css (replaced by a Sass entry importing @carbon/styles + Plex), src/components/ui/* (shadcn), src/components/viz/* (Recharts), src/lib/viz/palette.*, src/lib/viz/entity-slots.ts, and their tests.
NEW: src/styles/carbon.scss (Carbon + Plex entry), src/components/charts/* (thin @carbon/charts-react wrappers passing the shared theme), Carbon-based tiles/tables.
Task 1: Carbon foundation — deps, Sass build, Theme provider, light/dark toggle
Deliverable: Tailwind/shadcn/recharts removed; Carbon installed and building via Sass; app wrapped in <Theme> with a persisted light(g10)/dark(g90) toggle; IBM Plex loaded; data layer + its tests untouched and still green; a placeholder Carbon page renders in both themes.
- [ ] Branch
feat/carbon-reskin. Remove tailwindcss + shadcn + recharts +src/components/ui/*+src/components/viz/*+src/lib/viz/palette.*+entity-slots.*(and their tests).pnpm add @carbon/react @carbon/styles @carbon/charts @carbon/charts-react @carbon/ibm-products sass(+@ibm/plexor use Carbon font tokens). Verify installed major versions and pin. - [ ]
src/styles/carbon.scss: import@carbon/styles(grid + tokens) + IBM Plex; wire Vite to compile Sass. Replace the Tailwindindex.css. - [ ]
src/lib/theme.ts(rewrite): auseCarbonTheme()/provider exposing the current theme ('g10'|'g90'), atoggle(), localStorage persistence, and achartThemevalue (the SAME theme string) for charts to consume. Wrap<App>in Carbon<Theme theme={theme}>. Failing test first: toggling flips the persisted value AND the exposed chartTheme in lockstep (the set-both guarantee at the state layer). - [ ] Confirm the surviving data-layer tests still pass unchanged; a placeholder Carbon
<Content>/<Grid>page renders.pnpm build(Sass compiles) +pnpm test+pnpm lintgreen. - [ ] Commit
chore(carbon): remove tailwind/shadcn/recharts, add Carbon + Sass build + light/dark Theme.
Task 2: Carbon app shell + auth gate (role-aware nav, theme toggle, health pill)
Deliverable: Carbon UI Shell (Header, HeaderName, HeaderNavigation/SideNav) with role-aware nav (executive: Overview; operator: +Calls; auditor: +Audit), a theme toggle in the header (light/dark, persisted, sets Carbon Theme + chart source), a health pill (/healthz poll every 15s, Carbon Tag green/red + Tooltip with pending/ingested), and a Carbon LoginGate (token field + role dropdown + "server is the real gate" note). Reuses auth.tsx logic and nav.ts.
- [ ] Rebuild LoginGate with Carbon
TextInput/Dropdown/Button. AuthProvider logic unchanged; 401→clear+gate preserved (carry-forward #5). Token in sessionStorage only (#6). - [ ] Carbon UI Shell; nav items from
nav.tsrole mapping; route-gate a role lacking a section → redirect to Overview. - [ ] Theme toggle (header action) using the Task-1 provider. Health pill.
- [ ] Tests: role→visible-nav mapping (reuse/port); 401 clears session; theme toggle flips Carbon theme + persists. Commit
feat(carbon): UI Shell, role-aware nav, login gate, theme toggle, health pill.
Task 3: Carbon chart + tile primitives (@carbon/charts-react wrappers + Carbon Tiles)
Deliverable: thin, theme-aware wrappers over @carbon/charts-react (a LineTimeSeries for throughput/latency, a SimpleBar/GroupedBar for breakdowns) that receive the current chart theme from the Task-1 source, plus a StatTile built on Carbon Tile with the explicit Unavailable/loading/error/real-zero states (carry-forward #1). Invoke dataviz to confirm the chart options honor its non-negotiables (one axis, categorical order, legend, recessive axes) even though @carbon/charts supplies its own palette + built-in table/a11y.
- [ ] Chart wrappers: pass
options.theme = chartTheme; one y-axis; legend on; @carbon/charts' built-in "show as table" + keyboard a11y satisfy the table-view/a11y gates (do not hand-roll). Colors follow the data-group (entity), consistent across renders — configurecolor.scale/color.pairingso a series keeps its color when the set filters (the stable-entity anti-pattern, now via Carbon's color scale). - [ ]
StatTile: props for value/label/subtitle/tone/loading/unavailable.unavailable(query isError) → a muted "Unavailable / Query failed" state, NEVER a value or a "Good" tone (#1). Subtitle slot (the dev/prod external-calls caveat lives here). - [ ] Tests: StatTile unavailable state renders no number and no good-tone; chart wrapper receives and applies the theme; series color stable across a changing series set. Commit
feat(carbon-viz): themed @carbon/charts wrappers + Tile stat cards with honest error states.
Task 4: Executive view (Carbon Grid + Tiles + @carbon/charts)
Deliverable: the executive BI view rebuilt in Carbon — date-range filter row, stat tiles (total calls, error rate, tokens, external-calls proof), throughput/latency line charts, engine/model bar breakdowns. Reuses metrics.ts + date-range.ts + upstream-class.ts verbatim.
- [ ] Carbon
Grid/Columnlayout; Carbon date-range control (DatePickerrange or a presetDropdown1h/24h/7d) driving TanStack Query keys (reuse the existing query wiring/logic). - [ ] Stat tiles incl. the external-calls proof tile (0→good tone, nonzero→critical, isError→Unavailable per #1) with the dev-host subtitle. Rate labels from the bucket (#2). Latency chart avg/max with the "p95/p99 pending" note (#7). NO faked per-bucket status (#7) — keep the summary-backed error split.
- [ ] Tests: error-rate math wiring, external-tile 0/nonzero/unavailable, rate-label-from-bucket. Commit
feat(carbon): executive view — tiles, throughput/latency, breakdowns.
Task 5: Operator view (@carbon/ibm-products Datagrid + Carbon filters + detail panel)
Deliverable: the call explorer rebuilt on Carbon Datagrid — filter row, keyset-paginated rows via use-calls-query (reused), a Carbon SidePanel/Modal detail with trace fan-out, auditor-only "View bodies" deep-link, and the static "Fleet health — P3" placeholder.
- [ ] Datagrid (
@carbon/ibm-products) fed by the existinguse-calls-queryinfinite/keyset hook — render accumulated rows with a "Load more" action (NOT Datagrid's page-number pager; our API is keyset — carry-forward #3). Status column = CarbonTagwith a text label (never color-alone). Columns: ts, engine, surface, model, status, traffic_class, queue_ms, total_ms, tokens. - [ ] Row → detail panel from
/api/calls/{id}; trace_id "show all in trace" filters the grid; "View bodies" renders only forrole==='auditor', deep-linking/audit?event_id=...(Task 6 consumes it). Fleet-health static placeholder. 403 graceful. - [ ] Tests: keyset cursor last-row/no-dup/filter-reset (port the existing ones); auditor-only View-bodies. Commit
feat(carbon): operator call explorer on Datagrid with keyset load-more + detail panel.
Task 6: Auditor view (Carbon body viewer + chain-verify + access-log Datagrid)
Deliverable: the compliance surface rebuilt — recorded body viewer (JSON-pretty/raw, PROMINENT recorded notice), chain-verify with Carbon InlineNotification banners (ok=success / bad_chain_pos=error), access-log on a Datagrid. Reuses body-format.ts + errors.ts. Consumes the ?event_id= deep link (react-router useSearchParams).
- [ ] Body viewer: fetch
/api/calls/{id}/body(event_id from?event_id=or a paste field) → request/response panels (CarbonCodeSnippet/<pre>; JSON pretty when parseable, raw otherwise). Prominent "this view is recorded" notice, before content (#4). Truncation note if signaled. - [ ] Chain-verify: "Verify integrity" over an optional range →
bad_chain_pos(#4): ok → successInlineNotification, bad → error notification naming the position. - [ ] Access-log Datagrid: at / actor(hashed token, labeled as such #4) / role / event_id / action.
- [ ] 403 → clear "requires auditor role" state (#4/#5), no crash. Tests: pretty-vs-raw, ok-vs-bad_chain_pos banner, 403 state, deep-link auto-load. Commit
feat(carbon): auditor view — recorded body viewer, chain verify, access-log.
Task 7: Cross-view polish + full light/dark verification
Deliverable: consistent Carbon spacing/type tokens across all views, no leftover Tailwind classes anywhere, and a full pass confirming EVERY view + EVERY chart renders correctly in BOTH g10 and g90 (the set-both rule holds end to end).
- [ ] Grep for any residual
className=Tailwind utilities / shadcn imports / recharts /cn(— zero remaining. Remove dead deps from package.json. - [ ] Verify each chart's theme flips with the app theme (the set-both rule) — a test that mounting under dark passes
theme:'g90'to the chart options. - [ ]
pnpm test+pnpm build+pnpm lintgreen. Commitrefactor(carbon): remove residual tailwind, unify tokens, verify light/dark across views.
Task 8: Live deploy on ai-ahu + light AND dark screenshots (exit gate)
Deliverable: the Carbon dashboard replacing the running container on ai-ahu:8320, verified against real data, with screenshots in BOTH themes.
- [ ] README update (Carbon stack, themes, unchanged token/proxy model). Image builds < 100MB target (Carbon + Plex is heavier than shadcn — if it exceeds, note the real size; still ship). rsync →
efran@192.168.83.20:~/ahu-observatory-dashboard/,docker compose up -d --build(or docker save|ssh load as prior tasks did). Disk check (abort if free < 20G). - [ ] Live verify (operator + auditor tokens from the host
observatory.yaml— read-only, never print/commit): executive tiles show real data, Datagrid lists calls + load-more, a body view writes an access-log row, verify-chain ok, external-calls tile correct. Confirm the theme toggle flips app + charts together live. - [ ] Screenshots in BOTH light and dark (headless Chromium) of executive + operator + auditor → upload; report URLs. Commit
feat(deploy): Carbon dashboard live;git tag dash-carbon. Leave running; note stop command. Write + upload the drill report.
Self-review notes (plan-time)
- Every carry-forward invariant (1–7) is assigned to the task that owns its surface and named as a reviewer gate, so the reskin can't silently regress the prior build's Critical fixes (fabricated-0 tiles, 7d rate label, keyset correctness, recorded-notice, role-gating).
- The data/logic layer + its tests are explicitly out of scope for rewrite — the risky, valuable code doesn't move; only presentation changes.
- The "set both themes" rule is the migration's headline gotcha (mirrors the prior .dark/data-theme lesson): charts don't inherit the Carbon theme, so it's called out at the state layer (Task 1), the primitive layer (Task 3), and end-to-end (Task 7) + live (Task 8).
- Deliberate accepted loss: the CVD-validated custom chart palette is replaced by @carbon/charts' own audited palette (per the design decision to go native charts). @carbon/charts' built-in a11y (keyboard + screen-reader table + CSV) replaces the hand-built table-view toggle.
- Keyset-vs-Datagrid tension (Task 5): the API is keyset, Datagrid defaults to page-number paging — resolved by driving the grid from the existing
use-calls-queryhook with a "Load more" action, not Datagrid's pager.