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 (
+
+
+ {/* 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` : ''}
+
+
+ )
+}
+
// ─── 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 && (
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.
+