feat(ui): refresh layout, help assets, and panel styling

Update the main template and client scripts for the revised navigation
and zone/device panels, and add bundled help SVG assets under static.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 10:33:41 +12:00
parent 2382ef16a1
commit aab62efd4f
27 changed files with 1606 additions and 467 deletions

View File

@@ -2,7 +2,7 @@
<title>Header: tab buttons and action bar</title> <title>Header: tab buttons and action bar</title>
<rect width="820" height="108" fill="#1a1a1a"/> <rect width="820" height="108" fill="#1a1a1a"/>
<rect x="0" y="106" width="820" height="2" fill="#4a4a4a"/> <rect x="0" y="106" width="820" height="2" fill="#4a4a4a"/>
<text x="16" y="28" fill="#888" font-family="sans-serif" font-size="11">Tabs</text> <text x="16" y="28" fill="#888" font-family="sans-serif" font-size="11">Zones</text>
<rect x="16" y="40" width="72" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/> <rect x="16" y="40" width="72" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
<text x="34" y="63" fill="#ccc" font-family="sans-serif" font-size="13">default</text> <text x="34" y="63" fill="#ccc" font-family="sans-serif" font-size="13">default</text>
<rect x="96" y="40" width="88" height="36" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="2"/> <rect x="96" y="40" width="88" height="36" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="2"/>
@@ -13,7 +13,7 @@
<rect x="380" y="40" width="72" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/> <rect x="380" y="40" width="72" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="396" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Profiles</text> <text x="396" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Profiles</text>
<rect x="458" y="40" width="52" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/> <rect x="458" y="40" width="52" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="470" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Tabs</text> <text x="470" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Zones</text>
<rect x="516" y="40" width="64" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/> <rect x="516" y="40" width="64" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="524" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Presets</text> <text x="524" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Presets</text>
<rect x="586" y="40" width="78" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/> <rect x="586" y="40" width="78" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,26 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 340" width="300" height="340" role="img" aria-labelledby="t"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 340" width="300" height="340" role="img" aria-labelledby="mobile-menu-title">
<title id="t">Narrow screen: Menu aggregates header actions</title> <title id="mobile-menu-title">Narrow screen: Menu aggregates header actions</title>
<rect width="300" height="340" fill="#2e2e2e"/> <rect width="300" height="340" fill="#2e2e2e"/>
<rect x="0" y="0" width="300" height="52" fill="#1a1a1a"/> <rect x="0" y="0" width="300" height="52" fill="#1a1a1a"/>
<rect x="12" y="12" width="56" height="28" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/> <rect x="12" y="12" width="56" height="28" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="22" y="30" fill="#eee" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12">Menu <20></text> <text x="22" y="30" fill="#eee" font-family="sans-serif" font-size="12">Menu</text>
<rect x="76" y="14" width="52" height="24" rx="4" fill="#333" stroke="#666" stroke-width="1"/> <rect x="76" y="14" width="52" height="24" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
<text x="86" y="30" fill="#ccc" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text> <text x="86" y="30" fill="#ccc" font-family="sans-serif" font-size="11">tab</text>
<rect x="136" y="14" width="52" height="24" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="1"/> <rect x="136" y="14" width="52" height="24" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="1"/>
<text x="142" y="30" fill="#fff" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text> <text x="142" y="30" fill="#fff" font-family="sans-serif" font-size="11">tab</text>
<rect x="12" y="60" width="276" height="168" rx="4" fill="#252525" stroke="#4a4a4a" stroke-width="1"/> <rect x="12" y="60" width="276" height="168" rx="4" fill="#252525" stroke="#4a4a4a" stroke-width="1"/>
<text x="24" y="84" fill="#888" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Dropdown (same actions as desktop header)</text> <text x="24" y="84" fill="#888" font-family="sans-serif" font-size="10">Dropdown (same actions as desktop header)</text>
<g font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12" fill="#e8e8e8"> <g font-family="sans-serif" font-size="12" fill="#e8e8e8">
<text x="24" y="108">Run mode</text> <text x="24" y="108">Run mode</text>
<text x="24" y="132">Profiles</text> <text x="24" y="132">Profiles</text>
<text x="24" y="156">Tabs</text> <text x="24" y="156">Zones</text>
<text x="24" y="180">Presets</text> <text x="24" y="180">Presets</text>
<text x="24" y="204">Help</text> <text x="24" y="204">Help</text>
</g> </g>
<rect x="12" y="240" width="276" height="80" rx="6" fill="#222" stroke="#444" stroke-width="1"/> <rect x="12" y="240" width="276" height="80" rx="6" fill="#222" stroke="#444" stroke-width="1"/>
<text x="24" y="268" fill="#aaa" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Content area  presets as on desktop</text> <text x="24" y="268" fill="#aaa" font-family="sans-serif" font-size="10">Content area - presets as on desktop</text>
<rect x="24" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/> <rect x="24" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
<text x="36" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text> <text x="36" y="298" fill="#ddd" font-family="sans-serif" font-size="11">preset</text>
<rect x="112" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/> <rect x="112" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
<text x="124" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text> <text x="124" y="298" fill="#ddd" font-family="sans-serif" font-size="11">preset</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -872,7 +872,7 @@ class LightingController {
this.selectTab(this.state.zone_order[0]); this.selectTab(this.state.zone_order[0]);
} else { } else {
this.currentTab = null; this.currentTab = null;
document.getElementById('zone-content').innerHTML = '<p>No tabs available. Create a new zone to get started.</p>'; document.getElementById('zone-content').innerHTML = '<p>No zones available. Create a new zone to get started.</p>';
} }
} }
} catch (error) { } catch (error) {
@@ -1010,7 +1010,7 @@ class LightingController {
this.state.lights = {}; this.state.lights = {};
this.state.zone_order = []; this.state.zone_order = [];
this.renderTabs(); this.renderTabs();
document.getElementById('zone-content').innerHTML = '<p>No tabs available. Create a new zone to get started.</p>'; document.getElementById('zone-content').innerHTML = '<p>No zones available. Create a new zone to get started.</p>';
this.updateCurrentProfileDisplay(); this.updateCurrentProfileDisplay();
} }
} else { } else {

View File

@@ -938,7 +938,8 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
if (!pushRes.ok) return; if (!pushRes.ok) return;
} }
editDeviceModal.classList.remove('active'); await loadDevicesModal();
refreshEditDeviceDebug();
}); });
} }
if (editCloseBtn) { if (editCloseBtn) {

View File

@@ -85,29 +85,33 @@ function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
const macsInRows = new Set(macRows.map((r) => r.mac).filter(Boolean)); const macsInRows = new Set(macRows.map((r) => r.mac).filter(Boolean));
const addWrap = document.createElement('div'); const addWrap = document.createElement('div');
addWrap.className = 'zone-devices-add profiles-actions'; addWrap.className = 'zone-devices-add profiles-actions';
const sel = document.createElement('select'); const picker =
sel.className = 'zone-device-add-select'; typeof window.createSearchableAddPicker === 'function'
sel.appendChild(new Option('Add device…', '')); ? window.createSearchableAddPicker({
entries.forEach(([mac, d]) => { entries,
if (macsInRows.has(mac)) return; excludeIds: macsInRows,
const labelName = d && d.name ? String(d.name).trim() : ''; labelFor: (mac, d) => {
const optLabel = labelName ? `${labelName}${mac}` : mac; const labelName = d && d.name ? String(d.name).trim() : '';
sel.appendChild(new Option(optLabel, mac)); return labelName ? `${labelName}${mac}` : mac;
}); },
const addBtn = document.createElement('button'); searchTextFor: (mac, d) => {
addBtn.type = 'button'; const labelName = d && d.name ? String(d.name).trim() : '';
addBtn.className = 'btn btn-primary btn-small'; return `${labelName} ${mac}`;
addBtn.textContent = 'Add'; },
addBtn.addEventListener('click', () => { onPick: (mac, d) => {
const mac = sel.value; if (!mac || !devicesMap[mac]) return;
if (!mac || !devicesMap[mac]) return; const n = String((d.name || '').trim() || mac);
const n = String((devicesMap[mac].name || '').trim() || mac); macRows.push({ mac, label: n });
macRows.push({ mac, label: n }); renderGroupDevicesEditor(containerEl, macRows, devicesMap);
sel.value = ''; },
renderGroupDevicesEditor(containerEl, macRows, devicesMap); placeholder: 'Search devices to add…',
}); emptyMessage: 'No devices match your search.',
addWrap.appendChild(sel); noItemsMessage: 'All devices are already in this group.',
addWrap.appendChild(addBtn); })
: null;
if (picker) {
addWrap.appendChild(picker);
}
if (panel) { if (panel) {
panel.addSlot.appendChild(addWrap); panel.addSlot.appendChild(addWrap);
} else { } else {
@@ -130,15 +134,17 @@ function collectGroupEditPayload() {
const nl = document.getElementById('edit-group-wifi-num-leds'); const nl = document.getElementById('edit-group-wifi-num-leds');
const co = document.getElementById('edit-group-wifi-color-order'); const co = document.getElementById('edit-group-wifi-color-order');
const ws = document.getElementById('edit-group-wifi-startup-mode'); const ws = document.getElementById('edit-group-wifi-startup-mode');
if (dn && dn.value.trim()) payload.wifi_driver_display_name = dn.value.trim(); if (dn || nl || co || ws) {
else payload.wifi_driver_display_name = null; if (dn && dn.value.trim()) payload.wifi_driver_display_name = dn.value.trim();
if (nl && nl.value !== '') { else if (dn) payload.wifi_driver_display_name = null;
const n = parseInt(nl.value, 10); if (nl && nl.value !== '') {
if (!Number.isNaN(n) && n >= 1) payload.wifi_driver_num_leds = n; const n = parseInt(nl.value, 10);
else payload.wifi_driver_num_leds = null; if (!Number.isNaN(n) && n >= 1) payload.wifi_driver_num_leds = n;
} else payload.wifi_driver_num_leds = null; else payload.wifi_driver_num_leds = null;
if (co && co.value) payload.wifi_color_order = co.value; } else if (nl) payload.wifi_driver_num_leds = null;
if (ws && ws.value) payload.wifi_startup_mode = ws.value; if (co && co.value) payload.wifi_color_order = co.value;
if (ws && ws.value) payload.wifi_startup_mode = ws.value;
}
const gob = document.getElementById('edit-group-output-brightness'); const gob = document.getElementById('edit-group-output-brightness');
if (gob && gob.value !== '') { if (gob && gob.value !== '') {
const nb = parseInt(gob.value, 10); const nb = parseInt(gob.value, 10);
@@ -292,22 +298,27 @@ function renderGroupsList(groups) {
ids.forEach((gid) => { ids.forEach((gid) => {
const g = groups[gid]; const g = groups[gid];
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'profiles-row'; row.className = 'group-list-row';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '0.5rem';
row.style.flexWrap = 'wrap';
const label = document.createElement('span'); const info = document.createElement('div');
info.className = 'group-list-row-info';
const label = document.createElement('div');
label.className = 'group-list-row-title';
const devs = Array.isArray(g.devices) ? g.devices : []; const devs = Array.isArray(g.devices) ? g.devices : [];
label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`; label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`;
const meta = document.createElement('div'); const meta = document.createElement('div');
meta.className = 'muted-text'; meta.className = 'group-list-row-meta muted-text';
meta.style.fontSize = '0.8em';
const rawPid = g.profile_id != null ? g.profile_id : g.profileId; const rawPid = g.profile_id != null ? g.profile_id : g.profileId;
const scoped = rawPid != null && String(rawPid).trim() !== ''; const scoped = rawPid != null && String(rawPid).trim() !== '';
meta.textContent = scoped ? `This profile only (${rawPid})` : 'Shared across profiles'; meta.textContent = scoped ? `This profile only (${rawPid})` : 'Shared across profiles';
info.appendChild(label);
info.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'group-list-row-actions';
const editBtn = document.createElement('button'); const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small'; editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit'; editBtn.textContent = 'Edit';
@@ -392,17 +403,13 @@ function renderGroupsList(groups) {
} }
}); });
const left = document.createElement('div'); actions.appendChild(editBtn);
left.style.flex = '1'; actions.appendChild(brightBtn);
left.style.minWidth = '0'; actions.appendChild(applyBtn);
left.appendChild(label); actions.appendChild(identifyBtn);
left.appendChild(meta); actions.appendChild(delBtn);
row.appendChild(left); row.appendChild(info);
row.appendChild(editBtn); row.appendChild(actions);
row.appendChild(brightBtn);
row.appendChild(applyBtn);
row.appendChild(identifyBtn);
row.appendChild(delBtn);
container.appendChild(row); container.appendChild(row);
}); });
} }
@@ -540,8 +547,8 @@ document.addEventListener('DOMContentLoaded', () => {
} catch (_) { } catch (_) {
/* ignore push errors after save */ /* ignore push errors after save */
} }
if (editModal) editModal.classList.remove('active');
await loadGroupsModal(); await loadGroupsModal();
refreshEditGroupDebug();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert('Save failed'); alert('Save failed');

View File

@@ -7,9 +7,11 @@ document.addEventListener('DOMContentLoaded', () => {
const mainMenuDropdown = document.getElementById('main-menu-dropdown'); const mainMenuDropdown = document.getElementById('main-menu-dropdown');
if (helpBtn && helpModal) { if (helpBtn && helpModal) {
helpBtn.addEventListener('click', () => { const openHelp = () => {
helpModal.classList.add('active'); helpModal.classList.add('active');
}); switchHelpTab('overview');
};
helpBtn.addEventListener('click', openHelp);
} }
if (helpCloseBtn && helpModal) { if (helpCloseBtn && helpModal) {
@@ -18,10 +20,37 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
const helpTabButtons = document.querySelectorAll('[data-help-tab]');
const helpTabPanels = document.querySelectorAll('[data-help-panel]');
function switchHelpTab(tabId) {
if (!tabId) tabId = 'overview';
for (const btn of helpTabButtons) {
const on = btn.getAttribute('data-help-tab') === tabId;
btn.classList.toggle('active', on);
btn.setAttribute('aria-selected', on ? 'true' : 'false');
}
for (const panel of helpTabPanels) {
const on = panel.getAttribute('data-help-panel') === tabId;
panel.classList.toggle('active', on);
panel.hidden = !on;
}
}
for (const btn of helpTabButtons) {
btn.addEventListener('click', () => {
switchHelpTab(btn.getAttribute('data-help-tab'));
});
}
// Mobile main menu: forward clicks to existing header buttons // Mobile main menu: forward clicks to existing header buttons
if (mainMenuBtn && mainMenuDropdown) { if (mainMenuBtn && mainMenuDropdown) {
mainMenuBtn.addEventListener('click', () => { mainMenuBtn.addEventListener('click', () => {
mainMenuDropdown.classList.toggle('open'); mainMenuDropdown.classList.toggle('open');
const zonesMenuDropdown = document.getElementById('zones-menu-dropdown');
const zonesMenuBtn = document.getElementById('zones-menu-btn');
if (zonesMenuDropdown) zonesMenuDropdown.classList.remove('open');
if (zonesMenuBtn) zonesMenuBtn.setAttribute('aria-expanded', 'false');
}); });
mainMenuDropdown.addEventListener('click', (event) => { mainMenuDropdown.addEventListener('click', (event) => {

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 200" width="520" height="200">
<title>Audio beat detection</title>
<rect width="520" height="200" fill="#1e1e1e"/>
<rect x="40" y="16" width="440" height="168" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
<text x="60" y="44" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Audio Beat Detection</text>
<text x="60" y="72" fill="#aaa" font-family="sans-serif" font-size="10">Input device</text>
<rect x="60" y="78" width="200" height="24" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="68" y="94" fill="#bbb" font-family="sans-serif" font-size="10">monitor of Built-in</text>
<rect x="60" y="112" width="120" height="36" rx="4" fill="#2a4a2a" stroke="#5a8f5a" stroke-width="2"/>
<text x="72" y="128" fill="#afa" font-family="sans-serif" font-size="10">BPM</text>
<text x="72" y="142" fill="#fff" font-family="sans-serif" font-size="14" font-weight="600">128</text>
<rect x="200" y="120" width="200" height="8" rx="4" fill="#444"/>
<rect x="200" y="120" width="140" height="8" rx="4" fill="#6a9ee2"/>
<text x="200" y="148" fill="#888" font-family="sans-serif" font-size="9">Volume - live level meter - tap S to sync sequence</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 140" width="480" height="140">
<title>Colour Palette modal (concept)</title>
<rect width="480" height="140" fill="#2a2a2a" stroke="#555" stroke-width="1" rx="6"/>
<text x="20" y="28" fill="#fff" font-family="sans-serif" font-size="15" font-weight="600">Colour Palette</text>
<text x="20" y="48" fill="#888" font-family="sans-serif" font-size="10">Profile: current profile name</text>
<rect x="20" y="58" width="44" height="44" rx="4" fill="#e53935" stroke="#333"/>
<rect x="72" y="58" width="44" height="44" rx="4" fill="#fdd835" stroke="#333"/>
<rect x="124" y="58" width="44" height="44" rx="4" fill="#43a047" stroke="#333"/>
<rect x="176" y="58" width="44" height="44" rx="4" fill="#1e88e5" stroke="#333"/>
<rect x="228" y="58" width="44" height="44" rx="4" fill="#8e24aa" stroke="#333"/>
<rect x="280" y="70" width="36" height="28" rx="3" fill="#1a1a1a" stroke="#666"/>
<text x="288" y="88" fill="#ccc" font-family="sans-serif" font-size="10">+</text>
<text x="20" y="122" fill="#aaa" font-family="sans-serif" font-size="10">Swatches belong to the profile; preset editor uses them via From Palette.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 200" width="520" height="200">
<title>Devices modal</title>
<rect width="520" height="200" fill="#1e1e1e"/>
<rect x="40" y="20" width="440" height="160" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
<text x="60" y="48" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Devices</text>
<rect x="60" y="58" width="72" height="24" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="72" y="74" fill="#eee" font-family="sans-serif" font-size="10">Identify</text>
<rect x="140" y="58" width="96" height="24" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="150" y="74" fill="#eee" font-family="sans-serif" font-size="10">Update groups</text>
<text x="60" y="104" fill="#aaa" font-family="sans-serif" font-size="10">MAC</text>
<text x="200" y="104" fill="#aaa" font-family="sans-serif" font-size="10">Name</text>
<text x="60" y="128" fill="#ddd" font-family="monospace" font-size="11">AA:BB:CC:DD:EE:01</text>
<text x="200" y="128" fill="#ddd" font-family="sans-serif" font-size="11">lounge strip</text>
<text x="380" y="128" fill="#888" font-family="sans-serif" font-size="10">Edit</text>
<text x="60" y="156" fill="#ddd" font-family="monospace" font-size="11">AA:BB:CC:DD:EE:02</text>
<text x="200" y="156" fill="#ddd" font-family="sans-serif" font-size="11">ceiling</text>
<text x="60" y="176" fill="#888" font-family="sans-serif" font-size="10">ESP-NOW devices appear when they announce.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 220" width="520" height="220">
<title>Device groups modal</title>
<rect width="520" height="220" fill="#1e1e1e"/>
<rect x="40" y="16" width="440" height="188" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
<text x="60" y="44" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Device groups</text>
<rect x="60" y="54" width="140" height="24" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="68" y="70" fill="#bbb" font-family="sans-serif" font-size="11">Group name</text>
<rect x="210" y="54" width="56" height="24" rx="3" fill="#4a4a6a" stroke="#7a7aaf" stroke-width="1"/>
<text x="224" y="70" fill="#eee" font-family="sans-serif" font-size="11">Create</text>
<text x="60" y="104" fill="#ccc" font-family="sans-serif" font-size="12">lounge lights</text>
<text x="300" y="104" fill="#888" font-family="sans-serif" font-size="10">3 devices - Edit</text>
<text x="60" y="132" fill="#ccc" font-family="sans-serif" font-size="12">dj booth</text>
<text x="300" y="132" fill="#888" font-family="sans-serif" font-size="10">2 devices - Edit</text>
<rect x="60" y="148" width="360" height="44" rx="4" fill="#252525" stroke="#444" stroke-width="1"/>
<text x="72" y="168" fill="#aaa" font-family="sans-serif" font-size="10">Search devices to add...</text>
<text x="72" y="184" fill="#888" font-family="sans-serif" font-size="9">Pick from list - Identify group</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 108" width="820" height="108">
<title>Header: tab buttons and action bar</title>
<rect width="820" height="108" fill="#1a1a1a"/>
<rect x="0" y="106" width="820" height="2" fill="#4a4a4a"/>
<text x="16" y="28" fill="#888" font-family="sans-serif" font-size="11">Zones</text>
<rect x="16" y="40" width="72" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
<text x="34" y="63" fill="#ccc" font-family="sans-serif" font-size="13">default</text>
<rect x="96" y="40" width="88" height="36" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="2"/>
<text x="108" y="63" fill="#fff" font-family="sans-serif" font-size="13">lounge</text>
<rect x="192" y="40" width="56" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
<text x="204" y="63" fill="#ccc" font-family="sans-serif" font-size="13">dj</text>
<text x="380" y="28" fill="#888" font-family="sans-serif" font-size="11">Actions (Edit mode)</text>
<rect x="380" y="40" width="72" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="396" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Profiles</text>
<rect x="458" y="40" width="52" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="470" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Zones</text>
<rect x="516" y="40" width="64" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="524" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Presets</text>
<rect x="586" y="40" width="78" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="598" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Patterns</text>
<rect x="670" y="40" width="134" height="30" rx="3" fill="#4a4a6a" stroke="#7a7aaf" stroke-width="1"/>
<text x="688" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Run mode</text>
<text x="16" y="98" fill="#aaa" font-family="sans-serif" font-size="10">Active tab highlighted. Mode button shows the mode you switch to next.</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 340" width="300" height="340" role="img" aria-labelledby="mobile-menu-title">
<title id="mobile-menu-title">Narrow screen: Menu aggregates header actions</title>
<rect width="300" height="340" fill="#2e2e2e"/>
<rect x="0" y="0" width="300" height="52" fill="#1a1a1a"/>
<rect x="12" y="12" width="56" height="28" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="22" y="30" fill="#eee" font-family="sans-serif" font-size="12">Menu</text>
<rect x="76" y="14" width="52" height="24" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
<text x="86" y="30" fill="#ccc" font-family="sans-serif" font-size="11">tab</text>
<rect x="136" y="14" width="52" height="24" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="1"/>
<text x="142" y="30" fill="#fff" font-family="sans-serif" font-size="11">tab</text>
<rect x="12" y="60" width="276" height="168" rx="4" fill="#252525" stroke="#4a4a4a" stroke-width="1"/>
<text x="24" y="84" fill="#888" font-family="sans-serif" font-size="10">Dropdown (same actions as desktop header)</text>
<g font-family="sans-serif" font-size="12" fill="#e8e8e8">
<text x="24" y="108">Run mode</text>
<text x="24" y="132">Profiles</text>
<text x="24" y="156">Zones</text>
<text x="24" y="180">Presets</text>
<text x="24" y="204">Help</text>
</g>
<rect x="12" y="240" width="276" height="80" rx="6" fill="#222" stroke="#444" stroke-width="1"/>
<text x="24" y="268" fill="#aaa" font-family="sans-serif" font-size="10">Content area - presets as on desktop</text>
<rect x="24" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
<text x="36" y="298" fill="#ddd" font-family="sans-serif" font-size="11">preset</text>
<rect x="112" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
<text x="124" y="298" fill="#ddd" font-family="sans-serif" font-size="11">preset</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 180" width="520" height="180">
<title>Patterns list</title>
<rect width="520" height="180" fill="#1e1e1e"/>
<rect x="40" y="16" width="440" height="148" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
<text x="60" y="44" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Patterns</text>
<text x="60" y="72" fill="#ccc" font-family="sans-serif" font-size="12">pulse</text>
<text x="300" y="72" fill="#888" font-family="sans-serif" font-size="10">delay 20-200 ms</text>
<text x="60" y="98" fill="#ccc" font-family="sans-serif" font-size="12">rainbow</text>
<text x="300" y="98" fill="#888" font-family="sans-serif" font-size="10">delay 10-80 ms</text>
<text x="60" y="124" fill="#ccc" font-family="sans-serif" font-size="12">sparkle</text>
<text x="300" y="124" fill="#888" font-family="sans-serif" font-size="10">delay 5-50 ms</text>
<text x="60" y="152" fill="#888" font-family="sans-serif" font-size="9">Choose a pattern in the preset editor - n1-n8 depend on pattern.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 400" width="520" height="400">
<title>Preset editor modal (simplified)</title>
<rect width="520" height="400" fill="#1e1e1e"/>
<rect x="40" y="28" width="440" height="344" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
<text x="60" y="58" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="17" font-weight="600">Preset</text>
<text x="60" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Name</text>
<rect x="60" y="92" width="200" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="72" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">evening glow</text>
<text x="280" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Pattern</text>
<rect x="280" y="92" width="160" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="292" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">pulse</text>
<text x="60" y="148" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Colours</text>
<rect x="60" y="156" width="48" height="48" rx="4" fill="#7e57c2" stroke="#333" stroke-width="1"/>
<circle cx="66" cy="162" r="8" fill="#3f51b5" stroke="#fff" stroke-width="1"/>
<text x="63" y="166" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="7" font-weight="700">P</text>
<rect x="116" y="156" width="48" height="48" rx="4" fill="#26a69a" stroke="#333" stroke-width="1"/>
<text x="176" y="184" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">P = palette-linked</text>
<text x="60" y="232" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Brightness, delay, n1-n8</text>
<rect x="60" y="238" width="120" height="24" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="68" y="254" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">0-255</text>
<text x="60" y="290" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">Actions</text>
<rect x="60" y="298" width="44" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
<text x="72" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Try</text>
<rect x="112" y="298" width="56" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
<text x="120" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Default</text>
<rect x="176" y="298" width="88" height="26" rx="3" fill="#3d5a80" stroke="#5a7ab8" stroke-width="1"/>
<text x="188" y="315" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Save+Send</text>
<rect x="272" y="298" width="48" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
<text x="284" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Close</text>
<text x="60" y="352" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="9">Try: preview without device save. Save+Send: store and push with save.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 200" width="520" height="200">
<title>Profiles modal</title>
<rect width="520" height="200" fill="#1e1e1e"/>
<rect x="40" y="20" width="440" height="160" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
<text x="60" y="48" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Profiles</text>
<rect x="60" y="62" width="48" height="26" rx="3" fill="#4a4a6a" stroke="#7a7aaf" stroke-width="1"/>
<text x="72" y="79" fill="#eee" font-family="sans-serif" font-size="11">Apply</text>
<rect x="116" y="62" width="52" height="26" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="126" y="79" fill="#eee" font-family="sans-serif" font-size="11">Create</text>
<text x="60" y="108" fill="#ccc" font-family="sans-serif" font-size="12">Garden party</text>
<text x="360" y="108" fill="#888" font-family="sans-serif" font-size="10">Clone / Delete</text>
<text x="60" y="136" fill="#ccc" font-family="sans-serif" font-size="12">House default</text>
<text x="360" y="136" fill="#6a9ee2" font-family="sans-serif" font-size="10">active</text>
<text x="60" y="168" fill="#888" font-family="sans-serif" font-size="10">Apply switches zones and presets for this profile.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 200" width="520" height="200">
<title>Sequence editor</title>
<rect width="520" height="200" fill="#1e1e1e"/>
<rect x="40" y="16" width="440" height="168" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
<text x="60" y="44" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Sequence</text>
<text x="60" y="68" fill="#aaa" font-family="sans-serif" font-size="10">Lane 1 - lounge lights</text>
<rect x="60" y="74" width="56" height="28" rx="3" fill="#4a3a6a" stroke="#7a6aaf" stroke-width="1"/>
<text x="72" y="92" fill="#eee" font-family="sans-serif" font-size="9">step 1</text>
<rect x="122" y="74" width="56" height="28" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="134" y="92" fill="#eee" font-family="sans-serif" font-size="9">step 2</text>
<rect x="184" y="74" width="56" height="28" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="196" y="92" fill="#eee" font-family="sans-serif" font-size="9">step 3</text>
<text x="60" y="124" fill="#aaa" font-family="sans-serif" font-size="10">Lane 2 - dj booth</text>
<rect x="60" y="130" width="56" height="28" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="72" y="148" fill="#eee" font-family="sans-serif" font-size="9">step 1</text>
<text x="300" y="44" fill="#888" font-family="sans-serif" font-size="9">Beat / Downbeat</text>
<text x="60" y="176" fill="#888" font-family="sans-serif" font-size="9">Add lanes - assign presets per step - attach in zone editor.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 200" width="520" height="200">
<title>Settings modal</title>
<rect width="520" height="200" fill="#1e1e1e"/>
<rect x="40" y="16" width="440" height="168" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
<text x="60" y="44" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Settings</text>
<rect x="60" y="52" width="56" height="24" rx="3" fill="#1a1a1a" stroke="#6a5acd" stroke-width="2"/>
<text x="72" y="68" fill="#fff" font-family="sans-serif" font-size="10">Bridge</text>
<rect x="122" y="52" width="64" height="24" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="132" y="68" fill="#ccc" font-family="sans-serif" font-size="10">LED Tool</text>
<text x="60" y="100" fill="#aaa" font-family="sans-serif" font-size="10">USB serial</text>
<rect x="60" y="106" width="160" height="22" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="68" y="121" fill="#bbb" font-family="sans-serif" font-size="9">/dev/ttyUSB0</text>
<text x="60" y="144" fill="#aaa" font-family="sans-serif" font-size="10">Wi-Fi</text>
<rect x="60" y="150" width="120" height="22" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="68" y="165" fill="#bbb" font-family="sans-serif" font-size="9">Bridge-AP</text>
<text x="240" y="121" fill="#6a9e6a" font-family="sans-serif" font-size="9">connected</text>
<text x="240" y="165" fill="#888" font-family="sans-serif" font-size="9">LED Tool: deploy - flash - serial setup</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 220" width="800" height="220">
<title>Main area: brightness and preset tiles</title>
<defs>
<linearGradient id="rg1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#ffd54f"/><stop offset="100%" style="stop-color:#fff8e1"/>
</linearGradient>
<linearGradient id="rg2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#e53935"/><stop offset="33%" style="stop-color:#fdd835"/>
<stop offset="66%" style="stop-color:#43a047"/><stop offset="100%" style="stop-color:#1e88e5"/>
</linearGradient>
<linearGradient id="rg3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#00897b"/><stop offset="100%" style="stop-color:#4db6ac"/>
</linearGradient>
</defs>
<rect width="800" height="220" fill="#2e2e2e"/>
<text x="20" y="32" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">lounge</text>
<text x="20" y="56" fill="#aaa" font-family="sans-serif" font-size="11">Brightness (global)</text>
<rect x="20" y="64" width="320" height="8" rx="4" fill="#444"/>
<rect x="20" y="64" width="200" height="8" rx="4" fill="#6a9ee2"/>
<circle cx="220" cy="68" r="10" fill="#ccc" stroke="#333" stroke-width="1"/>
<text x="360" y="74" fill="#888" font-family="sans-serif" font-size="11">drag to adjust</text>
<text x="20" y="108" fill="#aaa" font-family="sans-serif" font-size="11">Click tile body to select on tab devices</text>
<rect x="20" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
<rect x="28" y="128" width="184" height="36" rx="4" fill="url(#rg1)"/>
<text x="32" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">warm white</text>
<rect x="232" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#6a9ee2" stroke-width="2"/>
<rect x="240" y="128" width="184" height="36" rx="4" fill="url(#rg2)"/>
<text x="244" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">rainbow</text>
<rect x="444" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
<rect x="452" y="128" width="184" height="36" rx="4" fill="url(#rg3)"/>
<text x="456" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">chase</text>
<rect x="656" y="130" width="56" height="48" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="670" y="158" fill="#ddd" font-family="sans-serif" font-size="11">Edit</text>
<text x="656" y="198" fill="#888" font-family="sans-serif" font-size="10">Edit mode: drag tiles to reorder</text>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 240" width="520" height="240">
<title>Zones editor</title>
<rect width="520" height="240" fill="#1e1e1e"/>
<rect x="40" y="16" width="440" height="208" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
<text x="60" y="44" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Edit zone</text>
<text x="60" y="72" fill="#aaa" font-family="sans-serif" font-size="10">Device groups on this zone</text>
<rect x="60" y="78" width="100" height="22" rx="3" fill="#333" stroke="#666" stroke-width="1"/>
<text x="72" y="93" fill="#ccc" font-family="sans-serif" font-size="10">lounge lights</text>
<text x="60" y="118" fill="#aaa" font-family="sans-serif" font-size="10">Presets on this zone</text>
<rect x="60" y="124" width="72" height="36" rx="4" fill="#3d3d3d" stroke="#666" stroke-width="1"/>
<text x="72" y="147" fill="#eee" font-family="sans-serif" font-size="10">warm</text>
<rect x="140" y="124" width="72" height="36" rx="4" fill="#3d3d3d" stroke="#666" stroke-width="1"/>
<text x="148" y="147" fill="#eee" font-family="sans-serif" font-size="10">pulse</text>
<text x="60" y="178" fill="#aaa" font-family="sans-serif" font-size="10">Sequences on this zone</text>
<rect x="60" y="184" width="120" height="28" rx="4" fill="#2a4a3a" stroke="#5a8f6a" stroke-width="1"/>
<text x="72" y="202" fill="#cfe" font-family="sans-serif" font-size="10">intro build</text>
<text x="60" y="228" fill="#888" font-family="sans-serif" font-size="9">Drag presets to reorder - presets and sequences can share a zone.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -268,10 +268,6 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error((data && data.error) || 'Create failed'); throw new Error((data && data.error) || 'Create failed');
} }
alert(data.message || 'Pattern created.'); alert(data.message || 'Pattern created.');
resetCreateForm();
if (patternEditorModal) {
patternEditorModal.classList.remove('active');
}
await loadPatterns(); await loadPatterns();
} catch (e) { } catch (e) {
console.error('Create pattern failed:', e); console.error('Create pattern failed:', e);

View File

@@ -1200,12 +1200,6 @@ document.addEventListener('DOMContentLoaded', () => {
const label = document.createElement('span'); const label = document.createElement('span');
label.textContent = (preset && preset.name) || presetId; label.textContent = (preset && preset.name) || presetId;
const details = document.createElement('span');
const pattern = preset && preset.pattern ? preset.pattern : '-';
details.textContent = pattern;
details.style.color = '#aaa';
details.style.fontSize = '0.85em';
const editButton = document.createElement('button'); const editButton = document.createElement('button');
editButton.className = 'btn btn-secondary btn-small'; editButton.className = 'btn btn-secondary btn-small';
editButton.textContent = 'Edit'; editButton.textContent = 'Edit';
@@ -1235,26 +1229,6 @@ document.addEventListener('DOMContentLoaded', () => {
void sendPresetViaEspNow(presetId, preset || {}, []); 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'); const deleteButton = document.createElement('button');
deleteButton.className = 'btn btn-danger btn-small'; deleteButton.className = 'btn btn-danger btn-small';
deleteButton.textContent = 'Delete'; deleteButton.textContent = 'Delete';
@@ -1282,9 +1256,7 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
row.appendChild(label); row.appendChild(label);
row.appendChild(details);
row.appendChild(editButton); row.appendChild(editButton);
row.appendChild(exportButton);
row.appendChild(sendButton); row.appendChild(sendButton);
row.appendChild(deleteButton); row.appendChild(deleteButton);
presetsList.appendChild(row); presetsList.appendChild(row);
@@ -1415,22 +1387,6 @@ document.addEventListener('DOMContentLoaded', () => {
return; 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, zoneId)
) {
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 // Load all presets
try { try {
const response = await fetch('/presets', { const response = await fetch('/presets', {
@@ -1470,11 +1426,13 @@ document.addEventListener('DOMContentLoaded', () => {
modal.id = 'add-preset-to-zone-modal'; modal.id = 'add-preset-to-zone-modal';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content"> <div class="modal-content">
<h2>Add Preset to Zone</h2> <div class="modal-head">
<div id="add-preset-list" class="profiles-list" style="max-height: 400px; overflow-y: auto;"></div> <h2>Add Preset to Zone</h2>
<div class="modal-actions"> <div class="modal-top-actions">
<button class="btn btn-secondary" id="add-preset-to-zone-close-btn">Close</button> <button class="btn btn-secondary" id="add-preset-to-zone-close-btn">Close</button>
</div>
</div> </div>
<div id="add-preset-list" class="profiles-list" style="max-height: 400px; overflow-y: auto;"></div>
</div> </div>
`; `;
@@ -1559,13 +1517,6 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to load zone'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData, zoneId)
) {
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
return;
}
// Normalize to flat array to check and update usage // Normalize to flat array to check and update usage
let flat = []; let flat = [];
@@ -1686,11 +1637,13 @@ document.addEventListener('DOMContentLoaded', () => {
modal.className = 'modal active modal-child-overlay'; modal.className = 'modal active modal-child-overlay';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content"> <div class="modal-content">
<h2>Pick Palette Color</h2> <div class="modal-head">
<div id="pick-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div> <h2>Pick Palette Color</h2>
<div class="modal-actions"> <div class="modal-top-actions">
<button class="btn btn-secondary" id="pick-palette-close-btn">Close</button> <button class="btn btn-secondary" id="pick-palette-close-btn">Close</button>
</div>
</div> </div>
<div id="pick-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
</div> </div>
`; `;
document.body.appendChild(modal); document.body.appendChild(modal);
@@ -1755,11 +1708,13 @@ document.addEventListener('DOMContentLoaded', () => {
modal.className = 'modal active modal-child-overlay'; modal.className = 'modal active modal-child-overlay';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content"> <div class="modal-content">
<h2>Pick background colour</h2> <div class="modal-head">
<div id="pick-bg-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div> <h2>Pick background colour</h2>
<div class="modal-actions"> <div class="modal-top-actions">
<button class="btn btn-secondary" id="pick-bg-palette-close-btn">Close</button> <button class="btn btn-secondary" id="pick-bg-palette-close-btn">Close</button>
</div>
</div> </div>
<div id="pick-bg-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
</div> </div>
`; `;
document.body.appendChild(modal); document.body.appendChild(modal);
@@ -1866,32 +1821,15 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to save preset'); throw new Error('Failed to save preset');
} }
// Same device targeting as Try: per-preset zone groups when in a zone tab.
const presetIdForSend = currentEditId || payload.name;
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetIdForSend);
// Use saved preset from server response for sending
const saved = await response.json().catch(() => null); const saved = await response.json().catch(() => null);
if (saved && typeof saved === 'object') { if (!currentEditId && saved && typeof saved === 'object') {
if (currentEditId) { const entries = Object.entries(saved);
// PUT returns the preset object directly; use the existing ID if (entries.length > 0) {
await sendPresetViaEspNow(currentEditId, saved, deviceNames, false, false, '2'); currentEditId = entries[0][0];
} else {
// POST returns { id: preset }
const entries = Object.entries(saved);
if (entries.length > 0) {
const [newId, presetData] = entries[0];
await sendPresetViaEspNow(newId, presetData, deviceNames, false, false, '2');
}
} }
} else {
// Fallback: send what we just built
await sendPresetViaEspNow(currentEditId || payload.name, payload, deviceNames, false, false, '2');
} }
await loadPresets(); await loadPresets();
clearForm();
closeEditor();
// Reload zone presets if we're in a zone view // Reload zone presets if we're in a zone view
const leftPanel = document.querySelector('.presets-section[data-zone-id]'); const leftPanel = document.querySelector('.presets-section[data-zone-id]');
@@ -2195,13 +2133,29 @@ async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPre
const selectedPresets = {}; const selectedPresets = {};
// Store selected preset payload per zone for beat-trigger reliability. // Store selected preset payload per zone for beat-trigger reliability.
const selectedPresetPayloads = {}; const selectedPresetPayloads = {};
// Run vs Edit for zone preset strip (in-memory only — each full page load starts in run mode) const PRESET_UI_MODE_STORAGE_KEY = 'led-controller-ui-mode';
let presetUiMode = 'run';
function readStoredPresetUiMode() {
try {
const stored = localStorage.getItem(PRESET_UI_MODE_STORAGE_KEY);
return stored === 'edit' ? 'edit' : 'run';
} catch (_) {
return 'run';
}
}
// Run vs Edit for zone preset strip (restored from localStorage on load)
let presetUiMode = readStoredPresetUiMode();
const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run'); const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run');
const setPresetUiMode = (mode) => { const setPresetUiMode = (mode) => {
presetUiMode = mode === 'edit' ? 'edit' : 'run'; presetUiMode = mode === 'edit' ? 'edit' : 'run';
try {
localStorage.setItem(PRESET_UI_MODE_STORAGE_KEY, presetUiMode);
} catch (_) {
/* ignore quota / private mode */
}
}; };
const updateUiModeToggleButtons = () => { const updateUiModeToggleButtons = () => {
@@ -2216,6 +2170,11 @@ const updateUiModeToggleButtons = () => {
document.body.classList.toggle('preset-ui-edit', mode === 'edit'); document.body.classList.toggle('preset-ui-edit', mode === 'edit');
document.body.classList.toggle('preset-ui-run', mode === 'run'); document.body.classList.toggle('preset-ui-run', mode === 'run');
}; };
if (typeof document !== 'undefined' && document.body) {
updateUiModeToggleButtons();
}
// Track if we're currently dragging a preset // Track if we're currently dragging a preset
let isDraggingPreset = false; let isDraggingPreset = false;
@@ -2273,12 +2232,6 @@ const savePresetGrid = async (zoneId, presetGrid) => {
throw new Error('Failed to load zone'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData, zoneId)
) {
throw new Error('This zone is for sequences only.');
}
// Store as 2D grid // Store as 2D grid
tabData.presets = presetGrid; tabData.presets = presetGrid;
@@ -2372,12 +2325,6 @@ const renderTabPresets = async (zoneId, options = {}) => {
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {}; const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
const ck =
typeof window.effectiveZoneContentKind === 'function'
? window.effectiveZoneContentKind(tabData)
: typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(tabData)
: 'presets';
// Get presets - support both 2D grid and flat array (for backward compatibility) // Get presets - support both 2D grid and flat array (for backward compatibility)
let presetGrid = tabData.presets; let presetGrid = tabData.presets;
@@ -2389,10 +2336,6 @@ const renderTabPresets = async (zoneId, options = {}) => {
// It's a flat array, convert to grid // It's a flat array, convert to grid
presetGrid = arrayToGrid(presetGrid, 3); presetGrid = arrayToGrid(presetGrid, 3);
} }
if (ck === 'sequences') {
presetGrid = [];
}
if (!presetsResponse.ok) { if (!presetsResponse.ok) {
throw new Error('Failed to load presets'); throw new Error('Failed to load presets');
} }
@@ -2474,18 +2417,12 @@ const renderTabPresets = async (zoneId, options = {}) => {
tabData.sequence_ids.some((x) => x != null && String(x).trim()); tabData.sequence_ids.some((x) => x != null && String(x).trim());
if (flatPresets.length === 0) { if (flatPresets.length === 0) {
const empty = document.createElement('p'); if (!hasSeq) {
empty.className = 'muted-text'; const empty = document.createElement('p');
empty.style.gridColumn = '1 / -1'; // Span all columns empty.className = 'muted-text';
if (ck === 'sequences') { empty.style.gridColumn = '1 / -1';
if (!hasSeq) {
empty.textContent =
"No sequences on this zone yet. Open the zone's Edit menu to add one.";
presetsList.appendChild(empty);
}
} else {
empty.textContent = empty.textContent =
'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.'; "No presets or sequences on this zone yet. Open Edit to add presets or sequences.";
presetsList.appendChild(empty); presetsList.appendChild(empty);
} }
} else { } else {
@@ -2515,11 +2452,7 @@ const renderTabPresets = async (zoneId, options = {}) => {
}); });
} }
if ( if (typeof window.appendZoneSequenceTiles === 'function') {
typeof window.appendZoneSequenceTiles === 'function' &&
(typeof window.zoneAllowsSequences !== 'function' ||
window.zoneAllowsSequences(tabData, zoneId))
) {
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList); await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
} }
} catch (error) { } catch (error) {
@@ -2760,13 +2693,6 @@ const removePresetFromTab = async (zoneId, presetId) => {
throw new Error('Failed to load zone'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData, zoneId)
) {
alert('This zone is for sequences only.');
return;
}
// Normalize to flat array // Normalize to flat array
let flat = []; let flat = [];

View File

@@ -13,6 +13,9 @@ document.addEventListener("DOMContentLoaded", () => {
} }
const isEditModeActive = () => { const isEditModeActive = () => {
if (typeof window.getPresetUiMode === 'function') {
return window.getPresetUiMode() === 'edit';
}
const toggle = document.querySelector('.ui-mode-toggle'); const toggle = document.querySelector('.ui-mode-toggle');
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true'); return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
}; };

View File

@@ -510,13 +510,6 @@ async function addSequenceToTab(sequenceId, zoneId) {
const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!tabResponse.ok) throw new Error('Failed to load zone'); if (!tabResponse.ok) throw new Error('Failed to load zone');
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsSequences === 'function' &&
!window.zoneAllowsSequences(tabData, zoneId)
) {
alert('This zone is for presets only. Add presets from the zone Edit menu instead.');
return;
}
const list = Array.isArray(tabData.sequence_ids) ? tabData.sequence_ids.map(String) : []; const list = Array.isArray(tabData.sequence_ids) ? tabData.sequence_ids.map(String) : [];
if (list.includes(String(sequenceId))) { if (list.includes(String(sequenceId))) {
alert('Sequence is already on this zone.'); alert('Sequence is already on this zone.');
@@ -579,15 +572,6 @@ async function refreshEditTabSequencesUi(zoneId) {
const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!zoneRes.ok) throw new Error('zone'); if (!zoneRes.ok) throw new Error('zone');
const zone = await zoneRes.json(); const zone = await zoneRes.json();
if (
typeof window.zoneAllowsSequences === 'function' &&
!window.zoneAllowsSequences(zone, zoneId)
) {
currentEl.innerHTML =
'<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>';
addEl.innerHTML = '<span class="muted-text">—</span>';
return;
}
const onZone = Array.isArray(zone.sequence_ids) ? zone.sequence_ids.map(String) : []; const onZone = Array.isArray(zone.sequence_ids) ? zone.sequence_ids.map(String) : [];
const seqMap = await fetchSequencesMap(); const seqMap = await fetchSequencesMap();
const onSet = new Set(onZone); const onSet = new Set(onZone);
@@ -600,11 +584,7 @@ async function refreshEditTabSequencesUi(zoneId) {
const sdoc = seqMap[sid] || {}; const sdoc = seqMap[sid] || {};
const name = sdoc.name || sid; const name = sdoc.name || sid;
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'profiles-row'; row.className = 'profiles-row edit-zone-item-row';
row.style.display = 'flex';
row.style.justifyContent = 'space-between';
row.style.alignItems = 'center';
row.style.gap = '0.5rem';
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = `${name}${sid}`; span.textContent = `${name}${sid}`;
const rm = document.createElement('button'); const rm = document.createElement('button');
@@ -1081,9 +1061,16 @@ async function saveSequenceEditor() {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error((err && err.error) || res.statusText); throw new Error((err && err.error) || res.statusText);
} }
const created = await res.json().catch(() => null);
if (created && typeof created === 'object') {
const entries = Object.entries(created);
if (entries.length > 0) {
sequenceEditorId = String(entries[0][0]);
const edDel = document.getElementById('sequence-editor-delete-btn');
if (edDel) edDel.style.display = 'inline-block';
}
}
} }
document.getElementById('sequence-editor-modal') && document.getElementById('sequence-editor-modal').classList.remove('active');
stopSequenceEditorBpmPoll();
await loadSequencesModalList(); await loadSequencesModalList();
const zid = resolveZoneIdForPresetStripRefresh(); const zid = resolveZoneIdForPresetStripRefresh();
if (zid && typeof window.refreshEditTabSequencesUi === 'function') { if (zid && typeof window.refreshEditTabSequencesUi === 'function') {
@@ -1164,31 +1151,12 @@ async function loadSequencesModalList() {
const nSteps = ln.reduce((a, l) => a + l.length, 0); const nSteps = ln.reduce((a, l) => a + l.length, 0);
const nLanes = ln.filter((l) => l.length > 0).length || 1; const nLanes = ln.filter((l) => l.length > 0).length || 1;
title.textContent = `${doc.name || id}${nLanes} lane(s), ${nSteps} step(s)`; 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'); const edit = document.createElement('button');
edit.type = 'button'; edit.type = 'button';
edit.className = 'btn btn-secondary btn-small'; edit.className = 'btn btn-secondary btn-small';
edit.textContent = 'Edit'; edit.textContent = 'Edit';
edit.addEventListener('click', () => openSequenceEditor(id, doc)); edit.addEventListener('click', () => openSequenceEditor(id, doc));
row.appendChild(title); row.appendChild(title);
row.appendChild(exportBtn);
row.appendChild(edit); row.appendChild(edit);
listEl.appendChild(row); listEl.appendChild(row);
}); });
@@ -1227,33 +1195,6 @@ document.addEventListener('DOMContentLoaded', () => {
openSequenceEditor(null, null); 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'); const openPresetsFromSeq = document.getElementById('sequences-open-presets-btn');
if (openPresetsFromSeq) { if (openPresetsFromSeq) {
openPresetsFromSeq.addEventListener('click', () => { openPresetsFromSeq.addEventListener('click', () => {

View File

@@ -125,6 +125,68 @@ header h1 {
justify-content: flex-end; justify-content: flex-end;
} }
.zones-menu-mobile {
display: none;
position: relative;
align-items: center;
flex-shrink: 0;
}
.zones-menu-dropdown {
position: absolute;
top: 100%;
left: 0;
background-color: #1a1a1a;
border: 1px solid #4a4a4a;
border-radius: 4px;
padding: 0.25rem 0;
display: none;
min-width: 10rem;
max-width: min(16rem, calc(100vw - 1rem));
max-height: min(50vh, 20rem);
overflow-y: auto;
z-index: 1100;
}
.zones-menu-dropdown.open {
display: block;
}
.zones-menu-item {
width: 100%;
background: none;
border: none;
color: white;
text-align: left;
padding: 0.45rem 0.75rem;
font-size: 0.85rem;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.zones-menu-item:hover {
background-color: #333;
}
.zones-menu-item.active {
background-color: #6a5acd;
color: white;
}
.zones-menu-empty {
padding: 0.45rem 0.75rem;
font-size: 0.85rem;
}
#zones-menu-btn {
max-width: 9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-menu-mobile { .header-menu-mobile {
display: none; display: none;
position: relative; position: relative;
@@ -1444,6 +1506,29 @@ body.preset-ui-run .edit-mode-only {
font-size: 1.3rem; font-size: 1.3rem;
} }
.modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.modal-head h2 {
margin: 0;
flex: 1;
min-width: 0;
}
.modal-top-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
flex-shrink: 0;
}
.modal-content label { .modal-content label {
display: block; display: block;
margin-top: 1rem; margin-top: 1rem;
@@ -1504,7 +1589,7 @@ body.preset-ui-run .edit-mode-only {
font-size: 1.1rem; font-size: 1.1rem;
} }
/* On mobile, hide header buttons; all actions (including Tabs) are in the Menu dropdown */ /* On mobile, hide header buttons; all actions (including Zones) are in the Menu dropdown */
.header-actions { .header-actions {
display: none; display: none;
} }
@@ -1540,17 +1625,28 @@ body.preset-ui-run .edit-mode-only {
transform: translateY(-50%); transform: translateY(-50%);
} }
.zones-menu-mobile {
display: flex;
margin-right: auto;
}
.zones-container {
display: none;
}
.header-menu-mobile { .header-menu-mobile {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
margin-top: 0; margin-top: 0;
margin-left: auto;
} }
.header-end { .header-end {
gap: 0.35rem; gap: 0.35rem;
flex-shrink: 0; flex-shrink: 0;
flex-wrap: nowrap;
} }
.header-end .audio-top-indicator { .header-end .audio-top-indicator {
@@ -1569,12 +1665,6 @@ body.preset-ui-run .edit-mode-only {
padding: 0.4rem 0.7rem; padding: 0.4rem 0.7rem;
} }
.zones-container {
padding: 0.35rem 0 0;
border-bottom: none;
width: 100%;
}
.zone-content { .zone-content {
padding: 0.5rem; padding: 0.5rem;
} }
@@ -1689,6 +1779,39 @@ body.preset-ui-run .edit-mode-only {
border-radius: 4px; border-radius: 4px;
} }
.group-list-row {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 0.65rem 0.75rem;
background-color: #3a3a3a;
border-radius: 4px;
}
.group-list-row-info {
min-width: 0;
}
.group-list-row-title {
font-weight: 600;
line-height: 1.35;
word-break: break-word;
}
.group-list-row-meta {
margin-top: 0.15rem;
font-size: 0.8em;
line-height: 1.35;
text-align: left;
}
.group-list-row-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.zone-modal-create-row { .zone-modal-create-row {
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
@@ -1774,6 +1897,65 @@ body.preset-ui-run .edit-mode-only {
color: white; color: white;
} }
.zone-device-add-picker {
flex: 1 1 100%;
min-width: 100%;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.zone-device-add-search {
width: 100%;
padding: 0.5rem;
background-color: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
font-size: 1rem;
}
.zone-device-add-search:focus {
outline: none;
border-color: #6a5acd;
}
.zone-device-add-results {
max-height: 10rem;
overflow-y: auto;
border: 1px solid #4a4a4a;
border-radius: 4px;
background-color: #3a3a3a;
}
.zone-device-add-results-empty {
padding: 0.5rem 0.6rem;
text-align: left;
}
.zone-device-add-result {
display: block;
width: 100%;
text-align: left;
padding: 0.45rem 0.6rem;
border: none;
border-bottom: 1px solid #4a4a4a;
background: transparent;
color: white;
cursor: pointer;
font: inherit;
}
.zone-device-add-result:last-child {
border-bottom: none;
}
.zone-device-add-result:hover,
.zone-device-add-result:focus-visible {
background-color: #4a4a4a;
outline: none;
}
.zone-devices-add { .zone-devices-add {
margin-top: 0; margin-top: 0;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1791,6 +1973,11 @@ body.preset-ui-run .edit-mode-only {
overflow-y: auto; overflow-y: auto;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.edit-zone-presets-scroll .edit-zone-item-row {
padding: 0.25rem 0.4rem;
margin-bottom: 0.25rem;
}
/* Hide any text content in palette rows - only show color swatches */ /* Hide any text content in palette rows - only show color swatches */
#palette-container .profiles-row { #palette-container .profiles-row {
font-size: 0; /* Hide any text nodes */ font-size: 0; /* Hide any text nodes */
@@ -1871,16 +2058,205 @@ body.preset-ui-run .edit-mode-only {
} }
} }
/* Help modal readability */ /* Help modal readability */
#help-modal .modal-content { #help-modal .modal-content,
max-width: 720px; #help-modal .help-modal-content {
max-width: 840px;
width: 95vw;
line-height: 1.6; line-height: 1.6;
font-size: 0.95rem; font-size: 0.95rem;
} }
#help-modal .modal-content h2 { #help-modal .modal-head {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
#help-modal .help-modal-intro {
margin-bottom: 0.25rem;
}
#help-modal .help-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0.5rem 0 1rem;
border-bottom: 1px solid #4a4a4a;
padding-bottom: 0.5rem;
}
#help-modal .help-tab-btn {
background: #3a3a3a;
color: #ccc;
border: 1px solid #4a4a4a;
border-radius: 4px 4px 0 0;
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
cursor: pointer;
}
#help-modal .help-tab-btn:hover {
color: #fff;
border-color: #6a5acd;
}
#help-modal .help-tab-btn.active {
background: #1a1a1a;
color: #fff;
border-color: #6a5acd;
border-bottom-color: #1a1a1a;
margin-bottom: -1px;
}
#help-modal .help-tab-panel:not(.active) {
display: none;
}
#help-modal .help-tab-panel {
max-height: min(70vh, 640px);
overflow-y: auto;
padding-right: 0.25rem;
}
#help-modal .help-ui-preview {
margin: 0 0 1rem;
border: 1px solid #4a4a4a;
border-radius: 8px;
background: #1a1a1a;
overflow: hidden;
pointer-events: none;
user-select: none;
}
#help-modal .help-ui-preview-caption {
margin: -0.5rem 0 1rem;
color: #999;
font-size: 0.85rem;
text-align: left;
}
#help-modal .help-ui-preview .help-preview-surface {
background-color: #2e2e2e;
padding: 1.25rem;
border-radius: 0;
}
#help-modal .help-ui-preview .modal-content {
position: static;
max-width: none;
min-width: 0;
width: 100%;
margin: 0;
padding: 1.25rem;
box-shadow: none;
border-radius: 0;
}
#help-modal .help-ui-preview .modal-head {
margin-bottom: 0.75rem;
}
#help-modal .help-ui-preview .profiles-list {
max-height: none;
margin-top: 0.75rem;
}
#help-modal .help-ui-preview .modal-actions {
margin-top: 0.75rem;
}
#help-modal .help-ui-preview--header .help-preview-header {
background-color: #1a1a1a;
padding: 0.75rem 1rem;
border-bottom: 2px solid #4a4a4a;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
#help-modal .help-ui-preview--strip .zone-content {
padding: 0.5rem 0.75rem;
max-height: 11rem;
overflow: hidden;
}
#help-modal .help-ui-preview .help-preview-presets-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: minmax(5rem, auto);
column-gap: 0.3rem;
row-gap: 0.3rem;
width: 100%;
}
#help-modal .help-ui-preview--mobile {
max-width: 220px;
margin-left: auto;
margin-right: auto;
}
#help-modal .help-ui-preview--mobile .help-preview-mobile-bar {
background-color: #1a1a1a;
padding: 0.75rem;
border-bottom: 1px solid #4a4a4a;
}
#help-modal .help-ui-preview--mobile .main-menu-dropdown {
display: block;
position: static;
border: none;
border-radius: 0;
min-width: 0;
}
#help-modal .help-ui-preview .preset-colors-container {
min-height: 5rem;
display: flex;
flex-wrap: nowrap;
gap: 0.5rem;
align-items: flex-start;
padding: 0.5rem;
background-color: #2a2a2a;
border-radius: 4px;
}
#help-modal .help-ui-preview .help-preview-color-swatch {
position: relative;
width: 64px;
height: 64px;
border-radius: 8px;
border: 2px solid #4a4a4a;
flex-shrink: 0;
}
#help-modal .help-ui-preview .help-preview-p-badge {
position: absolute;
left: -6px;
top: -6px;
min-width: 18px;
height: 18px;
border-radius: 9px;
background: #3f51b5;
color: #fff;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.35);
}
#help-modal .help-ui-preview .settings-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0 0 0.75rem;
border-bottom: 1px solid #4a4a4a;
padding-bottom: 0.5rem;
}
#help-modal .help-ui-preview .settings-tab-btn {
background: #3a3a3a;
color: #ccc;
border: 1px solid #4a4a4a;
border-radius: 4px 4px 0 0;
padding: 0.45rem 0.85rem;
font-size: 0.95rem;
}
#help-modal .help-ui-preview .settings-tab-btn.active {
background: #1a1a1a;
color: #fff;
border-color: #6a5acd;
}
#help-modal .help-ui-preview .settings-section {
margin-top: 0;
}
#help-modal .help-ui-preview select {
padding: 0.35rem 0.5rem;
background-color: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
font-size: 0.9rem;
}
#help-modal .help-ui-preview .profiles-actions input[type="text"] {
flex: 1;
min-width: 0;
}
#help-modal .modal-content h3 { #help-modal .modal-content h3 {
margin-top: 1.25rem; margin-top: 1rem;
margin-bottom: 0.4rem; margin-bottom: 0.4rem;
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 600; font-weight: 600;

