From db397f6ceb8f2f733c2786add1c7c5680b507109 Mon Sep 17 00:00:00 2001 From: vadimwit Date: Wed, 8 Jul 2026 23:56:22 +0100 Subject: [PATCH] feat(design): VoicingBrowser show prop + LickCard stack-guard hardening (task D-23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit show='guitar'|'piano'|'both' (default both — 50/50 SSR renders byte-identical for existing mounts); LickCard double-stop collision guard now checks all same-column notes. Critic PASS (subsumption proven; 13/13 lick layouts unchanged; adversarial overlap dead). Co-Authored-By: Claude Fable 5 --- src/components/LickCard.jsx | 15 ++++++++++----- src/components/VoicingBrowser.jsx | 20 ++++++++++++++++++-- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/components/LickCard.jsx b/src/components/LickCard.jsx index 8779853..5141f3a 100644 --- a/src/components/LickCard.jsx +++ b/src/components/LickCard.jsx @@ -12,8 +12,9 @@ // spaced columns in `tab` order — no bars, beams or durations are invented. // // Column rule: each note takes the next column, EXCEPT a note tagged -// `double-stop`, which stacks into the PREVIOUS note's column (unless it is on -// the same string, where stacking would overlap — then it takes a new column). +// `double-stop`, which stacks into the PREVIOUS note's column — unless ANY note +// already placed in that column is on the same string (stacking would overlap +// exactly), in which case it takes a new column. // // Technique glyphs (amber, the established secondary-tone colour): // hammer-on → slur arc from the previous note + italic "h" above @@ -95,9 +96,13 @@ export function layoutTab(tab) { let col = -1 for (let i = 0; i < clean.length; i++) { const n = clean[i] - const prev = notes[i - 1] - // double-stop stacks into the previous column — unless same string (overlap). - const stacks = i > 0 && n.technique === 'double-stop' && prev.string !== n.string + // double-stop stacks into the previous column — unless ANY note already + // placed there shares this string (a ≥3-note stack can repeat the string of + // a non-adjacent same-column note, which would overlap exactly — D-23). + const stacks = + i > 0 && + n.technique === 'double-stop' && + !notes.some((m) => m.col === col && m.string === n.string) if (!stacks) col++ const ghost = n.technique === 'ghost-note' notes.push({ diff --git a/src/components/VoicingBrowser.jsx b/src/components/VoicingBrowser.jsx index e512ed4..9a51562 100644 --- a/src/components/VoicingBrowser.jsx +++ b/src/components/VoicingBrowser.jsx @@ -24,6 +24,9 @@ // 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 row(s) to render. Any other value falls back to both, +// so every pre-existing mount renders identically with no prop. import { useEffect, useMemo, useRef, useState } from 'react' import ChordDiagram from './ChordDiagram' @@ -122,11 +125,19 @@ function SectionHeading({ children }) { // ─── The browser ────────────────────────────────────────────────────────────── -export default function VoicingBrowser({ rootPc = 0, quality = 'maj' }) { +export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'both' }) { const pc = mod12(Number.isFinite(rootPc) ? rootPc : 0) const name = chordName(pc, quality) const chordKey = `${pc}:${quality}` + // Row gating (D-23). 'guitar' hides the piano row, 'piano' hides the guitar + // row, anything else (incl. the 'both' default) shows both — so at least one + // row ALWAYS renders, and the mic-feedback microcopy below stays with it. + // Hooks stay unconditional; the shared stop-handle discipline (stopCurrent on + // chip switch / chord change / unmount) is untouched by hiding a row. + const showGuitar = show !== 'piano' + const showPiano = show !== 'guitar' + const guitarShapes = useMemo(() => matchingShapes(quality, pc), [quality, pc]) const pianoOptions = useMemo( () => @@ -189,6 +200,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj' }) { return (
{/* ── Guitar row ── */} + {showGuitar && (
)}
+ )} {/* ── Piano row ── */} + {showPiano && (
+ )} - {/* Mic-feedback caveat, per the L-20 header + D-20 §3 (microcopy tier). */} + {/* Mic-feedback caveat, per the L-20 header + D-20 §3 (microcopy tier). + At least one row always renders (see the gating above), so this stays. */}

Previews play through your speakers — while the mic is live, detection may hear them.