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
package/src/framework/app.css
CHANGED
|
@@ -53,46 +53,124 @@ canvas { display: block; }
|
|
|
53
53
|
sits at the rail's own padding, so each slider gains the ~22px the old box
|
|
54
54
|
border + padding used to take from both sides. */
|
|
55
55
|
.section {
|
|
56
|
-
padding:
|
|
56
|
+
/* No vertical padding at the top: the header band (below) owns the space
|
|
57
|
+
around the title so a section's title sits at the same offset whether
|
|
58
|
+
the section is open or collapsed — toggling must not move it. */
|
|
59
|
+
padding: 0 var(--pf-rail-pad) 11px;
|
|
57
60
|
}
|
|
58
61
|
/* Divider BETWEEN visible sections. `~` walks all preceding siblings, and a
|
|
59
62
|
relevance-hidden section (.section-hidden, display:none) fails the :not(),
|
|
60
63
|
so the topmost VISIBLE section never draws a hairline under the rail header.
|
|
61
|
-
|
|
64
|
+
Condition-hidden sections (.hidden, a `when` that evaluated false) are excluded
|
|
65
|
+
for the same reason. Do not simplify this to `.section:first-child { border-top: 0 }` — that
|
|
62
66
|
matches DOM position rather than visibility, and leaves a stray divider
|
|
63
67
|
floating at the top whenever applyRelevance hides the first section. */
|
|
64
|
-
.section:not(.section-hidden) ~ .section:not(.section-hidden) {
|
|
68
|
+
.section:not(.section-hidden):not(.hidden) ~ .section:not(.section-hidden):not(.hidden) {
|
|
65
69
|
border-top: 1px solid var(--pf-border);
|
|
66
70
|
}
|
|
67
|
-
|
|
71
|
+
/* Section disclosure header: the whole row is the click target (render.js
|
|
72
|
+
puts the toggle listener on the header; the title button's click bubbles
|
|
73
|
+
to it and the ⓘ stops propagation). Full-bleed: negative margins cancel
|
|
74
|
+
the rail padding so the hover band and the collapsed rules run edge to
|
|
75
|
+
edge while the text keeps the rail's alignment. Row order: title (flex:1),
|
|
76
|
+
ⓘ, chevron on the far right. */
|
|
77
|
+
.sec-header {
|
|
78
|
+
display: flex; align-items: center; gap: 6px;
|
|
79
|
+
margin: 0 calc(-1 * var(--pf-rail-pad)) 6px;
|
|
80
|
+
/* The same 13px band open or collapsed: the title never moves on toggle
|
|
81
|
+
and the hover target keeps one size. */
|
|
82
|
+
padding: 13px var(--pf-rail-pad);
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
}
|
|
85
|
+
.sec-header:hover { background: var(--pf-surface-2); }
|
|
68
86
|
.sec-title {
|
|
69
|
-
flex: 1; display: flex; align-items: center;
|
|
70
|
-
|
|
87
|
+
flex: 1; display: flex; align-items: center;
|
|
88
|
+
margin: 0; padding: 0; border: 0; background: transparent; cursor: pointer;
|
|
71
89
|
text-align: left;
|
|
72
|
-
font-family: var(--pf-mono); font-size:
|
|
73
|
-
letter-spacing: 0.
|
|
74
|
-
}
|
|
75
|
-
.sec-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
90
|
+
font-family: var(--pf-mono); font-size: 12px; font-weight: 600;
|
|
91
|
+
letter-spacing: 0.12em; text-transform: uppercase; color: var(--pf-text-2);
|
|
92
|
+
}
|
|
93
|
+
.sec-header:hover .sec-title { color: var(--pf-text); }
|
|
94
|
+
/* Disclosure triangle: closes the header row on the right, after the ⓘ.
|
|
95
|
+
Glyph via ::before — the span must stay text-free (tests match .sec-title
|
|
96
|
+
by exact textContent). Rotation keys off the collapsed class because the
|
|
97
|
+
chevron sits beside, not inside, the aria-carrying button. */
|
|
98
|
+
/* A real equilateral triangle (clip-path, not a font glyph): the box is
|
|
99
|
+
exactly the triangle's bounds, so rotating about the center is wobble-free
|
|
100
|
+
— a font's ▾ sits off-center in its em box and lurches when rotated.
|
|
101
|
+
Height = width × √3/2 keeps it equilateral. */
|
|
102
|
+
.sec-header .chev, .adv-header .chev {
|
|
103
|
+
display: inline-block; width: 8px; height: 7px; color: var(--pf-muted-2);
|
|
104
|
+
transition: transform 0.15s ease;
|
|
105
|
+
}
|
|
106
|
+
.sec-header .chev::before, .adv-header .chev::before {
|
|
107
|
+
content: ""; display: block; width: 100%; height: 100%;
|
|
108
|
+
background: currentColor;
|
|
109
|
+
clip-path: polygon(50% 100%, 0 0, 100% 0);
|
|
110
|
+
}
|
|
111
|
+
.section.collapsed > .sec-header .chev, .adv-wrap.collapsed > .adv-header .chev { transform: rotate(-90deg); }
|
|
112
|
+
/* Collapsed section: the header band sits flush between the section's top
|
|
113
|
+
divider and its own bottom rule — text vertically centered, hover and
|
|
114
|
+
click covering the entire band. The -1px bottom margin overlaps this rule
|
|
115
|
+
with the next section's border-top so they read as one line. */
|
|
116
|
+
.section.collapsed {
|
|
117
|
+
padding-bottom: 0;
|
|
118
|
+
border-bottom: 1px solid var(--pf-border); margin-bottom: -1px;
|
|
119
|
+
}
|
|
120
|
+
.section.collapsed .sec-header { margin-bottom: 0; }
|
|
79
121
|
.sec-body.hidden { display: none; }
|
|
80
|
-
|
|
122
|
+
/* Native menulist arrows hug the right border and ignore padding, so the
|
|
123
|
+
panel's selects draw their own — the header chevrons' equilateral triangle
|
|
124
|
+
as a data-URI, inset 10px from the edge. Fixed slate fill: url() can't
|
|
125
|
+
read theme variables, and this shade reads on both themes. */
|
|
126
|
+
select.preset, select.select-input {
|
|
81
127
|
width: 100%; background: var(--pf-input-bg); color: var(--pf-text-2);
|
|
82
|
-
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control);
|
|
128
|
+
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control);
|
|
129
|
+
padding: 7px 26px 7px 9px;
|
|
83
130
|
font-family: var(--pf-mono); font-size: 11px;
|
|
131
|
+
appearance: none;
|
|
132
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='7'%3E%3Cpolygon points='0,0 8,0 4,7' fill='%238b8b94'/%3E%3C/svg%3E");
|
|
133
|
+
background-repeat: no-repeat;
|
|
134
|
+
background-position: right 10px center;
|
|
84
135
|
}
|
|
85
136
|
.feat { display: flex; align-items: center; gap: 8px; margin: 6px 0;
|
|
86
137
|
color: var(--pf-text-2); cursor: pointer; }
|
|
87
138
|
.feat input { cursor: pointer; accent-color: var(--pf-accent); }
|
|
88
139
|
.feat-group { margin: 2px 0 8px; padding-left: 10px; border-left: 1px solid var(--pf-border); }
|
|
89
140
|
.feat-group.hidden { display: none; }
|
|
141
|
+
/* Condition-hidden nodes. Disclosure state uses `.hidden` on `.adv` /
|
|
142
|
+
`.sec-body` (rules above); everything else carrying `.hidden` inside the
|
|
143
|
+
panel is a `when` that evaluated false. */
|
|
144
|
+
.section.hidden, .slider.hidden, .feat.hidden, select.preset.hidden { display: none; }
|
|
145
|
+
/* Inner fold header: same row anatomy as .sec-header (title left, chevron
|
|
146
|
+
right, whole row clickable) at the subordinate scale. Spans the fold's own
|
|
147
|
+
width — not full-bleed — so it never crosses a feat-group's left border;
|
|
148
|
+
small negative margins let the hover band breathe past the text without
|
|
149
|
+
moving it. */
|
|
150
|
+
.adv-header {
|
|
151
|
+
display: flex; align-items: center; gap: 6px;
|
|
152
|
+
margin: 8px -6px 0; padding: 8px 6px;
|
|
153
|
+
/* Transparent borders reserve the collapsed rules' pixels so toggling
|
|
154
|
+
swaps only their color — the title never moves and the hover target
|
|
155
|
+
keeps one size, matching the section-header treatment. */
|
|
156
|
+
border-top: 1px solid transparent; border-bottom: 1px solid transparent;
|
|
157
|
+
border-radius: 6px; cursor: pointer;
|
|
158
|
+
}
|
|
159
|
+
.adv-header:hover { background: var(--pf-surface-2); }
|
|
90
160
|
.adv-toggle {
|
|
91
|
-
|
|
161
|
+
flex: 1; margin: 0; padding: 0; border: 0;
|
|
92
162
|
background: transparent; color: var(--pf-muted); cursor: pointer;
|
|
93
|
-
font-family: var(--pf-mono); font-size:
|
|
163
|
+
font-family: var(--pf-mono); font-size: 12px; font-weight: 600;
|
|
164
|
+
letter-spacing: 0.08em; text-transform: uppercase; text-align: left;
|
|
165
|
+
}
|
|
166
|
+
.adv-header:hover .adv-toggle { color: var(--pf-muted-2); }
|
|
167
|
+
/* Collapsed fold: the reserved borders take color — rules above and below,
|
|
168
|
+
text vertically centered between them (the section-band treatment at fold
|
|
169
|
+
scale). Square corners while the rules show. */
|
|
170
|
+
.adv-wrap.collapsed > .adv-header {
|
|
171
|
+
border-top-color: var(--pf-border); border-bottom-color: var(--pf-border);
|
|
172
|
+
border-radius: 0;
|
|
94
173
|
}
|
|
95
|
-
.adv-toggle:hover { color: var(--pf-muted-2); }
|
|
96
174
|
.adv.hidden { display: none; }
|
|
97
175
|
.adv { margin-top: 4px; }
|
|
98
176
|
|
|
@@ -139,6 +217,24 @@ input[type="range"]:focus-visible { outline: none; }
|
|
|
139
217
|
input[type="range"]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 5px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
140
218
|
input[type="range"]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 5px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
141
219
|
|
|
220
|
+
/* recommended band: a tinted span of the track between --band-lo and --band-hi */
|
|
221
|
+
.slider.has-band input[type="range"]::-webkit-slider-runnable-track {
|
|
222
|
+
background: linear-gradient(to right,
|
|
223
|
+
var(--pf-border) var(--band-lo),
|
|
224
|
+
color-mix(in oklab, var(--pf-accent) 30%, var(--pf-border)) var(--band-lo),
|
|
225
|
+
color-mix(in oklab, var(--pf-accent) 30%, var(--pf-border)) var(--band-hi),
|
|
226
|
+
var(--pf-border) var(--band-hi));
|
|
227
|
+
}
|
|
228
|
+
.slider.has-band input[type="range"]::-moz-range-track {
|
|
229
|
+
background: linear-gradient(to right,
|
|
230
|
+
var(--pf-border) var(--band-lo),
|
|
231
|
+
color-mix(in oklab, var(--pf-accent) 30%, var(--pf-border)) var(--band-lo),
|
|
232
|
+
color-mix(in oklab, var(--pf-accent) 30%, var(--pf-border)) var(--band-hi),
|
|
233
|
+
var(--pf-border) var(--band-hi));
|
|
234
|
+
}
|
|
235
|
+
/* value box outside the recommended band */
|
|
236
|
+
.row .num.warn { border-color: var(--pf-err); color: var(--pf-err); }
|
|
237
|
+
|
|
142
238
|
button.action {
|
|
143
239
|
width: 100%; margin-top: 8px; padding: 9px; border: 0; border-radius: var(--pf-radius-control);
|
|
144
240
|
background: var(--pf-accent); color: var(--pf-on-accent); font-weight: 500; cursor: pointer;
|
|
@@ -10,6 +10,6 @@
|
|
|
10
10
|
// This file stays at its path because animation-controls.js imports the popover
|
|
11
11
|
// helpers from it, and because it is the documented import site.
|
|
12
12
|
export { buildControls } from "./panel/render.js";
|
|
13
|
-
export { popoverTop, createInfoPopover, attachInfo } from "./panel/info.js";
|
|
13
|
+
export { popoverTop, popoverLeft, createInfoPopover, attachInfo } from "./panel/info.js";
|
|
14
14
|
export { clampToRange } from "./panel/widgets/numeric.js";
|
|
15
15
|
export { visibleAdvanced, visibleFeatures, visibleToggles, sectionRenders } from "./panel/legacy.js";
|
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
// resolve against `defaults`, which produce a control that silently does nothing.
|
|
5
5
|
import { err, warn } from "./finding.js";
|
|
6
6
|
import { suggest } from "../geometry/op-options.js";
|
|
7
|
-
import { fieldsFor } from "../panel/widget-specs.js";
|
|
8
|
-
import { sectionRenders } from "../panel/legacy.js";
|
|
7
|
+
import { fieldsFor, authorFieldsFor, WIDGET_TYPES, GROUP_FIELDS, PRESET_FIELDS, SECTION_FIELDS, normalizeOptions } from "../panel/widget-specs.js";
|
|
8
|
+
import { sectionRenders, desugar } from "../panel/legacy.js";
|
|
9
|
+
import { buildTree, WHEN_OPS } from "../panel/model.js";
|
|
10
|
+
import { resolveDerived } from "../derive.js";
|
|
11
|
+
|
|
12
|
+
export const SECTION_CONTROL_BUDGET = 12;
|
|
9
13
|
|
|
10
14
|
// Legacy container descriptors aren't widget types, so they keep explicit lists.
|
|
11
15
|
const FEATURE_FIELDS = ["key", "label", "on", "sliders", "hidden", "description"];
|
|
@@ -20,6 +24,39 @@ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArr
|
|
|
20
24
|
function collectDescriptors(part) {
|
|
21
25
|
const out = [];
|
|
22
26
|
sections(part).forEach((sec, si) => {
|
|
27
|
+
// The authored shape: children in `controls`, recursively. Field lists are
|
|
28
|
+
// the authored ones (authorFieldsFor) — the legacy lists stay untouched so
|
|
29
|
+
// `when` on a legacy descriptor still warns. A section routes to one shape
|
|
30
|
+
// or the other (desugar's winner-takes-all), so `return` before the legacy
|
|
31
|
+
// loops below rather than falling through to them.
|
|
32
|
+
function walkAuthored(list, base) {
|
|
33
|
+
arr(list).forEach((entry, i) => {
|
|
34
|
+
if (!entry) return;
|
|
35
|
+
const path = `${base}[${i}]`;
|
|
36
|
+
if (entry.type === "group") {
|
|
37
|
+
out.push({ d: entry, path, fields: GROUP_FIELDS, container: true, authored: true });
|
|
38
|
+
walkAuthored(entry.controls, `${path}.controls`);
|
|
39
|
+
} else if (entry.type === "preset") {
|
|
40
|
+
out.push({ d: entry, path, fields: PRESET_FIELDS, container: true, authored: true });
|
|
41
|
+
} else {
|
|
42
|
+
// A typo'd type (e.g. "grup") fails both branches above and lands
|
|
43
|
+
// here — authorFieldsFor falls back to AUTHOR_COMMON, and
|
|
44
|
+
// unknown-control-type (below) is what actually diagnoses it.
|
|
45
|
+
out.push({ d: entry, path, fields: authorFieldsFor(entry.type ?? "slider"), authored: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(sec?.controls)) {
|
|
50
|
+
// The section itself is a descriptor too, but only worth collecting when
|
|
51
|
+
// it carries a `when` — a section becomes a descriptor at all only in
|
|
52
|
+
// that case (a deliberate scope choice), and pushing every section
|
|
53
|
+
// unconditionally would produce findings with nothing to say. `when`-only
|
|
54
|
+
// rules just need it present when relevant.
|
|
55
|
+
if (sec?.when !== undefined) out.push({ d: sec, path: `parameters[${si}]`, fields: SECTION_FIELDS, container: true });
|
|
56
|
+
walkAuthored(sec.controls, `parameters[${si}].controls`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
23
60
|
arr(sec?.advanced).forEach((d, i) => {
|
|
24
61
|
if (d) out.push({ d, path: `parameters[${si}].advanced[${i}]`, fields: fieldsFor("slider") });
|
|
25
62
|
});
|
|
@@ -41,8 +78,51 @@ function collectDescriptors(part) {
|
|
|
41
78
|
return out;
|
|
42
79
|
}
|
|
43
80
|
|
|
81
|
+
// Every preset bundle with its source path — the legacy `presets:` field and
|
|
82
|
+
// authored `{ type: "preset" }` nodes both count.
|
|
83
|
+
function collectPresetBundles(part) {
|
|
84
|
+
const out = [];
|
|
85
|
+
sections(part).forEach((sec, si) => {
|
|
86
|
+
if (isPlainObject(sec?.presets) && !Array.isArray(sec?.controls)) {
|
|
87
|
+
for (const [name, bundle] of Object.entries(sec.presets)) {
|
|
88
|
+
out.push({ name, bundle, path: `parameters[${si}].presets` });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const walk = (list, base) => arr(list).forEach((entry, i) => {
|
|
92
|
+
if (!entry) return;
|
|
93
|
+
if (entry.type === "group") walk(entry.controls, `${base}[${i}].controls`);
|
|
94
|
+
else if (entry.type === "preset" && isPlainObject(entry.presets)) {
|
|
95
|
+
for (const [name, bundle] of Object.entries(entry.presets)) {
|
|
96
|
+
out.push({ name, bundle, path: `${base}[${i}].presets` });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
walk(sec?.controls, `parameters[${si}].controls`);
|
|
101
|
+
});
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
44
105
|
const defaultKeys = (part) => new Set(Object.keys(part?.defaults ?? {}));
|
|
45
106
|
|
|
107
|
+
// Walk one WhenCondition, calling onKey(key) for every param key it reads and
|
|
108
|
+
// onOp(op) for every operator name it uses. allOf/anyOf/not recurse; a bare
|
|
109
|
+
// `{ key: value }` entry counts as reading `key` with no operator to check.
|
|
110
|
+
function walkWhen(cond, onKey, onOp) {
|
|
111
|
+
if (cond === null || typeof cond !== "object" || Array.isArray(cond)) return;
|
|
112
|
+
for (const [key, want] of Object.entries(cond)) {
|
|
113
|
+
if (key === "allOf" || key === "anyOf") {
|
|
114
|
+
for (const c of Array.isArray(want) ? want : []) walkWhen(c, onKey, onOp);
|
|
115
|
+
} else if (key === "not") {
|
|
116
|
+
walkWhen(want, onKey, onOp);
|
|
117
|
+
} else {
|
|
118
|
+
onKey(key);
|
|
119
|
+
if (want !== null && typeof want === "object" && !Array.isArray(want)) {
|
|
120
|
+
for (const op of Object.keys(want)) onOp(op);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
46
126
|
// These used to be hand-copied from controls.js, because importing it would have
|
|
47
127
|
// dragged `marked`/`dompurify` into partforge/lint and broken its zero-dependency
|
|
48
128
|
// guarantee. panel/legacy.js imports nothing, so lint can share the real
|
|
@@ -103,6 +183,7 @@ export const SCHEMA_RULES = [
|
|
|
103
183
|
if (!isPlainObject(part?.defaults)) return [];
|
|
104
184
|
const known = defaultKeys(part);
|
|
105
185
|
return collectDescriptors(part)
|
|
186
|
+
.filter(({ container }) => !container)
|
|
106
187
|
.filter(({ d }) => typeof d.key === "string" && !known.has(d.key))
|
|
107
188
|
.map(({ d, path }) => err("control-key-not-in-defaults",
|
|
108
189
|
`control key "${d.key}" is not in \`defaults\``,
|
|
@@ -118,21 +199,17 @@ export const SCHEMA_RULES = [
|
|
|
118
199
|
if (!isPlainObject(part?.defaults)) return [];
|
|
119
200
|
const known = defaultKeys(part);
|
|
120
201
|
const out = [];
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
`preset "${name}" sets "${key}", which is not in \`defaults\``,
|
|
131
|
-
`Add "${key}" to \`defaults\`${hint ? `, or correct it to "${hint}"` : ""} — a preset field absent from defaults is dropped, so selecting the preset silently does nothing for it.`,
|
|
132
|
-
`parameters[${si}].presets[${JSON.stringify(name)}].${key}`));
|
|
133
|
-
}
|
|
202
|
+
for (const { name, bundle, path } of collectPresetBundles(part)) {
|
|
203
|
+
if (!bundle || typeof bundle !== "object") continue;
|
|
204
|
+
for (const key of Object.keys(bundle)) {
|
|
205
|
+
if (known.has(key)) continue;
|
|
206
|
+
const hint = suggest(key, [...known]);
|
|
207
|
+
out.push(err("preset-key-not-in-defaults",
|
|
208
|
+
`preset "${name}" sets "${key}", which is not in \`defaults\``,
|
|
209
|
+
`Add "${key}" to \`defaults\`${hint ? `, or correct it to "${hint}"` : ""} — a preset field absent from defaults is dropped, so selecting the preset silently does nothing for it.`,
|
|
210
|
+
`${path}[${JSON.stringify(name)}].${key}`));
|
|
134
211
|
}
|
|
135
|
-
}
|
|
212
|
+
}
|
|
136
213
|
return out;
|
|
137
214
|
},
|
|
138
215
|
},
|
|
@@ -141,6 +218,7 @@ export const SCHEMA_RULES = [
|
|
|
141
218
|
run: ({ part }) => {
|
|
142
219
|
const defaults = part?.defaults ?? {};
|
|
143
220
|
return collectDescriptors(part)
|
|
221
|
+
.filter(({ container }) => !container)
|
|
144
222
|
// A slider that shares its key with the feature that owns it (demo.js's
|
|
145
223
|
// flange_d) is exempt ONLY when the default is actually the feature's
|
|
146
224
|
// off-sentinel: controls.js sets `params[feat.key] = 0` on uncheck (and
|
|
@@ -177,12 +255,25 @@ export const SCHEMA_RULES = [
|
|
|
177
255
|
return out;
|
|
178
256
|
},
|
|
179
257
|
},
|
|
258
|
+
{
|
|
259
|
+
id: "unknown-control-type",
|
|
260
|
+
run: ({ part }) => collectDescriptors(part)
|
|
261
|
+
.filter(({ container, authored, d }) => authored && !container && typeof d.type === "string" && !WIDGET_TYPES.includes(d.type))
|
|
262
|
+
.map(({ d, path }) => {
|
|
263
|
+
const hint = suggest(d.type, WIDGET_TYPES);
|
|
264
|
+
return err("unknown-control-type",
|
|
265
|
+
`unrecognised control type "${d.type}"`,
|
|
266
|
+
`${hint ? `Did you mean "${hint}"? ` : ""}Recognised types: ${WIDGET_TYPES.join(", ")}.`,
|
|
267
|
+
`${path}.type`);
|
|
268
|
+
}),
|
|
269
|
+
},
|
|
180
270
|
{
|
|
181
271
|
id: "duplicate-control-key",
|
|
182
272
|
run: ({ part }) => {
|
|
183
273
|
const seen = new Map();
|
|
184
274
|
const out = [];
|
|
185
|
-
for (const { d, path } of collectDescriptors(part)) {
|
|
275
|
+
for (const { d, path, container } of collectDescriptors(part)) {
|
|
276
|
+
if (container) continue;
|
|
186
277
|
if (typeof d.key !== "string") continue;
|
|
187
278
|
// A feature and its own slider legitimately share a key (see demo.js's
|
|
188
279
|
// flange_d), so only flag a repeat that crosses to a different owner path.
|
|
@@ -204,10 +295,8 @@ export const SCHEMA_RULES = [
|
|
|
204
295
|
run: ({ part }) => {
|
|
205
296
|
if (sections(part).length === 0) return []; // no panel declared at all — nothing to expose
|
|
206
297
|
const exposed = new Set(collectDescriptors(part).map(({ d }) => d.key).filter(Boolean));
|
|
207
|
-
for (const
|
|
208
|
-
for (const
|
|
209
|
-
for (const key of Object.keys(bundle ?? {})) exposed.add(key);
|
|
210
|
-
}
|
|
298
|
+
for (const { bundle } of collectPresetBundles(part)) {
|
|
299
|
+
for (const key of Object.keys(bundle ?? {})) exposed.add(key);
|
|
211
300
|
}
|
|
212
301
|
return Object.keys(part?.defaults ?? {})
|
|
213
302
|
.filter((key) => !exposed.has(key))
|
|
@@ -217,4 +306,253 @@ export const SCHEMA_RULES = [
|
|
|
217
306
|
`defaults.${key}`));
|
|
218
307
|
},
|
|
219
308
|
},
|
|
309
|
+
{
|
|
310
|
+
id: "mixed-section-shape",
|
|
311
|
+
run: ({ part }) => {
|
|
312
|
+
const out = [];
|
|
313
|
+
sections(part).forEach((sec, si) => {
|
|
314
|
+
if (!Array.isArray(sec?.controls)) return;
|
|
315
|
+
const legacy = ["advanced", "toggles", "features", "presets"].filter((k) => sec[k] != null);
|
|
316
|
+
if (legacy.length) {
|
|
317
|
+
out.push(err("mixed-section-shape",
|
|
318
|
+
`section "${sec.id ?? si}" mixes \`controls\` with legacy ${legacy.map((k) => `\`${k}\``).join(", ")}`,
|
|
319
|
+
"A section is either the new shape (everything in `controls`) or the legacy shape — mixing them would make the render order arbitrary. Move the legacy entries into `controls` (a toggle becomes a checkbox control, `advanced` becomes a nested group, `presets` becomes a `{ type: \"preset\" }` node), or drop `controls`.",
|
|
320
|
+
`parameters[${si}]`));
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
return out;
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
id: "duplicate-preset-name",
|
|
328
|
+
run: ({ part }) => {
|
|
329
|
+
const seen = new Map(); // name -> first path
|
|
330
|
+
const out = [];
|
|
331
|
+
for (const { name, path } of collectPresetBundles(part)) {
|
|
332
|
+
if (seen.has(name)) {
|
|
333
|
+
out.push(err("duplicate-preset-name",
|
|
334
|
+
`preset "${name}" is declared more than once (first at ${seen.get(name)})`,
|
|
335
|
+
"Preset names are global to the part: verify() expands one case per name and throws on a repeat, which is a worse place to find out. Rename one of them.",
|
|
336
|
+
path));
|
|
337
|
+
} else seen.set(name, path);
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
id: "select-options-missing",
|
|
344
|
+
run: ({ part }) => collectDescriptors(part)
|
|
345
|
+
.filter(({ d, container }) => !container && (d.type === "select" || d.type === "radio"))
|
|
346
|
+
.filter(({ d }) => normalizeOptions(d.options).length === 0)
|
|
347
|
+
.map(({ d, path }) => err("select-options-missing",
|
|
348
|
+
`${d.type} "${d.key}" has no options`,
|
|
349
|
+
"A `select` or `radio` needs an `options` array — either strings/numbers (value doubles as label) or `{ value, label }` objects. With none, the control renders empty and the parameter can never change.",
|
|
350
|
+
`${path}.options`)),
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
id: "select-default-not-in-options",
|
|
354
|
+
run: ({ part }) => {
|
|
355
|
+
if (!isPlainObject(part?.defaults)) return [];
|
|
356
|
+
return collectDescriptors(part)
|
|
357
|
+
.filter(({ d, container }) => !container && (d.type === "select" || d.type === "radio"))
|
|
358
|
+
.filter(({ d }) => {
|
|
359
|
+
const opts = normalizeOptions(d.options);
|
|
360
|
+
return opts.length > 0 && typeof d.key === "string" && d.key in part.defaults
|
|
361
|
+
&& !opts.some((o) => o.value === part.defaults[d.key]);
|
|
362
|
+
})
|
|
363
|
+
.map(({ d, path }) => err("select-default-not-in-options",
|
|
364
|
+
`\`defaults.${d.key}\` is ${JSON.stringify(part.defaults[d.key])}, which is not one of the ${d.type}'s options`,
|
|
365
|
+
"The default value must be selectable, or the panel opens showing a value the user can never get back to. Add it to `options` or change the default.",
|
|
366
|
+
`${path}.options`));
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
id: "duplicate-node-id",
|
|
371
|
+
run: ({ part }) => {
|
|
372
|
+
// Ids key the renderer's element/state/disclosure maps — a collision
|
|
373
|
+
// silently cross-wires two nodes (one picker syncing another section's
|
|
374
|
+
// widgets). Catch it statically: build the tree and look for repeats.
|
|
375
|
+
const canonical = desugar(part?.parameters ?? []);
|
|
376
|
+
// A top-level section's built id is (authored ?? String(canonical index)),
|
|
377
|
+
// so this maps each built section back to its TRUE parameters[] index even
|
|
378
|
+
// after buildTree drops hidden/empty siblings.
|
|
379
|
+
const sourceIndex = new Map();
|
|
380
|
+
canonical.forEach((sec, i) => {
|
|
381
|
+
const key = sec.id ?? String(i);
|
|
382
|
+
if (!sourceIndex.has(key)) sourceIndex.set(key, i);
|
|
383
|
+
});
|
|
384
|
+
const seen = new Map(); // id -> [sectionIndex, ...]
|
|
385
|
+
const tree = buildTree(canonical);
|
|
386
|
+
tree.forEach((section) => {
|
|
387
|
+
const si = sourceIndex.get(section.id) ?? 0;
|
|
388
|
+
const walk = (nodes) => {
|
|
389
|
+
for (const n of nodes ?? []) {
|
|
390
|
+
if (!seen.has(n.id)) seen.set(n.id, []);
|
|
391
|
+
seen.get(n.id).push(si);
|
|
392
|
+
if (n.kind === "group") walk(n.children);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
seen.set(section.id, [...(seen.get(section.id) ?? []), si]);
|
|
396
|
+
walk(section.children);
|
|
397
|
+
});
|
|
398
|
+
return [...seen].filter(([, secs]) => secs.length > 1).map(([id, secs]) =>
|
|
399
|
+
err("duplicate-node-id",
|
|
400
|
+
`two panel nodes share the id "${id}"`,
|
|
401
|
+
"Node ids must be unique across the whole panel — the renderer keys its element and state maps on them, and a collision silently cross-wires the two nodes. Rename one `id` (or drop it to use the positional default).",
|
|
402
|
+
`parameters[${secs[0]}]`));
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
id: "readout-unknown-derived-key",
|
|
407
|
+
run: ({ part }) => {
|
|
408
|
+
let derivedKeys = null;
|
|
409
|
+
try { derivedKeys = new Set(Object.keys(resolveDerived(part, { ...part?.defaults }))); }
|
|
410
|
+
catch { return []; } // a throwing derive() is diagnosed elsewhere
|
|
411
|
+
return collectDescriptors(part)
|
|
412
|
+
.filter(({ d, container }) => !container && d.type === "readout")
|
|
413
|
+
.filter(({ d }) => typeof d.derivedKey !== "string" || !derivedKeys.has(d.derivedKey))
|
|
414
|
+
.map(({ d, path }) => warn("readout-unknown-derived-key",
|
|
415
|
+
`readout names derived key "${d.derivedKey}", which derive() does not produce`,
|
|
416
|
+
"A readout displays one output of `derive()`. Name a key a derive group returns, or add that key to `derive` — as it stands the readout shows an em-dash forever.",
|
|
417
|
+
`${path}.derivedKey`));
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
id: "log-scale-needs-positive-min",
|
|
422
|
+
run: ({ part }) => collectDescriptors(part)
|
|
423
|
+
.filter(({ d, container }) => !container && d.scale === "log" && !(typeof d.min === "number" && d.min > 0))
|
|
424
|
+
.map(({ d, path }) => err("log-scale-needs-positive-min",
|
|
425
|
+
`"${d.key}" uses scale:"log" with min ${d.min}`,
|
|
426
|
+
"A logarithmic track needs min > 0 — log(0) is -Infinity and the mapping breaks. Raise `min` (e.g. 0.1) or drop `scale`.",
|
|
427
|
+
`${path}.scale`)),
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
id: "slider-refinement-invalid",
|
|
431
|
+
run: ({ part }) => {
|
|
432
|
+
const out = [];
|
|
433
|
+
for (const { d, path, container } of collectDescriptors(part)) {
|
|
434
|
+
if (container) continue;
|
|
435
|
+
const numeric = typeof d.min === "number" && typeof d.max === "number";
|
|
436
|
+
if (Array.isArray(d.ticks) && numeric && d.ticks.some((t) => t < d.min || t > d.max)) {
|
|
437
|
+
out.push(warn("slider-refinement-invalid",
|
|
438
|
+
`"${d.key}" has ticks outside its ${d.min}..${d.max} range`,
|
|
439
|
+
"Every tick must sit inside [min, max] — an out-of-range tick renders nowhere and, with snap, drags the value out of range.",
|
|
440
|
+
`${path}.ticks`));
|
|
441
|
+
}
|
|
442
|
+
if (Array.isArray(d.recommended)
|
|
443
|
+
&& (d.recommended.length !== 2 || !(d.recommended[0] < d.recommended[1]))) {
|
|
444
|
+
out.push(warn("slider-refinement-invalid",
|
|
445
|
+
`"${d.key}" has a malformed recommended band`,
|
|
446
|
+
"`recommended` is [lo, hi] with lo < hi — the tinted span of the track the DFM checks consider safe.",
|
|
447
|
+
`${path}.recommended`));
|
|
448
|
+
}
|
|
449
|
+
if (d.scale === "log" && (d.ticks || d.recommended)) {
|
|
450
|
+
out.push(warn("slider-refinement-invalid",
|
|
451
|
+
`"${d.key}" combines scale:"log" with ticks/recommended`,
|
|
452
|
+
"Ticks and the recommended band render on a linear track only; on a log slider they are ignored. Drop one or the other.",
|
|
453
|
+
`${path}.scale`));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
id: "when-key-not-in-defaults",
|
|
461
|
+
run: ({ part }) => {
|
|
462
|
+
if (!isPlainObject(part?.defaults)) return [];
|
|
463
|
+
const known = defaultKeys(part);
|
|
464
|
+
const out = [];
|
|
465
|
+
for (const { d, path, fields } of collectDescriptors(part)) {
|
|
466
|
+
if (!d.when) continue;
|
|
467
|
+
// A legacy descriptor's `when` isn't a real field (fields wouldn't
|
|
468
|
+
// list it) — controls.js silently drops it as an unknown key
|
|
469
|
+
// (unknown-control-field already says so), so validating its
|
|
470
|
+
// *contents* would imply fixing the key alone makes it work.
|
|
471
|
+
if (!fields.includes("when")) continue;
|
|
472
|
+
walkWhen(d.when, (key) => {
|
|
473
|
+
if (!known.has(key)) {
|
|
474
|
+
const hint = suggest(key, [...known]);
|
|
475
|
+
out.push(err("when-key-not-in-defaults",
|
|
476
|
+
`\`when\` references "${key}", which is not in \`defaults\``,
|
|
477
|
+
`Conditions read raw parameter keys only${hint ? ` — did you mean "${hint}"?` : "."} A key defaults doesn't have always reads undefined, so the condition is always false and the node never shows.`,
|
|
478
|
+
`${path}.when`));
|
|
479
|
+
}
|
|
480
|
+
}, () => {});
|
|
481
|
+
}
|
|
482
|
+
return out;
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
id: "when-unknown-operator",
|
|
487
|
+
run: ({ part }) => {
|
|
488
|
+
const ops = Object.keys(WHEN_OPS);
|
|
489
|
+
const out = [];
|
|
490
|
+
for (const { d, path, fields } of collectDescriptors(part)) {
|
|
491
|
+
if (!d.when) continue;
|
|
492
|
+
// Same reasoning as when-key-not-in-defaults: a legacy `when` is a
|
|
493
|
+
// dropped unknown field, not a condition to validate.
|
|
494
|
+
if (!fields.includes("when")) continue;
|
|
495
|
+
walkWhen(d.when, () => {}, (op) => {
|
|
496
|
+
if (!ops.includes(op)) {
|
|
497
|
+
const hint = suggest(op, ops);
|
|
498
|
+
out.push(err("when-unknown-operator",
|
|
499
|
+
`\`when\` uses unknown operator "${op}"`,
|
|
500
|
+
`evalWhen treats an unknown operator as false, so the node silently never shows. Recognised: ${ops.join(", ")}${hint ? ` — did you mean "${hint}"?` : "."}`,
|
|
501
|
+
`${path}.when`));
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
return out;
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
id: "group-depth",
|
|
510
|
+
run: ({ part }) => {
|
|
511
|
+
// Depth counts AUTHORED nesting only, so it needs source paths — walk the
|
|
512
|
+
// raw sections, not the desugared tree (which adds the legacy Advanced
|
|
513
|
+
// group an author never wrote).
|
|
514
|
+
const out = [];
|
|
515
|
+
sections(part).forEach((sec, si) => {
|
|
516
|
+
const walk = (list, base, depth) => arr(list).forEach((entry, i) => {
|
|
517
|
+
if (!entry || entry.type !== "group") return;
|
|
518
|
+
const path = `${base}[${i}]`;
|
|
519
|
+
if (depth >= 2) {
|
|
520
|
+
out.push(warn("group-depth",
|
|
521
|
+
`group "${entry.title ?? i}" is nested ${depth + 1} levels deep`,
|
|
522
|
+
"Two levels (a section, one fold inside it) is as deep as a 300px rail stays readable. Flatten: promote the inner group to its own section, or fold its controls into the parent.",
|
|
523
|
+
path));
|
|
524
|
+
}
|
|
525
|
+
walk(entry.controls, `${path}.controls`, depth + 1);
|
|
526
|
+
});
|
|
527
|
+
walk(sec?.controls, `parameters[${si}].controls`, 1);
|
|
528
|
+
});
|
|
529
|
+
return out;
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
id: "section-too-many-controls",
|
|
534
|
+
run: ({ part }) => {
|
|
535
|
+
const out = [];
|
|
536
|
+
const countControls = (nodes) => {
|
|
537
|
+
let n = 0;
|
|
538
|
+
for (const node of nodes ?? []) {
|
|
539
|
+
if (node.hidden) continue;
|
|
540
|
+
if (node.kind === "group") n += countControls(node.children);
|
|
541
|
+
else if (node.kind === "control") n += 1;
|
|
542
|
+
}
|
|
543
|
+
return n;
|
|
544
|
+
};
|
|
545
|
+
desugar(part?.parameters ?? []).forEach((sec, si) => {
|
|
546
|
+
if (sec.hidden) return;
|
|
547
|
+
const n = countControls(sec.children);
|
|
548
|
+
if (n > SECTION_CONTROL_BUDGET) {
|
|
549
|
+
out.push(warn("section-too-many-controls",
|
|
550
|
+
`section "${sec.id ?? si}" shows ${n} controls`,
|
|
551
|
+
`More than ${SECTION_CONTROL_BUDGET} visible controls in one section reads as a wall. Split the section, or hide internals (\`hidden: true\`) — grouping organizes but does not reduce the count.`,
|
|
552
|
+
`parameters[${si}]`));
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
return out;
|
|
556
|
+
},
|
|
557
|
+
},
|
|
220
558
|
];
|
package/src/framework/mount.js
CHANGED
|
@@ -139,10 +139,16 @@ function createCleanupStack() {
|
|
|
139
139
|
// onViewChange fires once synchronously during mount with the initial resolved
|
|
140
140
|
// view (before ready), then again on every subsequent view change (user click
|
|
141
141
|
// or a programmatic setView) — always the new view name.
|
|
142
|
+
// onParamsCommit({ changed, params }) // the user FINISHED editing a panel control (slider
|
|
143
|
+
// // released, box committed, checkbox ticked, preset
|
|
144
|
+
// // applied): `changed` lists the keys written, `params`
|
|
145
|
+
// // is a snapshot copy. Never fired by setParams or
|
|
146
|
+
// // animation playback — hosts call setParams from their
|
|
147
|
+
// // own undo/reset, and firing here would loop.
|
|
142
148
|
// Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
|
|
143
149
|
// exactly once here — submodules take element refs and never query the document.
|
|
144
150
|
// `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
|
|
145
|
-
export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange,
|
|
151
|
+
export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit,
|
|
146
152
|
container: legacyContainer, controls: legacyControls } = {}) {
|
|
147
153
|
// --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
|
|
148
154
|
const byId = (id) => document.getElementById(id);
|
|
@@ -487,9 +493,18 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
487
493
|
const panel = buildControls(els.controls, part.parameters, params, () => {
|
|
488
494
|
animCtl?.notifyUserEdit();
|
|
489
495
|
onParamChange();
|
|
490
|
-
}
|
|
496
|
+
}, onParamsCommit
|
|
497
|
+
? (changed) => onParamsCommit({ changed, params: { ...params } })
|
|
498
|
+
: undefined);
|
|
491
499
|
cleanup.defer(() => panel.dispose());
|
|
492
|
-
const updateRelevance = () =>
|
|
500
|
+
const updateRelevance = () => {
|
|
501
|
+
// A throwing derive() must not break every slider drag — mount's pick
|
|
502
|
+
// flow already guards its own resolveDerived call the same way
|
|
503
|
+
// (mount.js ~:250). Readouts simply stay em-dashed.
|
|
504
|
+
let derived = {};
|
|
505
|
+
try { derived = resolveDerived(part, params); } catch { /* diagnosed by lint/build */ }
|
|
506
|
+
panel.refresh({ relevant: relevantParamKeys(part, view(), params), derived });
|
|
507
|
+
};
|
|
493
508
|
updateRelevance(); // initial view
|
|
494
509
|
|
|
495
510
|
// The ONLY caller of fastPath.repair(). It must never move into the regen /
|