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:
@@ -861,6 +861,7 @@ export default function App() {
|
|||||||
keyInfo={effectiveKey}
|
keyInfo={effectiveKey}
|
||||||
currentChord={currentChord}
|
currentChord={currentChord}
|
||||||
onChordClick={setSelectedChord}
|
onChordClick={setSelectedChord}
|
||||||
|
instrument={instrument}
|
||||||
/>
|
/>
|
||||||
<RelatedProgressions
|
<RelatedProgressions
|
||||||
loop={detectedProgression}
|
loop={detectedProgression}
|
||||||
|
|||||||
+94
-102
@@ -1,22 +1,27 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { CHORD_TYPES, NOTES, suggestSubstitutions } from '../lib/theory'
|
||||||
import { CHORD_TYPES, suggestSubstitutions } from '../lib/theory'
|
|
||||||
import { chordRootPC } from '../lib/match'
|
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
|
// For the chord under the playhead, in the detected key, show ALL curated
|
||||||
// substitution at a time (from theory.js `suggestSubstitutions`, the L-73 engine)
|
// substitutions (from theory.js `suggestSubstitutions`, the L-73 engine) SIDE BY
|
||||||
// and CYCLE to the next valid idea each time the loop completes a pass. The user
|
// SIDE — one card per idea — each carrying a mini instrument diagram of that
|
||||||
// asked for something "more surprising, more jam-like, keeps offering new ideas"
|
// chord (guitar ChordDiagram / piano MiniPiano / bass root caption), following
|
||||||
// (2026-07-13) — so a single nudge that rotates, not a static 4-chip grid.
|
// the global instrument. Reverses L-74's rotating one-at-a-time card: the user
|
||||||
// Spec: docs/design/try-this-subs.md §4/§5 (superseded to rotation by L-74's
|
// saw the rotation and asked for all-visible ("put them next to each other, we
|
||||||
// ledger row). Sibling of RelatedProgressions; same micro-header + chip idiom.
|
// have enough space"). Spec: docs/design/related-area-layout.md §1–3.
|
||||||
|
// Sibling of RelatedProgressions; same micro-header + chip idiom.
|
||||||
//
|
//
|
||||||
// Props:
|
// Props:
|
||||||
// loop : string[] | null — the detected repeating progression (chord names)
|
// loop : string[] | null — the detected repeating progression (chord names)
|
||||||
// keyInfo : { root, mode, confidence } | null — effective key (frames the why)
|
// keyInfo : { root, mode, confidence } | null — effective key (frames the why)
|
||||||
// currentChord : string — the chord sounding NOW ("F", "Dm7")
|
// currentChord : string — the chord sounding NOW ("F", "Dm7")
|
||||||
// onChordClick : fn(label) — opens ChordDetailModal (App's setSelectedChord)
|
// 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
|
// Invert CHORD_TYPES suffix → quality — the app idiom (mirrors
|
||||||
// RelatedProgressions' SUFFIX_TO_QUALITY). All 14 suffixes are unique.
|
// RelatedProgressions' SUFFIX_TO_QUALITY). All 14 suffixes are unique.
|
||||||
@@ -37,24 +42,6 @@ export function parseChordName(name) {
|
|||||||
return { rootPc, quality }
|
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 =
|
// Circle-of-fifths categories (relative = inner ring, secondary_dominant =
|
||||||
// clockwise step). Only these earn the ↻ glyph — borrowed/extension are modal /
|
// clockwise step). Only these earn the ↻ glyph — borrowed/extension are modal /
|
||||||
// vertical colour and must NOT claim the circle (docs §6).
|
// 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):
|
// 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 —
|
// (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
|
// (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;
|
// in a locked key shows the card immediately, with no live input;
|
||||||
// (c) else null — no loop and no valid live chord → honest empty.
|
// (c) else null — no loop and no valid live chord → honest empty.
|
||||||
@@ -102,97 +89,102 @@ function pickSubject(loopArr, keyInfo, currentChord) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TryThis({ loop, keyInfo, currentChord, onChordClick }) {
|
// The mini diagram drawn inside a SubCard, chosen by the global instrument (§2):
|
||||||
const [cycle, setCycle] = useState(0)
|
// guitar → first resolved guitar shape (absolute frets) via ChordDiagram thumb;
|
||||||
const lastPosRef = useRef(null)
|
// no shape available → no diagram (honest, chip + why only).
|
||||||
const lastNameRef = useRef(null)
|
// 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 loopArr = Array.isArray(loop) && loop.length ? loop : null
|
||||||
const subject = pickSubject(loopArr, keyInfo, currentChord)
|
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.
|
// No subject (no loop + no valid live chord) or 0 subs → no card.
|
||||||
if (!subject) return null
|
if (!subject) return null
|
||||||
const picked = pickSub(subject.subs, cycle)
|
|
||||||
if (!picked) return null
|
|
||||||
|
|
||||||
const subs = subject.subs
|
|
||||||
const subjectChord = subject.name
|
const subjectChord = subject.name
|
||||||
|
const subs = subject.subs
|
||||||
|
|
||||||
const { sub, idx, total } = picked
|
// Piano keyboards are up to ~199px wide (C-anchored window), so 4 cannot share a
|
||||||
const showIndicator = total > 1
|
// 720px row — lay them out 2×2. Guitar (75px cell) and bass (no diagram) sit
|
||||||
const isCircle = CIRCLE_CATEGORIES.has(sub.category)
|
// 4-across in one flex-wrap row; with 1–3 subs the cards grow to fill (§1.1/§3).
|
||||||
const tag = CATEGORY_TAG[sub.category] ?? sub.category
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
className="rounded-2xl border border-border bg-panel p-3"
|
className="rounded-2xl border border-border bg-panel p-3"
|
||||||
aria-label={`Try this instead of ${subjectChord}`}
|
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">
|
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
<span>
|
Try this instead of {subjectChord}
|
||||||
Try this instead of {subjectChord}
|
{keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
|
||||||
{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>
|
</h4>
|
||||||
|
|
||||||
<div className="flex items-baseline gap-2">
|
<div className={listClass}>
|
||||||
<button
|
{subs.map((sub, i) => (
|
||||||
type="button"
|
<div key={`${sub.label}-${i}`} className={cardBasis}>
|
||||||
onClick={() => onChordClick?.(sub.label)}
|
<SubCard sub={sub} instrument={instrument} onChordClick={onChordClick} />
|
||||||
aria-label={`${sub.label} — ${sub.why}`}
|
</div>
|
||||||
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>
|
</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user