// VoicingBrowser — a playable voicing GALLERY for ONE chord (task D-30; was the
// chip-switched browser of D-21/D-23).
//
// For a given { rootPc, quality } it shows every way the KB knows to voice that
// chord — ALL AT ONCE, no chips, no selection state (user directive 2026-07-10:
// "see all the variations G shape, C shape, etc in one view without having to
// push a button. so they all line up next to each other"):
//
// 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 +
// .
// 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)") + .
//
// 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 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
// narrow modal or the Jam Guide dock — no horizontal scroll needed except the
// per-cell guard around the widest MiniPiano thumbs (~266px for 2-octave
// rootless voicings).
//
// Props:
// rootPc — chord root pitch class 0–11 (default 0 = C)
// quality — CHORD_TYPES key; unknown values fall back to 'maj'
// (matching voicings.js / piano.js behaviour)
// show — 'guitar' | 'piano' | 'both' (default 'both', task D-23): which
// instrument section(s) to render. 'bass' (task D-41, D-40 §3)
// renders NEITHER gallery — guitar shapes are not bass patterns and
// pianoVoicing is piano, so showing either under the global BASS
// selector would lie; an honest one-liner renders instead. Any
// OTHER value still falls back to both, so every pre-existing
// mount renders identically with no prop.
// dense — boolean (default false, task L-33 — additive per D-31 §5; D-41
// restyled it for the all-expanded GlanceRail rows): drops the
// section chrome (border/panel background/heading — the row header
// already names the chord) and suppresses the per-mount
// mic-feedback microcopy (the rail shows it ONCE for the whole
// rail, D-31 §2.5). D-51 (one-screen.md §4) additionally tightens
// the DENSE cell spacing for the 500px right column — cell p-2 →
// p-1.5 and gallery gap-2 → gap-1.5 — which hardens the column's
// 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).
// 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 { useMemo } from 'react'
import ChordDiagram from './ChordDiagram'
import MiniPiano from './MiniPiano'
import { GUITAR_SHAPES } from '../lib/voicings'
import { pianoVoicing } from '../lib/piano'
import { NOTES, CHORD_TYPES } from '../lib/theory'
// Standard-tuning open-string pitch classes, low-E first (mirrors ChordDiagram).
const OPEN_PCS = [4, 9, 2, 7, 11, 4]
const PIANO_STYLES = ['root', 'shell', 'rootlessA', 'rootlessB']
const mod12 = (n) => ((n % 12) + 12) % 12
// The shapes of `quality` that can actually be shown for this root:
// - open shapes only when their native root matches (onlyRoot === rootPc);
// - movable shapes only when every fretted string lands in 0–15 under
// ChordDiagram's placement convention (root-at-open-string → fret-12 barre).
// Unknown quality falls back to maj (same fallback voicings.js itself uses).
function matchingShapes(quality, rootPc) {
const shapes = GUITAR_SHAPES[quality] ?? GUITAR_SHAPES.maj
return shapes.filter((shape) => {
if (Array.isArray(shape.frets)) {
// Open shape: fixed grip, valid only in its native key.
return shape.onlyRoot === undefined || shape.onlyRoot === rootPc
}
if (!Array.isArray(shape.offsets) || !Number.isFinite(shape.rootStr)) return false
const idx = 6 - shape.rootStr // rootStr 6 = low E → low-E-first index 0
let baseFret = mod12(rootPc - (OPEN_PCS[idx] ?? 4))
if (baseFret === 0) baseFret = 12 // ChordDiagram's octave-barre placement
const abs = shape.offsets.filter((o) => typeof o === 'number').map((o) => baseFret + o)
if (abs.length === 0) return false
return Math.min(...abs) >= 0 && Math.max(...abs) <= 15
})
}
// 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'
return `${NOTES[mod12(rootPc)]}${CHORD_TYPES[q].suffix}`
}
// ─── Small presentational atoms ───────────────────────────────────────────────
function SectionHeading({ children }) {
return (
{children}
)
}
// 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.
// `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 (
{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. `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, recommended = null, max,
}) {
const pc = mod12(Number.isFinite(rootPc) ? rootPc : 0)
const name = chordName(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.
const showGuitar = show !== 'piano' && show !== 'bass'
const showPiano = show !== 'guitar' && show !== 'bass'
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) => ({
style,
voicing: pianoVoicing({ rootPc: pc, quality }, { style }),
})),
[pc, quality],
)
// 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 (
{/* ── Guitar section: every placeable shape, side by side ── */}
{showGuitar && (
{/* dense (a GlanceRail row): the row header already names the chord and
the global selector names the instrument — no repeated heading. */}
{!dense && (
Guitar · {name}
)}
{guitarShapes.length === 0 ? (
// Graceful: nothing placeable for this root/quality — say so, no crash.
No guitar shape sits comfortably for {name} — try the piano voicings.
) : (
{guitarShapes.map((shape, i) => (
))}
)}
)}
{/* ── Piano section: all four voicing styles, side by side ── */}
{showPiano && (
{!dense && (
Piano · {name}
)}
{/* 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
voicings, whose bass is the 3rd (A) or 7th (B). Supply the chord root. */}
{pianoOptions.map(({ style, voicing }) => (
))}
)}
{/* ── show='bass' (D-41, D-40 §3): no gallery would be honest — say so
in one line instead of rendering guitar+piano under BASS. ── */}
{!showGuitar && !showPiano && (
No bass voicings for {name} yet — authored bass patterns are on the way
(blues first). Guitar and piano voicings live under those instruments.