diff --git a/docs/superpowers/plans/2026-03-18-design-system.md b/docs/superpowers/plans/2026-03-18-design-system.md new file mode 100644 index 0000000..743e297 --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-design-system.md @@ -0,0 +1,2613 @@ +# 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 + + + + + + Cameleer3 + + + +
+ + + +``` + +- [ ] **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( + + + + + + + , +) +``` + +- [ ] **Step 5: Create placeholder App.tsx** + +Create `src/App.tsx`: +```tsx +export default function App() { + return
Cameleer3 Design System
+} +``` + +- [ ] **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 ( +
+ {theme} + +
+ ) +} + +describe('ThemeProvider', () => { + beforeEach(() => { + localStorage.clear() + document.documentElement.removeAttribute('data-theme') + }) + + it('defaults to light theme', () => { + render( + + + , + ) + expect(screen.getByTestId('theme').textContent).toBe('light') + }) + + it('sets data-theme attribute on document', () => { + render( + + + , + ) + expect(document.documentElement.dataset.theme).toBe('light') + }) + + it('toggles theme on button click', async () => { + const user = userEvent.setup() + render( + + + , + ) + 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( + + + , + ) + 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( + + + , + ) + 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(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(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 ( + + {children} + + ) +} + +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() + expect(container.firstChild).toBeInTheDocument() + }) + + it('applies variant class', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('error') + }) + + it('applies pulse class for live variant by default', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('pulse') + }) + + it('disables pulse when pulse=false', () => { + const { container } = render() + 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