# Web UI Primitive Library Implementation Plan (T9)

> **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:** Replace copy-pasted Tailwind button/input/card/dialog markup with a small, token-backed, accessible `ui/` primitive library that is *actually adopted* across the web app.

**Architecture:** Fix the root cause first — add a real app-wide **semantic token layer** to `globals.css` (Tailwind v4 `@theme` over CSS variables, light/dark), since the prior Button died referencing tokens that only existed under `/executive`. Then build four primitives (`Button`, `Input`, `Card`, `Dialog`) test-first, adopt them immediately in high-traffic components (so they are never "aspirational"), consciously retire the `auditor-button-primitive-gone` smoke and replace it with an **adoption** smoke, then migrate the ~247 raw `<button>`/input/card sites across ~66 files in feature-area batches — each batch its own PR, gated by `tsc` + `jest` + the smoke runner.

**Tech Stack:** Next.js 16 App Router, React 19, Tailwind CSS v4 (`@import "tailwindcss"` + `@variant dark`), TypeScript, Jest (`dom` project, jsdom + @testing-library), `scripts/smoke/run-all.ts` regression gate.

**Why this is multi-PR (and reverses a guarded decision):** `auditor-button-primitive-gone.ts` guards the 2026-06-01 deletion of an *unused* Button (1 importer, 247 raw buttons). This plan only earns that reversal by *adopting* the primitives. Each phase below is an independently shippable PR off `claude/work`.

---

## File Structure

- `src/app/globals.css` — **modify**: add a semantic token layer (`@theme` + `:root`/`.dark` CSS vars).
- `src/components/ui/Button.tsx` — **create**: the primitive (replaces the deleted one, token-backed + accessible).
- `src/components/ui/__tests__/Button.test.tsx` — **create**.
- `src/components/ui/Input.tsx`, `Card.tsx`, `Dialog.tsx` — **create** (+ tests).
- `src/components/ui/index.ts` — **create**: barrel export.
- `scripts/smoke/auditor-button-primitive-gone.ts` — **delete** (decision consciously reversed).
- `scripts/smoke/ui-primitive-adoption.ts` — **create**: inverse guard — asserts the primitives exist, are exported, and that raw-`<button>` count is trending DOWN (a ratchet).
- `scripts/smoke/run-all.ts` — **modify**: deregister the gone-smoke, register the adoption smoke (same commit).
- Feature components (migration phases): `src/components/{dashboard,monitors,incidents,settings,profile,audit,status-page,maintenance,chat}/**` — **modify** in batches.

---

## Phase 0 — Token layer + Button primitive + adoption + smoke swap (PR 1)

### Task 0.1: Add the semantic token layer to globals.css

**Files:**
- Modify: `src/app/globals.css`

- [ ] **Step 1: Add `@theme` + light/dark CSS vars** (after the existing `.dark { … }` block, before `body`):

```css
/* Semantic token layer (T9). Components use bg-surface / text-fg / bg-brand
   etc. — never raw slate-*/indigo-* — so the whole app restyles from here.
   Mirrors the mobile app's token model. */
:root {
  --c-surface: #ffffff;
  --c-elevated: #f1f5f9;
  --c-border: #e2e8f0;
  --c-fg: #0f172a;
  --c-muted: #64748b;
  --c-brand: #1e2a64;       /* Evidence Action navy */
  --c-brand-fg: #ffffff;
  --c-accent: #d6296b;      /* magenta */
  --c-accent-fg: #ffffff;
  --c-danger: #dc2626;
  --c-danger-fg: #ffffff;
}
.dark {
  --c-surface: #0f172a;
  --c-elevated: #1e293b;
  --c-border: #1e293b;
  --c-fg: #f1f5f9;
  --c-muted: #94a3b8;
  --c-brand: #2540a8;
  --c-brand-fg: #ffffff;
  --c-accent: #ff2e7e;
  --c-accent-fg: #ffffff;
  --c-danger: #ef4444;
  --c-danger-fg: #ffffff;
}
@theme inline {
  --color-surface: var(--c-surface);
  --color-elevated: var(--c-elevated);
  --color-border: var(--c-border);
  --color-fg: var(--c-fg);
  --color-muted: var(--c-muted);
  --color-brand: var(--c-brand);
  --color-brand-fg: var(--c-brand-fg);
  --color-accent: var(--c-accent);
  --color-accent-fg: var(--c-accent-fg);
  --color-danger: var(--c-danger);
  --color-danger-fg: var(--c-danger-fg);
}
```

