Compare commits

...
10 Commits
Author SHA1 Message Date
ChuckandClaude Opus 5 6d1cbfb70b fix(web): plugin config page survives stored values the schema outgrew (#578)
Two stored shapes broke the config form:

* A scalar under a field that is now an object. News' dynamic_duration
  was a boolean and is becoming an object; render_nested_section did
  `key in true` and the whole page failed to render. Look into dicts
  only, and carry a legacy boolean over as the object's `enabled`, so
  the next save upgrades it without switching the feature off.
* A custom feed logo with a path but no id. The template always emitted
  an empty `logo.id` input, which the save route parsed to null, failing
  the id's string type on every save. Emit it only when there is an id,
  as custom-feeds.js already does.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:03:15 -04:00
ChuckandClaude Opus 5 8e6d7c280f test(element-style): cover the stateless element_color clamp path (#576)
#569 fixed _normalize_color and #572 covered the resolver path. That test's own
docstring notes the resolver "normalizes colour separately from element_color",
and the other path had no test: the stateless element_color(), which
src.common.sports_card delegates to and which every one of the nine scoreboard
plugins takes for each per-element colour it draws.

That is the path that regressed. element_color() moved here with the per-element
customization framework, the coercion rejected out-of-range components where the
reader it replaced clamped them, and a rejection reads as "not configured" -- so
one component over 255 painted the element white while the user's colour sat in
their config. Every scoreboard's test_element_text_colors.py failed on it, and
it took two plugin PRs red on CI to surface.

Six cases: clamping, in-range untouched, hex, unparseable fallback, missing
element, and agreement with sports_card.coerce_rgb. The last is the point --
the two shared readers disagreed about the same value, so this asserts against
coerce_rgb directly rather than restating the arithmetic, and any future move
of element_color has to keep them consistent.

Verified by mutation: restoring the rejecting coercion fails two of the six,
alongside the resolver test from #572.

Tests only; no source change.


Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:27:10 -04:00
ChuckandClaude Opus 5 dac71afedc fix(web): full-height plugin config form and full-width plugin card descriptions (#573)
* fix(web): let the plugin config form use the full page height

The form wrapper has carried `max-h-96 overflow-y-auto` since #145, but
the class was a no-op until #568 defined `.max-h-96` in app.css. That
silently capped the whole config form at 24rem with a nested scrollbar.
Drop the cap so the form flows naturally and the page scrolls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): give installed plugin card descriptions the full card width

The enable/disable toggle was a flex sibling of the whole text column
(name, metadata, description), so it reserved its width for the full
height of the card body. Descriptions wrapped into a narrow strip,
leaving blank space under the toggle and making cards very tall.

Move the toggle into a header row with just the name and badges, and
render the metadata and description below at full width.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:27:00 -04:00
ChuckandClaude Opus 5 a6b3384032 fix(web): show the action script's error in the file-manager widgets (#574)
A failing plugin action returns a 400 whose JSON body carries the
script's own message, but both file-manager widgets threw it away:
plugin-file-manager's toggle always said "Toggle failed", and
json-file-manager's request helper threw "Server error 400" before
reading the body. That hid of-the-day's "Category ... not found in
config", which is why its toggles looked broken for no reason.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:26:46 -04:00
ChuckandClaude Opus 5 11bf39cd66 fix(web): two plugin config saves that always returned 400 (geochron, news) (#575)
* fix(web): render widget-less arrays of objects as a table, not comma text

An array of objects with no x-widget (geochron's `cities`) fell through to
the comma-separated text input. Jinja joined each item as a Python dict
repr, the save route read them back as a list of strings, and the schema
rejected them -- so every save of the plugin returned 400 "Configuration
validation failed", whatever setting was changed.

Default such arrays to the existing array-table widget, which already
edits arrays of objects and posts `field.N.key` inputs the save route
rebuilds into a list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): don't leave an empty object stub in array items on save

The unchecked-checkbox pass walked into every nested object of an array
item looking for booleans, creating it when absent. A news custom feed
with no logo came out with `logo: {}`, which fails the logo's
`required: [id, path]`, so every save of the news plugin returned 400.

Recurse into a scratch dict instead and attach it only if a boolean was
actually set in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:04:11 -04:00
ChuckandClaude Sonnet 5 bc60b41445 test(element-style): cover ElementStyleResolver's colour clamp path (#572)
* fix(colour): clamp out-of-range text_color components instead of dropping them

_normalize_color returned None for a triple with a component outside 0..255,
and None means "not configured" to element_color -- so configuring
[300, 0, 20] silently handed the element its *default* colour rather than red.
Every scoreboard reads its per-element colours through this path, so the bug
reached all eight.

It is also the odd one out: sports_card.coerce_rgb and
SportsShared._coerce_rgb both clamp, and core's own test is named
test_coerce_rgb_clamps_rather_than_rejecting. The rejecting normaliser arrived
with the shared readers in 82a65ad2 (#425) while the eight plugins' colour
tests kept asserting the clamping behaviour they had before, so the two sides
have disagreed ever since.

Clamped inline rather than delegating to coerce_rgb: sports_card already
imports element_style, so importing back would be circular.

Adds the core assertion whose absence let this drift -- element_color had no
test covering an out-of-range component.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(element-style): cover ElementStyleResolver's own colour clamp path

CodeRabbit flagged that the new sports_card clamp regression test only
exercises element_color(); ElementStyleResolver._resolve() normalizes
configured colours through a separate call to the same _normalize_color,
comparing against a schema/classic reference to decide user_forced_color.
Add a resolver-level case so a future regression in that path (e.g. going
back to rejecting out-of-range components instead of clamping) is caught
too.

Mutation-checked: fails if _normalize_color rejects instead of clamps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP6kWxjUtJi72c56GaMmC8

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:03:23 -04:00
ChuckandClaude Opus 5 f9b1f87e8d fix: clamp colour components, and let the style editor actually take over (#569)
* fix(element-style): clamp out-of-range colour components instead of rejecting

A regression this framework shipped. The eight scoreboards used to read their
colours through sports_card.coerce_rgb, which clamps; routing them through the
shared element_color sent them through _normalize_color, which rejected any
component outside 0..255 and fell back to the default. So a configured
[999, -5, 20] -- a typo'd bright red -- rendered white instead of (255, 0, 20).

Their own test_element_text_colors.py caught it: one case of nineteen, in all
eight plugins, failing only once the core change reached main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): the style editor takes over its own blocks -- and gets to at all

Two defects, both found by rendering the real partial in a browser rather than
by reading the code.

It was losing a race to its own fields. The hand-off guard asked "do any
fallback controls differ from their server-rendered defaults?" as a proxy for
"is someone editing this?". But the fallback holds this block's own font
fields, and the font-selector widget populates them on the same 50ms timer --
so a plain page load, with nobody touching anything, raced into "dirty" and the
editor removed itself, leaving the 701-line accordion form it exists to
replace. Measured: seven customization.*.font selects dirty ~60ms after
injection, clean again by 400ms. The question is whether a *person* typed, and
event.isTrusted answers exactly that; the listeners now go on synchronously,
because the edit worth protecting can happen before initWidget runs.

It took over too much. Taking over removed the whole fallback section, but a
customization block can hold more than styling -- football keeps
favorite_result_colors there -- so that removed the only UI those fields had,
and the editor also rendered them as an element, giving every row an "enabled"
and three colour columns. Core now marks the blocks it recognises as styling
(the compact declaration already did; hand-written adoption did not), the
widget renders only those, and the template drops only the children the widget
reports owning.

Verified on football's real schema: 28 rows across four mode tabs, columns
Element/Font/Size/Colour/X/Y, favorite_result_colors still editable with its
ten inputs, no duplicated field names, no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): style editor no longer drops layout-only fields it never rendered

CodeRabbit flagged elementKeys() in style-editor.js: render() claims the
whole customization.layout child as the widget's own (removing it from the
generic fallback renderer, since posting the same offset twice is worse),
but elementKeys() only listed keys that also have their own top-level style
block. A hand-written schema can put a key under layout that never got one
-- a logo, a timeout indicator, a possession arrow with a position but no
font or colour -- and that key's only control silently disappeared: no row
in the style editor's table (elementKeys never listed it) and no fallback
section either (layout was removed wholesale).

elementKeys() now appends any layout-declared key not already covered by a
style element, so table() renders a row for it (layout columns only, no
style columns) and the wholesale layout ownership claim stays truthful.

Verified against current code before fixing. New regression test
(test/js/unit/test_style_editor_element_keys.js, following this repo's
existing eval-extraction pattern for testing widget JS without a browser)
fails against the reverted function and passes with the fix; added to
run_all.js and the suite table in test/js/README.md.

Full pytest suite: 4887 passed, 62 skipped, 2 failed -- both the
pre-existing Europe/Kiev/Asia/Calcutta tzdata-alias gap on this sandbox,
identical on origin/main, unrelated to this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dpg3HLWohdCUdzz2QNHanm

* fix(web): style editor no longer strands leaf-valued layout fields

A prior fix on this PR made elementKeys() append any layout-only key with
no style block of its own (a logo, a timeout indicator, a possession
arrow), so table() draws a row for it instead of losing it when the
wholesale `layout` claim removes the generic fallback. That covers a
layout-only key shaped like an object (x_offset/y_offset, ...), because
columnsFor() only ever produced columns from a key's *sub-fields*.

It missed the case where the layout-only key's own value is itself a
leaf -- a plain "show_logo" boolean directly under layout, no x/y object
underneath. elementKeys() still lists it (any row: no matching column),
so it renders as an uneditable blank row and its only control -- the
generic fallback checkbox -- is still gone. Confirmed by executing the
real widget's render() against a synthetic schema in Node (a DOM-stub
harness, not committed): the field's name never appeared as an <input>.

columnsFor() now gives such a leaf key a column keyed to itself
('layout-leaf'), and elementRow() binds it to the leaf's own path
(customization.layout.<key>, matching the name the fallback would have
used) instead of leaving every cell blank.

New regression test (test/js/unit/test_style_editor_layout_leaf_columns.js,
following this PR's existing eval-extraction pattern) checks the leaf
column is produced, is self-keyed, doesn't duplicate, and that a schema
with no leaf-valued layout key is unaffected; wired into run_all.js and
the suite table in test/js/README.md.

test/js/run_all.js: 84 + 6 + 6 = all suites passed (jsdom unavailable
here, DOM suites skip as before). Python suite untouched by this change;
test_style_editor_extra_fields.py, test_style_editor_save_roundtrip.py
and the one PIL-dependent style_editor_takeover.py case fail identically
before this commit -- missing flask/PIL in this sandbox, not this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): keep layout-leaf style-editor columns distinct from name collisions

columnsFor() keyed a layout-only leaf field's column by its bare field
name. If an unrelated element's style block or another element's layout
axis block happened to declare a sub-field with that same name, the
`!seen.has(key)` guard skipped creating the leaf's column, silently
dropping its only control again -- the same failure the leaf-column fix
was meant to close, just reached through a name collision (CodeRabbit
review on 324a7ea).

Key layout-leaf columns under a namespaced id so they can never be
shadowed by an unrelated column sharing their name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): CSS.escape() the owned key before it becomes a selector

