15 KiB
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#2A2A32bottom 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)
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:
- Extend
/api/stateto includeblocks— 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(). - 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. - WebSocket-first —
/wsalready 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):
// 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+BlockChipcomponents - 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
ParamsPanelcomponent with conditional rendering - Adapt existing
Knobcomponent for bigger stage-friendly size - Add
BigSlidercomponent for linear params - Wire param change →
PATCH /api/block-params - Show master volume / preset info when no block selected
Phase 3: Footswitch Bar
- Build
FootswitchBarcomponent - 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
: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:
# 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:
@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.