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.
- package/docs/AUTHORING-PARTS.md +138 -1
- package/package.json +8 -1
- package/src/framework/app.css +50 -2
- package/src/framework/lint/rules-schema.js +56 -0
- package/src/framework/lint/source-scan.js +116 -2
- package/src/framework/mount.js +21 -2
- package/src/framework/panel/author.js +5 -0
- package/src/framework/panel/json-value.js +55 -0
- package/src/framework/panel/render.js +119 -36
- package/src/framework/panel/scoped-params.js +52 -0
- package/src/framework/panel/widget-specs.js +2 -0
- package/src/framework/panel/widgets/custom.js +253 -0
- package/src/framework/panel/widgets/index.js +2 -0
- package/src/panel-values.js +6 -0
- package/types/index.d.ts +66 -0
- package/types/panel-values.d.ts +18 -0
- package/types/part.d.ts +10 -1
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -745,6 +745,7 @@ Every control accepts `key`, `type`, `label`, `description`, `hidden`, `when` an
|
|
|
745
745
|
| `"font"` | a typeface picker with a catalog, else a drop target | `allow`, `preview`, `sourceField` |
|
|
746
746
|
| `"image"` | an image picker with a catalog, else a drop target showing the artwork | `allow`, `sourceField` |
|
|
747
747
|
| `"vector"` | a drop target showing the artwork — no catalog exists | `sourceField` |
|
|
748
|
+
| `"custom"` | a widget the part draws itself — see "Custom controls" below | `widget`, `keys` |
|
|
748
749
|
|
|
749
750
|
Numeric controls always show the number box: drag the slider *or* type an exact
|
|
750
751
|
value. Typed values may be finer than `step` and may sit **outside `[min, max]`**:
|
|
@@ -954,6 +955,132 @@ pickers in one section both need to show divergence, give each its own section.
|
|
|
954
955
|
case per preset name (so every preset gets measured), and a repeated name throws
|
|
955
956
|
there. `duplicate-preset-name` catches it at lint time instead.
|
|
956
957
|
|
|
958
|
+
### Custom controls
|
|
959
|
+
|
|
960
|
+
A `type: "custom"` control renders a widget the part draws itself — a clickable hex
|
|
961
|
+
grid, an organizer whose walls toggle on click, a picture of the part's own outline
|
|
962
|
+
with hot regions. Reach for it when the user configures **many similar things
|
|
963
|
+
individually**, or makes a **spatial choice** no slider or select can express.
|
|
964
|
+
Never for a value an existing control already covers: a slider is still a slider.
|
|
965
|
+
|
|
966
|
+
```js
|
|
967
|
+
// part.js
|
|
968
|
+
import { tilePicker } from "./tile-picker.js";
|
|
969
|
+
|
|
970
|
+
export default {
|
|
971
|
+
defaults: {
|
|
972
|
+
tileSize: 20,
|
|
973
|
+
tiles: [{ q: 0, r: 0, height: 10 }, { q: 1, r: 0, height: 14 }],
|
|
974
|
+
},
|
|
975
|
+
parameters: [{
|
|
976
|
+
title: "Tiles",
|
|
977
|
+
controls: [
|
|
978
|
+
{ type: "slider", key: "tileSize", min: 10, max: 40 },
|
|
979
|
+
{ type: "custom", key: "tiles", label: "Tile layout", widget: tilePicker,
|
|
980
|
+
description: "Click a tile to select it. Drag to move it." },
|
|
981
|
+
],
|
|
982
|
+
}],
|
|
983
|
+
parts: { tiles: { build: (k, p) => /* p.tiles is the array */ } },
|
|
984
|
+
};
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
- `key` names the param the widget owns. It may hold a **JSON value**: numbers,
|
|
988
|
+
strings, booleans, arrays and plain objects of those, at most 16 KB serialized and
|
|
989
|
+
8 levels deep, never `null` (`custom-default-not-json`). `build()` reads it like any
|
|
990
|
+
other param. This is the one place the "a control writes one scalar" rule is relaxed.
|
|
991
|
+
- `keys` (optional) lists further scalar params the widget may also write.
|
|
992
|
+
- `widget` is a function `(host) => …`, normally imported from a sibling file so
|
|
993
|
+
`part.js` stays readable (`custom-control-widget-not-function`).
|
|
994
|
+
- `label`, `description`, `hidden`, `when` and `whenFalse` behave as on any control.
|
|
995
|
+
|
|
996
|
+
**The widget function.** It runs once per mount and draws into `host.el` with plain
|
|
997
|
+
DOM and SVG. It may return `{ update, dispose }`.
|
|
998
|
+
|
|
999
|
+
```js
|
|
1000
|
+
// tile-picker.js
|
|
1001
|
+
export function tilePicker(host) {
|
|
1002
|
+
const svg = host.h("svg", { viewBox: "0 0 200 160", width: "100%" });
|
|
1003
|
+
const detail = host.h("div");
|
|
1004
|
+
host.el.append(svg, detail);
|
|
1005
|
+
let stopDetail = null;
|
|
1006
|
+
|
|
1007
|
+
function draw() {
|
|
1008
|
+
const tiles = host.get(); // a fresh clone of the owned value
|
|
1009
|
+
const sel = host.state.selected ?? null; // survives the remount an edit performs
|
|
1010
|
+
svg.replaceChildren(...tiles.map((t, i) => host.h("polygon", {
|
|
1011
|
+
points: hexPoints(t.q, t.r), class: i === sel ? "pf-hit selected" : "pf-hit",
|
|
1012
|
+
onpointerdown: () => { host.setState({ selected: i }); draw(); },
|
|
1013
|
+
})));
|
|
1014
|
+
stopDetail?.();
|
|
1015
|
+
stopDetail = sel === null ? null : host.controls(detail, [
|
|
1016
|
+
{ type: "slider", key: "height", label: "Height", min: 4, max: 30 },
|
|
1017
|
+
], { path: `${sel}` });
|
|
1018
|
+
}
|
|
1019
|
+
draw();
|
|
1020
|
+
return { update: draw, dispose: () => stopDetail?.() };
|
|
1021
|
+
}
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
**The host.**
|
|
1025
|
+
|
|
1026
|
+
| Member | Meaning |
|
|
1027
|
+
|---|---|
|
|
1028
|
+
| `host.el`, `host.doc` | The slot to draw into, and its document. |
|
|
1029
|
+
| `host.get(key?)` | A clone of the current value (default: the owned key). Any param is readable. |
|
|
1030
|
+
| `host.set(value, {key?, commit?})` | Replace a value with a **new** one — never mutate what `get` returned. Schedules the rebuild and commits, unless `commit: false`; then call `host.commit()` when the gesture ends (a drag). Throws for a key you do not own or a value outside the contract. On a **retired** widget (one whose code already threw) `set`, `commit` and `setState` are silent no-ops. |
|
|
1031
|
+
| `host.commit(keys?)` | Ends a deferred gesture. A no-op when nothing changed. |
|
|
1032
|
+
| `host.derived` | The latest `derive()` output, the same object readouts show. |
|
|
1033
|
+
| `host.state`, `host.setState(patch)` | Transient JSON (a selection, an open panel). Not a param, never persisted, but it **survives a remount**, which every edit performs. |
|
|
1034
|
+
| `host.controls(container, controls, {path})` | Mount ordinary built-in controls bound *inside* the owned value at a dotted `path` (`"3"`, `"walls.north"`). Their edits commit the owning key. Returns a disposer. One level: no custom control inside, and no `font`, `image` or `vector` sub-control at a path (their asset lookups are keyed on the part's real param names). |
|
|
1035
|
+
| `host.h(tag, attrs, ...children)` | Element builder. SVG tags get the SVG namespace; `on<event>` attrs become listeners; `class` and `style` pass through. |
|
|
1036
|
+
| `host.svg(text)` | Parse an SVG string to an element you can append and wire up. |
|
|
1037
|
+
| `host.svgFromVector(doc)` | A partforge-vector document → inline `<svg>` (the same renderer the `vector` control uses). |
|
|
1038
|
+
| `host.file(pathOrToken)` | Text of one of the part's own files, by path or a `pfc-tree://` token, or null. |
|
|
1039
|
+
| `host.disabled` | Whether a `when`/`whenFalse: "disable"` currently disables this control. |
|
|
1040
|
+
|
|
1041
|
+
`update({reason, disabled})` is called when your key changes from outside the widget
|
|
1042
|
+
(`reason: "sync"` — a preset, undo, `setParams`), when `derived` changes
|
|
1043
|
+
(`"derived"`), and once after `host.state` was restored on mount (`"restore"`). It is
|
|
1044
|
+
**not** called for your own `set`. `dispose()` runs on teardown.
|
|
1045
|
+
|
|
1046
|
+
**Rules that keep it working.**
|
|
1047
|
+
|
|
1048
|
+
1. Size SVG with `viewBox` plus `width: 100%`. The rail is 288px wide by default and
|
|
1049
|
+
narrower on a phone, where it is the bottom sheet.
|
|
1050
|
+
2. Use pointer events, not mouse events, and put `class="pf-drag"` (or
|
|
1051
|
+
`touch-action: none`) on anything dragged — otherwise a finger scrolls the sheet.
|
|
1052
|
+
3. Keep selection and similar state in `host.state`, not in a closure: every edit
|
|
1053
|
+
remounts the part and your function runs again.
|
|
1054
|
+
4. Hand `set` a new value. `get` returns a clone precisely so the stored value is never
|
|
1055
|
+
edited in place.
|
|
1056
|
+
5. A throw in creation, `update`, or a listener installed through `host.h` replaces
|
|
1057
|
+
the widget with an error card and reports it (a hosting agent sees it in the apply
|
|
1058
|
+
result as `panelErrors`); other controls keep working. A throw in `dispose` is
|
|
1059
|
+
reported the same way but leaves no card — there is nothing left to show it on. A
|
|
1060
|
+
listener you attach yourself with `addEventListener`, rather than through `host.h`,
|
|
1061
|
+
is **not** guarded — wire listeners through `host.h`, or wrap your own in try/catch.
|
|
1062
|
+
|
|
1063
|
+
**Looking native.** The slot inherits the rail's font, colours and light/dark theme.
|
|
1064
|
+
Bare `<button>`, `<input>` and `<select>` elements pick up the built-in looks
|
|
1065
|
+
automatically; the built-in classes are available by name for the exact thing:
|
|
1066
|
+
`row`, `seg` (a segmented row of buttons), `action`, `ghost`, `num`, `text-input`,
|
|
1067
|
+
`select-input`; and for SVG, `pf-hit` (clickable, with a `selected` state) and
|
|
1068
|
+
`pf-drag`. Sub-controls mounted through `host.controls` *are* the built-in widgets.
|
|
1069
|
+
|
|
1070
|
+
**Reading the part's own files.** Artwork can live beside the code (the tree is text,
|
|
1071
|
+
so an SVG, a `partforge-vector` JSON document or a JSON data file — not a PNG).
|
|
1072
|
+
Two routes: store the SVG as a string in a JS module (`assets/emblem.svg.js` exporting
|
|
1073
|
+
a template literal) and import it like any sibling file — no framework support
|
|
1074
|
+
needed; or read it with `host.file("assets/emblem.svg")` and inline it with
|
|
1075
|
+
`host.svg(text)`. Give regions ids and wire `pointerdown` on `#wall-3` to toggle
|
|
1076
|
+
`walls[3]` in the owned value.
|
|
1077
|
+
|
|
1078
|
+
**What a widget cannot do.** Never `fetch` or otherwise reach the network — a widget
|
|
1079
|
+
is a pure function of the part and its params, and a hosted sandbox may refuse the
|
|
1080
|
+
request or have no credentials to make it with. Also: no imports beyond the part's
|
|
1081
|
+
own files, nothing outside `host.el`, and no reading of `params` except through
|
|
1082
|
+
`host`.
|
|
1083
|
+
|
|
957
1084
|
### Legacy section shapes (still supported)
|
|
958
1085
|
|
|
959
1086
|
Everything above is what a **new part should write**. The original array-based shapes
|
|
@@ -1143,6 +1270,10 @@ defaulting everything to a slider:
|
|
|
1143
1270
|
reason for a two-position slider to exist.
|
|
1144
1271
|
- **A computed value the user should see but not set** → `"readout"`. It costs no
|
|
1145
1272
|
parameter and answers the "so what did that do?" question in place.
|
|
1273
|
+
- **Many similar things configured individually, or a spatial choice** (which tiles
|
|
1274
|
+
exist and how tall each is; which walls of an organizer are present) →
|
|
1275
|
+
`"custom"`: a widget the part draws, holding one JSON value. See "Custom controls"
|
|
1276
|
+
above; use it only when no built-in control expresses the choice.
|
|
1146
1277
|
|
|
1147
1278
|
Then gate what doesn't always apply. A control that is meaningless in the current mode
|
|
1148
1279
|
should carry a **`when`** rather than sit there inert — hide it by default, or use
|
|
@@ -2791,7 +2922,9 @@ previously didn't; that's the fix working as intended, not a regression.
|
|
|
2791
2922
|
`preset-key-not-in-defaults`, `mixed-section-shape`,
|
|
2792
2923
|
`duplicate-preset-name`, `duplicate-node-id`, `select-options-missing`,
|
|
2793
2924
|
`select-default-not-in-options`, `log-scale-needs-positive-min`,
|
|
2794
|
-
`when-key-not-in-defaults`, `when-unknown-operator`, `unknown-control-type
|
|
2925
|
+
`when-key-not-in-defaults`, `when-unknown-operator`, `unknown-control-type`,
|
|
2926
|
+
`custom-control-widget-not-function`, `custom-default-not-json`,
|
|
2927
|
+
`custom-keys-not-in-defaults` (errors);
|
|
2795
2928
|
`slider-range-excludes-default`, `unknown-control-field`, `duplicate-control-key`,
|
|
2796
2929
|
`default-not-exposed`, `readout-unknown-derived-key`, `slider-refinement-invalid`,
|
|
2797
2930
|
`group-depth`, `section-too-many-controls` (warnings).
|
|
@@ -2802,6 +2935,10 @@ can't coexist, since mixing them would make the render order arbitrary. Move
|
|
|
2802
2935
|
the legacy entries into `controls` (a toggle becomes a checkbox control,
|
|
2803
2936
|
`advanced` becomes a nested group, `presets` becomes `{ type: "preset" }`
|
|
2804
2937
|
nodes), or drop `controls` and stay legacy.
|
|
2938
|
+
`custom-default-not-json` is `control-default-not-primitive`'s counterpart for a
|
|
2939
|
+
`type: "custom"` control, whose key may hold a JSON value (see "Custom controls"):
|
|
2940
|
+
it names the first member that is not one — `null`, a function, a class instance,
|
|
2941
|
+
a forbidden key, or a value past the 16 KB / depth-8 caps.
|
|
2805
2942
|
`duplicate-preset-name` fires when the same preset name is declared twice
|
|
2806
2943
|
(legacy `presets` and/or `{ type: "preset" }` nodes both count) — preset names
|
|
2807
2944
|
are global to the part, and `verify()` expands one case per name and throws on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.114.0",
|
|
4
4
|
"description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -58,6 +58,10 @@
|
|
|
58
58
|
"types": "./types/ingest.d.ts",
|
|
59
59
|
"default": "./src/ingest.js"
|
|
60
60
|
},
|
|
61
|
+
"./panel-values": {
|
|
62
|
+
"types": "./types/panel-values.d.ts",
|
|
63
|
+
"default": "./src/panel-values.js"
|
|
64
|
+
},
|
|
61
65
|
"./tokens.css": "./src/framework/tokens.css",
|
|
62
66
|
"./chrome.css": "./src/framework/chrome.css"
|
|
63
67
|
},
|
|
@@ -83,6 +87,9 @@
|
|
|
83
87
|
],
|
|
84
88
|
"ingest": [
|
|
85
89
|
"./types/ingest.d.ts"
|
|
90
|
+
],
|
|
91
|
+
"panel-values": [
|
|
92
|
+
"./types/panel-values.d.ts"
|
|
86
93
|
]
|
|
87
94
|
}
|
|
88
95
|
},
|
package/src/framework/app.css
CHANGED
|
@@ -64,8 +64,10 @@ canvas { display: block; }
|
|
|
64
64
|
Condition-hidden sections (.hidden, a `when` that evaluated false) are excluded
|
|
65
65
|
for the same reason. Do not simplify this to `.section:first-child { border-top: 0 }` — that
|
|
66
66
|
matches DOM position rather than visibility, and leaves a stray divider
|
|
67
|
-
floating at the top whenever applyRelevance hides the first section.
|
|
68
|
-
|
|
67
|
+
floating at the top whenever applyRelevance hides the first section. A bare
|
|
68
|
+
sub-panel section (host.controls inside a custom control) never gets the divider
|
|
69
|
+
because the widget owns its framing. */
|
|
70
|
+
.section:not(.section-hidden):not(.hidden) ~ .section:not(.section-hidden):not(.hidden):not(.bare) {
|
|
69
71
|
border-top: 1px solid var(--pf-border);
|
|
70
72
|
}
|
|
71
73
|
/* Section disclosure header: the whole row is the click target (render.js
|
|
@@ -382,6 +384,52 @@ button.ghost { background: transparent; border: 1px solid var(--pf-border); colo
|
|
|
382
384
|
button.ghost:hover:not(:disabled) { background: var(--pf-surface-2); }
|
|
383
385
|
button.action:disabled { opacity: .5; cursor: default; }
|
|
384
386
|
|
|
387
|
+
/* Custom controls (type: "custom"): a part-authored widget in the rail. The
|
|
388
|
+
slot inherits the rail's cascade (font, colours, --pf-* tokens); these rules
|
|
389
|
+
give BARE elements the built-in controls' look so a widget written with
|
|
390
|
+
plain <button>/<input>/<select> still reads as native, and provide two
|
|
391
|
+
opt-in classes for SVG: .pf-hit (a clickable region) and .pf-drag (a
|
|
392
|
+
dragged one — touch-action: none is what lets a finger drag it on a phone
|
|
393
|
+
instead of scrolling the sheet). */
|
|
394
|
+
.pf-custom { margin: 9px 0; }
|
|
395
|
+
.pf-custom.hidden { display: none; }
|
|
396
|
+
.pf-custom-slot { display: flow-root; }
|
|
397
|
+
/* Every selector in this block is wrapped so the WHOLE thing resolves to
|
|
398
|
+
(0,0,0) — including the :not([type=...]) chain, which is otherwise an
|
|
399
|
+
attribute selector per :not() and would still out-specify a class rule
|
|
400
|
+
like .text-input (0,1,0) or .row .num (0,2,0). At true zero specificity,
|
|
401
|
+
any class rule wins, so a named built-in class (.seg button, .text-input,
|
|
402
|
+
select.select-input, …) always beats these bare-element defaults. */
|
|
403
|
+
:where(.pf-custom) :where(svg) { display: block; max-width: 100%; height: auto; }
|
|
404
|
+
:where(.pf-custom) :where(button) {
|
|
405
|
+
font: inherit; font-size: 12px; padding: 5px 9px; cursor: pointer;
|
|
406
|
+
background: transparent; border: 1px solid var(--pf-border); color: var(--pf-text-2);
|
|
407
|
+
border-radius: var(--pf-radius-control);
|
|
408
|
+
}
|
|
409
|
+
:where(.pf-custom) :where(button:hover:not(:disabled)) { background: var(--pf-surface-2); }
|
|
410
|
+
:where(.pf-custom) :where(button:disabled) { opacity: .5; cursor: default; }
|
|
411
|
+
:where(.pf-custom) :where(input:not([type="range"]):not([type="checkbox"]):not([type="radio"]), select) {
|
|
412
|
+
font: 12px/1.4 var(--pf-mono);
|
|
413
|
+
background: var(--pf-input-bg); color: var(--pf-text-strong);
|
|
414
|
+
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); padding: 6px 8px;
|
|
415
|
+
}
|
|
416
|
+
:where(.pf-custom) :where(input:focus, select:focus, button:focus-visible) {
|
|
417
|
+
outline: none; border-color: var(--pf-accent);
|
|
418
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
|
|
419
|
+
}
|
|
420
|
+
.pf-custom .pf-hit { cursor: pointer; }
|
|
421
|
+
.pf-custom .pf-hit:hover { fill: color-mix(in oklab, var(--pf-accent) 25%, transparent); }
|
|
422
|
+
.pf-custom .pf-hit.selected { fill: color-mix(in oklab, var(--pf-accent) 45%, transparent); stroke: var(--pf-accent); }
|
|
423
|
+
.pf-custom .pf-drag { touch-action: none; }
|
|
424
|
+
.pf-custom-error {
|
|
425
|
+
margin: 4px 0; padding: 6px 8px; border: 1px solid var(--pf-err); border-radius: var(--pf-radius-control);
|
|
426
|
+
color: var(--pf-err); font-family: var(--pf-mono); font-size: 11px; line-height: 1.4; word-break: break-word;
|
|
427
|
+
}
|
|
428
|
+
.pf-custom-error-title { font-weight: 600; margin-bottom: 2px; }
|
|
429
|
+
/* A sub-panel inside a custom control (host.controls): no header, no indent. */
|
|
430
|
+
.section.bare > .sec-body { padding: 0; }
|
|
431
|
+
.section.bare { padding: 0; border: 0; }
|
|
432
|
+
|
|
385
433
|
.dl { margin-top: 14px; }
|
|
386
434
|
.dl-head {
|
|
387
435
|
font-family: var(--pf-mono); font-size: 10px; font-weight: 600;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// resolve against `defaults`, which produce a control that silently does nothing.
|
|
5
5
|
import { err, warn } from "./finding.js";
|
|
6
6
|
import { suggest } from "../geometry/op-options.js";
|
|
7
|
+
import { jsonValueProblem } from "../panel/json-value.js";
|
|
7
8
|
import { fieldsFor, authorFieldsFor, WIDGET_TYPES, GROUP_FIELDS, PRESET_FIELDS, SECTION_FIELDS, normalizeOptions } from "../panel/widget-specs.js";
|
|
8
9
|
import { sectionRenders, desugar } from "../panel/legacy.js";
|
|
9
10
|
import { buildTree, WHEN_OPS } from "../panel/model.js";
|
|
@@ -253,6 +254,9 @@ export const SCHEMA_RULES = [
|
|
|
253
254
|
const defaults = part.defaults;
|
|
254
255
|
return collectDescriptors(part)
|
|
255
256
|
.filter(({ container }) => !container)
|
|
257
|
+
// A custom control's OWN key may hold a JSON value; custom-default-not-json
|
|
258
|
+
// (below) is its rule. Its `keys` stay under this one via their own controls.
|
|
259
|
+
.filter(({ d }) => d.type !== "custom")
|
|
256
260
|
.filter(({ d }) => typeof d.key === "string" && Object.hasOwn(defaults, d.key))
|
|
257
261
|
.filter(({ d }) => !isEditableValue(defaults[d.key]))
|
|
258
262
|
.map(({ d, path }) => err("control-default-not-primitive",
|
|
@@ -625,4 +629,56 @@ export const SCHEMA_RULES = [
|
|
|
625
629
|
return out;
|
|
626
630
|
},
|
|
627
631
|
},
|
|
632
|
+
{
|
|
633
|
+
// The whole feature is the function: a missing or non-function `widget`
|
|
634
|
+
// renders the error card in the rail and nothing else, every time.
|
|
635
|
+
id: "custom-control-widget-not-function",
|
|
636
|
+
run: ({ part }) => collectDescriptors(part)
|
|
637
|
+
.filter(({ container, authored, d }) => authored && !container && d.type === "custom" && typeof d.widget !== "function")
|
|
638
|
+
.map(({ d, path }) => err("custom-control-widget-not-function",
|
|
639
|
+
`custom control "${d.key}" has no \`widget\` function`,
|
|
640
|
+
"Set `widget` to a function `(host) => …` — usually imported from a sibling file of the part — that draws into `host.el`. See \"Custom controls\" in AUTHORING-PARTS.md.",
|
|
641
|
+
`${path}.widget`)),
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
// The one relaxation of control-default-not-primitive: a custom control's
|
|
645
|
+
// key may hold a JSON value (json-value.js), and nothing else — a host that
|
|
646
|
+
// persists panel settings writes it back as a JSON literal.
|
|
647
|
+
id: "custom-default-not-json",
|
|
648
|
+
run: ({ part }) => {
|
|
649
|
+
if (!isPlainObject(part?.defaults)) return [];
|
|
650
|
+
const defaults = part.defaults;
|
|
651
|
+
return collectDescriptors(part)
|
|
652
|
+
.filter(({ container, authored, d }) => authored && !container && d.type === "custom")
|
|
653
|
+
.filter(({ d }) => typeof d.key === "string" && Object.hasOwn(defaults, d.key))
|
|
654
|
+
.map(({ d }) => ({ d, problem: jsonValueProblem(defaults[d.key]) }))
|
|
655
|
+
.filter(({ problem }) => problem !== null)
|
|
656
|
+
.map(({ d, problem }) => err("custom-default-not-json",
|
|
657
|
+
`\`defaults.${d.key}\` ${problem}`,
|
|
658
|
+
`Give "${d.key}" a JSON value: numbers, strings, booleans, arrays and plain objects of those, at most 16 KB and 8 levels deep, with no null. A custom control stores and persists its value as JSON, so anything else is silently lost on reload.`,
|
|
659
|
+
`defaults.${d.key}`));
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
// `keys` are params the widget writes besides its own; each needs a
|
|
664
|
+
// default or the write lands on a key the build never reads.
|
|
665
|
+
id: "custom-keys-not-in-defaults",
|
|
666
|
+
run: ({ part }) => {
|
|
667
|
+
if (!isPlainObject(part?.defaults)) return [];
|
|
668
|
+
const known = defaultKeys(part);
|
|
669
|
+
const out = [];
|
|
670
|
+
for (const { d, path, container, authored } of collectDescriptors(part)) {
|
|
671
|
+
if (!authored || container || d.type !== "custom" || !Array.isArray(d.keys)) continue;
|
|
672
|
+
d.keys.forEach((k, i) => {
|
|
673
|
+
if (typeof k === "string" && known.has(k)) return;
|
|
674
|
+
const hint = typeof k === "string" ? suggest(k, [...known]) : null;
|
|
675
|
+
out.push(err("custom-keys-not-in-defaults",
|
|
676
|
+
`custom control "${d.key}" lists key "${k}", which is not in \`defaults\``,
|
|
677
|
+
`Add "${k}" to \`defaults\`${hint ? `, or correct it to "${hint}"` : ""} — a key the widget writes must exist for the build to read it.`,
|
|
678
|
+
`${path}.keys[${i}]`));
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
return out;
|
|
682
|
+
},
|
|
683
|
+
},
|
|
628
684
|
];
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
// closure (test/lint-purity.test.js) so lintPart keeps running in Node, the
|
|
13
13
|
// browser sandbox iframe, and Deno.
|
|
14
14
|
//
|
|
15
|
+
// The one exception is json-value.js, which itself imports nothing — the
|
|
16
|
+
// shared predicate for a `type: "custom"` control's value.
|
|
17
|
+
//
|
|
15
18
|
// KNOWN BLIND SPOT — regex literals are not tokenized: a `/[/*]/` or `/x\/y/`
|
|
16
19
|
// reads as a comment opener and can blank the rest of the file, and a regex
|
|
17
20
|
// containing a quote can likewise derail string skipping.
|
|
@@ -19,6 +22,8 @@
|
|
|
19
22
|
// FALSE NEGATIVES — a derailed scan finds no defaults literal and no tokens,
|
|
20
23
|
// so a rule says nothing rather than something wrong.
|
|
21
24
|
|
|
25
|
+
import { isJsonValue, CUSTOM_VALUE_MAX_DEPTH } from "../panel/json-value.js";
|
|
26
|
+
|
|
22
27
|
// Span of the object literal after the first `defaults:` key (indices into
|
|
23
28
|
// `source`, end exclusive, covering `{...}`). String- and comment-aware so a
|
|
24
29
|
// "defaults: {" inside a string or comment can't fool the scan.
|
|
@@ -191,8 +196,9 @@ function decodeStringLiteral(raw) {
|
|
|
191
196
|
// into a different spelling of themselves.
|
|
192
197
|
const NUMBER_RE = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
193
198
|
|
|
194
|
-
// One value's source text → { value }, or null when it is not a
|
|
195
|
-
//
|
|
199
|
+
// One value's source text → { value }, or null when it is not a literal this
|
|
200
|
+
// module can read AND write back. Primitives first; then the JSON-literal
|
|
201
|
+
// grammar a `type: "custom"` control's value is written in.
|
|
196
202
|
function readValue(raw) {
|
|
197
203
|
if (raw === "true") return { value: true };
|
|
198
204
|
if (raw === "false") return { value: false };
|
|
@@ -205,6 +211,7 @@ function readValue(raw) {
|
|
|
205
211
|
const s = decodeStringLiteral(raw);
|
|
206
212
|
return s === null ? null : { value: s };
|
|
207
213
|
}
|
|
214
|
+
if (q === "[" || q === "{") return readJsonLiteral(raw);
|
|
208
215
|
return null;
|
|
209
216
|
}
|
|
210
217
|
|
|
@@ -220,6 +227,113 @@ function readKey(text, i, end) {
|
|
|
220
227
|
return m ? { key: m[0], next: i + m[0].length } : null;
|
|
221
228
|
}
|
|
222
229
|
|
|
230
|
+
// --- JSON literals ----------------------------------------------------------
|
|
231
|
+
//
|
|
232
|
+
// The grammar a custom control's owned value is spelled in inside `defaults`:
|
|
233
|
+
// object and array literals of primitives, nested; bare or quoted keys; JS
|
|
234
|
+
// string escapes (the same decoder as above); decimal numbers; true/false;
|
|
235
|
+
// trailing commas; whitespace. NOTHING else — no comments inside the span, no
|
|
236
|
+
// expressions, identifiers, templates, `null`, computed keys, spreads — so
|
|
237
|
+
// every value this reads, writeJsonLiteral can write back and this can read
|
|
238
|
+
// again. A parse failure is null, never a throw: the entry simply stays
|
|
239
|
+
// unreadable, exactly as an expression does.
|
|
240
|
+
//
|
|
241
|
+
// `depth` is threaded through value/array/object and checked BEFORE
|
|
242
|
+
// recursing into a container, so pathological nesting (thousands of `[`)
|
|
243
|
+
// fails fast during parsing instead of blowing the JS call stack — a
|
|
244
|
+
// RangeError would escape readJsonLiteral's try/catch (it only catches
|
|
245
|
+
// JsonLiteralError) and reach a host outside runRules' own try/catch. The
|
|
246
|
+
// threshold matches json-value.js's own depth check exactly (same "depth at
|
|
247
|
+
// which this container sits" numbering), so this is a fast-fail that agrees
|
|
248
|
+
// with the post-parse isJsonValue call below, never a stricter or looser one.
|
|
249
|
+
|
|
250
|
+
class JsonLiteralError extends Error {}
|
|
251
|
+
|
|
252
|
+
function jsonParser(text) {
|
|
253
|
+
let i = 0;
|
|
254
|
+
const fail = () => { throw new JsonLiteralError(); };
|
|
255
|
+
const ws = () => { while (i < text.length && /\s/.test(text[i])) i++; };
|
|
256
|
+
const value = (depth = 0) => {
|
|
257
|
+
ws();
|
|
258
|
+
const c = text[i];
|
|
259
|
+
if (c === "{") return object(depth);
|
|
260
|
+
if (c === "[") return array(depth);
|
|
261
|
+
if (c === '"' || c === "'") {
|
|
262
|
+
const end = skipQuoted(text, i, text.length);
|
|
263
|
+
const s = decodeStringLiteral(text.slice(i, end));
|
|
264
|
+
if (s === null) fail();
|
|
265
|
+
i = end;
|
|
266
|
+
return s;
|
|
267
|
+
}
|
|
268
|
+
const m = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/.exec(text.slice(i));
|
|
269
|
+
if (m) { i += m[0].length; const n = Number(m[0]); if (!Number.isFinite(n)) fail(); return n; }
|
|
270
|
+
if (text.startsWith("true", i)) { i += 4; return true; }
|
|
271
|
+
if (text.startsWith("false", i)) { i += 5; return false; }
|
|
272
|
+
return fail();
|
|
273
|
+
};
|
|
274
|
+
const array = (depth) => {
|
|
275
|
+
if (depth >= CUSTOM_VALUE_MAX_DEPTH) fail();
|
|
276
|
+
i++; // [
|
|
277
|
+
const out = [];
|
|
278
|
+
for (;;) {
|
|
279
|
+
ws();
|
|
280
|
+
if (text[i] === "]") { i++; return out; }
|
|
281
|
+
if (out.length) { if (text[i] !== ",") fail(); i++; ws(); if (text[i] === "]") { i++; return out; } }
|
|
282
|
+
out.push(value(depth + 1));
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const object = (depth) => {
|
|
286
|
+
if (depth >= CUSTOM_VALUE_MAX_DEPTH) fail();
|
|
287
|
+
i++; // {
|
|
288
|
+
const out = {};
|
|
289
|
+
let n = 0;
|
|
290
|
+
for (;;) {
|
|
291
|
+
ws();
|
|
292
|
+
if (text[i] === "}") { i++; return out; }
|
|
293
|
+
if (n) { if (text[i] !== ",") fail(); i++; ws(); if (text[i] === "}") { i++; return out; } }
|
|
294
|
+
const k = readKey(text, i, text.length);
|
|
295
|
+
if (!k) fail();
|
|
296
|
+
i = k.next;
|
|
297
|
+
ws();
|
|
298
|
+
if (text[i] !== ":") fail();
|
|
299
|
+
i++;
|
|
300
|
+
const v = value(depth + 1);
|
|
301
|
+
Object.defineProperty(out, k.key, { value: v, enumerable: true, writable: true, configurable: true });
|
|
302
|
+
n++;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
return { value, done: () => { ws(); return i === text.length; } };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// An array or object literal's source text → { value }, or null. Applies the
|
|
309
|
+
// custom-value caps (json-value.js) so a literal lint accepts is one the panel
|
|
310
|
+
// would accept too.
|
|
311
|
+
export function readJsonLiteral(text) {
|
|
312
|
+
if (typeof text !== "string") return null;
|
|
313
|
+
const first = text.trimStart()[0];
|
|
314
|
+
if (first !== "[" && first !== "{") return null;
|
|
315
|
+
try {
|
|
316
|
+
const p = jsonParser(text);
|
|
317
|
+
const v = p.value();
|
|
318
|
+
if (!p.done()) return null;
|
|
319
|
+
return isJsonValue(v) ? { value: v } : null;
|
|
320
|
+
} catch (e) {
|
|
321
|
+
if (e instanceof JsonLiteralError) return null;
|
|
322
|
+
throw e;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// The source text for a JSON value being written into `defaults`: compact
|
|
327
|
+
// when it fits on a line, else indented two spaces deeper than the entry's
|
|
328
|
+
// own indentation (`indent`, the whitespace before the entry's key) so a large
|
|
329
|
+
// layout diffs line by line. Always plain JSON — quoted keys — so the reader
|
|
330
|
+
// above accepts it unchanged.
|
|
331
|
+
export function writeJsonLiteral(value, { indent = "" } = {}) {
|
|
332
|
+
const compact = JSON.stringify(value);
|
|
333
|
+
if (compact.length <= 80) return compact;
|
|
334
|
+
return JSON.stringify(value, null, 2).replace(/\n/g, `\n${indent}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
223
337
|
// Split `{ … }` into entries in source order. `readable` says whether the
|
|
224
338
|
// value was interpreted; `raw` is its exact source text either way, and
|
|
225
339
|
// valueStart/valueEnd are its span (indices into `text`), which is what a
|
package/src/framework/mount.js
CHANGED
|
@@ -63,7 +63,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
|
|
|
63
63
|
// carries the worker's own error text. See the correlated "error" case below.
|
|
64
64
|
const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
|
|
65
65
|
|
|
66
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker }) {
|
|
66
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors }) {
|
|
67
67
|
return {
|
|
68
68
|
ready, dispose, setParams,
|
|
69
69
|
// Part-declared animation playback (spec 2026-08-02): animations are
|
|
@@ -101,6 +101,14 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
101
101
|
projection: viewer.getProjection?.() ?? "perspective",
|
|
102
102
|
cutaway: viewer.getCutawayState?.() ?? null,
|
|
103
103
|
}),
|
|
104
|
+
// Every custom control's transient state (selection, a scroll position),
|
|
105
|
+
// keyed by param, as plain JSON — the panel's twin of getViewerState. Hand
|
|
106
|
+
// it back as mount()'s `panelState` and a remount comes up with the same
|
|
107
|
+
// tile selected. {} when the mount resolved no panel.
|
|
108
|
+
getPanelState: getPanelState ?? (() => ({})),
|
|
109
|
+
// What custom controls reported failing this mount ({key, label, phase,
|
|
110
|
+
// message}), for a host to relay to whoever authored the part.
|
|
111
|
+
getPanelErrors: getPanelErrors ?? (() => []),
|
|
104
112
|
// Park/unpark the viewer: stops the render loop and frees the drawing
|
|
105
113
|
// buffer and the cached capture target. For an embedder that hides the
|
|
106
114
|
// canvas without unmounting it — `visibility: hidden`, an off-screen tab —
|
|
@@ -305,6 +313,13 @@ function createCleanupStack() {
|
|
|
305
313
|
// // first mount — the viewer then restores its own persisted
|
|
306
314
|
// // camera as before. Restore is best-effort per field: a pose
|
|
307
315
|
// // this part cannot support is dropped, never fatal.
|
|
316
|
+
// panelState: PanelState // a previous mount's runtime.getPanelState(): the transient
|
|
317
|
+
// // state of the part's custom controls (a selected tile), keyed
|
|
318
|
+
// // by param. Same remount story as viewerState; omit on a first
|
|
319
|
+
// // mount. Never persisted by partforge.
|
|
320
|
+
// files: { [path]: string } // the part's own source tree as text, for custom controls'
|
|
321
|
+
// // host.file(path) — an SVG or a vector document that lives
|
|
322
|
+
// // beside the code. Omit and host.file answers null.
|
|
308
323
|
// annotateSend: "viewbar" | "host" // who owns the Send affordance. "viewbar" (default) puts
|
|
309
324
|
// // Send in the sketch toolbar alongside the other tools.
|
|
310
325
|
// // "host" drops it: the host draws its own send control —
|
|
@@ -331,6 +346,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
331
346
|
imageCatalog,
|
|
332
347
|
onAssetUpload,
|
|
333
348
|
viewerState,
|
|
349
|
+
files, panelState,
|
|
334
350
|
annotateSend = "viewbar",
|
|
335
351
|
container: legacyContainer, controls: legacyControls } = {}) {
|
|
336
352
|
// --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
|
|
@@ -1017,7 +1033,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
1017
1033
|
// showing the bundled default instead of an empty tile. Rebuilt per panel
|
|
1018
1034
|
// build, because the declaration is a function of the current params.
|
|
1019
1035
|
{ fontCatalog, imageCatalog, onAssetUpload,
|
|
1020
|
-
declaredSource: declaredSourceLookup(part, params)
|
|
1036
|
+
declaredSource: declaredSourceLookup(part, params),
|
|
1037
|
+
files, panelState });
|
|
1021
1038
|
cleanup.defer(() => panel.dispose());
|
|
1022
1039
|
panelRef = panel;
|
|
1023
1040
|
const updateRelevance = () => {
|
|
@@ -1194,6 +1211,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
1194
1211
|
getView: view, // () => tabsCtl.current()
|
|
1195
1212
|
setView: (name) => tabsCtl.select(name),
|
|
1196
1213
|
captureView,
|
|
1214
|
+
getPanelState: () => panelRef?.getState() ?? {},
|
|
1215
|
+
getPanelErrors: () => panelRef?.errors() ?? [],
|
|
1197
1216
|
listExportableParts: () =>
|
|
1198
1217
|
exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
|
|
1199
1218
|
exportParts: (opts) => exportCtl.exportParts(opts),
|
|
@@ -44,6 +44,11 @@ function authoredControl(c) {
|
|
|
44
44
|
allow: c.allow,
|
|
45
45
|
preview: c.preview,
|
|
46
46
|
sourceField: c.sourceField,
|
|
47
|
+
// Custom controls (type: "custom"): the author's widget function and the
|
|
48
|
+
// extra scalar keys it may write. Both are on this allow-list for the same
|
|
49
|
+
// reason `allow` is (see above) — a field missing here is silently dropped.
|
|
50
|
+
widget: c.widget,
|
|
51
|
+
keys: c.keys,
|
|
47
52
|
preserveOn: false,
|
|
48
53
|
marksCustom: true,
|
|
49
54
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//
|
|
2
|
+
// The ONE predicate for the value a `type: "custom"` control may own. The panel
|
|
3
|
+
// (host.set, panel state), lint (custom-default-not-json) and partforge-cloud's
|
|
4
|
+
// persistence (through the `partforge/panel-values` export) all ask this module,
|
|
5
|
+
// so "the linter accepts it" and "the save can write it" cannot drift apart.
|
|
6
|
+
//
|
|
7
|
+
// Imports nothing: it sits inside partforge/lint's pure closure
|
|
8
|
+
// (test/lint-purity.test.js) and inside the sandbox iframe.
|
|
9
|
+
|
|
10
|
+
export const CUSTOM_VALUE_MAX_BYTES = 16384;
|
|
11
|
+
export const CUSTOM_VALUE_MAX_DEPTH = 8;
|
|
12
|
+
|
|
13
|
+
// Keys a JSON value may never carry: on a plain object each of these reaches
|
|
14
|
+
// Object.prototype, so a value that round-trips through JSON.parse and a
|
|
15
|
+
// plain-object assignment could change what `params.x.constructor` means.
|
|
16
|
+
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
17
|
+
|
|
18
|
+
const at = (path) => (path ? ` at ${path}` : "");
|
|
19
|
+
|
|
20
|
+
function shapeProblem(v, depth, maxDepth, path) {
|
|
21
|
+
if (v === null) return `is null${at(path)}`;
|
|
22
|
+
const t = typeof v;
|
|
23
|
+
if (t === "number") return Number.isFinite(v) ? null : `is a non-finite number${at(path)}`;
|
|
24
|
+
if (t === "string" || t === "boolean") return null;
|
|
25
|
+
if (t !== "object") return `is a ${t}${at(path)}`;
|
|
26
|
+
if (depth >= maxDepth) return `nests deeper than ${maxDepth} levels${at(path)}`;
|
|
27
|
+
if (Array.isArray(v)) {
|
|
28
|
+
for (let i = 0; i < v.length; i++) {
|
|
29
|
+
const p = shapeProblem(v[i], depth + 1, maxDepth, `${path}[${i}]`);
|
|
30
|
+
if (p) return p;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const proto = Object.getPrototypeOf(v);
|
|
35
|
+
if (proto !== Object.prototype && proto !== null) return `is not a plain object${at(path)}`;
|
|
36
|
+
for (const k of Object.keys(v)) {
|
|
37
|
+
if (FORBIDDEN_KEYS.has(k)) return `uses the forbidden key "${k}"${at(path)}`;
|
|
38
|
+
const p = shapeProblem(v[k], depth + 1, maxDepth, path ? `${path}.${k}` : k);
|
|
39
|
+
if (p) return p;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Why `v` is not a JSON value — a sentence fragment that reads after the
|
|
45
|
+
// value's name ("`defaults.tiles` is null at tiles[2].h") — or null when it is.
|
|
46
|
+
export function jsonValueProblem(v, { maxBytes = CUSTOM_VALUE_MAX_BYTES, maxDepth = CUSTOM_VALUE_MAX_DEPTH } = {}) {
|
|
47
|
+
const shape = shapeProblem(v, 0, maxDepth, "");
|
|
48
|
+
if (shape) return shape;
|
|
49
|
+
if (maxBytes === Infinity) return null;
|
|
50
|
+
const bytes = new TextEncoder().encode(JSON.stringify(v)).length;
|
|
51
|
+
if (bytes > maxBytes) return `is ${bytes} bytes serialized; the cap is ${maxBytes}`;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const isJsonValue = (v, opts) => jsonValueProblem(v, opts) === null;
|