feat(licks): dashboard licks strip — uniform compact cards following the instrument (task L-71)
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) <noreply@anthropic.com>
This commit is contained in:
+102
-41
@@ -6,6 +6,7 @@ import GlanceRail, { AimDots, SoloLabel } from './GlanceRail'
|
|||||||
import BassPatternCard from './BassPatternCard'
|
import BassPatternCard from './BassPatternCard'
|
||||||
import VoicingBrowser from './VoicingBrowser'
|
import VoicingBrowser from './VoicingBrowser'
|
||||||
import LickCard, { TechniqueLegend } from './LickCard'
|
import LickCard, { TechniqueLegend } from './LickCard'
|
||||||
|
import PianoLickCard from './PianoLickCard'
|
||||||
import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel'
|
import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel'
|
||||||
import { pianoVoicingChain } from '../lib/piano'
|
import { pianoVoicingChain } from '../lib/piano'
|
||||||
import { parseChord } from '../lib/voicings'
|
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
|
// `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
|
// 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).
|
// duplicating the defensive read. Guitar licks are tab entries (LickCard);
|
||||||
function licksFor(id) {
|
// 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
|
const l = kb?.[id]?.instruments?.guitar?.licks
|
||||||
return Array.isArray(l) ? l : []
|
return Array.isArray(l) ? l : []
|
||||||
}
|
}
|
||||||
@@ -425,23 +434,25 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── The licks strip (left column). Matched loops sort by the playhead
|
// ── The licks strip (left column). It now FOLLOWS the global instrument
|
||||||
// station; heard-live falls back to the live chord's quality key (e.g. a
|
// (D-70 §5.1): guitar → tab LickCards, piano → PianoLickCards realized over
|
||||||
// "dom7" lick fits a live G7). Bass hides it (guitar tab licks are noise
|
// the playhead root, bass → an honest "no bass licks" line. Matched loops
|
||||||
// to a bassist mid-jam); LicksStrip also hides itself when empty. ──
|
// sort by the playhead station; heard-live falls back to the live chord's
|
||||||
const licksStrip = instrument !== 'bass' && match.matched ? (
|
// 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) ? (
|
||||||
<LicksStrip
|
<LicksStrip
|
||||||
styleId={activeStyle}
|
styleId={activeStyle}
|
||||||
levels={ALL_LEVELS}
|
levels={ALL_LEVELS}
|
||||||
instrument={instrument}
|
instrument={instrument}
|
||||||
context={contextStation}
|
context={licksContext}
|
||||||
/>
|
|
||||||
) : instrument !== 'bass' && liveChord ? (
|
|
||||||
<LicksStrip
|
|
||||||
styleId={activeStyle}
|
|
||||||
levels={ALL_LEVELS}
|
|
||||||
instrument={instrument}
|
|
||||||
context={{ rn: '', quality: liveChord.type, label: currentChord }}
|
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
@@ -472,7 +483,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{relatedSlot != null && (
|
{relatedSlot != null && (
|
||||||
<div className={'order-4 xl:order-none min-w-0' + (fill ? ' xl:flex-1 xl:min-h-0 xl:overflow-y-auto' : '')}>
|
<div className={'order-4 xl:order-none min-w-0' + (fill ? ' xl:flex-1 xl:min-h-0 xl:overflow-y-auto dark-scroll' : '')}>
|
||||||
{relatedSlot}
|
{relatedSlot}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -481,7 +492,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
{/* RIGHT — the suggested-voicings rail (the one contained scroller) */}
|
{/* RIGHT — the suggested-voicings rail (the one contained scroller) */}
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'order-2 xl:order-none min-w-0 xl:w-[500px] xl:shrink-0 xl:overflow-y-auto ' +
|
'order-2 xl:order-none min-w-0 xl:w-[500px] xl:shrink-0 xl:overflow-y-auto dark-scroll ' +
|
||||||
(fill ? 'xl:h-full' : 'xl:max-h-[calc(100vh_-_1.5rem)]')
|
(fill ? 'xl:h-full' : 'xl:max-h-[calc(100vh_-_1.5rem)]')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -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
|
// context-first via the token-boundary matcher above. The "fits X — now" accent
|
||||||
// ring + microcopy are STRIP-OWNED chrome rendered AROUND the card — LickCard
|
// ring + caption are STRIP-OWNED chrome rendered AROUND the card — the cards
|
||||||
// itself is untouched and shows chordContext only at size="full". Licks are
|
// themselves are untouched and show chordContext only at size="full".
|
||||||
// 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) →
|
// FOLLOWS THE GLOBAL INSTRUMENT (D-70 §5.1):
|
||||||
// the strip hides entirely: an empty state would steal glance space to say
|
// guitar → LickCard (tab); piano → PianoLickCard, realized over the playhead
|
||||||
// nothing. Tap a thumb → the card enlarges inline (comfort, not information).
|
// 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)
|
// styleId — KB style whose licks to show (the active style)
|
||||||
// levels — the shared foundation/intermediate filter
|
// levels — the shared foundation/intermediate filter
|
||||||
// instrument — current instrument tab (piano → honest "guitar licks" heading)
|
// instrument — the global GUITAR/PIANO/BASS selector
|
||||||
// context — { rn, quality, label } of the playhead station (or the live
|
// context — { rn, quality, label, rootPc } of the playhead station (or the
|
||||||
// chord in the no-loop fallback); null → no context sort
|
// live chord in the no-loop fallback); rootPc realizes piano
|
||||||
function LicksStrip({ styleId, levels, instrument, context }) {
|
// licks, label/rn/quality drive the context sort; null → no sort
|
||||||
// Inline enlarge (one card at a time); reset when the style changes.
|
function EmptyLicksLine({ children }) {
|
||||||
const [expandedId, setExpandedId] = useState(null)
|
return (
|
||||||
useEffect(() => { setExpandedId(null) }, [styleId])
|
<p className="rounded-xl border border-dashed border-border px-3 py-2 text-xs text-gray-500">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
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 <EmptyLicksLine>No bass licks in the KB yet.</EmptyLicksLine>
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPiano = instrument === 'piano'
|
||||||
|
const visible = licksFor(styleId, instrument).filter(l => levels[lickLevel(l)])
|
||||||
const fitted = visible.filter(l => lickFitsContext(l, context))
|
const fitted = visible.filter(l => lickFitsContext(l, context))
|
||||||
const rest = visible.filter(l => !lickFitsContext(l, context))
|
const rest = visible.filter(l => !lickFitsContext(l, context))
|
||||||
const sorted = [...fitted, ...rest]
|
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
|
||||||
|
? <EmptyLicksLine>No {styleLabel} piano licks in the KB yet.</EmptyLicksLine>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
const styleLabel = kb?.[styleId]?.meta?.label ?? styleId
|
|
||||||
const fitLabel = typeof context?.label === 'string' ? context.label : null
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
className="rounded-2xl border border-border bg-panel p-3"
|
className="rounded-2xl border border-border bg-panel p-3"
|
||||||
aria-label={`${styleLabel} guitar licks`}
|
aria-label={`${styleLabel} ${instrument} licks`}
|
||||||
>
|
>
|
||||||
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
{styleLabel} licks · guitar
|
{styleLabel} licks · {instrument}
|
||||||
{instrument === 'piano' ? ' (no piano licks in the KB yet)' : ''}
|
|
||||||
{fitted.length > 0 && fitLabel ? ` · fits ${fitLabel} first` : ''}
|
{fitted.length > 0 && fitLabel ? ` · fits ${fitLabel} first` : ''}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-start gap-2 overflow-x-auto pb-1" role="list">
|
<div className="flex items-start gap-2 overflow-x-auto pb-1 dark-scroll" role="list">
|
||||||
{sorted.map((l, idx) => {
|
{sorted.map((l, idx) => {
|
||||||
const id = l?.id ?? `lick-${idx}`
|
const id = l?.id ?? `lick-${idx}`
|
||||||
const isFit = idx < fitted.length // sorted = fitted first, then rest
|
const isFit = idx < fitted.length // sorted = fitted first, then rest
|
||||||
const isOpen = expandedId === id
|
const isOpen = expandedId === id
|
||||||
|
const card = isPiano ? (
|
||||||
|
<PianoLickCard
|
||||||
|
lick={l}
|
||||||
|
rootPc={rootPc}
|
||||||
|
chordLabel={fitLabel ?? undefined}
|
||||||
|
size={isOpen ? 'full' : 'thumb'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LickCard lick={l} size={isOpen ? 'full' : 'thumb'} />
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<div key={id} role="listitem" className={`shrink-0 ${isOpen ? 'w-[340px]' : 'w-[220px]'}`}>
|
<div key={id} role="listitem" className={`shrink-0 ${isOpen ? 'w-[340px]' : 'w-[220px]'}`}>
|
||||||
<button
|
<button
|
||||||
@@ -900,10 +960,11 @@ function LicksStrip({ styleId, levels, instrument, context }) {
|
|||||||
className={
|
className={
|
||||||
'block w-full rounded-lg text-left outline-none transition ' +
|
'block w-full rounded-lg text-left outline-none transition ' +
|
||||||
'focus-visible:ring-2 focus-visible:ring-accent ' +
|
'focus-visible:ring-2 focus-visible:ring-accent ' +
|
||||||
(isFit ? 'ring-1 ring-accent' : '')
|
(isFit ? 'ring-1 ring-accent ' : '') +
|
||||||
|
(isOpen ? '' : thumbBox)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LickCard lick={l} size={isOpen ? 'full' : 'thumb'} />
|
{card}
|
||||||
</button>
|
</button>
|
||||||
{isFit && fitLabel && (
|
{isFit && fitLabel && (
|
||||||
<p className="mt-1 text-center text-[10px] font-medium text-accent">
|
<p className="mt-1 text-center text-[10px] font-medium text-accent">
|
||||||
|
|||||||
@@ -49,14 +49,12 @@
|
|||||||
// (MiniPiano's cropped-window geometry), technique chips, tips, source —
|
// (MiniPiano's cropped-window geometry), technique chips, tips, source —
|
||||||
// mirroring LickCard's full-size behaviour.
|
// mirroring LickCard's full-size behaviour.
|
||||||
//
|
//
|
||||||
// ── Playback (▶) ──────────────────────────────────────────────────────────────
|
// ── No playback (D-70 §3) ─────────────────────────────────────────────────────
|
||||||
// BassPatternCard's exact pattern: sequential single-note playVoicing calls
|
// The strip is purely visual — glance over audio (the user's settled call, "then
|
||||||
// (order lives in setTimeout scheduling — playVoicing sorts/dedupes, wrong for
|
// leave them off, better not"). The ▶ play path (sequencer + PlayButton) was
|
||||||
// a melody), beats at a fixed preview tempo (even eighths beatless), ONE
|
// removed with the rest of the ▶s across the app; the card only renders now.
|
||||||
// sequence module-wide + stopAll() so it never layers over other previews,
|
|
||||||
// unmount silences. Techniques are visual-only in playback (same precedent).
|
|
||||||
//
|
//
|
||||||
// ── Wiring contract (future LicksStrip integration — Luthier) ─────────────────
|
// ── Wiring contract (LicksStrip integration — Luthier, L-71) ──────────────────
|
||||||
// <PianoLickCard lick={…} rootPc={0–11} chordLabel="Dm7" size="thumb|full" />
|
// <PianoLickCard lick={…} rootPc={0–11} chordLabel="Dm7" size="thumb|full" />
|
||||||
// lick — one entry of a piano pack's top-level `licks` array
|
// lick — one entry of a piano pack's top-level `licks` array
|
||||||
// rootPc — the LIVE chord root pitch class (from the loop station)
|
// rootPc — the LIVE chord root pitch class (from the loop station)
|
||||||
@@ -67,10 +65,8 @@
|
|||||||
// Design tokens (tailwind.config.js) — SVG fills can't read Tailwind classes,
|
// Design tokens (tailwind.config.js) — SVG fills can't read Tailwind classes,
|
||||||
// so the constants below mirror the tokens (LickCard/MiniPiano convention).
|
// so the constants below mirror the tokens (LickCard/MiniPiano convention).
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { NOTES, CHORD_TYPES } from '../lib/theory'
|
import { NOTES, CHORD_TYPES } from '../lib/theory'
|
||||||
import { resolveDegree } from './JamGuide'
|
import { resolveDegree } from './JamGuide'
|
||||||
import { playVoicing, stopAll } from '../lib/chordAudio'
|
|
||||||
|
|
||||||
// ─── Vocabulary (hand-synced with validate-kb.mjs PIANO_LICK_TECHNIQUES; the
|
// ─── Vocabulary (hand-synced with validate-kb.mjs PIANO_LICK_TECHNIQUES; the
|
||||||
// smoke §7b-piano guard enforces set-equality — add to BOTH or neither) ────
|
// smoke §7b-piano guard enforces set-equality — add to BOTH or neither) ────
|
||||||
@@ -162,41 +158,6 @@ export function realizePianoLick(lick, rootPc) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sequential playback (module-level: one lick at a time, app-wide) ─────────
|
|
||||||
|
|
||||||
const PREVIEW_BPM = 96 // BassPatternCard's relaxed preview tempo
|
|
||||||
|
|
||||||
let currentSeq = null // { timeouts: number[], handles: {stop}[] }
|
|
||||||
|
|
||||||
function stopLick() {
|
|
||||||
if (!currentSeq) return
|
|
||||||
for (const t of currentSeq.timeouts) clearTimeout(t)
|
|
||||||
for (const h of currentSeq.handles) h.stop()
|
|
||||||
currentSeq = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function playLick(realized) {
|
|
||||||
stopLick()
|
|
||||||
stopAll() // never layer over a VoicingBrowser (or any other) preview
|
|
||||||
const beatMs = 60000 / PREVIEW_BPM
|
|
||||||
const hasBeats = realized.every((n) => Number.isFinite(n.beat))
|
|
||||||
const times = realized.map((n, i) => (hasBeats ? (n.beat - 1) * beatMs : (i * beatMs) / 2))
|
|
||||||
const seq = { timeouts: [], handles: [] }
|
|
||||||
realized.forEach((n, i) => {
|
|
||||||
// Ring until the next distinct onset (equal beats = a dyad, same onset);
|
|
||||||
// the last note gets one beat. Small floor so tight ornaments still sound.
|
|
||||||
const nextT = times.slice(i + 1).find((t) => t > times[i])
|
|
||||||
const durMs = Math.max(160, (nextT !== undefined ? nextT - times[i] : beatMs) + 120)
|
|
||||||
seq.timeouts.push(
|
|
||||||
setTimeout(() => {
|
|
||||||
// abs is already in chordAudio's note space (0 = C3) — no offset.
|
|
||||||
seq.handles.push(playVoicing([n.abs], { strumMs: 0, durMs, gain: 0.5 }))
|
|
||||||
}, times[i]),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
currentSeq = seq
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Timeline layout (x = beat/column, y = pitch) ─────────────────────────────
|
// ─── Timeline layout (x = beat/column, y = pitch) ─────────────────────────────
|
||||||
|
|
||||||
const PAD_T = 14 // headroom for grace glyphs above the top pill
|
const PAD_T = 14 // headroom for grace glyphs above the top pill
|
||||||
@@ -479,28 +440,6 @@ function TechniqueChip({ tech }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same ▶ pill as BassPatternCard / VoicingBrowser (classes mirrored so every
|
|
||||||
// gallery reads identically).
|
|
||||||
function PlayButton({ ariaLabel, onClick }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
onClick={onClick}
|
|
||||||
className={
|
|
||||||
'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-accent ' +
|
|
||||||
'bg-surface px-2.5 text-xs font-semibold text-accent outline-none transition ' +
|
|
||||||
'hover:bg-accent hover:text-black focus-visible:ring-2 focus-visible:ring-accent'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 12 12" className="h-3 w-3 fill-current">
|
|
||||||
<path d="M2.5 1.5v9l8-4.5z" />
|
|
||||||
</svg>
|
|
||||||
Play
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlaceholderCard({ name, size }) {
|
function PlaceholderCard({ name, size }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -525,10 +464,6 @@ export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full'
|
|||||||
const realized = realizePianoLick(lick, rootPc)
|
const realized = realizePianoLick(lick, rootPc)
|
||||||
const name = typeof lick?.name === 'string' && lick.name.trim() ? lick.name : 'Untitled lick'
|
const name = typeof lick?.name === 'string' && lick.name.trim() ? lick.name : 'Untitled lick'
|
||||||
|
|
||||||
// Unmount (loop/style/instrument change) silences any running sequence —
|
|
||||||
// module-level state, so this is idempotent across sibling cards.
|
|
||||||
useEffect(() => () => stopLick(), [])
|
|
||||||
|
|
||||||
if (!realized) {
|
if (!realized) {
|
||||||
return <PlaceholderCard name={lick ? name : null} size={size} />
|
return <PlaceholderCard name={lick ? name : null} size={size} />
|
||||||
}
|
}
|
||||||
@@ -573,7 +508,7 @@ export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full'
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* The pitch timeline */}
|
{/* The pitch timeline */}
|
||||||
<div className="max-w-full overflow-x-auto">
|
<div className="max-w-full overflow-x-auto dark-scroll">
|
||||||
<TimelineSvg
|
<TimelineSvg
|
||||||
layout={layout}
|
layout={layout}
|
||||||
ariaLabel={`Melody for ${name} over ${chordName}: ${pitchNames}`}
|
ariaLabel={`Melody for ${name} over ${chordName}: ${pitchNames}`}
|
||||||
@@ -582,7 +517,7 @@ export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full'
|
|||||||
|
|
||||||
{/* Keyboard view (full): the actual keys, numbered in strike order */}
|
{/* Keyboard view (full): the actual keys, numbered in strike order */}
|
||||||
{full && (
|
{full && (
|
||||||
<div className="max-w-full overflow-x-auto">
|
<div className="max-w-full overflow-x-auto dark-scroll">
|
||||||
<LickKeyboard
|
<LickKeyboard
|
||||||
realized={realized}
|
realized={realized}
|
||||||
ariaLabel={`Keys for ${name} over ${chordName}, numbered in playing order`}
|
ariaLabel={`Keys for ${name} over ${chordName}, numbered in playing order`}
|
||||||
@@ -602,11 +537,6 @@ export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full'
|
|||||||
{full && source && (
|
{full && source && (
|
||||||
<span className="text-[10px] text-gray-500 italic leading-snug">{source}</span>
|
<span className="text-[10px] text-gray-500 italic leading-snug">{source}</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PlayButton
|
|
||||||
ariaLabel={`Play ${name} over ${chordName}`}
|
|
||||||
onClick={() => playLick(realized)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user