From 19b1a4ff91cf7103d6932c780913166bd05bbc29 Mon Sep 17 00:00:00 2001
From: vadimwit
Date: Mon, 13 Jul 2026 12:58:40 +0100
Subject: [PATCH] =?UTF-8?q?feat(licks):=20dashboard=20licks=20strip=20?=
=?UTF-8?q?=E2=80=94=20uniform=20compact=20cards=20following=20the=20instr?=
=?UTF-8?q?ument=20(task=20L-71)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The dashboard licks strip now follows the global GUITAR/PIANO/BASS
selector: guitar shows LickCard tabs, piano shows the PianoLickCard
piano-roll (jazz/blues/gospel/rnb), bass shows an honest empty line.
Every strip card is a uniform compact 220x150 box (tab/roll normalized
to a 104px-tall panel) so the licks stop hogging space. Play buttons
removed from the strip. Knowledge Center LicksSection + LickCard left
byte-identical. .dark-scroll applied to the rail/strip scrollers.
Critic PASS (combined gate).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/components/JamGuide.jsx | 143 ++++++++++++++++++++++---------
src/components/PianoLickCard.jsx | 84 ++----------------
2 files changed, 109 insertions(+), 118 deletions(-)
diff --git a/src/components/JamGuide.jsx b/src/components/JamGuide.jsx
index 762d98d..03f8dad 100644
--- a/src/components/JamGuide.jsx
+++ b/src/components/JamGuide.jsx
@@ -6,6 +6,7 @@ import GlanceRail, { AimDots, SoloLabel } from './GlanceRail'
import BassPatternCard from './BassPatternCard'
import VoicingBrowser from './VoicingBrowser'
import LickCard, { TechniqueLegend } from './LickCard'
+import PianoLickCard from './PianoLickCard'
import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel'
import { pianoVoicingChain } from '../lib/piano'
import { parseChord } from '../lib/voicings'
@@ -132,8 +133,16 @@ function recipeVoicing(recipe, rootPc, quality) {
//
// `licksFor` was a closure-local inside LicksSection; lifted to module scope
// during the L-33 restructure (D-31 §5) so the strip shares it instead of
-// duplicating the defensive read. Licks are guitar-only in the KB (C-20 schema).
-function licksFor(id) {
+// duplicating the defensive read. Guitar licks are tab entries (LickCard);
+// piano licks (D-70 §5.1) are the STRUCTURED entries only — those carrying a
+// `notes` array PianoLickCard can realize — so prose-only piano education
+// entries never render as placeholder cards. The dock's LicksSection reads
+// guitar (its default arg), so its behaviour is unchanged.
+function licksFor(id, instrument = 'guitar') {
+ if (instrument === 'piano') {
+ const l = kb?.[id]?.instruments?.piano?.licks
+ return Array.isArray(l) ? l.filter(x => Array.isArray(x?.notes)) : []
+ }
const l = kb?.[id]?.instruments?.guitar?.licks
return Array.isArray(l) ? l : []
}
@@ -425,23 +434,25 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
)
- // ── The licks strip (left column). Matched loops sort by the playhead
- // station; heard-live falls back to the live chord's quality key (e.g. a
- // "dom7" lick fits a live G7). Bass hides it (guitar tab licks are noise
- // to a bassist mid-jam); LicksStrip also hides itself when empty. ──
- const licksStrip = instrument !== 'bass' && match.matched ? (
+ // ── The licks strip (left column). It now FOLLOWS the global instrument
+ // (D-70 §5.1): guitar → tab LickCards, piano → PianoLickCards realized over
+ // the playhead root, bass → an honest "no bass licks" line. Matched loops
+ // sort by the playhead station; heard-live falls back to the live chord's
+ // quality key (e.g. a "dom7" lick fits a live G7). The live-chord context
+ // carries rootPc so PianoLickCard can realize its degrees. LicksStrip itself
+ // decides the empty behaviour per instrument (guitar hides; piano/bass show a
+ // slim honest line). ──
+ const licksContext = match.matched
+ ? contextStation
+ : liveChord
+ ? { rn: '', quality: liveChord.type, label: currentChord, rootPc: liveChord.rootPc }
+ : null
+ const licksStrip = (match.matched || liveChord) ? (
- ) : instrument !== 'bass' && liveChord ? (
-
) : null
@@ -472,7 +483,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
)}
{relatedSlot != null && (
-
+
{relatedSlot}
)}
@@ -481,7 +492,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
{/* RIGHT — the suggested-voicings rail (the one contained scroller) */}
@@ -844,52 +855,101 @@ function LicksSection({ styles, levels, onToggleLevel }) {
)
}
-// ─── LicksStrip — glanceable licks below the rail (L-33, D-31 §2.4) ───────────
+// ─── LicksStrip — glanceable licks below the rail (L-33, D-31 §2.4; D-70 §5) ──
//
-// Thumb LickCards for the active style, level-filtered, sorted current-station-
+// Thumb lick cards for the active style, level-filtered, sorted current-station-
// context-first via the token-boundary matcher above. The "fits X — now" accent
-// ring + microcopy are STRIP-OWNED chrome rendered AROUND the card — LickCard
-// itself is untouched and shows chordContext only at size="full". Licks are
-// guitar-only in the KB, so under the piano tab the strip still shows them and
-// the heading says so. Style has no licks (or the level filter empties it) →
-// the strip hides entirely: an empty state would steal glance space to say
-// nothing. Tap a thumb → the card enlarges inline (comfort, not information).
+// ring + caption are STRIP-OWNED chrome rendered AROUND the card — the cards
+// themselves are untouched and show chordContext only at size="full".
+//
+// FOLLOWS THE GLOBAL INSTRUMENT (D-70 §5.1):
+// guitar → LickCard (tab); piano → PianoLickCard, realized over the playhead
+// root (context.rootPc); bass → the KB has no bass strip licks, so a slim
+// honest line renders instead of the section vanishing.
+// Piano licks are the STRUCTURED ones only (`licksFor(id,'piano')` filters to
+// entries with a `notes` array) — jazz/blues/gospel/rnb ship them; other styles
+// read as empty under piano.
+//
+// UNIFORM FOOTPRINT (D-70 §5.2), imposed AT THE STRIP MOUNT, not inside the
+// cards: every closed thumb sits in a fixed 220×150 box whose SVG is normalised
+// to h-104/w-full (`h-[150px] [&>div]:h-full [&_svg]:!h-[104px] [&_svg]:!w-full`).
+// preserveAspectRatio (SVG default) scales each tab / piano-roll to that box and
+// centres it, so a guitar card and a piano card occupy the SAME box — zero edit
+// to LickCard/PianoLickCard geometry, so the dock's size="full" cards are
+// byte-identical. Tapping enlarges a card inline (size="full", unclamped).
+//
+// EMPTY BEHAVIOUR (D-70 §5.3): guitar hides on empty (a silent gap is fine, the
+// default); piano/bass show a slim honest line — the user actively switched
+// instruments there, so a vanished section would be confusing.
//
// styleId — KB style whose licks to show (the active style)
// levels — the shared foundation/intermediate filter
-// instrument — current instrument tab (piano → honest "guitar licks" heading)
-// context — { rn, quality, label } of the playhead station (or the live
-// chord in the no-loop fallback); null → no context sort
-function LicksStrip({ styleId, levels, instrument, context }) {
- // Inline enlarge (one card at a time); reset when the style changes.
- const [expandedId, setExpandedId] = useState(null)
- useEffect(() => { setExpandedId(null) }, [styleId])
+// instrument — the global GUITAR/PIANO/BASS selector
+// context — { rn, quality, label, rootPc } of the playhead station (or the
+// live chord in the no-loop fallback); rootPc realizes piano
+// licks, label/rn/quality drive the context sort; null → no sort
+function EmptyLicksLine({ children }) {
+ return (
+
+ {children}
+
+ )
+}
- const visible = licksFor(styleId).filter(l => levels[lickLevel(l)])
+function LicksStrip({ styleId, levels, instrument, context }) {
+ // Inline enlarge (one card at a time); reset when the style/instrument changes.
+ const [expandedId, setExpandedId] = useState(null)
+ useEffect(() => { setExpandedId(null) }, [styleId, instrument])
+
+ const styleLabel = kb?.[styleId]?.meta?.label ?? styleId
+
+ // Bass: no strip licks in the KB — honest line, never a vanished section.
+ if (instrument === 'bass') {
+ return No bass licks in the KB yet.
+ }
+
+ const isPiano = instrument === 'piano'
+ const visible = licksFor(styleId, instrument).filter(l => levels[lickLevel(l)])
const fitted = visible.filter(l => lickFitsContext(l, context))
const rest = visible.filter(l => !lickFitsContext(l, context))
const sorted = [...fitted, ...rest]
- if (sorted.length === 0) return null
+ if (sorted.length === 0) {
+ // Piano: honest line (the player just switched to piano). Guitar: hide.
+ return isPiano
+ ? No {styleLabel} piano licks in the KB yet.
+ : null
+ }
- const styleLabel = kb?.[styleId]?.meta?.label ?? styleId
const fitLabel = typeof context?.label === 'string' ? context.label : null
+ const rootPc = Number.isFinite(context?.rootPc) ? context.rootPc : 0
+ // Fixed-box normaliser — applied only when closed (open cards grow freely).
+ const thumbBox = 'h-[150px] [&>div]:h-full [&_svg]:!h-[104px] [&_svg]:!w-full'
return (
- {styleLabel} licks · guitar
- {instrument === 'piano' ? ' (no piano licks in the KB yet)' : ''}
+ {styleLabel} licks · {instrument}
{fitted.length > 0 && fitLabel ? ` · fits ${fitLabel} first` : ''}