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
// 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
// that are placeable for this root (open shapes only in their native key,
// movable shapes only when the whole grip fits under fret 15), rendered via
// the existing <ChordDiagram/> ({rootStr, offsets} / {frets, onlyRoot} + rootPc).
// Piano row — the four src/lib/piano.js `pianoVoicing` styles
// (root / shell / rootlessA / rootlessB) rendered via <MiniPiano voicing/>,
// chips carrying each voicing's honest label (e.g. "rootless A (3-5-7-9)").
// Guitar section — every placeable `GUITAR_SHAPES[quality]` entry from
// src/lib/voicings.js (open shapes only in their native key, movable shapes
// only when the whole grip fits under fret 15), each cell = shape label +
// <ChordDiagram size="thumb"/> + its own ▶.
// Piano section — all four src/lib/piano.js `pianoVoicing` styles
// (root / shell / rootlessA / rootlessB), each cell = the voicing's honest
// 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
// voicing; the previous sound is always stopped via the returned {stop} handle
// before a new one starts (switching chips also stops it), so previews never
// layer. First ▶ click is the user gesture that lazily creates the AudioContext.
// Playback: src/lib/chordAudio.js (L-20). ONE live {stop} handle for the whole
// gallery — any ▶ stops the previous sound before starting (chord change and
// unmount also stop it), so previews never layer. First ▶ click is the user
// gesture that lazily creates the AudioContext.
//
// Mount points (docs/design/knowledge-center.md §3 — wired by L-21/L-22, NOT here):
// Knowledge Center Voicings section, ChordDetailModal Guitar/Piano tabs, and the
// Jam Guide station-enlarge view. This component stays pure & prop-driven.
// Mount points (wired by L-21/L-22, NOT here): Knowledge Center Voicings
// section, ChordDetailModal Guitar/Piano tabs (show="guitar"/"piano", L-25),
// 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:
// rootPc — chord root pitch class 011 (default 0 = C)
// quality — CHORD_TYPES key; unknown values fall back to 'maj'
// (matching voicings.js / piano.js behaviour)
// show — 'guitar' | 'piano' | 'both' (default 'both', task D-23): which
// instrument row(s) to render. Any other value falls back to both,
// so every pre-existing mount renders identically with no prop.
// instrument section(s) to render. Any other value falls back to
// 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 MiniPiano from './MiniPiano'
import { GUITAR_SHAPES } from '../lib/voicings'
import { pianoVoicing, hasTrueSeventh } from '../lib/piano'
import { pianoVoicing } from '../lib/piano'
import { playVoicing, guitarShapeToNotes } from '../lib/chordAudio'
import { NOTES, CHORD_TYPES } from '../lib/theory'
@@ -72,29 +82,8 @@ function chordName(rootPc, quality) {
// ─── Small presentational atoms ───────────────────────────────────────────────
// Selection chip. Active state puts small accent text on bg-surface (#0f0f0f),
// where accent #a855f7 measures ≈4.8:1 — AA for small text (surface-background
// 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>
)
}
// Per-cell ▶. Small accent text sits on bg-surface (#0f0f0f), where accent
// #a855f7 measures ≈4.8:1 — AA for small text (surface-background rule).
function PlayButton({ ariaLabel, onClick }) {
return (
<button
@@ -102,8 +91,8 @@ function PlayButton({ ariaLabel, onClick }) {
aria-label={ariaLabel}
onClick={onClick}
className={
'inline-flex h-8 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 ' +
'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-accent ' +
'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'
}
>
@@ -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' }) {
const pc = mod12(Number.isFinite(rootPc) ? rootPc : 0)
const name = chordName(pc, quality)
const chordKey = `${pc}:${quality}`
// Row gating (D-23). 'guitar' hides the piano row, 'piano' hides the guitar
// row, anything else (incl. the 'both' default) shows both — so at least one
// row ALWAYS renders, and the mic-feedback microcopy below stays with it.
// Hooks stay unconditional; the shared stop-handle discipline (stopCurrent on
// chip switch / chord change / unmount) is untouched by hiding a row.
// Section gating (D-23). 'guitar' hides the piano section, 'piano' hides the
// guitar section, anything else (incl. the 'both' default) shows both — so at
// least one section ALWAYS renders, and the mic-feedback microcopy below
// stays with it. Hooks stay unconditional; the shared stop-handle discipline
// (stop on chord change / unmount) is untouched by hiding a section.
const showGuitar = show !== 'piano'
const showPiano = show !== 'guitar'
@@ -148,34 +158,17 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
[pc, quality],
)
const [guitarIdx, setGuitarIdx] = useState(0)
const [pianoStyle, setPianoStyle] = useState(hasTrueSeventh(quality) ? 'shell' : 'root')
// 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.
// One live playback handle for the whole gallery: any new play (or chord
// change, or unmount) stops the previous sound first — the L-20 {stop}
// contract, so previews never layer or leak.
const handleRef = useRef(null)
const stopCurrent = () => {
handleRef.current?.stop()
handleRef.current = null
}
// New chord → reset selections, silence the old preview.
useEffect(() => {
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]
// Chord change → cleanup silences the old preview; same cleanup covers unmount.
useEffect(() => stopCurrent, [chordKey])
// 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
@@ -199,20 +192,14 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
return (
<div className="flex w-full min-w-0 flex-wrap gap-2">
{/* ── Guitar row ── */}
{/* ── Guitar section: every placeable shape, side by side ── */}
{showGuitar && (
<section
aria-label={`Guitar voicings for ${name}`}
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>
{selectedShape && (
<PlayButton
ariaLabel={`Play ${name}${selectedShape.label} guitar voicing`}
onClick={() => playGuitar(selectedShape)}
/>
)}
</div>
{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.
</p>
) : (
<>
<div
role="group"
aria-label={`${name} guitar shape options`}
className="mb-2 flex flex-wrap gap-1.5"
aria-label={`${name} guitar shapes — every shape shown, each playable`}
className="flex flex-wrap items-stretch gap-2"
>
{guitarShapes.map((shape, i) => (
<Chip
<GalleryCell
key={`${shape.label}-${i}`}
active={i === gi}
title={`${shape.label} shape`}
onClick={() => {
stopCurrent()
setGuitarIdx(i)
}}
label={shape.label}
playLabel={`Play ${name}${shape.label} guitar voicing`}
onPlay={() => playGuitar(shape)}
>
{shape.label}
</Chip>
<ChordDiagram shape={shape} rootPc={pc} size="thumb" />
</GalleryCell>
))}
</div>
<div className="flex flex-wrap items-start gap-3">
<ChordDiagram
shape={selectedShape}
rootPc={pc}
size="full"
label={name}
/>
</div>
</>
)}
</section>
)}
{/* ── Piano row ── */}
{/* ── Piano section: all four voicing styles, side by side ── */}
{showPiano && (
<section
aria-label={`Piano voicings for ${name}`}
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>
<PlayButton
ariaLabel={`Play ${name}${selectedPiano.voicing.label} piano voicing`}
onClick={() => playPiano(selectedPiano.voicing)}
/>
</div>
<div
role="group"
aria-label={`${name} piano voicing options`}
className="mb-2 flex flex-wrap gap-1.5"
aria-label={`${name} piano voicings — every style shown, each playable`}
className="flex flex-wrap items-stretch gap-2"
>
{pianoOptions.map(({ style, voicing }) => (
<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
{/* pianoVoicing() output carries no rootPc, and without it VoicingPiano
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. */}
<div className="max-w-full overflow-x-auto">
<MiniPiano voicing={{ ...selectedPiano.voicing, rootPc: pc }} size="thumb" />
{pianoOptions.map(({ style, voicing }) => (
<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>
</section>
)}
{/* 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">
Previews play through your speakers while the mic is live, detection may
hear them.