From 677d7b99d106ccc187401f7e18ae8ac0cdb813f7 Mon Sep 17 00:00:00 2001 From: vadimwit Date: Fri, 10 Jul 2026 19:03:48 +0100 Subject: [PATCH] feat(rail): all stations expanded, playhead highlights only (task D-41) Every loop chord is a permanently expanded vertical row: education header (solo-scale label, guide-tone aim dots with honest 5th fallback, voice-leading chip incl. the wrap-around loop rail) + the full voicing gallery for the global instrument; galleries flex-wrap, zero row-level horizontal scroll; playhead adds highlight classes only (rows proven byte-equal modulo highlight artifacts); pin simplified to a focus toggle (onFocusChord contract byte-compatible with L-40). VoicingBrowser: show='bass' now renders an honest one-liner instead of falling through to both galleries; dense drops section chrome (non-dense proven byte-identical across 336 SSR combos). Critic PASS. Co-Authored-By: Claude Fable 5 --- src/components/GlanceRail.jsx | 412 ++++++++++++++++++------------ src/components/JamGuide.jsx | 95 ++++--- src/components/VoicingBrowser.jsx | 73 ++++-- 3 files changed, 353 insertions(+), 227 deletions(-) diff --git a/src/components/GlanceRail.jsx b/src/components/GlanceRail.jsx index ecba23b..fb0fe9f 100644 --- a/src/components/GlanceRail.jsx +++ b/src/components/GlanceRail.jsx @@ -1,185 +1,271 @@ -// GlanceRail — the playhead accordion (task L-33, per docs/design/glance-mode.md §1–§4). +// GlanceRail — ALL loop stations expanded, always (task D-41, per +// docs/design/integrated-glance.md §4; supersedes the L-33 playhead accordion). // -// A station-aligned rail under the RoadmapTrack: columns mirror the Roadmap's -// stations (canonical KB order, same labels/rn). The column at `activeIndex` -// (the playhead) is EXPANDED to the full VoicingBrowser gallery — every -// placeable guitar shape, or every piano style, side by side — while every -// other column keeps its recommended thumb (the KB play's shape / the threaded -// piano voicing). The expansion ADVANCES WITH THE PLAYHEAD: over one loop cycle -// the player is shown every variation of every chord with zero clicks (user -// directive 2026-07-10). Constant footprint — expanding one column collapses -// the previous. +// One VERTICAL ROW per loop station, canonical KB order (the same order the +// banner's loop shows after rotation). Every row renders its full voicing +// gallery PERMANENTLY — the playhead HIGHLIGHTS the active row (accent ring + +// "now" badge + aria-current) and never hides, collapses, or reveals content. +// User directive 2026-07-10: "i'd like to see all the chords and their +// voicings … so you can follow and potentially learn new ways to play it while +// you are playing the loop. scrolling is easier then clicking." // -// Pinning (D-31 §4): tapping a collapsed column pins its gallery open and halts -// auto-follow; unpin by tapping the pinned column's header or the "follow the -// jam" chip. The PARENT owns the pin state and the onFocusChord emission — this -// component never emits focus-chord and NEVER triggers audio on its own -// (auto-follow must not feed the mic; every ▶ inside the gallery is a gesture). +// Row anatomy (D-40 §4): +// header — chord label + rn (the focus toggle) · "now" badge / "next" tag · +// solo-scale label · aim dots (3rd filled accent, 7th hollow — +// RoadmapTrack's GuideDot language, honest "5th" fallback kept) · +// transition chip ("next F→E · ½ step down"; the last row wraps: +// "loop"). This is where the retired RoadmapTrack's education +// folds in (D-40 §2) — theory.js `guideTones` / `voiceLeadingPairs` +// / `soloScale`, read-only imports. +// gallery — first cell = the station's OWN voicing (guitar: the KB play's +// recommended shape badged "play", when present; piano: the +// threaded/authored voicing labeled honestly, e.g. "LH 3-5-7-9" — +// the accordion's collapsed-thumb value survives here) + the full +// VoicingBrowser gallery (show={instrument}, dense). Cells +// FLEX-WRAP — rows never scroll horizontally; the piano worst case +// (~1,470px of cells) wraps to a second cell line instead (§4). // -// Space honesty (D-31 §3): cells are never shrunk below the D-30 sizes — the -// rail scrolls horizontally, USER-OWNED. The old auto-centre effect was deleted -// in L-40 (D-40 §4/§6.1): the rail now lives in page flow (the Jam Guide band, -// no 70vh scroller), where scrollIntoView's nearest scroller is the DOCUMENT — -// every playhead advance would yank the whole page. Scrolling is the piano -// rail's NORMAL state on most loops (a rootless 7th-chord gallery is ~940px on -// its own). Narrow (<640px, D-31 §3): one thing per row — the current -// station's full gallery (cells wrap), then a single "next" thumb; the loop -// display up top (ProgressionBanner) still shows the whole loop. +// Focus semantics (D-40 §4 — the pin, simplified): with everything always +// expanded there is nothing left to hold open, so tapping a row header TOGGLES +// that station as focused. The PARENT owns the state and the onFocusChord +// emission (the D-03 fretboard guide-tone contract, byte-compatible); a focused +// row shows an "aim on fretboard" chip; tap again (or the loop changes) to +// clear. This component never emits focus-chord itself and NEVER triggers +// audio on its own — every ▶ lives inside the gallery, behind a user gesture. // -// Pure presentational. Props (the D-31 §5 contract): -// stations — [{ shape, voicing, rootPc, quality, label, rn }] canonical order -// activeIndex — playhead station (canonicalPos); -1 = loop known, playhead -// not — station 0 expands, but nothing is marked "now" (§2.1) -// pinnedIndex — the pinned station index, or null (= follow the jam) -// onPin — fn(index|null): pin a station / unpin -// instrument — 'guitar' | 'piano' (VoicingBrowser `show`) -// keyRoot — key tonic pitch class 0–11 (ChordDiagram fret placement) +// NO auto-scroll (D-40 §4/§6.2 step 1): the band lives in page flow, where +// scrollIntoView's nearest scroller is the DOCUMENT — it would yank the whole +// 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. +// +// 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) +// 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) +import { NOTES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory' import ChordDiagram from './ChordDiagram' import MiniPiano from './MiniPiano' import VoicingBrowser from './VoicingBrowser' +const pcName = (pc) => NOTES[((pc % 12) + 12) % 12] + +// ─── Header atoms (exported — BassGuideRows in JamGuide.jsx composes the same +// anatomy for visual parity across instruments, D-40 §3) ────────────────── + +// Solo-scale label: "solo · G mixolydian" (theory.js snake_case → spaces). +export function SoloLabel({ rootPc, quality, keyMode }) { + const { name } = soloScale(quality, keyMode) + return ( + + solo · + {pcName(rootPc)} {name.replace(/_/g, ' ')} + + ) +} + +// One guide-tone dot: note name in a small circle + its honest kind label. +// Filled accent = the 3rd; hollow = the 7th (or the "5th" fallback — never +// badge a 5th as a 7th). Filled text is BLACK on accent (#a855f7 vs black +// ≈5.3:1 — AA; white would be ~4.0), matching VoicingBrowser's ▶ hover. +function GuideDot({ pc, kind, filled }) { + return ( + + + {pcName(pc)} + + {kind} + + ) +} + +// The "aim" pair — the RoadmapTrack TARGET lane, folded to one header line. +export function AimDots({ rootPc, quality }) { + const g = guideTones(rootPc, quality) + const seventhKind = g.hasSeventh ? '7th' : '5th' + return ( + + aim + + + + ) +} + +// The voice-leading rail, folded to a compact chip: "next F→E · ½ step down"; +// a held common tone reads "B holds · common tone"; the last row's chip is the +// wrap-around and says "loop" (D-40 §2). No smooth rail (≤2 semitones) → the +// caller passes null and no chip renders. +function TransitionChip({ pair, wraps }) { + if (!pair) return null + const held = pair.semitones === 0 + const label = held ? `${pcName(pair.from)} holds` : `${pcName(pair.from)}→${pcName(pair.to)}` + const motion = held + ? 'common tone' + : `${Math.abs(pair.semitones) === 1 ? '½' : Math.abs(pair.semitones)} step ${pair.semitones < 0 ? 'down' : 'up'}` + return ( + + {wraps ? 'loop' : 'next'} + {label} + · {motion} + + ) +} + +// ─── One always-expanded station row ───────────────────────────────────────── + +function StationRow({ + st, isNow, isNext, isFocused, onToggleFocus, instrument, keyRoot, keyMode, rail, wraps, +}) { + // The station's own voicing — the first gallery cell (D-40 §4). + const ownGuitar = instrument === 'guitar' && st.shape ? st.shape : null + const ownPiano = instrument === 'piano' && st.voicing ? st.voicing : null + + // Active row: unmistakable (accent ring + tint). Focused-but-not-now rows get + // the softer accent border; everything else recedes to the 0.85 opacity floor + // (never below AA legibility). + const stateClass = isNow + ? 'border-accent bg-accent/10 ring-2 ring-accent' + : isFocused + ? 'border-accent/60 bg-accent/5' + : 'border-border bg-surface' + + return ( +
+ {/* ── Header line: identity + the folded roadmap education ── */} +
+ + {isNow && ( + + now + + )} + {isNext && ( + + next + + )} + {isFocused && ( + + aim on fretboard + + )} + + + +
+ + {/* ── Gallery: own-voicing cell + the full dense browser; cells WRAP, + never scroll horizontally (D-40 §4). ── */} +
+ {(ownGuitar || ownPiano) && ( +
+
+ {ownGuitar && ( + + play + + )} + {ownGuitar ? ownGuitar.label : ownPiano.label} +
+ {ownGuitar ? ( + + ) : ( + + )} +
+ )} +
+ {/* dense: the rail shows the mic-feedback microcopy once, below. */} + +
+
+
+ ) +} + +// ─── The rail ───────────────────────────────────────────────────────────────── + export default function GlanceRail({ - stations = [], activeIndex = -1, pinnedIndex = null, onPin, instrument, keyRoot, + stations = [], activeIndex = -1, focusedIndex = null, onFocus, instrument, keyRoot, keyMode, }) { const n = stations.length - const pinned = pinnedIndex != null && pinnedIndex >= 0 && pinnedIndex < n - // The expanded column: the pin wins; otherwise follow the playhead; with the - // playhead unknown (-1) station 0 expands so the rail is never all-collapsed. - const expandedIndex = pinned ? pinnedIndex : (activeIndex >= 0 ? activeIndex : 0) - // Narrow-viewport lookahead: the one collapsed thumb worth its pixels (§3). - const followBase = activeIndex >= 0 ? activeIndex : 0 - const nextIndex = n > 1 ? (followBase + 1) % n : -1 - if (n === 0) return null + const nextIndex = 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. + const rails = stations.map((st, i) => { + if (n < 2) return null + const next = stations[(i + 1) % n] + return voiceLeadingPairs( + { root: st.rootPc, quality: st.quality }, + { root: next.rootPc, quality: next.quality }, + )[0] ?? null + }) return (
-
-

