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 <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-10 19:03:48 +01:00
parent 7d6fe8ad50
commit 677d7b99d1
3 changed files with 353 additions and 227 deletions
+225 -139
View File
@@ -1,108 +1,169 @@
// 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 // One VERTICAL ROW per loop station, canonical KB order (the same order the
// stations (canonical KB order, same labels/rn). The column at `activeIndex` // banner's loop shows after rotation). Every row renders its full voicing
// (the playhead) is EXPANDED to the full VoicingBrowser gallery — every // gallery PERMANENTLY — the playhead HIGHLIGHTS the active row (accent ring +
// placeable guitar shape, or every piano style, side by side — while every // "now" badge + aria-current) and never hides, collapses, or reveals content.
// other column keeps its recommended thumb (the KB play's shape / the threaded // User directive 2026-07-10: "i'd like to see all the chords and their
// piano voicing). The expansion ADVANCES WITH THE PLAYHEAD: over one loop cycle // voicings … so you can follow and potentially learn new ways to play it while
// the player is shown every variation of every chord with zero clicks (user // you are playing the loop. scrolling is easier then clicking."
// directive 2026-07-10). Constant footprint — expanding one column collapses
// the previous.
// //
// Pinning (D-31 §4): tapping a collapsed column pins its gallery open and halts // Row anatomy (D-40 §4):
// auto-follow; unpin by tapping the pinned column's header or the "follow the // header — chord label + rn (the focus toggle) · "now" badge / "next" tag ·
// jam" chip. The PARENT owns the pin state and the onFocusChord emission — this // solo-scale label · aim dots (3rd filled accent, 7th hollow —
// component never emits focus-chord and NEVER triggers audio on its own // RoadmapTrack's GuideDot language, honest "5th" fallback kept) ·
// (auto-follow must not feed the mic; every ▶ inside the gallery is a gesture). // 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 // Focus semantics (D-40 §4 — the pin, simplified): with everything always
// rail scrolls horizontally, USER-OWNED. The old auto-centre effect was deleted // expanded there is nothing left to hold open, so tapping a row header TOGGLES
// in L-40 (D-40 §4/§6.1): the rail now lives in page flow (the Jam Guide band, // that station as focused. The PARENT owns the state and the onFocusChord
// no 70vh scroller), where scrollIntoView's nearest scroller is the DOCUMENT — // emission (the D-03 fretboard guide-tone contract, byte-compatible); a focused
// every playhead advance would yank the whole page. Scrolling is the piano // row shows an "aim on fretboard" chip; tap again (or the loop changes) to
// rail's NORMAL state on most loops (a rootless 7th-chord gallery is ~940px on // clear. This component never emits focus-chord itself and NEVER triggers
// its own). Narrow (<640px, D-31 §3): one thing per row — the current // audio on its own — every ▶ lives inside the gallery, behind a user gesture.
// station's full gallery (cells wrap), then a single "next" thumb; the loop
// display up top (ProgressionBanner) still shows the whole loop.
// //
// Pure presentational. Props (the D-31 §5 contract): // 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 // 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 — station 0 expands, but nothing is marked "now" (§2.1) // not — no row is marked "now" (content never changes either way)
// pinnedIndex — the pinned station index, or null (= follow the jam) // focusedIndex — the focused station index, or null (nothing focused)
// onPin — fn(index|null): pin a station / unpin // onFocus — fn(index|null): toggle a station's focus
// instrument — 'guitar' | 'piano' (VoicingBrowser `show`) // instrument — 'guitar' | 'piano' (VoicingBrowser `show`; bass never mounts
// 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)
import { NOTES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory'
import ChordDiagram from './ChordDiagram' import ChordDiagram from './ChordDiagram'
import MiniPiano from './MiniPiano' import MiniPiano from './MiniPiano'
import VoicingBrowser from './VoicingBrowser' import VoicingBrowser from './VoicingBrowser'
export default function GlanceRail({ const pcName = (pc) => NOTES[((pc % 12) + 12) % 12]
stations = [], activeIndex = -1, pinnedIndex = null, onPin, instrument, keyRoot,
}) {
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 // ─── 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 ( return (
<section <span className="text-xs leading-none text-gray-400">
className="rounded-2xl border border-border bg-panel p-3" <span className="text-[9px] uppercase tracking-widest text-gray-500">solo · </span>
aria-label="Voicing variations — follows the playhead" {pcName(rootPc)} {name.replace(/_/g, ' ')}
> </span>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2"> )
<h4 className="text-[10px] font-semibold uppercase tracking-widest text-gray-500"> }
Variations · {pinned ? 'pinned' : 'follows the playhead'}
</h4>
{pinned && (
<button
type="button"
onClick={() => onPin?.(null)}
className="flex min-h-[32px] items-center gap-1.5 rounded-lg border border-accent bg-accent/10 px-2.5 py-1 text-xs font-medium text-accent outline-none transition hover:bg-accent/20 focus-visible:ring-2 focus-visible:ring-accent"
>
<span aria-hidden="true"></span> follow the jam
</button>
)}
</div>
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-start sm:overflow-x-auto sm:pb-1" role="list"> // One guide-tone dot: note name in a small circle + its honest kind label.
{stations.map((st, i) => { // Filled accent = the 3rd; hollow = the 7th (or the "5th" fallback — never
const isNow = i === activeIndex // badge a 5th as a 7th). Filled text is BLACK on accent (#a855f7 vs black
const isExpanded = i === expandedIndex // ≈5.3:1 — AA; white would be ~4.0), matching VoicingBrowser's ▶ hover.
const isPinnedHere = pinned && i === pinnedIndex function GuideDot({ pc, kind, filled }) {
if (isExpanded) {
// ── The expanded column: the full D-30 gallery for this station. ──
return ( return (
<div <span className="flex items-center gap-1">
key={i} <span
role="listitem"
aria-current={isNow ? 'true' : undefined}
aria-label={`${st.label}${st.rn ? ` (${st.rn})` : ''} — every ${instrument} voicing`}
className={ className={
'order-1 w-full shrink-0 rounded-lg border p-2 sm:order-none sm:w-max ' + 'flex h-5 w-5 items-center justify-center rounded-full text-[9px] font-bold leading-none ' +
(isNow ? 'border-accent bg-accent/10' : 'border-accent/60 bg-accent/5') (filled ? 'bg-accent text-black' : 'border-2 border-accent text-purple-300')
} }
> >
<div className="mb-1.5 flex items-center gap-2"> {pcName(pc)}
</span>
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">{kind}</span>
</span>
)
}
// 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 (
<span className="flex items-center gap-2">
<span className="text-[9px] uppercase tracking-widest text-gray-500">aim</span>
<GuideDot pc={g.third} kind="3rd" filled />
<GuideDot pc={g.seventh} kind={seventhKind} filled={false} />
</span>
)
}
// 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 (
<span className="flex items-center gap-1 rounded border border-border bg-surface px-1.5 py-1 text-[10px] leading-none">
<span className="font-medium uppercase tracking-wide text-gray-500">{wraps ? 'loop' : 'next'}</span>
<span className="font-semibold text-accent">{label}</span>
<span className="text-gray-400">· {motion}</span>
</span>
)
}
// ─── 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 (
<div
role="listitem"
aria-current={isNow ? 'true' : undefined}
aria-label={
`${st.label}${st.rn ? ` (${st.rn})` : ''} — every ${instrument} voicing` +
`${isNow ? ', now playing' : ''}${isNext ? ', up next' : ''}`
}
className={`rounded-lg border p-2 ${stateClass}`}
style={{ opacity: isNow || isFocused ? 1 : 0.85 }}
>
{/* ── Header line: identity + the folded roadmap education ── */}
<div className="mb-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
<button <button
type="button" type="button"
aria-pressed={isPinnedHere} aria-pressed={isFocused}
onClick={() => onPin?.(isPinnedHere ? null : i)} onClick={onToggleFocus}
title={isPinnedHere title={isFocused
? `Unpin ${st.label}follow the jam again` ? `Unfocus ${st.label}clear its guide tones from the fretboard`
: `Pin ${st.label} hold its voicings open, light its guide tones on the fretboard`} : `Focus ${st.label} — light its guide tones on the fretboard`}
className="flex min-h-[32px] items-center gap-1.5 rounded px-1 outline-none focus-visible:ring-2 focus-visible:ring-accent" 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-semibold leading-none text-gray-100">{st.label}</span> <span className="text-sm font-bold leading-none text-gray-100">{st.label}</span>
{st.rn && ( {st.rn && (
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400"> <span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
{st.rn} {st.rn}
@@ -114,72 +175,97 @@ export default function GlanceRail({
now now
</span> </span>
)} )}
{isPinnedHere && (
<span className="text-[9px] font-semibold uppercase tracking-widest text-gray-500">
pinned
</span>
)}
</div>
{/* dense: the rail shows the mic-feedback microcopy once, below. */}
<VoicingBrowser rootPc={st.rootPc} quality={st.quality} show={instrument} dense />
</div>
)
}
// ── 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 (
<button
key={i}
type="button"
role="listitem"
aria-pressed={false}
aria-current={isNow ? 'true' : undefined}
onClick={() => onPin?.(i)}
title={`Pin ${st.label}${st.rn ? ` (${st.rn})` : ''} — hold its voicings open`}
className={
(isNext ? 'order-2 flex sm:order-none ' : 'hidden sm:flex ') +
'shrink-0 flex-col items-center gap-1 rounded-lg border p-2 outline-none transition ' +
'focus-visible:ring-2 focus-visible:ring-accent ' +
(isNow
? 'border-accent bg-accent/10 ring-1 ring-accent'
: 'border-border bg-surface hover:border-gray-500')
}
style={{ opacity: isNow ? 1 : 0.85 }}
>
{isNext && ( {isNext && (
<span className="self-start text-[9px] font-semibold uppercase tracking-widest text-gray-500 sm:hidden"> <span className="rounded border border-accent/60 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-accent">
next next
</span> </span>
)} )}
{st.voicing ? ( {isFocused && (
<> <span className="rounded border border-accent bg-accent/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-accent">
<MiniPiano voicing={st.voicing} size="thumb" /> aim on fretboard
{/* MiniPiano has no built-in chord label; mirror ChordDiagram's
thumb label (text-gray-300, 9px) so the station stays named. */}
<span className="text-gray-300 leading-none" style={{ fontSize: 9 }}>
{st.label}
</span> </span>
</> )}
<SoloLabel rootPc={st.rootPc} quality={st.quality} keyMode={keyMode} />
<AimDots rootPc={st.rootPc} quality={st.quality} />
<TransitionChip pair={rail} wraps={wraps} />
</div>
{/* ── Gallery: own-voicing cell + the full dense browser; cells WRAP,
never scroll horizontally (D-40 §4). ── */}
<div className="flex flex-wrap items-start gap-2">
{(ownGuitar || ownPiano) && (
<figure className="flex shrink-0 flex-col items-center gap-1.5 rounded-md border border-accent/60 bg-surface p-2">
<figcaption className="flex max-w-full items-center gap-1.5 text-[11px] font-medium leading-tight text-gray-300">
{ownGuitar && (
<span className="rounded bg-accent px-1 py-0.5 text-[9px] font-bold uppercase tracking-wider text-black">
play
</span>
)}
<span className="break-words">{ownGuitar ? ownGuitar.label : ownPiano.label}</span>
</figcaption>
{ownGuitar ? (
<ChordDiagram shape={ownGuitar} keyRoot={keyRoot} rootPc={st.rootPc} size="thumb" />
) : ( ) : (
<ChordDiagram <MiniPiano voicing={ownPiano} size="thumb" />
shape={st.shape}
keyRoot={keyRoot}
rootPc={st.rootPc}
size="thumb"
label={st.label}
/>
)} )}
{st.rn && ( </figure>
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-500">
{st.rn}
</span>
)} )}
</button> <div className="min-w-0 flex-1 basis-[300px]">
{/* dense: the rail shows the mic-feedback microcopy once, below. */}
<VoicingBrowser rootPc={st.rootPc} quality={st.quality} show={instrument} dense />
</div>
</div>
</div>
) )
})} }
// ─── The rail ─────────────────────────────────────────────────────────────────
export default function GlanceRail({
stations = [], activeIndex = -1, focusedIndex = null, onFocus, instrument, keyRoot, keyMode,
}) {
const n = stations.length
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 (
<section
className="rounded-2xl border border-border bg-panel p-3"
aria-label="Voicing variations — every chord of the loop, all expanded"
>
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
Variations · every chord, every voicing the playhead highlights
</h4>
<div className="flex flex-col gap-2" role="list">
{stations.map((st, i) => (
<StationRow
key={i}
st={st}
isNow={i === activeIndex}
isNext={i === nextIndex}
isFocused={focusedIndex === i}
onToggleFocus={() => onFocus?.(focusedIndex === i ? null : i)}
instrument={instrument}
keyRoot={keyRoot}
keyMode={keyMode}
rail={rails[i]}
wraps={i === n - 1}
/>
))}
</div> </div>
{/* Mic-feedback microcopy — ONCE for the whole rail (D-31 §2.5); the {/* Mic-feedback microcopy — ONCE for the whole rail (D-31 §2.5); the
+47 -30
View File
@@ -2,7 +2,7 @@ import { useState, useMemo, useEffect } from 'react'
import kb from '../data/kb/index.js' import kb from '../data/kb/index.js'
import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC } from '../lib/match' import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC } from '../lib/match'
import { NOTES, CHORD_TYPES } from '../lib/theory' import { NOTES, CHORD_TYPES } from '../lib/theory'
import GlanceRail from './GlanceRail' import GlanceRail, { AimDots, SoloLabel } from './GlanceRail'
import VoicingBrowser from './VoicingBrowser' import VoicingBrowser from './VoicingBrowser'
import LickCard, { TechniqueLegend } from './LickCard' import LickCard, { TechniqueLegend } from './LickCard'
import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel' import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel'
@@ -295,34 +295,35 @@ 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])
// ── Pinned station (L-33 — replaces tap-to-enlarge `selectedStation`, same // ── Focused station (D-41 — the L-33 pin, simplified per D-40 §4: with every
// semantics): pinning halts the rail's auto-follow and holds that station's // row always expanded there is nothing to hold open, so the gesture collapses
// gallery open. null = follow the jam. ── // to a focus TOGGLE on the row header). Same JamGuide-owned state, same reset
const [pinnedStation, setPinnedStation] = useState(null) // effect, same onFocusChord emission as pinnedStation before it. ──
// Reset the pin whenever the loop or style changes underneath us. const [focusedStation, setFocusedStation] = useState(null)
useEffect(() => { setPinnedStation(null) }, [match.id, match.style, instrument]) // 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) ───────────────────────────────── // ── Cross-link to the main Fretboard (D-03) ─────────────────────────────────
// When a station is PINNED, report its {rootPc, quality} upward so the // When a station is FOCUSED, report its {rootPc, quality} upward so the
// Fretboard can light that chord's guide tones; clear (null) on unpin. The // Fretboard can light that chord's guide tones; clear (null) on unfocus. The
// reset effect above sets pinnedStation → null on loop/style/instrument // reset effect above sets focusedStation → null on loop/style/instrument
// change, which flows through here and clears the highlight too. Guarded so // change, which flows through here and clears the highlight too. Guarded so
// the component still works standalone (onFocusChord optional). // 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 // 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(() => { useEffect(() => {
if (!onFocusChord) return 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) onFocusChord(st ? { rootPc: st.rootPc, quality: st.quality } : null)
}, [pinnedStation, stationVoicings, onFocusChord]) }, [focusedStation, stationVoicings, onFocusChord])
// Clear the Fretboard highlight when JamGuide unmounts. // Clear the Fretboard highlight when JamGuide unmounts.
useEffect(() => () => { onFocusChord?.(null) }, [onFocusChord]) useEffect(() => () => { onFocusChord?.(null) }, [onFocusChord])
// Licks-strip context = the PLAYHEAD station (canonicalPos 1 → station 0, // 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 // The focus toggle aims the fretboard, not the strip — the strip keeps
// strip — the strip keeps 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
return ( return (
@@ -340,17 +341,19 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
both gallery generators are wrong for bass — computed roots/fifths/ both gallery generators are wrong for bass — computed roots/fifths/
approaches instead. The licks strip hides too (guitar tab licks approaches instead. The licks strip hides too (guitar tab licks
are noise to a bassist mid-jam). */ are noise to a bassist mid-jam). */
<BassGuideRows stations={stationVoicings} activeIndex={canonicalPos} /> <BassGuideRows stations={stationVoicings} activeIndex={canonicalPos} keyMode={keyInfo?.mode} />
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* 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). */}
<GlanceRail <GlanceRail
stations={stationVoicings} stations={stationVoicings}
activeIndex={canonicalPos} activeIndex={canonicalPos}
pinnedIndex={pinnedStation} focusedIndex={focusedStation}
onPin={setPinnedStation} onFocus={setFocusedStation}
instrument={instrument} instrument={instrument}
keyRoot={keyRoot} keyRoot={keyRoot}
keyMode={keyInfo?.mode}
/> />
<LicksStrip <LicksStrip
styleId={activeStyle} styleId={activeStyle}
@@ -370,6 +373,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
<BassGuideRows <BassGuideRows
stations={[{ rootPc: liveChord.rootPc, quality: liveChord.type, label: currentChord, rn: '' }]} stations={[{ rootPc: liveChord.rootPc, quality: liveChord.type, label: currentChord, rn: '' }]}
activeIndex={0} activeIndex={0}
keyMode={keyInfo?.mode}
live live
/> />
) : ( ) : (
@@ -420,14 +424,19 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
// minimum, PURE ARITHMETIC on data the band already has (no theory.js change): // 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 // 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 // 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 // station approaches the first (the loop wraps). Each row carries the same
// land with D-41's row anatomy; L-42 replaces these lines with authored // header anatomy as the GlanceRail rows (D-41, D-40 §3/§4 — chord + rn +
// BassPatternCards per station when the matched style ships a bass cell. // 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 // stations — [{ rootPc, quality, label, rn }] canonical KB order
// activeIndex — playhead station (canonicalPos); -1 = none marked "now" // 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 // 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 const n = stations.length
if (n === 0) return null if (n === 0) return null
return ( return (
@@ -450,23 +459,31 @@ function BassGuideRows({ stations = [], activeIndex = -1, live = false }) {
role="listitem" role="listitem"
aria-current={isNow ? 'true' : undefined} aria-current={isNow ? 'true' : undefined}
className={ className={
'flex flex-wrap items-baseline gap-x-3 gap-y-1 rounded-lg border px-3 py-2 ' + 'rounded-lg border px-3 py-2 ' +
(isNow ? 'border-accent bg-accent/10' : 'border-border bg-surface') (isNow ? 'border-accent bg-accent/10 ring-2 ring-accent' : 'border-border bg-surface')
} }
style={{ opacity: isNow ? 1 : 0.85 }} style={{ opacity: isNow ? 1 : 0.85 }}
> >
<span className="text-sm font-semibold leading-none text-gray-100">{st.label}</span> {/* Header line — visual parity with the GlanceRail rows. */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="flex min-h-[20px] items-center gap-2">
<span className="text-sm font-bold leading-none text-gray-100">{st.label}</span>
{st.rn && ( {st.rn && (
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400"> <span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
{st.rn} {st.rn}
</span> </span>
)} )}
</span>
{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
</span> </span>
)} )}
<span className="text-xs text-gray-300"> <SoloLabel rootPc={st.rootPc} quality={st.quality} keyMode={keyMode} />
<AimDots rootPc={st.rootPc} quality={st.quality} />
</div>
{/* The computed line — the gallery slot until L-42's pattern card. */}
<p className="mt-1.5 text-xs text-gray-300">
root <span className="font-semibold text-gray-100">{NOTES[st.rootPc]}</span> root <span className="font-semibold text-gray-100">{NOTES[st.rootPc]}</span>
<span className="text-gray-600"> · </span> <span className="text-gray-600"> · </span>
fifth <span className="font-semibold text-gray-100">{NOTES[fifthPc]}</span> fifth <span className="font-semibold text-gray-100">{NOTES[fifthPc]}</span>
@@ -479,7 +496,7 @@ function BassGuideRows({ stations = [], activeIndex = -1, live = false }) {
</span> </span>
</> </>
)} )}
</span> </p>
</div> </div>
) )
})} })}
+42 -19
View File
@@ -35,13 +35,19 @@
// quality — CHORD_TYPES key; unknown values fall back to 'maj' // quality — CHORD_TYPES key; unknown values fall back to 'maj'
// (matching voicings.js / piano.js behaviour) // (matching voicings.js / piano.js behaviour)
// show — 'guitar' | 'piano' | 'both' (default 'both', task D-23): which // show — 'guitar' | 'piano' | 'both' (default 'both', task D-23): which
// instrument section(s) to render. Any other value falls back to // instrument section(s) to render. 'bass' (task D-41, D-40 §3)
// both, so every pre-existing mount renders identically with no prop. // renders NEITHER gallery — guitar shapes are not bass patterns and
// dense — boolean (default false, task L-33 — additive per D-31 §5): trims // pianoVoicing is piano, so showing either under the global BASS
// section padding and suppresses the per-mount mic-feedback // selector would lie; an honest one-liner renders instead. Any
// microcopy, for mounts inside the GlanceRail accordion where the // OTHER value still falls back to both, so every pre-existing
// rail shows that microcopy ONCE for the whole rail (D-31 §2.5). // mount renders identically with no prop.
// 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 { useEffect, useMemo, useRef } from 'react'
import ChordDiagram from './ChordDiagram' import ChordDiagram from './ChordDiagram'
@@ -145,13 +151,15 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
const name = chordName(pc, quality) const name = chordName(pc, quality)
const chordKey = `${pc}:${quality}` const chordKey = `${pc}:${quality}`
// Section gating (D-23). 'guitar' hides the piano section, 'piano' hides the // Section gating (D-23; 'bass' added by D-41 per D-40 §3). 'guitar' hides
// guitar section, anything else (incl. the 'both' default) shows both — so at // the piano section, 'piano' hides the guitar section, 'bass' hides BOTH
// least one section ALWAYS renders, and the mic-feedback microcopy below // (neither gallery is honest for a bassist — the one-liner below renders
// stays with it. Hooks stay unconditional; the shared stop-handle discipline // 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. // (stop on chord change / unmount) is untouched by hiding a section.
const showGuitar = show !== 'piano' const showGuitar = show !== 'piano' && show !== 'bass'
const showPiano = show !== 'guitar' const showPiano = show !== 'guitar' && show !== 'bass'
const guitarShapes = useMemo(() => matchingShapes(quality, pc), [quality, pc]) const guitarShapes = useMemo(() => matchingShapes(quality, pc), [quality, pc])
const pianoOptions = useMemo( const pianoOptions = useMemo(
@@ -201,11 +209,15 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
{showGuitar && ( {showGuitar && (
<section <section
aria-label={`Guitar voicings for ${name}`} aria-label={`Guitar voicings for ${name}`}
className={`min-w-[240px] flex-1 basis-[300px] rounded-lg border border-border bg-panel ${dense ? 'p-2' : 'p-3'}`} className={`min-w-[240px] flex-1 basis-[300px] ${dense ? '' : 'rounded-lg border border-border bg-panel p-3'}`}
> >
{/* dense (a GlanceRail row): the row header already names the chord and
the global selector names the instrument — no repeated heading. */}
{!dense && (
<div className="mb-2"> <div className="mb-2">
<SectionHeading>Guitar · {name}</SectionHeading> <SectionHeading>Guitar · {name}</SectionHeading>
</div> </div>
)}
{guitarShapes.length === 0 ? ( {guitarShapes.length === 0 ? (
// Graceful: nothing placeable for this root/quality — say so, no crash. // 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 && ( {showPiano && (
<section <section
aria-label={`Piano voicings for ${name}`} aria-label={`Piano voicings for ${name}`}
className={`min-w-[240px] flex-1 basis-[300px] rounded-lg border border-border bg-panel ${dense ? 'p-2' : 'p-3'}`} className={`min-w-[240px] flex-1 basis-[300px] ${dense ? '' : 'rounded-lg border border-border bg-panel p-3'}`}
> >
{!dense && (
<div className="mb-2"> <div className="mb-2">
<SectionHeading>Piano · {name}</SectionHeading> <SectionHeading>Piano · {name}</SectionHeading>
</div> </div>
)}
<div <div
role="group" role="group"
@@ -265,11 +279,20 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
</section> </section>
)} )}
{/* ── show='bass' (D-41, D-40 §3): no gallery would be honest — say so
in one line instead of rendering guitar+piano under BASS. ── */}
{!showGuitar && !showPiano && (
<p className="text-xs text-gray-400">
No bass voicings for {name} yet authored bass patterns are on the way
(blues first). Guitar and piano voicings live under those instruments.
</p>
)}
{/* Mic-feedback caveat, per the L-20 header + D-20 §3 (microcopy tier). {/* 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 Skipped under `dense`, where the GlanceRail shows the SAME microcopy
stays — except under `dense`, where the GlanceRail shows the SAME once for the whole rail (D-31 §2.5) instead of per gallery — and under
microcopy once for the whole rail (D-31 §2.5) instead of per gallery. */} 'bass', where there is no ▶ to caveat. */}
{!dense && ( {!dense && (showGuitar || showPiano) && (
<p className="w-full basis-full text-[11px] text-gray-500"> <p className="w-full basis-full text-[11px] text-gray-500">
Previews play through your speakers while the mic is live, detection may Previews play through your speakers while the mic is live, detection may
hear them. hear them.