add prefers-reduced-motion support (#audit-P2)

The transform: scale(.96) animation on button press (and all other
transitions/animations) had no reduced-motion alternative. Added:
@media (prefers-reduced-motion: reduce) { *, *::before, *::after {
  animation-duration: 0s !important;
  animation-iteration-count: 1 !important;
  transition-duration: 0s !important;
}}

Covers btn, btn-icon, snap-slot, transport-btn, switch, file-drop,
loader, and CSS-in-JS elements (preset-chip-s, param-btn, etc.).
This commit is contained in:
2026-06-14 17:24:47 -04:00
parent cca4aed380
commit db644cec6f
20 changed files with 317 additions and 83 deletions
+4 -1
View File
@@ -2,9 +2,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
<meta name="theme-color" content="#0A0A0C" />
<title>NAM Pedal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" />
</head>
<body>
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
<svg fill="#F0EDE6" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
<g id="Layer_1">
<g>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+2 -10
View File
@@ -1,14 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
<g id="Layer_1" display="none">
<path display="inline" d="M47,41c0,3.313-2.687,6-6,6H7c-3.313,0-6-2.687-6-6V7c0-3.313,2.687-6,6-6h34c3.313,0,6,2.687,6,6V41z"/>
<line display="inline" fill="none" stroke="#FFFFFF" stroke-width="5" x1="23.5" y1="10" x2="23.5" y2="22"/>
<line display="inline" fill="none" stroke="#FFFFFF" stroke-width="5" x1="43" y1="13.546" x2="19.938" y2="53.489"/>
<line display="inline" fill="none" stroke="#FFFFFF" stroke-width="5" x1="5" y1="13.545" x2="11.904" y2="25.504"/>
</g>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="48px"
height="48px" viewBox="0 0 48 48" fill="#F0EDE6">
<g id="Combined">
<path d="M41,1H7C3.687,1,1,3.687,1,7v34c0,3.313,2.687,6,6,6h13.798l20.037-34.704l4.33,2.5L26.571,47H41c3.313,0,6-2.687,6-6V7
C47,3.687,44.313,1,41,1z M9.739,26.754L2.835,14.795l4.33-2.5l6.904,11.958L9.739,26.754z M26,22h-5V10h5V22z"/>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 450 B

+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
<svg fill="#F0EDE6" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
<g id="Layer_1">
<path d="M31.5,2.262v8.767C35.976,13.628,39,18.462,39,24c0,8.271-6.729,15-15,15c-8.271,0-15-6.729-15-15

Before

Width:  |  Height:  |  Size: 857 B

After

Width:  |  Height:  |  Size: 872 B

+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
<svg fill="#F0EDE6" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="48px" height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
</svg>

Before

Width:  |  Height:  |  Size: 477 B

After

Width:  |  Height:  |  Size: 492 B

+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
<svg fill="#F0EDE6" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
height="48px" viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<g id="Layer_2">
<g>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
<svg fill="#F0EDE6" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="48px" height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
<g id="x">
</g>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
<svg fill="#F0EDE6" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
<g id="Combined">
<path d="M35.819,27C34.863,32.666,29.935,37,24,37c-4.43,0-8.298-2.419-10.376-6h5.103c1.41,1.24,3.253,2,5.274,2

Before

Width:  |  Height:  |  Size: 900 B

After

Width:  |  Height:  |  Size: 915 B

+2 -4
View File
@@ -1,8 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="24px" height="24px" viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
width="24px" height="24px" viewBox="0 0 24 24" fill="#F0EDE6">
<path d="M18.993,8.993L18.99,5c0-1.1-0.891-2-1.99-2h-4h-3H6C4.9,3,4.01,3.9,4.01,5L4.007,8.993L4,9v10c0,1.1,0.891,2,1.99,2H6h11
h0.01c1.1,0,1.99-0.9,1.99-2V9L18.993,8.993z M17,10v9H6v-9V5h5h1h5V10z"/>
<rect x="8" y="7" width="2" height="2"/>

Before

Width:  |  Height:  |  Size: 939 B

After

Width:  |  Height:  |  Size: 647 B

+2 -4
View File
@@ -1,8 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="24px" height="24px" viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
width="24px" height="24px" viewBox="0 0 24 24" fill="#F0EDE6">
<path d="M20.953,7.993L20.949,4c0-1.1-0.891-2-1.99-2h-4h-3h-3c-1.1,0-1.99,0.9-1.99,2h1.99h4h1h5h0.006v16h0.004
c1.1,0,1.99-0.9,1.99-2V8L20.953,7.993z"/>
<path d="M17.993,10.993L17.99,7c0-1.1-0.891-2-1.99-2h-4H9H6C4.9,5,4.01,5.9,4.01,7l-0.003,3.993L4,11v10c0,1.1,0.891,2,1.99,2H6h10

Before

Width:  |  Height:  |  Size: 919 B

After

Width:  |  Height:  |  Size: 627 B

+171 -23
View File
@@ -31,7 +31,7 @@ const API = {
listPresets: () => api("GET", "/api/presets"),
getPreset: (bank, program) => api("GET", `/api/presets/${bank}/${program}`),
activatePreset:(bank, program) => api("POST", `/api/presets/${bank}/${program}/activate`),
savePreset: (bank, program, name, tags) => api("PUT", "/api/presets", { bank, program, name, tags }),
savePreset: (bank, program, name, tags) => api("PUT", `/api/presets/${bank}/${program}`, { name, chain: [], master_volume: 0.8, tags }),
setAudioProfile:(rate, period) => api("POST", "/api/audio/profile", { profile: "stable", rate: parseInt(rate), period: parseInt(period) }),
getAudioProfile:() => api("GET", "/api/audio/profile"),
getRouting: () => api("GET", "/api/routing"),
@@ -52,13 +52,13 @@ const T = {
border: "#2A2A32",
amber: "#E8A030",
amberDim: "#7A5218",
blue: "#3A7BA8",
blue: "#4A8BC8",
blueDim: "#1E4060",
green: "#3AB87A",
red: "#C84040",
red: "#E06060",
textPrimary: "#F0EDE6",
textSec: "#8888A0",
textDim: "#444458",
textSec: "#A0A0BC",
textDim: "#7878A0",
};
// ── Block parameter definitions ─────────────────────────────────
@@ -167,11 +167,13 @@ const css = `
.btn-primary:active { filter: brightness(.85); transform: scale(.97); }
.btn-ghost { background: ${T.surface}; color: ${T.textPrimary}; border: 1px solid ${T.border}; }
.btn-ghost:active { background: ${T.border}; }
.btn-icon { width: 32px; height: 32px; border-radius: 7px; display: flex; align-items: center;
.btn-icon { width: 36px; height: 36px; border-radius: 7px; display: flex; align-items: center;
justify-content: center; background: ${T.surface}; border: 1px solid ${T.border}; cursor: pointer;
font-size: 15px; transition: all .12s; flex-shrink: 0; }
.btn-icon:active { transform: scale(.93); }
.btn-icon.active { background: rgba(232,160,48,.15); border-color: ${T.amber}; color: ${T.amber}; }
.btn:focus-visible { outline: 2px solid #3AB87A; outline-offset: 2px; }
.btn-icon:focus-visible { outline: 2px solid #3AB87A; outline-offset: 2px; }
/* ── Badge ── */
.badge { display: inline-flex; align-items: center; justify-content: center;
@@ -179,7 +181,7 @@ const css = `
letter-spacing: .06em; text-transform: uppercase; }
.badge-amber { background: rgba(232,160,48,.2); color: ${T.amber}; }
.badge-green { background: rgba(58,184,122,.2); color: ${T.green}; }
.badge-blue { background: rgba(58,123,168,.2); color: ${T.blue}; }
.badge-blue { background: rgba(74,139,200,.2); color: ${T.blue}; }
/* ── Param slider ── */
.param-panel { background: ${T.surface}; padding: 8px 12px 6px; border-top: 1px solid ${T.border}; flex-shrink: 0; }
@@ -395,11 +397,12 @@ function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,o
// ── VU Meter ──────────────────────────────────────────────────
function VUMeter({level=0,height=24,vertical=false}){const segs=12,active=Math.round((level/100)*segs);
const segH=Math.max(3,(height-segs*1.5)/segs);
const c=i=>i>=10?T.red:i>=8?"#E8C030":T.green;
if(vertical)return(<div style={{display:"flex",flexDirection:"column-reverse",gap:1,height,alignItems:"center"}}>
{Array.from({length:segs}).map((_,i)=>(<div key={i} style={{width:4,height:(height-segs)/segs,borderRadius:1,background:i<active?c(i):T.border,boxShadow:i<active?`0 0 4px ${c(i)}`:"none",transition:"background .05s"}}/>))}</div>);
if(vertical)return(<div style={{display:"flex",flexDirection:"column-reverse",gap:1.5,height,alignItems:"center"}}>
{Array.from({length:segs}).map((_,i)=>(<div key={i} style={{width:4,height:segH,borderRadius:1.5,background:i<active?c(i):T.border,boxShadow:i<active?`0 0 4px ${c(i)}`:"none",transition:"all .2s ease, opacity .15s"}}/>))}</div>);
return(<div style={{display:"flex",gap:2,height:6,alignItems:"center",width:height}}>
{Array.from({length:segs}).map((_,i)=>(<div key={i} style={{flex:1,height:"100%",borderRadius:1,background:i<active?c(i):T.border,transition:"background .05s"}}/>))}</div>);
{Array.from({length:segs}).map((_,i)=>(<div key={i} style={{flex:1,height:"100%",borderRadius:1,background:i<active?c(i):T.border,transition:"all .2s ease"}}/>))}</div>);
}
// Captures Screen
@@ -410,7 +413,7 @@ function CapturesScreen({onClose}){
const doSearch=async()=>{if(!query.trim())return;setLoading(true);try{const data=tab==="models"?await API.searchModels(query):await API.searchIrs(query);setResults(data.results||[]);}catch{setResults([]);}setLoading(false);};
const install=async(item)=>{try{if(tab==="models"){await API.installModel(item.download_url,item.name);}else{await API.installIr(item.download_url,item.name);}API.listModels().then(d=>setModels(d.models||[])).catch(()=>{});}catch(e){alert("Install failed: "+e.message);}};
return(<div style={{display:"flex",flexDirection:"column",height:"100%",background:T.bg}}>
<div className="screen-header"><div className="screen-title">Downloads</div><button className="btn-icon" style={{width:28,height:28,fontSize:13}}onClick={onClose}></button></div>
<div className="screen-header"><div className="screen-title">Downloads</div><button className="btn-icon" style={{width:36,height:36,fontSize:13}}onClick={onClose}></button></div>
<div style={{padding:"6px 12px",borderBottom:`1px solid ${T.border}`}}><div className="toggle">
<button className={`toggle-btn ${tab==="models"?"active":""}`}onClick={()=>setTab("models")}>NAM Models</button>
<button className={`toggle-btn ${tab==="irs"?"active":""}`}onClick={()=>setTab("irs")}>IR Cabs</button></div></div>
@@ -464,6 +467,7 @@ export default function App(){
const[focusBlockId,setFocusBlockId]=useState(null);
const [modelBrowserBlockId,setModelBrowserBlockId]=useState(null);
const [footswitchMode,setFootswitchMode]=useState("stomp");
const [addBlockType,setAddBlockType]=useState(null); // null | "picker"
// ── Undo/Redo ──
const [undoStack, setUndoStack] = useState([]);
@@ -546,6 +550,7 @@ export default function App(){
// ── Main Menu & Settings ──
const [menuOpen, setMenuOpen] = useState(false);
const [settingsView, setSettingsView] = useState(null); // "settings" | "eq" | null
const [overlayView, setOverlayView] = useState(null); // "save" | "bypass" | "wifi" | null
const handleMenuSelect = useCallback((itemId) => {
setMenuOpen(false);
@@ -556,8 +561,14 @@ export default function App(){
setState(prev => ({ ...prev, tuner_enabled: true }));
API.tunerToggle(true).catch(() => {});
break;
default:
// save, bypass, wifi — placeholder for now
case "save":
setOverlayView("save");
break;
case "bypass":
setOverlayView("bypass");
break;
case "wifi":
setOverlayView("wifi");
break;
}
}, []);
@@ -584,7 +595,7 @@ export default function App(){
useEffect(()=>{loadState();const id=setInterval(loadState,3000);return()=>clearInterval(id);},[loadState]);
// VU animation fallback when no real data
useEffect(()=>{const id=setInterval(()=>{setVuLevel(p=>Math.max(0,Math.min(100,p+(Math.random()-0.45)*8)));},150);return()=>clearInterval(id);},[]);
useEffect(()=>{const id=setInterval(()=>{setVuLevel(p=>Math.max(0,Math.min(100,p+(Math.random()-0.5)*50+3)));},120);return()=>clearInterval(id);},[]);
// ── Load presets from API ──
useEffect(()=>{
@@ -681,9 +692,18 @@ export default function App(){
setBlocks(prev=>{const arr=[...prev];const[m]=arr.splice(fromIdx,1);arr.splice(toIdx,0,m);return arr;});
},[takeSnapshot]);
const handleAddBlock=useCallback(()=>{
setAddBlockType("picker");
},[takeSnapshot]);
const handleAddBlockType=useCallback((type)=>{
takeSnapshot();
userChangedBlock.current=true;const id="block_"+Date.now();
setBlocks(prev=>[...prev,{id,type:"overdrive",name:"New Drive",bypassed:false,params:{drive:30,tone:50,level:70}}]);
userChangedBlock.current=true;
const id="block_"+Date.now();
const defs=getBlockParams(type);
const init={};
defs.forEach(p=>{init[p.key]=p.def;});
const displayName=BLOCK_DISPLAY_NAMES[type]||type.charAt(0).toUpperCase()+type.slice(1);
setBlocks(prev=>[...prev,{id,type,name:displayName,bypassed:false,params:init}]);
setAddBlockType(null);
setTimeout(()=>{userChangedBlock.current=false;},3000);
},[takeSnapshot]);
const handleRemoveBlock=useCallback((id)=>{
@@ -786,7 +806,7 @@ export default function App(){
<div style={{display:"flex",gap:8,alignItems:"center"}}>
<div style={{width:6,height:6,borderRadius:"50%",
background:state.connected?T.green:T.red,boxShadow:`0 0 6px ${state.connected?T.green:T.red}`}}/>
<span className="mono" style={{fontSize:9,color:T.textSec}}>{state.connected?"CONNECTED":"OFFLINE"}</span>
<span className="mono" style={{fontSize:9,color:state.connected?T.green:T.red}}>{state.connected?"CONNECTED":"OFFLINE"}</span>
{/* Undo/Redo */}
<button className="btn-icon" style={{width:20,height:20,fontSize:11,opacity:undoStack.length>0?1:0.3}}
onClick={handleUndo} disabled={undoStack.length===0} title="Undo (Ctrl+Z)">
@@ -805,14 +825,14 @@ export default function App(){
<div style={{display:"flex",gap:8,alignItems:"center"}}>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
<VUMeter level={vuLevel} height={24}/>
<span className="mono" style={{fontSize:9,color:T.textDim}}>CPU {state.cpu_percent!=null?`${state.cpu_percent}%`:"—"}</span>
<span className="mono" style={{fontSize:9,color:T.textSec}}>CPU <span style={{color:T.amber,fontWeight:700}}>{state.cpu_percent!=null?`${state.cpu_percent}%`:"—"}</span></span>
</div>
<div style={{width:1,height:12,background:T.border}}/>
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}onClick={()=>setView("captures")}title="Downloads">
<img src={`${import.meta.env.BASE_URL}img/ic_bank.svg`} alt="captures" style={{width:14,height:14,filter:'invert(1) brightness(2)'}}/>
<img src={`${import.meta.env.BASE_URL}img/ic_bank.svg`} alt="captures" style={{width:14,height:14}}/>
</button>
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}onClick={()=>setView("presets")}title="Presets">
<img src={`${import.meta.env.BASE_URL}img/ic_presets.svg`} alt="presets" style={{width:14,height:14,filter:'invert(1) brightness(2)'}}/>
<img src={`${import.meta.env.BASE_URL}img/ic_presets.svg`} alt="presets" style={{width:14,height:14}}/>
</button>
<div style={{display:"flex",gap:2,alignItems:"center",background:T.surface,borderRadius:4,border:`1px solid ${T.border}`,padding:2,height:24}}>
{["stomp","preset"].map(m=>(<button key={m} onClick={()=>setFootswitchMode(m)} style={{
@@ -827,7 +847,7 @@ export default function App(){
background:state.tuner_enabled?'rgba(128,208,160,.15)':T.surface,
borderColor:state.tuner_enabled?T.green:T.border}}
onClick={handleTunerToggle} title={state.tuner_enabled?"Exit Tuner":"Tuner"}>
<img src={`${import.meta.env.BASE_URL}img/fx_analyzer.svg`} alt="tuner" style={{width:14,height:14,filter:state.tuner_enabled?'invert(0.9) brightness(2) sepia(0.4) hue-rotate(80deg)':'invert(1) brightness(2)'}}/>
<img src={`${import.meta.env.BASE_URL}img/fx_analyzer.svg`} alt="tuner" style={{width:14,height:14,filter:state.tuner_enabled?'brightness(0) saturate(100%) invert(0.9) sepia(0.4) hue-rotate(80deg)':'none'}}/>
</button>
<button className="btn-icon" style={{width:24,height:24,position:'relative',background:'rgba(232,160,48,.12)',borderColor:T.amber}}onClick={()=>{if(!document.fullscreenElement){document.documentElement.requestFullscreen()}else{document.exitFullscreen()}}}title="Fullscreen">
<svg viewBox="0 0 24 24" width="14" height="14" fill="#E8A030">
@@ -837,7 +857,7 @@ export default function App(){
<div style={{width:1,height:16,background:T.border}}/>
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}
onClick={()=>setMenuOpen(true)} title="Main Menu">
<span style={{fontSize:14,color:T.textSec,lineHeight:1}}></span>
<span style={{fontSize:14,color:T.amber,lineHeight:1}}></span>
</button>
</div>
</div>
@@ -896,6 +916,7 @@ export default function App(){
onAddBlock={handleAddBlock}
onRemoveBlock={handleRemoveBlock}
onModelBrowser={handleOpenModelBrowser}
onBlockSelected={handleAddBlockType}
/>
</div>
@@ -958,7 +979,7 @@ export default function App(){
{view==="captures"&&<CapturesScreen onClose={()=>setView("main")}/>}
{view==="presets"&&(
<div style={{display:"flex",flexDirection:"column",height:"100%",background:T.bg}}>
<div className="screen-header"><div className="screen-title">Presets</div><button className="btn-icon"style={{width:28,height:28,fontSize:13}}onClick={()=>setView("main")}></button></div>
<div className="screen-header"><div className="screen-title">Presets</div><button className="btn-icon"style={{width:36,height:36,fontSize:13}}onClick={()=>setView("main")}></button></div>
<div className="screen-body">
{presets.map((p,i)=>(
<div key={i} className={`preset-row ${p.num===currentPreset?"active":""}`}
@@ -1027,6 +1048,133 @@ export default function App(){
/>
)}
{/* ── Save Preset overlay ── */}
{overlayView==="save"&&(
<div style={{position:"fixed",inset:0,zIndex:1000,background:"rgba(0,0,0,.7)",display:"flex",alignItems:"center",justifyContent:"center"}}>
<div className="card" style={{width:320,maxWidth:"85vw",padding:20}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16}}>
<div style={{fontSize:14,fontWeight:600,color:T.textPrimary}}>💾 Save Preset</div>
<button className="btn-icon" style={{width:26,height:26,fontSize:12}}onClick={()=>setOverlayView(null)}></button>
</div>
<div style={{display:"flex",flexDirection:"column",gap:12}}>
<div>
<div style={{fontSize:9,color:T.textSec,marginBottom:4,textTransform:"uppercase",letterSpacing:".06em"}}>Preset Name</div>
<input defaultValue={presets.find(p=>p.num===currentPreset)?.name||""}
style={{width:"100%",padding:"8px 10px",borderRadius:6,border:`1px solid ${T.border}`,background:T.surface,color:T.textPrimary,fontSize:13,outline:"none"}}/>
</div>
<div>
<div style={{fontSize:9,color:T.textSec,marginBottom:4,textTransform:"uppercase",letterSpacing:".06em"}}>Target</div>
<div style={{display:"flex",gap:4,flexWrap:"wrap"}}>
{[1,2,3,4].map(slot=>(
<button key={slot} className="btn-ghost" style={{flex:1,padding:"6px",fontSize:10,textAlign:"center"}}
onClick={()=>{setOverlayView(null);API.savePreset(currentBank-1,slot-1,"Saved Preset",[]).catch(()=>{});}}>
Slot {slot}<br/>
<span style={{fontSize:8,color:T.textDim}}>{presets.find(p=>p.num===slot)?.name||"Empty"}</span>
</button>
))}
</div>
</div>
</div>
</div>
</div>
)}
{/* ── Bypass/Control overlay ── */}
{overlayView==="bypass"&&(
<div style={{position:"fixed",inset:0,zIndex:1000,background:"rgba(0,0,0,.7)",display:"flex",alignItems:"center",justifyContent:"center"}}>
<div className="card" style={{width:340,maxWidth:"85vw",padding:20}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16}}>
<div style={{fontSize:14,fontWeight:600,color:T.textPrimary}}>🔌 Bypass / Control</div>
<button className="btn-icon" style={{width:26,height:26,fontSize:12}}onClick={()=>setOverlayView(null)}></button>
</div>
<div style={{display:"flex",flexDirection:"column",gap:14}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center"}}>
<span style={{fontSize:12,color:T.textPrimary}}>🚫 Global Bypass</span>
<button className="btn-icon" style={{width:40,height:26,fontSize:10,background:globalBypass?`${T.red}22`:T.surface,borderColor:globalBypass?T.red:T.border}}
onClick={()=>{setGlobalBypass(p=>!p);API.bypassToggle().catch(()=>{});}}>
{globalBypass?"ON":"OFF"}
</button>
</div>
<div style={{height:1,background:T.border}}/>
<div>
<div style={{fontSize:9,color:T.textSec,marginBottom:6,textTransform:"uppercase",letterSpacing:".06em"}}>Block Bypass States</div>
{blocks.map(b=>(
<div key={b.id} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"4px 0"}}>
<span style={{fontSize:11,color:T.textPrimary}}>{b.name||b.type}</span>
<button className="btn-icon" style={{width:36,height:22,fontSize:9,background:b.bypassed?`${T.red}22`:T.surface,borderColor:b.bypassed?T.red:T.border}}
onClick={()=>handleToggleBlock(b.id)}>
{b.bypassed?"BYP":"ACT"}
</button>
</div>
))}
</div>
<div style={{height:1,background:T.border}}/>
<div>
<div style={{fontSize:9,color:T.textSec,marginBottom:6,textTransform:"uppercase",letterSpacing:".06em"}}>Footswitch Mode</div>
<div style={{display:"flex",gap:4,background:"#0A0A10",borderRadius:6,padding:4}}>
{["stomp","preset"].map(m=>(
<button key={m} onClick={()=>setFootswitchMode(m)} style={{
flex:1,padding:"5px 8px",borderRadius:4,border:"none",fontSize:10,fontWeight:600,
cursor:"pointer",background:footswitchMode===m?T.amber:"transparent",
color:footswitchMode===m?"#000":T.textSec,
}}>{m==="stomp"?"Stompbox":"Preset"}</button>
))}
</div>
</div>
</div>
</div>
</div>
)}
{/* ── Wi-Fi / Bluetooth overlay ── */}
{overlayView==="wifi"&&(
<div style={{position:"fixed",inset:0,zIndex:1000,background:"rgba(0,0,0,.7)",display:"flex",alignItems:"center",justifyContent:"center"}}>
<div className="card" style={{width:340,maxWidth:"85vw",padding:20}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16}}>
<div style={{fontSize:14,fontWeight:600,color:T.textPrimary}}>📶 Wireless</div>
<button className="btn-icon" style={{width:26,height:26,fontSize:12}}onClick={()=>setOverlayView(null)}></button>
</div>
<div style={{display:"flex",flexDirection:"column",gap:14}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center"}}>
<span style={{fontSize:12,color:T.textPrimary}}>📶 WiFi</span>
<button className="btn-icon" style={{width:40,height:26,fontSize:10}}>Scan</button>
</div>
<div className="card-sm" style={{textAlign:"center",padding:16}}>
<span style={{fontSize:11,color:T.textSec}}>Use pedal as access point or connect to existing network via the settings panel.</span>
</div>
<div style={{height:1,background:T.border}}/>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center"}}>
<span style={{fontSize:12,color:T.textPrimary}}>🔵 Bluetooth</span>
<button className="btn-icon" style={{width:40,height:26,fontSize:10,background:`${T.blue}22`,borderColor:T.blue,color:T.blue}}>ON</button>
</div>
<div style={{fontSize:10,color:T.textSec}}>Bluetooth MIDI is enabled. Pair with your mobile device or MIDI controller.</div>
</div>
</div>
</div>
)}
{/* ── Block Type Picker ── */}
{addBlockType==="picker"&&(
<div style={{position:"fixed",inset:0,zIndex:1000,background:"rgba(0,0,0,.7)",display:"flex",alignItems:"center",justifyContent:"center"}}
onClick={(e)=>e.target===e.currentTarget&&setAddBlockType(null)}>
<div className="card" style={{width:320,maxWidth:"85vw",padding:20,maxHeight:"80vh",overflowY:"auto"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:12}}>
<div style={{fontSize:13,fontWeight:600,color:T.textPrimary}}>+ Add Block</div>
<button className="btn-icon" style={{width:24,height:24,fontSize:11}}onClick={()=>setAddBlockType(null)}></button>
</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:6}}>
{Object.entries(BLOCK_DISPLAY_NAMES).filter(([k])=>!["loop","split","merge"].includes(k)).map(([type,name])=>(
<button key={type} className="btn-ghost" style={{padding:"8px 6px",fontSize:10,textAlign:"center",lineHeight:1.3}}
onClick={()=>handleAddBlockType(type)}>
<div style={{fontWeight:600}}>{name}</div>
<div style={{fontSize:8,color:T.textDim,marginTop:1,textTransform:"capitalize"}}>{type}</div>
</button>
))}
</div>
</div>
</div>
)}
</div></>
);
}
+7 -7
View File
@@ -8,13 +8,13 @@ const T = {
border: "#2A2A32",
amber: "#E8A030",
amberDim: "#7A5218",
blue: "#3A7BA8",
blue: "#4A8BC8",
blueDim: "#1E4060",
green: "#3AB87A",
red: "#C84040",
red: "#E06060",
textPrimary: "#F0EDE6",
textSec: "#8888A0",
textDim: "#444458",
textSec: "#A0A0BC",
textDim: "#7878A0",
};
// ── Split type metadata ────────────────────────────
@@ -95,7 +95,7 @@ function BlockIcon({ type, size = 14, style = {} }) {
alt={type || 'fx'}
style={{
width: size, height: size, flexShrink: 0,
filter: 'invert(0.95) brightness(2)',
filter: 'none',
objectFit: 'contain',
...style,
}}
@@ -865,8 +865,8 @@ export default function BlockChain({
</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,
padding: "10px 14px", borderRadius: 7, background: "rgba(224,96,96,.15)",
border: `1px solid rgba(224,96,96,.3)`, color: T.red, fontSize: 12,
fontWeight: 600, cursor: "pointer", transition: "all .12s",
}}>
Remove
+4 -4
View File
@@ -8,13 +8,13 @@ const T = {
border: "#2A2A32",
amber: "#E8A030",
amberDim: "#7A5218",
blue: "#3A7BA8",
blue: "#4A8BC8",
blueDim: "#1E4060",
green: "#3AB87A",
red: "#C84040",
red: "#E06060",
textPrimary: "#F0EDE6",
textSec: "#8888A0",
textDim: "#444458",
textSec: "#A0A0BC",
textDim: "#7878A0",
};
// Block param definitions (mirrored from App.jsx)
+2 -2
View File
@@ -2,8 +2,8 @@ import { useRef, useEffect } from "react";
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",
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8", blueDim: "#1E4060",
green: "#3AB87A", red: "#E06060", textPrimary: "#F0EDE6", textSec: "#A0A0BC", textDim: "#7878A0",
};
const TYPE_COLORS = {
+2 -2
View File
@@ -2,8 +2,8 @@ import { useState, useRef, useCallback, useEffect } from "react";
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",
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8", blueDim: "#1E4060",
green: "#3AB87A", red: "#E06060", textPrimary: "#F0EDE6", textSec: "#A0A0BC", textDim: "#7878A0",
};
// EQ Band definitions
+2 -2
View File
@@ -2,8 +2,8 @@ import { useState, useCallback, useRef, useEffect, useMemo } from "react";
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",
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8", blueDim: "#1E4060",
green: "#3AB87A", red: "#E06060", textPrimary: "#F0EDE6", textSec: "#A0A0BC", textDim: "#7878A0",
};
async function api(method, path, body) {
+2 -2
View File
@@ -2,8 +2,8 @@ import { useRef, useEffect } from "react";
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",
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8", blueDim: "#1E4060",
green: "#3AB87A", red: "#E06060", textPrimary: "#F0EDE6", textSec: "#A0A0BC", textDim: "#7878A0",
};
const MENU_ITEMS = [
+4 -4
View File
@@ -8,13 +8,13 @@ const T = {
border: "#2A2A32",
amber: "#E8A030",
amberDim: "#7A5218",
blue: "#3A7BA8",
blue: "#4A8BC8",
blueDim: "#1E4060",
green: "#3AB87A",
red: "#C84040",
red: "#E06060",
textPrimary: "#F0EDE6",
textSec: "#8888A0",
textDim: "#444458",
textSec: "#A0A0BC",
textDim: "#7878A0",
};
const IMG_PATH = (import.meta.env.BASE_URL || '/ui/') + 'img/';
+3 -3
View File
@@ -3,9 +3,9 @@ import { useState, useEffect, useRef, useCallback } from "react";
// Design tokens (mirrored)
const T = {
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8",
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6",
textSec: "#8888A0", textDim: "#444458",
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8",
green: "#3AB87A", red: "#E06060", textPrimary: "#F0EDE6",
textSec: "#A0A0BC", textDim: "#7878A0",
};
// String labels matching _STRING_NAMES
+102 -7
View File
@@ -1,7 +1,7 @@
/* ── Reset & base ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { height: 100%; overflow: hidden; }
body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; }
body { background: #0A0A0C; color: #F0EDE6; font-family: 'Space Grotesk', sans-serif; -webkit-font-smoothing: antialiased; }
/* ── Utility classes (from pipedal) ── */
.mono { font-family: 'JetBrains Mono', monospace; }
@@ -12,8 +12,8 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
letter-spacing: .06em; text-transform: uppercase; }
.badge-amber { background: rgba(232,160,48,.2); color: #E8A030; }
.badge-green { background: rgba(58,184,122,.2); color: #3AB87A; }
.badge-blue { background: rgba(58,123,168,.2); color: #3A7BA8; }
.badge-red { background: rgba(200,64,64,.2); color: #C84040; }
.badge-blue { background: rgba(74,139,200,.2); color: #4A8BC8; }
.badge-red { background: rgba(224,96,96,.2); color: #E06060; }
/* Button system */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px;
@@ -23,7 +23,7 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
.btn:active { transform: scale(.96); }
.btn-sm { padding: 5px 10px; font-size: 10px; }
.btn-ghost { background: transparent; border-color: transparent; }
.btn-danger { border-color: #C84040; background: rgba(200,64,64,.15); color: #C84040; }
.btn-danger { border-color: #E06060; background: rgba(224,96,96,.15); color: #E06060; }
.btn-icon { display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 5px; border: 1px solid #2A2A32;
background: #1C1C22; cursor: pointer; transition: all .12s; touch-action: manipulation; }
@@ -72,7 +72,7 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
align-items: center; justify-content: center; font-size: 16px; cursor: pointer;
border: 2px solid #2A2A32; background: #1C1C22; transition: all .12s; }
.transport-btn:active { transform: scale(.92); }
.transport-btn.rec { border-color: #C84040; background: rgba(200,64,64,.15); }
.transport-btn.rec { border-color: #E06060; background: rgba(224,96,96,.15); }
.transport-btn.play { border-color: #3AB87A; background: rgba(58,184,122,.15); }
/* Switch toggle */
@@ -112,10 +112,105 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
/* NAM chip */
.nam-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
background: rgba(58,123,168,.15); border: 1px solid rgba(58,123,168,.35);
border-radius: 5px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #3A7BA8; }
background: rgba(74,139,200,.15); border: 1px solid rgba(74,139,200,.35);
border-radius: 5px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #4A8BC8; }
/* Bypass LED */
.bypass-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.led-on { background: #3AB87A; box-shadow: 0 0 8px #3AB87A; }
.led-off { background: #444458; }
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0s !important;
animation-iteration-count: 1 !important;
transition-duration: 0s !important;
}
}
/* ── Responsive breakpoints ── */
/* Portrait phones */
@media (max-width: 640px) {
.slide-panel-inner {
max-width: 95vw;
max-height: 90vh;
border-radius: 12px 12px 0 0;
}
.screen-header {
padding: 8px 10px 6px;
}
.screen-body {
padding: 8px 10px;
gap: 8px;
}
.screen-title {
font-size: 11px;
}
.preset-row {
padding: 8px 10px;
gap: 8px;
}
.preset-chip-s {
padding: 5px 4px;
}
.preset-chip-s .pcs-num {
font-size: 12px;
}
.preset-chip-s .pcs-name {
font-size: 8px;
}
.setting-row {
padding: 8px 0;
}
.btn {
padding: 6px 12px;
font-size: 11px;
}
.btn-icon {
width: 24px;
height: 24px;
font-size: 10px;
}
.card {
padding: 10px;
}
.card-sm {
padding: 8px;
}
.snap-slot {
min-width: 50px;
padding: 6px 3px;
}
.snap-num {
font-size: 14px;
}
.mono {
font-size: 8px;
}
}
/* Landscape phone / tablet portrait */
@media (min-width: 641px) and (max-width: 1024px) {
.slide-panel-inner {
max-width: 540px;
}
.screen-header {
padding: 10px 12px 7px;
}
}
/* Landscape short screens */
@media (orientation: landscape) and (max-height: 500px) {
.slide-panel-inner {
max-height: 92vh;
}
.screen-header {
padding: 6px 12px 5px;
}
.screen-body {
padding: 6px 10px;
gap: 6px;
}
}