From 9415daf4a012d6e0cbc114498d74bbe0232e19ba Mon Sep 17 00:00:00 2001 From: vadimwit Date: Mon, 13 Jul 2026 19:01:00 +0100 Subject: [PATCH] =?UTF-8?q?feat(rail):=20hybrid=20voicings=20rail=20?= =?UTF-8?q?=E2=80=94=20highlight=20the=20loop=20+=20other=20recent=20chord?= =?UTF-8?q?s=20underneath=20(task=20L-77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/GlanceRail.jsx | 101 ++++++++++---- src/components/JamGuide.jsx | 241 +++++++++++++++++++++++++++------- 2 files changed, 270 insertions(+), 72 deletions(-) diff --git a/src/components/GlanceRail.jsx b/src/components/GlanceRail.jsx index a5ba4bc..85d872a 100644 --- a/src/components/GlanceRail.jsx +++ b/src/components/GlanceRail.jsx @@ -48,16 +48,31 @@ // 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. // +// 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: // stations — [{ shape, voicing, rootPc, quality, label, rn }] canonical order // 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) // onFocus — fn(index|null): toggle a station's focus // instrument — 'guitar' | 'piano' (VoicingBrowser `show`; bass never mounts // this rail — JamGuide renders BassGuideRows instead, D-40 §3) // keyRoot — key tonic pitch class 0–11 (ChordDiagram fret placement) // 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 VoicingBrowser from './VoicingBrowser' @@ -164,23 +179,38 @@ function StationRow({ style={{ opacity: isNow || isFocused ? 1 : 0.85 }} > {/* ── 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. */}
- + {onToggleFocus ? ( + + ) : ( +
+ {st.label} + {st.rn && ( + + {st.rn} + + )} +
+ )} {isNow && ( now @@ -222,17 +252,21 @@ function StationRow({ export default function GlanceRail({ stations = [], activeIndex = -1, focusedIndex = null, onFocus, instrument, keyRoot, keyMode, + showTransitions = true, }) { const n = stations.length 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 // 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 - // 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) => { - if (n < 2) return null + if (!showTransitions || n < 2) return null const next = stations[(i + 1) % n] return voiceLeadingPairs( { root: st.rootPc, quality: st.quality }, @@ -240,13 +274,30 @@ export default function GlanceRail({ )[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 (

- Variations · every chord, every voicing — the playhead highlights + {sectionTitle}

@@ -257,7 +308,7 @@ export default function GlanceRail({ isNow={i === activeIndex} isNext={i === nextIndex} isFocused={focusedIndex === i} - onToggleFocus={() => onFocus?.(focusedIndex === i ? null : i)} + onToggleFocus={focusable ? (() => onFocus(focusedIndex === i ? null : i)) : null} instrument={instrument} keyRoot={keyRoot} keyMode={keyMode} @@ -268,9 +319,9 @@ export default function GlanceRail({
{/* 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. */}

- Voicings follow the loop — the playhead highlights the chord you're on. + {sectionFoot}

) diff --git a/src/components/JamGuide.jsx b/src/components/JamGuide.jsx index 03f8dad..251598b 100644 --- a/src/components/JamGuide.jsx +++ b/src/components/JamGuide.jsx @@ -4,7 +4,6 @@ import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC } 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' @@ -165,6 +164,92 @@ function lickFitsContext(lick, context) { 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 ( +

+ {children} +

+ ) +} + // ─── JamGuide — the jam dashboard grid (default export) ─────────────────────── // // Props: @@ -324,6 +409,22 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = return stations }, [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) ────────────────────────────────────────────── // 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 @@ -367,62 +468,108 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = // 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). ── + // ── The rail (right column at xl / second block stacked) — HYBRID (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 … 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 ? ( 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). */ - +
+
+ the loop + {/* 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. */} + +
+ {hasHistory && ( +
+ also played · newest first + {/* `live` = no approach line (history is not a canonical loop). */} + +
+ )} +
) : ( - /* The voicing rail — ALL stations expanded as vertical rows; the - playhead only highlights (D-41, D-40 §4). */ - +
+
+ the loop + {/* The voicing rail — ALL loop stations expanded as vertical rows; the + playhead only highlights (D-41, D-40 §4). Untouched. */} + +
+ {hasHistory && ( +
+ also played · newest first + {/* History group: most-recent-first, no transition chips, no "now". */} + +
+ )} +
) - ) : 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. */ + ) : hasHistory ? ( + /* No loop matched, but chords have been played: the "also played" group IS + the rail — recent distinct chords, most-recent-first, ≥4 when available. + This replaces the old single-chord heard-live fallback (D-76 §0). Bass: + D-40 §3 forbids guitar/piano galleries under BASS, so BassGuideRows draws + the computed root/fifth line per chord (`live` → no approach). */ 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.'} -

- -
+ ) ) : ( /* Nothing heard yet — one slim line (~40px): the idle rail must not