From a79c88949f54905468cb21a284d8735726d89ec0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 31 Aug 2026 23:17:19 -0700 Subject: [PATCH 01/18] docs(cockpit): design for making Run discoverable from Code Records the brainstormed design: mode stops being sticky, the mode rail becomes legible as a switch, Run carries a runtime phase dot, and the Activity dot is re-scoped to unseen problems so the two dots make distinct claims. Co-Authored-By: Claude Opus 5 --- ...8-31-cockpit-run-discoverability-design.md | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-31-cockpit-run-discoverability-design.md diff --git a/docs/superpowers/specs/2026-08-31-cockpit-run-discoverability-design.md b/docs/superpowers/specs/2026-08-31-cockpit-run-discoverability-design.md new file mode 100644 index 000000000..40f26acd8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-cockpit-run-discoverability-design.md @@ -0,0 +1,174 @@ +# Cockpit: making Run discoverable from Code + +**Date:** 2026-08-31 +**Status:** Approved, ready for planning + +## Problem + +On a capability page in Code view, nothing tells the user they can run the example. The +mode rail exists and works, but it does not read as a switch. + +Three failures, observed on the live page at 1440×900 +(`/ag-ui/core-capabilities/streaming/overview/python`): + +1. **The rail reads as chrome.** `Docs / Run / Code / API` sit in a 66px column at the far + left edge, at the same visual weight as the `Activity` and `Settings` utilities beneath + them. Nothing groups the four as views of one capability. Inactive items are painted + with `--ds-text-muted` — the token also used for disabled text — so they read as + unavailable rather than clickable. + +2. **Code view has no exit affordance.** Roughly 1250px of the viewport is file tree, tabs + and source. The only route back to Run is a 48px target ~1200px away, with no + in-context prompt. + +3. **Runtime controls sit below the fold.** The `Runtime` section renders after the full + capability nav list. Measured on the live page: the heading lands at `y=1860` inside a + 900px viewport, in a pane with 1997px of scroll height. The one surface that says a + live runtime exists is unreachable without scrolling past ~30 nav links. + +**Amplifier.** `activeMode` is persisted in `localStorage` under a single global key +(`libs/ui-react/src/lib/control-plane/control-plane-preferences.ts`), not scoped per +capability. Verified live: a single Code click persisted for the rest of the session and +across navigations. One exploratory click therefore makes Code the landing view for *every* +capability, on *every* future visit. The `'Run'` default only ever applies to a first-time +visitor. + +## Non-goals + +- No in-content affordance inside the Code pane (a header "run this" pill, a live peek at + the running app). Both were considered and deliberately deferred; see Alternatives. +- No change to Docs, API or narrative content. +- No change to the mobile overlay beyond what falls out of shared components. + +**Accepted tradeoff:** this design does not address failure 2. Nothing lands in the 1250px +where the user's eye is while reading code. The rail becomes legible; it does not become +close. This is a deliberate first step, to be evaluated before deciding whether the header +pill is still needed. + +## Design + +### 1. Mode is no longer sticky + +Every capability opens in Run. Mode becomes per-page state, not a preference. + +- Remove `activeMode` / `setActiveMode` from `ControlPlanePreferencesV1['cockpit']` and + from `useControlPlanePreferences`. The hook retains `expanded` only. +- `CockpitShell` holds the mode in `useState('Run')`. +- The `?mode=` deep link keeps working. It currently waits on `preferences.hydrated`; + with mode as local state it instead applies in a mount effect, so the server-rendered + Run markup and the first client render agree and hydration stays clean. +- No storage version bump. `activeMode` in existing stored blobs is simply no longer read; + a stale ignored key is harmless. The `docs` surface does not use this field. + +Rationale: the cockpit's value is the running example; the code is supporting evidence. A +preference that quietly demotes the demo to a code dump is the wrong thing to remember — +and it is remembered from a single exploratory click, which is weak evidence of intent. + +### 2. Rail legibility + +In `apps/cockpit/src/app/cockpit.css`: + +- **Resting contrast:** inactive `[data-control-plane-rail-item]` moves from + `--ds-text-muted` to `--ds-text-secondary`. +- **Group rule:** a hairline above `[data-control-plane-rail-group="utilities"]`. The DOM + already separates `primary` from `utilities` + (`libs/ui-react/src/lib/control-plane/control-plane.tsx`); only the CSS is missing. +- **Set label:** a small `VIEW` cap above the primary group, so the switch is named rather + than inferred. Decorative, `aria-hidden` — the rail's `nav` already carries + `aria-label="Cockpit modes"`. + +### 3. A runtime phase dot on Run + +The `Run` rail item carries a dot driven by `runtimeSnapshot.phase`, already available in +`CockpitShell` via `controller.snapshot`. + +| Phase | Dot | +| --- | --- | +| `ready` | success | +| `connecting`, `checking`, `reloading` | working | +| `unresponsive`, `error`, `invalid_configuration` | error | +| `not_configured` | none | + +`not_configured` renders no dot: there is no runtime, so absence is the honest signal. + +`.cockpit-control-plane` already defines `--cockpit-state-success` and +`--cockpit-state-error` (with dark-theme overrides). The success and error buckets use +those; the working bucket needs one new sibling token defined the same way. + +Constraints: + +- **Colour is not the only channel.** The state goes into the Run item's accessible name + ("Run, runtime ready" / "Run, runtime error"), reusing the existing rail tooltip for the + hover affordance. +- **Steady fills, no pulse.** A blinking dot in permanent peripheral vision costs + attention without carrying information. +- Reuses the existing dot treatment established by `[data-cockpit-activity-attention]`. + +### 4. Activity's dot is re-scoped to unseen problems + +Adding a phase dot to Run collides with the Activity dot: today +`runtimeNeedsAttention(phase)` fires on exactly `invalid_configuration`, `unresponsive` +and `error` — the same three phases where Run now goes red. Left alone, one fault paints +two red dots in one 66px column and neither says which to click. + +The two dots are re-scoped to make two distinct claims: + +- **Run** — what the runtime is doing *right now*. +- **Activity** — there are problems in the log you have not read. + +Definition: the Activity dot shows when there is at least one event with +`severity === 'error'` that arrived since the panel was last opened. `severity` is already +assigned in `createSessionActivityEvent`, so this needs no new event plumbing. It covers +`runtime_unresponsive`, `runtime_initialization_error`, `configuration_invalid` and +`diagnostics_copy_failed`. + +- Seen-marker: opening the Activity utility marks all current events seen. `CockpitShell` + already tracks `activityOpenCycle` on open, which is the natural hook. +- Clearing the log clears the marker. +- Deriving from `severity === 'error'` rather than "any unread event" matters: `mode_changed` + and `runtime_ready` fire during ordinary use, and counting them would light the dot + constantly from the user's own actions. + +This is strictly better than today's behaviour on one case: a runtime that failed and then +self-recovered currently leaves no trace once the phase clears, because the dot tracks +live phase. Under the new rule the unread error survives the recovery until someone looks. + +`runtimeNeedsAttention` remains exported and still drives the Run dot's error bucket; only +the Activity dot stops calling it. + +## Components touched + +| Unit | Change | +| --- | --- | +| `control-plane-preferences.ts` | Drop `activeMode` from the persisted shape and the hook | +| `control-plane.tsx` | Rail item gains optional status-dot + accessible-name support | +| `cockpit-control-plane.tsx` | Pass phase to Run; switch Activity's dot to unseen-errors | +| `cockpit-shell.tsx` | Mode as local state; `?mode=` on mount; track seen-marker | +| `activity-panel.tsx` | `attention` prop reinterpreted as unseen-errors | +| `cockpit.css` | Contrast, group rule, `VIEW` cap, dot variants | + +## Testing + +- **Preferences:** stored `activeMode` is ignored; a fresh capability opens in Run; + `?mode=code` still lands in Code without a hydration mismatch. +- **Phase mapping:** all eight phases map to the right dot, `not_configured` renders none, + and the accessible name changes with phase. +- **Activity dot:** does not fire on `mode_changed` or `runtime_ready`; fires on an error + event; clears on panel open; survives a fault that recovers before the panel is opened. +- **Mutation check.** The Activity-dot rule and the `not_configured` no-dot case both fail + silently if wired wrong — the assertion is an absence. Each needs a deliberate mutation + to confirm the test actually fails, per prior experience with tests that pass vacuously. +- Existing rail, overflow-menu and mobile-overlay suites must stay green. + +## Alternatives considered + +- **Header runtime pill** (`● Running ›› See it run` in the workspace header). Hits all + three failures, lands where the eye is, and covers Docs and API with one affordance. + Recommended, not chosen — deferred so the cheaper rail work can be evaluated first. +- **Live peek** — the running app kept on screen beneath the code. Architecturally cheap, + because `RunMode` never unmounts (`invisible absolute inset-0`) and the iframe is already + warm. Deferred: these demos are chat UIs that want vertical room, and at laptop height it + risks leaving both panes unusable. Wants a real-size prototype before committing. +- **Presence-only dot on Run** (one colour, "a runtime is live"). Avoids the Activity + collision entirely. Not chosen: full phase is more informative, and the collision turned + out to have a resolution that improves Activity independently. From 0939ea49a4426133842199dd60a27a1e7580c85c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 31 Aug 2026 23:24:19 -0700 Subject: [PATCH 02/18] docs(cockpit): implementation plan for Run discoverability Six TDD tasks: drop mode stickiness, make the rail legible as a switch, add a status-dot slot to the ui-react rail primitive, wire the runtime phase onto Run, re-scope the Activity indicator to unseen problems, and verify in a browser. Co-Authored-By: Claude Opus 5 --- .../2026-08-31-cockpit-run-discoverability.md | 1073 +++++++++++++++++ 1 file changed, 1073 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-31-cockpit-run-discoverability.md diff --git a/docs/superpowers/plans/2026-08-31-cockpit-run-discoverability.md b/docs/superpowers/plans/2026-08-31-cockpit-run-discoverability.md new file mode 100644 index 000000000..0aadeab4b --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-cockpit-run-discoverability.md @@ -0,0 +1,1073 @@ +# Cockpit Run Discoverability 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 cockpit's mode rail read as a switch — Run always the landing view, the four modes visually grouped and legible, and Run carrying a live runtime phase dot — while re-scoping the Activity dot so the two dots make distinct claims. + +**Architecture:** Four independent slices over the existing control plane. `activeMode` stops being a persisted preference and becomes local shell state. Rail legibility is pure CSS over DOM hooks that already exist. `ControlPlaneRailItem` in `@threadplane/ui-react` gains a generic status-dot slot, which the cockpit wires to `runtimeSnapshot.phase` for Run and to an unseen-error count for Activity. + +**Tech Stack:** Next.js (App Router) + React 19, TypeScript, Nx, Vitest + Testing Library (jsdom), plain CSS with `--ds-*` design tokens. + +**Spec:** `docs/superpowers/specs/2026-08-31-cockpit-run-discoverability-design.md` + +**Test commands:** + +```bash +npx nx test ui-react +``` + +```bash +npx nx test cockpit +``` + +Both must be green before the final task is considered done. If this worktree has never installed dependencies, run `npm ci` once first (never `npm install`, never hand-copy packages). + +--- + +## File Structure + +| File | Responsibility | Change | +| --- | --- | --- | +| `libs/ui-react/src/lib/control-plane/control-plane-preferences.ts` | Persisted control-plane preferences | Drop `activeMode` from the stored shape and the hook | +| `libs/ui-react/src/lib/control-plane/control-plane.tsx` | Presentational control-plane primitives | `ControlPlaneRailItem` gains `status` + `statusLabel` | +| `apps/cockpit/src/components/cockpit-shell.tsx` | Cockpit page shell, owns mode + activity state | Mode as local state; track the activity seen-marker | +| `apps/cockpit/src/components/control-plane/cockpit-control-plane.tsx` | Cockpit's rail + pane composition | Map phase → Run dot; map unseen errors → Activity dot | +| `apps/cockpit/src/components/control-plane/activity-panel.tsx` | Activity log panel | `attention` reinterpreted as unseen errors | +| `apps/cockpit/src/lib/runtime/runtime-state.ts` | Runtime phase model | Add `runtimeRailStatus(phase)` | +| `apps/cockpit/src/app/cockpit.css` | Cockpit control-plane styling | Contrast, group rule, `VIEW` cap, dot variants, working token | + +--- + +## Task 1: Mode stops being sticky + +Every capability opens in Run. `activeMode` leaves persisted preferences entirely. + +**Files:** +- Modify: `libs/ui-react/src/lib/control-plane/control-plane-preferences.ts` +- Modify: `apps/cockpit/src/components/cockpit-shell.tsx` +- Test: `libs/ui-react/src/lib/control-plane/control-plane-preferences.spec.ts` +- Test: `apps/cockpit/src/components/cockpit-shell.spec.tsx` + +- [ ] **Step 1: Write the failing test** + +Add to `libs/ui-react/src/lib/control-plane/control-plane-preferences.spec.ts`: + +```ts +it('ignores a stored activeMode and never writes one back', () => { + window.localStorage.setItem( + CONTROL_PLANE_STORAGE_KEY, + JSON.stringify({ + version: 1, + docs: { expanded: {} }, + cockpit: { activeMode: 'Code', expanded: { Capability: false } }, + }), + ); + + const parsed = readControlPlanePreferences(window.localStorage); + + expect('activeMode' in parsed.cockpit).toBe(false); + expect(parsed.cockpit.expanded.Capability).toBe(false); +}); +``` + +This spec drives `window.localStorage` directly and clears it in `beforeEach` — there is +no fake-storage helper to reuse. `CONTROL_PLANE_STORAGE_KEY` and +`readControlPlanePreferences` are already imported at the top of the file. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test ui-react -- -t "ignores a stored activeMode"` +Expected: FAIL — `expect(true).toBe(false)`, because `readControlPlanePreferences` still +copies `activeMode` through. + +- [ ] **Step 3: Remove `activeMode` from the persisted shape** + +In `control-plane-preferences.ts`: + +Change the interface: + +```ts +export interface ControlPlanePreferencesV1 { + version: 1; + docs: { + expanded: Record; + }; + cockpit: { + expanded: Record; + }; +} +``` + +Change the defaults: + +```ts +const DEFAULT_PREFERENCES: ControlPlanePreferencesV1 = { + version: 1, + docs: { expanded: { Learn: true, Environment: false } }, + cockpit: { + expanded: { Capability: true, Environment: true }, + }, +}; +``` + +Change `cloneDefaults`: + +```ts +const cloneDefaults = (): ControlPlanePreferencesV1 => ({ + version: 1, + docs: { expanded: { ...DEFAULT_PREFERENCES.docs.expanded } }, + cockpit: { expanded: { ...DEFAULT_PREFERENCES.cockpit.expanded } }, +}); +``` + +In `readControlPlanePreferences`, drop the `activeMode` branch from the returned object: + +```ts + cockpit: { + expanded: booleanRecord(cockpit.expanded, defaults.cockpit.expanded), + }, +``` + +Delete the now-unused `isMode` helper. **Keep** `MODES`, `ControlPlaneMode` and +`parseControlPlaneMode` — the shell still uses them for `?mode=` deep links. + +Delete the `setActiveMode` callback entirely, and change the hook's return to: + +```ts + return { + hydrated, + expanded: preferences[surface].expanded, + setExpanded, + }; +``` + +Note `surface` is still read by `setExpanded`, so it stays a parameter. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test ui-react -- -t "ignores a stored activeMode"` +Expected: PASS + +- [ ] **Step 5: Fix the rest of the preferences suite** + +Run: `npx nx test ui-react` +Expected: several existing assertions fail — they set and read `activeMode` +(around lines 23, 36, 46, 80, 82, 104, 112, 122, 135, 143, 164 of the spec). + +Delete the assertions and fixture fields that concern `activeMode` and +`setActiveMode`. Keep every `expanded` assertion untouched. The test named for +docs-surface mode pinning (which asserted `activeMode` is `'Docs'` on the docs surface) +no longer has a subject — delete it. + +Re-run until green. + +- [ ] **Step 6: Move mode into the shell** + +In `apps/cockpit/src/components/cockpit-shell.tsx`: + +Add to the existing `useState` block near `isSidebarOpen`: + +```tsx + const [activeMode, setActiveMode] = useState('Run'); +``` + +Delete this line further down: + +```tsx + const activeMode: ControlPlaneMode = preferences.activeMode; +``` + +Replace the query-param effect with one that no longer waits on hydration: + +```tsx + useEffect(() => { + if (queryHandled.current) return; + queryHandled.current = true; + const url = new URL(window.location.href); + const rawMode = url.searchParams.get('mode'); + const requestedMode = parseControlPlaneMode(rawMode); + if (requestedMode) setActiveMode(requestedMode); + if (rawMode !== null) { + url.searchParams.delete('mode'); + window.history.replaceState( + window.history.state, + '', + url.pathname + url.search + url.hash + ); + } + }, []); +``` + +The effect runs only on the client after mount, so the server-rendered Run markup and the +first client render agree — `?mode=code` swaps in on the second render, not during +hydration. + +In `handleModeChange`, swap the setter and drop `preferences` from the dependency array: + +```tsx + const handleModeChange = useCallback( + (mode: ControlPlaneMode) => { + if (mode === activeMode) return; + setActiveMode(mode); + appendActivity( + createLocalActivityInput(entry.topic, { + kind: 'mode_changed', + mode, + }) + ); + track('cockpit:mode_switched', { + capability: entry.topic, + from_mode: MODE_ANALYTICS[activeMode], + to_mode: MODE_ANALYTICS[mode], + }); + }, + [activeMode, appendActivity, entry.topic] + ); +``` + +`preferences` is still used for `expanded`, `setExpanded` and `hydrated` — leave the +`useControlPlanePreferences('cockpit')` call in place. + +- [ ] **Step 7: Run the cockpit suite** + +Run: `npx nx test cockpit` +Expected: failures in `cockpit-shell.spec.tsx` around lines 96, 107 and 195, which seed +and assert `cockpit.activeMode` in `localStorage`. + +Rewrite those so that: +- the seeding helper no longer takes or writes an `activeMode`; +- a test asserts that a stored `activeMode: 'Code'` does **not** change the landing view — + the shell still renders Run; +- a test asserts `?mode=code` still lands in Code and strips the param from the URL. + +Re-run until green. + +- [ ] **Step 8: Mutation-check the stickiness test** + +Temporarily re-add `activeMode` passthrough in `readControlPlanePreferences` and confirm +the "stored `activeMode` does not change the landing view" test fails. Revert the +mutation. This assertion is about an absence and would pass vacuously if mis-wired. + +- [ ] **Step 9: Commit** + +```bash +git add libs/ui-react/src/lib/control-plane/control-plane-preferences.ts libs/ui-react/src/lib/control-plane/control-plane-preferences.spec.ts apps/cockpit/src/components/cockpit-shell.tsx apps/cockpit/src/components/cockpit-shell.spec.tsx +git commit -m "fix(cockpit): stop persisting the active mode so every capability opens in Run" +``` + +--- + +## Task 2: Rail legibility + +**Files:** +- Modify: `apps/cockpit/src/app/cockpit.css` +- Modify: `libs/ui-react/src/lib/control-plane/control-plane.tsx` +- Test: `apps/cockpit/src/components/control-plane/cockpit-control-plane.spec.tsx` + +- [ ] **Step 1: Write the failing test** + +Add to `cockpit-control-plane.spec.tsx`, copying the disk-read preamble that +`activity-panel.spec.tsx:11-17` already uses (the cwd check matters — Nx runs this suite +from `apps/cockpit`, not the workspace root): + +```tsx +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const workspaceRoot = process.cwd().endsWith('/apps/cockpit') + ? resolve(process.cwd(), '../..') + : process.cwd(); +const cockpitCss = readFileSync( + resolve(workspaceRoot, 'apps/cockpit/src/app/cockpit.css'), + 'utf8' +); +``` + +```tsx +it('separates the mode group from the utilities and lifts resting contrast', () => { + expect(cockpitCss).toMatch( + /\[data-control-plane-rail-group="utilities"\][^}]*border-top/ + ); + expect(cockpitCss).not.toMatch( + /\[data-control-plane-rail-item\]\s*\{[^}]*--ds-text-muted/ + ); +}); + +it('names the mode group without adding a second landmark label', () => { + renderControlPlane(); + const rail = screen.getByRole('navigation', { name: 'Cockpit modes' }); + const cap = rail.querySelector('[data-control-plane-rail-group-label]'); + expect(cap?.textContent).toBe('View'); + expect(cap?.getAttribute('aria-hidden')).toBe('true'); +}); +``` + +`renderControlPlane` is the existing render helper in this spec file. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test cockpit -- -t "separates the mode group"` +Expected: FAIL — no `border-top` on the utilities group, and the item rule still sets +`--ds-text-muted`. + +- [ ] **Step 3: Add the group label to the primitive** + +In `libs/ui-react/src/lib/control-plane/control-plane.tsx`, extend the rail: + +```tsx +export interface ControlPlaneRailProps extends CommonProps { + label: string; + primaryLabel?: string; + primary: ReactNode; + utilities?: ReactNode; +} + +export function ControlPlaneRail({ + label, + primaryLabel, + primary, + utilities, + className, +}: ControlPlaneRailProps) { + return ( + + ); +} +``` + +It is `aria-hidden` on purpose: the `nav` already carries `aria-label="Cockpit modes"`, and +a visible duplicate would announce the group twice. + +- [ ] **Step 4: Pass the label from the cockpit** + +In `apps/cockpit/src/components/control-plane/cockpit-control-plane.tsx`, on the +`` element add: + +```tsx + primaryLabel="View" +``` + +- [ ] **Step 5: Update the CSS** + +In `apps/cockpit/src/app/cockpit.css`, change the resting colour of the rail item: + +```css +.cockpit-control-plane [data-control-plane-rail-item] { + position: relative; + min-height: 48px; + padding: 6px 2px; + border: 0; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + color: var(--ds-text-secondary); + background: transparent; + text-decoration: none; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} +``` + +Then replace the utilities rule and add the cap: + +```css +.cockpit-control-plane [data-control-plane-rail-group="utilities"] { + margin-top: auto; + padding-top: 8px; + border-top: 1px solid var(--ds-border); +} +.cockpit-control-plane [data-control-plane-rail-group-label] { + display: block; + padding-bottom: 4px; + color: var(--ds-text-muted); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + text-align: center; +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npx nx test cockpit -- -t "separates the mode group"` +Run: `npx nx test cockpit -- -t "names the mode group"` +Expected: PASS + +- [ ] **Step 7: Run both suites** + +Run: `npx nx test ui-react` +Run: `npx nx test cockpit` +Expected: green. `control-plane.spec.tsx` may need a case for `primaryLabel` being +omitted — if a snapshot or structural assertion breaks, update it to reflect the new +optional wrapper. + +- [ ] **Step 8: Commit** + +```bash +git add libs/ui-react/src/lib/control-plane/control-plane.tsx apps/cockpit/src/components/control-plane/cockpit-control-plane.tsx apps/cockpit/src/app/cockpit.css apps/cockpit/src/components/control-plane/cockpit-control-plane.spec.tsx +git commit -m "feat(cockpit): make the mode rail read as a switch" +``` + +--- + +## Task 3: A status-dot slot on rail items + +Generic primitive change. No cockpit behaviour yet. + +**Files:** +- Modify: `libs/ui-react/src/lib/control-plane/control-plane.tsx` +- Modify: `libs/ui-react/src/index.ts` +- Test: `libs/ui-react/src/lib/control-plane/control-plane.spec.tsx` + +- [ ] **Step 1: Write the failing test** + +Add to `control-plane.spec.tsx`: + +```tsx +it('renders a status dot and folds its label into the accessible name', () => { + render( + } + status="error" + statusLabel="runtime error" + />, + ); + const button = screen.getByRole('button', { name: 'Run, runtime error' }); + expect( + button.querySelector('[data-control-plane-rail-status]')?.getAttribute( + 'data-control-plane-rail-status', + ), + ).toBe('error'); +}); + +it('renders no status dot when status is omitted', () => { + render(} />); + const button = screen.getByRole('button', { name: 'Run' }); + expect(button.querySelector('[data-control-plane-rail-status]')).toBeNull(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test ui-react -- -t "renders a status dot"` +Expected: FAIL — `status` is not a valid prop and no element matches. + +- [ ] **Step 3: Implement** + +In `control-plane.tsx`, replace `ControlPlaneRailItemProps` and the body of +`ControlPlaneRailItem`: + +```tsx +export type ControlPlaneRailStatus = 'success' | 'working' | 'error'; + +export interface ControlPlaneRailItemProps extends CommonProps { + label: string; + icon: ReactNode; + active?: boolean; + href?: string; + onSelect?: () => void; + iconOnly?: boolean; + target?: string; + rel?: string; + status?: ControlPlaneRailStatus; + statusLabel?: string; +} + +export function ControlPlaneRailItem({ + label, + icon, + active = false, + href, + onSelect, + iconOnly = false, + className, + target, + rel, + status, + statusLabel, +}: ControlPlaneRailItemProps) { + const tooltipId = useId(); + const accessibleName = statusLabel ? `${label}, ${statusLabel}` : label; + const showTooltip = iconOnly || Boolean(statusLabel); + const content = ( + <> + {icon} + {iconOnly ? null : {label}} + {status ? ( +