feat(jamguide): glance mode — the playhead accordion (task L-33)
The station under the playhead auto-expands to the full voicing
gallery (VoicingBrowser dense) and advances with the music; other
stations keep their recommended thumb; level-filtered licks strip
below with token-boundary context sort and strip-owned "fits — now"
chrome; pin replaces tap-to-enlarge with identical onFocusChord
semantics; auto-follow never emits focus or audio; heard-live fallback
shows the live chord gallery. App.jsx and all renderers untouched.
Critic PASS (SSR 37/37, matcher proven on hostile cases, dense-less
render byte-identical to 0945a08).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
// GlanceRail — the playhead accordion (task L-33, per docs/design/glance-mode.md §1–§4).
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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).
|
||||
//
|
||||
// Space honesty (D-31 §3): cells are never shrunk below the D-30 sizes — the
|
||||
// rail scrolls horizontally with the expanded column auto-centred. 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 Roadmap
|
||||
// above still shows the whole loop.
|
||||
//
|
||||
// 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)
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import ChordDiagram from './ChordDiagram'
|
||||
import MiniPiano from './MiniPiano'
|
||||
import VoicingBrowser from './VoicingBrowser'
|
||||
|
||||
export default function GlanceRail({
|
||||
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
|
||||
|
||||
// Auto-centre the expanded column as the accordion advances. Prop-driven —
|
||||
// no rAF, nothing tied to the audio thread. Respect prefers-reduced-motion
|
||||
// (D-31 §2.1): jump instead of smooth-scrolling.
|
||||
const expandedRef = useRef(null)
|
||||
useEffect(() => {
|
||||
const el = expandedRef.current
|
||||
if (!el) return
|
||||
const reduceMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
|
||||
el.scrollIntoView({
|
||||
behavior: reduceMotion ? 'auto' : 'smooth',
|
||||
inline: 'center',
|
||||
block: 'nearest',
|
||||
})
|
||||
}, [expandedIndex])
|
||||
|
||||
if (n === 0) return null
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-2xl border border-border bg-panel p-3"
|
||||
aria-label="Voicing variations — follows the playhead"
|
||||
>
|
||||
<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">
|
||||
{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}
|
||||
ref={expandedRef}
|
||||
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>
|
||||
|
||||
{/* Mic-feedback microcopy — ONCE for the whole rail (D-31 §2.5); the
|
||||
gallery mounts run `dense` and suppress their per-mount copy. */}
|
||||
<p className="mt-2 text-[11px] text-gray-500">
|
||||
▶ previews play through your speakers — while the mic is live, detection may
|
||||
hear them. Nothing plays automatically.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user