Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41751a1a58 | |||
| ce22561813 | |||
| 436522f3b4 | |||
| 077925f1d7 | |||
| b8c955570e |
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"projects": {
|
||||||
|
"default": "itsamejms"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,3 +13,8 @@ debug.log
|
|||||||
releases/
|
releases/
|
||||||
release/
|
release/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Firebase
|
||||||
|
.firebase/
|
||||||
|
firebase-debug.log
|
||||||
|
ui-debug.log
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
# Plan: PWA + Firebase Hosting for JamBuddy
|
||||||
|
|
||||||
|
Status: **proposed**. Each phase is independently shippable; stop after any one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Why this is low-risk
|
||||||
|
|
||||||
|
The Electron shell (`electron/main.cjs`, `preload.cjs`) does **nothing but host a window** — no IPC, no native APIs, `sandbox:false` only to give the renderer full Web Audio. All logic lives in the React renderer, which already runs browser-only via `npm run dev`. So the web build is the same `vite build` output the desktop installer already ships; we just add a manifest + service worker and serve `dist/` over HTTPS. Firebase gives us HTTPS for free, which `getUserMedia` (the mic) requires — that's the only hard web constraint.
|
||||||
|
|
||||||
|
Nothing in the app makes network calls today, so there's no API surface to re-host and no secrets to manage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — PWA scaffolding (no Firebase yet)
|
||||||
|
|
||||||
|
Goal: `vite build` produces an installable, offline-capable web app served from any static host.
|
||||||
|
|
||||||
|
### 1.1 Add `vite-plugin-pwa`
|
||||||
|
|
||||||
|
Only new dependency. Wraps Workbox under a Vite plugin — generates the service worker and injects the manifest. Cheaper than hand-rolling a SW and gets update flow for free.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// vite.config.js
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'JamBuddy — WhatTheFlat',
|
||||||
|
short_name: 'JamBuddy',
|
||||||
|
description: 'Real-time key and chord detection for live jams.',
|
||||||
|
theme_color: '#0f0f0f', // matches bg-surface token
|
||||||
|
background_color: '#0f0f0f',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'any',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
icons: [ /* see 1.3 */ ],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
|
||||||
|
// Audio is captured live — nothing to cache from a remote. Precache the app shell only.
|
||||||
|
navigateFallback: '/index.html',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Create `public/` directory
|
||||||
|
|
||||||
|
Vite serves `public/` at root and copies it verbatim into `dist/`. We need it for:
|
||||||
|
|
||||||
|
- `favicon.ico`, `apple-touch-icon.png` (180×180)
|
||||||
|
- Any static PWA icons referenced by the manifest
|
||||||
|
- (Optional) `robots.txt` — not needed for an app, skip.
|
||||||
|
|
||||||
|
The existing `src/assets/whattheflat-logo.png` is imported in `App.jsx` and bundled by Vite — leave that as-is; **don't** move it. The manifest icons must live in `public/` because they're referenced by URL, not imported.
|
||||||
|
|
||||||
|
### 1.3 Generate icon set from the existing logo
|
||||||
|
|
||||||
|
Derive all required sizes from `assets/whattheflat-logo.png` (one source image → PNG output). Required manifest entries:
|
||||||
|
|
||||||
|
```js
|
||||||
|
icons: [
|
||||||
|
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: '/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus `apple-touch-icon.png` (180×180) in `public/` — iOS ignores the manifest and uses this `<link>`. The maskable variant needs safe-zone padding (~10%) so the logo isn't cropped by Android's circular mask.
|
||||||
|
|
||||||
|
One-liner with ImageMagick if available, else export manually from the source PNG:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
magick assets/whattheflat-logo.png -resize 192x192 public/icon-192.png
|
||||||
|
magick assets/whattheflat-logo.png -resize 512x512 public/icon-512.png
|
||||||
|
magick assets/whattheflat-logo.png -resize 180x180 public/apple-touch-icon.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Update `index.html`
|
||||||
|
|
||||||
|
Two problems with the current `<head>`:
|
||||||
|
|
||||||
|
1. **No PWA tags** — add theme-color, manifest link, apple-touch-icon, apple-mobile-web-app-capable, description.
|
||||||
|
2. **CSP is hardcoded for dev** — `connect-src 'self' http://localhost:5173 ws://localhost:5173` and `script-src 'self' 'unsafe-eval'`. The `unsafe-eval` and the localhost entries are Vite dev artifacts; production Vite doesn't need `unsafe-eval`. Keep a CSP (it's good hygiene and the app is genuinely offline) but drop the dev bits:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<meta name="theme-color" content="#0f0f0f" />
|
||||||
|
<meta name="description" content="Real-time key and chord detection for live jams." />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
```
|
||||||
|
|
||||||
|
CSP (production):
|
||||||
|
```
|
||||||
|
default-src 'self';
|
||||||
|
script-src 'self';
|
||||||
|
style-src 'self' 'unsafe-inline';
|
||||||
|
img-src 'self' data:;
|
||||||
|
connect-src 'self';
|
||||||
|
media-src 'self' blob:; <!-- the mic stream lives in a blob -->
|
||||||
|
```
|
||||||
|
`blob:` in `media-src` is required because `getUserMedia` streams are blob-backed and the analyser reads them. Verify nothing else regresses — `vite-plugin-pwa` registers the SW from a same-origin script, so `'self'` covers it.
|
||||||
|
|
||||||
|
Move the dev-only CSP into a Vite conditional so dev still allows HMR/websocket:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// vite.config.js — inject dev CSP via plugin, or keep a separate dev index template.
|
||||||
|
```
|
||||||
|
Simplest: keep one CSP in `index.html` with the production values, and in dev let Vite's own injected tags coexist (HMR works over the same origin websocket; CSP `connect-src 'self'` already allows it). If dev breaks, add `ws://localhost:5173` only in a dev-specific block.
|
||||||
|
|
||||||
|
### 1.5 Verify
|
||||||
|
|
||||||
|
- `npm run build` → `dist/` contains `manifest.webmanifest`, `registerSW.js`, `sw.js`, and all `public/` icons.
|
||||||
|
- Serve `dist/` locally with `npm run preview` and use Chrome DevTools → Application → Manifest (should show icons, installability ✓) and Service Workers (registered).
|
||||||
|
- Install to desktop / "Add to Home Screen" on mobile.
|
||||||
|
- Confirm the app still works **fully offline** after first load (airplane mode). Since there are no network calls, the only risk is the SW failing to precache — Workbox handles this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Firebase Hosting setup
|
||||||
|
|
||||||
|
Goal: serve `dist/` over HTTPS on a `*.web.app` domain (or custom domain).
|
||||||
|
|
||||||
|
### 2.1 Install the CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D firebase-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Init
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx firebase init hosting
|
||||||
|
```
|
||||||
|
|
||||||
|
Answer:
|
||||||
|
- **Public directory:** `dist`
|
||||||
|
- **Single-page app (rewrite all urls to /index.html):** Yes — though the app has no client-side routing today, this protects future routes and is harmless.
|
||||||
|
- **Set up automatic builds with GitHub:** **No.** (You're not using GitHub.)
|
||||||
|
- **File `dist/index.html` already exists — overwrite?** **No.** This is critical — saying yes would clobber the Vite-built index.
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
- `firebase.json`
|
||||||
|
- `.firebaserc`
|
||||||
|
|
||||||
|
### 2.3 `firebase.json` (target shape)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hosting": {
|
||||||
|
"public": "dist",
|
||||||
|
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
|
||||||
|
"rewrites": [{ "source": "**", "destination": "/index.html" }],
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "**/*.@(js|css|svg|png|ico|woff2|webmanifest)",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/sw.js",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Cache-Control", "value": "no-cache" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/index.html",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Cache-Control", "value": "no-cache" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Why the headers: Vite hashes asset filenames, so JS/CSS/icons are content-addressed → cache them **immortal**. `index.html` and `sw.js` must stay fresh so updates propagate — `no-cache` revalidates them every time. (Note: with `vite-plugin-pwa`'s `autoUpdate`, the SW self-updates; the `no-cache` on `sw.js` is belt-and-braces.)
|
||||||
|
|
||||||
|
### 2.4 First deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npx firebase deploy --only hosting
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `https://<project>.web.app`. The mic will now work because the origin is HTTPS.
|
||||||
|
|
||||||
|
### 2.5 `.gitignore`
|
||||||
|
|
||||||
|
Add `.firebase/` and `firebase-debug.log` (already in a sane gitignore, but confirm).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — (optional) custom domain
|
||||||
|
|
||||||
|
In Firebase console → Hosting → Add custom domain. Add the DNS TXT/CNAME records it gives you. Lets Encrypt cert is auto-provisioned. No app change needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Web optimization checklist
|
||||||
|
|
||||||
|
Things that matter specifically for shipping this app on the web. Most are cheap; do them before the first public deploy.
|
||||||
|
|
||||||
|
### 4.1 Drop the dead dependency
|
||||||
|
|
||||||
|
`audiomotion-analyzer` is listed in `package.json` but **imported nowhere** in `src/`. Vite tree-shakes unused ESM, so it likely doesn't bloat the production bundle — but it still inflates `npm install` and is misleading. Either:
|
||||||
|
- **Remove it** from `dependencies` (one-line `npm uninstall audiomotion-analyzer`), or
|
||||||
|
- If something depends on it being present (it doesn't — grep confirms zero imports), wire it up.
|
||||||
|
|
||||||
|
Recommend: remove.
|
||||||
|
|
||||||
|
### 4.2 Audit bundle size
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npx vite-bundle-visualizer # one-off, no config needed
|
||||||
|
```
|
||||||
|
Expected heavy bits: `pitchy` (small, pure JS) and React 19. `electron` / `electron-builder` are devDeps and excluded from the web build. If the visualizer shows anything surprising (e.g. a transitive dep pulling in moment/lodash), trim it.
|
||||||
|
|
||||||
|
### 4.3 Code-split the heavy, rarely-used views
|
||||||
|
|
||||||
|
`DebugView`, `DrumView`, and `Tuner` are collapsible panels most users never open. They import theory helpers and own audio contexts. Lazy-load them so the initial bundle stays lean:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const Tuner = lazy(() => import('./components/Tuner'))
|
||||||
|
const DebugView = lazy(() => import('./components/DebugView'))
|
||||||
|
const DrumView = lazy(() => import('./components/DrumView'))
|
||||||
|
```
|
||||||
|
Wrap in `<Suspense fallback={null}>`. These are already behind toggle buttons, so there's no UX cost. This is the single biggest web win — the core pitch/chord flow loads first.
|
||||||
|
|
||||||
|
### 4.4 Audio permissions & UX on the web
|
||||||
|
|
||||||
|
- **HTTPS** — covered by Firebase. `getUserMedia` is blocked on plain HTTP (except `localhost`).
|
||||||
|
- **User gesture** — the existing "Start Listening" button already provides the gesture the browser requires to call `getUserMedia`. Don't change this; never auto-start listening on load.
|
||||||
|
- **Mobile Safari quirks** — iOS Safari:
|
||||||
|
- Requires `audio: { echoCancellation: false }` style may differ; test the existing `AudioCapture` constraints on an iPhone.
|
||||||
|
- AudioContext must be resumed after a gesture — already handled since capture starts on click.
|
||||||
|
- `sharedArrayBuffer` is not needed by this app (no worklets), so no COOP/COEP headers required. Good — don't add them; they'd complicate the Firebase static host.
|
||||||
|
- **Permissions API** — optionally surface "microphone blocked" before the user clicks, using `navigator.permissions.query({ name: 'microphone' })`. The existing `micError` state already covers the denied case; this is a polish step, not required.
|
||||||
|
|
||||||
|
### 4.5 Mobile layout
|
||||||
|
|
||||||
|
The app uses Tailwind with responsive `lg:` breakpoints already (e.g. the progressions sidebar is `hidden lg:block`). For a real mobile deploy:
|
||||||
|
- Test the fretboard / piano SVGs at phone widths — they're SVG so they scale, but text labels may crowd.
|
||||||
|
- The `controls bar` wraps with `flex-wrap` — good. Verify the key-lock select dropdowns are tappable.
|
||||||
|
- Consider a `display: standalone` install prompt; the manifest already sets `standalone`.
|
||||||
|
- This is a "play and look at the screen" app — portrait guitar fretboard orientation may want `orientation: 'portrait'` in the manifest instead of `'any'`. Decide based on the primary instrument view.
|
||||||
|
|
||||||
|
### 4.6 Performance for the audio hot loop
|
||||||
|
|
||||||
|
Already mostly tuned (the dual-analyser design is deliberate). Web-specific notes:
|
||||||
|
- `requestAnimationFrame` driving the analyser is fine on web — no change.
|
||||||
|
- `Float32Array` chroma math is cheap. No change needed.
|
||||||
|
- Don't add a Workbox runtime cache for audio — there's no audio to cache; everything is live.
|
||||||
|
- If you ever add an AudioWorklet, its file must be served with the correct MIME and same-origin — Workbox precaches it as part of `globPatterns` only if it's emitted to `dist/`. Not needed today.
|
||||||
|
|
||||||
|
### 4.7 SEO / social (minimal — this is an app, not content)
|
||||||
|
|
||||||
|
- `<title>` and meta description (added in 1.4) are enough for the web.app URL to have a sane share card.
|
||||||
|
- Add `og:title` / `og:description` / `og:image` to `index.html` if you want a nice link preview (point `og:image` to `/icon-512.png`). Optional.
|
||||||
|
- No sitemap needed for a single-page offline app.
|
||||||
|
|
||||||
|
### 4.8 Keep Electron working
|
||||||
|
|
||||||
|
The desktop build must not regress:
|
||||||
|
- `electron-builder` config in `package.json` is untouched by any of the above.
|
||||||
|
- The CSP change in `index.html` affects both targets — verify the desktop window still loads (it serves the built `dist/index.html`, so the new production CSP applies there too; that's fine and arguably better).
|
||||||
|
- `vite-plugin-pwa` is a dev dependency; it only runs at build time and doesn't touch the renderer runtime in a way that breaks Electron.
|
||||||
|
- Run `npm run electron:dev` after Phase 1 to confirm the Electron path still works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What stays Electron-only
|
||||||
|
|
||||||
|
Nothing in this plan removes or forks code. The Electron and web builds share the exact same `dist/` output. The only per-target differences are:
|
||||||
|
- Electron adds the `electron/main.cjs` shell and `electron-builder` packaging.
|
||||||
|
- Web adds the PWA manifest + SW (ignored by Electron) and is served over HTTPS.
|
||||||
|
|
||||||
|
If a future feature needs a native API (filesystem dialogs, auto-launch, tray), it would go through Electron's preload/IPC — which currently exposes nothing. At that point you'd add an `isElectron` guard and a `preload` IPC bridge. Not needed for any current feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested order of operations
|
||||||
|
|
||||||
|
1. **Phase 1.2** — create `public/`, drop in icons (Phase 1.3).
|
||||||
|
2. **Phase 1.1 + 1.4** — add `vite-plugin-pwa`, update `index.html`, relax CSP.
|
||||||
|
3. Verify with `npm run preview` + DevTools (Phase 1.5).
|
||||||
|
4. **Phase 2** — `firebase init hosting`, hand-tune `firebase.json`, first deploy.
|
||||||
|
5. **Phase 4.1** — remove dead `audiomotion-analyzer` dep.
|
||||||
|
6. **Phase 4.3** — lazy-load the three collapsible views.
|
||||||
|
7. Polish: mobile pass (4.5), bundle audit (4.2), optional OG tags (4.7).
|
||||||
|
|
||||||
|
Each step is independently revertible. Stop after step 4 and you have a working, installable, offline PWA on the web; the rest is optimization.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"hosting": {
|
||||||
|
"site": "jambuddy",
|
||||||
|
"public": "dist",
|
||||||
|
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
|
||||||
|
"rewrites": [{ "source": "**", "destination": "/index.html" }],
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "**/*.@(js|css|svg|png|ico|woff2|webmanifest)",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/sw.js",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Cache-Control", "value": "no-cache" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/index.html",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Cache-Control", "value": "no-cache" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-3
@@ -2,12 +2,19 @@
|
|||||||
<html lang="en">
|
<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>
|
||||||
Generated
+3876
-106
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -16,7 +16,6 @@
|
|||||||
"electron:build:linux": "vite build && electron-builder --linux --publish never"
|
"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
@@ -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">♭?</span> <span className="text-amber-400">- JamBuddy</span>
|
WhatTheFlat <span className="text-gray-600">♭?</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) ── */}
|
||||||
|
|||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -856,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 (
|
||||||
@@ -879,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}
|
||||||
@@ -891,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}
|
||||||
@@ -903,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user