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
+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);
};
}