Files
design-system/docs/superpowers/plans/2026-03-18-design-system.md

2614 lines
72 KiB
Markdown
Raw Permalink Normal View History

# Cameleer3 Design System Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a standalone React design system with ~35 components, theming, and 5 page layouts for the Cameleer3 operations dashboard.
**Architecture:** Three-tier component hierarchy (primitives → composites → layout) built on CSS Modules + CSS custom properties. All design tokens extracted from existing HTML mockups. Pages compose components with static mock data — no server needed.
**Tech Stack:** Vite, React 18, TypeScript, React Router v6, CSS Modules, Vitest + React Testing Library
**Spec:** `docs/superpowers/specs/2026-03-18-design-system-design.md`
**Mock reference:** `ui-mocks/mock-v2-light.html` (primary), `ui-mocks/mock-v3-*.html` (secondary pages)
---
## Phase 1: Scaffold & Foundations
### Task 1: Project Scaffold
**Files:**
- Create: `package.json`
- Create: `vite.config.ts`
- Create: `tsconfig.json`
- Create: `tsconfig.node.json`
- Create: `index.html`
- Create: `src/vite-env.d.ts`
- [ ] **Step 0: Ensure git is initialized**
The repo should already be initialized. If not:
```bash
cd C:/Users/Hendrik/Documents/projects/design-system
git init
echo "* text=auto eol=lf" > .gitattributes
```
- [ ] **Step 1: Initialize Vite project**
```bash
cd C:/Users/Hendrik/Documents/projects/design-system
npm create vite@latest . -- --template react-ts
```
Select "Ignore files and continue" if prompted (we have existing files).
- [ ] **Step 2: Install dependencies**
```bash
npm install react-router-dom
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event happy-dom
```
- [ ] **Step 3: Configure Vitest in vite.config.ts**
```ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
css: {
modules: {
localsConvention: 'camelCase',
},
},
test: {
globals: true,
environment: 'happy-dom',
setupFiles: ['./src/test-setup.ts'],
css: { modules: { classNameStrategy: 'non-scoped' } },
},
})
```
- [ ] **Step 4: Create test setup file**
Create `src/test-setup.ts`:
```ts
import '@testing-library/jest-dom/vitest'
```
- [ ] **Step 5: Update tsconfig.json**
Ensure `compilerOptions` includes:
```json
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}
```
- [ ] **Step 6: Update index.html**
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1920">
<title>Cameleer3</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
- [ ] **Step 7: Verify build**
```bash
npm run dev
```
Expected: Vite dev server starts on localhost, page loads.
- [ ] **Step 8: Commit**
```bash
git add -A
git commit -m "scaffold: Vite + React + TypeScript + Vitest project setup"
```
---
### Task 2: Design Tokens & Reset CSS
**Files:**
- Create: `src/design-system/tokens.css`
- Create: `src/design-system/reset.css`
- Create: `src/index.css`
- [ ] **Step 1: Create tokens.css with all light theme values**
Create `src/design-system/tokens.css`:
```css
:root {
/* Surface palette (warm parchment) */
--bg-body: #F5F2ED;
--bg-surface: #FFFFFF;
--bg-raised: #FAF8F5;
--bg-inset: #F0EDE8;
--bg-hover: #F5F0EA;
/* Sidebar (warm charcoal) */
--sidebar-bg: #2C2520;
--sidebar-hover: #3A322C;
--sidebar-active: #4A3F38;
--sidebar-text: #BFB5A8;
--sidebar-muted: #7A6F63;
/* Text */
--text-primary: #1A1612;
--text-secondary: #5C5347;
--text-muted: #9C9184;
--text-faint: #C4BAB0;
/* Borders */
--border: #E4DFD8;
--border-subtle: #EDE9E3;
/* Brand accent (amber-gold) */
--amber: #C6820E;
--amber-light: #F0D9A8;
--amber-bg: #FDF6E9;
--amber-deep: #8B5A06;
/* Status colors (warm) */
--success: #3D7C47;
--success-bg: #EFF7F0;
--success-border: #C2DFC6;
--warning: #C27516;
--warning-bg: #FEF5E7;
--warning-border: #F0D9A8;
--error: #C0392B;
--error-bg: #FDF0EE;
--error-border: #F0C4BE;
--running: #1A7F8E;
--running-bg: #E8F5F7;
--running-border: #B0DDE4;
/* Typography */
--font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* Spacing & Radii */
--radius-sm: 5px;
--radius-md: 8px;
--radius-lg: 12px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(44, 37, 32, 0.06);
--shadow-md: 0 2px 8px rgba(44, 37, 32, 0.08);
--shadow-lg: 0 4px 16px rgba(44, 37, 32, 0.10);
--shadow-card: 0 1px 3px rgba(44, 37, 32, 0.04), 0 0 0 1px rgba(44, 37, 32, 0.04);
/* Chart palette */
--chart-1: #C6820E; /* amber */
--chart-2: #3D7C47; /* olive green */
--chart-3: #1A7F8E; /* teal */
--chart-4: #C27516; /* burnt orange */
--chart-5: #8B5A06; /* deep amber */
--chart-6: #6B8E4E; /* sage */
--chart-7: #C0392B; /* terracotta */
--chart-8: #9C7A3C; /* gold */
}
/* Dark theme — muted redesign, same token names */
[data-theme="dark"] {
--bg-body: #1A1714;
--bg-surface: #242019;
--bg-raised: #2A2620;
--bg-inset: #151310;
--bg-hover: #302B24;
--sidebar-bg: #141210;
--sidebar-hover: #1E1B17;
--sidebar-active: #28241E;
--sidebar-text: #A89E92;
--sidebar-muted: #6A6058;
--text-primary: #E8E0D6;
--text-secondary: #B0A698;
--text-muted: #7A7068;
--text-faint: #4A4238;
--border: #3A3530;
--border-subtle: #2E2A25;
--amber: #D4941E;
--amber-light: #4A3A1E;
--amber-bg: #2A2418;
--amber-deep: #E8B04A;
--success: #5DB866;
--success-bg: #1A2A1C;
--success-border: #2A3E2C;
--warning: #D4901E;
--warning-bg: #2A2418;
--warning-border: #3E3420;
--error: #E05A4C;
--error-bg: #2A1A18;
--error-border: #4A2A24;
--running: #2AA0B0;
--running-bg: #1A2628;
--running-border: #243A3E;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(255, 255, 255, 0.04);
--chart-1: #D4941E;
--chart-2: #5DB866;
--chart-3: #2AA0B0;
--chart-4: #D4901E;
--chart-5: #E8B04A;
--chart-6: #7AAE5E;
--chart-7: #E05A4C;
--chart-8: #B89A4C;
}
```
- [ ] **Step 2: Create reset.css**
Create `src/design-system/reset.css`:
```css
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 14px; }
body {
font-family: var(--font-body);
background: var(--bg-body);
color: var(--text-primary);
line-height: 1.5;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-faint); }
/* Shared animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(61, 124, 71, 0.35); }
50% { box-shadow: 0 0 0 5px rgba(61, 124, 71, 0); }
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
```
- [ ] **Step 3: Create index.css entrypoint**
Create `src/index.css`:
```css
@import './design-system/tokens.css';
@import './design-system/reset.css';
```
- [ ] **Step 4: Wire up main.tsx**
Create `src/main.tsx`:
```tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider } from './design-system/providers/ThemeProvider'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<ThemeProvider>
<App />
</ThemeProvider>
</BrowserRouter>
</StrictMode>,
)
```
- [ ] **Step 5: Create placeholder App.tsx**
Create `src/App.tsx`:
```tsx
export default function App() {
return <div>Cameleer3 Design System</div>
}
```
- [ ] **Step 6: Commit**
```bash
git add src/design-system/tokens.css src/design-system/reset.css src/index.css src/main.tsx src/App.tsx
git commit -m "foundations: design tokens (light + dark) and CSS reset"
```
---
### Task 3: hashColor Utility
**Files:**
- Create: `src/design-system/utils/hashColor.ts`
- Create: `src/design-system/utils/hashColor.test.ts`
- [ ] **Step 1: Write failing tests**
Create `src/design-system/utils/hashColor.test.ts`:
```ts
import { describe, it, expect } from 'vitest'
import { hashColor, fnv1a } from './hashColor'
describe('fnv1a', () => {
it('returns a number for any string', () => {
expect(typeof fnv1a('test')).toBe('number')
})
it('returns consistent hash for same input', () => {
expect(fnv1a('order-service')).toBe(fnv1a('order-service'))
})
it('returns different hashes for different inputs', () => {
expect(fnv1a('order-service')).not.toBe(fnv1a('payment-service'))
})
})
describe('hashColor', () => {
it('returns bg, text, and border properties', () => {
const result = hashColor('test')
expect(result).toHaveProperty('bg')
expect(result).toHaveProperty('text')
expect(result).toHaveProperty('border')
})
it('returns HSL color strings', () => {
const result = hashColor('order-service')
expect(result.bg).toMatch(/^hsl\(\d+,\s*\d+%,\s*\d+%\)$/)
expect(result.text).toMatch(/^hsl\(\d+,\s*\d+%,\s*\d+%\)$/)
expect(result.border).toMatch(/^hsl\(\d+,\s*\d+%,\s*\d+%\)$/)
})
it('returns consistent colors for same name', () => {
const a = hashColor('VIEWER')
const b = hashColor('VIEWER')
expect(a).toEqual(b)
})
it('returns different hues for different names', () => {
const a = hashColor('VIEWER')
const b = hashColor('OPERATOR')
expect(a.bg).not.toBe(b.bg)
})
it('accepts dark mode parameter', () => {
const light = hashColor('test', 'light')
const dark = hashColor('test', 'dark')
expect(light.bg).not.toBe(dark.bg)
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
npx vitest run src/design-system/utils/hashColor.test.ts
```
Expected: FAIL — module not found.
- [ ] **Step 3: Implement hashColor**
Create `src/design-system/utils/hashColor.ts`:
```ts
const FNV_OFFSET = 2166136261
const FNV_PRIME = 16777619
export function fnv1a(str: string): number {
let hash = FNV_OFFSET
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i)
hash = Math.imul(hash, FNV_PRIME)
}
return hash >>> 0
}
export function hashColor(
name: string,
theme: 'light' | 'dark' = 'light',
): { bg: string; text: string; border: string } {
const hue = fnv1a(name) % 360
if (theme === 'dark') {
return {
bg: `hsl(${hue}, 35%, 20%)`,
text: `hsl(${hue}, 45%, 75%)`,
border: `hsl(${hue}, 30%, 30%)`,
}
}
return {
bg: `hsl(${hue}, 45%, 92%)`,
text: `hsl(${hue}, 55%, 35%)`,
border: `hsl(${hue}, 35%, 82%)`,
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
npx vitest run src/design-system/utils/hashColor.test.ts
```
Expected: All 7 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add src/design-system/utils/
git commit -m "feat: hashColor utility with FNV-1a for deterministic badge/avatar colors"
```
---
### Task 4: ThemeProvider
**Files:**
- Create: `src/design-system/providers/ThemeProvider.tsx`
- Create: `src/design-system/providers/ThemeProvider.test.tsx`
- [ ] **Step 1: Write failing test**
Create `src/design-system/providers/ThemeProvider.test.tsx`:
```tsx
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider, useTheme } from './ThemeProvider'
function TestConsumer() {
const { theme, toggleTheme } = useTheme()
return (
<div>
<span data-testid="theme">{theme}</span>
<button onClick={toggleTheme}>Toggle</button>
</div>
)
}
describe('ThemeProvider', () => {
beforeEach(() => {
localStorage.clear()
document.documentElement.removeAttribute('data-theme')
})
it('defaults to light theme', () => {
render(
<ThemeProvider>
<TestConsumer />
</ThemeProvider>,
)
expect(screen.getByTestId('theme').textContent).toBe('light')
})
it('sets data-theme attribute on document', () => {
render(
<ThemeProvider>
<TestConsumer />
</ThemeProvider>,
)
expect(document.documentElement.dataset.theme).toBe('light')
})
it('toggles theme on button click', async () => {
const user = userEvent.setup()
render(
<ThemeProvider>
<TestConsumer />
</ThemeProvider>,
)
await user.click(screen.getByText('Toggle'))
expect(screen.getByTestId('theme').textContent).toBe('dark')
expect(document.documentElement.dataset.theme).toBe('dark')
})
it('persists theme to localStorage', async () => {
const user = userEvent.setup()
render(
<ThemeProvider>
<TestConsumer />
</ThemeProvider>,
)
await user.click(screen.getByText('Toggle'))
expect(localStorage.getItem('cameleer-theme')).toBe('dark')
})
it('reads initial theme from localStorage', () => {
localStorage.setItem('cameleer-theme', 'dark')
render(
<ThemeProvider>
<TestConsumer />
</ThemeProvider>,
)
expect(screen.getByTestId('theme').textContent).toBe('dark')
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
npx vitest run src/design-system/providers/ThemeProvider.test.tsx
```
- [ ] **Step 3: Implement ThemeProvider**
Create `src/design-system/providers/ThemeProvider.tsx`:
```tsx
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextValue {
theme: Theme
toggleTheme: () => void
setTheme: (theme: Theme) => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
const STORAGE_KEY = 'cameleer-theme'
function getInitialTheme(): Theme {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'dark' || stored === 'light') return stored
} catch {
// localStorage unavailable
}
return 'light'
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(getInitialTheme)
const setTheme = useCallback((t: Theme) => {
setThemeState(t)
document.documentElement.dataset.theme = t
try {
localStorage.setItem(STORAGE_KEY, t)
} catch {
// localStorage unavailable
}
}, [])
const toggleTheme = useCallback(() => {
setTheme(theme === 'light' ? 'dark' : 'light')
}, [theme, setTheme])
useEffect(() => {
document.documentElement.dataset.theme = theme
}, [theme])
return (
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
npx vitest run src/design-system/providers/ThemeProvider.test.tsx
```
Expected: All 5 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add src/design-system/providers/
git commit -m "feat: ThemeProvider with light/dark toggle and localStorage persistence"
```
---
## Phase 2: Primitives
Each primitive follows this pattern: `ComponentName.tsx` + `ComponentName.module.css` in `src/design-system/primitives/ComponentName/`. Each component gets a basic render test. Complex components get additional behavior tests.
**Testing approach for primitives:** Each component gets at minimum:
- Renders without error
- Key props produce expected output (class names, text content)
- Interactive components: click/change handlers fire
### Task 5: StatusDot + Spinner
**Files:**
- Create: `src/design-system/primitives/StatusDot/StatusDot.tsx`
- Create: `src/design-system/primitives/StatusDot/StatusDot.module.css`
- Create: `src/design-system/primitives/StatusDot/StatusDot.test.tsx`
- Create: `src/design-system/primitives/Spinner/Spinner.tsx`
- Create: `src/design-system/primitives/Spinner/Spinner.module.css`
- [ ] **Step 1: Write StatusDot test**
Create `src/design-system/primitives/StatusDot/StatusDot.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { StatusDot } from './StatusDot'
describe('StatusDot', () => {
it('renders a dot element', () => {
const { container } = render(<StatusDot variant="success" />)
expect(container.firstChild).toBeInTheDocument()
})
it('applies variant class', () => {
const { container } = render(<StatusDot variant="error" />)
expect(container.firstChild).toHaveClass('error')
})
it('applies pulse class for live variant by default', () => {
const { container } = render(<StatusDot variant="live" />)
expect(container.firstChild).toHaveClass('pulse')
})
it('disables pulse when pulse=false', () => {
const { container } = render(<StatusDot variant="live" pulse={false} />)
expect(container.firstChild).not.toHaveClass('pulse')
})
})
```
- [ ] **Step 2: Implement StatusDot**
Create `src/design-system/primitives/StatusDot/StatusDot.tsx`:
```tsx
import styles from './StatusDot.module.css'
type StatusDotVariant = 'live' | 'stale' | 'dead' | 'success' | 'warning' | 'error' | 'running'
interface StatusDotProps {
variant: StatusDotVariant
pulse?: boolean
className?: string
}
export function StatusDot({ variant, pulse, className }: StatusDotProps) {
const showPulse = pulse ?? variant === 'live'
const classes = [
styles.dot,
styles[variant],
showPulse ? styles.pulse : '',
className ?? '',
].filter(Boolean).join(' ')
return <span className={classes} aria-hidden="true" />
}
```
Create `src/design-system/primitives/StatusDot/StatusDot.module.css`:
```css
.dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.live, .success { background: var(--success); }
.stale, .warning { background: var(--warning); }
.dead { background: var(--text-muted); }
.error { background: var(--error); }
.running { background: var(--running); }
.pulse { animation: pulse 2s ease-in-out infinite; }
```
- [ ] **Step 3: Implement Spinner**
Create `src/design-system/primitives/Spinner/Spinner.tsx`:
```tsx
import styles from './Spinner.module.css'
interface SpinnerProps {
size?: 'sm' | 'md' | 'lg'
className?: string
}
const sizes = { sm: 16, md: 24, lg: 32 }
export function Spinner({ size = 'md', className }: SpinnerProps) {
const px = sizes[size]
return (
<span
className={`${styles.spinner} ${className ?? ''}`}
style={{ width: px, height: px }}
role="status"
aria-label="Loading"
/>
)
}
```
Create `src/design-system/primitives/Spinner/Spinner.module.css`:
```css
.spinner {
display: inline-block;
border: 2px solid var(--border);
border-top-color: var(--amber);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
```
- [ ] **Step 4: Run tests**
```bash
npx vitest run src/design-system/primitives/StatusDot/
```
Expected: All 4 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add src/design-system/primitives/StatusDot/ src/design-system/primitives/Spinner/
git commit -m "feat: StatusDot and Spinner primitives"
```
---
### Task 6: MonoText, KeyboardHint, SectionHeader
**Files:**
- Create: `src/design-system/primitives/MonoText/MonoText.tsx`
- Create: `src/design-system/primitives/MonoText/MonoText.module.css`
- Create: `src/design-system/primitives/KeyboardHint/KeyboardHint.tsx`
- Create: `src/design-system/primitives/KeyboardHint/KeyboardHint.module.css`
- Create: `src/design-system/primitives/SectionHeader/SectionHeader.tsx`
- Create: `src/design-system/primitives/SectionHeader/SectionHeader.module.css`
These are simple display components. Follow the same pattern as StatusDot.
- [ ] **Step 1: Implement MonoText**
```tsx
// MonoText.tsx
import styles from './MonoText.module.css'
import type { ReactNode } from 'react'
interface MonoTextProps {
children: ReactNode
size?: 'xs' | 'sm' | 'md'
className?: string
}
export function MonoText({ children, size = 'md', className }: MonoTextProps) {
return <span className={`${styles.mono} ${styles[size]} ${className ?? ''}`}>{children}</span>
}
```
```css
/* MonoText.module.css */
.mono { font-family: var(--font-mono); }
.xs { font-size: 10px; }
.sm { font-size: 11px; }
.md { font-size: 13px; }
```
- [ ] **Step 2: Implement KeyboardHint**
```tsx
// KeyboardHint.tsx
import styles from './KeyboardHint.module.css'
interface KeyboardHintProps {
keys: string
className?: string
}
export function KeyboardHint({ keys, className }: KeyboardHintProps) {
return <kbd className={`${styles.kbd} ${className ?? ''}`}>{keys}</kbd>
}
```
```css
/* KeyboardHint.module.css */
.kbd {
font-family: var(--font-mono);
font-size: 10px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0 4px;
color: var(--text-secondary);
line-height: 1.6;
}
```
- [ ] **Step 3: Implement SectionHeader**
```tsx
// SectionHeader.tsx
import styles from './SectionHeader.module.css'
import type { ReactNode } from 'react'
interface SectionHeaderProps {
children: ReactNode
action?: ReactNode
className?: string
}
export function SectionHeader({ children, action, className }: SectionHeaderProps) {
return (
<div className={`${styles.header} ${className ?? ''}`}>
<span className={styles.label}>{children}</span>
{action && <span className={styles.action}>{action}</span>}
</div>
)
}
```
```css
/* SectionHeader.module.css */
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--text-muted);
}
.action {
font-size: 12px;
color: var(--text-muted);
}
```
- [ ] **Step 4: Verify build compiles**
```bash
npx tsc --noEmit
```
- [ ] **Step 5: Commit**
```bash
git add src/design-system/primitives/MonoText/ src/design-system/primitives/KeyboardHint/ src/design-system/primitives/SectionHeader/
git commit -m "feat: MonoText, KeyboardHint, SectionHeader primitives"
```
---
### Task 7: Button + Input
**Files:**
- Create: `src/design-system/primitives/Button/Button.tsx`
- Create: `src/design-system/primitives/Button/Button.module.css`
- Create: `src/design-system/primitives/Button/Button.test.tsx`
- Create: `src/design-system/primitives/Input/Input.tsx`
- Create: `src/design-system/primitives/Input/Input.module.css`
- [ ] **Step 1: Write Button test**
```tsx
// Button.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'
describe('Button', () => {
it('renders children text', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
})
it('fires onClick', async () => {
const onClick = vi.fn()
const user = userEvent.setup()
render(<Button onClick={onClick}>Click</Button>)
await user.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledOnce()
})
it('shows spinner when loading', () => {
render(<Button loading>Save</Button>)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('is disabled when loading', () => {
render(<Button loading>Save</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
})
```
- [ ] **Step 2: Implement Button**
```tsx
// Button.tsx
import styles from './Button.module.css'
import { Spinner } from '../Spinner/Spinner'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md'
loading?: boolean
children: ReactNode
}
export function Button({
variant = 'secondary',
size = 'md',
loading = false,
children,
className,
disabled,
...rest
}: ButtonProps) {
return (
<button
className={`${styles.btn} ${styles[variant]} ${styles[size]} ${className ?? ''}`}
disabled={disabled || loading}
{...rest}
>
{loading && <Spinner size="sm" />}
<span className={loading ? styles.hiddenText : ''}>{children}</span>
</button>
)
}
```
```css
/* Button.module.css */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font-family: var(--font-body);
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.sm { padding: 4px 10px; font-size: 11px; }
.md { padding: 6px 14px; font-size: 12px; }
.primary {
background: var(--amber);
color: white;
border: none;
}
.primary:hover:not(:disabled) { background: var(--amber-deep); }
.secondary {
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
}
.secondary:hover:not(:disabled) { border-color: var(--text-faint); color: var(--text-primary); }
.danger {
background: none;
border: 1px solid var(--error-border);
color: var(--error);
}
.danger:hover:not(:disabled) { background: var(--error-bg); }
.ghost {
background: none;
border: none;
color: var(--text-secondary);
}
.ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); }
.hiddenText { visibility: hidden; }
```
- [ ] **Step 3: Implement Input**
```tsx
// Input.tsx
import styles from './Input.module.css'
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
icon?: ReactNode
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ icon, className, ...rest }, ref) => {
return (
<div className={`${styles.wrap} ${className ?? ''}`}>
{icon && <span className={styles.icon}>{icon}</span>}
<input
ref={ref}
className={`${styles.input} ${icon ? styles.hasIcon : ''}`}
{...rest}
/>
</div>
)
},
)
Input.displayName = 'Input'
```
```css
/* Input.module.css */
.wrap { position: relative; }
.icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--text-faint);
font-size: 13px;
pointer-events: none;
}
.input {
width: 100%;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 12px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.input::placeholder { color: var(--text-faint); }
.input:focus { border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-bg); }
.hasIcon { padding-left: 30px; }
```
- [ ] **Step 4: Run tests**
```bash
npx vitest run src/design-system/primitives/Button/
```
Expected: All 4 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add src/design-system/primitives/Button/ src/design-system/primitives/Input/
git commit -m "feat: Button and Input primitives"
```
---
### Task 8: Select, Checkbox, Toggle
**Files:**
- Create: `src/design-system/primitives/Select/{Select.tsx,Select.module.css}`
- Create: `src/design-system/primitives/Checkbox/{Checkbox.tsx,Checkbox.module.css}`
- Create: `src/design-system/primitives/Toggle/{Toggle.tsx,Toggle.module.css}`
These are form primitives. Follow Input's styling patterns (warm focus ring, consistent border/padding).
- [ ] **Step 1: Implement Select** — styled `<select>` wrapping `options: { value: string; label: string }[]`, same focus ring as Input.
- [ ] **Step 2: Implement Checkbox** — styled `<input type="checkbox">` + `<label>`, amber checkmark, warm focus ring.
- [ ] **Step 3: Implement Toggle** — on/off switch using hidden checkbox + styled `<span>` track/thumb, amber active state.
- [ ] **Step 4: Verify build**
```bash
npx tsc --noEmit
```
- [ ] **Step 5: Commit**
```bash
git add src/design-system/primitives/Select/ src/design-system/primitives/Checkbox/ src/design-system/primitives/Toggle/
git commit -m "feat: Select, Checkbox, Toggle form primitives"
```
---
### Task 9: Badge, Avatar, Tag
**Files:**
- Create: `src/design-system/primitives/Badge/{Badge.tsx,Badge.module.css,Badge.test.tsx}`
- Create: `src/design-system/primitives/Avatar/{Avatar.tsx,Avatar.module.css,Avatar.test.tsx}`
- Create: `src/design-system/primitives/Tag/{Tag.tsx,Tag.module.css}`
These components use `hashColor`. Tests should verify color derivation.
- [ ] **Step 1: Write Badge test**
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Badge } from './Badge'
describe('Badge', () => {
it('renders label text', () => {
render(<Badge label="VIEWER" />)
expect(screen.getByText('VIEWER')).toBeInTheDocument()
})
it('applies inline hash color when color is auto', () => {
const { container } = render(<Badge label="VIEWER" color="auto" />)
const badge = container.firstChild as HTMLElement
expect(badge.style.backgroundColor).toBeTruthy()
})
it('applies semantic color class when specified', () => {
const { container } = render(<Badge label="Failed" color="error" />)
expect(container.firstChild).toHaveClass('error')
})
it('applies dashed variant class', () => {
const { container } = render(<Badge label="inherited" variant="dashed" />)
expect(container.firstChild).toHaveClass('dashed')
})
})
```
- [ ] **Step 2: Implement Badge**
```tsx
// Badge.tsx
import styles from './Badge.module.css'
import { hashColor } from '../../utils/hashColor'
import { useTheme } from '../../providers/ThemeProvider'
type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'
type BadgeVariant = 'filled' | 'outlined' | 'dashed'
interface BadgeProps {
label: string
variant?: BadgeVariant
color?: BadgeColor
onRemove?: () => void
className?: string
}
export function Badge({
label,
variant = 'filled',
color = 'auto',
onRemove,
className,
}: BadgeProps) {
const { theme } = useTheme()
const isAuto = color === 'auto'
const hashColors = isAuto ? hashColor(label, theme) : null
const inlineStyle = isAuto
? {
backgroundColor: variant === 'filled' ? hashColors!.bg : 'transparent',
color: hashColors!.text,
borderColor: hashColors!.border,
}
: undefined
const classes = [
styles.badge,
styles[variant],
!isAuto ? styles[color] : '',
className ?? '',
].filter(Boolean).join(' ')
return (
<span className={classes} style={inlineStyle}>
{label}
{onRemove && (
<button className={styles.remove} onClick={onRemove} aria-label={`Remove ${label}`}>
&times;
</button>
)}
</span>
)
}
```
```css
/* Badge.module.css */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 8px;
border-radius: 10px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
white-space: nowrap;
border: 1px solid transparent;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.filled { /* default — inline styles from hashColor or semantic class */ }
.outlined {
background: transparent !important;
}
.dashed {
background: transparent !important;
border-style: dashed;
}
.primary { background: var(--amber-bg); color: var(--amber-deep); border-color: var(--amber-light); }
.success { background: var(--success-bg); color: var(--success); border-color: var(--success-border); }
.warning { background: var(--warning-bg); color: var(--warning); border-color: var(--warning-border); }
.error { background: var(--error-bg); color: var(--error); border-color: var(--error-border); }
.running { background: var(--running-bg); color: var(--running); border-color: var(--running-border); }
.remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 12px;
line-height: 1;
opacity: 0.5;
padding: 0;
}
.remove:hover { opacity: 1; }
```
- [ ] **Step 3: Implement Avatar**
Avatar extracts initials from name, uses `hashColor` for background. Sizes: `sm` (24px), `md` (28px), `lg` (40px).
```tsx
// Avatar.tsx
import styles from './Avatar.module.css'
import { hashColor } from '../../utils/hashColor'
import { useTheme } from '../../providers/ThemeProvider'
interface AvatarProps {
name: string
size?: 'sm' | 'md' | 'lg'
className?: string
}
function getInitials(name: string): string {
const parts = name.split(/[\s-]+/).filter(Boolean)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
return name.slice(0, 2).toUpperCase()
}
export function Avatar({ name, size = 'md', className }: AvatarProps) {
const { theme } = useTheme()
const colors = hashColor(name, theme)
return (
<span
className={`${styles.avatar} ${styles[size]} ${className ?? ''}`}
style={{ backgroundColor: colors.bg, color: colors.text, borderColor: colors.border }}
aria-label={name}
>
{getInitials(name)}
</span>
)
}
```
```css
/* Avatar.module.css */
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: 600;
border: 1px solid;
flex-shrink: 0;
}
.sm { width: 24px; height: 24px; font-size: 9px; }
.md { width: 28px; height: 28px; font-size: 11px; }
.lg { width: 40px; height: 40px; font-size: 14px; }
```
- [ ] **Step 4: Implement Tag**
Tag differs from Badge: it's always removable (primary use is active filter tags and group membership). Uses `hashColor` by default for color, or explicit semantic color. Has a prominent `x` dismiss button.
```tsx
// Tag.tsx
import styles from './Tag.module.css'
import { hashColor } from '../../utils/hashColor'
import { useTheme } from '../../providers/ThemeProvider'
type TagColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'
interface TagProps {
label: string
onRemove?: () => void
color?: TagColor
className?: string
}
export function Tag({ label, onRemove, color = 'auto', className }: TagProps) {
const { theme } = useTheme()
const isAuto = color === 'auto'
const hashColors = isAuto ? hashColor(label, theme) : null
const inlineStyle = isAuto
? { backgroundColor: hashColors!.bg, color: hashColors!.text, borderColor: hashColors!.border }
: undefined
const classes = [styles.tag, !isAuto ? styles[color] : '', className ?? ''].filter(Boolean).join(' ')
return (
<span className={classes} style={inlineStyle}>
{label}
{onRemove && (
<button className={styles.remove} onClick={onRemove} aria-label={`Remove ${label}`}>
&times;
</button>
)}
</span>
)
}
```
```css
/* Tag.module.css */
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border: 1px solid;
border-radius: 20px;
font-size: 11px;
font-family: var(--font-mono);
}
.primary { background: var(--amber-bg); color: var(--amber-deep); border-color: var(--amber-light); }
.success { background: var(--success-bg); color: var(--success); border-color: var(--success-border); }
.warning { background: var(--warning-bg); color: var(--warning); border-color: var(--warning-border); }
.error { background: var(--error-bg); color: var(--error); border-color: var(--error-border); }
.running { background: var(--running-bg); color: var(--running); border-color: var(--running-border); }
.remove {
cursor: pointer;
opacity: 0.5;
font-size: 13px;
line-height: 1;
background: none;
border: none;
color: inherit;
padding: 0;
}
.remove:hover { opacity: 1; }
```
- [ ] **Step 5: Run tests**
```bash
npx vitest run src/design-system/primitives/Badge/ src/design-system/primitives/Avatar/
```
- [ ] **Step 6: Commit**
```bash
git add src/design-system/primitives/Badge/ src/design-system/primitives/Avatar/ src/design-system/primitives/Tag/
git commit -m "feat: Badge, Avatar, Tag primitives with hashColor integration"
```
---
### Task 10: Sparkline, Card, StatCard, FilterPill
**Files:**
- Create: `src/design-system/primitives/Sparkline/{Sparkline.tsx,Sparkline.test.tsx}`
- Create: `src/design-system/primitives/Card/{Card.tsx,Card.module.css}`
- Create: `src/design-system/primitives/StatCard/{StatCard.tsx,StatCard.module.css}`
- Create: `src/design-system/primitives/FilterPill/{FilterPill.tsx,FilterPill.module.css}`
- [ ] **Step 1: Implement and test Sparkline** (moved here from Task 12 because StatCard depends on it)
```tsx
// Sparkline.tsx
interface SparklineProps {
data: number[]
color?: string
width?: number
height?: number
strokeWidth?: number
className?: string
}
export function Sparkline({
data,
color = 'var(--amber)',
width = 60,
height = 20,
strokeWidth = 1.5,
className,
}: SparklineProps) {
if (data.length < 2) return null
const max = Math.max(...data)
const min = Math.min(...data)
const range = max - min || 1
const padding = 1
const points = data
.map((val, i) => {
const x = (i / (data.length - 1)) * (width - padding * 2) + padding
const y = height - padding - ((val - min) / range) * (height - padding * 2)
return `${x},${y}`
})
.join(' ')
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={className}
aria-hidden="true"
>
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
```
Test:
```tsx
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Sparkline } from './Sparkline'
describe('Sparkline', () => {
it('renders an SVG with a polyline', () => {
const { container } = render(<Sparkline data={[1, 3, 2, 5, 4]} />)
expect(container.querySelector('svg')).toBeInTheDocument()
expect(container.querySelector('polyline')).toBeInTheDocument()
})
it('returns null for less than 2 data points', () => {
const { container } = render(<Sparkline data={[1]} />)
expect(container.firstChild).toBeNull()
})
})
```
- [ ] **Step 2: Implement Card** — surface container with optional top accent stripe. Reference `ui-mocks/mock-v2-light.html` lines 527-553 for exact styles.
- [ ] **Step 3: Implement StatCard** — extends Card with: accent stripe, `.stat-label`, large `.stat-value` (mono), trend arrow, detail line. Accepts optional `sparkline` data (renders Sparkline inline). Reference mock lines 558-612.
- [ ] **Step 3: Implement FilterPill** — selectable pill with dot + count + active states. Reference mock lines 656-697.
- [ ] **Step 4: Verify build**
```bash
npx tsc --noEmit
```
- [ ] **Step 5: Commit**
```bash
git add src/design-system/primitives/Sparkline/ src/design-system/primitives/Card/ src/design-system/primitives/StatCard/ src/design-system/primitives/FilterPill/
git commit -m "feat: Sparkline, Card, StatCard, FilterPill primitives"
```
---
### Task 11: InfoCallout, EmptyState, CodeBlock
**Files:**
- Create: `src/design-system/primitives/InfoCallout/{InfoCallout.tsx,InfoCallout.module.css}`
- Create: `src/design-system/primitives/EmptyState/{EmptyState.tsx,EmptyState.module.css}`
- Create: `src/design-system/primitives/CodeBlock/{CodeBlock.tsx,CodeBlock.module.css,CodeBlock.test.tsx}`
- [ ] **Step 1: Implement InfoCallout** — block with 3px left border (amber default, variant colors), light background.
- [ ] **Step 2: Implement EmptyState** — centered icon + title + description + optional action button.
- [ ] **Step 3: Write CodeBlock test**
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { CodeBlock } from './CodeBlock'
describe('CodeBlock', () => {
it('renders content in a pre element', () => {
render(<CodeBlock content='{"key": "value"}' />)
expect(screen.getByText(/"key"/)).toBeInTheDocument()
})
it('pretty-prints JSON when language is json', () => {
render(<CodeBlock content='{"a":1}' language="json" />)
expect(screen.getByText(/"a":/)).toBeInTheDocument()
})
it('shows copy button when copyable', () => {
render(<CodeBlock content="test" copyable />)
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument()
})
})
```
- [ ] **Step 4: Implement CodeBlock**`<pre>` with mono font, `--bg-inset` background, optional line numbers gutter, optional copy button, auto-JSON-pretty-print.
- [ ] **Step 5: Run tests and commit**
```bash
npx vitest run src/design-system/primitives/CodeBlock/
git add src/design-system/primitives/InfoCallout/ src/design-system/primitives/EmptyState/ src/design-system/primitives/CodeBlock/
git commit -m "feat: InfoCallout, EmptyState, CodeBlock primitives"
```
---
### Task 12: Collapsible, Tooltip
**Files:**
- Create: `src/design-system/primitives/Collapsible/{Collapsible.tsx,Collapsible.module.css,Collapsible.test.tsx}`
- Create: `src/design-system/primitives/Tooltip/{Tooltip.tsx,Tooltip.module.css}`
Note: Sparkline was moved to Task 10 (dependency of StatCard).
- [ ] **Step 1: Write Collapsible test**
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Collapsible } from './Collapsible'
describe('Collapsible', () => {
it('renders title', () => {
render(<Collapsible title="Details">Content</Collapsible>)
expect(screen.getByText('Details')).toBeInTheDocument()
})
it('hides content by default', () => {
render(<Collapsible title="Details">Hidden content</Collapsible>)
expect(screen.queryByText('Hidden content')).not.toBeVisible()
})
it('shows content when defaultOpen', () => {
render(<Collapsible title="Details" defaultOpen>Visible content</Collapsible>)
expect(screen.getByText('Visible content')).toBeVisible()
})
it('toggles content on click', async () => {
const user = userEvent.setup()
render(<Collapsible title="Details">Content</Collapsible>)
await user.click(screen.getByText('Details'))
expect(screen.getByText('Content')).toBeVisible()
})
it('calls onToggle when toggled', async () => {
const onToggle = vi.fn()
const user = userEvent.setup()
render(<Collapsible title="Details" onToggle={onToggle}>Content</Collapsible>)
await user.click(screen.getByText('Details'))
expect(onToggle).toHaveBeenCalled()
})
})
```
- [ ] **Step 2: Implement Collapsible** — controlled (`open` prop) / uncontrolled (`defaultOpen` prop), animated height transition via `max-height` + `overflow: hidden` + CSS transition. Title acts as toggle trigger with chevron indicator.
- [ ] **Step 3: Implement Tooltip** — CSS-only hover popup positioned with `position: absolute` relative to the wrapper. Four positions: top/bottom/left/right.
- [ ] **Step 4: Run tests and commit**
```bash
npx vitest run src/design-system/primitives/Collapsible/
git add src/design-system/primitives/Collapsible/ src/design-system/primitives/Tooltip/
git commit -m "feat: Collapsible and Tooltip primitives"
```
---
### Task 13: DateTimePicker, DateRangePicker
**Files:**
- Create: `src/design-system/primitives/DateTimePicker/{DateTimePicker.tsx,DateTimePicker.module.css}`
- Create: `src/design-system/primitives/DateRangePicker/{DateRangePicker.tsx,DateRangePicker.module.css,DateRangePicker.test.tsx}`
- [ ] **Step 1: Implement DateTimePicker** — styled native `<input type="datetime-local">` wrapped in Input-like container with same focus ring styling.
- [ ] **Step 2: Write DateRangePicker test**
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DateRangePicker } from './DateRangePicker'
describe('DateRangePicker', () => {
it('renders two datetime inputs', () => {
const { container } = render(
<DateRangePicker
value={{ start: new Date(), end: new Date() }}
onChange={() => {}}
/>,
)
const inputs = container.querySelectorAll('input[type="datetime-local"]')
expect(inputs.length).toBe(2)
})
it('renders preset buttons', () => {
render(
<DateRangePicker
value={{ start: new Date(), end: new Date() }}
onChange={() => {}}
/>,
)
expect(screen.getByText('Last 1h')).toBeInTheDocument()
expect(screen.getByText('Today')).toBeInTheDocument()
})
it('calls onChange when a preset is clicked', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(
<DateRangePicker
value={{ start: new Date(), end: new Date() }}
onChange={onChange}
/>,
)
await user.click(screen.getByText('Last 1h'))
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
)
})
})
```
- [ ] **Step 3: Implement DateRangePicker** — two DateTimePicker inputs side by side with presets row above (FilterPill chips for each preset). Clicking a preset computes start/end dates and calls `onChange`.
Default presets:
```ts
const DEFAULT_PRESETS = [
{ label: 'Last 1h', value: 'last-1h' },
{ label: 'Last 6h', value: 'last-6h' },
{ label: 'Today', value: 'today' },
{ label: 'This shift', value: 'shift' },
{ label: 'Last 24h', value: 'last-24h' },
{ label: 'Last 7d', value: 'last-7d' },
{ label: 'Custom', value: 'custom' },
]
```
- [ ] **Step 4: Run tests, verify build, and commit**
```bash
npx tsc --noEmit
git add src/design-system/primitives/DateTimePicker/ src/design-system/primitives/DateRangePicker/
git commit -m "feat: DateTimePicker and DateRangePicker primitives"
```
---
### Task 14: Primitives Barrel Export
**Files:**
- Create: `src/design-system/primitives/index.ts`
- [ ] **Step 1: Create barrel export**
```ts
export { Avatar } from './Avatar/Avatar'
export { Badge } from './Badge/Badge'
export { Button } from './Button/Button'
export { Card } from './Card/Card'
export { Checkbox } from './Checkbox/Checkbox'
export { CodeBlock } from './CodeBlock/CodeBlock'
export { Collapsible } from './Collapsible/Collapsible'
export { DateTimePicker } from './DateTimePicker/DateTimePicker'
export { DateRangePicker } from './DateRangePicker/DateRangePicker'
export { EmptyState } from './EmptyState/EmptyState'
export { FilterPill } from './FilterPill/FilterPill'
export { InfoCallout } from './InfoCallout/InfoCallout'
export { Input } from './Input/Input'
export { KeyboardHint } from './KeyboardHint/KeyboardHint'
export { MonoText } from './MonoText/MonoText'
export { SectionHeader } from './SectionHeader/SectionHeader'
export { Select } from './Select/Select'
export { Sparkline } from './Sparkline/Sparkline'
export { Spinner } from './Spinner/Spinner'
export { StatCard } from './StatCard/StatCard'
export { StatusDot } from './StatusDot/StatusDot'
export { Tag } from './Tag/Tag'
export { Toggle } from './Toggle/Toggle'
export { Tooltip } from './Tooltip/Tooltip'
```
- [ ] **Step 2: Run all primitive tests**
```bash
npx vitest run src/design-system/primitives/
```
Expected: All tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/design-system/primitives/index.ts
git commit -m "feat: primitives barrel export"
```
---
## Phase 3: Composites
### Task 15: Breadcrumb + Tabs
**Files:**
- Create: `src/design-system/composites/Breadcrumb/{Breadcrumb.tsx,Breadcrumb.module.css}`
- Create: `src/design-system/composites/Tabs/{Tabs.tsx,Tabs.module.css,Tabs.test.tsx}`
- [ ] **Step 1: Implement Breadcrumb**`nav > ol > li` with `/` separators, last item bold. Reference mock lines 412-421.
- [ ] **Step 2: Write Tabs test**
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Tabs } from './Tabs'
describe('Tabs', () => {
const tabs = [
{ label: 'All', count: 14, value: 'all' },
{ label: 'Executions', count: 8, value: 'executions' },
{ label: 'Routes', count: 3, value: 'routes' },
]
it('renders all tab labels', () => {
render(<Tabs tabs={tabs} active="all" onChange={() => {}} />)
expect(screen.getByText('All')).toBeInTheDocument()
expect(screen.getByText('Executions')).toBeInTheDocument()
})
it('shows count badges', () => {
render(<Tabs tabs={tabs} active="all" onChange={() => {}} />)
expect(screen.getByText('14')).toBeInTheDocument()
})
it('calls onChange with tab value', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<Tabs tabs={tabs} active="all" onChange={onChange} />)
await user.click(screen.getByText('Routes'))
expect(onChange).toHaveBeenCalledWith('routes')
})
})
```
- [ ] **Step 3: Implement Tabs** — horizontal bar with underline active indicator, optional count badges.
- [ ] **Step 4: Run tests and commit**
```bash
npx vitest run src/design-system/composites/Tabs/
git add src/design-system/composites/Breadcrumb/ src/design-system/composites/Tabs/
git commit -m "feat: Breadcrumb and Tabs composites"
```
---
### Task 16: MenuItem + Dropdown
**Files:**
- Create: `src/design-system/composites/MenuItem/{MenuItem.tsx,MenuItem.module.css}`
- Create: `src/design-system/composites/Dropdown/{Dropdown.tsx,Dropdown.module.css,Dropdown.test.tsx}`
- [ ] **Step 1: Implement MenuItem** — sidebar nav item with StatusDot + label + meta + count. Reference mock lines 270-312 for exact CSS (left border active state, hover, indentation).
- [ ] **Step 2: Write Dropdown test**
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Dropdown } from './Dropdown'
const items = [
{ label: 'Edit', onClick: vi.fn() },
{ label: 'Delete', onClick: vi.fn() },
]
describe('Dropdown', () => {
it('does not show menu initially', () => {
render(<Dropdown trigger={<button>Actions</button>} items={items} />)
expect(screen.queryByText('Edit')).not.toBeInTheDocument()
})
it('shows menu on trigger click', async () => {
const user = userEvent.setup()
render(<Dropdown trigger={<button>Actions</button>} items={items} />)
await user.click(screen.getByText('Actions'))
expect(screen.getByText('Edit')).toBeInTheDocument()
})
it('closes on Esc', async () => {
const user = userEvent.setup()
render(<Dropdown trigger={<button>Actions</button>} items={items} />)
await user.click(screen.getByText('Actions'))
await user.keyboard('{Escape}')
expect(screen.queryByText('Edit')).not.toBeInTheDocument()
})
it('calls item onClick when clicked', async () => {
const user = userEvent.setup()
render(<Dropdown trigger={<button>Actions</button>} items={items} />)
await user.click(screen.getByText('Actions'))
await user.click(screen.getByText('Edit'))
expect(items[0].onClick).toHaveBeenCalled()
})
})
```
- [ ] **Step 3: Implement Dropdown** — trigger element + floating `<ul>` menu. Toggle on click, close on outside click or Esc. Items with optional icons and dividers.
- [ ] **Step 4: Run tests and commit**
```bash
git add src/design-system/composites/MenuItem/ src/design-system/composites/Dropdown/
git commit -m "feat: MenuItem and Dropdown composites"
```
---
### Task 17: Modal + DetailPanel
**Files:**
- Create: `src/design-system/composites/Modal/{Modal.tsx,Modal.module.css,Modal.test.tsx}`
- Create: `src/design-system/composites/DetailPanel/{DetailPanel.tsx,DetailPanel.module.css}`
- [ ] **Step 1: Write Modal test**
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Modal } from './Modal'
describe('Modal', () => {
it('renders children when open', () => {
render(<Modal open onClose={() => {}}>Content</Modal>)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('does not render when closed', () => {
render(<Modal open={false} onClose={() => {}}>Content</Modal>)
expect(screen.queryByText('Content')).not.toBeInTheDocument()
})
it('renders title when provided', () => {
render(<Modal open onClose={() => {}} title="My Modal">Content</Modal>)
expect(screen.getByText('My Modal')).toBeInTheDocument()
})
it('calls onClose on Esc', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<Modal open onClose={onClose}>Content</Modal>)
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
it('calls onClose on backdrop click', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<Modal open onClose={onClose}>Content</Modal>)
const backdrop = screen.getByTestId('modal-backdrop')
await user.click(backdrop)
expect(onClose).toHaveBeenCalled()
})
})
```
- [ ] **Step 2: Implement Modal** — overlay with backdrop (clicks close), centered Card content, close on Esc. Uses `createPortal` to render at document body. Size variants: `sm` (400px), `md` (560px), `lg` (720px). Add `data-testid="modal-backdrop"` to the backdrop element.
- [ ] **Step 3: Implement DetailPanel** — 400px right-side sliding panel. Animated `slideInRight`. Header with title + close button, Tabs for content switching, bottom action bar. Reference mock lines 1078-1179 for CSS.
- [ ] **Step 4: Run tests and commit**
```bash
npx vitest run src/design-system/composites/Modal/
git add src/design-system/composites/Modal/ src/design-system/composites/DetailPanel/
git commit -m "feat: Modal and DetailPanel composites"
```
```bash
git add src/design-system/composites/Modal/ src/design-system/composites/DetailPanel/
git commit -m "feat: Modal and DetailPanel composites"
```
---
### Task 18: FilterBar + ShortcutsBar
**Files:**
- Create: `src/design-system/composites/FilterBar/{FilterBar.tsx,FilterBar.module.css}`
- Create: `src/design-system/composites/ShortcutsBar/{ShortcutsBar.tsx,ShortcutsBar.module.css}`
- [ ] **Step 1: Implement FilterBar** — composable row of: Input (search) + separator + FilterPill group + active filter Tags below. Reference mock lines 614-773.
- [ ] **Step 2: Implement ShortcutsBar** — fixed bottom-right position, horizontal row of `KeyboardHint + label` pairs. Reference mock lines 1315-1346.
- [ ] **Step 3: Commit**
```bash
git add src/design-system/composites/FilterBar/ src/design-system/composites/ShortcutsBar/
git commit -m "feat: FilterBar and ShortcutsBar composites"
```
---
### Task 19: DataTable
**Files:**
- Create: `src/design-system/composites/DataTable/{DataTable.tsx,DataTable.module.css,DataTable.test.tsx,types.ts}`
This is one of the most complex composites. Dedicated task.
- [ ] **Step 1: Define types**
Create `src/design-system/composites/DataTable/types.ts`:
```ts
import type { ReactNode } from 'react'
export interface Column<T = unknown> {
key: string
header: string
width?: string
sortable?: boolean
render?: (value: unknown, row: T) => ReactNode
}
export interface DataTableProps<T extends { id: string }> {
columns: Column<T>[]
data: T[]
onRowClick?: (row: T) => void
selectedId?: string
sortable?: boolean
pageSize?: number
pageSizeOptions?: number[]
rowAccent?: (row: T) => 'error' | 'warning' | undefined
expandedContent?: (row: T) => ReactNode | null
}
```
- [ ] **Step 2: Write DataTable test**
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DataTable } from './DataTable'
const columns = [
{ key: 'name', header: 'Name' },
{ key: 'status', header: 'Status' },
]
const data = [
{ id: '1', name: 'Route A', status: 'ok' },
{ id: '2', name: 'Route B', status: 'error' },
{ id: '3', name: 'Route C', status: 'ok' },
]
describe('DataTable', () => {
it('renders column headers', () => {
render(<DataTable columns={columns} data={data} />)
expect(screen.getByText('Name')).toBeInTheDocument()
expect(screen.getByText('Status')).toBeInTheDocument()
})
it('renders all data rows', () => {
render(<DataTable columns={columns} data={data} />)
expect(screen.getByText('Route A')).toBeInTheDocument()
expect(screen.getByText('Route C')).toBeInTheDocument()
})
it('calls onRowClick when a row is clicked', async () => {
const onRowClick = vi.fn()
const user = userEvent.setup()
render(<DataTable columns={columns} data={data} onRowClick={onRowClick} />)
await user.click(screen.getByText('Route B'))
expect(onRowClick).toHaveBeenCalledWith(data[1])
})
it('highlights selected row', () => {
const { container } = render(
<DataTable columns={columns} data={data} selectedId="2" />,
)
const rows = container.querySelectorAll('tr')
expect(rows[2]).toHaveClass('selected') // row index 2 = data[1]
})
it('sorts when header clicked', async () => {
const user = userEvent.setup()
render(<DataTable columns={[{ key: 'name', header: 'Name', sortable: true }, ...columns.slice(1)]} data={data} sortable />)
await user.click(screen.getByText('Name'))
// After click, should show sort indicator
expect(screen.getByText('Name').closest('th')).toHaveAttribute('aria-sort')
})
})
```
- [ ] **Step 3: Implement DataTable**`<table>` with: sortable headers (click toggles asc/desc, arrow icon), 40px compact rows, row selection class, optional left accent border via `rowAccent`, optional `expandedContent` below row, pagination bar at bottom with page info + page size `<Select>` + prev/next `<Button>`.
Reference `ui-mocks/mock-v2-light.html` lines 776-1076 for table CSS.
- [ ] **Step 4: Run tests**
```bash
npx vitest run src/design-system/composites/DataTable/
```
- [ ] **Step 5: Commit**
```bash
git add src/design-system/composites/DataTable/
git commit -m "feat: DataTable composite with sorting, pagination, row selection"
```
---
### Task 20: AreaChart, LineChart, BarChart
**Files:**
- Create: `src/design-system/composites/AreaChart/{AreaChart.tsx,AreaChart.module.css}`
- Create: `src/design-system/composites/LineChart/{LineChart.tsx,LineChart.module.css}`
- Create: `src/design-system/composites/BarChart/{BarChart.tsx,BarChart.module.css}`
All charts are SVG-based, no external libraries.
- [ ] **Step 1: Create shared chart utilities**
Create `src/design-system/composites/_chart-utils.ts`:
```ts
export function computeScale(data: number[], height: number, padding: number) {
const min = Math.min(...data)
const max = Math.max(...data)
const range = max - min || 1
return {
min, max, range,
toY: (val: number) => height - padding - ((val - min) / range) * (height - 2 * padding),
}
}
export function formatAxisLabel(val: number): string {
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`
return String(val)
}
```
- [ ] **Step 2: Implement AreaChart** — SVG with: Y axis labels (left), X axis labels (bottom), grid lines (dashed, `--border-subtle`), area fill (colored with 0.1 opacity), line stroke, optional threshold line (dashed, labeled). Multi-series using `--chart-N` tokens. Hover crosshair + tooltip.
Reference `ui-mocks/mock-v3-metrics-dashboard.html` for chart layout patterns.
- [ ] **Step 3: Implement LineChart** — same as AreaChart but without area fill. Reuse chart utilities.
- [ ] **Step 4: Implement BarChart** — SVG vertical bars. Stacked or grouped mode. Same axis/grid pattern.
- [ ] **Step 5: Commit**
```bash
git add src/design-system/composites/AreaChart/ src/design-system/composites/LineChart/ src/design-system/composites/BarChart/ src/design-system/composites/_chart-utils.ts
git commit -m "feat: AreaChart, LineChart, BarChart SVG composites"
```
---
### Task 21: ProcessorTimeline + EventFeed
**Files:**
- Create: `src/design-system/composites/ProcessorTimeline/{ProcessorTimeline.tsx,ProcessorTimeline.module.css}`
- Create: `src/design-system/composites/EventFeed/{EventFeed.tsx,EventFeed.module.css}`
- [ ] **Step 1: Implement ProcessorTimeline** — Gantt-style horizontal bars. Each row: processor name label (120px), colored bar proportional to duration, duration label. Bar colors: `--success` for ok, `--warning` for slow, `--error` for fail. Clickable rows. Reference mock lines 1145-1232 (`.proc-row`, `.proc-bar-bg`, `.proc-bar-fill`).
- [ ] **Step 2: Implement EventFeed** — scrolling list inside a Card. Each event: severity icon (StatusDot), message text, relative timestamp. Auto-scroll to bottom on new events (pause on manual scroll). Optional severity filter pills at top.
- [ ] **Step 3: Commit**
```bash
git add src/design-system/composites/ProcessorTimeline/ src/design-system/composites/EventFeed/
git commit -m "feat: ProcessorTimeline and EventFeed composites"
```
---
### Task 22: CommandPalette
**Files:**
- Create: `src/design-system/composites/CommandPalette/{CommandPalette.tsx,CommandPalette.module.css,CommandPalette.test.tsx,types.ts}`
Complex composite — dedicated task.
- [ ] **Step 1: Define types**
Create `src/design-system/composites/CommandPalette/types.ts`:
```ts
import type { ReactNode } from 'react'
export type SearchCategory = 'execution' | 'route' | 'exchange' | 'agent'
export interface SearchResult {
id: string
category: SearchCategory
title: string
badges?: { label: string; color?: string }[]
meta: string
timestamp?: string
icon?: ReactNode
expandedContent?: string
matchRanges?: [number, number][]
}
export interface ScopeFilter {
field: string
value: string
}
```
- [ ] **Step 2: Write CommandPalette test**
Test: renders open/closed, keyboard navigation (arrow keys, esc closes), category tabs filter results, scoped filter tags.
- [ ] **Step 3: Implement CommandPalette**
Structure:
- Portal-rendered overlay (Modal-like backdrop)
- Search input area: search icon + ScopeFilter tags (removable) + text input + `esc close` hint
- Category Tabs below search (All, Executions, Routes, Exchanges, Agents) with counts
- Grouped result list: SectionHeader per category, result items below
- Result item: icon + title (with match highlighting) + badges + meta + timestamp
- Expandable CodeBlock for `expandedContent`
- Bottom ShortcutsBar (arrows navigate, enter open, tab filter, # scope)
- Keyboard: `ArrowUp`/`ArrowDown` navigate results, `Enter` selects, `Esc` closes, `Tab` cycles scope
- Match highlighting: wrap matched ranges in `<mark>` styled with amber
Reference the Cmd+K screenshot for exact layout.
- [ ] **Step 4: Register global Ctrl+K handler**
The CommandPalette should include a `useEffect` that listens for `Ctrl+K` / `Cmd+K` on the window and calls `onOpen`. This can be wired at the App level.
- [ ] **Step 5: Run tests and commit**
```bash
npx vitest run src/design-system/composites/CommandPalette/
git add src/design-system/composites/CommandPalette/
git commit -m "feat: CommandPalette composite with search, filtering, keyboard navigation"
```
---
### Task 23: Composites Barrel Export
**Files:**
- Create: `src/design-system/composites/index.ts`
- [ ] **Step 1: Create barrel export**
```ts
export { AreaChart } from './AreaChart/AreaChart'
export { BarChart } from './BarChart/BarChart'
export { Breadcrumb } from './Breadcrumb/Breadcrumb'
export { CommandPalette } from './CommandPalette/CommandPalette'
export { DataTable } from './DataTable/DataTable'
export { DetailPanel } from './DetailPanel/DetailPanel'
export { Dropdown } from './Dropdown/Dropdown'
export { EventFeed } from './EventFeed/EventFeed'
export { FilterBar } from './FilterBar/FilterBar'
export { LineChart } from './LineChart/LineChart'
export { MenuItem } from './MenuItem/MenuItem'
export { Modal } from './Modal/Modal'
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
export { Tabs } from './Tabs/Tabs'
```
- [ ] **Step 2: Run all composite tests**
```bash
npx vitest run src/design-system/composites/
```
- [ ] **Step 3: Commit**
```bash
git add src/design-system/composites/index.ts
git commit -m "feat: composites barrel export"
```
---
## Phase 4: Layout
### Task 24: AppShell, Sidebar, TopBar
**Files:**
- Create: `src/design-system/layout/AppShell/{AppShell.tsx,AppShell.module.css}`
- Create: `src/design-system/layout/Sidebar/{Sidebar.tsx,Sidebar.module.css}`
- Create: `src/design-system/layout/TopBar/{TopBar.tsx,TopBar.module.css}`
- Create: `src/design-system/layout/index.ts`
- [ ] **Step 1: Implement AppShell**
```tsx
// AppShell.tsx
import styles from './AppShell.module.css'
import type { ReactNode } from 'react'
interface AppShellProps {
sidebar: ReactNode
children: ReactNode
detail?: ReactNode
}
export function AppShell({ sidebar, children, detail }: AppShellProps) {
return (
<div className={styles.app}>
{sidebar}
<div className={styles.main}>
{children}
</div>
{detail}
</div>
)
}
```
```css
/* AppShell.module.css */
.app {
display: flex;
height: 100vh;
overflow: hidden;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
```
- [ ] **Step 2: Implement Sidebar** — 220px warm charcoal. Sections: logo, search, Applications section (MenuItem list), divider, Routes section (indented MenuItems), Agent health section (agent items with dot, name, version, tps, last-seen), bottom links. Reference mock lines 190-503 for exact structure and CSS.
- [ ] **Step 3: Implement TopBar** — 48px height. Breadcrumb left, search trigger center, environment badge + shift indicator + Avatar + name right. Reference mock lines 400-501.
- [ ] **Step 4: Create layout barrel export**
```ts
export { AppShell } from './AppShell/AppShell'
export { Sidebar } from './Sidebar/Sidebar'
export { TopBar } from './TopBar/TopBar'
```
- [ ] **Step 5: Verify build and commit**
```bash
npx tsc --noEmit
git add src/design-system/layout/
git commit -m "feat: AppShell, Sidebar, TopBar layout components"
```
---
## Phase 5: Mock Data + Pages
### Task 25: Mock Data
**Files:**
- Create: `src/mocks/executions.ts`
- Create: `src/mocks/routes.ts`
- Create: `src/mocks/agents.ts`
- Create: `src/mocks/metrics.ts`
- [ ] **Step 1: Create execution mock data**
Extract realistic data from `ui-mocks/mock-v2-light.html` body. Include ~15 execution rows with mixed statuses, order IDs, durations, error messages.
```ts
// executions.ts
export interface Execution {
id: string
orderId: string
customer: string
route: string
status: 'completed' | 'failed' | 'running' | 'warning'
durationMs: number
timestamp: Date
correlationId: string
errorMessage?: string
errorClass?: string
processors: {
name: string
type: string
durationMs: number
status: 'ok' | 'slow' | 'fail'
startMs: number
}[]
}
export const executions: Execution[] = [
{
id: 'E-2026-03-18-00142',
orderId: 'OP-88421',
customer: 'Acme Corp',
route: 'order-intake',
status: 'completed',
durationMs: 142,
timestamp: new Date('2026-03-18T10:32:15'),
correlationId: 'cmr-9a4f2b71-e8c3',
processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 5 },
{ name: 'validate(schema)', type: 'process', durationMs: 12, status: 'ok', startMs: 13 },
{ name: 'enrich(inventory)', type: 'enrich', durationMs: 85, status: 'slow', startMs: 25 },
{ name: 'to(payment-api)', type: 'to', durationMs: 28, status: 'ok', startMs: 110 },
],
},
// ... add ~14 more rows with varied statuses, some failed with error messages
]
```
- [ ] **Step 2: Create routes, agents, metrics mock data** — follow same pattern, extract from mockup data.
- [ ] **Step 3: Commit**
```bash
git add src/mocks/
git commit -m "feat: static mock data for executions, routes, agents, metrics"
```
---
### Task 26: Dashboard Page
**Files:**
- Create: `src/pages/Dashboard/Dashboard.tsx`
- Create: `src/pages/Dashboard/Dashboard.module.css`
- [ ] **Step 1: Implement Dashboard page**
Compose from design system components:
- `AppShell` wrapping `Sidebar` + main content + `DetailPanel`
- `TopBar` with breadcrumb "Applications / order-service"
- Health strip: 5x `StatCard` in a CSS grid (executions, success rate, errors, throughput, latency)
- `FilterBar` with status pills + search
- `DataTable` for execution list
- `DetailPanel` slides in on row click with tabs (Overview, Processors, Exchange, Error)
- `ShortcutsBar` at bottom
- CommandPalette triggered by Ctrl+K
All data sourced from `src/mocks/`.
Reference: `ui-mocks/mock-v2-light.html` for exact layout and data.
- [ ] **Step 2: Wire up in App.tsx**
```tsx
import { Routes, Route } from 'react-router-dom'
import { Dashboard } from './pages/Dashboard/Dashboard'
export default function App() {
return (
<Routes>
<Route path="/" element={<Dashboard />} />
</Routes>
)
}
```
- [ ] **Step 3: Visual verification**
```bash
npm run dev
```
Open browser, verify the dashboard matches `ui-mocks/mock-v2-light.html` layout. Check: sidebar, topbar, health strip, filter bar, table, detail panel interaction.
- [ ] **Step 4: Commit**
```bash
git add src/pages/Dashboard/ src/App.tsx
git commit -m "feat: Dashboard page composing full design system"
```
---
### Task 27: Metrics Page
**Files:**
- Create: `src/pages/Metrics/Metrics.tsx`
- Create: `src/pages/Metrics/Metrics.module.css`
- [ ] **Step 1: Implement Metrics page**
Reference: `ui-mocks/mock-v3-metrics-dashboard.html`
Compose: AppShell layout + TopBar (breadcrumb: Dashboard > Metrics) + DateRangePicker bar + 5 StatCards with sparklines + per-route DataTable with sparkline columns + 2x2 chart grid (AreaChart for throughput, LineChart for latency, BarChart for errors, AreaChart for volume) + optional EventFeed right rail.
- [ ] **Step 2: Add route to App.tsx**
```tsx
<Route path="/metrics" element={<Metrics />} />
```
- [ ] **Step 3: Commit**
```bash
git add src/pages/Metrics/ src/App.tsx
git commit -m "feat: Metrics dashboard page"
```
---
### Task 28: RouteDetail Page
**Files:**
- Create: `src/pages/RouteDetail/RouteDetail.tsx`
- Create: `src/pages/RouteDetail/RouteDetail.module.css`
- [ ] **Step 1: Implement RouteDetail page**
Reference: `ui-mocks/mock-v3-route-detail.html`
Compose: route header (name, status, uptime) + KPI strip (executions, success rate, latencies, inflight) + ProcessorTimeline aggregate view + filtered DataTable + error patterns section.
- [ ] **Step 2: Add route and commit**
```bash
git add src/pages/RouteDetail/ src/App.tsx
git commit -m "feat: RouteDetail page"
```
---
### Task 29: ExchangeDetail + AgentHealth Pages
**Files:**
- Create: `src/pages/ExchangeDetail/ExchangeDetail.tsx`
- Create: `src/pages/ExchangeDetail/ExchangeDetail.module.css`
- Create: `src/pages/AgentHealth/AgentHealth.tsx`
- Create: `src/pages/AgentHealth/AgentHealth.module.css`
- [ ] **Step 1: Implement ExchangeDetail page**
Reference: `ui-mocks/mock-v3-exchange-detail.html`
Compose: exchange header + ProcessorTimeline + step-by-step exchange inspector using CodeBlock + Collapsible for each processor step + error block.
- [ ] **Step 2: Implement AgentHealth page**
Reference: `ui-mocks/mock-v3-agent-health.html`
Compose: agent cards grid + per-agent detail panel + LineCharts for throughput/error rate trends.
- [ ] **Step 3: Add routes and commit**
```bash
git add src/pages/ExchangeDetail/ src/pages/AgentHealth/ src/App.tsx
git commit -m "feat: ExchangeDetail and AgentHealth pages"
```
---
### Task 30: Final Integration + Run All Tests
- [ ] **Step 1: Run full test suite**
```bash
npx vitest run
```
Expected: All tests pass.
- [ ] **Step 2: Run TypeScript check**
```bash
npx tsc --noEmit
```
Expected: No type errors.
- [ ] **Step 3: Build for production**
```bash
npm run build
```
Expected: Clean build, no warnings.
- [ ] **Step 4: Visual smoke test**
```bash
npm run dev
```
Navigate through all pages: `/`, `/metrics`, `/routes/order-intake`, `/exchanges/E-2026-03-18-00142`, `/agents`. Verify: sidebar navigation, theme toggle (add a temp button or use devtools to set `data-theme="dark"`), CommandPalette opens with Ctrl+K.
- [ ] **Step 5: Final commit**
```bash
git add -A
git commit -m "feat: Cameleer3 design system — complete standalone implementation"
```