# 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 ├── ├── 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
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.*