// 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 CircleOfFifths from './CircleOfFifths'
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'
const CHORD_TYPE_OPTIONS = [
{ key: 'maj', label: 'Major' },
{ key: 'min', label: 'Minor' },
{ key: 'dom7', label: '7' },
{ key: 'maj7', label: 'maj7' },
{ key: 'min7', label: 'm7' },
{ key: 'dim', label: 'dim' },
{ key: 'dim7', label: 'dim7' },
{ key: 'half_dim', label: 'm7♭5' },
{ key: 'aug', label: 'aug' },
{ key: 'sus4', label: 'sus4' },
{ key: 'sus2', label: 'sus2' },
{ key: 'maj6', label: '6' },
{ key: 'min6', label: 'm6' },
{ key: 'add9', label: 'add9' },
]
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 (
onToggle?.(d.key)}
title={lastActive
? 'At least one level stays on'
: `${active ? 'Hide' : 'Show'} ${d.label.toLowerCase()} material`}
className={`min-h-[32px] px-2.5 py-1 rounded-lg border text-xs transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent ${
active
? 'bg-accent/20 border-accent text-accent font-semibold'
: 'bg-surface border-border text-gray-400 hover:text-gray-200 hover:border-gray-500'
}`}
>
{d.label}
)
})}
)
}
// 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
return (
{label}
{chords.map(chord => {
const rn = keyInfo?.root ? toRomanNumeral(chord, keyInfo.root, keyInfo.mode) : ''
return (
onSelect(chord)}
className={`flex flex-col items-center px-2.5 py-1 rounded-lg border text-xs font-bold transition-all outline-none focus-visible:ring-2 focus-visible:ring-accent ${
active === chord
? 'bg-accent border-accent text-white'
: 'bg-surface border-border text-gray-300 hover:border-accent/50 hover:text-accent'
}`}>
{chord}
{rn && {rn} }
)
})}
)
}
// ─── Chord picker toolbar (controlled: root × quality) ───────────────────────
export function ChordPickerToolbar({ root, typeKey, onRootChange, onTypeChange }) {
const chordName = root + (CHORD_TYPES[typeKey]?.suffix ?? '')
return (
{NOTES.map(n => (
onRootChange?.(n)}
aria-pressed={root === n}
className={`px-2 py-0.5 rounded text-xs font-bold transition-all outline-none focus-visible:ring-2 focus-visible:ring-accent ${
root === n ? 'bg-accent text-white' : 'bg-border text-gray-400 hover:text-white'
}`}>
{n}
))}
onTypeChange?.(e.target.value)}
aria-label="Chord quality"
className="appearance-none bg-panel border border-border rounded-lg pl-2 pr-6 py-1 text-xs text-gray-200 cursor-pointer focus:outline-none focus:border-accent">
{CHORD_TYPE_OPTIONS.map(o => {o.label} )}
▾
{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 (
{voicings.map((v, i) => (
))}
Purple = chord tone · finger numbers inside dots (1=index 4=pinky) · fret number on left if not starting at fret 1
)
}
// ─── 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
if (!techniques.length) return No techniques for {chordName}.
return (
{techniques.map((t, i) => (
{t.name}
{t.desc}
Tip: {t.tip}
))}
)
}
// ─── 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
const { rootPc, type } = parsed
const root = NOTES[rootPc]
const isMajor = MAJOR_TYPES.has(type)
const matching = FAMOUS_PROGRESSIONS.filter(p => {
const q0 = p.qualities[0]
return isMajor ? MAJOR_TYPES.has(q0) : !MAJOR_TYPES.has(q0)
}).slice(0, 6)
return (
Famous progressions with {chordName} as the tonic.
Click any chord to see its voicings.
{matching.map(prog => {
const chordsHere = progressionInKey(prog, root)
return (
{prog.name}
{prog.pattern}
{prog.genre.slice(0, 2).map(g => (
{g}
))}
{chordsHere.map((c, i) => (
onChordClick?.(c)}
className={`px-2.5 py-1 rounded-lg font-bold text-sm border transition-all outline-none focus-visible:ring-2 focus-visible:ring-accent ${
i === 0
? 'bg-accent border-accent text-white'
: 'bg-panel border-border text-gray-200 hover:border-accent/50 hover:text-accent'
}`}>
{c}
{i < chordsHere.length - 1 && → }
))}
{prog.description}
{prog.songs.length > 0 && (
{prog.songs.slice(0, 3).join(' · ')}
)}
)
})}
)
}
// ─── One KB progression card (the Explore browser hero) ──────────────────────
function KbProgressionCard({ prog, keyRootPc, onChordClick }) {
const degrees = prog?.degrees ?? []
const qualities = prog?.qualities ?? []
const chords = degrees.map((deg, i) => {
const pc = (((keyRootPc + deg) % 12) + 12) % 12
return `${NOTES[pc]}${CHORD_TYPES[qualities[i]]?.suffix ?? ''}`
})
const songs = Array.isArray(prog?.songs) ? prog.songs : []
return (
{prog?.name ?? prog?.id ?? 'Untitled'}
{Array.isArray(prog?.rn) && prog.rn.length > 0 && (
{prog.rn.join(' – ')}
)}
{chords.length > 0 && (
{chords.map((c, i) => (
onChordClick?.(c)}
title={`Open ${c} details`}
className="px-2.5 py-1 rounded-lg font-bold text-sm border bg-panel border-border text-gray-200 transition-all outline-none hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent">
{c}
{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 (
setStyleOverride(s.id)}
className={`px-2.5 py-1 min-h-[32px] rounded-lg text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent ${
active
? 'bg-accent/20 border border-accent text-accent font-semibold'
: 'border border-transparent text-gray-400 hover:text-gray-200 hover:border-border'
}`}>
{s.label}
)
})}
{/* Circle of fifths — live key map with inline diatonics (task D-61).
keyInfo here IS App's effectiveKey (App → KnowledgeDock → this section);
the circle is read-only — tapping wedges never touches key state. */}
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).
// `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('')
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)
const p = parseChord(chord)
if (p) { setRoot(NOTES[p.rootPc]); setTypeKey(p.type) }
}
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 (
setOpen(v => !v)} aria-expanded={open}
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all">
EXPLORE ANY CHORD
{open ? '▲' : '▼'}
{open && (
)}
)
}