partforge 0.96.0 → 0.98.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 (52) hide show
  1. package/bin/cli.js +128 -9
  2. package/docs/AUTHORING-PARTS.md +371 -5
  3. package/docs/ERROR-PATTERNS.md +25 -1
  4. package/docs/KERNEL-CONTRACT.md +1 -0
  5. package/docs/VECTOR-FORMAT.md +23 -17
  6. package/package.json +9 -1
  7. package/src/app-relief.js +16 -0
  8. package/src/framework/app.css +30 -0
  9. package/src/framework/backend-select.js +7 -2
  10. package/src/framework/font-source.js +18 -1
  11. package/src/framework/geometry/heightfield.js +129 -0
  12. package/src/framework/geometry/kernel.js +3 -0
  13. package/src/framework/geometry/manifold-backend.js +61 -0
  14. package/src/framework/geometry/occt-backend.js +148 -1
  15. package/src/framework/geometry/op-options.js +10 -0
  16. package/src/framework/geometry/png-decode.js +107 -0
  17. package/src/framework/geometry/solid-hash.js +96 -0
  18. package/src/framework/image-source.js +76 -0
  19. package/src/framework/images.js +66 -0
  20. package/src/framework/ingest/image-ingest.js +41 -0
  21. package/src/framework/ingest/node-dom.js +69 -0
  22. package/src/framework/ingest/registry.js +57 -0
  23. package/src/framework/ingest/sniff.js +96 -0
  24. package/src/framework/jobs.js +107 -6
  25. package/src/framework/lint/index.js +2 -1
  26. package/src/framework/lint/rules-images.js +109 -0
  27. package/src/framework/lint/rules-vector.js +68 -3
  28. package/src/framework/measure/measure-mode.js +2 -1
  29. package/src/framework/mount.js +14 -1
  30. package/src/framework/oracle/verify.js +6 -1
  31. package/src/framework/panel/image-picker.js +152 -0
  32. package/src/framework/panel/render.js +2 -0
  33. package/src/framework/panel/widget-specs.js +4 -0
  34. package/src/framework/panel/widgets/file-drop.js +321 -0
  35. package/src/framework/panel/widgets/font.js +51 -12
  36. package/src/framework/panel/widgets/image.js +178 -0
  37. package/src/framework/panel/widgets/index.js +11 -5
  38. package/src/framework/panel/widgets/vector.js +77 -0
  39. package/src/framework/param-deps.js +7 -2
  40. package/src/framework/vector-source.js +127 -0
  41. package/src/framework/vectors.js +9 -0
  42. package/src/ingest.js +1 -0
  43. package/src/parts/assets/relief-demo.png +0 -0
  44. package/src/parts/emblem.js +2 -1
  45. package/src/parts/relief.js +84 -0
  46. package/src/relief-worker.js +3 -0
  47. package/src/testing/manifold.js +7 -1
  48. package/src/testing/occt.js +4 -1
  49. package/types/index.d.ts +67 -0
  50. package/types/ingest.d.ts +13 -0
  51. package/types/kernel.d.ts +32 -0
  52. package/types/part.d.ts +21 -0
@@ -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,8 @@ 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,
231
+ onAssetUpload: opts.onAssetUpload,
230
232
  });
231
233
  nodeEls.set(node.id, widget.el);
232
234
  if (node.key && !keyToId.has(node.key)) keyToId.set(node.key, node.id);
@@ -37,6 +37,8 @@ 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"] },
41
+ { type: "vector", kind: "control", fields: [...AUTHOR_COMMON, "allow"] },
40
42
  { type: "readout", kind: "display", fields: ["type", "label", "description", "unit", "derivedKey", "hidden", "when", "whenFalse"] },
41
43
  ];
42
44
 
@@ -56,6 +58,8 @@ const AUTHOR_EXTRAS = {
56
58
  select: ["options"],
57
59
  radio: ["options"],
58
60
  font: ["allow", "preview"],
61
+ image: ["allow"],
62
+ vector: ["allow"],
59
63
  };
