diff --git a/src/App.jsx b/src/App.jsx
index 82d10ae..5537a82 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -11,6 +11,7 @@ import DrumView from './components/DrumView'
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
import ChordDetailModal from './components/ChordDetailModal'
import RelatedProgressions from './components/RelatedProgressions'
+import TryThis from './components/TryThis'
import LoopStation from './components/LoopStation'
import JamGuide, { KnowledgeDock } from './components/JamGuide'
import { useLoopEngine } from './services/loopEngine'
@@ -854,11 +855,19 @@ export default function App() {
>
}
relatedSlot={
-
+
+
+
+
}
fill={jamView}
/>
diff --git a/src/components/TryThis.jsx b/src/components/TryThis.jsx
new file mode 100644
index 0000000..447c570
--- /dev/null
+++ b/src/components/TryThis.jsx
@@ -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 (
+
+
+
+ Try this instead of {currentChord}
+ {keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
+
+ {showIndicator && (
+
+ {idx + 1} of {total}
+
+ )}
+