feat(voicings): VoicingBrowser gallery — every shape and piano style side by side, no chips (task D-30)

All placeable guitar shapes and all four pianoVoicing styles render
simultaneously, each cell with its own play button; one-at-a-time
playback and the show prop semantics preserved. Critic PASS (SSR-verified
cell counts, zero aria-pressed, rootPc badge guard intact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-10 00:50:33 +01:00
parent 401d9d1f17
commit 0945a08497
+92 -131
View File
@@ -1,38 +1,48 @@
// VoicingBrowser — a standalone, playable voicing browser for ONE chord (task D-21). // VoicingBrowser — a playable voicing GALLERY for ONE chord (task D-30; was the
// chip-switched browser of D-21/D-23).
// //
// For a given { rootPc, quality } it shows every way the KB knows to voice that // For a given { rootPc, quality } it shows every way the KB knows to voice that
// chord, switchable via chips, each auditionable through the speakers: // chord — ALL AT ONCE, no chips, no selection state (user directive 2026-07-10:
// "see all the variations G shape, C shape, etc in one view without having to
// push a button. so they all line up next to each other"):
// //
// Guitar row — all `GUITAR_SHAPES[quality]` entries from src/lib/voicings.js // Guitar section — every placeable `GUITAR_SHAPES[quality]` entry from
// that are placeable for this root (open shapes only in their native key, // src/lib/voicings.js (open shapes only in their native key, movable shapes
// movable shapes only when the whole grip fits under fret 15), rendered via // only when the whole grip fits under fret 15), each cell = shape label +
// the existing <ChordDiagram/> ({rootStr, offsets} / {frets, onlyRoot} + rootPc). // <ChordDiagram size="thumb"/> + its own ▶.
// Piano row — the four src/lib/piano.js `pianoVoicing` styles // Piano section — all four src/lib/piano.js `pianoVoicing` styles
// (root / shell / rootlessA / rootlessB) rendered via <MiniPiano voicing/>, // (root / shell / rootlessA / rootlessB), each cell = the voicing's honest
// chips carrying each voicing's honest label (e.g. "rootless A (3-5-7-9)"). // label (e.g. "rootless A (3-5-7-9)") + <MiniPiano voicing size="thumb"/> +
// its own ▶.
// //
// Playback: src/lib/chordAudio.js (L-20). Each row's ▶ plays the SELECTED // Playback: src/lib/chordAudio.js (L-20). ONE live {stop} handle for the whole
// voicing; the previous sound is always stopped via the returned {stop} handle // gallery — any ▶ stops the previous sound before starting (chord change and
// before a new one starts (switching chips also stops it), so previews never // unmount also stop it), so previews never layer. First ▶ click is the user
// layer. First ▶ click is the user gesture that lazily creates the AudioContext. // gesture that lazily creates the AudioContext.
// //
// Mount points (docs/design/knowledge-center.md §3 — wired by L-21/L-22, NOT here): // Mount points (wired by L-21/L-22, NOT here): Knowledge Center Voicings
// Knowledge Center Voicings section, ChordDetailModal Guitar/Piano tabs, and the // section, ChordDetailModal Guitar/Piano tabs (show="guitar"/"piano", L-25),
// Jam Guide station-enlarge view. This component stays pure & prop-driven. // and the Jam Guide station-enlarge view. This component stays pure & prop-driven.
//
// Layout: each instrument section is a flex-wrap gallery of fixed-content-width
// cells, so it reflows to fewer columns (down to one cell per row) inside a
// narrow modal or the Jam Guide dock — no horizontal scroll needed except the
// per-cell guard around the widest MiniPiano thumbs (~266px for 2-octave
// rootless voicings).
// //
// Props: // Props:
// rootPc — chord root pitch class 011 (default 0 = C) // rootPc — chord root pitch class 011 (default 0 = C)
// quality — CHORD_TYPES key; unknown values fall back to 'maj' // quality — CHORD_TYPES key; unknown values fall back to 'maj'
// (matching voicings.js / piano.js behaviour) // (matching voicings.js / piano.js behaviour)
// show — 'guitar' | 'piano' | 'both' (default 'both', task D-23): which // show — 'guitar' | 'piano' | 'both' (default 'both', task D-23): which
// instrument row(s) to render. Any other value falls back to both, // instrument section(s) to render. Any other value falls back to
// so every pre-existing mount renders identically with no prop. // both, so every pre-existing mount renders identically with no prop.
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef } from 'react'
import ChordDiagram from './ChordDiagram' import ChordDiagram from './ChordDiagram'
import MiniPiano from './MiniPiano' import MiniPiano from './MiniPiano'
import { GUITAR_SHAPES } from '../lib/voicings' import { GUITAR_SHAPES } from '../lib/voicings'
import { pianoVoicing, hasTrueSeventh } from '../lib/piano' import { pianoVoicing } from '../lib/piano'
import { playVoicing, guitarShapeToNotes } from '../lib/chordAudio' import { playVoicing, guitarShapeToNotes } from '../lib/chordAudio'
import { NOTES, CHORD_TYPES } from '../lib/theory' import { NOTES, CHORD_TYPES } from '../lib/theory'
@@ -72,29 +82,8 @@ function chordName(rootPc, quality) {
// ─── Small presentational atoms ─────────────────────────────────────────────── // ─── Small presentational atoms ───────────────────────────────────────────────
// Selection chip. Active state puts small accent text on bg-surface (#0f0f0f), // Per-cell ▶. Small accent text sits on bg-surface (#0f0f0f), where accent
// where accent #a855f7 measures ≈4.8:1 — AA for small text (surface-background // #a855f7 measures ≈4.8:1 — AA for small text (surface-background rule).
// rule); inactive text is gray-300 on surface (AA comfortable).
function Chip({ active, onClick, title, children }) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
title={title}
className={
'h-8 shrink-0 rounded-full border px-3 text-xs leading-none outline-none transition ' +
'focus-visible:ring-2 focus-visible:ring-accent ' +
(active
? 'border-accent bg-surface font-semibold text-accent'
: 'border-border bg-surface text-gray-300 hover:border-gray-500 hover:text-gray-100')
}
>
{children}
</button>
)
}
function PlayButton({ ariaLabel, onClick }) { function PlayButton({ ariaLabel, onClick }) {
return ( return (
<button <button
@@ -102,8 +91,8 @@ function PlayButton({ ariaLabel, onClick }) {
aria-label={ariaLabel} aria-label={ariaLabel}
onClick={onClick} onClick={onClick}
className={ className={
'inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border border-accent ' + 'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-accent ' +
'bg-surface px-3 text-xs font-semibold text-accent outline-none transition ' + 'bg-surface px-2.5 text-xs font-semibold text-accent outline-none transition ' +
'hover:bg-accent hover:text-black focus-visible:ring-2 focus-visible:ring-accent' 'hover:bg-accent hover:text-black focus-visible:ring-2 focus-visible:ring-accent'
} }
> >
@@ -123,18 +112,39 @@ function SectionHeading({ children }) {
) )
} }
// ─── The browser ────────────────────────────────────────────────────────────── // One gallery cell: label on top, diagram thumb, its own ▶ underneath.
// bg-surface inside the bg-panel section gives the cells a quiet inlay border;
// label is gray-300 on surface (AA comfortable at 11px semibold).
function GalleryCell({ label, playLabel, onPlay, children }) {
return (
<figure className="flex min-w-0 flex-col items-center gap-1.5 rounded-md border border-border bg-surface p-2">
<figcaption
className="max-w-full break-words text-center text-[11px] font-medium leading-tight text-gray-300"
title={label}
>
{label}
</figcaption>
{/* Scroll guard: MiniPiano's SVG has a fixed pixel width (up to ~266px
for a 2-octave thumb window); scroll inside the cell on very narrow
viewports rather than letting it break the wrap layout. */}
<div className="max-w-full overflow-x-auto">{children}</div>
<PlayButton ariaLabel={playLabel} onClick={onPlay} />
</figure>
)
}
// ─── The gallery ──────────────────────────────────────────────────────────────
export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'both' }) { export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'both' }) {
const pc = mod12(Number.isFinite(rootPc) ? rootPc : 0) const pc = mod12(Number.isFinite(rootPc) ? rootPc : 0)
const name = chordName(pc, quality) const name = chordName(pc, quality)
const chordKey = `${pc}:${quality}` const chordKey = `${pc}:${quality}`
// Row gating (D-23). 'guitar' hides the piano row, 'piano' hides the guitar // Section gating (D-23). 'guitar' hides the piano section, 'piano' hides the
// row, anything else (incl. the 'both' default) shows both — so at least one // guitar section, anything else (incl. the 'both' default) shows both — so at
// row ALWAYS renders, and the mic-feedback microcopy below stays with it. // least one section ALWAYS renders, and the mic-feedback microcopy below
// Hooks stay unconditional; the shared stop-handle discipline (stopCurrent on // stays with it. Hooks stay unconditional; the shared stop-handle discipline
// chip switch / chord change / unmount) is untouched by hiding a row. // (stop on chord change / unmount) is untouched by hiding a section.
const showGuitar = show !== 'piano' const showGuitar = show !== 'piano'
const showPiano = show !== 'guitar' const showPiano = show !== 'guitar'
@@ -148,34 +158,17 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
[pc, quality], [pc, quality],
) )
const [guitarIdx, setGuitarIdx] = useState(0) // One live playback handle for the whole gallery: any new play (or chord
const [pianoStyle, setPianoStyle] = useState(hasTrueSeventh(quality) ? 'shell' : 'root') // change, or unmount) stops the previous sound first — the L-20 {stop}
// contract, so previews never layer or leak.
// One live playback handle for the whole browser: any new play (or chip
// switch, chord change, unmount) stops the previous sound first — the
// L-20 {stop} contract, so previews never layer or leak.
const handleRef = useRef(null) const handleRef = useRef(null)
const stopCurrent = () => { const stopCurrent = () => {
handleRef.current?.stop() handleRef.current?.stop()
handleRef.current = null handleRef.current = null
} }
// New chord → reset selections, silence the old preview. // Chord change → cleanup silences the old preview; same cleanup covers unmount.
useEffect(() => { useEffect(() => stopCurrent, [chordKey])
setGuitarIdx(0)
setPianoStyle(hasTrueSeventh(quality) ? 'shell' : 'root')
stopCurrent()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chordKey])
// Unmount → release whatever is still ringing.
useEffect(() => stopCurrent, [])
// Guard against a stale index during the one render before the reset effect.
const gi = Math.min(guitarIdx, Math.max(0, guitarShapes.length - 1))
const selectedShape = guitarShapes[gi] ?? null
const selectedPiano =
pianoOptions.find((o) => o.style === pianoStyle) ?? pianoOptions[0]
// Known advisory (L-20 gate): when a movable shape's root lands on an open // Known advisory (L-20 gate): when a movable shape's root lands on an open
// string (base fret 0), ChordDiagram draws the fret-12 octave barre while // string (base fret 0), ChordDiagram draws the fret-12 octave barre while
@@ -199,20 +192,14 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
return ( return (
<div className="flex w-full min-w-0 flex-wrap gap-2"> <div className="flex w-full min-w-0 flex-wrap gap-2">
{/* ── Guitar row ── */} {/* ── Guitar section: every placeable shape, side by side ── */}
{showGuitar && ( {showGuitar && (
<section <section
aria-label={`Guitar voicings for ${name}`} aria-label={`Guitar voicings for ${name}`}
className="min-w-[240px] flex-1 basis-[300px] rounded-lg border border-border bg-panel p-3" className="min-w-[240px] flex-1 basis-[300px] rounded-lg border border-border bg-panel p-3"
> >
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2">
<SectionHeading>Guitar · {name}</SectionHeading> <SectionHeading>Guitar · {name}</SectionHeading>
{selectedShape && (
<PlayButton
ariaLabel={`Play ${name}${selectedShape.label} guitar voicing`}
onClick={() => playGuitar(selectedShape)}
/>
)}
</div> </div>
{guitarShapes.length === 0 ? ( {guitarShapes.length === 0 ? (
@@ -221,86 +208,60 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
No guitar shape sits comfortably for {name} try the piano voicings. No guitar shape sits comfortably for {name} try the piano voicings.
</p> </p>
) : ( ) : (
<>
<div <div
role="group" role="group"
aria-label={`${name} guitar shape options`} aria-label={`${name} guitar shapes — every shape shown, each playable`}
className="mb-2 flex flex-wrap gap-1.5" className="flex flex-wrap items-stretch gap-2"
> >
{guitarShapes.map((shape, i) => ( {guitarShapes.map((shape, i) => (
<Chip <GalleryCell
key={`${shape.label}-${i}`} key={`${shape.label}-${i}`}
active={i === gi} label={shape.label}
title={`${shape.label} shape`} playLabel={`Play ${name}${shape.label} guitar voicing`}
onClick={() => { onPlay={() => playGuitar(shape)}
stopCurrent()
setGuitarIdx(i)
}}
> >
{shape.label} <ChordDiagram shape={shape} rootPc={pc} size="thumb" />
</Chip> </GalleryCell>
))} ))}
</div> </div>
<div className="flex flex-wrap items-start gap-3">
<ChordDiagram
shape={selectedShape}
rootPc={pc}
size="full"
label={name}
/>
</div>
</>
)} )}
</section> </section>
)} )}
{/* ── Piano row ── */} {/* ── Piano section: all four voicing styles, side by side ── */}
{showPiano && ( {showPiano && (
<section <section
aria-label={`Piano voicings for ${name}`} aria-label={`Piano voicings for ${name}`}
className="min-w-[240px] flex-1 basis-[300px] rounded-lg border border-border bg-panel p-3" className="min-w-[240px] flex-1 basis-[300px] rounded-lg border border-border bg-panel p-3"
> >
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2">
<SectionHeading>Piano · {name}</SectionHeading> <SectionHeading>Piano · {name}</SectionHeading>
<PlayButton
ariaLabel={`Play ${name}${selectedPiano.voicing.label} piano voicing`}
onClick={() => playPiano(selectedPiano.voicing)}
/>
</div> </div>
<div <div
role="group" role="group"
aria-label={`${name} piano voicing options`} aria-label={`${name} piano voicings — every style shown, each playable`}
className="mb-2 flex flex-wrap gap-1.5" className="flex flex-wrap items-stretch gap-2"
> >
{pianoOptions.map(({ style, voicing }) => ( {/* pianoVoicing() output carries no rootPc, and without it VoicingPiano
<Chip
key={style}
active={style === selectedPiano.style}
title={voicing.label}
onClick={() => {
stopCurrent()
setPianoStyle(style)
}}
>
{voicing.label}
</Chip>
))}
</div>
{/* MiniPiano's SVG has a fixed pixel width (up to ~390px for 3 octaves);
scroll it on narrow columns rather than letting it break the layout.
pianoVoicing() output carries no rootPc, and without it VoicingPiano
falls back to the LOWEST voice for its "R" badge — wrong for rootless falls back to the LOWEST voice for its "R" badge — wrong for rootless
voicings, whose bass is the 3rd (A) or 7th (B). Supply the chord root. */} voicings, whose bass is the 3rd (A) or 7th (B). Supply the chord root. */}
<div className="max-w-full overflow-x-auto"> {pianoOptions.map(({ style, voicing }) => (
<MiniPiano voicing={{ ...selectedPiano.voicing, rootPc: pc }} size="thumb" /> <GalleryCell
key={style}
label={voicing.label}
playLabel={`Play ${name}${voicing.label} piano voicing`}
onPlay={() => playPiano(voicing)}
>
<MiniPiano voicing={{ ...voicing, rootPc: pc }} size="thumb" />
</GalleryCell>
))}
</div> </div>
</section> </section>
)} )}
{/* Mic-feedback caveat, per the L-20 header + D-20 §3 (microcopy tier). {/* Mic-feedback caveat, per the L-20 header + D-20 §3 (microcopy tier).
At least one row always renders (see the gating above), so this stays. */} At least one section always renders (see the gating above), so this stays. */}
<p className="w-full basis-full text-[11px] text-gray-500"> <p className="w-full basis-full text-[11px] text-gray-500">
Previews play through your speakers while the mic is live, detection may Previews play through your speakers while the mic is live, detection may
hear them. hear them.