// 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 && {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 (
{children}
)
}
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 },
],
}