import { useState, useMemo, useEffect } from 'react'
import kb from '../data/kb/index.js'
import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC } from '../lib/match'
import { NOTES, CHORD_TYPES } from '../lib/theory'
import GlanceRail, { AimDots, SoloLabel } from './GlanceRail'
import BassPatternCard from './BassPatternCard'
import VoicingBrowser from './VoicingBrowser'
import LickCard, { TechniqueLegend } from './LickCard'
import PianoLickCard from './PianoLickCard'
import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel'
import { pianoVoicingChain } from '../lib/piano'
import { parseChord } from '../lib/voicings'
// ─── JamGuide.jsx — the jam-grid OWNER + the Knowledge Center DOCK ───────────
//
// Task L-50 (per docs/design/one-screen.md §6) promoted the default export from
// "band" to the two-column jam dashboard grid:
//
// default export `JamGuide` — renders the xl: two-column flex region.
// LEFT (flex-1): the `mainView` slot (App keeps choosing Fretboard /
// BassFretboard / Piano — JamGuide never imports them), the LicksStrip,
// and the `relatedSlot` (RelatedProgressions, mounted by App — null until
// L-51). RIGHT (500px): the suggested-voicings rail — GlanceRail /
// BassGuideRows / the heard-live fallback — inside the design's ONE
// justified internal scroller (height-bounded, NOT sticky, §1). Below xl
// the columns stack in jam-following order via display:contents + order
// classes: mainView → rail → licks → related (§7; rail unbounds).
// The `fill` prop (jam view, §1.1) swaps the rail's viewport-calc bound
// for h-full and makes the left column a flex stack whose related slot
// absorbs the remainder. The loop itself renders ONCE, in
// ProgressionBanner (D-40 §2).
// named export `KnowledgeDock` — the bottom collapsible browse/study area:
// Explore / Voicings / Licks & Techniques + the shared level filter (the
// old dock minus its jam section, which IS the band now).
//
// ONE instrument selector: App's global GUITAR/PIANO/BASS state (App.jsx:58)
// flows into both as the `instrument` prop — the old internal instrument tabs
// and the style-override tabs are gone (D-40 §3; dock sections keep their own
// style chips for browsing).
// Knowledge Center sections (D-20 §1, minus 'jam' — L-40/D-40 §5).
const SECTIONS = [
{ id: 'explore', label: 'Explore' },
{ id: 'voicings', label: 'Voicings' },
{ id: 'licks', label: 'Licks & Techniques' },
]
// ─── Authored piano recipes → MiniPiano voicings (L-24) ───────────────────────
//
// A style may ship an authored piano pack (SCHEMA.md "Piano play"): per-chord
// degree recipes like { LH: ['3','5','7','9'] }. When the matched style has one
// for the matched progression, the piano tab prefers the FIRST play's recipes
// over the computed `pianoVoicingChain` — the recipes already encode the play's
// voice-leading choices per station, so they are NOT re-threaded. Styles without
// a piano pack (and any station whose recipe fails to resolve) fall back to the
// computed chain, per-station — malformed data must never crash the panel.
// Resolve a degree string ('3', 'b9', '13'…) to a pitch-class offset from the
// chord root, through the quality's intervals where the degree is quality-
// dependent ('3' → ♭3 for min7, '7' → the chord's actual 7th…). This mirrors
// `resolveDegree` in scripts/validate-kb.mjs — the KB contract's reference
// implementation — replicated here because src/ must not import from scripts/.
// Keep the two in sync by hand. Exported for the smoke drift guard
// (scripts/smoke.mjs §7 sweeps both copies against a pinned truth table —
// C-40 follow-up landed with L-40) and for BassPatternCard, which realizes
// SCHEMA.md bass-pattern degrees through the same contract (L-42; the import
// cycle JamGuide → BassPatternCard → JamGuide is benign — a hoisted function
// used only at render time).
export function resolveDegree(deg, quality) {
const iv = CHORD_TYPES[quality]?.intervals
if (!iv) return null
const fixed = { 1: 0, b9: 1, 9: 2, '#9': 3, 11: 5, '#11': 6, b5: 6, b13: 8, 13: 9, 6: 9, b3: 3, b7: 10 }
if (deg === '3') return iv.find(i => i === 3 || i === 4) ?? iv.find(i => i === 2 || i === 5) ?? null
if (deg === '5') return iv.find(i => i === 6 || i === 7 || i === 8) ?? null
if (deg === '7') return iv.find(i => i === 9 || i === 10 || i === 11) ?? null
return fixed[deg] ?? null
}
// Convert one authored recipe into the MiniPiano `voicing` shape
// ({notes, pcs, bass, label, rootPc}). Placement follows the documented recipe
// convention (jazz/piano.js header): the order inside each hand IS the voicing
// order, low → high — so each note lands strictly above the previous one, in the
// nearest octave; the RH stacks on above the LH's top note (lh below rh). The
// octave anchor puts the bass in the first octave of MiniPiano's absolute-note
// space ([0,36], 0 = C3) — the first octave where the whole voicing fits — so
// the stack sits centrally in the rendered window. Returns null on ANY problem
// (missing/malformed recipe, unresolvable degree, span past the window) so the
// caller can fall back to the computed voicing for that station.
function recipeVoicing(recipe, rootPc, quality) {
try {
if (!recipe || typeof recipe !== 'object') return null
// Semitone offsets above the chord root, stacked strictly ascending.
const rel = []
const handLabels = []
let prev = null
for (const hand of ['LH', 'RH']) {
const degs = recipe[hand]
if (degs === undefined) continue
if (!Array.isArray(degs) || degs.length === 0) return null
for (const d of degs) {
const off = resolveDegree(String(d), quality)
if (off === null || off === undefined) return null
if (prev === null) {
prev = off // the bass voice sits at its plain offset above the root
} else {
let step = (((off - prev) % 12) + 12) % 12
if (step === 0) step = 12 // same pitch class → the next octave up
prev += step
}
rel.push(prev)
}
handLabels.push(`${hand} ${degs.join('-')}`)
}
if (rel.length === 0) return null
// Anchor: bass pitch class in the first octave; everything stacks above it.
const bass = (((rootPc + rel[0]) % 12) + 12) % 12
const notes = rel.map(r => bass + (r - rel[0]))
if (notes[notes.length - 1] > 36) return null // doesn't fit the keyboard window
return {
notes,
pcs: [...new Set(notes.map(n => ((n % 12) + 12) % 12))],
bass,
style: 'authored',
label: handLabels.join(' · '), // honest per-chord degrees, e.g. "LH 3-5-7-9"
rootPc,
}
} catch {
return null
}
}
// ─── 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. Guitar licks are tab entries (LickCard);
// piano licks (D-70 §5.1) are the STRUCTURED entries only — those carrying a
// `notes` array PianoLickCard can realize — so prose-only piano education
// entries never render as placeholder cards. The dock's LicksSection reads
// guitar (its default arg), so its behaviour is unchanged.
function licksFor(id, instrument = 'guitar') {
if (instrument === 'piano') {
const l = kb?.[id]?.instruments?.piano?.licks
return Array.isArray(l) ? l.filter(x => Array.isArray(x?.notes)) : []
}
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))
}
// ─── JamGuide — the jam dashboard grid (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
// mainView : JSX slot — the compact instrument view (App-chosen; L-50)
// relatedSlot : JSX slot — RelatedProgressions (App-mounted; null until L-51)
// fill : boolean — jam view (one-screen.md §1.1): the grid fills
// App's h-screen column; rail bound becomes h-full
// 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 }
export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], currentChord, onFocusChord, instrument = 'guitar', mainView = null, relatedSlot = null, fill = false }) {
// 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 })),
[]
)
// Build the rotation-invariant loop index once.
const kbIndex = useMemo(() => buildLoopIndex(kb), [])
// Resolve the live loop → KB progression + rotation, and the current station.
// Keyed on the loop input so this only recomputes when the loop changes.
const loopKey = detectedProgression ? detectedProgression.join(',') : ''
const match = useMemo(
() => matchLoopToProgression(detectedProgression, kbIndex),
[loopKey, kbIndex] // eslint-disable-line react-hooks/exhaustive-deps
)
const position = useMemo(
() => findLoopPosition(chordHistory, detectedProgression),
[chordHistory, loopKey] // eslint-disable-line react-hooks/exhaustive-deps
)
// 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
// 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"). 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])
// ── Playhead reconciliation ────────────────────────────────────────────────
// `position` from findLoopPosition is an index into the *detected* loop, which
// 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:
// canonicalPos = ((position − rotation) mod n + n) mod n
// Worked example — KB blues-turnaround [I VI ii V] looped as [ii V I VI]:
// 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
if (!n || typeof position !== 'number' || position < 0) return -1
return (((position - match.rotation) % n) + n) % n
}, [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 ────────────────────────────────────────────────────
// Stations are canonical KB order — index i aligns 1:1 with
// 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);
// its `chords` array is canonical order too. No shape → graceful gap.
// piano → `voicing`: an AUTHORED pack's recipes when the matched style ships
// piano plays for this progression (L-24 — first play, converted via
// recipeVoicing; the recipes carry their own voice-leading, so no
// re-threading); otherwise COMPUTED via pianoVoicingChain over the
// whole loop in canonical order, so each station's register threads
// from the previous one (minimal movement between stations). A
// 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): authored bass patterns flow
// through the separate `bassPlays` memo below into BassGuideRows
// (L-42) — the station entries carry identity only, exactly as in
// the pre-pack honest state (L-40 step 3, D-40 §3).
const stationVoicings = useMemo(() => {
if (!match.matched) return []
const prog = match.progression
const degrees = prog?.degrees ?? []
const qualities = prog?.qualities ?? []
const stations = degrees.map((deg, i) => {
const rootPc = (((keyRoot + deg) % 12) + 12) % 12
const noteName = NOTES[rootPc]
const suffix = CHORD_TYPES[qualities[i]]?.suffix ?? ''
return {
shape: null,
voicing: null,
rootPc,
quality: qualities[i] ?? 'maj',
label: `${noteName}${suffix}`,
rn: prog?.rn?.[i] ?? '',
// Fix (a) remap (jam-roulette.md §3.3.2): the RAW authored-play index this
// (possibly collapsed) station reads from. Raw matches have no sourceIndex
// → identity (i); collapsed matches carry the projection's map.
sourceIndex: prog?.sourceIndex?.[i] ?? i,
}
})
if (instrument === 'guitar') {
const plays = kb[match.style]?.instruments?.guitar?.plays?.[prog?.id]
const play = Array.isArray(plays) ? plays[0] : null
const chords = play?.chords ?? []
for (let i = 0; i < stations.length; i++) {
stations[i].shape = chords[stations[i].sourceIndex ?? i]?.shape ?? null
}
} 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]
const play = Array.isArray(pianoPlays) && pianoPlays.length ? pianoPlays[0] : null
const authored = play
? stations.map((st, i) => recipeVoicing(play.chords?.[st.sourceIndex ?? i]?.recipe, st.rootPc, st.quality))
: null
// Computed fallback — only built when needed (no pack, or a recipe that
// failed to resolve). Identical to the pre-L-24 computed path.
const chain = (!authored || authored.some(v => !v))
? pianoVoicingChain(stations.map(({ rootPc, quality }) => ({ rootPc, quality })))
: null
for (let i = 0; i < stations.length; i++) {
stations[i].voicing = authored?.[i]
?? (chain?.[i] ? { ...chain[i], rootPc: stations[i].rootPc } : null)
}
}
return stations
}, [match.matched, match.progression, match.style, instrument, keyRoot])
// ── Authored bass plays (L-42) ──────────────────────────────────────────────
// When the matched style ships a bass pack with plays for this progression,
// BassGuideRows renders each play's per-station pattern card in the gallery
// slot (all plays side by side, the D-30 gallery idiom); null keeps the
// computed root·fifth·approach fallback — styles without a bass cell and the
// heard-live path are unchanged.
const bassPlays = useMemo(() => {
if (!match.matched || instrument !== 'bass') return null
const plays = kb[match.style]?.instruments?.bass?.plays?.[match.progression?.id]
return Array.isArray(plays) && plays.length > 0 ? plays : null
}, [match.matched, match.style, match.progression, instrument])
// ── Focused station (D-41 — the L-33 pin, simplified per D-40 §4: with every
// row always expanded there is nothing to hold open, so the gesture collapses
// to a focus TOGGLE on the row header). Same JamGuide-owned state, same reset
// effect, same onFocusChord emission as pinnedStation before it. ──
const [focusedStation, setFocusedStation] = useState(null)
// Reset the focus whenever the loop or style changes underneath us.
useEffect(() => { setFocusedStation(null) }, [match.id, match.style, instrument])
// ── Cross-link to the main Fretboard (D-03) ─────────────────────────────────
// When a station is FOCUSED, report its {rootPc, quality} upward so the
// Fretboard can light that chord's guide tones; clear (null) on unfocus. The
// reset effect above sets focusedStation → null on loop/style/instrument
// change, which flows through here and clears the highlight too. Guarded so
// the component still works standalone (onFocusChord optional).
// The playhead (auto-follow highlight) 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 focus gesture reaches this effect.
useEffect(() => {
if (!onFocusChord) return
const st = focusedStation != null ? stationVoicings[focusedStation] : null
onFocusChord(st ? { rootPc: st.rootPc, quality: st.quality } : null)
}, [focusedStation, stationVoicings, onFocusChord])
// Clear the Fretboard highlight when JamGuide unmounts.
useEffect(() => () => { onFocusChord?.(null) }, [onFocusChord])
// Licks-strip context = the PLAYHEAD station (canonicalPos −1 → station 0).
// The focus toggle aims the fretboard, not the strip — the strip keeps
// re-sorting with the jam (D-31 §2.4).
const contextStation = stationVoicings[canonicalPos >= 0 ? canonicalPos : 0] ?? null
// ── The rail (right column at xl / second block stacked): the suggested-
// voicings surface — GlanceRail, BassGuideRows, the heard-live gallery, or
// the honest idle line (one-screen.md §3, §4). ──
const railContent = match.matched ? (
instrument === 'bass' ? (
/* Bass rows (D-40 §3): authored pattern cards when the matched style
ships a bass pack (L-42), computed roots/fifths/approaches as the
honest fallback otherwise. The licks strip hides either way
(guitar tab licks are noise to a bassist mid-jam). */
{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.'}
Heard live · {currentChord} — every voicing
{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.'}
) // ── The licks strip (left column). It now FOLLOWS the global instrument // (D-70 §5.1): guitar → tab LickCards, piano → PianoLickCards realized over // the playhead root, bass → an honest "no bass licks" line. Matched loops // sort by the playhead station; heard-live falls back to the live chord's // quality key (e.g. a "dom7" lick fits a live G7). The live-chord context // carries rootPc so PianoLickCard can realize its degrees. LicksStrip itself // decides the empty behaviour per instrument (guitar hides; piano/bass show a // slim honest line). ── const licksContext = match.matched ? contextStation : liveChord ? { rn: '', quality: liveChord.type, label: currentChord, rootPc: liveChord.rootPc } : null const licksStrip = (match.matched || liveChord) ? (root {NOTES[st.rootPc]} · fifth {NOTES[fifthPc]} {next && ( <> · approach{' '} {NOTES[approachPc]} → {NOTES[next.rootPc]} > )}
)}{play?.label} {play?.feel ? <> — {play.feel}> : null} {play?.position ? <> · {play.position}> : null}
))} {/* Amber + mic microcopy — once for the whole rail (D-31 §2.5). */}Amber note = the approach into the next chord. ▶ previews play through your speakers — while the mic is live, detection may hear them. Nothing plays automatically.
> ) : ( /* 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.
)}Guitar licks · tab reads high e on top · amber marks = techniques (legend below)
{visible.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.
)}{children}
) } function LicksStrip({ styleId, levels, instrument, context }) { // Inline enlarge (one card at a time); reset when the style/instrument changes. const [expandedId, setExpandedId] = useState(null) useEffect(() => { setExpandedId(null) }, [styleId, instrument]) const styleLabel = kb?.[styleId]?.meta?.label ?? styleId // Bass: no strip licks in the KB — honest line, never a vanished section. if (instrument === 'bass') { returnfits {fitLabel} — now
)}