diff --git a/docs/superpowers/plans/2026-03-24-admin-components.md b/docs/superpowers/plans/2026-03-24-admin-components.md new file mode 100644 index 0000000..e3b6295 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-admin-components.md @@ -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( + User list} + detail={
User detail
} + />, + ) + expect(screen.getByText('User list')).toBeInTheDocument() + expect(screen.getByText('User detail')).toBeInTheDocument() + }) + + it('shows default empty message when detail is null', () => { + render( + User list} + detail={null} + />, + ) + expect(screen.getByText('Select an item to view details')).toBeInTheDocument() + }) + + it('shows custom empty message when detail is null', () => { + render( + User list} + 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( + List} detail={
Detail
} ratio="1:1" />, + ) + const pane = container.firstChild as HTMLElement + expect(pane.style.getPropertyValue('--split-columns')).toBe('1fr 1fr') + + rerender( + List} detail={
Detail
} ratio="2:3" />, + ) + expect(pane.style.getPropertyValue('--split-columns')).toBe('2fr 3fr') + }) + + it('accepts className', () => { + const { container } = render( + List} + detail={
Detail
} + 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 = { + '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 ( +
+
{list}
+
+ {detail !== null ? detail : ( +
{emptyMessage}
+ )} +
+
+ ) +} +``` + +- [ ] **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) => {item.name}, + getItemId: (item: TestItem) => item.id, +} + +describe('EntityList', () => { + it('renders all items', () => { + render() + 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() + await user.click(screen.getByText('Bob')) + expect(onSelect).toHaveBeenCalledWith('2') + }) + + it('highlights selected item', () => { + render() + 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() + expect(screen.getByPlaceholderText('Search users...')).toBeInTheDocument() + }) + + it('calls onSearch when typing in search', async () => { + const onSearch = vi.fn() + const user = userEvent.setup() + render() + await user.type(screen.getByPlaceholderText('Search...'), 'alice') + expect(onSearch).toHaveBeenLastCalledWith('alice') + }) + + it('renders add button when onAdd provided', () => { + render() + 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() + await user.click(screen.getByRole('button', { name: '+ Add user' })) + expect(onAdd).toHaveBeenCalledOnce() + }) + + it('hides header when no search or add', () => { + const { container } = render() + // 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( + } + getItemId={() => ''} + />, + ) + expect(screen.getByText('No items found')).toBeInTheDocument() + }) + + it('shows custom empty message', () => { + render( + } + getItemId={() => ''} + emptyMessage="No users match your search" + />, + ) + expect(screen.getByText('No users match your search')).toBeInTheDocument() + }) + + it('accepts className', () => { + const { container } = render() + 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 { + 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({ + items, + renderItem, + getItemId, + selectedId, + onSelect, + searchPlaceholder = 'Search...', + onSearch, + addLabel, + onAdd, + emptyMessage = 'No items found', + className, +}: EntityListProps) { + const [searchValue, setSearchValue] = useState('') + const showHeader = !!onSearch || !!onAdd + + function handleSearchChange(e: React.ChangeEvent) { + const value = e.target.value + setSearchValue(value) + onSearch?.(value) + } + + function handleSearchClear() { + setSearchValue('') + onSearch?.('') + } + + return ( +
+ {showHeader && ( +
+ {onSearch && ( + + )} + {onAdd && addLabel && ( + + )} +
+ )} + +
+ {items.map((item) => { + const id = getItemId(item) + const isSelected = id === selectedId + return ( +
onSelect?.(id)} + role="option" + tabIndex={0} + aria-selected={isSelected} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect?.(id) + } + }} + > + {renderItem(item, isSelected)} +
+ ) + })} + {items.length === 0 && ( +
{emptyMessage}
+ )} +
+
+ ) +} +``` + +- [ ] **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" +``` diff --git a/docs/superpowers/plans/2026-03-24-documentation-updates.md b/docs/superpowers/plans/2026-03-24-documentation-updates.md new file mode 100644 index 0000000..9d73a35 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-documentation-updates.md @@ -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 */} + +
+
+ 99.8% uptime + SLA at risk + BREACH + Processing + N/A +
+
+ 99.8% uptime + SLA at risk + BREACH + Processing + N/A +
+
+
+``` + +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 with title header and separator
+
+ +
Title + accent combined
+
+``` + +--- + +## 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('1') + const [entitySearch, setEntitySearch] = useState('') +``` + +- [ ] **4d.** Add KpiStrip demo after the existing GroupCard demo. Insert a new DemoCard: + +```tsx + {/* KpiStrip */} + +
+ +
+
+``` + +- [ ] **4e.** Add SplitPane demo: + +```tsx + {/* SplitPane */} + +
+ +
Items
+
Item A
+
Item B
+
Item C
+
+ } + detail={ +
+
Detail View
+
Select an item on the left to see its details here.
+
+ } + ratio="1:2" + /> + +
+``` + +- [ ] **4f.** Add EntityList demo: + +```tsx + {/* EntityList */} + +
+ + u.name.toLowerCase().includes(entitySearch.toLowerCase()) + )} + renderItem={(item, isSelected) => ( +
+ +
+
{item.name}
+
{item.email}
+
+ +
+ )} + getItemId={(item) => item.id} + selectedId={selectedEntityId} + onSelect={setSelectedEntityId} + searchPlaceholder="Search users..." + onSearch={setEntitySearch} + addLabel="+ Add user" + onAdd={() => {}} + /> +
+
+``` + +- [ ] **4g.** Add LogViewer demo: + +```tsx + {/* LogViewer */} + +
+ +
+
+``` + +- [ ] **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 | diff --git a/docs/superpowers/plans/2026-03-24-metrics-components.md b/docs/superpowers/plans/2026-03-24-metrics-components.md new file mode 100644 index 0000000..7bf758e --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-metrics-components.md @@ -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(OK) + expect(screen.getByText('OK')).toBeInTheDocument() + }) + + it('renders as a span element', () => { + render(OK) + expect(screen.getByText('OK').tagName).toBe('SPAN') + }) + + it('applies variant class', () => { + render(BREACH) + expect(screen.getByText('BREACH')).toHaveClass('error') + }) + + it('applies bold class when bold=true', () => { + render(HIGH) + expect(screen.getByText('HIGH')).toHaveClass('bold') + }) + + it('does not apply bold class by default', () => { + render(idle) + expect(screen.getByText('idle')).not.toHaveClass('bold') + }) + + it('accepts custom className', () => { + render(active) + expect(screen.getByText('active')).toHaveClass('custom') + }) + + it('renders all variant classes correctly', () => { + const { rerender } = render(text) + expect(screen.getByText('text')).toHaveClass('success') + + rerender(text) + expect(screen.getByText('text')).toHaveClass('warning') + + rerender(text) + expect(screen.getByText('text')).toHaveClass('error') + + rerender(text) + expect(screen.getByText('text')).toHaveClass('running') + + rerender(text) + 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 {children} +} +``` + +- [ ] 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 content) + expect(screen.getByText('Card content')).toBeInTheDocument() + }) + + it('renders title when provided', () => { + render(content) + expect(screen.getByText('Section Title')).toBeInTheDocument() + }) + + it('does not render title header when title is omitted', () => { + const { container } = render(content) + expect(container.querySelector('.titleHeader')).not.toBeInTheDocument() + }) + + it('wraps children in body div when title is provided', () => { + render(body text) + const body = screen.getByText('body text').closest('div') + expect(body).toHaveClass('body') + }) + + it('renders with accent and title together', () => { + const { container } = render( + + details + + ) + expect(container.firstChild).toHaveClass('accent-success') + expect(screen.getByText('Status')).toBeInTheDocument() + expect(screen.getByText('details')).toBeInTheDocument() + }) + + it('accepts className prop', () => { + const { container } = render(content) + expect(container.firstChild).toHaveClass('custom') + }) + + it('renders children directly when no title (no wrapper div)', () => { + const { container } = render(hi) + 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 ( +
+ {title && ( +
+

{title}

+
+ )} + {title ?
{children}
: children} +
+ ) +} +``` + +- [ ] 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() + expect(screen.getByText('Total Throughput')).toBeInTheDocument() + expect(screen.getByText('Error Rate')).toBeInTheDocument() + expect(screen.getByText('Active Routes')).toBeInTheDocument() + }) + + it('renders labels and values', () => { + render() + expect(screen.getByText('12,847')).toBeInTheDocument() + expect(screen.getByText('0.42%')).toBeInTheDocument() + expect(screen.getByText('14')).toBeInTheDocument() + }) + + it('renders trend with correct text', () => { + render() + expect(screen.getByText('\u25B2 +8%')).toBeInTheDocument() + expect(screen.getByText('\u25BC -0.1%')).toBeInTheDocument() + }) + + it('applies variant class to trend', () => { + render() + const trend = screen.getByText('\u25B2 +8%') + expect(trend).toHaveClass('trendSuccess') + }) + + it('hides trend when omitted', () => { + render() + // 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() + 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() + // 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() + expect(container.firstChild).toHaveClass('custom') + }) + + it('handles empty items array', () => { + const { container } = render() + 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( + + ) + 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( + + ) + const card = container.querySelector('[class*="kpiCard"]') + expect(card).toHaveStyle({ '--kpi-border-color': 'var(--error)' }) + }) + + it('renders trend with muted variant by default', () => { + render( + + ) + 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 = { + 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 ( +
+ {items.map((item) => { + const borderColor = item.borderColor ?? 'var(--amber)' + const cardStyle: CSSProperties & Record = { + '--kpi-border-color': borderColor, + } + const trendVariant = item.trend?.variant ?? 'muted' + const trendClass = trendClassMap[trendVariant] ?? styles.trendMuted + + return ( +
+
{item.label}
+
+ {item.value} + {item.trend && ( + + {item.trend.label} + + )} +
+ {item.subtitle && ( +
{item.subtitle}
+ )} + {item.sparkline && item.sparkline.length >= 2 && ( +
+ +
+ )} +
+ ) + })} +
+ ) +} +``` + +- [ ] 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` | diff --git a/docs/superpowers/plans/2026-03-24-observability-components.md b/docs/superpowers/plans/2026-03-24-observability-components.md new file mode 100644 index 0000000..ddd49fc --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-observability-components.md @@ -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 `` 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 = { + 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(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 ( +
+ {entries.map((entry, i) => ( +
+ {formatTime(entry.timestamp)} + + {entry.level.toUpperCase()} + + {entry.message} +
+ ))} + {entries.length === 0 && ( +
No log entries.
+ )} +
+ ) +} +``` + +### 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({ui}) + +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() + 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() + 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() + const el = container.querySelector('[role="log"]') + expect(el).toHaveStyle({ maxHeight: '200px' }) + }) + + it('renders with string maxHeight', () => { + const { container } = wrap() + const el = container.querySelector('[role="log"]') + expect(el).toHaveStyle({ maxHeight: '50vh' }) + }) + + it('handles empty entries', () => { + wrap() + expect(screen.getByText('No log entries.')).toBeInTheDocument() + }) + + it('accepts className prop', () => { + const { container } = wrap() + const el = container.querySelector('[role="log"]') + expect(el?.className).toContain('custom-class') + }) + + it('has role="log" for accessibility', () => { + wrap() + 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 `` 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 `
` 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 `
` with `` +- **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 `
` block inside each `` with `` +- [ ] **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 `` 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 `
` 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[] = [ + { + key: 'status', + header: '', + width: '12px', + render: (_value, row) => ( + + ), + }, + { + key: 'name', + header: 'Instance', + render: (_value, row) => ( + {row.name} + ), + }, + { + key: 'state', + header: 'State', + render: (_value, row) => ( + + ), + }, + { + key: 'uptime', + header: 'Uptime', + render: (_value, row) => ( + {row.uptime} + ), + }, + { + key: 'tps', + header: 'TPS', + render: (_value, row) => ( + {row.tps.toFixed(1)}/s + ), + }, + { + key: 'errorRate', + header: 'Errors', + render: (_value, row) => ( + + {row.errorRate ?? '0 err/h'} + + ), + }, + { + key: 'lastSeen', + header: 'Heartbeat', + render: (_value, row) => ( + + {row.lastSeen} + + ), + }, +] +``` + +### 3.3 — Replace `` with `` + +Replace the entire `
...
` block (lines 365-423 of `AgentHealth.tsx`) inside each `` with: + +```tsx + +``` + +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 `
`): + +``` +.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} +```