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
|
// 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
|
||||||
// stations — [{ shape, voicing, rootPc, quality, label, rn }] canonical order
|
// scrollIntoView's nearest scroller is the DOCUMENT — it would yank the whole
|
||||||
// activeIndex — playhead station (canonicalPos); -1 = loop known, playhead
|
// page mid-jam. The L-33 auto-centre effect was deleted in L-40 and must never
|
||||||
// not — station 0 expands, but nothing is marked "now" (§2.1)
|
// return; the highlight travels, the user owns the scrollbar.
|
||||||
// pinnedIndex — the pinned station index, or null (= follow the jam)
|
//
|
||||||
// onPin — fn(index|null): pin a station / unpin
|
// Pure presentational. Props:
|
||||||
// instrument — 'guitar' | 'piano' (VoicingBrowser `show`)
|
// stations — [{ shape, voicing, rootPc, quality, label, rn }] canonical order
|
||||||
// keyRoot — key tonic pitch class 0–11 (ChordDiagram fret placement)
|
// 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 ChordDiagram from './ChordDiagram'
|
||||||
import MiniPiano from './MiniPiano'
|
import MiniPiano from './MiniPiano'
|
||||||
import VoicingBrowser from './VoicingBrowser'
|
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({
|
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 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
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
className="rounded-2xl border border-border bg-panel p-3"
|
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="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
<h4 className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
Variations · every chord, every voicing — the playhead highlights
|
||||||
Variations · {pinned ? 'pinned' : 'follows the playhead'}
|
</h4>
|
||||||
</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">
|
<div className="flex flex-col gap-2" role="list">
|
||||||
{stations.map((st, i) => {
|
{stations.map((st, i) => (
|
||||||
const isNow = i === activeIndex
|
<StationRow
|
||||||
const isExpanded = i === expandedIndex
|
key={i}
|
||||||
const isPinnedHere = pinned && i === pinnedIndex
|
st={st}
|
||||||
|
isNow={i === activeIndex}
|
||||||
if (isExpanded) {
|
isNext={i === nextIndex}
|
||||||
// ── The expanded column: the full D-30 gallery for this station. ──
|
isFocused={focusedIndex === i}
|
||||||
return (
|
onToggleFocus={() => onFocus?.(focusedIndex === i ? null : i)}
|
||||||
<div
|
instrument={instrument}
|
||||||
key={i}
|
keyRoot={keyRoot}
|
||||||
role="listitem"
|
keyMode={keyMode}
|
||||||
aria-current={isNow ? 'true' : undefined}
|
rail={rails[i]}
|
||||||
aria-label={`${st.label}${st.rn ? ` (${st.rn})` : ''} — every ${instrument} voicing`}
|
wraps={i === n - 1}
|
||||||
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>
|
</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
|
||||||
|
|||||||
+56
-39
@@ -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. */}
|
||||||
{st.rn && (
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
|
<span className="flex min-h-[20px] items-center gap-2">
|
||||||
{st.rn}
|
<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>
|
</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>
|
)}
|
||||||
)}
|
<SoloLabel rootPc={st.rootPc} quality={st.quality} keyMode={keyMode} />
|
||||||
<span className="text-xs text-gray-300">
|
<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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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'}`}
|
||||||
>
|
>
|
||||||
<div className="mb-2">
|
{/* dense (a GlanceRail row): the row header already names the chord and
|
||||||
<SectionHeading>Guitar · {name}</SectionHeading>
|
the global selector names the instrument — no repeated heading. */}
|
||||||
</div>
|
{!dense && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<SectionHeading>Guitar · {name}</SectionHeading>
|
||||||
|
</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'}`}
|
||||||
>
|
>
|
||||||
<div className="mb-2">
|
{!dense && (
|
||||||
<SectionHeading>Piano · {name}</SectionHeading>
|
<div className="mb-2">
|
||||||
</div>
|
<SectionHeading>Piano · {name}</SectionHeading>
|
||||||
|
</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.
|
||||||
|
|||||||
Reference in New Issue
Block a user