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 ( +
+

+ Bass · roots, fifths & approaches{live ? ' · heard live' : ''} +

+
+ {stations.map((st, i) => { + const isNow = i === activeIndex + const next = !live && n > 1 ? stations[(i + 1) % n] : null + const fifthPc = (((st.rootPc + 7) % 12) + 12) % 12 + const approachPc = next ? (((next.rootPc + 11) % 12) + 12) % 12 : null + return ( +
+ {st.label} + {st.rn && ( + + {st.rn} + + )} + {isNow && ( + + now + + )} + + root {NOTES[st.rootPc]} + · + fifth {NOTES[fifthPc]} + {next && ( + <> + · + approach{' '} + + {NOTES[approachPc]} → {NOTES[next.rootPc]} + + + )} + +
+ ) + })} +
+ {/* ONE notice for the whole rail, not per row (D-40 §3). */} +

+ Authored bass patterns are on the way (blues first) — meanwhile: roots, + fifths, and the approach into the next chord. +

+
+ ) +} + +// ─── KnowledgeDock — the bottom browse & study collapsible (named export) ───── +// +// The old dock minus its jam section (that IS the band now, D-40 §5): Explore / +// Voicings / Licks & Techniques behind the pill nav, plus the shared +// foundation/intermediate level filter. Header is static — the live match label +// moved to the band's micro-header. +// +// Props: +// keyInfo : { root, mode, confidence } | null — effective key +// chordHistory : string[] — feeds the Voicings quick-pick chips +// currentChord : string | undefined — re-aims the Voicings picker +// onChordClick : fn(chordName) — opens ChordDetailModal +// instrument : 'guitar' | 'piano' | 'bass' — App's global selector +export function KnowledgeDock({ keyInfo, chordHistory = [], currentChord, onChordClick, instrument }) { + const [open, setOpen] = useState(false) + + // 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('explore') + 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 + }) + + // Style labels straight from the KB registry (LicksSection's chips). + const styles = useMemo( + () => Object.entries(kb).map(([id, style]) => ({ id, label: style?.meta?.label ?? id })), + [] + ) + return (
- {/* ── Collapsed header bar (always visible) ── */} + {/* ── Collapsed header bar (always visible; static label, D-40 §5) ── */} @@ -372,13 +550,11 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
{SECTIONS.map(s => { const active = section === s.id - const live = s.id === 'jam' && match.matched return ( ) })}
- {/* ── Section 1: Jam Guide (live) — the Roadmap body, moved verbatim ── */} - {section === 'jam' && ( - <> - {/* ── Tab rows ── */} -
- - {/* Instrument tabs */} -
- {INSTRUMENTS.map(inst => { - // Computed instruments (piano) need no KB pack — always selectable. - const enabled = COMPUTED_INSTRUMENTS.has(inst.id) || availableInstruments.has(inst.id) - const active = enabled && inst.id === instrument - return ( - - ) - })} -
- -
- - {/* Style tabs (from the KB registry) */} -
- {styles.map(style => { - const active = style.id === activeStyle - const isMatched = match.matched && style.id === match.style - return ( - - ) - })} -
-
- - {/* ── 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). */} - -
- ) : ( -
-

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.'} -

-
- )} -
- - )} - - {/* ── Section 2: Explore — KB progression browser + famous progressions ── */} + {/* ── Explore — KB progression browser + famous progressions ── */} {section === 'explore' && (
)} - {/* ── Section 3: Voicings — picker (follows live chord) → VoicingBrowser ── */} + {/* ── Voicings — picker (follows live chord) → VoicingBrowser ── */} {section === 'voicings' && (
)} - {/* ── Section 4: Licks & Techniques — per-style LickCard grid ── */} + {/* ── Licks & Techniques — per-style LickCard grid ── */} {section === 'licks' && (
@@ -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). */} - -
- ) -}