- [ ] **Step 2: Verify build resolves the classes.** Run: `npx tsc --noEmit` (Expected: exit 0 — CSS is not type-checked, this just confirms nothing else broke) and a dev smoke: create a throwaway element `className="bg-surface text-fg"` in a scratch render, confirm no Tailwind "unknown utility" error in `npm run dev` output. Remove the scratch element.

- [ ] **Step 3: Commit.**
```bash
git add src/app/globals.css
git commit -m "feat(ui): add app-wide semantic token layer (T9 foundation)"
```

### Task 0.2: Button primitive (test-first)

**Files:**
- Create: `src/components/ui/Button.tsx`
- Test: `src/components/ui/__tests__/Button.test.tsx`

- [ ] **Step 1: Write the failing test:**

```tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '../Button';

describe('<Button>', () => {
  it('renders a native button with the label and fires onClick', async () => {
    const onClick = jest.fn();
    render(<Button onClick={onClick}>Save</Button>);
    await userEvent.click(screen.getByRole('button', { name: 'Save' }));
    expect(onClick).toHaveBeenCalledTimes(1);
  });

  it('does not fire onClick when disabled', async () => {
    const onClick = jest.fn();
    render(<Button onClick={onClick} disabled>Save</Button>);
    await userEvent.click(screen.getByRole('button', { name: 'Save' }));
    expect(onClick).not.toHaveBeenCalled();
  });

  it('exposes an accessible name from aria-label for icon-only buttons', () => {
    render(<Button aria-label="Delete" />);
    expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
  });
});
```

- [ ] **Step 2: Run to verify it fails.** Run: `npx jest --selectProjects dom Button` — Expected: FAIL "Cannot find module '../Button'".

- [ ] **Step 3: Minimal implementation:**

```tsx
import { forwardRef, type ButtonHTMLAttributes } from 'react';

type Variant = 'primary' | 'accent' | 'outline' | 'ghost' | 'danger';
type Size = 'sm' | 'md' | 'lg';

const VARIANT: Record<Variant, string> = {
  primary: 'bg-brand text-brand-fg hover:opacity-90',
  accent: 'bg-accent text-accent-fg hover:opacity-90',
  outline: 'border border-border text-fg hover:bg-elevated',
  ghost: 'text-fg hover:bg-elevated',
  danger: 'bg-danger text-danger-fg hover:opacity-90',
};
const SIZE: Record<Size, string> = {
  sm: 'h-8 px-3 text-sm rounded-md',
  md: 'h-10 px-4 text-sm rounded-lg',
  lg: 'h-11 px-5 text-base rounded-lg',
};

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: Variant;
  size?: Size;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  { variant = 'primary', size = 'md', className = '', type = 'button', ...rest },
  ref,
) {
  return (
    <button
      ref={ref}
      type={type}
      className={`inline-flex items-center justify-center gap-2 font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-40 disabled:pointer-events-none ${VARIANT[variant]} ${SIZE[size]} ${className}`}
      {...rest}
    />
  );
});
```

- [ ] **Step 4: Run to verify it passes.** Run: `npx jest --selectProjects dom Button` — Expected: PASS (3 tests).

- [ ] **Step 5: Commit.**
```bash
git add src/components/ui/Button.tsx src/components/ui/__tests__/Button.test.tsx
git commit -m "feat(ui): token-backed accessible Button primitive (test-first)"
```

### Task 0.3: Adopt Button in ChatMessage (prove it's used, not aspirational)

**Files:**
- Modify: `src/components/chat/ChatMessage.tsx` (the feedback thumbs-up/down controls)

