feat(rail): hybrid voicings rail — highlight the loop + other recent chords underneath (task L-77)

The suggested-voicings rail no longer restricts to loop chords or collapses
to a single heard-live chord. It now shows TWO groups: the LOOP group (the
canonical GlanceRail, byte-unchanged — KB order, moving "now" playhead,
voice-leading chips) under a "the loop" caption, and an "also played" group
of the other recently-played DISTINCT chords (most-recent-first, each
expanded to its full voicing gallery, no chips / no playhead). With no loop
the "also played" group IS the rail, replacing the old single-chord
fallback. At least 4 chords show as soon as history exists (RAIL_TOTAL_CAP
top-up; only chords actually played, never fabricated).

GlanceRail gains an optional showTransitions prop (default true = the loop
caller is byte-unchanged); the history group passes false, which suppresses
the voice-leading chips + "next" tag AND switches the section framing off
the loop/playhead language, and renders non-focusable static row headers
(no inert focus button / misleading tooltip on history rows). App.jsx
untouched — chordHistory already flowed in. Verified build + validate-KB +
smoke 903/903 green.

User directive 2026-07-13: "highlight the loop chords when it finds a loop
but also add the other chords underneath ... at least 4 or more."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-13 19:01:00 +01:00
parent 7561566ceb
commit 9415daf4a0
2 changed files with 270 additions and 72 deletions
+76 -25
View File
@@ -48,16 +48,31 @@
// page mid-jam. The L-33 auto-centre effect was deleted in L-40 and must never // page mid-jam. The L-33 auto-centre effect was deleted in L-40 and must never
// return; the highlight travels, the user owns the scrollbar. // return; the highlight travels, the user owns the scrollbar.
// //
// HYBRID rail reuse (task L-77, refines D-76; user directive 2026-07-13 — "highlight
// the loop chords when it finds a loop but also add the other chords underneath"):
// JamGuide now renders GlanceRail TWICE — once for the canonical LOOP group (the
// original call, byte-unchanged) and once for the "also played" recent-history
// group. The history group passes `showTransitions={false}` (see the prop below)
// because history order is NOT canonical: the between-adjacent voice-leading chips
// and the "next" tag are only true for the loop's canonical wheel, so they are
// suppressed for the history rail. Everything else (per-row gallery, "now" via
// activeIndex, the SoloLabel/AimDots guide-tone education) is correct for any chord
// and stays. Default (`showTransitions` absent) is byte-compatible with the loop.
//
// Pure presentational. Props: // Pure presentational. Props:
// stations — [{ shape, voicing, rootPc, quality, label, rn }] canonical order // stations — [{ shape, voicing, rootPc, quality, label, rn }] canonical order
// activeIndex — playhead station (canonicalPos); -1 = loop known, playhead // activeIndex — playhead station (canonicalPos); -1 = loop known, playhead
// not — no row is marked "now" (content never changes either way) // not — no row is marked "now" (content never changes either way).
// The history group passes -1 (no playhead — see JamGuide).
// focusedIndex — the focused station index, or null (nothing focused) // focusedIndex — the focused station index, or null (nothing focused)
// onFocus — fn(index|null): toggle a station's focus // onFocus — fn(index|null): toggle a station's focus
// instrument — 'guitar' | 'piano' (VoicingBrowser `show`; bass never mounts // instrument — 'guitar' | 'piano' (VoicingBrowser `show`; bass never mounts
// this rail — JamGuide renders BassGuideRows instead, D-40 §3) // this rail — JamGuide renders BassGuideRows instead, D-40 §3)
// keyRoot — key tonic pitch class 011 (ChordDiagram fret placement) // keyRoot — key tonic pitch class 011 (ChordDiagram fret placement)
// keyMode — key mode name (soloScale's minor-key dominant nudge) // keyMode — key mode name (soloScale's minor-key dominant nudge)
// showTransitions — default true (the loop caller is unchanged). false → the
// voice-leading TransitionChips and the "next" tag are suppressed
// (history order is not canonically adjacent, task L-77).
import { NOTES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory' import { NOTES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory'
import VoicingBrowser from './VoicingBrowser' import VoicingBrowser from './VoicingBrowser'
@@ -164,23 +179,38 @@ function StationRow({
style={{ opacity: isNow || isFocused ? 1 : 0.85 }} style={{ opacity: isNow || isFocused ? 1 : 0.85 }}
> >
{/* ── Header line: identity + the folded roadmap education ── */} {/* ── Header line: identity + the folded roadmap education ── */}
{/* Loop rows (onToggleFocus provided) keep the focus-toggle button — the
D-03 fretboard guide-tone contract, byte-unchanged. History rows pass
no toggle → a plain, non-interactive identity (no inert button /
misleading "focus" tooltip / stray focus ring), L-77. */}
<div className="mb-1.5 flex flex-wrap items-center gap-x-3 gap-y-1"> <div className="mb-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
<button {onToggleFocus ? (
type="button" <button
aria-pressed={isFocused} type="button"
onClick={onToggleFocus} aria-pressed={isFocused}
title={isFocused onClick={onToggleFocus}
? `Unfocus ${st.label} — clear its guide tones from the fretboard` title={isFocused
: `Focus ${st.label}light its guide tones on the fretboard`} ? `Unfocus ${st.label}clear its guide tones from the fretboard`
className="flex min-h-[32px] items-center gap-2 rounded px-1 outline-none focus-visible:ring-2 focus-visible:ring-accent" : `Focus ${st.label} — light its guide tones on the fretboard`}
> className="flex min-h-[32px] items-center gap-2 rounded px-1 outline-none focus-visible:ring-2 focus-visible:ring-accent"
<span className="text-sm font-bold leading-none text-gray-100">{st.label}</span> >
{st.rn && ( <span className="text-sm font-bold leading-none text-gray-100">{st.label}</span>
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400"> {st.rn && (
{st.rn} <span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
</span> {st.rn}
)} </span>
</button> )}
</button>
) : (
<div className="flex min-h-[32px] items-center gap-2 px-1">
<span className="text-sm font-bold leading-none text-gray-100">{st.label}</span>
{st.rn && (
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
{st.rn}
</span>
)}
</div>
)}
{isNow && ( {isNow && (
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent"> <span className="text-[9px] font-semibold uppercase tracking-widest text-accent">
now now
@@ -222,17 +252,21 @@ function StationRow({
export default function GlanceRail({ export default function GlanceRail({
stations = [], activeIndex = -1, focusedIndex = null, onFocus, instrument, keyRoot, keyMode, stations = [], activeIndex = -1, focusedIndex = null, onFocus, instrument, keyRoot, keyMode,
showTransitions = true,
}) { }) {
const n = stations.length const n = stations.length
if (n === 0) return null if (n === 0) return null
const nextIndex = activeIndex >= 0 && n > 1 ? (activeIndex + 1) % n : -1 // The "next" tag is a loop-adjacency claim → suppressed for the history group.
const nextIndex = showTransitions && activeIndex >= 0 && n > 1 ? (activeIndex + 1) % n : -1
// Voice-leading rails: rail i leaves station i for station (i+1) mod n — the // Voice-leading rails: rail i leaves station i for station (i+1) mod n — the
// last rail wraps back to station 0 (the loop is a wheel). The headline rail // last rail wraps back to station 0 (the loop is a wheel). The headline rail
// is the 7→3 (voiceLeadingPairs lists the 7th first); a one-chord loop has // is the 7→3 (voiceLeadingPairs lists the 7th first); a one-chord loop has
// no transition to speak of. // no transition to speak of. Suppressed entirely for the history group
// (showTransitions=false) — its rows are recent-first, not canonically
// adjacent, so a "next F→E" chip would point at the wrong neighbour (L-77).
const rails = stations.map((st, i) => { const rails = stations.map((st, i) => {
if (n < 2) return null if (!showTransitions || n < 2) return null
const next = stations[(i + 1) % n] const next = stations[(i + 1) % n]
return voiceLeadingPairs( return voiceLeadingPairs(
{ root: st.rootPc, quality: st.quality }, { root: st.rootPc, quality: st.quality },
@@ -240,13 +274,30 @@ export default function GlanceRail({
)[0] ?? null )[0] ?? null
}) })
// Section framing (L-77 honesty fix): showTransitions === true ⟺ the canonical
// LOOP group; the "also played" history group (showTransitions=false) is recent-
// first with no playhead, so it must NOT claim "the loop" / "the playhead". Rows
// in the history group are also non-focusable (no onFocus → no inert header
// button); the loop group's function keeps its focus toggle byte-unchanged.
const isLoop = showTransitions
const focusable = typeof onFocus === 'function'
const sectionAria = isLoop
? 'Voicing variations — every chord of the loop, all expanded'
: 'Voicing variations — every recently played chord, all expanded'
const sectionTitle = isLoop
? 'Variations · every chord, every voicing — the playhead highlights'
: 'Variations · every chord, every voicing'
const sectionFoot = isLoop
? "Voicings follow the loop — the playhead highlights the chord you're on."
: 'Recent chords — newest first; every voicing of each.'
return ( return (
<section <section
className="rounded-2xl border border-border bg-panel p-2" className="rounded-2xl border border-border bg-panel p-2"
aria-label="Voicing variations — every chord of the loop, all expanded" aria-label={sectionAria}
> >
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500"> <h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
Variations · every chord, every voicing the playhead highlights {sectionTitle}
</h4> </h4>
<div className="flex flex-col gap-2" role="list"> <div className="flex flex-col gap-2" role="list">
@@ -257,7 +308,7 @@ export default function GlanceRail({
isNow={i === activeIndex} isNow={i === activeIndex}
isNext={i === nextIndex} isNext={i === nextIndex}
isFocused={focusedIndex === i} isFocused={focusedIndex === i}
onToggleFocus={() => onFocus?.(focusedIndex === i ? null : i)} onToggleFocus={focusable ? (() => onFocus(focusedIndex === i ? null : i)) : null}
instrument={instrument} instrument={instrument}
keyRoot={keyRoot} keyRoot={keyRoot}
keyMode={keyMode} keyMode={keyMode}
@@ -268,9 +319,9 @@ export default function GlanceRail({
</div> </div>
{/* No ▶ anywhere anymore (dashboard-polish.md §3 — "leave them off, {/* No ▶ anywhere anymore (dashboard-polish.md §3 — "leave them off,
better not"); the rail is purely visual and follows the loop. */} better not"); the rail is purely visual. */}
<p className="mt-2 text-[11px] text-gray-500"> <p className="mt-2 text-[11px] text-gray-500">
Voicings follow the loop the playhead highlights the chord you're on. {sectionFoot}
</p> </p>
</section> </section>
) )
+194 -47
View File
@@ -4,7 +4,6 @@ import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC }
import { NOTES, CHORD_TYPES } from '../lib/theory' import { NOTES, CHORD_TYPES } from '../lib/theory'
import GlanceRail, { AimDots, SoloLabel } from './GlanceRail' import GlanceRail, { AimDots, SoloLabel } from './GlanceRail'
import BassPatternCard from './BassPatternCard' import BassPatternCard from './BassPatternCard'
import VoicingBrowser from './VoicingBrowser'
import LickCard, { TechniqueLegend } from './LickCard' import LickCard, { TechniqueLegend } from './LickCard'
import PianoLickCard from './PianoLickCard' import PianoLickCard from './PianoLickCard'
import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel' import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel'
@@ -165,6 +164,92 @@ function lickFitsContext(lick, context) {
return wanted.length > 0 && tokens.some(t => wanted.includes(t)) return wanted.length > 0 && tokens.some(t => wanted.includes(t))
} }
// ─── "Also played" history rail helpers (task L-77, refines D-76) ─────────────
//
// The HYBRID voicings rail (user directive 2026-07-13 — "highlight the loop
// chords when it finds a loop but also add the other chords underneath … at
// least 4 or more"): a loop group (canonical GlanceRail, untouched) PLUS an
// "also played" group of the other recently-played distinct chords, most-recent-
// first, and — when no loop is found — just the history group. All parsing reuses
// the established app idiom (chordRootPC + the CHORD_TYPES suffix inversion,
// mirroring TryThis.jsx `parseChordName` / RelatedProgressions) — no theory
// re-derivation.
// Rail size policy. Overall cap across BOTH groups so the column never runs
// away; the no-loop history rail caps a little lower. The ≥4 guarantee falls out
// of RAIL_TOTAL_CAP loopLen ≥ 4 loopLen for any loopLen ≤ RAIL_TOTAL_CAP:
// the history top-up is always allowed to reach four total when four distinct
// chords exist (it never fabricates — it shows only what was actually played).
const RAIL_TOTAL_CAP = 8 // loop group + "also played" group combined
const NO_LOOP_HISTORY_CAP = 6 // no loop matched → history rail alone
// Invert CHORD_TYPES suffix → quality (the app idiom — mirrors TryThis.jsx /
// RelatedProgressions; all 14 suffixes are unique).
const SUFFIX_TO_QUALITY = Object.fromEntries(
Object.entries(CHORD_TYPES).map(([quality, def]) => [def.suffix, quality])
)
// Parse a chord-name string → { rootPc, quality } via the shared helpers. Returns
// null when unparseable (unknown suffix / bad root) so the caller drops it.
function parseHistoryChord(name) {
if (typeof name !== 'string') return null
const rootPc = chordRootPC(name)
if (rootPc < 0) return null
const m = name.match(/^[A-G][b#]?(.*)$/)
const quality = m ? SUFFIX_TO_QUALITY[m[1]] : undefined
if (!quality) return null
return { rootPc, quality }
}
// recentDistinctChords(chordHistory, cap, excludeNames) → GlanceRail-shaped
// station rows for the "also played" group. Walks chordHistory from the NEWEST
// end backward, collecting DISTINCT chord NAMES (first-seen-from-newest wins —
// the most-recent occurrence fixes each chord's slot, so a "F Am F Am" ping-pong
// yields [Am, F], the different chords each once). Names in `excludeNames` (the
// loop group's chords) are skipped so the two groups never duplicate a chord.
// Unparseable names are dropped. Returns MOST-RECENT-FIRST, capped. Empty /
// undefined history → []. Stations carry identity only (shape/voicing null) — an
// arbitrary played chord has no authored KB play, exactly the null `recommended`
// GlanceRail already renders gracefully.
function recentDistinctChords(chordHistory, cap, excludeNames) {
if (!Array.isArray(chordHistory) || cap <= 0) return []
const exclude = excludeNames instanceof Set ? excludeNames : new Set(excludeNames ?? [])
const seen = new Set()
const out = []
for (let i = chordHistory.length - 1; i >= 0; i--) {
const name = chordHistory[i]
if (seen.has(name)) continue
seen.add(name)
if (exclude.has(name)) continue
const parsed = parseHistoryChord(name)
if (!parsed) continue
out.push({
shape: null,
voicing: null,
rootPc: parsed.rootPc,
quality: parsed.quality,
label: name,
rn: '', // history is key-relative-agnostic here; rn stays empty (cheap, honest)
})
if (out.length >= cap) break
}
return out
}
// Small group caption above each rail group (tokens only — no raw hex).
function RailGroupCaption({ children, tone = 'loop' }) {
return (
<p
className={
'mb-1 px-1 text-[9px] font-semibold uppercase tracking-widest ' +
(tone === 'loop' ? 'text-accent/80' : 'text-gray-500')
}
>
{children}
</p>
)
}
// ─── JamGuide — the jam dashboard grid (default export) ─────────────────────── // ─── JamGuide — the jam dashboard grid (default export) ───────────────────────
// //
// Props: // Props:
@@ -324,6 +409,22 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
return stations return stations
}, [match.matched, match.progression, match.style, instrument, keyRoot]) }, [match.matched, match.progression, match.style, instrument, keyRoot])
// ── "Also played" history stations (task L-77, refines D-76) ────────────────
// The other recently-played DISTINCT chords, most-recent-first, that are NOT in
// the loop group. When a loop is matched the cap tops the two groups up toward
// RAIL_TOTAL_CAP; with no loop the history rail stands alone (NO_LOOP_HISTORY_
// CAP). Keyed on chordHistory (+ the loop via stationVoicings) so it recomputes
// as chords commit. Loop chords are excluded by their rendered label so the two
// groups never repeat a chord. instrument-agnostic identity — GlanceRail /
// BassGuideRows draw the per-chord gallery from {rootPc, quality}.
const historyStations = useMemo(() => {
const loopNames = match.matched ? new Set(stationVoicings.map(s => s.label)) : null
const cap = match.matched
? Math.max(RAIL_TOTAL_CAP - stationVoicings.length, 0)
: NO_LOOP_HISTORY_CAP
return recentDistinctChords(chordHistory, cap, loopNames)
}, [match.matched, stationVoicings, chordHistory])
// ── Authored bass plays (L-42) ────────────────────────────────────────────── // ── Authored bass plays (L-42) ──────────────────────────────────────────────
// When the matched style ships a bass pack with plays for this progression, // 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 // BassGuideRows renders each play's per-station pattern card in the gallery
@@ -367,62 +468,108 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
// re-sorting with the jam (D-31 §2.4). // re-sorting with the jam (D-31 §2.4).
const contextStation = stationVoicings[canonicalPos >= 0 ? canonicalPos : 0] ?? null const contextStation = stationVoicings[canonicalPos >= 0 ? canonicalPos : 0] ?? null
// ── The rail (right column at xl / second block stacked): the suggested- // ── The rail (right column at xl / second block stacked) — HYBRID (task L-77,
// voicings surface — GlanceRail, BassGuideRows, the heard-live gallery, or // refines D-76; user directive 2026-07-13: "highlight the loop chords when it
// the honest idle line (one-screen.md §3, §4). ── // finds a loop but also add the other chords underneath … at least 4 or more").
// TWO groups, so multiple chords' voicings are ALWAYS visible (the old single-
// chord heard-live fallback is retired):
// A) LOOP group (only when a loop matches) — the canonical GlanceRail /
// BassGuideRows, byte-unchanged: KB order, moving "now" playhead, valid
// between-adjacent voice-leading chips. A subtle "the loop" caption marks
// it as THE loop.
// B) "ALSO PLAYED" group — the other recent DISTINCT chords (historyStations),
// most-recent-first, each expanded to its full voicing gallery. NO voice-
// leading chips (showTransitions=false — history order is not canonically
// adjacent) and NO "now" badge (activeIndex=-1). With no loop this group
// stands alone and IS the rail. The ≥4-total guarantee comes from the
// RAIL_TOTAL_CAP top-up in `historyStations` (it shows only chords actually
// played — never fabricates).
// Empty history + no loop → the slim idle line (unchanged). Bass mirrors the
// hybrid via BassGuideRows (`live` on the history group suppresses approach —
// history is not a loop). ──
const hasHistory = historyStations.length > 0
const railContent = match.matched ? ( const railContent = match.matched ? (
instrument === 'bass' ? ( instrument === 'bass' ? (
/* Bass rows (D-40 §3): authored pattern cards when the matched style <div className="flex flex-col gap-3">
ships a bass pack (L-42), computed roots/fifths/approaches as the <div>
honest fallback otherwise. The licks strip hides either way <RailGroupCaption>the loop</RailGroupCaption>
(guitar tab licks are noise to a bassist mid-jam). */ {/* Bass rows (D-40 §3): authored pattern cards when the matched style
<BassGuideRows ships a bass pack (L-42), computed roots/fifths/approaches as the
stations={stationVoicings} honest fallback otherwise. */}
activeIndex={canonicalPos} <BassGuideRows
keyMode={keyInfo?.mode} stations={stationVoicings}
plays={bassPlays} activeIndex={canonicalPos}
/> keyMode={keyInfo?.mode}
plays={bassPlays}
/>
</div>
{hasHistory && (
<div>
<RailGroupCaption tone="history">also played · newest first</RailGroupCaption>
{/* `live` = no approach line (history is not a canonical loop). */}
<BassGuideRows
stations={historyStations}
activeIndex={-1}
keyMode={keyInfo?.mode}
live
/>
</div>
)}
</div>
) : ( ) : (
/* The voicing rail — ALL stations expanded as vertical rows; the <div className="flex flex-col gap-3">
playhead only highlights (D-41, D-40 §4). */ <div>
<GlanceRail <RailGroupCaption>the loop</RailGroupCaption>
stations={stationVoicings} {/* The voicing rail — ALL loop stations expanded as vertical rows; the
activeIndex={canonicalPos} playhead only highlights (D-41, D-40 §4). Untouched. */}
focusedIndex={focusedStation} <GlanceRail
onFocus={setFocusedStation} stations={stationVoicings}
instrument={instrument} activeIndex={canonicalPos}
keyRoot={keyRoot} focusedIndex={focusedStation}
keyMode={keyInfo?.mode} onFocus={setFocusedStation}
/> instrument={instrument}
keyRoot={keyRoot}
keyMode={keyInfo?.mode}
/>
</div>
{hasHistory && (
<div>
<RailGroupCaption tone="history">also played · newest first</RailGroupCaption>
{/* History group: most-recent-first, no transition chips, no "now". */}
<GlanceRail
stations={historyStations}
activeIndex={-1}
instrument={instrument}
keyRoot={keyRoot}
keyMode={keyInfo?.mode}
showTransitions={false}
/>
</div>
)}
</div>
) )
) : liveChord ? ( ) : hasHistory ? (
/* No loop matched, but chords are committing (D-31 §2.3): a single /* No loop matched, but chords have been played: the "also played" group IS
"heard live" gallery, re-aimed on every chord commit. Auto-follow the rail — recent distinct chords, most-recent-first, ≥4 when available.
only — nothing plays by itself. Bass: D-40 §3's prose forbids guitar/ This replaces the old single-chord heard-live fallback (D-76 §0). Bass:
piano galleries under BASS, so the live chord gets the same computed D-40 §3 forbids guitar/piano galleries under BASS, so BassGuideRows draws
root/fifth line (no next chord → no approach) instead. */ the computed root/fifth line per chord (`live` → no approach). */
instrument === 'bass' ? ( instrument === 'bass' ? (
<BassGuideRows <BassGuideRows
stations={[{ rootPc: liveChord.rootPc, quality: liveChord.type, label: currentChord, rn: '' }]} stations={historyStations}
activeIndex={0} activeIndex={-1}
keyMode={keyInfo?.mode} keyMode={keyInfo?.mode}
live live
/> />
) : ( ) : (
<section <GlanceRail
className="rounded-2xl border border-border bg-panel p-3" stations={historyStations}
aria-label={`Heard live — every ${instrument} voicing of ${currentChord}`} activeIndex={-1}
> instrument={instrument}
<h4 className="mb-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500"> keyRoot={keyRoot}
Heard live · {currentChord} every voicing keyMode={keyInfo?.mode}
</h4> showTransitions={false}
<p className="mb-2 text-[11px] text-gray-500"> />
{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.'}
</p>
<VoicingBrowser rootPc={liveChord.rootPc} quality={liveChord.type} show={instrument} dense />
</section>
) )
) : ( ) : (
/* Nothing heard yet — one slim line (~40px): the idle rail must not /* Nothing heard yet — one slim line (~40px): the idle rail must not