60
64
  const AUTHOR_FIELDS = new Map(Object.entries(AUTHOR_EXTRAS).map(
61
65
  ([type, extra]) => [type, [...AUTHOR_COMMON, ...extra]]));
@@ -0,0 +1,321 @@
1
+ // The shared drop target behind `type: "image"`, `type: "vector"` and
2
+ // `type: "font"` (Tasks 6, 8, 9) — NOT itself a control. It owns no param key
3
+ // and draws no label/row of its own; it turns a dropped, picked or pasted
4
+ // file into either a host-stored source string or the converted artifact, and
5
+ // hands that to `onSource`. Kind-agnostic for classification and upload: the
6
+ // registry (`../../ingest/registry.js`) already knows what each kind accepts
7
+ // and how to convert it, and `classify`/`convertFor`/the `onAssetUpload`
8
+ // branch never look past `kind` as an opaque string.
9
+ //
10
+ // ONE named exception, in exactly two places, both explained where they
11
+ // occur: `convertedArtifact` and the "no host hook" branch of `handle()`
12
+ // both check `kind === "vector"` by name, because a vector's "no host hook"
13
+ // delivery is the PARSED document object, not bytes like every other kind
14
+ // (task-9 addendum, Ruling D) — that asymmetry has to live somewhere, and
15
+ // singling it out here is more honest than pretending every kind still ends
16
+ // up looking the same.
17
+ //
18
+ // `mountDrop` (bottom of this file) is the widget-facing half: wiring
19
+ // `makeFileDrop`'s output to a control's own `params[node.key]` and error
20
+ // surface is the same ~25 lines in widgets/image.js, widgets/font.js and
21
+ // widgets/vector.js — two copies were defensible as deliberate mirroring
22
+ // (Tasks 6, 8); a third (Task 9) is where a shared helper wins (task-9
23
+ // addendum, Ruling L). All three widgets call it instead of keeping their own
24
+ // `mountXDrop`/`makeDropError` pair.
25
+ //
26
+ // Before this file there was no drag-and-drop, file picker, or paste
27
+ // affordance anywhere in the panel (design doc, "Evidence" §1) — this is new
28
+ // code, not an extraction.
29
+ //
30
+ // MAIN-THREAD ONLY, like font.js/image.js: it reaches the real converters
31
+ // (canvas decode for images, paper.js for vectors) only through the
32
+ // registry's `convert` thunks, so a part with no such control never pays for
33
+ // either, and neither is reachable from the geometry worker's import closure
34
+ // (test/worker-layering.test.js walks the registry itself; this file adds
35
+ // nothing new to that surface).
36
+ import { classify, convertFor, rowFor } from "../../ingest/registry.js";
37
+
38
+ // A generous but bounded cap, checked against `file.size` — BEFORE even
39
+ // `file.arrayBuffer()` is called, let alone conversion — so an oversize file
40
+ // is never read into memory just to be rejected. 25 MB comfortably covers a
41
+ // photo, a font family file or a hand-drawn SVG; nothing this framework
42
+ // ingests is legitimately bigger, and letting a bigger one through would
43
+ // decode on the main thread with no progress UI. The design doc left the
44
+ // exact number to this task (open item 2) — this is that pick.
45
+ const MAX_BYTES = 25 * 1024 * 1024;
46
+
47
+ const bytesLabel = (n) =>
48
+ n >= 1024 * 1024 ? `${(n / (1024 * 1024)).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`;
49
+
50
+ // media type -> a short human noun, for a refusal message naming what a file
51
+ // actually IS (spec §5: "names what works"). Kept local and small: the
52
+ // registry's own `label`s already read as sentences ("an image (PNG, JPG or
53
+ // WebP)"), not nouns, so they are the wrong shape for "X is a ␣ file".
54
+ const MEDIA_NOUN = {
55
+ "image/png": "PNG", "image/jpeg": "JPEG", "image/webp": "WebP", "image/svg+xml": "SVG",
56
+ "font/otf": "OTF", "font/ttf": "TTF", "font/woff2": "WOFF2",
57
+ };
58
+
59
+ // Compose the refusal message from a failed `classify()`. Two shapes, both
60
+ // naming the actual file:
61
+ // - the registry knows another slot that WOULD take it (`suggestKind`) ->
62
+ // name where it belongs, e.g. "logo.svg is artwork (SVG) ... try the
63
+ // Artwork control instead" — the exact case the registry's `suggestKind`
64
+ // exists for. Uses the row's own `name` (a display name, e.g. "Artwork"),
65
+ // never the internal `kind` key (e.g. "vector") — the key is an
66
+ // implementation detail the registry happens to use for lookups, not
67
+ // something a control is labelled.
68
+ // - it knows only what THIS slot accepts — either the bytes are unrecognised,
69
+ // or they are a real, named format that just has no home anywhere (WOFF2)
70
+ // -> say what works, point nowhere.
71
+ function refusalMessage(filename, kind, { mediaType, suggestKind }) {
72
+ const needLabel = rowFor(kind).label;
73
+ if (suggestKind) {
74
+ const haveRow = rowFor(suggestKind);
75
+ return `"${filename}" is ${haveRow.label} — this control accepts ${needLabel}. Try the ${haveRow.name} control instead.`;
76
+ }
77
+ const noun = mediaType && MEDIA_NOUN[mediaType];
78
+ const what = noun ? `a ${noun} file` : "not a file this control recognises";
79
+ return `"${filename}" is ${what} — this control accepts ${needLabel}.`;
80
+ }
81
+
82
+ function el(tag, className, text) {
83
+ const node = document.createElement(tag);
84
+ if (className) node.className = className;
85
+ if (text != null) node.textContent = text;
86
+ return node;
87
+ }
88
+
89
+ export function makeFileDrop({ kind, onSource, onError, onAssetUpload }) {
90
+ const row = rowFor(kind);
91
+ const wrap = el("div", "file-drop");
92
+ wrap.tabIndex = 0;
93
+ wrap.setAttribute("role", "button");
94
+ const hint = el("span", "file-drop-hint", `Drop ${row?.label ?? "a file"} here, or click to choose`);
95
+ wrap.append(hint);
96
+
97
+ // The click/keyboard path to the same handler a drop uses. Hidden rather
98
+ // than absent: a real `<input type="file">` is what gives this a native
99
+ // "Choose File" affordance and OS-level type filtering (`accept`), neither
100
+ // of which is worth hand-rolling.
101
+ const input = document.createElement("input");
102
+ input.type = "file";
103
+ input.className = "file-drop-input";
104
+ input.hidden = true;
105
+ if (row?.accepts?.length) input.accept = row.accepts.join(",");
106
+ wrap.append(input);
107
+
108
+ // The converted artifact from the most recently accepted drop — a Blob (or,
109
+ // for a `convert: null` kind like font, the original File, which already IS
110
+ // the artifact since nothing transforms it). Kept so a failed
111
+ // `onAssetUpload` can be retried without re-reading and re-converting the
112
+ // file the user already dropped: that reconvert — a canvas decode, a
113
+ // paper.js import — is the whole reason this exists rather than just
114
+ // re-deriving it from the DOM on retry.
115
+ let converted = null;
116
+
117
+ // One AbortController for every listener this widget adds (created here,
118
+ // ahead of `handle`, so `handle` can close over `signal` — see `stale()`
119
+ // below). `dispose()` is a single `abort()` rather than a hand-maintained
120
+ // list of pairs to get wrong. font-picker.js:49 explains why removal
121
+ // matters: a stray listener keeps this whole closure (and everything it
122
+ // closes over — `onSource`, `onAssetUpload`, eventually a live `params`)
123
+ // alive long after the panel that created it is gone.
124
+ const ac = new AbortController();
125
+ const { signal } = ac;
126
+
127
+ // `handle()` is a multi-await chain (read -> classify -> convert -> upload),
128
+ // and two things can happen while it's suspended: the widget can be
129
+ // disposed (a panel rebuild tore this control down), or a SECOND drop can
130
+ // land before the first finishes (a slow first file, a fast second one).
131
+ // Neither must be allowed to write into `converted` or fire `onSource`/
132
+ // `onError` after the fact — a disposed widget has no live closure to write
133
+ // into safely, and a superseded drop must not clobber the newer one's
134
+ // result (the user's most recent drop has to win). `generation` is bumped
135
+ // on every `handle()` call; `stale()` is true once either reason applies,
136
+ // and every callback site below is gated on it.
137
+ let generation = 0;
138
+
139
+ // The argument shape a converter wants differs by kind — imageToPng wants
140
+ // the Blob itself, ingestSvg wants the decoded SVG text — so this is the one
141
+ // place that looks past "kind" as an opaque string. A `convert: null` row
142
+ // (font) needs no call at all: the dropped file is already the artifact.
143
+ //
144
+ // Returns `{ blob, doc }`: `blob` is what an `onAssetUpload` host hook gets
145
+ // (a host uploads bytes, not a live object) and what `lastBlob()` retains
146
+ // for a retry; `doc` is set only for "vector", where it is the PARSED
147
+ // partforge-vector document `ingestSvg` already produced. `blob` still
148
+ // carries the JSON-serialized form for the upload path, but `handle()`
149
+ // below delivers `doc` — never the serialized bytes — to `onSource` when
150
+ // there is no host hook to upload to (task-9 addendum, Ruling D: the
151
+ // resolver already accepts an in-tree parsed object directly, so
152
+ // serializing it only for the resolver to re-parse would be pure waste).
153
+ async function convertedArtifact(file, mediaType) {
154
+ const convert = await convertFor(kind, mediaType);
155
+ if (!convert) return { blob: file, doc: undefined };
156
+ if (kind === "vector") {
157
+ const text = new TextDecoder("utf-8").decode(await file.arrayBuffer());
158
+ const doc = convert(text, { source: file.name });
159
+ return { blob: new Blob([JSON.stringify(doc)], { type: "application/json" }), doc };
160
+ }
161
+ return { blob: await convert(file), doc: undefined };
162
+ }
163
+
164
+ async function handle(file) {
165
+ const myGen = ++generation;
166
+ const stale = () => signal.aborted || myGen !== generation;
167
+
168
+ // Checked against `file.size` alone — no read yet — so an oversize file
169
+ // never gets fully buffered into memory just to be rejected.
170
+ if (file.size > MAX_BYTES) {
171
+ onError?.(`"${file.name}" is ${bytesLabel(file.size)} — over the ${bytesLabel(MAX_BYTES)} limit.`);
172
+ return;
173
+ }
174
+
175
+ const bytes = new Uint8Array(await file.arrayBuffer());
176
+ if (stale()) return;
177
+
178
+ const result = classify(bytes, kind);
179
+ if (!result.ok) {
180
+ onError?.(refusalMessage(file.name, kind, result));
181
+ return;
182
+ }
183
+
184
+ let artifact;
185
+ try {
186
+ artifact = await convertedArtifact(file, result.mediaType);
187
+ } catch (err) {
188
+ if (stale()) return;
189
+ // Names the file and the stage (spec §5) — a malformed SVG must not
190
+ // read as a partforge bug.
191
+ onError?.(`"${file.name}" could not be converted: ${err.message}`);
192
+ return;
193
+ }
194
+ if (stale()) return; // a newer drop already won — don't clobber its `converted`
195
+ converted = artifact.blob; // retained from here on, success or not — see the field comment above
196
+
197
+ if (onAssetUpload) {
198
+ try {
199
+ const source = await onAssetUpload(artifact.blob, { kind, filename: file.name });
200
+ if (stale()) return;
201
+ // A host hook that resolves to anything but a non-empty string —
202
+ // `undefined`, an object, `""` — is a contract violation, not a
203
+ // source: writing it into the param would corrupt the part rather
204
+ // than fail loudly.
205
+ if (typeof source !== "string" || !source) {
206
+ onError?.(`"${file.name}" was converted, but the upload hook returned ${JSON.stringify(source)} instead of a source string.`);
207
+ return;
208
+ }
209
+ onSource?.(source);
210
+ } catch (err) {
211
+ if (stale()) return;
212
+ // The artifact stays in `converted` — a retry (the host re-driving
213
+ // onAssetUpload, e.g. from a "try again" button) costs a network
214
+ // call, not a reconvert.
215
+ onError?.(`"${file.name}" converted, but the upload failed: ${err.message}. Retry — it doesn't need reconverting.`);
216
+ }
217
+ return;
218
+ }
219
+
220
+ // No host hook. For "vector" the artifact IS the parsed document object —
221
+ // deliver that directly, never its serialized bytes (task-9 addendum,
222
+ // Ruling D; see `convertedArtifact` above).
223
+ if (kind === "vector") {
224
+ onSource?.(artifact.doc);
225
+ return;
226
+ }
227
+
228
+ // Every other kind: the bytes themselves are the param value. This is the
229
+ // path the partforge-cloud sandbox needs because it cannot fetch URLs —
230
+ // correct and expected, not a degraded fallback (see font.js/image.js's
231
+ // own byte-valued param handling for the downstream half of this).
232
+ const ab = await artifact.blob.arrayBuffer();
233
+ if (stale()) return;
234
+ onSource?.(ab);
235
+ }
236
+
237
+ async function handleFiles(files) {
238
+ const list = Array.from(files ?? []);
239
+ if (list.length === 0) return;
240
+ if (list.length > 1) {
241
+ // Silently dropping the rest would be worse than a one-line note
242
+ // (spec §5) — reported, but the first file still proceeds below.
243
+ onError?.(`${list.length} files were dropped — using only the first, "${list[0].name}".`);
244
+ }
245
+ await handle(list[0]);
246
+ }
247
+
248
+ const onDrop = (e) => {
249
+ e.preventDefault();
250
+ wrap.classList.remove("file-drop-over");
251
+ handleFiles(e.dataTransfer?.files);
252
+ };
253
+ const onDragOver = (e) => { e.preventDefault(); wrap.classList.add("file-drop-over"); };
254
+ const onDragLeave = () => wrap.classList.remove("file-drop-over");
255
+ const onClick = () => input.click();
256
+ const onKeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); input.click(); } };
257
+ const onChange = () => { handleFiles(input.files); input.value = ""; }; // same file twice must still fire `change`
258
+ // Paste is the third affordance the feature exists to add (design doc,
259
+ // "Evidence" §1: "no drag-and-drop, file picker, or paste affordance
260
+ // anywhere in the panel") — a clipboard image/font/SVG lands the same way a
261
+ // drop does.
262
+ const onPaste = (e) => { if (e.clipboardData?.files?.length) handleFiles(e.clipboardData.files); };
263
+
264
+ // `ac`/`signal` were created above, ahead of `handle()` — every listener
265
+ // below shares the same one.
266
+ wrap.addEventListener("drop", onDrop, { signal });
267
+ wrap.addEventListener("dragover", onDragOver, { signal });
268
+ wrap.addEventListener("dragleave", onDragLeave, { signal });
269
+ wrap.addEventListener("click", onClick, { signal });
270
+ wrap.addEventListener("keydown", onKeydown, { signal });
271
+ wrap.addEventListener("paste", onPaste, { signal });
272
+ input.addEventListener("change", onChange, { signal });
273
+
274
+ return {
275
+ el: wrap,
276
+ dispose: () => ac.abort(),
277
+ lastBlob: () => converted,
278
+ };
279
+ }
280
+
281
+ // The widget-facing wiring: mounts a `makeFileDrop` for one control and binds
282
+ // it to `params[node.key]`, including the control's own error surface (a
283
+ // hidden-by-default line under the drop target, filled verbatim with
284
+ // whatever `onError` composed — spec: "do not rewrite or wrap it; render
285
+ // it"). Marks the drop element `data-pf-drop` (the tests select on it).
286
+ //
287
+ // `onRender` is the widget's own repaint (`paintField`/`paint`) — called
288
+ // after a successful drop so the field/button reflects the new value
289
+ // immediately, the same way a catalog picker's `onPicked` already does.
290
+ // `onChange`/`onCommit` are then fired in that order, mirroring every other
291
+ // widget's own edit path (render.js wires them: `onChange` marks the section
292
+ // Custom and re-applies condition state, `onCommit` is the "the user finished
293
+ // an interaction" signal that triggers a rebuild).
294
+ //
295
+ // Returns `{ el, errorEl, dispose }` rather than appending anything itself —
296
+ // the caller still owns layout (where the drop target and error line sit
297
+ // relative to the field/button), only the wiring is shared.
298
+ export function mountDrop(kind, { params, node, onAssetUpload, onChange, onCommit, onRender }) {
299
+ const errorEl = el("div", "file-drop-error");
300
+ errorEl.hidden = true;
301
+
302
+ const drop = makeFileDrop({
303
+ kind,
304
+ onAssetUpload,
305
+ onSource: (source) => {
306
+ errorEl.hidden = true;
307
+ errorEl.textContent = "";
308
+ params[node.key] = source;
309
+ onRender?.();
310
+ onChange?.();
311
+ onCommit?.();
312
+ },
313
+ onError: (message) => {
314
+ errorEl.hidden = false;
315
+ errorEl.textContent = message;
316
+ },
317
+ });
318
+ drop.el.setAttribute("data-pf-drop", "");
319
+
320
+ return { el: drop.el, errorEl, dispose: () => drop.dispose() };
321
+ }
@@ -1,13 +1,22 @@
1
- // The `type: "font"` control. Its VALUE is a font source string the same
2
- // grammar `PartDefinition.fonts` already accepts so everything downstream
3
- // (presets, undo, the params hash, `when`) works with no special case.
1
+ // The `type: "font"` control. Its VALUE is a font source — either a source
2
+ // string (the same grammar `PartDefinition.fonts` already accepts) or raw
3
+ // font bytes (an ArrayBuffer/typed array): the partforge-cloud sandbox cannot
4
+ // fetch URLs, so it puts the bytes straight in the param (font-source.js now
5
+ // accepts that shape). Everything downstream (presets, undo, the params hash,
6
+ // `when`) works with either shape, no special case.
4
7
  //
