From aae69e8a7b75723abaad54af7374e16ac9da2c9d Mon Sep 17 00:00:00 2001
From: vadimwit
Date: Mon, 13 Jul 2026 12:58:39 +0100
Subject: [PATCH] =?UTF-8?q?feat(rail):=20voicings=20rail=20polish=20?=
=?UTF-8?q?=E2=80=94=204=20guitar=20shapes,=202x2=20mini=20piano,=20no=20p?=
=?UTF-8?q?lay=20buttons,=20dark=20scrollbars=20(task=20L-70)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
src/components/GlanceRail.jsx | 81 ++++-------
src/components/MiniPiano.jsx | 16 ++-
src/components/VoicingBrowser.jsx | 229 +++++++++++++++++-------------
src/index.css | 16 +++
4 files changed, 188 insertions(+), 154 deletions(-)
diff --git a/src/components/GlanceRail.jsx b/src/components/GlanceRail.jsx
index 87b0e1e..a5ba4bc 100644
--- a/src/components/GlanceRail.jsx
+++ b/src/components/GlanceRail.jsx
@@ -28,21 +28,20 @@
// "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).
+// gallery — the full VoicingBrowser (show={instrument}, dense) with the
+// station's OWN voicing passed as `recommended` so it renders as the
+// badged, accent-bordered first cell INSIDE the browser (no separate
+// own-cell, no dupe — dashboard-polish.md §1.1/§2.1). Guitar caps at
+// 4 recommended-first shapes on one line (§1); piano is a 2×2 of
+// `size="mini"` keyboards (§2). No ▶ anywhere (§3).
//
// Focus semantics (D-40 §4 — the pin, simplified): with everything always
// expanded there is nothing left to hold open, so tapping a row header TOGGLES
// that station as focused. The PARENT owns the state and the onFocusChord
// emission (the D-03 fretboard guide-tone contract, byte-compatible); a focused
// row shows an "aim on fretboard" chip; tap again (or the loop changes) to
-// clear. This component never emits focus-chord itself and NEVER triggers
-// audio on its own — every ▶ lives inside the gallery, behind a user gesture.
+// clear. This component never emits focus-chord itself and never triggers audio
+// — 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
// 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)
import { NOTES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory'
-import ChordDiagram from './ChordDiagram'
-import MiniPiano from './MiniPiano'
import VoicingBrowser from './VoicingBrowser'
const pcName = (pc) => NOTES[((pc % 12) + 12) % 12]
@@ -139,9 +136,12 @@ function TransitionChip({ pair, wraps }) {
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
+ // The station's own voicing — passed to VoicingBrowser as `recommended` so it
+ // renders as the badged, accent-bordered first cell inside the gallery (guitar:
+ // 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
// the softer accent border; everything else recedes to the 0.85 opacity floor
@@ -201,39 +201,19 @@ function StationRow({
- {/* ── Gallery: own-voicing cell FIRST (the recommended cell — full-accent
- border reads as "the answer", one-screen.md §4; same size, no new
- colour) + the full dense browser; cells WRAP, never scroll
- horizontally (D-40 §4). Browser basis 320px: a guitar own-cell
- (89px) still shares line one (89+6+320 = 415 ≤ 451) so the first
- visual line shows play + 3 shapes = 4 across, while a piano
- own-cell (≥156.4px) can never share it (156.4+6+320 = 482.4 > 468
- even scrollbar-free) — the recommended voicing sits alone on line
- one, prominence for free, and the gallery pairs at the FULL row
- interior below it (the 279.6+156.4 mixed-pair fit needs all 451px). ── */}
-
- {/* dense: the rail shows the mic-feedback microcopy once, below. */}
-
-
-
+ {/* ── Gallery (dashboard-polish.md §1/§2): the recommended voicing is now
+ cell #1 INSIDE the browser (badged "play", accent border — "the
+ answer" prominence the old own-cell had, no dupe). Guitar caps at 4
+ recommended-first cells on one line; piano is a 2×2 of mini
+ keyboards. Full row interior, no separate own-cell. ── */}
+
)
}
@@ -287,11 +267,10 @@ export default function GlanceRail({
))}
- {/* Mic-feedback microcopy — ONCE for the whole rail (D-31 §2.5); the
- gallery mounts run `dense` and suppress their per-mount copy. */}
+ {/* No ▶ anywhere anymore (dashboard-polish.md §3 — "leave them off,
+ better not"); the rail is purely visual and follows the loop. */}
- ▶ previews play through your speakers — while the mic is live, detection may
- hear them. Nothing plays automatically.
+ Voicings follow the loop — the playhead highlights the chord you're on.
)
diff --git a/src/components/MiniPiano.jsx b/src/components/MiniPiano.jsx
index cbe3c42..8c19aaf 100644
--- a/src/components/MiniPiano.jsx
+++ b/src/components/MiniPiano.jsx
@@ -24,6 +24,11 @@
// 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)
// 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
// view benefits from register context, and its output stays byte-identical
// to the D-10 behaviour.
@@ -177,6 +182,7 @@ function LegacyPiano({ rootPc, lh = [], rh = [] }) {
// ════════════════════════════════════════════════════════════════════════════
function VoicingPiano({ voicing, size }) {
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 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)
@@ -199,7 +205,7 @@ function VoicingPiano({ voicing, size }) {
: Math.max(1, Math.ceil((maxNote - octStart * 12) / 12))
// White keys: OCTAVES full octaves + 1 trailing C closing the top octave.
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 SVG_W = baseW * scale
const SVG_H = (WH + (isFull ? 26 : 4)) * scale
@@ -267,12 +273,12 @@ function VoicingPiano({ voicing, size }) {
{hl?.isBass && (
)}
{hl?.isRoot && (
+ textAnchor="middle" fill="white" fontSize={isMini ? 10 : 8} fontWeight="bold">
R
)}
@@ -292,12 +298,12 @@ function VoicingPiano({ voicing, size }) {
{hl?.isBass && (
)}
{hl?.isRoot && (
+ textAnchor="middle" fill="white" fontSize={isMini ? 8 : 6} fontWeight="bold">
R
)}
diff --git a/src/components/VoicingBrowser.jsx b/src/components/VoicingBrowser.jsx
index 0002b01..e9a1c77 100644
--- a/src/components/VoicingBrowser.jsx
+++ b/src/components/VoicingBrowser.jsx
@@ -9,20 +9,19 @@
// Guitar section — every placeable `GUITAR_SHAPES[quality]` entry from
// 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 +
-// + its own ▶.
+// .
// Piano section — all four src/lib/piano.js `pianoVoicing` styles
// (root / shell / rootlessA / rootlessB), each cell = the voicing's honest
-// label (e.g. "rootless A (3-5-7-9)") + +
-// its own ▶.
+// label (e.g. "rootless A (3-5-7-9)") + .
//
-// Playback: src/lib/chordAudio.js (L-20). ONE live {stop} handle for the whole
-// gallery — any ▶ stops the previous sound before starting (chord change and
-// unmount also stop it), so previews never layer. First ▶ click is the user
-// gesture that lazily creates the AudioContext.
+// No playback (L-70 / dashboard-polish.md §3): the per-cell ▶ preview was removed
+// from EVERY mount — the user settled "leave them off, better not." The component
+// is now purely visual; src/lib/chordAudio.js is no longer imported here.
//
// Mount points (wired by L-21/L-22, NOT here): Knowledge Center Voicings
// 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
// 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
// ≤ 451px row interior even with a classic Windows scrollbar in
// 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 MiniPiano from './MiniPiano'
import { GUITAR_SHAPES } from '../lib/voicings'
import { pianoVoicing } from '../lib/piano'
-import { playVoicing, guitarShapeToNotes } from '../lib/chordAudio'
import { NOTES, CHORD_TYPES } from '../lib/theory'
// 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). [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.
function chordName(rootPc, quality) {
const q = CHORD_TYPES[quality] ? quality : 'maj'
@@ -98,28 +160,6 @@ function chordName(rootPc, quality) {
// ─── 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 (
-
- )
-}
-
function SectionHeading({ children }) {
return (
@@ -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;
// 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
// 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 (
-
-
- {label}
-
+
+ {recommended ? (
+
+
+ play
+
+ {label}
+
+ ) : (
+
+ {label}
+
+ )}
{/* 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
- viewports rather than letting it break the wrap layout. */}
-
{children}
-
+ viewports rather than letting it break the wrap layout. `dark-scroll`
+ (L-70 §4) dresses this scroller on the dashboard rail only (dense) —
+ the KC/modal keep the OS default, so non-dense stays byte-identical. */}
+
{children}
)
}
// ─── 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 name = chordName(pc, quality)
- const chordKey = `${pc}:${quality}`
// 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
// (neither gallery is honest for a bassist — the one-liner below renders
// 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.
+ // shows both. Hooks stay unconditional.
const showGuitar = show !== 'piano' && 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(
() =>
PIANO_STYLES.map((style) => ({
@@ -177,38 +246,10 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
})),
[pc, quality],
)
-
- // One live playback handle for the whole gallery: any new play (or chord
- // change, or unmount) stops the previous sound first — the L-20 {stop}
- // contract, so previews never layer or leak.
- 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,
- })
- }
+ // Piano "recommended" (dashboard rail): the station's authored voicing matches
+ // one of the four styles → that 2×2 cell gets the accent border (§2.1). Guitar-
+ // shape `recommended` objects have no `.style`, so this stays null off-rail.
+ const recStyle = recommended && typeof recommended.style === 'string' ? recommended.style : null
return (
)}
+ {/* 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. */}
{/* pianoVoicing() output carries no rootPc, and without it VoicingPiano
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
playPiano(voicing)}
+ recommended={recStyle !== null && style === recStyle}
dense={dense}
>
-
+
))}
@@ -297,16 +340,6 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
)}
- {/* 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) && (
-
- Previews play through your speakers — while the mic is live, detection may
- hear them.
-
- )}
)
}
diff --git a/src/index.css b/src/index.css
index a451aff..a376544 100644
--- a/src/index.css
+++ b/src/index.css
@@ -6,3 +6,19 @@ body {
color: #f5f5f5;
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; }