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.
@@ -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.
@@ -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 = `${Math.max(8, r.left - 8)}px`;
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 nothing, on purpose: partforge/lint consumes desugar() and
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 -> raw sync } for preset application
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). `label` is set only for the inner groups, whose
40
- // button text carries the ▾/▴ instead of a chevron span.
41
- const disclosures = new Map(); // id -> { body, button, label }
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
- if (d.label) d.button.textContent = open ? `${d.label} ▴` : `${d.label} ▾`;
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
- for (const input of node.querySelectorAll("input, select, textarea")) {
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
- const toggle = el("button", "adv-toggle", `${node.title} ▾`);
129
- toggle.addEventListener("click", () => {
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(toggle, body);
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, label: node.title });
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 [key, sync] of rawSyncs.get(sectionCtx.id)) if (key in params) sync();
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).set(node.key, widget.sync);
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 ?? ""), el("span", "chev"));
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.addEventListener("click", () => {
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, label: null });
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, new Map());
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
- applyRelevance: (next) => { relevant = next; applyState(); },
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
- params[node.key] = +slider.value;
49
- box.value = numStr(+slider.value);
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
+ }