diff --git a/src/components/LickCard.jsx b/src/components/LickCard.jsx
new file mode 100644
index 0000000..8779853
--- /dev/null
+++ b/src/components/LickCard.jsx
@@ -0,0 +1,470 @@
+// 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 it is on
+// the same string, where stacking would overlap — then 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]
+ const prev = notes[i - 1]
+ // double-stop stacks into the previous column — unless same string (overlap).
+ const stacks = i > 0 && n.technique === 'double-stop' && prev.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 (
+
+
+
+ )
+}
+
+// ── 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 (
+
+ )
+}
+
+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 },
+ ],
+}