think
16px
820px

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-max for layout, and dataviz for the chart tasks. Verify every Carbon component/chart API against the INSTALLED package version (Carbon churned across v10→v11) — read node_modules/@carbon/react and @carbon/charts-react types; 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 branch feat/carbon-reskin off master (Task 1 creates it). Merge to master at the end (this replaces the current dash-p1 UI).
  • Consumes ONLY the observatory :8300 API 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 to white/g100 if 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 matching theme to EVERY @carbon/charts chart (charts take a theme: '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)

  1. Stat tiles show an explicit "Unavailable" state on query isError — NEVER a fabricated 0 / 0.0% / "Good". The external-calls proof tile especially must never read "Good"/"0" on a failed query. Distinguish loading / error / empty / real-zero.
  2. 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.)
  3. 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.)
  4. 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.
  5. 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.
  6. Token lives only in sessionStorage — never logged, never committed, never in a report.
  7. 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/charts provides 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 with Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT. pnpm test + pnpm build + pnpm lint green before each commit. Suite must stay green on any Node (the Storage polyfill from src/test/setup.ts stays).
    - Every created/modified .md uploaded to https://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/plex or 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 Tailwind index.css.
  • [ ] src/lib/theme.ts (rewrite): a useCarbonTheme()/provider exposing the current theme ('g10'|'g90'), a toggle(), localStorage persistence, and a chartTheme value (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 lint green.
  • [ ] 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.ts role 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 — configure color.scale/color.pairing so 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/Column layout; Carbon date-range control (DatePicker range or a preset Dropdown 1h/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 existing use-calls-query infinite/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 = Carbon Tag with 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 for role==='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 (Carbon CodeSnippet/<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 → success InlineNotification, 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 lint green. Commit refactor(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-query hook with a "Load more" action, not Datagrid's pager.