From 038fdfafc8b548e841260e240351bf8fec62a9c7 Mon Sep 17 00:00:00 2001 From: vadimwit Date: Mon, 13 Jul 2026 12:23:18 +0100 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20rotating=20Try-this=20card?= =?UTF-8?q?=20=E2=80=94=20one=20fresh=20substitution=20per=20loop=20pass?= =?UTF-8?q?=20(task=20L-74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/App.jsx | 19 +++-- src/components/TryThis.jsx | 160 +++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 src/components/TryThis.jsx 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} + + )} +

+ +
+ +

+ + {tag} + {isCircle && } + + {sub.why} +

+
+ + {showIndicator && ( + + )} +
+ ) +}