`;
@@ -1559,13 +1517,6 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to load zone');
}
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
let flat = [];
@@ -1686,11 +1637,13 @@ document.addEventListener('DOMContentLoaded', () => {
modal.className = 'modal active modal-child-overlay';
modal.innerHTML = `
-
Pick Palette Color
-
-
-
+
+
Pick Palette Color
+
+
+
+
`;
document.body.appendChild(modal);
@@ -1755,11 +1708,13 @@ document.addEventListener('DOMContentLoaded', () => {
modal.className = 'modal active modal-child-overlay';
modal.innerHTML = `
-
Pick background colour
-
-
-
+
+
Pick background colour
+
+
+
+
`;
document.body.appendChild(modal);
@@ -1866,33 +1821,16 @@ document.addEventListener('DOMContentLoaded', () => {
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);
- if (saved && typeof saved === 'object') {
- if (currentEditId) {
- // PUT returns the preset object directly; use the existing ID
- await sendPresetViaEspNow(currentEditId, saved, deviceNames, false, false, '2');
- } 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');
- }
+ if (!currentEditId && saved && typeof saved === 'object') {
+ const entries = Object.entries(saved);
+ if (entries.length > 0) {
+ currentEditId = entries[0][0];
}
- } else {
- // Fallback: send what we just built
- await sendPresetViaEspNow(currentEditId || payload.name, payload, deviceNames, false, false, '2');
}
await loadPresets();
- clearForm();
- closeEditor();
-
+
// Reload zone presets if we're in a zone view
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
if (leftPanel) {
@@ -2195,13 +2133,29 @@ async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPre
const selectedPresets = {};
// Store selected preset payload per zone for beat-trigger reliability.
const selectedPresetPayloads = {};
-// Run vs Edit for zone preset strip (in-memory only — each full page load starts in run mode)
-let presetUiMode = 'run';
+const PRESET_UI_MODE_STORAGE_KEY = 'led-controller-ui-mode';
+
+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 setPresetUiMode = (mode) => {
presetUiMode = mode === 'edit' ? 'edit' : 'run';
+ try {
+ localStorage.setItem(PRESET_UI_MODE_STORAGE_KEY, presetUiMode);
+ } catch (_) {
+ /* ignore quota / private mode */
+ }
};
const updateUiModeToggleButtons = () => {
@@ -2216,6 +2170,11 @@ const updateUiModeToggleButtons = () => {
document.body.classList.toggle('preset-ui-edit', mode === 'edit');
document.body.classList.toggle('preset-ui-run', mode === 'run');
};
+
+if (typeof document !== 'undefined' && document.body) {
+ updateUiModeToggleButtons();
+}
+
// Track if we're currently dragging a preset
let isDraggingPreset = false;
@@ -2273,13 +2232,7 @@ const savePresetGrid = async (zoneId, presetGrid) => {
throw new Error('Failed to load zone');
}
const tabData = await tabResponse.json();
- if (
- typeof window.zoneAllowsPresets === 'function' &&
- !window.zoneAllowsPresets(tabData, zoneId)
- ) {
- throw new Error('This zone is for sequences only.');
- }
-
+
// Store as 2D grid
tabData.presets = presetGrid;
// Also store as flat array for backward compatibility
@@ -2372,12 +2325,6 @@ const renderTabPresets = async (zoneId, options = {}) => {
}
const tabData = await tabResponse.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)
let presetGrid = tabData.presets;
@@ -2389,10 +2336,6 @@ const renderTabPresets = async (zoneId, options = {}) => {
// It's a flat array, convert to grid
presetGrid = arrayToGrid(presetGrid, 3);
}
- if (ck === 'sequences') {
- presetGrid = [];
- }
-
if (!presetsResponse.ok) {
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());
if (flatPresets.length === 0) {
- const empty = document.createElement('p');
- empty.className = 'muted-text';
- empty.style.gridColumn = '1 / -1'; // Span all columns
- if (ck === 'sequences') {
- if (!hasSeq) {
- empty.textContent =
- "No sequences on this zone yet. Open the zone's Edit menu to add one.";
- presetsList.appendChild(empty);
- }
- } else {
+ if (!hasSeq) {
+ const empty = document.createElement('p');
+ empty.className = 'muted-text';
+ empty.style.gridColumn = '1 / -1';
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);
}
} else {
@@ -2515,11 +2452,7 @@ const renderTabPresets = async (zoneId, options = {}) => {
});
}
- if (
- typeof window.appendZoneSequenceTiles === 'function' &&
- (typeof window.zoneAllowsSequences !== 'function' ||
- window.zoneAllowsSequences(tabData, zoneId))
- ) {
+ if (typeof window.appendZoneSequenceTiles === 'function') {
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
}
} catch (error) {
@@ -2760,14 +2693,7 @@ const removePresetFromTab = async (zoneId, presetId) => {
throw new Error('Failed to load zone');
}
const tabData = await tabResponse.json();
- if (
- typeof window.zoneAllowsPresets === 'function' &&
- !window.zoneAllowsPresets(tabData, zoneId)
- ) {
- alert('This zone is for sequences only.');
- return;
- }
-
+
// Normalize to flat array
let flat = [];
if (Array.isArray(tabData.presets_flat)) {
diff --git a/src/static/profiles.js b/src/static/profiles.js
index 710c19b..8e511d8 100644
--- a/src/static/profiles.js
+++ b/src/static/profiles.js
@@ -13,6 +13,9 @@ document.addEventListener("DOMContentLoaded", () => {
}
const isEditModeActive = () => {
+ if (typeof window.getPresetUiMode === 'function') {
+ return window.getPresetUiMode() === 'edit';
+ }
const toggle = document.querySelector('.ui-mode-toggle');
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
};
diff --git a/src/static/sequences.js b/src/static/sequences.js
index d5f7a4f..1f78d8b 100644
--- a/src/static/sequences.js
+++ b/src/static/sequences.js
@@ -510,13 +510,6 @@ async function addSequenceToTab(sequenceId, zoneId) {
const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!tabResponse.ok) throw new Error('Failed to load zone');
const tabData = await tabResponse.json();
- 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) : [];
if (list.includes(String(sequenceId))) {
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' } });
if (!zoneRes.ok) throw new Error('zone');
const zone = await zoneRes.json();
- if (
- typeof window.zoneAllowsSequences === 'function' &&
- !window.zoneAllowsSequences(zone, zoneId)
- ) {
- currentEl.innerHTML =
- '
This zone is for presets only. Sequences are hidden.';
- addEl.innerHTML = '
—';
- return;
- }
const onZone = Array.isArray(zone.sequence_ids) ? zone.sequence_ids.map(String) : [];
const seqMap = await fetchSequencesMap();
const onSet = new Set(onZone);
@@ -600,11 +584,7 @@ async function refreshEditTabSequencesUi(zoneId) {
const sdoc = seqMap[sid] || {};
const name = sdoc.name || sid;
const row = document.createElement('div');
- row.className = 'profiles-row';
- row.style.display = 'flex';
- row.style.justifyContent = 'space-between';
- row.style.alignItems = 'center';
- row.style.gap = '0.5rem';
+ row.className = 'profiles-row edit-zone-item-row';
const span = document.createElement('span');
span.textContent = `${name} — ${sid}`;
const rm = document.createElement('button');
@@ -1081,9 +1061,16 @@ async function saveSequenceEditor() {
const err = await res.json().catch(() => ({}));
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();
const zid = resolveZoneIdForPresetStripRefresh();
if (zid && typeof window.refreshEditTabSequencesUi === 'function') {
@@ -1164,31 +1151,12 @@ async function loadSequencesModalList() {
const nSteps = ln.reduce((a, l) => a + l.length, 0);
const nLanes = ln.filter((l) => l.length > 0).length || 1;
title.textContent = `${doc.name || id} — ${nLanes} lane(s), ${nSteps} step(s)`;
- const exportBtn = document.createElement('button');
- exportBtn.type = 'button';
- exportBtn.className = 'btn btn-secondary btn-small';
- exportBtn.textContent = 'Export';
- exportBtn.addEventListener('click', async () => {
- try {
- const response = await fetch(`/sequences/${id}/export`, {
- headers: { Accept: 'application/json' },
- });
- if (!response.ok) throw new Error('Export failed');
- const bundle = await response.json();
- const safeName = String(doc.name || id).replace(/[^\w.-]+/g, '_');
- window.downloadJsonFile(`sequence-${safeName}.json`, bundle);
- } catch (e) {
- console.error(e);
- alert('Failed to export sequence.');
- }
- });
const edit = document.createElement('button');
edit.type = 'button';
edit.className = 'btn btn-secondary btn-small';
edit.textContent = 'Edit';
edit.addEventListener('click', () => openSequenceEditor(id, doc));
row.appendChild(title);
- row.appendChild(exportBtn);
row.appendChild(edit);
listEl.appendChild(row);
});
@@ -1227,33 +1195,6 @@ document.addEventListener('DOMContentLoaded', () => {
openSequenceEditor(null, null);
});
}
- const importSeqBtn = document.getElementById('import-sequence-btn');
- if (importSeqBtn) {
- importSeqBtn.addEventListener('click', async () => {
- const text = await window.pickJsonFile();
- if (!text) return;
- const bundle = window.parseJsonFileText(text);
- if (!bundle || bundle.kind !== 'sequence') {
- alert('Invalid sequence bundle file.');
- return;
- }
- try {
- const response = await fetch('/sequences/import', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
- body: JSON.stringify({ bundle }),
- });
- if (!response.ok) {
- const err = await response.json().catch(() => ({}));
- throw new Error(err.error || 'Import failed');
- }
- await loadSequencesModalList();
- } catch (e) {
- console.error(e);
- alert(e.message || 'Failed to import sequence.');
- }
- });
- }
const openPresetsFromSeq = document.getElementById('sequences-open-presets-btn');
if (openPresetsFromSeq) {
openPresetsFromSeq.addEventListener('click', () => {
diff --git a/src/static/style.css b/src/static/style.css
index 102d8ff..ca26011 100644
--- a/src/static/style.css
+++ b/src/static/style.css
@@ -125,6 +125,68 @@ header h1 {
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 {
display: none;
position: relative;
@@ -1444,6 +1506,29 @@ body.preset-ui-run .edit-mode-only {
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 {
display: block;
margin-top: 1rem;
@@ -1504,7 +1589,7 @@ body.preset-ui-run .edit-mode-only {
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 {
display: none;
}
@@ -1540,17 +1625,28 @@ body.preset-ui-run .edit-mode-only {
transform: translateY(-50%);
}
+ .zones-menu-mobile {
+ display: flex;
+ margin-right: auto;
+ }
+
+ .zones-container {
+ display: none;
+ }
+
.header-menu-mobile {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.35rem;
margin-top: 0;
+ margin-left: auto;
}
.header-end {
gap: 0.35rem;
flex-shrink: 0;
+ flex-wrap: nowrap;
}
.header-end .audio-top-indicator {
@@ -1569,12 +1665,6 @@ body.preset-ui-run .edit-mode-only {
padding: 0.4rem 0.7rem;
}
- .zones-container {
- padding: 0.35rem 0 0;
- border-bottom: none;
- width: 100%;
- }
-
.zone-content {
padding: 0.5rem;
}
@@ -1689,6 +1779,39 @@ body.preset-ui-run .edit-mode-only {
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 {
flex-wrap: wrap;
align-items: center;
@@ -1774,6 +1897,65 @@ body.preset-ui-run .edit-mode-only {
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 {
margin-top: 0;
flex-wrap: wrap;
@@ -1791,6 +1973,11 @@ body.preset-ui-run .edit-mode-only {
overflow-y: auto;
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 */
#palette-container .profiles-row {
font-size: 0; /* Hide any text nodes */
@@ -1871,16 +2058,205 @@ body.preset-ui-run .edit-mode-only {
}
}
/* Help modal readability */
-#help-modal .modal-content {
- max-width: 720px;
+#help-modal .modal-content,
+#help-modal .help-modal-content {
+ max-width: 840px;
+ width: 95vw;
line-height: 1.6;
font-size: 0.95rem;
}
-#help-modal .modal-content h2 {
+#help-modal .modal-head {
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 {
- margin-top: 1.25rem;
+ margin-top: 1rem;
margin-bottom: 0.4rem;
font-size: 1.05rem;
font-weight: 600;
diff --git a/src/static/zone-devices-panel.js b/src/static/zone-devices-panel.js
index 637b529..8eacfd5 100644
--- a/src/static/zone-devices-panel.js
+++ b/src/static/zone-devices-panel.js
@@ -22,6 +22,101 @@ function prepareZoneDevicesPanel(containerEl) {
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') {
window.prepareZoneDevicesPanel = prepareZoneDevicesPanel;
+ window.createSearchableAddPicker = createSearchableAddPicker;
}
diff --git a/src/static/zones.js b/src/static/zones.js
index a613c2e..21a1989 100644
--- a/src/static/zones.js
+++ b/src/static/zones.js
@@ -127,6 +127,9 @@ function sendZoneBrightness(zoneId, value) {
}
const isEditModeActive = () => {
+ if (typeof window.getPresetUiMode === 'function') {
+ return window.getPresetUiMode() === 'edit';
+ }
const toggle = document.querySelector('.ui-mode-toggle');
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
};
@@ -534,27 +537,30 @@ function effectiveZoneContentKind(zoneDoc) {
/** @returns {boolean} */
function zoneAllowsPresets(zoneDoc, zoneId) {
+ void zoneDoc;
void zoneId;
- return effectiveZoneContentKind(zoneDoc) === 'presets';
+ return true;
}
/** @returns {boolean} */
function zoneAllowsSequences(zoneDoc, zoneId) {
+ void zoneDoc;
void zoneId;
- return effectiveZoneContentKind(zoneDoc) === 'sequences';
+ return true;
}
-function applyZoneContentKindEditModal(kind) {
+function applyZoneContentKindEditModal(_kind) {
const presetsBlock = document.getElementById('edit-zone-block-presets');
const groupsBlock = document.getElementById('edit-zone-block-groups');
const seqBlock = document.getElementById('edit-zone-block-sequences');
+ const typeLabel = document.getElementById('edit-zone-type-label');
const vis = (el, show) => {
if (el) el.style.display = show ? '' : 'none';
};
- const k = kind === 'sequences' ? 'sequences' : 'presets';
+ if (typeLabel) typeLabel.style.display = 'none';
vis(groupsBlock, true);
- vis(presetsBlock, k === 'presets');
- vis(seqBlock, k === 'sequences');
+ vis(presetsBlock, true);
+ vis(seqBlock, true);
}
window.normalizeZoneContentKind = normalizeZoneContentKind;
@@ -632,6 +638,52 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
}
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 = '';
+ 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 += `
+
+ `;
+ }
+ 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)
@@ -673,14 +725,6 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
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");
editButton.className = "btn btn-secondary btn-small";
editButton.textContent = "Edit";
@@ -771,7 +815,6 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
});
row.appendChild(label);
- row.appendChild(applyButton);
if (editMode) {
row.appendChild(editButton);
row.appendChild(cloneButton);
@@ -819,11 +862,12 @@ async function selectZone(zoneId) {
document.querySelectorAll('.zone-button').forEach(btn => {
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) {
btn.classList.add('active');
}
-
+ syncZonesMenuSelection(zoneId);
+
// Set as current zone
await setCurrentZone(zoneId);
// Load zone content
@@ -931,12 +975,6 @@ async function refreshEditTabPresetsUi(zoneId) {
return;
}
const tabData = await tabRes.json();
- if (!zoneAllowsPresets(tabData, zoneId)) {
- currentEl.innerHTML =
- '
This zone is for sequences only. Presets are hidden.';
- addEl.innerHTML = '
—';
- return;
- }
const inTabIds = tabPresetIdsInOrder(tabData);
const inTabSet = new Set(inTabIds.map((id) => String(id)));
@@ -960,12 +998,9 @@ async function refreshEditTabPresetsUi(zoneId) {
for (const presetId of inTabIds) {
const preset = allPresets[presetId] || {};
const name = preset.name || presetId;
- const block = document.createElement("div");
- block.style.cssText =
- "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 row = makeRow();
+ row.className = "profiles-row edit-zone-item-row";
const label = document.createElement("span");
- label.style.fontWeight = "600";
label.textContent = name;
const removeBtn = document.createElement("button");
removeBtn.type = "button";
@@ -977,11 +1012,9 @@ async function refreshEditTabPresetsUi(zoneId) {
await window.removePresetFromTab(zoneId, presetId);
await refreshEditTabPresetsUi(zoneId);
});
- top.appendChild(label);
- top.appendChild(removeBtn);
- block.appendChild(top);
-
- currentEl.appendChild(block);
+ row.appendChild(label);
+ row.appendChild(removeBtn);
+ currentEl.appendChild(row);
}
}
@@ -1069,17 +1102,8 @@ async function openEditZoneModal(zoneId, zone) {
});
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");
- applyZoneContentKindEditModal(kind);
+ applyZoneContentKindEditModal();
await refreshEditTabPresetsUi(zoneId);
if (typeof window.refreshEditTabSequencesUi === "function") {
await window.refreshEditTabSequencesUi(zoneId);
@@ -1104,7 +1128,6 @@ async function updateZone(zoneId, name, groupRows) {
} catch (_) {
/* use empty existing */
}
- const lockedKind = effectiveZoneContentKind(existing);
const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
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
: {},
- content_kind: lockedKind,
})
});
@@ -1131,8 +1153,6 @@ async function updateZone(zoneId, name, groupRows) {
if (String(currentZoneId) === String(zoneId)) {
await loadZoneContent(zoneId);
}
- // Close modal
- document.getElementById('edit-zone-modal').classList.remove('active');
return true;
} else {
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'``.
-async function createZone(name, contentKind) {
+// Create a new zone (add device groups, presets, and sequences in Edit zone).
+async function createZone(name) {
try {
- const ck =
- contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
const response = await fetch('/zones', {
method: 'POST',
headers: {
@@ -1159,7 +1177,6 @@ async function createZone(name, contentKind) {
name: name,
names: [],
group_ids: [],
- content_kind: ck,
})
});
@@ -1196,6 +1213,29 @@ document.addEventListener('DOMContentLoaded', () => {
const newTabNameInput = document.getElementById("new-zone-name");
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) {
tabsButton.addEventListener("click", async () => {
zonesModal.classList.add("active");
@@ -1240,12 +1280,7 @@ document.addEventListener('DOMContentLoaded', () => {
const name = newTabNameInput.value.trim();
if (name) {
- const kindRadio = document.querySelector(
- 'input[name="new-zone-content-kind"]:checked',
- );
- const contentKind =
- kindRadio && kindRadio.value === 'sequences' ? 'sequences' : 'presets';
- await createZone(name, contentKind);
+ await createZone(name);
if (newTabNameInput) newTabNameInput.value = "";
}
};
@@ -1276,7 +1311,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (zoneId && name) {
await updateZone(zoneId, name, groupRows);
- editZoneForm.reset();
}
});
}
diff --git a/src/templates/index.html b/src/templates/index.html
index b709317..5f85652 100644
--- a/src/templates/index.html
+++ b/src/templates/index.html
@@ -10,6 +10,10 @@
-
+
-
Edit Zone
+
+
Edit Zone
+
+
+
+
+
@@ -143,7 +146,12 @@
-
Profiles
+
@@ -156,16 +164,18 @@
-
-
-
@@ -194,7 +203,12 @@
-
Device groups
+
+
Device groups
+
+
+
+
Assign drivers to a group, set Wi‑Fi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lane’s groups only). By default new groups are shared across all profiles; tick “this profile only” to hide a group from other profiles.
@@ -204,15 +218,18 @@
-
-
-
-
Edit device group
+
+
Edit device group
+
+
+
+
+
-
Wi‑Fi driver defaults (apply to all members via Apply defaults to drivers on the list)
-
-
-
-
-
-
-
-
-
-
Stored row and the JSON preview for Save (updates as you edit).
-
-
-
-
-
-
Edit device
+
+
Edit device
+
+
+
+
+
@@ -330,39 +322,48 @@
-
Presets
+
-
-
-
-
Sequences
+
+
Sequences
+
+
+
+
-
-
-
-
-
Sequence
+
+
Sequence
+
+
+
+
+
@@ -394,7 +393,13 @@
-
Preset
+
+
Preset
+
+
+
+
+
@@ -494,22 +497,30 @@
-
Patterns
+
-
-
-
-
Pattern
+
+
Pattern
+
+
+
+
+
Add a driver .py file and editor metadata (stored in the pattern database).
@@ -572,8 +583,6 @@
Overwrite existing file
-
-
@@ -581,63 +590,522 @@
-
Colour Palette
+
+
Colour Palette
+
+
+
+
Profile: None
-
-
-
-
-
Help
-
How to use the LED controller UI.
-
-
Run mode
-
- - Select zone: left-click a zone button in the top bar.
- - Select preset: left-click a preset tile to send a
select message to all devices in the zone.
- - Profiles: open Profiles to apply a profile. Profile editing actions are hidden in Run mode.
- - Devices: open Devices to see drivers (Wi-Fi clients appear when they connect); edit addresses or remove rows.
- - Groups: define device groups, Wi‑Fi driver defaults, then assign groups to zones.
- - Send all presets: this action is available in Edit mode and pushes every preset used in the current zone to all zone devices.
- - Switch modes: use the mode button in the menu. The button label shows the mode you will switch to.
-
-
-
Edit mode
-
- - Tabs: create, edit, and manage zones and which device groups each zone drives.
- - Presets: create/manage reusable presets and edit preset details.
- - Preset tiles: each tile shows Edit and Remove controls in Edit mode.
- - Reorder presets: drag and drop preset tiles to save zone order.
- - Profiles: create/clone/delete profiles. New profiles get a populated default zone and can optionally seed a DJ zone.
- - Devices: registry rows are keyed by MAC; edit a device for transport/IP and per-driver Wi‑Fi settings, or use Groups for shared defaults.
- - Colour Palette: build profile colours and use From Palette in preset editor to add linked colours (badge P) that update when palette colours change.
-
-
-
LED Tool (Settings tab)
-
- - USB device setup: updates
settings.json on ESP32 drivers over serial (for example name, pin, LED count, Wi-Fi credentials).
- - Deploy and maintenance: uploads driver files, flashes firmware, resets device, and follows serial logs.
- - Scope: led-tool configures devices directly; this web UI controls profiles/zones/presets and sends runtime messages. Open Settings → LED Tool in Edit mode.
-
-
-
-
+
+
+
How to use the LED controller UI. Previews use the same styles as the live interface.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Zone buttons below the header; management buttons on the right (Edit mode).
+
Run mode and Edit mode
+
+ - Run mode: day-to-day control — choose a zone, tap presets, apply profiles. Management buttons are hidden.
+ - Edit mode: full setup — zones, presets, sequences, patterns, colour palette, and per-tile Edit on the strip.
+ - Switch modes: use the mode button in the header or mobile menu. The label shows the mode you will switch to.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Click a preset tile to select it on all devices in the zone.
+
+ - Select zone: click a zone button in the top bar.
+ - Brightness: the header slider adjusts global brightness for the current zone.
+ - Edit mode: drag preset tiles to reorder; use Edit and Remove on each tile.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
On narrow screens, Menu reaches the same actions as the desktop header.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ✓ House default
+
+
+
+
+
+
+
+
+ Garden party
+
+
+
+
+
+
+
+
+
+
+
+ - Apply: sets the current profile. Zones and presets you see are scoped to that profile.
+ - Create (Edit mode): new profiles get a populated default zone. Optionally tick DJ zone for a starter
dj zone.
+ - Clone / Delete: available in Edit mode from the profile list.
+ - In Run mode you can only apply profiles; create, clone, and delete are hidden.
+
+
+
+
+
+
+
+
+
+
+ lounge strip
+ AA:BB:CC:DD:EE:01
+ led · espnow · —
+
+
+
+
+
+ ceiling
+ AA:BB:CC:DD:EE:02
+ led · espnow · —
+
+
+
+
+
+
+
+
+
+
+
+ - Devices (Edit mode): registry of LED drivers keyed by MAC.
+ - ESP-NOW devices appear automatically after ANNOUNCE; you can also add rows manually.
+ - Identify: short red blink (~2 s) so you can spot hardware.
+ - Update groups: pushes group membership from device groups to ESP-NOW drivers.
+ - Edit a device for transport, IP, and per-driver settings; use Groups for shared Wi‑Fi defaults.
+
+
+
+
+
+
+
+
Device groups
+
+
+
+
+
+
+
+
+
+
+
+
lounge lights (3 devices)
+
Shared across profiles
+
+
+
+
+
+
+
+
+
dj booth (2 devices)
+
This profile only
+
+
+
+
+
+
+
+
+
+
+ - Assign drivers to a group, set Wi‑Fi defaults once per group, then attach groups to a zone.
+ - Standalone presets use the zone’s device groups. Sequence lanes each target their own group.
+ - New groups are shared across profiles by default; tick this profile only to hide a group elsewhere.
+ - In the group editor, search and pick devices from the list to add members; Identify devices in group blinks them together.
+
+
+
+
+
+
+
+
Edit Zone
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - Zones (Edit mode): create and manage zones from the header Zones button.
+ - Each zone lists device groups, presets, and sequences — presets and sequences can share the same zone.
+ - Drag presets on the main strip or in the zone editor to reorder.
+ - Right-click a zone button for quick access to zone settings.
+
+
+
+
+
+
+
+
Preset
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - Presets (Edit mode): profile-wide list — Add, Edit, Send, and Delete.
+ - Pattern and optional n1–n8 fields depend on the pattern.
+ - From Palette: inserts a colour linked to the profile palette (badge P).
+ - Try: previews on the current zone without saving on the device.
+ - Save: writes the preset to the server (does not close the editor).
+ - Send: pushes the definition to devices with save.
+ - Remove from zone (when opened from a zone): removes from this zone only.
+
+
+
+
+
+
+
+
Sequence
+
+
+
+
+
+
+
+
+
+
Lane 1 — lounge lights
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - Sequences (Edit mode): build multi-step shows with one or more lanes (each lane targets a device group).
+ - Add presets as steps per lane; open from the zone editor to attach a sequence to a zone.
+ - Beat / Downbeat toggle (header): when starting a sequence, wait for beat or downbeat before step 1.
+ - Tap S or the BPM button during playback to sync step timing to music (with Audio running).
+
+
+
+
+
+
+
+
+
+
+
+
+
pulsedelay 20–200 ms
+
rainbowdelay 10–80 ms
+
+
+
+
+ - Patterns (Edit mode): reference list of pattern names and typical delay ranges.
+ - Choose the pattern inside the preset editor; parameters map to n1–n8.
+ - Wi‑Fi drivers can install pattern modules over HTTP (OTA upload); ESP-NOW devices use the bridge you configure in Settings.
+
+
+
+
+
+
+
+
Colour Palette
+
+
+
+
+
Profile: House default
+
+
+
+
+
+
+
Add or change swatches; linked preset colours update automatically.
+
+ - Colour Palette (Edit mode): edits the current profile’s palette swatches.
+ - Use From Palette in the preset editor for colours that stay in sync (badge P).
+
+
+
+
+
+
+
+
Audio Beat Detection
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - Audio: beat detection from a chosen input device (monitor sources follow playback).
+ - BPM and beat indicators appear in the header and Audio modal while detection is running.
+ - Adjust Volume (gain before detection); the level meter shows live input.
+ - Start / Stop detection; Reset detector clears stuck BPM tracking.
+ - Sync sequences to music with S on a downbeat while a sequence plays.
+
+
+
+
+
+
+
+
+
+
+
+
+
USB serial: /dev/ttyUSB0 (connected)
+
Wi-Fi
+
Bridge-AP — ws://192.168.4.1/ws
+
+
+
+
+ - Settings (Edit mode): Bridge connects the Pi to ESP-NOW hardware over USB serial or Wi‑Fi.
+ - Save bridge profiles, scan for the bridge AP, and check connection status.
+ - LED Tool: USB serial setup for drivers —
settings.json, deploy, flash, and maintenance.
+ - LED Tool configures devices directly; this UI controls profiles, zones, presets, and runtime messages.
+
+
+
-
Audio Beat Detection
+
+
Audio Beat Detection
+
+
+
+
@@ -691,7 +1158,12 @@
-
Settings
+
@@ -778,9 +1250,6 @@
-
-
-