12 Commits

Author SHA1 Message Date
itsamejms 41751a1a58 flipping the vertical chord diagrams for guitar 2026-07-14 09:55:57 +01:00
itsamejms ce22561813 minor small screen optimizations 2026-07-13 22:13:40 +01:00
itsamejms 436522f3b4 Merge branch 'sprint-jamguide-piano' 2026-07-13 21:51:24 +01:00
itsamejms 077925f1d7 merging from sprint-jamguide-piano 2026-07-13 21:49:45 +01:00
vadimwit 072920ff0e docs+config: never-delete policy — CLAUDE.md hard rule + .claude/settings.json deny list
Claude must never execute destructive/irreversible commands (rm, git branch
-d/-D, git push --delete, reset --hard, force-push, DROP, etc.) — it proposes
them for the user to run. Enforced behaviorally in CLAUDE.md (authoritative)
and as permissions.deny rules in .claude/settings.json (defense-in-depth).

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

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

User directive 2026-07-13: "highlight the loop chords when it finds a loop
but also add the other chords underneath ... at least 4 or more."

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:46:10 +01:00
vadimwit ede3f2e372 docs(design): related-area v2 (task D-76) — multi-chord rail + try-this 3x3; returned once (add9 shape count), fixed 2026-07-13 16:57:30 +01:00
vadimwit dc503807ef ledger: D-76/L-77/L-78 — related-area v2 (rail always-multi-chord most-recent-first; try-this 3-cap stable 3x3 guitar) 2026-07-13 16:42:19 +01:00
itsamejms b8c955570e updating to make it more mobile optimized, making it a pwa, and testing it out on a phone 2026-07-12 20:38:58 +01:00
24 changed files with 4959 additions and 287 deletions
+35
View File
@@ -0,0 +1,35 @@
{
"$comment": "Team-shared restricted options. Destructive/irreversible commands are DENIED so Claude proposes them for the user to run instead of executing them. Authoritative policy: see the 'Destructive operations — NEVER delete' section of CLAUDE.md.",
"permissions": {
"deny": [
"Bash(rm:*)",
"Bash(rmdir:*)",
"Bash(git branch -d:*)",
"Bash(git branch -D:*)",
"Bash(git branch --delete:*)",
"Bash(git push --delete:*)",
"Bash(git push -d:*)",
"Bash(git push origin --delete:*)",
"Bash(git push github --delete:*)",
"Bash(git push origin -d:*)",
"Bash(git push github -d:*)",
"Bash(git tag -d:*)",
"Bash(git tag --delete:*)",
"Bash(git remote remove:*)",
"Bash(git remote rm:*)",
"Bash(git reset --hard:*)",
"Bash(git clean -f:*)",
"Bash(git clean -d:*)",
"Bash(git clean -x:*)",
"Bash(git push --force:*)",
"Bash(git push -f:*)",
"Bash(git push --force-with-lease:*)",
"Bash(git checkout --:*)",
"PowerShell(Remove-Item:*)",
"PowerShell(rm:*)",
"PowerShell(del:*)",
"PowerShell(rmdir:*)",
"PowerShell(Clear-Content:*)"
]
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"projects": {
"default": "itsamejms"
}
}
+5
View File
@@ -13,3 +13,8 @@ debug.log
releases/
release/
.DS_Store
# Firebase
.firebase/
firebase-debug.log
ui-debug.log
+18
View File
@@ -2,6 +2,24 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Destructive operations — NEVER delete (hard rule)
**Claude must never EXECUTE a destructive or irreversible command. Always ask, and hand the user the exact command(s) to run themselves.**
This covers (non-exhaustively):
- Deleting files/directories: `rm`, `rm -rf`, `rmdir`, `del`, `Remove-Item`.
- Deleting branches: `git branch -d` / `-D`, `git push --delete`, `git push <remote> :branch`.
- Deleting tags/remotes: `git tag -d`, `git remote remove` / `rm`.
- Discarding work: `git reset --hard`, `git checkout -- <path>`, `git clean -f`.
- Force-pushing: `git push -f` / `--force` / `--force-with-lease`.
- Dropping data: `DROP`, `TRUNCATE`, destructive migrations.
Instead: print the command(s) in a fenced block with a one-line note on what each does and what it affects, and let the **user run them**. Never run them yourself, even when the desired outcome is clear — `rm` and `-d` are **prompted, never executed**.
Leave regular branches alone (`main`, the active sprint branch) unless the user explicitly names them. Before calling any branch "stale", prove containment (`git branch --merged`, 0 unique commits) and report that evidence — do not act on it.
These are also enforced as `permissions.deny` rules in `.claude/settings.json` (defense-in-depth), but this behavioral rule is authoritative and covers cases the patterns can't.
## Commands
```bash
+6
View File
@@ -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
+7 -1
View File
@@ -83,7 +83,10 @@ Standing principles (memory): scroll > click; nothing duplicated; one global ins
| D-75 | **Related-area space layout** (user 2026-07-13, after seeing it — "we have enough space in that area… fit everything in one big screen"): (a) TRY THIS — show the current chord's up-to-4 substitutions SIDE BY SIDE (replaces the one-at-a-time rotation; user has seen rotation + prefers all-visible), each a small card = chord label + a mini instrument diagram of that chord's voicing (ChordDiagram guitar / MiniPiano piano, following the GLOBAL instrument selector; bass = honest simple) + a compact why + tap→ChordDetailModal; (b) SUGGESTED PROGRESSIONS (RelatedProgressions) — 2×2 grid instead of the vertical list. Honest space math for the LEFT column (~744px, wider than the 500px rail). Instrument prop threading for TryThis (App mount passes `instrument`). Bounded L-75/L-76 (disjoint: TryThis.jsx+App-mount vs RelatedProgressions.jsx) | design | ready | — | `docs/design/related-area-layout.md` | side-by-side try-this with per-sub diagrams + 2×2 related spec'd with honest column math; instrument-follow named; L-75/L-76 bounded |
| L-75 | Implement TryThis side-by-side + per-sub instrument diagrams per D-75 (drops the rotation; follows the loop/current chord; instrument-following diagram) | engineering | done `957ce88` (combined-gate PASS) | D-75 | `src/components/TryThis.jsx`, `src/App.jsx` (pass `instrument` to the mount — grep-clean) | up-to-4 subs side by side each with a playable-shape diagram in the current instrument; tap→modal; honest empties; build+smoke green |
| L-76 | Implement RelatedProgressions 2×2 grid per D-75 (same-style + cross-style sections in a 2-col grid using the left-column width) | engineering | done `e695716` (combined-gate PASS) | D-75 | `src/components/RelatedProgressions.jsx` | 2×2 layout; both sections adapt; no logic/scoring change; build+smoke green |
| C-70 | Sprint-end sweep + PR update (BOTH gitea+github). Fold: drop stale ", each playable" from VoicingBrowser aria (:278/:314, combined-gate finding) | quality | backlog | L-70, L-71, L-72, L-74, L-75, L-76 | `src/components/VoicingBrowser.jsx` (aria one-liner) | all green; aria fixed; PRs updated |
| D-76 | **Related-area v2** (user 2026-07-13, "one last change"): (a) VOICINGS RAIL — currently shows all loop stations when a loop matches, but only the SINGLE current chord in the heard-live/no-loop fallback (JamGuide railContent :373-424). User wants: ALWAYS show multiple chords' voicings, MOST RECENT FIRST — loop chords when matched (KEEP canonical order + a moving "now" playhead highlight — design call, reordering would break GlanceRail's between-adjacent voice-leading chips AND reshuffle rows every chord, contradicting the user's anti-jump preference; most-recent-first applies to the NO-LOOP history rail), else the recent distinct chord history (from chordHistory, cap ~6, most-recent-first). Never just one. **User confirm pending on the loop-ordering deviation (asked 2026-07-13).** (b) TRY THIS — cap at **3** suggestions (was 4); **STABLE fixed layout** (reserve 3 slots so 2-vs-3 subs never shifts position — the user: "annoying when the layout changes then u dont know where to look"); each suggestion shows **≥3 ways to play it** — guitar = **3×3** (3 subs × up to 3 getGuitarVoicings shapes each), piano = **1** MiniPiano each ("for piano it can be just one thats okay"), bass = root caption. Honest space math (left col ~744px for the 3×3 guitar; rail column for the multi-chord history). No user gate: pick strongest, ≥2 rejected alts | design | ready | — | `docs/design/related-area-v2.md` | rail always-multi-chord-most-recent-first spec'd; try-this 3-cap + stable-3-slot + 3-shapes-guitar/1-piano with honest math; L-77/L-78 bounded |
| L-77 | Voicings rail — HYBRID (user re-scope 2026-07-13): when a loop is found, HIGHLIGHT the loop chords as a "loop" group AND list the OTHER recent distinct chords from full chordHistory underneath ("also played"); when no loop, just the recent distinct history. Show **≥4 chords total** as soon as any chord history exists (top up from history to reach 4+). Loop group keeps canonical order + moving "now" playhead (voice-leading chips valid); history group most-recent-first, no chips. Each chord expands to its full voicing gallery (keep composition) | engineering | done `9415daf` (direct push per user) | D-76 | `src/components/JamGuide.jsx`, `src/components/GlanceRail.jsx` | loop highlighted + others underneath; ≥4 when history exists; no-loop → history ≥4 most-recent-first; galleries + focus preserved; audio contract untouched; build+validate+smoke green — **done** (GlanceRail +`showTransitions` prop (default true, byte-compat); JamGuide `recentDistinctChords`/`historyStations` memo + hybrid railContent; RAIL_TOTAL_CAP 8 / NO_LOOP_HISTORY_CAP 6; 903/903; only 2 locked files, contract-clean. Maestro folded 2 honesty fixes on shared GlanceRail: history group drops "the loop"/"playhead" framing; history rows non-focusable (no inert button). Full independent Critic gate waived per user "push when its done") |
| L-78 | Try-this per D-76: max 3 subs, stable 3-slot layout, 3 guitar shapes each (3×3) / 1 piano each | engineering | done `d543b98` (direct push per user 2026-07-13; verified build+validate+smoke 903/903 — full independent Critic gate waived for the push) | D-76 | `src/components/TryThis.jsx` (+ per doc) | ≤3 subs in fixed slots; guitar 3 shapes/sub; piano 1; layout stable 2-vs-3; reactivity preserved; build+smoke green |
| C-70 | Sprint-end sweep + PR update (BOTH gitea+github). Fold: drop stale ", each playable" from VoicingBrowser aria (:278/:314, combined-gate finding) | quality | backlog | L-70, L-71, L-72, L-74, L-75, L-76, L-77, L-78 | `src/components/VoicingBrowser.jsx` (aria one-liner) | all green; aria fixed; PRs updated |
> Sequencing (three parallel chains, file-disjoint): (A) D-70 → L-70 → L-71 (rail/licks); (B) D-72✓ → L-72 (related, same-style); (C) D-73 → L-73 → L-74 (try-this subs). ALL DONE except A's builds. **L-74b (2026-07-13, in-review):** TryThis loop-fallback so the card is visible for a rolled/detected loop without live input (user "i dont see rotating cards") — TryThis.jsx only, SSR-verified. **Batch plan:** L-70 ‖ L-71 ‖ L-74b (disjoint) → ONE combined gate (rail/licks + dock byte-identity + full-app SSR + contract) → commit all → push both remotes. C-70 closes.
@@ -304,6 +307,9 @@ Emphasis this sprint: **ship the Jam Guide MVP** (put the 8 guitar style packs o
_(Maestro appends one line per completed iteration: `<date> · <task ids done> · <next>`.)_
- 2026-07-13 · **sprint-dashboard-polish — direct push (user "push everything now")** · done: L-78 (`d543b98` Try-this 3×3 ways-to-play — up to 3 guitar grips / 1 piano per suggestion, fixed anti-jump 3-slot frame; verified build+validate-KB+smoke 903/903; full independent Critic gate waived at user request) · L-77 re-scoped SAME DAY per user: rail should HIGHLIGHT loop chords when a loop is found AND list the other recent chords underneath, ≥4 total once history exists (was: replace-with-history). Rebuilding + pushing per "add this to the branch also and push when its done" · next: build L-77 (hybrid loop+history rail) → verify → push both remotes
- 2026-07-13 · **L-77 done + pushed** (`9415daf`) · hybrid voicings rail: loop group highlighted ("the loop") + "also played" recent-distinct chords underneath (most-recent-first, ≥4 once history exists), no-loop → history rail replaces the single-chord fallback; GlanceRail gains `showTransitions` (default true, loop byte-unchanged). Maestro verified build+validate+smoke 903/903 and folded 2 honesty fixes on the shared GlanceRail (history group no longer says "loop"/"playhead"; history rows non-focusable → no inert button). App.jsx/audio untouched. Pushed both remotes · next: C-70 sprint-end sweep (aria one-liner + PR updates) when user confirms the rail looks right
- 2026-06-14 · done: M-01 · in-review (awaiting user pick): D-00a/b/c viz concepts · next: D-SEL (user chooses) → then L-01/D-01/D-02 implement chosen concept
- 2026-06-15 · done: D-00a/b/c, D-SEL (Roadmap chosen), L-01 (match.js + banner refactor), L-01b (guideTones/voiceLeadingPairs/soloScale) — Critic PASS both · next: L-02 (JamGuide shell) → D-01 (RoadmapTrack) ‖ D-01b (ChordDiagram)
- 2026-06-15 · done: L-02 (JamGuide shell + App mount), D-01 (RoadmapTrack), D-01b (ChordDiagram), D-02 (assembly + playhead rotation + thumbnails) — Critic PASS all 4, audio contract intact (App.jsx +10/0) · **Roadmap Jam Guide MVP is live & builds green** · next: D-03 (fretboard cross-link, backlog) ‖ P-01 (gospel content) ‖ H-01 (README reframe)
+196
View File
@@ -0,0 +1,196 @@
# Related-area v2 — always-multi-chord voicings rail + stable 3×3 Try-this (task D-76)
**Sprint:** `sprint-dashboard-polish` · **Owner:** Muse · **Impl tasks:** L-77 (voicings rail: GlanceRail.jsx + JamGuide.jsx) · L-78 (TryThis.jsx). Concept doc only — no code here. Supersedes the relevant parts of `related-area-layout.md` (D-75) that this refines.
**User ask (2026-07-13, verbatim — "one last change then we push everything"):**
> "for the right part 'different voicings' there is currently only one chord visible, we said to show all chords in the loop with the most recent first. at all times we wanna see all different voicings of the different chords. also for the TRY THIS part please always keep the layout the same if it's 2 or 3 chords i want it to stay in place its annoying when the layout changes then u dont know where to look. max it on 3 TRY THIS suggestions, PLUS please add at least 3 ways to play it. i think we have enough space for 3x3 guitar. for piano it can be just one thats okay."
Two independent surfaces:
- **(A) The voicings RAIL** (right column, `JamGuide` `railContent`) must show **multiple chords, most recent first, at all times** — the "only one chord visible" is the no-loop heard-live fallback.
- **(B) TRY THIS** (left column) caps at **3** suggestions, holds a **stable fixed layout** (2-vs-3 subs never shifts position), and gives each suggestion **≥3 ways to play it** — a **3×3** guitar grid, **1** piano keyboard, bass root caption.
---
## 0. Where "only one chord" actually comes from (say it plainly)
`JamGuide` `railContent` (JamGuide.jsx:373-435) has **three** states:
1. `match.matched``<GlanceRail stations={stationVoicings} …/>`**already** shows every loop chord as a vertical row (canonical KB order, playhead highlight, full per-row gallery). This is NOT the bug.
2. `liveChord` (no KB match but a chord is committing, JamGuide.jsx:252) → a **single** `<VoicingBrowser rootPc quality show dense/>` inside a "Heard live · {chord} — every voicing" section (JamGuide.jsx:411-425). **This is the "only one chord."**
3. else → the slim idle line.
So the fix is scoped to state 2 (and the idle-with-history case): replace the single-chord fallback with a **multi-chord, most-recent-first history rail**. State 1 (the loop) already satisfies "all chords of the loop" — the open question is only whether to physically reorder it most-recent-first (§1.3: no, and why).
---
## 1. The voicings rail — always multiple chords, most recent first
### 1.1 Decision: keep GlanceRail-for-loop + a new history fallback — do NOT fully unify
**Recommendation: keep the two paths, replace only the single-chord fallback.**
- **Loop matched** → `GlanceRail` as today (canonical order, playhead, per-row gallery, the D-40 voice-leading education). Untouched.
- **No loop** → a **new `HistoryRail`** driven by the recent DISTINCT `chordHistory`, most-recent-first, each rendered as a GlanceRail-style voicing row.
**Why not unify** (always drive the rail from recent-distinct-history, loop adds highlighting): the loop rail's education is *canonical-order-dependent*. `GlanceRail` computes `voiceLeadingPairs` between **canonically adjacent** stations (GlanceRail.jsx:234-241) and renders per-row `TransitionChip`s ("next F→E · ½ step down") plus the wrap chip ("loop") — a voice-leading **wheel**. Reordering those rows most-recent-first would make every "next …" chip point at the wrong neighbour, and the wrap chip lie. Unifying therefore *destroys* the loop education to satisfy a directive the loop already meets another way (§1.3). So: two paths, one shared row idiom, no loss.
This is also the **minimal, lowest-regression** change: `StationRow` (loop) stays byte-identical; the new `HistoryRow`/`HistoryRail` is additive.
### 1.2 The no-loop history rail — data source & most-recent-first
`chordHistory` is already a `JamGuide` prop (JamGuide.jsx:186; App passes it at App.jsx:846) — **no App change needed.** Build the rail entirely from it:
```
recentDistinct(chordHistory, cap = 6):
seen = new Set(); out = []
for name from END of chordHistory backwards: // newest first
parsed = parseChord(name) // voicings.js — same parser JamGuide already imports (line 12)
if (!parsed) continue // unparseable → skip, no crash
if (seen.has(name)) continue // DISTINCT by chord name (subsumes consecutive-dedup)
seen.add(name); out.push({ ...parsed, label: name })
if (out.length === cap) break
return out // out[0] = the chord sounding now
```
**"Most recent first" mapping:** walk `chordHistory` from the tail (the newest commit is `chordHistory[len-1]` = `currentChord`, App.jsx:572). `out[0]` is therefore the current chord, `out[1]` the previous distinct chord, etc. **Distinct-by-name** (not merely consecutive-dedup) so "F Am F Am F" yields `[F, Am]` — the user wants the *different chords*, each once, not a ping-pong; the most-recent occurrence fixes each chord's slot. Cap **6** rows.
Each entry parses to `{ rootPc, type, label }` (voicings.js `parseChord` returns `{ rootPc, type }`). Feed each as a row.
**Honest edges:**
- **1 chord ever played** → 1 row. Unavoidable (there is genuinely one chord) — but it is now a *row in the rail idiom*, not a bespoke single-chord section. Strictly better than today, which also showed one.
- **20 chords** → the 6 most-recent distinct, newest first. Older ones roll off (the rail is a live window, not a log — the KnowledgeDock Voicings section is the browse-everything surface).
- **All same chord** (F F F F) → 1 row.
- **Unparseable name** → skipped; if *nothing* parses → fall through to the existing slim idle line (state 3), unchanged.
### 1.3 What "most recent first" means for the LOOP (honest)
For the loop, `GlanceRail` stays **canonical KB order** and the **playhead** (accent ring + "now" badge + `aria-current`, GlanceRail.jsx:184-188) marks the chord you're on. That IS "most recent, unmistakable" — it is exactly the active-chord-at-a-glance the DoD demands — without reordering. Physically reordering the loop rows most-recent-first is **rejected** (§6.1) because it breaks the voice-leading wheel. If the user, after seeing this, still wants the loop *physically* re-sequenced, that is a follow-up that must first rework/retire the `TransitionChip` education — flag to Maestro, don't silently do it.
### 1.4 The HistoryRail row anatomy (reuse, minus loop chrome)
A new `HistoryRow` (sibling of `StationRow` in GlanceRail.jsx — keep `StationRow` untouched for the loop). Per row, most-recent-first:
- **Header line:** `st.label` (plain bold text — no focus-toggle button; history chords aren't stations, so no fretboard-focus wiring → simpler, and the main Fretboard already follows `currentChord`). `SoloLabel` + `AimDots` **kept** (GlanceRail.jsx exported atoms) — those are *per-chord* guide-tone education, correct for any chord, and give the history rail visual parity with the loop rail. **No** `TransitionChip` (no canonical adjacency), **no** "next" tag.
- **"now" marker on row 0** (the most-recent chord): the same `border-accent bg-accent/10 ring-2 ring-accent` + "now" badge `StationRow` uses for the playhead — so the active chord is unmistakable (DoD). Rows 1-5 recede to the `opacity: 0.85` floor, exactly as inactive loop rows.
- **Gallery — as-is:** `<VoicingBrowser rootPc={st.rootPc} quality={st.type} show={instrument} dense max={4} />`. Guitar ≤4 recommended-first shapes on one line, piano 2×2 mini, **no ▶** — the identical per-row gallery the loop rail uses. `recommended` is **null** (an arbitrary heard chord has no authored KB play), which `VoicingBrowser` already handles (order untouched, no badge).
`HistoryRail` header (the section h4): `Recent chords · newest first — every voicing`, with the one-line microcopy `Following what you play — the newest chord is up top; no repeating loop yet.` (tokens: `bg-panel`, `border-border`, `text-gray-500`).
### 1.5 Bass, no loop
The no-loop bass path today renders `BassGuideRows` with a **single** live station (JamGuide.jsx:404-410). Extend it to the **same recent-distinct stations, most-recent-first**: pass the `recentDistinct` array to `BassGuideRows` with approach suppressed (history is not a loop → no "approach into the next chord" line; reuse the existing `live` gate, which already turns `next` off at BassGuideRows.jsx:557, or add a `history` flag if the "· heard live" header wording should change to "· recent"). Root + fifth per row; row 0 marked "now". This keeps all three instruments honest and multi-chord.
### 1.6 Rail fit (unchanged geometry)
The rail column is 500px with the D-51 margin-hardened interior (~451px worst-case; GlanceRail.jsx:5-13). `HistoryRow` reuses the exact `VoicingBrowser dense max={4}` cell math, so guitar 4-across (374 ≤ 451) and the piano 2×2 pair (442 ≤ 451) fits are **byte-for-byte the loop rail's** — no new fit risk. Up to 6 rows stack vertically inside the column's existing `overflow-y-auto` scroller (JamGuide.jsx:493-497); the user owns the scrollbar (no auto-scroll — GlanceRail.jsx:46-49's law holds).
---
## 2. Try this — max 3, stable 3-slot layout, ≥3 ways to play each
### 2.1 The reshape: from cards-across to rows-of-shapes
Today `TryThis` (TryThis.jsx:171-189) lays subs **side by side** (columns = subs), each `SubCard` carrying **one** diagram. D-76 turns this **90°**: **rows = subs, columns = shapes.** Each sub becomes a **horizontal row**: an identity block (chord chip + tag + why) on the left, and its **≥3 ways to play it** on the right. Three sub-rows stacked = the "3×3" the user pictured (3 subs × 3 guitar shapes).
Cap: `subject.subs.slice(0, 3)` (was up to 4 — TryThis.jsx:160 `subs`). The engine (`suggestSubstitutions`, L-73) and `pickSubject`/`subsForChord`/`parseChordName` are **unchanged** — reactivity preserved: subject still follows `currentChord`, subs recompute per chord (TryThis.jsx:79-90, 152-160). Only the render and the cap change.
### 2.2 The STABLE fixed 3-slot layout (the anti-jump)
**Always render exactly 3 slots**, in order:
```
<div className="flex flex-col gap-2">
{[0,1,2].map(i => subs[i] ? <SubRow sub={subs[i]} …/> : <EmptySlot key={i}/>)}
</div>
```
- A present sub → `SubRow`. An absent one → `EmptySlot`: a **subtle placeholder** that holds the **exact SubRow height** for the current instrument — `rounded-lg border border-dashed border-border/50 bg-transparent` at low opacity, a centred muted `—` (`text-gray-600 text-[11px]`), `aria-hidden`. It reads as "an intentionally empty slot," not as broken, and — critically — the first two rows **never move** whether the chord yields 2 or 3 subs. This is the exact complaint the user raised ("annoying when the layout changes then u dont know where to look"): with fixed slots, sub #1 and sub #2 are always in the same pixel band.
- **Uniform row height per instrument** (not across instruments — the global `instrument` is fixed at any moment, so all 3 slots share one instrument's row height): guitar `min-h-[100px]`, piano `min-h-[72px]`, bass `min-h-[64px]` (§3 math). `EmptySlot` inherits the same `min-h-*`.
Why 3 slots and not "as many as fire": stability is worth a little reserved whitespace. The user explicitly chose predictable position over density here.
### 2.3 SubRow — guitar (the 3×3), piano (1), bass (caption)
Outer row: `flex items-center gap-3 rounded-lg border border-border bg-border/30 p-2 min-h-[…]` (reuses the SubCard token palette — TryThis.jsx:126).
**Left identity block** (`flex flex-col gap-1 shrink-0 w-[180px]`), all instruments:
1. **Chord chip** — the tappable control, reused verbatim from TryThis.jsx:127-134: `button → onChordClick?.(sub.label)`, `rounded-lg border border-border bg-border px-2 py-1 text-sm font-bold text-gray-100 hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent`, `aria-label={`${sub.label} — ${sub.why}`}`. Tap → `ChordDetailModal`. Inline beside it: the **category tag** (`relative`/`borrowed`/`colour`/`V7`) + the `↻` accent glyph for circle categories only (`CIRCLE_CATEGORIES`, TryThis.jsx:48-55 — unchanged rule).
2. **Why**`sub.why`, `text-[11px] leading-snug text-gray-400`, `line-clamp-2` (the identity block is ~180px wide; 2 lines ≈ 50 chars; full text stays on the chip `aria-label` + tap→modal). Wider than the old 152px card, so whys clip less.
**Right "ways to play" block**, by instrument:
| instrument | right block |
|---|---|
| **guitar** | `flex gap-2` of **up to 3** `ChordDiagram size="thumb"` (~75px), from `getGuitarVoicings(sub.label).slice(0, 3)`. Each thumb captioned underneath with its shape name (`text-[9px] text-gray-500` — e.g. "E Barre", "A Barre / 5fr") so the three read as **three genuinely different grips**, not a repeat. **Omit** ChordDiagram's own `label` (the chip names the chord). Fewer than 3 shapes exist (dim/aug, §2.4) → show what exists; the row does not pad with blanks (the shapes left-align, the row height is fixed by `min-h`). |
| **piano** | **one** `MiniPiano size="mini"` (per the user: "for piano it can be just one thats okay"): `<MiniPiano voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }} size="mini" />`. Spread `rootPc` back in (the VoicingBrowser.jsx:317-319 caveat) so "R" badges correctly. |
| **bass** | no diagram — `root · {NOTES[sub.rootPc]}` caption (`text-[10px] text-gray-500`), the same honesty call TryThis/BassGuideRows already make (there is no compact bass-chord renderer). |
All diagrams use the established tiers baked into `ChordDiagram`/`MiniPiano` (accent-purple root, gray/light-purple other tones) — **no new colour, no token change.**
### 2.4 Honest empties
- **Fewer than 3 subs** for the chord → the missing slot(s) render `EmptySlot` (§2.2). Position of the present subs is unchanged.
- **A guitar sub with fewer than 3 shapes**: `getGuitarVoicings` returns all placeable shapes — barre forms are always placeable (any root ≤ fret 15). The engine only ever emits **{min, maj, dom7, maj7, add9, maj6, min7, sus4}** (every `mk()` call in theory.js:801-917 — dim/aug are **never** substitution candidates, so they can't appear here). Of those, **maj/min/dom7/maj7/min7/sus4/maj6** carry ≥3 barre shapes → the full 3×3 (voicings.js:10-53, 76-105). The genuine 2-shape case is **add9**: `getGuitarVoicings` returns only 2 barre shapes (E Barre, A Shape) for **9 of 12 roots**, and 3 only for **C/G/D** (which add an open Cadd9/Gadd9/Dadd9, voicings.js:116-121) → that sub-row honestly shows **2** thumbs, `slice(0,3)` simply yielding two. And add9 is **routinely** emitted — Rule C picks add9 for a major-family chord whose maj7 added tone is non-diatonic (e.g. in D major, the V chord A → **Aadd9**), which is common on major-family V-type chords. So a 2-thumb guitar row is a **regular, honest occurrence**, not a rarity; it never crashes and never pads with fakes.
- **No subject** (no key / no loop / atonal) → `pickSubject` returns null → render nothing (TryThis.jsx:157, unchanged).
### 2.5 Reactivity preserved
`pickSubject(loopArr, keyInfo, currentChord)` still chooses the live chord first, then the first loop station that yields subs (TryThis.jsx:79-90). As the progression evolves the subject and its (now ≤3) subs recompute every commit — the user confirmed they want it to evolve with the playing. The only behavioural deltas: **cap 3** and **3 shapes each**.
---
## 3. Space math — honest
**Left column** `flex-1 min-w-0`**744px**, section `p-3` → interior **≈ 720px** (one-screen.md §1; carried from D-75 §3). Footprints from source geometry: `ChordDiagram size="thumb"` = **75×72px** (padL 14 + gridW 55 + padR 6; padT 11 + gridH 55 + padB 6). `MiniPiano size="mini"` = up to **199×38px** (C-anchored window, D-75 §3 — width varies with root, **height is octave-independent 38.4px**).
**Guitar SubRow.** Right block: 3 thumbs = `3·75 + 2·8 = 241px` (+ 9px shape captions → ~84px tall). Left identity 180px + gaps ≈ `180 + 12 + 241 = 433 ≤ 720` — comfortable, ~287px to spare. Row height = max(thumb 72 + caption 12 = 84, chip 22 + why-2-lines 30 = 52) + `p-2` 16 ≈ **~100px** → `min-h-[100px]`. **3 slots** = `3·100 + 2·8 = 316`; block (+ header 20 + `p-3` 24) ≈ **~360px** — fixed, 2 subs or 3.
**Piano SubRow.** Right block: one `mini` keyboard ≤199px wide, 38px tall. `180 + 12 + 199 = 391 ≤ 720`. Row = max(38, 52) + 16 ≈ **~72px** → `min-h-[72px]`. **3 slots** = `3·72 + 16` ≈ 232; block ≈ **~276px**.
**Bass SubRow.** No diagram. Row = 52 + 16 ≈ **~64px** → `min-h-[64px]`. 3 slots ≈ 208; block ≈ **~250px**.
Note the 3×3 guitar block (~360px) is **taller** than D-75's 4-across single row (~221px) — the honest cost of "at least 3 ways to play each," which the user asked for directly.
### One-screen budget @ 1280×900 (usable box ≈ 836px, one-screen.md §4)
Left jam-view stack = instrument view + licks strip + `relatedSlot` (`flex-1 min-h-0 overflow-y-auto` absorber = **new** Try-this + RelatedProgressions 2×2 from L-76); gaps = 3·`gap-3` = 36px.
| instrument | instrument | licks | try-this (new) | related 2×2 | +gaps | **total** | vs 836 |
|---|---|---|---|---|---|---|---|
| **guitar** | 240 | 190 | ~360 (3×3, 3 slots) | ~272 | 36 | **~1098** | overflow **~262px** |
| **piano** | 240 | 190 | ~276 (1 kbd, 3 slots) | ~272 | 36 | **~1014** | overflow **~178px** |
| **bass** | 240 | 190 | ~250 (caption, 3 slots) | ~272 | 36 | **~988** | overflow **~152px** |
**Scroll story (honest):** the instrument view and licks strip stay **pinned** (they are `xl:shrink-0`, JamGuide.jsx:476-481); only the `relatedSlot` — the `flex-1 min-h-0 overflow-y-auto` absorber (JamGuide.jsx:485-489) — scrolls. So the taller 3×3 try-this deepens the fold in the *related area only*: guitar wants a ~262px flick to reach the bottom of the RelatedProgressions grid, piano ~178px, bass ~152px. This is ~139px more than D-75's guitar fold (~123px) — the direct, user-requested trade for 3 grips per sub. Instrument + licks never move; the active chord and the Try-this subjects are always above the fold. At **1280×1080+** guitar clears; piano/bass clear at ~1100. The `overflow-y-auto` absorber makes the fit exact by construction — the residue lands only in the related area, never on the instrument/licks the player watches.
*(The right rail is independent: `HistoryRail`'s ≤6 rows live in the 500px column's own `overflow-y-auto` scroller — they do not affect the left-column budget.)*
---
## 4. Bounded scopes (file-disjoint → parallelisable)
- **L-77 — the voicings rail.** Files: **`src/components/GlanceRail.jsx`** (add `HistoryRow` + exported `HistoryRail`, reusing the `VoicingBrowser dense max={4}` gallery + `SoloLabel`/`AimDots`; `StationRow`/`GlanceRail` for the loop stay byte-identical) **+ `src/components/JamGuide.jsx`** (a `recentDistinct(chordHistory)` memo; the no-loop `railContent` branch → `HistoryRail` for guitar/piano and `BassGuideRows` fed the recent-distinct stations with approach suppressed for bass; the single-`VoicingBrowser` fallback is retired). **No App change** (`chordHistory` already flows in, JamGuide.jsx:186 / App.jsx:846). No `theory.js`/`voicings.js`/`piano.js`/`tailwind.config.js` change (resolvers/tokens consumed as-is).
- **L-78 — Try this.** File: **`src/components/TryThis.jsx` only.** Cap `subs.slice(0,3)`; replace the cards-across body with the 3-slot rows-of-shapes (§2); keep `parseChordName`/`subsForChord`/`pickSubject`. **No App change** (`instrument` already threaded by L-75, App.jsx:864). No engine change (§2.5).
**Disjoint:** L-77 = {GlanceRail.jsx, JamGuide.jsx}; L-78 = {TryThis.jsx}. **Zero file overlap** → they can run in parallel (worktree) or serial, in any order. Neither touches `theory.js`, `voicings.js`, `piano.js`, `App.jsx`, or `tailwind.config.js`. C-70's `VoicingBrowser.jsx` aria fold is disjoint from both.
*(If a future revision wants the history rail and the loop rail to share one component body, that unification is out of D-76 scope — it would re-touch `StationRow` and risk the loop path; keep it a separate task.)*
---
## 5. What does NOT change
- The engine `suggestSubstitutions` and its rules/why-copy (L-73) — untouched; only the display cap (4→3) and per-sub shape count change.
- The loop rail (`GlanceRail`/`StationRow`): canonical order, playhead, voice-leading wheel, focus→fretboard — all byte-identical.
- `VoicingBrowser`, `ChordDiagram`, `MiniPiano`, `pianoVoicing`, `getGuitarVoicings` — consumed as-is.
- Tokens: `bg-panel`, `bg-surface`, `border-border`, `text-accent`/`bg-accent`, the accent ring, the established note-colour tiers. **No new colour, no `tailwind.config.js` edit.**
---
## 6. Rejected alternatives (≥2)
1. **Reorder the LOOP rail most-recent-first (physically re-sequence the rows).** Rejected: `GlanceRail` builds `voiceLeadingPairs` between *canonically adjacent* stations and shows per-row "next F→E · ½ step down" + a "loop" wrap chip (GlanceRail.jsx:118-132, 234-241). Reordering makes every transition chip point at the wrong neighbour and the wrap chip lie — it trades away the loop's whole voice-leading education for a re-sort the playhead highlight already conveys. The current chord is unmistakable *in place* via the "now" ring.
2. **Fully unify: always drive the rail from recent-distinct-history, loop merely overlays highlighting.** Rejected: same casualty — the transition/wrap education is canonical-order-only, and a history-ordered rail can't carry it. Unifying is a larger refactor that *reduces* the loop's teaching value. Two paths sharing one row idiom is smaller and loses nothing.
3. **Keep Try-this side-by-side (columns = subs) and just add 2 more diagrams per card.** Rejected: 3 subs × 3 guitar thumbs across = `3·(3·75) ≈ 675px` of diagrams fighting for a 720px row with no room for chips/whys, and it re-introduces the 2-vs-3 horizontal jump the user hates (cards grow/reflow). Rows = subs, columns = shapes, fixed 3 slots is the stable-position answer.
4. **Let the number of Try-this slots follow the sub count (render 2 rows when 2 subs).** Rejected: that is exactly the "layout changes, you don't know where to look" jump the user called out. Reserving 3 slots with a subtle placeholder costs a little whitespace and buys constant position — the user's explicit priority.
5. **Shrink guitar thumbs to force less height / cram the 3×3.** Rejected: 75px is the established `ChordDiagram thumb` size used everywhere (rail, KC, modal); shrinking it below the shared size fragments the visual language and hurts readability mid-jam. The honest cost of 3 grips per sub is ~140px more fold in the *related area only* (§3) — pinned instrument/licks are unaffected, and the user asked for the 3 grips knowing it takes room.
+307
View File
@@ -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.
+28
View File
@@ -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
View File
@@ -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>
+3876 -106
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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

+31 -23
View File
@@ -1,13 +1,10 @@
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 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'
@@ -19,6 +16,13 @@ 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
@@ -585,15 +589,15 @@ export default function App() {
}
return (
<div className={`min-h-screen bg-surface text-white p-3${jamView ? ' xl:h-screen xl:overflow-hidden xl:flex xl:flex-col' : ''}`}>
<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">&#9837;?</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
@@ -624,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">
@@ -727,7 +731,7 @@ export default function App() {
)}
{/* ── Jam roulette (L-60, jam-roulette.md §1.1/§1.2) ── */}
<div className="relative ml-auto" ref={rouletteRef}>
<div className="static sm:relative ml-auto" ref={rouletteRef}>
<button
type="button"
onClick={() => setRouletteMenuOpen(o => !o)}
@@ -906,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>
@@ -931,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>
@@ -946,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) ── */}
+5 -5
View File
@@ -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} />}
+8 -8
View File
@@ -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 topbottom 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 leftright 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.
+76 -25
View File
@@ -48,16 +48,31 @@
// page mid-jam. The L-33 auto-centre effect was deleted in L-40 and must never
// return; the highlight travels, the user owns the scrollbar.
//
// HYBRID rail reuse (task L-77, refines D-76; user directive 2026-07-13 "highlight
// the loop chords when it finds a loop but also add the other chords underneath"):
// JamGuide now renders GlanceRail TWICE once for the canonical LOOP group (the
// original call, byte-unchanged) and once for the "also played" recent-history
// group. The history group passes `showTransitions={false}` (see the prop below)
// because history order is NOT canonical: the between-adjacent voice-leading chips
// and the "next" tag are only true for the loop's canonical wheel, so they are
// suppressed for the history rail. Everything else (per-row gallery, "now" via
// activeIndex, the SoloLabel/AimDots guide-tone education) is correct for any chord
// and stays. Default (`showTransitions` absent) is byte-compatible with the loop.
//
// Pure presentational. Props:
// stations [{ shape, voicing, rootPc, quality, label, rn }] canonical order
// activeIndex playhead station (canonicalPos); -1 = loop known, playhead
// not no row is marked "now" (content never changes either way)
// not no row is marked "now" (content never changes either way).
// The history group passes -1 (no playhead see JamGuide).
// focusedIndex the focused station index, or null (nothing focused)
// onFocus fn(index|null): toggle a station's focus
// instrument 'guitar' | 'piano' (VoicingBrowser `show`; bass never mounts
// this rail JamGuide renders BassGuideRows instead, D-40 §3)
// keyRoot key tonic pitch class 011 (ChordDiagram fret placement)
// keyMode key mode name (soloScale's minor-key dominant nudge)
// showTransitions default true (the loop caller is unchanged). false the
// voice-leading TransitionChips and the "next" tag are suppressed
// (history order is not canonically adjacent, task L-77).
import { NOTES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory'
import VoicingBrowser from './VoicingBrowser'
@@ -164,23 +179,38 @@ function StationRow({
style={{ opacity: isNow || isFocused ? 1 : 0.85 }}
>
{/* ── Header line: identity + the folded roadmap education ── */}
{/* Loop rows (onToggleFocus provided) keep the focus-toggle button the
D-03 fretboard guide-tone contract, byte-unchanged. History rows pass
no toggle a plain, non-interactive identity (no inert button /
misleading "focus" tooltip / stray focus ring), L-77. */}
<div className="mb-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
<button
type="button"
aria-pressed={isFocused}
onClick={onToggleFocus}
title={isFocused
? `Unfocus ${st.label} — clear its guide tones from the fretboard`
: `Focus ${st.label}light its guide tones on the fretboard`}
className="flex min-h-[32px] items-center gap-2 rounded px-1 outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<span className="text-sm font-bold leading-none text-gray-100">{st.label}</span>
{st.rn && (
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
{st.rn}
</span>
)}
</button>
{onToggleFocus ? (
<button
type="button"
aria-pressed={isFocused}
onClick={onToggleFocus}
title={isFocused
? `Unfocus ${st.label}clear its guide tones from the fretboard`
: `Focus ${st.label} — light its guide tones on the fretboard`}
className="flex min-h-[32px] items-center gap-2 rounded px-1 outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<span className="text-sm font-bold leading-none text-gray-100">{st.label}</span>
{st.rn && (
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
{st.rn}
</span>
)}
</button>
) : (
<div className="flex min-h-[32px] items-center gap-2 px-1">
<span className="text-sm font-bold leading-none text-gray-100">{st.label}</span>
{st.rn && (
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
{st.rn}
</span>
)}
</div>
)}
{isNow && (
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent">
now
@@ -222,17 +252,21 @@ function StationRow({
export default function GlanceRail({
stations = [], activeIndex = -1, focusedIndex = null, onFocus, instrument, keyRoot, keyMode,
showTransitions = true,
}) {
const n = stations.length
if (n === 0) return null
const nextIndex = activeIndex >= 0 && n > 1 ? (activeIndex + 1) % n : -1
// The "next" tag is a loop-adjacency claim suppressed for the history group.
const nextIndex = showTransitions && activeIndex >= 0 && n > 1 ? (activeIndex + 1) % n : -1
// Voice-leading rails: rail i leaves station i for station (i+1) mod n the
// last rail wraps back to station 0 (the loop is a wheel). The headline rail
// is the 73 (voiceLeadingPairs lists the 7th first); a one-chord loop has
// no transition to speak of.
// no transition to speak of. Suppressed entirely for the history group
// (showTransitions=false) its rows are recent-first, not canonically
// adjacent, so a "next FE" chip would point at the wrong neighbour (L-77).
const rails = stations.map((st, i) => {
if (n < 2) return null
if (!showTransitions || n < 2) return null
const next = stations[(i + 1) % n]
return voiceLeadingPairs(
{ root: st.rootPc, quality: st.quality },
@@ -240,13 +274,30 @@ export default function GlanceRail({
)[0] ?? null
})
// Section framing (L-77 honesty fix): showTransitions === true the canonical
// LOOP group; the "also played" history group (showTransitions=false) is recent-
// first with no playhead, so it must NOT claim "the loop" / "the playhead". Rows
// in the history group are also non-focusable (no onFocus no inert header
// button); the loop group's function keeps its focus toggle byte-unchanged.
const isLoop = showTransitions
const focusable = typeof onFocus === 'function'
const sectionAria = isLoop
? 'Voicing variations — every chord of the loop, all expanded'
: 'Voicing variations — every recently played chord, all expanded'
const sectionTitle = isLoop
? 'Variations · every chord, every voicing — the playhead highlights'
: 'Variations · every chord, every voicing'
const sectionFoot = isLoop
? "Voicings follow the loop — the playhead highlights the chord you're on."
: 'Recent chords — newest first; every voicing of each.'
return (
<section
className="rounded-2xl border border-border bg-panel p-2"
aria-label="Voicing variations — every chord of the loop, all expanded"
aria-label={sectionAria}
>
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
Variations · every chord, every voicing the playhead highlights
{sectionTitle}
</h4>
<div className="flex flex-col gap-2" role="list">
@@ -257,7 +308,7 @@ export default function GlanceRail({
isNow={i === activeIndex}
isNext={i === nextIndex}
isFocused={focusedIndex === i}
onToggleFocus={() => onFocus?.(focusedIndex === i ? null : i)}
onToggleFocus={focusable ? (() => onFocus(focusedIndex === i ? null : i)) : null}
instrument={instrument}
keyRoot={keyRoot}
keyMode={keyMode}
@@ -268,9 +319,9 @@ export default function GlanceRail({
</div>
{/* No anywhere anymore (dashboard-polish.md §3 "leave them off,
better not"); the rail is purely visual and follows the loop. */}
better not"); the rail is purely visual. */}
<p className="mt-2 text-[11px] text-gray-500">
Voicings follow the loop the playhead highlights the chord you're on.
{sectionFoot}
</p>
</section>
)
+198 -51
View File
@@ -4,7 +4,6 @@ import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC }
import { NOTES, CHORD_TYPES } from '../lib/theory'
import GlanceRail, { AimDots, SoloLabel } from './GlanceRail'
import BassPatternCard from './BassPatternCard'
import VoicingBrowser from './VoicingBrowser'
import LickCard, { TechniqueLegend } from './LickCard'
import PianoLickCard from './PianoLickCard'
import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel'
@@ -165,6 +164,92 @@ function lickFitsContext(lick, context) {
return wanted.length > 0 && tokens.some(t => wanted.includes(t))
}
// "Also played" history rail helpers (task L-77, refines D-76)
//
// The HYBRID voicings rail (user directive 2026-07-13 "highlight the loop
// chords when it finds a loop but also add the other chords underneath at
// least 4 or more"): a loop group (canonical GlanceRail, untouched) PLUS an
// "also played" group of the other recently-played distinct chords, most-recent-
// first, and when no loop is found just the history group. All parsing reuses
// the established app idiom (chordRootPC + the CHORD_TYPES suffix inversion,
// mirroring TryThis.jsx `parseChordName` / RelatedProgressions) no theory
// re-derivation.
// Rail size policy. Overall cap across BOTH groups so the column never runs
// away; the no-loop history rail caps a little lower. The 4 guarantee falls out
// of RAIL_TOTAL_CAP loopLen 4 loopLen for any loopLen RAIL_TOTAL_CAP:
// the history top-up is always allowed to reach four total when four distinct
// chords exist (it never fabricates it shows only what was actually played).
const RAIL_TOTAL_CAP = 8 // loop group + "also played" group combined
const NO_LOOP_HISTORY_CAP = 6 // no loop matched history rail alone
// Invert CHORD_TYPES suffix quality (the app idiom mirrors TryThis.jsx /
// RelatedProgressions; all 14 suffixes are unique).
const SUFFIX_TO_QUALITY = Object.fromEntries(
Object.entries(CHORD_TYPES).map(([quality, def]) => [def.suffix, quality])
)
// Parse a chord-name string { rootPc, quality } via the shared helpers. Returns
// null when unparseable (unknown suffix / bad root) so the caller drops it.
function parseHistoryChord(name) {
if (typeof name !== 'string') return null
const rootPc = chordRootPC(name)
if (rootPc < 0) return null
const m = name.match(/^[A-G][b#]?(.*)$/)
const quality = m ? SUFFIX_TO_QUALITY[m[1]] : undefined
if (!quality) return null
return { rootPc, quality }
}
// recentDistinctChords(chordHistory, cap, excludeNames) GlanceRail-shaped
// station rows for the "also played" group. Walks chordHistory from the NEWEST
// end backward, collecting DISTINCT chord NAMES (first-seen-from-newest wins
// the most-recent occurrence fixes each chord's slot, so a "F Am F Am" ping-pong
// yields [Am, F], the different chords each once). Names in `excludeNames` (the
// loop group's chords) are skipped so the two groups never duplicate a chord.
// Unparseable names are dropped. Returns MOST-RECENT-FIRST, capped. Empty /
// undefined history []. Stations carry identity only (shape/voicing null) an
// arbitrary played chord has no authored KB play, exactly the null `recommended`
// GlanceRail already renders gracefully.
function recentDistinctChords(chordHistory, cap, excludeNames) {
if (!Array.isArray(chordHistory) || cap <= 0) return []
const exclude = excludeNames instanceof Set ? excludeNames : new Set(excludeNames ?? [])
const seen = new Set()
const out = []
for (let i = chordHistory.length - 1; i >= 0; i--) {
const name = chordHistory[i]
if (seen.has(name)) continue
seen.add(name)
if (exclude.has(name)) continue
const parsed = parseHistoryChord(name)
if (!parsed) continue
out.push({
shape: null,
voicing: null,
rootPc: parsed.rootPc,
quality: parsed.quality,
label: name,
rn: '', // history is key-relative-agnostic here; rn stays empty (cheap, honest)
})
if (out.length >= cap) break
}
return out
}
// Small group caption above each rail group (tokens only no raw hex).
function RailGroupCaption({ children, tone = 'loop' }) {
return (
<p
className={
'mb-1 px-1 text-[9px] font-semibold uppercase tracking-widest ' +
(tone === 'loop' ? 'text-accent/80' : 'text-gray-500')
}
>
{children}
</p>
)
}
// JamGuide the jam dashboard grid (default export)
//
// Props:
@@ -324,6 +409,22 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
return stations
}, [match.matched, match.progression, match.style, instrument, keyRoot])
// "Also played" history stations (task L-77, refines D-76)
// The other recently-played DISTINCT chords, most-recent-first, that are NOT in
// the loop group. When a loop is matched the cap tops the two groups up toward
// RAIL_TOTAL_CAP; with no loop the history rail stands alone (NO_LOOP_HISTORY_
// CAP). Keyed on chordHistory (+ the loop via stationVoicings) so it recomputes
// as chords commit. Loop chords are excluded by their rendered label so the two
// groups never repeat a chord. instrument-agnostic identity GlanceRail /
// BassGuideRows draw the per-chord gallery from {rootPc, quality}.
const historyStations = useMemo(() => {
const loopNames = match.matched ? new Set(stationVoicings.map(s => s.label)) : null
const cap = match.matched
? Math.max(RAIL_TOTAL_CAP - stationVoicings.length, 0)
: NO_LOOP_HISTORY_CAP
return recentDistinctChords(chordHistory, cap, loopNames)
}, [match.matched, stationVoicings, chordHistory])
// Authored bass plays (L-42)
// When the matched style ships a bass pack with plays for this progression,
// BassGuideRows renders each play's per-station pattern card in the gallery
@@ -367,62 +468,108 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
// re-sorting with the jam (D-31 §2.4).
const contextStation = stationVoicings[canonicalPos >= 0 ? canonicalPos : 0] ?? null
// The rail (right column at xl / second block stacked): the suggested-
// voicings surface GlanceRail, BassGuideRows, the heard-live gallery, or
// the honest idle line (one-screen.md §3, §4).
// The rail (right column at xl / second block stacked) HYBRID (task L-77,
// refines D-76; user directive 2026-07-13: "highlight the loop chords when it
// finds a loop but also add the other chords underneath at least 4 or more").
// TWO groups, so multiple chords' voicings are ALWAYS visible (the old single-
// chord heard-live fallback is retired):
// A) LOOP group (only when a loop matches) the canonical GlanceRail /
// BassGuideRows, byte-unchanged: KB order, moving "now" playhead, valid
// between-adjacent voice-leading chips. A subtle "the loop" caption marks
// it as THE loop.
// B) "ALSO PLAYED" group the other recent DISTINCT chords (historyStations),
// most-recent-first, each expanded to its full voicing gallery. NO voice-
// leading chips (showTransitions=false history order is not canonically
// adjacent) and NO "now" badge (activeIndex=-1). With no loop this group
// stands alone and IS the rail. The 4-total guarantee comes from the
// RAIL_TOTAL_CAP top-up in `historyStations` (it shows only chords actually
// played never fabricates).
// Empty history + no loop the slim idle line (unchanged). Bass mirrors the
// hybrid via BassGuideRows (`live` on the history group suppresses approach
// history is not a loop).
const hasHistory = historyStations.length > 0
const railContent = match.matched ? (
instrument === 'bass' ? (
/* Bass rows (D-40 §3): authored pattern cards when the matched style
ships a bass pack (L-42), computed roots/fifths/approaches as the
honest fallback otherwise. The licks strip hides either way
(guitar tab licks are noise to a bassist mid-jam). */
<BassGuideRows
stations={stationVoicings}
activeIndex={canonicalPos}
keyMode={keyInfo?.mode}
plays={bassPlays}
/>
<div className="flex flex-col gap-3">
<div>
<RailGroupCaption>the loop</RailGroupCaption>
{/* Bass rows (D-40 §3): authored pattern cards when the matched style
ships a bass pack (L-42), computed roots/fifths/approaches as the
honest fallback otherwise. */}
<BassGuideRows
stations={stationVoicings}
activeIndex={canonicalPos}
keyMode={keyInfo?.mode}
plays={bassPlays}
/>
</div>
{hasHistory && (
<div>
<RailGroupCaption tone="history">also played · newest first</RailGroupCaption>
{/* `live` = no approach line (history is not a canonical loop). */}
<BassGuideRows
stations={historyStations}
activeIndex={-1}
keyMode={keyInfo?.mode}
live
/>
</div>
)}
</div>
) : (
/* The voicing rail ALL stations expanded as vertical rows; the
playhead only highlights (D-41, D-40 §4). */
<GlanceRail
stations={stationVoicings}
activeIndex={canonicalPos}
focusedIndex={focusedStation}
onFocus={setFocusedStation}
instrument={instrument}
keyRoot={keyRoot}
keyMode={keyInfo?.mode}
/>
<div className="flex flex-col gap-3">
<div>
<RailGroupCaption>the loop</RailGroupCaption>
{/* The voicing rail ALL loop stations expanded as vertical rows; the
playhead only highlights (D-41, D-40 §4). Untouched. */}
<GlanceRail
stations={stationVoicings}
activeIndex={canonicalPos}
focusedIndex={focusedStation}
onFocus={setFocusedStation}
instrument={instrument}
keyRoot={keyRoot}
keyMode={keyInfo?.mode}
/>
</div>
{hasHistory && (
<div>
<RailGroupCaption tone="history">also played · newest first</RailGroupCaption>
{/* History group: most-recent-first, no transition chips, no "now". */}
<GlanceRail
stations={historyStations}
activeIndex={-1}
instrument={instrument}
keyRoot={keyRoot}
keyMode={keyInfo?.mode}
showTransitions={false}
/>
</div>
)}
</div>
)
) : liveChord ? (
/* No loop matched, but chords are committing (D-31 §2.3): a single
"heard live" gallery, re-aimed on every chord commit. Auto-follow
only nothing plays by itself. Bass: D-40 §3's prose forbids guitar/
piano galleries under BASS, so the live chord gets the same computed
root/fifth line (no next chord no approach) instead. */
) : hasHistory ? (
/* No loop matched, but chords have been played: the "also played" group IS
the rail recent distinct chords, most-recent-first, 4 when available.
This replaces the old single-chord heard-live fallback (D-76 §0). Bass:
D-40 §3 forbids guitar/piano galleries under BASS, so BassGuideRows draws
the computed root/fifth line per chord (`live` no approach). */
instrument === 'bass' ? (
<BassGuideRows
stations={[{ rootPc: liveChord.rootPc, quality: liveChord.type, label: currentChord, rn: '' }]}
activeIndex={0}
stations={historyStations}
activeIndex={-1}
keyMode={keyInfo?.mode}
live
/>
) : (
<section
className="rounded-2xl border border-border bg-panel p-3"
aria-label={`Heard live — every ${instrument} voicing of ${currentChord}`}
>
<h4 className="mb-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
Heard live · {currentChord} every voicing
</h4>
<p className="mb-2 text-[11px] text-gray-500">
{detectedProgression?.length
? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; following the chord as it commits.`
: 'No repeating loop yet — following the chord as it commits.'}
</p>
<VoicingBrowser rootPc={liveChord.rootPc} quality={liveChord.type} show={instrument} dense />
</section>
<GlanceRail
stations={historyStations}
activeIndex={-1}
instrument={instrument}
keyRoot={keyRoot}
keyMode={keyInfo?.mode}
showTransitions={false}
/>
)
) : (
/* Nothing heard yet one slim line (~40px): the idle rail must not
@@ -709,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 (
@@ -732,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}
@@ -744,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}
@@ -756,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>
)}
+106 -62
View File
@@ -5,16 +5,23 @@ import { pianoVoicing } from '../lib/piano'
import ChordDiagram from './ChordDiagram'
import MiniPiano from './MiniPiano'
// TryThis side-by-side substitution cards, each with a mini diagram (L-75)
// TryThis stable 3-slot rows-of-shapes substitution rail (L-78 / D-76)
//
// For the chord under the playhead, in the detected key, show ALL curated
// substitutions (from theory.js `suggestSubstitutions`, the L-73 engine) SIDE BY
// SIDE one card per idea each carrying a mini instrument diagram of that
// chord (guitar ChordDiagram / piano MiniPiano / bass root caption), following
// the global instrument. Reverses L-74's rotating one-at-a-time card: the user
// saw the rotation and asked for all-visible ("put them next to each other, we
// have enough space"). Spec: docs/design/related-area-layout.md §13.
// Sibling of RelatedProgressions; same micro-header + chip idiom.
// For the chord under the playhead, in the detected key, show up to THREE curated
// substitutions (from theory.js `suggestSubstitutions`, the L-73 engine) as
// stacked ROWS turned 90° from L-75's cards-across. Each row = a left identity
// block (tappable chord chip + category tag + why) and a right "ways to play it"
// block that follows the global instrument:
// guitar up to 3 ChordDiagram thumbs (the 3×3 grid), the genuinely different
// grips from getGuitarVoicings(label).slice(0, 3)
// piano ONE MiniPiano ("for piano it can be just one thats okay")
// bass a root · {note} caption (no honest compact bass-chord renderer)
//
// The layout is a FIXED 3-slot frame: present subs render SubRow, absent slots
// render a subtle EmptySlot that holds the exact row height so sub #1 and #2
// never shift position whether the chord yields 2 or 3 subs (the user's anti-jump
// ask: "annoying when the layout changes then u dont know where to look").
// Spec: docs/design/related-area-v2.md §2. Sibling of RelatedProgressions.
//
// Props:
// loop : string[] | null the detected repeating progression (chord names)
@@ -54,6 +61,14 @@ const CATEGORY_TAG = {
secondary_dominant: 'V7',
}
// Per-instrument uniform row height (§2.2/§3). All 3 slots filled or empty
// share the current instrument's height so the frame never reflows.
const ROW_MIN_H = {
guitar: 'min-h-[100px]',
piano: 'min-h-[72px]',
bass: 'min-h-[64px]',
}
// Compute the substitution set for a chord NAME at loop index `pos` (1 when the
// chord is not a loop station). The next station's root pc gates Rule D (secondary
// dominant of the next chord). Returns { name, pos, subs } or null when the name
@@ -89,22 +104,26 @@ function pickSubject(loopArr, keyInfo, currentChord) {
return null
}
// The mini diagram drawn inside a SubCard, chosen by the global instrument (§2):
// guitar first resolved guitar shape (absolute frets) via ChordDiagram thumb;
// no shape available no diagram (honest, chip + why only).
// piano pianoVoicing for the chord, rootPc spread back in so the "R" badge
// lands correctly (VoicingBrowser:317-319 caveat), MiniPiano size mini.
// bass no diagram (no honest compact bass-chord renderer); a small
// root · {note} caption instead.
function SubDiagram({ sub, instrument }) {
// The "ways to play" block on the right of a SubRow, chosen by the global
// instrument (§2.3):
// guitar up to 3 ChordDiagram thumbs (the 3×3), each captioned with its shape
// name so the three read as genuinely different grips. Fewer than 3
// shapes exist (e.g. add9 2 for most roots) show what exists, never
// pad with fakes (honest, §2.4). None nothing (chip + why carry it).
// piano ONE MiniPiano; rootPc spread back in so the "R" badge lands right.
// bass root · {note} caption (no honest compact bass-chord renderer).
function WaysToPlay({ sub, instrument }) {
if (instrument === 'piano') {
return (
<MiniPiano
voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }}
size="mini"
/>
<div className="flex items-center">
<MiniPiano
voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }}
size="mini"
/>
</div>
)
}
if (instrument === 'bass') {
return (
<span className="text-[10px] text-gray-500">
@@ -112,39 +131,71 @@ function SubDiagram({ sub, instrument }) {
</span>
)
}
// guitar (default): first resolved shape; omit the label the chip names it.
const shape = getGuitarVoicings(sub.label)[0]
if (!shape) return null
return <ChordDiagram shape={shape} rootPc={sub.rootPc} size="thumb" />
// guitar (default): up to 3 genuinely different grips, left-aligned. Omit the
// ChordDiagram label (the chip names the chord) caption the shape name below.
const shapes = getGuitarVoicings(sub.label).slice(0, 3)
if (!shapes.length) return null
return (
<div className="flex gap-2">
{shapes.map((shape, i) => (
<div key={`${shape.label}-${i}`} className="flex flex-col items-center gap-0.5">
<ChordDiagram shape={shape} rootPc={sub.rootPc} size="thumb" />
<span className="max-w-[75px] truncate text-center text-[9px] leading-none text-gray-500">
{shape.label}
</span>
</div>
))}
</div>
)
}
// One substitution card chip (tappable modal) + mini diagram + tag + why.
function SubCard({ sub, instrument, onChordClick }) {
// One substitution ROW left identity (chip modal + tag + why) | right ways.
function SubRow({ sub, instrument, onChordClick }) {
const isCircle = CIRCLE_CATEGORIES.has(sub.category)
const tag = CATEGORY_TAG[sub.category] ?? sub.category
return (
<div className="flex min-w-0 flex-col items-center gap-1.5 rounded-lg border border-border bg-border/30 p-2">
<button
type="button"
onClick={() => onChordClick?.(sub.label)}
aria-label={`${sub.label}${sub.why}`}
className="shrink-0 rounded-lg border border-border bg-border px-2 py-1 text-sm font-bold leading-none text-gray-100 outline-none transition-all cursor-pointer hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent"
>
{sub.label}
</button>
<div className="flex min-h-[38px] items-center justify-center">
<SubDiagram sub={sub} instrument={instrument} />
<div
className={`flex 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>
<span className="text-[9px] uppercase tracking-wide text-gray-500">
{tag}
{isCircle && <span className="ml-0.5 text-accent" aria-hidden="true"></span>}
</span>
{/* Right "ways to play" block */}
<div className="min-w-0 flex-1">
<WaysToPlay sub={sub} instrument={instrument} />
</div>
</div>
)
}
<p className="line-clamp-3 text-center text-[11px] leading-snug text-gray-400">
{sub.why}
</p>
// A reserved-but-empty slot holds the exact SubRow height so the present subs
// never move when the chord yields fewer than 3 (§2.2, the anti-jump).
function EmptySlot({ instrument }) {
return (
<div
aria-hidden="true"
className={`flex items-center justify-center rounded-lg border border-dashed border-border/50 bg-transparent ${ROW_MIN_H[instrument] ?? ROW_MIN_H.guitar}`}
>
<span className="text-[11px] text-gray-600"></span>
</div>
)
}
@@ -157,16 +208,7 @@ export default function TryThis({ loop, keyInfo, currentChord, onChordClick, ins
if (!subject) return null
const subjectChord = subject.name
const subs = subject.subs
// Piano keyboards are up to ~199px wide (C-anchored window), so 4 cannot share a
// 720px row lay them out 2×2. Guitar (75px cell) and bass (no diagram) sit
// 4-across in one flex-wrap row; with 13 subs the cards grow to fill (§1.1/§3).
const isPiano = instrument === 'piano'
const listClass = isPiano
? 'grid grid-cols-1 sm:grid-cols-2 gap-2'
: 'flex flex-wrap gap-2'
const cardBasis = isPiano ? '' : 'basis-[168px] grow min-w-[152px]'
const subs = subject.subs.slice(0, 3) // cap at 3 (§2.1)
return (
<section
@@ -178,12 +220,14 @@ export default function TryThis({ loop, keyInfo, currentChord, onChordClick, ins
{keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
</h4>
<div className={listClass}>
{subs.map((sub, i) => (
<div key={`${sub.label}-${i}`} className={cardBasis}>
<SubCard sub={sub} instrument={instrument} onChordClick={onChordClick} />
</div>
))}
{/* Fixed 3-slot frame a present sub SubRow, an absent one EmptySlot,
so sub #1/#2 hold their position whether there are 2 or 3 subs. */}
<div className="flex flex-col gap-2">
{[0, 1, 2].map(i =>
subs[i]
? <SubRow key={`${subs[i].label}-${i}`} sub={subs[i]} instrument={instrument} onChordClick={onChordClick} />
: <EmptySlot key={`empty-${i}`} instrument={instrument} />
)}
</div>
</section>
)
+11
View File
@@ -5,6 +5,17 @@ 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
+30 -2
View File
@@ -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',
},
})
})