diff --git a/src/App.jsx b/src/App.jsx index c8ea22f..c5e965d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -675,6 +675,7 @@ export default function App() { bpm={bpm} currentChord={currentChord} onFocusChord={setJamFocusChord} + onChordClick={setSelectedChord} /> ) diff --git a/src/components/ExplorePanel.jsx b/src/components/ExplorePanel.jsx index 55bc780..61f5676 100644 --- a/src/components/ExplorePanel.jsx +++ b/src/components/ExplorePanel.jsx @@ -1,6 +1,24 @@ -import { useState } from 'react' +// ExplorePanel — refactored into Knowledge Center parts (task L-22, per +// docs/design/knowledge-center.md §7 step 1). +// +// This file now exports the named building blocks the Knowledge Center shell +// (JamGuide.jsx) composes: +// +// — the shared foundation/intermediate filter +// — controlled root × quality picker row +// — KB progression browser + famous progressions +// — picker (follows the live chord) → VoicingBrowser +// +// The default export remains a thin standalone composition of the parts (the +// panel is verified-orphaned — no importer — so it exists only so the file +// stays a complete, mountable component). GuitarGrid/PianoGrid are kept as +// exported no-audio fallbacks per the D-20 IA map (§2). + +import { useEffect, useMemo, useState } from 'react' import ChordBox from './ChordBox' import MiniPiano from './MiniPiano' +import VoicingBrowser from './VoicingBrowser' +import kb from '../data/kb/index.js' import { getGuitarVoicings, getPianoTechniques, parseChord } from '../lib/voicings' import { CHORD_TYPES, NOTES, getChordsInKey, toRomanNumeral } from '../lib/theory' import { FAMOUS_PROGRESSIONS, progressionInKey } from '../lib/education' @@ -24,6 +42,66 @@ const CHORD_TYPE_OPTIONS = [ const MAJOR_TYPES = new Set(['maj','maj7','maj6','add9','sus4','sus2','aug','dom7']) +// Progressions/licks without a `level` count as foundation (D-20 §4). +const levelOf = (item) => (item?.level === 'intermediate' ? 'intermediate' : 'foundation') + +// ─── Level filter chips (shared by Explore + Licks toolbars) ────────────────── +// Two toggle chips, both on by default. The SHELL owns the `levels` state +// ({foundation, intermediate}) and enforces "both can't be off"; the chip for +// the last active level advertises the no-op via its title. +export function LevelChips({ levels = {}, onToggle }) { + const defs = [ + { key: 'foundation', label: 'Foundation' }, + { key: 'intermediate', label: 'Intermediate' }, + ] + return ( +
+ {defs.map(d => { + const active = !!levels[d.key] + const lastActive = active && !defs.some(o => o.key !== d.key && levels[o.key]) + return ( + + ) + })} +
+ ) +} + +// Level badge on cards — mirrors LickCard's badge treatment (amber = the +// existing secondary-tone token; foundation stays quiet). +function LevelBadge({ level }) { + if (level === 'intermediate') { + return ( + + intermediate + + ) + } + if (level === 'foundation') { + return ( + + foundation + + ) + } + return null +} + // ─── Quick-pick chip row ────────────────────────────────────────────────────── function ChipRow({ label, chords, active, keyInfo, onSelect }) { if (!chords?.length) return null @@ -35,7 +113,7 @@ function ChipRow({ label, chords, active, keyInfo, onSelect }) { const rn = keyInfo?.root ? toRomanNumeral(chord, keyInfo.root, keyInfo.mode) : '' return ( + ))} + +
+
+ + +
+
{chordName}
+
+ ) +} + +// ─── Guitar voicings grid (no-audio fallback; superseded by VoicingBrowser) ── +export function GuitarGrid({ chordName }) { const voicings = getGuitarVoicings(chordName) if (!voicings.length) return

No voicings for {chordName}.