- [ ] **Step 1: Update the ChatMessage a11y test** (or add one) asserting the feedback buttons still expose `aria-label="Thumbs up"`/`"Thumbs down"` AND are rendered via the `Button` primitive (query by role+name; behavior identical).

- [ ] **Step 2: Run — expect FAIL** if asserting the import/usage before changing the component.

- [ ] **Step 3: Replace the two native `<button>`s** with `<Button variant="ghost" size="sm" aria-label="Thumbs up">…</Button>` etc. Keep the exact `aria-label`s the existing `auditor-backup-code-race`/chat tests rely on.

- [ ] **Step 4: Run `npx jest --selectProjects dom ChatMessage`** — Expected: PASS.

- [ ] **Step 5: Commit.**
```bash
git add src/components/chat/ChatMessage.tsx src/components/chat/__tests__/ChatMessage.test.tsx
git commit -m "refactor(ui): adopt Button primitive in ChatMessage feedback controls"
```

### Task 0.4: Retire the gone-smoke, add the adoption smoke

**Files:**
- Delete: `scripts/smoke/auditor-button-primitive-gone.ts`
- Create: `scripts/smoke/ui-primitive-adoption.ts`
- Modify: `scripts/smoke/run-all.ts`

- [ ] **Step 1: Write `ui-primitive-adoption.ts`** — asserts (a) `src/components/ui/Button.tsx` exists and is exported from `src/components/ui/index.ts`; (b) it is imported by ≥ 1 file (ratchet seed); (c) records the current raw-`<button>` count and **fails if it exceeds a stored ceiling** (a ratchet: the ceiling drops each migration phase). Pattern mirrors `t6-schema-drift-boot-guard.ts` (static file reads + `ok`/`fail`).

```ts
import './_lib';
import { ok, fail } from './_lib';
import { existsSync, readFileSync, readdirSync } from 'fs';
import path from 'path';

const ROOT = path.resolve(__dirname, '../..');
// Ratchet ceiling — LOWER this with each migration phase; never raise it.
const RAW_BUTTON_CEILING = 247;

function countRawButtons(dir: string): number {
  let n = 0;
  for (const e of readdirSync(dir, { withFileTypes: true })) {
    const p = path.join(dir, e.name);
    if (e.isDirectory()) n += countRawButtons(p);
    else if (e.name.endsWith('.tsx')) {
      n += (readFileSync(p, 'utf8').match(/<button[\s>]/g) ?? []).length;
    }
  }
  return n;
}

function main(): void {
  if (!existsSync(path.join(ROOT, 'src/components/ui/Button.tsx'))) {
    fail('ui-primitive-adoption: Button exists', 'src/components/ui/Button.tsx missing');
  }
  const barrel = readFileSync(path.join(ROOT, 'src/components/ui/index.ts'), 'utf8');
  if (!/export\s+\{[^}]*\bButton\b/.test(barrel)) {
    fail('ui-primitive-adoption: Button exported from barrel', 'index.ts does not export Button');
  }
  ok('ui-primitive-adoption: Button primitive present and exported');

  const raw = countRawButtons(path.join(ROOT, 'src/components'));
  if (raw > RAW_BUTTON_CEILING) {
    fail('ui-primitive-adoption: raw <button> ratchet', `found ${raw} raw <button>, ceiling ${RAW_BUTTON_CEILING}`);
  }
  ok('ui-primitive-adoption', `raw <button> count ${raw} <= ceiling ${RAW_BUTTON_CEILING}`);
}
main();
```

- [ ] **Step 2: Edit `run-all.ts`** — remove the `'auditor-button-primitive-gone.ts',` line; add `'ui-primitive-adoption.ts',` in its place (same commit).

- [ ] **Step 3: Run `npx tsx scripts/smoke/run-all.ts`** — Expected: all pass, count == 108.

- [ ] **Step 4: Commit.**
```bash
git add scripts/smoke/ui-primitive-adoption.ts scripts/smoke/run-all.ts
git rm scripts/smoke/auditor-button-primitive-gone.ts
git commit -m "chore(smoke): replace button-gone guard with adoption ratchet (T9 reversal, justified)"
```

