fix(web): the style editor takes over its own blocks -- and gets to at all

Two defects, both found by rendering the real partial in a browser rather than
by reading the code.

It was losing a race to its own fields. The hand-off guard asked "do any
fallback controls differ from their server-rendered defaults?" as a proxy for
"is someone editing this?". But the fallback holds this block's own font
fields, and the font-selector widget populates them on the same 50ms timer --
so a plain page load, with nobody touching anything, raced into "dirty" and the
editor removed itself, leaving the 701-line accordion form it exists to
replace. Measured: seven customization.*.font selects dirty ~60ms after
injection, clean again by 400ms. The question is whether a *person* typed, and
event.isTrusted answers exactly that; the listeners now go on synchronously,
because the edit worth protecting can happen before initWidget runs.

It took over too much. Taking over removed the whole fallback section, but a
customization block can hold more than styling -- football keeps
favorite_result_colors there -- so that removed the only UI those fields had,
and the editor also rendered them as an element, giving every row an "enabled"
and three colour columns. Core now marks the blocks it recognises as styling
(the compact declaration already did; hand-written adoption did not), the
widget renders only those, and the template drops only the children the widget
reports owning.

Verified on football's real schema: 28 rows across four mode tabs, columns
Element/Font/Size/Colour/X/Y, favorite_result_colors still editable with its
ten inputs, no duplicated field names, no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-13 12:21:12 -04:00
co-authored by Claude Opus 5
parent c0e473882a
commit b8d344ae60
3 changed files with 204 additions and 23 deletions
@@ -0,0 +1,127 @@
"""When the style editor takes over a customization block, it takes over
exactly its own part of it -- and it actually gets to take over at all.
Two defects this pins, both found by rendering the real partial in a browser
rather than by reading the code:
1. The hand-off guard asked "do any fallback controls differ from their
server-rendered defaults?" as a proxy for "is someone editing this?". The
fallback contains this block's own font fields, and the font-selector
widget populates them on the same 50ms timer -- so a plain page load with
nobody touching anything raced into "dirty" (seven customization.*.font
selects, measured ~60ms after injection) and the style editor removed
itself, leaving the accordion form it exists to replace. The question is
whether a *person* typed, and isTrusted answers exactly that.
2. Taking over used to remove the whole fallback section. A customization
block can hold more than styling -- football keeps favorite_result_colors
there -- so that removed the only UI those fields had.
These assert on the template source because the behaviour lives in an inline
script that no Python test executes; the browser check that found both is not
something CI runs.
"""
import re
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
TEMPLATE = (PROJECT_ROOT / "web_interface" / "templates" / "v3" / "partials"
/ "plugin_config.html")
@pytest.fixture(scope="module")
def source():
return TEMPLATE.read_text(encoding="utf-8")
class TestTheHandOffGuard:
def test_it_asks_whether_a_person_typed(self, source):
assert "isTrusted" in source, (
"a programmatic change by a sibling widget must not read as a "
"user edit -- that is what removed the editor on a plain load")
assert "userEdited" in source
def test_it_no_longer_diffs_values_against_defaults(self, source):
assert "fallbackIsDirty" not in source, (
"value-diffing cannot distinguish a user's edit from another "
"widget populating this block's own fields")
assert "defaultSelected" not in source
def test_the_listeners_are_attached_synchronously(self, source):
"""The edit worth protecting can happen before initWidget runs, so
the listeners cannot wait for it.
Scoped to this branch: the template defines an initWidget for every
widget it can render, and the first one in the file belongs to a
different branch entirely.
"""
start = source.index("obj_widget == 'style-editor'")
branch = source[start:source.index("{% elif prop.properties %}", start)]
assert "watchForRealEdits" in branch
assert branch.index("watchForRealEdits") < branch.index("function initWidget"), (
"the watcher must be installed above this branch's initWidget, "
"and invoked immediately rather than from inside it")
def test_the_guard_still_backs_off_for_a_real_edit(self, source):
assert re.search(r"if \(userEdited\) \{ container\.remove\(\); return; \}",
source), "a genuine in-progress edit must still win"
class TestItTakesOverOnlyItsOwnBlocks:
def test_children_are_individually_addressable(self, source):
assert 'data-child-key="{{ nested_key }}"' in source, (
"without a handle per child the only options are removing the "
"whole section or none of it")
def test_removal_is_driven_by_what_the_widget_reported(self, source):
assert "ownedKeys" in source
assert "fallback.querySelector(" in source
def test_an_unowned_child_survives(self, source):
"""The empty-section case still removes the lot, so a block that is
entirely styling looks exactly as it did before."""
assert "!fallback.querySelector('[data-child-key]')" in source
class TestTheSchemaSaysWhichBlocksAreStyling:
def test_adopted_blocks_are_marked_and_others_are_not(self):
from src.element_style import expand_style_elements
schema = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"properties": {
"score_text": {
"type": "object",
"properties": {
"font": {"type": "string",
"default": "PressStart2P-Regular.ttf"},
"font_size": {"type": "integer", "default": 10},
"text_color": {"type": "array",
"default": [255, 255, 255]},
},
},
# Not styling: a feature that happens to live here.
"favorite_result_colors": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"win_color": {"type": "array",
"default": [0, 255, 0]},
},
},
},
},
},
}
props = expand_style_elements(schema)["properties"]["customization"]["properties"]
assert props["score_text"]["x-style-managed"] is True
assert "x-style-managed" not in props["favorite_result_colors"]