import { CHORD_TYPES, NOTES, suggestSubstitutions } from '../lib/theory' import { chordRootPC } from '../lib/match' import { getGuitarVoicings } from '../lib/voicings' import { pianoVoicing } from '../lib/piano' import ChordDiagram from './ChordDiagram' import MiniPiano from './MiniPiano' // ─── TryThis — stable 3-slot rows-of-shapes substitution rail (L-78 / D-76) ───── // // For the chord under the playhead, in the detected key, show up to THREE curated // substitutions (from theory.js `suggestSubstitutions`, the L-73 engine) as // stacked ROWS — turned 90° from L-75's cards-across. Each row = a left identity // block (tappable chord chip + category tag + why) and a right "ways to play it" // block that follows the global instrument: // guitar → up to 3 ChordDiagram thumbs (the 3×3 grid), the genuinely different // grips from getGuitarVoicings(label).slice(0, 3) // piano → ONE MiniPiano ("for piano it can be just one thats okay") // bass → a root · {note} caption (no honest compact bass-chord renderer) // // The layout is a FIXED 3-slot frame: present subs render SubRow, absent slots // render a subtle EmptySlot that holds the exact row height — so sub #1 and #2 // never shift position whether the chord yields 2 or 3 subs (the user's anti-jump // ask: "annoying when the layout changes then u dont know where to look"). // Spec: docs/design/related-area-v2.md §2. Sibling of RelatedProgressions. // // 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) // instrument : 'guitar' | 'piano' | 'bass' — which mini diagram to draw // 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 } } // 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', } // Per-instrument uniform row height (§2.2/§3). All 3 slots — filled or empty — // share the current instrument's height so the frame never reflows. const ROW_MIN_H = { guitar: 'min-h-[100px]', piano: 'min-h-[72px]', bass: 'min-h-[64px]', } // 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; keeps the card live as you play); // (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 } // The "ways to play" block on the right of a SubRow, chosen by the global // instrument (§2.3): // guitar → up to 3 ChordDiagram thumbs (the 3×3), each captioned with its shape // name so the three read as genuinely different grips. Fewer than 3 // shapes exist (e.g. add9 → 2 for most roots) → show what exists, never // pad with fakes (honest, §2.4). None → nothing (chip + why carry it). // piano → ONE MiniPiano; rootPc spread back in so the "R" badge lands right. // bass → root · {note} caption (no honest compact bass-chord renderer). function WaysToPlay({ sub, instrument }) { if (instrument === 'piano') { return (
) } if (instrument === 'bass') { return ( root · {NOTES[((sub.rootPc % 12) + 12) % 12]} ) } // guitar (default): up to 3 genuinely different grips, left-aligned. Omit the // ChordDiagram label (the chip names the chord) — caption the shape name below. const shapes = getGuitarVoicings(sub.label).slice(0, 3) if (!shapes.length) return null return (
{shapes.map((shape, i) => (
{shape.label}
))}
) } // One substitution ROW — left identity (chip → modal + tag + why) | right ways. function SubRow({ sub, instrument, onChordClick }) { const isCircle = CIRCLE_CATEGORIES.has(sub.category) const tag = CATEGORY_TAG[sub.category] ?? sub.category return (
{/* Left identity block (~180px) */}
{tag} {isCircle && }

{sub.why}

{/* Right "ways to play" block */}
) } // A reserved-but-empty slot — holds the exact SubRow height so the present subs // never move when the chord yields fewer than 3 (§2.2, the anti-jump). function EmptySlot({ instrument }) { return ( ) } export default function TryThis({ loop, keyInfo, currentChord, onChordClick, instrument = 'guitar' }) { const loopArr = Array.isArray(loop) && loop.length ? loop : null const subject = pickSubject(loopArr, keyInfo, currentChord) // No subject (no loop + no valid live chord) or 0 subs → no card. if (!subject) return null const subjectChord = subject.name const subs = subject.subs.slice(0, 3) // cap at 3 (§2.1) return (

Try this instead of {subjectChord} {keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}

{/* Fixed 3-slot frame — a present sub → SubRow, an absent one → EmptySlot, so sub #1/#2 hold their position whether there are 2 or 3 subs. */}
{[0, 1, 2].map(i => subs[i] ? : )}
) }