**PR 1 boundary.** Open PR, verify `tsc` + `jest` + smoke green, merge, sync `claude/work`.

---

## Phase 1 — Input, Card, Dialog primitives (PR 2)

Each follows the exact Task 0.2 shape (test-first; native element; token classes; `forwardRef`; `focus-visible:ring-accent`). Concrete APIs:

- **Input** — `<input>` wrapper: props `{ label?, hint?, error? }`, `h-10 rounded-lg border border-border bg-surface px-3 text-fg`, associates `<label htmlFor>` + `aria-describedby` for hint/error (a11y: fixes the unassociated-label debt the report flagged).
- **Card** — `<div>`/`<button>` wrapper: `rounded-lg border border-border bg-surface p-4`; if `onPress` provided, render a real `<button>` (keyboard-operable — fixes the "clickable card" a11y debt) not a `<div onClick>`.
- **Dialog** — extract the focus-trap/Escape/restore logic already written for `ConfirmDialog` (T4) into a reusable `Dialog` shell; refactor `ConfirmDialog` to compose it. (Reuse, not reinvention — see T4's `ConfirmDialog.tsx`.)

Each primitive: test-first, adopt in ≥1 real component in the same PR, then update the adoption smoke's ratchet if buttons were removed. Barrel-export from `src/components/ui/index.ts`.

**PR 2 boundary.** tsc + jest + smoke green, merge, sync.

---

## Phase 2…N — Migration batches (one PR per feature area)

**The mechanical pattern** (apply per file):

```
- import { Button } from '@/components/ui';
- <button type="button" onClick={x} className="…bespoke slate/indigo…">Label</button>
+ <Button variant={…} size={…} onClick={x}>Label</Button>   // pick variant by intent:
    primary action → primary · cancel/secondary → outline/ghost · destructive → danger
```
Preserve every existing `onClick`, `disabled`, `type`, and `aria-*`. Where a raw button had only an icon, ensure `aria-label` is set (a11y win).

**Batch inventory** (each its own PR; after each, LOWER `RAW_BUTTON_CEILING` in `ui-primitive-adoption.ts` to the new count, so the ratchet can't regress):

- [ ] Batch A: `src/components/dashboard/**`
- [ ] Batch B: `src/components/monitors/**` + `src/components/maintenance/**`
- [ ] Batch C: `src/components/incidents/**` + `src/components/status-page/**`
- [ ] Batch D: `src/components/settings/**`
- [ ] Batch E: `src/components/profile/**` + `src/components/audit/**`
- [ ] Batch F: remaining `src/components/**` + `src/app/**` route-level buttons

**Per-batch checklist:** migrate → `npx tsc --noEmit` (exit 0) → `npx jest` (all pass) → lower the ratchet ceiling → `npx tsx scripts/smoke/run-all.ts` (pass) → commit → PR → merge → sync.

**Done when:** the ratchet ceiling reaches the irreducible floor (raw `<button>`s that legitimately remain — e.g. inside the `Button` primitive itself and any third-party render props), and Input/Card/Dialog are the default across the app.

---

## Self-Review

- **Spec coverage:** token layer (root-cause fix) ✓; Button/Input/Card/Dialog ✓; adoption-not-aspiration (Task 0.3 + per-PR adoption) ✓; smoke reversal handled explicitly (Task 0.4) ✓; 247/66 migration as ratcheted batches ✓; a11y debt (labels, keyboard cards, dialog focus) folded into Input/Card/Dialog ✓.
- **Placeholder scan:** none — primitives have full code; migration is a documented mechanical pattern + explicit file batches + ratchet.
- **Type consistency:** `Button`/`ButtonProps`, `variant`/`size` names consistent across Task 0.2, 0.4 smoke, and migration pattern. Token class names (`bg-brand`, `text-fg`, …) match the `@theme` keys in Task 0.1.
- **Risk:** the only behavior-affecting change is reversing the gone-smoke; mitigated by replacing it with a stricter *adoption ratchet* so the codebase can't slide back to an unused primitive.
