From d1e821c625ee7918b1f88cd477e8a484c07336e1 Mon Sep 17 00:00:00 2001
From: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
Date: Mon, 14 Sep 2026 09:42:24 -0400
Subject: [PATCH] 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
* 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
* 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
* 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
* 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 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
* 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
* 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
---------
Co-authored-by: Claude Opus 5
---
test/test_color_picker_widget.py | 25 +
test/test_web_smoke.py | 12 +
test/test_web_static_audit.py | 85 +-
test/test_widget_scripts.py | 113 ++-
.../test_calendar_oauth_endpoints.py | 8 +-
test/web_interface/test_gzip_fallback.py | 68 ++
.../web_interface/test_plugin_widget_route.py | 21 +
web_interface/app.py | 79 +-
web_interface/blueprints/pages_v3.py | 33 +-
web_interface/static/v3/app.css | 724 ++++++++++++++++--
web_interface/static/v3/app.js | 62 +-
web_interface/static/v3/js/app-shell.js | 228 +++++-
.../static/v3/js/config/diff_viewer.js | 66 +-
web_interface/static/v3/js/settings-search.js | 4 +-
web_interface/static/v3/js/utils/dialog.js | 139 ++++
.../static/v3/js/utils/error_handler.js | 195 +++--
.../static/v3/js/widgets/array-table.js | 32 +-
.../static/v3/js/widgets/color-picker.js | 6 +-
.../static/v3/js/widgets/date-picker.js | 6 +-
.../v3/js/widgets/file-upload-single.js | 4 +-
.../static/v3/js/widgets/file-upload.js | 12 +-
.../static/v3/js/widgets/json-file-manager.js | 164 ++--
.../static/v3/js/widgets/notification.js | 239 +++++-
.../static/v3/js/widgets/number-input.js | 10 +-
.../static/v3/js/widgets/password-input.js | 8 +-
.../v3/js/widgets/plugin-file-manager.js | 205 +++--
.../static/v3/js/widgets/schedule-picker.js | 7 +-
.../static/v3/js/widgets/text-input.js | 4 +-
.../static/v3/js/widgets/time-picker.js | 6 +-
web_interface/static/v3/plugins_manager.js | 186 ++++-
web_interface/templates/v3/base.html | 183 ++---
web_interface/templates/v3/captive_setup.html | 230 ++++--
.../templates/v3/partials/backup_restore.html | 2 +-
.../templates/v3/partials/display.html | 19 +-
.../templates/v3/partials/fonts.html | 72 +-
web_interface/templates/v3/partials/logs.html | 68 +-
.../v3/partials/operation_history.html | 10 +-
.../templates/v3/partials/overview.html | 2 +-
.../templates/v3/partials/plugin_config.html | 120 ++-
.../templates/v3/partials/plugins.html | 49 +-
.../templates/v3/partials/raw_json.html | 8 +-
.../v3/partials/starlark_config.html | 47 +-
.../templates/v3/partials/tools.html | 68 +-
web_interface/templates/v3/partials/wifi.html | 3 +
web_interface/widget_bundle.py | 107 +++
45 files changed, 2869 insertions(+), 870 deletions(-)
create mode 100644 test/test_color_picker_widget.py
create mode 100644 test/web_interface/test_gzip_fallback.py
create mode 100644 web_interface/static/v3/js/utils/dialog.js
create mode 100644 web_interface/widget_bundle.py
diff --git a/test/test_color_picker_widget.py b/test/test_color_picker_widget.py
new file mode 100644
index 00000000..bd6a9577
--- /dev/null
+++ b/test/test_color_picker_widget.py
@@ -0,0 +1,25 @@
+"""Regression: the native color input in the color-picker widget must have
+an accessible name independent of showHexInput.
+
+CodeRabbit flagged (PR #568) that the only carried a
+`title` attribute -- screen readers don't reliably announce `title`, and
+when showHexInput is false there is no other label naming the control.
+"""
+from pathlib import Path
+
+WIDGET_JS = (
+ Path(__file__).resolve().parent.parent
+ / "web_interface" / "static" / "v3" / "js" / "widgets" / "color-picker.js"
+)
+
+
+def test_native_color_input_has_aria_label():
+ source = WIDGET_JS.read_text(encoding="utf-8")
+ start = source.index('", start)
+ tag = source[start:end]
+ assert 'aria-label=' in tag, (
+ "native color lost its accessible name; screen readers need "
+ "aria-label since it has no associated