diff --git a/frontend-react/src/App.jsx b/frontend-react/src/App.jsx
index a1a29fd..35b4556 100644
--- a/frontend-react/src/App.jsx
+++ b/frontend-react/src/App.jsx
@@ -89,6 +89,13 @@ function usePedalState() {
if (msg.type === 'model_loaded' || msg.type === 'ir_loaded') {
apiGet('/api/state').then(s => setState(s));
}
+ if (msg.type === 'snapshot_recalled' && msg.state) {
+ setState(msg.state);
+ }
+ if (msg.type === 'snapshot_saved') {
+ // Refresh state to pick up current_snapshot
+ apiGet('/api/state').then(s => setState(s));
+ }
} catch (e) { /* ignore parse errors */ }
};
ws.onclose = () => {
@@ -263,6 +270,38 @@ const css = `
border-radius: 16px 16px 0 0; overflow: hidden; display: flex; flex-direction: column; }
.slide-panel-handle { width: 36px; height: 4px; border-radius: 2px; background: ${T.border};
margin: 8px auto; flex-shrink: 0; }
+
+ /* Snapshot slots */
+ .snap-grid { display: flex; gap: 6px; overflow-x: auto; padding: 4px 0; }
+ .snap-slot { min-width: 60px; flex-shrink: 0; border-radius: 8px; border: 1.5px solid ${T.border};
+ background: ${T.surface}; cursor: pointer; text-align: center; padding: 8px 4px;
+ transition: all .12s; touch-action: manipulation; position: relative; }
+ .snap-slot:active { transform: scale(.95); }
+ .snap-slot.filled { border-color: ${T.amberDim}; }
+ .snap-slot.active { border-color: ${T.amber}; background: rgba(232,160,48,.08); }
+ .snap-slot .snap-num { font-family: 'JetBrains Mono', monospace; font-size: 16px;
+ font-weight: 700; color: ${T.textDim}; }
+ .snap-slot.filled .snap-num { color: ${T.amber}; }
+ .snap-slot.active .snap-num { color: ${T.amber}; text-shadow: 0 0 8px ${T.amber}; }
+ .snap-slot .snap-name { font-size: 9px; color: ${T.textDim}; margin-top: 3px;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .snap-slot.filled .snap-name { color: ${T.textSec}; }
+ .snap-slot.active .snap-name { color: ${T.amber}; }
+ .snap-slot .snap-empty { font-size: 9px; color: ${T.textDim}; margin-top: 3px; font-style: italic; }
+ .snap-slot .snap-save-indicator { position: absolute; top: -3px; right: -3px;
+ width: 10px; height: 10px; border-radius: 50%; background: ${T.green};
+ box-shadow: 0 0 6px ${T.green}; animation: snap-pop .3s ease-out; }
+ @keyframes snap-pop { 0% { transform: scale(2); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
+ .snap-name-input { background: ${T.surface}; border: 1px solid ${T.amber}; border-radius: 4px;
+ padding: 2px 6px; color: ${T.amber}; font-size: 10px; text-align: center; outline: none;
+ font-family: 'Inter', sans-serif; width: 100%; }
+ .snap-header-btn { cursor: pointer; border: none; background: none; color: ${T.textSec};
+ font-size: 16px; padding: 4px 6px; border-radius: 6px; transition: all .12s;
+ display: flex; align-items: center; gap: 4px; }
+ .snap-header-btn:hover { color: ${T.amber}; background: rgba(232,160,48,.1); }
+ .snap-header-btn.active { color: ${T.amber}; background: rgba(232,160,48,.15); }
+ .snap-header-num { font-family: 'JetBrains Mono', monospace; font-size: 11px;
+ font-weight: 700; color: ${T.amber}; min-width: 16px; text-align: center; }
`;
// ── Knob ──────────────────────────────────────────────────────
@@ -1640,6 +1679,167 @@ function RecordScreen({ state }) {
);
}
+// ══════════════════════════════════════════════════════════════
+// SNAPSHOT PANEL
+// ══════════════════════════════════════════════════════════════
+
+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 (
+