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}
|
||||
currentChord={currentChord}
|
||||
onChordClick={setSelectedChord}
|
||||
instrument={instrument}
|
||||
/>
|
||||
<RelatedProgressions
|
||||
loop={detectedProgression}
|
||||
|
||||
+90
-98
@@ -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 §1–3.
|
||||
// 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,70 +89,41 @@ 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)
|
||||
|
||||
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)
|
||||
// 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
}, [rotationPos, subjectName])
|
||||
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" />
|
||||
}
|
||||
|
||||
// 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 { sub, idx, total } = picked
|
||||
const showIndicator = total > 1
|
||||
// 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 (
|
||||
<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>
|
||||
|
||||
<div className="flex items-baseline gap-2">
|
||||
<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)}
|
||||
@@ -174,25 +132,59 @@ export default function TryThis({ loop, keyInfo, currentChord, onChordClick }) {
|
||||
>
|
||||
{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">
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
{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'}`}
|
||||
/>
|
||||
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)
|
||||
|
||||
// No subject (no loop + no valid live chord) or 0 subs → no card.
|
||||
if (!subject) return null
|
||||
|
||||
const subjectChord = subject.name
|
||||
const subs = subject.subs
|
||||
|
||||
// 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 (
|
||||
<section
|
||||
className="rounded-2xl border border-border bg-panel p-3"
|
||||
aria-label={`Try this instead of ${subjectChord}`}
|
||||
>
|
||||
<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={listClass}>
|
||||
{subs.map((sub, i) => (
|
||||
<div key={`${sub.label}-${i}`} className={cardBasis}>
|
||||
<SubCard sub={sub} instrument={instrument} onChordClick={onChordClick} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user