partforge 0.113.0 → 0.114.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.
@@ -7,6 +7,10 @@ import { computeState } from "./panel-state.js";
7
7
  import { WIDGET_FACTORIES } from "./widgets/index.js";
8
8
  import { makeReadout } from "./widgets/readout.js";
9
9
  import { createInfoPopover, attachInfo } from "./info.js";
10
+ import { isJsonValue } from "./json-value.js";
11
+
12
+ // The budget every custom control's transient state shares (getState below).
13
+ export const PANEL_STATE_MAX_BYTES = 65536;
10
14
 
11
15
  function el(tag, className, text) {
12
16
  const node = document.createElement(tag);
@@ -48,6 +52,22 @@ export function buildControls(root, parameters, params, onDirty, onCommit, opts
48
52
  indexNodes(tree, nodeById);
49
53
  let relevant = null;
50
54
 
55
+ const panelErrors = []; // {key, label, phase, message} from custom controls
56
+ const customWidgets = new Map(); // param key -> { label, getState } for custom controls
57
+ const reportedStateDrops = new Set(); // keys already recorded as a dropped getState() — getState may be called many times
58
+ // What a custom control's host reaches back into the panel for: the part's
59
+ // own files (host.file), the state a previous mount left (host.state), the
60
+ // error sink, and the sub-panel builder — a re-entry into buildControls with
61
+ // a scoped params view and no section header (opts.bare, Task 5).
62
+ const customCtx = {
63
+ files: opts.files ?? null,
64
+ panelState: opts.panelState ?? null,
65
+ onError: (e) => { panelErrors.push(e); opts.onPanelError?.(e); },
66
+ buildSubPanel: (container, controls, scoped, onSubDirty, onSubCommit) =>
67
+ buildControls(container, [{ controls }], scoped, onSubDirty, onSubCommit,
68
+ { ...opts, bare: true, panelState: null, onPanelError: customCtx.onError }),
69
+ };
70
+
51
71
  // Re-apply state after any change that could flip a condition. This is what
52
72
  // reproduces the legacy feature behavior generically: ticking a feature's
53
73
  // checkbox now simply makes its group's condition true. It also re-syncs any
@@ -224,67 +244,87 @@ export function buildControls(root, parameters, params, onDirty, onCommit, opts
224
244
  };
225
245
  const widget = factory(node, params, {
226
246
  onChange: () => { markCustom(); onEdit(); },
227
- onCommit: () => commit([node.key]),
247
+ // A factory may name the keys it committed (custom controls own several);
248
+ // the built-ins call onCommit() bare and get their own key, as before.
249
+ onCommit: (keys) => commit(Array.isArray(keys) && keys.length ? keys : [node.key]),
228
250
  info,
229
251
  fontCatalog: opts.fontCatalog,
230
252
  imageCatalog: opts.imageCatalog,
231
253
  onAssetUpload: opts.onAssetUpload,
232
254
  declaredSource: opts.declaredSource,
255
+ custom: customCtx,
233
256
  });
234
257
  nodeEls.set(node.id, widget.el);
235
258
  if (node.key && !keyToId.has(node.key)) keyToId.set(node.key, node.id);
236
259
  widgetSyncs.set(node.id, widget.sync);
237
260
  if (widget.dispose) disposers.push(widget.dispose);
261
+ // A custom control reads derive() output like a readout does, and keeps
262
+ // transient state the host may carry across a remount.
263
+ if (typeof widget.onDerived === "function") displayUpdates.set(node.id, widget.onDerived);
264
+ if (typeof widget.getState === "function" && node.key) {
265
+ customWidgets.set(node.key, { label: node.label ?? node.key, getState: widget.getState });
266
+ }
238
267
  container.append(widget.el);
239
268
 
240
269
  // The raw sync is what a PRESET application uses — it must not mark itself
241
270
  // Custom (controls.test.js:366). The registered sync is what an external
242
271
  // syncValues() uses, and for a preset-section control it does drop the
243
272
  // picker to Custom (controls.test.js:350), because a programmatic edit
244
- // diverges from the preset exactly as a user edit does.
273
+ // diverges from the preset exactly as a user edit does. A widget that owns
274
+ // several keys (custom's `keys`) registers under each of them.
245
275
  if (sectionCtx) rawSyncs.get(sectionCtx.id).push({ key: node.key, sync: widget.sync });
246
- syncFns.push({
247
- key: node.key,
248
- sync: () => { widget.sync(); markCustom(); },
249
- });
276
+ const ownedKeys = Array.isArray(widget.keys) && widget.keys.length ? widget.keys : [node.key];
277
+ for (const key of ownedKeys) {
278
+ syncFns.push({ key, sync: () => { widget.sync(); markCustom(); } });
279
+ }
250
280
  }
251
281
 
252
282
  for (const section of tree) {
253
283
  groupIds.add(section.id);
254
284
  const secEl = el("div", "section");
255
285
  nodeEls.set(section.id, secEl);
256
-
257
- const header = el("div", "sec-header");
258
- const title = el("button", "sec-title");
259
- title.type = "button";
260
- // The chev span carries NO text — its glyph comes from CSS (::before) —
261
- // because sectionByTitle-style lookups match `.sec-title` by exact
262
- // textContent === title (controls.test.js:210), and a text chevron here
263
- // would break that match.
264
- title.append(el("span", "sec-name", section.title ?? ""));
265
- header.append(title);
266
- // Row order: title (flex:1), then ⓘ, then the chevron on the far right.
267
- // The ⓘ is a SIBLING of the button, never a child: attachInfo appends a
268
- // <button>, and a button nested in a button is invalid HTML that never
269
- // receives clicks.
270
- attachInfo(header, section.description, info);
271
- header.append(el("span", "chev"));
272
- secEl.append(header);
273
-
274
286
  const body = el("div", "sec-body");
275
- body.id = `pf-sec-${section.id.replaceAll("/", "-")}`;
276
- title.setAttribute("aria-controls", body.id);
277
- secEl.append(body);
278
287
 
279
- // The whole header row toggles: the title button's own click bubbles up
280
- // here, the chevron and the empty row space hit it directly, and the ⓘ
281
- // stops propagation in attachInfo. aria state stays on the title button.
282
- header.addEventListener("click", () => {
283
- const nowHidden = body.classList.toggle("hidden");
284
- title.setAttribute("aria-expanded", String(!nowHidden));
285
- secEl.classList.toggle("collapsed", nowHidden);
286
- });
287
- disclosures.set(section.id, { body, button: title, el: secEl });
288
+ if (opts.bare) {
289
+ // A sub-panel inside a custom control (host.controls): controls only,
290
+ // no header row and no disclosure the widget owns the framing, so
291
+ // there is no aria-controls to point at the body and it gets no id
292
+ // (a mounted sub-panel would otherwise collide with the outer panel's
293
+ // own pf-sec-<id>, or with another sub-panel's, since ids are not
294
+ // scoped to a mount).
295
+ secEl.classList.add("bare");
296
+ secEl.append(body);
297
+ } else {
298
+ body.id = `pf-sec-${section.id.replaceAll("/", "-")}`;
299
+ const header = el("div", "sec-header");
300
+ const title = el("button", "sec-title");
301
+ title.type = "button";
302
+ // The chev span carries NO text — its glyph comes from CSS (::before) —
303
+ // because sectionByTitle-style lookups match `.sec-title` by exact
304
+ // textContent === title (controls.test.js:210), and a text chevron here
305
+ // would break that match.
306
+ title.append(el("span", "sec-name", section.title ?? ""));
307
+ header.append(title);
308
+ // Row order: title (flex:1), then ⓘ, then the chevron on the far right.
309
+ // The ⓘ is a SIBLING of the button, never a child: attachInfo appends a
310
+ // <button>, and a button nested in a button is invalid HTML that never
311
+ // receives clicks.
312
+ attachInfo(header, section.description, info);
313
+ header.append(el("span", "chev"));
314
+ secEl.append(header);
315
+ title.setAttribute("aria-controls", body.id);
316
+ secEl.append(body);
317
+
318
+ // The whole header row toggles: the title button's own click bubbles up
319
+ // here, the chevron and the empty row space hit it directly, and the ⓘ
320
+ // stops propagation in attachInfo. aria state stays on the title button.
321
+ header.addEventListener("click", () => {
322
+ const nowHidden = body.classList.toggle("hidden");
323
+ title.setAttribute("aria-expanded", String(!nowHidden));
324
+ secEl.classList.toggle("collapsed", nowHidden);
325
+ });
326
+ disclosures.set(section.id, { body, button: title, el: secEl });
327
+ }
288
328
 
289
329
  // `preset` is filled in when a preset node renders. Controls read it late, so
290
330
  // one appearing after them in the children array still works.
@@ -357,6 +397,49 @@ export function buildControls(root, parameters, params, onDirty, onCommit, opts
357
397
  }
358
398
  return true;
359
399
  },
400
+ // What custom controls reported failing this mount, in order. A host that
401
+ // remounts per edit (partforge-cloud) reads this after mount and hands it
402
+ // to the agent beside the build's own warnings.
403
+ errors: () => panelErrors.map((e) => ({ ...e })),
404
+ // Every custom control's non-empty transient state, keyed by param, as
405
+ // plain JSON — hand it back as mount({ panelState }) so a selection survives
406
+ // the remount an edit performs. One shared budget: a state that would push
407
+ // the total past it is dropped and recorded (phase "state") rather than
408
+ // silently lost.
409
+ getState: () => {
410
+ const out = {};
411
+ let budget = PANEL_STATE_MAX_BYTES;
412
+ for (const [key, w] of customWidgets) {
413
+ try {
414
+ const state = w.getState();
415
+ if (!state || typeof state !== "object" || Object.keys(state).length === 0) continue;
416
+ if (!isJsonValue(state, { maxBytes: Infinity })) {
417
+ if (!reportedStateDrops.has(key)) {
418
+ reportedStateDrops.add(key);
419
+ customCtx.onError({ key, label: w.label, phase: "state", message: "panel state is not a JSON value; dropped" });
420
+ }
421
+ continue;
422
+ }
423
+ const size = new TextEncoder().encode(JSON.stringify(state)).length;
424
+ if (size > budget) {
425
+ if (!reportedStateDrops.has(key)) {
426
+ reportedStateDrops.add(key);
427
+ customCtx.onError({ key, label: w.label, phase: "state", message: `panel state (${size} bytes) exceeds the ${PANEL_STATE_MAX_BYTES}-byte budget; dropped` });
428
+ }
429
+ continue;
430
+ }
431
+ budget -= size;
432
+ out[key] = structuredClone(state);
433
+ } catch (e) {
434
+ if (!reportedStateDrops.has(key)) {
435
+ reportedStateDrops.add(key);
436
+ const message = (e && typeof e.message === "string" && e.message) || String(e);
437
+ customCtx.onError({ key, label: w.label, phase: "state", message: `panel state could not be read: ${message}` });
438
+ }
439
+ }
440
+ }
441
+ return out;
442
+ },
360
443
  // replaceChildren() only reaches what is INSIDE root; a widget that parked
361
444
  // DOM (or a document-level listener) elsewhere has to be told to let go.
362
445
  dispose: () => { info.dispose(); for (const d of disposers) d(); root.replaceChildren(); },
@@ -0,0 +1,52 @@
1
+ // A params-shaped view INTO a custom control's owned value, so the built-in
2
+ // control factories — which read and write `params[node.key]` and nothing
3
+ // else — can edit `tiles[3].height` without knowing they are inside a JSON
4
+ // value. Every read clones the owned value (read()) and every write hands a
5
+ // whole new value back (write(next)): the stored value is never mutated in
6
+ // place, which is what keeps the worker's cache keys and the relevance
7
+ // recorder honest.
8
+ //
9
+ // `path` is dotted from the owned value's root: "" (the root itself), "3"
10
+ // (the fourth array element), "walls.north". A write whose parent does not
11
+ // exist is refused and reported through onError, never thrown — a throwing
12
+ // params write inside a slider's input handler would take the panel down.
13
+ export function scopedParams({ read, write, path = "", onError }) {
14
+ const segs = path === "" ? [] : String(path).split(".");
15
+ const parentOf = (root) => {
16
+ let cur = root;
17
+ for (const s of segs) {
18
+ if (cur === null || typeof cur !== "object") return null;
19
+ cur = cur[s];
20
+ }
21
+ return cur !== null && typeof cur === "object" ? cur : null;
22
+ };
23
+ return new Proxy({}, {
24
+ get(_, key) {
25
+ if (typeof key !== "string") return undefined;
26
+ const p = parentOf(read());
27
+ return p ? p[key] : undefined;
28
+ },
29
+ has(_, key) {
30
+ const p = parentOf(read());
31
+ return typeof key === "string" && !!p && Object.hasOwn(p, key);
32
+ },
33
+ ownKeys() {
34
+ const p = parentOf(read());
35
+ return p ? Object.keys(p) : [];
36
+ },
37
+ getOwnPropertyDescriptor(_, key) {
38
+ const p = parentOf(read());
39
+ if (!p || typeof key !== "string" || !Object.hasOwn(p, key)) return undefined;
40
+ return { value: p[key], enumerable: true, configurable: true, writable: true };
41
+ },
42
+ set(_, key, value) {
43
+ if (typeof key !== "string") return false;
44
+ const root = read();
45
+ const p = parentOf(root);
46
+ if (!p) { onError?.(`no value at path "${path}" to write "${key}" into`); return true; }
47
+ p[key] = value;
48
+ write(root);
49
+ return true;
50
+ },
51
+ });
52
+ }
@@ -39,6 +39,7 @@ export const WIDGET_SPECS = [
39
39
  { type: "font", kind: "control", fields: [...AUTHOR_COMMON, "allow", "preview", "sourceField"] },
40
40
  { type: "image", kind: "control", fields: [...AUTHOR_COMMON, "allow", "sourceField"] },
41
41
  { type: "vector", kind: "control", fields: [...AUTHOR_COMMON, "allow", "sourceField"] },
42
+ { type: "custom", kind: "control", fields: [...AUTHOR_COMMON, "widget", "keys"] },
42
43
  { type: "readout", kind: "display", fields: ["type", "label", "description", "unit", "derivedKey", "hidden", "when", "whenFalse"] },
43
44
  ];
44
45
 
@@ -60,6 +61,7 @@ const AUTHOR_EXTRAS = {
60
61
  font: ["allow", "preview", "sourceField"],
61
62
  image: ["allow", "sourceField"],
62
63
  vector: ["allow", "sourceField"],
64
+ custom: ["widget", "keys"],
63
65
  };
64
66
  const AUTHOR_FIELDS = new Map(Object.entries(AUTHOR_EXTRAS).map(
65
67
  ([type, extra]) => [type, [...AUTHOR_COMMON, ...extra]]));
@@ -0,0 +1,253 @@
1
+ // A part-authored widget in the rail (type: "custom"). The author's `widget`
2
+ // function draws whatever it wants into the slot — DOM, SVG, canvas — through
3
+ // a small host object; the framework's job is to make that function safe to
4
+ // run (a throw lands on a card, never on the panel), to route its writes
5
+ // through the ordinary dirty/commit chain, and to keep its transient state
6
+ // across the remount every edit performs (getState/panelState).
7
+ //
8
+ // The widget runs in the panel's own realm: mount() receives the part module
9
+ // directly, so `node.widget` is the author's real function, never a clone.
10
+ // It must never reach the geometry worker — it does not: only `params` cross.
11
+ import { attachInfo } from "../info.js";
12
+ import { vectorThumb } from "./vector-thumb.js";
13
+ import { isJsonValue, jsonValueProblem } from "../json-value.js";
14
+ import { scopedParams } from "../scoped-params.js";
15
+
16
+ const SVG_NS = "http://www.w3.org/2000/svg";
17
+ const SVG_TAGS = new Set([
18
+ "svg", "g", "path", "polygon", "polyline", "circle", "ellipse", "rect", "line", "text", "tspan",
19
+ "use", "defs", "clipPath", "mask", "pattern", "marker", "symbol", "image", "title", "desc",
20
+ "foreignObject", "linearGradient", "radialGradient", "stop",
21
+ ]);
22
+ // `pfc-tree://<path>?v=<stamp>` — the token a `vector` control writes into a
23
+ // param when the artwork lives as a file in the part (partforge-cloud's
24
+ // treeTokens.js). The stamp is content-addressed and irrelevant here: the
25
+ // files map is the tree as of THIS mount.
26
+ const TREE_TOKEN_RE = /^pfc-tree:\/\/([^?#]+)(?:[?#].*)?$/i;
27
+
28
+ const isScalar = (v) => typeof v === "string" || typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v));
29
+ const errorText = (e) => (e && typeof e.message === "string" && e.message) || String(e);
30
+ const cloneValue = (v) => (v !== null && typeof v === "object" ? structuredClone(v) : v);
31
+
32
+ function el(tag, className, text) {
33
+ const node = document.createElement(tag);
34
+ if (className) node.className = className;
35
+ if (text != null) node.textContent = text;
36
+ return node;
37
+ }
38
+
39
+ // Drop any `type: "custom"` entry (recursively through groups) from a
40
+ // sub-control list: a widget inside a widget is refused, one level only.
41
+ function stripNested(controls, report) {
42
+ const out = [];
43
+ for (const c of Array.isArray(controls) ? controls : []) {
44
+ if (!c) continue;
45
+ if (c.type === "custom") { report(typeof c.key === "string" ? c.key : "?"); continue; }
46
+ if (c.type === "group") { out.push({ ...c, controls: stripNested(c.controls, report) }); continue; }
47
+ out.push(c);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ export function makeCustom(node, params, { onChange, onCommit, info, custom = {} } = {}) {
53
+ const { files = null, panelState = null, onError, buildSubPanel } = custom;
54
+ const label = node.label ?? node.key;
55
+ const wrap = el("div", "pf-custom");
56
+ if (node.label) {
57
+ const row = el("div", "row");
58
+ const lbl = el("label", "", node.label);
59
+ attachInfo(lbl, node.description, info);
60
+ row.append(lbl);
61
+ wrap.append(row);
62
+ }
63
+ const slot = el("div", "pf-custom-slot");
64
+ wrap.append(slot);
65
+
66
+ const ownedKeys = [node.key, ...(Array.isArray(node.keys) ? node.keys.filter((k) => typeof k === "string") : [])];
67
+ const pending = new Set(); // keys set since the last commit
68
+ const subPanels = new Set(); // disposers of open host.controls panels
69
+ let instance = null;
70
+ let retired = false;
71
+ let lastDerived = "{}";
72
+
73
+ const report = (phase, message) => onError?.({ key: node.key, label, phase, message });
74
+
75
+ // A throw anywhere in widget code: record it, retire the widget for this
76
+ // mount, tear down its sub-panels, and put the card in the slot. Every other
77
+ // control keeps working; the geometry keeps rendering.
78
+ const fail = (phase, e) => {
79
+ if (retired) return;
80
+ retired = true;
81
+ const message = errorText(e);
82
+ report(phase, message);
83
+ for (const d of [...subPanels]) { try { d(); } catch { /* already failing */ } }
84
+ subPanels.clear();
85
+ const card = el("div", "pf-custom-error");
86
+ card.append(el("div", "pf-custom-error-title", `${label}: custom control failed`));
87
+ card.append(el("div", "pf-custom-error-message", message));
88
+ slot.replaceChildren(card);
89
+ };
90
+ const guarded = (phase, fn) => {
91
+ if (retired) return undefined;
92
+ try { return fn(); } catch (e) { fail(phase, e); return undefined; }
93
+ };
94
+
95
+ const restoredState = panelState && typeof panelState === "object" && Object.hasOwn(panelState, node.key)
96
+ && panelState[node.key] && typeof panelState[node.key] === "object" && !Array.isArray(panelState[node.key])
97
+ && isJsonValue(panelState[node.key], { maxBytes: Infinity })
98
+ ? structuredClone(panelState[node.key])
99
+ : null;
100
+
101
+ const host = {
102
+ el: slot,
103
+ doc: slot.ownerDocument,
104
+ key: node.key,
105
+ state: restoredState ?? {},
106
+ derived: {},
107
+ get disabled() { return wrap.classList.contains("disabled"); },
108
+
109
+ get(key = node.key) { return cloneValue(params[key]); },
110
+
111
+ set(value, { key = node.key, commit = true } = {}) {
112
+ if (retired) return;
113
+ if (!ownedKeys.includes(key)) {
114
+ throw new TypeError(`custom control "${node.key}" may not write "${key}" — list it in \`keys\``);
115
+ }
116
+ if (key === node.key) {
117
+ const problem = jsonValueProblem(value);
118
+ if (problem) throw new TypeError(`value for "${key}" ${problem}`);
119
+ } else if (!isScalar(value)) {
120
+ throw new TypeError(`value for "${key}" must be a finite number, string or boolean`);
121
+ }
122
+ params[key] = cloneValue(value);
123
+ pending.add(key);
124
+ onChange?.();
125
+ if (commit) host.commit([key]);
126
+ },
127
+
128
+ commit(keys = [node.key]) {
129
+ if (retired) return;
130
+ const changed = keys.filter((k) => pending.has(k));
131
+ if (!changed.length) return;
132
+ for (const k of changed) pending.delete(k);
133
+ onCommit?.(changed);
134
+ },
135
+
136
+ setState(patch) { if (retired) return; Object.assign(host.state, patch); },
137
+
138
+ // Element builder: SVG tags get the SVG namespace, `on<event>` attrs
139
+ // become listeners (wrapped: a throw retires the widget), everything else
140
+ // is set verbatim; `true` → a bare attribute, null/undefined/false → omitted.
141
+ h(tag, attrs = {}, ...children) {
142
+ const doc = slot.ownerDocument;
143
+ const elm = SVG_TAGS.has(tag) ? doc.createElementNS(SVG_NS, tag) : doc.createElement(tag);
144
+ for (const [name, v] of Object.entries(attrs ?? {})) {
145
+ if (v === null || v === undefined || v === false) continue;
146
+ if (name.startsWith("on") && typeof v === "function") {
147
+ elm.addEventListener(name.slice(2).toLowerCase(), (ev) => guarded("event", () => v(ev)));
148
+ } else {
149
+ elm.setAttribute(name, v === true ? "" : String(v));
150
+ }
151
+ }
152
+ const append = (c) => {
153
+ if (c === null || c === undefined || c === false) return;
154
+ if (Array.isArray(c)) { c.forEach(append); return; }
155
+ elm.append(typeof c === "object" ? c : doc.createTextNode(String(c)));
156
+ };
157
+ children.forEach(append);
158
+ return elm;
159
+ },
160
+
161
+ // An SVG string → its root element (HTML parsing puts `<svg>` in the SVG
162
+ // namespace), or null. No sanitizer: this is the same author's code that
163
+ // already runs here.
164
+ svg(text) {
165
+ if (typeof text !== "string") return null;
166
+ const tpl = slot.ownerDocument.createElement("template");
167
+ tpl.innerHTML = text.trim();
168
+ const root = tpl.content.firstElementChild;
169
+ return root && root.tagName.toLowerCase() === "svg" ? root : null;
170
+ },
171
+
172
+ svgFromVector: (doc) => vectorThumb(doc),
173
+
174
+ // The text of one of the part's own files, by path or pfc-tree:// token.
175
+ file(pathOrToken) {
176
+ if (!files || typeof pathOrToken !== "string") return null;
177
+ const m = TREE_TOKEN_RE.exec(pathOrToken);
178
+ const path = (m ? m[1] : pathOrToken).replace(/^\.\//, "");
179
+ return Object.hasOwn(files, path) && typeof files[path] === "string" ? files[path] : null;
180
+ },
181
+
182
+ // Built-in controls bound INSIDE the owned value (scoped-params.js). One
183
+ // level: a custom control in the list is dropped and reported.
184
+ controls(container, controls, { path = "" } = {}) {
185
+ if (!buildSubPanel || retired) return () => {};
186
+ const stripped = stripNested(controls, (key) =>
187
+ report("create", `custom control "${key}" cannot be nested inside "${node.key}"`));
188
+ const scoped = scopedParams({
189
+ read: () => host.get(),
190
+ // A sub-control write can push the owned value past the JSON cap —
191
+ // that is the value contract refusing one edit, not widget code
192
+ // failing, so it is reported and swallowed rather than left to throw
193
+ // out of the built-in factory's own (unguarded) input handler.
194
+ write: (next) => {
195
+ try { host.set(next, { commit: false }); } catch (e) { report("event", errorText(e)); }
196
+ },
197
+ path,
198
+ onError: (message) => report("event", message),
199
+ });
200
+ const sub = buildSubPanel(container, stripped, scoped, () => {}, () => host.commit());
201
+ const dispose = () => { subPanels.delete(dispose); sub.dispose(); };
202
+ subPanels.add(dispose);
203
+ return dispose;
204
+ },
205
+ };
206
+
207
+ guarded("create", () => {
208
+ if (typeof node.widget !== "function") throw new TypeError("`widget` is not a function");
209
+ const r = node.widget(host);
210
+ instance = r && typeof r === "object" ? r : null;
211
+ });
212
+
213
+ const update = (reason) => guarded("update", () => instance?.update?.({ reason, disabled: host.disabled }));
214
+
215
+ // Restoring panel state is not part of creation: a throw here is a bad
216
+ // `update`, not a bad `create` — the widget did construct successfully.
217
+ if (restoredState && !retired) update("restore");
218
+
219
+ return {
220
+ el: wrap,
221
+ keys: ownedKeys,
222
+ sync: () => update("sync"),
223
+ onDerived: (derived) => {
224
+ const next = derived ?? {};
225
+ host.derived = next;
226
+ let s;
227
+ try {
228
+ s = JSON.stringify(next);
229
+ } catch {
230
+ // Not everything is stringifiable (a cycle, a BigInt). Treat that as
231
+ // "changed" rather than let it escape into panel.refresh().
232
+ update("derived");
233
+ return;
234
+ }
235
+ if (s === lastDerived) return;
236
+ lastDerived = s;
237
+ update("derived");
238
+ },
239
+ getState: () => host.state,
240
+ dispose: () => {
241
+ for (const d of [...subPanels]) { try { d(); } catch { /* disposing anyway */ } }
242
+ subPanels.clear();
243
+ // Attempted even when retired: a widget that already failed once (a
244
+ // throwing listener, say) still owns resources — timers, observers,
245
+ // an open sub-panel's DOM — that its own dispose() is the only code
246
+ // that knows how to release.
247
+ try { instance?.dispose?.(); } catch (e) { report("dispose", errorText(e)); }
248
+ // A disposed panel's host must stop accepting writes: host.set/commit
249
+ // are silent no-ops once retired, same as after a widget's own throw.
250
+ retired = true;
251
+ },
252
+ };
253
+ }
@@ -7,6 +7,7 @@ import { makeSelect, makeRadio } from "./select.js";
7
7
  import { makeFont } from "./font.js";
8
8
  import { makeImage } from "./image.js";
9
9
  import { makeVector } from "./vector.js";
10
+ import { makeCustom } from "./custom.js";
10
11
  // Side-effect imports: font-picker.js / image-picker.js call setFontPicker() /
11
12
  // setImagePicker() at module scope, so each widget's button finds a picker to
12
13
  // open. They live HERE and not in font.js/image.js because the dependency has
@@ -27,4 +28,5 @@ export const WIDGET_FACTORIES = {
27
28
  font: makeFont,
28
29
  image: makeImage,
29
30
  vector: makeVector,
31
+ custom: makeCustom,
30
32
  };
@@ -0,0 +1,6 @@
1
+ // Public entry for `partforge/panel-values`: the value contract of a
2
+ // `type: "custom"` control, for hosts that persist panel settings (partforge-
3
+ // cloud rewrites `defaults` with these). Dependency-free, DOM-free, tiny — a
4
+ // host imports this without pulling partforge/lint's rule set into its bundle.
5
+ export { isJsonValue, jsonValueProblem, CUSTOM_VALUE_MAX_BYTES, CUSTOM_VALUE_MAX_DEPTH } from "./framework/panel/json-value.js";
6
+ export { readJsonLiteral, writeJsonLiteral } from "./framework/lint/source-scan.js";
package/types/index.d.ts CHANGED
@@ -199,6 +199,17 @@ export interface MountOptions {
199
199
  * part cannot support is dropped, never fatal.
200
200
  */
201
201
  viewerState?: ViewerState | null;
202
+ /**
203
+ * A previous mount's `runtime.getPanelState()`: the transient state of the
204
+ * part's custom controls (a selected tile), keyed by param. Same remount
205
+ * story as `viewerState`; omit on a first mount. Never persisted by partforge.
206
+ */
207
+ panelState?: PanelState | null;
208
+ /**
209
+ * The part's own source tree as text, for custom controls' `host.file(path)`.
210
+ * Omit and `host.file` answers null.
211
+ */
212
+ files?: Record<string, string>;
202
213
  /**
203
214
  * A provider backing every `type: "font"` control in the part. partforge
204
215
  * ships none — without one, a font control renders as a plain URL field.
@@ -256,6 +267,57 @@ export interface ViewerState {
256
267
  cutaway: CutawayState | null;
257
268
  }
258
269
 
270
+ /** Custom controls' transient state, keyed by the param each control owns. Plain JSON. */
271
+ export type PanelState = Record<string, Record<string, unknown>>;
272
+
273
+ export interface PanelError {
274
+ /** The param the failing control owns. */
275
+ key: string;
276
+ label: string;
277
+ phase: "create" | "update" | "event" | "dispose" | "state";
278
+ message: string;
279
+ }
280
+
281
+ /** What a `type: "custom"` control's `widget(host)` receives. */
282
+ export interface CustomControlHost {
283
+ /** The slot to draw into, inside the rail. */
284
+ el: HTMLElement;
285
+ doc: Document;
286
+ /** The param this control owns. */
287
+ key: string;
288
+ /** A structured clone of the current value of `key` (default: the owned key). */
289
+ get(key?: string): unknown;
290
+ /**
291
+ * Replace a value. Refused (throws TypeError) for a key the control does not
292
+ * own or a value outside the contract (owned key: a JSON value; `keys`: a
293
+ * scalar). Stores a clone, schedules a rebuild, and commits unless
294
+ * `commit: false` — then call `commit()` when the gesture ends.
295
+ */
296
+ set(value: unknown, opts?: { key?: string; commit?: boolean }): void;
297
+ commit(keys?: string[]): void;
298
+ /** The latest `derive()` output. */
299
+ derived: Record<string, unknown>;
300
+ /** Transient JSON state that survives a remount; never a param. */
301
+ state: Record<string, unknown>;
302
+ setState(patch: Record<string, unknown>): void;
303
+ /** Mount built-in controls bound at `path` inside the owned value. Returns a disposer. */
304
+ controls(container: HTMLElement, controls: import("./part.js").PanelEntry[], opts?: { path?: string }): () => void;
305
+ /** Element builder; SVG tags get the SVG namespace, `on*` attrs become listeners. */
306
+ h(tag: string, attrs?: Record<string, unknown>, ...children: unknown[]): Element;
307
+ /** Parse an SVG string to its root element, or null. */
308
+ svg(text: string): SVGSVGElement | null;
309
+ /** Render a partforge-vector document to an inline `<svg>`, or null. */
310
+ svgFromVector(doc: unknown): SVGSVGElement | null;
311
+ /** Text of one of the part's own files, by path or `pfc-tree://` token, or null. */
312
+ file(pathOrToken: string): string | null;
313
+ readonly disabled: boolean;
314
+ }
315
+
316
+ export interface CustomControlInstance {
317
+ update?(ctx: { reason: "sync" | "derived" | "restore"; disabled: boolean }): void;
318
+ dispose?(): void;
319
+ }
320
+
259
321
  export interface ExportPartsOptions {
260
322
  /** Sub-part names, as `listExportableParts()` reports them. */
261
323
  parts: string[];
@@ -472,6 +534,10 @@ export interface PartRuntime {
472
534
  * view-cube click, Reframe, or an animation cue).
473
535
  */
474
536
  getViewerState(): ViewerState;
537
+ /** Every custom control's non-empty transient state, keyed by param. Hand back as `panelState`. */
538
+ getPanelState(): PanelState;
539
+ /** What custom controls reported failing this mount, in order. */
540
+ getPanelErrors(): PanelError[];
475
541
  /**
476
542
  * Subscribe to WebGL context loss — i.e. the GPU or the OS gave up — so a host
477
543
  * can say so rather than showing a dead canvas. The listener takes no
@@ -0,0 +1,18 @@
1
+ // partforge/panel-values — the value contract of a `type: "custom"` control.
2
+
3
+ export const CUSTOM_VALUE_MAX_BYTES: 16384;
4
+ export const CUSTOM_VALUE_MAX_DEPTH: 8;
5
+
6
+ export interface JsonValueLimits { maxBytes?: number; maxDepth?: number }
7
+
8
+ /** A string, boolean, finite number, or an array / plain object of those. */
9
+ export type JsonValue = string | number | boolean | JsonValue[] | { [key: string]: JsonValue };
10
+
11
+ /** Why `v` is not a JSON value within the caps (a fragment such as `is null at tiles[2].h`), or null. */
12
+ export function jsonValueProblem(v: unknown, limits?: JsonValueLimits): string | null;
13
+ export function isJsonValue(v: unknown, limits?: JsonValueLimits): v is JsonValue;
14
+
15
+ /** Parse an array/object literal's source text (bare or quoted keys, trailing commas, JS string escapes). Null on anything else. */
16
+ export function readJsonLiteral(text: string): { value: JsonValue } | null;
17
+ /** Source text for a value: compact under 80 chars, else indented JSON relative to `indent`. */
18
+ export function writeJsonLiteral(value: JsonValue, opts?: { indent?: string }): string;