PEDAL-UI: Helix Stadium-style redesign — block chain, big touch sliders, footswitch bar with scribble strips

This commit is contained in:
2026-06-12 18:03:27 -04:00
commit b832ae06a3
16 changed files with 5932 additions and 0 deletions
+811
View File
@@ -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>
+617
View File
@@ -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>
+397
View File
@@ -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.*