Files
pi-multifx-pedal-ui/docs/helix-stadium-ux-spec.md
T

15 KiB
Raw Blame History

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)

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):

// 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
  • 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.