From 1a7ab3f2c6c349b43f6a674f0347ab1f8f9658cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 22:33:40 +0000 Subject: [PATCH 1/4] feat(ui): D.2 migrate SamplePlayback to ui/ primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 3 of 16 in the UI consistency overhaul. Worst-drift fix: kills every raw zinc-* / amber-* / emerald-* / rounded-lg instance in SamplePlayback.tsx (was 13 occurrences, now 0). Stacked on top of PR #65 (D.1 primitives). Will be rebased onto main once PR #65 merges. Migration: - Outer
. Rack status reflects panel state — error tone on failure, active tone while generating, success tone once a manifest is rendered. - Original

id-based labelling replaced by aria-label on the DeviceRack; the title strip carries the visible name. - Regenerate - )} - - - {!measurementCompleted && ( -

- Measurements still running — audition samples become available once Phase 1 - completes. + + ) + } + > +

+

+ Short clips derived from Phase 1 measurements (and Phase 2 context when + available) so you can ear-check the measurement chain. These are not + Ableton-accurate reconstructions — follow Phase 2 in Live for the + production character.

- )} - {measurementCompleted && !manifest && status.kind !== 'generating' && ( - - )} + {!measurementCompleted && ( +

+ Measurements still running — audition samples become available once + Phase 1 completes. +

+ )} - {status.kind === 'generating' && ( -

Rendering audition clips…

- )} + {measurementCompleted && !manifest && status.kind !== 'generating' && ( + + )} - {status.kind === 'error' && ( -

- {status.message} -

- )} + {status.kind === 'generating' && ( +

+ Rendering audition clips… +

+ )} - {manifest && groupedSamples.length > 0 && ( -
- - {groupedSamples.map(([category, samples]) => ( - - - - ))} -
- )} -

