fix(web): harden, polish and optimize the web UI per the Sept 2026 audit (#568)

* fix(web): harden, polish and optimize the web UI per the September 2026 audit

Works through docs/archive/WEB_UI_AUDIT_2026-09.md (health 8/20).

Implementation integrity (P0)
- app.css now defines every utility class the templates and JS use,
  including .hidden, so the ~145 JS show/hide toggles work. Button reset,
  and base component rules (.btn, .form-control) wrapped in :where() so
  utility classes on the same element win. New static-audit test fails
  when a used utility class has no rule.

Accessibility
- Focus rings render (the old ring rule referenced undefined variables);
  one :focus-visible outline everywhere; skip link; labelled nav landmarks.
- Shared dialog helper (js/utils/dialog.js): role/aria-modal, focus trap,
  Escape, focus return, applied to every modal.
- Named icon-only buttons and labelled ~70 form fields.
- Toasts announced once; errors persist >= 10s; one showNotification.
- Captive WiFi page: live region, timeouts, dark mode, 16px inputs.

Performance (Pi Zero 2 W)
- SSE streams and tab timers pause when hidden or off-tab; the display
  stream only runs while a preview is visible. app-shell.js deferred.
- Widget scripts served as one versioned bundle (/assets/widgets.js):
  52 -> 21 script tags, 66 -> 35 requests on first load.
- Stdlib gzip fallback when flask-compress is missing: first-load JS/CSS
  1358 KB -> 291 KB on the wire. SSE untouched.

Theming and responsive
- File managers, form fields and Fonts upload on theme tokens; bare
  inputs themed in dark mode; no more white surfaces.
- No horizontal overflow at 375px on any tab; 44px touch targets on
  coarse pointers; reduced-motion respected; header title truncates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): clear Codacy findings on #568

- json-file-manager: focus-trap releases kept in a Map (no dynamic
  property access or delete; no value-returning forEach callback)
- notification / schedule-picker: style and day-label lookups via Map
- app.js: move the pending-queue assignment out of the expression
- diff_viewer / error_handler: named function declarations instead of
  arrow consts

No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: check the OAuth widget ships in the widget bundle

base.html no longer tags widget scripts one by one; they load through
/assets/widgets.js. Assert the page requests the bundle and the bundle
contains google-oauth.js, which is what the test was protecting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): address review feedback on #568

- widget bundle version fingerprints every file (name, mtime_ns, size)
- gzip fallback appends Accept-Encoding to an existing Vary header
- dialog helper: releasing a non-top dialog no longer moves focus out of
  the dialog the user is in
- labels: file-upload targets its file input; fallback config fields get
  label for/id pairs; native color input has a fallback name
- utility audit also reads class names inside bound :class expressions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): give the native color-picker input an accessible name

CodeRabbit flagged this on PR #568 as an outside-diff finding (never
posted inline, so it was missed in the round of fixes that addressed
the other 6 review comments). The <input type="color"> only carried a
title attribute; screen readers don't reliably announce title, and
there's no other label naming the control when showHexInput is false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): clear Codacy findings in app-shell.js

- drop the unused catch binding on the SSE JSON parse
- move the pending-notification queue assignment out of the expression

No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): contain plugin widgets/ dir and bound style-editor retries

From CodeRabbit review on #568 (code that arrived with the main merge):
- serve_plugin_widget resolves widgets/ with resolve_under before
  resolving the manifest script under it, so a symlinked widgets
  directory can't become the containment base (CWE-22). New test.
- style-editor init stops polling after ~10s when the widget never
  registers and leaves the plain fallback fields in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-14 09:42:24 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 69d408b321
