Compare commits
47 Commits
2c18c71598
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 41751a1a58 | |||
| ce22561813 | |||
| 436522f3b4 | |||
| 077925f1d7 | |||
| 072920ff0e | |||
| 8ebdc73958 | |||
| 9415daf4a0 | |||
| 7561566ceb | |||
| d543b986dc | |||
| ede3f2e372 | |||
| dc503807ef | |||
| 8c018d697d | |||
| e6957160f6 | |||
| 957ce88fd6 | |||
| 51c2be90cc | |||
| 2905a4b979 | |||
| c62cd60595 | |||
| 89b3e323ea | |||
| 6885fc3ce1 | |||
| 19b1a4ff91 | |||
| aae69e8a7b | |||
| c16e9f1827 | |||
| 9e21df127c | |||
| 3fdc72ebd4 | |||
| 245d9f6fb2 | |||
| 038fdfafc8 | |||
| 3469bafa0d | |||
| 0b75ebdce1 | |||
| a311895d50 | |||
| a95ad147f7 | |||
| c2207ef7b6 | |||
| a13c8fecc0 | |||
| bb802db2b6 | |||
| 952d8076df | |||
| c6c6427f50 | |||
| 4d562d758e | |||
| b8c955570e | |||
| 2072f3674e | |||
| 1df6fe8e9e | |||
| 8d74e54f5a | |||
| 4b64c41417 | |||
| 4deba7e443 | |||
| 6e8d8f40fc | |||
| d5a45f433a | |||
| e58a7a624c | |||
| 800fd43778 | |||
| 8e7a648660 |
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"projects": {
|
||||
"default": "itsamejms"
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,8 @@ debug.log
|
||||
releases/
|
||||
release/
|
||||
.DS_Store
|
||||
|
||||
# Firebase
|
||||
.firebase/
|
||||
firebase-debug.log
|
||||
ui-debug.log
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -120,3 +120,9 @@ Two audio pipelines run in parallel: a fast **pitch path** (4096-sample FFT, McL
|
||||
## License
|
||||
|
||||
No license file is set yet. Until one is added, all rights are reserved by the authors — please open an issue before reusing the code.
|
||||
|
||||
|
||||
## Deploy
|
||||
- firebase login
|
||||
- npm run build
|
||||
- firebase deploy --only hosting:jambuddy
|
||||
+65
-14
@@ -16,15 +16,15 @@ The shared queue all agents read and write. Lifecycle and rules: [`PROTOCOL.md`]
|
||||
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
||||
|----|-------|--------|--------|-----------|--------------|--------------------|
|
||||
| M-07 | Seed `sprint-roulette-kb`; schedule hourly cron | maestro | done | — | `docs/agents/LEDGER.md` | seeded, cron created |
|
||||
| C-60 | **Piano lick schema** (additive): licks today are guitar-tab-only — design the piano-lick shape reusing the bass-pattern language (per-note `deg` XOR typed `approach`, `beat`, `octave`, technique from a PIANO-appropriate vocab subset — decide which of the 8 words apply to keys and whether grace-note/roll style additions are needed; keep it validateable like C-41 did) in SCHEMA.md + validate-kb.mjs + smoke bite-test | quality | ready | — | `src/data/kb/SCHEMA.md`, `scripts/validate-kb.mjs`, `scripts/smoke.mjs` | schema documented; validator enforces; existing KB green; smoke proves it bites |
|
||||
| D-60 | **PianoLickCard** renderer — visual rhyme with LickCard's tab idiom but for keys (timeline + MiniPiano-style pitches or a degree ladder — pick per readability at strip size); playback via chordAudio like BassPatternCard | design | backlog | C-60 | `src/components/PianoLickCard.jsx` (new) + LicksStrip wiring (re-lock at promotion — JamGuide.jsx is one-screen-locked until C-50) | renders every P-60 lick; playable; graceful empty/malformed |
|
||||
| P-60 | Piano licks pack #1: 3-4 each for **jazz + blues** piano (C-60 schema; arithmetic-verified prose, the P-41 bar) | content | backlog | C-60 | `src/data/kb/jazz/piano.js`, `src/data/kb/blues/piano.js` | validator green; every lick playable as written |
|
||||
| P-61 | Piano licks pack #2: 3-4 each for **gospel + rnb** piano | content | backlog | P-60 | `src/data/kb/gospel/piano.js`, `src/data/kb/rnb/piano.js` | validator green |
|
||||
| P-62 | KB voicings expansion: **pop piano cell** (next per kb-backlog) + audit `GUITAR_SHAPES` for qualities with <3 placeable shapes and author additions where idiomatic | content | ready | — | `src/data/kb/pop/piano.js`, `src/data/kb/index.js`, `src/lib/voicings.js` (additive shapes only) | validator green; every new shape hand-verified (L-23 lesson: base-fret convention) |
|
||||
| D-61 | **Circle of fifths** in the Knowledge Center: interactive SVG (12 keys around the circle, live/locked key highlighted, relative minor ring, diatonic chords on tap-free hover/inline, key-signature glyphs), mounted inside ExploreSection; educational copy in the app's voice | design | ready | — | `src/components/CircleOfFifths.jsx` (new), `src/components/ExplorePanel.jsx` (ExploreSection integration only) | renders any key incl. none-detected; follows effectiveKey live; tokens + AA; zero clicks for the core view |
|
||||
| D-62 | **Jam Roulette UX spec** (doc): button up top ("Jam roulette", controls bar per one-screen's layout), genre picker (the 10 KB styles), randomization rules (random key; "interesting" progression selection — weight intermediate level + variety, avoid repeats within a session), how the seed fills the screen (seeds the committed loop + locked key so the banner/strip, voicing rail, licks, related progressions all populate exactly as if detected — reuse, don't duplicate), and the **detection handoff**: musicians start playing and live detection continues from the seed (spec the interplay with the L-31 commit layer: the seed behaves like a committed loop — replacement needs the same consistent evidence; New Song clears it) | design | ready | — | `docs/design/jam-roulette.md` | mechanism spec'd against the real App state (lockedKey, detectedProgression, progressionVoteRef); no state duplication; L-60 scope bounded |
|
||||
| L-60 | Implement Jam Roulette per D-62 (App.jsx UI-state + seeding fn; the roulette button + genre menu; 🚨 audio contract grep-gated — seeding touches display state and the progression refs ONLY, never callbacks) | engineering | backlog | D-62, C-50 | `src/App.jsx` (+ per D-62 — re-lock at promotion) | one click → genre → key+progression fills the jam dashboard; detection continues from it; New Song clears; build + smoke green; contract grep clean |
|
||||
| C-61 | Sprint-end sweep + PR #3 update | quality | backlog | L-60, D-60, P-61, P-62, D-61 | (none — verification) | all green; PR updated |
|
||||
| C-60 | **Piano lick schema** (additive): licks today are guitar-tab-only — design the piano-lick shape reusing the bass-pattern language (per-note `deg` XOR typed `approach`, `beat`, `octave`, technique from a PIANO-appropriate vocab subset — decide which of the 8 words apply to keys and whether grace-note/roll style additions are needed; keep it validateable like C-41 did) in SCHEMA.md + validate-kb.mjs + smoke bite-test | quality | done | — | `src/data/kb/SCHEMA.md`, `scripts/validate-kb.mjs`, `scripts/smoke.mjs` | schema documented; validator enforces; existing KB green; smoke proves it bites |
|
||||
| D-60 | **PianoLickCard** renderer — visual rhyme with LickCard's tab idiom but for keys (timeline + MiniPiano-style pitches or a degree ladder — pick per readability at strip size); playback via chordAudio like BassPatternCard. Must export `PIANO_TECHNIQUE_VOCAB` set-equal to the validator's (the dormant C-60 smoke guard activates on this file's existence). **Component only — LicksStrip wiring deferred (JamGuide.jsx reserved for one-screen L-50); wiring folded into C-61-era integration. Wiring DoD must include (D-60 gate): a shared sequencer-stop so BassPatternCard/PianoLickCard previews can't layer (each holds a private currentSeq today — impossible to trigger until both mount together)** | design | done | C-60 | `src/components/PianoLickCard.jsx` (new) ONLY — done `a7dbd6a` | renders every P-60 lick; playable; graceful empty/malformed |
|
||||
| P-60 | Piano licks pack #1: 3-4 each for **jazz + blues** piano (C-60 schema; arithmetic-verified prose, the P-41 bar). **PASS first try** (`70263e2` — gate realized all 8 in two keys, 35/35 prose claims; dom7-heavy blues coverage accepted as musically right) | content | done | C-60 | `src/data/kb/jazz/piano.js`, `src/data/kb/blues/piano.js` | validator green; every lick playable as written |
|
||||
| P-61 | Piano licks pack #2: 3-4 each for **gospel + rnb** piano. **Returned once (2026-07-11):** one false interval claim in rnb-quartal-slip tips ("each move a step" — actually skip-then-step); 42/43 checks passed. Fixed with the gate's own prescribed wording; Maestro verified the line against the gate's computed −3/−2 motion (scoped re-gate waived — the fix IS the gate's pre-approved example) | content | done | P-60 | `src/data/kb/gospel/piano.js`, `src/data/kb/rnb/piano.js` | validator green |
|
||||
| P-62 | KB voicings expansion: **pop piano cell** (next per kb-backlog) + audit `GUITAR_SHAPES` for qualities with <3 placeable shapes and author additions where idiomatic | content | done | — | `src/data/kb/pop/piano.js`, `src/data/kb/index.js`, `src/lib/voicings.js` (additive shapes only) | validator green; every new shape hand-verified (L-23 lesson: base-fret convention) |
|
||||
| D-61 | **Circle of fifths** in the Knowledge Center: interactive SVG (12 keys around the circle, live/locked key highlighted, relative minor ring, diatonic chords on tap-free hover/inline, key-signature glyphs), mounted inside ExploreSection; educational copy in the app's voice. **Returned once (2026-07-11):** modal hub key signature wrong (A dorian showed ♮ while its chips show F♯ — all 4 dropdown modal modes affected; fix = derive the parent-major signature, teaching "A dorian = G major's notes") + preview flat/sharp spelling split; geometry/rings/tiers/a11y all passed 60/60. Fixed + re-gate PASS (`c999c95` — hub computed from SCALES set-equality, 72/72 uniqueness, one spelling authority) | design | done | — | `src/components/CircleOfFifths.jsx` (new), `src/components/ExplorePanel.jsx` (ExploreSection integration only) | renders any key incl. none-detected; follows effectiveKey live; tokens + AA; zero clicks for the core view |
|
||||
| D-62 | **Jam Roulette UX spec** (doc): button up top ("Jam roulette", controls bar per one-screen's layout), genre picker (the 10 KB styles), randomization rules (random key; "interesting" progression selection — weight intermediate level + variety, avoid repeats within a session), how the seed fills the screen (seeds the committed loop + locked key so the banner/strip, voicing rail, licks, related progressions all populate exactly as if detected — reuse, don't duplicate), and the **detection handoff**: musicians start playing and live detection continues from the seed (spec the interplay with the L-31 commit layer: the seed behaves like a committed loop — replacement needs the same consistent evidence; New Song clears it) **Returned once (2026-07-11):** "matched by construction" false for 10/56 progressions — buildLoopIndex indexes RAW degrees while collapse changes name-sequence length (incl. blues-12bar: LIVE detection of a real 12-bar gets an empty JamGuide — pre-existing bug surfaced!); detector len-cap 8 makes collapsed 9-10 unconfirmable; round-trip empirical not constructional (blues-8bar loses to its own [V7,I7] alternation); funk-one-chord collapses to 2 names not 1; flag-1 timeline off by one (EVERY seed dies unguarded — guard even more necessary). Maestro direction: fix (a) — additively index collapsed forms in match.js (fixes live detection too); pool = empirical round-trip passers ∩ collapsed len 2-8. **Returned twice (2026-07-11):** round 2 verified fix-(a) construction/timeline/proofs sound but found the sweep protocol as written evicts all 8 two-name vamps (needs steady-state + all-offsets), behaviour-additive false in exactly one case (country-145 now self-attributes — an improvement, enumerate it), census 11 not 10, and the BASS plays path (BassGuideRows :530/:533) missed by the remap. Round-3 PASS (`see commit` — literal protocol implementation reproduced 4/56 failures + 52/56 pool + 11-census exactly; sourceIndex mechanism verified at current line numbers) | design | done | — | `docs/design/jam-roulette.md` | mechanism spec'd against the real App state (lockedKey, detectedProgression, progressionVoteRef); no state duplication; L-60 scope bounded |
|
||||
| L-60 | Implement Jam Roulette per D-62 (App.jsx UI-state + seeding fn; the roulette button + genre menu; 🚨 audio contract grep-gated — seeding touches display state and the progression refs ONLY, never callbacks). Ride-alongs: pass `dense` to the heard-live VoicingBrowser in the rail (D-51 gate flag — same fragile pair fit in the fallback path); one-screen.md §7 "two-octave cells alone" sentence is stale by a few px (D-51 gate nit) | engineering | done | D-62, C-50 | `src/App.jsx` (+ per D-62 — re-lock at promotion) | one click → genre → key+progression fills the jam dashboard; detection continues from it; New Song clears; build + smoke green; contract grep clean |
|
||||
| C-61 | Sprint-end sweep + PR #3 update (COMBINED — covers BOTH sprint-one-screen and sprint-roulette-kb in one comment) | quality | done | L-60, D-60, P-61, P-62, D-61 | (none — verification) | all green; PR updated — **BOTH SPRINTS COMPLETE** |
|
||||
|
||||
> Sequencing: C-60 ‖ P-62 ‖ D-61 ‖ D-62 are file-disjoint and ready now (none touch one-screen's locks). Then C-60 → {D-60, P-60 → P-61}, and D-62 + C-50(one-screen) → L-60. C-61 closes. Critic gates every task.
|
||||
|
||||
@@ -46,16 +46,52 @@ Standing principles (memory): scroll > click; nothing duplicated; one global ins
|
||||
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
||||
|----|-------|--------|--------|-----------|--------------|--------------------|
|
||||
| M-06 | Seed `sprint-one-screen` | maestro | done | — | `docs/agents/LEDGER.md` | seeded |
|
||||
| D-50 | One-screen dashboard concept doc: full-viewport grid at 1280×900 — LEFT: slim loop strip (banner without the big now-playing chord; playhead chip is the "now"), compact instrument view (decide the mechanism: height cap / reduced fret range / scale — honest about legibility), licks strip, related-progressions card; RIGHT column: the suggested-voicings rail (GlanceRail adapted to ~420-520px — decide cell strategy: recommended voicing prominent + gallery wrapped, sizes recomputed honestly per the D-40 corrected math); where LoopStation/collapsibles/KnowledgeDock land; what "related progressions" means computationally (KB-sourced: same style + level + degree-shape overlap with the loop — spec the ranking); narrow-viewport plan (columns stack); migration order with bounded L-50/D-51/L-51 scopes; ≥2 rejected alternatives. No user gate: pick strongest, record rationale | design | claimed | — | `docs/design/one-screen.md` | every component named incl. what's removed (big chord display, ProgressionSuggestions); honest space math (everything visible at 1280×900 with at most the licks/progressions row below a fold-flick); bounded scopes |
|
||||
| L-50 | App restructure per D-50: grid columns, banner slimmed (big now-playing chord removed — ProgressionBanner edit), JamGuide rail moves to the right column, ProgressionSuggestions unmounted (file kept), instrument view compacted per doc, licks strip placed per doc. 🚨 App.jsx = layout/mounts/UI-state only, audio contract grep-gated | engineering | backlog | D-50 | `src/App.jsx`, `src/components/ProgressionBanner.jsx`, `src/components/JamGuide.jsx` (+ per doc — re-lock at promotion) | one-screen layout live; no duplicated info; build + smoke green; contract grep clean |
|
||||
| D-51 | Rail right-column adaptation per D-50: GlanceRail/VoicingBrowser sizing for the narrow column (recommended-first cell strategy per doc), playhead highlight preserved, wrap never horizontal-scroll | design | backlog | D-50, L-50 | `src/components/GlanceRail.jsx`, `src/components/VoicingBrowser.jsx` (+ per doc) | rail readable at column width for guitar/piano/bass; zero clicks; build + smoke green |
|
||||
| L-51 | RelatedProgressions component per D-50: KB-sourced, loop-relative (style + level + degree-shape overlap ranking per the doc's spec), replaces the old generic suggestions; mounts in the D-50 slot | engineering | backlog | D-50, L-50 | `src/components/RelatedProgressions.jsx` (new) + mount slot per doc | shows genuinely related KB progressions for a live loop; honest empty state; build + smoke green |
|
||||
| C-50 | Sprint-end sweep + PR #3 update per PROTOCOL §6 | quality | backlog | L-50, D-51, L-51 | (none — verification) | all green; PR updated |
|
||||
| D-50 | One-screen dashboard concept doc: full-viewport grid at 1280×900 — LEFT: slim loop strip (banner without the big now-playing chord; playhead chip is the "now"), compact instrument view (decide the mechanism: height cap / reduced fret range / scale — honest about legibility), licks strip, related-progressions card; RIGHT column: the suggested-voicings rail (GlanceRail adapted to ~420-520px — decide cell strategy: recommended voicing prominent + gallery wrapped, sizes recomputed honestly per the D-40 corrected math); where LoopStation/collapsibles/KnowledgeDock land; what "related progressions" means computationally (KB-sourced: same style + level + degree-shape overlap with the loop — spec the ranking); narrow-viewport plan (columns stack); migration order with bounded L-50/D-51/L-51 scopes; ≥2 rejected alternatives. No user gate: pick strongest, record rationale. **Returned once (2026-07-11):** strip really ~92-96px (fix: spec p-4→p-2 trim); `sticky` rail geometrically inert in the flex row (zero travel — flick claims deleted, rationale re-grounded); §4 double-counted cell padding (piano pair actually fits TODAY by ~4px; D-51 premise → margin-hardening); +100 ranking term inherits the raw-vs-collapsed mismatch (collapse pairs before canonicalDegrees/T(p), coordinate with D-62 fix (a)). Layout/jam-view/scopes all held. Fixed + re-gate PASS (`d24bf35` — all budgets recomputed clean) | design | done | — | `docs/design/one-screen.md` | every component named incl. what's removed (big chord display, ProgressionSuggestions); honest space math (everything visible at 1280×900 with at most the licks/progressions row below a fold-flick); bounded scopes |
|
||||
| L-50 | App restructure per D-50: grid columns, banner slimmed (big now-playing chord removed — ProgressionBanner edit), JamGuide rail moves to the right column, ProgressionSuggestions unmounted (file kept), instrument view compacted per doc, licks strip placed per doc. 🚨 App.jsx = layout/mounts/UI-state only, audio contract grep-gated. **PASS** (`800fd43`+`e58a7a6` — SSR 73/73; gate applied a sanctioned one-class legend restoration for non-compact byte-identity). Nits filed: dead currentChord prop on the banner mount (ride-along for L-51); strip chips are div-onClick (pre-existing a11y debt → backlog) | engineering | done | D-50 | `src/App.jsx`, `src/components/ProgressionBanner.jsx`, `src/components/JamGuide.jsx` (+ per doc — re-lock at promotion) | one-screen layout live; no duplicated info; jam view works both layers; build + smoke green; contract grep clean |
|
||||
| D-51 | Rail right-column adaptation per D-50: GlanceRail/VoicingBrowser sizing for the narrow column (recommended-first cell strategy per doc), playhead highlight preserved, wrap never horizontal-scroll | design | done | D-50, L-50 | `src/components/GlanceRail.jsx`, `src/components/VoicingBrowser.jsx` (+ per doc) | rail readable at column width for guitar/piano/bass; zero clicks; build + smoke green — **PASS** (`see commit` — arithmetic verified to the built CSS; heard-live dense-pass flag folded into L-60's ride-alongs) |
|
||||
| L-51 | RelatedProgressions component per D-50: KB-sourced, loop-relative (style + level + degree-shape overlap ranking per the doc's spec), replaces the old generic suggestions; mounts in the D-50 slot. **PASS** (`see commit` — scores recomputed independently, collapse counterfactual proven). Taste flag for the user: "same changes" links quality-mismatched same-root-motion relatives (ii-V-I ≡ I-IV-V) — spec-conformant; a "same motion" wording tweak is a one-word option | engineering | done | D-50, L-50 | `src/components/RelatedProgressions.jsx` (new) + mount slot per doc | shows genuinely related KB progressions for a live loop; honest empty state; build + smoke green |
|
||||
| C-50 | Sprint-end sweep (PR comment consolidated into C-61's combined summary — Maestro call). Folded (L-51 gate): pin the two verified ranking scores + the no-collapse counterfactual as smoke checks; add a global-id-uniqueness assertion to validate-kb.mjs | quality | done | L-50, D-51, L-51 | `scripts/smoke.mjs`, `scripts/validate-kb.mjs` (additions only) | all green; ranking pinned; ids asserted unique — **PASS** (`see commit` — smoke 886, SSR 60/60, whole-sprint contract clean). **SPRINT sprint-one-screen COMPLETE** |
|
||||
|
||||
> Sequencing: D-50 first (everything hangs off it) → L-50 → D-51 ‖ L-51 (re-lock at promotion to enforce disjointness) → C-50. Critic gates every task.
|
||||
|
||||
---
|
||||
|
||||
## 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 Am–C–F case (both A-minor and C-major readings), arithmetically correct (the gate WILL recompute). The UI surface: where it mounts in the jam dashboard (per-station in the rail? a "Try this" line under the loop? — decide, keep it glanceable, tappable→ChordDetailModal), honest circle-of-fifths tie-in (a lens for the relative/neighbour subs, NOT claimed for borrowed/extension), edge states (no key / atonal), keep-it-simple (learnable, not a reharm engine). Use a SEPARATE component (disjoint from RelatedProgressions.jsx). No user gate: pick strongest, ≥2 rejected alternatives | design | done (returned once — 3 copy/plumbing fixes verified: chordRootPC→noteIndex circular-import, ♭6 flat spelling, mediant claim; rules all recomputed correct; `c2207ef`) | — | `docs/design/try-this-subs.md` | substitution categories + rules + why-copy spec'd and arithmetically honest; Am–C–F worked; UI surface + mount decided; L-73/L-74 bounded |
|
||||
| L-73 | Substitution engine: `suggestSubstitutions({rootPc,quality}, keyInfo, opts?)` in `src/lib/theory.js` (additive) → `[{rootPc, quality, label, why, category}]` per D-73's rules + a smoke truth-table (expected subs for known chords/keys, sabotage-proven like the resolveDegree guard) | engineering | backlog | D-73 | `src/lib/theory.js` (additive), `scripts/smoke.mjs` | engine returns musically-correct subs for all 14 qualities in-key; smoke truth-table bites; build green | ← claimed 2026-07-13; **returned once** — Rule C self-suggested the sounding chord on 7th-chord inputs (Dm7→Dm7) + latent add9-on-minor mis-spelling; fixed + re-gate PASS (Dm7→[F], min7 sweep no add9, triad path intact, sabotage bites, 903/903); **done** `see commit`
|
||||
| L-74 | "Try this" UI per D-73, with VARY/ROTATE (user decided 2026-07-13: "more surprising, more jam-like, keeps offering new ideas"): a new `TryThis.jsx` that, for the current playhead chord, shows ONE suggestion at a time (matches "try this" = a single nudge) and CYCLES to the next valid substitution each time the loop completes a pass (playhead wraps to station 0 — watch the position prop). Sequential cycling through the engine's ordered valid set (softest-first on first appearance) so the player eventually learns all options and never repeats until exhausted; a small "1 of N" / dot indicator; 0 subs → no card; 1 sub → static (no rotation). Tappable→ChordDetailModal; tokens/AA; glanceable. Supersedes D-73's static ≤4-chip display for the UI (engine/rules unchanged). Mount App.jsx-only in relatedSlot above RelatedProgressions (disjoint) | engineering | backlog | D-73, L-73 | `src/components/TryThis.jsx` (new), `src/App.jsx` (mount) | one fresh honest sub per chord, cycling each loop pass; zero-click; empties honest; build + smoke green | **done** `038fdfa` (Critic PASS — 2-pass rotation trace Dm→Fm verified, contract-clean)
|
||||
| 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,14 @@ 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.
|
||||
- **Pop piano prose uses absolute note names (Professor, tiny — P-62 gate flag):** ~8 spots (pop/piano.js:88,117,130-133,159,186) say e.g. "the G holds; top C→B" — correct only in the declared reference key; degree-ify before any piano-prose render surface lands (sibling of the bass-tips-surface backlog item).
|
||||
- **Two wrong GUITAR_SHAPES in live data (Luthier, small — P-62 audit finding 2026-07-11):** (1) `half_dim "E Barre"` offsets `[0,1,2,0,0,'x']` sounds {0,3,6,7} — natural 5th clashing the ♭5, NO ♭7; correct E-form ø7 = `[0,1,0,0,'x','x']`. (2) `add9 "Open Dadd9"` (xx0230 = D-A-D-E) has no 3rd — it's Dsus2, duplicating the sus2 open D. Both pre-existing; fix after P-62 commits (additive lock kept P-62 from editing them).
|
||||
- **Matcher blues/jazz disambiguation (Luthier, small):** `['C7','A7','Dm7','G7']` matches `jazz-rhythm-a` (identical I–VI–ii–V degree shape, quality tie → jazz), so `blues-turnaround`'s bass plays are live-unreachable — pre-existing tie-break in match.js; consider quality-profile weighting (all-dom7 → blues). Evidence-verified by the L-42 gate 2026-07-10.
|
||||
- **Bass play `tips` have no render surface (Muse/Luthier, small):** the authored teaching prose in bass packs renders nowhere (card shows label/feel/position/note only; the dock has no bass section) — real content-surface gap, L-42 gate 2026-07-10.
|
||||
- **TransitionChip "2 step down" wording (Muse, one line):** whole-step rails render as "2 step down/up" (52 of 268 real KB transitions) — verbatim port of RoadmapTrack.jsx:59; "whole step down" is the musician's phrase (D-41 gate note 2026-07-10).
|
||||
@@ -263,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)
|
||||
@@ -279,6 +326,10 @@ _(Maestro appends one line per completed iteration: `<date> · <task ids done>
|
||||
- 2026-07-09 · **iterations 6–7/24** · done: L-25 (`e24aa4b` instrument-scoped modal tabs), C-22 (`b4e8f12` validator hand-span rule, smoke 799), P-23 (`564a9e0` gospel piano cell, 140 plays — returned once: one improv-row scale claim false for vi7/iii7, split into exact rows, PASS re-gate; 39/40 first-pass claims held), L-24 (`2165bb5` authored piano packs render at stations — implementer agent hung at verification after ~1 h, killed, completion-pass agent audited + finished; Critic PASS with 0 resolver drift) · incidents: P-23 stalled once mid-authoring (resumed from transcript, no loss); L-24 hung silently (diff intact, completed by fresh agent) · backlog: resolveDegree drift guard filed · **board empty except C-21 → sprint-end sweep + PR**
|
||||
- 2026-07-08/09 · **iteration 5/24** · done: D-23 (`db397f6` VoicingBrowser show prop + LickCard stack-guard), D-24 (`e2110ae` MiniPiano thumbs crop to voicing span, 266→142 px), P-24 (11 licks rock/country/reggae — KB now **23 licks across 6 styles**, every note pitch-verified) — Critic PASS all three, zero returns · D-24 gate raised a false alarm on the D-21 rootPc fix (its harness bypassed VoicingBrowser; Maestro verified line 297 intact — no regression); hardening idea backlogged (`pianoVoicing` return rootPc) · promoted L-25 · next ready (all file-disjoint): L-24 ‖ L-25 ‖ C-22 ‖ P-23, then C-21 closes the sprint
|
||||
- 2026-07-10 · **sprint-integrated-glance iteration 4/24 — SPRINT COMPLETE** · done: L-42 (`af7c3cc` **BASS real end-to-end** — BassPatternCard tab cards with playback; gate re-realized all 912 placements exact, byte-identity on untouched paths, import cycle proven safe), C-42 (`218c0ba` smoke §7 sweeps BOTH resolveDegree copies, 866/866 — the sweep agent authored the wiring then died on the ACCOUNT MONTHLY SPEND LIMIT; Maestro completed the sweep inline: build/validator/smoke green at HEAD, sabotage-proved the new guard [b13 perturbation → red naming the cell → restored green], whole-sprint App.jsx contract grep = 0 hits; per-instrument SSR relied on the L-42 gate's run minutes earlier — only smoke.mjs changed since) · backlogged: matcher blues/jazz tie-break (turnaround bass plays live-unreachable), bass tips surface · all 11 sprint tasks done, 2 returned-then-fixed (D-40, and D-41's predecessor D-31 pattern held: zero returns this sprint besides D-40), 0 unresolved · PR #3 updated; cron deleted (4 of 24 iterations) · ⚠️ NOTE: the Anthropic monthly spend limit is HIT — no further subagent work possible until raised
|
||||
- 2026-07-11 · **sprint-roulette-kb iterations 1-2/12** (notification-driven; hourly cron as fallback) · done: M-07, C-60 (`5f4e2b7` piano lick schema — enclosures native, 25-cap proven tight, dormant renderer guard), P-62 (`ffc1bb1` pop piano cell 182 plays + 8 shapes; found 2 WRONG pre-existing shapes → backlog), D-61 (`c999c95` circle of fifths — returned once on modal hub signature, now teaches parent-major relation), P-60 (`70263e2` first piano licks, jazz+blues — PASS first try 35/35), D-60 (`a7dbd6a` PianoLickCard piano-roll renderer — PASS, 41/41 realization audit), D-62 (`e87b2bb` jam-roulette spec — returned TWICE, round-3 literal-implementation test passed; surfaced the pre-existing live bug: 10 progressions incl. blues-12bar never match detection; fix (a) specced into L-60), P-61 (`2c18c71` gospel+rnb licks, 39 total — returned once on one interval claim, gate-prescribed fix verified) · KB today: 172→182 plays, 23→39 licks, 6th piano style, circle of fifths · remaining: L-60 (blocked on one-screen C-50) → C-61
|
||||
- 2026-07-11 · **BOTH SPRINTS COMPLETE** · sprint-roulette-kb: L-60 (`8d74e54`+`1df6fe8` **Jam Roulette LIVE** + the matcher collapse fix repairing the pre-existing live 12-bar/8-bar empty-JamGuide bug — Critic PASS on Opus: pool sweep reproduced from spec 4/52, fix-(a) additivity proven registry-wide, seed timeline hand-traced), C-61 (combined sweep — build/validator 182·39/smoke 891 all green, full-App SSR ×6 combos, whole-branch audio contract clean; PASS) · gate stats across both sprints: 9 first-try passes, 5 returns across 4 tasks (D-50, P-61, D-61, D-62×2) all re-gated to PASS · cron deleted · PR #3 updated with the combined summary · backlog: PianoLickCard LicksStrip wiring (needs shared sequencer-stop), 2 wrong GUITAR_SHAPES, strip-chip a11y, matcher blues/jazz tie-break, bass tips surface, 6 pre-existing orphan components
|
||||
- 2026-07-11 · **sprint-one-screen iterations 2-3 + roulette-kb progress** · one-screen COMPLETE: L-50 (`800fd43`+`e58a7a6` dashboard grid, slim strip, jam view), D-51 (`d5a45f4` rail column adaptation — verified to the built CSS), L-51 (`6e8d8f4` RelatedProgressions — collapse step proven load-bearing), C-50 (`4deba7e` sweep, smoke 886, ranking pinned + registry id-uniqueness) — all Critic PASS · roulette-kb: P-61 (`2c18c71` gospel+rnb licks, 39 total) · **incident:** the L-60 (Jam Roulette) dispatch hit the account MONTHLY SPEND LIMIT on Fable 5 mid-read (no files written, clean tree); user switched the session to Opus 4.8 and L-60 re-dispatched fresh on Opus — proceeding · remaining: L-60 → C-61 closes both sprints
|
||||
- 2026-07-11 · **sprint-one-screen iteration 1** (notification-driven) · done: M-06, D-50 (`d24bf35` one-screen dashboard + jam-view concept — returned once: strip height, geometrically-inert sticky, double-counted padding, collapse-blind ranking; all fixed, re-gate PASS with recomputed budgets) · L-50 (the build) in flight · then D-51 ‖ L-51 → C-50 → unlocks L-60
|
||||
- 2026-07-10 · **sprint-integrated-glance iteration 3/24** · done: D-41 (`677d7b9` **all-expanded rail LIVE** — every station a permanently expanded row with education header [solo-scale, aim dots, voice-leading chips incl. wrap-around] + full gallery; playhead highlight-only proven byte-equal modulo highlight artifacts; focus toggle keeps the onFocusChord contract; show='bass' fall-through fixed; dense byte-identity proven across 336 SSR combos; black-on-accent 5.31:1 beats the old sub-AA white) — gate PASS, 19/19 SSR incl. independent theory recompute of a row's education · backlogged: TransitionChip "2 step down"→"whole step" wording (52 of 268 transitions) · L-42 (bass pattern renderer) dispatched — then C-42 closes
|
||||
- 2026-07-10 · **sprint-integrated-glance iteration 2/24** · done: P-41 (`01a7c32` **the KB's first bass cell** — blues, 8 plays/172 total; gate machine-realized all 76 patterns in C AND A, 12 identities hold key-agnostically; PASS first try), L-40 (`c899132` **the restructure is live** — band below the main module, banner = the single loop display, RoadmapTrack/CurrentJamPanel unmounted, App's one GUITAR/PIANO/BASS selector drives band + dock, honest bass rows, GlanceRail yank-effect deleted; gate SSR 22/22, audio contract grep-clean, deletion sweep clean) · folded: smoke §7 resolveDegree wiring → C-42; VoicingBrowser show='bass' fall-through fix → D-41 (lock extended) · D-41 (all-rows-expanded rail) dispatched — then L-42 → C-42 closes
|
||||
- 2026-07-10 · **sprint-integrated-glance iteration 1/24** · done: M-05, P-40 (`6f59add` kb-backlog refreshed — gate audited all 14 cells + 10 dates), C-40 (`090d3a9` drift guards, smoke 836 — gate recomputed all 224 truth-table cells), D-40 (`7d1f64c` integrated-glance concept — returned once: Dm7-specific piano math + 3 factual fixes incl. the interim page-yank, fixed, PASS re-gate with pixel-exact recompute), C-41 (`996218f` bass play schema, smoke 849 — degree-based, typed approaches, PASS with 19-semitone bound proven exactly maximal) · in flight: L-40 (App restructure — band up top, single loop display, one selector) ‖ P-41 (blues bass, first bass cell) · then D-41 → L-42 → C-42 closes
|
||||
|
||||
@@ -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.
|
||||
@@ -506,8 +506,10 @@ flow below `xl` (JamGuide reorders its own children with responsive classes or
|
||||
conditional order — no duplicate mounts). The rail **unbounds** below `xl`
|
||||
(the max-h/overflow classes are `xl:`-prefixed) and lays out at natural height
|
||||
in page flow — a nested scroller inside a scrolling page is a trap on touch.
|
||||
Cell wrap at ~576px interior: guitar 5/line, piano two-octave cells alone —
|
||||
same behaviour the D-41 gate already verified for the band. The instrument
|
||||
Cell wrap at ~576px interior: guitar 5/line, piano cells **pair** (a 284px
|
||||
two-octave + a 160px one-octave = 452, or two two-octaves at the 576 boundary) —
|
||||
the same margin-hardened pairing D-51 verified for the bounded column (§4),
|
||||
with more room here, not the "cells ride alone" of the narrower bounded width. The instrument
|
||||
selector never moves: it lives in the controls bar, global, above everything
|
||||
at every width.
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Related-area space layout — Try-this side-by-side + Related 2×2 (task D-75)
|
||||
|
||||
**Sprint:** `sprint-dashboard-polish` · **Owner:** Muse · **Impl tasks:** L-75 (TryThis.jsx + App mount) · L-76 (RelatedProgressions.jsx). Concept doc only — no code here.
|
||||
|
||||
**User ask (2026-07-13, verbatim, after running the working dashboard — happy with it):**
|
||||
> "for the TRY THIS can we also add the little piano/fretboard next to it? now it also has a 1/2 option, but please put them next to each other, we have enough space in that area. also for the suggested progressions underneath there is space to have it cut in half to make it 2x2 in a similar fashion so we use the space and we fit everything in one big screen."
|
||||
|
||||
The user is pointing at the **LEFT column** of the jam dashboard (`flex-1` ≈ **744px** per `one-screen.md` §1 — far wider than the 500px rail) and asking us to **use its horizontal width**: (A) Try-this stops rotating one-at-a-time and shows **all** the current chord's substitutions **side by side**, each with a **mini instrument diagram** of that chord; (B) RelatedProgressions goes from a vertical list to a **2×2 grid**. Both to compress the vertical footprint so more fits on one screen.
|
||||
|
||||
---
|
||||
|
||||
## 0. The honest reversal (say it plainly)
|
||||
|
||||
L-74 shipped a **rotating one-at-a-time** Try-this card (`pickSub` / `advanceOnWrap` / `cycle`), because the same-day directive was *"more surprising, more jam-like, keeps offering new ideas."* The user has now **seen** that rotation and prefers **all-visible side-by-side** ("put them next to each other, we have enough space"). This doc **reverses** the rotation decision. That is not churn for its own sake — it is the user refining after seeing it live, exactly the loop the ensemble is built for. The **engine (`suggestSubstitutions`, L-73) and its rules do not change**; only the UI shape does.
|
||||
|
||||
What survives from L-74's `TryThis.jsx`:
|
||||
- `parseChordName(name)` — pure, keep.
|
||||
- `subsForChord(name, pos, loopArr, keyInfo)` — pure, keep.
|
||||
- `pickSubject(loopArr, keyInfo, currentChord)` — pure, keep. This is what keeps the card **live**: it picks (a) the live `currentChord` if it parses and yields ≥1 sub, else (b) the first loop station that yields subs, else (c) `null` (honest empty). The card still follows the playhead — as you play, the subject chord and its ≤4 subs update.
|
||||
|
||||
What is **deleted**:
|
||||
- `useState(cycle)` + the rotation `useEffect`, `advanceOnWrap`, `pickSub`, the `lastPosRef` / `lastNameRef`, the "N of M" indicator, the dot rail. Smoke coupling: **none** — `scripts/smoke.mjs` has zero references to `advanceOnWrap` / `pickSub` / `parseChordName` (grep-confirmed; its "rotation" hits are all progression-match tests). So L-75 can drop the helpers without touching smoke; its lock stays `TryThis.jsx` + `App.jsx`.
|
||||
|
||||
Honest empties preserved: no key / no loop / atonal → `pickSubject` returns null → render nothing (unchanged). A chord with fewer than 4 valid subs simply shows fewer cards (the engine already `.slice(0,4)`; often 1–3 fire).
|
||||
|
||||
---
|
||||
|
||||
## 1. Try-this — side by side, one card per sub, each with an instrument diagram
|
||||
|
||||
### 1.1 The layout
|
||||
|
||||
Keep the outer section shell (`rounded-2xl border border-border bg-panel p-3`, the micro-header "Try this instead of {chord} · in {key} {mode}"). Below it, replace the single-card body with a **flex-wrap row of sub-cards**, one per `subject.subs` entry (≤4):
|
||||
|
||||
```
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subject.subs.map(sub => <SubCard … />)}
|
||||
</div>
|
||||
```
|
||||
|
||||
- **Container adapts to the instrument's cell width** (§3 proves the footprints — the across-count is NOT a fixed 4):
|
||||
- **guitar / bass** — `flex flex-wrap gap-2`, each card `basis-[168px] grow min-w-[152px]`: the small cells (guitar 75px, bass none) let **4 fit one row** in the 720px interior; with 1–3 subs the cards `grow` to fill.
|
||||
- **piano** — a **`grid grid-cols-2 gap-2`** (2×2): the mini keyboard's real footprint is up to **199px** (§3), so 4 piano cards cannot share one 720px row; a clean 2×2 mirrors the rail's own piano idiom (`VoicingBrowser` dense = `grid grid-cols-2`) and avoids the unbalanced 3+1 that a plain `flex-wrap` would produce.
|
||||
- On a narrow/stacked viewport both collapse to **1-per-row** (`grid-cols-1` / `flex-wrap`) — the DoD reflow, for free.
|
||||
|
||||
Each **SubCard** is a vertical stack (`flex flex-col items-center gap-1.5 rounded-lg border border-border bg-border/30 p-2`):
|
||||
|
||||
1. **Chord chip** (top) — the tappable control, reusing L-74's chip idiom exactly:
|
||||
`button` → `onChordClick?.(sub.label)`, classes `rounded-lg border border-border bg-border px-2 py-1 text-sm font-bold text-gray-100 hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent`. `aria-label={`${sub.label} — ${sub.why}`}`. Tap → `ChordDetailModal` (App's `setSelectedChord`), where the **full** why + all voicings live.
|
||||
2. **Mini instrument diagram** (middle) — of `{sub.rootPc, sub.quality}`, following the **global instrument** (§2). This is the "little piano/fretboard next to it" the user asked for.
|
||||
3. **Category tag** — the L-74 language: `relative` / `borrowed` / `colour` / `V7`, with the `↻` accent glyph **only** for `relative` + `secondary_dominant` (circle categories — unchanged rule, `text-accent`).
|
||||
4. **Why** (bottom) — `sub.why`, `text-[11px] leading-snug text-gray-400`, **`line-clamp-3`** with the full text on the chip's `aria-label` + tap→modal. At ~152–168px card width, clamp-3 keeps whys up to ~75 chars whole; longer ones truncate visibly and the full sentence is one tap away. This is the honest trade of side-by-side: 4 whys visible at a glance costs each one its full width — the user chose see-all over the single full-width why.
|
||||
|
||||
### 1.2 Across-count is a function of the diagram footprint, not a fixed "4"
|
||||
|
||||
The user said *"put them next to each other … we have enough space"* and — resolving the piano case directly — *"for the piano's u can take more space in case there is indeed a bigger piano needed"* (2026-07-13). So: spend the **horizontal** budget as far as each instrument's diagram allows, and let piano take the **vertical** room it needs rather than cramming it. Guitar/bass cells are small (75px / none) → 4 subs sit **one row across**. Piano cells are large (up to 199px, §3) → 4 subs form a **2×2** — still "next to each other," still using the width, just a taller block (which the user explicitly OK'd). The count follows the footprint; the layout adapts (§1.1) instead of forcing a uniform grid that would clip the piano or waste the guitar row. **No MiniPiano change** — the keyboard renders at its honest size.
|
||||
|
||||
---
|
||||
|
||||
## 2. The instrument prop — threading + which resolver draws each diagram
|
||||
|
||||
`TryThis` needs the global `instrument` (`'guitar' | 'piano' | 'bass'`), which lives in `App.jsx` and is already handed to `JamGuide` (line 849). **Prop path:** add `instrument={instrument}` to the `<TryThis … />` mount in the `relatedSlot` (App.jsx ~line 859) and add `instrument` to the component signature: `TryThis({ loop, keyInfo, currentChord, onChordClick, instrument })`. One-line App change, grep-clean (no audio/callback contract touched) — this is the App half of L-75.
|
||||
|
||||
Per-instrument diagram resolver (mirror the established rail idiom in `VoicingBrowser.jsx:288`/`:327`):
|
||||
|
||||
| instrument | diagram | resolver → render |
|
||||
|---|---|---|
|
||||
| `guitar` | `ChordDiagram size="thumb"` (~75px) | `const shape = getGuitarVoicings(sub.label)[0]` → `<ChordDiagram shape={shape} rootPc={sub.rootPc} size="thumb" />`. `getGuitarVoicings` (voicings.js) resolves the first (barre/open) shape to **absolute** low-E-first frets; ChordDiagram's open-shape path windows them (draws a `5fr` label when up-neck). **Omit** ChordDiagram's own `label` prop — the chip above already names the chord (saves ~13px). If `getGuitarVoicings(sub.label)` is empty (rare), render **no** diagram — just chip + why (honest, no crash). |
|
||||
| `piano` | `MiniPiano size="mini"` (107–199px, §3) | `<MiniPiano voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }} size="mini" />`. **Spread `rootPc` back in** — `pianoVoicing()` output carries none, and `VoicingPiano` needs it to badge "R" correctly (the VoicingBrowser:317-319 caveat). `size="mini"` = the dashboard-density scale already used in the rail's 2×2. |
|
||||
| `bass` | **no diagram — honest** | Bass players read a chord symbol + root, not a chord grip; there is no compact bass-chord renderer, and reusing the guitar/piano diagram under a BASS selector would misrepresent the instrument (same honesty call the licks strip makes for bass). SubCard shows the chip + a small caption `root · {NOTES[sub.rootPc]}` (`text-[10px] text-gray-500`) + the why. Compact, correct, no faked shape. |
|
||||
|
||||
All diagrams use the established note-colour tiers already baked into `ChordDiagram`/`MiniPiano` (accent-purple root, light-purple/gray other tones, amber bass ring) — no new colour, no token change.
|
||||
|
||||
---
|
||||
|
||||
## 3. Space math — the LEFT column (~744px), honest
|
||||
|
||||
- Left column `flex-1 min-w-0` ≈ **744px** (one-screen.md §1). The `relatedSlot` is `flex flex-col gap-3` **directly** in it, so the Try-this `section` spans the full 744px. Section `p-3` (12px each side) → **interior ≈ 720px**.
|
||||
- Per-card diagram footprints (recomputed from source geometry):
|
||||
- **Guitar** `ChordDiagram size="thumb"`: `padL 14 + gridW 55 + padR 6 = 75px` wide; `padT 11 + gridH 55 + padB 6 = 72px` tall (label omitted). Root-independent.
|
||||
- **Piano** `MiniPiano size="mini"` — the window is **C-octave-boundary anchored, NOT span-anchored** (MiniPiano.jsx:200-207): `octStart = ⌊minNote/12⌋`, `OCTAVES = max(1, ⌈(maxNote − octStart·12)/12⌉)`, `SVG_W = (22·(7·OCTAVES + 1) + 2)·0.60`. A voicing whose *interval span* is under an octave STILL renders **2 octaves** whenever its notes straddle a C boundary — so "span ≤ 1 octave" does **not** imply "renders 1 octave." Worked: F♯ major triad `pianoVoicing({rootPc:6})` → notes `[6,10,13]` (span 7) → `octStart 0`, `maxNote 13` → `OCTAVES = ⌈13/12⌉ = 2` → `SVG_W = (22·15 + 2)·0.60 = 332·0.60 = **199.2px**`. This fires for **every triad quality at roots pc ≥ 6 (F♯–B)** (maxNote = root + top-interval > 12) and for shells at most roots (the +12 lift pushes the 7th over the next C) — i.e. roughly **half** of all sub roots, and sub roots routinely land upper-half (any G♯/A♯/B relative / borrowed / secondary-dominant candidate). One octave (107px) is the *best* case (triad roots pc 0–5); **199.2px is the planning footprint.** MiniPiano.jsx:29-31's own header documents this "2-octave mini cell (213.2px)". `SVG_H = (60 + 4)·0.60 = **38.4px** — octave-INDEPENDENT`: the window widens, height never does.
|
||||
- **Across-count per instrument** (interior 720px, `gap-2` = 8px; an SVG's hard `width` attr floors the flex item's min-content at ≈ its px, so `basis-[168px]` cannot shrink a piano cell below its keyboard):
|
||||
- **Guitar** — cell 75px, card ~152px min (diagram + `p-2` + chip width) → `4·152 + 3·8 = 632 ≤ 720` → **4-across, one row.**
|
||||
- **Bass** — no diagram, card ~152px → **4-across, one row.**
|
||||
- **Piano** — worst-case cell 199.2px, card ~215px (+ `p-2` 16) → `3·215 + 2·8 = 661 ≤ 720` but `4·215 + 3·8 = 884 > 720`. So **at most 3 share a row**, and 4 can't. Laid out as a **2×2 grid** (§1.1, per user's "take more space"): 2 cells/row, `2·215 + 8 = 438 ≤ 720` (roomy, each keyboard gets its full width) → **2 across × up to 2 rows.**
|
||||
- **Row / block heights** (`SVG_H` is octave-independent, so piano height is driven purely by sub-count, not cell width):
|
||||
- **Guitar** card: chip ~22 + diagram 72 + why (clamp-3 @11px/1.35 ≈ 45) + tag ~12 + `p-2` 16 + inner gaps ~10 ≈ **~177px** → block (1 row + header 20 + `p-3` 24) ≈ **~221px.**
|
||||
- **Piano** card: chip ~22 + diagram 38 + why ~45 + tag ~12 + `p-2` 16 + gaps ~12 ≈ **~145px**. 4 subs → 2×2 = `2·145 + 8 ≈ 298` → block (+ 44 chrome) ≈ **~342px**. ≤3 subs → one row → block ≈ **~189px.**
|
||||
- **Bass** card ≈ chip 22 + root caption 14 + why 45 + tag 12 + `p-2` 16 + gaps 12 ≈ **~121px** → block ≈ **~165px.**
|
||||
- **Headline:** guitar & bass show all ≤4 subs **4-across in one row** (~165–221px block). Piano shows them as a **2×2** taking the full room each 199px keyboard needs — **~189px** block for ≤3 subs, **~342px when all 4 fire** (two rows). Only piano's across-count/row-count vary; height is width-independent (38.4px SVG). This taller piano block is the honest, user-sanctioned cost of "the little piano next to it."
|
||||
|
||||
---
|
||||
|
||||
## 4. RelatedProgressions → 2×2 grid (L-76, layout only)
|
||||
|
||||
### 4.1 The change
|
||||
|
||||
Today the populated list is `<ul className="flex flex-col gap-2.5">` of up to `RELATED_MAX_ENTRIES` (5) entries, each = name + meta row (style/level/annotation-or-role) + `ChordChain` chips. **Swap the container** to a 2-column grid:
|
||||
|
||||
```
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
```
|
||||
|
||||
Each `<li>` (its inner markup — name row, `LevelBadge`, role/annotation, `ChordChain`) is **untouched**. `grid-cols-1` on narrow (the stacked reflow), `sm:grid-cols-2` at width. No ranking, scoring, `collapseChanges`, `siblingRole`, or annotation logic changes — L-76 is **presentation only**.
|
||||
|
||||
### 4.2 The cap: 5 → display **4** (no ranker change)
|
||||
|
||||
A clean 2×2 wants **4** cells; 5 leaves an orphan in a lonely third row that reads as broken. **Decision:** the ranker keeps `RELATED_MAX_ENTRIES = 5` (untouched — no smoke re-pin, no exported-constant edit); the **component renders `primary.slice(0, 4)`** into the grid. Entries are score-sorted, so this drops only the **lowest-scoring** 5th sibling — honest ("top 4"), and a future "show all" affordance could reveal it. This keeps L-76 strictly layout-scoped.
|
||||
|
||||
### 4.3 Which sections grid
|
||||
|
||||
Per L-72's finding-A, `secondary` (cross-style) is **currently always `[]`**: when a style is locked the panel is same-style-only; when unlocked, everything lands in `primary`. So **only `primary` is ever populated** → only it needs the grid. The optional cross-style section stays dormant; **if** a future change repopulates `secondary`, it gets its own `grid grid-cols-2` block below its own subheading (spec'd, not built). The **idle** dashed card and the two **empty-state sentences** ("You're on the only {style} loop…", "Nothing in the songbook genuinely relates…") stay single-column — they are prose, not a grid.
|
||||
|
||||
### 4.4 Space + height
|
||||
|
||||
- 2-col cell width ≈ `(720 − 10 gap) / 2 ≈ 355px`. A `ChordChain` of up to 8 chips (~40px each) wraps to ~2 rows inside 355px — fine, same chips, just narrower flow.
|
||||
- Cell height ≈ name 18 + meta 16 + chain (2 chip-rows ~30 each) 60 ≈ **~95–100px**. Block = header ~20 + subheader ("Try these in {style}") ~18 + (2 rows × 100 + 10 gap) 210 + `p-3` 24 ≈ **~272px**.
|
||||
- The old vertical **5**-list block ≈ header 20 + subheader 18 + 5 × ~90 + 4 × 10 + 24 ≈ **~552px**. **2×2(4) ≈ 272px → saves ~280px** — the compression the user is after ("cut in half"), almost exactly halved.
|
||||
|
||||
---
|
||||
|
||||
## 5. The whole left column on one screen (honest)
|
||||
|
||||
Usable content box at 1280×900 ≈ **836px** tall (one-screen.md §4). Left stack in jam-view = instrument view + licks strip + `relatedSlot` (`flex-1 min-h-0 overflow-y-auto` absorber = TryThis + RelatedProgressions). Heights are **section-inclusive** (own header + `p-3`); gaps = 3 × `gap-3` = 36px.
|
||||
|
||||
| build / instrument | instrument | licks | try-this | related | +gaps | **total** | vs 836 box |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **current L-74** (guitar) | 240 | 190 | ~154 (rotating, 1 card) | ~552 (5-list) | 36 | **~1172** | overflow **~336px** |
|
||||
| **D-75 new — guitar** | 240 | 190 | ~221 (4-across, 1 row) | ~272 (2×2) | 36 | **~959** | overflow **~123px** |
|
||||
| **D-75 new — bass** | 240 | 190 | ~165 (4-across, 1 row) | ~272 | 36 | **~903** | overflow **~67px** |
|
||||
| **D-75 new — piano, ≤3 subs** | 240 | 190 | ~189 (one row) | ~272 | 36 | **~927** | overflow **~91px** |
|
||||
| **D-75 new — piano, 4 subs** | 240 | 190 | ~342 (2×2, 2 rows) | ~272 | 36 | **~1080** | overflow **~244px** |
|
||||
|
||||
**Honest headline:** the RelatedProgressions 2×2 halves that block in every case (**~552 → ~272, −280px**) — the compression the user asked for. Guitar and bass compress hard: the fold shrinks from the current build's ~336px to **~67–123px** (a micro-flick). Piano is the honest exception the user pre-approved: because MiniPiano's C-anchored window makes each keyboard up to 199px, four piano subs form a **2×2 (two rows, ~342px)**, so the piano-with-4-subs fold is **~244px** — still ~90px better than the current build, and only in the corner case where all four sub-rules fire *and* the diagram is piano (many chords yield 2–3 subs → piano one row → ~91px fold). The residue is absorbed only by the related area's `overflow-y-auto` (instrument + licks never move); a fold-flick is within the standing scroll license, and the user explicitly OK'd more vertical space for the piano. At **1280×960+** guitar/bass/piano-≤3 clear; piano-4-subs still wants a short flick for the related bottom row.
|
||||
|
||||
(The `flex-1 min-h-0 overflow-y-auto` absorber already exists — jam-view fit is exact by construction; only the related area, never the instrument/licks, absorbs the residue.)
|
||||
|
||||
> **Future option (out of D-75 scope):** the only lever to shrink the piano block further is a **span-anchored** MiniPiano `mini` window (crop to the voicing's actual keys instead of C-boundary octaves) — a MiniPiano code change, option (b), deliberately deferred. Flag for a follow-up task if the piano fold proves annoying in play.
|
||||
|
||||
---
|
||||
|
||||
## 6. Bounded scopes (disjoint)
|
||||
|
||||
- **L-75** — `src/components/TryThis.jsx` (rewrite: drop the rotation state/effect + `pickSub`/`advanceOnWrap` + indicator; keep `parseChordName`/`subsForChord`/`pickSubject`; render `subject.subs` as a `flex-wrap` row of SubCards, each with the instrument-following diagram per §2) **+** `src/App.jsx` (add `instrument={instrument}` to the TryThis mount — one line, grep-clean). No smoke coupling (§0). Disjoint from L-76.
|
||||
- **L-76** — `src/components/RelatedProgressions.jsx` **only** (`ul` container `flex flex-col` → `grid grid-cols-1 sm:grid-cols-2`; `primary.slice(0, 4)` for display; idle/empty states untouched; **no** ranking/scoring/constant change). Disjoint from L-75.
|
||||
|
||||
Serialise or worktree — the two locks share no file. No `tailwind.config.js` change (no new colour); no `theory.js` / `piano.js` / `voicings.js` change (resolvers consumed as-is).
|
||||
|
||||
---
|
||||
|
||||
## 7. Rejected alternatives (≥2)
|
||||
|
||||
1. **Keep the rotation AND add a diagram to the single card.** Rejected: the user has seen rotation and explicitly asked for side-by-side ("put them next to each other, we have enough space") — a single card leaves the 744px width the user pointed at mostly empty, and re-litigates a decision the user has already moved past.
|
||||
2. **One big shared diagram for all subs** (e.g. mark all subs on a single fretboard/piano, or a circle-of-fifths mini). Rejected: the subs differ in root, quality, **and** the right instrument grip; a per-sub diagram is exactly what teaches "play *this* shape instead," and it preserves the tap→modal-per-chord affordance. A merged diagram is unreadable and loses per-chord tapping.
|
||||
3. **A uniform 2×2 for try-this across all instruments.** Rejected as the *default*: for guitar/bass (75px / no diagram) a 2×2 wastes half the row and is taller than 4-across for no benefit. Piano adopts a 2×2 only because its ~199px keyboards genuinely can't fit 4-across (§3) and the user OK'd more space for it — an instrument-driven adaptation, not a uniform choice.
|
||||
4. **Cram/shrink the piano keyboards to force 4-across.** Rejected (and the user directly vetoed it, 2026-07-13): the `mini` SVG carries a hard width attr that floors the flex item, so "shrinking" would clip the keyboard; the honest fix is to let piano take a 2×2 and more vertical space. (A genuine width fix = span-anchored MiniPiano window = §5's deferred option b.)
|
||||
4. **Change `RELATED_MAX_ENTRIES` 5→4 in the ranker for the 2×2.** Rejected: that edits an exported constant + forces a smoke re-pin, pulling L-76 out of "layout only." A component-side `slice(0, 4)` gets the clean grid with zero engine/scoring/test churn.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Related-area v2 — always-multi-chord voicings rail + stable 3×3 Try-this (task D-76)
|
||||
|
||||
**Sprint:** `sprint-dashboard-polish` · **Owner:** Muse · **Impl tasks:** L-77 (voicings rail: GlanceRail.jsx + JamGuide.jsx) · L-78 (TryThis.jsx). Concept doc only — no code here. Supersedes the relevant parts of `related-area-layout.md` (D-75) that this refines.
|
||||
|
||||
**User ask (2026-07-13, verbatim — "one last change then we push everything"):**
|
||||
> "for the right part 'different voicings' there is currently only one chord visible, we said to show all chords in the loop with the most recent first. at all times we wanna see all different voicings of the different chords. also for the TRY THIS part please always keep the layout the same if it's 2 or 3 chords i want it to stay in place its annoying when the layout changes then u dont know where to look. max it on 3 TRY THIS suggestions, PLUS please add at least 3 ways to play it. i think we have enough space for 3x3 guitar. for piano it can be just one thats okay."
|
||||
|
||||
Two independent surfaces:
|
||||
- **(A) The voicings RAIL** (right column, `JamGuide` `railContent`) must show **multiple chords, most recent first, at all times** — the "only one chord visible" is the no-loop heard-live fallback.
|
||||
- **(B) TRY THIS** (left column) caps at **3** suggestions, holds a **stable fixed layout** (2-vs-3 subs never shifts position), and gives each suggestion **≥3 ways to play it** — a **3×3** guitar grid, **1** piano keyboard, bass root caption.
|
||||
|
||||
---
|
||||
|
||||
## 0. Where "only one chord" actually comes from (say it plainly)
|
||||
|
||||
`JamGuide` `railContent` (JamGuide.jsx:373-435) has **three** states:
|
||||
|
||||
1. `match.matched` → `<GlanceRail stations={stationVoicings} …/>` — **already** shows every loop chord as a vertical row (canonical KB order, playhead highlight, full per-row gallery). This is NOT the bug.
|
||||
2. `liveChord` (no KB match but a chord is committing, JamGuide.jsx:252) → a **single** `<VoicingBrowser rootPc quality show dense/>` inside a "Heard live · {chord} — every voicing" section (JamGuide.jsx:411-425). **This is the "only one chord."**
|
||||
3. else → the slim idle line.
|
||||
|
||||
So the fix is scoped to state 2 (and the idle-with-history case): replace the single-chord fallback with a **multi-chord, most-recent-first history rail**. State 1 (the loop) already satisfies "all chords of the loop" — the open question is only whether to physically reorder it most-recent-first (§1.3: no, and why).
|
||||
|
||||
---
|
||||
|
||||
## 1. The voicings rail — always multiple chords, most recent first
|
||||
|
||||
### 1.1 Decision: keep GlanceRail-for-loop + a new history fallback — do NOT fully unify
|
||||
|
||||
**Recommendation: keep the two paths, replace only the single-chord fallback.**
|
||||
|
||||
- **Loop matched** → `GlanceRail` as today (canonical order, playhead, per-row gallery, the D-40 voice-leading education). Untouched.
|
||||
- **No loop** → a **new `HistoryRail`** driven by the recent DISTINCT `chordHistory`, most-recent-first, each rendered as a GlanceRail-style voicing row.
|
||||
|
||||
**Why not unify** (always drive the rail from recent-distinct-history, loop adds highlighting): the loop rail's education is *canonical-order-dependent*. `GlanceRail` computes `voiceLeadingPairs` between **canonically adjacent** stations (GlanceRail.jsx:234-241) and renders per-row `TransitionChip`s ("next F→E · ½ step down") plus the wrap chip ("loop") — a voice-leading **wheel**. Reordering those rows most-recent-first would make every "next …" chip point at the wrong neighbour, and the wrap chip lie. Unifying therefore *destroys* the loop education to satisfy a directive the loop already meets another way (§1.3). So: two paths, one shared row idiom, no loss.
|
||||
|
||||
This is also the **minimal, lowest-regression** change: `StationRow` (loop) stays byte-identical; the new `HistoryRow`/`HistoryRail` is additive.
|
||||
|
||||
### 1.2 The no-loop history rail — data source & most-recent-first
|
||||
|
||||
`chordHistory` is already a `JamGuide` prop (JamGuide.jsx:186; App passes it at App.jsx:846) — **no App change needed.** Build the rail entirely from it:
|
||||
|
||||
```
|
||||
recentDistinct(chordHistory, cap = 6):
|
||||
seen = new Set(); out = []
|
||||
for name from END of chordHistory backwards: // newest first
|
||||
parsed = parseChord(name) // voicings.js — same parser JamGuide already imports (line 12)
|
||||
if (!parsed) continue // unparseable → skip, no crash
|
||||
if (seen.has(name)) continue // DISTINCT by chord name (subsumes consecutive-dedup)
|
||||
seen.add(name); out.push({ ...parsed, label: name })
|
||||
if (out.length === cap) break
|
||||
return out // out[0] = the chord sounding now
|
||||
```
|
||||
|
||||
**"Most recent first" mapping:** walk `chordHistory` from the tail (the newest commit is `chordHistory[len-1]` = `currentChord`, App.jsx:572). `out[0]` is therefore the current chord, `out[1]` the previous distinct chord, etc. **Distinct-by-name** (not merely consecutive-dedup) so "F Am F Am F" yields `[F, Am]` — the user wants the *different chords*, each once, not a ping-pong; the most-recent occurrence fixes each chord's slot. Cap **6** rows.
|
||||
|
||||
Each entry parses to `{ rootPc, type, label }` (voicings.js `parseChord` returns `{ rootPc, type }`). Feed each as a row.
|
||||
|
||||
**Honest edges:**
|
||||
- **1 chord ever played** → 1 row. Unavoidable (there is genuinely one chord) — but it is now a *row in the rail idiom*, not a bespoke single-chord section. Strictly better than today, which also showed one.
|
||||
- **20 chords** → the 6 most-recent distinct, newest first. Older ones roll off (the rail is a live window, not a log — the KnowledgeDock Voicings section is the browse-everything surface).
|
||||
- **All same chord** (F F F F) → 1 row.
|
||||
- **Unparseable name** → skipped; if *nothing* parses → fall through to the existing slim idle line (state 3), unchanged.
|
||||
|
||||
### 1.3 What "most recent first" means for the LOOP (honest)
|
||||
|
||||
For the loop, `GlanceRail` stays **canonical KB order** and the **playhead** (accent ring + "now" badge + `aria-current`, GlanceRail.jsx:184-188) marks the chord you're on. That IS "most recent, unmistakable" — it is exactly the active-chord-at-a-glance the DoD demands — without reordering. Physically reordering the loop rows most-recent-first is **rejected** (§6.1) because it breaks the voice-leading wheel. If the user, after seeing this, still wants the loop *physically* re-sequenced, that is a follow-up that must first rework/retire the `TransitionChip` education — flag to Maestro, don't silently do it.
|
||||
|
||||
### 1.4 The HistoryRail row anatomy (reuse, minus loop chrome)
|
||||
|
||||
A new `HistoryRow` (sibling of `StationRow` in GlanceRail.jsx — keep `StationRow` untouched for the loop). Per row, most-recent-first:
|
||||
|
||||
- **Header line:** `st.label` (plain bold text — no focus-toggle button; history chords aren't stations, so no fretboard-focus wiring → simpler, and the main Fretboard already follows `currentChord`). `SoloLabel` + `AimDots` **kept** (GlanceRail.jsx exported atoms) — those are *per-chord* guide-tone education, correct for any chord, and give the history rail visual parity with the loop rail. **No** `TransitionChip` (no canonical adjacency), **no** "next" tag.
|
||||
- **"now" marker on row 0** (the most-recent chord): the same `border-accent bg-accent/10 ring-2 ring-accent` + "now" badge `StationRow` uses for the playhead — so the active chord is unmistakable (DoD). Rows 1-5 recede to the `opacity: 0.85` floor, exactly as inactive loop rows.
|
||||
- **Gallery — as-is:** `<VoicingBrowser rootPc={st.rootPc} quality={st.type} show={instrument} dense max={4} />`. Guitar ≤4 recommended-first shapes on one line, piano 2×2 mini, **no ▶** — the identical per-row gallery the loop rail uses. `recommended` is **null** (an arbitrary heard chord has no authored KB play), which `VoicingBrowser` already handles (order untouched, no badge).
|
||||
|
||||
`HistoryRail` header (the section h4): `Recent chords · newest first — every voicing`, with the one-line microcopy `Following what you play — the newest chord is up top; no repeating loop yet.` (tokens: `bg-panel`, `border-border`, `text-gray-500`).
|
||||
|
||||
### 1.5 Bass, no loop
|
||||
|
||||
The no-loop bass path today renders `BassGuideRows` with a **single** live station (JamGuide.jsx:404-410). Extend it to the **same recent-distinct stations, most-recent-first**: pass the `recentDistinct` array to `BassGuideRows` with approach suppressed (history is not a loop → no "approach into the next chord" line; reuse the existing `live` gate, which already turns `next` off at BassGuideRows.jsx:557, or add a `history` flag if the "· heard live" header wording should change to "· recent"). Root + fifth per row; row 0 marked "now". This keeps all three instruments honest and multi-chord.
|
||||
|
||||
### 1.6 Rail fit (unchanged geometry)
|
||||
|
||||
The rail column is 500px with the D-51 margin-hardened interior (~451px worst-case; GlanceRail.jsx:5-13). `HistoryRow` reuses the exact `VoicingBrowser dense max={4}` cell math, so guitar 4-across (374 ≤ 451) and the piano 2×2 pair (442 ≤ 451) fits are **byte-for-byte the loop rail's** — no new fit risk. Up to 6 rows stack vertically inside the column's existing `overflow-y-auto` scroller (JamGuide.jsx:493-497); the user owns the scrollbar (no auto-scroll — GlanceRail.jsx:46-49's law holds).
|
||||
|
||||
---
|
||||
|
||||
## 2. Try this — max 3, stable 3-slot layout, ≥3 ways to play each
|
||||
|
||||
### 2.1 The reshape: from cards-across to rows-of-shapes
|
||||
|
||||
Today `TryThis` (TryThis.jsx:171-189) lays subs **side by side** (columns = subs), each `SubCard` carrying **one** diagram. D-76 turns this **90°**: **rows = subs, columns = shapes.** Each sub becomes a **horizontal row**: an identity block (chord chip + tag + why) on the left, and its **≥3 ways to play it** on the right. Three sub-rows stacked = the "3×3" the user pictured (3 subs × 3 guitar shapes).
|
||||
|
||||
Cap: `subject.subs.slice(0, 3)` (was up to 4 — TryThis.jsx:160 `subs`). The engine (`suggestSubstitutions`, L-73) and `pickSubject`/`subsForChord`/`parseChordName` are **unchanged** — reactivity preserved: subject still follows `currentChord`, subs recompute per chord (TryThis.jsx:79-90, 152-160). Only the render and the cap change.
|
||||
|
||||
### 2.2 The STABLE fixed 3-slot layout (the anti-jump)
|
||||
|
||||
**Always render exactly 3 slots**, in order:
|
||||
|
||||
```
|
||||
<div className="flex flex-col gap-2">
|
||||
{[0,1,2].map(i => subs[i] ? <SubRow sub={subs[i]} …/> : <EmptySlot key={i}/>)}
|
||||
</div>
|
||||
```
|
||||
|
||||
- A present sub → `SubRow`. An absent one → `EmptySlot`: a **subtle placeholder** that holds the **exact SubRow height** for the current instrument — `rounded-lg border border-dashed border-border/50 bg-transparent` at low opacity, a centred muted `—` (`text-gray-600 text-[11px]`), `aria-hidden`. It reads as "an intentionally empty slot," not as broken, and — critically — the first two rows **never move** whether the chord yields 2 or 3 subs. This is the exact complaint the user raised ("annoying when the layout changes then u dont know where to look"): with fixed slots, sub #1 and sub #2 are always in the same pixel band.
|
||||
- **Uniform row height per instrument** (not across instruments — the global `instrument` is fixed at any moment, so all 3 slots share one instrument's row height): guitar `min-h-[100px]`, piano `min-h-[72px]`, bass `min-h-[64px]` (§3 math). `EmptySlot` inherits the same `min-h-*`.
|
||||
|
||||
Why 3 slots and not "as many as fire": stability is worth a little reserved whitespace. The user explicitly chose predictable position over density here.
|
||||
|
||||
### 2.3 SubRow — guitar (the 3×3), piano (1), bass (caption)
|
||||
|
||||
Outer row: `flex items-center gap-3 rounded-lg border border-border bg-border/30 p-2 min-h-[…]` (reuses the SubCard token palette — TryThis.jsx:126).
|
||||
|
||||
**Left identity block** (`flex flex-col gap-1 shrink-0 w-[180px]`), all instruments:
|
||||
1. **Chord chip** — the tappable control, reused verbatim from TryThis.jsx:127-134: `button → onChordClick?.(sub.label)`, `rounded-lg border border-border bg-border px-2 py-1 text-sm font-bold text-gray-100 hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent`, `aria-label={`${sub.label} — ${sub.why}`}`. Tap → `ChordDetailModal`. Inline beside it: the **category tag** (`relative`/`borrowed`/`colour`/`V7`) + the `↻` accent glyph for circle categories only (`CIRCLE_CATEGORIES`, TryThis.jsx:48-55 — unchanged rule).
|
||||
2. **Why** — `sub.why`, `text-[11px] leading-snug text-gray-400`, `line-clamp-2` (the identity block is ~180px wide; 2 lines ≈ 50 chars; full text stays on the chip `aria-label` + tap→modal). Wider than the old 152px card, so whys clip less.
|
||||
|
||||
**Right "ways to play" block**, by instrument:
|
||||
|
||||
| instrument | right block |
|
||||
|---|---|
|
||||
| **guitar** | `flex gap-2` of **up to 3** `ChordDiagram size="thumb"` (~75px), from `getGuitarVoicings(sub.label).slice(0, 3)`. Each thumb captioned underneath with its shape name (`text-[9px] text-gray-500` — e.g. "E Barre", "A Barre / 5fr") so the three read as **three genuinely different grips**, not a repeat. **Omit** ChordDiagram's own `label` (the chip names the chord). Fewer than 3 shapes exist (dim/aug, §2.4) → show what exists; the row does not pad with blanks (the shapes left-align, the row height is fixed by `min-h`). |
|
||||
| **piano** | **one** `MiniPiano size="mini"` (per the user: "for piano it can be just one thats okay"): `<MiniPiano voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }} size="mini" />`. Spread `rootPc` back in (the VoicingBrowser.jsx:317-319 caveat) so "R" badges correctly. |
|
||||
| **bass** | no diagram — `root · {NOTES[sub.rootPc]}` caption (`text-[10px] text-gray-500`), the same honesty call TryThis/BassGuideRows already make (there is no compact bass-chord renderer). |
|
||||
|
||||
All diagrams use the established tiers baked into `ChordDiagram`/`MiniPiano` (accent-purple root, gray/light-purple other tones) — **no new colour, no token change.**
|
||||
|
||||
### 2.4 Honest empties
|
||||
|
||||
- **Fewer than 3 subs** for the chord → the missing slot(s) render `EmptySlot` (§2.2). Position of the present subs is unchanged.
|
||||
- **A guitar sub with fewer than 3 shapes**: `getGuitarVoicings` returns all placeable shapes — barre forms are always placeable (any root ≤ fret 15). The engine only ever emits **{min, maj, dom7, maj7, add9, maj6, min7, sus4}** (every `mk()` call in theory.js:801-917 — dim/aug are **never** substitution candidates, so they can't appear here). Of those, **maj/min/dom7/maj7/min7/sus4/maj6** carry ≥3 barre shapes → the full 3×3 (voicings.js:10-53, 76-105). The genuine 2-shape case is **add9**: `getGuitarVoicings` returns only 2 barre shapes (E Barre, A Shape) for **9 of 12 roots**, and 3 only for **C/G/D** (which add an open Cadd9/Gadd9/Dadd9, voicings.js:116-121) → that sub-row honestly shows **2** thumbs, `slice(0,3)` simply yielding two. And add9 is **routinely** emitted — Rule C picks add9 for a major-family chord whose maj7 added tone is non-diatonic (e.g. in D major, the V chord A → **Aadd9**), which is common on major-family V-type chords. So a 2-thumb guitar row is a **regular, honest occurrence**, not a rarity; it never crashes and never pads with fakes.
|
||||
- **No subject** (no key / no loop / atonal) → `pickSubject` returns null → render nothing (TryThis.jsx:157, unchanged).
|
||||
|
||||
### 2.5 Reactivity preserved
|
||||
|
||||
`pickSubject(loopArr, keyInfo, currentChord)` still chooses the live chord first, then the first loop station that yields subs (TryThis.jsx:79-90). As the progression evolves the subject and its (now ≤3) subs recompute every commit — the user confirmed they want it to evolve with the playing. The only behavioural deltas: **cap 3** and **3 shapes each**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Space math — honest
|
||||
|
||||
**Left column** `flex-1 min-w-0` ≈ **744px**, section `p-3` → interior **≈ 720px** (one-screen.md §1; carried from D-75 §3). Footprints from source geometry: `ChordDiagram size="thumb"` = **75×72px** (padL 14 + gridW 55 + padR 6; padT 11 + gridH 55 + padB 6). `MiniPiano size="mini"` = up to **199×38px** (C-anchored window, D-75 §3 — width varies with root, **height is octave-independent 38.4px**).
|
||||
|
||||
**Guitar SubRow.** Right block: 3 thumbs = `3·75 + 2·8 = 241px` (+ 9px shape captions → ~84px tall). Left identity 180px + gaps ≈ `180 + 12 + 241 = 433 ≤ 720` — comfortable, ~287px to spare. Row height = max(thumb 72 + caption 12 = 84, chip 22 + why-2-lines 30 = 52) + `p-2` 16 ≈ **~100px** → `min-h-[100px]`. **3 slots** = `3·100 + 2·8 = 316`; block (+ header 20 + `p-3` 24) ≈ **~360px** — fixed, 2 subs or 3.
|
||||
|
||||
**Piano SubRow.** Right block: one `mini` keyboard ≤199px wide, 38px tall. `180 + 12 + 199 = 391 ≤ 720`. Row = max(38, 52) + 16 ≈ **~72px** → `min-h-[72px]`. **3 slots** = `3·72 + 16` ≈ 232; block ≈ **~276px**.
|
||||
|
||||
**Bass SubRow.** No diagram. Row = 52 + 16 ≈ **~64px** → `min-h-[64px]`. 3 slots ≈ 208; block ≈ **~250px**.
|
||||
|
||||
Note the 3×3 guitar block (~360px) is **taller** than D-75's 4-across single row (~221px) — the honest cost of "at least 3 ways to play each," which the user asked for directly.
|
||||
|
||||
### One-screen budget @ 1280×900 (usable box ≈ 836px, one-screen.md §4)
|
||||
|
||||
Left jam-view stack = instrument view + licks strip + `relatedSlot` (`flex-1 min-h-0 overflow-y-auto` absorber = **new** Try-this + RelatedProgressions 2×2 from L-76); gaps = 3·`gap-3` = 36px.
|
||||
|
||||
| instrument | instrument | licks | try-this (new) | related 2×2 | +gaps | **total** | vs 836 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **guitar** | 240 | 190 | ~360 (3×3, 3 slots) | ~272 | 36 | **~1098** | overflow **~262px** |
|
||||
| **piano** | 240 | 190 | ~276 (1 kbd, 3 slots) | ~272 | 36 | **~1014** | overflow **~178px** |
|
||||
| **bass** | 240 | 190 | ~250 (caption, 3 slots) | ~272 | 36 | **~988** | overflow **~152px** |
|
||||
|
||||
**Scroll story (honest):** the instrument view and licks strip stay **pinned** (they are `xl:shrink-0`, JamGuide.jsx:476-481); only the `relatedSlot` — the `flex-1 min-h-0 overflow-y-auto` absorber (JamGuide.jsx:485-489) — scrolls. So the taller 3×3 try-this deepens the fold in the *related area only*: guitar wants a ~262px flick to reach the bottom of the RelatedProgressions grid, piano ~178px, bass ~152px. This is ~139px more than D-75's guitar fold (~123px) — the direct, user-requested trade for 3 grips per sub. Instrument + licks never move; the active chord and the Try-this subjects are always above the fold. At **1280×1080+** guitar clears; piano/bass clear at ~1100. The `overflow-y-auto` absorber makes the fit exact by construction — the residue lands only in the related area, never on the instrument/licks the player watches.
|
||||
|
||||
*(The right rail is independent: `HistoryRail`'s ≤6 rows live in the 500px column's own `overflow-y-auto` scroller — they do not affect the left-column budget.)*
|
||||
|
||||
---
|
||||
|
||||
## 4. Bounded scopes (file-disjoint → parallelisable)
|
||||
|
||||
- **L-77 — the voicings rail.** Files: **`src/components/GlanceRail.jsx`** (add `HistoryRow` + exported `HistoryRail`, reusing the `VoicingBrowser dense max={4}` gallery + `SoloLabel`/`AimDots`; `StationRow`/`GlanceRail` for the loop stay byte-identical) **+ `src/components/JamGuide.jsx`** (a `recentDistinct(chordHistory)` memo; the no-loop `railContent` branch → `HistoryRail` for guitar/piano and `BassGuideRows` fed the recent-distinct stations with approach suppressed for bass; the single-`VoicingBrowser` fallback is retired). **No App change** (`chordHistory` already flows in, JamGuide.jsx:186 / App.jsx:846). No `theory.js`/`voicings.js`/`piano.js`/`tailwind.config.js` change (resolvers/tokens consumed as-is).
|
||||
- **L-78 — Try this.** File: **`src/components/TryThis.jsx` only.** Cap `subs.slice(0,3)`; replace the cards-across body with the 3-slot rows-of-shapes (§2); keep `parseChordName`/`subsForChord`/`pickSubject`. **No App change** (`instrument` already threaded by L-75, App.jsx:864). No engine change (§2.5).
|
||||
|
||||
**Disjoint:** L-77 = {GlanceRail.jsx, JamGuide.jsx}; L-78 = {TryThis.jsx}. **Zero file overlap** → they can run in parallel (worktree) or serial, in any order. Neither touches `theory.js`, `voicings.js`, `piano.js`, `App.jsx`, or `tailwind.config.js`. C-70's `VoicingBrowser.jsx` aria fold is disjoint from both.
|
||||
|
||||
*(If a future revision wants the history rail and the loop rail to share one component body, that unification is out of D-76 scope — it would re-touch `StationRow` and risk the loop path; keep it a separate task.)*
|
||||
|
||||
---
|
||||
|
||||
## 5. What does NOT change
|
||||
|
||||
- The engine `suggestSubstitutions` and its rules/why-copy (L-73) — untouched; only the display cap (4→3) and per-sub shape count change.
|
||||
- The loop rail (`GlanceRail`/`StationRow`): canonical order, playhead, voice-leading wheel, focus→fretboard — all byte-identical.
|
||||
- `VoicingBrowser`, `ChordDiagram`, `MiniPiano`, `pianoVoicing`, `getGuitarVoicings` — consumed as-is.
|
||||
- Tokens: `bg-panel`, `bg-surface`, `border-border`, `text-accent`/`bg-accent`, the accent ring, the established note-colour tiers. **No new colour, no `tailwind.config.js` edit.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Rejected alternatives (≥2)
|
||||
|
||||
1. **Reorder the LOOP rail most-recent-first (physically re-sequence the rows).** Rejected: `GlanceRail` builds `voiceLeadingPairs` between *canonically adjacent* stations and shows per-row "next F→E · ½ step down" + a "loop" wrap chip (GlanceRail.jsx:118-132, 234-241). Reordering makes every transition chip point at the wrong neighbour and the wrap chip lie — it trades away the loop's whole voice-leading education for a re-sort the playhead highlight already conveys. The current chord is unmistakable *in place* via the "now" ring.
|
||||
2. **Fully unify: always drive the rail from recent-distinct-history, loop merely overlays highlighting.** Rejected: same casualty — the transition/wrap education is canonical-order-only, and a history-ordered rail can't carry it. Unifying is a larger refactor that *reduces* the loop's teaching value. Two paths sharing one row idiom is smaller and loses nothing.
|
||||
3. **Keep Try-this side-by-side (columns = subs) and just add 2 more diagrams per card.** Rejected: 3 subs × 3 guitar thumbs across = `3·(3·75) ≈ 675px` of diagrams fighting for a 720px row with no room for chips/whys, and it re-introduces the 2-vs-3 horizontal jump the user hates (cards grow/reflow). Rows = subs, columns = shapes, fixed 3 slots is the stable-position answer.
|
||||
4. **Let the number of Try-this slots follow the sub count (render 2 rows when 2 subs).** Rejected: that is exactly the "layout changes, you don't know where to look" jump the user called out. Reserving 3 slots with a subtle placeholder costs a little whitespace and buys constant position — the user's explicit priority.
|
||||
5. **Shrink guitar thumbs to force less height / cram the 3×3.** Rejected: 75px is the established `ChordDiagram thumb` size used everywhere (rail, KC, modal); shrinking it below the shared size fragments the visual language and hurts readability mid-jam. The honest cost of 3 grips per sub is ~140px more fold in the *related area only* (§3) — pinned instrument/licks are unaffected, and the user asked for the 3 grips knowing it takes room.
|
||||
@@ -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) ii–V–I 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.
|
||||
@@ -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 3–4 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 0–11 + 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, ii–V 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), 3–4 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>
|
||||
@@ -0,0 +1,307 @@
|
||||
# Plan: PWA + Firebase Hosting for JamBuddy
|
||||
|
||||
Status: **proposed**. Each phase is independently shippable; stop after any one.
|
||||
|
||||
---
|
||||
|
||||
## 0. Why this is low-risk
|
||||
|
||||
The Electron shell (`electron/main.cjs`, `preload.cjs`) does **nothing but host a window** — no IPC, no native APIs, `sandbox:false` only to give the renderer full Web Audio. All logic lives in the React renderer, which already runs browser-only via `npm run dev`. So the web build is the same `vite build` output the desktop installer already ships; we just add a manifest + service worker and serve `dist/` over HTTPS. Firebase gives us HTTPS for free, which `getUserMedia` (the mic) requires — that's the only hard web constraint.
|
||||
|
||||
Nothing in the app makes network calls today, so there's no API surface to re-host and no secrets to manage.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — PWA scaffolding (no Firebase yet)
|
||||
|
||||
Goal: `vite build` produces an installable, offline-capable web app served from any static host.
|
||||
|
||||
### 1.1 Add `vite-plugin-pwa`
|
||||
|
||||
Only new dependency. Wraps Workbox under a Vite plugin — generates the service worker and injects the manifest. Cheaper than hand-rolling a SW and gets update flow for free.
|
||||
|
||||
```js
|
||||
// vite.config.js
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
|
||||
manifest: {
|
||||
name: 'JamBuddy — WhatTheFlat',
|
||||
short_name: 'JamBuddy',
|
||||
description: 'Real-time key and chord detection for live jams.',
|
||||
theme_color: '#0f0f0f', // matches bg-surface token
|
||||
background_color: '#0f0f0f',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
icons: [ /* see 1.3 */ ],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
|
||||
// Audio is captured live — nothing to cache from a remote. Precache the app shell only.
|
||||
navigateFallback: '/index.html',
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### 1.2 Create `public/` directory
|
||||
|
||||
Vite serves `public/` at root and copies it verbatim into `dist/`. We need it for:
|
||||
|
||||
- `favicon.ico`, `apple-touch-icon.png` (180×180)
|
||||
- Any static PWA icons referenced by the manifest
|
||||
- (Optional) `robots.txt` — not needed for an app, skip.
|
||||
|
||||
The existing `src/assets/whattheflat-logo.png` is imported in `App.jsx` and bundled by Vite — leave that as-is; **don't** move it. The manifest icons must live in `public/` because they're referenced by URL, not imported.
|
||||
|
||||
### 1.3 Generate icon set from the existing logo
|
||||
|
||||
Derive all required sizes from `assets/whattheflat-logo.png` (one source image → PNG output). Required manifest entries:
|
||||
|
||||
```js
|
||||
icons: [
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
]
|
||||
```
|
||||
|
||||
Plus `apple-touch-icon.png` (180×180) in `public/` — iOS ignores the manifest and uses this `<link>`. The maskable variant needs safe-zone padding (~10%) so the logo isn't cropped by Android's circular mask.
|
||||
|
||||
One-liner with ImageMagick if available, else export manually from the source PNG:
|
||||
|
||||
```bash
|
||||
magick assets/whattheflat-logo.png -resize 192x192 public/icon-192.png
|
||||
magick assets/whattheflat-logo.png -resize 512x512 public/icon-512.png
|
||||
magick assets/whattheflat-logo.png -resize 180x180 public/apple-touch-icon.png
|
||||
```
|
||||
|
||||
### 1.4 Update `index.html`
|
||||
|
||||
Two problems with the current `<head>`:
|
||||
|
||||
1. **No PWA tags** — add theme-color, manifest link, apple-touch-icon, apple-mobile-web-app-capable, description.
|
||||
2. **CSP is hardcoded for dev** — `connect-src 'self' http://localhost:5173 ws://localhost:5173` and `script-src 'self' 'unsafe-eval'`. The `unsafe-eval` and the localhost entries are Vite dev artifacts; production Vite doesn't need `unsafe-eval`. Keep a CSP (it's good hygiene and the app is genuinely offline) but drop the dev bits:
|
||||
|
||||
```html
|
||||
<meta name="theme-color" content="#0f0f0f" />
|
||||
<meta name="description" content="Real-time key and chord detection for live jams." />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
```
|
||||
|
||||
CSP (production):
|
||||
```
|
||||
default-src 'self';
|
||||
script-src 'self';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data:;
|
||||
connect-src 'self';
|
||||
media-src 'self' blob:; <!-- the mic stream lives in a blob -->
|
||||
```
|
||||
`blob:` in `media-src` is required because `getUserMedia` streams are blob-backed and the analyser reads them. Verify nothing else regresses — `vite-plugin-pwa` registers the SW from a same-origin script, so `'self'` covers it.
|
||||
|
||||
Move the dev-only CSP into a Vite conditional so dev still allows HMR/websocket:
|
||||
|
||||
```js
|
||||
// vite.config.js — inject dev CSP via plugin, or keep a separate dev index template.
|
||||
```
|
||||
Simplest: keep one CSP in `index.html` with the production values, and in dev let Vite's own injected tags coexist (HMR works over the same origin websocket; CSP `connect-src 'self'` already allows it). If dev breaks, add `ws://localhost:5173` only in a dev-specific block.
|
||||
|
||||
### 1.5 Verify
|
||||
|
||||
- `npm run build` → `dist/` contains `manifest.webmanifest`, `registerSW.js`, `sw.js`, and all `public/` icons.
|
||||
- Serve `dist/` locally with `npm run preview` and use Chrome DevTools → Application → Manifest (should show icons, installability ✓) and Service Workers (registered).
|
||||
- Install to desktop / "Add to Home Screen" on mobile.
|
||||
- Confirm the app still works **fully offline** after first load (airplane mode). Since there are no network calls, the only risk is the SW failing to precache — Workbox handles this.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Firebase Hosting setup
|
||||
|
||||
Goal: serve `dist/` over HTTPS on a `*.web.app` domain (or custom domain).
|
||||
|
||||
### 2.1 Install the CLI
|
||||
|
||||
```bash
|
||||
npm install -D firebase-tools
|
||||
```
|
||||
|
||||
### 2.2 Init
|
||||
|
||||
```bash
|
||||
npx firebase init hosting
|
||||
```
|
||||
|
||||
Answer:
|
||||
- **Public directory:** `dist`
|
||||
- **Single-page app (rewrite all urls to /index.html):** Yes — though the app has no client-side routing today, this protects future routes and is harmless.
|
||||
- **Set up automatic builds with GitHub:** **No.** (You're not using GitHub.)
|
||||
- **File `dist/index.html` already exists — overwrite?** **No.** This is critical — saying yes would clobber the Vite-built index.
|
||||
|
||||
This creates:
|
||||
- `firebase.json`
|
||||
- `.firebaserc`
|
||||
|
||||
### 2.3 `firebase.json` (target shape)
|
||||
|
||||
```json
|
||||
{
|
||||
"hosting": {
|
||||
"public": "dist",
|
||||
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
|
||||
"rewrites": [{ "source": "**", "destination": "/index.html" }],
|
||||
"headers": [
|
||||
{
|
||||
"source": "**/*.@(js|css|svg|png|ico|woff2|webmanifest)",
|
||||
"headers": [
|
||||
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/sw.js",
|
||||
"headers": [
|
||||
{ "key": "Cache-Control", "value": "no-cache" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/index.html",
|
||||
"headers": [
|
||||
{ "key": "Cache-Control", "value": "no-cache" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Why the headers: Vite hashes asset filenames, so JS/CSS/icons are content-addressed → cache them **immortal**. `index.html` and `sw.js` must stay fresh so updates propagate — `no-cache` revalidates them every time. (Note: with `vite-plugin-pwa`'s `autoUpdate`, the SW self-updates; the `no-cache` on `sw.js` is belt-and-braces.)
|
||||
|
||||
### 2.4 First deploy
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npx firebase deploy --only hosting
|
||||
```
|
||||
|
||||
Output: `https://<project>.web.app`. The mic will now work because the origin is HTTPS.
|
||||
|
||||
### 2.5 `.gitignore`
|
||||
|
||||
Add `.firebase/` and `firebase-debug.log` (already in a sane gitignore, but confirm).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — (optional) custom domain
|
||||
|
||||
In Firebase console → Hosting → Add custom domain. Add the DNS TXT/CNAME records it gives you. Lets Encrypt cert is auto-provisioned. No app change needed.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Web optimization checklist
|
||||
|
||||
Things that matter specifically for shipping this app on the web. Most are cheap; do them before the first public deploy.
|
||||
|
||||
### 4.1 Drop the dead dependency
|
||||
|
||||
`audiomotion-analyzer` is listed in `package.json` but **imported nowhere** in `src/`. Vite tree-shakes unused ESM, so it likely doesn't bloat the production bundle — but it still inflates `npm install` and is misleading. Either:
|
||||
- **Remove it** from `dependencies` (one-line `npm uninstall audiomotion-analyzer`), or
|
||||
- If something depends on it being present (it doesn't — grep confirms zero imports), wire it up.
|
||||
|
||||
Recommend: remove.
|
||||
|
||||
### 4.2 Audit bundle size
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npx vite-bundle-visualizer # one-off, no config needed
|
||||
```
|
||||
Expected heavy bits: `pitchy` (small, pure JS) and React 19. `electron` / `electron-builder` are devDeps and excluded from the web build. If the visualizer shows anything surprising (e.g. a transitive dep pulling in moment/lodash), trim it.
|
||||
|
||||
### 4.3 Code-split the heavy, rarely-used views
|
||||
|
||||
`DebugView`, `DrumView`, and `Tuner` are collapsible panels most users never open. They import theory helpers and own audio contexts. Lazy-load them so the initial bundle stays lean:
|
||||
|
||||
```jsx
|
||||
const Tuner = lazy(() => import('./components/Tuner'))
|
||||
const DebugView = lazy(() => import('./components/DebugView'))
|
||||
const DrumView = lazy(() => import('./components/DrumView'))
|
||||
```
|
||||
Wrap in `<Suspense fallback={null}>`. These are already behind toggle buttons, so there's no UX cost. This is the single biggest web win — the core pitch/chord flow loads first.
|
||||
|
||||
### 4.4 Audio permissions & UX on the web
|
||||
|
||||
- **HTTPS** — covered by Firebase. `getUserMedia` is blocked on plain HTTP (except `localhost`).
|
||||
- **User gesture** — the existing "Start Listening" button already provides the gesture the browser requires to call `getUserMedia`. Don't change this; never auto-start listening on load.
|
||||
- **Mobile Safari quirks** — iOS Safari:
|
||||
- Requires `audio: { echoCancellation: false }` style may differ; test the existing `AudioCapture` constraints on an iPhone.
|
||||
- AudioContext must be resumed after a gesture — already handled since capture starts on click.
|
||||
- `sharedArrayBuffer` is not needed by this app (no worklets), so no COOP/COEP headers required. Good — don't add them; they'd complicate the Firebase static host.
|
||||
- **Permissions API** — optionally surface "microphone blocked" before the user clicks, using `navigator.permissions.query({ name: 'microphone' })`. The existing `micError` state already covers the denied case; this is a polish step, not required.
|
||||
|
||||
### 4.5 Mobile layout
|
||||
|
||||
The app uses Tailwind with responsive `lg:` breakpoints already (e.g. the progressions sidebar is `hidden lg:block`). For a real mobile deploy:
|
||||
- Test the fretboard / piano SVGs at phone widths — they're SVG so they scale, but text labels may crowd.
|
||||
- The `controls bar` wraps with `flex-wrap` — good. Verify the key-lock select dropdowns are tappable.
|
||||
- Consider a `display: standalone` install prompt; the manifest already sets `standalone`.
|
||||
- This is a "play and look at the screen" app — portrait guitar fretboard orientation may want `orientation: 'portrait'` in the manifest instead of `'any'`. Decide based on the primary instrument view.
|
||||
|
||||
### 4.6 Performance for the audio hot loop
|
||||
|
||||
Already mostly tuned (the dual-analyser design is deliberate). Web-specific notes:
|
||||
- `requestAnimationFrame` driving the analyser is fine on web — no change.
|
||||
- `Float32Array` chroma math is cheap. No change needed.
|
||||
- Don't add a Workbox runtime cache for audio — there's no audio to cache; everything is live.
|
||||
- If you ever add an AudioWorklet, its file must be served with the correct MIME and same-origin — Workbox precaches it as part of `globPatterns` only if it's emitted to `dist/`. Not needed today.
|
||||
|
||||
### 4.7 SEO / social (minimal — this is an app, not content)
|
||||
|
||||
- `<title>` and meta description (added in 1.4) are enough for the web.app URL to have a sane share card.
|
||||
- Add `og:title` / `og:description` / `og:image` to `index.html` if you want a nice link preview (point `og:image` to `/icon-512.png`). Optional.
|
||||
- No sitemap needed for a single-page offline app.
|
||||
|
||||
### 4.8 Keep Electron working
|
||||
|
||||
The desktop build must not regress:
|
||||
- `electron-builder` config in `package.json` is untouched by any of the above.
|
||||
- The CSP change in `index.html` affects both targets — verify the desktop window still loads (it serves the built `dist/index.html`, so the new production CSP applies there too; that's fine and arguably better).
|
||||
- `vite-plugin-pwa` is a dev dependency; it only runs at build time and doesn't touch the renderer runtime in a way that breaks Electron.
|
||||
- Run `npm run electron:dev` after Phase 1 to confirm the Electron path still works.
|
||||
|
||||
---
|
||||
|
||||
## What stays Electron-only
|
||||
|
||||
Nothing in this plan removes or forks code. The Electron and web builds share the exact same `dist/` output. The only per-target differences are:
|
||||
- Electron adds the `electron/main.cjs` shell and `electron-builder` packaging.
|
||||
- Web adds the PWA manifest + SW (ignored by Electron) and is served over HTTPS.
|
||||
|
||||
If a future feature needs a native API (filesystem dialogs, auto-launch, tray), it would go through Electron's preload/IPC — which currently exposes nothing. At that point you'd add an `isElectron` guard and a `preload` IPC bridge. Not needed for any current feature.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order of operations
|
||||
|
||||
1. **Phase 1.2** — create `public/`, drop in icons (Phase 1.3).
|
||||
2. **Phase 1.1 + 1.4** — add `vite-plugin-pwa`, update `index.html`, relax CSP.
|
||||
3. Verify with `npm run preview` + DevTools (Phase 1.5).
|
||||
4. **Phase 2** — `firebase init hosting`, hand-tune `firebase.json`, first deploy.
|
||||
5. **Phase 4.1** — remove dead `audiomotion-analyzer` dep.
|
||||
6. **Phase 4.3** — lazy-load the three collapsible views.
|
||||
7. Polish: mobile pass (4.5), bundle audit (4.2), optional OG tags (4.7).
|
||||
|
||||
Each step is independently revertible. Stop after step 4 and you have a working, installable, offline PWA on the web; the rest is optimization.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"hosting": {
|
||||
"site": "jambuddy",
|
||||
"public": "dist",
|
||||
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
|
||||
"rewrites": [{ "source": "**", "destination": "/index.html" }],
|
||||
"headers": [
|
||||
{
|
||||
"source": "**/*.@(js|css|svg|png|ico|woff2|webmanifest)",
|
||||
"headers": [
|
||||
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/sw.js",
|
||||
"headers": [
|
||||
{ "key": "Cache-Control", "value": "no-cache" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/index.html",
|
||||
"headers": [
|
||||
{ "key": "Cache-Control", "value": "no-cache" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -2,12 +2,19 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval'; connect-src 'self' http://localhost:5173 ws://localhost:5173 http://127.0.0.1:5173 ws://127.0.0.1:5173; style-src 'self' 'unsafe-inline'; img-src 'self' data:;">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="description" content="Real-time key and chord detection for live jams. Play any instrument into your mic and JamBuddy identifies the key, chords, and tempo — fully offline." />
|
||||
<meta name="theme-color" content="#0f0f0f" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="JamBuddy" />
|
||||
<!-- Production CSP. dev HMR runs same-origin so 'self' covers the ws upgrade. -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' http://127.0.0.1:5173 ws://127.0.0.1:5173; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' blob:; connect-src 'self';" />
|
||||
<title>WhatTheFlat ♭? - JamBuddy</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
Generated
+3876
-106
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -16,7 +16,6 @@
|
||||
"electron:build:linux": "vite build && electron-builder --linux --publish never"
|
||||
},
|
||||
"dependencies": {
|
||||
"audiomotion-analyzer": "^4.5.4",
|
||||
"pitchy": "^4.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
@@ -31,6 +30,7 @@
|
||||
"electron-builder": "^26.8.1",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"wait-on": "^9.0.4"
|
||||
},
|
||||
"build": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
+380
-1
@@ -1193,7 +1193,386 @@ if (existsSync(join(ROOT, 'src/components/PianoLickCard.jsx'))) {
|
||||
warn('PianoLickCard vocab drift guard', 'src/components/PianoLickCard.jsx not yet authored (D-60)')
|
||||
}
|
||||
|
||||
// ─── 8. Summary + exit code ───────────────────────────────────────────────────
|
||||
// ─── 7d. Jam Roulette round-trip sweep (L-60, jam-roulette.md §2.2) ───────────
|
||||
//
|
||||
// The pool gate, protocol VERBATIM: for each of the 56 progressions, seed its
|
||||
// canonical collapsed form in C, fill a 32-commit window with repetitions and
|
||||
// truncate at EVERY partial-cycle offset (0…len−1); it passes iff
|
||||
// detectRepeatingProgression returns exactly that form at ALL offsets, AND the
|
||||
// collapsed length is 2–8. Steady-state-plus-all-offsets is the honest protocol
|
||||
// (a jam is sampled mid-cycle; a naive "2 clean cycles" feed evicts the 2-name
|
||||
// vamps and passes blues-8bar — §2.2). Expected: 52 passers, exactly 4 excluded
|
||||
// (jazz-blues, blues-quickchange, bossa-blue over-length; blues-8bar's
|
||||
// 1-of-7-offsets self-competition). Independent of match.roundTripPasses so a
|
||||
// detector or seed regression turns smoke red; cross-checked against it below.
|
||||
|
||||
console.log('\nJam Roulette round-trip sweep (L-60):')
|
||||
|
||||
const { seedableLoop, roundTripPasses, buildRoulettePool } = match
|
||||
|
||||
// Verbatim §2.2 protocol — deliberately NOT calling match.roundTripPasses.
|
||||
function sweepPasses(form) {
|
||||
if (!Array.isArray(form) || form.length < 2 || form.length > 8) return false
|
||||
const target = form.join(',')
|
||||
for (let offset = 0; offset < form.length; offset++) {
|
||||
const history = []
|
||||
for (let k = 0; k < 32; k++) history.push(form[(offset + k) % form.length])
|
||||
const detected = detectRepeatingProgression(history)
|
||||
if (!detected || detected.join(',') !== target) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const sweepFails = []
|
||||
let sweepPool = 0
|
||||
let sweepTotal = 0
|
||||
for (const style of Object.keys(kb)) {
|
||||
for (const prog of kb[style].progressions ?? []) {
|
||||
sweepTotal++
|
||||
const form = seedableLoop(prog, 0) // key C — key-independent (§3.3.3)
|
||||
if (form && sweepPasses(form)) sweepPool++
|
||||
else sweepFails.push(`${style}/${prog.id}`)
|
||||
}
|
||||
}
|
||||
console.log(` round-trip sweep: ${sweepFails.length} failures / pool ${sweepPool} (of ${sweepTotal})`)
|
||||
|
||||
check('round-trip sweep: exactly 4 failures, pool 52 of 56 (§2.2 steady-state, all offsets)', () => {
|
||||
assert(sweepTotal === 56, `swept ${sweepTotal} progressions, expected 56`)
|
||||
assert(sweepPool === 52, `pool ${sweepPool} ≠ 52`)
|
||||
assert(sweepFails.length === 4, `${sweepFails.length} failures ≠ 4: [${sweepFails.join(', ')}]`)
|
||||
})
|
||||
|
||||
check('round-trip sweep: the 4 excluded are exactly jazz-blues, blues-quickchange, blues-8bar, bossa-blue', () => {
|
||||
const want = ['blues/blues-8bar', 'blues/blues-quickchange', 'bossa/bossa-blue', 'jazz/jazz-blues']
|
||||
assert(JSON.stringify([...sweepFails].sort()) === JSON.stringify(want),
|
||||
`excluded set [${[...sweepFails].sort().join(', ')}] ≠ [${want.join(', ')}]`)
|
||||
})
|
||||
|
||||
check('match.roundTripPasses agrees with the verbatim sweep on all 56 (drift guard)', () => {
|
||||
for (const style of Object.keys(kb)) {
|
||||
for (const prog of kb[style].progressions ?? []) {
|
||||
const form = seedableLoop(prog, 0)
|
||||
const mine = form ? sweepPasses(form) : false
|
||||
const theirs = form ? roundTripPasses(form) : false
|
||||
assert(mine === theirs, `${style}/${prog.id}: verbatim ${mine} ≠ match.roundTripPasses ${theirs}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
check('buildRoulettePool exposes exactly the 52 passers, all collapsed len 2–8', () => {
|
||||
const pool = buildRoulettePool(kb)
|
||||
let n = 0
|
||||
for (const [, members] of pool.byStyle) {
|
||||
for (const mem of members) {
|
||||
n++
|
||||
assert(mem.collapsedLen >= 2 && mem.collapsedLen <= 8, `${mem.id} collapsedLen ${mem.collapsedLen} out of 2–8`)
|
||||
}
|
||||
}
|
||||
assert(n === 52, `pool holds ${n} members ≠ 52`)
|
||||
})
|
||||
|
||||
// Fix (a): a clean LIVE 12-bar commit stream now matches blues-12bar — the
|
||||
// pre-existing live-detection bug pinned fixed (jam-roulette.md §3.3.1). The live
|
||||
// commit stream is ALREADY collapsed (App.jsx dedupes back-to-back commits), so a
|
||||
// real 12-bar in G commits [G7,C7,G7,D7,C7,G7,D7] per chorus. Before fix (a) this
|
||||
// collapsed loop matched NOTHING → an empty JamGuide; now it detects + matches.
|
||||
check('fix (a) live: a clean collapsed 12-bar commit stream detects + matches blues-12bar (was an empty JamGuide)', () => {
|
||||
const G12_COMMITS = ['G7', 'C7', 'G7', 'D7', 'C7', 'G7', 'D7'] // one chorus, as committed
|
||||
const history = [].concat(...Array.from({ length: 5 }, () => G12_COMMITS)) // 5 choruses
|
||||
const detected = detectRepeatingProgression(history)
|
||||
assert(detected, 'detector returned null for a clean 12-bar commit stream')
|
||||
const m = matchLoopToProgression(detected, index)
|
||||
assert(m.matched && m.id === 'blues-12bar' && m.style === 'blues',
|
||||
`detected ${JSON.stringify(detected)} matched ${m.matched ? m.style + '/' + m.id : 'NONE'}, expected blues/blues-12bar`)
|
||||
})
|
||||
|
||||
// ─── 8. RelatedProgressions ranking pins (C-50) ───────────────────────────────
|
||||
//
|
||||
// Pins the L-51-gate-verified ranking outcomes of RelatedProgressions.jsx
|
||||
// (rankRelatedProgressions / collapseChanges, exported for exactly this) so a
|
||||
// scoring-constant tweak, a collapse regression, or a KB addition that
|
||||
// reshuffles the top entries turns smoke red instead of silently changing what
|
||||
// jammers are recommended. Two live pins + one counterfactual:
|
||||
//
|
||||
// (a) collapsed live 12-bar in A → RE-PINNED for L-60 fix (a): the
|
||||
// collapsed-form index now recognizes the loop as blues-12bar, which the
|
||||
// id-only rule excludes from its own related list. The new top is
|
||||
// blues/blues-8bar at score 92, annotation 'shares I7→V7' (0 shape + 40
|
||||
// same-style + 36 transitions Δ{5,7,10} + 16 Jaccard·1.0 − 0 length);
|
||||
// blues-quickchange edges just under at 90 (same terms, |7−9|=2 length
|
||||
// penalty). This is the exact flip the pre-fix note here foretold.
|
||||
// (b) live ii–V–I in C → match jazz/jazz-251-major (quality overlap wins
|
||||
// over jazz-251-minor), which is EXCLUDED from entries; top entry is
|
||||
// jazz/jazz-251-minor at 156 'same changes' (100 same canonical shape +
|
||||
// 40 same style + 0 shared transitions — every quality differs — + 16
|
||||
// Jaccard − 0 length).
|
||||
// (c) no-collapse counterfactual: an INDEPENDENT replica of the §5 scoring
|
||||
// formula (docs/design/one-screen.md), fed blues-12bar's RAW bar-per-bar
|
||||
// degrees, scores 39 (36 transitions + 8 Jaccard·0.5 − 5 length, no
|
||||
// +100) vs 152 with collapse — proving the mandatory collapse step is
|
||||
// what makes the flagship "this 12-bar IS their 12-bar" relation fire.
|
||||
// Post fix (a) blues-12bar is the MATCH (excluded), so the live grounding
|
||||
// moves to the actual top entry: the replica of blues-8bar (same-style
|
||||
// relative) must equal its live component score (92), grounding the
|
||||
// replica so the 39/152 can't drift into fiction.
|
||||
|
||||
console.log('\nRelatedProgressions ranking pins (C-50):')
|
||||
|
||||
const relMod = await load('src/components/RelatedProgressions.jsx')
|
||||
const { rankRelatedProgressions, collapseChanges, siblingRole } = relMod
|
||||
const { loopToDegrees, canonicalDegrees } = match
|
||||
|
||||
const LOOP_12BAR_A = ['A7', 'D7', 'A7', 'E7', 'D7', 'A7', 'E7'] // detection-collapsed 12-bar
|
||||
const LOOP_251_C = ['Dm7', 'G7', 'Cmaj7'] // live ii–V–I
|
||||
|
||||
check('RelatedProgressions exports rankRelatedProgressions + collapseChanges', () => {
|
||||
assert(typeof rankRelatedProgressions === 'function', 'rankRelatedProgressions is not an exported function')
|
||||
assert(typeof collapseChanges === 'function', 'collapseChanges is not an exported function')
|
||||
})
|
||||
|
||||
check('collapseChanges(blues-12bar) → 7 units [0,5,0,7,5,0,7] (raw 12 bars collapse to the detection form)', () => {
|
||||
const prog = kb.blues.progressions.find((p) => p.id === 'blues-12bar')
|
||||
assert(prog, 'blues-12bar missing from the registry')
|
||||
const units = collapseChanges(prog)
|
||||
const degs = units.map((u) => u.deg).join(',')
|
||||
assert(degs === '0,5,0,7,5,0,7', `collapsed degrees [${degs}] ≠ [0,5,0,7,5,0,7]`)
|
||||
assert(units.every((u) => u.quality === 'dom7'), 'collapsed qualities are not all dom7')
|
||||
})
|
||||
|
||||
check('collapseChanges(blues-minor) pops the wrap-around pair → 5 units [0,5,0,8,7]', () => {
|
||||
const prog = kb.blues.progressions.find((p) => p.id === 'blues-minor')
|
||||
assert(prog, 'blues-minor missing from the registry')
|
||||
const degs = collapseChanges(prog).map((u) => u.deg).join(',')
|
||||
assert(degs === '0,5,0,8,7', `collapsed degrees [${degs}] ≠ [0,5,0,8,7] — the cycle wrap (last unit === first unit) must collapse too`)
|
||||
})
|
||||
|
||||
const rank12 = rankRelatedProgressions(LOOP_12BAR_A)
|
||||
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]
|
||||
// blues-8bar: same style (+40) + all 3 loop transitions Δ{5,7,10} present (cap 36)
|
||||
// + identical rebased degree-set {0,5,10} (Jaccard 1.0 ×16) − |7−7| length = 92.
|
||||
// Edges blues-quickchange (90; same terms but |7−9|=2 length penalty).
|
||||
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)`)
|
||||
// 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): ii–V–I 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)`)
|
||||
// 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`)
|
||||
})
|
||||
|
||||
// Independent §5-formula replica (expectation-side reimplementation, same
|
||||
// philosophy as §7's pinned truth table — deliberately NOT calling the
|
||||
// component's private helpers). `collapse: false` reproduces the pre-L-51
|
||||
// bug the mandatory collapse step exists to prevent.
|
||||
function replicaScore(loop, prog, sameStyle, { collapse }) {
|
||||
const SUFFIX_Q = { '': 'maj', 'm': 'min', '7': 'dom7', 'maj7': 'maj7', 'm7': 'min7' } // enough for the pinned loops
|
||||
const loopDeg = loopToDegrees(loop)
|
||||
const loopCanon = canonicalDegrees(loopDeg)
|
||||
const trans = (units) => units.map((u, i) => {
|
||||
const b = units[(i + 1) % units.length]
|
||||
return { d: (((b.deg - u.deg) % 12) + 12) % 12, qa: u.quality, qb: b.quality }
|
||||
})
|
||||
const loopT = trans(loop.map((name, i) => ({ deg: loopDeg[i], quality: SUFFIX_Q[name.match(/^[A-G][b#]?(.*)$/)[1]] ?? null })))
|
||||
const units = collapse
|
||||
? collapseChanges(prog)
|
||||
: prog.degrees.map((d, i) => ({ deg: d, quality: prog.qualities[i] ?? null }))
|
||||
const pCanon = canonicalDegrees(units.map((u) => u.deg))
|
||||
const seen = new Set()
|
||||
let shared = 0
|
||||
for (const t of trans(units)) {
|
||||
const k = `${t.d}|${t.qa}|${t.qb}`
|
||||
if (seen.has(k)) continue
|
||||
seen.add(k)
|
||||
if (loopT.some((l) => l.d === t.d && (l.qa == null || t.qa == null || l.qa === t.qa) && (l.qb == null || t.qb == null || l.qb === t.qb))) shared++
|
||||
}
|
||||
const A = new Set(loopCanon.split(',')), B = new Set(pCanon.split(','))
|
||||
const inter = [...A].filter((x) => B.has(x)).length
|
||||
return (pCanon === loopCanon ? 100 : 0) + (sameStyle ? 40 : 0)
|
||||
+ Math.min(shared * 12, 36) + (inter / (A.size + B.size - inter)) * 16
|
||||
- Math.abs(loop.length - units.length)
|
||||
}
|
||||
|
||||
check('pin (c): no-collapse counterfactual — raw blues-12bar would score exactly 39 vs 152 with collapse; replica grounded against the live top blues-8bar (92)', () => {
|
||||
const prog = kb.blues.progressions.find((p) => p.id === 'blues-12bar')
|
||||
// The flagship "this 12-bar IS their 12-bar" relation: 152 with the mandatory
|
||||
// collapse step, 39 without it — proving collapse is what fires the +100 shape
|
||||
// term. blues-12bar is no longer a live *entry* post fix (a) (it's the match,
|
||||
// excluded), so this is a pure-formula check of the replica, not read off the
|
||||
// component. Both remain literal so a scoring-constant tweak turns smoke red.
|
||||
const withCollapse = replicaScore(LOOP_12BAR_A, prog, false, { collapse: true })
|
||||
assert(withCollapse === 152, `replica with collapse scored ${withCollapse} ≠ 152 — the replica drifted from the §5 formula; fix the replica (or the component changed: re-derive BOTH pins)`)
|
||||
const noCollapse = replicaScore(LOOP_12BAR_A, prog, false, { collapse: false })
|
||||
assert(noCollapse === 39, `no-collapse counterfactual scored ${noCollapse} ≠ 39 (36 transitions + 8 half-Jaccard − 5 length) — the raw/collapsed relationship changed; re-derive the counterfactual`)
|
||||
// Ground the replica against the LIVE component via the actual top entry
|
||||
// (blues-8bar, same-style relative): the replica must reproduce its score.
|
||||
const prog8 = kb.blues.progressions.find((p) => p.id === 'blues-8bar')
|
||||
const w8 = replicaScore(LOOP_12BAR_A, prog8, true, { collapse: true })
|
||||
assert(w8 === 92, `replica of blues-8bar scored ${w8} ≠ 92 — the replica drifted from the §5 formula`)
|
||||
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.
|
||||
// A7–E7 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 (A7–E7)`)
|
||||
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 A7–E7')
|
||||
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 Am–C–F 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
|
||||
console.log('')
|
||||
|
||||
@@ -499,6 +499,31 @@ for (const style of styleDirs) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global progression-id uniqueness over the REGISTRY (task C-50) ────────────
|
||||
// RelatedProgressions' ranking excludes the matched progression by ID ALONE
|
||||
// (rankRelatedProgressions: `prog.id !== match.id` — no style comparison, task
|
||||
// L-51), so a progression id repeated anywhere in the registry would silently
|
||||
// drop an unrelated style's entry from the related list. The per-style loop
|
||||
// above walks the FILES (kb/*/progressions.js) into `allIds`; this asserts the
|
||||
// same invariant over kb/index.js — the object the components actually
|
||||
// consume — so a registry wiring mistake the file walk never sees (a style
|
||||
// registered against another style's progressions module, a shared/duplicated
|
||||
// array) cannot reintroduce a duplicate.
|
||||
if (registry) {
|
||||
const seenRegIds = new Map() // id → style it first appeared under
|
||||
for (const style of Object.keys(registry)) {
|
||||
const progs = registry[style]?.progressions
|
||||
if (!Array.isArray(progs)) continue
|
||||
for (const p of progs) {
|
||||
const rw = `kb/index.js [${style}]`
|
||||
if (typeof p?.id !== 'string' || !p.id) { err(rw, 'registry progression with missing/non-string id'); continue }
|
||||
if (seenRegIds.has(p.id))
|
||||
err(rw, `progression id '${p.id}' already registered under style '${seenRegIds.get(p.id)}' — ids must be globally unique across the registry (RelatedProgressions excludes the matched progression by id alone)`)
|
||||
else seenRegIds.set(p.id, style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error(`✗ KB validation failed — ${errors.length} error(s):\n`)
|
||||
for (const e of errors) console.error(' ' + e)
|
||||
|
||||
+293
-45
@@ -1,21 +1,28 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect, lazy, Suspense } from 'react'
|
||||
import AudioCapture from './components/AudioCapture'
|
||||
import ProgressionBanner from './components/ProgressionBanner'
|
||||
import ProgressionSuggestions from './components/ProgressionSuggestions'
|
||||
import Fretboard from './components/Fretboard'
|
||||
import BassFretboard from './components/BassFretboard'
|
||||
import Tuner from './components/Tuner'
|
||||
import Piano from './components/Piano'
|
||||
import Settings from './components/Settings'
|
||||
import DebugView from './components/DebugView'
|
||||
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'
|
||||
import kb from './data/kb/index.js'
|
||||
import { seedableLoop, buildRoulettePool } from './lib/match'
|
||||
import settingIcon from './assets/setting-icon.png'
|
||||
|
||||
// ponytail: collapsible panels most users never open — lazy-load so the core
|
||||
// pitch/chord flow ships in the initial bundle. Suspense fallback null: they
|
||||
// render behind a toggle button, so there's no visible flash.
|
||||
const Tuner = lazy(() => import('./components/Tuner'))
|
||||
const DebugView = lazy(() => import('./components/DebugView'))
|
||||
const DrumView = lazy(() => import('./components/DrumView'))
|
||||
|
||||
const DEFAULTS = {
|
||||
// Key detection
|
||||
noteHistorySize: 2000, // ~60s of notes — stable across a song section
|
||||
@@ -60,6 +67,36 @@ export default function App() {
|
||||
const [showDrumView, setShowDrumView] = useState(false)
|
||||
const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false))
|
||||
|
||||
// ── Jam view (task L-50, one-screen.md §1.1) — pure UI/layout state ──────────
|
||||
// Layer 1: CSS lock — at xl the page root becomes h-screen overflow-hidden and
|
||||
// everything below the dashboard is unmounted. Layer 2: best-effort browser
|
||||
// fullscreen (Promise-caught — a refusal leaves layer 1 fully working). The
|
||||
// toggle never starts/stops listening and never touches audio state.
|
||||
const [jamView, setJamView] = useState(false)
|
||||
|
||||
function enterJamView() {
|
||||
setJamView(true)
|
||||
document.documentElement.requestFullscreen?.()?.catch(() => {})
|
||||
}
|
||||
function exitJamView() {
|
||||
setJamView(false)
|
||||
if (document.fullscreenElement) document.exitFullscreen?.()?.catch(() => {})
|
||||
}
|
||||
|
||||
// Escape and the native fullscreen exit (any means) both restore normal flow —
|
||||
// one state, never half-exited. Listeners active only while jamView.
|
||||
useEffect(() => {
|
||||
if (!jamView) return
|
||||
const onKey = (e) => { if (e.key === 'Escape') exitJamView() }
|
||||
const onFsChange = () => { if (!document.fullscreenElement) setJamView(false) }
|
||||
window.addEventListener('keydown', onKey)
|
||||
document.addEventListener('fullscreenchange', onFsChange)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('fullscreenchange', onFsChange)
|
||||
}
|
||||
}, [jamView]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Jam Guide → Fretboard cross-link (D-03) ──────────────────────────────────
|
||||
// When a Roadmap station is tapped, JamGuide reports its {rootPc, quality}
|
||||
// here and the main Fretboard highlights that chord's guide tones (3rd/7th).
|
||||
@@ -122,6 +159,13 @@ export default function App() {
|
||||
const [detectedProgression, setDetectedProgression] = useState(null)
|
||||
const [selectedChord, setSelectedChord] = useState(null)
|
||||
|
||||
// ── Jam Roulette (task L-60, jam-roulette.md) — pure UI/display state ─────────
|
||||
// seedInfo: provenance of an unconfirmed rolled loop { styleLabel, name, bars }
|
||||
// (null once live detection confirms or replaces it). Invariant (§3.5):
|
||||
// seedInfo !== null ⇔ progressionVoteRef.current?.seeded === true.
|
||||
const [seedInfo, setSeedInfo] = useState(null)
|
||||
const [rouletteMenuOpen, setRouletteMenuOpen] = useState(false)
|
||||
|
||||
// ── Top key candidates (shown as quick-lock chips) ────────────────────────────
|
||||
const [topKeyCandidates, setTopKeyCandidates] = useState([])
|
||||
|
||||
@@ -137,6 +181,13 @@ export default function App() {
|
||||
const progressionVoteRef = useRef(null)
|
||||
const progressionMissRef = useRef(0)
|
||||
const pendingKeyRef = useRef(null)
|
||||
// Roulette session memory (UI-state refs, never read by audio code): the last
|
||||
// 6 rolled progression ids (no-repeat), the last rolled root pc (§2), and the
|
||||
// last resolved style id (for the "Re-roll — {style}" row).
|
||||
const rouletteMemoryRef = useRef([])
|
||||
const prevRootPcRef = useRef(null)
|
||||
const lastRolledStyleRef = useRef(null)
|
||||
const rouletteRef = useRef(null)
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey])
|
||||
@@ -183,7 +234,11 @@ export default function App() {
|
||||
const vote = progressionVoteRef.current
|
||||
if (!detected) {
|
||||
progressionMissRef.current++
|
||||
if (progressionMissRef.current >= NULL_CLEAR) {
|
||||
// L-60 flag 1 (§3.4): a SEEDED card is an instruction, not an observation —
|
||||
// ramp-up nulls (every seed's own effect run is miss 1, and no detection can
|
||||
// land before commit 6) must NOT wipe it. It stays until confirmed, replaced
|
||||
// by a consistently-detected different loop, re-rolled, or New Song.
|
||||
if (progressionMissRef.current >= NULL_CLEAR && !vote?.seeded) {
|
||||
setDetectedProgression(null)
|
||||
progressionVoteRef.current = null
|
||||
}
|
||||
@@ -197,6 +252,9 @@ export default function App() {
|
||||
setDetectedProgression(detected)
|
||||
vote.candidateKey = null
|
||||
vote.candidateCount = 0
|
||||
// L-60 (§3.4): the seed is now a normal committed loop — the first agreeing
|
||||
// live detection confirms it; drop the provenance flag + chip.
|
||||
if (vote.seeded) { vote.seeded = false; setSeedInfo(null) }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -204,11 +262,17 @@ export default function App() {
|
||||
if (vote && vote.candidateKey === key) {
|
||||
vote.candidateCount++
|
||||
} else {
|
||||
progressionVoteRef.current = { committedKey, candidateKey: key, candidateCount: 1 }
|
||||
// L-60 flag 3 (§3.4): preserve `seeded` across candidate rebuilds — a
|
||||
// transient ghost sub-cycle must not strip the flag and resurrect flag 1's
|
||||
// ramp-up kill through the side door.
|
||||
progressionVoteRef.current = { committedKey, candidateKey: key, candidateCount: 1, seeded: vote?.seeded ?? false }
|
||||
}
|
||||
if (progressionVoteRef.current.candidateCount >= (committedKey ? REPLACE_VOTES : COMMIT_VOTES)) {
|
||||
setDetectedProgression(detected)
|
||||
progressionVoteRef.current = { committedKey: key, candidateKey: null, candidateCount: 0 }
|
||||
// L-60 (§3.4/§3.5): a genuinely different loop replaced the seed — the new
|
||||
// vote is detection-owned; end the seed provenance.
|
||||
setSeedInfo(null)
|
||||
}
|
||||
}, [chordHistory])
|
||||
|
||||
@@ -231,6 +295,7 @@ export default function App() {
|
||||
effectiveKeyRef.current = null
|
||||
setChordHistory([])
|
||||
setDetectedProgression(null)
|
||||
setSeedInfo(null) // L-60 (§3.6): the seed clears with the full reset
|
||||
setTopKeyCandidates([])
|
||||
setBpm(null)
|
||||
setMicError(null)
|
||||
@@ -260,6 +325,94 @@ export default function App() {
|
||||
effectiveKeyRef.current = keyInfo
|
||||
}
|
||||
|
||||
// ── Jam Roulette (task L-60, jam-roulette.md §2/§3.1) ────────────────────────
|
||||
// Rolls a random key + interesting KB progression and seeds the EXACT state
|
||||
// live detection writes (lockedKey + detectedProgression + a committed-shape
|
||||
// progressionVoteRef), so the whole dashboard populates as if detected. All
|
||||
// randomness is here (plain Math.random, no audio contact); inputs = the KB
|
||||
// registry only. `styleId === 'surprise'` picks uniformly over the 10 styles.
|
||||
function rollJam(styleId) {
|
||||
const pool = buildRoulettePool(kb) // lazy memo — round-trip passers, len 2–8
|
||||
const sid = styleId === 'surprise'
|
||||
? Object.keys(kb)[Math.floor(Math.random() * Object.keys(kb).length)]
|
||||
: styleId
|
||||
const members = pool.byStyle.get(sid) ?? []
|
||||
if (!members.length) return
|
||||
|
||||
// No-repeat memory (§2.2): exclude the last 6 rolled ids; if that empties the
|
||||
// pool (small styles), fall back to excluding only the immediately previous.
|
||||
const mem = rouletteMemoryRef.current
|
||||
let candidates = members.filter(m => !mem.includes(m.id))
|
||||
if (!candidates.length) {
|
||||
const prev = mem[mem.length - 1]
|
||||
candidates = members.filter(m => m.id !== prev)
|
||||
if (!candidates.length) candidates = members
|
||||
}
|
||||
|
||||
// Weighted draw: weight = levelW × lenW (§2.2). Intermediate leans in (×2),
|
||||
// the collapsed "4-bar-ish" sweet spot 3–7 leans in (×2).
|
||||
const weightOf = (m) => {
|
||||
const levelW = m.progression.level === 'intermediate' ? 2 : 1
|
||||
const lenW = (m.collapsedLen >= 3 && m.collapsedLen <= 7) ? 2 : 1
|
||||
return levelW * lenW
|
||||
}
|
||||
const totalW = candidates.reduce((s, m) => s + weightOf(m), 0)
|
||||
let r = Math.random() * totalW
|
||||
let picked = candidates[candidates.length - 1]
|
||||
for (const m of candidates) { r -= weightOf(m); if (r <= 0) { picked = m; break } }
|
||||
const prog = picked.progression
|
||||
|
||||
// Root: uniform over 12 pcs, SHARP spelling (§2.1 — only sharp names
|
||||
// string-match live detection); don't repeat the previous roll's root.
|
||||
let rolledPc = Math.floor(Math.random() * 12)
|
||||
if (prevRootPcRef.current != null && rolledPc === prevRootPcRef.current) {
|
||||
rolledPc = Math.floor(Math.random() * 12) // re-draw once
|
||||
}
|
||||
const loop = seedableLoop(prog, rolledPc)
|
||||
if (!loop) return // pool guarantees this, but stay defensive
|
||||
prevRootPcRef.current = rolledPc
|
||||
|
||||
// ── The seed writes (§3.1) — clean slate, then key + loop + committed vote ──
|
||||
newSong() // §3.6: roulette = New Song + seed (a stale window poisons handoff)
|
||||
const info = { root: NOTES[rolledPc], mode: prog.mode, confidence: 1 }
|
||||
setLockedKey(info)
|
||||
effectiveKeyRef.current = info // the quickLock precedent — detection uses it NOW
|
||||
chordVotesRef.current = []
|
||||
setDetectedProgression(loop)
|
||||
progressionVoteRef.current = {
|
||||
committedKey: loop.join(','), // the committed shape, L-31's own key
|
||||
candidateKey: null,
|
||||
candidateCount: 0,
|
||||
seeded: true, // the one flag L-60 adds (§3.4)
|
||||
}
|
||||
progressionMissRef.current = 0
|
||||
setSeedInfo({ styleLabel: kb[sid]?.meta?.label ?? sid, name: prog.name, bars: prog.bars })
|
||||
|
||||
// Session bookkeeping (§2.2) — survives New Song deliberately.
|
||||
lastRolledStyleRef.current = sid
|
||||
mem.push(picked.id)
|
||||
while (mem.length > 6) mem.shift()
|
||||
setRouletteMenuOpen(false)
|
||||
}
|
||||
|
||||
// Escape / click-outside closes the roulette menu; focus returns to the button.
|
||||
useEffect(() => {
|
||||
if (!rouletteMenuOpen) return
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setRouletteMenuOpen(false)
|
||||
rouletteRef.current?.querySelector('button')?.focus()
|
||||
}
|
||||
}
|
||||
const onDown = (e) => { if (!rouletteRef.current?.contains(e.target)) setRouletteMenuOpen(false) }
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
}
|
||||
}, [rouletteMenuOpen])
|
||||
|
||||
// ── Waveform handler: feeds oscilloscope / drum view ─────────────────────────
|
||||
const handleWaveform = useCallback((data) => {
|
||||
if (showDebugRef.current || showDrumViewRef.current) {
|
||||
@@ -436,15 +589,15 @@ export default function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface text-white p-3">
|
||||
<div className={`min-h-screen bg-surface text-white p-3 max-sm:p-2${jamView ? ' xl:h-screen xl:overflow-hidden xl:flex xl:flex-col' : ''}`}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<header className="mb-2 flex items-center justify-between">
|
||||
<header className="mb-2 flex items-center justify-between gap-2 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-accent">
|
||||
WhatTheFlat <span className="text-gray-600">♭?</span> <span className="text-amber-400">- JamBuddy</span>
|
||||
</h1>
|
||||
<p className="text-xs text-gray-600">Real-time key detection for live jams</p>
|
||||
<p className="text-xs text-gray-600 hidden sm:block">Real-time key detection for live jams</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
@@ -475,7 +628,7 @@ export default function App() {
|
||||
</header>
|
||||
|
||||
{/* ── Controls bar ── */}
|
||||
<div className="mb-2 flex flex-wrap gap-2 items-center p-2 bg-panel border border-border rounded-xl">
|
||||
<div className="mb-2 relative flex flex-wrap gap-2 items-center p-2 max-sm:p-1.5 bg-panel border border-border rounded-xl">
|
||||
|
||||
{/* Instrument select */}
|
||||
<div className="relative">
|
||||
@@ -576,6 +729,77 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Jam roulette (L-60, jam-roulette.md §1.1/§1.2) ── */}
|
||||
<div className="static sm:relative ml-auto" ref={rouletteRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRouletteMenuOpen(o => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={rouletteMenuOpen}
|
||||
aria-pressed={seedInfo ? true : undefined}
|
||||
title="Jam roulette — roll a random key + progression to jam on"
|
||||
className={`min-h-[32px] px-3 py-1 rounded-lg border text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
seedInfo
|
||||
? 'border-accent/40 text-accent bg-accent/10 font-semibold'
|
||||
: 'border-border text-gray-200 hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
🎲 Jam roulette
|
||||
</button>
|
||||
{rouletteMenuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label="Pick a genre to roll"
|
||||
className="absolute right-0 mt-1 w-56 bg-panel border border-border rounded-xl shadow-lg p-1 z-20"
|
||||
>
|
||||
{seedInfo && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => rollJam(lastRolledStyleRef.current)}
|
||||
className="w-full text-left px-3 py-1.5 rounded-lg text-sm text-gray-200 hover:bg-accent/10 border-b border-border outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
⟳ Re-roll — {seedInfo.styleLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => rollJam('surprise')}
|
||||
className="w-full text-left px-3 py-1.5 rounded-lg text-sm text-gray-200 hover:bg-accent/10 border-b border-border outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
✨ Surprise me
|
||||
</button>
|
||||
{Object.entries(kb).map(([id, s]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => rollJam(id)}
|
||||
className="w-full text-left px-3 py-1.5 rounded-lg text-sm text-gray-200 hover:bg-accent/10 outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{s.meta?.label ?? id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Jam view toggle (L-50, one-screen.md §1.1) — layout-only ── */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={jamView ? exitJamView : enterJamView}
|
||||
aria-pressed={jamView}
|
||||
title={jamView ? 'Exit jam view' : 'Jam view — the one-screen dashboard, fullscreen'}
|
||||
className={`ml-auto min-h-[32px] px-3 py-1 rounded-lg text-sm border transition-all outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
jamView
|
||||
? 'bg-accent/20 border-accent text-accent font-semibold'
|
||||
: 'border-border text-gray-400 hover:border-gray-500 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{jamView ? '✕ Exit' : '⛶ Jam view'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AudioCapture
|
||||
@@ -609,29 +833,17 @@ export default function App() {
|
||||
chordHistory={chordHistory}
|
||||
keyInfo={effectiveKey}
|
||||
detectedProgression={detectedProgression}
|
||||
currentChord={currentChord}
|
||||
seedInfo={seedInfo}
|
||||
onChordClick={setSelectedChord}
|
||||
/>
|
||||
|
||||
{/* ── Instrument + progressions row ── */}
|
||||
<div className="flex gap-3 mb-3 items-stretch">
|
||||
<div className="w-full lg:w-[70%] min-w-0">
|
||||
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} jamFocusChord={jamFocusChord} />}
|
||||
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
||||
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block w-[30%] min-w-0 relative">
|
||||
<div className="absolute inset-0">
|
||||
<ProgressionSuggestions keyInfo={effectiveKey} currentChord={currentChord} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* ── Jam Guide band — always open, right below the instrument row (L-40,
|
||||
D-40 §1: CurrentJamPanel's old slot; the loop shows ONCE, in the
|
||||
banner above). Follows the one global instrument selector. ── */}
|
||||
{/* ── The jam dashboard grid (task L-50, one-screen.md §1/§6): JamGuide
|
||||
owns the two-column layout — LEFT: compact instrument view (chosen
|
||||
here, passed as the mainView slot) + licks strip + the related-
|
||||
progressions slot (RelatedProgressions, task L-51); RIGHT: the
|
||||
suggested-voicings rail. ProgressionSuggestions is unmounted (file
|
||||
kept) — its job split into the rail + RelatedProgressions per the
|
||||
user directive. Follows the one global instrument selector. ── */}
|
||||
<JamGuide
|
||||
detectedProgression={detectedProgression}
|
||||
keyInfo={effectiveKey}
|
||||
@@ -639,8 +851,38 @@ export default function App() {
|
||||
currentChord={currentChord}
|
||||
onFocusChord={setJamFocusChord}
|
||||
instrument={instrument}
|
||||
mainView={
|
||||
<>
|
||||
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} jamFocusChord={jamFocusChord} compact />}
|
||||
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} compact />}
|
||||
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} compact />}
|
||||
</>
|
||||
}
|
||||
relatedSlot={
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* ── Below the dashboard — the learning / behind-the-scenes area (page
|
||||
scroll in normal mode; UNMOUNTED in jam view, one-screen.md §1.1/§3:
|
||||
conditional mount, not `hidden`, so collapsed chrome can't leak
|
||||
height). ── */}
|
||||
{!jamView && (
|
||||
<>
|
||||
{/* ── Loop station ── */}
|
||||
<LoopStation
|
||||
slots={slots}
|
||||
@@ -668,17 +910,19 @@ export default function App() {
|
||||
<span>{showDebug ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{showDebug && (
|
||||
<div className="border-t border-border p-4">
|
||||
<DebugView
|
||||
chroma={debugChroma}
|
||||
chordCandidates={debugCandidates}
|
||||
noteAnalysis={debugNoteAnalysis}
|
||||
waveform={debugWaveform}
|
||||
keyInfo={effectiveKey}
|
||||
currentChord={currentChord}
|
||||
instrument={instrument}
|
||||
monoColor={monoColor}
|
||||
/>
|
||||
<div className="border-t border-border p-4 max-sm:p-3">
|
||||
<Suspense fallback={null}>
|
||||
<DebugView
|
||||
chroma={debugChroma}
|
||||
chordCandidates={debugCandidates}
|
||||
noteAnalysis={debugNoteAnalysis}
|
||||
waveform={debugWaveform}
|
||||
keyInfo={effectiveKey}
|
||||
currentChord={currentChord}
|
||||
instrument={instrument}
|
||||
monoColor={monoColor}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -693,8 +937,10 @@ export default function App() {
|
||||
<span>{showDrumView ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{showDrumView && (
|
||||
<div className="border-t border-border p-4">
|
||||
<DrumView waveform={debugWaveform} bpm={bpm} />
|
||||
<div className="border-t border-border p-4 max-sm:p-3">
|
||||
<Suspense fallback={null}>
|
||||
<DrumView waveform={debugWaveform} bpm={bpm} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -708,7 +954,7 @@ export default function App() {
|
||||
<span>TUNER</span>
|
||||
<span>{showTuner ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{showTuner && <div className="border-t border-border"><Tuner /></div>}
|
||||
{showTuner && <div className="border-t border-border"><Suspense fallback={null}><Tuner /></Suspense></div>}
|
||||
</div>
|
||||
|
||||
{/* ── Knowledge Center — bottom browse & study dock (L-40, D-40 §5) ── */}
|
||||
@@ -719,6 +965,8 @@ export default function App() {
|
||||
onChordClick={setSelectedChord}
|
||||
instrument={instrument}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ function noteColor(isChordTone, isPenta, isScale, mono = false) {
|
||||
return null
|
||||
}
|
||||
|
||||
export default function BassFretboard({ keyInfo, currentChord, monoColor = false }) {
|
||||
// `compact` (task L-50, one-screen.md §2): trimmed card chrome (p-3, legend
|
||||
// merged onto the heading line) + a natural-width cap on the SVG (max-width =
|
||||
// its viewBox width, so it never renders above scale 1.0).
|
||||
export default function BassFretboard({ keyInfo, currentChord, monoColor = false, compact = false }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
|
||||
if (!root) return null
|
||||
@@ -44,19 +47,40 @@ export default function BassFretboard({ keyInfo, currentChord, monoColor = false
|
||||
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
|
||||
: new Set()
|
||||
|
||||
const heading = (
|
||||
<p className={`text-sm text-gray-500 uppercase tracking-widest ${compact ? '' : 'mb-4'}`}>
|
||||
Bass — {root} {mode}
|
||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||
</p>
|
||||
)
|
||||
|
||||
const legend = (
|
||||
// Critic mechanical fix (L-50 gate): non-compact keeps HEAD's exact class
|
||||
// string so the non-compact render stays byte-identical to the committed one.
|
||||
<div className={compact ? 'flex items-center text-xs text-gray-500 flex-wrap gap-3' : 'mt-3 flex gap-5 text-xs text-gray-500'}>
|
||||
<span><span className="text-accent">●</span> Chord tone</span>
|
||||
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
||||
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
||||
Bass — {root} {mode}
|
||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||
</p>
|
||||
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
|
||||
{compact ? (
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||
{heading}
|
||||
{legend}
|
||||
</div>
|
||||
) : (
|
||||
heading
|
||||
)}
|
||||
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{ display: 'block' }}
|
||||
style={{ display: 'block', ...(compact ? { maxWidth: BOARD_W } : null) }}
|
||||
>
|
||||
{/* Fretboard background */}
|
||||
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={3 * STRING_H + 12}
|
||||
@@ -139,11 +163,7 @@ export default function BassFretboard({ keyInfo, currentChord, monoColor = false
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
||||
<span><span className="text-accent">●</span> Chord tone</span>
|
||||
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
||||
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
||||
</div>
|
||||
{!compact && legend}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -757,13 +757,13 @@ export default function ChordDetailModal({ chord, onClose, onChordClick, keyInfo
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/70 backdrop-blur-sm p-4 overflow-y-auto"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/70 backdrop-blur-sm p-4 max-sm:p-2 overflow-y-auto"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="w-full max-w-3xl bg-panel border border-border rounded-2xl shadow-2xl mt-8 mb-8">
|
||||
<div className="w-full max-w-3xl bg-panel border border-border rounded-2xl shadow-2xl mt-8 mb-8 max-sm:my-2">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center justify-between px-6 py-4 max-sm:px-4 max-sm:py-3 border-b border-border">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-accent leading-none">{chord}</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{typeName} chord · tap a voicing to study it</p>
|
||||
@@ -778,7 +778,7 @@ export default function ChordDetailModal({ chord, onClose, onChordClick, keyInfo
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 px-6 pt-4 overflow-x-auto">
|
||||
<div className="flex gap-1 px-6 pt-4 max-sm:px-4 max-sm:pt-3 overflow-x-auto">
|
||||
{[
|
||||
{ key: 'guitar', label: '🎸 Guitar' },
|
||||
{ key: 'piano', label: '🎹 Piano' },
|
||||
@@ -800,7 +800,7 @@ export default function ChordDetailModal({ chord, onClose, onChordClick, keyInfo
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-5">
|
||||
<div className="px-6 py-5 max-sm:px-4 max-sm:py-4">
|
||||
{tab === 'guitar' && <GuitarTab chordName={chord} />}
|
||||
{tab === 'piano' && <PianoTab chordName={chord} />}
|
||||
{tab === 'theory' && <TheoryTab chordName={chord} />}
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
// Standard tuning open-string pitch classes, indexed low-E (0) → high-E (5).
|
||||
const OPEN_PCS = [4, 9, 2, 7, 11, 4] // E A D G B E
|
||||
|
||||
// We render strings top→bottom as high-E first (matches Fretboard.jsx idiom),
|
||||
// so display index 0 = high E, 5 = low E. Data arrays are low-E first, so the
|
||||
// data index for display row `di` is `5 - di`.
|
||||
// We render strings left→right as low-E first (chord-diagram convention:
|
||||
// 6th string on the left, 1st on the right). Data arrays are also low-E first,
|
||||
// so display row index == data index.
|
||||
|
||||
const ACCENT = '#a855f7' // chord-tone tier (root highlight)
|
||||
const DOT = '#e5e7eb' // non-root finger dots (light gray, AA on dark board)
|
||||
@@ -114,10 +114,10 @@ function buildRows(resolved) {
|
||||
// Root pitch class for colouring.
|
||||
const rootPc = resolved.rootPc
|
||||
|
||||
// Convert to display rows (high-E first → reverse of low-E-first).
|
||||
// Convert to display rows (low-E first — display index == data index).
|
||||
const rows = []
|
||||
for (let di = 0; di < NUM_STRINGS; di++) {
|
||||
const dataIdx = NUM_STRINGS - 1 - di
|
||||
const dataIdx = di
|
||||
const f = absLowE[dataIdx]
|
||||
const stringPc = (OPEN_PCS[dataIdx] + (typeof f === 'number' ? f : 0)) % 12
|
||||
const isRoot = typeof f === 'number' && f >= 0 && stringPc === rootPc
|
||||
@@ -153,7 +153,7 @@ export default function ChordDiagram({
|
||||
const svgW = padL + gridW + padR
|
||||
const svgH = padT + gridH + padB
|
||||
|
||||
const stringX = si => padL + si * sw // si: 0 = high E (left) … 5 = low E
|
||||
const stringX = si => padL + si * sw // si: 0 = low E (left) … 5 = high E
|
||||
const fretY = fi => padT + fi * cell // fi: 0 = top line … NUM_FRETS
|
||||
|
||||
if (!built) {
|
||||
@@ -235,14 +235,14 @@ export default function ChordDiagram({
|
||||
x2={stringX(si)}
|
||||
y2={fretY(NUM_FRETS)}
|
||||
stroke={STRING_COL}
|
||||
strokeWidth={(si >= 4 ? 1.4 : si >= 2 ? 1.1 : 0.8) * scale}
|
||||
strokeWidth={(si < 2 ? 1.4 : si < 4 ? 1.1 : 0.8) * scale}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Per-string markers: mute ✕ / open ○ above the nut, dots on the grid */}
|
||||
{rows.map((row, si) => {
|
||||
const x = stringX(si)
|
||||
const dataIdx = NUM_STRINGS - 1 - si
|
||||
const dataIdx = si
|
||||
const finger = fingers ? fingers[dataIdx] : 0
|
||||
|
||||
// Muted string → ✕ above the board.
|
||||
|
||||
@@ -37,7 +37,11 @@ function noteColor(isChordTone, isPenta, isScale, mono = false) {
|
||||
return null
|
||||
}
|
||||
|
||||
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false, jamFocusChord = null }) {
|
||||
// `compact` (task L-50, one-screen.md §2): trimmed card chrome (p-3, legend
|
||||
// merged onto the heading line) + a natural-width cap on the SVG (max-width =
|
||||
// its viewBox width, so it never renders above scale 1.0). No fret reduction,
|
||||
// no transform scaling — the notes stay at their designed size.
|
||||
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false, jamFocusChord = null, compact = false }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
|
||||
if (!root) return null
|
||||
@@ -78,20 +82,49 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
||||
const focusLabel = pc =>
|
||||
pc === focusThird ? '3' : pc === focusSeventh ? focusSeventhLabel : null
|
||||
|
||||
const heading = (
|
||||
<p className={`text-sm text-gray-500 uppercase tracking-widest ${compact ? '' : 'mb-4'}`}>
|
||||
Fretboard — {root} {mode}
|
||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||
{hasFocus && <span className="text-accent ml-2">◎ guide tones</span>}
|
||||
</p>
|
||||
)
|
||||
|
||||
const legend = (
|
||||
// Critic mechanical fix (L-50 gate): non-compact keeps HEAD's exact class
|
||||
// string so the non-compact render stays byte-identical to the committed one.
|
||||
<div className={compact ? 'flex flex-wrap items-center text-xs text-gray-500 gap-3' : 'mt-3 flex flex-wrap gap-5 text-xs text-gray-500'}>
|
||||
<span><span className="text-accent">●</span> Chord tone</span>
|
||||
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
||||
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
||||
{hasFocus && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-full border-2 border-accent"
|
||||
/>
|
||||
Guide tones (3 / {focusSeventhLabel})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
||||
Fretboard — {root} {mode}
|
||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||
{hasFocus && <span className="text-accent ml-2">◎ guide tones</span>}
|
||||
</p>
|
||||
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
|
||||
{compact ? (
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||
{heading}
|
||||
{legend}
|
||||
</div>
|
||||
) : (
|
||||
heading
|
||||
)}
|
||||
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{ display: 'block' }}
|
||||
style={{ display: 'block', ...(compact ? { maxWidth: BOARD_W } : null) }}
|
||||
>
|
||||
{/* Fretboard background */}
|
||||
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={5 * STRING_H + 12}
|
||||
@@ -202,19 +235,7 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-5 text-xs text-gray-500">
|
||||
<span><span className="text-accent">●</span> Chord tone</span>
|
||||
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
||||
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
||||
{hasFocus && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-full border-2 border-accent"
|
||||
/>
|
||||
Guide tones (3 / {focusSeventhLabel})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!compact && legend}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+117
-68
@@ -1,6 +1,17 @@
|
||||
// GlanceRail — ALL loop stations expanded, always (task D-41, per
|
||||
// docs/design/integrated-glance.md §4; supersedes the L-33 playhead accordion).
|
||||
//
|
||||
// D-51 (docs/design/one-screen.md §4/§6.2) adapts the rail to the dashboard's
|
||||
// 500px right column. Margin-hardening arithmetic, worst case = a classic
|
||||
// Windows scrollbar (~17px) inside the column's overflow-y-auto wrapper:
|
||||
// row interior = 500 − 17 (scrollbar) − 18 (section border + p-2)
|
||||
// − 14 (row border + p-1.5) = 451px
|
||||
// guitar cell 89 (75 SVG + p-1.5 + border) → 4/line (374 ≤ 451; a 5th = 469 ✗)
|
||||
// piano 1-octave 156.4 / 2-octave 279.6 → the mixed pair 279.6+6+156.4 = 442
|
||||
// fits with ~9px margin even under the scrollbar (26px without) — the fit the
|
||||
// doc calls fragile at the old paddings is now robust. Without the scrollbar
|
||||
// the interior is 468px; every count above is unchanged.
|
||||
//
|
||||
// One VERTICAL ROW per loop station, canonical KB order (the same order the
|
||||
// banner's loop shows after rotation). Every row renders its full voicing
|
||||
// gallery PERMANENTLY — the playhead HIGHLIGHTS the active row (accent ring +
|
||||
@@ -17,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 0–11 (ChordDiagram fret placement)
|
||||
// keyMode — key mode name (soloScale's minor-key dominant nudge)
|
||||
// showTransitions — default true (the loop caller is unchanged). false → the
|
||||
// voice-leading TransitionChips and the "next" tag are suppressed
|
||||
// (history order is not canonically adjacent, task L-77).
|
||||
|
||||
import { NOTES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory'
|
||||
import ChordDiagram from './ChordDiagram'
|
||||
import MiniPiano from './MiniPiano'
|
||||
import VoicingBrowser from './VoicingBrowser'
|
||||
|
||||
const pcName = (pc) => NOTES[((pc % 12) + 12) % 12]
|
||||
@@ -128,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
|
||||
@@ -149,27 +175,42 @@ function StationRow({
|
||||
`${st.label}${st.rn ? ` (${st.rn})` : ''} — every ${instrument} voicing` +
|
||||
`${isNow ? ', now playing' : ''}${isNext ? ', up next' : ''}`
|
||||
}
|
||||
className={`rounded-lg border p-2 ${stateClass}`}
|
||||
className={`rounded-lg border p-1.5 ${stateClass}`}
|
||||
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
|
||||
@@ -190,31 +231,19 @@ function StationRow({
|
||||
<TransitionChip pair={rail} wraps={wraps} />
|
||||
</div>
|
||||
|
||||
{/* ── Gallery: own-voicing cell + the full dense browser; cells WRAP,
|
||||
never scroll horizontally (D-40 §4). ── */}
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
{(ownGuitar || ownPiano) && (
|
||||
<figure className="flex shrink-0 flex-col items-center gap-1.5 rounded-md border border-accent/60 bg-surface p-2">
|
||||
<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-[300px]">
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
@@ -223,17 +252,21 @@ function StationRow({
|
||||
|
||||
export default function GlanceRail({
|
||||
stations = [], activeIndex = -1, focusedIndex = null, onFocus, instrument, keyRoot, keyMode,
|
||||
showTransitions = true,
|
||||
}) {
|
||||
const n = stations.length
|
||||
if (n === 0) return null
|
||||
const nextIndex = activeIndex >= 0 && n > 1 ? (activeIndex + 1) % n : -1
|
||||
// The "next" tag is a loop-adjacency claim → suppressed for the history group.
|
||||
const nextIndex = showTransitions && activeIndex >= 0 && n > 1 ? (activeIndex + 1) % n : -1
|
||||
|
||||
// Voice-leading rails: rail i leaves station i for station (i+1) mod n — the
|
||||
// last rail wraps back to station 0 (the loop is a wheel). The headline rail
|
||||
// is the 7→3 (voiceLeadingPairs lists the 7th first); a one-chord loop has
|
||||
// no transition to speak of.
|
||||
// no transition to speak of. Suppressed entirely for the history group
|
||||
// (showTransitions=false) — its rows are recent-first, not canonically
|
||||
// adjacent, so a "next F→E" chip would point at the wrong neighbour (L-77).
|
||||
const rails = stations.map((st, i) => {
|
||||
if (n < 2) return null
|
||||
if (!showTransitions || n < 2) return null
|
||||
const next = stations[(i + 1) % n]
|
||||
return voiceLeadingPairs(
|
||||
{ root: st.rootPc, quality: st.quality },
|
||||
@@ -241,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-3"
|
||||
aria-label="Voicing variations — every chord of the loop, all expanded"
|
||||
className="rounded-2xl border border-border bg-panel p-2"
|
||||
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">
|
||||
@@ -258,7 +308,7 @@ export default function GlanceRail({
|
||||
isNow={i === activeIndex}
|
||||
isNext={i === nextIndex}
|
||||
isFocused={focusedIndex === i}
|
||||
onToggleFocus={() => onFocus?.(focusedIndex === i ? null : i)}
|
||||
onToggleFocus={focusable ? (() => onFocus(focusedIndex === i ? null : i)) : null}
|
||||
instrument={instrument}
|
||||
keyRoot={keyRoot}
|
||||
keyMode={keyMode}
|
||||
@@ -268,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>
|
||||
)
|
||||
|
||||
+387
-120
@@ -4,23 +4,30 @@ 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'
|
||||
|
||||
// ─── JamGuide.jsx — the jam BAND + the Knowledge Center DOCK ─────────────────
|
||||
// ─── JamGuide.jsx — the jam-grid OWNER + the Knowledge Center DOCK ───────────
|
||||
//
|
||||
// Task L-40 (per docs/design/integrated-glance.md §5–§6.1) split the old
|
||||
// four-section bottom dock in two:
|
||||
// Task L-50 (per docs/design/one-screen.md §6) promoted the default export from
|
||||
// "band" to the two-column jam dashboard grid:
|
||||
//
|
||||
// default export `JamGuide` — the always-open, zero-chrome jam band, mounted
|
||||
// by App directly below the instrument row (CurrentJamPanel's old slot).
|
||||
// Body: GlanceRail voicings + LicksStrip for the matched loop; heard-live
|
||||
// single gallery when no loop is matched; a slim one-line hint when nothing
|
||||
// is heard. The loop itself renders ONCE, in ProgressionBanner (D-40 §2) —
|
||||
// RoadmapTrack is unmounted (file retired in place, deletion backlogged).
|
||||
// default export `JamGuide` — renders the xl: two-column flex region.
|
||||
// LEFT (flex-1): the `mainView` slot (App keeps choosing Fretboard /
|
||||
// BassFretboard / Piano — JamGuide never imports them), the LicksStrip,
|
||||
// and the `relatedSlot` (RelatedProgressions, mounted by App — null until
|
||||
// L-51). RIGHT (500px): the suggested-voicings rail — GlanceRail /
|
||||
// BassGuideRows / the heard-live fallback — inside the design's ONE
|
||||
// justified internal scroller (height-bounded, NOT sticky, §1). Below xl
|
||||
// the columns stack in jam-following order via display:contents + order
|
||||
// classes: mainView → rail → licks → related (§7; rail unbounds).
|
||||
// The `fill` prop (jam view, §1.1) swaps the rail's viewport-calc bound
|
||||
// for h-full and makes the left column a flex stack whose related slot
|
||||
// absorbs the remainder. The loop itself renders ONCE, in
|
||||
// ProgressionBanner (D-40 §2).
|
||||
// named export `KnowledgeDock` — the bottom collapsible browse/study area:
|
||||
// Explore / Voicings / Licks & Techniques + the shared level filter (the
|
||||
// old dock minus its jam section, which IS the band now).
|
||||
@@ -125,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 : []
|
||||
}
|
||||
@@ -149,7 +164,93 @@ function lickFitsContext(lick, context) {
|
||||
return wanted.length > 0 && tokens.some(t => wanted.includes(t))
|
||||
}
|
||||
|
||||
// ─── JamGuide — the always-open jam band (default export) ─────────────────────
|
||||
// ─── "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:
|
||||
// detectedProgression : string[] | null — the live detected loop (chord names)
|
||||
@@ -158,12 +259,16 @@ function lickFitsContext(lick, context) {
|
||||
// currentChord : string | undefined — most recent committed chord
|
||||
// onFocusChord : fn({rootPc,quality}|null) — Fretboard guide-tone link (D-03)
|
||||
// instrument : 'guitar' | 'piano' | 'bass' — App's global selector
|
||||
// mainView : JSX slot — the compact instrument view (App-chosen; L-50)
|
||||
// relatedSlot : JSX slot — RelatedProgressions (App-mounted; null until L-51)
|
||||
// fill : boolean — jam view (one-screen.md §1.1): the grid fills
|
||||
// App's h-screen column; rail bound becomes h-full
|
||||
|
||||
// The band shows ALL levels — a glance surface filters nothing (D-40 §5); the
|
||||
// level filter lives in the KnowledgeDock only.
|
||||
const ALL_LEVELS = { foundation: true, intermediate: true }
|
||||
|
||||
export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], currentChord, onFocusChord, instrument = 'guitar' }) {
|
||||
export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], currentChord, onFocusChord, instrument = 'guitar', mainView = null, relatedSlot = null, fill = false }) {
|
||||
// Style labels straight from the KB registry, via each style's meta.
|
||||
const styles = useMemo(
|
||||
() => Object.entries(kb).map(([id, style]) => ({ id, label: style?.meta?.label ?? id })),
|
||||
@@ -270,6 +375,10 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
||||
quality: qualities[i] ?? 'maj',
|
||||
label: `${noteName}${suffix}`,
|
||||
rn: prog?.rn?.[i] ?? '',
|
||||
// Fix (a) remap (jam-roulette.md §3.3.2): the RAW authored-play index this
|
||||
// (possibly collapsed) station reads from. Raw matches have no sourceIndex
|
||||
// → identity (i); collapsed matches carry the projection's map.
|
||||
sourceIndex: prog?.sourceIndex?.[i] ?? i,
|
||||
}
|
||||
})
|
||||
if (instrument === 'guitar') {
|
||||
@@ -277,7 +386,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
||||
const play = Array.isArray(plays) ? plays[0] : null
|
||||
const chords = play?.chords ?? []
|
||||
for (let i = 0; i < stations.length; i++) {
|
||||
stations[i].shape = chords[i]?.shape ?? null
|
||||
stations[i].shape = chords[stations[i].sourceIndex ?? i]?.shape ?? null
|
||||
}
|
||||
} else if (instrument === 'piano') {
|
||||
// Authored piano pack first (L-24): the matched style's first piano play
|
||||
@@ -285,7 +394,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
||||
const pianoPlays = kb[match.style]?.instruments?.piano?.plays?.[prog?.id]
|
||||
const play = Array.isArray(pianoPlays) && pianoPlays.length ? pianoPlays[0] : null
|
||||
const authored = play
|
||||
? stations.map((st, i) => recipeVoicing(play.chords?.[i]?.recipe, st.rootPc, st.quality))
|
||||
? stations.map((st, i) => recipeVoicing(play.chords?.[st.sourceIndex ?? i]?.recipe, st.rootPc, st.quality))
|
||||
: null
|
||||
// Computed fallback — only built when needed (no pack, or a recipe that
|
||||
// failed to resolve). Identical to the pre-L-24 computed path.
|
||||
@@ -300,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
|
||||
@@ -343,31 +468,60 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
||||
// re-sorting with the jam (D-31 §2.4).
|
||||
const contextStation = stationVoicings[canonicalPos >= 0 ? canonicalPos : 0] ?? null
|
||||
|
||||
return (
|
||||
<section className="mb-3" aria-label="Jam Guide — follows the loop">
|
||||
|
||||
{/* ── Micro-header — a line, not a button (D-40 §1: zero chrome) ── */}
|
||||
<h3 className="mb-1.5 px-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||
Jam Guide — {headerLabel}
|
||||
{match.matched && keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode}` : ''}
|
||||
</h3>
|
||||
|
||||
{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). */
|
||||
// ── 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' ? (
|
||||
<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 className="flex flex-col gap-3">
|
||||
{/* The voicing rail — ALL stations expanded as vertical rows; the
|
||||
playhead only highlights (D-41, D-40 §4). */}
|
||||
</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>
|
||||
) : (
|
||||
<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}
|
||||
@@ -377,62 +531,125 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
||||
keyRoot={keyRoot}
|
||||
keyMode={keyInfo?.mode}
|
||||
/>
|
||||
<LicksStrip
|
||||
styleId={activeStyle}
|
||||
levels={ALL_LEVELS}
|
||||
instrument={instrument}
|
||||
context={contextStation}
|
||||
/>
|
||||
</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. */
|
||||
instrument === 'bass' ? (
|
||||
<BassGuideRows
|
||||
stations={[{ rootPc: liveChord.rootPc, quality: liveChord.type, label: currentChord, rn: '' }]}
|
||||
activeIndex={0}
|
||||
keyMode={keyInfo?.mode}
|
||||
live
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<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} />
|
||||
</section>
|
||||
{/* No station rn without a loop — context sort falls back to the
|
||||
live chord's quality key (e.g. a "dom7" lick fits a live G7). */}
|
||||
<LicksStrip
|
||||
styleId={activeStyle}
|
||||
levels={ALL_LEVELS}
|
||||
instrument={instrument}
|
||||
context={{ rn: '', quality: liveChord.type, label: currentChord }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
/* Nothing heard yet — one slim line (~40px, D-40 §1): the idle band
|
||||
must not waste main-module space. */
|
||||
<p className="rounded-xl border border-dashed border-border px-3 py-2 text-xs text-gray-500">
|
||||
{detectedProgression?.length
|
||||
? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; voicings follow the next chord that commits.`
|
||||
: 'Play a few bars — voicings and licks for your loop land here.'}
|
||||
</p>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
) : 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={historyStations}
|
||||
activeIndex={-1}
|
||||
keyMode={keyInfo?.mode}
|
||||
live
|
||||
/>
|
||||
) : (
|
||||
<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
|
||||
waste dashboard space. */
|
||||
<p className="rounded-xl border border-dashed border-border px-3 py-2 text-xs text-gray-500">
|
||||
{detectedProgression?.length
|
||||
? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; voicings follow the next chord that commits.`
|
||||
: 'Play a few bars — voicings and licks for your loop land here.'}
|
||||
</p>
|
||||
)
|
||||
|
||||
// ── 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={licksContext}
|
||||
/>
|
||||
) : null
|
||||
|
||||
// ── The grid (one-screen.md §1, §6): left flex-1 / right 500px at xl;
|
||||
// stacked below xl in jam-following order (mainView → rail → licks →
|
||||
// related) via display:contents on the left wrapper + order classes — one
|
||||
// mount per surface, no duplicates (§7). The rail wrapper is the design's
|
||||
// ONE justified internal scroller: height-bounded in normal mode, h-full
|
||||
// in jam view (`fill`), NOT sticky (§1). ──
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
'mb-3 flex flex-col gap-3 xl:flex-row' +
|
||||
(fill ? ' xl:mb-0 xl:flex-1 xl:min-h-0' : ' xl:items-start')
|
||||
}
|
||||
aria-label="Jam Guide — follows the loop"
|
||||
>
|
||||
{/* LEFT — instrument view · licks · related progressions */}
|
||||
<div className={'contents xl:flex xl:flex-col xl:gap-3 xl:flex-1 xl:min-w-0' + (fill ? ' xl:min-h-0' : '')}>
|
||||
{mainView != null && (
|
||||
<div className={'order-1 xl:order-none min-w-0' + (fill ? ' xl:shrink-0' : '')}>
|
||||
{mainView}
|
||||
</div>
|
||||
)}
|
||||
{licksStrip != null && (
|
||||
<div className={'order-3 xl:order-none min-w-0' + (fill ? ' xl:shrink-0' : '')}>
|
||||
{licksStrip}
|
||||
</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 dark-scroll' : '')}>
|
||||
{relatedSlot}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 dark-scroll ' +
|
||||
(fill ? 'xl:h-full' : 'xl:max-h-[calc(100vh_-_1.5rem)]')
|
||||
}
|
||||
>
|
||||
{/* Micro-header — a line, not a button (D-40 §1: zero chrome) */}
|
||||
<h3 className="mb-1.5 px-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||
Suggested voicings — {headerLabel}
|
||||
{match.matched && keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode}` : ''}
|
||||
</h3>
|
||||
{railContent}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -527,10 +744,10 @@ function BassGuideRows({ stations = [], activeIndex = -1, keyMode, live = false,
|
||||
rootPc={st.rootPc}
|
||||
quality={st.quality}
|
||||
nextRootPc={stations[(i + 1) % n].rootPc}
|
||||
pattern={play?.chords?.[i]?.pattern}
|
||||
pattern={play?.chords?.[st.sourceIndex ?? i]?.pattern}
|
||||
playLabel={play?.label}
|
||||
feel={play?.feel}
|
||||
note={play?.chords?.[i]?.note}
|
||||
note={play?.chords?.[st.sourceIndex ?? i]?.note}
|
||||
chordLabel={st.label}
|
||||
/>
|
||||
))}
|
||||
@@ -639,7 +856,7 @@ export function KnowledgeDock({ keyInfo, chordHistory = [], currentChord, onChor
|
||||
<div className="border-t border-border flex flex-col" style={{ height: '70vh' }}>
|
||||
|
||||
{/* ── Section nav (Knowledge Center pills, D-20 §1) ── */}
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-border overflow-x-auto">
|
||||
<div className="flex items-center gap-1 px-4 py-2 max-sm:px-3 border-b border-border overflow-x-auto">
|
||||
{SECTIONS.map(s => {
|
||||
const active = section === s.id
|
||||
return (
|
||||
@@ -662,7 +879,7 @@ export function KnowledgeDock({ keyInfo, chordHistory = [], currentChord, onChor
|
||||
|
||||
{/* ── Explore — KB progression browser + famous progressions ── */}
|
||||
{section === 'explore' && (
|
||||
<div className="flex-1 min-h-0 p-4 overflow-auto">
|
||||
<div className="flex-1 min-h-0 p-4 max-sm:p-3 overflow-auto">
|
||||
<ExploreSection
|
||||
keyInfo={keyInfo}
|
||||
levels={levels}
|
||||
@@ -674,7 +891,7 @@ export function KnowledgeDock({ keyInfo, chordHistory = [], currentChord, onChor
|
||||
|
||||
{/* ── Voicings — picker (follows live chord) → VoicingBrowser ── */}
|
||||
{section === 'voicings' && (
|
||||
<div className="flex-1 min-h-0 p-4 overflow-auto">
|
||||
<div className="flex-1 min-h-0 p-4 max-sm:p-3 overflow-auto">
|
||||
<VoicingsSection
|
||||
keyInfo={keyInfo}
|
||||
chordHistory={chordHistory}
|
||||
@@ -686,7 +903,7 @@ export function KnowledgeDock({ keyInfo, chordHistory = [], currentChord, onChor
|
||||
|
||||
{/* ── Licks & Techniques — per-style LickCard grid ── */}
|
||||
{section === 'licks' && (
|
||||
<div className="flex-1 min-h-0 p-4 overflow-auto">
|
||||
<div className="flex-1 min-h-0 p-4 max-sm:p-3 overflow-auto">
|
||||
<LicksSection styles={styles} levels={levels} onToggleLevel={toggleLevel} />
|
||||
</div>
|
||||
)}
|
||||
@@ -785,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
|
||||
@@ -841,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">
|
||||
|
||||
@@ -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 2–3 (~266–390px)
|
||||
// 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 (8→10 vu) and the bass-ring stroke (2→2.5) so both stay legible at
|
||||
// the smaller render. `thumb`/`full` are byte-untouched — `mini` is additive.
|
||||
// `size="full"` keeps the wide C-anchored 2–3-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>
|
||||
)}
|
||||
|
||||
+32
-12
@@ -37,7 +37,10 @@ function keyColor(isChordTone, isPenta, isScale, isBlack, mono = false) {
|
||||
: { fill: '#f5f5f5', text: '#6b7280' }
|
||||
}
|
||||
|
||||
export default function Piano({ keyInfo, currentChord, monoColor = false }) {
|
||||
// `compact` (task L-50, one-screen.md §2): trimmed card chrome (p-3, legend
|
||||
// merged onto the heading line) + a natural-width cap on the SVG (max-width =
|
||||
// its viewBox width, so it never renders above scale 1.0).
|
||||
export default function Piano({ keyInfo, currentChord, monoColor = false, compact = false }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
if (!root) return null
|
||||
|
||||
@@ -51,15 +54,36 @@ export default function Piano({ keyInfo, currentChord, monoColor = false }) {
|
||||
const svgW = totalWhite * KEY_W + 2
|
||||
const svgH = KEY_H + 20 // +20 for octave labels
|
||||
|
||||
const heading = (
|
||||
<p className={`text-sm text-gray-500 uppercase tracking-widest ${compact ? '' : 'mb-4'}`}>
|
||||
Piano — {root} {mode}
|
||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||
</p>
|
||||
)
|
||||
|
||||
const legend = (
|
||||
// Critic mechanical fix (L-50 gate): non-compact keeps HEAD's exact class
|
||||
// string so the non-compact render stays byte-identical to the committed one.
|
||||
<div className={compact ? 'flex items-center text-xs text-gray-500 flex-wrap gap-3' : 'mt-3 flex gap-5 text-xs text-gray-500'}>
|
||||
<span><span className="text-accent">●</span> Chord tone</span>
|
||||
<span><span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span> Pentatonic</span>
|
||||
<span><span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span> Scale</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
||||
Piano — {root} {mode}
|
||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||
</p>
|
||||
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
|
||||
{compact ? (
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||
{heading}
|
||||
{legend}
|
||||
</div>
|
||||
) : (
|
||||
heading
|
||||
)}
|
||||
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${svgW} ${svgH}`} width="100%" height="auto" style={{ display: 'block' }}>
|
||||
<svg viewBox={`0 0 ${svgW} ${svgH}`} width="100%" height="auto" style={{ display: 'block', ...(compact ? { maxWidth: svgW } : null) }}>
|
||||
|
||||
{/* White keys */}
|
||||
{Array.from({ length: OCTAVES }, (_, oct) =>
|
||||
@@ -132,11 +156,7 @@ export default function Piano({ keyInfo, currentChord, monoColor = false }) {
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
||||
<span><span className="text-accent">●</span> Chord tone</span>
|
||||
<span><span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span> Pentatonic</span>
|
||||
<span><span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span> Scale</span>
|
||||
</div>
|
||||
{!compact && legend}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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={0–11} 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,15 +2,29 @@ import { useRef, useEffect } from 'react'
|
||||
import { toRomanNumeral } from '../lib/theory'
|
||||
import { findLoopPosition } from '../lib/match'
|
||||
|
||||
const HISTORY_SHOWN = 8
|
||||
// ─── ProgressionBanner — the SLIM LOOP STRIP (task L-50, one-screen.md §1.2) ──
|
||||
//
|
||||
// One full-width status row: key chip · chord history (last 5, age-faded) ·
|
||||
// ♻ loop chips. The old right-30% "Now Playing" text-6xl chord + its divider
|
||||
// are GONE per the user directive — the enlarged current history chip and the
|
||||
// accent-glowing active loop chip ARE the now-playing display. Chrome trimmed
|
||||
// (p-4 → p-2, history pb-1 dropped) so the strip lands at ~76px.
|
||||
const HISTORY_SHOWN = 5
|
||||
|
||||
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, currentChord, onChordClick }) {
|
||||
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, seedInfo, onChordClick }) {
|
||||
const { root, mode, confidence } = keyInfo ?? {}
|
||||
|
||||
// Jam-roulette provenance (task L-60, jam-roulette.md §1.3): while a rolled
|
||||
// loop is unconfirmed the loop row swaps its ♻ chrome for 🎲 + an amber chip
|
||||
// naming the source; it flips back to normal the moment live detection
|
||||
// confirms it (App clears seedInfo). The loop chips themselves are identical.
|
||||
const seedBars = Array.isArray(seedInfo?.bars)
|
||||
? seedInfo.bars.reduce((a, b) => a + (b ?? 0), 0)
|
||||
: null
|
||||
|
||||
const visible = chordHistory.slice(-HISTORY_SHOWN)
|
||||
const current = visible[visible.length - 1]
|
||||
const loopPos = findLoopPosition(chordHistory, detectedProgression)
|
||||
const currentRN = root && current ? toRomanNumeral(current, root, mode) : ''
|
||||
|
||||
const currentRef = useRef(null)
|
||||
const prevChord = useRef(null)
|
||||
@@ -25,71 +39,69 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
||||
}, [current])
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-4 mb-3 flex gap-4">
|
||||
<div className="bg-panel border border-border rounded-2xl p-2 mb-3 flex flex-wrap items-end gap-x-3 gap-y-1">
|
||||
|
||||
{/* ── Left: key + chord history + loop ── */}
|
||||
<div className="w-full lg:w-[70%] min-w-0 flex flex-col gap-2">
|
||||
|
||||
{/* Key + history on one row */}
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="shrink-0 flex items-baseline gap-1.5">
|
||||
{root ? (
|
||||
<>
|
||||
<span className="text-2xl font-bold text-accent">{root}</span>
|
||||
<span className="text-gray-400 text-sm">{mode}</span>
|
||||
{confidence && (
|
||||
<span className="text-xs text-gray-600">{Math.round(confidence * 100)}%</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-600 text-sm">Detecting key…</span>
|
||||
{/* ── Key chip ── */}
|
||||
<div className="shrink-0 flex items-baseline gap-1.5 self-center">
|
||||
{root ? (
|
||||
<>
|
||||
<span className="text-2xl font-bold text-accent">{root}</span>
|
||||
<span className="text-gray-400 text-sm">{mode}</span>
|
||||
{confidence && (
|
||||
<span className="text-xs text-gray-600">{Math.round(confidence * 100)}%</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-600 text-sm">Detecting key…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
<div className="w-px h-6 bg-border shrink-0 self-center" />
|
||||
|
||||
{!chordHistory.length ? (
|
||||
<p className="text-gray-600 text-sm">Start listening…</p>
|
||||
) : (
|
||||
<div className="flex items-end gap-1 overflow-x-auto pb-1">
|
||||
{visible.map((chord, i) => {
|
||||
const isCurrent = i === visible.length - 1
|
||||
const age = visible.length - 1 - i
|
||||
const opacity = Math.max(0.25, 1 - age * 0.09)
|
||||
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
ref={isCurrent ? currentRef : null}
|
||||
style={{ opacity }}
|
||||
onClick={() => onChordClick?.(chord)}
|
||||
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 cursor-pointer ${
|
||||
isCurrent
|
||||
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20 hover:bg-accent/20'
|
||||
: 'border border-transparent hover:border-border hover:bg-panel'
|
||||
}`}
|
||||
>
|
||||
<span className={`font-black leading-none tracking-tight ${
|
||||
isCurrent ? 'text-3xl text-accent' : 'text-xl text-gray-200'
|
||||
}`}>
|
||||
{chord}
|
||||
</span>
|
||||
<span className={`text-xs font-semibold mt-0.5 ${
|
||||
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{rn || '\u00A0'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* ── Chord history (the enlarged current chip is the "now") ── */}
|
||||
{!chordHistory.length ? (
|
||||
<p className="text-gray-600 text-sm self-center">Start listening…</p>
|
||||
) : (
|
||||
<div className="flex items-end gap-1 overflow-x-auto">
|
||||
{visible.map((chord, i) => {
|
||||
const isCurrent = i === visible.length - 1
|
||||
const age = visible.length - 1 - i
|
||||
const opacity = Math.max(0.25, 1 - age * 0.09)
|
||||
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
ref={isCurrent ? currentRef : null}
|
||||
style={{ opacity }}
|
||||
onClick={() => onChordClick?.(chord)}
|
||||
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 cursor-pointer ${
|
||||
isCurrent
|
||||
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20 hover:bg-accent/20'
|
||||
: 'border border-transparent hover:border-border hover:bg-panel'
|
||||
}`}
|
||||
>
|
||||
<span className={`font-black leading-none tracking-tight ${
|
||||
isCurrent ? 'text-3xl text-accent' : 'text-xl text-gray-200'
|
||||
}`}>
|
||||
{chord}
|
||||
</span>
|
||||
<span className={`text-xs font-semibold mt-0.5 ${
|
||||
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{rn || ' '}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loop */}
|
||||
{detectedProgression && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs text-gray-500">♻</span>
|
||||
{/* ── Loop — on the same row (divider between); playhead chip = the now ── */}
|
||||
{detectedProgression && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-border shrink-0 self-center" />
|
||||
<div className="flex items-center gap-1.5 flex-wrap self-center">
|
||||
<span className="text-xs text-gray-500">{seedInfo ? '🎲' : '♻'}</span>
|
||||
{detectedProgression.map((chord, i) => {
|
||||
const isActive = i === loopPos
|
||||
const rn = root ? toRomanNumeral(chord, root, mode) : chord
|
||||
@@ -110,31 +122,17 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<span className="text-gray-600 text-xs">→ loop</span>
|
||||
{seedInfo ? (
|
||||
<span className="text-amber-400 text-xs">
|
||||
rolled · {seedInfo.styleLabel} · {seedInfo.name}
|
||||
{seedBars != null ? ` · ${seedBars} bars` : ''} — play it!
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-600 text-xs">→ loop</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Divider ── */}
|
||||
<div className="hidden lg:block w-px bg-border shrink-0" />
|
||||
|
||||
{/* ── Right: big chord ── */}
|
||||
<div className="hidden lg:flex w-[30%] flex-col items-center justify-center gap-1">
|
||||
{current ? (
|
||||
<button
|
||||
onClick={() => onChordClick?.(current)}
|
||||
className="flex flex-col items-center gap-1 px-4 py-2 rounded-xl hover:bg-accent/10 transition-colors group"
|
||||
title="Click to see voicings"
|
||||
>
|
||||
<p className="text-xs text-gray-600 uppercase tracking-widest">Now Playing</p>
|
||||
<div className="text-6xl font-black text-amber-400 leading-none group-hover:text-accent transition-colors">{current}</div>
|
||||
<div className="text-sm text-gray-500">{currentRN}</div>
|
||||
<p className="text-[10px] text-gray-700 group-hover:text-gray-500 transition-colors">tap for voicings</p>
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-gray-600 text-xs text-center">Play a chord</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useMemo } from 'react'
|
||||
import kb from '../data/kb/index.js'
|
||||
import { NOTES, CHORD_TYPES } from '../lib/theory'
|
||||
import {
|
||||
buildLoopIndex,
|
||||
matchLoopToProgression,
|
||||
loopToDegrees,
|
||||
canonicalDegrees,
|
||||
chordRootPC,
|
||||
} from '../lib/match'
|
||||
|
||||
// ─── RelatedProgressions — loop-relative songbook relatives (task L-51) ───────
|
||||
//
|
||||
// 3–5 KB progressions genuinely related to the DETECTED loop, replacing the
|
||||
// retired generic ProgressionSuggestions table. KB-sourced only, loop-relative
|
||||
// by construction. Spec: docs/design/one-screen.md §5 (ranking + render + empty
|
||||
// states) and §6 (component boundary: pure/prop-driven, computes its own match
|
||||
// so it stays file-disjoint from D-51's rail work).
|
||||
//
|
||||
// Props:
|
||||
// loop : string[] | null — the detected repeating progression (chord names)
|
||||
// keyInfo : { root, mode, confidence } | null — effective key (realizes chips)
|
||||
// onChordClick : fn(chordName) — opens ChordDetailModal (App's setSelectedChord)
|
||||
//
|
||||
// The ranking (rankRelatedProgressions) is exported for smoke coverage.
|
||||
|
||||
// Suffix → KB quality token, derived from CHORD_TYPES (the vocabulary
|
||||
// authority — same inverse mapping as match.js's private suffixToQuality; all
|
||||
// 14 suffixes are unique). Unknown suffixes stay unmapped → wildcard (§5:
|
||||
// match on Δ alone).
|
||||
const SUFFIX_TO_QUALITY = Object.fromEntries(
|
||||
Object.entries(CHORD_TYPES).map(([quality, def]) => [def.suffix, quality])
|
||||
)
|
||||
|
||||
// §5 scoring constants.
|
||||
const SCORE_SAME_CHANGES = 100 // same canonical degree shape
|
||||
const SCORE_SAME_STYLE = 40 // same style as the matched progression
|
||||
const SCORE_PER_TRANSITION = 12 // per shared (Δ, quality→quality) transition…
|
||||
const TRANSITION_CAP = 36 // …capped (= 3 distinct transitions)
|
||||
const SCORE_JACCARD = 16 // × rebased degree-set Jaccard
|
||||
export const RELATED_SCORE_FLOOR = 24
|
||||
export const RELATED_MAX_ENTRIES = 5
|
||||
|
||||
// The module-level index (§6: "matchLoopToProgression over a module-level
|
||||
// index is trivially cheap") — built once, shared across renders.
|
||||
const DEFAULT_INDEX = buildLoopIndex(kb)
|
||||
|
||||
// ─── The MANDATORY collapse step (§5 step 2) ──────────────────────────────────
|
||||
// The live detected loop is a COLLAPSED form (detection never commits the same
|
||||
// chord twice in a row), while KB `degrees` are raw, bar-per-bar — blues-12bar
|
||||
// is [0,0,0,0,5,5,0,0,7,5,0,7]. Compared raw, the +100 same-shape term would
|
||||
// never fire for collapse-affected KB progressions. So before
|
||||
// canonicalDegrees(p.degrees) AND before building T(p): collapse consecutive
|
||||
// equal (degree, quality) pairs — including the wrap-around pair (last === first
|
||||
// as a cycle). Each surviving unit keeps the rn of its first bar for the
|
||||
// "shares {rn}→{rn}" annotation.
|
||||
// (L-60 will land a shared collapsed-form index in match.js; this local
|
||||
// collapse is deliberately component-scoped until then — one-screen.md §9.)
|
||||
export function collapseChanges(progression) {
|
||||
const degrees = Array.isArray(progression?.degrees) ? progression.degrees : []
|
||||
const qualities = Array.isArray(progression?.qualities) ? progression.qualities : []
|
||||
const rn = Array.isArray(progression?.rn) ? progression.rn : []
|
||||
const out = []
|
||||
for (let i = 0; i < degrees.length; i++) {
|
||||
const q = qualities[i] ?? null
|
||||
const prev = out[out.length - 1]
|
||||
if (prev && prev.deg === degrees[i] && prev.quality === q) continue
|
||||
out.push({ deg: degrees[i], quality: q, rn: rn[i] ?? '' })
|
||||
}
|
||||
if (out.length > 1) {
|
||||
const first = out[0]
|
||||
const last = out[out.length - 1]
|
||||
if (first.deg === last.deg && first.quality === last.quality) out.pop()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Transition set over units [{deg, quality, rn?}], wrap-around included (§5):
|
||||
// triples (Δ = (deg[i+1] − deg[i]) mod 12, q[i], q[i+1]); rn labels ride along
|
||||
// for the annotation.
|
||||
function transitionsOf(units) {
|
||||
const n = units.length
|
||||
if (n < 2) return []
|
||||
const out = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = units[i]
|
||||
const b = units[(i + 1) % n]
|
||||
out.push({
|
||||
d: (((b.deg - a.deg) % 12) + 12) % 12,
|
||||
qa: a.quality,
|
||||
qb: b.quality,
|
||||
rnFrom: a.rn ?? '',
|
||||
rnTo: b.rn ?? '',
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Live-loop units: degree from loopToDegrees, quality from the chord-name
|
||||
// suffix (unmappable → null = wildcard, matches on Δ alone).
|
||||
function loopUnitsOf(loop, loopDeg) {
|
||||
return loop.map((name, i) => {
|
||||
const m = typeof name === 'string' ? name.match(/^[A-G][b#]?(.*)$/) : null
|
||||
const suffix = m ? m[1] : null
|
||||
return { deg: loopDeg[i], quality: suffix != null ? SUFFIX_TO_QUALITY[suffix] ?? null : null }
|
||||
})
|
||||
}
|
||||
|
||||
// Distinct triples of T(p) matched by some loop transition (wildcard-aware),
|
||||
// in p's canonical order — shared[0] names the annotation.
|
||||
function sharedTransitions(loopT, pT) {
|
||||
const seen = new Set()
|
||||
const shared = []
|
||||
for (const t of pT) {
|
||||
const key = `${t.d}|${t.qa}|${t.qb}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
const hit = loopT.some(
|
||||
l =>
|
||||
l.d === t.d &&
|
||||
(l.qa == null || t.qa == null || l.qa === t.qa) &&
|
||||
(l.qb == null || t.qb == null || l.qb === t.qb)
|
||||
)
|
||||
if (hit) shared.push(t)
|
||||
}
|
||||
return shared
|
||||
}
|
||||
|
||||
// Rebased degree-set Jaccard over the canonical rotations (both sides rebased
|
||||
// so their canonical first element is 0 — rotation-invariant by construction).
|
||||
function degreeSetJaccard(aCanon, bCanon) {
|
||||
const a = new Set(aCanon.split(','))
|
||||
const b = new Set(bCanon.split(','))
|
||||
let inter = 0
|
||||
for (const x of a) if (b.has(x)) inter++
|
||||
const union = a.size + b.size - inter
|
||||
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 2–4).
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* siblingRole(sibling, active) → role phrase | null (same-style-first §3)
|
||||
*
|
||||
* 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`
|
||||
}
|
||||
// 2–4 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)
|
||||
if (!loopDeg || !loopDeg.length) return null
|
||||
const loopCanon = canonicalDegrees(loopDeg)
|
||||
const index = kbRegistry === kb ? DEFAULT_INDEX : buildLoopIndex(kbRegistry)
|
||||
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
|
||||
const progs = kbRegistry[style]?.progressions
|
||||
if (!Array.isArray(progs)) continue
|
||||
for (const prog of progs) {
|
||||
if (!Array.isArray(prog.degrees) || !prog.degrees.length) continue
|
||||
if (match.matched && prog.id === match.id) continue // §5: every p ≠ the matched one
|
||||
const collapsed = collapseChanges(prog)
|
||||
if (!collapsed.length) continue
|
||||
const pCanon = canonicalDegrees(collapsed.map(u => u.deg))
|
||||
const shared = sharedTransitions(loopT, transitionsOf(collapsed))
|
||||
const sameChanges = pCanon === loopCanon
|
||||
const sameStyle = activeStyle != null && style === activeStyle
|
||||
|
||||
let score = 0
|
||||
if (sameChanges) score += SCORE_SAME_CHANGES
|
||||
if (sameStyle) score += SCORE_SAME_STYLE
|
||||
score += Math.min(shared.length * SCORE_PER_TRANSITION, TRANSITION_CAP)
|
||||
score += degreeSetJaccard(loopCanon, pCanon) * SCORE_JACCARD
|
||||
score -= Math.abs(loop.length - collapsed.length)
|
||||
// §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 (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 (Δ, quality→quality) 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,
|
||||
id: prog.id,
|
||||
name: prog.name,
|
||||
level: prog.level === 'intermediate' ? 'intermediate' : 'foundation', // untagged counts foundation
|
||||
score,
|
||||
sameChanges,
|
||||
sameStyle,
|
||||
annotation,
|
||||
role,
|
||||
progression: prog,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort(
|
||||
(a, b) => b.score - a.score || a.style.localeCompare(b.style) || a.id.localeCompare(b.id)
|
||||
)
|
||||
|
||||
// §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 ──────────────────────────────────────────────────────
|
||||
|
||||
// Level chip — ExplorePanel's LevelBadge language (amber = the existing
|
||||
// secondary-tone token; foundation stays quiet; untagged counts foundation).
|
||||
function LevelBadge({ level }) {
|
||||
if (level === 'intermediate') {
|
||||
return (
|
||||
<span className="shrink-0 text-[9px] uppercase tracking-wide font-semibold text-amber border border-amber/40 rounded px-1.5 py-px">
|
||||
intermediate
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="shrink-0 text-[9px] uppercase tracking-wide font-semibold text-gray-400 border border-border rounded px-1.5 py-px">
|
||||
foundation
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// The chord chain realized in the current key — the raw (bar-per-bar) KB form,
|
||||
// truncated to the first 8 + "…" (§5); each chip taps through to
|
||||
// ChordDetailModal via onChordClick (the banner-chip pattern, smaller).
|
||||
const CHAIN_SHOWN = 8
|
||||
|
||||
function ChordChain({ progression, keyRoot, onChordClick }) {
|
||||
const degrees = progression.degrees ?? []
|
||||
const qualities = progression.qualities ?? []
|
||||
const rn = progression.rn ?? []
|
||||
const shown = degrees.slice(0, CHAIN_SHOWN)
|
||||
return (
|
||||
<div className="mt-1 flex items-end gap-1 flex-wrap">
|
||||
{shown.map((deg, i) => {
|
||||
const rootPc = (((keyRoot + deg) % 12) + 12) % 12
|
||||
const label = `${NOTES[rootPc]}${CHORD_TYPES[qualities[i]]?.suffix ?? ''}`
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => onChordClick?.(label)}
|
||||
aria-label={`${label} — open chord detail`}
|
||||
className="flex flex-col items-center px-1.5 py-0.5 rounded-lg border border-border bg-border hover:border-gray-500 transition-all cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<span className="text-xs font-bold leading-none text-gray-300">{label}</span>
|
||||
<span className="text-[10px] text-gray-500">{rn[i] ?? ''}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{degrees.length > CHAIN_SHOWN && <span className="self-center text-xs text-gray-500">…</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RelatedProgressions({ loop, keyInfo, onChordClick }) {
|
||||
const loopKey = Array.isArray(loop) ? loop.join(',') : ''
|
||||
const ranked = useMemo(
|
||||
() => (loopKey ? rankRelatedProgressions(loop) : null),
|
||||
[loopKey] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
)
|
||||
|
||||
// keyInfo.root is a note NAME; chips want a pitch class. Default to C (0)
|
||||
// until a key is known, same fallback JamGuide's stations use.
|
||||
const keyRoot = useMemo(() => {
|
||||
const pc = chordRootPC(keyInfo?.root)
|
||||
return pc >= 0 ? pc : 0
|
||||
}, [keyInfo?.root])
|
||||
|
||||
// Idle — no loop (or unparseable chord names, which rank as no loop).
|
||||
if (!ranked) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-2xl border border-dashed border-border p-3"
|
||||
aria-label="Related progressions"
|
||||
>
|
||||
<p className="text-sm text-gray-500">
|
||||
Loop a progression — related changes from the songbook land here.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const { activeStyle, activeStyleLabel, primary } = ranked
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-2xl border border-border bg-panel p-3"
|
||||
aria-label="Related progressions"
|
||||
>
|
||||
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||
Related progressions · from the songbook
|
||||
{keyInfo?.root ? ` · in ${keyInfo.root}` : ''}
|
||||
</h4>
|
||||
{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’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>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{/* §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>
|
||||
)
|
||||
}
|
||||
@@ -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 flex-col gap-2 sm:flex-row sm:items-center sm: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-full sm: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>
|
||||
)
|
||||
}
|
||||
+142
-100
@@ -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
|
||||
@@ -46,15 +45,32 @@
|
||||
// section chrome (border/panel background/heading — the row header
|
||||
// already names the chord) and suppresses the per-mount
|
||||
// mic-feedback microcopy (the rail shows it ONCE for the whole
|
||||
// rail, D-31 §2.5). Every pre-existing mount renders identically
|
||||
// with no prop.
|
||||
// rail, D-31 §2.5). D-51 (one-screen.md §4) additionally tightens
|
||||
// the DENSE cell spacing for the 500px right column — cell p-2 →
|
||||
// p-1.5 and gallery gap-2 → gap-1.5 — which hardens the column's
|
||||
// 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).
|
||||
// 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).
|
||||
@@ -85,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'
|
||||
@@ -93,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">
|
||||
@@ -123,45 +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).
|
||||
function GalleryCell({ label, playLabel, onPlay, children }) {
|
||||
// `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.
|
||||
// `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 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) => ({
|
||||
@@ -170,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">
|
||||
@@ -228,14 +276,14 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`${name} guitar shapes — every shape shown, each playable`}
|
||||
className="flex flex-wrap items-stretch gap-2"
|
||||
className={`flex flex-wrap items-stretch ${dense ? 'gap-1.5' : 'gap-2'}`}
|
||||
>
|
||||
{guitarShapes.map((shape, i) => (
|
||||
<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" />
|
||||
</GalleryCell>
|
||||
@@ -257,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 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
|
||||
@@ -269,10 +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>
|
||||
@@ -288,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,4 +5,31 @@ body {
|
||||
background-color: #0f0f0f;
|
||||
color: #f5f5f5;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
/* ponytail: mobile PWA full-bleed bg with content cleared from notch / home indicator. viewport-fit=cover lets bg extend under insets; app-shell pads content back in. */
|
||||
overscroll-behavior: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
padding:
|
||||
max(0.75rem, env(safe-area-inset-top))
|
||||
max(0.75rem, env(safe-area-inset-right))
|
||||
max(0.75rem, env(safe-area-inset-bottom))
|
||||
max(0.75rem, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
|
||||
+192
-7
@@ -11,7 +11,7 @@
|
||||
// This file is matching/position logic only. All music-theory primitives
|
||||
// (note names, roman numerals) come read-only from theory.js.
|
||||
|
||||
import { NOTES, NOTES_FLAT, toRomanNumeral } from './theory'
|
||||
import { NOTES, NOTES_FLAT, CHORD_TYPES, toRomanNumeral, canonicalize, detectRepeatingProgression } from './theory'
|
||||
|
||||
// ─── Chord-name parsing (local — theory.js does not export a pitch-class helper) ─
|
||||
|
||||
@@ -91,7 +91,9 @@ export function findLoopPosition(chordHistory, loop) {
|
||||
// for each rotation, re-base so the first element is 0, then pick the
|
||||
// lexicographically smallest resulting sequence. Same loop → same string,
|
||||
// regardless of which chord it starts on.
|
||||
function canonicalDegrees(degrees) {
|
||||
// Exported additively for RelatedProgressions' ranking (task L-51,
|
||||
// one-screen.md §5) — no behaviour change.
|
||||
export function canonicalDegrees(degrees) {
|
||||
const n = degrees.length
|
||||
if (n === 0) return ''
|
||||
let best = null
|
||||
@@ -110,7 +112,9 @@ function canonicalDegrees(degrees) {
|
||||
|
||||
// Converts a loop of chord-name strings into semitone offsets from the loop's
|
||||
// own first chord. Returns null if any chord root is unparseable.
|
||||
function loopToDegrees(loop) {
|
||||
// Exported additively for RelatedProgressions' ranking (task L-51,
|
||||
// one-screen.md §5) — no behaviour change.
|
||||
export function loopToDegrees(loop) {
|
||||
if (!loop?.length) return null
|
||||
const pcs = loop.map(chordRootPC)
|
||||
if (pcs.some(pc => pc < 0)) return null
|
||||
@@ -118,25 +122,108 @@ function loopToDegrees(loop) {
|
||||
return pcs.map(pc => ((pc - tonic) % 12 + 12) % 12)
|
||||
}
|
||||
|
||||
// Suffix of a KB quality token (the display suffix, '' fallback for out-of-vocab
|
||||
// tokens). Name-collapse compares SUFFIXES, not raw quality tokens: two
|
||||
// out-of-vocab qualities both fall back to '' and realize to equal names, so a
|
||||
// token comparison would under-collapse (jam-roulette.md §3.3.2).
|
||||
function suffixOfQuality(quality) {
|
||||
return CHORD_TYPES[quality]?.suffix ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* collapseProjection(progression) → { degrees, qualities, rn, bars, sourceIndex }
|
||||
*
|
||||
* The key-free collapsed shape of a KB progression (fix (a), jam-roulette.md
|
||||
* §3.3.2 / §3.3.3): dedupe adjacent stations whose (degree, suffix) pairs are
|
||||
* equal, then wrap-dedupe (if the last pair equals the first, drop the last).
|
||||
* The projection mirrors detection's collapsed commit stream:
|
||||
* - rn : the first-of-run roman numeral
|
||||
* - bars : summed per run
|
||||
* - sourceIndex[i] : the first RAW station index of collapsed station i — the
|
||||
* remap every authored-play lookup must go through so a collapsed
|
||||
* match reads the right raw play entry (JamGuide §3.3.2).
|
||||
* Name-collapse is provably key-independent (§3.3.3), so this is computed once
|
||||
* with no key in hand.
|
||||
*/
|
||||
function collapseProjection(progression) {
|
||||
const degrees = Array.isArray(progression?.degrees) ? progression.degrees : []
|
||||
const qualities = Array.isArray(progression?.qualities) ? progression.qualities : []
|
||||
const rn = Array.isArray(progression?.rn) ? progression.rn : []
|
||||
const bars = Array.isArray(progression?.bars) ? progression.bars : []
|
||||
const outDeg = [], outQual = [], outRn = [], outBars = [], sourceIndex = []
|
||||
for (let i = 0; i < degrees.length; i++) {
|
||||
const last = outDeg.length - 1
|
||||
if (last >= 0 && outDeg[last] === degrees[i] && suffixOfQuality(outQual[last]) === suffixOfQuality(qualities[i])) {
|
||||
outBars[last] += bars[i] ?? 0
|
||||
continue
|
||||
}
|
||||
outDeg.push(degrees[i])
|
||||
outQual.push(qualities[i])
|
||||
outRn.push(rn[i] ?? '')
|
||||
outBars.push(bars[i] ?? 0)
|
||||
sourceIndex.push(i)
|
||||
}
|
||||
// Wrap-dedupe — only the boundary pair can merge post-collapse (§3.3.2).
|
||||
if (outDeg.length > 1
|
||||
&& outDeg[outDeg.length - 1] === outDeg[0]
|
||||
&& suffixOfQuality(outQual[outQual.length - 1]) === suffixOfQuality(outQual[0])) {
|
||||
outBars[0] += outBars[outDeg.length - 1]
|
||||
outDeg.pop(); outQual.pop(); outRn.pop(); outBars.pop(); sourceIndex.pop()
|
||||
}
|
||||
return { degrees: outDeg, qualities: outQual, rn: outRn, bars: outBars, sourceIndex }
|
||||
}
|
||||
|
||||
/**
|
||||
* Precompute a lookup table from a KB registry (kb/index.js default export).
|
||||
* Returns { byCanonical: Map<canonicalDegrees, entry[]> } where each entry is
|
||||
* { style, id, progression }. Build once, reuse across matches.
|
||||
*
|
||||
* Fix (a) (jam-roulette.md §3.3.2): each progression is indexed by its RAW
|
||||
* degrees AND — when the collapsed shape differs (11/56 today) — by its
|
||||
* collapsed form, whose `progression` is the collapsed PROJECTION (degrees /
|
||||
* qualities / rn / bars / sourceIndex). Collapsed entries are appended AFTER all
|
||||
* raw entries so matchLoopToProgression's strict-`>` disambiguation keeps every
|
||||
* previously-matching input's winner on ties (behaviour-preserving except the
|
||||
* one enumerated strict win, country-145 → its own collapsed form). This makes
|
||||
* a detected COLLAPSED loop (the live commit stream dedupes back-to-back chords)
|
||||
* match its progression — repairing a pre-existing live-detection miss for real
|
||||
* 12-bar / 8-bar streams and enabling the roulette seed to populate the guide.
|
||||
*/
|
||||
export function buildLoopIndex(kb) {
|
||||
const byCanonical = new Map()
|
||||
if (!kb) return { byCanonical }
|
||||
const add = (canon, entry) => {
|
||||
if (!byCanonical.has(canon)) byCanonical.set(canon, [])
|
||||
byCanonical.get(canon).push(entry)
|
||||
}
|
||||
const collapsedPending = []
|
||||
for (const style of Object.keys(kb)) {
|
||||
const progs = kb[style]?.progressions
|
||||
if (!Array.isArray(progs)) continue
|
||||
for (const progression of progs) {
|
||||
if (!Array.isArray(progression.degrees) || !progression.degrees.length) continue
|
||||
const canon = canonicalDegrees(progression.degrees)
|
||||
const entry = { style, id: progression.id, progression }
|
||||
if (!byCanonical.has(canon)) byCanonical.set(canon, [])
|
||||
byCanonical.get(canon).push(entry)
|
||||
add(canonicalDegrees(progression.degrees), { style, id: progression.id, progression })
|
||||
// Collapsed-form entry — only when the collapsed shape differs (a run
|
||||
// collapse strictly shortens length, so a length change ⇔ a real collapse).
|
||||
const proj = collapseProjection(progression)
|
||||
if (proj.degrees.length && proj.degrees.length !== progression.degrees.length) {
|
||||
const collapsedProg = {
|
||||
...progression,
|
||||
degrees: proj.degrees,
|
||||
qualities: proj.qualities,
|
||||
rn: proj.rn,
|
||||
bars: proj.bars,
|
||||
sourceIndex: proj.sourceIndex,
|
||||
}
|
||||
collapsedPending.push({
|
||||
canon: canonicalDegrees(proj.degrees),
|
||||
entry: { style, id: progression.id, progression: collapsedProg },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Append collapsed entries after ALL raw entries (the tie-preservation invariant).
|
||||
for (const { canon, entry } of collapsedPending) add(canon, entry)
|
||||
return { byCanonical }
|
||||
}
|
||||
|
||||
@@ -195,6 +282,10 @@ export function matchLoopToProgression(loop, kbOrIndex) {
|
||||
matched: true,
|
||||
id: best.id,
|
||||
style: best.style,
|
||||
// For a collapsed-form hit best.progression.degrees IS the collapsed shape
|
||||
// (equal length to the loop by construction, fix (a)), so rotation is
|
||||
// computed against the collapsed degrees — the length-mismatch guard never
|
||||
// silently returns 0 for a genuine collapsed match (jam-roulette.md §3.3.2).
|
||||
rotation: rotationToCanonicalOrder(degrees, best.progression.degrees),
|
||||
progression: best.progression,
|
||||
}
|
||||
@@ -220,6 +311,100 @@ function rotationToCanonicalOrder(loopDegrees, kbDegrees) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ─── Jam Roulette — seed realization + the seedable pool (task L-60) ──────────
|
||||
//
|
||||
// jam-roulette.md §3.2 / §2.2. The seed writes the exact loop detection would
|
||||
// commit when the band plays this progression in the rolled key, so the L-31
|
||||
// commit layer treats it like any committed loop.
|
||||
|
||||
/**
|
||||
* seedableLoop(progression, keyRootPc) → string[] | null
|
||||
*
|
||||
* Realize → collapse (+ wrap-dedupe) → canonicalize (§3.2):
|
||||
* 1. Realize each station in the rolled key with SHARP spellings (§2.1 — only
|
||||
* sharp names string-match live detection's noteName path).
|
||||
* 2. Collapse consecutive duplicate NAMES, then drop the last if it equals the
|
||||
* first (the cyclic wrap — the loop's tail flows into its head live).
|
||||
* 3. Canonicalize the rotation with theory.js's own rule, so the seeded string
|
||||
* equals detectRepeatingProgression's canonical output exactly (the L-31
|
||||
* agreement branch compares join(',') strings).
|
||||
* Returns null when the collapsed loop has < 2 names (unrepresentable as a
|
||||
* detected loop — detectRepeatingProgression's min pattern length is 2).
|
||||
*/
|
||||
export function seedableLoop(progression, keyRootPc) {
|
||||
const degrees = Array.isArray(progression?.degrees) ? progression.degrees : []
|
||||
const qualities = Array.isArray(progression?.qualities) ? progression.qualities : []
|
||||
if (!degrees.length) return null
|
||||
const names = degrees.map((deg, i) => {
|
||||
const rootPc = (((keyRootPc + deg) % 12) + 12) % 12
|
||||
return NOTES[rootPc] + suffixOfQuality(qualities[i])
|
||||
})
|
||||
const collapsed = names.filter((n, i) => i === 0 || n !== names[i - 1])
|
||||
if (collapsed.length > 1 && collapsed[0] === collapsed[collapsed.length - 1]) collapsed.pop()
|
||||
if (collapsed.length < 2) return null
|
||||
return canonicalize(collapsed)
|
||||
}
|
||||
|
||||
/**
|
||||
* roundTripPasses(canonicalForm) → boolean (jam-roulette.md §2.2)
|
||||
*
|
||||
* The pool gate: a progression is confirmable iff, with a 32-commit window
|
||||
* filled with repetitions of its seeded canonical form (steady state) and
|
||||
* truncated at EVERY partial-cycle offset (0…len−1), detectRepeatingProgression
|
||||
* returns exactly that form at ALL offsets. Steady-state-plus-all-offsets is the
|
||||
* honest protocol — a jam is sampled mid-cycle, not at cycle boundaries, and a
|
||||
* naive "2 clean cycles" feed gets both failure modes wrong (§2.2). Hard length
|
||||
* bounds 2–8 (the detector only sweeps those lengths) are part of the gate.
|
||||
* Key-independent (§3.3.3): the detector consumes only the name stream's
|
||||
* equality structure, so one sweep in any key (the pool builds in C) covers all.
|
||||
*/
|
||||
export function roundTripPasses(canonicalForm) {
|
||||
if (!Array.isArray(canonicalForm)) return false
|
||||
const len = canonicalForm.length
|
||||
if (len < 2 || len > 8) return false
|
||||
const WINDOW = 32
|
||||
const target = canonicalForm.join(',')
|
||||
for (let offset = 0; offset < len; offset++) {
|
||||
const history = []
|
||||
for (let k = 0; k < WINDOW; k++) history.push(canonicalForm[(offset + k) % len])
|
||||
const detected = detectRepeatingProgression(history)
|
||||
if (!detected || detected.join(',') !== target) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Lazy module-level memo (§2.2): one sweep on first roulette open, reused for
|
||||
// every roll. Key-independent, so the pool is computed once in C.
|
||||
let _roulettePoolMemo = null
|
||||
|
||||
/**
|
||||
* buildRoulettePool(kb) → { byStyle: Map<style, member[]> }
|
||||
* member = { style, id, progression, collapsedLen }
|
||||
*
|
||||
* The roulette pool (§2.2): per style, the progressions whose seedable canonical
|
||||
* form passes the round-trip invariant AND lands in the 2–8 length bounds. An
|
||||
* 11th style or a new progression joins automatically. Randomization (weighting,
|
||||
* no-repeat memory, key) lives in the caller (App.rollJam) — this is the pure,
|
||||
* audio-free eligibility set.
|
||||
*/
|
||||
export function buildRoulettePool(kb) {
|
||||
if (_roulettePoolMemo) return _roulettePoolMemo
|
||||
const byStyle = new Map()
|
||||
for (const style of Object.keys(kb ?? {})) {
|
||||
const progs = kb[style]?.progressions
|
||||
if (!Array.isArray(progs)) { byStyle.set(style, []); continue }
|
||||
const eligible = []
|
||||
for (const progression of progs) {
|
||||
const form = seedableLoop(progression, 0) // key C — key-independent (§3.3.3)
|
||||
if (!form || !roundTripPasses(form)) continue
|
||||
eligible.push({ style, id: progression.id, progression, collapsedLen: form.length })
|
||||
}
|
||||
byStyle.set(style, eligible)
|
||||
}
|
||||
_roulettePoolMemo = { byStyle }
|
||||
return _roulettePoolMemo
|
||||
}
|
||||
|
||||
// ─── Roman-numeral helpers (re-exported for callers that only need matching) ───
|
||||
|
||||
/**
|
||||
|
||||
+173
-1
@@ -576,7 +576,9 @@ function matchLoopOccurrence(win, start, cand) {
|
||||
|
||||
// Returns the lexicographically smallest rotation so the same loop always
|
||||
// produces the same string regardless of where in the cycle we currently are.
|
||||
function canonicalize(pattern) {
|
||||
// Exported additively for match.js's seedableLoop / round-trip pool (task L-60,
|
||||
// jam-roulette.md §3.2) — the seed must canonicalize identically to detection.
|
||||
export function canonicalize(pattern) {
|
||||
let best = pattern
|
||||
for (let i = 1; i < pattern.length; i++) {
|
||||
const rot = [...pattern.slice(i), ...pattern.slice(0, i)]
|
||||
@@ -744,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
|
||||
// Am–C–F 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 (Am–C–F 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)
|
||||
}
|
||||
|
||||
+30
-2
@@ -1,9 +1,37 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['apple-touch-icon.png', 'icon-192.png', 'icon-512.png'],
|
||||
manifest: {
|
||||
name: 'JamBuddy — WhatTheFlat',
|
||||
short_name: 'JamBuddy',
|
||||
description: 'Real-time key and chord detection for live jams.',
|
||||
theme_color: '#0f0f0f',
|
||||
background_color: '#0f0f0f',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
start_url: './',
|
||||
scope: './',
|
||||
icons: [
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2,webmanifest}'],
|
||||
navigateFallback: 'index.html',
|
||||
},
|
||||
}),
|
||||
],
|
||||
base: './', // relative paths so Electron can load files from disk
|
||||
server: {
|
||||
// Explicit IPv4 bind: with plain `localhost`, node ≥17 can bind ::1 only,
|
||||
@@ -14,4 +42,4 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user