Add settings tooltips and search to the web UI

Help users quickly find settings and understand how each one works.

Tooltips: a new delegated controller (static/v3/js/tooltips.js) drives an
accessible (i) info tooltip that appears on hover, keyboard focus, and tap.
A shared `help_tip` Jinja macro (partials/_macros.html) emits the trigger;
the plugin config macro and the core settings partials now surface help
text through it. Per the design, the always-visible field help paragraphs
are folded into the tooltip to declutter the forms, and the hardware/display
settings carry authored detail (default, range, recommendation).

Search: a global header search box finds settings across every settings tab
— even ones not yet opened — via a lazy client-side index built by scanning
the same field markup (static/v3/js/settings-search.js). Selecting a result
switches tabs, waits for the field to load, then scrolls to and flashes it.
A per-tab filter box hides non-matching fields on the current tab.

Plugin settings get tooltips for free by reusing each field's schema
`description`; every settings field also gets a stable `setting-<tab>-<key>`
anchor id for search navigation.

Styling uses the existing --color-* theme vars so light/dark mode both work,
and honors prefers-reduced-motion. Adds Flask render smoke tests that assert
each settings partial ships tooltips, anchors, and a filter box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz
This commit is contained in:
Claude
2026-07-08 15:01:19 +00:00
parent 63a233f3ed
commit 58a28f3a14
13 changed files with 1044 additions and 154 deletions
+117
View File
@@ -0,0 +1,117 @@
"""
Smoke tests for the settings tooltips + search UI.
These render the settings partials through Flask and assert that every settings
field carries:
- a stable search anchor id (`id="setting-..."` on its .form-group), and
- an info tooltip (`class="help-tip"` emitted by the help_tip macro).
They guard against macro/import breakage and against fields losing their anchor
or tooltip when partials are edited. See web_interface/static/v3/js/tooltips.js
and settings-search.js for the consumers of this markup.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
# A realistic-enough config so the hand-written partials render every field.
REALISTIC_CONFIG = {
"web_display_autostart": True,
"timezone": "America/Chicago",
"location": {"city": "Dallas", "state": "Texas", "country": "US"},
"plugin_system": {
"auto_discover": True,
"auto_load_enabled": True,
"development_mode": False,
"plugins_directory": "plugin-repos",
},
"schedule": {},
"dim_schedule": {"dim_brightness": 30},
"sync": {"role": "standalone", "port": 5765, "follower_position": "left"},
"display": {
"hardware": {
"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1,
"brightness": 95, "hardware_mapping": "adafruit-hat-pwm",
"led_rgb_sequence": "RGB", "multiplexing": 0, "panel_type": "",
"row_address_type": 0, "scan_mode": 0, "pwm_bits": 9,
"pwm_dither_bits": 1, "pwm_lsb_nanoseconds": 130,
"limit_refresh_rate_hz": 120, "disable_hardware_pulsing": False,
"inverse_colors": False, "show_refresh_rate": False,
},
"runtime": {"gpio_slowdown": 3, "rp1_rio": 0},
"double_sided": {"enabled": False, "copies": 2, "axis": "horizontal"},
"use_short_date_format": False,
"dynamic_duration": {"max_duration_seconds": 180},
"vegas_scroll": {
"enabled": False, "scroll_speed": 50, "separator_width": 32,
"target_fps": 125, "buffer_ahead": 2,
"plugin_order": [], "excluded_plugins": [],
},
"display_durations": {"clock": 15, "weather": 30},
},
}
@pytest.fixture
def client():
base = PROJECT_ROOT / "web_interface"
app = Flask(
__name__,
template_folder=str(base / "templates"),
static_folder=str(base / "static"),
)
app.config["TESTING"] = True
from web_interface.blueprints import pages_v3 as pv
mock_cm = MagicMock()
mock_cm.load_config.return_value = REALISTIC_CONFIG
mock_cm.get_raw_file_content.return_value = REALISTIC_CONFIG
mock_cm.get_config_path.return_value = "config/config.json"
mock_cm.get_secrets_path.return_value = "config/config_secrets.json"
pv.pages_v3.config_manager = mock_cm
pv.pages_v3.plugin_manager = MagicMock(plugins={})
app.register_blueprint(pv.pages_v3, url_prefix="/v3")
return app.test_client()
# Settings tabs that must expose searchable, tooltipped fields.
SETTINGS_TABS = ["general", "display", "durations", "schedule", "wifi"]
@pytest.mark.parametrize("tab", SETTINGS_TABS)
def test_settings_partial_has_tooltips_and_anchors(client, tab):
resp = client.get(f"/v3/partials/{tab}")
assert resp.status_code == 200, f"{tab} partial failed to render"
body = resp.get_data(as_text=True)
assert 'class="help-tip"' in body, f"{tab}: no tooltips rendered"
assert 'id="setting-' in body, f"{tab}: no search anchors rendered"
# Every settings field should be both anchored and tooltipped; tooltip count
# should not exceed anchor count (each field has at most one help_tip).
anchors = body.count('id="setting-')
tips = body.count('class="help-tip"')
assert tips >= 1 and anchors >= 1
assert tips <= anchors, f"{tab}: more tooltips ({tips}) than anchors ({anchors})"
@pytest.mark.parametrize("tab", SETTINGS_TABS)
def test_settings_partial_has_per_tab_filter(client, tab):
body = client.get(f"/v3/partials/{tab}").get_data(as_text=True)
assert 'class="settings-filter' in body, f"{tab}: per-tab filter box missing"
def test_display_tooltip_carries_rich_text(client):
# The brightness tooltip should include the authored guidance, not just a label.
body = client.get("/v3/partials/display").get_data(as_text=True)
assert 'id="setting-display-brightness"' in body
assert "Recommended:" in body # rich detail authored into a tooltip
+145
View File
@@ -766,6 +766,151 @@ button.bg-white {
} }
} }
/* ============================================================================ */
/* Settings tooltips (help_tip macro + tooltips.js) */
/* ============================================================================ */
/* The (i) info trigger placed next to a setting label. */
.help-tip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
margin-left: 0.375rem;
padding: 0;
border: none;
background: transparent;
color: var(--color-text-tertiary);
font-size: 0.8125rem;
line-height: 1;
cursor: help;
vertical-align: middle;
border-radius: 9999px;
transition: color 0.12s ease;
}
.help-tip:hover,
.help-tip:focus-visible {
color: var(--color-primary);
}
.help-tip:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Singleton tooltip panel appended to <body> by tooltips.js. */
#ledm-tooltip {
position: fixed;
z-index: 1000;
max-width: 20rem;
padding: 0.5rem 0.75rem;
background: var(--color-surface);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: var(--shadow-lg);
font-size: 0.8125rem;
line-height: 1.45;
white-space: pre-line; /* renders authored "\n" line breaks */
pointer-events: none; /* never steals hover/click from the page */
animation: tooltipFade 0.12s ease;
}
#ledm-tooltip[hidden] {
display: none;
}
@keyframes tooltipFade {
from { opacity: 0; }
to { opacity: 1; }
}
/* ============================================================================ */
/* Settings search — global header dropdown + per-tab filter */
/* ============================================================================ */
#settings-search-results {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: var(--shadow-lg);
z-index: 50;
padding: 0.25rem;
}
.ssr-group {
padding: 0.375rem 0.625rem 0.25rem;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-tertiary);
position: sticky;
top: 0;
background: var(--color-surface);
}
.ssr-option {
display: flex;
flex-direction: column;
width: 100%;
text-align: left;
padding: 0.4rem 0.625rem;
border: none;
background: transparent;
border-radius: 0.375rem;
cursor: pointer;
color: var(--color-text-primary);
}
.ssr-option:hover,
.ssr-option.active {
background: var(--color-info-bg);
}
.ssr-label {
font-size: 0.875rem;
font-weight: 500;
}
.ssr-help {
font-size: 0.75rem;
color: var(--color-text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.ssr-empty {
padding: 0.75rem 0.625rem;
font-size: 0.8125rem;
color: var(--color-text-tertiary);
}
/* Flash highlight applied to a field after search navigation. */
@keyframes settingFlash {
0% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
25% { box-shadow: 0 0 0 3px var(--color-primary); }
100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
}
.setting-flash {
animation: settingFlash 1.4s ease;
border-radius: 0.5rem;
}
@media (prefers-reduced-motion: reduce) {
#ledm-tooltip { animation: none; }
.setting-flash {
animation: none;
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
/* Removed .divider and .divider-light - not used anywhere */ /* Removed .divider and .divider-light - not used anywhere */
/* Enhanced Spacing Utilities - Only unique classes not in main utility section */ /* Enhanced Spacing Utilities - Only unique classes not in main utility section */
@@ -0,0 +1,428 @@
/*
* settings-search.js — global settings search + per-tab filter for the v3 UI.
*
* Two features share one lightweight index built from the same markup the
* tooltip work standardizes (.form-group[id^="setting-"] + <label> +
* .help-tip[data-tooltip]), so it can never drift from what is rendered:
*
* 1. Global search (header box): finds settings across ALL tabs, even ones
* not yet opened, by fetching each tab's partial once and scanning it.
* Clicking a result switches to the tab, waits for the field to load,
* then scrolls to and flashes it.
* 2. Per-tab filter (the .settings-filter box under a partial title):
* hides non-matching fields on the current tab. Delegated, so it keeps
* working across HTMX swaps.
*
* No backend endpoint is required — plugins are enumerated from
* window.installedPlugins and their config partials are fetched the same way.
*/
(function () {
'use strict';
if (window._settingsSearchInit) return;
window._settingsSearchInit = true;
// Core settings tabs to index. Only tabs that contain real settings fields
// (a .form-group[id^="setting-"]) are listed. Operational/action tabs
// (overview, logs, history, cache, tools, fonts, backup/restore, raw config
// editor, plugin store) have nothing to navigate to and are excluded.
var CORE_TABS = [
{ tab: 'general', label: 'General', url: '/v3/partials/general' },
{ tab: 'display', label: 'Display', url: '/v3/partials/display' },
{ tab: 'schedule', label: 'Schedule', url: '/v3/partials/schedule' },
{ tab: 'wifi', label: 'WiFi', url: '/v3/partials/wifi' }
];
var MAX_RESULTS = 25;
function debounce(fn, ms) {
var t;
return function () {
var args = arguments, ctx = this;
clearTimeout(t);
t = setTimeout(function () { fn.apply(ctx, args); }, ms);
};
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function textOf(el) {
return (el && el.textContent ? el.textContent : '').replace(/\s+/g, ' ').trim();
}
// --- Index building -------------------------------------------------------
// Extract one index entry per settings field from a parsed document.
function scanDoc(doc, tab, tabLabel) {
var entries = [];
var nodes = doc.querySelectorAll('h2, h3, h4, .form-group[id^="setting-"]');
var section = '';
nodes.forEach(function (node) {
var tag = node.tagName.toLowerCase();
if (tag === 'h3' || tag === 'h4') {
section = textOf(node);
return;
}
if (tag === 'h2') { section = ''; return; }
// A settings .form-group
var labelEl = node.querySelector('label');
var label = textOf(labelEl) || node.id.replace(/^setting-/, '');
var tipEl = node.querySelector('.help-tip');
var help = tipEl ? (tipEl.getAttribute('data-tooltip') || '') : '';
var key = node.getAttribute('data-setting-key') || node.id.replace(/^setting-/, '');
entries.push({
tab: tab,
tabLabel: tabLabel,
section: section,
key: key,
label: label,
help: help,
anchorId: node.id,
hay: [label, help, key, tabLabel, section].join(' ').toLowerCase()
});
});
return entries;
}
function fetchAndScan(url, tab, tabLabel) {
return fetch(url, { headers: { 'X-Requested-With': 'settings-search' } })
.then(function (r) { return r.ok ? r.text() : ''; })
.then(function (html) {
if (!html) return [];
var doc = new DOMParser().parseFromString(html, 'text/html');
return scanDoc(doc, tab, tabLabel);
})
.catch(function () { return []; });
}
var buildPromise = null;
function buildIndex(force) {
if (window._settingsIndex && !force) return Promise.resolve(window._settingsIndex);
if (buildPromise && !force) return buildPromise;
var jobs = CORE_TABS.map(function (t) { return fetchAndScan(t.url, t.tab, t.label); });
var plugins = (window.installedPlugins || []);
plugins.forEach(function (p) {
if (!p || !p.id) return;
jobs.push(fetchAndScan('/v3/partials/plugin-config/' + encodeURIComponent(p.id),
p.id, p.name || p.id));
});
buildPromise = Promise.all(jobs).then(function (lists) {
var index = [];
lists.forEach(function (l) { index = index.concat(l); });
window._settingsIndex = index;
return index;
});
return buildPromise;
}
// --- Global search UI -----------------------------------------------------
var input = null, resultsBox = null, activeIndex = -1, currentResults = [];
function search(q) {
q = q.trim().toLowerCase();
if (!q) return [];
var terms = q.split(/\s+/);
var index = window._settingsIndex || [];
var out = [];
for (var i = 0; i < index.length && out.length < MAX_RESULTS; i++) {
var hay = index[i].hay;
var ok = true;
for (var j = 0; j < terms.length; j++) {
if (hay.indexOf(terms[j]) === -1) { ok = false; break; }
}
if (ok) out.push(index[i]);
}
return out;
}
function renderResults(results) {
currentResults = results;
activeIndex = -1;
if (!results.length) {
resultsBox.innerHTML = '<div class="ssr-empty">No settings found.</div>';
openResults();
return;
}
var html = '';
var lastTab = null;
results.forEach(function (r, i) {
if (r.tabLabel !== lastTab) {
html += '<div class="ssr-group">' + escapeHtml(r.tabLabel) + '</div>';
lastTab = r.tabLabel;
}
var sub = r.section ? (r.section + ' · ') : '';
var snippet = r.help ? r.help.split('\n')[0] : '';
html += '<button type="button" class="ssr-option" role="option" id="ssr-' + i + '" data-idx="' + i + '">' +
'<span class="ssr-label">' + escapeHtml(r.label) + '</span>' +
(snippet ? '<span class="ssr-help">' + escapeHtml(sub + snippet) + '</span>'
: (sub ? '<span class="ssr-help">' + escapeHtml(r.section) + '</span>' : '')) +
'</button>';
});
resultsBox.innerHTML = html;
openResults();
}
function openResults() {
resultsBox.classList.remove('hidden');
if (input) input.setAttribute('aria-expanded', 'true');
}
function closeResults() {
resultsBox.classList.add('hidden');
activeIndex = -1;
if (input) {
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
}
}
function highlight(idx) {
var opts = resultsBox.querySelectorAll('.ssr-option');
opts.forEach(function (o) { o.classList.remove('active'); });
if (idx < 0 || idx >= opts.length) { activeIndex = -1; return; }
activeIndex = idx;
var el = opts[idx];
el.classList.add('active');
el.scrollIntoView({ block: 'nearest' });
input.setAttribute('aria-activedescendant', el.id);
}
// --- Navigation to a setting ---------------------------------------------
function getAppData() {
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
if (!appEl) return null;
if (appEl._x_dataStack && appEl._x_dataStack[0]) return appEl._x_dataStack[0];
if (appEl.__x && appEl.__x.$data) return appEl.__x.$data;
return null;
}
function setActiveTab(tab) {
var data = getAppData();
if (data) { data.activeTab = tab; return true; }
return false;
}
function waitForElement(id, timeout) {
return new Promise(function (resolve) {
var existing = document.getElementById(id);
if (existing) { resolve(existing); return; }
var host = document.getElementById('tab-content') || document.body;
var done = false;
var obs = new MutationObserver(function () {
var el = document.getElementById(id);
if (el && !done) {
done = true;
obs.disconnect();
resolve(el);
}
});
obs.observe(host, { childList: true, subtree: true });
setTimeout(function () {
if (!done) { done = true; obs.disconnect(); resolve(document.getElementById(id)); }
}, timeout || 6000);
});
}
// Reveal any collapsed nested section (from render_nested_section) so the
// target field is actually visible before we scroll to it.
function revealAncestors(el) {
var node = el.parentElement;
while (node && node !== document.body) {
if (node.classList && node.classList.contains('nested-content')) {
var hidden = node.classList.contains('hidden') ||
(node.style && node.style.display === 'none') ||
(window.getComputedStyle(node).display === 'none');
if (hidden) {
// toggleSection handles the class, inline display, and chevron.
if (node.id && typeof window.toggleSection === 'function') {
window.toggleSection(node.id);
} else {
node.classList.remove('hidden');
node.style.display = 'block';
}
}
}
node = node.parentElement;
}
}
function flash(el) {
el.classList.remove('setting-flash');
// force reflow so re-adding the class restarts the animation
void el.offsetWidth;
el.classList.add('setting-flash');
var clear = function () { el.classList.remove('setting-flash'); el.removeEventListener('animationend', clear); };
el.addEventListener('animationend', clear);
}
function navigateToSetting(entry) {
closeResults();
setActiveTab(entry.tab);
waitForElement(entry.anchorId, 6000).then(function (el) {
if (!el) return;
revealAncestors(el);
// Let the tab transition settle before scrolling.
setTimeout(function () {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
flash(el);
}, 60);
});
}
// --- Wire up the header search box ----------------------------------------
function initSearchBox() {
input = document.getElementById('settings-search');
resultsBox = document.getElementById('settings-search-results');
if (!input || !resultsBox) return;
// Warm the index in the background so the first search is instant.
var warm = function () { buildIndex().catch(function () {}); };
if ('requestIdleCallback' in window) {
requestIdleCallback(warm, { timeout: 4000 });
} else {
setTimeout(warm, 3000);
}
input.addEventListener('focus', function () {
buildIndex().then(function () {
if (input.value.trim()) renderResults(search(input.value));
});
});
input.addEventListener('input', debounce(function () {
var q = input.value;
if (!q.trim()) { closeResults(); return; }
buildIndex().then(function () { renderResults(search(q)); });
}, 200));
input.addEventListener('keydown', function (e) {
var opts = resultsBox.querySelectorAll('.ssr-option');
if (e.key === 'ArrowDown') {
e.preventDefault();
if (resultsBox.classList.contains('hidden')) { renderResults(search(input.value)); return; }
highlight(Math.min(activeIndex + 1, opts.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlight(Math.max(activeIndex - 1, 0));
} else if (e.key === 'Enter') {
if (activeIndex >= 0 && currentResults[activeIndex]) {
e.preventDefault();
navigateToSetting(currentResults[activeIndex]);
} else if (currentResults.length) {
e.preventDefault();
navigateToSetting(currentResults[0]);
}
} else if (e.key === 'Escape') {
closeResults();
input.blur();
}
});
resultsBox.addEventListener('mousedown', function (e) {
// mousedown (not click) so it fires before the input blur closes us
var opt = e.target.closest('.ssr-option');
if (!opt) return;
e.preventDefault();
var idx = parseInt(opt.getAttribute('data-idx'), 10);
if (currentResults[idx]) navigateToSetting(currentResults[idx]);
});
document.addEventListener('click', function (e) {
if (!input) return;
if (e.target === input || resultsBox.contains(e.target)) return;
closeResults();
});
}
// --- Per-tab filter (delegated) -------------------------------------------
function filterScope(input) {
return input.closest('.plugin-config-tab') ||
input.closest('[id$="-content"]') ||
input.closest('.bg-white') ||
document;
}
function fieldHay(fg) {
var label = textOf(fg.querySelector('label'));
var tip = fg.querySelector('.help-tip');
var help = tip ? (tip.getAttribute('data-tooltip') || '') : '';
var key = fg.getAttribute('data-setting-key') || fg.id.replace(/^setting-/, '');
return (label + ' ' + help + ' ' + key).toLowerCase();
}
function applyTabFilter(scope, q) {
q = q.trim().toLowerCase();
var terms = q ? q.split(/\s+/) : [];
var fields = scope.querySelectorAll('.form-group[id^="setting-"]');
var anyVisible = false;
fields.forEach(function (fg) {
var show = true;
if (terms.length) {
var hay = fieldHay(fg);
for (var i = 0; i < terms.length; i++) {
if (hay.indexOf(terms[i]) === -1) { show = false; break; }
}
}
fg.style.display = show ? '' : 'none';
if (show) anyVisible = true;
});
// Hide section headings whose settings all got filtered out.
var nodes = scope.querySelectorAll('h3, h4, .form-group');
var headings = [];
var current = null;
nodes.forEach(function (node) {
if (node.tagName === 'H3' || node.tagName === 'H4') {
current = { el: node, total: 0, visible: 0 };
headings.push(current);
} else if (current && node.matches('.form-group[id^="setting-"]')) {
current.total++;
if (node.style.display !== 'none') current.visible++;
}
});
headings.forEach(function (h) {
// Only auto-hide headings that exclusively group settings fields.
h.el.style.display = (terms.length && h.total > 0 && h.visible === 0) ? 'none' : '';
});
// Toggle the "no matches" note if the filter box provides one.
var wrap = scope.querySelector('.settings-filter-wrap');
if (wrap) {
var empty = wrap.querySelector('.settings-filter-empty');
if (empty) empty.classList.toggle('hidden', !(terms.length && !anyVisible));
}
}
document.addEventListener('input', function (e) {
var box = e.target.closest ? e.target.closest('.settings-filter') : null;
if (!box) return;
applyTabFilter(filterScope(box), box.value);
});
// --- Boot -----------------------------------------------------------------
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSearchBox);
} else {
initSearchBox();
}
// Expose for debugging / programmatic use.
window.LEDMatrixSettingsSearch = {
buildIndex: buildIndex,
navigateToSetting: navigateToSetting
};
console.log('[SettingsSearch] registered');
})();
+161
View File
@@ -0,0 +1,161 @@
/*
* tooltips.js — accessible, delegated tooltip controller for the v3 web UI.
*
* A single controller handles every `.help-tip` trigger on the page, including
* ones inside partials that HTMX swaps in later, with zero per-field wiring.
* Triggers are emitted by the `help_tip` Jinja macro (partials/_macros.html) as
* <button class="help-tip" data-tooltip="..."><i class="fas fa-circle-info">.
*
* Behaviour:
* - hover (mouse) -> show / hide
* - keyboard focus -> show / hide (only for :focus-visible)
* - click / tap -> toggle (the touch path)
* - Escape / outside click -> hide
* The tooltip text is set via textContent (XSS-safe) and supports "\n" line
* breaks via CSS `white-space: pre-line`. Styling lives in app.css and uses the
* --color-* theme vars, so light/dark mode work automatically.
*/
(function () {
'use strict';
if (window._tooltipsInit) return;
window._tooltipsInit = true;
var panel = null;
var currentTrigger = null;
function getPanel() {
if (panel) return panel;
panel = document.createElement('div');
panel.id = 'ledm-tooltip';
panel.setAttribute('role', 'tooltip');
panel.hidden = true;
document.body.appendChild(panel);
return panel;
}
function positionPanel(trigger) {
var p = getPanel();
var margin = 8;
var rect = trigger.getBoundingClientRect();
var pw = p.offsetWidth;
var ph = p.offsetHeight;
var vw = document.documentElement.clientWidth;
var vh = document.documentElement.clientHeight;
// Prefer above the trigger; flip below if it would clip the top.
var top = rect.top - ph - margin;
var placedBelow = false;
if (top < margin) {
top = rect.bottom + margin;
placedBelow = true;
}
// Keep it on screen vertically as a last resort.
if (top + ph > vh - margin) top = Math.max(margin, vh - ph - margin);
// Center horizontally on the trigger, clamped to the viewport.
var left = rect.left + rect.width / 2 - pw / 2;
if (left < margin) left = margin;
if (left + pw > vw - margin) left = Math.max(margin, vw - pw - margin);
p.style.top = Math.round(top) + 'px';
p.style.left = Math.round(left) + 'px';
p.setAttribute('data-placement', placedBelow ? 'below' : 'above');
}
function show(trigger) {
var text = trigger.getAttribute('data-tooltip');
if (!text) return;
var p = getPanel();
p.textContent = text;
p.hidden = false;
// Measure after it is displayed, then position.
positionPanel(trigger);
trigger.setAttribute('aria-describedby', 'ledm-tooltip');
currentTrigger = trigger;
}
function hide() {
if (!panel) return;
panel.hidden = true;
if (currentTrigger) {
currentTrigger.removeAttribute('aria-describedby');
currentTrigger = null;
}
}
function triggerFrom(target) {
return target && target.closest ? target.closest('.help-tip') : null;
}
// --- Delegated listeners on document (survive HTMX swaps) ---
document.addEventListener('mouseover', function (e) {
var t = triggerFrom(e.target);
if (t) show(t);
});
document.addEventListener('mouseout', function (e) {
var t = triggerFrom(e.target);
if (!t) return;
// Ignore moves that stay within the same trigger.
var to = e.relatedTarget;
if (to && t.contains(to)) return;
if (currentTrigger === t) hide();
});
document.addEventListener('focusin', function (e) {
var t = triggerFrom(e.target);
if (!t) return;
// Only auto-show on keyboard focus, so a mouse/touch focus does not
// fight the click handler below.
var focusVisible = true;
try {
focusVisible = t.matches(':focus-visible');
} catch (err) {
focusVisible = true; // older browsers: fall back to always show
}
if (focusVisible) show(t);
});
document.addEventListener('focusout', function (e) {
var t = triggerFrom(e.target);
if (t && currentTrigger === t) hide();
});
document.addEventListener('click', function (e) {
var t = triggerFrom(e.target);
if (t) {
// Prevent an enclosing <label> from toggling its control, and
// prevent form submission.
e.preventDefault();
e.stopPropagation();
if (currentTrigger === t && !getPanel().hidden) {
hide();
} else {
show(t);
}
return;
}
// Click anywhere else closes an open tooltip.
if (panel && !panel.hidden && !panel.contains(e.target)) hide();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && panel && !panel.hidden) hide();
});
// Reposition while visible; close when content is swapped out.
window.addEventListener('scroll', function () {
if (currentTrigger && panel && !panel.hidden) positionPanel(currentTrigger);
}, true);
window.addEventListener('resize', function () {
if (currentTrigger && panel && !panel.hidden) positionPanel(currentTrigger);
});
document.body.addEventListener('htmx:afterSwap', function () {
// The current trigger may have been removed by the swap.
if (currentTrigger && !document.body.contains(currentTrigger)) hide();
});
console.log('[Tooltips] controller registered');
})();
+23
View File
@@ -882,6 +882,25 @@
<!-- Connection status and theme toggle --> <!-- Connection status and theme toggle -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<!-- Global settings search -->
<div class="relative hidden sm:block" id="settings-search-wrap">
<input id="settings-search"
type="text"
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-controls="settings-search-results"
aria-label="Search settings"
placeholder="Search settings…"
autocomplete="off"
class="form-control text-sm pl-8 pr-4 py-1.5 w-48 lg:w-64">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs" aria-hidden="true"></i>
<div id="settings-search-results"
role="listbox"
aria-label="Settings search results"
class="hidden absolute right-0 mt-1 w-80 max-h-96 overflow-y-auto"></div>
</div>
<!-- Theme toggle --> <!-- Theme toggle -->
<button id="theme-toggle" <button id="theme-toggle"
type="button" type="button"
@@ -4748,6 +4767,10 @@
<!-- Custom v3 JavaScript --> <!-- Custom v3 JavaScript -->
<script src="{{ url_for('static', filename='v3/app.js') }}" defer></script> <script src="{{ url_for('static', filename='v3/app.js') }}" defer></script>
<!-- Settings tooltips + settings search -->
<script src="{{ url_for('static', filename='v3/js/tooltips.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/settings-search.js') }}" defer></script>
<!-- Modular Plugin Management JavaScript --> <!-- Modular Plugin Management JavaScript -->
<!-- Load utilities first --> <!-- Load utilities first -->
<script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script> <script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script>
@@ -0,0 +1,42 @@
{# ============================================================================ #}
{# Shared UI macros for the v3 web interface. #}
{# #}
{# Import at the top of a partial with: #}
{# {% import 'v3/partials/_macros.html' as ui %} #}
{# #}
{# These power the settings tooltips and the settings search feature: #}
{# - help_tip(text, label): the (i) info icon whose hover/focus tooltip #}
{# explains a setting. This replaces the old always-visible <p> help. #}
{# - fg_id(tab, key): stable anchor id for a .form-group so global search #}
{# can scroll to it (e.g. "setting-display-brightness"). #}
{# - settings_filter(): the per-tab filter box shown under a partial title. #}
{# ============================================================================ #}
{# Info (i) tooltip trigger placed next to a setting label. #}
{# `text` supports "\n" line breaks (rendered via CSS white-space: pre-line). #}
{# Renders nothing when `text` is empty so callers can pass through schema data. #}
{% macro help_tip(text, label='') -%}
{%- if text -%}
<button type="button" class="help-tip" data-tooltip="{{ text }}"
aria-label="{% if label %}Help for {{ label }}{% else %}More information{% endif %}">
<i class="fas fa-circle-info" aria-hidden="true"></i>
</button>
{%- endif -%}
{%- endmacro %}
{# Stable anchor id for a settings field's .form-group wrapper. #}
{% macro fg_id(tab, key) -%}setting-{{ tab }}-{{ key }}{%- endmacro %}
{# Per-tab filter box. Place directly under a partial's <h2> title block. #}
{# The delegated handler in settings-search.js scopes to the enclosing tab. #}
{% macro settings_filter(placeholder='Filter these settings…') -%}
<div class="settings-filter-wrap relative mb-6">
<input type="text"
class="settings-filter form-control text-sm pl-9 pr-4 py-2 w-full"
placeholder="{{ placeholder }}"
aria-label="Filter settings on this tab"
autocomplete="off">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm" aria-hidden="true"></i>
<p class="settings-filter-empty text-sm text-gray-500 mt-3 hidden">No settings match your filter.</p>
</div>
{%- endmacro %}
@@ -1,3 +1,4 @@
{% import 'v3/partials/_macros.html' as ui %}
<div class="space-y-6" id="backup-restore-root"> <div class="space-y-6" id="backup-restore-root">
<!-- Security warning --> <!-- Security warning -->
@@ -70,12 +71,12 @@
<h3 class="text-sm font-medium text-gray-900 mt-4 mb-2">Choose what to restore</h3> <h3 class="text-sm font-medium text-gray-900 mt-4 mb-2">Choose what to restore</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-gray-700"> <div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-gray-700">
<label class="flex items-center gap-2"><input type="checkbox" id="opt-config" checked> <span>Main configuration</span></label> <label class="flex items-center gap-2"><input type="checkbox" id="opt-config" checked> <span>Main configuration</span>{{ ui.help_tip('Restore config.json — display settings, schedules, location, and plugin configuration.', 'Main configuration') }}</label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-secrets" checked> <span>API keys (secrets)</span></label> <label class="flex items-center gap-2"><input type="checkbox" id="opt-secrets" checked> <span>API keys (secrets)</span>{{ ui.help_tip('Restore config_secrets.json — your weather, sports, and other service API keys.\nLeave off if you prefer to re-enter keys by hand.', 'API keys') }}</label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-wifi" checked> <span>WiFi configuration</span></label> <label class="flex items-center gap-2"><input type="checkbox" id="opt-wifi" checked> <span>WiFi configuration</span>{{ ui.help_tip('Restore saved WiFi network names and credentials.', 'WiFi configuration') }}</label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-fonts" checked> <span>User-uploaded fonts</span></label> <label class="flex items-center gap-2"><input type="checkbox" id="opt-fonts" checked> <span>User-uploaded fonts</span>{{ ui.help_tip('Restore any custom fonts you uploaded via the Fonts tab.', 'User-uploaded fonts') }}</label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-plugin-uploads" checked> <span>Plugin image uploads</span></label> <label class="flex items-center gap-2"><input type="checkbox" id="opt-plugin-uploads" checked> <span>Plugin image uploads</span>{{ ui.help_tip('Restore images and files that plugins let you upload (logos, backgrounds, etc.).', 'Plugin image uploads') }}</label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-reinstall" checked> <span>Reinstall missing plugins</span></label> <label class="flex items-center gap-2"><input type="checkbox" id="opt-reinstall" checked> <span>Reinstall missing plugins</span>{{ ui.help_tip('After restoring, re-download any plugins that were installed in the backup but are missing now.', 'Reinstall missing plugins') }}</label>
</div> </div>
<div class="mt-4 flex gap-2"> <div class="mt-4 flex gap-2">
@@ -1,9 +1,12 @@
{% import 'v3/partials/_macros.html' as ui %}
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Display Settings</h2> <h2 class="text-lg font-semibold text-gray-900">Display Settings</h2>
<p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p> <p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p>
</div> </div>
{{ ui.settings_filter('Filter display settings…') }}
<!-- Hardware status banner: shown when display service is in fallback/simulation mode --> <!-- Hardware status banner: shown when display service is in fallback/simulation mode -->
<div x-data="{ show: false, errorMsg: '' }" <div x-data="{ show: false, errorMsg: '' }"
x-init="fetch('/api/v3/hardware/status').then(r => r.json()).then(d => { x-init="fetch('/api/v3/hardware/status').then(r => r.json()).then(d => {
@@ -37,8 +40,8 @@
<h3 class="text-md font-medium text-gray-900 mb-4">Hardware Configuration</h3> <h3 class="text-md font-medium text-gray-900 mb-4">Hardware Configuration</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4 mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4 mb-4">
<div class="form-group"> <div class="form-group" id="setting-display-rows" data-setting-key="display.hardware.rows">
<label for="rows" class="block text-sm font-medium text-gray-700">Rows</label> <label for="rows" class="block text-sm font-medium text-gray-700">Rows{{ ui.help_tip('Number of LED rows on a single panel.\nCommon: 16, 32, or 64. Default: 32. Must match your panel.', 'Rows') }}</label>
<input type="number" <input type="number"
id="rows" id="rows"
name="rows" name="rows"
@@ -46,11 +49,10 @@
min="1" min="1"
max="64" max="64"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Number of LED rows</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-cols" data-setting-key="display.hardware.cols">
<label for="cols" class="block text-sm font-medium text-gray-700">Columns</label> <label for="cols" class="block text-sm font-medium text-gray-700">Columns{{ ui.help_tip('Number of LED columns on a single panel.\nCommon: 32 or 64. Default: 64. Must match your panel.', 'Columns') }}</label>
<input type="number" <input type="number"
id="cols" id="cols"
name="cols" name="cols"
@@ -58,11 +60,10 @@
min="1" min="1"
max="128" max="128"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Number of LED columns</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-chain_length" data-setting-key="display.hardware.chain_length">
<label for="chain_length" class="block text-sm font-medium text-gray-700">Chain Length</label> <label for="chain_length" class="block text-sm font-medium text-gray-700">Chain Length{{ ui.help_tip('How many panels are wired end-to-end in one chain.\nDefault: 2. Example: two 64×32 panels chained make a 128×32 display.', 'Chain Length') }}</label>
<input type="number" <input type="number"
id="chain_length" id="chain_length"
name="chain_length" name="chain_length"
@@ -70,11 +71,10 @@
min="1" min="1"
max="24" max="24"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Number of LED panels chained together</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-parallel" data-setting-key="display.hardware.parallel">
<label for="parallel" class="block text-sm font-medium text-gray-700">Parallel</label> <label for="parallel" class="block text-sm font-medium text-gray-700">Parallel{{ ui.help_tip('Number of separate chains driven in parallel from the HAT.\nDefault: 1. The Raspberry Pi supports up to 3 (some HATs allow more).', 'Parallel') }}</label>
<input type="number" <input type="number"
id="parallel" id="parallel"
name="parallel" name="parallel"
@@ -82,13 +82,12 @@
min="1" min="1"
max="4" max="4"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Number of parallel chains</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-brightness" data-setting-key="display.hardware.brightness">
<label for="brightness" class="block text-sm font-medium text-gray-700">Brightness</label> <label for="brightness" class="block text-sm font-medium text-gray-700">Brightness{{ ui.help_tip('Overall LED brightness (1100%).\nLower is dimmer, higher is brighter. Recommended: 7090 indoors, 90100 in bright rooms.', 'Brightness') }}</label>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<input type="range" <input type="range"
id="brightness" id="brightness"
@@ -99,11 +98,10 @@
class="flex-1"> class="flex-1">
<span id="brightness-value" class="text-sm font-medium w-12">{{ main_config.display.hardware.brightness or 95 }}</span> <span id="brightness-value" class="text-sm font-medium w-12">{{ main_config.display.hardware.brightness or 95 }}</span>
</div> </div>
<p class="mt-1 text-sm text-gray-600">LED brightness: <span id="brightness-display">{{ main_config.display.hardware.brightness or 95 }}</span>%</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-hardware_mapping" data-setting-key="display.hardware.hardware_mapping">
<label for="hardware_mapping" class="block text-sm font-medium text-gray-700">Hardware Mapping</label> <label for="hardware_mapping" class="block text-sm font-medium text-gray-700">Hardware Mapping{{ ui.help_tip('How the LED panel is wired to the Pi.\nUse "Adafruit HAT PWM" for an Adafruit HAT/Bonnet with the PWM solder mod; "Adafruit HAT" without it; "Regular" for direct GPIO wiring.', 'Hardware Mapping') }}</label>
<select id="hardware_mapping" name="hardware_mapping" class="form-control"> <select id="hardware_mapping" name="hardware_mapping" class="form-control">
<option value="adafruit-hat-pwm" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat-pwm" %}selected{% endif %}>Adafruit HAT PWM</option> <option value="adafruit-hat-pwm" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat-pwm" %}selected{% endif %}>Adafruit HAT PWM</option>
<option value="adafruit-hat" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat" %}selected{% endif %}>Adafruit HAT</option> <option value="adafruit-hat" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat" %}selected{% endif %}>Adafruit HAT</option>
@@ -114,8 +112,8 @@
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence">
<label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence</label> <label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence{{ ui.help_tip('Order the panel expects color channels in.\nChange this only if reds/greens/blues look swapped. Default: RGB.', 'LED RGB Sequence') }}</label>
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control"> <select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">
<option value="RGB" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RGB" %}selected{% endif %}>RGB</option> <option value="RGB" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RGB" %}selected{% endif %}>RGB</option>
<option value="RBG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RBG" %}selected{% endif %}>RBG</option> <option value="RBG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RBG" %}selected{% endif %}>RBG</option>
@@ -124,11 +122,10 @@
<option value="BRG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BRG" %}selected{% endif %}>BRG</option> <option value="BRG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BRG" %}selected{% endif %}>BRG</option>
<option value="BGR" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BGR" %}selected{% endif %}>BGR</option> <option value="BGR" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BGR" %}selected{% endif %}>BGR</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Color channel order for your LED panels</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-multiplexing" data-setting-key="display.hardware.multiplexing">
<label for="multiplexing" class="block text-sm font-medium text-gray-700">Multiplexing</label> <label for="multiplexing" class="block text-sm font-medium text-gray-700">Multiplexing{{ ui.help_tip('Pixel-mapping scheme used by outdoor/specialty panels.\nLeave at 0 (Direct) for most indoor panels. Only change if the image is scrambled — try values until it looks right.', 'Multiplexing') }}</label>
<select id="multiplexing" name="multiplexing" class="form-control"> <select id="multiplexing" name="multiplexing" class="form-control">
<option value="0" {% if main_config.display.hardware.get('multiplexing', 0)|int == 0 %}selected{% endif %}>0 - Direct</option> <option value="0" {% if main_config.display.hardware.get('multiplexing', 0)|int == 0 %}selected{% endif %}>0 - Direct</option>
<option value="1" {% if main_config.display.hardware.get('multiplexing', 0)|int == 1 %}selected{% endif %}>1 - Stripe</option> <option value="1" {% if main_config.display.hardware.get('multiplexing', 0)|int == 1 %}selected{% endif %}>1 - Stripe</option>
@@ -154,21 +151,19 @@
<option value="21" {% if main_config.display.hardware.get('multiplexing', 0)|int == 21 %}selected{% endif %}>21 - DoubleZMultiplex</option> <option value="21" {% if main_config.display.hardware.get('multiplexing', 0)|int == 21 %}selected{% endif %}>21 - DoubleZMultiplex</option>
<option value="22" {% if main_config.display.hardware.get('multiplexing', 0)|int == 22 %}selected{% endif %}>22 - P4Outdoor-80x40</option> <option value="22" {% if main_config.display.hardware.get('multiplexing', 0)|int == 22 %}selected{% endif %}>22 - P4Outdoor-80x40</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Multiplexing scheme for your LED panels</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-panel_type" data-setting-key="display.hardware.panel_type">
<label for="panel_type" class="block text-sm font-medium text-gray-700">Panel Type</label> <label for="panel_type" class="block text-sm font-medium text-gray-700">Panel Type{{ ui.help_tip('Special initialization for panels with a specific driver chip (e.g. FM6126A, FM6127).\nLeave on Standard unless your panel stays blank or shows only the first pixel.', 'Panel Type') }}</label>
<select id="panel_type" name="panel_type" class="form-control"> <select id="panel_type" name="panel_type" class="form-control">
<option value="" {% if not main_config.display.hardware.get('panel_type', '') %}selected{% endif %}>Standard</option> <option value="" {% if not main_config.display.hardware.get('panel_type', '') %}selected{% endif %}>Standard</option>
<option value="FM6126A" {% if main_config.display.hardware.get('panel_type', '') == "FM6126A" %}selected{% endif %}>FM6126A</option> <option value="FM6126A" {% if main_config.display.hardware.get('panel_type', '') == "FM6126A" %}selected{% endif %}>FM6126A</option>
<option value="FM6127" {% if main_config.display.hardware.get('panel_type', '') == "FM6127" %}selected{% endif %}>FM6127</option> <option value="FM6127" {% if main_config.display.hardware.get('panel_type', '') == "FM6127" %}selected{% endif %}>FM6127</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Special panel chipset initialization (use Standard unless your panel requires it)</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-row_address_type" data-setting-key="display.hardware.row_address_type">
<label for="row_address_type" class="block text-sm font-medium text-gray-700">Row Address Type</label> <label for="row_address_type" class="block text-sm font-medium text-gray-700">Row Address Type{{ ui.help_tip('Row addressing scheme used by the panel.\nLeave at 0 (Default) unless your panel needs AB/ABC addressing — a wrong value shows a garbled or shifted image.', 'Row Address Type') }}</label>
<select id="row_address_type" name="row_address_type" class="form-control"> <select id="row_address_type" name="row_address_type" class="form-control">
<option value="0" {% if main_config.display.hardware.get('row_address_type', 0)|int == 0 %}selected{% endif %}>0 - Default</option> <option value="0" {% if main_config.display.hardware.get('row_address_type', 0)|int == 0 %}selected{% endif %}>0 - Default</option>
<option value="1" {% if main_config.display.hardware.get('row_address_type', 0)|int == 1 %}selected{% endif %}>1 - AB-addressed panels</option> <option value="1" {% if main_config.display.hardware.get('row_address_type', 0)|int == 1 %}selected{% endif %}>1 - AB-addressed panels</option>
@@ -176,13 +171,12 @@
<option value="3" {% if main_config.display.hardware.get('row_address_type', 0)|int == 3 %}selected{% endif %}>3 - ABC-addressed panels</option> <option value="3" {% if main_config.display.hardware.get('row_address_type', 0)|int == 3 %}selected{% endif %}>3 - ABC-addressed panels</option>
<option value="4" {% if main_config.display.hardware.get('row_address_type', 0)|int == 4 %}selected{% endif %}>4 - ABC Shift + DE direct</option> <option value="4" {% if main_config.display.hardware.get('row_address_type', 0)|int == 4 %}selected{% endif %}>4 - ABC Shift + DE direct</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Row addressing scheme — leave at Default (0) unless your panel requires a specific type</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-gpio_slowdown" data-setting-key="display.runtime.gpio_slowdown">
<label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown</label> <label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown{{ ui.help_tip('Slows the GPIO signal so the panel keeps up.\nGuide: Pi 3 → 12, Pi 4 → 24, Pi 5 (PIO) → 13. Increase if the display shows garbage or flicker; in RIO mode higher values may improve performance.', 'GPIO Slowdown') }}</label>
<input type="number" <input type="number"
id="gpio_slowdown" id="gpio_slowdown"
name="gpio_slowdown" name="gpio_slowdown"
@@ -190,22 +184,20 @@
min="0" min="0"
max="10" max="10"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Pi 3: 1&ndash;2 &middot; Pi 4: 2&ndash;4 &middot; Pi 5 PIO: 1&ndash;3. Increase if display shows garbage; in RIO mode higher values may improve performance.</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-rp1_rio" data-setting-key="display.runtime.rp1_rio">
<label for="rp1_rio" class="block text-sm font-medium text-gray-700"> <label for="rp1_rio" class="block text-sm font-medium text-gray-700">
RP1 Backend <span class="text-xs text-gray-400 font-normal">(Pi 5 only)</span> RP1 Backend <span class="text-xs text-gray-400 font-normal">(Pi 5 only)</span>{{ ui.help_tip('Pi 5 RP1 coprocessor driver mode.\nPIO (0) is the default and uses less CPU. RIO (1) can push a higher refresh rate but inverts the GPIO Slowdown behavior. Ignored on Pi 3/4.', 'RP1 Backend') }}
</label> </label>
<select id="rp1_rio" name="rp1_rio" class="form-control"> <select id="rp1_rio" name="rp1_rio" class="form-control">
<option value="0" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 0 %}selected{% endif %}>0 &mdash; PIO (default, low CPU)</option> <option value="0" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 0 %}selected{% endif %}>0 &mdash; PIO (default, low CPU)</option>
<option value="1" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 1 %}selected{% endif %}>1 &mdash; RIO (higher throughput; slowdown inverted)</option> <option value="1" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 1 %}selected{% endif %}>1 &mdash; RIO (higher throughput; slowdown inverted)</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Pi 5 RP1 coprocessor mode. Ignored on Pi 3/4.</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-scan_mode" data-setting-key="display.hardware.scan_mode">
<label for="scan_mode" class="block text-sm font-medium text-gray-700">Scan Mode</label> <label for="scan_mode" class="block text-sm font-medium text-gray-700">Scan Mode{{ ui.help_tip('Order rows are refreshed in.\n0 = progressive (default), 1 = interlaced. Change only if you see banding or flicker on certain panels.', 'Scan Mode') }}</label>
<input type="number" <input type="number"
id="scan_mode" id="scan_mode"
name="scan_mode" name="scan_mode"
@@ -213,13 +205,12 @@
min="0" min="0"
max="1" max="1"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Scan mode for LED matrix (0-1)</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-pwm_bits" data-setting-key="display.hardware.pwm_bits">
<label for="pwm_bits" class="block text-sm font-medium text-gray-700">PWM Bits</label> <label for="pwm_bits" class="block text-sm font-medium text-gray-700">PWM Bits{{ ui.help_tip('Color depth per channel (111).\nHigher means smoother color but a lower refresh rate; lower means faster refresh with more banding. Default: 11 (this build defaults to 9).', 'PWM Bits') }}</label>
<input type="number" <input type="number"
id="pwm_bits" id="pwm_bits"
name="pwm_bits" name="pwm_bits"
@@ -227,11 +218,10 @@
min="1" min="1"
max="11" max="11"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">PWM bits for brightness control (1-11)</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-pwm_dither_bits" data-setting-key="display.hardware.pwm_dither_bits">
<label for="pwm_dither_bits" class="block text-sm font-medium text-gray-700">PWM Dither Bits</label> <label for="pwm_dither_bits" class="block text-sm font-medium text-gray-700">PWM Dither Bits{{ ui.help_tip('Time-dithering to gain apparent color depth (04).\nDefault: 0. Raising it can smooth gradients at the cost of a slightly lower refresh rate.', 'PWM Dither Bits') }}</label>
<input type="number" <input type="number"
id="pwm_dither_bits" id="pwm_dither_bits"
name="pwm_dither_bits" name="pwm_dither_bits"
@@ -239,13 +229,12 @@
min="0" min="0"
max="4" max="4"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">PWM dither bits (0-4)</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-pwm_lsb_nanoseconds" data-setting-key="display.hardware.pwm_lsb_nanoseconds">
<label for="pwm_lsb_nanoseconds" class="block text-sm font-medium text-gray-700">PWM LSB Nanoseconds</label> <label for="pwm_lsb_nanoseconds" class="block text-sm font-medium text-gray-700">PWM LSB Nanoseconds{{ ui.help_tip('Base time for the least-significant color bit (50500 ns).\nDefault: 130. Raising it can reduce flicker on some panels but lowers the maximum refresh rate.', 'PWM LSB Nanoseconds') }}</label>
<input type="number" <input type="number"
id="pwm_lsb_nanoseconds" id="pwm_lsb_nanoseconds"
name="pwm_lsb_nanoseconds" name="pwm_lsb_nanoseconds"
@@ -253,11 +242,10 @@
min="50" min="50"
max="500" max="500"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">PWM LSB nanoseconds (50-500)</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-limit_refresh_rate_hz" data-setting-key="display.hardware.limit_refresh_rate_hz">
<label for="limit_refresh_rate_hz" class="block text-sm font-medium text-gray-700">Limit Refresh Rate (Hz)</label> <label for="limit_refresh_rate_hz" class="block text-sm font-medium text-gray-700">Limit Refresh Rate (Hz){{ ui.help_tip('Caps the panel refresh rate (11000 Hz).\nDefault: 120. A steady cap reduces flicker in camera recordings and keeps timing consistent. Set higher or to the max your panel supports for the smoothest motion.', 'Limit Refresh Rate') }}</label>
<input type="number" <input type="number"
id="limit_refresh_rate_hz" id="limit_refresh_rate_hz"
name="limit_refresh_rate_hz" name="limit_refresh_rate_hz"
@@ -265,7 +253,6 @@
min="1" min="1"
max="1000" max="1000"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Limit refresh rate in Hz (1-1000)</p>
</div> </div>
</div> </div>
</div> </div>
@@ -276,7 +263,7 @@
<p class="text-sm text-gray-600 mb-4">Show the same content on every panel in the chain &mdash; e.g. two 64&times;32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.</p> <p class="text-sm text-gray-600 mb-4">Show the same content on every panel in the chain &mdash; e.g. two 64&times;32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-double_sided_enabled" data-setting-key="display.double_sided.enabled">
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">
<input type="checkbox" <input type="checkbox"
id="double_sided_enabled" id="double_sided_enabled"
@@ -285,12 +272,12 @@
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %} {% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="text-sm font-medium text-gray-700">Enabled</span> <span class="text-sm font-medium text-gray-700">Enabled</span>
{{ ui.help_tip('Show the same content mirrored across every panel in the chain.\nRendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.', 'Double-Sided Enabled') }}
</label> </label>
<p class="mt-1 text-sm text-gray-600">Mirror one screen across all panels.</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-double_sided_copies" data-setting-key="display.double_sided.copies">
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies</label> <label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies{{ ui.help_tip('How many identical screens to split the panel area into (28).\nMust divide the panel evenly — e.g. 2 for a two-sided cube.', 'Copies') }}</label>
<input type="number" <input type="number"
id="double_sided_copies" id="double_sided_copies"
name="double_sided_copies" name="double_sided_copies"
@@ -298,16 +285,14 @@
min="2" min="2"
max="8" max="8"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Number of identical screens. Must divide the panel evenly.</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-double_sided_axis" data-setting-key="display.double_sided.axis">
<label for="double_sided_axis" class="block text-sm font-medium text-gray-700">Split Axis</label> <label for="double_sided_axis" class="block text-sm font-medium text-gray-700">Split Axis{{ ui.help_tip('Direction the display is divided into copies.\nHorizontal splits along the chained panels (side by side); Vertical splits along parallel chains (stacked).', 'Split Axis') }}</label>
<select id="double_sided_axis" name="double_sided_axis" class="form-control"> <select id="double_sided_axis" name="double_sided_axis" class="form-control">
<option value="horizontal" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'horizontal' %}selected{% endif %}>Horizontal &mdash; chained panels (side by side)</option> <option value="horizontal" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'horizontal' %}selected{% endif %}>Horizontal &mdash; chained panels (side by side)</option>
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical &mdash; parallel chains (stacked)</option> <option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical &mdash; parallel chains (stacked)</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Horizontal splits the chain; vertical splits parallel outputs.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -317,7 +302,7 @@
<h3 class="text-md font-medium text-gray-900 mb-4">Display Options</h3> <h3 class="text-md font-medium text-gray-900 mb-4">Display Options</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-disable_hardware_pulsing" data-setting-key="display.hardware.disable_hardware_pulsing">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" <input type="checkbox"
name="disable_hardware_pulsing" name="disable_hardware_pulsing"
@@ -325,10 +310,11 @@
{% if main_config.display.hardware.disable_hardware_pulsing %}checked{% endif %} {% if main_config.display.hardware.disable_hardware_pulsing %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Disable Hardware Pulsing</span> <span class="ml-2 text-sm font-medium text-gray-900">Disable Hardware Pulsing</span>
{{ ui.help_tip('Turn off hardware PWM pulsing.\nEnable this if the Pi audio is in use or you hear buzzing / see instability. Slightly increases CPU usage.', 'Disable Hardware Pulsing') }}
</label> </label>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-inverse_colors" data-setting-key="display.hardware.inverse_colors">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" <input type="checkbox"
name="inverse_colors" name="inverse_colors"
@@ -336,10 +322,11 @@
{% if main_config.display.hardware.inverse_colors %}checked{% endif %} {% if main_config.display.hardware.inverse_colors %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Inverse Colors</span> <span class="ml-2 text-sm font-medium text-gray-900">Inverse Colors</span>
{{ ui.help_tip('Invert every color the panel shows.\nDefault: off. Only needed for panels wired with inverted color logic.', 'Inverse Colors') }}
</label> </label>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-show_refresh_rate" data-setting-key="display.hardware.show_refresh_rate">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" <input type="checkbox"
name="show_refresh_rate" name="show_refresh_rate"
@@ -347,10 +334,11 @@
{% if main_config.display.hardware.show_refresh_rate %}checked{% endif %} {% if main_config.display.hardware.show_refresh_rate %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Show Refresh Rate</span> <span class="ml-2 text-sm font-medium text-gray-900">Show Refresh Rate</span>
{{ ui.help_tip('Overlay the live panel refresh rate on the display.\nUseful for tuning GPIO Slowdown and PWM settings; turn off for normal use.', 'Show Refresh Rate') }}
</label> </label>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-use_short_date_format" data-setting-key="display.use_short_date_format">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" <input type="checkbox"
name="use_short_date_format" name="use_short_date_format"
@@ -358,6 +346,7 @@
{% if main_config.display.use_short_date_format %}checked{% endif %} {% if main_config.display.use_short_date_format %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Use Short Date Format</span> <span class="ml-2 text-sm font-medium text-gray-900">Use Short Date Format</span>
{{ ui.help_tip('Show dates in a compact form (e.g. 7/8 instead of July 8).\nHandy on narrow displays where space is tight.', 'Use Short Date Format') }}
</label> </label>
</div> </div>
</div> </div>
@@ -366,8 +355,8 @@
<div class="mt-6 pt-4 border-t border-gray-300"> <div class="mt-6 pt-4 border-t border-gray-300">
<h4 class="text-sm font-medium text-gray-900 mb-3">Dynamic Duration</h4> <h4 class="text-sm font-medium text-gray-900 mb-3">Dynamic Duration</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-max_dynamic_duration_seconds" data-setting-key="display.dynamic_duration.max_duration_seconds">
<label for="max_dynamic_duration_seconds" class="block text-sm font-medium text-gray-700">Max Dynamic Duration (seconds)</label> <label for="max_dynamic_duration_seconds" class="block text-sm font-medium text-gray-700">Max Dynamic Duration (seconds){{ ui.help_tip('Ceiling on how long a plugin may extend its own on-screen time (301800s).\nDefault: 180. Plugins with live content (e.g. a game in progress) can request extra time up to this limit.', 'Max Dynamic Duration') }}</label>
<input type="number" <input type="number"
id="max_dynamic_duration_seconds" id="max_dynamic_duration_seconds"
name="max_dynamic_duration_seconds" name="max_dynamic_duration_seconds"
@@ -375,7 +364,6 @@
min="30" min="30"
max="1800" max="1800"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Maximum time plugins can extend display duration (30-1800 seconds)</p>
</div> </div>
</div> </div>
</div> </div>
@@ -405,8 +393,8 @@
<!-- Vegas Settings (shown when enabled) --> <!-- Vegas Settings (shown when enabled) -->
<div id="vegas_scroll_settings" class="space-y-4" style="{% if not main_config.display.get('vegas_scroll', {}).get('enabled', false) %}display: none;{% endif %}"> <div id="vegas_scroll_settings" class="space-y-4" style="{% if not main_config.display.get('vegas_scroll', {}).get('enabled', false) %}display: none;{% endif %}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-vegas_scroll_speed" data-setting-key="display.vegas_scroll.scroll_speed">
<label for="vegas_scroll_speed" class="block text-sm font-medium text-gray-700">Scroll Speed (pixels/second)</label> <label for="vegas_scroll_speed" class="block text-sm font-medium text-gray-700">Scroll Speed (pixels/second){{ ui.help_tip('How fast the Vegas ticker scrolls (10200 px/s).\nDefault: 50. Higher is faster but harder to read.', 'Scroll Speed') }}</label>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<input type="range" <input type="range"
id="vegas_scroll_speed" id="vegas_scroll_speed"
@@ -418,11 +406,10 @@
class="flex-1"> class="flex-1">
<span id="vegas_scroll_speed_value" class="text-sm font-medium w-12">{{ main_config.display.get('vegas_scroll', {}).get('scroll_speed', 50) }}</span> <span id="vegas_scroll_speed_value" class="text-sm font-medium w-12">{{ main_config.display.get('vegas_scroll', {}).get('scroll_speed', 50) }}</span>
</div> </div>
<p class="mt-1 text-sm text-gray-600">Speed of the scrolling ticker (10-200 px/s)</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width">
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels)</label> <label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap inserted between each plugin block in the ticker (0128 px).\nDefault: 32. Larger values make the boundary between plugins clearer.', 'Separator Width') }}</label>
<input type="number" <input type="number"
id="vegas_separator_width" id="vegas_separator_width"
name="vegas_separator_width" name="vegas_separator_width"
@@ -430,29 +417,26 @@
min="0" min="0"
max="128" max="128"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Gap between plugin content blocks (0-128 px)</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps">
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS</label> <label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label>
<select id="vegas_target_fps" name="vegas_target_fps" class="form-control"> <select id="vegas_target_fps" name="vegas_target_fps" class="form-control">
<option value="60" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 60 %}selected{% endif %}>60 FPS (Lower CPU)</option> <option value="60" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 60 %}selected{% endif %}>60 FPS (Lower CPU)</option>
<option value="90" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 90 %}selected{% endif %}>90 FPS (Balanced)</option> <option value="90" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 90 %}selected{% endif %}>90 FPS (Balanced)</option>
<option value="125" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 125 %}selected{% endif %}>125 FPS (Smoothest)</option> <option value="125" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 125 %}selected{% endif %}>125 FPS (Smoothest)</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Higher FPS = smoother scroll, more CPU usage</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-vegas_buffer_ahead" data-setting-key="display.vegas_scroll.buffer_ahead">
<label for="vegas_buffer_ahead" class="block text-sm font-medium text-gray-700">Buffer Ahead</label> <label for="vegas_buffer_ahead" class="block text-sm font-medium text-gray-700">Buffer Ahead{{ ui.help_tip('How many upcoming plugins to pre-render so the scroll never stalls.\nDefault: 2 (recommended). More uses extra memory; less saves memory but risks hitches.', 'Buffer Ahead') }}</label>
<select id="vegas_buffer_ahead" name="vegas_buffer_ahead" class="form-control"> <select id="vegas_buffer_ahead" name="vegas_buffer_ahead" class="form-control">
<option value="1" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 1 %}selected{% endif %}>1 Plugin (Less memory)</option> <option value="1" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 1 %}selected{% endif %}>1 Plugin (Less memory)</option>
<option value="2" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 2 %}selected{% endif %}>2 Plugins (Recommended)</option> <option value="2" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 2 %}selected{% endif %}>2 Plugins (Recommended)</option>
<option value="3" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 3 %}selected{% endif %}>3 Plugins (More buffer)</option> <option value="3" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 3 %}selected{% endif %}>3 Plugins (More buffer)</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">How many plugins to pre-load ahead</p>
</div> </div>
</div> </div>
@@ -485,18 +469,17 @@
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-sync_role" data-setting-key="sync.role">
<label for="sync_role" class="block text-sm font-medium text-gray-700">Role</label> <label for="sync_role" class="block text-sm font-medium text-gray-700">Role{{ ui.help_tip('This unit\'s part in a two-display setup.\nStandalone = sync off. Set one Pi to Leader (drives the scroll) and the other to Follower (receives frames). Restart required after changing.', 'Sync Role') }}</label>
<select id="sync_role" name="sync_role" class="form-control" onchange="updateSyncUI()"> <select id="sync_role" name="sync_role" class="form-control" onchange="updateSyncUI()">
<option value="standalone" {% if main_config.get('sync', {}).get('role', 'standalone') == 'standalone' %}selected{% endif %}>Standalone (disabled)</option> <option value="standalone" {% if main_config.get('sync', {}).get('role', 'standalone') == 'standalone' %}selected{% endif %}>Standalone (disabled)</option>
<option value="leader" {% if main_config.get('sync', {}).get('role', 'standalone') == 'leader' %}selected{% endif %}>Leader (drives scroll)</option> <option value="leader" {% if main_config.get('sync', {}).get('role', 'standalone') == 'leader' %}selected{% endif %}>Leader (drives scroll)</option>
<option value="follower" {% if main_config.get('sync', {}).get('role', 'standalone') == 'follower' %}selected{% endif %}>Follower (receives frames)</option> <option value="follower" {% if main_config.get('sync', {}).get('role', 'standalone') == 'follower' %}selected{% endif %}>Follower (receives frames)</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Set Leader on one Pi, Follower on the other. Restart required after changing.</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-sync_port" data-setting-key="sync.port">
<label for="sync_port" class="block text-sm font-medium text-gray-700">UDP Port</label> <label for="sync_port" class="block text-sm font-medium text-gray-700">UDP Port{{ ui.help_tip('UDP port the two displays use to exchange frames (102465535).\nDefault: 5765. Must match on both Pis. If the ufw firewall is active, allow it with: sudo ufw allow ' ~ main_config.get('sync', {}).get('port', 5765) ~ '/udp', 'Sync UDP Port') }}</label>
<input type="number" <input type="number"
id="sync_port" id="sync_port"
name="sync_port" name="sync_port"
@@ -504,19 +487,14 @@
min="1024" min="1024"
max="65535" max="65535"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">
Must match on both Pis. If ufw is active:
<code class="text-xs bg-gray-200 px-1 rounded">sudo ufw allow {{ main_config.get('sync', {}).get('port', 5765) }}/udp</code>
</p>
</div> </div>
<div class="form-group" id="sync_position_group" style="display:none"> <div class="form-group" id="setting-display-sync_follower_position" data-setting-key="sync.follower_position" style="display:none">
<label for="sync_follower_position" class="block text-sm font-medium text-gray-700">Position</label> <label for="sync_follower_position" class="block text-sm font-medium text-gray-700">Position{{ ui.help_tip('Which side of the leader this follower display sits on.\nSets whether this unit shows the left or right half of the extended scroll.', 'Follower Position') }}</label>
<select id="sync_follower_position" name="sync_follower_position" class="form-control"> <select id="sync_follower_position" name="sync_follower_position" class="form-control">
<option value="left" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'left' %}selected{% endif %}>Left of leader</option> <option value="left" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'left' %}selected{% endif %}>Left of leader</option>
<option value="right" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'right' %}selected{% endif %}>Right of leader</option> <option value="right" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'right' %}selected{% endif %}>Right of leader</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Which side of the leader display this unit sits on.</p>
</div> </div>
</div> </div>
@@ -795,7 +773,7 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
function updateSyncUI() { function updateSyncUI() {
const role = document.getElementById('sync_role').value; const role = document.getElementById('sync_role').value;
const bar = document.getElementById('sync_status_bar'); const bar = document.getElementById('sync_status_bar');
const posGroup = document.getElementById('sync_position_group'); const posGroup = document.getElementById('setting-display-sync_follower_position');
if (role === 'standalone') { if (role === 'standalone') {
bar.classList.add('hidden'); bar.classList.add('hidden');
document.getElementById('sync_error_detail').classList.add('hidden'); document.getElementById('sync_error_detail').classList.add('hidden');
@@ -1,9 +1,12 @@
{% import 'v3/partials/_macros.html' as ui %}
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Display Durations</h2> <h2 class="text-lg font-semibold text-gray-900">Display Durations</h2>
<p class="mt-1 text-sm text-gray-600">Configure how long each screen is shown before switching. Values in seconds.</p> <p class="mt-1 text-sm text-gray-600">Configure how long each screen is shown before switching. Values in seconds.</p>
</div> </div>
{{ ui.settings_filter() }}
<form hx-post="/api/v3/config/main" <form hx-post="/api/v3/config/main"
hx-ext="json-enc" hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}' hx-headers='{"Content-Type": "application/json"}'
@@ -15,9 +18,9 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for key, value in main_config.display.display_durations.items() %} {% for key, value in main_config.display.display_durations.items() %}
<div class="form-group"> <div class="form-group" id="setting-durations-{{ key }}" data-setting-key="display.display_durations.{{ key }}">
<label for="duration_{{ key }}" class="block text-sm font-medium text-gray-700"> <label for="duration_{{ key }}" class="block text-sm font-medium text-gray-700">
{{ key | replace('_', ' ') | title }} {{ key | replace('_', ' ') | title }}{{ ui.help_tip('How long the ' ~ (key | replace('_', ' ')) ~ ' screen stays on before rotating to the next one, in seconds.\nRange: 5600. Currently ' ~ value ~ 's.', key | replace('_', ' ') | title) }}
</label> </label>
<input type="number" <input type="number"
id="duration_{{ key }}" id="duration_{{ key }}"
@@ -26,7 +29,6 @@
min="5" min="5"
max="600" max="600"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">{{ value }} seconds</p>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -1,9 +1,12 @@
{% import 'v3/partials/_macros.html' as ui %}
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">General Settings</h2> <h2 class="text-lg font-semibold text-gray-900">General Settings</h2>
<p class="mt-1 text-sm text-gray-600">Configure general system settings and location information.</p> <p class="mt-1 text-sm text-gray-600">Configure general system settings and location information.</p>
</div> </div>
{{ ui.settings_filter() }}
<form hx-post="/api/v3/config/main" <form hx-post="/api/v3/config/main"
hx-ext="json-enc" hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}' hx-headers='{"Content-Type": "application/json"}'
@@ -29,7 +32,7 @@
class="space-y-6"> class="space-y-6">
<!-- Web Display Autostart --> <!-- Web Display Autostart -->
<div class="form-group"> <div class="form-group" id="setting-general-web_display_autostart" data-setting-key="web_display_autostart">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" <input type="checkbox"
name="web_display_autostart" name="web_display_autostart"
@@ -37,15 +40,14 @@
{% if main_config.web_display_autostart %}checked{% endif %} {% if main_config.web_display_autostart %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Web Display Autostart</span> <span class="ml-2 text-sm font-medium text-gray-900">Web Display Autostart</span>
{{ ui.help_tip('Automatically start the web interface when the device boots.\nDefault: on. Turn off if you launch the web UI manually or run headless.', 'Web Display Autostart') }}
</label> </label>
<p class="mt-1 text-sm text-gray-600">Start the web interface on boot for easier access.</p>
</div> </div>
<!-- Timezone --> <!-- Timezone -->
<div class="form-group"> <div class="form-group" id="setting-general-timezone" data-setting-key="timezone">
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone</label> <label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
<div id="timezone_container" class="mt-1"></div> <div id="timezone_container" class="mt-1"></div>
<p class="mt-1 text-sm text-gray-600">Select your timezone for time-based features and scheduling.</p>
</div> </div>
<script> <script>
(function() { (function() {
@@ -80,8 +82,8 @@
<!-- Location Information --> <!-- Location Information -->
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-4">
<div class="form-group"> <div class="form-group" id="setting-general-city" data-setting-key="location.city">
<label for="city" class="block text-sm font-medium text-gray-700">City</label> <label for="city" class="block text-sm font-medium text-gray-700">City{{ ui.help_tip('City used for weather, sunrise/sunset, and other location-based content.\nExample: Dallas.', 'City') }}</label>
<input type="text" <input type="text"
id="city" id="city"
name="city" name="city"
@@ -89,8 +91,8 @@
class="form-control"> class="form-control">
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-general-state" data-setting-key="location.state">
<label for="state" class="block text-sm font-medium text-gray-700">State</label> <label for="state" class="block text-sm font-medium text-gray-700">State{{ ui.help_tip('State or region for your location.\nExample: Texas. Improves location-lookup accuracy.', 'State') }}</label>
<input type="text" <input type="text"
id="state" id="state"
name="state" name="state"
@@ -98,8 +100,8 @@
class="form-control"> class="form-control">
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-general-country" data-setting-key="location.country">
<label for="country" class="block text-sm font-medium text-gray-700">Country</label> <label for="country" class="block text-sm font-medium text-gray-700">Country{{ ui.help_tip('Country code or name for your location.\nExample: US. Used with City and State for weather and geolocation.', 'Country') }}</label>
<input type="text" <input type="text"
id="country" id="country"
name="country" name="country"
@@ -115,7 +117,7 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Auto Discover --> <!-- Auto Discover -->
<div class="form-group"> <div class="form-group" id="setting-general-auto_discover" data-setting-key="plugin_system.auto_discover">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" <input type="checkbox"
name="auto_discover" name="auto_discover"
@@ -123,12 +125,12 @@
{% if main_config.get('plugin_system', {}).get('auto_discover', True) %}checked{% endif %} {% if main_config.get('plugin_system', {}).get('auto_discover', True) %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Auto Discover Plugins</span> <span class="ml-2 text-sm font-medium text-gray-900">Auto Discover Plugins</span>
{{ ui.help_tip('Scan the plugins directory for installed plugins each time the service starts.\nDefault: on. Leave on unless you manage plugins manually.', 'Auto Discover Plugins') }}
</label> </label>
<p class="mt-1 text-sm text-gray-600">Automatically discover plugins in the plugins directory on startup.</p>
</div> </div>
<!-- Auto Load Enabled --> <!-- Auto Load Enabled -->
<div class="form-group"> <div class="form-group" id="setting-general-auto_load_enabled" data-setting-key="plugin_system.auto_load_enabled">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" <input type="checkbox"
name="auto_load_enabled" name="auto_load_enabled"
@@ -136,12 +138,12 @@
{% if main_config.get('plugin_system', {}).get('auto_load_enabled', True) %}checked{% endif %} {% if main_config.get('plugin_system', {}).get('auto_load_enabled', True) %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Auto Load Enabled Plugins</span> <span class="ml-2 text-sm font-medium text-gray-900">Auto Load Enabled Plugins</span>
{{ ui.help_tip('Load every plugin marked enabled in the configuration at startup.\nDefault: on. Turn off to keep plugins installed but dormant.', 'Auto Load Enabled Plugins') }}
</label> </label>
<p class="mt-1 text-sm text-gray-600">Automatically load plugins that are enabled in configuration.</p>
</div> </div>
<!-- Development Mode --> <!-- Development Mode -->
<div class="form-group"> <div class="form-group" id="setting-general-development_mode" data-setting-key="plugin_system.development_mode">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" <input type="checkbox"
name="development_mode" name="development_mode"
@@ -149,20 +151,19 @@
{% if main_config.get('plugin_system', {}).get('development_mode', False) %}checked{% endif %} {% if main_config.get('plugin_system', {}).get('development_mode', False) %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Development Mode</span> <span class="ml-2 text-sm font-medium text-gray-900">Development Mode</span>
{{ ui.help_tip('Enable verbose logging and developer features for plugin debugging.\nDefault: off. Keep off for normal use — it increases log volume.', 'Development Mode') }}
</label> </label>
<p class="mt-1 text-gray-600 text-sm">Enable verbose logging and development features for plugin debugging.</p>
</div> </div>
<!-- Plugins Directory --> <!-- Plugins Directory -->
<div class="form-group"> <div class="form-group" id="setting-general-plugins_directory" data-setting-key="plugin_system.plugins_directory">
<label for="plugins_directory" class="block text-sm font-medium text-gray-700">Plugins Directory</label> <label for="plugins_directory" class="block text-sm font-medium text-gray-700">Plugins Directory{{ ui.help_tip('Folder (relative to the project root) where plugins are stored.\nDefault: plugin-repos. Only change this if you keep plugins in a custom location.', 'Plugins Directory') }}</label>
<input type="text" <input type="text"
id="plugins_directory" id="plugins_directory"
name="plugins_directory" name="plugins_directory"
value="{{ main_config.get('plugin_system', {}).get('plugins_directory', 'plugin-repos') }}" value="{{ main_config.get('plugin_system', {}).get('plugins_directory', 'plugin-repos') }}"
placeholder="plugin-repos" placeholder="plugin-repos"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Directory where plugins are stored (relative to project root).</p>
</div> </div>
</div> </div>
</div> </div>
@@ -1,6 +1,8 @@
{# Plugin Configuration Partial - Server-side rendered form #} {# Plugin Configuration Partial - Server-side rendered form #}
{# This template is loaded via HTMX when a plugin tab is clicked #} {# This template is loaded via HTMX when a plugin tab is clicked #}
{% import 'v3/partials/_macros.html' as ui %}
{# ===== MACROS FOR FORM FIELD GENERATION ===== #} {# ===== MACROS FOR FORM FIELD GENERATION ===== #}
{# Render a single form field based on schema type #} {# Render a single form field based on schema type #}
@@ -18,9 +20,8 @@
{% if obj_widget == 'schedule-picker' %} {% if obj_widget == 'schedule-picker' %}
{# Schedule picker widget - renders enable/mode/times UI #} {# Schedule picker widget - renders enable/mode/times UI #}
{% set obj_value = value if value is not none else {} %} {% set obj_value = value if value is not none else {} %}
<div class="form-group mb-4"> <div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
{% if description %}<p class="text-sm text-gray-500 mb-2">{{ description }}</p>{% endif %}
<div id="{{ field_id }}_container" class="schedule-picker-container mt-1"></div> <div id="{{ field_id }}_container" class="schedule-picker-container mt-1"></div>
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "&#39;") }}'> <input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "&#39;") }}'>
</div> </div>
@@ -46,9 +47,8 @@
{% elif obj_widget == 'time-range' %} {% elif obj_widget == 'time-range' %}
{# Time range widget - renders start/end time inputs #} {# Time range widget - renders start/end time inputs #}
{% set obj_value = value if value is not none else {} %} {% set obj_value = value if value is not none else {} %}
<div class="form-group mb-4"> <div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
{% if description %}<p class="text-sm text-gray-500 mb-2">{{ description }}</p>{% endif %}
<div id="{{ field_id }}_container" class="time-range-container mt-1"></div> <div id="{{ field_id }}_container" class="time-range-container mt-1"></div>
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "&#39;") }}'> <input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "&#39;") }}'>
</div> </div>
@@ -75,15 +75,11 @@
{{ render_nested_section(key, prop, value, prefix, plugin_id) }} {{ render_nested_section(key, prop, value, prefix, plugin_id) }}
{% endif %} {% endif %}
{% else %} {% else %}
<div class="form-group mb-4"> <div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
<label for="{{ field_id }}" class="block text-sm font-medium text-gray-700 mb-1"> <label for="{{ field_id }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }} {{ label }}{{ ui.help_tip(description, label) }}
</label> </label>
{% if description %}
<p class="text-sm text-gray-500 mb-2">{{ description }}</p>
{% endif %}
{# Boolean - check for widget first #} {# Boolean - check for widget first #}
{% if field_type == 'boolean' %} {% if field_type == 'boolean' %}
{% set bool_widget = prop.get('x-widget') or prop.get('x_widget') %} {% set bool_widget = prop.get('x-widget') or prop.get('x_widget') %}
@@ -1,9 +1,12 @@
{% import 'v3/partials/_macros.html' as ui %}
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Schedule Settings</h2> <h2 class="text-lg font-semibold text-gray-900">Schedule Settings</h2>
<p class="mt-1 text-sm text-gray-600">Configure when the LED matrix display should be active. You can set global hours or customize times for each day of the week.</p> <p class="mt-1 text-sm text-gray-600">Configure when the LED matrix display should be active. You can set global hours or customize times for each day of the week.</p>
</div> </div>
{{ ui.settings_filter() }}
<form id="schedule_form" <form id="schedule_form"
hx-post="/api/v3/config/schedule" hx-post="/api/v3/config/schedule"
hx-ext="json-enc" hx-ext="json-enc"
@@ -42,9 +45,9 @@
class="space-y-6"> class="space-y-6">
<!-- Dim Brightness Level --> <!-- Dim Brightness Level -->
<div class="bg-gray-50 rounded-lg p-4 mb-4"> <div class="form-group bg-gray-50 rounded-lg p-4 mb-4" id="setting-schedule-dim_brightness" data-setting-key="dim_schedule.dim_brightness">
<label for="dim_brightness" class="block text-sm font-medium text-gray-700 mb-2"> <label for="dim_brightness" class="block text-sm font-medium text-gray-700 mb-2">
Dim Brightness Level Dim Brightness Level{{ ui.help_tip('Brightness the display drops to during dim hours (0100%).\nApplies only while the display is on. Your normal brightness is currently ' ~ normal_brightness ~ '%.', 'Dim Brightness Level') }}
</label> </label>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<input type="range" <input type="range"
@@ -59,7 +62,6 @@
{{ dim_schedule_config.dim_brightness | default(30) }}% {{ dim_schedule_config.dim_brightness | default(30) }}%
</span> </span>
</div> </div>
<p class="mt-1 text-xs text-gray-500">Current normal brightness: {{ normal_brightness }}%</p>
</div> </div>
<!-- Dim Schedule Picker Widget Container --> <!-- Dim Schedule Picker Widget Container -->
+11 -17
View File
@@ -1,3 +1,4 @@
{% import 'v3/partials/_macros.html' as ui %}
<div class="bg-white rounded-lg shadow p-6" x-data="wifiSetup()" x-init="init(); loadStatus()"> <div class="bg-white rounded-lg shadow p-6" x-data="wifiSetup()" x-init="init(); loadStatus()">
<!-- Captive Portal Banner (shown when AP mode is active) --> <!-- Captive Portal Banner (shown when AP mode is active) -->
<div x-show="status.ap_mode_active" <div x-show="status.ap_mode_active"
@@ -23,6 +24,8 @@
<p class="mt-1 text-sm text-gray-600">Configure WiFi connection for your Raspberry Pi. Access point mode will automatically activate when no WiFi connection is detected.</p> <p class="mt-1 text-sm text-gray-600">Configure WiFi connection for your Raspberry Pi. Access point mode will automatically activate when no WiFi connection is detected.</p>
</div> </div>
{{ ui.settings_filter('Filter WiFi settings…') }}
<!-- Current WiFi Status --> <!-- Current WiFi Status -->
<div class="mb-6 p-4 bg-gray-50 rounded-lg"> <div class="mb-6 p-4 bg-gray-50 rounded-lg">
<h3 class="text-sm font-medium text-gray-900 mb-2">Current Status</h3> <h3 class="text-sm font-medium text-gray-900 mb-2">Current Status</h3>
@@ -73,9 +76,9 @@
<h3 class="text-sm font-medium text-gray-900 mb-4">Connect to WiFi Network</h3> <h3 class="text-sm font-medium text-gray-900 mb-4">Connect to WiFi Network</h3>
<!-- Network Selection --> <!-- Network Selection -->
<div class="form-group mb-4"> <div class="form-group mb-4" id="setting-wifi-ssid" data-setting-key="wifi.ssid">
<label for="wifi-ssid" class="block text-sm font-medium text-gray-700 mb-2"> <label for="wifi-ssid" class="block text-sm font-medium text-gray-700 mb-2">
Step 1: Select Network Step 1: Select Network{{ ui.help_tip('Choose the WiFi network to join.\nClick Scan to list nearby networks, or type the name manually below if it is hidden.', 'Select Network') }}
</label> </label>
<div class="flex gap-2"> <div class="flex gap-2">
<select id="wifi-ssid" <select id="wifi-ssid"
@@ -98,7 +101,6 @@
<span class="ml-2">Scan</span> <span class="ml-2">Scan</span>
</button> </button>
</div> </div>
<p class="mt-1 text-sm text-gray-600">Scan for available networks or manually enter SSID below.</p>
<!-- Show selected network --> <!-- Show selected network -->
<div x-show="selectedSSID" class="mt-2 p-2 bg-blue-50 border border-blue-200 rounded text-sm" x-cloak> <div x-show="selectedSSID" class="mt-2 p-2 bg-blue-50 border border-blue-200 rounded text-sm" x-cloak>
<i class="fas fa-check-circle text-blue-600 mr-2"></i> <i class="fas fa-check-circle text-blue-600 mr-2"></i>
@@ -107,9 +109,9 @@
</div> </div>
<!-- Manual SSID Entry --> <!-- Manual SSID Entry -->
<div class="form-group mb-4"> <div class="form-group mb-4" id="setting-wifi-manual_ssid" data-setting-key="wifi.manual_ssid">
<label for="manual-ssid" class="block text-sm font-medium text-gray-700 mb-2"> <label for="manual-ssid" class="block text-sm font-medium text-gray-700 mb-2">
Or Enter SSID Manually Or Enter SSID Manually{{ ui.help_tip('Type a network name by hand when it is hidden or not shown in the scan results.\nExact spelling and capitalization matter.', 'Enter SSID Manually') }}
</label> </label>
<input type="text" <input type="text"
id="manual-ssid" id="manual-ssid"
@@ -120,19 +122,15 @@
</div> </div>
<!-- Password --> <!-- Password -->
<div class="form-group mb-4"> <div class="form-group mb-4" id="setting-wifi-password" data-setting-key="wifi.password">
<label for="wifi-password" class="block text-sm font-medium text-gray-700 mb-2"> <label for="wifi-password" class="block text-sm font-medium text-gray-700 mb-2">
Step 2: Enter Password Step 2: Enter Password{{ ui.help_tip('Password for the selected network.\nLeave empty if the network is open (no password required).', 'WiFi Password') }}
</label> </label>
<input type="password" <input type="password"
id="wifi-password" id="wifi-password"
x-model="password" x-model="password"
placeholder="Enter password (leave empty for open networks)" placeholder="Enter password (leave empty for open networks)"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">
<i class="fas fa-info-circle mr-1"></i>
Enter the WiFi password. Leave empty if the network is open (no password required).
</p>
</div> </div>
<!-- Connect Button --> <!-- Connect Button -->
@@ -154,14 +152,10 @@
</p> </p>
<!-- Auto-Enable Toggle --> <!-- Auto-Enable Toggle -->
<div class="mb-4 p-4 bg-gray-50 rounded-lg border border-gray-200"> <div class="form-group mb-4 p-4 bg-gray-50 rounded-lg border border-gray-200" id="setting-wifi-auto_enable_ap_mode" data-setting-key="wifi.auto_enable_ap_mode">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex-1"> <div class="flex-1">
<label class="text-sm font-medium text-gray-900 block mb-1">Auto-Enable AP Mode</label> <label class="text-sm font-medium text-gray-900 block mb-1">Auto-Enable AP Mode{{ ui.help_tip('Automatically start access-point mode when both WiFi and Ethernet are disconnected, so you can always reach the device to reconfigure it.\nDefault: on. When off, AP mode must be enabled manually.', 'Auto-Enable AP Mode') }}</label>
<p class="text-xs text-gray-600">
When enabled, AP mode will automatically activate when both WiFi and Ethernet are disconnected.
When disabled, AP mode must be manually enabled.
</p>
</div> </div>
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<button type="button" <button type="button"