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>
This commit is contained in:
Chuck
2026-09-13 10:39:51 -04:00
co-authored by Claude Opus 5
parent da3cef0bc0
commit a016dd9446
3 changed files with 207 additions and 36 deletions
+135
View File
@@ -0,0 +1,135 @@
# Per-element styling for plugin authors
Users want to change the font, size and colour of individual things on screen,
nudge them a few pixels, hide the ones they do not care about, and scale a logo
down. This is the one system that does that, and a plugin joins it by
**declaring elements in its `config_schema.json`** — not by writing a style
resolver, a font cache or a web form.
The short version:
```jsonc
"customization": {
"type": "object",
"title": "Display Customization",
"x-style-elements": {
"score_text": {
"title": "Score",
"font": { "default": "PressStart2P-Regular.ttf" },
"size": { "default": 10, "min": 4, "max": 16 },
"color": { "default": [255, 255, 255] },
"offsets": true,
"visible": true,
"align": true
},
"home_logo": { "title": "Home logo", "offsets": true, "scale": true }
},
"x-style-modes": ["live", "upcoming", "recent"]
}
```
That is the whole declaration. The core expands it into a full JSON Schema, the
web UI renders a compact style editor with a row per element, and the values
land in `config.json` under the keys you named.
## What each key does
| Key | Effect |
|---|---|
| `font` | Font picker listing every shipped **and user-uploaded** font. |
| `size` | Number field. `min`/`max` also cap which fixed-size fonts are offered. |
| `color` | Colour swatch; stored as `[r, g, b]`. |
| `offsets` | X/Y nudge, stored under `customization.layout.<element>`. |
| `visible` | Show/hide toggle. |
| `align` | `left` / `center` / `right`. |
| `scale` | Size multiplier, for logos and images. Also under `layout`. |
`x-style-modes` is optional. Declare it and every element gains a per-mode
override tab — a scoreboard can then style its live, upcoming and recent cards
separately. **A mode field left blank means "inherit", not zero.**
## Reading the values
Every plugin inherits `BasePlugin.styles`, which finds your `config_schema.json`
on its own:
```python
style = self.styles.style(
"score_text",
classic_font="PressStart2P-Regular.ttf", # what you shipped
classic_size=10,
classic_color=(255, 255, 255),
)
if style.visible:
draw.text((x + style.offset[0], y + style.offset[1]),
text, font=style.font, fill=style.color)
```
For a specific mode, use `self.styles_for("recent")`, or set
`STYLE_MODE = "recent"` on the class and keep calling `self.styles`.
### The one rule that matters
**Pass your shipped values as the `classic_*` arguments.** The resolver returns
them verbatim unless the user actually changed something, which is what keeps an
untouched install rendering byte-identically. It can tell the difference because
a value only counts as user-forced when it *differs from the schema default* —
the save path writes the full default object into `config.json` on every save,
so "present in config" proves nothing.
Never compare against the default yourself; that rule lives in exactly one place.
### Stateless readers
For helpers handed a config dict rather than a plugin instance:
```python
from src.element_style import (element_color, element_visible,
element_align, element_scale, layout_offset)
colour = element_color(config, "score_text", (255, 255, 255), mode)
shown = element_visible(config, "records", True, mode)
dy = layout_offset(config, "score", "y_offset", 0, mode)
```
## Sports scoreboards
`SportsCoreSharedMixin` wires most of this up already. Two things to know:
* **Name your draws.** `_draw_text_with_outline(..., element="score_text")`
resolves the colour by name *and* honours the visibility toggle. Without it
the colour has to be guessed from the identity of the font object, which
cannot tell two elements apart when they share a face — the case every
bitmap font is in.
* **Modes are free.** Live/upcoming/recent are separate instances, so setting
`SKIN_MODE` on each is enough; no call site passes a mode.
## Adopting an existing hand-written block
If your schema already spells out `font` / `font_size` / `text_color` per
element longhand, **you do not need to change anything**. The core recognises
that shape and upgrades it in place: the style editor, the real font picker
(including uploaded fonts) and per-mode overrides all appear on a core update.
Add `x-style-modes` if you want the mode tabs.
## Fonts, and why size is sometimes locked
32 of the 35 shipped fonts are fixed-strike BDF bitmaps: they render at exactly
one pixel size and ignore `font_size`. The picker knows which, and the editor
locks the size field to the native size and labels it `fixed`. A font too tall
for the `max` you declared is not offered at all.
Uploaded fonts (Fonts tab) land in `assets/fonts/` and appear in the picker
automatically.
## Checklist
1. Declare `x-style-elements` (and `x-style-modes` if you have modes).
2. Read through `self.styles`, passing your shipped values as `classic_*`.
3. Honour `style.visible`, `style.offset` and `style.scale` where they apply.
4. Confirm an untouched config renders identically:
`python scripts/check_plugin.py --plugin <id>`.
5. Monorepo plugins: bump `manifest.json` and run `python update_registry.py`.
See also: [docs/PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md),
[docs/FONT_MANAGER.md](FONT_MANAGER.md).
+2
View File
@@ -45,6 +45,8 @@ Going deeper:
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) — minimal config you need
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) — schema design
- [PLUGIN_ELEMENT_STYLING.md](PLUGIN_ELEMENT_STYLING.md) — let users restyle,
move, hide and scale individual elements (per display mode, if you have them)
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) — multi-tab UI configs
- [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) — how the config system works
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) — properties every plugin honors
@@ -39,6 +39,28 @@
var FONT_CACHE = null;
var FONT_INFLIGHT = null;
var hasOwn = Object.prototype.hasOwnProperty;
/**
* 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; }
return hasOwn.call(obj, key) ? obj[key] : 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); }
@@ -51,7 +73,7 @@
.then(function (payload) {
var catalog = (payload && payload.data && payload.data.catalog) || {};
FONT_CACHE = Object.keys(catalog).map(function (key) {
var entry = catalog[key];
var entry = ownObj(catalog, key);
return {
filename: entry.filename,
label: entry.display_name || entry.filename,
@@ -74,10 +96,11 @@
function el(tag, attrs, children) {
var node = document.createElement(tag);
Object.keys(attrs || {}).forEach(function (k) {
if (k === 'class') { node.className = attrs[k]; }
else if (k === 'text') { node.textContent = attrs[k]; }
else if (attrs[k] !== null && attrs[k] !== undefined) {
node.setAttribute(k, attrs[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); });
@@ -87,9 +110,10 @@
/** Walk a nested value object by path segments. */
function at(value, path) {
var cur = value;
for (var i = 0; i < path.length; i++) {
var i;
for (i = 0; i < path.length; i++) {
if (cur === null || typeof cur !== 'object') { return undefined; }
cur = cur[path[i]];
cur = own(cur, path[i]);
}
return cur;
}
@@ -121,12 +145,12 @@
var order = schema['x-propertyOrder'] || Object.keys(props);
return order.filter(function (k) {
return k !== 'layout' && k !== 'modes'
&& props[k] && props[k].properties;
&& own(props, k) && ownObj(props, k).properties;
});
}
function titleOf(schema, key) {
var prop = (schema.properties || {})[key] || {};
var prop = ownObj(schema.properties || {}, key);
return prop.title || key.replace(/_/g, ' ');
}
@@ -257,11 +281,16 @@
// drives them.
var wrap = el('div', { class: 'flex items-center gap-1' });
var has = Array.isArray(current) && current.length >= 3;
var channels = [0, 1, 2].map(function (i) {
var input = el('input', { type: 'hidden', name: name + '.' + i });
if (has) { input.value = current[i]; }
return input;
});
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
@@ -280,8 +309,8 @@
});
swatch.addEventListener('input', function () {
var hex = swatch.value;
[1, 3, 5].forEach(function (start, i) {
channels[i].value = parseInt(hex.substr(start, 2), 16);
channels.forEach(function (channel, i) {
channel.value = parseInt(hex.substr(1 + i * 2, 2), 16);
});
setSubmitted(true);
if (clearBtn) { clearBtn.classList.remove('hidden'); }
@@ -334,15 +363,17 @@
*/
function columnsFor(schema) {
var props = schema.properties || {};
var layoutProps = (props.layout || {}).properties || {};
var seen = {};
var layoutProps = ownObj(props, 'layout').properties || {};
// No prototype to inherit from, so a field literally named
// "constructor" is a column like any other.
var seen = Object.create(null);
elementKeys(schema).forEach(function (key) {
Object.keys((props[key] || {}).properties || {}).forEach(
Object.keys(ownObj(props, key).properties || {}).forEach(
function (f) { seen[f] = 'element'; });
Object.keys((layoutProps[key] || {}).properties || {}).forEach(
Object.keys(ownObj(layoutProps, key).properties || {}).forEach(
function (f) { seen[f] = 'layout'; });
});
var known = COLUMN_ORDER.filter(function (f) { return seen[f]; });
var known = COLUMN_ORDER.filter(function (f) { return own(seen, f); });
// Anything the schema declares that this file has never heard of
// still gets a column, rather than silently vanishing.
var extra = Object.keys(seen).filter(function (f) {
@@ -351,8 +382,8 @@
return known.concat(extra).map(function (f) {
return {
key: f,
where: seen[f],
label: COLUMN_LABELS[f] || f.replace(/_/g, ' ')
where: own(seen, f),
label: own(COLUMN_LABELS, f) || f.replace(/_/g, ' ')
};
});
}
@@ -362,10 +393,10 @@
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') {
var xOptions = prop['x-options'] || prop['x_options'] || {};
var cap = Number(xOptions.maxFixedSize) || opts.maxFixedSize || null;
return fontControl(opts.name, opts.current,
usableFonts(opts.fonts, cap, opts.current),
opts.optional, opts.onFontChange);
@@ -390,9 +421,9 @@
var value = opts.value;
var optional = opts.optional;
var props = ((schema.properties || {})[key] || {}).properties || {};
var layoutProps = ((schema.properties || {}).layout || {}).properties || {};
var axes = (layoutProps[key] || {}).properties || {};
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',
@@ -427,7 +458,8 @@
opts.columns.forEach(function (col) {
var inLayout = col.where === 'layout';
var prop = inLayout ? axes[col.key] : props[col.key];
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.
@@ -452,7 +484,7 @@
if (col.key === 'font') { fontSelect = node; }
if (col.key === 'font_size') {
sizeInput = node;
var cell = el('div', { class: 'flex items-center' });
cell = el('div', { class: 'flex items-center' });
cell.appendChild(node);
cell.appendChild(sizeNote);
node = cell;
@@ -520,10 +552,12 @@
container.appendChild(root);
loadFonts().then(function (fonts) {
var modeProps = ((schema.properties || {}).modes || {}).properties || {};
var modeProps = ownObj(schema.properties || {}, 'modes').properties || {};
var modes = Object.keys(modeProps);
var panels = {};
// 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' });
@@ -531,14 +565,14 @@
}
function panel(id, node) {
panels[id] = node;
panels.push({ id: id, node: node });
node.classList.add('style-editor-panel');
root.appendChild(node);
}
function show(id) {
Object.keys(panels).forEach(function (k) {
panels[k].hidden = (k !== id);
panels.forEach(function (p) {
p.node.hidden = (p.id !== id);
});
if (!tabs) { return; }
Array.prototype.forEach.call(tabs.children, function (b) {
@@ -565,7 +599,7 @@
tab('__base__', 'All modes');
modes.forEach(function (mode) {
var modeSchema = modeProps[mode] || {};
var modeSchema = ownObj(modeProps, mode);
var modeValue = at(current, ['modes', mode]) || {};
var node = el('div');
node.appendChild(el('p', {