partforge 0.47.1 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +454 -80
- package/docs/ERROR-PATTERNS.md +24 -0
- package/package.json +1 -1
- package/src/framework/app.css +114 -18
- package/src/framework/controls.js +1 -1
- package/src/framework/lint/rules-schema.js +359 -21
- package/src/framework/mount.js +18 -3
- package/src/framework/oracle/cases.js +17 -6
- package/src/framework/panel/author.js +88 -0
- package/src/framework/panel/info.js +10 -1
- package/src/framework/panel/legacy.js +9 -1
- package/src/framework/panel/render.js +85 -19
- package/src/framework/panel/widget-specs.js +58 -0
- package/src/framework/panel/widgets/checkbox.js +2 -1
- package/src/framework/panel/widgets/index.js +3 -0
- package/src/framework/panel/widgets/numeric.js +69 -8
- package/src/framework/panel/widgets/readout.js +31 -0
- package/src/framework/panel/widgets/select.js +73 -0
- package/src/framework/panel/widgets/text.js +2 -1
- package/src/parts/bracket.js +5 -5
- package/src/parts/planter.js +23 -17
- package/types/part.d.ts +96 -5
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
// Enumerate the parameter configurations verify() checks: the default config plus
|
|
2
2
|
// every declared preset (or an explicit part.verify.cases list).
|
|
3
3
|
|
|
4
|
+
import { desugar } from "../panel/legacy.js";
|
|
5
|
+
|
|
6
|
+
// Preset name -> overrides, discovered from the desugared node tree so both the
|
|
7
|
+
// legacy `presets:` field and authored `{ type: "preset" }` nodes count. The
|
|
8
|
+
// duplicate-name guard predates the duplicate-preset-name lint rule and stays:
|
|
9
|
+
// verify must fail loudly even on an unlinted part.
|
|
4
10
|
function presetMap(part) {
|
|
5
11
|
const map = {};
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
const walk = (nodes) => {
|
|
13
|
+
for (const node of nodes ?? []) {
|
|
14
|
+
if (node.kind === "preset") {
|
|
15
|
+
for (const [name, overrides] of Object.entries(node.presets ?? {})) {
|
|
16
|
+
if (name in map) throw new Error(`duplicate preset name across sections: "${name}"`);
|
|
17
|
+
map[name] = overrides;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (node.kind === "group") walk(node.children);
|
|
11
21
|
}
|
|
12
|
-
}
|
|
22
|
+
};
|
|
23
|
+
walk(desugar(part.parameters ?? []));
|
|
13
24
|
return map;
|
|
14
25
|
}
|
|
15
26
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The NEW authored parameter-schema shape — a section (or nested group) whose
|
|
2
|
+
// children live in a `controls: []` array — normalized to canonical nodes.
|
|
3
|
+
// This file is author.js's mirror of legacy.js: legacy.js is the only code that
|
|
4
|
+
// knows the OLD shapes, this is the only code that knows the new one. Hidden
|
|
5
|
+
// nodes are RETAINED (lint needs them; buildTree drops them).
|
|
6
|
+
//
|
|
7
|
+
// No bare imports: partforge/lint consumes this through desugar() and
|
|
8
|
+
// test/lint-purity.test.js requires a dependency-free closure.
|
|
9
|
+
|
|
10
|
+
const arr = (x) => (Array.isArray(x) ? x : []);
|
|
11
|
+
|
|
12
|
+
// Uniform rule for the new shape: every control marks Custom. The legacy
|
|
13
|
+
// exemptions (feature sliders, toggles) encoded legacy-renderer history, not a
|
|
14
|
+
// design principle — preset application still goes through raw syncs, so
|
|
15
|
+
// applying a preset never marks itself Custom.
|
|
16
|
+
function authoredControl(c) {
|
|
17
|
+
return {
|
|
18
|
+
kind: "control",
|
|
19
|
+
key: c.key,
|
|
20
|
+
type: c.type ?? "slider",
|
|
21
|
+
label: c.label,
|
|
22
|
+
description: c.description,
|
|
23
|
+
unit: c.unit,
|
|
24
|
+
min: c.min,
|
|
25
|
+
max: c.max,
|
|
26
|
+
step: c.step,
|
|
27
|
+
on: c.type === "checkbox" ? (c.on ?? 1) : c.on,
|
|
28
|
+
options: c.options,
|
|
29
|
+
scale: c.scale,
|
|
30
|
+
ticks: c.ticks,
|
|
31
|
+
snap: c.snap,
|
|
32
|
+
recommended: c.recommended,
|
|
33
|
+
hidden: !!c.hidden,
|
|
34
|
+
when: c.when,
|
|
35
|
+
whenFalse: c.whenFalse,
|
|
36
|
+
preserveOn: false,
|
|
37
|
+
marksCustom: true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function authoredPreset(p) {
|
|
42
|
+
const names = p.presets ? Object.keys(p.presets) : [];
|
|
43
|
+
if (!names.length) return null; // a picker with only "Custom" in it is useless
|
|
44
|
+
return {
|
|
45
|
+
kind: "preset", id: p.id, label: p.label, presets: p.presets,
|
|
46
|
+
hidden: !!p.hidden, when: p.when, whenFalse: p.whenFalse,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function authoredGroup(g) {
|
|
51
|
+
// No description on inner groups: the fold toggle is itself a button, so
|
|
52
|
+
// there is nowhere to hang an info glyph. Sections keep theirs.
|
|
53
|
+
return {
|
|
54
|
+
kind: "group", id: g.id, title: g.title,
|
|
55
|
+
collapsed: g.collapsed ?? "auto", bare: !!g.bare, hidden: !!g.hidden,
|
|
56
|
+
when: g.when, whenFalse: g.whenFalse,
|
|
57
|
+
children: authoredChildren(g.controls),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function authoredChildren(list) {
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const entry of arr(list)) {
|
|
64
|
+
if (!entry) continue; // lint must be able to walk a broken part
|
|
65
|
+
if (entry.type === "group") out.push(authoredGroup(entry));
|
|
66
|
+
else if (entry.type === "preset") {
|
|
67
|
+
const node = authoredPreset(entry);
|
|
68
|
+
if (node) out.push(node);
|
|
69
|
+
} else if (entry.type === "readout") out.push({
|
|
70
|
+
kind: "display", type: "readout", label: entry.label, description: entry.description,
|
|
71
|
+
unit: entry.unit, derivedKey: entry.derivedKey,
|
|
72
|
+
hidden: !!entry.hidden, when: entry.when, whenFalse: entry.whenFalse,
|
|
73
|
+
});
|
|
74
|
+
else out.push(authoredControl(entry));
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function authoredSection(sec) {
|
|
80
|
+
return {
|
|
81
|
+
kind: "group", id: sec?.id, title: sec?.title, description: sec?.description,
|
|
82
|
+
collapsed: sec?.collapsed ?? "auto", hidden: !!sec?.hidden,
|
|
83
|
+
when: sec?.when, whenFalse: sec?.whenFalse,
|
|
84
|
+
children: authoredChildren(sec?.controls),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// Authored `id` is honored on containers only; a control entry's `id` is
|
|
88
|
+
// dropped (positional ids serve) and lint warns on the unknown field.
|
|
@@ -20,6 +20,15 @@ export function popoverTop({ glyphTop, glyphBottom, popHeight, viewportHeight })
|
|
|
20
20
|
return Math.max(8, glyphTop - 6 - popHeight);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// Popover left edge: aligned 8px left of the glyph when that fits, pulled
|
|
24
|
+
// left so the popover's right edge keeps a 10px margin from the viewport
|
|
25
|
+
// edge, and never past a 10px margin on the left (left margin wins when both
|
|
26
|
+
// would be violated). Pure, for direct unit testing — happy-dom reports zero
|
|
27
|
+
// layout metrics, same as popoverTop above.
|
|
28
|
+
export function popoverLeft({ glyphLeft, popWidth, viewportWidth }) {
|
|
29
|
+
return Math.max(10, Math.min(glyphLeft - 8, viewportWidth - 10 - popWidth));
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
// One popover element per panel, shared by all its glyphs (only one open at a
|
|
24
33
|
// time). Document-level dismiss listeners are registered per panel and removed
|
|
25
34
|
// by panel.dispose().
|
|
@@ -51,7 +60,7 @@ export function createInfoPopover() {
|
|
|
51
60
|
glyph.setAttribute("aria-expanded", "true");
|
|
52
61
|
const r = glyph.getBoundingClientRect();
|
|
53
62
|
pop.style.top = `${popoverTop({ glyphTop: r.top, glyphBottom: r.bottom, popHeight: pop.offsetHeight, viewportHeight: window.innerHeight })}px`;
|
|
54
|
-
pop.style.left = `${
|
|
63
|
+
pop.style.left = `${popoverLeft({ glyphLeft: r.left, popWidth: pop.offsetWidth, viewportWidth: window.innerWidth })}px`;
|
|
55
64
|
},
|
|
56
65
|
dispose() {
|
|
57
66
|
document.removeEventListener("click", onDocClick);
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
// retired, this is one file to delete rather than an archaeology dig through the
|
|
5
5
|
// model.
|
|
6
6
|
//
|
|
7
|
-
// Imports
|
|
7
|
+
// Imports author.js, on purpose: partforge/lint consumes desugar() and
|
|
8
8
|
// test/lint-purity.test.js asserts lint's whole import closure has zero bare
|
|
9
9
|
// dependencies.
|
|
10
10
|
|
|
11
|
+
import { authoredSection } from "./author.js";
|
|
12
|
+
|
|
11
13
|
const arr = (x) => (Array.isArray(x) ? x : []);
|
|
12
14
|
|
|
13
15
|
// --- the legacy visibility predicates (unchanged behavior) ------------------
|
|
@@ -74,6 +76,12 @@ function featureNodes(f) {
|
|
|
74
76
|
|
|
75
77
|
export function desugar(parameters) {
|
|
76
78
|
return arr(parameters).map((sec) => {
|
|
79
|
+
// The NEW shape: children live in `controls`. author.js owns it entirely;
|
|
80
|
+
// when both `controls` and legacy arrays appear (a lint error,
|
|
81
|
+
// mixed-section-shape), `controls` wins — same winner-takes-all routing the
|
|
82
|
+
// features branch below applies to the legacy shapes.
|
|
83
|
+
if (Array.isArray(sec?.controls)) return authoredSection(sec);
|
|
84
|
+
|
|
77
85
|
const children = [];
|
|
78
86
|
|
|
79
87
|
// controls.js:180 routes any section with a truthy `features` field
|
|
@@ -5,6 +5,7 @@ import { desugar } from "./legacy.js";
|
|
|
5
5
|
import { buildTree, controlNodes } from "./model.js";
|
|
6
6
|
import { computeState } from "./panel-state.js";
|
|
7
7
|
import { WIDGET_FACTORIES } from "./widgets/index.js";
|
|
8
|
+
import { makeReadout } from "./widgets/readout.js";
|
|
8
9
|
import { createInfoPopover, attachInfo } from "./info.js";
|
|
9
10
|
|
|
10
11
|
function el(tag, className, text) {
|
|
@@ -23,22 +24,25 @@ function indexNodes(nodes, map) {
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export function buildControls(root, parameters, params, onDirty) {
|
|
27
|
+
export function buildControls(root, parameters, params, onDirty, onCommit) {
|
|
27
28
|
const info = createInfoPopover();
|
|
28
29
|
const tree = buildTree(desugar(parameters));
|
|
29
30
|
|
|
30
31
|
const nodeEls = new Map(); // id -> the element whose visibility we toggle
|
|
32
|
+
const displayUpdates = new Map(); // id -> a display widget's update(derived)
|
|
31
33
|
const groupIds = new Set(); // ids that are group/section wrappers, never controls
|
|
32
34
|
const syncFns = []; // { key, sync } for every widget
|
|
33
|
-
const rawSyncs = new Map(); // sectionId -> { key
|
|
35
|
+
const rawSyncs = new Map(); // sectionId -> [{ key, sync }] for preset application
|
|
34
36
|
const widgetSyncs = new Map(); // id -> the RAW widget sync (no markCustom)
|
|
35
37
|
const nodeById = new Map(); // id -> node, for the reveal re-sync
|
|
36
38
|
const lastVisible = new Map(); // id -> previous `visible`, to detect a reveal
|
|
37
39
|
const lastDisabled = new Map(); // id -> previous `disabled`, to skip a no-op input pass
|
|
38
40
|
// Containers that own a disclosure: sections, and titled inner groups (the
|
|
39
|
-
// legacy "Advanced" fold).
|
|
40
|
-
// button
|
|
41
|
-
|
|
41
|
+
// legacy "Advanced" fold). Both share the same anatomy — a header row with
|
|
42
|
+
// the aria-carrying button and a text-free chevron span — so `el` (the
|
|
43
|
+
// section element / the fold wrapper) mirrors the disclosure with a
|
|
44
|
+
// `.collapsed` class and CSS draws the closed-band affordance off that.
|
|
45
|
+
const disclosures = new Map(); // id -> { body, button, el }
|
|
42
46
|
indexNodes(tree, nodeById);
|
|
43
47
|
let relevant = null;
|
|
44
48
|
|
|
@@ -60,7 +64,7 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
60
64
|
const open = state.get(id)?.open ?? true;
|
|
61
65
|
d.body.classList.toggle("hidden", !open);
|
|
62
66
|
d.button.setAttribute("aria-expanded", String(open));
|
|
63
|
-
|
|
67
|
+
d.el.classList.toggle("collapsed", !open);
|
|
64
68
|
}
|
|
65
69
|
};
|
|
66
70
|
|
|
@@ -84,7 +88,15 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
84
88
|
// entirely when `disabled` hasn't changed also keeps this off the hot
|
|
85
89
|
// path (applyState runs on every slider drag and relevance update).
|
|
86
90
|
if (!isGroup && lastDisabled.get(id) !== s.disabled) {
|
|
87
|
-
|
|
91
|
+
if (node.matches?.("input, select, textarea")) node.disabled = s.disabled;
|
|
92
|
+
// `.seg button` is in the list for the radio widget, whose options are
|
|
93
|
+
// <button>s: without it a disabled radio stayed keyboard-focusable and
|
|
94
|
+
// only LOOKED disabled. Scoped to `.seg` rather than every button so a
|
|
95
|
+
// disabled control's ⓘ glyph stays reachable — the popover is where the
|
|
96
|
+
// author explains what has to be enabled first, which is exactly what
|
|
97
|
+
// the reader wants here. (Group nodes skip this branch entirely, so the
|
|
98
|
+
// section and fold disclosure buttons are never touched either way.)
|
|
99
|
+
for (const input of node.querySelectorAll("input, select, textarea, .seg button")) {
|
|
88
100
|
input.disabled = s.disabled;
|
|
89
101
|
}
|
|
90
102
|
lastDisabled.set(id, s.disabled);
|
|
@@ -102,6 +114,15 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
102
114
|
|
|
103
115
|
const onEdit = () => { applyState(); onDirty?.(); };
|
|
104
116
|
|
|
117
|
+
// A commit = the user FINISHED an interaction (slider released, box
|
|
118
|
+
// committed, checkbox ticked, preset applied). Distinct from onDirty, which
|
|
119
|
+
// fires on every input event mid-drag. Wrapped: a throwing host handler
|
|
120
|
+
// must never break the panel.
|
|
121
|
+
const commit = (keys) => {
|
|
122
|
+
if (!onCommit) return;
|
|
123
|
+
try { onCommit(keys); } catch { /* host's problem, not the panel's */ }
|
|
124
|
+
};
|
|
125
|
+
|
|
105
126
|
// --- build ---------------------------------------------------------------
|
|
106
127
|
//
|
|
107
128
|
// TWO DIFFERENT THINGS USE `.hidden`, and conflating them is a real bug:
|
|
@@ -125,16 +146,25 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
125
146
|
|
|
126
147
|
const wrap = el("div", "adv-wrap");
|
|
127
148
|
const body = el("div", "adv hidden"); // starts closed — legacy parity
|
|
128
|
-
|
|
129
|
-
|
|
149
|
+
body.id = `pf-fold-${node.id.replaceAll("/", "-")}`;
|
|
150
|
+
// Same row anatomy as a section header — title button (text-free of any
|
|
151
|
+
// arrow, same exact-textContent reasoning), chevron span on the right,
|
|
152
|
+
// whole row clickable — at the fold's subordinate scale.
|
|
153
|
+
const foldHeader = el("div", "adv-header");
|
|
154
|
+
const toggle = el("button", "adv-toggle");
|
|
155
|
+
toggle.type = "button";
|
|
156
|
+
toggle.append(el("span", "adv-name", node.title));
|
|
157
|
+
toggle.setAttribute("aria-controls", body.id);
|
|
158
|
+
foldHeader.append(toggle, el("span", "chev"));
|
|
159
|
+
foldHeader.addEventListener("click", () => {
|
|
130
160
|
const nowHidden = body.classList.toggle("hidden");
|
|
131
|
-
toggle.textContent = nowHidden ? `${node.title} ▾` : `${node.title} ▴`;
|
|
132
161
|
toggle.setAttribute("aria-expanded", String(!nowHidden));
|
|
162
|
+
wrap.classList.toggle("collapsed", nowHidden);
|
|
133
163
|
});
|
|
134
164
|
for (const child of node.children) renderNode(child, body, sectionCtx);
|
|
135
|
-
wrap.append(
|
|
165
|
+
wrap.append(foldHeader, body);
|
|
136
166
|
nodeEls.set(node.id, wrap); // conditions act on the wrapper
|
|
137
|
-
disclosures.set(node.id, { body, button: toggle,
|
|
167
|
+
disclosures.set(node.id, { body, button: toggle, el: wrap });
|
|
138
168
|
container.append(wrap);
|
|
139
169
|
}
|
|
140
170
|
|
|
@@ -142,6 +172,11 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
142
172
|
// section's controls through their RAW syncs — a preset application must not
|
|
143
173
|
// mark itself Custom (controls.test.js:366).
|
|
144
174
|
function renderPreset(node, container, sectionCtx) {
|
|
175
|
+
if (node.label) {
|
|
176
|
+
const row = el("div", "row");
|
|
177
|
+
row.append(el("label", "", node.label));
|
|
178
|
+
container.append(row);
|
|
179
|
+
}
|
|
145
180
|
const names = Object.keys(node.presets);
|
|
146
181
|
const select = document.createElement("select");
|
|
147
182
|
select.className = "preset";
|
|
@@ -154,8 +189,9 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
154
189
|
const bundle = node.presets[select.value];
|
|
155
190
|
if (!bundle) return; // "Custom"
|
|
156
191
|
Object.assign(params, bundle);
|
|
157
|
-
for (const
|
|
192
|
+
for (const { key, sync } of rawSyncs.get(sectionCtx.id)) if (key in params) sync();
|
|
158
193
|
onEdit();
|
|
194
|
+
commit(Object.keys(bundle));
|
|
159
195
|
});
|
|
160
196
|
// The section's controls need a handle on the picker to drop it to Custom
|
|
161
197
|
// when one of them is edited. First picker in the section wins.
|
|
@@ -167,6 +203,13 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
167
203
|
function renderNode(node, container, sectionCtx) {
|
|
168
204
|
if (node.kind === "group") { renderGroup(node, container, sectionCtx); return; }
|
|
169
205
|
if (node.kind === "preset") { renderPreset(node, container, sectionCtx); return; }
|
|
206
|
+
if (node.kind === "display") {
|
|
207
|
+
const widget = makeReadout(node, { info });
|
|
208
|
+
nodeEls.set(node.id, widget.el);
|
|
209
|
+
displayUpdates.set(node.id, widget.update);
|
|
210
|
+
container.append(widget.el);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
170
213
|
|
|
171
214
|
const factory = WIDGET_FACTORIES[node.type];
|
|
172
215
|
if (!factory) return; // unknown type: lint reports it; the panel skips it
|
|
@@ -179,6 +222,7 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
179
222
|
};
|
|
180
223
|
const widget = factory(node, params, {
|
|
181
224
|
onChange: () => { markCustom(); onEdit(); },
|
|
225
|
+
onCommit: () => commit([node.key]),
|
|
182
226
|
info,
|
|
183
227
|
});
|
|
184
228
|
nodeEls.set(node.id, widget.el);
|
|
@@ -190,7 +234,7 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
190
234
|
// syncValues() uses, and for a preset-section control it does drop the
|
|
191
235
|
// picker to Custom (controls.test.js:350), because a programmatic edit
|
|
192
236
|
// diverges from the preset exactly as a user edit does.
|
|
193
|
-
if (sectionCtx) rawSyncs.get(sectionCtx.id).
|
|
237
|
+
if (sectionCtx) rawSyncs.get(sectionCtx.id).push({ key: node.key, sync: widget.sync });
|
|
194
238
|
syncFns.push({
|
|
195
239
|
key: node.key,
|
|
196
240
|
sync: () => { widget.sync(); markCustom(); },
|
|
@@ -209,27 +253,35 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
209
253
|
// because sectionByTitle-style lookups match `.sec-title` by exact
|
|
210
254
|
// textContent === title (controls.test.js:210), and a text chevron here
|
|
211
255
|
// would break that match.
|
|
212
|
-
title.append(el("span", "sec-name", section.title ?? "")
|
|
256
|
+
title.append(el("span", "sec-name", section.title ?? ""));
|
|
213
257
|
header.append(title);
|
|
258
|
+
// Row order: title (flex:1), then ⓘ, then the chevron on the far right.
|
|
214
259
|
// The ⓘ is a SIBLING of the button, never a child: attachInfo appends a
|
|
215
260
|
// <button>, and a button nested in a button is invalid HTML that never
|
|
216
261
|
// receives clicks.
|
|
217
262
|
attachInfo(header, section.description, info);
|
|
263
|
+
header.append(el("span", "chev"));
|
|
218
264
|
secEl.append(header);
|
|
219
265
|
|
|
220
266
|
const body = el("div", "sec-body");
|
|
267
|
+
body.id = `pf-sec-${section.id.replaceAll("/", "-")}`;
|
|
268
|
+
title.setAttribute("aria-controls", body.id);
|
|
221
269
|
secEl.append(body);
|
|
222
270
|
|
|
223
|
-
title
|
|
271
|
+
// The whole header row toggles: the title button's own click bubbles up
|
|
272
|
+
// here, the chevron and the empty row space hit it directly, and the ⓘ
|
|
273
|
+
// stops propagation in attachInfo. aria state stays on the title button.
|
|
274
|
+
header.addEventListener("click", () => {
|
|
224
275
|
const nowHidden = body.classList.toggle("hidden");
|
|
225
276
|
title.setAttribute("aria-expanded", String(!nowHidden));
|
|
277
|
+
secEl.classList.toggle("collapsed", nowHidden);
|
|
226
278
|
});
|
|
227
|
-
disclosures.set(section.id, { body, button: title,
|
|
279
|
+
disclosures.set(section.id, { body, button: title, el: secEl });
|
|
228
280
|
|
|
229
281
|
// `preset` is filled in when a preset node renders. Controls read it late, so
|
|
230
282
|
// one appearing after them in the children array still works.
|
|
231
283
|
const ctx = { id: section.id, preset: null };
|
|
232
|
-
rawSyncs.set(section.id,
|
|
284
|
+
rawSyncs.set(section.id, []);
|
|
233
285
|
|
|
234
286
|
for (const child of section.children) renderNode(child, body, ctx);
|
|
235
287
|
root.append(secEl);
|
|
@@ -237,8 +289,22 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
237
289
|
|
|
238
290
|
applyState();
|
|
239
291
|
|
|
292
|
+
// The single entry point for a param change: relevance dims/undims controls,
|
|
293
|
+
// derived pushes fresh values into every readout. Either argument may be
|
|
294
|
+
// omitted (mount.js's initial call, or a syncValues-only path) — only what's
|
|
295
|
+
// passed updates. `applyRelevance` is the old name, kept as a thin delegate
|
|
296
|
+
// so existing callers (and mount.test.js) don't have to change.
|
|
297
|
+
const refresh = ({ relevant: nextRelevant, derived } = {}) => {
|
|
298
|
+
if (nextRelevant !== undefined) relevant = nextRelevant;
|
|
299
|
+
if (derived !== undefined) {
|
|
300
|
+
for (const update of displayUpdates.values()) update(derived);
|
|
301
|
+
}
|
|
302
|
+
applyState();
|
|
303
|
+
};
|
|
304
|
+
|
|
240
305
|
return {
|
|
241
|
-
|
|
306
|
+
refresh,
|
|
307
|
+
applyRelevance: (next) => refresh({ relevant: next }),
|
|
242
308
|
syncValues: (keys) => {
|
|
243
309
|
const only = keys && new Set(keys);
|
|
244
310
|
for (const { key, sync } of syncFns) if (!only || only.has(key)) sync();
|
|
@@ -21,12 +21,22 @@ const LEGACY_CONTROL = ["key", "label", "unit", "min", "max", "step", "control",
|
|
|
21
21
|
// What it called TOGGLE_FIELDS.
|
|
22
22
|
const LEGACY_TOGGLE = ["key", "label", "on", "hidden", "description"];
|
|
23
23
|
|
|
24
|
+
// The authored shape (author.js's normalized node tree): every control carries
|
|
25
|
+
// `type`, and `when`/`whenFalse` are real fields there (phase 6 landed them for
|
|
26
|
+
// this shape only — the legacy lists above stay frozen so a legacy `when` still
|
|
27
|
+
// warns). select/radio are new-shape-only types (no legacy equivalent), so
|
|
28
|
+
// their WIDGET_SPECS `fields` use this list directly rather than a legacy one.
|
|
29
|
+
const AUTHOR_COMMON = ["key", "type", "label", "description", "hidden", "when", "whenFalse"];
|
|
30
|
+
|
|
24
31
|
export const WIDGET_SPECS = [
|
|
25
32
|
{ type: "slider", kind: "control", fields: LEGACY_CONTROL },
|
|
26
33
|
{ type: "number", kind: "control", fields: LEGACY_CONTROL },
|
|
27
34
|
{ type: "text", kind: "control", fields: LEGACY_CONTROL },
|
|
28
35
|
{ type: "textarea", kind: "control", fields: LEGACY_CONTROL },
|
|
29
36
|
{ type: "checkbox", kind: "control", fields: LEGACY_TOGGLE },
|
|
37
|
+
{ type: "select", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
|
|
38
|
+
{ type: "radio", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
|
|
39
|
+
{ type: "readout", kind: "display", fields: ["type", "label", "description", "unit", "derivedKey", "hidden", "when", "whenFalse"] },
|
|
30
40
|
];
|
|
31
41
|
|
|
32
42
|
const BY_TYPE = new Map(WIDGET_SPECS.map((s) => [s.type, s]));
|
|
@@ -34,3 +44,51 @@ const BY_TYPE = new Map(WIDGET_SPECS.map((s) => [s.type, s]));
|
|
|
34
44
|
export const WIDGET_TYPES = WIDGET_SPECS.map((s) => s.type);
|
|
35
45
|
export const specFor = (type) => BY_TYPE.get(type);
|
|
36
46
|
export const fieldsFor = (type) => BY_TYPE.get(type)?.fields ?? [];
|
|
47
|
+
|
|
48
|
+
// Per-type extras beyond AUTHOR_COMMON.
|
|
49
|
+
const AUTHOR_EXTRAS = {
|
|
50
|
+
slider: ["unit", "min", "max", "step", "scale", "ticks", "snap", "recommended"],
|
|
51
|
+
number: ["unit", "min", "max", "step", "scale", "ticks", "snap", "recommended"],
|
|
52
|
+
text: [],
|
|
53
|
+
textarea: [],
|
|
54
|
+
checkbox: ["on"],
|
|
55
|
+
select: ["options"],
|
|
56
|
+
radio: ["options"],
|
|
57
|
+
};
|
|
58
|
+
const AUTHOR_FIELDS = new Map(Object.entries(AUTHOR_EXTRAS).map(
|
|
59
|
+
([type, extra]) => [type, [...AUTHOR_COMMON, ...extra]]));
|
|
60
|
+
// readout is a display, not a control — it has no `key`, so it doesn't compose
|
|
61
|
+
// with AUTHOR_COMMON like the control types above. Its author-facing fields
|
|
62
|
+
// are exactly its WIDGET_SPECS fields.
|
|
63
|
+
AUTHOR_FIELDS.set("readout", specFor("readout").fields);
|
|
64
|
+
// An unrecognised type (a typo like "sldier") falls back to AUTHOR_COMMON
|
|
65
|
+
// rather than []: with [], every field on the descriptor — including "key"
|
|
66
|
+
// and "label" — reads as unrecognised, so a single typo cascades into a wall
|
|
67
|
+
// of unknown-control-field warnings with nothing pointing at the real cause.
|
|
68
|
+
// lint's unknown-control-type rule (rules-schema.js) is what actually names
|
|
69
|
+
// the typo; this fallback just keeps the field-level noise from drowning it.
|
|
70
|
+
export const authorFieldsFor = (type) => AUTHOR_FIELDS.get(type) ?? AUTHOR_COMMON;
|
|
71
|
+
|
|
72
|
+
// Container node types in the authored tree. Not widget types — no DOM factory
|
|
73
|
+
// looks them up — so, like the legacy FEATURE_FIELDS/TOGGLE_FIELDS, they keep
|
|
74
|
+
// explicit field lists here rather than living in WIDGET_SPECS.
|
|
75
|
+
export const GROUP_FIELDS = ["type", "id", "title", "collapsed", "bare", "when", "whenFalse", "hidden", "controls"];
|
|
76
|
+
// NB: no "description" — renderGroup has nowhere to hang an info glyph (the
|
|
77
|
+
// toggle is itself a button). Sections keep descriptions (SECTION_FIELDS).
|
|
78
|
+
export const PRESET_FIELDS = ["type", "id", "label", "presets", "when", "whenFalse", "hidden"];
|
|
79
|
+
// A section itself, in the authored shape — collectDescriptors pushes it as a
|
|
80
|
+
// descriptor only when it carries a `when`, so `when-key-not-in-defaults` and
|
|
81
|
+
// `when-unknown-operator` cover section-level conditions too.
|
|
82
|
+
export const SECTION_FIELDS = ["id", "title", "description", "hidden", "collapsed", "when", "whenFalse", "controls"];
|
|
83
|
+
|
|
84
|
+
// select/radio option normalization: long form [{ value, label?, description? }]
|
|
85
|
+
// or shorthand ["round", 8, ...] where each entry is both value and label. Lives
|
|
86
|
+
// here rather than in the select widget because lint's validators consume it
|
|
87
|
+
// and must stay DOM-free (widgets/select.js imports info.js -> markdown.js).
|
|
88
|
+
export function normalizeOptions(options) {
|
|
89
|
+
return (Array.isArray(options) ? options : [])
|
|
90
|
+
.filter((o) => o != null)
|
|
91
|
+
.map((o) => (typeof o === "object"
|
|
92
|
+
? { value: o.value, label: o.label ?? String(o.value), description: o.description }
|
|
93
|
+
: { value: o, label: String(o) }));
|
|
94
|
+
}
|
|
@@ -14,7 +14,7 @@ function el(tag, className, text) {
|
|
|
14
14
|
return node;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function makeCheckbox(node, params, { onChange, info }) {
|
|
17
|
+
export function makeCheckbox(node, params, { onChange, onCommit, info }) {
|
|
18
18
|
const row = el("label", "feat");
|
|
19
19
|
const box = document.createElement("input");
|
|
20
20
|
box.type = "checkbox";
|
|
@@ -30,6 +30,7 @@ export function makeCheckbox(node, params, { onChange, info }) {
|
|
|
30
30
|
params[node.key] = 0;
|
|
31
31
|
}
|
|
32
32
|
onChange?.();
|
|
33
|
+
onCommit?.();
|
|
33
34
|
});
|
|
34
35
|
|
|
35
36
|
const sync = () => { box.checked = params[node.key] > 0; };
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { makeNumeric } from "./numeric.js";
|
|
4
4
|
import { makeText } from "./text.js";
|
|
5
5
|
import { makeCheckbox } from "./checkbox.js";
|
|
6
|
+
import { makeSelect, makeRadio } from "./select.js";
|
|
6
7
|
|
|
7
8
|
export const WIDGET_FACTORIES = {
|
|
8
9
|
slider: makeNumeric,
|
|
@@ -10,4 +11,6 @@ export const WIDGET_FACTORIES = {
|
|
|
10
11
|
text: makeText,
|
|
11
12
|
textarea: makeText,
|
|
12
13
|
checkbox: makeCheckbox,
|
|
14
|
+
select: makeSelect,
|
|
15
|
+
radio: makeRadio,
|
|
13
16
|
};
|
|
@@ -19,7 +19,7 @@ function el(tag, className, text) {
|
|
|
19
19
|
return node;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export function makeNumeric(node, params, { onChange, info }) {
|
|
22
|
+
export function makeNumeric(node, params, { onChange, onCommit, info }) {
|
|
23
23
|
const numeric = node.type === "number";
|
|
24
24
|
const wrap = el("div", "slider");
|
|
25
25
|
const row = el("div", "row");
|
|
@@ -38,26 +38,83 @@ export function makeNumeric(node, params, { onChange, info }) {
|
|
|
38
38
|
row.append(val);
|
|
39
39
|
wrap.append(row);
|
|
40
40
|
|
|
41
|
+
// A log track maps thumb position 0..LOG_STEPS onto [min, max] geometrically —
|
|
42
|
+
// the value box stays linear and exact (see AUTHORING-PARTS.md's "Slider
|
|
43
|
+
// refinements" section). Only valid when min > 0; lint's log-scale-needs-positive-min
|
|
44
|
+
// catches an authored part that violates that before it ever reaches here.
|
|
45
|
+
const LOG_STEPS = 1000;
|
|
46
|
+
const log = node.scale === "log" && node.min > 0;
|
|
47
|
+
const toValue = (t) => Math.exp(Math.log(node.min) + (t / LOG_STEPS) * (Math.log(node.max) - Math.log(node.min)));
|
|
48
|
+
const toPos = (v) => Math.round(LOG_STEPS * (Math.log(v) - Math.log(node.min)) / (Math.log(node.max) - Math.log(node.min)));
|
|
49
|
+
// toPos(0) is -Infinity and a non-finite assignment to slider.value snaps the
|
|
50
|
+
// thumb to mid-track instead of an end — guard the live-typed, unclamped box
|
|
51
|
+
// value before it reaches the slider.
|
|
52
|
+
const toPosSafe = (v) => {
|
|
53
|
+
if (!(v > 0)) return 0;
|
|
54
|
+
const t = toPos(v);
|
|
55
|
+
return Math.max(0, Math.min(LOG_STEPS, Number.isFinite(t) ? t : 0));
|
|
56
|
+
};
|
|
57
|
+
|
|
41
58
|
let slider = null;
|
|
42
59
|
if (!numeric) {
|
|
43
60
|
slider = document.createElement("input");
|
|
44
61
|
slider.type = "range";
|
|
45
|
-
slider.min = node.min; slider.max = node.max; slider.step = node.step;
|
|
46
|
-
slider.value = params[node.key];
|
|
62
|
+
slider.min = log ? 0 : node.min; slider.max = log ? LOG_STEPS : node.max; slider.step = log ? 1 : node.step;
|
|
63
|
+
slider.value = log ? toPosSafe(params[node.key]) : params[node.key];
|
|
47
64
|
slider.addEventListener("input", () => {
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
const v = log ? toValue(+slider.value) : snapTo(+slider.value);
|
|
66
|
+
params[node.key] = v;
|
|
67
|
+
box.value = numStr(v);
|
|
68
|
+
paintWarn();
|
|
50
69
|
onChange?.();
|
|
51
70
|
});
|
|
71
|
+
slider.addEventListener("change", () => onCommit?.());
|
|
52
72
|
wrap.append(slider);
|
|
53
73
|
}
|
|
54
74
|
|
|
75
|
+
// ticks: native datalist marks; snap quantizes input to the nearest tick.
|
|
76
|
+
// The datalist id derives from the node id (assigned by buildTree before
|
|
77
|
+
// factories run) — stable across re-renders, no randomness.
|
|
78
|
+
if (slider && !log && Array.isArray(node.ticks) && node.ticks.length) {
|
|
79
|
+
const dl = document.createElement("datalist");
|
|
80
|
+
dl.id = `pf-ticks-${node.id.replaceAll("/", "-")}`;
|
|
81
|
+
for (const t of node.ticks) {
|
|
82
|
+
const o = document.createElement("option");
|
|
83
|
+
o.value = String(t);
|
|
84
|
+
dl.append(o);
|
|
85
|
+
}
|
|
86
|
+
wrap.append(dl);
|
|
87
|
+
slider.setAttribute("list", dl.id);
|
|
88
|
+
}
|
|
89
|
+
const snapTo = (v) => {
|
|
90
|
+
if (!node.snap || !Array.isArray(node.ticks) || !node.ticks.length) return v;
|
|
91
|
+
return node.ticks.reduce((best, t) => Math.abs(t - v) < Math.abs(best - v) ? t : best);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// recommended: a tinted band of the track, and a warning on the value box
|
|
95
|
+
// when the current value sits outside it. Linear tracks only (like ticks).
|
|
96
|
+
const band = !log && Array.isArray(node.recommended) && node.recommended.length === 2
|
|
97
|
+
? node.recommended : null;
|
|
98
|
+
if (band) {
|
|
99
|
+
wrap.classList.add("has-band");
|
|
100
|
+
const pct = (v) => {
|
|
101
|
+
const raw = Math.max(0, Math.min(100, ((v - node.min) / (node.max - node.min)) * 100));
|
|
102
|
+
return `${Math.round(raw * 1e4) / 1e4}%`; // trim float noise (e.g. 12.499999999999996)
|
|
103
|
+
};
|
|
104
|
+
wrap.style.setProperty("--band-lo", pct(band[0]));
|
|
105
|
+
wrap.style.setProperty("--band-hi", pct(band[1]));
|
|
106
|
+
}
|
|
107
|
+
const paintWarn = () => {
|
|
108
|
+
if (band) box.classList.toggle("warn", params[node.key] < band[0] || params[node.key] > band[1]);
|
|
109
|
+
};
|
|
110
|
+
|
|
55
111
|
// live preview while typing (unclamped); clamp + reformat on commit
|
|
56
112
|
box.addEventListener("input", () => {
|
|
57
113
|
const v = parseFloat(box.value);
|
|
58
114
|
if (!Number.isFinite(v)) return;
|
|
59
115
|
params[node.key] = v;
|
|
60
|
-
if (slider) slider.value = v;
|
|
116
|
+
if (slider) slider.value = log ? toPosSafe(v) : v;
|
|
117
|
+
paintWarn();
|
|
61
118
|
onChange?.();
|
|
62
119
|
});
|
|
63
120
|
box.addEventListener("change", () => {
|
|
@@ -65,13 +122,17 @@ export function makeNumeric(node, params, { onChange, info }) {
|
|
|
65
122
|
if (v == null) { box.value = numStr(params[node.key]); return; } // revert invalid input
|
|
66
123
|
params[node.key] = v;
|
|
67
124
|
box.value = numStr(v);
|
|
68
|
-
if (slider) slider.value = v;
|
|
125
|
+
if (slider) slider.value = log ? toPosSafe(v) : v;
|
|
126
|
+
paintWarn();
|
|
69
127
|
onChange?.();
|
|
128
|
+
onCommit?.();
|
|
70
129
|
});
|
|
71
130
|
|
|
72
131
|
const sync = () => {
|
|
73
132
|
box.value = numStr(params[node.key]);
|
|
74
|
-
if (slider) slider.value = params[node.key];
|
|
133
|
+
if (slider) slider.value = log ? toPosSafe(params[node.key]) : params[node.key];
|
|
134
|
+
paintWarn();
|
|
75
135
|
};
|
|
136
|
+
paintWarn();
|
|
76
137
|
return { el: wrap, sync };
|
|
77
138
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// readout: a read-only display of one derive() output, named by `derivedKey`.
|
|
2
|
+
// A display node, not a control — it has no key, never writes params, and gets
|
|
3
|
+
// its value pushed via panel.refresh({ derived }), not pulled from params.
|
|
4
|
+
import { attachInfo } from "../info.js";
|
|
5
|
+
|
|
6
|
+
function el(tag, className, text) {
|
|
7
|
+
const node = document.createElement(tag);
|
|
8
|
+
if (className) node.className = className;
|
|
9
|
+
if (text != null) node.textContent = text;
|
|
10
|
+
return node;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Same float-noise trim the numeric widgets use (4 dp max).
|
|
14
|
+
const numStr = (v) => String(Math.round(v * 1e4) / 1e4);
|
|
15
|
+
|
|
16
|
+
export function makeReadout(node, { info }) {
|
|
17
|
+
const wrap = el("div", "slider readout");
|
|
18
|
+
const row = el("div", "row");
|
|
19
|
+
const label = el("label", "", node.label);
|
|
20
|
+
attachInfo(label, node.description, info);
|
|
21
|
+
const val = el("div", "val", "—");
|
|
22
|
+
row.append(label, val);
|
|
23
|
+
wrap.append(row);
|
|
24
|
+
const update = (derived) => {
|
|
25
|
+
const v = derived?.[node.derivedKey];
|
|
26
|
+
val.textContent = v == null ? "—"
|
|
27
|
+
: typeof v === "number" ? numStr(v) + (node.unit ? ` ${node.unit}` : "")
|
|
28
|
+
: String(v);
|
|
29
|
+
};
|
|
30
|
+
return { el: wrap, update };
|
|
31
|
+
}
|