fix(dashboard): Try-this card visible for any detected/rolled loop, not only live input (task L-74b)

The rotating Try-this card was gated on a live-detected currentChord,
so a rolled Jam Roulette (which locks a key + loop but leaves the
history empty until you play) showed nothing. TryThis now falls back
to the first loop chord that yields a substitution, so the card is
visible whenever a loop is on screen in a locked key; the live-playing
path is unchanged and honest-empty (no key / no loop) is preserved.
Critic PASS (combined gate: renders for the rolled-jam case, no
infinite render).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-13 12:58:40 +01:00
parent 19b1a4ff91
commit 6885fc3ce1
+63 -25
View File
@@ -67,44 +67,82 @@ const CATEGORY_TAG = {
secondary_dominant: 'V7', secondary_dominant: 'V7',
} }
// Compute the substitution set for a chord NAME at loop index `pos` (1 when the
// chord is not a loop station). The next station's root pc gates Rule D (secondary
// dominant of the next chord). Returns { name, pos, subs } or null when the name
// won't parse. Reuses parseChordName / chordRootPC / suggestSubstitutions — never
// re-derives theory.
function subsForChord(name, pos, loopArr, keyInfo) {
const parsed = parseChordName(name)
if (!parsed) return null
let nextRootPc
if (loopArr && pos >= 0) {
const pc = chordRootPC(loopArr[(pos + 1) % loopArr.length])
if (pc >= 0) nextRootPc = pc
}
return { name, pos, subs: suggestSubstitutions(parsed, keyInfo, { nextRootPc }) }
}
// 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);
// (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.
function pickSubject(loopArr, keyInfo, currentChord) {
const pos = loopArr ? loopArr.indexOf(currentChord) : -1
const live = subsForChord(currentChord, pos, loopArr, keyInfo)
if (live && live.subs.length) return live
if (loopArr) {
for (let i = 0; i < loopArr.length; i++) {
const cand = subsForChord(loopArr[i], i, loopArr, keyInfo)
if (cand && cand.subs.length) return cand
}
}
return null
}
export default function TryThis({ loop, keyInfo, currentChord, onChordClick }) { export default function TryThis({ loop, keyInfo, currentChord, onChordClick }) {
const [cycle, setCycle] = useState(0) const [cycle, setCycle] = useState(0)
const lastPosRef = useRef(null) const lastPosRef = useRef(null)
const lastChordRef = useRef(null) const lastNameRef = useRef(null)
const target = parseChordName(currentChord)
const loopArr = Array.isArray(loop) && loop.length ? loop : null const loopArr = Array.isArray(loop) && loop.length ? loop : null
const position = loopArr && target ? loopArr.indexOf(currentChord) : -1 const subject = pickSubject(loopArr, keyInfo, currentChord)
// Next station's root pc → gates Rule D (secondary dominant of the next chord). // Position/name that DRIVE rotation. When following a live loop chord this is its
let nextRootPc // playhead index (wrap → advance). When following a live chord not in the loop it
if (loopArr && position >= 0) { // is 1 (advance on chord change). In the (b) fallback the subject is a fixed loop
const pc = chordRootPC(loopArr[(position + 1) % loopArr.length]) // station with no playhead — pos is stable and name is stable, so the effect fires
if (pc >= 0) nextRootPc = pc // once and the shown idea holds steady (visible, no flicker).
} const rotationPos = subject ? subject.pos : -1
const subjectName = subject ? subject.name : null
const subs = target ? suggestSubstitutions(target, keyInfo, { nextRootPc }) : []
// Rotation: advance one idea each loop pass (playhead position wraps toward 0). // Rotation: advance one idea each loop pass (playhead position wraps toward 0).
// With no loop (position 1), advance on each genuine currentChord change so // With no loop position (1), advance on each genuine subject-chord change so the
// the nudge still refreshes as the player moves. Detection watches the previous // nudge refreshes as the player moves. Refs carry the previous pos/name across
// position/chord across renders via refs (no side effects during render). // renders (no side effects during render). Runs unconditionally (before returns).
useEffect(() => { useEffect(() => {
const prevPos = lastPosRef.current const prevPos = lastPosRef.current
const prevChord = lastChordRef.current const prevName = lastNameRef.current
lastPosRef.current = position lastPosRef.current = rotationPos
lastChordRef.current = currentChord lastNameRef.current = subjectName
if (position >= 0) { if (subjectName == null) return
setCycle(c => advanceOnWrap(c, prevPos, position)) if (rotationPos >= 0) {
} else if (prevChord != null && prevChord !== currentChord) { setCycle(c => advanceOnWrap(c, prevPos, rotationPos))
} else if (prevName != null && prevName !== subjectName) {
setCycle(c => c + 1) setCycle(c => c + 1)
} }
}, [position, currentChord]) }, [rotationPos, subjectName])
// 0 subs → no card (no key, atonal, or a chord with no honest sub). // No subject (no loop + no valid live chord) or 0 subs → no card.
const picked = pickSub(subs, cycle) if (!subject) return null
const picked = pickSub(subject.subs, cycle)
if (!picked) return null if (!picked) return null
const subs = subject.subs
const subjectChord = subject.name
const { sub, idx, total } = picked const { sub, idx, total } = picked
const showIndicator = total > 1 const showIndicator = total > 1
const isCircle = CIRCLE_CATEGORIES.has(sub.category) const isCircle = CIRCLE_CATEGORIES.has(sub.category)
@@ -113,11 +151,11 @@ export default function TryThis({ loop, keyInfo, currentChord, onChordClick }) {
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 ${currentChord}`} 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 flex items-baseline justify-between gap-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
<span> <span>
Try this instead of {currentChord} Try this instead of {subjectChord}
{keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''} {keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
</span> </span>
{showIndicator && ( {showIndicator && (