diff --git a/src/components/GlanceRail.jsx b/src/components/GlanceRail.jsx new file mode 100644 index 0000000..0c1ec7e --- /dev/null +++ b/src/components/GlanceRail.jsx @@ -0,0 +1,209 @@ +// GlanceRail — the playhead accordion (task L-33, per docs/design/glance-mode.md §1–§4). +// +// A station-aligned rail under the RoadmapTrack: columns mirror the Roadmap's +// stations (canonical KB order, same labels/rn). The column at `activeIndex` +// (the playhead) is EXPANDED to the full VoicingBrowser gallery — every +// placeable guitar shape, or every piano style, side by side — while every +// other column keeps its recommended thumb (the KB play's shape / the threaded +// piano voicing). The expansion ADVANCES WITH THE PLAYHEAD: over one loop cycle +// the player is shown every variation of every chord with zero clicks (user +// directive 2026-07-10). Constant footprint — expanding one column collapses +// the previous. +// +// Pinning (D-31 §4): tapping a collapsed column pins its gallery open and halts +// auto-follow; unpin by tapping the pinned column's header or the "follow the +// jam" chip. The PARENT owns the pin state and the onFocusChord emission — this +// component never emits focus-chord and NEVER triggers audio on its own +// (auto-follow must not feed the mic; every ▶ inside the gallery is a gesture). +// +// Space honesty (D-31 §3): cells are never shrunk below the D-30 sizes — the +// rail scrolls horizontally with the expanded column auto-centred. Scrolling is +// the piano rail's NORMAL state on most loops (a rootless 7th-chord gallery is +// ~940px on its own). Narrow (<640px, D-31 §3): one thing per row — the current +// station's full gallery (cells wrap), then a single "next" thumb; the Roadmap +// above still shows the whole loop. +// +// Pure presentational. Props (the D-31 §5 contract): +// stations — [{ shape, voicing, rootPc, quality, label, rn }] canonical order +// activeIndex — playhead station (canonicalPos); -1 = loop known, playhead +// not — station 0 expands, but nothing is marked "now" (§2.1) +// pinnedIndex — the pinned station index, or null (= follow the jam) +// onPin — fn(index|null): pin a station / unpin +// instrument — 'guitar' | 'piano' (VoicingBrowser `show`) +// keyRoot — key tonic pitch class 0–11 (ChordDiagram fret placement) + +import { useEffect, useRef } from 'react' +import ChordDiagram from './ChordDiagram' +import MiniPiano from './MiniPiano' +import VoicingBrowser from './VoicingBrowser' + +export default function GlanceRail({ + stations = [], activeIndex = -1, pinnedIndex = null, onPin, instrument, keyRoot, +}) { + const n = stations.length + const pinned = pinnedIndex != null && pinnedIndex >= 0 && pinnedIndex < n + // The expanded column: the pin wins; otherwise follow the playhead; with the + // playhead unknown (-1) station 0 expands so the rail is never all-collapsed. + const expandedIndex = pinned ? pinnedIndex : (activeIndex >= 0 ? activeIndex : 0) + // Narrow-viewport lookahead: the one collapsed thumb worth its pixels (§3). + const followBase = activeIndex >= 0 ? activeIndex : 0 + const nextIndex = n > 1 ? (followBase + 1) % n : -1 + + // Auto-centre the expanded column as the accordion advances. Prop-driven — + // no rAF, nothing tied to the audio thread. Respect prefers-reduced-motion + // (D-31 §2.1): jump instead of smooth-scrolling. + const expandedRef = useRef(null) + useEffect(() => { + const el = expandedRef.current + if (!el) return + const reduceMotion = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches + el.scrollIntoView({ + behavior: reduceMotion ? 'auto' : 'smooth', + inline: 'center', + block: 'nearest', + }) + }, [expandedIndex]) + + if (n === 0) return null + + return ( +
+
+

+ Variations · {pinned ? 'pinned' : 'follows the playhead'} +

