Covers: scaffold, foundations, 23 primitives, 15 composites, 3 layout components, mock data, and 5 pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
72 KiB
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:
cd C:/Users/Hendrik/Documents/projects/design-system
git init
echo "* text=auto eol=lf" > .gitattributes
- Step 1: Initialize Vite project
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
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
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:
import '@testing-library/jest-dom/vitest'
- Step 5: Update tsconfig.json
Ensure compilerOptions includes:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}
- Step 6: Update index.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
npm run dev
Expected: Vite dev server starts on localhost, page loads.
- Step 8: Commit
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:
: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:
*, *::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:
@import './design-system/tokens.css';
@import './design-system/reset.css';
- Step 4: Wire up main.tsx
Create src/main.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:
export default function App() {
return <div>Cameleer3 Design System</div>
}
- Step 6: Commit
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:
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
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:
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
npx vitest run src/design-system/utils/hashColor.test.ts
Expected: All 7 tests PASS.
- Step 5: Commit
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:
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
npx vitest run src/design-system/providers/ThemeProvider.test.tsx
- Step 3: Implement ThemeProvider
Create src/design-system/providers/ThemeProvider.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
npx vitest run src/design-system/providers/ThemeProvider.test.tsx
Expected: All 5 tests PASS.
- Step 5: Commit
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:
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:
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:
.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:
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:
.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
npx vitest run src/design-system/primitives/StatusDot/
Expected: All 4 tests PASS.
- Step 5: Commit
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
// 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>
}
/* 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
// 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>
}
/* 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
// 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>
)
}
/* 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
npx tsc --noEmit
- Step 5: Commit
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
// 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
// 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>
)
}
/* 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
// 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'
/* 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
npx vitest run src/design-system/primitives/Button/
Expected: All 4 tests PASS.
- Step 5: Commit
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>wrappingoptions: { 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
npx tsc --noEmit
- Step 5: Commit
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
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
// 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}`}>
×
</button>
)}
</span>
)
}
/* 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).
// 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>
)
}
/* 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.
// 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}`}>
×
</button>
)}
</span>
)
}
/* 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
npx vitest run src/design-system/primitives/Badge/ src/design-system/primitives/Avatar/
- Step 6: Commit
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)
// 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:
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.htmllines 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 optionalsparklinedata (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
npx tsc --noEmit
- Step 5: Commit
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
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-insetbackground, optional line numbers gutter, optional copy button, auto-JSON-pretty-print. -
Step 5: Run tests and commit
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
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 (
openprop) / uncontrolled (defaultOpenprop), animated height transition viamax-height+overflow: hidden+ CSS transition. Title acts as toggle trigger with chevron indicator. -
Step 3: Implement Tooltip — CSS-only hover popup positioned with
position: absoluterelative to the wrapper. Four positions: top/bottom/left/right. -
Step 4: Run tests and commit
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
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:
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
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
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
npx vitest run src/design-system/primitives/
Expected: All tests pass.
- Step 3: Commit
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 > liwith/separators, last item bold. Reference mock lines 412-421. -
Step 2: Write Tabs test
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
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
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
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
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
createPortalto render at document body. Size variants:sm(400px),md(560px),lg(720px). Adddata-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
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"
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 + labelpairs. Reference mock lines 1315-1346. -
Step 3: Commit
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:
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
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 viarowAccent, optionalexpandedContentbelow 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
npx vitest run src/design-system/composites/DataTable/
- Step 5: Commit
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:
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-Ntokens. 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
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:
--successfor ok,--warningfor slow,--errorfor 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
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:
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 closehint - 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/ArrowDownnavigate results,Enterselects,Esccloses,Tabcycles 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
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
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
npx vitest run src/design-system/composites/
- Step 3: Commit
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
// 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>
)
}
/* 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
export { AppShell } from './AppShell/AppShell'
export { Sidebar } from './Sidebar/Sidebar'
export { TopBar } from './TopBar/TopBar'
- Step 5: Verify build and commit
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.
// 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
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:
AppShellwrappingSidebar+ main content +DetailPanelTopBarwith breadcrumb "Applications / order-service"- Health strip: 5x
StatCardin a CSS grid (executions, success rate, errors, throughput, latency) FilterBarwith status pills + searchDataTablefor execution listDetailPanelslides in on row click with tabs (Overview, Processors, Exchange, Error)ShortcutsBarat 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
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
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
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
<Route path="/metrics" element={<Metrics />} />
- Step 3: Commit
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
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
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
npx vitest run
Expected: All tests pass.
- Step 2: Run TypeScript check
npx tsc --noEmit
Expected: No type errors.
- Step 3: Build for production
npm run build
Expected: Clean build, no warnings.
- Step 4: Visual smoke test
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
git add -A
git commit -m "feat: Cameleer3 design system — complete standalone implementation"