From 44d9c4f0e806276c8432f7aa8a9f6c1b1bb8fc66 Mon Sep 17 00:00:00 2001 From: vadimwit Date: Wed, 8 Jul 2026 17:29:17 +0100 Subject: [PATCH] =?UTF-8?q?feat(design):=20LickCard=20=E2=80=94=20tab-styl?= =?UTF-8?q?e=20SVG=20lick=20renderer=20with=20technique=20glyphs=20(task?= =?UTF-8?q?=20D-22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit String 1 (high e) on top per tab convention; h/p slurs, slide, bend, vibrato, ghost, double-stop column stacking; TechniqueLegend named export (once per grid); layoutTab pure + testable; DEMO_LICK fixture until P-21 lands. Critic PASS (47/47 harness checks, schema-exact). Co-Authored-By: Claude Fable 5 --- src/components/LickCard.jsx | 470 ++++++++++++++++++++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 src/components/LickCard.jsx 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 ( +
+ + {/* 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 }, + ], +}