container.dataset.ownedKeys round-trips schema property keys through a
DOM dataset attribute, and the takeover handoff spliced each one
straight into '[data-child-key="' + k + '"]' with no escaping --
inconsistent with this codebase's own convention elsewhere
(plugin-file-manager.js, app-shell.js's escapeCssSelector) for building
a selector from a dynamic value. A key containing a quote or backslash
would break the selector or be steerable; Codacy's static analysis
flagged this pattern (1 high ErrorProne finding on PR #569, current
head at the time) as a new issue, though its dashboard is unreachable
from this sandbox (egress to app.codacy.com is blocked) and the
check-run API returned no detail text -- verified and fixed by reading
the diff directly rather than the tool's own description.

Added a source-assertion regression test alongside this file's
existing ones (this behavior lives in an inline script no Python test
executes).

Full suite: 4888 passed, 62 skipped, 2 failed -- both the pre-existing
Europe/Kiev/Asia/Calcutta tzdata-alias gap in this sandbox, identical
on origin/main, unrelated to this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(web): every advertised layout offset gets a control in the style editor

The editor took the whole layout section over but matched offsets to style
rows by exact key. A hand-written schema's two blocks were never named alike --
football styles score_text but positions score -- so of football's eleven
positionable things only status_text had a control. Score, odds, both logos,
timeouts, possession, down-and-distance, date, time and records were options
the schema advertised and the renderer reads, reachable nowhere in the UI.

Core now resolves each style element's layout key through alias_keys, the map
the resolver already reads offsets with, and records it as x-layout-key. The
widget reads that rather than carrying a second copy of the rules, and posts
under the key the schema declares: football's own offset reader looks up
layout.score, so a value saved as layout.score_text would be kept and never
drawn. Layout entries no style element claims get an "Other positions" table
with its own columns, in every mode panel as well as the base one, in the order
the plugin declared them.

Verified in a browser against football's real schema: 92 of 92 layout fields
(23 base, 23 per mode) rendered exactly once under their declared names, none
posted under a style key, no duplicated field names, favorite_result_colors
still editable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:48:36 -04:00
ChuckandClaude Opus 5 7e580dc005 fix(wifi): make Connect work from the setup AP (#571)
* fix(wifi): make Connect work from the setup AP

Joining a network from LEDMatrix-Setup has to take the AP down first, which
drops the phone that sent the request. The connect endpoint answered only
after the attempt finished, so the browser never got a reply and the WiFi
tab's Connect button appeared to do nothing.

- /wifi/connect answers 202 immediately while the AP is active and connects
  in a background thread; the result (never the password) is reported via
  /wifi/status as last_connect_attempt. A second connect while one is
  pending gets 409.
- connect_to_network holds a /tmp flag for the attempt; the monitor daemon
  skips AP management while it is fresh. Previously the daemon's
  disconnected counter, accumulated over the whole AP session, re-enabled
  the AP on its next tick in the middle of the connect.
- The WiFi tab and captive setup page explain the handoff up front, and on
  reopening show why the last attempt failed. The wrong-password message
  now works: the route sets the error_type the captive page checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(wifi): serialize connect attempts on both paths

Addresses CodeRabbit review on #571:

- Check for a pending attempt before branching on AP state. A background
  attempt takes the AP down long before it finishes, so a second click
  used to bypass the 409 and start a competing synchronous connect.
- Record pending for the synchronous (non-AP) path too, so two requests
  can't overlap and have the first clear the daemon's in-progress flag
  while the second is still connecting.
- Clear the pending state if the background thread fails to start, rather
  than refusing every later request until restart.
- Say the setup network returns "within a few minutes": a stale flag plus
  the daemon's grace period can take longer than one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:40:41 -04:00
ChuckandClaude Opus 5 d51f7ada14 chore(scroll): drop the dead sub-pixel path, and two dev-tooling papercuts (#570)
Three independent changes, none of which alter runtime behaviour.

1. Remove ScrollHelper._get_visible_portion_subpixel and
   _interpolate_subpixel (162 lines). get_visible_portion dispatches only to
   _blend_visible_portion, so _get_visible_portion_subpixel had no caller, and
   _interpolate_subpixel was reachable only from inside it -- a closed island.
   _blend_visible_portion's own docstring already records that the scipy path
   it replaced was dead; the replacement landed but the corpse stayed.

2. scripts/check_plugin.py: also search ../ledmatrix-plugins/plugins. The
   scoreboards live in the sibling checkout, so --all silently skipped every
   one of them and only --plugin-dir reached them.

3. .gitignore: ignore config/.config_secrets.json.tmp.*, which the suite
   leaves behind several of per run.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:42:50 -04:00
ChuckandClaude Opus 5 d1e821c625 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>
2026-09-14 09:42:24 -04:00
66 changed files with 4683 additions and 1137 deletions
+3
View File
@@ -5,6 +5,9 @@ __pycache__/
# Secrets
config/config_secrets.json
# Atomic writes leave these behind when a save or a test is interrupted;
# the suite drops several per run.
config/.config_secrets.json.tmp.*
config/config.json
config/config.json.backup
config/wifi_config.json
+3
View File
@@ -78,6 +78,9 @@ logger = get_logger("[Check Plugin]")
DEFAULT_SEARCH_DIRS = [
str(PROJECT_ROOT / 'plugins'),
str(PROJECT_ROOT / 'plugin-repos'),
# The scoreboards live in the sibling ledmatrix-plugins checkout, not
# in this repo. Without this, --all silently skips every one of them.
str(PROJECT_ROOT.parent / 'ledmatrix-plugins' / 'plugins'),
]
-162
View File
@@ -534,168 +534,6 @@ class ScrollHelper:
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
"""
Get visible portion with sub-pixel interpolation for smooth scrolling.
Uses bilinear interpolation to blend between pixels.
"""
# We need to extract a region that's 1 pixel wider to allow for interpolation
start_x = start_x_int
end_x = start_x_int + self.display_width + 1
# Check if we need wrap-around
if end_x <= self.cached_image.width:
# Normal case: extract region with 1 extra pixel for interpolation
source_region = self.cached_array[:, start_x:end_x]
# Use bilinear interpolation for sub-pixel shifting
if HAS_SCIPY:
# Use scipy for high-quality sub-pixel shifting
shifted = shift(source_region, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
# Extract the display_width portion
frame_array = shifted[:, :self.display_width].astype(np.uint8)
else:
# Fallback: simple linear interpolation using numpy
# Blend between current and next pixel based on fractional part
frame_array = self._interpolate_subpixel(source_region, fractional)
return Image.fromarray(frame_array)
else:
# Wrap-around case with sub-pixel
# Use pre-allocated buffer
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
width1 = self.cached_image.width - start_x
if width1 > 0:
# First part from end of image
# Need width1 + 1 pixels for interpolation
source1_width = min(width1 + 1, self.cached_image.width - start_x)
source1 = self.cached_array[:, start_x:start_x + source1_width]
if HAS_SCIPY:
shifted1 = shift(source1, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
# Ensure we get exactly width1 pixels, padding if necessary
if shifted1.shape[1] >= width1:
self._frame_buffer[:, :width1] = shifted1[:, :width1].astype(np.uint8)
else:
# Shifted array is smaller - pad with zeros or repeat last pixel
actual_width = shifted1.shape[1]
self._frame_buffer[:, :actual_width] = shifted1.astype(np.uint8)
if actual_width < width1:
# Pad with last pixel
self._frame_buffer[:, actual_width:width1] = shifted1[:, -1:].astype(np.uint8)
else:
interpolated1 = self._interpolate_subpixel(source1, fractional, output_width=width1)
# Ensure exact width match
if interpolated1.shape[1] == width1:
self._frame_buffer[:, :width1] = interpolated1
else:
# Handle size mismatch
copy_width = min(width1, interpolated1.shape[1])
self._frame_buffer[:, :copy_width] = interpolated1[:, :copy_width]
if copy_width < width1:
self._frame_buffer[:, copy_width:width1] = interpolated1[:, -1:]
# Second part from beginning
remaining_width = self.display_width - width1
if remaining_width > 0:
source2 = self.cached_array[:, :remaining_width + 1]
if HAS_SCIPY:
shifted2 = shift(source2, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
# Ensure we get exactly remaining_width pixels
if shifted2.shape[1] >= remaining_width:
self._frame_buffer[:, width1:width1 + remaining_width] = shifted2[:, :remaining_width].astype(np.uint8)
else:
# Shifted array is smaller - pad if necessary
actual_width = shifted2.shape[1]
self._frame_buffer[:, width1:width1 + actual_width] = shifted2.astype(np.uint8)
if actual_width < remaining_width:
self._frame_buffer[:, width1 + actual_width:width1 + remaining_width] = shifted2[:, -1:].astype(np.uint8)
else:
interpolated2 = self._interpolate_subpixel(source2, fractional, output_width=remaining_width)
# Ensure exact width match
if interpolated2.shape[1] == remaining_width:
self._frame_buffer[:, width1:] = interpolated2
else:
copy_width = min(remaining_width, interpolated2.shape[1])
self._frame_buffer[:, width1:width1 + copy_width] = interpolated2[:, :copy_width]
if copy_width < remaining_width:
self._frame_buffer[:, width1 + copy_width:width1 + remaining_width] = interpolated2[:, -1:]
else:
# Edge case: wrap to beginning
source = self.cached_array[:, :self.display_width + 1]
if HAS_SCIPY:
shifted = shift(source, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
# Ensure we get exactly display_width pixels
if shifted.shape[1] >= self.display_width:
self._frame_buffer = shifted[:, :self.display_width].astype(np.uint8)
else:
# Shifted array is smaller - pad if necessary
actual_width = shifted.shape[1]
self._frame_buffer[:, :actual_width] = shifted.astype(np.uint8)
if actual_width < self.display_width:
self._frame_buffer[:, actual_width:] = shifted[:, -1:].astype(np.uint8)
else:
interpolated = self._interpolate_subpixel(source, fractional, output_width=self.display_width)
# _interpolate_subpixel now always returns exact width, so this should work
self._frame_buffer = interpolated
return Image.fromarray(self._frame_buffer)
def _interpolate_subpixel(self, source: np.ndarray, fractional: float, output_width: Optional[int] = None) -> np.ndarray:
"""
Simple linear interpolation for sub-pixel positioning.
Blends between adjacent pixels based on fractional offset.
Args:
source: Source array to interpolate (width should be at least output_width + 1)
fractional: Fractional part of scroll position (0.0-1.0)
output_width: Desired output width (defaults to display_width)
Returns:
Interpolated array of shape (height, output_width, 3) - ALWAYS exactly output_width
"""
if output_width is None:
output_width = self.display_width
# Always return exactly output_width pixels, padding if necessary
result = np.zeros((source.shape[0], output_width, 3), dtype=np.uint8)
# Ensure we have enough source pixels for interpolation
if source.shape[1] < 2:
# Very small source - just copy what we have and pad
copy_width = min(source.shape[1], output_width)
result[:, :copy_width] = source[:, :copy_width].astype(np.uint8)
if copy_width < output_width:
# Pad with last pixel
result[:, copy_width:] = source[:, -1:].astype(np.uint8)
return result
# Calculate how many pixels we can actually interpolate
# Need at least 2 pixels to interpolate, so max output is source.shape[1] - 1
max_interpolated_width = source.shape[1] - 1
interpolated_width = min(output_width, max_interpolated_width)
if interpolated_width > 0:
# Extract pixels at x and x+1 for interpolation
pixels_x = source[:, :interpolated_width].astype(np.float32)
pixels_x1 = source[:, 1:interpolated_width + 1].astype(np.float32)
# Linear interpolation
interpolated = pixels_x * (1.0 - fractional) + pixels_x1 * fractional
# Clip and convert back to uint8
interpolated = np.clip(interpolated, 0, 255).astype(np.uint8)
# Copy interpolated portion to result
result[:, :interpolated_width] = interpolated
# If we need more pixels than we can interpolate, pad with last pixel
if interpolated_width < output_width:
result[:, interpolated_width:] = source[:, -1:].astype(np.uint8)
return result
def calculate_dynamic_duration(self) -> int:
"""
Calculate display duration based on content width and scroll settings.
+46 -3
View File
@@ -761,14 +761,54 @@ def _adopt_handwritten_block(schema: Dict[str, Any],
customization.setdefault('x-widget', 'style-editor')
props = customization['properties']
layout_block = props.get('layout')
layout_fields = (layout_block.get('properties')
if isinstance(layout_block, dict) else None)
layout_fields = layout_fields if isinstance(layout_fields, dict) else {}
for key in element_keys:
_upgrade_font_property(props[key])
# Marked like a declared element, so consumers can tell the style
# blocks from whatever else the plugin keeps under customization.
# Football's block also holds favorite_result_colors, which is a
# feature with its own fields -- without this the editor treats it
# as an element and every row grows an "enabled"/"win color" column.
props[key]['x-style-managed'] = True
# Where this element's offsets live, resolved through the same alias
# map the renderer reads them with. The two blocks were never named
# alike -- football styles score_text but positions score -- and the
# editor matched them by exact name, so it drew one offset in eleven
# and the rest had no control anywhere. Recording the answer here
# keeps the alias rules in one place instead of a JavaScript copy.
# Only an object-shaped entry (x_offset/y_offset/...) can hold an
# element's offsets. A leaf directly under layout -- a show_logo
# toggle -- is its own control, so it is never claimed by a row and
# always gets a position row of its own.
layout_key = next((name for name in alias_keys(key)
if isinstance(layout_fields.get(name), dict)
and isinstance(layout_fields[name].get('properties'),
dict)), None)
if layout_key is not None:
props[key]['x-layout-key'] = layout_key
if layout_fields:
# Positions are listed in the order the plugin declared them. Flask's
# JSON provider sorts keys, which would put the logos after the date.
layout_block.setdefault('x-propertyOrder', list(layout_fields))
modes = customization.get('x-style-modes')
if isinstance(modes, list) and modes:
props.setdefault('modes',
_modes_block_from_properties(props, element_keys,
modes))
mode_blocks = props['modes'].get('properties') if isinstance(
props.get('modes'), dict) else None
for mode_block in (mode_blocks or {}).values():
mode_layout = ((mode_block or {}).get('properties') or {}).get('layout')
if (isinstance(mode_layout, dict)
and isinstance(mode_layout.get('properties'), dict)):
mode_layout.setdefault('x-propertyOrder',
list(mode_layout['properties']))
# Stated explicitly because the config form serialises the schema with
# Flask's JSON provider, which sorts keys -- without this the elements
@@ -904,11 +944,14 @@ def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]:
return None
if isinstance(value, (list, tuple)) and len(value) == 3:
try:
rgb = tuple(int(c) for c in value)
# Clamped, not rejected. The readers this replaced clamped
# (sports_card.coerce_rgb), and the eight scoreboards' own tests
# pin it: a configured [999, -5, 20] is a typo'd bright red, and
# answering "unusable, take the default" turned it white instead.
rgb = tuple(max(0, min(255, int(c))) for c in value)
except (TypeError, ValueError):
return None
if all(0 <= c <= 255 for c in rgb):
return rgb # type: ignore[return-value]
return rgb # type: ignore[return-value]
return None
+39 -1
View File
@@ -714,9 +714,24 @@ class WiFiManager:
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
# Written by the web process while connect_to_network runs. Joining a network
# from the setup AP takes the AP down first, and the monitor daemon (a separate
# process) would otherwise see "disconnected" on its next tick and bring the
# AP straight back up mid-connect.
_CONNECT_IN_PROGRESS_FLAG_PATH = Path("/tmp/ledmatrix_wifi_connect_in_progress") # nosec B108 - process-specific named file; device is single-user RPi
# Longest a connect can legitimately take (AP teardown, nmcli's 30s timeout,
# verification, restore). An older flag was left by a process that died.
_CONNECT_FLAG_MAX_AGE_SECONDS = 180
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
_startup_cleanup_done: bool = False
def _connect_in_progress(self) -> bool:
try:
age = time.time() - self._CONNECT_IN_PROGRESS_FLAG_PATH.stat().st_mtime
except OSError:
return False
return age < self._CONNECT_FLAG_MAX_AGE_SECONDS
def _validate_ap_config(self) -> Tuple[str, int]:
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
ssid = str(self.config.get("ap_ssid", DEFAULT_AP_SSID))
@@ -1270,6 +1285,21 @@ class WiFiManager:
logger.warning("Rejected WiFi connect request: %s", error)
return False, error
try:
self._CONNECT_IN_PROGRESS_FLAG_PATH.touch()
except OSError as e:
logger.warning(f"Could not create connect-in-progress flag: {e}")
try:
return self._connect_validated(ssid, password)
finally:
try:
self._CONNECT_IN_PROGRESS_FLAG_PATH.unlink(missing_ok=True)
except OSError as e:
# Never mask the connect result; the age limit retires the flag.
logger.warning(f"Could not remove connect-in-progress flag: {e}")
def _connect_validated(self, ssid: str, password: str) -> Tuple[bool, str]:
"""connect_to_network after validation, with the in-progress flag held."""
# Save current connection info for failsafe restoration
original_connection = None
original_ssid = None
@@ -2690,7 +2720,15 @@ address=/detectportal.firefox.com/192.168.4.1
if self._disconnected_checks > 0:
logger.debug("Network connected, resetting disconnected check counter")
self._disconnected_checks = 0
if self._connect_in_progress():
# A connect has just taken the AP down on purpose. Leave the
# radio alone, and restart the grace period so a failed attempt
# (which re-enables the AP itself) isn't followed by a flap.
logger.debug("WiFi connect in progress; skipping AP management this check")
self._disconnected_checks = 0
return False
# Only enable AP if we've had enough consecutive disconnected checks
should_have_ap = (auto_enable and
is_disconnected and
+3
View File
@@ -36,6 +36,9 @@ nothing is listening, so it stays useful in a bare checkout.
|---|---|---|
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
+3 -1
View File
@@ -15,7 +15,9 @@ const fs = require('fs');
const BASE = process.env.BASE || 'http://localhost:5000';
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_html_escaping.js'];
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
'unit/test_style_editor_layout_leaf_columns.js',
'unit/test_style_editor_layout_leaf_collision.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js'];
@@ -0,0 +1,126 @@
// Regression test for how style-editor.js decides which rows to draw,
// extracted verbatim from the shipped widget (own/ownObj/elementKeys/
// styleRows/positionRows have no DOM dependency) so the test can't drift from
// the real implementation.
//
// render() claims the whole `layout` child of a customization schema as the
// widget's own -- removing it from the generic fallback renderer entirely,
// because posting the same offset twice from two controls is worse than
// owning slightly too much. That is only safe when every entry under `layout`
// gets exactly one row:
//
// * styleRows(): one per style element, paired with the layout entry that
// holds its offsets. The pairing is not by exact name -- a hand-written
// schema styles 'score_text' but positions 'score' -- so core records it
// as x-layout-key, resolved through the same alias map the renderer reads
// offsets with.
// * positionRows(): one per layout entry no style element claimed -- a logo,
// a timeout indicator, a possession arrow, or a bare leaf like a
// show_logo toggle.
//
// This contract replaced an earlier one in which elementKeys() itself
// appended layout-only keys. That compared names exactly, so an aliased entry
// ('score' for 'score_text') was listed a second time and its offsets got two
// controls posting the same field.
const fs = require('fs');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
const src = fs.readFileSync(path.join(V3, 'js/widgets/style-editor.js'), 'utf8');
const start = src.indexOf('function own(obj, key)');
const endMark = 'function control(opts)';
const end = src.indexOf(endMark);
if (start === -1 || end === -1) {
console.error('FAIL: could not locate own()..positionRows() in style-editor.js -- slice markers need updating');
process.exit(1);
}
eval(src.slice(start, end)); // defines own/ownObj/elementKeys/styleRows/positionRows
let failures = 0;
function ok(desc, cond) {
if (cond) { console.log(`ok - ${desc}`); }
else { console.log(`FAIL - ${desc}`); failures++; }
}
function layoutKeysOf(rows) { return rows.map(function (r) { return r.layoutKey; }); }
// score_text is styled and positioned under an alias; odds_text has no
// offsets at all; possession is positioned but never styled; show_logo is a
// bare leaf under layout.
const schema = {
properties: {
score_text: {
type: 'object', 'x-style-managed': true, 'x-layout-key': 'score',
properties: { font: { type: 'string' }, text_color: { type: 'array' } },
},
odds_text: {
type: 'object', 'x-style-managed': true,
properties: { font: { type: 'string' } },
},
layout: {
type: 'object',
'x-propertyOrder': ['score', 'possession', 'show_logo'],
properties: {
score: { type: 'object', properties: { x_offset: {}, y_offset: {} } },
possession: { type: 'object', properties: { x_offset: {}, y_offset: {} } },
show_logo: { type: 'boolean', default: true },
},
},
},
};
const keys = elementKeys(schema);
ok('elementKeys lists the style elements', keys.join(',') === 'score_text,odds_text');
ok('layout and modes containers are never treated as elements',
keys.indexOf('layout') === -1 && keys.indexOf('modes') === -1);
const styled = styleRows(schema);
ok('a style element is paired with the layout entry core resolved for it',
styled[0].key === 'score_text' && styled[0].layoutKey === 'score');
ok('a style element with no offsets claims nothing', styled[1].layoutKey === null);
const positions = positionRows(schema);
ok('a layout-only object entry gets a position row',
layoutKeysOf(positions).indexOf('possession') !== -1);
ok('a layout-only leaf gets a position row too',
layoutKeysOf(positions).indexOf('show_logo') !== -1);
ok('an entry claimed through an alias is not listed again as a position',
layoutKeysOf(positions).indexOf('score') === -1);
ok('positions follow the declared order',
layoutKeysOf(positions).join(',') === 'possession,show_logo');
const every = layoutKeysOf(styled).filter(Boolean).concat(layoutKeysOf(positions));
ok('every layout entry gets exactly one row',
every.length === new Set(every).size
&& every.slice().sort().join(',') === ['possession', 'score', 'show_logo'].join(','));
// A schema with no layout block at all must behave exactly as before --
// the overwhelmingly common case must not gain a phantom row.
const noLayout = {
properties: {
score_text: {
type: 'object', 'x-style-managed': true,
properties: { font: { type: 'string' } },
},
},
};
ok('a schema without a layout block has no positions',
elementKeys(noLayout).join(',') === 'score_text' && positionRows(noLayout).length === 0);
// Without an annotation (the compact declaration, or a schema that never went
// through expansion) the two blocks share one key.
const unannotated = {
properties: {
home_logo: { type: 'object', 'x-style-managed': true, properties: {} },
layout: {
type: 'object',
properties: { home_logo: { type: 'object', properties: { x_offset: {}, scale: {} } } },
},
},
};
ok('an exact-name layout entry is claimed without an annotation',
styleRows(unannotated)[0].layoutKey === 'home_logo'
&& positionRows(unannotated).length === 0);
if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
console.log('\nall checks passed');
@@ -0,0 +1,76 @@
// Regression test for style-editor.js's columnsFor(), extracted verbatim
// from the shipped widget (see test_style_editor_layout_leaf_columns.js for
// the base case this builds on).
//
// A layout-only leaf key (e.g. a bare "scale" toggle directly under
// `layout`, no x/y object underneath) used to be keyed in the internal
// `seen` map by its own bare field name. If some *other* element's style
// block, or another element's layout axis block, happened to declare a
// sub-field with that exact same name (e.g. "scale" is also a real
// COLUMN_ORDER axis name most elements use), the `!seen.has(key)` guard
// skipped creating the leaf's column -- it silently reused the existing
// 'element'/'layout' column instead. That column's row binding in
// elementRow() then looked the field up under the wrong schema location for
// the leaf's own row, found nothing, and rendered a blank cell: the same
// "silently disappears" bug the leaf-column fix was meant to close, just
// reached through a name collision instead of a missing column altogether.
// This pins the fix: layout-leaf columns are keyed under a namespaced id so
// they can never be shadowed by an unrelated column sharing their name.
const fs = require('fs');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
const src = fs.readFileSync(path.join(V3, 'js/widgets/style-editor.js'), 'utf8');
const start = src.indexOf('function own(obj, key)');
const endMark = 'function control(opts)';
const end = src.indexOf(endMark);
if (start === -1 || end === -1) {
console.error('FAIL: could not locate own()/columnsFor() in style-editor.js -- slice markers need updating');
process.exit(1);
}
eval(src.slice(start, end)); // defines own/ownObj/elementKeys/columnsFor
let failures = 0;
function ok(desc, cond) {
if (cond) { console.log(`ok - ${desc}`); }
else { console.log(`FAIL - ${desc}`); failures++; }
}
// `possession` is a real style element whose layout axis block declares a
// "scale" sub-field, which seeds seen.set('scale', 'layout'). `timeout_flag`
// is layout-only and a bare leaf valued directly under layout, but its own
// key is *also* "scale" -- an unrelated collision with that same name.
const schema = {
properties: {
possession: {
type: 'object',
'x-style-managed': true,
properties: { font: { type: 'string' } },
},
scale: {
// The collision: a second element literally named "scale", entirely
// unrelated to the layout-only leaf below sharing that string.
type: 'boolean',
},
layout: {
type: 'object',
properties: {
possession: { type: 'object', properties: { scale: { type: 'number' } } },
scale: { type: 'boolean', default: true },
},
},
},
};
const columns = columnsFor(schema);
const leafColumns = columns.filter(function (c) { return c.where === 'layout-leaf'; });
ok('the colliding layout axis sub-field still gets its own column',
columns.some(function (c) { return c.key === 'scale' && c.where === 'layout'; }));
ok('the layout-only leaf key still gets its own column despite the name collision',
leafColumns.length === 1 && leafColumns[0].key === 'scale');
ok('the leaf column is distinct from the colliding element/layout column',
columns.filter(function (c) { return c.key === 'scale'; }).length === 2);
if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
console.log('\nall checks passed');
@@ -0,0 +1,89 @@
// Regression test for style-editor.js's columnsFor(), extracted verbatim
// from the shipped widget (own/ownObj/elementKeys/columnsFor have no DOM
// dependency) so the test can't drift from the real implementation.
//
// elementKeys() already appends a layout-only key that never got its own
// top-level style block (see test_style_editor_element_keys.js), so table()
// draws a row for it. But render() still claims the whole `layout` child as
// the widget's own regardless of what that row actually shows -- and
// columnsFor() only produced a column for a layout-only key whose own value
// is an *object* with sub-fields (x_offset, y_offset, ...). A hand-written
// schema can instead put a plain leaf value directly under layout -- a
// "show_logo" toggle, a timeout indicator's on/off flag -- with no x/y
// object underneath it. That key still got a row (data-element="show_logo"),
// but zero columns ever matched it, so the row rendered with every cell
// blank: no control, and no way back to the fallback since layout was
// removed wholesale. This pins the fix: such a key gets its own
// self-keyed 'layout-leaf' column.
const fs = require('fs');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
const src = fs.readFileSync(path.join(V3, 'js/widgets/style-editor.js'), 'utf8');
const start = src.indexOf('function own(obj, key)');
const endMark = 'function control(opts)';
const end = src.indexOf(endMark);
if (start === -1 || end === -1) {
console.error('FAIL: could not locate own()/columnsFor() in style-editor.js -- slice markers need updating');
process.exit(1);
}
eval(src.slice(start, end)); // defines own/ownObj/elementKeys/columnsFor
let failures = 0;
function ok(desc, cond) {
if (cond) { console.log(`ok - ${desc}`); }
else { console.log(`FAIL - ${desc}`); failures++; }
}
// score_text is a real style element; possession is layout-only but shaped
// like an object (x/y); show_logo is layout-only and a bare leaf value.
const schema = {
properties: {
score_text: {
type: 'object',
'x-style-managed': true,
properties: { font: { type: 'string' }, text_color: { type: 'array' } },
},
layout: {
type: 'object',
properties: {
score_text: { type: 'object', properties: { x_offset: {}, y_offset: {} } },
possession: { type: 'object', properties: { x_offset: {}, y_offset: {} } },
show_logo: { type: 'boolean', default: true },
},
},
},
};
const columns = columnsFor(schema);
const byKey = {};
columns.forEach(function (c) { byKey[c.key] = c; });
ok('an element style field still gets its own column', byKey.font && byKey.font.where === 'element');
ok('a layout-only object key still contributes its x/y columns',
byKey.x_offset && byKey.x_offset.where === 'layout');
ok('a layout-only leaf key gets a column keyed to itself',
!!byKey.show_logo);
ok('...marked as a layout-leaf column, not a shared sub-field',
byKey.show_logo && byKey.show_logo.where === 'layout-leaf');
ok('nothing is duplicated', columns.length === new Set(columns.map(function (c) { return c.key; })).size);
// A schema with no leaf-valued layout key must not gain a phantom column --
// this is the common case (every existing scoreboard) and must be unaffected.
const noLeaf = {
properties: {
score_text: {
type: 'object', 'x-style-managed': true,
properties: { font: { type: 'string' } },
},
layout: {
type: 'object',
properties: { score_text: { type: 'object', properties: { x_offset: {} } } },
},
},
};
ok('a schema with only object-shaped layout keys gets no layout-leaf column',
columnsFor(noLeaf).every(function (c) { return c.where !== 'layout-leaf'; }));
if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
console.log('\nall checks passed');
+126
View File
@@ -26,6 +26,9 @@ def wifi_manager():
"""Patch WiFiManager where it is defined; yield the instance mock."""
with patch("src.wifi_manager.WiFiManager") as cls:
instance = MagicMock()
# A bare MagicMock is truthy, which would send every connect down
# the setup-AP background path.
instance._is_ap_mode_active.return_value = False
cls.return_value = instance
yield instance
@@ -97,6 +100,129 @@ class TestConnect:
assert "RuntimeError" in body["details"]
class TestConnectThroughSetupAp:
"""With the setup AP up, the phone making the request is connected through
the very network the connect tears down. A synchronous answer can never
arrive, so the route answers 202 first and connects in the background."""
URL = "/api/v3/wifi/connect"
@pytest.fixture(autouse=True)
def ap_active(self, api_v3_module, wifi_manager, monkeypatch):
from web_interface.blueprints.api_v3 import wifi as wifi_routes
wifi_manager._is_ap_mode_active.return_value = True
monkeypatch.setattr(wifi_routes, "_AP_HANDOFF_DELAY_SECONDS", 0)
monkeypatch.setattr(wifi_routes, "_last_connect_attempt", None)
self.spawned = []
monkeypatch.setattr(wifi_routes, "_spawn", self.spawned.append)
self.routes = wifi_routes
def _run_spawned(self):
assert len(self.spawned) == 1
self.spawned.pop()()
def test_answers_202_before_connecting(self, api_v3_client, wifi_manager):
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
assert response.status_code == 202
assert response.get_json()["status"] == "pending"
wifi_manager.connect_to_network.assert_not_called()
assert self.routes._last_connect_snapshot()["state"] == "pending"
def test_background_result_is_reported_by_status(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (True, "Connected to HomeNet")
api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
self._run_spawned()
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "hunter22")
wifi_manager.config = {}
wifi_manager.get_wifi_status.return_value = MagicMock(
connected=True, ssid="HomeNet", ip_address="10.0.0.5", signal=70,
ap_mode_active=False)
attempt = api_v3_client.get("/api/v3/wifi/status").get_json()["data"]["last_connect_attempt"]
assert attempt["ssid"] == "HomeNet"
assert attempt["state"] == "success"
assert "hunter22" not in str(attempt)
def test_wrong_password_is_flagged(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (
False, "wrong_password: Secrets were required, but not provided")
api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
self._run_spawned()
attempt = self.routes._last_connect_snapshot()
assert attempt["state"] == "failed"
assert attempt["error_type"] == "wrong_password"
assert attempt["message"] == "Incorrect password for HomeNet"
def test_manager_exception_is_recorded_as_a_failure(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.side_effect = RuntimeError("nmcli vanished")
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
self._run_spawned()
attempt = self.routes._last_connect_snapshot()
assert attempt["state"] == "failed"
assert "RuntimeError" in attempt["message"]
def test_a_second_connect_while_one_is_pending_is_refused(self, api_v3_client, wifi_manager):
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
response = api_v3_client.post(self.URL, json={"ssid": "OtherNet"})
assert response.status_code == 409
assert len(self.spawned) == 1
def test_pending_is_honoured_after_the_ap_has_gone_down(self, api_v3_client, wifi_manager):
# The background attempt tears the AP down long before it finishes; a
# second click must not slip through as a synchronous connect.
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
wifi_manager._is_ap_mode_active.return_value = False
response = api_v3_client.post(self.URL, json={"ssid": "OtherNet"})
assert response.status_code == 409
wifi_manager.connect_to_network.assert_not_called()
def test_a_thread_that_fails_to_start_does_not_leave_pending_behind(
self, api_v3_client, wifi_manager, monkeypatch):
def refuse(_target):
raise RuntimeError("can't start new thread")
monkeypatch.setattr(self.routes, "_spawn", refuse)
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 500
assert self.routes._last_connect_snapshot() is None
monkeypatch.setattr(self.routes, "_spawn", self.spawned.append)
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 202
class TestConnectSerializedWithoutAp:
"""Without the AP the route connects synchronously, but still one attempt
at a time: a second connect_to_network would remove the in-progress flag
the daemon relies on when the first finishes."""
URL = "/api/v3/wifi/connect"
@pytest.fixture(autouse=True)
def no_prior_attempt(self, api_v3_module, monkeypatch):
from web_interface.blueprints.api_v3 import wifi as wifi_routes
monkeypatch.setattr(wifi_routes, "_last_connect_attempt", None)
self.routes = wifi_routes
def test_a_connect_during_a_synchronous_connect_is_refused(
self, api_v3_client, wifi_manager):
inner = []
def connect(ssid, password):
if not inner:
inner.append(api_v3_client.post(self.URL, json={"ssid": "OtherNet"}))
return True, f"Connected to {ssid}"
wifi_manager.connect_to_network.side_effect = connect
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
assert response.status_code == 200
assert inner[0].status_code == 409
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "")
assert self.routes._last_connect_snapshot()["state"] == "success"
def test_an_exception_does_not_leave_pending_behind(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.side_effect = RuntimeError("nmcli gone")
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 500
assert self.routes._last_connect_snapshot()["state"] == "failed"
class TestDisconnect:
URL = "/api/v3/wifi/disconnect"
+25
View File
@@ -0,0 +1,25 @@
"""Regression: the native color input in the color-picker widget must have
an accessible name independent of showHexInput.
CodeRabbit flagged (PR #568) that the <input type="color"> only carried a
`title` attribute -- screen readers don't reliably announce `title`, and
when showHexInput is false there is no other label naming the control.
"""
from pathlib import Path
WIDGET_JS = (
Path(__file__).resolve().parent.parent
/ "web_interface" / "static" / "v3" / "js" / "widgets" / "color-picker.js"
)
def test_native_color_input_has_aria_label():
source = WIDGET_JS.read_text(encoding="utf-8")
start = source.index('<input type="color"')
end = source.index(">", start)
tag = source[start:end]
assert 'aria-label=' in tag, (
"native color <input> lost its accessible name; screen readers need "
"aria-label since it has no associated <label> and showHexInput can "
"be false"
)
+59
View File
@@ -25,6 +25,7 @@ from PIL import ImageFont
from src.element_style import (
alias_keys,
element_color,
ElementStyleResolver,
_load_font_sized,
native_bdf_size,
@@ -306,6 +307,18 @@ class TestUserOverrides:
assert not style.user_forced
assert style.color == (255, 0, 0)
def test_color_override_clamps_out_of_range_components(self, style_schema_path):
"""The resolver path normalizes colour separately from element_color
(see src/element_style.py's _resolve) -- a rejection there reads as
"not configured" and the element would silently get the default
colour back instead of the clamped one the user asked for."""
config = {"customization": {"title_text": {"text_color": [999, -5, 20]}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(255, 255, 255))
assert style.user_forced_color
assert style.color == (255, 0, 20)
def test_offsets(self, style_schema_path):
config = {"customization": {"layout": {
"title_text": {"x_offset": 4, "y_offset": -2}}}}
@@ -1270,3 +1283,49 @@ class TestAdoptedModes:
font = self._live()["score_text"]["properties"]["font"]
assert font["x-options"]["maxFixedSize"] == 16
assert "null" in font["type"]
class TestElementColorClamps:
"""An out-of-range component is clamped, not thrown away.
#569 fixed this in _normalize_color and #572 covered the resolver path,
whose own test notes that "the resolver path normalizes colour separately
from element_color". This covers the other one: the stateless element_color()
that src.common.sports_card delegates to, which is the path all nine
scoreboard plugins take for every per-element colour they draw.
It regressed once already. element_color() moved here with the per-element
customization framework, the coercion rejected out-of-range components where
the reader it replaced clamped them, and rejection reads as "not configured"
-- so a single component over 255 painted the element white while the user's
chosen colour sat in their config. Every scoreboard's
test_element_text_colors.py failed on it.
"""
CFG = {"customization": {
"rank_text": {"text_color": [999, -5, 20]},
"score_text": {"text_color": "#FF0000"},
"period_text": {"text_color": [0, 255, 255]},
"team_name": {"text_color": "nonsense"},
}}
def test_out_of_range_components_are_clamped(self):
assert element_color(self.CFG, "rank_text") == (255, 0, 20)
def test_in_range_values_are_unchanged(self):
assert element_color(self.CFG, "period_text") == (0, 255, 255)
def test_hex_still_parses(self):
assert element_color(self.CFG, "score_text") == (255, 0, 0)
def test_an_unparseable_value_still_falls_back(self):
assert element_color(self.CFG, "team_name") == (255, 255, 255)
def test_a_missing_element_still_falls_back(self):
assert element_color(self.CFG, "detail_text") == (255, 255, 255)
def test_it_agrees_with_coerce_rgb(self):
from src.common.sports_card import coerce_rgb
for value in ([999, -5, 20], [300, 300, 300], [-1, -1, -1], [0, 128, 255]):
cfg = {"customization": {"rank_text": {"text_color": value}}}
assert element_color(cfg, "rank_text") == coerce_rgb(value, (0, 0, 0)), value
+8
View File
@@ -53,6 +53,14 @@ class TestColour:
cfg = {"customization": {"score_text": {"text_color": value}}}
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (9, 9, 9)
def test_out_of_range_components_are_clamped_not_rejected(self):
"""The readers this replaced clamped, and the eight scoreboards' own
tests pin it. Rejecting instead turned a typo'd [999, -5, 20] -- a
bright red -- into the default white, which is a colour the user never
asked for rather than the one they nearly asked for."""
cfg = {"customization": {"score_text": {"text_color": [999, -5, 20]}}}
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (255, 0, 20)
def test_coerce_rgb_clamps_rather_than_rejecting(self):
assert C.coerce_rgb([300, -5, 20], (0, 0, 0)) == (255, 0, 20)
+12
View File
@@ -164,6 +164,18 @@ def test_static_asset_served(client, asset):
assert len(resp.data) > 0
def test_widget_bundle_is_served_and_requested(client):
"""base.html loads every widget through one versioned bundle request."""
page = client.get("/").get_data(as_text=True)
assert "/assets/widgets.js?v=" in page
resp = client.get("/assets/widgets.js")
assert resp.status_code == 200
assert resp.mimetype == "application/javascript"
body = resp.get_data(as_text=True)
assert body.index("/* registry.js */") < body.index("/* notification.js */")
assert "/* json-file-manager.js */" in body
def test_durations_page_groups_by_plugin(client):
"""One duration input per display mode of each enabled plugin, plus the
leftover group for saved keys no enabled plugin owns."""
+81 -4
View File
@@ -37,11 +37,11 @@ def test_every_used_breakpoint_class_is_defined():
class_attr = re.compile(r'class="([^"]*)"')
bp_class = re.compile(r"\b(%s):[A-Za-z0-9_.-]+" % "|".join(BP_PREFIXES))
for path in _template_files():
for attr in class_attr.findall(path.read_text()):
for attr in class_attr.findall(path.read_text(encoding="utf-8")):
for m in bp_class.finditer(attr):
used.add(m.group(0))
css = APP_CSS.read_text()
css = APP_CSS.read_text(encoding="utf-8")
defined = {
m.group(0).lstrip(".").replace("\\:", ":")
for m in re.finditer(
@@ -56,13 +56,90 @@ def test_every_used_breakpoint_class_is_defined():
)
_VARIANTS = (
r"(?:(?:sm|md|lg|xl|2xl|hover|focus|focus-visible|active|disabled|first|last"
r"|group-hover|peer-checked|peer-focus|placeholder|file):)*"
)
_UTILITY = re.compile(
"^" + _VARIANTS + r"-?(?:"
r"(?:block|inline|inline-block|inline-flex|flex|grid|hidden|table|contents"
r"|static|fixed|absolute|relative|sticky|truncate|sr-only|transform"
r"|uppercase|lowercase|capitalize|italic|underline|line-through|border|shadow"
r"|rounded|transition|grow|shrink|group|peer)"
r"|(?:m|p)[trblxy]?-[\w./]+"
r"|(?:w|h|min-w|min-h|max-w|max-h|gap|gap-x|gap-y|space-x|space-y|top|right"
r"|bottom|left|inset|inset-x|inset-y|z|opacity|duration|scale|rotate"
r"|translate-x|translate-y|grid-cols|col-span|leading|tracking)-[\w./\[\]]+"
r"|(?:text|bg|border|divide|ring|placeholder|accent)-(?:[a-z]+-\d{2,3}(?:/\d+)?"
r"|white|black|transparent|xs|sm|base|md|lg|xl|[2-5]xl|left|center|right"
r"|opacity-\d+|[trblxy](?:-\d)?|\d|dashed|dotted|offset-\d)"
r"|font-(?:mono|sans|serif|thin|light|normal|medium|semibold|bold|extrabold)"
r"|select-(?:none|all|text|auto)"
r"|(?:rounded|shadow|items|justify|self|flex|whitespace|break|object|list"
r"|overflow|overflow-x|overflow-y|cursor|pointer-events|appearance"
r"|ease|divide|line-clamp)-[a-z0-9]+(?:-[a-z0-9]+)?(?:\[[^\]]+\])?"
r")$"
)
def _css_light_classes(css):
"""Classes that get a rule outside [data-theme="dark"]."""
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
defined = set()
for block in re.finditer(r"([^{}]+)\{", css):
for sel in block.group(1).split(","):
sel = sel.strip()
if sel.startswith("@") or sel.startswith('[data-theme="dark"]'):
continue
for m in re.finditer(r"\.((?:\\.|[\w-])+)", sel):
defined.add(m.group(1).replace("\\", ""))
return defined
def test_every_used_utility_class_is_defined():
"""app.css is the whole stylesheet (no Tailwind build), so a utility class
it doesn't define silently does nothing. `.hidden` was missing for years,
which broke every JS show/hide toggle. Scans templates and static JS."""
attr = re.compile(r"""(?:class|className)\s*[=:]\s*(["'`])(.*?)\1""", re.S)
class_list = re.compile(r"classList\.(?:add|remove|toggle)\(([^)]*)\)")
template_expr = re.compile(r"\{\{.*?\}\}|\{%.*?%\}|\$\{[^}]*\}", re.S)
files = list(_template_files()) + [
p for p in (STATIC / "v3").rglob("*.js")
if "vendor" not in p.parts and not p.name.endswith(".min.js")
]
used = {}
for path in files:
text = template_expr.sub(" ", path.read_text(encoding="utf-8"))
chunks = [m.group(2) for m in attr.finditer(text)]
chunks += re.findall(r"""['"]([^'"]*)['"]""", " ".join(
m.group(1) for m in class_list.finditer(text)))
# :class="{ 'hidden': open }" and ternaries hold class names inside
# string literals; read those as well as the raw chunk.
bound = re.compile(r""":class\s*=\s*"([^"]*)""")
for m in bound.finditer(text):
chunks.append(m.group(1))
for chunk in chunks:
candidates = [chunk] + re.findall(r"""['`]([^'`]*)['`]""", chunk)
for candidate in candidates:
for token in candidate.split():
if _UTILITY.match(token):
used.setdefault(token, path.relative_to(PROJECT_ROOT))
defined = _css_light_classes(APP_CSS.read_text(encoding="utf-8"))
missing = sorted(f"{cls} ({used[cls]})" for cls in used if cls not in defined)
assert not missing, (
"Utility classes used in templates/JS but not defined in app.css "
f"(they silently no-op): {missing}"
)
def test_every_static_url_for_points_to_a_real_file():
ref = re.compile(
r"url_for\(\s*['\"]static['\"]\s*,\s*filename\s*=\s*['\"]([^'\"]+)['\"]"
)
missing = []
for path in _template_files():
for filename in ref.findall(path.read_text()):
for filename in ref.findall(path.read_text(encoding="utf-8")):
if not (STATIC / filename).is_file():
missing.append(f"{path.relative_to(PROJECT_ROOT)}: {filename}")
assert not missing, f"Templates reference missing static assets: {missing}"
@@ -73,7 +150,7 @@ def test_js_files_calling_debuglog_declare_the_global():
for path in sorted((STATIC / "v3").rglob("*.js")):
if "vendor" in path.parts:
continue
text = path.read_text()
text = path.read_text(encoding="utf-8")
# Calls debugLog( but neither defines it nor declares the global
calls = re.search(r"(?<![.\w])debugLog\(", text)
defines = "window.debugLog" in text
+73 -40
View File
@@ -1,66 +1,99 @@
"""Guard: every widget JS file must be loaded by base.html or explicitly allowlisted.
"""Guard: every widget JS file must ship in the widget bundle or be allowlisted.
Widget files register themselves with LEDMatrixWidgets at load time; a file
that exists but is never <script>-included silently breaks any plugin whose
config schema declares that widget (the field renders as an empty container
that polls the registry forever). base.html's widget list is maintained by
hand, so this test keeps it honest.
that exists but is never loaded silently breaks any plugin whose config schema
declares that widget (the field renders as an empty container that polls the
registry forever).
base.html loads them as one concatenated request (web_interface/widget_bundle.py),
so BUNDLE_ORDER is the hand-maintained list this test keeps honest. It also
checks that base.html actually requests the bundle, and that the bundle's
concatenation order puts the registry and base class first.
"""
import re
import sys
from pathlib import Path
from typing import Set
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from web_interface import widget_bundle # noqa: E402
WIDGETS_DIR = PROJECT_ROOT / 'web_interface' / 'static' / 'v3' / 'js' / 'widgets'
BASE_HTML = PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'base.html'
# Matches url_for('static', filename='...') inside actual <script> tags.
SCRIPT_SRC_RE = re.compile(
r"""<script\s[^>]*src="\{\{\s*url_for\(\s*'static'\s*,\s*filename='([^']+)'\s*\)\s*\}\}[^"]*"""
)
# Files that must NOT be script-included, with the reason.
ALLOWLIST = {
# Documentation example (docs/widget-guide.md); registers the name
# 'color-picker' and would shadow the real color-picker.js if loaded.
'example-color-picker.js',
}
# Files that must NOT be loaded, with the reason.
ALLOWLIST = set(widget_bundle.EXCLUDED)
def _included_widget_scripts() -> Set[str]:
"""Return widget JS basenames referenced by real <script> tags in base.html."""
base_html = BASE_HTML.read_text(encoding='utf-8')
return {
Path(filename).name
for filename in SCRIPT_SRC_RE.findall(base_html)
if filename.startswith('v3/js/widgets/')
}
def _bundled_names() -> Set[str]:
return set(widget_bundle.BUNDLE_ORDER)
def test_every_widget_script_is_included_in_base_html() -> None:
"""Every non-allowlisted widget file must be loaded by a <script> tag."""
def test_every_widget_script_is_bundled() -> None:
"""Every non-allowlisted widget file must be in BUNDLE_ORDER."""
assert WIDGETS_DIR.is_dir(), f'Widget directory missing: {WIDGETS_DIR}'
included = _included_widget_scripts()
assert included, 'No widget <script> tags found in base.html — regex or template drift?'
bundled = _bundled_names()
assert bundled, 'BUNDLE_ORDER is empty — widget_bundle drift?'
missing = [
js_file.name
for js_file in sorted(WIDGETS_DIR.glob('*.js'))
if js_file.name not in ALLOWLIST and js_file.name not in included
if js_file.name not in ALLOWLIST and js_file.name not in bundled
]
assert not missing, (
'Widget files exist but are never <script>-included in base.html '
'(plugins declaring these widgets get blank config fields): '
+ ', '.join(missing)
+ '. Add a script tag to base.html or add the file to ALLOWLIST '
'with a reason.'
'Widget files exist but are never loaded (plugins declaring these '
'widgets get blank config fields): ' + ', '.join(missing)
+ '. Add the file to BUNDLE_ORDER in web_interface/widget_bundle.py, '
'or to EXCLUDED with a reason.'
)
def test_allowlisted_widgets_are_not_included() -> None:
"""Allowlisted (must-not-load) widget files must stay out of base.html."""
included = _included_widget_scripts()
wrongly_included = [name for name in ALLOWLIST if name in included]
def test_allowlisted_widgets_are_not_bundled() -> None:
"""Allowlisted (must-not-load) widget files stay out of the bundle."""
wrongly_included = sorted(ALLOWLIST & _bundled_names())
assert not wrongly_included, (
'Allowlisted (must-not-load) widget files are script-included in '
'base.html: ' + ', '.join(wrongly_included)
'Allowlisted (must-not-load) widget files are in the bundle: '
+ ', '.join(wrongly_included)
)
def test_bundle_order_lists_only_existing_files() -> None:
"""A renamed or deleted widget must not linger in BUNDLE_ORDER."""
stale = [name for name in widget_bundle.BUNDLE_ORDER
if not (WIDGETS_DIR / name).is_file()]
assert not stale, f'BUNDLE_ORDER names files that do not exist: {stale}'
def test_registry_and_base_widget_load_first() -> None:
"""Widgets call into the registry and base class as they load."""
order = widget_bundle.BUNDLE_ORDER
assert order[0] == 'registry.js', order[:3]
assert order[1] == 'base-widget.js', order[:3]
def test_base_html_requests_the_bundle() -> None:
"""base.html must load the bundle (and no longer tag widgets one by one)."""
base_html = BASE_HTML.read_text(encoding='utf-8')
assert 'widgets_bundle_url()' in base_html, (
'base.html does not request the widget bundle'
)
per_file = re.findall(
r"""<script\s[^>]*src="\{\{\s*url_for\(\s*'static'\s*,\s*"""
r"""filename='(v3/js/widgets/[^']+)'""",
base_html,
)
assert not per_file, (
'base.html still loads widget files individually alongside the '
f'bundle (they would run twice): {per_file}'
)
def test_bundle_concatenates_every_file() -> None:
"""The built bundle contains each file, separated so sources can't merge."""
body, version = widget_bundle.build_bundle()
assert version and version == widget_bundle.bundle_version()
for name in widget_bundle.BUNDLE_ORDER:
assert f'/* {name} */' in body, f'{name} missing from the built bundle'
for name in ALLOWLIST:
assert f'/* {name} */' not in body, f'{name} must not be bundled'
+51
View File
@@ -40,6 +40,57 @@ def _wire(wm, connected, ethernet, ap_active):
wm._save_cached_scan = MagicMock()
wm._FORCE_AP_FLAG_PATH = MagicMock()
wm._FORCE_AP_FLAG_PATH.exists.return_value = False
wm._connect_in_progress = MagicMock(return_value=False)
class TestConnectInProgressHoldsOffTheDaemon:
"""Connecting from the setup AP takes the AP down before joining. The
daemon had already counted the whole AP session as "disconnected", so its
next tick re-enabled the AP in the middle of the connect."""
def test_no_ap_enable_while_a_connect_is_running(self, wm):
_wire(wm, connected=False, ethernet=False, ap_active=False)
wm._connect_in_progress.return_value = True
wm._disconnected_checks = 10 # accumulated while the AP was up
changed, *_ = wm.check_and_manage_ap_mode_with_state()
assert changed is False
wm.enable_ap_mode.assert_not_called()
assert wm._disconnected_checks == 0
def test_grace_period_restarts_after_the_connect(self, wm):
_wire(wm, connected=False, ethernet=False, ap_active=False)
wm._connect_in_progress.return_value = True
wm._disconnected_checks = 10
wm.check_and_manage_ap_mode_with_state()
wm._connect_in_progress.return_value = False
wm.check_and_manage_ap_mode_with_state()
wm.enable_ap_mode.assert_not_called()
def test_flag_is_held_for_the_connect_and_removed_after(self, wm, tmp_path):
flag = tmp_path / "connecting"
wm._CONNECT_IN_PROGRESS_FLAG_PATH = flag
seen = []
wm._connect_validated = lambda ssid, pw: seen.append(flag.exists()) or (True, "ok")
assert wm.connect_to_network("HomeNet", "hunter22") == (True, "ok")
assert seen == [True]
assert not flag.exists()
def test_flag_is_removed_when_the_connect_raises(self, wm, tmp_path):
flag = tmp_path / "connecting"
wm._CONNECT_IN_PROGRESS_FLAG_PATH = flag
wm._connect_validated = MagicMock(side_effect=RuntimeError("nmcli gone"))
with pytest.raises(RuntimeError):
wm.connect_to_network("HomeNet", "hunter22")
assert not flag.exists()
def test_a_stale_flag_from_a_dead_process_is_ignored(self, wm, tmp_path):
flag = tmp_path / "connecting"
flag.touch()
wm._CONNECT_IN_PROGRESS_FLAG_PATH = flag
assert WiFiManager._connect_in_progress(wm) is True
old = flag.stat().st_mtime - WiFiManager._CONNECT_FLAG_MAX_AGE_SECONDS - 1
os.utime(flag, (old, old))
assert WiFiManager._connect_in_progress(wm) is False
class TestWithState:
@@ -134,6 +134,7 @@ class TestConnectEndpointSurfacesTheRefusal:
def wifi_manager(self):
with patch("src.wifi_manager.WiFiManager") as cls:
instance = MagicMock()
instance._is_ap_mode_active.return_value = False
cls.return_value = instance
yield instance
@@ -0,0 +1,201 @@
"""An array of objects with no ``x-widget`` must survive a save of its own form.
Geochron's ``cities`` is a list of ``{name, lat, lon, timezone}`` objects and
names no widget. The template sent that to the comma-separated text input,
which renders each city as a Python dict repr joined by commas. Saving the
untouched form posted those back as a list of strings, the schema rejected
them, and *every* save of the plugin failed with 400 "Configuration
validation failed" -- no setting on the page could be changed.
So the test renders the real form, submits what a browser would, and requires
the save to succeed with the cities intact.
"""
import json
import sys
from html.parser import HTMLParser
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from werkzeug.datastructures import MultiDict
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
CITIES = [
{"name": "New York", "lat": 40.71, "lon": -74.01, "timezone": "America/New_York"},
{"name": "Tokyo", "lat": 35.68, "lon": 139.65, "timezone": "Asia/Tokyo"},
]
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"timezone": {"type": ["string", "null"], "default": None},
"show_cities": {"type": "boolean", "default": True},
"cities": {
"type": "array",
"maxItems": 8,
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "maxLength": 20},
"lat": {"type": "number", "minimum": -90, "maximum": 90},
"lon": {"type": "number", "minimum": -180, "maximum": 180},
"timezone": {"type": "string"},
},
"required": ["name", "lat", "lon"],
"additionalProperties": False,
},
"default": CITIES,
},
},
"required": ["enabled"],
"additionalProperties": False,
}
STORED = {"enabled": True, "timezone": None, "show_cities": True,
"cities": CITIES}
class _FormFields(HTMLParser):
"""Collect what a browser would submit from the rendered form."""
def __init__(self):
super().__init__()
self.pairs = []
self._select = None
self._select_first = None
self._select_chosen = None
def handle_starttag(self, tag, attrs):
a = dict(attrs)
name = a.get("name")
if tag == "input" and name and "disabled" not in a:
if a.get("type") in ("checkbox", "radio") and "checked" not in a:
return
if a.get("type") in ("button", "submit", "file"):
return
self.pairs.append((name, a.get("value", "")))
elif tag == "select" and name:
self._select, self._select_first, self._select_chosen = name, None, None
elif tag == "option" and self._select:
if self._select_first is None:
self._select_first = a.get("value", "")
if "selected" in a:
self._select_chosen = a.get("value", "")
def handle_endtag(self, tag):
if tag == "select" and self._select:
chosen = self._select_chosen if self._select_chosen is not None else self._select_first
self.pairs.append((self._select, chosen or ""))
self._select = None
def _render_form():
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
autoescape=select_autoescape(["html"]),
)
plugin = {"id": "demo", "name": "Demo", "description": "", "enabled": True,
"author": "me", "version": "1.0.0"}
return env.get_template("v3/partials/plugin_config.html").render(
plugin=plugin, schema=SCHEMA, config=json.loads(json.dumps(STORED)))
@pytest.fixture
def post(tmp_path):
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
store = {"demo": json.loads(json.dumps(STORED))}
cm = MagicMock()
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
cm.get_raw_file_content.return_value = {}
cm.get_config_path.return_value = str(tmp_path / "config.json")
def _save(cfg, **_kw):
store.clear()
store.update(cfg)
return type("R", (), {"status": type("S", (), {"value": "success"})(),
"message": None})()
cm.save_config_atomic.side_effect = _save
a.api_v3.config_manager = cm
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
client = app.test_client()
def _post(pairs):
resp = client.post("/api/v3/plugins/config?plugin_id=demo",
data=MultiDict(pairs))
return resp, store.get("demo", {})
try:
yield _post
finally:
for k, v in originals.items():
setattr(a.api_v3, k, v)
def test_the_form_does_not_flatten_objects_into_text():
html = _render_form()
assert "{&#39;name&#39;" not in html and "{'name'" not in html, (
"cities rendered as a dict repr in a comma-separated text box")
def test_saving_the_untouched_form_succeeds(post):
parser = _FormFields()
parser.feed(_render_form())
resp, cfg = post(parser.pairs)
assert resp.status_code == 200, resp.get_json()
assert cfg["cities"] == CITIES
def test_an_item_without_its_optional_object_does_not_gain_an_empty_one(post):
"""News: a custom feed with no logo. The unchecked-checkbox pass walked
into ``logo`` to look for booleans and left ``logo: {}`` behind, which
fails the logo's ``required: [id, path]`` -- 400 on every save."""
from web_interface.blueprints.api_v3 import _set_missing_booleans_to_false
props = {"feeds": {"type": "object", "properties": {"custom_feeds": {
"type": "array",
"items": {"type": "object", "properties": {
"name": {"type": "string"},
"enabled": {"type": "boolean"},
"logo": {"type": "object", "required": ["id", "path"],
"properties": {"id": {"type": "string"},
"path": {"type": "string"}}},
"extra": {"type": "object",
"properties": {"flag": {"type": "boolean"}}},
}},
}}}}
cfg = {"feeds": {"custom_feeds": [{"name": "Verge", "enabled": True}]}}
_set_missing_booleans_to_false(
cfg, props, {"feeds.custom_feeds.0.name"}, sections={"feeds"})
item = cfg["feeds"]["custom_feeds"][0]
assert "logo" not in item
# A nested object that really does hold an unchecked box still gets it.
assert item["extra"] == {"flag": False}
assert item["enabled"] is False
@@ -99,9 +99,15 @@ class TestTheRoutesExistAtAll:
assert "'google-oauth'" in allow_list_line[0], allow_list_line[0]
def test_the_widget_script_is_served(self):
# Widgets load through one bundle (web_interface/widget_bundle.py), so
# the page must request the bundle and the bundle must carry the file.
from web_interface import widget_bundle
base = (Path(project_root) / 'web_interface/templates/v3/base.html'
).read_text(encoding='utf-8')
assert 'widgets/google-oauth.js' in base
assert 'widgets_bundle_url()' in base
assert 'google-oauth.js' in widget_bundle.BUNDLE_ORDER
body, _version = widget_bundle.build_bundle()
assert '/* google-oauth.js */' in body
def test_the_status_line_is_announced(self):
# Every message the widget gives arrives after an async call, so a
+68
View File
@@ -0,0 +1,68 @@
"""The stdlib gzip fallback must compress the UI's text assets.
flask-compress is optional (app.py tolerates its absence). Without a fallback a
Pi missing it ships ~1.2 MB of uncompressed JavaScript to a phone over WiFi on
every first load and every update. These pin the fallback's contract: text
assets compress and round-trip byte-for-byte, clients that don't ask for gzip
get the original bytes, and small or non-text responses are left alone.
"""
import gzip
from pathlib import Path
import pytest
STATIC = Path(__file__).resolve().parents[2] / "web_interface" / "static"
@pytest.fixture
def client(monkeypatch):
import web_interface.app as web_app
# Exercise the fallback even on machines that do have flask-compress.
monkeypatch.setattr(web_app, "_HAVE_FLASK_COMPRESS", False)
web_app.app.config["TESTING"] = True
with web_app.app.test_client() as c:
yield c
@pytest.mark.parametrize("url,path", [
("/static/v3/plugins_manager.js", "v3/plugins_manager.js"),
("/static/v3/app.css", "v3/app.css"),
])
def test_static_text_asset_is_gzipped_and_round_trips(client, url, path):
resp = client.get(url, headers={"Accept-Encoding": "gzip, deflate"})
assert resp.status_code == 200
assert resp.headers.get("Content-Encoding") == "gzip"
assert "Accept-Encoding" in resp.headers.get("Vary", "")
original = (STATIC / path).read_bytes()
assert gzip.decompress(resp.data) == original
assert len(resp.data) < len(original) / 2
assert int(resp.headers["Content-Length"]) == len(resp.data)
def test_repeat_request_serves_identical_compressed_bytes(client):
headers = {"Accept-Encoding": "gzip"}
first = client.get("/static/v3/js/app-shell.js", headers=headers).data
second = client.get("/static/v3/js/app-shell.js", headers=headers).data
assert first == second
def test_client_without_gzip_gets_the_original_bytes(client):
resp = client.get("/static/v3/app.css", headers={"Accept-Encoding": "identity"})
assert resp.status_code == 200
assert "Content-Encoding" not in resp.headers
assert resp.data == (STATIC / "v3/app.css").read_bytes()
def test_binary_assets_are_not_recompressed(client):
resp = client.get("/static/v3/icons/icon-192.png", headers={"Accept-Encoding": "gzip"})
assert resp.status_code == 200
assert "Content-Encoding" not in resp.headers
def test_widget_bundle_is_gzipped_and_immutable(client):
resp = client.get("/assets/widgets.js", headers={"Accept-Encoding": "gzip"})
assert resp.status_code == 200
assert resp.headers.get("Content-Encoding") == "gzip"
assert "immutable" in resp.headers.get("Cache-Control", "")
assert b"/* registry.js */" in gzip.decompress(resp.data)
@@ -0,0 +1,180 @@
"""Stored values the current schema no longer describes must not break the form.
Two cases from the news plugin:
* ``global.dynamic_duration`` used to be a boolean and is now an object. An
install that has not saved since still holds ``true``; rendering the nested
section did ``key in true`` and the whole config page failed to load.
* A custom feed logo stored with a ``path`` but no ``id``. The template always
emitted an empty ``logo.id`` input, which the save route parsed to ``None``
and the schema rejected (``Expected type string``) -- 400 on every save.
"""
import json
import sys
from html.parser import HTMLParser
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from werkzeug.datastructures import MultiDict
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"global": {"type": "object", "properties": {
"dynamic_duration": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"min_duration_seconds": {"type": "integer", "default": 30,
"minimum": 10, "maximum": 300},
},
"additionalProperties": False,
},
}, "additionalProperties": False},
"feeds": {"type": "object", "properties": {
"custom_feeds": {
"type": "array",
"x-widget": "custom-feeds",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"url": {"type": "string"},
"enabled": {"type": "boolean", "default": True},
"logo": {"type": "object", "required": ["path"],
"properties": {"id": {"type": "string"},
"path": {"type": "string"}}},
},
"required": ["name", "url"],
"additionalProperties": False,
},
},
}, "additionalProperties": False},
},
"required": ["enabled"],
"additionalProperties": False,
}
LOGO_PATH = "assets/plugins/news/uploads/verge.png"
STORED = {
"enabled": True,
"global": {"dynamic_duration": True},
"feeds": {"custom_feeds": [{"name": "Verge",
"url": "https://www.theverge.com/rss/index.xml",
"enabled": True,
"logo": {"path": LOGO_PATH}}]},
}
class _FormFields(HTMLParser):
"""Collect what a browser would submit from the rendered form."""
def __init__(self):
super().__init__()
self.pairs = []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag != "input" or not a.get("name") or "disabled" in a:
return
if a.get("type") in ("checkbox", "radio") and "checked" not in a:
return
if a.get("type") in ("button", "submit", "file"):
return
self.pairs.append((a["name"], a.get("value", "")))
def _render():
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
autoescape=select_autoescape(["html"]),
)
plugin = {"id": "demo", "name": "Demo", "description": "", "enabled": True,
"author": "me", "version": "1.0.0"}
return env.get_template("v3/partials/plugin_config.html").render(
plugin=plugin, schema=SCHEMA, config=json.loads(json.dumps(STORED)))
@pytest.fixture
def post(tmp_path):
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
store = {"demo": json.loads(json.dumps(STORED))}
cm = MagicMock()
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
cm.get_raw_file_content.return_value = {}
cm.get_config_path.return_value = str(tmp_path / "config.json")
def _save(cfg, **_kw):
store.clear()
store.update(cfg)
return type("R", (), {"status": type("S", (), {"value": "success"})(),
"message": None})()
cm.save_config_atomic.side_effect = _save
a.api_v3.config_manager = cm
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
client = app.test_client()
def _post(pairs):
resp = client.post("/api/v3/plugins/config?plugin_id=demo",
data=MultiDict(pairs))
return resp, store.get("demo", {})
try:
yield _post
finally:
for k, v in originals.items():
setattr(a.api_v3, k, v)
def test_a_scalar_stored_under_an_object_field_still_renders():
html = _render()
assert 'name="global.dynamic_duration.min_duration_seconds"' in html
def test_a_logo_without_an_id_posts_no_id_field():
parser = _FormFields()
parser.feed(_render())
names = [name for name, _ in parser.pairs]
assert "feeds.custom_feeds.0.logo.path" in names
assert "feeds.custom_feeds.0.logo.id" not in names
def test_saving_that_form_succeeds_and_upgrades_the_legacy_value(post):
parser = _FormFields()
parser.feed(_render())
resp, cfg = post(parser.pairs)
assert resp.status_code == 200, resp.get_json()
assert isinstance(cfg["global"]["dynamic_duration"], dict)
# The legacy `true` was the on switch; upgrading must not turn it off.
assert cfg["global"]["dynamic_duration"]["enabled"] is True
assert cfg["global"]["dynamic_duration"]["min_duration_seconds"] == 30
assert cfg["feeds"]["custom_feeds"][0]["logo"] == {"path": LOGO_PATH}
@@ -165,6 +165,27 @@ class TestPathTraversal:
assert "hunter2" not in r.get_data(as_text=True)
def test_a_symlinked_widgets_directory_cannot_escape(self, tmp_path, make_client):
"""widgets/ itself is contained before the script is resolved under it,
so a symlink out of the plugin can't become the containment base."""
outside = tmp_path / "outside"
outside.mkdir()
(outside / "loot.js").write_text("const KEY='hunter2';", encoding="utf-8")
d = tmp_path / "soccer-scoreboard"
d.mkdir()
manifest = {"id": "soccer-scoreboard", "name": "soccer-scoreboard",
"version": "1.0.0",
"widgets": [{"name": "loot", "script": "loot.js"}]}
(d / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
try:
(d / "widgets").symlink_to(outside, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks unavailable on this platform")
r = make_client().get(URL.format("soccer-scoreboard", "loot"))
assert r.status_code == 404
assert "hunter2" not in r.get_data(as_text=True)
class TestDegradation:
def test_an_unknown_plugin_is_404(self, tmp_path, make_client):
assert make_client().get(URL.format("nope", "w")).status_code == 404
@@ -0,0 +1,251 @@
"""When the style editor takes over a customization block, it takes over
exactly its own part of it -- and it actually gets to take over at all.
Two defects this pins, both found by rendering the real partial in a browser
rather than by reading the code:
1. The hand-off guard asked "do any fallback controls differ from their
server-rendered defaults?" as a proxy for "is someone editing this?". The
fallback contains this block's own font fields, and the font-selector
widget populates them on the same 50ms timer -- so a plain page load with
nobody touching anything raced into "dirty" (seven customization.*.font
selects, measured ~60ms after injection) and the style editor removed
itself, leaving the accordion form it exists to replace. The question is
whether a *person* typed, and isTrusted answers exactly that.
2. Taking over used to remove the whole fallback section. A customization
block can hold more than styling -- football keeps favorite_result_colors
there -- so that removed the only UI those fields had.
These assert on the template source because the behaviour lives in an inline
script that no Python test executes; the browser check that found both is not
something CI runs.
"""
import re
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
TEMPLATE = (PROJECT_ROOT / "web_interface" / "templates" / "v3" / "partials"
/ "plugin_config.html")
@pytest.fixture(scope="module")
def source():
return TEMPLATE.read_text(encoding="utf-8")
class TestTheHandOffGuard:
def test_it_asks_whether_a_person_typed(self, source):
assert "isTrusted" in source, (
"a programmatic change by a sibling widget must not read as a "
"user edit -- that is what removed the editor on a plain load")
assert "userEdited" in source
def test_it_no_longer_diffs_values_against_defaults(self, source):
assert "fallbackIsDirty" not in source, (
"value-diffing cannot distinguish a user's edit from another "
"widget populating this block's own fields")
assert "defaultSelected" not in source
def test_the_listeners_are_attached_synchronously(self, source):
"""The edit worth protecting can happen before initWidget runs, so
the listeners cannot wait for it.
Scoped to this branch: the template defines an initWidget for every
widget it can render, and the first one in the file belongs to a
different branch entirely.
"""
start = source.index("obj_widget == 'style-editor'")
branch = source[start:source.index("{% elif prop.properties %}", start)]
assert "watchForRealEdits" in branch
assert branch.index("watchForRealEdits") < branch.index("function initWidget"), (
"the watcher must be installed above this branch's initWidget, "
"and invoked immediately rather than from inside it")
def test_the_guard_still_backs_off_for_a_real_edit(self, source):
assert re.search(r"if \(userEdited\) \{ container\.remove\(\); return; \}",
source), "a genuine in-progress edit must still win"
class TestItTakesOverOnlyItsOwnBlocks:
def test_children_are_individually_addressable(self, source):
assert 'data-child-key="{{ nested_key }}"' in source, (
"without a handle per child the only options are removing the "
"whole section or none of it")
def test_removal_is_driven_by_what_the_widget_reported(self, source):
assert "ownedKeys" in source
assert "fallback.querySelector(" in source
def test_an_unowned_child_survives(self, source):
"""The empty-section case still removes the lot, so a block that is
entirely styling looks exactly as it did before."""
assert "!fallback.querySelector('[data-child-key]')" in source
def test_the_owned_key_is_escaped_before_it_becomes_a_selector(self, source):
"""ownedKeys comes back off a dataset attribute, not a literal --
splicing it into '[data-child-key="' + k + '"]' unescaped breaks (or
is steerable) on a key containing a quote or backslash. The rest of
this codebase (plugin-file-manager.js, app-shell.js) always routes a
dynamic value through CSS.escape() before it lands in a selector."""
assert "CSS.escape(k)" in source, (
"an owned key must be CSS.escape()'d before being interpolated "
"into the data-child-key attribute selector")
class TestTheSchemaSaysWhichBlocksAreStyling:
def test_adopted_blocks_are_marked_and_others_are_not(self):
from src.element_style import expand_style_elements
schema = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"properties": {
"score_text": {
"type": "object",
"properties": {
"font": {"type": "string",
"default": "PressStart2P-Regular.ttf"},
"font_size": {"type": "integer", "default": 10},
"text_color": {"type": "array",
"default": [255, 255, 255]},
},
},
# Not styling: a feature that happens to live here.
"favorite_result_colors": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"win_color": {"type": "array",
"default": [0, 255, 0]},
},
},
},
},
},
}
props = expand_style_elements(schema)["properties"]["customization"]["properties"]
assert props["score_text"]["x-style-managed"] is True
assert "x-style-managed" not in props["favorite_result_colors"]
# Football's real layout keys, in its schema's order. The style block and the
# layout block were written years apart and never agreed on names.
FOOTBALL_LAYOUT = ["home_logo", "away_logo", "score", "status_text", "date",
"time", "down_distance", "timeouts", "possession",
"records", "odds"]
def _offsets(*axes):
return {"type": "object",
"properties": {a: {"type": "integer", "default": 0} for a in axes}}
def _style_block():
return {"type": "object", "properties": {
"font": {"type": "string", "default": "PressStart2P-Regular.ttf"},
"font_size": {"type": "integer", "default": 10},
"text_color": {"type": "array", "default": [255, 255, 255]},
}}
def _football_shaped():
layout = {k: _offsets("x_offset", "y_offset") for k in FOOTBALL_LAYOUT}
layout["records"] = _offsets("away_x_offset", "home_x_offset", "y_offset")
return {"type": "object", "properties": {"customization": {
"type": "object",
"x-style-modes": ["live", "recent"],
"properties": {
**{k: _style_block() for k in (
"score_text", "period_text", "team_name", "status_text",
"detail_text", "odds_text", "rank_text")},
"layout": {"type": "object", "properties": layout},
}}}}
class TestEveryAdvertisedOffsetGetsAControl:
"""The style editor took the layout section over but only drew offsets
whose layout key matched a style key exactly. In football that was
status_text alone: score, odds, both logos, timeouts, possession,
down-and-distance, date, time and records -- options the schema
advertises and the renderer reads -- had no control anywhere."""
def _customization(self):
from src.element_style import expand_style_elements
return expand_style_elements(_football_shaped())[
"properties"]["customization"]
def test_a_style_element_is_told_where_its_offsets_live(self):
"""Resolved in core through the same alias map the resolver reads
offsets with, so the editor cannot drift from the renderer."""
props = self._customization()["properties"]
assert props["score_text"]["x-layout-key"] == "score"
assert props["odds_text"]["x-layout-key"] == "odds"
assert props["status_text"]["x-layout-key"] == "status_text"
def test_an_element_with_no_offsets_claims_nothing(self):
props = self._customization()["properties"]
for key in ("period_text", "detail_text", "team_name", "rank_text"):
assert "x-layout-key" not in props[key], key
def test_the_mode_copies_carry_it_too(self):
live = self._customization()["properties"]["modes"]["properties"][
"live"]["properties"]
assert live["score_text"]["x-layout-key"] == "score"
def test_positions_keep_the_order_the_plugin_declared(self):
"""Flask sorts keys when it serialises the schema, which would
otherwise list the logos after the date."""
c = self._customization()
assert c["properties"]["layout"]["x-propertyOrder"] == FOOTBALL_LAYOUT
live_layout = c["properties"]["modes"]["properties"]["live"][
"properties"]["layout"]
assert live_layout["x-propertyOrder"] == FOOTBALL_LAYOUT
def test_every_layout_entry_is_claimed_or_listed_as_a_position(self):
"""Mirrors the widget's split: a layout entry either belongs to a
style row or gets a row of its own. Nothing is left over."""
props = self._customization()["properties"]
layout = props["layout"]["properties"]
claimed = {props[k]["x-layout-key"] for k in props
if isinstance(props[k], dict) and "x-layout-key" in props[k]}
positions = [k for k in props["layout"]["x-propertyOrder"]
if k not in claimed]
assert claimed | set(positions) == set(layout)
assert positions == ["home_logo", "away_logo", "date", "time",
"down_distance", "timeouts", "possession",
"records"]
def test_the_widget_reads_the_annotation_and_lists_positions(self):
js = (PROJECT_ROOT / "web_interface" / "static" / "v3" / "js"
/ "widgets" / "style-editor.js").read_text(encoding="utf-8")
assert "x-layout-key" in js, (
"the alias rules live in element_style.py; the widget must read "
"their result rather than carry a second copy")
assert "function positionRows" in js
def test_a_leaf_under_layout_is_never_claimed_as_offsets(self):
"""A show_logo toggle straight under layout has no x/y object, so it
cannot hold an element's offsets. Claiming it would give that row no
layout columns and remove the leaf from the positions list -- the
field would lose its only control."""
from src.element_style import expand_style_elements
schema = _football_shaped()
cust = schema["properties"]["customization"]
# A style element whose alias ("show_logo") names a leaf.
cust["properties"]["show_logo_text"] = _style_block()
cust["properties"]["layout"]["properties"]["show_logo"] = {
"type": "boolean", "default": True}
props = expand_style_elements(schema)["properties"]["customization"][
"properties"]
assert "x-layout-key" not in props["show_logo_text"]
assert "show_logo" in props["layout"]["x-propertyOrder"]
+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')
+10 -4
View File
@@ -1131,14 +1131,20 @@ def _set_missing_booleans_to_false(plugin_config, schema_props, form_keys, prefi
elif prop_type == 'object' and 'properties' in prop_schema:
# Recurse into nested objects
if config_node is not None:
# Inside an array item — ensure nested dict exists in item
if prop_name not in node or not isinstance(node[prop_name], dict):
node[prop_name] = {}
# Inside an array item. Walk into the existing dict, or a scratch
# one that is attached only if a boolean actually landed in it:
# an optional object the item never had (a custom feed with no
# logo) must stay absent, because an empty stub fails the
# object's own `required` list and 400s every save.
existing = node.get(prop_name)
child = existing if isinstance(existing, dict) else {}
_set_missing_booleans_to_false(
plugin_config, prop_schema['properties'], form_keys, full_path,
config_node=node[prop_name],
config_node=child,
sections=sections, submitted_parents=submitted_parents
)
if child is not existing and child:
node[prop_name] = child
else:
_set_missing_booleans_to_false(
plugin_config, prop_schema['properties'], form_keys, full_path,
+113 -12
View File
@@ -3,10 +3,73 @@
Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
import threading
import time
from web_interface.blueprints.api_v3 import (
api_v3, describe_exception, jsonify, logger, request,
)
# Joining a network while the setup AP is up has to take the AP down first,
# and the caller is almost always a phone connected *through* that AP. Run
# synchronously, the request outlives the link it arrived on: the browser
# never gets an answer and the Connect button appears to do nothing. So in AP
# mode the connect runs in the background and the result is kept here, where
# /wifi/status can report it once the user is back on either network.
_connect_lock = threading.Lock()
_last_connect_attempt = None
# Lets the 202 reach the phone before hostapd stops.
_AP_HANDOFF_DELAY_SECONDS = 2.0
def _last_connect_snapshot():
with _connect_lock:
return dict(_last_connect_attempt) if _last_connect_attempt else None
def _spawn(target):
threading.Thread(target=target, name='wifi-connect', daemon=True).start()
def _connect_result_payload(ssid, success, message):
"""Shape a connect_to_network result for the client.
The manager flags a rejected passphrase by prefixing the message with
"wrong_password:"; the setup pages key off `error_type` rather than
parsing nmcli's wording.
"""
if success:
return {'status': 'success', 'message': message}
payload = {'status': 'error', 'message': message or 'Failed to connect to network'}
if message and message.startswith('wrong_password:'):
payload['error_type'] = 'wrong_password'
payload['message'] = f'Incorrect password for {ssid}'
return payload
def _record_connect_result(ssid, payload):
global _last_connect_attempt
with _connect_lock:
_last_connect_attempt = {
'ssid': ssid,
'state': 'success' if payload['status'] == 'success' else 'failed',
'message': payload['message'],
'error_type': payload.get('error_type'),
'finished_at': time.time(),
}
def _run_background_connect(ssid, password):
try:
time.sleep(_AP_HANDOFF_DELAY_SECONDS)
from src.wifi_manager import WiFiManager
success, message = WiFiManager().connect_to_network(ssid, password)
payload = _connect_result_payload(ssid, success, message)
except Exception as e:
logger.error("Background WiFi connect failed", exc_info=True)
payload = {'status': 'error', 'message': describe_exception(e)}
_record_connect_result(ssid, payload)
def _parse_bool_ish(value):
"""Coerce a JSON value that is supposed to be a boolean.
@@ -65,7 +128,8 @@ def get_wifi_status():
'ip_address': status.ip_address,
'signal': status.signal,
'ap_mode_active': status.ap_mode_active,
'auto_enable_ap_mode': auto_enable_ap
'auto_enable_ap_mode': auto_enable_ap,
'last_connect_attempt': _last_connect_snapshot(),
}
})
except Exception as e:
@@ -149,7 +213,12 @@ def scan_wifi_networks():
}), 500
@api_v3.route('/wifi/connect', methods=['POST'])
def connect_wifi():
"""Connect to a WiFi network"""
"""Connect to a WiFi network.
With the setup AP active this answers 202 at once and connects in the
background (see _last_connect_attempt); otherwise it waits for the result.
"""
global _last_connect_attempt
try:
from src.wifi_manager import WiFiManager
@@ -177,18 +246,50 @@ def connect_wifi():
password = data.get('password', '') or ''
wifi_manager = WiFiManager()
success, message = wifi_manager.connect_to_network(ssid, password)
ap_mode_active = wifi_manager._is_ap_mode_active()
if success:
# One attempt at a time on either path: concurrent connects fight over
# the radio, and the first to finish would clear the in-progress flag
# the monitor daemon still needs for the other. The check can't depend
# on AP state either -- a background attempt takes the AP down long
# before it finishes.
with _connect_lock:
if _last_connect_attempt and _last_connect_attempt['state'] == 'pending':
return jsonify({
'status': 'error',
'message': f"Already connecting to {_last_connect_attempt['ssid']}"
}), 409
_last_connect_attempt = {
'ssid': ssid, 'state': 'pending', 'message': None,
'error_type': None, 'finished_at': None,
}
if ap_mode_active:
try:
_spawn(lambda: _run_background_connect(ssid, password))
except Exception:
# Nothing will ever finish this attempt; don't leave every
# later request refused.
with _connect_lock:
_last_connect_attempt = None
raise
return jsonify({
'status': 'success',
'message': message
})
else:
return jsonify({
'status': 'error',
'message': message or 'Failed to connect to network'
}), 400
'status': 'pending',
'message': (
f'Connecting to {ssid}. The LEDMatrix-Setup network will turn off, '
'so this page will lose its connection.'
),
'data': {'ssid': ssid},
}), 202
try:
success, message = wifi_manager.connect_to_network(ssid, password)
except Exception as e:
_record_connect_result(ssid, {'status': 'error', 'message': describe_exception(e)})
raise
payload = _connect_result_payload(ssid, success, message)
_record_connect_result(ssid, payload)
return jsonify(payload), (200 if success else 400)
except Exception as e:
logger.error("Error connecting to WiFi", exc_info=True)
return jsonify({
+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();
@@ -408,8 +440,13 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!r.ok) throw new Error('Server error ' + r.status);
const ct = r.headers.get('content-type') || '';
if (!r.ok) {
// A failing action script comes back as a 400 whose JSON body
// carries the script's own message; surface it, not the status.
const data = ct.includes('application/json') ? await r.json().catch(() => null) : null;
throw new Error(data?.message || 'Server error ' + r.status);
}
if (!ct.includes('application/json')) {
const txt = await r.text();
throw new Error('Unexpected response: ' + txt.slice(0, 120));
@@ -488,6 +525,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 +536,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 +545,7 @@
if (this._editModal) this._editModal.hidden = true;
this._editFile = null;
this._clearErr();
this._release('edit');
}
_formatJson() {
@@ -562,12 +601,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 +642,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) {
@@ -691,12 +729,14 @@
window._pfmToggle = async function (fieldId, categoryName, enabled) {
const st = getState(fieldId);
const result = await callAction(st.pluginId, st.actions.toggle, { category_name: categoryName, enabled })
.catch(() => ({ status: 'error' }));
.catch(() => ({ status: 'error', message: 'Network error' }));
if (result.status === 'success') {
notify(enabled ? `${categoryName} enabled` : `${categoryName} disabled`, 'success');
await loadFiles(fieldId);
} else {
notify('Toggle failed', 'error');
// The action endpoint relays the script's own message on failure;
// without it every error reads as a bare "Toggle failed".
notify(result.message ? `Toggle failed: ${result.message}` : 'Toggle failed', 'error');
await loadFiles(fieldId); // revert UI
}
};
@@ -739,11 +779,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 +850,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}')"
@@ -146,10 +146,20 @@
function elementKeys(schema) {
var props = (schema && schema.properties) || {};
var order = schema['x-propertyOrder'] || Object.keys(props);
return order.filter(function (k) {
var shaped = order.filter(function (k) {
return k !== 'layout' && k !== 'modes'
&& own(props, k) && ownObj(props, k).properties;
});
// Core marks the blocks it recognises as styling. Prefer that: a
// customization block can also hold a feature of its own -- football
// keeps favorite_result_colors there -- and treating one as an element
// gives every row that feature's fields as extra columns.
var managed = shaped.filter(function (k) {
return ownObj(props, k)['x-style-managed'] === true;
});
// Nothing marked means the schema never went through expansion, so
// fall back to the shape test rather than rendering an empty table.
return managed.length ? managed : shaped;
}
function titleOf(schema, key) {
@@ -357,25 +367,100 @@
visible: '3.5rem'
};
/**
* Where an element's offsets live in the layout block, or null.
*
* A hand-written schema does not line up: football styles 'score_text'
* but positions it under 'score'. Core resolves that through the same
* alias map the renderer reads offsets with and records the answer as
* x-layout-key, so the rules exist once, in element_style.py. Without
* the annotation both blocks share one key, as the compact declaration
* does.
*/
function layoutKeyFor(schema, key) {
var props = (schema && schema.properties) || {};
var layoutProps = ownObj(props, 'layout').properties || {};
var declared = ownObj(props, key)['x-layout-key'];
// Only an object-shaped entry holds offsets. A leaf straight under
// layout (a show_logo toggle) is a control of its own, so it is never
// claimed here and always gets a position row.
if (typeof declared === 'string' && ownObj(layoutProps, declared).properties) {
return declared;
}
return ownObj(layoutProps, key).properties ? key : null;
}
/** One row per styled element, paired with its offsets if it has any. */
function styleRows(schema) {
return elementKeys(schema).map(function (key) {
return { key: key, layoutKey: layoutKeyFor(schema, key) };
});
}
/**
* One row per positioned thing that has no style block of its own.
*
* Football positions both logos, timeouts, possession, down-and-distance,
* the date, the time and the records, none of which has a font or a
* colour. The editor takes the whole layout section over, so anything
* not drawn here has no control at all.
*/
function positionRows(schema) {
var layout = ownObj((schema && schema.properties) || {}, 'layout');
var layoutProps = layout.properties || {};
var claimed = styleRows(schema).map(function (r) { return r.layoutKey; });
var order = layout['x-propertyOrder'] || Object.keys(layoutProps);
return order.filter(function (lk) {
var entry = own(layoutProps, lk);
// Object-shaped entries carry offsets; a leaf is itself the
// control. Both need a row here, because the editor removes the
// fallback's whole layout section.
return entry && typeof entry === 'object' && claimed.indexOf(lk) === -1;
}).map(function (lk) {
return { key: null, layoutKey: lk };
});
}
function layoutTitleOf(schema, layoutKey) {
var layoutProps = ownObj((schema && schema.properties) || {}, 'layout').properties || {};
return ownObj(layoutProps, layoutKey).title || layoutKey.replace(/_/g, ' ');
}
/**
* The columns a table needs, derived from the schema rather than fixed.
*
* Two sources: the sub-fields elements declare (font, font_size,
* text_color, visible, align) and the sub-fields their layout blocks
* declare (x_offset, y_offset, scale).
* Three sources: the sub-fields a row's style block declares (font,
* font_size, text_color, visible, align), the sub-fields of its layout
* entry (x_offset, y_offset, scale), and a layout entry whose own value
* *is* the field -- a show_logo toggle straight under layout has no x/y
* object underneath, so it gets a column keyed to itself.
*
* `rows` defaults to every row the schema produces, style and position.
*/
function columnsFor(schema) {
function columnsFor(schema, rows) {
var props = schema.properties || {};
var layoutProps = ownObj(props, 'layout').properties || {};
rows = rows || styleRows(schema).concat(positionRows(schema));
// A Map, not an object: the keys are field names out of a schema, so
// a field literally named "constructor" is a column like any other
// and never touches a prototype.
var seen = new Map();
elementKeys(schema).forEach(function (key) {
Object.keys(ownObj(props, key).properties || {}).forEach(
function (f) { seen.set(f, 'element'); });
Object.keys(ownObj(layoutProps, key).properties || {}).forEach(
function (f) { seen.set(f, 'layout'); });
rows.forEach(function (row) {
if (row.key) {
Object.keys(ownObj(props, row.key).properties || {}).forEach(
function (f) { seen.set(f, 'element'); });
}
if (!row.layoutKey) { return; }
var entry = own(layoutProps, row.layoutKey);
if (entry && typeof entry === 'object' && entry.properties) {
Object.keys(entry.properties).forEach(
function (f) { seen.set(f, 'layout'); });
} else if (entry && typeof entry === 'object') {
// A leaf. Namespaced so an unrelated column sharing its name
// -- another entry's "scale" axis, say -- can never shadow it
// and leave this row's only control blank.
seen.set('layout-leaf:' + row.layoutKey, 'layout-leaf');
}
});
var known = COLUMN_ORDER.filter(function (f) { return seen.get(f); });
// Anything the schema declares that this file has never heard of
@@ -384,10 +469,12 @@
return COLUMN_ORDER.indexOf(f) === -1;
}).sort();
return known.concat(extra).map(function (f) {
var isLeaf = f.indexOf('layout-leaf:') === 0;
var fieldKey = isLeaf ? f.slice('layout-leaf:'.length) : f;
return {
key: f,
key: fieldKey,
where: seen.get(f),
label: own(COLUMN_LABELS, f) || f.replace(/_/g, ' ')
label: own(COLUMN_LABELS, fieldKey) || fieldKey.replace(/_/g, ' ')
};
});
}
@@ -421,21 +508,22 @@
function elementRow(opts) {
var schema = opts.schema;
var key = opts.key;
var key = opts.row.key; // null for a position-only row
var layoutKey = opts.row.layoutKey; // null for a style-only row
var value = opts.value;
var optional = opts.optional;
var props = ownObj(schema.properties || {}, key).properties || {};
var props = key ? (ownObj(schema.properties || {}, key).properties || {}) : {};
var layoutProps = ownObj(schema.properties || {}, 'layout').properties || {};
var axes = ownObj(layoutProps, key).properties || {};
var axes = layoutKey ? (ownObj(layoutProps, layoutKey).properties || {}) : {};
var row = el('div', {
class: 'style-editor-row grid items-center gap-2 py-1',
'data-element': key
'data-element': key || ('layout.' + layoutKey)
});
row.appendChild(el('div', {
class: 'text-sm text-gray-700 style-editor-label',
text: titleOf(schema, key)
text: key ? titleOf(schema, key) : layoutTitleOf(schema, layoutKey)
}));
var sizeInput = null;
@@ -461,8 +549,12 @@
}
opts.columns.forEach(function (col) {
var isLeaf = col.where === 'layout-leaf';
var inLayout = col.where === 'layout';
var prop = inLayout ? own(axes, col.key) : own(props, col.key);
// A leaf column belongs to the one position row named after it;
// every other row leaves that cell blank.
var prop = isLeaf ? (col.key === layoutKey ? own(layoutProps, layoutKey) : null)
: inLayout ? own(axes, col.key) : own(props, col.key);
var cell;
if (!prop) {
// This element does not declare that field; keep the grid
@@ -470,13 +562,19 @@
row.appendChild(el('span'));
return;
}
var path = inLayout ? ['layout', key, col.key] : [key, col.key];
var base = inLayout ? opts.layoutPrefix + '.' + key
: opts.prefix + '.' + key;
var path = isLeaf ? ['layout', layoutKey]
: inLayout ? ['layout', layoutKey, col.key] : [key, col.key];
// Posted under the key the schema declares, never the style key:
// a plugin's own offset reader looks up layout.score, so a value
// saved as layout.score_text would be kept and never drawn. A
// leaf is the field itself, so its name has no sub-field suffix --
// the same name the generic renderer would have posted.
var base = (isLeaf || inLayout) ? opts.layoutPrefix + '.' + layoutKey
: opts.prefix + '.' + key;
var node = control({
key: col.key,
prop: prop,
name: base + '.' + col.key,
name: isLeaf ? base : base + '.' + col.key,
current: effective(value, path, prop, optional),
optional: optional,
fonts: opts.fonts,
@@ -500,11 +598,11 @@
return row;
}
function header(columns) {
function header(columns, firstLabel) {
var row = el('div', {
class: 'style-editor-row style-editor-head grid gap-2 pb-1 mb-1 border-b border-gray-300'
});
['Element'].concat(columns.map(function (c) { return c.label; }))
[firstLabel || 'Element'].concat(columns.map(function (c) { return c.label; }))
.forEach(function (label) {
row.appendChild(el('div', {
class: 'text-xs font-semibold text-gray-500 uppercase',
@@ -516,7 +614,9 @@
function table(opts) {
var wrap = el('div', { class: 'style-editor-table' });
var columns = columnsFor(opts.schema);
// Columns per table, so the positions table does not inherit a Font
// column and the style table does not grow a "home x offset" one.
var columns = columnsFor(opts.schema, opts.rows);
// Sized here rather than in CSS: the column count depends on what
// the plugin declared.
wrap.style.gridTemplateColumns = '';
@@ -524,11 +624,11 @@
'minmax(7rem, 1.4fr) ' + columns.map(function (c) {
return COLUMN_WIDTHS[c.key] || '5rem';
}).join(' '));
wrap.appendChild(header(columns));
elementKeys(opts.schema).forEach(function (key) {
wrap.appendChild(header(columns, opts.firstLabel));
opts.rows.forEach(function (row) {
wrap.appendChild(elementRow({
schema: opts.schema,
key: key,
row: row,
columns: columns,
prefix: opts.prefix,
layoutPrefix: opts.prefix + '.layout',
@@ -540,6 +640,29 @@
return wrap;
}
/** One panel's tables: the styled elements, then anything only positioned. */
function panelBody(opts) {
var body = el('div');
var shared = {
schema: opts.schema, prefix: opts.prefix, value: opts.value,
fonts: opts.fonts, optional: opts.optional
};
var styled = styleRows(opts.schema);
if (styled.length) {
body.appendChild(table(Object.assign({ rows: styled }, shared)));
}
var positioned = positionRows(opts.schema);
if (positioned.length) {
body.appendChild(el('div', {
class: 'text-xs font-semibold text-gray-500 uppercase mt-4 mb-1 style-editor-subhead',
text: 'Other positions'
}));
body.appendChild(table(Object.assign(
{ rows: positioned, firstLabel: 'Item' }, shared)));
}
return body;
}
// ---- widget ----------------------------------------------------------
window.LEDMatrixWidgets.register('style-editor', {
@@ -555,6 +678,20 @@
var root = el('div', { class: 'style-editor' });
container.appendChild(root);
// Published synchronously, because the host reads it the moment
// this returns while the panels below wait on the font catalog.
// layout and modes count as ours: their fields appear as columns
// in these rows, so leaving them to the generic renderer would
// post every offset twice from two different controls.
var owned = elementKeys(schema);
if (ownObj(schema.properties || {}, 'layout').properties) {
owned = owned.concat(['layout']);
}
if (ownObj(schema.properties || {}, 'modes').properties) {
owned = owned.concat(['modes']);
}
container.dataset.ownedKeys = owned.join(',');
loadFonts().then(function (fonts) {
var modeProps = ownObj(schema.properties || {}, 'modes').properties || {};
var modes = Object.keys(modeProps);
@@ -596,7 +733,7 @@
tabs.appendChild(b);
}
panel('__base__', table({
panel('__base__', panelBody({
schema: schema, prefix: base, value: current,
fonts: fonts, optional: false
}));
@@ -610,7 +747,7 @@
class: 'text-xs text-gray-500 mb-2',
text: 'Anything left blank follows the "All modes" tab.'
}));
node.appendChild(table({
node.appendChild(panelBody({
schema: modeSchema,
prefix: base + '.modes.' + mode,
value: modeValue,
@@ -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>
`;
}
+161 -50
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);
@@ -1565,32 +1556,32 @@ function renderInstalledCards(plugins, total) {
return `
<div class="plugin-card">
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0">
<div class="flex items-center flex-wrap gap-2 mb-2">
<div class="mb-4">
<!-- Header row: only the name and badges share a line with the
toggle, so the metadata and description below get the
card's full width. -->
<div class="flex items-start justify-between gap-3 mb-2">
<div class="flex items-center flex-wrap gap-2 min-w-0">
<h4 class="font-semibold text-gray-900 text-base">${escapeHtml(plugin.name || plugin.id)}</h4>
${plugin.is_starlark_app ? '<span class="badge badge-warning"><i class="fas fa-star mr-1"></i>Starlark</span>' : ''}
${plugin.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : ''}
</div>
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p>
${plugin.version ? `<p class="flex items-center flex-wrap gap-1.5"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${escapeHtml(plugin.version)}${plugin.update_available && plugin.latest_version ? `<span class="badge badge-info" title="Installed v${escapeAttr(plugin.version)} → latest v${escapeAttr(plugin.latest_version)}"><i class="fas fa-arrow-circle-up mr-1"></i>v${escapeHtml(plugin.latest_version)} available</span>` : ''}</p>` : ''}
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
</div>
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p>
</div>
<!-- Toggle Switch in Top Right -->
<div class="flex-shrink-0 ml-4">
<div class="flex-shrink-0">
<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>
@@ -1603,6 +1594,13 @@ function renderInstalledCards(plugins, total) {
</div>
</label>
</div>
</div>
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p>
${plugin.version ? `<p class="flex items-center flex-wrap gap-1.5"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${escapeHtml(plugin.version)}${plugin.update_available && plugin.latest_version ? `<span class="badge badge-info" title="Installed v${escapeAttr(plugin.version)} → latest v${escapeAttr(plugin.latest_version)}"><i class="fas fa-arrow-circle-up mr-1"></i>v${escapeHtml(plugin.latest_version)} available</span>` : ''}</p>` : ''}
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
</div>
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p>
</div>
<!-- Plugin Tags -->
@@ -1638,9 +1636,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 +1894,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 +2186,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 +2224,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 +2467,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 +2918,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 +3767,7 @@ function getStoreFilter() {
search: {
el: 'plugin-search',
fields: ['name', 'description', 'author', 'id', 'category', 'tags'],
debounceMs: 300,
debounceMs: 150,
},
sort: {
el: 'store-sort',
@@ -3781,7 +3882,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 +3931,7 @@ function renderPluginStore(plugins) {
</div>
</div>
</div>`;
}).join('');
}).join(''));
}
// Expose functions to window for onclick handlers
@@ -4004,8 +4105,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 +4704,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 +5093,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 +5111,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 +5244,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 +5257,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 +5265,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 +5295,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 +5484,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 +5932,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 +6011,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 +6043,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>
+232 -85
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,58 @@ 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>
<div id="handoff-view" class="card hidden" tabindex="-1" aria-labelledby="handoff-title">
<div class="success-box">
<svg class="icon" style="color:var(--accent)" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>
<p class="title" id="handoff-title">Connecting to <span id="handoff-ssid"></span></p>
<p class="hint">LEDMatrix-Setup has turned off, so this page can’t follow along any more.</p>
<p class="hint"><strong>If it worked:</strong> the LED display shows the new address. Rejoin your home WiFi and open it.</p>
<p class="hint"><strong>If it didn’t:</strong> LEDMatrix-Setup comes back within a few minutes. Rejoin it and reopen this page to see why.</p>
</div>
</div>
@@ -94,17 +117,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 +187,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,39 +249,88 @@ 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') {
if (data.status === 'pending') {
// Connecting in the background; this network is about to go away.
showMsg(handoffMessage(ssid), 'info');
setTimeout(function() { checkNewIP(ssid); }, 3000);
} else if (data.status === 'success') {
clearMsg();
// Poll for the new IP
setTimeout(function() { checkNewIP(ssid); }, 3000);
} else {
var msg = data.error_type === 'wrong_password'
? 'Incorrect password — please try again'
: (data.message || 'Connection failed');
showMsg(msg, 'err');
connecting = false;
btn.disabled = false;
btn.innerHTML = 'Connect';
showConnectError(data, ssid);
}
})
.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');
setTimeout(function() { showSuccessFallback(ssid); }, 5000);
showHandoff(ssid);
});
}
function handoffMessage(ssid) {
return 'Connecting to ' + ssid + '. LEDMatrix-Setup will turn off and your phone will disconnect from it. ' +
'If it works, the LED display shows its new address. If not, LEDMatrix-Setup comes back within a few minutes: ' +
'rejoin it and reopen this page to see why.';
}
function resetConnectBtn() {
var btn = $('btn-connect');
connecting = false;
btn.removeAttribute('aria-busy');
btn.textContent = 'Connect';
updateConnectBtn();
}
// `attempt` is a /wifi/connect error body or a failed last_connect_attempt.
function showConnectError(attempt, ssid) {
var name = attempt.ssid || ssid;
var wrongPassword = attempt.error_type === 'wrong_password';
var msg = wrongPassword
? 'Incorrect password for ' + name + ' — please try again.'
: (attempt.message || 'Couldn’t connect to ' + name + '. Check the network name and password, then try again.');
// A background failure brings the AP back: put the form back too.
$('handoff-view').classList.add('hidden');
$('setup-form').classList.remove('hidden');
showMsg(msg, 'err');
resetConnectBtn();
if (wrongPassword) $('password').focus();
}
function showHandoff(ssid) {
$('setup-form').classList.add('hidden');
$('success-view').classList.add('hidden');
var view = $('handoff-view');
view.classList.remove('hidden');
$('handoff-ssid').textContent = ssid;
clearMsg();
view.focus();
}
var MAX_IP_RETRIES = 20;
function checkNewIP(ssid, retriesLeft) {
@@ -216,36 +339,60 @@ 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);
var d = (data.status === 'success' && data.data) || {};
var attempt = d.last_connect_attempt;
if (attempt && attempt.ssid === ssid && attempt.state === 'failed') {
showConnectError(attempt);
} else if (d.connected && d.ip_address && !d.ap_mode_active) {
showSuccess(d.ip_address);
} else {
setTimeout(function() { checkNewIP(ssid, retriesLeft - 1); }, 3000);
}
})
.catch(function() {
// AP likely down — show fallback
showSuccessFallback(ssid);
// The setup network went down, which is what connecting does.
showHandoff(ssid);
});
}
function showSuccess(ip) {
// A failed attempt brings LEDMatrix-Setup back; tell the user who rejoined why.
function showPreviousFailure() {
fetchWithTimeout('/api/v3/wifi/status', {}, 8000)
.then(readJson)
.then(function(data) {
var attempt = data.data && data.data.last_connect_attempt;
if (attempt && attempt.state === 'failed' && !data.data.connected) {
var reason = attempt.error_type === 'wrong_password'
? 'the password was incorrect'
: (attempt.message || 'an unknown error');
showMsg('Last attempt to connect to ' + attempt.ssid + ' failed: ' + reason + '.', 'err');
}
})
.catch(function() {});
}
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
showPreviousFailure();
doScan();
</script>
</body>
@@ -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,27 +88,46 @@
</div>
<script>
(function() {
function fallbackIsDirty(root) {
if (!root) { return false; }
var controls = root.querySelectorAll('input, select, textarea');
for (var i = 0; i < controls.length; i++) {
var c = controls[i];
if (c.type === 'checkbox' || c.type === 'radio') {
if (c.checked !== c.defaultChecked) { return true; }
} else if (c.tagName === 'SELECT') {
for (var j = 0; j < c.options.length; j++) {
if (c.options[j].selected !== c.options[j].defaultSelected) { return true; }
}
} else if (c.value !== c.defaultValue) {
return true;
}
}
return false;
// ~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(); }
}
// Did a *person* type into the fallback before this widget
// took over? Comparing values against their defaults cannot
// answer that. The fallback holds this block's own font
// fields, and the font-selector widget populates them on the
// same 50ms timer -- so a plain page load with nobody
// touching anything raced into "dirty" (measured: seven
// customization.*.font selects, ~60ms in) and silently
// removed the style editor, leaving the accordion form it
// exists to replace.
//
// isTrusted is false for programmatic changes and true only
// for real input, which is exactly the question. The
// listeners go on now, synchronously, because the edit to
// protect can happen before initWidget ever runs.
var userEdited = false;
(function watchForRealEdits() {
var fb = document.getElementById('{{ field_id }}_fallback');
if (!fb) { return; }
['input', 'change'].forEach(function (evt) {
fb.addEventListener(evt, function (e) {
if (e && e.isTrusted) { userEdited = true; }
}, true);
});
})();
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; }
@@ -116,7 +135,7 @@
// while this widget's script is still loading; swapping
// in the widget now would replace them with the stale
// server-rendered value and silently drop that edit.
if (fallbackIsDirty(fallback)) { container.remove(); return; }
if (userEdited) { container.remove(); return; }
var value = {{ obj_value|tojson|safe }};
var config = { schema: {{ prop|tojson|safe }} };
try {
@@ -130,9 +149,32 @@
container.remove();
return;
}
// The widget owns these fields now; drop the generic
// rendering so the form does not post both.
if (fallback) { fallback.remove(); }
// The widget owns the style blocks now, so drop those from
// the generic rendering or the form would post both. Only
// those: a customization block can also hold a feature of
// its own (football keeps favorite_result_colors there),
// and removing the section wholesale took the only UI that
// feature had with it.
if (fallback) {
var owned = (container.dataset.ownedKeys || '')
.split(',').filter(function (k) { return k; });
owned.forEach(function (k) {
// CSS.escape: k is a schema property key read
// back off dataset.ownedKeys, not a literal --
// building the attribute selector by raw
// concatenation would break (or be steerable)
// on a key containing a quote or backslash.
var node = fallback.querySelector(
'[data-child-key="' + CSS.escape(k) + '"]');
if (node) { node.remove(); }
});
// Nothing left but the section heading, or a widget
// that reported nothing: drop the lot, as before.
if (!owned.length
|| !fallback.querySelector('[data-child-key]')) {
fallback.remove();
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWidget);
@@ -145,8 +187,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>
@@ -254,7 +315,14 @@
{# Array - check for file upload widget first (to avoid breaking static-image plugin), then checkbox-group, then array of objects #}
{% elif field_type == 'array' %}
{% set x_widget = prop.get('x-widget') or prop.get('x_widget') %}
{# An array of objects with no widget named would otherwise reach the
comma-separated text input, which joins each item as a Python dict
repr and posts it back as a list of strings -- the schema rejects
that and every save of the plugin returns 400. The table widget
already edits any array of objects, so make it the default. #}
{% set _items = prop.get('items') or {} %}
{% set x_widget = prop.get('x-widget') or prop.get('x_widget')
or ('array-table' if (_items.get('type') == 'object' and _items.get('properties')) else none) %}
{% if x_widget == 'file-upload' %}
{# File upload widget for arrays #}
{% set upload_config = prop.get('x-upload-config') or {} %}
@@ -277,7 +345,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 +382,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 +409,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 +420,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 +462,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 +583,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 +592,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,9 +611,13 @@
<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', '') }}">
{# Only when there is one, as custom-feeds.js does: a blank id
is saved as null and fails the id's string type. #}
{% if logo_value.get('id') %}
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.id" value="{{ logo_value.get('id') }}">
{% endif %}
{% else %}
<span class="text-xs text-gray-400">No logo</span>
{% endif %}
@@ -549,13 +630,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 +707,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 +737,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 +745,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 +758,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 +767,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 +801,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 +818,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 +899,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 +911,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 +920,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"
@@ -1016,8 +1112,21 @@
{% set section_id = (plugin_id ~ '-section-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
{% set description = prop.description if prop.description else '' %}
{% set nested_value = value if value else {} %}
{# Only a dict can be looked into. A schema that turns a legacy scalar into
an object (news' dynamic_duration: true -> {enabled, ...}) still finds
the scalar in config.json until the next save, and `key in true` is a
TypeError that takes down the whole config page. A legacy boolean was
the on/off switch the object's `enabled` now holds, so carry it over --
rendering {} would draw that checkbox unchecked and the next save would
quietly turn the feature off. The save then writes the object. #}
{% if value is mapping %}
{% set nested_value = value %}
{% elif value is boolean and 'enabled' in (prop.properties or {}) %}
{% set nested_value = {'enabled': value} %}
{% else %}
{% set nested_value = {} %}
{% endif %}
<div class="nested-section border border-gray-300 rounded-lg mb-4">
<button type="button"
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"
@@ -1036,7 +1145,12 @@
{% if nested_key in prop.properties %}
{% set nested_prop = prop.properties[nested_key] %}
{% set nested_val = nested_value[nested_key] if nested_key in nested_value else none %}
{{ render_field(nested_key, nested_prop, nested_val, full_key, plugin_id) }}
{# Tagged so a widget that takes over part of a block can
drop exactly the children it owns and leave the rest
rendered, rather than removing the whole section. #}
<div data-child-key="{{ nested_key }}">
{{ render_field(nested_key, nested_prop, nested_val, full_key, plugin_id) }}
</div>
{% endif %}
{% endfor %}
</div>
@@ -1169,7 +1283,7 @@
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-md font-medium text-gray-900 mb-3">Configuration</h3>
{{ ui.settings_filter("Filter this plugin's settings…") }}
<div class="space-y-4 max-h-96 overflow-y-auto pr-2">
<div class="space-y-4">
{% if schema and schema.properties %}
{# Use property order if defined, otherwise use natural order #}
{# Skip 'enabled' field - it's handled by the header toggle #}
@@ -1235,20 +1349,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>
+76 -4
View File
@@ -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'
@@ -194,7 +197,11 @@
<!-- Messages -->
<div x-show="message"
x-text="message"
:class="messageType === 'error' ? 'bg-red-50 border-red-200 text-red-800' : 'bg-green-50 border-green-200 text-green-800'"
:class="{
error: 'bg-red-50 border-red-200 text-red-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800'
}[messageType] || 'bg-green-50 border-green-200 text-green-800'"
class="mt-4 p-3 rounded border"
x-cloak></div>
</div>
@@ -211,6 +218,7 @@ function wifiSetup() {
ap_mode_active: false,
auto_enable_ap_mode: true // Default: true (safe due to grace period)
},
statusLoaded: false,
networks: [],
selectedSSID: '',
manualSSID: '',
@@ -255,7 +263,15 @@ function wifiSetup() {
const response = await fetch('/api/v3/wifi/status');
const data = await response.json();
if (data.status === 'success') {
const firstLoad = !this.statusLoaded;
this.status = data.data;
this.statusLoaded = true;
// A failed attempt brings LEDMatrix-Setup back; tell the
// user who rejoined it why.
const attempt = data.data.last_connect_attempt;
if (firstLoad && attempt && attempt.state === 'failed' && !data.data.connected) {
this.showMessage(this.describeFailedAttempt(attempt), 'error');
}
} else {
this.showMessage(data.message, 'error');
}
@@ -313,6 +329,10 @@ function wifiSetup() {
this.connecting = true;
this.message = '';
const viaSetupNetwork = this.status.ap_mode_active;
if (viaSetupNetwork) {
this.showMessage(this.handoffMessage(ssid.trim()), 'info');
}
try {
const response = await fetch('/api/v3/wifi/connect', {
method: 'POST',
@@ -324,7 +344,16 @@ function wifiSetup() {
password: this.password || ''
})
});
if (response.status === 202) {
// Connecting in the background; the setup network is about
// to go down and take this page's connection with it.
this.password = '';
this.showMessage(this.handoffMessage(ssid.trim()), 'info');
this.pollConnectResult(ssid.trim());
return;
}
// Check if response is ok before parsing JSON
if (!response.ok) {
// Try to parse error message from response
@@ -359,13 +388,56 @@ function wifiSetup() {
this.showMessage(data.message || 'Failed to connect to network', 'error');
}
} catch (error) {
console.error('WiFi connect error:', error);
this.showMessage('Failed to connect: ' + error.message, 'error');
if (viaSetupNetwork) {
// The setup network went down mid-request: that is the
// connection attempt happening, not a failure.
this.showMessage(this.handoffMessage(ssid.trim()), 'info');
} else {
console.error('WiFi connect error:', error);
this.showMessage('Failed to connect: ' + error.message, 'error');
}
} finally {
this.connecting = false;
}
},
handoffMessage(ssid) {
return `Connecting to ${ssid}. LEDMatrix-Setup will turn off, so this page will stop responding. ` +
`If it works, the LED display shows the new address; rejoin your home WiFi and open it. ` +
`If it fails, LEDMatrix-Setup comes back within a few minutes: rejoin it and reopen this page to see why.`;
},
// Follow a background connect until its result lands or the setup
// network drops out from under this page.
async pollConnectResult(ssid, retriesLeft = 20) {
if (retriesLeft <= 0) return;
await new Promise(resolve => setTimeout(resolve, 3000));
try {
const response = await fetch('/api/v3/wifi/status');
const data = await response.json();
const attempt = data.data && data.data.last_connect_attempt;
if (attempt && attempt.ssid === ssid && attempt.state !== 'pending') {
this.status = data.data;
if (attempt.state === 'success') {
this.showMessage(attempt.message || `Connected to ${ssid}`, 'success');
} else {
this.showMessage(this.describeFailedAttempt(attempt), 'error');
}
return;
}
} catch (error) {
return; // Setup network is down; the handoff message already explains.
}
this.pollConnectResult(ssid, retriesLeft - 1);
},
describeFailedAttempt(attempt) {
const reason = attempt.error_type === 'wrong_password'
? 'the password was incorrect'
: (attempt.message || 'unknown error');
return `Could not connect to ${attempt.ssid}: ${reason}.`;
},
async disconnectFromNetwork() {
if (!this.status.connected) {
this.showMessage('Not connected to any network', 'warning');
+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