partforge 0.96.0 → 0.97.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.
Files changed (39) hide show
  1. package/bin/cli.js +14 -1
  2. package/docs/AUTHORING-PARTS.md +199 -1
  3. package/docs/ERROR-PATTERNS.md +24 -0
  4. package/docs/KERNEL-CONTRACT.md +1 -0
  5. package/package.json +1 -1
  6. package/src/app-relief.js +16 -0
  7. package/src/framework/app.css +30 -0
  8. package/src/framework/backend-select.js +7 -2
  9. package/src/framework/geometry/heightfield.js +129 -0
  10. package/src/framework/geometry/kernel.js +3 -0
  11. package/src/framework/geometry/manifold-backend.js +61 -0
  12. package/src/framework/geometry/occt-backend.js +148 -1
  13. package/src/framework/geometry/op-options.js +10 -0
  14. package/src/framework/geometry/png-decode.js +107 -0
  15. package/src/framework/geometry/solid-hash.js +96 -0
  16. package/src/framework/image-ingest.js +41 -0
  17. package/src/framework/image-source.js +72 -0
  18. package/src/framework/images.js +66 -0
  19. package/src/framework/jobs.js +55 -0
  20. package/src/framework/lint/index.js +2 -1
  21. package/src/framework/lint/rules-images.js +109 -0
  22. package/src/framework/measure/measure-mode.js +2 -1
  23. package/src/framework/mount.js +2 -1
  24. package/src/framework/oracle/verify.js +6 -1
  25. package/src/framework/panel/image-picker.js +152 -0
  26. package/src/framework/panel/render.js +1 -0
  27. package/src/framework/panel/widget-specs.js +2 -0
  28. package/src/framework/panel/widgets/image.js +164 -0
  29. package/src/framework/panel/widgets/index.js +9 -5
  30. package/src/framework/param-deps.js +7 -2
  31. package/src/index.js +1 -0
  32. package/src/parts/assets/relief-demo.png +0 -0
  33. package/src/parts/relief.js +84 -0
  34. package/src/relief-worker.js +3 -0
  35. package/src/testing/manifold.js +7 -1
  36. package/src/testing/occt.js +4 -1
  37. package/types/index.d.ts +13 -0
  38. package/types/kernel.d.ts +32 -0
  39. package/types/part.d.ts +21 -0
@@ -0,0 +1,66 @@
1
+ // Resolve a part's declared `images` ({ name: source }) to a decoded luminance
2
+ // grid + a SHA-256 content digest, before the synchronous build — the third
3
+ // asset sibling beside fonts.js and imports.js, on the shared resolution core in
4
+ // asset-resolve.js. DOM-free and node:-free; crypto.subtle exists in workers and
5
+ // Node.
6
+ //
7
+ // Registration is simpler than imports': every backend can consume a normalized
8
+ // grid, so there are no per-format error entries and no crossover.
9
+ import { makeAssetResolver, resolveDecl } from "./asset-resolve.js";
10
+ import { decodePng } from "./geometry/png-decode.js";
11
+
12
+ const PNG_SIG = [0x89, 0x50, 0x4e, 0x47];
13
+
14
+ async function sha256Hex(bytes) {
15
+ const d = await globalThis.crypto.subtle.digest("SHA-256", bytes);
16
+ return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
17
+ }
18
+
19
+ const cache = new Map(); // source → Promise<{digest, width, height, data}>
20
+ const resolveOne = makeAssetResolver(
21
+ cache,
22
+ async (bytes) => {
23
+ const u8 = new Uint8Array(bytes);
24
+ for (let i = 0; i < 4; i++) {
25
+ if (u8[i] !== PNG_SIG[i]) {
26
+ throw new Error(
27
+ "images: only PNG is supported — convert with imageToPng() from \"partforge\" before storing, " +
28
+ "or have the host normalize on upload",
29
+ );
30
+ }
31
+ }
32
+ const { width, height, data } = decodePng(u8);
33
+ return { digest: await sha256Hex(bytes), width, height, data };
34
+ },
35
+ "resolveImages: an image source must be bytes, a URL, or a thunk returning one",
36
+ );
37
+
38
+ // `images` may be a plain { name: source } map or a function of the resolved
39
+ // params — the second form is what lets a `type: "image"` control drive the
40
+ // source. Mirrors fontsFor.
41
+ export function imagesFor(part, p) {
42
+ const decl = part?.images;
43
+ return typeof decl === "function" ? decl(p) : decl;
44
+ }
45
+
46
+ export async function resolveImages(imagesDecl) {
47
+ // A function reaching here means a caller passed `part.images` raw. It
48
+ // cannot be resolved without params — resolve it with imagesFor(part, p)
49
+ // first (mirrors resolveFonts's guard in fonts.js).
50
+ if (typeof imagesDecl === "function") {
51
+ throw new Error("resolveImages: `images` is a function of params — resolve it with imagesFor(part, p) first");
52
+ }
53
+ return resolveDecl(imagesDecl, resolveOne);
54
+ }
55
+
56
+ // Register a part's images on a booted kernel (idempotent per digest). Called in
57
+ // the async phase before every job's synchronous build — worker (jobs.js) and
58
+ // Node boots alike.
59
+ export async function ensureImages(kernel, imagesDecl) {
60
+ if (!imagesDecl || typeof kernel?._registerImage !== "function") return;
61
+ const resolved = await resolveImages(imagesDecl);
62
+ for (const [name, a] of resolved) {
63
+ if (kernel._imageDigest?.(name) === a.digest) continue;
64
+ await kernel._registerImage({ name, digest: a.digest, width: a.width, height: a.height, data: a.data });
65
+ }
66
+ }
@@ -7,6 +7,8 @@ import { exportablePartNames } from "./export-select.js";
7
7
  import { fontControlAllows, fontSourceAllowed, isNoFontSource } from "./font-source.js";
