// LickCard — tab-style SVG lick renderer (task D-22). // // Renders one structured lick from the KB `licks` schema (src/data/kb/SCHEMA.md): // { id, name, level, chordContext, techniques[], source?, // tab: [{ string: 1–6, fret: 0–15, technique? }] } // ordered, first → last // // Tab convention (per SCHEMA.md): string 1 = high e rendered on TOP, // string 6 = low E on the bottom — standard guitar tab. NOTE: this is the // REVERSE of RiffDiagram.jsx's row order (that diagram puts low E on top). // // There is no rhythm information in the schema, so notes are simply evenly // spaced columns in `tab` order — no bars, beams or durations are invented. // // Column rule: each note takes the next column, EXCEPT a note tagged // `double-stop`, which stacks into the PREVIOUS note's column — unless ANY note // already placed in that column is on the same string (stacking would overlap // exactly), in which case it takes a new column. // // Technique glyphs (amber, the established secondary-tone colour): // hammer-on → slur arc from the previous note + italic "h" above // pull-off → slur arc from the previous note + italic "p" above // slide → short diagonal segment into the note (rises toward higher frets) // bend → curved arrow rising from the note // vibrato → small ~ wave above the note // ghost-note → fret number in parentheses, dimmed // double-stop→ no glyph; renders as a stacked column (see above) // chromatic-approach → no glyph (melodic content — the tag chip covers it) // unknown strings → no glyph, note still renders (graceful) // // Exports: // default (production API) // — the glyph key, rendered ONCE per grid (per D-20 §3) // layoutTab(tab) — pure layout helper (returns null on empty/invalid) // DEMO_LICK — SCHEMA.md's worked B.B.-box example, dev fixture only // // Design tokens (tailwind.config.js) — SVG fills can't read Tailwind classes, // so the constants below mirror the tokens (same convention as MiniPiano / // ChordDiagram): accent #a855f7, amber #f59e0b, surface #0f0f0f. const AMBER = '#f59e0b' // token `amber` — technique glyphs const FRET_TEXT = '#e5e7eb' // gray-200 — fret numbers (≈15:1 on surface) const GHOST_TEXT = '#9ca3af' // gray-400 — ghost notes, quieter but AA (≈7:1) const STRING_LINE = '#3a3a3a' // string lines (decorative, RiffDiagram idiom) const STRING_LABEL = '#6b7280' // gray-500 — string-name microcopy (decorative) const CARD_BG = '#0f0f0f' // token `surface` — backing pill behind fret numbers // Fixed technique vocabulary (C-20 schema) — anything else gets no glyph. export const TECHNIQUE_VOCAB = [ 'hammer-on', 'pull-off', 'slide', 'bend', 'double-stop', 'ghost-note', 'chromatic-approach', 'vibrato', ] // Chip symbol per technique (shown in the technique-tag chips). const TECH_SYMBOL = { 'hammer-on': 'h', 'pull-off': 'p', slide: '⟋', bend: '↑', vibrato: '~', 'ghost-note': '( )', 'double-stop': '⋮', 'chromatic-approach': null, // tag only — no mark on the tab } // ── Geometry (SVG user units; the svg scales to card width via viewBox) ────── const STR_GAP = 14 // vertical gap between string lines const PAD_T = 17 // headroom for bend arrows / vibrato above string 1 const PAD_B = 9 const PAD_L = 20 // room for string-name labels const PAD_R = 12 const COL_W = 26 // horizontal pitch per note column const STRING_NAMES = ['e', 'B', 'G', 'D', 'A', 'E'] // index = string − 1 (top → bottom) function isValidNote(n) { return ( n && typeof n === 'object' && Number.isInteger(n.string) && n.string >= 1 && n.string <= 6 && Number.isInteger(n.fret) && n.fret >= 0 && n.fret <= 15 ) } /** * Pure layout: tab array → positioned notes. * Returns null when there is nothing renderable (not an array / no valid note). * Otherwise: { notes: [{string,fret,technique?,col,x,y,label,ghost}], nCols, width, height } * Columns are monotonically non-decreasing; y grows with string number * (string 1 = smallest y = top line). */ export function layoutTab(tab) { if (!Array.isArray(tab)) return null const clean = tab.filter(isValidNote) if (clean.length === 0) return null const notes = [] let col = -1 for (let i = 0; i < clean.length; i++) { const n = clean[i] // double-stop stacks into the previous column — unless ANY note already // placed there shares this string (a ≥3-note stack can repeat the string of // a non-adjacent same-column note, which would overlap exactly — D-23). const stacks = i > 0 && n.technique === 'double-stop' && !notes.some((m) => m.col === col && m.string === n.string) if (!stacks) col++ const ghost = n.technique === 'ghost-note' notes.push({ string: n.string, fret: n.fret, technique: typeof n.technique === 'string' ? n.technique : undefined, col, x: PAD_L + col * COL_W + COL_W / 2, y: PAD_T + (n.string - 1) * STR_GAP, label: ghost ? `(${n.fret})` : String(n.fret), ghost, }) } const nCols = col + 1 return { notes, nCols, width: PAD_L + nCols * COL_W + PAD_R, height: PAD_T + 5 * STR_GAP + PAD_B, } } // ── Glyph fragments (pure SVG, all in user units so they scale with the tab) ─ function SlurGlyph({ note, prev, letter }) { const fs = 8 if (!prev || prev.col === note.col) { // No source note to slur from — letter alone, just before the note. return ( {letter} ) } const midX = (prev.x + note.x) / 2 const topY = Math.min(prev.y, note.y) return ( {letter} ) } function SlideGlyph({ note, prev }) { // Rises toward the higher fret (up-slide ⟋), falls for a down-slide (⟍). if (prev && prev.col !== note.col) { const up = note.fret >= prev.fret const midY = (prev.y + note.y) / 2 return ( ) } // Slide-in from nowhere: short lead-in segment. return ( ) } function BendGlyph({ note }) { const { x, y } = note return ( ) } function VibratoGlyph({ note }) { const { x, y } = note return ( ) } function NoteGlyph({ note, prev }) { switch (note.technique) { case 'hammer-on': return case 'pull-off': return case 'slide': return case 'bend': return case 'vibrato': return // ghost-note is handled by the parenthesised label; double-stop by the // column stacking; chromatic-approach and unknown strings get no mark. default: return null } } // ── The tab SVG ─────────────────────────────────────────────────────────────── function TabSvg({ layout, name, size }) { const { notes, width, height } = layout const full = size === 'full' return (
{/* String lines — string 1 (high e) on top, string 6 (low E) at bottom */} {STRING_NAMES.map((label, i) => { const y = PAD_T + i * STR_GAP return ( {label} ) })} {/* Technique glyphs (under the numbers so pills stay readable) */} {notes.map((n, i) => ( ))} {/* Fret numbers on their strings, backed by a surface pill so the number interrupts the string line like printed tab */} {notes.map((n, i) => { const w = n.label.length * 5.2 + 3 return ( {n.label} ) })}
) } // ── Card chrome ─────────────────────────────────────────────────────────────── function LevelBadge({ level }) { if (level === 'intermediate') { return ( intermediate ) } if (level === 'foundation') { return ( foundation ) } return null // unknown/missing level → no badge, never a wrong claim } function TechniqueChip({ tech }) { const symbol = TECH_SYMBOL[tech] return ( {symbol && } {tech} ) } function PlaceholderCard({ name, size }) { return (
{name ? `${name} — tab unavailable` : 'lick unavailable'}
) } /** * * Pure/presentational — renders one KB lick object; never crashes on bad data. */ export default function LickCard({ lick, size = 'full' }) { const layout = layoutTab(lick?.tab) const name = typeof lick?.name === 'string' && lick.name.trim() ? lick.name : 'Untitled lick' if (!lick || !layout) { return } const full = size === 'full' 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 return (
{/* Header: name + level badge */}
{name}
{/* Where it lands */} {full && chordContext && (
{chordContext}
)} {/* The tab */} {/* Technique tags */} {full && techniques.length > 0 && (
{techniques.map(t => )}
)} {/* Attribution */} {full && source && ( {source} )}
) } // ── Glyph legend — render ONCE per lick grid (D-20 §3), not per card ───────── function LegendSample({ children, w = 22 }) { return ( ) } export function TechniqueLegend() { const items = [ { key: 'hammer-on', label: 'hammer-on', sample: ( h ), }, { key: 'pull-off', label: 'pull-off', sample: ( p ), }, { key: 'slide', label: 'slide', sample: ( ), }, { key: 'bend', label: 'bend', sample: ( ), }, { key: 'vibrato', label: 'vibrato', sample: ( ), }, { key: 'ghost-note', label: 'ghost note', sample: ( (5) ), }, { key: 'double-stop', label: 'double-stop (stacked)', sample: ( 5 7 ), }, ] return (
{items.map(it => ( {it.sample} {it.label} ))} chromatic-approach: tag only, no mark
) } // ── Dev fixture — SCHEMA.md's worked example (P-21 real data replaces this in // the app; this export exists so the card can be exercised before P-21) ──── export const DEMO_LICK = { id: 'blues-box1-bb-answer', name: 'B.B. box answer phrase', level: 'foundation', chordContext: 'over the I7', techniques: ['bend', 'vibrato'], source: 'the B.B. King box, e.g. "The Thrill Is Gone" fills', tab: [ { string: 2, fret: 8 }, { string: 1, fret: 8, technique: 'bend' }, { string: 1, fret: 10, technique: 'vibrato' }, { string: 2, fret: 8 }, ], }