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) => ( + + ))} + + + + {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 ( + + ); + })} + + ))} + +
+ {formatDisplayText(column.label, 'eyebrow')} +
+ {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 + + +
+
+
+ ), +}; 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={ + + } + /> +
+ ), +}; 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' ? ( + + ) : 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';