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>
This commit is contained in:
vadimwit
2026-07-13 12:58:39 +01:00
parent c16e9f1827
commit aae69e8a7b
4 changed files with 188 additions and 154 deletions
+30 -51
View File
@@ -28,21 +28,20 @@
// "loop"). This is where the retired RoadmapTrack's education // "loop"). This is where the retired RoadmapTrack's education
// folds in (D-40 §2) — theory.js `guideTones` / `voiceLeadingPairs` // folds in (D-40 §2) — theory.js `guideTones` / `voiceLeadingPairs`
// / `soloScale`, read-only imports. // / `soloScale`, read-only imports.
// gallery — first cell = the station's OWN voicing (guitar: the KB play's // gallery — the full VoicingBrowser (show={instrument}, dense) with the
// recommended shape badged "play", when present; piano: the // station's OWN voicing passed as `recommended` so it renders as the
// threaded/authored voicing labeled honestly, e.g. "LH 3-5-7-9" — // badged, accent-bordered first cell INSIDE the browser (no separate
// the accordion's collapsed-thumb value survives here) + the full // own-cell, no dupe — dashboard-polish.md §1.1/§2.1). Guitar caps at
// VoicingBrowser gallery (show={instrument}, dense). Cells // 4 recommended-first shapes on one line (§1); piano is a 2×2 of
// FLEX-WRAP — rows never scroll horizontally; the piano worst case // `size="mini"` keyboards (§2). No ▶ anywhere (§3).
// (~1,470px of cells) wraps to a second cell line instead (§4).
// //
// Focus semantics (D-40 §4 — the pin, simplified): with everything always // 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 // 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 // that station as focused. The PARENT owns the state and the onFocusChord
// emission (the D-03 fretboard guide-tone contract, byte-compatible); a focused // 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 // 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 // clear. This component never emits focus-chord itself and never triggers audio
// audio on its own — every ▶ lives inside the gallery, behind a user gesture. // — 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 // 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 // scrollIntoView's nearest scroller is the DOCUMENT — it would yank the whole
@@ -61,8 +60,6 @@
// keyMode — key mode name (soloScale's minor-key dominant nudge) // keyMode — key mode name (soloScale's minor-key dominant nudge)
import { NOTES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory' import { NOTES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory'
import ChordDiagram from './ChordDiagram'
import MiniPiano from './MiniPiano'
import VoicingBrowser from './VoicingBrowser' import VoicingBrowser from './VoicingBrowser'
const pcName = (pc) => NOTES[((pc % 12) + 12) % 12] const pcName = (pc) => NOTES[((pc % 12) + 12) % 12]
@@ -139,9 +136,12 @@ function TransitionChip({ pair, wraps }) {
function StationRow({ function StationRow({
st, isNow, isNext, isFocused, onToggleFocus, instrument, keyRoot, keyMode, rail, wraps, st, isNow, isNext, isFocused, onToggleFocus, instrument, keyRoot, keyMode, rail, wraps,
}) { }) {
// The station's own voicing — the first gallery cell (D-40 §4). // The station's own voicing — passed to VoicingBrowser as `recommended` so it
const ownGuitar = instrument === 'guitar' && st.shape ? st.shape : null // renders as the badged, accent-bordered first cell inside the gallery (guitar:
const ownPiano = instrument === 'piano' && st.voicing ? st.voicing : null // 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 // 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 // the softer accent border; everything else recedes to the 0.85 opacity floor
@@ -201,39 +201,19 @@ function StationRow({
<TransitionChip pair={rail} wraps={wraps} /> <TransitionChip pair={rail} wraps={wraps} />
</div> </div>
{/* ── Gallery: own-voicing cell FIRST (the recommended cell — full-accent {/* ── Gallery (dashboard-polish.md §1/§2): the recommended voicing is now
border reads as "the answer", one-screen.md §4; same size, no new cell #1 INSIDE the browser (badged "play", accent border — "the
colour) + the full dense browser; cells WRAP, never scroll answer" prominence the old own-cell had, no dupe). Guitar caps at 4
horizontally (D-40 §4). Browser basis 320px: a guitar own-cell recommended-first cells on one line; piano is a 2×2 of mini
(89px) still shares line one (89+6+320 = 415 ≤ 451) so the first keyboards. Full row interior, no separate own-cell. ── */}
visual line shows play + 3 shapes = 4 across, while a piano <VoicingBrowser
own-cell (≥156.4px) can never share it (156.4+6+320 = 482.4 > 468 rootPc={st.rootPc}
even scrollbar-free) — the recommended voicing sits alone on line quality={st.quality}
one, prominence for free, and the gallery pairs at the FULL row show={instrument}
interior below it (the 279.6+156.4 mixed-pair fit needs all 451px). ── */} dense
<div className="flex flex-wrap items-start gap-1.5"> recommended={recommended}
{(ownGuitar || ownPiano) && ( max={4}
<figure className="flex shrink-0 flex-col items-center gap-1.5 rounded-md border border-accent bg-surface p-1.5"> />
<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-[320px]">
{/* dense: the rail shows the mic-feedback microcopy once, below. */}
<VoicingBrowser rootPc={st.rootPc} quality={st.quality} show={instrument} dense />
</div>
</div>
</div> </div>
) )
} }
@@ -287,11 +267,10 @@ export default function GlanceRail({
))} ))}
</div> </div>
{/* Mic-feedback microcopy — ONCE for the whole rail (D-31 §2.5); the {/* No ▶ anywhere anymore (dashboard-polish.md §3 — "leave them off,
gallery mounts run `dense` and suppress their per-mount copy. */} better not"); the rail is purely visual and follows the loop. */}
<p className="mt-2 text-[11px] text-gray-500"> <p className="mt-2 text-[11px] text-gray-500">
previews play through your speakers while the mic is live, detection may Voicings follow the loop the playhead highlights the chord you're on.
hear them. Nothing plays automatically.
</p> </p>
</section> </section>
) )
+11 -5
View File
@@ -24,6 +24,11 @@
// octave-end at-or-above the highest (plus the closing high C) — so a // octave-end at-or-above the highest (plus the closing high C) — so a
// ≤1-octave shell renders ONE octave (~140px) instead of 23 (~266390px) // ≤1-octave shell renders ONE octave (~140px) instead of 23 (~266390px)
// and Jam Guide station thumbs sit level with guitar ChordDiagram thumbs. // and Jam Guide station thumbs sit level with guitar ChordDiagram thumbs.
// `size="mini"` (L-70, dashboard-polish.md §2) is the SAME cropped window at
// scale 0.60 (vs thumb 0.8) so two cells fit one grid column of the rail's
// ~451px interior (2-octave cell 213.2px < 222.5px); it bumps the in-SVG "R"
// glyph (8→10 vu) and the bass-ring stroke (2→2.5) so both stay legible at
// the smaller render. `thumb`/`full` are byte-untouched — `mini` is additive.
// `size="full"` keeps the wide C-anchored 23-octave window: the enlarged // `size="full"` keeps the wide C-anchored 23-octave window: the enlarged
// view benefits from register context, and its output stays byte-identical // view benefits from register context, and its output stays byte-identical
// to the D-10 behaviour. // to the D-10 behaviour.
@@ -177,6 +182,7 @@ function LegacyPiano({ rootPc, lh = [], rh = [] }) {
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function VoicingPiano({ voicing, size }) { function VoicingPiano({ voicing, size }) {
const isFull = size === 'full' const isFull = size === 'full'
const isMini = size === 'mini' // L-70: cropped-thumb window at 0.60 for the dashboard 2×2 grid
const notes = Array.isArray(voicing?.notes) ? voicing.notes : [] const notes = Array.isArray(voicing?.notes) ? voicing.notes : []
const rootPc = ((voicing?.rootPc ?? (notes.length ? notes[0] : 0)) % 12 + 12) % 12 const rootPc = ((voicing?.rootPc ?? (notes.length ? notes[0] : 0)) % 12 + 12) % 12
const bass = typeof voicing?.bass === 'number' ? voicing.bass : (notes.length ? Math.min(...notes) : null) const bass = typeof voicing?.bass === 'number' ? voicing.bass : (notes.length ? Math.min(...notes) : null)
@@ -199,7 +205,7 @@ function VoicingPiano({ voicing, size }) {
: Math.max(1, Math.ceil((maxNote - octStart * 12) / 12)) : Math.max(1, Math.ceil((maxNote - octStart * 12) / 12))
// White keys: OCTAVES full octaves + 1 trailing C closing the top octave. // White keys: OCTAVES full octaves + 1 trailing C closing the top octave.
const TOTAL_WHITES = WHITE_PCS.length * OCTAVES + 1 const TOTAL_WHITES = WHITE_PCS.length * OCTAVES + 1
const scale = isFull ? 1 : 0.8 const scale = isFull ? 1 : isMini ? 0.6 : 0.8
const baseW = WW * TOTAL_WHITES + 2 const baseW = WW * TOTAL_WHITES + 2
const SVG_W = baseW * scale const SVG_W = baseW * scale
const SVG_H = (WH + (isFull ? 26 : 4)) * scale const SVG_H = (WH + (isFull ? 26 : 4)) * scale
@@ -267,12 +273,12 @@ function VoicingPiano({ voicing, size }) {
{hl?.isBass && ( {hl?.isBass && (
<rect <rect
x={x + 1} y={2} width={WW - 3} height={WH - 2} x={x + 1} y={2} width={WW - 3} height={WH - 2}
rx={2} fill="none" stroke={BASS_RING} strokeWidth={2} rx={2} fill="none" stroke={BASS_RING} strokeWidth={isMini ? 2.5 : 2}
/> />
)} )}
{hl?.isRoot && ( {hl?.isRoot && (
<text x={x + (WW - 1) / 2} y={WH - 8} <text x={x + (WW - 1) / 2} y={WH - 8}
textAnchor="middle" fill="white" fontSize={8} fontWeight="bold"> textAnchor="middle" fill="white" fontSize={isMini ? 10 : 8} fontWeight="bold">
R R
</text> </text>
)} )}
@@ -292,12 +298,12 @@ function VoicingPiano({ voicing, size }) {
{hl?.isBass && ( {hl?.isBass && (
<rect <rect
x={x + 1} y={2} width={BW - 2} height={BH - 2} x={x + 1} y={2} width={BW - 2} height={BH - 2}
rx={2} fill="none" stroke={BASS_RING} strokeWidth={2} rx={2} fill="none" stroke={BASS_RING} strokeWidth={isMini ? 2.5 : 2}
/> />
)} )}
{hl?.isRoot && ( {hl?.isRoot && (
<text x={x + BW / 2} y={BH - 5} <text x={x + BW / 2} y={BH - 5}
textAnchor="middle" fill="white" fontSize={6} fontWeight="bold"> textAnchor="middle" fill="white" fontSize={isMini ? 8 : 6} fontWeight="bold">
R R
</text> </text>
)} )}
+125 -92
View File
@@ -9,20 +9,19 @@
// Guitar section — every placeable `GUITAR_SHAPES[quality]` entry from // Guitar section — every placeable `GUITAR_SHAPES[quality]` entry from
// src/lib/voicings.js (open shapes only in their native key, movable shapes // src/lib/voicings.js (open shapes only in their native key, movable shapes
// only when the whole grip fits under fret 15), each cell = shape label + // only when the whole grip fits under fret 15), each cell = shape label +
// <ChordDiagram size="thumb"/> + its own ▶. // <ChordDiagram size="thumb"/>.
// Piano section — all four src/lib/piano.js `pianoVoicing` styles // Piano section — all four src/lib/piano.js `pianoVoicing` styles
// (root / shell / rootlessA / rootlessB), each cell = the voicing's honest // (root / shell / rootlessA / rootlessB), each cell = the voicing's honest
// label (e.g. "rootless A (3-5-7-9)") + <MiniPiano voicing size="thumb"/> + // label (e.g. "rootless A (3-5-7-9)") + <MiniPiano voicing/>.
// its own ▶.
// //
// Playback: src/lib/chordAudio.js (L-20). ONE live {stop} handle for the whole // No playback (L-70 / dashboard-polish.md §3): the per-cell ▶ preview was removed
// gallery — any ▶ stops the previous sound before starting (chord change and // from EVERY mount — the user settled "leave them off, better not." The component
// unmount also stop it), so previews never layer. First ▶ click is the user // is now purely visual; src/lib/chordAudio.js is no longer imported here.
// gesture that lazily creates the AudioContext.
// //
// Mount points (wired by L-21/L-22, NOT here): Knowledge Center Voicings // Mount points (wired by L-21/L-22, NOT here): Knowledge Center Voicings
// section, ChordDetailModal Guitar/Piano tabs (show="guitar"/"piano", L-25), // section, ChordDetailModal Guitar/Piano tabs (show="guitar"/"piano", L-25),
// and the Jam Guide station-enlarge view. This component stays pure & prop-driven. // and the Jam Guide station rail / heard-live view (dense). This component stays
// pure & prop-driven.
// //
// Layout: each instrument section is a flex-wrap gallery of fixed-content-width // Layout: each instrument section is a flex-wrap gallery of fixed-content-width
// cells, so it reflows to fewer columns (down to one cell per row) inside a // cells, so it reflows to fewer columns (down to one cell per row) inside a
@@ -52,14 +51,26 @@
// two-octave + one-octave piano pair fit (279.6 + 6 + 156.4 = 442 // two-octave + one-octave piano pair fit (279.6 + 6 + 156.4 = 442
// ≤ 451px row interior even with a classic Windows scrollbar in // ≤ 451px row interior even with a classic Windows scrollbar in
// the rail's scroller; it was 452 vs 456, a ~4px squeak, before). // the rail's scroller; it was 452 vs 456, a ~4px squeak, before).
// Every pre-existing mount renders identically with no prop. // L-70 (dashboard-polish.md §2) additionally renders the piano
// styles as a 2×2 grid of `size="mini"` keyboards under dense, and
// applies the `.dark-scroll` utility to each cell's scroller. Every
// pre-existing mount renders identically with no prop.
// recommended — a shape object (guitar) or voicing object (piano) marking the
// KB play's own voicing (dashboard rail only). Guitar: matched into
// the list by `label`, floated to cell #1, badged "play" + accent
// border, and the list reordered recommended-first (§1.2). Piano:
// matched by `voicing.style`, that 2×2 cell gets the accent border
// (§2.1). Default null → no highlight, order untouched.
// max — cap the guitar list to this many cells (dashboard rail passes 4).
// Undefined → no cap (all placeable shapes). Off-rail mounts pass
// neither `recommended` nor `max`, so the guitar list keeps its raw
// `matchingShapes` declared order — byte-identical to today.
import { useEffect, useMemo, useRef } from 'react' import { useMemo } from 'react'
import ChordDiagram from './ChordDiagram' import ChordDiagram from './ChordDiagram'
import MiniPiano from './MiniPiano' import MiniPiano from './MiniPiano'
import { GUITAR_SHAPES } from '../lib/voicings' import { GUITAR_SHAPES } from '../lib/voicings'
import { pianoVoicing } from '../lib/piano' import { pianoVoicing } from '../lib/piano'
import { playVoicing, guitarShapeToNotes } from '../lib/chordAudio'
import { NOTES, CHORD_TYPES } from '../lib/theory' import { NOTES, CHORD_TYPES } from '../lib/theory'
// Standard-tuning open-string pitch classes, low-E first (mirrors ChordDiagram). // Standard-tuning open-string pitch classes, low-E first (mirrors ChordDiagram).
@@ -90,6 +101,57 @@ function matchingShapes(quality, rootPc) {
}) })
} }
// Lowest base fret of a movable shape under ChordDiagram's placement convention
// (root-at-open-string → fret-12 barre). Open shapes sit at the nut (0).
function baseFretOf(shape, rootPc) {
if (Array.isArray(shape.frets)) return 0
const idx = 6 - shape.rootStr
let bf = mod12(rootPc - (OPEN_PCS[idx] ?? 4))
if (bf === 0) bf = 12
return bf
}
// Count of muted strings in a shape's grip (fewer = a fuller voicing wins ties).
function mutedCount(shape) {
const arr = Array.isArray(shape.frets) ? shape.frets : shape.offsets
return Array.isArray(arr) ? arr.filter((v) => v === 'x').length : 0
}
// The rail's ≤max, recommended-first ordering (dashboard-polish.md §1.2). Only
// invoked when `recommended` and/or `max` are supplied (the dashboard rail) —
// non-dense mounts skip it entirely and keep `matchingShapes` declared order,
// so VoicingsSection/ChordDetailModal stay byte-identical.
// 1. recommended first (matched into the placeable list by label; prepended
// even if it was filtered out as unplaceable);
// 2. open-position forms (Array.isArray(frets));
// 3. movable/barre by lowest base fret ascending;
// 4. tiebreak: fewer muted strings, then declared order in GUITAR_SHAPES.
// Then slice to `max` (recommended deduped so it never repeats). <max placeable
// shapes just show what exist — the rule caps, never pads.
function orderGuitarShapes(shapes, rootPc, recommended, max) {
const declaredIndex = new Map(shapes.map((s, i) => [s, i]))
let rec = null
if (recommended?.label) {
rec = shapes.find((s) => s.label === recommended.label) ?? recommended
}
const rest = shapes.filter((s) => s.label !== rec?.label)
rest.sort((a, b) => {
const aOpen = Array.isArray(a.frets) ? 0 : 1
const bOpen = Array.isArray(b.frets) ? 0 : 1
if (aOpen !== bOpen) return aOpen - bOpen
if (aOpen === 1) {
const bf = baseFretOf(a, rootPc) - baseFretOf(b, rootPc)
if (bf !== 0) return bf
}
const mc = mutedCount(a) - mutedCount(b)
if (mc !== 0) return mc
return (declaredIndex.get(a) ?? 0) - (declaredIndex.get(b) ?? 0)
})
const ordered = rec ? [rec, ...rest] : rest
const list = Number.isFinite(max) ? ordered.slice(0, max) : ordered
return { list, recLabel: rec?.label ?? null }
}
// "C", "Cm7", "Cmaj7"… — display name from the app's canonical chord model. // "C", "Cm7", "Cmaj7"… — display name from the app's canonical chord model.
function chordName(rootPc, quality) { function chordName(rootPc, quality) {
const q = CHORD_TYPES[quality] ? quality : 'maj' const q = CHORD_TYPES[quality] ? quality : 'maj'
@@ -98,28 +160,6 @@ function chordName(rootPc, quality) {
// ─── Small presentational atoms ─────────────────────────────────────────────── // ─── Small presentational atoms ───────────────────────────────────────────────
// Per-cell ▶. Small accent text sits on bg-surface (#0f0f0f), where accent
// #a855f7 measures ≈4.8:1 — AA for small text (surface-background rule).
function PlayButton({ ariaLabel, onClick }) {
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className={
'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-accent ' +
'bg-surface px-2.5 text-xs font-semibold text-accent outline-none transition ' +
'hover:bg-accent hover:text-black focus-visible:ring-2 focus-visible:ring-accent'
}
>
<svg aria-hidden="true" viewBox="0 0 12 12" className="h-3 w-3 fill-current">
<path d="M2.5 1.5v9l8-4.5z" />
</svg>
Play
</button>
)
}
function SectionHeading({ children }) { function SectionHeading({ children }) {
return ( return (
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-gray-400"> <h4 className="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
@@ -128,47 +168,76 @@ function SectionHeading({ children }) {
) )
} }
// One gallery cell: label on top, diagram thumb, its own ▶ underneath. // One gallery cell: label on top, diagram/keyboard thumb underneath.
// bg-surface inside the bg-panel section gives the cells a quiet inlay border; // bg-surface inside the bg-panel section gives the cells a quiet inlay border;
// label is gray-300 on surface (AA comfortable at 11px semibold). // label is gray-300 on surface (AA comfortable at 11px semibold).
// `dense` (D-51): p-1.5 instead of p-2 — 4px off each cell's box width, part of // `dense` (D-51): p-1.5 instead of p-2 — 4px off each cell's box width, part of
// the right-column margin-hardening. Non-dense output is byte-identical. // the right-column margin-hardening. Non-dense output is byte-identical.
function GalleryCell({ label, playLabel, onPlay, dense = false, children }) { // `recommended` (L-70, dashboard-polish.md §1.2): the KB play's shape/style —
// accent border + a "play" badge (finger-this, not audio; ▶ is gone), the
// prominence the old GlanceRail own-cell carried. Only ever set under the
// dashboard rail, so non-recommended cells stay byte-identical.
function GalleryCell({ label, recommended = false, dense = false, children }) {
return ( return (
<figure className={`flex min-w-0 flex-col items-center gap-1.5 rounded-md border border-border bg-surface ${dense ? 'p-1.5' : 'p-2'}`}> <figure className={`flex min-w-0 flex-col items-center gap-1.5 rounded-md border ${recommended ? 'border-accent' : 'border-border'} bg-surface ${dense ? 'p-1.5' : 'p-2'}`}>
{recommended ? (
<figcaption
className="flex max-w-full items-center gap-1.5 text-center text-[11px] font-medium leading-tight text-gray-300"
title={label}
>
<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">{label}</span>
</figcaption>
) : (
<figcaption <figcaption
className="max-w-full break-words text-center text-[11px] font-medium leading-tight text-gray-300" className="max-w-full break-words text-center text-[11px] font-medium leading-tight text-gray-300"
title={label} title={label}
> >
{label} {label}
</figcaption> </figcaption>
)}
{/* Scroll guard: MiniPiano's SVG has a fixed pixel width (up to ~266px {/* Scroll guard: MiniPiano's SVG has a fixed pixel width (up to ~266px
for a 2-octave thumb window); scroll inside the cell on very narrow for a 2-octave thumb window); scroll inside the cell on very narrow
viewports rather than letting it break the wrap layout. */} viewports rather than letting it break the wrap layout. `dark-scroll`
<div className="max-w-full overflow-x-auto">{children}</div> (L-70 §4) dresses this scroller on the dashboard rail only (dense) —
<PlayButton ariaLabel={playLabel} onClick={onPlay} /> the KC/modal keep the OS default, so non-dense stays byte-identical. */}
<div className={`max-w-full overflow-x-auto${dense ? ' dark-scroll' : ''}`}>{children}</div>
</figure> </figure>
) )
} }
// ─── The gallery ────────────────────────────────────────────────────────────── // ─── The gallery ──────────────────────────────────────────────────────────────
export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'both', dense = false }) { export default function VoicingBrowser({
rootPc = 0, quality = 'maj', show = 'both', dense = false, recommended = null, max,
}) {
const pc = mod12(Number.isFinite(rootPc) ? rootPc : 0) const pc = mod12(Number.isFinite(rootPc) ? rootPc : 0)
const name = chordName(pc, quality) const name = chordName(pc, quality)
const chordKey = `${pc}:${quality}`
// Section gating (D-23; 'bass' added by D-41 per D-40 §3). 'guitar' hides // 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 // the piano section, 'piano' hides the guitar section, 'bass' hides BOTH
// (neither gallery is honest for a bassist — the one-liner below renders // (neither gallery is honest for a bassist — the one-liner below renders
// instead, so the dock's VoicingsSection under the global BASS selector // instead, so the dock's VoicingsSection under the global BASS selector
// stops showing guitar+piano). Anything else (incl. the 'both' default) // stops showing guitar+piano). Anything else (incl. the 'both' default)
// shows both. Hooks stay unconditional; the shared stop-handle discipline // shows both. Hooks stay unconditional.
// (stop on chord change / unmount) is untouched by hiding a section.
const showGuitar = show !== 'piano' && show !== 'bass' const showGuitar = show !== 'piano' && show !== 'bass'
const showPiano = show !== 'guitar' && show !== 'bass' const showPiano = show !== 'guitar' && show !== 'bass'
const guitarShapes = useMemo(() => matchingShapes(quality, pc), [quality, pc]) const allGuitarShapes = useMemo(() => matchingShapes(quality, pc), [quality, pc])
// Guitar list: the dashboard rail (recommended and/or max supplied) reorders
// recommended-first + caps to `max` (§1.2); every other mount keeps the raw
// `matchingShapes` declared order untouched — byte-identical to today.
const guitarView = useMemo(() => {
if (!recommended && !Number.isFinite(max)) {
return { list: allGuitarShapes, recLabel: null }
}
return orderGuitarShapes(allGuitarShapes, pc, recommended, max)
}, [allGuitarShapes, pc, recommended, max])
const guitarShapes = guitarView.list
const pianoOptions = useMemo( const pianoOptions = useMemo(
() => () =>
PIANO_STYLES.map((style) => ({ PIANO_STYLES.map((style) => ({
@@ -177,38 +246,10 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
})), })),
[pc, quality], [pc, quality],
) )
// Piano "recommended" (dashboard rail): the station's authored voicing matches
// One live playback handle for the whole gallery: any new play (or chord // one of the four styles → that 2×2 cell gets the accent border (§2.1). Guitar-
// change, or unmount) stops the previous sound first — the L-20 {stop} // shape `recommended` objects have no `.style`, so this stays null off-rail.
// contract, so previews never layer or leak. const recStyle = recommended && typeof recommended.style === 'string' ? recommended.style : null
const handleRef = useRef(null)
const stopCurrent = () => {
handleRef.current?.stop()
handleRef.current = null
}
// Chord change → cleanup silences the old preview; same cleanup covers unmount.
useEffect(() => stopCurrent, [chordKey])
// Known advisory (L-20 gate): when a movable shape's root lands on an open
// string (base fret 0), ChordDiagram draws the fret-12 octave barre while
// guitarShapeToNotes places the grip at the open position — the SAME chord,
// one octave lower than drawn. Deliberately left as-is on both sides.
function playGuitar(shape) {
stopCurrent()
handleRef.current = playVoicing(guitarShapeToNotes(shape, { rootPc: pc }), {
strumMs: 45, // a light strum reads "guitar"
durMs: 1800,
})
}
function playPiano(voicing) {
stopCurrent()
handleRef.current = playVoicing(voicing?.notes ?? [], {
strumMs: 15, // near-block chord reads "piano"
durMs: 1800,
})
}
return ( return (
<div className="flex w-full min-w-0 flex-wrap gap-2"> <div className="flex w-full min-w-0 flex-wrap gap-2">
@@ -241,8 +282,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
<GalleryCell <GalleryCell
key={`${shape.label}-${i}`} key={`${shape.label}-${i}`}
label={shape.label} label={shape.label}
playLabel={`Play ${name}${shape.label} guitar voicing`} recommended={guitarView.recLabel !== null && shape.label === guitarView.recLabel}
onPlay={() => playGuitar(shape)}
dense={dense} dense={dense}
> >
<ChordDiagram shape={shape} rootPc={pc} size="thumb" /> <ChordDiagram shape={shape} rootPc={pc} size="thumb" />
@@ -265,10 +305,14 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
</div> </div>
)} )}
{/* dense (dashboard rail, §2.1): the four styles render as a tidy 2×2
grid of compact `size="mini"` keyboards (two per column fit the
~451px row interior). Non-dense keeps today's flex-wrap gallery at
size="thumb" — byte-identical. */}
<div <div
role="group" role="group"
aria-label={`${name} piano voicings — every style shown, each playable`} aria-label={`${name} piano voicings — every style shown, each playable`}
className={`flex flex-wrap items-stretch ${dense ? 'gap-1.5' : 'gap-2'}`} className={dense ? 'grid grid-cols-2 gap-1.5' : 'flex flex-wrap items-stretch gap-2'}
> >
{/* pianoVoicing() output carries no rootPc, and without it VoicingPiano {/* pianoVoicing() output carries no rootPc, and without it VoicingPiano
falls back to the LOWEST voice for its "R" badge — wrong for rootless falls back to the LOWEST voice for its "R" badge — wrong for rootless
@@ -277,11 +321,10 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
<GalleryCell <GalleryCell
key={style} key={style}
label={voicing.label} label={voicing.label}
playLabel={`Play ${name}${voicing.label} piano voicing`} recommended={recStyle !== null && style === recStyle}
onPlay={() => playPiano(voicing)}
dense={dense} dense={dense}
> >
<MiniPiano voicing={{ ...voicing, rootPc: pc }} size="thumb" /> <MiniPiano voicing={{ ...voicing, rootPc: pc }} size={dense ? 'mini' : 'thumb'} />
</GalleryCell> </GalleryCell>
))} ))}
</div> </div>
@@ -297,16 +340,6 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
</p> </p>
)} )}
{/* Mic-feedback caveat, per the L-20 header + D-20 §3 (microcopy tier).
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.
</p>
)}
</div> </div>
) )
} }
+16
View File
@@ -6,3 +6,19 @@ body {
color: #f5f5f5; color: #f5f5f5;
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
} }
/* Thin, dark, overlay-feel scrollbars for the jam dashboard's scrollers
(dashboard-polish.md §4). Scoped (a class), NOT global — the KnowledgeDock /
Debug / Tuner / Settings keep the OS default. Raw hex is unavoidable here:
::-webkit-scrollbar pseudo-elements are not reachable by Tailwind utilities;
the values mirror the design tokens (border #2a2a2a, panel #1a1a1a). */
.dark-scroll {
scrollbar-width: thin; /* Firefox */
scrollbar-color: #2a2a2a transparent; /* thumb = border token · track transparent */
}
.dark-scroll::-webkit-scrollbar { width: 8px; height: 8px; } /* WebKit/Blink */
.dark-scroll::-webkit-scrollbar-track { background: transparent; }
.dark-scroll::-webkit-scrollbar-thumb {
background: #2a2a2a; border-radius: 4px; /* border token */
}
.dark-scroll::-webkit-scrollbar-thumb:hover { background: #3a3a3a; }