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:
+91
-47
@@ -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 §1–3.
|
// 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 1–3 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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user