From 9f94c23ce81c03964b8052107271ccd854334fcc Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Wed, 13 May 2026 12:35:46 +1200 Subject: [PATCH] feat(ui): distinct color tones for Session Musician confidence bands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #22 shipped a four-band confidence ladder (Solid scaffold / Workable draft / Rough sketch / Unreliable) but the PILL_CLASSES map collapsed them into only two tones — accent for solid + workable, warning for rough + unreliable. A producer couldn't visually distinguish Solid from Workable, or Rough from Unreliable, at a glance. Map the four bands to a green → orange → amber → red traffic-light ladder so the visual hierarchy matches the producer's mental model ("best to worst at a glance"): Solid scaffold (≥80%) → success (#00ff9d green) Workable draft (50-79%) → accent (#ff8800 orange) Rough sketch (25-49%) → warning (#ffb800 amber) Unreliable (<25%) → error (#ff3333 red) All four CSS variables already exist in apps/ui/src/index.css and are the canonical severity vocabulary across MeasurementPrimitives, DiagnosticLog, and FileUpload (success = ready, accent = active, warning = caution, error = critical). No theme additions; no new shades. Opacity tuning unified at /30 border, /10 bg, text-{color} to match MeasurementPrimitives.BADGE_TONE_CLASSES. Color carries the differentiation now — the previous per-band opacity wobble didn't communicate anything. NoteDraftBlock's overrideTone="rough" path (used for full-mix-fallback and legacy render states) now renders amber, which is the right tone for "treat with caution, not unreliable junk." No wiring change needed. Tests: new tests/services/confidenceBandBadge.test.ts renders all four confidence values and asserts each emits the expected color tokens. A pairwise-distinct assertion survives future opacity tweaks. Two override-path tests lock in the full-mix-fallback / legacy visual (high-confidence input with overrideTone="rough" produces warning tokens, not green; low-confidence input stays warning, not red). No existing tests touched — confidenceBand asserts id/label/percent (not classes); panel + smoke tests assert on text. Targets PR #22 (claude/thirsty-easley-faeaea) as the base. Plan in ~/.claude/plans/re-evaluate-asa-s-session-musician-sharded-balloon.md ("Follow-up plan #1" section). Co-Authored-By: Claude Opus 4.7 --- .../sessionMusician/ConfidenceBandBadge.tsx | 14 +- .../services/confidenceBandBadge.test.ts | 139 ++++++++++++++++++ 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 apps/ui/tests/services/confidenceBandBadge.test.ts diff --git a/apps/ui/src/components/sessionMusician/ConfidenceBandBadge.tsx b/apps/ui/src/components/sessionMusician/ConfidenceBandBadge.tsx index fc5af7b5..64c0831a 100644 --- a/apps/ui/src/components/sessionMusician/ConfidenceBandBadge.tsx +++ b/apps/ui/src/components/sessionMusician/ConfidenceBandBadge.tsx @@ -10,11 +10,19 @@ import { type ConfidenceBand, } from '../../services/sessionMusician/confidenceBand'; +// 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 +// `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 +// needed. Uniform opacity (`/30` border, `/10` bg) matches the established +// BADGE_TONE_CLASSES convention; the color is what carries the distinction. const PILL_CLASSES: Record = { - solid: 'border-accent/40 text-accent bg-accent/10', - workable: 'border-accent/30 text-accent bg-accent/5', + solid: 'border-success/30 text-success bg-success/10', + workable: 'border-accent/40 text-accent bg-accent/10', rough: 'border-warning/30 text-warning bg-warning/10', - unreliable: 'border-warning/40 text-warning bg-warning/15', + unreliable: 'border-error/30 text-error bg-error/10', }; interface ConfidenceBandBadgeProps { diff --git a/apps/ui/tests/services/confidenceBandBadge.test.ts b/apps/ui/tests/services/confidenceBandBadge.test.ts new file mode 100644 index 00000000..bb01e4a8 --- /dev/null +++ b/apps/ui/tests/services/confidenceBandBadge.test.ts @@ -0,0 +1,139 @@ +// Verifies the four confidence bands render with distinct color tokens so +// the producer can tell Solid from Workable, and Rough from Unreliable, at +// a glance. The pure helper tests in `confidenceBand.test.ts` cover label +// and copy; this file pins the visual tone mapping in +// `ConfidenceBandBadge.tsx` and the override path used by +// full-mix-fallback / legacy render states. + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { ConfidenceBandBadge } from '../../src/components/sessionMusician/ConfidenceBandBadge'; + +// Confidence values that fall squarely inside each band per the thresholds +// in services/sessionMusician/confidenceBand.ts (≥0.80 solid, ≥0.50 workable, +// ≥0.25 rough, <0.25 unreliable). +const BAND_CONFIDENCE = { + solid: 0.9, + workable: 0.6, + rough: 0.3, + unreliable: 0.1, +} as const; + +// Extracts the three color tokens (border, text, bg) from a rendered pill so +// tests can compare bands without pinning the exact opacity suffix. Returns +// e.g. `{ border: 'border-success/30', text: 'text-success', bg: 'bg-success/10' }`. +function extractColorTokens(html: string): { border: string; text: string; bg: string } { + const border = html.match(/border-(success|accent|warning|error)\/\d+/)?.[0]; + const text = html.match(/text-(success|accent|warning|error)(?!-)\b/)?.[0]; + const bg = html.match(/bg-(success|accent|warning|error)\/\d+/)?.[0]; + if (!border || !text || !bg) { + throw new Error(`Could not extract all three color tokens from markup: ${html}`); + } + return { border, text, bg }; +} + +function render(confidence: number, overrides: Partial> = {}) { + return renderToStaticMarkup( + React.createElement(ConfidenceBandBadge, { confidence, ...overrides }), + ); +} + +describe('ConfidenceBandBadge — per-band color tones', () => { + it('renders the Solid scaffold band with success/green tokens', () => { + const tokens = extractColorTokens(render(BAND_CONFIDENCE.solid)); + expect(tokens.border).toMatch(/^border-success\//); + expect(tokens.text).toBe('text-success'); + expect(tokens.bg).toMatch(/^bg-success\//); + }); + + it('renders the Workable draft band with accent/orange tokens', () => { + const tokens = extractColorTokens(render(BAND_CONFIDENCE.workable)); + expect(tokens.border).toMatch(/^border-accent\//); + expect(tokens.text).toBe('text-accent'); + expect(tokens.bg).toMatch(/^bg-accent\//); + }); + + it('renders the Rough sketch band with warning/amber tokens', () => { + const tokens = extractColorTokens(render(BAND_CONFIDENCE.rough)); + expect(tokens.border).toMatch(/^border-warning\//); + expect(tokens.text).toBe('text-warning'); + expect(tokens.bg).toMatch(/^bg-warning\//); + }); + + it('renders the Unreliable band with error/red tokens', () => { + const tokens = extractColorTokens(render(BAND_CONFIDENCE.unreliable)); + expect(tokens.border).toMatch(/^border-error\//); + expect(tokens.text).toBe('text-error'); + expect(tokens.bg).toMatch(/^bg-error\//); + }); +}); + +describe('ConfidenceBandBadge — bands are pairwise distinct', () => { + // The whole point of this redesign: producers must be able to tell the + // four bands apart visually. Extract each band's text-color token and + // assert no two bands share it. This survives future opacity tweaks. + it('no two bands share the same text-color token', () => { + const textTokens = (Object.keys(BAND_CONFIDENCE) as Array).map( + (band) => extractColorTokens(render(BAND_CONFIDENCE[band])).text, + ); + const unique = new Set(textTokens); + expect(unique.size).toBe(4); + }); + + it('Solid and Unreliable use opposite ends of the severity ladder', () => { + const solid = extractColorTokens(render(BAND_CONFIDENCE.solid)); + const unreliable = extractColorTokens(render(BAND_CONFIDENCE.unreliable)); + expect(solid.text).toBe('text-success'); + expect(unreliable.text).toBe('text-error'); + }); + + it('Workable and Rough sit between Solid and Unreliable', () => { + const workable = extractColorTokens(render(BAND_CONFIDENCE.workable)); + const rough = extractColorTokens(render(BAND_CONFIDENCE.rough)); + expect(workable.text).toBe('text-accent'); + expect(rough.text).toBe('text-warning'); + }); +}); + +describe('ConfidenceBandBadge — override path used by full-mix-fallback / legacy', () => { + // NoteDraftBlock passes `overrideTone="rough"` (with an overrideLabel like + // "Full-mix fallback" or "Legacy run") so the band visually reads as + // "amber caution" regardless of the underlying averageConfidence number. + it('overrideTone="rough" produces warning/amber tokens even when confidence is solid-range', () => { + const html = render(0.95, { + overrideTone: 'rough', + overrideLabel: 'Full-mix fallback', + overrideCopy: 'Pitch tracked across the whole mix; treat as approximate.', + }); + const tokens = extractColorTokens(html); + expect(tokens.border).toMatch(/^border-warning\//); + expect(tokens.text).toBe('text-warning'); + expect(tokens.bg).toMatch(/^bg-warning\//); + // And the pill text is the override, not "Solid scaffold · 95%". + expect(html).toContain('Full-mix fallback'); + expect(html).not.toContain('Solid scaffold'); + }); + + it('overrideTone="rough" with low-confidence input still uses warning tokens (not red)', () => { + const html = render(0.05, { + overrideTone: 'rough', + overrideLabel: 'Legacy run', + overrideCopy: 'Re-analyze for current stem-aware quality.', + }); + const tokens = extractColorTokens(html); + expect(tokens.text).toBe('text-warning'); + expect(html).toContain('Legacy run'); + expect(html).not.toContain('Unreliable'); + }); + + it('overrideCopy replaces the band copy without touching the pill class', () => { + const html = render(BAND_CONFIDENCE.solid, { + overrideCopy: 'Custom guidance for the producer.', + }); + expect(html).toContain('Custom guidance for the producer.'); + // Default band copy must NOT appear when overrideCopy is set. + expect(html).not.toContain("Notes look reliable. Expect light cleanup in Ableton's piano roll."); + }); +});