Compare commits
6 Commits
89b3e323ea
...
8c018d697d
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c018d697d | |||
| e6957160f6 | |||
| 957ce88fd6 | |||
| 51c2be90cc | |||
| 2905a4b979 | |||
| c62cd60595 |
@@ -80,7 +80,10 @@ Standing principles (memory): scroll > click; nothing duplicated; one global ins
|
|||||||
| D-73 | **"Try this" chord-substitution feature** design (engine rules + UI): for a loop chord `{rootPc,quality}` in the detected key/style, a SMALL curated set (~3-4) of alternative chords — recommend the categories (relative/diatonic-third sub, borrowed-minor colour e.g. IV→iv, extension/colour e.g. maj7/add9/sus, a circle-of-fifths / secondary-dominant move) — each with a plain one-line WHY that teaches. Concrete worked examples for the user's Am–C–F case (both A-minor and C-major readings), arithmetically correct (the gate WILL recompute). The UI surface: where it mounts in the jam dashboard (per-station in the rail? a "Try this" line under the loop? — decide, keep it glanceable, tappable→ChordDetailModal), honest circle-of-fifths tie-in (a lens for the relative/neighbour subs, NOT claimed for borrowed/extension), edge states (no key / atonal), keep-it-simple (learnable, not a reharm engine). Use a SEPARATE component (disjoint from RelatedProgressions.jsx). No user gate: pick strongest, ≥2 rejected alternatives | design | done (returned once — 3 copy/plumbing fixes verified: chordRootPC→noteIndex circular-import, ♭6 flat spelling, mediant claim; rules all recomputed correct; `c2207ef`) | — | `docs/design/try-this-subs.md` | substitution categories + rules + why-copy spec'd and arithmetically honest; Am–C–F worked; UI surface + mount decided; L-73/L-74 bounded |
|
| D-73 | **"Try this" chord-substitution feature** design (engine rules + UI): for a loop chord `{rootPc,quality}` in the detected key/style, a SMALL curated set (~3-4) of alternative chords — recommend the categories (relative/diatonic-third sub, borrowed-minor colour e.g. IV→iv, extension/colour e.g. maj7/add9/sus, a circle-of-fifths / secondary-dominant move) — each with a plain one-line WHY that teaches. Concrete worked examples for the user's Am–C–F case (both A-minor and C-major readings), arithmetically correct (the gate WILL recompute). The UI surface: where it mounts in the jam dashboard (per-station in the rail? a "Try this" line under the loop? — decide, keep it glanceable, tappable→ChordDetailModal), honest circle-of-fifths tie-in (a lens for the relative/neighbour subs, NOT claimed for borrowed/extension), edge states (no key / atonal), keep-it-simple (learnable, not a reharm engine). Use a SEPARATE component (disjoint from RelatedProgressions.jsx). No user gate: pick strongest, ≥2 rejected alternatives | design | done (returned once — 3 copy/plumbing fixes verified: chordRootPC→noteIndex circular-import, ♭6 flat spelling, mediant claim; rules all recomputed correct; `c2207ef`) | — | `docs/design/try-this-subs.md` | substitution categories + rules + why-copy spec'd and arithmetically honest; Am–C–F worked; UI surface + mount decided; L-73/L-74 bounded |
|
||||||
| L-73 | Substitution engine: `suggestSubstitutions({rootPc,quality}, keyInfo, opts?)` in `src/lib/theory.js` (additive) → `[{rootPc, quality, label, why, category}]` per D-73's rules + a smoke truth-table (expected subs for known chords/keys, sabotage-proven like the resolveDegree guard) | engineering | backlog | D-73 | `src/lib/theory.js` (additive), `scripts/smoke.mjs` | engine returns musically-correct subs for all 14 qualities in-key; smoke truth-table bites; build green | ← claimed 2026-07-13; **returned once** — Rule C self-suggested the sounding chord on 7th-chord inputs (Dm7→Dm7) + latent add9-on-minor mis-spelling; fixed + re-gate PASS (Dm7→[F], min7 sweep no add9, triad path intact, sabotage bites, 903/903); **done** `see commit`
|
| L-73 | Substitution engine: `suggestSubstitutions({rootPc,quality}, keyInfo, opts?)` in `src/lib/theory.js` (additive) → `[{rootPc, quality, label, why, category}]` per D-73's rules + a smoke truth-table (expected subs for known chords/keys, sabotage-proven like the resolveDegree guard) | engineering | backlog | D-73 | `src/lib/theory.js` (additive), `scripts/smoke.mjs` | engine returns musically-correct subs for all 14 qualities in-key; smoke truth-table bites; build green | ← claimed 2026-07-13; **returned once** — Rule C self-suggested the sounding chord on 7th-chord inputs (Dm7→Dm7) + latent add9-on-minor mis-spelling; fixed + re-gate PASS (Dm7→[F], min7 sweep no add9, triad path intact, sabotage bites, 903/903); **done** `see commit`
|
||||||
| L-74 | "Try this" UI per D-73, with VARY/ROTATE (user decided 2026-07-13: "more surprising, more jam-like, keeps offering new ideas"): a new `TryThis.jsx` that, for the current playhead chord, shows ONE suggestion at a time (matches "try this" = a single nudge) and CYCLES to the next valid substitution each time the loop completes a pass (playhead wraps to station 0 — watch the position prop). Sequential cycling through the engine's ordered valid set (softest-first on first appearance) so the player eventually learns all options and never repeats until exhausted; a small "1 of N" / dot indicator; 0 subs → no card; 1 sub → static (no rotation). Tappable→ChordDetailModal; tokens/AA; glanceable. Supersedes D-73's static ≤4-chip display for the UI (engine/rules unchanged). Mount App.jsx-only in relatedSlot above RelatedProgressions (disjoint) | engineering | backlog | D-73, L-73 | `src/components/TryThis.jsx` (new), `src/App.jsx` (mount) | one fresh honest sub per chord, cycling each loop pass; zero-click; empties honest; build + smoke green | **done** `038fdfa` (Critic PASS — 2-pass rotation trace Dm→Fm verified, contract-clean)
|
| L-74 | "Try this" UI per D-73, with VARY/ROTATE (user decided 2026-07-13: "more surprising, more jam-like, keeps offering new ideas"): a new `TryThis.jsx` that, for the current playhead chord, shows ONE suggestion at a time (matches "try this" = a single nudge) and CYCLES to the next valid substitution each time the loop completes a pass (playhead wraps to station 0 — watch the position prop). Sequential cycling through the engine's ordered valid set (softest-first on first appearance) so the player eventually learns all options and never repeats until exhausted; a small "1 of N" / dot indicator; 0 subs → no card; 1 sub → static (no rotation). Tappable→ChordDetailModal; tokens/AA; glanceable. Supersedes D-73's static ≤4-chip display for the UI (engine/rules unchanged). Mount App.jsx-only in relatedSlot above RelatedProgressions (disjoint) | engineering | backlog | D-73, L-73 | `src/components/TryThis.jsx` (new), `src/App.jsx` (mount) | one fresh honest sub per chord, cycling each loop pass; zero-click; empties honest; build + smoke green | **done** `038fdfa` (Critic PASS — 2-pass rotation trace Dm→Fm verified, contract-clean)
|
||||||
| C-70 | Sprint-end sweep + PR #3 update (covers rail/licks + related + try-this) | quality | backlog | L-70, L-71, L-72, L-74 | (none — verification) | all green; PR updated |
|
| D-75 | **Related-area space layout** (user 2026-07-13, after seeing it — "we have enough space in that area… fit everything in one big screen"): (a) TRY THIS — show the current chord's up-to-4 substitutions SIDE BY SIDE (replaces the one-at-a-time rotation; user has seen rotation + prefers all-visible), each a small card = chord label + a mini instrument diagram of that chord's voicing (ChordDiagram guitar / MiniPiano piano, following the GLOBAL instrument selector; bass = honest simple) + a compact why + tap→ChordDetailModal; (b) SUGGESTED PROGRESSIONS (RelatedProgressions) — 2×2 grid instead of the vertical list. Honest space math for the LEFT column (~744px, wider than the 500px rail). Instrument prop threading for TryThis (App mount passes `instrument`). Bounded L-75/L-76 (disjoint: TryThis.jsx+App-mount vs RelatedProgressions.jsx) | design | ready | — | `docs/design/related-area-layout.md` | side-by-side try-this with per-sub diagrams + 2×2 related spec'd with honest column math; instrument-follow named; L-75/L-76 bounded |
|
||||||
|
| L-75 | Implement TryThis side-by-side + per-sub instrument diagrams per D-75 (drops the rotation; follows the loop/current chord; instrument-following diagram) | engineering | done `957ce88` (combined-gate PASS) | D-75 | `src/components/TryThis.jsx`, `src/App.jsx` (pass `instrument` to the mount — grep-clean) | up-to-4 subs side by side each with a playable-shape diagram in the current instrument; tap→modal; honest empties; build+smoke green |
|
||||||
|
| L-76 | Implement RelatedProgressions 2×2 grid per D-75 (same-style + cross-style sections in a 2-col grid using the left-column width) | engineering | done `e695716` (combined-gate PASS) | D-75 | `src/components/RelatedProgressions.jsx` | 2×2 layout; both sections adapt; no logic/scoring change; build+smoke green |
|
||||||
|
| C-70 | Sprint-end sweep + PR update (BOTH gitea+github). Fold: drop stale ", each playable" from VoicingBrowser aria (:278/:314, combined-gate finding) | quality | backlog | L-70, L-71, L-72, L-74, L-75, L-76 | `src/components/VoicingBrowser.jsx` (aria one-liner) | all green; aria fixed; PRs updated |
|
||||||
|
|
||||||
> Sequencing (three parallel chains, file-disjoint): (A) D-70 → L-70 → L-71 (rail/licks); (B) D-72✓ → L-72 (related, same-style); (C) D-73 → L-73 → L-74 (try-this subs). ALL DONE except A's builds. **L-74b (2026-07-13, in-review):** TryThis loop-fallback so the card is visible for a rolled/detected loop without live input (user "i dont see rotating cards") — TryThis.jsx only, SSR-verified. **Batch plan:** L-70 ‖ L-71 ‖ L-74b (disjoint) → ONE combined gate (rail/licks + dock byte-identity + full-app SSR + contract) → commit all → push both remotes. C-70 closes.
|
> Sequencing (three parallel chains, file-disjoint): (A) D-70 → L-70 → L-71 (rail/licks); (B) D-72✓ → L-72 (related, same-style); (C) D-73 → L-73 → L-74 (try-this subs). ALL DONE except A's builds. **L-74b (2026-07-13, in-review):** TryThis loop-fallback so the card is visible for a rolled/detected loop without live input (user "i dont see rotating cards") — TryThis.jsx only, SSR-verified. **Batch plan:** L-70 ‖ L-71 ‖ L-74b (disjoint) → ONE combined gate (rail/licks + dock byte-identity + full-app SSR + contract) → commit all → push both remotes. C-70 closes.
|
||||||
|
|
||||||
@@ -274,7 +277,9 @@ Emphasis this sprint: **ship the Jam Guide MVP** (put the 8 guitar style packs o
|
|||||||
- **`pianoVoicing` should return `rootPc` (Luthier, tiny):** callers currently must attach it themselves (JamGuide and VoicingBrowser both do); returning it at the source removes the false-"R" foot-gun for future consumers (D-24 gate observation — NOT a live bug, both call sites verified correct 2026-07-08).
|
- **`pianoVoicing` should return `rootPc` (Luthier, tiny):** callers currently must attach it themselves (JamGuide and VoicingBrowser both do); returning it at the source removes the false-"R" foot-gun for future consumers (D-24 gate observation — NOT a live bug, both call sites verified correct 2026-07-08).
|
||||||
- **`docs/kb-backlog.md` is stale (Professor, tiny):** still lists gospel/pop guitar + jazz/gospel piano as "todo" though shipped; P-30 (rnb piano) AND P-31 (blues piano, both 2026-07-10) need done entries — refresh next content iteration (flagged by P-30 + C-31 sweep, outside their locks).
|
- **`docs/kb-backlog.md` is stale (Professor, tiny):** still lists gospel/pop guitar + jazz/gospel piano as "todo" though shipped; P-30 (rnb piano) AND P-31 (blues piano, both 2026-07-10) need done entries — refresh next content iteration (flagged by P-30 + C-31 sweep, outside their locks).
|
||||||
- **`scripts/loop-fixtures.mjs` header comment drift (tiny):** cites "App.jsx:322-324" for adjacent-dup suppression; L-31 shifted it to ~line 364 — comment-only, fix when the file is next touched (C-31 sweep finding).
|
- **`scripts/loop-fixtures.mjs` header comment drift (tiny):** cites "App.jsx:322-324" for adjacent-dup suppression; L-31 shifted it to ~line 364 — comment-only, fix when the file is next touched (C-31 sweep finding).
|
||||||
|
- **Try-this vs Suggested-progressions should show TWO DIFFERENT things (product direction, user 2026-07-13 — "keep as is for now… revise later"):** today both are chord/loop-relative but overlap conceptually. Intent: **Try this** = per-chord substitutions (an alternative to the CURRENT chord; built via `suggestSubstitutions`). **Suggested progressions** = currently same-style KB siblings (other real progressions in the style); the desired distinction is to make it **loop-level** — propose altered/reharmonized versions of the USER'S ACTUAL loop (apply `suggestSubstitutions` across every loop chord to generate modified progressions), i.e. the "computed modifications" option deferred earlier. The engine (L-73) now exists to power it. Keep same-style-siblings for now; revisit as a distinct feature.
|
||||||
- **TryThis duplicate-name-loop rotation (Luthier, small — L-74 gate 2026-07-13):** `loop.indexOf(currentChord)` returns the FIRST index, so a loop with a repeated chord name (e.g. a collapsed 12-bar where I7 recurs) double-advances the rotation (~2× pacing) and can mislabel Rule D's "next" chord. Subs for the current chord stay correct. Honest fix needs App to pass a true playhead station index (beyond L-74's mount-only lock) — wire it if TryThis ever couples to the loop engine.
|
- **TryThis duplicate-name-loop rotation (Luthier, small — L-74 gate 2026-07-13):** `loop.indexOf(currentChord)` returns the FIRST index, so a loop with a repeated chord name (e.g. a collapsed 12-bar where I7 recurs) double-advances the rotation (~2× pacing) and can mislabel Rule D's "next" chord. Subs for the current chord stay correct. Honest fix needs App to pass a true playhead station index (beyond L-74's mount-only lock) — wire it if TryThis ever couples to the loop engine.
|
||||||
|
- **TryThis/related category-tag contrast (Muse, tiny — L-75/L-76 gate 2026-07-13):** the decorative category tag uses `text-gray-500` at 9px (~3.4:1, under AA for small text) — the app-wide secondary-label idiom (also in section headers/DebugView), so pre-existing and non-load-bearing; consider a bump to `gray-400` system-wide someday.
|
||||||
- **ProgressionBanner strip chips are div-onClick (Muse, small):** pre-existing a11y debt — history/loop chips should be real buttons (L-50 gate 2026-07-11; RelatedProgressions' chips already show the pattern to copy).
|
- **ProgressionBanner strip chips are div-onClick (Muse, small):** pre-existing a11y debt — history/loop chips should be real buttons (L-50 gate 2026-07-11; RelatedProgressions' chips already show the pattern to copy).
|
||||||
- **Test-infra note (any agent writing throwaway SSR harnesses):** a bare `import('esbuild')` inside a `data:` module hook dies silently (ERR_MODULE_NOT_FOUND in the hooks thread, exit 1, zero output) — smoke.mjs:1047's interpolated `import.meta.resolve` pattern is load-bearing; prefer esbuild pre-bundle harnesses (confirmed empirically twice, 2026-07-11).
|
- **Test-infra note (any agent writing throwaway SSR harnesses):** a bare `import('esbuild')` inside a `data:` module hook dies silently (ERR_MODULE_NOT_FOUND in the hooks thread, exit 1, zero output) — smoke.mjs:1047's interpolated `import.meta.resolve` pattern is load-bearing; prefer esbuild pre-bundle harnesses (confirmed empirically twice, 2026-07-11).
|
||||||
- **GUITAR_SHAPES self-audit in smoke (Critic, small — P-62 gate suggestion 2026-07-11):** pc-spell every GUITAR_SHAPES entry against its quality in smoke — the validator audits only KB chord steps, never the shape library itself; would have caught both wrong shapes below and guards future additions.
|
- **GUITAR_SHAPES self-audit in smoke (Critic, small — P-62 gate suggestion 2026-07-11):** pc-spell every GUITAR_SHAPES entry against its quality in smoke — the validator audits only KB chord steps, never the shape library itself; would have caught both wrong shapes below and guards future additions.
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Related-area space layout — Try-this side-by-side + Related 2×2 (task D-75)
|
||||||
|
|
||||||
|
**Sprint:** `sprint-dashboard-polish` · **Owner:** Muse · **Impl tasks:** L-75 (TryThis.jsx + App mount) · L-76 (RelatedProgressions.jsx). Concept doc only — no code here.
|
||||||
|
|
||||||
|
**User ask (2026-07-13, verbatim, after running the working dashboard — happy with it):**
|
||||||
|
> "for the TRY THIS can we also add the little piano/fretboard next to it? now it also has a 1/2 option, but please put them next to each other, we have enough space in that area. also for the suggested progressions underneath there is space to have it cut in half to make it 2x2 in a similar fashion so we use the space and we fit everything in one big screen."
|
||||||
|
|
||||||
|
The user is pointing at the **LEFT column** of the jam dashboard (`flex-1` ≈ **744px** per `one-screen.md` §1 — far wider than the 500px rail) and asking us to **use its horizontal width**: (A) Try-this stops rotating one-at-a-time and shows **all** the current chord's substitutions **side by side**, each with a **mini instrument diagram** of that chord; (B) RelatedProgressions goes from a vertical list to a **2×2 grid**. Both to compress the vertical footprint so more fits on one screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The honest reversal (say it plainly)
|
||||||
|
|
||||||
|
L-74 shipped a **rotating one-at-a-time** Try-this card (`pickSub` / `advanceOnWrap` / `cycle`), because the same-day directive was *"more surprising, more jam-like, keeps offering new ideas."* The user has now **seen** that rotation and prefers **all-visible side-by-side** ("put them next to each other, we have enough space"). This doc **reverses** the rotation decision. That is not churn for its own sake — it is the user refining after seeing it live, exactly the loop the ensemble is built for. The **engine (`suggestSubstitutions`, L-73) and its rules do not change**; only the UI shape does.
|
||||||
|
|
||||||
|
What survives from L-74's `TryThis.jsx`:
|
||||||
|
- `parseChordName(name)` — pure, keep.
|
||||||
|
- `subsForChord(name, pos, loopArr, keyInfo)` — pure, keep.
|
||||||
|
- `pickSubject(loopArr, keyInfo, currentChord)` — pure, keep. This is what keeps the card **live**: it picks (a) the live `currentChord` if it parses and yields ≥1 sub, else (b) the first loop station that yields subs, else (c) `null` (honest empty). The card still follows the playhead — as you play, the subject chord and its ≤4 subs update.
|
||||||
|
|
||||||
|
What is **deleted**:
|
||||||
|
- `useState(cycle)` + the rotation `useEffect`, `advanceOnWrap`, `pickSub`, the `lastPosRef` / `lastNameRef`, the "N of M" indicator, the dot rail. Smoke coupling: **none** — `scripts/smoke.mjs` has zero references to `advanceOnWrap` / `pickSub` / `parseChordName` (grep-confirmed; its "rotation" hits are all progression-match tests). So L-75 can drop the helpers without touching smoke; its lock stays `TryThis.jsx` + `App.jsx`.
|
||||||
|
|
||||||
|
Honest empties preserved: no key / no loop / atonal → `pickSubject` returns null → render nothing (unchanged). A chord with fewer than 4 valid subs simply shows fewer cards (the engine already `.slice(0,4)`; often 1–3 fire).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Try-this — side by side, one card per sub, each with an instrument diagram
|
||||||
|
|
||||||
|
### 1.1 The layout
|
||||||
|
|
||||||
|
Keep the outer section shell (`rounded-2xl border border-border bg-panel p-3`, the micro-header "Try this instead of {chord} · in {key} {mode}"). Below it, replace the single-card body with a **flex-wrap row of sub-cards**, one per `subject.subs` entry (≤4):
|
||||||
|
|
||||||
|
```
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{subject.subs.map(sub => <SubCard … />)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Container adapts to the instrument's cell width** (§3 proves the footprints — the across-count is NOT a fixed 4):
|
||||||
|
- **guitar / bass** — `flex flex-wrap gap-2`, each card `basis-[168px] grow min-w-[152px]`: the small cells (guitar 75px, bass none) let **4 fit one row** in the 720px interior; with 1–3 subs the cards `grow` to fill.
|
||||||
|
- **piano** — a **`grid grid-cols-2 gap-2`** (2×2): the mini keyboard's real footprint is up to **199px** (§3), so 4 piano cards cannot share one 720px row; a clean 2×2 mirrors the rail's own piano idiom (`VoicingBrowser` dense = `grid grid-cols-2`) and avoids the unbalanced 3+1 that a plain `flex-wrap` would produce.
|
||||||
|
- On a narrow/stacked viewport both collapse to **1-per-row** (`grid-cols-1` / `flex-wrap`) — the DoD reflow, for free.
|
||||||
|
|
||||||
|
Each **SubCard** is a vertical stack (`flex flex-col items-center gap-1.5 rounded-lg border border-border bg-border/30 p-2`):
|
||||||
|
|
||||||
|
1. **Chord chip** (top) — the tappable control, reusing L-74's chip idiom exactly:
|
||||||
|
`button` → `onChordClick?.(sub.label)`, classes `rounded-lg border border-border bg-border px-2 py-1 text-sm font-bold text-gray-100 hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent`. `aria-label={`${sub.label} — ${sub.why}`}`. Tap → `ChordDetailModal` (App's `setSelectedChord`), where the **full** why + all voicings live.
|
||||||
|
2. **Mini instrument diagram** (middle) — of `{sub.rootPc, sub.quality}`, following the **global instrument** (§2). This is the "little piano/fretboard next to it" the user asked for.
|
||||||
|
3. **Category tag** — the L-74 language: `relative` / `borrowed` / `colour` / `V7`, with the `↻` accent glyph **only** for `relative` + `secondary_dominant` (circle categories — unchanged rule, `text-accent`).
|
||||||
|
4. **Why** (bottom) — `sub.why`, `text-[11px] leading-snug text-gray-400`, **`line-clamp-3`** with the full text on the chip's `aria-label` + tap→modal. At ~152–168px card width, clamp-3 keeps whys up to ~75 chars whole; longer ones truncate visibly and the full sentence is one tap away. This is the honest trade of side-by-side: 4 whys visible at a glance costs each one its full width — the user chose see-all over the single full-width why.
|
||||||
|
|
||||||
|
### 1.2 Across-count is a function of the diagram footprint, not a fixed "4"
|
||||||
|
|
||||||
|
The user said *"put them next to each other … we have enough space"* and — resolving the piano case directly — *"for the piano's u can take more space in case there is indeed a bigger piano needed"* (2026-07-13). So: spend the **horizontal** budget as far as each instrument's diagram allows, and let piano take the **vertical** room it needs rather than cramming it. Guitar/bass cells are small (75px / none) → 4 subs sit **one row across**. Piano cells are large (up to 199px, §3) → 4 subs form a **2×2** — still "next to each other," still using the width, just a taller block (which the user explicitly OK'd). The count follows the footprint; the layout adapts (§1.1) instead of forcing a uniform grid that would clip the piano or waste the guitar row. **No MiniPiano change** — the keyboard renders at its honest size.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The instrument prop — threading + which resolver draws each diagram
|
||||||
|
|
||||||
|
`TryThis` needs the global `instrument` (`'guitar' | 'piano' | 'bass'`), which lives in `App.jsx` and is already handed to `JamGuide` (line 849). **Prop path:** add `instrument={instrument}` to the `<TryThis … />` mount in the `relatedSlot` (App.jsx ~line 859) and add `instrument` to the component signature: `TryThis({ loop, keyInfo, currentChord, onChordClick, instrument })`. One-line App change, grep-clean (no audio/callback contract touched) — this is the App half of L-75.
|
||||||
|
|
||||||
|
Per-instrument diagram resolver (mirror the established rail idiom in `VoicingBrowser.jsx:288`/`:327`):
|
||||||
|
|
||||||
|
| instrument | diagram | resolver → render |
|
||||||
|
|---|---|---|
|
||||||
|
| `guitar` | `ChordDiagram size="thumb"` (~75px) | `const shape = getGuitarVoicings(sub.label)[0]` → `<ChordDiagram shape={shape} rootPc={sub.rootPc} size="thumb" />`. `getGuitarVoicings` (voicings.js) resolves the first (barre/open) shape to **absolute** low-E-first frets; ChordDiagram's open-shape path windows them (draws a `5fr` label when up-neck). **Omit** ChordDiagram's own `label` prop — the chip above already names the chord (saves ~13px). If `getGuitarVoicings(sub.label)` is empty (rare), render **no** diagram — just chip + why (honest, no crash). |
|
||||||
|
| `piano` | `MiniPiano size="mini"` (107–199px, §3) | `<MiniPiano voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }} size="mini" />`. **Spread `rootPc` back in** — `pianoVoicing()` output carries none, and `VoicingPiano` needs it to badge "R" correctly (the VoicingBrowser:317-319 caveat). `size="mini"` = the dashboard-density scale already used in the rail's 2×2. |
|
||||||
|
| `bass` | **no diagram — honest** | Bass players read a chord symbol + root, not a chord grip; there is no compact bass-chord renderer, and reusing the guitar/piano diagram under a BASS selector would misrepresent the instrument (same honesty call the licks strip makes for bass). SubCard shows the chip + a small caption `root · {NOTES[sub.rootPc]}` (`text-[10px] text-gray-500`) + the why. Compact, correct, no faked shape. |
|
||||||
|
|
||||||
|
All diagrams use the established note-colour tiers already baked into `ChordDiagram`/`MiniPiano` (accent-purple root, light-purple/gray other tones, amber bass ring) — no new colour, no token change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Space math — the LEFT column (~744px), honest
|
||||||
|
|
||||||
|
- Left column `flex-1 min-w-0` ≈ **744px** (one-screen.md §1). The `relatedSlot` is `flex flex-col gap-3` **directly** in it, so the Try-this `section` spans the full 744px. Section `p-3` (12px each side) → **interior ≈ 720px**.
|
||||||
|
- Per-card diagram footprints (recomputed from source geometry):
|
||||||
|
- **Guitar** `ChordDiagram size="thumb"`: `padL 14 + gridW 55 + padR 6 = 75px` wide; `padT 11 + gridH 55 + padB 6 = 72px` tall (label omitted). Root-independent.
|
||||||
|
- **Piano** `MiniPiano size="mini"` — the window is **C-octave-boundary anchored, NOT span-anchored** (MiniPiano.jsx:200-207): `octStart = ⌊minNote/12⌋`, `OCTAVES = max(1, ⌈(maxNote − octStart·12)/12⌉)`, `SVG_W = (22·(7·OCTAVES + 1) + 2)·0.60`. A voicing whose *interval span* is under an octave STILL renders **2 octaves** whenever its notes straddle a C boundary — so "span ≤ 1 octave" does **not** imply "renders 1 octave." Worked: F♯ major triad `pianoVoicing({rootPc:6})` → notes `[6,10,13]` (span 7) → `octStart 0`, `maxNote 13` → `OCTAVES = ⌈13/12⌉ = 2` → `SVG_W = (22·15 + 2)·0.60 = 332·0.60 = **199.2px**`. This fires for **every triad quality at roots pc ≥ 6 (F♯–B)** (maxNote = root + top-interval > 12) and for shells at most roots (the +12 lift pushes the 7th over the next C) — i.e. roughly **half** of all sub roots, and sub roots routinely land upper-half (any G♯/A♯/B relative / borrowed / secondary-dominant candidate). One octave (107px) is the *best* case (triad roots pc 0–5); **199.2px is the planning footprint.** MiniPiano.jsx:29-31's own header documents this "2-octave mini cell (213.2px)". `SVG_H = (60 + 4)·0.60 = **38.4px** — octave-INDEPENDENT`: the window widens, height never does.
|
||||||
|
- **Across-count per instrument** (interior 720px, `gap-2` = 8px; an SVG's hard `width` attr floors the flex item's min-content at ≈ its px, so `basis-[168px]` cannot shrink a piano cell below its keyboard):
|
||||||
|
- **Guitar** — cell 75px, card ~152px min (diagram + `p-2` + chip width) → `4·152 + 3·8 = 632 ≤ 720` → **4-across, one row.**
|
||||||
|
- **Bass** — no diagram, card ~152px → **4-across, one row.**
|
||||||
|
- **Piano** — worst-case cell 199.2px, card ~215px (+ `p-2` 16) → `3·215 + 2·8 = 661 ≤ 720` but `4·215 + 3·8 = 884 > 720`. So **at most 3 share a row**, and 4 can't. Laid out as a **2×2 grid** (§1.1, per user's "take more space"): 2 cells/row, `2·215 + 8 = 438 ≤ 720` (roomy, each keyboard gets its full width) → **2 across × up to 2 rows.**
|
||||||
|
- **Row / block heights** (`SVG_H` is octave-independent, so piano height is driven purely by sub-count, not cell width):
|
||||||
|
- **Guitar** card: chip ~22 + diagram 72 + why (clamp-3 @11px/1.35 ≈ 45) + tag ~12 + `p-2` 16 + inner gaps ~10 ≈ **~177px** → block (1 row + header 20 + `p-3` 24) ≈ **~221px.**
|
||||||
|
- **Piano** card: chip ~22 + diagram 38 + why ~45 + tag ~12 + `p-2` 16 + gaps ~12 ≈ **~145px**. 4 subs → 2×2 = `2·145 + 8 ≈ 298` → block (+ 44 chrome) ≈ **~342px**. ≤3 subs → one row → block ≈ **~189px.**
|
||||||
|
- **Bass** card ≈ chip 22 + root caption 14 + why 45 + tag 12 + `p-2` 16 + gaps 12 ≈ **~121px** → block ≈ **~165px.**
|
||||||
|
- **Headline:** guitar & bass show all ≤4 subs **4-across in one row** (~165–221px block). Piano shows them as a **2×2** taking the full room each 199px keyboard needs — **~189px** block for ≤3 subs, **~342px when all 4 fire** (two rows). Only piano's across-count/row-count vary; height is width-independent (38.4px SVG). This taller piano block is the honest, user-sanctioned cost of "the little piano next to it."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. RelatedProgressions → 2×2 grid (L-76, layout only)
|
||||||
|
|
||||||
|
### 4.1 The change
|
||||||
|
|
||||||
|
Today the populated list is `<ul className="flex flex-col gap-2.5">` of up to `RELATED_MAX_ENTRIES` (5) entries, each = name + meta row (style/level/annotation-or-role) + `ChordChain` chips. **Swap the container** to a 2-column grid:
|
||||||
|
|
||||||
|
```
|
||||||
|
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `<li>` (its inner markup — name row, `LevelBadge`, role/annotation, `ChordChain`) is **untouched**. `grid-cols-1` on narrow (the stacked reflow), `sm:grid-cols-2` at width. No ranking, scoring, `collapseChanges`, `siblingRole`, or annotation logic changes — L-76 is **presentation only**.
|
||||||
|
|
||||||
|
### 4.2 The cap: 5 → display **4** (no ranker change)
|
||||||
|
|
||||||
|
A clean 2×2 wants **4** cells; 5 leaves an orphan in a lonely third row that reads as broken. **Decision:** the ranker keeps `RELATED_MAX_ENTRIES = 5` (untouched — no smoke re-pin, no exported-constant edit); the **component renders `primary.slice(0, 4)`** into the grid. Entries are score-sorted, so this drops only the **lowest-scoring** 5th sibling — honest ("top 4"), and a future "show all" affordance could reveal it. This keeps L-76 strictly layout-scoped.
|
||||||
|
|
||||||
|
### 4.3 Which sections grid
|
||||||
|
|
||||||
|
Per L-72's finding-A, `secondary` (cross-style) is **currently always `[]`**: when a style is locked the panel is same-style-only; when unlocked, everything lands in `primary`. So **only `primary` is ever populated** → only it needs the grid. The optional cross-style section stays dormant; **if** a future change repopulates `secondary`, it gets its own `grid grid-cols-2` block below its own subheading (spec'd, not built). The **idle** dashed card and the two **empty-state sentences** ("You're on the only {style} loop…", "Nothing in the songbook genuinely relates…") stay single-column — they are prose, not a grid.
|
||||||
|
|
||||||
|
### 4.4 Space + height
|
||||||
|
|
||||||
|
- 2-col cell width ≈ `(720 − 10 gap) / 2 ≈ 355px`. A `ChordChain` of up to 8 chips (~40px each) wraps to ~2 rows inside 355px — fine, same chips, just narrower flow.
|
||||||
|
- Cell height ≈ name 18 + meta 16 + chain (2 chip-rows ~30 each) 60 ≈ **~95–100px**. Block = header ~20 + subheader ("Try these in {style}") ~18 + (2 rows × 100 + 10 gap) 210 + `p-3` 24 ≈ **~272px**.
|
||||||
|
- The old vertical **5**-list block ≈ header 20 + subheader 18 + 5 × ~90 + 4 × 10 + 24 ≈ **~552px**. **2×2(4) ≈ 272px → saves ~280px** — the compression the user is after ("cut in half"), almost exactly halved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The whole left column on one screen (honest)
|
||||||
|
|
||||||
|
Usable content box at 1280×900 ≈ **836px** tall (one-screen.md §4). Left stack in jam-view = instrument view + licks strip + `relatedSlot` (`flex-1 min-h-0 overflow-y-auto` absorber = TryThis + RelatedProgressions). Heights are **section-inclusive** (own header + `p-3`); gaps = 3 × `gap-3` = 36px.
|
||||||
|
|
||||||
|
| build / instrument | instrument | licks | try-this | related | +gaps | **total** | vs 836 box |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| **current L-74** (guitar) | 240 | 190 | ~154 (rotating, 1 card) | ~552 (5-list) | 36 | **~1172** | overflow **~336px** |
|
||||||
|
| **D-75 new — guitar** | 240 | 190 | ~221 (4-across, 1 row) | ~272 (2×2) | 36 | **~959** | overflow **~123px** |
|
||||||
|
| **D-75 new — bass** | 240 | 190 | ~165 (4-across, 1 row) | ~272 | 36 | **~903** | overflow **~67px** |
|
||||||
|
| **D-75 new — piano, ≤3 subs** | 240 | 190 | ~189 (one row) | ~272 | 36 | **~927** | overflow **~91px** |
|
||||||
|
| **D-75 new — piano, 4 subs** | 240 | 190 | ~342 (2×2, 2 rows) | ~272 | 36 | **~1080** | overflow **~244px** |
|
||||||
|
|
||||||
|
**Honest headline:** the RelatedProgressions 2×2 halves that block in every case (**~552 → ~272, −280px**) — the compression the user asked for. Guitar and bass compress hard: the fold shrinks from the current build's ~336px to **~67–123px** (a micro-flick). Piano is the honest exception the user pre-approved: because MiniPiano's C-anchored window makes each keyboard up to 199px, four piano subs form a **2×2 (two rows, ~342px)**, so the piano-with-4-subs fold is **~244px** — still ~90px better than the current build, and only in the corner case where all four sub-rules fire *and* the diagram is piano (many chords yield 2–3 subs → piano one row → ~91px fold). The residue is absorbed only by the related area's `overflow-y-auto` (instrument + licks never move); a fold-flick is within the standing scroll license, and the user explicitly OK'd more vertical space for the piano. At **1280×960+** guitar/bass/piano-≤3 clear; piano-4-subs still wants a short flick for the related bottom row.
|
||||||
|
|
||||||
|
(The `flex-1 min-h-0 overflow-y-auto` absorber already exists — jam-view fit is exact by construction; only the related area, never the instrument/licks, absorbs the residue.)
|
||||||
|
|
||||||
|
> **Future option (out of D-75 scope):** the only lever to shrink the piano block further is a **span-anchored** MiniPiano `mini` window (crop to the voicing's actual keys instead of C-boundary octaves) — a MiniPiano code change, option (b), deliberately deferred. Flag for a follow-up task if the piano fold proves annoying in play.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Bounded scopes (disjoint)
|
||||||
|
|
||||||
|
- **L-75** — `src/components/TryThis.jsx` (rewrite: drop the rotation state/effect + `pickSub`/`advanceOnWrap` + indicator; keep `parseChordName`/`subsForChord`/`pickSubject`; render `subject.subs` as a `flex-wrap` row of SubCards, each with the instrument-following diagram per §2) **+** `src/App.jsx` (add `instrument={instrument}` to the TryThis mount — one line, grep-clean). No smoke coupling (§0). Disjoint from L-76.
|
||||||
|
- **L-76** — `src/components/RelatedProgressions.jsx` **only** (`ul` container `flex flex-col` → `grid grid-cols-1 sm:grid-cols-2`; `primary.slice(0, 4)` for display; idle/empty states untouched; **no** ranking/scoring/constant change). Disjoint from L-75.
|
||||||
|
|
||||||
|
Serialise or worktree — the two locks share no file. No `tailwind.config.js` change (no new colour); no `theory.js` / `piano.js` / `voicings.js` change (resolvers consumed as-is).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Rejected alternatives (≥2)
|
||||||
|
|
||||||
|
1. **Keep the rotation AND add a diagram to the single card.** Rejected: the user has seen rotation and explicitly asked for side-by-side ("put them next to each other, we have enough space") — a single card leaves the 744px width the user pointed at mostly empty, and re-litigates a decision the user has already moved past.
|
||||||
|
2. **One big shared diagram for all subs** (e.g. mark all subs on a single fretboard/piano, or a circle-of-fifths mini). Rejected: the subs differ in root, quality, **and** the right instrument grip; a per-sub diagram is exactly what teaches "play *this* shape instead," and it preserves the tap→modal-per-chord affordance. A merged diagram is unreadable and loses per-chord tapping.
|
||||||
|
3. **A uniform 2×2 for try-this across all instruments.** Rejected as the *default*: for guitar/bass (75px / no diagram) a 2×2 wastes half the row and is taller than 4-across for no benefit. Piano adopts a 2×2 only because its ~199px keyboards genuinely can't fit 4-across (§3) and the user OK'd more space for it — an instrument-driven adaptation, not a uniform choice.
|
||||||
|
4. **Cram/shrink the piano keyboards to force 4-across.** Rejected (and the user directly vetoed it, 2026-07-13): the `mini` SVG carries a hard width attr that floors the flex item, so "shrinking" would clip the keyboard; the honest fix is to let piano take a 2×2 and more vertical space. (A genuine width fix = span-anchored MiniPiano window = §5's deferred option b.)
|
||||||
|
4. **Change `RELATED_MAX_ENTRIES` 5→4 in the ranker for the 2×2.** Rejected: that edits an exported constant + forces a smoke re-pin, pulling L-76 out of "layout only." A component-side `slice(0, 4)` gets the clean grid with zero engine/scoring/test churn.
|
||||||
@@ -861,6 +861,7 @@ export default function App() {
|
|||||||
keyInfo={effectiveKey}
|
keyInfo={effectiveKey}
|
||||||
currentChord={currentChord}
|
currentChord={currentChord}
|
||||||
onChordClick={setSelectedChord}
|
onChordClick={setSelectedChord}
|
||||||
|
instrument={instrument}
|
||||||
/>
|
/>
|
||||||
<RelatedProgressions
|
<RelatedProgressions
|
||||||
loop={detectedProgression}
|
loop={detectedProgression}
|
||||||
|
|||||||
@@ -413,8 +413,11 @@ export default function RelatedProgressions({ loop, keyInfo, onChordClick }) {
|
|||||||
Try these in {activeStyleLabel}
|
Try these in {activeStyleLabel}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul className="flex flex-col gap-2.5">
|
{/* D-75 §4: 2-col grid to use the left column's width; display cap
|
||||||
{primary.map(entry => (
|
4 for a clean 2×2 (render-time slice — RELATED_MAX_ENTRIES and the
|
||||||
|
ranker are untouched). Stacks to 1-col on narrow. */}
|
||||||
|
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||||
|
{primary.slice(0, 4).map(entry => (
|
||||||
<li key={`${entry.style}-${entry.id}`} className="min-w-0">
|
<li key={`${entry.style}-${entry.id}`} className="min-w-0">
|
||||||
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
|
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
|
||||||
<span className="text-sm font-semibold text-gray-100">{entry.name}</span>
|
<span className="text-sm font-semibold text-gray-100">{entry.name}</span>
|
||||||
|
|||||||
+94
-102
@@ -1,22 +1,27 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { CHORD_TYPES, NOTES, suggestSubstitutions } from '../lib/theory'
|
||||||
import { CHORD_TYPES, suggestSubstitutions } from '../lib/theory'
|
|
||||||
import { chordRootPC } from '../lib/match'
|
import { chordRootPC } from '../lib/match'
|
||||||
|
import { getGuitarVoicings } from '../lib/voicings'
|
||||||
|
import { pianoVoicing } from '../lib/piano'
|
||||||
|
import ChordDiagram from './ChordDiagram'
|
||||||
|
import MiniPiano from './MiniPiano'
|
||||||
|
|
||||||
// ─── TryThis — the rotating "Try this" substitution nudge (task L-74) ─────────
|
// ─── TryThis — side-by-side substitution cards, each with a mini diagram (L-75) ─
|
||||||
//
|
//
|
||||||
// For the chord under the playhead, in the detected key, show ONE curated
|
// For the chord under the playhead, in the detected key, show ALL curated
|
||||||
// substitution at a time (from theory.js `suggestSubstitutions`, the L-73 engine)
|
// substitutions (from theory.js `suggestSubstitutions`, the L-73 engine) SIDE BY
|
||||||
// and CYCLE to the next valid idea each time the loop completes a pass. The user
|
// SIDE — one card per idea — each carrying a mini instrument diagram of that
|
||||||
// asked for something "more surprising, more jam-like, keeps offering new ideas"
|
// chord (guitar ChordDiagram / piano MiniPiano / bass root caption), following
|
||||||
// (2026-07-13) — so a single nudge that rotates, not a static 4-chip grid.
|
// the global instrument. Reverses L-74's rotating one-at-a-time card: the user
|
||||||
// Spec: docs/design/try-this-subs.md §4/§5 (superseded to rotation by L-74's
|
// saw the rotation and asked for all-visible ("put them next to each other, we
|
||||||
// ledger row). Sibling of RelatedProgressions; same micro-header + chip idiom.
|
// have enough space"). Spec: docs/design/related-area-layout.md §1–3.
|
||||||
|
// Sibling of RelatedProgressions; same micro-header + chip idiom.
|
||||||
//
|
//
|
||||||
// Props:
|
// Props:
|
||||||
// loop : string[] | null — the detected repeating progression (chord names)
|
// loop : string[] | null — the detected repeating progression (chord names)
|
||||||
// keyInfo : { root, mode, confidence } | null — effective key (frames the why)
|
// keyInfo : { root, mode, confidence } | null — effective key (frames the why)
|
||||||
// currentChord : string — the chord sounding NOW ("F", "Dm7")
|
// currentChord : string — the chord sounding NOW ("F", "Dm7")
|
||||||
// onChordClick : fn(label) — opens ChordDetailModal (App's setSelectedChord)
|
// 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
|
// Invert CHORD_TYPES suffix → quality — the app idiom (mirrors
|
||||||
// RelatedProgressions' SUFFIX_TO_QUALITY). All 14 suffixes are unique.
|
// RelatedProgressions' SUFFIX_TO_QUALITY). All 14 suffixes are unique.
|
||||||
@@ -37,24 +42,6 @@ export function parseChordName(name) {
|
|||||||
return { rootPc, quality }
|
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 =
|
// Circle-of-fifths categories (relative = inner ring, secondary_dominant =
|
||||||
// clockwise step). Only these earn the ↻ glyph — borrowed/extension are modal /
|
// clockwise step). Only these earn the ↻ glyph — borrowed/extension are modal /
|
||||||
// vertical colour and must NOT claim the circle (docs §6).
|
// vertical colour and must NOT claim the circle (docs §6).
|
||||||
@@ -85,7 +72,7 @@ function subsForChord(name, pos, loopArr, keyInfo) {
|
|||||||
|
|
||||||
// Choose the SUBJECT chord the card speaks about (pure — no hooks):
|
// 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 —
|
// (a) the live currentChord, if it parses and yields ≥1 sub (the playing case —
|
||||||
// whether or not it's a loop station; unchanged behaviour);
|
// 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
|
// (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;
|
// in a locked key shows the card immediately, with no live input;
|
||||||
// (c) else null — no loop and no valid live chord → honest empty.
|
// (c) else null — no loop and no valid live chord → honest empty.
|
||||||
@@ -102,97 +89,102 @@ function pickSubject(loopArr, keyInfo, currentChord) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TryThis({ loop, keyInfo, currentChord, onChordClick }) {
|
// The mini diagram drawn inside a SubCard, chosen by the global instrument (§2):
|
||||||
const [cycle, setCycle] = useState(0)
|
// guitar → first resolved guitar shape (absolute frets) via ChordDiagram thumb;
|
||||||
const lastPosRef = useRef(null)
|
// no shape available → no diagram (honest, chip + why only).
|
||||||
const lastNameRef = useRef(null)
|
// piano → pianoVoicing for the chord, rootPc spread back in so the "R" badge
|
||||||
|
// lands correctly (VoicingBrowser:317-319 caveat), MiniPiano size mini.
|
||||||
|
// bass → no diagram (no honest compact bass-chord renderer); a small
|
||||||
|
// root · {note} caption instead.
|
||||||
|
function SubDiagram({ sub, instrument }) {
|
||||||
|
if (instrument === 'piano') {
|
||||||
|
return (
|
||||||
|
<MiniPiano
|
||||||
|
voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }}
|
||||||
|
size="mini"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (instrument === 'bass') {
|
||||||
|
return (
|
||||||
|
<span className="text-[10px] text-gray-500">
|
||||||
|
root · {NOTES[((sub.rootPc % 12) + 12) % 12]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// guitar (default): first resolved shape; omit the label — the chip names it.
|
||||||
|
const shape = getGuitarVoicings(sub.label)[0]
|
||||||
|
if (!shape) return null
|
||||||
|
return <ChordDiagram shape={shape} rootPc={sub.rootPc} size="thumb" />
|
||||||
|
}
|
||||||
|
|
||||||
|
// One substitution card — chip (tappable → modal) + mini diagram + tag + why.
|
||||||
|
function SubCard({ sub, instrument, onChordClick }) {
|
||||||
|
const isCircle = CIRCLE_CATEGORIES.has(sub.category)
|
||||||
|
const tag = CATEGORY_TAG[sub.category] ?? sub.category
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col items-center gap-1.5 rounded-lg border border-border bg-border/30 p-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChordClick?.(sub.label)}
|
||||||
|
aria-label={`${sub.label} — ${sub.why}`}
|
||||||
|
className="shrink-0 rounded-lg border border-border bg-border px-2 py-1 text-sm font-bold leading-none text-gray-100 outline-none transition-all cursor-pointer hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
{sub.label}
|
||||||
|
</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">
|
||||||
|
{tag}
|
||||||
|
{isCircle && <span className="ml-0.5 text-accent" aria-hidden="true">↻</span>}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<p className="line-clamp-3 text-center text-[11px] leading-snug text-gray-400">
|
||||||
|
{sub.why}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TryThis({ loop, keyInfo, currentChord, onChordClick, instrument = 'guitar' }) {
|
||||||
const loopArr = Array.isArray(loop) && loop.length ? loop : null
|
const loopArr = Array.isArray(loop) && loop.length ? loop : null
|
||||||
const subject = pickSubject(loopArr, keyInfo, currentChord)
|
const subject = pickSubject(loopArr, keyInfo, currentChord)
|
||||||
|
|
||||||
// Position/name that DRIVE rotation. When following a live loop chord this is its
|
|
||||||
// playhead index (wrap → advance). When following a live chord not in the loop it
|
|
||||||
// is −1 (advance on chord change). In the (b) fallback the subject is a fixed loop
|
|
||||||
// station with no playhead — pos is stable and name is stable, so the effect fires
|
|
||||||
// once and the shown idea holds steady (visible, no flicker).
|
|
||||||
const rotationPos = subject ? subject.pos : -1
|
|
||||||
const subjectName = subject ? subject.name : null
|
|
||||||
|
|
||||||
// Rotation: advance one idea each loop pass (playhead position wraps toward 0).
|
|
||||||
// With no loop position (−1), advance on each genuine subject-chord change so the
|
|
||||||
// nudge refreshes as the player moves. Refs carry the previous pos/name across
|
|
||||||
// renders (no side effects during render). Runs unconditionally (before returns).
|
|
||||||
useEffect(() => {
|
|
||||||
const prevPos = lastPosRef.current
|
|
||||||
const prevName = lastNameRef.current
|
|
||||||
lastPosRef.current = rotationPos
|
|
||||||
lastNameRef.current = subjectName
|
|
||||||
if (subjectName == null) return
|
|
||||||
if (rotationPos >= 0) {
|
|
||||||
setCycle(c => advanceOnWrap(c, prevPos, rotationPos))
|
|
||||||
} else if (prevName != null && prevName !== subjectName) {
|
|
||||||
setCycle(c => c + 1)
|
|
||||||
}
|
|
||||||
}, [rotationPos, subjectName])
|
|
||||||
|
|
||||||
// No subject (no loop + no valid live chord) or 0 subs → no card.
|
// No subject (no loop + no valid live chord) or 0 subs → no card.
|
||||||
if (!subject) return null
|
if (!subject) return null
|
||||||
const picked = pickSub(subject.subs, cycle)
|
|
||||||
if (!picked) return null
|
|
||||||
|
|
||||||
const subs = subject.subs
|
|
||||||
const subjectChord = subject.name
|
const subjectChord = subject.name
|
||||||
|
const subs = subject.subs
|
||||||
|
|
||||||
const { sub, idx, total } = picked
|
// Piano keyboards are up to ~199px wide (C-anchored window), so 4 cannot share a
|
||||||
const showIndicator = total > 1
|
// 720px row — lay them out 2×2. Guitar (75px cell) and bass (no diagram) sit
|
||||||
const isCircle = CIRCLE_CATEGORIES.has(sub.category)
|
// 4-across in one flex-wrap row; with 1–3 subs the cards grow to fill (§1.1/§3).
|
||||||
const tag = CATEGORY_TAG[sub.category] ?? sub.category
|
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
|
||||||
className="rounded-2xl border border-border bg-panel p-3"
|
className="rounded-2xl border border-border bg-panel p-3"
|
||||||
aria-label={`Try this instead of ${subjectChord}`}
|
aria-label={`Try this instead of ${subjectChord}`}
|
||||||
>
|
>
|
||||||
<h4 className="mb-2 flex items-baseline justify-between gap-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
<span>
|
Try this instead of {subjectChord}
|
||||||
Try this instead of {subjectChord}
|
{keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
|
||||||
{keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
|
|
||||||
</span>
|
|
||||||
{showIndicator && (
|
|
||||||
<span className="shrink-0 normal-case tracking-normal text-gray-500">
|
|
||||||
{idx + 1} of {total}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div className="flex items-baseline gap-2">
|
<div className={listClass}>
|
||||||
<button
|
{subs.map((sub, i) => (
|
||||||
type="button"
|
<div key={`${sub.label}-${i}`} className={cardBasis}>
|
||||||
onClick={() => onChordClick?.(sub.label)}
|
<SubCard sub={sub} instrument={instrument} onChordClick={onChordClick} />
|
||||||
aria-label={`${sub.label} — ${sub.why}`}
|
</div>
|
||||||
className="shrink-0 rounded-lg border border-border bg-border px-2 py-1 text-sm font-bold leading-none text-gray-100 outline-none transition-all cursor-pointer hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent"
|
))}
|
||||||
>
|
|
||||||
{sub.label}
|
|
||||||
</button>
|
|
||||||
<p className="min-w-0 text-[11px] leading-snug text-gray-400">
|
|
||||||
<span className="mr-1 align-baseline text-[9px] uppercase tracking-wide text-gray-500">
|
|
||||||
{tag}
|
|
||||||
{isCircle && <span className="ml-0.5 text-accent" aria-hidden="true">↻</span>}
|
|
||||||
</span>
|
|
||||||
{sub.why}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showIndicator && (
|
|
||||||
<div className="mt-2 flex items-center gap-1" aria-hidden="true">
|
|
||||||
{subs.map((_, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className={`h-1 w-1 rounded-full ${i === idx ? 'bg-accent' : 'bg-border'}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user