From b8d344ae60ea06cb0232089ac0577f76efc27e2d Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:21:12 -0400 Subject: [PATCH] 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 --- .../test_style_editor_takeover.py | 127 ++++++++++++++++++ .../static/v3/js/widgets/style-editor.js | 26 +++- .../templates/v3/partials/plugin_config.html | 74 +++++++--- 3 files changed, 204 insertions(+), 23 deletions(-) create mode 100644 test/web_interface/test_style_editor_takeover.py diff --git a/test/web_interface/test_style_editor_takeover.py b/test/web_interface/test_style_editor_takeover.py new file mode 100644 index 00000000..4831fb97 --- /dev/null +++ b/test/web_interface/test_style_editor_takeover.py @@ -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"] diff --git a/web_interface/static/v3/js/widgets/style-editor.js b/web_interface/static/v3/js/widgets/style-editor.js index 1017ae39..29d0b359 100644 --- a/web_interface/static/v3/js/widgets/style-editor.js +++ b/web_interface/static/v3/js/widgets/style-editor.js @@ -146,10 +146,20 @@ function elementKeys(schema) { var props = (schema && schema.properties) || {}; var order = schema['x-propertyOrder'] || Object.keys(props); - return order.filter(function (k) { + var shaped = order.filter(function (k) { return k !== 'layout' && k !== 'modes' && own(props, k) && ownObj(props, k).properties; }); + // Core marks the blocks it recognises as styling. Prefer that: a + // customization block can also hold a feature of its own -- football + // keeps favorite_result_colors there -- and treating one as an element + // gives every row that feature's fields as extra columns. + var managed = shaped.filter(function (k) { + return ownObj(props, k)['x-style-managed'] === true; + }); + // Nothing marked means the schema never went through expansion, so + // fall back to the shape test rather than rendering an empty table. + return managed.length ? managed : shaped; } function titleOf(schema, key) { @@ -555,6 +565,20 @@ var root = el('div', { class: 'style-editor' }); container.appendChild(root); + // Published synchronously, because the host reads it the moment + // this returns while the panels below wait on the font catalog. + // layout and modes count as ours: their fields appear as columns + // in these rows, so leaving them to the generic renderer would + // post every offset twice from two different controls. + var owned = elementKeys(schema); + if (ownObj(schema.properties || {}, 'layout').properties) { + owned = owned.concat(['layout']); + } + if (ownObj(schema.properties || {}, 'modes').properties) { + owned = owned.concat(['modes']); + } + container.dataset.ownedKeys = owned.join(','); + loadFonts().then(function (fonts) { var modeProps = ownObj(schema.properties || {}, 'modes').properties || {}; var modes = Object.keys(modeProps); diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 07615448..8d4752a1 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -88,23 +88,30 @@