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:
573
docs/superpowers/plans/2026-03-24-admin-components.md
Normal file
573
docs/superpowers/plans/2026-03-24-admin-components.md
Normal 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"
|
||||
```
|
||||
431
docs/superpowers/plans/2026-03-24-documentation-updates.md
Normal file
431
docs/superpowers/plans/2026-03-24-documentation-updates.md
Normal 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 |
|
||||
703
docs/superpowers/plans/2026-03-24-metrics-components.md
Normal file
703
docs/superpowers/plans/2026-03-24-metrics-components.md
Normal 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` |
|
||||
506
docs/superpowers/plans/2026-03-24-observability-components.md
Normal file
506
docs/superpowers/plans/2026-03-24-observability-components.md
Normal 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}
|
||||
```
|
||||
Reference in New Issue
Block a user