Files
JamBuddy/src/components/GlanceRail.jsx
T
vadimwit aae69e8a7b feat(rail): voicings rail polish — 4 guitar shapes, 2x2 mini piano, no play buttons, dark scrollbars (task L-70)
Dashboard voicings rail: guitar galleries capped to 4 shapes
(recommended-first, then open, then movable by base fret; prop-gated so
the Knowledge Center and chord modal keep all shapes); piano voicings
render as a 2x2 grid of size="mini" (scale 0.60) keyboards under the
rail's dense path only. Play buttons removed globally from
VoicingBrowser per the user ("leave them off, better not") — the
Knowledge Center + modal render byte-identical minus the buttons.
A scoped .dark-scroll utility (thin, #2a2a2a on transparent) dresses
the dashboard scrollers. Critic PASS (combined gate: byte-identity
proven, zero play buttons everywhere, contract intact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:58:39 +01:00

278 lines
13 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// GlanceRail — ALL loop stations expanded, always (task D-41, per
// docs/design/integrated-glance.md §4; supersedes the L-33 playhead accordion).
//
// D-51 (docs/design/one-screen.md §4/§6.2) adapts the rail to the dashboard's
// 500px right column. Margin-hardening arithmetic, worst case = a classic
// Windows scrollbar (~17px) inside the column's overflow-y-auto wrapper:
// row interior = 500 17 (scrollbar) 18 (section border + p-2)
// 14 (row border + p-1.5) = 451px
// guitar cell 89 (75 SVG + p-1.5 + border) → 4/line (374 ≤ 451; a 5th = 469 ✗)
// piano 1-octave 156.4 / 2-octave 279.6 → the mixed pair 279.6+6+156.4 = 442
// fits with ~9px margin even under the scrollbar (26px without) — the fit the
// doc calls fragile at the old paddings is now robust. Without the scrollbar
// the interior is 468px; every count above is unchanged.
//
// 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."
//
// 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 — the full VoicingBrowser (show={instrument}, dense) with the
// station's OWN voicing passed as `recommended` so it renders as the
// badged, accent-bordered first cell INSIDE the browser (no separate
// own-cell, no dupe — dashboard-polish.md §1.1/§2.1). Guitar caps at
// 4 recommended-first shapes on one line (§1); piano is a 2×2 of
// `size="mini"` keyboards (§2). No ▶ anywhere (§3).
//
// 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
// — the ▶ previews were removed everywhere (dashboard-polish.md §3).
//
// 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 011 (ChordDiagram fret placement)
// keyMode — key mode name (soloScale's minor-key dominant nudge)
import { NOTES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory'
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 — passed to VoicingBrowser as `recommended` so it
// renders as the badged, accent-bordered first cell inside the gallery (guitar:
// a shape; piano: a voicing matched by style). The separate own-cell is gone
// (dashboard-polish.md §1.1/§2.1 — it centralises the ≤4 rule and kills the old
// recommended/gallery duplicate).
const recommended = instrument === 'guitar' ? (st.shape ?? null) : (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-1.5 ${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 (dashboard-polish.md §1/§2): the recommended voicing is now
cell #1 INSIDE the browser (badged "play", accent border — "the
answer" prominence the old own-cell had, no dupe). Guitar caps at 4
recommended-first cells on one line; piano is a 2×2 of mini
keyboards. Full row interior, no separate own-cell. ── */}
<VoicingBrowser
rootPc={st.rootPc}
quality={st.quality}
show={instrument}
dense
recommended={recommended}
max={4}
/>
</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-2"
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>
{/* No ▶ anywhere anymore (dashboard-polish.md §3 — "leave them off,
better not"); the rail is purely visual and follows the loop. */}
<p className="mt-2 text-[11px] text-gray-500">
Voicings follow the loop the playhead highlights the chord you're on.
</p>
</section>
)
}