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>
This commit is contained in:
Chuck
2026-09-11 11:01:01 -04:00
co-authored by Claude Opus 5
parent 6cd886cd78
commit e86dc9c27b
4 changed files with 423 additions and 5 deletions
+193 -2
View File
@@ -307,7 +307,9 @@ def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
return schema return schema
declaration = customization.get('x-style-elements') declaration = customization.get('x-style-elements')
if not isinstance(declaration, dict) or not declaration: if not isinstance(declaration, dict) or not declaration:
return schema # No compact declaration: the plugin may still have hand-written
# its style blocks longhand, which nineteen of them do.
return _adopt_handwritten_block(schema, customization)
expanded = copy.deepcopy(schema) expanded = copy.deepcopy(schema)
customization = expanded['properties']['customization'] customization = expanded['properties']['customization']
@@ -370,6 +372,7 @@ def _element_block_from_spec(element_key: str,
properties: Dict[str, Any] = {} properties: Dict[str, Any] = {}
order = [] order = []
size_spec = spec.get('size') if isinstance(spec.get('size'), dict) else None
font_spec = spec.get('font') font_spec = spec.get('font')
if isinstance(font_spec, dict): if isinstance(font_spec, dict):
font_prop: Dict[str, Any] = { font_prop: Dict[str, Any] = {
@@ -381,6 +384,13 @@ def _element_block_from_spec(element_key: str,
# text box the user had to type a filename into. # text box the user had to type a filename into.
'x-widget': 'font-selector', 'x-widget': 'font-selector',
} }
# A bitmap font ignores font_size and renders at its own baked-in
# size, so the size ceiling has to be enforced when picking the
# font, not when setting the size.
max_size = (size_spec or {}).get('max') if isinstance(
spec.get('size'), dict) else None
if isinstance(max_size, (int, float)):
font_prop['x-options'] = {'maxFixedSize': max_size}
if 'default' in font_spec: if 'default' in font_spec:
font_prop['default'] = font_spec['default'] font_prop['default'] = font_spec['default']
if isinstance(font_spec.get('enum'), list): if isinstance(font_spec.get('enum'), list):
@@ -388,7 +398,6 @@ def _element_block_from_spec(element_key: str,
properties['font'] = font_prop properties['font'] = font_prop
order.append('font') order.append('font')
size_spec = spec.get('size')
if isinstance(size_spec, dict): if isinstance(size_spec, dict):
size_prop: Dict[str, Any] = { size_prop: Dict[str, Any] = {
'type': 'integer', 'type': 'integer',
@@ -587,6 +596,188 @@ def _modes_block(declaration: Dict[str, Any],
} }
#: The sub-fields that make a customization sub-object a style element.
#: Checked against every published schema: 68 blocks across 19 plugins match
#: exactly, and nothing else does -- favorite_result_colors, baseball's
#: bases/outs/player_card, jellyfin's progress_bar and the stocks blocks all
#: carry other fields and are correctly left alone.
_STYLE_BLOCK_FIELDS = frozenset(_STYLE_KEYS)
def _looks_like_style_block(block: Any) -> bool:
"""Whether a hand-written customization sub-object is a style element.
Deliberately strict: every field must be one this system understands.
A looser rule ("has at least one style field") would sweep in blocks
like baseball's ``count``, which happens to carry a text_color next to
geometry that means nothing here.
"""
if not isinstance(block, dict):
return False
props = block.get('properties')
if not isinstance(props, dict) or not props:
return False
return set(props) <= _STYLE_BLOCK_FIELDS
def _detect_style_blocks(customization: Dict[str, Any]) -> list:
"""Element keys in a hand-written customization block, in declared order."""
props = customization.get('properties')
if not isinstance(props, dict):
return []
return [key for key, block in props.items()
if key not in ('layout', 'modes') and _looks_like_style_block(block)]
def _upgrade_font_property(block: Dict[str, Any]) -> None:
"""Point a hand-written font field at the font picker, in place.
These fields ship a hardcoded ``enum`` -- football lists five of the
thirty-five installed fonts -- which is why a font a user uploads can
never appear in one. The enum is replaced rather than extended: it is
not a curated safe set (it omits some twenty other faces that fit
just as well), it is the fonts that happened to exist when it was
written.
The size ceiling the block already declares is carried across as
``maxFixedSize``, because a bitmap font ignores font_size and renders
at its own baked-in size -- so widening the list without that would
offer faces that overflow the panel no matter what size is set.
"""
props = block.get('properties')
if not isinstance(props, dict):
return
font_prop = props.get('font')
if not isinstance(font_prop, dict):
return
font_prop.pop('enum', None)
font_prop['x-widget'] = 'font-selector'
size_prop = props.get('font_size')
maximum = size_prop.get('maximum') if isinstance(size_prop, dict) else None
if isinstance(maximum, (int, float)):
options = font_prop.setdefault('x-options', {})
if isinstance(options, dict):
options.setdefault('maxFixedSize', maximum)
def _nullable_block(block: Dict[str, Any], title: Optional[str] = None,
description: Optional[str] = None) -> Dict[str, Any]:
"""A copy of an element block with every field optional.
The per-mode counterpart of a hand-written block: same fields, all
nullable and defaulting to null, which is this system's "inherit".
"""
out = copy.deepcopy(block)
out['properties'] = {k: _nullable(v)
for k, v in (out.get('properties') or {}).items()}
out['x-style-managed'] = True
if title:
out['title'] = title
if description is not None:
out['description'] = description
return out
def _modes_block_from_properties(props: Dict[str, Any], element_keys: list,
modes: Any) -> Dict[str, Any]:
"""``customization.modes`` built from already-expanded element blocks.
The compact declaration has ``_modes_block``; this is the same thing for
a plugin that hand-wrote its blocks, so both forms get per-mode overrides
from one declaration line.
"""
layout_source = (props.get('layout') or {}).get('properties') or {}
mode_props: Dict[str, Any] = {}
for mode in modes:
if not isinstance(mode, str) or not mode:
continue
element_props: Dict[str, Any] = {}
layout_props: Dict[str, Any] = {}
for key in element_keys:
block = props.get(key)
if isinstance(block, dict):
element_props[key] = _nullable_block(
block,
description='Leave blank to use the settings above for '
'this mode.')
# Every layout element, not just those with a style block. The two
# namespaces do not line up in a hand-written schema -- football
# styles 'score_text' but positions 'score', and positions logos,
# timeouts and possession that have no style block at all. Keying
# this off the style elements would have given six of its eleven
# positionable things no per-mode offset.
for key, layout_block in layout_source.items():
if isinstance(layout_block, dict):
layout_props[key] = _nullable_block(layout_block)
if layout_props:
element_props['layout'] = {
'type': 'object',
'title': 'Layout Offsets',
'x-advanced': True,
'additionalProperties': False,
'properties': layout_props,
}
mode_props[mode] = {
'type': 'object',
'title': mode.replace('_', ' ').title(),
'x-style-managed': True,
'additionalProperties': False,
'properties': element_props,
}
return {
'type': 'object',
'title': 'Per-Mode Overrides',
'description': 'Override the settings above for one display mode. '
'Anything left blank follows the settings above.',
'x-advanced': True,
'additionalProperties': False,
'properties': mode_props,
}
def _adopt_handwritten_block(schema: Dict[str, Any],
customization: Dict[str, Any]) -> Dict[str, Any]:
"""Give a hand-written customization block the same treatment as a
declared one, without the plugin rewriting its schema.
Nineteen plugins spell their style elements out longhand -- football's
block is 701 lines for seven elements -- and predate every part of this
system. Recognising that shape lets them pick up the row-per-element
editor and the real font picker on a core update, with no plugin
release. What they do not get for free is per-mode overrides and the
visible/align/scale fields, because core cannot invent a plugin's list
of display modes: adding ``x-style-modes`` is the one line that unlocks
the rest.
"""
element_keys = _detect_style_blocks(customization)
if not element_keys:
return schema
expanded = copy.deepcopy(schema)
customization = expanded['properties']['customization']
customization.setdefault('x-widget', 'style-editor')
props = customization['properties']
for key in element_keys:
_upgrade_font_property(props[key])
modes = customization.get('x-style-modes')
if isinstance(modes, list) and modes:
props.setdefault('modes',
_modes_block_from_properties(props, element_keys,
modes))
# Stated explicitly because the config form serialises the schema with
# Flask's JSON provider, which sorts keys -- without this the elements
# reach the browser alphabetised.
order = list(element_keys)
order += [k for k in props if k not in order]
customization.setdefault('x-propertyOrder', order)
return expanded
def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]: def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
"""Extract per-element style defaults from a config schema dict. """Extract per-element style defaults from a config schema dict.
+186 -2
View File
@@ -177,10 +177,25 @@ class TestExpandStyleElements:
expand_style_elements(STYLE_ELEMENTS_SCHEMA) expand_style_elements(STYLE_ELEMENTS_SCHEMA)
assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before
def test_no_declaration_returns_same_object(self): def test_a_schema_with_nothing_to_expand_is_returned_as_is(self):
assert expand_style_elements(MANUAL_SCHEMA) is MANUAL_SCHEMA """No needless deep copy when there is nothing to do."""
empty = {"properties": {}} empty = {"properties": {}}
assert expand_style_elements(empty) is empty assert expand_style_elements(empty) is empty
no_style = {"properties": {"customization": {"type": "object",
"properties": {"favorite_result_colors": {"type": "object",
"properties": {"win_color": {"type": "array"}}}}}}}
assert expand_style_elements(no_style) is no_style
def test_a_hand_written_block_is_adopted(self):
"""Nineteen plugins spell their style elements out longhand rather
than declaring them, and predate this system entirely. They pick up
the editor and the font picker on a core update rather than on a
plugin release."""
expanded = expand_style_elements(MANUAL_SCHEMA)
assert expanded is not MANUAL_SCHEMA
customization = expanded["properties"]["customization"]
assert customization["x-widget"] == "style-editor"
assert "x-style-elements" not in MANUAL_SCHEMA["properties"]["customization"]
def test_garbage_input_never_raises(self): def test_garbage_input_never_raises(self):
bad = {"properties": {"customization": {"x-style-elements": "nope"}}} bad = {"properties": {"customization": {"x-style-elements": "nope"}}}
@@ -1073,3 +1088,172 @@ class TestAliasedLookup:
config, defaults_from_schema_file(extra_schema_path) config, defaults_from_schema_file(extra_schema_path)
).style("score_text") ).style("score_text")
assert st.scale == 2.0 assert st.scale == 2.0
HANDWRITTEN = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"title": "Display Customization",
"properties": {
"score_text": {
"type": "object",
"title": "Game Score",
"properties": {
"font": {
"type": "string",
"enum": ["PressStart2P-Regular.ttf", "4x6-font.ttf",
"5by7.regular.ttf", "5x7.bdf", "4x6.bdf"],
"default": "PressStart2P-Regular.ttf",
},
"font_size": {"type": "integer", "minimum": 4,
"maximum": 16, "default": 10},
"text_color": {"type": "array", "minItems": 3,
"maxItems": 3, "default": [255, 255, 255]},
},
},
"favorite_result_colors": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"win_color": {"type": "array", "default": [0, 255, 0]},
},
},
# baseball's 'count' shape: one field this system knows,
# next to geometry that means nothing to it.
"count": {
"type": "object",
"properties": {
"text_color": {"type": "array", "default": [0, 255, 0]},
"y_offset": {"type": "integer", "default": 2},
},
},
"layout": {
"type": "object",
"properties": {
"score": {"type": "object", "properties": {
"x_offset": {"type": "integer", "default": 0},
"y_offset": {"type": "integer", "default": 0}}},
"home_logo": {"type": "object", "properties": {
"x_offset": {"type": "integer", "default": 0}}},
},
},
},
},
},
}
class TestHandWrittenAdoption:
"""Nineteen plugins spell their style elements out longhand -- football's
block is 701 lines for seven elements -- and predate this system. Core
recognises that shape so they pick up the editor and the real font picker
on a core update rather than on a plugin release.
"""
def _customization(self, schema=None):
return expand_style_elements(
schema or HANDWRITTEN)["properties"]["customization"]
def test_the_block_gets_the_composite_editor(self):
assert self._customization()["x-widget"] == "style-editor"
def test_the_declared_order_is_stated_explicitly(self):
"""Flask's JSON provider sorts keys, so without this the elements
reach the browser alphabetised."""
order = self._customization()["x-propertyOrder"]
assert order[0] == "score_text"
def test_a_block_that_is_not_styling_is_left_alone(self):
"""favorite_result_colors, baseball's bases/outs/player_card and the
stocks blocks all carry fields this system knows nothing about."""
block = self._customization()["properties"]["favorite_result_colors"]
assert "x-style-managed" not in block
assert block["properties"]["win_color"]["default"] == [0, 255, 0]
def test_a_block_that_merely_shares_a_field_is_left_alone(self):
"""The reason detection requires *every* field to be one this system
understands. baseball's 'count' carries a text_color beside a
y_offset that means nothing here.
Asserted on the element list rather than on the block itself: an
over-eager rule leaves a fontless block looking untouched, and only
shows up as an extra row in the editor and an extra per-mode
override group.
"""
schema = copy.deepcopy(HANDWRITTEN)
schema["properties"]["customization"]["x-style-modes"] = ["live"]
live = expand_style_elements(schema)["properties"]["customization"][
"properties"]["modes"]["properties"]["live"]["properties"]
assert "score_text" in live
assert "count" not in live, (
"'count' is not a style element -- it shares one field and "
"carries geometry this system does not understand")
assert "favorite_result_colors" not in live
def test_the_hardcoded_font_list_is_replaced_by_the_picker(self):
"""The reason an uploaded font could never appear in one of these."""
font = self._customization()["properties"]["score_text"]["properties"]["font"]
assert "enum" not in font
assert font["x-widget"] == "font-selector"
def test_the_picker_inherits_the_declared_size_ceiling(self):
"""A bitmap font ignores font_size and renders at its own baked-in
size, so a field capped at 16 must not offer a 27px face."""
font = self._customization()["properties"]["score_text"]["properties"]["font"]
assert font["x-options"]["maxFixedSize"] == 16
def test_the_users_existing_font_choice_stays_valid(self):
font = self._customization()["properties"]["score_text"]["properties"]["font"]
assert font["default"] == "PressStart2P-Regular.ttf"
assert font["type"] == "string"
def test_the_input_schema_is_not_mutated(self):
expand_style_elements(HANDWRITTEN)
original = HANDWRITTEN["properties"]["customization"]
assert "x-widget" not in original
assert "enum" in original["properties"]["score_text"]["properties"]["font"]
def test_no_style_blocks_means_no_expansion(self):
schema = {"properties": {"customization": {"type": "object",
"properties": {"favorite_result_colors": {"type": "object",
"properties": {"win_color": {"type": "array"}}}}}}}
assert expand_style_elements(schema) is schema
class TestAdoptedModes:
"""Per-mode overrides stay opt-in: core cannot invent a plugin's list of
display modes. Declaring x-style-modes is the one line that unlocks them
for a hand-written block."""
def _live(self):
schema = copy.deepcopy(HANDWRITTEN)
schema["properties"]["customization"]["x-style-modes"] = ["live", "recent"]
return expand_style_elements(schema)["properties"]["customization"][
"properties"]["modes"]["properties"]["live"]["properties"]
def test_modes_are_not_invented(self):
assert "modes" not in self._customization_props()
def _customization_props(self):
return expand_style_elements(HANDWRITTEN)["properties"]["customization"]["properties"]
def test_declaring_modes_generates_them(self):
assert "score_text" in self._live()
def test_mode_fields_are_nullable(self):
size = self._live()["score_text"]["properties"]["font_size"]
assert size["default"] is None and "null" in size["type"]
def test_every_positionable_element_gets_per_mode_offsets(self):
"""The two namespaces do not line up in a hand-written schema: this
one styles 'score_text' but positions 'score', and positions a logo
that has no style block at all. Keying the layout off the style
elements would have left both without a per-mode offset."""
layout = self._live()["layout"]["properties"]
assert set(layout) == {"score", "home_logo"}
def test_the_mode_font_picker_keeps_the_ceiling(self):
font = self._live()["score_text"]["properties"]["font"]
assert font["x-options"]["maxFixedSize"] == 16
assert "null" in font["type"]
@@ -174,6 +174,12 @@
const xOptions = config['x-options'] || config['x_options'] || {}; const xOptions = config['x-options'] || config['x_options'] || {};
const placeholder = xOptions.placeholder || 'Select a font...'; const placeholder = xOptions.placeholder || 'Select a font...';
const filterTypes = xOptions.filterTypes || null; // e.g., ['ttf', 'bdf'] 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 showPreview = xOptions.showPreview === true;
const disabled = xOptions.disabled === true; const disabled = xOptions.disabled === true;
const required = xOptions.required === true; const required = xOptions.required === true;
@@ -204,6 +210,16 @@
return filterTypes.some(t => t.toLowerCase() === fontType); 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 // Build select HTML
let html = `<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">`; let html = `<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">`;
@@ -127,6 +127,27 @@
// ---- individual controls ------------------------------------------- // ---- 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) {
if (!maxFixedSize) { return fonts; }
return fonts.filter(function (f) {
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) { function fontControl(name, current, fonts, optional, onChange) {
var select = el('select', { var select = el('select', {
name: name, name: name,
@@ -334,7 +355,10 @@
var types = Array.isArray(declared) ? declared : [declared]; var types = Array.isArray(declared) ? declared : [declared];
if (opts.key === 'font' || prop['x-widget'] === 'font-selector') { if (opts.key === 'font' || prop['x-widget'] === 'font-selector') {
return fontControl(opts.name, opts.current, opts.fonts, 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.optional, opts.onFontChange); opts.optional, opts.onFontChange);
} }
if (types.indexOf('boolean') !== -1) { if (types.indexOf('boolean') !== -1) {
@@ -411,6 +435,9 @@
current: effective(value, path, prop, optional), current: effective(value, path, prop, optional),
optional: optional, optional: optional,
fonts: opts.fonts, 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); } onFontChange: function () { syncSize(fontSelect); }
}); });
if (col.key === 'font') { fontSelect = node; } if (col.key === 'font') { fontSelect = node; }