Files
JamBuddy/src/components/MiniPiano.jsx
T
vadimwit aae69e8a7b feat(rail): voicings rail polish — 4 guitar shapes, 2x2 mini piano, no play buttons, dark scrollbars (task L-70)
Dashboard voicings rail: guitar galleries capped to 4 shapes
(recommended-first, then open, then movable by base fret; prop-gated so
the Knowledge Center and chord modal keep all shapes); piano voicings
render as a 2x2 grid of size="mini" (scale 0.60) keyboards under the
rail's dense path only. Play buttons removed globally from
VoicingBrowser per the user ("leave them off, better not") — the
Knowledge Center + modal render byte-identical minus the buttons.
A scoped .dark-scroll utility (thin, #2a2a2a on transparent) dresses
the dashboard scrollers. Critic PASS (combined gate: byte-identity
proven, zero play buttons everywhere, contract intact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:58:39 +01:00

333 lines
14 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Mini piano keyboard.
//
// TWO render modes, chosen by props (additive — legacy path is the default):
//
// 1. LEGACY <MiniPiano rootPc={..} lh={[..]} rh={[..]} />
// 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 <MiniPiano voicing={pianoVoicing(...)} size="thumb|full" />
// 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.
//
// Keyboard window (D-24): `size="thumb"` crops to the voicing's whole-octave
// span — from the octave-start at-or-below the lowest sounded key to the
// octave-end at-or-above the highest (plus the closing high C) — so a
// ≤1-octave shell renders ONE octave (~140px) instead of 23 (~266390px)
// and Jam Guide station thumbs sit level with guitar ChordDiagram thumbs.
// `size="mini"` (L-70, dashboard-polish.md §2) is the SAME cropped window at
// scale 0.60 (vs thumb 0.8) so two cells fit one grid column of the rail's
// ~451px interior (2-octave cell 213.2px < 222.5px); it bumps the in-SVG "R"
// glyph (8→10 vu) and the bass-ring stroke (2→2.5) so both stay legible at
// the smaller render. `thumb`/`full` are byte-untouched — `mini` is additive.
// `size="full"` keeps the wide C-anchored 23-octave window: the enlarged
// view benefits from register context, and its output stays byte-identical
// to the D-10 behaviour.
//
// Design tokens (tailwind.config.js): accent #a855f7. The SVG also uses the
// established Piano/Fretboard note language (accent purple for the focal tone).
const WW = 22 // white key width
const WH = 60 // white key height
const BW = 14 // black key width
const BH = 38 // black key height
// White key pitch classes within an octave, in order
const WHITE_PCS = [0, 2, 4, 5, 7, 9, 11] // C D E F G A B
const WHITE_NAMES = ['C','D','E','F','G','A','B']
// Black key offsets (x position relative to white key 0) and pitch classes
const BLACK_OFFSETS = [
{ pc: 1, afterWhite: 0 }, // C#
{ pc: 3, afterWhite: 1 }, // D#
{ pc: 6, afterWhite: 3 }, // F#
{ pc: 8, afterWhite: 4 }, // G#
{ pc: 10, afterWhite: 5 }, // A#
]
// ─── 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 legacyNoteColor(hand) {
return hand === 'L' ? LH_BLUE : ACCENT
}
function handLabel(hand) {
return hand === 'L' ? '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 }
function addNotes(intervals, hand) {
for (const iv of intervals) {
const octave = Math.floor(iv / 12)
const pc = (rootPc + iv) % 12
highlights.set(`${octave}-${pc}`, { color: legacyNoteColor(hand), label: handLabel(hand) })
}
}
addNotes(lh, 'L')
addNotes(rh, 'R')
function isHighlighted(octave, pc) {
return highlights.get(`${octave}-${pc}`)
}
// White keys
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 x = absWi * WW + 1
const hl = isHighlighted(oct, pc)
whites.push({ x, pc, oct, wi, absWi, hl, name: WHITE_NAMES[wi] + (oct + 4) })
}
}
// Black keys
const blacks = []
for (let oct = 0; oct < OCTAVES; oct++) {
for (const { pc, afterWhite } of BLACK_OFFSETS) {
const absWi = oct * WHITE_PCS.length + afterWhite
const x = absWi * WW + WW - BW / 2
const hl = isHighlighted(oct, pc)
blacks.push({ x, pc, oct, hl })
}
}
return (
<svg width={SVG_W} height={SVG_H} viewBox={`0 0 ${SVG_W} ${SVG_H}`} className="overflow-visible">
{/* White keys */}
{whites.map(({ x, hl, name, absWi }) => (
<g key={`w${absWi}`}>
<rect
x={x} y={1} width={WW - 1} height={WH}
rx={2}
fill={hl ? hl.color : WHITE_FILL}
stroke={WHITE_STROKE}
strokeWidth={0.5}
/>
{hl && (
<text x={x + (WW - 1) / 2} y={WH - 8}
textAnchor="middle" fill="white" fontSize={7} fontWeight="bold">
{hl.label}
</text>
)}
</g>
))}
{/* Black keys */}
{blacks.map(({ x, pc, oct, hl }, i) => (
<g key={`b${oct}-${pc}`}>
<rect
x={x} y={1} width={BW} height={BH}
rx={2}
fill={hl ? hl.color : BLACK_FILL}
stroke={BLACK_STROKE}
strokeWidth={0.5}
/>
{hl && (
<text x={x + BW / 2} y={BH - 5}
textAnchor="middle" fill="white" fontSize={6} fontWeight="bold">
{hl.label}
</text>
)}
</g>
))}
{/* Root label at bottom */}
{whites.map(({ x, pc, oct, name, absWi }) => {
const isRoot = pc === rootPc && oct === 0
if (!isRoot) return null
return (
<text key={`lbl${absWi}`} x={x + (WW - 1) / 2} y={WH + 14}
textAnchor="middle" fill={ACCENT} fontSize={8} fontWeight="bold">
R
</text>
)
})}
</svg>
)
}
// ════════════════════════════════════════════════════════════════════════════
// 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 isMini = size === 'mini' // L-70: cropped-thumb window at 0.60 for the dashboard 2×2 grid
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)
// ── Keyboard window (D-24) ──────────────────────────────────────────────────
// `full`: the original wide window — C-anchored, 23 octaves, enough to contain
// the highest note (byte-identical to the pre-D-24 output).
// `thumb`: crop to the voicing's whole-octave span. The window starts at the
// octave C at-or-below the lowest sounded key (octStart) and runs whole
// octaves until the closing C at-or-above the highest key, so white-key
// geometry stays simple and a ≤1-octave voicing renders ONE octave.
// The trailing high C is always drawn (window note octStart*12 +
// OCTAVES*12), so a top note landing exactly on the octave boundary
// still shows without adding a whole extra octave.
const maxNote = notes.length ? Math.max(...notes) : 0
const minNote = notes.length ? Math.min(...notes) : 0
const octStart = isFull ? 0 : Math.floor(minNote / 12)
const OCTAVES = isFull
? Math.min(3, Math.max(2, Math.ceil((maxNote + 1) / 12)))
: Math.max(1, Math.ceil((maxNote - octStart * 12) / 12))
// White keys: OCTAVES full octaves + 1 trailing C closing the top octave.
const TOTAL_WHITES = WHITE_PCS.length * OCTAVES + 1
const scale = isFull ? 1 : isMini ? 0.6 : 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. `oct` is window-relative
// (drives x geometry); absNote adds octStart so highlights land on the right
// keys inside a cropped window.
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 = (octStart + 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 = (octStart + 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 = (octStart + 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 (
<svg
width={SVG_W} height={SVG_H} viewBox={`0 0 ${baseW} ${WH + (isFull ? 26 : 4)}`}
className="overflow-visible" role="img" aria-label={ariaLabel}
>
{/* White keys */}
{whites.map(({ x, hl, absWi }) => (
<g key={`w${absWi}`}>
<rect
x={x} y={1} width={WW - 1} height={WH}
rx={2}
fill={hl ? hl.color : WHITE_FILL}
stroke={WHITE_STROKE}
strokeWidth={0.5}
/>
{hl?.isBass && (
<rect
x={x + 1} y={2} width={WW - 3} height={WH - 2}
rx={2} fill="none" stroke={BASS_RING} strokeWidth={isMini ? 2.5 : 2}
/>
)}
{hl?.isRoot && (
<text x={x + (WW - 1) / 2} y={WH - 8}
textAnchor="middle" fill="white" fontSize={isMini ? 10 : 8} fontWeight="bold">
R
</text>
)}
</g>
))}
{/* Black keys */}
{blacks.map(({ x, pc, oct, hl }) => (
<g key={`b${oct}-${pc}`}>
<rect
x={x} y={1} width={BW} height={BH}
rx={2}
fill={hl ? hl.color : BLACK_FILL}
stroke={BLACK_STROKE}
strokeWidth={0.5}
/>
{hl?.isBass && (
<rect
x={x + 1} y={2} width={BW - 2} height={BH - 2}
rx={2} fill="none" stroke={BASS_RING} strokeWidth={isMini ? 2.5 : 2}
/>
)}
{hl?.isRoot && (
<text x={x + BW / 2} y={BH - 5}
textAnchor="middle" fill="white" fontSize={isMini ? 8 : 6} fontWeight="bold">
R
</text>
)}
</g>
))}
{/* Caption (full size only): label + style */}
{isFull && voicing?.label && (
<text x={baseW / 2} y={WH + 20}
textAnchor="middle" fill={ACCENT} fontSize={9} fontWeight="bold">
{voicing.label}
</text>
)}
</svg>
)
}
// ════════════════════════════════════════════════════════════════════════════
// Public component — dispatches on whether `voicing` is supplied (additive).
// ════════════════════════════════════════════════════════════════════════════
export default function MiniPiano({ rootPc, lh = [], rh = [], voicing, size = 'thumb' }) {
if (voicing) {
return <VoicingPiano voicing={voicing} size={size} />
}
return <LegacyPiano rootPc={rootPc} lh={lh} rh={rh} />
}