commit d1e821c625
45 changed files with 2869 additions and 870 deletions
@@ -52,7 +52,7 @@
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Backup file</label>
<label for="restore-file-input" class="block text-sm font-medium text-gray-700 mb-2">Backup file</label>
<input type="file" id="restore-file-input" accept=".zip"
class="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
</div>
@@ -1039,10 +1039,25 @@ window.showDisplaySaveResult = function(xhr) {
content.appendChild(textSpan);
}
// Initial UI state and polling — guard against duplicate intervals on re-run
// Initial UI state, then the 5s sync-status poll — only while the Display
// tab is active and the page is visible. LEDVisibility keys the
// registration, so a re-run of this partial replaces the previous timer.
function stopSyncTimer() {
if (window.syncStatusInterval) {
clearInterval(window.syncStatusInterval);
window.syncStatusInterval = null;
}
}
function startSyncTimer() {
stopSyncTimer();
pollSyncStatus();
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
}
function startSyncPolling() {
updateSyncUI();
if (!window.syncStatusInterval) {
if (window.LEDVisibility) {
window.LEDVisibility.onActive('display', startSyncTimer, stopSyncTimer, 'display-sync-status');
} else if (!window.syncStatusInterval) {
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
}
}
+42 -30
View File
@@ -39,7 +39,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family Name</label>
<label for="upload-font-family" class="block text-sm font-medium text-gray-700 mb-1">Font Family Name</label>
<input type="text" id="upload-font-family" class="form-control" placeholder="e.g., my-custom-font">
<p class="text-sm text-gray-600 mt-1">Custom name for this font (letters, numbers, underscores, hyphens)</p>
</div>
@@ -75,24 +75,24 @@
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6">
<div>
<div id="font-preview-container" class="border border-gray-300 bg-black rounded p-4 min-h-[100px] flex items-center justify-center">
<img id="font-preview-image" src="" alt="Font preview" class="max-w-full" style="display: none;">
<img id="font-preview-image" alt="Font preview" class="max-w-full" style="display: none;">
<span id="font-preview-loading" class="text-gray-400">Select a font to preview</span>
</div>
</div>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Preview Text</label>
<label for="preview-text" class="block text-sm font-medium text-gray-700 mb-1">Preview Text</label>
<input type="text" id="preview-text" value="Sample Text 123" class="form-control text-sm">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
<label for="preview-family" class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
<select id="preview-family" class="form-control text-sm">
<!-- Dynamically populated from font catalog -->
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Size</label>
<label for="preview-size" class="block text-sm font-medium text-gray-700 mb-1">Font Size</label>
<select id="preview-size" class="form-control text-sm">
<option value="xs">XS (6px)</option>
<option value="sm">SM (8px)</option>
@@ -163,11 +163,18 @@ function initializeFontsTab() {
// Reset retry counter on successful init
initRetryCount = 0;
// showNotification is provided by the notification widget (notification.js)
// Fallback only if widget hasn't loaded yet
// showNotification is implemented by the notification widget
// (widgets/notification.js). If it hasn't loaded, queue the message; the
// widget shows queued messages as soon as it registers.
if (typeof window.showNotification !== 'function') {
window.showNotification = function(message, type = 'info') {
console.log(`[${type.toUpperCase()}]`, message);
const registry = window.LEDMatrixWidgets;
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
if (widget && typeof widget.show === 'function') {
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
}
(window.__pendingNotifications = window.__pendingNotifications || []).push([message, type]);
console.log(`[${String((type && type.type) || type).toUpperCase()}]`, message);
};
}
@@ -725,9 +732,8 @@ function updateUploadProgress(percent) {
<style>
.drag-over {
border-color: #3b82f6 !important;
background-color: #eff6ff !important;
transform: scale(1.02);
border-color: var(--color-primary, #3b82f6) !important;
background-color: var(--color-info-bg, #eff6ff) !important;
}
.font-upload-area {
@@ -735,45 +741,51 @@ function updateUploadProgress(percent) {
}
.upload-dropzone {
border: 3px dashed #d1d5db;
border-radius: 10px;
padding: 40px;
border: 2px dashed var(--color-field-border, #d1d5db);
border-radius: 0.75rem;
padding: 2rem 1.5rem;
text-align: center;
background: #fafafa;
transition: all 0.3s ease;
background: var(--color-background, #f9fafb);
transition: border-color 0.15s ease, background-color 0.15s ease;
cursor: pointer;
}
@media (min-width: 640px) {
.upload-dropzone {
padding: 2.5rem;
}
}
.upload-dropzone:hover {
border-color: #3b82f6;
background: #f0f8ff;
border-color: var(--color-primary, #3b82f6);
background: var(--color-info-bg, #eff6ff);
}
.upload-dropzone i {
font-size: 3rem;
color: #3b82f6;
margin-bottom: 15px;
color: var(--color-primary, #3b82f6);
margin-bottom: 0.75rem;
display: block;
}
.upload-dropzone p {
margin: 5px 0;
font-size: 1.1rem;
color: #374151;
margin: 0.25rem 0;
font-size: 1rem;
color: var(--color-text-secondary, #374151);
}
.upload-hint {
font-size: 0.9rem !important;
color: #6b7280 !important;
font-size: 0.875rem !important;
color: var(--color-text-tertiary, #4b5563) !important;
font-style: italic;
}
.upload-form {
margin-top: 20px;
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
border: 1px solid #d1d5db;
margin-top: 1.25rem;
padding: 1.25rem;
background: var(--color-background, #f9fafb);
border-radius: 0.5rem;
border: 1px solid var(--color-border, #d1d5db);
}
#font-preview-container {
+51 -17
View File
@@ -17,9 +17,9 @@
<!-- Controls -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4">
<div class="flex flex-wrap items-center gap-3 sm:gap-4">
<!-- Log Mode Toggle -->
<div class="flex items-center space-x-2">
<div class="flex items-center gap-2">
<label class="flex items-center">
<input type="checkbox" id="log-realtime-toggle" class="form-control h-4 w-4" checked>
<span class="ml-2 text-sm font-medium">Real-time</span>
@@ -31,7 +31,7 @@
</div>
<!-- Log Level Filter -->
<select id="log-level-filter" class="form-control text-sm">
<select id="log-level-filter" aria-label="Filter logs by level" class="form-control text-sm w-auto">
<option value="">All Levels</option>
<option value="ERROR">Errors Only</option>
<option value="WARNING">Warnings & Errors</option>
@@ -39,18 +39,18 @@
</select>
<!-- Plugin Filter -->
<select id="log-plugin-filter" class="form-control text-sm">
<select id="log-plugin-filter" aria-label="Filter logs by plugin" class="form-control text-sm w-auto">
<option value="">All Plugins</option>
</select>
<!-- Search -->
<div class="relative">
<input type="text" id="log-search" placeholder="Search logs..." class="form-control text-sm pl-8 pr-4 py-1 w-48">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
<input type="text" id="log-search" placeholder="Search logs..." aria-label="Search logs" class="form-control text-sm pl-8 pr-4 py-1 w-48">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs pointer-events-none" aria-hidden="true"></i>
</div>
</div>
<div class="flex items-center space-x-2">
<div class="flex flex-wrap items-center gap-2">
<!-- Auto-scroll toggle -->
<label class="flex items-center">
<input type="checkbox" id="log-autoscroll" class="form-control h-4 w-4" checked>
@@ -72,7 +72,7 @@
<!-- Log Display -->
<div class="relative">
<div id="logs-container" class="bg-gray-900 text-gray-100 font-mono text-sm rounded-lg p-3 border border-gray-700 shadow-inner relative" style="height: 500px; min-height: 400px; max-height: 70vh;">
<div id="logs-loading" class="absolute inset-0 flex items-center justify-center text-gray-400 bg-gray-900">
<div id="logs-loading" class="absolute inset-0 flex items-center justify-center text-gray-300 bg-gray-900">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-2xl mb-2"></i>
<p>Loading logs...</p>
@@ -83,7 +83,7 @@
<!-- Logs will be inserted here -->
</div>
</div>
<div id="logs-empty" class="hidden absolute inset-0 flex items-center justify-center text-gray-400 bg-gray-900">
<div id="logs-empty" class="hidden absolute inset-0 flex items-center justify-center text-gray-300 bg-gray-900">
<div class="text-center">
<i class="fas fa-file-alt text-4xl mb-2"></i>
<p>No logs available</p>
@@ -215,13 +215,32 @@ window._filteredLogs = [];
});
}
refreshCurrentPluginStatus();
if (window._currentPluginPollTimer) {
clearInterval(window._currentPluginPollTimer);
// Current-plugin poll and the realtime log stream run only while the Logs
// tab is active and the page is visible (both restart with an immediate
// refresh). Re-registering on partial reload replaces the old one.
function startLogsActivity() {
refreshCurrentPluginStatus();
if (window._currentPluginPollTimer) clearInterval(window._currentPluginPollTimer);
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
if (window._isRealtime && !window._logsEventSource) setupRealtimeLogs();
}
function stopLogsActivity() {
if (window._currentPluginPollTimer) {
clearInterval(window._currentPluginPollTimer);
window._currentPluginPollTimer = null;
}
stopRealtimeLogs();
}
if (window.LEDVisibility) {
window.LEDVisibility.onActive('logs', startLogsActivity, stopLogsActivity);
} else {
startLogsActivity();
}
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
// Handle window resize for responsive height
// Handle window resize for responsive height. Added once: this script
// re-runs on every partial load, and the handler reads window globals.
if (window._logsResizeListenerAdded) return;
window._logsResizeListenerAdded = true;
window.addEventListener('resize', function() {
if (window._logContainer) {
// Force a reflow to update sizing
@@ -270,10 +289,24 @@ function loadLogs() {
});
}
function setupRealtimeLogs() {
// Closes the log stream and cancels any pending reconnect, so a paused
// (inactive tab / hidden page) stream stays closed.
function stopRealtimeLogs() {
if (window._logsReconnectTimer) {
clearTimeout(window._logsReconnectTimer);
window._logsReconnectTimer = null;
}
if (window._logsEventSource) {
window._logsEventSource.close();
window._logsEventSource = null;
}
}
function setupRealtimeLogs() {
stopRealtimeLogs();
// Don't open (or reconnect) while the Logs tab isn't on screen;
// LEDVisibility restarts the stream when it is.
if (window.LEDVisibility && !window.LEDVisibility.isActive('logs')) return;
window._logsEventSource = new EventSource('/api/v3/stream/logs');
@@ -302,8 +335,9 @@ function setupRealtimeLogs() {
<span>Disconnected from log stream</span>
`;
// Attempt to reconnect after 5 seconds
setTimeout(setupRealtimeLogs, 5000);
// Attempt to reconnect after 5 seconds (cancelled by stopRealtimeLogs)
if (window._logsReconnectTimer) clearTimeout(window._logsReconnectTimer);
window._logsReconnectTimer = setTimeout(setupRealtimeLogs, 5000);
};
}
@@ -8,12 +8,12 @@
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4">
<!-- Filter by Plugin -->
<select id="history-plugin-filter" class="form-control text-sm">
<select id="history-plugin-filter" class="form-control text-sm" aria-label="Filter by plugin">
<option value="">All Plugins</option>
</select>
<!-- Filter by Operation Type -->
<select id="history-type-filter" class="form-control text-sm">
<select id="history-type-filter" class="form-control text-sm" aria-label="Filter by operation type">
<option value="">All Operations</option>
<option value="install">Install</option>
<option value="update">Update</option>
@@ -24,7 +24,7 @@
</select>
<!-- Filter by Status -->
<select id="history-status-filter" class="form-control text-sm">
<select id="history-status-filter" class="form-control text-sm" aria-label="Filter by status">
<option value="">All Statuses</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
@@ -32,8 +32,8 @@
<!-- Search -->
<div class="relative">
<input type="text" id="history-search" placeholder="Search operations..." class="form-control text-sm pl-8 pr-4 py-1 w-48">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
<input type="text" id="history-search" placeholder="Search operations..." aria-label="Search operations" class="form-control text-sm pl-8 pr-4 py-1 w-48">
<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>
</div>
@@ -98,7 +98,7 @@
<div class="flex-1">
<p class="text-sm font-semibold text-blue-900"><i class="fas fa-rocket mr-1"></i>Getting Started</p>
<p class="text-xs text-blue-700 mt-0.5 mb-2">A few steps to get your display up and running. Click a step to jump there, or click its checkbox to mark it done yourself. The card hides once everything is checked.</p>
<ul class="space-y-1 text-sm" id="getting-started-items">
<ul class="list-none pl-0 space-y-1 text-sm" id="getting-started-items">
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _hw_done else '0' }}" data-tab="display">
<i class="far fa-square mr-2"></i>Set your panel size (Display tab)</button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="timezone" data-tz="{{ _tz }}" data-tab="general">
@@ -21,8 +21,8 @@
{# Schedule picker widget - renders enable/mode/times UI #}
{% set obj_value = value if value is not none else {} %}
<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 }}{{ ui.help_tip(description, label) }}</label>
<div id="{{ field_id }}_container" class="schedule-picker-container mt-1"></div>
<label id="{{ field_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
<div id="{{ field_id }}_container" class="schedule-picker-container mt-1" role="group" aria-labelledby="{{ field_id }}-label"></div>
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "&#39;") }}'>
</div>
<script>
@@ -48,8 +48,8 @@
{# Time range widget - renders start/end time inputs #}
{% set obj_value = value if value is not none else {} %}
<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 }}{{ ui.help_tip(description, label) }}</label>
<div id="{{ field_id }}_container" class="time-range-container mt-1"></div>
<label id="{{ field_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
<div id="{{ field_id }}_container" class="time-range-container mt-1" role="group" aria-labelledby="{{ field_id }}-label"></div>
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "&#39;") }}'>
</div>
<script>
@@ -88,6 +88,15 @@
</div>
<script>
(function() {
// ~10s at 50ms: enough for the widget bundle to arrive over a
// slow link to a Pi, bounded so a bundle that never registers
// style-editor doesn't poll for the life of the page.
var retries = 0;
var MAX_RETRIES = 200;
function giveUp() {
var container = document.getElementById('{{ field_id }}_container');
if (container) { container.remove(); }
}
function fallbackIsDirty(root) {
if (!root) { return false; }
var controls = root.querySelectorAll('input, select, textarea');
@@ -106,9 +115,12 @@
return false;
}
function initWidget() {
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
var widget = window.LEDMatrixWidgets.get('style-editor');
if (!widget) { setTimeout(initWidget, 50); return; }
var widget = window.LEDMatrixWidgets && window.LEDMatrixWidgets.get('style-editor');
if (!widget) {
// The plain fallback fields stay in place either way.
if (++retries < MAX_RETRIES) { setTimeout(initWidget, 50); } else { giveUp(); }
return;
}
var container = document.getElementById('{{ field_id }}_container');
var fallback = document.getElementById('{{ field_id }}_fallback');
if (!container) { return; }
@@ -145,8 +157,27 @@
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
{% endif %}
{% else %}
{# Point the label at the control that actually takes focus. Widgets
render their own element (e.g. <id>_input), so a plain for="<id>"
would label nothing. Mirrors the branch order below; a miss just
falls back to the old for="<id>". #}
{% set _lw = prop.get('x-widget') or prop.get('x_widget') %}
{% set label_target = field_id %}
{% if field_type == 'boolean' %}
{% if _lw == 'toggle-switch' %}{% set label_target = field_id ~ '_button' %}{% endif %}
{% elif prop.enum %}
{% elif field_type in ['number', 'integer'] %}
{% if _lw in ['slider', 'number-input'] %}{% set label_target = field_id ~ '_input' %}{% endif %}
{% elif field_type == 'array' %}
{% if _lw == 'color-picker' %}{% set label_target = field_id ~ '_hex' %}
{% elif _lw == 'file-upload' %}{% set label_target = field_id ~ '_file_input' %}{% endif %}
{% elif _lw in ['text-input', 'textarea', 'select-dropdown', 'date-picker', 'time-picker', 'slider', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
{% set label_target = field_id ~ '_input' %}
{% elif _lw == 'color-picker' %}
{% set label_target = field_id ~ '_color' %}
{% endif %}
<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="{{ label_target }}" id="{{ field_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}{{ ui.help_tip(description, label) }}
</label>
@@ -277,7 +308,11 @@
data-max-files="{{ max_files }}"
data-max-size-mb="{{ max_size_mb }}"
data-allowed-types="{{ allowed_types|join(',') }}"
onclick="document.getElementById('{{ field_id }}_file_input').click()">
role="button"
tabindex="0"
aria-label="Upload files: drop them here or press Enter to choose"
onclick="document.getElementById('{{ field_id }}_file_input').click()"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();document.getElementById('{{ field_id }}_file_input').click();}">
<input type="file"
id="{{ field_id }}_file_input"
multiple
@@ -310,8 +345,9 @@
<div id="img_{{ img_id|string|replace('.', '_')|replace('-', '_') }}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-3 flex-1">
<img src="/{{ img.get('path', '') }}"
alt="{{ img.get('filename', '') }}"
<img src="/{{ img.get('path', '') }}"
alt="{{ img.get('filename', '') }}"
loading="lazy" decoding="async"
class="w-16 h-16 object-cover rounded"
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
<div style="display:none;" class="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
@@ -336,9 +372,10 @@
data-image-id="{{ img_id }}"
data-image-idx="{{ loop.index0 }}"
onclick="window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId || null, parseInt(this.dataset.imageIdx))"
class="text-blue-600 hover:text-blue-800 p-2"
title="Schedule this image">
<i class="fas fa-calendar-alt"></i>
class="text-blue-600 hover:text-blue-800 p-2"
title="Schedule this image"
aria-label="Schedule image {{ img.get('original_filename') or img.get('filename', '') }}">
<i class="fas fa-calendar-alt" aria-hidden="true"></i>
</button>
<button type="button"
data-field-id="{{ field_id }}"
@@ -346,8 +383,9 @@
data-plugin-id="{{ plugin_id_from_config }}"
onclick="window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId)"
class="text-red-600 hover:text-red-800 p-2"
title="Delete image">
<i class="fas fa-trash"></i>
title="Delete image"
aria-label="Delete image {{ img.get('original_filename') or img.get('filename', '') }}">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -387,7 +425,7 @@
</div>
{% endif %}
<div class="mt-1 space-y-2">
<div class="mt-1 space-y-2" role="group" aria-labelledby="{{ field_id }}-label">
{% for option in enum_items %}
{% set is_checked = option in array_value %}
{% set option_label = labels.get(option, option|replace('_', ' ')|title) %}
@@ -508,6 +546,7 @@
name="{{ full_key }}.{{ item_index }}.name"
value="{{ item.get('name', '') }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
aria-label="Feed name, row {{ item_index + 1 }}"
placeholder="Feed Name"
required>
</td>
@@ -516,6 +555,7 @@
name="{{ full_key }}.{{ item_index }}.url"
value="{{ item.get('url', '') }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
aria-label="Feed URL, row {{ item_index + 1 }}"
placeholder="https://example.com/feed"
required>
</td>
@@ -534,7 +574,7 @@
<i class="fas fa-upload mr-1"></i> Upload
</button>
{% if logo_path %}
<img src="/{{ logo_path }}" alt="Logo" class="w-8 h-8 object-cover rounded border" id="{{ field_id }}_logo_preview_{{ item_index }}">
<img src="/{{ logo_path }}" alt="Logo" loading="lazy" decoding="async" class="w-8 h-8 object-cover rounded border" id="{{ field_id }}_logo_preview_{{ item_index }}">
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.path" value="{{ logo_path }}">
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.id" value="{{ logo_value.get('id', '') }}">
{% else %}
@@ -549,13 +589,15 @@
{% if item.get('enabled', true) %}checked{% endif %}
value="true"
onchange="this.previousElementSibling.value = this.checked ? 'true' : 'false'"
aria-label="Feed enabled, row {{ item_index + 1 }}"
class="h-4 w-4 text-blue-600">
</td>
<td class="px-4 py-3 whitespace-nowrap text-center">
<button type="button"
onclick="removeCustomFeedRow(this)"
aria-label="Remove feed, row {{ item_index + 1 }}"
class="text-red-600 hover:text-red-800 px-2 py-1">
<i class="fas fa-trash"></i>
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</td>
</tr>
@@ -624,6 +666,8 @@
<tr class="array-table-row" data-index="{{ item_index }}">
{% for col_name in display_columns %}
{% set col_def = item_properties.get(col_name, {}) %}
{# Accessible name for the cell's control: column title + row. #}
{% set cell_label = col_def.get('title', col_name|replace('_', ' ')|title) ~ ', row ' ~ (item_index + 1) %}
{# Normalize nullable types e.g. ["null","integer"] → "integer" #}
{% set _raw_type = col_def.get('type', 'string') %}
{% if _raw_type is iterable and _raw_type is not string %}
@@ -652,6 +696,7 @@
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ col_name }}" value="{{ 'true' if col_value else 'false' }}">
<input type="checkbox"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
{% if col_value %}checked{% endif %}
value="true"
onchange="this.previousElementSibling.value = this.checked ? 'true' : 'false'"
@@ -659,6 +704,7 @@
{% elif col_type == 'integer' or col_type == 'number' %}
<input type="number"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '' }}"
{% if col_def.get('minimum') is not none %}min="{{ col_def.get('minimum') }}"{% endif %}
{% if col_def.get('maximum') is not none %}max="{{ col_def.get('maximum') }}"{% endif %}
@@ -671,6 +717,7 @@
which must not be title-cased behind the user's back. #}
{% set col_labels = (col_def.get('x-options') or col_def.get('x_options') or {}).get('labels') or {} %}
<select name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white">
{% for opt in col_enum %}{% if opt is not none %}
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ col_labels.get(opt, opt) }}</option>
@@ -679,28 +726,32 @@
{% elif col_xwidget == 'date-picker' %}
<input type="date"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '' }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
{% elif col_xwidget == 'time-picker' %}
<input type="time"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '00:00' }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
{% elif col_xwidget == 'file-upload-single' %}
{% set cell_input_id = field_id ~ '_' ~ item_index ~ '_' ~ col_name %}
<div class="flex items-center gap-1">
{% if col_value %}<img src="/{{ col_value }}" class="w-6 h-6 object-cover rounded flex-shrink-0" onerror="this.style.display='none'">{% endif %}
{% if col_value %}<img src="/{{ col_value }}" alt="" loading="lazy" decoding="async" class="w-6 h-6 object-cover rounded flex-shrink-0" onerror="this.style.display='none'">{% endif %}
<input type="text"
id="{{ cell_input_id }}"
aria-label="{{ cell_label }}"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
value="{{ col_value if col_value is not none else '' }}"
class="block w-20 px-1 py-1 border border-gray-300 rounded text-xs"
placeholder="path…">
<label class="cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100" title="Upload image">
<i class="fas fa-upload"></i>
<label class="file-trigger cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100" title="Upload image">
<i class="fas fa-upload" aria-hidden="true"></i>
<input type="file"
class="sr-only"
aria-label="Upload image: {{ cell_label }}"
accept="image/png,image/jpeg,image/bmp,image/gif"
style="display:none"
data-plugin-id="{{ plugin_id }}"
data-target-input="{{ cell_input_id }}"
onchange="(function(e){ const t=document.getElementById('{{ cell_input_id }}'); const p=t.previousElementSibling && t.previousElementSibling.tagName==='IMG' ? t.previousElementSibling : null; window.handleArrayTableImageUpload(e,t,p,'{{ plugin_id }}'); })(event)">
@@ -709,6 +760,7 @@
{% else %}
<input type="text"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '' }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
{% if col_def.get('description') %}placeholder="{{ col_def.get('description') }}"{% endif %}
@@ -725,15 +777,17 @@
<td class="px-3 py-3 whitespace-nowrap text-center" style="min-width:90px;vertical-align:middle">
<button type="button"
onclick="removeArrayTableRow(this)"
aria-label="Remove row {{ item_index + 1 }}"
class="text-red-600 hover:text-red-800 px-2 py-1">
<i class="fas fa-trash"></i>
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
{% if has_advanced.value %}
<button type="button"
onclick="openArrayTableRowEditor(this)"
class="text-blue-500 hover:text-blue-700 px-2 py-1 ml-1"
aria-label="Edit advanced settings for row {{ item_index + 1 }}"
title="Edit layout, style and other advanced properties">
<i class="fas fa-sliders-h"></i>
<i class="fas fa-sliders-h" aria-hidden="true"></i>
</button>
{% endif %}
</td>
@@ -804,9 +858,10 @@
value="{{ hex_val }}"
class="h-9 w-12 cursor-pointer rounded border border-gray-300"
title="Color picker"
aria-label="{{ label }} color picker"
oninput="(function(h){var r=parseInt(h.slice(1,3),16),g=parseInt(h.slice(3,5),16),b=parseInt(h.slice(5,7),16);document.getElementById('{{ field_id }}_r').value=r;document.getElementById('{{ field_id }}_g').value=g;document.getElementById('{{ field_id }}_b').value=b;})(this.value)">
<div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">R</label>
<label for="{{ field_id }}_r" class="text-xs text-gray-500 font-medium"><span aria-hidden="true">R</span><span class="sr-only">Red</span></label>
<input type="number" min="0" max="255" step="1"
id="{{ field_id }}_r"
name="{{ full_key }}.0"
@@ -815,7 +870,7 @@
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
</div>
<div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">G</label>
<label for="{{ field_id }}_g" class="text-xs text-gray-500 font-medium"><span aria-hidden="true">G</span><span class="sr-only">Green</span></label>
<input type="number" min="0" max="255" step="1"
id="{{ field_id }}_g"
name="{{ full_key }}.1"
@@ -824,7 +879,7 @@
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
</div>
<div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">B</label>
<label for="{{ field_id }}_b" class="text-xs text-gray-500 font-medium"><span aria-hidden="true">B</span><span class="sr-only">Blue</span></label>
<input type="number" min="0" max="255" step="1"
id="{{ field_id }}_b"
name="{{ full_key }}.2"
@@ -1235,20 +1290,21 @@
{% for key, value in config.items() %}
{% if key not in ['enabled'] %}
<div class="form-group mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">
{% set fallback_id = 'fallback-field-' ~ (key|string|replace(' ', '-')) %}
<label for="{{ fallback_id }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ key|replace('_', ' ')|title }}
</label>
{% if value is sameas true or value is sameas false %}
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="{{ key }}" {% if value %}checked{% endif %}
<input type="checkbox" id="{{ fallback_id }}" name="{{ key }}" {% if value %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm text-gray-600">Enabled</span>
</label>
{% elif value is number %}
<input type="number" name="{{ key }}" value="{{ value }}"
<input type="number" id="{{ fallback_id }}" name="{{ key }}" value="{{ value }}"
class="form-input w-full rounded-md border-gray-300 bg-gray-900 text-white placeholder:text-gray-400">
{% else %}
<input type="text" name="{{ key }}" value="{{ value }}"
<input type="text" id="{{ fallback_id }}" name="{{ key }}" value="{{ value }}"
class="form-input w-full rounded-md border-gray-300 bg-gray-900 text-white placeholder:text-gray-400">
{% endif %}
</div>
@@ -6,7 +6,7 @@
<!-- Plugin Controls -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4">
<div class="flex flex-wrap items-center gap-2 sm:gap-4">
<button type="button" id="refresh-plugins-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
<i class="fas fa-sync-alt mr-2"></i>Refresh Plugins
</button>
@@ -34,7 +34,7 @@
<div id="installed-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Search -->
<div class="relative flex-1 min-w-[12rem]">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none" aria-hidden="true"></i>
<input type="text"
id="installed-search"
class="form-control text-sm pl-9 pr-8 py-2"
@@ -44,7 +44,7 @@
<button type="button" id="installed-search-clear"
class="hidden absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="Clear search">
<i class="fas fa-times-circle"></i>
<i class="fas fa-times-circle" aria-hidden="true"></i>
</button>
</div>
@@ -149,7 +149,7 @@
<!-- GitHub Token Configuration (Combined Warning + Settings) -->
<div id="github-token-container" class="mb-5">
<!-- Warning Banner (shown when no token configured) -->
<div id="github-auth-warning" class="hidden bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded-r-lg">
<div id="github-auth-warning" class="hidden bg-yellow-50 border border-yellow-300 p-4 rounded-lg">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-yellow-400"></i>
@@ -170,8 +170,8 @@
</p>
</div>
<div class="flex-shrink-0">
<button onclick="dismissGithubWarning()" class="text-yellow-700 hover:text-yellow-900">
<i class="fas fa-times"></i>
<button type="button" onclick="dismissGithubWarning()" class="text-yellow-700 hover:text-yellow-900" aria-label="Dismiss GitHub token warning">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -207,9 +207,10 @@
<input type="password" id="github-token-input"
class="w-full px-3 py-2 pr-20 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx">
<button type="button" onclick="toggleGithubTokenVisibility()"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700">
<i id="github-token-icon" class="fas fa-eye"></i>
<button type="button" onclick="toggleGithubTokenVisibility()"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700"
aria-label="Show token" aria-controls="github-token-input">
<i id="github-token-icon" class="fas fa-eye" aria-hidden="true"></i>
</button>
</div>
<p class="text-xs text-gray-500 mt-1">
@@ -246,8 +247,8 @@
</div>
<!-- Search Row -->
<div class="flex gap-3 mb-4">
<input type="text" id="plugin-search" placeholder="Search plugins by name, description, or tags..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="plugin-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<input type="text" id="plugin-search" placeholder="Search plugins by name, description, or tags..." aria-label="Search the Plugin Store" class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="plugin-category" aria-label="Filter the Plugin Store by category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<option value="">All Categories</option>
<option value="sports">Sports</option>
<option value="content">Content</option>
@@ -262,7 +263,7 @@
<!-- Sort & Filter Bar -->
<div id="store-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Sort -->
<select id="store-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<select id="store-sort" aria-label="Sort the Plugin Store" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="a-z">A → Z</option>
<option value="z-a">Z → A</option>
<option value="category">Category</option>
@@ -289,13 +290,13 @@
<!-- Results Bar (top pagination) -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<span id="store-results-info" class="text-sm text-gray-600"></span>
<div class="flex items-center gap-3">
<select id="store-per-page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<div class="flex flex-wrap items-center gap-3">
<select id="store-per-page" aria-label="Plugins per page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<option value="12">12 per page</option>
<option value="24">24 per page</option>
<option value="48">48 per page</option>
</select>
<div id="store-pagination-top" class="flex items-center gap-1"></div>
<div id="store-pagination-top" class="flex flex-wrap items-center gap-1"></div>
</div>
</div>
@@ -355,8 +356,8 @@
<!-- Search Row -->
<div class="flex gap-3 mb-4">
<input type="text" id="starlark-search" placeholder="Search by name, description, author..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="starlark-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<input type="text" id="starlark-search" placeholder="Search by name, description, author..." aria-label="Search Starlark apps" class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="starlark-category" aria-label="Filter Starlark apps by category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<option value="">All Categories</option>
</select>
</div>
@@ -364,7 +365,7 @@
<!-- Sort & Filter Bar -->
<div id="starlark-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Sort -->
<select id="starlark-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<select id="starlark-sort" aria-label="Sort Starlark apps" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="a-z">A → Z</option>
<option value="z-a">Z → A</option>
<option value="category">Category</option>
@@ -379,7 +380,7 @@
</button>
<!-- Author filter -->
<select id="starlark-filter-author" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<select id="starlark-filter-author" aria-label="Filter Starlark apps by author" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="">All Authors</option>
</select>
@@ -395,13 +396,13 @@
<!-- Results Bar (top pagination) -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<span id="starlark-results-info" class="text-sm text-gray-600"></span>
<div class="flex items-center gap-3">
<select id="starlark-per-page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<div class="flex flex-wrap items-center gap-3">
<select id="starlark-per-page" aria-label="Starlark apps per page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<option value="24">24 per page</option>
<option value="48">48 per page</option>
<option value="96">96 per page</option>
</select>
<div id="starlark-pagination-top" class="flex items-center gap-1"></div>
<div id="starlark-pagination-top" class="flex flex-wrap items-center gap-1"></div>
</div>
</div>
@@ -463,7 +464,7 @@
<p class="text-xs text-gray-600 mb-3">Install a plugin directly from its GitHub repository URL</p>
<div class="space-y-2">
<div class="flex gap-2">
<input type="text" id="github-plugin-url"
<input type="text" id="github-plugin-url" aria-label="Plugin GitHub repository URL"
placeholder="https://github.com/user/ledmatrix-plugin-name"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<button type="button" id="install-plugin-from-url"
@@ -491,7 +492,7 @@
</h4>
<p class="text-xs text-gray-600 mb-3">Load a registry-style monorepo (like the official ledmatrix-plugins repo) to browse and install plugins</p>
<div class="flex gap-2 mb-2">
<input type="text" id="github-registry-url"
<input type="text" id="github-registry-url" aria-label="Plugin registry GitHub URL"
placeholder="https://github.com/user/ledmatrix-plugins"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<button id="load-registry-from-url" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm rounded-md whitespace-nowrap">
@@ -4,7 +4,7 @@
<div class="border-b border-gray-200 pb-4 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">config.json Editor</h2>
<h2 id="main-config-editor-title" class="text-lg font-semibold text-gray-900">config.json Editor</h2>
<p class="mt-1 text-sm text-gray-600">{{ main_config_path }}</p>
</div>
<div class="flex gap-2">
@@ -28,7 +28,7 @@
</div>
<div class="relative">
<textarea id="main-config-editor"
<textarea id="main-config-editor" aria-labelledby="main-config-editor-title"
class="w-full h-96 font-mono text-sm p-4 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
spellcheck="false">{{ main_config_json }}</textarea>
<div id="main-config-validation" class="mt-2 text-sm"></div>
@@ -55,7 +55,7 @@
<div class="border-b border-gray-200 pb-4 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">config_secrets.json Editor</h2>
<h2 id="secrets-config-editor-title" class="text-lg font-semibold text-gray-900">config_secrets.json Editor</h2>
<p class="mt-1 text-sm text-gray-600">{{ secrets_config_path }}</p>
</div>
<div class="flex gap-2">
@@ -79,7 +79,7 @@
</div>
<div class="relative">
<textarea id="secrets-config-editor"
<textarea id="secrets-config-editor" aria-labelledby="secrets-config-editor-title"
class="w-full h-96 font-mono text-sm p-4 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
spellcheck="false">{{ secrets_config_json }}</textarea>
<div id="secrets-config-validation" class="mt-2 text-sm"></div>
@@ -1,3 +1,6 @@
{# Prefix for label/input id pairs; app ids and field ids are sanitised so
the ids stay valid and unique if several app configs are on the page. #}
{% set sl_prefix = ('starlark-' ~ app_id)|replace(':', '-')|replace('.', '-')|replace(' ', '-')|replace('/', '-') %}
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between pb-4 border-b border-gray-200">
@@ -59,16 +62,18 @@
<div id="starlark-config-form" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">Render Interval (seconds)</label>
<label for="{{ sl_prefix }}-render-interval" class="block text-sm font-medium text-gray-700 mb-1">Render Interval (seconds)</label>
<input type="number" min="10" max="86400" step="1"
id="{{ sl_prefix }}-render-interval"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ render_interval }}"
data-starlark-config="render_interval">
<p class="text-xs text-gray-400 mt-1">How often the app re-renders (fetches new data)</p>
</div>
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
<label for="{{ sl_prefix }}-display-duration" class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
<input type="number" min="1" max="3600" step="1"
id="{{ sl_prefix }}-display-duration"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ display_duration }}"
data-starlark-config="display_duration">
@@ -98,12 +103,14 @@
{% set field_desc = field.desc or '' %}
{% set field_default = field.default if field.default is defined else '' %}
{% set current_val = config.get(field_id, field_default) if config else field_default %}
{% set input_id = (sl_prefix ~ '-f-' ~ field_id)|replace(' ', '-')|replace('.', '-')|replace(':', '-')|replace('/', '-') %}
{# ── text ── #}
{% if field_type == 'text' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
placeholder="{{ field_desc }}"
@@ -116,8 +123,8 @@
{# ── dropdown ── #}
{% elif field_type == 'dropdown' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<select class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white"
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<select id="{{ input_id }}" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white"
data-starlark-config="{{ field_id }}">
{% for opt in (field.options or []) %}
<option value="{{ opt.value }}" {{ 'selected' if current_val|string == opt.value|string else '' }}>
@@ -149,14 +156,16 @@
{# ── color ── #}
{% elif field_type == 'color' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="flex items-center gap-2">
<input type="color"
aria-label="{{ field_name }} color picker"
class="w-10 h-10 rounded border border-gray-300 cursor-pointer p-0.5"
value="{{ current_val or '#FFFFFF' }}"
data-starlark-color-picker="{{ field_id }}"
oninput="this.closest('.space-y-6').querySelector('[data-starlark-config={{ field_id }}]').value = this.value">
<input type="text"
id="{{ input_id }}"
class="form-control flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm font-mono"
value="{{ current_val or '#FFFFFF' }}"
placeholder="#RRGGBB"
@@ -171,8 +180,9 @@
{# ── datetime ── #}
{% elif field_type == 'datetime' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="datetime-local"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
data-starlark-config="{{ field_id }}">
@@ -184,10 +194,11 @@
{# ── location (mini-form) ── #}
{% elif field_type == 'location' %}
<div class="form-group" data-starlark-location-group="{{ field_id }}" data-starlark-location-value="{{ current_val }}">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<label id="{{ input_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2" role="group" aria-labelledby="{{ input_id }}-label">
<div>
<input type="number" step="any" min="-90" max="90"
aria-label="{{ field_name }} latitude"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Latitude"
data-starlark-location-field="{{ field_id }}"
@@ -195,6 +206,7 @@
</div>
<div>
<input type="number" step="any" min="-180" max="180"
aria-label="{{ field_name }} longitude"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Longitude"
data-starlark-location-field="{{ field_id }}"
@@ -204,6 +216,7 @@
<input type="text"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Timezone (e.g. America/New_York)"
aria-label="{{ field_name }} timezone"
data-starlark-location-field="{{ field_id }}"
data-starlark-location-key="timezone">
</div>
@@ -243,8 +256,9 @@
{# ── typeahead / location_based (text fallback with note) ── #}
{% elif field_type in ('typeahead', 'location_based') %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
placeholder="{{ field_desc }}"
@@ -261,8 +275,9 @@
{# ── unknown type (text fallback) ── #}
{% else %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
placeholder="{{ field_desc }}"
@@ -287,8 +302,9 @@
{% for key, value in config.items() %}
{% if key not in ('render_interval', 'display_duration') and key not in schema_ids %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }} <span class="text-xs text-gray-400">(custom)</span></label>
<input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
{% set custom_id = (sl_prefix ~ '-c-' ~ key)|replace(' ', '-')|replace('.', '-')|replace(':', '-')|replace('/', '-') %}
<label for="{{ custom_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ key }} <span class="text-xs text-gray-400">(custom)</span></label>
<input type="text" id="{{ custom_id }}" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ value }}"
data-starlark-config="{{ key }}">
</div>
@@ -303,8 +319,9 @@
{% for key, value in config.items() %}
{% if key not in ('render_interval', 'display_duration') %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }}</label>
<input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
{% set raw_id = (sl_prefix ~ '-r-' ~ key)|replace(' ', '-')|replace('.', '-')|replace(':', '-')|replace('/', '-') %}
<label for="{{ raw_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ key }}</label>
<input type="text" id="{{ raw_id }}" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ value }}"
data-starlark-config="{{ key }}">
</div>
+37 -31
View File
@@ -32,7 +32,7 @@
<div class="space-y-4">
<!-- Switch branch -->
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">Branch</p>
<p class="text-xs text-gray-500 mt-0.5">Choose which branch this pi follows. Switching attaches tracking, so Pull Latest works afterwards.</p>
@@ -50,7 +50,7 @@
<div id="result-checkout-branch" class="hidden"></div>
<!-- Pull latest -->
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Pull latest (rebase)</p>
<p class="text-xs text-gray-500 mt-0.5">Stashes any local changes, then runs <code class="bg-gray-100 px-1 rounded">git pull --rebase</code>. The stash is preserved but not re-applied.</p>
@@ -63,7 +63,7 @@
<div id="result-git-pull" class="hidden"></div>
<!-- Force reset -->
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Force reset to <code class="bg-gray-100 px-1 rounded">origin/main</code></p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">git fetch origin</code> then <code class="bg-gray-100 px-1 rounded">git reset --hard origin/main</code>. Discards all local changes.</p>
@@ -99,7 +99,7 @@
<div class="space-y-4">
<!-- Base requirements -->
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Reinstall base requirements</p>
<p class="text-xs text-gray-500 mt-0.5">Installs from <code class="bg-gray-100 px-1 rounded">requirements.txt</code> in the project root.</p>
@@ -112,7 +112,7 @@
<div id="result-base-reqs" class="hidden"></div>
<!-- Plugin requirements -->
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Reinstall plugin requirements</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">pip install</code> for every installed plugin that has a <code class="bg-gray-100 px-1 rounded">requirements.txt</code>.</p>
@@ -134,7 +134,7 @@
</div>
<div class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Clear Python cache</p>
<p class="text-xs text-gray-500 mt-0.5">Deletes all <code class="bg-gray-100 px-1 rounded">__pycache__</code> directories in the project. Useful after switching branches or debugging import issues.</p>
@@ -166,14 +166,15 @@
<p class="mt-1 text-sm text-gray-600">Turn the WiFi radio on or off. Full WiFi setup (scan, connect, hotspot) lives on the <span class="font-medium">WiFi</span> tab.</p>
</div>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">WiFi radio</p>
<p id="wifi-radio-label" class="text-sm font-medium text-gray-900">WiFi radio</p>
<p id="wifi-radio-note" class="text-xs text-gray-500 mt-0.5">Checking current state…</p>
</div>
<div class="shrink-0 flex flex-col items-end gap-2">
<!-- Toggle switch -->
<button id="wifi-radio-toggle" type="button" role="switch" aria-checked="false"
aria-labelledby="wifi-radio-label" aria-describedby="wifi-radio-note"
onclick="onWifiRadioToggleClick()" disabled
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-gray-200 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 opacity-60">
<span id="wifi-radio-knob" aria-hidden="true"
@@ -207,7 +208,7 @@
</div>
<div class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Restart display service</p>
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix.service</code>.</p>
@@ -219,7 +220,7 @@
</div>
<div id="result-restart-display" class="hidden"></div>
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Restart web interface</p>
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix-web.service</code>. The page will go offline briefly.</p>
@@ -282,7 +283,7 @@
<div class="space-y-4">
<!-- Reboot -->
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Reboot</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">sudo reboot</code>. The Pi will restart and come back online in a minute or two.</p>
@@ -308,7 +309,7 @@
<div id="result-reboot" class="hidden"></div>
<!-- Shutdown -->
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Shut down</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">sudo poweroff</code>. The Pi will power off and must be unplugged/replugged (or power-cycled) to turn back on.</p>
@@ -979,26 +980,31 @@
loadGitInfo();
loadBranches();
// Plugin health: initial load + periodic refresh. Guard against duplicate
// timers if this partial is re-swapped in by HTMX; the handler re-resolves
// DOM nodes by id each tick.
refreshPluginHealth(false);
if (!window._pluginHealthTimer) {
window._pluginHealthTimer = setInterval(function () { refreshPluginHealth(true); }, 15000);
// Plugin health (15s) and system diagnostics (10s): the partial stays in
// the DOM (hidden via x-show) when another tab is active, so both polls
// run only while the Tools tab is active and the page is visible — each
// restarts with an immediate refresh. LEDVisibility keys the registration,
// so a partial re-swapped in by HTMX replaces (not stacks) the timers.
function stopToolsPolling() {
if (window._pluginHealthTimer) { clearInterval(window._pluginHealthTimer); window._pluginHealthTimer = null; }
if (window._diagPollInterval) { clearInterval(window._diagPollInterval); window._diagPollInterval = null; }
}
// System diagnostics: load now, then refresh every 10s. Clear any prior
// interval so re-swapping the partial doesn't stack. The recurring poll is
// gated on visibility — the partial stays in the DOM (hidden via x-show)
// when another tab is active, so without this it would keep hitting
// /api/v3/system/status every 10s and churn the Pi while off-screen.
if (window._diagPollInterval) clearInterval(window._diagPollInterval);
window.loadSystemDiagnostics();
window._diagPollInterval = setInterval(function () {
const panel = document.getElementById('diag-panel');
if (!panel || document.hidden || panel.offsetParent === null) return;
function startToolsPolling() {
stopToolsPolling();
refreshPluginHealth(false);
window._pluginHealthTimer = setInterval(function () { refreshPluginHealth(true); }, 15000);
window.loadSystemDiagnostics();
}, 10000);
window._diagPollInterval = setInterval(function () {
const panel = document.getElementById('diag-panel');
if (!panel || document.hidden || panel.offsetParent === null) return;
window.loadSystemDiagnostics();
}, 10000);
}
if (window.LEDVisibility) {
window.LEDVisibility.onActive('tools', startToolsPolling, stopToolsPolling);
} else {
startToolsPolling();
}
// WiFi radio current state.
window.loadWifiRadio();
@@ -1223,7 +1229,7 @@
const active = status.running ? `
<div class="rounded-md border border-amber-300 bg-amber-50 p-3">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-amber-900">
<i class="fas fa-circle-notch fa-spin mr-1"></i>
@@ -159,6 +159,9 @@
</div>
<div class="flex-shrink-0">
<button type="button"
role="switch"
aria-label="Auto-Enable AP Mode"
:aria-checked="status.auto_enable_ap_mode ? 'true' : 'false'"
@click="toggleAutoEnable(!status.auto_enable_ap_mode)"
:class="status.auto_enable_ap_mode
? 'bg-blue-600 hover:bg-blue-700'