Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 072920ff0e | |||
| 8ebdc73958 | |||
| 9415daf4a0 | |||
| 7561566ceb | |||
| d543b986dc | |||
| ede3f2e372 | |||
| dc503807ef | |||
| 8c018d697d | |||
| e6957160f6 | |||
| 957ce88fd6 | |||
| 51c2be90cc | |||
| 2905a4b979 | |||
| c62cd60595 |
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$comment": "Team-shared restricted options. Destructive/irreversible commands are DENIED so Claude proposes them for the user to run instead of executing them. Authoritative policy: see the 'Destructive operations — NEVER delete' section of CLAUDE.md.",
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Bash(rm:*)",
|
||||
"Bash(rmdir:*)",
|
||||
"Bash(git branch -d:*)",
|
||||
"Bash(git branch -D:*)",
|
||||
"Bash(git branch --delete:*)",
|
||||
"Bash(git push --delete:*)",
|
||||
"Bash(git push -d:*)",
|
||||
"Bash(git push origin --delete:*)",
|
||||
"Bash(git push github --delete:*)",
|
||||
"Bash(git push origin -d:*)",
|
||||
"Bash(git push github -d:*)",
|
||||
"Bash(git tag -d:*)",
|
||||
"Bash(git tag --delete:*)",
|
||||
"Bash(git remote remove:*)",
|
||||
"Bash(git remote rm:*)",
|
||||
"Bash(git reset --hard:*)",
|
||||
"Bash(git clean -f:*)",
|
||||
"Bash(git clean -d:*)",
|
||||
"Bash(git clean -x:*)",
|
||||
"Bash(git push --force:*)",
|
||||
"Bash(git push -f:*)",
|
||||
"Bash(git push --force-with-lease:*)",
|
||||
"Bash(git checkout --:*)",
|
||||
"PowerShell(Remove-Item:*)",
|
||||
"PowerShell(rm:*)",
|
||||
"PowerShell(del:*)",
|
||||
"PowerShell(rmdir:*)",
|
||||
"PowerShell(Clear-Content:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,24 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Destructive operations — NEVER delete (hard rule)
|
||||
|
||||
**Claude must never EXECUTE a destructive or irreversible command. Always ask, and hand the user the exact command(s) to run themselves.**
|
||||
|
||||
This covers (non-exhaustively):
|
||||
- Deleting files/directories: `rm`, `rm -rf`, `rmdir`, `del`, `Remove-Item`.
|
||||
- Deleting branches: `git branch -d` / `-D`, `git push --delete`, `git push <remote> :branch`.
|
||||
- Deleting tags/remotes: `git tag -d`, `git remote remove` / `rm`.
|
||||
- Discarding work: `git reset --hard`, `git checkout -- <path>`, `git clean -f`.
|
||||
- Force-pushing: `git push -f` / `--force` / `--force-with-lease`.
|
||||
- Dropping data: `DROP`, `TRUNCATE`, destructive migrations.
|
||||
|
||||
Instead: print the command(s) in a fenced block with a one-line note on what each does and what it affects, and let the **user run them**. Never run them yourself, even when the desired outcome is clear — `rm` and `-d` are **prompted, never executed**.
|
||||
|
||||
Leave regular branches alone (`main`, the active sprint branch) unless the user explicitly names them. Before calling any branch "stale", prove containment (`git branch --merged`, 0 unique commits) and report that evidence — do not act on it.
|
||||
|
||||
These are also enforced as `permissions.deny` rules in `.claude/settings.json` (defense-in-depth), but this behavioral rule is authoritative and covers cases the patterns can't.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
|
||||
+12
-1
@@ -80,7 +80,13 @@ 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 |
|
||||
| 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)
|
||||
| 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 |
|
||||
| D-76 | **Related-area v2** (user 2026-07-13, "one last change"): (a) VOICINGS RAIL — currently shows all loop stations when a loop matches, but only the SINGLE current chord in the heard-live/no-loop fallback (JamGuide railContent :373-424). User wants: ALWAYS show multiple chords' voicings, MOST RECENT FIRST — loop chords when matched (KEEP canonical order + a moving "now" playhead highlight — design call, reordering would break GlanceRail's between-adjacent voice-leading chips AND reshuffle rows every chord, contradicting the user's anti-jump preference; most-recent-first applies to the NO-LOOP history rail), else the recent distinct chord history (from chordHistory, cap ~6, most-recent-first). Never just one. **User confirm pending on the loop-ordering deviation (asked 2026-07-13).** (b) TRY THIS — cap at **3** suggestions (was 4); **STABLE fixed layout** (reserve 3 slots so 2-vs-3 subs never shifts position — the user: "annoying when the layout changes then u dont know where to look"); each suggestion shows **≥3 ways to play it** — guitar = **3×3** (3 subs × up to 3 getGuitarVoicings shapes each), piano = **1** MiniPiano each ("for piano it can be just one thats okay"), bass = root caption. Honest space math (left col ~744px for the 3×3 guitar; rail column for the multi-chord history). No user gate: pick strongest, ≥2 rejected alts | design | ready | — | `docs/design/related-area-v2.md` | rail always-multi-chord-most-recent-first spec'd; try-this 3-cap + stable-3-slot + 3-shapes-guitar/1-piano with honest math; L-77/L-78 bounded |
|
||||
| L-77 | Voicings rail — HYBRID (user re-scope 2026-07-13): when a loop is found, HIGHLIGHT the loop chords as a "loop" group AND list the OTHER recent distinct chords from full chordHistory underneath ("also played"); when no loop, just the recent distinct history. Show **≥4 chords total** as soon as any chord history exists (top up from history to reach 4+). Loop group keeps canonical order + moving "now" playhead (voice-leading chips valid); history group most-recent-first, no chips. Each chord expands to its full voicing gallery (keep composition) | engineering | done `9415daf` (direct push per user) | D-76 | `src/components/JamGuide.jsx`, `src/components/GlanceRail.jsx` | loop highlighted + others underneath; ≥4 when history exists; no-loop → history ≥4 most-recent-first; galleries + focus preserved; audio contract untouched; build+validate+smoke green — **done** (GlanceRail +`showTransitions` prop (default true, byte-compat); JamGuide `recentDistinctChords`/`historyStations` memo + hybrid railContent; RAIL_TOTAL_CAP 8 / NO_LOOP_HISTORY_CAP 6; 903/903; only 2 locked files, contract-clean. Maestro folded 2 honesty fixes on shared GlanceRail: history group drops "the loop"/"playhead" framing; history rows non-focusable (no inert button). Full independent Critic gate waived per user "push when its done") |
|
||||
| L-78 | Try-this per D-76: max 3 subs, stable 3-slot layout, 3 guitar shapes each (3×3) / 1 piano each | engineering | done `d543b98` (direct push per user 2026-07-13; verified build+validate+smoke 903/903 — full independent Critic gate waived for the push) | D-76 | `src/components/TryThis.jsx` (+ per doc) | ≤3 subs in fixed slots; guitar 3 shapes/sub; piano 1; layout stable 2-vs-3; reactivity preserved; 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, L-77, L-78 | `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.
|
||||
|
||||
@@ -274,7 +280,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).
|
||||
- **`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).
|
||||
- **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/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).
|
||||
- **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.
|
||||
@@ -299,6 +307,9 @@ Emphasis this sprint: **ship the Jam Guide MVP** (put the 8 guitar style packs o
|
||||
|
||||
_(Maestro appends one line per completed iteration: `<date> · <task ids done> · <next>`.)_
|
||||
|
||||
- 2026-07-13 · **sprint-dashboard-polish — direct push (user "push everything now")** · done: L-78 (`d543b98` Try-this 3×3 ways-to-play — up to 3 guitar grips / 1 piano per suggestion, fixed anti-jump 3-slot frame; verified build+validate-KB+smoke 903/903; full independent Critic gate waived at user request) · L-77 re-scoped SAME DAY per user: rail should HIGHLIGHT loop chords when a loop is found AND list the other recent chords underneath, ≥4 total once history exists (was: replace-with-history). Rebuilding + pushing per "add this to the branch also and push when its done" · next: build L-77 (hybrid loop+history rail) → verify → push both remotes
|
||||
- 2026-07-13 · **L-77 done + pushed** (`9415daf`) · hybrid voicings rail: loop group highlighted ("the loop") + "also played" recent-distinct chords underneath (most-recent-first, ≥4 once history exists), no-loop → history rail replaces the single-chord fallback; GlanceRail gains `showTransitions` (default true, loop byte-unchanged). Maestro verified build+validate+smoke 903/903 and folded 2 honesty fixes on the shared GlanceRail (history group no longer says "loop"/"playhead"; history rows non-focusable → no inert button). App.jsx/audio untouched. Pushed both remotes · next: C-70 sprint-end sweep (aria one-liner + PR updates) when user confirms the rail looks right
|
||||
|
||||
- 2026-06-14 · done: M-01 · in-review (awaiting user pick): D-00a/b/c viz concepts · next: D-SEL (user chooses) → then L-01/D-01/D-02 implement chosen concept
|
||||
- 2026-06-15 · done: D-00a/b/c, D-SEL (Roadmap chosen), L-01 (match.js + banner refactor), L-01b (guideTones/voiceLeadingPairs/soloScale) — Critic PASS both · next: L-02 (JamGuide shell) → D-01 (RoadmapTrack) ‖ D-01b (ChordDiagram)
|
||||
- 2026-06-15 · done: L-02 (JamGuide shell + App mount), D-01 (RoadmapTrack), D-01b (ChordDiagram), D-02 (assembly + playhead rotation + thumbnails) — Critic PASS all 4, audio contract intact (App.jsx +10/−0) · **Roadmap Jam Guide MVP is live & builds green** · next: D-03 (fretboard cross-link, backlog) ‖ P-01 (gospel content) ‖ H-01 (README reframe)
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Related-area v2 — always-multi-chord voicings rail + stable 3×3 Try-this (task D-76)
|
||||
|
||||
**Sprint:** `sprint-dashboard-polish` · **Owner:** Muse · **Impl tasks:** L-77 (voicings rail: GlanceRail.jsx + JamGuide.jsx) · L-78 (TryThis.jsx). Concept doc only — no code here. Supersedes the relevant parts of `related-area-layout.md` (D-75) that this refines.
|
||||
|
||||
**User ask (2026-07-13, verbatim — "one last change then we push everything"):**
|
||||
> "for the right part 'different voicings' there is currently only one chord visible, we said to show all chords in the loop with the most recent first. at all times we wanna see all different voicings of the different chords. also for the TRY THIS part please always keep the layout the same if it's 2 or 3 chords i want it to stay in place its annoying when the layout changes then u dont know where to look. max it on 3 TRY THIS suggestions, PLUS please add at least 3 ways to play it. i think we have enough space for 3x3 guitar. for piano it can be just one thats okay."
|
||||
|
||||
Two independent surfaces:
|
||||
- **(A) The voicings RAIL** (right column, `JamGuide` `railContent`) must show **multiple chords, most recent first, at all times** — the "only one chord visible" is the no-loop heard-live fallback.
|
||||
- **(B) TRY THIS** (left column) caps at **3** suggestions, holds a **stable fixed layout** (2-vs-3 subs never shifts position), and gives each suggestion **≥3 ways to play it** — a **3×3** guitar grid, **1** piano keyboard, bass root caption.
|
||||
|
||||
---
|
||||
|
||||
## 0. Where "only one chord" actually comes from (say it plainly)
|
||||
|
||||
`JamGuide` `railContent` (JamGuide.jsx:373-435) has **three** states:
|
||||
|
||||
1. `match.matched` → `<GlanceRail stations={stationVoicings} …/>` — **already** shows every loop chord as a vertical row (canonical KB order, playhead highlight, full per-row gallery). This is NOT the bug.
|
||||
2. `liveChord` (no KB match but a chord is committing, JamGuide.jsx:252) → a **single** `<VoicingBrowser rootPc quality show dense/>` inside a "Heard live · {chord} — every voicing" section (JamGuide.jsx:411-425). **This is the "only one chord."**
|
||||
3. else → the slim idle line.
|
||||
|
||||
So the fix is scoped to state 2 (and the idle-with-history case): replace the single-chord fallback with a **multi-chord, most-recent-first history rail**. State 1 (the loop) already satisfies "all chords of the loop" — the open question is only whether to physically reorder it most-recent-first (§1.3: no, and why).
|
||||
|
||||
---
|
||||
|
||||
## 1. The voicings rail — always multiple chords, most recent first
|
||||
|
||||
### 1.1 Decision: keep GlanceRail-for-loop + a new history fallback — do NOT fully unify
|
||||
|
||||
**Recommendation: keep the two paths, replace only the single-chord fallback.**
|
||||
|
||||
- **Loop matched** → `GlanceRail` as today (canonical order, playhead, per-row gallery, the D-40 voice-leading education). Untouched.
|
||||
- **No loop** → a **new `HistoryRail`** driven by the recent DISTINCT `chordHistory`, most-recent-first, each rendered as a GlanceRail-style voicing row.
|
||||
|
||||
**Why not unify** (always drive the rail from recent-distinct-history, loop adds highlighting): the loop rail's education is *canonical-order-dependent*. `GlanceRail` computes `voiceLeadingPairs` between **canonically adjacent** stations (GlanceRail.jsx:234-241) and renders per-row `TransitionChip`s ("next F→E · ½ step down") plus the wrap chip ("loop") — a voice-leading **wheel**. Reordering those rows most-recent-first would make every "next …" chip point at the wrong neighbour, and the wrap chip lie. Unifying therefore *destroys* the loop education to satisfy a directive the loop already meets another way (§1.3). So: two paths, one shared row idiom, no loss.
|
||||
|
||||
This is also the **minimal, lowest-regression** change: `StationRow` (loop) stays byte-identical; the new `HistoryRow`/`HistoryRail` is additive.
|
||||
|
||||
### 1.2 The no-loop history rail — data source & most-recent-first
|
||||
|
||||
`chordHistory` is already a `JamGuide` prop (JamGuide.jsx:186; App passes it at App.jsx:846) — **no App change needed.** Build the rail entirely from it:
|
||||
|
||||
```
|
||||
recentDistinct(chordHistory, cap = 6):
|
||||
seen = new Set(); out = []
|
||||
for name from END of chordHistory backwards: // newest first
|
||||
parsed = parseChord(name) // voicings.js — same parser JamGuide already imports (line 12)
|
||||
if (!parsed) continue // unparseable → skip, no crash
|
||||
if (seen.has(name)) continue // DISTINCT by chord name (subsumes consecutive-dedup)
|
||||
seen.add(name); out.push({ ...parsed, label: name })
|
||||
if (out.length === cap) break
|
||||
return out // out[0] = the chord sounding now
|
||||
```
|
||||
|
||||
**"Most recent first" mapping:** walk `chordHistory` from the tail (the newest commit is `chordHistory[len-1]` = `currentChord`, App.jsx:572). `out[0]` is therefore the current chord, `out[1]` the previous distinct chord, etc. **Distinct-by-name** (not merely consecutive-dedup) so "F Am F Am F" yields `[F, Am]` — the user wants the *different chords*, each once, not a ping-pong; the most-recent occurrence fixes each chord's slot. Cap **6** rows.
|
||||
|
||||
Each entry parses to `{ rootPc, type, label }` (voicings.js `parseChord` returns `{ rootPc, type }`). Feed each as a row.
|
||||
|
||||
**Honest edges:**
|
||||
- **1 chord ever played** → 1 row. Unavoidable (there is genuinely one chord) — but it is now a *row in the rail idiom*, not a bespoke single-chord section. Strictly better than today, which also showed one.
|
||||
- **20 chords** → the 6 most-recent distinct, newest first. Older ones roll off (the rail is a live window, not a log — the KnowledgeDock Voicings section is the browse-everything surface).
|
||||
- **All same chord** (F F F F) → 1 row.
|
||||
- **Unparseable name** → skipped; if *nothing* parses → fall through to the existing slim idle line (state 3), unchanged.
|
||||
|
||||
### 1.3 What "most recent first" means for the LOOP (honest)
|
||||
|
||||
For the loop, `GlanceRail` stays **canonical KB order** and the **playhead** (accent ring + "now" badge + `aria-current`, GlanceRail.jsx:184-188) marks the chord you're on. That IS "most recent, unmistakable" — it is exactly the active-chord-at-a-glance the DoD demands — without reordering. Physically reordering the loop rows most-recent-first is **rejected** (§6.1) because it breaks the voice-leading wheel. If the user, after seeing this, still wants the loop *physically* re-sequenced, that is a follow-up that must first rework/retire the `TransitionChip` education — flag to Maestro, don't silently do it.
|
||||
|
||||
### 1.4 The HistoryRail row anatomy (reuse, minus loop chrome)
|
||||
|
||||
A new `HistoryRow` (sibling of `StationRow` in GlanceRail.jsx — keep `StationRow` untouched for the loop). Per row, most-recent-first:
|
||||
|
||||
- **Header line:** `st.label` (plain bold text — no focus-toggle button; history chords aren't stations, so no fretboard-focus wiring → simpler, and the main Fretboard already follows `currentChord`). `SoloLabel` + `AimDots` **kept** (GlanceRail.jsx exported atoms) — those are *per-chord* guide-tone education, correct for any chord, and give the history rail visual parity with the loop rail. **No** `TransitionChip` (no canonical adjacency), **no** "next" tag.
|
||||
- **"now" marker on row 0** (the most-recent chord): the same `border-accent bg-accent/10 ring-2 ring-accent` + "now" badge `StationRow` uses for the playhead — so the active chord is unmistakable (DoD). Rows 1-5 recede to the `opacity: 0.85` floor, exactly as inactive loop rows.
|
||||
- **Gallery — as-is:** `<VoicingBrowser rootPc={st.rootPc} quality={st.type} show={instrument} dense max={4} />`. Guitar ≤4 recommended-first shapes on one line, piano 2×2 mini, **no ▶** — the identical per-row gallery the loop rail uses. `recommended` is **null** (an arbitrary heard chord has no authored KB play), which `VoicingBrowser` already handles (order untouched, no badge).
|
||||
|
||||
`HistoryRail` header (the section h4): `Recent chords · newest first — every voicing`, with the one-line microcopy `Following what you play — the newest chord is up top; no repeating loop yet.` (tokens: `bg-panel`, `border-border`, `text-gray-500`).
|
||||
|
||||
### 1.5 Bass, no loop
|
||||
|
||||
The no-loop bass path today renders `BassGuideRows` with a **single** live station (JamGuide.jsx:404-410). Extend it to the **same recent-distinct stations, most-recent-first**: pass the `recentDistinct` array to `BassGuideRows` with approach suppressed (history is not a loop → no "approach into the next chord" line; reuse the existing `live` gate, which already turns `next` off at BassGuideRows.jsx:557, or add a `history` flag if the "· heard live" header wording should change to "· recent"). Root + fifth per row; row 0 marked "now". This keeps all three instruments honest and multi-chord.
|
||||
|
||||
### 1.6 Rail fit (unchanged geometry)
|
||||
|
||||
The rail column is 500px with the D-51 margin-hardened interior (~451px worst-case; GlanceRail.jsx:5-13). `HistoryRow` reuses the exact `VoicingBrowser dense max={4}` cell math, so guitar 4-across (374 ≤ 451) and the piano 2×2 pair (442 ≤ 451) fits are **byte-for-byte the loop rail's** — no new fit risk. Up to 6 rows stack vertically inside the column's existing `overflow-y-auto` scroller (JamGuide.jsx:493-497); the user owns the scrollbar (no auto-scroll — GlanceRail.jsx:46-49's law holds).
|
||||
|
||||
---
|
||||
|
||||
## 2. Try this — max 3, stable 3-slot layout, ≥3 ways to play each
|
||||
|
||||
### 2.1 The reshape: from cards-across to rows-of-shapes
|
||||
|
||||
Today `TryThis` (TryThis.jsx:171-189) lays subs **side by side** (columns = subs), each `SubCard` carrying **one** diagram. D-76 turns this **90°**: **rows = subs, columns = shapes.** Each sub becomes a **horizontal row**: an identity block (chord chip + tag + why) on the left, and its **≥3 ways to play it** on the right. Three sub-rows stacked = the "3×3" the user pictured (3 subs × 3 guitar shapes).
|
||||
|
||||
Cap: `subject.subs.slice(0, 3)` (was up to 4 — TryThis.jsx:160 `subs`). The engine (`suggestSubstitutions`, L-73) and `pickSubject`/`subsForChord`/`parseChordName` are **unchanged** — reactivity preserved: subject still follows `currentChord`, subs recompute per chord (TryThis.jsx:79-90, 152-160). Only the render and the cap change.
|
||||
|
||||
### 2.2 The STABLE fixed 3-slot layout (the anti-jump)
|
||||
|
||||
**Always render exactly 3 slots**, in order:
|
||||
|
||||
```
|
||||
<div className="flex flex-col gap-2">
|
||||
{[0,1,2].map(i => subs[i] ? <SubRow sub={subs[i]} …/> : <EmptySlot key={i}/>)}
|
||||
</div>
|
||||
```
|
||||
|
||||
- A present sub → `SubRow`. An absent one → `EmptySlot`: a **subtle placeholder** that holds the **exact SubRow height** for the current instrument — `rounded-lg border border-dashed border-border/50 bg-transparent` at low opacity, a centred muted `—` (`text-gray-600 text-[11px]`), `aria-hidden`. It reads as "an intentionally empty slot," not as broken, and — critically — the first two rows **never move** whether the chord yields 2 or 3 subs. This is the exact complaint the user raised ("annoying when the layout changes then u dont know where to look"): with fixed slots, sub #1 and sub #2 are always in the same pixel band.
|
||||
- **Uniform row height per instrument** (not across instruments — the global `instrument` is fixed at any moment, so all 3 slots share one instrument's row height): guitar `min-h-[100px]`, piano `min-h-[72px]`, bass `min-h-[64px]` (§3 math). `EmptySlot` inherits the same `min-h-*`.
|
||||
|
||||
Why 3 slots and not "as many as fire": stability is worth a little reserved whitespace. The user explicitly chose predictable position over density here.
|
||||
|
||||
### 2.3 SubRow — guitar (the 3×3), piano (1), bass (caption)
|
||||
|
||||
Outer row: `flex items-center gap-3 rounded-lg border border-border bg-border/30 p-2 min-h-[…]` (reuses the SubCard token palette — TryThis.jsx:126).
|
||||
|
||||
**Left identity block** (`flex flex-col gap-1 shrink-0 w-[180px]`), all instruments:
|
||||
1. **Chord chip** — the tappable control, reused verbatim from TryThis.jsx:127-134: `button → onChordClick?.(sub.label)`, `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`. Inline beside it: the **category tag** (`relative`/`borrowed`/`colour`/`V7`) + the `↻` accent glyph for circle categories only (`CIRCLE_CATEGORIES`, TryThis.jsx:48-55 — unchanged rule).
|
||||
2. **Why** — `sub.why`, `text-[11px] leading-snug text-gray-400`, `line-clamp-2` (the identity block is ~180px wide; 2 lines ≈ 50 chars; full text stays on the chip `aria-label` + tap→modal). Wider than the old 152px card, so whys clip less.
|
||||
|
||||
**Right "ways to play" block**, by instrument:
|
||||
|
||||
| instrument | right block |
|
||||
|---|---|
|
||||
| **guitar** | `flex gap-2` of **up to 3** `ChordDiagram size="thumb"` (~75px), from `getGuitarVoicings(sub.label).slice(0, 3)`. Each thumb captioned underneath with its shape name (`text-[9px] text-gray-500` — e.g. "E Barre", "A Barre / 5fr") so the three read as **three genuinely different grips**, not a repeat. **Omit** ChordDiagram's own `label` (the chip names the chord). Fewer than 3 shapes exist (dim/aug, §2.4) → show what exists; the row does not pad with blanks (the shapes left-align, the row height is fixed by `min-h`). |
|
||||
| **piano** | **one** `MiniPiano size="mini"` (per the user: "for piano it can be just one thats okay"): `<MiniPiano voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }} size="mini" />`. Spread `rootPc` back in (the VoicingBrowser.jsx:317-319 caveat) so "R" badges correctly. |
|
||||
| **bass** | no diagram — `root · {NOTES[sub.rootPc]}` caption (`text-[10px] text-gray-500`), the same honesty call TryThis/BassGuideRows already make (there is no compact bass-chord renderer). |
|
||||
|
||||
All diagrams use the established tiers baked into `ChordDiagram`/`MiniPiano` (accent-purple root, gray/light-purple other tones) — **no new colour, no token change.**
|
||||
|
||||
### 2.4 Honest empties
|
||||
|
||||
- **Fewer than 3 subs** for the chord → the missing slot(s) render `EmptySlot` (§2.2). Position of the present subs is unchanged.
|
||||
- **A guitar sub with fewer than 3 shapes**: `getGuitarVoicings` returns all placeable shapes — barre forms are always placeable (any root ≤ fret 15). The engine only ever emits **{min, maj, dom7, maj7, add9, maj6, min7, sus4}** (every `mk()` call in theory.js:801-917 — dim/aug are **never** substitution candidates, so they can't appear here). Of those, **maj/min/dom7/maj7/min7/sus4/maj6** carry ≥3 barre shapes → the full 3×3 (voicings.js:10-53, 76-105). The genuine 2-shape case is **add9**: `getGuitarVoicings` returns only 2 barre shapes (E Barre, A Shape) for **9 of 12 roots**, and 3 only for **C/G/D** (which add an open Cadd9/Gadd9/Dadd9, voicings.js:116-121) → that sub-row honestly shows **2** thumbs, `slice(0,3)` simply yielding two. And add9 is **routinely** emitted — Rule C picks add9 for a major-family chord whose maj7 added tone is non-diatonic (e.g. in D major, the V chord A → **Aadd9**), which is common on major-family V-type chords. So a 2-thumb guitar row is a **regular, honest occurrence**, not a rarity; it never crashes and never pads with fakes.
|
||||
- **No subject** (no key / no loop / atonal) → `pickSubject` returns null → render nothing (TryThis.jsx:157, unchanged).
|
||||
|
||||
### 2.5 Reactivity preserved
|
||||
|
||||
`pickSubject(loopArr, keyInfo, currentChord)` still chooses the live chord first, then the first loop station that yields subs (TryThis.jsx:79-90). As the progression evolves the subject and its (now ≤3) subs recompute every commit — the user confirmed they want it to evolve with the playing. The only behavioural deltas: **cap 3** and **3 shapes each**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Space math — honest
|
||||
|
||||
**Left column** `flex-1 min-w-0` ≈ **744px**, section `p-3` → interior **≈ 720px** (one-screen.md §1; carried from D-75 §3). Footprints from source geometry: `ChordDiagram size="thumb"` = **75×72px** (padL 14 + gridW 55 + padR 6; padT 11 + gridH 55 + padB 6). `MiniPiano size="mini"` = up to **199×38px** (C-anchored window, D-75 §3 — width varies with root, **height is octave-independent 38.4px**).
|
||||
|
||||
**Guitar SubRow.** Right block: 3 thumbs = `3·75 + 2·8 = 241px` (+ 9px shape captions → ~84px tall). Left identity 180px + gaps ≈ `180 + 12 + 241 = 433 ≤ 720` — comfortable, ~287px to spare. Row height = max(thumb 72 + caption 12 = 84, chip 22 + why-2-lines 30 = 52) + `p-2` 16 ≈ **~100px** → `min-h-[100px]`. **3 slots** = `3·100 + 2·8 = 316`; block (+ header 20 + `p-3` 24) ≈ **~360px** — fixed, 2 subs or 3.
|
||||
|
||||
**Piano SubRow.** Right block: one `mini` keyboard ≤199px wide, 38px tall. `180 + 12 + 199 = 391 ≤ 720`. Row = max(38, 52) + 16 ≈ **~72px** → `min-h-[72px]`. **3 slots** = `3·72 + 16` ≈ 232; block ≈ **~276px**.
|
||||
|
||||
**Bass SubRow.** No diagram. Row = 52 + 16 ≈ **~64px** → `min-h-[64px]`. 3 slots ≈ 208; block ≈ **~250px**.
|
||||
|
||||
Note the 3×3 guitar block (~360px) is **taller** than D-75's 4-across single row (~221px) — the honest cost of "at least 3 ways to play each," which the user asked for directly.
|
||||
|
||||
### One-screen budget @ 1280×900 (usable box ≈ 836px, one-screen.md §4)
|
||||
|
||||
Left jam-view stack = instrument view + licks strip + `relatedSlot` (`flex-1 min-h-0 overflow-y-auto` absorber = **new** Try-this + RelatedProgressions 2×2 from L-76); gaps = 3·`gap-3` = 36px.
|
||||
|
||||
| instrument | instrument | licks | try-this (new) | related 2×2 | +gaps | **total** | vs 836 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **guitar** | 240 | 190 | ~360 (3×3, 3 slots) | ~272 | 36 | **~1098** | overflow **~262px** |
|
||||
| **piano** | 240 | 190 | ~276 (1 kbd, 3 slots) | ~272 | 36 | **~1014** | overflow **~178px** |
|
||||
| **bass** | 240 | 190 | ~250 (caption, 3 slots) | ~272 | 36 | **~988** | overflow **~152px** |
|
||||
|
||||
**Scroll story (honest):** the instrument view and licks strip stay **pinned** (they are `xl:shrink-0`, JamGuide.jsx:476-481); only the `relatedSlot` — the `flex-1 min-h-0 overflow-y-auto` absorber (JamGuide.jsx:485-489) — scrolls. So the taller 3×3 try-this deepens the fold in the *related area only*: guitar wants a ~262px flick to reach the bottom of the RelatedProgressions grid, piano ~178px, bass ~152px. This is ~139px more than D-75's guitar fold (~123px) — the direct, user-requested trade for 3 grips per sub. Instrument + licks never move; the active chord and the Try-this subjects are always above the fold. At **1280×1080+** guitar clears; piano/bass clear at ~1100. The `overflow-y-auto` absorber makes the fit exact by construction — the residue lands only in the related area, never on the instrument/licks the player watches.
|
||||
|
||||
*(The right rail is independent: `HistoryRail`'s ≤6 rows live in the 500px column's own `overflow-y-auto` scroller — they do not affect the left-column budget.)*
|
||||
|
||||
---
|
||||
|
||||
## 4. Bounded scopes (file-disjoint → parallelisable)
|
||||
|
||||
- **L-77 — the voicings rail.** Files: **`src/components/GlanceRail.jsx`** (add `HistoryRow` + exported `HistoryRail`, reusing the `VoicingBrowser dense max={4}` gallery + `SoloLabel`/`AimDots`; `StationRow`/`GlanceRail` for the loop stay byte-identical) **+ `src/components/JamGuide.jsx`** (a `recentDistinct(chordHistory)` memo; the no-loop `railContent` branch → `HistoryRail` for guitar/piano and `BassGuideRows` fed the recent-distinct stations with approach suppressed for bass; the single-`VoicingBrowser` fallback is retired). **No App change** (`chordHistory` already flows in, JamGuide.jsx:186 / App.jsx:846). No `theory.js`/`voicings.js`/`piano.js`/`tailwind.config.js` change (resolvers/tokens consumed as-is).
|
||||
- **L-78 — Try this.** File: **`src/components/TryThis.jsx` only.** Cap `subs.slice(0,3)`; replace the cards-across body with the 3-slot rows-of-shapes (§2); keep `parseChordName`/`subsForChord`/`pickSubject`. **No App change** (`instrument` already threaded by L-75, App.jsx:864). No engine change (§2.5).
|
||||
|
||||
**Disjoint:** L-77 = {GlanceRail.jsx, JamGuide.jsx}; L-78 = {TryThis.jsx}. **Zero file overlap** → they can run in parallel (worktree) or serial, in any order. Neither touches `theory.js`, `voicings.js`, `piano.js`, `App.jsx`, or `tailwind.config.js`. C-70's `VoicingBrowser.jsx` aria fold is disjoint from both.
|
||||
|
||||
*(If a future revision wants the history rail and the loop rail to share one component body, that unification is out of D-76 scope — it would re-touch `StationRow` and risk the loop path; keep it a separate task.)*
|
||||
|
||||
---
|
||||
|
||||
## 5. What does NOT change
|
||||
|
||||
- The engine `suggestSubstitutions` and its rules/why-copy (L-73) — untouched; only the display cap (4→3) and per-sub shape count change.
|
||||
- The loop rail (`GlanceRail`/`StationRow`): canonical order, playhead, voice-leading wheel, focus→fretboard — all byte-identical.
|
||||
- `VoicingBrowser`, `ChordDiagram`, `MiniPiano`, `pianoVoicing`, `getGuitarVoicings` — consumed as-is.
|
||||
- Tokens: `bg-panel`, `bg-surface`, `border-border`, `text-accent`/`bg-accent`, the accent ring, the established note-colour tiers. **No new colour, no `tailwind.config.js` edit.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Rejected alternatives (≥2)
|
||||
|
||||
1. **Reorder the LOOP rail most-recent-first (physically re-sequence the rows).** Rejected: `GlanceRail` builds `voiceLeadingPairs` between *canonically adjacent* stations and shows per-row "next F→E · ½ step down" + a "loop" wrap chip (GlanceRail.jsx:118-132, 234-241). Reordering makes every transition chip point at the wrong neighbour and the wrap chip lie — it trades away the loop's whole voice-leading education for a re-sort the playhead highlight already conveys. The current chord is unmistakable *in place* via the "now" ring.
|
||||
2. **Fully unify: always drive the rail from recent-distinct-history, loop merely overlays highlighting.** Rejected: same casualty — the transition/wrap education is canonical-order-only, and a history-ordered rail can't carry it. Unifying is a larger refactor that *reduces* the loop's teaching value. Two paths sharing one row idiom is smaller and loses nothing.
|
||||
3. **Keep Try-this side-by-side (columns = subs) and just add 2 more diagrams per card.** Rejected: 3 subs × 3 guitar thumbs across = `3·(3·75) ≈ 675px` of diagrams fighting for a 720px row with no room for chips/whys, and it re-introduces the 2-vs-3 horizontal jump the user hates (cards grow/reflow). Rows = subs, columns = shapes, fixed 3 slots is the stable-position answer.
|
||||
4. **Let the number of Try-this slots follow the sub count (render 2 rows when 2 subs).** Rejected: that is exactly the "layout changes, you don't know where to look" jump the user called out. Reserving 3 slots with a subtle placeholder costs a little whitespace and buys constant position — the user's explicit priority.
|
||||
5. **Shrink guitar thumbs to force less height / cram the 3×3.** Rejected: 75px is the established `ChordDiagram thumb` size used everywhere (rail, KC, modal); shrinking it below the shared size fragments the visual language and hurts readability mid-jam. The honest cost of 3 grips per sub is ~140px more fold in the *related area only* (§3) — pinned instrument/licks are unaffected, and the user asked for the 3 grips knowing it takes room.
|
||||
@@ -861,6 +861,7 @@ export default function App() {
|
||||
keyInfo={effectiveKey}
|
||||
currentChord={currentChord}
|
||||
onChordClick={setSelectedChord}
|
||||
instrument={instrument}
|
||||
/>
|
||||
<RelatedProgressions
|
||||
loop={detectedProgression}
|
||||
|
||||
@@ -48,16 +48,31 @@
|
||||
// page mid-jam. The L-33 auto-centre effect was deleted in L-40 and must never
|
||||
// return; the highlight travels, the user owns the scrollbar.
|
||||
//
|
||||
// HYBRID rail reuse (task L-77, refines D-76; user directive 2026-07-13 — "highlight
|
||||
// the loop chords when it finds a loop but also add the other chords underneath"):
|
||||
// JamGuide now renders GlanceRail TWICE — once for the canonical LOOP group (the
|
||||
// original call, byte-unchanged) and once for the "also played" recent-history
|
||||
// group. The history group passes `showTransitions={false}` (see the prop below)
|
||||
// because history order is NOT canonical: the between-adjacent voice-leading chips
|
||||
// and the "next" tag are only true for the loop's canonical wheel, so they are
|
||||
// suppressed for the history rail. Everything else (per-row gallery, "now" via
|
||||
// activeIndex, the SoloLabel/AimDots guide-tone education) is correct for any chord
|
||||
// and stays. Default (`showTransitions` absent) is byte-compatible with the loop.
|
||||
//
|
||||
// Pure presentational. Props:
|
||||
// stations — [{ shape, voicing, rootPc, quality, label, rn }] canonical order
|
||||
// activeIndex — playhead station (canonicalPos); -1 = loop known, playhead
|
||||
// not — no row is marked "now" (content never changes either way)
|
||||
// not — no row is marked "now" (content never changes either way).
|
||||
// The history group passes -1 (no playhead — see JamGuide).
|
||||
// focusedIndex — the focused station index, or null (nothing focused)
|
||||
// onFocus — fn(index|null): toggle a station's focus
|
||||
// instrument — 'guitar' | 'piano' (VoicingBrowser `show`; bass never mounts
|
||||
// this rail — JamGuide renders BassGuideRows instead, D-40 §3)
|
||||
// keyRoot — key tonic pitch class 0–11 (ChordDiagram fret placement)
|
||||
// keyMode — key mode name (soloScale's minor-key dominant nudge)
|
||||
// showTransitions — default true (the loop caller is unchanged). false → the
|
||||
// voice-leading TransitionChips and the "next" tag are suppressed
|
||||
// (history order is not canonically adjacent, task L-77).
|
||||
|
||||
import { NOTES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory'
|
||||
import VoicingBrowser from './VoicingBrowser'
|
||||
@@ -164,23 +179,38 @@ function StationRow({
|
||||
style={{ opacity: isNow || isFocused ? 1 : 0.85 }}
|
||||
>
|
||||
{/* ── Header line: identity + the folded roadmap education ── */}
|
||||
{/* Loop rows (onToggleFocus provided) keep the focus-toggle button — the
|
||||
D-03 fretboard guide-tone contract, byte-unchanged. History rows pass
|
||||
no toggle → a plain, non-interactive identity (no inert button /
|
||||
misleading "focus" tooltip / stray focus ring), L-77. */}
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isFocused}
|
||||
onClick={onToggleFocus}
|
||||
title={isFocused
|
||||
? `Unfocus ${st.label} — clear its guide tones from the fretboard`
|
||||
: `Focus ${st.label} — light its guide tones on the fretboard`}
|
||||
className="flex min-h-[32px] items-center gap-2 rounded px-1 outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<span className="text-sm font-bold leading-none text-gray-100">{st.label}</span>
|
||||
{st.rn && (
|
||||
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
|
||||
{st.rn}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{onToggleFocus ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isFocused}
|
||||
onClick={onToggleFocus}
|
||||
title={isFocused
|
||||
? `Unfocus ${st.label} — clear its guide tones from the fretboard`
|
||||
: `Focus ${st.label} — light its guide tones on the fretboard`}
|
||||
className="flex min-h-[32px] items-center gap-2 rounded px-1 outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<span className="text-sm font-bold leading-none text-gray-100">{st.label}</span>
|
||||
{st.rn && (
|
||||
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
|
||||
{st.rn}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex min-h-[32px] items-center gap-2 px-1">
|
||||
<span className="text-sm font-bold leading-none text-gray-100">{st.label}</span>
|
||||
{st.rn && (
|
||||
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
|
||||
{st.rn}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isNow && (
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent">
|
||||
now
|
||||
@@ -222,17 +252,21 @@ function StationRow({
|
||||
|
||||
export default function GlanceRail({
|
||||
stations = [], activeIndex = -1, focusedIndex = null, onFocus, instrument, keyRoot, keyMode,
|
||||
showTransitions = true,
|
||||
}) {
|
||||
const n = stations.length
|
||||
if (n === 0) return null
|
||||
const nextIndex = activeIndex >= 0 && n > 1 ? (activeIndex + 1) % n : -1
|
||||
// The "next" tag is a loop-adjacency claim → suppressed for the history group.
|
||||
const nextIndex = showTransitions && activeIndex >= 0 && n > 1 ? (activeIndex + 1) % n : -1
|
||||
|
||||
// Voice-leading rails: rail i leaves station i for station (i+1) mod n — the
|
||||
// last rail wraps back to station 0 (the loop is a wheel). The headline rail
|
||||
// is the 7→3 (voiceLeadingPairs lists the 7th first); a one-chord loop has
|
||||
// no transition to speak of.
|
||||
// no transition to speak of. Suppressed entirely for the history group
|
||||
// (showTransitions=false) — its rows are recent-first, not canonically
|
||||
// adjacent, so a "next F→E" chip would point at the wrong neighbour (L-77).
|
||||
const rails = stations.map((st, i) => {
|
||||
if (n < 2) return null
|
||||
if (!showTransitions || n < 2) return null
|
||||
const next = stations[(i + 1) % n]
|
||||
return voiceLeadingPairs(
|
||||
{ root: st.rootPc, quality: st.quality },
|
||||
@@ -240,13 +274,30 @@ export default function GlanceRail({
|
||||
)[0] ?? null
|
||||
})
|
||||
|
||||
// Section framing (L-77 honesty fix): showTransitions === true ⟺ the canonical
|
||||
// LOOP group; the "also played" history group (showTransitions=false) is recent-
|
||||
// first with no playhead, so it must NOT claim "the loop" / "the playhead". Rows
|
||||
// in the history group are also non-focusable (no onFocus → no inert header
|
||||
// button); the loop group's function keeps its focus toggle byte-unchanged.
|
||||
const isLoop = showTransitions
|
||||
const focusable = typeof onFocus === 'function'
|
||||
const sectionAria = isLoop
|
||||
? 'Voicing variations — every chord of the loop, all expanded'
|
||||
: 'Voicing variations — every recently played chord, all expanded'
|
||||
const sectionTitle = isLoop
|
||||
? 'Variations · every chord, every voicing — the playhead highlights'
|
||||
: 'Variations · every chord, every voicing'
|
||||
const sectionFoot = isLoop
|
||||
? "Voicings follow the loop — the playhead highlights the chord you're on."
|
||||
: 'Recent chords — newest first; every voicing of each.'
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-2xl border border-border bg-panel p-2"
|
||||
aria-label="Voicing variations — every chord of the loop, all expanded"
|
||||
aria-label={sectionAria}
|
||||
>
|
||||
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||
Variations · every chord, every voicing — the playhead highlights
|
||||
{sectionTitle}
|
||||
</h4>
|
||||
|
||||
<div className="flex flex-col gap-2" role="list">
|
||||
@@ -257,7 +308,7 @@ export default function GlanceRail({
|
||||
isNow={i === activeIndex}
|
||||
isNext={i === nextIndex}
|
||||
isFocused={focusedIndex === i}
|
||||
onToggleFocus={() => onFocus?.(focusedIndex === i ? null : i)}
|
||||
onToggleFocus={focusable ? (() => onFocus(focusedIndex === i ? null : i)) : null}
|
||||
instrument={instrument}
|
||||
keyRoot={keyRoot}
|
||||
keyMode={keyMode}
|
||||
@@ -268,9 +319,9 @@ export default function GlanceRail({
|
||||
</div>
|
||||
|
||||
{/* No ▶ anywhere anymore (dashboard-polish.md §3 — "leave them off,
|
||||
better not"); the rail is purely visual and follows the loop. */}
|
||||
better not"); the rail is purely visual. */}
|
||||
<p className="mt-2 text-[11px] text-gray-500">
|
||||
Voicings follow the loop — the playhead highlights the chord you're on.
|
||||
{sectionFoot}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
|
||||
+194
-47
@@ -4,7 +4,6 @@ import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC }
|
||||
import { NOTES, CHORD_TYPES } from '../lib/theory'
|
||||
import GlanceRail, { AimDots, SoloLabel } from './GlanceRail'
|
||||
import BassPatternCard from './BassPatternCard'
|
||||
import VoicingBrowser from './VoicingBrowser'
|
||||
import LickCard, { TechniqueLegend } from './LickCard'
|
||||
import PianoLickCard from './PianoLickCard'
|
||||
import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel'
|
||||
@@ -165,6 +164,92 @@ function lickFitsContext(lick, context) {
|
||||
return wanted.length > 0 && tokens.some(t => wanted.includes(t))
|
||||
}
|
||||
|
||||
// ─── "Also played" history rail helpers (task L-77, refines D-76) ─────────────
|
||||
//
|
||||
// The HYBRID voicings rail (user directive 2026-07-13 — "highlight the loop
|
||||
// chords when it finds a loop but also add the other chords underneath … at
|
||||
// least 4 or more"): a loop group (canonical GlanceRail, untouched) PLUS an
|
||||
// "also played" group of the other recently-played distinct chords, most-recent-
|
||||
// first, and — when no loop is found — just the history group. All parsing reuses
|
||||
// the established app idiom (chordRootPC + the CHORD_TYPES suffix inversion,
|
||||
// mirroring TryThis.jsx `parseChordName` / RelatedProgressions) — no theory
|
||||
// re-derivation.
|
||||
|
||||
// Rail size policy. Overall cap across BOTH groups so the column never runs
|
||||
// away; the no-loop history rail caps a little lower. The ≥4 guarantee falls out
|
||||
// of RAIL_TOTAL_CAP − loopLen ≥ 4 − loopLen for any loopLen ≤ RAIL_TOTAL_CAP:
|
||||
// the history top-up is always allowed to reach four total when four distinct
|
||||
// chords exist (it never fabricates — it shows only what was actually played).
|
||||
const RAIL_TOTAL_CAP = 8 // loop group + "also played" group combined
|
||||
const NO_LOOP_HISTORY_CAP = 6 // no loop matched → history rail alone
|
||||
|
||||
// Invert CHORD_TYPES suffix → quality (the app idiom — mirrors TryThis.jsx /
|
||||
// RelatedProgressions; 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 } via the shared helpers. Returns
|
||||
// null when unparseable (unknown suffix / bad root) so the caller drops it.
|
||||
function parseHistoryChord(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 }
|
||||
}
|
||||
|
||||
// recentDistinctChords(chordHistory, cap, excludeNames) → GlanceRail-shaped
|
||||
// station rows for the "also played" group. Walks chordHistory from the NEWEST
|
||||
// end backward, collecting DISTINCT chord NAMES (first-seen-from-newest wins —
|
||||
// the most-recent occurrence fixes each chord's slot, so a "F Am F Am" ping-pong
|
||||
// yields [Am, F], the different chords each once). Names in `excludeNames` (the
|
||||
// loop group's chords) are skipped so the two groups never duplicate a chord.
|
||||
// Unparseable names are dropped. Returns MOST-RECENT-FIRST, capped. Empty /
|
||||
// undefined history → []. Stations carry identity only (shape/voicing null) — an
|
||||
// arbitrary played chord has no authored KB play, exactly the null `recommended`
|
||||
// GlanceRail already renders gracefully.
|
||||
function recentDistinctChords(chordHistory, cap, excludeNames) {
|
||||
if (!Array.isArray(chordHistory) || cap <= 0) return []
|
||||
const exclude = excludeNames instanceof Set ? excludeNames : new Set(excludeNames ?? [])
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (let i = chordHistory.length - 1; i >= 0; i--) {
|
||||
const name = chordHistory[i]
|
||||
if (seen.has(name)) continue
|
||||
seen.add(name)
|
||||
if (exclude.has(name)) continue
|
||||
const parsed = parseHistoryChord(name)
|
||||
if (!parsed) continue
|
||||
out.push({
|
||||
shape: null,
|
||||
voicing: null,
|
||||
rootPc: parsed.rootPc,
|
||||
quality: parsed.quality,
|
||||
label: name,
|
||||
rn: '', // history is key-relative-agnostic here; rn stays empty (cheap, honest)
|
||||
})
|
||||
if (out.length >= cap) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Small group caption above each rail group (tokens only — no raw hex).
|
||||
function RailGroupCaption({ children, tone = 'loop' }) {
|
||||
return (
|
||||
<p
|
||||
className={
|
||||
'mb-1 px-1 text-[9px] font-semibold uppercase tracking-widest ' +
|
||||
(tone === 'loop' ? 'text-accent/80' : 'text-gray-500')
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── JamGuide — the jam dashboard grid (default export) ───────────────────────
|
||||
//
|
||||
// Props:
|
||||
@@ -324,6 +409,22 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
||||
return stations
|
||||
}, [match.matched, match.progression, match.style, instrument, keyRoot])
|
||||
|
||||
// ── "Also played" history stations (task L-77, refines D-76) ────────────────
|
||||
// The other recently-played DISTINCT chords, most-recent-first, that are NOT in
|
||||
// the loop group. When a loop is matched the cap tops the two groups up toward
|
||||
// RAIL_TOTAL_CAP; with no loop the history rail stands alone (NO_LOOP_HISTORY_
|
||||
// CAP). Keyed on chordHistory (+ the loop via stationVoicings) so it recomputes
|
||||
// as chords commit. Loop chords are excluded by their rendered label so the two
|
||||
// groups never repeat a chord. instrument-agnostic identity — GlanceRail /
|
||||
// BassGuideRows draw the per-chord gallery from {rootPc, quality}.
|
||||
const historyStations = useMemo(() => {
|
||||
const loopNames = match.matched ? new Set(stationVoicings.map(s => s.label)) : null
|
||||
const cap = match.matched
|
||||
? Math.max(RAIL_TOTAL_CAP - stationVoicings.length, 0)
|
||||
: NO_LOOP_HISTORY_CAP
|
||||
return recentDistinctChords(chordHistory, cap, loopNames)
|
||||
}, [match.matched, stationVoicings, chordHistory])
|
||||
|
||||
// ── Authored bass plays (L-42) ──────────────────────────────────────────────
|
||||
// When the matched style ships a bass pack with plays for this progression,
|
||||
// BassGuideRows renders each play's per-station pattern card in the gallery
|
||||
@@ -367,62 +468,108 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
||||
// re-sorting with the jam (D-31 §2.4).
|
||||
const contextStation = stationVoicings[canonicalPos >= 0 ? canonicalPos : 0] ?? null
|
||||
|
||||
// ── The rail (right column at xl / second block stacked): the suggested-
|
||||
// voicings surface — GlanceRail, BassGuideRows, the heard-live gallery, or
|
||||
// the honest idle line (one-screen.md §3, §4). ──
|
||||
// ── The rail (right column at xl / second block stacked) — HYBRID (task L-77,
|
||||
// refines D-76; user directive 2026-07-13: "highlight the loop chords when it
|
||||
// finds a loop but also add the other chords underneath … at least 4 or more").
|
||||
// TWO groups, so multiple chords' voicings are ALWAYS visible (the old single-
|
||||
// chord heard-live fallback is retired):
|
||||
// A) LOOP group (only when a loop matches) — the canonical GlanceRail /
|
||||
// BassGuideRows, byte-unchanged: KB order, moving "now" playhead, valid
|
||||
// between-adjacent voice-leading chips. A subtle "the loop" caption marks
|
||||
// it as THE loop.
|
||||
// B) "ALSO PLAYED" group — the other recent DISTINCT chords (historyStations),
|
||||
// most-recent-first, each expanded to its full voicing gallery. NO voice-
|
||||
// leading chips (showTransitions=false — history order is not canonically
|
||||
// adjacent) and NO "now" badge (activeIndex=-1). With no loop this group
|
||||
// stands alone and IS the rail. The ≥4-total guarantee comes from the
|
||||
// RAIL_TOTAL_CAP top-up in `historyStations` (it shows only chords actually
|
||||
// played — never fabricates).
|
||||
// Empty history + no loop → the slim idle line (unchanged). Bass mirrors the
|
||||
// hybrid via BassGuideRows (`live` on the history group suppresses approach —
|
||||
// history is not a loop). ──
|
||||
const hasHistory = historyStations.length > 0
|
||||
const railContent = match.matched ? (
|
||||
instrument === 'bass' ? (
|
||||
/* Bass rows (D-40 §3): authored pattern cards when the matched style
|
||||
ships a bass pack (L-42), computed roots/fifths/approaches as the
|
||||
honest fallback otherwise. The licks strip hides either way
|
||||
(guitar tab licks are noise to a bassist mid-jam). */
|
||||
<BassGuideRows
|
||||
stations={stationVoicings}
|
||||
activeIndex={canonicalPos}
|
||||
keyMode={keyInfo?.mode}
|
||||
plays={bassPlays}
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<RailGroupCaption>the loop</RailGroupCaption>
|
||||
{/* Bass rows (D-40 §3): authored pattern cards when the matched style
|
||||
ships a bass pack (L-42), computed roots/fifths/approaches as the
|
||||
honest fallback otherwise. */}
|
||||
<BassGuideRows
|
||||
stations={stationVoicings}
|
||||
activeIndex={canonicalPos}
|
||||
keyMode={keyInfo?.mode}
|
||||
plays={bassPlays}
|
||||
/>
|
||||
</div>
|
||||
{hasHistory && (
|
||||
<div>
|
||||
<RailGroupCaption tone="history">also played · newest first</RailGroupCaption>
|
||||
{/* `live` = no approach line (history is not a canonical loop). */}
|
||||
<BassGuideRows
|
||||
stations={historyStations}
|
||||
activeIndex={-1}
|
||||
keyMode={keyInfo?.mode}
|
||||
live
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* The voicing rail — ALL stations expanded as vertical rows; the
|
||||
playhead only highlights (D-41, D-40 §4). */
|
||||
<GlanceRail
|
||||
stations={stationVoicings}
|
||||
activeIndex={canonicalPos}
|
||||
focusedIndex={focusedStation}
|
||||
onFocus={setFocusedStation}
|
||||
instrument={instrument}
|
||||
keyRoot={keyRoot}
|
||||
keyMode={keyInfo?.mode}
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<RailGroupCaption>the loop</RailGroupCaption>
|
||||
{/* The voicing rail — ALL loop stations expanded as vertical rows; the
|
||||
playhead only highlights (D-41, D-40 §4). Untouched. */}
|
||||
<GlanceRail
|
||||
stations={stationVoicings}
|
||||
activeIndex={canonicalPos}
|
||||
focusedIndex={focusedStation}
|
||||
onFocus={setFocusedStation}
|
||||
instrument={instrument}
|
||||
keyRoot={keyRoot}
|
||||
keyMode={keyInfo?.mode}
|
||||
/>
|
||||
</div>
|
||||
{hasHistory && (
|
||||
<div>
|
||||
<RailGroupCaption tone="history">also played · newest first</RailGroupCaption>
|
||||
{/* History group: most-recent-first, no transition chips, no "now". */}
|
||||
<GlanceRail
|
||||
stations={historyStations}
|
||||
activeIndex={-1}
|
||||
instrument={instrument}
|
||||
keyRoot={keyRoot}
|
||||
keyMode={keyInfo?.mode}
|
||||
showTransitions={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : liveChord ? (
|
||||
/* No loop matched, but chords are committing (D-31 §2.3): a single
|
||||
"heard live" gallery, re-aimed on every chord commit. Auto-follow
|
||||
only — nothing plays by itself. Bass: D-40 §3's prose forbids guitar/
|
||||
piano galleries under BASS, so the live chord gets the same computed
|
||||
root/fifth line (no next chord → no approach) instead. */
|
||||
) : hasHistory ? (
|
||||
/* No loop matched, but chords have been played: the "also played" group IS
|
||||
the rail — recent distinct chords, most-recent-first, ≥4 when available.
|
||||
This replaces the old single-chord heard-live fallback (D-76 §0). Bass:
|
||||
D-40 §3 forbids guitar/piano galleries under BASS, so BassGuideRows draws
|
||||
the computed root/fifth line per chord (`live` → no approach). */
|
||||
instrument === 'bass' ? (
|
||||
<BassGuideRows
|
||||
stations={[{ rootPc: liveChord.rootPc, quality: liveChord.type, label: currentChord, rn: '' }]}
|
||||
activeIndex={0}
|
||||
stations={historyStations}
|
||||
activeIndex={-1}
|
||||
keyMode={keyInfo?.mode}
|
||||
live
|
||||
/>
|
||||
) : (
|
||||
<section
|
||||
className="rounded-2xl border border-border bg-panel p-3"
|
||||
aria-label={`Heard live — every ${instrument} voicing of ${currentChord}`}
|
||||
>
|
||||
<h4 className="mb-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||
Heard live · {currentChord} — every voicing
|
||||
</h4>
|
||||
<p className="mb-2 text-[11px] text-gray-500">
|
||||
{detectedProgression?.length
|
||||
? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; following the chord as it commits.`
|
||||
: 'No repeating loop yet — following the chord as it commits.'}
|
||||
</p>
|
||||
<VoicingBrowser rootPc={liveChord.rootPc} quality={liveChord.type} show={instrument} dense />
|
||||
</section>
|
||||
<GlanceRail
|
||||
stations={historyStations}
|
||||
activeIndex={-1}
|
||||
instrument={instrument}
|
||||
keyRoot={keyRoot}
|
||||
keyMode={keyInfo?.mode}
|
||||
showTransitions={false}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
/* Nothing heard yet — one slim line (~40px): the idle rail must not
|
||||
|
||||
@@ -413,8 +413,11 @@ export default function RelatedProgressions({ loop, keyInfo, onChordClick }) {
|
||||
Try these in {activeStyleLabel}
|
||||
</p>
|
||||
)}
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
{primary.map(entry => (
|
||||
{/* D-75 §4: 2-col grid to use the left column's width; display cap
|
||||
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">
|
||||
<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>
|
||||
|
||||
+139
-103
@@ -1,22 +1,34 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { CHORD_TYPES, suggestSubstitutions } from '../lib/theory'
|
||||
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 — the rotating "Try this" substitution nudge (task L-74) ─────────
|
||||
// ─── TryThis — stable 3-slot rows-of-shapes substitution rail (L-78 / D-76) ─────
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
@@ -37,24 +49,6 @@ export function parseChordName(name) {
|
||||
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).
|
||||
@@ -67,6 +61,14 @@ const CATEGORY_TAG = {
|
||||
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
|
||||
@@ -85,7 +87,7 @@ function subsForChord(name, pos, loopArr, keyInfo) {
|
||||
|
||||
// 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; 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
|
||||
// in a locked key shows the card immediately, with no live input;
|
||||
// (c) else null — no loop and no valid live chord → honest empty.
|
||||
@@ -102,97 +104,131 @@ function pickSubject(loopArr, keyInfo, currentChord) {
|
||||
return null
|
||||
}
|
||||
|
||||
export default function TryThis({ loop, keyInfo, currentChord, onChordClick }) {
|
||||
const [cycle, setCycle] = useState(0)
|
||||
const lastPosRef = useRef(null)
|
||||
const lastNameRef = useRef(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 (
|
||||
<div className="flex items-center">
|
||||
<MiniPiano
|
||||
voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }}
|
||||
size="mini"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (instrument === 'bass') {
|
||||
return (
|
||||
<span className="text-[10px] text-gray-500">
|
||||
root · {NOTES[((sub.rootPc % 12) + 12) % 12]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<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 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 (
|
||||
<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
|
||||
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>
|
||||
<span className="text-[9px] uppercase tracking-wide text-gray-500">
|
||||
{tag}
|
||||
{isCircle && <span className="ml-0.5 text-accent" aria-hidden="true">↻</span>}
|
||||
</span>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-[11px] leading-snug text-gray-400">
|
||||
{sub.why}
|
||||
</p>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 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.
|
||||
if (!subject) return null
|
||||
const picked = pickSub(subject.subs, cycle)
|
||||
if (!picked) return null
|
||||
|
||||
const subs = subject.subs
|
||||
const subjectChord = subject.name
|
||||
|
||||
const { sub, idx, total } = picked
|
||||
const showIndicator = total > 1
|
||||
const isCircle = CIRCLE_CATEGORIES.has(sub.category)
|
||||
const tag = CATEGORY_TAG[sub.category] ?? sub.category
|
||||
const subs = subject.subs.slice(0, 3) // cap at 3 (§2.1)
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-2xl border border-border bg-panel p-3"
|
||||
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">
|
||||
<span>
|
||||
Try this instead of {subjectChord}
|
||||
{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 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||
Try this instead of {subjectChord}
|
||||
{keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
|
||||
</h4>
|
||||
|
||||
<div className="flex items-baseline gap-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>
|
||||
<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>
|
||||
{/* 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. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{[0, 1, 2].map(i =>
|
||||
subs[i]
|
||||
? <SubRow key={`${subs[i].label}-${i}`} sub={subs[i]} instrument={instrument} onChordClick={onChordClick} />
|
||||
: <EmptySlot key={`empty-${i}`} instrument={instrument} />
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user