feat(dashboard): rotating Try-this card — one fresh substitution per loop pass (task L-74)
A compact TryThis card follows the playhead chord and shows ONE suggestion at a time, cycling to the next valid substitution each time the loop completes a pass (playhead wraps to a lower station). Over Am-C-F the F cycles Dm -> Fm -> Fmaj7 -> E7 across passes, so the app keeps offering a new idea and eventually teaches every honest move, never a wrong one. Chip taps into ChordDetailModal; 0 subs -> no card, 1 sub -> static. Mounted App.jsx-only above RelatedProgressions in the related slot; audio contract grep-clean; rotation logic exported pure and StrictMode-safe. Critic PASS (2-pass rotation trace verified). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import DrumView from './components/DrumView'
|
|||||||
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
|
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
|
||||||
import ChordDetailModal from './components/ChordDetailModal'
|
import ChordDetailModal from './components/ChordDetailModal'
|
||||||
import RelatedProgressions from './components/RelatedProgressions'
|
import RelatedProgressions from './components/RelatedProgressions'
|
||||||
|
import TryThis from './components/TryThis'
|
||||||
import LoopStation from './components/LoopStation'
|
import LoopStation from './components/LoopStation'
|
||||||
import JamGuide, { KnowledgeDock } from './components/JamGuide'
|
import JamGuide, { KnowledgeDock } from './components/JamGuide'
|
||||||
import { useLoopEngine } from './services/loopEngine'
|
import { useLoopEngine } from './services/loopEngine'
|
||||||
@@ -854,11 +855,19 @@ export default function App() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
relatedSlot={
|
relatedSlot={
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<TryThis
|
||||||
|
loop={detectedProgression}
|
||||||
|
keyInfo={effectiveKey}
|
||||||
|
currentChord={currentChord}
|
||||||
|
onChordClick={setSelectedChord}
|
||||||
|
/>
|
||||||
<RelatedProgressions
|
<RelatedProgressions
|
||||||
loop={detectedProgression}
|
loop={detectedProgression}
|
||||||
keyInfo={effectiveKey}
|
keyInfo={effectiveKey}
|
||||||
onChordClick={setSelectedChord}
|
onChordClick={setSelectedChord}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
fill={jamView}
|
fill={jamView}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { CHORD_TYPES, suggestSubstitutions } from '../lib/theory'
|
||||||
|
import { chordRootPC } from '../lib/match'
|
||||||
|
|
||||||
|
// ─── TryThis — the rotating "Try this" substitution nudge (task L-74) ─────────
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// Invert CHORD_TYPES suffix → quality — the app idiom (mirrors
|
||||||
|
// RelatedProgressions' SUFFIX_TO_QUALITY). All 14 suffixes are unique.
|
||||||
|
const SUFFIX_TO_QUALITY = Object.fromEntries(
|
||||||
|
Object.entries(CHORD_TYPES).map(([quality, def]) => [def.suffix, quality])
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse a chord-name string → { rootPc, quality } (a CHORD_TYPES key) using the
|
||||||
|
// established helpers — chordRootPC for the root pc, the CHORD_TYPES suffix
|
||||||
|
// inversion for the quality. Never re-derived. Returns null when unparseable.
|
||||||
|
export function parseChordName(name) {
|
||||||
|
if (typeof name !== 'string') return null
|
||||||
|
const rootPc = chordRootPC(name)
|
||||||
|
if (rootPc < 0) return null
|
||||||
|
const m = name.match(/^[A-G][b#]?(.*)$/)
|
||||||
|
const quality = m ? SUFFIX_TO_QUALITY[m[1]] : undefined
|
||||||
|
if (!quality) return null
|
||||||
|
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).
|
||||||
|
const CIRCLE_CATEGORIES = new Set(['relative', 'secondary_dominant'])
|
||||||
|
|
||||||
|
const CATEGORY_TAG = {
|
||||||
|
relative: 'relative',
|
||||||
|
borrowed: 'borrowed',
|
||||||
|
extension: 'colour',
|
||||||
|
secondary_dominant: 'V7',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TryThis({ loop, keyInfo, currentChord, onChordClick }) {
|
||||||
|
const [cycle, setCycle] = useState(0)
|
||||||
|
const lastPosRef = useRef(null)
|
||||||
|
const lastChordRef = useRef(null)
|
||||||
|
|
||||||
|
const target = parseChordName(currentChord)
|
||||||
|
const loopArr = Array.isArray(loop) && loop.length ? loop : null
|
||||||
|
const position = loopArr && target ? loopArr.indexOf(currentChord) : -1
|
||||||
|
|
||||||
|
// Next station's root pc → gates Rule D (secondary dominant of the next chord).
|
||||||
|
let nextRootPc
|
||||||
|
if (loopArr && position >= 0) {
|
||||||
|
const pc = chordRootPC(loopArr[(position + 1) % loopArr.length])
|
||||||
|
if (pc >= 0) nextRootPc = pc
|
||||||
|
}
|
||||||
|
|
||||||
|
const subs = target ? suggestSubstitutions(target, keyInfo, { nextRootPc }) : []
|
||||||
|
|
||||||
|
// Rotation: advance one idea each loop pass (playhead position wraps toward 0).
|
||||||
|
// With no loop (position −1), advance on each genuine currentChord change so
|
||||||
|
// the nudge still refreshes as the player moves. Detection watches the previous
|
||||||
|
// position/chord across renders via refs (no side effects during render).
|
||||||
|
useEffect(() => {
|
||||||
|
const prevPos = lastPosRef.current
|
||||||
|
const prevChord = lastChordRef.current
|
||||||
|
lastPosRef.current = position
|
||||||
|
lastChordRef.current = currentChord
|
||||||
|
if (position >= 0) {
|
||||||
|
setCycle(c => advanceOnWrap(c, prevPos, position))
|
||||||
|
} else if (prevChord != null && prevChord !== currentChord) {
|
||||||
|
setCycle(c => c + 1)
|
||||||
|
}
|
||||||
|
}, [position, currentChord])
|
||||||
|
|
||||||
|
// 0 subs → no card (no key, atonal, or a chord with no honest sub).
|
||||||
|
const picked = pickSub(subs, cycle)
|
||||||
|
if (!picked) return null
|
||||||
|
|
||||||
|
const { sub, idx, total } = picked
|
||||||
|
const showIndicator = total > 1
|
||||||
|
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 ${currentChord}`}
|
||||||
|
>
|
||||||
|
<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 {currentChord}
|
||||||
|
{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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user