feat(trythis): 3x3 ways-to-play — up to 3 guitar grips / 1 piano per suggestion (task L-78)

Try-this now fills its space: each of up to 3 substitutions renders as a
row with a left identity block (chord chip -> modal, category tag, why)
and a right "ways to play it" block that follows the global instrument —
guitar shows up to 3 genuinely different grips (getGuitarVoicings), piano
one MiniPiano, bass a root caption. A FIXED 3-slot frame reserves height
for all three rows (present -> SubRow, absent -> dashed EmptySlot) so
suggestions #1/#2 never shift when a chord yields 2 vs 3 subs (the
anti-jump ask). Honest: add9's 2-shape roots show 2 grips, not padded
fakes. Pure parseChordName/subsForChord/pickSubject preserved (per-chord
reactivity intact). Design tokens only. Verified: build + validate-KB +
smoke 903/903 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-13 18:46:10 +01:00
parent ede3f2e372
commit d543b986dc
+91 -47
View File
@@ -5,16 +5,23 @@ import { pianoVoicing } from '../lib/piano'
import ChordDiagram from './ChordDiagram' import ChordDiagram from './ChordDiagram'
import MiniPiano from './MiniPiano' import MiniPiano from './MiniPiano'
// ─── TryThis — side-by-side substitution cards, each with a mini diagram (L-75) ─ // ─── TryThis — stable 3-slot rows-of-shapes substitution rail (L-78 / D-76) ─────
// //
// For the chord under the playhead, in the detected key, show ALL curated // For the chord under the playhead, in the detected key, show up to THREE curated
// substitutions (from theory.js `suggestSubstitutions`, the L-73 engine) SIDE BY // substitutions (from theory.js `suggestSubstitutions`, the L-73 engine) as
// SIDE — one card per idea — each carrying a mini instrument diagram of that // stacked ROWS — turned 90° from L-75's cards-across. Each row = a left identity
// chord (guitar ChordDiagram / piano MiniPiano / bass root caption), following // block (tappable chord chip + category tag + why) and a right "ways to play it"
// the global instrument. Reverses L-74's rotating one-at-a-time card: the user // block that follows the global instrument:
// saw the rotation and asked for all-visible ("put them next to each other, we // guitar → up to 3 ChordDiagram thumbs (the 3×3 grid), the genuinely different
// have enough space"). Spec: docs/design/related-area-layout.md §13. // grips from getGuitarVoicings(label).slice(0, 3)
// Sibling of RelatedProgressions; same micro-header + chip idiom. // 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: // Props:
// loop : string[] | null — the detected repeating progression (chord names) // loop : string[] | null — the detected repeating progression (chord names)
@@ -54,6 +61,14 @@ const CATEGORY_TAG = {
secondary_dominant: 'V7', 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 // 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 // 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 // dominant of the next chord). Returns { name, pos, subs } or null when the name
@@ -89,22 +104,26 @@ function pickSubject(loopArr, keyInfo, currentChord) {
return null return null
} }
// The mini diagram drawn inside a SubCard, chosen by the global instrument (§2): // The "ways to play" block on the right of a SubRow, chosen by the global
// guitar → first resolved guitar shape (absolute frets) via ChordDiagram thumb; // instrument (§2.3):
// no shape available → no diagram (honest, chip + why only). // guitar → up to 3 ChordDiagram thumbs (the 3×3), each captioned with its shape
// piano → pianoVoicing for the chord, rootPc spread back in so the "R" badge // name so the three read as genuinely different grips. Fewer than 3
// lands correctly (VoicingBrowser:317-319 caveat), MiniPiano size mini. // shapes exist (e.g. add9 → 2 for most roots) → show what exists, never
// bass → no diagram (no honest compact bass-chord renderer); a small // pad with fakes (honest, §2.4). None → nothing (chip + why carry it).
// root · {note} caption instead. // piano → ONE MiniPiano; rootPc spread back in so the "R" badge lands right.
function SubDiagram({ sub, instrument }) { // bass → root · {note} caption (no honest compact bass-chord renderer).
function WaysToPlay({ sub, instrument }) {
if (instrument === 'piano') { if (instrument === 'piano') {
return ( return (
<div className="flex items-center">
<MiniPiano <MiniPiano
voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }} voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }}
size="mini" size="mini"
/> />
</div>
) )
} }
if (instrument === 'bass') { if (instrument === 'bass') {
return ( return (
<span className="text-[10px] text-gray-500"> <span className="text-[10px] text-gray-500">
@@ -112,18 +131,36 @@ function SubDiagram({ sub, instrument }) {
</span> </span>
) )
} }
// guitar (default): first resolved shape; omit the label — the chip names it.
const shape = getGuitarVoicings(sub.label)[0] // guitar (default): up to 3 genuinely different grips, left-aligned. Omit the
if (!shape) return null // ChordDiagram label (the chip names the chord) — caption the shape name below.
return <ChordDiagram shape={shape} rootPc={sub.rootPc} size="thumb" /> const shapes = getGuitarVoicings(sub.label).slice(0, 3)
if (!shapes.length) return null
return (
<div className="flex gap-2">
{shapes.map((shape, i) => (
<div key={`${shape.label}-${i}`} className="flex flex-col items-center gap-0.5">
<ChordDiagram shape={shape} rootPc={sub.rootPc} size="thumb" />
<span className="max-w-[75px] truncate text-center text-[9px] leading-none text-gray-500">
{shape.label}
</span>
</div>
))}
</div>
)
} }
// One substitution card — chip (tappable → modal) + mini diagram + tag + why. // One substitution ROW — left identity (chip → modal + tag + why) | right ways.
function SubCard({ sub, instrument, onChordClick }) { function SubRow({ sub, instrument, onChordClick }) {
const isCircle = CIRCLE_CATEGORIES.has(sub.category) const isCircle = CIRCLE_CATEGORIES.has(sub.category)
const tag = CATEGORY_TAG[sub.category] ?? sub.category const tag = CATEGORY_TAG[sub.category] ?? sub.category
return ( return (
<div className="flex min-w-0 flex-col items-center gap-1.5 rounded-lg border border-border bg-border/30 p-2"> <div
className={`flex items-center gap-3 rounded-lg border border-border bg-border/30 p-2 ${ROW_MIN_H[instrument] ?? ROW_MIN_H.guitar}`}
>
{/* Left identity block (~180px) */}
<div className="flex w-[180px] shrink-0 flex-col gap-1">
<div className="flex items-center gap-1.5">
<button <button
type="button" type="button"
onClick={() => onChordClick?.(sub.label)} onClick={() => onChordClick?.(sub.label)}
@@ -132,20 +169,34 @@ function SubCard({ sub, instrument, onChordClick }) {
> >
{sub.label} {sub.label}
</button> </button>
<div className="flex min-h-[38px] items-center justify-center">
<SubDiagram sub={sub} instrument={instrument} />
</div>
<span className="text-[9px] uppercase tracking-wide text-gray-500"> <span className="text-[9px] uppercase tracking-wide text-gray-500">
{tag} {tag}
{isCircle && <span className="ml-0.5 text-accent" aria-hidden="true"></span>} {isCircle && <span className="ml-0.5 text-accent" aria-hidden="true"></span>}
</span> </span>
</div>
<p className="line-clamp-3 text-center text-[11px] leading-snug text-gray-400"> <p className="line-clamp-2 text-[11px] leading-snug text-gray-400">
{sub.why} {sub.why}
</p> </p>
</div> </div>
{/* Right "ways to play" block */}
<div className="min-w-0 flex-1">
<WaysToPlay sub={sub} instrument={instrument} />
</div>
</div>
)
}
// 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 (
<div
aria-hidden="true"
className={`flex items-center justify-center rounded-lg border border-dashed border-border/50 bg-transparent ${ROW_MIN_H[instrument] ?? ROW_MIN_H.guitar}`}
>
<span className="text-[11px] text-gray-600"></span>
</div>
) )
} }
@@ -157,16 +208,7 @@ export default function TryThis({ loop, keyInfo, currentChord, onChordClick, ins
if (!subject) return null if (!subject) return null
const subjectChord = subject.name const subjectChord = subject.name
const subs = subject.subs const subs = subject.subs.slice(0, 3) // cap at 3 (§2.1)
// Piano keyboards are up to ~199px wide (C-anchored window), so 4 cannot share a
// 720px row — lay them out 2×2. Guitar (75px cell) and bass (no diagram) sit
// 4-across in one flex-wrap row; with 13 subs the cards grow to fill (§1.1/§3).
const isPiano = instrument === 'piano'
const listClass = isPiano
? 'grid grid-cols-1 sm:grid-cols-2 gap-2'
: 'flex flex-wrap gap-2'
const cardBasis = isPiano ? '' : 'basis-[168px] grow min-w-[152px]'
return ( return (
<section <section
@@ -178,12 +220,14 @@ export default function TryThis({ loop, keyInfo, currentChord, onChordClick, ins
{keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''} {keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
</h4> </h4>
<div className={listClass}> {/* Fixed 3-slot frame — a present sub → SubRow, an absent one → EmptySlot,
{subs.map((sub, i) => ( so sub #1/#2 hold their position whether there are 2 or 3 subs. */}
<div key={`${sub.label}-${i}`} className={cardBasis}> <div className="flex flex-col gap-2">
<SubCard sub={sub} instrument={instrument} onChordClick={onChordClick} /> {[0, 1, 2].map(i =>
</div> subs[i]
))} ? <SubRow key={`${subs[i].label}-${i}`} sub={subs[i]} instrument={instrument} onChordClick={onChordClick} />
: <EmptySlot key={`empty-${i}`} instrument={instrument} />
)}
</div> </div>
</section> </section>
) )