partforge 0.47.0 → 0.48.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.
@@ -4,11 +4,15 @@
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, 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";
7
11
 
8
- // Fields controls.js reads on a slider/number descriptor.
9
- const CONTROL_FIELDS = ["key", "label", "unit", "min", "max", "step", "control", "hidden", "description"];
12
+ export const SECTION_CONTROL_BUDGET = 12;
13
+
14
+ // Legacy container descriptors aren't widget types, so they keep explicit lists.
10
15
  const FEATURE_FIELDS = ["key", "label", "on", "sliders", "hidden", "description"];
11
- const TOGGLE_FIELDS = ["key", "label", "on", "hidden", "description"];
12
16
 
13
17
  const sections = (part) => (Array.isArray(part?.parameters) ? part.parameters : []);
14
18
  const arr = (x) => (Array.isArray(x) ? x : []);
@@ -20,8 +24,41 @@ 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
- if (d) out.push({ d, path: `parameters[${si}].advanced[${i}]`, fields: CONTROL_FIELDS });
61
+ if (d) out.push({ d, path: `parameters[${si}].advanced[${i}]`, fields: fieldsFor("slider") });
25
62
  });
26
63
  arr(sec?.features).forEach((f, i) => {
27
64
  if (!f) return;
@@ -31,34 +68,66 @@ function collectDescriptors(part) {
31
68
  // recognise the demo.js flange_d pattern below: a slider sharing its key
32
69
  // with the feature is not an independent parameter, it's the feature's own
33
70
  // magnitude, and `defaults[key] === 0` there means "off", not "out of range".
34
- if (s) out.push({ d: s, path: `parameters[${si}].features[${i}].sliders[${j}]`, fields: CONTROL_FIELDS, featureKey: f.key });
71
+ if (s) out.push({ d: s, path: `parameters[${si}].features[${i}].sliders[${j}]`, fields: fieldsFor("slider"), featureKey: f.key });
35
72
  });
36
73
  });
37
74
  arr(sec?.toggles).forEach((t, i) => {
38
- if (t) out.push({ d: t, path: `parameters[${si}].toggles[${i}]`, fields: TOGGLE_FIELDS });
75
+ if (t) out.push({ d: t, path: `parameters[${si}].toggles[${i}]`, fields: fieldsFor("checkbox") });
39
76
  });
40
77
  });
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
 
46
- // Mirrors src/framework/controls.js's own visibility predicates (visibleFeatures /
47
- // sectionRenders, controls.js:32,36-41) NOT imported, because controls.js pulls in
48
- // `marked`/`dompurify` (via markdown.js) for its description popovers, which would
49
- // break partforge/lint's zero-bare-dependency purity guarantee (test/lint-purity.test.js).
50
- // `features-requires-sliders` must not flag a `feat.sliders.filter(...)` the panel
51
- // will never reach: controls.js only iterates `visibleFeatures(sec)` (skipping any
52
- // feature marked `hidden: true`), and only builds a section at all when
53
- // `sectionRenders(sec)` is true.
54
- const visibleFeatures = (sec) => arr(sec?.features).filter((f) => f && !f.hidden);
55
- function sectionRenders(sec) {
56
- if (sec?.hidden) return false;
57
- if (sec?.features) return visibleFeatures(sec).length > 0;
58
- const hasPresets = sec?.presets && Object.keys(sec.presets).length > 0;
59
- return !!hasPresets || arr(sec?.advanced).some((d) => d && !d.hidden) || arr(sec?.toggles).some((t) => t && !t.hidden);
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
+ }
60
124
  }
61
125
 