5
- // Two renderings. With a host-supplied `fontCatalog` it is a button showing the
6
- // current face IN that face, opening the picker. Without one it degrades to a
7
- // URL text field, so a standalone partforge app (which ships no catalog) still
8
- // exposes the parameter.
8
+ // Two renderings, mirroring widgets/image.js. With a host-supplied
9
+ // `fontCatalog` it is a button showing the current face IN that face, opening
10
+ // the picker. Without one it degrades to a URL text field, so a standalone
11
+ // partforge app (which ships no catalog) still exposes the parameter.
12
+ //
13
+ // Fonts are the "used as-is" kind: the ingest registry's `font` row has
14
+ // `convert: null` (a TTF/OTF is validated, never converted), so unlike
15
+ // image.js/svg there is no converter to warm up here — see makeFont's own
16
+ // note below.
9
17
  import { attachInfo } from "../info.js";
10
18
  import { FONT_ALLOW_DEFAULT, fontSourceAllowed } from "../../font-source.js";
19
+ import { mountDrop } from "./file-drop.js";
11
20
 
12
21
  function el(tag, className, text) {
13
22
  const node = document.createElement(tag);
@@ -16,6 +25,8 @@ function el(tag, className, text) {
16
25
  return node;
17
26
  }
18
27
 
28
+ const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
29
+
19
30
  const WEIGHTS = { 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular", 500: "Medium",
20
31
  600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black" };
21
32
  export const variantLabel = (v) => {
@@ -41,7 +52,7 @@ export function fontLabel(source) {
41
52
  return { family, variant: m ? m[2] : null };
42
53
  }
43
54
 
44
- export function makeFont(node, params, { onChange, onCommit, info, fontCatalog } = {}) {
55
+ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog, onAssetUpload } = {}) {
45
56
  const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : FONT_ALLOW_DEFAULT;
46
57
  const wrap = el("div", "slider");
47
58
  const row = el("div", "row");
@@ -57,7 +68,16 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog }
57
68
  const field = document.createElement("input");
58
69
  field.type = "text";
59
70
  field.className = "text-input";
60
- field.value = String(params[node.key] ?? "");
71
+ const paintField = () => {
72
+ const v = params[node.key];
73
+ // Bytes cannot round-trip through a text field — `String(arrayBuffer)`
74
+ // is "[object ArrayBuffer]", not a value anyone typed. Show an honest
75
+ // placeholder instead of that, and leave the field free to type a
76
+ // replacement URL over it. Mirrors widgets/image.js's paintField.
77
+ field.value = isBytes(v) ? "" : String(v ?? "");
78
+ field.placeholder = isBytes(v) ? "Uploaded font" : "";
79
+ field.classList.remove("warn");
80
+ };
61
81
  field.addEventListener("change", () => {
62
82
  if (!fontSourceAllowed(field.value, allow)) { field.classList.add("warn"); return; }
63
83
  field.classList.remove("warn");
@@ -65,8 +85,15 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog }
65
85
  onChange?.();
66
86
  onCommit?.();
67
87
  });
88
+ paintField();
68
89
  wrap.append(field);
69
- return { el: wrap, sync: () => { field.value = String(params[node.key] ?? ""); field.classList.remove("warn"); } };
90
+
91
+ const drop = mountDrop("font", {
92
+ params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
93
+ });
94
+ wrap.append(drop.el, drop.errorEl);
95
+
96
+ return { el: wrap, sync: paintField, dispose: () => drop.dispose() };
70
97
  }
