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')
+31 -2
View File
@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, flash, jsonify
from flask import Blueprint, Response, render_template, flash, jsonify, url_for
from jinja2 import TemplateNotFound
from markupsafe import escape
from html.parser import HTMLParser
@@ -14,6 +14,7 @@ _SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
from src.web_interface.secret_helpers import mask_secret_fields
from src.common.path_safety import resolve_under, safe_path_component
from web_interface import widget_bundle
logger = logging.getLogger(__name__)
@@ -26,6 +27,30 @@ schema_manager = None
pages_v3 = Blueprint('pages_v3', __name__)
@pages_v3.route('/assets/widgets.js')
def widgets_bundle():
"""Every widget script in one request (see web_interface/widget_bundle.py).
The individual files stay served from /static for plugin-loader.js and
debugging; this saves the Pi ~30 round trips on each first page load.
Cached as immutable by app.py because the URL carries the version.
"""
body, version = widget_bundle.build_bundle()
response = Response(body, mimetype='application/javascript')
response.headers['X-Widget-Bundle-Version'] = str(version)
return response
@pages_v3.app_context_processor
def inject_widget_bundle_url():
"""`widgets_bundle_url()` for templates, versioned by file mtime."""
return {
'widgets_bundle_url': lambda: url_for(
'pages_v3.widgets_bundle', v=widget_bundle.bundle_version()
)
}
class _SettingsIndexParser(HTMLParser):
"""Extract searchable settings fields from a rendered partial's HTML.
@@ -456,7 +481,11 @@ def serve_plugin_widget(plugin_id, widget_name):
# to ship an undeclared file is not something to confirm.
return 'Not found', 404, {'Content-Type': 'text/plain'}
widgets_dir = (plugin_dir / 'widgets').resolve()
# Contain widgets/ itself first: a symlinked widgets directory would
# otherwise become the base the script is checked against.
widgets_dir = resolve_under(plugin_dir, 'widgets')
if widgets_dir is None:
return 'Not found', 404, {'Content-Type': 'text/plain'}
# The script name comes from the plugin's manifest, not the request,
# so it gets the same containment treatment the URL parts got.
script_path = resolve_under(widgets_dir, script)
+654 -70
View File
@@ -26,6 +26,9 @@
--color-info-bg: #dbeafe;
--color-purple-bg: #f3e8ff;
--color-purple-text: #6b21a8;
--color-field-bg: #ffffff;
--color-field-border: #d1d5db;
--color-focus-ring: #2563eb;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 5%);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 10%), 0 1px 2px 0 rgb(0 0 0 / 6%);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 10%), 0 2px 4px -1px rgb(0 0 0 / 6%);
@@ -57,6 +60,9 @@
--color-info-bg: #1e3a5f;
--color-purple-bg: #2e1065;
--color-purple-text: #c4b5fd;
--color-field-bg: #374151;
--color-field-border: #4b5563;
--color-focus-ring: #60a5fa;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 30%);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 40%), 0 1px 2px 0 rgb(0 0 0 / 30%);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 40%), 0 2px 4px -1px rgb(0 0 0 / 30%);
@@ -105,6 +111,77 @@ body {
background-color: var(--color-background);
}
/* Border utilities (.border, .border-t, ...) set width and style only; this
gives them the theme's border color unless a border-* color class says
otherwise. */
*, ::before, ::after {
border-color: var(--color-border);
}
/* Button reset. Markup styles buttons with utility classes (bg-*, px-*,
border); without it, any button the classes don't fully style falls back to
the browser's gray outset look. Element selector, so every class wins. */
:where(button) {
font: inherit;
color: inherit;
line-height: inherit;
background-color: transparent;
background-image: none;
border-width: 0;
border-style: solid;
padding: 0;
cursor: pointer;
}
:where(button:disabled) {
cursor: not-allowed;
}
/* Inline code (paths, commands) must not push a row wider than a phone. */
code {
overflow-wrap: anywhere;
}
/* One keyboard focus indicator for everything. Pointer clicks don't show it
(:focus-visible); components with their own ring keep theirs. */
:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
::selection {
background-color: rgb(37 99 235 / 25%);
}
[data-theme="dark"] ::selection {
background-color: rgb(96 165 250 / 35%);
}
html {
scrollbar-color: #94a3b8 transparent;
}
html[data-theme="dark"] {
scrollbar-color: #4b5563 transparent;
}
.skip-link {
position: absolute;
left: 0.75rem;
top: -4rem;
z-index: 100;
padding: 0.625rem 1rem;
border-radius: 0.375rem;
background-color: var(--color-surface);
color: var(--color-primary);
font-weight: 600;
box-shadow: var(--shadow-lg);
}
.skip-link:focus {
top: 0.75rem;
}
/* Utility classes */
.bg-gray-50 { background-color: #f9fafb; }
.bg-gray-100 { background-color: #f3f4f6; }
@@ -285,10 +362,10 @@ h4 { font-size: 1.125rem; }
.hover\:text-gray-700:hover { color: #374151; }
.hover\:border-gray-300:hover { border-color: #d1d5db; }
.focus\:outline-none:focus { outline: 2px solid transparent; outline-offset: 2px; }
/* Only suppress the outline for pointer focus; keyboard focus keeps it. */
.focus\:outline-none:focus:not(:focus-visible) { outline: 2px solid transparent; outline-offset: 2px; }
.focus\:ring-2:focus {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-shadow, 0 0 #0000);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 0 0 0 calc(2px + var(--tw-ring-offset-width, 0px)) var(--tw-ring-color, rgb(59 130 246 / 50%)), var(--tw-shadow, 0 0 #0000);
}
/* Optimized: Split complex selector onto multiple lines for readability */
.focus\:ring-blue-500:focus { --tw-ring-color: #3b82f6; }
@@ -353,12 +430,16 @@ a, button, input, select, textarea {
overflow-y: auto !important;
}
/* Ensure logs content doesn't cause overflow issues */
/* Ensure logs content doesn't cause overflow issues. content-visibility lets
the Pi skip layout and paint for the rows scrolled out of view; the
intrinsic size keeps the scrollbar stable. */
.log-entry {
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 100%;
box-sizing: border-box;
content-visibility: auto;
contain-intrinsic-size: auto 1.5rem;
}
/* Ensure proper containment of logs */
@@ -398,17 +479,356 @@ a, button, input, select, textarea {
/* Logs container responsive height - simplified for better scrolling */
@media (max-width: 768px) {
#logs-container {
height: 400px !important;
min-height: 300px !important;
height: clamp(260px, 60vh, 480px) !important;
height: clamp(260px, 60svh, 480px) !important;
min-height: 0 !important;
}
}
@media (max-width: 640px) {
#logs-container {
height: 350px !important;
min-height: 250px !important;
}
}
/* ===== Utility layer (fills the Tailwind-style gaps) =====
Templates and JS are authored in Tailwind class names, but there is no
Tailwind build: this file is the whole stylesheet. Every utility class the
markup uses must be defined here or it silently does nothing
(test/test_web_static_audit.py enforces this). Values follow Tailwind v3.
Keep `.hidden` after the display utilities so JS show/hide wins. */
.-translate-y-1\/2 { --tw-translate-y: -50%; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.accent-blue-600 { accent-color: #2563eb; }
.align-bottom { vertical-align: bottom; }
.appearance-none { appearance: none; -webkit-appearance: none; }
.backdrop-blur-sm { backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
.bg-amber-100 { --tw-bg-opacity: 1; background-color: rgb(254 243 199 / var(--tw-bg-opacity, 1)); }
.bg-amber-50 { --tw-bg-opacity: 1; background-color: rgb(255 251 235 / var(--tw-bg-opacity, 1)); }
.bg-amber-500\/10 { background-color: rgb(245 158 11 / 0.1); }
.bg-amber-600 { --tw-bg-opacity: 1; background-color: rgb(217 119 6 / var(--tw-bg-opacity, 1)); }
.bg-black { --tw-bg-opacity: 1; background-color: rgb(0 0 0 / var(--tw-bg-opacity, 1)); }
.bg-blue-100 { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); }
.bg-blue-50 { --tw-bg-opacity: 1; background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1)); }
.bg-gray-300 { --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1)); }
.bg-gray-400 { --tw-bg-opacity: 1; background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); }
.bg-gray-500 { --tw-bg-opacity: 1; background-color: rgb(107 114 128 / var(--tw-bg-opacity, 1)); }
.bg-gray-600 { --tw-bg-opacity: 1; background-color: rgb(75 85 99 / var(--tw-bg-opacity, 1)); }
.bg-green-50 { --tw-bg-opacity: 1; background-color: rgb(240 253 244 / var(--tw-bg-opacity, 1)); }
.bg-opacity-20 { --tw-bg-opacity: 0.2; }
.bg-opacity-70 { --tw-bg-opacity: 0.7; }
.bg-opacity-75 { --tw-bg-opacity: 0.75; }
.bg-purple-100 { --tw-bg-opacity: 1; background-color: rgb(243 232 255 / var(--tw-bg-opacity, 1)); }
.bg-purple-50 { --tw-bg-opacity: 1; background-color: rgb(250 245 255 / var(--tw-bg-opacity, 1)); }
.bg-purple-700 { --tw-bg-opacity: 1; background-color: rgb(126 34 206 / var(--tw-bg-opacity, 1)); }
.bg-red-50 { --tw-bg-opacity: 1; background-color: rgb(254 242 242 / var(--tw-bg-opacity, 1)); }
.bg-red-800 { --tw-bg-opacity: 1; background-color: rgb(153 27 27 / var(--tw-bg-opacity, 1)); }
.bg-yellow-50 { --tw-bg-opacity: 1; background-color: rgb(254 252 232 / var(--tw-bg-opacity, 1)); }
.block { display: block; }
.border { border-width: 1px; border-style: solid; }
.border-2 { border-width: 2px; border-style: solid; }
.border-amber-200 { border-color: #fde68a; }
.border-amber-300 { border-color: #fcd34d; }
.border-amber-500\/60 { border-color: rgb(245 158 11 / 0.6); }
.border-blue-200 { border-color: #bfdbfe; }
.border-blue-300 { border-color: #93c5fd; }
.border-dashed { border-style: dashed; }
.border-gray-100 { border-color: #f3f4f6; }
.border-gray-400 { border-color: #9ca3af; }
.border-gray-600 { border-color: #4b5563; }
.border-green-200 { border-color: #bbf7d0; }
.border-green-300 { border-color: #86efac; }
.border-green-400 { border-color: #4ade80; }
.border-green-500 { border-color: #22c55e; }
.border-l-0 { border-left-width: 0px; border-left-style: solid; }
.border-purple-200 { border-color: #e9d5ff; }
.border-purple-300 { border-color: #d8b4fe; }
.border-r-0 { border-right-width: 0px; border-right-style: solid; }
.border-red-200 { border-color: #fecaca; }
.border-red-300 { border-color: #fca5a5; }
.border-red-400 { border-color: #f87171; }
.border-red-500 { border-color: #ef4444; }
.border-t { border-top-width: 1px; border-top-style: solid; }
.border-yellow-200 { border-color: #fef08a; }
.border-yellow-300 { border-color: #fde047; }
.border-yellow-400 { border-color: #facc15; }
.break-all { word-break: break-all; }
.break-words { overflow-wrap: break-word; }
.capitalize { text-transform: capitalize; }
.col-span-full { grid-column: 1 / -1; }
.cursor-move { cursor: move; }
.cursor-not-allowed { cursor: not-allowed; }
.cursor-pointer { cursor: pointer; }
.cursor-wait { cursor: wait; }
.divide-gray-100 > :not(:first-child) { border-color: #f3f4f6; }
.divide-gray-200 > :not(:first-child) { border-color: #e5e7eb; }
.divide-y > :not(:first-child) { border-top-width: 1px; border-top-style: solid; }
.duration-150 { transition-duration: 150ms; }
.duration-200 { transition-duration: 200ms; }
.duration-300 { transition-duration: 300ms; }
.ease-in-out { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }
.ease-out { transition-timing-function: cubic-bezier(0, 0, 0.2, 1); }
.flex-\[3\] { flex: 3 1 0%; }
.flex-col { flex-direction: column; }
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; }
.font-normal { font-weight: 400; }
.gap-1 { gap: 0.25rem; }
.h-1 { height: 0.25rem; }
.h-12 { height: 3rem; }
.h-20 { height: 5rem; }
.h-3 { height: 0.75rem; }
.h-40 { height: 10rem; }
.h-5 { height: 1.25rem; }
.h-6 { height: 1.5rem; }
.h-64 { height: 16rem; }
.h-7 { height: 1.75rem; }
.h-8 { height: 2rem; }
.h-9 { height: 2.25rem; }
.h-full { height: 100%; }
.inline { display: inline; }
.inline-block { display: inline-block; }
.inset-0 { inset: 0px; }
.inset-y-0 { top: 0px; bottom: 0px; }
.italic { font-style: italic; }
.items-end { align-items: flex-end; }
.items-start { align-items: flex-start; }
.justify-end { justify-content: flex-end; }
.leading-6 { line-height: 1.5rem; }
.leading-none { line-height: 1; }
.left-0 { left: 0px; }
.left-3 { left: 0.75rem; }
.left-\[3px\] { left: 3px; }
.line-clamp-2 { overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.line-through { text-decoration-line: line-through; }
.list-disc { list-style-type: disc; }
.list-inside { list-style-position: inside; }
.max-h-32 { max-height: 8rem; }
.max-h-48 { max-height: 12rem; }
.max-h-64 { max-height: 16rem; }
.max-h-96 { max-height: 24rem; }
.max-h-screen { max-height: 100vh; }
.max-w-\[14rem\] { max-width: 14rem; }
.max-w-lg { max-width: 32rem; }
.max-w-md { max-width: 28rem; }
.max-w-xs { max-width: 20rem; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-5 { margin-bottom: 1.25rem; }
.min-h-\[100px\] { min-height: 100px; }
.min-h-screen { min-height: 100vh; }
.min-w-\[12rem\] { min-width: 12rem; }
.min-w-full { min-width: 100%; }
.ml-1 { margin-left: 0.25rem; }
.ml-1\.5 { margin-left: 0.375rem; }
.ml-2 { margin-left: 0.5rem; }
.ml-4 { margin-left: 1rem; }
.ml-5 { margin-left: 1.25rem; }
.ml-6 { margin-left: 1.5rem; }
.ml-auto { margin-left: auto; }
.mr-1 { margin-right: 0.25rem; }
.mr-3 { margin-right: 0.75rem; }
.mt-0\.5 { margin-top: 0.125rem; }
.mt-8 { margin-top: 2rem; }
.mt-auto { margin-top: auto; }
.mx-2 { margin-left: 0.5rem; margin-right: 0.5rem; }
.my-2 { margin-top: 0.5rem; margin-bottom: 0.5rem; }
.object-cover { object-fit: cover; }
.opacity-50 { opacity: 0.5; }
.opacity-60 { opacity: 0.6; }
.opacity-70 { opacity: 0.7; }
.overflow-auto { overflow: auto; }
.overflow-y-auto { overflow-y: auto; }
.p-0 { padding: 0px; }
.p-0\.5 { padding: 0.125rem; }
.p-1 { padding: 0.25rem; }
.pb-20 { padding-bottom: 5rem; }
.pb-3 { padding-bottom: 0.75rem; }
.pl-3 { padding-left: 0.75rem; }
.pl-5 { padding-left: 1.25rem; }
.pl-8 { padding-left: 2rem; }
.pl-9 { padding-left: 2.25rem; }
.pointer-events-auto { pointer-events: auto; }
.pointer-events-none { pointer-events: none; }
.pr-10 { padding-right: 2.5rem; }
.pr-2 { padding-right: 0.5rem; }
.pr-20 { padding-right: 5rem; }
.pr-3 { padding-right: 0.75rem; }
.pr-4 { padding-right: 1rem; }
.pr-8 { padding-right: 2rem; }
.pt-2 { padding-top: 0.5rem; }
.pt-3 { padding-top: 0.75rem; }
.pt-4 { padding-top: 1rem; }
.pt-5 { padding-top: 1.25rem; }
.pt-6 { padding-top: 1.5rem; }
.pt-8 { padding-top: 2rem; }
.px-1 { padding-left: 0.25rem; padding-right: 0.25rem; }
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-0\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; }
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
.py-12 { padding-top: 3rem; padding-bottom: 3rem; }
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
.right-0 { right: 0px; }
.right-2 { right: 0.5rem; }
.ring-0 { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 0 0 0 var(--tw-ring-offset-width, 0px) var(--tw-ring-color, transparent); }
.rounded-b-lg { border-bottom-left-radius: 0.5rem; border-bottom-right-radius: 0.5rem; }
.rounded-l-md { border-top-left-radius: 0.375rem; border-bottom-left-radius: 0.375rem; }
.rounded-r-lg { border-top-right-radius: 0.5rem; border-bottom-right-radius: 0.5rem; }
.rounded-r-md { border-top-right-radius: 0.375rem; border-bottom-right-radius: 0.375rem; }
.rounded-t-lg { border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem; }
.self-center { align-self: center; }
.shadow-inner { box-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 5%); }
.shadow-xl { box-shadow: 0 20px 25px -5px rgb(0 0 0 / 10%), 0 8px 10px -6px rgb(0 0 0 / 10%); }
.shrink-0 { flex-shrink: 0; }
.space-x-3 > * + * { margin-left: 0.75rem; }
.space-y-3 > * + * { margin-top: 0.75rem; }
.space-y-5 > * + * { margin-top: 1.25rem; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; }
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
.text-amber-200\/90 { color: rgb(253 230 138 / 0.9); }
.text-amber-300 { color: #fcd34d; }
.text-amber-500 { color: #f59e0b; }
.text-amber-600 { color: #d97706; }
.text-amber-700 { color: #b45309; }
.text-amber-800 { color: #92400e; }
.text-amber-900 { color: #78350f; }
.text-black { color: #000000; }
.text-blue-400 { color: #60a5fa; }
.text-blue-500 { color: #3b82f6; }
.text-blue-600 { color: #2563eb; }
.text-blue-700 { color: #1d4ed8; }
.text-blue-800 { color: #1e40af; }
.text-blue-900 { color: #1e3a8a; }
.text-center { text-align: center; }
.text-gray-700 { color: #374151; }
.text-gray-800 { color: #1f2937; }
.text-green-500 { color: #22c55e; }
.text-green-700 { color: #15803d; }
.text-green-900 { color: #14532d; }
.text-left { text-align: left; }
.text-md { font-size: 1rem; line-height: 1.5rem; }
.text-purple-600 { color: #9333ea; }
.text-purple-700 { color: #7e22ce; }
.text-purple-800 { color: #6b21a8; }
.text-red-400 { color: #f87171; }
.text-red-500 { color: #ef4444; }
.text-red-700 { color: #b91c1c; }
.text-red-900 { color: #7f1d1d; }
.text-right { text-align: right; }
.text-yellow-400 { color: #facc15; }
.text-yellow-500 { color: #eab308; }
.text-yellow-600 { color: #ca8a04; }
.text-yellow-700 { color: #a16207; }
.text-yellow-900 { color: #713f12; }
.top-1\/2 { top: 50%; }
.top-2 { top: 0.5rem; }
.top-\[3px\] { top: 3px; }
.tracking-wider { letter-spacing: 0.05em; }
.transform { transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.transition-colors { transition-property: color, background-color, border-color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.transition-opacity { transition-property: opacity; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.transition-shadow { transition-property: box-shadow; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.transition-transform { transition-property: transform; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.translate-x-0 { --tw-translate-x: 0px; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.translate-x-1 { --tw-translate-x: 0.25rem; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.translate-x-5 { --tw-translate-x: 1.25rem; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.translate-x-6 { --tw-translate-x: 1.5rem; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.translate-x-full { --tw-translate-x: 100%; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.underline { text-decoration-line: underline; text-underline-offset: 0.15em; }
.uppercase { text-transform: uppercase; }
.w-1\/2 { width: 50%; }
.w-1\/3 { width: 33.333333%; }
.w-1\/4 { width: 25%; }
.w-10 { width: 2.5rem; }
.w-11 { width: 2.75rem; }
.w-12 { width: 3rem; }
.w-14 { width: 3.5rem; }
.w-16 { width: 4rem; }
.w-2\/3 { width: 66.666667%; }
.w-2\/5 { width: 40%; }
.w-20 { width: 5rem; }
.w-24 { width: 6rem; }
.w-3 { width: 0.75rem; }
.w-3\/4 { width: 75%; }
.w-3\/5 { width: 60%; }
.w-32 { width: 8rem; }
.w-4\/5 { width: 80%; }
.w-4\/6 { width: 66.666667%; }
.w-48 { width: 12rem; }
.w-5 { width: 1.25rem; }
.w-5\/6 { width: 83.333333%; }
.w-6 { width: 1.5rem; }
.w-8 { width: 2rem; }
.w-80 { width: 20rem; }
.w-96 { width: 24rem; }
.w-px { width: 1px; }
.whitespace-nowrap { white-space: nowrap; }
.whitespace-pre-wrap { white-space: pre-wrap; }
.-m-2 { margin: -0.5rem; }
.list-none { list-style-type: none; }
.pl-0 { padding-left: 0px; }
.w-auto { width: auto; }
.pb-1 { padding-bottom: 0.25rem; }
.hidden { display: none; }
/* State and pseudo variants */
.disabled\:bg-gray-400:disabled { --tw-bg-opacity: 1; background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); }
.disabled\:cursor-not-allowed:disabled { cursor: not-allowed; }
.disabled\:opacity-50:disabled { opacity: 0.5; }
.disabled\:text-gray-400:disabled { color: #6b7280; }
.file\:bg-blue-50::file-selector-button { --tw-bg-opacity: 1; background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1)); }
.file\:border-0::file-selector-button { border-width: 0px; }
.file\:font-medium::file-selector-button { font-weight: 500; }
.file\:mr-4::file-selector-button { margin-right: 1rem; }
.file\:px-4::file-selector-button { padding-left: 1rem; padding-right: 1rem; }
.file\:py-2::file-selector-button { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.file\:rounded-md::file-selector-button { border-radius: 0.375rem; }
.file\:text-blue-700::file-selector-button { color: #1d4ed8; }
.file\:text-sm::file-selector-button { font-size: 0.875rem; line-height: 1.25rem; }
.first\:border-t-0:first-child { border-top-width: 0px; border-top-style: solid; }
.focus\:border-blue-500:focus { border-color: #3b82f6; }
.focus\:ring-indigo-500:focus { --tw-ring-color: #6366f1; }
.focus\:ring-offset-2:focus { --tw-ring-offset-width: 2px; --tw-ring-offset-shadow: 0 0 0 2px var(--color-surface, #fff); }
.focus\:shadow-md:focus { box-shadow: var(--shadow-md); }
.group:hover .group-hover\:scale-105 { --tw-scale-x: 1.05; --tw-scale-y: 1.05; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.hover\:bg-amber-50:hover { --tw-bg-opacity: 1; background-color: rgb(255 251 235 / var(--tw-bg-opacity, 1)); }
.hover\:bg-amber-700:hover { --tw-bg-opacity: 1; background-color: rgb(180 83 9 / var(--tw-bg-opacity, 1)); }
.hover\:bg-blue-100:hover { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); }
.hover\:bg-blue-50:hover { --tw-bg-opacity: 1; background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1)); }
.hover\:bg-blue-700:hover { --tw-bg-opacity: 1; background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1)); }
.hover\:bg-gray-100:hover { --tw-bg-opacity: 1; background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); }
.hover\:bg-gray-300:hover { --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1)); }
.hover\:bg-gray-400:hover { --tw-bg-opacity: 1; background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); }
.hover\:bg-gray-700:hover { --tw-bg-opacity: 1; background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1)); }
.hover\:bg-opacity-20:hover { --tw-bg-opacity: 0.2; }
.hover\:bg-opacity-30:hover { --tw-bg-opacity: 0.3; }
.hover\:bg-red-50:hover { --tw-bg-opacity: 1; background-color: rgb(254 242 242 / var(--tw-bg-opacity, 1)); }
.hover\:bg-red-900:hover { --tw-bg-opacity: 1; background-color: rgb(127 29 29 / var(--tw-bg-opacity, 1)); }
.hover\:bg-white:hover { --tw-bg-opacity: 1; background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); }
.hover\:border-blue-400:hover { border-color: #60a5fa; }
.hover\:file\:bg-blue-100::file-selector-button:hover { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); }
.hover\:opacity-100:hover { opacity: 1; }
.hover\:scale-110:hover { --tw-scale-x: 1.1; --tw-scale-y: 1.1; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.hover\:shadow-md:hover { box-shadow: var(--shadow-md); }
.hover\:text-blue-600:hover { color: #2563eb; }
.hover\:text-blue-700:hover { color: #1d4ed8; }
.hover\:text-blue-800:hover { color: #1e40af; }
.hover\:text-gray-600:hover { color: #374151; }
.hover\:text-gray-900:hover { color: #111827; }
.hover\:text-red-300:hover { color: #fca5a5; }
.hover\:text-red-600:hover { color: #dc2626; }
.hover\:text-red-800:hover { color: #991b1b; }
.hover\:text-red-900:hover { color: #7f1d1d; }
.hover\:text-white:hover { color: #ffffff; }
.hover\:text-yellow-700:hover { color: #a16207; }
.hover\:text-yellow-800:hover { color: #854d0e; }
.hover\:text-yellow-900:hover { color: #713f12; }
.hover\:underline:hover { text-decoration-line: underline; text-underline-offset: 0.15em; }
.last\:border-0:last-child { border-width: 0px; }
.peer:checked ~ .peer-checked\:bg-green-500 { --tw-bg-opacity: 1; background-color: rgb(34 197 94 / var(--tw-bg-opacity, 1)); }
.peer:focus-visible ~ .peer-focus\:outline-none { outline: 2px solid transparent; outline-offset: 2px; }
.peer:focus-visible ~ .peer-focus\:ring-4 { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 0 0 0 calc(4px + var(--tw-ring-offset-width, 0px)) var(--tw-ring-color, rgb(59 130 246 / 50%)), var(--tw-shadow, 0 0 #0000); }
.peer:focus-visible ~ .peer-focus\:ring-blue-300 { --tw-ring-color: #93c5fd; }
.placeholder\:text-gray-400::placeholder { color: #6b7280; }
.placeholder\:text-gray-500::placeholder { color: #4b5563; }
.hover\:bg-gray-200:hover { --tw-bg-opacity: 1; background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); }
.hover\:text-gray-800:hover { color: #1f2937; }
/* Responsive breakpoints */
@media (min-width: 640px) {
@@ -416,6 +836,30 @@ a, button, input, select, textarea {
.sm\:block { display: block; }
.sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sm\:text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.sm\:align-middle { vertical-align: middle; }
.sm\:flex { display: flex; }
.sm\:flex-row-reverse { flex-direction: row-reverse; }
.sm\:h-10 { height: 2.5rem; }
.sm\:hidden { display: none; }
.sm\:inline { display: inline; }
.sm\:items-start { align-items: flex-start; }
.sm\:max-w-4xl { max-width: 56rem; }
.sm\:max-w-lg { max-width: 32rem; }
.sm\:ml-3 { margin-left: 0.75rem; }
.sm\:ml-4 { margin-left: 1rem; }
.sm\:mt-0 { margin-top: 0px; }
.sm\:mx-0 { margin-left: 0px; margin-right: 0px; }
.sm\:my-8 { margin-top: 2rem; margin-bottom: 2rem; }
.sm\:p-0 { padding: 0px; }
.sm\:p-6 { padding: 1.5rem; }
.sm\:pb-4 { padding-bottom: 1rem; }
.sm\:text-left { text-align: left; }
.sm\:text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.sm\:w-10 { width: 2.5rem; }
.sm\:w-auto { width: auto; }
.sm\:w-full { width: 100%; }
.sm\:flex-nowrap { flex-wrap: nowrap; }
.sm\:gap-4 { gap: 1rem; }
}
@media (min-width: 768px) {
@@ -601,8 +1045,9 @@ a, button, input, select, textarea {
opacity: 0.5;
}
/* Enhanced Button styles */
.btn {
/* Enhanced Button styles. :where() keeps the base at zero specificity so
utility classes on the same element (px-3 py-1, rounded) take effect. */
:where(.btn) {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -637,10 +1082,9 @@ a, button, input, select, textarea {
transform: none;
}
.btn:focus {
outline: 2px solid transparent;
.btn:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
box-shadow: 0 0 0 3px rgb(37 99 235 / 30%);
}
/* Global button text contrast fix: Ensure buttons with white backgrounds have dark text */
@@ -661,14 +1105,16 @@ button.bg-white {
margin-bottom: 0.25rem;
}
.form-control {
/* :where() so utilities on the field (pl-8 for a search icon, w-48,
h-4 w-4 on checkboxes) override the component defaults. */
:where(.form-control) {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border: 1px solid var(--color-field-border);
border-radius: 0.375rem;
background-color: #ffffff;
color: #111827; /* text-gray-900 - ensure dark text on white background */
background-color: var(--color-field-bg);
color: var(--color-text-primary);
font-size: 0.875rem;
line-height: 1.25rem;
transition: border-color 0.15s ease-in-out;
@@ -678,12 +1124,12 @@ button.bg-white {
.form-control:focus {
border-color: var(--color-primary);
/* Using outline instead of box-shadow for focus state (better performance) */
outline: 2px solid rgb(37 99 235 / 20%);
outline-offset: 2px;
outline: 2px solid var(--color-focus-ring);
outline-offset: 1px;
}
.form-control:disabled {
background-color: #f9fafb;
background-color: var(--color-background);
opacity: 0.6;
cursor: not-allowed;
}
@@ -718,28 +1164,12 @@ button.bg-white {
/* Added CSS containment for better performance isolation */
}
.plugin-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary));
opacity: 0;
transition: opacity 0.15s ease;
}
.plugin-card:hover {
transform: translateY(-2px);
border-color: var(--color-primary);
/* Removed box-shadow transition for better performance */
}
.plugin-card:hover::before {
opacity: 1;
}
.plugin-card:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
@@ -1080,7 +1510,7 @@ button.bg-white {
font-size: 0.875rem;
font-weight: 500;
line-height: 1.5;
color: #374151; /* text-gray-700 for better readability */
color: var(--color-text-secondary);
white-space: nowrap;
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
cursor: pointer;
@@ -1097,30 +1527,30 @@ button.bg-white {
/* Inactive state - improved contrast */
.nav-tab:not(.nav-tab-active) {
color: #374151; /* text-gray-700 */
color: var(--color-text-secondary);
}
.nav-tab:not(.nav-tab-active) i {
color: #374151; /* text-gray-700 */
color: var(--color-text-secondary);
}
/* Hover state - enhanced visibility */
.nav-tab:not(.nav-tab-active):hover {
color: #111827; /* text-gray-900 */
background-color: #f3f4f6; /* bg-gray-100 */
border-bottom-color: #d1d5db; /* border-gray-300 */
color: var(--color-text-primary);
background-color: var(--color-border-light);
border-bottom-color: var(--color-border);
}
.nav-tab:not(.nav-tab-active):hover i {
color: #111827; /* text-gray-900 */
color: var(--color-text-primary);
}
/* Active state - prominent with gradient background */
.nav-tab-active {
color: #1d4ed8; /* text-blue-700 */
color: var(--color-primary-hover);
font-weight: 600;
border-bottom-width: 3px;
border-bottom-color: #2563eb; /* border-blue-600 */
border-bottom-color: var(--color-primary);
background: linear-gradient(135deg, rgb(37 99 235 / 10%) 0%, rgb(59 130 246 / 5%) 100%);
box-shadow: 0 2px 4px rgb(37 99 235 / 10%);
}
@@ -1155,17 +1585,6 @@ button.bg-white {
}
/* Form controls */
[data-theme="dark"] .form-control {
background-color: var(--color-border);
color: var(--color-text-primary);
border-color: #4b5563;
}
[data-theme="dark"] .form-control:focus {
border-color: var(--color-primary);
outline-color: rgb(59 130 246 / 30%);
}
[data-theme="dark"] .form-control:disabled {
background-color: var(--color-surface);
}
@@ -1275,6 +1694,84 @@ button.bg-white {
[data-theme="dark"] .text-green-800 { color: #6ee7b7; }
[data-theme="dark"] .text-green-700 { color: #6ee7b7; }
/* Dark theme for the generated color utilities */
[data-theme="dark"] .bg-amber-100 { background-color: rgb(245 158 11 / 18%); }
[data-theme="dark"] .bg-amber-50 { background-color: rgb(245 158 11 / 12%); }
[data-theme="dark"] .bg-blue-100 { background-color: rgb(59 130 246 / 18%); }
[data-theme="dark"] .bg-gray-300 { background-color: #4b5563; }
[data-theme="dark"] .bg-gray-400 { background-color: #6b7280; }
[data-theme="dark"] .bg-purple-100 { background-color: rgb(168 85 247 / 18%); }
[data-theme="dark"] .bg-purple-50 { background-color: rgb(168 85 247 / 12%); }
[data-theme="dark"] .border-amber-200 { border-color: rgb(245 158 11 / 40%); }
[data-theme="dark"] .border-amber-300 { border-color: rgb(245 158 11 / 55%); }
[data-theme="dark"] .border-blue-300 { border-color: rgb(59 130 246 / 55%); }
[data-theme="dark"] .border-gray-100 { border-color: var(--color-border); }
[data-theme="dark"] .border-gray-400 { border-color: #6b7280; }
[data-theme="dark"] .border-green-300 { border-color: rgb(34 197 94 / 55%); }
[data-theme="dark"] .border-purple-200 { border-color: rgb(168 85 247 / 40%); }
[data-theme="dark"] .border-purple-300 { border-color: rgb(168 85 247 / 55%); }
[data-theme="dark"] .border-red-300 { border-color: rgb(239 68 68 / 55%); }
[data-theme="dark"] .border-yellow-300 { border-color: rgb(234 179 8 / 55%); }
[data-theme="dark"] .divide-gray-100 > :not(:first-child) { border-color: var(--color-border); }
[data-theme="dark"] .divide-gray-200 > :not(:first-child) { border-color: var(--color-border); }
[data-theme="dark"] .text-amber-500 { color: #fbbf24; }
[data-theme="dark"] .text-amber-600 { color: #fcd34d; }
[data-theme="dark"] .text-amber-700 { color: #fcd34d; }
[data-theme="dark"] .text-amber-800 { color: #fcd34d; }
[data-theme="dark"] .text-amber-900 { color: #fcd34d; }
[data-theme="dark"] .text-black { color: var(--color-text-primary); }
[data-theme="dark"] .text-blue-500 { color: #60a5fa; }
[data-theme="dark"] .text-blue-600 { color: #93c5fd; }
[data-theme="dark"] .text-blue-900 { color: #93c5fd; }
[data-theme="dark"] .text-gray-700 { color: var(--color-text-secondary); }
[data-theme="dark"] .text-gray-800 { color: var(--color-text-primary); }
[data-theme="dark"] .text-green-500 { color: #4ade80; }
[data-theme="dark"] .text-green-900 { color: #86efac; }
[data-theme="dark"] .text-purple-600 { color: #d8b4fe; }
[data-theme="dark"] .text-purple-700 { color: #d8b4fe; }
[data-theme="dark"] .text-purple-800 { color: #d8b4fe; }
[data-theme="dark"] .text-red-500 { color: #f87171; }
[data-theme="dark"] .text-red-900 { color: #fca5a5; }
[data-theme="dark"] .text-yellow-500 { color: #facc15; }
[data-theme="dark"] .text-yellow-900 { color: #fde047; }
[data-theme="dark"] .disabled\:bg-gray-400:disabled { background-color: #6b7280; }
[data-theme="dark"] .file\:bg-blue-50::file-selector-button { background-color: rgb(59 130 246 / 12%); }
[data-theme="dark"] .file\:text-blue-700::file-selector-button { color: #93c5fd; }
[data-theme="dark"] .hover\:bg-amber-50:hover { background-color: rgb(245 158 11 / 12%); }
[data-theme="dark"] .hover\:bg-blue-100:hover { background-color: rgb(59 130 246 / 18%); }
[data-theme="dark"] .hover\:bg-blue-50:hover { background-color: rgb(59 130 246 / 12%); }
[data-theme="dark"] .hover\:bg-gray-100:hover { background-color: var(--color-border); }
[data-theme="dark"] .hover\:bg-gray-300:hover { background-color: #4b5563; }
[data-theme="dark"] .hover\:bg-gray-400:hover { background-color: #6b7280; }
[data-theme="dark"] .hover\:bg-red-50:hover { background-color: rgb(239 68 68 / 12%); }
[data-theme="dark"] .hover\:file\:bg-blue-100::file-selector-button:hover { background-color: rgb(59 130 246 / 18%); }
[data-theme="dark"] .hover\:text-blue-600:hover { color: #bfdbfe; }
[data-theme="dark"] .hover\:text-blue-700:hover { color: #bfdbfe; }
[data-theme="dark"] .hover\:text-blue-800:hover { color: #bfdbfe; }
[data-theme="dark"] .hover\:text-gray-600:hover { color: var(--color-text-tertiary); }
[data-theme="dark"] .hover\:text-gray-900:hover { color: var(--color-text-primary); }
[data-theme="dark"] .hover\:text-red-600:hover { color: #fecaca; }
[data-theme="dark"] .hover\:text-red-800:hover { color: #fecaca; }
[data-theme="dark"] .hover\:text-red-900:hover { color: #fecaca; }
[data-theme="dark"] .hover\:text-yellow-700:hover { color: #fef08a; }
[data-theme="dark"] .hover\:text-yellow-800:hover { color: #fef08a; }
[data-theme="dark"] .hover\:text-yellow-900:hover { color: #fef08a; }
/* Dark theme for the generated color utilities */
/* Dark theme for the generated color utilities */
/* Dark theme for the generated color utilities */
/* Dark theme for the generated color utilities */
[data-theme="dark"] .hover\:text-gray-800:hover { color: var(--color-text-primary); }
/* Dark theme for the generated color utilities */
/* Theme toggle button */
.theme-toggle-btn {
display: inline-flex;
@@ -1432,10 +1929,10 @@ button.bg-white {
position: sticky;
left: 0;
z-index: 1;
background-color: #ffffff;
background-color: var(--color-surface, #ffffff);
}
.style-editor-head > :first-child {
background-color: #f9fafb;
background-color: var(--color-background, #f9fafb);
}
.style-editor-row {
/* Set by the widget: the column count depends on what the plugin
@@ -1455,27 +1952,27 @@ button.bg-white {
width: 100%;
}
.style-editor-row input[type="number"]:disabled {
background-color: #f3f4f6;
color: #9ca3af;
background-color: var(--color-border-light, #f3f4f6);
color: var(--color-text-tertiary, #9ca3af);
cursor: not-allowed;
}
.style-editor-colour {
width: 2rem;
height: 1.6rem;
padding: 0;
border: 1px solid #d1d5db;
border: 1px solid var(--color-field-border, #d1d5db);
border-radius: 0.25rem;
background: none;
cursor: pointer;
}
.style-editor-tab {
border: 1px solid #d1d5db;
background-color: #f9fafb;
color: #374151;
border: 1px solid var(--color-field-border, #d1d5db);
background-color: var(--color-background, #f9fafb);
color: var(--color-text-secondary, #374151);
}
.style-editor-tab.is-active {
background-color: #2563eb;
border-color: #2563eb;
background-color: var(--color-primary, #2563eb);
border-color: var(--color-primary, #2563eb);
color: #ffffff;
}
.style-editor-panel[hidden] {
@@ -1494,3 +1991,90 @@ button.bg-white {
font-weight: 600;
}
}
/* A label wrapping a visually hidden file input shows the input's focus. */
.file-trigger:focus-within {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
/* Dark theme: native fields that no component or utility styles would
otherwise keep the browser's white surface. */
[data-theme="dark"] :where(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea) {
background-color: var(--color-field-bg);
color: var(--color-text-primary);
border-color: var(--color-field-border);
}
[data-theme="dark"] ::placeholder {
color: #9ca3af;
}
/* Range tracks and native controls follow the theme accent instead of the
browser's default light chrome. */
input[type="range"],
input[type="checkbox"],
input[type="radio"],
progress {
accent-color: var(--color-primary);
}
/* ===== Guards that must stay at the end of the file ===== */
/* `.hidden` has to beat component classes that set their own display. */
.btn.hidden,
.badge.hidden,
.status-indicator.hidden,
.help-tip.hidden,
.nav-tab.hidden,
.theme-toggle-btn.hidden {
display: none;
}
@media (min-width: 768px) {
/* .theme-toggle-btn (display: inline-flex) is declared after md:hidden,
which left the mobile menu button visible on desktop. */
.theme-toggle-btn.md\:hidden {
display: none;
}
}
/* Touch screens: controls in the page content get a 44px target. Switches
and inline help triggers keep their drawn size. */
@media (pointer: coarse) {
#tab-content button:not([role="switch"], .help-tip),
#tab-content a.inline-flex,
#tab-content select,
#tab-content input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"], .sr-only) {
min-height: 44px;
}
}
/* Reduced motion: no entrances, slides, pulses or smooth scrolling.
Spinners keep turning, slowly, because they are the only busy signal. */
@media (prefers-reduced-motion: reduce) {
html,
#logs-container {
scroll-behavior: auto;
}
*,
::before,
::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.fa-spin,
.fa-spinner,
.animate-spin,
.jfm-spin,
.spinner {
animation-duration: 1.5s !important;
animation-iteration-count: infinite !important;
}
.card:hover,
.plugin-card:hover,
.btn:hover {
transform: none;
}
}
+25 -37
View File
@@ -2,20 +2,28 @@
// LED Matrix v3 JavaScript
// Additional helpers for HTMX and Alpine.js integration
// Global notification system
window.showNotification = function(message, type = 'info') {
// Use Alpine.js notification if available
if (window.Alpine) {
// This would trigger the Alpine.js notification system
const event = new CustomEvent('show-notification', {
// Global notification system — implemented by widgets/notification.js.
// This fallback only exists if nothing defined showNotification earlier; it
// hands off to the widget when registered, otherwise queues the message for
// the widget to show once it loads.
if (typeof window.showNotification !== 'function') {
window.showNotification = function(message, type = 'info') {
const registry = window.LEDMatrixWidgets;
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
if (widget && typeof widget.show === 'function') {
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
}
if (!Array.isArray(window.__pendingNotifications)) {
window.__pendingNotifications = [];
}
window.__pendingNotifications.push([message, type]);
document.dispatchEvent(new CustomEvent('show-notification', {
detail: { message, type }
});
document.dispatchEvent(event);
} else {
// Fallback notification — user-facing last resort, so never gated
console.info(`${type}: ${message}`);
}
};
}));
// User-facing last resort, so never gated
console.info(`${(type && type.type) || type}: ${message}`);
};
}
// HTMX response handlers
document.body.addEventListener('htmx:beforeRequest', function(event) {
@@ -180,30 +188,8 @@ document.addEventListener('DOMContentLoaded', function() {
} catch { /* no-op */ }
});
// SSE reconnection helper — closes and reopens both SSE streams,
// reattaching the open/error handlers defined in base.html.
window.reconnectSSE = function() {
if (window.statsSource) {
window.statsSource.close();
window.statsSource = new EventSource('/api/v3/stream/stats');
window.statsSource.onmessage = function(event) {
const data = JSON.parse(event.data);
if (typeof updateSystemStats === 'function') updateSystemStats(data);
};
if (window._statsOpenHandler) window.statsSource.addEventListener('open', window._statsOpenHandler);
if (window._statsErrorHandler) window.statsSource.addEventListener('error', window._statsErrorHandler);
}
if (window.displaySource) {
window.displaySource.close();
window.displaySource = new EventSource('/api/v3/stream/display');
window.displaySource.onmessage = function(event) {
const data = JSON.parse(event.data);
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
};
if (window._displayErrorHandler) window.displaySource.addEventListener('error', window._displayErrorHandler);
}
};
// SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in
// js/app-shell.js — do not open EventSources for stats/display here.
// Utility functions
window.hexToRgb = function(hex) {
@@ -462,6 +448,8 @@ window.updateFloatingPreviewVisibility = function(tab) {
img.src = 'data:image/png;base64,' + window._lastPreviewFrame;
}
}
// The display SSE stream is only open while a preview is on screen
if (window.LEDStreams) window.LEDStreams.refresh();
};
document.addEventListener('DOMContentLoaded', function() {
+197 -31
View File
@@ -1,24 +1,16 @@
/* global debugLog */
// SSE wiring + full Alpine app() implementation and tab logic
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
// Assign to window so reconnectSSE() in app.js can reach them.
window.statsSource = new EventSource('/api/v3/stream/stats');
window.displaySource = new EventSource('/api/v3/stream/display');
window.statsSource.onmessage = function(event) {
const data = JSON.parse(event.data);
updateSystemStats(data);
};
window.displaySource.onmessage = function(event) {
const data = JSON.parse(event.data);
updateDisplayPreview(data);
};
function _setConnectionStatus(connected, reconnecting) {
function _setConnectionStatus(connected, reconnecting, paused) {
const el = document.getElementById('connection-status');
if (!el) return;
if (connected) {
if (paused) {
// Intentionally closed while the page is hidden — not an error.
el.innerHTML = `
<div class="w-2 h-2 bg-gray-200 border border-gray-300 rounded-full"></div>
<span class="text-gray-600" title="Live updates pause while this page is in the background">Paused</span>
`;
} else if (connected) {
el.innerHTML = `
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span class="text-gray-600">Connected</span>
@@ -38,8 +30,8 @@
var _statsErrorCount = 0;
// Named on window so reconnectSSE() in app.js can reattach them after
// replacing the EventSource instances.
// Kept on window for backward compatibility; LEDStreams attaches them
// to every EventSource it opens.
window._statsOpenHandler = function() {
_statsErrorCount = 0;
_setConnectionStatus(true, false);
@@ -56,9 +48,173 @@
console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
};
window.statsSource.addEventListener('open', window._statsOpenHandler);
window.statsSource.addEventListener('error', window._statsErrorHandler);
window.displaySource.addEventListener('error', window._displayErrorHandler);
// ===== Tab/page visibility — shared by partials =====
// LEDVisibility.onActive(tab, start, stop[, key]) runs start() when
// `tab` is the active tab AND the page is visible, and stop() when
// either stops being true. start() should refresh immediately. The
// registration is keyed (default: tab name), so a partial re-injected
// by HTMX replaces its previous registration — the old stop() runs
// first — instead of stacking intervals. Returns an unregister fn.
// Tab changes are also broadcast as `ledmatrix:tab-changed` on document.
window.LEDVisibility = (function() {
const regs = new Map();
let lastTab = null;
function activeTab() {
const el = document.querySelector('[x-data="app()"]');
const data = el && el._x_dataStack && el._x_dataStack[0];
return (data && data.activeTab) || lastTab;
}
function isActive(tab) {
return !document.hidden && activeTab() === tab;
}
function evaluate(reg) {
const want = isActive(reg.tab);
if (want === reg.running) return;
reg.running = want;
try {
(want ? reg.start : reg.stop)();
} catch (e) {
console.error('[LEDVisibility] ' + reg.key + (want ? ' start' : ' stop') + ' failed:', e);
}
}
function onActive(tab, start, stop, key) {
key = key || tab;
const prev = regs.get(key);
if (prev && prev.running) {
prev.running = false;
try { prev.stop(); } catch (e) { console.error('[LEDVisibility] ' + key + ' stop failed:', e); }
}
const reg = { tab: tab, start: start, stop: stop, key: key, running: false };
regs.set(key, reg);
evaluate(reg);
return function() {
if (regs.get(key) !== reg) return;
regs.delete(key);
if (reg.running) { reg.running = false; stop(); }
};
}
function refresh() {
regs.forEach(evaluate);
if (window.LEDStreams) window.LEDStreams.refresh();
}
document.addEventListener('visibilitychange', refresh);
document.addEventListener('ledmatrix:tab-changed', function(e) {
lastTab = (e.detail && e.detail.tab) || lastTab;
refresh();
});
return { activeTab: activeTab, isActive: isActive, onActive: onActive, refresh: refresh };
})();
// ===== Live SSE streams — the single owner =====
// /stream/stats feeds the header stats and connection badge on every
// tab, so it stays open while the page is visible. /stream/display
// pushes base64 PNG frames and is only opened while a preview is on
// screen: the Overview live preview, or the floating preview when open.
// Both close while the page is hidden. The server's broadcaster ends its
// generator thread once the last subscriber leaves, so closing here
// saves Pi CPU, not only bandwidth.
//
// window.statsSource / window.displaySource remain public. An
// EventSource can't be reopened, so each reopen creates a new one;
// listeners other code attached with addEventListener (e.g. the Tools
// power panel) are recorded and re-attached to the replacement.
window.LEDStreams = (function() {
const CONFIG = {
stats: {
url: '/api/v3/stream/stats',
prop: 'statsSource',
onmessage: function(data) { updateSystemStats(data); },
wire: function(src) {
src.onopen = window._statsOpenHandler;
src.onerror = window._statsErrorHandler;
}
},
display: {
url: '/api/v3/stream/display',
prop: 'displaySource',
onmessage: function(data) { updateDisplayPreview(data); },
wire: function(src) { src.onerror = window._displayErrorHandler; }
}
};
const open = { stats: null, display: null };
const external = { stats: [], display: [] };
function openStream(name) {
if (open[name]) return;
const cfg = CONFIG[name];
const src = new EventSource(cfg.url);
const add = src.addEventListener.bind(src);
const remove = src.removeEventListener.bind(src);
src.addEventListener = function(type, listener, options) {
external[name].push([type, listener, options]);
add(type, listener, options);
};
src.removeEventListener = function(type, listener, options) {
external[name] = external[name].filter(function(l) {
return !(l[0] === type && l[1] === listener);
});
remove(type, listener, options);
};
external[name].forEach(function(l) { add(l[0], l[1], l[2]); });
src.onmessage = function(event) {
let data;
try { data = JSON.parse(event.data); } catch { return; }
cfg.onmessage(data);
};
cfg.wire(src);
open[name] = src;
window[cfg.prop] = src;
}
// The closed instance stays on window so late `addEventListener`
// calls are still recorded and replayed on the next open.
function closeStream(name) {
if (!open[name]) return;
open[name].close();
open[name] = null;
}
function previewOnScreen() {
if (window.LEDVisibility.activeTab() === 'overview') return true;
const panel = document.getElementById('floating-preview');
return !!(panel && panel.style.display === 'block');
}
function refresh() {
if (document.hidden) {
// Also covers a page first loaded in a background tab.
closeStream('stats');
closeStream('display');
_setConnectionStatus(false, false, true);
return;
}
if (!open.stats) {
_statsErrorCount = 0;
openStream('stats');
}
if (previewOnScreen()) openStream('display');
else closeStream('display');
}
function reconnect() {
closeStream('stats');
closeStream('display');
refresh();
}
return { refresh: refresh, reconnect: reconnect, isOpen: function(name) { return !!open[name]; } };
})();
// Public helper (previously duplicated in app.js).
window.reconnectSSE = function() { window.LEDStreams.reconnect(); };
// Header stats start immediately; the display stream opens once the
// active tab is known (Alpine init dispatches ledmatrix:tab-changed).
window.LEDStreams.refresh();
document.addEventListener('DOMContentLoaded', function() { window.LEDStreams.refresh(); });
// Reset any time the currently-active warning clears, so a future
// (new) occurrence shows the banner again even if this one was dismissed.
@@ -323,6 +479,10 @@
if (typeof window.updateFloatingPreviewVisibility === 'function') {
window.updateFloatingPreviewVisibility(newTab);
}
// Pause/resume per-tab timers and SSE (LEDVisibility)
document.dispatchEvent(new CustomEvent('ledmatrix:tab-changed', {
detail: { tab: newTab, previous: oldTab }
}));
// Trigger content load when tab changes
this.$nextTick(() => {
this.loadTabContent(newTab);
@@ -335,6 +495,9 @@
if (typeof window.updateNavAriaCurrent === 'function') {
window.updateNavAriaCurrent(this.activeTab);
}
document.dispatchEvent(new CustomEvent('ledmatrix:tab-changed', {
detail: { tab: this.activeTab, previous: null }
}));
});
// Listen for plugin updates from pluginManager
@@ -1878,18 +2041,21 @@
});
};
// showNotification is provided by notification.js widget
// This fallback is only used if the widget hasn't loaded yet
// showNotification is implemented by the notification.js widget.
// Until it loads, this fallback queues messages; the widget shows the
// queue as soon as it registers (and then replaces this function).
if (typeof window.showNotification !== 'function') {
window.showNotification = function(message, type = 'info') {
debugLog(`[${type.toUpperCase()}]`, message);
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ${
type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500'
} text-white`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => { notification.style.opacity = '0'; setTimeout(() => notification.remove(), 500); }, 3000);
const registry = window.LEDMatrixWidgets;
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
if (widget && typeof widget.show === 'function') {
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
}
if (!Array.isArray(window.__pendingNotifications)) {
window.__pendingNotifications = [];
}
window.__pendingNotifications.push([message, type]);
debugLog(`[${String((type && type.type) || type).toUpperCase()}]`, message);
};
}
@@ -211,28 +211,28 @@ const ConfigDiffViewer = {
modalContainer.className = 'fixed inset-0 z-50 overflow-y-auto';
modalContainer.innerHTML = `
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onclick="this.closest('#config-diff-modal-container').remove(); window.__configDiffResolve(false);"></div>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" data-diff-backdrop aria-hidden="true"></div>
<div id="config-diff-modal-panel" class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full" style="position:relative;">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10" aria-hidden="true">
<i class="fas fa-code-branch text-blue-600"></i>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 class="text-lg leading-6 font-medium text-gray-900">Review Configuration Changes</h3>
<div class="mt-4 max-h-96 overflow-y-auto">
<h3 id="config-diff-modal-title" class="text-lg leading-6 font-medium text-gray-900">Review Configuration Changes</h3>
<div class="mt-4 max-h-96 overflow-y-auto" tabindex="0" role="region" aria-label="Changes to save">
${this.formatDiff(differences)}
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button id="config-diff-confirm-btn"
<button type="button" id="config-diff-confirm-btn"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm">
<i class="fas fa-check mr-2"></i>Save Changes
<i class="fas fa-check mr-2" aria-hidden="true"></i>Save Changes
</button>
<button id="config-diff-cancel-btn"
<button type="button" id="config-diff-cancel-btn"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Cancel
</button>
@@ -240,27 +240,41 @@ const ConfigDiffViewer = {
</div>
</div>
`;
document.body.appendChild(modalContainer);
// Store resolve function globally (hack for onclick handlers)
window.__configDiffResolve = resolve;
let release = null;
let settled = false;
// Single close path: remove the modal, release the focus trap
// (returning focus to where the user was) and settle the promise.
function finish(result) {
if (settled) return;
settled = true;
modalContainer.remove();
if (window.__configDiffResolve === finish) window.__configDiffResolve = undefined;
if (release) release();
resolve(result);
}
// Kept for backwards compatibility with code that calls it directly.
window.__configDiffResolve = finish;
// Attach event listeners
const confirmBtn = modalContainer.querySelector('#config-diff-confirm-btn');
const cancelBtn = modalContainer.querySelector('#config-diff-cancel-btn');
confirmBtn.addEventListener('click', () => {
modalContainer.remove();
delete window.__configDiffResolve;
resolve(true);
});
cancelBtn.addEventListener('click', () => {
modalContainer.remove();
delete window.__configDiffResolve;
resolve(false);
});
const backdrop = modalContainer.querySelector('[data-diff-backdrop]');
confirmBtn.addEventListener('click', () => finish(true));
cancelBtn.addEventListener('click', () => finish(false));
if (backdrop) backdrop.addEventListener('click', () => finish(false));
if (window.LEDDialog) {
release = window.LEDDialog.trap(modalContainer.querySelector('#config-diff-modal-panel'), {
labelledBy: 'config-diff-modal-title',
initialFocus: confirmBtn,
onEscape: () => finish(false)
});
}
});
},
@@ -134,9 +134,7 @@
function openResults() {
resultsBox.classList.remove('hidden');
// .hidden has no effect without a matching CSS rule (this app's stylesheet
// is a hand-picked utility subset, not full Tailwind) - force it directly,
// same as the revealNode/collapseNode fallback below.
// Clear any inline display left by closeResults(); the class does the rest.
resultsBox.style.display = '';
if (input) input.setAttribute('aria-expanded', 'true');
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Shared modal-dialog accessibility helper.
*
* The UI has several modal implementations (inline markup toggled with
* style.display, markup injected and removed, the `hidden` class). This helper
* deliberately does not show or hide anything — each caller keeps its own
* visibility logic — it only supplies what every modal was missing:
*
* - role="dialog", aria-modal="true" and aria-labelledby on the panel
* - initial focus inside the panel
* - Tab / Shift+Tab kept inside the panel while it is open
* - Escape routed to the caller's close function
* - focus returned to whatever was focused before the dialog opened
*
* Usage:
* const release = window.LEDDialog.trap(panelEl, {
* labelledBy: 'my-modal-title', // id of the visible heading (optional)
* label: 'Plugin files', // aria-label when there is no heading
* initialFocus: inputEl, // element or selector (optional)
* onEscape: closeMyModal // omit to ignore Escape
* });
* // ...when the modal closes (from any path):
* release();
*
* Nested dialogs stack: only the top-most trap handles Tab and Escape.
* release() is idempotent, and calling trap() again on the same panel
* releases the previous trap first.
*/
(function () {
'use strict';
const FOCUSABLE = [
'a[href]', 'area[href]', 'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])',
'textarea:not([disabled])', 'iframe', '[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])'
].join(',');
const stack = [];
function visible(el) {
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
}
function focusables(panel) {
return Array.prototype.filter.call(panel.querySelectorAll(FOCUSABLE), visible);
}
function onKeydown(e) {
const top = stack[stack.length - 1];
if (!top) return;
if (e.key === 'Escape' && top.onEscape) {
e.preventDefault();
e.stopPropagation();
top.onEscape(e);
return;
}
if (e.key !== 'Tab') return;
const items = focusables(top.panel);
if (items.length === 0) {
e.preventDefault();
top.panel.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (!top.panel.contains(active)) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
function trap(panel, opts) {
if (!panel) return function () {};
opts = opts || {};
const existing = stack.find(function (t) { return t.panel === panel; });
if (existing) existing.release();
panel.setAttribute('role', opts.role || 'dialog');
panel.setAttribute('aria-modal', 'true');
if (opts.labelledBy) {
panel.setAttribute('aria-labelledby', opts.labelledBy);
} else if (opts.label) {
panel.setAttribute('aria-label', opts.label);
}
if (!panel.hasAttribute('tabindex')) panel.setAttribute('tabindex', '-1');
const entry = {
panel: panel,
onEscape: opts.onEscape || null,
returnTo: document.activeElement,
released: false,
release: null
};
entry.release = function () {
if (entry.released) return;
entry.released = true;
const wasTop = stack[stack.length - 1] === entry;
const i = stack.indexOf(entry);
if (i !== -1) stack.splice(i, 1);
if (stack.length === 0) document.removeEventListener('keydown', onKeydown, true);
// Releasing a dialog underneath another one must not pull focus
// out of the dialog the user is actually in.
if (!wasTop) return;
const target = entry.returnTo;
const active = stack[stack.length - 1];
if (active && !(target && active.panel.contains(target))) {
active.panel.focus();
} else if (target && typeof target.focus === 'function' && document.contains(target)) {
target.focus();
}
};
stack.push(entry);
if (stack.length === 1) document.addEventListener('keydown', onKeydown, true);
// Focus after the caller has made the panel visible.
requestAnimationFrame(function () {
if (entry.released) return;
let target = opts.initialFocus;
if (typeof target === 'string') target = panel.querySelector(target);
if (!target || !visible(target)) target = focusables(panel)[0] || panel;
target.focus();
});
return entry.release;
}
window.LEDDialog = { trap: trap };
})();
+117 -78
View File
@@ -7,95 +7,97 @@
/**
* Comprehensive error code to user-friendly message mapping.
* Used only when the server response carries no message of its own.
*/
const ERROR_MESSAGES = {
// Configuration errors
'CONFIG_SAVE_FAILED': 'Failed to save configuration',
'CONFIG_LOAD_FAILED': 'Failed to load configuration',
'CONFIG_VALIDATION_FAILED': 'Configuration validation failed',
'CONFIG_ROLLBACK_FAILED': 'Failed to rollback configuration',
'CONFIG_SAVE_FAILED': "Couldn't save your settings. Check the values and try again",
'CONFIG_LOAD_FAILED': "Couldn't load your settings. Reload the page to try again",
'CONFIG_VALIDATION_FAILED': 'Some settings have invalid values. Fix the highlighted fields and save again',
'CONFIG_ROLLBACK_FAILED': "Couldn't restore the previous settings. Try again, or restore from Backup & Restore",
// Plugin errors
'PLUGIN_NOT_FOUND': 'Plugin not found',
'PLUGIN_INSTALL_FAILED': 'Failed to install plugin',
'PLUGIN_UPDATE_FAILED': 'Failed to update plugin',
'PLUGIN_UNINSTALL_FAILED': 'Failed to uninstall plugin',
'PLUGIN_LOAD_FAILED': 'Failed to load plugin',
'PLUGIN_OPERATION_CONFLICT': 'Plugin operation conflict - another operation is in progress',
'PLUGIN_NOT_FOUND': "That plugin isn't installed. Check the Plugin Manager",
'PLUGIN_INSTALL_FAILED': "Couldn't install the plugin. Check the Pi's internet connection and try again",
'PLUGIN_UPDATE_FAILED': "Couldn't update the plugin. Try again in a moment",
'PLUGIN_UNINSTALL_FAILED': "Couldn't uninstall the plugin. Try again in a moment",
'PLUGIN_LOAD_FAILED': "The plugin couldn't start. Check the Logs tab for details",
'PLUGIN_OPERATION_CONFLICT': 'Another plugin operation is still running. Wait for it to finish, then try again',
// Validation errors
'VALIDATION_ERROR': 'Validation error',
'SCHEMA_VALIDATION_FAILED': 'Configuration schema validation failed',
'INVALID_INPUT': 'Invalid input provided',
'VALIDATION_ERROR': 'Some values are invalid. Fix them and try again',
'SCHEMA_VALIDATION_FAILED': "Some settings don't match what the plugin expects. Fix them and save again",
'INVALID_INPUT': "That value isn't valid. Check it and try again",
// Network errors
'NETWORK_ERROR': 'Network error occurred',
'API_ERROR': 'API request failed',
'TIMEOUT': 'Operation timed out',
'NETWORK_ERROR': "Couldn't reach the LEDMatrix device. Check that it's on and connected, then try again",
'API_ERROR': 'The request failed. Try again in a moment',
'TIMEOUT': 'This took too long to respond. Try again in a moment',
// Permission errors
'PERMISSION_DENIED': 'Permission denied',
'FILE_PERMISSION_ERROR': 'File permission error',
'PERMISSION_DENIED': "LEDMatrix doesn't have permission to do that. See the troubleshooting guide",
'FILE_PERMISSION_ERROR': "LEDMatrix can't write a file it needs. See the troubleshooting guide",
// System errors
'SYSTEM_ERROR': 'System error occurred',
'SERVICE_UNAVAILABLE': 'Service unavailable',
'SYSTEM_ERROR': 'Something went wrong on the device. Check the Logs tab for details',
'SERVICE_UNAVAILABLE': 'The LEDMatrix service is not running. Restart it from the Overview tab',
// Unknown errors
'UNKNOWN_ERROR': 'An unknown error occurred'
'UNKNOWN_ERROR': 'Something went wrong. Try again, and check the Logs tab if it keeps happening'
};
/**
* Error code to troubleshooting documentation links.
*/
const TROUBLESHOOTING_URL = 'https://github.com/ChuckBuilds/LEDMatrix/blob/main/docs/TROUBLESHOOTING.md';
const ERROR_DOCS = {
'CONFIG_SAVE_FAILED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#configuration-errors',
'CONFIG_VALIDATION_FAILED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#validation-errors',
'PLUGIN_INSTALL_FAILED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#plugin-installation',
'PLUGIN_OPERATION_CONFLICT': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#plugin-operations',
'PERMISSION_DENIED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#permissions',
'FILE_PERMISSION_ERROR': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#permissions'
'CONFIG_SAVE_FAILED': TROUBLESHOOTING_URL + '#4-check-configuration',
'CONFIG_VALIDATION_FAILED': TROUBLESHOOTING_URL + '#4-check-configuration',
'PLUGIN_INSTALL_FAILED': TROUBLESHOOTING_URL + '#plugin-issues',
'PLUGIN_OPERATION_CONFLICT': TROUBLESHOOTING_URL + '#plugin-issues',
'PERMISSION_DENIED': TROUBLESHOOTING_URL + '#permission-issues',
'FILE_PERMISSION_ERROR': TROUBLESHOOTING_URL + '#permission-issues'
};
/**
* Format error message for display to user.
*
*
* @param {Object} error - Error object from API response
* @returns {string} Formatted error message
*/
function formatError(error) {
if (!error) {
return 'An unknown error occurred';
return ERROR_MESSAGES.UNKNOWN_ERROR;
}
// If error is a string, return it
if (typeof error === 'string') {
return error;
}
// If error has a message, use it
if (error.message) {
return error.message;
}
// If error has error_code, format it
if (error.error_code) {
const message = ERROR_MESSAGES[error.error_code] || error.message || 'An error occurred';
const message = ERROR_MESSAGES[error.error_code] || error.message || ERROR_MESSAGES.UNKNOWN_ERROR;
// Add details if available
if (error.details) {
return `${message}: ${error.details}`;
}
return message;
}
return 'An error occurred';
return ERROR_MESSAGES.UNKNOWN_ERROR;
}
/**
* Get suggested fixes for an error.
*
*
* @param {Object} error - Error object from API response
* @returns {Array<string>} Array of suggested fixes
*/
@@ -103,13 +105,13 @@ function getSuggestedFixes(error) {
if (!error || !error.suggested_fixes) {
return [];
}
return error.suggested_fixes;
}
/**
* Display error with suggestions in a rich UI.
*
*
* @param {Object} error - Error object from API response
* @param {string} context - Optional context about what was being done
* @param {Object} options - Display options
@@ -121,19 +123,19 @@ function displayError(error, context = null, options = {}) {
const suggestions = getSuggestedFixes(error);
const errorCode = error?.error_code;
const docLink = errorCode ? ERROR_DOCS[errorCode] : null;
// Build full message
let fullMessage = message;
if (context) {
fullMessage = `${context}: ${message}`;
}
if (suggestions.length > 0) {
fullMessage += '\n\nSuggested fixes:\n' + suggestions.map(s => `• ${s}`).join('\n');
}
// If showDetails is true, show a rich error modal
if (options.showDetails !== false && (suggestions.length > 0 || docLink || error.details)) {
if (options.showDetails !== false && (suggestions.length > 0 || docLink || error?.details)) {
showErrorModal(error, context, message, suggestions, docLink);
} else {
// Simple notification
@@ -146,9 +148,12 @@ function displayError(error, context = null, options = {}) {
}
}
// Release function for the open error modal's focus trap (see utils/dialog.js).
let errorModalRelease = null;
/**
* Show a rich error modal with details, suggestions, and copy button.
*
*
* @param {Object} error - Error object
* @param {string} context - Context
* @param {string} message - Formatted message
@@ -156,6 +161,9 @@ function displayError(error, context = null, options = {}) {
* @param {string} docLink - Documentation link
*/
function showErrorModal(error, context, message, suggestions, docLink) {
error = error || {};
suggestions = suggestions || [];
// Create modal container if it doesn't exist
let modalContainer = document.getElementById('error-modal-container');
if (!modalContainer) {
@@ -165,7 +173,13 @@ function showErrorModal(error, context, message, suggestions, docLink) {
modalContainer.style.display = 'none';
document.body.appendChild(modalContainer);
}
// Re-opening while open: release the previous trap before replacing it.
if (errorModalRelease) {
errorModalRelease();
errorModalRelease = null;
}
// Build modal content
const contextText = context ? `<div class="text-sm text-gray-600 mb-2">${escapeHtml(context)}</div>` : '';
const suggestionsHtml = suggestions.length > 0 ? `
@@ -176,15 +190,15 @@ function showErrorModal(error, context, message, suggestions, docLink) {
</ul>
</div>
` : '';
const docLinkHtml = docLink ? `
<div class="mt-4">
<a href="${docLink}" target="_blank" class="text-blue-600 hover:text-blue-800 text-sm underline">
<i class="fas fa-book mr-1"></i>View troubleshooting guide
<a href="${escapeHtml(docLink)}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 text-sm underline">
<i class="fas fa-book mr-1" aria-hidden="true"></i>View troubleshooting guide<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;"> (opens in a new tab)</span>
</a>
</div>
` : '';
const detailsHtml = error.details ? `
<div class="mt-4">
<details class="cursor-pointer">
@@ -193,26 +207,26 @@ function showErrorModal(error, context, message, suggestions, docLink) {
</details>
</div>
` : '';
const errorCodeHtml = error.error_code ? `
<div class="mt-2 text-xs text-gray-500">
Error code: <code class="bg-gray-100 px-1 py-0.5 rounded">${escapeHtml(error.error_code)}</code>
</div>
` : '';
modalContainer.innerHTML = `
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onclick="window.errorHandler.closeErrorModal()"></div>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" onclick="window.errorHandler.closeErrorModal()"></div>
<div id="error-modal-panel" class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full" style="position:relative;">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10" aria-hidden="true">
<i class="fas fa-exclamation-triangle text-red-600"></i>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 class="text-lg leading-6 font-medium text-gray-900">Error</h3>
<div class="mt-2">
<h3 id="error-modal-title" class="text-lg leading-6 font-medium text-gray-900">Something went wrong</h3>
<div class="mt-2" id="error-modal-description">
${contextText}
<p class="text-sm text-gray-500">${escapeHtml(message)}</p>
${errorCodeHtml}
@@ -224,11 +238,11 @@ function showErrorModal(error, context, message, suggestions, docLink) {
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button id="error-modal-copy-btn"
<button type="button" id="error-modal-copy-btn"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm">
<i class="fas fa-copy mr-2"></i>Copy Error Details
<i class="fas fa-copy mr-2" aria-hidden="true"></i>Copy Error Details
</button>
<button onclick="window.errorHandler.closeErrorModal()"
<button type="button" id="error-modal-close-btn" onclick="window.errorHandler.closeErrorModal()"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Close
</button>
@@ -236,7 +250,7 @@ function showErrorModal(error, context, message, suggestions, docLink) {
</div>
</div>
`;
// Attach event listener to copy button
const copyBtn = modalContainer.querySelector('#error-modal-copy-btn');
if (copyBtn) {
@@ -244,8 +258,20 @@ function showErrorModal(error, context, message, suggestions, docLink) {
copyErrorDetails(error);
});
}
modalContainer.style.display = 'block';
const panel = modalContainer.querySelector('#error-modal-panel');
if (panel) {
panel.setAttribute('aria-describedby', 'error-modal-description');
if (window.LEDDialog) {
errorModalRelease = window.LEDDialog.trap(panel, {
labelledBy: 'error-modal-title',
initialFocus: '#error-modal-close-btn',
onEscape: closeErrorModal
});
}
}
}
/**
@@ -256,6 +282,11 @@ function closeErrorModal() {
if (modalContainer) {
modalContainer.style.display = 'none';
}
if (errorModalRelease) {
const release = errorModalRelease;
errorModalRelease = null;
release();
}
}
/**
@@ -277,27 +308,35 @@ function escapeHtml(text) {
/**
* Copy error details to clipboard.
*
*
* @param {Object} error - Error object from API response
*/
function copyErrorDetails(error) {
const errorText = JSON.stringify(error, null, 2);
function copyFailed(err) {
console.error('Failed to copy error details:', err);
if (typeof showNotification === 'function') {
showNotification("Couldn't copy to the clipboard. Open Technical details and copy the text by hand.", 'warning');
}
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(errorText).then(() => {
if (typeof showNotification === 'function') {
showNotification('Error details copied to clipboard', 'success');
}
}).catch(err => {
console.error('Failed to copy error details:', err);
});
}).catch(copyFailed);
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = errorText;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
// Keep focus inside the open dialog while copying.
const panel = document.getElementById('error-modal-panel');
const host = panel && panel.offsetParent !== null ? panel : document.body;
const returnFocus = document.activeElement;
host.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
@@ -305,9 +344,10 @@ function copyErrorDetails(error) {
showNotification('Error details copied to clipboard', 'success');
}
} catch (err) {
console.error('Failed to copy error details:', err);
copyFailed(err);
}
document.body.removeChild(textArea);
host.removeChild(textArea);
if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus();
}
}
@@ -336,4 +376,3 @@ if (typeof module !== 'undefined' && module.exports) {
ERROR_DOCS
};
}
@@ -415,7 +415,8 @@
removeBtn.type = 'button';
removeBtn.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeBtn.onclick = function() { window.removeArrayTableRow(this); };
removeBtn.innerHTML = '<i class="fas fa-trash"></i>';
removeBtn.setAttribute('aria-label', 'Remove row');
removeBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
actionsCell.appendChild(removeBtn);
if (hasAdvanced) {
@@ -424,7 +425,8 @@
editBtn.className = 'text-blue-500 hover:text-blue-700 px-2 py-1 ml-1';
editBtn.title = 'Edit advanced properties (layout, style…)';
editBtn.onclick = function() { window.openArrayTableRowEditor(this); };
editBtn.innerHTML = '<i class="fas fa-sliders-h"></i>';
editBtn.setAttribute('aria-label', 'Edit advanced properties');
editBtn.innerHTML = '<i class="fas fa-sliders-h" aria-hidden="true"></i>';
actionsCell.appendChild(editBtn);
}
@@ -446,9 +448,8 @@
if (!advancedCell) return;
const schema = JSON.parse(advancedCell.dataset.propSchema || '{}');
// Close any existing modal
const existing = document.getElementById('array-row-editor-modal');
if (existing) existing.remove();
// Close any existing modal (also releases its focus trap)
window.closeArrayTableRowEditor();
const overlay = document.createElement('div');
overlay.id = 'array-row-editor-modal';
@@ -463,9 +464,10 @@
// Header
safeSetHTML(dialog, `
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
<h3 id="array-row-editor-title" class="text-base font-semibold text-gray-900">Advanced Properties</h3>
<button type="button" onclick="window.closeArrayTableRowEditor()"
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
aria-label="Close advanced properties"
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times" aria-hidden="true"></i></button>
</div>`);
const body = document.createElement('div');
@@ -558,11 +560,27 @@
dialog.appendChild(footer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
if (window.LEDDialog) {
rowEditorRelease = window.LEDDialog.trap(dialog, {
labelledBy: 'array-row-editor-title',
initialFocus: '[data-modal-prop]',
onEscape: window.closeArrayTableRowEditor
});
}
};
// Focus-trap release for the open row editor (see utils/dialog.js).
let rowEditorRelease = null;
window.closeArrayTableRowEditor = function() {
const modal = document.getElementById('array-row-editor-modal');
if (modal) modal.remove();
if (rowEditorRelease) {
const release = rowEditorRelease;
rowEditorRelease = null;
release();
}
};
/**
@@ -107,6 +107,8 @@
html += `
<input type="color"
id="${fieldId}_color"
title="Choose color"
aria-label="Choose color"
value="${currentValue}"
${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('color-picker').onColorChange('${fieldId}')"
@@ -122,6 +124,7 @@
id="${fieldId}_hex"
value="${currentValue.substring(1)}"
maxlength="6"
aria-label="Hex color code"
${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('color-picker').onHexChange('${fieldId}')"
oninput="window.LEDMatrixWidgets.getHandlers('color-picker').onHexInput('${fieldId}')"
@@ -161,7 +164,8 @@
onclick="window.LEDMatrixWidgets.getHandlers('color-picker').onPresetClick('${fieldId}', this.dataset.color)"
class="w-6 h-6 rounded border border-gray-300 hover:scale-110 transition-transform ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
style="background-color: ${escapeHtml(normalized)};"
title="${escapeHtml(normalized)}">
title="${escapeHtml(normalized)}"
aria-label="Use color ${escapeHtml(normalized)}">
</button>
`;
}
@@ -85,7 +85,7 @@
onchange="window.LEDMatrixWidgets.getHandlers('date-picker').onChange('${fieldId}')"
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<i class="fas fa-calendar-alt text-gray-400"></i>
<i class="fas fa-calendar-alt text-gray-400" aria-hidden="true"></i>
</div>
</div>
`;
@@ -96,8 +96,8 @@
id="${fieldId}_clear"
onclick="window.LEDMatrixWidgets.getHandlers('date-picker').onClear('${fieldId}')"
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
title="Clear">
<i class="fas fa-times"></i>
title="Clear" aria-label="Clear date">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
`;
}
@@ -102,8 +102,8 @@
</div>`;
html += `<button type="button"
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')"
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image">
<i class="fas fa-times"></i>
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image" aria-label="Remove image">
<i class="fas fa-times" aria-hidden="true"></i>
</button>`;
html += '</div>';
@@ -840,7 +840,7 @@
<!-- Schedule Mode -->
<div id="schedule_options_${sanitizedId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
<label for="schedule_mode_${sanitizedId}" class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
<select id="schedule_mode_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
@@ -855,8 +855,8 @@
<!-- Time Range Mode -->
<div id="time_range_${sanitizedId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
<input type="time"
<label for="schedule_start_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
<input type="time"
id="schedule_start_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
@@ -865,8 +865,8 @@
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
<input type="time"
<label for="schedule_end_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
<input type="time"
id="schedule_end_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
@@ -900,6 +900,7 @@
<div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${sanitizedId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
<input type="time"
id="day_${day}_start_${sanitizedId}"
aria-label="${day} start time"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
@@ -909,6 +910,7 @@
${!dayConfig.enabled ? 'disabled' : ''}>
<input type="time"
id="day_${day}_end_${sanitizedId}"
aria-label="${day} end time"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
@@ -57,6 +57,8 @@
this._editFile = null;
this._deleteFile = null;
this._keyHandler = this._onKey.bind(this);
// Focus-trap release functions for open modals (utils/dialog.js)
this._releases = new Map();
this._inject();
this._bind();
@@ -67,9 +69,30 @@
_destroy() {
document.removeEventListener('keydown', this._keyHandler);
for (const key of Array.from(this._releases.keys())) this._release(key);
this.el._jfmInstance = null;
}
// Open a modal's focus trap. Escape is routed here, so _onKey no
// longer handles it.
_trap(key, modal, titleId, initialFocus, onClose) {
if (!modal || !window.LEDDialog) return;
const box = modal.querySelector('.jfm-modal-box');
this._releases.set(key, window.LEDDialog.trap(box, {
labelledBy: titleId,
initialFocus: initialFocus || null,
onEscape: onClose
}));
}
_release(key) {
const release = this._releases.get(key);
if (release) {
this._releases.delete(key);
release();
}
}
// ── DOM Injection ────────────────────────────────────────────────────
_inject() {
@@ -108,21 +131,22 @@
</div>` : ''}
<!-- ── Edit modal ─────────────────────────────────────── -->
<div class="jfm-modal" id="${u}-edit-modal" role="dialog" aria-modal="true" hidden>
<div class="jfm-modal" id="${u}-edit-modal" hidden>
<div class="jfm-modal-box jfm-modal-wide">
<div class="jfm-modal-head">
<span id="${u}-edit-title" class="jfm-modal-title">Edit file</span>
<div class="jfm-modal-tools">
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="fmt">Format</button>
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="validate">Validate</button>
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close">&times;</button>
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close editor">&times;</button>
</div>
</div>
<div id="${u}-edit-err" class="jfm-err-bar" hidden></div>
<div id="${u}-edit-err" class="jfm-err-bar" role="alert" hidden></div>
<textarea id="${u}-editor" class="jfm-editor"
spellcheck="false" autocomplete="off"
autocorrect="off" autocapitalize="off"
aria-label="JSON editor"></textarea>
aria-label="JSON editor" aria-describedby="${u}-editor-hint"></textarea>
<span id="${u}-editor-hint" hidden>Tab inserts two spaces. Shift+Tab moves focus back to the toolbar. Ctrl+S saves.</span>
<div class="jfm-modal-foot">
<span id="${u}-charcount" class="jfm-stat"></span>
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
@@ -133,10 +157,10 @@
<!-- ── Delete modal ───────────────────────────────────── -->
${hasDelete ? `
<div class="jfm-modal" id="${u}-del-modal" role="dialog" aria-modal="true" hidden>
<div class="jfm-modal" id="${u}-del-modal" hidden>
<div class="jfm-modal-box">
<div class="jfm-modal-head">
<span class="jfm-modal-title">Delete file</span>
<span id="${u}-del-title" class="jfm-modal-title">Delete file</span>
<button type="button" class="jfm-close-btn" data-jfm="close-del" aria-label="Close">&times;</button>
</div>
<div class="jfm-modal-body">
@@ -144,7 +168,7 @@
<p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
</div>
<div class="jfm-modal-foot">
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del">Cancel</button>
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del" id="${u}-del-cancel">Cancel</button>
<button type="button" class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
</div>
</div>
@@ -152,10 +176,10 @@
<!-- ── Create modal ───────────────────────────────────── -->
${hasCreate ? `
<div class="jfm-modal" id="${u}-create-modal" role="dialog" aria-modal="true" hidden>
<div class="jfm-modal" id="${u}-create-modal" hidden>
<div class="jfm-modal-box">
<div class="jfm-modal-head">
<span class="jfm-modal-title">Create new file</span>
<span id="${u}-create-title" class="jfm-modal-title">Create new file</span>
<button type="button" class="jfm-close-btn" data-jfm="close-create" aria-label="Close">&times;</button>
</div>
<div class="jfm-modal-body">
@@ -189,93 +213,106 @@
}
_css(u) {
// Colors come from the app theme tokens (app.css :root / [data-theme="dark"])
// with the original light values as fallbacks for pages without app.css.
// Shades the tokens don't cover are local variables with a dark override.
return `<style>
#${u}{font-family:inherit;color:#111827;}
#${u}{--jfm-border-strong:#d1d5db;--jfm-border-hover:#9ca3af;--jfm-subtle:#f3f4f6;--jfm-accent-soft:#93c5fd;--jfm-danger-border:#fecaca;--jfm-danger-text:#991b1b;--jfm-text-muted:#6b7280;}
[data-theme="dark"] #${u}{--jfm-border-strong:#4b5563;--jfm-border-hover:#6b7280;--jfm-subtle:#374151;--jfm-accent-soft:#1e40af;--jfm-danger-border:#991b1b;--jfm-danger-text:#fecaca;--jfm-text-muted:#9ca3af;}
#${u}{font-family:inherit;color:var(--color-text-primary,#111827);}
#${u} *{box-sizing:border-box;}
/* Header */
#${u} .jfm-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.875rem;gap:.5rem;}
#${u} .jfm-header-left{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:#111827;}
#${u} .jfm-dir{font-size:.75rem;color:#6b7280;background:#f3f4f6;padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:var(--color-text-primary,#111827);}
#${u} .jfm-dir{font-size:.75rem;color:var(--jfm-text-muted);background:var(--jfm-subtle);padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
#${u} .jfm-header-right{display:flex;gap:.375rem;align-items:center;flex-shrink:0;}
/* Buttons */
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid #d1d5db;background:#fff;color:#374151;font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
#${u} .jfm-btn:hover:not(:disabled){background:#f9fafb;border-color:#9ca3af;}
#${u} .jfm-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:1px;}
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid var(--jfm-border-strong);background:var(--color-surface,#fff);color:var(--color-text-secondary,#374151);font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
#${u} .jfm-btn:hover:not(:disabled){background:var(--color-background,#f9fafb);border-color:var(--jfm-border-hover);}
#${u} .jfm-btn:focus-visible,#${u} .jfm-close-btn:focus-visible{outline:2px solid var(--color-primary,#3b82f6);outline-offset:1px;}
#${u} .jfm-btn:disabled{opacity:.5;cursor:not-allowed;}
#${u} .jfm-btn-sm{padding:.3125rem .625rem;font-size:.8125rem;}
#${u} .jfm-btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff;}
#${u} .jfm-btn-primary:hover:not(:disabled){background:#2563eb;border-color:#2563eb;}
#${u} .jfm-btn-danger{background:#ef4444;border-color:#ef4444;color:#fff;}
#${u} .jfm-btn-danger:hover:not(:disabled){background:#dc2626;border-color:#dc2626;}
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:#6b7280;}
#${u} .jfm-btn-ghost:hover:not(:disabled){background:#f3f4f6;color:#374151;}
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
#${u} .jfm-close-btn:hover{background:#f3f4f6;color:#374151;}
#${u} .jfm-btn-primary{background:var(--color-primary,#3b82f6);border-color:var(--color-primary,#3b82f6);color:#fff;}
#${u} .jfm-btn-primary:hover:not(:disabled){background:var(--color-primary-hover,#2563eb);border-color:var(--color-primary-hover,#2563eb);}
#${u} .jfm-btn-danger{background:#dc2626;border-color:#dc2626;color:#fff;}
#${u} .jfm-btn-danger:hover:not(:disabled){background:#b91c1c;border-color:#b91c1c;}
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:var(--jfm-text-muted);}
#${u} .jfm-btn-ghost:hover:not(:disabled){background:var(--jfm-subtle);color:var(--color-text-secondary,#374151);}
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:var(--jfm-text-muted);font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
#${u} .jfm-close-btn:hover{background:var(--jfm-subtle);color:var(--color-text-secondary,#374151);}
/* File list */
#${u} .jfm-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.625rem;margin-bottom:1rem;min-height:5rem;}
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:#6b7280;font-size:.875rem;}
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:#9ca3af;}
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:var(--jfm-text-muted);font-size:.875rem;}
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:var(--jfm-text-muted);}
#${u} .jfm-empty-icon{font-size:2.25rem;margin-bottom:.625rem;}
#${u} .jfm-empty-title{font-weight:600;color:#374151;margin:0 0 .25rem;}
#${u} .jfm-empty-title{font-weight:600;color:var(--color-text-secondary,#374151);margin:0 0 .25rem;}
#${u} .jfm-empty-sub{font-size:.875rem;margin:0;}
/* File cards */
#${u} .jfm-card{border:1px solid #e5e7eb;border-radius:.5rem;padding:.875rem;background:#fff;display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
#${u} .jfm-card:hover{border-color:#93c5fd;box-shadow:0 2px 8px rgba(59,130,246,.1);}
#${u} .jfm-card{border:1px solid var(--color-border,#e5e7eb);border-radius:.5rem;padding:.875rem;background:var(--color-surface,#fff);display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
#${u} .jfm-card:hover{border-color:var(--jfm-accent-soft);box-shadow:var(--shadow-md,0 2px 8px rgba(59,130,246,.1));}
#${u} .jfm-card.jfm-off{opacity:.6;}
#${u} .jfm-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;}
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:#111827;flex:1;}
#${u} .jfm-card-meta{font-size:.75rem;color:#6b7280;display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid #f3f4f6;margin-top:.125rem;}
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:var(--color-text-primary,#111827);flex:1;}
#${u} .jfm-card-meta{font-size:.75rem;color:var(--jfm-text-muted);display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid var(--color-border-light,#f3f4f6);margin-top:.125rem;}
#${u} .jfm-card-actions .jfm-btn{flex:1;justify-content:center;}
#${u} .jfm-card-actions .jfm-del{flex:0 0 auto;}
/* Toggle */
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:#6b7280;white-space:nowrap;flex-shrink:0;}
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:var(--jfm-text-muted);white-space:nowrap;flex-shrink:0;}
#${u} .jfm-toggle input[type=checkbox]{width:.9375rem;height:.9375rem;cursor:pointer;accent-color:#22c55e;margin:0;}
/* Upload zone */
#${u} .jfm-upload-wrap{margin-top:.25rem;}
#${u} input[type=file]#${u}-fileinput{position:absolute;left:-9999px;width:1px;height:1px;opacity:0;}
#${u} .jfm-dropzone{border:2px dashed #d1d5db;border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:#f9fafb;user-select:none;}
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:#3b82f6;background:#eff6ff;border-style:solid;outline:none;}
#${u} .jfm-dropzone{border:2px dashed var(--jfm-border-strong);border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:var(--color-background,#f9fafb);user-select:none;}
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:var(--color-primary,#3b82f6);background:var(--color-info-bg,#eff6ff);border-style:solid;outline:none;}
#${u} .jfm-drop-icon{font-size:1.75rem;display:block;margin-bottom:.375rem;}
#${u} .jfm-drop-primary{font-size:.875rem;color:#374151;margin:0 0 .25rem;}
#${u} .jfm-drop-hint{font-size:.75rem;color:#9ca3af;margin:0;}
#${u} .jfm-drop-primary{font-size:.875rem;color:var(--color-text-secondary,#374151);margin:0 0 .25rem;}
#${u} .jfm-drop-hint{font-size:.75rem;color:var(--jfm-text-muted);margin:0;}
/* Modals */
#${u} .jfm-modal{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;backdrop-filter:blur(1px);}
#${u} .jfm-modal[hidden]{display:none;}
#${u} .jfm-modal-box{background:#fff;border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
#${u} .jfm-modal-box{background:var(--color-surface,#fff);color:var(--color-text-primary,#111827);border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
#${u} .jfm-modal-box:focus{outline:none;}
#${u} .jfm-modal-wide{max-width:880px;}
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid #e5e7eb;flex-shrink:0;gap:.5rem;}
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid var(--color-border,#e5e7eb);flex-shrink:0;gap:.5rem;}
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:var(--color-text-primary,#111827);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
#${u} .jfm-modal-tools{display:flex;gap:.25rem;align-items:center;flex-shrink:0;}
#${u} .jfm-modal-body{padding:1.125rem;overflow-y:auto;flex:1;}
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid #e5e7eb;flex-shrink:0;background:#f9fafb;border-radius:0 0 .5rem .5rem;}
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:#9ca3af;font-variant-numeric:tabular-nums;}
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid var(--color-border,#e5e7eb);flex-shrink:0;background:var(--color-background,#f9fafb);border-radius:0 0 .5rem .5rem;}
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:var(--jfm-text-muted);font-variant-numeric:tabular-nums;}
/* JSON editor */
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:#1e293b;background:#fafafa;tab-size:2;}
#${u} .jfm-err-bar{background:#fef2f2;border-bottom:1px solid #fecaca;color:#991b1b;font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:var(--color-text-primary,#1e293b);background:var(--color-background,#fafafa);tab-size:2;}
#${u} .jfm-editor:focus-visible{box-shadow:inset 0 0 0 2px var(--color-primary,#3b82f6);}
#${u} .jfm-err-bar{background:var(--color-error-bg,#fef2f2);border-bottom:1px solid var(--jfm-danger-border);color:var(--jfm-danger-text);font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
#${u} .jfm-err-bar[hidden]{display:none;}
/* Create form */
#${u} .jfm-field{margin-bottom:.875rem;}
#${u} .jfm-field:last-child{margin-bottom:0;}
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:#374151;margin-bottom:.3125rem;}
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid #d1d5db;border-radius:.375rem;font-size:.875rem;color:#111827;background:#fff;}
#${u} .jfm-field input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12);}
#${u} .jfm-hint{display:block;font-size:.75rem;color:#9ca3af;margin-top:.25rem;}
#${u} .jfm-muted{font-size:.875rem;color:#6b7280;margin-top:.375rem;}
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:var(--color-text-secondary,#374151);margin-bottom:.3125rem;}
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid var(--jfm-border-strong);border-radius:.375rem;font-size:.875rem;color:var(--color-text-primary,#111827);background:var(--color-surface,#fff);}
#${u} .jfm-field input:focus{outline:none;border-color:var(--color-primary,#3b82f6);box-shadow:0 0 0 3px rgba(59,130,246,.25);}
#${u} .jfm-hint{display:block;font-size:.75rem;color:var(--jfm-text-muted);margin-top:.25rem;}
#${u} .jfm-muted{font-size:.875rem;color:var(--jfm-text-muted);margin-top:.375rem;}
/* Spinner */
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid var(--color-border,#e5e7eb);border-top-color:var(--color-primary,#3b82f6);border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
@keyframes jfm-spin-${u}{to{transform:rotate(360deg);}}
@media (prefers-reduced-motion: reduce){
#${u} .jfm-btn,#${u} .jfm-card,#${u} .jfm-dropzone{transition:none;}
#${u} .jfm-spin{animation-duration:1.5s;}
#${u} .jfm-modal{backdrop-filter:none;}
}
</style>`;
}
@@ -319,7 +356,9 @@
if (this._editorEl) {
this._editorEl.addEventListener('input', () => this._updateStat());
this._editorEl.addEventListener('keydown', e => {
if (e.key === 'Tab') {
// Plain Tab indents; Shift+Tab is left alone so keyboard
// users can always move focus out of the editor.
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
const s = this._editorEl.selectionStart;
const end = this._editorEl.selectionEnd;
@@ -336,15 +375,8 @@
}
_onKey(e) {
// Escape is handled by the dialog focus trap (see _trap).
const editOpen = this._editModal && !this._editModal.hidden;
const delOpen = this._delModal && !this._delModal.hidden;
const createOpen = this._createModal && !this._createModal.hidden;
if (e.key === 'Escape') {
if (editOpen) { this._closeEdit(); return; }
if (delOpen) { this._closeDel(); return; }
if (createOpen) { this._closeCreate(); return; }
}
if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
e.preventDefault();
this._doSave();
@@ -488,6 +520,7 @@
this._editorEl.value = 'Loading…';
this._updateStat();
this._editModal.hidden = false;
this._trap('edit', this._editModal, `${this._uid}-edit-title`, this._editorEl, () => this._closeEdit());
try {
const data = await this._api('get', { filename });
@@ -498,7 +531,7 @@
this._editorEl.setSelectionRange(0, 0);
this._editorEl.scrollTop = 0;
} catch (err) {
this._showErr('Failed to load file: ' + err.message);
this._showErr(`Couldn't load this file (${err.message}). Close the editor and try again.`);
this._editorEl.value = '';
}
}
@@ -507,6 +540,7 @@
if (this._editModal) this._editModal.hidden = true;
this._editFile = null;
this._clearErr();
this._release('edit');
}
_formatJson() {
@@ -562,12 +596,18 @@
this._deleteFile = filename;
const el = document.getElementById(`${this._uid}-del-name`);
if (el) el.textContent = filename;
if (this._delModal) this._delModal.hidden = false;
if (this._delModal) {
this._delModal.hidden = false;
// Destructive dialog: start on Cancel.
this._trap('del', this._delModal, `${this._uid}-del-title`,
document.getElementById(`${this._uid}-del-cancel`), () => this._closeDel());
}
}
_closeDel() {
if (this._delModal) this._delModal.hidden = true;
this._deleteFile = null;
this._release('del');
}
async _doDelete() {
@@ -597,11 +637,13 @@
});
this._createModal.hidden = false;
const first = this.createFields[0];
if (first) document.getElementById(`${this._uid}-cf-${first.key}`)?.focus();
const firstInput = first ? document.getElementById(`${this._uid}-cf-${first.key}`) : null;
this._trap('create', this._createModal, `${this._uid}-create-title`, firstInput, () => this._closeCreate());
}
_closeCreate() {
if (this._createModal) this._createModal.hidden = true;
this._release('create');
}
async _doCreate() {
@@ -2,7 +2,9 @@
* LEDMatrix Notification Widget
*
* Global notification/toast system for displaying messages to users.
* Consolidates all notification functionality into a single widget.
* This is the single implementation: the early fallbacks in app-shell.js,
* app.js and partials/fonts.html only queue messages until this widget has
* loaded (see window.__pendingNotifications) and then delegate to it.
*
* Usage:
* window.showNotification('Message here', 'success');
@@ -11,6 +13,16 @@
*
* Types: success, error, warning, info (default)
*
* Accessibility:
* - Toasts are announced through two persistent, visually hidden live
* regions: errors use an assertive region (role="alert"), everything else
* a polite one (role="status"). The visible toast is not itself a live
* region, so each message is announced exactly once.
* - Errors stay on screen for at least ERROR_MIN_DURATION and always have a
* dismiss button.
* - Auto-dismiss pauses while the pointer is over a toast or focus is in it.
* - Slide animations are skipped under prefers-reduced-motion.
*
* @module NotificationWidget
*/
@@ -26,35 +38,51 @@
// Configuration
const CONFIG = {
containerId: 'notifications',
politeRegionId: 'notifications-live-polite',
assertiveRegionId: 'notifications-live-assertive',
defaultDuration: 4000,
warningDuration: 6000,
errorMinDuration: 10000,
fadeOutDuration: 300,
maxNotifications: 5,
position: 'top-right' // top-right, top-left, bottom-right, bottom-left
};
// Type-specific styling
// Type-specific styling. `bg` class names are kept for anyone styling
// against them; `color` is applied inline so white text meets 4.5:1
// contrast even where the utility classes are missing or lighter.
const TYPE_STYLES = {
success: {
bg: 'bg-green-500',
color: '#047857',
icon: 'fa-check-circle',
label: 'Success'
},
error: {
bg: 'bg-red-500',
color: '#b91c1c',
icon: 'fa-exclamation-circle',
label: 'Error'
},
warning: {
bg: 'bg-yellow-500',
color: '#b45309',
icon: 'fa-exclamation-triangle',
label: 'Warning'
},
info: {
bg: 'bg-blue-500',
color: '#1d4ed8',
icon: 'fa-info-circle',
label: 'Info'
}
};
// Lookups go through a Map so an arbitrary type string can't reach
// Object.prototype properties.
const STYLE_BY_TYPE = new Map(Object.entries(TYPE_STYLES));
const VISUALLY_HIDDEN = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;' +
'overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
// Track active notifications
let activeNotifications = [];
@@ -62,8 +90,14 @@
// keyed by notification id (cleaned up on dismiss). A Map rather than a
// plain object so ids can never collide with prototype properties.
const actionCallbacks = new Map();
// Auto-dismiss timers, keyed by notification id: { timer, remaining, startedAt }
const timers = new Map();
let notificationCounter = 0;
function prefersReducedMotion() {
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
}
/**
* Get or create the notifications container
* @returns {HTMLElement} Container element
@@ -75,14 +109,55 @@
container = document.createElement('div');
container.id = CONFIG.containerId;
container.className = 'fixed top-4 right-4 z-50 space-y-2 pointer-events-none';
container.setAttribute('aria-live', 'polite');
container.setAttribute('aria-label', 'Notifications');
document.body.appendChild(container);
}
if (!container.dataset.ledNotifications) {
container.dataset.ledNotifications = 'true';
// Announcements go through the dedicated live regions below; a
// live container would announce every toast a second time.
container.removeAttribute('aria-live');
container.setAttribute('role', 'region');
container.setAttribute('aria-label', 'Notifications');
// Let clicks pass through the empty parts of the stack even when
// the utility classes are not defined.
container.style.pointerEvents = 'none';
}
getLiveRegion(CONFIG.politeRegionId, 'status', 'polite');
getLiveRegion(CONFIG.assertiveRegionId, 'alert', 'assertive');
return container;
}
function getLiveRegion(id, role, politeness) {
let region = document.getElementById(id);
if (!region) {
region = document.createElement('div');
region.id = id;
region.setAttribute('role', role);
region.setAttribute('aria-live', politeness);
region.setAttribute('aria-atomic', 'true');
region.style.cssText = VISUALLY_HIDDEN;
document.body.appendChild(region);
}
return region;
}
/**
* Announce a message to screen readers once.
*/
function announce(text, assertive) {
const region = getLiveRegion(
assertive ? CONFIG.assertiveRegionId : CONFIG.politeRegionId,
assertive ? 'alert' : 'status',
assertive ? 'assertive' : 'polite'
);
// Clear first so repeating the same message is announced again.
region.textContent = '';
setTimeout(() => { region.textContent = text; }, 50);
}
/**
* Escape HTML to prevent XSS
* @param {string} text - Text to escape
@@ -94,16 +169,55 @@
return div.innerHTML;
}
function clearTimer(notificationId) {
const t = timers.get(notificationId);
if (t && t.timer) clearTimeout(t.timer);
timers.delete(notificationId);
}
function startTimer(notificationId, ms) {
const t = timers.get(notificationId) || {};
if (t.timer) clearTimeout(t.timer);
t.remaining = ms;
t.startedAt = Date.now();
t.timer = setTimeout(() => removeNotification(notificationId), ms);
timers.set(notificationId, t);
}
function pauseTimer(notificationId) {
const t = timers.get(notificationId);
if (!t || !t.timer) return;
clearTimeout(t.timer);
t.timer = null;
t.remaining = Math.max(0, t.remaining - (Date.now() - t.startedAt));
}
function resumeTimer(notificationId) {
const t = timers.get(notificationId);
if (!t || t.timer) return;
// Give the reader a moment after they move away.
startTimer(notificationId, Math.max(t.remaining, 2000));
}
/**
* Remove a notification by ID
* @param {string} notificationId - Notification ID
* @param {boolean} immediate - Skip fade animation
*/
function removeNotification(notificationId, immediate = false) {
clearTimer(notificationId);
const notification = document.getElementById(notificationId);
// Remove from tracking array
activeNotifications = activeNotifications.filter(id => id !== notificationId);
actionCallbacks.delete(notificationId);
if (!notification) return;
if (immediate) {
// If focus is inside the toast, don't strand it on a removed node.
const hadFocus = notification.contains(document.activeElement);
if (immediate || prefersReducedMotion()) {
notification.remove();
} else {
notification.style.transition = `opacity ${CONFIG.fadeOutDuration}ms, transform ${CONFIG.fadeOutDuration}ms`;
@@ -115,9 +229,16 @@
}, CONFIG.fadeOutDuration);
}
// Remove from tracking array
activeNotifications = activeNotifications.filter(id => id !== notificationId);
actionCallbacks.delete(notificationId);
if (hadFocus) {
const next = activeNotifications.length
? document.querySelector(`#${activeNotifications[activeNotifications.length - 1]} button`)
: null;
if (next) {
next.focus();
} else if (document.activeElement && typeof document.activeElement.blur === 'function') {
document.activeElement.blur();
}
}
}
/**
@@ -131,15 +252,26 @@
if (typeof options === 'string') {
options = { type: options };
}
options = options || {};
const type = options.type || 'info';
const duration = options.duration !== undefined ? options.duration : CONFIG.defaultDuration;
const type = STYLE_BY_TYPE.has(options.type) ? options.type : 'info';
const isError = type === 'error';
let duration = options.duration !== undefined
? Number(options.duration)
: (isError ? CONFIG.errorMinDuration
: type === 'warning' ? CONFIG.warningDuration : CONFIG.defaultDuration);
// Errors must stay long enough to read; 0 still means "until dismissed".
if (isError && duration > 0 && duration < CONFIG.errorMinDuration) {
duration = CONFIG.errorMinDuration;
}
const showIcon = options.showIcon !== false;
const dismissible = options.dismissible !== false;
// Errors always get a dismiss button.
const dismissible = isError || duration <= 0 || options.dismissible !== false;
const style = TYPE_STYLES[type] || TYPE_STYLES.info;
const style = STYLE_BY_TYPE.get(type);
const container = getContainer();
const notificationId = `notification_${++notificationCounter}`;
const reduceMotion = prefersReducedMotion();
// Enforce max notifications limit
while (activeNotifications.length >= CONFIG.maxNotifications) {
@@ -150,18 +282,25 @@
const notification = document.createElement('div');
notification.id = notificationId;
notification.className = `${style.bg} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 pointer-events-auto transform transition-all duration-300 ease-out`;
notification.style.opacity = '0';
notification.style.transform = 'translateX(100%)';
notification.setAttribute('role', 'alert');
notification.style.backgroundColor = style.color;
notification.style.color = '#ffffff';
notification.style.pointerEvents = 'auto';
notification.dataset.type = type;
if (reduceMotion) {
notification.style.transition = 'none';
} else {
notification.style.opacity = '0';
notification.style.transform = 'translateX(100%)';
}
// Build content
let html = '';
if (showIcon) {
html += `<i class="fas ${style.icon} flex-shrink-0"></i>`;
html += `<i class="fas ${style.icon} flex-shrink-0" aria-hidden="true"></i>`;
}
html += `<span class="flex-1 text-sm">${escapeHtml(message)}</span>`;
html += `<span class="flex-1 text-sm"><span style="${VISUALLY_HIDDEN}">${style.label}: </span>${escapeHtml(message)}</span>`;
// Optional inline action button (e.g. "Restart Now" on a restart nudge).
// The callback is stored by id and invoked via triggerAction, which
@@ -171,7 +310,8 @@
html += `
<button type="button"
onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')"
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150">
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150"
style="background:rgba(255,255,255,.2);color:inherit;border:0;">
${escapeHtml(options.actionLabel)}
</button>
`;
@@ -182,8 +322,9 @@
<button type="button"
onclick="window.LEDMatrixWidgets.get('notification').dismiss('${notificationId}')"
class="flex-shrink-0 ml-2 w-5 h-5 flex items-center justify-center rounded-full opacity-70 hover:opacity-100 hover:bg-white hover:bg-opacity-20 transition-all duration-150"
aria-label="Dismiss notification">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
style="width:1.75rem;height:1.75rem;display:inline-flex;align-items:center;justify-content:center;background:transparent;color:inherit;border:0;border-radius:9999px;cursor:pointer;"
aria-label="Dismiss ${style.label.toLowerCase()} notification">
<svg class="w-3 h-3" width="12" height="12" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
@@ -194,17 +335,29 @@
container.appendChild(notification);
activeNotifications.push(notificationId);
// Trigger animation (need to wait for DOM update)
requestAnimationFrame(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateX(0)';
});
announce(`${style.label}: ${String(message)}`, isError);
// Auto-dismiss (0 = no auto-dismiss)
if (!reduceMotion) {
// Trigger animation (need to wait for DOM update)
requestAnimationFrame(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateX(0)';
});
}
// Auto-dismiss (0 = no auto-dismiss), paused while hovered or focused
if (duration > 0) {
setTimeout(() => {
removeNotification(notificationId);
}, duration);
startTimer(notificationId, duration);
notification.addEventListener('mouseenter', () => pauseTimer(notificationId));
notification.addEventListener('mouseleave', () => {
if (!notification.contains(document.activeElement)) resumeTimer(notificationId);
});
notification.addEventListener('focusin', () => pauseTimer(notificationId));
notification.addEventListener('focusout', (e) => {
if (!notification.contains(e.relatedTarget) && !notification.matches(':hover')) {
resumeTimer(notificationId);
}
});
}
// Log for debugging
@@ -224,16 +377,17 @@
// Register the widget
window.LEDMatrixWidgets.register('notification', {
name: 'Notification Widget',
version: '1.0.0',
version: '1.1.0',
/**
* Show a notification
* @param {string} message - Message to display
* @param {Object} options - Configuration options
* @param {string} options.type - Notification type: success, error, warning, info
* @param {number} options.duration - Auto-dismiss duration in ms (0 = no auto-dismiss)
* @param {number} options.duration - Auto-dismiss duration in ms (0 = no auto-dismiss).
* Errors are kept for at least 10s.
* @param {boolean} options.showIcon - Show type icon (default: true)
* @param {boolean} options.dismissible - Show dismiss button (default: true)
* @param {boolean} options.dismissible - Show dismiss button (default: true; always true for errors)
* @returns {string} Notification ID
*/
show: showNotification,
@@ -295,15 +449,28 @@
// Global shorthand function (backwards compatible with existing code).
// Accepts either the legacy type string or a full options object
// ({ type, duration, actionLabel, onAction, ... }).
// This always replaces the early fallbacks, whatever order they loaded in.
window.showNotification = function(message, type = 'info') {
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {}));
};
window.showNotification.__ledNotificationWidget = true;
// Initialize container on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', getContainer);
} else {
function flushPending() {
getContainer();
const pending = window.__pendingNotifications;
window.__pendingNotifications = [];
if (Array.isArray(pending)) {
pending.forEach(args => {
try { window.showNotification(args[0], args[1]); } catch (e) { console.error(e); }
});
}
}
// Initialize container (and show anything queued before we loaded)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', flushPending);
} else {
flushPending();
}
console.log('[NotificationWidget] Notification widget registered');
@@ -101,8 +101,9 @@
html += `
<button type="button"
onclick="window.LEDMatrixWidgets.getHandlers('number-input').onDecrement('${fieldId}')"
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-r-0 border-gray-300 hover:bg-gray-200 ${prefix ? '' : 'rounded-l-md'}">
<i class="fas fa-minus text-xs"></i>
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-r-0 border-gray-300 hover:bg-gray-200 ${prefix ? '' : 'rounded-l-md'}"
aria-label="Decrease" aria-controls="${fieldId}_input">
<i class="fas fa-minus text-xs" aria-hidden="true"></i>
</button>
`;
}
@@ -128,8 +129,9 @@
html += `
<button type="button"
onclick="window.LEDMatrixWidgets.getHandlers('number-input').onIncrement('${fieldId}')"
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-l-0 border-gray-300 hover:bg-gray-200 ${suffix ? '' : 'rounded-r-md'}">
<i class="fas fa-plus text-xs"></i>
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-l-0 border-gray-300 hover:bg-gray-200 ${suffix ? '' : 'rounded-r-md'}"
aria-label="Increase" aria-controls="${fieldId}_input">
<i class="fas fa-plus text-xs" aria-hidden="true"></i>
</button>
`;
}
@@ -142,8 +142,9 @@
id="${fieldId}_toggle"
onclick="window.LEDMatrixWidgets.getHandlers('password-input').onToggle('${fieldId}')"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
title="Show/hide password">
<i id="${fieldId}_icon" class="fas fa-eye"></i>
title="Show/hide password"
aria-label="Show password" aria-controls="${fieldId}_input">
<i id="${fieldId}_icon" class="fas fa-eye" aria-hidden="true"></i>
</button>
`;
}
@@ -297,16 +298,19 @@
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
const icon = document.getElementById(`${safeId}_icon`);
const toggleBtn = document.getElementById(`${safeId}_toggle`);
if (input && icon) {
if (input.type === 'password') {
input.type = 'text';
icon.classList.remove('fa-eye');
icon.classList.add('fa-eye-slash');
if (toggleBtn) toggleBtn.setAttribute('aria-label', 'Hide password');
} else {
input.type = 'password';
icon.classList.remove('fa-eye-slash');
icon.classList.add('fa-eye');
if (toggleBtn) toggleBtn.setAttribute('aria-label', 'Show password');
}
}
}
@@ -56,108 +56,141 @@
if (!document.getElementById('pfm-styles')) {
const style = document.createElement('style');
style.id = 'pfm-styles';
// Colors come from the app theme tokens (app.css :root / [data-theme="dark"]),
// with the original light values as fallbacks for pages without app.css.
// The few shades the tokens don't cover are widget-local variables with
// a dark-theme override.
style.textContent = `
.pfm-root { font-family: inherit; }
.pfm-root, .pfm-overlay {
--pfm-border-strong:#d1d5db; --pfm-subtle:#f3f4f6; --pfm-subtle-hover:#e5e7eb;
--pfm-danger-border:#fecaca; --pfm-danger-text:#991b1b; --pfm-today:#fef9c3;
--pfm-text-muted:#6b7280;
}
[data-theme="dark"] .pfm-root, [data-theme="dark"] .pfm-overlay {
--pfm-border-strong:#4b5563; --pfm-subtle:#374151; --pfm-subtle-hover:#4b5563;
--pfm-danger-border:#991b1b; --pfm-danger-text:#fecaca; --pfm-today:#422006;
--pfm-text-muted:#9ca3af;
}
.pfm-root { font-family: inherit; color:var(--color-text-primary,#111827); }
.pfm-header { display:flex; align-items:center; justify-content:space-between;
margin-bottom:.75rem; }
.pfm-title { font-size:1rem; font-weight:600; color:#111827; }
.pfm-dir { font-size:.75rem; color:#6b7280; margin-top:.125rem; }
.pfm-upload { border:2px dashed #d1d5db; border-radius:.5rem; padding:1.25rem;
.pfm-title { font-size:1rem; font-weight:600; color:var(--color-text-primary,#111827); }
.pfm-dir { font-size:.75rem; color:var(--pfm-text-muted); margin-top:.125rem; }
.pfm-upload { border:2px dashed var(--pfm-border-strong); border-radius:.5rem; padding:1.25rem;
text-align:center; cursor:pointer; transition:border-color .15s,background .15s; }
.pfm-upload:hover,.pfm-upload.dragover { border-color:#3b82f6; background:#eff6ff; }
.pfm-upload p { font-size:.875rem; color:#4b5563; margin:.25rem 0 0; }
.pfm-upload small { font-size:.75rem; color:#9ca3af; }
.pfm-upload:hover,.pfm-upload.dragover { border-color:var(--color-primary,#3b82f6); background:var(--color-info-bg,#eff6ff); }
.pfm-upload p { font-size:.875rem; color:var(--color-text-tertiary,#4b5563); margin:.25rem 0 0; }
.pfm-upload small { font-size:.75rem; color:var(--pfm-text-muted); }
.pfm-upload-icon { font-size:1.5rem; color:var(--pfm-text-muted); }
.pfm-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
gap:.75rem; margin-top:.75rem; }
.pfm-card { border:1px solid #e5e7eb; border-radius:.5rem; padding:.875rem;
background:#fff; transition:box-shadow .15s; }
.pfm-card:hover { box-shadow:0 1px 4px rgba(0,0,0,.1); }
.pfm-card { border:1px solid var(--color-border,#e5e7eb); border-radius:.5rem; padding:.875rem;
background:var(--color-surface,#fff); transition:box-shadow .15s; }
.pfm-card:hover { box-shadow:var(--shadow-md,0 1px 4px rgba(0,0,0,.1)); }
.pfm-card.disabled { opacity:.55; }
.pfm-card-top { display:flex; align-items:center; justify-content:space-between;
margin-bottom:.5rem; }
.pfm-card-icon { width:2rem; height:2rem; background:#f3f4f6; border-radius:.375rem;
.pfm-card-icon { width:2rem; height:2rem; background:var(--pfm-subtle); border-radius:.375rem;
display:flex; align-items:center; justify-content:center;
color:#6b7280; font-size:1rem; }
.pfm-card-name { font-weight:600; color:#111827; font-size:.875rem; margin:.375rem 0 .125rem; }
.pfm-card-meta { font-size:.75rem; color:#6b7280; line-height:1.5; }
color:var(--pfm-text-muted); font-size:1rem; }
.pfm-card-name { font-weight:600; color:var(--color-text-primary,#111827); font-size:.875rem; margin:.375rem 0 .125rem; }
.pfm-card-meta { font-size:.75rem; color:var(--pfm-text-muted); line-height:1.5; }
.pfm-card-actions { display:flex; gap:.375rem; margin-top:.625rem; }
.pfm-btn { display:inline-flex; align-items:center; gap:.25rem; padding:.375rem .75rem;
border-radius:.375rem; font-size:.8125rem; font-weight:500;
border:none; cursor:pointer; transition:background .15s; }
.pfm-btn-primary { background:#2563eb; color:#fff; flex:1; justify-content:center; }
.pfm-btn-primary:hover { background:#1d4ed8; }
.pfm-btn:focus-visible { outline:2px solid var(--color-primary,#2563eb); outline-offset:2px; }
.pfm-btn:disabled { opacity:.5; cursor:not-allowed; }
.pfm-btn-primary { background:var(--color-primary,#2563eb); color:#fff; flex:1; justify-content:center; }
.pfm-btn-primary:hover { background:var(--color-primary-hover,#1d4ed8); }
/* Solid fills with white text: fixed shades read well on both themes. */
.pfm-btn-danger { background:#dc2626; color:#fff; }
.pfm-btn-danger:hover { background:#b91c1c; }
.pfm-btn-secondary { background:#f3f4f6; color:#374151; border:1px solid #d1d5db; }
.pfm-btn-secondary:hover { background:#e5e7eb; }
.pfm-btn-secondary { background:var(--pfm-subtle); color:var(--color-text-secondary,#374151); border:1px solid var(--pfm-border-strong); }
.pfm-btn-secondary:hover { background:var(--pfm-subtle-hover); }
.pfm-btn-sm { padding:.25rem .5rem; font-size:.75rem; }
.pfm-btn-create { background:#059669; color:#fff; }
.pfm-btn-create:hover { background:#047857; }
.pfm-toggle-wrap { display:flex; align-items:center; gap:.375rem; }
.pfm-toggle-label { font-size:.75rem; color:#6b7280; }
.pfm-toggle-label { font-size:.75rem; color:var(--pfm-text-muted); }
.pfm-toggle-cb { position:relative; display:inline-block; width:2rem; height:1.125rem; }
.pfm-toggle-cb input { opacity:0; width:0; height:0; }
.pfm-toggle-slider { position:absolute; inset:0; background:#d1d5db; border-radius:9999px;
.pfm-toggle-slider { position:absolute; inset:0; background:var(--pfm-border-strong); border-radius:9999px;
cursor:pointer; transition:background .2s; }
.pfm-toggle-slider:before { content:''; position:absolute; height:.75rem; width:.75rem;
left:.1875rem; bottom:.1875rem; background:#fff;
border-radius:50%; transition:transform .2s; }
.pfm-toggle-cb input:checked + .pfm-toggle-slider { background:#10b981; }
.pfm-toggle-cb input:checked + .pfm-toggle-slider:before { transform:translateX(.875rem); }
.pfm-empty { text-align:center; padding:2rem; color:#9ca3af; }
.pfm-toggle-cb input:focus-visible + .pfm-toggle-slider { outline:2px solid var(--color-primary,#2563eb); outline-offset:2px; }
.pfm-empty { text-align:center; padding:2rem; color:var(--pfm-text-muted); }
.pfm-empty i { font-size:2rem; margin-bottom:.5rem; display:block; }
.pfm-error-text { color:var(--color-error,#dc2626); }
/* Modal */
.pfm-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5);
display:flex; align-items:flex-start; justify-content:center;
z-index:9999; padding:2rem 1rem; overflow-y:auto; }
.pfm-modal { background:#fff; border-radius:.75rem; width:100%; max-width:56rem;
.pfm-modal { background:var(--color-surface,#fff); color:var(--color-text-primary,#111827);
border-radius:.75rem; width:100%; max-width:56rem;
box-shadow:0 20px 50px rgba(0,0,0,.3); margin:auto; }
.pfm-modal:focus { outline:none; }
.pfm-modal-header { display:flex; align-items:center; justify-content:space-between;
padding:1rem 1.25rem; border-bottom:1px solid #e5e7eb; }
.pfm-modal-title { font-size:1rem; font-weight:600; color:#111827; }
padding:1rem 1.25rem; border-bottom:1px solid var(--color-border,#e5e7eb); }
.pfm-modal-title { font-size:1rem; font-weight:600; color:var(--color-text-primary,#111827); }
.pfm-modal-body { padding:1.25rem; overflow-y:auto; max-height:70vh; }
.pfm-modal-footer { display:flex; justify-content:flex-end; gap:.5rem;
padding:.875rem 1.25rem; border-top:1px solid #e5e7eb;
background:#f9fafb; border-radius:0 0 .75rem .75rem; }
padding:.875rem 1.25rem; border-top:1px solid var(--color-border,#e5e7eb);
background:var(--color-background,#f9fafb); border-radius:0 0 .75rem .75rem; }
/* Entry table */
.pfm-table-wrap { overflow-x:auto; }
.pfm-table { width:100%; border-collapse:collapse; font-size:.8125rem; }
.pfm-table th { background:#f9fafb; text-align:left; padding:.5rem .625rem;
font-weight:600; color:#374151; border-bottom:1px solid #e5e7eb;
.pfm-table th { background:var(--color-background,#f9fafb); text-align:left; padding:.5rem .625rem;
font-weight:600; color:var(--color-text-secondary,#374151); border-bottom:1px solid var(--color-border,#e5e7eb);
white-space:nowrap; position:sticky; top:0; }
.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid #f3f4f6;
.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid var(--color-border-light,#f3f4f6);
vertical-align:top; }
.pfm-table tr.today-row td { background:#fef9c3; }
.pfm-table tr.today-row td { background:var(--pfm-today); }
.pfm-table td input, .pfm-table td textarea {
width:100%; border:1px solid #d1d5db; border-radius:.25rem;
width:100%; border:1px solid var(--pfm-border-strong); border-radius:.25rem;
padding:.25rem .375rem; font-size:.8125rem; font-family:inherit;
resize:vertical; background:#fff; }
resize:vertical; background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
.pfm-table td input:focus, .pfm-table td textarea:focus {
outline:none; border-color:#3b82f6; }
outline:none; border-color:var(--color-primary,#3b82f6); box-shadow:0 0 0 2px var(--color-primary,#3b82f6); }
.pfm-day-col { width:3rem; text-align:center; font-weight:600;
color:#6b7280; white-space:nowrap; }
color:var(--pfm-text-muted); white-space:nowrap; }
.pfm-table-info { font-size:.75rem; color:var(--pfm-text-muted); margin-bottom:.375rem; }
.pfm-pagination { display:flex; align-items:center; justify-content:space-between;
margin-top:.75rem; font-size:.8125rem; color:#6b7280; }
margin-top:.75rem; font-size:.8125rem; color:var(--pfm-text-muted); }
.pfm-page-jump { display:flex; align-items:center; gap:.375rem; font-size:.8125rem; }
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid #d1d5db;
border-radius:.25rem; text-align:center; }
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid var(--pfm-border-strong);
border-radius:.25rem; text-align:center;
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
.pfm-json-ta { width:100%; font-family:monospace; font-size:.75rem; border:1px solid var(--pfm-border-strong);
border-radius:.375rem; padding:.5rem;
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
.pfm-json-err { color:var(--color-error,#dc2626); font-size:.75rem; margin-top:.25rem; }
/* Form in create modal */
.pfm-field { margin-bottom:.875rem; }
.pfm-field label { display:block; font-size:.875rem; font-weight:500;
color:#374151; margin-bottom:.25rem; }
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid #d1d5db;
border-radius:.375rem; font-size:.875rem; }
.pfm-field input:focus { outline:none; border-color:#3b82f6; }
.pfm-field-hint { font-size:.75rem; color:#9ca3af; margin-top:.2rem; }
.pfm-field-error { font-size:.75rem; color:#dc2626; margin-top:.2rem; }
color:var(--color-text-secondary,#374151); margin-bottom:.25rem; }
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid var(--pfm-border-strong);
border-radius:.375rem; font-size:.875rem;
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
.pfm-field input:focus { outline:none; border-color:var(--color-primary,#3b82f6); box-shadow:0 0 0 2px var(--color-primary,#3b82f6); }
.pfm-field-hint { font-size:.75rem; color:var(--pfm-text-muted); margin-top:.2rem; }
.pfm-field-error { font-size:.75rem; color:var(--color-error,#dc2626); margin-top:.2rem; }
/* Delete danger box */
.pfm-danger-box { background:#fef2f2; border:1px solid #fecaca;
.pfm-danger-box { background:var(--color-error-bg,#fef2f2); border:1px solid var(--pfm-danger-border);
border-radius:.5rem; padding:.875rem; font-size:.875rem;
color:#991b1b; }
color:var(--pfm-danger-text); }
@media (prefers-reduced-motion: reduce) {
.pfm-upload, .pfm-card, .pfm-btn, .pfm-toggle-slider, .pfm-toggle-slider:before { transition:none; }
}
`;
document.head.appendChild(style);
}
@@ -356,7 +389,8 @@
delBtn.dataset.pfmAction = 'delete';
delBtn.dataset.pfmField = fieldId;
delBtn.dataset.pfmFile = f.filename;
delBtn.innerHTML = '<i class="fas fa-trash"></i>'; // static
delBtn.setAttribute('aria-label', 'Delete ' + f.filename);
delBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>'; // static
actions.appendChild(delBtn);
}
card.appendChild(actions);
@@ -375,18 +409,18 @@
modal.className = 'pfm-modal';
safeSetHTML(modal, `
<div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close">
<i class="fas fa-times"></i>
<span class="pfm-modal-title" id="${escHtml(fieldId)}_modal_title"><i class="fas fa-edit mr-2" aria-hidden="true"></i>${escHtml(filename)}</span>
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close" aria-label="Close">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<div class="pfm-modal-body" id="${escHtml(fieldId)}_edit_body">
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
<div class="pfm-empty"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i>Loading…</div>
</div>
<div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
<i class="fas fa-save mr-1"></i>Save
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
<button type="button" class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
<i class="fas fa-save mr-1" aria-hidden="true"></i>Save
</button>
</div>`);
overlay.appendChild(modal);
@@ -394,11 +428,12 @@
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_modal_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_save_btn`).addEventListener('click', () => window._pfmSave(fieldId, filename));
trapModal(fieldId, modal, `${fieldId}_modal_title`);
const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
const body = document.getElementById(`${fieldId}_edit_body`);
if (!data || data.status !== 'success' || !body) {
if (body) safeSetHTML(body, '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>');
if (body) safeSetHTML(body, `<div class="pfm-empty pfm-error-text">Couldn't load ${escHtml(filename)}. Close this window and try again.</div>`);
return;
}
@@ -413,10 +448,10 @@
// Textarea path: _editData stays null; save() reads from the <textarea>
st._editData = null;
safeSetHTML(body, `
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
<textarea id="${escHtml(fieldId)}_json_ta" rows="20" class="pfm-json-ta"
aria-label="File contents (JSON)" aria-describedby="${escHtml(fieldId)}_json_err"
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`);
<div id="${escHtml(fieldId)}_json_err" class="pfm-json-err" role="alert"></div>`);
}
};
@@ -461,7 +496,7 @@
const totalPages = Math.ceil(total / perPage);
safeSetHTML(container, `
<div class="pfm-table-info" style="font-size:.75rem;color:#6b7280;margin-bottom:.375rem;">
<div class="pfm-table-info">
${total} entries total
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" style="margin-left:.5rem"
onclick="(function(){const targetPage=Math.ceil(${todayDoy}/${perPage});window._pfmTablePage('${fieldId}',targetPage);setTimeout(function(){const row=document.querySelector('tr[data-day=\\'${todayDoy}\\']');if(row)row.scrollIntoView({block:'center'});},60);})()">
@@ -579,9 +614,9 @@
modal.style.maxWidth = '28rem';
safeSetHTML(modal, `
<div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close">
<i class="fas fa-times"></i>
<span class="pfm-modal-title" id="${escHtml(fieldId)}_del_title"><i class="fas fa-trash mr-2" aria-hidden="true"></i>Delete File</span>
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close" aria-label="Close">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<div class="pfm-modal-body">
@@ -591,15 +626,18 @@
</div>
</div>
<div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
<i class="fas fa-trash mr-1"></i>Delete
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
<button type="button" class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
<i class="fas fa-trash mr-1" aria-hidden="true"></i>Delete
</button>
</div>`);
overlay.appendChild(modal);
const cancelBtn = modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`);
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
cancelBtn.addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_del_confirm`).addEventListener('click', () => window._pfmConfirmDelete(fieldId, filename));
// Destructive dialog: start on Cancel.
trapModal(fieldId, modal, `${fieldId}_del_title`, cancelBtn);
};
window._pfmConfirmDelete = async function (fieldId, filename) {
@@ -611,7 +649,7 @@
window._pfmCloseModal(fieldId);
await loadFiles(fieldId);
} else {
notify('Delete failed: ' + (result.message || ''), 'error');
notify("Couldn't delete the file: " + (result.message || 'unknown error') + '. Try again.', 'error');
}
};
@@ -626,13 +664,13 @@
modal.style.maxWidth = '32rem';
safeSetHTML(modal, `
<div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close">
<i class="fas fa-times"></i>
<span class="pfm-modal-title" id="${escHtml(fieldId)}_cre_title"><i class="fas fa-plus-circle mr-2" aria-hidden="true"></i>Create New File</span>
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close" aria-label="Close">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<div class="pfm-modal-body">
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" role="alert" style="margin-bottom:.5rem;"></div>
${fields.map(f => `
<div class="pfm-field">
<label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
@@ -643,16 +681,16 @@
</div>`).join('')}
</div>
<div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
<button class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
<i class="fas fa-plus mr-1"></i>Create
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
<button type="button" class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
<i class="fas fa-plus mr-1" aria-hidden="true"></i>Create
</button>
</div>
</div>`);
overlay.appendChild(modal);
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_create_btn`).addEventListener('click', () => window._pfmConfirmCreate(fieldId));
trapModal(fieldId, modal, `${fieldId}_cre_title`, modal.querySelector('.pfm-field input'));
};
window._pfmConfirmCreate = async function (fieldId) {
@@ -739,11 +777,28 @@
return overlay;
}
// Dialog semantics + focus trap for an open modal (utils/dialog.js).
// Every close path goes through _pfmCloseModal, which releases it.
function trapModal(fieldId, modal, titleId, initialFocus) {
if (!window.LEDDialog) return;
const st = getState(fieldId);
st._releaseDialog = window.LEDDialog.trap(modal, {
labelledBy: titleId,
initialFocus: initialFocus || null,
onEscape: () => window._pfmCloseModal(fieldId)
});
}
window._pfmCloseModal = function (fieldId) {
const st = getState(fieldId);
if (st.currentModal) { st.currentModal.remove(); st.currentModal = null; }
st._editData = null;
st._editFilename = null;
if (st._releaseDialog) {
const release = st._releaseDialog;
st._releaseDialog = null;
release();
}
};
// ─── Widget registration ──────────────────────────────────────────────────
@@ -793,7 +848,7 @@
<input type="file" id="${fieldId}_file_input" accept=".json"
style="display:none"
onchange="if(this.files[0])window._pfmUpload('${fieldId}',this.files[0]);this.value=''">
<i class="fas fa-cloud-upload-alt" style="font-size:1.5rem;color:#9ca3af;"></i>
<i class="fas fa-cloud-upload-alt pfm-upload-icon" aria-hidden="true"></i>
<p>Drag and drop or click to upload</p>
<small>${escHtml(st.uploadHint)}</small>
</div>` : ''}
@@ -49,6 +49,7 @@
saturday: 'Saturday',
sunday: 'Sunday'
};
const DAY_LABEL_MAP = new Map(Object.entries(DAY_LABELS));
// Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null;
@@ -283,17 +284,19 @@
// Render each day row
DAYS.forEach(day => {
const dayConfig = schedule.days[day];
const dayLabel = escapeHtml(DAY_LABEL_MAP.get(day) || day);
const disabled = !dayConfig.enabled;
const disabledClass = disabled ? 'bg-gray-100' : '';
html += `
<tr class="hover:bg-gray-50" id="${fieldId}_row_${day}">
<td class="px-3 py-2 whitespace-nowrap">
<span class="text-sm font-medium text-gray-900">${escapeHtml(DAY_LABELS[day])}</span>
<span class="text-sm font-medium text-gray-900">${dayLabel}</span>
</td>
<td class="px-3 py-2 whitespace-nowrap">
<input type="checkbox"
id="${fieldId}_${day}_enabled"
aria-label="${dayLabel} enabled"
${dayConfig.enabled ? 'checked' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayEnabledChange('${fieldId}', '${day}', this.checked)"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
@@ -301,6 +304,7 @@
<td class="px-3 py-2 whitespace-nowrap">
<input type="time"
id="${fieldId}_${day}_start"
aria-label="${dayLabel} start time"
value="${escapeHtml(dayConfig.start_time)}"
${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
@@ -309,6 +313,7 @@
<td class="px-3 py-2 whitespace-nowrap">
<input type="time"
id="${fieldId}_${day}_end"
aria-label="${dayLabel} end time"
value="${escapeHtml(dayConfig.end_time)}"
${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
@@ -121,8 +121,8 @@
id="${fieldId}_clear"
onclick="window.LEDMatrixWidgets.getHandlers('text-input').onClear('${fieldId}')"
class="inline-flex items-center px-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
title="Clear">
<i class="fas fa-times"></i>
title="Clear" aria-label="Clear">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
`;
}
@@ -90,7 +90,7 @@
onchange="window.LEDMatrixWidgets.getHandlers('time-picker').onChange('${fieldId}')"
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<i class="fas fa-clock text-gray-400"></i>
<i class="fas fa-clock text-gray-400" aria-hidden="true"></i>
</div>
</div>
`;
@@ -101,8 +101,8 @@
id="${fieldId}_clear"
onclick="window.LEDMatrixWidgets.getHandlers('time-picker').onClear('${fieldId}')"
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
title="Clear">
<i class="fas fa-times"></i>
title="Clear" aria-label="Clear time">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
`;
}
+147 -39
View File
@@ -1044,20 +1044,11 @@ window.initPluginsPage = function() {
setupInstalledFilterListeners();
setupStoreFilterListeners();
if (closeOnDemandModalBtn) {
closeOnDemandModalBtn.replaceWith(closeOnDemandModalBtn.cloneNode(true));
document.getElementById('close-on-demand-modal').addEventListener('click', closeOnDemandModal);
}
if (cancelOnDemandBtn) {
cancelOnDemandBtn.replaceWith(cancelOnDemandBtn.cloneNode(true));
document.getElementById('cancel-on-demand').addEventListener('click', closeOnDemandModal);
}
if (onDemandForm) {
onDemandForm.replaceWith(onDemandForm.cloneNode(true));
document.getElementById('on-demand-form').addEventListener('submit', submitOnDemandRequest);
}
if (onDemandModal) {
onDemandModal.onclick = closeOnDemandModalOnBackdrop;
// The on-demand modal lives in base.html and survives tab swaps, so bind it
// through the one guarded initializer instead of cloning its controls here
// (cloning copied data-initialized and left two code paths owning listeners).
if (closeOnDemandModalBtn || cancelOnDemandBtn || onDemandForm || onDemandModal) {
initializeOnDemandModal();
}
// Load on-demand status silently (false = don't show notification)
@@ -1385,7 +1376,7 @@ function getInstalledFilter() {
el: 'installed-search',
clearEl: 'installed-search-clear',
fields: ['name', 'id', 'description', 'author', 'category', 'tags'],
debounceMs: 200,
debounceMs: 150,
},
sort: {
el: 'installed-sort',
@@ -1551,7 +1542,7 @@ function renderInstalledCards(plugins, total) {
return JSON.stringify(text || '');
};
container.innerHTML = plugins.map(plugin => {
setGridHtmlIfChanged(container, plugins.map(plugin => {
// Convert enabled to boolean for consistent rendering
const enabledBool = Boolean(plugin.enabled);
@@ -1582,15 +1573,19 @@ function renderInstalledCards(plugins, total) {
<!-- Toggle Switch in Top Right -->
<div class="flex-shrink-0 ml-4">
<label class="relative inline-flex items-center cursor-pointer group">
<!-- input.peer must stay immediately before the visible pill:
peer-focus:* only reaches a following sibling. -->
<input type="checkbox"
class="sr-only peer"
role="switch"
aria-label="Enable ${escapeAttr(plugin.name || plugin.id)}"
id="toggle-${escapedPluginId}"
${enabledBool ? 'checked' : ''}
data-plugin-id="${escapedPluginId}"
data-action="toggle">
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg border-2 transition-all duration-200 ${enabledBool ? 'bg-green-50 border-green-500' : 'bg-gray-50 border-gray-300'} hover:shadow-md group-hover:scale-105">
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg border-2 transition-all duration-200 ${enabledBool ? 'bg-green-50 border-green-500' : 'bg-gray-50 border-gray-300'} hover:shadow-md group-hover:scale-105 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300" aria-hidden="true">
<!-- Toggle Switch -->
<div class="relative w-14 h-7 ${enabledBool ? 'bg-green-500' : 'bg-gray-300'} peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:bg-green-500 transition-colors duration-200 ease-in-out shadow-inner">
<div class="relative w-14 h-7 ${enabledBool ? 'bg-green-500' : 'bg-gray-300'} rounded-full transition-colors duration-200 ease-in-out shadow-inner">
<div class="absolute top-[3px] left-[3px] bg-white ${enabledBool ? 'translate-x-full' : ''} border-2 ${enabledBool ? 'border-green-500' : 'border-gray-400'} rounded-full h-5 w-5 transition-all duration-200 ease-in-out shadow-sm flex items-center justify-center">
${enabledBool ? '<i class="fas fa-check text-green-600 text-xs"></i>' : '<i class="fas fa-times text-gray-400 text-xs"></i>'}
</div>
@@ -1638,9 +1633,30 @@ function renderInstalledCards(plugins, total) {
</div>
</div>
`;
}).join('');
}).join(''));
}
// Filter/search changes re-run the card renderers. When the markup they
// produce is identical to what is already in the grid (same visible set, same
// state), skip the innerHTML rebuild: it saves layout work on small Pis and
// keeps focus and any typed branch names in place. The cache lives on the
// element, so an HTMX swap that replaces the grid starts fresh, and it is
// tied to the first node it produced, so any other write to the grid (empty
// state, loading skeleton, error message) invalidates it automatically.
function setGridHtmlIfChanged(container, html) {
if (container._lastRenderedHtml === html &&
container._lastRenderedFirst &&
container._lastRenderedFirst === container.firstElementChild) {
return false;
}
container.innerHTML = html;
container._lastRenderedHtml = html;
container._lastRenderedFirst = container.firstElementChild;
return true;
}
// Shared with the Starlark section, which lives in a separate IIFE below.
window.__pmSetGridHtmlIfChanged = setGridHtmlIfChanged;
// Set up event delegation for plugin action buttons (fallback if onclick doesn't work)
// Only set up once per container to avoid redundant listeners, which is what
// makes it safe to rebuild the grid's innerHTML on every keystroke.
@@ -1875,11 +1891,73 @@ function loadOnDemandStatus(fromRefreshButton = false) {
});
}
// The 15s on-demand status poll only runs while someone can see it: the page
// is visible and the Plugin Manager tab is the active one. On a Pi Zero 2 W a
// forgotten background tab should cost nothing. When it becomes visible again
// it refreshes immediately, then resumes the interval.
const ON_DEMAND_POLL_MS = 15000;
function isPluginManagerTabVisible() {
if (document.hidden) return false;
try {
if (window.Alpine && typeof window.Alpine.$data === 'function') {
const data = window.Alpine.$data(document.body);
if (data && typeof data.activeTab === 'string') {
return data.activeTab === 'plugins';
}
}
} catch (e) {
// Alpine not initialised on body yet; fall through to the DOM check.
}
const content = document.getElementById('plugins-content');
return !!(content && content.offsetParent !== null);
}
function syncOnDemandStatusPolling() {
const shouldRun = isPluginManagerTabVisible();
if (shouldRun && !onDemandStatusInterval) {
loadOnDemandStatus(false);
onDemandStatusInterval = setInterval(() => {
if (!isPluginManagerTabVisible()) {
clearInterval(onDemandStatusInterval);
onDemandStatusInterval = null;
return;
}
loadOnDemandStatus(false);
}, ON_DEMAND_POLL_MS);
} else if (!shouldRun && onDemandStatusInterval) {
clearInterval(onDemandStatusInterval);
onDemandStatusInterval = null;
}
}
function startOnDemandStatusPolling() {
if (onDemandStatusInterval) {
clearInterval(onDemandStatusInterval);
onDemandStatusInterval = null;
}
// The caller has just loaded status, so start the interval without a
// second immediate fetch; later resumes go through syncOnDemandStatusPolling.
if (isPluginManagerTabVisible()) {
onDemandStatusInterval = setInterval(() => {
if (!isPluginManagerTabVisible()) {
clearInterval(onDemandStatusInterval);
onDemandStatusInterval = null;
return;
}
loadOnDemandStatus(false);
}, ON_DEMAND_POLL_MS);
}
if (!window.__onDemandPollingListenersBound) {
window.__onDemandPollingListenersBound = true;
document.addEventListener('visibilitychange', syncOnDemandStatusPolling);
// Tab switches: nav clicks change Alpine's activeTab, and HTMX swaps
// tab content in. Defer so Alpine has applied x-show first.
document.addEventListener('click', () => setTimeout(syncOnDemandStatusPolling, 0));
document.addEventListener('htmx:afterSettle', syncOnDemandStatusPolling);
window.addEventListener('popstate', () => setTimeout(syncOnDemandStatusPolling, 0));
}
onDemandStatusInterval = setInterval(() => loadOnDemandStatus(false), 15000);
}
window.loadOnDemandStatus = loadOnDemandStatus;
@@ -2105,6 +2183,17 @@ window.__openOnDemandModalImpl = function(pluginId) {
modalContent.style.overflowY = 'auto';
}
// Dialog semantics, focus trap, Escape, and focus return. Opening again
// while already open (e.g. a double click) keeps the existing trap so
// focus still returns to the control that first opened it.
if (modalContent && window.LEDDialog && !onDemandDialogRelease) {
onDemandDialogRelease = window.LEDDialog.trap(modalContent, {
labelledBy: 'on-demand-modal-title',
initialFocus: modeSelect,
onEscape: closeOnDemandModal
});
}
// Scroll to top of page to ensure modal is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
@@ -2132,12 +2221,21 @@ window.__openOnDemandModalImpl = function(pluginId) {
// Replace the stub with the real implementation
window.openOnDemandModal = window.__openOnDemandModalImpl;
// Release handle for the on-demand modal's focus trap (window.LEDDialog).
// Every close path funnels through closeOnDemandModal, which releases it once.
let onDemandDialogRelease = null;
function closeOnDemandModal() {
const modal = document.getElementById('on-demand-modal');
if (modal) {
modal.style.display = 'none';
}
currentOnDemandPluginId = null;
if (onDemandDialogRelease) {
const release = onDemandDialogRelease;
onDemandDialogRelease = null;
release();
}
}
function submitOnDemandRequest(event) {
@@ -2366,7 +2464,7 @@ function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, inde
if (logoValue.path) {
html += `
<div class="mt-2 flex items-center space-x-2 uploaded-image-container">
<img src="/${escapeAttribute(logoValue.path.replace(/^\/+/, ''))}" alt="Logo" class="w-16 h-16 object-cover rounded border">
<img src="/${escapeAttribute(logoValue.path.replace(/^\/+/, ''))}" alt="Logo" loading="lazy" decoding="async" class="w-16 h-16 object-cover rounded border">
<button type="button"
onclick="removeArrayObjectFile('${escapeAttribute(fieldId)}', ${index}, '${escapeAttribute(propKey)}')"
class="text-red-600 hover:text-red-800">
@@ -2817,7 +2915,7 @@ window.handleArrayObjectFileUpload = async function(event, fieldId, itemIndex, p
// Get current item index from data-index attribute for remove button
const currentItemIndex = itemEl.getAttribute('data-index') || itemIndex;
imageContainer.innerHTML = `
<img src="/${escapedPath}" alt="Logo" class="w-16 h-16 object-cover rounded border">
<img src="/${escapedPath}" alt="Logo" loading="lazy" decoding="async" class="w-16 h-16 object-cover rounded border">
<button type="button"
onclick="removeArrayObjectFile('${escapedFieldId}', ${currentItemIndex}, '${escapedPropKey}')"
class="text-red-600 hover:text-red-800">
@@ -3666,7 +3764,7 @@ function getStoreFilter() {
search: {
el: 'plugin-search',
fields: ['name', 'description', 'author', 'id', 'category', 'tags'],
debounceMs: 300,
debounceMs: 150,
},
sort: {
el: 'store-sort',
@@ -3781,7 +3879,7 @@ function renderPluginStore(plugins) {
return JSON.stringify(text || '');
};
container.innerHTML = plugins.map(plugin => {
setGridHtmlIfChanged(container, plugins.map(plugin => {
const installed = isStorePluginInstalled(plugin);
return `
<div class="plugin-card">
@@ -3830,7 +3928,7 @@ function renderPluginStore(plugins) {
</div>
</div>
</div>`;
}).join('');
}).join(''));
}
// Expose functions to window for onclick handlers
@@ -4004,8 +4102,8 @@ function renderSavedRepositories(repositories) {
</div>
<p class="text-xs text-gray-500 truncate" title="${escapeAttribute(repoUrl)}">${escapeHtml(repoUrl)}</p>
</div>
<button onclick='if(window.removeSavedRepository){window.removeSavedRepository(${escapeJs(repoUrl)})}else{console.error("removeSavedRepository not available")}' class="ml-2 text-red-600 hover:text-red-800 text-xs px-2 py-1" title="Remove repository">
<i class="fas fa-trash"></i>
<button onclick='if(window.removeSavedRepository){window.removeSavedRepository(${escapeJs(repoUrl)})}else{console.error("removeSavedRepository not available")}' class="ml-2 text-red-600 hover:text-red-800 text-xs px-2 py-1" title="Remove repository" aria-label="Remove saved repository ${escapeAttribute(repoName)}">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
`;
@@ -4603,14 +4701,17 @@ window.toggleGithubTokenVisibility = function() {
const icon = document.getElementById('github-token-icon');
if (input && icon) {
const btn = icon.closest('button');
if (input.type === 'password') {
input.type = 'text';
icon.classList.remove('fa-eye');
icon.classList.add('fa-eye-slash');
if (btn) btn.setAttribute('aria-label', 'Hide token');
} else {
input.type = 'password';
icon.classList.remove('fa-eye-slash');
icon.classList.add('fa-eye');
if (btn) btn.setAttribute('aria-label', 'Show token');
}
}
}
@@ -4989,6 +5090,7 @@ window.updateImageList = function(fieldId, images) {
<div class="flex items-center space-x-3 flex-1">
<img src="/${img.path || ''}"
alt="${img.filename || ''}"
loading="lazy" decoding="async"
class="w-16 h-16 object-cover rounded"
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
<div style="display:none;" class="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
@@ -5006,14 +5108,16 @@ window.updateImageList = function(fieldId, images) {
<button type="button"
onclick="window.openImageSchedule('${fieldId}', '${img.id}', ${idx})"
class="text-blue-600 hover:text-blue-800 p-2"
title="Schedule this image">
<i class="fas fa-calendar-alt"></i>
title="Schedule this image"
aria-label="Schedule image ${escapeAttribute(img.original_filename || img.filename || '')}">
<i class="fas fa-calendar-alt" aria-hidden="true"></i>
</button>
<button type="button"
onclick="window.deleteUploadedImage('${fieldId}', '${img.id}', '${pluginId}')"
class="text-red-600 hover:text-red-800 p-2"
title="Delete image">
<i class="fas fa-trash"></i>
title="Delete image"
aria-label="Delete image ${escapeAttribute(img.original_filename || img.filename || '')}">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -5137,7 +5241,7 @@ window.openImageSchedule = function(fieldId, imageId, imageIdx) {
<!-- Schedule Mode -->
<div id="schedule_options_${imageId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
<label for="schedule_mode_${imageId}" class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
<select id="schedule_mode_${imageId}"
onchange="window.updateImageScheduleMode('${fieldId}', '${imageId}', ${imageIdx})"
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
@@ -5150,7 +5254,7 @@ window.openImageSchedule = function(fieldId, imageId, imageIdx) {
<!-- Time Range Mode -->
<div id="time_range_${imageId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
<label for="schedule_start_${imageId}" class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
<input type="time"
id="schedule_start_${imageId}"
value="${schedule.start_time || '08:00'}"
@@ -5158,7 +5262,7 @@ window.openImageSchedule = function(fieldId, imageId, imageIdx) {
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
<label for="schedule_end_${imageId}" class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
<input type="time"
id="schedule_end_${imageId}"
value="${schedule.end_time || '18:00'}"
@@ -5188,12 +5292,14 @@ window.openImageSchedule = function(fieldId, imageId, imageIdx) {
<div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${imageId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
<input type="time"
id="day_${day}_start_${imageId}"
aria-label="${day} start time"
value="${dayConfig.start_time || '08:00'}"
onchange="updateImageScheduleDay('${fieldId}', '${imageId}', ${imageIdx}, '${day}')"
class="text-xs px-2 py-1 border border-gray-300 rounded"
${!dayConfig.enabled ? 'disabled' : ''}>
<input type="time"
id="day_${day}_end_${imageId}"
aria-label="${day} end time"
value="${dayConfig.end_time || '18:00'}"
onchange="updateImageScheduleDay('${fieldId}', '${imageId}', ${imageIdx}, '${day}')"
class="text-xs px-2 py-1 border border-gray-300 rounded"
@@ -5375,12 +5481,12 @@ if (typeof window !== 'undefined') {
if (propSchema.type === 'boolean') {
const checked = propValue ? 'checked' : '';
// No name attribute - rely solely on _data field to prevent key leakage
itemHtml += `<input type="checkbox" data-prop-key="${propKey}" ${checked} class="h-4 w-4 text-blue-600" onchange="window.updateArrayObjectData('${fieldId}')">`;
itemHtml += `<input type="checkbox" data-prop-key="${propKey}" aria-label="${escapeHtml(propLabel)}" ${checked} class="h-4 w-4 text-blue-600" onchange="window.updateArrayObjectData('${fieldId}')">`;
} else {
// Escape HTML to prevent XSS
// No name attribute - rely solely on _data field to prevent key leakage
const escapedValue = typeof propValue === 'string' ? propValue.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;') : (propValue || '');
itemHtml += `<input type="text" data-prop-key="${propKey}" value="${escapedValue}" class="block w-full px-3 py-2 border border-gray-300 rounded-md" onchange="window.updateArrayObjectData('${fieldId}')">`;
itemHtml += `<input type="text" data-prop-key="${propKey}" aria-label="${escapeHtml(propLabel)}" value="${escapedValue}" class="block w-full px-3 py-2 border border-gray-300 rounded-md" onchange="window.updateArrayObjectData('${fieldId}')">`;
}
itemHtml += `</div>`;
});
@@ -5823,7 +5929,7 @@ document.addEventListener('htmx:afterSettle', function() {
search: {
el: 'starlark-search',
fields: ['name', 'summary', 'desc', 'author', 'id', 'category'],
debounceMs: 300,
debounceMs: 150,
},
sort: {
el: 'starlark-sort',
@@ -5902,7 +6008,9 @@ document.addEventListener('htmx:afterSettle', function() {
return;
}
grid.innerHTML = apps.map(app => {
const setGridHtmlIfChanged = window.__pmSetGridHtmlIfChanged ||
((el, html) => { el.innerHTML = html; return true; });
setGridHtmlIfChanged(grid, apps.map(app => {
const installed = isStarlarkInstalled(app.id);
return `
<div class="plugin-card" data-app-id="${escapeHtml(app.id)}">
@@ -5932,7 +6040,7 @@ document.addEventListener('htmx:afterSettle', function() {
</button>
</div>
</div>`;
}).join('');
}).join(''));
// Add delegated event listener only once (prevent duplicate handlers)
if (!grid.dataset.starlarkHandlerAttached) {
+84 -99
View File
@@ -204,7 +204,7 @@
clearTimeout(timeout);
console.error('Failed to load plugins:', err);
content.removeAttribute('data-loaded');
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load Plugin Manager. Please refresh the page.</p></div>';
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load the Plugin Manager. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadPluginsDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
});
}
}
@@ -229,6 +229,11 @@
after DOM parse but before DOMContentLoaded, which is exactly what
Alpine wants — so no deferLoadingAlpine gymnastics are needed.
The inline rescue below only fires if the local file is missing. -->
<!-- App shell (full app(), Alpine stores, SSE streams). Deferred so it
doesn't block rendering, and placed before Alpine: deferred scripts run
in document order, and its alpine:init listener must be registered
before Alpine starts. -->
<script defer src="{{ url_for('static', filename='v3/js/app-shell.js') }}"></script>
<script defer src="{{ url_for('static', filename='v3/js/alpinejs.min.js') }}"></script>
<script>
// Rescue: if the local Alpine didn't load for any reason, pull the CDN
@@ -238,7 +243,7 @@
if (typeof window.Alpine === 'undefined') {
console.warn('[Alpine] Local file failed to load, falling back to CDN');
const s = document.createElement('script');
s.src = 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js';
s.src = 'https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js';
document.head.appendChild(s);
}
});
@@ -291,11 +296,12 @@
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
</head>
<body x-data="app()" class="bg-gray-50 min-h-screen">
<a href="#tab-content" class="skip-link">Skip to main content</a>
<!-- Header -->
<header class="bg-white shadow-md border-b border-gray-200">
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16" style="max-width: 100%;">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<div class="flex items-center min-w-0">
<!-- Mobile nav toggle (hamburger) — hidden at md and up -->
<button type="button"
class="theme-toggle-btn p-2 rounded-md mr-2 md:hidden"
@@ -307,14 +313,13 @@
title="Menu">
<i class="fas fa-bars" aria-hidden="true"></i>
</button>
<h1 class="text-xl font-bold text-gray-900">
<i class="fas fa-tv text-blue-600 mr-2"></i>
LED Matrix Control
<h1 class="text-lg sm:text-xl font-bold text-gray-900 truncate">
<i class="fas fa-tv text-blue-600 mr-2" aria-hidden="true"></i>LED Matrix Control
</h1>
</div>
<!-- Connection status and theme toggle -->
<div id="header-widgets" class="flex items-center space-x-4">
<div id="header-widgets" class="flex items-center flex-shrink-0 ml-2 space-x-4">
<!-- Global settings search -->
<div class="relative hidden sm:block" id="settings-search-wrap">
<input id="settings-search"
@@ -353,20 +358,20 @@
<!-- System stats (populated via SSE) -->
<div id="system-stats" class="hidden lg:flex items-center space-x-4 text-sm text-gray-600 xl:space-x-6 2xl:space-x-8">
<span id="cpu-stat" class="flex items-center space-x-1">
<i class="fas fa-microchip"></i>
<span id="cpu-stat" class="flex items-center space-x-1" title="CPU usage">
<i class="fas fa-microchip" aria-hidden="true"></i>
<span>--%</span>
</span>
<span id="memory-stat" class="flex items-center space-x-1">
<i class="fas fa-memory"></i>
<span id="memory-stat" class="flex items-center space-x-1" title="Memory usage">
<i class="fas fa-memory" aria-hidden="true"></i>
<span>--%</span>
</span>
<span id="temp-stat" class="flex items-center space-x-1">
<i class="fas fa-thermometer-half"></i>
<span id="temp-stat" class="flex items-center space-x-1" title="CPU temperature">
<i class="fas fa-thermometer-half" aria-hidden="true"></i>
<span>--°C</span>
</span>
<span id="power-stat" class="hidden items-center space-x-1" title="">
<i class="fas fa-bolt"></i>
<i class="fas fa-bolt" aria-hidden="true"></i>
<span>Power</span>
</span>
</div>
@@ -381,7 +386,7 @@
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<i class="fas fa-arrow-circle-up text-lg"></i>
<i class="fas fa-arrow-circle-up text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium" id="update-banner-text"
aria-live="polite" aria-atomic="true">
A new LEDMatrix update is available
@@ -391,12 +396,12 @@
<button onclick="applyUpdate()" id="update-banner-btn"
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
update-banner-action transition-colors duration-150">
<i class="fas fa-download mr-1"></i> Update Now
<i class="fas fa-download mr-1" aria-hidden="true"></i> Update Now
</button>
<button type="button" onclick="dismissUpdateBanner()"
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
title="Dismiss" aria-label="Dismiss update">
<i class="fas fa-times text-sm"></i>
<i class="fas fa-times text-sm" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -412,7 +417,7 @@
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<i class="fas fa-rotate text-lg"></i>
<i class="fas fa-rotate text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium" aria-live="polite"
id="restart-pending-text">
Configuration saved &mdash; restart the display to apply the changes
@@ -422,12 +427,12 @@
<button onclick="window.restartPendingNow()" id="restart-pending-btn"
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
update-banner-action transition-colors duration-150">
<i class="fas fa-power-off mr-1"></i> Restart Now
<i class="fas fa-power-off mr-1" aria-hidden="true"></i> Restart Now
</button>
<button type="button" onclick="window.dismissRestartPending()"
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
title="Dismiss" aria-label="Dismiss restart reminder">
<i class="fas fa-times text-sm"></i>
<i class="fas fa-times text-sm" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -440,7 +445,7 @@
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<i class="fas fa-exclamation-triangle text-lg"></i>
<i class="fas fa-exclamation-triangle text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium" id="power-warning-banner-text"
aria-live="polite" aria-atomic="true">
A power/thermal issue detected right now — the display may flicker or degrade. Check your power supply and cooling.
@@ -449,7 +454,7 @@
<button type="button" onclick="dismissPowerWarningBanner()"
class="power-warning-banner-dismiss rounded p-1 transition-colors duration-150"
title="Dismiss" aria-label="Dismiss power warning">
<i class="fas fa-times text-sm"></i>
<i class="fas fa-times text-sm" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -471,90 +476,90 @@
<div id="drawer-widgets"></div>
<!-- Navigation tabs -->
<nav class="mb-8">
<div class="mb-8">
<!-- First row - System tabs -->
<div class="border-b border-gray-200 mb-4">
<nav class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<nav aria-label="System settings" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'overview'"
:class="activeTab === 'overview' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-tachometer-alt"></i>Overview
<i class="fas fa-tachometer-alt" aria-hidden="true"></i>Overview
</button>
<button @click="activeTab = 'general'"
:class="activeTab === 'general' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-sliders-h"></i>General
<i class="fas fa-sliders-h" aria-hidden="true"></i>General
</button>
<button @click="activeTab = 'wifi'"
:class="activeTab === 'wifi' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-wifi"></i>WiFi
<i class="fas fa-wifi" aria-hidden="true"></i>WiFi
</button>
<button @click="activeTab = 'schedule'"
:class="activeTab === 'schedule' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-clock"></i>Schedule
<i class="fas fa-clock" aria-hidden="true"></i>Schedule
</button>
<button @click="activeTab = 'display'"
:class="activeTab === 'display' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-desktop"></i>Display
<i class="fas fa-desktop" aria-hidden="true"></i>Display
</button>
<button @click="activeTab = 'durations'"
:class="activeTab === 'durations' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-rotate"></i>Rotation
<i class="fas fa-rotate" aria-hidden="true"></i>Rotation
</button>
<button @click="activeTab = 'config-editor'"
:class="activeTab === 'config-editor' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-file-code"></i>Config Editor
<i class="fas fa-file-code" aria-hidden="true"></i>Config Editor
</button>
<button @click="activeTab = 'backup-restore'"
:class="activeTab === 'backup-restore' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-save"></i>Backup &amp; Restore
<i class="fas fa-save" aria-hidden="true"></i>Backup &amp; Restore
</button>
<button @click="activeTab = 'fonts'"
:class="activeTab === 'fonts' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-font"></i>Fonts
<i class="fas fa-font" aria-hidden="true"></i>Fonts
</button>
<button @click="activeTab = 'logs'"
:class="activeTab === 'logs' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-file-alt"></i>Logs
<i class="fas fa-file-alt" aria-hidden="true"></i>Logs
</button>
<button @click="activeTab = 'cache'"
:class="activeTab === 'cache' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-database"></i>Cache
<i class="fas fa-database" aria-hidden="true"></i>Cache
</button>
<button @click="activeTab = 'operation-history'"
:class="activeTab === 'operation-history' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-history"></i>Operation History
<i class="fas fa-history" aria-hidden="true"></i>Operation History
</button>
<button @click="activeTab = 'tools'"
:class="activeTab === 'tools' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-tools"></i>Tools
<i class="fas fa-tools" aria-hidden="true"></i>Tools
</button>
</nav>
</div>
<!-- Second row - Plugin tabs (populated dynamically) -->
<div id="plugin-tabs-row" class="border-b border-gray-200">
<nav class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<nav aria-label="Plugins" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPluginsDirect()); }"
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-plug"></i>Plugin Manager
<i class="fas fa-plug" aria-hidden="true"></i>Plugin Manager
</button>
<!-- Installed plugin tabs will be added here dynamically -->
</nav>
</div>
</nav>
</div>
</div> <!-- /#site-nav -->
<!-- Floating mini display preview: available on every tab except
@@ -563,16 +568,16 @@
persists in localStorage. -->
<div id="floating-preview" class="floating-preview" style="display:none">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-xs text-gray-300"><i class="fas fa-tv mr-1"></i>Live Preview</span>
<span class="text-xs text-gray-300"><i class="fas fa-tv mr-1" aria-hidden="true"></i>Live Preview</span>
<span class="flex items-center">
<button type="button" onclick="window.cycleFloatingPreviewSize()"
class="text-gray-400 hover:text-white ml-2" aria-label="Cycle preview size"
title="Cycle preview size (drag the corner to resize on desktop)">
<i class="fas fa-up-right-and-down-left-from-center text-xs"></i>
<i class="fas fa-up-right-and-down-left-from-center text-xs" aria-hidden="true"></i>
</button>
<button type="button" onclick="window.toggleFloatingPreview(false)"
class="text-gray-400 hover:text-white ml-2" aria-label="Close live preview">
<i class="fas fa-times text-xs"></i>
<i class="fas fa-times text-xs" aria-hidden="true"></i>
</button>
</span>
</div>
@@ -582,12 +587,12 @@
<button id="floating-preview-toggle" type="button" onclick="window.toggleFloatingPreview(true)"
class="floating-preview-toggle" style="display:none"
title="Show live display preview" aria-label="Show live display preview">
<i class="fas fa-tv"></i>
<i class="fas fa-tv" aria-hidden="true"></i>
</button>
<!-- Tab content -->
<div id="tab-content" class="space-y-6">
<div id="tab-content" class="space-y-6" tabindex="-1">
<!-- Overview tab -->
<div x-show="activeTab === 'overview'" x-transition>
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
@@ -606,7 +611,10 @@
const overviewContent = document.getElementById('overview-content');
if (overviewContent && !overviewContent.hasAttribute('data-loaded')) {
fetch('/v3/partials/overview')
.then(response => response.text())
.then(response => {
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
return response.text();
})
.then(html => {
overviewContent.innerHTML = html;
overviewContent.setAttribute('data-loaded', 'true');
@@ -620,7 +628,7 @@
})
.catch(err => {
console.error('Failed to load overview content:', err);
overviewContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load overview page. Please refresh the page.</p></div>';
overviewContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load the Overview. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadOverviewDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
});
}
}
@@ -698,7 +706,10 @@
const wifiContent = document.getElementById('wifi-content');
if (wifiContent && !wifiContent.hasAttribute('data-loaded')) {
fetch('/v3/partials/wifi')
.then(response => response.text())
.then(response => {
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
return response.text();
})
.then(html => {
wifiContent.innerHTML = html;
wifiContent.setAttribute('data-loaded', 'true');
@@ -709,7 +720,7 @@
})
.catch(err => {
console.error('Failed to load WiFi content:', err);
wifiContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load WiFi setup page. Please refresh the page.</p></div>';
wifiContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load WiFi setup. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadWifiDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
});
}
}
@@ -952,9 +963,6 @@
<!-- Notifications -->
<div id="notifications" class="fixed top-4 right-4 z-50 space-y-2"></div>
<!-- SSE connection for real-time updates -->
<script src="{{ url_for('static', filename='v3/js/app-shell.js') }}"></script>
<!-- Custom v3 JavaScript -->
<script src="{{ url_for('static', filename='v3/app.js') }}" defer></script>
@@ -964,6 +972,7 @@
<!-- Modular Plugin Management JavaScript -->
<!-- Load utilities first -->
<script src="{{ url_for('static', filename='v3/js/utils/dialog.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script>
<!-- Load core API client first (used by other modules) -->
@@ -979,43 +988,10 @@
<!-- Load config utilities -->
<script src="{{ url_for('static', filename='v3/js/config/diff_viewer.js') }}" defer></script>
<!-- Widget Registry System -->
<script src="{{ url_for('static', filename='v3/js/widgets/registry.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/base-widget.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/notification.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-order-list.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/file-upload.js') }}?v=20260307" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/checkbox-group.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/custom-feeds.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/array-table.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/google-calendar-picker.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/google-oauth.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/day-selector.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/time-range.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/time-picker.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/file-upload-single.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-file-manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/schedule-picker.js') }}" defer></script>
<!-- Basic input widgets -->
<script src="{{ url_for('static', filename='v3/js/widgets/text-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/number-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/textarea.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/select-dropdown.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/font-selector.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/style-editor.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/toggle-switch.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/radio-group.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/date-picker.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/slider.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/color-picker.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/email-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/url-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/password-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
<!-- Reusable JSON file manager widget (used by of-the-day and others via x-widget: json-file-manager) -->
<script src="{{ url_for('static', filename='v3/js/widgets/json-file-manager.js') }}" defer></script>
<!-- Widget Registry System: all widget scripts in one request (see
web_interface/widget_bundle.py). The individual files stay served at
their own URLs for plugin-loader.js and debugging. -->
<script src="{{ widgets_bundle_url() }}" defer></script>
<!-- plugins_manager.js: loaded LAST on purpose — it defines the live
window.* implementations for several plugin actions (installPlugin,
@@ -1030,17 +1006,17 @@
<!-- On-Demand Modal (moved here from plugins.html so it's always available) -->
<div id="on-demand-modal" class="fixed inset-0 modal-backdrop flex items-center justify-center z-50" style="display: none;">
<div class="modal-content p-6 w-full max-w-md bg-white rounded-lg shadow-lg">
<div class="modal-content p-6 w-full max-w-md bg-white rounded-lg shadow-lg" role="dialog" aria-modal="true" aria-labelledby="on-demand-modal-title">
<div class="flex justify-between items-center mb-4">
<h3 id="on-demand-modal-title" class="text-lg font-semibold">Run Plugin On-Demand</h3>
<button id="close-on-demand-modal" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
<h2 id="on-demand-modal-title" class="text-lg font-semibold">Run Plugin On-Demand</h2>
<button type="button" id="close-on-demand-modal" class="text-gray-500 hover:text-gray-700 p-2 -m-2 rounded-md" aria-label="Close">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<!-- Service Status Alert -->
<div id="on-demand-service-warning" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div class="flex items-start">
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2"></i>
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2" aria-hidden="true"></i>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-800">Display service is not running</p>
<p class="text-xs text-yellow-700 mt-1">
@@ -1148,8 +1124,8 @@
window.applyUpdate = function() {
var btn = document.getElementById('update-banner-btn');
var originalHTML = '<i class="fas fa-download mr-1"></i> Update Now';
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Updating...';
var originalHTML = '<i class="fas fa-download mr-1" aria-hidden="true"></i> Update Now';
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Updating...';
btn.disabled = true;
fetch('/api/v3/system/action', {
method: 'POST',
@@ -1191,9 +1167,18 @@
});
};
// Initial check shortly after page load, then periodic
setTimeout(checkForUpdate, 2000);
setInterval(checkForUpdate, CHECK_INTERVAL);
// Initial check shortly after page load, then every CHECK_INTERVAL while
// the page is visible. A hidden tab makes no requests and catches up as
// soon as it is shown again.
var lastCheck = 0;
function checkIfDue() {
if (document.hidden || Date.now() - lastCheck < CHECK_INTERVAL) return;
lastCheck = Date.now();
checkForUpdate();
}
setTimeout(checkIfDue, 2000);
setInterval(checkIfDue, 60 * 1000);
document.addEventListener('visibilitychange', checkIfDue);
})();
</script>
</body>
+155 -75
View File
@@ -3,39 +3,48 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>LEDMatrix WiFi Setup</title>
<style>
:root{--bg:#f3f4f6;--surface:#fff;--text:#1f2937;--label:#374151;--muted:#4b5563;--faint:#6b7280;--line:#e5e7eb;--field:#d1d5db;--accent:#2563eb;--accent-hover:#1d4ed8;--scan:#e5e7eb;--scan-hover:#d1d5db;--ok-bg:#d1fae5;--ok:#065f46;--err-bg:#fee2e2;--err:#991b1b;--info-bg:#dbeafe;--info:#1e40af}
@media (prefers-color-scheme:dark){:root{--bg:#111827;--surface:#1f2937;--text:#f9fafb;--label:#d1d5db;--muted:#d1d5db;--faint:#9ca3af;--line:#374151;--field:#4b5563;--accent:#3b82f6;--accent-hover:#60a5fa;--scan:#374151;--scan-hover:#4b5563;--ok-bg:#064e3b;--ok:#6ee7b7;--err-bg:#7f1d1d;--err:#fecaca;--info-bg:#1e3a5f;--info:#bfdbfe}}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f3f4f6;color:#1f2937;padding:16px;max-width:480px;margin:0 auto}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);padding:16px;max-width:480px;margin:0 auto;line-height:1.45}
h1{font-size:20px;margin-bottom:4px}
.subtitle{color:#6b7280;font-size:13px;margin-bottom:20px}
.card{background:#fff;border-radius:12px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.1);margin-bottom:16px}
label{display:block;font-size:13px;font-weight:600;color:#374151;margin-bottom:6px}
select,input[type=text],input[type=password]{width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:15px;background:#fff;-webkit-appearance:none;appearance:none}
select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b7280' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.15)}
.btn{display:block;width:100%;padding:12px;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;text-align:center;transition:background .15s}
.btn-primary{background:#2563eb;color:#fff}.btn-primary:hover{background:#1d4ed8}
.btn-scan{background:#e5e7eb;color:#374151}.btn-scan:hover{background:#d1d5db}
.btn:disabled{background:#d1d5db;color:#9ca3af;cursor:not-allowed}
.subtitle{color:var(--muted);font-size:14px;margin-bottom:20px}
.card{background:var(--surface);border-radius:12px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.1);margin-bottom:16px}
label{display:block;font-size:14px;font-weight:600;color:var(--label);margin-bottom:6px}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
/* 16px inputs: iOS Safari zooms any focused field smaller than that */
select,input[type=text],input[type=password]{width:100%;min-width:0;min-height:44px;padding:10px 12px;border:1px solid var(--field);border-radius:8px;font-size:16px;background-color:var(--surface);color:var(--text);-webkit-appearance:none;appearance:none}
select{padding-right:32px;text-overflow:ellipsis;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b7280' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
select:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:1px;border-color:var(--accent)}
.btn{display:block;width:100%;min-height:44px;padding:12px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;text-align:center;transition:background-color .15s}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-hover)}
.btn-scan{background:var(--scan);color:var(--label);width:auto;padding:10px 16px;white-space:nowrap}.btn-scan:hover{background:var(--scan-hover)}
.btn:disabled{background:var(--scan);color:var(--muted);cursor:not-allowed;opacity:.75}
.row{display:flex;gap:8px;margin-bottom:16px}
.row>*:first-child{flex:1}
.msg{padding:12px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none}
.msg-ok{background:#d1fae5;color:#065f46;display:block}
.msg-err{background:#fee2e2;color:#991b1b;display:block}
.msg-info{background:#dbeafe;color:#1e40af;display:block}
.step{font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
.sep{margin:16px 0;border:none;border-top:1px solid #e5e7eb}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:6px}
.row>*:first-child{flex:1;min-width:0}
.msg{padding:12px;border-radius:8px;font-size:14px;margin-bottom:12px;display:none;overflow-wrap:anywhere}
.msg-ok{background:var(--ok-bg);color:var(--ok);display:block}
.msg-err{background:var(--err-bg);color:var(--err);display:block}
.msg-info{background:var(--info-bg);color:var(--info);display:block}
.step{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .6s linear infinite;vertical-align:-2px;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
.footer{text-align:center;margin-top:20px;font-size:12px;color:#9ca3af}
.footer a{color:#3b82f6;text-decoration:none}
@media (prefers-reduced-motion:reduce){.spinner{animation-duration:1.8s}.btn{transition:none}}
.footer{text-align:center;margin-top:20px;font-size:14px}
.footer a{color:var(--accent);text-underline-offset:.15em;display:inline-block;padding:10px}
.success-box{text-align:center;padding:24px}
.success-box .icon{font-size:48px;margin-bottom:12px}
.success-box .ip{font-size:18px;font-weight:700;color:#2563eb;word-break:break-all}
.success-box .icon{width:48px;height:48px;margin:0 auto 12px;color:#059669}
.success-box .title{font-size:16px;font-weight:600;margin-bottom:8px}
.success-box .hint{font-size:14px;color:var(--muted);margin-bottom:12px}
.success-box .note{font-size:13px;color:var(--faint);margin-top:12px}
.success-box .ip{font-size:18px;font-weight:700;color:var(--accent);word-break:break-all}
.hidden{display:none}
.or-divider{text-align:center;color:#9ca3af;font-size:12px;margin:12px 0;position:relative}
.or-divider::before,.or-divider::after{content:'';position:absolute;top:50%;width:40%;height:1px;background:#e5e7eb}
.or-divider{text-align:center;color:var(--faint);font-size:13px;margin:12px 0;position:relative}
.or-divider::before,.or-divider::after{content:'';position:absolute;top:50%;width:30%;height:1px;background:var(--line)}
.or-divider::before{left:0}
.or-divider::after{right:0}
</style>
@@ -45,44 +54,48 @@ select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px
<h1>LEDMatrix WiFi Setup</h1>
<p class="subtitle">Connect your device to a WiFi network</p>
<div id="msg" class="msg"></div>
<div id="msg" class="msg" role="status" aria-live="polite" aria-atomic="true"></div>
<div id="setup-form">
<div class="card">
<div class="step">Step 1 &mdash; Choose Network</div>
<div class="step" aria-hidden="true">Step 1 &mdash; Choose Network</div>
<label for="net-select">Available Networks</label>
<div class="row">
<select id="net-select" onchange="onSelectNetwork()">
<option value="">-- Scan to find networks --</option>
</select>
<button class="btn btn-scan" id="btn-scan" onclick="doScan()" style="width:auto;padding:10px 16px">
<button type="button" class="btn btn-scan" id="btn-scan" onclick="doScan()">
Scan
</button>
</div>
<div class="or-divider">or enter manually</div>
<input type="text" id="manual-ssid" placeholder="Network name (SSID)" oninput="onManualInput()">
<div class="or-divider" aria-hidden="true">or enter manually</div>
<label for="manual-ssid" class="sr-only">Network name (SSID), if it isn't listed</label>
<input type="text" id="manual-ssid" placeholder="Network name (SSID)" maxlength="32"
autocomplete="off" autocapitalize="off" spellcheck="false" oninput="onManualInput()">
</div>
<div class="card">
<div class="step">Step 2 &mdash; Password</div>
<div class="step" aria-hidden="true">Step 2 &mdash; Password</div>
<label for="password">WiFi Password</label>
<input type="password" id="password" placeholder="Leave empty for open networks">
<input type="password" id="password" placeholder="Leave empty for open networks" maxlength="63"
autocomplete="current-password" aria-describedby="password-hint">
<p id="password-hint" class="sr-only">Leave empty for open networks.</p>
</div>
<div class="card">
<button class="btn btn-primary" id="btn-connect" onclick="doConnect()" disabled>
<button type="button" class="btn btn-primary" id="btn-connect" onclick="doConnect()" disabled>
Connect
</button>
</div>
</div>
<div id="success-view" class="card hidden">
<div id="success-view" class="card hidden" tabindex="-1" aria-labelledby="success-title">
<div class="success-box">
<div class="icon">&#10003;</div>
<p style="font-size:16px;font-weight:600;margin-bottom:8px">Connected!</p>
<p style="font-size:13px;color:#6b7280;margin-bottom:12px">Your device is now on the network. Access the full interface at:</p>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M7.5 12.5l3 3 6-6"/></svg>
<p class="title" id="success-title">Connected!</p>
<p class="hint">Your device is now on the network. Access the full interface at:</p>
<p class="ip" id="new-ip"></p>
<p style="font-size:12px;color:#9ca3af;margin-top:12px">You may need to reconnect your phone to the same WiFi network.</p>
<p class="note">You may need to reconnect your phone to the same WiFi network.</p>
</div>
</div>
@@ -94,17 +107,53 @@ select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px
var selectedSSID = '';
var scanning = false;
var connecting = false;
var msgTimer = null;
function $(id) { return document.getElementById(id); }
function showMsg(text, type) {
var el = $('msg');
el.textContent = text;
el.className = 'msg msg-' + (type || 'info');
if (type === 'ok') setTimeout(function() { el.style.display = 'none'; }, 8000);
// fetch() with a deadline: on a flaky AP link a request can otherwise hang
// forever and leave the buttons stuck in their busy state.
function fetchWithTimeout(url, options, ms) {
options = options || {};
if (typeof AbortController === 'undefined') return fetch(url, options);
var controller = new AbortController();
var timer = setTimeout(function() { controller.abort(); }, ms);
options.signal = controller.signal;
return fetch(url, options).finally(function() { clearTimeout(timer); });
}
function clearMsg() { $('msg').className = 'msg'; }
function readJson(r) {
return r.json().catch(function() {
throw new Error(r.ok ? 'bad-response' : 'http-' + r.status);
});
}
function setBusy(btn, label) {
btn.textContent = '';
var spin = document.createElement('span');
spin.className = 'spinner';
spin.setAttribute('aria-hidden', 'true');
btn.appendChild(spin);
btn.appendChild(document.createTextNode(label));
}
function showMsg(text, type) {
var el = $('msg');
if (msgTimer) { clearTimeout(msgTimer); msgTimer = null; }
el.style.display = '';
// Errors interrupt; progress and success wait their turn.
el.setAttribute('role', type === 'err' ? 'alert' : 'status');
el.setAttribute('aria-live', type === 'err' ? 'assertive' : 'polite');
el.textContent = text;
el.className = 'msg msg-' + (type || 'info');
if (type === 'ok') msgTimer = setTimeout(function() { el.style.display = 'none'; }, 8000);
}
function clearMsg() {
if (msgTimer) { clearTimeout(msgTimer); msgTimer = null; }
$('msg').className = 'msg';
$('msg').textContent = '';
}
function updateConnectBtn() {
var ssid = $('net-select').value || $('manual-ssid').value.trim();
@@ -128,43 +177,58 @@ function doScan() {
scanning = true;
var btn = $('btn-scan');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Scanning';
btn.setAttribute('aria-busy', 'true');
setBusy(btn, 'Scanning');
clearMsg();
fetch('/api/v3/wifi/scan')
.then(function(r) { return r.json(); })
fetchWithTimeout('/api/v3/wifi/scan', {}, 30000)
.then(readJson)
.then(function(data) {
var sel = $('net-select');
var previous = sel.value;
sel.innerHTML = '<option value="">-- Select a network --</option>';
if (data.status === 'success' && Array.isArray(data.data)) {
var nets = data.data;
var seen = {};
var nets = data.data.filter(function(n) {
if (!n || !n.ssid || seen[n.ssid]) return false;
seen[n.ssid] = true;
return true;
});
for (var i = 0; i < nets.length; i++) {
var n = nets[i];
var opt = document.createElement('option');
opt.value = n.ssid;
opt.textContent = n.ssid + ' (' + n.signal + '% - ' + n.security + ')';
var details = [];
if (n.signal !== undefined && n.signal !== null) details.push(n.signal + '%');
if (n.security) details.push(n.security);
opt.textContent = n.ssid + (details.length ? ' (' + details.join(' - ') + ')' : '');
sel.appendChild(opt);
}
if (previous && seen[previous]) sel.value = previous;
if (nets.length > 0) {
var msg = 'Found ' + nets.length + ' network' + (nets.length > 1 ? 's' : '');
var msg = 'Found ' + nets.length + (nets.length === 1 ? ' network' : ' networks');
if (data.cached) {
msg += ' \u2014 Showing cached networks. Connect to see the latest.';
msg += ' — showing cached networks. Connect to see the latest.';
}
showMsg(msg, data.cached ? 'info' : 'ok');
} else {
showMsg('No networks found. ' + (data.cached ? 'Enter your network name manually.' : 'Try scanning again.'), 'info');
showMsg('No networks found. ' + (data.cached ? 'Enter your network name manually.' : 'Try scanning again, or enter your network name manually.'), 'info');
}
} else {
showMsg(data.message || 'Scan failed', 'err');
showMsg(data.message || 'Scan failed. Try again, or enter your network name manually.', 'err');
}
})
.catch(function(e) {
showMsg('Scan failed: ' + e.message, 'err');
var why = e && e.name === 'AbortError'
? 'The scan took too long.'
: 'Couldn’t reach the device to scan.';
showMsg(why + ' Stay close to the device and try again, or enter your network name manually.', 'err');
})
.finally(function() {
scanning = false;
btn.disabled = false;
btn.innerHTML = 'Scan';
btn.removeAttribute('aria-busy');
btn.textContent = 'Scan';
updateConnectBtn();
});
}
@@ -175,16 +239,25 @@ function doConnect() {
connecting = true;
var btn = $('btn-connect');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Connecting...';
btn.setAttribute('aria-busy', 'true');
setBusy(btn, 'Connecting…');
clearMsg();
showMsg('Connecting to ' + ssid + '... This may take 15-30 seconds.', 'info');
showMsg('Connecting to ' + ssid + '… This may take 15–30 seconds.', 'info');
fetch('/api/v3/wifi/connect', {
function resetConnect() {
connecting = false;
btn.disabled = false;
btn.removeAttribute('aria-busy');
btn.textContent = 'Connect';
updateConnectBtn();
}
fetchWithTimeout('/api/v3/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: ssid, password: $('password').value || '' })
})
.then(function(r) { return r.json(); })
}, 60000)
.then(readJson)
.then(function(data) {
if (data.status === 'success') {
clearMsg();
@@ -192,15 +265,19 @@ function doConnect() {
setTimeout(function() { checkNewIP(ssid); }, 3000);
} else {
var msg = data.error_type === 'wrong_password'
? 'Incorrect password — please try again'
: (data.message || 'Connection failed');
? 'Incorrect password — please try again.'
: (data.message || 'Couldn’t connect to ' + ssid + '. Check the network name and password, then try again.');
showMsg(msg, 'err');
connecting = false;
btn.disabled = false;
btn.innerHTML = 'Connect';
resetConnect();
if (data.error_type === 'wrong_password') $('password').focus();
}
})
.catch(function(e) {
if (e && /^http-|^bad-response$/.test(e.message)) {
showMsg('The device couldn’t start the connection. Try again in a moment.', 'err');
resetConnect();
return;
}
// Connection may drop if AP mode was disabled — that's expected
clearMsg();
showMsg('Connection attempt sent. If the page stops responding, the device is connecting to ' + ssid + '.', 'info');
@@ -216,8 +293,8 @@ function checkNewIP(ssid, retriesLeft) {
showSuccessFallback(ssid);
return;
}
fetch('/api/v3/wifi/status')
.then(function(r) { return r.json(); })
fetchWithTimeout('/api/v3/wifi/status', {}, 8000)
.then(readJson)
.then(function(data) {
if (data.status === 'success' && data.data && data.data.connected && data.data.ip_address) {
showSuccess(data.data.ip_address);
@@ -231,18 +308,21 @@ function checkNewIP(ssid, retriesLeft) {
});
}
function showSuccess(ip) {
function revealSuccess(text) {
$('setup-form').classList.add('hidden');
$('success-view').classList.remove('hidden');
$('new-ip').textContent = 'http://' + ip + ':5000';
$('msg').className = 'msg';
var view = $('success-view');
view.classList.remove('hidden');
$('new-ip').textContent = text;
clearMsg();
view.focus();
}
function showSuccess(ip) {
revealSuccess('http://' + ip + ':5000');
}
function showSuccessFallback(ssid) {
$('setup-form').classList.add('hidden');
$('success-view').classList.remove('hidden');
$('new-ip').textContent = 'Check your router for the device IP';
$('msg').className = 'msg';
revealSuccess('Check your router for the device IP');
}
// Auto-scan on load
@@ -52,7 +52,7 @@
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Backup file</label>
<label for="restore-file-input" class="block text-sm font-medium text-gray-700 mb-2">Backup file</label>
<input type="file" id="restore-file-input" accept=".zip"
class="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
</div>
@@ -1039,10 +1039,25 @@ window.showDisplaySaveResult = function(xhr) {
content.appendChild(textSpan);
}
// Initial UI state and polling — guard against duplicate intervals on re-run
// Initial UI state, then the 5s sync-status poll — only while the Display
// tab is active and the page is visible. LEDVisibility keys the
// registration, so a re-run of this partial replaces the previous timer.
function stopSyncTimer() {
if (window.syncStatusInterval) {
clearInterval(window.syncStatusInterval);
window.syncStatusInterval = null;
}
}
function startSyncTimer() {
stopSyncTimer();
pollSyncStatus();
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
}
function startSyncPolling() {
updateSyncUI();
if (!window.syncStatusInterval) {
if (window.LEDVisibility) {
window.LEDVisibility.onActive('display', startSyncTimer, stopSyncTimer, 'display-sync-status');
} else if (!window.syncStatusInterval) {
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
}
}
+42 -30
View File
@@ -39,7 +39,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family Name</label>
<label for="upload-font-family" class="block text-sm font-medium text-gray-700 mb-1">Font Family Name</label>
<input type="text" id="upload-font-family" class="form-control" placeholder="e.g., my-custom-font">
<p class="text-sm text-gray-600 mt-1">Custom name for this font (letters, numbers, underscores, hyphens)</p>
</div>
@@ -75,24 +75,24 @@
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6">
<div>
<div id="font-preview-container" class="border border-gray-300 bg-black rounded p-4 min-h-[100px] flex items-center justify-center">
<img id="font-preview-image" src="" alt="Font preview" class="max-w-full" style="display: none;">
<img id="font-preview-image" alt="Font preview" class="max-w-full" style="display: none;">
<span id="font-preview-loading" class="text-gray-400">Select a font to preview</span>
</div>
</div>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Preview Text</label>
<label for="preview-text" class="block text-sm font-medium text-gray-700 mb-1">Preview Text</label>
<input type="text" id="preview-text" value="Sample Text 123" class="form-control text-sm">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
<label for="preview-family" class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
<select id="preview-family" class="form-control text-sm">
<!-- Dynamically populated from font catalog -->
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Size</label>
<label for="preview-size" class="block text-sm font-medium text-gray-700 mb-1">Font Size</label>
<select id="preview-size" class="form-control text-sm">
<option value="xs">XS (6px)</option>
<option value="sm">SM (8px)</option>
@@ -163,11 +163,18 @@ function initializeFontsTab() {
// Reset retry counter on successful init
initRetryCount = 0;
// showNotification is provided by the notification widget (notification.js)
// Fallback only if widget hasn't loaded yet
// showNotification is implemented by the notification widget
// (widgets/notification.js). If it hasn't loaded, queue the message; the
// widget shows queued messages as soon as it registers.
if (typeof window.showNotification !== 'function') {
window.showNotification = function(message, type = 'info') {
console.log(`[${type.toUpperCase()}]`, message);
const registry = window.LEDMatrixWidgets;
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
if (widget && typeof widget.show === 'function') {
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
}
(window.__pendingNotifications = window.__pendingNotifications || []).push([message, type]);
console.log(`[${String((type && type.type) || type).toUpperCase()}]`, message);
};
}
@@ -725,9 +732,8 @@ function updateUploadProgress(percent) {
<style>
.drag-over {
border-color: #3b82f6 !important;
background-color: #eff6ff !important;
transform: scale(1.02);
border-color: var(--color-primary, #3b82f6) !important;
background-color: var(--color-info-bg, #eff6ff) !important;
}
.font-upload-area {
@@ -735,45 +741,51 @@ function updateUploadProgress(percent) {
}
.upload-dropzone {
border: 3px dashed #d1d5db;
border-radius: 10px;
padding: 40px;
border: 2px dashed var(--color-field-border, #d1d5db);
border-radius: 0.75rem;
padding: 2rem 1.5rem;
text-align: center;
background: #fafafa;
transition: all 0.3s ease;
background: var(--color-background, #f9fafb);
transition: border-color 0.15s ease, background-color 0.15s ease;
cursor: pointer;
}
@media (min-width: 640px) {
.upload-dropzone {
padding: 2.5rem;
}
}
.upload-dropzone:hover {
border-color: #3b82f6;
background: #f0f8ff;
border-color: var(--color-primary, #3b82f6);
background: var(--color-info-bg, #eff6ff);
}
.upload-dropzone i {
font-size: 3rem;
color: #3b82f6;
margin-bottom: 15px;
color: var(--color-primary, #3b82f6);
margin-bottom: 0.75rem;
display: block;
}
.upload-dropzone p {
margin: 5px 0;
font-size: 1.1rem;
color: #374151;
margin: 0.25rem 0;
font-size: 1rem;
color: var(--color-text-secondary, #374151);
}
.upload-hint {
font-size: 0.9rem !important;
color: #6b7280 !important;
font-size: 0.875rem !important;
color: var(--color-text-tertiary, #4b5563) !important;
font-style: italic;
}
.upload-form {
margin-top: 20px;
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
border: 1px solid #d1d5db;
margin-top: 1.25rem;
padding: 1.25rem;
background: var(--color-background, #f9fafb);
border-radius: 0.5rem;
border: 1px solid var(--color-border, #d1d5db);
}
#font-preview-container {
+51 -17
View File
@@ -17,9 +17,9 @@
<!-- Controls -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4">
<div class="flex flex-wrap items-center gap-3 sm:gap-4">
<!-- Log Mode Toggle -->
<div class="flex items-center space-x-2">
<div class="flex items-center gap-2">
<label class="flex items-center">
<input type="checkbox" id="log-realtime-toggle" class="form-control h-4 w-4" checked>
<span class="ml-2 text-sm font-medium">Real-time</span>
@@ -31,7 +31,7 @@
</div>
<!-- Log Level Filter -->
<select id="log-level-filter" class="form-control text-sm">
<select id="log-level-filter" aria-label="Filter logs by level" class="form-control text-sm w-auto">
<option value="">All Levels</option>
<option value="ERROR">Errors Only</option>
<option value="WARNING">Warnings & Errors</option>
@@ -39,18 +39,18 @@
</select>
<!-- Plugin Filter -->
<select id="log-plugin-filter" class="form-control text-sm">
<select id="log-plugin-filter" aria-label="Filter logs by plugin" class="form-control text-sm w-auto">
<option value="">All Plugins</option>
</select>
<!-- Search -->
<div class="relative">
<input type="text" id="log-search" placeholder="Search logs..." class="form-control text-sm pl-8 pr-4 py-1 w-48">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
<input type="text" id="log-search" placeholder="Search logs..." aria-label="Search logs" class="form-control text-sm pl-8 pr-4 py-1 w-48">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs pointer-events-none" aria-hidden="true"></i>
</div>
</div>
<div class="flex items-center space-x-2">
<div class="flex flex-wrap items-center gap-2">
<!-- Auto-scroll toggle -->
<label class="flex items-center">
<input type="checkbox" id="log-autoscroll" class="form-control h-4 w-4" checked>
@@ -72,7 +72,7 @@
<!-- Log Display -->
<div class="relative">
<div id="logs-container" class="bg-gray-900 text-gray-100 font-mono text-sm rounded-lg p-3 border border-gray-700 shadow-inner relative" style="height: 500px; min-height: 400px; max-height: 70vh;">
<div id="logs-loading" class="absolute inset-0 flex items-center justify-center text-gray-400 bg-gray-900">
<div id="logs-loading" class="absolute inset-0 flex items-center justify-center text-gray-300 bg-gray-900">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-2xl mb-2"></i>
<p>Loading logs...</p>
@@ -83,7 +83,7 @@
<!-- Logs will be inserted here -->
</div>
</div>
<div id="logs-empty" class="hidden absolute inset-0 flex items-center justify-center text-gray-400 bg-gray-900">
<div id="logs-empty" class="hidden absolute inset-0 flex items-center justify-center text-gray-300 bg-gray-900">
<div class="text-center">
<i class="fas fa-file-alt text-4xl mb-2"></i>
<p>No logs available</p>
@@ -215,13 +215,32 @@ window._filteredLogs = [];
});
}
refreshCurrentPluginStatus();
if (window._currentPluginPollTimer) {
clearInterval(window._currentPluginPollTimer);
// Current-plugin poll and the realtime log stream run only while the Logs
// tab is active and the page is visible (both restart with an immediate
// refresh). Re-registering on partial reload replaces the old one.
function startLogsActivity() {
refreshCurrentPluginStatus();
if (window._currentPluginPollTimer) clearInterval(window._currentPluginPollTimer);
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
if (window._isRealtime && !window._logsEventSource) setupRealtimeLogs();
}
function stopLogsActivity() {
if (window._currentPluginPollTimer) {
clearInterval(window._currentPluginPollTimer);
window._currentPluginPollTimer = null;
}
stopRealtimeLogs();
}
if (window.LEDVisibility) {
window.LEDVisibility.onActive('logs', startLogsActivity, stopLogsActivity);
} else {
startLogsActivity();
}
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
// Handle window resize for responsive height
// Handle window resize for responsive height. Added once: this script
// re-runs on every partial load, and the handler reads window globals.
if (window._logsResizeListenerAdded) return;
window._logsResizeListenerAdded = true;
window.addEventListener('resize', function() {
if (window._logContainer) {
// Force a reflow to update sizing
@@ -270,10 +289,24 @@ function loadLogs() {
});
}
function setupRealtimeLogs() {
// Closes the log stream and cancels any pending reconnect, so a paused
// (inactive tab / hidden page) stream stays closed.
function stopRealtimeLogs() {
if (window._logsReconnectTimer) {
clearTimeout(window._logsReconnectTimer);
window._logsReconnectTimer = null;
}
if (window._logsEventSource) {
window._logsEventSource.close();
window._logsEventSource = null;
}
}
function setupRealtimeLogs() {
stopRealtimeLogs();
// Don't open (or reconnect) while the Logs tab isn't on screen;
// LEDVisibility restarts the stream when it is.
if (window.LEDVisibility && !window.LEDVisibility.isActive('logs')) return;
window._logsEventSource = new EventSource('/api/v3/stream/logs');
@@ -302,8 +335,9 @@ function setupRealtimeLogs() {
<span>Disconnected from log stream</span>
`;
// Attempt to reconnect after 5 seconds
setTimeout(setupRealtimeLogs, 5000);
// Attempt to reconnect after 5 seconds (cancelled by stopRealtimeLogs)
if (window._logsReconnectTimer) clearTimeout(window._logsReconnectTimer);
window._logsReconnectTimer = setTimeout(setupRealtimeLogs, 5000);
};
}
@@ -8,12 +8,12 @@
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4">
<!-- Filter by Plugin -->
<select id="history-plugin-filter" class="form-control text-sm">
<select id="history-plugin-filter" class="form-control text-sm" aria-label="Filter by plugin">
<option value="">All Plugins</option>
</select>
<!-- Filter by Operation Type -->
<select id="history-type-filter" class="form-control text-sm">
<select id="history-type-filter" class="form-control text-sm" aria-label="Filter by operation type">
<option value="">All Operations</option>
<option value="install">Install</option>
<option value="update">Update</option>
@@ -24,7 +24,7 @@
</select>
<!-- Filter by Status -->
<select id="history-status-filter" class="form-control text-sm">
<select id="history-status-filter" class="form-control text-sm" aria-label="Filter by status">
<option value="">All Statuses</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
@@ -32,8 +32,8 @@
<!-- Search -->
<div class="relative">
<input type="text" id="history-search" placeholder="Search operations..." class="form-control text-sm pl-8 pr-4 py-1 w-48">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
<input type="text" id="history-search" placeholder="Search operations..." aria-label="Search operations" class="form-control text-sm pl-8 pr-4 py-1 w-48">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs" aria-hidden="true"></i>
</div>
</div>
@@ -98,7 +98,7 @@
<div class="flex-1">
<p class="text-sm font-semibold text-blue-900"><i class="fas fa-rocket mr-1"></i>Getting Started</p>
<p class="text-xs text-blue-700 mt-0.5 mb-2">A few steps to get your display up and running. Click a step to jump there, or click its checkbox to mark it done yourself. The card hides once everything is checked.</p>
<ul class="space-y-1 text-sm" id="getting-started-items">
<ul class="list-none pl-0 space-y-1 text-sm" id="getting-started-items">
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _hw_done else '0' }}" data-tab="display">
<i class="far fa-square mr-2"></i>Set your panel size (Display tab)</button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="timezone" data-tz="{{ _tz }}" data-tab="general">
@@ -21,8 +21,8 @@
{# Schedule picker widget - renders enable/mode/times UI #}
{% set obj_value = value if value is not none else {} %}
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
<div id="{{ field_id }}_container" class="schedule-picker-container mt-1"></div>
<label id="{{ field_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
<div id="{{ field_id }}_container" class="schedule-picker-container mt-1" role="group" aria-labelledby="{{ field_id }}-label"></div>
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "&#39;") }}'>
</div>
<script>
@@ -48,8 +48,8 @@
{# Time range widget - renders start/end time inputs #}
{% set obj_value = value if value is not none else {} %}
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
<div id="{{ field_id }}_container" class="time-range-container mt-1"></div>
<label id="{{ field_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
<div id="{{ field_id }}_container" class="time-range-container mt-1" role="group" aria-labelledby="{{ field_id }}-label"></div>
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "&#39;") }}'>
</div>
<script>
@@ -88,6 +88,15 @@
</div>
<script>
(function() {
// ~10s at 50ms: enough for the widget bundle to arrive over a
// slow link to a Pi, bounded so a bundle that never registers
// style-editor doesn't poll for the life of the page.
var retries = 0;
var MAX_RETRIES = 200;
function giveUp() {
var container = document.getElementById('{{ field_id }}_container');
if (container) { container.remove(); }
}
function fallbackIsDirty(root) {
if (!root) { return false; }
var controls = root.querySelectorAll('input, select, textarea');
@@ -106,9 +115,12 @@
return false;
}
function initWidget() {
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
var widget = window.LEDMatrixWidgets.get('style-editor');
if (!widget) { setTimeout(initWidget, 50); return; }
var widget = window.LEDMatrixWidgets && window.LEDMatrixWidgets.get('style-editor');
if (!widget) {
// The plain fallback fields stay in place either way.
if (++retries < MAX_RETRIES) { setTimeout(initWidget, 50); } else { giveUp(); }
return;
}
var container = document.getElementById('{{ field_id }}_container');
var fallback = document.getElementById('{{ field_id }}_fallback');
if (!container) { return; }
@@ -145,8 +157,27 @@
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
{% endif %}
{% else %}
{# Point the label at the control that actually takes focus. Widgets
render their own element (e.g. <id>_input), so a plain for="<id>"
would label nothing. Mirrors the branch order below; a miss just
falls back to the old for="<id>". #}
{% set _lw = prop.get('x-widget') or prop.get('x_widget') %}
{% set label_target = field_id %}
{% if field_type == 'boolean' %}
{% if _lw == 'toggle-switch' %}{% set label_target = field_id ~ '_button' %}{% endif %}
{% elif prop.enum %}
{% elif field_type in ['number', 'integer'] %}
{% if _lw in ['slider', 'number-input'] %}{% set label_target = field_id ~ '_input' %}{% endif %}
{% elif field_type == 'array' %}
{% if _lw == 'color-picker' %}{% set label_target = field_id ~ '_hex' %}
{% elif _lw == 'file-upload' %}{% set label_target = field_id ~ '_file_input' %}{% endif %}
{% elif _lw in ['text-input', 'textarea', 'select-dropdown', 'date-picker', 'time-picker', 'slider', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
{% set label_target = field_id ~ '_input' %}
{% elif _lw == 'color-picker' %}
{% set label_target = field_id ~ '_color' %}
{% endif %}
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
<label for="{{ field_id }}" class="block text-sm font-medium text-gray-700 mb-1">
<label for="{{ label_target }}" id="{{ field_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}{{ ui.help_tip(description, label) }}
</label>
@@ -277,7 +308,11 @@
data-max-files="{{ max_files }}"
data-max-size-mb="{{ max_size_mb }}"
data-allowed-types="{{ allowed_types|join(',') }}"
onclick="document.getElementById('{{ field_id }}_file_input').click()">
role="button"
tabindex="0"
aria-label="Upload files: drop them here or press Enter to choose"
onclick="document.getElementById('{{ field_id }}_file_input').click()"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();document.getElementById('{{ field_id }}_file_input').click();}">
<input type="file"
id="{{ field_id }}_file_input"
multiple
@@ -310,8 +345,9 @@
<div id="img_{{ img_id|string|replace('.', '_')|replace('-', '_') }}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-3 flex-1">
<img src="/{{ img.get('path', '') }}"
alt="{{ img.get('filename', '') }}"
<img src="/{{ img.get('path', '') }}"
alt="{{ img.get('filename', '') }}"
loading="lazy" decoding="async"
class="w-16 h-16 object-cover rounded"
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
<div style="display:none;" class="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
@@ -336,9 +372,10 @@
data-image-id="{{ img_id }}"
data-image-idx="{{ loop.index0 }}"
onclick="window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId || null, parseInt(this.dataset.imageIdx))"
class="text-blue-600 hover:text-blue-800 p-2"
title="Schedule this image">
<i class="fas fa-calendar-alt"></i>
class="text-blue-600 hover:text-blue-800 p-2"
title="Schedule this image"
aria-label="Schedule image {{ img.get('original_filename') or img.get('filename', '') }}">
<i class="fas fa-calendar-alt" aria-hidden="true"></i>
</button>
<button type="button"
data-field-id="{{ field_id }}"
@@ -346,8 +383,9 @@
data-plugin-id="{{ plugin_id_from_config }}"
onclick="window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId)"
class="text-red-600 hover:text-red-800 p-2"
title="Delete image">
<i class="fas fa-trash"></i>
title="Delete image"
aria-label="Delete image {{ img.get('original_filename') or img.get('filename', '') }}">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -387,7 +425,7 @@
</div>
{% endif %}
<div class="mt-1 space-y-2">
<div class="mt-1 space-y-2" role="group" aria-labelledby="{{ field_id }}-label">
{% for option in enum_items %}
{% set is_checked = option in array_value %}
{% set option_label = labels.get(option, option|replace('_', ' ')|title) %}
@@ -508,6 +546,7 @@
name="{{ full_key }}.{{ item_index }}.name"
value="{{ item.get('name', '') }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
aria-label="Feed name, row {{ item_index + 1 }}"
placeholder="Feed Name"
required>
</td>
@@ -516,6 +555,7 @@
name="{{ full_key }}.{{ item_index }}.url"
value="{{ item.get('url', '') }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
aria-label="Feed URL, row {{ item_index + 1 }}"
placeholder="https://example.com/feed"
required>
</td>
@@ -534,7 +574,7 @@
<i class="fas fa-upload mr-1"></i> Upload
</button>
{% if logo_path %}
<img src="/{{ logo_path }}" alt="Logo" class="w-8 h-8 object-cover rounded border" id="{{ field_id }}_logo_preview_{{ item_index }}">
<img src="/{{ logo_path }}" alt="Logo" loading="lazy" decoding="async" class="w-8 h-8 object-cover rounded border" id="{{ field_id }}_logo_preview_{{ item_index }}">
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.path" value="{{ logo_path }}">
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.id" value="{{ logo_value.get('id', '') }}">
{% else %}
@@ -549,13 +589,15 @@
{% if item.get('enabled', true) %}checked{% endif %}
value="true"
onchange="this.previousElementSibling.value = this.checked ? 'true' : 'false'"
aria-label="Feed enabled, row {{ item_index + 1 }}"
class="h-4 w-4 text-blue-600">
</td>
<td class="px-4 py-3 whitespace-nowrap text-center">
<button type="button"
onclick="removeCustomFeedRow(this)"
aria-label="Remove feed, row {{ item_index + 1 }}"
class="text-red-600 hover:text-red-800 px-2 py-1">
<i class="fas fa-trash"></i>
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</td>
</tr>
@@ -624,6 +666,8 @@
<tr class="array-table-row" data-index="{{ item_index }}">
{% for col_name in display_columns %}
{% set col_def = item_properties.get(col_name, {}) %}
{# Accessible name for the cell's control: column title + row. #}
{% set cell_label = col_def.get('title', col_name|replace('_', ' ')|title) ~ ', row ' ~ (item_index + 1) %}
{# Normalize nullable types e.g. ["null","integer"] → "integer" #}
{% set _raw_type = col_def.get('type', 'string') %}
{% if _raw_type is iterable and _raw_type is not string %}
@@ -652,6 +696,7 @@
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ col_name }}" value="{{ 'true' if col_value else 'false' }}">
<input type="checkbox"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
{% if col_value %}checked{% endif %}
value="true"
onchange="this.previousElementSibling.value = this.checked ? 'true' : 'false'"
@@ -659,6 +704,7 @@
{% elif col_type == 'integer' or col_type == 'number' %}
<input type="number"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '' }}"
{% if col_def.get('minimum') is not none %}min="{{ col_def.get('minimum') }}"{% endif %}
{% if col_def.get('maximum') is not none %}max="{{ col_def.get('maximum') }}"{% endif %}
@@ -671,6 +717,7 @@
which must not be title-cased behind the user's back. #}
{% set col_labels = (col_def.get('x-options') or col_def.get('x_options') or {}).get('labels') or {} %}
<select name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white">
{% for opt in col_enum %}{% if opt is not none %}
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ col_labels.get(opt, opt) }}</option>
@@ -679,28 +726,32 @@
{% elif col_xwidget == 'date-picker' %}
<input type="date"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '' }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
{% elif col_xwidget == 'time-picker' %}
<input type="time"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '00:00' }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
{% elif col_xwidget == 'file-upload-single' %}
{% set cell_input_id = field_id ~ '_' ~ item_index ~ '_' ~ col_name %}
<div class="flex items-center gap-1">
{% if col_value %}<img src="/{{ col_value }}" class="w-6 h-6 object-cover rounded flex-shrink-0" onerror="this.style.display='none'">{% endif %}
{% if col_value %}<img src="/{{ col_value }}" alt="" loading="lazy" decoding="async" class="w-6 h-6 object-cover rounded flex-shrink-0" onerror="this.style.display='none'">{% endif %}
<input type="text"
id="{{ cell_input_id }}"
aria-label="{{ cell_label }}"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
value="{{ col_value if col_value is not none else '' }}"
class="block w-20 px-1 py-1 border border-gray-300 rounded text-xs"
placeholder="path…">
<label class="cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100" title="Upload image">
<i class="fas fa-upload"></i>
<label class="file-trigger cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100" title="Upload image">
<i class="fas fa-upload" aria-hidden="true"></i>
<input type="file"
class="sr-only"
aria-label="Upload image: {{ cell_label }}"
accept="image/png,image/jpeg,image/bmp,image/gif"
style="display:none"
data-plugin-id="{{ plugin_id }}"
data-target-input="{{ cell_input_id }}"
onchange="(function(e){ const t=document.getElementById('{{ cell_input_id }}'); const p=t.previousElementSibling && t.previousElementSibling.tagName==='IMG' ? t.previousElementSibling : null; window.handleArrayTableImageUpload(e,t,p,'{{ plugin_id }}'); })(event)">
@@ -709,6 +760,7 @@
{% else %}
<input type="text"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '' }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
{% if col_def.get('description') %}placeholder="{{ col_def.get('description') }}"{% endif %}
@@ -725,15 +777,17 @@
<td class="px-3 py-3 whitespace-nowrap text-center" style="min-width:90px;vertical-align:middle">
<button type="button"
onclick="removeArrayTableRow(this)"
aria-label="Remove row {{ item_index + 1 }}"
class="text-red-600 hover:text-red-800 px-2 py-1">
<i class="fas fa-trash"></i>
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
{% if has_advanced.value %}
<button type="button"
onclick="openArrayTableRowEditor(this)"
class="text-blue-500 hover:text-blue-700 px-2 py-1 ml-1"
aria-label="Edit advanced settings for row {{ item_index + 1 }}"
title="Edit layout, style and other advanced properties">
<i class="fas fa-sliders-h"></i>
<i class="fas fa-sliders-h" aria-hidden="true"></i>
</button>
{% endif %}
</td>
@@ -804,9 +858,10 @@
value="{{ hex_val }}"
class="h-9 w-12 cursor-pointer rounded border border-gray-300"
title="Color picker"
aria-label="{{ label }} color picker"
oninput="(function(h){var r=parseInt(h.slice(1,3),16),g=parseInt(h.slice(3,5),16),b=parseInt(h.slice(5,7),16);document.getElementById('{{ field_id }}_r').value=r;document.getElementById('{{ field_id }}_g').value=g;document.getElementById('{{ field_id }}_b').value=b;})(this.value)">
<div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">R</label>
<label for="{{ field_id }}_r" class="text-xs text-gray-500 font-medium"><span aria-hidden="true">R</span><span class="sr-only">Red</span></label>
<input type="number" min="0" max="255" step="1"
id="{{ field_id }}_r"
name="{{ full_key }}.0"
@@ -815,7 +870,7 @@
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
</div>
<div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">G</label>
<label for="{{ field_id }}_g" class="text-xs text-gray-500 font-medium"><span aria-hidden="true">G</span><span class="sr-only">Green</span></label>
<input type="number" min="0" max="255" step="1"
id="{{ field_id }}_g"
name="{{ full_key }}.1"
@@ -824,7 +879,7 @@
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
</div>
<div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">B</label>
<label for="{{ field_id }}_b" class="text-xs text-gray-500 font-medium"><span aria-hidden="true">B</span><span class="sr-only">Blue</span></label>
<input type="number" min="0" max="255" step="1"
id="{{ field_id }}_b"
name="{{ full_key }}.2"
@@ -1235,20 +1290,21 @@
{% for key, value in config.items() %}
{% if key not in ['enabled'] %}
<div class="form-group mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">
{% set fallback_id = 'fallback-field-' ~ (key|string|replace(' ', '-')) %}
<label for="{{ fallback_id }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ key|replace('_', ' ')|title }}
</label>
{% if value is sameas true or value is sameas false %}
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="{{ key }}" {% if value %}checked{% endif %}
<input type="checkbox" id="{{ fallback_id }}" name="{{ key }}" {% if value %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm text-gray-600">Enabled</span>
</label>
{% elif value is number %}
<input type="number" name="{{ key }}" value="{{ value }}"
<input type="number" id="{{ fallback_id }}" name="{{ key }}" value="{{ value }}"
class="form-input w-full rounded-md border-gray-300 bg-gray-900 text-white placeholder:text-gray-400">
{% else %}
<input type="text" name="{{ key }}" value="{{ value }}"
<input type="text" id="{{ fallback_id }}" name="{{ key }}" value="{{ value }}"
class="form-input w-full rounded-md border-gray-300 bg-gray-900 text-white placeholder:text-gray-400">
{% endif %}
</div>
@@ -6,7 +6,7 @@
<!-- Plugin Controls -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4">
<div class="flex flex-wrap items-center gap-2 sm:gap-4">
<button type="button" id="refresh-plugins-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
<i class="fas fa-sync-alt mr-2"></i>Refresh Plugins
</button>
@@ -34,7 +34,7 @@
<div id="installed-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Search -->
<div class="relative flex-1 min-w-[12rem]">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none" aria-hidden="true"></i>
<input type="text"
id="installed-search"
class="form-control text-sm pl-9 pr-8 py-2"
@@ -44,7 +44,7 @@
<button type="button" id="installed-search-clear"
class="hidden absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="Clear search">
<i class="fas fa-times-circle"></i>
<i class="fas fa-times-circle" aria-hidden="true"></i>
</button>
</div>
@@ -149,7 +149,7 @@
<!-- GitHub Token Configuration (Combined Warning + Settings) -->
<div id="github-token-container" class="mb-5">
<!-- Warning Banner (shown when no token configured) -->
<div id="github-auth-warning" class="hidden bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded-r-lg">
<div id="github-auth-warning" class="hidden bg-yellow-50 border border-yellow-300 p-4 rounded-lg">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-yellow-400"></i>
@@ -170,8 +170,8 @@
</p>
</div>
<div class="flex-shrink-0">
<button onclick="dismissGithubWarning()" class="text-yellow-700 hover:text-yellow-900">
<i class="fas fa-times"></i>
<button type="button" onclick="dismissGithubWarning()" class="text-yellow-700 hover:text-yellow-900" aria-label="Dismiss GitHub token warning">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -207,9 +207,10 @@
<input type="password" id="github-token-input"
class="w-full px-3 py-2 pr-20 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx">
<button type="button" onclick="toggleGithubTokenVisibility()"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700">
<i id="github-token-icon" class="fas fa-eye"></i>
<button type="button" onclick="toggleGithubTokenVisibility()"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700"
aria-label="Show token" aria-controls="github-token-input">
<i id="github-token-icon" class="fas fa-eye" aria-hidden="true"></i>
</button>
</div>
<p class="text-xs text-gray-500 mt-1">
@@ -246,8 +247,8 @@
</div>
<!-- Search Row -->
<div class="flex gap-3 mb-4">
<input type="text" id="plugin-search" placeholder="Search plugins by name, description, or tags..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="plugin-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<input type="text" id="plugin-search" placeholder="Search plugins by name, description, or tags..." aria-label="Search the Plugin Store" class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="plugin-category" aria-label="Filter the Plugin Store by category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<option value="">All Categories</option>
<option value="sports">Sports</option>
<option value="content">Content</option>
@@ -262,7 +263,7 @@
<!-- Sort & Filter Bar -->
<div id="store-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Sort -->
<select id="store-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<select id="store-sort" aria-label="Sort the Plugin Store" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="a-z">A → Z</option>
<option value="z-a">Z → A</option>
<option value="category">Category</option>
@@ -289,13 +290,13 @@
<!-- Results Bar (top pagination) -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<span id="store-results-info" class="text-sm text-gray-600"></span>
<div class="flex items-center gap-3">
<select id="store-per-page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<div class="flex flex-wrap items-center gap-3">
<select id="store-per-page" aria-label="Plugins per page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<option value="12">12 per page</option>
<option value="24">24 per page</option>
<option value="48">48 per page</option>
</select>
<div id="store-pagination-top" class="flex items-center gap-1"></div>
<div id="store-pagination-top" class="flex flex-wrap items-center gap-1"></div>
</div>
</div>
@@ -355,8 +356,8 @@
<!-- Search Row -->
<div class="flex gap-3 mb-4">
<input type="text" id="starlark-search" placeholder="Search by name, description, author..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="starlark-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<input type="text" id="starlark-search" placeholder="Search by name, description, author..." aria-label="Search Starlark apps" class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="starlark-category" aria-label="Filter Starlark apps by category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<option value="">All Categories</option>
</select>
</div>
@@ -364,7 +365,7 @@
<!-- Sort & Filter Bar -->
<div id="starlark-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Sort -->
<select id="starlark-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<select id="starlark-sort" aria-label="Sort Starlark apps" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="a-z">A → Z</option>
<option value="z-a">Z → A</option>
<option value="category">Category</option>
@@ -379,7 +380,7 @@
</button>
<!-- Author filter -->
<select id="starlark-filter-author" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<select id="starlark-filter-author" aria-label="Filter Starlark apps by author" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="">All Authors</option>
</select>
@@ -395,13 +396,13 @@
<!-- Results Bar (top pagination) -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<span id="starlark-results-info" class="text-sm text-gray-600"></span>
<div class="flex items-center gap-3">
<select id="starlark-per-page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<div class="flex flex-wrap items-center gap-3">
<select id="starlark-per-page" aria-label="Starlark apps per page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<option value="24">24 per page</option>
<option value="48">48 per page</option>
<option value="96">96 per page</option>
</select>
<div id="starlark-pagination-top" class="flex items-center gap-1"></div>
<div id="starlark-pagination-top" class="flex flex-wrap items-center gap-1"></div>
</div>
</div>
@@ -463,7 +464,7 @@
<p class="text-xs text-gray-600 mb-3">Install a plugin directly from its GitHub repository URL</p>
<div class="space-y-2">
<div class="flex gap-2">
<input type="text" id="github-plugin-url"
<input type="text" id="github-plugin-url" aria-label="Plugin GitHub repository URL"
placeholder="https://github.com/user/ledmatrix-plugin-name"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<button type="button" id="install-plugin-from-url"
@@ -491,7 +492,7 @@
</h4>
<p class="text-xs text-gray-600 mb-3">Load a registry-style monorepo (like the official ledmatrix-plugins repo) to browse and install plugins</p>
<div class="flex gap-2 mb-2">
<input type="text" id="github-registry-url"
<input type="text" id="github-registry-url" aria-label="Plugin registry GitHub URL"
placeholder="https://github.com/user/ledmatrix-plugins"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<button id="load-registry-from-url" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm rounded-md whitespace-nowrap">
@@ -4,7 +4,7 @@
<div class="border-b border-gray-200 pb-4 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">config.json Editor</h2>
<h2 id="main-config-editor-title" class="text-lg font-semibold text-gray-900">config.json Editor</h2>
<p class="mt-1 text-sm text-gray-600">{{ main_config_path }}</p>
</div>
<div class="flex gap-2">
@@ -28,7 +28,7 @@
</div>
<div class="relative">
<textarea id="main-config-editor"
<textarea id="main-config-editor" aria-labelledby="main-config-editor-title"
class="w-full h-96 font-mono text-sm p-4 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
spellcheck="false">{{ main_config_json }}</textarea>
<div id="main-config-validation" class="mt-2 text-sm"></div>
@@ -55,7 +55,7 @@
<div class="border-b border-gray-200 pb-4 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">config_secrets.json Editor</h2>
<h2 id="secrets-config-editor-title" class="text-lg font-semibold text-gray-900">config_secrets.json Editor</h2>
<p class="mt-1 text-sm text-gray-600">{{ secrets_config_path }}</p>
</div>
<div class="flex gap-2">
@@ -79,7 +79,7 @@
</div>
<div class="relative">
<textarea id="secrets-config-editor"
<textarea id="secrets-config-editor" aria-labelledby="secrets-config-editor-title"
class="w-full h-96 font-mono text-sm p-4 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
spellcheck="false">{{ secrets_config_json }}</textarea>
<div id="secrets-config-validation" class="mt-2 text-sm"></div>
@@ -1,3 +1,6 @@
{# Prefix for label/input id pairs; app ids and field ids are sanitised so
the ids stay valid and unique if several app configs are on the page. #}
{% set sl_prefix = ('starlark-' ~ app_id)|replace(':', '-')|replace('.', '-')|replace(' ', '-')|replace('/', '-') %}
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between pb-4 border-b border-gray-200">
@@ -59,16 +62,18 @@
<div id="starlark-config-form" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">Render Interval (seconds)</label>
<label for="{{ sl_prefix }}-render-interval" class="block text-sm font-medium text-gray-700 mb-1">Render Interval (seconds)</label>
<input type="number" min="10" max="86400" step="1"
id="{{ sl_prefix }}-render-interval"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ render_interval }}"
data-starlark-config="render_interval">
<p class="text-xs text-gray-400 mt-1">How often the app re-renders (fetches new data)</p>
</div>
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
<label for="{{ sl_prefix }}-display-duration" class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
<input type="number" min="1" max="3600" step="1"
id="{{ sl_prefix }}-display-duration"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ display_duration }}"
data-starlark-config="display_duration">
@@ -98,12 +103,14 @@
{% set field_desc = field.desc or '' %}
{% set field_default = field.default if field.default is defined else '' %}
{% set current_val = config.get(field_id, field_default) if config else field_default %}
{% set input_id = (sl_prefix ~ '-f-' ~ field_id)|replace(' ', '-')|replace('.', '-')|replace(':', '-')|replace('/', '-') %}
{# ── text ── #}
{% if field_type == 'text' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
placeholder="{{ field_desc }}"
@@ -116,8 +123,8 @@
{# ── dropdown ── #}
{% elif field_type == 'dropdown' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<select class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white"
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<select id="{{ input_id }}" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white"
data-starlark-config="{{ field_id }}">
{% for opt in (field.options or []) %}
<option value="{{ opt.value }}" {{ 'selected' if current_val|string == opt.value|string else '' }}>
@@ -149,14 +156,16 @@
{# ── color ── #}
{% elif field_type == 'color' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="flex items-center gap-2">
<input type="color"
aria-label="{{ field_name }} color picker"
class="w-10 h-10 rounded border border-gray-300 cursor-pointer p-0.5"
value="{{ current_val or '#FFFFFF' }}"
data-starlark-color-picker="{{ field_id }}"
oninput="this.closest('.space-y-6').querySelector('[data-starlark-config={{ field_id }}]').value = this.value">
<input type="text"
id="{{ input_id }}"
class="form-control flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm font-mono"
value="{{ current_val or '#FFFFFF' }}"
placeholder="#RRGGBB"
@@ -171,8 +180,9 @@
{# ── datetime ── #}
{% elif field_type == 'datetime' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="datetime-local"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
data-starlark-config="{{ field_id }}">
@@ -184,10 +194,11 @@
{# ── location (mini-form) ── #}
{% elif field_type == 'location' %}
<div class="form-group" data-starlark-location-group="{{ field_id }}" data-starlark-location-value="{{ current_val }}">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<label id="{{ input_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2" role="group" aria-labelledby="{{ input_id }}-label">
<div>
<input type="number" step="any" min="-90" max="90"
aria-label="{{ field_name }} latitude"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Latitude"
data-starlark-location-field="{{ field_id }}"
@@ -195,6 +206,7 @@
</div>
<div>
<input type="number" step="any" min="-180" max="180"
aria-label="{{ field_name }} longitude"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Longitude"
data-starlark-location-field="{{ field_id }}"
@@ -204,6 +216,7 @@
<input type="text"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Timezone (e.g. America/New_York)"
aria-label="{{ field_name }} timezone"
data-starlark-location-field="{{ field_id }}"
data-starlark-location-key="timezone">
</div>
@@ -243,8 +256,9 @@
{# ── typeahead / location_based (text fallback with note) ── #}
{% elif field_type in ('typeahead', 'location_based') %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
placeholder="{{ field_desc }}"
@@ -261,8 +275,9 @@
{# ── unknown type (text fallback) ── #}
{% else %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
placeholder="{{ field_desc }}"
@@ -287,8 +302,9 @@
{% for key, value in config.items() %}
{% if key not in ('render_interval', 'display_duration') and key not in schema_ids %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }} <span class="text-xs text-gray-400">(custom)</span></label>
<input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
{% set custom_id = (sl_prefix ~ '-c-' ~ key)|replace(' ', '-')|replace('.', '-')|replace(':', '-')|replace('/', '-') %}
<label for="{{ custom_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ key }} <span class="text-xs text-gray-400">(custom)</span></label>
<input type="text" id="{{ custom_id }}" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ value }}"
data-starlark-config="{{ key }}">
</div>
@@ -303,8 +319,9 @@
{% for key, value in config.items() %}
{% if key not in ('render_interval', 'display_duration') %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }}</label>
<input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
{% set raw_id = (sl_prefix ~ '-r-' ~ key)|replace(' ', '-')|replace('.', '-')|replace(':', '-')|replace('/', '-') %}
<label for="{{ raw_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ key }}</label>
<input type="text" id="{{ raw_id }}" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ value }}"
data-starlark-config="{{ key }}">
</div>
+37 -31
View File
@@ -32,7 +32,7 @@
<div class="space-y-4">
<!-- Switch branch -->
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">Branch</p>
<p class="text-xs text-gray-500 mt-0.5">Choose which branch this pi follows. Switching attaches tracking, so Pull Latest works afterwards.</p>
@@ -50,7 +50,7 @@
<div id="result-checkout-branch" class="hidden"></div>
<!-- Pull latest -->
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Pull latest (rebase)</p>
<p class="text-xs text-gray-500 mt-0.5">Stashes any local changes, then runs <code class="bg-gray-100 px-1 rounded">git pull --rebase</code>. The stash is preserved but not re-applied.</p>
@@ -63,7 +63,7 @@
<div id="result-git-pull" class="hidden"></div>
<!-- Force reset -->
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Force reset to <code class="bg-gray-100 px-1 rounded">origin/main</code></p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">git fetch origin</code> then <code class="bg-gray-100 px-1 rounded">git reset --hard origin/main</code>. Discards all local changes.</p>
@@ -99,7 +99,7 @@
<div class="space-y-4">
<!-- Base requirements -->
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Reinstall base requirements</p>
<p class="text-xs text-gray-500 mt-0.5">Installs from <code class="bg-gray-100 px-1 rounded">requirements.txt</code> in the project root.</p>
@@ -112,7 +112,7 @@
<div id="result-base-reqs" class="hidden"></div>
<!-- Plugin requirements -->
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Reinstall plugin requirements</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">pip install</code> for every installed plugin that has a <code class="bg-gray-100 px-1 rounded">requirements.txt</code>.</p>
@@ -134,7 +134,7 @@
</div>
<div class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Clear Python cache</p>
<p class="text-xs text-gray-500 mt-0.5">Deletes all <code class="bg-gray-100 px-1 rounded">__pycache__</code> directories in the project. Useful after switching branches or debugging import issues.</p>
@@ -166,14 +166,15 @@
<p class="mt-1 text-sm text-gray-600">Turn the WiFi radio on or off. Full WiFi setup (scan, connect, hotspot) lives on the <span class="font-medium">WiFi</span> tab.</p>
</div>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">WiFi radio</p>
<p id="wifi-radio-label" class="text-sm font-medium text-gray-900">WiFi radio</p>
<p id="wifi-radio-note" class="text-xs text-gray-500 mt-0.5">Checking current state…</p>
</div>
<div class="shrink-0 flex flex-col items-end gap-2">
<!-- Toggle switch -->
<button id="wifi-radio-toggle" type="button" role="switch" aria-checked="false"
aria-labelledby="wifi-radio-label" aria-describedby="wifi-radio-note"
onclick="onWifiRadioToggleClick()" disabled
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-gray-200 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 opacity-60">
<span id="wifi-radio-knob" aria-hidden="true"
@@ -207,7 +208,7 @@
</div>
<div class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Restart display service</p>
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix.service</code>.</p>
@@ -219,7 +220,7 @@
</div>
<div id="result-restart-display" class="hidden"></div>
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Restart web interface</p>
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix-web.service</code>. The page will go offline briefly.</p>
@@ -282,7 +283,7 @@
<div class="space-y-4">
<!-- Reboot -->
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Reboot</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">sudo reboot</code>. The Pi will restart and come back online in a minute or two.</p>
@@ -308,7 +309,7 @@
<div id="result-reboot" class="hidden"></div>
<!-- Shutdown -->
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Shut down</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">sudo poweroff</code>. The Pi will power off and must be unplugged/replugged (or power-cycled) to turn back on.</p>
@@ -979,26 +980,31 @@
loadGitInfo();
loadBranches();
// Plugin health: initial load + periodic refresh. Guard against duplicate
// timers if this partial is re-swapped in by HTMX; the handler re-resolves
// DOM nodes by id each tick.
refreshPluginHealth(false);
if (!window._pluginHealthTimer) {
window._pluginHealthTimer = setInterval(function () { refreshPluginHealth(true); }, 15000);
// Plugin health (15s) and system diagnostics (10s): the partial stays in
// the DOM (hidden via x-show) when another tab is active, so both polls
// run only while the Tools tab is active and the page is visible — each
// restarts with an immediate refresh. LEDVisibility keys the registration,
// so a partial re-swapped in by HTMX replaces (not stacks) the timers.
function stopToolsPolling() {
if (window._pluginHealthTimer) { clearInterval(window._pluginHealthTimer); window._pluginHealthTimer = null; }
if (window._diagPollInterval) { clearInterval(window._diagPollInterval); window._diagPollInterval = null; }
}
// System diagnostics: load now, then refresh every 10s. Clear any prior
// interval so re-swapping the partial doesn't stack. The recurring poll is
// gated on visibility — the partial stays in the DOM (hidden via x-show)
// when another tab is active, so without this it would keep hitting
// /api/v3/system/status every 10s and churn the Pi while off-screen.
if (window._diagPollInterval) clearInterval(window._diagPollInterval);
window.loadSystemDiagnostics();
window._diagPollInterval = setInterval(function () {
const panel = document.getElementById('diag-panel');
if (!panel || document.hidden || panel.offsetParent === null) return;
function startToolsPolling() {
stopToolsPolling();
refreshPluginHealth(false);
window._pluginHealthTimer = setInterval(function () { refreshPluginHealth(true); }, 15000);
window.loadSystemDiagnostics();
}, 10000);
window._diagPollInterval = setInterval(function () {
const panel = document.getElementById('diag-panel');
if (!panel || document.hidden || panel.offsetParent === null) return;
window.loadSystemDiagnostics();
}, 10000);
}
if (window.LEDVisibility) {
window.LEDVisibility.onActive('tools', startToolsPolling, stopToolsPolling);
} else {
startToolsPolling();
}
// WiFi radio current state.
window.loadWifiRadio();
@@ -1223,7 +1229,7 @@
const active = status.running ? `
<div class="rounded-md border border-amber-300 bg-amber-50 p-3">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-amber-900">
<i class="fas fa-circle-notch fa-spin mr-1"></i>
@@ -159,6 +159,9 @@
</div>
<div class="flex-shrink-0">
<button type="button"
role="switch"
aria-label="Auto-Enable AP Mode"
:aria-checked="status.auto_enable_ap_mode ? 'true' : 'false'"
@click="toggleAutoEnable(!status.auto_enable_ap_mode)"
:class="status.auto_enable_ap_mode
? 'bg-blue-600 hover:bg-blue-700'
+107
View File
@@ -0,0 +1,107 @@
"""Single-request bundle for the config-form widget scripts.
Every page used to carry one <script> tag per widget (34 of them, ~700 KB
uncompressed). On a Pi Zero 2 W serving a phone over WiFi, each tag is a
separate request the Python server has to answer. The files are plain scripts
that register themselves on window/LEDMatrixWidgets at load time, so
concatenating them in the same order is exactly equivalent to loading them
one by one — no module wrapper, no scope change.
The individual files stay on disk and keep working as direct URLs, which is
what plugin-loader.js and any third-party page expect.
BUNDLE_ORDER is the authority for which widgets ship; test_widget_scripts.py
checks it against the directory so a new widget can't be forgotten.
"""
import hashlib
from pathlib import Path
from threading import Lock
WIDGETS_DIR = Path(__file__).parent / "static" / "v3" / "js" / "widgets"
# Load order matters: the registry and base class must exist before the
# widgets that call them, and notification.js owns window.showNotification.
BUNDLE_ORDER = [
"registry.js",
"base-widget.js",
"notification.js",
"plugin-order-list.js",
"file-upload.js",
"checkbox-group.js",
"custom-feeds.js",
"array-table.js",
"google-calendar-picker.js",
"google-oauth.js",
"day-selector.js",
"time-range.js",
"time-picker.js",
"file-upload-single.js",
"plugin-file-manager.js",
"schedule-picker.js",
# Basic input widgets
"text-input.js",
"number-input.js",
"textarea.js",
"select-dropdown.js",
"font-selector.js",
"style-editor.js",
"toggle-switch.js",
"radio-group.js",
"date-picker.js",
"slider.js",
"color-picker.js",
"email-input.js",
"url-input.js",
"password-input.js",
"timezone-selector.js",
"plugin-loader.js",
# Reusable JSON file manager (used via x-widget: json-file-manager)
"json-file-manager.js",
]
# Widget files that must NOT be bundled, with the reason.
EXCLUDED = {
# Documentation example (docs/widget-guide.md); it registers the name
# 'color-picker' and would shadow the real color-picker.js.
"example-color-picker.js": "documentation example",
}
_lock = Lock()
_cache = {"version": None, "body": None}
def bundle_paths():
"""Widget files in load order (only those present on disk)."""
return [WIDGETS_DIR / name for name in BUNDLE_ORDER if (WIDGETS_DIR / name).is_file()]
def bundle_version():
"""Cache-busting token: a fingerprint of every bundled file.
Uses each file's name, nanosecond mtime and size, so an edit that lands
in the same second as another file's, or leaves mtime behind the newest
file, still changes the token (and the URL).
"""
digest = hashlib.sha256()
for path in bundle_paths():
stat = path.stat()
digest.update(f"{path.name}:{stat.st_mtime_ns}:{stat.st_size};".encode())
return digest.hexdigest()[:12]
def build_bundle():
"""Concatenated widget sources, rebuilt only when a file changes."""
version = bundle_version()
with _lock:
if _cache["version"] == version and _cache["body"] is not None:
return _cache["body"], version
parts = []
for path in bundle_paths():
source = path.read_text(encoding="utf-8")
# A file ending in a line comment would swallow the next file's
# first line, and a missing semicolon can join two statements.
parts.append("/* %s */\n%s\n;\n" % (path.name, source))
body = "".join(parts)
_cache["version"] = version
_cache["body"] = body
return body, version