diff --git a/apps/ui/src/components/Tooltip.tsx b/apps/ui/src/components/Tooltip.tsx
index f1eae4c9..518e729b 100644
--- a/apps/ui/src/components/Tooltip.tsx
+++ b/apps/ui/src/components/Tooltip.tsx
@@ -1,34 +1,7 @@
-import React, { useState } from 'react';
-import { motion, AnimatePresence } from 'motion/react';
-
-interface TooltipProps {
- text: string;
- children: React.ReactNode;
-}
-
-export function Tooltip({ text, children }: TooltipProps) {
- const [isVisible, setIsVisible] = useState(false);
-
- return (
-
setIsVisible(true)}
- onMouseLeave={() => setIsVisible(false)}
- >
- {children}
-
- {isVisible && (
-
- {text}
-
-
- )}
-
-
- );
-}
+// Re-export shim. The Tooltip implementation moved to ./ui/Tooltip.tsx
+// (Radix-backed, accessible, with collision detection). The original
+// custom + Framer Motion implementation has been removed.
+//
+// Callers must wrap their tree in (exported from
+// ./ui/Tooltip) once at the app root before mounting any .
+export { Tooltip, TooltipProvider, type TooltipProps } from './ui/Tooltip';
diff --git a/apps/ui/src/components/ui/ChainSeparator.stories.tsx b/apps/ui/src/components/ui/ChainSeparator.stories.tsx
new file mode 100644
index 00000000..10f3eb75
--- /dev/null
+++ b/apps/ui/src/components/ui/ChainSeparator.stories.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { ChainSeparator } from './ChainSeparator';
+
+const meta: Meta = {
+ title: 'UI/ChainSeparator',
+ component: ChainSeparator,
+ args: { tone: 'active', animated: true, orientation: 'horizontal' },
+ argTypes: {
+ tone: { control: 'radio', options: ['idle', 'active', 'success'] },
+ orientation: { control: 'radio', options: ['horizontal', 'vertical'] },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Horizontal: Story = {
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const Vertical: Story = {
+ render: () => (
+
+ UP
+
+ DOWN
+
+ ),
+};
+
+export const Tones: Story = {
+ render: () => (
+
+ {(['idle', 'active', 'success'] as const).map((tone) => (
+
+
+ {tone}
+
+
+
+ ))}
+
+ ),
+};
diff --git a/apps/ui/src/components/ui/ChainSeparator.tsx b/apps/ui/src/components/ui/ChainSeparator.tsx
new file mode 100644
index 00000000..6ea0fc84
--- /dev/null
+++ b/apps/ui/src/components/ui/ChainSeparator.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+
+import { cn } from './cn';
+
+export type SignalTone = 'idle' | 'active' | 'success';
+
+export interface ChainSeparatorProps extends React.HTMLAttributes {
+ tone?: SignalTone;
+ animated?: boolean;
+ orientation?: 'horizontal' | 'vertical';
+}
+
+export const ChainSeparator = React.forwardRef(
+ function ChainSeparator(
+ { tone = 'idle', animated = false, orientation = 'horizontal', className, ...rest },
+ ref,
+ ) {
+ if (orientation === 'vertical') {
+ // Vertical chain: cable is a thin vertical line, arrow points down.
+ const cableTone =
+ tone === 'active'
+ ? 'bg-[color:var(--color-signal-cable)]'
+ : tone === 'success'
+ ? 'bg-[color:var(--color-signal-cable-success)]'
+ : 'bg-[color:var(--color-signal-cable-idle)]';
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ },
+);
diff --git a/apps/ui/src/components/ui/DataTable.stories.tsx b/apps/ui/src/components/ui/DataTable.stories.tsx
new file mode 100644
index 00000000..f9547960
--- /dev/null
+++ b/apps/ui/src/components/ui/DataTable.stories.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { DataTable } from './DataTable';
+import { Pill } from './Pill';
+
+const meta: Meta = {
+ title: 'UI/DataTable',
+ component: DataTable as React.ComponentType,
+};
+
+export default meta;
+type Story = StoryObj;
+
+interface Row {
+ band: string;
+ energy: string;
+ target: string;
+ delta: string;
+ status: 'success' | 'warning' | 'error';
+}
+
+const rows: Row[] = [
+ { band: 'Sub bass', energy: '-14.2 dB', target: '-12.0', delta: '-2.2', status: 'warning' },
+ { band: 'Low mids', energy: '-9.1 dB', target: '-10.0', delta: '+0.9', status: 'success' },
+ { band: 'Mids', energy: '-8.4 dB', target: '-8.0', delta: '-0.4', status: 'success' },
+ { band: 'High mids', energy: '-11.3 dB', target: '-9.0', delta: '-2.3', status: 'warning' },
+ { band: 'Highs', energy: '-15.7 dB', target: '-13.0', delta: '-2.7', status: 'error' },
+];
+
+export const Default: Story = {
+ render: () => (
+
+
+ data={rows}
+ columns={[
+ { key: 'band', label: 'Band', textRole: 'item-title' },
+ { key: 'energy', label: 'Energy', align: 'right', monospace: true, textRole: 'value' },
+ { key: 'target', label: 'Target', align: 'right', monospace: true, textRole: 'meta' },
+ { key: 'delta', label: 'Δ', align: 'right', monospace: true, textRole: 'value' },
+ {
+ key: 'status',
+ label: 'Status',
+ align: 'right',
+ render: (row) => (
+
+ {row.status}
+
+ ),
+ },
+ ]}
+ />
+
+ ),
+};
diff --git a/apps/ui/src/components/ui/DataTable.tsx b/apps/ui/src/components/ui/DataTable.tsx
new file mode 100644
index 00000000..0739049e
--- /dev/null
+++ b/apps/ui/src/components/ui/DataTable.tsx
@@ -0,0 +1,108 @@
+import React from 'react';
+
+import {
+ formatDisplayText,
+ getTextRoleClassName,
+ type DisplayTextCase,
+ type TextRole,
+} from '../../utils/displayText';
+import { cn } from './cn';
+
+export interface DataTableColumn {
+ key: string;
+ label: string;
+ align?: 'left' | 'right';
+ monospace?: boolean;
+ displayCase?: DisplayTextCase;
+ textRole?: TextRole;
+ render?: (row: T) => React.ReactNode;
+}
+
+export interface DataTableProps {
+ data: T[];
+ columns: DataTableColumn[];
+ rowClassName?: (row: T, index: number) => string;
+ className?: string;
+}
+
+export function DataTable({
+ data,
+ columns,
+ rowClassName,
+ className,
+}: DataTableProps) {
+ return (
+
+
+
+
+ {columns.map((column) => (
+
+ {formatDisplayText(column.label, 'eyebrow')}
+
+ ))}
+
+
+
+ {data.map((row, rowIndex) => (
+
+ {columns.map((column) => {
+ const rendered = column.render
+ ? column.render(row)
+ : ((row as Record)[column.key] as React.ReactNode);
+ const textRole = column.textRole ?? 'body';
+ const isPrimitive =
+ typeof rendered === 'string' || typeof rendered === 'number';
+ return (
+
+ {isPrimitive ? (
+
+ {typeof rendered === 'string'
+ ? formatDisplayText(rendered, column.displayCase ?? 'none')
+ : rendered}
+
+ ) : (
+ rendered
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/ui/DeviceRack.stories.tsx b/apps/ui/src/components/ui/DeviceRack.stories.tsx
new file mode 100644
index 00000000..1bdf02a4
--- /dev/null
+++ b/apps/ui/src/components/ui/DeviceRack.stories.tsx
@@ -0,0 +1,159 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { X, RotateCcw } from 'lucide-react';
+
+import { DeviceRack } from './DeviceRack';
+import { Button } from './Button';
+import { MetricTile } from './MetricTile';
+import { Pill } from './Pill';
+import { SignalChain } from './SignalChain';
+
+const meta: Meta = {
+ title: 'UI/DeviceRack',
+ component: DeviceRack,
+ args: {
+ name: 'MEASUREMENT',
+ subtitle: '· 4–5 min',
+ status: 'idle',
+ },
+ argTypes: {
+ status: {
+ control: 'radio',
+ options: ['idle', 'active', 'success', 'warning', 'error'],
+ },
+ density: { control: 'radio', options: ['normal', 'dense'] },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// === 01 — Live 12 Vocabulary anchor: empty shell ===
+export const EmptyShell: Story = {
+ name: '01 · Empty Shell',
+ render: () => (
+
+
+
+ The bare Live 12 device chrome: title strip + LED + body.
+
+
+
+ ),
+};
+
+// === 02 — Live 12 Vocabulary anchor: with parameter grid ===
+export const WithParameterGrid: Story = {
+ name: '02 · With Parameter Grid',
+ render: () => (
+
+ ),
+};
+
+// === 03 — Live 12 Vocabulary anchor: in a signal chain ===
+export const InChain: Story = {
+ name: '03 · In Chain',
+ render: () => (
+
+
2:14,
+ },
+ {
+ key: 'pitch',
+ name: 'PITCH/NOTE',
+ status: 'active',
+ statusLabel: 'RUNNING',
+ parameter: 62% ,
+ },
+ {
+ key: 'interpret',
+ name: 'INTERPRET',
+ status: 'queued',
+ statusLabel: 'WAITING',
+ },
+ ]}
+ />
+
+ ),
+};
+
+// === 04 — Live 12 Vocabulary anchor: active state progression ===
+export const ActiveStateProgression: Story = {
+ name: '04 · Active State Progression',
+ render: () => (
+
+ {(['idle', 'active', 'success', 'error'] as const).map((status) => (
+
+
+ box-shadow + LED reflect the status state.
+
+
+ ))}
+
+ ),
+};
+
+export const WithAction: Story = {
+ render: () => (
+
+
}>
+ Stop
+
+ }
+ >
+
+ Title-strip action slot. Button stays on the strip; body remains scrollable.
+
+
+
+ ),
+};
+
+export const WithSignalRail: Story = {
+ render: () => (
+
+
62%}
+ >
+
+
+ Stem analysis in progress
+
+ }>
+ Retry
+
+
+
+
+ ),
+};
diff --git a/apps/ui/src/components/ui/DeviceRack.tsx b/apps/ui/src/components/ui/DeviceRack.tsx
new file mode 100644
index 00000000..d964664d
--- /dev/null
+++ b/apps/ui/src/components/ui/DeviceRack.tsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from './cn';
+import { LedIndicator } from './LedIndicator';
+import type { SignalTone } from './ChainSeparator';
+
+const rackVariants = cva('device-rack', {
+ variants: {
+ status: {
+ idle: '',
+ active: 'device-rack--active',
+ success: 'device-rack--success',
+ warning: 'device-rack--warning',
+ error: 'device-rack--error',
+ },
+ },
+ defaultVariants: { status: 'idle' },
+});
+
+const bodyVariants = cva('device-rack__body', {
+ variants: {
+ density: {
+ normal: '',
+ dense: 'device-rack__body--dense',
+ },
+ },
+ defaultVariants: { density: 'normal' },
+});
+
+type RackStatus = 'idle' | 'active' | 'success' | 'warning' | 'error';
+
+function statusToLedTone(
+ status: RackStatus,
+): 'idle' | 'active' | 'success' | 'warning' | 'error' {
+ return status;
+}
+
+export interface DeviceRackProps
+ extends Omit, 'title'>,
+ VariantProps {
+ /** Title-strip text (e.g. "MEASUREMENT"). */
+ name: React.ReactNode;
+ /** Optional subtitle after the LED dot ("· 4–5 min"). */
+ subtitle?: React.ReactNode;
+ /** Right-aligned slot on the title strip (action button, count, etc.). */
+ action?: React.ReactNode;
+ /** Bottom signal-flow rail: where input arrives from. */
+ signalIn?: SignalTone | null;
+ /** Bottom signal-flow rail: where output flows to. */
+ signalOut?: SignalTone | null;
+ /** Free-form right-side content for the rail (e.g. a progress percent). */
+ railContent?: React.ReactNode;
+ /** Hide the title strip entirely (rare — for inset sub-racks). */
+ hideTitleStrip?: boolean;
+ /** Body density. Dense uses smaller vertical padding for parameter grids. */
+ density?: VariantProps['density'];
+ children?: React.ReactNode;
+}
+
+export const DeviceRack = React.forwardRef(
+ function DeviceRack(
+ {
+ name,
+ subtitle,
+ action,
+ status = 'idle',
+ signalIn,
+ signalOut,
+ railContent,
+ hideTitleStrip,
+ density,
+ className,
+ children,
+ ...rest
+ },
+ ref,
+ ) {
+ const resolvedStatus: RackStatus = (status ?? 'idle') as RackStatus;
+ const showRail = signalIn != null || signalOut != null || railContent != null;
+
+ return (
+
+ {!hideTitleStrip && (
+
+
+
{name}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+ {action &&
{action}
}
+
+ )}
+
{children}
+ {showRail && (
+
+ {signalIn != null && (
+
+
+ in
+
+ )}
+ {railContent && {railContent} }
+ {signalOut != null && (
+
+ out
+
+
+ )}
+
+ )}
+
+ );
+ },
+);
+
+export { rackVariants as deviceRackVariants };
diff --git a/apps/ui/src/components/ui/EmptyState.stories.tsx b/apps/ui/src/components/ui/EmptyState.stories.tsx
new file mode 100644
index 00000000..e86433a3
--- /dev/null
+++ b/apps/ui/src/components/ui/EmptyState.stories.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { AlertTriangle, FileQuestion, Music2 } from 'lucide-react';
+
+import { EmptyState } from './EmptyState';
+import { Button } from './Button';
+
+const meta: Meta = {
+ title: 'UI/EmptyState',
+ component: EmptyState,
+ args: {
+ tone: 'neutral',
+ padding: 'md',
+ title: 'No data yet',
+ description: 'Run an analysis to populate this panel.',
+ },
+ argTypes: {
+ tone: { control: 'radio', options: ['neutral', 'warning', 'error'] },
+ padding: { control: 'radio', options: ['sm', 'md', 'lg'] },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+ } />
+
+ ),
+};
+
+export const Tones: Story = {
+ render: () => (
+
+ }
+ title="No data yet"
+ description="Run an analysis to populate this panel."
+ />
+ }
+ title="Low confidence"
+ description="Pitch estimate could not be locked. Treat as approximate."
+ />
+ }
+ title="Analysis failed"
+ description="The pipeline returned an error. Retry or upload a different file."
+ />
+
+ ),
+};
+
+export const WithAction: Story = {
+ render: () => (
+
+ }
+ title="Session Musician off"
+ description="Enable pitch/note translation to populate this panel."
+ action={
+
+ Re-analyze with pitch/note
+
+ }
+ />
+
+ ),
+};
diff --git a/apps/ui/src/components/ui/EmptyState.tsx b/apps/ui/src/components/ui/EmptyState.tsx
new file mode 100644
index 00000000..e57fe53a
--- /dev/null
+++ b/apps/ui/src/components/ui/EmptyState.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from './cn';
+
+const emptyStateVariants = cva(
+ 'flex flex-col items-center justify-center gap-2 rounded-sm border text-center',
+ {
+ variants: {
+ tone: {
+ neutral: 'border-border-light bg-bg-card/30 text-text-secondary',
+ warning: 'border-warning/30 bg-warning/5 text-warning',
+ error: 'border-error/30 bg-error/5 text-error',
+ },
+ padding: {
+ sm: 'p-3',
+ md: 'p-4',
+ lg: 'p-6',
+ },
+ },
+ defaultVariants: { tone: 'neutral', padding: 'md' },
+ },
+);
+
+export interface EmptyStateProps
+ extends Omit, 'title'>,
+ VariantProps {
+ icon?: React.ReactNode;
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ action?: React.ReactNode;
+}
+
+export const EmptyState = React.forwardRef(
+ function EmptyState(
+ { className, tone, padding, icon, title, description, action, children, ...rest },
+ ref,
+ ) {
+ return (
+
+ {icon &&
{icon}
}
+ {title && (
+
+ {title}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+ {children}
+ {action &&
{action}
}
+
+ );
+ },
+);
+
+export { emptyStateVariants };
diff --git a/apps/ui/src/components/ui/MetricBar.stories.tsx b/apps/ui/src/components/ui/MetricBar.stories.tsx
new file mode 100644
index 00000000..9f6cd481
--- /dev/null
+++ b/apps/ui/src/components/ui/MetricBar.stories.tsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { MetricBar } from './MetricBar';
+
+const meta: Meta = {
+ title: 'UI/MetricBar',
+ component: MetricBar,
+ args: { value: 0.6, min: 0, max: 1, glow: true },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const Tones: Story = {
+ render: () => (
+
+
+
+
+
+
+ ),
+};
+
+export const WithLabels: Story = {
+ render: () => (
+
+
+
+ ),
+};
diff --git a/apps/ui/src/components/ui/MetricBar.tsx b/apps/ui/src/components/ui/MetricBar.tsx
new file mode 100644
index 00000000..77ed7b91
--- /dev/null
+++ b/apps/ui/src/components/ui/MetricBar.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+
+import { cn } from './cn';
+
+export interface MetricBarProps extends Omit, 'color'> {
+ value: number | null | undefined;
+ min?: number;
+ max?: number;
+ /** Pre-computed percent override (0-100). Overrides value/min/max. */
+ percent?: number;
+ /** Any valid CSS color. Defaults to the accent token. */
+ color?: string;
+ glow?: boolean;
+ leftLabel?: string;
+ rightLabel?: string;
+ /** Tailwind height class for the bar track (e.g. 'h-2'). */
+ heightClassName?: string;
+}
+
+function clamp(value: number, lo: number, hi: number): number {
+ return Math.max(lo, Math.min(hi, value));
+}
+
+function resolvePercent({
+ value,
+ min = 0,
+ max = 1,
+ percent,
+}: {
+ value: number | null | undefined;
+ min?: number;
+ max?: number;
+ percent?: number;
+}): number {
+ if (typeof percent === 'number' && Number.isFinite(percent)) {
+ return clamp(percent, 0, 100);
+ }
+ if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
+ if (max === min) return 0;
+ return clamp(((value - min) / (max - min)) * 100, 0, 100);
+}
+
+export const MetricBar = React.forwardRef(function MetricBar(
+ {
+ value,
+ min = 0,
+ max = 1,
+ percent,
+ color = 'var(--color-accent)',
+ glow = false,
+ leftLabel,
+ rightLabel,
+ heightClassName = 'h-2',
+ className,
+ ...rest
+ },
+ ref,
+) {
+ const width = resolvePercent({ value, min, max, percent });
+
+ return (
+
+
+ {(leftLabel || rightLabel) && (
+
+ {leftLabel ?? ''}
+ {rightLabel ?? ''}
+
+ )}
+
+ );
+});
diff --git a/apps/ui/src/components/ui/MetricBarRow.stories.tsx b/apps/ui/src/components/ui/MetricBarRow.stories.tsx
new file mode 100644
index 00000000..6f70be53
--- /dev/null
+++ b/apps/ui/src/components/ui/MetricBarRow.stories.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { MetricBarRow } from './MetricBarRow';
+
+const meta: Meta = {
+ title: 'UI/MetricBarRow',
+ component: MetricBarRow,
+ args: {
+ label: 'Integrated loudness',
+ valueLabel: '-14.2 LUFS',
+ value: -14.2,
+ min: -23,
+ max: -6,
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const Stack: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
diff --git a/apps/ui/src/components/ui/MetricBarRow.tsx b/apps/ui/src/components/ui/MetricBarRow.tsx
new file mode 100644
index 00000000..b7e7fb6e
--- /dev/null
+++ b/apps/ui/src/components/ui/MetricBarRow.tsx
@@ -0,0 +1,75 @@
+import React from 'react';
+
+import { formatDisplayText, getTextRoleClassName } from '../../utils/displayText';
+import { cn } from './cn';
+import { MetricBar } from './MetricBar';
+
+export interface MetricBarRowProps extends Omit, 'color'> {
+ label: string;
+ valueLabel: React.ReactNode;
+ value: number | null | undefined;
+ min?: number;
+ max?: number;
+ percent?: number;
+ color?: string;
+ sparkline?: React.ReactNode;
+ leftLabel?: string;
+ rightLabel?: string;
+ monospaceValue?: boolean;
+}
+
+export const MetricBarRow = React.forwardRef(
+ function MetricBarRow(
+ {
+ label,
+ valueLabel,
+ value,
+ min,
+ max,
+ percent,
+ color,
+ sparkline,
+ leftLabel,
+ rightLabel,
+ monospaceValue = true,
+ className,
+ ...rest
+ },
+ ref,
+ ) {
+ return (
+
+
+
+ {formatDisplayText(label, 'eyebrow')}
+
+
+ {sparkline && {sparkline} }
+
+ {valueLabel}
+
+
+
+
+
+ );
+ },
+);
diff --git a/apps/ui/src/components/ui/MetricTile.stories.tsx b/apps/ui/src/components/ui/MetricTile.stories.tsx
new file mode 100644
index 00000000..29063e87
--- /dev/null
+++ b/apps/ui/src/components/ui/MetricTile.stories.tsx
@@ -0,0 +1,121 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Music, AudioWaveform } from 'lucide-react';
+
+import { MetricTile } from './MetricTile';
+import { Pill } from './Pill';
+
+const meta: Meta = {
+ title: 'UI/MetricTile',
+ component: MetricTile,
+ args: {
+ label: 'TEMPO',
+ value: '126',
+ unit: 'BPM',
+ size: 'lg',
+ accent: 'accent',
+ },
+ argTypes: {
+ accent: {
+ control: 'radio',
+ options: ['none', 'accent', 'success', 'warning', 'error', 'neutral'],
+ },
+ size: { control: 'radio', options: ['sm', 'md', 'lg', 'xl'] },
+ status: {
+ control: 'radio',
+ options: [undefined, 'idle', 'active', 'success', 'warning', 'error'],
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const MeasurementSummary: Story = {
+ render: () => (
+
+
+
+
+
+
+ ),
+};
+
+export const SizeMatrix: Story = {
+ render: () => (
+
+ {(['sm', 'md', 'lg', 'xl'] as const).map((size) => (
+
+ ))}
+
+ ),
+};
+
+export const WithHeaderRight: Story = {
+ render: () => (
+
+ MEASURED}
+ />
+
+ ),
+};
+
+export const WithIconAndFooter: Story = {
+ render: () => (
+
+ }
+ accent="success"
+ footer={
+
+ High confidence · 0.92
+
+ }
+ />
+
+ ),
+};
+
+export const Stack: Story = {
+ render: () => (
+
+ }
+ accent="accent"
+ />
+ }
+ accent="success"
+ />
+ }
+ accent="warning"
+ />
+
+ ),
+};
diff --git a/apps/ui/src/components/ui/MetricTile.tsx b/apps/ui/src/components/ui/MetricTile.tsx
new file mode 100644
index 00000000..b74c28ad
--- /dev/null
+++ b/apps/ui/src/components/ui/MetricTile.tsx
@@ -0,0 +1,115 @@
+import React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from './cn';
+import { LedIndicator } from './LedIndicator';
+
+const tileVariants = cva(
+ 'flex flex-col gap-2 rounded-sm border border-border-light bg-bg-surface-dark p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]',
+ {
+ variants: {
+ accent: {
+ none: '',
+ accent: 'border-l-2 border-l-accent',
+ success: 'border-l-2 border-l-success',
+ warning: 'border-l-2 border-l-warning',
+ error: 'border-l-2 border-l-error',
+ neutral: 'border-l-2 border-l-border-light',
+ },
+ },
+ defaultVariants: { accent: 'none' },
+ },
+);
+
+const valueSizeClass: Record, string> = {
+ sm: 'text-base',
+ md: 'text-xl',
+ lg: 'text-2xl',
+ xl: 'text-3xl',
+};
+
+const unitSizeClass: Record, string> = {
+ sm: 'text-[9px]',
+ md: 'text-[10px]',
+ lg: 'text-[11px]',
+ xl: 'text-xs',
+};
+
+export interface MetricTileProps
+ extends Omit, 'children'>,
+ VariantProps {
+ label: React.ReactNode;
+ value: React.ReactNode;
+ unit?: React.ReactNode;
+ icon?: React.ReactNode;
+ /** Optional LED indicator on the header row. */
+ status?: 'idle' | 'active' | 'success' | 'warning' | 'error';
+ /** Right-aligned slot on the header row (badges, source pill, etc.). */
+ headerRight?: React.ReactNode;
+ /** Optional content below the value (sparkline, confidence band, etc.). */
+ footer?: React.ReactNode;
+ size?: 'sm' | 'md' | 'lg' | 'xl';
+}
+
+export const MetricTile = React.forwardRef(
+ function MetricTile(
+ {
+ label,
+ value,
+ unit,
+ icon,
+ status,
+ headerRight,
+ footer,
+ size = 'md',
+ accent,
+ className,
+ ...rest
+ },
+ ref,
+ ) {
+ const resolvedSize = size ?? 'md';
+ return (
+
+
+
+ {status && }
+ {icon && {icon} }
+
+ {label}
+
+
+ {headerRight &&
{headerRight}
}
+
+
+
+ {value}
+
+ {unit && (
+
+ {unit}
+
+ )}
+
+ {footer &&
{footer}
}
+
+ );
+ },
+);
+
+export { tileVariants as metricTileVariants };
diff --git a/apps/ui/src/components/ui/SignalChain.stories.tsx b/apps/ui/src/components/ui/SignalChain.stories.tsx
new file mode 100644
index 00000000..22e7f863
--- /dev/null
+++ b/apps/ui/src/components/ui/SignalChain.stories.tsx
@@ -0,0 +1,146 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { RotateCcw } from 'lucide-react';
+
+import { SignalChain, type SignalStageStatus } from './SignalChain';
+import { Pill } from './Pill';
+import { Button } from './Button';
+import { TimeReadout } from './TimeReadout';
+
+const meta: Meta = {
+ title: 'UI/SignalChain',
+ component: SignalChain,
+};
+
+export default meta;
+type Story = StoryObj;
+
+const baseStages = (
+ measure: SignalStageStatus,
+ pitch: SignalStageStatus,
+ interpret: SignalStageStatus,
+) => [
+ {
+ key: 'measure',
+ name: 'MEASURE',
+ status: measure,
+ parameter:
+ measure === 'success' ? (
+
+ ) : measure === 'active' ? (
+
+ ) : null,
+ statusLabel:
+ measure === 'success'
+ ? 'DONE'
+ : measure === 'active'
+ ? 'RUNNING'
+ : measure === 'error'
+ ? 'FAILED'
+ : measure === 'queued'
+ ? 'QUEUED'
+ : 'WAITING',
+ },
+ {
+ key: 'pitch',
+ name: 'PITCH/NOTE',
+ status: pitch,
+ parameter:
+ pitch === 'active' ? (
+ 62%
+ ) : pitch === 'success' ? (
+ 4 stems
+ ) : null,
+ statusLabel:
+ pitch === 'success'
+ ? 'DONE'
+ : pitch === 'active'
+ ? 'RUNNING'
+ : pitch === 'error'
+ ? 'FAILED'
+ : pitch === 'queued'
+ ? 'QUEUED'
+ : 'WAITING',
+ action:
+ pitch === 'error' ? (
+ }>
+ Retry
+
+ ) : undefined,
+ },
+ {
+ key: 'interpret',
+ name: 'INTERPRET',
+ status: interpret,
+ parameter:
+ interpret === 'success' ? (
+ Gemini 2.5
+ ) : null,
+ statusLabel:
+ interpret === 'success'
+ ? 'DONE'
+ : interpret === 'active'
+ ? 'RUNNING'
+ : interpret === 'error'
+ ? 'FAILED'
+ : interpret === 'queued'
+ ? 'QUEUED'
+ : 'WAITING',
+ },
+];
+
+export const AllIdle: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const Measuring: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const PitchActive: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const InterpretActive: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const AllComplete: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const PitchFailed: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const Vertical: Story = {
+ render: () => (
+
+
+
+ ),
+};
diff --git a/apps/ui/src/components/ui/SignalChain.tsx b/apps/ui/src/components/ui/SignalChain.tsx
new file mode 100644
index 00000000..889d6d65
--- /dev/null
+++ b/apps/ui/src/components/ui/SignalChain.tsx
@@ -0,0 +1,124 @@
+import React from 'react';
+
+import { cn } from './cn';
+import { ChainSeparator, type SignalTone } from './ChainSeparator';
+import { DeviceRack } from './DeviceRack';
+
+export type SignalStageStatus =
+ | 'idle'
+ | 'queued'
+ | 'active'
+ | 'success'
+ | 'warning'
+ | 'error';
+
+export interface SignalStage {
+ key: string;
+ name: React.ReactNode;
+ status: SignalStageStatus;
+ /** Optional content rendered inside the stage's device body. */
+ parameter?: React.ReactNode;
+ /** Optional STATUS label rendered inside the stage (e.g. "RUNNING"). */
+ statusLabel?: React.ReactNode;
+ /** Right-aligned slot on the stage device's title strip. */
+ action?: React.ReactNode;
+ /** Optional subtitle next to the device name. */
+ subtitle?: React.ReactNode;
+}
+
+export interface SignalChainProps extends React.HTMLAttributes {
+ stages: SignalStage[];
+ orientation?: 'horizontal' | 'vertical';
+ /** Animate cables between active stages. */
+ animated?: boolean;
+}
+
+function stageStatusToRack(
+ status: SignalStageStatus,
+): 'idle' | 'active' | 'success' | 'warning' | 'error' {
+ if (status === 'queued') return 'idle';
+ return status;
+}
+
+function cableToneForTransition(
+ prev: SignalStageStatus,
+ next: SignalStageStatus,
+): SignalTone {
+ // Cable shows the "data is flowing from prev to next" state.
+ // If prev completed, the cable carries data into next.
+ if (prev === 'success' && next === 'active') return 'active';
+ if (prev === 'success' && next === 'success') return 'success';
+ if (prev === 'active') return 'active';
+ return 'idle';
+}
+
+export const SignalChain = React.forwardRef(
+ function SignalChain(
+ { stages, orientation = 'horizontal', animated = false, className, ...rest },
+ ref,
+ ) {
+ const isVertical = orientation === 'vertical';
+ return (
+
+ {stages.map((stage, i) => {
+ const prev = stages[i - 1];
+ const cableTone: SignalTone = prev
+ ? cableToneForTransition(prev.status, stage.status)
+ : 'idle';
+ return (
+
+ {i > 0 && (
+
+ )}
+
+
+
+ {stage.parameter ?? }
+ {stage.statusLabel && (
+
+ {stage.statusLabel}
+
+ )}
+
+
+
+
+ );
+ })}
+
+ );
+ },
+);
diff --git a/apps/ui/src/components/ui/TimeReadout.stories.tsx b/apps/ui/src/components/ui/TimeReadout.stories.tsx
new file mode 100644
index 00000000..fa5d5d06
--- /dev/null
+++ b/apps/ui/src/components/ui/TimeReadout.stories.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { TimeReadout } from './TimeReadout';
+
+const meta: Meta = {
+ title: 'UI/TimeReadout',
+ component: TimeReadout,
+ args: { elapsedMs: 42_000, estimateMs: 120_000 },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Pending: Story = {
+ args: { pending: true, estimateRangeMs: [60_000, 90_000] },
+};
+
+export const WithRange: Story = {
+ args: { elapsedMs: 132_000, estimateRangeMs: [120_000, 180_000] },
+};
+
+export const NoEstimate: Story = {
+ args: { elapsedMs: 9_500, estimateMs: undefined, estimateRangeMs: undefined },
+};
diff --git a/apps/ui/src/components/ui/TimeReadout.tsx b/apps/ui/src/components/ui/TimeReadout.tsx
new file mode 100644
index 00000000..50fb420b
--- /dev/null
+++ b/apps/ui/src/components/ui/TimeReadout.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+
+import { cn } from './cn';
+
+export interface TimeReadoutProps extends React.HTMLAttributes {
+ elapsedMs?: number | null;
+ estimateMs?: number | null;
+ estimateRangeMs?: [number, number] | null;
+ /** When true, render placeholder dashes instead of zeros. */
+ pending?: boolean;
+}
+
+function formatMs(ms: number | null | undefined): string {
+ if (ms == null || !Number.isFinite(ms) || ms < 0) return '—';
+ const total = Math.floor(ms / 1000);
+ const m = Math.floor(total / 60);
+ const s = total % 60;
+ return `${m}:${s.toString().padStart(2, '0')}`;
+}
+
+function formatRange(range: [number, number] | null | undefined): string {
+ if (!range) return '';
+ const [lo, hi] = range;
+ return `${formatMs(lo)}–${formatMs(hi)}`;
+}
+
+export const TimeReadout = React.forwardRef(
+ function TimeReadout(
+ { elapsedMs, estimateMs, estimateRangeMs, pending, className, ...rest },
+ ref,
+ ) {
+ const elapsedLabel = pending ? '—:——' : formatMs(elapsedMs);
+ const estimateLabel = estimateRangeMs
+ ? formatRange(estimateRangeMs)
+ : formatMs(estimateMs);
+ const hasEstimate = Boolean(estimateRangeMs) || estimateMs != null;
+
+ return (
+
+ {elapsedLabel}
+ {hasEstimate && (
+
+ est {estimateLabel}
+
+ )}
+
+ );
+ },
+);
diff --git a/apps/ui/src/components/ui/index.ts b/apps/ui/src/components/ui/index.ts
index bbb03fa4..05553333 100644
--- a/apps/ui/src/components/ui/index.ts
+++ b/apps/ui/src/components/ui/index.ts
@@ -2,8 +2,31 @@ export { cn } from './cn';
export type { Tone, Status, Size } from './variants';
export { Button, buttonVariants, type ButtonProps } from './Button';
+export { ChainSeparator, type ChainSeparatorProps, type SignalTone } from './ChainSeparator';
export { Checkbox, checkboxVariants, type CheckboxProps } from './Checkbox';
+export {
+ DataTable,
+ type DataTableColumn,
+ type DataTableProps,
+} from './DataTable';
+export {
+ DeviceRack,
+ deviceRackVariants,
+ type DeviceRackProps,
+} from './DeviceRack';
+export {
+ EmptyState,
+ emptyStateVariants,
+ type EmptyStateProps,
+} from './EmptyState';
export { LedIndicator, type LedIndicatorProps } from './LedIndicator';
+export { MetricBar, type MetricBarProps } from './MetricBar';
+export { MetricBarRow, type MetricBarRowProps } from './MetricBarRow';
+export {
+ MetricTile,
+ metricTileVariants,
+ type MetricTileProps,
+} from './MetricTile';
export { Panel, panelVariants, type PanelProps } from './Panel';
export { Pill, pillVariants, type PillProps } from './Pill';
export {
@@ -11,4 +34,11 @@ export {
sectionHeaderVariants,
type SectionHeaderProps,
} from './SectionHeader';
+export {
+ SignalChain,
+ type SignalChainProps,
+ type SignalStage,
+ type SignalStageStatus,
+} from './SignalChain';
+export { TimeReadout, type TimeReadoutProps } from './TimeReadout';
export { Tooltip, TooltipProvider, type TooltipProps } from './Tooltip';