2614 lines
72 KiB
Markdown
2614 lines
72 KiB
Markdown
|
|
# 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}`}>
|
||
|
|
×
|
||
|
|
</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}`}>
|
||
|
|
×
|
||
|
|
</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"
|
||
|
|
```
|