diff --git a/apps/agent-forge/src/ui/MetricCard.tsx b/apps/agent-forge/src/ui/MetricCard.tsx
new file mode 100644
index 000000000..08b58ab9a
--- /dev/null
+++ b/apps/agent-forge/src/ui/MetricCard.tsx
@@ -0,0 +1,114 @@
+import type { ReactNode } from 'react';
+import { ArrowDown, ArrowUp, Minus } from 'lucide-react';
+import { Skeleton } from './Skeleton';
+import './metricCard.css';
+
+/** A change against a comparison window, shown under the value. */
+interface MetricDelta {
+ /** Signed change: positive is up, negative down, 0 no change. */
+ value: number;
+ /** Formats the magnitude (`Math.abs(value)`); the arrow and phrase carry the direction. Default `12%`. */
+ format?: (v: number) => string;
+ /** Which direction is good news (default `'up'`; `'down'` for errors, latency, cost). */
+ goodWhen?: 'up' | 'down';
+ /** The comparison, read after the change (default `'vs previous window'`). */
+ label?: string;
+}
+
+/** Props for {@link MetricCard}. */
+interface MetricCardProps {
+ /** Overline label ("Runs", "p95 latency"). */
+ label: string;
+ /** The metric, already formatted. */
+ value: ReactNode;
+ delta?: MetricDelta;
+ /** Recent values, oldest first, drawn as a tiny line; fewer than two points draw nothing. */
+ sparkline?: number[];
+ /** Muted line under the value ("last 24 h"). */
+ hint?: ReactNode;
+ /** Skeletons in place of the value, delta and sparkline. */
+ loading?: boolean;
+}
+
+const percent = (v: number) => `${v.toLocaleString('en-US', { maximumFractionDigits: 1 })}%`;
+
+/** good = success, bad = danger, neutral = muted (no change). */
+function toneOf(value: number, goodWhen: 'up' | 'down'): 'good' | 'bad' | 'neutral' {
+ if (value === 0) return 'neutral';
+ return value > 0 === (goodWhen === 'up') ? 'good' : 'bad';
+}
+
+function Delta({ value, format = percent, goodWhen = 'up', label = 'vs previous window' }: MetricDelta) {
+ const shown = format(Math.abs(value));
+ const Icon = value > 0 ? ArrowUp : value < 0 ? ArrowDown : Minus;
+ const spoken = value === 0 ? `no change ${label}` : `${value > 0 ? 'up' : 'down'} ${shown} ${label}`;
+ return (
+
+
+ {shown}
+ {spoken}
+
+ );
+}
+
+const SPARK_W = 96;
+const SPARK_H = 32;
+
+/** `points` for an SVG polyline spanning SPARK_W x SPARK_H (a flat series sits mid-height). */
+function sparkPoints(values: number[]): string {
+ const min = Math.min(...values);
+ const span = Math.max(...values) - min;
+ const step = SPARK_W / (values.length - 1);
+ return values
+ .map((v, i) => {
+ const y = span === 0 ? SPARK_H / 2 : SPARK_H - 2 - ((v - min) / span) * (SPARK_H - 4);
+ return `${(i * step).toFixed(1)},${y.toFixed(1)}`;
+ })
+ .join(' ');
+}
+
+function Sparkline({ values }: { values: number[] }) {
+ return (
+
+
+
+ );
+}
+
+/** One KPI tile: overline label, a 24px tabular value, optional delta, hint and sparkline. */
+export function MetricCard({ label, value, delta, sparkline, hint, loading }: MetricCardProps) {
+ // A NaN/Infinity change (0/0 against an empty window) shows no delta; non-finite points are dropped.
+ const showDelta = delta !== undefined && Number.isFinite(delta.value) && !loading;
+ const points = sparkline?.filter(Number.isFinite);
+ return (
+
+
+ {label}
+
+ {loading ? (
+ <>
+ Loading
+
+ >
+ ) : (
+ value
+ )}
+
+ {(showDelta || hint != null) && (
+
+ {showDelta && }
+ {hint != null && {hint} }
+
+ )}
+
+ {points && points.length >= 2 && !loading &&
}
+ {sparkline && loading &&
}
+
+ );
+}
diff --git a/apps/agent-forge/src/ui/Skeleton.tsx b/apps/agent-forge/src/ui/Skeleton.tsx
new file mode 100644
index 000000000..9b9021d10
--- /dev/null
+++ b/apps/agent-forge/src/ui/Skeleton.tsx
@@ -0,0 +1,88 @@
+import type { CSSProperties } from 'react';
+import './skeleton.css';
+
+/** A length: a number is pixels, a string is any CSS length (`'60%'`, `'1.5em'`). */
+type Length = number | string;
+
+const css = (v: Length | undefined) => (typeof v === 'number' ? `${v}px` : v);
+
+/** Props for {@link Skeleton}. */
+interface SkeletonProps {
+ width?: Length;
+ height?: Length;
+ /** Corner radius; defaults to `var(--radius-sm)`. */
+ radius?: string;
+ className?: string;
+}
+
+/**
+ * One shimmer block standing in for content that is still loading. Purely
+ * decorative (`aria-hidden`): a screen that shows only skeletons pairs them
+ * with {@link SkeletonRows} or its own status text.
+ */
+export function Skeleton({ width, height, radius = 'var(--radius-sm)', className }: SkeletonProps) {
+ const style: CSSProperties = { width: css(width), height: css(height), borderRadius: radius };
+ return
;
+}
+
+/** Cell widths cycled down the rows, so a block reads as ragged text rather than bars. */
+const WIDTHS = ['72%', '56%', '84%', '48%', '64%', '78%'];
+const widthAt = (row: number, col: number) => WIDTHS[(row * 2 + col) % WIDTHS.length];
+
+/** Props for {@link SkeletonRows}. */
+interface SkeletonRowsProps {
+ /** Number of rows (default 6). */
+ rows?: number;
+ /** Blocks per row (default 1). */
+ cols?: number;
+ /** `'tr'` renders table rows of `
`s for a ` `; `'div'` (default) a stack of flex rows. */
+ as?: 'div' | 'tr';
+ /** Extra class for the cell at column `index` (table alignment, e.g. `'dt-right'`). */
+ cellClassName?: (index: number) => string | undefined;
+}
+
+const Status = () => (
+
+ Loading
+
+);
+
+/** Table rows: the status sits in the first cell; the other rows are hidden from assistive tech. */
+function TableRows({ rows, cols, cellClassName }: Required>) {
+ return (
+ <>
+ {Array.from({ length: rows }, (_, r) => (
+ 0 ? true : undefined}>
+ {Array.from({ length: cols }, (_, c) => (
+
+ {r === 0 && c === 0 && }
+
+
+ ))}
+
+ ))}
+ >
+ );
+}
+
+/**
+ * A block of shimmer rows with ONE visually-hidden `role="status"` "Loading"
+ * text: the loading state for tables and lists. Static under reduced motion.
+ */
+export function SkeletonRows({ rows = 6, cols = 1, as = 'div', cellClassName = () => undefined }: SkeletonRowsProps) {
+ if (as === 'tr') return ;
+ return (
+
+
+ {Array.from({ length: rows }, (_, r) => (
+
+ {Array.from({ length: cols }, (_, c) => (
+
+
+
+ ))}
+
+ ))}
+
+ );
+}
diff --git a/apps/agent-forge/src/ui/TimeRangePicker.tsx b/apps/agent-forge/src/ui/TimeRangePicker.tsx
index f35443c93..673447d4d 100644
--- a/apps/agent-forge/src/ui/TimeRangePicker.tsx
+++ b/apps/agent-forge/src/ui/TimeRangePicker.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
+import { CalendarRange } from 'lucide-react';
import './filterBar.css';
/** Preset window names, shortest first. */
@@ -54,7 +55,10 @@ interface TimeRangePickerProps {
onChange: (next: TimeRange) => void;
}
-/** Controlled range picker: five preset buttons plus "Custom" with two datetime-local inputs and an Apply button. */
+/**
+ * Controlled range picker: a segmented control of five presets plus "Custom",
+ * which opens two datetime-local inputs and an Apply button.
+ */
export function TimeRangePicker({ value, onChange }: TimeRangePickerProps) {
const absolute = !('preset' in value);
const key = formatTimeRange(value);
@@ -74,42 +78,45 @@ export function TimeRangePicker({ value, onChange }: TimeRangePickerProps) {
return (
- {PRESETS.map((p) => (
+
+ {PRESETS.map((p) => (
+ {
+ setCustomOpen(false);
+ onChange({ preset: p });
+ }}
+ >
+ {p}
+
+ ))}
{
- setCustomOpen(false);
- onChange({ preset: p });
- }}
+ aria-pressed={absolute}
+ aria-expanded={customOpen}
+ onClick={() => setCustomOpen((o) => !o)}
>
- {p}
+
+ Custom
- ))}
- setCustomOpen((o) => !o)}
- >
- Custom
-
+
{customOpen && (
From
- setFrom(e.target.value)} />
+ setFrom(e.target.value)} />
To
- setTo(e.target.value)} />
+ setTo(e.target.value)} />
onChange({ from: fromMs, to: toMs })}
>
diff --git a/apps/agent-forge/src/ui/__tests__/Card.test.tsx b/apps/agent-forge/src/ui/__tests__/Card.test.tsx
new file mode 100644
index 000000000..af9ef4ce3
--- /dev/null
+++ b/apps/agent-forge/src/ui/__tests__/Card.test.tsx
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+
+import { Card } from '../Card';
+import { render } from './render';
+
+describe('Card', () => {
+ it('renders a section whose header title labels it, with description and actions', () => {
+ const c = render(
+ Refresh }>
+
body
+ ,
+ );
+ const card = c.querySelector('section.card') as HTMLElement;
+ const title = card.querySelector('.card-header h2.card-title') as HTMLElement;
+ expect(title.textContent).toBe('Fleet');
+ expect(title.id).not.toBe('');
+ expect(card.getAttribute('aria-labelledby')).toBe(title.id);
+ expect(card.querySelector('.card-description')?.textContent).toBe('Agents seen in the window');
+ expect(card.querySelector('.card-actions button')?.textContent).toBe('Refresh');
+ expect(card.querySelector('.card-body p')?.textContent).toBe('body');
+ expect(card.classList.contains('card-pad-md')).toBe(true);
+ });
+
+ it('renders no header without a title, and no aria-labelledby', () => {
+ const c = render(
+
+
+ ,
+ );
+ const card = c.querySelector('div.card') as HTMLElement;
+ expect(card.querySelector('.card-header')).toBeNull();
+ expect(card.hasAttribute('aria-labelledby')).toBe(false);
+ expect(card.classList.contains('card-pad-none')).toBe(true);
+ expect(card.classList.contains('extra')).toBe(true);
+ });
+
+ it('a titled div card keeps its header but is not labelled by it', () => {
+ const c = render(
+
+ x
+ ,
+ );
+ const card = c.querySelector('div.card') as HTMLElement;
+ expect(card.querySelector('.card-title')?.textContent).toBe('Plain');
+ expect(card.hasAttribute('aria-labelledby')).toBe(false);
+ });
+});
diff --git a/apps/agent-forge/src/ui/__tests__/DataTable.test.tsx b/apps/agent-forge/src/ui/__tests__/DataTable.test.tsx
index a863782a7..80b34fb35 100644
--- a/apps/agent-forge/src/ui/__tests__/DataTable.test.tsx
+++ b/apps/agent-forge/src/ui/__tests__/DataTable.test.tsx
@@ -3,7 +3,7 @@ import { resolve } from 'node:path';
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { DataTable, type Column, type TableSort } from '../DataTable';
+import { DataTable, ErrorCell, type Column, type TableSort } from '../DataTable';
import { render, rerender } from './render';
interface Row {
@@ -115,6 +115,36 @@ describe('DataTable rendering', () => {
expect(c.textContent).not.toContain('Nothing here');
});
+ it('shows six skeleton rows (one cell per visible column) and one Loading status on a first load', () => {
+ setWidth(1200);
+ const c = render(
r.id} loading empty="Nothing here" />);
+ const skeletonRows = c.querySelectorAll('tbody tr.skeleton-row');
+ expect(skeletonRows).toHaveLength(6);
+ expect(skeletonRows[0].querySelectorAll('td')).toHaveLength(3);
+ expect(skeletonRows[0].querySelectorAll('td')[1].classList.contains('dt-right')).toBe(true);
+ expect(c.querySelectorAll('tbody [role="status"]')).toHaveLength(1);
+ expect(c.querySelector('tbody [role="status"]')?.textContent).toBe('Loading');
+ expect(c.textContent).not.toContain('Loading…');
+ // Skeleton rows are not data rows: no row index, never focusable.
+ expect(c.querySelector('tbody tr[data-row-index]')).toBeNull();
+ setWidth(600);
+ expect(c.querySelectorAll('tbody tr.skeleton-row')[0].querySelectorAll('td')).toHaveLength(2);
+ });
+
+ it('keeps real rows (no skeleton) when loading with rows already shown', () => {
+ const c = render( r.id} loading empty="x" />);
+ expect(c.querySelector('.skeleton-row')).toBeNull();
+ });
+
+ it('titles plain-text cells so truncated text keeps a tooltip', () => {
+ setWidth(1200);
+ const c = render( r.id} empty="x" />);
+ const [name, tokens, id] = bodyRows(c)[0].querySelectorAll('td');
+ expect(name.title).toBe('alpha');
+ expect(tokens.title).toBe('10 tok');
+ expect(id.title).toBe('a');
+ });
+
it('applies the density class (compact default, comfortable on request)', () => {
const c = render( r.id} empty="x" />);
expect(c.querySelector('table')?.classList.contains('dt-compact')).toBe(true);
@@ -395,3 +425,81 @@ describe('DataTable paging', () => {
expect(btn('Previous').disabled).toBe(false);
});
});
+
+describe('ErrorCell', () => {
+ it('renders danger text with an aria-hidden 14px alert icon and a title tooltip', () => {
+ const c = render(upstream 529 overloaded );
+ const cell = c.querySelector('.dt-error') as HTMLElement;
+ expect(cell.textContent).toBe('upstream 529 overloaded');
+ expect(cell.title).toBe('upstream 529 overloaded');
+ const svg = cell.querySelector('svg') as SVGElement;
+ expect(svg.getAttribute('aria-hidden')).toBe('true');
+ expect(svg.getAttribute('width')).toBe('14');
+ expect(cell.querySelector('.dt-error-text')?.textContent).toBe('upstream 529 overloaded');
+ });
+
+ it('takes an explicit title for non-string content', () => {
+ const c = render(
+
+ boom
+ ,
+ );
+ expect((c.querySelector('.dt-error') as HTMLElement).title).toBe('boom: stack');
+ });
+});
+
+describe('DataTable layout (review fixes)', () => {
+ const css = (f: string) => readFileSync(resolve(__dirname, '..', f), 'utf8');
+ const rule = (src: string, sel: string) => src.match(new RegExp(`(^|\\n)${sel.replace(/[.[\]()*]/g, '\\$&')}\\s*\\{([^}]*)\\}`))?.[2] ?? '';
+
+ it('the wrap clips instead of scrolling, so the sticky header sticks to the screen', () => {
+ const table = css('dataTable.css');
+ expect(rule(table, '.data-table-wrap')).toMatch(/overflow-x:\s*clip/);
+ expect(rule(table, '.data-table-wrap')).toMatch(/min-height:\s*min-content/);
+ expect(rule(table, '.data-table-wrap.dt-scroll-x')).toMatch(/overflow-x:\s*auto/);
+ expect(rule(css('card.css'), '.card-pad-none > .card-body')).toMatch(/overflow:\s*clip/);
+ });
+
+ it('becomes a horizontal scroller (dt-scroll-x) only while the table is wider than the wrap', () => {
+ const offset = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800);
+ const client = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(800);
+ const c = render( r.id} empty="x" />);
+ const wrap = () => c.querySelector('.data-table-wrap') as HTMLElement;
+ expect(wrap().classList.contains('dt-scroll-x')).toBe(false);
+ offset.mockReturnValue(1200);
+ rerender( r.id} empty="y" />);
+ expect(wrap().classList.contains('dt-scroll-x')).toBe(true);
+ offset.mockReturnValue(800);
+ rerender( r.id} empty="z" />);
+ expect(wrap().classList.contains('dt-scroll-x')).toBe(false);
+ client.mockRestore();
+ });
+
+ it('maxHeight opts into a bounded scroller (both axes) whose header sticks inside it', () => {
+ const c = render( r.id} maxHeight={480} empty="x" />);
+ const wrap = c.querySelector('.data-table-wrap') as HTMLElement;
+ expect(wrap.classList.contains('dt-scroll')).toBe(true);
+ expect(wrap.style.maxHeight).toBe('480px');
+ rerender( r.id} maxHeight="calc(100dvh - 200px)" empty="x" />);
+ expect(wrap.style.maxHeight).toBe('calc(100dvh - 200px)');
+ expect(rule(css('dataTable.css'), '.data-table-wrap.dt-scroll')).toMatch(/overflow:\s*auto/);
+ rerender( r.id} empty="x" />);
+ expect(wrap.classList.contains('dt-scroll')).toBe(false);
+ expect(wrap.style.maxHeight).toBe('');
+ });
+
+ it('wraps header labels in .dt-label so they ellipsise inside the flex row', () => {
+ setWidth(1200);
+ const c = render( r.id} empty="x" />);
+ expect([...c.querySelectorAll('th .dt-label')].map((s) => s.textContent)).toEqual(['Name', 'Tokens', 'Id']);
+ expect(rule(css('dataTable.css'), '.dt-label')).toMatch(/text-overflow:\s*ellipsis/);
+ });
+
+ it('pager has no side padding outside an edge-to-edge card; compact buttons only in .dt-actions', () => {
+ const table = css('dataTable.css');
+ expect(rule(table, '.dt-pager')).toMatch(/padding:\s*var\(--space-2\)\s+0;/);
+ expect(rule(css('card.css'), '.card-pad-none .dt-pager')).toMatch(/padding-inline/);
+ expect(table).not.toMatch(/\.data-table td \.btn/);
+ expect(rule(table, '.dt-actions .btn')).toMatch(/min-height:\s*26px/);
+ });
+});
diff --git a/apps/agent-forge/src/ui/__tests__/FilterBar.test.tsx b/apps/agent-forge/src/ui/__tests__/FilterBar.test.tsx
index 0d26e4359..af5cdf738 100644
--- a/apps/agent-forge/src/ui/__tests__/FilterBar.test.tsx
+++ b/apps/agent-forge/src/ui/__tests__/FilterBar.test.tsx
@@ -1,3 +1,5 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
import { act } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -181,6 +183,39 @@ describe('TimeRangePicker via FilterBar', () => {
});
});
+describe('FilterBar look', () => {
+ it('presets and Custom sit in one segmented control; the group keeps its name', () => {
+ render( );
+ const group = container.querySelector('.time-range[role="group"]') as HTMLElement;
+ expect(group.getAttribute('aria-label')).toBe('Time range');
+ const seg = group.querySelector('.segmented') as HTMLElement;
+ expect([...seg.querySelectorAll('button.seg-btn')].map((b) => b.textContent)).toEqual(['15m', '1h', '24h', '7d', '30d', 'Custom']);
+ });
+
+ it('the search box carries a decorative search icon; facet buttons a chevron', () => {
+ const facet: FacetDef = { key: 's', label: 'Status', options: [], value: [], onChange: vi.fn() };
+ render( );
+ expect(container.querySelector('.filter-search-wrap svg')?.getAttribute('aria-hidden')).toBe('true');
+ expect(container.querySelector('input.filter-search')?.getAttribute('aria-label')).toBe('Search');
+ const btn = container.querySelector('button.facet-btn') as HTMLElement;
+ expect(btn.textContent).toBe('Status');
+ expect(btn.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true');
+ });
+});
+
+describe('TimeRangePicker Custom open state', () => {
+ it('Custom reads as open (aria-expanded) while its inputs show, and the CSS styles that state', () => {
+ render( );
+ const custom = byText('.time-range button', 'Custom');
+ expect(custom.getAttribute('aria-expanded')).toBe('false');
+ click(custom);
+ expect(custom.getAttribute('aria-expanded')).toBe('true');
+ expect(byText('.time-range button', '24h').getAttribute('aria-pressed')).toBe('true');
+ const css = readFileSync(resolve(__dirname, '../filterBar.css'), 'utf8');
+ expect(css).toMatch(/\.seg-btn\[aria-expanded='true'\]:not\(\[aria-pressed='true'\]\)\s*\{[^}]*background:\s*var\(--accent-soft\)/);
+ });
+});
+
describe('TimeRangePicker re-seeding', () => {
const inputs = () => [...container.querySelectorAll('input[type="datetime-local"]')] as HTMLInputElement[];
const ui = (value: TimeRange) => ;
diff --git a/apps/agent-forge/src/ui/__tests__/MetricCard.test.tsx b/apps/agent-forge/src/ui/__tests__/MetricCard.test.tsx
new file mode 100644
index 000000000..8a83d12a8
--- /dev/null
+++ b/apps/agent-forge/src/ui/__tests__/MetricCard.test.tsx
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest';
+
+import { MetricCard } from '../MetricCard';
+import { render } from './render';
+
+const delta = (c: HTMLElement) => c.querySelector('.metric-delta') as HTMLElement;
+const spoken = (c: HTMLElement) => delta(c).querySelector('.visually-hidden')?.textContent;
+const ms = (v: number) => `${v} ms`;
+
+describe('MetricCard', () => {
+ it('renders the label as an overline and the value as a tabular metric', () => {
+ const c = render( );
+ expect(c.querySelector('.metric-label')?.textContent).toBe('Runs');
+ expect(c.querySelector('.metric-label')?.classList.contains('overline')).toBe(true);
+ expect(c.querySelector('.metric-value')?.textContent).toBe('1,204');
+ expect(c.querySelector('.metric-value')?.classList.contains('tabular-nums')).toBe(true);
+ expect(c.querySelector('.metric-hint')?.textContent).toBe('last 24 h');
+ expect(delta(c)).toBeNull();
+ expect(c.querySelector('svg.metric-sparkline')).toBeNull();
+ });
+
+ it('an upward move is good by default: success tone, "up" phrase', () => {
+ const c = render( );
+ expect(delta(c).dataset.tone).toBe('good');
+ expect(delta(c).textContent).toContain('12%');
+ expect(spoken(c)).toBe('up 12% vs previous window');
+ });
+
+ it('a downward move is bad by default', () => {
+ const c = render( );
+ expect(delta(c).dataset.tone).toBe('bad');
+ expect(spoken(c)).toBe('down 4.5% vs previous window');
+ });
+
+ it('goodWhen "down" flips the tones (errors, latency)', () => {
+ const c = render( );
+ expect(delta(c).dataset.tone).toBe('good');
+ const up = render( );
+ expect(delta(up).dataset.tone).toBe('bad');
+ });
+
+ it('zero is neutral, and format / label shape the text', () => {
+ const c = render( );
+ expect(delta(c).dataset.tone).toBe('neutral');
+ expect(spoken(c)).toBe('no change vs yesterday');
+ const down = render( );
+ expect(spoken(down)).toBe('down 40 ms vs yesterday');
+ });
+
+ it('draws an aria-hidden sparkline polyline', () => {
+ const c = render( );
+ const svg = c.querySelector('svg.metric-sparkline') as SVGElement;
+ expect(svg.getAttribute('aria-hidden')).toBe('true');
+ const points = svg.querySelector('polyline')?.getAttribute('points')?.trim().split(' ');
+ expect(points).toHaveLength(4);
+ });
+
+ it('non-finite input: a NaN/Infinity delta shows no delta, non-finite sparkline points are dropped', () => {
+ const c = render( );
+ expect(delta(c)).toBeNull();
+ const points = c.querySelector('svg.metric-sparkline polyline')?.getAttribute('points')?.split(' ');
+ expect(points).toHaveLength(3);
+ expect(points?.join(' ')).not.toMatch(/NaN|Infinity/);
+ const inf = render( );
+ expect(delta(inf)).toBeNull();
+ expect(inf.querySelector('svg.metric-sparkline')).toBeNull();
+ });
+
+ it('skips a sparkline with fewer than two points', () => {
+ const c = render( );
+ expect(c.querySelector('svg.metric-sparkline')).toBeNull();
+ });
+
+ it('loading shows skeletons instead of the value, delta and sparkline', () => {
+ const c = render( );
+ expect(c.querySelector('.metric-value')?.textContent).not.toContain('1,204');
+ expect(c.querySelectorAll('.skeleton').length).toBeGreaterThanOrEqual(2);
+ expect(c.querySelector('svg.metric-sparkline')).toBeNull();
+ expect(delta(c)).toBeNull();
+ expect(c.querySelector('.metric-card')?.getAttribute('aria-busy')).toBe('true');
+ });
+});
diff --git a/apps/agent-forge/src/ui/__tests__/Skeleton.test.tsx b/apps/agent-forge/src/ui/__tests__/Skeleton.test.tsx
new file mode 100644
index 000000000..ba93edc39
--- /dev/null
+++ b/apps/agent-forge/src/ui/__tests__/Skeleton.test.tsx
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+
+import { Skeleton, SkeletonRows } from '../Skeleton';
+import { render } from './render';
+
+describe('Skeleton', () => {
+ it('is a decorative, aria-hidden block sized by number (px) or CSS string', () => {
+ const c = render( );
+ const el = c.querySelector('.skeleton') as HTMLElement;
+ expect(el.getAttribute('aria-hidden')).toBe('true');
+ expect(el.style.width).toBe('120px');
+ expect(el.style.height).toBe('1.5em');
+ expect(el.style.borderRadius).toBe('var(--radius-sm)');
+ expect(c.querySelector('[role="status"]')).toBeNull();
+ });
+
+ it('takes a radius and a className', () => {
+ const c = render( );
+ const el = c.querySelector('.skeleton') as HTMLElement;
+ expect(el.classList.contains('avatar')).toBe(true);
+ expect(el.style.borderRadius).toBe('var(--radius-full)');
+ });
+});
+
+describe('SkeletonRows', () => {
+ it('renders rows x cols aria-hidden blocks plus one visually-hidden "Loading" status', () => {
+ const c = render( );
+ expect(c.querySelectorAll('.skeleton-row')).toHaveLength(4);
+ expect(c.querySelectorAll('.skeleton')).toHaveLength(12);
+ for (const s of c.querySelectorAll('.skeleton')) expect(s.getAttribute('aria-hidden')).toBe('true');
+ const status = c.querySelectorAll('[role="status"]');
+ expect(status).toHaveLength(1);
+ expect(status[0].textContent).toBe('Loading');
+ expect(status[0].classList.contains('visually-hidden')).toBe(true);
+ });
+
+ it('defaults to six rows of one column', () => {
+ const c = render( );
+ expect(c.querySelectorAll('.skeleton-row')).toHaveLength(6);
+ expect(c.querySelectorAll('.skeleton')).toHaveLength(6);
+ });
+
+ it('renders table rows (one status in the first cell) when as="tr"', () => {
+ const c = render(
+
+
+ (i === 2 ? 'dt-right' : undefined)} />
+
+
,
+ );
+ const trs = c.querySelectorAll('tbody tr.skeleton-row');
+ expect(trs).toHaveLength(2);
+ expect(trs[0].querySelectorAll('td')).toHaveLength(3);
+ expect(trs[0].querySelectorAll('td')[2].classList.contains('dt-right')).toBe(true);
+ expect(c.querySelectorAll('[role="status"]')).toHaveLength(1);
+ expect(trs[0].querySelector('[role="status"]')?.textContent).toBe('Loading');
+ expect(trs[1].getAttribute('aria-hidden')).toBe('true');
+ });
+});
diff --git a/apps/agent-forge/src/ui/__tests__/primitives.test.tsx b/apps/agent-forge/src/ui/__tests__/primitives.test.tsx
index 57cea14c2..1a568b69c 100644
--- a/apps/agent-forge/src/ui/__tests__/primitives.test.tsx
+++ b/apps/agent-forge/src/ui/__tests__/primitives.test.tsx
@@ -1,4 +1,5 @@
import { act } from 'react';
+import { Search } from 'lucide-react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render as mount } from './render';
@@ -97,12 +98,28 @@ describe('StatusPill', () => {
describe('EmptyState', () => {
it('renders the title, hint, action and a decorative icon', () => {
- render(Run} icon={ } />);
+ render(Run} icon={Search} />);
const root = container.querySelector('.empty-state') as HTMLElement;
expect(root.querySelector('.empty-state-title')?.textContent).toBe('No traces yet');
expect(root.querySelector('.empty-state-hint')?.textContent).toBe('Run an agent.');
expect(root.querySelector('.empty-state-action button')?.textContent).toBe('Run');
expect(root.querySelector('.empty-state-icon')?.getAttribute('aria-hidden')).toBe('true');
+ const svg = root.querySelector('.empty-state-icon svg') as SVGElement;
+ expect(svg.classList.contains('lucide-search')).toBe(true);
+ expect(svg.getAttribute('width')).toBe('20');
+ expect(svg.getAttribute('stroke-width')).toBe('1.75');
+ });
+
+ it('defaults to the Inbox icon tile, and icon={null} renders no tile', () => {
+ render(
+ <>
+
+
+ >,
+ );
+ const [a, b] = [...container.querySelectorAll('.empty-state')];
+ expect(a.querySelector('.empty-state-icon svg')?.classList.contains('lucide-inbox')).toBe(true);
+ expect(b.querySelector('.empty-state-icon')).toBeNull();
});
it('accepts a hint without an action, and an action without a hint', () => {
diff --git a/apps/agent-forge/src/ui/card.css b/apps/agent-forge/src/ui/card.css
new file mode 100644
index 000000000..a47af10fb
--- /dev/null
+++ b/apps/agent-forge/src/ui/card.css
@@ -0,0 +1,65 @@
+/* Card: surface, hairline border, radius-lg, shadow-sm; optional header row. Tokens only. */
+.card {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+ color: var(--text);
+ font-family: var(--font-ui);
+}
+.card-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--space-2) var(--space-4);
+ padding: var(--space-4) var(--space-5) 0;
+}
+.card-heading {
+ min-width: 0;
+}
+.card-title {
+ margin: 0;
+ font-size: var(--fs-body);
+ font-weight: 600;
+ line-height: 1.4;
+ color: var(--text);
+}
+.card-description {
+ margin: 2px 0 0;
+ font-size: var(--fs-meta);
+ line-height: 1.5;
+ color: var(--text-muted);
+}
+.card-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+}
+.card-pad-md > .card-body {
+ padding: var(--space-4) var(--space-5);
+}
+/* edge to edge: a titled card still separates the header from the table with the table's own header. */
+.card-pad-none > .card-header {
+ padding-bottom: var(--space-3);
+}
+.card-pad-none > .card-body {
+ /* clip, not hidden: no scroll container, so a DataTable's sticky header still sticks. */
+ overflow: clip;
+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
+}
+.card-pad-none > .card-body:first-child {
+ border-radius: var(--radius-lg);
+}
+/* edge-to-edge tables: the pager keeps the card's inner gutter. */
+.card-pad-none .dt-pager {
+ padding-inline: var(--space-3);
+}
+/* a table that ends the card lets the card's own border close it. */
+.card-pad-none > .card-body > .data-table-wrap:last-child .data-table tbody tr:last-child > td {
+ border-bottom: 0;
+}
diff --git a/apps/agent-forge/src/ui/dataTable.css b/apps/agent-forge/src/ui/dataTable.css
index 14a9326ea..6c8018d80 100644
--- a/apps/agent-forge/src/ui/dataTable.css
+++ b/apps/agent-forge/src/ui/dataTable.css
@@ -1,13 +1,30 @@
-/* DataTable: fills its container, 13 px text, 32 px (compact) / 40 px (comfortable) rows. Tokens only. */
+/*
+ * DataTable: fills its container (a Card with padding="none" in the redesign),
+ * 13px text, sticky 36px header, 32px (compact) / 40px (comfortable) rows,
+ * hairline dividers, tabular right-aligned numbers. Tokens only.
+ */
.data-table-wrap {
width: 100%;
min-width: 0;
+ /* never squeezed below its rows by a height-limited flex column. */
+ min-height: min-content;
+ /* clip, not auto: a clipping wrap is no scroll container, so the sticky header sticks to the screen. */
+ overflow-x: clip;
+}
+/* the table is wider than the wrap (resized columns): scroll sideways instead; the header stops sticking. */
+.data-table-wrap.dt-scroll-x {
overflow-x: auto;
}
+/* maxHeight: a bounded scroller in both axes; the sticky header sticks inside it. */
+.data-table-wrap.dt-scroll {
+ overflow: auto;
+ min-height: 0;
+}
.data-table {
width: 100%;
table-layout: fixed;
- border-collapse: collapse;
+ border-collapse: separate;
+ border-spacing: 0;
font-family: var(--font-ui);
font-size: 13px;
color: var(--text);
@@ -21,43 +38,86 @@
overflow: hidden;
text-overflow: ellipsis;
}
-.dt-compact th,
+.data-table th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ height: 36px;
+ background: var(--surface-2);
+ color: var(--text-muted);
+ font-size: var(--fs-meta);
+ font-weight: 500;
+}
.dt-compact td {
height: 32px;
}
-.dt-comfortable th,
.dt-comfortable td {
height: 40px;
}
-.data-table th {
- position: relative;
- background: var(--surface-2);
- color: var(--text-muted);
- font-weight: 600;
+/* the first column names the row. */
+.data-table td:first-child {
+ font-weight: 500;
}
.data-table .dt-right {
text-align: right;
+ font-variant-numeric: tabular-nums;
}
+.dt-head,
.dt-sort {
display: inline-flex;
align-items: center;
- gap: 4px;
+ gap: var(--space-1);
+ width: 100%;
+ min-width: 0;
+}
+.dt-right .dt-head {
+ justify-content: flex-end;
+}
+/* the header label keeps the th ellipsis inside the flex row. */
+.dt-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.dt-right .dt-sort {
+ flex-direction: row-reverse;
+}
+.dt-sort {
padding: 0;
background: none;
border: 0;
+ border-radius: var(--radius-sm);
color: inherit;
font: inherit;
cursor: pointer;
}
+.dt-sort:hover,
+th[aria-sort="ascending"] .dt-sort,
+th[aria-sort="descending"] .dt-sort {
+ color: var(--text);
+}
.dt-sort:focus-visible,
-.dt-resize:focus-visible,
+.dt-resize:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
.dt-row-open:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.dt-sort-arrow {
+ flex: none;
color: var(--accent);
- font-size: 10px;
+}
+.dt-sort-idle {
+ color: var(--text-faint);
+ opacity: 0;
+ transition: opacity var(--dur-fast) var(--ease);
+}
+.dt-sort:hover .dt-sort-idle,
+.dt-sort:focus-visible .dt-sort-idle {
+ opacity: 1;
}
.dt-resize {
position: absolute;
@@ -68,8 +128,25 @@
cursor: col-resize;
touch-action: none;
}
-.dt-resize:hover {
- background: var(--accent-soft);
+.dt-resize::after {
+ content: "";
+ position: absolute;
+ top: 10px;
+ bottom: 10px;
+ right: 2px;
+ width: 1px;
+ background: var(--border);
+}
+.dt-resize:hover::after,
+.dt-resize:focus-visible::after {
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ right: 2px;
+ background: var(--accent);
+}
+.data-table tbody tr {
+ transition: background-color var(--dur-fast) var(--ease);
}
.dt-row-open {
cursor: pointer;
@@ -83,36 +160,62 @@
.dt-row-selected:focus {
background: var(--accent-soft);
}
-.data-table[aria-busy='true'] tbody {
+.dt-row-selected > td:first-child {
+ box-shadow: inset 2px 0 0 var(--accent);
+}
+.data-table[aria-busy="true"] tbody tr:not(.skeleton-row) {
opacity: 0.6;
}
+/* row actions (inside an element with class dt-actions) stay inside the row height. */
+.dt-actions .btn {
+ min-height: 26px;
+ padding: 0 8px;
+ font-size: 12px;
+ border-radius: var(--radius-sm);
+}
.data-table .dt-empty {
height: auto;
- padding: 24px 10px;
+ padding: 0 10px;
white-space: normal;
text-align: center;
+ font-weight: 400;
color: var(--text-muted);
}
+/* ErrorCell: danger text behind an alert icon, ellipsised. */
+.dt-error {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ color: var(--danger);
+ font-weight: 400;
+}
+.dt-error svg {
+ flex: none;
+}
+.dt-error-text {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
.dt-pager {
display: flex;
align-items: center;
justify-content: flex-end;
- gap: 8px;
- padding: 8px 0;
+ gap: var(--space-2);
+ padding: var(--space-2) 0;
font-family: var(--font-ui);
- font-size: 13px;
+ font-size: var(--fs-meta);
color: var(--text-muted);
}
-.dt-pager button {
- padding: 4px 10px;
- background: var(--surface);
- color: var(--text);
- border: 1px solid var(--border);
- border-radius: 6px;
- font: inherit;
- cursor: pointer;
+.dt-pager-range {
+ margin-right: var(--space-1);
+ font-variant-numeric: tabular-nums;
}
-.dt-pager button:disabled {
- opacity: 0.5;
- cursor: not-allowed;
+@media (prefers-reduced-motion: reduce) {
+ .dt-sort-idle,
+ .data-table tbody tr {
+ transition: none;
+ }
}
diff --git a/apps/agent-forge/src/ui/filterBar.css b/apps/agent-forge/src/ui/filterBar.css
index 8c8d7df5e..a9168b624 100644
--- a/apps/agent-forge/src/ui/filterBar.css
+++ b/apps/agent-forge/src/ui/filterBar.css
@@ -1,105 +1,243 @@
-/* FilterBar, facet popovers and TimeRangePicker. Tokens only; the bar wraps at narrow widths. */
+/* FilterBar, facet popovers and the TimeRangePicker segmented control. Tokens only; the bar wraps at narrow widths. */
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
- gap: 8px;
+ gap: var(--space-2);
min-width: 0;
}
-.filter-search {
+.filter-search-wrap {
+ position: relative;
+ display: flex;
flex: 1 1 200px;
min-width: 0;
- padding: 6px 10px;
+}
+.filter-search-icon {
+ position: absolute;
+ top: 50%;
+ left: 10px;
+ transform: translateY(-50%);
+ color: var(--text-faint);
+ pointer-events: none;
+}
+.filter-search {
+ flex: 1 1 auto;
+ min-width: 0;
+ height: 32px;
+ padding: 0 10px 0 32px;
background: var(--surface);
color: var(--text);
- border: 1px solid var(--border);
- border-radius: 6px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
font-family: var(--font-ui);
+ font-size: 13px;
+ transition:
+ border-color var(--dur-fast) var(--ease),
+ box-shadow var(--dur-fast) var(--ease);
+}
+.filter-search::placeholder {
+ color: var(--text-faint);
+ opacity: 1;
+}
+.filter-search:focus {
+ outline: 2px solid transparent;
+ outline-offset: -1px;
+ border-color: var(--accent);
+ box-shadow: var(--ring);
}
.facet {
position: relative;
}
-.facet-btn,
-.seg-btn,
-.facet-clear {
- padding: 6px 10px;
+.facet-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 32px;
+ padding: 0 10px 0 12px;
background: var(--surface);
color: var(--text);
- border: 1px solid var(--border);
- border-radius: 6px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-sm);
font-family: var(--font-ui);
+ font-size: 13px;
+ font-weight: 500;
cursor: pointer;
+ transition: background-color var(--dur-fast) var(--ease);
}
-.seg-btn[aria-pressed='true'] {
- background: var(--accent-soft);
- color: var(--accent);
+.facet-btn:hover {
+ background: var(--surface-2);
+}
+.facet-btn[aria-expanded='true'] {
+ background: var(--surface-2);
border-color: var(--accent);
}
-.seg-btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
+.facet-chevron {
+ color: var(--text-faint);
+ transition: transform var(--dur-fast) var(--ease);
+}
+.facet-btn[aria-expanded='true'] .facet-chevron {
+ transform: rotate(180deg);
}
.facet-count {
- margin-left: 6px;
- padding: 0 6px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
background: var(--accent-soft);
color: var(--accent);
- border-radius: 8px;
- font-size: 12px;
+ border-radius: var(--radius-full);
+ font-size: 11px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
}
.facet-pop {
position: absolute;
z-index: 20;
top: calc(100% + 4px);
left: 0;
- min-width: 180px;
+ min-width: 200px;
max-width: calc(100vw - 32px);
max-height: 280px;
overflow: auto;
- padding: 6px;
+ padding: var(--space-1);
background: var(--surface);
border: 1px solid var(--border);
- border-radius: 8px;
+ border-radius: var(--radius);
box-shadow: var(--shadow);
}
.facet-opt {
display: flex;
align-items: center;
- gap: 8px;
- padding: 4px 6px;
+ gap: var(--space-2);
+ min-height: 32px;
+ padding: 0 var(--space-2);
+ border-radius: var(--radius-sm);
color: var(--text);
+ font-size: 13px;
cursor: pointer;
}
+.facet-opt:hover {
+ background: var(--surface-2);
+}
+.facet-opt input {
+ margin: 0;
+ accent-color: var(--accent-solid);
+}
.facet-opt-count {
margin-left: auto;
- color: var(--text-muted);
- font-family: var(--font-mono);
+ color: var(--text-faint);
font-size: 12px;
+ font-variant-numeric: tabular-nums;
}
.facet-clear {
width: 100%;
- margin-top: 4px;
+ min-height: 30px;
+ margin-top: var(--space-1);
+ padding: 0 var(--space-2);
+ background: none;
color: var(--text-muted);
+ border: 0;
+ border-top: 1px solid var(--border);
+ border-radius: 0 0 var(--radius-sm) var(--radius-sm);
+ font-family: var(--font-ui);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+}
+.facet-clear:hover {
+ color: var(--text);
+ background: var(--surface-2);
}
+.facet-btn:focus-visible,
+.facet-clear:focus-visible,
+.seg-btn:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+/* ---------- TimeRangePicker: segmented control + custom range row ---------- */
.time-range {
display: flex;
flex-wrap: wrap;
align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+}
+.segmented {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 2px;
+ padding: 2px;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+}
+.seg-btn {
+ display: inline-flex;
+ align-items: center;
gap: 4px;
+ height: 26px;
+ padding: 0 10px;
+ background: transparent;
+ color: var(--text-muted);
+ border: 0;
+ border-radius: var(--radius-sm);
+ font-family: var(--font-ui);
+ font-size: 12px;
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+ cursor: pointer;
+ transition:
+ background-color var(--dur-fast) var(--ease),
+ color var(--dur-fast) var(--ease);
+}
+.seg-btn:hover {
+ color: var(--text);
+}
+/* Custom while its inputs are open and no absolute range is applied yet. */
+.seg-btn[aria-expanded='true']:not([aria-pressed='true']) {
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+.seg-btn[aria-pressed='true'] {
+ background: var(--surface);
+ color: var(--text);
+ box-shadow:
+ var(--shadow-sm),
+ 0 0 0 1px var(--border);
}
.time-range-custom {
display: flex;
flex-wrap: wrap;
align-items: center;
- gap: 8px;
+ gap: var(--space-2);
color: var(--text-muted);
+ font-size: 12px;
+ font-weight: 500;
}
-.time-range-custom input {
- margin-left: 4px;
- padding: 4px 6px;
- background: var(--surface);
- color: var(--text);
- border: 1px solid var(--border);
- border-radius: 6px;
- font-family: var(--font-ui);
+.time-range-custom label {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+.time-range-custom .input {
+ width: auto;
+ min-height: 28px;
+ height: 28px;
+ padding: 0 8px;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ color-scheme: inherit;
+}
+@media (prefers-reduced-motion: reduce) {
+ .filter-search,
+ .facet-btn,
+ .facet-chevron,
+ .seg-btn {
+ transition: none;
+ }
}
diff --git a/apps/agent-forge/src/ui/metricCard.css b/apps/agent-forge/src/ui/metricCard.css
new file mode 100644
index 000000000..fc406205e
--- /dev/null
+++ b/apps/agent-forge/src/ui/metricCard.css
@@ -0,0 +1,75 @@
+/* MetricCard: a KPI tile (overline label, 24px tabular value, delta, sparkline). Tokens only. */
+.metric-card {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--space-3);
+ min-width: 0;
+ padding: var(--space-4) var(--space-5);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+ font-family: var(--font-ui);
+}
+.metric-main {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ min-width: 0;
+}
+.metric-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.metric-value {
+ display: flex;
+ align-items: center;
+ min-height: 32px;
+ font-size: var(--fs-metric);
+ font-weight: 600;
+ line-height: 1.3;
+ letter-spacing: -0.01em;
+ color: var(--text);
+ white-space: nowrap;
+}
+.metric-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-1) var(--space-2);
+ font-size: var(--fs-meta);
+ color: var(--text-muted);
+}
+.metric-delta {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+ color: var(--text-muted);
+}
+.metric-delta[data-tone="good"] {
+ color: var(--success);
+}
+.metric-delta[data-tone="bad"] {
+ color: var(--danger);
+}
+.metric-sparkline,
+.metric-spark-skeleton {
+ flex: none;
+ width: 96px;
+ height: 32px;
+}
+.metric-sparkline {
+ overflow: visible;
+}
+.metric-sparkline polyline {
+ fill: none;
+ stroke: var(--chart-1);
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ vector-effect: non-scaling-stroke;
+}
diff --git a/apps/agent-forge/src/ui/primitives.css b/apps/agent-forge/src/ui/primitives.css
index 8430b8163..fcdeac814 100644
--- a/apps/agent-forge/src/ui/primitives.css
+++ b/apps/agent-forge/src/ui/primitives.css
@@ -36,30 +36,39 @@
display: flex;
flex-direction: column;
align-items: center;
- gap: 8px;
- padding: 32px 16px;
+ gap: var(--space-2);
+ padding: var(--space-10) var(--space-4);
text-align: center;
color: var(--text-muted);
font-family: var(--font-ui);
}
.empty-state-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ margin-bottom: var(--space-1);
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
color: var(--text-muted);
- font-size: 24px;
- line-height: 1;
}
.empty-state-title {
margin: 0;
color: var(--text);
font-size: 14px;
font-weight: 600;
+ line-height: 1.4;
}
.empty-state-hint {
margin: 0;
font-size: 13px;
+ line-height: 1.5;
max-width: 48ch;
}
.empty-state-action {
- margin-top: 4px;
+ margin-top: var(--space-2);
}
/* ---------- Toast ---------- */
diff --git a/apps/agent-forge/src/ui/skeleton.css b/apps/agent-forge/src/ui/skeleton.css
new file mode 100644
index 000000000..c46b8c998
--- /dev/null
+++ b/apps/agent-forge/src/ui/skeleton.css
@@ -0,0 +1,48 @@
+/* Skeleton + SkeletonRows: shimmer blocks (surface-2 -> surface-3), static under reduced motion. Tokens only. */
+.skeleton {
+ display: block;
+ width: 100%;
+ height: 12px;
+ max-width: 100%;
+ background-color: var(--surface-2);
+ background-image: linear-gradient(90deg, var(--surface-2) 0%, var(--surface-3) 50%, var(--surface-2) 100%);
+ background-size: 200% 100%;
+ animation: skeleton-shimmer 1.4s var(--ease) infinite;
+}
+@keyframes skeleton-shimmer {
+ from {
+ background-position: 100% 0;
+ }
+ to {
+ background-position: -100% 0;
+ }
+}
+.skeleton-rows {
+ display: flex;
+ flex-direction: column;
+}
+.skeleton-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+ min-height: 32px;
+}
+.skeleton-cell {
+ display: flex;
+ flex: 1 1 0;
+ min-width: 0;
+}
+/* table rows keep their table layout; right-aligned cells push the block right. */
+tr.skeleton-row {
+ display: table-row;
+}
+.skeleton-cell.dt-right .skeleton,
+td.dt-right > .skeleton {
+ margin-left: auto;
+}
+@media (prefers-reduced-motion: reduce) {
+ .skeleton {
+ animation: none;
+ background-image: none;
+ }
+}