diff --git a/COMPONENT_GUIDE.md b/COMPONENT_GUIDE.md index 9a2988a..7e19fed 100644 --- a/COMPONENT_GUIDE.md +++ b/COMPONENT_GUIDE.md @@ -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 | diff --git a/src/design-system/composites/EntityList/EntityList.module.css b/src/design-system/composites/EntityList/EntityList.module.css new file mode 100644 index 0000000..a537409 --- /dev/null +++ b/src/design-system/composites/EntityList/EntityList.module.css @@ -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); +} diff --git a/src/design-system/composites/EntityList/EntityList.test.tsx b/src/design-system/composites/EntityList/EntityList.test.tsx new file mode 100644 index 0000000..2696da4 --- /dev/null +++ b/src/design-system/composites/EntityList/EntityList.test.tsx @@ -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( + {item.name}} + 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( + {item.name}} + 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( + {item.name}} + 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( + {item.name}} + 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( + {item.name}} + 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( + {item.name}} + 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( + {item.name}} + 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( + {item.name}} + 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( + {item.name}} + getItemId={(item: TestItem) => item.id} + /> + ) + expect(screen.getByText('No items found')).toBeInTheDocument() + }) + + it('shows custom empty message', () => { + render( + {item.name}} + getItemId={(item: TestItem) => item.id} + emptyMessage="Nothing here" + /> + ) + expect(screen.getByText('Nothing here')).toBeInTheDocument() + }) + + it('accepts className', () => { + const { container } = render( + {item.name}} + getItemId={(item) => item.id} + className="custom-class" + /> + ) + expect(container.firstChild).toHaveClass('custom-class') + }) +}) diff --git a/src/design-system/composites/EntityList/EntityList.tsx b/src/design-system/composites/EntityList/EntityList.tsx new file mode 100644 index 0000000..7f07ebe --- /dev/null +++ b/src/design-system/composites/EntityList/EntityList.tsx @@ -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 { + items: T[] + renderItem: (item: T, isSelected: boolean) => ReactNode + getItemId: (item: T) => string + selectedId?: string + onSelect?: (id: string) => void + searchPlaceholder?: string + onSearch?: (query: string) => void + addLabel?: string + onAdd?: () => void + emptyMessage?: string + className?: string +} + +export function EntityList({ + items, + renderItem, + getItemId, + selectedId, + onSelect, + searchPlaceholder = 'Search...', + onSearch, + addLabel, + onAdd, + emptyMessage = 'No items found', + className, +}: EntityListProps) { + const [searchValue, setSearchValue] = useState('') + const showHeader = !!onSearch || !!onAdd + + function handleSearchChange(e: React.ChangeEvent) { + const value = e.target.value + setSearchValue(value) + onSearch?.(value) + } + + function handleSearchClear() { + setSearchValue('') + onSearch?.('') + } + + return ( +
+ {showHeader && ( +
+ {onSearch && ( + + )} + {onAdd && addLabel && ( + + )} +
+ )} + +
+ {items.map((item) => { + const id = getItemId(item) + const isSelected = id === selectedId + return ( +
onSelect?.(id)} + role="option" + tabIndex={0} + aria-selected={isSelected} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect?.(id) + } + }} + > + {renderItem(item, isSelected)} +
+ ) + })} + {items.length === 0 && ( +
{emptyMessage}
+ )} +
+
+ ) +} diff --git a/src/design-system/composites/KpiStrip/KpiStrip.module.css b/src/design-system/composites/KpiStrip/KpiStrip.module.css new file mode 100644 index 0000000..0c94875 --- /dev/null +++ b/src/design-system/composites/KpiStrip/KpiStrip.module.css @@ -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; +} diff --git a/src/design-system/composites/KpiStrip/KpiStrip.test.tsx b/src/design-system/composites/KpiStrip/KpiStrip.test.tsx new file mode 100644 index 0000000..371bdd3 --- /dev/null +++ b/src/design-system/composites/KpiStrip/KpiStrip.test.tsx @@ -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() + const cards = container.querySelectorAll('[class*="kpiCard"]') + expect(cards).toHaveLength(3) + }) + + it('renders labels and values', () => { + render() + 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() + expect(screen.getByText('+3')).toBeInTheDocument() + }) + + it('applies variant class to trend (trendSuccess)', () => { + render() + const trend = screen.getByText('+3') + expect(trend.className).toContain('trendSuccess') + }) + + it('hides trend when omitted', () => { + render() + const { container } = render() + const trends = container.querySelectorAll('[class*="trend"]') + expect(trends).toHaveLength(0) + }) + + it('renders subtitle', () => { + render() + expect(screen.getByText('last 24h')).toBeInTheDocument() + }) + + it('renders sparkline when data provided', () => { + const { container } = render() + const svgs = container.querySelectorAll('svg') + expect(svgs.length).toBeGreaterThanOrEqual(1) + }) + + it('accepts className prop', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('custom') + }) + + it('handles empty items array', () => { + const { container } = render() + const cards = container.querySelectorAll('[class*="kpiCard"]') + expect(cards).toHaveLength(0) + }) + + it('uses default border color (--amber) when borderColor omitted', () => { + const { container } = render() + 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() + 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() + const trend = screen.getByText('0%') + expect(trend.className).toContain('trendMuted') + }) +}) diff --git a/src/design-system/composites/KpiStrip/KpiStrip.tsx b/src/design-system/composites/KpiStrip/KpiStrip.tsx new file mode 100644 index 0000000..76fd3ae --- /dev/null +++ b/src/design-system/composites/KpiStrip/KpiStrip.tsx @@ -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 = { + success: styles.trendSuccess, + warning: styles.trendWarning, + error: styles.trendError, + muted: styles.trendMuted, +} + +export function KpiStrip({ items, className }: KpiStripProps) { + const stripClasses = [styles.kpiStrip, className ?? ''].filter(Boolean).join(' ') + const gridStyle: CSSProperties = { + gridTemplateColumns: items.length > 0 ? `repeat(${items.length}, 1fr)` : undefined, + } + + return ( +
+ {items.map((item) => { + const borderColor = item.borderColor ?? 'var(--amber)' + const cardStyle: CSSProperties & Record = { + '--kpi-border-color': borderColor, + } + const trendVariant = item.trend?.variant ?? 'muted' + const trendClass = trendClassMap[trendVariant] ?? styles.trendMuted + + return ( +
+
{item.label}
+
+ {item.value} + {item.trend && ( + + {item.trend.label} + + )} +
+ {item.subtitle && ( +
{item.subtitle}
+ )} + {item.sparkline && item.sparkline.length >= 2 && ( +
+ +
+ )} +
+ ) + })} +
+ ) +} diff --git a/src/design-system/composites/LogViewer/LogViewer.module.css b/src/design-system/composites/LogViewer/LogViewer.module.css new file mode 100644 index 0000000..70a468c --- /dev/null +++ b/src/design-system/composites/LogViewer/LogViewer.module.css @@ -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); +} diff --git a/src/design-system/composites/LogViewer/LogViewer.test.tsx b/src/design-system/composites/LogViewer/LogViewer.test.tsx new file mode 100644 index 0000000..5f4e723 --- /dev/null +++ b/src/design-system/composites/LogViewer/LogViewer.test.tsx @@ -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() + 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() + 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() + const el = container.firstElementChild as HTMLElement + expect(el.style.maxHeight).toBe('300px') + }) + + it('renders with string maxHeight', () => { + const { container } = render() + const el = container.firstElementChild as HTMLElement + expect(el.style.maxHeight).toBe('50vh') + }) + + it('handles empty entries', () => { + render() + expect(screen.getByText('No log entries.')).toBeInTheDocument() + }) + + it('accepts className prop', () => { + const { container } = render() + const el = container.firstElementChild as HTMLElement + expect(el.classList.contains('custom-class')).toBe(true) + }) + + it('has role="log" for accessibility', () => { + render() + expect(screen.getByRole('log')).toBeInTheDocument() + }) +}) diff --git a/src/design-system/composites/LogViewer/LogViewer.tsx b/src/design-system/composites/LogViewer/LogViewer.tsx new file mode 100644 index 0000000..4839d76 --- /dev/null +++ b/src/design-system/composites/LogViewer/LogViewer.tsx @@ -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 = { + info: styles.levelInfo, + warn: styles.levelWarn, + error: styles.levelError, + debug: styles.levelDebug, +} + +function formatTime(iso: string): string { + try { + return new Date(iso).toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + } catch { + return iso + } +} + +export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) { + const scrollRef = useRef(null) + const isAtBottomRef = useRef(true) + + const handleScroll = useCallback(() => { + const el = scrollRef.current + if (!el) return + 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 ( +
+ {entries.map((entry, i) => ( +
+ {formatTime(entry.timestamp)} + + {entry.level.toUpperCase()} + + {entry.message} +
+ ))} + {entries.length === 0 && ( +
No log entries.
+ )} +
+ ) +} diff --git a/src/design-system/composites/SplitPane/SplitPane.module.css b/src/design-system/composites/SplitPane/SplitPane.module.css new file mode 100644 index 0000000..ab0840b --- /dev/null +++ b/src/design-system/composites/SplitPane/SplitPane.module.css @@ -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; +} diff --git a/src/design-system/composites/SplitPane/SplitPane.test.tsx b/src/design-system/composites/SplitPane/SplitPane.test.tsx new file mode 100644 index 0000000..f285376 --- /dev/null +++ b/src/design-system/composites/SplitPane/SplitPane.test.tsx @@ -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( + List items} + detail={
Detail content
} + /> + ) + expect(screen.getByText('List items')).toBeInTheDocument() + expect(screen.getByText('Detail content')).toBeInTheDocument() + }) + + it('shows default empty message when detail is null', () => { + render( + List items} + detail={null} + /> + ) + expect(screen.getByText('Select an item to view details')).toBeInTheDocument() + }) + + it('shows custom empty message', () => { + render( + List items} + 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( + List} + detail={
Detail
} + ratio="1:1" + /> + ) + const root = container.firstChild as HTMLElement + expect(root.style.getPropertyValue('--split-columns')).toBe('1fr 1fr') + + rerender( + List} + detail={
Detail
} + ratio="2:3" + /> + ) + expect(root.style.getPropertyValue('--split-columns')).toBe('2fr 3fr') + }) + + it('accepts className', () => { + const { container } = render( + List} + detail={
Detail
} + className="custom-class" + /> + ) + expect(container.firstChild).toHaveClass('custom-class') + }) +}) diff --git a/src/design-system/composites/SplitPane/SplitPane.tsx b/src/design-system/composites/SplitPane/SplitPane.tsx new file mode 100644 index 0000000..bbb68e6 --- /dev/null +++ b/src/design-system/composites/SplitPane/SplitPane.tsx @@ -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 = { + '1:1': '1fr 1fr', + '1:2': '1fr 2fr', + '2:3': '2fr 3fr', +} + +export function SplitPane({ + list, + detail, + emptyMessage = 'Select an item to view details', + ratio = '1:2', + className, +}: SplitPaneProps) { + return ( +
+
{list}
+
+ {detail !== null ? detail : ( +
{emptyMessage}
+ )} +
+
+ ) +} diff --git a/src/design-system/composites/index.ts b/src/design-system/composites/index.ts index 4a4ec9b..6578721 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -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' diff --git a/src/design-system/primitives/Card/Card.module.css b/src/design-system/primitives/Card/Card.module.css index aa9085b..6e88525 100644 --- a/src/design-system/primitives/Card/Card.module.css +++ b/src/design-system/primitives/Card/Card.module.css @@ -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; +} diff --git a/src/design-system/primitives/Card/Card.test.tsx b/src/design-system/primitives/Card/Card.test.tsx new file mode 100644 index 0000000..bff36e4 --- /dev/null +++ b/src/design-system/primitives/Card/Card.test.tsx @@ -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(Hello world) + expect(screen.getByText('Hello world')).toBeInTheDocument() + }) + + it('renders title when provided', () => { + render(Content) + expect(screen.getByText('Status')).toBeInTheDocument() + }) + + it('does not render title header when title is omitted', () => { + const { container } = render(Content) + expect(container.querySelector('h3')).toBeNull() + }) + + it('wraps children in body div when title is provided', () => { + render(Content) + const content = screen.getByText('Content') + expect(content.parentElement).toHaveClass('body') + }) + + it('renders with accent and title together', () => { + const { container } = render( + Content, + ) + 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(Content) + const card = container.firstChild as HTMLElement + expect(card).toHaveClass('custom') + }) + + it('renders children directly when no title (no wrapper div)', () => { + const { container } = render(Direct child) + const card = container.firstChild as HTMLElement + const span = screen.getByText('Direct child') + expect(span.parentElement).toBe(card) + }) +}) diff --git a/src/design-system/primitives/Card/Card.tsx b/src/design-system/primitives/Card/Card.tsx index e6656d4..1595e0e 100644 --- a/src/design-system/primitives/Card/Card.tsx +++ b/src/design-system/primitives/Card/Card.tsx @@ -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
{children}
+ return ( +
+ {title && ( +
+

{title}

+
+ )} + {title ?
{children}
: children} +
+ ) } diff --git a/src/design-system/primitives/StatusText/StatusText.module.css b/src/design-system/primitives/StatusText/StatusText.module.css new file mode 100644 index 0000000..20d2127 --- /dev/null +++ b/src/design-system/primitives/StatusText/StatusText.module.css @@ -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; } diff --git a/src/design-system/primitives/StatusText/StatusText.test.tsx b/src/design-system/primitives/StatusText/StatusText.test.tsx new file mode 100644 index 0000000..7b1bab7 --- /dev/null +++ b/src/design-system/primitives/StatusText/StatusText.test.tsx @@ -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(Online) + expect(screen.getByText('Online')).toBeInTheDocument() + }) + + it('renders as a span element', () => { + render(Status) + const el = screen.getByText('Status') + expect(el.tagName).toBe('SPAN') + }) + + it('applies variant class', () => { + render(Failed) + expect(screen.getByText('Failed')).toHaveClass('error') + }) + + it('applies bold class when bold=true', () => { + render(OK) + expect(screen.getByText('OK')).toHaveClass('bold') + }) + + it('does not apply bold class by default', () => { + render(OK) + expect(screen.getByText('OK')).not.toHaveClass('bold') + }) + + it('accepts custom className', () => { + render(Text) + 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( + {variant} + ) + expect(screen.getByText(variant)).toHaveClass(variant) + unmount() + } + }) +}) diff --git a/src/design-system/primitives/StatusText/StatusText.tsx b/src/design-system/primitives/StatusText/StatusText.tsx new file mode 100644 index 0000000..cc2798e --- /dev/null +++ b/src/design-system/primitives/StatusText/StatusText.tsx @@ -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 {children} +} diff --git a/src/design-system/primitives/index.ts b/src/design-system/primitives/index.ts index 23798e3..06cdb0b 100644 --- a/src/design-system/primitives/index.ts +++ b/src/design-system/primitives/index.ts @@ -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' diff --git a/src/pages/AgentHealth/AgentHealth.module.css b/src/pages/AgentHealth/AgentHealth.module.css index 322e6e2..f19bc9f 100644 --- a/src/pages/AgentHealth/AgentHealth.module.css +++ b/src/pages/AgentHealth/AgentHealth.module.css @@ -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; diff --git a/src/pages/AgentHealth/AgentHealth.tsx b/src/pages/AgentHealth/AgentHealth.tsx index 2b74538..54e9b03 100644 --- a/src/pages/AgentHealth/AgentHealth.tsx +++ b/src/pages/AgentHealth/AgentHealth.tsx @@ -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[] = useMemo(() => [ + { + key: 'status', + header: '', + width: '12px', + render: (_val, row) => ( + + ), + }, + { + key: 'name', + header: 'Instance', + render: (_val, row) => ( + {row.name} + ), + }, + { + key: 'state', + header: 'State', + render: (_val, row) => ( + + ), + }, + { + key: 'uptime', + header: 'Uptime', + render: (_val, row) => ( + {row.uptime} + ), + }, + { + key: 'tps', + header: 'TPS', + render: (_val, row) => ( + {row.tps.toFixed(1)}/s + ), + }, + { + key: 'errorRate', + header: 'Errors', + render: (_val, row) => ( + + {row.errorRate ?? '0 err/h'} + + ), + }, + { + key: 'lastSeen', + header: 'Heartbeat', + render: (_val, row) => ( + + {row.lastSeen} + + ), + }, + ], []) + function handleInstanceClick(inst: AgentHealthData) { setSelectedInstance(inst) setPanelOpen(true) @@ -362,65 +430,14 @@ export function AgentHealth() { ) : undefined} > - - - - - - - - - - - - - {group.instances.map((inst) => ( - handleInstanceClick(inst)} - > - - - - - - - - - ))} - -
- InstanceStateUptimeTPSErrorsHeartbeat
- - - {inst.name} - - - - {inst.uptime} - - {inst.tps.toFixed(1)}/s - - - {inst.errorRate ?? '0 err/h'} - - - - {inst.lastSeen} - -
+ + columns={instanceColumns} + data={group.instances} + onRowClick={handleInstanceClick} + selectedId={panelOpen ? selectedInstance?.id : undefined} + pageSize={50} + flush + /> ))} diff --git a/src/pages/Inventory/Inventory.tsx b/src/pages/Inventory/Inventory.tsx index 68e1022..ba94051 100644 --- a/src/pages/Inventory/Inventory.tsx +++ b/src/pages/Inventory/Inventory.tsx @@ -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' }, diff --git a/src/pages/Inventory/sections/CompositesSection.tsx b/src/pages/Inventory/sections/CompositesSection.tsx index dfde29f..3d938e2 100644 --- a/src/pages/Inventory/sections/CompositesSection.tsx +++ b/src/pages/Inventory/sections/CompositesSection.tsx @@ -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(['admin']) + // EntityList state + const [selectedEntityId, setSelectedEntityId] = useState('1') + const [entitySearch, setEntitySearch] = useState('') + // LoginDialog const [loginDialogOpen, setLoginDialogOpen] = useState(false) const [loginLoading, setLoginLoading] = useState(false) @@ -465,6 +527,38 @@ export function CompositesSection() { + {/* EntityList */} + +
+ + u.name.toLowerCase().includes(entitySearch.toLowerCase()) + )} + renderItem={(item, isSelected) => ( +
+ +
+
{item.name}
+
{item.email}
+
+ +
+ )} + getItemId={(item) => item.id} + selectedId={selectedEntityId} + onSelect={setSelectedEntityId} + searchPlaceholder="Search users..." + onSearch={setEntitySearch} + addLabel="+ Add user" + onAdd={() => {}} + /> +
+
+ {/* 11b. GroupCard */} + {/* KpiStrip */} + +
+ +
+
+ {/* 12. FilterBar */} + {/* LogViewer */} + +
+ +
+
+ {/* 14. MenuItem */} + {/* SplitPane */} + +
+ +
Items
+
Item A
+
Item B
+
Item C
+
+ } + detail={ +
+
Detail View
+
Select an item on the left to see its details here.
+
+ } + ratio="1:2" + /> + +
+ {/* 19. Tabs */}
Plain card
Amber accent
Success accent
Error accent
+ +
Card with title header and separator
+
+ +
Title + accent combined
+
{/* 6. Checkbox */} @@ -559,7 +566,31 @@ export function PrimitivesSection() { - {/* 29. Tag */} + {/* 29. StatusText */} + +
+
+ 99.8% uptime + SLA at risk + BREACH + Processing + N/A +
+
+ 99.8% uptime + SLA at risk + BREACH + Processing + N/A +
+
+
+ + {/* 30. Tag */} undefined} /> - {/* 30. Textarea */} + {/* 31. Textarea */} - {/* 31. Toggle */} + {/* 32. Toggle */} - {/* 32. Tooltip */} + {/* 33. Tooltip */}