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:
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 2–3 (~266–390px)
|
// ≤1-octave shell renders ONE octave (~140px) instead of 2–3 (~266–390px)
|
||||||
// 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 2–3-octave window: the enlarged
|
// `size="full"` keeps the wide C-anchored 2–3-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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user