// PianoLickCard — degree-based piano lick renderer (task D-60).
//
// Renders one structured piano lick from the KB `licks` schema for piano packs
// (src/data/kb/SCHEMA.md "Piano licks"): degree-language notes realized over a
// LIVE root — the same reasoning as BassPatternCard: the data transposes, the
// card shows the actual keys under your hands right now.
//
// { id, name, level, chordContext, quality, techniques[], source?, tips?,
// notes: [{ deg|approach, octave?, beat?, technique? }] } // ordered
//
// ── Realization (SCHEMA.md rules) ─────────────────────────────────────────────
// Pitch space: MiniPiano's absolute-note window, 0 = C3, range [0, 36] — which
// is ALSO chordAudio's playVoicing space (its doc: "0 = the C of the low
// displayed octave = C3"), so realized notes drop straight into playback.
// · The ROOT sits at its pitch class in the bottom octave: rootAbs = rootPc
// (0–11). The schema caps every resolved offset at 25 semitones, so the
// highest legal note is 11 + 25 = 36 — every lick fits in all 12 keys.
// · A `deg` note: abs = rootAbs + resolveDegree(deg, lick.quality) + 12·octave.
// `quality` is the lick's own machine truth (NOT the live chord's quality —
// the lick states what it resolves through; the caller picks licks that fit).
// · An `approach` note derives from the NEXT `deg` note in `notes[]` order
// (scanning past intervening approaches): chrom-below = target − 1,
// chrom-above = target + 1. Approaches are non-terminal by schema; a lick
// ending on an approach (or any unresolvable note) nulls the realization and
// the card renders an honest placeholder instead of lying.
//
// ── Visual design (thumb): pitch-timeline ─────────────────────────────────────
// x = beat (proportional columns when every note carries a beat, LickCard-style
// even columns otherwise), y = pitch — a piano-roll miniature. Chosen over
// order-numbered keyboard dots for the thumb because a lick IS contour + rhythm:
// the line shape reads at strip size without decoding numbers. Note markers are
// LickCard's surface-pill idiom carrying NOTE NAMES (the actionable info for a
// keyboard player), connected by a faint contour line; dashed accent lines mark
// the root pitch ("R" gutter label) so "home" is visible at a glance.
//
// Colour tiers (established language, Fretboard/Piano/BassPatternCard):
// chord tone → purple (ACCENT_SOFT text; root itself full ACCENT)
// other scale degrees/extensions → gray-200
// approaches (borrowed chromatics) → amber, like BassPatternCard
// ghost-note → parenthesised, dimmed (LickCard idiom)
//
// Technique glyphs (amber): grace-note = slashed mini-note crushed before its
// target · slide = thicker amber glissando segment replacing that contour hop ·
// double-stop = stacked pills at one x (equal beats; or LickCard's column rule
// beatless) · ghost-note = the parenthesised dim label.
// renders the key ONCE per grid (LickCard's TechniqueLegend contract).
//
// `size="full"` adds: chordContext chip, an order-numbered keyboard view
// (MiniPiano's cropped-window geometry), technique chips, tips, source —
// mirroring LickCard's full-size behaviour.
//
// ── No playback (D-70 §3) ─────────────────────────────────────────────────────
// The strip is purely visual — glance over audio (the user's settled call, "then
// leave them off, better not"). The ▶ play path (sequencer + PlayButton) was
// removed with the rest of the ▶s across the app; the card only renders now.
//
// ── Wiring contract (LicksStrip integration — Luthier, L-71) ──────────────────
//
// lick — one entry of a piano pack's top-level `licks` array
// rootPc — the LIVE chord root pitch class (from the loop station)
// chordLabel — optional display name for aria/captions (falls back to
// NOTES[rootPc] + quality)
// Pure/presentational; never crashes on bad data (placeholder instead).
//
// Design tokens (tailwind.config.js) — SVG fills can't read Tailwind classes,
// so the constants below mirror the tokens (LickCard/MiniPiano convention).
import { NOTES, CHORD_TYPES } from '../lib/theory'
import { resolveDegree } from './JamGuide'
// ─── Vocabulary (hand-synced with validate-kb.mjs PIANO_LICK_TECHNIQUES; the
// smoke §7b-piano guard enforces set-equality — add to BOTH or neither) ────
export const PIANO_TECHNIQUE_VOCAB = ['slide', 'double-stop', 'ghost-note', 'grace-note']
// Chip symbol per technique (technique-tag chips, full size).
const TECH_SYMBOL = {
slide: '⟋',
'double-stop': '⋮',
'ghost-note': '( )',
'grace-note': '♪',
}
// ─── Palette (mirrors tailwind.config.js tokens + MiniPiano's key colours) ────
const ACCENT = '#a855f7' // token `accent` — the root (focal tone)
const ACCENT_SOFT = '#c084fc' // lighter accent — chord tones (MiniPiano tier)
const AMBER = '#f59e0b' // token `amber` — approaches + technique glyphs
const NOTE_TEXT = '#e5e7eb' // gray-200 — non-chord-tone degrees
const GHOST_TEXT = '#9ca3af' // gray-400 — ghost notes (dim but AA)
const CONTOUR = '#3a3a3a' // contour line (decorative, LickCard string idiom)
const LABEL = '#6b7280' // gray-500 — beat numbers / gutter microcopy
const CARD_BG = '#0f0f0f' // token `surface` — backing pills
const WHITE_FILL = '#f5f5f5' // MiniPiano key colours (keyboard view)
const BLACK_FILL = '#1f2937'
const WHITE_STROKE = '#374151'
const BLACK_STROKE = '#111827'
const APPROACH_DELTA = { 'chrom-below': -1, 'chrom-above': 1 }
const ABS_MAX = 36 // MiniPiano window top key
const mod12 = (n) => ((n % 12) + 12) % 12
// abs (0 = C3) → "A3"/"F4"… scientific spelling, for aria pitch lists.
const absName = (abs) => `${NOTES[mod12(abs)]}${Math.floor(abs / 12) + 3}`
/**
* realizePianoLick(lick, rootPc)
* → [{ abs, name, beat?, technique?, approach, chordTone, isRoot }] | null
*
* Pure realization per the header rules. Returns null on ANY malformed or
* out-of-window note so the caller renders a placeholder — authored data is
* validator-guaranteed, but a renderer must never crash on bad input.
*/
export function realizePianoLick(lick, rootPc) {
if (!lick || typeof lick !== 'object' || !Number.isFinite(rootPc)) return null
const notes = lick.notes
if (!Array.isArray(notes) || notes.length === 0) return null
const quality = lick.quality
const intervals = CHORD_TYPES[quality]?.intervals
if (!intervals) return null // quality is required machine truth (schema)
const root = mod12(rootPc)
const chordPcs = new Set(intervals.map(mod12))
// Pass 1: resolve every deg note's absolute pitch.
const abs = new Array(notes.length).fill(null)
for (let i = 0; i < notes.length; i++) {
const n = notes[i]
if (!n || typeof n !== 'object') return null
const isDeg = n.deg !== undefined
const isApp = n.approach !== undefined
if (isDeg === isApp) return null // exactly one of deg | approach (schema)
if (isDeg) {
const off = resolveDegree(String(n.deg), quality)
if (off === null || off === undefined) return null
const oct = n.octave === undefined ? 0 : n.octave
if (![0, 1, 2].includes(oct)) return null
abs[i] = root + off + 12 * oct
}
}
// Pass 2: approaches derive from the NEXT deg note (schema: never terminal).
for (let i = 0; i < notes.length; i++) {
if (abs[i] !== null) continue
const delta = APPROACH_DELTA[notes[i].approach]
if (delta === undefined) return null
const target = abs.slice(i + 1).find((a) => a !== null)
if (target === undefined) return null // approach with nothing to target
abs[i] = target + delta
}
if (abs.some((a) => a < 0 || a > ABS_MAX)) return null // outside the window
return notes.map((n, i) => ({
abs: abs[i],
name: NOTES[mod12(abs[i])],
beat: Number.isFinite(n.beat) ? n.beat : undefined,
technique: typeof n.technique === 'string' ? n.technique : undefined,
approach: n.approach !== undefined,
chordTone: !(n.approach !== undefined) && chordPcs.has(mod12(abs[i] - root)),
isRoot: !(n.approach !== undefined) && mod12(abs[i] - root) === 0,
}))
}
// ─── Timeline layout (x = beat/column, y = pitch) ─────────────────────────────
const PAD_T = 14 // headroom for grace glyphs above the top pill
const PAD_B = 8
const PAD_L = 16 // gutter for the "R" root-line label
const PAD_R = 12
const COL_W = 26 // beatless column pitch (LickCard's)
const BEAT_ROW = 10
const SEMI_MIN = 3 // px per semitone bounds — wide licks compress,
const SEMI_MAX = 7 // narrow licks stretch, so contour stays legible
function layoutLick(realized, rootPc) {
const hasBeats =
realized.every((n) => Number.isFinite(n.beat)) &&
realized.every((n, i) => i === 0 || n.beat >= realized[i - 1].beat)
// x: proportional to beat when authored (BassPatternCard), else LickCard
// columns — where a double-stop stacks into the previous column.
let xs, width, maxBeat = null, unit = null
if (hasBeats) {
const gaps = realized
.map((n, i) => (i > 0 ? n.beat - realized[i - 1].beat : 0))
.filter((g) => g > 0)
const minGap = gaps.length ? Math.min(...gaps) : 1
unit = Math.min(COL_W / minGap, COL_W * 4)
xs = realized.map((n) => PAD_L + (n.beat - 1) * unit + 10)
maxBeat = realized[realized.length - 1].beat
width = PAD_L + (maxBeat - 1) * unit + 20 + PAD_R
} else {
let col = -1
xs = realized.map((n, i) => {
const stacks = i > 0 && n.technique === 'double-stop'
if (!stacks) col++
return PAD_L + col * COL_W + COL_W / 2
})
width = PAD_L + (col + 1) * COL_W + PAD_R
}
// y: linear pitch axis over the lick's own range.
const lo = Math.min(...realized.map((n) => n.abs))
const hi = Math.max(...realized.map((n) => n.abs))
const range = Math.max(1, hi - lo)
const semi = Math.min(SEMI_MAX, Math.max(SEMI_MIN, 66 / range))
const plotH = Math.max(26, range * semi)
const yFor = (a) => PAD_T + plotH - (a - lo) * semi
return {
hasBeats,
maxBeat,
unit,
width,
height: PAD_T + plotH + PAD_B + (hasBeats ? BEAT_ROW : 0),
plotBottom: PAD_T + plotH,
// Root reference lines: every root-pc pitch inside the plotted range.
rootYs: Array.from({ length: hi - lo + 1 }, (_, k) => lo + k)
.filter((a) => mod12(a) === mod12(rootPc))
.map(yFor),
notes: realized.map((n, i) => ({
...n,
x: xs[i],
y: yFor(n.abs),
label: n.technique === 'ghost-note' ? `(${n.name})` : n.name,
ghost: n.technique === 'ghost-note',
})),
}
}
function pillColor(n) {
if (n.approach) return AMBER
if (n.ghost) return GHOST_TEXT
if (n.isRoot) return ACCENT
if (n.chordTone) return ACCENT_SOFT
return NOTE_TEXT
}
// Grace ornament: slashed mini-note crushed just before its target pill.
function GraceGlyph({ note }) {
const cx = note.x - 12
const cy = note.y - 8
return (
)
}
function TimelineSvg({ layout, ariaLabel }) {
const { notes, width, height, hasBeats, maxBeat, unit, plotBottom, rootYs } = layout
return (
)
}
// ─── Keyboard view (full size): which keys, in what order ────────────────────
// MiniPiano's cropped-window geometry (whole-octave span + trailing high C),
// replicated here because order badges aren't part of MiniPiano's contract.
const WW = 22
const WH = 60
const BW = 14
const BH = 38
const WHITE_PCS = [0, 2, 4, 5, 7, 9, 11]
const BLACK_OFFSETS = [
{ pc: 1, afterWhite: 0 }, { pc: 3, afterWhite: 1 }, { pc: 6, afterWhite: 3 },
{ pc: 8, afterWhite: 4 }, { pc: 10, afterWhite: 5 },
]
function LickKeyboard({ realized, ariaLabel }) {
// abs → { orders: [1-based strike order…], tier }
const hits = new Map()
realized.forEach((n, i) => {
const h = hits.get(n.abs) ?? { orders: [], isRoot: false, deg: false, approach: false }
h.orders.push(i + 1)
h.isRoot ||= n.isRoot
h.deg ||= !n.approach
h.approach ||= n.approach
hits.set(n.abs, h)
})
const lo = Math.min(...realized.map((n) => n.abs))
const hi = Math.max(...realized.map((n) => n.abs))
const octStart = Math.floor(lo / 12)
const OCTAVES = Math.max(1, Math.ceil((hi - octStart * 12) / 12))
const TOTAL_WHITES = WHITE_PCS.length * OCTAVES + 1
const baseW = WW * TOTAL_WHITES + 2
const scale = 0.85
const fillFor = (h) => (h.deg ? (h.isRoot ? ACCENT : ACCENT_SOFT) : AMBER)
const whites = []
for (let oct = 0; oct < OCTAVES; oct++) {
for (let wi = 0; wi < WHITE_PCS.length; wi++) {
const absWi = oct * WHITE_PCS.length + wi
whites.push({ x: absWi * WW + 1, absWi, absNote: (octStart + oct) * 12 + WHITE_PCS[wi] })
}
}
whites.push({
x: OCTAVES * WHITE_PCS.length * WW + 1,
absWi: OCTAVES * WHITE_PCS.length,
absNote: (octStart + OCTAVES) * 12,
})
const blacks = []
for (let oct = 0; oct < OCTAVES; oct++) {
for (const { pc, afterWhite } of BLACK_OFFSETS) {
blacks.push({
x: (oct * WHITE_PCS.length + afterWhite) * WW + WW - BW / 2,
key: `${oct}-${pc}`,
absNote: (octStart + oct) * 12 + pc,
})
}
}
return (
)
}
// ─── Card chrome (LickCard's idiom) ───────────────────────────────────────────
function LevelBadge({ level }) {
if (level === 'intermediate') {
return (
intermediate
)
}
if (level === 'foundation') {
return (
foundation
)
}
return null
}
function TechniqueChip({ tech }) {
const symbol = TECH_SYMBOL[tech]
return (
{symbol && {symbol}}
{tech}
)
}
function PlaceholderCard({ name, size }) {
return (
—
{name ? `${name} — lick unavailable` : 'lick unavailable'}
)
}
/**
*
* Pure/presentational — realizes one KB piano lick over the live chord root;
* never crashes on bad data. See the header for the full wiring contract.
*/
export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full' }) {
const realized = realizePianoLick(lick, rootPc)
const name = typeof lick?.name === 'string' && lick.name.trim() ? lick.name : 'Untitled lick'
if (!realized) {
return
}
const full = size === 'full'
const chordName =
typeof chordLabel === 'string' && chordLabel.trim()
? chordLabel
: `${NOTES[mod12(rootPc)]}${CHORD_TYPES[lick.quality]?.suffix ?? lick.quality}`
const chordContext =
typeof lick.chordContext === 'string' && lick.chordContext.trim() ? lick.chordContext : null
const techniques = Array.isArray(lick.techniques)
? lick.techniques.filter((t) => typeof t === 'string' && t.trim())
: []
const source = typeof lick.source === 'string' && lick.source.trim() ? lick.source : null
const tips = typeof lick.tips === 'string' && lick.tips.trim() ? lick.tips : null
const layout = layoutLick(realized, rootPc)
const pitchNames = realized.map((n) => absName(n.abs)).join(', ')
return (
{/* Header: name + level badge */}
{name}
{/* Where it lands */}
{full && chordContext && (
{chordContext} · here: {chordName}
)}
{/* The pitch timeline */}
{/* Keyboard view (full): the actual keys, numbered in strike order */}
{full && (
)}
{/* Technique tags */}
{full && techniques.length > 0 && (
{techniques.map((t) => )}
)}
{/* Transferable idea + attribution */}
{full && tips &&
{tips}
}
{full && source && (
{source}
)}
)
}
// ─── Glyph legend — render ONCE per lick grid (LickCard contract) ─────────────
function LegendSample({ children, w = 22 }) {
return (
)
}
export function PianoTechniqueLegend() {
const items = [
{
key: 'grace-note', label: 'grace note',
sample: (
),
},
{
key: 'slide', label: 'slide (key-slip)',
sample: (
),
},
{
key: 'double-stop', label: 'double-stop (stacked)',
sample: (
G
D
),
},
{
key: 'ghost-note', label: 'ghost note',
sample: (
(E)
),
},
]
return (
{items.map((it) => (
{it.sample}
{it.label}
))}
amber = chromatic approach · purple = chord tone · R line = the root
)
}
// ─── Dev fixture — SCHEMA.md's worked enclosure (P-60 real data replaces this
// in the app; exported so the card can be exercised before P-60 lands) ─────
export const DEMO_PIANO_LICK = {
id: 'jazz-enclosure-into-3',
name: 'Bebop enclosure into the 3rd',
level: 'intermediate',
chordContext: 'over the ii7',
quality: 'min7',
techniques: ['grace-note'],
source: 'Barry Harris workshop vocabulary',
notes: [
{ deg: '5', octave: 1, beat: 1 },
{ approach: 'chrom-above', beat: 2 },
{ approach: 'chrom-below', beat: 2.5 },
{ deg: '3', octave: 1, beat: 3, technique: 'grace-note' },
],
}