8
8
  import { fontsFor, resolveFonts } from "./fonts.js";
9
9
  import { normalizeOpentype, parseFont } from "./geometry/opentype-interop.js";
10
+ import { imageControlAllows, imageSourceAllowed, isNoImageSource } from "./image-source.js";
11
+ import { imagesFor, ensureImages } from "./images.js";
10
12
  import { ensureImports, resolveImports } from "./imports.js";
11
13
  import { safeName } from "./safe-name.js";
12
14
  import { ensureVectors } from "./vectors.js";
@@ -158,6 +160,25 @@ export async function handle(kernel, part, msg, post, opts = {}) {
158
160
  jobWarnings.push({ part: null, message }); // …and the durable record
159
161
  params[key] = part.defaults?.[key];
160
162
  }
163
+ // Same shape for `type: "image"` controls — a param bound to one is user
164
+ // input, and on a shared link a STRING value is arbitrary attacker text
165
+ // that `images: (p) => …` would turn into a fetch URL. A BYTE value
166
+ // (ArrayBuffer/typed array) always passes imageSourceAllowed regardless of
167
+ // `allow` — see image-source.js's header: it cannot have arrived via a
168
+ // share link (a URL can't carry megabytes), only from the host's own
169
+ // trusted panel (the partforge-cloud sandbox path). Runs in this same
170
+ // sanitize hook, not after resolveParams returns, for the reason the
171
+ // comment above states: rewriting p[key] afterwards would leave derive()
172
+ // — and therefore `d` and the geometry — holding the refused value while
173
+ // build() saw the default.
174
+ for (const [key, allow] of imageControlAllows(part)) {
175
+ const v = params[key];
176
+ if (isNoImageSource(v) || imageSourceAllowed(v, allow)) continue;
177
+ const message = `image source for "${key}" is not allowed — using the default`;
178
+ onProgress(message);
179
+ jobWarnings.push({ part: null, message });
180
+ params[key] = part.defaults?.[key];
181
+ }
161
182
  });
162
183
  // Preload any part-declared fonts into the kernel before building. A lazy
163
184
  // dynamic import because this is async context (unlike the synchronous
