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"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NAM Pedal — Helix Stadium UX Concept</title> <title>NAM Pedal — Helix Stadium UX Concept</title>
<style> <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; } * { box-sizing: border-box; margin: 0; padding: 0; user-select: none; -webkit-tap-highlight-color: transparent; }
@@ -29,7 +29,7 @@
height: 100%; height: 100%;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: 'Inter', sans-serif; font-family: 'Space Grotesk', sans-serif;
overflow: hidden; overflow: hidden;
} }
+2 -2
View File
@@ -4,10 +4,10 @@
<meta charset="UTF-8"> <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, user-scalable=no">
<title>Pi Multi-FX — Helix Stadium Mockup</title> <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> <style>
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; user-select: none; } * { 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 { .device {
width: 100%; width: 100%;
+4 -1
View File
@@ -2,9 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
<meta name="theme-color" content="#0A0A0C" /> <meta name="theme-color" content="#0A0A0C" />
<title>NAM Pedal</title> <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" /> <link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" />
</head> </head>
<body> <body>
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- 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"> <!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"> height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
<g id="Layer_1"> <g id="Layer_1">
<g> <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"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="48px"
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> height="48px" viewBox="0 0 48 48" fill="#F0EDE6">
<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>
<g id="Combined"> <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 <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"/> 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"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- 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"> <!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"> height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
<g id="Layer_1"> <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 <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"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- 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"> <!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"> width="48px" height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
</svg> </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"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- 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"> <!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"> height="48px" viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<g id="Layer_2"> <g id="Layer_2">
<g> <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"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- 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"> <!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"> width="48px" height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
<g id="x"> <g id="x">
</g> </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"?> <?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"> <!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"> height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
<g id="Combined"> <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 <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"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> width="24px" height="24px" viewBox="0 0 24 24" fill="#F0EDE6">
<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">
<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 <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"/> 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"/> <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"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> width="24px" height="24px" viewBox="0 0 24 24" fill="#F0EDE6">
<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">
<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 <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"/> 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 <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

+313 -112
View File
@@ -7,6 +7,7 @@ import ModelBrowser from "./ModelBrowser.jsx";
import MainMenu from "./MainMenu.jsx"; import MainMenu from "./MainMenu.jsx";
import GlobalSettings from "./GlobalSettings.jsx"; import GlobalSettings from "./GlobalSettings.jsx";
import GlobalEQ from "./GlobalEQ.jsx"; import GlobalEQ from "./GlobalEQ.jsx";
import { T } from "./theme.js";
// ── API Layer ────────────────────────────────────────────────────── // ── API Layer ──────────────────────────────────────────────────────
const API_BASE = ""; const API_BASE = "";
@@ -31,8 +32,12 @@ const API = {
listPresets: () => api("GET", "/api/presets"), listPresets: () => api("GET", "/api/presets"),
getPreset: (bank, program) => api("GET", `/api/presets/${bank}/${program}`), getPreset: (bank, program) => api("GET", `/api/presets/${bank}/${program}`),
activatePreset:(bank, program) => api("POST", `/api/presets/${bank}/${program}/activate`), activatePreset:(bank, program) => api("POST", `/api/presets/${bank}/${program}/activate`),
savePreset: (bank, program, name, tags) => api("PUT", "/api/presets", { bank, program, name, tags }), 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", { rate: parseInt(rate), period: parseInt(period) }), 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)}`), searchModels: (q) => api("GET", `/api/tonehub/search?q=${encodeURIComponent(q)}`),
searchIrs: (q) => api("GET", `/api/tonehub/search/irs?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 }), 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 }), 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 ───────────────────────────────── // ── Block parameter definitions ─────────────────────────────────
const SPLIT_TYPES = [ const SPLIT_TYPES = [
{ value:'y', label:'Y — Split evenly', pan:true }, { value:'y', label:'Y — Split evenly', pan:true },
@@ -93,10 +81,10 @@ const BLOCK_PARAMS = {
const BLOCK_COLORS = { const BLOCK_COLORS = {
overdrive: T.amber, distortion: T.red, fuzz: '#C07030', 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', chorus: T.green, flanger: '#50C080', phaser: '#80C050',
tremolo: T.green, compressor: '#D09040', gate: T.textSec, eq: '#7080D0', 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', wah: '#D0A060', volume: T.textSec, tuner: '#80D0A0',
split: '#B080C0', merge: '#80B0C0', split: '#B080C0', merge: '#80B0C0',
}; };
@@ -106,7 +94,7 @@ const BLOCK_DISPLAY_NAMES = {
delay: "Digital Delay", echo: "Tape Echo", reverb: "Hall Reverb", delay: "Digital Delay", echo: "Tape Echo", reverb: "Hall Reverb",
chorus: "Chorus", flanger: "Flanger", phaser: "Phase 90", chorus: "Chorus", flanger: "Flanger", phaser: "Phase 90",
tremolo: "Tremolo", compressor: "Compressor", gate: "Noise Gate", 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", boost: "Clean Boost", wah: "Wah", volume: "Volume", tuner: "Tuner",
loop: "FX Loop", loop: "FX Loop",
split: "Split", merge: "Merge", split: "Split", merge: "Merge",
@@ -135,15 +123,14 @@ function getBlockDisplayName(block) {
// ── CSS ───────────────────────────────────────────────────── // ── CSS ─────────────────────────────────────────────────────
const 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; } * { 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}; } html, body { height: 100%; overflow: hidden; background: var(--bg); }
body { color: ${T.textPrimary}; font-family: 'Inter', sans-serif; } body { color: var(--text-primary); font-family: 'Space Grotesk', sans-serif; }
:root { --amber: ${T.amber}; --blue: ${T.blue}; --green: ${T.green}; }
.mono { font-family: 'JetBrains Mono', monospace; } .mono { font-family: 'JetBrains Mono', monospace; }
::-webkit-scrollbar { width: 4px; height: 4px; } ::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: ${T.panel}; } ::-webkit-scrollbar-track { background: var(--panel); }
::-webkit-scrollbar-thumb { background: ${T.border}; border-radius: 2px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* ── Knob ── */ /* ── Knob ── */
.knob-wrap { display: flex; flex-direction: column; align-items: center; gap: 4px; cursor: pointer; } .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); 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); } 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); .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); } left: 50%; transform-origin: bottom center; }
.knob-label { font-size: 9px; color: ${T.textSec}; letter-spacing: .06em; text-transform: uppercase; text-align: 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: 10px; color: ${T.amber}; } .knob-value { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--amber); }
/* ── Buttons ── */ /* ── Buttons ── */
.btn { padding: 8px 14px; border-radius: 6px; font-size: 12px; font-weight: 600; .btn { padding: 8px 14px; border-radius: 6px; font-size: 12px; font-weight: 600;
letter-spacing: .04em; border: none; cursor: pointer; transition: all .12s; } 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-primary:active { filter: brightness(.85); transform: scale(.97); }
.btn-ghost { background: ${T.surface}; color: ${T.textPrimary}; border: 1px solid ${T.border}; } .btn-ghost { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border); }
.btn-ghost:active { background: ${T.border}; } .btn-ghost:active { background: var(--border); }
.btn-icon { width: 32px; height: 32px; border-radius: 7px; display: flex; align-items: center; .btn-icon { width: 44px; height: 44px; border-radius: 8px; display: flex; align-items: center;
justify-content: center; background: ${T.surface}; border: 1px solid ${T.border}; cursor: pointer; justify-content: center; background: var(--surface); border: 1px solid var(--border); cursor: pointer;
font-size: 15px; transition: all .12s; flex-shrink: 0; } font-size: 15px; transition: all .12s; flex-shrink: 0; }
.btn-icon:active { transform: scale(.93); } .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 ── */
.badge { display: inline-flex; align-items: center; justify-content: center; .badge { display: inline-flex; align-items: center; justify-content: center;
padding: 2px 7px; border-radius: 4px; font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 4px; font-size: 10px; font-weight: 700;
letter-spacing: .06em; text-transform: uppercase; } letter-spacing: .06em; text-transform: uppercase; }
.badge-amber { background: rgba(232,160,48,.2); color: ${T.amber}; } .badge-amber { background: rgba(232,160,48,.2); color: var(--amber); }
.badge-green { background: rgba(58,184,122,.2); color: ${T.green}; } .badge-green { background: rgba(58,184,122,.2); color: var(--green); }
.badge-blue { background: rgba(58,123,168,.2); color: ${T.blue}; } .badge-blue { background: rgba(74,139,200,.2); color: var(--blue); }
/* ── Param slider ── */ /* ── 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-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-row { display: flex; flex-direction: column; gap: 1px; padding: 1px 0; }
.param-header { display: flex; justify-content: space-between; align-items: center; } .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-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-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-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; transition: width .04s; } .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%; .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; transform: translate(-50%,-50%); box-shadow: 0 1px 6px rgba(0,0,0,.6); pointer-events: none;
border: 3px solid ${T.bg}; } border: 3px solid var(--bg); }
.param-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid ${T.border}; .param-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--border);
background: ${T.panel}; color: ${T.textPrimary}; font-size: 14px; font-weight: 700; 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; } 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; } .param-knobs { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; padding: 4px 0; }
/* ── Screen header (overlays) ── */ /* ── Screen header (overlays) ── */
.screen-header { padding: 10px 14px 8px; display: flex; align-items: center; .screen-header { padding: 10px 14px 8px; display: flex; align-items: center;
justify-content: space-between; border-bottom: 1px solid ${T.border}; } 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: ${T.textSec}; } .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; } .screen-body { flex: 1; overflow-y: auto; padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; }
/* ── Preset sidebar chip ── */ /* ── 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; } 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: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-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: ${T.textSec}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; text-align: center; line-height: 1.2; } .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-chip-s .pcs-indicator { width: 4px; height: 4px; border-radius: 50%; margin-top: 3px; }
/* ── Preset row (captures) ── */ /* ── Preset row (captures) ── */
@@ -217,9 +206,35 @@ const css = `
border-radius: 6px; cursor: pointer; transition: background .12s; border: 1px solid transparent; } 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); } .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; .section-label { font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
color: ${T.textDim}; font-weight: 600; margin-bottom: 8px; } color: var(--text-dim); font-weight: 600; margin-bottom: 8px; }
.card { background: ${T.panel}; border: 1px solid ${T.border}; border-radius: 8px; padding: 14px; } .card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px; }
.card-sm { background: ${T.surface}; border: 1px solid ${T.border}; border-radius: 6px; padding: 10px; } .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 ────────────────────────────────────────────────────── // ── 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}> <div className="knob" style={{width:size,height:size}} onMouseDown={handleStart} onTouchStart={handleStart}>
<svg style={{position:"absolute",inset:-6,width:svgSize,height:svgSize,overflow:"visible"}}> <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"/> <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> </svg>
<div className="knob-inner" style={{"--amber":color}}> <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`}}/> <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>
<div className="param-track-wrap" ref={trackRef} onMouseDown={handleStart} onTouchStart={handleStart}> <div className="param-track-wrap" ref={trackRef} onMouseDown={handleStart} onTouchStart={handleStart}>
<div className="param-track"> <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 className="param-thumb" style={{left:`${pct}%`,background:color}}/>
</div> </div>
</div> </div>
@@ -362,7 +377,7 @@ function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,o
<div style={{display:'flex',gap:4,alignItems:'center'}}> <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'}} {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>} 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> onClick={onToggleView}>{viewMode==='slider'?'◉':'▦'}</button>
</div> </div>
</div> </div>
@@ -391,11 +406,12 @@ function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,o
// ── VU Meter ────────────────────────────────────────────────── // ── VU Meter ──────────────────────────────────────────────────
function VUMeter({level=0,height=24,vertical=false}){const segs=12,active=Math.round((level/100)*segs); 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; 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"}}> 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:(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>); {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}}> 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 // 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 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);}}; 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}}> 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"> <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==="models"?"active":""}`}onClick={()=>setTab("models")}>NAM Models</button>
<button className={`toggle-btn ${tab==="irs"?"active":""}`}onClick={()=>setTab("irs")}>IR Cabs</button></div></div> <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"}}> <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> <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> <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"> <div className="screen-body">
{results===null&&models.length>0&&(<><div className="section-label">Installed Models</div> {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"}}> {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) ──────────────── // ── MOCK DATA (used when pedal not connected) ────────────────
const MOCK_BLOCKS=[ const MOCK_BLOCKS=[
{id:"gate",type:"gate",name:"Noise Gate",bypassed:false,params:{threshold:40,release:25,depth:70,mix:100}}, {id:"gate",type:"gate",name:"Noise Gate",bypassed:false,params:{threshold:40,release:25}},
{id:"split",type:"split",name:"Split",bypassed:false,params:{splitType:"y",panA:50,panB:50,activePath:"A"}}, {id:"comp",type:"compressor",name:"Compressor",bypassed:false,params:{threshold:50,ratio:4,gain:50}},
{id:"drive_a",type:"overdrive",name:"OD-808",path:"A",bypassed:false,params:{drive:65,tone:55,level:70}}, {id:"amp",type:"nam",name:"Twin Reverb",bypassed:false,params:{level:75}},
{id:"delay_a",type:"delay",name:"Digital Delay",path:"A",bypassed:false,params:{time:45,feedback:25,mix:40}}, {id:"cab",type:"ir",name:"2x12 American",bypassed:false,params:{level:75}},
{id:"fuzz_b",type:"fuzz",name:"Big Muff",path:"B",bypassed:false,params:{fuzz:75,tone:40,level:65}}, {id:"eq",type:"eq",name:"EQ",bypassed:false,params:{low:0,mid:0,high:0,level:70}},
{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:{subtype:"hall",decay:50,mix:30,tone:50}},
{id:"reverb",type:"reverb",name:"Hall Reverb",bypassed:false,params:{decay:60,mix:35,tone:50}},
]; ];
const MOCK_PRESETS=[ const MOCK_PRESETS=[
{num:1,bank:1,name:"Plexi Crunch"},{num:2,bank:1,name:"Modern Hi-Gain"}, {num:1,bank:1,name:"Plexi Crunch"},{num:2,bank:1,name:"Blues Rock Lead"},
{num:3,bank:1,name:"Clean Chime"},{num:4,bank:1,name:"Blues Drive"}, {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: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"}, {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[globalBypass,setGlobalBypass]=useState(false);
const[loadingPreset,setLoadingPreset]=useState(false); const[loadingPreset,setLoadingPreset]=useState(false);
const[focusBlockId,setFocusBlockId]=useState(null); const[focusBlockId,setFocusBlockId]=useState(null);
const[modelBrowserBlockId,setModelBrowserBlockId]=useState(null); const [modelBrowserBlockId,setModelBrowserBlockId]=useState(null);
const[footswitchMode,setFootswitchMode]=useState("stomp"); 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 ── // ── Undo/Redo ──
const [undoStack, setUndoStack] = useState([]); const [undoStack, setUndoStack] = useState([]);
@@ -543,6 +576,7 @@ export default function App(){
// ── Main Menu & Settings ── // ── Main Menu & Settings ──
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [settingsView, setSettingsView] = useState(null); // "settings" | "eq" | null const [settingsView, setSettingsView] = useState(null); // "settings" | "eq" | null
const [overlayView, setOverlayView] = useState(null); // "save" | "bypass" | "wifi" | null
const handleMenuSelect = useCallback((itemId) => { const handleMenuSelect = useCallback((itemId) => {
setMenuOpen(false); setMenuOpen(false);
@@ -553,8 +587,14 @@ export default function App(){
setState(prev => ({ ...prev, tuner_enabled: true })); setState(prev => ({ ...prev, tuner_enabled: true }));
API.tunerToggle(true).catch(() => {}); API.tunerToggle(true).catch(() => {});
break; break;
default: case "save":
// save, bypass, wifi — placeholder for now setOverlayView("save");
break;
case "bypass":
setOverlayView("bypass");
break;
case "wifi":
setOverlayView("wifi");
break; break;
} }
}, []); }, []);
@@ -581,7 +621,7 @@ export default function App(){
useEffect(()=>{loadState();const id=setInterval(loadState,3000);return()=>clearInterval(id);},[loadState]); useEffect(()=>{loadState();const id=setInterval(loadState,3000);return()=>clearInterval(id);},[loadState]);
// VU animation fallback when no real data // 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 ── // ── Load presets from API ──
useEffect(()=>{ useEffect(()=>{
@@ -646,7 +686,7 @@ export default function App(){
const p=presets.find(pr=>pr.num===num); const p=presets.find(pr=>pr.num===num);
if(!p)return; if(!p)return;
setCurrentPreset(num); setCurrentPreset(num);
await handleLoadPreset(p.bank||1,num-1); await handleLoadPreset((p.bank||1)-1,num-1);
},[presets,handleLoadPreset]); },[presets,handleLoadPreset]);
// ── Blocks ── // ── 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;}); setBlocks(prev=>{const arr=[...prev];const[m]=arr.splice(fromIdx,1);arr.splice(toIdx,0,m);return arr;});
},[takeSnapshot]); },[takeSnapshot]);
const handleAddBlock=useCallback(()=>{ const handleAddBlock=useCallback(()=>{
setAddBlockType("picker");
},[takeSnapshot]);
const handleAddBlockType=useCallback((type)=>{
takeSnapshot(); takeSnapshot();
userChangedBlock.current=true;const id="block_"+Date.now(); userChangedBlock.current=true;
setBlocks(prev=>[...prev,{id,type:"overdrive",name:"New Drive",bypassed:false,params:{drive:30,tone:50,level:70}}]); 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); setTimeout(()=>{userChangedBlock.current=false;},3000);
},[takeSnapshot]); },[takeSnapshot]);
const handleRemoveBlock=useCallback((id)=>{ const handleRemoveBlock=useCallback((id)=>{
@@ -699,14 +748,14 @@ export default function App(){
const next=Math.min(currentBank+1,8); const next=Math.min(currentBank+1,8);
setCurrentBank(next); setCurrentBank(next);
const bp=presets.filter(p=>p.bank===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]); },[currentBank,presets,handleLoadPreset]);
const handleBankDown=useCallback(()=>{ const handleBankDown=useCallback(()=>{
const next=Math.max(currentBank-1,1); const next=Math.max(currentBank-1,1);
setCurrentBank(next); setCurrentBank(next);
const bp=presets.filter(p=>p.bank===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]); },[currentBank,presets,handleLoadPreset]);
const handleGlobalBypass=useCallback(()=>{ const handleGlobalBypass=useCallback(()=>{
@@ -771,25 +820,30 @@ export default function App(){
return( return(
<><style>{css}</style> <><style>{css}</style>
<div style={{width:"100%",height:"100vh",display:"flex",flexDirection:"column",background:T.bg,overflow:"hidden", <div style={{width:"100%",height:"100vh",display:"flex",flexDirection:"column",background:T.bg,overflow:"hidden",
fontFamily:"'Inter',sans-serif",margin:"0 auto", fontFamily:"'Space Grotesk',sans-serif",margin:"0 auto",
maxWidth:980, maxWidth:isNarrow?undefined:980,
}}> }}>
{view==="main"&&( {view==="main"&&(
<> <>
{/* ══════ STATUS BAR ══════ */} {/* ══════ STATUS BAR ══════ */}
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center", <div className="resp-status-bar" style={{display:"flex",justifyContent:"space-between",alignItems:"center",
padding:"4px 12px",background:T.panel,borderBottom:`1px solid ${T.border}`,flexShrink:0,minHeight:32}}> 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"}}> <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%", <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}`}}/> background:state.connected?T.green:T.red}}/>
<span className="mono" style={{fontSize:9,color:T.textSec}}>{state.connected?"CONNECTED":"OFFLINE"}</span> {!isNarrow&&<span className="mono" style={{fontSize:9,color:state.connected?T.green:T.red}}>{state.connected?"CONNECTED":"OFFLINE"}</span>}
{/* Undo/Redo */} {/* 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)"> onClick={handleUndo} disabled={undoStack.length===0} title="Undo (Ctrl+Z)">
</button> </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)"> onClick={handleRedo} disabled={redoStack.length===0} title="Redo (Ctrl+Shift+Z)">
</button> </button>
@@ -800,41 +854,41 @@ export default function App(){
{loadingPreset&&<span className="mono" style={{fontSize:8,color:T.amber}}></span>} {loadingPreset&&<span className="mono" style={{fontSize:8,color:T.amber}}></span>}
</div> </div>
<div style={{display:"flex",gap:8,alignItems:"center"}}> <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}/> <VUMeter level={vuLevel} height={24}/>
<span className="mono" style={{fontSize:9,color:T.textDim}}>CPU {state.cpu_percent!=null?`${state.cpu_percent}%`:"—"}</span> <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>}
<div style={{width:1,height:12,background:T.border}}/> <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"> <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,filter:'invert(1) brightness(2)'}}/> <img src={`${import.meta.env.BASE_URL}img/ic_bank.svg`} alt="captures" style={{width:14,height:14}}/>
</button> </button>
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}onClick={()=>setView("presets")}title="Presets"> <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,filter:'invert(1) brightness(2)'}}/> <img src={`${import.meta.env.BASE_URL}img/ic_presets.svg`} alt="presets" style={{width:14,height:14}}/>
</button> </button>
<div style={{display:"flex",gap:2,alignItems:"center",background:T.surface,borderRadius:4,border:`1px solid ${T.border}`,padding:2,height:24}}> <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={{ {["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", fontSize:8,fontWeight:700,letterSpacing:".04em",textTransform:"uppercase",
background:footswitchMode===m?T.amber:"transparent", background:footswitchMode===m?T.amber:"transparent",
color:footswitchMode===m?"#000":T.textDim, 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>))} }}>{m==="stomp"?"SM":"PR"}</button>))}
</div> </div>
<button className="btn-icon" style={{width:24,height:24,position:'relative', <button className="btn-icon" style={{width:44,height:44,position:"relative",
background:state.tuner_enabled?'rgba(128,208,160,.15)':T.surface, background:state.tuner_enabled?"rgba(128,208,160,.15)":T.surface,
borderColor:state.tuner_enabled?T.green:T.border}} borderColor:state.tuner_enabled?T.green:T.border}}
onClick={handleTunerToggle} title={state.tuner_enabled?"Exit Tuner":"Tuner"}> 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)'}}/> <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>
<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"> <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"/> <path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
</svg> </svg>
</button> </button>
<div style={{width:1,height:16,background:T.border}}/> <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"> 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> </button>
</div> </div>
</div> </div>
@@ -843,8 +897,22 @@ export default function App(){
<div style={{flex:1,display:"flex",overflow:"hidden"}}> <div style={{flex:1,display:"flex",overflow:"hidden"}}>
{/* ─── Preset Sidebar ─── */} {/* ─── Preset Sidebar ─── */}
<div style={{width:90,flexShrink:0,background:T.surface,borderRight:`1px solid ${T.border}`, {(!isNarrow || sidebarOpen)&&<div style={{
display:"flex",flexDirection:"column",overflow:"hidden"}}> 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 */} {/* Bank header */}
<div style={{padding:"6px 8px",textAlign:"center",borderBottom:`1px solid ${T.border}`,background:T.panel}}> <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> <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>
</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 ─── */} {/* ─── Main Content Area ─── */}
<div style={{flex:1,display:"flex",flexDirection:"column",overflow:"hidden",minWidth:0}}> <div style={{flex:1,display:"flex",flexDirection:"column",overflow:"hidden",minWidth:0}}>
@@ -893,6 +964,7 @@ export default function App(){
onAddBlock={handleAddBlock} onAddBlock={handleAddBlock}
onRemoveBlock={handleRemoveBlock} onRemoveBlock={handleRemoveBlock}
onModelBrowser={handleOpenModelBrowser} onModelBrowser={handleOpenModelBrowser}
onBlockSelected={handleAddBlockType}
/> />
</div> </div>
@@ -955,7 +1027,7 @@ export default function App(){
{view==="captures"&&<CapturesScreen onClose={()=>setView("main")}/>} {view==="captures"&&<CapturesScreen onClose={()=>setView("main")}/>}
{view==="presets"&&( {view==="presets"&&(
<div style={{display:"flex",flexDirection:"column",height:"100%",background:T.bg}}> <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"> <div className="screen-body">
{presets.map((p,i)=>( {presets.map((p,i)=>(
<div key={i} className={`preset-row ${p.num===currentPreset?"active":""}`} <div key={i} className={`preset-row ${p.num===currentPreset?"active":""}`}
@@ -1012,6 +1084,8 @@ export default function App(){
onClose={()=>setSettingsView(null)} onClose={()=>setSettingsView(null)}
onMasterVolume={(v)=>API.masterVolume(v).catch(()=>{})} onMasterVolume={(v)=>API.masterVolume(v).catch(()=>{})}
onAudioProfile={(rate, period)=>API.setAudioProfile(rate, period).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></> </div></>
); );
} }
+6 -23
View File
@@ -1,21 +1,5 @@
import { useState, useRef, useCallback, useMemo } from "react"; import { useState, useRef, useCallback, useMemo } from "react";
import { T } from "./theme.js";
// ── 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",
};
// ── Split type metadata ──────────────────────────── // ── Split type metadata ────────────────────────────
const SPLIT_TYPES = { const SPLIT_TYPES = {
@@ -32,7 +16,7 @@ const TYPE_COLORS = {
fuzz: "#D060E0", fuzz: "#D060E0",
mod: T.blue, chorus: T.blue, flange: T.blue, phaser: T.blue, mod: T.blue, chorus: T.blue, flange: T.blue, phaser: T.blue,
tremolo: T.blue, vibrato: T.blue, rotary: 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, pitch: "#A060E0", comp: T.green, compressor: T.green,
gate: T.green, filter: "#D0A060", wah: "#D0A060", gate: T.green, filter: "#D0A060", wah: "#D0A060",
eq: "#80A0B0", ir: T.green, cab: T.green, nam: "#E0A040", eq: "#80A0B0", ir: T.green, cab: T.green, nam: "#E0A040",
@@ -95,7 +79,7 @@ function BlockIcon({ type, size = 14, style = {} }) {
alt={type || 'fx'} alt={type || 'fx'}
style={{ style={{
width: size, height: size, flexShrink: 0, width: size, height: size, flexShrink: 0,
filter: 'invert(0.95) brightness(2)', filter: 'none',
objectFit: 'contain', objectFit: 'contain',
...style, ...style,
}} }}
@@ -389,12 +373,11 @@ function BlockTile({ block, selected, onSelect, onToggle, pathLabel, onModelBrow
<div style={{ <div style={{
position: "absolute", top: 0, left: 0, right: 0, height: 3, position: "absolute", top: 0, left: 0, right: 0, height: 3,
background: color, background: color,
boxShadow: enabled ? `0 0 6px ${color}` : "none",
}} /> }} />
{/* Drag handle */} {/* Drag handle */}
<div style={{ <div style={{
position: "absolute", top: 5, left: 5, fontSize: 9, color: T.textDim, 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> </div>
@@ -865,8 +848,8 @@ export default function BlockChain({
</button> </button>
{selectedBlockId && ( {selectedBlockId && (
<button onClick={() => onRemoveBlock(selectedBlockId)} style={{ <button onClick={() => onRemoveBlock(selectedBlockId)} style={{
padding: "10px 14px", borderRadius: 7, background: "rgba(200,64,64,.15)", padding: "10px 14px", borderRadius: 7, background: "rgba(224,96,96,.15)",
border: `1px solid rgba(200,64,64,.3)`, color: T.red, fontSize: 12, border: `1px solid rgba(224,96,96,.3)`, color: T.red, fontSize: 12,
fontWeight: 600, cursor: "pointer", transition: "all .12s", fontWeight: 600, cursor: "pointer", transition: "all .12s",
}}> }}>
Remove Remove
+12 -27
View File
@@ -1,21 +1,5 @@
import { useState, useRef, useCallback, useMemo } from "react"; import { useState, useRef, useCallback, useMemo } from "react";
import { T } from "./theme.js";
// ── 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 param definitions (mirrored from App.jsx) ── // ── Block param definitions (mirrored from App.jsx) ──
const BLOCK_PARAMS = { const BLOCK_PARAMS = {
@@ -37,7 +21,7 @@ const BLOCK_PARAMS = {
const BLOCK_COLORS = { const BLOCK_COLORS = {
overdrive: T.amber, distortion: T.red, fuzz: '#C07030', 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', chorus: T.green, flanger: '#50C080', phaser: '#80C050',
tremolo: T.green, compressor: '#D09040', gate: T.textSec, eq: '#7080D0', 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', boost: T.amber,
@@ -111,7 +95,7 @@ function FocusKnob({ label, value = 50, onChange, size = 72, color = T.amber, mi
onMouseDown={handleStart} onTouchStart={handleStart}> onMouseDown={handleStart} onTouchStart={handleStart}>
<svg style={{ position: 'absolute', inset: -8, width: svgSize, height: svgSize, overflow: 'visible' }}> <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" /> <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> </svg>
<div style={{ <div style={{
borderRadius: '50%', width: '100%', height: '100%', position: 'relative', 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, height: dotH, top: dotTop,
transform: `translateX(-50%) rotate(${angle}deg)`, transform: `translateX(-50%) rotate(${angle}deg)`,
transformOrigin: `50% ${dotH}px`, transformOrigin: `50% ${dotH}px`,
boxShadow: `0 0 6px ${color}`, boxShadow: `0 0 4px ${color}33`,
}} /> }} />
</div> </div>
</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> <span style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '.06em', color }}>{label}</span>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button style={{ <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, background: T.panel, color: T.textPrimary, fontSize: 16, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
lineHeight: 1, lineHeight: 1,
@@ -190,7 +174,7 @@ function FocusSlider({ label, value = 50, onChange, onChangeEnd, min = 0, max =
minWidth: 40, textAlign: 'center', minWidth: 40, textAlign: 'center',
}}>{touchVal ?? value}</span> }}>{touchVal ?? value}</span>
<button style={{ <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, background: T.panel, color: T.textPrimary, fontSize: 16, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
lineHeight: 1, lineHeight: 1,
@@ -209,9 +193,10 @@ function FocusSlider({ label, value = 50, onChange, onChangeEnd, min = 0, max =
}}> }}>
<div style={{ <div style={{
height: '100%', borderRadius: 5, position: 'absolute', top: 0, left: 0, height: '100%', borderRadius: 5, position: 'absolute', top: 0, left: 0,
width: `${pct}%`, background: color, width: '100%', background: color,
boxShadow: `0 0 8px ${color}44`, transition: 'transform .03s',
transition: 'width .03s', transform: `scaleX(${pct/100})`,
transformOrigin: 'left center',
}} /> }} />
</div> </div>
<div style={{ <div style={{
@@ -338,7 +323,7 @@ export default function FocusView({
position: 'fixed', inset: 0, zIndex: 1000, position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', flexDirection: 'column', display: 'flex', flexDirection: 'column',
background: T.bg, background: T.bg,
fontFamily: "'Inter', sans-serif", fontFamily: "'Space Grotesk', sans-serif",
color: T.textPrimary, color: T.textPrimary,
}}> }}>
{/* ── Header ── */} {/* ── Header ── */}
@@ -352,7 +337,7 @@ export default function FocusView({
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<button onClick={onClose} <button onClick={onClose}
style={{ style={{
width: 34, height: 34, borderRadius: 8, width: 44, height: 44, borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: T.surface, border: `1px solid ${T.border}`, background: T.surface, border: `1px solid ${T.border}`,
color: T.textPrimary, fontSize: 16, cursor: 'pointer', color: T.textPrimary, fontSize: 16, cursor: 'pointer',
+4 -11
View File
@@ -1,15 +1,10 @@
import { useRef, useEffect } from "react"; import { useRef, useEffect } 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",
};
const TYPE_COLORS = { const TYPE_COLORS = {
overdrive: T.amber, drive: T.amber, distortion: T.red, fuzz: "#D060E0", overdrive: T.amber, drive: T.amber, distortion: T.red, fuzz: "#D060E0",
chorus: T.blue, flanger: T.blue, phaser: T.blue, tremolo: T.blue, 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", compressor: T.green, gate: T.green, eq: "#80A0B0",
ir: T.green, cab: T.green, nam: "#E0A040", 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", cursor: "pointer", minHeight: 58, position: "relative",
transition: "all .1s", WebkitTapHighlightColor: "transparent", transition: "all .1s", WebkitTapHighlightColor: "transparent",
boxShadow: isActive && !isBypassSwitch boxShadow: isActive && !isBypassSwitch
? `0 0 8px ${T.amber}33, inset 0 0 0 1px ${T.amber}22` ? `inset 0 0 0 1px ${T.amber}22`
: isBypassSwitch && !enabled ? `0 0 8px ${T.red}33` : "none", : isBypassSwitch && !enabled ? `inset 0 0 0 1px ${T.red}22` : "none",
}} }}
> >
{/* Scribble strip */} {/* 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}`, border: `1px solid ${isActive ? T.amber + "66" : T.border}`,
borderRadius: 3, padding: "2px 2px", textAlign: "center", borderRadius: 3, padding: "2px 2px", textAlign: "center",
minHeight: 18, display: "flex", alignItems: "center", justifyContent: "center", minHeight: 18, display: "flex", alignItems: "center", justifyContent: "center",
boxShadow: isActive ? `inset 0 0 4px ${T.amber}22` : "none",
}}> }}>
<span style={{ <span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: 7, fontWeight: 700, 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, position: "absolute", top: 3, right: 3, width: 4, height: 4,
borderRadius: "50%", borderRadius: "50%",
background: enabled !== false ? T.green : T.textDim, background: enabled !== false ? T.green : T.textDim,
boxShadow: enabled !== false ? `0 0 5px ${T.green}` : "none",
transition: "all .15s", transition: "all .15s",
}} /> }} />
)} )}
+5 -10
View File
@@ -1,14 +1,9 @@
import { useState, useRef, useCallback, useEffect } from "react"; import { useState, useRef, useCallback, useEffect } 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",
};
// ── EQ Band definitions ── // ── EQ Band definitions ──
const EQ_BANDS = [ 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: "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" }, { 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 && ( {norm > 0 && (
<path d={`M ${lx} ${ly} A ${r} ${r} 0 ${large} 1 ${ex} ${ey}`} <path d={`M ${lx} ${ly} A ${r} ${r} 0 ${large} 1 ${ex} ${ey}`}
fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" fill="none" stroke={color} strokeWidth="3" strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 4px ${color})` }} /> style={{ opacity: 0.8 }} />
)} )}
</svg> </svg>
<div style={{ <div style={{
@@ -78,7 +73,7 @@ function EqKnob({ label, freq, value, min, max, color, onChange }) {
height: dotH, top: dotTop, height: dotH, top: dotTop,
transform: `translateX(-50%) rotate(${angle}deg)`, transform: `translateX(-50%) rotate(${angle}deg)`,
transformOrigin: `50% ${dotH}px`, transformOrigin: `50% ${dotH}px`,
boxShadow: `0 0 6px ${color}`, boxShadow: `0 0 4px ${color}33`,
}} /> }} />
</div> </div>
</div> </div>
@@ -265,7 +260,7 @@ export default function GlobalEQ({ onClose }) {
position: "fixed", inset: 0, zIndex: 998, position: "fixed", inset: 0, zIndex: 998,
display: "flex", flexDirection: "column", display: "flex", flexDirection: "column",
background: T.bg, color: T.textPrimary, background: T.bg, color: T.textPrimary,
fontFamily: "'Inter', sans-serif", fontFamily: "'Space Grotesk', sans-serif",
}}> }}>
{/* Header */} {/* Header */}
<div style={{ <div style={{
+225 -205
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 = { async function api(method, path, body) {
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32", const opts = { method, headers: { "Content-Type": "application/json" } };
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060", if (body) opts.body = JSON.stringify(body);
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458", const res = await fetch(`${path}`, opts);
}; if (!res.ok) throw new Error(`API ${method} ${path}: ${res.status}`);
return res.json();
}
// ── Settings definitions ──
const SETTINGS = { const SETTINGS = {
"Audio": [ "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", { key: "sample_rate", label: "Sample Rate", type: "select", def: "48000",
options: [ options: [
{ value: "22050", label: "22.05 kHz" }, { value: "22050", label: "22.05 kHz" },
@@ -17,7 +20,7 @@ const SETTINGS = {
{ value: "48000", label: "48 kHz" }, { value: "48000", label: "48 kHz" },
{ value: "88200", label: "88.2 kHz" }, { value: "88200", label: "88.2 kHz" },
{ value: "96000", label: "96 kHz" }, { value: "96000", label: "96 kHz" },
], icon: "📊" }, ], icon: "📊", via: "audio_profile" },
{ key: "buffer_size", label: "Buffer Size", type: "select", def: "512", { key: "buffer_size", label: "Buffer Size", type: "select", def: "512",
options: [ options: [
{ value: "64", label: "64 (1.3ms)" }, { value: "64", label: "64 (1.3ms)" },
@@ -25,14 +28,14 @@ const SETTINGS = {
{ value: "256", label: "256 (5.3ms)" }, { value: "256", label: "256 (5.3ms)" },
{ value: "512", label: "512 (10.6ms)" }, { value: "512", label: "512 (10.6ms)" },
{ value: "1024",label: "1024 (21.3ms)" }, { value: "1024",label: "1024 (21.3ms)" },
], icon: "📦" }, ], icon: "📦", via: "audio_profile" },
{ key: "input_pad", label: "Input Pad", type: "select", def: "off", { key: "input_pad", label: "Input Pad", type: "select", def: "off",
options: [ options: [
{ value: "off", label: "Off (0 dB)" }, { value: "off", label: "Off (0 dB)" },
{ value: "-6", label: "-6 dB" }, { value: "-6", label: "-6 dB" },
{ value: "-12", label: "-12 dB" }, { value: "-12", label: "-12 dB" },
{ value: "-18", label: "-18 dB" }, { value: "-18", label: "-18 dB" },
], icon: "📥" }, ], icon: "📥", via: "settings" },
{ key: "input_impedance", label: "Input Impedance", type: "select", def: "1m", { key: "input_impedance", label: "Input Impedance", type: "select", def: "1m",
options: [ options: [
{ value: "1m", label: "1 MΩ" }, { value: "1m", label: "1 MΩ" },
@@ -40,7 +43,45 @@ const SETTINGS = {
{ value: "250k", label: "250 kΩ" }, { value: "250k", label: "250 kΩ" },
{ value: "150k", label: "150 kΩ" }, { value: "150k", label: "150 kΩ" },
{ value: "68k", label: "68 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": [ "MIDI": [
{ key: "midi_channel", label: "MIDI Channel", type: "select", def: "omni", { key: "midi_channel", label: "MIDI Channel", type: "select", def: "omni",
@@ -49,13 +90,13 @@ const SETTINGS = {
...Array.from({ length: 16 }, (_, i) => ({ ...Array.from({ length: 16 }, (_, i) => ({
value: String(i + 1), label: `Channel ${i + 1}` value: String(i + 1), label: `Channel ${i + 1}`
})), })),
], icon: "🎹" }, ], icon: "🎹", via: "settings" },
{ key: "midi_clock", label: "MIDI Clock", type: "toggle", def: false, icon: "⏱" }, { key: "midi_clock", label: "MIDI Clock", type: "toggle", def: false, via: "settings", icon: "⏱" },
{ key: "midi_thru", label: "MIDI Thru", type: "toggle", def: true, icon: "↔" }, { key: "midi_thru", label: "MIDI Thru", type: "toggle", def: true, via: "settings", icon: "↔" },
], ],
"Display": [ "Display": [
{ key: "brightness", label: "Display Brightness", type: "slider", min: 1, max: 10, def: 8, unit: "", 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, icon: "🌙" }, { key: "auto_dim", label: "Auto Dim", type: "toggle", def: true, via: "settings", icon: "🌙" },
{ key: "screen_saver", label: "Screen Saver", type: "select", def: "5m", { key: "screen_saver", label: "Screen Saver", type: "select", def: "5m",
options: [ options: [
{ value: "off", label: "Off" }, { value: "off", label: "Off" },
@@ -63,10 +104,10 @@ const SETTINGS = {
{ value: "5m", label: "5 min" }, { value: "5m", label: "5 min" },
{ value: "15m", label: "15 min" }, { value: "15m", label: "15 min" },
{ value: "30m", label: "30 min" }, { value: "30m", label: "30 min" },
], icon: "💤" }, ], icon: "💤", via: "settings" },
], ],
"Tempo": [ "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", { key: "tempo_division", label: "Tempo Division", type: "select", def: "quarter",
options: [ options: [
{ value: "half", label: "½ Note" }, { value: "half", label: "½ Note" },
@@ -75,12 +116,23 @@ const SETTINGS = {
{ value: "triplet", label: "⅛ Triplet" }, { value: "triplet", label: "⅛ Triplet" },
{ value: "sixteenth", label: "¹⁄₁₆ Note" }, { value: "sixteenth", label: "¹⁄₁₆ Note" },
{ value: "dotted_eighth", label: "Dotted ⅛" }, { value: "dotted_eighth", label: "Dotted ⅛" },
], icon: "🎶" }, ], icon: "🎶", via: "settings" },
{ key: "tap_tempo_mute", label: "Mute on Tap", type: "toggle", def: false, icon: "🔇" }, { 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 }) { function SettingsSlider({ label, value, min, max, unit, onChange }) {
const trackRef = useRef(null); const trackRef = useRef(null);
const dragRef = useRef(false); 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> <span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}> <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<button style={{ <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, background: T.panel, color: T.textPrimary, fontSize: 14, fontWeight: 700,
display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", lineHeight: 1, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", lineHeight: 1,
}} onMouseDown={quick(-1)} onTouchStart={quick(-1)}></button> }} onMouseDown={quick(-1)} onTouchStart={quick(-1)}></button>
@@ -126,7 +178,7 @@ function SettingsSlider({ label, value, min, max, unit, onChange }) {
minWidth: 36, textAlign: "center", minWidth: 36, textAlign: "center",
}}>{value}{unit}</span> }}>{value}{unit}</span>
<button style={{ <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, background: T.panel, color: T.textPrimary, fontSize: 14, fontWeight: 700,
display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", lineHeight: 1, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", lineHeight: 1,
}} onMouseDown={quick(1)} onTouchStart={quick(1)}>+</button> }} 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", position: "relative", height: 32, display: "flex", alignItems: "center",
cursor: "pointer", touchAction: "none", cursor: "pointer", touchAction: "none",
}}> }}>
<div style={{ <div style={{ width: "100%", height: 6, background: T.border, borderRadius: 3, position: "relative", overflow: "hidden" }}>
width: "100%", height: 6, background: T.border, borderRadius: 3, <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" }} />
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> </div>
<div style={{ <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" }} />
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>
</div> </div>
); );
} }
// ── Select Widget ── function SettingsSelect({ label, value, options, onChange }) {
function SettingsSelect({ label, value, options, onChange, icon }) {
return ( return (
<div> <div>
<div style={{ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 6,
}}>
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span> <span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
</div> </div>
<div style={{ <div style={{ display: "flex", gap: 4, flexWrap: "wrap", background: T.panel, borderRadius: 6, padding: 4 }}>
display: "flex", gap: 4, flexWrap: "wrap",
background: T.panel, borderRadius: 6, padding: 4,
}}>
{options.map(opt => ( {options.map(opt => (
<button key={opt.value} <button key={opt.value}
onClick={() => onChange?.(opt.value)} onClick={() => onChange?.(opt.value)}
@@ -186,25 +215,18 @@ function SettingsSelect({ label, value, options, onChange, icon }) {
background: value === opt.value ? T.amber : "transparent", background: value === opt.value ? T.amber : "transparent",
color: value === opt.value ? "#000" : T.textSec, color: value === opt.value ? "#000" : T.textSec,
transition: "all .1s", letterSpacing: ".03em", transition: "all .1s", letterSpacing: ".03em",
whiteSpace: "nowrap", whiteSpace: "nowrap", minWidth: 0,
minWidth: 0,
}} }}
> >{opt.label}</button>
{opt.label}
</button>
))} ))}
</div> </div>
</div> </div>
); );
} }
// ── Toggle Widget ──
function SettingsToggle({ label, value, onChange }) { function SettingsToggle({ label, value, onChange }) {
return ( return (
<div style={{ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "4px 0" }}>
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "4px 0",
}}>
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span> <span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
<button <button
onClick={() => onChange?.(!value)} onClick={() => onChange?.(!value)}
@@ -227,7 +249,6 @@ function SettingsToggle({ label, value, onChange }) {
); );
} }
// ── BPM/Tap Tempo Widget ──
function BpmWidget({ label, value, onChange }) { function BpmWidget({ label, value, onChange }) {
const tapTimes = useRef([]); const tapTimes = useRef([]);
@@ -247,178 +268,177 @@ function BpmWidget({ label, value, onChange }) {
return ( return (
<div> <div>
<div style={{ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 6,
}}>
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span> <span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
</div> </div>
<div style={{ <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
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={handleTap} <button onClick={() => onChange?.(Math.max(20, value - 1))} style={{ background: "none", border: "none", color: T.textDim, fontSize: 14, cursor: "pointer", padding: "0 4px" }}></button>
style={{ <span style={{ flex: 1, textAlign: "center", fontFamily: "'JetBrains Mono', monospace", fontSize: 16, fontWeight: 700, color: T.amber }}>{value} BPM</span>
padding: "8px 20px", borderRadius: 8, border: `1px solid ${T.amber}66`, <button onClick={() => onChange?.(Math.min(300, value + 1))} style={{ background: "none", border: "none", color: T.textDim, fontSize: 14, cursor: "pointer", padding: "0 4px" }}>+</button>
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> </div>
</div> </div>
); );
} }
// ── Global Settings ── // ── Global Settings ─────────────────────────────────────────────────────────
export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile }) {
const [values, setValues] = useState(() => { export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile, onRoutingChange, onSaveSettings }) {
const init = {}; const [values, setValues] = useState(buildDefaults());
for (const group of Object.values(SETTINGS)) { const [savedValues, setSavedValues] = useState(buildDefaults());
for (const s of group) { const [loaded, setLoaded] = useState(false);
init[s.key] = s.def; 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;
} }
if (audioProfile) {
if (audioProfile.rate != null) overrides.sample_rate = String(audioProfile.rate);
if (audioProfile.period != null) overrides.buffer_size = String(audioProfile.period);
} }
return init; 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 groups = Object.entries(SETTINGS);
const update = useCallback((key, val) => { const update = useCallback((key, val) => {
setValues(prev => ({ ...prev, [key]: val })); setValues(prev => ({ ...prev, [key]: val }));
// Fire external callback for master volume setSaveMsg(null);
if (key === "master_volume") { }, []);
onMasterVolume?.(val);
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") { // Apply instant-effect settings via dedicated endpoints first
// Get the latest values — use the new val for the changed key const promises = [];
setValues(prev => { if (changed.master_volume != null) {
const rate = key === "sample_rate" ? val : (prev.sample_rate || "48000"); onMasterVolume?.(changed.master_volume);
const period = key === "buffer_size" ? val : (prev.buffer_size || "512"); }
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)); onAudioProfile?.(parseInt(rate), parseInt(period));
return prev;
});
} }
}, [onMasterVolume, onAudioProfile]); 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 ( return (
<div style={{ <div style={{ position: "fixed", inset: 0, zIndex: 998, display: "flex", flexDirection: "column", background: T.bg, color: T.textPrimary, fontFamily: "'Space Grotesk', sans-serif" }}>
position: "fixed", inset: 0, zIndex: 998, <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 14px", borderBottom: `1px solid ${T.border}`, background: T.panel, flexShrink: 0 }}>
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={{ display: "flex", alignItems: "center", gap: 10 }}> <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button onClick={onClose} <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>
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>
<div> <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> </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> </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]) => ( {groups.map(([groupName, settings]) => (
<div key={groupName}> <div key={groupName}>
{/* Group header */} <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".1em", textTransform: "uppercase", color: T.textDim, padding: "14px 0 8px" }}>{groupName}</div>
<div style={{ <div style={{ display: "flex", flexDirection: "column", gap: 12, background: T.surface, borderRadius: 8, border: `1px solid ${T.border}`, padding: "12px 14px" }}>
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",
}}>
{settings.map(s => ( {settings.map(s => (
<div key={s.key} style={{ <div key={s.key} style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
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>
}}>
{/* 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 style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{s.type === "slider" && ( {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)} />}
<SettingsSlider {s.type === "select" && <SettingsSelect label={s.label} value={values[s.key] ?? s.def} options={s.options} onChange={v => update(s.key, v)} />}
label={s.label} {s.type === "toggle" && <SettingsToggle label={s.label} value={values[s.key] ?? s.def} onChange={v => update(s.key, v)} />}
value={values[s.key] ?? s.def} {s.type === "bpm" && <BpmWidget label={s.label} value={values[s.key] ?? s.def} onChange={v => update(s.key, v)} />}
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>
</div> </div>
))} ))}
+7 -10
View File
@@ -1,10 +1,5 @@
import { useRef, useEffect } from "react"; import { useRef, useEffect } 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",
};
const MENU_ITEMS = [ const MENU_ITEMS = [
{ id: "save", label: "Save Preset", icon: "💾", desc: "Save current preset to bank" }, { 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 }) { export default function MainMenu({ onClose, onItemSelect }) {
const overlayRef = useRef(null); const sidebarRef = useRef(null);
const backdropRef = useRef(null);
useEffect(() => { useEffect(() => {
const el = overlayRef.current; const el = sidebarRef.current;
if (!el) return; if (!el) return;
// Trigger enter animation // Trigger enter animation — slide sidebar into view
requestAnimationFrame(() => { el.style.transform = "translateX(0)"; }); requestAnimationFrame(() => { el.style.transform = "translateX(0)"; });
}, []); }, []);
@@ -31,7 +27,7 @@ export default function MainMenu({ onClose, onItemSelect }) {
return ( return (
<div <div
ref={overlayRef} ref={backdropRef}
onClick={handleBackdropClick} onClick={handleBackdropClick}
style={{ style={{
position: "fixed", inset: 0, zIndex: 999, position: "fixed", inset: 0, zIndex: 999,
@@ -42,6 +38,7 @@ export default function MainMenu({ onClose, onItemSelect }) {
> >
{/* Sidebar */} {/* Sidebar */}
<div <div
ref={sidebarRef}
style={{ style={{
width: 260, maxWidth: "75vw", height: "100%", width: 260, maxWidth: "75vw", height: "100%",
background: T.panel, background: T.panel,
+7 -24
View File
@@ -1,21 +1,5 @@
import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { T } from "./theme.js";
// ── 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",
};
const IMG_PATH = (import.meta.env.BASE_URL || '/ui/') + 'img/'; const IMG_PATH = (import.meta.env.BASE_URL || '/ui/') + 'img/';
@@ -24,8 +8,8 @@ const CATEGORY_DEFS = {
"Distortions": { icon: "fx_distortion", color: T.red }, "Distortions": { icon: "fx_distortion", color: T.red },
"Dynamics": { icon: "fx_compressor", color: T.green }, "Dynamics": { icon: "fx_compressor", color: T.green },
"Modulations": { icon: "fx_modulator", color: T.blue }, "Modulations": { icon: "fx_modulator", color: T.blue },
"Delays": { icon: "fx_delay", color: "#60A0E0" }, "Delays": { icon: "fx_delay", color: "#6080B0" },
"Reverbs": { icon: "fx_reverb", color: "#60C0D0" }, "Reverbs": { icon: "fx_reverb", color: "#5090A0" },
"Filters": { icon: "fx_filter", color: "#D0A060" }, "Filters": { icon: "fx_filter", color: "#D0A060" },
"Amps": { icon: "fx_amplifier", color: "#E0A040" }, "Amps": { icon: "fx_amplifier", color: "#E0A040" },
"Preamps": { icon: "fx_simulator", color: "#D08040" }, "Preamps": { icon: "fx_simulator", color: "#D08040" },
@@ -75,7 +59,7 @@ const TYPE_COLORS = {
fuzz: "#D060E0", fuzz: "#D060E0",
mod: T.blue, chorus: T.blue, flange: T.blue, phaser: T.blue, mod: T.blue, chorus: T.blue, flange: T.blue, phaser: T.blue,
tremolo: T.blue, vibrato: T.blue, rotary: 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, pitch: "#A060E0", comp: T.green, compressor: T.green,
gate: T.green, filter: "#D0A060", wah: "#D0A060", gate: T.green, filter: "#D0A060", wah: "#D0A060",
eq: "#80A0B0", ir: T.green, cab: T.green, nam: "#E0A040", eq: "#80A0B0", ir: T.green, cab: T.green, nam: "#E0A040",
@@ -365,7 +349,7 @@ export default function ModelBrowser({
display: "flex", flexDirection: "column", display: "flex", flexDirection: "column",
background: T.bg, background: T.bg,
color: T.textPrimary, color: T.textPrimary,
fontFamily: "'Inter', sans-serif", fontFamily: "'Space Grotesk', sans-serif",
}}> }}>
{/* ── Header ── */} {/* ── Header ── */}
<div style={{ <div style={{
@@ -587,7 +571,7 @@ function ModelTile({ model, isCurrent, isFavorite, onSelect, onToggleFav }) {
cursor: "pointer", cursor: "pointer",
position: "relative", position: "relative",
transition: "all .12s", transition: "all .12s",
boxShadow: isCurrent ? `0 0 12px ${color}33` : "none", boxShadow: isCurrent ? `0 0 6px ${color}22` : "none",
minHeight: 72, minHeight: 72,
}} }}
> >
@@ -615,7 +599,7 @@ function ModelTile({ model, isCurrent, isFavorite, onSelect, onToggleFav }) {
alt={model.type} alt={model.type}
style={{ style={{
width: 28, height: 28, 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", objectFit: "contain",
}} }}
onError={() => setImgError(true)} onError={() => setImgError(true)}
@@ -672,7 +656,6 @@ function ModelTile({ model, isCurrent, isFavorite, onSelect, onToggleFav }) {
position: "absolute", top: 4, left: 4, position: "absolute", top: 4, left: 4,
width: 6, height: 6, borderRadius: "50%", width: 6, height: 6, borderRadius: "50%",
background: T.green, background: T.green,
boxShadow: `0 0 6px ${T.green}`,
}} /> }} />
)} )}
</div> </div>
+6 -13
View File
@@ -1,12 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { T } from "./theme.js";
// ── 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",
};
// ── String labels matching _STRING_NAMES ────────────────────── // ── String labels matching _STRING_NAMES ──────────────────────
const STRING_LABELS = [ const STRING_LABELS = [
@@ -286,8 +279,8 @@ export default function TunerScreen({ tunerData, connected, onExit }) {
borderRadius: 1, borderRadius: 1,
margin: "0 auto", margin: "0 auto",
boxShadow: isInTune boxShadow: isInTune
? `0 0 8px ${T.green}` ? `0 0 4px ${T.green}44`
: `0 0 4px ${isClose ? T.amber : "transparent"}`, : `0 0 2px ${isClose ? T.amber : "transparent"}`,
}} /> }} />
{/* Needle dot */} {/* Needle dot */}
<div style={{ <div style={{
@@ -297,8 +290,8 @@ export default function TunerScreen({ tunerData, connected, onExit }) {
background: isInTune ? T.green : (isClose ? T.amber : T.textPrimary), background: isInTune ? T.green : (isClose ? T.amber : T.textPrimary),
margin: "-1px auto 0", margin: "-1px auto 0",
boxShadow: isInTune boxShadow: isInTune
? `0 0 12px ${T.green}` ? `0 0 6px ${T.green}44`
: `0 0 6px ${isClose ? T.amber : "transparent"}`, : `0 0 4px ${isClose ? T.amber : "transparent"}`,
}} /> }} />
</div> </div>
</div> </div>
@@ -341,7 +334,7 @@ export default function TunerScreen({ tunerData, connected, onExit }) {
background: isActive ? `${s.color}22` : T.surface, background: isActive ? `${s.color}22` : T.surface,
border: `2px solid ${isActive ? s.color : T.border}`, border: `2px solid ${isActive ? s.color : T.border}`,
transition: "all .1s", transition: "all .1s",
boxShadow: isActive ? `0 0 12px ${s.color}44` : "none", boxShadow: isActive ? `0 0 6px ${s.color}22` : "none",
}} }}
> >
<span style={{ <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 ── */ /* ── Reset & base ── */
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { height: 100%; overflow: hidden; } 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) ── */ /* ── Utility classes (from pipedal) ── */
.mono { font-family: 'JetBrains Mono', monospace; } .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; } letter-spacing: .06em; text-transform: uppercase; }
.badge-amber { background: rgba(232,160,48,.2); color: #E8A030; } .badge-amber { background: rgba(232,160,48,.2); color: #E8A030; }
.badge-green { background: rgba(58,184,122,.2); color: #3AB87A; } .badge-green { background: rgba(58,184,122,.2); color: #3AB87A; }
.badge-blue { background: rgba(58,123,168,.2); color: #3A7BA8; } .badge-blue { background: rgba(74,139,200,.2); color: #70B0E0; }
.badge-red { background: rgba(200,64,64,.2); color: #C84040; } .badge-red { background: rgba(224,96,96,.2); color: #E06060; }
/* Button system */ /* Button system */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; .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:active { transform: scale(.96); }
.btn-sm { padding: 5px 10px; font-size: 10px; } .btn-sm { padding: 5px 10px; font-size: 10px; }
.btn-ghost { background: transparent; border-color: transparent; } .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; .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; } background: #1C1C22; cursor: pointer; transition: all .12s; touch-action: manipulation; }
.btn-icon:active { transform: scale(.92); } .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 (bottom drawer) */
.slide-panel { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 100; .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-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-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; .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; .snap-name-input { width: 100%; background: #0A0A0C; border: 1px solid #2A2A32;
border-radius: 3px; padding: 2px 4px; font-size: 9px; color: #F0EDE6; border-radius: 3px; padding: 2px 4px; font-size: 9px; color: #F0EDE6;
text-align: center; outline: none; margin-top: 2px; } 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; align-items: center; justify-content: center; font-size: 16px; cursor: pointer;
border: 2px solid #2A2A32; background: #1C1C22; transition: all .12s; } border: 2px solid #2A2A32; background: #1C1C22; transition: all .12s; }
.transport-btn:active { transform: scale(.92); } .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); } .transport-btn.play { border-color: #3AB87A; background: rgba(58,184,122,.15); }
/* Switch toggle */ /* Switch toggle */
@@ -112,10 +131,105 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
/* NAM chip */ /* NAM chip */
.nam-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; .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); 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: #3A7BA8; } border-radius: 5px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--blue); }
/* Bypass LED */ /* Bypass LED */
.bypass-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .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; } .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'),
};