+ {pinned && ( + + )} +
+ +
+ {stations.map((st, i) => { + const isNow = i === activeIndex + const isExpanded = i === expandedIndex + const isPinnedHere = pinned && i === pinnedIndex + + if (isExpanded) { + // ── The expanded column: the full D-30 gallery for this station. ── + return ( +
+
+ + {isNow && ( + + now + + )} + {isPinnedHere && ( + + pinned + + )} +
+ {/* dense: the rail shows the mic-feedback microcopy once, below. */} + +
+ ) + } + + // ── Collapsed column: the recommended thumb; tap to pin. ── + // Narrow (<640px): only the "next" thumb survives — the rest are + // dropped (the Roadmap above still shows the whole loop, §3). + const isNext = i === nextIndex + return ( + + ) + })} +
+ + {/* Mic-feedback microcopy — ONCE for the whole rail (D-31 §2.5); the + gallery mounts run `dense` and suppress their per-mount copy. */} +

+ ▶ previews play through your speakers — while the mic is live, detection may + hear them. Nothing plays automatically. +

+
+ ) +} diff --git a/src/components/JamGuide.jsx b/src/components/JamGuide.jsx index e4b97d2..9c7593f 100644 --- a/src/components/JamGuide.jsx +++ b/src/components/JamGuide.jsx @@ -1,14 +1,14 @@ -import { useState, useMemo, useRef, useEffect } from 'react' +import { useState, useMemo, useEffect } from 'react' import kb from '../data/kb/index.js' import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC } from '../lib/match' import { NOTES, CHORD_TYPES } from '../lib/theory' import RoadmapTrack from './RoadmapTrack' -import ChordDiagram from './ChordDiagram' -import MiniPiano from './MiniPiano' +import GlanceRail from './GlanceRail' import VoicingBrowser from './VoicingBrowser' import LickCard, { TechniqueLegend } from './LickCard' import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel' import { pianoVoicingChain } from '../lib/piano' +import { parseChord } from '../lib/voicings' // ─── JamGuide — the Knowledge Center bottom dock ────────────────────────────── // @@ -133,6 +133,34 @@ function recipeVoicing(recipe, rootPc, quality) { } } +// ─── Licks helpers (shared by LicksSection + the glance LicksStrip) ─────────── +// +// `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) { + const l = kb?.[id]?.instruments?.guitar?.licks + return Array.isArray(l) ? l : [] +} + +// Level-filter rule (D-20 §4): licks without a `level` count as foundation. +const lickLevel = (l) => (l?.level === 'intermediate' ? 'intermediate' : 'foundation') + +// Token-boundary chordContext match (D-31 §2.4). `chordContext` is FREE TEXT +// ("over the I7", "♭VII9 → I9, landing on the One", "i7/i9 Dorian vamp") — a +// naive substring would make rn "I" match "♭VII" / "Imaj7" / "I7". So: tokenise +// the text on chord-symbol characters and require EXACT token equality against +// the station's rn or quality key. Case-sensitive — Roman-numeral case is +// semantic ("i" minor ≠ "I" major). +const CONTEXT_TOKEN_RE = /[A-Za-z0-9#♭]+/g +function lickFitsContext(lick, context) { + if (!context) return false + const ctx = typeof lick?.chordContext === 'string' ? lick.chordContext : '' + const tokens = ctx.match(CONTEXT_TOKEN_RE) ?? [] + const wanted = [context.rn, context.quality].filter(Boolean) + return wanted.length > 0 && tokens.some(t => wanted.includes(t)) +} + export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], bpm, currentChord, onFocusChord, onChordClick }) { const [open, setOpen] = useState(false) @@ -216,6 +244,16 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = return (((position - match.rotation) % n) + n) % n }, [match.matched, match.progression, match.rotation, position]) + // ── Heard-live fallback chord (L-33, D-31 §2.3) ───────────────────────────── + // No loop matched but chords are committing → the jam section shows a single + // expanded gallery for the live chord, re-aimed on every commit. parseChord is + // the same src/lib/voicings.js parser VoicingsSection uses; unparseable names + // yield null and keep the dashed empty state. + const liveChord = useMemo( + () => (!match.matched && currentChord ? parseChord(currentChord) : null), + [match.matched, currentChord] + ) + // ── Per-station voicings ──────────────────────────────────────────────────── // Stations are canonical KB order — index i aligns 1:1 with // progression.degrees[i] (the same station order RoadmapTrack renders). Each @@ -278,22 +316,27 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = return stations }, [match.matched, match.progression, match.style, instrument, keyRoot]) - // ── Tap-to-enlarge: which station's voicing is expanded (full diagram). ── - const [selectedStation, setSelectedStation] = useState(null) - // Reset the selection whenever the loop or style changes underneath us. - useEffect(() => { setSelectedStation(null) }, [match.id, match.style, instrument]) + // ── Pinned station (L-33 — replaces tap-to-enlarge `selectedStation`, same + // semantics): pinning halts the rail's auto-follow and holds that station's + // gallery open. null = follow the jam. ── + const [pinnedStation, setPinnedStation] = useState(null) + // Reset the pin whenever the loop or style changes underneath us. + useEffect(() => { setPinnedStation(null) }, [match.id, match.style, instrument]) // ── Cross-link to the main Fretboard (D-03) ───────────────────────────────── - // When a station is selected, report its {rootPc, quality} upward so the - // Fretboard can light that chord's guide tones; clear (null) on deselect. The - // reset effect above sets selectedStation → null on loop/style/instrument + // When a station is PINNED, report its {rootPc, quality} upward so the + // Fretboard can light that chord's guide tones; clear (null) on unpin. The + // reset effect above sets pinnedStation → null on loop/style/instrument // change, which flows through here and clears the highlight too. Guarded so // the component still works standalone (onFocusChord optional). + // Auto-follow (the unpinned accordion) NEVER emits focus-chord — repainting + // the player's fretboard uninvited every chord change would fight their own + // key view (D-31 §2.6). Only the pin gesture reaches this effect. useEffect(() => { if (!onFocusChord) return - const st = selectedStation != null ? stationVoicings[selectedStation] : null + const st = pinnedStation != null ? stationVoicings[pinnedStation] : null onFocusChord(st ? { rootPc: st.rootPc, quality: st.quality } : null) - }, [selectedStation, stationVoicings, onFocusChord]) + }, [pinnedStation, stationVoicings, onFocusChord]) // Clear the Fretboard highlight when JamGuide unmounts. useEffect(() => () => { onFocusChord?.(null) }, [onFocusChord]) @@ -415,7 +458,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = - {/* ── Roadmap slot (D-02 assembly) ── */} + {/* ── Roadmap slot (D-02 assembly; L-33 glance fallbacks) ── */}
{match.matched ? ( + ) : liveChord ? ( + /* No loop matched, but chords are committing (D-31 §2.3): the rail + degrades to a single "heard live" gallery, re-aimed on every + chord commit. Auto-follow only — nothing plays by itself. */ +
+
+

+ Heard live · {currentChord} — every voicing +

+

+ {detectedProgression?.length + ? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; following the chord as it commits.` + : 'No repeating loop yet — following the chord as it commits.'} +

+ +
+ {/* No station rn without a loop — context sort falls back to the + live chord's quality key (e.g. a "dom7" lick fits a live G7). */} + +
) : (
{ - const l = kb?.[id]?.instruments?.guitar?.licks - return Array.isArray(l) ? l : [] - } const stylesWithLicks = useMemo( () => styles.filter(s => licksFor(s.id).length > 0), [styles] // kb is a static module import @@ -501,7 +571,7 @@ function LicksSection({ styles, levels, onToggleLevel }) { const all = licksFor(activeStyle) // Licks without a `level` count as foundation (D-20 §4). - const visible = all.filter(l => levels[l?.level === 'intermediate' ? 'intermediate' : 'foundation']) + const visible = all.filter(l => levels[lickLevel(l)]) return (
@@ -572,31 +642,94 @@ function LicksSection({ styles, levels, onToggleLevel }) { ) } +// ─── LicksStrip — glanceable licks below the rail (L-33, D-31 §2.4) ─────────── +// +// Thumb LickCards 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). +// +// 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]) + + const visible = licksFor(styleId).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 + + const styleLabel = kb?.[styleId]?.meta?.label ?? styleId + const fitLabel = typeof context?.label === 'string' ? context.label : null + + return ( +
+

+ {styleLabel} licks · guitar + {instrument === 'piano' ? ' (no piano licks in the KB yet)' : ''} + {fitted.length > 0 && fitLabel ? ` · fits ${fitLabel} first` : ''} +

+
+ {sorted.map((l, idx) => { + const id = l?.id ?? `lick-${idx}` + const isFit = idx < fitted.length // sorted = fitted first, then rest + const isOpen = expandedId === id + return ( +
+ + {isFit && fitLabel && ( +

+ fits {fitLabel} — now +

+ )} +
+ ) + })} +
+
+ ) +} + // ─── RoadmapAssembly — the live panel body ──────────────────────────────────── // -// Composes RoadmapTrack (the improv highway) with a secondary voicing strip -// (one thumbnail per station, canonical KB order): ChordDiagram when a station -// carries a guitar `shape`, MiniPiano when it carries a piano `voicing` — -// authored (L-24) or computed (L-11 — the piano tab). Tapping a thumbnail enlarges it to a full view inline. The active station auto-scrolls -// into view. Narrow viewports (< ~640px) reflow: the strip wraps and the whole -// panel scrolls vertically rather than forcing a wide horizontal layout. +// Composes RoadmapTrack (the improv highway) with the GlanceRail — the +// station-aligned voicing rail (extracted verbatim in L-33 commit 1; the +// playhead accordion lands in commit 2). The parent keeps ownership of the +// pinned-station state so the onFocusChord contract stays in JamGuide. function RoadmapAssembly({ progression, keyRoot, keyMode, position, bpm, - stationVoicings, selectedStation, onSelectStation, + stationVoicings, pinnedStation, onPin, instrument, styleId, levels, }) { - const stripRef = useRef(null) - const activeRef = useRef(null) - - // Auto-scroll the active station's thumbnail into view as the playhead moves. - // Prop-driven (off `position`) — no rAF loop tied to the audio thread. - useEffect(() => { - if (position < 0 || !activeRef.current) return - activeRef.current.scrollIntoView({ - behavior: 'smooth', inline: 'center', block: 'nearest', - }) - }, [position]) - - const selected = selectedStation != null ? stationVoicings[selectedStation] : null + // Licks-strip context = the PLAYHEAD station (position -1 → station 0, the + // same rule as the rail's expansion). The pin freezes the accordion, not the + // strip — the strip keeps re-sorting with the jam (D-31 §2.4). + const contextStation = stationVoicings[position >= 0 ? position : 0] ?? null return (
@@ -609,111 +742,24 @@ function RoadmapAssembly({ bpm={bpm} /> - {/* Secondary voicing strip: one thumbnail per station, canonical order. */} - {stationVoicings.length > 0 && ( -
-

- Voicings · tap to enlarge -

-
- {stationVoicings.map((st, i) => { - const isNow = i === position - const isSelected = i === selectedStation - return ( - - ) - })} -
+ {/* The playhead accordion (one column per station, canonical order). */} + - {/* Enlarged view of the tapped station (deferred fretboard cross-link - lives here instead — see D-02 return note). */} - {selected && ( -
- {selected.voicing ? ( - <> - - - {`${selected.label}${selected.rn ? ` · ${selected.rn}` : ''}`} - - - ) : ( - - )} - {/* Browse + audition every voicing of this station's chord (L-21). - Stations already carry {rootPc, quality} (L-11); the browser is - fully self-contained (D-21) and wraps dock-friendly. Purely - additive — the onFocusChord guide-tone contract above is - untouched (it keys off selectedStation, not this render). */} -
- -
- -
- )} -
- )} + {/* Licks for the active style, current-station-context first. At 1280×900 + this sits just below the fold — one scroll-flick down (D-31 §3). */} +
) } diff --git a/src/components/VoicingBrowser.jsx b/src/components/VoicingBrowser.jsx index d5a2a51..3ca318b 100644 --- a/src/components/VoicingBrowser.jsx +++ b/src/components/VoicingBrowser.jsx @@ -37,6 +37,11 @@ // show — 'guitar' | 'piano' | 'both' (default 'both', task D-23): which // instrument section(s) to render. Any other value falls back to // both, so every pre-existing mount renders identically with no prop. +// dense — boolean (default false, task L-33 — additive per D-31 §5): trims +// section padding and suppresses the per-mount mic-feedback +// microcopy, for mounts inside the GlanceRail accordion where the +// rail shows that microcopy ONCE for the whole rail (D-31 §2.5). +// Every pre-existing mount renders identically with no prop. import { useEffect, useMemo, useRef } from 'react' import ChordDiagram from './ChordDiagram' @@ -135,7 +140,7 @@ function GalleryCell({ label, playLabel, onPlay, children }) { // ─── The gallery ────────────────────────────────────────────────────────────── -export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'both' }) { +export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'both', dense = false }) { const pc = mod12(Number.isFinite(rootPc) ? rootPc : 0) const name = chordName(pc, quality) const chordKey = `${pc}:${quality}` @@ -196,7 +201,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo {showGuitar && (
Guitar · {name} @@ -232,7 +237,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo {showPiano && (
Piano · {name} @@ -261,11 +266,15 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo )} {/* Mic-feedback caveat, per the L-20 header + D-20 §3 (microcopy tier). - At least one section always renders (see the gating above), so this stays. */} -

- Previews play through your speakers — while the mic is live, detection may - hear them. -

+ At least one section always renders (see the gating above), so this + stays — except under `dense`, where the GlanceRail shows the SAME + microcopy once for the whole rail (D-31 §2.5) instead of per gallery. */} + {!dense && ( +

+ Previews play through your speakers — while the mic is live, detection may + hear them. +

+ )}
) }