diff --git a/src/element_style.py b/src/element_style.py index 8d7eb293..7fff25b3 100644 --- a/src/element_style.py +++ b/src/element_style.py @@ -304,6 +304,12 @@ def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]: expanded = copy.deepcopy(schema) customization = expanded['properties']['customization'] customization.setdefault('type', 'object') + # One composite editor for the whole block. Rendered element by + # element, a realistic scoreboard is 65 nested accordions and five + # levels of clicking to reach one per-mode font size; the widget + # collapses that to a row per element. setdefault, so a plugin that + # names its own widget keeps it. + customization.setdefault('x-widget', 'style-editor') props = customization.setdefault('properties', {}) layout_props: Dict[str, Any] = {} @@ -335,6 +341,15 @@ def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]: props.setdefault('modes', _modes_block(declaration, modes)) + # Declaration order, stated explicitly. Python preserves it in the + # dict, but the config form serialises the schema to JSON with + # Flask's provider, which sorts keys -- so without this the elements + # reach the browser alphabetised, and a scoreboard lists Detail and + # Odds above Score. + order = [k for k in declaration if isinstance(declaration.get(k), dict)] + order += [k for k in ('layout', 'modes') if k in props] + customization.setdefault('x-propertyOrder', order) + return expanded except Exception as e: logger.warning("Error expanding x-style-elements: %s", e) diff --git a/test/web_interface/test_style_editor_save_roundtrip.py b/test/web_interface/test_style_editor_save_roundtrip.py new file mode 100644 index 00000000..50f91a6f --- /dev/null +++ b/test/web_interface/test_style_editor_save_roundtrip.py @@ -0,0 +1,222 @@ +"""The style editor's form data must survive the real save route. + +The widget posts ordinary dotted field names so the existing pipeline needs no +new parsing -- but per-mode override fields are typed ``["integer", "null"]`` +and ``["array", "null"]``, and two places in that pipeline compared the +declared type to a bare string: + +- the indexed-array recombiner (``text_color.0/.1/.2`` -> one list) skipped + anything whose type was not exactly ``'array'``, so a per-mode colour was + never reassembled; +- ``_parse_form_value_with_schema`` did the same, and turned a blank nullable + field into ``[]`` rather than ``None`` -- which then failed the ``minItems`` + the colour array declares. + +Both would have surfaced as "saving a scoreboard's per-mode colour fails +validation", with nothing in the widget to suggest why. This posts what the +widget really emits and asserts on what lands in config, then on what the +resolver makes of it. +""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +SCHEMA = { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": True}, + "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 _SaveOk: + status = type("S", (), {"value": "success"})() + message = None + + +@pytest.fixture +def post(tmp_path): + """POST form data to the real save route; yields the stored config.""" + from src.plugin_system.schema_manager import SchemaManager + from web_interface.blueprints import api_v3 as a + + originals = {k: getattr(a.api_v3, k, None) + for k in ("config_manager", "schema_manager", "plugin_manager")} + + pdir = tmp_path / "plugins" / "demo" + pdir.mkdir(parents=True) + (pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8") + + store = {"demo": {"enabled": True}} + + cm = MagicMock() + cm.load_config.side_effect = lambda: json.loads(json.dumps(store)) + cm.get_config_path.return_value = str(tmp_path / "config.json") + + def _save(cfg, **_kw): + store.clear() + store.update(cfg) + return _SaveOk() + + cm.save_config_atomic.side_effect = _save + + a.api_v3.config_manager = cm + a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins", + project_root=tmp_path) + pm = MagicMock() + pm.plugins = {} + pm.get_plugin.return_value = None + a.api_v3.plugin_manager = pm + + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(a.api_v3, url_prefix="/api/v3") + client = app.test_client() + + def _post(form): + resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=form) + return resp, store.get("demo", {}) + + try: + yield _post, pdir + finally: + for k, v in originals.items(): + setattr(a.api_v3, k, v) + + +BASE_FORM = { + "enabled": "true", + "customization.score_text.font": "PressStart2P-Regular.ttf", + "customization.score_text.font_size": "12", + "customization.score_text.text_color.0": "255", + "customization.score_text.text_color.1": "200", + "customization.score_text.text_color.2": "0", + "customization.layout.score_text.x_offset": "0", + "customization.layout.score_text.y_offset": "0", +} + +# A mode the user left alone: every field blank. Blank colours are simply +# absent, because the widget disables those inputs rather than posting "". +BLANK_MODE = { + "customization.modes.recent.score_text.font": "", + "customization.modes.recent.score_text.font_size": "", + "customization.modes.recent.layout.score_text.x_offset": "", + "customization.modes.recent.layout.score_text.y_offset": "", +} + + +class TestSaveRoundTrip: + def test_the_base_element_saves(self, post): + _post, _ = post + resp, cfg = _post(dict(BASE_FORM)) + assert resp.status_code == 200, resp.get_json() + assert cfg["customization"]["score_text"] == { + "font": "PressStart2P-Regular.ttf", + "font_size": 12, + "text_color": [255, 200, 0], + } + + def test_a_blank_mode_saves_as_null_not_zero(self, post): + """Null is the inherit sentinel; 0 or [] would pin the mode.""" + _post, _ = post + resp, cfg = _post(dict(BASE_FORM, **BLANK_MODE)) + assert resp.status_code == 200, resp.get_json() + recent = cfg["customization"]["modes"]["recent"] + assert recent["score_text"] == {"font": None, "font_size": None, + "text_color": None} + assert recent["layout"]["score_text"] == {"x_offset": None, + "y_offset": None} + + def test_a_partly_set_mode_keeps_the_rest_inheriting(self, post): + _post, _ = post + resp, cfg = _post(dict( + BASE_FORM, + **{"customization.modes.live.score_text.font": "", + "customization.modes.live.score_text.font_size": "16", + "customization.modes.live.layout.score_text.x_offset": "", + "customization.modes.live.layout.score_text.y_offset": "-3"})) + assert resp.status_code == 200, resp.get_json() + live = cfg["customization"]["modes"]["live"] + assert live["score_text"]["font_size"] == 16 + assert live["score_text"]["font"] is None + assert live["score_text"]["text_color"] is None + assert live["layout"]["score_text"] == {"x_offset": None, + "y_offset": -3} + + def test_a_per_mode_colour_is_reassembled_into_a_list(self, post): + """The recombiner used to skip this: its type is ["array", "null"], + not "array", so the three indexed inputs were never joined.""" + _post, _ = post + resp, cfg = _post(dict( + BASE_FORM, + **{"customization.modes.live.score_text.text_color.0": "0", + "customization.modes.live.score_text.text_color.1": "255", + "customization.modes.live.score_text.text_color.2": "0"})) + assert resp.status_code == 200, resp.get_json() + assert (cfg["customization"]["modes"]["live"]["score_text"]["text_color"] + == [0, 255, 0]) + + def test_all_blank_colour_channels_mean_inherit(self, post): + """A hand-written form (or an older widget) can still post three + empty strings; they must not become [] and fail minItems.""" + _post, _ = post + resp, cfg = _post(dict( + BASE_FORM, + **{"customization.modes.live.score_text.text_color.0": "", + "customization.modes.live.score_text.text_color.1": "", + "customization.modes.live.score_text.text_color.2": ""})) + assert resp.status_code == 200, resp.get_json() + assert (cfg["customization"]["modes"]["live"]["score_text"]["text_color"] + is None) + + +class TestWhatTheDisplayThenRenders: + """The point of the round trip: what the resolver makes of what was saved.""" + + def _styles(self, cfg, pdir): + from src.element_style import (ElementStyleResolver, + defaults_from_schema_file) + defaults = defaults_from_schema_file(str(pdir / "config_schema.json")) + out = {} + for mode in (None, "live", "recent"): + r = ElementStyleResolver(cfg, defaults, mode=mode) + style = r.style("score_text", + classic_font="PressStart2P-Regular.ttf", + classic_size=10, classic_color=(255, 255, 255)) + out[mode] = (style.font_size, style.color, r.offset("score_text")) + return out + + def test_a_mode_override_reaches_the_display_and_the_rest_inherits(self, post): + _post, pdir = post + _resp, cfg = _post(dict( + BASE_FORM, **BLANK_MODE, + **{"customization.modes.live.score_text.font": "", + "customization.modes.live.score_text.font_size": "16", + "customization.modes.live.layout.score_text.x_offset": "", + "customization.modes.live.layout.score_text.y_offset": "-3"})) + styles = self._styles(cfg, pdir) + assert styles[None] == (12, (255, 200, 0), (0, 0)) + # live overrides size and y, and inherits the colour it never set + assert styles["live"] == (16, (255, 200, 0), (0, -3)) + assert styles["recent"] == (12, (255, 200, 0), (0, 0)) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 865789f0..054feb7f 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -4635,6 +4635,29 @@ def _is_field_required(key_path, schema): # Sentinel object to indicate a field should be skipped (not set in config) _SKIP_FIELD = object() +def _schema_type_is(prop, wanted): + """Whether a schema property is of ``wanted`` type. + + JSON Schema allows a union (``["array", "null"]``), which the per-element + style system uses for its per-mode override fields: null there means + "inherit the base", so the type genuinely is "an array or nothing". A + bare ``prop.get('type') == 'array'`` reads False for those, which meant + the indexed colour inputs a form posts as ``...text_color.0/.1/.2`` were + never recombined into a list. + """ + if not isinstance(prop, dict): + return False + declared = prop.get('type') + if isinstance(declared, list): + return wanted in declared + return declared == wanted + + +def _schema_allows_null(prop): + """Whether a schema property's declared type includes null.""" + return _schema_type_is(prop, 'null') + + def _parse_form_value_with_schema(value, key_path, schema): """ Parse a form value using schema information to determine correct type. @@ -4655,11 +4678,17 @@ def _parse_form_value_with_schema(value, key_path, schema): # Handle None/empty values if value is None or (isinstance(value, str) and value.strip() == ''): + # A nullable field left blank means null, not an empty container. + # This is the inherit sentinel for per-mode style overrides: an + # empty list there would read as "the user chose no colour" rather + # than "follow the base element". + if _schema_allows_null(prop): + return None # If schema says it's an array, return empty array instead of None - if prop and prop.get('type') == 'array': + if prop and _schema_type_is(prop, 'array'): return [] # If schema says it's an object, return empty dict instead of None - if prop and prop.get('type') == 'object': + if prop and _schema_type_is(prop, 'object'): return {} # If it's an optional string field, preserve empty string instead of None if prop and prop.get('type') == 'string': @@ -4696,7 +4725,7 @@ def _parse_form_value_with_schema(value, key_path, schema): return False # Handle arrays based on schema - if prop and prop.get('type') == 'array': + if prop and _schema_type_is(prop, 'array'): # Try parsing as JSON first (handles "[1,2,3]" format) if stripped.startswith('['): try: @@ -5135,7 +5164,7 @@ def save_plugin_config(): if last_part.isdigit(): # Get schema property for the base path to verify it's an array base_prop = _get_schema_property(schema, base_path) - if base_prop and base_prop.get('type') == 'array': + if base_prop and _schema_type_is(base_prop, 'array'): # This is an array index field index = int(last_part) if base_path not in array_fields: @@ -5150,6 +5179,16 @@ def save_plugin_config(): # Sort by index and extract values index_values.sort(key=lambda x: x[0]) values = [v for _, v in index_values] + # Every channel blank on a nullable field means "unset", not + # an empty array: joining them would produce ", , ", which + # parses to [] and then fails the minItems the array + # declares. This is how a per-mode colour override says + # "inherit the base colour". + base_prop_for_null = _get_schema_property(schema, base_path) + if (_schema_allows_null(base_prop_for_null) + and all(str(v).strip() == '' for v in values)): + _set_nested_value(plugin_config, base_path, None) + continue # Combine values into comma-separated string for parsing combined_value = ', '.join(str(v) for v in values) # Parse as array using schema @@ -6713,6 +6752,18 @@ def get_fonts_catalog(): # Check if this is a system font (cannot be deleted) is_system = catalog_key.lower() in SYSTEM_FONTS + # BDF files are fixed-size bitmap strikes: FreeType + # accepts only the pixel size baked into the file. The + # UI needs to know that before offering a size control, + # or it offers a number that cannot take effect. + native_size = None + if font_type == 'bdf': + try: + from src.element_style import _read_bdf_native_size + native_size = _read_bdf_native_size(str(filepath)) + except Exception: + native_size = None + catalog[catalog_key] = { 'filename': filename, 'family_name': family_name, @@ -6720,6 +6771,8 @@ def get_fonts_catalog(): 'path': relative_path, 'type': font_type, 'is_system': is_system, + 'scalable': font_type != 'bdf', + 'native_size': native_size, 'metadata': metadata if metadata else None } diff --git a/web_interface/static/v3/app.css b/web_interface/static/v3/app.css index 993902dd..5108b19c 100644 --- a/web_interface/static/v3/app.css +++ b/web_interface/static/v3/app.css @@ -1412,3 +1412,57 @@ button.bg-white { max-width: calc(100vw - 2rem); } } + +/* ---- Style editor widget -------------------------------------------- */ +/* One row per display element: label, font, size, colour, X, Y. The grid + is declared once here so the header and the rows cannot drift apart. */ +.style-editor-row { + grid-template-columns: minmax(7rem, 1.4fr) minmax(8rem, 2fr) 5.5rem 4.5rem 4rem 4rem; +} +.style-editor-row .form-control { + padding: 0.2rem 0.4rem; + height: auto; +} +.style-editor-row input[type="number"] { + width: 100%; +} +.style-editor-row input[type="number"]:disabled { + background-color: #f3f4f6; + color: #9ca3af; + cursor: not-allowed; +} +.style-editor-colour { + width: 2rem; + height: 1.6rem; + padding: 0; + border: 1px solid #d1d5db; + border-radius: 0.25rem; + background: none; + cursor: pointer; +} +.style-editor-tab { + border: 1px solid #d1d5db; + background-color: #f9fafb; + color: #374151; +} +.style-editor-tab.is-active { + background-color: #2563eb; + border-color: #2563eb; + color: #ffffff; +} +.style-editor-panel[hidden] { + display: none; +} +@media (max-width: 768px) { + /* Stack rather than squeeze: six columns on a phone is unreadable. */ + .style-editor-row { + grid-template-columns: 1fr 1fr; + } + .style-editor-head { + display: none; + } + .style-editor-label { + grid-column: 1 / -1; + font-weight: 600; + } +} diff --git a/web_interface/static/v3/js/widgets/style-editor.js b/web_interface/static/v3/js/widgets/style-editor.js new file mode 100644 index 00000000..94030839 --- /dev/null +++ b/web_interface/static/v3/js/widgets/style-editor.js @@ -0,0 +1,454 @@ +/** + * Style Editor Widget + * + * One compact row per display element -- font, size, colour, X/Y nudge -- + * instead of the nested accordions the generic object renderer produces. A + * realistic scoreboard declares seven elements with layout offsets and three + * modes, which comes to 65 nested sections and five levels of clicking to + * reach one per-mode font size. + * + * Two things about this widget are load-bearing: + * + * 1. It emits ordinary inputs with the same dotted names the generic + * renderer would produce (`customization.score_text.font`, + * `customization.score_text.text_color.0`, `customization.layout. + * score_text.x_offset`, `customization.modes.live....`). The whole + * save/validate/merge pipeline is therefore untouched: no hidden JSON + * blob, no new server-side parsing. + * + * 2. It is driven entirely by the schema block it is handed. It renders + * whatever sub-fields each element declares, so fields added to the + * schema later appear here without touching this file. + * + * Mode tabs edit `customization.modes.`, whose fields mean "inherit" + * when blank. Blank must therefore post as empty (-> null), never as 0, or + * every mode would pin itself to the base the first time it was saved. + * + * @module StyleEditorWidget + */ + +(function () { + 'use strict'; + + if (typeof window.LEDMatrixWidgets === 'undefined') { + console.error('[StyleEditor] LEDMatrixWidgets registry not found. Load registry.js first.'); + return; + } + + var FONT_CACHE = null; + var FONT_INFLIGHT = null; + + /** The font catalog, fetched once per page. */ + function loadFonts() { + if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); } + if (FONT_INFLIGHT) { return FONT_INFLIGHT; } + FONT_INFLIGHT = fetch('/api/v3/fonts/catalog') + .then(function (r) { return r.json(); }) + .then(function (payload) { + var catalog = (payload && payload.data && payload.data.catalog) || {}; + FONT_CACHE = Object.keys(catalog).map(function (key) { + var entry = catalog[key]; + return { + filename: entry.filename, + label: entry.display_name || entry.filename, + scalable: entry.scalable !== false, + nativeSize: entry.native_size || null + }; + }).sort(function (a, b) { return a.label.localeCompare(b.label); }); + return FONT_CACHE; + }) + .catch(function (e) { + console.warn('[StyleEditor] could not load the font catalog', e); + FONT_CACHE = []; + return FONT_CACHE; + }); + return FONT_INFLIGHT; + } + + function el(tag, attrs, children) { + var node = document.createElement(tag); + Object.keys(attrs || {}).forEach(function (k) { + if (k === 'class') { node.className = attrs[k]; } + else if (k === 'text') { node.textContent = attrs[k]; } + else if (attrs[k] !== null && attrs[k] !== undefined) { + node.setAttribute(k, attrs[k]); + } + }); + (children || []).forEach(function (c) { node.appendChild(c); }); + return node; + } + + /** + * What a control should show: the configured value if there is one, + * else the schema default. + * + * The base tab must fall back to the default rather than leaving the + * control empty. An empty has no empty + // state of its own. + clearBtn = el('button', { + type: 'button', + class: 'text-xs text-gray-500 hover:text-gray-800 style-editor-clear', + title: 'Inherit the colour above', + text: '×' + }); + if (!has) { clearBtn.classList.add('hidden'); } + clearBtn.addEventListener('click', function () { + channels.forEach(function (c) { c.value = ''; }); + setSubmitted(false); + swatch.value = '#ffffff'; + clearBtn.classList.add('hidden'); + }); + wrap.appendChild(clearBtn); + } + return wrap; + } + + // ---- rows ------------------------------------------------------------ + + /** + * One element's row. `prefix` is the dotted path the inputs post under; + * `optional` marks a mode layer, where every field may be left blank. + */ + function elementRow(opts) { + var schema = opts.schema; + var key = opts.key; + var prefix = opts.prefix; + var value = opts.value; + var fonts = opts.fonts; + var optional = opts.optional; + + var props = ((schema.properties || {})[key] || {}).properties || {}; + var row = el('div', { + class: 'style-editor-row grid items-center gap-2 py-1', + 'data-element': key + }); + row.appendChild(el('div', { + class: 'text-sm text-gray-700 style-editor-label', + text: titleOf(schema, key) + })); + + var sizeInput = null; + var sizeNote = el('span', { class: 'text-xs text-gray-500 ml-1' }); + + function syncSize(select) { + // A BDF font has exactly one usable pixel size; offering a free + // number there is offering something that cannot take effect. + if (!sizeInput || !select) { return; } + var opt = select.options[select.selectedIndex]; + var scalable = !opt || opt.dataset.scalable !== '0'; + if (scalable) { + sizeInput.disabled = false; + sizeInput.title = ''; + sizeNote.textContent = ''; + } else { + sizeInput.disabled = true; + sizeInput.value = opt.dataset.nativeSize || ''; + sizeInput.title = 'This is a bitmap font; it renders at one fixed size.'; + sizeNote.textContent = 'fixed'; + } + } + + var fontSelect = null; + if (props.font) { + fontSelect = fontControl(prefix + '.font', + effective(value, [key, 'font'], + props.font, optional), + fonts, optional, + function () { syncSize(fontSelect); }); + row.appendChild(fontSelect); + } else { + row.appendChild(el('span')); + } + + if (props.font_size) { + var cell = el('div', { class: 'flex items-center' }); + sizeInput = numberControl(prefix + '.font_size', + effective(value, [key, 'font_size'], + props.font_size, optional), + props.font_size, + optional ? 'inherit' : ''); + cell.appendChild(sizeInput); + cell.appendChild(sizeNote); + row.appendChild(cell); + } else { + row.appendChild(el('span')); + } + + if (props.text_color) { + row.appendChild(colourControl(prefix + '.text_color', + effective(value, [key, 'text_color'], + props.text_color, optional), + optional)); + } else { + row.appendChild(el('span')); + } + + // Offsets live in a sibling `layout` block, not inside the element, + // which is exactly why this widget takes the whole customization + // object rather than being registered per element. + var layoutProps = ((schema.properties || {}).layout || {}).properties || {}; + var axes = (layoutProps[key] || {}).properties || {}; + ['x_offset', 'y_offset'].forEach(function (axis) { + if (!axes[axis]) { row.appendChild(el('span')); return; } + row.appendChild(numberControl( + opts.layoutPrefix + '.' + key + '.' + axis, + effective(value, ['layout', key, axis], axes[axis], optional), + axes[axis], + optional ? 'inherit' : '0')); + }); + + if (fontSelect) { syncSize(fontSelect); } + return row; + } + + function header(schema) { + var cols = ['Element', 'Font', 'Size', 'Colour', 'X', 'Y']; + var row = el('div', { + class: 'style-editor-row style-editor-head grid gap-2 pb-1 mb-1 border-b border-gray-300' + }); + cols.forEach(function (c) { + row.appendChild(el('div', { + class: 'text-xs font-semibold text-gray-500 uppercase', + text: c + })); + }); + return row; + } + + function table(opts) { + var wrap = el('div', { class: 'style-editor-table' }); + wrap.appendChild(header(opts.schema)); + elementKeys(opts.schema).forEach(function (key) { + wrap.appendChild(elementRow({ + schema: opts.schema, + key: key, + prefix: opts.prefix + '.' + key, + layoutPrefix: opts.prefix + '.layout', + value: opts.value, + fonts: opts.fonts, + optional: opts.optional + })); + }); + return wrap; + } + + // ---- widget ---------------------------------------------------------- + + window.LEDMatrixWidgets.register('style-editor', { + name: 'Style Editor', + version: '1.0.0', + + render: function (container, config, value, options) { + var schema = (config && config.schema) || {}; + var base = (options && options.name) || 'customization'; + var current = value || {}; + + container.innerHTML = ''; + var root = el('div', { class: 'style-editor' }); + container.appendChild(root); + + loadFonts().then(function (fonts) { + var modeProps = ((schema.properties || {}).modes || {}).properties || {}; + var modes = Object.keys(modeProps); + + var panels = {}; + var tabs = null; + if (modes.length) { + tabs = el('div', { class: 'style-editor-tabs flex gap-1 mb-2' }); + root.appendChild(tabs); + } + + function panel(id, node) { + panels[id] = node; + node.classList.add('style-editor-panel'); + root.appendChild(node); + } + + function show(id) { + Object.keys(panels).forEach(function (k) { + panels[k].hidden = (k !== id); + }); + if (!tabs) { return; } + Array.prototype.forEach.call(tabs.children, function (b) { + b.classList.toggle('is-active', b.dataset.panel === id); + }); + } + + function tab(id, label) { + if (!tabs) { return; } + var b = el('button', { + type: 'button', + class: 'style-editor-tab text-sm px-3 py-1 rounded', + text: label + }); + b.dataset.panel = id; + b.addEventListener('click', function () { show(id); }); + tabs.appendChild(b); + } + + panel('__base__', table({ + schema: schema, prefix: base, value: current, + fonts: fonts, optional: false + })); + tab('__base__', 'All modes'); + + modes.forEach(function (mode) { + var modeSchema = modeProps[mode] || {}; + var modeValue = at(current, ['modes', mode]) || {}; + var node = el('div'); + node.appendChild(el('p', { + class: 'text-xs text-gray-500 mb-2', + text: 'Anything left blank follows the "All modes" tab.' + })); + node.appendChild(table({ + schema: modeSchema, + prefix: base + '.modes.' + mode, + value: modeValue, + fonts: fonts, + optional: true + })); + panel(mode, node); + tab(mode, modeSchema.title || mode); + }); + + show('__base__'); + }); + }, + + getValue: function (fieldId) { + // The inputs are ordinary named form fields; the form itself is + // the source of truth, so there is no separate value to hand back. + return null; + } + }); + + console.log('[StyleEditor] widget registered'); +})(); diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 33db68c3..eb23905b 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -1002,6 +1002,7 @@ + diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 190d85df..4cf94efe 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -71,6 +71,54 @@ } })(); + {% elif obj_widget == 'style-editor' %} + {# Composite per-element style editor. It renders its own inputs + with the same dotted names the generic renderer would produce + (customization.score_text.font, ...text_color.0, ...), so the + save/validate/merge pipeline is untouched -- no hidden JSON + blob, no new server-side parsing. It falls back to the normal + nested rendering if the widget cannot be loaded. #} + {% set obj_value = value if value is not none else {} %} +
+ +
+
+ {{ render_nested_section(key, prop, value, prefix, plugin_id) }} +
+
+ {% elif prop.properties %} {{ render_nested_section(key, prop, value, prefix, plugin_id) }} {% endif %}