return ( @@ -71,8 +179,8 @@ function GuitarGrid({ chordName }) { ) } -// ─── Piano techniques grid ──────────────────────────────────────────────────── -function PianoGrid({ chordName }) { +// ─── Piano techniques grid (no-audio fallback; superseded by VoicingBrowser) ─ +export function PianoGrid({ chordName }) { const parsed = parseChord(chordName) const techniques = getPianoTechniques(chordName) const rootPc = parsed?.rootPc ?? 0 @@ -98,6 +206,8 @@ function PianoGrid({ chordName }) { } // ─── Famous progressions using this chord as tonic ─────────────────────────── +// NOTE (D-20 §4, recorded Maestro call): FAMOUS_PROGRESSIONS carries no `level` +// field — these cards show no badge and are EXEMPT from the level filter. function ProgressionCards({ chordName, onChordClick }) { const parsed = parseChord(chordName) if (!parsed) return null @@ -131,7 +241,7 @@ function ProgressionCards({ chordName, onChordClick }) { {chordsHere.map((c, i) => ( + {i < chords.length - 1 && } + + ))} + + )} + {prog?.tip &&

{prog.tip}

} + {songs.length > 0 && ( +

{songs.slice(0, 3).join(' · ')}

+ )} + + ) +} + +// ─── Explore section — KB progression browser + famous progressions ────────── +// Props: keyInfo (chords render in the detected key; C until one is known), +// levels + onToggleLevel (shell-owned shared filter), onChordClick (chord name +// string → ChordDetailModal). +export function ExploreSection({ keyInfo, levels, onToggleLevel, onChordClick }) { + const styles = useMemo( + () => Object.entries(kb ?? {}).map(([id, s]) => ({ id, label: s?.meta?.label ?? id })), + [] + ) + const [styleOverride, setStyleOverride] = useState(null) + const activeStyle = styleOverride ?? styles[0]?.id + + const keyRootPc = parseChord(keyInfo?.root ?? '')?.rootPc ?? 0 + const keyMode = keyInfo?.mode === 'minor' ? 'minor' : 'major' + const tonicName = `${NOTES[keyRootPc]}${keyMode === 'minor' ? 'm' : ''}` + + const progressions = kb?.[activeStyle]?.progressions ?? [] + const visible = progressions.filter(p => levels?.[levelOf(p)]) + + return ( +
+ {/* Toolbar: style chips + shared level filter */} +
+
+ {styles.map(s => { + const active = s.id === activeStyle + return ( + + ) + })} +
+
+ +
+ +

+ Chords shown in {NOTES[keyRootPc]} {keyMode}{keyInfo?.root ? '' : ' (no key detected yet)'} · tap any chord for voicings +

+ + {/* KB progression cards */} + {visible.length > 0 ? ( +
+ {visible.map((p, i) => ( + + ))} +
+ ) : ( +

+ {progressions.length === 0 + ? 'No progressions authored for this style yet.' + : 'Nothing at the selected level for this style — flip the level filter back on.'} +

+ )} + + {/* Famous progressions (exempt from the level filter — untagged corpus) */} +
+

+ Famous progressions · not affected by the level filter +

