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>
This commit is contained in:
Chuck
2026-09-10 11:16:32 -04:00
co-authored by Claude Opus 5
parent 9998aaf51c
commit 75026115d5
2 changed files with 231 additions and 1 deletions
+88 -1
View File
@@ -330,6 +330,11 @@ def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
for element_key, block in layout_props.items():
layout['properties'].setdefault(element_key, block)
modes = customization.get('x-style-modes')
if isinstance(modes, list) and modes:
props.setdefault('modes',
_modes_block(declaration, modes))
return expanded
except Exception as e:
logger.warning("Error expanding x-style-elements: %s", e)
@@ -348,6 +353,10 @@ def _element_block_from_spec(element_key: str,
'type': 'string',
'title': 'Font Family',
'x-advanced': True,
# The core already ships this widget and the config form already
# allowlists it; without the hint the field rendered as a bare
# text box the user had to type a filename into.
'x-widget': 'font-selector',
}
if 'default' in font_spec:
font_prop['default'] = font_spec['default']
@@ -418,6 +427,84 @@ def _offset_block_from_spec(element_key: str,
}
def _nullable(prop: Dict[str, Any]) -> Dict[str, Any]:
"""The same property, retyped as "this or unset".
A mode field defaults to null, meaning inherit the base element. The
default has to be null rather than the base value: the save flow writes
schema defaults into config.json wholesale, so a concrete default here
would turn every mode into a copy of the base the moment a user pressed
Save, and the base would stop reaching them.
"""
out = dict(prop)
declared = out.get('type', 'string')
types = declared if isinstance(declared, list) else [declared]
if 'null' not in types:
types = list(types) + ['null']
out['type'] = types
out['default'] = None
return out
def _mode_element_block(element_key: str, spec: Dict[str, Any]) -> Dict[str, Any]:
"""One element's override block for one mode: every field nullable."""
base = _element_block_from_spec(element_key, spec)
base['properties'] = {k: _nullable(v)
for k, v in base.get('properties', {}).items()}
base['description'] = ('Leave blank to use the settings above for this '
'mode.')
return base
def _mode_offset_block(element_key: str, spec: Dict[str, Any]) -> Dict[str, Any]:
"""One element's offset overrides for one mode: both axes nullable."""
base = _offset_block_from_spec(element_key, spec)
base['properties'] = {k: _nullable(v)
for k, v in base.get('properties', {}).items()}
return base
def _modes_block(declaration: Dict[str, Any],
modes: Any) -> Dict[str, Any]:
"""``customization.modes`` — one override group per declared mode."""
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 element_key, spec in declaration.items():
if not isinstance(spec, dict):
continue
element_props[element_key] = _mode_element_block(element_key, spec)
if spec.get('offsets'):
layout_props[element_key] = _mode_offset_block(element_key, spec)
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 defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
"""Extract per-element style defaults from a config schema dict.
@@ -458,7 +545,7 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
properties = customization.get('properties')
if isinstance(properties, dict):
for element_key, block in properties.items():
if element_key == 'layout' or element_key in elements:
if element_key in ('layout', 'modes') or element_key in elements:
continue
if not isinstance(block, dict):
continue
+143
View File
@@ -16,6 +16,7 @@ ledmatrix-music, football-scoreboard):
- style() never raises; malformed input degrades to the classic style.
"""
import copy
import json
import os
@@ -681,3 +682,145 @@ class TestModeOffsets:
config, defaults_from_schema_file(style_schema_path),
mode="live").style("title_text", classic_size=8)
assert st.font_size == 12
MODE_SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"enabled": {"type": "boolean", "default": False},
"customization": {
"type": "object",
"x-style-modes": ["live", "recent"],
"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,
},
},
},
},
}
class TestModeSchemaEmission:
def _expanded(self):
return expand_style_elements(MODE_SCHEMA)["properties"]["customization"]
def test_a_modes_block_is_emitted_per_declared_mode(self):
assert sorted(self._expanded()["properties"]["modes"]["properties"]) == [
"live", "recent"]
def test_mode_fields_are_nullable_and_default_to_null(self):
"""Null is the inherit sentinel. A concrete default here would turn
every mode into a copy of the base the moment the user pressed Save,
because the save flow writes schema defaults into config wholesale."""
live = self._expanded()["properties"]["modes"]["properties"]["live"]
block = live["properties"]["score_text"]["properties"]
for name, prop in block.items():
assert prop["default"] is None, name
assert "null" in prop["type"], name
def test_mode_offsets_are_nullable_too(self):
live = self._expanded()["properties"]["modes"]["properties"]["live"]
axes = live["properties"]["layout"]["properties"]["score_text"]["properties"]
assert set(axes) == {"x_offset", "y_offset"}
for prop in axes.values():
assert prop["default"] is None
assert "null" in prop["type"]
def test_the_base_block_keeps_its_real_defaults(self):
block = self._expanded()["properties"]["score_text"]["properties"]
assert block["font_size"]["default"] == 10
assert block["font"]["default"] == "PressStart2P-Regular.ttf"
def test_the_font_field_asks_for_the_font_selector_widget(self):
"""The widget already existed and was already allowlisted by the
config form; without the hint the field was a bare text box the user
had to type a filename into."""
block = self._expanded()["properties"]["score_text"]["properties"]
assert block["font"]["x-widget"] == "font-selector"
def test_no_declared_modes_emits_no_modes_block(self):
schema = copy.deepcopy(MODE_SCHEMA)
del schema["properties"]["customization"]["x-style-modes"]
expanded = expand_style_elements(schema)["properties"]["customization"]
assert "modes" not in expanded["properties"]
@pytest.mark.parametrize("modes", ["nonsense", [], [None], [""], 5, {}])
def test_garbage_mode_declarations_do_not_break_expansion(self, modes):
schema = copy.deepcopy(MODE_SCHEMA)
schema["properties"]["customization"]["x-style-modes"] = modes
expanded = expand_style_elements(schema)["properties"]["customization"]
assert "score_text" in expanded["properties"]
def test_the_input_schema_is_not_mutated(self):
expand_style_elements(MODE_SCHEMA)
assert "properties" not in MODE_SCHEMA["properties"]["customization"]
class TestModeSaveRoundTrip:
"""The path a real save takes: schema -> defaults -> merge -> validate ->
resolve. The risk being covered is that the save flow writes schema
defaults into config.json wholesale, so a mode block has to survive that
still meaning "inherit"."""
@pytest.fixture
def plugin(self, tmp_path):
from src.plugin_system.schema_manager import SchemaManager
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(MODE_SCHEMA),
encoding="utf-8")
sm = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
schema = sm.load_schema("demo", use_cache=False)
return sm, schema, sm.extract_defaults_from_schema(schema), pdir
def test_a_save_leaves_mode_blocks_meaning_inherit(self, plugin):
sm, schema, defaults, pdir = plugin
merged = sm.merge_with_defaults(
{"enabled": True, "customization": {"score_text": {"font_size": 14}}},
defaults)
live = merged["customization"]["modes"]["live"]["score_text"]
assert live == {"font": None, "font_size": None, "text_color": None}
ok, errors = sm.validate_config_against_schema(merged, schema)
assert ok, errors
style = ElementStyleResolver(
merged, defaults_from_schema_file(str(pdir / "config_schema.json")),
mode="live").style("score_text",
classic_font="PressStart2P-Regular.ttf",
classic_size=10)
assert style.font_size == 14, "the base must still reach the mode"
def test_a_real_mode_override_validates_and_wins(self, plugin):
sm, schema, defaults, pdir = plugin
merged = sm.merge_with_defaults({"customization": {
"score_text": {"font_size": 14},
"modes": {"live": {"score_text": {"font_size": 16},
"layout": {"score_text": {"y_offset": -3}}}},
}}, defaults)
ok, errors = sm.validate_config_against_schema(merged, schema)
assert ok, errors
schema_defaults = defaults_from_schema_file(
str(pdir / "config_schema.json"))
live = ElementStyleResolver(merged, schema_defaults, mode="live")
recent = ElementStyleResolver(merged, schema_defaults, mode="recent")
assert live.style("score_text", classic_size=10).font_size == 16
assert live.offset("score_text") == (0, -3)
assert recent.style("score_text", classic_size=10).font_size == 14
assert recent.offset("score_text") == (0, 0)
def test_a_mode_size_outside_the_declared_range_is_rejected(self, plugin):
"""min/max from the declaration must carry into the mode blocks."""
sm, schema, defaults, _ = plugin
merged = sm.merge_with_defaults(
{"customization": {"modes": {"live": {"score_text": {
"font_size": 99}}}}}, defaults)
ok, _errors = sm.validate_config_against_schema(merged, schema)
assert not ok