32 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
vadimwit 8c018d697d ledger: L-75/L-76 done (side-by-side try-this + 2x2 related, combined-gate PASS) 2026-07-13 16:32:35 +01:00
vadimwit e6957160f6 feat(related): 2x2 grid for suggested progressions (task L-76)
The suggested-progressions panel lays its entries out as a 2x2 grid
(grid-cols-1 sm:grid-cols-2) instead of a vertical list, using the
left-column width and halving the block height. Layout-only: a grid
class + a render-time slice(0,4); the ranker, scoring, cap
(RELATED_MAX_ENTRIES=5), and every entry's chips/labels are untouched,
so the C-50 ranking pins stay green. Idle/empty states stay
single-column. Critic PASS (combined gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:31:54 +01:00
vadimwit 957ce88fd6 feat(trythis): side-by-side suggestions with per-chord instrument diagrams (task L-75)
Try this now shows all of the current chord's valid substitutions at
once (no more rotation), each as a card with the chord name, a mini
diagram of how to play it in the selected instrument (guitar
ChordDiagram 4-across, piano MiniPiano keyboards in a 2x2, bass a root
caption), and the plain why. Still follows the live playhead so the
set refreshes per chord as the progression evolves; honest-empty
without a key/loop; the rolled-jam visibility fallback preserved.
App.jsx change is the single instrument prop, contract-clean. Critic
PASS (combined gate: diagrams resolve, root badge correct, reactivity
verified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:31:54 +01:00
vadimwit 51c2be90cc docs(design): related-area layout — side-by-side try-this with per-sub diagrams, 2x2 suggested progressions (task D-75) 2026-07-13 16:15:38 +01:00
vadimwit 2905a4b979 ledger(backlog): Try-this vs Suggested-progressions should be two distinct features (user direction, revise later) 2026-07-13 16:11:06 +01:00
vadimwit c62cd60595 ledger: D-75/L-75/L-76 — related-area space layout (side-by-side try-this + diagrams, 2x2 related) 2026-07-13 15:55:28 +01:00
vadimwit 89b3e323ea ledger: L-70/L-71/L-74b done (combined-gate PASS) — rail/licks polish live 2026-07-13 12:59:39 +01:00
vadimwit 6885fc3ce1 fix(dashboard): Try-this card visible for any detected/rolled loop, not only live input (task L-74b)
The rotating Try-this card was gated on a live-detected currentChord,
so a rolled Jam Roulette (which locks a key + loop but leaves the
history empty until you play) showed nothing. TryThis now falls back
to the first loop chord that yields a substitution, so the card is
visible whenever a loop is on screen in a locked key; the live-playing
path is unchanged and honest-empty (no key / no loop) is preserved.
Critic PASS (combined gate: renders for the rolled-jam case, no
infinite render).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:58:40 +01:00
vadimwit 19b1a4ff91 feat(licks): dashboard licks strip — uniform compact cards following the instrument (task L-71)
The dashboard licks strip now follows the global GUITAR/PIANO/BASS
selector: guitar shows LickCard tabs, piano shows the PianoLickCard
piano-roll (jazz/blues/gospel/rnb), bass shows an honest empty line.
Every strip card is a uniform compact 220x150 box (tab/roll normalized
to a 104px-tall panel) so the licks stop hogging space. Play buttons
removed from the strip. Knowledge Center LicksSection + LickCard left
byte-identical. .dark-scroll applied to the rail/strip scrollers.
Critic PASS (combined gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:58:40 +01:00
vadimwit aae69e8a7b feat(rail): voicings rail polish — 4 guitar shapes, 2x2 mini piano, no play buttons, dark scrollbars (task L-70)
Dashboard voicings rail: guitar galleries capped to 4 shapes
(recommended-first, then open, then movable by base fret; prop-gated so
the Knowledge Center and chord modal keep all shapes); piano voicings
render as a 2x2 grid of size="mini" (scale 0.60) keyboards under the
rail's dense path only. Play buttons removed globally from
VoicingBrowser per the user ("leave them off, better not") — the
Knowledge Center + modal render byte-identical minus the buttons.
A scoped .dark-scroll utility (thin, #2a2a2a on transparent) dresses
the dashboard scrollers. Critic PASS (combined gate: byte-identity
proven, zero play buttons everywhere, contract intact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:58:39 +01:00
vadimwit c16e9f1827 ledger: L-74b done (TryThis loop-fallback visibility); batch-gate plan for rail/licks 2026-07-13 12:40:48 +01:00
vadimwit 9e21df127c ledger: D-70 accepted; L-70/L-71 claimed (rail/licks build); play buttons global per user 2026-07-13 12:34:15 +01:00
vadimwit 3fdc72ebd4 docs(design): dashboard-polish concept (task D-70) — rail/licks optimizations, accepted to unblock the build 2026-07-13 12:33:37 +01:00
vadimwit 245d9f6fb2 ledger: L-74 done (rotating Try-this card); duplicate-name-loop rotation caveat backlogged 2026-07-13 12:23:42 +01:00
vadimwit 038fdfafc8 feat(dashboard): rotating Try-this card — one fresh substitution per loop pass (task L-74)
A compact TryThis card follows the playhead chord and shows ONE
suggestion at a time, cycling to the next valid substitution each time
the loop completes a pass (playhead wraps to a lower station). Over
Am-C-F the F cycles Dm -> Fm -> Fmaj7 -> E7 across passes, so the app
keeps offering a new idea and eventually teaches every honest move,
never a wrong one. Chip taps into ChordDetailModal; 0 subs -> no card,
1 sub -> static. Mounted App.jsx-only above RelatedProgressions in the
related slot; audio contract grep-clean; rotation logic exported pure
and StrictMode-safe. Critic PASS (2-pass rotation trace verified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:23:18 +01:00
vadimwit 3469bafa0d feat(theory): suggestSubstitutions engine for the Try-this feature (task L-73)
Additive suggestSubstitutions({rootPc,quality}, keyInfo, opts) returns
up to 4 honest, correctly-spelled chord alternatives in the detected
key: relative/diatonic-third, borrowed-minor iv (flat-spelled b6),
diatonic extension colour, and the secondary dominant of the next
chord — each with a plain teaching why. A sabotage-proven smoke
truth-table pins the tables (Am-C-F both readings, the Rule-D blues
moves). Critic returned once (Rule C self-suggested the sounding chord
on 7th inputs: Dm7->Dm7, plus a latent add9-on-minor mis-spelling),
fixed (skip self-quality extension; omit category C when nothing new
to add), PASS on scoped re-gate. Smoke 903/903; theory.js purely
additive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:04:34 +01:00
vadimwit 0b75ebdce1 ledger: L-73 returned (Rule C self-suggestion); L-74 = vary/rotate display (user decision) 2026-07-13 11:58:37 +01:00
vadimwit a311895d50 feat(related): same-style-first variations when a style is active (task L-72)
When the loop matches a KB style (rolled or live-detected),
RelatedProgressions leads with that style's other progressions under a
"Try these in {style}" header, labelled by data-derived character
(minor version / shorter form / extended form / reharmonized) computed
from mode/bars/qualities — no cross-style jumping. The role phrase is
suppressed unless the sibling is genuinely related (same changes or a
shared transition), so no false "variation" claim. When no style is
locked, today's cross-style list is preserved byte-for-byte. Smoke
893/893: §8 re-pinned (scores stable 92/156, labels moved) plus two
gate-added durable assertions (finding-B suppression + no-match
fallback). Critic PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:37:37 +01:00
vadimwit a95ad147f7 ledger: D-73 done (try-this design PASS after 3 copy fixes) 2026-07-13 11:31:37 +01:00
vadimwit c2207ef7b6 docs(design): Try this chord-substitution feature — 4 curated moves with plain why-copy (task D-73)
For a loop chord in the detected key, suggest up to 4 alternatives:
relative/diatonic-third sub, borrowed-minor iv colour, extension
colour, and the secondary dominant of the next chord — each with a
one-line teaching why, ranked softest to boldest. Worked through the
Am-C-F case under both the A-minor and C-major readings; circle-of-
fifths tie-in kept honest (relative + secondary-dominant are circle
moves, borrowed + extension are not). Mounts as a compact TryThis card
above RelatedProgressions in App's relatedSlot (file-disjoint). Critic
returned once (chordRootPC circular-import trap, sharp-spelled b6, one
wrong mediant claim — all copy/plumbing, rules verified correct),
fixed, re-gate waived (gate-prescribed fixes verified directly).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:31:18 +01:00
vadimwit a13c8fecc0 ledger: no song forms; L-72 unblocked (findings A/B resolved); new Try-this substitution feature (D-73/L-73/L-74) 2026-07-13 11:15:54 +01:00
vadimwit bb802db2b6 ledger: D-72 done (design PASS); L-72 held pending user song-forms decision + finding-A/B
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:08:11 +01:00
vadimwit 952d8076df docs(design): same-style related-progressions — variations by structural difference (task D-72)
When a style is active (rolled or detected), RelatedProgressions leads
with same-style siblings reframed by data-derived character (minor
version / shorter form / extended form / reharmonized), computed from
mode/bars/qualities — no new KB content, no matcher change. Cross-style
same-changes entries demoted to a secondary section. Critic PASS (role
labels recomputed honest for blues + jazz; smoke-pin shift is
label-only). Deliberately avoids verse/chorus/bridge labels the KB has
no data for.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:07:32 +01:00
vadimwit c6c6427f50 ledger: user answer folded — related-progressions = reuse existing KB same-style siblings; D-72 unblocked, runs parallel
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 10:52:15 +01:00
vadimwit 4d562d758e ledger: seed sprint-dashboard-polish (M-08) — 4-guitar cap, 2x2 piano, no play buttons, dark scrollbars, licks follow instrument, same-style related
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 10:48:16 +01:00
19 changed files with 2636 additions and 384 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
+42
View File
@@ -56,6 +56,42 @@ Standing principles (memory): scroll > click; nothing duplicated; one global ins
---
## Active sprint: `sprint-dashboard-polish` (branch: `sprint-jamguide-piano` — continued; commits extend PR #3)
**Goal (user directive 2026-07-13, after running the one-screen build):** "this looks amazing" + six refinements to the live dashboard:
1. **Guitar: 4 options max** — "only have 4 guitar options visible so it would fit the screen without scrolling." Cap each chord's guitar gallery to ≤4 shapes (a musically-sensible top-4 selection rule; fewer is fine).
2. **Hidden-but-scrollable dark scrollbars** — "the scroll bars are not visible but it can scroll if we need to (make them black or something)." Thin/dark styled scrollbars, overlay feel; content still scrolls.
3. **Piano: 2×2 smaller keyboards** — "smaller keyboards so there would be 2×2 for each chord." The 4 pianoVoicing styles in a 2-col × 2-row grid of compact MiniPianos per chord.
4. **Remove play buttons** — "leave off the PLAY buttons, they take up a lot of space for no reason, no need to hear it." Drop every ▶ from the voicings rail (and the licks strip — Maestro extension, matching the stated glance-over-audio preference; also removes the shared-sequencer blocker so piano licks can wire in cleanly).
5. **Licks: uniform size + follow the instrument** — "the licks section should all have the same size and transform into piano when selected." Uniform card size; the strip follows the global GUITAR/PIANO/BASS selector — wiring the deferred PianoLickCard into the strip (bass → no licks / honest note).
6. **Related progressions: same-style bridge/chorus/modifications** — "i would also want bridge/chorus/modifications in the same style… say i select jam roulette with blues, then i want for that progression other options and not necessarily go into other styles." Same-style variations for the active/rolled loop. **User decided 2026-07-13: reuse existing KB same-style progressions (lightest option) — NO computed modifications, NO new authored sections, NO song forms (verse/chorus/bridge — KB has no section data and most progressions are complete forms; Maestro + user agreed not to fake it).** So L-72 ships the honest same-style variations. **NEW feature added same day** (user: "imagine we play Am C F, i'd like an alternative to that F… an option that says 'try this'… i want musicians to learn how they can make the jam more interesting"): a **per-chord "Try this" chord-substitution** surface — for a loop chord in the detected key/style, suggest a small curated set of alternative chords (relative sub, borrowed-minor colour, extension colour, a circle-of-fifths / secondary-dominant move) each with a plain-language WHY. Circle-of-fifths-informed where it applies (honest that not all subs are circle-adjacent). Keep it simple/learnable, not a reharm engine. → tasks D-73/L-73/L-74 below.
Standing principles (memory): scroll > click; nothing duplicated; one global instrument selector; playhead highlights; glance over audio. User is PRESENT — notification-driven, no cron. Weights: Muse 3, Luthier 3, Critic gate.
| id | title | domain | status | depends-on | files (lock) | definition of done |
|----|-------|--------|--------|-----------|--------------|--------------------|
| M-08 | Seed `sprint-dashboard-polish` | maestro | done | — | `docs/agents/LEDGER.md` | seeded |
| D-70 | Rail + licks layout concept doc: (a) guitar ≤4 shapes with the selection rule (open + common movable, lowest-position-first — name it); (b) piano 4 voicings as a 2×2 grid of compact MiniPianos — pick the MiniPiano thumb scale that fits two side-by-side in the ~456px column interior and two rows within a sane row height, honest math; (c) all ▶ removed from the rail's guitar+piano cells AND the licks strip (name every removal site); (d) hidden-but-scrollable scrollbars — the mechanism (webkit ::-webkit-scrollbar thin + dark thumb, and Firefox scrollbar-width/color; overlay where supported) and WHERE it applies (the rail's overflow-y column, any inner scrollers); (e) licks: uniform card footprint (thumb size parity between LickCard and PianoLickCard) + the strip follows the global instrument (guitar→piano licks on PIANO; bass honest empty); (f) recompute the row heights + the 500px column budget with the new smaller cells; migration order with bounded L-70/L-71 scopes (keep them file-disjoint or serialize on JamGuide.jsx). No user gate: pick strongest, record rationale + ≥2 rejected alternatives | design | done (`3fdc72e`; final scope: play buttons OFF EVERYWHERE per user; guitar-4/2x2-piano/uniform-licks dashboard-scoped) | — | `docs/design/dashboard-polish.md` | every change specced with honest numbers; the 2×2 piano scale chosen + proven to fit; bounded impl scopes |
| L-70 | Implement the voicings rail per D-70: guitar ≤4, 2×2 smaller piano, ▶ removed, dark hidden scrollbars | engineering | done `aae69e8` (combined-gate PASS) | D-70 | `src/components/GlanceRail.jsx`, `src/components/VoicingBrowser.jsx`, `src/index.css` (scrollbar CSS) (+ per doc — re-lock at promotion) | rail matches the spec; no ▶; guitar ≤4; piano 2×2; scrollbars hidden+dark+functional; build + smoke green |
| L-71 | Implement the licks strip per D-70: uniform card size, follow the global instrument (wire PianoLickCard), ▶ removed | engineering | done `19b1a4f` (combined-gate PASS; L-74b `6885fc3` visibility fix too) | D-70 | `src/components/JamGuide.jsx` (LicksStrip), `src/components/LickCard.jsx`, `src/components/PianoLickCard.jsx` (+ per doc — re-lock at promotion) | licks uniform; piano licks show under PIANO; no ▶; bass honest; build + smoke green |
| D-72 | Same-style related-progressions design (user answer = reuse existing KB): decide the presentation — when a style is active, RelatedProgressions leads with same-style siblings reframed as "variations / sections to try in {style}" (labels/section framing that reads as bridge/chorus/variation without new content); how the active style is known (roulette seed carries it; live detection's match yields it — name the prop/source); whether cross-style entries stay as a secondary "other styles with these changes" section or are dropped when a style is locked; empty/edge states. Files disjoint from the rail/licks chain (RelatedProgressions.jsx only) → runs in PARALLEL | design | done | — | `docs/design/related-same-style.md` | presentation spec'd against the real RelatedProgressions/match.js; active-style source named; L-72 bounded |
| L-72 | Implement D-72: RelatedProgressions leads with same-style variations when a style is active; reframed labels; cross-style demoted/dropped per the doc. **Unblocked 2026-07-13** (no song forms). Resolutions: (finding-A) when a style IS locked, DROP the secondary cross-style section — same-style only, per goal #6 + the user's "not necessarily other styles"; keep cross-style ONLY when no style is locked (match.matched===false). (finding-B) null the role phrase when a same-style sibling shares NO transitions with the loop (no false "variation" claim — just name + level). Re-pin smoke §8 labels ('shares I7→V7'→'shorter form' etc.; scores stable) | engineering | claimed | D-72 | `src/components/RelatedProgressions.jsx`, `scripts/smoke.mjs` (§8 re-pin) | same-style-only when locked; honest cross-style only when unlocked; role phrase honest (finding-B); build + smoke green — **PASS** (`see commit` — findings A/B verified; gate added 2 durable smoke assertions, 893/893) |
| 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 AmCF 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; AmCF 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)
| 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.
---
## Shipped sprint: `sprint-integrated-glance` (branch: `sprint-jamguide-piano` — complete 2026-07-10, 4 iterations, PR #3 updated)
**Goal (user directive 2026-07-10, after testing the glance-mode sprint — "its already a lot better, but"):**
@@ -244,6 +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.
@@ -268,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)
+510
View File
@@ -0,0 +1,510 @@
# Dashboard polish — rail + licks (task D-70)
Concept doc for `sprint-dashboard-polish`. Revises the rail/licks layout that
`docs/design/one-screen.md` §4 and D-51 established, per the user directive of
2026-07-13 ("this looks amazing" + six refinements). No code here — this specs
the two bounded implementation tasks **L-70** (rail) and **L-71** (licks) and
proves the numbers.
Design tokens only (`tailwind.config.js`): `surface` #0f0f0f, `panel` #1a1a1a,
`border` #2a2a2a, `accent` #a855f7, plus the SVG note language already mirrored
in `MiniPiano`/`ChordDiagram`/`LickCard` (amber #f59e0b secondary, ACCENT_SOFT
#c084fc chord-tone, BASS_RING #fbbf24). No new colour is introduced.
The column geometry we build against (from D-51 / one-screen.md §4, unchanged):
```
right column 500px
section border+p-2 18
row border+p-1.5 14
vertical scrollbar 17 (classic Windows) … 8 (the thin bar we add in §2)
= row interior 451px worst case (classic) · 460px (thin) · 468 (none)
```
All fits below are proven against the **conservative 451px** interior; the thin
scrollbar we introduce in §2 only ever makes them more comfortable.
---
## 0. Scope boundary — dashboard only (user refinement 2026-07-13)
> "perhaps within the knowledge center it could be nice but leave it for now as
> is, only in the main JAM FULL SCREEN we need to optimize what we show."
**The net boundary (two settled user clarifications, 2026-07-13):**
- **Dashboard** (always-open band + ⛶ jam-view fullscreen) = **full visual
optimisation**: guitar ≤4, 2×2 smaller piano, uniform licks, dark scoped
scrollbars, no ▶.
- **Knowledge Center dock** (`KnowledgeDock`'s VoicingsSection + LicksSection,
the Circle of Fifths) **and `ChordDetailModal`** = **layout UNCHANGED** (all
shapes, normal piano, current lick size) **except the ▶ play buttons are
removed** — the user decided "then leave them off, better not," so ▶ comes off
**everywhere**, not just the rail.
So there are **two** kinds of change with **two** scopes: the *visual layout*
changes are **dashboard-only** (gated behind the dashboard fork), while the
*▶ removal* is a **deliberate global** change across every mount.
The trap is **shared components**:
| component | dashboard mount | Knowledge Center / modal mount | how they differ today |
|---|---|---|---|
| `VoicingBrowser` | GlanceRail rows + heard-live (`dense`) | VoicingsSection + ChordDetailModal (**no `dense`**) | the `dense` flag already forks them |
| `LickCard` | LicksStrip (`size="thumb"`) | LicksSection (`size="full"`) | the `size` prop already forks them |
| `MiniPiano` | rail thumbs (`size="thumb"`) | modal/ExplorePanel (`size="thumb"`/`"full"`) | needs a **new** `size="mini"` variant |
| `PianoLickCard` | LicksStrip (new wiring) | **never mounted** | no KC concern at all |
**The rule: gate the *visual* optimisations behind the dashboard fork and change
no default there; remove ▶ globally.** Concretely:
- `VoicingBrowser`*visual, dashboard-only:* guitar ≤4 + piano 2×2 apply
**only under `dense`** (the flag only dashboard mounts pass); non-dense
(VoicingsSection + ChordDetailModal) keeps **all shapes + the normal piano
layout**. *Global:* the ▶ is **removed from the component outright** (§3) — no
mount wants it anymore.
- `MiniPiano`: the smaller keyboard is a **new additive `size="mini"`**; `thumb`
and `full` are byte-untouched, so the modal/ExplorePanel render identically.
- `LickCard`: the uniform footprint is imposed **at the dashboard-strip mount**
(a wrapper box), **not** in `LickCard` — its default footprint is unchanged.
`LickCard` itself is edited only if it must lose a ▶ (it has none — see §3.3),
so the dock's LicksSection (`size="full"`) renders unchanged. `PianoLickCard`
is all-new to the strip.
- Scrollbar CSS: a **scoped `.dark-scroll` class** on the dashboard scrollers
only — the KC's own scrolling keeps the OS default.
**Restated L-70/L-71 byte-identity DoD:** the dock's VoicingsSection /
LicksSection and `ChordDetailModal` render **byte-identical to today EXCEPT the
▶ buttons are gone** (the D-41/D-51 non-dense-mount precedent — Critic
diff-checks it: the only permitted diff in those mounts is the removed play
button + its now-dead wiring).
---
## 1. Guitar: ≤4 shapes, recommended-first (dashboard `dense` only)
### 1.1 What changes structurally
Today the rail's guitar station shows a **separate own-cell** (the KB play's
recommended shape, accent border, "play" badge — `GlanceRail` StationRow
:215-231) **plus** `VoicingBrowser`'s full gallery of *every* placeable shape
(`matchingShapes` :76 returns all). Two problems: (a) the recommended shape is
usually also in `matchingShapes`, so it renders **twice**; (b) `maj`/`min`/`dom7`
have 5-6 placeable shapes, so own-cell + gallery = 6-7 cells that wrap to a
second line.
**Decision:** centralise the ≤4 rule inside `VoicingBrowser` and **delete the
separate own-cell**. `GlanceRail` passes the recommended shape down; the browser
renders it as the badged first cell and caps the total.
- New `VoicingBrowser` props (additive, default-off so modal/dock stay
byte-identical): `recommended` (a shape object) and `max` (number).
- `GlanceRail` (dense rail) passes `recommended={st.shape}` and `max={4}`.
- Non-dense mounts (`ChordDetailModal` Guitar tab, `KnowledgeDock` VoicingsSection)
pass neither → they keep showing **all** shapes with no cap (a detail view
should be exhaustive; the ≤4 fit constraint is the rail's alone).
This removes the pre-existing duplicate and guarantees ≤4 in one place.
### 1.2 The top-4 selection rule (exact ordering + tiebreak)
`matchingShapes(quality, rootPc)` still returns every placeable shape. The rail
then orders and slices:
1. **Recommended first** — the KB play's shape (`recommended`, matched into the
list by `label`; if it was filtered out as unplaceable, prepend it anyway).
Badged `play`, accent border — "the answer" prominence the old own-cell had,
now cell #1 of the row.
2. **Open-position forms** — shapes with an `Array.isArray(shape.frets)` grip
(open chords). They sit at the nut, sound the most idiomatic, and are what a
player reaches for first.
3. **Movable / barre forms by lowest base fret ascending** — closest to the nut
first (easiest hand position, most common voicing). Base fret is the value
`matchingShapes` already computes (`mod12(rootPc OPEN_PCS[idx])`, 0→12).
4. **Tiebreak** at equal category/base fret: **fewer muted strings first**
(count of `'x'` in `frets`/`offsets` — a fuller voicing wins), then the
shape's **declared order in `GUITAR_SHAPES[quality]`** (stable, honours the
KB author's priority). Deterministic — no reflow flicker as the root moves.
Take the first **4** (recommended + up to 3 more, recommended deduped so it
never repeats). **Qualities with <4 placeable shapes just show what they have**
— the rule is a cap, never padding; nothing is invented to reach four.
### 1.3 It fits one line
Guitar `GalleryCell` (dense, **no play button** — see §3): `ChordDiagram` thumb
75px + p-1.5 (12) + border (2) = **89px** box. Four across:
```
4×89 + 3×6 (gap-1.5) = 356 + 18 = 374 ≤ 451 ✓ (a 5th would be 469 ✗)
```
Four shapes on **one horizontal line, no wrap, no scroll** — exactly the user's
"4 guitar options visible so it would fit without scrolling."
---
## 2. Piano: 2×2 of compact MiniPianos (dashboard `dense` only)
### 2.1 The grid
**Under `dense`** the four `pianoVoicing` styles (`root` / `shell` / `rootlessA`
/ `rootlessB`, `PIANO_STYLES` :67) render as a **2-column × 2-row CSS grid**
(`grid-cols-2 gap-1.5`) — one clean 2×2 per chord. **Non-dense** (VoicingsSection,
ChordDetailModal) keeps today's flex-wrap gallery at `MiniPiano size="thumb"`
(0.8) with ▶ — byte-identical. As with guitar, the **separate own-cell is
dropped for piano**; the 2×2 *is* the chord's voicings. If the station's
authored voicing corresponds to one of the four styles (match on
`voicing.style`), that cell gets the accent border — the "recommended" signal,
for free, without a fifth cell. (Fuzzy-authored voicings that match no style →
no highlight; honest.)
Because the own-cell is gone, the piano grid spans the **full row interior**
(the `basis-[320px] flex-1` gallery column grows to ~451px), so each of the two
grid columns is `(451 6)/2 = 222.5px`.
### 2.2 The scale — arithmetic
`MiniPiano` VOICING thumb geometry: `baseW = 22·(7·OCTAVES + 1) + 2`,
`SVG_W = baseW · scale`, `SVG_H = (60 + 4)·scale`. Today `thumb` scale = **0.8**:
| crop | baseW | SVG_W @0.8 | cell box @0.8 (+14) | two-up + gap |
|---|---|---|---|---|
| 1-octave | 178 | 142.4 | 156.4 | 318.8 ≤ 451 ✓ |
| 2-octave | 332 | 265.6 | 279.6 | **565.2 > 451 ✗** |
So 0.8 **cannot** put two 2-octave crops side by side (the brief's 532>456). The
2-octave crop (the rootless 7th-chord voicings — `rootlessA/B` of `min7`/`dom7`/
`maj7` whose span pushes past one octave) dictates the scale. Solve for a column
of 222.5px:
```
332·scale + 14 ≤ 222.5 → scale ≤ 208.5 / 332 = 0.628 (the ceiling)
```
**Chosen: a new uniform `size="mini"` at scale 0.60** — below the 0.628 ceiling,
leaving a robust ~19px margin for sub-pixel rounding and the classic-scrollbar
worst case:
| crop @ **0.60** | SVG_W | cell box (+14) | two-up + gap (6) | fits 451? |
|---|---|---|---|---|
| 1-octave | 106.8 | 120.8 | 247.6 | ✓ (203 spare) |
| 2-octave | 199.2 | 213.2 | **432.4** | ✓ (18.6 spare) |
`grid-cols-2` sizes every cell to the 222.5px column, so 1- and 2-octave cells
share one uniform grid — a true 2×2 at both crops. A thumb crop never exceeds
**2 octaves** (a 4-note `pianoVoicing` spans ≤24 semitones; `OCTAVES =
ceil((maxNote octStart·12)/12) ≤ 2`), so 2-octave is the proven worst case —
no 3-octave escape hatch needed.
### 2.3 Legibility at 0.60 — the marks stay honest
White key = 22·0.60 = **13.2px** wide (vs `ChordDiagram`'s 11px string gap —
comparable, legibly playable). The root/bass signal is carried **primarily by
colour**, which does not shrink in meaning: root key = full accent #a855f7, other
tones = ACCENT_SOFT #c084fc, bass = amber ring #fbbf24 — the established tier
language. The "R" glyph and the amber ring are secondary reinforcement. Because
the SVG viewBox scales text with the keys, at 0.60 the `R` renders ~4.8px and the
ring ~1.2px — thin. **So `size="mini"` bumps the in-SVG `R` fontSize (8→10 in
viewBox units) and the bass-ring `strokeWidth` (2→2.5)** so both stay readable at
the smaller render. This is an additive branch in `MiniPiano.jsx` (Muse-owned SVG
renderer); the existing `thumb`/`full` paths are byte-untouched, so the modal and
dock render identically.
### 2.4 Row height (piano)
`MiniPiano` mini SVG_H = 64·0.60 = **38.4px**. Piano `GalleryCell` (dense, no
play): caption ~13 + gap-1.5 (6) + SVG 38.4 + p-1.5 (12) + border (2) = **71.4px**.
Two rows + grid row-gap (6) = **148.8px** for the 2×2 block — versus the old
gallery's ~3 wrapped cell-lines of ~118px each (~366px). Nearly halved (§6).
---
## 3. Remove ▶ everywhere (deliberate global removal)
Standing principle (memory): **glance over audio.** The user settled it — "then
leave them off, better not." So ▶ comes off **every mount**: the dashboard rail,
the dashboard strip, **and** the Knowledge Center + `ChordDetailModal`. Because no
mount wants playback, the machinery is **deleted outright**, not gated — this is
intended, not a side-effect.
### 3.1 `VoicingBrowser.jsx` (L-70) — delete the play machinery
- The `PlayButton` component (:103-121).
- `import { playVoicing, guitarShapeToNotes } from '../lib/chordAudio'` (:62).
- `handleRef` + `stopCurrent` (:184-188) and the `useEffect(() => stopCurrent,
[chordKey])` cleanup (:191) — both now dead.
- `playGuitar` / `playPiano` handlers (:197-211).
- The `<PlayButton>` inside `GalleryCell` (:149) and its `playLabel`/`onPlay`
props; the `playLabel`/`onPlay` passed at each call site (:243-244, :280-281).
- The mic-feedback microcopy (:300-309) — nothing plays anywhere now, so the
caveat is false; delete it.
- Now-unused after the above: the `useRef`/`useEffect` imports and the `chordKey`
local (only the deleted effect read it). Update the file-header comment.
This is the **only** permitted change to the non-dense (VoicingsSection /
ChordDetailModal) render — those mounts are otherwise byte-identical (all shapes,
normal piano layout); the visual `dense`-fork of §1/§2 leaves them alone.
### 3.2 `GlanceRail.jsx` (L-70) — dashboard-exclusive
`GlanceRail` is mounted **only** by the JamGuide rail, so its edits never leak:
- Delete the footer microcopy "▶ previews play through your speakers…"
(:292-295); replace with nothing (or a quiet "voicings follow the loop").
- Delete the own-cell `<figure>` block (:215-231) — folded into `VoicingBrowser`
per §1; pass `recommended={st.shape}` + `max={4}`.
- Update the header comment (":45 …every ▶ lives inside the gallery").
### 3.3 The licks strip (L-71)
- **`LickCard.jsx` has no play path** — the guitar tab card never had a ▶
(verified: no `PlayButton`, no `chordAudio` import). Nothing to remove there;
the dock's `size="full"` LickCards are unchanged.
- **`PianoLickCard.jsx` is the only lick ▶.** Remove: `import { playVoicing,
stopAll } from '../lib/chordAudio'` (:73); the `PREVIEW_BPM`/`currentSeq`/
`stopLick`/`playLick` sequencer (:167-198); the `PlayButton` component
(:484-502) and its render (:606-609); the `useEffect(() => () => stopLick(),
[])` unmount silence (:530, then drop the now-unused `useEffect` import). Keep
the `resolveDegree` import — realization needs it.
### 3.4 `chordAudio.js` stays — verified importers
`grep chordAudio src/**` → importers are `VoicingBrowser.jsx` (▶ deleted in
§3.1), `PianoLickCard.jsx` (▶ deleted in §3.3), and **`BassPatternCard.jsx`**
(untouched — the bass-rail pattern previews the user did **not** ask to remove,
out of both locks); the rest are doc comments in `voicings.js`.
`ChordDetailModal.jsx` does **not** import it directly — it plays via
`VoicingBrowser`, so deleting that ▶ removes the modal's too, exactly as the
user's global decision intends. So `chordAudio.js` stays (for `BassPatternCard`),
but nothing in the voicing/lick UI plays.
### 3.5 The shared-sequencer blocker is eliminated
D-60 flagged that `BassPatternCard` and `PianoLickCard` each hold a **private
module-level `currentSeq`**, so their previews could layer once both mount — the
reason PianoLickCard was never wired into the strip. Removing PianoLickCard's
playback deletes its `currentSeq` entirely: **there is no piano sequencer left to
conflict with.** The strip becomes purely visual; `BassPatternCard`'s sequencer
lives only under the BASS selector (where the strip is hidden anyway). So
PianoLickCard wires into the strip (§5) with **no `currentSeq` collision**
confirmed.
---
## 4. Hidden-but-scrollable dark scrollbars
### 4.1 Mechanism — a scoped utility, defined once in `index.css` (L-70)
```css
/* Thin, dark, overlay-feel scrollbars for the jam dashboard's scrollers.
Scoped (a class), NOT global — the KnowledgeDock/Debug/Tuner/Settings
below keep the OS default. Raw hex is unavoidable here: ::-webkit-scrollbar
pseudo-elements are not reachable by Tailwind utilities. The values mirror
the design tokens (border #2a2a2a, panel #1a1a1a). */
.dark-scroll {
scrollbar-width: thin; /* Firefox */
scrollbar-color: #2a2a2a transparent; /* thumb=border token · track transparent */
}
.dark-scroll::-webkit-scrollbar { width: 8px; height: 8px; } /* WebKit/Blink */
.dark-scroll::-webkit-scrollbar-track { background: transparent; }
.dark-scroll::-webkit-scrollbar-thumb {
background: #2a2a2a; border-radius: 4px; /* border token */
}
.dark-scroll::-webkit-scrollbar-thumb:hover { background: #3a3a3a; }
```
- **Scoped, not global** (`*`): the recommendation is a `.dark-scroll` opt-in
class. Global would restyle every scroller in the app (the whole normal-mode
page, the dock, Settings' full-screen scroller). The user's ask is about the
dashboard's rail; keep the blast radius there.
- **Raw-hex exception, flagged to Maestro:** the DoD says "no raw hex outside
`tailwind.config.js`," but `::-webkit-scrollbar` cannot consume a Tailwind
class. `index.css` (which already carries the raw `#0f0f0f` body background)
is the correct home; the values are documented as mirroring the `border`
token. No *new* colour — #3a3a3a hover is a one-step lift of the same family;
if Maestro prefers, use `panel` #1a1a1a for the base and `border` #2a2a2a for
hover instead (both existing tokens). Either is fine.
- Contrast: a #2a2a2a thumb on the #0f0f0f/#1a1a1a surface is intentionally
quiet — it is chrome, not content, so it is exempt from AA text contrast; it
is still clearly grabbable (the point of "make them black or something").
### 4.2 Where it applies — every dashboard scroll container
| # | scroller | file:line | task | note |
|---|---|---|---|---|
| 1 | **rail right column** `xl:overflow-y-auto` | `JamGuide.jsx:484` | **L-71** | the headline visible bar |
| 2 | left column (jam-view) `xl:overflow-y-auto` | `JamGuide.jsx:475` | L-71 | consistency |
| 3 | LicksStrip horizontal `overflow-x-auto` | `JamGuide.jsx:888` | L-71 | the licks row |
| 4 | per-cell `overflow-x-auto` (voicing) | `VoicingBrowser.jsx:148` | L-70 | rarely triggers now (§2) — apply for the edge case |
| 5 | timeline/keyboard `overflow-x-auto` | `PianoLickCard.jsx:576,585` | L-71 | wide-lick edge case |
**File-collision resolution (the brief's ask):** the rail's *outer vertical
scroller lives in `JamGuide.jsx`*, which is **L-71's exclusive lock** — not
`GlanceRail`, not `App.jsx`. So the class **definition** ships in `index.css`
(L-70), and each `className="… dark-scroll"` **application** is made by whichever
task owns the file it lives in: L-70 applies it in `VoicingBrowser` (#4), L-71
applies it in `JamGuide` (#1-3) and `PianoLickCard` (#5). The class exists before
L-71 runs because **L-71 depends-on L-70** (already in the ledger). Zero shared
files. The visible rail bar is styled in L-71 — acceptable, because that scroller
was always a JamGuide-column concern, not a `GlanceRail` one.
---
## 5. Licks: uniform size + follow the instrument
### 5.1 The strip follows the global instrument
`instrument` already reaches the strip: App's global selector → `JamGuide`
`instrument` prop (:177) → `LicksStrip({ …, instrument })` (:433-445, :863).
Today the strip **always renders `LickCard`** (guitar), even under PIANO, with an
apologetic heading "(no piano licks in the KB yet)" (:885). Change:
- `guitar``LickCard` (unchanged renderer).
- `piano``PianoLickCard` (wire it in — the D-60 component, never mounted).
Needs `rootPc` + `chordLabel` from the playhead `context` station (which
carries `rootPc`/`quality`/`label`), and each lick carries its own `quality`.
- `bass` → the strip is already gated off (:432/:439 `instrument !== 'bass'`);
render a slim honest line "No bass licks in the KB yet" instead of nothing, so
the section doesn't silently vanish when a bassist is selected.
The licks reader `licksFor(id)` (:136) is **hardcoded to
`instruments.guitar.licks`** — L-71 generalises it to
`licksFor(id, instrument)`, reading `instruments.piano.licks` under piano, and
**filtering piano licks to the structured ones** (those with a `notes` array) so
prose-only entries don't render as `PianoLickCard` placeholders (see §5.3).
### 5.2 Uniform footprint — measure, target, change
Both are wrapped by the strip in `w-[220px]` (closed) / `w-[340px]` (open)
(:894), so **width is already uniform**. The gap is **height** — the two SVGs
auto-size by different aspect ratios:
- **`LickCard` thumb** (`layoutTab`): viewBox 96 units tall (17 + 5·14 + 9),
`TabSvg` `maxWidth = width·1.3`; inside a 204px inner box it renders ~125px
tall. Card ≈ 163px.
- **`PianoLickCard` thumb** (`layoutLick`): height = 14 + `plotH` + 8 (+10 beat
row), `plotH = max(26, range·semi)`**varies with the lick's pitch range**,
so a wide lick is much taller than a narrow one and neither matches LickCard.
**Target: a shared thumb card of `220 × 150px`** (closed), `340px` wide (open).
**Imposed at the dashboard-strip mount, not inside the cards** (per the scope
refinement — do not touch `LickCard`'s default footprint). The `LicksStrip`
wrapper (JamGuide, L-71) that already sets `w-[220px]`/`w-[340px]` (:894) also
sets a **fixed content height** and normalises the card's SVG to fill it, e.g.
`class="… h-[150px] [&_svg]:!h-[104px] [&_svg]:!w-full"`. The cards' SVGs already
carry a `viewBox`, so `preserveAspectRatio="xMidYMid meet"` (the SVG default)
scales the tab / timeline to the 104px box and centres it — **zero edit to
`LickCard` or `PianoLickCard` internals.** Every card — guitar tab or piano roll —
then occupies the **same box**, "all the same size," and flips instrument in
place. (The `maxWidth: width·1.3/1.4` inline caps mean a short lick renders
narrower than 220 and centres — the card *box* is uniform; content is centred.)
**Scope proof (KC untouched):** the fixed-box CSS lives on the **`LicksStrip`
wrapper only** — a dashboard-exclusive element. `LickCard`'s and `PianoLickCard`'s
own geometry is unchanged, so the dock's LicksSection `size="full"` LickCards
(:817) render **byte-identical** (the L-71 DoD asserts it). `PianoLickCard` has no
KC mount at all.
### 5.3 KB coverage + empty-per-style behaviour
Verified against the KB (`instruments.piano.licks` with a structured `notes`
array — PianoLickCard-renderable):
| style | guitar licks | piano licks (structured) |
|---|---|---|
| jazz, blues, gospel, rnb | ✓ | ✓ (P-60/P-61) |
| pop | ✓ | **prose only** (`over`/`description`) — not renderable |
| bossa, country, funk, reggae, rock | ✓ | ✗ |
- **Guitar:** all 10 styles have tab licks — the strip works everywhere.
- **Piano:** structured, renderable licks exist for **jazz, blues, gospel, rnb**
only. Pop's `piano.licks` are the old prose education entries (no `notes`) —
the structured filter drops them, so pop reads as empty under piano too.
- **Empty-per-style:** today the strip returns `null` when empty (hides). Keep
hide-on-empty for **guitar** (the default; a silent gap is fine). For **piano
with zero structured licks** (pop + the 5 styles above), show a **slim honest
line** — "No {style} piano licks yet" — because the user *actively switched to
piano* and a vanished section is confusing there. Same slim line for bass.
---
## 6. Recomputed budget
Per-station row (row p-1.5 12 + border 2 + header block ~54 + gallery):
| | gallery block | **new row** | pre-polish row |
|---|---|---|---|
| Guitar | 4 cells ×1 line, no ▶ = **105px** | **~173px** | ~207px (5-6 cells, ▶, wraps) |
| Piano | 2×2 mini, no ▶ = **148.8px** | **~217px** | ~445px (0.8, ▶, 3 wrapped lines) |
Rail overhead (section p-2 16 + border 2 + h4 title ~22 + footer ~40) ≈ 80px;
rows joined by `gap-2` (8px).
| loop | instrument | **new total** | pre-polish | Δ |
|---|---|---|---|---|
| 4-chord | guitar | 80 + 4·173 + 24 = **796** | ~932 | 15% |
| 4-chord | piano | 80 + 4·217 + 24 = **972** | ~1884 | **48%** |
| 8-chord | guitar | 80 + 8·173 + 56 = **1520** | ~1720 | 12% |
| 8-chord | piano | 80 + 8·217 + 56 = **1872** | ~3640 | **49%** |
**Headline: the piano rail nearly halves (48%), and the guitar row is now a
guaranteed single line (≤4, never wraps).** Every total still exceeds the 500px
column, so **the rail remains the dashboard's one vertical scroller** — which is
exactly what §2's dark thin scrollbar dresses, and what the user licensed ("it
can scroll if we need to"). The polish makes each row compact and the scroll
pretty; it does not (and need not) fit a whole loop in 500px.
---
## 7. Migration order + bounded scopes
**L-70 — the rail.** Files (file-disjoint from L-71):
`src/components/GlanceRail.jsx`, `src/components/VoicingBrowser.jsx`,
`src/components/MiniPiano.jsx` (new `size="mini"` + R/ring bump — additive),
`src/index.css` (define `.dark-scroll`).
Does: guitar ≤4 recommended-first, **dense-only** (§1); piano 2×2 mini @0.60,
**dense-only** (§2); ▶ **deleted globally** from `VoicingBrowser`, own-cell +
footer from `GlanceRail` (§3.1-3.2); `.dark-scroll` defined + applied to
VoicingBrowser's cell scroller (§4 #4). `MiniPiano.jsx` is safe to lock here — no
other polish task touches it (`PianoLickCard`/`LickCard` carry their **own**
keyboard geometry and do not import `MiniPiano`).
**DoD byte-identity:** the non-dense `VoicingBrowser` render (VoicingsSection +
ChordDetailModal) is byte-identical to today **except the ▶ is removed**;
`MiniPiano` `thumb`/`full` byte-untouched (new `mini` is additive).
**L-71 — the licks.** Files: `src/components/JamGuide.jsx` (LicksStrip +
`licksFor` + the fixed-box wrapper + the scroller classNames),
`src/components/PianoLickCard.jsx`. **`LickCard.jsx` is *not* edited** — it has no
▶ and its footprint is imposed from the strip wrapper (§5.2), so it drops out of
the lock (dock LicksSection unaffected).
Does: uniform 220×150 thumb via the **strip wrapper** (§5.2); instrument branch +
PianoLickCard wiring + piano/bass empty states (§5.1/5.3); ▶ deleted from
`PianoLickCard` (§3.3); `.dark-scroll` applied to JamGuide's three scrollers +
PianoLickCard's (§4 #1-3,5).
**DoD byte-identity:** the dock LicksSection's `size="full"` LickCards render
byte-identical to today (no ▶ existed to remove there).
**Serialise L-70 → L-71** (already the ledger's `depends-on`): they are
**file-disjoint** (no shared file — the `.dark-scroll` *class* is defined in
L-70's `index.css` and merely *referenced* by L-71's JSX, which is not a file
edit collision), and L-71 needs L-70's class to exist. `JamGuide.jsx` belongs to
**exactly one** task (L-71), so the rail-column scrollbar has a single owner — no
shared-file clash. Critic gates each; `C-70` closes the sprint.
### Rejected alternatives
1. **Shrink `MiniPiano`'s global `thumb` scale (0.8→0.6) instead of a new
`mini`.** Rejected — it also shrinks the `ChordDetailModal` and dock thumbs,
which have room to spare and benefit from the larger keys; a scoped `mini`
variant keeps those byte-identical and confines the change to the rail.
2. **Give 2-octave piano crops their own full-width row (mixed cell sizes) at a
larger scale (~0.7).** Rejected — it breaks the clean "2×2 for each chord" the
user asked for; a uniform 0.60 grid keeps every chord a tidy 2×2 and 0.60 is
still legibly playable (13.2px keys ≈ the guitar diagram's string gap).
3. **Keep the separate own-cell and cap the gallery to 3.** Rejected — it leaves
the ≤4 rule split across two components (own-cell in `GlanceRail`, cap in
`VoicingBrowser`) and preserves the today's recommended/gallery duplicate;
centralising in `VoicingBrowser` (§1.1) is one place, one rule, no dupe.
4. **Global `*` scrollbar styling.** Rejected — restyles the entire app
(dock/Debug/Tuner/Settings) for a dashboard-scoped ask; a `.dark-scroll`
opt-in class is surgical.
+156
View File
@@ -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 13 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 13 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 ~152168px 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"` (107199px, §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 05); **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** (~165221px 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 ≈ **~95100px**. 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 **~67123px** (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 23 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.
+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.
+219
View File
@@ -0,0 +1,219 @@
# Related progressions — same-style-first (D-72)
Concept doc for **L-72**. Design-only; no code here. Scope is one bounded edit to
`src/components/RelatedProgressions.jsx` (match.js untouched — see §6).
## The user's ask (2026-07-13, verbatim intent)
> "for the related progressions this is good also, but i would also want
> bridge/chorus/modifications in the same style … say i select jam roulette with
> blues, then i want for that progression other options and not necessarily go
> into other styles."
**Chosen scope:** reuse the KB's *existing* same-style progressions. No computed
modifications, no new authored content, no new KB fields. When a style is active
(rolled via Jam Roulette **or** live-detected), the panel leads with the OTHER
progressions of that same style, reframed as variations/sections to try — instead
of jumping to other styles.
The component today already computes a match, ranks every *other* KB progression
by musical proximity, and prints a flat cross-style list. This doc changes only
**how the list is partitioned, floored, ordered, and labelled** once a style is
known. When no style is known, behaviour is unchanged.
---
## 1. How the active style is known — reuse the component's own match
`rankRelatedProgressions(loop)` already calls
`matchLoopToProgression(loop, index)`, whose result is
`{ matched, id, style, rotation, progression }`. **`match.style` IS the active
style** — for both entry points:
- **Jam Roulette:** `rollJam` seeds `detectedProgression = seedableLoop(prog, key)`
(the collapsed canonical form). The L-60 collapsed-form index makes the
component re-match that loop back to the rolled progression → `match.style ==
the rolled style`, `match.id == the rolled progression's id`. That id is already
excluded from `entries` (the `prog.id === match.id` guard), so the surviving
same-style progressions are exactly "other options for the style I rolled."
- **Live detection:** the detected repeating loop is what App passes as
`loop={detectedProgression}`; the same match resolves the live style/id.
**Recommendation — use the internally-computed `match.style`; do NOT add a prop.**
Naming it explicitly: inside `rankRelatedProgressions`, after the existing
`const match = matchLoopToProgression(...)`, take
```
const activeStyle = match.matched ? match.style : null
```
Rejected: threading a `matchedStyle` prop down from App (roulette knows it via
`lastRolledStyleRef`; live detection could expose its own match). It duplicates
state that the component already derives identically from the same `loop`, and
introduces a divergence risk (App's match vs the component's match drifting).
The single source of truth is the loop → its match. Keep it in one place.
**No-match fallback.** When `match.matched === false` (the loop matches no KB
progression — an off-book jam), `activeStyle` is `null`; the panel renders exactly
today's cross-style list (flat, floor `RELATED_SCORE_FLOOR`, existing
annotations). Nothing about the current behaviour changes when no style is locked.
---
## 2. Same-style-first presentation — two sections
When `activeStyle != null`, partition the scored candidates by
`entry.style === activeStyle`:
**Primary — "Try these in {styleLabel}"** (same-style siblings).
All same-style progressions except the one being played, in scorer order (§4),
capped at `RELATED_MAX_ENTRIES` (5). The score floor is **relaxed to 0 for this
section** — a sibling of your own style is never "junk"; it is exactly the "other
options" the user asked for. Styles hold ≤7 progressions, so this shows all of
them (blues → 4 siblings).
**Secondary — "Same changes, other styles"** (cross-style), demoted, small.
Only progressions whose canonical changes are *identical* to the loop
(`sameChanges === true`), capped at **2**, floor kept. This preserves a genuinely
valuable, rare relative — "this exact turnaround also lives in jazz and gospel" —
without "going into other styles" for merely-similar material. If none qualify,
the section is omitted entirely.
**Recommendation: ship both sections (option b).** It honours "not necessarily go
into other styles" (same-style leads and dominates the panel) while not hiding an
exact-match cousin elsewhere. Dropping the secondary later is a one-line change
(don't render it) if the user wants pure same-style — noted as the toggle.
Rejected: a hard same-style-only filter that *never* shows cross-style. It throws
away the exact-match cousin (musically the most useful cross-style pointer we
have) and would also have to special-case the no-match path. Kept only as the
one-line fallback if the user insists on zero cross-style.
---
## 3. The reframe — "bridge/chorus/modifications" with NO new content
Same-style siblings must read as *sections/variations to try*, not a flat list.
We label each with a short **role phrase** derived only from data already in the
KB — comparing the sibling to the active (matched) progression. Fields used:
`mode`, `bars` (summed = the form length), `qualities` (the colour set),
`name`, `level`. No new fields.
`siblingRole(sibling, active)` → a short phrase or `null`, first rule that fires:
1. **mode differs**`"{mode} version"` — minor→`"minor version"`,
major→`"major version"`, else the mode name (`"dorian version"`, …).
2. **same mode, fewer total bars**`"shorter form"`.
3. **same mode, more total bars**`"extended form"`.
4. **same mode & length, a quality the active lacks**`"reharmonized"`.
5. **otherwise**`null` (honest: just the name + level badge, no role line).
The active progression's `mode`/`bars` come from looking the raw KB entry up by
`match.id` in `kbRegistry[activeStyle].progressions` (avoids any collapsed-
projection subtlety; the raw entry is authoritative).
### Concrete — active = blues **Standard 12-bar** (major, 12 bars)
| sibling | mode | bars | role phrase | reads as |
|---|---|---|---|---|
| Quick-change 12-bar | major | 12 | `null` | name + `foundation` (name already says "quick-change") |
| 8-bar blues | major | 8 | **shorter form** | "the compact take" |
| Minor blues | minor | 12 | **minor version** | "the minor cousin" |
| Turnaround cycle | major | 4 | **shorter form** | name already says "Turnaround cycle" |
Every phrase is honest and re-derivable from `mode`/`bars`/`qualities`. Where no
character is derivable (Quick-change: same mode, same length, same all-dom7 colour
set) we print **nothing** beyond the name and level badge — the name carries it.
The two "shorter form"s are fine: their *names* (`8-bar blues`, `Turnaround
cycle`) already distinguish them, and the scorer orders them by proximity (§4).
This is where the "bridge/chorus/modification" feel comes from: the KB already
holds the minor version, the short form, the turnaround, the quick-change — we are
just *reframing existing siblings* with a one-line role, not synthesising sections.
---
## 4. Ranking within same-style — keep the scorer order
The existing scorer still runs over every candidate. Within the same-style group
the `+40 SCORE_SAME_STYLE` term is constant, so it cancels — ordering is driven by
`same changes (+100)``shared transitions``Jaccard`` length`, i.e.
**musical proximity to the loop you're playing.** That is more useful mid-jam than
alphabetical, so:
**Recommendation: keep the scorer sort within same-style. Do NOT re-sort by
level/name.** The closest variation to what your hands are already doing surfaces
first. The only change is relaxing the floor to 0 for this section (§2) so no
sibling is silently dropped for being "only" a distant relative — the user
explicitly wants *all* the style's options.
---
## 5. Edge / empty states
- **Style with only 1 progression** (0 same-style siblings after excluding the
played one). No current style hits this (all have ≥5), but handle it: render an
honest primary line — *"You're on the only {styleLabel} loop in the songbook."*
then fall through to the cross-style secondary (kept at floor). Never pad.
- **Secondary empty** (no exact cross-style cousin) → omit the secondary section
silently; the primary stands alone.
- **No match at all** (`activeStyle == null`) → today's single flat list, unchanged.
- **Instrument-agnostic — confirmed.** `RelatedProgressions` reads only
`progressions` (`degrees`/`qualities`/`rn`/`level`/`name`/`mode`/`bars`), never
the `instruments` cells. It behaves identically under guitar / piano / bass; the
global instrument selector does not touch it.
---
## 6. L-72 change list (RelatedProgressions.jsx only)
`match.js` needs **no change**`matchLoopToProgression` already returns
`{ style, id, progression }`. Reuse it.
In `rankRelatedProgressions`:
1. After computing `match`, derive `const activeStyle = match.matched ? match.style
: null` and look up the raw active progression (`kbRegistry[activeStyle]
?.progressions.find(p => p.id === match.id)`) for its `mode` + summed `bars`.
2. Keep the existing scoring loop. Change the floor test: skip the
`score < RELATED_SCORE_FLOOR → continue` **only when** `style === activeStyle`
(same-style siblings bypass the floor); cross-style keeps the floor.
3. Add a `siblingRole(sibling, active)` helper (§3) and attach `role` to each
same-style entry; leave cross-style entries' existing `annotation` intact.
4. Partition the sorted entries into `primary` (`style === activeStyle`, cap 5)
and `secondary` (`style !== activeStyle && sameChanges`, cap 2). Return
`{ match, activeStyle, activeStyleLabel, primary, secondary }`. Keep
`entries` (= `primary.concat(secondary)`) on the return so any existing
`entries[0]` / `entries.length` reads still resolve during the transition.
When `activeStyle == null`, return today's shape (`primary = entries`,
`secondary = []`) so the render path collapses to the current flat list.
In the render:
5. Two `<section>`-internal blocks: primary headed *"Try these in
{activeStyleLabel}"*, secondary headed *"Same changes, elsewhere"* (rendered
only when non-empty). Same-style rows swap the `annotation` line for the `role`
phrase (omit the line when `role == null`). Reuse the existing `LevelBadge` /
`ChordChain` — no new tokens, no new colours (all within `bg-panel` /
`border-border` / `text-gray-*` / `text-amber`, already in use).
**Smoke pins (C-50, `scripts/smoke.mjs` §8) will shift — re-derive, coordinate
with C-70:**
- Pin (a) collapsed 12-bar in A: top stays **blues/blues-8bar, score 92**
(blues is now the active style; 8-bar is a same-style sibling; the +40 is still
in its score, order unchanged). Its **annotation label changes** from
`'shares I7→V7'` to the role `'shorter form'` — the assertion must be re-pinned.
- Pin (b) iiVI in C: top stays **jazz/jazz-251-minor, score 156** (same-style,
same changes). Its label changes from `'same changes'` to the role
`'minor version'` (mode differs) — re-pin.
- Scores and top ids are stable; only the annotation strings and the return shape
move. Add coverage for the primary/secondary split and the no-match fallback.
### Rejected alternatives (recap)
- **Computed modifications** (synthesise a bridge/chorus by transposing or
reharmonising) — rejected per the user's explicit choice to reuse existing KB
content; also risks inventing non-idiomatic changes we can't vouch for.
- **Hard same-style-only, no cross-style ever** — rejected: loses the exact-match
cousin in another style and complicates the no-match path. Retained only as the
one-line "drop the secondary" toggle if the user later wants zero cross-style.
- **An explicit `matchedStyle` prop from App** — rejected as redundant (§1): the
component derives the same style from the same loop; a prop only adds a
divergence surface.
+170
View File
@@ -0,0 +1,170 @@
# "Try this" — chord-substitution nudge (task D-73)
**Sprint:** `sprint-dashboard-polish` · **Owner:** Muse · **Impl tasks:** L-73 (engine, `theory.js`) + L-74 (UI, new `TryThis.jsx`)
**User ask (2026-07-13, verbatim):** *"imagine we play a simple Am C F then i'd like to have an alternative to that F that would be in the similar style … potentially based on the circle of fifths? i'd like an option that says: 'try this' … i want musicians to learn how they can make the jam more interesting."* Plus the guard-rail: *"we dont have to create something too difficult."*
So this is a **small, curated, learnable nudge** — not a reharmonisation engine. For the chord under the playhead, in the detected key, show 34 alternative chords, each with one plain sentence that *teaches why it works*. Tap a suggestion → the existing `ChordDetailModal` to study it.
> **Not** the existing `getChordSubstitutions` (education.js). That returns **context-free, same-root colour swaps** (`maj → maj7, add9, maj6…`) and never looks at the key. The new engine is **key-aware** and, crucially, **changes the root** (relative sub, secondary dominant) with a *why* framed against the live key. They coexist; the modal keeps its colour-swap grid, the dashboard gets the new nudge.
---
## 1. The reading model — which key/mode the engine trusts
**Recommendation: take the app's `effectiveKey` (`lockedKey ?? keyInfo`) as the single reading. Do not compute both readings at once.**
- `effectiveKey` is `{ root, mode, confidence }`. The **mode disambiguates the i-vs-vi ambiguity** that makes `Am C F` read two ways: if the app committed **A minor**, `F` is `♭VI`; if **C major**, `F` is `IV`. The engine frames the *why* against whichever one is live, and gates the moves that need a specific reading (borrowed `iv` needs a major reading — see Rule B).
- The user can already flip the mode in the key dropdown (the app's intended workflow, per project memory — e.g. A minor → A Dorian). When they do, the *why* copy and the applicable moves change with it. That is the honest way to "see the other framing" — one reading on screen at a time, driven by the user's own mode choice.
- **Edge — no key / atonal / low confidence:** `suggestSubstitutions` returns `[]` when `!keyInfo?.root`. `TryThis` then renders a quiet idle line ("Lock a key to see substitutions") — never a fabricated suggestion. This mirrors `RelatedProgressions`/`CircleOfFifths` idle states.
The worked examples in §4 show **both** readings only so the gate can verify the arithmetic under each; at runtime exactly one is shown.
---
## 2. The curated set — 4 categories, ranked softest → boldest
All rules operate on `{ rootPc, quality }` (a pitch class 011 + a `CHORD_TYPES` key) — the same shape the KB/JamGuide stations already use — and read `keyInfo {root, mode}`. `keyRootPc = noteIndex(keyInfo.root)` (theory.js's own in-module note-name→pc helper, line 133 — **not** match.js's `chordRootPC`, which would make theory.js import from a module that imports it back = circular). Candidates are returned as `{ rootPc, quality, label, why, category }` where `label = NOTES[rootPc] + CHORD_TYPES[quality].suffix`. **Cap the output at 4**, in the order below (softest first, so the glance reads top-down by boldness).
Notation: pc arithmetic is mod 12. `NOTES = [C,C#,D,D#,E,F,F#,G,G#,A,A#,B]` (C=0 … B=11).
### A. Relative / diatonic-third sub — *the softest, most universal*
Swap a chord for the diatonic chord a third away that **shares two of three tones**.
- **major-family chord** (`maj, maj7, maj6, add9`): candidate = `{ (rootPc + 9) % 12, 'min' }` — the **relative minor** (a minor 3rd below). *Shared tones:* the original root and 3rd become the relative's 3rd and 5th.
- **minor-family chord** (`min, min7, min6`): candidate = `{ (rootPc + 3) % 12, 'maj' }` — the **relative major** (a minor 3rd above).
- **Gate:** emit only if the candidate is **diatonic in `keyInfo`** (`getChordsInKey(root,mode)` contains it). This keeps the swap "safe/soft" and never forces an out-of-key relative. (Non-diatonic relatives are out of MVP scope.)
- **Circle tie-in: yes** — the relative minor/major is the circle's *inner ring* (see `CircleOfFifths.jsx`). The *why* may say so.
- **Why template:** `"{cand} is {orig}'s relative {minor|major} — shares {t1} & {t2}. In this key it's the {rn(cand)}: {softer|brighter} pull, same family."`
### B. Borrowed minor colour — *the "blue"/"Creep" move* (conditional)
Major `IV → iv` (same root, major → minor) — lowers the 6th of the key to the ♭6.
- **Gate (strict, honest):** emit **only** when `keyInfo.mode` is **major-ish** AND the chord is the **IV** (`rootPc === (keyRootPc + 5) % 12`) AND quality is major-family. Under a **minor reading it is suppressed** (in A minor, `F` is a diatonic major `♭VI`; `Fm` would be a chromatic `♭vi` with no honest function — we do not fake it).
- Candidate = `{ rootPc, 'min' }`.
- **Circle tie-in: no** — this is a modal borrowing, not a circle step. The *why* must not claim the circle.
- **Why template:** `"Borrow {cand} (the iv) from the parallel minor — {n6}→{nb6} adds that wistful pull home. The 'Creep' move."` where `nb6 = noteName((keyRootPc + 8) % 12, /*preferFlat*/true)` (the ♭6). **Spell it flat** — this is a flatward modal borrow (A→A♭), never the sharp `NOTES[8]='G#'`. (The *ascending* leading tone in Rule D stays sharp — see §3.)
### C. Extension / colour — *same function, more colour*
Keep the root and function; add one diatonically-honest colour tone.
- Pick the extension whose **added tone is diatonic** in `keyInfo` (prefer, in order): major-family → `maj7` if `(rootPc+11)` diatonic, else `add9` if `(rootPc+2)` diatonic, else `maj6`; minor-family → `min7` if `(rootPc+10)` diatonic, else `add9`; `dom7``sus4` (the 9sus-ish suspension). Same root, so `label` = `NOTES[rootPc] + suffix`.
- **Gate:** the chosen added tone must be in `getScale(root,mode)`; if none qualifies, omit category C rather than add a clashing tone.
- **Circle tie-in: no** — vertical colour, not a circle step.
- **Why template:** `"Add the {intervalName} ({addedNote}) — same {rn}, lusher. {addedNote} is the key's own {degreeWord}, so it stays in the family."`
### D. Secondary dominant of the next chord — *the circle move*, boldest (conditional)
Approach the **next loop chord** by its own `V7` — the circle-of-fifths, dominant-direction pull.
- **Gate:** requires `opts.nextRootPc` (the next station's root pc). Candidate = `{ (nextRootPc + 7) % 12, 'dom7' }`. Emit only when a loop/next chord is known and the candidate root ≠ current root.
- **Circle tie-in: yes** — the candidate root is **one wedge clockwise from the next chord** on the circle (its dominant). Its 3rd is the **leading tone** into the next root.
- **Why template:** `"Swap for {cand}, the V7 of {next} — its 3rd ({leadingTone}) leans a half-step into {next}, pulling the loop around. One step clockwise on the circle."`
> **Honest circle summary:** A and D **are** circle relationships (inner ring; dominant step) — name the circle in their copy. B and C are **not** — never claim the circle for them. We do **not** require the D-61 circle widget on the dashboard; the *why* copy carries the lesson.
---
## 3. Worked examples — `Am C F`, both readings (gate: recompute me)
Loop wraps `Am → C → F → Am`. Target = **F** = `{ rootPc: 5, quality: 'maj' }`, tones `{F=5, A=9, C=0}`. Next chord after F = **Am** (`nextRootPc = 9`).
### Reading (i) — **A minor** (`i · III · ♭VI`) → 3 subs (borrowed iv suppressed)
`A-minor scale = {9,11,0,2,4,5,7}` = A B C D E F G. `getChordsInKey(A,minor) = [Am, B°, C, Dm, Em, F, G]`.
| # | Cat | Candidate (pc) | label | Diatonic check | WHY copy |
|---|-----|----------------|-------|----------------|----------|
| A | relative | (5+9)=**2**, min | **Dm** | Dm ∈ A-min = `iv` ✓ | "Dm is F's relative minor — shares **F & A**. In A minor it's the **iv**: a darker, more grounded step than the bright ♭VI. (Circle: F's inner-ring relative.)" |
| B | borrowed | — | — | mode = minor → **suppressed** | *(not shown — F is ♭VI here, not IV; Fm would be chromatic. Honest omission.)* |
| C | extension | 5, add 11→**E(4)** | **Fmaj7** | E ∈ A-min (the 5th) ✓ | "Add the major 7th (**E**) — ♭VI becomes Fmaj7, dreamy and floating. E is A minor's own 5th, so it stays in the family." |
| D | 2nd-dom | (9+7)=**4**, dom7 | **E7** | leads to Am | "Swap for **E7**, the V7 of Am — its 3rd (**G♯**) leans a half-step into A, pulling the loop back around. One step clockwise on the circle." |
*Arithmetic:* Dm={2,5,9}∩F{5,9,0}={5,9}=F,A ✓. Fmaj7={5,9,0,4}, all ∈ A-min ✓. E7={4,8,11,2}; G♯=8→A=9 ✓; E is a fifth above A (9+7=4) ✓.
### Reading (ii) — **C major** (`vi · I · IV`) → 4 subs (cap)
`C-major scale = {0,2,4,5,7,9,11}` = C D E F G A B. `getChordsInKey(C,major) = [C, Dm, Em, F, G, Am, B°]`.
| # | Cat | Candidate (pc) | label | Diatonic check | WHY copy |
|---|-----|----------------|-------|----------------|----------|
| A | relative | (5+9)=**2**, min | **Dm** | Dm ∈ C-maj = `ii` ✓ | "Dm is F's relative minor — shares **F & A**. In C it's the **ii**: trades IV's brightness for a softer, more forward pull. (Circle: F's inner-ring relative.)" |
| B | borrowed | 5, **min** | **Fm** | mode major **and** F = IV (0+5=5) ✓ | "Borrow **Fm** (the iv) from C minor — lowering A to **A♭** adds that wistful 'Creep' pull home. The classic blue move." |
| C | extension | 5, add 11→**E(4)** | **Fmaj7** | E ∈ C-maj (the 3rd) ✓ | "Add the major 7th (**E**) — same IV, lusher and static. E is C's own 3rd (the mediant), so it glues the chord to the key." |
| D | 2nd-dom | (9+7)=**4**, dom7 | **E7** | leads to Am | "Swap for **E7**, the V7 of Am — its 3rd (**G♯**) leans into A, pulling the loop around. One step clockwise on the circle." |
*Arithmetic:* Fm={5,8,0}; A(9)→A♭(8) ✓; A♭=8=(0+8)=♭6 of C ✓. Everything else as above.
**Payoff:** the *same* candidate chord (Dm, Fmaj7, E7) is right under both readings — only its role-name and *why* change with the mode. Borrowed `Fm` appears **only** under the major reading. That is the honesty the feature promises.
**Bonus — the same rules over the whole loop** (feeds the smoke truth-table): current **Am**→next C ⇒ D = **G7** (V7/C, B→C); current **C**→next F ⇒ D = **C7** (V7/F, E→F — the classic bluesy `I7→IV`). Both musically gold, both from the one rule.
---
## 4. Which chord gets suggestions — the playhead chord
**Decision: one `TryThis` card that follows the playhead — subs for the *currently sounding* chord, updated as the loop turns.**
- Rejected: one static sub for the whole loop (misses the point — the user asked specifically about *F*), and a per-station sub grid across the rail (too dense, collides with the rail — see §6).
- Target selection: `currentChord` when present → fall back to the committed loop's active/first station when silent → else idle. `opts.nextRootPc` = the following loop station's root (so Rule D can fire); when there is no loop, D is simply omitted.
- Keep it tiny: **≤4 chips, one row.** It is a nudge, not a panel.
- *(Optional nicety, not required):* the UI may drop a candidate that is already a loop chord (e.g. relative of Am = C, which is already in `Am C F`) to avoid a redundant suggestion. Engine stays pure; dedup lives in `TryThis`.
---
## 5. The UI surface — new `TryThis.jsx`, left column
A compact card, visually a sibling of `RelatedProgressions`/`CircleOfFifths` (same micro-header + chip language):
```
Try this instead of F · in A minor ← text-[10px] uppercase tracking-widest text-gray-500
[ Dm ] relative minor — softer, same family ← chip + one-line why, per row
[ Fmaj7 ] add the maj7 (E) — dreamy, in-key
[ E7 ] V7 of Am — pulls the loop around ↻
```
- **Chip = tappable**`onChordClick(label)` = App's `setSelectedChord``ChordDetailModal` (the established tap target; reuse verbatim). Each chip is a `<button>`, keyboard-reachable, `focus-visible:ring-2 focus-visible:ring-accent`.
- **Layout:** chip on the left (bold, `text-gray-100`), *why* to the right (`text-[11px] text-gray-400`), category label optional as a faint tag. `Category D` gets a small `↻` glyph hinting the circle (no widget dependency).
- **Tokens only** (`tailwind.config.js`): `bg-panel`, `border-border`, `text-accent`, `text-gray-100/400/500`, `hover:border-accent/50 hover:text-accent`. **No new colour** — nothing to flag to Maestro. Accent-on-panel meets AA (same usage cleared in D-61).
- **Mount — App.jsx only (file-disjoint from the rail/licks/related chains):** compose `TryThis` **above** `RelatedProgressions` inside App's existing `relatedSlot` prop:
```jsx
relatedSlot={
<div className="flex flex-col gap-3">
<TryThis
chord={currentChord} {/* App parses to {rootPc,quality} */}
nextRootPc={/* next station root of the committed loop, or undefined */}
keyInfo={effectiveKey}
onChordClick={setSelectedChord}
/>
<RelatedProgressions loop={detectedProgression} keyInfo={effectiveKey} onChordClick={setSelectedChord} />
</div>
}
```
This edits **App.jsx only** (mount) + the new file — it does **not** touch `JamGuide.jsx` (locked by L-71), `GlanceRail/VoicingBrowser/index.css` (L-70), or `RelatedProgressions.jsx` (L-72). The left column already reflows to one-per-row narrow (JamGuide `order-4` wrapper); `TryThis` inherits that. Glanceable in the ≤4-chip footprint at 1280×900 and stacks cleanly narrow.
---
## 6. Circle-of-fifths tie-in — honest
- **Rule A (relative)** and **Rule D (secondary dominant)** ARE circle relationships — the inner ring and the clockwise/dominant step respectively (both visible in `CircleOfFifths.jsx`). Their *why* copy names the circle.
- **Rule B (borrowed iv)** and **Rule C (extension)** are **not** circle-adjacent — modal/vertical colour. Their copy must **never** invoke the circle.
- We **do not** require the D-61 circle widget on the dashboard (it lives in the Knowledge Center). The lesson travels entirely in the one-line *why*. Optional future polish: a tiny `↻` affordance on circle-derived chips.
---
## 7. Impl scopes (bounded)
### L-73 — engine (`src/lib/theory.js`, additive; `scripts/smoke.mjs`)
- Add `export function suggestSubstitutions({ rootPc, quality }, keyInfo, opts = {})``[{ rootPc, quality, label, why, category }]`, categories in order `relative, borrowed, extension, secondary_dominant`, capped at 4, `[]` when `!keyInfo?.root`. Reuse `NOTES`, `NOTES_FLAT`, `noteName`, `noteIndex`, `CHORD_TYPES`, `getChordsInKey`, `getScale`, `getChordTones`, `intervalName`, `toRomanNumeral` — all **in-module** to theory.js (do **not** import `chordRootPC` from match.js — circular). **Never re-derive** intervals or diatonic sets. `opts.nextRootPc` gates Rule D.
- **Smoke truth-table** in `smoke.mjs` (sabotage-proven, like the `resolveDegree` guard): pin the §3 tables — `F/maj` in **A minor**`[Dm, Fmaj7, E7]` (no Fm); in **C major**`[Dm, Fm, Fmaj7, E7]`; plus `Am/min`→next C ⇒ D=`G7`, `C/maj`→next F ⇒ D=`C7`; and a no-key case → `[]`. Flip one rule constant → smoke must go red.
- **⚠ Sequencing flag for Maestro:** L-73 and **L-72 both lock `scripts/smoke.mjs`** — they cannot be claimed concurrently. Serialize them (L-73 appends a new smoke section to minimise merge friction). `theory.js` is the shared Professor+Luthier file → single-task lock + Critic + non-owning-domain review, per PROTOCOL §3.
### L-74 — UI (`src/components/TryThis.jsx` new; `src/App.jsx` mount)
- Pure/presentational component consuming `suggestSubstitutions`; props `{ chord, nextRootPc?, keyInfo, onChordClick }`; renders header + ≤4 chip/why rows; tap → `onChordClick`. Idle line when the engine returns `[]`.
- Mount per §5 (App composes it into `relatedSlot` above `RelatedProgressions`). **Files disjoint** from L-70/L-71/L-72; App.jsx is unlocked by all three. Audio contract untouched (mount/UI-state only — the App.jsx grep gate applies).
---
## 8. Rejected alternatives (≥2)
1. **Full reharmonisation engine** (chord-scale subs, iiV insertion, Coltrane changes, tritone-on-everything). **Rejected** per the user's explicit *"we dont have to create something too difficult"* + "not a reharm engine." Un-glanceable mid-jam and un-learnable. The curated 4-move set is the whole point.
2. **Blanket tritone sub** (`root+6` dom7 on every chord). **Rejected as a general rule:** the tritone sub is only functionally honest on a *dominant resolving down a fifth*; on a static major IV like `F` it yields `B7` — jarring, no diatonic footing, no *why* that teaches. The **secondary-dominant** move (Rule D) captures the same circle-of-fifths energy but is functionally grounded in the *actual next chord*. (Tritone could return later as a dom7-only opt-in.)
3. **Per-station subs plastered across the voicings rail.** **Rejected:** clutters the glance-critical rail (owned by L-70), 34 chips × N stations is too dense, and it competes with the voicings the user is reading to *play*. One playhead-following card is the nudge.
4. **Show both key readings at once.** **Rejected:** doubles the surface, contradicts "keep it small," and the app already commits to one `effectiveKey` (the mode disambiguates). The user flips the mode dropdown to see the other framing — one reading on screen at a time.
</content>
</invoke>
+145 -5
View File
@@ -1319,7 +1319,7 @@ check('fix (a) live: a clean collapsed 12-bar commit stream detects + matches bl
console.log('\nRelatedProgressions ranking pins (C-50):')
const relMod = await load('src/components/RelatedProgressions.jsx')
const { rankRelatedProgressions, collapseChanges } = relMod
const { rankRelatedProgressions, collapseChanges, siblingRole } = relMod
const { loopToDegrees, canonicalDegrees } = match
const LOOP_12BAR_A = ['A7', 'D7', 'A7', 'E7', 'D7', 'A7', 'E7'] // detection-collapsed 12-bar
@@ -1347,13 +1347,18 @@ check('collapseChanges(blues-minor) pops the wrap-around pair → 5 units [0,5,0
})
const rank12 = rankRelatedProgressions(LOOP_12BAR_A)
check('pin (a): collapsed 12-bar → matcher now recognizes blues-12bar (fix (a)); it self-excludes, blues-8bar tops at exactly 92, "shares I7→V7"', () => {
check('pin (a): collapsed 12-bar → matcher recognizes blues-12bar (fix (a)); it self-excludes, activeStyle=blues, blues-8bar tops the same-style primary at exactly 92, role "shorter form"', () => {
assert(rank12, 'ranking returned null for a parseable loop')
// Re-pinned for L-60 fix (a): the collapsed-form index makes the detected
// collapsed 12-bar match blues-12bar itself, which the id-only rule excludes
// from its own related list (§8's pre-fix note foretold exactly this flip).
assert(rank12.match.matched && rank12.match.id === 'blues-12bar',
`match is ${rank12.match.matched ? rank12.match.id : 'NONE'} — fix (a) should make the collapsed 12-bar match blues-12bar`)
// L-72 same-style-first: the match's style is the active style; the panel
// leads with SAME-STYLE ONLY siblings (finding-A → secondary dropped).
assert(rank12.activeStyle === 'blues', `activeStyle is ${rank12.activeStyle}, expected blues`)
assert(rank12.secondary.length === 0, 'secondary is non-empty — finding-A drops cross-style when a style is locked')
assert(rank12.primary.every((e) => e.style === 'blues'), 'a non-blues entry leaked into the same-style primary')
assert(!rank12.entries.some((e) => e.id === 'blues-12bar'),
'blues-12bar leaked into its own related list — the id-only exclusion broke')
const top = rank12.entries[0]
@@ -1363,21 +1368,30 @@ check('pin (a): collapsed 12-bar → matcher now recognizes blues-12bar (fix (a)
assert(top && top.id === 'blues-8bar' && top.style === 'blues',
`top entry is ${top?.style}/${top?.id}, expected blues/blues-8bar`)
assert(top.score === 92, `blues-8bar scored ${top.score}, pinned 92 (0 shape + 40 style + 36 transitions + 16 Jaccard 0 length)`)
assert(top.annotation === 'shares I7→V7', `annotation '${top.annotation}' ≠ 'shares I7→V7'`)
// L-72 §3: same-mode (major), 8 bars < 12 → role "shorter form" (was the §5
// annotation 'shares I7→V7'). Genuine relationship (shares Δ{5,7,10}), so a
// role IS emitted (finding-B gate passes).
assert(top.role === 'shorter form', `role '${top.role}' ≠ 'shorter form'`)
})
const rank251 = rankRelatedProgressions(LOOP_251_C)
check('pin (b): iiVI in C → match jazz-251-major (excluded); jazz-251-minor top at exactly 156, "same changes"', () => {
check('pin (b): iiVI in C → match jazz-251-major (excluded); activeStyle=jazz, jazz-251-minor top at exactly 156, role "minor version"', () => {
assert(rank251, 'ranking returned null for a parseable loop')
assert(rank251.match.matched && rank251.match.id === 'jazz-251-major',
`match is ${rank251.match.matched ? rank251.match.id : 'NONE'}, expected jazz-251-major (quality-overlap disambiguation)`)
assert(rank251.activeStyle === 'jazz', `activeStyle is ${rank251.activeStyle}, expected jazz`)
assert(rank251.secondary.length === 0, 'secondary is non-empty — finding-A drops cross-style when a style is locked')
assert(rank251.primary.every((e) => e.style === 'jazz'), 'a non-jazz entry leaked into the same-style primary')
assert(!rank251.entries.some((e) => e.id === 'jazz-251-major'),
'the matched progression leaked into its own related list — the id-only exclusion broke')
const top = rank251.entries[0]
assert(top && top.id === 'jazz-251-minor' && top.style === 'jazz',
`top entry is ${top?.style}/${top?.id}, expected jazz/jazz-251-minor`)
assert(top.score === 156, `jazz-251-minor scored ${top.score}, pinned 156 (100 shape + 40 style + 0 transitions + 16 Jaccard 0 length)`)
assert(top.annotation === 'same changes', `annotation '${top.annotation}' ≠ 'same changes'`)
// L-72 §3: mode differs (minor vs the active major) → role "minor version"
// (was the §5 annotation 'same changes'). Genuine relationship (same changes),
// so the finding-B gate passes even though every quality differs.
assert(top.role === 'minor version', `role '${top.role}' ≠ 'minor version'`)
assert(rank251.entries.length <= 5, `${rank251.entries.length} entries > max 5`)
})
@@ -1432,6 +1446,132 @@ check('pin (c): no-collapse counterfactual — raw blues-12bar would score exact
assert(rank12.entries[0].score === w8, `replica (${w8}) ≠ live component top score (${rank12.entries[0].score}) — the grounding broke`)
})
// L-72 finding-B (Critic gate hardening): the `genuine` gate must SUPPRESS the
// role phrase on a same-style sibling that shares NO transitions with the loop
// and is not same-changes — otherwise we'd print a false "variation" claim.
// A7E7 matches funk; funk-smooth-loop surfaces via the floor-0 same-style rule
// but is a distant sibling (0 shared, not same-changes). siblingRole() ALONE
// would still label it (mode/bars differ) — the assertion below proves the GATE
// is what nulls it. Removing `&& genuine` at the call site turns this red.
check('finding-B: a distant same-style sibling (0 shared transitions, not same-changes) carries NO role, even though siblingRole() alone would label it', () => {
const rf = rankRelatedProgressions(['A7', 'E7'])
assert(rf && rf.activeStyle === 'funk', `activeStyle is ${rf?.activeStyle}, expected funk (A7E7)`)
const activeProg = kb.funk.progressions.find((p) => p.id === rf.match.id)
assert(activeProg, `could not resolve the active funk progression ${rf.match.id}`)
const distant = rf.primary.find((e) => e.id === 'funk-smooth-loop')
assert(distant, 'funk-smooth-loop should surface as a same-style sibling (floor relaxed to 0)')
assert(distant.sameChanges === false, 'precondition: funk-smooth-loop must NOT be same-changes vs A7E7')
assert(siblingRole(distant.progression, activeProg) !== null,
'precondition: siblingRole() ALONE would label funk-smooth-loop — the genuine gate is what must suppress it')
assert(distant.role === null,
`finding-B breached: distant sibling carries role '${distant.role}' — the \`genuine\` gate was dropped (false variation claim)`)
})
// L-72 finding-A / no-match fallback (Critic gate hardening, design §6): when the
// loop matches NO KB progression, activeStyle is null and the panel is the
// pre-L-72 flat cross-style list — secondary always empty, cross-style rows keep
// their §5 annotation, and NO role phrases appear. This 8-chord A blues loop is
// not a canonical collapsed form, so it stays unmatched while still drawing
// cross-style relatives. Bites if the null path grows an activeStyle default or
// leaks roles onto cross-style rows.
check('no-match fallback: unmatched loop → activeStyle null, secondary empty, flat cross-style list with annotations (no roles)', () => {
const rNo = rankRelatedProgressions(['A7', 'D7', 'A7', 'A7', 'E7', 'D7', 'A7', 'E7'])
assert(rNo, 'ranking returned null for a parseable loop')
assert(rNo.match.matched === false, `precondition: expected NO match, got ${rNo.match.id}`)
assert(rNo.activeStyle === null, `no-match: activeStyle must be null, got ${rNo.activeStyle}`)
assert(rNo.secondary.length === 0, 'no-match: secondary must be empty')
assert(rNo.primary.length > 0, 'precondition: expected cross-style relatives above the floor')
assert(rNo.primary.every((e) => e.role === null), 'no-match: cross-style rows must carry NO role phrase')
assert(rNo.primary.every((e) => typeof e.annotation === 'string' && e.annotation.length > 0),
'no-match: cross-style rows must keep their §5 annotation')
})
// ─── 8b. "Try this" substitution engine truth-table (task L-73) ───────────────
//
// Pins docs/design/try-this-subs.md §3 (the AmCF worked tables) against the
// REAL theory.suggestSubstitutions. Asserts the FULL ordered candidate list
// (label + category), not just counts — so flipping any rule constant (e.g. the
// relative offset 9→8, the borrowed-iv +5 gate, the 2nd-dom +7) turns this red
// and names it. Categories order: relative · borrowed · extension · 2nd-dom, cap 4.
console.log('\n"Try this" substitution engine (§3 truth-tables):')
const { suggestSubstitutions } = theory
// Compact "Label[category]" projection — the shape the pins compare.
const subShape = (arr) => arr.map((s) => `${s.label}[${s.category}]`)
const F_MAJ = { rootPc: 5, quality: 'maj' } // the F under the playhead
const AM_MIN = { rootPc: 9, quality: 'min' }
const C_MAJ = { rootPc: 0, quality: 'maj' }
check('F/maj in A MINOR (next Am) → [Dm(rel), Fmaj7(ext), E7(2nd-dom)] — borrowed iv SUPPRESSED', () => {
const got = subShape(suggestSubstitutions(F_MAJ, { root: 'A', mode: 'minor' }, { nextRootPc: 9 }))
const want = ['Dm[relative]', 'Fmaj7[extension]', 'E7[secondary_dominant]']
assert(JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`)
assert(!got.some((s) => s.startsWith('Fm[')), 'Fm must NOT appear under a minor reading (F is ♭VI, not IV)')
})
check('F/maj in C MAJOR (next Am) → [Dm, Fm(borrowed), Fmaj7, E7] — cap 4', () => {
const got = subShape(suggestSubstitutions(F_MAJ, { root: 'C', mode: 'major' }, { nextRootPc: 9 }))
const want = ['Dm[relative]', 'Fm[borrowed]', 'Fmaj7[extension]', 'E7[secondary_dominant]']
assert(JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`)
})
check('Rule D: Am/min → next C(0) yields G7 (V7 of C)', () => {
const got = subShape(suggestSubstitutions(AM_MIN, { root: 'C', mode: 'major' }, { nextRootPc: 0 }))
const want = ['C[relative]', 'Am7[extension]', 'G7[secondary_dominant]']
assert(JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`)
})
check('Rule D: C/maj → next F(5) yields C7 (V7 of F — the bluesy I7→IV, same-root but different chord)', () => {
const got = subShape(suggestSubstitutions(C_MAJ, { root: 'C', mode: 'major' }, { nextRootPc: 5 }))
const want = ['Am[relative]', 'Cmaj7[extension]', 'C7[secondary_dominant]']
assert(JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`)
})
check('borrowed-iv why spells the ♭6 FLAT (A→Ab), never the sharp G#', () => {
const subs = suggestSubstitutions(F_MAJ, { root: 'C', mode: 'major' }, { nextRootPc: 9 })
const fm = subs.find((s) => s.category === 'borrowed')
assert(fm && /A→Ab/.test(fm.why), `borrowed why must contain the flat A→Ab, got: ${fm && fm.why}`)
assert(fm && !/G#/.test(fm.why), 'borrowed why must not spell the ♭6 as the sharp G#')
})
check('extension why names the added tone by scale degree ("E is C\'s 3rd (the mediant)")', () => {
const subs = suggestSubstitutions(F_MAJ, { root: 'C', mode: 'major' }, { nextRootPc: 9 })
const ext = subs.find((s) => s.category === 'extension')
assert(ext && /3rd \(the mediant\)/.test(ext.why), `extension why: ${ext && ext.why}`)
})
check('Rule C never re-emits the input chord: Dm7 in C major → [F(rel)] (min7 extension self-skipped, NO add9-on-minor)', () => {
const chord = { rootPc: 2, quality: 'min7' }
const subs = suggestSubstitutions(chord, { root: 'C', mode: 'major' }, {})
assert(JSON.stringify(subShape(subs)) === JSON.stringify(['F[relative]']), `got ${JSON.stringify(subShape(subs))}`)
assert(!subs.some((s) => s.category === 'extension'), 'extension must be OMITTED when the only fit is the chord already sounding')
assert(!subs.some((s) => s.rootPc === chord.rootPc && s.quality === chord.quality),
'no suggestion may equal the input chord {rootPc,quality}')
assert(!subs.some((s) => s.quality === 'add9'), 'a MAJOR add9 must never be suggested for a minor-family chord (would raise the 3rd)')
})
check('Rule C on a 7th input adds a DIFFERENT colour: Cmaj7 in C major → [Am(rel), Cadd9(ext)] (not Cmaj7)', () => {
const chord = { rootPc: 0, quality: 'maj7' }
const subs = suggestSubstitutions(chord, { root: 'C', mode: 'major' }, {})
assert(JSON.stringify(subShape(subs)) === JSON.stringify(['Am[relative]', 'Cadd9[extension]']), `got ${JSON.stringify(subShape(subs))}`)
const ext = subs.find((s) => s.category === 'extension')
assert(ext && !(ext.rootPc === chord.rootPc && ext.quality === chord.quality),
'the extension must not be the input chord itself')
})
check('no key (!keyInfo.root) → [] (idle, never a fabricated suggestion)', () => {
assert(JSON.stringify(suggestSubstitutions(F_MAJ, {}, {})) === '[]', 'expected [] with empty keyInfo')
assert(JSON.stringify(suggestSubstitutions(F_MAJ, null, {})) === '[]', 'expected [] with null keyInfo')
})
check('Rule D omitted when no next chord is known (opts.nextRootPc absent)', () => {
const got = subShape(suggestSubstitutions(F_MAJ, { root: 'C', mode: 'major' }, {}))
assert(!got.some((s) => s.endsWith('[secondary_dominant]')), `2nd-dom must be absent, got ${JSON.stringify(got)}`)
})
// ─── 9. Summary + exit code ───────────────────────────────────────────────────
const total = passed + failures.length
+15 -5
View File
@@ -11,6 +11,7 @@ import DrumView from './components/DrumView'
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
import ChordDetailModal from './components/ChordDetailModal'
import RelatedProgressions from './components/RelatedProgressions'
import TryThis from './components/TryThis'
import LoopStation from './components/LoopStation'
import JamGuide, { KnowledgeDock } from './components/JamGuide'
import { useLoopEngine } from './services/loopEngine'
@@ -854,11 +855,20 @@ export default function App() {
</>
}
relatedSlot={
<RelatedProgressions
loop={detectedProgression}
keyInfo={effectiveKey}
onChordClick={setSelectedChord}
/>
<div className="flex flex-col gap-3">
<TryThis
loop={detectedProgression}
keyInfo={effectiveKey}
currentChord={currentChord}
onChordClick={setSelectedChord}
instrument={instrument}
/>
<RelatedProgressions
loop={detectedProgression}
keyInfo={effectiveKey}
onChordClick={setSelectedChord}
/>
</div>
}
fill={jamView}
/>
+104 -74
View File
@@ -28,41 +28,53 @@
// "loop"). This is where the retired RoadmapTrack's education
// folds in (D-40 §2) theory.js `guideTones` / `voiceLeadingPairs`
// / `soloScale`, read-only imports.
// gallery first cell = the station's OWN voicing (guitar: the KB play's
// recommended shape badged "play", when present; piano: the
// threaded/authored voicing labeled honestly, e.g. "LH 3-5-7-9"
// the accordion's collapsed-thumb value survives here) + the full
// VoicingBrowser gallery (show={instrument}, dense). Cells
// FLEX-WRAP rows never scroll horizontally; the piano worst case
// (~1,470px of cells) wraps to a second cell line instead (§4).
// gallery the full VoicingBrowser (show={instrument}, dense) with the
// station's OWN voicing passed as `recommended` so it renders as the
// badged, accent-bordered first cell INSIDE the browser (no separate
// own-cell, no dupe dashboard-polish.md §1.1/§2.1). Guitar caps at
// 4 recommended-first shapes on one line (§1); piano is a 2×2 of
// `size="mini"` keyboards (§2). No anywhere (§3).
//
// Focus semantics (D-40 §4 the pin, simplified): with everything always
// expanded there is nothing left to hold open, so tapping a row header TOGGLES
// that station as focused. The PARENT owns the state and the onFocusChord
// emission (the D-03 fretboard guide-tone contract, byte-compatible); a focused
// row shows an "aim on fretboard" chip; tap again (or the loop changes) to
// clear. This component never emits focus-chord itself and NEVER triggers
// audio on its own every lives inside the gallery, behind a user gesture.
// clear. This component never emits focus-chord itself and never triggers audio
// the previews were removed everywhere (dashboard-polish.md §3).
//
// NO auto-scroll (D-40 §4/§6.2 step 1): the band lives in page flow, where
// scrollIntoView's nearest scroller is the DOCUMENT it would yank the whole
// 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 ChordDiagram from './ChordDiagram'
import MiniPiano from './MiniPiano'
import VoicingBrowser from './VoicingBrowser'
const pcName = (pc) => NOTES[((pc % 12) + 12) % 12]
@@ -139,9 +151,12 @@ function TransitionChip({ pair, wraps }) {
function StationRow({
st, isNow, isNext, isFocused, onToggleFocus, instrument, keyRoot, keyMode, rail, wraps,
}) {
// The station's own voicing the first gallery cell (D-40 §4).
const ownGuitar = instrument === 'guitar' && st.shape ? st.shape : null
const ownPiano = instrument === 'piano' && st.voicing ? st.voicing : null
// The station's own voicing passed to VoicingBrowser as `recommended` so it
// renders as the badged, accent-bordered first cell inside the gallery (guitar:
// a shape; piano: a voicing matched by style). The separate own-cell is gone
// (dashboard-polish.md §1.1/§2.1 it centralises the 4 rule and kills the old
// recommended/gallery duplicate).
const recommended = instrument === 'guitar' ? (st.shape ?? null) : (st.voicing ?? null)
// Active row: unmistakable (accent ring + tint). Focused-but-not-now rows get
// the softer accent border; everything else recedes to the 0.85 opacity floor
@@ -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
@@ -201,39 +231,19 @@ function StationRow({
<TransitionChip pair={rail} wraps={wraps} />
</div>
{/* Gallery: own-voicing cell FIRST (the recommended cell full-accent
border reads as "the answer", one-screen.md §4; same size, no new
colour) + the full dense browser; cells WRAP, never scroll
horizontally (D-40 §4). Browser basis 320px: a guitar own-cell
(89px) still shares line one (89+6+320 = 415 451) so the first
visual line shows play + 3 shapes = 4 across, while a piano
own-cell (156.4px) can never share it (156.4+6+320 = 482.4 > 468
even scrollbar-free) the recommended voicing sits alone on line
one, prominence for free, and the gallery pairs at the FULL row
interior below it (the 279.6+156.4 mixed-pair fit needs all 451px). */}
<div className="flex flex-wrap items-start gap-1.5">
{(ownGuitar || ownPiano) && (
<figure className="flex shrink-0 flex-col items-center gap-1.5 rounded-md border border-accent bg-surface p-1.5">
<figcaption className="flex max-w-full items-center gap-1.5 text-[11px] font-medium leading-tight text-gray-300">
{ownGuitar && (
<span className="rounded bg-accent px-1 py-0.5 text-[9px] font-bold uppercase tracking-wider text-black">
play
</span>
)}
<span className="break-words">{ownGuitar ? ownGuitar.label : ownPiano.label}</span>
</figcaption>
{ownGuitar ? (
<ChordDiagram shape={ownGuitar} keyRoot={keyRoot} rootPc={st.rootPc} size="thumb" />
) : (
<MiniPiano voicing={ownPiano} size="thumb" />
)}
</figure>
)}
<div className="min-w-0 flex-1 basis-[320px]">
{/* dense: the rail shows the mic-feedback microcopy once, below. */}
<VoicingBrowser rootPc={st.rootPc} quality={st.quality} show={instrument} dense />
</div>
</div>
{/* ── Gallery (dashboard-polish.md §1/§2): the recommended voicing is now
cell #1 INSIDE the browser (badged "play", accent border "the
answer" prominence the old own-cell had, no dupe). Guitar caps at 4
recommended-first cells on one line; piano is a 2×2 of mini
keyboards. Full row interior, no separate own-cell. */}
<VoicingBrowser
rootPc={st.rootPc}
quality={st.quality}
show={instrument}
dense
recommended={recommended}
max={4}
/>
</div>
)
}
@@ -242,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 73 (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 FE" 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 },
@@ -260,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">
@@ -277,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}
@@ -287,11 +318,10 @@ export default function GlanceRail({
))}
</div>
{/* Mic-feedback microcopy ONCE for the whole rail (D-31 §2.5); the
gallery mounts run `dense` and suppress their per-mount copy. */}
{/* No anywhere anymore (dashboard-polish.md §3 "leave them off,
better not"); the rail is purely visual. */}
<p className="mt-2 text-[11px] text-gray-500">
previews play through your speakers while the mic is live, detection may
hear them. Nothing plays automatically.
{sectionFoot}
</p>
</section>
)
+296 -88
View File
@@ -4,8 +4,8 @@ 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'
import { pianoVoicingChain } from '../lib/piano'
import { parseChord } from '../lib/voicings'
@@ -132,8 +132,16 @@ function recipeVoicing(recipe, rootPc, quality) {
//
// `licksFor` was a closure-local inside LicksSection; lifted to module scope
// during the L-33 restructure (D-31 §5) so the strip shares it instead of
// duplicating the defensive read. Licks are guitar-only in the KB (C-20 schema).
function licksFor(id) {
// duplicating the defensive read. Guitar licks are tab entries (LickCard);
// piano licks (D-70 §5.1) are the STRUCTURED entries only those carrying a
// `notes` array PianoLickCard can realize so prose-only piano education
// entries never render as placeholder cards. The dock's LicksSection reads
// guitar (its default arg), so its behaviour is unchanged.
function licksFor(id, instrument = 'guitar') {
if (instrument === 'piano') {
const l = kb?.[id]?.instruments?.piano?.licks
return Array.isArray(l) ? l.filter(x => Array.isArray(x?.notes)) : []
}
const l = kb?.[id]?.instruments?.guitar?.licks
return Array.isArray(l) ? l : []
}
@@ -156,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:
@@ -315,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
@@ -358,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
@@ -425,23 +581,25 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
</p>
)
// The licks strip (left column). Matched loops sort by the playhead
// station; heard-live falls back to the live chord's quality key (e.g. a
// "dom7" lick fits a live G7). Bass hides it (guitar tab licks are noise
// to a bassist mid-jam); LicksStrip also hides itself when empty.
const licksStrip = instrument !== 'bass' && match.matched ? (
// The licks strip (left column). It now FOLLOWS the global instrument
// (D-70 §5.1): guitar tab LickCards, piano PianoLickCards realized over
// the playhead root, bass an honest "no bass licks" line. Matched loops
// sort by the playhead station; heard-live falls back to the live chord's
// quality key (e.g. a "dom7" lick fits a live G7). The live-chord context
// carries rootPc so PianoLickCard can realize its degrees. LicksStrip itself
// decides the empty behaviour per instrument (guitar hides; piano/bass show a
// slim honest line).
const licksContext = match.matched
? contextStation
: liveChord
? { rn: '', quality: liveChord.type, label: currentChord, rootPc: liveChord.rootPc }
: null
const licksStrip = (match.matched || liveChord) ? (
<LicksStrip
styleId={activeStyle}
levels={ALL_LEVELS}
instrument={instrument}
context={contextStation}
/>
) : instrument !== 'bass' && liveChord ? (
<LicksStrip
styleId={activeStyle}
levels={ALL_LEVELS}
instrument={instrument}
context={{ rn: '', quality: liveChord.type, label: currentChord }}
context={licksContext}
/>
) : null
@@ -472,7 +630,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
</div>
)}
{relatedSlot != null && (
<div className={'order-4 xl:order-none min-w-0' + (fill ? ' xl:flex-1 xl:min-h-0 xl:overflow-y-auto' : '')}>
<div className={'order-4 xl:order-none min-w-0' + (fill ? ' xl:flex-1 xl:min-h-0 xl:overflow-y-auto dark-scroll' : '')}>
{relatedSlot}
</div>
)}
@@ -481,7 +639,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
{/* RIGHT — the suggested-voicings rail (the one contained scroller) */}
<div
className={
'order-2 xl:order-none min-w-0 xl:w-[500px] xl:shrink-0 xl:overflow-y-auto ' +
'order-2 xl:order-none min-w-0 xl:w-[500px] xl:shrink-0 xl:overflow-y-auto dark-scroll ' +
(fill ? 'xl:h-full' : 'xl:max-h-[calc(100vh_-_1.5rem)]')
}
>
@@ -844,52 +1002,101 @@ function LicksSection({ styles, levels, onToggleLevel }) {
)
}
// LicksStrip glanceable licks below the rail (L-33, D-31 §2.4)
// LicksStrip glanceable licks below the rail (L-33, D-31 §2.4; D-70 §5)
//
// Thumb LickCards for the active style, level-filtered, sorted current-station-
// Thumb lick cards for the active style, level-filtered, sorted current-station-
// context-first via the token-boundary matcher above. The "fits X now" accent
// ring + microcopy are STRIP-OWNED chrome rendered AROUND the card LickCard
// itself is untouched and shows chordContext only at size="full". Licks are
// guitar-only in the KB, so under the piano tab the strip still shows them and
// the heading says so. Style has no licks (or the level filter empties it)
// the strip hides entirely: an empty state would steal glance space to say
// nothing. Tap a thumb the card enlarges inline (comfort, not information).
// ring + caption are STRIP-OWNED chrome rendered AROUND the card the cards
// themselves are untouched and show chordContext only at size="full".
//
// FOLLOWS THE GLOBAL INSTRUMENT (D-70 §5.1):
// guitar LickCard (tab); piano PianoLickCard, realized over the playhead
// root (context.rootPc); bass the KB has no bass strip licks, so a slim
// honest line renders instead of the section vanishing.
// Piano licks are the STRUCTURED ones only (`licksFor(id,'piano')` filters to
// entries with a `notes` array) jazz/blues/gospel/rnb ship them; other styles
// read as empty under piano.
//
// UNIFORM FOOTPRINT (D-70 §5.2), imposed AT THE STRIP MOUNT, not inside the
// cards: every closed thumb sits in a fixed 220×150 box whose SVG is normalised
// to h-104/w-full (`h-[150px] [&>div]:h-full [&_svg]:!h-[104px] [&_svg]:!w-full`).
// preserveAspectRatio (SVG default) scales each tab / piano-roll to that box and
// centres it, so a guitar card and a piano card occupy the SAME box zero edit
// to LickCard/PianoLickCard geometry, so the dock's size="full" cards are
// byte-identical. Tapping enlarges a card inline (size="full", unclamped).
//
// EMPTY BEHAVIOUR (D-70 §5.3): guitar hides on empty (a silent gap is fine, the
// default); piano/bass show a slim honest line the user actively switched
// instruments there, so a vanished section would be confusing.
//
// styleId KB style whose licks to show (the active style)
// levels the shared foundation/intermediate filter
// instrument current instrument tab (piano honest "guitar licks" heading)
// context { rn, quality, label } of the playhead station (or the live
// chord in the no-loop fallback); null no context sort
function LicksStrip({ styleId, levels, instrument, context }) {
// Inline enlarge (one card at a time); reset when the style changes.
const [expandedId, setExpandedId] = useState(null)
useEffect(() => { setExpandedId(null) }, [styleId])
// instrument the global GUITAR/PIANO/BASS selector
// context { rn, quality, label, rootPc } of the playhead station (or the
// live chord in the no-loop fallback); rootPc realizes piano
// licks, label/rn/quality drive the context sort; null no sort
function EmptyLicksLine({ children }) {
return (
<p className="rounded-xl border border-dashed border-border px-3 py-2 text-xs text-gray-500">
{children}
</p>
)
}
const visible = licksFor(styleId).filter(l => levels[lickLevel(l)])
function LicksStrip({ styleId, levels, instrument, context }) {
// Inline enlarge (one card at a time); reset when the style/instrument changes.
const [expandedId, setExpandedId] = useState(null)
useEffect(() => { setExpandedId(null) }, [styleId, instrument])
const styleLabel = kb?.[styleId]?.meta?.label ?? styleId
// Bass: no strip licks in the KB honest line, never a vanished section.
if (instrument === 'bass') {
return <EmptyLicksLine>No bass licks in the KB yet.</EmptyLicksLine>
}
const isPiano = instrument === 'piano'
const visible = licksFor(styleId, instrument).filter(l => levels[lickLevel(l)])
const fitted = visible.filter(l => lickFitsContext(l, context))
const rest = visible.filter(l => !lickFitsContext(l, context))
const sorted = [...fitted, ...rest]
if (sorted.length === 0) return null
if (sorted.length === 0) {
// Piano: honest line (the player just switched to piano). Guitar: hide.
return isPiano
? <EmptyLicksLine>No {styleLabel} piano licks in the KB yet.</EmptyLicksLine>
: null
}
const styleLabel = kb?.[styleId]?.meta?.label ?? styleId
const fitLabel = typeof context?.label === 'string' ? context.label : null
const rootPc = Number.isFinite(context?.rootPc) ? context.rootPc : 0
// Fixed-box normaliser applied only when closed (open cards grow freely).
const thumbBox = 'h-[150px] [&>div]:h-full [&_svg]:!h-[104px] [&_svg]:!w-full'
return (
<section
className="rounded-2xl border border-border bg-panel p-3"
aria-label={`${styleLabel} guitar licks`}
aria-label={`${styleLabel} ${instrument} licks`}
>
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
{styleLabel} licks · guitar
{instrument === 'piano' ? ' (no piano licks in the KB yet)' : ''}
{styleLabel} licks · {instrument}
{fitted.length > 0 && fitLabel ? ` · fits ${fitLabel} first` : ''}
</h4>
<div className="flex items-start gap-2 overflow-x-auto pb-1" role="list">
<div className="flex items-start gap-2 overflow-x-auto pb-1 dark-scroll" role="list">
{sorted.map((l, idx) => {
const id = l?.id ?? `lick-${idx}`
const isFit = idx < fitted.length // sorted = fitted first, then rest
const isOpen = expandedId === id
const card = isPiano ? (
<PianoLickCard
lick={l}
rootPc={rootPc}
chordLabel={fitLabel ?? undefined}
size={isOpen ? 'full' : 'thumb'}
/>
) : (
<LickCard lick={l} size={isOpen ? 'full' : 'thumb'} />
)
return (
<div key={id} role="listitem" className={`shrink-0 ${isOpen ? 'w-[340px]' : 'w-[220px]'}`}>
<button
@@ -900,10 +1107,11 @@ function LicksStrip({ styleId, levels, instrument, context }) {
className={
'block w-full rounded-lg text-left outline-none transition ' +
'focus-visible:ring-2 focus-visible:ring-accent ' +
(isFit ? 'ring-1 ring-accent' : '')
(isFit ? 'ring-1 ring-accent ' : '') +
(isOpen ? '' : thumbBox)
}
>
<LickCard lick={l} size={isOpen ? 'full' : 'thumb'} />
{card}
</button>
{isFit && fitLabel && (
<p className="mt-1 text-center text-[10px] font-medium text-accent">
+11 -5
View File
@@ -24,6 +24,11 @@
// octave-end at-or-above the highest (plus the closing high C) so a
// 1-octave shell renders ONE octave (~140px) instead of 23 (~266390px)
// and Jam Guide station thumbs sit level with guitar ChordDiagram thumbs.
// `size="mini"` (L-70, dashboard-polish.md §2) is the SAME cropped window at
// scale 0.60 (vs thumb 0.8) so two cells fit one grid column of the rail's
// ~451px interior (2-octave cell 213.2px < 222.5px); it bumps the in-SVG "R"
// glyph (810 vu) and the bass-ring stroke (22.5) so both stay legible at
// the smaller render. `thumb`/`full` are byte-untouched `mini` is additive.
// `size="full"` keeps the wide C-anchored 23-octave window: the enlarged
// view benefits from register context, and its output stays byte-identical
// to the D-10 behaviour.
@@ -177,6 +182,7 @@ function LegacyPiano({ rootPc, lh = [], rh = [] }) {
//
function VoicingPiano({ voicing, size }) {
const isFull = size === 'full'
const isMini = size === 'mini' // L-70: cropped-thumb window at 0.60 for the dashboard 2×2 grid
const notes = Array.isArray(voicing?.notes) ? voicing.notes : []
const rootPc = ((voicing?.rootPc ?? (notes.length ? notes[0] : 0)) % 12 + 12) % 12
const bass = typeof voicing?.bass === 'number' ? voicing.bass : (notes.length ? Math.min(...notes) : null)
@@ -199,7 +205,7 @@ function VoicingPiano({ voicing, size }) {
: Math.max(1, Math.ceil((maxNote - octStart * 12) / 12))
// White keys: OCTAVES full octaves + 1 trailing C closing the top octave.
const TOTAL_WHITES = WHITE_PCS.length * OCTAVES + 1
const scale = isFull ? 1 : 0.8
const scale = isFull ? 1 : isMini ? 0.6 : 0.8
const baseW = WW * TOTAL_WHITES + 2
const SVG_W = baseW * scale
const SVG_H = (WH + (isFull ? 26 : 4)) * scale
@@ -267,12 +273,12 @@ function VoicingPiano({ voicing, size }) {
{hl?.isBass && (
<rect
x={x + 1} y={2} width={WW - 3} height={WH - 2}
rx={2} fill="none" stroke={BASS_RING} strokeWidth={2}
rx={2} fill="none" stroke={BASS_RING} strokeWidth={isMini ? 2.5 : 2}
/>
)}
{hl?.isRoot && (
<text x={x + (WW - 1) / 2} y={WH - 8}
textAnchor="middle" fill="white" fontSize={8} fontWeight="bold">
textAnchor="middle" fill="white" fontSize={isMini ? 10 : 8} fontWeight="bold">
R
</text>
)}
@@ -292,12 +298,12 @@ function VoicingPiano({ voicing, size }) {
{hl?.isBass && (
<rect
x={x + 1} y={2} width={BW - 2} height={BH - 2}
rx={2} fill="none" stroke={BASS_RING} strokeWidth={2}
rx={2} fill="none" stroke={BASS_RING} strokeWidth={isMini ? 2.5 : 2}
/>
)}
{hl?.isRoot && (
<text x={x + BW / 2} y={BH - 5}
textAnchor="middle" fill="white" fontSize={6} fontWeight="bold">
textAnchor="middle" fill="white" fontSize={isMini ? 8 : 6} fontWeight="bold">
R
</text>
)}
+7 -77
View File
@@ -49,14 +49,12 @@
// (MiniPiano's cropped-window geometry), technique chips, tips, source
// mirroring LickCard's full-size behaviour.
//
// Playback ()
// BassPatternCard's exact pattern: sequential single-note playVoicing calls
// (order lives in setTimeout scheduling playVoicing sorts/dedupes, wrong for
// a melody), beats at a fixed preview tempo (even eighths beatless), ONE
// sequence module-wide + stopAll() so it never layers over other previews,
// unmount silences. Techniques are visual-only in playback (same precedent).
// No playback (D-70 §3)
// The strip is purely visual glance over audio (the user's settled call, "then
// leave them off, better not"). The play path (sequencer + PlayButton) was
// removed with the rest of the s across the app; the card only renders now.
//
// Wiring contract (future LicksStrip integration Luthier)
// Wiring contract (LicksStrip integration Luthier, L-71)
// <PianoLickCard lick={} rootPc={011} chordLabel="Dm7" size="thumb|full" />
// lick one entry of a piano pack's top-level `licks` array
// rootPc the LIVE chord root pitch class (from the loop station)
@@ -67,10 +65,8 @@
// Design tokens (tailwind.config.js) SVG fills can't read Tailwind classes,
// so the constants below mirror the tokens (LickCard/MiniPiano convention).
import { useEffect } from 'react'
import { NOTES, CHORD_TYPES } from '../lib/theory'
import { resolveDegree } from './JamGuide'
import { playVoicing, stopAll } from '../lib/chordAudio'
// Vocabulary (hand-synced with validate-kb.mjs PIANO_LICK_TECHNIQUES; the
// smoke §7b-piano guard enforces set-equality add to BOTH or neither)
@@ -162,41 +158,6 @@ export function realizePianoLick(lick, rootPc) {
}))
}
// Sequential playback (module-level: one lick at a time, app-wide)
const PREVIEW_BPM = 96 // BassPatternCard's relaxed preview tempo
let currentSeq = null // { timeouts: number[], handles: {stop}[] }
function stopLick() {
if (!currentSeq) return
for (const t of currentSeq.timeouts) clearTimeout(t)
for (const h of currentSeq.handles) h.stop()
currentSeq = null
}
function playLick(realized) {
stopLick()
stopAll() // never layer over a VoicingBrowser (or any other) preview
const beatMs = 60000 / PREVIEW_BPM
const hasBeats = realized.every((n) => Number.isFinite(n.beat))
const times = realized.map((n, i) => (hasBeats ? (n.beat - 1) * beatMs : (i * beatMs) / 2))
const seq = { timeouts: [], handles: [] }
realized.forEach((n, i) => {
// Ring until the next distinct onset (equal beats = a dyad, same onset);
// the last note gets one beat. Small floor so tight ornaments still sound.
const nextT = times.slice(i + 1).find((t) => t > times[i])
const durMs = Math.max(160, (nextT !== undefined ? nextT - times[i] : beatMs) + 120)
seq.timeouts.push(
setTimeout(() => {
// abs is already in chordAudio's note space (0 = C3) no offset.
seq.handles.push(playVoicing([n.abs], { strumMs: 0, durMs, gain: 0.5 }))
}, times[i]),
)
})
currentSeq = seq
}
// Timeline layout (x = beat/column, y = pitch)
const PAD_T = 14 // headroom for grace glyphs above the top pill
@@ -479,28 +440,6 @@ function TechniqueChip({ tech }) {
)
}
// Same pill as BassPatternCard / VoicingBrowser (classes mirrored so every
// gallery reads identically).
function PlayButton({ ariaLabel, onClick }) {
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className={
'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-accent ' +
'bg-surface px-2.5 text-xs font-semibold text-accent outline-none transition ' +
'hover:bg-accent hover:text-black focus-visible:ring-2 focus-visible:ring-accent'
}
>
<svg aria-hidden="true" viewBox="0 0 12 12" className="h-3 w-3 fill-current">
<path d="M2.5 1.5v9l8-4.5z" />
</svg>
Play
</button>
)
}
function PlaceholderCard({ name, size }) {
return (
<div
@@ -525,10 +464,6 @@ export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full'
const realized = realizePianoLick(lick, rootPc)
const name = typeof lick?.name === 'string' && lick.name.trim() ? lick.name : 'Untitled lick'
// Unmount (loop/style/instrument change) silences any running sequence
// module-level state, so this is idempotent across sibling cards.
useEffect(() => () => stopLick(), [])
if (!realized) {
return <PlaceholderCard name={lick ? name : null} size={size} />
}
@@ -573,7 +508,7 @@ export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full'
)}
{/* The pitch timeline */}
<div className="max-w-full overflow-x-auto">
<div className="max-w-full overflow-x-auto dark-scroll">
<TimelineSvg
layout={layout}
ariaLabel={`Melody for ${name} over ${chordName}: ${pitchNames}`}
@@ -582,7 +517,7 @@ export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full'
{/* Keyboard view (full): the actual keys, numbered in strike order */}
{full && (
<div className="max-w-full overflow-x-auto">
<div className="max-w-full overflow-x-auto dark-scroll">
<LickKeyboard
realized={realized}
ariaLabel={`Keys for ${name} over ${chordName}, numbered in playing order`}
@@ -602,11 +537,6 @@ export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full'
{full && source && (
<span className="text-[10px] text-gray-500 italic leading-snug">{source}</span>
)}
<PlayButton
ariaLabel={`Play ${name} over ${chordName}`}
onClick={() => playLick(realized)}
/>
</div>
)
}
+161 -32
View File
@@ -137,13 +137,69 @@ function degreeSetJaccard(aCanon, bCanon) {
return union ? inter / union : 0
}
// Summed bar count of a progression (the total form length), or null when the
// KB entry carries no `bars` array. Used only by siblingRole (§3 rules 24).
function totalBars(prog) {
const bars = Array.isArray(prog?.bars) ? prog.bars : null
if (!bars) return null
return bars.reduce((sum, n) => sum + (typeof n === 'number' ? n : 0), 0)
}
/**
* rankRelatedProgressions(loop, kbRegistry?) { match, entries } | null
* siblingRole(sibling, active) role phrase | null (same-style-first §3)
*
* The §5 ranking. Returns null when the loop yields no degrees (no loop /
* unparseable chord names) the caller renders the idle state. `entries` is
* score-desc (stable by style, id), floored at RELATED_SCORE_FLOOR, at most
* RELATED_MAX_ENTRIES, never padded. Exported for smoke coverage.
* A short character phrase framing a same-style `sibling` against the `active`
* (matched) progression, derived ONLY from KB `mode` / `bars` / `qualities`.
* First rule that fires:
* 1. mode differs "{mode} version" (minor/major, else the name)
* 2. same mode, fewer bars "shorter form"
* 3. same mode, more bars "extended form"
* 4. same mode & length, a quality the active lacks "reharmonized"
* 5. otherwise null (honest: name + level only)
* Returns null when `active` is unresolved. The finding-B gate (a sibling that
* shares NO genuine relationship with the played loop) is applied at the call
* site see the `genuine` guard in rankRelatedProgressions.
*/
export function siblingRole(sibling, active) {
if (!sibling || !active) return null
const sMode = sibling.mode ?? null
const aMode = active.mode ?? null
// 1. mode differs the mode-flavoured version.
if (sMode && aMode && sMode !== aMode) {
if (sMode === 'minor') return 'minor version'
if (sMode === 'major') return 'major version'
return `${sMode} version`
}
// 24 only compare within a shared mode (or when both modes are absent).
if (sMode !== aMode) return null
const sBars = totalBars(sibling)
const aBars = totalBars(active)
if (sBars != null && aBars != null) {
if (sBars < aBars) return 'shorter form'
if (sBars > aBars) return 'extended form'
}
// 4. same mode & length: a colour the active progression lacks.
const aQ = new Set(Array.isArray(active.qualities) ? active.qualities : [])
const sQ = Array.isArray(sibling.qualities) ? sibling.qualities : []
if (aQ.size && sQ.some(q => !aQ.has(q))) return 'reharmonized'
return null
}
/**
* rankRelatedProgressions(loop, kbRegistry?)
* { match, activeStyle, activeStyleLabel, primary, secondary, entries } | null
*
* The §5 ranking, extended for same-style-first (L-72, docs/design/related-
* same-style.md). Returns null when the loop yields no degrees. When the loop
* matches a KB progression, `activeStyle = match.style` and the panel leads with
* that style's OTHER progressions (same-style siblings) floor relaxed to 0,
* scorer order kept, each carrying a `role` phrase (siblingRole, §3). Per the
* Maestro finding-A resolution, when a style is locked the panel shows SAME-STYLE
* ONLY (`secondary` stays empty no cross-style section). When `!match.matched`
* (`activeStyle == null`) the pre-existing cross-style flat list is returned
* unchanged (floor RELATED_SCORE_FLOOR, annotations). `entries` =
* `primary.concat(secondary)` for back-compat with `entries[0]` reads.
* Exported for smoke coverage.
*/
export function rankRelatedProgressions(loop, kbRegistry = kb) {
const loopDeg = loopToDegrees(loop)
@@ -153,6 +209,17 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) {
const match = matchLoopToProgression(loop, index)
const loopT = transitionsOf(loopUnitsOf(loop, loopDeg))
// §1: the active style IS the component's own match (roulette seed + live
// detection both route through the L-60 collapsed index). No prop needed.
const activeStyle = match.matched ? match.style : null
const activeStyleLabel = activeStyle
? kbRegistry[activeStyle]?.meta?.label ?? activeStyle
: null
// The raw active KB entry authoritative mode/bars for siblingRole (§3).
const activeProg = activeStyle
? kbRegistry[activeStyle]?.progressions?.find(p => p.id === match.id) ?? null
: null
const entries = []
for (const style of Object.keys(kbRegistry)) {
const styleLabel = kbRegistry[style]?.meta?.label ?? style
@@ -166,7 +233,7 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) {
const pCanon = canonicalDegrees(collapsed.map(u => u.deg))
const shared = sharedTransitions(loopT, transitionsOf(collapsed))
const sameChanges = pCanon === loopCanon
const sameStyle = match.matched && style === match.style
const sameStyle = activeStyle != null && style === activeStyle
let score = 0
if (sameChanges) score += SCORE_SAME_CHANGES
@@ -174,16 +241,26 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) {
score += Math.min(shared.length * SCORE_PER_TRANSITION, TRANSITION_CAP)
score += degreeSetJaccard(loopCanon, pCanon) * SCORE_JACCARD
score -= Math.abs(loop.length - collapsed.length)
if (score < RELATED_SCORE_FLOOR) continue // never pad with junk
// §2/§4: same-style siblings bypass the floor (never "junk" they are the
// "other options" the user asked for); cross-style keeps the floor.
const floor = sameStyle ? 0 : RELATED_SCORE_FLOOR
if (score < floor) continue
// §5 annotation: why this entry is here (survivors always have one
// below the floor nothing scores on Jaccard length alone).
// §5 annotation (cross-style rows): why this entry is here.
const annotation = sameChanges
? 'same changes'
: shared.length
? `shares ${shared[0].rnFrom || '?'}${shared[0].rnTo || '?'}`
: 'same style'
// §3 role (same-style rows only). finding-B gate: emit a phrase only when
// there is a genuine relationship to the played loop identical changes
// OR at least one shared (Δ, qualityquality) transition. A distantly-
// related same-style sibling (neither) gets no false "extended form" /
// "reharmonized" label just its name + level.
const genuine = sameChanges || shared.length > 0
const role = sameStyle && genuine ? siblingRole(prog, activeProg) : null
entries.push({
style,
styleLabel,
@@ -191,7 +268,10 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) {
name: prog.name,
level: prog.level === 'intermediate' ? 'intermediate' : 'foundation', // untagged counts foundation
score,
sameChanges,
sameStyle,
annotation,
role,
progression: prog,
})
}
@@ -200,7 +280,25 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) {
entries.sort(
(a, b) => b.score - a.score || a.style.localeCompare(b.style) || a.id.localeCompare(b.id)
)
return { match, entries: entries.slice(0, RELATED_MAX_ENTRIES) }
// §2 + finding-A: with a style locked, primary = same-style siblings only
// (cap 5), secondary dropped. Without a lock, the flat cross-style list.
let primary, secondary
if (activeStyle != null) {
primary = entries.filter(e => e.sameStyle).slice(0, RELATED_MAX_ENTRIES)
secondary = [] // finding-A: no cross-style section when a style is locked
} else {
primary = entries.slice(0, RELATED_MAX_ENTRIES)
secondary = []
}
return {
match,
activeStyle,
activeStyleLabel,
primary,
secondary,
entries: primary.concat(secondary),
}
}
// Presentational bits
@@ -283,6 +381,8 @@ export default function RelatedProgressions({ loop, keyInfo, onChordClick }) {
)
}
const { activeStyle, activeStyleLabel, primary } = ranked
return (
<section
className="rounded-2xl border border-border bg-panel p-3"
@@ -292,29 +392,58 @@ export default function RelatedProgressions({ loop, keyInfo, onChordClick }) {
Related progressions · from the songbook
{keyInfo?.root ? ` · in ${keyInfo.root}` : ''}
</h4>
{ranked.entries.length === 0 ? (
// Loop, but nothing clears the floor honest, never padded (§5).
<p className="text-sm text-gray-500">
Nothing in the songbook genuinely relates to this loop yet.
</p>
{primary.length === 0 ? (
activeStyle != null ? (
// Locked to a style with no siblings (§5 edge). Honest, never padded.
<p className="text-sm text-gray-500">
You&rsquo;re on the only {activeStyleLabel} loop in the songbook.
</p>
) : (
// Loop, but nothing clears the floor honest, never padded (§5).
<p className="text-sm text-gray-500">
Nothing in the songbook genuinely relates to this loop yet.
</p>
)
) : (
<ul className="flex flex-col gap-2.5">
{ranked.entries.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>
<span className="text-xs text-gray-500">{entry.styleLabel}</span>
<LevelBadge level={entry.level} />
<span className="text-[10px] text-gray-500">{entry.annotation}</span>
</div>
<ChordChain
progression={entry.progression}
keyRoot={keyRoot}
onChordClick={onChordClick}
/>
</li>
))}
</ul>
<>
{/* §2/§3: when a style is locked, lead with its OTHER progressions
reframed as variations to try same-style only (finding-A). */}
{activeStyle != null && (
<p className="mb-2 text-xs font-medium text-gray-300">
Try these in {activeStyleLabel}
</p>
)}
{/* 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>
{/* Same-style rows share the header's style hide the redundant
label; cross-style rows keep it. */}
{activeStyle == null && (
<span className="text-xs text-gray-500">{entry.styleLabel}</span>
)}
<LevelBadge level={entry.level} />
{/* Same-style rows show the role phrase (omitted when null,
finding-B); cross-style rows keep the §5 annotation. */}
{activeStyle != null
? entry.role && (
<span className="text-[10px] text-gray-500">{entry.role}</span>
)
: <span className="text-[10px] text-gray-500">{entry.annotation}</span>}
</div>
<ChordChain
progression={entry.progression}
keyRoot={keyRoot}
onChordClick={onChordClick}
/>
</li>
))}
</ul>
</>
)}
</section>
)
+234
View File
@@ -0,0 +1,234 @@
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 stable 3-slot rows-of-shapes substitution rail (L-78 / D-76)
//
// 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.
const SUFFIX_TO_QUALITY = Object.fromEntries(
Object.entries(CHORD_TYPES).map(([quality, def]) => [def.suffix, quality])
)
// Parse a chord-name string { rootPc, quality } (a CHORD_TYPES key) using the
// established helpers chordRootPC for the root pc, the CHORD_TYPES suffix
// inversion for the quality. Never re-derived. Returns null when unparseable.
export function parseChordName(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 }
}
// 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).
const CIRCLE_CATEGORIES = new Set(['relative', 'secondary_dominant'])
const CATEGORY_TAG = {
relative: 'relative',
borrowed: 'borrowed',
extension: 'colour',
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
// won't parse. Reuses parseChordName / chordRootPC / suggestSubstitutions never
// re-derives theory.
function subsForChord(name, pos, loopArr, keyInfo) {
const parsed = parseChordName(name)
if (!parsed) return null
let nextRootPc
if (loopArr && pos >= 0) {
const pc = chordRootPC(loopArr[(pos + 1) % loopArr.length])
if (pc >= 0) nextRootPc = pc
}
return { name, pos, subs: suggestSubstitutions(parsed, keyInfo, { nextRootPc }) }
}
// 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; 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.
function pickSubject(loopArr, keyInfo, currentChord) {
const pos = loopArr ? loopArr.indexOf(currentChord) : -1
const live = subsForChord(currentChord, pos, loopArr, keyInfo)
if (live && live.subs.length) return live
if (loopArr) {
for (let i = 0; i < loopArr.length; i++) {
const cand = subsForChord(loopArr[i], i, loopArr, keyInfo)
if (cand && cand.subs.length) return cand
}
}
return 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)
// No subject (no loop + no valid live chord) or 0 subs no card.
if (!subject) return null
const subjectChord = subject.name
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 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
Try this instead of {subjectChord}
{keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
</h4>
{/* 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>
)
}
+131 -98
View File
@@ -9,20 +9,19 @@
// Guitar section every placeable `GUITAR_SHAPES[quality]` entry from
// src/lib/voicings.js (open shapes only in their native key, movable shapes
// only when the whole grip fits under fret 15), each cell = shape label +
// <ChordDiagram size="thumb"/> + its own .
// <ChordDiagram size="thumb"/>.
// Piano section all four src/lib/piano.js `pianoVoicing` styles
// (root / shell / rootlessA / rootlessB), each cell = the voicing's honest
// label (e.g. "rootless A (3-5-7-9)") + <MiniPiano voicing size="thumb"/> +
// its own .
// label (e.g. "rootless A (3-5-7-9)") + <MiniPiano voicing/>.
//
// Playback: src/lib/chordAudio.js (L-20). ONE live {stop} handle for the whole
// gallery any stops the previous sound before starting (chord change and
// unmount also stop it), so previews never layer. First click is the user
// gesture that lazily creates the AudioContext.
// No playback (L-70 / dashboard-polish.md §3): the per-cell preview was removed
// from EVERY mount the user settled "leave them off, better not." The component
// is now purely visual; src/lib/chordAudio.js is no longer imported here.
//
// Mount points (wired by L-21/L-22, NOT here): Knowledge Center Voicings
// section, ChordDetailModal Guitar/Piano tabs (show="guitar"/"piano", L-25),
// and the Jam Guide station-enlarge view. This component stays pure & prop-driven.
// and the Jam Guide station rail / heard-live view (dense). This component stays
// pure & prop-driven.
//
// Layout: each instrument section is a flex-wrap gallery of fixed-content-width
// cells, so it reflows to fewer columns (down to one cell per row) inside a
@@ -52,14 +51,26 @@
// two-octave + one-octave piano pair fit (279.6 + 6 + 156.4 = 442
// 451px row interior even with a classic Windows scrollbar in
// the rail's scroller; it was 452 vs 456, a ~4px squeak, before).
// Every pre-existing mount renders identically with no prop.
// L-70 (dashboard-polish.md §2) additionally renders the piano
// styles as a 2×2 grid of `size="mini"` keyboards under dense, and
// applies the `.dark-scroll` utility to each cell's scroller. Every
// pre-existing mount renders identically with no prop.
// recommended a shape object (guitar) or voicing object (piano) marking the
// KB play's own voicing (dashboard rail only). Guitar: matched into
// the list by `label`, floated to cell #1, badged "play" + accent
// border, and the list reordered recommended-first (§1.2). Piano:
// matched by `voicing.style`, that 2×2 cell gets the accent border
// (§2.1). Default null no highlight, order untouched.
// max cap the guitar list to this many cells (dashboard rail passes 4).
// Undefined no cap (all placeable shapes). Off-rail mounts pass
// neither `recommended` nor `max`, so the guitar list keeps its raw
// `matchingShapes` declared order byte-identical to today.
import { useEffect, useMemo, useRef } from 'react'
import { useMemo } from 'react'
import ChordDiagram from './ChordDiagram'
import MiniPiano from './MiniPiano'
import { GUITAR_SHAPES } from '../lib/voicings'
import { pianoVoicing } from '../lib/piano'
import { playVoicing, guitarShapeToNotes } from '../lib/chordAudio'
import { NOTES, CHORD_TYPES } from '../lib/theory'
// Standard-tuning open-string pitch classes, low-E first (mirrors ChordDiagram).
@@ -90,6 +101,57 @@ function matchingShapes(quality, rootPc) {
})
}
// Lowest base fret of a movable shape under ChordDiagram's placement convention
// (root-at-open-string fret-12 barre). Open shapes sit at the nut (0).
function baseFretOf(shape, rootPc) {
if (Array.isArray(shape.frets)) return 0
const idx = 6 - shape.rootStr
let bf = mod12(rootPc - (OPEN_PCS[idx] ?? 4))
if (bf === 0) bf = 12
return bf
}
// Count of muted strings in a shape's grip (fewer = a fuller voicing wins ties).
function mutedCount(shape) {
const arr = Array.isArray(shape.frets) ? shape.frets : shape.offsets
return Array.isArray(arr) ? arr.filter((v) => v === 'x').length : 0
}
// The rail's max, recommended-first ordering (dashboard-polish.md §1.2). Only
// invoked when `recommended` and/or `max` are supplied (the dashboard rail)
// non-dense mounts skip it entirely and keep `matchingShapes` declared order,
// so VoicingsSection/ChordDetailModal stay byte-identical.
// 1. recommended first (matched into the placeable list by label; prepended
// even if it was filtered out as unplaceable);
// 2. open-position forms (Array.isArray(frets));
// 3. movable/barre by lowest base fret ascending;
// 4. tiebreak: fewer muted strings, then declared order in GUITAR_SHAPES.
// Then slice to `max` (recommended deduped so it never repeats). <max placeable
// shapes just show what exist the rule caps, never pads.
function orderGuitarShapes(shapes, rootPc, recommended, max) {
const declaredIndex = new Map(shapes.map((s, i) => [s, i]))
let rec = null
if (recommended?.label) {
rec = shapes.find((s) => s.label === recommended.label) ?? recommended
}
const rest = shapes.filter((s) => s.label !== rec?.label)
rest.sort((a, b) => {
const aOpen = Array.isArray(a.frets) ? 0 : 1
const bOpen = Array.isArray(b.frets) ? 0 : 1
if (aOpen !== bOpen) return aOpen - bOpen
if (aOpen === 1) {
const bf = baseFretOf(a, rootPc) - baseFretOf(b, rootPc)
if (bf !== 0) return bf
}
const mc = mutedCount(a) - mutedCount(b)
if (mc !== 0) return mc
return (declaredIndex.get(a) ?? 0) - (declaredIndex.get(b) ?? 0)
})
const ordered = rec ? [rec, ...rest] : rest
const list = Number.isFinite(max) ? ordered.slice(0, max) : ordered
return { list, recLabel: rec?.label ?? null }
}
// "C", "Cm7", "Cmaj7" display name from the app's canonical chord model.
function chordName(rootPc, quality) {
const q = CHORD_TYPES[quality] ? quality : 'maj'
@@ -98,28 +160,6 @@ function chordName(rootPc, quality) {
// Small presentational atoms
// Per-cell . Small accent text sits on bg-surface (#0f0f0f), where accent
// #a855f7 measures 4.8:1 AA for small text (surface-background rule).
function PlayButton({ ariaLabel, onClick }) {
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className={
'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-accent ' +
'bg-surface px-2.5 text-xs font-semibold text-accent outline-none transition ' +
'hover:bg-accent hover:text-black focus-visible:ring-2 focus-visible:ring-accent'
}
>
<svg aria-hidden="true" viewBox="0 0 12 12" className="h-3 w-3 fill-current">
<path d="M2.5 1.5v9l8-4.5z" />
</svg>
Play
</button>
)
}
function SectionHeading({ children }) {
return (
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
@@ -128,47 +168,76 @@ function SectionHeading({ children }) {
)
}
// One gallery cell: label on top, diagram thumb, its own underneath.
// One gallery cell: label on top, diagram/keyboard thumb underneath.
// bg-surface inside the bg-panel section gives the cells a quiet inlay border;
// label is gray-300 on surface (AA comfortable at 11px semibold).
// `dense` (D-51): p-1.5 instead of p-2 4px off each cell's box width, part of
// the right-column margin-hardening. Non-dense output is byte-identical.
function GalleryCell({ label, playLabel, onPlay, dense = false, children }) {
// `recommended` (L-70, dashboard-polish.md §1.2): the KB play's shape/style
// accent border + a "play" badge (finger-this, not audio; is gone), the
// prominence the old GlanceRail own-cell carried. Only ever set under the
// dashboard rail, so non-recommended cells stay byte-identical.
function GalleryCell({ label, recommended = false, dense = false, children }) {
return (
<figure className={`flex min-w-0 flex-col items-center gap-1.5 rounded-md border border-border bg-surface ${dense ? 'p-1.5' : 'p-2'}`}>
<figcaption
className="max-w-full break-words text-center text-[11px] font-medium leading-tight text-gray-300"
title={label}
>
{label}
</figcaption>
<figure className={`flex min-w-0 flex-col items-center gap-1.5 rounded-md border ${recommended ? 'border-accent' : 'border-border'} bg-surface ${dense ? 'p-1.5' : 'p-2'}`}>
{recommended ? (
<figcaption
className="flex max-w-full items-center gap-1.5 text-center text-[11px] font-medium leading-tight text-gray-300"
title={label}
>
<span className="rounded bg-accent px-1 py-0.5 text-[9px] font-bold uppercase tracking-wider text-black">
play
</span>
<span className="break-words">{label}</span>
</figcaption>
) : (
<figcaption
className="max-w-full break-words text-center text-[11px] font-medium leading-tight text-gray-300"
title={label}
>
{label}
</figcaption>
)}
{/* Scroll guard: MiniPiano's SVG has a fixed pixel width (up to ~266px
for a 2-octave thumb window); scroll inside the cell on very narrow
viewports rather than letting it break the wrap layout. */}
<div className="max-w-full overflow-x-auto">{children}</div>
<PlayButton ariaLabel={playLabel} onClick={onPlay} />
viewports rather than letting it break the wrap layout. `dark-scroll`
(L-70 §4) dresses this scroller on the dashboard rail only (dense)
the KC/modal keep the OS default, so non-dense stays byte-identical. */}
<div className={`max-w-full overflow-x-auto${dense ? ' dark-scroll' : ''}`}>{children}</div>
</figure>
)
}
// The gallery
export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'both', dense = false }) {
export default function VoicingBrowser({
rootPc = 0, quality = 'maj', show = 'both', dense = false, recommended = null, max,
}) {
const pc = mod12(Number.isFinite(rootPc) ? rootPc : 0)
const name = chordName(pc, quality)
const chordKey = `${pc}:${quality}`
// Section gating (D-23; 'bass' added by D-41 per D-40 §3). 'guitar' hides
// the piano section, 'piano' hides the guitar section, 'bass' hides BOTH
// (neither gallery is honest for a bassist the one-liner below renders
// instead, so the dock's VoicingsSection under the global BASS selector
// stops showing guitar+piano). Anything else (incl. the 'both' default)
// shows both. Hooks stay unconditional; the shared stop-handle discipline
// (stop on chord change / unmount) is untouched by hiding a section.
// shows both. Hooks stay unconditional.
const showGuitar = show !== 'piano' && show !== 'bass'
const showPiano = show !== 'guitar' && show !== 'bass'
const guitarShapes = useMemo(() => matchingShapes(quality, pc), [quality, pc])
const allGuitarShapes = useMemo(() => matchingShapes(quality, pc), [quality, pc])
// Guitar list: the dashboard rail (recommended and/or max supplied) reorders
// recommended-first + caps to `max` (§1.2); every other mount keeps the raw
// `matchingShapes` declared order untouched byte-identical to today.
const guitarView = useMemo(() => {
if (!recommended && !Number.isFinite(max)) {
return { list: allGuitarShapes, recLabel: null }
}
return orderGuitarShapes(allGuitarShapes, pc, recommended, max)
}, [allGuitarShapes, pc, recommended, max])
const guitarShapes = guitarView.list
const pianoOptions = useMemo(
() =>
PIANO_STYLES.map((style) => ({
@@ -177,38 +246,10 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
})),
[pc, quality],
)
// One live playback handle for the whole gallery: any new play (or chord
// change, or unmount) stops the previous sound first the L-20 {stop}
// contract, so previews never layer or leak.
const handleRef = useRef(null)
const stopCurrent = () => {
handleRef.current?.stop()
handleRef.current = null
}
// Chord change cleanup silences the old preview; same cleanup covers unmount.
useEffect(() => stopCurrent, [chordKey])
// Known advisory (L-20 gate): when a movable shape's root lands on an open
// string (base fret 0), ChordDiagram draws the fret-12 octave barre while
// guitarShapeToNotes places the grip at the open position the SAME chord,
// one octave lower than drawn. Deliberately left as-is on both sides.
function playGuitar(shape) {
stopCurrent()
handleRef.current = playVoicing(guitarShapeToNotes(shape, { rootPc: pc }), {
strumMs: 45, // a light strum reads "guitar"
durMs: 1800,
})
}
function playPiano(voicing) {
stopCurrent()
handleRef.current = playVoicing(voicing?.notes ?? [], {
strumMs: 15, // near-block chord reads "piano"
durMs: 1800,
})
}
// Piano "recommended" (dashboard rail): the station's authored voicing matches
// one of the four styles that 2×2 cell gets the accent border (§2.1). Guitar-
// shape `recommended` objects have no `.style`, so this stays null off-rail.
const recStyle = recommended && typeof recommended.style === 'string' ? recommended.style : null
return (
<div className="flex w-full min-w-0 flex-wrap gap-2">
@@ -241,8 +282,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
<GalleryCell
key={`${shape.label}-${i}`}
label={shape.label}
playLabel={`Play ${name}${shape.label} guitar voicing`}
onPlay={() => playGuitar(shape)}
recommended={guitarView.recLabel !== null && shape.label === guitarView.recLabel}
dense={dense}
>
<ChordDiagram shape={shape} rootPc={pc} size="thumb" />
@@ -265,10 +305,14 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
</div>
)}
{/* dense (dashboard rail, §2.1): the four styles render as a tidy 2×2
grid of compact `size="mini"` keyboards (two per column fit the
~451px row interior). Non-dense keeps today's flex-wrap gallery at
size="thumb" byte-identical. */}
<div
role="group"
aria-label={`${name} piano voicings — every style shown, each playable`}
className={`flex flex-wrap items-stretch ${dense ? 'gap-1.5' : 'gap-2'}`}
className={dense ? 'grid grid-cols-2 gap-1.5' : 'flex flex-wrap items-stretch gap-2'}
>
{/* pianoVoicing() output carries no rootPc, and without it VoicingPiano
falls back to the LOWEST voice for its "R" badge wrong for rootless
@@ -277,11 +321,10 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
<GalleryCell
key={style}
label={voicing.label}
playLabel={`Play ${name}${voicing.label} piano voicing`}
onPlay={() => playPiano(voicing)}
recommended={recStyle !== null && style === recStyle}
dense={dense}
>
<MiniPiano voicing={{ ...voicing, rootPc: pc }} size="thumb" />
<MiniPiano voicing={{ ...voicing, rootPc: pc }} size={dense ? 'mini' : 'thumb'} />
</GalleryCell>
))}
</div>
@@ -297,16 +340,6 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
</p>
)}
{/* Mic-feedback caveat, per the L-20 header + D-20 §3 (microcopy tier).
Skipped under `dense`, where the GlanceRail shows the SAME microcopy
once for the whole rail (D-31 §2.5) instead of per gallery and under
'bass', where there is no to caveat. */}
{!dense && (showGuitar || showPiano) && (
<p className="w-full basis-full text-[11px] text-gray-500">
Previews play through your speakers while the mic is live, detection may
hear them.
</p>
)}
</div>
)
}
+16
View File
@@ -6,3 +6,19 @@ body {
color: #f5f5f5;
font-family: system-ui, -apple-system, sans-serif;
}
/* Thin, dark, overlay-feel scrollbars for the jam dashboard's scrollers
(dashboard-polish.md §4). Scoped (a class), NOT global the KnowledgeDock /
Debug / Tuner / Settings keep the OS default. Raw hex is unavoidable here:
::-webkit-scrollbar pseudo-elements are not reachable by Tailwind utilities;
the values mirror the design tokens (border #2a2a2a, panel #1a1a1a). */
.dark-scroll {
scrollbar-width: thin; /* Firefox */
scrollbar-color: #2a2a2a transparent; /* thumb = border token · track transparent */
}
.dark-scroll::-webkit-scrollbar { width: 8px; height: 8px; } /* WebKit/Blink */
.dark-scroll::-webkit-scrollbar-track { background: transparent; }
.dark-scroll::-webkit-scrollbar-thumb {
background: #2a2a2a; border-radius: 4px; /* border token */
}
.dark-scroll::-webkit-scrollbar-thumb:hover { background: #3a3a3a; }
+170
View File
@@ -746,3 +746,173 @@ export function transposeChord(chordName, semitones) {
export function transposeProgression(chords, semitones) {
return chords.map(c => transposeChord(c, semitones))
}
// ─── "Try this" — key-aware substitution nudge (task L-73) ────────────────────
//
// Curated, learnable chord-substitution engine for the Jam Guide dashboard.
// Spec: docs/design/try-this-subs.md §2 (the 4 category rules) + §3 (the worked
// AmCF truth-tables). For the chord under the playhead, in the detected key,
// returns up to 4 alternatives — each with one plain sentence that teaches WHY
// it works. This is NOT the context-free colour-swap grid in education.js; this
// one is key-aware and changes the root (relative / secondary dominant).
//
// All-in-module: reuses NOTES, NOTES_FLAT, noteName, noteIndex, CHORD_TYPES,
// getChordsInKey, getScale, intervalName, toRomanNumeral, chordTonePcs. It does
// NOT import match.js's chordRootPC (that would be circular) — it uses theory's
// own noteIndex(keyInfo.root) for the key root pc.
// Chord-quality families the rules branch on.
const SUB_MAJOR_FAMILY = ['maj', 'maj7', 'maj6', 'add9']
const SUB_MINOR_FAMILY = ['min', 'min7', 'min6']
// Key modes with a major tonic ("major-ish") — the only readings under which the
// borrowed-iv (Rule B) is honest. A minor/dorian/phrygian reading suppresses it.
const SUB_MAJORISH_MODES = ['major', 'lydian', 'mixolydian']
// Scale-degree vocabulary for the extension why-copy ("E is C's 3rd (the mediant)").
const DEGREE_ORDINALS = ['root', '2nd', '3rd', '4th', '5th', '6th', '7th']
const DEGREE_NAMES = ['tonic', 'supertonic', 'mediant', 'subdominant', 'dominant', 'submediant', 'leading tone']
// Name a pitch class by its scale degree in the key, e.g. "3rd (the mediant)".
// Returns null if the pc is not diatonic (Rule C never calls it off-scale).
function subDegreeWord(pc, keyRootPc, mode) {
const scale = SCALES[mode] ?? SCALES.major
const semi = ((((pc - keyRootPc) % 12) + 12) % 12)
const idx = scale.indexOf(semi)
if (idx < 0) return null
return `${DEGREE_ORDINALS[idx]} (the ${DEGREE_NAMES[idx]})`
}
/**
* suggestSubstitutions({ rootPc, quality }, keyInfo, opts = {})
* [{ rootPc, quality, label, why, category }]
*
* Categories, always in this order (softest boldest), capped at 4:
* relative · borrowed · extension · secondary_dominant
* Returns [] when there is no key (`!keyInfo?.root`) the UI shows an idle line.
*
* `label = NOTES[rootPc] + CHORD_TYPES[quality].suffix`. `opts.nextRootPc` (the
* next loop station's root pc) gates Rule D. See docs/design/try-this-subs.md §2.
*
* Sanity (AmCF loop, §3):
* F/maj (5) in A minor, next Am (9) [Dm(rel), Fmaj7(ext), E7(2nd-dom)] (no Fm)
* F/maj (5) in C major, next Am (9) [Dm, Fm(borrowed), Fmaj7, E7] (cap 4)
* Am/min(9) next C (0): Rule D = G7 ; C/maj(0) next F (5): Rule D = C7
*/
export function suggestSubstitutions({ rootPc, quality } = {}, keyInfo, opts = {}) {
if (!keyInfo?.root) return []
const keyRootPc = noteIndex(keyInfo.root)
if (keyRootPc < 0 || rootPc == null || quality == null) return []
const mode = keyInfo.mode ?? 'major'
const r = ((rootPc % 12) + 12) % 12
const origLabel = NOTES[r] + (CHORD_TYPES[quality]?.suffix ?? '')
const isMajorFam = SUB_MAJOR_FAMILY.includes(quality)
const isMinorFam = SUB_MINOR_FAMILY.includes(quality)
// Diatonic chord-name set + scale pitch-class set for the gates.
const diatonic = new Set(getChordsInKey(keyInfo.root, mode))
const scaleInts = SCALES[mode] ?? SCALES.major
const scalePcs = new Set(scaleInts.map(i => (keyRootPc + i) % 12))
const mk = (candRootPc, candQuality, why, category) => {
const pc = ((candRootPc % 12) + 12) % 12
return {
rootPc: pc,
quality: candQuality,
label: NOTES[pc] + (CHORD_TYPES[candQuality]?.suffix ?? ''),
why,
category,
}
}
const out = []
// ── Rule A — relative / diatonic-third sub (softest). Circle: inner ring. ──
// major-family → relative minor (root+9); minor-family → relative major (root+3).
// Emit only if the candidate is diatonic in the key.
{
let candRootPc = null, candQuality = null
if (isMajorFam) { candRootPc = (r + 9) % 12; candQuality = 'min' }
else if (isMinorFam) { candRootPc = (r + 3) % 12; candQuality = 'maj' }
if (candRootPc !== null) {
const label = NOTES[candRootPc] + (CHORD_TYPES[candQuality].suffix)
if (diatonic.has(label)) {
// Shared tones = intersection of the two triads (root & 3rd of the original).
const origTones = new Set(chordTonePcs(r, quality))
const shared = chordTonePcs(candRootPc, candQuality).filter(t => origTones.has(t))
const sharedNames = shared.map(t => noteName(t)).join(' & ')
const relWord = isMajorFam ? 'minor' : 'major'
const pull = isMajorFam ? 'softer' : 'brighter'
const rn = toRomanNumeral(label, keyInfo.root, mode)
out.push(mk(candRootPc, candQuality,
`${label} is ${origLabel}'s relative ${relWord} — shares ${sharedNames}. `
+ `In this key it's the ${rn}: a ${pull} pull, same family. `
+ `(Circle: its inner-ring relative.)`,
'relative'))
}
}
}
// ── Rule B — borrowed iv (the "Creep" move), conditional. NOT a circle step. ──
// Only under a major-ish reading, on the IV (root === keyRoot+5), major-family.
if (isMajorFam && SUB_MAJORISH_MODES.includes(mode) && r === (keyRootPc + 5) % 12) {
const n6 = noteName(keyRootPc + 9) // natural 6th of the key
const nb6 = noteName(keyRootPc + 8, true) // ♭6 — spelled FLAT (A→A♭, never G#)
out.push(mk(r, 'min',
`Borrow ${NOTES[r]}m (the iv) from the parallel minor — ${n6}${nb6} adds that `
+ `wistful pull home. The 'Creep' move.`,
'borrowed'))
}
// ── Rule C — extension / colour (same function, one diatonic colour tone). ──
// Vertical colour, NOT a circle step. Pick the first extension whose added tone
// is diatonic; omit the category if none qualifies.
//
// Two guards keep the suggestion an honest ALTERNATIVE, not the chord already
// sounding (the common case — jazz stations are min7/maj7/dom7):
// (a) skip any candidate whose quality === the input quality — the chord
// already carries that colour (a min7 input never re-emits min7).
// (b) minor-family offers ONLY min7. CHORD_TYPES.add9 = [0,2,4,7] is a MAJOR
// add9 (interval 4 = major 3rd), so applying it to a minor chord would
// raise the 3rd (Dm → D, F♮→F♯) = a wrong-note suggestion. Never do it.
{
let opts2 = null
if (isMajorFam) opts2 = [['maj7', 11], ['add9', 2], ['maj6', 9]]
else if (isMinorFam) opts2 = [['min7', 10]]
else if (quality === 'dom7') opts2 = [['sus4', 5]]
if (opts2) {
const pick = opts2.find(([q, interval]) =>
q !== quality && scalePcs.has((r + interval) % 12))
if (pick) {
const [extQuality, interval] = pick
const addedPc = (r + interval) % 12
const addedNote = noteName(addedPc)
const rn = toRomanNumeral(origLabel, keyInfo.root, mode)
const dw = subDegreeWord(addedPc, keyRootPc, mode)
out.push(mk(r, extQuality,
`Add the ${intervalName(interval).toLowerCase()} (${addedNote}) — same ${rn}, lusher. `
+ `${addedNote} is ${keyInfo.root}'s own ${dw}, so it stays in the family.`,
'extension'))
}
}
}
// ── Rule D — secondary dominant of the next chord (boldest). Circle move. ──
// V7 of the next loop chord = (nextRootPc+7) dom7. Requires a known next chord;
// omit when the candidate is the identical chord already sounding.
if (opts.nextRootPc != null) {
const nextPc = ((opts.nextRootPc % 12) + 12) % 12
const candRootPc = (nextPc + 7) % 12
const isSameChord = candRootPc === r && quality === 'dom7'
if (!isSameChord) {
const leadingTone = noteName(candRootPc + 4) // dom7's 3rd = ascending leading tone (SHARP)
const nextName = noteName(nextPc)
out.push(mk(candRootPc, 'dom7',
`Swap for ${NOTES[candRootPc]}7, the V7 of ${nextName} — its 3rd (${leadingTone}) leans `
+ `a half-step into ${nextName}, pulling the loop around. One step clockwise on the circle.`,
'secondary_dominant'))
}
}
return out.slice(0, 4)
}