` 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()
+ expect(screen.getByText('VIEWER')).toBeInTheDocument()
+ })
+
+ it('applies inline hash color when color is auto', () => {
+ const { container } = render()
+ const badge = container.firstChild as HTMLElement
+ expect(badge.style.backgroundColor).toBeTruthy()
+ })
+
+ it('applies semantic color class when specified', () => {
+ const { container } = render()
+ expect(container.firstChild).toHaveClass('error')
+ })
+
+ it('applies dashed variant class', () => {
+ const { container } = render()
+ 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 (
+
+ {label}
+ {onRemove && (
+
+ )}
+
+ )
+}
+```
+
+```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 (
+
+ {getInitials(name)}
+
+ )
+}
+```
+
+```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 (
+
+ {label}
+ {onRemove && (
+
+ )}
+
+ )
+}
+```
+
+```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 (
+
+ )
+}
+```
+
+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()
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ expect(container.querySelector('polyline')).toBeInTheDocument()
+ })
+
+ it('returns null for less than 2 data points', () => {
+ const { container } = render()
+ 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()
+ expect(screen.getByText(/"key"/)).toBeInTheDocument()
+ })
+
+ it('pretty-prints JSON when language is json', () => {
+ render()
+ expect(screen.getByText(/"a":/)).toBeInTheDocument()
+ })
+
+ it('shows copy button when copyable', () => {
+ render()
+ expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument()
+ })
+})
+```
+
+- [ ] **Step 4: Implement CodeBlock** — `` 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(Content)
+ expect(screen.getByText('Details')).toBeInTheDocument()
+ })
+
+ it('hides content by default', () => {
+ render(Hidden content)
+ expect(screen.queryByText('Hidden content')).not.toBeVisible()
+ })
+
+ it('shows content when defaultOpen', () => {
+ render(Visible content)
+ expect(screen.getByText('Visible content')).toBeVisible()
+ })
+
+ it('toggles content on click', async () => {
+ const user = userEvent.setup()
+ render(Content)
+ 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(Content)
+ 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 `` 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(
+ {}}
+ />,
+ )
+ const inputs = container.querySelectorAll('input[type="datetime-local"]')
+ expect(inputs.length).toBe(2)
+ })
+
+ it('renders preset buttons', () => {
+ render(
+ {}}
+ />,
+ )
+ 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(
+ ,
+ )
+ 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( {}} />)
+ expect(screen.getByText('All')).toBeInTheDocument()
+ expect(screen.getByText('Executions')).toBeInTheDocument()
+ })
+
+ it('shows count badges', () => {
+ render( {}} />)
+ expect(screen.getByText('14')).toBeInTheDocument()
+ })
+
+ it('calls onChange with tab value', async () => {
+ const onChange = vi.fn()
+ const user = userEvent.setup()
+ render()
+ 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(Actions} items={items} />)
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument()
+ })
+
+ it('shows menu on trigger click', async () => {
+ const user = userEvent.setup()
+ render(Actions} items={items} />)
+ await user.click(screen.getByText('Actions'))
+ expect(screen.getByText('Edit')).toBeInTheDocument()
+ })
+
+ it('closes on Esc', async () => {
+ const user = userEvent.setup()
+ render(Actions} 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(Actions} 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 `` 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( {}}>Content)
+ expect(screen.getByText('Content')).toBeInTheDocument()
+ })
+
+ it('does not render when closed', () => {
+ render( {}}>Content)
+ expect(screen.queryByText('Content')).not.toBeInTheDocument()
+ })
+
+ it('renders title when provided', () => {
+ render( {}} title="My Modal">Content)
+ expect(screen.getByText('My Modal')).toBeInTheDocument()
+ })
+
+ it('calls onClose on Esc', async () => {
+ const onClose = vi.fn()
+ const user = userEvent.setup()
+ render(Content)
+ await user.keyboard('{Escape}')
+ expect(onClose).toHaveBeenCalled()
+ })
+
+ it('calls onClose on backdrop click', async () => {
+ const onClose = vi.fn()
+ const user = userEvent.setup()
+ render(Content)
+ 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 {
+ key: string
+ header: string
+ width?: string
+ sortable?: boolean
+ render?: (value: unknown, row: T) => ReactNode
+}
+
+export interface DataTableProps {
+ columns: Column[]
+ 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()
+ expect(screen.getByText('Name')).toBeInTheDocument()
+ expect(screen.getByText('Status')).toBeInTheDocument()
+ })
+
+ it('renders all data rows', () => {
+ render()
+ 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()
+ await user.click(screen.getByText('Route B'))
+ expect(onRowClick).toHaveBeenCalledWith(data[1])
+ })
+
+ it('highlights selected row', () => {
+ const { container } = render(
+ ,
+ )
+ 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()
+ 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** — `` 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 `