From f00dc797f252eb42e2a9a21be259e7139c1e8033 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:12:20 +0100 Subject: [PATCH 1/8] feat: add StatusText primitive with semantic color variants Inline component supporting success, warning, error, running, and muted variants with optional bold styling. Includes 7 unit tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../StatusText/StatusText.module.css | 7 +++ .../primitives/StatusText/StatusText.test.tsx | 47 +++++++++++++++++++ .../primitives/StatusText/StatusText.tsx | 20 ++++++++ src/design-system/primitives/index.ts | 1 + 4 files changed, 75 insertions(+) create mode 100644 src/design-system/primitives/StatusText/StatusText.module.css create mode 100644 src/design-system/primitives/StatusText/StatusText.test.tsx create mode 100644 src/design-system/primitives/StatusText/StatusText.tsx 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' From c89c1630680c1e59ad668b92de269bfccc0420d0 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:14:08 +0100 Subject: [PATCH 2/8] feat(Card): add optional title prop with uppercase monospace header When a title string is provided, renders an uppercase monospace h3 header with a subtle border separator above the card body. Children are wrapped in a padded body div when title is present; without title, children render directly as before (no breaking change). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../primitives/Card/Card.module.css | 19 +++++++ .../primitives/Card/Card.test.tsx | 49 +++++++++++++++++++ src/design-system/primitives/Card/Card.tsx | 14 +++++- 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 src/design-system/primitives/Card/Card.test.tsx 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} +
+ ) } From 22c098f9b6c8545c6b4e24cb92094a47e1184290 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:17:33 +0100 Subject: [PATCH 3/8] feat: add KpiStrip composite for horizontal metric card rows Horizontal grid of KPI cards with labels, values, trend indicators, subtitles, and optional sparklines. Uses CSS custom property for per-card accent border color. 12 tests included. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../composites/KpiStrip/KpiStrip.module.css | 79 +++++++++++++++++ .../composites/KpiStrip/KpiStrip.test.tsx | 86 +++++++++++++++++++ .../composites/KpiStrip/KpiStrip.tsx | 71 +++++++++++++++ src/design-system/composites/index.ts | 2 + 4 files changed, 238 insertions(+) create mode 100644 src/design-system/composites/KpiStrip/KpiStrip.module.css create mode 100644 src/design-system/composites/KpiStrip/KpiStrip.test.tsx create mode 100644 src/design-system/composites/KpiStrip/KpiStrip.tsx 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/index.ts b/src/design-system/composites/index.ts index 4a4ec9b..7fe8060 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -14,6 +14,8 @@ export { DetailPanel } from './DetailPanel/DetailPanel' 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' From 5fe7752b4609403ae2b5171ca06ca90878310805 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:20:53 +0100 Subject: [PATCH 4/8] feat: add SplitPane composite for two-column list/detail layouts Two-column grid layout with configurable ratio (1:1, 1:2, 2:3), list/detail slots, and empty state message. Uses CSS custom property for dynamic grid columns. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../composites/SplitPane/SplitPane.module.css | 37 ++++++++++ .../composites/SplitPane/SplitPane.test.tsx | 69 +++++++++++++++++++ .../composites/SplitPane/SplitPane.tsx | 38 ++++++++++ src/design-system/composites/index.ts | 1 + 4 files changed, 145 insertions(+) create mode 100644 src/design-system/composites/SplitPane/SplitPane.module.css create mode 100644 src/design-system/composites/SplitPane/SplitPane.test.tsx create mode 100644 src/design-system/composites/SplitPane/SplitPane.tsx 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 7fe8060..08890f2 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -34,6 +34,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' From 4abf80144e48ed90d283bd750db0b12335b8906c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:23:56 +0100 Subject: [PATCH 5/8] feat: add EntityList composite for searchable, selectable item lists Generic list component with render props for item content, search input, add button, selection highlighting, and keyboard navigation support. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EntityList/EntityList.module.css | 49 +++++ .../composites/EntityList/EntityList.test.tsx | 167 ++++++++++++++++++ .../composites/EntityList/EntityList.tsx | 97 ++++++++++ src/design-system/composites/index.ts | 1 + 4 files changed, 314 insertions(+) create mode 100644 src/design-system/composites/EntityList/EntityList.module.css create mode 100644 src/design-system/composites/EntityList/EntityList.test.tsx create mode 100644 src/design-system/composites/EntityList/EntityList.tsx 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/index.ts b/src/design-system/composites/index.ts index 08890f2..5e457a1 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -11,6 +11,7 @@ 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' From 8c1c9532599a5327f2839af46b886a0bb0a35890 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:27:03 +0100 Subject: [PATCH 6/8] feat: add LogViewer composite for timestamped, severity-colored log display Scrollable log viewer with auto-scroll behavior, level badges (info/warn/ error/debug) with semantic colors, monospace font, and role="log" for accessibility. Includes 7 tests and barrel exports. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../composites/LogViewer/LogViewer.module.css | 75 ++++++++++++++++++ .../composites/LogViewer/LogViewer.test.tsx | 56 ++++++++++++++ .../composites/LogViewer/LogViewer.tsx | 77 +++++++++++++++++++ src/design-system/composites/index.ts | 2 + 4 files changed, 210 insertions(+) create mode 100644 src/design-system/composites/LogViewer/LogViewer.module.css create mode 100644 src/design-system/composites/LogViewer/LogViewer.test.tsx create mode 100644 src/design-system/composites/LogViewer/LogViewer.tsx 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/index.ts b/src/design-system/composites/index.ts index 5e457a1..6578721 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -20,6 +20,8 @@ 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' From 08bac437f71a1c9b439e7bbb5c002ccb6aa748b4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:29:45 +0100 Subject: [PATCH 7/8] refactor: replace raw HTML tables in AgentHealth with DataTable composite Replace hand-rolled // markup in the AgentHealth page with the existing DataTable composite, using column definitions with custom render functions for StatusDot, Badge, and MonoText cells. Uses flush prop for seamless GroupCard integration and pageSize=50 to avoid pagination. Removes unused table-specific CSS classes (.instanceTable, .instanceRow, .thStatus, .tdStatus, .instanceRowActive, .instanceCountBadge). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pages/AgentHealth/AgentHealth.module.css | 66 --------- src/pages/AgentHealth/AgentHealth.tsx | 135 +++++++++++-------- 2 files changed, 76 insertions(+), 125 deletions(-) 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 + /> ))} From 80678a0d6136060f59f27c86275ab52ef5fd08b9 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:35:11 +0100 Subject: [PATCH 8/8] docs: add COMPONENT_GUIDE entries and Inventory demos for KpiStrip, SplitPane, EntityList, LogViewer, StatusText, Card title Co-Authored-By: Claude Opus 4.6 (1M context) --- COMPONENT_GUIDE.md | 22 ++- src/pages/Inventory/Inventory.tsx | 5 + .../Inventory/sections/CompositesSection.tsx | 145 +++++++++++++++++- .../Inventory/sections/PrimitivesSection.tsx | 41 ++++- 4 files changed, 205 insertions(+), 8 deletions(-) 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/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 */}