- Variations · {pinned ? 'pinned' : 'follows the playhead'} -

- {pinned && ( - - )} -
+

+ Variations · every chord, every voicing — the playhead highlights +

-
- {stations.map((st, i) => { - const isNow = i === activeIndex - const isExpanded = i === expandedIndex - const isPinnedHere = pinned && i === pinnedIndex - - if (isExpanded) { - // ── The expanded column: the full D-30 gallery for this station. ── - return ( -
-
- - {isNow && ( - - now - - )} - {isPinnedHere && ( - - pinned - - )} -
- {/* dense: the rail shows the mic-feedback microcopy once, below. */} - -
- ) - } - - // ── Collapsed column: the recommended thumb; tap to pin. ── - // Narrow (<640px): only the "next" thumb survives — the rest are - // dropped (the Roadmap above still shows the whole loop, §3). - const isNext = i === nextIndex - return ( - - ) - })} +
+ {stations.map((st, i) => ( + onFocus?.(focusedIndex === i ? null : i)} + instrument={instrument} + keyRoot={keyRoot} + keyMode={keyMode} + rail={rails[i]} + wraps={i === n - 1} + /> + ))}
{/* Mic-feedback microcopy — ONCE for the whole rail (D-31 §2.5); the diff --git a/src/components/JamGuide.jsx b/src/components/JamGuide.jsx index 99e6120..ecfdb96 100644 --- a/src/components/JamGuide.jsx +++ b/src/components/JamGuide.jsx @@ -2,7 +2,7 @@ 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 from './GlanceRail' +import GlanceRail, { AimDots, SoloLabel } from './GlanceRail' import VoicingBrowser from './VoicingBrowser' import LickCard, { TechniqueLegend } from './LickCard' import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel' @@ -295,34 +295,35 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = return stations }, [match.matched, match.progression, match.style, instrument, keyRoot]) - // ── Pinned station (L-33 — replaces tap-to-enlarge `selectedStation`, same - // semantics): pinning halts the rail's auto-follow and holds that station's - // gallery open. null = follow the jam. ── - const [pinnedStation, setPinnedStation] = useState(null) - // Reset the pin whenever the loop or style changes underneath us. - useEffect(() => { setPinnedStation(null) }, [match.id, match.style, 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 PINNED, report its {rootPc, quality} upward so the - // Fretboard can light that chord's guide tones; clear (null) on unpin. The - // reset effect above sets pinnedStation → null on loop/style/instrument + // 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). - // Auto-follow (the unpinned accordion) NEVER emits focus-chord — repainting + // 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 pin gesture reaches this effect. + // key view (D-31 §2.6). Only the focus gesture reaches this effect. useEffect(() => { if (!onFocusChord) return - const st = pinnedStation != null ? stationVoicings[pinnedStation] : null + const st = focusedStation != null ? stationVoicings[focusedStation] : null onFocusChord(st ? { rootPc: st.rootPc, quality: st.quality } : null) - }, [pinnedStation, stationVoicings, onFocusChord]) + }, [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 same rule as the rail's expansion). The pin freezes the rail, not the - // strip — the strip keeps re-sorting with the jam (D-31 §2.4). + // 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 return ( @@ -340,17 +341,19 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = both gallery generators are wrong for bass — computed roots/fifths/ approaches instead. The licks strip hides too (guitar tab licks are noise to a bassist mid-jam). */ - + ) : (
- {/* The voicing rail (the playhead accordion until D-41 expands all rows). */} + {/* The voicing rail — ALL stations expanded as vertical rows; the + playhead only highlights (D-41, D-40 §4). */} ) : ( @@ -420,14 +424,19 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = // minimum, PURE ARITHMETIC on data the band already has (no theory.js change): // the ROOT, the FIFTH (root + 7 semitones), and the chromatic APPROACH into the // NEXT station's root (one semitone below it — "approach: G♯ → A"). The last -// station approaches the first (the loop wraps). Solo-scale/guide-tone headers -// land with D-41's row anatomy; L-42 replaces these lines with authored -// BassPatternCards per station when the matched style ships a bass cell. +// station approaches the first (the loop wraps). Each row carries the same +// header anatomy as the GlanceRail rows (D-41, D-40 §3/§4 — chord + rn + +// solo-scale label + aim dots, via GlanceRail's exported atoms): guide tones +// ARE the bassist's target notes; none of that education is instrument- +// specific. L-42 replaces the computed line with an authored BassPatternCard +// per station when the matched style ships a bass cell — the row structure, +// highlight, and header need zero changes for it (the D-40 §3 contract). // // stations — [{ rootPc, quality, label, rn }] canonical KB order // activeIndex — playhead station (canonicalPos); -1 = none marked "now" +// keyMode — key mode name (soloScale's minor-key dominant nudge) // live — heard-live single chord: no next chord, so no approach line -function BassGuideRows({ stations = [], activeIndex = -1, live = false }) { +function BassGuideRows({ stations = [], activeIndex = -1, keyMode, live = false }) { const n = stations.length if (n === 0) return null return ( @@ -450,23 +459,31 @@ function BassGuideRows({ stations = [], activeIndex = -1, live = false }) { role="listitem" aria-current={isNow ? 'true' : undefined} className={ - 'flex flex-wrap items-baseline gap-x-3 gap-y-1 rounded-lg border px-3 py-2 ' + - (isNow ? 'border-accent bg-accent/10' : 'border-border bg-surface') + 'rounded-lg border px-3 py-2 ' + + (isNow ? 'border-accent bg-accent/10 ring-2 ring-accent' : 'border-border bg-surface') } style={{ opacity: isNow ? 1 : 0.85 }} > - {st.label} - {st.rn && ( - - {st.rn} + {/* Header line — visual parity with the GlanceRail rows. */} +
+ + {st.label} + {st.rn && ( + + {st.rn} + + )} - )} - {isNow && ( - - now - - )} - + {isNow && ( + + now + + )} + + +
+ {/* The computed line — the gallery slot until L-42's pattern card. */} +