+ +
+
+ ) +} + +// ─── 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 }) { const [root, setRoot] = useState('C') const [typeKey, setTypeKey] = useState('maj') - const [view, setView] = useState('guitar') // guitar | piano | progressions const [active, setActive] = useState('') - const chordName = root + (CHORD_TYPES[typeKey]?.suffix ?? '') + useEffect(() => { + if (!currentChord) return + const p = parseChord(currentChord) + if (p) { setRoot(NOTES[p.rootPc]); setTypeKey(p.type); setActive(currentChord) } + }, [currentChord]) function selectChord(chord) { setActive(chord) @@ -169,79 +396,58 @@ export default function ExplorePanel({ keyInfo, chordHistory, onChordClick }) { if (p) { setRoot(NOTES[p.rootPc]); setTypeKey(p.type) } } - // Context-aware quick-picks - const recentChords = [...new Set([...(chordHistory ?? [])].reverse())].slice(0, 12) - const keyChords = keyInfo?.root ? getChordsInKey(keyInfo.root, keyInfo.mode ?? 'major') : [] + const recentChords = [...new Set([...(chordHistory ?? [])].reverse())].slice(0, 12) + const keyChords = keyInfo?.root ? getChordsInKey(keyInfo.root, keyInfo.mode ?? 'major') : [] + const rootPc = parseChord(root)?.rootPc ?? 0 + + return ( +
+ {(recentChords.length > 0 || keyChords.length > 0) && ( +
+ + {keyChords.length > 0 && recentChords.length > 0 &&
} + {keyChords.length > 0 && ( + + )} +
+ )} + + { setRoot(n); setActive('') }} + onTypeChange={k => { setTypeKey(k); setActive('') }} + /> + + +
+ ) +} + +// ─── Standalone panel (thin composition; orphaned — kept mountable) ────────── +export default function ExplorePanel({ keyInfo, chordHistory, currentChord, onChordClick }) { + const [open, setOpen] = useState(false) + 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 // both can't be off + }) return (
- {open && ( -
- - {/* ── Context quick-picks ── */} - {(recentChords.length > 0 || keyChords.length > 0) && ( -
- - {keyChords.length > 0 && recentChords.length > 0 &&
} - {keyChords.length > 0 && ( - - )} -
- )} - - {/* ── Manual chord picker ── */} -
-
- {NOTES.map(n => ( - - ))} -
-
-
- - -
-
{chordName}
-
- - {/* ── View tabs ── */} -
- {[ - { key: 'guitar', label: '🎸 Guitar Voicings' }, - { key: 'piano', label: '🎹 Piano Techniques' }, - { key: 'progressions', label: '🎵 Progressions' }, - ].map(t => ( - - ))} -
- - {/* ── Content ── */} - {view === 'guitar' && } - {view === 'piano' && } - {view === 'progressions' && { selectChord(c); onChordClick?.(c) }} />} - +
+ +
)}
diff --git a/src/components/JamGuide.jsx b/src/components/JamGuide.jsx index a70026a..6dcb92c 100644 --- a/src/components/JamGuide.jsx +++ b/src/components/JamGuide.jsx @@ -6,25 +6,34 @@ import RoadmapTrack from './RoadmapTrack' import ChordDiagram from './ChordDiagram' import MiniPiano from './MiniPiano' import VoicingBrowser from './VoicingBrowser' +import LickCard, { TechniqueLegend } from './LickCard' +import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel' import { pianoVoicingChain } from '../lib/piano' -// ─── JamGuide — the Roadmap bottom dock ─────────────────────────────────────── +// ─── JamGuide — the Knowledge Center bottom dock ────────────────────────────── // -// The large bottom panel of JamBuddy. This is the SHELL (task L-02): the -// collapsed header bar, instrument + style tabs (derived from the KB registry), -// live loop → KB progression resolution, and a clearly-marked placeholder slot -// where the Roadmap visualization (RoadmapTrack + ChordDiagram, task D-02) will -// be wired in afterwards. +// The large bottom panel of JamBuddy. Originally the Roadmap Jam Guide dock +// (tasks L-02/D-02/L-11); task L-22 grew it into the KNOWLEDGE CENTER shell per +// docs/design/knowledge-center.md — one dock, four sections behind a pill nav: // -// This component does NOT import RoadmapTrack or ChordDiagram — sibling tasks -// build those in parallel; D-02 fills the [data-roadmap-slot] left here. +// Jam Guide (live) — the original Roadmap body, moved verbatim (default) +// Explore — KB progression browser + famous progressions +// Voicings — chord picker (follows the live chord) → VoicingBrowser +// Licks & Techniques — per-style LickCard grid + technique legend // -// Props (the contract D-02 relies on): +// Explore/Voicings parts come from ExplorePanel.jsx (refactored to named +// exports); the shell owns the shared foundation/intermediate level filter +// consumed by Explore + Licks. Collapsed-bar behaviour is unchanged apart from +// the "Knowledge Center" name. +// +// Props: // detectedProgression : string[] | null — the live detected loop (chord names) // keyInfo : { root, mode, confidence } | null — effective key // chordHistory : string[] — committed chord history (for position) // bpm : number | null — live tempo from the onset pipeline // currentChord : string | undefined — most recent committed chord +// onFocusChord : fn({rootPc,quality}|null) — Fretboard guide-tone link (D-03) +// onChordClick : fn(chordName) — opens ChordDetailModal (additive, L-22) // Display order for instrument tabs; availability is derived from the KB, not // hardcoded — EXCEPT piano, which is always available: its voicings are COMPUTED @@ -37,9 +46,27 @@ const INSTRUMENTS = [ ] const COMPUTED_INSTRUMENTS = new Set(['piano']) -export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], bpm, currentChord, onFocusChord }) { +// Knowledge Center sections (D-20 §1). 'jam' is the default landing section. +const SECTIONS = [ + { id: 'jam', label: 'Jam Guide' }, + { id: 'explore', label: 'Explore' }, + { id: 'voicings', label: 'Voicings' }, + { id: 'licks', label: 'Licks & Techniques' }, +] + +export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], bpm, currentChord, onFocusChord, onChordClick }) { const [open, setOpen] = useState(false) + // ── 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 + }) + // Which instruments have at least one KB pack across the registry. const availableInstruments = useMemo(() => { const set = new Set() @@ -186,7 +213,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = > 🎸 - Jam Guide + Knowledge Center {headerLabel} {match.matched && keyInfo?.root && ( @@ -202,6 +229,40 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = {open && (
+ {/* ── Section nav (Knowledge Center pills, D-20 §1) ── */} +
+ {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 ── */}
@@ -284,6 +345,131 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
)}
+ + )} + + {/* ── Section 2: Explore — KB progression browser + famous progressions ── */} + {section === 'explore' && ( +
+ +
+ )} + + {/* ── Section 3: Voicings — picker (follows live chord) → VoicingBrowser ── */} + {section === 'voicings' && ( +
+ +
+ )} + + {/* ── Section 4: Licks & Techniques — per-style LickCard grid ── */} + {section === 'licks' && ( +
+ +
+ )} +
+ )} +
+ ) +} + +// ─── LicksSection — per-style structured-lick grid (D-20 §1 section 4) ──────── +// +// Reads the STRUCTURED top-level `kb[style].instruments.guitar.licks ?? []` +// (C-20 schema; P-21 authors blues/jazz/funk concurrently — the section must +// 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. +function LicksSection({ styles, levels, onToggleLevel }) { + const licksFor = (id) => { + 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 + ) + + const [styleOverride, setStyleOverride] = useState(null) + const activeStyle = styleOverride ?? stylesWithLicks[0]?.id ?? styles[0]?.id + const activeLabel = styles.find(s => s.id === activeStyle)?.label ?? activeStyle + + 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']) + + return ( +
+ {/* Toolbar: style chips + shared level filter */} +
+
+ {styles.map(s => { + const active = s.id === activeStyle + const has = stylesWithLicks.some(w => w.id === s.id) + return ( + + ) + })} +
+
+ +
+ +

+ Guitar licks · tab reads high e on top · amber marks = techniques (legend below) +

+ + {visible.length > 0 ? ( + <> +
+ {visible.map((l, i) => ( + + ))} +
+ {/* Glyph key — once per grid (D-20 §3), not per card */} +
+ +
+ + ) : ( +
+ {all.length === 0 ? ( + <> +

No licks authored for {activeLabel} yet.

+

+ {stylesWithLicks.length > 0 + ? `${stylesWithLicks.map(s => s.label).join(', ')} ${stylesWithLicks.length === 1 ? 'has' : 'have'} them — pick one above.` + : 'Lick packs are landing style by style — check back soon.'} +

+ + ) : ( +

+ Nothing at the selected level for {activeLabel} — flip the level filter back on. +

+ )}
)}