7 Commits

Author SHA1 Message Date
vadimwit 072920ff0e docs+config: never-delete policy — CLAUDE.md hard rule + .claude/settings.json deny list
Claude must never execute destructive/irreversible commands (rm, git branch
-d/-D, git push --delete, reset --hard, force-push, DROP, etc.) — it proposes
them for the user to run. Enforced behaviorally in CLAUDE.md (authoritative)
and as permissions.deny rules in .claude/settings.json (defense-in-depth).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:36:02 +01:00
vadimwit 8ebdc73958 ledger: L-77 done (9415daf) — hybrid loop+history voicings rail, pushed both remotes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:01:54 +01:00
vadimwit 9415daf4a0 feat(rail): hybrid voicings rail — highlight the loop + other recent chords underneath (task L-77)
The suggested-voicings rail no longer restricts to loop chords or collapses
to a single heard-live chord. It now shows TWO groups: the LOOP group (the
canonical GlanceRail, byte-unchanged — KB order, moving "now" playhead,
voice-leading chips) under a "the loop" caption, and an "also played" group
of the other recently-played DISTINCT chords (most-recent-first, each
expanded to its full voicing gallery, no chips / no playhead). With no loop
the "also played" group IS the rail, replacing the old single-chord
fallback. At least 4 chords show as soon as history exists (RAIL_TOTAL_CAP
top-up; only chords actually played, never fabricated).

