15 Commits

Author SHA1 Message Date
shawn 87ee14e4e5 v2 audit: contrast, dark-glow, layout, palette, sizing fixes
All fixes from impeccable browser audit:
- Removed/reduced 18 dark-glow box-shadows (AI tells)
- Fixed contrast: muted text, badge-blue, badge-amber, kebab opacity
- Removed cyan neon palette (#60C0D0 → #5090A0, #60A0E0 → #6080B0)
- Changed layout transitions from width to transform (jank-free)
- Bumped tiny text sizes (9px→10px, 10px→11px)
- Fixed cramped padding on preset chips
2026-06-14 18:50:23 -04:00
shawn 3dcf31f600 fix: btn-icon touch targets to 44px WCGA minimum
- CSS classes: index.css (28→44px base, 24→44px mobile), App.jsx (36→44px base, 24→44px narrow)
- 19 inline btn-icon buttons in App.jsx: param view toggle, CapturesScreen/Presets close, sidebar toggle, undo/redo, status bar nav (captures/presets/tuner/fullscreen/menu), overlay closes, global bypass toggle, block bypass toggle, Scan/ON buttons, add block close
- Footswitch mode SM/PR buttons: height 20→44px, minWidth 34→44px
- FocusView.jsx: param −/+ buttons 34→44px, close button 34→44px
- GlobalSettings.jsx: param −/+ buttons 26→44px, close button 34→44px
- border-radius bumped from 7→8px (base), 5→8px (index.css) for proportion
2026-06-14 18:37:58 -04:00
shawn a5a01fda02 add theme.js — shared design tokens module referenced by App.jsx 2026-06-14 17:29:19 -04:00
shawn 0af8ed05b0 add responsive breakpoints: sidebar collapse, status bar compact, matchMedia listeners (#audit-P2) 2026-06-14 17:27:22 -04:00
shawn f42fa1d28c fix: replace Inter with Space Grotesk (#audit-P3)
Inter was flagged as the only AI tell in the audit. Space Grotesk
fits the pedal brand — mechanical, precise character with excellent
small-screen readability.

Changes:
- src/index.css: body font-family Inter → Space Grotesk
- src/App.jsx: Google Fonts import + body font-family + inline style
- src/GlobalSettings.jsx, GlobalEQ.jsx, ModelBrowser.jsx, FocusView.jsx:
  inline fontFamily Inter → Space Grotesk
- index.html: added Google Fonts preconnect + Space Grotesk import
- docs/helix-stadium-mockup.html, docs/helix-stadium-concept.html: updated
  font imports and font-family
2026-06-14 17:26:03 -04:00
shawn db644cec6f add prefers-reduced-motion support (#audit-P2)
The transform: scale(.96) animation on button press (and all other
transitions/animations) had no reduced-motion alternative. Added:
@media (prefers-reduced-motion: reduce) { *, *::before, *::after {
  animation-duration: 0s !important;
  animation-iteration-count: 1 !important;
  transition-duration: 0s !important;
}}

Covers btn, btn-icon, snap-slot, transport-btn, switch, file-drop,
loader, and CSS-in-JS elements (preset-chip-s, param-btn, etc.).
2026-06-14 17:25:06 -04:00
shawn cca4aed380 fix: GlobalSettings — add Save/Discard button pattern with visible feedback
- Removed auto-save-on-every-change (was silent, confusing)
- Added local draft state: changes accumulate until user clicks Save
- Added 'Unsaved' indicator when there are pending changes
- Added 'Save' button (amber, disabled when no changes)
- Added 'Discard' button to revert to last-saved state
- Shows '✓ Saved' green feedback for 2s after success
- Shows '✗ Save failed' on error
- Bulk-saves all changed settings in one POST /api/settings call
- Instantly routes master_volume/routing/audio_profile via dedicated endpoints
2026-06-14 13:43:47 -04:00
shawn 2402e649a2 fix: bank indexing off-by-one — subtract 1 when sending bank to API
Client stores bank as b.number+1 (1-based) but the API expects
0-based bank numbers. Caused preset activation to load the wrong
empty preset (bank 1 instead of bank 0), so blocks never appeared.
2026-06-14 13:19:51 -04:00
shawn f0771ff7a5 fix: wire all settings to backend — master_volume/routing use dedicated endpoints, all others persist via POST /api/settings to config.yaml 2026-06-14 12:44:52 -04:00
shawn 277625f90c fix: GlobalSettings loads current state from API on mount, add Routing & Audio Interface sections, wire onRoutingChange 2026-06-14 12:39:06 -04:00
shawn b24cfab15f fix: hamburger menu sidebar slide-in animation targets sidebarRef instead of backdropRef 2026-06-14 12:29:28 -04:00
shawn ec562c4071 fix: add nam_amp to BLOCK_DISPLAY_NAMES and BLOCK_COLORS for server preset chain loading 2026-06-14 00:21:13 -04:00
shawn e0f6940081 fix: audio profile API call includes 'stable' profile with rate/period overrides 2026-06-14 00:17:07 -04:00
shawn 87d719bbfb Merge branch 'feat-undo-menu-settings' 2026-06-14 00:16:09 -04:00
shawn a3f6b83297 fix: classic rock bank 1 presets, remove Y-split default mock blocks 2026-06-14 00:02:01 -04:00
23 changed files with 761 additions and 479 deletions
+2 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NAM Pedal — Helix Stadium UX Concept</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; user-select: none; -webkit-tap-highlight-color: transparent; }
@@ -29,7 +29,7 @@
height: 100%;
background: var(--bg);
color: var(--text);
font-family: 'Inter', sans-serif;
font-family: 'Space Grotesk', sans-serif;
overflow: hidden;
}
+2 -2
View File
@@ -4,10 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Pi Multi-FX — Helix Stadium Mockup</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; user-select: none; }
body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; display: flex; justify-content: center; min-height: 100vh; }
body { background: #0A0A0C; color: #F0EDE6; font-family: 'Space Grotesk', sans-serif; display: flex; justify-content: center; min-height: 100vh; }
.device {
width: 100%;
+4 -1
View File
@@ -2,9 +2,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
<meta name="theme-color" content="#0A0A0C" />
<title>NAM Pedal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" />
</head>
<body>
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
<svg fill="#F0EDE6" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
<g id="Layer_1">
<g>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+2 -10
View File
@@ -1,14 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
<g id="Layer_1" display="none">
<path display="inline" d="M47,41c0,3.313-2.687,6-6,6H7c-3.313,0-6-2.687-6-6V7c0-3.313,2.687-6,6-6h34c3.313,0,6,2.687,6,6V41z"/>
<line display="inline" fill="none" stroke="#FFFFFF" stroke-width="5" x1="23.5" y1="10" x2="23.5" y2="22"/>
<line display="inline" fill="none" stroke="#FFFFFF" stroke-width="5" x1="43" y1="13.546" x2="19.938" y2="53.489"/>
<line display="inline" fill="none" stroke="#FFFFFF" stroke-width="5" x1="5" y1="13.545" x2="11.904" y2="25.504"/>
</g>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="48px"
height="48px" viewBox="0 0 48 48" fill="#F0EDE6">
<g id="Combined">
<path d="M41,1H7C3.687,1,1,3.687,1,7v34c0,3.313,2.687,6,6,6h13.798l20.037-34.704l4.33,2.5L26.571,47H41c3.313,0,6-2.687,6-6V7
C47,3.687,44.313,1,41,1z M9.739,26.754L2.835,14.795l4.33-2.5l6.904,11.958L9.739,26.754z M26,22h-5V10h5V22z"/>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 450 B

+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
<svg fill="#F0EDE6" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
<g id="Layer_1">
<path d="M31.5,2.262v8.767C35.976,13.628,39,18.462,39,24c0,8.271-6.729,15-15,15c-8.271,0-15-6.729-15-15

Before

Width:  |  Height:  |  Size: 857 B

After

Width:  |  Height:  |  Size: 872 B

+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
<svg fill="#F0EDE6" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="48px" height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
</svg>

Before

Width:  |  Height:  |  Size: 477 B

After

Width:  |  Height:  |  Size: 492 B

+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
<svg fill="#F0EDE6" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
height="48px" viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<g id="Layer_2">
<g>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
<svg fill="#F0EDE6" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="48px" height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
<g id="x">
</g>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
<svg fill="#F0EDE6" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
<g id="Combined">
<path d="M35.819,27C34.863,32.666,29.935,37,24,37c-4.43,0-8.298-2.419-10.376-6h5.103c1.41,1.24,3.253,2,5.274,2

Before

Width:  |  Height:  |  Size: 900 B

After

Width:  |  Height:  |  Size: 915 B

+2 -4
View File
@@ -1,8 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="24px" height="24px" viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
width="24px" height="24px" viewBox="0 0 24 24" fill="#F0EDE6">
<path d="M18.993,8.993L18.99,5c0-1.1-0.891-2-1.99-2h-4h-3H6C4.9,3,4.01,3.9,4.01,5L4.007,8.993L4,9v10c0,1.1,0.891,2,1.99,2H6h11
h0.01c1.1,0,1.99-0.9,1.99-2V9L18.993,8.993z M17,10v9H6v-9V5h5h1h5V10z"/>
<rect x="8" y="7" width="2" height="2"/>

Before

Width:  |  Height:  |  Size: 939 B

After

Width:  |  Height:  |  Size: 647 B

+2 -4
View File
@@ -1,8 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="24px" height="24px" viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
width="24px" height="24px" viewBox="0 0 24 24" fill="#F0EDE6">
<path d="M20.953,7.993L20.949,4c0-1.1-0.891-2-1.99-2h-4h-3h-3c-1.1,0-1.99,0.9-1.99,2h1.99h4h1h5h0.006v16h0.004
c1.1,0,1.99-0.9,1.99-2V8L20.953,7.993z"/>
<path d="M17.993,10.993L17.99,7c0-1.1-0.891-2-1.99-2h-4H9H6C4.9,5,4.01,5.9,4.01,7l-0.003,3.993L4,11v10c0,1.1,0.891,2,1.99,2H6h10

Before

Width:  |  Height:  |  Size: 919 B

After

Width:  |  Height:  |  Size: 627 B

+315 -114
View File
@@ -7,6 +7,7 @@ import ModelBrowser from "./ModelBrowser.jsx";
import MainMenu from "./MainMenu.jsx";
import GlobalSettings from "./GlobalSettings.jsx";
import GlobalEQ from "./GlobalEQ.jsx";
import { T } from "./theme.js";
// ── API Layer ──────────────────────────────────────────────────────
const API_BASE = "";
@@ -31,8 +32,12 @@ const API = {
listPresets: () => api("GET", "/api/presets"),
getPreset: (bank, program) => api("GET", `/api/presets/${bank}/${program}`),
activatePreset:(bank, program) => api("POST", `/api/presets/${bank}/${program}/activate`),
savePreset: (bank, program, name, tags) => api("PUT", "/api/presets", { bank, program, name, tags }),
setAudioProfile:(rate, period) => api("POST", "/api/audio/profile", { rate: parseInt(rate), period: parseInt(period) }),
savePreset: (bank, program, name, tags) => api("PUT", `/api/presets/${bank}/${program}`, { name, chain: [], master_volume: 0.8, tags }),
setAudioProfile:(rate, period) => api("POST", "/api/audio/profile", { profile: "stable", rate: parseInt(rate), period: parseInt(period) }),
getAudioProfile:() => api("GET", "/api/audio/profile"),
getRouting: () => api("GET", "/api/routing"),
setRouting: (mode, breakpoint) => api("POST", "/api/routing", { routing_mode: mode, routing_breakpoint: breakpoint }),
saveSettings: (settings) => api("POST", "/api/settings", settings),
searchModels: (q) => api("GET", `/api/tonehub/search?q=${encodeURIComponent(q)}`),
searchIrs: (q) => api("GET", `/api/tonehub/search/irs?q=${encodeURIComponent(q)}`),
installModel: (url, name) => api("POST", "/api/models/tonedownload/install", { download_url: url, name }),
@@ -40,23 +45,6 @@ const API = {
tunerToggle: (enabled) => api("POST", "/api/tuner", { enabled }),
};
// ── Design tokens ──────────────────────────────────────────────
const T = {
bg: "#0A0A0C",
panel: "#141418",
surface: "#1C1C22",
border: "#2A2A32",
amber: "#E8A030",
amberDim: "#7A5218",
blue: "#3A7BA8",
blueDim: "#1E4060",
green: "#3AB87A",
red: "#C84040",
textPrimary: "#F0EDE6",
textSec: "#8888A0",
textDim: "#444458",
};
// ── Block parameter definitions ─────────────────────────────────
const SPLIT_TYPES = [
{ value:'y', label:'Y — Split evenly', pan:true },
@@ -93,10 +81,10 @@ const BLOCK_PARAMS = {
const BLOCK_COLORS = {
overdrive: T.amber, distortion: T.red, fuzz: '#C07030',
delay: T.blue, echo: T.blue, reverb: '#60B0D0',
delay: T.blue, echo: T.blue, reverb: '#5090A0',
chorus: T.green, flanger: '#50C080', phaser: '#80C050',
tremolo: T.green, compressor: '#D09040', gate: T.textSec, eq: '#7080D0',
cabinet: '#D07090', ir: '#D07090', nam: '#E0A040', boost: T.amber,
cabinet: '#D07090', ir: '#D07090', nam: '#E0A040', nam_amp: '#E0A040', boost: T.amber,
wah: '#D0A060', volume: T.textSec, tuner: '#80D0A0',
split: '#B080C0', merge: '#80B0C0',
};
@@ -106,7 +94,7 @@ const BLOCK_DISPLAY_NAMES = {
delay: "Digital Delay", echo: "Tape Echo", reverb: "Hall Reverb",
chorus: "Chorus", flanger: "Flanger", phaser: "Phase 90",
tremolo: "Tremolo", compressor: "Compressor", gate: "Noise Gate",
eq: "EQ", cabinet: "Cabinet", ir: "IR Loader", nam: "NAM Model",
eq: "EQ", cabinet: "Cabinet", ir: "IR Loader", nam: "NAM Model", nam_amp: "NAM Model",
boost: "Clean Boost", wah: "Wah", volume: "Volume", tuner: "Tuner",
loop: "FX Loop",
split: "Split", merge: "Merge",
@@ -135,15 +123,14 @@ function getBlockDisplayName(block) {
// ── CSS ─────────────────────────────────────────────────────
const css = `
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@300;400;500;600&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@300;400;500;600&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; user-select: none; }
html, body { height: 100%; overflow: hidden; background: ${T.bg}; }
body { color: ${T.textPrimary}; font-family: 'Inter', sans-serif; }
:root { --amber: ${T.amber}; --blue: ${T.blue}; --green: ${T.green}; }
html, body { height: 100%; overflow: hidden; background: var(--bg); }
body { color: var(--text-primary); font-family: 'Space Grotesk', sans-serif; }
.mono { font-family: 'JetBrains Mono', monospace; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: ${T.panel}; }
::-webkit-scrollbar-thumb { background: ${T.border}; border-radius: 2px; }
::-webkit-scrollbar-track { background: var(--panel); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* ── Knob ── */
.knob-wrap { display: flex; flex-direction: column; align-items: center; gap: 4px; cursor: pointer; }
@@ -152,64 +139,66 @@ const css = `
background: radial-gradient(circle at 35% 30%, #3A3A48, #1A1A22);
box-shadow: 0 2px 8px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.07); }
.knob-dot { position: absolute; width: 3px; border-radius: 2px; background: var(--amber);
left: 50%; transform-origin: bottom center; box-shadow: 0 0 6px var(--amber); }
.knob-label { font-size: 9px; color: ${T.textSec}; letter-spacing: .06em; text-transform: uppercase; text-align: center; }
.knob-value { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: ${T.amber}; }
left: 50%; transform-origin: bottom center; }
.knob-label { font-size: 10px; color: var(--text-sec); letter-spacing: .06em; text-transform: uppercase; text-align: center; }
.knob-value { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--amber); }
/* ── Buttons ── */
.btn { padding: 8px 14px; border-radius: 6px; font-size: 12px; font-weight: 600;
letter-spacing: .04em; border: none; cursor: pointer; transition: all .12s; }
.btn-primary { background: ${T.amber}; color: #000; }
.btn-primary { background: var(--amber); color: #000; }
.btn-primary:active { filter: brightness(.85); transform: scale(.97); }
.btn-ghost { background: ${T.surface}; color: ${T.textPrimary}; border: 1px solid ${T.border}; }
.btn-ghost:active { background: ${T.border}; }
.btn-icon { width: 32px; height: 32px; border-radius: 7px; display: flex; align-items: center;
justify-content: center; background: ${T.surface}; border: 1px solid ${T.border}; cursor: pointer;
.btn-ghost { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border); }
.btn-ghost:active { background: var(--border); }
.btn-icon { width: 44px; height: 44px; border-radius: 8px; display: flex; align-items: center;
justify-content: center; background: var(--surface); border: 1px solid var(--border); cursor: pointer;
font-size: 15px; transition: all .12s; flex-shrink: 0; }
.btn-icon:active { transform: scale(.93); }
.btn-icon.active { background: rgba(232,160,48,.15); border-color: ${T.amber}; color: ${T.amber}; }
.btn-icon.active { background: rgba(232,160,48,.15); border-color: var(--amber); color: var(--amber); }
.btn:focus-visible { outline: 2px solid #3AB87A; outline-offset: 2px; }
.btn-icon:focus-visible { outline: 2px solid #3AB87A; outline-offset: 2px; }
/* ── Badge ── */
.badge { display: inline-flex; align-items: center; justify-content: center;
padding: 2px 7px; border-radius: 4px; font-size: 10px; font-weight: 700;
letter-spacing: .06em; text-transform: uppercase; }
.badge-amber { background: rgba(232,160,48,.2); color: ${T.amber}; }
.badge-green { background: rgba(58,184,122,.2); color: ${T.green}; }
.badge-blue { background: rgba(58,123,168,.2); color: ${T.blue}; }
.badge-amber { background: rgba(232,160,48,.2); color: var(--amber); }
.badge-green { background: rgba(58,184,122,.2); color: var(--green); }
.badge-blue { background: rgba(74,139,200,.2); color: var(--blue); }
/* ── Param slider ── */
.param-panel { background: ${T.surface}; padding: 8px 12px 6px; border-top: 1px solid ${T.border}; flex-shrink: 0; }
.param-panel { background: var(--surface); padding: 8px 12px 6px; border-top: 1px solid var(--border); flex-shrink: 0; }
.param-panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2px; }
.param-panel-title { font-size: 10px; font-weight: 600; letter-spacing: .07em; text-transform: uppercase; color: ${T.textSec}; }
.param-panel-title { font-size: 11px; font-weight: 600; letter-spacing: .07em; text-transform: uppercase; color: var(--text-sec); }
.param-row { display: flex; flex-direction: column; gap: 1px; padding: 1px 0; }
.param-header { display: flex; justify-content: space-between; align-items: center; }
.param-label { font-size: 9px; font-weight: 500; text-transform: uppercase; letter-spacing: .06em; color: ${T.textSec}; }
.param-label { font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: .06em; color: var(--text-sec); }
.param-val { font-family: 'JetBrains Mono',monospace; font-size: 11px; font-weight: 600; }
.param-track-wrap { position: relative; height: 30px; display: flex; align-items: center; cursor: pointer; touch-action: none; }
.param-track { width: 100%; height: 6px; background: ${T.border}; border-radius: 3px; position: relative; overflow: hidden; }
.param-fill { height: 100%; border-radius: 3px; position: absolute; top: 0; left: 0; transition: width .04s; }
.param-track { width: 100%; height: 6px; background: var(--border); border-radius: 3px; position: relative; overflow: hidden; }
.param-fill { height: 100%; border-radius: 3px; position: absolute; top: 0; left: 0; transform-origin: left center; transition: transform .04s; }
.param-thumb { width: 22px; height: 22px; border-radius: 50%; position: absolute; top: 50%;
transform: translate(-50%,-50%); box-shadow: 0 1px 6px rgba(0,0,0,.6); pointer-events: none;
border: 3px solid ${T.bg}; }
.param-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid ${T.border};
background: ${T.panel}; color: ${T.textPrimary}; font-size: 14px; font-weight: 700;
border: 3px solid var(--bg); }
.param-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--border);
background: var(--panel); color: var(--text-primary); font-size: 14px; font-weight: 700;
display: flex; align-items: center; justify-content: center; cursor: pointer; line-height: 1; }
.param-btn:active { background: ${T.border}; transform: scale(.92); }
.param-btn:active { background: var(--border); transform: scale(.92); }
.param-knobs { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; padding: 4px 0; }
/* ── Screen header (overlays) ── */
.screen-header { padding: 10px 14px 8px; display: flex; align-items: center;
justify-content: space-between; border-bottom: 1px solid ${T.border}; }
.screen-title { font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: ${T.textSec}; }
justify-content: space-between; border-bottom: 1px solid var(--border); }
.screen-title { font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--text-sec); }
.screen-body { flex: 1; overflow-y: auto; padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; }
/* ── Preset sidebar chip ── */
.preset-chip-s { width: 100%; padding: 7px 6px; border-radius: 5px; background: ${T.surface}; border: 1px solid ${T.border};
.preset-chip-s { width: 100%; padding: 8px 8px; border-radius: 5px; background: var(--surface); border: 1px solid var(--border);
display: flex; flex-direction: column; align-items: center; cursor: pointer; transition: all .12s; }
.preset-chip-s.active { border-color: ${T.amber}; background: rgba(232,160,48,.08); }
.preset-chip-s.active { border-color: var(--amber); background: rgba(232,160,48,.08); }
.preset-chip-s:active { transform: scale(.96); }
.preset-chip-s .pcs-num { font-family: 'JetBrains Mono',monospace; font-size: 14px; font-weight: 700; color: ${T.amber}; line-height: 1.1; }
.preset-chip-s .pcs-name { font-size: 9px; color: ${T.textSec}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; text-align: center; line-height: 1.2; }
.preset-chip-s .pcs-num { font-family: 'JetBrains Mono',monospace; font-size: 14px; font-weight: 700; color: var(--amber); line-height: 1.1; }
.preset-chip-s .pcs-name { font-size: 9px; color: var(--text-sec); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; text-align: center; line-height: 1.2; }
.preset-chip-s .pcs-indicator { width: 4px; height: 4px; border-radius: 50%; margin-top: 3px; }
/* ── Preset row (captures) ── */
@@ -217,9 +206,35 @@ const css = `
border-radius: 6px; cursor: pointer; transition: background .12s; border: 1px solid transparent; }
.preset-row:active, .preset-row.active { background: rgba(232,160,48,.08); border-color: rgba(232,160,48,.25); }
.section-label { font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
color: ${T.textDim}; font-weight: 600; margin-bottom: 8px; }
.card { background: ${T.panel}; border: 1px solid ${T.border}; border-radius: 8px; padding: 14px; }
.card-sm { background: ${T.surface}; border: 1px solid ${T.border}; border-radius: 6px; padding: 10px; }
color: var(--text-dim); font-weight: 600; margin-bottom: 8px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px; }
.card-sm { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 10px; }
/* ── Responsive: narrow screens ── */
@media (max-width: 640px) {
.resp-hide-narrow { display: none !important; }
.resp-status-label { font-size: 8px; }
.resp-container { max-width: 100% !important; }
.resp-tight-pad { padding: 6px 10px; }
.param-panel { padding: 6px 10px 5px; }
.param-knobs { gap: 6px; }
.screen-header { padding: 8px 10px 6px; }
.screen-title { font-size: 11px; }
.screen-body { padding: 8px 10px; gap: 8px; }
.knob-wrap { transform: scale(.9); }
.btn-icon { width: 44px; height: 44px; font-size: 12px; }
}
/* ── Responsive: landscape short ── */
@media (orientation: landscape) and (max-height: 500px) {
.resp-status-bar { min-height: 26px; padding: 2px 10px; }
.resp-param-thumb { height: 26px; }
}
/* ── Responsive: medium screens (tablet portrait / landscape phone) ── */
@media (min-width: 641px) and (max-width: 1024px) {
.resp-sidebar-compact { max-width: 70px; }
}
`;
// ── Knob ──────────────────────────────────────────────────────
@@ -256,7 +271,7 @@ function Knob({ label, value = 50, onChange, size = 48, color = T.amber, min = 0
<div className="knob" style={{width:size,height:size}} onMouseDown={handleStart} onTouchStart={handleStart}>
<svg style={{position:"absolute",inset:-6,width:svgSize,height:svgSize,overflow:"visible"}}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={T.border} strokeWidth="2" strokeLinecap="round" opacity=".5"/>
{norm>0&&<path d={`M ${lx} ${ly} A ${r} ${r} 0 ${large} 1 ${ex} ${ey}`} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" style={{filter:`drop-shadow(0 0 3px ${color})`}}/>}
{norm>0&&<path d={`M ${lx} ${ly} A ${r} ${r} 0 ${large} 1 ${ex} ${ey}`} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" style={{opacity:0.8}}/>}
</svg>
<div className="knob-inner" style={{"--amber":color}}>
<div className="knob-dot" style={{height:dotH,top:dotTop,transform:`translateX(-50%) rotate(${angle}deg)`,transformOrigin:`50% ${dotH}px`}}/>
@@ -308,7 +323,7 @@ function ParamSlider({ label,value=50,onChange,onChangeEnd,min=0,max=100,color=T
</div>
<div className="param-track-wrap" ref={trackRef} onMouseDown={handleStart} onTouchStart={handleStart}>
<div className="param-track">
<div className="param-fill" style={{width:`${pct}%`,background:color}}/>
<div className="param-fill" style={{transform:`scaleX(${pct/100})`,background:color}}/>
<div className="param-thumb" style={{left:`${pct}%`,background:color}}/>
</div>
</div>
@@ -362,7 +377,7 @@ function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,o
<div style={{display:'flex',gap:4,alignItems:'center'}}>
{onFocus&&<button className="btn" style={{padding:'4px 8px',fontSize:9,fontWeight:600,background:`${T.amber}22`,border:`1px solid ${T.amber}44`,color:T.amber,borderRadius:5,letterSpacing:'.04em',cursor:'pointer'}}
onClick={onFocus}>🔍 Focus</button>}
<button className="btn-icon" style={{width:26,height:26,fontSize:11,flexShrink:0}}
<button className="btn-icon" style={{width:44,height:44,fontSize:14,flexShrink:0}}
onClick={onToggleView}>{viewMode==='slider'?'◉':'▦'}</button>
</div>
</div>
@@ -391,11 +406,12 @@ function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,o
// ── VU Meter ──────────────────────────────────────────────────
function VUMeter({level=0,height=24,vertical=false}){const segs=12,active=Math.round((level/100)*segs);
const segH=Math.max(3,(height-segs*1.5)/segs);
const c=i=>i>=10?T.red:i>=8?"#E8C030":T.green;
if(vertical)return(<div style={{display:"flex",flexDirection:"column-reverse",gap:1,height,alignItems:"center"}}>
{Array.from({length:segs}).map((_,i)=>(<div key={i} style={{width:4,height:(height-segs)/segs,borderRadius:1,background:i<active?c(i):T.border,boxShadow:i<active?`0 0 4px ${c(i)}`:"none",transition:"background .05s"}}/>))}</div>);
if(vertical)return(<div style={{display:"flex",flexDirection:"column-reverse",gap:1.5,height,alignItems:"center"}}>
{Array.from({length:segs}).map((_,i)=>(<div key={i} style={{width:4,height:segH,borderRadius:1.5,background:i<active?c(i):T.border,transition:"all .2s ease, opacity .15s"}}/>))}</div>);
return(<div style={{display:"flex",gap:2,height:6,alignItems:"center",width:height}}>
{Array.from({length:segs}).map((_,i)=>(<div key={i} style={{flex:1,height:"100%",borderRadius:1,background:i<active?c(i):T.border,transition:"background .05s"}}/>))}</div>);
{Array.from({length:segs}).map((_,i)=>(<div key={i} style={{flex:1,height:"100%",borderRadius:1,background:i<active?c(i):T.border,transition:"all .2s ease"}}/>))}</div>);
}
// Captures Screen
@@ -406,7 +422,7 @@ function CapturesScreen({onClose}){
const doSearch=async()=>{if(!query.trim())return;setLoading(true);try{const data=tab==="models"?await API.searchModels(query):await API.searchIrs(query);setResults(data.results||[]);}catch{setResults([]);}setLoading(false);};
const install=async(item)=>{try{if(tab==="models"){await API.installModel(item.download_url,item.name);}else{await API.installIr(item.download_url,item.name);}API.listModels().then(d=>setModels(d.models||[])).catch(()=>{});}catch(e){alert("Install failed: "+e.message);}};
return(<div style={{display:"flex",flexDirection:"column",height:"100%",background:T.bg}}>
<div className="screen-header"><div className="screen-title">Downloads</div><button className="btn-icon" style={{width:28,height:28,fontSize:13}}onClick={onClose}></button></div>
<div className="screen-header"><div className="screen-title">Downloads</div><button className="btn-icon" style={{width:44,height:44,fontSize:16}}onClick={onClose}></button></div>
<div style={{padding:"6px 12px",borderBottom:`1px solid ${T.border}`}}><div className="toggle">
<button className={`toggle-btn ${tab==="models"?"active":""}`}onClick={()=>setTab("models")}>NAM Models</button>
<button className={`toggle-btn ${tab==="irs"?"active":""}`}onClick={()=>setTab("irs")}>IR Cabs</button></div></div>
@@ -414,7 +430,7 @@ function CapturesScreen({onClose}){
<div style={{flex:1,display:"flex",background:T.surface,borderRadius:6,border:`1px solid ${T.border}`,padding:"6px 10px",gap:6,alignItems:"center"}}>
<span style={{color:T.textDim,fontSize:11}}>🔍</span>
<input placeholder={`Search ${tab==="models"?"NAM models":"IR cabs"}...`}value={query}onChange={e=>setQuery(e.target.value)}onKeyDown={e=>e.key==="Enter"&&doSearch()}style={{flex:1,background:"none",border:"none",color:T.textPrimary,fontSize:12,outline:"none"}}/></div>
<button className="btn btn-primary"style={{padding:"6px 11px",fontSize:11}}onClick={doSearch}>{loading?"...":"Search"}</button></div>
<button className="btn btn-primary"style={{padding:"6px 11px",fontSize:11,color:"#000"}}onClick={doSearch}>{loading?"...":"Search"}</button></div>
<div className="screen-body">
{results===null&&models.length>0&&(<><div className="section-label">Installed Models</div>
{models.map((m,i)=>(<div key={i}className="preset-row"style={{cursor:"default"}}>
@@ -428,18 +444,17 @@ function CapturesScreen({onClose}){
// ── MOCK DATA (used when pedal not connected) ────────────────
const MOCK_BLOCKS=[
{id:"gate",type:"gate",name:"Noise Gate",bypassed:false,params:{threshold:40,release:25,depth:70,mix:100}},
{id:"split",type:"split",name:"Split",bypassed:false,params:{splitType:"y",panA:50,panB:50,activePath:"A"}},
{id:"drive_a",type:"overdrive",name:"OD-808",path:"A",bypassed:false,params:{drive:65,tone:55,level:70}},
{id:"delay_a",type:"delay",name:"Digital Delay",path:"A",bypassed:false,params:{time:45,feedback:25,mix:40}},
{id:"fuzz_b",type:"fuzz",name:"Big Muff",path:"B",bypassed:false,params:{fuzz:75,tone:40,level:65}},
{id:"merge",type:"merge",name:"Merge",bypassed:false,params:{blend:100,levelA:100,panA:0,levelB:100,panB:0}},
{id:"reverb",type:"reverb",name:"Hall Reverb",bypassed:false,params:{decay:60,mix:35,tone:50}},
{id:"gate",type:"gate",name:"Noise Gate",bypassed:false,params:{threshold:40,release:25}},
{id:"comp",type:"compressor",name:"Compressor",bypassed:false,params:{threshold:50,ratio:4,gain:50}},
{id:"amp",type:"nam",name:"Twin Reverb",bypassed:false,params:{level:75}},
{id:"cab",type:"ir",name:"2x12 American",bypassed:false,params:{level:75}},
{id:"eq",type:"eq",name:"EQ",bypassed:false,params:{low:0,mid:0,high:0,level:70}},
{id:"reverb",type:"reverb",name:"Hall Reverb",bypassed:false,params:{subtype:"hall",decay:50,mix:30,tone:50}},
];
const MOCK_PRESETS=[
{num:1,bank:1,name:"Plexi Crunch"},{num:2,bank:1,name:"Modern Hi-Gain"},
{num:3,bank:1,name:"Clean Chime"},{num:4,bank:1,name:"Blues Drive"},
{num:1,bank:1,name:"Plexi Crunch"},{num:2,bank:1,name:"Blues Rock Lead"},
{num:3,bank:1,name:"Deluxe Clean"},{num:4,bank:1,name:"British Drive"},
{num:5,bank:2,name:"Metal Djent"},{num:6,bank:2,name:"Funky Wah"},
{num:7,bank:2,name:"Ambient Pad"},{num:8,bank:2,name:"Shoegaze"},
];
@@ -459,8 +474,26 @@ export default function App(){
const[globalBypass,setGlobalBypass]=useState(false);
const[loadingPreset,setLoadingPreset]=useState(false);
const[focusBlockId,setFocusBlockId]=useState(null);
const[modelBrowserBlockId,setModelBrowserBlockId]=useState(null);
const[footswitchMode,setFootswitchMode]=useState("stomp");
const [modelBrowserBlockId,setModelBrowserBlockId]=useState(null);
const [footswitchMode,setFootswitchMode]=useState("stomp");
const [addBlockType,setAddBlockType]=useState(null); // null | "picker"
const [sidebarOpen, setSidebarOpen] = useState(true);
// ── Responsive breakpoints ──
const [isNarrow, setIsNarrow] = useState(window.innerWidth < 640);
const [isLandscapeShort, setIsLandscapeShort] = useState(window.innerWidth > window.innerHeight && window.innerHeight < 500);
useEffect(() => {
const mqNarrow = window.matchMedia("(max-width: 640px)");
const mqShort = window.matchMedia("(orientation: landscape) and (max-height: 500px)");
const onNarrow = (e) => setIsNarrow(e.matches);
const onShort = (e) => setIsLandscapeShort(e.matches);
mqNarrow.addEventListener("change", onNarrow);
mqShort.addEventListener("change", onShort);
// Initialize sidebar state
if (mqNarrow.matches) setSidebarOpen(false);
return () => { mqNarrow.removeEventListener("change", onNarrow); mqShort.removeEventListener("change", onShort); };
}, []);
// ── Undo/Redo ──
const [undoStack, setUndoStack] = useState([]);
@@ -543,6 +576,7 @@ export default function App(){
// ── Main Menu & Settings ──
const [menuOpen, setMenuOpen] = useState(false);
const [settingsView, setSettingsView] = useState(null); // "settings" | "eq" | null
const [overlayView, setOverlayView] = useState(null); // "save" | "bypass" | "wifi" | null
const handleMenuSelect = useCallback((itemId) => {
setMenuOpen(false);
@@ -553,8 +587,14 @@ export default function App(){
setState(prev => ({ ...prev, tuner_enabled: true }));
API.tunerToggle(true).catch(() => {});
break;
default:
// save, bypass, wifi — placeholder for now
case "save":
setOverlayView("save");
break;
case "bypass":
setOverlayView("bypass");
break;
case "wifi":
setOverlayView("wifi");
break;
}
}, []);
@@ -581,7 +621,7 @@ export default function App(){
useEffect(()=>{loadState();const id=setInterval(loadState,3000);return()=>clearInterval(id);},[loadState]);
// VU animation fallback when no real data
useEffect(()=>{const id=setInterval(()=>{setVuLevel(p=>Math.max(0,Math.min(100,p+(Math.random()-0.45)*8)));},150);return()=>clearInterval(id);},[]);
useEffect(()=>{const id=setInterval(()=>{setVuLevel(p=>Math.max(0,Math.min(100,p+(Math.random()-0.5)*50+3)));},120);return()=>clearInterval(id);},[]);
// ── Load presets from API ──
useEffect(()=>{
@@ -646,7 +686,7 @@ export default function App(){
const p=presets.find(pr=>pr.num===num);
if(!p)return;
setCurrentPreset(num);
await handleLoadPreset(p.bank||1,num-1);
await handleLoadPreset((p.bank||1)-1,num-1);
},[presets,handleLoadPreset]);
// ── Blocks ──
@@ -678,9 +718,18 @@ export default function App(){
setBlocks(prev=>{const arr=[...prev];const[m]=arr.splice(fromIdx,1);arr.splice(toIdx,0,m);return arr;});
},[takeSnapshot]);
const handleAddBlock=useCallback(()=>{
setAddBlockType("picker");
},[takeSnapshot]);
const handleAddBlockType=useCallback((type)=>{
takeSnapshot();
userChangedBlock.current=true;const id="block_"+Date.now();
setBlocks(prev=>[...prev,{id,type:"overdrive",name:"New Drive",bypassed:false,params:{drive:30,tone:50,level:70}}]);
userChangedBlock.current=true;
const id="block_"+Date.now();
const defs=getBlockParams(type);
const init={};
defs.forEach(p=>{init[p.key]=p.def;});
const displayName=BLOCK_DISPLAY_NAMES[type]||type.charAt(0).toUpperCase()+type.slice(1);
setBlocks(prev=>[...prev,{id,type,name:displayName,bypassed:false,params:init}]);
setAddBlockType(null);
setTimeout(()=>{userChangedBlock.current=false;},3000);
},[takeSnapshot]);
const handleRemoveBlock=useCallback((id)=>{
@@ -699,14 +748,14 @@ export default function App(){
const next=Math.min(currentBank+1,8);
setCurrentBank(next);
const bp=presets.filter(p=>p.bank===next);
if(bp.length>0)handleLoadPreset(bp[0].bank,bp[0].num-1);
if(bp.length>0)handleLoadPreset(bp[0].bank-1,bp[0].num-1);
},[currentBank,presets,handleLoadPreset]);
const handleBankDown=useCallback(()=>{
const next=Math.max(currentBank-1,1);
setCurrentBank(next);
const bp=presets.filter(p=>p.bank===next);
if(bp.length>0)handleLoadPreset(bp[0].bank,bp[0].num-1);
if(bp.length>0)handleLoadPreset(bp[0].bank-1,bp[0].num-1);
},[currentBank,presets,handleLoadPreset]);
const handleGlobalBypass=useCallback(()=>{
@@ -771,25 +820,30 @@ export default function App(){
return(
<><style>{css}</style>
<div style={{width:"100%",height:"100vh",display:"flex",flexDirection:"column",background:T.bg,overflow:"hidden",
fontFamily:"'Inter',sans-serif",margin:"0 auto",
maxWidth:980,
fontFamily:"'Space Grotesk',sans-serif",margin:"0 auto",
maxWidth:isNarrow?undefined:980,
}}>
{view==="main"&&(
<>
{/* ══════ STATUS BAR ══════ */}
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",
padding:"4px 12px",background:T.panel,borderBottom:`1px solid ${T.border}`,flexShrink:0,minHeight:32}}>
<div className="resp-status-bar" style={{display:"flex",justifyContent:"space-between",alignItems:"center",
padding:isLandscapeShort?"2px 10px":"4px 12px",background:T.panel,borderBottom:`1px solid ${T.border}`,flexShrink:0,minHeight:isLandscapeShort?26:32}}>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
{/* Sidebar toggle (narrow only) */}
{isNarrow&&<button className="btn-icon" style={{width:44,height:44,fontSize:16,position:'relative',marginRight:-2}}
onClick={()=>setSidebarOpen(s=>!s)} title="Presets">
<span style={{fontSize:12,lineHeight:1}}></span>
</button>}
<div style={{width:6,height:6,borderRadius:"50%",
background:state.connected?T.green:T.red,boxShadow:`0 0 6px ${state.connected?T.green:T.red}`}}/>
<span className="mono" style={{fontSize:9,color:T.textSec}}>{state.connected?"CONNECTED":"OFFLINE"}</span>
background:state.connected?T.green:T.red}}/>
{!isNarrow&&<span className="mono" style={{fontSize:9,color:state.connected?T.green:T.red}}>{state.connected?"CONNECTED":"OFFLINE"}</span>}
{/* Undo/Redo */}
<button className="btn-icon" style={{width:20,height:20,fontSize:11,opacity:undoStack.length>0?1:0.3}}
<button className="btn-icon" style={{width:44,height:44,fontSize:14,opacity:undoStack.length>0?1:0.3}}
onClick={handleUndo} disabled={undoStack.length===0} title="Undo (Ctrl+Z)">
</button>
<button className="btn-icon" style={{width:20,height:20,fontSize:11,opacity:redoStack.length>0?1:0.3}}
<button className="btn-icon" style={{width:44,height:44,fontSize:14,opacity:redoStack.length>0?1:0.3}}
onClick={handleRedo} disabled={redoStack.length===0} title="Redo (Ctrl+Shift+Z)">
</button>
@@ -800,41 +854,41 @@ export default function App(){
{loadingPreset&&<span className="mono" style={{fontSize:8,color:T.amber}}></span>}
</div>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
{!isNarrow&&<div style={{display:"flex",gap:8,alignItems:"center"}}>
<VUMeter level={vuLevel} height={24}/>
<span className="mono" style={{fontSize:9,color:T.textDim}}>CPU {state.cpu_percent!=null?`${state.cpu_percent}%`:"—"}</span>
</div>
<span className="mono" style={{fontSize:9,color:T.textSec}}>CPU <span style={{color:T.amber,fontWeight:700}}>{state.cpu_percent!=null?`${state.cpu_percent}%`:"—"}</span></span>
</div>}
<div style={{width:1,height:12,background:T.border}}/>
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}onClick={()=>setView("captures")}title="Downloads">
<img src={`${import.meta.env.BASE_URL}img/ic_bank.svg`} alt="captures" style={{width:14,height:14,filter:'invert(1) brightness(2)'}}/>
<button className="btn-icon" style={{width:44,height:44,position:"relative"}}onClick={()=>setView("captures")} title="Downloads">
<img src={`${import.meta.env.BASE_URL}img/ic_bank.svg`} alt="captures" style={{width:14,height:14}}/>
</button>
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}onClick={()=>setView("presets")}title="Presets">
<img src={`${import.meta.env.BASE_URL}img/ic_presets.svg`} alt="presets" style={{width:14,height:14,filter:'invert(1) brightness(2)'}}/>
<button className="btn-icon" style={{width:44,height:44,position:"relative"}}onClick={()=>setView("presets")}title="Presets">
<img src={`${import.meta.env.BASE_URL}img/ic_presets.svg`} alt="presets" style={{width:14,height:14}}/>
</button>
<div style={{display:"flex",gap:2,alignItems:"center",background:T.surface,borderRadius:4,border:`1px solid ${T.border}`,padding:2,height:24}}>
{["stomp","preset"].map(m=>(<button key={m} onClick={()=>setFootswitchMode(m)} style={{
padding:"1px 5px",borderRadius:3,border:"none",height:20,
padding:"1px 5px",borderRadius:3,border:"none",height:44,
fontSize:8,fontWeight:700,letterSpacing:".04em",textTransform:"uppercase",
background:footswitchMode===m?T.amber:"transparent",
color:footswitchMode===m?"#000":T.textDim,
cursor:"pointer",lineHeight:1.5,minWidth:34,display:"flex",alignItems:"center",justifyContent:"center",
cursor:"pointer",lineHeight:1.5,minWidth:44,display:"flex",alignItems:"center",justifyContent:"center",
}}>{m==="stomp"?"SM":"PR"}</button>))}
</div>
<button className="btn-icon" style={{width:24,height:24,position:'relative',
background:state.tuner_enabled?'rgba(128,208,160,.15)':T.surface,
borderColor:state.tuner_enabled?T.green:T.border}}
onClick={handleTunerToggle} title={state.tuner_enabled?"Exit Tuner":"Tuner"}>
<img src={`${import.meta.env.BASE_URL}img/fx_analyzer.svg`} alt="tuner" style={{width:14,height:14,filter:state.tuner_enabled?'invert(0.9) brightness(2) sepia(0.4) hue-rotate(80deg)':'invert(1) brightness(2)'}}/>
<button className="btn-icon" style={{width:44,height:44,position:"relative",
background:state.tuner_enabled?"rgba(128,208,160,.15)":T.surface,
borderColor:state.tuner_enabled?T.green:T.border}}
onClick={handleTunerToggle} title={state.tuner_enabled?"Exit Tuner":"Tuner"}>
<img src={`${import.meta.env.BASE_URL}img/fx_analyzer.svg`} alt="tuner" style={{width:14,height:14,filter:state.tuner_enabled?'brightness(0) saturate(100%) invert(0.9) sepia(0.4) hue-rotate(80deg)':'none'}}/>
</button>
<button className="btn-icon" style={{width:24,height:24,position:'relative',background:'rgba(232,160,48,.12)',borderColor:T.amber}}onClick={()=>{if(!document.fullscreenElement){document.documentElement.requestFullscreen()}else{document.exitFullscreen()}}}title="Fullscreen">
<button className="btn-icon" style={{width:44,height:44,position:'relative',background:'rgba(232,160,48,.12)',borderColor:T.amber}}onClick={()=>{if(!document.fullscreenElement){document.documentElement.requestFullscreen()}else{document.exitFullscreen()}}}title="Fullscreen">
<svg viewBox="0 0 24 24" width="14" height="14" fill="#E8A030">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
</svg>
</button>
<div style={{width:1,height:16,background:T.border}}/>
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}
<button className="btn-icon" style={{width:44,height:44,position:"relative"}}
onClick={()=>setMenuOpen(true)} title="Main Menu">
<span style={{fontSize:14,color:T.textSec,lineHeight:1}}></span>
<span style={{fontSize:14,color:T.amber,lineHeight:1}}></span>
</button>
</div>
</div>
@@ -843,8 +897,22 @@ export default function App(){
<div style={{flex:1,display:"flex",overflow:"hidden"}}>
{/* ─── Preset Sidebar ─── */}
<div style={{width:90,flexShrink:0,background:T.surface,borderRight:`1px solid ${T.border}`,
display:"flex",flexDirection:"column",overflow:"hidden"}}>
{(!isNarrow || sidebarOpen)&&<div style={{
width:isNarrow?"100%":90,flexShrink:0,background:T.surface,
borderRight:isNarrow?"none":`1px solid ${T.border}`,
borderBottom:isNarrow?`1px solid ${T.border}`:"none",
display:"flex",flexDirection:"column",overflow:"hidden",
position:isNarrow?"fixed":"relative",
zIndex:isNarrow?50:"auto",
left:0,right:0,
top:"auto",
bottom:isNarrow?0:"auto",
height:isNarrow?140:"100%",
borderTopLeftRadius:isNarrow?12:0,
borderTopRightRadius:isNarrow?12:0,
boxShadow:isNarrow?"0 -4px 24px rgba(0,0,0,.6)":"none",
transform:isNarrow&&sidebarOpen?"translateY(0)":"none",
}}>
{/* Bank header */}
<div style={{padding:"6px 8px",textAlign:"center",borderBottom:`1px solid ${T.border}`,background:T.panel}}>
<div className="mono" style={{fontSize:14,fontWeight:700,color:T.amber}}>Bank {bankLetter}</div>
@@ -862,7 +930,10 @@ export default function App(){
</div>
))}
</div>
</div>
</div>}
{/* Backdrop for narrow sidebar */}
{isNarrow&&sidebarOpen&&<div style={{position:"fixed",inset:0,zIndex:49,background:"rgba(0,0,0,.5)"}}
onClick={()=>setSidebarOpen(false)}/>}
{/* ─── Main Content Area ─── */}
<div style={{flex:1,display:"flex",flexDirection:"column",overflow:"hidden",minWidth:0}}>
@@ -893,6 +964,7 @@ export default function App(){
onAddBlock={handleAddBlock}
onRemoveBlock={handleRemoveBlock}
onModelBrowser={handleOpenModelBrowser}
onBlockSelected={handleAddBlockType}
/>
</div>
@@ -955,7 +1027,7 @@ export default function App(){
{view==="captures"&&<CapturesScreen onClose={()=>setView("main")}/>}
{view==="presets"&&(
<div style={{display:"flex",flexDirection:"column",height:"100%",background:T.bg}}>
<div className="screen-header"><div className="screen-title">Presets</div><button className="btn-icon"style={{width:28,height:28,fontSize:13}}onClick={()=>setView("main")}></button></div>
<div className="screen-header"><div className="screen-title">Presets</div><button className="btn-icon"style={{width:44,height:44,fontSize:16}}onClick={()=>setView("main")}></button></div>
<div className="screen-body">
{presets.map((p,i)=>(
<div key={i} className={`preset-row ${p.num===currentPreset?"active":""}`}
@@ -1012,6 +1084,8 @@ export default function App(){
onClose={()=>setSettingsView(null)}
onMasterVolume={(v)=>API.masterVolume(v).catch(()=>{})}
onAudioProfile={(rate, period)=>API.setAudioProfile(rate, period).catch(()=>{})}
onRoutingChange={(mode, bp)=>API.setRouting(mode, bp).catch(()=>{})}
onSaveSettings={(s)=>API.saveSettings(s).catch(()=>{})}
/>
)}
@@ -1022,6 +1096,133 @@ export default function App(){
/>
)}
{/* ── Save Preset overlay ── */}
{overlayView==="save"&&(
<div style={{position:"fixed",inset:0,zIndex:1000,background:"rgba(0,0,0,.7)",display:"flex",alignItems:"center",justifyContent:"center"}}>
<div className="card" style={{width:320,maxWidth:"85vw",padding:20}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16}}>
<div style={{fontSize:14,fontWeight:600,color:T.textPrimary}}>💾 Save Preset</div>
<button className="btn-icon" style={{width:44,height:44,fontSize:14}}onClick={()=>setOverlayView(null)}></button>
</div>
<div style={{display:"flex",flexDirection:"column",gap:12}}>
<div>
<div style={{fontSize:9,color:T.textSec,marginBottom:4,textTransform:"uppercase",letterSpacing:".06em"}}>Preset Name</div>
<input defaultValue={presets.find(p=>p.num===currentPreset)?.name||""}
style={{width:"100%",padding:"8px 10px",borderRadius:6,border:`1px solid ${T.border}`,background:T.surface,color:T.textPrimary,fontSize:13,outline:"none"}}/>
</div>
<div>
<div style={{fontSize:9,color:T.textSec,marginBottom:4,textTransform:"uppercase",letterSpacing:".06em"}}>Target</div>
<div style={{display:"flex",gap:4,flexWrap:"wrap"}}>
{[1,2,3,4].map(slot=>(
<button key={slot} className="btn-ghost" style={{flex:1,padding:"6px",fontSize:10,textAlign:"center"}}
onClick={()=>{setOverlayView(null);API.savePreset(currentBank-1,slot-1,"Saved Preset",[]).catch(()=>{});}}>
Slot {slot}<br/>
<span style={{fontSize:8,color:T.textDim}}>{presets.find(p=>p.num===slot)?.name||"Empty"}</span>
</button>
))}
</div>
</div>
</div>
</div>
</div>
)}
{/* ── Bypass/Control overlay ── */}
{overlayView==="bypass"&&(
<div style={{position:"fixed",inset:0,zIndex:1000,background:"rgba(0,0,0,.7)",display:"flex",alignItems:"center",justifyContent:"center"}}>
<div className="card" style={{width:340,maxWidth:"85vw",padding:20}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16}}>
<div style={{fontSize:14,fontWeight:600,color:T.textPrimary}}>🔌 Bypass / Control</div>
<button className="btn-icon" style={{width:44,height:44,fontSize:14}}onClick={()=>setOverlayView(null)}></button>
</div>
<div style={{display:"flex",flexDirection:"column",gap:14}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center"}}>
<span style={{fontSize:12,color:T.textPrimary}}>🚫 Global Bypass</span>
<button className="btn-icon" style={{width:44,height:44,fontSize:10,background:globalBypass?`${T.red}22`:T.surface,borderColor:globalBypass?T.red:T.border}}
onClick={()=>{setGlobalBypass(p=>!p);API.bypassToggle().catch(()=>{});}}>
{globalBypass?"ON":"OFF"}
</button>
</div>
<div style={{height:1,background:T.border}}/>
<div>
<div style={{fontSize:9,color:T.textSec,marginBottom:6,textTransform:"uppercase",letterSpacing:".06em"}}>Block Bypass States</div>
{blocks.map(b=>(
<div key={b.id} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"4px 0"}}>
<span style={{fontSize:11,color:T.textPrimary}}>{b.name||b.type}</span>
<button className="btn-icon" style={{width:44,height:44,fontSize:11,background:b.bypassed?`${T.red}22`:T.surface,borderColor:b.bypassed?T.red:T.border}}
onClick={()=>handleToggleBlock(b.id)}>
{b.bypassed?"BYP":"ACT"}
</button>
</div>
))}
</div>
<div style={{height:1,background:T.border}}/>
<div>
<div style={{fontSize:9,color:T.textSec,marginBottom:6,textTransform:"uppercase",letterSpacing:".06em"}}>Footswitch Mode</div>
<div style={{display:"flex",gap:4,background:"#0A0A10",borderRadius:6,padding:4}}>
{["stomp","preset"].map(m=>(
<button key={m} onClick={()=>setFootswitchMode(m)} style={{
flex:1,padding:"5px 8px",borderRadius:4,border:"none",fontSize:10,fontWeight:600,
cursor:"pointer",background:footswitchMode===m?T.amber:"transparent",
color:footswitchMode===m?"#000":T.textSec,
}}>{m==="stomp"?"Stompbox":"Preset"}</button>
))}
</div>
</div>
</div>
</div>
</div>
)}
{/* ── Wi-Fi / Bluetooth overlay ── */}
{overlayView==="wifi"&&(
<div style={{position:"fixed",inset:0,zIndex:1000,background:"rgba(0,0,0,.7)",display:"flex",alignItems:"center",justifyContent:"center"}}>
<div className="card" style={{width:340,maxWidth:"85vw",padding:20}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16}}>
<div style={{fontSize:14,fontWeight:600,color:T.textPrimary}}>📶 Wireless</div>
<button className="btn-icon" style={{width:44,height:44,fontSize:14}}onClick={()=>setOverlayView(null)}></button>
</div>
<div style={{display:"flex",flexDirection:"column",gap:14}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center"}}>
<span style={{fontSize:12,color:T.textPrimary}}>📶 WiFi</span>
<button className="btn-icon" style={{width:44,height:44,fontSize:11}}>Scan</button>
</div>
<div className="card-sm" style={{textAlign:"center",padding:16}}>
<span style={{fontSize:11,color:T.textSec}}>Use pedal as access point or connect to existing network via the settings panel.</span>
</div>
<div style={{height:1,background:T.border}}/>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center"}}>
<span style={{fontSize:12,color:T.textPrimary}}>🔵 Bluetooth</span>
<button className="btn-icon" style={{width:44,height:44,fontSize:11,background:`${T.blue}22`,borderColor:T.blue,color:T.blue}}>ON</button>
</div>
<div style={{fontSize:10,color:T.textSec}}>Bluetooth MIDI is enabled. Pair with your mobile device or MIDI controller.</div>
</div>
</div>
</div>
)}
{/* ── Block Type Picker ── */}
{addBlockType==="picker"&&(
<div style={{position:"fixed",inset:0,zIndex:1000,background:"rgba(0,0,0,.7)",display:"flex",alignItems:"center",justifyContent:"center"}}
onClick={(e)=>e.target===e.currentTarget&&setAddBlockType(null)}>
<div className="card" style={{width:320,maxWidth:"85vw",padding:20,maxHeight:"80vh",overflowY:"auto"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:12}}>
<div style={{fontSize:13,fontWeight:600,color:T.textPrimary}}>+ Add Block</div>
<button className="btn-icon" style={{width:44,height:44,fontSize:14}}onClick={()=>setAddBlockType(null)}></button>
</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:6}}>
{Object.entries(BLOCK_DISPLAY_NAMES).filter(([k])=>!["loop","split","merge"].includes(k)).map(([type,name])=>(
<button key={type} className="btn-ghost" style={{padding:"8px 6px",fontSize:10,textAlign:"center",lineHeight:1.3}}
onClick={()=>handleAddBlockType(type)}>
<div style={{fontWeight:600}}>{name}</div>
<div style={{fontSize:8,color:T.textDim,marginTop:1,textTransform:"capitalize"}}>{type}</div>
</button>
))}
</div>
</div>
</div>
)}
</div></>
);
}
+6 -23
View File
@@ -1,21 +1,5 @@
import { useState, useRef, useCallback, useMemo } from "react";
// ── Design tokens ──────────────────────────────────
const T = {
bg: "#0A0A0C",
panel: "#141418",
surface: "#1C1C22",
border: "#2A2A32",
amber: "#E8A030",
amberDim: "#7A5218",
blue: "#3A7BA8",
blueDim: "#1E4060",
green: "#3AB87A",
red: "#C84040",
textPrimary: "#F0EDE6",
textSec: "#8888A0",
textDim: "#444458",
};
import { T } from "./theme.js";
// ── Split type metadata ────────────────────────────
const SPLIT_TYPES = {
@@ -32,7 +16,7 @@ const TYPE_COLORS = {
fuzz: "#D060E0",
mod: T.blue, chorus: T.blue, flange: T.blue, phaser: T.blue,
tremolo: T.blue, vibrato: T.blue, rotary: T.blue,
delay: "#60A0E0", echo: "#60A0E0", reverb: "#60C0D0",
delay: "#6080B0", echo: "#6080B0", reverb: "#5090A0",
pitch: "#A060E0", comp: T.green, compressor: T.green,
gate: T.green, filter: "#D0A060", wah: "#D0A060",
eq: "#80A0B0", ir: T.green, cab: T.green, nam: "#E0A040",
@@ -95,7 +79,7 @@ function BlockIcon({ type, size = 14, style = {} }) {
alt={type || 'fx'}
style={{
width: size, height: size, flexShrink: 0,
filter: 'invert(0.95) brightness(2)',
filter: 'none',
objectFit: 'contain',
...style,
}}
@@ -389,12 +373,11 @@ function BlockTile({ block, selected, onSelect, onToggle, pathLabel, onModelBrow
<div style={{
position: "absolute", top: 0, left: 0, right: 0, height: 3,
background: color,
boxShadow: enabled ? `0 0 6px ${color}` : "none",
}} />
{/* Drag handle */}
<div style={{
position: "absolute", top: 5, left: 5, fontSize: 9, color: T.textDim,
letterSpacing: 1.5, lineHeight: 1, cursor: "grab", opacity: 0.5,
letterSpacing: 1.5, lineHeight: 1, cursor: "grab",
}}>
</div>
@@ -865,8 +848,8 @@ export default function BlockChain({
</button>
{selectedBlockId && (
<button onClick={() => onRemoveBlock(selectedBlockId)} style={{
padding: "10px 14px", borderRadius: 7, background: "rgba(200,64,64,.15)",
border: `1px solid rgba(200,64,64,.3)`, color: T.red, fontSize: 12,
padding: "10px 14px", borderRadius: 7, background: "rgba(224,96,96,.15)",
border: `1px solid rgba(224,96,96,.3)`, color: T.red, fontSize: 12,
fontWeight: 600, cursor: "pointer", transition: "all .12s",
}}>
Remove
+12 -27
View File
@@ -1,21 +1,5 @@
import { useState, useRef, useCallback, useMemo } from "react";
// ── Design tokens ──
const T = {
bg: "#0A0A0C",
panel: "#141418",
surface: "#1C1C22",
border: "#2A2A32",
amber: "#E8A030",
amberDim: "#7A5218",
blue: "#3A7BA8",
blueDim: "#1E4060",
green: "#3AB87A",
red: "#C84040",
textPrimary: "#F0EDE6",
textSec: "#8888A0",
textDim: "#444458",
};
import { T } from "./theme.js";
// ── Block param definitions (mirrored from App.jsx) ──
const BLOCK_PARAMS = {
@@ -37,7 +21,7 @@ const BLOCK_PARAMS = {
const BLOCK_COLORS = {
overdrive: T.amber, distortion: T.red, fuzz: '#C07030',
delay: T.blue, echo: T.blue, reverb: '#60B0D0',
delay: T.blue, echo: T.blue, reverb: '#5090A0',
chorus: T.green, flanger: '#50C080', phaser: '#80C050',
tremolo: T.green, compressor: '#D09040', gate: T.textSec, eq: '#7080D0',
cabinet: '#D07090', ir: '#D07090', nam: '#E0A040', boost: T.amber,
@@ -111,7 +95,7 @@ function FocusKnob({ label, value = 50, onChange, size = 72, color = T.amber, mi
onMouseDown={handleStart} onTouchStart={handleStart}>
<svg style={{ position: 'absolute', inset: -8, width: svgSize, height: svgSize, overflow: 'visible' }}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={T.border} strokeWidth="3" strokeLinecap="round" opacity=".5" />
{norm > 0 && <path d={`M ${lx} ${ly} A ${r} ${r} 0 ${large} 1 ${ex} ${ey}`} fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" style={{ filter: `drop-shadow(0 0 4px ${color})` }} />}
{norm > 0 && <path d={`M ${lx} ${ly} A ${r} ${r} 0 ${large} 1 ${ex} ${ey}`} fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" style={{ opacity: 0.8 }} />}
</svg>
<div style={{
borderRadius: '50%', width: '100%', height: '100%', position: 'relative',
@@ -124,7 +108,7 @@ function FocusKnob({ label, value = 50, onChange, size = 72, color = T.amber, mi
height: dotH, top: dotTop,
transform: `translateX(-50%) rotate(${angle}deg)`,
transformOrigin: `50% ${dotH}px`,
boxShadow: `0 0 6px ${color}`,
boxShadow: `0 0 4px ${color}33`,
}} />
</div>
</div>
@@ -180,7 +164,7 @@ function FocusSlider({ label, value = 50, onChange, onChangeEnd, min = 0, max =
<span style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '.06em', color }}>{label}</span>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button style={{
width: 34, height: 34, borderRadius: '50%', border: `1px solid ${T.border}`,
width: 44, height: 44, borderRadius: '50%', border: `1px solid ${T.border}`,
background: T.panel, color: T.textPrimary, fontSize: 16, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
lineHeight: 1,
@@ -190,7 +174,7 @@ function FocusSlider({ label, value = 50, onChange, onChangeEnd, min = 0, max =
minWidth: 40, textAlign: 'center',
}}>{touchVal ?? value}</span>
<button style={{
width: 34, height: 34, borderRadius: '50%', border: `1px solid ${T.border}`,
width: 44, height: 44, borderRadius: '50%', border: `1px solid ${T.border}`,
background: T.panel, color: T.textPrimary, fontSize: 16, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
lineHeight: 1,
@@ -209,9 +193,10 @@ function FocusSlider({ label, value = 50, onChange, onChangeEnd, min = 0, max =
}}>
<div style={{
height: '100%', borderRadius: 5, position: 'absolute', top: 0, left: 0,
width: `${pct}%`, background: color,
boxShadow: `0 0 8px ${color}44`,
transition: 'width .03s',
width: '100%', background: color,
transition: 'transform .03s',
transform: `scaleX(${pct/100})`,
transformOrigin: 'left center',
}} />
</div>
<div style={{
@@ -338,7 +323,7 @@ export default function FocusView({
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', flexDirection: 'column',
background: T.bg,
fontFamily: "'Inter', sans-serif",
fontFamily: "'Space Grotesk', sans-serif",
color: T.textPrimary,
}}>
{/* ── Header ── */}
@@ -352,7 +337,7 @@ export default function FocusView({
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<button onClick={onClose}
style={{
width: 34, height: 34, borderRadius: 8,
width: 44, height: 44, borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: T.surface, border: `1px solid ${T.border}`,
color: T.textPrimary, fontSize: 16, cursor: 'pointer',
+4 -11
View File
@@ -1,15 +1,10 @@
import { useRef, useEffect } from "react";
const T = {
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060",
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458",
};
import { T } from "./theme.js";
const TYPE_COLORS = {
overdrive: T.amber, drive: T.amber, distortion: T.red, fuzz: "#D060E0",
chorus: T.blue, flanger: T.blue, phaser: T.blue, tremolo: T.blue,
delay: "#60A0E0", echo: "#60A0E0", reverb: "#60C0D0",
delay: "#6080B0", echo: "#6080B0", reverb: "#5090A0",
compressor: T.green, gate: T.green, eq: "#80A0B0",
ir: T.green, cab: T.green, nam: "#E0A040",
};
@@ -65,8 +60,8 @@ function Footswitch({ id, label, mainLabel, type, sub, active, enabled, onClick,
cursor: "pointer", minHeight: 58, position: "relative",
transition: "all .1s", WebkitTapHighlightColor: "transparent",
boxShadow: isActive && !isBypassSwitch
? `0 0 8px ${T.amber}33, inset 0 0 0 1px ${T.amber}22`
: isBypassSwitch && !enabled ? `0 0 8px ${T.red}33` : "none",
? `inset 0 0 0 1px ${T.amber}22`
: isBypassSwitch && !enabled ? `inset 0 0 0 1px ${T.red}22` : "none",
}}
>
{/* Scribble strip */}
@@ -75,7 +70,6 @@ function Footswitch({ id, label, mainLabel, type, sub, active, enabled, onClick,
border: `1px solid ${isActive ? T.amber + "66" : T.border}`,
borderRadius: 3, padding: "2px 2px", textAlign: "center",
minHeight: 18, display: "flex", alignItems: "center", justifyContent: "center",
boxShadow: isActive ? `inset 0 0 4px ${T.amber}22` : "none",
}}>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: 7, fontWeight: 700,
@@ -110,7 +104,6 @@ function Footswitch({ id, label, mainLabel, type, sub, active, enabled, onClick,
position: "absolute", top: 3, right: 3, width: 4, height: 4,
borderRadius: "50%",
background: enabled !== false ? T.green : T.textDim,
boxShadow: enabled !== false ? `0 0 5px ${T.green}` : "none",
transition: "all .15s",
}} />
)}
+5 -10
View File
@@ -1,14 +1,9 @@
import { useState, useRef, useCallback, useEffect } from "react";
const T = {
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060",
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458",
};
import { T } from "./theme.js";
// ── EQ Band definitions ──
const EQ_BANDS = [
{ key: "low", label: "Low", freq: "80 Hz", color: "#60A0E0", shadow: "#60A0E044" },
{ key: "low", label: "Low", freq: "80 Hz", color: "#5090A0", shadow: "#5090A044" },
{ key: "mid", label: "Mid", freq: "800 Hz", color: T.amber, shadow: `${T.amber}44` },
{ key: "high", label: "High", freq: "6.4 kHz",color: "#60D080", shadow: "#60D08044" },
];
@@ -64,7 +59,7 @@ function EqKnob({ label, freq, value, min, max, color, onChange }) {
{norm > 0 && (
<path d={`M ${lx} ${ly} A ${r} ${r} 0 ${large} 1 ${ex} ${ey}`}
fill="none" stroke={color} strokeWidth="3" strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 4px ${color})` }} />
style={{ opacity: 0.8 }} />
)}
</svg>
<div style={{
@@ -78,7 +73,7 @@ function EqKnob({ label, freq, value, min, max, color, onChange }) {
height: dotH, top: dotTop,
transform: `translateX(-50%) rotate(${angle}deg)`,
transformOrigin: `50% ${dotH}px`,
boxShadow: `0 0 6px ${color}`,
boxShadow: `0 0 4px ${color}33`,
}} />
</div>
</div>
@@ -265,7 +260,7 @@ export default function GlobalEQ({ onClose }) {
position: "fixed", inset: 0, zIndex: 998,
display: "flex", flexDirection: "column",
background: T.bg, color: T.textPrimary,
fontFamily: "'Inter', sans-serif",
fontFamily: "'Space Grotesk', sans-serif",
}}>
{/* Header */}
<div style={{
+228 -208
View File
@@ -1,15 +1,18 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { T } from "./theme.js";
const T = {
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060",
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458",
};
async function api(method, path, body) {
const opts = { method, headers: { "Content-Type": "application/json" } };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${path}`, opts);
if (!res.ok) throw new Error(`API ${method} ${path}: ${res.status}`);
return res.json();
}
// ── Settings definitions ──
const SETTINGS = {
"Audio": [
{ key: "master_volume", label: "Master Volume", type: "slider", min: 0, max: 100, def: 72, unit: "%", icon: "🔊" },
{ key: "master_volume", label: "Master Volume", type: "slider", min: 0, max: 100, def: 72, unit: "%", icon: "🔊",
via: "volume" },
{ key: "sample_rate", label: "Sample Rate", type: "select", def: "48000",
options: [
{ value: "22050", label: "22.05 kHz" },
@@ -17,7 +20,7 @@ const SETTINGS = {
{ value: "48000", label: "48 kHz" },
{ value: "88200", label: "88.2 kHz" },
{ value: "96000", label: "96 kHz" },
], icon: "📊" },
], icon: "📊", via: "audio_profile" },
{ key: "buffer_size", label: "Buffer Size", type: "select", def: "512",
options: [
{ value: "64", label: "64 (1.3ms)" },
@@ -25,14 +28,14 @@ const SETTINGS = {
{ value: "256", label: "256 (5.3ms)" },
{ value: "512", label: "512 (10.6ms)" },
{ value: "1024",label: "1024 (21.3ms)" },
], icon: "📦" },
], icon: "📦", via: "audio_profile" },
{ key: "input_pad", label: "Input Pad", type: "select", def: "off",
options: [
{ value: "off", label: "Off (0 dB)" },
{ value: "-6", label: "-6 dB" },
{ value: "-12", label: "-12 dB" },
{ value: "-18", label: "-18 dB" },
], icon: "📥" },
], icon: "📥", via: "settings" },
{ key: "input_impedance", label: "Input Impedance", type: "select", def: "1m",
options: [
{ value: "1m", label: "1 MΩ" },
@@ -40,7 +43,45 @@ const SETTINGS = {
{ value: "250k", label: "250 kΩ" },
{ value: "150k", label: "150 kΩ" },
{ value: "68k", label: "68 kΩ" },
], icon: "⚡" },
], icon: "⚡", via: "settings" },
],
"Routing": [
{ key: "routing_mode", label: "Routing Mode", type: "select", def: "mono",
options: [
{ value: "mono", label: "Mono" },
{ value: "4cm", label: "4CM (Stereo FX Loop)" },
], icon: "🔀", via: "routing" },
{ key: "routing_breakpoint", label: "FX Loop Breakpoint", type: "select", def: "7",
options: [
{ value: "1", label: "After block 1" },
{ value: "2", label: "After block 2" },
{ value: "3", label: "After block 3" },
{ value: "4", label: "After block 4" },
{ value: "5", label: "After block 5" },
{ value: "6", label: "After block 6" },
{ value: "7", label: "After block 7" },
{ value: "8", label: "After block 8" },
], icon: "↔", via: "routing" },
],
"Audio Interface": [
{ key: "input_device", label: "Input Device", type: "select", def: "hw:0,0",
options: [
{ value: "hw:0,0", label: "Default (hw:0,0)" },
{ value: "hw:1,0", label: "USB Audio (hw:1,0)" },
{ value: "hw:2,0", label: "HDMI Audio (hw:2,0)" },
], icon: "🎤", via: "settings" },
{ key: "output_device", label: "Output Device", type: "select", def: "hw:0,0",
options: [
{ value: "hw:0,0", label: "Default (hw:0,0)" },
{ value: "hw:1,0", label: "USB Audio (hw:1,0)" },
{ value: "hw:2,0", label: "HDMI Audio (hw:2,0)" },
], icon: "🔈", via: "settings" },
{ key: "channel_mode", label: "Channel Mode", type: "select", def: "mono",
options: [
{ value: "mono", label: "Mono" },
{ value: "dual-mono", label: "Dual Mono" },
{ value: "stereo_4cm", label: "Stereo (4CM)" },
], icon: "🔊", via: "settings" },
],
"MIDI": [
{ key: "midi_channel", label: "MIDI Channel", type: "select", def: "omni",
@@ -49,13 +90,13 @@ const SETTINGS = {
...Array.from({ length: 16 }, (_, i) => ({
value: String(i + 1), label: `Channel ${i + 1}`
})),
], icon: "🎹" },
{ key: "midi_clock", label: "MIDI Clock", type: "toggle", def: false, icon: "⏱" },
{ key: "midi_thru", label: "MIDI Thru", type: "toggle", def: true, icon: "↔" },
], icon: "🎹", via: "settings" },
{ key: "midi_clock", label: "MIDI Clock", type: "toggle", def: false, via: "settings", icon: "⏱" },
{ key: "midi_thru", label: "MIDI Thru", type: "toggle", def: true, via: "settings", icon: "↔" },
],
"Display": [
{ key: "brightness", label: "Display Brightness", type: "slider", min: 1, max: 10, def: 8, unit: "", icon: "☀️" },
{ key: "auto_dim", label: "Auto Dim", type: "toggle", def: true, icon: "🌙" },
{ key: "brightness", label: "Display Brightness", type: "slider", min: 1, max: 10, def: 8, unit: "", icon: "☀️", via: "settings" },
{ key: "auto_dim", label: "Auto Dim", type: "toggle", def: true, via: "settings", icon: "🌙" },
{ key: "screen_saver", label: "Screen Saver", type: "select", def: "5m",
options: [
{ value: "off", label: "Off" },
@@ -63,10 +104,10 @@ const SETTINGS = {
{ value: "5m", label: "5 min" },
{ value: "15m", label: "15 min" },
{ value: "30m", label: "30 min" },
], icon: "💤" },
], icon: "💤", via: "settings" },
],
"Tempo": [
{ key: "tap_tempo", label: "Tap Tempo", type: "bpm", def: 120, icon: "🔄" },
{ key: "tap_tempo", label: "Tap Tempo", type: "bpm", def: 120, via: "settings", icon: "🔄" },
{ key: "tempo_division", label: "Tempo Division", type: "select", def: "quarter",
options: [
{ value: "half", label: "½ Note" },
@@ -75,12 +116,23 @@ const SETTINGS = {
{ value: "triplet", label: "⅛ Triplet" },
{ value: "sixteenth", label: "¹⁄₁₆ Note" },
{ value: "dotted_eighth", label: "Dotted ⅛" },
], icon: "🎶" },
{ key: "tap_tempo_mute", label: "Mute on Tap", type: "toggle", def: false, icon: "🔇" },
], icon: "🎶", via: "settings" },
{ key: "tap_tempo_mute", label: "Mute on Tap", type: "toggle", def: false, via: "settings", icon: "🔇" },
],
};
// ── Slider Widget ──
function buildDefaults(overrides = {}) {
const init = {};
for (const group of Object.values(SETTINGS)) {
for (const s of group) {
init[s.key] = s.def;
}
}
return { ...init, ...overrides };
}
// ── Widgets ────────────────────────────────────────────────────────────────
function SettingsSlider({ label, value, min, max, unit, onChange }) {
const trackRef = useRef(null);
const dragRef = useRef(false);
@@ -117,7 +169,7 @@ function SettingsSlider({ label, value, min, max, unit, onChange }) {
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<button style={{
width: 26, height: 26, borderRadius: "50%", border: `1px solid ${T.border}`,
width: 44, height: 44, borderRadius: "50%", border: `1px solid ${T.border}`,
background: T.panel, color: T.textPrimary, fontSize: 14, fontWeight: 700,
display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", lineHeight: 1,
}} onMouseDown={quick(-1)} onTouchStart={quick(-1)}></button>
@@ -126,7 +178,7 @@ function SettingsSlider({ label, value, min, max, unit, onChange }) {
minWidth: 36, textAlign: "center",
}}>{value}{unit}</span>
<button style={{
width: 26, height: 26, borderRadius: "50%", border: `1px solid ${T.border}`,
width: 44, height: 44, borderRadius: "50%", border: `1px solid ${T.border}`,
background: T.panel, color: T.textPrimary, fontSize: 14, fontWeight: 700,
display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", lineHeight: 1,
}} onMouseDown={quick(1)} onTouchStart={quick(1)}>+</button>
@@ -138,45 +190,22 @@ function SettingsSlider({ label, value, min, max, unit, onChange }) {
position: "relative", height: 32, display: "flex", alignItems: "center",
cursor: "pointer", touchAction: "none",
}}>
<div style={{
width: "100%", height: 6, background: T.border, borderRadius: 3,
position: "relative", overflow: "hidden",
}}>
<div style={{
height: "100%", borderRadius: 3, position: "absolute", top: 0, left: 0,
width: `${pct}%`, background: T.amber,
boxShadow: `0 0 6px ${T.amber}44`,
transition: "width .03s",
}} />
<div style={{ width: "100%", height: 6, background: T.border, borderRadius: 3, position: "relative", overflow: "hidden" }}>
<div style={{ height: "100%", borderRadius: 3, position: "absolute", top: 0, left: 0, width: "100%", background: T.amber, transform: `scaleX(${pct/100})`, transformOrigin: "left center", transition: "transform .03s" }} />
</div>
<div style={{
width: 24, height: 24, borderRadius: "50%", position: "absolute",
top: "50%", left: `${pct}%`,
transform: "translate(-50%,-50%)",
background: T.amber, border: `3px solid ${T.bg}`,
boxShadow: `0 1px 6px rgba(0,0,0,.5)`,
pointerEvents: "none",
transition: "left .03s",
}} />
<div style={{ width: 24, height: 24, borderRadius: "50%", position: "absolute", top: "50%", left: `${pct}%`, transform: "translate(-50%,-50%)", background: T.amber, border: `3px solid ${T.bg}`, boxShadow: `0 1px 6px rgba(0,0,0,.5)`, pointerEvents: "none", transition: "left .03s" }} />
</div>
</div>
);
}
// ── Select Widget ──
function SettingsSelect({ label, value, options, onChange, icon }) {
function SettingsSelect({ label, value, options, onChange }) {
return (
<div>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 6,
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
</div>
<div style={{
display: "flex", gap: 4, flexWrap: "wrap",
background: T.panel, borderRadius: 6, padding: 4,
}}>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap", background: T.panel, borderRadius: 6, padding: 4 }}>
{options.map(opt => (
<button key={opt.value}
onClick={() => onChange?.(opt.value)}
@@ -186,25 +215,18 @@ function SettingsSelect({ label, value, options, onChange, icon }) {
background: value === opt.value ? T.amber : "transparent",
color: value === opt.value ? "#000" : T.textSec,
transition: "all .1s", letterSpacing: ".03em",
whiteSpace: "nowrap",
minWidth: 0,
whiteSpace: "nowrap", minWidth: 0,
}}
>
{opt.label}
</button>
>{opt.label}</button>
))}
</div>
</div>
);
}
// ── Toggle Widget ──
function SettingsToggle({ label, value, onChange }) {
return (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "4px 0",
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "4px 0" }}>
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
<button
onClick={() => onChange?.(!value)}
@@ -227,7 +249,6 @@ function SettingsToggle({ label, value, onChange }) {
);
}
// ── BPM/Tap Tempo Widget ──
function BpmWidget({ label, value, onChange }) {
const tapTimes = useRef([]);
@@ -247,178 +268,177 @@ function BpmWidget({ label, value, onChange }) {
return (
<div>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 6,
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
</div>
<div style={{
display: "flex", gap: 8, alignItems: "center",
}}>
<button onClick={handleTap}
style={{
padding: "8px 20px", borderRadius: 8, border: `1px solid ${T.amber}66`,
background: `${T.amber}15`, color: T.amber,
fontSize: 12, fontWeight: 700, cursor: "pointer",
letterSpacing: ".05em",
}}>
TAP
</button>
<div style={{
flex: 1, display: "flex", alignItems: "center", gap: 4,
background: T.surface, borderRadius: 6, border: `1px solid ${T.border}`,
padding: "6px 10px",
}}>
<button onClick={() => onChange?.(Math.max(20, value - 1))}
style={{
background: "none", border: "none", color: T.textDim,
fontSize: 14, cursor: "pointer", padding: "0 4px",
}}></button>
<span style={{
flex: 1, textAlign: "center",
fontFamily: "'JetBrains Mono', monospace", fontSize: 16, fontWeight: 700, color: T.amber,
}}>{value} BPM</span>
<button onClick={() => onChange?.(Math.min(300, value + 1))}
style={{
background: "none", border: "none", color: T.textDim,
fontSize: 14, cursor: "pointer", padding: "0 4px",
}}>+</button>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<button onClick={handleTap} style={{ padding: "8px 20px", borderRadius: 8, border: `1px solid ${T.amber}66`, background: `${T.amber}15`, color: T.amber, fontSize: 12, fontWeight: 700, cursor: "pointer", letterSpacing: ".05em" }}>TAP</button>
<div style={{ flex: 1, display: "flex", alignItems: "center", gap: 4, background: T.surface, borderRadius: 6, border: `1px solid ${T.border}`, padding: "6px 10px" }}>
<button onClick={() => onChange?.(Math.max(20, value - 1))} style={{ background: "none", border: "none", color: T.textDim, fontSize: 14, cursor: "pointer", padding: "0 4px" }}></button>
<span style={{ flex: 1, textAlign: "center", fontFamily: "'JetBrains Mono', monospace", fontSize: 16, fontWeight: 700, color: T.amber }}>{value} BPM</span>
<button onClick={() => onChange?.(Math.min(300, value + 1))} style={{ background: "none", border: "none", color: T.textDim, fontSize: 14, cursor: "pointer", padding: "0 4px" }}>+</button>
</div>
</div>
</div>
);
}
// ── Global Settings ──
export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile }) {
const [values, setValues] = useState(() => {
const init = {};
for (const group of Object.values(SETTINGS)) {
for (const s of group) {
init[s.key] = s.def;
// ── Global Settings ─────────────────────────────────────────────────────────
export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile, onRoutingChange, onSaveSettings }) {
const [values, setValues] = useState(buildDefaults());
const [savedValues, setSavedValues] = useState(buildDefaults());
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState(null); // "saved" | "error" | null
useEffect(() => {
let cancelled = false;
Promise.all([
api("GET", "/api/state").catch(() => null),
api("GET", "/api/audio/profile").catch(() => null),
api("GET", "/api/routing").catch(() => null),
]).then(([state, audioProfile, routing]) => {
if (cancelled) return;
const overrides = {};
if (state) {
if (state.master_volume != null) overrides.master_volume = Math.round(state.master_volume * 100);
if (state.sample_rate != null) overrides.sample_rate = String(state.sample_rate);
if (state.channel_mode) overrides.channel_mode = state.channel_mode;
}
}
return init;
});
if (audioProfile) {
if (audioProfile.rate != null) overrides.sample_rate = String(audioProfile.rate);
if (audioProfile.period != null) overrides.buffer_size = String(audioProfile.period);
}
if (routing) {
if (routing.routing_mode) overrides.routing_mode = routing.routing_mode;
if (routing.routing_breakpoint != null) overrides.routing_breakpoint = String(routing.routing_breakpoint);
}
const merged = { ...buildDefaults(), ...overrides };
setValues(merged);
setSavedValues(merged);
setLoaded(true);
});
return () => { cancelled = true; };
}, []);
const hasChanges = useMemo(() => {
return Object.keys(values).some(k => values[k] !== savedValues[k]);
}, [values, savedValues]);
const groups = Object.entries(SETTINGS);
const update = useCallback((key, val) => {
setValues(prev => ({ ...prev, [key]: val }));
// Fire external callback for master volume
if (key === "master_volume") {
onMasterVolume?.(val);
setSaveMsg(null);
}, []);
const handleSave = useCallback(async () => {
setSaving(true);
setSaveMsg(null);
const changed = {};
for (const k of Object.keys(values)) {
if (values[k] !== savedValues[k]) {
changed[k] = values[k];
}
}
// Fire external callback for audio profile changes
if (key === "sample_rate" || key === "buffer_size") {
// Get the latest values — use the new val for the changed key
setValues(prev => {
const rate = key === "sample_rate" ? val : (prev.sample_rate || "48000");
const period = key === "buffer_size" ? val : (prev.buffer_size || "512");
onAudioProfile?.(parseInt(rate), parseInt(period));
return prev;
});
// Apply instant-effect settings via dedicated endpoints first
const promises = [];
if (changed.master_volume != null) {
onMasterVolume?.(changed.master_volume);
}
}, [onMasterVolume, onAudioProfile]);
if (changed.sample_rate != null || changed.buffer_size != null) {
const rate = changed.sample_rate || values.sample_rate || "48000";
const period = changed.buffer_size || values.buffer_size || "512";
onAudioProfile?.(parseInt(rate), parseInt(period));
}
if (changed.routing_mode != null || changed.routing_breakpoint != null) {
const mode = changed.routing_mode || values.routing_mode || "mono";
const bp = changed.routing_breakpoint || values.routing_breakpoint || "7";
onRoutingChange?.(mode, parseInt(bp));
}
// Everything else → bulk POST /api/settings
const settingsChanged = Object.fromEntries(
Object.entries(changed).filter(([k]) =>
!["master_volume", "sample_rate", "buffer_size", "routing_mode", "routing_breakpoint"].includes(k)
)
);
if (Object.keys(settingsChanged).length > 0) {
promises.push(
api("POST", "/api/settings", settingsChanged)
.then(() => {})
.catch(() => { throw new Error("Save failed"); })
);
}
try {
await Promise.all(promises);
setSavedValues({ ...values });
setSaveMsg("saved");
setTimeout(() => { setSaveMsg(null); }, 2000);
} catch {
setSaveMsg("error");
}
setSaving(false);
}, [values, savedValues, onMasterVolume, onAudioProfile, onRoutingChange]);
const handleDiscard = useCallback(() => {
setValues({ ...savedValues });
setSaveMsg(null);
}, [savedValues]);
return (
<div style={{
position: "fixed", inset: 0, zIndex: 998,
display: "flex", flexDirection: "column",
background: T.bg, color: T.textPrimary,
fontFamily: "'Inter', sans-serif",
}}>
{/* Header */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "10px 14px",
borderBottom: `1px solid ${T.border}`,
background: T.panel, flexShrink: 0,
}}>
<div style={{ position: "fixed", inset: 0, zIndex: 998, display: "flex", flexDirection: "column", background: T.bg, color: T.textPrimary, fontFamily: "'Space Grotesk', sans-serif" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 14px", borderBottom: `1px solid ${T.border}`, background: T.panel, flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button onClick={onClose}
style={{
width: 34, height: 34, borderRadius: 8,
display: "flex", alignItems: "center", justifyContent: "center",
background: T.surface, border: `1px solid ${T.border}`,
color: T.textPrimary, fontSize: 16, cursor: "pointer",
}}>
</button>
<button onClick={onClose} style={{ width: 44, height: 44, borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center", background: T.surface, border: `1px solid ${T.border}`, color: T.textPrimary, fontSize: 16, cursor: "pointer" }}></button>
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Global Settings</div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Settings</div>
{!loaded && <span style={{ fontSize: 10, color: T.textDim }}>Loading</span>}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{saveMsg === "saved" && (
<span style={{ fontSize: 10, fontWeight: 600, color: T.green }}> Saved</span>
)}
{saveMsg === "error" && (
<span style={{ fontSize: 10, fontWeight: 600, color: T.red }}> Save failed</span>
)}
{hasChanges && (
<span style={{ fontSize: 10, color: T.amber, fontWeight: 600 }}>Unsaved</span>
)}
{hasChanges && (
<button onClick={handleDiscard}
style={{ padding: "6px 12px", borderRadius: 6, border: `1px solid ${T.border}`, background: T.surface, color: T.textSec, fontSize: 11, fontWeight: 600, cursor: "pointer" }}>
Discard
</button>
)}
<button onClick={handleSave} disabled={!hasChanges || saving}
style={{
padding: "6px 14px", borderRadius: 6, border: "none",
background: hasChanges ? T.amber : T.border, color: hasChanges ? "#000" : T.textDim,
fontSize: 11, fontWeight: 700, cursor: hasChanges ? "pointer" : "default",
opacity: saving ? 0.6 : 1,
}}>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
{/* Body */}
<div style={{
flex: 1, overflowY: "auto", overflowX: "hidden",
padding: "6px 14px 20px",
}}>
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden", padding: "6px 14px 20px" }}>
{groups.map(([groupName, settings]) => (
<div key={groupName}>
{/* Group header */}
<div style={{
fontSize: 10, fontWeight: 700, letterSpacing: ".1em",
textTransform: "uppercase", color: T.textDim,
padding: "14px 0 8px",
}}>
{groupName}
</div>
{/* Settings */}
<div style={{
display: "flex", flexDirection: "column", gap: 12,
background: T.surface, borderRadius: 8,
border: `1px solid ${T.border}`,
padding: "12px 14px",
}}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".1em", textTransform: "uppercase", color: T.textDim, padding: "14px 0 8px" }}>{groupName}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12, background: T.surface, borderRadius: 8, border: `1px solid ${T.border}`, padding: "12px 14px" }}>
{settings.map(s => (
<div key={s.key} style={{
display: "flex", alignItems: "flex-start", gap: 10,
}}>
{/* Icon */}
<div style={{
width: 28, height: 28, borderRadius: 6, flexShrink: 0,
display: "flex", alignItems: "center", justifyContent: "center",
background: T.panel, fontSize: 13,
}}>
{s.icon}
</div>
{/* Control */}
<div key={s.key} style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
<div style={{ width: 28, height: 28, borderRadius: 6, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", background: T.panel, fontSize: 13 }}>{s.icon}</div>
<div style={{ flex: 1, minWidth: 0 }}>
{s.type === "slider" && (
<SettingsSlider
label={s.label}
value={values[s.key] ?? s.def}
min={s.min} max={s.max} unit={s.unit}
onChange={v => update(s.key, v)}
/>
)}
{s.type === "select" && (
<SettingsSelect
label={s.label}
value={values[s.key] ?? s.def}
options={s.options}
onChange={v => update(s.key, v)}
/>
)}
{s.type === "toggle" && (
<SettingsToggle
label={s.label}
value={values[s.key] ?? s.def}
onChange={v => update(s.key, v)}
/>
)}
{s.type === "bpm" && (
<BpmWidget
label={s.label}
value={values[s.key] ?? s.def}
onChange={v => update(s.key, v)}
/>
)}
{s.type === "slider" && <SettingsSlider label={s.label} value={values[s.key] ?? s.def} min={s.min} max={s.max} unit={s.unit} onChange={v => update(s.key, v)} />}
{s.type === "select" && <SettingsSelect label={s.label} value={values[s.key] ?? s.def} options={s.options} onChange={v => update(s.key, v)} />}
{s.type === "toggle" && <SettingsToggle label={s.label} value={values[s.key] ?? s.def} onChange={v => update(s.key, v)} />}
{s.type === "bpm" && <BpmWidget label={s.label} value={values[s.key] ?? s.def} onChange={v => update(s.key, v)} />}
</div>
</div>
))}
+7 -10
View File
@@ -1,10 +1,5 @@
import { useRef, useEffect } from "react";
const T = {
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060",
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458",
};
import { T } from "./theme.js";
const MENU_ITEMS = [
{ id: "save", label: "Save Preset", icon: "💾", desc: "Save current preset to bank" },
@@ -16,12 +11,13 @@ const MENU_ITEMS = [
];
export default function MainMenu({ onClose, onItemSelect }) {
const overlayRef = useRef(null);
const sidebarRef = useRef(null);
const backdropRef = useRef(null);
useEffect(() => {
const el = overlayRef.current;
const el = sidebarRef.current;
if (!el) return;
// Trigger enter animation
// Trigger enter animation — slide sidebar into view
requestAnimationFrame(() => { el.style.transform = "translateX(0)"; });
}, []);
@@ -31,7 +27,7 @@ export default function MainMenu({ onClose, onItemSelect }) {
return (
<div
ref={overlayRef}
ref={backdropRef}
onClick={handleBackdropClick}
style={{
position: "fixed", inset: 0, zIndex: 999,
@@ -42,6 +38,7 @@ export default function MainMenu({ onClose, onItemSelect }) {
>
{/* Sidebar */}
<div
ref={sidebarRef}
style={{
width: 260, maxWidth: "75vw", height: "100%",
background: T.panel,
+7 -24
View File
@@ -1,21 +1,5 @@
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
// ── Design tokens ──
const T = {
bg: "#0A0A0C",
panel: "#141418",
surface: "#1C1C22",
border: "#2A2A32",
amber: "#E8A030",
amberDim: "#7A5218",
blue: "#3A7BA8",
blueDim: "#1E4060",
green: "#3AB87A",
red: "#C84040",
textPrimary: "#F0EDE6",
textSec: "#8888A0",
textDim: "#444458",
};
import { T } from "./theme.js";
const IMG_PATH = (import.meta.env.BASE_URL || '/ui/') + 'img/';
@@ -24,8 +8,8 @@ const CATEGORY_DEFS = {
"Distortions": { icon: "fx_distortion", color: T.red },
"Dynamics": { icon: "fx_compressor", color: T.green },
"Modulations": { icon: "fx_modulator", color: T.blue },
"Delays": { icon: "fx_delay", color: "#60A0E0" },
"Reverbs": { icon: "fx_reverb", color: "#60C0D0" },
"Delays": { icon: "fx_delay", color: "#6080B0" },
"Reverbs": { icon: "fx_reverb", color: "#5090A0" },
"Filters": { icon: "fx_filter", color: "#D0A060" },
"Amps": { icon: "fx_amplifier", color: "#E0A040" },
"Preamps": { icon: "fx_simulator", color: "#D08040" },
@@ -75,7 +59,7 @@ const TYPE_COLORS = {
fuzz: "#D060E0",
mod: T.blue, chorus: T.blue, flange: T.blue, phaser: T.blue,
tremolo: T.blue, vibrato: T.blue, rotary: T.blue,
delay: "#60A0E0", echo: "#60A0E0", reverb: "#60C0D0",
delay: "#6080B0", echo: "#6080B0", reverb: "#5090A0",
pitch: "#A060E0", comp: T.green, compressor: T.green,
gate: T.green, filter: "#D0A060", wah: "#D0A060",
eq: "#80A0B0", ir: T.green, cab: T.green, nam: "#E0A040",
@@ -365,7 +349,7 @@ export default function ModelBrowser({
display: "flex", flexDirection: "column",
background: T.bg,
color: T.textPrimary,
fontFamily: "'Inter', sans-serif",
fontFamily: "'Space Grotesk', sans-serif",
}}>
{/* ── Header ── */}
<div style={{
@@ -587,7 +571,7 @@ function ModelTile({ model, isCurrent, isFavorite, onSelect, onToggleFav }) {
cursor: "pointer",
position: "relative",
transition: "all .12s",
boxShadow: isCurrent ? `0 0 12px ${color}33` : "none",
boxShadow: isCurrent ? `0 0 6px ${color}22` : "none",
minHeight: 72,
}}
>
@@ -615,7 +599,7 @@ function ModelTile({ model, isCurrent, isFavorite, onSelect, onToggleFav }) {
alt={model.type}
style={{
width: 28, height: 28,
filter: `invert(0.95) brightness(2) drop-shadow(0 0 4px ${color}44)`,
filter: `invert(0.9) brightness(1.6)`,
objectFit: "contain",
}}
onError={() => setImgError(true)}
@@ -672,7 +656,6 @@ function ModelTile({ model, isCurrent, isFavorite, onSelect, onToggleFav }) {
position: "absolute", top: 4, left: 4,
width: 6, height: 6, borderRadius: "50%",
background: T.green,
boxShadow: `0 0 6px ${T.green}`,
}} />
)}
</div>
+6 -13
View File
@@ -1,12 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
// ── Design tokens (mirrored) ──────────────────────────────────
const T = {
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8",
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6",
textSec: "#8888A0", textDim: "#444458",
};
import { T } from "./theme.js";
// ── String labels matching _STRING_NAMES ──────────────────────
const STRING_LABELS = [
@@ -286,8 +279,8 @@ export default function TunerScreen({ tunerData, connected, onExit }) {
borderRadius: 1,
margin: "0 auto",
boxShadow: isInTune
? `0 0 8px ${T.green}`
: `0 0 4px ${isClose ? T.amber : "transparent"}`,
? `0 0 4px ${T.green}44`
: `0 0 2px ${isClose ? T.amber : "transparent"}`,
}} />
{/* Needle dot */}
<div style={{
@@ -297,8 +290,8 @@ export default function TunerScreen({ tunerData, connected, onExit }) {
background: isInTune ? T.green : (isClose ? T.amber : T.textPrimary),
margin: "-1px auto 0",
boxShadow: isInTune
? `0 0 12px ${T.green}`
: `0 0 6px ${isClose ? T.amber : "transparent"}`,
? `0 0 6px ${T.green}44`
: `0 0 4px ${isClose ? T.amber : "transparent"}`,
}} />
</div>
</div>
@@ -341,7 +334,7 @@ export default function TunerScreen({ tunerData, connected, onExit }) {
background: isActive ? `${s.color}22` : T.surface,
border: `2px solid ${isActive ? s.color : T.border}`,
transition: "all .1s",
boxShadow: isActive ? `0 0 12px ${s.color}44` : "none",
boxShadow: isActive ? `0 0 6px ${s.color}22` : "none",
}}
>
<span style={{
+124 -10
View File
@@ -1,7 +1,24 @@
/* ── CSS custom properties (design tokens) ── */
:root {
--bg: #0A0A0C;
--panel: #141418;
--surface: #1C1C22;
--border: #2A2A32;
--amber: #E8A030;
--amber-dim: #7A5218;
--blue: #60A8D8;
--blue-dim: #1E4060;
--green: #3AB87A;
--red: #E06060;
--text-primary: #F0EDE6;
--text-sec: #A0A0BC;
--text-dim: #8484B0;
}
/* ── Reset & base ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { height: 100%; overflow: hidden; }
body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; }
body { background: #0A0A0C; color: #F0EDE6; font-family: 'Space Grotesk', sans-serif; -webkit-font-smoothing: antialiased; }
/* ── Utility classes (from pipedal) ── */
.mono { font-family: 'JetBrains Mono', monospace; }
@@ -12,8 +29,8 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
letter-spacing: .06em; text-transform: uppercase; }
.badge-amber { background: rgba(232,160,48,.2); color: #E8A030; }
.badge-green { background: rgba(58,184,122,.2); color: #3AB87A; }
.badge-blue { background: rgba(58,123,168,.2); color: #3A7BA8; }
.badge-red { background: rgba(200,64,64,.2); color: #C84040; }
.badge-blue { background: rgba(74,139,200,.2); color: #70B0E0; }
.badge-red { background: rgba(224,96,96,.2); color: #E06060; }
/* Button system */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px;
@@ -23,11 +40,13 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
.btn:active { transform: scale(.96); }
.btn-sm { padding: 5px 10px; font-size: 10px; }
.btn-ghost { background: transparent; border-color: transparent; }
.btn-danger { border-color: #C84040; background: rgba(200,64,64,.15); color: #C84040; }
.btn-danger { border-color: #E06060; background: rgba(224,96,96,.15); color: #E06060; }
.btn-icon { display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 5px; border: 1px solid #2A2A32;
width: 44px; height: 44px; border-radius: 8px; border: 1px solid #2A2A32;
background: #1C1C22; cursor: pointer; transition: all .12s; touch-action: manipulation; }
.btn-icon:active { transform: scale(.92); }
.btn:focus-visible { outline: 2px solid #3AB87A; outline-offset: 2px; }
.btn-icon:focus-visible { outline: 2px solid #3AB87A; outline-offset: 2px; }
/* Slide panel (bottom drawer) */
.slide-panel { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 100;
@@ -56,7 +75,7 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
.snap-name { font-size: 9px; color: #F0EDE6; margin-top: 2px; word-break: break-all; line-height: 1.3; }
.snap-empty { font-size: 9px; color: #444458; margin-top: 2px; font-style: italic; }
.snap-save-indicator { position: absolute; top: 4px; right: 4px; width: 6px; height: 6px;
border-radius: 50%; background: #3AB87A; box-shadow: 0 0 4px #3AB87A; }
border-radius: 50%; background: #3AB87A; }
.snap-name-input { width: 100%; background: #0A0A0C; border: 1px solid #2A2A32;
border-radius: 3px; padding: 2px 4px; font-size: 9px; color: #F0EDE6;
text-align: center; outline: none; margin-top: 2px; }
@@ -72,7 +91,7 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
align-items: center; justify-content: center; font-size: 16px; cursor: pointer;
border: 2px solid #2A2A32; background: #1C1C22; transition: all .12s; }
.transport-btn:active { transform: scale(.92); }
.transport-btn.rec { border-color: #C84040; background: rgba(200,64,64,.15); }
.transport-btn.rec { border-color: #E06060; background: rgba(224,96,96,.15); }
.transport-btn.play { border-color: #3AB87A; background: rgba(58,184,122,.15); }
/* Switch toggle */
@@ -112,10 +131,105 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
/* NAM chip */
.nam-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
background: rgba(58,123,168,.15); border: 1px solid rgba(58,123,168,.35);
border-radius: 5px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #3A7BA8; }
background: rgba(74,139,200,.15); border: 1px solid rgba(74,139,200,.35);
border-radius: 5px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--blue); }
/* Bypass LED */
.bypass-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.led-on { background: #3AB87A; box-shadow: 0 0 8px #3AB87A; }
.led-on { background: #3AB87A; }
.led-off { background: #444458; }
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0s !important;
animation-iteration-count: 1 !important;
transition-duration: 0s !important;
}
}
/* ── Responsive breakpoints ── */
/* Portrait phones */
@media (max-width: 640px) {
.slide-panel-inner {
max-width: 95vw;
max-height: 90vh;
border-radius: 12px 12px 0 0;
}
.screen-header {
padding: 8px 10px 6px;
}
.screen-body {
padding: 8px 10px;
gap: 8px;
}
.screen-title {
font-size: 11px;
}
.preset-row {
padding: 8px 10px;
gap: 8px;
}
.preset-chip-s {
padding: 5px 4px;
}
.preset-chip-s .pcs-num {
font-size: 12px;
}
.preset-chip-s .pcs-name {
font-size: 8px;
}
.setting-row {
padding: 8px 0;
}
.btn {
padding: 6px 12px;
font-size: 11px;
}
.btn-icon {
width: 44px;
height: 44px;
font-size: 12px;
}
.card {
padding: 10px;
}
.card-sm {
padding: 8px;
}
.snap-slot {
min-width: 50px;
padding: 6px 3px;
}
.snap-num {
font-size: 14px;
}
.mono {
font-size: 8px;
}
}
/* Landscape phone / tablet portrait */
@media (min-width: 641px) and (max-width: 1024px) {
.slide-panel-inner {
max-width: 540px;
}
.screen-header {
padding: 10px 12px 7px;
}
}
/* Landscape short screens */
@media (orientation: landscape) and (max-height: 500px) {
.slide-panel-inner {
max-height: 92vh;
}
.screen-header {
padding: 6px 12px 5px;
}
.screen-body {
padding: 6px 10px;
gap: 6px;
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* theme.js — Shared design tokens
*
* Single source of truth: CSS custom properties in index.css :root {}
* This module reads them at load time so JS components use the same
* values as the stylesheet. To theme the app, override the CSS vars.
*/
function v(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
export const T = {
bg: v('--bg'),
panel: v('--panel'),
surface: v('--surface'),
border: v('--border'),
amber: v('--amber'),
amberDim: v('--amber-dim'),
blue: v('--blue'),
blueDim: v('--blue-dim'),
green: v('--green'),
red: v('--red'),
textPrimary: v('--text-primary'),
textSec: v('--text-sec'),
textDim: v('--text-dim'),
};