From 4b3989265fb7e8ed2484a0bfa1af768856473284 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 04:33:11 +0000 Subject: [PATCH] UI overhaul Phase 2d: promote DAW-lane primitives to ui/, delete MeasurementPrimitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final slice of Phase 2 — the competing MeasurementPrimitives.tsx layer is now deleted; ui/ is the single primitive layer. - New ui/Lane.tsx (LaneContainer, LaneRow, TimeRuler, StatsBar) tokenizes the lane chrome: the raw #1a1a1a/#222/#252525/#1e1e1e/#2a2a2a/#333 shades snap to the surface-darker / surface-dark / border-light token ladder, #777/#555 text -> text-muted, and the StatsBar default value color -> var(--color-text-primary). No raw hex remains in the primitives. - HarmonyLanes + StructureLanes import the lanes from ./ui (their own viz hex is unchanged — they are not under the ui/ hex rule). - Add ui/Lane Storybook story (convention; Chromatic covers it). - Fix the now-stale MeasurementPrimitives reference in ConfidenceBandBadge's comment. - Delete apps/ui/src/components/MeasurementPrimitives.tsx. Verified: lint:style, tsc --noEmit, 830 unit tests, and the production build all green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016to3B8Hf9eAGxAbzpQNvku --- apps/ui/src/components/HarmonyLanes.tsx | 2 +- .../src/components/MeasurementPrimitives.tsx | 565 ------------------ apps/ui/src/components/StructureLanes.tsx | 2 +- .../sessionMusician/ConfidenceBandBadge.tsx | 2 +- apps/ui/src/components/ui/Lane.stories.tsx | 45 ++ apps/ui/src/components/ui/Lane.tsx | 86 +++ apps/ui/src/components/ui/index.ts | 9 + 7 files changed, 143 insertions(+), 568 deletions(-) delete mode 100644 apps/ui/src/components/MeasurementPrimitives.tsx create mode 100644 apps/ui/src/components/ui/Lane.stories.tsx create mode 100644 apps/ui/src/components/ui/Lane.tsx diff --git a/apps/ui/src/components/HarmonyLanes.tsx b/apps/ui/src/components/HarmonyLanes.tsx index 4570d282..61b14109 100644 --- a/apps/ui/src/components/HarmonyLanes.tsx +++ b/apps/ui/src/components/HarmonyLanes.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type { Phase1Result } from '../types'; -import { LaneContainer, LaneRow, StatsBar } from './MeasurementPrimitives'; +import { LaneContainer, LaneRow, StatsBar } from './ui'; import { analyzeChord, deduplicateChords, getChordColor } from '../utils/chordTheory'; interface HarmonyLanesProps { diff --git a/apps/ui/src/components/MeasurementPrimitives.tsx b/apps/ui/src/components/MeasurementPrimitives.tsx deleted file mode 100644 index c61a0ce9..00000000 --- a/apps/ui/src/components/MeasurementPrimitives.tsx +++ /dev/null @@ -1,565 +0,0 @@ -import React from 'react'; - -import { - formatDisplayText, - getTextRoleClassName, - type DisplayTextCase, - type TextRole, -} from '../utils/displayText'; - -type PrimitiveTone = - | 'accent' - | 'success' - | 'warning' - | 'error' - | 'muted' - | 'info' - | 'violet' - | 'blue' - | 'teal'; - -const BADGE_TONE_CLASSES: Record = { - accent: 'border-accent/40 bg-accent/10 text-accent', - success: 'border-success/30 bg-success/10 text-success', - warning: 'border-warning/30 bg-warning/10 text-warning', - error: 'border-error/30 bg-error/10 text-error', - muted: 'border-border-light bg-bg-card/20 text-text-secondary', - info: 'border-sky-500/30 bg-sky-500/10 text-sky-300', - violet: 'border-violet-500/30 bg-violet-500/10 text-violet-300', - blue: 'border-blue-500/30 bg-blue-500/10 text-blue-300', - teal: 'border-teal-500/30 bg-teal-500/10 text-teal-300', -}; - -const ACCENT_CARD_CLASSES: Record, string> = { - accent: 'border-l-2 border-accent', - success: 'border-l-2 border-success', - warning: 'border-l-2 border-warning', - error: 'border-l-2 border-error', - violet: 'border-l-2 border-violet-400', - blue: 'border-l-2 border-blue-400', - teal: 'border-l-2 border-teal-400', -}; - -function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, 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); -} - -function formatSignedNumber(value: number, decimals: number, showSign: boolean): string { - const abs = Math.abs(value).toFixed(decimals); - if (!showSign) return value.toFixed(decimals); - return `${value >= 0 ? '+' : '-'}${abs}`; -} - -export interface StatusBadgeProps { - label: React.ReactNode; - tone: PrimitiveTone; - compact?: boolean; - className?: string; -} - -export function StatusBadge({ - label, - tone, - compact = false, - className = '', -}: StatusBadgeProps) { - return ( - - {label} - - ); -} - -export interface DeltaBadgeProps { - value: number | null | undefined; - unit?: string; - decimals?: number; - okThreshold: number; - warnThreshold: number; - invert?: boolean; - showSign?: boolean; - className?: string; -} - -export function DeltaBadge({ - value, - unit, - decimals = 1, - okThreshold, - warnThreshold, - invert = false, - showSign = true, - className, -}: DeltaBadgeProps) { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return ; - } - - const magnitude = Math.abs(value); - const tone = - magnitude <= okThreshold - ? invert - ? 'error' - : 'success' - : magnitude <= warnThreshold - ? 'warning' - : invert - ? 'success' - : 'error'; - const label = `${formatSignedNumber(value, decimals, showSign)}${unit ? ` ${unit}` : ''}`; - - return ; -} - -export interface MetricBarProps { - value: number | null | undefined; - min?: number; - max?: number; - percent?: number; - color?: string; - glow?: boolean; - leftLabel?: string; - rightLabel?: string; - heightClassName?: string; - className?: string; -} - -export function MetricBar({ - value, - min = 0, - max = 1, - percent, - color = 'var(--color-accent)', - glow = false, - leftLabel, - rightLabel, - heightClassName = 'h-2', - className = '', -}: MetricBarProps) { - const width = resolvePercent({ value, min, max, percent }); - - return ( -
-
-
-
- {(leftLabel || rightLabel) && ( -
- {leftLabel ?? ''} - {rightLabel ?? ''} -
- )} -
- ); -} - -export interface MetricBarRowProps { - 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; - className?: string; -} - -export function MetricBarRow({ - label, - valueLabel, - value, - min, - max, - percent, - color, - sparkline, - leftLabel, - rightLabel, - monospaceValue = true, - className = '', -}: MetricBarRowProps) { - return ( -
-
- - {formatDisplayText(label, 'eyebrow')} - -
- {sparkline && {sparkline}} - - {valueLabel} - -
-
- -
- ); -} - -export interface AccentMetricCardProps { - label: React.ReactNode; - value: React.ReactNode; - unit?: React.ReactNode; - accent?: 'accent' | 'success' | 'warning' | 'error' | 'violet' | 'blue' | 'teal'; - headerRight?: React.ReactNode; - footer?: React.ReactNode; - className?: string; -} - -export function AccentMetricCard({ - label, - value, - unit, - accent = 'accent', - headerRight, - footer, - className = '', -}: AccentMetricCardProps) { - return ( -
-
- - {label} - - {headerRight} -
-
- - {value} - - {unit && ( - - {unit} - - )} -
- {footer &&
{footer}
} -
- ); -} - -export interface TokenBadgeListProps { - items: Array<{ label: string; tone?: 'accent' | 'success' | 'warning' | 'error' | 'muted' | 'violet' | 'blue' }>; - className?: string; -} - -export function TokenBadgeList({ items, className = '' }: TokenBadgeListProps) { - return ( -
- {items.map((item, index) => ( - - {item.label} - - ))} -
- ); -} - -export interface StyledDataTableColumn { - key: string; - label: string; - align?: 'left' | 'right'; - monospace?: boolean; - displayCase?: DisplayTextCase; - textRole?: TextRole; - render?: (row: T) => React.ReactNode; -} - -export interface StyledDataTableProps { - data: T[]; - columns: StyledDataTableColumn[]; - rowClassName?: (row: T, index: number) => string; - className?: string; -} - -export function StyledDataTable({ - data, - columns, - rowClassName, - className = '', -}: StyledDataTableProps) { - const renderTextValue = ( - value: string | number, - textRole: TextRole, - displayCase: DisplayTextCase, - monospace = false, - ) => ( - - {typeof value === 'string' ? formatDisplayText(value, displayCase) : value} - - ); - - return ( -
- - - - {columns.map((column) => ( - - ))} - - - - {data.map((row, rowIndex) => ( - - {columns.map((column) => { - const value = column.render - ? column.render(row) - : String((row as Record)[column.key] ?? '—'); - return ( - - ); - })} - - ))} - -
- {formatDisplayText(column.label, 'eyebrow')} -
- {typeof value === 'string' || typeof value === 'number' - ? renderTextValue( - value, - column.textRole ?? (column.monospace ? 'value' : 'body'), - column.displayCase ?? 'none', - column.monospace, - ) - : value} -
-
- ); -} - -export interface OutlinePillButtonProps { - label: string; - active?: boolean; - done?: boolean; - disabled?: boolean; - onClick?: () => void; - tone?: 'accent' | 'success' | 'muted'; - className?: string; -} - -export function OutlinePillButton({ - label, - active = false, - done = false, - disabled = false, - onClick, - tone = 'muted', - className = '', -}: OutlinePillButtonProps) { - const resolvedTone = done ? 'success' : active ? tone : 'muted'; - return ( - - ); -} - -/* ── DAW Lane Primitives ─────────────────────────────────────────────── */ - -export function LaneContainer({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - -export interface LaneRowProps { - label: string; - height?: string; - children: React.ReactNode; -} - -export function LaneRow({ label, height = 'h-8', children }: LaneRowProps) { - return ( -
-
- - {label} - -
-
{children}
-
- ); -} - -export function TimeRuler({ - durationSeconds, - label = 'Structure', -}: { - durationSeconds: number; - label?: string; -}) { - const markerCount = Math.min(Math.max(Math.floor(durationSeconds / 30) + 1, 3), 10); - const step = durationSeconds / (markerCount - 1); - const markers = Array.from({ length: markerCount }, (_, i) => { - const secs = Math.round(i * step); - const m = Math.floor(secs / 60); - const s = secs % 60; - return `${m}:${s.toString().padStart(2, '0')}`; - }); - - return ( -
- {label} -
- {markers.map((m, i) => ( - {m} - ))} -
-
- ); -} - -export interface StatsBarItem { - label: string; - value: React.ReactNode; - color?: string; -} - -export function StatsBar({ items }: { items: StatsBarItem[] }) { - return ( -
- {items.map((item, i) => ( -
- {item.label} - - {item.value} - -
- ))} -
- ); -} diff --git a/apps/ui/src/components/StructureLanes.tsx b/apps/ui/src/components/StructureLanes.tsx index 4408d6a4..b9c13b64 100644 --- a/apps/ui/src/components/StructureLanes.tsx +++ b/apps/ui/src/components/StructureLanes.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type { Phase1Result } from '../types'; -import { LaneContainer, LaneRow, StatsBar, TimeRuler } from './MeasurementPrimitives'; +import { LaneContainer, LaneRow, StatsBar, TimeRuler } from './ui'; interface StructureLanesProps { phase1: Phase1Result; diff --git a/apps/ui/src/components/sessionMusician/ConfidenceBandBadge.tsx b/apps/ui/src/components/sessionMusician/ConfidenceBandBadge.tsx index 5cdf1550..70f05bb7 100644 --- a/apps/ui/src/components/sessionMusician/ConfidenceBandBadge.tsx +++ b/apps/ui/src/components/sessionMusician/ConfidenceBandBadge.tsx @@ -12,7 +12,7 @@ import { // Four-tone traffic-light ladder so the producer can distinguish all four // bands at a glance: green → orange → amber → red, best to worst. The -// vocabulary mirrors what `MeasurementPrimitives`, `DiagnosticLog`, and +// vocabulary mirrors what `ui/Pill`, `DiagnosticLog`, and // `FileUpload` already use for severity across the app — success = ready, // accent = active, warning = caution, error = critical. All four CSS // variables are defined in `apps/ui/src/index.css`; no new theme tokens diff --git a/apps/ui/src/components/ui/Lane.stories.tsx b/apps/ui/src/components/ui/Lane.stories.tsx new file mode 100644 index 00000000..fbee65f4 --- /dev/null +++ b/apps/ui/src/components/ui/Lane.stories.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { LaneContainer, LaneRow, StatsBar, TimeRuler } from './Lane'; + +const meta: Meta = { + title: 'UI/Lane', + component: LaneContainer, +}; + +export default meta; +type Story = StoryObj; + +export const ArrangementLanes: Story = { + render: () => ( + + + + +
+ {['Am', 'F', 'C', 'G'].map((c) => ( +
+ {c} +
+ ))} +
+
+ +
+ bass · 110 Hz + lead · 440 Hz +
+
+
+ ), +}; diff --git a/apps/ui/src/components/ui/Lane.tsx b/apps/ui/src/components/ui/Lane.tsx new file mode 100644 index 00000000..1a6f07b9 --- /dev/null +++ b/apps/ui/src/components/ui/Lane.tsx @@ -0,0 +1,86 @@ +import React from 'react'; + +/** + * DAW arrangement-lane primitives — the horizontal label-cell + content-row + * vocabulary used by the harmony and structure lane views. Migrated from the + * retired MeasurementPrimitives layer onto the design tokens (the lane shades + * snap to the surface-dark / surface-darker / border-light token ladder). + */ + +export function LaneContainer({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export interface LaneRowProps { + label: string; + height?: string; + children: React.ReactNode; +} + +export function LaneRow({ label, height = 'h-8', children }: LaneRowProps) { + return ( +
+
+ + {label} + +
+
{children}
+
+ ); +} + +export interface TimeRulerProps { + durationSeconds: number; + label?: string; +} + +export function TimeRuler({ durationSeconds, label = 'Structure' }: TimeRulerProps) { + const markerCount = Math.min(Math.max(Math.floor(durationSeconds / 30) + 1, 3), 10); + const step = durationSeconds / (markerCount - 1); + const markers = Array.from({ length: markerCount }, (_, i) => { + const secs = Math.round(i * step); + const m = Math.floor(secs / 60); + const s = secs % 60; + return `${m}:${s.toString().padStart(2, '0')}`; + }); + + return ( +
+ {label} +
+ {markers.map((m, i) => ( + {m} + ))} +
+
+ ); +} + +export interface StatsBarItem { + label: string; + value: React.ReactNode; + color?: string; +} + +export function StatsBar({ items }: { items: StatsBarItem[] }) { + return ( +
+ {items.map((item, i) => ( +
+ {item.label} + + {item.value} + +
+ ))} +
+ ); +} diff --git a/apps/ui/src/components/ui/index.ts b/apps/ui/src/components/ui/index.ts index 7f18a0f5..6845ee1f 100644 --- a/apps/ui/src/components/ui/index.ts +++ b/apps/ui/src/components/ui/index.ts @@ -20,6 +20,15 @@ export { emptyStateVariants, type EmptyStateProps, } from './EmptyState'; +export { + LaneContainer, + LaneRow, + TimeRuler, + StatsBar, + type LaneRowProps, + type TimeRulerProps, + type StatsBarItem, +} from './Lane'; export { LedIndicator, type LedIndicatorProps } from './LedIndicator'; export { MetricBar, type MetricBarProps } from './MetricBar'; export { MetricBarRow, type MetricBarRowProps } from './MetricBarRow';