@@ -227,6 +248,40 @@ export async function handle(kernel, part, msg, post, opts = {}) {
227
248
  // lazy-error policy that keeps a STEP import inert until a build actually
228
249
  // calls k.import on it.
229
250
  if (part.imports) await ensureImports(kernel, part.imports, opts.importMeshes ?? null);
251
+ // Register this part's declared images on the kernel running this job — the
252
+ // third asset sibling beside fonts and imports. The allow-check already ran
253
+ // as resolveParams' sanitize hook above, so `p` here already reflects any
254
+ // refusal (reset to the part's default) — this step only resolves and
255
+ // uploads the resulting bytes.
256
+ //
257
+ // Gated on the part DECLARING `images` at all, not on this job having a
258
+ // source to resolve — the prune below has to run on the empty declaration
259
+ // too (a cleared pick), and a part with no `images` field must not touch
260
+ // the kernel's image map (a host or test harness may have seeded it
261
+ // directly via _registerImage).
262
+ if (part.images && typeof kernel._registerImage === "function") {
263
+ const imagesDecl = imagesFor(part, p) ?? {};
264
+ const declared = Object.fromEntries(
265
+ Object.entries(imagesDecl).filter(([name, src]) => {
266
+ if (!isNoImageSource(src)) return true;
267
+ onProgress(`no image source declared for "${name}" — skipping`);
268
+ return false;
269
+ }),
270
+ );
271
+ if (Object.keys(declared).length) {
272
+ onProgress("resolving images");
273
+ await ensureImages(kernel, declared);
274
+ }
275
+ // Drop every registered name this build's declaration does not supply.
276
+ // `_pruneImages` is the images twin of the `kernel._fonts` prune above:
277
+ // `heightfield(name)` looks a name up by the part's declared key, not by
278
+ // content, so a relief the user picked and then CLEARED would otherwise
279
+ // stay registered under its old name and go on rendering instead of
280
+ // whatever a missing source actually does — the unknown-image throw, or
281
+ // a branch a part's own build() takes around the call — the
282
+ // stale-registration bug of spec §5, one asset over.
283
+ kernel._pruneImages?.(new Set(Object.keys(declared)));
284
+ }
230
285
  // Vector art, the third asset family after fonts and imports. Same pre-build
231
286
  // timing; ensureVectors owns the prune, so this stays one line. Call it
232
287
  // unconditionally, even when this part has no `vectors` at all: ensureVectors
@@ -17,10 +17,11 @@ import { ANIMATION_RULES } from "./rules-animations.js";
17
17
  import { PLACE_RULES } from "./rules-place.js";
18
18
  import { IMPORT_RULES } from "./rules-imports.js";
19
19
  import { FONT_RULES } from "./rules-fonts.js";
20
+ import { IMAGE_RULES } from "./rules-images.js";
20
21
  import { SOURCE_RULES } from "./rules-source.js";
21
22
  import { VECTOR_RULES } from "./rules-vector.js";
22
23
 
23
- export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...SOURCE_RULES, ...VECTOR_RULES];
24
+ export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...IMAGE_RULES, ...SOURCE_RULES, ...VECTOR_RULES];
24
25
 
25
26
  // A usable sources input, or null. Deliberately forgiving: lintPart's callers
26
27
  // include hosted paths handing over user/LLM-authored trees, so a malformed