126
+ // These used to be hand-copied from controls.js, because importing it would have
127
+ // dragged `marked`/`dompurify` into partforge/lint and broken its zero-dependency
128
+ // guarantee. panel/legacy.js imports nothing, so lint can share the real
129
+ // implementation and the two can no longer drift.
130
+
62
131
  export const SCHEMA_RULES = [
63
132
  {
64
133
  id: "features-requires-sliders",
@@ -114,6 +183,7 @@ export const SCHEMA_RULES = [
114
183
  if (!isPlainObject(part?.defaults)) return [];
115
184
  const known = defaultKeys(part);
116
185
  return collectDescriptors(part)
186
+ .filter(({ container }) => !container)
117
187
  .filter(({ d }) => typeof d.key === "string" && !known.has(d.key))
118
188
  .map(({ d, path }) => err("control-key-not-in-defaults",
119
189
  `control key "${d.key}" is not in \`defaults\``,
@@ -129,21 +199,17 @@ export const SCHEMA_RULES = [
129
199
  if (!isPlainObject(part?.defaults)) return [];
130
200
  const known = defaultKeys(part);
131
201
  const out = [];
132
- sections(part).forEach((sec, si) => {
133
- const presets = sec?.presets;
134
- if (!presets || typeof presets !== "object") return;
135
- for (const [name, bundle] of Object.entries(presets)) {
136
- if (!bundle || typeof bundle !== "object") continue;
137
- for (const key of Object.keys(bundle)) {
138
- if (known.has(key)) continue;
139
- const hint = suggest(key, [...known]);
140
- out.push(err("preset-key-not-in-defaults",
141
- `preset "${name}" sets "${key}", which is not in \`defaults\``,
142
- `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.`,
143
- `parameters[${si}].presets[${JSON.stringify(name)}].${key}`));
144
- }
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}`));
145
211
  }
146
- });
212
+ }
147
213
  return out;
148
214
  },
149
215
  },
@@ -152,6 +218,7 @@ export const SCHEMA_RULES = [
152
218
  run: ({ part }) => {
153
219
  const defaults = part?.defaults ?? {};
154
220
  return collectDescriptors(part)
221
+ .filter(({ container }) => !container)
155
222
  // A slider that shares its key with the feature that owns it (demo.js's
156
223
  // flange_d) is exempt ONLY when the default is actually the feature's
157
224
  // off-sentinel: controls.js sets `params[feat.key] = 0` on uncheck (and
@@ -188,12 +255,25 @@ export const SCHEMA_RULES = [
188
255
  return out;
189
256
  },
190
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
+ },
191
270
  {
192
271
  id: "duplicate-control-key",
193
272
  run: ({ part }) => {
194
273
  const seen = new Map();
195
274
  const out = [];
196
- for (const { d, path } of collectDescriptors(part)) {
275
+ for (const { d, path, container } of collectDescriptors(part)) {
276
+ if (container) continue;
197
277
  if (typeof d.key !== "string") continue;
198
278
  // A feature and its own slider legitimately share a key (see demo.js's
199
279
  // flange_d), so only flag a repeat that crosses to a different owner path.
@@ -215,10 +295,8 @@ export const SCHEMA_RULES = [
215
295
  run: ({ part }) => {
216
296
  if (sections(part).length === 0) return []; // no panel declared at all — nothing to expose
217
297
  const exposed = new Set(collectDescriptors(part).map(({ d }) => d.key).filter(Boolean));
218
- for (const sec of sections(part)) {
219
- for (const bundle of Object.values(sec?.presets ?? {})) {
220
- for (const key of Object.keys(bundle ?? {})) exposed.add(key);
221
- }
298
+ for (const { bundle } of collectPresetBundles(part)) {
299
+ for (const key of Object.keys(bundle ?? {})) exposed.add(key);
222
300
  }
223
301
  return Object.keys(part?.defaults ?? {})
224
302
  .filter((key) => !exposed.has(key))
@@ -228,4 +306,253 @@ export const SCHEMA_RULES = [
228
306
  `defaults.${key}`));
229
307
  },
230
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
+ },
231
558
  ];
@@ -489,7 +489,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
489
489
  onParamChange();
490
490
  });
491
491
  cleanup.defer(() => panel.dispose());
492
- const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view(), params));
492
+ const updateRelevance = () => {
493
+ // A throwing derive() must not break every slider drag — mount's pick
494
+ // flow already guards its own resolveDerived call the same way
495
+ // (mount.js ~:250). Readouts simply stay em-dashed.
496
+ let derived = {};
497
+ try { derived = resolveDerived(part, params); } catch { /* diagnosed by lint/build */ }
498
+ panel.refresh({ relevant: relevantParamKeys(part, view(), params), derived });
499
+ };
493
500
  updateRelevance(); // initial view
494
501
 
495
502
  // The ONLY caller of fastPath.repair(). It must never move into the regen /
@@ -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
- for (const section of part.parameters ?? []) {
7
- if (!section.presets) continue;
8
- for (const [name, overrides] of Object.entries(section.presets)) {
9
- if (name in map) throw new Error(`duplicate preset name across sections: "${name}"`);
10
- map[name] = overrides;
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.