feat(core): one per-element display-customization framework, wired into the web UI (#566)

* fix(sports): rebuild un-shared faces through the pinned layout engine

unshare_element_fonts re-instantiates a duplicate font face so two
elements can be told apart by id(). It did so through bare
ImageFont.truetype, which takes PIL's default layout engine rather than
the one src/common/font_layout.py pins. Raqm and Basic disagree on
fractional advances -- that disagreement is the reason the pin exists,
having broken golden images across machines -- so a rebuilt face could
measure differently from the shared face it replaced, on any host where
Raqm is installed.

These were the only two call sites in src/ bypassing the pin.

The guard asserts that the rebuild goes through the pinned loader rather
than comparing engine values: where Raqm is absent, bare truetype returns
BASIC anyway, so an engine comparison passes whether or not the pin is
honoured. The first draft of this test did exactly that and passed with
the bug reintroduced.

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

* refactor(web): drop the two dead client-side config-form renderers

generateConfigForm and generateSimpleConfigForm (580 lines) were defined
on the Alpine component and never called: server-side Jinja replaced them,
as pages_v3.py:641 records. Nothing in any template invokes them -- there
is no x-html in the templates and no bracket access on the component.

They carried their own x-widget dispatch, which made them an active trap:
the next person adding a widget would reasonably think both renderers
needed updating.

plugins/config_manager.js (PluginConfigManager, 133 lines) goes for the
same reason -- loaded on every page from base.html, referenced only by
itself and by an archived doc.

Kept, having checked them: widgets/example-color-picker.js is the worked
example docs/widget-guide.md points plugin authors at, and
widgets/plugin-loader.js is the client half of a documented feature
(manifest-declared plugin widgets) whose server route is missing --
soccer-scoreboard already ships a widgets/custom-leagues.js that this
loader is meant to fetch. That is an unfinished feature to complete, not
dead code to delete.

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

* feat(web): serve plugin-declared widgets, and actually ask for them

LEDMatrixWidgets.loadPluginWidget has always fetched
/static/plugin-widgets/<plugin>/<widget>.js, and docs/widget-guide.md has
always documented that path, but nothing served it. soccer-scoreboard has
shipped a 17KB widgets/custom-leagues.js since August that could never
load. Both halves were missing, not just the route:

- serve_plugin_widget serves the script from the plugin's widgets/
  directory as text/javascript. The manifest is the allowlist -- only a
  widget the plugin declares is reachable -- so installing a plugin does
  not publish everything it ships. Path handling mirrors the sibling
  serve_plugin_web_ui: allowlist regexes, os.path.basename, resolve() +
  relative_to() containment, and the ledmatrix- prefix fallback. The
  declared script name is guarded too, since it comes from the plugin
  rather than the request.

- The config form never requested one. Its x-widget dispatch is a
  hardcoded list of core widget names, so a plugin's own widget fell
  through to a plain text input. An unrecognised x-widget on a string
  field now asks ensureWidget() for it. The text input stays as the
  fallback and is removed only once the widget has actually rendered, so
  a missing or broken widget costs the user an editor rather than their
  configured value on the next save.

- manifest_schema.json gains "widgets", so the declaration is validated
  rather than merely tolerated by additionalProperties.

Verified in a browser against the real partial: a declared widget loads,
registers and renders, and its field posts exactly one value; a field
whose widget 404s keeps its text input and still posts its value.

Not addressed: loadPluginWidgetsFromManifest still has no caller. The
per-field ensureWidget path is lazier and is what the form now uses, so
that bulk helper is dead weight -- worth removing, but left alone here
rather than inventing a call site for it.

Known limitation, documented: only string-typed fields take this path.
object/array/boolean/number fields and enums are dispatched by the
template's own branches, which still only know core widgets.

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

* fix(element-style): a wrong-size BDF now keeps its font, not its size

BDF fonts are fixed-size bitmap strikes: FreeType accepts only the pixel
size baked into the file and raises for anything else. 32 of the 35
shipped fonts are BDF, so a size picked in the web UI usually is not a
valid strike -- and load_font caught that failure with its generic
"unloadable font" handler, which substitutes PressStart2P. Asking for
5x7.bdf at size 10 therefore rendered a completely different typeface,
silently.

It now falls back to the file's own native size instead, which is what
SportsCore._load_custom_font_from_element_config has always done. The
native size is read via FontManager._read_bdf_native_size rather than a
fourth copy of that parser, matching how core.py already delegates.

Also here, because they are the same code path:

- native_bdf_size() is exposed for the web UI, which needs to know when a
  size field can take effect at all. None means "free choice".
- ElementStyle.font_size now reports the size actually realised rather
  than the one requested. Callers lay out from it, and reserving space
  for a size nothing was drawn at is how this surfaces.
- The module font cache is a bounded LRU (256) instead of an unbounded
  dict. The display process runs for weeks and every config save can add
  a (font, size) pair; every other hot cache in the codebase is bounded
  this way.

Untouched configs are unaffected: the shipped classic fonts are the three
TTFs, so nothing was hitting the substitution path by default.

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

* feat(element-style): per-mode style and offset overrides

Lets one element be styled differently per situation -- a scoreboard's
live / upcoming / recent cards, weather's current / hourly / daily
screens -- under customization.modes.<mode>.

The mode is bound at construction rather than passed per call. That is
what makes this cheap to adopt: SportsUpcoming and SportsRecent are
already separate instances with distinct SKIN_MODE values, so binding
once makes every existing style()/offset_value() call site mode-aware
without editing any of them. A per-call mode argument exists for the rare
host that renders more than one mode.

The two layers answer different questions, deliberately:

- The base layer keeps the existing "differs from the schema default"
  rule, because the save flow writes the full default object into
  config.json whether or not the user touched it.
- A mode layer is pure override -- its fields default to None, so
  presence is intent. Nothing writes into it unasked, so there is nothing
  for the stricter rule to protect against.

None therefore means inherit, and has to stay distinct from 0: a mode
y_offset of 0 means "sit at the base position", not "no preference".
This is the same distinction scroll_card.switch_* draws with "inherit".

A malformed mode value falls back to the resolved base value rather than
to the caller's default -- caught by the degradation tests, which is what
they are for: resolving the mode first let one bad string in a mode block
silently discard a good base offset.

With no modes block, and for every existing caller, resolution is
unchanged.

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

* feat(element-style): declare per-mode overrides in config_schema.json

A plugin adds "x-style-modes": ["live", "upcoming", "recent"] alongside
its x-style-elements declaration and gets a customization.modes.<mode>
group per mode, with every field of every declared element repeated as an
override.

Those override fields are typed nullable and default to null, which is
the whole trick. The save flow writes schema defaults into config.json
wholesale, so giving a mode field the base element's default would make
every mode a frozen copy of the base the first time a user pressed Save,
and the base would stop reaching them. Null means inherit. The mutation
test for this is explicit: with concrete defaults, a base font_size of 14
resolves as 10 with user_forced set.

min/max from the declaration carry into the mode blocks, so an
out-of-range override is rejected by validation rather than clamped
silently at render time.

Also: the emitted font field now carries "x-widget": "font-selector". The
widget already shipped and the config form already allowlisted it -- the
hint was simply never emitted, so the field rendered as a bare text box
that the user had to type a font filename into.

Verified through the real SchemaManager path -- load_schema, defaults
extraction, merge_with_defaults, validation, then resolution -- rather
than against a hand-built dict, since the thing at risk is what that
pipeline does to a null.

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

* fix(web): render the config form from the schema the save route validates

The form read config_schema.json with a raw json.load while
api_v3.save_plugin_config went through SchemaManager. Those are not the
same schema: SchemaManager applies expand_style_elements, which turns a
compact customization.x-style-elements declaration into the per-element
blocks the form knows how to render.

Without it, that customization object has an x-style-elements key and no
"properties", so the template's object branch matched nothing and the
section rendered as empty space -- while saving still validated against
the expanded shape. of-the-day ships the compact form, so its
customization section has been invisible in the web UI.

pages_v3 gains a schema_manager the way it already has config_manager and
plugin_manager. use_cache=False matches the save route, so an edited
schema is not served stale during plugin development. The raw read stays
as a fallback for callers that register this blueprint without one.

Checked before making the change: load_schema does nothing here except
read, validate and expand -- inject_skin_selector is a separate method it
does not call -- so this is not a behaviour change for schemas without
the declaration.

The test pair renders the same compact schema with and without a
SchemaManager, so it documents exactly what was broken as well as what is
fixed.

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

* feat(web): style-editor widget -- a row per element instead of 65 accordions

Rendered element by element, a realistic scoreboard's customization block
is 65 nested sections, and reaching one per-mode font size takes five
levels of expanding. The widget collapses that to one compact row per
element -- font, size, colour, X, Y -- with a tab per declared mode.

It emits ordinary inputs under the same dotted names the generic renderer
would produce, so the save/validate/merge pipeline is untouched: no hidden
JSON blob and no new server-side parsing. It is driven entirely by the
schema block it is handed, so fields added to the schema later appear
without editing the widget. If it fails to load or throws, the generic
nested rendering it replaces is left in place.

Fixing two things the save path got wrong for nullable fields, found by
posting what the widget actually emits:

- The indexed-array recombiner (text_color.0/.1/.2 -> one list) compared
  the declared type to the string 'array', so a per-mode colour, typed
  ["array", "null"], was never reassembled and failed validation on save.
  _parse_form_value_with_schema had the same comparison.
- A blank nullable field became [] rather than None, which then failed the
  minItems the colour array declares. Null is the inherit sentinel, so it
  has to survive.

And two things the widget itself got wrong, found by looking at it:

- An unset base control fell back to the select's first option, so an
  untouched scoreboard claimed every element used 10x20.bdf -- and the
  size box then locked itself to that bitmap font's fixed size. Base
  controls now show the schema default; mode controls stay blank, because
  blank there means inherit.
- Elements arrived alphabetised (Detail and Odds above Score). Flask's
  JSON provider sorts keys, so declaration order has to be stated
  explicitly; expand_style_elements now emits x-propertyOrder, which the
  generic renderer already honoured too.

Size is disabled and shown as fixed for a bitmap font, using the
scalable/native_size the font catalog now reports.

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

* feat(element-style): visibility, alignment and scale per element

Completes the customization vocabulary: hide an element, align it, and
resize a logo, alongside the font/size/colour/offset that already existed.
All three per mode.

They resolve to "change nothing" until the user asks for something -- True,
None and 1.0 -- rather than to whatever the schema declares. That is the
same invariant the font fields keep: a caller that honours them still
renders an untouched config exactly as it did before they existed. A
schema default therefore does not count as a choice, which matters because
the save flow writes that default into config either way.

scale sits in the layout block with the offsets rather than in the element
block, because it is geometry: a logo has a scale and no font. The widget's
columns come from the schema, so a logo row shows visibility, offsets and
scale and no empty font cell.

Two bugs found by the tests rather than by reading:

- A nullable enum needs null in its enum list, not just in its type. The
  mode copy of `align` defaulted to null and then failed its own schema, so
  a plugin declaring any enum field with modes could not save at all. Six
  tests failed on this before any of them reached what they were testing.
- defaults_from_schema only ever extracted font/font_size/text_color, so
  the schema defaults for the new fields were invisible to the resolver and
  a declared default read as a user choice.

Widget: the table scrolls horizontally and pins the element-name column.
Nine columns do not fit the config panel, and clipping them hid the offsets
entirely while scrolling them made every row anonymous.

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

* feat(element-style): resolve elements under the names plugins actually use

Two naming conventions collided as the scoreboards grew. Counted across
the published schemas: the style block names elements with a _text suffix
(score_text, status_text, detail_text), while the layout block mostly uses
the bare noun (score, date, time, odds) -- except status_text, which kept
the suffix in seven plugins and lost it in two. records vs record splits
seven to two the same way.

A lookup now tries the exact name first and then the spellings that mean
the same thing. Exact-first is what makes this inert for any config that
already matches; the aliases only decide cases that resolved to nothing
before.

This is also what makes migrating to the compact declaration form safe.
That form uses one key for both blocks, so a scoreboard adopting it asks
for layout.score_text while its users have layout.score saved -- without
the aliases, every offset they had dialled in would silently become 0.

Applies to the style block, the layout block, the schema defaults and the
per-mode overrides, since the drift shows up in all four.

Not attempting to canonicalise on write: renaming keys in config.json
would break the plugins still reading the old spelling from their own
bundled code, and the drift costs a dict miss rather than correctness.

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

* feat(plugins): BasePlugin.styles -- per-element styling every plugin inherits

Adopting the element-style system meant repeating three things in every
plugin: a guarded import, finding its own config_schema.json, and
rebuilding the resolver when on_config_change swapped the config dict.
This is those three things once, on the class all 45 plugins already
inherit from.

    title = self.styles.style('title_text',
                              classic_font='PressStart2P-Regular.ttf',
                              classic_size=8, classic_color=(255, 255, 255))

The classic_* arguments are the adoption contract: with nothing configured
they come back verbatim, so a plugin that switches to this renders exactly
as before until a user changes something.

A plugin with one instance per display mode sets STYLE_MODE on the class
and every existing lookup becomes mode-aware without a call site changing
-- which is the point of binding the mode to the resolver rather than
passing it per call. styles_for() covers a plugin that renders several
modes from one instance.

Schema discovery reads the concrete class's own module rather than this
file, because this file lives in src/plugin_system where no plugin schema
exists -- the same trap SportsCore._config_schema_path documents. The
first mutation test for that passed anyway: an installed plugin's module
directory and its entry under plugins_dir are the same path, so the test
could not tell the two apart. The case where they diverge is a plugin
symlinked in for development, and the test now forces that shape.

Getting discovery wrong is silent rather than loud: with no schema the
resolver has no defaults to compare against, so every configured value
reads as a deliberate override and the plugin quietly stops honouring its
own shipped styling.

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

* feat(element-style): adopt hand-written customization blocks, and widen the font list

Nineteen plugins spell their style elements out longhand instead of
declaring them -- football's block is 701 lines for seven elements -- and
predate this system entirely. Core now recognises that shape, so they pick
up the row-per-element editor and the real font picker on a core update
rather than on a plugin release. Checked against every published schema:
21 plugins adopt, and the defaults of each still validate against the
schema generated for it.

Detection requires *every* field in a block to be one this system
understands. A looser "has at least one style field" rule sweeps in
baseball's `count`, which carries a text_color beside geometry that means
nothing here. That distinction took three attempts to test: the first two
assertions passed under both rules, because an over-eager rule leaves a
fontless block looking untouched and only surfaces as an extra row in the
editor.

The hardcoded font enum is replaced rather than extended. Football lists
five of the thirty-five installed fonts, which is why a font a user
uploads can never appear in one. It is not a curated safe set -- it omits
some twenty other faces that fit the declared size cap just as well -- it
is the fonts that happened to exist when it was written.

Widening it does need a guard, though, and not the one the schema already
has: a bitmap font ignores font_size and renders at its size baked into
the file, so `maximum: 16` cannot stop a 27px face. The picker now filters
out fixed-size fonts taller than the element's own declared ceiling, which
drops exactly the four that would overflow a 32px panel and keeps the
other thirty.

Per-mode overrides stay opt-in: core cannot invent a plugin's display
modes, so `x-style-modes` remains the one line that unlocks them. Their
layout half covers every positionable element rather than only those with
a style block -- the two namespaces do not line up in a hand-written
schema, and football positions six things (logos, timeouts, possession)
that have no style block at all.

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

* refactor(web): remove the two Fonts-tab panels that reported invented data

"Element Font Overrides" let a user configure an override, showed a
success toast, and changed nothing. All three endpoints behind it were
stubs -- GET returned a hardcoded {}, POST and DELETE returned success
without calling anything -- each marked "This would integrate with the
actual font system".

Wiring them to FontManager would not have fixed it. The machinery there is
real (_load_overrides/_save_overrides persist config/font_overrides.json,
resolve_font applies them, and the countdown plugin genuinely consumes
it), but the panel's element dropdown offered eleven invented keys --
nfl.live.score, clock.time, weather.current -- that no plugin has ever
read. An override saved against one of those would have persisted
correctly and still done nothing.

"Detected Manager Fonts" goes for the same reason. It claimed to show
"fonts currently in use by managers (auto-detected)"; its own comment said
"we'll simulate this", and it listed every font in the catalog with a
hardcoded usage_count of 1 -- the panel beside it, with fabricated
numbers attached.

Per-element font choice now lives in each plugin's own config editor,
against the elements that plugin actually has, and covers size, colour,
offsets, visibility, alignment and scale rather than family and size.

Kept: the font library (upload, preview, delete), which works, and
/fonts/tokens, which is a stub but genuinely feeds the preview's size
dropdown. FontManager's override methods are untouched -- countdown uses
them.

Verified in a browser with the tab's JS running: no console errors, 35
fonts listed, upload and preview intact. Removing the panel meant unwiring
it from populateFontSelects too, which would otherwise have bailed out
early on the missing select and left the preview dropdown empty.

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

* refactor(sports): one reader for element colours and layout offsets

There were two copies of the per-element colour read and three of the
layout-offset read. They had already drifted -- the scroll-card renderer
carries a comment about having ignored offsets its own schema advertised
-- and each new capability had to be added to all of them or silently work
in some places and not others.

All of them now go through src.element_style, which is what carries the
alias handling and the per-mode lookup. That lands immediately for the
nine plugins importing these modules: a scoreboard asking for `score_text`
offsets finds the `layout.score` its users configured, and a Live instance
resolves its own colours through SKIN_MODE without any call site passing a
mode.

_normalize_color learned "#RRGGBB" in the process. The scoreboards' own
readers have always accepted it, so the shared one had to, or consolidating
would have quietly dropped a form users' configs may hold. _coerce_offset
picked up the non-finite guard the scroll-card reader had and the other two
did not.

_get_layout_offset is promoted onto SportsCoreSharedMixin. Each plugin
still carries its own copy in its bundled sports.py, which wins by MRO --
so adopting this is a deletion in the plugin, and until that deletion
nothing changes for it.

Note for whoever runs the suite next: test_display_dirty_tracking.py is
order-dependent. Fifteen of its tests failed in one full run and passed in
the next with no change in between, and pass in isolation. Pre-existing,
unrelated to this, but it makes a full-run diff untrustworthy until it is
fixed.

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

* docs(changelog): record the element-style work under Unreleased

This file's own preamble asks for it: a plugin may delete its bundled
fallback copy of a core module only when its manifest floors on the first
release that shipped that module, which requires the additions to be
recorded here against a version.

Names a plugin can now import and floor on -- the stateless layout_offset
and element_color readers, alias_keys, native_bdf_size, the resolver's mode
binding, BasePlugin.styles, and the promoted
SportsCoreSharedMixin._get_layout_offset -- plus the schema and web-UI
changes, the four fixes and the three removals.

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

* fix(fonts): log the BDF native-size read failure instead of swallowing it

The bdf-native-size lookup in get_fonts_catalog() caught any exception
and silently discarded it. Every other guarded read added in this PR
(the manifest parse in _declared_widget_script, the SchemaManager
fallback in _load_plugin_config_partial) logs before falling through
to the same degraded behavior. This one didn't, which is the shape a
silent-exception-swallow lint rule flags. Behavior is unchanged --
native_size still comes back None -- but a corrupt or unreadable BDF
file now leaves a trace.

Verified: font-related tests (140) and the full suite still pass,
with only the 2 pre-existing Europe/Kiev/Asia/Calcutta tzdata-alias
failures already present on origin/main.

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

* fix: address CodeRabbit findings on the style-editor/font-selector PR

- Fix _load_font_sized double-wrapping the (font, size) tuple on the
  missing-font path, which handed callers a tuple instead of a font.
- Fix _set_nested_value skipping an explicit None when the key already
  existed, which silently kept stale overrides when a user cleared a
  nullable per-mode field or blanked all channels of an indexed color.
- Preserve BDF scalable/native_size metadata through fetchFontCatalog's
  catalog-format mapping so maxFixedSize filtering actually applies.
- Stop caching an empty array on a failed font-catalog fetch so a later
  call can retry instead of being stuck with the failed result.
- Keep a saved font selected in the style editor even when it no longer
  fits a newly declared maxFixedSize, instead of silently deselecting it.
- Don't drop in-progress user edits to fallback fields when a plugin
  widget finishes loading asynchronously and takes over the form.
- Tighten the removed font-override endpoint test to assert 405, not
  just != 200.

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

* fix(web): a partial save no longer switches off checkboxes it never showed

An HTML checkbox posts nothing when unchecked, so the save route walked the
schema and forced every boolean missing from the form to False. That is right
for the rendered form and wrong for every other caller: a script, the MQTT
bridge or a curl against the documented endpoint never rendered a checkbox, and
reading its silence as "all off" turns a one-field save into a mass disable.

Found on hardware. Posting four customization.* keys to a live device switched
off nfl.enabled, ncaa_fb.enabled and every display-mode toggle in one request.

The form now reports the top-level sections it drew (__rendered_section), and
inside those an absent checkbox still means unchecked -- including a section
whose only fields are checkboxes that are all off, which no heuristic could
recover. A post with no marker only touches objects it actually posted a field
from. Meta fields are dropped before form keys are treated as config paths,
because unknown keys are otherwise written straight into config.json.

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

* feat(sports): resolve element colour by name, and honour visible/align/scale

Two of the three gaps this framework shipped with.

Colour by name. A draw resolved its colour by comparing the *identity* of the
font object it was handed, which cannot tell two elements apart when they share
a face -- so those draws went out white. Every bitmap font is in that case,
because a freetype.Face cannot be re-instantiated to un-share it, which is how
an element rendered in any of the 32 shipped BDF fonts silently lost a colour
its picker had offered all along. _draw_text_with_outline now takes
element="score_text" and reads the colour by name; the identity path remains
for un-annotated callers, but narrows before giving up -- one configured colour
among the sharers is the only thing the user can have meant.

Visible, align and scale. The resolver has understood these since the
framework landed and nothing consumed them: an element could be marked hidden
in the web UI and still render. Adds the stateless readers, the mixin
accessors, and a scale parameter on the one shared logo-sizing seam (keyed into
the cache, so two elements scaled differently cannot be served each other's
image). Naming an element in a draw also honours its visibility.

Untouched configs are unaffected: every new parameter defaults to today's
behaviour, and all ten affected plugins render pixel-identically to main across
every harness size.

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

* docs(plugins): how to declare styleable elements; harden the widget's lookups

The plugin-author guide for the compact x-style-elements declaration -- what
each key does, how to read values back without breaking the "user-forced only
when it differs from the default" rule, and why a hand-written block needs no
changes to be adopted.

Also clears the static-analysis findings on style-editor.js. Every lookup in
that file is keyed by something out of a schema or a saved config, so a key of
__proto__ or constructor would walk the prototype chain and hand back a
function instead of a schema; reads now go through an own-property helper. The
panel registry became a list, and the flagged vars moved to their function
roots.

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

* fix(web): clear the remaining static-analysis findings

Five, all on lines this branch touched.

The Python one is not a new defect: _set_missing_booleans_to_false's first
parameter was always named `config`, which shadows the `config` submodule
imported for its side effects at the bottom of this module. Editing the
signature simply put the existing warning on a changed line. The parameter is
the plugin's config dict, so `plugin_config` is what it should have been called
anyway; callers pass it positionally and are unaffected.

The JavaScript ones are the object-injection rule firing on reads keyed by
data. own() now goes through a property descriptor, so the one unavoidable
data-keyed read is no longer a computed member access; at() consumes its path
instead of indexing it; and the column set is a Map, which has no prototype to
pollute and needs no guarded reads at all.

Verified the widget still renders identically against football's real schema:
29 element rows, all four mode tabs, values populated, no console errors.

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

* fix(web): drop the hasOwnProperty alias the descriptor read made redundant

own() now reads through Object.getOwnPropertyDescriptor, so the alias it used to call has no remaining reference.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-13 11:53:50 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 92ac231138
commit 69d408b321
39 changed files with 6572 additions and 2337 deletions
+1
View File
@@ -168,6 +168,7 @@ pages_v3.config_manager = config_manager
pages_v3.plugin_manager = plugin_manager
pages_v3.plugin_store_manager = plugin_store_manager
pages_v3.saved_repositories_manager = saved_repositories_manager
pages_v3.schema_manager = schema_manager
api_v3.config_manager = config_manager
api_v3.plugin_manager = plugin_manager
+116 -26
View File
@@ -808,6 +808,31 @@ def _is_field_required(key_path, schema):
return field_name in required
# Sentinel object to indicate a field should be skipped (not set in config)
_SKIP_FIELD = object()
def _schema_type_is(prop, wanted):
"""Whether a schema property is of ``wanted`` type.
JSON Schema allows a union (``["array", "null"]``), which the per-element
style system uses for its per-mode override fields: null there means
"inherit the base", so the type genuinely is "an array or nothing". A
bare ``prop.get('type') == 'array'`` reads False for those, which meant
the indexed colour inputs a form posts as ``...text_color.0/.1/.2`` were
never recombined into a list.
"""
if not isinstance(prop, dict):
return False
declared = prop.get('type')
if isinstance(declared, list):
return wanted in declared
return declared == wanted
def _schema_allows_null(prop):
"""Whether a schema property's declared type includes null."""
return _schema_type_is(prop, 'null')
def _parse_form_value_with_schema(value, key_path, schema):
"""
Parse a form value using schema information to determine correct type.
@@ -828,11 +853,17 @@ def _parse_form_value_with_schema(value, key_path, schema):
# Handle None/empty values
if value is None or (isinstance(value, str) and value.strip() == ''):
# A nullable field left blank means null, not an empty container.
# This is the inherit sentinel for per-mode style overrides: an
# empty list there would read as "the user chose no colour" rather
# than "follow the base element".
if _schema_allows_null(prop):
return None
# If schema says it's an array, return empty array instead of None
if prop and prop.get('type') == 'array':
if prop and _schema_type_is(prop, 'array'):
return []
# If schema says it's an object, return empty dict instead of None
if prop and prop.get('type') == 'object':
if prop and _schema_type_is(prop, 'object'):
return {}
# If it's an optional string field, preserve empty string instead of None
if prop and prop.get('type') == 'string':
@@ -869,7 +900,7 @@ def _parse_form_value_with_schema(value, key_path, schema):
return False
# Handle arrays based on schema
if prop and prop.get('type') == 'array':
if prop and _schema_type_is(prop, 'array'):
# Try parsing as JSON first (handles "[1,2,3]" format)
if stripped.startswith('['):
try:
@@ -892,7 +923,7 @@ def _parse_form_value_with_schema(value, key_path, schema):
return []
# Handle objects based on schema
if prop and prop.get('type') == 'object':
if prop and _schema_type_is(prop, 'object'):
# Try parsing as JSON
if stripped.startswith('{'):
try:
@@ -995,17 +1026,64 @@ def _set_nested_value(config, key_path, value):
current[seg] = {}
current = current[seg]
# Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure)
if value is not None or segments[-1] not in current:
current[segments[-1]] = value
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
# Set the final value. _SKIP_FIELD (checked above) is the only sentinel
# for "leave the existing value alone" -- an explicit None here is a real
# value (e.g. the per-mode "inherit the base" override) and must overwrite
# whatever was already stored.
current[segments[-1]] = value
#: Hidden field the rendered plugin form repeats once per top-level section it
#: drew. Named with a leading underscore pair so the save path can drop it (and
#: anything else meta) before treating form keys as config paths.
_RENDERED_SECTION_FIELD = '__rendered_section'
def _boolean_is_in_scope(full_path, prefix, sections, submitted_parents):
"""Whether a missing checkbox at ``full_path`` may be forced to False.
"Missing" only means "unchecked" for a form that actually rendered the
control. A caller that posts a handful of fields -- a script, the MQTT
bridge, a curl against the documented endpoint -- never rendered anything,
and reading its silence as "every other checkbox is off" turns a one-field
save into a mass disable. That is not hypothetical: a partial post of four
``customization.*`` keys switched off ``nfl.enabled``, ``ncaa_fb.enabled``
and every display-mode toggle on a live device.
Two ways to be in scope:
* ``sections`` -- the rendered form lists the top-level sections it drew
(``__rendered_section``). Anything it drew is fair game, including a
section whose only fields are checkboxes that are all unchecked, which
is the case no heuristic can recover.
* ``submitted_parents`` -- no marker, so fall back to evidence: the
containing object must have had at least one field posted.
"""
if sections is not None:
return full_path.split('.', 1)[0] in sections
if submitted_parents is None:
return True
return prefix in submitted_parents
def _submitted_parents(form_keys):
"""The object paths a form actually posted a field from ('' = top level)."""
parents = set()
for key in form_keys:
parents.add(key.rsplit('.', 1)[0] if '.' in key else '')
return parents
def _set_missing_booleans_to_false(plugin_config, schema_props, form_keys, prefix='', config_node=None,
sections=None, submitted_parents=None):
"""Walk schema and set missing boolean form fields to False.
HTML checkboxes don't submit values when unchecked. When saving plugin config,
the backend starts from existing config (to support partial form updates), which
HTML checkboxes don't submit values when unchecked. When saving plugin plugin_config,
the backend starts from existing plugin_config (to support partial form updates), which
means an unchecked checkbox's old ``True`` value persists. This function detects
boolean schema properties not present in the form submission and explicitly sets
them to ``False``.
them to ``False`` -- but only where that silence is evidence, see
:func:`_boolean_is_in_scope`.
The top-level ``enabled`` field is excluded because it has its own preservation
logic in the save endpoint.
@@ -1014,15 +1092,21 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c
(e.g. ``feeds.custom_feeds.0.enabled``).
Args:
config: The root plugin config dict (used for pure-dict paths)
plugin_config: The root plugin plugin_config dict (used for pure-dict paths)
schema_props: Schema ``properties`` dict at the current nesting level
form_keys: Set of form field names that were submitted
prefix: Dot-notation prefix for the current nesting level
config_node: The current config subtree when inside an array item (avoids
config_node: The current plugin_config subtree when inside an array item (avoids
using _set_nested_value which corrupts lists)
sections: Top-level sections the form reported rendering, or None when it
reported none (then submitted_parents decides)
submitted_parents: Object paths with at least one posted field; computed
on the first call when there are no section markers
"""
# Determine which config node to operate on
node = config_node if config_node is not None else config
if sections is None and submitted_parents is None:
submitted_parents = _submitted_parents(form_keys)
# Determine which plugin_config node to operate on
node = config_node if config_node is not None else plugin_config
for prop_name, prop_schema in schema_props.items():
if not isinstance(prop_schema, dict):
@@ -1032,14 +1116,17 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c
prop_type = prop_schema.get('type')
if prop_type == 'boolean' and full_path != 'enabled':
# If this boolean wasn't submitted in the form, it's an unchecked checkbox
if full_path not in form_keys:
# If this boolean wasn't submitted in the form, it's an unchecked
# checkbox -- provided the form drew it at all.
if (full_path not in form_keys
and _boolean_is_in_scope(full_path, prefix, sections,
submitted_parents)):
if config_node is not None:
# Inside an array item — set directly on the item dict
node[prop_name] = False
else:
# Pure dict path — use helper
_set_nested_value(config, full_path, False)
_set_nested_value(plugin_config, full_path, False)
elif prop_type == 'object' and 'properties' in prop_schema:
# Recurse into nested objects
@@ -1048,12 +1135,14 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c
if prop_name not in node or not isinstance(node[prop_name], dict):
node[prop_name] = {}
_set_missing_booleans_to_false(
config, prop_schema['properties'], form_keys, full_path,
config_node=node[prop_name]
plugin_config, prop_schema['properties'], form_keys, full_path,
config_node=node[prop_name],
sections=sections, submitted_parents=submitted_parents
)
else:
_set_missing_booleans_to_false(
config, prop_schema['properties'], form_keys, full_path
plugin_config, prop_schema['properties'], form_keys, full_path,
sections=sections, submitted_parents=submitted_parents
)
elif prop_type == 'array':
@@ -1075,15 +1164,15 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c
if not indices:
continue
# Navigate to the array in the config (create if missing)
# Navigate to the array in the plugin_config (create if missing)
if config_node is not None:
if prop_name not in node or not isinstance(node[prop_name], list):
node[prop_name] = []
array_list = node[prop_name]
else:
# Navigate from root config through dict keys to get the list
# Navigate from root plugin_config through dict keys to get the list
parts = full_path.split('.')
current = config
current = plugin_config
for part in parts[:-1]:
if part not in current or not isinstance(current[part], dict):
current[part] = {}
@@ -1102,8 +1191,9 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c
array_list[idx] = {}
item_prefix = f"{full_path}.{idx}"
_set_missing_booleans_to_false(
config, items_schema['properties'], form_keys, item_prefix,
config_node=array_list[idx]
plugin_config, items_schema['properties'], form_keys, item_prefix,
config_node=array_list[idx],
sections=sections, submitted_parents=submitted_parents
)
def _enhance_schema_with_core_properties(schema):
"""
+16 -33
View File
@@ -83,6 +83,20 @@ def get_fonts_catalog():
# Check if this is a system font (cannot be deleted)
is_system = catalog_key.lower() in SYSTEM_FONTS
# BDF files are fixed-size bitmap strikes: FreeType
# accepts only the pixel size baked into the file. The
# UI needs to know that before offering a size control,
# or it offers a number that cannot take effect.
native_size = None
if font_type == 'bdf':
try:
from src.element_style import _read_bdf_native_size
native_size = _read_bdf_native_size(str(filepath))
except Exception as e:
logger.debug("Could not read native size for BDF font %s: %s",
filepath, e)
native_size = None
catalog[catalog_key] = {
'filename': filename,
'family_name': family_name,
@@ -90,6 +104,8 @@ def get_fonts_catalog():
'path': relative_path,
'type': font_type,
'is_system': is_system,
'scalable': font_type != 'bdf',
'native_size': native_size,
'metadata': metadata if metadata else None
}
@@ -124,39 +140,6 @@ def get_font_tokens():
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/overrides', methods=['GET'])
def get_fonts_overrides():
"""Get font overrides"""
try:
# This would integrate with the actual font system
# For now, return empty overrides
overrides = {}
return jsonify({'status': 'success', 'data': {'overrides': overrides}})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/overrides', methods=['POST'])
def save_fonts_overrides():
"""Save font overrides"""
try:
data = request.get_json(silent=True)
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
# This would integrate with the actual font system
return jsonify({'status': 'success', 'message': 'Font overrides saved'})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/overrides/<element_key>', methods=['DELETE'])
def delete_font_override(element_key):
"""Delete font override"""
try:
# This would integrate with the actual font system
return jsonify({'status': 'success', 'message': f'Font override for {element_key} deleted'})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/upload', methods=['POST'])
def upload_font():
"""Upload font file"""
+27 -4
View File
@@ -5,12 +5,13 @@ endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
ErrorCode, OperationType, PROJECT_ROOT, Path, Response,
_CALENDAR_LIST_MAX_PAGES, _SKIP_FIELD, _coerce_to_bool,
_CALENDAR_LIST_MAX_PAGES, _RENDERED_SECTION_FIELD, _SKIP_FIELD, _coerce_to_bool,
_do_transactional_uninstall, _enhance_schema_with_core_properties,
_filter_config_by_schema, _get_plugin_version, _get_schema_property,
_installed_plugin_ids, _is_plugin_update_available,
_parse_form_value_with_schema, _prune_credential_backups,
_run_calendar_registration, _set_missing_booleans_to_false,
_run_calendar_registration, _schema_allows_null, _schema_type_is,
_set_missing_booleans_to_false,
_set_nested_value, _starlark_virtual_plugins, _toggle_starlark_app,
api_v3, datetime, deep_merge, describe_exception, error_response,
find_secret_fields, hashlib, json, jsonify, logger, logging,
@@ -1729,6 +1730,11 @@ def save_plugin_config():
# Convert form data to config dict
# Form fields can use dot notation for nested values (e.g., "transition.type")
form_data = request.form.to_dict()
# Meta fields describe the submission, they are not config paths.
# Unknown keys are otherwise written straight into config.json by
# the non-indexed pass below.
form_data = {k: v for k, v in form_data.items()
if not k.startswith('__')}
# First pass: handle bracket notation array fields (e.g., "field_name[]" from checkbox-group)
# These fields use getlist() to preserve all values, then replace in form_data
@@ -1781,7 +1787,7 @@ def save_plugin_config():
if last_part.isdigit():
# Get schema property for the base path to verify it's an array
base_prop = _get_schema_property(schema, base_path)
if base_prop and base_prop.get('type') == 'array':
if base_prop and _schema_type_is(base_prop, 'array'):
# This is an array index field
index = int(last_part)
if base_path not in array_fields:
@@ -1796,6 +1802,16 @@ def save_plugin_config():
# Sort by index and extract values
index_values.sort(key=lambda x: x[0])
values = [v for _, v in index_values]
# Every channel blank on a nullable field means "unset", not
# an empty array: joining them would produce ", , ", which
# parses to [] and then fails the minItems the array
# declares. This is how a per-mode colour override says
# "inherit the base colour".
base_prop_for_null = _get_schema_property(schema, base_path)
if (_schema_allows_null(base_prop_for_null)
and all(str(v).strip() == '' for v in values)):
_set_nested_value(plugin_config, base_path, None)
continue
# Combine values into comma-separated string for parsing
combined_value = ', '.join(str(v) for v in values)
# Parse as array using schema
@@ -2078,7 +2094,14 @@ def save_plugin_config():
# Walk the schema and set any boolean fields missing from form data to False.
if schema and 'properties' in schema:
form_keys = set(request.form.keys())
_set_missing_booleans_to_false(plugin_config, schema['properties'], form_keys)
# The rendered form reports which top-level sections it drew, so
# an unchecked box can be told apart from a field the caller
# never had in front of it. A caller that sends none gets the
# evidence-based fallback in _boolean_is_in_scope.
rendered_sections = set(request.form.getlist(_RENDERED_SECTION_FIELD))
_set_missing_booleans_to_false(
plugin_config, schema['properties'], form_keys,
sections=rendered_sections or None)
# Get schema manager instance (for JSON requests)
schema_mgr = api_v3.schema_manager
+149 -6
View File
@@ -10,6 +10,8 @@ from pathlib import Path
# Strict allowlists for URL-derived values used in path and script operations.
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
_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
@@ -19,6 +21,7 @@ logger = logging.getLogger(__name__)
config_manager = None
plugin_manager = None
plugin_store_manager = None
schema_manager = None
pages_v3 = Blueprint('pages_v3', __name__)
@@ -363,6 +366,119 @@ def serve_plugin_web_ui(plugin_id, filename):
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
def _plugin_dir_for(safe_id):
"""Resolve a sanitised plugin id to its directory, or None.
Mirrors serve_plugin_web_ui: containment-guarded against the configured
plugins directory, with PluginManager's ``ledmatrix-`` prefix fallback.
"""
plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
plugin_dir = resolve_under(plugins_base, safe_id)
if plugin_dir is None:
raise ValueError('plugin id escapes the plugins directory')
if not plugin_dir.exists():
alt = resolve_under(plugins_base, f'ledmatrix-{safe_id}')
if alt is not None:
plugin_dir = alt
return plugin_dir
def _declared_widget_script(plugin_dir, widget_name):
"""The script filename a plugin's manifest declares for ``widget_name``.
The manifest is the allowlist: only a widget the plugin actually declares
can be served, so this route never exposes arbitrary files under the
plugin directory even though the directory itself is attacker-influenced
(plugins are user-installed). Returns None when the widget is not
declared, the manifest is unreadable, or the declared script name is not
a plain ``<name>.js`` basename.
"""
manifest_path = plugin_dir / 'manifest.json'
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
except (OSError, ValueError):
return None
if not isinstance(manifest, dict):
return None
for entry in manifest.get('widgets') or ():
if not isinstance(entry, dict):
continue
if entry.get('name') != widget_name:
continue
script = entry.get('script') or f'{widget_name}.js'
if not isinstance(script, str) or not _SAFE_WIDGET_SCRIPT_RE.match(script):
return None
return script
return None
@pages_v3.route('/static/plugin-widgets/<plugin_id>/<widget_name>.js')
def serve_plugin_widget(plugin_id, widget_name):
"""Serve a plugin-declared widget script from its ``widgets/`` directory.
This is the server half of ``LEDMatrixWidgets.loadPluginWidget`` (see
static/v3/js/widgets/plugin-loader.js), which fetches exactly this path.
The loader uses a dynamic ``import()``, so the response must carry a
JavaScript MIME type or the browser refuses the module.
The route is deliberately narrower than the plugin directory: a script is
served only when the plugin's own manifest declares a widget by that name,
so installing a plugin does not publish everything it ships.
"""
if not _SAFE_PLUGIN_ID_RE.match(plugin_id):
return 'Invalid plugin ID', 400, {'Content-Type': 'text/plain'}
if not _SAFE_WIDGET_NAME_RE.match(widget_name):
return 'Invalid widget name', 400, {'Content-Type': 'text/plain'}
# safe_path_component is this codebase's sanitiser (src/common/
# path_safety.py): it rejects rather than mangles, so a name that is not
# a plain path component never reaches the filesystem.
safe_id = safe_path_component(plugin_id)
safe_widget = safe_path_component(widget_name)
if not safe_id or not safe_widget:
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
if not pages_v3.plugin_manager:
return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'}
try:
plugin_dir = _plugin_dir_for(safe_id)
if not plugin_dir.exists():
return 'Not found', 404, {'Content-Type': 'text/plain'}
script = _declared_widget_script(plugin_dir, safe_widget)
if script is None:
# Undeclared is a 404 rather than a 403: whether a plugin happens
# to ship an undeclared file is not something to confirm.
return 'Not found', 404, {'Content-Type': 'text/plain'}
widgets_dir = (plugin_dir / 'widgets').resolve()
# 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)
if script_path is None or not script_path.is_file():
return 'Not found', 404, {'Content-Type': 'text/plain'}
body = script_path.read_text(encoding='utf-8')
return body, 200, {
'Content-Type': 'text/javascript; charset=utf-8',
# Plugin updates replace this file in place; revalidate so a
# stale widget cannot outlive the plugin version that shipped it.
'Cache-Control': 'no-cache',
}
except ValueError:
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
except Exception:
logger.error('Error serving plugin widget %s/%s', plugin_id, widget_name,
exc_info=True)
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
def _load_overview_partial():
"""Load overview partial with system stats"""
try:
@@ -715,15 +831,42 @@ def _load_plugin_config_partial(plugin_id):
except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below
logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e)
# Get plugin schema
# Get plugin schema.
#
# Through SchemaManager, not a raw json.load, because that is what
# the save route uses (api_v3.save_plugin_config) -- and the two
# disagreeing is not academic. SchemaManager applies
# expand_style_elements, which turns a compact
# customization.x-style-elements declaration into the per-element
# blocks this form renders. Reading the file directly meant a plugin
# using that form (of-the-day ships one) had a customization section
# that rendered nothing at all, while saving still validated against
# the expanded shape.
#
# use_cache=False matches the save route: a plugin's schema changes
# on disk during development, and a cached copy would keep serving
# the old form.
#
# The raw read stays as a fallback for callers that never set a
# schema_manager (several tests, and any embedder of this blueprint).
schema = {}
schema_path = resolve_under(_plugin_dir, "config_schema.json")
if schema_path is not None and schema_path.exists():
schema_mgr = getattr(pages_v3, 'schema_manager', None)
if schema_mgr is not None:
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
schema = schema_mgr.load_schema(plugin_id, use_cache=False) or {}
except Exception as e:
logger.warning("Could not load schema for plugin: %s", e)
logger.warning("SchemaManager could not load schema for %s: %s",
plugin_id, e)
if not schema:
# resolve_under keeps the containment guard main added here; the
# SchemaManager path above does its own.
schema_path = resolve_under(_plugin_dir, "config_schema.json")
if schema_path is not None and schema_path.exists():
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
except Exception as e:
logger.warning("Could not load schema for plugin: %s", e)
# Get web UI actions from plugin manifest
web_ui_actions = []
+82
View File
@@ -1412,3 +1412,85 @@ button.bg-white {
max-width: calc(100vw - 2rem);
}
}
/* ---- Style editor widget -------------------------------------------- */
/* One row per display element: label, font, size, colour, X, Y. The grid
is declared once here so the header and the rows cannot drift apart. */
/* The plugin config panel is narrower than nine columns of controls, and a
clipped table hides the offsets entirely -- scroll the table rather than
crushing the controls or dropping columns. */
.style-editor {
overflow-x: auto;
}
.style-editor-table {
min-width: max-content;
}
/* Scrolled right, the element names would otherwise scroll away and the
row you are editing becomes anonymous. */
.style-editor-label,
.style-editor-head > :first-child {
position: sticky;
left: 0;
z-index: 1;
background-color: #ffffff;
}
.style-editor-head > :first-child {
background-color: #f9fafb;
}
.style-editor-row {
/* Set by the widget: the column count depends on what the plugin
declares, so the track list cannot be fixed here. */
grid-template-columns: var(--style-editor-columns,
minmax(7rem, 1.4fr) minmax(8rem, 2fr) 5rem 4.5rem 5rem 5rem);
}
.style-editor-check {
width: 1rem;
height: 1rem;
}
.style-editor-row .form-control {
padding: 0.2rem 0.4rem;
height: auto;
}
.style-editor-row input[type="number"] {
width: 100%;
}
.style-editor-row input[type="number"]:disabled {
background-color: #f3f4f6;
color: #9ca3af;
cursor: not-allowed;
}
.style-editor-colour {
width: 2rem;
height: 1.6rem;
padding: 0;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
background: none;
cursor: pointer;
}
.style-editor-tab {
border: 1px solid #d1d5db;
background-color: #f9fafb;
color: #374151;
}
.style-editor-tab.is-active {
background-color: #2563eb;
border-color: #2563eb;
color: #ffffff;
}
.style-editor-panel[hidden] {
display: none;
}
@media (max-width: 768px) {
/* Stack rather than squeeze: six columns on a phone is unreadable. */
.style-editor-row {
grid-template-columns: 1fr 1fr;
}
.style-editor-head {
display: none;
}
.style-editor-label {
grid-column: 1 / -1;
font-weight: 600;
}
}
-580
View File
@@ -752,586 +752,6 @@
}
},
generateConfigForm(pluginId, config, schema, webUiActions = []) {
// Safety check - if schema/config not ready, return empty
if (!pluginId || !config) {
return '<div class="text-gray-500">Loading configuration...</div>';
}
// Only log once per plugin to avoid spam (Alpine.js may call this multiple times during rendering)
if (!this._configFormLogged || this._configFormLogged !== pluginId) {
debugLog('[DEBUG] generateConfigForm called for', pluginId, 'with', webUiActions?.length || 0, 'actions');
// Debug: Check if image_config.images has x-widget in schema
if (schema && schema.properties && schema.properties.image_config) {
const imgConfig = schema.properties.image_config;
if (imgConfig.properties && imgConfig.properties.images) {
const imagesProp = imgConfig.properties.images;
debugLog('[DEBUG] Schema check - image_config.images:', {
type: imagesProp.type,
'x-widget': imagesProp['x-widget'],
'has x-widget': 'x-widget' in imagesProp,
keys: Object.keys(imagesProp)
});
}
}
this._configFormLogged = pluginId;
}
if (!schema || !schema.properties) {
return this.generateSimpleConfigForm(config, webUiActions, pluginId);
}
// Helper function to get schema property by full key path
const getSchemaProperty = (schemaObj, keyPath) => {
if (!schemaObj || !schemaObj.properties) return null;
const keys = keyPath.split('.');
let current = schemaObj.properties;
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (!current || !current[k]) {
return null;
}
const prop = current[k];
// If this is the last key, return the property
if (i === keys.length - 1) {
return prop;
}
// If this property has nested properties, navigate deeper
if (prop && typeof prop === 'object' && prop.properties) {
current = prop.properties;
} else {
// Can't navigate deeper
return null;
}
}
return null;
};
const generateFieldHtml = (key, prop, value, prefix = '') => {
const fullKey = prefix ? `${prefix}.${key}` : key;
const label = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
const description = prop.description || '';
let html = '';
// Debug: Log property structure for arrays to help diagnose file-upload widget issues
if (prop.type === 'array') {
// Also check schema directly as fallback
const schemaProp = getSchemaProperty(schema, fullKey);
const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null;
debugLog('[DEBUG generateFieldHtml] Array property:', fullKey, {
'prop.x-widget': prop['x-widget'],
'prop.x_widget': prop['x_widget'],
'schema.x-widget': xWidgetFromSchema,
'hasOwnProperty(x-widget)': prop.hasOwnProperty('x-widget'),
'x-widget in prop': 'x-widget' in prop,
'all prop keys': Object.keys(prop),
'schemaProp keys': schemaProp ? Object.keys(schemaProp) : 'null'
});
}
// Handle nested objects
if (prop.type === 'object' && prop.properties) {
const sectionId = `section-${fullKey.replace(/\./g, '-')}`;
const nestedConfig = value || {};
const sectionLabel = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
// Calculate nesting depth for better spacing
const nestingDepth = (fullKey.match(/\./g) || []).length;
const marginClass = nestingDepth > 1 ? 'mb-6' : 'mb-4';
html += `
<div class="nested-section border border-gray-300 rounded-lg ${marginClass}">
<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"
onclick="toggleNestedSection('${sectionId}', event); return false;">
<div class="flex-1">
<h4 class="font-semibold text-gray-900">${sectionLabel}</h4>
${description ? `<p class="text-sm text-gray-600 mt-1">${description}</p>` : ''}
</div>
<i id="${sectionId}-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
</button>
<div id="${sectionId}" class="nested-content collapsed bg-gray-50 px-4 py-4 space-y-3" style="max-height: 0; display: none;">
`;
// Recursively generate fields for nested properties
// Get ordered properties if x-propertyOrder is defined
let nestedPropertyEntries = Object.entries(prop.properties);
if (prop['x-propertyOrder'] && Array.isArray(prop['x-propertyOrder'])) {
const order = prop['x-propertyOrder'];
const orderedEntries = [];
const unorderedEntries = [];
// Separate ordered and unordered properties
nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
const index = order.indexOf(nestedKey);
if (index !== -1) {
orderedEntries[index] = [nestedKey, nestedProp];
} else {
unorderedEntries.push([nestedKey, nestedProp]);
}
});
// Combine ordered entries (filter out undefined from sparse array) with unordered entries
nestedPropertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
}
nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
// Use config value if it exists and is not null (including false), otherwise use schema default
// Check if key exists in config and value is not null/undefined
const hasValue = nestedKey in nestedConfig && nestedConfig[nestedKey] !== null && nestedConfig[nestedKey] !== undefined;
// For nested objects, if the value is an empty object, still use it (don't fall back to default)
const isNestedObject = nestedProp.type === 'object' && nestedProp.properties;
const nestedValue = hasValue ? nestedConfig[nestedKey] :
(nestedProp.default !== undefined ? nestedProp.default :
(isNestedObject ? {} : (nestedProp.type === 'array' ? [] : (nestedProp.type === 'boolean' ? false : ''))));
// Debug logging for file-upload widgets
if (nestedProp.type === 'array' && (nestedProp['x-widget'] === 'file-upload' || nestedProp['x_widget'] === 'file-upload')) {
debugLog('[DEBUG] Found file-upload widget in nested property:', nestedKey, 'fullKey:', fullKey + '.' + nestedKey, 'prop:', nestedProp);
}
html += generateFieldHtml(nestedKey, nestedProp, nestedValue, fullKey);
});
html += `
</div>
</div>
`;
// Add extra spacing after nested sections to prevent overlap with next section
if (nestingDepth > 0) {
html += `<div class="mb-2"></div>`;
}
return html;
}
// Regular (non-nested) field
html += `<div class="form-group">`;
html += `<label class="block text-sm font-medium text-gray-700 mb-1">${label}</label>`;
if (description) {
html += `<p class="text-sm text-gray-600 mb-2">${description}</p>`;
}
// Generate appropriate input based on type
if (prop.type === 'boolean') {
html += `<label class="flex items-center">`;
html += `<input type="checkbox" name="${fullKey}" ${value ? 'checked' : ''} class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">`;
html += `<span class="ml-2 text-sm">Enabled</span>`;
html += `</label>`;
} else if (prop.type === 'number' || prop.type === 'integer' ||
(Array.isArray(prop.type) && (prop.type.includes('number') || prop.type.includes('integer')))) {
// Handle union types like ["integer", "null"]
const isUnionType = Array.isArray(prop.type);
const allowsNull = isUnionType && prop.type.includes('null');
const isInteger = prop.type === 'integer' || (isUnionType && prop.type.includes('integer'));
const isNumber = prop.type === 'number' || (isUnionType && prop.type.includes('number'));
const min = prop.minimum !== undefined ? `min="${prop.minimum}"` : '';
const max = prop.maximum !== undefined ? `max="${prop.maximum}"` : '';
const step = isInteger ? 'step="1"' : 'step="any"';
// For union types with null, don't show default if value is null (leave empty)
// This allows users to explicitly set null by leaving it empty
let fieldValue = '';
if (value !== undefined && value !== null) {
fieldValue = value;
} else if (!allowsNull && prop.default !== undefined) {
// Only use default if null is not allowed
fieldValue = prop.default;
}
// Ensure value respects min/max constraints
if (fieldValue !== '' && fieldValue !== undefined && fieldValue !== null) {
const numValue = typeof fieldValue === 'string' ? parseFloat(fieldValue) : fieldValue;
if (!isNaN(numValue)) {
// Clamp value to min/max if constraints exist
if (prop.minimum !== undefined && numValue < prop.minimum) {
fieldValue = prop.minimum;
} else if (prop.maximum !== undefined && numValue > prop.maximum) {
fieldValue = prop.maximum;
} else {
fieldValue = numValue;
}
}
}
// Add placeholder/help text for null-able fields
const placeholder = allowsNull ? 'Leave empty to use current time (random)' : '';
const helpText = allowsNull && description && description.includes('null') ?
`<p class="text-xs text-gray-500 mt-1">${description}</p>` : '';
html += `<input type="number" name="${fullKey}" value="${fieldValue}" ${min} ${max} ${step} placeholder="${placeholder}" class="mt-1 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">`;
if (helpText) {
html += helpText;
}
} else if (prop.type === 'array') {
// AGGRESSIVE file upload widget detection
// For 'images' field in static-image plugin, always check schema directly
let isFileUpload = false;
let uploadConfig = {};
// Direct check: if this is the 'images' field and schema has it with x-widget
if (fullKey === 'images' && schema && schema.properties && schema.properties.images) {
const imagesSchema = schema.properties.images;
if (imagesSchema['x-widget'] === 'file-upload' || imagesSchema['x_widget'] === 'file-upload') {
isFileUpload = true;
uploadConfig = imagesSchema['x-upload-config'] || imagesSchema['x_upload_config'] || {};
debugLog('[DEBUG] ✅ Direct detection: images field has file-upload widget', uploadConfig);
}
}
// Fallback: check prop object (should have x-widget if schema loaded correctly)
if (!isFileUpload) {
const xWidgetFromProp = prop['x-widget'] || prop['x_widget'] || prop.xWidget;
if (xWidgetFromProp === 'file-upload') {
isFileUpload = true;
uploadConfig = prop['x-upload-config'] || prop['x_upload_config'] || {};
debugLog('[DEBUG] ✅ Detection via prop object');
}
}
// Fallback: schema property lookup
if (!isFileUpload) {
let schemaProp = getSchemaProperty(schema, fullKey);
if (!schemaProp && fullKey === 'images' && schema && schema.properties && schema.properties.images) {
schemaProp = schema.properties.images;
}
const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null;
if (xWidgetFromSchema === 'file-upload') {
isFileUpload = true;
uploadConfig = schemaProp['x-upload-config'] || schemaProp['x_upload_config'] || {};
debugLog('[DEBUG] ✅ Detection via schema lookup');
}
}
// Debug logging for ALL array fields to diagnose
debugLog('[DEBUG] Array field check:', fullKey, {
'isFileUpload': isFileUpload,
'prop keys': Object.keys(prop),
'prop.x-widget': prop['x-widget'],
'schema.properties.images exists': !!(schema && schema.properties && schema.properties.images),
'schema.properties.images.x-widget': (schema && schema.properties && schema.properties.images) ? schema.properties.images['x-widget'] : null,
'uploadConfig': uploadConfig
});
if (isFileUpload) {
debugLog('[DEBUG] ✅ Rendering file-upload widget for', fullKey, 'with config:', uploadConfig);
// Use the file upload widget from plugins.html
// We'll need to call a function that exists in the global scope
const maxFiles = uploadConfig.max_files || 10;
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
const maxSizeMB = uploadConfig.max_size_mb || 5;
const currentImages = Array.isArray(value) ? value : [];
const fieldId = fullKey.replace(/\./g, '_');
const safePluginId = (uploadConfig.plugin_id || pluginId || 'static-image').toString().replace(/[^a-zA-Z0-9_-]/g, '_');
html += `
<div id="${fieldId}_upload_widget" class="mt-1">
<!-- File Upload Drop Zone -->
<div id="${fieldId}_drop_zone"
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors cursor-pointer"
ondrop="window.handleFileDrop(event, this.dataset.fieldId)"
ondragover="event.preventDefault()"
data-field-id="${fieldId}"
onclick="document.getElementById(this.dataset.fieldId + '_file_input').click()">
<input type="file"
id="${fieldId}_file_input"
multiple
accept="${allowedTypes.join(',')}"
style="display: none;"
data-field-id="${fieldId}"
onchange="window.handleFileSelect(event, this.dataset.fieldId)">
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
<p class="text-sm text-gray-600">Drag and drop images here or click to browse</p>
<p class="text-xs text-gray-500 mt-1">Max ${maxFiles} files, ${maxSizeMB}MB each (PNG, JPG, GIF, BMP)</p>
</div>
<!-- Uploaded Images List -->
<div id="${fieldId}_image_list" class="mt-4 space-y-2">
${currentImages.map((img, idx) => {
const imgSchedule = img.schedule || {};
const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always';
let scheduleSummary = 'Always shown';
if (hasSchedule && window.getScheduleSummary) {
try {
scheduleSummary = window.getScheduleSummary(imgSchedule) || 'Scheduled';
} catch (e) {
scheduleSummary = 'Scheduled';
}
} else if (hasSchedule) {
scheduleSummary = 'Scheduled';
}
// Escape the summary for HTML
scheduleSummary = String(scheduleSummary).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return `
<div id="img_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" 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.path || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;')}"
alt="${(img.filename || '').replace(/"/g, '&quot;')}"
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">
<i class="fas fa-image text-gray-400"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">${String(img.original_filename || img.filename || 'Image').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
<p class="text-xs text-gray-500">${img.size ? (Math.round(img.size / 1024) + ' KB') : ''} • ${(img.uploaded_at || '').replace(/&/g, '&amp;')}</p>
<p class="text-xs text-blue-600 mt-1">
<i class="fas fa-clock mr-1"></i>${scheduleSummary}
</p>
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
<button type="button"
data-field-id="${fieldId}"
data-image-id="${img.id || ''}"
data-image-idx="${idx}"
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>
</button>
<button type="button"
data-field-id="${fieldId}"
data-image-id="${img.id || ''}"
data-plugin-id="${safePluginId}"
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>
</button>
</div>
</div>
<!-- Schedule widget will be inserted here when opened -->
<div id="schedule_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
</div>
`;
}).join('')}
</div>
<!-- Hidden input to store image data -->
<input type="hidden" id="${fieldId}_images_data" name="${fullKey}" value="${JSON.stringify(currentImages).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')}">
</div>
`;
} else {
// Regular array input
const arrayValue = Array.isArray(value) ? value.join(', ') : '';
html += `<input type="text" name="${fullKey}" value="${arrayValue}" placeholder="Enter values separated by commas" class="mt-1 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">`;
html += `<p class="text-sm text-gray-600 mt-1">Enter values separated by commas</p>`;
}
} else if (prop.enum) {
html += `<select name="${fullKey}" class="mt-1 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">`;
prop.enum.forEach(option => {
const selected = value === option ? 'selected' : '';
html += `<option value="${option}" ${selected}>${option}</option>`;
});
html += `</select>`;
} else if (prop.type === 'string' && prop['x-widget'] === 'file-upload') {
// File upload widget for string fields (e.g., credentials.json)
const uploadConfig = prop['x-upload-config'] || {};
const uploadEndpoint = uploadConfig.upload_endpoint || '/api/v3/plugins/assets/upload';
const maxSizeMB = uploadConfig.max_size_mb || 1;
const allowedExtensions = uploadConfig.allowed_extensions || ['.json'];
const targetFilename = uploadConfig.target_filename || 'file.json';
const fieldId = fullKey.replace(/\./g, '_');
const hasFile = value && value !== '';
html += `
<div id="${fieldId}_upload_widget" class="mt-1">
<div id="${fieldId}_file_upload"
class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-400 transition-colors cursor-pointer"
onclick="document.getElementById('${fieldId}_file_input').click()">
<input type="file"
id="${fieldId}_file_input"
accept="${allowedExtensions.join(',')}"
style="display: none;"
data-field-id="${fieldId}"
data-upload-endpoint="${uploadEndpoint}"
data-target-filename="${targetFilename}"
onchange="window.handleCredentialsUpload(event, this.dataset.fieldId, this.dataset.uploadEndpoint, this.dataset.targetFilename)">
<i class="fas fa-file-upload text-2xl text-gray-400 mb-2"></i>
<p class="text-sm text-gray-600" id="${fieldId}_status">
${hasFile ? `Current file: ${value}` : 'Click to upload ' + targetFilename}
</p>
<p class="text-xs text-gray-500 mt-1">Max ${maxSizeMB}MB (${allowedExtensions.join(', ')})</p>
</div>
<input type="hidden" name="${fullKey}" value="${value || ''}" id="${fieldId}_hidden">
</div>
`;
} else {
// Default to text input
const maxLength = prop.maxLength || '';
const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : '';
html += `<input type="text" name="${fullKey}" value="${value !== undefined ? value : ''}" ${maxLengthAttr} class="mt-1 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">`;
}
html += `</div>`;
return html;
};
let formHtml = '';
// Get ordered properties if x-propertyOrder is defined
let propertyEntries = Object.entries(schema.properties);
if (schema['x-propertyOrder'] && Array.isArray(schema['x-propertyOrder'])) {
const order = schema['x-propertyOrder'];
const orderedEntries = [];
const unorderedEntries = [];
// Separate ordered and unordered properties
propertyEntries.forEach(([key, prop]) => {
const index = order.indexOf(key);
if (index !== -1) {
orderedEntries[index] = [key, prop];
} else {
unorderedEntries.push([key, prop]);
}
});
// Combine ordered entries (filter out undefined from sparse array) with unordered entries
propertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
}
propertyEntries.forEach(([key, prop]) => {
// Skip the 'enabled' property - it's managed separately via the header toggle
if (key === 'enabled') return;
// Use config value if key exists and is not null/undefined, otherwise use schema default
// Check if key exists in config and value is not null/undefined
const hasValue = key in config && config[key] !== null && config[key] !== undefined;
// For nested objects, if the value is an empty object, still use it (don't fall back to default)
const isNestedObject = prop.type === 'object' && prop.properties;
const value = hasValue ? config[key] :
(prop.default !== undefined ? prop.default :
(isNestedObject ? {} : (prop.type === 'array' ? [] : (prop.type === 'boolean' ? false : ''))));
formHtml += generateFieldHtml(key, prop, value);
});
// Add web UI actions section if plugin defines any
if (webUiActions && webUiActions.length > 0) {
debugLog('[DEBUG] Rendering', webUiActions.length, 'actions in tab form');
// Map color names to explicit Tailwind classes
const colorMap = {
'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' },
'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' },
'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' },
'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' },
'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' }
};
formHtml += `
<div class="border-t border-gray-200 pt-4 mt-4">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
<p class="text-sm text-gray-600 mb-4">${webUiActions[0].section_description || 'Perform actions for this plugin'}</p>
<div class="space-y-3">
`;
webUiActions.forEach((action, index) => {
const actionId = `action-${action.id}-${index}`;
const statusId = `action-status-${action.id}-${index}`;
const bgColor = action.color || 'blue';
const colors = colorMap[bgColor] || colorMap['blue'];
// Ensure pluginId is valid for template interpolation
const safePluginId = pluginId || '';
formHtml += `
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<h4 class="font-medium ${colors.text} mb-1">
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
</h4>
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
</div>
<button type="button"
id="${actionId}"
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
data-plugin-id="${safePluginId}"
data-action-id="${action.id}"
class="btn ${colors.btn} text-white px-4 py-2 rounded-md whitespace-nowrap">
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
</button>
</div>
<div id="${statusId}" class="mt-3 hidden"></div>
</div>
`;
});
formHtml += `
</div>
</div>
`;
}
return formHtml;
},
generateSimpleConfigForm(config, webUiActions = [], pluginId = '') {
let actionsHtml = '';
if (webUiActions && webUiActions.length > 0) {
const colorMap = {
'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' },
'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' },
'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' },
'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' },
'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' }
};
actionsHtml = `
<div class="border-t border-gray-200 pt-4 mt-4">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
<div class="space-y-3">
`;
webUiActions.forEach((action, index) => {
const actionId = `action-${action.id}-${index}`;
const statusId = `action-status-${action.id}-${index}`;
const bgColor = action.color || 'blue';
const colors = colorMap[bgColor] || colorMap['blue'];
// Ensure pluginId is valid for template interpolation
const safePluginId = pluginId || '';
actionsHtml += `
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<h4 class="font-medium ${colors.text} mb-1">
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
</h4>
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
</div>
<button type="button"
id="${actionId}"
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
data-plugin-id="${safePluginId}"
data-action-id="${action.id}"
class="btn ${colors.btn} text-white px-4 py-2 rounded-md">
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
</button>
</div>
<div id="${statusId}" class="mt-3 hidden"></div>
</div>
`;
});
actionsHtml += `
</div>
</div>
`;
}
return `
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
<input type="number" name="display_duration" value="${Math.max(5, Math.min(300, config.display_duration || 30))}" min="5" max="300" class="mt-1 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">
<p class="text-sm text-gray-600 mt-1">How long to show this plugin's content</p>
</div>
${actionsHtml}
`;
},
// Helper function to get schema property type for a field path
getSchemaPropertyType(schema, path) {
if (!schema || !schema.properties) return null;
@@ -1,133 +0,0 @@
/**
* Plugin configuration form management.
*
* Handles configuration form generation, validation, and submission.
*/
const PluginConfigManager = {
/**
* Current plugin configuration state.
*/
currentState: {
pluginId: null,
config: null,
schema: null,
jsonEditor: null
},
/**
* Initialize configuration for a plugin.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Configuration and schema
*/
async initialize(pluginId) {
try {
const [config, schema] = await Promise.all([
window.PluginAPI.getPluginConfig(pluginId),
window.PluginAPI.getPluginSchema(pluginId)
]);
this.currentState = {
pluginId,
config,
schema,
jsonEditor: null
};
return { config, schema };
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to initialize config for ${pluginId}`);
}
throw error;
}
},
/**
* Reset configuration to defaults.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Default configuration
*/
async resetToDefaults(pluginId) {
try {
const result = await window.PluginAPI.resetPluginConfig(pluginId);
// Reload configuration
if (this.currentState.pluginId === pluginId) {
await this.initialize(pluginId);
}
return result;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to reset config for ${pluginId}`);
}
throw error;
}
},
/**
* Save configuration.
*
* @param {string} pluginId - Plugin identifier
* @param {Object} config - Configuration data
* @returns {Promise<Object>} Save result
*/
async save(pluginId, config) {
try {
const result = await window.PluginAPI.savePluginConfig(pluginId, config);
// Update local state
if (this.currentState.pluginId === pluginId) {
this.currentState.config = config;
}
return result;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to save config for ${pluginId}`);
}
throw error;
}
},
/**
* Validate configuration against schema.
*
* @param {Object} config - Configuration data
* @param {Object} schema - JSON schema
* @returns {Object} Validation result with errors
*/
validate(config, schema) {
// Basic validation - full validation happens on server
const errors = [];
if (!schema || !schema.properties) {
return { valid: true, errors: [] };
}
// Check required fields
if (schema.required) {
for (const field of schema.required) {
if (!(field in config)) {
errors.push(`Required field '${field}' is missing`);
}
}
}
return {
valid: errors.length === 0,
errors: errors
};
}
};
// Export
if (typeof module !== 'undefined' && module.exports) {
module.exports = PluginConfigManager;
} else {
window.PluginConfigManager = PluginConfigManager;
}
+30 -4
View File
@@ -280,14 +280,40 @@ In your plugin's `config_schema.json`:
}
```
### Step 3: Widget Loading
### Step 3: Declare the Widget in `manifest.json`
The widget will be automatically loaded when the plugin configuration form is rendered. The system will:
The manifest is the allowlist -- a widget is served only if the plugin declares
it, so shipping a file under `widgets/` does not by itself publish it:
1. Check if widget is registered in the core registry
2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js`
```json
{
"widgets": [
{ "name": "my-custom-widget", "script": "my-custom-widget.js" }
]
}
```
`script` is optional and defaults to `[name].js`. It must be a plain filename
directly inside the plugin's `widgets/` directory.
### Step 4: Widget Loading
The widget is loaded on demand when the config form renders a field that
references it. The system will:
1. Check if the widget is registered in the core registry
2. If not, fetch `/static/plugin-widgets/[plugin-id]/[widget-name].js`, which
serves the declared script from the plugin's `widgets/` directory
3. Render the widget using the registered `render` function
The fetch is a dynamic `import()`, so the file must parse as an ES module (a
plain IIFE does). If anything fails, the field falls back to a plain text input
holding the current value, so a broken widget never costs the user their
configured value.
Only `string`-typed fields take this path today; see `docs/widget-guide.md`
for the full details and limitations.
## Widget API Reference
### Widget Definition Object
@@ -121,7 +121,9 @@
family: family,
display_name: info.display_name || generateDisplayName(info.filename || family),
path: info.path,
type: info.type || 'unknown'
type: info.type || 'unknown',
scalable: info.scalable,
native_size: info.native_size
}));
} else if (Array.isArray(data)) {
// Direct array format
@@ -174,6 +176,12 @@
const xOptions = config['x-options'] || config['x_options'] || {};
const placeholder = xOptions.placeholder || 'Select a font...';
const filterTypes = xOptions.filterTypes || null; // e.g., ['ttf', 'bdf']
// Bitmap (BDF) fonts render at the one size baked into the file
// and ignore the size setting entirely, so a field that caps size
// at 16 can still be handed a 27px face. maxFixedSize hides the
// ones that cannot honour the cap, rather than offering a choice
// that silently overflows the panel.
const maxFixedSize = Number(xOptions.maxFixedSize) || null;
const showPreview = xOptions.showPreview === true;
const disabled = xOptions.disabled === true;
const required = xOptions.required === true;
@@ -204,6 +212,16 @@
return filterTypes.some(t => t.toLowerCase() === fontType);
});
}
if (maxFixedSize) {
filteredFonts = filteredFonts.filter(font => {
// A scalable face can always meet the cap.
if (font.scalable !== false) { return true; }
// An unknown native size is not evidence it is too
// big; keep it rather than hiding a usable font.
if (!font.native_size) { return true; }
return font.native_size <= maxFixedSize;
});
}
// Build select HTML
let html = `<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">`;
@@ -0,0 +1,636 @@
/**
* Style Editor Widget
*
* One compact row per display element -- font, size, colour, alignment,
* visibility, X/Y nudge, scale -- instead of the nested accordions the
* generic object renderer produces. A realistic scoreboard declares seven
* elements with layout offsets and three modes, which comes to 65 nested
* sections and five levels of clicking to reach one per-mode font size.
*
* Three things about this widget are load-bearing:
*
* 1. It emits ordinary inputs with the same dotted names the generic
* renderer would produce (`customization.score_text.font`,
* `customization.score_text.text_color.0`, `customization.layout.
* score_text.x_offset`, `customization.modes.live....`). The whole
* save/validate/merge pipeline is therefore untouched: no hidden JSON
* blob, no new server-side parsing.
*
* 2. Its columns come from the schema, not from a list in here. A column
* appears when any element declares that field, so a plugin adding a
* field to its schema gets a control without this file changing, and a
* logo that declares only offsets and a scale gets no empty font cell.
*
* 3. Mode tabs edit `customization.modes.<mode>`, whose fields mean
* "inherit" when blank. Blank must post as empty (-> null), never as 0,
* or every mode would pin itself to the base the first time it was saved.
*
* @module StyleEditorWidget
*/
(function () {
'use strict';
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[StyleEditor] LEDMatrixWidgets registry not found. Load registry.js first.');
return;
}
var FONT_CACHE = null;
var FONT_INFLIGHT = null;
/**
* Read one own property, by a key that came from data.
*
* Every lookup in here is keyed by something out of a schema or a saved
* config -- an element name, a mode name, a field name. A key of
* `__proto__` or `constructor` would otherwise walk up the prototype
* chain and hand back a function instead of a schema, so reads go
* through here and misses come back undefined.
*/
function own(obj, key) {
if (!obj || typeof obj !== 'object') { return undefined; }
// Via the descriptor rather than obj[key]: this is the one read that
// cannot avoid a data-supplied key, and going through the descriptor
// means there is no computed member access here at all.
var descriptor = Object.getOwnPropertyDescriptor(obj, key);
return descriptor ? descriptor.value : undefined;
}
/** `own`, but always an object -- for `(x || {}).properties` chains. */
function ownObj(obj, key) {
var found = own(obj, key);
return (found && typeof found === 'object') ? found : {};
}
/** The font catalog, fetched once per page. */
function loadFonts() {
if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); }
if (FONT_INFLIGHT) { return FONT_INFLIGHT; }
FONT_INFLIGHT = fetch('/api/v3/fonts/catalog')
.then(function (r) {
if (!r.ok) { throw new Error('font catalog fetch failed: ' + r.status); }
return r.json();
})
.then(function (payload) {
var catalog = (payload && payload.data && payload.data.catalog) || {};
FONT_CACHE = Object.keys(catalog).map(function (key) {
var entry = ownObj(catalog, key);
return {
filename: entry.filename,
label: entry.display_name || entry.filename,
scalable: entry.scalable !== false,
nativeSize: entry.native_size || null
};
}).sort(function (a, b) { return a.label.localeCompare(b.label); });
return FONT_CACHE;
})
.catch(function (e) {
console.warn('[StyleEditor] could not load the font catalog', e);
// Leave FONT_CACHE unset and clear FONT_INFLIGHT so the next
// call retries instead of being stuck on an empty result.
FONT_INFLIGHT = null;
return [];
});
return FONT_INFLIGHT;
}
function el(tag, attrs, children) {
var node = document.createElement(tag);
Object.keys(attrs || {}).forEach(function (k) {
var v = own(attrs, k);
if (k === 'class') { node.className = v; }
else if (k === 'text') { node.textContent = v; }
else if (v !== null && v !== undefined) {
node.setAttribute(k, v);
}
});
(children || []).forEach(function (c) { node.appendChild(c); });
return node;
}
/** Walk a nested value object by path segments. */
function at(value, path) {
var cur = value;
// Consumed rather than indexed, so no step reads path[i].
var remaining = (path || []).slice();
while (remaining.length) {
if (cur === null || typeof cur !== 'object') { return undefined; }
cur = own(cur, remaining.shift());
}
return cur;
}
/**
* What a control should show: the configured value if there is one,
* else the schema default.
*
* The base tab must fall back to the default rather than leaving the
* control empty. An empty <select> shows its first option, which for
* fonts is whatever sorts first alphabetically -- so an untouched
* scoreboard claimed every element used 10x20.bdf, and the size control
* then locked itself to that bitmap font's fixed size. A mode tab is the
* opposite: blank there means inherit, so it must stay blank.
*/
function effective(value, path, prop, optional) {
var v = at(value, path);
if (v !== undefined && v !== null) { return v; }
if (optional) { return undefined; }
return prop ? prop.default : undefined;
}
/**
* Element blocks in a customization schema, in declared order.
* `layout` and `modes` are containers, not elements.
*/
function elementKeys(schema) {
var props = (schema && schema.properties) || {};
var order = schema['x-propertyOrder'] || Object.keys(props);
return order.filter(function (k) {
return k !== 'layout' && k !== 'modes'
&& own(props, k) && ownObj(props, k).properties;
});
}
function titleOf(schema, key) {
var prop = ownObj(schema.properties || {}, key);
return prop.title || key.replace(/_/g, ' ');
}
// ---- individual controls -------------------------------------------
/**
* Fonts this field can actually honour.
*
* A bitmap (BDF) face renders at the one size baked into the file and
* ignores the size setting, so an element that caps size at 16 can
* still be handed a 27px face and overflow a 32px panel. The cap comes
* from the element's own font_size.maximum, so this hides exactly the
* faces that cannot meet the limit the plugin already declared --
* rather than a list curated by hand, which is what these fields used
* to carry and why an uploaded font could never appear in one.
*/
function usableFonts(fonts, maxFixedSize, current) {
if (!maxFixedSize) { return fonts; }
return fonts.filter(function (f) {
// Keep the font already saved on this element even if it no
// longer fits the cap -- dropping it would leave the select
// with nothing chosen and silently misrepresent the config.
if (current && f.filename === current) { return true; }
if (f.scalable !== false) { return true; }
// An unknown native size is not evidence it is too big.
if (!f.nativeSize) { return true; }
return f.nativeSize <= maxFixedSize;
});
}
function fontControl(name, current, fonts, optional, onChange) {
var select = el('select', {
name: name,
class: 'form-control text-sm style-editor-font'
});
if (optional) {
select.appendChild(el('option', { value: '', text: 'Inherit' }));
}
fonts.forEach(function (f) {
var opt = el('option', { value: f.filename, text: f.label });
opt.dataset.scalable = f.scalable ? '1' : '0';
opt.dataset.nativeSize = f.nativeSize || '';
select.appendChild(opt);
});
if (current) { select.value = current; }
if (onChange) { select.addEventListener('change', onChange); }
return select;
}
function enumControl(name, current, choices, optional) {
var select = el('select', {
name: name,
class: 'form-control text-sm style-editor-enum'
});
if (optional) {
select.appendChild(el('option', { value: '', text: 'Inherit' }));
}
choices.forEach(function (c) {
select.appendChild(el('option', {
value: c,
text: String(c).replace(/_/g, ' ')
}));
});
if (current !== undefined && current !== null) { select.value = current; }
return select;
}
function booleanControl(name, current, optional) {
if (optional) {
// Three states, not two: on, off, and "follow the base". A bare
// checkbox cannot say the third, and an unchecked box would read
// as "hide this in live mode" rather than "no preference".
return enumControl(name, boolAsString(current),
['true', 'false'], true);
}
// An unchecked checkbox posts nothing at all, so the hidden field
// carries the value and the checkbox drives it. The save path turns
// "true"/"false" into a real boolean.
var wrap = el('div', { class: 'flex items-center' });
var hidden = el('input', { type: 'hidden', name: name });
var box = el('input', { type: 'checkbox', class: 'style-editor-check' });
box.checked = current !== false;
hidden.value = box.checked ? 'true' : 'false';
box.addEventListener('change', function () {
hidden.value = box.checked ? 'true' : 'false';
});
wrap.appendChild(box);
wrap.appendChild(hidden);
return wrap;
}
function boolAsString(value) {
if (value === true) { return 'true'; }
if (value === false) { return 'false'; }
return undefined;
}
function numberControl(name, current, prop, placeholder) {
var input = el('input', {
type: 'number',
name: name,
class: 'form-control text-sm style-editor-size',
placeholder: placeholder || ''
});
if (prop && prop.minimum !== undefined) { input.min = prop.minimum; }
if (prop && prop.maximum !== undefined) { input.max = prop.maximum; }
if (prop && prop.type
&& String(prop.type).indexOf('number') !== -1) {
input.step = 'any';
}
// Blank stays blank. For a mode field that is the inherit sentinel;
// writing 0 here would pin the mode to the base on the next save.
if (current !== undefined && current !== null) { input.value = current; }
return input;
}
function toHex(rgb) {
if (!Array.isArray(rgb) || rgb.length < 3) { return '#ffffff'; }
return '#' + rgb.slice(0, 3).map(function (c) {
var v = Math.max(0, Math.min(255, parseInt(c, 10) || 0));
return ('0' + v.toString(16)).slice(-2);
}).join('');
}
function colourControl(name, current, optional) {
// Three number inputs carry the value (name.0/.1/.2 is what the save
// path recombines); the colour swatch is the human control and just
// drives them.
var wrap = el('div', { class: 'flex items-center gap-1' });
var has = Array.isArray(current) && current.length >= 3;
var startValues = has ? current.slice(0, 3) : [];
var channels = startValues.concat([null, null, null])
.slice(0, 3)
.map(function (channelValue, i) {
var input = el('input', {
type: 'hidden', name: name + '.' + i
});
if (has) { input.value = channelValue; }
return input;
});
// A disabled input is not submitted at all, which is how an unset
// optional colour says nothing rather than posting three empty
// strings. The server copes with those too, but not posting them is
// both clearer and one less thing depending on that.
function setSubmitted(on) {
channels.forEach(function (c) { c.disabled = !on; });
}
setSubmitted(!optional || has);
var clearBtn = null;
var swatch = el('input', {
type: 'color',
class: 'style-editor-colour',
value: has ? toHex(current) : '#ffffff'
});
swatch.addEventListener('input', function () {
var hex = swatch.value;
channels.forEach(function (channel, i) {
channel.value = parseInt(hex.substr(1 + i * 2, 2), 16);
});
setSubmitted(true);
if (clearBtn) { clearBtn.classList.remove('hidden'); }
});
wrap.appendChild(swatch);
channels.forEach(function (c) { wrap.appendChild(c); });
if (optional) {
// A mode colour must be able to go back to "inherit", which means
// posting nothing at all -- an <input type=color> has no empty
// state of its own.
clearBtn = el('button', {
type: 'button',
class: 'text-xs text-gray-500 hover:text-gray-800 style-editor-clear',
title: 'Inherit the colour above',
text: '×'
});
if (!has) { clearBtn.classList.add('hidden'); }
clearBtn.addEventListener('click', function () {
channels.forEach(function (c) { c.value = ''; });
setSubmitted(false);
swatch.value = '#ffffff';
clearBtn.classList.add('hidden');
});
wrap.appendChild(clearBtn);
}
return wrap;
}
// ---- columns ---------------------------------------------------------
var COLUMN_ORDER = ['font', 'font_size', 'text_color', 'align', 'visible',
'x_offset', 'y_offset', 'scale'];
var COLUMN_LABELS = {
font: 'Font', font_size: 'Size', text_color: 'Colour',
align: 'Align', visible: 'Show',
x_offset: 'X', y_offset: 'Y', scale: 'Scale'
};
var COLUMN_WIDTHS = {
font: 'minmax(8rem, 2fr)', text_color: '4.5rem', align: '6rem',
visible: '3.5rem'
};
/**
* 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).
*/
function columnsFor(schema) {
var props = schema.properties || {};
var layoutProps = ownObj(props, 'layout').properties || {};
// 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'); });
});
var known = COLUMN_ORDER.filter(function (f) { return seen.get(f); });
// Anything the schema declares that this file has never heard of
// still gets a column, rather than silently vanishing.
var extra = Array.from(seen.keys()).filter(function (f) {
return COLUMN_ORDER.indexOf(f) === -1;
}).sort();
return known.concat(extra).map(function (f) {
return {
key: f,
where: seen.get(f),
label: own(COLUMN_LABELS, f) || f.replace(/_/g, ' ')
};
});
}
/** Build the control for one cell from its schema property. */
function control(opts) {
var prop = opts.prop;
var declared = prop.type;
var types = Array.isArray(declared) ? declared : [declared];
var xOptions = prop['x-options'] || prop['x_options'] || {};
var cap = Number(xOptions.maxFixedSize) || opts.maxFixedSize || null;
if (opts.key === 'font' || prop['x-widget'] === 'font-selector') {
return fontControl(opts.name, opts.current,
usableFonts(opts.fonts, cap, opts.current),
opts.optional, opts.onFontChange);
}
if (types.indexOf('boolean') !== -1) {
return booleanControl(opts.name, opts.current, opts.optional);
}
if (types.indexOf('array') !== -1) {
return colourControl(opts.name, opts.current, opts.optional);
}
if (Array.isArray(prop.enum)) {
return enumControl(opts.name, opts.current, prop.enum,
opts.optional);
}
return numberControl(opts.name, opts.current, prop,
opts.optional ? 'inherit' : '');
}
function elementRow(opts) {
var schema = opts.schema;
var key = opts.key;
var value = opts.value;
var optional = opts.optional;
var props = ownObj(schema.properties || {}, key).properties || {};
var layoutProps = ownObj(schema.properties || {}, 'layout').properties || {};
var axes = ownObj(layoutProps, key).properties || {};
var row = el('div', {
class: 'style-editor-row grid items-center gap-2 py-1',
'data-element': key
});
row.appendChild(el('div', {
class: 'text-sm text-gray-700 style-editor-label',
text: titleOf(schema, key)
}));
var sizeInput = null;
var sizeNote = el('span', { class: 'text-xs text-gray-500 ml-1' });
var fontSelect = null;
function syncSize(select) {
// A BDF font has exactly one usable pixel size; offering a free
// number there offers something that cannot take effect.
if (!sizeInput || !select) { return; }
var opt = select.options[select.selectedIndex];
var scalable = !opt || opt.dataset.scalable !== '0';
if (scalable) {
sizeInput.disabled = false;
sizeInput.title = '';
sizeNote.textContent = '';
} else {
sizeInput.disabled = true;
sizeInput.value = opt.dataset.nativeSize || '';
sizeInput.title = 'This is a bitmap font; it renders at one fixed size.';
sizeNote.textContent = 'fixed';
}
}
opts.columns.forEach(function (col) {
var inLayout = col.where === 'layout';
var prop = inLayout ? own(axes, col.key) : own(props, col.key);
var cell;
if (!prop) {
// This element does not declare that field; keep the grid
// aligned with an empty cell.
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 node = control({
key: col.key,
prop: prop,
name: base + '.' + col.key,
current: effective(value, path, prop, optional),
optional: optional,
fonts: opts.fonts,
// The element's declared size ceiling is what a fixed-size
// font has to fit under.
maxFixedSize: (props.font_size || {}).maximum || null,
onFontChange: function () { syncSize(fontSelect); }
});
if (col.key === 'font') { fontSelect = node; }
if (col.key === 'font_size') {
sizeInput = node;
cell = el('div', { class: 'flex items-center' });
cell.appendChild(node);
cell.appendChild(sizeNote);
node = cell;
}
row.appendChild(node);
});
if (fontSelect) { syncSize(fontSelect); }
return row;
}
function header(columns) {
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; }))
.forEach(function (label) {
row.appendChild(el('div', {
class: 'text-xs font-semibold text-gray-500 uppercase',
text: label
}));
});
return row;
}
function table(opts) {
var wrap = el('div', { class: 'style-editor-table' });
var columns = columnsFor(opts.schema);
// Sized here rather than in CSS: the column count depends on what
// the plugin declared.
wrap.style.gridTemplateColumns = '';
wrap.style.setProperty('--style-editor-columns',
'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(elementRow({
schema: opts.schema,
key: key,
columns: columns,
prefix: opts.prefix,
layoutPrefix: opts.prefix + '.layout',
value: opts.value,
fonts: opts.fonts,
optional: opts.optional
}));
});
return wrap;
}
// ---- widget ----------------------------------------------------------
window.LEDMatrixWidgets.register('style-editor', {
name: 'Style Editor',
version: '1.1.0',
render: function (container, config, value, options) {
var schema = (config && config.schema) || {};
var base = (options && options.name) || 'customization';
var current = value || {};
container.innerHTML = '';
var root = el('div', { class: 'style-editor' });
container.appendChild(root);
loadFonts().then(function (fonts) {
var modeProps = ownObj(schema.properties || {}, 'modes').properties || {};
var modes = Object.keys(modeProps);
// A list, not a keyed object: the ids are mode names out of
// the schema, and nothing here needs a lookup by key.
var panels = [];
var tabs = null;
if (modes.length) {
tabs = el('div', { class: 'style-editor-tabs flex gap-1 mb-2' });
root.appendChild(tabs);
}
function panel(id, node) {
panels.push({ id: id, node: node });
node.classList.add('style-editor-panel');
root.appendChild(node);
}
function show(id) {
panels.forEach(function (p) {
p.node.hidden = (p.id !== id);
});
if (!tabs) { return; }
Array.prototype.forEach.call(tabs.children, function (b) {
b.classList.toggle('is-active', b.dataset.panel === id);
});
}
function tab(id, label) {
if (!tabs) { return; }
var b = el('button', {
type: 'button',
class: 'style-editor-tab text-sm px-3 py-1 rounded',
text: label
});
b.dataset.panel = id;
b.addEventListener('click', function () { show(id); });
tabs.appendChild(b);
}
panel('__base__', table({
schema: schema, prefix: base, value: current,
fonts: fonts, optional: false
}));
tab('__base__', 'All modes');
modes.forEach(function (mode) {
var modeSchema = ownObj(modeProps, mode);
var modeValue = at(current, ['modes', mode]) || {};
var node = el('div');
node.appendChild(el('p', {
class: 'text-xs text-gray-500 mb-2',
text: 'Anything left blank follows the "All modes" tab.'
}));
node.appendChild(table({
schema: modeSchema,
prefix: base + '.modes.' + mode,
value: modeValue,
fonts: fonts,
optional: true
}));
panel(mode, node);
tab(mode, modeSchema.title || mode);
});
show('__base__');
});
},
getValue: function () {
// The inputs are ordinary named form fields; the form itself is
// the source of truth, so there is no separate value to hand back.
return null;
}
});
console.log('[StyleEditor] widget registered');
})();
+1 -1
View File
@@ -972,7 +972,6 @@
<!-- Load plugin management modules -->
<script src="{{ url_for('static', filename='v3/js/plugins/store_manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/plugins/config_manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
<!-- Shared search/filter/sort controller for the plugin card grids -->
<script src="{{ url_for('static', filename='v3/js/plugins/list_filter.js') }}" defer></script>
@@ -1003,6 +1002,7 @@
<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>
+13 -349
View File
@@ -1,20 +1,11 @@
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Font Management</h2>
<p class="mt-1 text-sm text-gray-600">Manage custom fonts, overrides, and system font configuration for your LED matrix display.</p>
<p class="mt-1 text-sm text-gray-600">Upload, preview and manage the fonts available to every plugin.</p>
</div>
<!-- Font System Overview -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6 mb-8">
<!-- Detected Fonts from Managers -->
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-md font-medium text-gray-900 mb-3">Detected Manager Fonts</h3>
<div id="detected-fonts" class="bg-gray-800 text-gray-100 font-mono text-sm p-3 rounded h-40 overflow-y-auto">
<div class="text-gray-400">Loading...</div>
</div>
<p class="text-sm text-gray-600 mt-2">Fonts currently in use by managers (auto-detected)</p>
</div>
<div class="mb-8">
<!-- Available Fonts -->
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-md font-medium text-gray-900 mb-3">Available Font Families</h3>
@@ -78,73 +69,6 @@
</div>
</div>
<!-- Font Overrides -->
<div class="bg-gray-50 rounded-lg p-4 mb-8">
<h3 class="text-md font-medium text-gray-900 mb-4">Element Font Overrides</h3>
<p class="text-sm text-gray-600 mb-4">Override fonts for specific display elements. Changes take effect immediately.</p>
<!-- Add New Override -->
<div class="grid grid-cols-1 md:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4 mb-4 p-4 bg-white rounded border">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Element</label>
<select id="override-element" class="form-control text-sm">
<option value="">Select an element...</option>
<optgroup label="Sports">
<option value="nfl.live.score">NFL Live Score</option>
<option value="nfl.live.time">NFL Live Time</option>
<option value="nfl.live.team">NFL Live Team</option>
<option value="mlb.live.score">MLB Live Score</option>
<option value="nhl.live.score">NHL Live Score</option>
<option value="nba.live.score">NBA Live Score</option>
</optgroup>
<optgroup label="Clock">
<option value="clock.time">Clock Time</option>
<option value="clock.date">Clock Date</option>
</optgroup>
<optgroup label="Weather">
<option value="weather.current">Weather Current</option>
<option value="weather.forecast">Weather Forecast</option>
</optgroup>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
<select id="override-family" class="form-control text-sm">
<option value="">Use default</option>
<!-- Dynamically populated from font catalog -->
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Size</label>
<select id="override-size" class="form-control text-sm">
<option value="">Use default</option>
<option value="xs">Extra Small (6px)</option>
<option value="sm">Small (8px)</option>
<option value="md">Medium (10px)</option>
<option value="lg">Large (12px)</option>
<option value="xl">Extra Large (14px)</option>
</select>
</div>
<div class="flex items-end">
<button id="add-override-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2">
<i class="fas fa-plus mr-2"></i>Add Override
</button>
</div>
</div>
<!-- Current Overrides List -->
<div id="overrides-container">
<h4 class="text-sm font-medium text-gray-900 mb-3">Current Overrides</h4>
<div id="overrides-list" class="space-y-2">
<!-- Overrides will be populated here -->
<div class="text-gray-500 text-sm italic">No font overrides configured</div>
</div>
</div>
</div>
<!-- Font Preview -->
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-md font-medium text-gray-900 mb-4">Font Preview</h3>
@@ -206,13 +130,11 @@
// Initialize global variables on window object
window.fontCatalog = window.fontCatalog || {};
window.fontTokens = window.fontTokens || {};
window.fontOverrides = window.fontOverrides || {};
window.selectedFontFiles = window.selectedFontFiles || [];
// Create references that can be reassigned
var fontCatalog = window.fontCatalog;
var fontTokens = window.fontTokens;
var fontOverrides = window.fontOverrides;
var selectedFontFiles = window.selectedFontFiles;
// Retry counter for initialization
@@ -222,17 +144,15 @@
function initializeFontsTab() {
// Allow re-initialization on each HTMX content swap
// The window._fontsScriptLoaded guard prevents function redeclaration
const detectedEl = document.getElementById('detected-fonts');
const availableEl = document.getElementById('available-fonts');
if (!detectedEl || !availableEl) {
if (!availableEl) {
initRetryCount++;
if (initRetryCount >= MAX_INIT_RETRIES) {
console.error('Fonts tab elements not found after max retries, giving up');
return;
}
console.log('Fonts tab elements not found, retrying...', {
detectedFonts: !!detectedEl,
availableFonts: !!availableEl,
attempt: initRetryCount
});
@@ -259,7 +179,6 @@ function initializeFontsTab() {
const fontFileInput = document.getElementById('font-file-input');
const uploadFontsBtn = document.getElementById('upload-fonts-btn');
const cancelUploadBtn = document.getElementById('cancel-upload-btn');
const addOverrideBtn = document.getElementById('add-override-btn');
const updatePreviewBtn = document.getElementById('update-preview-btn');
if (uploadDropzone && fontFileInput) {
@@ -280,9 +199,6 @@ function initializeFontsTab() {
cancelUploadBtn.addEventListener('click', cancelFontUpload);
}
if (addOverrideBtn) {
addOverrideBtn.addEventListener('click', addFontOverride);
}
if (updatePreviewBtn) {
updatePreviewBtn.addEventListener('click', updateFontPreview);
@@ -319,9 +235,8 @@ window.initializeFontsTab = initializeFontsTab;
// Function to initialize when fonts content is loaded
function tryInitializeFontsTab() {
const fontsContent = document.getElementById('fonts-content');
const detectedFonts = document.getElementById('detected-fonts');
if (fontsContent && detectedFonts) {
if (fontsContent) {
console.log('Fonts content detected, initializing...');
setTimeout(() => {
initializeFontsTab();
@@ -355,7 +270,6 @@ async function initializeFontManagement() {
try {
await loadFontData();
populateFontSelects();
displayCurrentOverrides();
updateFontPreview();
initializeFontUpload();
} catch (error) {
@@ -365,44 +279,39 @@ async function initializeFontManagement() {
}
async function loadFontData() {
const detectedContainer = document.getElementById('detected-fonts');
const availableContainer = document.getElementById('available-fonts');
// Ensure containers exist before proceeding
if (!detectedContainer || !availableContainer) {
if (!availableContainer) {
console.error('Font containers not found, cannot load font data');
return;
}
// Show loading states
detectedContainer.innerHTML = '<div class="text-blue-400">Loading font data...</div>';
availableContainer.innerHTML = '<div class="text-blue-400">Loading font data...</div>';
try {
// Use absolute URLs to ensure they work when loaded via HTMX
const [catalogRes, tokensRes, overridesRes] = await Promise.all([
const [catalogRes, tokensRes] = await Promise.all([
fetch(`/api/v3/fonts/catalog`),
fetch(`/api/v3/fonts/tokens`),
fetch(`/api/v3/fonts/overrides`)
fetch(`/api/v3/fonts/tokens`)
]);
// Check if all responses are successful
if (!catalogRes.ok || !tokensRes.ok || !overridesRes.ok) {
const statusText = `HTTP ${catalogRes.status}/${tokensRes.status}/${overridesRes.status}`;
if (!catalogRes.ok || !tokensRes.ok) {
const statusText = `HTTP ${catalogRes.status}/${tokensRes.status}`;
console.error('Font API error:', statusText);
throw new Error(`Failed to load font data: ${statusText}`);
}
const catalogData = await catalogRes.json();
const tokensData = await tokensRes.json();
const overridesData = await overridesRes.json();
// Validate response structure
if (!catalogData || !catalogData.data || !tokensData || !tokensData.data || !overridesData || !overridesData.data) {
if (!catalogData || !catalogData.data || !tokensData || !tokensData.data) {
console.error('Invalid font API response structure:', {
catalog: !!catalogData?.data,
tokens: !!tokensData?.data,
overrides: !!overridesData?.data
tokens: !!tokensData?.data
});
throw new Error('Invalid response format from font API');
}
@@ -410,27 +319,22 @@ async function loadFontData() {
// Update both window properties and local references
window.fontCatalog = catalogData.data.catalog || {};
window.fontTokens = tokensData.data.tokens || {};
window.fontOverrides = overridesData.data.overrides || {};
// Update local variable references
fontCatalog = window.fontCatalog;
fontTokens = window.fontTokens;
fontOverrides = window.fontOverrides;
// Update displays
updateDetectedFontsDisplay();
updateAvailableFontsDisplay();
console.log('Font data loaded successfully', {
catalogSize: Object.keys(fontCatalog).length,
tokensSize: Object.keys(fontTokens).length,
overridesSize: Object.keys(fontOverrides).length
tokensSize: Object.keys(fontTokens).length
});
} catch (error) {
console.error('Error loading font data:', error);
// Show error states
detectedContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>';
availableContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>';
// Only show notification if showNotification is available
@@ -444,52 +348,6 @@ async function loadFontData() {
}
}
function updateDetectedFontsDisplay() {
const container = document.getElementById('detected-fonts');
if (!container) return;
// In a real implementation, this would collect font usage from all active managers
// For now, we'll simulate this by analyzing the font overrides and catalog
const detectedFonts = {};
// Check font overrides for active elements
for (const [elementKey, override] of Object.entries(fontOverrides)) {
if (override.family) {
detectedFonts[elementKey] = {
family: override.family,
size_px: override.size_px || 8,
usage_count: 1, // Would be actual usage count in real implementation
source: 'override'
};
}
}
// Check font catalog for commonly used fonts
for (const [fontKey, fontPath] of Object.entries(fontCatalog)) {
// Add some commonly used system fonts if not already in overrides
if (!detectedFonts[fontKey]) {
detectedFonts[fontKey] = {
family: fontKey,
size_px: 8,
usage_count: 1,
source: 'system'
};
}
}
if (Object.keys(detectedFonts).length === 0) {
container.innerHTML = '<div class="text-gray-400">No fonts detected yet (managers will register fonts when they render)</div>';
return;
}
const lines = [];
for (const [elementKey, fontInfo] of Object.entries(detectedFonts)) {
const sourceStr = fontInfo.source === 'override' ? ' [OVERRIDE]' : ' [SYSTEM]';
lines.push(`${elementKey}: ${fontInfo.family}@${fontInfo.size_px}px (used ${fontInfo.usage_count}x)${sourceStr}`);
}
container.textContent = lines.join('\n');
}
function updateAvailableFontsDisplay() {
const container = document.getElementById('available-fonts');
if (!container) return;
@@ -594,10 +452,9 @@ async function deleteFont(fontFamily) {
function populateFontSelects() {
// Populate font family dropdowns from catalog
const overrideSelect = document.getElementById('override-family');
const previewSelect = document.getElementById('preview-family');
if (!overrideSelect || !previewSelect) return;
if (!previewSelect) return;
// Get font entries sorted by display name
const fontEntries = Object.entries(fontCatalog).map(([key, info]) => {
@@ -608,13 +465,6 @@ function populateFontSelects() {
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
// Build options using DOM APIs to prevent XSS
// Clear and add default option for override select
overrideSelect.innerHTML = '';
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.textContent = 'Use default';
overrideSelect.appendChild(defaultOption);
// Clear preview select
previewSelect.innerHTML = '';
@@ -622,11 +472,6 @@ function populateFontSelects() {
fontEntries.forEach(font => {
const typeLabel = font.fontType ? ` (${font.fontType})` : '';
const overrideOpt = document.createElement('option');
overrideOpt.value = font.filename;
overrideOpt.textContent = font.displayName + typeLabel;
overrideSelect.appendChild(overrideOpt);
const previewOpt = document.createElement('option');
previewOpt.value = font.filename;
previewOpt.textContent = font.displayName + typeLabel;
@@ -641,187 +486,6 @@ function populateFontSelects() {
console.log(`Populated font selects with ${fontEntries.length} fonts`);
}
async function addFontOverride() {
const element = document.getElementById('override-element').value;
const family = document.getElementById('override-family').value;
const sizeToken = document.getElementById('override-size').value;
if (!element) {
showNotification('Please select an element', 'warning');
return;
}
if (!family && !sizeToken) {
showNotification('Please specify at least a font family or size', 'warning');
return;
}
try {
const overrideData = {};
if (family) overrideData.family = family;
if (sizeToken) {
const sizePx = fontTokens[sizeToken];
if (sizePx) overrideData.size_px = sizePx;
}
const response = await fetch(`/api/v3/fonts/overrides`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
[element]: overrideData
})
});
if (!response.ok) {
const text = await response.text();
let message;
try {
const errorData = JSON.parse(text);
message = errorData.message || `Server error: ${response.status}`;
} catch {
message = `Server error: ${response.status}`;
}
showNotification('Error adding font override: ' + message, 'error');
return;
}
const data = await response.json();
if (data.status === 'success') {
showNotification('Font override added successfully', 'success');
await loadFontData();
displayCurrentOverrides();
// Clear form
document.getElementById('override-element').value = '';
document.getElementById('override-family').value = '';
document.getElementById('override-size').value = '';
} else {
showNotification('Error adding font override: ' + data.message, 'error');
}
} catch (error) {
console.error('Error adding font override:', error);
showNotification('Error adding font override: ' + error, 'error');
}
}
async function deleteFontOverride(elementKey) {
if (!confirm(`Are you sure you want to remove the font override for "${elementKey}"?`)) {
return;
}
try {
const response = await fetch(`/api/v3/fonts/overrides/${elementKey}`, {
method: 'DELETE'
});
if (!response.ok) {
const text = await response.text();
let message;
try {
const errorData = JSON.parse(text);
message = errorData.message || `Server error: ${response.status}`;
} catch {
message = `Server error: ${response.status}`;
}
showNotification('Error removing font override: ' + message, 'error');
return;
}
const data = await response.json();
if (data.status === 'success') {
showNotification('Font override removed successfully', 'success');
await loadFontData();
displayCurrentOverrides();
} else {
showNotification('Error removing font override: ' + data.message, 'error');
}
} catch (error) {
console.error('Error deleting font override:', error);
showNotification('Error removing font override: ' + error, 'error');
}
}
function displayCurrentOverrides() {
const container = document.getElementById('overrides-list');
if (!container) return;
if (Object.keys(fontOverrides).length === 0) {
container.innerHTML = '<div class="text-gray-500 text-sm italic">No font overrides configured</div>';
return;
}
// Build list using DOM APIs to prevent XSS
container.innerHTML = '';
Object.entries(fontOverrides).forEach(([elementKey, override]) => {
const elementName = getElementDisplayName(elementKey);
const settings = [];
if (override.family) {
const familyName = getFontDisplayName(override.family);
settings.push(`Family: ${familyName}`);
}
if (override.size_px) {
settings.push(`Size: ${override.size_px}px`);
}
const row = document.createElement('div');
row.className = 'flex items-center justify-between p-3 bg-white rounded border';
const infoDiv = document.createElement('div');
const nameDiv = document.createElement('div');
nameDiv.className = 'font-medium text-gray-900';
nameDiv.textContent = elementName;
const settingsDiv = document.createElement('div');
settingsDiv.className = 'text-sm text-gray-600';
settingsDiv.textContent = settings.join(', ');
infoDiv.appendChild(nameDiv);
infoDiv.appendChild(settingsDiv);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 text-sm';
const trashIcon = document.createElement('i');
trashIcon.className = 'fas fa-trash mr-1';
deleteBtn.appendChild(trashIcon);
deleteBtn.appendChild(document.createTextNode('Remove'));
deleteBtn.dataset.elementKey = elementKey;
deleteBtn.addEventListener('click', function() {
deleteFontOverride(this.dataset.elementKey);
});
row.appendChild(infoDiv);
row.appendChild(deleteBtn);
container.appendChild(row);
});
}
function getElementDisplayName(elementKey) {
const names = {
'nfl.live.score': 'NFL Live Score',
'nfl.live.time': 'NFL Live Time',
'nfl.live.team': 'NFL Live Team',
'mlb.live.score': 'MLB Live Score',
'nhl.live.score': 'NHL Live Score',
'nba.live.score': 'NBA Live Score',
'clock.time': 'Clock Time',
'clock.date': 'Clock Date',
'weather.current': 'Weather Current',
'weather.forecast': 'Weather Forecast'
};
return names[elementKey] || elementKey;
}
function getFontDisplayName(fontKey) {
const names = {
'press_start': 'Press Start 2P',
'four_by_six': '4x6 Font',
'matrix_light_6': 'Matrix Light 6'
};
return names[fontKey] || fontKey;
}
async function updateFontPreview() {
const previewImage = document.getElementById('font-preview-image');
const loadingText = document.getElementById('font-preview-loading');
@@ -71,6 +71,76 @@
}
})();
</script>
{% elif obj_widget == 'style-editor' %}
{# Composite per-element style editor. It renders its own inputs
with the same dotted names the generic renderer would produce
(customization.score_text.font, ...text_color.0, ...), so the
save/validate/merge pipeline is untouched -- no hidden JSON
blob, no new server-side parsing. It falls back to the normal
nested rendering if the widget cannot be loaded. #}
{% 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="style-editor-container mt-1"></div>
<div id="{{ field_id }}_fallback">
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
</div>
</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;
}
function initWidget() {
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
var widget = window.LEDMatrixWidgets.get('style-editor');
if (!widget) { setTimeout(initWidget, 50); return; }
var container = document.getElementById('{{ field_id }}_container');
var fallback = document.getElementById('{{ field_id }}_fallback');
if (!container) { return; }
// A user may already be editing the plain fallback fields
// 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; }
var value = {{ obj_value|tojson|safe }};
var config = { schema: {{ prop|tojson|safe }} };
try {
widget.render(container, config, value, {
fieldId: '{{ field_id }}',
name: '{{ full_key }}',
pluginId: '{{ plugin_id }}'
});
} catch (e) {
console.error('[style-editor] render failed', e);
container.remove();
return;
}
// The widget owns these fields now; drop the generic
// rendering so the form does not post both.
if (fallback) { fallback.remove(); }
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWidget);
} else {
setTimeout(initWidget, 50);
}
})();
</script>
{% elif prop.properties %}
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
{% endif %}
@@ -872,6 +942,68 @@
name="{{ full_key }}"
value="{{ str_value }}"
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500">
{% if str_widget %}
{# An x-widget the core does not ship may be supplied by the
plugin itself (manifest "widgets", served from its widgets/
directory). Ask the loader for it; the text input above is
the fallback and stays put unless the widget really renders,
so a missing or broken widget degrades to an editable field
rather than dropping the value on save. #}
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
<script>
(function() {
var WIDGET = {{ str_widget|tojson|safe }};
var PLUGIN = {{ plugin_id|tojson|safe }};
function initPluginWidget() {
if (!window.LEDMatrixWidgets || !window.LEDMatrixWidgets.ensureWidget) {
setTimeout(initPluginWidget, 50);
return;
}
var container = document.getElementById('{{ field_id }}_container');
var fallback = document.getElementById('{{ field_id }}');
if (!container) { return; }
window.LEDMatrixWidgets.ensureWidget(WIDGET, PLUGIN).then(function(available) {
if (!available) { container.remove(); return; }
var widget = window.LEDMatrixWidgets.get(WIDGET);
if (!widget || typeof widget.render !== 'function') { container.remove(); return; }
// Read the fallback's live value rather than the
// server-rendered one, so an edit typed while the
// widget script was still loading isn't lost.
var value = fallback ? fallback.value : {{ str_value|tojson|safe }};
var config = {
'type': '{{ field_type }}',
'enum': {{ (prop.enum or [])|tojson|safe }},
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }},
'x-widget-config': {{ (prop.get('x-widget-config') or prop.get('x_widget_config') or {})|tojson|safe }}
};
try {
widget.render(container, config, value, {
fieldId: '{{ field_id }}',
name: '{{ full_key }}',
pluginId: PLUGIN
});
} catch (e) {
console.error('[PluginWidget] render failed for ' + WIDGET, e);
container.remove();
return;
}
// Only now is the widget authoritative for this
// field; drop the fallback so the form posts one
// value rather than two under the same name.
if (fallback) { fallback.remove(); }
}).catch(function(e) {
console.warn('[PluginWidget] could not load ' + WIDGET, e);
container.remove();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPluginWidget);
} else {
setTimeout(initPluginWidget, 50);
}
})();
</script>
{% endif %}
{% endif %}
{% endif %}
</div>
@@ -1059,6 +1191,14 @@
{% endif %}
{% endif %}
{% endfor %}
{# Tell the save path which sections this form actually
drew. An unchecked checkbox posts nothing, so without
this the server cannot tell "the user cleared it"
from "the caller never had that field" -- and a
partial post would read as every box being off. #}
{% for key in tiers.basic + tiers.advanced %}
<input type="hidden" name="__rendered_section" value="{{ key }}">
{% endfor %}
{% for key in tiers.basic %}
{% set prop = schema.properties[key] %}
{% set value = config[key] if key in config else none %}