71
98
 
72
99
  const btn = el("button", "font-btn");
@@ -83,6 +110,11 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog }
83
110
  // back to the filename — which is right for a vendored `<family>-<variant>.ttf`
84
111
  // and merely ugly for a hash. `describe` is optional and may be async, so the
85
112
  // label is painted twice: filename immediately, catalog answer when it lands.
113
+ //
114
+ // A byte-valued param (the cloud sandbox path, dropped or uploaded) has no
115
+ // filename to derive a label from — `fontLabel` would print "—" for it, so
116
+ // it is special-cased to an honest "Uploaded font" instead, the same rule
117
+ // widgets/image.js applies for a byte-valued image.
86
118
  let paintSeq = 0;
87
119
  const paint = () => {
88
120
  const src = params[node.key];
@@ -93,7 +125,7 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog }
93
125
  fvar.textContent = variantLabel(variant);
94
126
  fname.style.fontFamily = `"${family}", var(--pf-sans)`;
95
127
  };
96
- show(fontLabel(src));
128
+ show(isBytes(src) ? { family: "Uploaded font", variant: null } : fontLabel(src));
97
129
  if (typeof fontCatalog.describe !== "function") return;
98
130
  Promise.resolve()
99
131
  .then(() => fontCatalog.describe(src))
@@ -114,7 +146,14 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog }
114
146
  picker = openFontPicker?.({ node, params, allow, fontCatalog, anchor: wrap, onPicked: () => { paint(); onChange?.(); onCommit?.(); } }) ?? null;
115
147
  });
116
148
 
117
- return { el: wrap, sync: paint, dispose: () => { picker?.close(); picker = null; } };
149
+ const drop = mountDrop("font", { params, node, onAssetUpload, onChange, onCommit, onRender: paint });
150
+ wrap.append(drop.el, drop.errorEl);
151
+
152
+ return {
153
+ el: wrap,
154
+ sync: paint,
155
+ dispose: () => { picker?.close(); picker = null; drop.dispose(); },
156
+ };
118
157
  }
119
158
 
120
159
  // Assigned by font-picker.js, which widgets/index.js imports for the side