@@ -0,0 +1,109 @@
1
+ // Group 10 — image-control well-formedness + heightfield name resolution. The
2
+ // sibling of rules-fonts.js for the `type: "image"` control / `images` field /
3
+ // `k.heightfield()` triangle: same silent-failure shapes, same source-scheme
4
+ // concern, plus one this group owns alone — a `k.heightfield(name, opts)` call
5
+ // naming an image the part never declared.
6
+ //
7
+ // image-control-not-in-images and heightfield-unknown-image are deliberately
8
+ // COMPLEMENTARY, not overlapping: each fires in exactly the case the other
9
+ // skips. A function-form `images` can depend on a param — good, but its return
10
+ // value can't be read without calling it, so image-control-not-in-images
11
+ // actually calls it (with a sentinel substituted for the control's key) rather
12
+ // than settling for font-control-not-in-fonts's cheaper "is it a function at
13
+ // all?" question; a static `images` object provably CANNOT depend on a
14
+ // param — that mistake belongs to image-control-not-in-images too, and would
15
+ // fire on every correctly-authored fixed-image part if this rule also ran
16
+ // there, so it is skipped entirely for a static `images`. Conversely, only a
17
+ // static `images` object has statically-knowable keys, so heightfield-unknown-
18
+ // image runs only there and skips whenever `images` is a function.
19
+ import { err, warn } from "./finding.js";
20
+ import { imageControlAllows, imageSourceAllowed, isNoImageSource } from "../image-source.js";
21
+
22
+ // A URL-shaped sentinel (has a "://"), not an arbitrary string — some `images`
23
+ // functions run `new URL(v)` or similar on the value before deciding whether to
24
+ // use it, and an arbitrary string would make that throw and short-circuit the
25
+ // probe for reasons that have nothing to do with whether the key is read.
26
+ const SENTINEL = "pf-lint-sentinel://image-control-not-in-images";
27
+
28
+ const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
29
+
30
+ export const IMAGE_RULES = [
31
+ {
32
+ id: "image-control-not-in-images",
33
+ run: ({ part, p }) => {
34
+ // Static-object `images` always fails this check by construction — it
35
+ // cannot read a param at all — so it is a different mistake
36
+ // (image-source-scheme's business, or none) and not this rule's.
37
+ if (typeof part?.images !== "function") return [];
38
+ const controls = imageControlAllows(part);
39
+ if (controls.size === 0) return [];
40
+ const out = [];
41
+ for (const key of controls.keys()) {
42
+ let resolved;
43
+ try { resolved = part.images({ ...p, [key]: SENTINEL }); }
44
+ catch { continue; } // can't be probed safely — not evidence either way
45
+ const reached = isPlainObject(resolved) && Object.values(resolved).includes(SENTINEL);
46
+ if (reached) continue;
47
+ out.push(err("image-control-not-in-images",
48
+ `control "${key}" is an image picker, but this part's \`images\` function never returns the picked value — the picked value is never resolved.`,
49
+ `Reference p.${key} from images, e.g. images: (p) => ({ ${key}: p.${key} }), and consume it with k.heightfield("${key}", opts).`,
50
+ "images"));
51
+ }
52
+ return out;
53
+ },
54
+ },
55
+ {
56
+ id: "heightfield-unknown-image",
57
+ run: ({ part, probe }) => {
58
+ // Only a static `images` object has statically-knowable names; a
59
+ // function's return value depends on params lint doesn't exhaustively
60
+ // enumerate, so it's skipped here (see file header).
61
+ if (typeof part?.images === "function") return [];
62
+ const known = new Set(isPlainObject(part?.images) ? Object.keys(part.images) : []);
63
+ const seen = new Set();
64
+ const out = [];
65
+ for (const call of probe().calls) {
66
+ if (call.scope !== "kernel" || call.op !== "heightfield") continue;
67
+ // k.heightfield(nameOrGrid, opts) also accepts an INLINE grid object as
68
+ // its first argument — args are recorded via JSON.stringify (probe.js's
69
+ // `describe`), so a grid parses back to an object, not a string. Only a
70
+ // string-literal first argument names a declared image; anything else
71
+ // (an inline grid, a computed/non-literal value the probe can't read)
72
+ // is silently skipped rather than flagged — flagging a supported inline
73
+ // grid as an "unknown image" would be a false positive.
74
+ let name;
75
+ try { name = JSON.parse(call.args[0]); } catch { name = null; }
76
+ if (typeof name !== "string" || known.has(name) || seen.has(name)) continue;
77
+ seen.add(name);
78
+ out.push(err("heightfield-unknown-image",
79
+ `build calls k.heightfield with name "${name}", which the part's images field does not declare: ${[...known].join(", ") || "(nothing)"}`,
80
+ "Declare the source under images: { name: source }, or fix the name to match an existing entry.",
81
+ "images"));
82
+ }
83
+ return out;
84
+ },
85
+ },
86
+ {
87
+ id: "image-source-scheme",
88
+ run: ({ part }) => {
89
+ const out = [];
90
+ for (const [key, allow] of imageControlAllows(part)) {
91
+ const v = part?.defaults?.[key];
92
+ // An empty source declares no image (jobs.js's sanitize hook drops it
93
+ // before it reaches ensureImages, and a build() that still calls
94
+ // k.heightfield for it gets the ordinary unknown-image throw — there
95
+ // is no automatic "no relief" fallback) — a legitimate way to author
96
+ // an optional relief, not a source the allow list is refusing. A
97
+ // bytes source (ArrayBuffer/typed array) always
98
+ // passes imageSourceAllowed regardless of allow — see image-source.js's
99
+ // header — so it never reaches this branch either.
100
+ if (isNoImageSource(v) || imageSourceAllowed(v, allow)) continue;
101
+ out.push(warn("image-source-scheme",
102
+ `defaults.${key} is "${String(v).slice(0, 120)}", which control "${key}" would refuse (allow: ${allow.join(", ")}).`,
103
+ `Use a source the allow list accepts, or widen \`allow\` on the control. At build time this value is replaced by defaults.${key}, so as written the part has no usable image.`,
104
+ "defaults"));
105
+ }
106
+ return out;
107
+ },
108
+ },
109
+ ];
@@ -13,6 +13,7 @@ import { raycastViewer } from "../selection/raycast.js";
13
13
  import { createFeatureHighlight } from "../selection/feature-highlight.js";
