mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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:
+73
-40
@@ -1,66 +1,99 @@
|
||||
"""Guard: every widget JS file must be loaded by base.html or explicitly allowlisted.
|
||||
"""Guard: every widget JS file must ship in the widget bundle or be allowlisted.
|
||||
|
||||
Widget files register themselves with LEDMatrixWidgets at load time; a file
|
||||
that exists but is never <script>-included silently breaks any plugin whose
|
||||
config schema declares that widget (the field renders as an empty container
|
||||
that polls the registry forever). base.html's widget list is maintained by
|
||||
hand, so this test keeps it honest.
|
||||
that exists but is never loaded silently breaks any plugin whose config schema
|
||||
declares that widget (the field renders as an empty container that polls the
|
||||
registry forever).
|
||||
|
||||
base.html loads them as one concatenated request (web_interface/widget_bundle.py),
|
||||
so BUNDLE_ORDER is the hand-maintained list this test keeps honest. It also
|
||||
checks that base.html actually requests the bundle, and that the bundle's
|
||||
concatenation order puts the registry and base class first.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Set
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from web_interface import widget_bundle # noqa: E402
|
||||
|
||||
WIDGETS_DIR = PROJECT_ROOT / 'web_interface' / 'static' / 'v3' / 'js' / 'widgets'
|
||||
BASE_HTML = PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'base.html'
|
||||
|
||||
# Matches url_for('static', filename='...') inside actual <script> tags.
|
||||
SCRIPT_SRC_RE = re.compile(
|
||||
r"""<script\s[^>]*src="\{\{\s*url_for\(\s*'static'\s*,\s*filename='([^']+)'\s*\)\s*\}\}[^"]*"""
|
||||
)
|
||||
|
||||
# Files that must NOT be script-included, with the reason.
|
||||
ALLOWLIST = {
|
||||
# Documentation example (docs/widget-guide.md); registers the name
|
||||
# 'color-picker' and would shadow the real color-picker.js if loaded.
|
||||
'example-color-picker.js',
|
||||
}
|
||||
# Files that must NOT be loaded, with the reason.
|
||||
ALLOWLIST = set(widget_bundle.EXCLUDED)
|
||||
|
||||
|
||||
def _included_widget_scripts() -> Set[str]:
|
||||
"""Return widget JS basenames referenced by real <script> tags in base.html."""
|
||||
base_html = BASE_HTML.read_text(encoding='utf-8')
|
||||
return {
|
||||
Path(filename).name
|
||||
for filename in SCRIPT_SRC_RE.findall(base_html)
|
||||
if filename.startswith('v3/js/widgets/')
|
||||
}
|
||||
def _bundled_names() -> Set[str]:
|
||||
return set(widget_bundle.BUNDLE_ORDER)
|
||||
|
||||
|
||||
def test_every_widget_script_is_included_in_base_html() -> None:
|
||||
"""Every non-allowlisted widget file must be loaded by a <script> tag."""
|
||||
def test_every_widget_script_is_bundled() -> None:
|
||||
"""Every non-allowlisted widget file must be in BUNDLE_ORDER."""
|
||||
assert WIDGETS_DIR.is_dir(), f'Widget directory missing: {WIDGETS_DIR}'
|
||||
included = _included_widget_scripts()
|
||||
assert included, 'No widget <script> tags found in base.html — regex or template drift?'
|
||||
bundled = _bundled_names()
|
||||
assert bundled, 'BUNDLE_ORDER is empty — widget_bundle drift?'
|
||||
missing = [
|
||||
js_file.name
|
||||
for js_file in sorted(WIDGETS_DIR.glob('*.js'))
|
||||
if js_file.name not in ALLOWLIST and js_file.name not in included
|
||||
if js_file.name not in ALLOWLIST and js_file.name not in bundled
|
||||
]
|
||||
assert not missing, (
|
||||
'Widget files exist but are never <script>-included in base.html '
|
||||
'(plugins declaring these widgets get blank config fields): '
|
||||
+ ', '.join(missing)
|
||||
+ '. Add a script tag to base.html or add the file to ALLOWLIST '
|
||||
'with a reason.'
|
||||
'Widget files exist but are never loaded (plugins declaring these '
|
||||
'widgets get blank config fields): ' + ', '.join(missing)
|
||||
+ '. Add the file to BUNDLE_ORDER in web_interface/widget_bundle.py, '
|
||||
'or to EXCLUDED with a reason.'
|
||||
)
|
||||
|
||||
|
||||
def test_allowlisted_widgets_are_not_included() -> None:
|
||||
"""Allowlisted (must-not-load) widget files must stay out of base.html."""
|
||||
included = _included_widget_scripts()
|
||||
wrongly_included = [name for name in ALLOWLIST if name in included]
|
||||
def test_allowlisted_widgets_are_not_bundled() -> None:
|
||||
"""Allowlisted (must-not-load) widget files stay out of the bundle."""
|
||||
wrongly_included = sorted(ALLOWLIST & _bundled_names())
|
||||
assert not wrongly_included, (
|
||||
'Allowlisted (must-not-load) widget files are script-included in '
|
||||
'base.html: ' + ', '.join(wrongly_included)
|
||||
'Allowlisted (must-not-load) widget files are in the bundle: '
|
||||
+ ', '.join(wrongly_included)
|
||||
)
|
||||
|
||||
|
||||
def test_bundle_order_lists_only_existing_files() -> None:
|
||||
"""A renamed or deleted widget must not linger in BUNDLE_ORDER."""
|
||||
stale = [name for name in widget_bundle.BUNDLE_ORDER
|
||||
if not (WIDGETS_DIR / name).is_file()]
|
||||
assert not stale, f'BUNDLE_ORDER names files that do not exist: {stale}'
|
||||
|
||||
|
||||
def test_registry_and_base_widget_load_first() -> None:
|
||||
"""Widgets call into the registry and base class as they load."""
|
||||
order = widget_bundle.BUNDLE_ORDER
|
||||
assert order[0] == 'registry.js', order[:3]
|
||||
assert order[1] == 'base-widget.js', order[:3]
|
||||
|
||||
|
||||
def test_base_html_requests_the_bundle() -> None:
|
||||
"""base.html must load the bundle (and no longer tag widgets one by one)."""
|
||||
base_html = BASE_HTML.read_text(encoding='utf-8')
|
||||
assert 'widgets_bundle_url()' in base_html, (
|
||||
'base.html does not request the widget bundle'
|
||||
)
|
||||
per_file = re.findall(
|
||||
r"""<script\s[^>]*src="\{\{\s*url_for\(\s*'static'\s*,\s*"""
|
||||
r"""filename='(v3/js/widgets/[^']+)'""",
|
||||
base_html,
|
||||
)
|
||||
assert not per_file, (
|
||||
'base.html still loads widget files individually alongside the '
|
||||
f'bundle (they would run twice): {per_file}'
|
||||
)
|
||||
|
||||
|
||||
def test_bundle_concatenates_every_file() -> None:
|
||||
"""The built bundle contains each file, separated so sources can't merge."""
|
||||
body, version = widget_bundle.build_bundle()
|
||||
assert version and version == widget_bundle.bundle_version()
|
||||
for name in widget_bundle.BUNDLE_ORDER:
|
||||
assert f'/* {name} */' in body, f'{name} missing from the built bundle'
|
||||
for name in ALLOWLIST:
|
||||
assert f'/* {name} */' not in body, f'{name} must not be bundled'
|
||||
|
||||
Reference in New Issue
Block a user