From 957ce88fd62ae8da7970b116ec8e46ba06576cc3 Mon Sep 17 00:00:00 2001 From: vadimwit Date: Mon, 13 Jul 2026 16:31:54 +0100 Subject: [PATCH] feat(trythis): side-by-side suggestions with per-chord instrument diagrams (task L-75) Try this now shows all of the current chord's valid substitutions at once (no more rotation), each as a card with the chord name, a mini diagram of how to play it in the selected instrument (guitar ChordDiagram 4-across, piano MiniPiano keyboards in a 2x2, bass a root caption), and the plain why. Still follows the live playhead so the set refreshes per chord as the progression evolves; honest-empty without a key/loop; the rolled-jam visibility fallback preserved. App.jsx change is the single instrument prop, contract-clean. Critic PASS (combined gate: diagrams resolve, root badge correct, reactivity verified). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.jsx | 1 + src/components/TryThis.jsx | 196 ++++++++++++++++++------------------- 2 files changed, 95 insertions(+), 102 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 5537a82..7dadb73 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -861,6 +861,7 @@ export default function App() { keyInfo={effectiveKey} currentChord={currentChord} onChordClick={setSelectedChord} + instrument={instrument} /> = 0 && prevPos != null && pos < prevPos) return cycle + 1 - return cycle -} - -// Pick the sub shown for a given monotonic cycle counter (softest-first order -// preserved; modulo keeps it in range as the chord — and its sub set — changes). -export function pickSub(subs, cycle) { - if (!subs || !subs.length) return null - const total = subs.length - const idx = ((cycle % total) + total) % total - return { sub: subs[idx], idx, total } -} - // Circle-of-fifths categories (relative = inner ring, secondary_dominant = // clockwise step). Only these earn the ↻ glyph — borrowed/extension are modal / // vertical colour and must NOT claim the circle (docs §6). @@ -85,7 +72,7 @@ function subsForChord(name, pos, loopArr, keyInfo) { // Choose the SUBJECT chord the card speaks about (pure — no hooks): // (a) the live currentChord, if it parses and yields ≥1 sub (the playing case — -// whether or not it's a loop station; unchanged behaviour); +// whether or not it's a loop station; keeps the card live as you play); // (b) else the FIRST loop chord that yields ≥1 sub — so a rolled/detected loop // in a locked key shows the card immediately, with no live input; // (c) else null — no loop and no valid live chord → honest empty. @@ -102,97 +89,102 @@ function pickSubject(loopArr, keyInfo, currentChord) { return null } -export default function TryThis({ loop, keyInfo, currentChord, onChordClick }) { - const [cycle, setCycle] = useState(0) - const lastPosRef = useRef(null) - const lastNameRef = useRef(null) +// The mini diagram drawn inside a SubCard, chosen by the global instrument (§2): +// guitar → first resolved guitar shape (absolute frets) via ChordDiagram thumb; +// no shape available → no diagram (honest, chip + why only). +// piano → pianoVoicing for the chord, rootPc spread back in so the "R" badge +// lands correctly (VoicingBrowser:317-319 caveat), MiniPiano size mini. +// bass → no diagram (no honest compact bass-chord renderer); a small +// root · {note} caption instead. +function SubDiagram({ sub, instrument }) { + if (instrument === 'piano') { + return ( + + ) + } + if (instrument === 'bass') { + return ( + + root · {NOTES[((sub.rootPc % 12) + 12) % 12]} + + ) + } + // guitar (default): first resolved shape; omit the label — the chip names it. + const shape = getGuitarVoicings(sub.label)[0] + if (!shape) return null + return +} +// One substitution card — chip (tappable → modal) + mini diagram + tag + why. +function SubCard({ sub, instrument, onChordClick }) { + const isCircle = CIRCLE_CATEGORIES.has(sub.category) + const tag = CATEGORY_TAG[sub.category] ?? sub.category + return ( +
+ + +
+ +
+ + + {tag} + {isCircle && } + + +

+ {sub.why} +

+
+ ) +} + +export default function TryThis({ loop, keyInfo, currentChord, onChordClick, instrument = 'guitar' }) { const loopArr = Array.isArray(loop) && loop.length ? loop : null const subject = pickSubject(loopArr, keyInfo, currentChord) - // Position/name that DRIVE rotation. When following a live loop chord this is its - // playhead index (wrap → advance). When following a live chord not in the loop it - // is −1 (advance on chord change). In the (b) fallback the subject is a fixed loop - // station with no playhead — pos is stable and name is stable, so the effect fires - // once and the shown idea holds steady (visible, no flicker). - const rotationPos = subject ? subject.pos : -1 - const subjectName = subject ? subject.name : null - - // Rotation: advance one idea each loop pass (playhead position wraps toward 0). - // With no loop position (−1), advance on each genuine subject-chord change so the - // nudge refreshes as the player moves. Refs carry the previous pos/name across - // renders (no side effects during render). Runs unconditionally (before returns). - useEffect(() => { - const prevPos = lastPosRef.current - const prevName = lastNameRef.current - lastPosRef.current = rotationPos - lastNameRef.current = subjectName - if (subjectName == null) return - if (rotationPos >= 0) { - setCycle(c => advanceOnWrap(c, prevPos, rotationPos)) - } else if (prevName != null && prevName !== subjectName) { - setCycle(c => c + 1) - } - }, [rotationPos, subjectName]) - // No subject (no loop + no valid live chord) or 0 subs → no card. if (!subject) return null - const picked = pickSub(subject.subs, cycle) - if (!picked) return null - const subs = subject.subs const subjectChord = subject.name + const subs = subject.subs - const { sub, idx, total } = picked - const showIndicator = total > 1 - const isCircle = CIRCLE_CATEGORIES.has(sub.category) - const tag = CATEGORY_TAG[sub.category] ?? sub.category + // Piano keyboards are up to ~199px wide (C-anchored window), so 4 cannot share a + // 720px row — lay them out 2×2. Guitar (75px cell) and bass (no diagram) sit + // 4-across in one flex-wrap row; with 1–3 subs the cards grow to fill (§1.1/§3). + const isPiano = instrument === 'piano' + const listClass = isPiano + ? 'grid grid-cols-1 sm:grid-cols-2 gap-2' + : 'flex flex-wrap gap-2' + const cardBasis = isPiano ? '' : 'basis-[168px] grow min-w-[152px]' return (
-

- - Try this instead of {subjectChord} - {keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''} - - {showIndicator && ( - - {idx + 1} of {total} - - )} +

+ Try this instead of {subjectChord} + {keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}

-
- -

- - {tag} - {isCircle && } - - {sub.why} -

+
+ {subs.map((sub, i) => ( +
+ +
+ ))}
- - {showIndicator && ( - - )}
) }