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). ── */} -
- {(ownGuitar || ownPiano) && ( -
-
- {ownGuitar && ( - - play - - )} - {ownGuitar ? ownGuitar.label : ownPiano.label} -
- {ownGuitar ? ( - - ) : ( - - )} -
- )} -
- {/* 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 (
@@ -241,8 +282,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo playGuitar(shape)} + recommended={guitarView.recLabel !== null && shape.label === guitarView.recLabel} dense={dense} > @@ -265,10 +305,14 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
)} + {/* 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; }