feat(jamguide): glance mode — the playhead accordion (task L-33)
The station under the playhead auto-expands to the full voicing
gallery (VoicingBrowser dense) and advances with the music; other
stations keep their recommended thumb; level-filtered licks strip
below with token-boundary context sort and strip-owned "fits — now"
chrome; pin replaces tap-to-enlarge with identical onFocusChord
semantics; auto-follow never emits focus or audio; heard-live fallback
shows the live chord gallery. App.jsx and all renderers untouched.
Critic PASS (SSR 37/37, matcher proven on hostile cases, dense-less
render byte-identical to 0945a08).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||||
|
<section
|
||||||
|
className="rounded-2xl border border-border bg-panel p-3"
|
||||||
|
aria-label="Voicing variations — follows the playhead"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h4 className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
|
Variations · {pinned ? 'pinned' : 'follows the playhead'}
|
||||||
|
</h4>
|
||||||
|
{pinned && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPin?.(null)}
|
||||||
|
className="flex min-h-[32px] items-center gap-1.5 rounded-lg border border-accent bg-accent/10 px-2.5 py-1 text-xs font-medium text-accent outline-none transition hover:bg-accent/20 focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">▶</span> follow the jam
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-start sm:overflow-x-auto sm:pb-1" role="list">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
ref={expandedRef}
|
||||||
|
role="listitem"
|
||||||
|
aria-current={isNow ? 'true' : undefined}
|
||||||
|
aria-label={`${st.label}${st.rn ? ` (${st.rn})` : ''} — every ${instrument} voicing`}
|
||||||
|
className={
|
||||||
|
'order-1 w-full shrink-0 rounded-lg border p-2 sm:order-none sm:w-max ' +
|
||||||
|
(isNow ? 'border-accent bg-accent/10' : 'border-accent/60 bg-accent/5')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mb-1.5 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={isPinnedHere}
|
||||||
|
onClick={() => onPin?.(isPinnedHere ? null : i)}
|
||||||
|
title={isPinnedHere
|
||||||
|
? `Unpin ${st.label} — follow the jam again`
|
||||||
|
: `Pin ${st.label} — hold its voicings open, light its guide tones on the fretboard`}
|
||||||
|
className="flex min-h-[32px] items-center gap-1.5 rounded px-1 outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold leading-none text-gray-100">{st.label}</span>
|
||||||
|
{st.rn && (
|
||||||
|
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
|
||||||
|
{st.rn}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isNow && (
|
||||||
|
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent">
|
||||||
|
now
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isPinnedHere && (
|
||||||
|
<span className="text-[9px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
|
pinned
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* dense: the rail shows the mic-feedback microcopy once, below. */}
|
||||||
|
<VoicingBrowser rootPc={st.rootPc} quality={st.quality} show={instrument} dense />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
aria-pressed={false}
|
||||||
|
aria-current={isNow ? 'true' : undefined}
|
||||||
|
onClick={() => onPin?.(i)}
|
||||||
|
title={`Pin ${st.label}${st.rn ? ` (${st.rn})` : ''} — hold its voicings open`}
|
||||||
|
className={
|
||||||
|
(isNext ? 'order-2 flex sm:order-none ' : 'hidden sm:flex ') +
|
||||||
|
'shrink-0 flex-col items-center gap-1 rounded-lg border p-2 outline-none transition ' +
|
||||||
|
'focus-visible:ring-2 focus-visible:ring-accent ' +
|
||||||
|
(isNow
|
||||||
|
? 'border-accent bg-accent/10 ring-1 ring-accent'
|
||||||
|
: 'border-border bg-surface hover:border-gray-500')
|
||||||
|
}
|
||||||
|
style={{ opacity: isNow ? 1 : 0.85 }}
|
||||||
|
>
|
||||||
|
{isNext && (
|
||||||
|
<span className="self-start text-[9px] font-semibold uppercase tracking-widest text-gray-500 sm:hidden">
|
||||||
|
next
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{st.voicing ? (
|
||||||
|
<>
|
||||||
|
<MiniPiano voicing={st.voicing} size="thumb" />
|
||||||
|
{/* MiniPiano has no built-in chord label; mirror ChordDiagram's
|
||||||
|
thumb label (text-gray-300, 9px) so the station stays named. */}
|
||||||
|
<span className="text-gray-300 leading-none" style={{ fontSize: 9 }}>
|
||||||
|
{st.label}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ChordDiagram
|
||||||
|
shape={st.shape}
|
||||||
|
keyRoot={keyRoot}
|
||||||
|
rootPc={st.rootPc}
|
||||||
|
size="thumb"
|
||||||
|
label={st.label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{st.rn && (
|
||||||
|
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-500">
|
||||||
|
{st.rn}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mic-feedback microcopy — ONCE for the whole rail (D-31 §2.5); the
|
||||||
|
gallery mounts run `dense` and suppress their per-mount copy. */}
|
||||||
|
<p className="mt-2 text-[11px] text-gray-500">
|
||||||
|
▶ previews play through your speakers — while the mic is live, detection may
|
||||||
|
hear them. Nothing plays automatically.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
+187
-141
@@ -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 kb from '../data/kb/index.js'
|
||||||
import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC } from '../lib/match'
|
import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC } from '../lib/match'
|
||||||
import { NOTES, CHORD_TYPES } from '../lib/theory'
|
import { NOTES, CHORD_TYPES } from '../lib/theory'
|
||||||
import RoadmapTrack from './RoadmapTrack'
|
import RoadmapTrack from './RoadmapTrack'
|
||||||
import ChordDiagram from './ChordDiagram'
|
import GlanceRail from './GlanceRail'
|
||||||
import MiniPiano from './MiniPiano'
|
|
||||||
import VoicingBrowser from './VoicingBrowser'
|
import VoicingBrowser from './VoicingBrowser'
|
||||||
import LickCard, { TechniqueLegend } from './LickCard'
|
import LickCard, { TechniqueLegend } from './LickCard'
|
||||||
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'
|
||||||
|
|
||||||
// ─── JamGuide — the Knowledge Center bottom dock ──────────────────────────────
|
// ─── 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 }) {
|
export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], bpm, currentChord, onFocusChord, onChordClick }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
@@ -216,6 +244,16 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
return (((position - match.rotation) % n) + n) % n
|
return (((position - match.rotation) % n) + n) % n
|
||||||
}, [match.matched, match.progression, match.rotation, position])
|
}, [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 ────────────────────────────────────────────────────
|
// ── Per-station voicings ────────────────────────────────────────────────────
|
||||||
// Stations are canonical KB order — index i aligns 1:1 with
|
// Stations are canonical KB order — index i aligns 1:1 with
|
||||||
// progression.degrees[i] (the same station order RoadmapTrack renders). Each
|
// progression.degrees[i] (the same station order RoadmapTrack renders). Each
|
||||||
@@ -278,22 +316,27 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
return stations
|
return stations
|
||||||
}, [match.matched, match.progression, match.style, instrument, keyRoot])
|
}, [match.matched, match.progression, match.style, instrument, keyRoot])
|
||||||
|
|
||||||
// ── Tap-to-enlarge: which station's voicing is expanded (full diagram). ──
|
// ── Pinned station (L-33 — replaces tap-to-enlarge `selectedStation`, same
|
||||||
const [selectedStation, setSelectedStation] = useState(null)
|
// semantics): pinning halts the rail's auto-follow and holds that station's
|
||||||
// Reset the selection whenever the loop or style changes underneath us.
|
// gallery open. null = follow the jam. ──
|
||||||
useEffect(() => { setSelectedStation(null) }, [match.id, match.style, instrument])
|
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) ─────────────────────────────────
|
// ── Cross-link to the main Fretboard (D-03) ─────────────────────────────────
|
||||||
// When a station is selected, report its {rootPc, quality} upward so the
|
// When a station is PINNED, report its {rootPc, quality} upward so the
|
||||||
// Fretboard can light that chord's guide tones; clear (null) on deselect. The
|
// Fretboard can light that chord's guide tones; clear (null) on unpin. The
|
||||||
// reset effect above sets selectedStation → null on loop/style/instrument
|
// reset effect above sets pinnedStation → null on loop/style/instrument
|
||||||
// change, which flows through here and clears the highlight too. Guarded so
|
// change, which flows through here and clears the highlight too. Guarded so
|
||||||
// the component still works standalone (onFocusChord optional).
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!onFocusChord) return
|
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)
|
onFocusChord(st ? { rootPc: st.rootPc, quality: st.quality } : null)
|
||||||
}, [selectedStation, stationVoicings, onFocusChord])
|
}, [pinnedStation, stationVoicings, onFocusChord])
|
||||||
|
|
||||||
// Clear the Fretboard highlight when JamGuide unmounts.
|
// Clear the Fretboard highlight when JamGuide unmounts.
|
||||||
useEffect(() => () => { onFocusChord?.(null) }, [onFocusChord])
|
useEffect(() => () => { onFocusChord?.(null) }, [onFocusChord])
|
||||||
@@ -415,7 +458,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Roadmap slot (D-02 assembly) ── */}
|
{/* ── Roadmap slot (D-02 assembly; L-33 glance fallbacks) ── */}
|
||||||
<div data-roadmap-slot className="flex-1 min-h-0 p-4 overflow-auto">
|
<div data-roadmap-slot className="flex-1 min-h-0 p-4 overflow-auto">
|
||||||
{match.matched ? (
|
{match.matched ? (
|
||||||
<RoadmapAssembly
|
<RoadmapAssembly
|
||||||
@@ -425,9 +468,40 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
position={canonicalPos}
|
position={canonicalPos}
|
||||||
bpm={bpm}
|
bpm={bpm}
|
||||||
stationVoicings={stationVoicings}
|
stationVoicings={stationVoicings}
|
||||||
selectedStation={selectedStation}
|
pinnedStation={pinnedStation}
|
||||||
onSelectStation={setSelectedStation}
|
onPin={setPinnedStation}
|
||||||
|
instrument={instrument}
|
||||||
|
styleId={activeStyle}
|
||||||
|
levels={levels}
|
||||||
/>
|
/>
|
||||||
|
) : 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. */
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<section
|
||||||
|
className="rounded-2xl border border-border bg-panel p-3"
|
||||||
|
aria-label={`Heard live — every ${instrument} voicing of ${currentChord}`}
|
||||||
|
>
|
||||||
|
<h4 className="mb-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
|
Heard live · {currentChord} — every voicing
|
||||||
|
</h4>
|
||||||
|
<p className="mb-2 text-[11px] text-gray-500">
|
||||||
|
{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.'}
|
||||||
|
</p>
|
||||||
|
<VoicingBrowser rootPc={liveChord.rootPc} quality={liveChord.type} show={instrument} />
|
||||||
|
</section>
|
||||||
|
{/* 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). */}
|
||||||
|
<LicksStrip
|
||||||
|
styleId={activeStyle}
|
||||||
|
levels={levels}
|
||||||
|
instrument={instrument}
|
||||||
|
context={{ rn: '', quality: liveChord.type, label: currentChord }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="h-full min-h-[200px] flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border text-center"
|
className="h-full min-h-[200px] flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border text-center"
|
||||||
@@ -486,10 +560,6 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
// work whether or not that data has landed, hence the defensive reads and the
|
// work whether or not that data has landed, hence the defensive reads and the
|
||||||
// honest per-style empty states). One TechniqueLegend per grid, never per card.
|
// honest per-style empty states). One TechniqueLegend per grid, never per card.
|
||||||
function LicksSection({ styles, levels, onToggleLevel }) {
|
function LicksSection({ styles, levels, onToggleLevel }) {
|
||||||
const licksFor = (id) => {
|
|
||||||
const l = kb?.[id]?.instruments?.guitar?.licks
|
|
||||||
return Array.isArray(l) ? l : []
|
|
||||||
}
|
|
||||||
const stylesWithLicks = useMemo(
|
const stylesWithLicks = useMemo(
|
||||||
() => styles.filter(s => licksFor(s.id).length > 0),
|
() => styles.filter(s => licksFor(s.id).length > 0),
|
||||||
[styles] // kb is a static module import
|
[styles] // kb is a static module import
|
||||||
@@ -501,7 +571,7 @@ function LicksSection({ styles, levels, onToggleLevel }) {
|
|||||||
|
|
||||||
const all = licksFor(activeStyle)
|
const all = licksFor(activeStyle)
|
||||||
// Licks without a `level` count as foundation (D-20 §4).
|
// 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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -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 (
|
||||||
|
<section
|
||||||
|
className="rounded-2xl border border-border bg-panel p-3"
|
||||||
|
aria-label={`${styleLabel} guitar licks`}
|
||||||
|
>
|
||||||
|
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
|
{styleLabel} licks · guitar
|
||||||
|
{instrument === 'piano' ? ' (no piano licks in the KB yet)' : ''}
|
||||||
|
{fitted.length > 0 && fitLabel ? ` · fits ${fitLabel} first` : ''}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-start gap-2 overflow-x-auto pb-1" role="list">
|
||||||
|
{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 (
|
||||||
|
<div key={id} role="listitem" className={`shrink-0 ${isOpen ? 'w-[340px]' : 'w-[220px]'}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={isOpen}
|
||||||
|
aria-label={`${l?.name ?? 'lick'}${isFit && fitLabel ? ` — fits ${fitLabel} now` : ''}; tap to ${isOpen ? 'shrink' : 'enlarge'}`}
|
||||||
|
onClick={() => setExpandedId(isOpen ? null : id)}
|
||||||
|
className={
|
||||||
|
'block w-full rounded-lg text-left outline-none transition ' +
|
||||||
|
'focus-visible:ring-2 focus-visible:ring-accent ' +
|
||||||
|
(isFit ? 'ring-1 ring-accent' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LickCard lick={l} size={isOpen ? 'full' : 'thumb'} />
|
||||||
|
</button>
|
||||||
|
{isFit && fitLabel && (
|
||||||
|
<p className="mt-1 text-center text-[10px] font-medium text-accent">
|
||||||
|
fits {fitLabel} — now
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── RoadmapAssembly — the live panel body ────────────────────────────────────
|
// ─── RoadmapAssembly — the live panel body ────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Composes RoadmapTrack (the improv highway) with a secondary voicing strip
|
// Composes RoadmapTrack (the improv highway) with the GlanceRail — the
|
||||||
// (one thumbnail per station, canonical KB order): ChordDiagram when a station
|
// station-aligned voicing rail (extracted verbatim in L-33 commit 1; the
|
||||||
// carries a guitar `shape`, MiniPiano when it carries a piano `voicing` —
|
// playhead accordion lands in commit 2). The parent keeps ownership of the
|
||||||
// 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
|
// pinned-station state so the onFocusChord contract stays in JamGuide.
|
||||||
// into view. Narrow viewports (< ~640px) reflow: the strip wraps and the whole
|
|
||||||
// panel scrolls vertically rather than forcing a wide horizontal layout.
|
|
||||||
function RoadmapAssembly({
|
function RoadmapAssembly({
|
||||||
progression, keyRoot, keyMode, position, bpm,
|
progression, keyRoot, keyMode, position, bpm,
|
||||||
stationVoicings, selectedStation, onSelectStation,
|
stationVoicings, pinnedStation, onPin, instrument, styleId, levels,
|
||||||
}) {
|
}) {
|
||||||
const stripRef = useRef(null)
|
// Licks-strip context = the PLAYHEAD station (position -1 → station 0, the
|
||||||
const activeRef = useRef(null)
|
// 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).
|
||||||
// Auto-scroll the active station's thumbnail into view as the playhead moves.
|
const contextStation = stationVoicings[position >= 0 ? position : 0] ?? null
|
||||||
// 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
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||||
@@ -609,111 +742,24 @@ function RoadmapAssembly({
|
|||||||
bpm={bpm}
|
bpm={bpm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Secondary voicing strip: one thumbnail per station, canonical order. */}
|
{/* The playhead accordion (one column per station, canonical order). */}
|
||||||
{stationVoicings.length > 0 && (
|
<GlanceRail
|
||||||
<section
|
stations={stationVoicings}
|
||||||
className="rounded-2xl border border-border bg-panel p-3"
|
activeIndex={position}
|
||||||
aria-label="Voicing thumbnails"
|
pinnedIndex={pinnedStation}
|
||||||
>
|
onPin={onPin}
|
||||||
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
instrument={instrument}
|
||||||
Voicings · tap to enlarge
|
|
||||||
</h4>
|
|
||||||
<div
|
|
||||||
ref={stripRef}
|
|
||||||
className="flex flex-wrap gap-2 overflow-x-auto sm:flex-nowrap"
|
|
||||||
role="list"
|
|
||||||
>
|
|
||||||
{stationVoicings.map((st, i) => {
|
|
||||||
const isNow = i === position
|
|
||||||
const isSelected = i === selectedStation
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
ref={isNow ? activeRef : undefined}
|
|
||||||
type="button"
|
|
||||||
role="listitem"
|
|
||||||
aria-pressed={isSelected}
|
|
||||||
aria-current={isNow ? 'true' : undefined}
|
|
||||||
onClick={() => onSelectStation(isSelected ? null : i)}
|
|
||||||
title={`${st.label} — ${st.rn || `station ${i + 1}`}`}
|
|
||||||
className={
|
|
||||||
`flex shrink-0 flex-col items-center gap-1 rounded-lg border p-2 outline-none transition ` +
|
|
||||||
`focus-visible:ring-2 focus-visible:ring-accent ` +
|
|
||||||
(isNow
|
|
||||||
? 'border-accent bg-accent/10 ring-1 ring-accent'
|
|
||||||
: isSelected
|
|
||||||
? 'border-accent/60 bg-accent/5'
|
|
||||||
: 'border-border bg-surface hover:border-gray-500')
|
|
||||||
}
|
|
||||||
style={{ opacity: isNow ? 1 : 0.85 }}
|
|
||||||
>
|
|
||||||
{st.voicing ? (
|
|
||||||
<>
|
|
||||||
<MiniPiano voicing={st.voicing} size="thumb" />
|
|
||||||
{/* MiniPiano has no built-in chord label; mirror ChordDiagram's
|
|
||||||
thumb label (text-gray-300, 9px) so the station stays named. */}
|
|
||||||
<span className="text-gray-300 leading-none" style={{ fontSize: 9 }}>
|
|
||||||
{st.label}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<ChordDiagram
|
|
||||||
shape={st.shape}
|
|
||||||
keyRoot={keyRoot}
|
keyRoot={keyRoot}
|
||||||
rootPc={st.rootPc}
|
|
||||||
size="thumb"
|
|
||||||
label={st.label}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{st.rn && (
|
|
||||||
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-500">
|
|
||||||
{st.rn}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Enlarged view of the tapped station (deferred fretboard cross-link
|
{/* Licks for the active style, current-station-context first. At 1280×900
|
||||||
lives here instead — see D-02 return note). */}
|
this sits just below the fold — one scroll-flick down (D-31 §3). */}
|
||||||
{selected && (
|
<LicksStrip
|
||||||
<div className="mt-3 flex flex-col items-center gap-2 border-t border-border pt-3">
|
styleId={styleId}
|
||||||
{selected.voicing ? (
|
levels={levels}
|
||||||
<>
|
instrument={instrument}
|
||||||
<MiniPiano voicing={selected.voicing} size="full" />
|
context={contextStation}
|
||||||
<span className="text-gray-300 leading-none" style={{ fontSize: 12 }}>
|
|
||||||
{`${selected.label}${selected.rn ? ` · ${selected.rn}` : ''}`}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<ChordDiagram
|
|
||||||
shape={selected.shape}
|
|
||||||
keyRoot={keyRoot}
|
|
||||||
rootPc={selected.rootPc}
|
|
||||||
size="full"
|
|
||||||
label={`${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). */}
|
|
||||||
<div className="w-full">
|
|
||||||
<VoicingBrowser rootPc={selected.rootPc} quality={selected.quality} />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelectStation(null)}
|
|
||||||
className="text-[11px] text-gray-500 underline-offset-2 hover:text-gray-300 hover:underline focus-visible:ring-2 focus-visible:ring-accent rounded outline-none"
|
|
||||||
>
|
|
||||||
close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
// show — 'guitar' | 'piano' | 'both' (default 'both', task D-23): which
|
// show — 'guitar' | 'piano' | 'both' (default 'both', task D-23): which
|
||||||
// instrument section(s) to render. Any other value falls back to
|
// instrument section(s) to render. Any other value falls back to
|
||||||
// both, so every pre-existing mount renders identically with no prop.
|
// 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 { useEffect, useMemo, useRef } from 'react'
|
||||||
import ChordDiagram from './ChordDiagram'
|
import ChordDiagram from './ChordDiagram'
|
||||||
@@ -135,7 +140,7 @@ function GalleryCell({ label, playLabel, onPlay, children }) {
|
|||||||
|
|
||||||
// ─── The gallery ──────────────────────────────────────────────────────────────
|
// ─── 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 pc = mod12(Number.isFinite(rootPc) ? rootPc : 0)
|
||||||
const name = chordName(pc, quality)
|
const name = chordName(pc, quality)
|
||||||
const chordKey = `${pc}:${quality}`
|
const chordKey = `${pc}:${quality}`
|
||||||
@@ -196,7 +201,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
|
|||||||
{showGuitar && (
|
{showGuitar && (
|
||||||
<section
|
<section
|
||||||
aria-label={`Guitar voicings for ${name}`}
|
aria-label={`Guitar voicings for ${name}`}
|
||||||
className="min-w-[240px] flex-1 basis-[300px] rounded-lg border border-border bg-panel p-3"
|
className={`min-w-[240px] flex-1 basis-[300px] rounded-lg border border-border bg-panel ${dense ? 'p-2' : 'p-3'}`}
|
||||||
>
|
>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<SectionHeading>Guitar · {name}</SectionHeading>
|
<SectionHeading>Guitar · {name}</SectionHeading>
|
||||||
@@ -232,7 +237,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
|
|||||||
{showPiano && (
|
{showPiano && (
|
||||||
<section
|
<section
|
||||||
aria-label={`Piano voicings for ${name}`}
|
aria-label={`Piano voicings for ${name}`}
|
||||||
className="min-w-[240px] flex-1 basis-[300px] rounded-lg border border-border bg-panel p-3"
|
className={`min-w-[240px] flex-1 basis-[300px] rounded-lg border border-border bg-panel ${dense ? 'p-2' : 'p-3'}`}
|
||||||
>
|
>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<SectionHeading>Piano · {name}</SectionHeading>
|
<SectionHeading>Piano · {name}</SectionHeading>
|
||||||
@@ -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).
|
{/* 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. */}
|
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 && (
|
||||||
<p className="w-full basis-full text-[11px] text-gray-500">
|
<p className="w-full basis-full text-[11px] text-gray-500">
|
||||||
Previews play through your speakers — while the mic is live, detection may
|
Previews play through your speakers — while the mic is live, detection may
|
||||||
hear them.
|
hear them.
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user