14
14
  import { createDragTracker } from "../selection/drag-tracker.js";
15
15
  import { subPartReadKeys, RELEVANT_ALL } from "../param-deps.js";
16
+ import { byteAwareReplacer } from "../geometry/solid-hash.js";
16
17
  import { classifyFeature, bboxSpec, unionBounds } from "./feature-dims.js";
17
18
  import { paramMatches } from "./param-link.js";
18
19
  import { createPinStore, occurrenceOf } from "./pins.js";
@@ -136,7 +137,7 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
136
137
  // direct test) falls back to the content hash.
137
138
  let readsKey = null, readsMap = null;
138
139
  function readsFor(view, params) {
139
- const key = `${view}|${getParamsVersion ? getParamsVersion() : JSON.stringify(params)}`;
140
+ const key = `${view}|${getParamsVersion ? getParamsVersion() : JSON.stringify(params, byteAwareReplacer)}`;
140
141
  if (readsKey !== key) {
141
142
  readsKey = key;
142
143
  try { readsMap = subPartReadKeys(part, view, params); } catch { readsMap = null; }
@@ -304,6 +304,7 @@ function createCleanupStack() {
304
304
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
305
305
  export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
306
306
  fontCatalog,
307
+ imageCatalog,
307
308
  viewerState,
308
309
  annotateSend = "viewbar",
309
310
  container: legacyContainer, controls: legacyControls } = {}) {
@@ -963,7 +964,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
963
964
  }, onParamsCommit
964
965
  ? (changed) => onParamsCommit({ changed, params: { ...params } })
965
966
  : undefined,
966
- { fontCatalog });
967
+ { fontCatalog, imageCatalog });
967
968
  cleanup.defer(() => panel.dispose());
968
969
  panelRef = panel;
