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