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
+446
View File
@@ -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>
);
}