feat(design): VoicingBrowser show prop + LickCard stack-guard hardening (task D-23)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,9 @@
|
|||||||
// spaced columns in `tab` order — no bars, beams or durations are invented.
|
// spaced columns in `tab` order — no bars, beams or durations are invented.
|
||||||
//
|
//
|
||||||
// Column rule: each note takes the next column, EXCEPT a note tagged
|
// 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
|
// `double-stop`, which stacks into the PREVIOUS note's column — unless ANY note
|
||||||
// the same string, where stacking would overlap — then it takes a new column).
|
// 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):
|
// Technique glyphs (amber, the established secondary-tone colour):
|
||||||
// hammer-on → slur arc from the previous note + italic "h" above
|
// hammer-on → slur arc from the previous note + italic "h" above
|
||||||
@@ -95,9 +96,13 @@ export function layoutTab(tab) {
|
|||||||
let col = -1
|
let col = -1
|
||||||
for (let i = 0; i < clean.length; i++) {
|
for (let i = 0; i < clean.length; i++) {
|
||||||
const n = clean[i]
|
const n = clean[i]
|
||||||
const prev = notes[i - 1]
|
// double-stop stacks into the previous column — unless ANY note already
|
||||||
// double-stop stacks into the previous column — unless same string (overlap).
|
// placed there shares this string (a ≥3-note stack can repeat the string of
|
||||||
const stacks = i > 0 && n.technique === 'double-stop' && prev.string !== n.string
|
// 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++
|
if (!stacks) col++
|
||||||
const ghost = n.technique === 'ghost-note'
|
const ghost = n.technique === 'ghost-note'
|
||||||
notes.push({
|
notes.push({
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
// rootPc — chord root pitch class 0–11 (default 0 = C)
|
// rootPc — chord root pitch class 0–11 (default 0 = C)
|
||||||
// quality — CHORD_TYPES key; unknown values fall back to 'maj'
|
// quality — CHORD_TYPES key; unknown values fall back to 'maj'
|
||||||
// (matching voicings.js / piano.js behaviour)
|
// (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 { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import ChordDiagram from './ChordDiagram'
|
import ChordDiagram from './ChordDiagram'
|
||||||
@@ -122,11 +125,19 @@ function SectionHeading({ children }) {
|
|||||||
|
|
||||||
// ─── The browser ──────────────────────────────────────────────────────────────
|
// ─── 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 pc = mod12(Number.isFinite(rootPc) ? rootPc : 0)
|
||||||
const name = chordName(pc, quality)
|
const name = chordName(pc, quality)
|
||||||
const chordKey = `${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 guitarShapes = useMemo(() => matchingShapes(quality, pc), [quality, pc])
|
||||||
const pianoOptions = useMemo(
|
const pianoOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -189,6 +200,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj' }) {
|
|||||||
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">
|
||||||
{/* ── Guitar row ── */}
|
{/* ── Guitar row ── */}
|
||||||
|
{showGuitar && (
|
||||||
<section
|
<section
|
||||||
aria-label={`Guitar voicings for ${name}`}
|
aria-label={`Guitar voicings for ${name}`}
|
||||||
className="min-w-[240px] flex-1 basis-[300px] rounded-lg border border-border bg-panel p-3"
|
className="min-w-[240px] flex-1 basis-[300px] rounded-lg border border-border bg-panel p-3"
|
||||||
@@ -240,8 +252,10 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj' }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Piano row ── */}
|
{/* ── Piano row ── */}
|
||||||
|
{showPiano && (
|
||||||
<section
|
<section
|
||||||
aria-label={`Piano voicings for ${name}`}
|
aria-label={`Piano voicings for ${name}`}
|
||||||
className="min-w-[240px] flex-1 basis-[300px] rounded-lg border border-border bg-panel p-3"
|
className="min-w-[240px] flex-1 basis-[300px] rounded-lg border border-border bg-panel p-3"
|
||||||
@@ -283,8 +297,10 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj' }) {
|
|||||||
<MiniPiano voicing={{ ...selectedPiano.voicing, rootPc: pc }} size="thumb" />
|
<MiniPiano voicing={{ ...selectedPiano.voicing, rootPc: pc }} size="thumb" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 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. */}
|
||||||
<p className="w-full basis-full text-[11px] text-gray-500">
|
<p className="w-full basis-full text-[11px] text-gray-500">
|
||||||
Previews play through your speakers — while the mic is live, detection may
|
Previews play through your speakers — while the mic is live, detection may
|
||||||
hear them.
|
hear them.
|
||||||
|
|||||||
Reference in New Issue
Block a user