View File

@@ -22,6 +22,101 @@ function prepareZoneDevicesPanel(containerEl) {
return { listEl, addSlot }; return { listEl, addSlot };
} }
/**
* Search field + scrollable filtered list for picking an item to add.
*/
function createSearchableAddPicker({
entries,
excludeIds,
labelFor,
searchTextFor,
onPick,
placeholder = 'Search…',
emptyMessage = 'No matches.',
noItemsMessage = 'Nothing to add.',
}) {
const wrap = document.createElement('div');
wrap.className = 'zone-device-add-picker';
const excluded = excludeIds || new Set();
const available = (entries || []).filter(([id]) => !excluded.has(id));
if (!available.length) {
const empty = document.createElement('span');
empty.className = 'muted-text';
empty.textContent = noItemsMessage;
wrap.appendChild(empty);
return wrap;
}
const search = document.createElement('input');
search.type = 'search';
search.className = 'zone-device-add-search';
search.placeholder = placeholder;
search.setAttribute('aria-label', placeholder);
const results = document.createElement('div');
results.className = 'zone-device-add-results';
results.setAttribute('role', 'listbox');
const filterAvailable = (query) => {
const q = String(query || '').trim().toLowerCase();
return available.filter(([id, item]) => {
if (!q) return true;
const text = searchTextFor(id, item);
return String(text).toLowerCase().includes(q);
});
};
const pickEntry = (id, item) => {
onPick(id, item);
search.value = '';
renderResults('');
};
const renderResults = (query) => {
results.innerHTML = '';
const filtered = filterAvailable(query);
if (!filtered.length) {
const none = document.createElement('div');
none.className = 'zone-device-add-results-empty muted-text';
none.textContent = emptyMessage;
results.appendChild(none);
return;
}
filtered.forEach(([id, item]) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'zone-device-add-result';
btn.setAttribute('role', 'option');
btn.textContent = labelFor(id, item);
btn.addEventListener('click', () => pickEntry(id, item));
results.appendChild(btn);
});
};
search.addEventListener('input', () => renderResults(search.value));
search.addEventListener('focus', () => renderResults(search.value));
search.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
const filtered = filterAvailable(search.value);
if (filtered.length === 1) {
event.preventDefault();
pickEntry(filtered[0][0], filtered[0][1]);
}
});
renderResults('');
wrap.appendChild(search);
wrap.appendChild(results);
return wrap;
}
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.prepareZoneDevicesPanel = prepareZoneDevicesPanel; window.prepareZoneDevicesPanel = prepareZoneDevicesPanel;
window.createSearchableAddPicker = createSearchableAddPicker;
} }

