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 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:
@@ -37,11 +37,11 @@ def test_every_used_breakpoint_class_is_defined():
|
||||
class_attr = re.compile(r'class="([^"]*)"')
|
||||
bp_class = re.compile(r"\b(%s):[A-Za-z0-9_.-]+" % "|".join(BP_PREFIXES))
|
||||
for path in _template_files():
|
||||
for attr in class_attr.findall(path.read_text()):
|
||||
for attr in class_attr.findall(path.read_text(encoding="utf-8")):
|
||||
for m in bp_class.finditer(attr):
|
||||
used.add(m.group(0))
|
||||
|
||||
css = APP_CSS.read_text()
|
||||
css = APP_CSS.read_text(encoding="utf-8")
|
||||
defined = {
|
||||
m.group(0).lstrip(".").replace("\\:", ":")
|
||||
for m in re.finditer(
|
||||
@@ -56,13 +56,90 @@ def test_every_used_breakpoint_class_is_defined():
|
||||
)
|
||||
|
||||
|
||||
_VARIANTS = (
|
||||
r"(?:(?:sm|md|lg|xl|2xl|hover|focus|focus-visible|active|disabled|first|last"
|
||||
r"|group-hover|peer-checked|peer-focus|placeholder|file):)*"
|
||||
)
|
||||
_UTILITY = re.compile(
|
||||
"^" + _VARIANTS + r"-?(?:"
|
||||
r"(?:block|inline|inline-block|inline-flex|flex|grid|hidden|table|contents"
|
||||
r"|static|fixed|absolute|relative|sticky|truncate|sr-only|transform"
|
||||
r"|uppercase|lowercase|capitalize|italic|underline|line-through|border|shadow"
|
||||
r"|rounded|transition|grow|shrink|group|peer)"
|
||||
r"|(?:m|p)[trblxy]?-[\w./]+"
|
||||
r"|(?:w|h|min-w|min-h|max-w|max-h|gap|gap-x|gap-y|space-x|space-y|top|right"
|
||||
r"|bottom|left|inset|inset-x|inset-y|z|opacity|duration|scale|rotate"
|
||||
r"|translate-x|translate-y|grid-cols|col-span|leading|tracking)-[\w./\[\]]+"
|
||||
r"|(?:text|bg|border|divide|ring|placeholder|accent)-(?:[a-z]+-\d{2,3}(?:/\d+)?"
|
||||
r"|white|black|transparent|xs|sm|base|md|lg|xl|[2-5]xl|left|center|right"
|
||||
r"|opacity-\d+|[trblxy](?:-\d)?|\d|dashed|dotted|offset-\d)"
|
||||
r"|font-(?:mono|sans|serif|thin|light|normal|medium|semibold|bold|extrabold)"
|
||||
r"|select-(?:none|all|text|auto)"
|
||||
r"|(?:rounded|shadow|items|justify|self|flex|whitespace|break|object|list"
|
||||
r"|overflow|overflow-x|overflow-y|cursor|pointer-events|appearance"
|
||||
r"|ease|divide|line-clamp)-[a-z0-9]+(?:-[a-z0-9]+)?(?:\[[^\]]+\])?"
|
||||
r")$"
|
||||
)
|
||||
|
||||
|
||||
def _css_light_classes(css):
|
||||
"""Classes that get a rule outside [data-theme="dark"]."""
|
||||
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||
defined = set()
|
||||
for block in re.finditer(r"([^{}]+)\{", css):
|
||||
for sel in block.group(1).split(","):
|
||||
sel = sel.strip()
|
||||
if sel.startswith("@") or sel.startswith('[data-theme="dark"]'):
|
||||
continue
|
||||
for m in re.finditer(r"\.((?:\\.|[\w-])+)", sel):
|
||||
defined.add(m.group(1).replace("\\", ""))
|
||||
return defined
|
||||
|
||||
|
||||
def test_every_used_utility_class_is_defined():
|
||||
"""app.css is the whole stylesheet (no Tailwind build), so a utility class
|
||||
it doesn't define silently does nothing. `.hidden` was missing for years,
|
||||
which broke every JS show/hide toggle. Scans templates and static JS."""
|
||||
attr = re.compile(r"""(?:class|className)\s*[=:]\s*(["'`])(.*?)\1""", re.S)
|
||||
class_list = re.compile(r"classList\.(?:add|remove|toggle)\(([^)]*)\)")
|
||||
template_expr = re.compile(r"\{\{.*?\}\}|\{%.*?%\}|\$\{[^}]*\}", re.S)
|
||||
files = list(_template_files()) + [
|
||||
p for p in (STATIC / "v3").rglob("*.js")
|
||||
if "vendor" not in p.parts and not p.name.endswith(".min.js")
|
||||
]
|
||||
used = {}
|
||||
for path in files:
|
||||
text = template_expr.sub(" ", path.read_text(encoding="utf-8"))
|
||||
chunks = [m.group(2) for m in attr.finditer(text)]
|
||||
chunks += re.findall(r"""['"]([^'"]*)['"]""", " ".join(
|
||||
m.group(1) for m in class_list.finditer(text)))
|
||||
# :class="{ 'hidden': open }" and ternaries hold class names inside
|
||||
# string literals; read those as well as the raw chunk.
|
||||
bound = re.compile(r""":class\s*=\s*"([^"]*)""")
|
||||
for m in bound.finditer(text):
|
||||
chunks.append(m.group(1))
|
||||
for chunk in chunks:
|
||||
candidates = [chunk] + re.findall(r"""['`]([^'`]*)['`]""", chunk)
|
||||
for candidate in candidates:
|
||||
for token in candidate.split():
|
||||
if _UTILITY.match(token):
|
||||
used.setdefault(token, path.relative_to(PROJECT_ROOT))
|
||||
|
||||
defined = _css_light_classes(APP_CSS.read_text(encoding="utf-8"))
|
||||
missing = sorted(f"{cls} ({used[cls]})" for cls in used if cls not in defined)
|
||||
assert not missing, (
|
||||
"Utility classes used in templates/JS but not defined in app.css "
|
||||
f"(they silently no-op): {missing}"
|
||||
)
|
||||
|
||||
|
||||
def test_every_static_url_for_points_to_a_real_file():
|
||||
ref = re.compile(
|
||||
r"url_for\(\s*['\"]static['\"]\s*,\s*filename\s*=\s*['\"]([^'\"]+)['\"]"
|
||||
)
|
||||
missing = []
|
||||
for path in _template_files():
|
||||
for filename in ref.findall(path.read_text()):
|
||||
for filename in ref.findall(path.read_text(encoding="utf-8")):
|
||||
if not (STATIC / filename).is_file():
|
||||
missing.append(f"{path.relative_to(PROJECT_ROOT)}: {filename}")
|
||||
assert not missing, f"Templates reference missing static assets: {missing}"
|
||||
@@ -73,7 +150,7 @@ def test_js_files_calling_debuglog_declare_the_global():
|
||||
for path in sorted((STATIC / "v3").rglob("*.js")):
|
||||
if "vendor" in path.parts:
|
||||
continue
|
||||
text = path.read_text()
|
||||
text = path.read_text(encoding="utf-8")
|
||||
# Calls debugLog( but neither defines it nor declares the global
|
||||
calls = re.search(r"(?<![.\w])debugLog\(", text)
|
||||
defines = "window.debugLog" in text
|
||||
|
||||
Reference in New Issue
Block a user