mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* 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>
100 lines
3.9 KiB
Python
100 lines
3.9 KiB
Python
"""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 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'
|
|
|
|
# Files that must NOT be loaded, with the reason.
|
|
ALLOWLIST = set(widget_bundle.EXCLUDED)
|
|
|
|
|
|
def _bundled_names() -> Set[str]:
|
|
return set(widget_bundle.BUNDLE_ORDER)
|
|
|
|
|
|
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}'
|
|
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 bundled
|
|
]
|
|
assert not missing, (
|
|
'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_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 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'
|