969
970
  const updateRelevance = () => {
@@ -4,6 +4,7 @@ import { pairKey, CONTACT_EPS } from "./gaps.js";
4
4
  import { resolveProfile } from "./dfm-profiles.js";
5
5
  import { expandExpectations, partGatesMinWall } from "./gates.js";
6
6
  import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../param-deps.js";
7
+ import { byteAwareReplacer } from "../geometry/solid-hash.js";
7
8
  import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
8
9
 
9
10
  // Re-exported for backwards compatibility: the registries moved to framework/ so
@@ -208,9 +209,13 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
208
209
  const expanded = expandExpectations(part);
209
210
  const needMinWall = partGatesMinWall(part, { process, expanded });
210
211
  const readKeys = subPartReadKeys(part, view, part.defaults);
212
+ // byteAwareReplacer on the RELEVANT_ALL branch too: an unattributable derive()
213
+ // still might read a byte-valued image param, and this memo key gates whether
214
+ // a case's geometry gets rebuilt or an earlier result reused (see the seeding
215
+ // block below) — the same collision the relevanceHash branch guards against.
211
216
  const signature = (params) =>
212
217
  readKeys === RELEVANT_ALL
213
- ? JSON.stringify(params)
218
+ ? JSON.stringify(params, byteAwareReplacer)
214
219
  : [...readKeys.entries()].map(([name, keys]) => `${name}:${relevanceHash([...keys], params)}`).join("|");
215
220
 
216
221
  const memo = new Map();
@@ -0,0 +1,152 @@
1
+ // The `type: "image"` picker: a takeover panel over the rail — a search box
2
+ // above a thumbnail grid — reusing the `.picker`/`.pk-head`/`.pk-search`
3
+ // scaffolding font-picker.js built, since a search-then-pick takeover is the
4
+ // same shape for either asset kind. No variants pane: an image source has no
5
+ // weight/style axis to drill into, so choosing IS committing.
6
+ //
7
+ // Main-thread only — it is DOM-heavy and is NOT part of the worker graph. It
8
+ // draws thumbnails through plain `<img src>`, so the browser's own decoder does
9
+ // the work; nothing here imports `png-decode.js`.
10
+ import { setImagePicker } from "./widgets/image.js";
11
+ import { imageSourceAllowed } from "../image-source.js";
12
+
13
+ const SEARCH_LIMIT = 60;
14
+ const SEARCH_DEBOUNCE_MS = 120;
15
+
16
+ function el(tag, className, text) {
17
+ const node = document.createElement(tag);
18
+ if (className) node.className = className;
19
+ if (text != null) node.textContent = text;
20
+ return node;
21
+ }
22
+
23
+ // At most one picker is open at a time, and the previous one has to be CLOSED
24
+ // rather than merely detached: its `keydown` listener lives on `document`, so
25
+ // dropping the element off the DOM leaves the handler — and the whole closure —
26
+ // alive forever, one more on every re-open. Only close() unregisters it.
27
+ let openPicker = null;
28
+
29
+ export function openImagePicker({ node, params, allow, imageCatalog, anchor, onPicked }) {
30
+ // Takeover: the picker covers the rail on desktop and the single visible pane
31
+ // below the narrow breakpoint, same as the font picker.
32
+ const host = anchor?.closest?.(".pf-rail") ?? anchor?.parentElement ?? document.body;
33
+ openPicker?.close(); // never two at once
34
+
35
+ let results = [];
36
+ let query = "";
37
+ let closed = false;
38
+ let searchSeq = 0;
39
+ let debounce = null;
40
+ let failed = false;
41
+
42
+ // ── DOM ─────────────────────────────────────────────────────────────────
43
+ const picker = el("div", "picker");
44
+ const head = el("div", "pk-head");
45
+ const titlebar = el("div", "pk-titlebar");
46
+ const closeBtn = el("button", "pk-x", "×");
47
+ closeBtn.type = "button";
48
+ closeBtn.title = "Close";
49
+ titlebar.append(el("b", "", node.label ?? node.key), closeBtn);
50
+ const search = document.createElement("input");
51
+ search.className = "pk-search";
52
+ search.type = "text";
53
+ search.placeholder = "Search images";
54
+ search.autocomplete = "off";
55
+ search.spellcheck = false;
56
+ head.append(titlebar, search);
57
+ const grid = el("div", "pk-img-grid");
58
+ const empty = el("p", "pk-empty");
59
+ empty.hidden = true;
60
+ picker.append(head, grid, empty);
61
+ host.append(picker);
62
+ search.focus?.();
63
+
64
+ // ── the grid ────────────────────────────────────────────────────────────
65
+ // Not virtualized like the font list: a search result page is bounded by
66
+ // SEARCH_LIMIT, so the DOM cost of every row existing at once stays small —
67
+ // no scroll-position bookkeeping to get wrong for what is, at most, one
68
+ // catalog page of thumbnails.
69
+ function render() {
70
+ if (closed) return;
71
+ grid.textContent = "";
72
+ // A catalog is host-supplied, not trusted — drop any asset the allowlist
73
+ // refuses, same rule the font picker applies to a family's variants.
74
+ const admissible = results.filter((a) => a && typeof a.url === "string" && imageSourceAllowed(a.url, allow));
75
+ for (const asset of admissible) {
76
+ const card = el("button", "pk-img-card");
77
+ card.type = "button";
78
+ card.dataset.sel = String(asset.url === params[node.key]);
79
+ const thumb = document.createElement("img");
80
+ thumb.className = "pk-img-thumb";
81
+ thumb.alt = "";
82
+ thumb.src = asset.thumbUrl || asset.url;
83
+ thumb.addEventListener("error", () => { thumb.hidden = true; });
84
+ const cap = el("span", "pk-img-cap", asset.label ?? "");
85
+ card.append(thumb, cap);
86
+ card.addEventListener("click", () => choose(asset));
87
+ grid.append(card);
88
+ }
89
+ empty.hidden = admissible.length > 0;
90
+ if (!admissible.length) {
91
+ empty.textContent = failed ? "The image catalog is unavailable."
92
+ : query.trim() ? `No images match "${query.trim()}".`
93
+ : "No images available.";
94
+ }
95
+ }
96
+
97
+ function choose(asset) {
98
+ params[node.key] = asset.url;
99
+ onPicked?.();
100
+ close();
101
+ }
102
+
103
+ function runSearch(q) {
104
+ const seq = ++searchSeq;
105
+ Promise.resolve()
106
+ .then(() => imageCatalog.search(q, { limit: SEARCH_LIMIT }))
107
+ .then((entries) => {
108
+ if (closed || seq !== searchSeq) return; // a newer search already won
109
+ failed = false;
110
+ results = Array.isArray(entries) ? entries : [];
111
+ render();
112
+ })
113
+ .catch(() => {
114
+ if (closed || seq !== searchSeq) return;
115
+ failed = true;
116
+ results = [];
117
+ render();
118
+ });
119
+ }
120
+
121
+ search.addEventListener("input", () => {
122
+ query = search.value;
123
+ clearTimeout(debounce);
124
+ debounce = setTimeout(() => runSearch(query.trim()), SEARCH_DEBOUNCE_MS);
125
+ });
126
+
127
+ // ── closing ─────────────────────────────────────────────────────────────
128
+ const handle = { close };
129
+
130
+ function close() {
131
+ if (closed) return; // idempotent
132
+ closed = true;
133
+ clearTimeout(debounce);
134
+ document.removeEventListener("keydown", onKey);
135
+ picker.remove();
136
+ if (openPicker === handle) openPicker = null;
137
+ }
138
+ function onKey(ev) {
139
+ if (ev.key !== "Escape") return;
140
+ ev.stopPropagation();
141
+ close();
142
+ }
143
+ document.addEventListener("keydown", onKey);
144
+ closeBtn.addEventListener("click", close);
145
+
146
+ runSearch("");
147
+ render();
148
+ openPicker = handle;
149
+ return handle;
150
+ }
151
+
152
+ setImagePicker(openImagePicker);
@@ -227,6 +227,7 @@ export function buildControls(root, parameters, params, onDirty, onCommit, opts
227
227
  onCommit: () => commit([node.key]),
228
228
  info,
229
229
  fontCatalog: opts.fontCatalog,
230
+ imageCatalog: opts.imageCatalog,
230
231
  });
231
232
  nodeEls.set(node.id, widget.el);
232
233
  if (node.key && !keyToId.has(node.key)) keyToId.set(node.key, node.id);
@@ -37,6 +37,7 @@ export const WIDGET_SPECS = [
37
37
  { type: "select", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
38
38
  { type: "radio", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
39
39
  { type: "font", kind: "control", fields: [...AUTHOR_COMMON, "allow", "preview"] },
40
+ { type: "image", kind: "control", fields: [...AUTHOR_COMMON, "allow"] },
40
41
  { type: "readout", kind: "display", fields: ["type", "label", "description", "unit", "derivedKey", "hidden", "when", "whenFalse"] },
41
42
  ];
42
43
 
@@ -56,6 +57,7 @@ const AUTHOR_EXTRAS = {
56
57
  select: ["options"],
57
58
  radio: ["options"],
58
59
  font: ["allow", "preview"],
60
+ image: ["allow"],
59
61
  };
60
62
  const AUTHOR_FIELDS = new Map(Object.entries(AUTHOR_EXTRAS).map(
61
63
  ([type, extra]) => [type, [...AUTHOR_COMMON, ...extra]]));