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:
+249
-163
@@ -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 (
|
||||
<span className="text-xs leading-none text-gray-400">
|
||||
<span className="text-[9px] uppercase tracking-widest text-gray-500">solo · </span>
|
||||
{pcName(rootPc)} {name.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className={
|
||||
'flex h-5 w-5 items-center justify-center rounded-full text-[9px] font-bold leading-none ' +
|
||||
(filled ? 'bg-accent text-black' : 'border-2 border-accent text-purple-300')
|
||||
}
|
||||
>
|
||||
{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
|
||||
type="button"
|
||||
aria-pressed={isFocused}
|
||||
onClick={onToggleFocus}
|
||||
title={isFocused
|
||||
? `Unfocus ${st.label} — clear its guide tones from the fretboard`
|
||||
: `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-[9px] font-medium uppercase tracking-wide text-gray-400">
|
||||
{st.rn}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isNow && (
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent">
|
||||
now
|
||||
</span>
|
||||
)}
|
||||
{isNext && (
|
||||
<span className="rounded border border-accent/60 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-accent">
|
||||
next
|
||||
</span>
|
||||
)}
|
||||
{isFocused && (
|
||||
<span className="rounded border border-accent bg-accent/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-accent">
|
||||
aim on fretboard
|
||||
</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" />
|
||||
) : (
|
||||
<MiniPiano voicing={ownPiano} size="thumb" />
|
||||
)}
|
||||
</figure>
|
||||
)}
|
||||
<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, 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 (
|
||||
<section
|
||||
className="rounded-2xl border border-border bg-panel p-3"
|
||||
aria-label="Voicing variations — follows the playhead"
|
||||
aria-label="Voicing variations — every chord of the loop, all expanded"
|
||||
>
|
||||
<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>
|
||||
<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 items-stretch gap-2 sm:flex-row sm:items-start sm:overflow-x-auto sm:pb-1" role="list">
|
||||
{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 (
|
||||
<div
|
||||
key={i}
|
||||
role="listitem"
|
||||
aria-current={isNow ? 'true' : undefined}
|
||||
aria-label={`${st.label}${st.rn ? ` (${st.rn})` : ''} — every ${instrument} voicing`}
|
||||
className={
|
||||
'order-1 w-full shrink-0 rounded-lg border p-2 sm:order-none sm:w-max ' +
|
||||
(isNow ? 'border-accent bg-accent/10' : 'border-accent/60 bg-accent/5')
|
||||
}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isPinnedHere}
|
||||
onClick={() => onPin?.(isPinnedHere ? null : i)}
|
||||
title={isPinnedHere
|
||||
? `Unpin ${st.label} — follow the jam again`
|
||||
: `Pin ${st.label} — hold its voicings open, 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"
|
||||
>
|
||||
<span className="text-sm font-semibold 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>
|
||||
)}
|
||||
</button>
|
||||
{isNow && (
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent">
|
||||
now
|
||||
</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 && (
|
||||
<span className="self-start text-[9px] font-semibold uppercase tracking-widest text-gray-500 sm:hidden">
|
||||
next
|
||||
</span>
|
||||
)}
|
||||
{st.voicing ? (
|
||||
<>
|
||||
<MiniPiano voicing={st.voicing} size="thumb" />
|
||||
{/* 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>
|
||||
</>
|
||||
) : (
|
||||
<ChordDiagram
|
||||
shape={st.shape}
|
||||
keyRoot={keyRoot}
|
||||
rootPc={st.rootPc}
|
||||
size="thumb"
|
||||
label={st.label}
|
||||
/>
|
||||
)}
|
||||
{st.rn && (
|
||||
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-500">
|
||||
{st.rn}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<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>
|
||||
|
||||
{/* Mic-feedback microcopy — ONCE for the whole rail (D-31 §2.5); the
|
||||
|
||||
+56
-39
@@ -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). */
|
||||
<BassGuideRows stations={stationVoicings} activeIndex={canonicalPos} />
|
||||
<BassGuideRows stations={stationVoicings} activeIndex={canonicalPos} keyMode={keyInfo?.mode} />
|
||||
) : (
|
||||
<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
|
||||
stations={stationVoicings}
|
||||
activeIndex={canonicalPos}
|
||||
pinnedIndex={pinnedStation}
|
||||
onPin={setPinnedStation}
|
||||
focusedIndex={focusedStation}
|
||||
onFocus={setFocusedStation}
|
||||
instrument={instrument}
|
||||
keyRoot={keyRoot}
|
||||
keyMode={keyInfo?.mode}
|
||||
/>
|
||||
<LicksStrip
|
||||
styleId={activeStyle}
|
||||
@@ -370,6 +373,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
||||
<BassGuideRows
|
||||
stations={[{ rootPc: liveChord.rootPc, quality: liveChord.type, label: currentChord, rn: '' }]}
|
||||
activeIndex={0}
|
||||
keyMode={keyInfo?.mode}
|
||||
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):
|
||||
// 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 }}
|
||||
>
|
||||
<span className="text-sm font-semibold leading-none text-gray-100">{st.label}</span>
|
||||
{st.rn && (
|
||||
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
|
||||
{st.rn}
|
||||
{/* 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 && (
|
||||
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
|
||||
{st.rn}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{isNow && (
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent">
|
||||
now
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-300">
|
||||
{isNow && (
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent">
|
||||
now
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
<span className="text-gray-600"> · </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>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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 && (
|
||||
<section
|
||||
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'}`}
|
||||
>
|
||||
<div className="mb-2">
|
||||
<SectionHeading>Guitar · {name}</SectionHeading>
|
||||
</div>
|
||||
{/* 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">
|
||||
<SectionHeading>Guitar · {name}</SectionHeading>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
<section
|
||||
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'}`}
|
||||
>
|
||||
<div className="mb-2">
|
||||
<SectionHeading>Piano · {name}</SectionHeading>
|
||||
</div>
|
||||
{!dense && (
|
||||
<div className="mb-2">
|
||||
<SectionHeading>Piano · {name}</SectionHeading>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
role="group"
|
||||
@@ -265,11 +279,20 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
|
||||
</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).
|
||||
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) && (
|
||||
<p className="w-full basis-full text-[11px] text-gray-500">
|
||||
Previews play through your speakers — while the mic is live, detection may
|
||||
hear them.
|
||||
|
||||
Reference in New Issue
Block a user