Files
design-system/docs/superpowers/plans/2026-03-24-metrics-components.md

704 lines
21 KiB
Markdown
Raw Permalink Normal View History

# 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` |