feat: promote mock UI patterns into design system

New components: KpiStrip, SplitPane, EntityList, LogViewer, StatusText
Extended: Card (title prop)
Refactored: AgentHealth to use DataTable
Updated: COMPONENT_GUIDE.md, Inventory demos

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-24 15:16:03 +01:00
26 changed files with 1343 additions and 135 deletions

View File

@@ -33,6 +33,7 @@
### "I need to show status"
- Dot indicator → **StatusDot** (live, stale, dead, success, warning, error, running)
- Inline colored status value → **StatusText** (success, warning, error, running, muted — with optional bold)
- Labeled status → **Badge** with semantic color
- Removable label → **Tag**
@@ -57,6 +58,9 @@
- Event log → **EventFeed**
- Processing pipeline (Gantt view) → **ProcessorTimeline**
- Processing pipeline (flow diagram) → **RouteFlow**
- 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)
### "I need to organize content"
- Collapsible sections (standalone) → **Collapsible**
@@ -64,15 +68,17 @@
- Tabbed content → **Tabs**
- Tab switching with pill/segment style → **SegmentedTabs**
- Side panel inspector → **DetailPanel**
- Master/detail split layout → **SplitPane** (list on left, detail on right, configurable ratio)
- Section with title + action → **SectionHeader**
- Empty content placeholder → **EmptyState**
- Grouped content box → **Card** (with optional accent)
- Grouped content box → **Card** (with optional accent and title)
- Grouped items with header + meta + footer → **GroupCard** (e.g., app instances)
### "I need to display text"
- Code/JSON payload → **CodeBlock** (with line numbers, copy button)
- Monospace inline text → **MonoText**
- Keyboard shortcut hint → **KeyboardHint**
- Colored inline status text → **StatusText** (semantic color + optional bold, see also "I need to show status")
### "I need to show people/users"
- Single user avatar → **Avatar**
@@ -115,6 +121,13 @@ Row of StatCard components (each with optional Sparkline and trend)
Below: charts (AreaChart, LineChart, BarChart)
```
### 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
```
### Detail/inspector pattern
```
DetailPanel (right slide) with Tabs for sections OR children for scrollable content
@@ -162,7 +175,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| Breadcrumb | composite | Navigation path showing current location |
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
| ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
| Card | primitive | Content container with optional accent border |
| Card | primitive | Content container with optional accent border and title header |
| Checkbox | primitive | Boolean input with label |
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
| Collapsible | primitive | Single expand/collapse section |
@@ -174,6 +187,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| DetailPanel | composite | Slide-in side panel with tabs or children for scrollable content |
| Dropdown | composite | Action menu triggered by any element |
| EmptyState | primitive | Placeholder for empty content areas |
| EntityList | composite | Searchable, selectable entity list with add button. Pair with SplitPane for CRUD management screens |
| EventFeed | composite | Chronological event log with severity |
| FilterBar | composite | Search + filter controls for data views |
| GroupCard | composite | Card with header, meta row, children, and optional footer/alert. Used for grouping instances by application. |
@@ -183,8 +197,10 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| InlineEdit | primitive | Click-to-edit text field. Enter saves, Escape/blur cancels. Props: value, onSave, placeholder, disabled, className |
| Input | primitive | Single-line text input with optional icon |
| KeyboardHint | primitive | Keyboard shortcut display |
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
| Label | primitive | Form label with optional required asterisk |
| LineChart | composite | Time series line visualization |
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
| MenuItem | composite | Sidebar navigation item with health/count |
| Modal | composite | Generic dialog overlay with backdrop |
| MultiSelect | composite | Dropdown with searchable checkbox list and Apply action. Props: options, value, onChange, placeholder, searchable, disabled, className |
@@ -202,9 +218,11 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
| Sparkline | primitive | Inline mini chart for trends |
| SplitPane | composite | Two-column master/detail layout with configurable ratio and empty state |
| Spinner | primitive | Animated loading indicator |
| StatCard | primitive | KPI card with value, trend, optional sparkline |
| StatusDot | primitive | Colored dot for status indication |
| StatusText | primitive | Inline colored status span (success, warning, error, running, muted) with optional bold |
| Tabs | composite | Tabbed content switcher with optional counts |
| Tag | primitive | Removable colored label |
| Textarea | primitive | Multi-line text input with resize control |

View File

@@ -0,0 +1,49 @@
.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);
}

View File

@@ -0,0 +1,167 @@
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: 'Alpha' },
{ id: '2', name: 'Beta' },
{ id: '3', name: 'Gamma' },
]
describe('EntityList', () => {
it('renders all items', () => {
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
/>
)
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Beta')).toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
it('calls onSelect when item clicked', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onSelect={onSelect}
/>
)
await user.click(screen.getByText('Beta'))
expect(onSelect).toHaveBeenCalledWith('2')
})
it('highlights selected item (aria-selected="true" and has selected class)', () => {
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
selectedId="2"
/>
)
const selectedOption = screen.getByText('Beta').closest('[role="option"]')
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
const unselectedOption = screen.getByText('Alpha').closest('[role="option"]')
expect(unselectedOption).toHaveAttribute('aria-selected', 'false')
})
it('renders search input when onSearch provided', () => {
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onSearch={() => {}}
searchPlaceholder="Filter items..."
/>
)
expect(screen.getByPlaceholderText('Filter items...')).toBeInTheDocument()
})
it('calls onSearch when typing in search', async () => {
const onSearch = vi.fn()
const user = userEvent.setup()
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onSearch={onSearch}
/>
)
const input = screen.getByPlaceholderText('Search...')
await user.type(input, 'test')
expect(onSearch).toHaveBeenLastCalledWith('test')
})
it('renders add button when onAdd provided', () => {
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onAdd={() => {}}
addLabel="Add Item"
/>
)
expect(screen.getByText('Add Item')).toBeInTheDocument()
})
it('calls onAdd when add button clicked', async () => {
const onAdd = vi.fn()
const user = userEvent.setup()
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onAdd={onAdd}
addLabel="Add Item"
/>
)
await user.click(screen.getByText('Add Item'))
expect(onAdd).toHaveBeenCalledOnce()
})
it('hides header when no search or add', () => {
const { container } = render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
/>
)
// No input or button should be present in the header area
expect(container.querySelector('input')).toBeNull()
expect(container.querySelector('button')).toBeNull()
})
it('shows empty message when items is empty', () => {
render(
<EntityList
items={[]}
renderItem={(item: TestItem) => <span>{item.name}</span>}
getItemId={(item: TestItem) => item.id}
/>
)
expect(screen.getByText('No items found')).toBeInTheDocument()
})
it('shows custom empty message', () => {
render(
<EntityList
items={[]}
renderItem={(item: TestItem) => <span>{item.name}</span>}
getItemId={(item: TestItem) => item.id}
emptyMessage="Nothing here"
/>
)
expect(screen.getByText('Nothing here')).toBeInTheDocument()
})
it('accepts className', () => {
const { container } = render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
className="custom-class"
/>
)
expect(container.firstChild).toHaveClass('custom-class')
})
})

View File

@@ -0,0 +1,97 @@
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>
)
}

View File

@@ -0,0 +1,79 @@
.kpiStrip {
display: grid;
gap: 12px;
margin-bottom: 20px;
}
.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);
}
.kpiCard::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--kpi-border-color), transparent);
}
.label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 6px;
}
.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 {
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 {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.sparkline {
margin-top: 8px;
height: 32px;
}

View File

@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { KpiStrip } from './KpiStrip'
import type { KpiItem } from './KpiStrip'
const sampleItems: KpiItem[] = [
{ label: 'Total', value: 42 },
{ label: 'Active', value: '18', trend: { label: '+3', variant: 'success' } },
{ label: 'Errors', value: 5, subtitle: 'last 24h', sparkline: [1, 3, 2, 5, 4] },
]
describe('KpiStrip', () => {
it('renders all items', () => {
const { container } = render(<KpiStrip items={sampleItems} />)
const cards = container.querySelectorAll('[class*="kpiCard"]')
expect(cards).toHaveLength(3)
})
it('renders labels and values', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('Total')).toBeInTheDocument()
expect(screen.getByText('42')).toBeInTheDocument()
expect(screen.getByText('Active')).toBeInTheDocument()
expect(screen.getByText('18')).toBeInTheDocument()
})
it('renders trend with correct text', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('+3')).toBeInTheDocument()
})
it('applies variant class to trend (trendSuccess)', () => {
render(<KpiStrip items={sampleItems} />)
const trend = screen.getByText('+3')
expect(trend.className).toContain('trendSuccess')
})
it('hides trend when omitted', () => {
render(<KpiStrip items={[{ label: 'No Trend', value: 10 }]} />)
const { container } = render(<KpiStrip items={[{ label: 'No Trend2', value: 10 }]} />)
const trends = container.querySelectorAll('[class*="trend"]')
expect(trends).toHaveLength(0)
})
it('renders subtitle', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('last 24h')).toBeInTheDocument()
})
it('renders sparkline when data provided', () => {
const { container } = render(<KpiStrip items={sampleItems} />)
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThanOrEqual(1)
})
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={[]} />)
const cards = container.querySelectorAll('[class*="kpiCard"]')
expect(cards).toHaveLength(0)
})
it('uses default border color (--amber) when borderColor omitted', () => {
const { container } = render(<KpiStrip items={[{ label: 'Default', value: 1 }]} />)
const card = container.querySelector('[class*="kpiCard"]') as HTMLElement
expect(card.style.getPropertyValue('--kpi-border-color')).toBe('var(--amber)')
})
it('applies custom borderColor', () => {
const items: KpiItem[] = [{ label: 'Custom', value: 1, borderColor: 'var(--teal)' }]
const { container } = render(<KpiStrip items={items} />)
const card = container.querySelector('[class*="kpiCard"]') as HTMLElement
expect(card.style.getPropertyValue('--kpi-border-color')).toBe('var(--teal)')
})
it('renders trend with muted variant by default', () => {
const items: KpiItem[] = [{ label: 'Muted', value: 1, trend: { label: '0%' } }]
render(<KpiStrip items={items} />)
const trend = screen.getByText('0%')
expect(trend.className).toContain('trendMuted')
})
})

View File

@@ -0,0 +1,71 @@
import styles from './KpiStrip.module.css'
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
import type { CSSProperties } 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>
)
}

View File

@@ -0,0 +1,75 @@
.container {
overflow-y: auto;
background: var(--bg-inset);
border-radius: var(--radius-md);
padding: 8px 0;
font-family: var(--font-mono);
}
.line {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 3px 12px;
line-height: 1.5;
}
.line:hover {
background: var(--bg-hover);
}
.timestamp {
flex-shrink: 0;
font-size: 11px;
color: var(--text-muted);
min-width: 56px;
}
.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 {
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-primary);
word-break: break-word;
line-height: 1.5;
}
.empty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { LogViewer, type LogEntry } from './LogViewer'
const entries: LogEntry[] = [
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'Server started' },
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
]
describe('LogViewer', () => {
it('renders entries with timestamps and messages', () => {
render(<LogViewer entries={entries} />)
expect(screen.getByText('Server started')).toBeInTheDocument()
expect(screen.getByText('High memory usage')).toBeInTheDocument()
expect(screen.getByText('Connection failed')).toBeInTheDocument()
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
})
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG)', () => {
render(<LogViewer entries={entries} />)
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 (number)', () => {
const { container } = render(<LogViewer entries={entries} maxHeight={300} />)
const el = container.firstElementChild as HTMLElement
expect(el.style.maxHeight).toBe('300px')
})
it('renders with string maxHeight', () => {
const { container } = render(<LogViewer entries={entries} maxHeight="50vh" />)
const el = container.firstElementChild as HTMLElement
expect(el.style.maxHeight).toBe('50vh')
})
it('handles empty entries', () => {
render(<LogViewer entries={[]} />)
expect(screen.getByText('No log entries.')).toBeInTheDocument()
})
it('accepts className prop', () => {
const { container } = render(<LogViewer entries={entries} className="custom-class" />)
const el = container.firstElementChild as HTMLElement
expect(el.classList.contains('custom-class')).toBe(true)
})
it('has role="log" for accessibility', () => {
render(<LogViewer entries={entries} />)
expect(screen.getByRole('log')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,77 @@
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
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
}, [])
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>
)
}

View File

@@ -0,0 +1,37 @@
.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;
}

View File

@@ -0,0 +1,69 @@
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>List items</div>}
detail={<div>Detail content</div>}
/>
)
expect(screen.getByText('List items')).toBeInTheDocument()
expect(screen.getByText('Detail content')).toBeInTheDocument()
})
it('shows default empty message when detail is null', () => {
render(
<SplitPane
list={<div>List items</div>}
detail={null}
/>
)
expect(screen.getByText('Select an item to view details')).toBeInTheDocument()
})
it('shows custom empty message', () => {
render(
<SplitPane
list={<div>List items</div>}
detail={null}
emptyMessage="Pick something"
/>
)
expect(screen.getByText('Pick something')).toBeInTheDocument()
})
it('renders with different ratios (checks --split-columns CSS property)', () => {
const { container, rerender } = render(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
ratio="1:1"
/>
)
const root = container.firstChild as HTMLElement
expect(root.style.getPropertyValue('--split-columns')).toBe('1fr 1fr')
rerender(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
ratio="2:3"
/>
)
expect(root.style.getPropertyValue('--split-columns')).toBe('2fr 3fr')
})
it('accepts className', () => {
const { container } = render(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
className="custom-class"
/>
)
expect(container.firstChild).toHaveClass('custom-class')
})
})

View File

@@ -0,0 +1,38 @@
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>
)
}

View File

@@ -11,12 +11,17 @@ export type { ConfirmDialogProps } from './ConfirmDialog/ConfirmDialog'
export { DataTable } from './DataTable/DataTable'
export type { Column, DataTableProps } from './DataTable/types'
export { DetailPanel } from './DetailPanel/DetailPanel'
export { EntityList } from './EntityList/EntityList'
export { Dropdown } from './Dropdown/Dropdown'
export { EventFeed } from './EventFeed/EventFeed'
export { GroupCard } from './GroupCard/GroupCard'
export { KpiStrip } from './KpiStrip/KpiStrip'
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
export type { FeedEvent } from './EventFeed/EventFeed'
export { FilterBar } from './FilterBar/FilterBar'
export { LineChart } from './LineChart/LineChart'
export { LogViewer } from './LogViewer/LogViewer'
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
export { LoginDialog } from './LoginForm/LoginDialog'
export type { LoginDialogProps } from './LoginForm/LoginDialog'
export { LoginForm } from './LoginForm/LoginForm'
@@ -32,6 +37,7 @@ export { RouteFlow } from './RouteFlow/RouteFlow'
export type { RouteNode } from './RouteFlow/RouteFlow'
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
export { SplitPane } from './SplitPane/SplitPane'
export { Tabs } from './Tabs/Tabs'
export { ToastProvider, useToast } from './Toast/Toast'
export { TreeView } from './TreeView/TreeView'

View File

@@ -11,3 +11,22 @@
.accent-warning { border-top: 3px solid var(--warning); }
.accent-error { border-top: 3px solid var(--error); }
.accent-running { border-top: 3px solid var(--running); }
.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;
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Card } from './Card'
describe('Card', () => {
it('renders children', () => {
render(<Card>Hello world</Card>)
expect(screen.getByText('Hello world')).toBeInTheDocument()
})
it('renders title when provided', () => {
render(<Card title="Status">Content</Card>)
expect(screen.getByText('Status')).toBeInTheDocument()
})
it('does not render title header when title is omitted', () => {
const { container } = render(<Card>Content</Card>)
expect(container.querySelector('h3')).toBeNull()
})
it('wraps children in body div when title is provided', () => {
render(<Card title="Status"><span>Content</span></Card>)
const content = screen.getByText('Content')
expect(content.parentElement).toHaveClass('body')
})
it('renders with accent and title together', () => {
const { container } = render(
<Card accent="success" title="Health">Content</Card>,
)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('accent-success')
expect(screen.getByText('Health')).toBeInTheDocument()
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('accepts className prop', () => {
const { container } = render(<Card className="custom">Content</Card>)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('custom')
})
it('renders children directly when no title (no wrapper div)', () => {
const { container } = render(<Card><span>Direct child</span></Card>)
const card = container.firstChild as HTMLElement
const span = screen.getByText('Direct child')
expect(span.parentElement).toBe(card)
})
})

View File

@@ -4,15 +4,25 @@ 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', className }: CardProps) {
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}>{children}</div>
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>
)
}

View File

@@ -0,0 +1,7 @@
.statusText {}
.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; }

View File

@@ -0,0 +1,47 @@
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">Online</StatusText>)
expect(screen.getByText('Online')).toBeInTheDocument()
})
it('renders as a span element', () => {
render(<StatusText variant="success">Status</StatusText>)
const el = screen.getByText('Status')
expect(el.tagName).toBe('SPAN')
})
it('applies variant class', () => {
render(<StatusText variant="error">Failed</StatusText>)
expect(screen.getByText('Failed')).toHaveClass('error')
})
it('applies bold class when bold=true', () => {
render(<StatusText variant="success" bold>OK</StatusText>)
expect(screen.getByText('OK')).toHaveClass('bold')
})
it('does not apply bold class by default', () => {
render(<StatusText variant="success">OK</StatusText>)
expect(screen.getByText('OK')).not.toHaveClass('bold')
})
it('accepts custom className', () => {
render(<StatusText variant="muted" className="custom">Text</StatusText>)
expect(screen.getByText('Text')).toHaveClass('custom')
})
it('renders all 5 variant classes correctly', () => {
const variants = ['success', 'warning', 'error', 'running', 'muted'] as const
for (const variant of variants) {
const { unmount } = render(
<StatusText variant={variant}>{variant}</StatusText>
)
expect(screen.getByText(variant)).toHaveClass(variant)
unmount()
}
})
})

View File

@@ -0,0 +1,20 @@
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>
}

View File

@@ -30,6 +30,7 @@ export { Sparkline } from './Sparkline/Sparkline'
export { Spinner } from './Spinner/Spinner'
export { StatCard } from './StatCard/StatCard'
export { StatusDot } from './StatusDot/StatusDot'
export { StatusText } from './StatusText/StatusText'
export { Tag } from './Tag/Tag'
export { Textarea } from './Textarea/Textarea'
export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'

View File

@@ -96,16 +96,6 @@
margin-bottom: 20px;
}
/* Instance count badge in group header */
.instanceCountBadge {
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-muted);
background: var(--bg-inset);
padding: 2px 8px;
border-radius: 10px;
}
/* Group meta row */
.groupMeta {
display: flex;
@@ -138,62 +128,6 @@
flex-shrink: 0;
}
/* Instance table */
.instanceTable {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.instanceTable thead th {
padding: 4px 12px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-faint);
text-align: left;
border-bottom: 1px solid var(--border-subtle);
white-space: nowrap;
}
.thStatus {
width: 12px;
}
.tdStatus {
width: 12px;
text-align: center;
}
/* Instance row */
.instanceRow {
cursor: pointer;
transition: background 0.1s;
}
.instanceRow td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
white-space: nowrap;
}
.instanceRow:last-child td {
border-bottom: none;
}
.instanceRow:hover td {
background: var(--bg-hover);
}
.instanceRowActive td {
background: var(--amber-bg);
}
.instanceRowActive td:first-child {
box-shadow: inset 3px 0 0 var(--amber);
}
/* Instance fields */
.instanceName {
font-weight: 600;

View File

@@ -9,9 +9,11 @@ import { TopBar } from '../../design-system/layout/TopBar/TopBar'
// Composites
import { GroupCard } from '../../design-system/composites/GroupCard/GroupCard'
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
import type { Column } from '../../design-system/composites/DataTable/types'
// Primitives
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
@@ -143,6 +145,72 @@ export function AgentHealth() {
// Build trend data for selected instance
const trendData = selectedInstance ? buildTrendData(selectedInstance) : null
// Column definitions for the instance DataTable
const instanceColumns: Column<AgentHealthData>[] = useMemo(() => [
{
key: 'status',
header: '',
width: '12px',
render: (_val, row) => (
<StatusDot variant={row.status === 'live' ? 'live' : row.status === 'stale' ? 'stale' : 'dead'} />
),
},
{
key: 'name',
header: 'Instance',
render: (_val, row) => (
<MonoText size="sm" className={styles.instanceName}>{row.name}</MonoText>
),
},
{
key: 'state',
header: 'State',
render: (_val, row) => (
<Badge
label={row.status.toUpperCase()}
color={row.status === 'live' ? 'success' : row.status === 'stale' ? 'warning' : 'error'}
variant="filled"
/>
),
},
{
key: 'uptime',
header: 'Uptime',
render: (_val, row) => (
<MonoText size="xs" className={styles.instanceMeta}>{row.uptime}</MonoText>
),
},
{
key: 'tps',
header: 'TPS',
render: (_val, row) => (
<MonoText size="xs" className={styles.instanceMeta}>{row.tps.toFixed(1)}/s</MonoText>
),
},
{
key: 'errorRate',
header: 'Errors',
render: (_val, row) => (
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
{row.errorRate ?? '0 err/h'}
</MonoText>
),
},
{
key: 'lastSeen',
header: 'Heartbeat',
render: (_val, row) => (
<MonoText size="xs" className={
row.status === 'dead' ? styles.instanceHeartbeatDead :
row.status === 'stale' ? styles.instanceHeartbeatStale :
styles.instanceMeta
}>
{row.lastSeen}
</MonoText>
),
},
], [])
function handleInstanceClick(inst: AgentHealthData) {
setSelectedInstance(inst)
setPanelOpen(true)
@@ -362,65 +430,14 @@ export function AgentHealth() {
</div>
) : undefined}
>
<table className={styles.instanceTable}>
<thead>
<tr>
<th className={styles.thStatus} />
<th>Instance</th>
<th>State</th>
<th>Uptime</th>
<th>TPS</th>
<th>Errors</th>
<th>Heartbeat</th>
</tr>
</thead>
<tbody>
{group.instances.map((inst) => (
<tr
key={inst.id}
className={[
styles.instanceRow,
selectedInstance?.id === inst.id && panelOpen ? styles.instanceRowActive : '',
].filter(Boolean).join(' ')}
onClick={() => handleInstanceClick(inst)}
>
<td className={styles.tdStatus}>
<StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} />
</td>
<td>
<MonoText size="sm" className={styles.instanceName}>{inst.name}</MonoText>
</td>
<td>
<Badge
label={inst.status.toUpperCase()}
color={inst.status === 'live' ? 'success' : inst.status === 'stale' ? 'warning' : 'error'}
variant="filled"
/>
</td>
<td>
<MonoText size="xs" className={styles.instanceMeta}>{inst.uptime}</MonoText>
</td>
<td>
<MonoText size="xs" className={styles.instanceMeta}>{inst.tps.toFixed(1)}/s</MonoText>
</td>
<td>
<MonoText size="xs" className={inst.errorRate ? styles.instanceError : styles.instanceMeta}>
{inst.errorRate ?? '0 err/h'}
</MonoText>
</td>
<td>
<MonoText size="xs" className={
inst.status === 'dead' ? styles.instanceHeartbeatDead :
inst.status === 'stale' ? styles.instanceHeartbeatStale :
styles.instanceMeta
}>
{inst.lastSeen}
</MonoText>
</td>
</tr>
))}
</tbody>
</table>
<DataTable<AgentHealthData>
columns={instanceColumns}
data={group.instances}
onRowClick={handleInstanceClick}
selectedId={panelOpen ? selectedInstance?.id : undefined}
pageSize={50}
flush
/>
</GroupCard>
))}
</div>

View File

@@ -39,6 +39,7 @@ const NAV_SECTIONS = [
{ label: 'Spinner', href: '#spinner' },
{ label: 'StatCard', href: '#statcard' },
{ label: 'StatusDot', href: '#statusdot' },
{ label: 'StatusText', href: '#statustext' },
{ label: 'Tag', href: '#tag' },
{ label: 'Textarea', href: '#textarea' },
{ label: 'Toggle', href: '#toggle' },
@@ -60,12 +61,15 @@ const NAV_SECTIONS = [
{ label: 'DataTable', href: '#datatable' },
{ label: 'DetailPanel', href: '#detailpanel' },
{ label: 'Dropdown', href: '#dropdown' },
{ label: 'EntityList', href: '#entitylist' },
{ label: 'EventFeed', href: '#eventfeed' },
{ label: 'FilterBar', href: '#filterbar' },
{ label: 'GroupCard', href: '#groupcard' },
{ label: 'KpiStrip', href: '#kpistrip' },
{ label: 'LineChart', href: '#linechart' },
{ label: 'LoginDialog', href: '#logindialog' },
{ label: 'LoginForm', href: '#loginform' },
{ label: 'LogViewer', href: '#logviewer' },
{ label: 'MenuItem', href: '#menuitem' },
{ label: 'Modal', href: '#modal' },
{ label: 'MultiSelect', href: '#multi-select' },
@@ -74,6 +78,7 @@ const NAV_SECTIONS = [
{ label: 'RouteFlow', href: '#routeflow' },
{ label: 'SegmentedTabs', href: '#segmented-tabs' },
{ label: 'ShortcutsBar', href: '#shortcutsbar' },
{ label: 'SplitPane', href: '#splitpane' },
{ label: 'Tabs', href: '#tabs' },
{ label: 'Toast', href: '#toast' },
{ label: 'TreeView', href: '#treeview' },

View File

@@ -12,12 +12,15 @@ import {
DataTable,
DetailPanel,
Dropdown,
EntityList,
EventFeed,
FilterBar,
GroupCard,
KpiStrip,
LineChart,
LoginDialog,
LoginForm,
LogViewer,
MenuItem,
Modal,
MultiSelect,
@@ -26,13 +29,14 @@ import {
RouteFlow,
SegmentedTabs,
ShortcutsBar,
SplitPane,
Tabs,
ToastProvider,
useToast,
TreeView,
} from '../../../design-system/composites'
import type { SearchResult } from '../../../design-system/composites'
import { Button } from '../../../design-system/primitives'
import { Avatar, Badge, Button } from '../../../design-system/primitives'
// ── DemoCard helper ──────────────────────────────────────────────────────────
@@ -178,6 +182,60 @@ const TREE_NODES = [
},
]
// ── Sample data for new composites ───────────────────────────────────────────
const KPI_ITEMS = [
{
label: 'Exchanges',
value: '12,847',
trend: { label: '\u2191 +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: '\u2191 +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: '\u2193 -12ms', variant: 'success' as const },
subtitle: 'P95: 380ms',
borderColor: 'var(--success)',
},
{
label: 'Active Routes',
value: '37',
trend: { label: '\u00b10', 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 \u2014 200 OK' },
{ timestamp: '2026-03-24T10:00:15Z', level: 'warn' as const, message: 'Retry queue depth at 847 \u2014 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 \u2014 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% \u2014 GC scheduled' },
]
// ── CompositesSection ─────────────────────────────────────────────────────────
export function CompositesSection() {
@@ -221,6 +279,10 @@ export function CompositesSection() {
// MultiSelect
const [multiValue, setMultiValue] = useState<string[]>(['admin'])
// EntityList state
const [selectedEntityId, setSelectedEntityId] = useState<string | undefined>('1')
const [entitySearch, setEntitySearch] = useState('')
// LoginDialog
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const [loginLoading, setLoginLoading] = useState(false)
@@ -465,6 +527,38 @@ export function CompositesSection() {
</div>
</DemoCard>
{/* 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>
<span style={{ marginLeft: 'auto' }}><Badge label={item.role} /></span>
</div>
)}
getItemId={(item) => item.id}
selectedId={selectedEntityId}
onSelect={setSelectedEntityId}
searchPlaceholder="Search users..."
onSearch={setEntitySearch}
addLabel="+ Add user"
onAdd={() => {}}
/>
</div>
</DemoCard>
{/* 11b. GroupCard */}
<DemoCard
id="groupcard"
@@ -484,6 +578,17 @@ export function CompositesSection() {
</div>
</DemoCard>
{/* 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>
{/* 12. FilterBar */}
<DemoCard
id="filterbar"
@@ -562,6 +667,17 @@ export function CompositesSection() {
</div>
</DemoCard>
{/* 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>
{/* 14. MenuItem */}
<DemoCard
id="menuitem"
@@ -707,6 +823,33 @@ export function CompositesSection() {
/>
</DemoCard>
{/* 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>
{/* 19. Tabs */}
<DemoCard
id="tabs"

View File

@@ -32,6 +32,7 @@ import {
Spinner,
StatCard,
StatusDot,
StatusText,
Tag,
Textarea,
Toggle,
@@ -204,12 +205,18 @@ export function PrimitivesSection() {
<DemoCard
id="card"
title="Card"
description="Surface container with optional left-border accent colour."
description="Surface container with optional left-border accent colour and title header."
>
<Card><div style={{ padding: '8px 12px', fontSize: 13 }}>Plain card</div></Card>
<Card accent="amber"><div style={{ padding: '8px 12px', fontSize: 13 }}>Amber accent</div></Card>
<Card accent="success"><div style={{ padding: '8px 12px', fontSize: 13 }}>Success accent</div></Card>
<Card accent="error"><div style={{ padding: '8px 12px', fontSize: 13 }}>Error accent</div></Card>
<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>
</DemoCard>
{/* 6. Checkbox */}
@@ -559,7 +566,31 @@ export function PrimitivesSection() {
</div>
</DemoCard>
{/* 29. Tag */}
{/* 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>
{/* 30. Tag */}
<DemoCard
id="tag"
title="Tag"
@@ -573,7 +604,7 @@ export function PrimitivesSection() {
<Tag label="removable" color="primary" onRemove={() => undefined} />
</DemoCard>
{/* 30. Textarea */}
{/* 31. Textarea */}
<DemoCard
id="textarea"
title="Textarea"
@@ -582,7 +613,7 @@ export function PrimitivesSection() {
<Textarea placeholder="Enter a description…" style={{ width: 280 }} />
</DemoCard>
{/* 31. Toggle */}
{/* 32. Toggle */}
<DemoCard
id="toggle"
title="Toggle"
@@ -602,7 +633,7 @@ export function PrimitivesSection() {
<Toggle label="Locked off" disabled />
</DemoCard>
{/* 32. Tooltip */}
{/* 33. Tooltip */}
<DemoCard
id="tooltip"
title="Tooltip"