root {NOTES[st.rootPc]} · fifth {NOTES[fifthPc]} @@ -479,7 +496,7 @@ function BassGuideRows({ stations = [], activeIndex = -1, live = false }) { )} - +

) })} diff --git a/src/components/VoicingBrowser.jsx b/src/components/VoicingBrowser.jsx index 3ca318b..d907d7c 100644 --- a/src/components/VoicingBrowser.jsx +++ b/src/components/VoicingBrowser.jsx @@ -35,13 +35,19 @@ // quality — CHORD_TYPES key; unknown values fall back to 'maj' // (matching voicings.js / piano.js behaviour) // show — 'guitar' | 'piano' | 'both' (default 'both', task D-23): which -// instrument section(s) to render. Any other value falls back to -// both, so every pre-existing mount renders identically with no prop. -// dense — boolean (default false, task L-33 — additive per D-31 §5): trims -// section padding and suppresses the per-mount mic-feedback -// microcopy, for mounts inside the GlanceRail accordion where the -// rail shows that microcopy ONCE for the whole rail (D-31 §2.5). -// Every pre-existing mount renders identically with no prop. +// instrument section(s) to render. 'bass' (task D-41, D-40 §3) +// renders NEITHER gallery — guitar shapes are not bass patterns and +// pianoVoicing is piano, so showing either under the global BASS +// selector would lie; an honest one-liner renders instead. Any +// OTHER value still falls back to both, so every pre-existing +// mount renders identically with no prop. +// dense — boolean (default false, task L-33 — additive per D-31 §5; D-41 +// restyled it for the all-expanded GlanceRail rows): drops the +// section chrome (border/panel background/heading — the row header +// already names the chord) and suppresses the per-mount +// mic-feedback microcopy (the rail shows it ONCE for the whole +// rail, D-31 §2.5). Every pre-existing mount renders identically +// with no prop. import { useEffect, useMemo, useRef } from 'react' import ChordDiagram from './ChordDiagram' @@ -145,13 +151,15 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo const name = chordName(pc, quality) const chordKey = `${pc}:${quality}` - // Section gating (D-23). 'guitar' hides the piano section, 'piano' hides the - // guitar section, anything else (incl. the 'both' default) shows both — so at - // least one section ALWAYS renders, and the mic-feedback microcopy below - // stays with it. Hooks stay unconditional; the shared stop-handle discipline + // Section gating (D-23; 'bass' added by D-41 per D-40 §3). 'guitar' hides + // the piano section, 'piano' hides the guitar section, 'bass' hides BOTH + // (neither gallery is honest for a bassist — the one-liner below renders + // instead, so the dock's VoicingsSection under the global BASS selector + // stops showing guitar+piano). Anything else (incl. the 'both' default) + // shows both. Hooks stay unconditional; the shared stop-handle discipline // (stop on chord change / unmount) is untouched by hiding a section. - const showGuitar = show !== 'piano' - const showPiano = show !== 'guitar' + const showGuitar = show !== 'piano' && show !== 'bass' + const showPiano = show !== 'guitar' && show !== 'bass' const guitarShapes = useMemo(() => matchingShapes(quality, pc), [quality, pc]) const pianoOptions = useMemo( @@ -201,11 +209,15 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo {showGuitar && (
-
- Guitar · {name} -
+ {/* dense (a GlanceRail row): the row header already names the chord and + the global selector names the instrument — no repeated heading. */} + {!dense && ( +
+ Guitar · {name} +
+ )} {guitarShapes.length === 0 ? ( // Graceful: nothing placeable for this root/quality — say so, no crash. @@ -237,11 +249,13 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo {showPiano && (
-
- Piano · {name} -
+ {!dense && ( +
+ Piano · {name} +
+ )}
+ No bass voicings for {name} yet — authored bass patterns are on the way + (blues first). Guitar and piano voicings live under those instruments. +

+ )} + {/* Mic-feedback caveat, per the L-20 header + D-20 §3 (microcopy tier). - At least one section always renders (see the gating above), so this - stays — except under `dense`, where the GlanceRail shows the SAME - microcopy once for the whole rail (D-31 §2.5) instead of per gallery. */} - {!dense && ( + Skipped under `dense`, where the GlanceRail shows the SAME microcopy + once for the whole rail (D-31 §2.5) instead of per gallery — and under + 'bass', where there is no ▶ to caveat. */} + {!dense && (showGuitar || showPiano) && (

Previews play through your speakers — while the mic is live, detection may hear them.