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:
+63
-25
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user