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 (
+
+ )
+}
+
+// ════════════════════════════════════════════════════════════════════════════
+// Public component — dispatches on whether `voicing` is supplied (additive).
+// ════════════════════════════════════════════════════════════════════════════
+export default function MiniPiano({ rootPc, lh = [], rh = [], voicing, size = 'thumb' }) {
+ if (voicing) {
+ return
+ }
+ return
+}