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) <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-13 16:31:54 +01:00
parent 51c2be90cc
commit 957ce88fd6
2 changed files with 95 additions and 102 deletions
+1
View File
@@ -861,6 +861,7 @@ export default function App() {
keyInfo={effectiveKey}
currentChord={currentChord}
onChordClick={setSelectedChord}
instrument={instrument}
/>
<RelatedProgressions
loop={detectedProgression}
+94 -102
View File
@@ -1,22 +1,27 @@
import { useEffect, useRef, useState } from 'react'
import { CHORD_TYPES, suggestSubstitutions } from '../lib/theory'
import { CHORD_TYPES, NOTES, suggestSubstitutions } from '../lib/theory'
import { chordRootPC } from '../lib/match'
import { getGuitarVoicings } from '../lib/voicings'
import { pianoVoicing } from '../lib/piano'
import ChordDiagram from './ChordDiagram'
import MiniPiano from './MiniPiano'
// ─── TryThis — the rotating "Try this" substitution nudge (task L-74) ─────────
// ─── TryThis — side-by-side substitution cards, each with a mini diagram (L-75) ─
//
// For the chord under the playhead, in the detected key, show ONE curated
// substitution at a time (from theory.js `suggestSubstitutions`, the L-73 engine)
// and CYCLE to the next valid idea each time the loop completes a pass. The user
// asked for something "more surprising, more jam-like, keeps offering new ideas"
// (2026-07-13) — so a single nudge that rotates, not a static 4-chip grid.
// Spec: docs/design/try-this-subs.md §4/§5 (superseded to rotation by L-74's
// ledger row). Sibling of RelatedProgressions; same micro-header + chip idiom.
// For the chord under the playhead, in the detected key, show ALL curated
// substitutions (from theory.js `suggestSubstitutions`, the L-73 engine) SIDE BY
// SIDE — one card per idea each carrying a mini instrument diagram of that
// chord (guitar ChordDiagram / piano MiniPiano / bass root caption), following
// the global instrument. Reverses L-74's rotating one-at-a-time card: the user
// saw the rotation and asked for all-visible ("put them next to each other, we
// have enough space"). Spec: docs/design/related-area-layout.md §13.
// Sibling of RelatedProgressions; same micro-header + chip idiom.
//
// Props:
// loop : string[] | null — the detected repeating progression (chord names)
// keyInfo : { root, mode, confidence } | null — effective key (frames the why)
// currentChord : string — the chord sounding NOW ("F", "Dm7")
// onChordClick : fn(label) — opens ChordDetailModal (App's setSelectedChord)
// instrument : 'guitar' | 'piano' | 'bass' — which mini diagram to draw
// Invert CHORD_TYPES suffix → quality — the app idiom (mirrors
// RelatedProgressions' SUFFIX_TO_QUALITY). All 14 suffixes are unique.
@@ -37,24 +42,6 @@ export function parseChordName(name) {
return { rootPc, quality }
}
// A loop pass completes when the playhead position wraps from a later station
// back toward 0 — i.e. the new position is lower than the previous one. On that
// wrap, advance the rotation by one idea. Pure + exported so the rotation can be
// proven independently of React effect timing.
export function advanceOnWrap(cycle, prevPos, pos) {
if (pos >= 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 (
<MiniPiano
voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }}
size="mini"
/>
)
}
if (instrument === 'bass') {
return (
<span className="text-[10px] text-gray-500">
root · {NOTES[((sub.rootPc % 12) + 12) % 12]}
</span>
)
}
// guitar (default): first resolved shape; omit the label — the chip names it.
const shape = getGuitarVoicings(sub.label)[0]
if (!shape) return null
return <ChordDiagram shape={shape} rootPc={sub.rootPc} size="thumb" />
}
// 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 (
<div className="flex min-w-0 flex-col items-center gap-1.5 rounded-lg border border-border bg-border/30 p-2">
<button
type="button"
onClick={() => onChordClick?.(sub.label)}
aria-label={`${sub.label}${sub.why}`}
className="shrink-0 rounded-lg border border-border bg-border px-2 py-1 text-sm font-bold leading-none text-gray-100 outline-none transition-all cursor-pointer hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent"
>
{sub.label}
</button>
<div className="flex min-h-[38px] items-center justify-center">
<SubDiagram sub={sub} instrument={instrument} />
</div>
<span className="text-[9px] uppercase tracking-wide text-gray-500">
{tag}
{isCircle && <span className="ml-0.5 text-accent" aria-hidden="true"></span>}
</span>
<p className="line-clamp-3 text-center text-[11px] leading-snug text-gray-400">
{sub.why}
</p>
</div>
)
}
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 13 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 (
<section
className="rounded-2xl border border-border bg-panel p-3"
aria-label={`Try this instead of ${subjectChord}`}
>
<h4 className="mb-2 flex items-baseline justify-between gap-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
<span>
Try this instead of {subjectChord}
{keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
</span>
{showIndicator && (
<span className="shrink-0 normal-case tracking-normal text-gray-500">
{idx + 1} of {total}
</span>
)}
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
Try this instead of {subjectChord}
{keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
</h4>
<div className="flex items-baseline gap-2">
<button
type="button"
onClick={() => onChordClick?.(sub.label)}
aria-label={`${sub.label}${sub.why}`}
className="shrink-0 rounded-lg border border-border bg-border px-2 py-1 text-sm font-bold leading-none text-gray-100 outline-none transition-all cursor-pointer hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent"
>
{sub.label}
</button>
<p className="min-w-0 text-[11px] leading-snug text-gray-400">
<span className="mr-1 align-baseline text-[9px] uppercase tracking-wide text-gray-500">
{tag}
{isCircle && <span className="ml-0.5 text-accent" aria-hidden="true"></span>}
</span>
{sub.why}
</p>
<div className={listClass}>
{subs.map((sub, i) => (
<div key={`${sub.label}-${i}`} className={cardBasis}>
<SubCard sub={sub} instrument={instrument} onChordClick={onChordClick} />
</div>
))}
</div>
{showIndicator && (
<div className="mt-2 flex items-center gap-1" aria-hidden="true">
{subs.map((_, i) => (
<span
key={i}
className={`h-1 w-1 rounded-full ${i === idx ? 'bg-accent' : 'bg-border'}`}
/>
))}
</div>
)}
</section>
)
}