diff --git a/src/App.jsx b/src/App.jsx
index ec12567..6a910e2 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -11,9 +11,8 @@ import DebugView from './components/DebugView'
import DrumView from './components/DrumView'
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
import ChordDetailModal from './components/ChordDetailModal'
-import CurrentJamPanel from './components/CurrentJamPanel'
import LoopStation from './components/LoopStation'
-import JamGuide from './components/JamGuide'
+import JamGuide, { KnowledgeDock } from './components/JamGuide'
import { useLoopEngine } from './services/loopEngine'
import settingIcon from './assets/setting-icon.png'
@@ -630,12 +629,16 @@ export default function App() {
- {/* ── Current jam — collapsible ── */}
-
{/* ── Loop station ── */}
@@ -708,15 +711,13 @@ export default function App() {
{showTuner &&
}
- {/* ── Jam Guide — bottom dock (Roadmap) ── */}
-
)
diff --git a/src/components/ExplorePanel.jsx b/src/components/ExplorePanel.jsx
index 61f5676..3a2196d 100644
--- a/src/components/ExplorePanel.jsx
+++ b/src/components/ExplorePanel.jsx
@@ -379,7 +379,10 @@ export function ExploreSection({ keyInfo, levels, onToggleLevel, onChordClick })
// ─── Voicings section — picker (follows the live chord) → VoicingBrowser ─────
// Props: keyInfo + chordHistory feed the quick-pick chips; currentChord re-aims
// the picker whenever a new chord commits (manual picks hold until then).
-export function VoicingsSection({ keyInfo, chordHistory, currentChord }) {
+// `instrument` (L-40, D-40 §3) scopes the browser to App's global selector —
+// omitted (the orphaned standalone panel below) it falls back to 'both' via
+// VoicingBrowser's own `show` default.
+export function VoicingsSection({ keyInfo, chordHistory, currentChord, instrument }) {
const [root, setRoot] = useState('C')
const [typeKey, setTypeKey] = useState('maj')
const [active, setActive] = useState('')
@@ -422,7 +425,7 @@ export function VoicingsSection({ keyInfo, chordHistory, currentChord }) {
onTypeChange={k => { setTypeKey(k); setActive('') }}
/>
-
+
)
}
diff --git a/src/components/GlanceRail.jsx b/src/components/GlanceRail.jsx
index 0c1ec7e..ecba23b 100644
--- a/src/components/GlanceRail.jsx
+++ b/src/components/GlanceRail.jsx
@@ -17,11 +17,14 @@
// (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.
+// rail scrolls horizontally, USER-OWNED. The old auto-centre effect was deleted
+// in L-40 (D-40 §4/§6.1): the rail now lives in page flow (the Jam Guide band,
+// no 70vh scroller), where scrollIntoView's nearest scroller is the DOCUMENT —
+// every playhead advance would yank the whole page. 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 loop
+// display up top (ProgressionBanner) still shows the whole loop.
//
// Pure presentational. Props (the D-31 §5 contract):
// stations — [{ shape, voicing, rootPc, quality, label, rn }] canonical order
@@ -32,7 +35,6 @@
// 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'
@@ -49,23 +51,6 @@ export default function GlanceRail({
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 (
@@ -99,7 +84,6 @@ export default function GlanceRail({
return (
0 && tokens.some(t => wanted.includes(t))
}
-export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], bpm, currentChord, onFocusChord, onChordClick }) {
- const [open, setOpen] = useState(false)
+// ─── JamGuide — the always-open jam band (default export) ─────────────────────
+//
+// Props:
+// detectedProgression : string[] | null — the live detected loop (chord names)
+// keyInfo : { root, mode, confidence } | null — effective key
+// chordHistory : string[] — committed chord history (playhead)
+// currentChord : string | undefined — most recent committed chord
+// onFocusChord : fn({rootPc,quality}|null) — Fretboard guide-tone link (D-03)
+// instrument : 'guitar' | 'piano' | 'bass' — App's global selector
- // ── Knowledge Center shell state ────────────────────────────────────────────
- // Active section + the shared level filter (Explore + Licks toolbars, D-20 §4).
- // Both levels on by default; both can never be off (last-chip tap is a no-op).
- const [section, setSection] = useState('jam')
- const [levels, setLevels] = useState({ foundation: true, intermediate: true })
- const toggleLevel = (key) => setLevels(prev => {
- const next = { ...prev, [key]: !prev[key] }
- return (next.foundation || next.intermediate) ? next : prev
- })
+// The band shows ALL levels — a glance surface filters nothing (D-40 §5); the
+// level filter lives in the KnowledgeDock only.
+const ALL_LEVELS = { foundation: true, intermediate: true }
- // Which instruments have at least one KB pack across the registry.
- const availableInstruments = useMemo(() => {
- const set = new Set()
- for (const style of Object.values(kb)) {
- for (const inst of Object.keys(style?.instruments ?? {})) set.add(inst)
- }
- return set
- }, [])
-
- // Style tabs straight from the KB registry, labelled via each style's meta.
+export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], currentChord, onFocusChord, instrument = 'guitar' }) {
+ // Style labels straight from the KB registry, via each style's meta.
const styles = useMemo(
() => Object.entries(kb).map(([id, style]) => ({ id, label: style?.meta?.label ?? id })),
[]
@@ -204,31 +181,28 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
[chordHistory, loopKey] // eslint-disable-line react-hooks/exhaustive-deps
)
- // Instrument tab: default to guitar (the only packs that exist today).
- const [instrument, setInstrument] = useState('guitar')
+ // Style: always follow the matched style (the style-override tabs died with
+ // the instrument tabs, D-40 §1 — browsing other styles lives in the dock's
+ // own chips); nothing matched → styles[0] anchors the heard-live LicksStrip.
+ const activeStyle = match.matched ? match.style : styles[0]?.id
- // Style tab: follow the matched style, but let the user override.
- const [styleOverride, setStyleOverride] = useState(null)
- const activeStyle = styleOverride ?? (match.matched ? match.style : styles[0]?.id)
-
- // Header summary: matched progression name, or a listening hint.
+ // Micro-header summary: matched progression name, or a listening hint.
const matchedName = match.matched ? match.progression?.name : null
const headerLabel = matchedName
? matchedName
: (detectedProgression?.length ? 'mapping the changes…' : 'listening…')
- // ── Key root: keyInfo.root is a note NAME (e.g. "C"). RoadmapTrack and
- // ChordDiagram both want a pitch class 0–11. Convert once; default to C (0)
- // until a key is known so the roadmap still resolves to *some* spelling. ──
+ // ── Key root: keyInfo.root is a note NAME (e.g. "C"). ChordDiagram wants a
+ // pitch class 0–11. Convert once; default to C (0) until a key is known so
+ // the stations still resolve to *some* spelling. ──
const keyRoot = useMemo(() => {
const pc = chordRootPC(keyInfo?.root)
return pc >= 0 ? pc : 0
}, [keyInfo?.root])
- const keyMode = keyInfo?.mode === 'minor' ? 'minor' : 'major'
// ── Playhead reconciliation ────────────────────────────────────────────────
// `position` from findLoopPosition is an index into the *detected* loop, which
- // can start on any rotation of the KB progression. RoadmapTrack renders the
+ // can start on any rotation of the KB progression. The rail renders the
// progression in *canonical KB order* (degrees[0] first). They differ by
// `match.rotation` — the loop index that aligns with KB degrees[0]. To map a
// detected-loop index back to its canonical station:
@@ -237,6 +211,8 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
// rotation = 2 (loop index 2 = the "I" = KB degrees[0]).
// Playhead on the V (detected index 1) → ((1 − 2) % 4 + 4) % 4 = 3 = the V's
// canonical station. NOW lands on the right station. ✓
+ // (ProgressionBanner highlights the same playhead on its own loop chips —
+ // the ONE loop display, D-40 §2.)
const canonicalPos = useMemo(() => {
if (!match.matched) return -1
const n = match.progression?.degrees?.length ?? 0
@@ -256,7 +232,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
// ── Per-station voicings ────────────────────────────────────────────────────
// 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 the rail renders). Each
// entry carries the station's chord identity ({rootPc, quality, label, rn} —
// the tap-to-fretboard contract) plus an instrument-specific payload:
// guitar → `shape`: from the recommended KB guitar play (the first play);
@@ -270,8 +246,11 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
// station whose recipe fails to resolve falls back to the computed
// chain individually. The station's rootPc is attached so MiniPiano
// marks the root key ("R") reliably.
+ // bass → neither payload (both stay null): no authored bass patterns exist
+ // yet, so the band renders the computed BassGuideRows from the
+ // station identities alone (L-40 step 3, D-40 §3).
const stationVoicings = useMemo(() => {
- if (!match.matched || (instrument !== 'guitar' && instrument !== 'piano')) return []
+ if (!match.matched) return []
const prog = match.progression
const degrees = prog?.degrees ?? []
const qualities = prog?.qualities ?? []
@@ -295,7 +274,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
for (let i = 0; i < stations.length; i++) {
stations[i].shape = chords[i]?.shape ?? null
}
- } else {
+ } else if (instrument === 'piano') {
// Authored piano pack first (L-24): the matched style's first piano play
// for this progression, per-chord recipes resolved via recipeVoicing.
const pianoPlays = kb[match.style]?.instruments?.piano?.plays?.[prog?.id]
@@ -341,10 +320,214 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
// Clear the Fretboard highlight when JamGuide unmounts.
useEffect(() => () => { onFocusChord?.(null) }, [onFocusChord])
+ // Licks-strip context = the PLAYHEAD station (canonicalPos −1 → station 0,
+ // the same rule as the rail's expansion). The pin freezes the rail, not the
+ // strip — the strip keeps re-sorting with the jam (D-31 §2.4).
+ const contextStation = stationVoicings[canonicalPos >= 0 ? canonicalPos : 0] ?? null
+
+ return (
+
+
+ {/* ── Micro-header — a line, not a button (D-40 §1: zero chrome) ── */}
+
+ Jam Guide — {headerLabel}
+ {match.matched && keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode}` : ''}
+
+
+ {match.matched ? (
+ instrument === 'bass' ? (
+ /* Honest bass state (D-40 §3): the KB has no bass patterns yet and
+ both gallery generators are wrong for bass — computed roots/fifths/
+ approaches instead. The licks strip hides too (guitar tab licks
+ are noise to a bassist mid-jam). */
+
+ ) : (
+
+ {/* The voicing rail (the playhead accordion until D-41 expands all rows). */}
+
+
+
+ )
+ ) : liveChord ? (
+ /* No loop matched, but chords are committing (D-31 §2.3): a single
+ "heard live" gallery, re-aimed on every chord commit. Auto-follow
+ only — nothing plays by itself. Bass: D-40 §3's prose forbids guitar/
+ piano galleries under BASS, so the live chord gets the same computed
+ root/fifth line (no next chord → no approach) instead. */
+ instrument === 'bass' ? (
+
+ ) : (
+
+
+
+ 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). */}
+
+
+ )
+ ) : (
+ /* Nothing heard yet — one slim line (~40px, D-40 §1): the idle band
+ must not waste main-module space. */
+
+ {detectedProgression?.length
+ ? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; voicings follow the next chord that commits.`
+ : 'Play a few bars — voicings and licks for your loop land here.'}
+
+ )}
+
+ )
+}
+
+// ─── BassGuideRows — the honest bass state (L-40 step 3, D-40 §3) ─────────────
+//
+// The KB has zero authored bass content (the C-41 → P-41 → L-42 chain builds
+// it, blues first) and both gallery generators are wrong for bass: guitar
+// shapes are not bass patterns, pianoVoicing is piano. Showing either would
+// break the one-selector promise — so each station renders the honest useful
+// minimum, PURE ARITHMETIC on data the band already has (no theory.js change):
+// the ROOT, the FIFTH (root + 7 semitones), and the chromatic APPROACH into the
+// NEXT station's root (one semitone below it — "approach: G♯ → A"). The last
+// station approaches the first (the loop wraps). Solo-scale/guide-tone headers
+// land with D-41's row anatomy; L-42 replaces these lines with authored
+// BassPatternCards per station when the matched style ships a bass cell.
+//
+// stations — [{ rootPc, quality, label, rn }] canonical KB order
+// activeIndex — playhead station (canonicalPos); -1 = none marked "now"
+// live — heard-live single chord: no next chord, so no approach line
+function BassGuideRows({ stations = [], activeIndex = -1, live = false }) {
+ const n = stations.length
+ if (n === 0) return null
+ return (
+
+
- {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). */}
-
-
- ) : (
-
-
Play a few bars — I'll map the changes
-
- {detectedProgression?.length
- ? `Heard ${detectedProgression.join(' → ')}, but it doesn't match a ${activeStyle} pattern yet.`
- : 'Roadmap renders here once a repeating loop is detected.'}
-
@@ -716,50 +767,3 @@ function LicksStrip({ styleId, levels, instrument, context }) {
)
}
-// ─── RoadmapAssembly — the live panel body ────────────────────────────────────
-//
-// 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, pinnedStation, onPin, instrument, styleId, levels,
-}) {
- // 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 (
-
- {/* The improv highway — active-station styling + playhead live inside it. */}
-
-
- {/* The playhead accordion (one column per station, canonical order). */}
-
-
- {/* 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). */}
-
-