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
+78 -1
View File
@@ -4,6 +4,7 @@ import logging
import os
import queue
import re
import gzip
import shutil
import sys
import subprocess
@@ -69,10 +70,12 @@ except ImportError:
# Enable gzip/brotli response compression (Flask-Compress skips streaming
# responses, so the SSE endpoints are unaffected). Optional, like limiter:
# missing package just means uncompressed responses.
_HAVE_FLASK_COMPRESS = False
try:
from flask_compress import Compress
Compress(app)
_HAVE_FLASK_COMPRESS = True
except ImportError:
logging.getLogger(__name__).warning(
"flask-compress not installed - responses will be served uncompressed. "
@@ -500,6 +503,80 @@ def add_static_version(endpoint, values):
pass
# Gzip fallback for when flask-compress isn't installed. Without it the UI
# ships ~1.2 MB of uncompressed JS to a phone over WiFi. Compressed bytes are
# cached per URL+version, so the Pi compresses each asset once, not per request.
# URL-versioned assets: safe to cache as immutable (and to gzip once).
# /assets/ is the widget bundle from pages_v3 (also reachable under /v3).
_VERSIONED_ASSET_PREFIXES = ('/static/', '/assets/', '/v3/assets/')
_GZIP_MIN_BYTES = 1024
_GZIP_TYPES = (
'text/html', 'text/css', 'text/plain', 'text/javascript',
'application/javascript', 'application/json', 'image/svg+xml',
)
_GZIP_CACHE_MAX_BYTES = 4 * 1024 * 1024
_gzip_cache = {}
_gzip_cache_bytes = 0
_gzip_cache_lock = threading.Lock()
@app.after_request
def compress_text_responses(response):
"""Gzip text responses when flask-compress is absent (SSE untouched)."""
if _HAVE_FLASK_COMPRESS or response.status_code != 200:
return response
if 'Content-Encoding' in response.headers:
return response
# send_file responses report is_streamed (they wrap a file) but have a
# known size; genuinely streamed bodies (SSE, generators) are left alone.
# SSE is also excluded by its text/event-stream mimetype below.
if response.is_streamed and not response.direct_passthrough:
return response
mimetype = (response.mimetype or '').lower()
if mimetype not in _GZIP_TYPES:
return response
if 'gzip' not in (request.headers.get('Accept-Encoding') or '').lower():
return response
cache_key = None
if request.path.startswith(_VERSIONED_ASSET_PREFIXES):
cache_key = (request.full_path, response.headers.get('Last-Modified'))
with _gzip_cache_lock:
cached = _gzip_cache.get(cache_key)
if cached is not None:
return _apply_gzip(response, cached)
# send_file responses stream from disk; materialize before compressing.
if response.direct_passthrough:
response.direct_passthrough = False
data = response.get_data()
if len(data) < _GZIP_MIN_BYTES:
return response
compressed = gzip.compress(data, compresslevel=6)
if len(compressed) >= len(data):
return response
if cache_key is not None:
global _gzip_cache_bytes
with _gzip_cache_lock:
if _gzip_cache_bytes + len(compressed) <= _GZIP_CACHE_MAX_BYTES:
_gzip_cache[cache_key] = compressed
_gzip_cache_bytes += len(compressed)
return _apply_gzip(response, compressed)
def _apply_gzip(response, compressed):
response.set_data(compressed)
response.headers['Content-Encoding'] = 'gzip'
response.headers['Content-Length'] = str(len(compressed))
if 'accept-encoding' not in (response.headers.get('Vary') or '').lower():
response.headers.add('Vary', 'Accept-Encoding')
etag = response.headers.get('ETag')
if etag and 'gzip' not in etag:
response.headers['ETag'] = etag.rstrip('"') + '-gzip"'
return response
# Add security headers and caching to all responses
@app.after_request
def add_security_headers(response):
@@ -511,7 +588,7 @@ def add_security_headers(response):
response.headers['X-XSS-Protection'] = '1; mode=block'
# Add caching headers for static assets
if request.path.startswith('/static/'):
if request.path.startswith(_VERSIONED_ASSET_PREFIXES):
# Cache static assets for 1 year (with versioning via query params)
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
response.headers['Expires'] = (datetime.now() + timedelta(days=365)).strftime('%a, %d %b %Y %H:%M:%S GMT')