PEDAL-UI: Helix Stadium-style redesign — block chain, big touch sliders, footswitch bar with scribble strips
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
@@ -0,0 +1,811 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>NAM Pedal — Helix Stadium UX Concept</title>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; user-select: none; -webkit-tap-highlight-color: transparent; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0A0A0C;
|
||||||
|
--panel: #141418;
|
||||||
|
--surface: #1C1C22;
|
||||||
|
--border: #2A2A32;
|
||||||
|
--amber: #E8A030;
|
||||||
|
--amberDim: #7A5218;
|
||||||
|
--blue: #3A7BA8;
|
||||||
|
--blueDim: #1E4060;
|
||||||
|
--green: #3AB87A;
|
||||||
|
--red: #C84040;
|
||||||
|
--text: #F0EDE6;
|
||||||
|
--textSec: #8888A0;
|
||||||
|
--textDim: #444458;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main Layout ── */
|
||||||
|
.pedal {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status Bar ── */
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.status-left { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.status-led {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 8px var(--green);
|
||||||
|
}
|
||||||
|
.status-text { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--textSec); letter-spacing: .08em; }
|
||||||
|
.status-right { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.status-chip {
|
||||||
|
font-family: 'JetBrains Mono', monospace; font-size: 10px;
|
||||||
|
padding: 3px 8px; border-radius: 4px;
|
||||||
|
background: rgba(58,123,168,.15); color: var(--blue);
|
||||||
|
border: 1px solid rgba(58,123,168,.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scribble Strip / Info Bar ── */
|
||||||
|
.scribble-bar {
|
||||||
|
display: flex;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #0F0F14;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.scribble {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.scribble .label { font-size: 9px; color: var(--textDim); letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.scribble .value { font-family: 'JetBrains Mono', monospace; font-size: 13px; font-weight: 600; color: var(--amber); margin-top: 1px; }
|
||||||
|
|
||||||
|
/* ── Block Chain ── */
|
||||||
|
.chain-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
.chain-scroll::-webkit-scrollbar { height: 4px; }
|
||||||
|
.chain-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.chain-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||||
|
|
||||||
|
.chain-term {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
color: var(--blue);
|
||||||
|
background: var(--blueDim);
|
||||||
|
border: 1px solid rgba(58,123,168,.4);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-block {
|
||||||
|
min-width: 110px;
|
||||||
|
max-width: 140px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chain-block.selected {
|
||||||
|
border-color: var(--amber);
|
||||||
|
box-shadow: 0 0 12px rgba(232,160,48,.3);
|
||||||
|
}
|
||||||
|
.chain-block.bypassed { opacity: .45; }
|
||||||
|
|
||||||
|
.chain-block .block-led {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 8px; right: 8px;
|
||||||
|
}
|
||||||
|
.block-led.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||||
|
.block-led.off { background: var(--textDim); }
|
||||||
|
|
||||||
|
.chain-block .block-scribble {
|
||||||
|
padding: 8px 10px 4px;
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.block-scribble .block-type {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: var(--textDim);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.block-scribble .block-name {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.block-scribble .block-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--textSec);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-block .block-indicator {
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-arrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--textDim);
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Parameter Panel ── */
|
||||||
|
.param-panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.param-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.param-header h3 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: var(--textSec);
|
||||||
|
}
|
||||||
|
.param-header .block-label {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.param-group .param-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.param-label .name { color: var(--textSec); text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
.param-label .value { font-family: 'JetBrains Mono', monospace; color: var(--amber); font-weight: 600; }
|
||||||
|
|
||||||
|
.touch-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.touch-slider::-webkit-slider-runnable-track {
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: linear-gradient(to right, var(--amberDim) 0%, var(--amber) 100%);
|
||||||
|
background-size: var(--pct, 50%) 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-color: var(--surface);
|
||||||
|
}
|
||||||
|
.touch-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--amber);
|
||||||
|
border: 2px solid var(--bg);
|
||||||
|
box-shadow: 0 1px 6px rgba(0,0,0,.5);
|
||||||
|
margin-top: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .1s;
|
||||||
|
}
|
||||||
|
.touch-slider::-webkit-slider-thumb:active {
|
||||||
|
transform: scale(1.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.touch-slider::-moz-range-track {
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.touch-slider::-moz-range-thumb {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--amber);
|
||||||
|
border: 2px solid var(--bg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footswitch Bar ── */
|
||||||
|
.footswitch-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px 14px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footswitch {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .12s;
|
||||||
|
position: relative;
|
||||||
|
min-height: 68px;
|
||||||
|
}
|
||||||
|
.footswitch:active {
|
||||||
|
transform: scale(.95);
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
.footswitch.active {
|
||||||
|
border-color: var(--amber);
|
||||||
|
background: rgba(232,160,48,.1);
|
||||||
|
}
|
||||||
|
.footswitch.preset {
|
||||||
|
border-color: var(--blueDim);
|
||||||
|
}
|
||||||
|
.footswitch.preset.active {
|
||||||
|
border-color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footswitch .fs-scribble {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--textDim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.footswitch .fs-label {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.footswitch .fs-sub {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--textSec);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.footswitch .fs-led {
|
||||||
|
width: 5px; height: 5px; border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 6px; right: 6px;
|
||||||
|
}
|
||||||
|
.fs-led.green { background: var(--green); }
|
||||||
|
.fs-led.amber { background: var(--amber); }
|
||||||
|
.fs-led.off { background: var(--textDim); }
|
||||||
|
|
||||||
|
.footswitch .fs-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px; left: 5px;
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(58,123,168,.2);
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Animations ── */
|
||||||
|
@keyframes pulse-amber { 0%,100%{opacity:1} 50%{opacity:.6} }
|
||||||
|
.recording { animation: pulse-amber 1s infinite; }
|
||||||
|
|
||||||
|
/* ── VU Meter ── */
|
||||||
|
.vu-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
.vu-seg {
|
||||||
|
width: 4px;
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: background .05s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Focus / Active styles for touch ── */
|
||||||
|
.touch-target {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .1s;
|
||||||
|
}
|
||||||
|
.touch-target:active {
|
||||||
|
transform: scale(.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Preset Selector ── */
|
||||||
|
.preset-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(0,0,0,.3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.preset-chip {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .12s;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.preset-chip.active {
|
||||||
|
border-color: var(--amber);
|
||||||
|
background: rgba(232,160,48,.1);
|
||||||
|
}
|
||||||
|
.preset-chip .pc-name { font-size: 11px; font-weight: 500; }
|
||||||
|
.preset-chip .pc-num { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--textDim); }
|
||||||
|
|
||||||
|
/* ── Responsive: slim down for smaller screens ── */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.chain-block { min-width: 90px; max-width: 110px; }
|
||||||
|
.footswitch { min-height: 58px; padding: 8px 4px; }
|
||||||
|
.footswitch .fs-label { font-size: 11px; }
|
||||||
|
.param-grid { grid-template-columns: 1fr; gap: 8px; }
|
||||||
|
.preset-chip .pc-name { font-size: 10px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="pedal">
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<div class="status-bar">
|
||||||
|
<div class="status-left">
|
||||||
|
<div class="status-led"></div>
|
||||||
|
<span class="status-text">CONNECTED</span>
|
||||||
|
<span class="status-chip">● Plexi Crunch</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-right">
|
||||||
|
<div class="vu-strip" id="vuL"></div>
|
||||||
|
<span class="status-text" style="color:var(--textDim);font-size:10px;">CPU 23%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scribble Strip Info Bar -->
|
||||||
|
<div class="scribble-bar">
|
||||||
|
<div class="scribble"><div class="label">Preset</div><div class="value">Plexi Crunch</div></div>
|
||||||
|
<div class="scribble"><div class="label">Bank</div><div class="value">A · 01</div></div>
|
||||||
|
<div class="scribble"><div class="label">Routing</div><div class="value">MONO</div></div>
|
||||||
|
<div class="scribble"><div class="label">Master</div><div class="value">72%</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Strip -->
|
||||||
|
<div class="preset-strip">
|
||||||
|
<div class="preset-chip" onclick="selectPreset(this)">
|
||||||
|
<div class="pc-name">Plexi Crunch</div>
|
||||||
|
<div class="pc-num">01</div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-chip active" onclick="selectPreset(this)">
|
||||||
|
<div class="pc-name">Modern Hi-Gain</div>
|
||||||
|
<div class="pc-num">02</div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-chip" onclick="selectPreset(this)">
|
||||||
|
<div class="pc-name">Clean Chime</div>
|
||||||
|
<div class="pc-num">03</div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-chip" onclick="selectPreset(this)">
|
||||||
|
<div class="pc-name">Blues Drive</div>
|
||||||
|
<div class="pc-num">04</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Block Chain Area -->
|
||||||
|
<div class="chain-area">
|
||||||
|
<div class="chain-scroll" id="chainScroll">
|
||||||
|
<div class="chain-term">IN</div>
|
||||||
|
<div class="chain-arrow">▶</div>
|
||||||
|
|
||||||
|
<div class="chain-block selected" data-block="gate" onclick="selectBlock(this)">
|
||||||
|
<div class="block-led on"></div>
|
||||||
|
<div class="block-scribble">
|
||||||
|
<div class="block-type">Dynamics</div>
|
||||||
|
<div class="block-name">Noise Gate</div>
|
||||||
|
<div class="block-sub">Threshold 40%</div>
|
||||||
|
</div>
|
||||||
|
<div class="block-indicator" style="background:var(--green);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chain-arrow">▶</div>
|
||||||
|
|
||||||
|
<div class="chain-block" data-block="drive" onclick="selectBlock(this)">
|
||||||
|
<div class="block-led on"></div>
|
||||||
|
<div class="block-scribble">
|
||||||
|
<div class="block-type">Drive</div>
|
||||||
|
<div class="block-name">OD-808</div>
|
||||||
|
<div class="block-sub">Drive 65%</div>
|
||||||
|
</div>
|
||||||
|
<div class="block-indicator" style="background:var(--amber);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chain-arrow">▶</div>
|
||||||
|
|
||||||
|
<div class="chain-block bypassed" data-block="dist" onclick="selectBlock(this)">
|
||||||
|
<div class="block-led off"></div>
|
||||||
|
<div class="block-scribble">
|
||||||
|
<div class="block-type">Distortion</div>
|
||||||
|
<div class="block-name">RAT</div>
|
||||||
|
<div class="block-sub">— Bypassed —</div>
|
||||||
|
</div>
|
||||||
|
<div class="block-indicator" style="background:var(--textDim);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chain-arrow">▶</div>
|
||||||
|
|
||||||
|
<div class="chain-block" data-block="mod" onclick="selectBlock(this)">
|
||||||
|
<div class="block-led on"></div>
|
||||||
|
<div class="block-scribble">
|
||||||
|
<div class="block-type">Modulation</div>
|
||||||
|
<div class="block-name">Phase 90</div>
|
||||||
|
<div class="block-sub">Rate 35%</div>
|
||||||
|
</div>
|
||||||
|
<div class="block-indicator" style="background:var(--blue);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chain-arrow">▶</div>
|
||||||
|
|
||||||
|
<div class="chain-block" data-block="delay" onclick="selectBlock(this)">
|
||||||
|
<div class="block-led on"></div>
|
||||||
|
<div class="block-scribble">
|
||||||
|
<div class="block-type">Delay</div>
|
||||||
|
<div class="block-name">Digital Delay</div>
|
||||||
|
<div class="block-sub">450ms · 25%</div>
|
||||||
|
</div>
|
||||||
|
<div class="block-indicator" style="background:#A060E0;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chain-arrow">▶</div>
|
||||||
|
|
||||||
|
<div class="chain-block" data-block="reverb" onclick="selectBlock(this)">
|
||||||
|
<div class="block-led on"></div>
|
||||||
|
<div class="block-scribble">
|
||||||
|
<div class="block-type">Reverb</div>
|
||||||
|
<div class="block-name">Hall Reverb</div>
|
||||||
|
<div class="block-sub">Decay 60%</div>
|
||||||
|
</div>
|
||||||
|
<div class="block-indicator" style="background:#60A0E0;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chain-arrow">▶</div>
|
||||||
|
<div class="chain-term">OUT</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Parameter Panel -->
|
||||||
|
<div class="param-panel" id="paramPanel">
|
||||||
|
<div class="param-header">
|
||||||
|
<h3>Noise Gate <span class="block-label">· Dynamics</span></h3>
|
||||||
|
<span style="color:var(--green);font-size:11px;display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="width:6px;height:6px;border-radius:50%;background:var(--green);display:inline-block;"></span> Active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="param-grid">
|
||||||
|
<div class="param-group">
|
||||||
|
<div class="param-label">
|
||||||
|
<span class="name">Threshold</span>
|
||||||
|
<span class="value" id="val_thresh">40</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" class="touch-slider" min="0" max="100" value="40" oninput="updateSlider(this,'val_thresh')" style="--pct:40%">
|
||||||
|
</div>
|
||||||
|
<div class="param-group">
|
||||||
|
<div class="param-label">
|
||||||
|
<span class="name">Release</span>
|
||||||
|
<span class="value" id="val_rel">25</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" class="touch-slider" min="0" max="100" value="25" oninput="updateSlider(this,'val_rel')" style="--pct:25%">
|
||||||
|
</div>
|
||||||
|
<div class="param-group">
|
||||||
|
<div class="param-label">
|
||||||
|
<span class="name">Depth</span>
|
||||||
|
<span class="value" id="val_dep">70</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" class="touch-slider" min="0" max="100" value="70" oninput="updateSlider(this,'val_dep')" style="--pct:70%">
|
||||||
|
</div>
|
||||||
|
<div class="param-group">
|
||||||
|
<div class="param-label">
|
||||||
|
<span class="name">Mix</span>
|
||||||
|
<span class="value" id="val_mix">100</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" class="touch-slider" min="0" max="100" value="100" oninput="updateSlider(this,'val_mix')" style="--pct:100%">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footswitch Bar -->
|
||||||
|
<div class="footswitch-bar">
|
||||||
|
<div class="footswitch preset" onclick="toggleFS(this)">
|
||||||
|
<span class="fs-scribble">BANK</span>
|
||||||
|
<span class="fs-label">▲ A</span>
|
||||||
|
<span class="fs-sub">Bank Up</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footswitch active" data-block="gate" onclick="toggleBlock(this,'gate')">
|
||||||
|
<span class="fs-scribble">GATE</span>
|
||||||
|
<span class="fs-label">Noise Gate</span>
|
||||||
|
<span class="fs-sub">Threshold 40%</span>
|
||||||
|
<div class="fs-led green"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footswitch" data-block="drive" onclick="toggleBlock(this,'drive')">
|
||||||
|
<span class="fs-scribble">DRIVE</span>
|
||||||
|
<span class="fs-label">OD-808</span>
|
||||||
|
<span class="fs-sub">Drive 65%</span>
|
||||||
|
<div class="fs-led green"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footswitch" data-block="dist" onclick="toggleBlock(this,'dist')">
|
||||||
|
<span class="fs-scribble">DIST</span>
|
||||||
|
<span class="fs-label" style="color:var(--textDim);">RAT</span>
|
||||||
|
<span class="fs-sub" style="color:var(--textDim);">Bypassed</span>
|
||||||
|
<div class="fs-led off"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footswitch" data-block="mod" onclick="selectBlockFromFS(this,'mod')">
|
||||||
|
<span class="fs-scribble">MOD</span>
|
||||||
|
<span class="fs-label">Phase 90</span>
|
||||||
|
<span class="fs-sub">Rate 35%</span>
|
||||||
|
<div class="fs-led green"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footswitch preset" onclick="toggleFS(this)">
|
||||||
|
<span class="fs-scribble">BANK</span>
|
||||||
|
<span class="fs-label">▼ A</span>
|
||||||
|
<span class="fs-sub">Bank Down</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Block Chain Selection ──
|
||||||
|
function selectBlock(el) {
|
||||||
|
document.querySelectorAll('.chain-block').forEach(b => b.classList.remove('selected'));
|
||||||
|
el.classList.add('selected');
|
||||||
|
|
||||||
|
// Update footswitch bar
|
||||||
|
document.querySelectorAll('.footswitch[data-block]').forEach(f => f.classList.remove('active'));
|
||||||
|
const blockId = el.dataset.block;
|
||||||
|
const fs = document.querySelector(`.footswitch[data-block="${blockId}"]`);
|
||||||
|
if (fs) fs.classList.add('active');
|
||||||
|
|
||||||
|
// Update parameter panel (simple demo)
|
||||||
|
const name = el.querySelector('.block-name').textContent;
|
||||||
|
const type = el.querySelector('.block-type').textContent;
|
||||||
|
const header = document.querySelector('.param-header h3');
|
||||||
|
header.innerHTML = `${name} <span class="block-label">· ${type}</span>`;
|
||||||
|
|
||||||
|
// Update param panel content based on block
|
||||||
|
const params = {
|
||||||
|
gate: [
|
||||||
|
['Threshold', '40', 0, 100],
|
||||||
|
['Release', '25', 0, 100],
|
||||||
|
['Depth', '70', 0, 100],
|
||||||
|
['Mix', '100', 0, 100],
|
||||||
|
],
|
||||||
|
drive: [
|
||||||
|
['Drive', '65', 0, 100],
|
||||||
|
['Tone', '55', 0, 100],
|
||||||
|
['Level', '70', 0, 100],
|
||||||
|
['Tight', '30', 0, 100],
|
||||||
|
],
|
||||||
|
dist: [
|
||||||
|
['Distortion', '75', 0, 100],
|
||||||
|
['Filter', '50', 0, 100],
|
||||||
|
['Volume', '65', 0, 100],
|
||||||
|
['Gain', '80', 0, 100],
|
||||||
|
],
|
||||||
|
mod: [
|
||||||
|
['Rate', '35', 0, 100],
|
||||||
|
['Depth', '60', 0, 100],
|
||||||
|
['Feedback', '40', 0, 100],
|
||||||
|
['Mix', '50', 0, 100],
|
||||||
|
],
|
||||||
|
delay: [
|
||||||
|
['Time', '450', 20, 2000],
|
||||||
|
['Feedback', '25', 0, 100],
|
||||||
|
['Mix', '40', 0, 100],
|
||||||
|
['Low Cut', '80', 20, 500],
|
||||||
|
],
|
||||||
|
reverb: [
|
||||||
|
['Decay', '60', 0, 100],
|
||||||
|
['Pre-Delay', '30', 0, 200],
|
||||||
|
['Damping', '50', 0, 100],
|
||||||
|
['Mix', '35', 0, 100],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const p = params[blockId] || params.gate;
|
||||||
|
const grid = document.querySelector('.param-grid');
|
||||||
|
grid.innerHTML = p.map(([name, val, min, max], i) => {
|
||||||
|
const id = `p${i}`;
|
||||||
|
const pct = Math.round((val / max) * 100);
|
||||||
|
return `<div class="param-group">
|
||||||
|
<div class="param-label">
|
||||||
|
<span class="name">${name}</span>
|
||||||
|
<span class="value" id="${id}">${val}</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" class="touch-slider" min="${min}" max="${max}" value="${val}" oninput="updateSlider(this,'${id}')" style="--pct:${pct}%">
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSlider(el, valId) {
|
||||||
|
const val = el.value;
|
||||||
|
document.getElementById(valId).textContent = val;
|
||||||
|
const min = parseInt(el.min);
|
||||||
|
const max = parseInt(el.max);
|
||||||
|
const pct = Math.round(((val - min) / (max - min)) * 100);
|
||||||
|
el.style.setProperty('--pct', pct + '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footswitch Interaction ──
|
||||||
|
function toggleBlock(el, blockId) {
|
||||||
|
// Toggle bypass
|
||||||
|
const chainBlock = document.querySelector(`.chain-block[data-block="${blockId}"]`);
|
||||||
|
if (chainBlock) {
|
||||||
|
chainBlock.classList.toggle('bypassed');
|
||||||
|
const led = chainBlock.querySelector('.block-led');
|
||||||
|
const indicator = chainBlock.querySelector('.block-indicator');
|
||||||
|
if (chainBlock.classList.contains('bypassed')) {
|
||||||
|
led.className = 'block-led off';
|
||||||
|
indicator.style.background = 'var(--textDim)';
|
||||||
|
el.querySelector('.fs-label').style.color = 'var(--textDim)';
|
||||||
|
el.querySelector('.fs-sub').textContent = 'Bypassed';
|
||||||
|
el.querySelector('.fs-sub').style.color = 'var(--textDim)';
|
||||||
|
el.querySelector('.fs-led').className = 'fs-led off';
|
||||||
|
} else {
|
||||||
|
led.className = 'block-led on';
|
||||||
|
// Restore original indicator color
|
||||||
|
const colors = {gate:'var(--green)',drive:'var(--amber)',dist:'var(--red)',mod:'var(--blue)',delay:'#A060E0',reverb:'#60A0E0'};
|
||||||
|
indicator.style.background = colors[blockId] || 'var(--green)';
|
||||||
|
el.querySelector('.fs-label').style.color = '';
|
||||||
|
el.querySelector('.fs-sub').style.color = '';
|
||||||
|
// Restore sub text from chain block
|
||||||
|
const sub = chainBlock.querySelector('.block-sub');
|
||||||
|
el.querySelector('.fs-sub').textContent = sub.textContent;
|
||||||
|
el.querySelector('.fs-led').className = 'fs-led green';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.classList.toggle('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBlockFromFS(el, blockId) {
|
||||||
|
// Select block in chain
|
||||||
|
const chainBlock = document.querySelector(`.chain-block[data-block="${blockId}"]`);
|
||||||
|
if (chainBlock) selectBlock(chainBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFS(el) {
|
||||||
|
el.classList.toggle('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPreset(el) {
|
||||||
|
document.querySelectorAll('.preset-chip').forEach(c => c.classList.remove('active'));
|
||||||
|
el.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── VU Meter Animation ──
|
||||||
|
(function animateVU() {
|
||||||
|
const vu = document.getElementById('vuL');
|
||||||
|
const segs = 8;
|
||||||
|
vu.innerHTML = '';
|
||||||
|
for (let i = 0; i < segs; i++) {
|
||||||
|
const seg = document.createElement('div');
|
||||||
|
seg.className = 'vu-seg';
|
||||||
|
seg.style.height = '100%';
|
||||||
|
seg.style.flex = '1';
|
||||||
|
vu.appendChild(seg);
|
||||||
|
}
|
||||||
|
function tick() {
|
||||||
|
const level = Math.random();
|
||||||
|
const active = Math.floor(level * segs);
|
||||||
|
const segs_ = vu.querySelectorAll('.vu-seg');
|
||||||
|
segs_.forEach((seg, i) => {
|
||||||
|
const on = i < active;
|
||||||
|
const color = i >= 6 ? '#C84040' : i >= 4 ? '#E8C030' : '#3AB87A';
|
||||||
|
seg.style.background = on ? color : '#2A2A32';
|
||||||
|
seg.style.boxShadow = on ? `0 0 4px ${color}` : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setInterval(tick, 100);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Auto-scroll to show chain fully ──
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const scroll = document.getElementById('chainScroll');
|
||||||
|
if (scroll) scroll.scrollLeft = 120;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Touch feedback ──
|
||||||
|
document.addEventListener('touchstart', () => {}, {passive: true});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<title>Pi Multi-FX — Helix Stadium Mockup</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; user-select: none; }
|
||||||
|
body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; display: flex; justify-content: center; min-height: 100vh; }
|
||||||
|
|
||||||
|
.device {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #0A0A0C;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status Bar ─────────────────────────────── */
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px 6px;
|
||||||
|
background: #141418;
|
||||||
|
border-bottom: 1px solid #2A2A32;
|
||||||
|
height: 38px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.status-left { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #3AB87A; box-shadow: 0 0 8px #3AB87A; }
|
||||||
|
.status-label { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: #8888A0; letter-spacing: .08em; }
|
||||||
|
.status-right { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.status-preset { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: #8888A0; }
|
||||||
|
.status-cpu { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: #444458; }
|
||||||
|
.status-sig { font-size: 13px; color: #E8A030; }
|
||||||
|
|
||||||
|
/* ── MAIN AREA ───────────────────────────────── */
|
||||||
|
.main-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Block Chain ─────────────────────────────── */
|
||||||
|
.chain-section {
|
||||||
|
padding: 12px 10px 10px;
|
||||||
|
background: #0A0A0C;
|
||||||
|
border-bottom: 1px solid #2A2A32;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chain-label {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #444458;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.chain-scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.chain-scroll::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.terminator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.terminator-in { background: #1E4060; color: #3A7BA8; border: 1px solid #3A7BA855; }
|
||||||
|
.terminator-out { background: #1E4060; color: #3A7BA8; border: 1px solid #3A7BA855; }
|
||||||
|
|
||||||
|
.connector {
|
||||||
|
width: 18px;
|
||||||
|
height: 2px;
|
||||||
|
background: #2A2A32;
|
||||||
|
border-radius: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.connector::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: -2px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-right: 2px solid #2A2A32;
|
||||||
|
border-top: 2px solid #2A2A32;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 120px;
|
||||||
|
height: 56px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1C1C22;
|
||||||
|
border: 1px solid #2A2A32;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all .12s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.block-chip.active { border-color: #E8A030; background: #1C1C22; box-shadow: 0 0 12px #E8A03033; }
|
||||||
|
.block-chip.bypassed { opacity: .4; }
|
||||||
|
.block-chip.drag-over { border-color: #3AB87A; border-style: dashed; }
|
||||||
|
|
||||||
|
.block-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.block-led {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.block-led.on { background: #3AB87A; box-shadow: 0 0 6px #3AB87A; }
|
||||||
|
.block-led.off { background: #444458; }
|
||||||
|
|
||||||
|
.block-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #F0EDE6;
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.block-type {
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #7A521888;
|
||||||
|
color: #E8A030;
|
||||||
|
}
|
||||||
|
.block-drag {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: grab;
|
||||||
|
opacity: .3;
|
||||||
|
}
|
||||||
|
.block-drag::before, .block-drag::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #8888A0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PARAMS PANEL ────────────────────────────── */
|
||||||
|
.params-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.params-title {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #8888A0;
|
||||||
|
}
|
||||||
|
.param-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.param-action {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #1C1C22;
|
||||||
|
border: 1px solid #2A2A32;
|
||||||
|
color: #8888A0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .1s;
|
||||||
|
}
|
||||||
|
.param-action:active { transform: scale(.95); }
|
||||||
|
|
||||||
|
.knob-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px 20px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.knob-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.knob-shell {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.knob-bg {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 35% 30%, #3A3A48, #1A1A22);
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.07);
|
||||||
|
}
|
||||||
|
.knob-dot {
|
||||||
|
position: absolute;
|
||||||
|
width: 3px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #E8A030;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 8px;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
transform: translateX(-50%) rotate(30deg);
|
||||||
|
box-shadow: 0 0 6px #E8A030;
|
||||||
|
}
|
||||||
|
.knob-value {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #E8A030;
|
||||||
|
}
|
||||||
|
.knob-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #8888A0;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* slider */
|
||||||
|
.slider-group {
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.slider-label-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.slider-label { color: #8888A0; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.slider-value { font-family: 'JetBrains Mono', monospace; color: #E8A030; }
|
||||||
|
.slider-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #2A2A32;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.slider-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #E8A030;
|
||||||
|
width: 65%;
|
||||||
|
box-shadow: 0 0 6px #E8A03066;
|
||||||
|
}
|
||||||
|
.slider-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 65%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #F0EDE6;
|
||||||
|
border: 2px solid #0A0A0C;
|
||||||
|
box-shadow: 0 1px 6px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.master-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #F0EDE6;
|
||||||
|
}
|
||||||
|
.master-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8888A0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FOOTSWITCH BAR ───────────────────────────── */
|
||||||
|
.footswitch-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #141418;
|
||||||
|
border-top: 1px solid #2A2A32;
|
||||||
|
height: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bank-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1C1C22;
|
||||||
|
border: 1px solid #2A2A32;
|
||||||
|
color: #8888A0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all .1s;
|
||||||
|
}
|
||||||
|
.bank-btn:active { background: #2A2A32; transform: scale(.95); }
|
||||||
|
|
||||||
|
.footswitch {
|
||||||
|
flex: 1;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1C1C22;
|
||||||
|
border: 1px solid #2A2A32;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
transition: all .12s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.footswitch.active { border-color: #E8A03066; box-shadow: 0 0 10px #E8A03022; }
|
||||||
|
.footswitch:active { transform: scale(.96); }
|
||||||
|
.fs-number {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #E8A030;
|
||||||
|
}
|
||||||
|
.fs-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #F0EDE6;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 90%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.fs-sub {
|
||||||
|
font-size: 7px;
|
||||||
|
color: #444458;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Add Block Button ── */
|
||||||
|
.add-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 36px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px dashed #2A2A32;
|
||||||
|
background: transparent;
|
||||||
|
color: #444458;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.add-block:hover { border-color: #3A7BA8; color: #3A7BA8; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="device">
|
||||||
|
|
||||||
|
<!-- ── Status Bar ──────────────────────────── -->
|
||||||
|
<div class="status-bar">
|
||||||
|
<div class="status-left">
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<span class="status-label">CONNECTED</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-right">
|
||||||
|
<span class="status-preset">Plexi Crunch</span>
|
||||||
|
<span class="status-cpu">CPU 12%</span>
|
||||||
|
<span class="status-sig">▃▅▇█</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Main Area ───────────────────────────── -->
|
||||||
|
<div class="main-area">
|
||||||
|
|
||||||
|
<!-- ── Block Chain ────────────────────────── -->
|
||||||
|
<div class="chain-section">
|
||||||
|
<div class="chain-label">Signal Chain</div>
|
||||||
|
<div class="chain-scroll">
|
||||||
|
<div class="terminator terminator-in">IN</div>
|
||||||
|
|
||||||
|
<div class="connector"></div>
|
||||||
|
|
||||||
|
<div class="block-chip active" onclick="selectBlock(this, 0)">
|
||||||
|
<div class="block-top">
|
||||||
|
<div class="block-led on"></div>
|
||||||
|
<span class="block-name">Compressor</span>
|
||||||
|
<span class="block-type">DYN</span>
|
||||||
|
</div>
|
||||||
|
<div class="block-drag"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connector"></div>
|
||||||
|
|
||||||
|
<div class="block-chip" onclick="selectBlock(this, 1)">
|
||||||
|
<div class="block-top">
|
||||||
|
<div class="block-led on"></div>
|
||||||
|
<span class="block-name">Overdrive</span>
|
||||||
|
<span class="block-type">DRV</span>
|
||||||
|
</div>
|
||||||
|
<div class="block-drag"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connector"></div>
|
||||||
|
|
||||||
|
<div class="block-chip bypassed" onclick="selectBlock(this, 2)">
|
||||||
|
<div class="block-top">
|
||||||
|
<div class="block-led off"></div>
|
||||||
|
<span class="block-name">Chorus</span>
|
||||||
|
<span class="block-type">MOD</span>
|
||||||
|
</div>
|
||||||
|
<div class="block-drag"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connector"></div>
|
||||||
|
|
||||||
|
<div class="block-chip" onclick="selectBlock(this, 3)">
|
||||||
|
<div class="block-top">
|
||||||
|
<div class="block-led on"></div>
|
||||||
|
<span class="block-name">Delay</span>
|
||||||
|
<span class="block-type">TIME</span>
|
||||||
|
</div>
|
||||||
|
<div class="block-drag"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connector"></div>
|
||||||
|
|
||||||
|
<div class="block-chip" onclick="selectBlock(this, 4)">
|
||||||
|
<div class="block-top">
|
||||||
|
<div class="block-led on"></div>
|
||||||
|
<span class="block-name">Reverb</span>
|
||||||
|
<span class="block-type">AMB</span>
|
||||||
|
</div>
|
||||||
|
<div class="block-drag"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connector"></div>
|
||||||
|
|
||||||
|
<div class="terminator terminator-out">OUT</div>
|
||||||
|
|
||||||
|
<div class="add-block" title="Add block">+</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Params Panel ───────────────────────── -->
|
||||||
|
<div class="params-panel" id="paramsPanel">
|
||||||
|
<div class="params-header">
|
||||||
|
<div class="params-title" id="paramsTitle">Parameters — Compressor</div>
|
||||||
|
<div class="param-actions">
|
||||||
|
<div class="param-action" style="color:#3AB87A">○ Bypass</div>
|
||||||
|
<div class="param-action" style="color:#C84040">✕ Delete</div>
|
||||||
|
<div class="param-action">↻ Reset</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="knob-grid">
|
||||||
|
<div class="knob-wrap">
|
||||||
|
<div class="knob-shell">
|
||||||
|
<div class="knob-bg"></div>
|
||||||
|
<div class="knob-dot" style="transform: translateX(-50%) rotate(-30deg)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="knob-value" style="color: #E8A030">-20</div>
|
||||||
|
<div class="knob-label">Threshold</div>
|
||||||
|
</div>
|
||||||
|
<div class="knob-wrap">
|
||||||
|
<div class="knob-shell">
|
||||||
|
<div class="knob-bg"></div>
|
||||||
|
<div class="knob-dot" style="transform: translateX(-50%) rotate(45deg)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="knob-value" style="color: #3A7BA8">3:1</div>
|
||||||
|
<div class="knob-label">Ratio</div>
|
||||||
|
</div>
|
||||||
|
<div class="knob-wrap">
|
||||||
|
<div class="knob-shell">
|
||||||
|
<div class="knob-bg"></div>
|
||||||
|
<div class="knob-dot" style="transform: translateX(-50%) rotate(-15deg)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="knob-value" style="color: #E8A030">5</div>
|
||||||
|
<div class="knob-label">Attack</div>
|
||||||
|
</div>
|
||||||
|
<div class="knob-wrap">
|
||||||
|
<div class="knob-shell">
|
||||||
|
<div class="knob-bg"></div>
|
||||||
|
<div class="knob-dot" style="transform: translateX(-50%) rotate(20deg)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="knob-value" style="color: #E8A030">100</div>
|
||||||
|
<div class="knob-label">Release</div>
|
||||||
|
</div>
|
||||||
|
<div class="knob-wrap">
|
||||||
|
<div class="knob-shell">
|
||||||
|
<div class="knob-bg"></div>
|
||||||
|
<div class="knob-dot" style="transform: translateX(-50%) rotate(60deg)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="knob-value" style="color: #E8A030">1.0</div>
|
||||||
|
<div class="knob-label">Makeup</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-group">
|
||||||
|
<div class="slider-label-row">
|
||||||
|
<span class="slider-label">Mix</span>
|
||||||
|
<span class="slider-value">65%</span>
|
||||||
|
</div>
|
||||||
|
<div class="slider-track">
|
||||||
|
<div class="slider-fill" style="width:65%"></div>
|
||||||
|
<div class="slider-thumb" style="left:65%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Footswitch Bar ────────────────────────── -->
|
||||||
|
<div class="footswitch-bar">
|
||||||
|
<div class="bank-btn">◀</div>
|
||||||
|
|
||||||
|
<div class="footswitch active">
|
||||||
|
<div class="fs-number">1</div>
|
||||||
|
<div class="fs-label">Plexi Crunch</div>
|
||||||
|
<div class="fs-sub">Preset</div>
|
||||||
|
</div>
|
||||||
|
<div class="footswitch">
|
||||||
|
<div class="fs-number">2</div>
|
||||||
|
<div class="fs-label">Mod Hi-Gain</div>
|
||||||
|
<div class="fs-sub">Preset</div>
|
||||||
|
</div>
|
||||||
|
<div class="footswitch">
|
||||||
|
<div class="fs-number">3</div>
|
||||||
|
<div class="fs-label">Clean Chimey</div>
|
||||||
|
<div class="fs-sub">Preset</div>
|
||||||
|
</div>
|
||||||
|
<div class="footswitch">
|
||||||
|
<div class="fs-number">4</div>
|
||||||
|
<div class="fs-label">Soul Blues</div>
|
||||||
|
<div class="fs-sub">Preset</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bank-btn">▶</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let selectedIdx = 0;
|
||||||
|
const chips = document.querySelectorAll('.block-chip');
|
||||||
|
const paramsTitle = document.getElementById('paramsTitle');
|
||||||
|
|
||||||
|
function selectBlock(el, idx) {
|
||||||
|
chips.forEach(c => c.classList.remove('active'));
|
||||||
|
el.classList.add('active');
|
||||||
|
selectedIdx = idx;
|
||||||
|
const name = el.querySelector('.block-name').textContent;
|
||||||
|
paramsTitle.textContent = 'Parameters — ' + name;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
# Helix Stadium UX Redesign — Spec & Mockup
|
||||||
|
|
||||||
|
> **Task:** Redesign the Pi Multi-FX Pedal UI from a mobile-first vertical tab layout to a
|
||||||
|
> Helix Stadium-style horizontal block chain with footswitch navigation.
|
||||||
|
> **Status:** Research / Spec Complete
|
||||||
|
> **Date:** 2026-06-12
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Design Philosophy
|
||||||
|
|
||||||
|
The Helix Stadium is designed for **stage use**: large touch targets, high contrast at
|
||||||
|
arm's length, minimal scrolling, instant access to block placement and parameters.
|
||||||
|
Our redesign translates this philosophy into a legally distinct UI using the existing
|
||||||
|
amber/blue/green palette and JetBrains Mono + Inter typography.
|
||||||
|
|
||||||
|
### Key UX Principles
|
||||||
|
|
||||||
|
| Principle | Application |
|
||||||
|
|-----------|-------------|
|
||||||
|
| One primary view | Block chain is the main screen — IN → blocks → OUT |
|
||||||
|
| Touch-first | 60-80px minimum tap targets, no hover-dependent controls |
|
||||||
|
| Context-sensitive | Select a block → knob/slider panel shows its params |
|
||||||
|
| Visual feedback | Bypass LED, bypassed opacity, active block highlight |
|
||||||
|
| No dead space | Every pixel is a control or shows status |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Screen Layout (Top → Bottom)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ STATUS BAR │
|
||||||
|
│ ● CONNECTED Preset Name CPU 12% ▂▃▅▇ │ ~36px
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ BLOCK CHAIN (horizontal scroll) │
|
||||||
|
│ │ ~80-100px
|
||||||
|
│ [IN] → [Comp] → [OD] → [Rev] → [OUT] │
|
||||||
|
│ ↑ selected │
|
||||||
|
│ ───────────────────────────────────── │
|
||||||
|
│ ⬡ BYPASS ⬡ DRIVE ⬡ TONE ⬡ GAIN │
|
||||||
|
│ PARAMETER KNOBS / SLIDERS │ flex
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ FOOTSWITCH BAR │
|
||||||
|
│ [1 PlexiC] [2 ModHi] [3 Clean] [4 Soul] │ ~64-72px
|
||||||
|
│ [5 Metal] [6 Studio] [7 Acous] [8 Twang]│
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1 Status Bar (36px)
|
||||||
|
|
||||||
|
- **Left:** Green/red connection dot, "CONNECTED" / "OFFLINE" label
|
||||||
|
- **Right:** Current preset name (if loaded), CPU %, audio signal meter
|
||||||
|
- Background: `#141418` (panel) with `#2A2A32` bottom border
|
||||||
|
- Same as current, but more compact
|
||||||
|
|
||||||
|
### 2.2 Block Chain (80-100px)
|
||||||
|
|
||||||
|
- Horizontal scrolling flex container with overflow-x: auto
|
||||||
|
- Left terminators: **IN** chip (blue) — fixed, always first
|
||||||
|
- Right terminator: **OUT** chip (blue) — fixed, always last
|
||||||
|
- Blocks between: **amber** chips, min 48px height (target 64px)
|
||||||
|
- Gap between blocks shown as a connector line (`→` or border-based line)
|
||||||
|
- Scrollable via horizontal swipe or footswitch left/right buttons
|
||||||
|
- Visual distinction between bypassed (opacity 0.4) and active blocks
|
||||||
|
|
||||||
|
**Block Chip Spec:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ ██ Compressor │ ← green LED when active, dim when bypassed
|
||||||
|
│ ODRIVE │ ← subtype badge (small)
|
||||||
|
│ ▾ │ ← expand indicator (tap to select)
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Interaction:
|
||||||
|
- **Tap:** Select block → parameter panel updates below
|
||||||
|
- **Long press / drag handle:** Reorder (drag-and-drop within chain)
|
||||||
|
- **Double tap:** Toggle bypass
|
||||||
|
- **Swipe left on block:** Delete (with undo toast)
|
||||||
|
|
||||||
|
### 2.3 Parameters Panel (flex, fills remaining space)
|
||||||
|
|
||||||
|
When a block is selected, the panel below the chain shows its editable parameters.
|
||||||
|
Each param renders as either a **big knob** (continuous, like drive, tone, rate) or
|
||||||
|
a **big slider** (linear range, like volume, mix, feedback) or a **toggle** (bypass, enable).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ PARAMETERS — Compressor │ ~24px section label
|
||||||
|
│ │
|
||||||
|
│ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||||
|
│ │ ╭──╮ │ │ ╭──╮ │ │ ╭──╮ │ │
|
||||||
|
│ │ ╰──╯ │ │ ╰──╯ │ │ ╰──╯ │ │ knobs 52-64px
|
||||||
|
│ │ -24 │ │ 3:1 │ │ 5ms │ │
|
||||||
|
│ │THRESH│ │RATIO │ │ATTACK│ │
|
||||||
|
│ └──────┘ └──────┘ └──────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────────────────────────┐ │
|
||||||
|
│ │ ○ MASTER VOLUME ▓▓▓▓░░│ │ slider
|
||||||
|
│ └───────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [○ BYPASS BLOCK] [✕ DELETE] [↻ RESET] │ action buttons
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
When no block is selected, this panel shows **master volume**, **global bypass**,
|
||||||
|
and **active preset** info (like current RigScreen).
|
||||||
|
|
||||||
|
### 2.4 Footswitch Bar (64-72px)
|
||||||
|
|
||||||
|
- **Fixed at bottom** — never scrolls away
|
||||||
|
- **4 footswitch buttons** shown at a time (with bank left/right pagination for 8+)
|
||||||
|
- Each button = big rounded rect with **scribble strip** (number + short label)
|
||||||
|
- Colors match preset/bank colors (amber, red, blue, green)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┬──────────┬──────────┬──────────┐
|
||||||
|
│ 1 │ 2 │ 3 │ 4 │
|
||||||
|
│ Plexi │ Modern │ Clean │ Southern │ ← scribble strip
|
||||||
|
│ Crunch │ Hi-Gain │ Chimey │ Blues │
|
||||||
|
├──────────┼──────────┼──────────┼──────────┤
|
||||||
|
│ ◀ │ │ │ ▶│ ← bank nav
|
||||||
|
└──────────┴──────────┴──────────┴──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Footswitch Interactions:
|
||||||
|
- **Tap:** Load that preset
|
||||||
|
- **Long press:** Save current chain to that slot
|
||||||
|
- **Bank nav (◀ ▶):** Switch between banks (pages of 4)
|
||||||
|
- **Scribble strip:** Shows preset name (truncated to ~8-10 chars) rendered in amber/green
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Component Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
App
|
||||||
|
├── <style>{CSS}</style>
|
||||||
|
├── Shell (100vh, flex column)
|
||||||
|
│ ├── StatusBar
|
||||||
|
│ │ ├── ConnectionDot
|
||||||
|
│ │ ├── PresetLabel
|
||||||
|
│ │ └── SystemStats (CPU + signal)
|
||||||
|
│ ├── ChainPanel (flex: 1)
|
||||||
|
│ │ ├── BlockChain (horizontal scroll)
|
||||||
|
│ │ │ ├── ChainTerminator("IN", fixed, blue)
|
||||||
|
│ │ │ ├── BlockChip × N (draggable, amber)
|
||||||
|
│ │ │ │ ├── BypassLED
|
||||||
|
│ │ │ │ ├── BlockName
|
||||||
|
│ │ │ │ ├── TypeBadge
|
||||||
|
│ │ │ │ └── DragHandle
|
||||||
|
│ │ │ ├── ChainConnector × N-1 (→ line)
|
||||||
|
│ │ │ └── ChainTerminator("OUT", fixed, blue)
|
||||||
|
│ │ └── ParamsPanel
|
||||||
|
│ │ ├── (if block selected) KnobGrid
|
||||||
|
│ │ │ └── BigKnob × N
|
||||||
|
│ │ ├── (if block selected) SliderList
|
||||||
|
│ │ │ └── BigSlider × N
|
||||||
|
│ │ ├── (if block selected) BlockActions
|
||||||
|
│ │ │ └── BypassBtn / DeleteBtn / ResetBtn
|
||||||
|
│ │ └── (if none selected) MasterVolume + PresetInfo
|
||||||
|
│ │ ├── VolumeSlider (existing)
|
||||||
|
│ │ └── PresetSummary
|
||||||
|
│ └── FootswitchBar (fixed 64-72px)
|
||||||
|
│ ├── BankLeftBtn (◀)
|
||||||
|
│ ├── FootswitchBtn × 4
|
||||||
|
│ │ └── ScribbleStrip (number + label)
|
||||||
|
│ └── BankRightBtn (▶)
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Shape (React)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const [selectedBlock, setSelectedBlock] = useState(null); // block id or null
|
||||||
|
const [blocks, setBlocks] = useState([]); // current block chain
|
||||||
|
const [activePreset, setActivePreset] = useState(null);
|
||||||
|
const [footswitchBank, setFootswitchBank] = useState(0); // 0-based
|
||||||
|
const [params, setParams] = useState({}); // fetched from API when block selected
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Color & Typography (Existing Design Tokens)
|
||||||
|
|
||||||
|
Use the existing `T` object from App.jsx unchanged. No new colors needed —
|
||||||
|
the amber/blue/green palette maps directly to Helix-style needs:
|
||||||
|
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|-------|-----|-------|
|
||||||
|
| `T.amber` | `#E8A030` | Active block, selected state, knobs, scribble strip text |
|
||||||
|
| `T.amberDim` | `#7A5218` | Block chip background |
|
||||||
|
| `T.blue` | `#3A7BA8` | IN/OUT terminators, connection dots |
|
||||||
|
| `T.blueDim` | `#1E4060` | Terminator backgrounds |
|
||||||
|
| `T.green` | `#3AB87A` | Bypass LED on, signal OK |
|
||||||
|
| `T.bg` | `#0A0A0C` | Page background |
|
||||||
|
| `T.panel` | `#141418` | Card/section backgrounds |
|
||||||
|
| `T.surface` | `#1C1C22` | Input fields, button surfaces |
|
||||||
|
| `T.border` | `#2A2A32` | Lines, dividers |
|
||||||
|
|
||||||
|
Typography unchanged: **Inter** for body/UI text, **JetBrains Mono** for values/labels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API Integration
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Mount → GET /api/state
|
||||||
|
├── connected, master_volume, bypass
|
||||||
|
└── current_preset (name, bank, program)
|
||||||
|
|
||||||
|
On mount of preset: GET /api/presets/{bank}/{program}
|
||||||
|
└── chain[].blocks → populates BlockChain
|
||||||
|
|
||||||
|
On block select: GET /api/block-params/{fx_type}
|
||||||
|
└── params[] → renders knobs/sliders
|
||||||
|
|
||||||
|
Param change: PATCH /api/block-params { id, key, value }
|
||||||
|
Toggle bypass: PATCH /api/blocks { id, enabled }
|
||||||
|
Master volume: POST /api/volume { volume }
|
||||||
|
Global bypass: POST /api/bypass/toggle
|
||||||
|
Load preset: POST /api/presets/{bank}/{program}/activate
|
||||||
|
Save preset: PUT /api/presets/{bank}/{program}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Gap: Blocks in `/api/state`
|
||||||
|
|
||||||
|
The current `_gather_state()` does NOT return `blocks[]` — but the UI needs the
|
||||||
|
current block chain to render. Options ranked by preference:
|
||||||
|
|
||||||
|
1. **Extend `/api/state` to include `blocks`** — Add the current preset's chain
|
||||||
|
to the state response. Most efficient: single fetch on mount + WebSocket push.
|
||||||
|
Requires adding ~5 lines to `_gather_state()`.
|
||||||
|
2. **Load preset chain separately** — On mount, fetch `GET /api/presets/{bank}/{program}`
|
||||||
|
to get the chain. Needs bank/program from state. Works without backend change
|
||||||
|
but adds an extra round trip.
|
||||||
|
3. **WebSocket-first** — `/ws` already pushes state on connect. Extend the push
|
||||||
|
to include blocks.
|
||||||
|
|
||||||
|
**Recommendation:** Option 1 (backend change) — it's trivial and eliminates an
|
||||||
|
extra HTTP round trip on every page load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Drag-and-Drop Reordering
|
||||||
|
|
||||||
|
For reordering blocks in the chain, use the **HTML5 Drag and Drop API**
|
||||||
|
(native, no library needed in modern React):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// BlockChip
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => e.dataTransfer.setData('text/plain', blockId)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const draggedId = e.dataTransfer.getData('text/plain');
|
||||||
|
reorderBlocks(draggedId, blockId); // swap or insert
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
After reorder: `PATCH /api/blocks/reorder { ids: [new order] }` — a new endpoint
|
||||||
|
to persist the sequence. For MVP, keep the reorder local-only (state-only) and
|
||||||
|
save with the preset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Responsive Behavior
|
||||||
|
|
||||||
|
| Viewport | Layout |
|
||||||
|
|----------|--------|
|
||||||
|
| < 480px (phone) | Single column, chain scrolls horizontally, param panel below, footswitch at bottom |
|
||||||
|
| 480-768px (tablet) | Same layout, more knobs visible per row |
|
||||||
|
| > 768px (desktop) | Optional: chain + params side-by-side. Footswitch always at bottom. |
|
||||||
|
|
||||||
|
The current app is capped at `maxWidth: 440px` — Stage UI should use **full width**
|
||||||
|
on desktop (for the pedal's browser UI) while remaining touch-friendly on tablets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Block Chain View
|
||||||
|
- [ ] Extract block rendering into `ChainPanel` + `BlockChain` + `BlockChip` components
|
||||||
|
- [ ] Build horizontal scroll layout with IN/OUT terminators
|
||||||
|
- [ ] Wire block select → highlight state
|
||||||
|
- [ ] Bypass toggle on double-tap
|
||||||
|
- [ ] Fetch and display blocks from `/api/state` (requires backend change #1)
|
||||||
|
|
||||||
|
### Phase 2: Parameters Panel
|
||||||
|
- [ ] Build `ParamsPanel` component with conditional rendering
|
||||||
|
- [ ] Adapt existing `Knob` component for bigger stage-friendly size
|
||||||
|
- [ ] Add `BigSlider` component for linear params
|
||||||
|
- [ ] Wire param change → `PATCH /api/block-params`
|
||||||
|
- [ ] Show master volume / preset info when no block selected
|
||||||
|
|
||||||
|
### Phase 3: Footswitch Bar
|
||||||
|
- [ ] Build `FootswitchBar` component
|
||||||
|
- [ ] Scribble strip rendering (number + truncated name)
|
||||||
|
- [ ] Bank pagination (◀ ▶)
|
||||||
|
- [ ] Tap → load preset via API
|
||||||
|
- [ ] Long press → save current chain as preset
|
||||||
|
|
||||||
|
### Phase 4: Drag-and-Drop Reorder
|
||||||
|
- [ ] Implement HTML5 DnD on `BlockChip`
|
||||||
|
- [ ] Reorder state on drop
|
||||||
|
- [ ] Persist to API
|
||||||
|
|
||||||
|
### Phase 5: Polish & Legal Distinctiveness
|
||||||
|
- [ ] Custom block icon SVGs per FX type
|
||||||
|
- [ ] Scribble strip font rendering optimization
|
||||||
|
- [ ] Touch sensitivity tuning
|
||||||
|
- [ ] Build to `dist/` and verify backend serves at `/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Size & Layout Constants
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--block-height: 64px;
|
||||||
|
--footswitch-height: 68px;
|
||||||
|
--statusbar-height: 36px;
|
||||||
|
--knob-size: 56px;
|
||||||
|
--slider-height: 36px;
|
||||||
|
--min-tap-target: 48px;
|
||||||
|
--border-radius-block: 8px;
|
||||||
|
--border-radius-footswitch: 6px;
|
||||||
|
--chain-gap: 8px;
|
||||||
|
--connector-width: 16px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Existing Components to Reuse
|
||||||
|
|
||||||
|
| Component | Status | Changes Needed |
|
||||||
|
|-----------|--------|----------------|
|
||||||
|
| `Knob` | Already exists (SVG arc, touch drag) | Increase default size to 56px, adjustable sizing |
|
||||||
|
| `VUMeter` | Already exists (vertical + horizontal) | None — fits in status bar |
|
||||||
|
| `FXBlock` | Currently vertical card | Rewrite as horizontal `BlockChip` |
|
||||||
|
| `T` design tokens | Already defined | Use unchanged |
|
||||||
|
| CSS styles | Currently in tagged template | Extract into separate `.module.css` or keep inline |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Backend Changes Required
|
||||||
|
|
||||||
|
### 1. Add `blocks` to `/api/state`
|
||||||
|
|
||||||
|
In `_gather_state()`, add after line 1163:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Current block chain from active preset
|
||||||
|
if preset:
|
||||||
|
state["blocks"] = [
|
||||||
|
{
|
||||||
|
"id": f"block_{i}",
|
||||||
|
"fx_type": b.fx_type.value,
|
||||||
|
"name": b.fx_type.value.replace("_", " ").title(),
|
||||||
|
"enabled": b.enabled,
|
||||||
|
"bypass": b.bypass,
|
||||||
|
"params": dict(b.params),
|
||||||
|
"nam_model_path": b.nam_model_path,
|
||||||
|
"ir_file_path": b.ir_file_path,
|
||||||
|
}
|
||||||
|
for i, b in enumerate(preset.chain)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
state["blocks"] = []
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. (Optional) `/api/blocks/reorder` endpoint
|
||||||
|
|
||||||
|
For persistable drag-and-drop ordering:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.put("/api/blocks/reorder")
|
||||||
|
async def reorder_blocks(data: dict):
|
||||||
|
"""Reorder blocks in the current preset chain."""
|
||||||
|
new_ids = data.get("ids", [])
|
||||||
|
# Map old block order to new order based on block ids
|
||||||
|
pm = self.deps.presets
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of spec. Ready for handoff to implementation.*
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#0A0A0C" />
|
||||||
|
<title>NAM Pedal</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2424
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-multifx-pedal-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.6.0",
|
||||||
|
"vite": "^8.0.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
+813
@@ -0,0 +1,813 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import BlockChain from "./BlockChain.jsx";
|
||||||
|
import FootswitchBar from "./FootswitchBar.jsx";
|
||||||
|
|
||||||
|
// ── API Layer ──────────────────────────────────────────────────────
|
||||||
|
const API_BASE = "";
|
||||||
|
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const opts = { method, headers: { "Content-Type": "application/json" } };
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, opts);
|
||||||
|
if (!res.ok) throw new Error(`API ${method} ${path}: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const API = {
|
||||||
|
getState: () => api("GET", "/api/state"),
|
||||||
|
getVu: () => api("GET", "/api/vu"),
|
||||||
|
updateBlock: (id, params) => api("PATCH", "/api/block-params", { id, ...params }),
|
||||||
|
toggleBlock: (id, enabled) => api("PATCH", "/api/blocks", { id, enabled }),
|
||||||
|
bypassToggle: () => api("POST", "/api/bypass/toggle"),
|
||||||
|
masterVolume: (v) => api("POST", "/api/volume", { volume: v / 100 }),
|
||||||
|
listModels: () => api("GET", "/api/models"),
|
||||||
|
loadModel: (path) => api("POST", "/api/models/load", { path }),
|
||||||
|
listPresets: () => api("GET", "/api/presets"),
|
||||||
|
loadPreset: (bank, program) => api("GET", "/api/presets", { bank, program }),
|
||||||
|
savePreset: (bank, program, name, tags) => api("PUT", "/api/presets", { bank, program, name, tags }),
|
||||||
|
searchModels: (q) => api("GET", `/api/tonehub/search?q=${encodeURIComponent(q)}`),
|
||||||
|
searchIrs: (q) => api("GET", `/api/tonehub/search/irs?q=${encodeURIComponent(q)}`),
|
||||||
|
installModel: (url, name) => api("POST", "/api/models/tonedownload/install", { download_url: url, name }),
|
||||||
|
installIr: (url, name) => api("POST", "/api/irs/tonedownload/install", { download_url: url, name }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Design tokens ──────────────────────────────────────────────
|
||||||
|
const T = {
|
||||||
|
bg: "#0A0A0C",
|
||||||
|
panel: "#141418",
|
||||||
|
surface: "#1C1C22",
|
||||||
|
border: "#2A2A32",
|
||||||
|
amber: "#E8A030",
|
||||||
|
amberDim: "#7A5218",
|
||||||
|
blue: "#3A7BA8",
|
||||||
|
blueDim: "#1E4060",
|
||||||
|
green: "#3AB87A",
|
||||||
|
red: "#C84040",
|
||||||
|
textPrimary: "#F0EDE6",
|
||||||
|
textSec: "#8888A0",
|
||||||
|
textDim: "#444458",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Block parameter definitions ─────────────────────────────────
|
||||||
|
const BLOCK_PARAMS = {
|
||||||
|
overdrive: [{ key:'drive', label:'Drive', min:0, max:100, def:30 }, { key:'tone', label:'Tone', min:0, max:100, def:50 }, { key:'level', label:'Level', min:0, max:100, def:70 }],
|
||||||
|
distortion: [{ key:'drive', label:'Drive', min:0, max:100, def:50 }, { key:'tone', label:'Tone', min:0, max:100, def:40 }, { key:'level', label:'Level', min:0, max:100, def:60 }],
|
||||||
|
fuzz: [{ key:'fuzz', label:'Fuzz', min:0, max:100, def:60 }, { key:'tone', label:'Tone', min:0, max:100, def:30 }, { key:'level', label:'Level', min:0, max:100, def:60 }],
|
||||||
|
delay: [{ key:'time', label:'Time', min:0, max:100, def:40 }, { key:'feedback', label:'Feedback', min:0, max:100, def:30 }, { key:'mix', label:'Mix', min:0, max:100, def:35 }],
|
||||||
|
echo: [{ key:'time', label:'Time', min:0, max:100, def:40 }, { key:'feedback', label:'Feedback', min:0, max:100, def:30 }, { key:'mix', label:'Mix', min:0, max:100, def:35 }],
|
||||||
|
reverb: [{ key:'decay', label:'Decay', min:0, max:100, def:50 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }, { key:'tone', label:'Tone', min:0, max:100, def:50 }],
|
||||||
|
chorus: [{ key:'rate', label:'Rate', min:0, max:100, def:40 }, { key:'depth', label:'Depth', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }],
|
||||||
|
flanger: [{ key:'rate', label:'Rate', min:0, max:100, def:30 }, { key:'depth', label:'Depth', min:0, max:100, def:50 }, { key:'feedback', label:'Feedback', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }],
|
||||||
|
phaser: [{ key:'rate', label:'Rate', min:0, max:100, def:30 }, { key:'depth', label:'Depth', min:0, max:100, def:50 }, { key:'feedback', label:'Feedback', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }],
|
||||||
|
compressor: [{ key:'threshold', label:'Thresh', min:0, max:100, def:50 }, { key:'ratio', label:'Ratio', min:1, max:20, def:4 }, { key:'gain', label:'Gain', min:0, max:100, def:50 }],
|
||||||
|
gate: [{ key:'threshold', label:'Thresh', min:0, max:100, def:30 }, { key:'release', label:'Release', min:0, max:100, def:50 }],
|
||||||
|
eq: [{ key:'low', label:'Low', min:-12, max:12, def:0 }, { key:'mid', label:'Mid', min:-12, max:12, def:0 }, { key:'high', label:'High', min:-12, max:12, def:0 }, { key:'level', label:'Level', min:0, max:100, def:70 }],
|
||||||
|
cabinet: [{ key:'level', label:'Level', min:0, max:100, def:75 }],
|
||||||
|
ir: [{ key:'level', label:'Level', min:0, max:100, def:75 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLOCK_COLORS = {
|
||||||
|
overdrive: T.amber, distortion: T.red, fuzz: '#C07030',
|
||||||
|
delay: T.blue, echo: T.blue, reverb: '#60B0D0',
|
||||||
|
chorus: T.green, flanger: '#50C080', phaser: '#80C050',
|
||||||
|
compressor: '#D09040', gate: T.textSec, eq: '#7080D0',
|
||||||
|
cabinet: '#D07090', ir: '#D07090',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBlockParams(type) {
|
||||||
|
return BLOCK_PARAMS[(type || '').toLowerCase()] || [{ key:'level', label:'Level', min:0, max:100, def:50 }];
|
||||||
|
}
|
||||||
|
function getBlockColor(type) {
|
||||||
|
return BLOCK_COLORS[(type || '').toLowerCase()] || T.amber;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSS (Helix Stadium theme) ─────────────────────────────────
|
||||||
|
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');
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; user-select: none; }
|
||||||
|
body { background: ${T.bg}; color: ${T.textPrimary}; font-family: 'Inter', sans-serif; }
|
||||||
|
|
||||||
|
:root { --amber: ${T.amber}; --blue: ${T.blue}; --green: ${T.green}; }
|
||||||
|
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
|
::-webkit-scrollbar-track { background: ${T.panel}; }
|
||||||
|
::-webkit-scrollbar-thumb { background: ${T.border}; border-radius: 2px; }
|
||||||
|
|
||||||
|
/* ── Knob ── */
|
||||||
|
.knob-wrap { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; }
|
||||||
|
.knob { border-radius: 50%; position: relative; touch-action: none; }
|
||||||
|
.knob-inner { border-radius: 50%; width: 100%; height: 100%; position: relative;
|
||||||
|
background: radial-gradient(circle at 35% 30%, #3A3A48, #1A1A22);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.07); }
|
||||||
|
.knob-dot { position: absolute; width: 3px; border-radius: 2px; background: var(--amber);
|
||||||
|
left: 50%; transform-origin: bottom center; box-shadow: 0 0 6px var(--amber); }
|
||||||
|
.knob-label { font-size: 10px; color: ${T.textSec}; letter-spacing: .06em; text-transform: uppercase; text-align: center; }
|
||||||
|
.knob-value { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: ${T.amber}; }
|
||||||
|
|
||||||
|
/* ── Toggle ── */
|
||||||
|
.toggle { display: flex; background: ${T.surface}; border-radius: 6px; border: 1px solid ${T.border}; overflow: hidden; }
|
||||||
|
.toggle-btn { flex: 1; padding: 7px 12px; font-size: 12px; font-weight: 500; letter-spacing: .04em;
|
||||||
|
background: none; border: none; color: ${T.textSec}; cursor: pointer; transition: all .15s; }
|
||||||
|
.toggle-btn.active { background: ${T.amber}; color: #000; }
|
||||||
|
|
||||||
|
/* ── Buttons ── */
|
||||||
|
.btn { padding: 10px 18px; border-radius: 7px; font-size: 13px; font-weight: 600;
|
||||||
|
letter-spacing: .04em; border: none; cursor: pointer; transition: all .12s; }
|
||||||
|
.btn-primary { background: ${T.amber}; color: #000; }
|
||||||
|
.btn-primary:active { filter: brightness(.85); transform: scale(.97); }
|
||||||
|
.btn-ghost { background: ${T.surface}; color: ${T.textPrimary}; border: 1px solid ${T.border}; }
|
||||||
|
.btn-ghost:active { background: ${T.border}; }
|
||||||
|
.btn-danger { background: rgba(200,64,64,.2); color: ${T.red}; border: 1px solid rgba(200,64,64,.3); }
|
||||||
|
.btn-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center;
|
||||||
|
justify-content: center; background: ${T.surface}; border: 1px solid ${T.border}; cursor: pointer;
|
||||||
|
font-size: 16px; transition: all .12s; }
|
||||||
|
.btn-icon:active { transform: scale(.93); }
|
||||||
|
.btn-icon.active { background: rgba(232,160,48,.15); border-color: ${T.amber}; color: ${T.amber}; }
|
||||||
|
|
||||||
|
/* ── Badge ── */
|
||||||
|
.badge { display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
padding: 2px 7px; border-radius: 4px; font-size: 10px; font-weight: 700;
|
||||||
|
letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.badge-amber { background: rgba(232,160,48,.2); color: ${T.amber}; }
|
||||||
|
.badge-green { background: rgba(58,184,122,.2); color: ${T.green}; }
|
||||||
|
.badge-blue { background: rgba(58,123,168,.2); color: ${T.blue}; }
|
||||||
|
.badge-red { background: rgba(200,64,64,.2); color: ${T.red}; }
|
||||||
|
|
||||||
|
/* ── Screen header (for overlay screens) ── */
|
||||||
|
.screen-header { padding: 14px 16px 10px; display: flex; align-items: center;
|
||||||
|
justify-content: space-between; border-bottom: 1px solid ${T.border}; }
|
||||||
|
.screen-title { font-size: 13px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: ${T.textSec}; }
|
||||||
|
.screen-body { flex: 1; overflow-y: auto; padding: 14px 12px; display: flex;
|
||||||
|
flex-direction: column; gap: 14px; }
|
||||||
|
|
||||||
|
/* ── Param slider ── */
|
||||||
|
.param-panel { background: ${T.surface}; border-radius: 12px 12px 0 0; padding: 10px 14px 8px;
|
||||||
|
flex-shrink: 0; overflow-y: auto; border-top: 1px solid ${T.border}; }
|
||||||
|
.param-panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||||
|
.param-panel-title { font-size: 11px; font-weight: 600; letter-spacing: .07em; text-transform: uppercase; color: ${T.textSec}; }
|
||||||
|
.param-row { display: flex; flex-direction: column; gap: 2px; padding: 2px 0; }
|
||||||
|
.param-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.param-label { font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: .06em; color: ${T.textSec}; }
|
||||||
|
.param-val { font-family: 'JetBrains Mono',monospace; font-size: 12px; font-weight: 600; }
|
||||||
|
.param-track-wrap { position: relative; height: 38px; display: flex; align-items: center; cursor: pointer; touch-action: none; }
|
||||||
|
.param-track { width: 100%; height: 8px; background: ${T.border}; border-radius: 4px; position: relative; overflow: hidden; }
|
||||||
|
.param-fill { height: 100%; border-radius: 4px; position: absolute; top: 0; left: 0; transition: width .04s; }
|
||||||
|
.param-thumb { width: 26px; height: 26px; border-radius: 50%; position: absolute; top: 50%;
|
||||||
|
transform: translate(-50%,-50%); box-shadow: 0 1px 6px rgba(0,0,0,.6); pointer-events: none;
|
||||||
|
border: 3px solid ${T.bg}; }
|
||||||
|
.param-btn { width: 32px; height: 32px; border-radius: 50%; border: 1px solid ${T.border};
|
||||||
|
background: ${T.panel}; color: ${T.textPrimary}; font-size: 15px; font-weight: 700;
|
||||||
|
display: flex; align-items: center; justify-content: center; cursor: pointer; line-height: 1; }
|
||||||
|
.param-btn:active { background: ${T.border}; transform: scale(.92); }
|
||||||
|
.param-knobs { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; padding: 8px 0; }
|
||||||
|
|
||||||
|
/* ── Preset chip ── */
|
||||||
|
.preset-chip { padding: 5px 8px; border-radius: 6px; background: ${T.surface}; border: 1px solid ${T.border};
|
||||||
|
display: flex; flex-direction: column; align-items: center; cursor: pointer; transition: all .12s; flex: 1;
|
||||||
|
min-width: 0; }
|
||||||
|
.preset-chip.active { border-color: ${T.amber}; background: rgba(232,160,48,.08); }
|
||||||
|
.preset-chip .pc-name { font-size: 10px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||||||
|
.preset-chip .pc-num { font-family: 'JetBrains Mono', monospace; font-size: 8px; color: ${T.textDim}; margin-top: 1px; }
|
||||||
|
|
||||||
|
/* ── Preset row (captures screen) ── */
|
||||||
|
.preset-row { display: flex; align-items: center; gap: 12px; padding: 11px 14px;
|
||||||
|
border-radius: 8px; cursor: pointer; transition: background .12s; border: 1px solid transparent; }
|
||||||
|
.preset-row:active, .preset-row.active { background: rgba(232,160,48,.08); border-color: rgba(232,160,48,.25); }
|
||||||
|
.section-label { font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
|
||||||
|
color: ${T.textDim}; font-weight: 600; margin-bottom: 10px; }
|
||||||
|
|
||||||
|
/* ── Card ── */
|
||||||
|
.card { background: ${T.panel}; border: 1px solid ${T.border}; border-radius: 10px; padding: 16px; }
|
||||||
|
.card-sm { background: ${T.surface}; border: 1px solid ${T.border}; border-radius: 8px; padding: 12px; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Knob ──────────────────────────────────────────────────────
|
||||||
|
function Knob({ label, value = 50, onChange, size = 52, color = T.amber, min = 0, max = 100 }) {
|
||||||
|
const startRef = useRef(null);
|
||||||
|
const norm = (value - min) / (max - min);
|
||||||
|
const angle = -140 + norm * 280;
|
||||||
|
const dotH = size * 0.38;
|
||||||
|
const dotTop = size * 0.5 - dotH;
|
||||||
|
|
||||||
|
const handleStart = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||||
|
startRef.current = { y: clientY, val: value };
|
||||||
|
const move = (ev) => {
|
||||||
|
const cy = ev.touches ? ev.touches[0].clientY : ev.clientY;
|
||||||
|
const delta = (startRef.current.y - cy) / 120;
|
||||||
|
const next = Math.max(min, Math.min(max, startRef.current.val + delta * (max - min)));
|
||||||
|
onChange?.(Math.round(next));
|
||||||
|
};
|
||||||
|
const up = () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up);
|
||||||
|
window.removeEventListener("touchmove", move); window.removeEventListener("touchend", up); };
|
||||||
|
window.addEventListener("mousemove", move); window.addEventListener("mouseup", up);
|
||||||
|
window.addEventListener("touchmove", move, { passive: false });
|
||||||
|
window.addEventListener("touchend", up);
|
||||||
|
};
|
||||||
|
|
||||||
|
const svgSize = size + 12;
|
||||||
|
const cx = svgSize / 2, cy = svgSize / 2, r = size / 2 + 3;
|
||||||
|
const lx = cx + r * Math.cos((-140 - 90) * (Math.PI / 180));
|
||||||
|
const ly = cy + r * Math.sin((-140 - 90) * (Math.PI / 180));
|
||||||
|
const ex = cx + r * Math.cos((angle - 90) * (Math.PI / 180));
|
||||||
|
const ey = cy + r * Math.sin((angle - 90) * (Math.PI / 180));
|
||||||
|
const large = norm * 280 > 180 ? 1 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="knob-wrap" style={{ width: size + 12 }}>
|
||||||
|
<div className="knob" style={{ width: size, height: size }} onMouseDown={handleStart} onTouchStart={handleStart}>
|
||||||
|
<svg style={{ position: "absolute", inset: -6, width: svgSize, height: svgSize, overflow: "visible" }}>
|
||||||
|
<circle cx={cx} cy={cy} r={r} fill="none" stroke={T.border} strokeWidth="2.5" 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.5" strokeLinecap="round"
|
||||||
|
style={{ filter: `drop-shadow(0 0 3px ${color})` }} />}
|
||||||
|
</svg>
|
||||||
|
<div className="knob-inner" style={{ "--amber": color }}>
|
||||||
|
<div className="knob-dot" style={{
|
||||||
|
height: dotH, top: dotTop, bottom: "auto",
|
||||||
|
transform: `translateX(-50%) rotate(${angle}deg)`,
|
||||||
|
transformOrigin: `50% ${dotH}px`,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="knob-value mono" style={{ color }}>{value}</div>
|
||||||
|
<div className="knob-label">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Param Slider (big touch-friendly) ─────────────────────────
|
||||||
|
function ParamSlider({ label, value = 50, onChange, onChangeEnd, min = 0, max = 100, color = T.amber }) {
|
||||||
|
const trackRef = useRef(null);
|
||||||
|
const dragRef = useRef(false);
|
||||||
|
const lastValRef = useRef(value);
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
const onChangeEndRef = useRef(onChangeEnd);
|
||||||
|
const pct = ((value - min) / (max - min)) * 100;
|
||||||
|
|
||||||
|
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
|
||||||
|
useEffect(() => { onChangeEndRef.current = onChangeEnd; }, [onChangeEnd]);
|
||||||
|
|
||||||
|
const valFromEvent = useCallback((e) => {
|
||||||
|
const t = trackRef.current;
|
||||||
|
if (!t) return value;
|
||||||
|
const r = t.getBoundingClientRect();
|
||||||
|
const x = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
|
||||||
|
return Math.round(min + Math.max(0, Math.min(1, x / r.width)) * (max - min));
|
||||||
|
}, [min, max, value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const move = (e) => {
|
||||||
|
if (!dragRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const v = valFromEvent(e);
|
||||||
|
lastValRef.current = v;
|
||||||
|
onChangeRef.current?.(v);
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
if (dragRef.current) {
|
||||||
|
onChangeEndRef.current?.(lastValRef.current);
|
||||||
|
dragRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', move);
|
||||||
|
window.addEventListener('mouseup', up);
|
||||||
|
window.addEventListener('touchmove', move, { passive: false });
|
||||||
|
window.addEventListener('touchend', up);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', move);
|
||||||
|
window.removeEventListener('mouseup', up);
|
||||||
|
window.removeEventListener('touchmove', move);
|
||||||
|
window.removeEventListener('touchend', up);
|
||||||
|
};
|
||||||
|
}, [valFromEvent]);
|
||||||
|
|
||||||
|
const handleStart = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const v = valFromEvent(e);
|
||||||
|
lastValRef.current = v;
|
||||||
|
dragRef.current = true;
|
||||||
|
onChangeRef.current?.(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
const quick = (delta) => (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const next = Math.max(min, Math.min(max, value + delta));
|
||||||
|
onChangeRef.current?.(next);
|
||||||
|
onChangeEndRef.current?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="param-row">
|
||||||
|
<div className="param-header">
|
||||||
|
<span className="param-label" style={{ color }}>{label}</span>
|
||||||
|
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
|
||||||
|
<button className="param-btn" onMouseDown={quick(-5)} onTouchStart={quick(-5)}>−</button>
|
||||||
|
<span className="param-val" style={{ color }}>{value}</span>
|
||||||
|
<button className="param-btn" onMouseDown={quick(5)} onTouchStart={quick(5)}>+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="param-track-wrap" ref={trackRef}
|
||||||
|
onMouseDown={handleStart} onTouchStart={handleStart}>
|
||||||
|
<div className="param-track">
|
||||||
|
<div className="param-fill" style={{ width: `${pct}%`, background: color }} />
|
||||||
|
<div className="param-thumb" style={{ left: `${pct}%`, background: color }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parameter Panel ──────────────────────────────────────────
|
||||||
|
function ParameterPanel({ block, params, onParamChange, onParamChangeEnd, viewMode, onToggleView }) {
|
||||||
|
const defs = getBlockParams(block.type);
|
||||||
|
const color = getBlockColor(block.type);
|
||||||
|
const blockName = block.name || (block.type || '').toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="param-panel" style={{ maxHeight: '50%', flexShrink: 0 }}>
|
||||||
|
<div className="param-panel-header">
|
||||||
|
<span className="param-panel-title" style={{ color: T.textSec }}>
|
||||||
|
<span style={{ color }}>●</span> {blockName}
|
||||||
|
</span>
|
||||||
|
<button className="btn-icon" style={{ width: 28, height: 28, fontSize: 12, flexShrink: 0 }}
|
||||||
|
onClick={onToggleView} title={viewMode === 'slider' ? 'Switch to knobs' : 'Switch to sliders'}>
|
||||||
|
{viewMode === 'slider' ? '◉' : '▦'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{viewMode === 'slider' ? (
|
||||||
|
defs.map(p => (
|
||||||
|
<ParamSlider key={p.key}
|
||||||
|
label={p.label}
|
||||||
|
value={params[p.key] != null ? params[p.key] : p.def}
|
||||||
|
min={p.min} max={p.max}
|
||||||
|
color={color}
|
||||||
|
onChange={v => onParamChange?.(p.key, v)}
|
||||||
|
onChangeEnd={v => onParamChangeEnd?.(p.key, v)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="param-knobs">
|
||||||
|
{defs.map(p => (
|
||||||
|
<Knob key={p.key}
|
||||||
|
label={p.label}
|
||||||
|
value={params[p.key] != null ? params[p.key] : p.def}
|
||||||
|
min={p.min} max={p.max}
|
||||||
|
size={60}
|
||||||
|
color={color}
|
||||||
|
onChange={v => {
|
||||||
|
onParamChange?.(p.key, v);
|
||||||
|
onParamChangeEnd?.(p.key, v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── VU Meter ──────────────────────────────────────────────────
|
||||||
|
function VUMeter({ level = 0, height = 28, vertical = false }) {
|
||||||
|
const segs = 12;
|
||||||
|
const active = Math.round((level / 100) * segs);
|
||||||
|
const segColor = (i) => {
|
||||||
|
if (i >= 10) return T.red;
|
||||||
|
if (i >= 8) return "#E8C030";
|
||||||
|
return T.green;
|
||||||
|
};
|
||||||
|
if (vertical) return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column-reverse", gap: 1, height, alignItems: "center" }}>
|
||||||
|
{Array.from({ length: segs }).map((_, i) => (
|
||||||
|
<div key={i} style={{ width: 4, height: (height - segs) / segs,
|
||||||
|
borderRadius: 1, background: i < active ? segColor(i) : T.border,
|
||||||
|
boxShadow: i < active ? `0 0 4px ${segColor(i)}` : "none",
|
||||||
|
transition: "background .05s, box-shadow .05s" }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", gap: 2, height: 6, alignItems: "center", width: height }}>
|
||||||
|
{Array.from({ length: segs }).map((_, i) => (
|
||||||
|
<div key={i} style={{ flex: 1, height: "100%", borderRadius: 1,
|
||||||
|
background: i < active ? segColor(i) : T.border,
|
||||||
|
transition: "background .05s" }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scribble Info Bar ──────────────────────────────────────
|
||||||
|
function ScribbleBar({ state }) {
|
||||||
|
const vol = state.master_volume != null ? Math.round(state.master_volume * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: "flex", gap: 4, padding: "4px 10px",
|
||||||
|
background: T.surface, borderBottom: `1px solid ${T.border}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{[
|
||||||
|
{ label: "Preset", value: state.current_preset?.name || "—" },
|
||||||
|
{ label: "Routing", value: (state.routing_mode || "MONO").toUpperCase() },
|
||||||
|
{ label: "Volume", value: `${vol}%` },
|
||||||
|
{ label: "CPU", value: state.cpu_percent != null ? `${state.cpu_percent}%` : "—" },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.label} style={{ flex: 1, textAlign: "center", padding: "3px 6px",
|
||||||
|
borderRadius: 4, background: T.panel, border: `1px solid ${T.border}` }}>
|
||||||
|
<div style={{ fontSize: 8, color: T.textDim, letterSpacing: ".06em", textTransform: "uppercase" }}>{s.label}</div>
|
||||||
|
<div className="mono" style={{ fontSize: 11, fontWeight: 600, color: T.amber, marginTop: 1 }}>{s.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Captures Screen (Model/IR downloads) ──────────────────────
|
||||||
|
function CapturesScreen({ onClose }) {
|
||||||
|
const [tab, setTab] = useState("models");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState(null);
|
||||||
|
const [models, setModels] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
API.listModels().then(d => setModels(d.models || [])).catch(()=>{});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const doSearch = async () => {
|
||||||
|
if (!query.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = tab === "models"
|
||||||
|
? await API.searchModels(query)
|
||||||
|
: await API.searchIrs(query);
|
||||||
|
setResults(data.results || []);
|
||||||
|
} catch { setResults([]); }
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const install = async (item) => {
|
||||||
|
try {
|
||||||
|
if (tab === "models") {
|
||||||
|
await API.installModel(item.download_url, item.name);
|
||||||
|
} else {
|
||||||
|
await API.installIr(item.download_url, item.name);
|
||||||
|
}
|
||||||
|
API.listModels().then(d => setModels(d.models || [])).catch(()=>{});
|
||||||
|
} catch(e) { alert("Install failed: " + e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: T.bg }}>
|
||||||
|
<div className="screen-header">
|
||||||
|
<div className="screen-title">Downloads</div>
|
||||||
|
<button className="btn-icon" style={{ width: 30, height: 30, fontSize: 14 }}
|
||||||
|
onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "8px 12px", borderBottom: `1px solid ${T.border}` }}>
|
||||||
|
<div className="toggle">
|
||||||
|
<button className={`toggle-btn ${tab === "models" ? "active" : ""}`} onClick={() => setTab("models")}>NAM Models</button>
|
||||||
|
<button className={`toggle-btn ${tab === "irs" ? "active" : ""}`} onClick={() => setTab("irs")}>IR Cabs</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "6px 12px", display: "flex", gap: 6, borderBottom: `1px solid ${T.border}` }}>
|
||||||
|
<div style={{ flex: 1, display: "flex", background: T.surface, borderRadius: 7, border: `1px solid ${T.border}`,
|
||||||
|
padding: "7px 10px", gap: 8, alignItems: "center" }}>
|
||||||
|
<span style={{ color: T.textDim, fontSize: 12 }}>🔍</span>
|
||||||
|
<input placeholder={`Search ${tab === "models" ? "NAM models" : "IR cabs"}...`}
|
||||||
|
value={query} onChange={e => setQuery(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === "Enter" && doSearch()}
|
||||||
|
style={{ flex: 1, background: "none", border: "none", color: T.textPrimary, fontSize: 12, outline: "none" }} />
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" style={{ padding: "7px 12px", fontSize: 11 }} onClick={doSearch}>
|
||||||
|
{loading ? "..." : "Search"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="screen-body">
|
||||||
|
{results === null && models.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="section-label">Installed Models</div>
|
||||||
|
{models.map((m, i) => (
|
||||||
|
<div key={i} className="preset-row" style={{ cursor: "default" }}>
|
||||||
|
<div style={{ width: 28, height: 28, borderRadius: 6, display: "flex",
|
||||||
|
alignItems: "center", justifyContent: "center", fontSize: 12,
|
||||||
|
background: m.loaded ? `${T.green}22` : T.surface,
|
||||||
|
color: m.loaded ? T.green : T.textDim }}>🎛</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 500 }}>{m.name}</div>
|
||||||
|
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>
|
||||||
|
{m.architecture} · {m.size_mb}MB {m.loaded ? "· CURRENT" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{results !== null && results.length === 0 && (
|
||||||
|
<div className="card-sm" style={{ textAlign: "center", color: T.textDim, padding: 24 }}>No results found</div>
|
||||||
|
)}
|
||||||
|
{results !== null && results.map((r, i) => (
|
||||||
|
<div key={i} className="preset-row" style={{ cursor: "default" }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 500 }}>{r.name}</div>
|
||||||
|
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>
|
||||||
|
{r.author} · {r.size_display}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-ghost" style={{ padding: "5px 10px", fontSize: 11 }}
|
||||||
|
onClick={() => install(r)}>Get</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Presets Screen ─────────────────────────────────────────
|
||||||
|
function PresetsScreen({ state, onClose, onLoadPreset }) {
|
||||||
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
|
const samplePresets = [
|
||||||
|
{ num: 1, name: "Plexi Crunch", tags: ["Crunch", "Vintage"], color: T.amber },
|
||||||
|
{ num: 2, name: "Modern High Gain", tags: ["High Gain", "Lead"], color: T.red },
|
||||||
|
{ num: 3, name: "Clean Chimey", tags: ["Clean", "Sparkle"], color: T.blue },
|
||||||
|
{ num: 4, name: "Southern Blues", tags: ["Crunch", "Blues"], color: T.green },
|
||||||
|
{ num: 5, name: "Metal Djent", tags: ["High Gain", "Djent"], color: T.red },
|
||||||
|
{ num: 6, name: "Studio Direct", tags: ["Clean", "Studio"], color: T.blue },
|
||||||
|
{ num: 7, name: "Acoustic Sim", tags: ["Acoustic", "Clean"], color: "#A060E0" },
|
||||||
|
{ num: 8, name: "Nashville Twang", tags: ["Country", "Clean"], color: T.green },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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: 30, height: 30, fontSize: 14 }}
|
||||||
|
onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="screen-body">
|
||||||
|
<div className="toggle" style={{ marginBottom: 4 }}>
|
||||||
|
{["All", "Live", "Studio", "Practice"].map(t => (
|
||||||
|
<button key={t} className={`toggle-btn ${t === "All" ? "active" : ""}`}>{t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{samplePresets.map((p, i) => (
|
||||||
|
<div key={i} className={`preset-row ${activeIdx === i ? "active" : ""}`}
|
||||||
|
onClick={() => { setActiveIdx(i); onLoadPreset?.(p.num); }}>
|
||||||
|
<div className="mono" style={{ width: 28, height: 28, borderRadius: 6, display: "flex",
|
||||||
|
alignItems: "center", justifyContent: "center", fontSize: 12, fontWeight: 700,
|
||||||
|
background: activeIdx === i ? `${p.color}22` : T.surface,
|
||||||
|
color: activeIdx === i ? p.color : T.textDim,
|
||||||
|
border: `1px solid ${activeIdx === i ? p.color + "60" : T.border}` }}>{p.num}</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 3 }}>{p.name}</div>
|
||||||
|
<div style={{ display: "flex", gap: 4 }}>
|
||||||
|
{p.tags.map(t => (
|
||||||
|
<span key={t} className="badge" style={{ background: `${p.color}18`, color: p.color, fontSize: 9 }}>{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{activeIdx === i && <span style={{ color: T.amber, fontSize: 18 }}>●</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App Shell — Helix Stadium Layout ──────────────────────────
|
||||||
|
const MOCK_BLOCKS = [
|
||||||
|
{ id: "gate", type: "gate", name: "Noise Gate", bypassed: false, params: { threshold: 40, release: 25, depth: 70, mix: 100 }},
|
||||||
|
{ id: "drive", type: "overdrive", name: "OD-808", bypassed: false, params: { drive: 65, tone: 55, level: 70 }},
|
||||||
|
{ id: "dist", type: "distortion", name: "RAT", bypassed: true, params: { drive: 75, filter: 50, level: 65 }},
|
||||||
|
{ id: "mod", type: "phaser", name: "Phase 90", bypassed: false, params: { rate: 35, depth: 60, feedback: 40, mix: 50 }},
|
||||||
|
{ id: "delay", type: "delay", name: "Digital Delay", bypassed: false, params: { time: 45, feedback: 25, mix: 40 }},
|
||||||
|
{ id: "reverb", type: "reverb", name: "Hall Reverb", bypassed: false, params: { decay: 60, mix: 35, tone: 50 }},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_PRESETS = [
|
||||||
|
{ num: 1, name: "Plexi Crunch" },
|
||||||
|
{ num: 2, name: "Modern Hi-Gain" },
|
||||||
|
{ num: 3, name: "Clean Chime" },
|
||||||
|
{ num: 4, name: "Blues Drive" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
// ── State ──
|
||||||
|
const [view, setView] = useState("main"); // "main" | "captures" | "presets"
|
||||||
|
const [state, setState] = useState({ connected: false, master_volume: 0.72, routing_mode: "mono", cpu_percent: 0 });
|
||||||
|
const [blocks, setBlocks] = useState(MOCK_BLOCKS);
|
||||||
|
const [selectedBlockId, setSelectedBlockId] = useState("gate");
|
||||||
|
const [blockParams, setBlockParams] = useState({});
|
||||||
|
const [paramView, setParamView] = useState("slider");
|
||||||
|
const [vuLevel, setVuLevel] = useState(0);
|
||||||
|
const [currentPreset, setCurrentPreset] = useState(1);
|
||||||
|
const [currentBank, setCurrentBank] = useState(1);
|
||||||
|
|
||||||
|
// Poll pedal state
|
||||||
|
useEffect(() => {
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const s = await API.getState();
|
||||||
|
setState(s);
|
||||||
|
if (s.cpu_percent != null) setState(prev => ({ ...prev, cpu_percent: s.cpu_percent }));
|
||||||
|
if (s.input_level != null) setVuLevel(s.input_level);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
const id = setInterval(poll, 3000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Mock VU animation when no real data
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setVuLevel(prev => Math.max(0, Math.min(100, prev + (Math.random() - 0.45) * 8)));
|
||||||
|
}, 150);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Selected block
|
||||||
|
const selectedBlock = useMemo(() => blocks.find(b => b.id === selectedBlockId), [blocks, selectedBlockId]);
|
||||||
|
|
||||||
|
// Initialize params when block is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedBlock && blockParams[selectedBlockId] == null) {
|
||||||
|
const defs = getBlockParams(selectedBlock.type);
|
||||||
|
const init = {};
|
||||||
|
defs.forEach(p => {
|
||||||
|
init[p.key] = (selectedBlock.params?.[p.key] != null) ? selectedBlock.params[p.key] : p.def;
|
||||||
|
});
|
||||||
|
setBlockParams(prev => ({ ...prev, [selectedBlockId]: init }));
|
||||||
|
}
|
||||||
|
}, [selectedBlockId, selectedBlock, blockParams]);
|
||||||
|
|
||||||
|
const handleSelectBlock = useCallback((id) => {
|
||||||
|
setSelectedBlockId(prev => prev === id ? null : id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleBlock = useCallback((id) => {
|
||||||
|
setBlocks(prev => prev.map(b => b.id === id ? { ...b, bypassed: !b.bypassed } : b));
|
||||||
|
API.toggleBlock(id, false).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleParamChange = useCallback((id, key, value) => {
|
||||||
|
setBlockParams(prev => ({ ...prev, [id]: { ...(prev[id] || {}), [key]: value } }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleParamChangeEnd = useCallback((id, key, value) => {
|
||||||
|
API.updateBlock(id, { [key]: value }).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReorder = useCallback((fromIdx, toIdx) => {
|
||||||
|
setBlocks(prev => {
|
||||||
|
const arr = [...prev];
|
||||||
|
const [moved] = arr.splice(fromIdx, 1);
|
||||||
|
arr.splice(toIdx, 0, moved);
|
||||||
|
return arr;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddBlock = useCallback(() => {
|
||||||
|
const id = "block_" + Date.now();
|
||||||
|
setBlocks(prev => [...prev, { id, type: "overdrive", name: "New Drive", bypassed: false, params: { drive: 30, tone: 50, level: 70 } }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemoveBlock = useCallback((id) => {
|
||||||
|
setBlocks(prev => prev.filter(b => b.id !== id));
|
||||||
|
if (selectedBlockId === id) setSelectedBlockId(null);
|
||||||
|
}, [selectedBlockId]);
|
||||||
|
|
||||||
|
const handleBankUp = useCallback(() => {
|
||||||
|
setCurrentBank(prev => Math.min(prev + 1, 8));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBankDown = useCallback(() => {
|
||||||
|
setCurrentBank(prev => Math.max(prev - 1, 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Convert bank number to letter (1=A, 2=B, etc.)
|
||||||
|
const bankLetter = String.fromCharCode(64 + currentBank);
|
||||||
|
|
||||||
|
// ── Render ──
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{css}</style>
|
||||||
|
<div style={{
|
||||||
|
width: "100%", maxWidth: 480, margin: "0 auto", height: "100vh",
|
||||||
|
display: "flex", flexDirection: "column", background: T.bg, overflow: "hidden",
|
||||||
|
fontFamily: "'Inter', sans-serif",
|
||||||
|
}}>
|
||||||
|
{view === "main" && (
|
||||||
|
<>
|
||||||
|
{/* ── Status Bar ── */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||||
|
padding: "6px 12px 4px", background: T.panel, borderBottom: `1px solid ${T.border}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<div style={{ width: 6, height: 6, borderRadius: "50%",
|
||||||
|
background: state.connected ? T.green : T.red,
|
||||||
|
boxShadow: `0 0 6px ${state.connected ? T.green : T.red}` }} />
|
||||||
|
<span className="mono" style={{ fontSize: 9, color: T.textSec }}>
|
||||||
|
{state.connected ? "CONNECTED" : "OFFLINE"}
|
||||||
|
</span>
|
||||||
|
<span className="mono" style={{ fontSize: 9, color: T.textDim }}>
|
||||||
|
{state.current_preset?.name || "Plexi Crunch"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
||||||
|
<VUMeter level={vuLevel} height={28} />
|
||||||
|
<button className="btn-icon" style={{ width: 26, height: 26, fontSize: 11 }}
|
||||||
|
onClick={() => setView("captures")} title="Downloads">📁</button>
|
||||||
|
<button className="btn-icon" style={{ width: 26, height: 26, fontSize: 11 }}
|
||||||
|
onClick={() => setView("presets")} title="Presets">⭐</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Scribble Info Bar ── */}
|
||||||
|
<ScribbleBar state={state} />
|
||||||
|
|
||||||
|
{/* ── Preset Strip ── */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", gap: 4, padding: "5px 10px",
|
||||||
|
flexShrink: 0, borderBottom: `1px solid ${T.border}`,
|
||||||
|
}}>
|
||||||
|
{MOCK_PRESETS.map(p => (
|
||||||
|
<div key={p.num}
|
||||||
|
className={`preset-chip ${p.num === currentPreset ? "active" : ""}`}
|
||||||
|
onClick={() => setCurrentPreset(p.num)}>
|
||||||
|
<div className="pc-name">{p.name}</div>
|
||||||
|
<div className="pc-num">0{p.num}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Block Chain + Params ── */}
|
||||||
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||||
|
<div style={{ flex: selectedBlock ? "0 0 auto" : 1, overflow: "hidden" }}>
|
||||||
|
<BlockChain
|
||||||
|
blocks={blocks}
|
||||||
|
selectedBlockId={selectedBlockId}
|
||||||
|
onSelectBlock={handleSelectBlock}
|
||||||
|
onToggleBlock={handleToggleBlock}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
onAddBlock={handleAddBlock}
|
||||||
|
onRemoveBlock={handleRemoveBlock}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Parameter Panel ── */}
|
||||||
|
{selectedBlock && blockParams[selectedBlockId] && (
|
||||||
|
<ParameterPanel
|
||||||
|
block={selectedBlock}
|
||||||
|
params={blockParams[selectedBlockId] || {}}
|
||||||
|
viewMode={paramView}
|
||||||
|
onToggleView={() => setParamView(p => p === 'slider' ? 'knob' : 'slider')}
|
||||||
|
onParamChange={(key, value) => handleParamChange(selectedBlockId, key, value)}
|
||||||
|
onParamChangeEnd={(key, value) => handleParamChangeEnd(selectedBlockId, key, value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!selectedBlock && (
|
||||||
|
<div style={{
|
||||||
|
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
background: T.bg, color: T.textDim, fontSize: 12,
|
||||||
|
}}>
|
||||||
|
Tap a block to edit its parameters
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Footswitch Bar ── */}
|
||||||
|
<FootswitchBar
|
||||||
|
blocks={blocks}
|
||||||
|
selectedBlockId={selectedBlockId}
|
||||||
|
currentBank="A"
|
||||||
|
onSelectBlock={handleSelectBlock}
|
||||||
|
onToggleBlock={handleToggleBlock}
|
||||||
|
onBankUp={() => {}}
|
||||||
|
onBankDown={() => {}}
|
||||||
|
onGlobalBypass={() => setState(prev => ({ ...prev, bypass: !prev.bypass }))}
|
||||||
|
globalBypass={state.bypass}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Overlay screens ── */}
|
||||||
|
{view === "captures" && (
|
||||||
|
<CapturesScreen onClose={() => setView("main")} />
|
||||||
|
)}
|
||||||
|
{view === "presets" && (
|
||||||
|
<PresetsScreen
|
||||||
|
state={state}
|
||||||
|
onClose={() => setView("main")}
|
||||||
|
onLoadPreset={(num) => setCurrentPreset(num)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
// ── Design tokens (mirrored from App.jsx) ────────────────────
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Type-to-color mapping for accent strips ─────────────────
|
||||||
|
const TYPE_COLORS = {
|
||||||
|
od: T.amber,
|
||||||
|
overdrive: T.amber,
|
||||||
|
drive: T.amber,
|
||||||
|
dist: T.red,
|
||||||
|
distortion: T.red,
|
||||||
|
fuzz: "#D060E0",
|
||||||
|
mod: T.blue,
|
||||||
|
chorus: T.blue,
|
||||||
|
flange: T.blue,
|
||||||
|
phaser: T.blue,
|
||||||
|
tremolo: T.blue,
|
||||||
|
vibrato: T.blue,
|
||||||
|
rotary: T.blue,
|
||||||
|
delay: "#60A0E0",
|
||||||
|
echo: "#60A0E0",
|
||||||
|
reverb: "#60C0D0",
|
||||||
|
pitch: "#A060E0",
|
||||||
|
comp: T.green,
|
||||||
|
compressor: T.green,
|
||||||
|
gate: T.green,
|
||||||
|
filter: "#D0A060",
|
||||||
|
wah: "#D0A060",
|
||||||
|
eq: "#80A0B0",
|
||||||
|
ir: T.green,
|
||||||
|
cab: T.green,
|
||||||
|
nam: "#E0A040",
|
||||||
|
volume: T.textSec,
|
||||||
|
boost: T.amber,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBlockColor(type) {
|
||||||
|
const key = (type || "").toLowerCase().trim();
|
||||||
|
for (const [k, v] of Object.entries(TYPE_COLORS)) {
|
||||||
|
if (key === k || key.startsWith(k) || key.includes(k)) return v;
|
||||||
|
}
|
||||||
|
return T.amber;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBlockIcon(type) {
|
||||||
|
const key = (type || "").toLowerCase();
|
||||||
|
if (odMatch(key)) return "\uD83D\uDD25";
|
||||||
|
if (key.includes("dist") || key.includes("gain")) return "\uD83D\uDCA5";
|
||||||
|
if (key.includes("fuzz")) return "\u26A1";
|
||||||
|
if (modMatch(key)) return "\uD83C\uDF00";
|
||||||
|
if (key.includes("delay") || key.includes("echo")) return "\u23F3";
|
||||||
|
if (key.includes("reverb")) return "\uD83C\uDF0A";
|
||||||
|
if (key.includes("pitch") || key.includes("harm") || key.includes("shift")) return "\uD83C\uDFB5";
|
||||||
|
if (key.includes("comp") || key.includes("limit")) return "\uD83D\uDCCA";
|
||||||
|
if (key.includes("gate") || key.includes("noise")) return "\uD83D\uDD07";
|
||||||
|
if (key.includes("filter") || key.includes("wah")) return "\uD83D\uDCC8";
|
||||||
|
if (key.includes("eq")) return "\u2696\uFE0F";
|
||||||
|
if (key.includes("ir") || key.includes("cab")) return "\uD83D\uDD0A";
|
||||||
|
if (key.includes("nam") || key.includes("capture")) return "\uD83C\uDF9B";
|
||||||
|
if (key.includes("volume") || key.includes("level") || key.includes("boost")) return "\uD83D\uDD08";
|
||||||
|
if (key.includes("tuner")) return "\uD83C\uDFB9";
|
||||||
|
return "\u25A3";
|
||||||
|
function odMatch(k) { return k.includes("od") || k.includes("overdrive") || k.includes("drive") || k.includes("boost") && !k.includes("volume"); }
|
||||||
|
function modMatch(k) { return k.includes("mod") || k.includes("chorus") || k.includes("flange") || k.includes("phaser") || k.includes("trem") || k.includes("vibr") || k.includes("rotary"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chain Arrow SVG ─────────────────────────────────────────
|
||||||
|
function ChainArrow() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="24" viewBox="0 0 18 24" fill="none" style={{ flexShrink: 0 }}>
|
||||||
|
<line x1="0" y1="12" x2="12" y2="12" stroke={T.border} strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<polyline points="6,7 14,12 6,17" fill="none" stroke={T.border} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IN / OUT Terminator ─────────────────────────────────────
|
||||||
|
function Terminator({ label, isLeft }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexDirection: isLeft ? "row" : "row-reverse",
|
||||||
|
}}>
|
||||||
|
{!isLeft && <ChainArrow />}
|
||||||
|
<div style={{
|
||||||
|
padding: "7px 12px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: ".12em",
|
||||||
|
background: T.blueDim,
|
||||||
|
color: T.blue,
|
||||||
|
border: `1px solid ${T.blue}60`,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{isLeft && <ChainArrow />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Block Tile ──────────────────────────────────────────────
|
||||||
|
function BlockTile({ block, selected, index, onSelect, onToggle }) {
|
||||||
|
const color = getBlockColor(block.type);
|
||||||
|
const enabled = !block.bypassed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onClick={() => onSelect(block.id)}
|
||||||
|
style={{
|
||||||
|
minWidth: 120,
|
||||||
|
width: 120,
|
||||||
|
height: 76,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: T.panel,
|
||||||
|
border: `2px solid ${selected ? T.amber : T.border}`,
|
||||||
|
opacity: enabled ? 1 : 0.35,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
transition: "all .15s ease",
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: selected
|
||||||
|
? `0 0 16px ${T.amber}44, inset 0 0 0 1px ${T.amber}22`
|
||||||
|
: "none",
|
||||||
|
transform: selected ? "scale(1.05)" : "scale(1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Color accent strip at top (like Helix footswitch LCD bezel) */}
|
||||||
|
<div style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 3,
|
||||||
|
background: color,
|
||||||
|
boxShadow: enabled ? `0 0 6px ${color}` : "none",
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Drag handle */}
|
||||||
|
<div style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 5,
|
||||||
|
left: 5,
|
||||||
|
fontSize: 9,
|
||||||
|
color: T.textDim,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
lineHeight: 1,
|
||||||
|
cursor: "grab",
|
||||||
|
opacity: 0.5,
|
||||||
|
}}>
|
||||||
|
⋮⋮
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BYPASS LED - tap to toggle */}
|
||||||
|
<div
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggle(block.id); }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: enabled ? T.green : T.textDim,
|
||||||
|
boxShadow: enabled ? `0 0 8px ${T.green}` : "none",
|
||||||
|
transition: "all .15s",
|
||||||
|
cursor: "pointer",
|
||||||
|
zIndex: 2,
|
||||||
|
border: `1px solid ${enabled ? `${T.green}88` : T.border}`,
|
||||||
|
}}
|
||||||
|
title={enabled ? "Active — tap to bypass" : "Bypassed — tap to enable"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scribble strip — LCD-like display area */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "14px 10px 6px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}>
|
||||||
|
{/* Block name */}
|
||||||
|
<div style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: T.textPrimary,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
lineHeight: 1.3,
|
||||||
|
paddingRight: 16,
|
||||||
|
}}>
|
||||||
|
{block.name || block.type || "FX"}
|
||||||
|
</div>
|
||||||
|
{/* Type badge row */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
marginTop: 3,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12, lineHeight: 1 }}>{getBlockIcon(block.type)}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: color,
|
||||||
|
letterSpacing: ".08em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
background: `${color}18`,
|
||||||
|
padding: "1px 5px",
|
||||||
|
borderRadius: 3,
|
||||||
|
}}>
|
||||||
|
{(block.type || "fx").replace(/_/g, " ").toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Simple parameter row (placeholder for child task) ────────
|
||||||
|
function ParamRow({ label, value, color }) {
|
||||||
|
const numVal = typeof value === "number" ? value : 50;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: T.textSec,
|
||||||
|
minWidth: 72,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "capitalize",
|
||||||
|
letterSpacing: ".02em",
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: T.border,
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${numVal}%`,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: color,
|
||||||
|
transition: "width .1s ease",
|
||||||
|
boxShadow: `0 0 4px ${color}44`,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: 11,
|
||||||
|
color: T.textPrimary,
|
||||||
|
minWidth: 34,
|
||||||
|
textAlign: "right",
|
||||||
|
}}>
|
||||||
|
{numVal}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Block Chain (exported) ──────────────────────────────────
|
||||||
|
export default function BlockChain({ blocks, selectedBlockId, onSelectBlock, onToggleBlock, onReorder, onAddBlock, onRemoveBlock }) {
|
||||||
|
const scrollRef = useRef(null);
|
||||||
|
const [dragIdx, setDragIdx] = useState(null);
|
||||||
|
const [scrollPos, setScrollPos] = useState(0);
|
||||||
|
|
||||||
|
// ── HTML5 drag-to-reorder ──
|
||||||
|
const handleDragStart = useCallback((e, idx) => {
|
||||||
|
setDragIdx(idx);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
e.dataTransfer.setData("text/plain", String(idx));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e, _idx) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e, dropIdx) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragIdx !== null && dragIdx !== dropIdx && onReorder) {
|
||||||
|
onReorder(dragIdx, dropIdx);
|
||||||
|
}
|
||||||
|
setDragIdx(null);
|
||||||
|
}, [dragIdx, onReorder]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setDragIdx(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Auto-scroll selected block into view ──
|
||||||
|
const scrollToSelected = useCallback(() => {
|
||||||
|
if (!scrollRef.current || !selectedBlockId) return;
|
||||||
|
const idx = blocks.findIndex(b => b.id === selectedBlockId);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const child = scrollRef.current.children[idx + 1]; // +1 for IN terminator
|
||||||
|
if (child) child.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
|
||||||
|
}, [selectedBlockId, blocks]);
|
||||||
|
|
||||||
|
// Scroll on selection change
|
||||||
|
const prevSelected = useRef(selectedBlockId);
|
||||||
|
if (selectedBlockId !== prevSelected.current) {
|
||||||
|
prevSelected.current = selectedBlockId;
|
||||||
|
setTimeout(scrollToSelected, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedBlock = blocks.find(b => b.id === selectedBlockId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||||
|
{/* ── Horizontal scrollable chain ── */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={(e) => setScrollPos(e.target.scrollLeft)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
padding: "10px 12px",
|
||||||
|
overflowX: "auto",
|
||||||
|
overflowY: "hidden",
|
||||||
|
minHeight: 96,
|
||||||
|
background: T.bg,
|
||||||
|
borderBottom: `1px solid ${T.border}`,
|
||||||
|
scrollBehavior: "smooth",
|
||||||
|
WebkitOverflowScrolling: "touch",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* IN */}
|
||||||
|
<Terminator label="IN" isLeft />
|
||||||
|
|
||||||
|
{/* Blocks */}
|
||||||
|
{blocks.map((block, idx) => (
|
||||||
|
<div key={block.id} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
||||||
|
<BlockTile
|
||||||
|
block={block}
|
||||||
|
selected={block.id === selectedBlockId}
|
||||||
|
index={idx}
|
||||||
|
onSelect={onSelectBlock}
|
||||||
|
onToggle={onToggleBlock}
|
||||||
|
/>
|
||||||
|
<ChainArrow />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{blocks.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: "22px 20px",
|
||||||
|
color: T.textDim,
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: "center",
|
||||||
|
border: `1px dashed ${T.border}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
minWidth: 140,
|
||||||
|
}}>
|
||||||
|
No blocks in chain
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OUT */}
|
||||||
|
<Terminator label="OUT" isLeft={false} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Add/Remove bar ── */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderBottom: `1px solid ${T.border}`,
|
||||||
|
background: T.panel,
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={onAddBlock}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "10px",
|
||||||
|
borderRadius: 7,
|
||||||
|
background: T.surface,
|
||||||
|
border: `1px dashed ${T.border}`,
|
||||||
|
color: T.textSec,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
letterSpacing: ".04em",
|
||||||
|
transition: "all .12s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add Block
|
||||||
|
</button>
|
||||||
|
{selectedBlockId && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRemoveBlock(selectedBlockId)}
|
||||||
|
style={{
|
||||||
|
padding: "10px 14px",
|
||||||
|
borderRadius: 7,
|
||||||
|
background: "rgba(200,64,64,.15)",
|
||||||
|
border: `1px solid rgba(200,64,64,.3)`,
|
||||||
|
color: T.red,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all .12s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕ Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
// ── Design tokens ──
|
||||||
|
const T = {
|
||||||
|
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
||||||
|
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060",
|
||||||
|
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_COLORS = {
|
||||||
|
overdrive: T.amber, drive: T.amber, distortion: T.red, fuzz: "#D060E0",
|
||||||
|
chorus: T.blue, flanger: T.blue, phaser: T.blue, tremolo: T.blue,
|
||||||
|
delay: "#60A0E0", echo: "#60A0E0", reverb: "#60C0D0",
|
||||||
|
compressor: T.green, gate: T.green, eq: "#80A0B0",
|
||||||
|
ir: T.green, cab: T.green, nam: "#E0A040",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBlockColor(type) {
|
||||||
|
return TYPE_COLORS[(type || "").toLowerCase()] || T.amber;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single Footswitch with Scribble Strip ──
|
||||||
|
function Footswitch({ id, label, mainLabel, type, sub, active, enabled, onClick, onDoubleClick, isBank, bankDir }) {
|
||||||
|
const color = getBlockColor(type);
|
||||||
|
const tapRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = tapRef.current;
|
||||||
|
if (!el || !onDoubleClick) return;
|
||||||
|
let lastTap = 0;
|
||||||
|
const handler = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTap < 300) {
|
||||||
|
onDoubleClick(id);
|
||||||
|
lastTap = 0;
|
||||||
|
} else {
|
||||||
|
lastTap = now;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
el.addEventListener("click", handler);
|
||||||
|
return () => el.removeEventListener("click", handler);
|
||||||
|
}, [id, onDoubleClick]);
|
||||||
|
|
||||||
|
const isBypassSwitch = type === "bypass";
|
||||||
|
const isBankSwitch = type === "bank";
|
||||||
|
const isActive = active || (isBypassSwitch && !enabled);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={tapRef}
|
||||||
|
onClick={() => onClick?.(id)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
padding: "6px 3px 8px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${isActive ? T.amber : isBankSwitch ? T.blueDim : T.border}`,
|
||||||
|
background: isActive && !isBypassSwitch
|
||||||
|
? `linear-gradient(180deg, ${T.amber + "15"} 0%, ${T.panel} 100%)`
|
||||||
|
: isBypassSwitch && !enabled
|
||||||
|
? `linear-gradient(180deg, ${T.red + "15"} 0%, ${T.panel} 100%)`
|
||||||
|
: T.surface,
|
||||||
|
cursor: "pointer",
|
||||||
|
minHeight: 58,
|
||||||
|
position: "relative",
|
||||||
|
transition: "all .1s",
|
||||||
|
WebkitTapHighlightColor: "transparent",
|
||||||
|
boxShadow: isActive && !isBypassSwitch
|
||||||
|
? `0 0 8px ${T.amber}33, inset 0 0 0 1px ${T.amber}22`
|
||||||
|
: isBypassSwitch && !enabled
|
||||||
|
? `0 0 8px ${T.red}33`
|
||||||
|
: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Scribble strip — LCD-like display ── */}
|
||||||
|
<div style={{
|
||||||
|
width: "100%",
|
||||||
|
background: "#0A0A10",
|
||||||
|
border: `1px solid ${isActive ? T.amber + "66" : T.border}`,
|
||||||
|
borderRadius: 3,
|
||||||
|
padding: "2px 2px",
|
||||||
|
textAlign: "center",
|
||||||
|
minHeight: 18,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
boxShadow: isActive ? `inset 0 0 4px ${T.amber}22` : "none",
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: 7,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: ".02em",
|
||||||
|
color: isActive ? T.amber : (isBypassSwitch && !enabled ? T.red : T.textPrimary),
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
maxWidth: "100%",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main label ── */}
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: isBankSwitch ? 10 : 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: enabled !== false ? T.textPrimary : T.textDim,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
maxWidth: "100%",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
marginTop: 1,
|
||||||
|
}}>
|
||||||
|
{mainLabel || label || type || id || "FX"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Sub label (bypassed state / param value) ── */}
|
||||||
|
{sub && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 7,
|
||||||
|
color: T.textDim,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
maxWidth: "100%",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}>
|
||||||
|
{sub}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Status LED ── */}
|
||||||
|
{!isBankSwitch && (
|
||||||
|
<div style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 3,
|
||||||
|
right: 3,
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: enabled !== false ? T.green : T.textDim,
|
||||||
|
boxShadow: enabled !== false ? `0 0 5px ${T.green}` : "none",
|
||||||
|
transition: "all .15s",
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Bank direction indicator ── */}
|
||||||
|
{isBankSwitch && (
|
||||||
|
<div style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 3,
|
||||||
|
right: 3,
|
||||||
|
fontSize: 6,
|
||||||
|
padding: "1px 3px",
|
||||||
|
borderRadius: 2,
|
||||||
|
background: `${T.blueDim}88`,
|
||||||
|
color: T.blue,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}>
|
||||||
|
{bankDir === "up" ? "▲" : "▼"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footswitch Bar ──
|
||||||
|
export default function FootswitchBar({
|
||||||
|
blocks,
|
||||||
|
selectedBlockId,
|
||||||
|
currentBank = "A",
|
||||||
|
onSelectBlock,
|
||||||
|
onToggleBlock,
|
||||||
|
onBankUp,
|
||||||
|
onBankDown,
|
||||||
|
onGlobalBypass,
|
||||||
|
globalBypass,
|
||||||
|
}) {
|
||||||
|
// Build items: [bank_up, up_to_4_blocks, BYPASS, bank_down]
|
||||||
|
// Always reserve slots for BYPASS and bank controls by capping block count
|
||||||
|
const MAX_BLOCK_SWITCHES = 4;
|
||||||
|
|
||||||
|
const blockSwitches = (blocks || []).slice(0, MAX_BLOCK_SWITCHES).map(b => ({
|
||||||
|
id: b.id,
|
||||||
|
label: (b.name || b.type || "FX").toUpperCase(),
|
||||||
|
mainLabel: b.name || b.type || "FX",
|
||||||
|
type: b.type,
|
||||||
|
sub: b.bypassed ? "BYPASSED" : "",
|
||||||
|
enabled: !b.bypassed,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
id: "bank_up",
|
||||||
|
type: "bank",
|
||||||
|
label: "BANK ▲",
|
||||||
|
mainLabel: `${currentBank} · 01`,
|
||||||
|
bankDir: "up",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
...blockSwitches,
|
||||||
|
{
|
||||||
|
id: "bypass",
|
||||||
|
type: "bypass",
|
||||||
|
label: globalBypass ? "MUTED" : "BYPASS",
|
||||||
|
mainLabel: "GLOBAL",
|
||||||
|
sub: globalBypass ? "" : "tap to mute",
|
||||||
|
enabled: !globalBypass,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bank_down",
|
||||||
|
type: "bank",
|
||||||
|
label: "BANK ▼",
|
||||||
|
mainLabel: `${currentBank} · 01`,
|
||||||
|
bankDir: "down",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 4,
|
||||||
|
padding: "6px 8px 10px",
|
||||||
|
background: T.bg,
|
||||||
|
borderTop: `1px solid ${T.border}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{items.map(item => (
|
||||||
|
<Footswitch
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
label={item.label}
|
||||||
|
mainLabel={item.mainLabel}
|
||||||
|
type={item.type}
|
||||||
|
sub={item.sub}
|
||||||
|
active={item.id === selectedBlockId || (item.type === "bypass" && globalBypass)}
|
||||||
|
enabled={item.enabled}
|
||||||
|
isBank={item.type === "bank"}
|
||||||
|
bankDir={item.bankDir}
|
||||||
|
onClick={(id) => {
|
||||||
|
if (id === "bank_up") onBankUp?.();
|
||||||
|
else if (id === "bank_down") onBankDown?.();
|
||||||
|
else if (id === "bypass") onGlobalBypass?.();
|
||||||
|
else onSelectBlock?.(id);
|
||||||
|
}}
|
||||||
|
onDoubleClick={(id) => {
|
||||||
|
if (id !== "bank_up" && id !== "bank_down" && id !== "bypass") {
|
||||||
|
onToggleBlock?.(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Styles are embedded in App.jsx via CSS-in-JS template literal.
|
||||||
|
* This file intentionally left empty — remove default Vite styles.
|
||||||
|
*/
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: '/ui/',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user