GlanceRail gains an optional showTransitions prop (default true = the loop
caller is byte-unchanged); the history group passes false, which suppresses
the voice-leading chips + "next" tag AND switches the section framing off
the loop/playhead language, and renders non-focusable static row headers
(no inert focus button / misleading tooltip on history rows). App.jsx
untouched — chordHistory already flowed in. Verified build + validate-KB +
smoke 903/903 green.

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."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:01:00 +01:00
vadimwit 7561566ceb ledger: L-78 done (d543b98, direct push); L-77 re-scoped to hybrid loop+history rail (>=4)
L-78 pushed on user request ("push everything now") — build+validate+smoke
903/903, full Critic gate waived. L-77 re-scoped same day: highlight loop
chords + list other recent chords underneath, >=4 total once history exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:47:37 +01:00
vadimwit d543b986dc feat(trythis): 3x3 ways-to-play — up to 3 guitar grips / 1 piano per suggestion (task L-78)
Try-this now fills its space: each of up to 3 substitutions renders as a
row with a left identity block (chord chip -> modal, category tag, why)
and a right "ways to play it" block that follows the global instrument —
guitar shows up to 3 genuinely different grips (getGuitarVoicings), piano
one MiniPiano, bass a root caption. A FIXED 3-slot frame reserves height
for all three rows (present -> SubRow, absent -> dashed EmptySlot) so
suggestions #1/#2 never shift when a chord yields 2 vs 3 subs (the
anti-jump ask). Honest: add9's 2-shape roots show 2 grips, not padded
fakes. Pure parseChordName/subsForChord/pickSubject preserved (per-chord
reactivity intact). Design tokens only. Verified: build + validate-KB +
smoke 903/903 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:46:10 +01:00
vadimwit ede3f2e372 docs(design): related-area v2 (task D-76) — multi-chord rail + try-this 3x3; returned once (add9 shape count), fixed 2026-07-13 16:57:30 +01:00
vadimwit dc503807ef ledger: D-76/L-77/L-78 — related-area v2 (rail always-multi-chord most-recent-first; try-this 3-cap stable 3x3 guitar) 2026-07-13 16:42:19 +01:00
7 changed files with 632 additions and 135 deletions
+35
View File
@@ -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:*)"
]
}
}
+18
View File
@@ -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
+7 -1
View File
@@ -83,7 +83,10 @@ Standing principles (memory): scroll > click; nothing duplicated; one global ins
| D-75 | **Related-area space layout** (user 2026-07-13, after seeing it — "we have enough space in that area… fit everything in one big screen"): (a) TRY THIS — show the current chord's up-to-4 substitutions SIDE BY SIDE (replaces the one-at-a-time rotation; user has seen rotation + prefers all-visible), each a small card = chord label + a mini instrument diagram of that chord's voicing (ChordDiagram guitar / MiniPiano piano, following the GLOBAL instrument selector; bass = honest simple) + a compact why + tap→ChordDetailModal; (b) SUGGESTED PROGRESSIONS (RelatedProgressions) — 2×2 grid instead of the vertical list. Honest space math for the LEFT column (~744px, wider than the 500px rail). Instrument prop threading for TryThis (App mount passes `instrument`). Bounded L-75/L-76 (disjoint: TryThis.jsx+App-mount vs RelatedProgressions.jsx) | design | ready | — | `docs/design/related-area-layout.md` | side-by-side try-this with per-sub diagrams + 2×2 related spec'd with honest column math; instrument-follow named; L-75/L-76 bounded |
| L-75 | Implement TryThis side-by-side + per-sub instrument diagrams per D-75 (drops the rotation; follows the loop/current chord; instrument-following diagram) | engineering | done `957ce88` (combined-gate PASS) | D-75 | `src/components/TryThis.jsx`, `src/App.jsx` (pass `instrument` to the mount — grep-clean) | up-to-4 subs side by side each with a playable-shape diagram in the current instrument; tap→modal; honest empties; build+smoke green |
| L-76 | Implement RelatedProgressions 2×2 grid per D-75 (same-style + cross-style sections in a 2-col grid using the left-column width) | engineering | done `e695716` (combined-gate PASS) | D-75 | `src/components/RelatedProgressions.jsx` | 2×2 layout; both sections adapt; no logic/scoring change; build+smoke green |
| C-70 | Sprint-end sweep + PR update (BOTH gitea+github). Fold: drop stale ", each playable" from VoicingBrowser aria (:278/:314, combined-gate finding) | quality | backlog | L-70, L-71, L-72, L-74, L-75, L-76 | `src/components/VoicingBrowser.jsx` (aria one-liner) | all green; aria fixed; PRs updated |
| 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.
@@ -304,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)
+196
View File
@@ -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.
+76 -25
View File
@@ -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 011 (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
View File
@@ -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
+106 -62
View File
@@ -5,16 +5,23 @@ import { pianoVoicing } from '../lib/piano'
import ChordDiagram from './ChordDiagram'
import MiniPiano from './MiniPiano'
// ─── TryThis — side-by-side substitution cards, each with a mini diagram (L-75) ─
// ─── TryThis — stable 3-slot rows-of-shapes substitution rail (L-78 / D-76) ─────
//
// For the chord under the playhead, in the detected key, show ALL curated
// substitutions (from theory.js `suggestSubstitutions`, the L-73 engine) SIDE BY
// SIDE — one card per idea — each carrying a mini instrument diagram of that
// chord (guitar ChordDiagram / piano MiniPiano / bass root caption), following
// the global instrument. Reverses L-74's rotating one-at-a-time card: the user
// saw the rotation and asked for all-visible ("put them next to each other, we
// have enough space"). Spec: docs/design/related-area-layout.md §13.
// 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)
@@ -54,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
@@ -89,22 +104,26 @@ function pickSubject(loopArr, keyInfo, currentChord) {
return null
}
// The mini diagram drawn inside a SubCard, chosen by the global instrument (§2):
// guitar → first resolved guitar shape (absolute frets) via ChordDiagram thumb;
// no shape available → no diagram (honest, chip + why only).
// piano → pianoVoicing for the chord, rootPc spread back in so the "R" badge
// lands correctly (VoicingBrowser:317-319 caveat), MiniPiano size mini.
// bass → no diagram (no honest compact bass-chord renderer); a small
// root · {note} caption instead.
function SubDiagram({ sub, instrument }) {
// 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 (
<MiniPiano
voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }}
size="mini"
/>
<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">
@@ -112,39 +131,71 @@ function SubDiagram({ sub, instrument }) {
</span>
)
}
// guitar (default): first resolved shape; omit the label — the chip names it.
const shape = getGuitarVoicings(sub.label)[0]
if (!shape) return null
return <ChordDiagram shape={shape} rootPc={sub.rootPc} size="thumb" />
// 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 card — chip (tappable → modal) + mini diagram + tag + why.
function SubCard({ sub, instrument, onChordClick }) {
// 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 min-w-0 flex-col items-center gap-1.5 rounded-lg border border-border bg-border/30 p-2">
<button
type="button"
onClick={() => onChordClick?.(sub.label)}
aria-label={`${sub.label}${sub.why}`}
className="shrink-0 rounded-lg border border-border bg-border px-2 py-1 text-sm font-bold leading-none text-gray-100 outline-none transition-all cursor-pointer hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent"
>
{sub.label}
</button>
<div className="flex min-h-[38px] items-center justify-center">
<SubDiagram sub={sub} instrument={instrument} />
<div
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>
<span className="text-[9px] uppercase tracking-wide text-gray-500">
{tag}
{isCircle && <span className="ml-0.5 text-accent" aria-hidden="true"></span>}
</span>
{/* Right "ways to play" block */}
<div className="min-w-0 flex-1">
<WaysToPlay sub={sub} instrument={instrument} />
</div>
</div>
)
}
<p className="line-clamp-3 text-center text-[11px] leading-snug text-gray-400">
{sub.why}
</p>
// A reserved-but-empty slot — holds the exact SubRow height so the present subs
// never move when the chord yields fewer than 3 (§2.2, the anti-jump).
function EmptySlot({ instrument }) {
return (
<div
aria-hidden="true"
className={`flex items-center justify-center rounded-lg border border-dashed border-border/50 bg-transparent ${ROW_MIN_H[instrument] ?? ROW_MIN_H.guitar}`}
>
<span className="text-[11px] text-gray-600"></span>
</div>
)
}
@@ -157,16 +208,7 @@ export default function TryThis({ loop, keyInfo, currentChord, onChordClick, ins
if (!subject) return null
const subjectChord = subject.name
const subs = subject.subs
// Piano keyboards are up to ~199px wide (C-anchored window), so 4 cannot share a
// 720px row — lay them out 2×2. Guitar (75px cell) and bass (no diagram) sit
// 4-across in one flex-wrap row; with 13 subs the cards grow to fill (§1.1/§3).
const isPiano = instrument === 'piano'
const listClass = isPiano
? 'grid grid-cols-1 sm:grid-cols-2 gap-2'
: 'flex flex-wrap gap-2'
const cardBasis = isPiano ? '' : 'basis-[168px] grow min-w-[152px]'
const subs = subject.subs.slice(0, 3) // cap at 3 (§2.1)
return (
<section
@@ -178,12 +220,14 @@ export default function TryThis({ loop, keyInfo, currentChord, onChordClick, ins
{keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
</h4>
<div className={listClass}>
{subs.map((sub, i) => (
<div key={`${sub.label}-${i}`} className={cardBasis}>
<SubCard sub={sub} instrument={instrument} onChordClick={onChordClick} />
</div>
))}
{/* 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>
</section>
)