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
|
||||
|
||||
Reference in New Issue
Block a user