View File

@@ -127,6 +127,9 @@ function sendZoneBrightness(zoneId, value) {
} }
const isEditModeActive = () => { const isEditModeActive = () => {
if (typeof window.getPresetUiMode === 'function') {
return window.getPresetUiMode() === 'edit';
}
const toggle = document.querySelector('.ui-mode-toggle'); const toggle = document.querySelector('.ui-mode-toggle');
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true'); return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
}; };
@@ -534,27 +537,30 @@ function effectiveZoneContentKind(zoneDoc) {
/** @returns {boolean} */ /** @returns {boolean} */
function zoneAllowsPresets(zoneDoc, zoneId) { function zoneAllowsPresets(zoneDoc, zoneId) {
void zoneDoc;
void zoneId; void zoneId;
return effectiveZoneContentKind(zoneDoc) === 'presets'; return true;
} }
/** @returns {boolean} */ /** @returns {boolean} */
function zoneAllowsSequences(zoneDoc, zoneId) { function zoneAllowsSequences(zoneDoc, zoneId) {
void zoneDoc;
void zoneId; void zoneId;
return effectiveZoneContentKind(zoneDoc) === 'sequences'; return true;
} }
function applyZoneContentKindEditModal(kind) { function applyZoneContentKindEditModal(_kind) {
const presetsBlock = document.getElementById('edit-zone-block-presets'); const presetsBlock = document.getElementById('edit-zone-block-presets');
const groupsBlock = document.getElementById('edit-zone-block-groups'); const groupsBlock = document.getElementById('edit-zone-block-groups');
const seqBlock = document.getElementById('edit-zone-block-sequences'); const seqBlock = document.getElementById('edit-zone-block-sequences');
const typeLabel = document.getElementById('edit-zone-type-label');
const vis = (el, show) => { const vis = (el, show) => {
if (el) el.style.display = show ? '' : 'none'; if (el) el.style.display = show ? '' : 'none';
}; };
const k = kind === 'sequences' ? 'sequences' : 'presets'; if (typeLabel) typeLabel.style.display = 'none';
vis(groupsBlock, true); vis(groupsBlock, true);
vis(presetsBlock, k === 'presets'); vis(presetsBlock, true);
vis(seqBlock, k === 'sequences'); vis(seqBlock, true);
} }
window.normalizeZoneContentKind = normalizeZoneContentKind; window.normalizeZoneContentKind = normalizeZoneContentKind;
@@ -632,6 +638,52 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
} }
html += '</div>'; html += '</div>';
container.innerHTML = html; container.innerHTML = html;
renderZonesMenuMobile(tabs, tabOrder, currentZoneId);
}
function renderZonesMenuMobile(tabs, tabOrder, currentZoneId) {
const dropdown = document.getElementById('zones-menu-dropdown');
const menuBtn = document.getElementById('zones-menu-btn');
if (!dropdown) return;
if (!tabOrder || tabOrder.length === 0) {
dropdown.innerHTML = '<p class="muted-text zones-menu-empty">No zones</p>';
if (menuBtn) menuBtn.textContent = 'Zones';
return;
}
let html = '';
for (const zoneId of tabOrder) {
const zone = tabs[zoneId];
if (!zone) continue;
const activeClass = String(zoneId) === String(currentZoneId) ? ' active' : '';
const disp = zone.name || `Zone ${zoneId}`;
html += `
<button type="button" class="zones-menu-item${activeClass}"
data-zone-id="${zoneId}" role="menuitem">
${escapeHtmlAttr(disp)}
</button>
`;
}
dropdown.innerHTML = html;
if (menuBtn) {
const cur = tabs[currentZoneId];
menuBtn.textContent = cur ? (cur.name || `Zone ${currentZoneId}`) : 'Zones';
}
}
function syncZonesMenuSelection(zoneId) {
document.querySelectorAll('.zones-menu-item').forEach((item) => {
item.classList.toggle('active', item.dataset.zoneId === String(zoneId));
});
const menuBtn = document.getElementById('zones-menu-btn');
const activeItem = document.querySelector(
`.zones-menu-item[data-zone-id="${zoneId}"]`,
);
if (menuBtn && activeItem) {
menuBtn.textContent = activeItem.textContent.trim();
}
} }
// Render tabs list in modal (like profiles) // Render tabs list in modal (like profiles)
@@ -673,14 +725,6 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
label.style.color = "#FFD700"; label.style.color = "#FFD700";
} }
const applyButton = document.createElement("button");
applyButton.className = "btn btn-secondary btn-small";
applyButton.textContent = "Select";
applyButton.addEventListener("click", async () => {
await selectZone(zoneId);
document.getElementById('zones-modal').classList.remove('active');
});
const editButton = document.createElement("button"); const editButton = document.createElement("button");
editButton.className = "btn btn-secondary btn-small"; editButton.className = "btn btn-secondary btn-small";
editButton.textContent = "Edit"; editButton.textContent = "Edit";
@@ -771,7 +815,6 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
}); });
row.appendChild(label); row.appendChild(label);
row.appendChild(applyButton);
if (editMode) { if (editMode) {
row.appendChild(editButton); row.appendChild(editButton);
row.appendChild(cloneButton); row.appendChild(cloneButton);
@@ -819,10 +862,11 @@ async function selectZone(zoneId) {
document.querySelectorAll('.zone-button').forEach(btn => { document.querySelectorAll('.zone-button').forEach(btn => {
btn.classList.remove('active'); btn.classList.remove('active');
}); });
const btn = document.querySelector(`[data-zone-id="${zoneId}"]`); const btn = document.querySelector(`#zones-list .zone-button[data-zone-id="${zoneId}"]`);
if (btn) { if (btn) {
btn.classList.add('active'); btn.classList.add('active');
} }
syncZonesMenuSelection(zoneId);
// Set as current zone // Set as current zone
await setCurrentZone(zoneId); await setCurrentZone(zoneId);
@@ -931,12 +975,6 @@ async function refreshEditTabPresetsUi(zoneId) {
return; return;
} }
const tabData = await tabRes.json(); const tabData = await tabRes.json();
if (!zoneAllowsPresets(tabData, zoneId)) {
currentEl.innerHTML =
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
addEl.innerHTML = '<span class="muted-text">—</span>';
return;
}
const inTabIds = tabPresetIdsInOrder(tabData); const inTabIds = tabPresetIdsInOrder(tabData);
const inTabSet = new Set(inTabIds.map((id) => String(id))); const inTabSet = new Set(inTabIds.map((id) => String(id)));
@@ -960,12 +998,9 @@ async function refreshEditTabPresetsUi(zoneId) {
for (const presetId of inTabIds) { for (const presetId of inTabIds) {
const preset = allPresets[presetId] || {}; const preset = allPresets[presetId] || {};
const name = preset.name || presetId; const name = preset.name || presetId;
const block = document.createElement("div"); const row = makeRow();
block.style.cssText = row.className = "profiles-row edit-zone-item-row";
"border:1px solid rgba(255,255,255,0.12);border-radius:6px;padding:0.5rem 0.65rem;margin-bottom:0.65rem;";
const top = makeRow();
const label = document.createElement("span"); const label = document.createElement("span");
label.style.fontWeight = "600";
label.textContent = name; label.textContent = name;
const removeBtn = document.createElement("button"); const removeBtn = document.createElement("button");
removeBtn.type = "button"; removeBtn.type = "button";
@@ -977,11 +1012,9 @@ async function refreshEditTabPresetsUi(zoneId) {
await window.removePresetFromTab(zoneId, presetId); await window.removePresetFromTab(zoneId, presetId);
await refreshEditTabPresetsUi(zoneId); await refreshEditTabPresetsUi(zoneId);
}); });
top.appendChild(label); row.appendChild(label);
top.appendChild(removeBtn); row.appendChild(removeBtn);
block.appendChild(top); currentEl.appendChild(row);
currentEl.appendChild(block);
} }
} }
@@ -1069,17 +1102,8 @@ async function openEditZoneModal(zoneId, zone) {
}); });
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap); renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
const kind = effectiveZoneContentKind(tabData);
const typeLabel = document.getElementById('edit-zone-type-label');
if (typeLabel) {
typeLabel.textContent =
kind === 'sequences'
? 'Zone type: Sequences (set when the zone was created)'
: 'Zone type: Presets (set when the zone was created)';
}
if (modal) modal.classList.add("active"); if (modal) modal.classList.add("active");
applyZoneContentKindEditModal(kind); applyZoneContentKindEditModal();
await refreshEditTabPresetsUi(zoneId); await refreshEditTabPresetsUi(zoneId);
if (typeof window.refreshEditTabSequencesUi === "function") { if (typeof window.refreshEditTabSequencesUi === "function") {
await window.refreshEditTabSequencesUi(zoneId); await window.refreshEditTabSequencesUi(zoneId);
@@ -1104,7 +1128,6 @@ async function updateZone(zoneId, name, groupRows) {
} catch (_) { } catch (_) {
/* use empty existing */ /* use empty existing */
} }
const lockedKind = effectiveZoneContentKind(existing);
const response = await fetch(`/zones/${zoneId}`, { const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
@@ -1119,7 +1142,6 @@ async function updateZone(zoneId, name, groupRows) {
existing.preset_group_ids && typeof existing.preset_group_ids === 'object' existing.preset_group_ids && typeof existing.preset_group_ids === 'object'
? existing.preset_group_ids ? existing.preset_group_ids
: {}, : {},
content_kind: lockedKind,
}) })
}); });
@@ -1131,8 +1153,6 @@ async function updateZone(zoneId, name, groupRows) {
if (String(currentZoneId) === String(zoneId)) { if (String(currentZoneId) === String(zoneId)) {
await loadZoneContent(zoneId); await loadZoneContent(zoneId);
} }
// Close modal
document.getElementById('edit-zone-modal').classList.remove('active');
return true; return true;
} else { } else {
alert(`Error: ${data.error || 'Failed to update zone'}`); alert(`Error: ${data.error || 'Failed to update zone'}`);
@@ -1145,11 +1165,9 @@ async function updateZone(zoneId, name, groupRows) {
} }
} }
// Create a new zone (add devices in Edit zone). ``contentKind`` is ``'presets'`` | ``'sequences'``. // Create a new zone (add device groups, presets, and sequences in Edit zone).
async function createZone(name, contentKind) { async function createZone(name) {
try { try {
const ck =
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
const response = await fetch('/zones', { const response = await fetch('/zones', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -1159,7 +1177,6 @@ async function createZone(name, contentKind) {
name: name, name: name,
names: [], names: [],
group_ids: [], group_ids: [],
content_kind: ck,
}) })
}); });
@@ -1196,6 +1213,29 @@ document.addEventListener('DOMContentLoaded', () => {
const newTabNameInput = document.getElementById("new-zone-name"); const newTabNameInput = document.getElementById("new-zone-name");
const createZoneButton = document.getElementById("create-zone-btn"); const createZoneButton = document.getElementById("create-zone-btn");
const zonesMenuBtn = document.getElementById('zones-menu-btn');
const zonesMenuDropdown = document.getElementById('zones-menu-dropdown');
const mainMenuDropdown = document.getElementById('main-menu-dropdown');
if (zonesMenuBtn && zonesMenuDropdown) {
zonesMenuBtn.addEventListener('click', (event) => {
event.stopPropagation();
const open = zonesMenuDropdown.classList.toggle('open');
zonesMenuBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open && mainMenuDropdown) {
mainMenuDropdown.classList.remove('open');
}
});
zonesMenuDropdown.addEventListener('click', async (event) => {
const item = event.target.closest('.zones-menu-item');
if (!item || !item.dataset.zoneId) return;
await selectZone(item.dataset.zoneId);
zonesMenuDropdown.classList.remove('open');
zonesMenuBtn.setAttribute('aria-expanded', 'false');
});
}
if (tabsButton && zonesModal) { if (tabsButton && zonesModal) {
tabsButton.addEventListener("click", async () => { tabsButton.addEventListener("click", async () => {
zonesModal.classList.add("active"); zonesModal.classList.add("active");
@@ -1240,12 +1280,7 @@ document.addEventListener('DOMContentLoaded', () => {
const name = newTabNameInput.value.trim(); const name = newTabNameInput.value.trim();
if (name) { if (name) {
const kindRadio = document.querySelector( await createZone(name);
'input[name="new-zone-content-kind"]:checked',
);
const contentKind =
kindRadio && kindRadio.value === 'sequences' ? 'sequences' : 'presets';
await createZone(name, contentKind);
if (newTabNameInput) newTabNameInput.value = ""; if (newTabNameInput) newTabNameInput.value = "";
} }
}; };
@@ -1276,7 +1311,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (zoneId && name) { if (zoneId && name) {
await updateZone(zoneId, name, groupRows); await updateZone(zoneId, name, groupRows);
editZoneForm.reset();
} }
}); });
} }

View File

@@ -10,6 +10,10 @@
<div class="app-container"> <div class="app-container">
<header> <header>
<div class="header-end"> <div class="header-end">
<div class="zones-menu-mobile">
<button type="button" class="btn btn-secondary" id="zones-menu-btn" aria-haspopup="true" aria-expanded="false" aria-controls="zones-menu-dropdown">Zones</button>
<div id="zones-menu-dropdown" class="zones-menu-dropdown" role="menu" aria-label="Zones"></div>
</div>
<div class="nav-slide-toggle-wrap seq-switch-toggle-wrap edit-mode-only" id="seq-switch-toggle-wrap"> <div class="nav-slide-toggle-wrap seq-switch-toggle-wrap edit-mode-only" id="seq-switch-toggle-wrap">
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span> <span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span>
<button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle" aria-pressed="false" aria-label="Switch sequence on beat" title="When starting a sequence: wait for beat or downbeat"> <button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle" aria-pressed="false" aria-label="Switch sequence on beat" title="When starting a sequence: wait for beat or downbeat">
@@ -61,7 +65,7 @@
<button type="button" data-target="profiles-btn">Profiles</button> <button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button> <button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button>
<button type="button" class="edit-mode-only" data-target="groups-btn">Groups</button> <button type="button" class="edit-mode-only" data-target="groups-btn">Groups</button>
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button> <button type="button" class="edit-mode-only" data-target="zones-btn">Zones</button>
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button> <button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
<button type="button" class="edit-mode-only" data-target="sequences-btn">Sequences</button> <button type="button" class="edit-mode-only" data-target="sequences-btn">Sequences</button>
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button> <button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
@@ -88,34 +92,37 @@
</div> </div>
</div> </div>
<!-- Tabs Modal --> <!-- Zones Modal -->
<div id="zones-modal" class="modal"> <div id="zones-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Tabs</h2> <div class="modal-head">
<h2>Zones</h2>
<div class="modal-top-actions">
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
</div>
</div>
<div class="profiles-actions zone-modal-create-row"> <div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-zone-name" placeholder="Zone name"> <input type="text" id="new-zone-name" placeholder="Zone name">
<button class="btn btn-primary" id="create-zone-btn">Create</button> <button class="btn btn-primary" id="create-zone-btn">Create</button>
</div> </div>
<div class="zone-content-kind-row muted-text">
<label><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
<label><input type="radio" name="new-zone-content-kind" value="sequences"> Sequences</label>
</div>
<div id="zones-list-modal" class="profiles-list"></div> <div id="zones-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
</div>
</div> </div>
</div> </div>
<!-- Edit Zone Modal --> <!-- Edit Zone Modal -->
<div id="edit-zone-modal" class="modal"> <div id="edit-zone-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Edit Zone</h2> <div class="modal-head">
<h2>Edit Zone</h2>
<div class="modal-top-actions">
<button type="submit" form="edit-zone-form" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
</div>
</div>
<form id="edit-zone-form"> <form id="edit-zone-form">
<input type="hidden" id="edit-zone-id"> <input type="hidden" id="edit-zone-id">
<label>Zone Name:</label> <label>Zone Name:</label>
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required> <input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<p id="edit-zone-type-label" class="zone-content-kind-row muted-text" aria-live="polite"></p>
<div id="edit-zone-block-groups"> <div id="edit-zone-block-groups">
<label class="zone-devices-label">Device groups on this zone</label> <label class="zone-devices-label">Device groups on this zone</label>
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div> <div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
@@ -132,10 +139,6 @@
<label class="zone-presets-section-label">Add a sequence to this zone</label> <label class="zone-presets-section-label">Add a sequence to this zone</label>
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div> <div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div>
</div> </div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -143,7 +146,12 @@
<!-- Profiles Modal --> <!-- Profiles Modal -->
<div id="profiles-modal" class="modal"> <div id="profiles-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Profiles</h2> <div class="modal-head">
<h2>Profiles</h2>
<div class="modal-top-actions">
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
</div>
</div>
<div class="profiles-actions"> <div class="profiles-actions">
<input type="text" id="new-profile-name" placeholder="Profile name"> <input type="text" id="new-profile-name" placeholder="Profile name">
<button class="btn btn-primary" id="create-profile-btn">Create</button> <button class="btn btn-primary" id="create-profile-btn">Create</button>
@@ -156,16 +164,18 @@
</label> </label>
</div> </div>
<div id="profiles-list" class="profiles-list"></div> <div id="profiles-list" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
</div>
</div> </div>
</div> </div>
<!-- Devices Modal (registry: Wi-Fi drivers appear when they connect over TCP) --> <!-- Devices Modal (registry: Wi-Fi drivers appear when they connect over TCP) -->
<div id="devices-modal" class="modal"> <div id="devices-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Devices</h2> <div class="modal-head">
<h2>Devices</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
</div>
</div>
<div class="form-group" style="margin-bottom:0.75rem;"> <div class="form-group" style="margin-bottom:0.75rem;">
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;"> <div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;">
<input type="text" id="devices-add-name" placeholder="Device name" autocomplete="off" style="min-width:10rem;"> <input type="text" id="devices-add-name" placeholder="Device name" autocomplete="off" style="min-width:10rem;">
@@ -186,7 +196,6 @@
<span id="devices-ping-status" class="muted-text" aria-live="polite"></span> <span id="devices-ping-status" class="muted-text" aria-live="polite"></span>
<button type="button" class="btn btn-secondary" id="devices-update-groups-btn" title="Push group membership from Device groups to all ESP-NOW drivers">Update groups</button> <button type="button" class="btn btn-secondary" id="devices-update-groups-btn" title="Push group membership from Device groups to all ESP-NOW drivers">Update groups</button>
<span id="devices-groups-status" class="muted-text" aria-live="polite"></span> <span id="devices-groups-status" class="muted-text" aria-live="polite"></span>
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
</div> </div>
</div> </div>
</div> </div>
@@ -194,7 +203,12 @@
<!-- Device groups: members + WiFi driver defaults (zones reference groups for presets) --> <!-- Device groups: members + WiFi driver defaults (zones reference groups for presets) -->
<div id="groups-modal" class="modal"> <div id="groups-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Device groups</h2> <div class="modal-head">
<h2>Device groups</h2>
<div class="modal-top-actions">
<button class="btn btn-secondary" id="groups-close-btn">Close</button>
</div>
</div>
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lanes groups only). By default new groups are <strong>shared</strong> across all profiles; tick “this profile only” to hide a group from other profiles.</p> <p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lanes groups only). By default new groups are <strong>shared</strong> across all profiles; tick “this profile only” to hide a group from other profiles.</p>
<div class="profiles-actions zone-modal-create-row"> <div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-group-name" placeholder="Group name"> <input type="text" id="new-group-name" placeholder="Group name">
@@ -204,15 +218,18 @@
<button class="btn btn-primary" id="create-group-btn">Create</button> <button class="btn btn-primary" id="create-group-btn">Create</button>
</div> </div>
<div id="groups-list-modal" class="profiles-list"></div> <div id="groups-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="groups-close-btn">Close</button>
</div>
</div> </div>
</div> </div>
<div id="edit-group-modal" class="modal"> <div id="edit-group-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Edit device group</h2> <div class="modal-head">
<h2>Edit device group</h2>
<div class="modal-top-actions">
<button type="submit" form="edit-group-form" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Close</button>
</div>
</div>
<form id="edit-group-form"> <form id="edit-group-form">
<input type="hidden" id="edit-group-id"> <input type="hidden" id="edit-group-id">
<label for="edit-group-name">Group name</label> <label for="edit-group-name">Group name</label>
@@ -232,40 +249,19 @@
<input type="range" id="edit-group-output-brightness" min="0" max="255" value="255" style="flex:1;"> <input type="range" id="edit-group-output-brightness" min="0" max="255" value="255" style="flex:1;">
<span id="edit-group-output-brightness-value" class="muted-text" style="min-width:2.5rem;">255</span> <span id="edit-group-output-brightness-value" class="muted-text" style="min-width:2.5rem;">255</span>
</div> </div>
<p class="muted-text" style="margin-top:0.75rem;margin-bottom:0.35rem;">WiFi driver defaults (apply to all members via <strong>Apply defaults to drivers</strong> on the list)</p>
<label for="edit-group-wifi-driver-name">Display name</label>
<input type="text" id="edit-group-wifi-driver-name" placeholder="hello / discovery name" autocomplete="off">
<label for="edit-group-wifi-num-leds" style="margin-top:0.5rem;display:block;">Number of LEDs</label>
<input type="number" id="edit-group-wifi-num-leds" min="1" max="2048" step="1" placeholder="119">
<label for="edit-group-wifi-color-order" style="margin-top:0.5rem;display:block;">Colour order</label>
<select id="edit-group-wifi-color-order">
<option value="rgb">RGB</option>
<option value="rbg">RBG</option>
<option value="grb">GRB</option>
<option value="gbr">GBR</option>
<option value="brg">BRG</option>
<option value="bgr">BGR</option>
</select>
<label for="edit-group-wifi-startup-mode" style="margin-top:0.5rem;display:block;">Power-on pattern</label>
<select id="edit-group-wifi-startup-mode">
<option value="default">Default preset</option>
<option value="last">Last preset</option>
<option value="off">Off</option>
</select>
<label for="edit-group-debug" style="margin-top:1rem;display:block;">Debug</label>
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
<textarea id="edit-group-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
</div>
</form> </form>
</div> </div>
</div> </div>
<div id="edit-device-modal" class="modal"> <div id="edit-device-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Edit device</h2> <div class="modal-head">
<h2>Edit device</h2>
<div class="modal-top-actions">
<button type="submit" form="edit-device-form" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-device-close-btn">Close</button>
</div>
</div>
<form id="edit-device-form"> <form id="edit-device-form">
<input type="hidden" id="edit-device-id"> <input type="hidden" id="edit-device-id">
<p class="muted-text" style="margin-bottom:0.75rem;">MAC (id): <code id="edit-device-storage-id"></code></p> <p class="muted-text" style="margin-bottom:0.75rem;">MAC (id): <code id="edit-device-storage-id"></code></p>
@@ -319,10 +315,6 @@
<label for="edit-device-debug" style="margin-top:1rem;display:block;">Debug</label> <label for="edit-device-debug" style="margin-top:1rem;display:block;">Debug</label>
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored registry row and the JSON preview for <strong>Save</strong> (updates as you edit).</small> <small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored registry row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
<textarea id="edit-device-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea> <textarea id="edit-device-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-device-close-btn">Cancel</button>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -330,39 +322,48 @@
<!-- Presets Modal --> <!-- Presets Modal -->
<div id="presets-modal" class="modal"> <div id="presets-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Presets</h2> <div class="modal-head">
<h2>Presets</h2>
<div class="modal-top-actions">
<button class="btn btn-secondary" id="presets-close-btn">Close</button>
</div>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-primary" id="preset-add-btn">Add</button> <button class="btn btn-primary" id="preset-add-btn">Add</button>
<button type="button" class="btn btn-secondary" id="import-preset-btn">Import</button> <button type="button" class="btn btn-secondary" id="import-preset-btn">Import</button>
<button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button> <button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button>
</div> </div>
<div id="presets-list" class="profiles-list"></div> <div id="presets-list" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="presets-close-btn">Close</button>
</div>
</div> </div>
</div> </div>
<!-- Sequences Modal --> <!-- Sequences Modal -->
<div id="sequences-modal" class="modal"> <div id="sequences-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Sequences</h2> <div class="modal-head">
<h2>Sequences</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-secondary" id="sequences-close-btn">Close</button>
</div>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button> <button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button>
<button type="button" class="btn btn-secondary" id="import-sequence-btn">Import</button>
<button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button> <button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button>
</div> </div>
<div id="sequences-list" class="profiles-list"></div> <div id="sequences-list" class="profiles-list"></div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="sequences-close-btn">Close</button>
</div>
</div> </div>
</div> </div>
<!-- Sequence Editor Modal --> <!-- Sequence Editor Modal -->
<div id="sequence-editor-modal" class="modal"> <div id="sequence-editor-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Sequence</h2> <div class="modal-head">
<h2>Sequence</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
</div>
</div>
<div class="preset-editor-field"> <div class="preset-editor-field">
<label for="sequence-editor-name">Name</label> <label for="sequence-editor-name">Name</label>
<input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;"> <input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;">
@@ -385,8 +386,6 @@
<div class="modal-actions preset-editor-modal-actions"> <div class="modal-actions preset-editor-modal-actions">
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button> <button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
<button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button> <button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button>
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
</div> </div>
</div> </div>
</div> </div>
@@ -394,7 +393,13 @@
<!-- Preset Editor Modal --> <!-- Preset Editor Modal -->
<div id="preset-editor-modal" class="modal"> <div id="preset-editor-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Preset</h2> <div class="modal-head">
<h2>Preset</h2>
<div class="modal-top-actions">
<button class="btn btn-primary" id="preset-save-btn">Save</button>
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
</div>
</div>
<div class="profiles-actions"> <div class="profiles-actions">
<input type="text" id="preset-name-input" placeholder="Preset name"> <input type="text" id="preset-name-input" placeholder="Preset name">
<select id="preset-pattern-input"> <select id="preset-pattern-input">
@@ -485,8 +490,6 @@
<button class="btn btn-secondary" id="preset-send-btn">Try</button> <button class="btn btn-secondary" id="preset-send-btn">Try</button>
<button class="btn btn-secondary" id="preset-default-btn">Default</button> <button class="btn btn-secondary" id="preset-default-btn">Default</button>
<button type="button" class="btn btn-danger" id="preset-remove-from-zone-btn" hidden>Remove from zone</button> <button type="button" class="btn btn-danger" id="preset-remove-from-zone-btn" hidden>Remove from zone</button>
<button class="btn btn-primary" id="preset-save-btn">Save &amp; Send</button>
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
</div> </div>
</div> </div>
</div> </div>
@@ -494,22 +497,30 @@
<!-- Patterns Modal --> <!-- Patterns Modal -->
<div id="patterns-modal" class="modal"> <div id="patterns-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Patterns</h2> <div class="modal-head">
<h2>Patterns</h2>
<div class="modal-top-actions">
<button class="btn btn-secondary" id="patterns-close-btn">Close</button>
</div>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button> <button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button>
<button type="button" class="btn btn-secondary" id="pattern-send-all-btn">Send All Patterns</button> <button type="button" class="btn btn-secondary" id="pattern-send-all-btn">Send All Patterns</button>
</div> </div>
<div id="patterns-list" class="profiles-list"></div> <div id="patterns-list" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="patterns-close-btn">Close</button>
</div>
</div> </div>
</div> </div>
<!-- Pattern Editor Modal --> <!-- Pattern Editor Modal -->
<div id="pattern-editor-modal" class="modal"> <div id="pattern-editor-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Pattern</h2> <div class="modal-head">
<h2>Pattern</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-primary" id="pattern-create-btn">Save</button>
<button type="button" class="btn btn-secondary" id="pattern-editor-close-btn">Close</button>
</div>
</div>
<p class="muted-text" style="margin: 0 0 0.75rem 0;">Add a driver <code>.py</code> file and editor metadata (stored in the pattern database).</p> <p class="muted-text" style="margin: 0 0 0.75rem 0;">Add a driver <code>.py</code> file and editor metadata (stored in the pattern database).</p>
<div class="profiles-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;"> <div class="profiles-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
<label for="pattern-create-name" style="min-width: 7rem;">Name</label> <label for="pattern-create-name" style="min-width: 7rem;">Name</label>
@@ -572,8 +583,6 @@
<input type="checkbox" id="pattern-create-overwrite" checked> <input type="checkbox" id="pattern-create-overwrite" checked>
<span>Overwrite existing file</span> <span>Overwrite existing file</span>
</label> </label>
<button type="button" class="btn btn-primary" id="pattern-create-btn">Save</button>
<button type="button" class="btn btn-secondary" id="pattern-editor-close-btn">Close</button>
</div> </div>
</div> </div>
</div> </div>
@@ -581,63 +590,522 @@
<!-- Colour Palette Modal --> <!-- Colour Palette Modal -->
<div id="color-palette-modal" class="modal"> <div id="color-palette-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Colour Palette</h2> <div class="modal-head">
<h2>Colour Palette</h2>
<div class="modal-top-actions">
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
</div>
</div>
<p class="muted-text">Profile: <span id="palette-current-profile-name">None</span></p> <p class="muted-text">Profile: <span id="palette-current-profile-name">None</span></p>
<div id="palette-container" class="profiles-list"></div> <div id="palette-container" class="profiles-list"></div>
<div class="profiles-actions"> <div class="profiles-actions">
<input type="color" id="palette-new-color" value="#ffffff"> <input type="color" id="palette-new-color" value="#ffffff">
</div> </div>
<div class="modal-actions">
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
</div>
</div> </div>
</div> </div>
<!-- Help Modal --> <!-- Help Modal -->
<div id="help-modal" class="modal"> <div id="help-modal" class="modal">
<div class="modal-content"> <div class="modal-content help-modal-content">
<h2>Help</h2> <div class="modal-head">
<p class="muted-text">How to use the LED controller UI.</p> <h2>Help</h2>
<div class="modal-top-actions">
<h3>Run mode</h3> <button class="btn btn-secondary" id="help-close-btn">Close</button>
<ul> </div>
<li><strong>Select zone</strong>: left-click a zone button in the top bar.</li>
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the zone.</li>
<li><strong>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</li>
<li><strong>Devices</strong>: open <strong>Devices</strong> to see drivers (Wi-Fi clients appear when they connect); edit addresses or remove rows.</li>
<li><strong>Groups</strong>: define device groups, WiFi driver defaults, then assign groups to zones.</li>
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current zone to all zone devices.</li>
<li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
</ul>
<h3>Edit mode</h3>
<ul>
<li><strong>Tabs</strong>: create, edit, and manage zones and which <strong>device groups</strong> each zone drives.</li>
<li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li>
<li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li>
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save zone order.</li>
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> zone and can optionally seed a <strong>DJ zone</strong>.</li>
<li><strong>Devices</strong>: registry rows are keyed by <strong>MAC</strong>; edit a device for transport/IP and per-driver WiFi settings, or use <strong>Groups</strong> for shared defaults.</li>
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
</ul>
<h3>LED Tool (Settings tab)</h3>
<ul>
<li><strong>USB device setup</strong>: updates <code>settings.json</code> on ESP32 drivers over serial (for example name, pin, LED count, Wi-Fi credentials).</li>
<li><strong>Deploy and maintenance</strong>: uploads driver files, flashes firmware, resets device, and follows serial logs.</li>
<li><strong>Scope</strong>: led-tool configures devices directly; this web UI controls profiles/zones/presets and sends runtime messages. Open <strong>Settings → LED Tool</strong> in Edit mode.</li>
</ul>
<div class="modal-actions">
<button class="btn btn-secondary" id="help-close-btn">Close</button>
</div> </div>
<p class="muted-text help-modal-intro">How to use the LED controller UI. Previews use the same styles as the live interface.</p>
<div class="help-tabs" role="tablist" aria-label="Help sections">
<button type="button" class="help-tab-btn active" role="tab" id="help-tab-overview" data-help-tab="overview" aria-selected="true" aria-controls="help-panel-overview">Overview</button>
<button type="button" class="help-tab-btn" role="tab" id="help-tab-profiles" data-help-tab="profiles" aria-selected="false" aria-controls="help-panel-profiles">Profiles</button>
<button type="button" class="help-tab-btn" role="tab" id="help-tab-devices" data-help-tab="devices" aria-selected="false" aria-controls="help-panel-devices">Devices</button>
<button type="button" class="help-tab-btn" role="tab" id="help-tab-groups" data-help-tab="groups" aria-selected="false" aria-controls="help-panel-groups">Groups</button>
<button type="button" class="help-tab-btn" role="tab" id="help-tab-zones" data-help-tab="zones" aria-selected="false" aria-controls="help-panel-zones">Zones</button>
<button type="button" class="help-tab-btn" role="tab" id="help-tab-presets" data-help-tab="presets" aria-selected="false" aria-controls="help-panel-presets">Presets</button>
<button type="button" class="help-tab-btn" role="tab" id="help-tab-sequences" data-help-tab="sequences" aria-selected="false" aria-controls="help-panel-sequences">Sequences</button>
<button type="button" class="help-tab-btn" role="tab" id="help-tab-patterns" data-help-tab="patterns" aria-selected="false" aria-controls="help-panel-patterns">Patterns</button>
<button type="button" class="help-tab-btn" role="tab" id="help-tab-colour-palette" data-help-tab="colour-palette" aria-selected="false" aria-controls="help-panel-colour-palette">Colour Palette</button>
<button type="button" class="help-tab-btn" role="tab" id="help-tab-audio" data-help-tab="audio" aria-selected="false" aria-controls="help-panel-audio">Audio</button>
<button type="button" class="help-tab-btn" role="tab" id="help-tab-settings" data-help-tab="settings" aria-selected="false" aria-controls="help-panel-settings">Settings</button>
</div>
<div id="help-panel-overview" class="help-tab-panel active" data-help-panel="overview" role="tabpanel" aria-labelledby="help-tab-overview">
<div class="help-ui-preview help-ui-preview--header" aria-hidden="true">
<div class="help-preview-header">
<div class="header-end">
<div class="header-actions">
<div class="header-brightness-control">
<label>Brightness</label>
<input type="range" min="0" max="255" value="200" tabindex="-1">
</div>
<button type="button" class="btn btn-secondary" tabindex="-1">Profiles</button>
<button type="button" class="btn btn-secondary" tabindex="-1">Devices</button>
<button type="button" class="btn btn-secondary" tabindex="-1">Zones</button>
<button type="button" class="btn btn-secondary" tabindex="-1">Presets</button>
<button type="button" class="btn btn-secondary" tabindex="-1">Audio</button>
<button type="button" class="btn btn-secondary ui-mode-toggle" tabindex="-1">Run mode</button>
</div>
</div>
<div class="zones-container">
<div class="zones-list">
<button type="button" class="zone-button" tabindex="-1">default</button>
<button type="button" class="zone-button active" tabindex="-1">lounge</button>
<button type="button" class="zone-button" tabindex="-1">dj</button>
</div>
</div>
</div>
</div>
<p class="help-ui-preview-caption">Zone buttons below the header; management buttons on the right (Edit mode).</p>
<h3>Run mode and Edit mode</h3>
<ul>
<li><strong>Run mode</strong>: day-to-day control — choose a zone, tap presets, apply profiles. Management buttons are hidden.</li>
<li><strong>Edit mode</strong>: full setup — zones, presets, sequences, patterns, colour palette, and per-tile <strong>Edit</strong> on the strip.</li>
<li><strong>Switch modes</strong>: use the mode button in the header or mobile menu. The label shows the mode you will switch <em>to</em>.</li>
</ul>
<div class="help-ui-preview help-ui-preview--strip" aria-hidden="true">
<div class="zone-content">
<div class="presets-section">
<div class="help-preview-presets-grid">
<div class="preset-tile-row preset-tile-row--edit">
<div class="preset-tile-row-top">
<button type="button" class="pattern-button preset-tile-main active" style="background-image:linear-gradient(rgba(0,0,0,0.4),rgba(0,0,0,0.4)),linear-gradient(to right,#ffd54f 0%,#fff8e1 100%)" tabindex="-1"><span class="pattern-button-label">warm white</span></button>
<div class="preset-tile-actions">
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button>
<button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button>
</div>
</div>
</div>
<div class="preset-tile-row preset-tile-row--edit">
<div class="preset-tile-row-top">
<button type="button" class="pattern-button preset-tile-main" style="background-image:linear-gradient(rgba(0,0,0,0.4),rgba(0,0,0,0.4)),linear-gradient(to right,#e53935 0%,#1e88e5 100%)" tabindex="-1"><span class="pattern-button-label">rainbow</span></button>
<div class="preset-tile-actions">
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button>
<button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button>
</div>
</div>
</div>
<div class="preset-tile-row preset-tile-row--edit">
<div class="preset-tile-row-top">
<button type="button" class="pattern-button preset-tile-main" style="background-image:linear-gradient(rgba(0,0,0,0.4),rgba(0,0,0,0.4)),linear-gradient(to right,#00897b 0%,#4db6ac 100%)" tabindex="-1"><span class="pattern-button-label">pulse</span></button>
<div class="preset-tile-actions">
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button>
<button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<p class="help-ui-preview-caption">Click a preset tile to select it on all devices in the zone.</p>
<ul>
<li><strong>Select zone</strong>: click a zone button in the top bar.</li>
<li><strong>Brightness</strong>: the header slider adjusts global brightness for the current zone.</li>
<li><strong>Edit mode</strong>: drag preset tiles to reorder; use <strong>Edit</strong> and <strong>Remove</strong> on each tile.</li>
</ul>
<div class="help-ui-preview help-ui-preview--mobile" aria-hidden="true">
<div class="help-preview-mobile-bar">
<button type="button" class="btn btn-secondary" tabindex="-1">Menu</button>
</div>
<div class="main-menu-dropdown">
<button type="button" tabindex="-1">Run mode</button>
<button type="button" tabindex="-1">Profiles</button>
<button type="button" tabindex="-1">Zones</button>
<button type="button" tabindex="-1">Presets</button>
<button type="button" tabindex="-1">Help</button>
</div>
</div>
<p class="help-ui-preview-caption">On narrow screens, <strong>Menu</strong> reaches the same actions as the desktop header.</p>
</div>
<div id="help-panel-profiles" class="help-tab-panel" data-help-panel="profiles" role="tabpanel" aria-labelledby="help-tab-profiles" hidden>
<div class="help-ui-preview" aria-hidden="true">
<div class="modal-content help-preview-surface">
<div class="modal-head">
<h2>Profiles</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
</div>
</div>
<div class="profiles-actions">
<input type="text" value="New profile" readonly tabindex="-1">
<button type="button" class="btn btn-primary" tabindex="-1">Create</button>
<button type="button" class="btn btn-secondary" tabindex="-1">Import</button>
</div>
<div class="profiles-list">
<div class="profiles-row">
<span style="font-weight:bold;color:#FFD700">&#10003; House default</span>
<span>
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Apply</button>
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Export</button>
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Clone</button>
<button type="button" class="btn btn-danger btn-small" tabindex="-1">Delete</button>
</span>
</div>
<div class="profiles-row">
<span>Garden party</span>
<span>
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Apply</button>
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Export</button>
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Clone</button>
<button type="button" class="btn btn-danger btn-small" tabindex="-1">Delete</button>
</span>
</div>
</div>
</div>
</div>
<ul>
<li><strong>Apply</strong>: sets the current profile. Zones and presets you see are scoped to that profile.</li>
<li><strong>Create</strong> (Edit mode): new profiles get a populated <strong>default</strong> zone. Optionally tick <strong>DJ zone</strong> for a starter <code>dj</code> zone.</li>
<li><strong>Clone</strong> / <strong>Delete</strong>: available in Edit mode from the profile list.</li>
<li>In Run mode you can only apply profiles; create, clone, and delete are hidden.</li>
</ul>
</div>
<div id="help-panel-devices" class="help-tab-panel" data-help-panel="devices" role="tabpanel" aria-labelledby="help-tab-devices" hidden>
<div class="help-ui-preview" aria-hidden="true">
<div class="modal-content help-preview-surface">
<div class="modal-head">
<h2>Devices</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
</div>
</div>
<div class="profiles-list">
<div class="profiles-row">
<span class="device-status-dot device-status-dot--online" role="img"></span>
<span style="flex:1">lounge strip</span>
<code class="device-row-mac">AA:BB:CC:DD:EE:01</code>
<span class="muted-text" style="font-size:0.85em">led · espnow · —</span>
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button>
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Identify</button>
</div>
<div class="profiles-row">
<span class="device-status-dot device-status-dot--unknown" role="img"></span>
<span style="flex:1">ceiling</span>
<code class="device-row-mac">AA:BB:CC:DD:EE:02</code>
<span class="muted-text" style="font-size:0.85em">led · espnow · —</span>
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button>
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Identify</button>
</div>
</div>
<div class="modal-actions" style="justify-content:flex-start;margin-top:0.75rem;">
<button type="button" class="btn btn-secondary" tabindex="-1">Ping drivers</button>
<button type="button" class="btn btn-secondary" tabindex="-1">Update groups</button>
</div>
</div>
</div>
<ul>
<li><strong>Devices</strong> (Edit mode): registry of LED drivers keyed by <strong>MAC</strong>.</li>
<li>ESP-NOW devices appear automatically after <strong>ANNOUNCE</strong>; you can also add rows manually.</li>
<li><strong>Identify</strong>: short red blink (~2 s) so you can spot hardware.</li>
<li><strong>Update groups</strong>: pushes group membership from device groups to ESP-NOW drivers.</li>
<li>Edit a device for transport, IP, and per-driver settings; use <strong>Groups</strong> for shared WiFi defaults.</li>
</ul>
</div>
<div id="help-panel-groups" class="help-tab-panel" data-help-panel="groups" role="tabpanel" aria-labelledby="help-tab-groups" hidden>
<div class="help-ui-preview" aria-hidden="true">
<div class="modal-content help-preview-surface">
<div class="modal-head">
<h2>Device groups</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
</div>
</div>
<div class="profiles-actions zone-modal-create-row">
<input type="text" value="Group name" readonly tabindex="-1">
<button type="button" class="btn btn-primary" tabindex="-1">Create</button>
</div>
<div class="profiles-list">
<div class="group-list-row">
<div class="group-list-row-info">
<div class="group-list-row-title">lounge lights (3 devices)</div>
<div class="group-list-row-meta muted-text">Shared across profiles</div>
</div>
<div class="group-list-row-actions">
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button>
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Apply brightness</button>
</div>
</div>
<div class="group-list-row">
<div class="group-list-row-info">
<div class="group-list-row-title">dj booth (2 devices)</div>
<div class="group-list-row-meta muted-text">This profile only</div>
</div>
<div class="group-list-row-actions">
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button>
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Apply brightness</button>
</div>
</div>
</div>
</div>
</div>
<ul>
<li>Assign drivers to a <strong>group</strong>, set WiFi defaults once per group, then attach groups to a zone.</li>
<li>Standalone presets use the zones device groups. Sequence lanes each target their own group.</li>
<li>New groups are <strong>shared</strong> across profiles by default; tick <strong>this profile only</strong> to hide a group elsewhere.</li>
<li>In the group editor, search and pick devices from the list to add members; <strong>Identify devices in group</strong> blinks them together.</li>
</ul>
</div>
<div id="help-panel-zones" class="help-tab-panel" data-help-panel="zones" role="tabpanel" aria-labelledby="help-tab-zones" hidden>
<div class="help-ui-preview" aria-hidden="true">
<div class="modal-content help-preview-surface">
<div class="modal-head">
<h2>Edit Zone</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-primary" tabindex="-1">Save</button>
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
</div>
</div>
<label>Zone Name:</label>
<input type="text" value="lounge" readonly tabindex="-1">
<label class="zone-devices-label">Device groups on this zone</label>
<div class="profiles-list">
<div class="profiles-row edit-zone-item-row"><span>lounge lights</span><button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button></div>
</div>
<label class="zone-presets-section-label">Presets on this zone</label>
<div class="profiles-list edit-zone-presets-scroll">
<div class="profiles-row edit-zone-item-row"><span>warm white</span><button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button></div>
<div class="profiles-row edit-zone-item-row"><span>rainbow</span><button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button></div>
</div>
<label class="zone-presets-section-label">Sequences on this zone</label>
<div class="profiles-list edit-zone-presets-scroll">
<div class="profiles-row edit-zone-item-row"><span>intro build</span><button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button></div>
</div>
</div>
</div>
<ul>
<li><strong>Zones</strong> (Edit mode): create and manage zones from the header <strong>Zones</strong> button.</li>
<li>Each zone lists <strong>device groups</strong>, <strong>presets</strong>, and <strong>sequences</strong> — presets and sequences can share the same zone.</li>
<li>Drag presets on the main strip or in the zone editor to reorder.</li>
<li>Right-click a zone button for quick access to zone settings.</li>
</ul>
</div>
<div id="help-panel-presets" class="help-tab-panel" data-help-panel="presets" role="tabpanel" aria-labelledby="help-tab-presets" hidden>
<div class="help-ui-preview" aria-hidden="true">
<div class="modal-content help-preview-surface">
<div class="modal-head">
<h2>Preset</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-primary" tabindex="-1">Save</button>
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
</div>
</div>
<div class="profiles-actions">
<input type="text" value="evening glow" readonly tabindex="-1">
<select tabindex="-1"><option>pulse</option></select>
</div>
<label>Colours</label>
<div class="preset-colors-container">
<div class="help-preview-color-swatch" style="background-color:#7e57c2"><span class="help-preview-p-badge">P</span></div>
<div class="help-preview-color-swatch" style="background-color:#26a69a"></div>
</div>
<div class="profiles-actions">
<input type="color" value="#ffffff" tabindex="-1">
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">From Palette</button>
</div>
<div class="modal-actions preset-editor-modal-actions">
<button type="button" class="btn btn-secondary" tabindex="-1">Try</button>
<button type="button" class="btn btn-secondary" tabindex="-1">Default</button>
<button type="button" class="btn btn-secondary" tabindex="-1">Send</button>
</div>
</div>
</div>
<ul>
<li><strong>Presets</strong> (Edit mode): profile-wide list — <strong>Add</strong>, <strong>Edit</strong>, <strong>Send</strong>, and <strong>Delete</strong>.</li>
<li><strong>Pattern</strong> and optional <strong>n1n8</strong> fields depend on the pattern.</li>
<li><strong>From Palette</strong>: inserts a colour linked to the profile palette (badge <strong>P</strong>).</li>
<li><strong>Try</strong>: previews on the current zone without saving on the device.</li>
<li><strong>Save</strong>: writes the preset to the server (does not close the editor).</li>
<li><strong>Send</strong>: pushes the definition to devices with save.</li>
<li><strong>Remove from zone</strong> (when opened from a zone): removes from this zone only.</li>
</ul>
</div>
<div id="help-panel-sequences" class="help-tab-panel" data-help-panel="sequences" role="tabpanel" aria-labelledby="help-tab-sequences" hidden>
<div class="help-ui-preview" aria-hidden="true">
<div class="modal-content help-preview-surface">
<div class="modal-head">
<h2>Sequence</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-primary" tabindex="-1">Save</button>
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
</div>
</div>
<div class="preset-editor-field">
<label>Name</label>
<input type="text" value="intro build" readonly tabindex="-1">
</div>
<p class="muted-text" style="font-size:0.85em;margin:0.5rem 0;">Lane 1 — lounge lights</p>
<div class="profiles-list">
<div class="sequence-step-row profiles-row" style="display:flex;flex-direction:column;gap:0.35rem;padding:0.5rem;border:1px solid rgba(255,255,255,0.12);border-radius:6px;">
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
<label>Preset</label>
<select tabindex="-1"><option>warm white — 1</option></select>
<label>Beats</label>
<input type="number" value="4" readonly style="width:4rem" tabindex="-1">
</div>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Add lane</button>
</div>
</div>
</div>
<ul>
<li><strong>Sequences</strong> (Edit mode): build multi-step shows with one or more <strong>lanes</strong> (each lane targets a device group).</li>
<li>Add presets as steps per lane; open from the zone editor to attach a sequence to a zone.</li>
<li><strong>Beat</strong> / <strong>Downbeat</strong> toggle (header): when starting a sequence, wait for beat or downbeat before step 1.</li>
<li>Tap <kbd>S</kbd> or the BPM button during playback to sync step timing to music (with Audio running).</li>
</ul>
</div>
<div id="help-panel-patterns" class="help-tab-panel" data-help-panel="patterns" role="tabpanel" aria-labelledby="help-tab-patterns" hidden>
<div class="help-ui-preview" aria-hidden="true">
<div class="modal-content help-preview-surface">
<div class="modal-head">
<h2>Patterns</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
</div>
</div>
<div class="modal-actions" style="margin-top:0;justify-content:flex-start;">
<button type="button" class="btn btn-primary" tabindex="-1">Add</button>
<button type="button" class="btn btn-secondary" tabindex="-1">Send All Patterns</button>
</div>
<div class="profiles-list">
<div class="profiles-row"><span>pulse</span><span class="muted-text">delay 20200 ms</span><button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button></div>
<div class="profiles-row"><span>rainbow</span><span class="muted-text">delay 1080 ms</span><button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button></div>
</div>
</div>
</div>
<ul>
<li><strong>Patterns</strong> (Edit mode): reference list of pattern names and typical delay ranges.</li>
<li>Choose the pattern inside the preset editor; parameters map to <strong>n1n8</strong>.</li>
<li>WiFi drivers can install pattern modules over HTTP (OTA upload); ESP-NOW devices use the bridge you configure in <strong>Settings</strong>.</li>
</ul>
</div>
<div id="help-panel-colour-palette" class="help-tab-panel" data-help-panel="colour-palette" role="tabpanel" aria-labelledby="help-tab-colour-palette" hidden>
<div class="help-ui-preview" aria-hidden="true">
<div class="modal-content help-preview-surface">
<div class="modal-head">
<h2>Colour Palette</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
</div>
</div>
<p class="muted-text">Profile: <span>House default</span></p>
<div id="palette-container" class="profiles-list">
<div class="profiles-row" style="display:flex;align-items:center;gap:1rem;">
<div style="width:64px;height:64px;border-radius:8px;background:#7e57c2;border:2px solid #4a4a4a;flex-shrink:0;"></div>
<button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button>
</div>
<div class="profiles-row" style="display:flex;align-items:center;gap:1rem;">
<div style="width:64px;height:64px;border-radius:8px;background:#26a69a;border:2px solid #4a4a4a;flex-shrink:0;"></div>
<button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button>
</div>
</div>
<div class="profiles-actions">
<input type="color" value="#ffffff" tabindex="-1">
</div>
</div>
</div>
<p class="help-ui-preview-caption">Add or change swatches; linked preset colours update automatically.</p>
<ul>
<li><strong>Colour Palette</strong> (Edit mode): edits the current profiles palette swatches.</li>
<li>Use <strong>From Palette</strong> in the preset editor for colours that stay in sync (badge <strong>P</strong>).</li>
</ul>
</div>
<div id="help-panel-audio" class="help-tab-panel" data-help-panel="audio" role="tabpanel" aria-labelledby="help-tab-audio" hidden>
<div class="help-ui-preview" aria-hidden="true">
<div class="modal-content audio-modal-content help-preview-surface">
<div class="modal-head">
<h2>Audio Beat Detection</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
</div>
</div>
<div class="form-group audio-device-block">
<label>Input device</label>
<div class="profiles-actions audio-device-select-row">
<select tabindex="-1"><option>Monitor of Built-in Audio</option></select>
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Refresh</button>
</div>
</div>
<div class="form-group">
<label>Beat indicators</label>
<button type="button" class="audio-beat-sync-btn audio-modal-beat-sync" tabindex="-1">
<span class="audio-top-indicator-label">BPM</span>
<span class="audio-top-indicator-value">128</span>
</button>
</div>
<div class="form-group audio-volume-block">
<div class="audio-volume-header">
<label>Volume</label>
<span class="audio-volume-readout">100% (0.00 dB)</span>
</div>
<input type="range" class="audio-volume-slider" min="0" max="200" value="100" tabindex="-1">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-primary" tabindex="-1">Start</button>
<button type="button" class="btn btn-secondary" tabindex="-1">Stop</button>
</div>
</div>
</div>
<ul>
<li><strong>Audio</strong>: beat detection from a chosen input device (monitor sources follow playback).</li>
<li>BPM and beat indicators appear in the header and Audio modal while detection is running.</li>
<li>Adjust <strong>Volume</strong> (gain before detection); the level meter shows live input.</li>
<li><strong>Start</strong> / <strong>Stop</strong> detection; <strong>Reset detector</strong> clears stuck BPM tracking.</li>
<li>Sync sequences to music with <kbd>S</kbd> on a downbeat while a sequence plays.</li>
</ul>
</div>
<div id="help-panel-settings" class="help-tab-panel" data-help-panel="settings" role="tabpanel" aria-labelledby="help-tab-settings" hidden>
<div class="help-ui-preview" aria-hidden="true">
<div class="modal-content settings-modal-content help-preview-surface">
<div class="modal-head">
<h2>Settings</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
</div>
</div>
<div class="settings-tabs" role="tablist">
<button type="button" class="settings-tab-btn active" tabindex="-1">Bridge</button>
<button type="button" class="settings-tab-btn" tabindex="-1">LED Tool</button>
</div>
<div class="settings-section">
<span class="muted-text">USB serial: /dev/ttyUSB0 (connected)</span>
<h3 class="settings-subheading" style="margin-top:0.75rem;">Wi-Fi</h3>
<p class="muted-text" style="margin:0;">Bridge-AP — ws://192.168.4.1/ws</p>
</div>
</div>
</div>
<ul>
<li><strong>Settings</strong> (Edit mode): <strong>Bridge</strong> connects the Pi to ESP-NOW hardware over USB serial or WiFi.</li>
<li>Save bridge profiles, scan for the bridge AP, and check connection status.</li>
<li><strong>LED Tool</strong>: USB serial setup for drivers — <code>settings.json</code>, deploy, flash, and maintenance.</li>
<li>LED Tool configures devices directly; this UI controls profiles, zones, presets, and runtime messages.</li>
</ul>
</div>
</div> </div>
</div> </div>
<!-- Audio Modal --> <!-- Audio Modal -->
<div id="audio-modal" class="modal"> <div id="audio-modal" class="modal">
<div class="modal-content audio-modal-content"> <div class="modal-content audio-modal-content">
<h2>Audio Beat Detection</h2> <div class="modal-head">
<h2>Audio Beat Detection</h2>
<div class="modal-top-actions">
<button type="button" class="btn btn-secondary" id="audio-close-btn">Close</button>
</div>
</div>
<div class="form-group audio-device-block"> <div class="form-group audio-device-block">
<label for="audio-device-select">Input device</label> <label for="audio-device-select">Input device</label>
<div class="profiles-actions audio-device-select-row"> <div class="profiles-actions audio-device-select-row">
@@ -683,7 +1151,6 @@
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button> <button type="button" class="btn btn-primary" id="audio-start-btn">Start</button>
<button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button> <button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button>
<button type="button" class="btn btn-secondary" id="audio-reset-btn" disabled title="Clear stuck BPM / beat tracking">Reset detector</button> <button type="button" class="btn btn-secondary" id="audio-reset-btn" disabled title="Clear stuck BPM / beat tracking">Reset detector</button>
<button type="button" class="btn btn-secondary" id="audio-close-btn">Close</button>
</div> </div>
</div> </div>
</div> </div>
@@ -691,7 +1158,12 @@
<!-- Settings Modal --> <!-- Settings Modal -->
<div id="settings-modal" class="modal"> <div id="settings-modal" class="modal">
<div class="modal-content settings-modal-content"> <div class="modal-content settings-modal-content">
<h2>Settings</h2> <div class="modal-head">
<h2>Settings</h2>
<div class="modal-top-actions">
<button class="btn btn-secondary" id="settings-close-btn">Close</button>
</div>
</div>
<div class="settings-tabs" role="tablist" aria-label="Settings sections"> <div class="settings-tabs" role="tablist" aria-label="Settings sections">
<button type="button" class="settings-tab-btn active" role="tab" id="settings-tab-bridge" data-settings-tab="bridge" aria-selected="true" aria-controls="settings-panel-bridge">Bridge</button> <button type="button" class="settings-tab-btn active" role="tab" id="settings-tab-bridge" data-settings-tab="bridge" aria-selected="true" aria-controls="settings-panel-bridge">Bridge</button>
<button type="button" class="settings-tab-btn" role="tab" id="settings-tab-led-tool" data-settings-tab="led-tool" aria-selected="false" aria-controls="settings-panel-led-tool">LED Tool</button> <button type="button" class="settings-tab-btn" role="tab" id="settings-tab-led-tool" data-settings-tab="led-tool" aria-selected="false" aria-controls="settings-panel-led-tool">LED Tool</button>
@@ -778,9 +1250,6 @@
<iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" class="settings-led-tool-iframe"></iframe> <iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" class="settings-led-tool-iframe"></iframe>
</div> </div>
<div class="modal-actions">
<button class="btn btn-secondary" id="settings-close-btn">Close</button>
</div>
</div> </div>
</div> </div>