From fe49af36acb61b0f10f5b39abb8e8e0af24f063a Mon Sep 17 00:00:00 2001 From: vadimwit Date: Tue, 16 Jun 2026 11:00:40 +0100 Subject: [PATCH] feat(piano): MiniPiano gains additive voicing prop for the Jam Guide (task D-10) MiniPiano now dispatches: with a voicing={pianoVoicing(...)} prop it renders the {notes,pcs,bass,style,label} on a 2-3 octave keyboard (root accent, bass amber-ringed, thumb/full); without it, the legacy {rootPc,lh,rh} path is byte-unchanged so ChordDetailModal + ExplorePanel render identically. Critic PASS. Co-Authored-By: Claude Fable 5 --- src/components/MiniPiano.jsx | 209 +++++++++++++++++++++++++++++++---- 1 file changed, 189 insertions(+), 20 deletions(-) diff --git a/src/components/MiniPiano.jsx b/src/components/MiniPiano.jsx index 208c4c6..019b820 100644 --- a/src/components/MiniPiano.jsx +++ b/src/components/MiniPiano.jsx @@ -1,10 +1,27 @@ -// 2-octave mini piano keyboard showing technique notes -// Props: -// rootPc — root pitch class 0-11 -// lh — array of semitone intervals above root (left hand, shown in blue) -// rh — array of semitone intervals above root (right hand, shown in purple) +// Mini piano keyboard. +// +// TWO render modes, chosen by props (additive — legacy path is the default): +// +// 1. LEGACY +// Used by ChordDetailModal + ExplorePanel. `lh`/`rh` are semitone intervals +// ABOVE the root (left hand shown blue, right hand shown purple), spanning a +// 2-octave keyboard. Behaviour here is UNCHANGED — byte-for-byte the same +// output the existing consumers have always rendered. +// +// 2. VOICING +// Renders the output of src/lib/piano.js `pianoVoicing({rootPc,quality},opts)`: +// voicing.notes — ABSOLUTE semitone key positions, 0 = C of the low octave, +// range [0,36]. A note value `n` maps to the key `n` +// semitones above the low C (octave = ⌊n/12⌋, pc = n%12). +// voicing.pcs — pitch classes sounding. +// voicing.bass — lowest absolute note (the LH anchor) — marked distinctly. +// voicing.style / voicing.label — captions (shown in `full` size). +// The root pitch class lights in accent purple; the bass key is ringed as the +// LH anchor; the other voicing tones light in a lighter purple. +// +// Design tokens (tailwind.config.js): accent #a855f7. The SVG also uses the +// established Piano/Fretboard note language (accent purple for the focal tone). -const OCTAVES = 2 const WW = 22 // white key width const WH = 60 // white key height const BW = 14 // black key width @@ -22,20 +39,33 @@ const BLACK_OFFSETS = [ { pc: 10, afterWhite: 5 }, // A# ] -const TOTAL_WHITES = WHITE_PCS.length * OCTAVES // 14 -const SVG_W = WW * TOTAL_WHITES + 2 -const SVG_H = WH + 24 +// ─── Design tokens (literal — SVG fills can't read Tailwind classes) ─────────── +const ACCENT = '#a855f7' // text-accent — root pitch class (focal tone) +const ACCENT_SOFT = '#c084fc' // lighter accent — non-root voicing tones +const LH_BLUE = '#3b82f6' // legacy left-hand colour (unchanged) +const WHITE_FILL = '#f5f5f5' +const BLACK_FILL = '#1f2937' +const WHITE_STROKE = '#374151' +const BLACK_STROKE = '#111827' +const BASS_RING = '#fbbf24' // amber ring marking the LH bass anchor (AA on keys) -function noteColor(interval) { - // interval < 12 → first octave (root region), ≥12 → second octave - return interval < 12 ? '#a855f7' : '#c084fc' +function legacyNoteColor(hand) { + return hand === 'L' ? LH_BLUE : ACCENT } function handLabel(hand) { return hand === 'L' ? 'LH' : 'RH' } -export default function MiniPiano({ rootPc, lh = [], rh = [] }) { +// ════════════════════════════════════════════════════════════════════════════ +// LEGACY render path — {rootPc, lh, rh}. UNCHANGED from the original component. +// ════════════════════════════════════════════════════════════════════════════ +function LegacyPiano({ rootPc, lh = [], rh = [] }) { + const OCTAVES = 2 + const TOTAL_WHITES = WHITE_PCS.length * OCTAVES // 14 + const SVG_W = WW * TOTAL_WHITES + 2 + const SVG_H = WH + 24 + // Build a set of highlighted notes: pc → { hand, interval } // We span 2 octaves (semitones 0…23 above root), mapped to absolute pitch classes const highlights = new Map() // absIdx → { color, label } @@ -44,8 +74,7 @@ export default function MiniPiano({ rootPc, lh = [], rh = [] }) { for (const iv of intervals) { const octave = Math.floor(iv / 12) const pc = (rootPc + iv) % 12 - const absIdx = octave * 12 + pc // unique index per octave slot - highlights.set(`${octave}-${pc}`, { color: hand === 'L' ? '#3b82f6' : '#a855f7', label: handLabel(hand) }) + highlights.set(`${octave}-${pc}`, { color: legacyNoteColor(hand), label: handLabel(hand) }) } } addNotes(lh, 'L') @@ -86,8 +115,8 @@ export default function MiniPiano({ rootPc, lh = [], rh = [] }) { {hl && ( @@ -105,8 +134,8 @@ export default function MiniPiano({ rootPc, lh = [], rh = [] }) { {hl && ( @@ -124,7 +153,7 @@ export default function MiniPiano({ rootPc, lh = [], rh = [] }) { if (!isRoot) return null return ( + textAnchor="middle" fill={ACCENT} fontSize={8} fontWeight="bold"> R ) @@ -132,3 +161,143 @@ export default function MiniPiano({ rootPc, lh = [], rh = [] }) { ) } + +// ════════════════════════════════════════════════════════════════════════════ +// VOICING render path — renders a pianoVoicing({notes,pcs,bass,style,label}). +// `notes` are absolute semitone positions, 0 = C of the low octave, range [0,36]. +// ════════════════════════════════════════════════════════════════════════════ +function VoicingPiano({ voicing, size }) { + const isFull = size === 'full' + const notes = Array.isArray(voicing?.notes) ? voicing.notes : [] + const rootPc = ((voicing?.rootPc ?? (notes.length ? notes[0] : 0)) % 12 + 12) % 12 + const bass = typeof voicing?.bass === 'number' ? voicing.bass : (notes.length ? Math.min(...notes) : null) + + // Span enough octaves to contain the highest note (notes ≤ 36 → 3 octaves + the + // closing high C, so a value of 36 lands on the last white key). + const maxNote = notes.length ? Math.max(...notes) : 0 + const OCTAVES = Math.min(3, Math.max(2, Math.ceil((maxNote + 1) / 12))) + // White keys: OCTAVES full octaves + 1 trailing C so the top octave's C (e.g. 36) shows. + const TOTAL_WHITES = WHITE_PCS.length * OCTAVES + 1 + const scale = isFull ? 1 : 0.8 + const baseW = WW * TOTAL_WHITES + 2 + const SVG_W = baseW * scale + const SVG_H = (WH + (isFull ? 26 : 4)) * scale + + // A highlighted absolute note → its render style. Keyed by absolute note value. + const noteSet = new Set(notes) + function styleFor(absNote) { + if (!noteSet.has(absNote)) return null + const pc = ((absNote % 12) + 12) % 12 + return { + isRoot: pc === rootPc, + isBass: absNote === bass, + color: pc === rootPc ? ACCENT : ACCENT_SOFT, + } + } + + // White keys across OCTAVES octaves + trailing C. + const whites = [] + for (let oct = 0; oct < OCTAVES; oct++) { + for (let wi = 0; wi < WHITE_PCS.length; wi++) { + const pc = WHITE_PCS[wi] + const absWi = oct * WHITE_PCS.length + wi + const absNote = oct * 12 + pc // absolute semitone of this white key + const x = absWi * WW + 1 + whites.push({ x, absWi, absNote, hl: styleFor(absNote) }) + } + } + // Trailing high C (top of the renderable window, e.g. note 36 when OCTAVES=3). + { + const absWi = OCTAVES * WHITE_PCS.length + const absNote = OCTAVES * 12 + whites.push({ x: absWi * WW + 1, absWi, absNote, hl: styleFor(absNote) }) + } + + // Black keys across OCTAVES octaves. + const blacks = [] + for (let oct = 0; oct < OCTAVES; oct++) { + for (const { pc, afterWhite } of BLACK_OFFSETS) { + const absWi = oct * WHITE_PCS.length + afterWhite + const absNote = oct * 12 + pc + const x = absWi * WW + WW - BW / 2 + blacks.push({ x, pc, oct, absNote, hl: styleFor(absNote) }) + } + } + + const ariaLabel = `Piano voicing${voicing?.label ? `: ${voicing.label}` : ''}` + + return ( + + {/* White keys */} + {whites.map(({ x, hl, absWi }) => ( + + + {hl?.isBass && ( + + )} + {hl?.isRoot && ( + + R + + )} + + ))} + + {/* Black keys */} + {blacks.map(({ x, pc, oct, hl }) => ( + + + {hl?.isBass && ( + + )} + {hl?.isRoot && ( + + R + + )} + + ))} + + {/* Caption (full size only): label + style */} + {isFull && voicing?.label && ( + + {voicing.label} + + )} + + ) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Public component — dispatches on whether `voicing` is supplied (additive). +// ════════════════════════════════════════════════════════════════════════════ +export default function MiniPiano({ rootPc, lh = [], rh = [], voicing, size = 'thumb' }) { + if (voicing) { + return + } + return +}