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

@@ -454,11 +454,10 @@ async function addSequenceToTab(sequenceId, zoneId) {
const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!tabResponse.ok) throw new Error('Failed to load zone');
const tabData = await tabResponse.json();
const kind =
typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(tabData)
: null;
if (kind === 'presets') {
if (
typeof window.zoneAllowsSequences === 'function' &&
!window.zoneAllowsSequences(tabData)
) {
alert('This zone is for presets only. Add presets from the zone Edit menu instead.');
return;
}
@@ -524,11 +523,10 @@ async function refreshEditTabSequencesUi(zoneId) {
const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!zoneRes.ok) throw new Error('zone');
const zone = await zoneRes.json();
const kind =
typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(zone)
: null;
if (kind === 'presets') {
if (
typeof window.zoneAllowsSequences === 'function' &&
!window.zoneAllowsSequences(zone)
) {
currentEl.innerHTML =
'<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>';
addEl.innerHTML = '<span class="muted-text">—</span>';
@@ -1092,12 +1090,31 @@ async function loadSequencesModalList() {
const nSteps = ln.reduce((a, l) => a + l.length, 0);
const nLanes = ln.filter((l) => l.length > 0).length || 1;
title.textContent = `${doc.name || id}${nLanes} lane(s), ${nSteps} step(s)`;
const exportBtn = document.createElement('button');
exportBtn.type = 'button';
exportBtn.className = 'btn btn-secondary btn-small';
exportBtn.textContent = 'Export';
exportBtn.addEventListener('click', async () => {
try {
const response = await fetch(`/sequences/${id}/export`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error('Export failed');
const bundle = await response.json();
const safeName = String(doc.name || id).replace(/[^\w.-]+/g, '_');
window.downloadJsonFile(`sequence-${safeName}.json`, bundle);
} catch (e) {
console.error(e);
alert('Failed to export sequence.');
}
});
const edit = document.createElement('button');
edit.type = 'button';
edit.className = 'btn btn-secondary btn-small';
edit.textContent = 'Edit';
edit.addEventListener('click', () => openSequenceEditor(id, doc));
row.appendChild(title);
row.appendChild(exportBtn);
row.appendChild(edit);
listEl.appendChild(row);
});
@@ -1139,6 +1156,33 @@ document.addEventListener('DOMContentLoaded', () => {
openSequenceEditor(null, null);
});
}
const importSeqBtn = document.getElementById('import-sequence-btn');
if (importSeqBtn) {
importSeqBtn.addEventListener('click', async () => {
const text = await window.pickJsonFile();
if (!text) return;
const bundle = window.parseJsonFileText(text);
if (!bundle || bundle.kind !== 'sequence') {
alert('Invalid sequence bundle file.');
return;
}
try {
const response = await fetch('/sequences/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 loadSequencesModalList();
} catch (e) {
console.error(e);
alert(e.message || 'Failed to import sequence.');
}
});
}
const openPresetsFromSeq = document.getElementById('sequences-open-presets-btn');
if (openPresetsFromSeq) {
openPresetsFromSeq.addEventListener('click', () => {