diff --git a/docs/pipedal-components-reference.txt b/docs/pipedal-components-reference.txt
new file mode 100644
index 0000000..a2b6fa9
--- /dev/null
+++ b/docs/pipedal-components-reference.txt
@@ -0,0 +1,593 @@
+=== FXScreen (block library) ===
+function FXScreen({ state, connected }) {
+ const [fxList, setFxList] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [showAdd, setShowAdd] = useState(false);
+ const [paramSchemas, setParamSchemas] = useState({});
+
+ // Load block param schemas
+ const loadParams = useCallback(async (fxType) => {
+ if (paramSchemas[fxType]) return paramSchemas[fxType];
+ try {
+ const data = await apiGet(`/api/block-params/${fxType}`);
+ setParamSchemas(prev => ({ ...prev, [fxType]: data.params || [] }));
+ return data.params || [];
+ } catch (e) { return []; }
+ }, [paramSchemas]);
+
+ useEffect(() => {
+ if (!connected || !state?.current_preset) {
+ // Offline: show a simple empty state
+ setFxList([]);
+ setLoading(false);
+ return;
+ }
+
+ // Load active preset's chain from presets API
+ apiGet('/api/presets').then(async data => {
+ const banks = data.banks || [];
+ const cp = state.current_preset;
+ const preset = banks[cp.bank]?.presets?.[cp.program];
+ if (preset?.chain) {
+ const blocks = await Promise.all(preset.chain.map(async (b, i) => {
+ const params = await loadParams(b.fx_type);
+ return {
+ id: i + 1,
+ title: FX_TYPE_LABELS[b.fx_type] || b.fx_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
+ type: b.fx_type,
+ bypassed: b.bypass || !b.enabled,
+ params: params,
+ };
+ }));
+ setFxList(blocks);
+ }
+ setLoading(false);
+ }).catch(() => {
+ setFxList([]);
+ setLoading(false);
+ });
+ }, [connected, state, loadParams]);
+
+ const toggle = (id) => setFxList(prev => prev ? prev.map(f => f.id === id ? { ...f, bypassed: !f.bypassed } : f) : prev);
+
+ const addBlock = async (fxType) => {
+ setShowAdd(false);
+ const params = await loadParams(fxType);
+ setFxList(prev => [...(prev || []), {
+ id: Date.now(),
+ title: FX_TYPE_LABELS[fxType] || fxType,
+ type: fxType,
+ bypassed: false,
+ params: params,
+ }]);
+ };
+
+ if (loading) return
;
+
+ return (
+
+
+
FX Chain {(fxList?.length || 0) > 0 ? `(${fxList.length})` : ''}
+
+
+
+ {/* Signal path */}
+ {fxList && fxList.length > 0 && (
+
+ {["IN", ...fxList.map(f => f.type.toUpperCase()), "OUT"].map((s, i, arr) => (
+
+
{s}
+ {i < arr.length - 1 &&
}
+
+ ))}
+
+ )}
+ {(!fxList || fxList.length === 0) ? (
+
+ No FX blocks in the chain.
+
+
+ ) : (
+ fxList.map(f => (
+
toggle(f.id)} />
+ ))
+ )}
+
+
+ {/* Add Block slide panel */}
+ {showAdd && (
+
setShowAdd(false)}>
+
+
+ {Object.entries(FX_TYPE_LABELS).map(([key, label]) => (
+
addBlock(key)} style={{ borderRadius: 6 }}>
+ {key.split('_')[0]}
+ {label}
+ +
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+// 3. MODELS SCREEN ─────────────────────────────────────────────
+
+
+=== ModelsScreen ===
+function ModelsScreen({ state, connected, refresh }) {
+ const [models, setModels] = useState([]);
+ const [current, setCurrent] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [searching, setSearching] = useState(false);
+ const [searchQ, setSearchQ] = useState('');
+ const [searchResults, setSearchResults] = useState([]);
+ const [showSearch, setShowSearch] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const fileRef = useRef(null);
+
+ const loadModels = useCallback(async () => {
+ if (!connected) { setLoading(false); return; }
+ try {
+ const data = await apiGet('/api/models');
+ setModels(data.models || []);
+ setCurrent(data.current);
+ } catch (e) { /* ignore */ }
+ setLoading(false);
+ }, [connected]);
+
+ useEffect(() => { loadModels(); }, [loadModels]);
+
+ const handleLoad = async (path) => {
+ try { await apiPost('/api/models/load', { path }); await loadModels(); refresh(); } catch (e) { alert('Failed to load model'); }
+ };
+
+ const handleUnload = async () => {
+ try { await apiPost('/api/models/unload'); setCurrent(null); await loadModels(); refresh(); } catch (e) { alert('Failed to unload'); }
+ };
+
+ const handleUpload = async (e) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setUploading(true);
+ try {
+ await apiUpload('/api/models/upload', file);
+ await loadModels();
+ } catch (err) { alert('Upload failed'); }
+ setUploading(false);
+ };
+
+ const handleSearch = useCallback(async () => {
+ if (!searchQ.trim()) return;
+ setSearching(true);
+ try {
+ const data = await apiGet(`/api/models/tonedownload/search?q=${encodeURIComponent(searchQ)}`);
+ setSearchResults(data.results || []);
+ } catch (e) { alert('Search failed'); }
+ setSearching(false);
+ }, [searchQ]);
+
+ const handleInstall = async (result) => {
+ try {
+ await apiPost('/api/models/tonedownload/install', {
+ download_url: result.download_url,
+ name: result.name || result.filename,
+ id: result.id,
+ size: result.size,
+ pack_name: result.pack_name,
+ display_name: result.name,
+ });
+ await loadModels();
+ refresh();
+ } catch (e) { alert('Install failed'); }
+ };
+
+ if (loading) return ;
+
+ return (
+
+
+
NAM Models ({models.length})
+
+
+
+
+
+
+
+ {showSearch && (
+
+
+ 🔍
+ setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} />
+
+
+ {searchResults.length > 0 && (
+
+ {searchResults.map(r => (
+
handleInstall(r)} style={{ borderBottom: `1px solid ${T.border}`, borderRadius: 0 }}>
+
+
{r.name}
+
{r.author} · {r.size_display} · {r.architecture}
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ {current && (
+
+ )}
+
+ {models.length === 0 ? (
+
+ No NAM models found.
Upload a .nam file or search Tone3000.
+
+ ) : (
+ models.map(m => (
+
+
+
{m.name}
+
{m.architecture} · {m.size_mb} MB
+
+ {m.loaded ? (
+
Loaded
+ ) : (
+
+ )}
+
+ ))
+ )}
+
+
+ );
+}
+
+// 4. IRS SCREEN ────────────────────────────────────────────────
+
+
+=== IRsScreen ===
+function IRsScreen({ state, connected, refresh }) {
+ const [irs, setIrs] = useState([]);
+ const [current, setCurrent] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [searching, setSearching] = useState(false);
+ const [searchQ, setSearchQ] = useState('');
+ const [searchResults, setSearchResults] = useState([]);
+ const [showSearch, setShowSearch] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const fileRef = useRef(null);
+
+ const loadIrs = useCallback(async () => {
+ if (!connected) { setLoading(false); return; }
+ try {
+ const data = await apiGet('/api/irs');
+ setIrs(data.irs || []);
+ setCurrent(data.current);
+ } catch (e) { /* ignore */ }
+ setLoading(false);
+ }, [connected]);
+
+ useEffect(() => { loadIrs(); }, [loadIrs]);
+
+ const handleLoad = async (path) => {
+ try { await apiPost('/api/irs/load', { path }); await loadIrs(); refresh(); } catch (e) { alert('Failed to load IR'); }
+ };
+
+ const handleUnload = async () => {
+ try { await apiPost('/api/irs/unload'); setCurrent(null); await loadIrs(); refresh(); } catch (e) { alert('Failed to unload'); }
+ };
+
+ const handleUpload = async (e) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setUploading(true);
+ try {
+ await apiUpload('/api/irs/upload', file);
+ await loadIrs();
+ } catch (err) { alert('Upload failed'); }
+ setUploading(false);
+ };
+
+ const handleSearch = useCallback(async () => {
+ if (!searchQ.trim()) return;
+ setSearching(true);
+ try {
+ const data = await apiGet(`/api/irs/tonedownload/search?q=${encodeURIComponent(searchQ)}`);
+ setSearchResults(data.results || []);
+ } catch (e) { alert('Search failed'); }
+ setSearching(false);
+ }, [searchQ]);
+
+ const handleInstall = async (result) => {
+ try {
+ await apiPost('/api/irs/tonedownload/install', {
+ download_url: result.download_url,
+ name: result.name || result.filename,
+ id: result.id,
+ size: result.size,
+ display_name: result.name,
+ });
+ await loadIrs();
+ refresh();
+ } catch (e) { alert('Install failed'); }
+ };
+
+ if (loading) return ;
+
+ return (
+
+
+
IR Files ({irs.length})
+
+
+
+
+
+
+
+ {showSearch && (
+
+
+ 🔍
+ setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} />
+
+
+ {searchResults.length > 0 && (
+
+ {searchResults.map(r => (
+
handleInstall(r)} style={{ borderBottom: `1px solid ${T.border}`, borderRadius: 0 }}>
+
+
{r.name}
+
{r.author} · {r.size_display}
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ {current && (
+
+ )}
+
+ {irs.length === 0 ? (
+
+ No IR files found.
Upload a .wav file or search Tone3000.
+
+ ) : (
+ irs.map(ir => (
+
+
+
{ir.name}
+
{ir.sample_rate} Hz · {ir.length_ms} ms · {ir.num_taps} taps
+
+ {ir.loaded ? (
+
Loaded
+ ) : (
+
+ )}
+
+ ))
+ )}
+
+
+ );
+}
+
+// 5. PRESETS SCREEN ────────────────────────────────────────────
+
+
+=== PresetsScreen ===
+function PresetsScreen({ state, connected, refresh }) {
+ const [banks, setBanks] = useState([]);
+ const [selectedBank, setSelectedBank] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [saveName, setSaveName] = useState('');
+ const [showSave, setShowSave] = useState(false);
+ const [deleting, setDeleting] = useState(null);
+
+ const loadPresets = useCallback(async () => {
+ if (!connected) {
+ setBanks([{ name: "Default", number: 0, presets: [
+ { name: "Edge of Breakup", bank: 0, program: 0 },
+ { name: "Modern High Gain", bank: 0, program: 1 },
+ null, null,
+ ]}]);
+ setLoading(false);
+ return;
+ }
+ try {
+ const data = await apiGet('/api/presets');
+ setBanks(data.banks || []);
+ } catch (e) { /* ignore */ }
+ setLoading(false);
+ }, [connected]);
+
+ useEffect(() => { loadPresets(); }, [loadPresets]);
+
+ const handleActivate = async (bank, program) => {
+ try {
+ await apiPost(`/api/presets/${bank}/${program}/activate`);
+ await loadPresets();
+ refresh();
+ } catch (e) { alert('Failed to load preset'); }
+ };
+
+ const handleSave = async () => {
+ if (!saveName.trim()) return;
+ const currentBank = state?.current_preset?.bank ?? 0;
+ const currentProgram = state?.current_preset?.program ?? 0;
+ try {
+ await apiPut(`/api/presets/${currentBank}/${currentProgram}`, { name: saveName });
+ setShowSave(false);
+ await loadPresets();
+ } catch (e) { alert('Failed to save'); }
+ };
+
+ const handleNewPreset = async () => {
+ // Find the first empty slot, or create at next program number
+ const bank = selectedBank;
+ const bankData = banks[bank];
+ const presets = bankData?.presets || [];
+ let nextProg = presets.findIndex(p => p == null);
+ if (nextProg === -1) nextProg = presets.length;
+ const name = `New Preset ${nextProg + 1}`;
+ try {
+ await apiPut(`/api/presets/${bank}/${nextProg}`, { name });
+ await loadPresets();
+ await apiPost(`/api/presets/${bank}/${nextProg}/activate`);
+ refresh();
+ } catch (e) { alert('Failed to create preset'); }
+ };
+
+ const handleDelete = async (bank, program) => {
+ try {
+ await apiDelete(`/api/presets/${bank}/${program}`);
+ setDeleting(null);
+ await loadPresets();
+ } catch (e) { alert('Failed to delete'); }
+ };
+
+ if (loading) return ;
+
+ const currentPreset = state?.current_preset;
+ const bankData = banks[selectedBank];
+ const presets = bankData?.presets || [];
+
+ return (
+
+
+
Presets
+
+
+
+
+
+
+
+ {/* Bank selector */}
+ {banks.length > 1 && (
+
+ {banks.map((b, i) => (
+
+ ))}
+
+ )}
+
+ {/* Current preset indicator */}
+ {currentPreset && (
+
+
+
Current
+
+ {currentPreset.name}
+
+ Bank {currentPreset.bank} · Prog {currentPreset.program}
+
+
+
+
+ )}
+
+ {/* Preset list */}
+ {presets.length === 0 ? (
+
No presets in this bank.
+ ) : (
+ presets.map((p, i) => {
+ if (!p) return null;
+ const isActive = currentPreset?.bank === p.bank && currentPreset?.program === p.program;
+ const isDeleting = deleting === `${p.bank}-${p.program}`;
+ return (
+
!isDeleting && handleActivate(p.bank, p.program)}>
+
{p.program + 1}
+
+
{p.name}
+
Bank {p.bank} · Program {p.program}
+
+ {isActive &&
●}
+ {isDeleting ? (
+
+
+
+
+ ) : (
+
+ )}
+
+ );
+ })
+ )}
+
+
+ {/* Save dialog */}
+ {showSave && (
+
setShowSave(false)}>
+
+
+
+ setSaveName(e.target.value)} autoFocus
+ onKeyDown={e => e.key === 'Enter' && handleSave()} />
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+// 6. SETTINGS SCREEN ───────────────────────────────────────────
diff --git a/docs/snapshot-reference.jsx b/docs/snapshot-reference.jsx
new file mode 100644
index 0000000..a1bd3e7
--- /dev/null
+++ b/docs/snapshot-reference.jsx
@@ -0,0 +1,156 @@
+function SnapshotPanel({ onClose, state, connected }) {
+ const [snapshots, setSnapshots] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [editingSlot, setEditingSlot] = useState(null);
+ const [editName, setEditName] = useState('');
+ const [saveFlash, setSaveFlash] = useState(null);
+ const [discardEdits, setDiscardEdits] = useState(false);
+ const holdTimer = useRef(null);
+ const currentSlot = state?.current_snapshot ?? 0;
+
+ const loadSnapshots = useCallback(async () => {
+ if (!connected) { setLoading(false); return; }
+ try {
+ const data = await apiGet('/api/snapshots');
+ setSnapshots(data.snapshots || {});
+ } catch (e) { /* ignore */ }
+ setLoading(false);
+ }, [connected]);
+
+ useEffect(() => { loadSnapshots(); }, [loadSnapshots]);
+
+ // Handle press & hold vs tap
+ const handlePointerDown = (slot) => {
+ holdTimer.current = setTimeout(() => {
+ // Long press → save
+ holdTimer.current = null;
+ handleSave(slot);
+ }, 600);
+ };
+
+ const handlePointerUp = (slot) => {
+ if (holdTimer.current) {
+ clearTimeout(holdTimer.current);
+ holdTimer.current = null;
+ // Short tap → recall (or save if empty)
+ if (snapshots[String(slot)]) {
+ handleRecall(slot);
+ } else {
+ handleSave(slot);
+ }
+ }
+ };
+
+ const handlePointerLeave = () => {
+ if (holdTimer.current) {
+ clearTimeout(holdTimer.current);
+ holdTimer.current = null;
+ }
+ };
+
+ const handleSave = async (slot) => {
+ try {
+ await apiPost(`/api/snapshots/${slot}/save`);
+ setSaveFlash(slot);
+ setTimeout(() => setSaveFlash(null), 800);
+ await loadSnapshots();
+ } catch (e) { /* ignore */ }
+ };
+
+ const handleRecall = async (slot) => {
+ try {
+ await apiPost(`/api/snapshots/${slot}/recall`);
+ await loadSnapshots();
+ } catch (e) { /* ignore */ }
+ };
+
+ const handleRename = async (slot) => {
+ if (!editName.trim()) return;
+ try {
+ await apiPut(`/api/snapshots/${slot}`, { name: editName.trim() });
+ setEditingSlot(null);
+ await loadSnapshots();
+ } catch (e) { /* ignore */ }
+ };
+
+ const handleDelete = async (slot) => {
+ try {
+ await apiDelete(`/api/snapshots/${slot}`);
+ await loadSnapshots();
+ } catch (e) { /* ignore */ }
+ };
+
+ return (
+
+
+
Snapshots
+
+
+
+
+
+
+ Tap to recall · Hold to save
+ {discardEdits && Edits will be discarded on recall}
+
+
+ {[1,2,3,4,5,6,7,8].map(slot => {
+ const snap = snapshots[String(slot)];
+ const isActive = currentSlot === slot;
+ const isFilled = !!snap;
+ const isEditing = editingSlot === slot;
+ const isFlashing = saveFlash === slot;
+ return (
+
handlePointerDown(slot)}
+ onTouchStart={() => handlePointerDown(slot)}
+ onMouseUp={() => handlePointerUp(slot)}
+ onTouchEnd={() => handlePointerUp(slot)}
+ onMouseLeave={handlePointerLeave}
+ onTouchCancel={handlePointerLeave}
+ style={isFlashing ? { background: 'rgba(58,184,122,.15)' } : {}}>
+
{slot}
+ {isEditing ? (
+
setEditName(e.target.value)}
+ onBlur={() => handleRename(slot)}
+ onKeyDown={e => { if (e.key === 'Enter') handleRename(slot); if (e.key === 'Escape') setEditingSlot(null); }}
+ autoFocus onClick={e => e.stopPropagation()} />
+ ) : snap ? (
+
{
+ e.stopPropagation();
+ setEditingSlot(slot);
+ setEditName(snap.name);
+ }}>{snap.name}
+ ) : (
+
Empty
+ )}
+ {isFlashing &&
}
+ {isActive &&
}
+
+ );
+ })}
+
+
+ {/* Delete button for active snapshot */}
+ {currentSlot > 0 && snapshots[String(currentSlot)] && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/App.jsx b/src/App.jsx
index 9b04a8c..8465be6 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -638,8 +638,12 @@ export default function App(){
CPU {state.cpu_percent!=null?`${state.cpu_percent}%`:"—"}
-
-
+
+
{["stomp","preset"].map(m=>())}
-
-
+ borderColor:state.tuner_enabled?T.green:T.border}}
+ onClick={handleTunerToggle} title={state.tuner_enabled?"Exit Tuner":"Tuner"}>
+

+
+
diff --git a/src/BlockChain.jsx b/src/BlockChain.jsx
index 0118d24..6071b2e 100644
--- a/src/BlockChain.jsx
+++ b/src/BlockChain.jsx
@@ -40,6 +40,70 @@ const TYPE_COLORS = {
split: "#B080C0", merge: "#80B0C0",
};
+// ── Type-to-SVG-icon mapping (from pipedal icon set) ──
+const TYPE_ICON_MAP = {
+ od: "fx_distortion", overdrive: "fx_distortion", drive: "fx_distortion",
+ dist: "fx_distortion", distortion: "fx_distortion", gain: "fx_distortion",
+ fuzz: "fx_distortion",
+ delay: "fx_delay", echo: "fx_delay",
+ reverb: "fx_reverb",
+ chorus: "fx_chorus", flange: "fx_flanger", flanger: "fx_flanger",
+ phaser: "fx_phaser", tremolo: "fx_modulator", vibrato: "fx_modulator",
+ rotary: "fx_modulator", modulator: "fx_modulator",
+ pitch: "fx_pitch", harmonizer: "fx_pitch", shifter: "fx_pitch",
+ comp: "fx_compressor", compressor: "fx_compressor",
+ limiter: "fx_limiter",
+ gate: "fx_gate", noise_gate: "fx_gate",
+ filter: "fx_filter", wah: "fx_filter",
+ eq: "fx_eq", graphic_eq: "fx_eq",
+ parametric_eq: "fx_parametric_eq", multiband_eq: "fx_multiband_eq",
+ ir: "fx_spatial", cab: "fx_spatial", cabinet: "fx_spatial",
+ nam: "fx_simulator", capture: "fx_simulator", amp: "fx_amplifier",
+ amplifier: "fx_amplifier",
+ volume: "fx_constant", level: "fx_constant", boost: "fx_constant",
+ split: "fx_split_a", merge: "fx_mixer",
+ tuner: "fx_analyzer",
+ mixer: "fx_mixer", utility: "fx_utility",
+ plugin: "fx_plugin",
+ instrument: "fx_instrument",
+ reamp: "fx_converter",
+ looper: "fx_oscillator",
+};
+
+const IMG_PATH = (import.meta.env.BASE_URL || '/ui/') + 'img/';
+
+// ── SVG Block Icon component ─────────────────────────
+function BlockIcon({ type, size = 14, style = {} }) {
+ const key = (type || '').toLowerCase().trim();
+ let icon = 'fx_plugin';
+ let found = false;
+ for (const [k, v] of Object.entries(TYPE_ICON_MAP)) {
+ if (key === k || key.startsWith(k) || key.includes(k)) {
+ icon = v;
+ found = true;
+ break;
+ }
+ }
+ if (!found && key) {
+ // Try direct match by replacing spaces/special chars
+ const slug = key.replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
+ if (TYPE_ICON_MAP[slug]) icon = TYPE_ICON_MAP[slug];
+ }
+ return (
+
{ e.target.style.display = 'none'; }}
+ />
+ );
+}
+
function getBlockColor(type) {
const key = (type || "").toLowerCase().trim();
for (const [k, v] of Object.entries(TYPE_COLORS)) {
@@ -48,30 +112,6 @@ function getBlockColor(type) {
return T.amber;
}
-function getBlockIcon(type) {
- const key = (type || "").toLowerCase();
- if (key === "split") return "⇄";
- if (key === "merge") return "⨁";
- if (key.includes("dist") || key.includes("gain")) return "💥";
- if (key.includes("fuzz")) return "⚡";
- if (key.includes("delay") || key.includes("echo")) return "⏳";
- if (key.includes("reverb")) return "🌊";
- if (key.includes("pitch") || key.includes("harm") || key.includes("shift")) return "🎵";
- if (key.includes("comp") || key.includes("limit")) return "📊";
- if (key.includes("gate") || key.includes("noise")) return "🔇";
- if (key.includes("filter") || key.includes("wah")) return "📈";
- if (key.includes("eq")) return "⚖️";
- if (odMatch(key)) return "🔥";
- if (modMatch(key)) return "🌀";
- if (key.includes("ir") || key.includes("cab")) return "🔊";
- if (key.includes("nam") || key.includes("capture")) return "🎛";
- if (key.includes("volume") || key.includes("level") || key.includes("boost")) return "🔈";
- if (key.includes("tuner")) return "🎹";
- return "▣";
- 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({ color = T.border, narrow = false }) {
const w = narrow ? 14 : 18;
@@ -166,7 +206,7 @@ function SplitBlockTile({ block, selected, onSelect, onToggle }) {
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
lineHeight: 1.3, display: "flex", alignItems: "center", gap: 6,
}}>
- ⇄
+
SPLIT {st.label}
@@ -234,7 +274,7 @@ function MergeBlockTile({ block, selected, onSelect, onToggle }) {
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
lineHeight: 1.3, display: "flex", alignItems: "center", gap: 6,
}}>
- ⨁
+
MERGE
@@ -332,7 +372,7 @@ function BlockTile({ block, selected, onSelect, onToggle, pathLabel }) {
{block.name || block.type || "FX"}
- {getBlockIcon(block.type)}
+