From 69306fc872b52f0d8ec2c57ada9ec2bd7fda6292 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 08:29:20 +0300 Subject: [PATCH 1/4] feat(studio): Skeleton, Card and MetricCard kit components Skeleton is an aria-hidden shimmer block (static under reduced motion); SkeletonRows renders a block of them (div rows or table rows) with one visually-hidden "Loading" status. Card is the shared panel with an optional title/description/actions header that labels a section. MetricCard is a KPI tile: overline label, 24px tabular value, a delta toned by goodWhen with a spoken "up 12% vs previous window" phrase, and an aria-hidden --chart-1 sparkline. Each ships its own CSS file. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/contrast.test.ts | 2 + apps/agent-forge/src/ui/Card.tsx | 44 +++++++ apps/agent-forge/src/ui/MetricCard.tsx | 112 ++++++++++++++++++ apps/agent-forge/src/ui/Skeleton.tsx | 88 ++++++++++++++ .../src/ui/__tests__/Card.test.tsx | 47 ++++++++ .../src/ui/__tests__/MetricCard.test.tsx | 71 +++++++++++ .../src/ui/__tests__/Skeleton.test.tsx | 59 +++++++++ apps/agent-forge/src/ui/card.css | 60 ++++++++++ apps/agent-forge/src/ui/metricCard.css | 75 ++++++++++++ apps/agent-forge/src/ui/skeleton.css | 48 ++++++++ 10 files changed, 606 insertions(+) create mode 100644 apps/agent-forge/src/ui/Card.tsx create mode 100644 apps/agent-forge/src/ui/MetricCard.tsx create mode 100644 apps/agent-forge/src/ui/Skeleton.tsx create mode 100644 apps/agent-forge/src/ui/__tests__/Card.test.tsx create mode 100644 apps/agent-forge/src/ui/__tests__/MetricCard.test.tsx create mode 100644 apps/agent-forge/src/ui/__tests__/Skeleton.test.tsx create mode 100644 apps/agent-forge/src/ui/card.css create mode 100644 apps/agent-forge/src/ui/metricCard.css create mode 100644 apps/agent-forge/src/ui/skeleton.css diff --git a/apps/agent-forge/src/__tests__/contrast.test.ts b/apps/agent-forge/src/__tests__/contrast.test.ts index fa6dd19f4..d6b270505 100644 --- a/apps/agent-forge/src/__tests__/contrast.test.ts +++ b/apps/agent-forge/src/__tests__/contrast.test.ts @@ -64,6 +64,8 @@ const PAIRS: [text: string, background: string][] = [ ['on-accent', 'accent-solid-hover'], // A switched-on chip (Requires approval) draws --accent on the accent tint. ['accent', 'accent-soft'], + // MetricCard: a good delta draws --success on the card's --surface (a bad one --danger, listed above). + ['success', 'surface'], ]; describe('theme contrast', () => { diff --git a/apps/agent-forge/src/ui/Card.tsx b/apps/agent-forge/src/ui/Card.tsx new file mode 100644 index 000000000..6e7bd5c77 --- /dev/null +++ b/apps/agent-forge/src/ui/Card.tsx @@ -0,0 +1,44 @@ +import { useId, type ReactNode } from 'react'; +import './card.css'; + +/** Props for {@link Card}. */ +interface CardProps { + /** Header title (an h2). With `as='section'` it also labels the card. */ + title?: ReactNode; + /** Muted line under the title. */ + description?: ReactNode; + /** Right side of the header row (buttons, a range picker). */ + actions?: ReactNode; + /** `'md'` (default) pads the body; `'none'` lets a DataTable run edge to edge. */ + padding?: 'md' | 'none'; + className?: string; + /** `'section'` (default) is a region labelled by its title; `'div'` a plain box. */ + as?: 'section' | 'div'; + children?: ReactNode; +} + +/** + * The Studio's panel: surface, hairline border, large radius, a soft lift, + * and an optional header row (title + description left, actions right). + */ +export function Card({ title, description, actions, padding = 'md', className, as: Tag = 'section', children }: CardProps) { + const titleId = useId(); + const labelled = Tag === 'section' && title != null; + const classes = ['card', `card-pad-${padding}`, className].filter(Boolean).join(' '); + return ( + + {title != null && ( +
+
+

+ {title} +

+ {description != null &&

{description}

} +
+ {actions != null &&
{actions}
} +
+ )} +
{children}
+
+ ); +} diff --git a/apps/agent-forge/src/ui/MetricCard.tsx b/apps/agent-forge/src/ui/MetricCard.tsx new file mode 100644 index 000000000..e266f6012 --- /dev/null +++ b/apps/agent-forge/src/ui/MetricCard.tsx @@ -0,0 +1,112 @@ +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 ( + + + ); +} + +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) { + const showDelta = delta !== undefined && !loading; + return ( +
+
+ {label} + + {loading ? ( + <> + Loading + + + ) : ( + value + )} + + {(showDelta || hint != null) && ( + + {showDelta && } + {hint != null && {hint}} + + )} +
+ {sparkline && sparkline.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