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>
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>