8 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
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
23 changed files with 4654 additions and 227 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/ releases/
release/ release/
.DS_Store .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. 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 ## Commands
```bash ```bash
+6
View File
@@ -120,3 +120,9 @@ Two audio pipelines run in parallel: a fast **pitch path** (4096-sample FFT, McL
## License ## 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. 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
+2 -1
View File
@@ -84,7 +84,7 @@ Standing principles (memory): scroll > click; nothing duplicated; one global ins
| 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-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 | | L-76 | Implement RelatedProgressions 2×2 grid per D-75 (same-style + cross-style sections in a 2-col grid using the left-column width) | engineering | done `e695716` (combined-gate PASS) | D-75 | `src/components/RelatedProgressions.jsx` | 2×2 layout; both sections adapt; no logic/scoring change; build+smoke green |
| D-76 | **Related-area v2** (user 2026-07-13, "one last change"): (a) VOICINGS RAIL — currently shows all loop stations when a loop matches, but only the SINGLE current chord in the heard-live/no-loop fallback (JamGuide railContent :373-424). User wants: ALWAYS show multiple chords' voicings, MOST RECENT FIRST — loop chords when matched (KEEP canonical order + a moving "now" playhead highlight — design call, reordering would break GlanceRail's between-adjacent voice-leading chips AND reshuffle rows every chord, contradicting the user's anti-jump preference; most-recent-first applies to the NO-LOOP history rail), else the recent distinct chord history (from chordHistory, cap ~6, most-recent-first). Never just one. **User confirm pending on the loop-ordering deviation (asked 2026-07-13).** (b) TRY THIS — cap at **3** suggestions (was 4); **STABLE fixed layout** (reserve 3 slots so 2-vs-3 subs never shifts position — the user: "annoying when the layout changes then u dont know where to look"); each suggestion shows **≥3 ways to play it** — guitar = **3×3** (3 subs × up to 3 getGuitarVoicings shapes each), piano = **1** MiniPiano each ("for piano it can be just one thats okay"), bass = root caption. Honest space math (left col ~744px for the 3×3 guitar; rail column for the multi-chord history). No user gate: pick strongest, ≥2 rejected alts | design | ready | — | `docs/design/related-area-v2.md` | rail always-multi-chord-most-recent-first spec'd; try-this 3-cap + stable-3-slot + 3-shapes-guitar/1-piano with honest math; L-77/L-78 bounded | | 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 | claimed | 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 | | 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 | | 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 | | 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 |
@@ -308,6 +308,7 @@ 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>`.)_ _(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 · **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-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: 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)
+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"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<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="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> <title>WhatTheFlat ♭? - JamBuddy</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </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" "electron:build:linux": "vite build && electron-builder --linux --publish never"
}, },
"dependencies": { "dependencies": {
"audiomotion-analyzer": "^4.5.4",
"pitchy": "^4.1.0", "pitchy": "^4.1.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
@@ -31,6 +30,7 @@
"electron-builder": "^26.8.1", "electron-builder": "^26.8.1",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-pwa": "^1.3.0",
"wait-on": "^9.0.4" "wait-on": "^9.0.4"
}, },
"build": { "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 AudioCapture from './components/AudioCapture'
import ProgressionBanner from './components/ProgressionBanner' import ProgressionBanner from './components/ProgressionBanner'
import Fretboard from './components/Fretboard' import Fretboard from './components/Fretboard'
import BassFretboard from './components/BassFretboard' import BassFretboard from './components/BassFretboard'
import Tuner from './components/Tuner'
import Piano from './components/Piano' import Piano from './components/Piano'
import Settings from './components/Settings' 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 { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
import ChordDetailModal from './components/ChordDetailModal' import ChordDetailModal from './components/ChordDetailModal'
import RelatedProgressions from './components/RelatedProgressions' import RelatedProgressions from './components/RelatedProgressions'
@@ -19,6 +16,13 @@ import kb from './data/kb/index.js'
import { seedableLoop, buildRoulettePool } from './lib/match' import { seedableLoop, buildRoulettePool } from './lib/match'
import settingIcon from './assets/setting-icon.png' 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 = { const DEFAULTS = {
// Key detection // Key detection
noteHistorySize: 2000, // ~60s of notes — stable across a song section noteHistorySize: 2000, // ~60s of notes — stable across a song section
@@ -585,15 +589,15 @@ export default function App() {
} }
return ( 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 ── */}
<header className="mb-2 flex items-center justify-between"> <header className="mb-2 flex items-center justify-between gap-2 flex-wrap">
<div> <div>
<h1 className="text-xl font-bold text-accent"> <h1 className="text-xl font-bold text-accent">
WhatTheFlat <span className="text-gray-600">&#9837;?</span> <span className="text-amber-400">- JamBuddy</span> WhatTheFlat <span className="text-gray-600">&#9837;?</span> <span className="text-amber-400">- JamBuddy</span>
</h1> </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>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<button <button
@@ -624,7 +628,7 @@ export default function App() {
</header> </header>
{/* ── Controls bar ── */} {/* ── 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 */} {/* Instrument select */}
<div className="relative"> <div className="relative">
@@ -727,7 +731,7 @@ export default function App() {
)} )}
{/* ── Jam roulette (L-60, jam-roulette.md §1.1/§1.2) ── */} {/* ── 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 <button
type="button" type="button"
onClick={() => setRouletteMenuOpen(o => !o)} onClick={() => setRouletteMenuOpen(o => !o)}
@@ -906,17 +910,19 @@ export default function App() {
<span>{showDebug ? '▲' : '▼'}</span> <span>{showDebug ? '▲' : '▼'}</span>
</button> </button>
{showDebug && ( {showDebug && (
<div className="border-t border-border p-4"> <div className="border-t border-border p-4 max-sm:p-3">
<DebugView <Suspense fallback={null}>
chroma={debugChroma} <DebugView
chordCandidates={debugCandidates} chroma={debugChroma}
noteAnalysis={debugNoteAnalysis} chordCandidates={debugCandidates}
waveform={debugWaveform} noteAnalysis={debugNoteAnalysis}
keyInfo={effectiveKey} waveform={debugWaveform}
currentChord={currentChord} keyInfo={effectiveKey}
instrument={instrument} currentChord={currentChord}
monoColor={monoColor} instrument={instrument}
/> monoColor={monoColor}
/>
</Suspense>
</div> </div>
)} )}
</div> </div>
@@ -931,8 +937,10 @@ export default function App() {
<span>{showDrumView ? '▲' : '▼'}</span> <span>{showDrumView ? '▲' : '▼'}</span>
</button> </button>
{showDrumView && ( {showDrumView && (
<div className="border-t border-border p-4"> <div className="border-t border-border p-4 max-sm:p-3">
<DrumView waveform={debugWaveform} bpm={bpm} /> <Suspense fallback={null}>
<DrumView waveform={debugWaveform} bpm={bpm} />
</Suspense>
</div> </div>
)} )}
</div> </div>
@@ -946,7 +954,7 @@ export default function App() {
<span>TUNER</span> <span>TUNER</span>
<span>{showTuner ? '▲' : '▼'}</span> <span>{showTuner ? '▲' : '▼'}</span>
</button> </button>
{showTuner && <div className="border-t border-border"><Tuner /></div>} {showTuner && <div className="border-t border-border"><Suspense fallback={null}><Tuner /></Suspense></div>}
</div> </div>
{/* ── Knowledge Center — bottom browse & study dock (L-40, D-40 §5) ── */} {/* ── 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 ( return (
<div <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() }} 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 */} {/* 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> <div>
<h2 className="text-3xl font-black text-accent leading-none">{chord}</h2> <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> <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> </div>
{/* Tab bar */} {/* 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: 'guitar', label: '🎸 Guitar' },
{ key: 'piano', label: '🎹 Piano' }, { key: 'piano', label: '🎹 Piano' },
@@ -800,7 +800,7 @@ export default function ChordDetailModal({ chord, onClose, onChordClick, keyInfo
</div> </div>
{/* Content */} {/* 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 === 'guitar' && <GuitarTab chordName={chord} />}
{tab === 'piano' && <PianoTab chordName={chord} />} {tab === 'piano' && <PianoTab chordName={chord} />}
{tab === 'theory' && <TheoryTab 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). // 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 const OPEN_PCS = [4, 9, 2, 7, 11, 4] // E A D G B E
// We render strings top→bottom as high-E first (matches Fretboard.jsx idiom), // We render strings left→right as low-E first (chord-diagram convention:
// so display index 0 = high E, 5 = low E. Data arrays are low-E first, so the // 6th string on the left, 1st on the right). Data arrays are also low-E first,
// data index for display row `di` is `5 - di`. // so display row index == data index.
const ACCENT = '#a855f7' // chord-tone tier (root highlight) const ACCENT = '#a855f7' // chord-tone tier (root highlight)
const DOT = '#e5e7eb' // non-root finger dots (light gray, AA on dark board) 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. // Root pitch class for colouring.
const rootPc = resolved.rootPc 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 = [] const rows = []
for (let di = 0; di < NUM_STRINGS; di++) { for (let di = 0; di < NUM_STRINGS; di++) {
const dataIdx = NUM_STRINGS - 1 - di const dataIdx = di
const f = absLowE[dataIdx] const f = absLowE[dataIdx]
const stringPc = (OPEN_PCS[dataIdx] + (typeof f === 'number' ? f : 0)) % 12 const stringPc = (OPEN_PCS[dataIdx] + (typeof f === 'number' ? f : 0)) % 12
const isRoot = typeof f === 'number' && f >= 0 && stringPc === rootPc const isRoot = typeof f === 'number' && f >= 0 && stringPc === rootPc
@@ -153,7 +153,7 @@ export default function ChordDiagram({
const svgW = padL + gridW + padR const svgW = padL + gridW + padR
const svgH = padT + gridH + padB 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 const fretY = fi => padT + fi * cell // fi: 0 = top line … NUM_FRETS
if (!built) { if (!built) {
@@ -235,14 +235,14 @@ export default function ChordDiagram({
x2={stringX(si)} x2={stringX(si)}
y2={fretY(NUM_FRETS)} y2={fretY(NUM_FRETS)}
stroke={STRING_COL} 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 */} {/* Per-string markers: mute ✕ / open ○ above the nut, dots on the grid */}
{rows.map((row, si) => { {rows.map((row, si) => {
const x = stringX(si) const x = stringX(si)
const dataIdx = NUM_STRINGS - 1 - si const dataIdx = si
const finger = fingers ? fingers[dataIdx] : 0 const finger = fingers ? fingers[dataIdx] : 0
// Muted string → ✕ above the board. // 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 // 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. // 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: // Pure presentational. Props:
// stations — [{ shape, voicing, rootPc, quality, label, rn }] canonical order // stations — [{ shape, voicing, rootPc, quality, label, rn }] canonical order
// activeIndex — playhead station (canonicalPos); -1 = loop known, playhead // 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) // focusedIndex — the focused station index, or null (nothing focused)
// onFocus — fn(index|null): toggle a station's focus // onFocus — fn(index|null): toggle a station's focus
// instrument — 'guitar' | 'piano' (VoicingBrowser `show`; bass never mounts // instrument — 'guitar' | 'piano' (VoicingBrowser `show`; bass never mounts
// this rail — JamGuide renders BassGuideRows instead, D-40 §3) // this rail — JamGuide renders BassGuideRows instead, D-40 §3)
// keyRoot — key tonic pitch class 011 (ChordDiagram fret placement) // keyRoot — key tonic pitch class 011 (ChordDiagram fret placement)
// keyMode — key mode name (soloScale's minor-key dominant nudge) // 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 { NOTES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory'
import VoicingBrowser from './VoicingBrowser' import VoicingBrowser from './VoicingBrowser'
@@ -164,23 +179,38 @@ function StationRow({
style={{ opacity: isNow || isFocused ? 1 : 0.85 }} style={{ opacity: isNow || isFocused ? 1 : 0.85 }}
> >
{/* ── Header line: identity + the folded roadmap education ── */} {/* ── 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"> <div className="mb-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
<button {onToggleFocus ? (
type="button" <button
aria-pressed={isFocused} type="button"
onClick={onToggleFocus} aria-pressed={isFocused}
title={isFocused onClick={onToggleFocus}
? `Unfocus ${st.label} — clear its guide tones from the fretboard` title={isFocused
: `Focus ${st.label}light its guide tones on the fretboard`} ? `Unfocus ${st.label}clear its guide tones from the fretboard`
className="flex min-h-[32px] items-center gap-2 rounded px-1 outline-none focus-visible:ring-2 focus-visible:ring-accent" : `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-sm font-bold leading-none text-gray-100">{st.label}</span>
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400"> {st.rn && (
{st.rn} <span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
</span> {st.rn}
)} </span>
</button> )}
</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 && ( {isNow && (
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent"> <span className="text-[9px] font-semibold uppercase tracking-widest text-accent">
now now
@@ -222,17 +252,21 @@ function StationRow({
export default function GlanceRail({ export default function GlanceRail({
stations = [], activeIndex = -1, focusedIndex = null, onFocus, instrument, keyRoot, keyMode, stations = [], activeIndex = -1, focusedIndex = null, onFocus, instrument, keyRoot, keyMode,
showTransitions = true,
}) { }) {
const n = stations.length const n = stations.length
if (n === 0) return null 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 // 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 // last rail wraps back to station 0 (the loop is a wheel). The headline rail
// is the 7→3 (voiceLeadingPairs lists the 7th first); a one-chord loop has // is the 7→3 (voiceLeadingPairs lists the 7th first); a one-chord loop has
// no transition to speak of. // no transition to speak of. Suppressed entirely for the history group
// (showTransitions=false) — its rows are recent-first, not canonically
// adjacent, so a "next F→E" chip would point at the wrong neighbour (L-77).
const rails = stations.map((st, i) => { const rails = stations.map((st, i) => {
if (n < 2) return null if (!showTransitions || n < 2) return null
const next = stations[(i + 1) % n] const next = stations[(i + 1) % n]
return voiceLeadingPairs( return voiceLeadingPairs(
{ root: st.rootPc, quality: st.quality }, { root: st.rootPc, quality: st.quality },
@@ -240,13 +274,30 @@ export default function GlanceRail({
)[0] ?? null )[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 ( return (
<section <section
className="rounded-2xl border border-border bg-panel p-2" 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"> <h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
Variations · every chord, every voicing the playhead highlights {sectionTitle}
</h4> </h4>
<div className="flex flex-col gap-2" role="list"> <div className="flex flex-col gap-2" role="list">
@@ -257,7 +308,7 @@ export default function GlanceRail({
isNow={i === activeIndex} isNow={i === activeIndex}
isNext={i === nextIndex} isNext={i === nextIndex}
isFocused={focusedIndex === i} isFocused={focusedIndex === i}
onToggleFocus={() => onFocus?.(focusedIndex === i ? null : i)} onToggleFocus={focusable ? (() => onFocus(focusedIndex === i ? null : i)) : null}
instrument={instrument} instrument={instrument}
keyRoot={keyRoot} keyRoot={keyRoot}
keyMode={keyMode} keyMode={keyMode}
@@ -268,9 +319,9 @@ export default function GlanceRail({
</div> </div>
{/* No ▶ anywhere anymore (dashboard-polish.md §3 — "leave them off, {/* 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"> <p className="mt-2 text-[11px] text-gray-500">
Voicings follow the loop the playhead highlights the chord you're on. {sectionFoot}
</p> </p>
</section> </section>
) )
+198 -51
View File
@@ -4,7 +4,6 @@ import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC }
import { NOTES, CHORD_TYPES } from '../lib/theory' import { NOTES, CHORD_TYPES } from '../lib/theory'
import GlanceRail, { AimDots, SoloLabel } from './GlanceRail' import GlanceRail, { AimDots, SoloLabel } from './GlanceRail'
import BassPatternCard from './BassPatternCard' import BassPatternCard from './BassPatternCard'
import VoicingBrowser from './VoicingBrowser'
import LickCard, { TechniqueLegend } from './LickCard' import LickCard, { TechniqueLegend } from './LickCard'
import PianoLickCard from './PianoLickCard' import PianoLickCard from './PianoLickCard'
import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel' import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel'
@@ -165,6 +164,92 @@ function lickFitsContext(lick, context) {
return wanted.length > 0 && tokens.some(t => wanted.includes(t)) 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) ─────────────────────── // ─── JamGuide — the jam dashboard grid (default export) ───────────────────────
// //
// Props: // Props:
@@ -324,6 +409,22 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
return stations return stations
}, [match.matched, match.progression, match.style, instrument, keyRoot]) }, [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) ────────────────────────────────────────────── // ── Authored bass plays (L-42) ──────────────────────────────────────────────
// When the matched style ships a bass pack with plays for this progression, // 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 // 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). // re-sorting with the jam (D-31 §2.4).
const contextStation = stationVoicings[canonicalPos >= 0 ? canonicalPos : 0] ?? null const contextStation = stationVoicings[canonicalPos >= 0 ? canonicalPos : 0] ?? null
// ── The rail (right column at xl / second block stacked): the suggested- // ── The rail (right column at xl / second block stacked) — HYBRID (task L-77,
// voicings surface — GlanceRail, BassGuideRows, the heard-live gallery, or // refines D-76; user directive 2026-07-13: "highlight the loop chords when it
// the honest idle line (one-screen.md §3, §4). ── // 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 ? ( const railContent = match.matched ? (
instrument === 'bass' ? ( instrument === 'bass' ? (
/* Bass rows (D-40 §3): authored pattern cards when the matched style <div className="flex flex-col gap-3">
ships a bass pack (L-42), computed roots/fifths/approaches as the <div>
honest fallback otherwise. The licks strip hides either way <RailGroupCaption>the loop</RailGroupCaption>
(guitar tab licks are noise to a bassist mid-jam). */ {/* Bass rows (D-40 §3): authored pattern cards when the matched style
<BassGuideRows ships a bass pack (L-42), computed roots/fifths/approaches as the
stations={stationVoicings} honest fallback otherwise. */}
activeIndex={canonicalPos} <BassGuideRows
keyMode={keyInfo?.mode} stations={stationVoicings}
plays={bassPlays} 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 <div className="flex flex-col gap-3">
playhead only highlights (D-41, D-40 §4). */ <div>
<GlanceRail <RailGroupCaption>the loop</RailGroupCaption>
stations={stationVoicings} {/* The voicing rail — ALL loop stations expanded as vertical rows; the
activeIndex={canonicalPos} playhead only highlights (D-41, D-40 §4). Untouched. */}
focusedIndex={focusedStation} <GlanceRail
onFocus={setFocusedStation} stations={stationVoicings}
instrument={instrument} activeIndex={canonicalPos}
keyRoot={keyRoot} focusedIndex={focusedStation}
keyMode={keyInfo?.mode} 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 ? ( ) : hasHistory ? (
/* No loop matched, but chords are committing (D-31 §2.3): a single /* No loop matched, but chords have been played: the "also played" group IS
"heard live" gallery, re-aimed on every chord commit. Auto-follow the rail — recent distinct chords, most-recent-first, ≥4 when available.
only — nothing plays by itself. Bass: D-40 §3's prose forbids guitar/ This replaces the old single-chord heard-live fallback (D-76 §0). Bass:
piano galleries under BASS, so the live chord gets the same computed D-40 §3 forbids guitar/piano galleries under BASS, so BassGuideRows draws
root/fifth line (no next chord → no approach) instead. */ the computed root/fifth line per chord (`live` → no approach). */
instrument === 'bass' ? ( instrument === 'bass' ? (
<BassGuideRows <BassGuideRows
stations={[{ rootPc: liveChord.rootPc, quality: liveChord.type, label: currentChord, rn: '' }]} stations={historyStations}
activeIndex={0} activeIndex={-1}
keyMode={keyInfo?.mode} keyMode={keyInfo?.mode}
live live
/> />
) : ( ) : (
<section <GlanceRail
className="rounded-2xl border border-border bg-panel p-3" stations={historyStations}
aria-label={`Heard live — every ${instrument} voicing of ${currentChord}`} activeIndex={-1}
> instrument={instrument}
<h4 className="mb-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500"> keyRoot={keyRoot}
Heard live · {currentChord} every voicing keyMode={keyInfo?.mode}
</h4> showTransitions={false}
<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>
) )
) : ( ) : (
/* Nothing heard yet — one slim line (~40px): the idle rail must not /* 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' }}> <div className="border-t border-border flex flex-col" style={{ height: '70vh' }}>
{/* ── Section nav (Knowledge Center pills, D-20 §1) ── */} {/* ── 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 => { {SECTIONS.map(s => {
const active = section === s.id const active = section === s.id
return ( return (
@@ -732,7 +879,7 @@ export function KnowledgeDock({ keyInfo, chordHistory = [], currentChord, onChor
{/* ── Explore — KB progression browser + famous progressions ── */} {/* ── Explore — KB progression browser + famous progressions ── */}
{section === 'explore' && ( {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 <ExploreSection
keyInfo={keyInfo} keyInfo={keyInfo}
levels={levels} levels={levels}
@@ -744,7 +891,7 @@ export function KnowledgeDock({ keyInfo, chordHistory = [], currentChord, onChor
{/* ── Voicings — picker (follows live chord) → VoicingBrowser ── */} {/* ── Voicings — picker (follows live chord) → VoicingBrowser ── */}
{section === 'voicings' && ( {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 <VoicingsSection
keyInfo={keyInfo} keyInfo={keyInfo}
chordHistory={chordHistory} chordHistory={chordHistory}
@@ -756,7 +903,7 @@ export function KnowledgeDock({ keyInfo, chordHistory = [], currentChord, onChor
{/* ── Licks & Techniques — per-style LickCard grid ── */} {/* ── Licks & Techniques — per-style LickCard grid ── */}
{section === 'licks' && ( {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} /> <LicksSection styles={styles} levels={levels} onToggleLevel={toggleLevel} />
</div> </div>
)} )}
+2 -2
View File
@@ -156,10 +156,10 @@ function SubRow({ sub, instrument, onChordClick }) {
const tag = CATEGORY_TAG[sub.category] ?? sub.category const tag = CATEGORY_TAG[sub.category] ?? sub.category
return ( return (
<div <div
className={`flex items-center gap-3 rounded-lg border border-border bg-border/30 p-2 ${ROW_MIN_H[instrument] ?? ROW_MIN_H.guitar}`} 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) */} {/* Left identity block (~180px) */}
<div className="flex w-[180px] shrink-0 flex-col gap-1"> <div className="flex w-full sm:w-[180px] shrink-0 flex-col gap-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button <button
type="button" type="button"
+11
View File
@@ -5,6 +5,17 @@ body {
background-color: #0f0f0f; background-color: #0f0f0f;
color: #f5f5f5; color: #f5f5f5;
font-family: system-ui, -apple-system, sans-serif; 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 /* Thin, dark, overlay-feel scrollbars for the jam dashboard's scrollers
+30 -2
View File
@@ -1,9 +1,37 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({ 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 base: './', // relative paths so Electron can load files from disk
server: { server: {
// Explicit IPv4 bind: with plain `localhost`, node ≥17 can bind ::1 only, // Explicit IPv4 bind: with plain `localhost`, node ≥17 can bind ::1 only,
@@ -14,4 +42,4 @@ export default defineConfig({
build: { build: {
outDir: 'dist', outDir: 'dist',
}, },
}) })