mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 18:46:37 +00:00
docs(plugins): how to declare styleable elements; harden the widget's lookups
The plugin-author guide for the compact x-style-elements declaration -- what each key does, how to read values back without breaking the "user-forced only when it differs from the default" rule, and why a hand-written block needs no changes to be adopted. Also clears the static-analysis findings on style-editor.js. Every lookup in that file is keyed by something out of a schema or a saved config, so a key of __proto__ or constructor would walk the prototype chain and hand back a function instead of a schema; reads now go through an own-property helper. The panel registry became a list, and the flagged vars moved to their function roots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
|||||||
|
# Per-element styling for plugin authors
|
||||||
|
|
||||||
|
Users want to change the font, size and colour of individual things on screen,
|
||||||
|
nudge them a few pixels, hide the ones they do not care about, and scale a logo
|
||||||
|
down. This is the one system that does that, and a plugin joins it by
|
||||||
|
**declaring elements in its `config_schema.json`** — not by writing a style
|
||||||
|
resolver, a font cache or a web form.
|
||||||
|
|
||||||
|
The short version:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"customization": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "Display Customization",
|
||||||
|
"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,
|
||||||
|
"visible": true,
|
||||||
|
"align": true
|
||||||
|
},
|
||||||
|
"home_logo": { "title": "Home logo", "offsets": true, "scale": true }
|
||||||
|
},
|
||||||
|
"x-style-modes": ["live", "upcoming", "recent"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole declaration. The core expands it into a full JSON Schema, the
|
||||||
|
web UI renders a compact style editor with a row per element, and the values
|
||||||
|
land in `config.json` under the keys you named.
|
||||||
|
|
||||||
|
## What each key does
|
||||||
|
|
||||||
|
| Key | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `font` | Font picker listing every shipped **and user-uploaded** font. |
|
||||||
|
| `size` | Number field. `min`/`max` also cap which fixed-size fonts are offered. |
|
||||||
|
| `color` | Colour swatch; stored as `[r, g, b]`. |
|
||||||
|
| `offsets` | X/Y nudge, stored under `customization.layout.<element>`. |
|
||||||
|
| `visible` | Show/hide toggle. |
|
||||||
|
| `align` | `left` / `center` / `right`. |
|
||||||
|
| `scale` | Size multiplier, for logos and images. Also under `layout`. |
|
||||||
|
|
||||||
|
`x-style-modes` is optional. Declare it and every element gains a per-mode
|
||||||
|
override tab — a scoreboard can then style its live, upcoming and recent cards
|
||||||
|
separately. **A mode field left blank means "inherit", not zero.**
|
||||||
|
|
||||||
|
## Reading the values
|
||||||
|
|
||||||
|
Every plugin inherits `BasePlugin.styles`, which finds your `config_schema.json`
|
||||||
|
on its own:
|
||||||
|
|
||||||
|
```python
|
||||||
|
style = self.styles.style(
|
||||||
|
"score_text",
|
||||||
|
classic_font="PressStart2P-Regular.ttf", # what you shipped
|
||||||
|
classic_size=10,
|
||||||
|
classic_color=(255, 255, 255),
|
||||||
|
)
|
||||||
|
if style.visible:
|
||||||
|
draw.text((x + style.offset[0], y + style.offset[1]),
|
||||||
|
text, font=style.font, fill=style.color)
|
||||||
|
```
|
||||||
|
|
||||||
|
For a specific mode, use `self.styles_for("recent")`, or set
|
||||||
|
`STYLE_MODE = "recent"` on the class and keep calling `self.styles`.
|
||||||
|
|
||||||
|
### The one rule that matters
|
||||||
|
|
||||||
|
**Pass your shipped values as the `classic_*` arguments.** The resolver returns
|
||||||
|
them verbatim unless the user actually changed something, which is what keeps an
|
||||||
|
untouched install rendering byte-identically. It can tell the difference because
|
||||||
|
a value only counts as user-forced when it *differs from the schema default* —
|
||||||
|
the save path writes the full default object into `config.json` on every save,
|
||||||
|
so "present in config" proves nothing.
|
||||||
|
|
||||||
|
Never compare against the default yourself; that rule lives in exactly one place.
|
||||||
|
|
||||||
|
### Stateless readers
|
||||||
|
|
||||||
|
For helpers handed a config dict rather than a plugin instance:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.element_style import (element_color, element_visible,
|
||||||
|
element_align, element_scale, layout_offset)
|
||||||
|
|
||||||
|
colour = element_color(config, "score_text", (255, 255, 255), mode)
|
||||||
|
shown = element_visible(config, "records", True, mode)
|
||||||
|
dy = layout_offset(config, "score", "y_offset", 0, mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sports scoreboards
|
||||||
|
|
||||||
|
`SportsCoreSharedMixin` wires most of this up already. Two things to know:
|
||||||
|
|
||||||
|
* **Name your draws.** `_draw_text_with_outline(..., element="score_text")`
|
||||||
|
resolves the colour by name *and* honours the visibility toggle. Without it
|
||||||
|
the colour has to be guessed from the identity of the font object, which
|
||||||
|
cannot tell two elements apart when they share a face — the case every
|
||||||
|
bitmap font is in.
|
||||||
|
* **Modes are free.** Live/upcoming/recent are separate instances, so setting
|
||||||
|
`SKIN_MODE` on each is enough; no call site passes a mode.
|
||||||
|
|
||||||
|
## Adopting an existing hand-written block
|
||||||
|
|
||||||
|
If your schema already spells out `font` / `font_size` / `text_color` per
|
||||||
|
element longhand, **you do not need to change anything**. The core recognises
|
||||||
|
that shape and upgrades it in place: the style editor, the real font picker
|
||||||
|
(including uploaded fonts) and per-mode overrides all appear on a core update.
|
||||||
|
Add `x-style-modes` if you want the mode tabs.
|
||||||
|
|
||||||
|
## Fonts, and why size is sometimes locked
|
||||||
|
|
||||||
|
32 of the 35 shipped fonts are fixed-strike BDF bitmaps: they render at exactly
|
||||||
|
one pixel size and ignore `font_size`. The picker knows which, and the editor
|
||||||
|
locks the size field to the native size and labels it `fixed`. A font too tall
|
||||||
|
for the `max` you declared is not offered at all.
|
||||||
|
|
||||||
|
Uploaded fonts (Fonts tab) land in `assets/fonts/` and appear in the picker
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
1. Declare `x-style-elements` (and `x-style-modes` if you have modes).
|
||||||
|
2. Read through `self.styles`, passing your shipped values as `classic_*`.
|
||||||
|
3. Honour `style.visible`, `style.offset` and `style.scale` where they apply.
|
||||||
|
4. Confirm an untouched config renders identically:
|
||||||
|
`python scripts/check_plugin.py --plugin <id>`.
|
||||||
|
5. Monorepo plugins: bump `manifest.json` and run `python update_registry.py`.
|
||||||
|
|
||||||
|
See also: [docs/PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md),
|
||||||
|
[docs/FONT_MANAGER.md](FONT_MANAGER.md).
|
||||||
@@ -45,6 +45,8 @@ Going deeper:
|
|||||||
|
|
||||||
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) — minimal config you need
|
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) — minimal config you need
|
||||||
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) — schema design
|
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) — schema design
|
||||||
|
- [PLUGIN_ELEMENT_STYLING.md](PLUGIN_ELEMENT_STYLING.md) — let users restyle,
|
||||||
|
move, hide and scale individual elements (per display mode, if you have them)
|
||||||
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) — multi-tab UI configs
|
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) — multi-tab UI configs
|
||||||
- [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) — how the config system works
|
- [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) — how the config system works
|
||||||
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) — properties every plugin honors
|
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) — properties every plugin honors
|
||||||
|
|||||||
@@ -39,6 +39,28 @@
|
|||||||
var FONT_CACHE = null;
|
var FONT_CACHE = null;
|
||||||
var FONT_INFLIGHT = null;
|
var FONT_INFLIGHT = null;
|
||||||
|
|
||||||
|
var hasOwn = Object.prototype.hasOwnProperty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read one own property, by a key that came from data.
|
||||||
|
*
|
||||||
|
* Every lookup in here is keyed by something out of a schema or a saved
|
||||||
|
* config -- an element name, a mode name, a field name. A key of
|
||||||
|
* `__proto__` or `constructor` would otherwise walk up the prototype
|
||||||
|
* chain and hand back a function instead of a schema, so reads go
|
||||||
|
* through here and misses come back undefined.
|
||||||
|
*/
|
||||||
|
function own(obj, key) {
|
||||||
|
if (!obj || typeof obj !== 'object') { return undefined; }
|
||||||
|
return hasOwn.call(obj, key) ? obj[key] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `own`, but always an object -- for `(x || {}).properties` chains. */
|
||||||
|
function ownObj(obj, key) {
|
||||||
|
var found = own(obj, key);
|
||||||
|
return (found && typeof found === 'object') ? found : {};
|
||||||
|
}
|
||||||
|
|
||||||
/** The font catalog, fetched once per page. */
|
/** The font catalog, fetched once per page. */
|
||||||
function loadFonts() {
|
function loadFonts() {
|
||||||
if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); }
|
if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); }
|
||||||
@@ -51,7 +73,7 @@
|
|||||||
.then(function (payload) {
|
.then(function (payload) {
|
||||||
var catalog = (payload && payload.data && payload.data.catalog) || {};
|
var catalog = (payload && payload.data && payload.data.catalog) || {};
|
||||||
FONT_CACHE = Object.keys(catalog).map(function (key) {
|
FONT_CACHE = Object.keys(catalog).map(function (key) {
|
||||||
var entry = catalog[key];
|
var entry = ownObj(catalog, key);
|
||||||
return {
|
return {
|
||||||
filename: entry.filename,
|
filename: entry.filename,
|
||||||
label: entry.display_name || entry.filename,
|
label: entry.display_name || entry.filename,
|
||||||
@@ -74,10 +96,11 @@
|
|||||||
function el(tag, attrs, children) {
|
function el(tag, attrs, children) {
|
||||||
var node = document.createElement(tag);
|
var node = document.createElement(tag);
|
||||||
Object.keys(attrs || {}).forEach(function (k) {
|
Object.keys(attrs || {}).forEach(function (k) {
|
||||||
if (k === 'class') { node.className = attrs[k]; }
|
var v = own(attrs, k);
|
||||||
else if (k === 'text') { node.textContent = attrs[k]; }
|
if (k === 'class') { node.className = v; }
|
||||||
else if (attrs[k] !== null && attrs[k] !== undefined) {
|
else if (k === 'text') { node.textContent = v; }
|
||||||
node.setAttribute(k, attrs[k]);
|
else if (v !== null && v !== undefined) {
|
||||||
|
node.setAttribute(k, v);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
(children || []).forEach(function (c) { node.appendChild(c); });
|
(children || []).forEach(function (c) { node.appendChild(c); });
|
||||||
@@ -87,9 +110,10 @@
|
|||||||
/** Walk a nested value object by path segments. */
|
/** Walk a nested value object by path segments. */
|
||||||
function at(value, path) {
|
function at(value, path) {
|
||||||
var cur = value;
|
var cur = value;
|
||||||
for (var i = 0; i < path.length; i++) {
|
var i;
|
||||||
|
for (i = 0; i < path.length; i++) {
|
||||||
if (cur === null || typeof cur !== 'object') { return undefined; }
|
if (cur === null || typeof cur !== 'object') { return undefined; }
|
||||||
cur = cur[path[i]];
|
cur = own(cur, path[i]);
|
||||||
}
|
}
|
||||||
return cur;
|
return cur;
|
||||||
}
|
}
|
||||||
@@ -121,12 +145,12 @@
|
|||||||
var order = schema['x-propertyOrder'] || Object.keys(props);
|
var order = schema['x-propertyOrder'] || Object.keys(props);
|
||||||
return order.filter(function (k) {
|
return order.filter(function (k) {
|
||||||
return k !== 'layout' && k !== 'modes'
|
return k !== 'layout' && k !== 'modes'
|
||||||
&& props[k] && props[k].properties;
|
&& own(props, k) && ownObj(props, k).properties;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function titleOf(schema, key) {
|
function titleOf(schema, key) {
|
||||||
var prop = (schema.properties || {})[key] || {};
|
var prop = ownObj(schema.properties || {}, key);
|
||||||
return prop.title || key.replace(/_/g, ' ');
|
return prop.title || key.replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,9 +281,14 @@
|
|||||||
// drives them.
|
// drives them.
|
||||||
var wrap = el('div', { class: 'flex items-center gap-1' });
|
var wrap = el('div', { class: 'flex items-center gap-1' });
|
||||||
var has = Array.isArray(current) && current.length >= 3;
|
var has = Array.isArray(current) && current.length >= 3;
|
||||||
var channels = [0, 1, 2].map(function (i) {
|
var startValues = has ? current.slice(0, 3) : [];
|
||||||
var input = el('input', { type: 'hidden', name: name + '.' + i });
|
var channels = startValues.concat([null, null, null])
|
||||||
if (has) { input.value = current[i]; }
|
.slice(0, 3)
|
||||||
|
.map(function (channelValue, i) {
|
||||||
|
var input = el('input', {
|
||||||
|
type: 'hidden', name: name + '.' + i
|
||||||
|
});
|
||||||
|
if (has) { input.value = channelValue; }
|
||||||
return input;
|
return input;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,8 +309,8 @@
|
|||||||
});
|
});
|
||||||
swatch.addEventListener('input', function () {
|
swatch.addEventListener('input', function () {
|
||||||
var hex = swatch.value;
|
var hex = swatch.value;
|
||||||
[1, 3, 5].forEach(function (start, i) {
|
channels.forEach(function (channel, i) {
|
||||||
channels[i].value = parseInt(hex.substr(start, 2), 16);
|
channel.value = parseInt(hex.substr(1 + i * 2, 2), 16);
|
||||||
});
|
});
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
if (clearBtn) { clearBtn.classList.remove('hidden'); }
|
if (clearBtn) { clearBtn.classList.remove('hidden'); }
|
||||||
@@ -334,15 +363,17 @@
|
|||||||
*/
|
*/
|
||||||
function columnsFor(schema) {
|
function columnsFor(schema) {
|
||||||
var props = schema.properties || {};
|
var props = schema.properties || {};
|
||||||
var layoutProps = (props.layout || {}).properties || {};
|
var layoutProps = ownObj(props, 'layout').properties || {};
|
||||||
var seen = {};
|
// No prototype to inherit from, so a field literally named
|
||||||
|
// "constructor" is a column like any other.
|
||||||
|
var seen = Object.create(null);
|
||||||
elementKeys(schema).forEach(function (key) {
|
elementKeys(schema).forEach(function (key) {
|
||||||
Object.keys((props[key] || {}).properties || {}).forEach(
|
Object.keys(ownObj(props, key).properties || {}).forEach(
|
||||||
function (f) { seen[f] = 'element'; });
|
function (f) { seen[f] = 'element'; });
|
||||||
Object.keys((layoutProps[key] || {}).properties || {}).forEach(
|
Object.keys(ownObj(layoutProps, key).properties || {}).forEach(
|
||||||
function (f) { seen[f] = 'layout'; });
|
function (f) { seen[f] = 'layout'; });
|
||||||
});
|
});
|
||||||
var known = COLUMN_ORDER.filter(function (f) { return seen[f]; });
|
var known = COLUMN_ORDER.filter(function (f) { return own(seen, f); });
|
||||||
// Anything the schema declares that this file has never heard of
|
// Anything the schema declares that this file has never heard of
|
||||||
// still gets a column, rather than silently vanishing.
|
// still gets a column, rather than silently vanishing.
|
||||||
var extra = Object.keys(seen).filter(function (f) {
|
var extra = Object.keys(seen).filter(function (f) {
|
||||||
@@ -351,8 +382,8 @@
|
|||||||
return known.concat(extra).map(function (f) {
|
return known.concat(extra).map(function (f) {
|
||||||
return {
|
return {
|
||||||
key: f,
|
key: f,
|
||||||
where: seen[f],
|
where: own(seen, f),
|
||||||
label: COLUMN_LABELS[f] || f.replace(/_/g, ' ')
|
label: own(COLUMN_LABELS, f) || f.replace(/_/g, ' ')
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -362,10 +393,10 @@
|
|||||||
var prop = opts.prop;
|
var prop = opts.prop;
|
||||||
var declared = prop.type;
|
var declared = prop.type;
|
||||||
var types = Array.isArray(declared) ? declared : [declared];
|
var types = Array.isArray(declared) ? declared : [declared];
|
||||||
|
|
||||||
if (opts.key === 'font' || prop['x-widget'] === 'font-selector') {
|
|
||||||
var xOptions = prop['x-options'] || prop['x_options'] || {};
|
var xOptions = prop['x-options'] || prop['x_options'] || {};
|
||||||
var cap = Number(xOptions.maxFixedSize) || opts.maxFixedSize || null;
|
var cap = Number(xOptions.maxFixedSize) || opts.maxFixedSize || null;
|
||||||
|
|
||||||
|
if (opts.key === 'font' || prop['x-widget'] === 'font-selector') {
|
||||||
return fontControl(opts.name, opts.current,
|
return fontControl(opts.name, opts.current,
|
||||||
usableFonts(opts.fonts, cap, opts.current),
|
usableFonts(opts.fonts, cap, opts.current),
|
||||||
opts.optional, opts.onFontChange);
|
opts.optional, opts.onFontChange);
|
||||||
@@ -390,9 +421,9 @@
|
|||||||
var value = opts.value;
|
var value = opts.value;
|
||||||
var optional = opts.optional;
|
var optional = opts.optional;
|
||||||
|
|
||||||
var props = ((schema.properties || {})[key] || {}).properties || {};
|
var props = ownObj(schema.properties || {}, key).properties || {};
|
||||||
var layoutProps = ((schema.properties || {}).layout || {}).properties || {};
|
var layoutProps = ownObj(schema.properties || {}, 'layout').properties || {};
|
||||||
var axes = (layoutProps[key] || {}).properties || {};
|
var axes = ownObj(layoutProps, key).properties || {};
|
||||||
|
|
||||||
var row = el('div', {
|
var row = el('div', {
|
||||||
class: 'style-editor-row grid items-center gap-2 py-1',
|
class: 'style-editor-row grid items-center gap-2 py-1',
|
||||||
@@ -427,7 +458,8 @@
|
|||||||
|
|
||||||
opts.columns.forEach(function (col) {
|
opts.columns.forEach(function (col) {
|
||||||
var inLayout = col.where === 'layout';
|
var inLayout = col.where === 'layout';
|
||||||
var prop = inLayout ? axes[col.key] : props[col.key];
|
var prop = inLayout ? own(axes, col.key) : own(props, col.key);
|
||||||
|
var cell;
|
||||||
if (!prop) {
|
if (!prop) {
|
||||||
// This element does not declare that field; keep the grid
|
// This element does not declare that field; keep the grid
|
||||||
// aligned with an empty cell.
|
// aligned with an empty cell.
|
||||||
@@ -452,7 +484,7 @@
|
|||||||
if (col.key === 'font') { fontSelect = node; }
|
if (col.key === 'font') { fontSelect = node; }
|
||||||
if (col.key === 'font_size') {
|
if (col.key === 'font_size') {
|
||||||
sizeInput = node;
|
sizeInput = node;
|
||||||
var cell = el('div', { class: 'flex items-center' });
|
cell = el('div', { class: 'flex items-center' });
|
||||||
cell.appendChild(node);
|
cell.appendChild(node);
|
||||||
cell.appendChild(sizeNote);
|
cell.appendChild(sizeNote);
|
||||||
node = cell;
|
node = cell;
|
||||||
@@ -520,10 +552,12 @@
|
|||||||
container.appendChild(root);
|
container.appendChild(root);
|
||||||
|
|
||||||
loadFonts().then(function (fonts) {
|
loadFonts().then(function (fonts) {
|
||||||
var modeProps = ((schema.properties || {}).modes || {}).properties || {};
|
var modeProps = ownObj(schema.properties || {}, 'modes').properties || {};
|
||||||
var modes = Object.keys(modeProps);
|
var modes = Object.keys(modeProps);
|
||||||
|
|
||||||
var panels = {};
|
// A list, not a keyed object: the ids are mode names out of
|
||||||
|
// the schema, and nothing here needs a lookup by key.
|
||||||
|
var panels = [];
|
||||||
var tabs = null;
|
var tabs = null;
|
||||||
if (modes.length) {
|
if (modes.length) {
|
||||||
tabs = el('div', { class: 'style-editor-tabs flex gap-1 mb-2' });
|
tabs = el('div', { class: 'style-editor-tabs flex gap-1 mb-2' });
|
||||||
@@ -531,14 +565,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function panel(id, node) {
|
function panel(id, node) {
|
||||||
panels[id] = node;
|
panels.push({ id: id, node: node });
|
||||||
node.classList.add('style-editor-panel');
|
node.classList.add('style-editor-panel');
|
||||||
root.appendChild(node);
|
root.appendChild(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(id) {
|
function show(id) {
|
||||||
Object.keys(panels).forEach(function (k) {
|
panels.forEach(function (p) {
|
||||||
panels[k].hidden = (k !== id);
|
p.node.hidden = (p.id !== id);
|
||||||
});
|
});
|
||||||
if (!tabs) { return; }
|
if (!tabs) { return; }
|
||||||
Array.prototype.forEach.call(tabs.children, function (b) {
|
Array.prototype.forEach.call(tabs.children, function (b) {
|
||||||
@@ -565,7 +599,7 @@
|
|||||||
tab('__base__', 'All modes');
|
tab('__base__', 'All modes');
|
||||||
|
|
||||||
modes.forEach(function (mode) {
|
modes.forEach(function (mode) {
|
||||||
var modeSchema = modeProps[mode] || {};
|
var modeSchema = ownObj(modeProps, mode);
|
||||||
var modeValue = at(current, ['modes', mode]) || {};
|
var modeValue = at(current, ['modes', mode]) || {};
|
||||||
var node = el('div');
|
var node = el('div');
|
||||||
node.appendChild(el('p', {
|
node.appendChild(el('p', {
|
||||||
|
|||||||
Reference in New Issue
Block a user