+ {status.kind === 'error' && ( +

+ {status.message} +

+ )} + + {manifest && groupedSamples.length > 0 && ( +
+ + {groupedSamples.map(([category, samples]) => ( + + + + ))} +
+ )} + + ); } @@ -184,13 +209,9 @@ function ManifestMeta({ manifest }: { manifest: SamplesManifest }) { ? 'PyTheory' : 'Pure-Python theory fallback'; return ( -
- - Music theory: {theoryLabel} - - - Synthesis: {synthesisLabel} - +
+ Music theory: {theoryLabel} + Synthesis: {synthesisLabel}
); } @@ -204,15 +225,18 @@ interface SampleGroupProps { function SampleGroup({ category, samples, runId, apiBaseUrl }: SampleGroupProps) { return ( -
-

- {CATEGORY_LABELS[category]} -

-
From bb1d557ef5069f80c2e989874db9470a5a86321f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 22:37:43 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat(ui):=20D.3=20AnalysisStatusPanel=20?= =?UTF-8?q?=E2=86=92=20SignalChain=20(marquee=20transformation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 4 of 16 in the UI consistency overhaul. The visual hook reviewers will remember: the analysis pipeline literally renders as a horizontal Live 12 device chain. Stacked on PR #66 (D.2 SamplePlayback + DeviceRack rail fix). Branch already includes the rail fix needed for the all-3-slots layout this PR exercises. Migration: - Outer
. Status maps progress tone + isActive to idle/active/success/error. - Stop button → + ) : ( + nonRetryableHint + ); + + return { + key: stage.key, + name: STAGE_LABELS[stage.key], + status: toSignalStatus(stage.status), + statusLabel: statusLabel(stage.status), + action, + }; + }); + + const rackStatus = rackStatusFromProgress(progress, isActive); + const subtitle = run ? `· ${run.runId.slice(-8)}` : undefined; + const railTone: SignalTone = + progress.tone === 'success' ? 'success' : progress.tone === 'failed' ? 'idle' : 'active'; + + const railProgressLabel = progress.indeterminate + ? 'estimating' + : `${Math.round(progress.percent)}%`; + return ( -
- {/* Header row */} -
-
- Analysis Run - {run && ( - - {run.runId} - - )} -
-
-
-
- {formatElapsed(elapsedMs)} -
+ } + onClick={onStopAnalysis} + title="Stop analysis" + aria-label="Stop analysis" + > + Stop + + ) : undefined + } + signalIn={railTone} + signalOut={progress.tone === 'success' ? 'success' : 'idle'} + railContent={ + + + {formatElapsed(elapsedMs)} + {estimate && ( - - est {formatEstimateRange(estimate)} - + est {formatEstimateRange(estimate)} )} - {onStopAnalysis && isActive && ( - + {railProgressLabel} + + } + > +
+ {/* Audit Finding #6: primary readout. The stage diagnostic message + used to render at `text-[9px] text-secondary/50` below the percent + — sized as background fluff. During a 4–5 minute Phase 2 wait the + producer would tab away and miss any actual signal about what's + happening. Now it sits at the top of the device body as the visual + focus, with the active stage label as a mono eyebrow above it. The + SignalChain below becomes the secondary "which stage is which" + landmark. */} + + {progress.activeStageKey && ( +

+ {STAGE_LABELS[progress.activeStageKey]} + {progress.tone === 'failed' ? ' · failure' : null} +

)} -
-
- - {/* Audit Finding #6: primary readout. The stage diagnostic message used - to render at `text-[9px] text-secondary/50` below the percent — sized - as background fluff. During a 4–5 minute Phase 2 wait the producer - would tab away and miss any actual signal about what's happening. - Now it sits between the header and the stage chips as the visual - focus, with the active stage label as a mono eyebrow above it. The - chips below become the secondary "which stage is which" landmark. */} -
- {progress.activeStageKey && ( -

- {STAGE_LABELS[progress.activeStageKey]} - {progress.tone === 'failed' ? ' · failure' : null} +

+ {progress.message}

- )} -

- {progress.message} -

-
+ - {/* Stage pipeline */} -
- {stages.map((stage, i) => { - // A retry button only makes sense when (a) the parent provided a - // handler, (b) the stage is in a retryable state, and (c) the - // backend hasn't explicitly marked the error as non-retryable - // (e.g. GEMINI_NOT_CONFIGURED: clicking RETRY won't fix a missing - // env var). Audit N1 sibling: previously the button rendered for - // every failed stage regardless of error.retryable. - const errorMarkedNonRetryable = stage.error?.retryable === false; - const isRetryable = - stage.onRetry && - (stage.status === 'failed' || stage.status === 'interrupted' || stage.status === 'ready') && - !errorMarkedNonRetryable; - return ( -
-
- - {STAGE_LABELS[stage.key]} - -
-
-
- - {statusLabel(stage.status)} - - {isRetryable ? ( - - ) : errorMarkedNonRetryable && stage.error?.code ? ( - // Audit N1 sibling: a non-retryable failure (e.g. - // GEMINI_NOT_CONFIGURED) used to render FAILED with no - // actionable feedback. Surface the error code so the user - // knows where to look; full message goes in the tooltip. - - {stage.error.code} - - ) : null} -
-
- ); - })} -
- - {/* Progress bar */} -
-
- {progress.indeterminate ? ( -
- ) : ( -
= 95 && progress.tone === 'running' ? 'animate-pulse' : '' - }`} - style={{ width: `${progress.percent}%` }} - /> - )} -
-
- - {progress.indeterminate ? 'estimating' : `${Math.round(progress.percent)}%`} - -
- {/* Audit Finding #6: the duplicate small `progress.message` that used - to render here was removed — the primary readout above is the - single source for "what's happening". Keeping it here would have - been visual noise repeating the same sentence twice. */} +
-
+ ); } From f76b7a422bc906e4249514a93d509ef995ce71f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 22:42:58 +0000 Subject: [PATCH 4/4] test(ui): unit-cover AnalysisStatusPanel helpers + tighten ready mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #67 review (REQUEST CHANGES) called out that toSignalStatus() and rackStatusFromProgress() were new logic without tests, and that 'ready' mapping to 'queued' was a judgment-call regression (the user already sees a Retry button in the action slot — the tile reading 'queued' implies the chain will auto-resume when it actually won't). Changes: - Export toSignalStatus, statusLabel, and rackStatusFromProgress from AnalysisStatusPanel.tsx so the test suite can import them. Add doc comments to each explaining the intent + the per-case mapping rationale. - Remap 'ready' from 'queued' to 'idle'. statusLabel('ready') still returns 'READY' so the user sees the correct label inside the tile; the LED + cable + box-shadow no longer fake imminent execution. - New test file tests/services/analysisStatusPanelHelpers.test.ts with 23 tests covering every AnalysisStageStatus value, the unknown fallback for both helpers, the dominance of failed/success tones in rackStatusFromProgress, the running+isActive interaction, and the ready-vs-queued separation explicitly. Test count: 605 → 628. Verified: lint clean, full unit suite 628/628 passing. https://claude.ai/code/session_01WNtYcWXwn4mVnnjxqT7uAe --- .../ui/src/components/AnalysisStatusPanel.tsx | 27 +++++- .../analysisStatusPanelHelpers.test.ts | 92 +++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 apps/ui/tests/services/analysisStatusPanelHelpers.test.ts diff --git a/apps/ui/src/components/AnalysisStatusPanel.tsx b/apps/ui/src/components/AnalysisStatusPanel.tsx index be01460b..2fba38f9 100644 --- a/apps/ui/src/components/AnalysisStatusPanel.tsx +++ b/apps/ui/src/components/AnalysisStatusPanel.tsx @@ -234,20 +234,33 @@ export function computeLiveProgress( * Map a stage's internal status onto the visual SignalChain status. The * SignalChain primitive owns the device-rack chrome for each stage, so we * collapse the wider AnalysisStageStatus enum onto its smaller vocabulary. + * + * - `running` → active (LED pulses, cable animates) + * - `queued` → queued (waiting in line behind earlier stages) + * - `blocked` → queued (waiting on measurement to finish; same + * visual semantic as queued) + * - `ready` → idle (failed stage was reset and is now waiting + * for the user to click Retry — the Retry + * button in the action slot owns this state's + * CTA, so the device tile reads as idle + * rather than queued-for-auto-execution) + * - `completed` → success + * - `failed`/`interrupted` → error + * - `not_requested` → idle (stage was not requested for this run) */ -function toSignalStatus(status: AnalysisStageStatus): SignalStageStatus { +export function toSignalStatus(status: AnalysisStageStatus): SignalStageStatus { switch (status) { case 'running': return 'active'; case 'queued': case 'blocked': - case 'ready': return 'queued'; case 'completed': return 'success'; case 'failed': case 'interrupted': return 'error'; + case 'ready': case 'not_requested': return 'idle'; default: @@ -255,7 +268,7 @@ function toSignalStatus(status: AnalysisStageStatus): SignalStageStatus { } } -function statusLabel(status: AnalysisStageStatus): string { +export function statusLabel(status: AnalysisStageStatus): string { switch (status) { case 'running': return 'RUNNING'; case 'queued': return 'QUEUED'; @@ -269,7 +282,13 @@ function statusLabel(status: AnalysisStageStatus): string { } } -function rackStatusFromProgress( +/** + * Map the live ProgressState + isActive onto a DeviceRack status tone for + * the outer ANALYSIS RUN rack. Failure dominates (error tone) regardless of + * isActive; an explicit success tone overrides isActive; otherwise the rack + * lights up active while running and falls to idle when no stage is active. + */ +export function rackStatusFromProgress( progress: ProgressState, isActive: boolean, ): 'idle' | 'active' | 'success' | 'error' { diff --git a/apps/ui/tests/services/analysisStatusPanelHelpers.test.ts b/apps/ui/tests/services/analysisStatusPanelHelpers.test.ts new file mode 100644 index 00000000..25e7f278 --- /dev/null +++ b/apps/ui/tests/services/analysisStatusPanelHelpers.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { + rackStatusFromProgress, + statusLabel, + toSignalStatus, + type ProgressState, +} from '../../src/components/AnalysisStatusPanel'; +import type { AnalysisStageStatus } from '../../src/types'; + +describe('toSignalStatus', () => { + it.each<[AnalysisStageStatus, string]>([ + ['running', 'active'], + ['queued', 'queued'], + ['blocked', 'queued'], + ['completed', 'success'], + ['failed', 'error'], + ['interrupted', 'error'], + ['ready', 'idle'], + ['not_requested', 'idle'], + ])('maps %s → %s', (input, expected) => { + expect(toSignalStatus(input)).toBe(expected); + }); + + it('maps unknown statuses to idle', () => { + // Cast to bypass the AnalysisStageStatus union — exercising the default branch. + expect(toSignalStatus('mystery' as AnalysisStageStatus)).toBe('idle'); + }); + + it('treats ready as idle (not queued) so the Retry CTA owns the action', () => { + // ready means "failed stage was reset; user must click Retry". The action + // slot already carries the Retry button; the tile must read as idle so + // the chain doesn't look like it will auto-resume. + expect(toSignalStatus('ready')).toBe('idle'); + expect(toSignalStatus('ready')).not.toBe('queued'); + }); +}); + +describe('statusLabel', () => { + it.each<[AnalysisStageStatus, string]>([ + ['running', 'RUNNING'], + ['queued', 'QUEUED'], + ['completed', 'DONE'], + ['failed', 'FAILED'], + ['interrupted', 'STOPPED'], + ['not_requested', 'SKIP'], + ['blocked', 'WAIT'], + ['ready', 'READY'], + ])('maps %s → %s', (input, expected) => { + expect(statusLabel(input)).toBe(expected); + }); + + it('uppercases unknown statuses', () => { + expect(statusLabel('weird-state' as AnalysisStageStatus)).toBe('WEIRD-STATE'); + }); +}); + +describe('rackStatusFromProgress', () => { + function progress( + tone: ProgressState['tone'], + overrides: Partial = {}, + ): ProgressState { + return { + percent: 50, + indeterminate: false, + message: '', + tone, + activeStageKey: null, + ...overrides, + }; + } + + it('failure tone always wins over isActive', () => { + expect(rackStatusFromProgress(progress('failed'), true)).toBe('error'); + expect(rackStatusFromProgress(progress('failed'), false)).toBe('error'); + }); + + it('success tone always wins over isActive', () => { + expect(rackStatusFromProgress(progress('success'), true)).toBe('success'); + expect(rackStatusFromProgress(progress('success'), false)).toBe('success'); + }); + + it('running + active → active', () => { + expect(rackStatusFromProgress(progress('running'), true)).toBe('active'); + }); + + it('running + not active → idle', () => { + // Edge case: progress tone says running but isActive=false. The rack + // should fall back to idle rather than lying about activity. + expect(rackStatusFromProgress(progress('running'), false)).toBe('idle'); + }); +});