feat(ui): pattern modes, bundles, and zone content kind

Add profile/preset/sequence JSON import and export; map preset mode to
wire n6 with a mode dropdown for multi-mode patterns; zone edit shows
presets or sequences only with content_kind on save; update catalogue
and tests for merged pattern names.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-16 21:12:42 +12:00
parent 6286297646
commit 96d1e1b5fd
28 changed files with 1715 additions and 458 deletions

View File

@@ -4,6 +4,25 @@ let espnowSocketReady = false;
let espnowPendingMessages = [];
let currentProfileIdCache = null;
function coercePresetInt(v, def = 0) {
if (typeof v === 'number' && Number.isFinite(v)) {
return v;
}
const t = parseInt(String(v), 10);
return Number.isFinite(t) ? t : def;
}
/** Style variant for wire ``n6``; presets may store ``mode`` or legacy ``n6``. */
function presetWireN6(preset, def = 0) {
if (!preset || typeof preset !== 'object') {
return def;
}
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
return coercePresetInt(preset.mode, def);
}
return coercePresetInt(preset.n6, def);
}
const getCurrentProfileId = async () => {
try {
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
@@ -243,6 +262,8 @@ document.addEventListener('DOMContentLoaded', () => {
const presetSaveButton = document.getElementById('preset-save-btn');
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn');
const presetModeInput = document.getElementById('preset-mode-input');
const presetModeGroup = document.getElementById('preset-mode-group');
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
return;
@@ -297,7 +318,8 @@ document.addEventListener('DOMContentLoaded', () => {
patternConfig.parameter_mappings &&
typeof patternConfig.parameter_mappings === 'object'
) {
patternConfig = patternConfig.parameter_mappings;
const { parameter_mappings: pm, data: _data, ...rest } = patternConfig;
patternConfig = { ...rest, ...pm };
}
return patternConfig && typeof patternConfig === 'object' ? patternConfig : null;
};
@@ -311,6 +333,46 @@ document.addEventListener('DOMContentLoaded', () => {
return cfg.supports_manual !== false;
};
const getPatternModeOptions = (patternName) => {
const cfg = resolvePatternConfig(patternName);
if (!cfg || typeof cfg.mode !== 'object' || cfg.mode === null || Array.isArray(cfg.mode)) {
return null;
}
const entries = Object.entries(cfg.mode).filter(
([, label]) => typeof label === 'string' && label.trim(),
);
if (entries.length < 2) {
return null;
}
entries.sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10));
return entries;
};
const patternSupportsModes = (patternName) => getPatternModeOptions(patternName) !== null;
const setPresetModeFieldVisible = (show) => {
if (!presetModeGroup) {
return;
}
presetModeGroup.hidden = !show;
presetModeGroup.style.display = show ? '' : 'none';
if (!show && presetModeInput) {
presetModeInput.innerHTML = '';
}
};
const presetStoredMode = (preset) => {
if (!preset || typeof preset !== 'object') {
return 0;
}
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
const m = parseInt(String(preset.mode), 10);
return Number.isFinite(m) ? m : 0;
}
const n6 = parseInt(String(preset.n6), 10);
return Number.isFinite(n6) ? n6 : 0;
};
const updateManualBeatNVisibility = () => {
if (!presetManualBeatNWrap) {
return;
@@ -734,7 +796,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
// After values: show only mapped n params with labels from pattern.json; clear hidden inputs
updatePresetNLabels(patternName);
updatePresetNLabels(patternName, preset);
updateManualModeAvailability();
updatePresetEditorTabActionsVisibility();
};
@@ -793,10 +855,29 @@ document.addEventListener('DOMContentLoaded', () => {
return section ? section.dataset.zoneId : null;
};
const updatePresetEditorTabActionsVisibility = () => {
const updatePresetEditorTabActionsVisibility = async () => {
if (!presetRemoveFromTabButton) return;
const show = Boolean(currentEditTabId && currentEditId);
presetRemoveFromTabButton.hidden = !show;
if (!currentEditTabId || !currentEditId) {
presetRemoveFromTabButton.hidden = true;
return;
}
try {
const tabRes = await fetch(`/zones/${currentEditTabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabRes.ok) {
presetRemoveFromTabButton.hidden = false;
return;
}
const tabData = await tabRes.json();
const allowed =
typeof window.zoneAllowsPresets === 'function'
? window.zoneAllowsPresets(tabData)
: true;
presetRemoveFromTabButton.hidden = !allowed;
} catch (e) {
presetRemoveFromTabButton.hidden = false;
}
};
const updateTabDefaultPreset = async (presetId) => {
@@ -827,8 +908,15 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetEditorModal) {
presetEditorModal.classList.add('active');
}
const patternName = presetPatternInput ? presetPatternInput.value : '';
const modeBefore = patternSupportsModes(patternName)
? presetStoredMode({
mode: presetModeInput ? presetModeInput.value : undefined,
n6: getNumberInput('preset-n6-input'),
})
: 0;
loadPatterns().then(() => {
updatePresetNLabels(presetPatternInput ? presetPatternInput.value : '');
updatePresetNLabels(patternName, { mode: modeBefore, n6: modeBefore });
updateColorSectionVisibility();
});
};
@@ -859,11 +947,20 @@ document.addEventListener('DOMContentLoaded', () => {
})(),
};
// Always store numeric parameters as n1..n8.
// Always store numeric parameters as n1..n8 (except n6 when pattern uses mode).
const modeEntries = patternSupportsModes(payload.pattern)
? getPatternModeOptions(payload.pattern)
: null;
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
if (modeEntries && nKey === 'n6') {
continue;
}
payload[nKey] = getNumberInput(`preset-${nKey}-input`);
}
if (modeEntries && presetModeInput) {
payload.mode = parseInt(presetModeInput.value, 10) || 0;
}
return payload;
};
@@ -950,30 +1047,8 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
const updatePresetNLabels = (patternName) => {
const rawPatternName = String(patternName || '').trim();
const normalizedPatternName = rawPatternName.endsWith('.py')
? rawPatternName.slice(0, -3)
: rawPatternName;
let patternConfig =
(cachedPatterns && cachedPatterns[rawPatternName]) ||
(cachedPatterns && cachedPatterns[normalizedPatternName]) ||
null;
if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') {
const lower = normalizedPatternName.toLowerCase();
const matchedKey = Object.keys(cachedPatterns).find(
(k) => String(k).toLowerCase() === lower,
);
if (matchedKey) {
patternConfig = cachedPatterns[matchedKey];
}
}
if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') {
patternConfig = patternConfig.data;
}
if (patternConfig && typeof patternConfig === 'object' && patternConfig.parameter_mappings && typeof patternConfig.parameter_mappings === 'object') {
patternConfig = patternConfig.parameter_mappings;
}
const updatePresetNLabels = (patternName, presetForMode = null) => {
const patternConfig = resolvePatternConfig(patternName);
const labels = {};
const visibleNKeys = new Set();
@@ -989,9 +1064,35 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
const modeEntries = patternSupportsModes(patternName) ? getPatternModeOptions(patternName) : null;
if (modeEntries) {
visibleNKeys.delete('n6');
}
if (presetModeInput) {
if (modeEntries) {
setPresetModeFieldVisible(true);
presetModeInput.innerHTML = '';
modeEntries.forEach(([val, label]) => {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = label.trim();
presetModeInput.appendChild(opt);
});
const modeVal = presetForMode ? presetStoredMode(presetForMode) : 0;
const modeStr = String(modeVal);
if ([...presetModeInput.options].some((o) => o.value === modeStr)) {
presetModeInput.value = modeStr;
} else if (presetModeInput.options.length) {
presetModeInput.selectedIndex = 0;
}
} else {
setPresetModeFieldVisible(false);
}
}
const hasPatternMeta =
patternConfig && typeof patternConfig === 'object' && Object.keys(patternConfig).length > 0;
const hasAnyNLabel = visibleNKeys.size > 0;
const hasAnyNLabel = visibleNKeys.size > 0 || Boolean(modeEntries);
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
@@ -1073,6 +1174,26 @@ document.addEventListener('DOMContentLoaded', () => {
void sendPresetViaEspNow(presetId, preset || {}, []);
});
const exportButton = document.createElement('button');
exportButton.className = 'btn btn-secondary btn-small';
exportButton.textContent = 'Export';
exportButton.addEventListener('click', async () => {
try {
const response = await fetch(`/presets/${presetId}/export`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Export failed');
}
const bundle = await response.json();
const safeName = ((preset && preset.name) || presetId).replace(/[^\w.-]+/g, '_');
window.downloadJsonFile(`preset-${safeName}.json`, bundle);
} catch (error) {
console.error('Export preset failed:', error);
alert('Failed to export preset.');
}
});
const deleteButton = document.createElement('button');
deleteButton.className = 'btn btn-danger btn-small';
deleteButton.textContent = 'Delete';
@@ -1102,6 +1223,7 @@ document.addEventListener('DOMContentLoaded', () => {
row.appendChild(label);
row.appendChild(details);
row.appendChild(editButton);
row.appendChild(exportButton);
row.appendChild(sendButton);
row.appendChild(deleteButton);
presetsList.appendChild(row);
@@ -1148,6 +1270,34 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetsCloseButton) {
presetsCloseButton.addEventListener('click', closeModal);
}
const importPresetBtn = document.getElementById('import-preset-btn');
if (importPresetBtn) {
importPresetBtn.addEventListener('click', async () => {
const text = await window.pickJsonFile();
if (!text) return;
const bundle = window.parseJsonFileText(text);
if (!bundle || bundle.kind !== 'preset') {
alert('Invalid preset bundle file.');
return;
}
try {
const response = await fetch('/presets/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ bundle }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Import failed');
}
await loadPresets();
} catch (error) {
console.error('Import preset failed:', error);
alert(error.message || 'Failed to import preset.');
}
});
}
if (presetsAddButton) {
presetsAddButton.addEventListener('click', () => {
clearForm();
@@ -1198,6 +1348,22 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Could not determine current zone.');
return;
}
try {
const zoneCheck = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (zoneCheck.ok) {
const zoneDoc = await zoneCheck.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(zoneDoc)
) {
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
return;
}
}
} catch (e) {
console.warn('Could not verify zone content kind:', e);
}
// Load all presets
try {
@@ -1327,11 +1493,10 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to load zone');
}
const tabData = await tabResponse.json();
const kind =
typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(tabData)
: null;
if (kind === 'sequences') {
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData)
) {
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
return;
}
@@ -1697,14 +1862,6 @@ document.addEventListener('DOMContentLoaded', () => {
clearForm();
});
const coercePresetInt = (v, def = 0) => {
if (typeof v === 'number' && Number.isFinite(v)) {
return v;
}
const t = parseInt(String(v), 10);
return Number.isFinite(t) ? t : def;
};
/** Device field ``a`` / API ``auto``; missing → auto-run (matches server build_preset_dict). */
const coercePresetAuto = (preset) => {
if (!preset || typeof preset !== 'object') {
@@ -1826,7 +1983,7 @@ const sendPresetViaEspNow = async (
n3: coercePresetInt(preset.n3),
n4: coercePresetInt(preset.n4),
n5: coercePresetInt(preset.n5),
n6: coercePresetInt(preset.n6),
n6: presetWireN6(preset),
manual_beat_n: coerceManualBeatN(preset),
},
},
@@ -1929,7 +2086,7 @@ try {
// window may not exist in some environments; ignore.
}
// Store selected preset(s) per zone (multi-select; merge send order = click order, last wins on device).
// Store selected preset per zone (single-select; one tile active, one driver push per click).
const zoneSelectedPresetIds = {};
const zonePresetSelectionOrder = {};
@@ -1956,19 +2113,21 @@ function getOrderedZonePresetSelection(zoneId) {
return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
}
async function sendMergedZonePresetSelection(zoneId, tabData, allPresets) {
const ids = getOrderedZonePresetSelection(zoneId);
if (!ids.length) return;
for (let i = 0; i < ids.length; i += 1) {
const pid = ids[i];
const preset = allPresets[pid];
if (!preset) continue;
const names =
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
: [];
await sendPresetViaEspNow(pid, preset, names, false, false, '2');
}
/** Preset id that should show the tile outline (last click in selection order). */
function getLastZonePresetSelectionId(zoneId) {
const order = getOrderedZonePresetSelection(zoneId);
return order.length ? String(order[order.length - 1]) : null;
}
async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPresets) {
const pid = String(presetId);
const body = (allPresets && allPresets[pid]) || preset;
if (!body) return;
const names =
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
: [];
await sendPresetViaEspNow(pid, body, names, false, false, '2');
}
// Store selected preset per zone
@@ -2053,6 +2212,12 @@ const savePresetGrid = async (zoneId, presetGrid) => {
throw new Error('Failed to load zone');
}
const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData)
) {
throw new Error('This zone is for sequences only.');
}
// Store as 2D grid
tabData.presets = presetGrid;
@@ -2265,7 +2430,9 @@ const renderTabPresets = async (zoneId, options = {}) => {
const preset = allPresets[presetId];
if (preset) {
ensureZonePresetSelection(zoneId);
const isSelected = zoneSelectedPresetIds[String(zoneId)].has(String(presetId));
const lastSelectedId = getLastZonePresetSelectionId(zoneId);
const isSelected =
lastSelectedId !== null && lastSelectedId === String(presetId);
const displayPreset = {
...preset,
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
@@ -2285,7 +2452,10 @@ const renderTabPresets = async (zoneId, options = {}) => {
});
}
if (typeof window.appendZoneSequenceTiles === 'function' && ck !== 'presets') {
if (
typeof window.appendZoneSequenceTiles === 'function' &&
(typeof window.zoneAllowsSequences !== 'function' || window.zoneAllowsSequences(tabData))
) {
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
}
} catch (error) {
@@ -2311,7 +2481,9 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
}
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow';
const pat = (preset.pattern || '').toLowerCase();
const mode = presetWireN6(preset);
const isRainbow = pat === 'rainbow' || (pat === 'colour_cycle' && mode === 1);
const barColors = isRainbow
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
: colors;
@@ -2389,34 +2561,32 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
ensureZonePresetSelection(zoneId);
const z = String(zoneId);
const set = zoneSelectedPresetIds[z];
const order = zonePresetSelectionOrder[z];
const idStr = String(presetId);
if (set.has(idStr)) {
set.delete(idStr);
zonePresetSelectionOrder[z] = order.filter((x) => String(x) !== idStr);
} else {
const wasSelected = set.has(idStr);
set.clear();
zonePresetSelectionOrder[z] = [];
if (!wasSelected) {
set.add(idStr);
order.push(idStr);
zonePresetSelectionOrder[z] = [idStr];
}
const outlinePresetId = getLastZonePresetSelectionId(zoneId);
if (presetsListEl) {
presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => {
const pid = rw.dataset.presetId;
const btnEl = rw.querySelector('.preset-tile-main');
if (!btnEl || !pid) return;
if (set.has(String(pid))) btnEl.classList.add('active');
if (outlinePresetId && String(pid) === outlinePresetId) btnEl.classList.add('active');
else btnEl.classList.remove('active');
});
}
const orderList = getOrderedZonePresetSelection(zoneId);
if (orderList.length) {
const lastPid = orderList[orderList.length - 1];
selectedPresets[zoneId] = lastPid;
selectedPresetPayloads[zoneId] = (allPresets && allPresets[lastPid]) || preset;
if (!wasSelected) {
selectedPresets[zoneId] = idStr;
selectedPresetPayloads[zoneId] = (allPresets && allPresets[idStr]) || preset;
void sendZonePresetSelection(zoneId, tabData, idStr, preset, allPresets);
} else {
delete selectedPresets[zoneId];
delete selectedPresetPayloads[zoneId];
}
void sendMergedZonePresetSelection(zoneId, tabData, allPresets);
});
if (canDrag) {
@@ -2526,6 +2696,13 @@ const removePresetFromTab = async (zoneId, presetId) => {
throw new Error('Failed to load zone');
}
const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData)
) {
alert('This zone is for sequences only.');
return;
}
// Normalize to flat array
let flat = [];