docs: add 4 implementation plans for mock deviation cleanup

Plan 1: KpiStrip + StatusText + Card title (metrics)
Plan 2: SplitPane + EntityList (admin)
Plan 3: LogViewer + AgentHealth DataTable refactor (observability)
Plan 4: COMPONENT_GUIDE.md + Inventory updates (documentation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-24 12:10:00 +01:00
parent b168d7c867
commit e664e449c3
4 changed files with 2213 additions and 0 deletions

View File

@@ -0,0 +1,573 @@
# Admin Components Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add SplitPane and EntityList composites to provide reusable master/detail layout and searchable entity list patterns, replacing ~150 lines of duplicated CSS and structure across admin RBAC tabs.
**Architecture:** SplitPane is a layout-only component providing a two-column grid with configurable ratio. EntityList provides a searchable, selectable list with render props for item content. They compose together naturally: EntityList slots into SplitPane's list panel.
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 2, 2b)
---
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `src/design-system/composites/SplitPane/SplitPane.tsx` | Create | Two-column grid layout with list/detail slots and empty state |
| `src/design-system/composites/SplitPane/SplitPane.module.css` | Create | Grid layout, scrollable panels, empty state styling |
| `src/design-system/composites/SplitPane/SplitPane.test.tsx` | Create | 5 test cases for SplitPane |
| `src/design-system/composites/EntityList/EntityList.tsx` | Create | Generic searchable, selectable list with render props |
| `src/design-system/composites/EntityList/EntityList.module.css` | Create | Header, scrollable list, item hover/selected states |
| `src/design-system/composites/EntityList/EntityList.test.tsx` | Create | 11 test cases for EntityList |
| `src/design-system/composites/index.ts` | Modify | Add SplitPane and EntityList exports |
---
### Task 1: SplitPane composite
**Files:**
- Create: `src/design-system/composites/SplitPane/SplitPane.tsx`
- Create: `src/design-system/composites/SplitPane/SplitPane.module.css`
- Create: `src/design-system/composites/SplitPane/SplitPane.test.tsx`
- [ ] **Step 1: Write SplitPane tests**
Create `src/design-system/composites/SplitPane/SplitPane.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { SplitPane } from './SplitPane'
describe('SplitPane', () => {
it('renders list and detail content', () => {
render(
<SplitPane
list={<div>User list</div>}
detail={<div>User detail</div>}
/>,
)
expect(screen.getByText('User list')).toBeInTheDocument()
expect(screen.getByText('User detail')).toBeInTheDocument()
})
it('shows default empty message when detail is null', () => {
render(
<SplitPane
list={<div>User list</div>}
detail={null}
/>,
)
expect(screen.getByText('Select an item to view details')).toBeInTheDocument()
})
it('shows custom empty message when detail is null', () => {
render(
<SplitPane
list={<div>User list</div>}
detail={null}
emptyMessage="Pick a user to see info"
/>,
)
expect(screen.getByText('Pick a user to see info')).toBeInTheDocument()
})
it('renders with different ratios', () => {
const { container, rerender } = render(
<SplitPane list={<div>List</div>} detail={<div>Detail</div>} ratio="1:1" />,
)
const pane = container.firstChild as HTMLElement
expect(pane.style.getPropertyValue('--split-columns')).toBe('1fr 1fr')
rerender(
<SplitPane list={<div>List</div>} detail={<div>Detail</div>} ratio="2:3" />,
)
expect(pane.style.getPropertyValue('--split-columns')).toBe('2fr 3fr')
})
it('accepts className', () => {
const { container } = render(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
className="custom"
/>,
)
expect(container.firstChild).toHaveClass('custom')
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/design-system/composites/SplitPane/SplitPane.test.tsx`
Expected: FAIL — module not found
- [ ] **Step 3: Create SplitPane CSS module**
Create `src/design-system/composites/SplitPane/SplitPane.module.css`:
CSS extracted from `src/pages/Admin/UserManagement/UserManagement.module.css` (`.splitPane`, `.listPane`, `.detailPane`, `.emptyDetail`), generalized with a CSS custom property for the column ratio.
```css
.splitPane {
display: grid;
grid-template-columns: var(--split-columns, 1fr 2fr);
gap: 1px;
background: var(--border-subtle);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
min-height: 0;
height: 100%;
box-shadow: var(--shadow-card);
}
.listPane {
background: var(--bg-surface);
display: flex;
flex-direction: column;
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
overflow-y: auto;
}
.detailPane {
background: var(--bg-raised);
overflow-y: auto;
padding: 20px;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
}
.emptyDetail {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-faint);
font-size: 13px;
font-family: var(--font-body);
font-style: italic;
}
```
- [ ] **Step 4: Create SplitPane component**
Create `src/design-system/composites/SplitPane/SplitPane.tsx`:
```tsx
import type { ReactNode } from 'react'
import styles from './SplitPane.module.css'
interface SplitPaneProps {
list: ReactNode
detail: ReactNode | null
emptyMessage?: string
ratio?: '1:1' | '1:2' | '2:3'
className?: string
}
const ratioMap: Record<string, string> = {
'1:1': '1fr 1fr',
'1:2': '1fr 2fr',
'2:3': '2fr 3fr',
}
export function SplitPane({
list,
detail,
emptyMessage = 'Select an item to view details',
ratio = '1:2',
className,
}: SplitPaneProps) {
return (
<div
className={`${styles.splitPane} ${className ?? ''}`}
style={{ '--split-columns': ratioMap[ratio] } as React.CSSProperties}
>
<div className={styles.listPane}>{list}</div>
<div className={styles.detailPane}>
{detail !== null ? detail : (
<div className={styles.emptyDetail}>{emptyMessage}</div>
)}
</div>
</div>
)
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `npx vitest run src/design-system/composites/SplitPane/SplitPane.test.tsx`
Expected: 5 tests PASS
- [ ] **Step 6: Commit**
```bash
git add src/design-system/composites/SplitPane/SplitPane.tsx \
src/design-system/composites/SplitPane/SplitPane.module.css \
src/design-system/composites/SplitPane/SplitPane.test.tsx
git commit -m "feat: add SplitPane composite for master/detail layouts"
```
---
### Task 2: EntityList composite
**Files:**
- Create: `src/design-system/composites/EntityList/EntityList.tsx`
- Create: `src/design-system/composites/EntityList/EntityList.module.css`
- Create: `src/design-system/composites/EntityList/EntityList.test.tsx`
- [ ] **Step 1: Write EntityList tests**
Create `src/design-system/composites/EntityList/EntityList.test.tsx`:
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { EntityList } from './EntityList'
interface TestItem {
id: string
name: string
}
const items: TestItem[] = [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
{ id: '3', name: 'Charlie' },
]
const defaultProps = {
items,
renderItem: (item: TestItem) => <span>{item.name}</span>,
getItemId: (item: TestItem) => item.id,
}
describe('EntityList', () => {
it('renders all items', () => {
render(<EntityList {...defaultProps} />)
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
expect(screen.getByText('Charlie')).toBeInTheDocument()
})
it('calls onSelect when item clicked', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
render(<EntityList {...defaultProps} onSelect={onSelect} />)
await user.click(screen.getByText('Bob'))
expect(onSelect).toHaveBeenCalledWith('2')
})
it('highlights selected item', () => {
render(<EntityList {...defaultProps} selectedId="2" />)
const selectedOption = screen.getByText('Bob').closest('[role="option"]')
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
expect(selectedOption).toHaveClass(/selected/i)
})
it('renders search input when onSearch provided', () => {
render(<EntityList {...defaultProps} onSearch={vi.fn()} searchPlaceholder="Search users..." />)
expect(screen.getByPlaceholderText('Search users...')).toBeInTheDocument()
})
it('calls onSearch when typing in search', async () => {
const onSearch = vi.fn()
const user = userEvent.setup()
render(<EntityList {...defaultProps} onSearch={onSearch} />)
await user.type(screen.getByPlaceholderText('Search...'), 'alice')
expect(onSearch).toHaveBeenLastCalledWith('alice')
})
it('renders add button when onAdd provided', () => {
render(<EntityList {...defaultProps} onAdd={vi.fn()} addLabel="+ Add user" />)
expect(screen.getByRole('button', { name: '+ Add user' })).toBeInTheDocument()
})
it('calls onAdd when add button clicked', async () => {
const onAdd = vi.fn()
const user = userEvent.setup()
render(<EntityList {...defaultProps} onAdd={onAdd} addLabel="+ Add user" />)
await user.click(screen.getByRole('button', { name: '+ Add user' }))
expect(onAdd).toHaveBeenCalledOnce()
})
it('hides header when no search or add', () => {
const { container } = render(<EntityList {...defaultProps} />)
// No header element should be rendered (no search input, no add button)
expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument()
expect(container.querySelector('[class*="listHeader"]')).not.toBeInTheDocument()
})
it('shows empty message when items is empty', () => {
render(
<EntityList
items={[]}
renderItem={() => <span />}
getItemId={() => ''}
/>,
)
expect(screen.getByText('No items found')).toBeInTheDocument()
})
it('shows custom empty message', () => {
render(
<EntityList
items={[]}
renderItem={() => <span />}
getItemId={() => ''}
emptyMessage="No users match your search"
/>,
)
expect(screen.getByText('No users match your search')).toBeInTheDocument()
})
it('accepts className', () => {
const { container } = render(<EntityList {...defaultProps} className="custom" />)
expect(container.firstChild).toHaveClass('custom')
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/design-system/composites/EntityList/EntityList.test.tsx`
Expected: FAIL — module not found
- [ ] **Step 3: Create EntityList CSS module**
Create `src/design-system/composites/EntityList/EntityList.module.css`:
CSS extracted from `src/pages/Admin/UserManagement/UserManagement.module.css` (`.listHeader`, `.listHeaderSearch`, `.entityList`, `.entityItem`, `.entityItemSelected`), generalized for reuse.
```css
.entityListRoot {
display: flex;
flex-direction: column;
height: 100%;
}
.listHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.listHeaderSearch {
flex: 1;
}
.list {
flex: 1;
overflow-y: auto;
}
.entityItem {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.1s;
border-bottom: 1px solid var(--border-subtle);
}
.entityItem:hover {
background: var(--bg-hover);
}
.entityItemSelected {
background: var(--amber-bg);
border-left: 3px solid var(--amber);
}
.emptyMessage {
padding: 32px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}
```
- [ ] **Step 4: Create EntityList component**
Create `src/design-system/composites/EntityList/EntityList.tsx`:
The component uses `role="listbox"` / `role="option"` for accessibility, matching the pattern in `UsersTab.tsx`. It delegates search input and add button to the existing `Input` and `Button` primitives.
```tsx
import { useState, type ReactNode } from 'react'
import { Input } from '../../primitives/Input/Input'
import { Button } from '../../primitives/Button/Button'
import styles from './EntityList.module.css'
interface EntityListProps<T> {
items: T[]
renderItem: (item: T, isSelected: boolean) => ReactNode
getItemId: (item: T) => string
selectedId?: string
onSelect?: (id: string) => void
searchPlaceholder?: string
onSearch?: (query: string) => void
addLabel?: string
onAdd?: () => void
emptyMessage?: string
className?: string
}
export function EntityList<T>({
items,
renderItem,
getItemId,
selectedId,
onSelect,
searchPlaceholder = 'Search...',
onSearch,
addLabel,
onAdd,
emptyMessage = 'No items found',
className,
}: EntityListProps<T>) {
const [searchValue, setSearchValue] = useState('')
const showHeader = !!onSearch || !!onAdd
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value
setSearchValue(value)
onSearch?.(value)
}
function handleSearchClear() {
setSearchValue('')
onSearch?.('')
}
return (
<div className={`${styles.entityListRoot} ${className ?? ''}`}>
{showHeader && (
<div className={styles.listHeader}>
{onSearch && (
<Input
placeholder={searchPlaceholder}
value={searchValue}
onChange={handleSearchChange}
onClear={handleSearchClear}
className={styles.listHeaderSearch}
/>
)}
{onAdd && addLabel && (
<Button size="sm" variant="secondary" onClick={onAdd}>
{addLabel}
</Button>
)}
</div>
)}
<div className={styles.list} role="listbox">
{items.map((item) => {
const id = getItemId(item)
const isSelected = id === selectedId
return (
<div
key={id}
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
onClick={() => onSelect?.(id)}
role="option"
tabIndex={0}
aria-selected={isSelected}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect?.(id)
}
}}
>
{renderItem(item, isSelected)}
</div>
)
})}
{items.length === 0 && (
<div className={styles.emptyMessage}>{emptyMessage}</div>
)}
</div>
</div>
)
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `npx vitest run src/design-system/composites/EntityList/EntityList.test.tsx`
Expected: 11 tests PASS
- [ ] **Step 6: Commit**
```bash
git add src/design-system/composites/EntityList/EntityList.tsx \
src/design-system/composites/EntityList/EntityList.module.css \
src/design-system/composites/EntityList/EntityList.test.tsx
git commit -m "feat: add EntityList composite for searchable, selectable lists"
```
---
### Task 3: Barrel exports & full test suite
**Files:**
- Modify: `src/design-system/composites/index.ts`
- [ ] **Step 1: Add exports to barrel**
Add these lines to `src/design-system/composites/index.ts` in alphabetical position.
After the `DetailPanel` export (line 13), add:
```ts
export { EntityList } from './EntityList/EntityList'
```
After the `LineChart` export (line 19), before `LoginDialog`, add:
```ts
// (no change needed here — LoginDialog is already present)
```
After the `ShortcutsBar` export (line 33), before `SegmentedTabs`, add:
```ts
export { SplitPane } from './SplitPane/SplitPane'
```
The resulting new lines in `index.ts` (in their alphabetical positions):
```ts
export { EntityList } from './EntityList/EntityList'
```
```ts
export { SplitPane } from './SplitPane/SplitPane'
```
- [ ] **Step 2: Run the full component test suite**
Run: `npx vitest run src/design-system/composites/SplitPane/ src/design-system/composites/EntityList/`
Expected: All 16 tests PASS (5 SplitPane + 11 EntityList)
- [ ] **Step 3: Run the full project test suite to check for regressions**
Run: `npx vitest run`
Expected: All tests PASS
- [ ] **Step 4: Commit**
```bash
git add src/design-system/composites/index.ts
git commit -m "feat: export SplitPane and EntityList from composites barrel"
```

View File

@@ -0,0 +1,431 @@
# Documentation Updates Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Update COMPONENT_GUIDE.md and Inventory page with entries and demos for all new components: KpiStrip, SplitPane, EntityList, LogViewer, StatusText, and Card title extension.
**Architecture:** COMPONENT_GUIDE.md gets new decision tree entries and component index rows. Inventory page gets DemoCard sections with realistic sample data for each new component.
**Tech Stack:** React, TypeScript, CSS Modules
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Documentation Updates section)
---
## Task 1: Update COMPONENT_GUIDE.md
**File:** `COMPONENT_GUIDE.md`
### Steps
- [ ] **1a.** In the `"I need to show status"` decision tree (line ~34), add StatusText entry after StatusDot:
```markdown
- Inline colored status value → **StatusText** (success, warning, error, running, muted — with optional bold)
```
- [ ] **1b.** In the `"I need to display data"` decision tree (line ~51), add three entries after the EventFeed line:
```markdown
- Row of summary KPIs → **KpiStrip** (horizontal strip with colored borders, trends, sparklines)
- Scrollable log output → **LogViewer** (timestamped, severity-colored monospace entries)
- Searchable, selectable entity list → **EntityList** (search header, selection highlighting, pairs with SplitPane)
```
- [ ] **1c.** In the `"I need to organize content"` decision tree (line ~62), add SplitPane entry after DetailPanel and update the Card entry:
After the `- Side panel inspector → **DetailPanel**` line, add:
```markdown
- Master/detail split layout → **SplitPane** (list on left, detail on right, configurable ratio)
```
Update the existing Card line from:
```markdown
- Grouped content box → **Card** (with optional accent)
```
to:
```markdown
- Grouped content box → **Card** (with optional accent and title)
```
- [ ] **1d.** In the `"I need to display text"` decision tree (line ~72), add StatusText cross-reference:
```markdown
- Colored inline status text → **StatusText** (semantic color + optional bold, see also "I need to show status")
```
- [ ] **1e.** Add a new composition pattern after the existing "KPI dashboard" pattern (line ~113):
```markdown
### Master/detail management pattern
```
SplitPane + EntityList for CRUD list/detail screens (users, groups, roles)
EntityList provides: search header, add button, selectable list
SplitPane provides: responsive two-column layout with empty state
```
```
- [ ] **1f.** Add five new rows to the Component Index table (maintaining alphabetical order):
After the `EventFeed` row:
```markdown
| EntityList | composite | Searchable, selectable entity list with add button. Pair with SplitPane for CRUD management screens |
```
After the `KeyboardHint` row:
```markdown
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
```
After the `LineChart` row:
```markdown
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
```
After the `Sparkline` row:
```markdown
| SplitPane | composite | Two-column master/detail layout with configurable ratio and empty state |
| StatusText | primitive | Inline colored status span (success, warning, error, running, muted) with optional bold |
```
- [ ] **1g.** Update the existing `Card` row in the Component Index from:
```markdown
| Card | primitive | Content container with optional accent border |
```
to:
```markdown
| Card | primitive | Content container with optional accent border and title header |
```
---
## Task 2: Add StatusText demo to PrimitivesSection
**File:** `src/pages/Inventory/sections/PrimitivesSection.tsx`
### Steps
- [ ] **2a.** Add `StatusText` to the import from `'../../../design-system/primitives'` (insert alphabetically after `StatCard`):
```tsx
StatusText,
```
- [ ] **2b.** Add a new DemoCard after the StatusDot demo (after line ~560, before the Tag demo). Insert this block:
```tsx
{/* 29. StatusText */}
<DemoCard
id="statustext"
title="StatusText"
description="Inline coloured text for status values — five semantic variants with optional bold."
>
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
<div className={styles.demoAreaRow}>
<StatusText variant="success">99.8% uptime</StatusText>
<StatusText variant="warning">SLA at risk</StatusText>
<StatusText variant="error">BREACH</StatusText>
<StatusText variant="running">Processing</StatusText>
<StatusText variant="muted">N/A</StatusText>
</div>
<div className={styles.demoAreaRow}>
<StatusText variant="success" bold>99.8% uptime</StatusText>
<StatusText variant="warning" bold>SLA at risk</StatusText>
<StatusText variant="error" bold>BREACH</StatusText>
<StatusText variant="running" bold>Processing</StatusText>
<StatusText variant="muted" bold>N/A</StatusText>
</div>
</div>
</DemoCard>
```
Note: Renumber subsequent demos (Tag becomes 30, Textarea becomes 31, Toggle becomes 32, Tooltip becomes 33).
---
## Task 3: Update Card demo in PrimitivesSection
**File:** `src/pages/Inventory/sections/PrimitivesSection.tsx`
### Steps
- [ ] **3a.** Update the Card DemoCard description from:
```tsx
description="Surface container with optional left-border accent colour."
```
to:
```tsx
description="Surface container with optional left-border accent colour and title header."
```
- [ ] **3b.** Add a title prop example to the Card demo. After the existing `Card accent="error"` line (~212), add:
```tsx
<Card title="Throughput (msg/s)">
<div style={{ padding: '8px 12px', fontSize: 13 }}>Card with title header and separator</div>
</Card>
<Card accent="amber" title="Error Rate">
<div style={{ padding: '8px 12px', fontSize: 13 }}>Title + accent combined</div>
</Card>
```
---
## Task 4: Add composite demos to CompositesSection
**File:** `src/pages/Inventory/sections/CompositesSection.tsx`
### Steps
- [ ] **4a.** Add new imports. Add `KpiStrip`, `SplitPane`, `EntityList`, `LogViewer` to the import from `'../../../design-system/composites'` (insert alphabetically):
```tsx
EntityList,
KpiStrip,
LogViewer,
SplitPane,
```
Also add `Badge` and `Avatar` to the import from `'../../../design-system/primitives'` (needed for EntityList demo renderItem):
```tsx
import { Avatar, Badge, Button } from '../../../design-system/primitives'
```
- [ ] **4b.** Add sample data constants after the existing sample data section (before the `CompositesSection` function). Add:
```tsx
// ── Sample data for new composites ───────────────────────────────────────────
const KPI_ITEMS = [
{
label: 'Exchanges',
value: '12,847',
trend: { label: '↑ +8.2%', variant: 'success' as const },
subtitle: 'Last 24h',
sparkline: [40, 55, 48, 62, 70, 65, 78],
borderColor: 'var(--amber)',
},
{
label: 'Error Rate',
value: '0.34%',
trend: { label: '↑ +0.12pp', variant: 'error' as const },
subtitle: 'Above threshold',
sparkline: [10, 12, 11, 15, 18, 22, 19],
borderColor: 'var(--error)',
},
{
label: 'Avg Latency',
value: '142ms',
trend: { label: '↓ -12ms', variant: 'success' as const },
subtitle: 'P95: 380ms',
borderColor: 'var(--success)',
},
{
label: 'Active Routes',
value: '37',
trend: { label: '±0', variant: 'muted' as const },
subtitle: '3 paused',
borderColor: 'var(--running)',
},
]
const ENTITY_LIST_ITEMS = [
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' },
{ id: '2', name: 'Bob Chen', email: 'bob@example.com', role: 'Editor' },
{ id: '3', name: 'Carol Smith', email: 'carol@example.com', role: 'Viewer' },
{ id: '4', name: 'David Park', email: 'david@example.com', role: 'Editor' },
{ id: '5', name: 'Eva Martinez', email: 'eva@example.com', role: 'Admin' },
]
const LOG_ENTRIES = [
{ timestamp: '2026-03-24T10:00:01Z', level: 'info' as const, message: 'Route timer-aggregator started successfully' },
{ timestamp: '2026-03-24T10:00:03Z', level: 'debug' as const, message: 'Polling endpoint https://api.internal/health — 200 OK' },
{ timestamp: '2026-03-24T10:00:15Z', level: 'warn' as const, message: 'Retry queue depth at 847 — approaching threshold (1000)' },
{ timestamp: '2026-03-24T10:00:22Z', level: 'error' as const, message: 'Exchange failed: Connection refused to jdbc:postgresql://db-primary:5432/orders' },
{ timestamp: '2026-03-24T10:00:23Z', level: 'info' as const, message: 'Failover activated — routing to db-secondary' },
{ timestamp: '2026-03-24T10:00:30Z', level: 'info' as const, message: 'Exchange completed in 142ms via fallback route' },
{ timestamp: '2026-03-24T10:00:45Z', level: 'debug' as const, message: 'Metrics flush: 328 data points written to InfluxDB' },
{ timestamp: '2026-03-24T10:01:00Z', level: 'warn' as const, message: 'Memory usage at 78% — GC scheduled' },
]
```
- [ ] **4c.** Add state variables inside the `CompositesSection` function for EntityList demo:
```tsx
// EntityList state
const [selectedEntityId, setSelectedEntityId] = useState<string | undefined>('1')
const [entitySearch, setEntitySearch] = useState('')
```
- [ ] **4d.** Add KpiStrip demo after the existing GroupCard demo. Insert a new DemoCard:
```tsx
{/* KpiStrip */}
<DemoCard
id="kpistrip"
title="KpiStrip"
description="Horizontal row of KPI cards with coloured left border, trend indicator, subtitle, and optional sparkline."
>
<div style={{ width: '100%' }}>
<KpiStrip items={KPI_ITEMS} />
</div>
</DemoCard>
```
- [ ] **4e.** Add SplitPane demo:
```tsx
{/* SplitPane */}
<DemoCard
id="splitpane"
title="SplitPane"
description="Two-column master/detail layout with configurable ratio and empty-state placeholder."
>
<div style={{ width: '100%', height: 200 }}>
<SplitPane
list={
<div style={{ padding: 16, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>Items</div>
<div>Item A</div>
<div>Item B</div>
<div>Item C</div>
</div>
}
detail={
<div style={{ padding: 16, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>Detail View</div>
<div>Select an item on the left to see its details here.</div>
</div>
}
ratio="1:2"
/>
</div>
</DemoCard>
```
- [ ] **4f.** Add EntityList demo:
```tsx
{/* EntityList */}
<DemoCard
id="entitylist"
title="EntityList"
description="Searchable, selectable entity list with add button — designed to pair with SplitPane."
>
<div style={{ width: '100%', height: 260 }}>
<EntityList
items={ENTITY_LIST_ITEMS.filter(u =>
u.name.toLowerCase().includes(entitySearch.toLowerCase())
)}
renderItem={(item, isSelected) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar name={item.name} size="sm" />
<div>
<div style={{ fontSize: 13, fontWeight: isSelected ? 600 : 400 }}>{item.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.email}</div>
</div>
<Badge label={item.role} style={{ marginLeft: 'auto' }} />
</div>
)}
getItemId={(item) => item.id}
selectedId={selectedEntityId}
onSelect={setSelectedEntityId}
searchPlaceholder="Search users..."
onSearch={setEntitySearch}
addLabel="+ Add user"
onAdd={() => {}}
/>
</div>
</DemoCard>
```
- [ ] **4g.** Add LogViewer demo:
```tsx
{/* LogViewer */}
<DemoCard
id="logviewer"
title="LogViewer"
description="Scrollable log output with timestamped, severity-coloured monospace entries and auto-scroll."
>
<div style={{ width: '100%' }}>
<LogViewer entries={LOG_ENTRIES} maxHeight={240} />
</div>
</DemoCard>
```
- [ ] **4h.** Verify all four new DemoCards are placed in alphabetical order among existing demos — EntityList after EventFeed, KpiStrip after GroupCard, LogViewer after LoginForm, SplitPane after ShortcutsBar. Adjust comment numbering accordingly.
---
## Task 5: Update Inventory nav
**File:** `src/pages/Inventory/Inventory.tsx`
### Steps
- [ ] **5a.** Add `StatusText` to the Primitives nav components array (insert alphabetically after `StatusDot`):
```tsx
{ label: 'StatusText', href: '#statustext' },
```
- [ ] **5b.** Add four entries to the Composites nav components array (insert alphabetically):
After `EventFeed`:
```tsx
{ label: 'EntityList', href: '#entitylist' },
```
After `GroupCard`:
```tsx
{ label: 'KpiStrip', href: '#kpistrip' },
```
After `LoginForm`:
```tsx
{ label: 'LogViewer', href: '#logviewer' },
```
After `ShortcutsBar`:
```tsx
{ label: 'SplitPane', href: '#splitpane' },
```
---
## Task 6: Commit all documentation
### Steps
- [ ] **6a.** Run `npx vitest run src/pages/Inventory` to verify Inventory page has no import/type errors (if tests exist for it).
- [ ] **6b.** Stage changed files:
- `COMPONENT_GUIDE.md`
- `src/pages/Inventory/Inventory.tsx`
- `src/pages/Inventory/sections/PrimitivesSection.tsx`
- `src/pages/Inventory/sections/CompositesSection.tsx`
- [ ] **6c.** Commit with message: `docs: add COMPONENT_GUIDE entries and Inventory demos for KpiStrip, SplitPane, EntityList, LogViewer, StatusText, Card title`
---
## Dependency Notes
- **Tasks 1-5 are independent** and can be worked in any order.
- **Task 6 depends on Tasks 1-5** being complete.
- **All tasks depend on the components already existing** — StatusText, Card title extension, KpiStrip, SplitPane, EntityList, and LogViewer must be built and exported from their barrel files before the Inventory demos will compile.
## Files Modified
| File | Change |
|------|--------|
| `COMPONENT_GUIDE.md` | Decision tree entries + component index rows |
| `src/pages/Inventory/Inventory.tsx` | 5 new nav entries (1 primitive + 4 composites) |
| `src/pages/Inventory/sections/PrimitivesSection.tsx` | StatusText demo + Card title demo update |
| `src/pages/Inventory/sections/CompositesSection.tsx` | KpiStrip, SplitPane, EntityList, LogViewer demos with sample data |

View File

@@ -0,0 +1,703 @@
# Metrics Components Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add StatusText primitive, Card title prop, and KpiStrip composite to eliminate ~320 lines of duplicated KPI layout code across Dashboard, Routes, and AgentHealth pages.
**Architecture:** StatusText is a tiny inline span primitive with semantic color variants. Card gets an optional title prop for a header row. KpiStrip is a new composite that renders a horizontal row of metric cards with labels, values, trends, subtitles, and sparklines.
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 1, 5, 6)
---
## File Map
| Action | File | Task |
|--------|------|------|
| CREATE | `src/design-system/primitives/StatusText/StatusText.tsx` | 1 |
| CREATE | `src/design-system/primitives/StatusText/StatusText.module.css` | 1 |
| CREATE | `src/design-system/primitives/StatusText/StatusText.test.tsx` | 1 |
| MODIFY | `src/design-system/primitives/index.ts` | 1 |
| MODIFY | `src/design-system/primitives/Card/Card.tsx` | 2 |
| MODIFY | `src/design-system/primitives/Card/Card.module.css` | 2 |
| CREATE | `src/design-system/primitives/Card/Card.test.tsx` | 2 |
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.tsx` | 3 |
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.module.css` | 3 |
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.test.tsx` | 3 |
| MODIFY | `src/design-system/composites/index.ts` | 3 |
---
## Task 1: StatusText Primitive
**Files:**
- CREATE `src/design-system/primitives/StatusText/StatusText.tsx`
- CREATE `src/design-system/primitives/StatusText/StatusText.module.css`
- CREATE `src/design-system/primitives/StatusText/StatusText.test.tsx`
- MODIFY `src/design-system/primitives/index.ts`
### Step 1.1 — Write test (RED)
- [ ] Create `src/design-system/primitives/StatusText/StatusText.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { StatusText } from './StatusText'
describe('StatusText', () => {
it('renders children text', () => {
render(<StatusText variant="success">OK</StatusText>)
expect(screen.getByText('OK')).toBeInTheDocument()
})
it('renders as a span element', () => {
render(<StatusText variant="success">OK</StatusText>)
expect(screen.getByText('OK').tagName).toBe('SPAN')
})
it('applies variant class', () => {
render(<StatusText variant="error">BREACH</StatusText>)
expect(screen.getByText('BREACH')).toHaveClass('error')
})
it('applies bold class when bold=true', () => {
render(<StatusText variant="warning" bold>HIGH</StatusText>)
expect(screen.getByText('HIGH')).toHaveClass('bold')
})
it('does not apply bold class by default', () => {
render(<StatusText variant="muted">idle</StatusText>)
expect(screen.getByText('idle')).not.toHaveClass('bold')
})
it('accepts custom className', () => {
render(<StatusText variant="running" className="custom">active</StatusText>)
expect(screen.getByText('active')).toHaveClass('custom')
})
it('renders all variant classes correctly', () => {
const { rerender } = render(<StatusText variant="success">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('success')
rerender(<StatusText variant="warning">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('warning')
rerender(<StatusText variant="error">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('error')
rerender(<StatusText variant="running">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('running')
rerender(<StatusText variant="muted">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('muted')
})
})
```
- [ ] Run test — expect FAIL (module not found):
```bash
npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx
```
### Step 1.2 — Implement (GREEN)
- [ ] Create `src/design-system/primitives/StatusText/StatusText.module.css`:
```css
.statusText {
/* Inherits font-size from parent */
}
.success { color: var(--success); }
.warning { color: var(--warning); }
.error { color: var(--error); }
.running { color: var(--running); }
.muted { color: var(--text-muted); }
.bold { font-weight: 600; }
```
- [ ] Create `src/design-system/primitives/StatusText/StatusText.tsx`:
```tsx
import styles from './StatusText.module.css'
import type { ReactNode } from 'react'
interface StatusTextProps {
variant: 'success' | 'warning' | 'error' | 'running' | 'muted'
bold?: boolean
children: ReactNode
className?: string
}
export function StatusText({ variant, bold = false, children, className }: StatusTextProps) {
const classes = [
styles.statusText,
styles[variant],
bold ? styles.bold : '',
className ?? '',
].filter(Boolean).join(' ')
return <span className={classes}>{children}</span>
}
```
- [ ] Run test — expect PASS:
```bash
npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx
```
### Step 1.3 — Barrel export
- [ ] Add to `src/design-system/primitives/index.ts` (alphabetical, after `StatusDot`):
```ts
export { StatusText } from './StatusText/StatusText'
```
### Step 1.4 — Commit
```bash
git add src/design-system/primitives/StatusText/ src/design-system/primitives/index.ts
git commit -m "feat: add StatusText primitive with semantic color variants"
```
---
## Task 2: Card Title Extension
**Files:**
- MODIFY `src/design-system/primitives/Card/Card.tsx`
- MODIFY `src/design-system/primitives/Card/Card.module.css`
- CREATE `src/design-system/primitives/Card/Card.test.tsx`
### Step 2.1 — Write test (RED)
- [ ] Create `src/design-system/primitives/Card/Card.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Card } from './Card'
describe('Card', () => {
it('renders children', () => {
render(<Card>Card content</Card>)
expect(screen.getByText('Card content')).toBeInTheDocument()
})
it('renders title when provided', () => {
render(<Card title="Section Title">content</Card>)
expect(screen.getByText('Section Title')).toBeInTheDocument()
})
it('does not render title header when title is omitted', () => {
const { container } = render(<Card>content</Card>)
expect(container.querySelector('.titleHeader')).not.toBeInTheDocument()
})
it('wraps children in body div when title is provided', () => {
render(<Card title="Header">body text</Card>)
const body = screen.getByText('body text').closest('div')
expect(body).toHaveClass('body')
})
it('renders with accent and title together', () => {
const { container } = render(
<Card accent="success" title="Status">
details
</Card>
)
expect(container.firstChild).toHaveClass('accent-success')
expect(screen.getByText('Status')).toBeInTheDocument()
expect(screen.getByText('details')).toBeInTheDocument()
})
it('accepts className prop', () => {
const { container } = render(<Card className="custom">content</Card>)
expect(container.firstChild).toHaveClass('custom')
})
it('renders children directly when no title (no wrapper div)', () => {
const { container } = render(<Card><span data-testid="direct">hi</span></Card>)
expect(screen.getByTestId('direct')).toBeInTheDocument()
// Should not have a body wrapper when there is no title
expect(container.querySelector('.body')).not.toBeInTheDocument()
})
})
```
- [ ] Run test — expect FAIL (title prop not supported yet, body class missing):
```bash
npx vitest run src/design-system/primitives/Card/Card.test.tsx
```
### Step 2.2 — Implement (GREEN)
- [ ] Add to `src/design-system/primitives/Card/Card.module.css` (append after existing rules):
```css
.titleHeader {
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.titleText {
font-size: 11px;
text-transform: uppercase;
font-family: var(--font-mono);
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.5px;
margin: 0;
}
.body {
padding: 16px;
}
```
- [ ] Replace `src/design-system/primitives/Card/Card.tsx` with:
```tsx
import styles from './Card.module.css'
import type { ReactNode } from 'react'
interface CardProps {
children: ReactNode
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' | 'none'
title?: string
className?: string
}
export function Card({ children, accent = 'none', title, className }: CardProps) {
const classes = [
styles.card,
accent !== 'none' ? styles[`accent-${accent}`] : '',
className ?? '',
].filter(Boolean).join(' ')
return (
<div className={classes}>
{title && (
<div className={styles.titleHeader}>
<h3 className={styles.titleText}>{title}</h3>
</div>
)}
{title ? <div className={styles.body}>{children}</div> : children}
</div>
)
}
```
- [ ] Run test — expect PASS:
```bash
npx vitest run src/design-system/primitives/Card/Card.test.tsx
```
### Step 2.3 — Commit
```bash
git add src/design-system/primitives/Card/
git commit -m "feat: add optional title prop to Card primitive"
```
---
## Task 3: KpiStrip Composite
**Files:**
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.tsx`
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.module.css`
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.test.tsx`
- MODIFY `src/design-system/composites/index.ts`
### Step 3.1 — Write test (RED)
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { KpiStrip } from './KpiStrip'
const sampleItems = [
{
label: 'Total Throughput',
value: '12,847',
trend: { label: '\u25B2 +8%', variant: 'success' as const },
subtitle: '35.7 msg/s',
sparkline: [44, 46, 45, 47, 48, 46, 47],
borderColor: 'var(--amber)',
},
{
label: 'Error Rate',
value: '0.42%',
trend: { label: '\u25BC -0.1%', variant: 'success' as const },
subtitle: '54 errors / 12,847 total',
},
{
label: 'Active Routes',
value: 14,
},
]
describe('KpiStrip', () => {
it('renders all items', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('Total Throughput')).toBeInTheDocument()
expect(screen.getByText('Error Rate')).toBeInTheDocument()
expect(screen.getByText('Active Routes')).toBeInTheDocument()
})
it('renders labels and values', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('12,847')).toBeInTheDocument()
expect(screen.getByText('0.42%')).toBeInTheDocument()
expect(screen.getByText('14')).toBeInTheDocument()
})
it('renders trend with correct text', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('\u25B2 +8%')).toBeInTheDocument()
expect(screen.getByText('\u25BC -0.1%')).toBeInTheDocument()
})
it('applies variant class to trend', () => {
render(<KpiStrip items={sampleItems} />)
const trend = screen.getByText('\u25B2 +8%')
expect(trend).toHaveClass('trendSuccess')
})
it('hides trend when omitted', () => {
render(<KpiStrip items={[{ label: 'Routes', value: 14 }]} />)
// Should only have label and value, no trend element
const card = screen.getByText('Routes').closest('[class*="kpiCard"]')
expect(card?.querySelector('[class*="trend"]')).toBeNull()
})
it('renders subtitle', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('35.7 msg/s')).toBeInTheDocument()
expect(screen.getByText('54 errors / 12,847 total')).toBeInTheDocument()
})
it('renders sparkline when data provided', () => {
const { container } = render(<KpiStrip items={sampleItems} />)
// Sparkline renders an SVG with aria-hidden
const svgs = container.querySelectorAll('svg[aria-hidden="true"]')
expect(svgs.length).toBe(1) // Only first item has sparkline
})
it('accepts className prop', () => {
const { container } = render(<KpiStrip items={sampleItems} className="custom" />)
expect(container.firstChild).toHaveClass('custom')
})
it('handles empty items array', () => {
const { container } = render(<KpiStrip items={[]} />)
expect(container.firstChild).toBeInTheDocument()
// No cards rendered
expect(container.querySelectorAll('[class*="kpiCard"]').length).toBe(0)
})
it('uses default border color when borderColor is omitted', () => {
const { container } = render(
<KpiStrip items={[{ label: 'Test', value: 100 }]} />
)
const card = container.querySelector('[class*="kpiCard"]')
expect(card).toBeInTheDocument()
// The default borderColor is applied via inline style
expect(card).toHaveStyle({ '--kpi-border-color': 'var(--amber)' })
})
it('applies custom borderColor', () => {
const { container } = render(
<KpiStrip items={[{ label: 'Errors', value: 5, borderColor: 'var(--error)' }]} />
)
const card = container.querySelector('[class*="kpiCard"]')
expect(card).toHaveStyle({ '--kpi-border-color': 'var(--error)' })
})
it('renders trend with muted variant by default', () => {
render(
<KpiStrip items={[{ label: 'Test', value: 1, trend: { label: '~ stable' } }]} />
)
const trend = screen.getByText('~ stable')
expect(trend).toHaveClass('trendMuted')
})
})
```
- [ ] Run test — expect FAIL (module not found):
```bash
npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx
```
### Step 3.2 — Implement (GREEN)
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.module.css`:
```css
/* KpiStrip — horizontal row of metric cards */
.kpiStrip {
display: grid;
gap: 12px;
margin-bottom: 20px;
}
/* ── Individual card ─────────────────────────────────────────────── */
.kpiCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 16px 18px 12px;
box-shadow: var(--shadow-card);
position: relative;
overflow: hidden;
transition: box-shadow 0.15s;
}
.kpiCard:hover {
box-shadow: var(--shadow-md);
}
/* Top gradient border — color driven by CSS custom property */
.kpiCard::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--kpi-border-color), transparent);
}
/* ── Label ───────────────────────────────────────────────────────── */
.label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 6px;
}
/* ── Value row ───────────────────────────────────────────────────── */
.valueRow {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 4px;
}
.value {
font-family: var(--font-mono);
font-size: 26px;
font-weight: 600;
line-height: 1.2;
color: var(--text-primary);
}
/* ── Trend ────────────────────────────────────────────────────────── */
.trend {
font-family: var(--font-mono);
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: auto;
}
.trendSuccess { color: var(--success); }
.trendWarning { color: var(--warning); }
.trendError { color: var(--error); }
.trendMuted { color: var(--text-muted); }
/* ── Subtitle ─────────────────────────────────────────────────────── */
.subtitle {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* ── Sparkline ────────────────────────────────────────────────────── */
.sparkline {
margin-top: 8px;
height: 32px;
}
```
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.tsx`:
```tsx
import styles from './KpiStrip.module.css'
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
import type { CSSProperties, ReactNode } from 'react'
export interface KpiItem {
label: string
value: string | number
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
subtitle?: string
sparkline?: number[]
borderColor?: string
}
export interface KpiStripProps {
items: KpiItem[]
className?: string
}
const trendClassMap: Record<string, string> = {
success: styles.trendSuccess,
warning: styles.trendWarning,
error: styles.trendError,
muted: styles.trendMuted,
}
export function KpiStrip({ items, className }: KpiStripProps) {
const stripClasses = [styles.kpiStrip, className ?? ''].filter(Boolean).join(' ')
const gridStyle: CSSProperties = {
gridTemplateColumns: items.length > 0 ? `repeat(${items.length}, 1fr)` : undefined,
}
return (
<div className={stripClasses} style={gridStyle}>
{items.map((item) => {
const borderColor = item.borderColor ?? 'var(--amber)'
const cardStyle: CSSProperties & Record<string, string> = {
'--kpi-border-color': borderColor,
}
const trendVariant = item.trend?.variant ?? 'muted'
const trendClass = trendClassMap[trendVariant] ?? styles.trendMuted
return (
<div key={item.label} className={styles.kpiCard} style={cardStyle}>
<div className={styles.label}>{item.label}</div>
<div className={styles.valueRow}>
<span className={styles.value}>{item.value}</span>
{item.trend && (
<span className={`${styles.trend} ${trendClass}`}>
{item.trend.label}
</span>
)}
</div>
{item.subtitle && (
<div className={styles.subtitle}>{item.subtitle}</div>
)}
{item.sparkline && item.sparkline.length >= 2 && (
<div className={styles.sparkline}>
<Sparkline
data={item.sparkline}
color={borderColor}
width={200}
height={32}
/>
</div>
)}
</div>
)
})}
</div>
)
}
```
- [ ] Run test — expect PASS:
```bash
npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx
```
### Step 3.3 — Barrel export
- [ ] Add to `src/design-system/composites/index.ts` (alphabetical, after `GroupCard`):
```ts
export { KpiStrip } from './KpiStrip/KpiStrip'
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
```
### Step 3.4 — Commit
```bash
git add src/design-system/composites/KpiStrip/ src/design-system/composites/index.ts
git commit -m "feat: add KpiStrip composite for reusable metric card rows"
```
---
## Task 4: Barrel Exports Verification & Full Test Run
**Files:**
- VERIFY `src/design-system/primitives/index.ts` (modified in Task 1)
- VERIFY `src/design-system/composites/index.ts` (modified in Task 3)
### Step 4.1 — Verify barrel exports
- [ ] Confirm `src/design-system/primitives/index.ts` contains:
```ts
export { StatusText } from './StatusText/StatusText'
```
- [ ] Confirm `src/design-system/composites/index.ts` contains:
```ts
export { KpiStrip } from './KpiStrip/KpiStrip'
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
```
### Step 4.2 — Run full test suite
- [ ] Run all tests to confirm nothing is broken:
```bash
npx vitest run
```
- [ ] Verify zero failures. If any test fails, fix and re-run before proceeding.
### Step 4.3 — Final commit (if barrel-only changes remain)
If the barrel export changes were not already committed in their respective tasks:
```bash
git add src/design-system/primitives/index.ts src/design-system/composites/index.ts
git commit -m "chore: add StatusText and KpiStrip to barrel exports"
```
---
## Summary of Expected Barrel Export Additions
**`src/design-system/primitives/index.ts`** — insert after `StatusDot` line:
```ts
export { StatusText } from './StatusText/StatusText'
```
**`src/design-system/composites/index.ts`** — insert after `GroupCard` line:
```ts
export { KpiStrip } from './KpiStrip/KpiStrip'
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
```
---
## Test Commands Quick Reference
| Scope | Command |
|-------|---------|
| StatusText only | `npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx` |
| Card only | `npx vitest run src/design-system/primitives/Card/Card.test.tsx` |
| KpiStrip only | `npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx` |
| All tests | `npx vitest run` |

View File

@@ -0,0 +1,506 @@
# Observability Components Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add LogViewer composite for log display and refactor AgentHealth to use DataTable instead of raw HTML tables.
**Architecture:** LogViewer is a scrollable log display with timestamped, severity-colored entries and auto-scroll behavior. The AgentHealth refactor replaces raw `<table>` elements with the existing DataTable composite.
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 3, 4)
---
## Task 1: LogViewer composite
Create a new composite component that renders a scrollable log viewer with timestamped, severity-colored entries. This replaces the custom log rendering in `AgentInstance.tsx`.
### Files
- **Create** `src/design-system/composites/LogViewer/LogViewer.tsx`
- **Create** `src/design-system/composites/LogViewer/LogViewer.module.css`
- **Create** `src/design-system/composites/LogViewer/LogViewer.test.tsx`
### Steps
- [ ] **1.1** Create `src/design-system/composites/LogViewer/LogViewer.tsx` with the component and exported types
- [ ] **1.2** Create `src/design-system/composites/LogViewer/LogViewer.module.css` with all styles
- [ ] **1.3** Create `src/design-system/composites/LogViewer/LogViewer.test.tsx` with tests
- [ ] **1.4** Run `npx vitest run src/design-system/composites/LogViewer` and fix any failures
### API
```tsx
export interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug'
message: string
}
export interface LogViewerProps {
entries: LogEntry[]
maxHeight?: number | string // Default: 400
className?: string
}
```
### Component implementation — `LogViewer.tsx`
```tsx
import { useRef, useEffect, useCallback } from 'react'
import styles from './LogViewer.module.css'
export interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug'
message: string
}
export interface LogViewerProps {
entries: LogEntry[]
maxHeight?: number | string
className?: string
}
const LEVEL_CLASS: Record<LogEntry['level'], string> = {
info: styles.levelInfo,
warn: styles.levelWarn,
error: styles.levelError,
debug: styles.levelDebug,
}
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
} catch {
return iso
}
}
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true)
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
// Consider "at bottom" when within 20px of the end
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
}, [])
// Auto-scroll to bottom when entries change, but only if user hasn't scrolled up
useEffect(() => {
const el = scrollRef.current
if (el && isAtBottomRef.current) {
el.scrollTop = el.scrollHeight
}
}, [entries])
const heightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
return (
<div
ref={scrollRef}
className={[styles.container, className].filter(Boolean).join(' ')}
style={{ maxHeight: heightStyle }}
onScroll={handleScroll}
role="log"
>
{entries.map((entry, i) => (
<div key={i} className={styles.line}>
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
{entry.level.toUpperCase()}
</span>
<span className={styles.message}>{entry.message}</span>
</div>
))}
{entries.length === 0 && (
<div className={styles.empty}>No log entries.</div>
)}
</div>
)
}
```
### Styles — `LogViewer.module.css`
```css
/* Scrollable container */
.container {
overflow-y: auto;
background: var(--bg-inset);
border-radius: var(--radius-md);
padding: 8px 0;
font-family: var(--font-mono);
}
/* Each log line */
.line {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 3px 12px;
line-height: 1.5;
}
.line:hover {
background: var(--bg-hover);
}
/* Timestamp */
.timestamp {
flex-shrink: 0;
font-size: 11px;
color: var(--text-muted);
min-width: 56px;
}
/* Level badge — pill with tinted background */
.levelBadge {
flex-shrink: 0;
font-size: 9px;
font-weight: 600;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.3px;
padding: 1px 6px;
border-radius: 9999px;
line-height: 1.5;
white-space: nowrap;
}
.levelInfo {
color: var(--running);
background: color-mix(in srgb, var(--running) 12%, transparent);
}
.levelWarn {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
}
.levelError {
color: var(--error);
background: color-mix(in srgb, var(--error) 12%, transparent);
}
.levelDebug {
color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
}
/* Message text */
.message {
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-primary);
word-break: break-word;
line-height: 1.5;
}
/* Empty state */
.empty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}
```
### Tests — `LogViewer.test.tsx`
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { LogViewer, type LogEntry } from './LogViewer'
import { ThemeProvider } from '../../providers/ThemeProvider'
const wrap = (ui: React.ReactElement) => render(<ThemeProvider>{ui}</ThemeProvider>)
const sampleEntries: LogEntry[] = [
{ timestamp: '2026-03-24T10:00:00Z', level: 'info', message: 'Server started' },
{ timestamp: '2026-03-24T10:01:00Z', level: 'warn', message: 'Slow query detected' },
{ timestamp: '2026-03-24T10:02:00Z', level: 'error', message: 'Connection refused' },
{ timestamp: '2026-03-24T10:03:00Z', level: 'debug', message: 'Cache hit ratio: 0.95' },
]
describe('LogViewer', () => {
it('renders entries with timestamps and messages', () => {
wrap(<LogViewer entries={sampleEntries} />)
expect(screen.getByText('Server started')).toBeInTheDocument()
expect(screen.getByText('Slow query detected')).toBeInTheDocument()
expect(screen.getByText('Connection refused')).toBeInTheDocument()
expect(screen.getByText('Cache hit ratio: 0.95')).toBeInTheDocument()
})
it('renders level badges with correct text', () => {
wrap(<LogViewer entries={sampleEntries} />)
expect(screen.getByText('INFO')).toBeInTheDocument()
expect(screen.getByText('WARN')).toBeInTheDocument()
expect(screen.getByText('ERROR')).toBeInTheDocument()
expect(screen.getByText('DEBUG')).toBeInTheDocument()
})
it('renders with custom maxHeight', () => {
const { container } = wrap(<LogViewer entries={sampleEntries} maxHeight={200} />)
const el = container.querySelector('[role="log"]')
expect(el).toHaveStyle({ maxHeight: '200px' })
})
it('renders with string maxHeight', () => {
const { container } = wrap(<LogViewer entries={sampleEntries} maxHeight="50vh" />)
const el = container.querySelector('[role="log"]')
expect(el).toHaveStyle({ maxHeight: '50vh' })
})
it('handles empty entries', () => {
wrap(<LogViewer entries={[]} />)
expect(screen.getByText('No log entries.')).toBeInTheDocument()
})
it('accepts className prop', () => {
const { container } = wrap(<LogViewer entries={sampleEntries} className="custom-class" />)
const el = container.querySelector('[role="log"]')
expect(el?.className).toContain('custom-class')
})
it('has role="log" for accessibility', () => {
wrap(<LogViewer entries={sampleEntries} />)
expect(screen.getByRole('log')).toBeInTheDocument()
})
})
```
### Key design decisions
- **Auto-scroll behavior:** Uses a `useRef` to track whether the user is at the bottom of the scroll container. On new entries (via `useEffect` on `entries`), scrolls to bottom only if `isAtBottomRef.current` is `true`. Pauses when user scrolls up (more than 20px from bottom). Resumes when user scrolls back to bottom.
- **Level colors:** Map to existing design tokens: `info` -> `var(--running)`, `warn` -> `var(--warning)`, `error` -> `var(--error)`, `debug` -> `var(--text-muted)`. Pill backgrounds use `color-mix` with 12% opacity tint.
- **No Badge dependency:** The level badge is a styled `<span>` rather than using the `Badge` primitive. This avoids pulling in `hashColor`/`useTheme` and keeps the badge styling tightly scoped (9px pill vs Badge's larger size). The spec calls for a very compact pill at 9px mono — a custom element is cleaner.
- **`role="log"`** on the container for accessibility (indicates a log region to screen readers).
---
## Task 2: Barrel exports for LogViewer
Add LogViewer and its types to the composites barrel export.
### Files
- **Modify** `src/design-system/composites/index.ts`
### Steps
- [ ] **2.1** Add LogViewer export and type exports to `src/design-system/composites/index.ts`
### Changes
Add these lines to `src/design-system/composites/index.ts`, in alphabetical position (after the `LineChart` export):
```ts
export { LogViewer } from './LogViewer/LogViewer'
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
```
The full insertion point — after line 19 (`export { LineChart } from './LineChart/LineChart'`) and before line 20 (`export { LoginDialog } from './LoginForm/LoginDialog'`):
```ts
export { LineChart } from './LineChart/LineChart'
export { LogViewer } from './LogViewer/LogViewer'
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
export { LoginDialog } from './LoginForm/LoginDialog'
```
---
## Task 3: AgentHealth DataTable refactor
Replace the raw HTML `<table>` in `AgentHealth.tsx` with the existing `DataTable` composite. This is a **page-level refactor** — no design system components are changed.
### Files
- **Modify** `src/pages/AgentHealth/AgentHealth.tsx` — replace `<table>` with `<DataTable>`
- **Modify** `src/pages/AgentHealth/AgentHealth.module.css` — remove table CSS
### Steps
- [ ] **3.1** Add `DataTable` and `Column` imports to `AgentHealth.tsx`
- [ ] **3.2** Define the instance columns array
- [ ] **3.3** Replace the `<table>` block inside each `<GroupCard>` with `<DataTable>`
- [ ] **3.4** Remove unused table CSS classes from `AgentHealth.module.css`
- [ ] **3.5** Visually verify the page looks identical (run dev server, navigate to `/agents`)
### 3.1 — Add imports
Add to the composites import block in `AgentHealth.tsx`:
```tsx
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
import type { Column } from '../../design-system/composites/DataTable/types'
```
### 3.2 — Define columns
Add a column definition constant above the `AgentHealth` component function. The columns mirror the existing `<th>` headers. Custom `render` functions handle the StatusDot and Badge cells.
**Important:** DataTable requires rows with an `id: string` field. The `AgentHealthData` type already has `id`, so no transformation is needed.
```tsx
const instanceColumns: Column<AgentHealthData>[] = [
{
key: 'status',
header: '',
width: '12px',
render: (_value, row) => (
<StatusDot variant={row.status === 'live' ? 'live' : row.status === 'stale' ? 'stale' : 'dead'} />
),
},
{
key: 'name',
header: 'Instance',
render: (_value, row) => (
<MonoText size="sm" className={styles.instanceName}>{row.name}</MonoText>
),
},
{
key: 'state',
header: 'State',
render: (_value, row) => (
<Badge
label={row.status.toUpperCase()}
color={row.status === 'live' ? 'success' : row.status === 'stale' ? 'warning' : 'error'}
variant="filled"
/>
),
},
{
key: 'uptime',
header: 'Uptime',
render: (_value, row) => (
<MonoText size="xs" className={styles.instanceMeta}>{row.uptime}</MonoText>
),
},
{
key: 'tps',
header: 'TPS',
render: (_value, row) => (
<MonoText size="xs" className={styles.instanceMeta}>{row.tps.toFixed(1)}/s</MonoText>
),
},
{
key: 'errorRate',
header: 'Errors',
render: (_value, row) => (
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
{row.errorRate ?? '0 err/h'}
</MonoText>
),
},
{
key: 'lastSeen',
header: 'Heartbeat',
render: (_value, row) => (
<MonoText size="xs" className={
row.status === 'dead' ? styles.instanceHeartbeatDead :
row.status === 'stale' ? styles.instanceHeartbeatStale :
styles.instanceMeta
}>
{row.lastSeen}
</MonoText>
),
},
]
```
### 3.3 — Replace `<table>` with `<DataTable>`
Replace the entire `<table className={styles.instanceTable}>...</table>` block (lines 365-423 of `AgentHealth.tsx`) inside each `<GroupCard>` with:
```tsx
<DataTable
columns={instanceColumns}
data={group.instances}
flush
selectedId={selectedInstance?.id}
onRowClick={handleInstanceClick}
pageSize={50}
/>
```
Key props:
- `flush` — strips DataTable's outer border/radius/shadow so it sits seamlessly inside the GroupCard
- `selectedId` — highlights the currently selected row (replaces the manual `instanceRowActive` CSS class)
- `onRowClick` — replaces the manual `onClick` on `<tr>` elements
- `pageSize={50}` — high enough to avoid pagination for typical instance counts per app group
### 3.4 — Remove unused CSS
Remove these CSS classes from `AgentHealth.module.css` (they were only used by the raw `<table>`):
```
.instanceTable
.instanceTable thead th
.thStatus
.tdStatus
.instanceRow
.instanceRow td
.instanceRow:last-child td
.instanceRow:hover td
.instanceRowActive td
.instanceRowActive td:first-child
```
**Keep** these classes (still used by DataTable `render` functions):
```
.instanceName
.instanceMeta
.instanceError
.instanceHeartbeatStale
.instanceHeartbeatDead
```
### Visual verification checklist
After the refactor, verify at `/agents`:
- [ ] StatusDot column renders colored dots in the first column
- [ ] Instance name renders in mono bold
- [ ] State column shows Badge with correct color variant
- [ ] Uptime, TPS, Errors, Heartbeat columns show muted mono text
- [ ] Error values show in `var(--error)` red
- [ ] Stale/dead heartbeat timestamps show warning/error colors
- [ ] Row click opens the DetailPanel
- [ ] Selected row is visually highlighted
- [ ] Table sits flush inside GroupCard (no double borders)
- [ ] Alert banner still renders below the table for groups with dead instances
---
## Execution order
1. **Task 1** — LogViewer composite (no dependencies)
2. **Task 2** — Barrel exports (depends on Task 1)
3. **Task 3** — AgentHealth DataTable refactor (independent of Tasks 1-2)
Tasks 1+2 and Task 3 can be parallelized since they touch different parts of the codebase.
## Verification
```bash
# Run LogViewer tests
npx vitest run src/design-system/composites/LogViewer
# Run all tests to check nothing broke
npx vitest run
# Start dev server for visual verification
npm run dev
# Then navigate to /agents and /agents/{appId}/{instanceId}
```