partforge 0.102.0 → 0.104.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/README.md CHANGED
@@ -113,6 +113,15 @@ await runtime.ready; // first successful build (rejects on a first-build error
113
113
  runtime.setHostPane("rail"); // narrow layout only: show just the controls
114
114
  // rail ('stage' | 'rail'), suppressing the
115
115
  // built-in tab bar. null hands selection back.
116
+ runtime.setRailLayout({ mode: "dock", inset: 380, railHeight: 316 });
117
+ // where the rail SITS when the host draws chrome over
118
+ // the frame: docked into its bottom sheet (inset = the
119
+ // sheet's height, railHeight = the slice of it the rail
120
+ // may paint into), or { mode: "overlay" } for a
121
+ // right-edge drawer over a full-width stage. null (or
122
+ // any shape partforge can't read) restores partforge's
123
+ // own layout; resize and collapse are suspended while
124
+ // a layout is leased.
116
125
  runtime.setActive(false); // park the viewer: stop the render loop, release the
117
126
  // drawing buffer. setActive(true) restores both.
118
127
  runtime.attachTooltips([{ element: myButton }]); // host chrome buttons join the mount's
@@ -2057,8 +2057,17 @@ see "Wiring a part into a runnable app", above, for its full signature):
2057
2057
  **The `allow` list, and what bypasses it.** All three controls take the same
2058
2058
  `allow` list already documented for `"font"` in the control-types table above
2059
2059
  (`"https"` — the default; `"gstatic"`, font-only, `https://fonts.gstatic.com`
2060
- exactly; `"asset"`, a `pfc-asset://` token the host has stored for this part),
2061
- gating what a **param-supplied** value may be. It never restricts a source an
2060
+ exactly; `"asset"`, a `pfc-asset://` token the host has stored for this part;
2061
+ `"tree"`, vector-only, a `pfc-tree://` token naming artwork that lives as a
2062
+ **file inside the part itself** rather than in host storage), gating what a
2063
+ **param-supplied** value may be.
2064
+
2065
+ `"tree"` is vector-only by construction, not by omission: a part's files are
2066
+ text, so a `partforge-vector` JSON document can live in one and a PNG or a
2067
+ font cannot. Its payoff is that the artwork is versioned *with* the part —
2068
+ a host that stores parts as a file tree keeps the document in the same
2069
+ snapshot as the code that reads it, so publishing, history and undo carry the
2070
+ two together instead of leaving a param pointing at storage that moved on. It never restricts a source an
2062
2071
  author writes into `images`/`fonts`/`vectors` themselves — that's code, not
2063
2072
  user input.
2064
2073
 
@@ -2338,14 +2347,17 @@ yourself (e.g. to download from a different origin) instead of partforge's own D
2338
2347
  where `ImageAsset` is `{ id, label, url, width, height, thumbUrl }`. With no
2339
2348
  provider a `type: "image"` control degrades to a URL field.
2340
2349
 
2341
- - `onAssetUpload(blob, { kind, filename }) → Promise<string>` — the drop target
2342
- shared by `"image"`/`"vector"`/`"font"` controls (see "Getting files into a
2343
- part", below) calls this with the converted artifact after a drop, paste, or
2344
- file-picker choice, and writes whatever it resolves to into the param.
2345
- `blob` is the CONVERTED artifact — a PNG, a partforge-vector JSON blob, or the
2346
- original file for a font — never the user's raw drop; `kind` is `"image"`,
2347
- `"vector"`, or `"font"`. Must resolve to a non-empty source string (an
2348
- `https:` URL or a host-defined `pfc-asset:` token); anything else is treated
2350
+ - `onAssetUpload(blob, { kind, key, filename }) → Promise<string>` — the drop
2351
+ target shared by `"image"`/`"vector"`/`"font"` controls (see "Getting files
2352
+ into a part", below) calls this with the converted artifact after a drop,
2353
+ paste, or file-picker choice, and writes whatever it resolves to into the
2354
+ param. `blob` is the CONVERTED artifact — a PNG, a partforge-vector JSON blob,
2355
+ or the original file for a font — never the user's raw drop; `kind` is
2356
+ `"image"`, `"vector"`, or `"font"`; `key` is the param the drop landed on,
2357
+ which is what lets a host give each control a stable destination of its own
2358
+ rather than reconciling uploads against the part afterwards. Must resolve to
2359
+ a non-empty source string (an `https:` URL, or a host-defined `pfc-asset:` or
2360
+ `pfc-tree:` token); anything else is treated
2349
2361
  as a contract violation and reported through the control's own error line,
2350
2362
  not written into the param. Omit it and the converted bytes land straight in
2351
2363
  the param instead — the path a host that cannot fetch URLs (the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.102.0",
3
+ "version": "0.104.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",
@@ -454,7 +454,74 @@
454
454
  .pf-anim-bar { width: calc(100% - 24px); max-width: 520px; bottom: 64px; }
455
455
  }
456
456
 
457
-
457
+ /* ---- host-driven rail layouts (partforge-cloud) ---------------------------
458
+ data-pf-rail-layout is written by mobile-tabs.js from the host's
459
+ setRailLayout. Two modes, both host-only (nothing in partforge's own UI can
460
+ enter them):
461
+
462
+ DOCK — the host draws a bottom sheet over the iframe. --pf-rail-inset is the
463
+ sheet's full height: the shell pads by it so the stage (and its bottom
464
+ chrome) clears the sheet. With a "rail" lease the rail renders INTO the
465
+ sheet region — but only its bottom --pf-rail-dock-h px, because the host's
466
+ opaque chrome strip (handle + tabs) covers the top of the region. The spec
467
+ (2026-08-31-responsive-layout-redesign) has the two-numbers reasoning. Note
468
+ that a dock lease ABOVE 719px does not enter the block below at all: the
469
+ rail stays side by side with the stage at its usual --pf-rail-w (rail.js
470
+ keeps the width there, and only suspends resize/collapse/toggle).
471
+
472
+ OVERLAY — the rail is a right-edge drawer OVER the stage, open while
473
+ data-pf-rail-open is present (rail.js's toggle sets it; mobile-tabs.js's
474
+ stage pointerdown clears it). The stage never resizes. Width is its own
475
+ min(288px, 85%) — NOT --pf-rail-w, which rail.js zeroes under an overlay
476
+ lease at any window width (and under any layout below the breakpoint).
477
+
478
+ These come after the narrow block above because they override its one-pane
479
+ hiding, and before the reduced-motion block below so that block's
480
+ transition:none wins the tie at equal specificity. */
481
+ .pf-shell[data-pf-rail-layout="dock"] { padding-bottom: var(--pf-rail-inset, 0px); }
482
+ @media (max-width: 719px) {
483
+ /* pane=rail on a phone: stage above, rail docked in the sheet region. The
484
+ three rules below override the narrow block's one-pane hiding. */
485
+ .pf-shell[data-pf-rail-layout="dock"][data-pf-pane="rail"] { padding-bottom: 0; }
486
+ .pf-shell[data-pf-rail-layout="dock"][data-pf-pane="rail"] .pf-stage { display: block; }
487
+ .pf-shell[data-pf-rail-layout="dock"][data-pf-pane="rail"] .pf-rail {
488
+ display: flex;
489
+ flex: none;
490
+ height: var(--pf-rail-dock-h, 0px);
491
+ width: auto;
492
+ border-left: 0;
493
+ border-top: 1px solid var(--pf-border);
494
+ box-shadow: none;
495
+ }
496
+ }
497
+ .pf-shell[data-pf-rail-layout="dock"] .pf-rail-seam { display: none; }
498
+
499
+ /* visibility, not just the transform: collapse is suppressed under a host
500
+ layout (rail.js), so nothing else makes a shut drawer inert — it would sit
501
+ off-canvas as a run of invisible tab stops. The delay is what keeps it on
502
+ screen for the 200ms it takes to slide out; opening flips it back at once,
503
+ which is why the open state restates the transition without it. */
504
+ .pf-shell[data-pf-rail-layout="overlay"] .pf-stage { display: block; }
505
+ .pf-shell[data-pf-rail-layout="overlay"] .pf-rail {
506
+ display: flex;
507
+ position: absolute;
508
+ top: 0; right: 0; bottom: 0;
509
+ flex: none;
510
+ width: min(288px, 85%);
511
+ border-left: 1px solid var(--pf-border);
512
+ box-shadow: var(--pf-shadow-rail), -16px 0 32px rgb(0 0 0 / .35);
513
+ transform: translateX(102%);
514
+ visibility: hidden;
515
+ transition: transform 200ms ease, visibility 0s 200ms;
516
+ z-index: 30;
517
+ }
518
+ .pf-shell[data-pf-rail-layout="overlay"][data-pf-rail-open] .pf-rail {
519
+ transform: translateX(0);
520
+ visibility: visible;
521
+ transition: transform 200ms ease;
522
+ }
523
+ .pf-shell[data-pf-rail-layout="overlay"] .pf-rail-seam,
524
+ .pf-shell[data-pf-rail-layout="overlay"] .pf-tabbar { display: none; }
458
525
 
459
526
  /* ---- reduced motion -----------------------------------------------------
460
527
  Collapsing the rail slides 288px of layout across the screen — the first
@@ -465,6 +532,13 @@
465
532
  state indicator and is left alone. */
466
533
  @media (prefers-reduced-motion: reduce) {
467
534
  .pf-rail, .pf-rail-seam > span { transition: none; }
535
+ /* The overlay drawer states its own transition twice, at higher specificity
536
+ than the blanket rule above, so both states have to be named here or the
537
+ drawer would keep sliding. Killing the transition also drops the closing
538
+ visibility delay, which is right: with no slide there is nothing to wait
539
+ for, and the drawer leaves the a11y tree the moment it shuts. */
540
+ .pf-shell[data-pf-rail-layout="overlay"] .pf-rail,
541
+ .pf-shell[data-pf-rail-layout="overlay"][data-pf-rail-open] .pf-rail { transition: none; }
468
542
  }
469
543
 
470
544
  /* ---- annotation ink layer: a transparent 2D canvas over the viewer --------
@@ -4,7 +4,7 @@
4
4
  // this module writes onto .pf-shell; a missing attribute reads as "stage", so
5
5
  // the layout is already correct before any of this runs.
6
6
  //
7
- // Two independent reasons the bar can be absent, and they are deliberately
7
+ // Three independent reasons the bar can be absent, and they are deliberately
8
8
  // handled by different mechanisms:
9
9
  // * WIDTH — above the breakpoint the rail sits beside the viewer and no tab is
10
10
  // needed. That is pure CSS (.pf-tabbar { display: none }). There is no JS
@@ -12,6 +12,10 @@
12
12
  // * A HOST owns pane selection — partforge-cloud draws its own bottom bar at
13
13
  // the window level and drives this layout through setHostPane(). Then our
14
14
  // bar is `hidden` and only the host's choice writes data-pf-pane.
15
+ // * A HOST owns the rail's LAYOUT — setRailLayout() docks the rail into a
16
+ // bottom sheet or floats it as an overlay drawer, each with its own way in
17
+ // and out, so a pane tab would be a second, competing control. That lease
18
+ // is independent of the pane one: either alone hides the bar.
15
19
  //
16
20
  // State is in-memory: a fresh load starts on the stage, and nothing persists.
17
21
  // Which pane you are looking at right now is not a preference.
@@ -42,6 +46,26 @@ const ICONS = {
42
46
  const LABELS = { stage: "3D", rail: "Controls" };
43
47
  const PANES = ["stage", "rail"];
44
48
 
49
+ // A host-driven rail layout (partforge-cloud's bottom sheet / overlay drawer).
50
+ // null = partforge's own default layout. Normalization is strict: a shape this
51
+ // module does not recognize is a null, never a guess — the host is trusted to
52
+ // send well-formed layouts and a garbled one must not wedge the shell.
53
+ const MAX_RAIL_INSET = 4096;
54
+
55
+ function normalizeRailLayout(next) {
56
+ if (!next || typeof next !== "object") return null;
57
+ if (next.mode === "overlay") return { mode: "overlay" };
58
+ if (next.mode !== "dock") return null;
59
+ if (!Number.isInteger(next.inset) || next.inset < 0 || next.inset > MAX_RAIL_INSET) return null;
60
+ // A missing or unusable railHeight is a zero, not a rejection: the inset is
61
+ // what the stage lays out against, and a sheet with no visible collapsed
62
+ // height is a legitimate state.
63
+ const railHeight = Number.isInteger(next.railHeight)
64
+ ? Math.max(0, Math.min(next.inset, next.railHeight))
65
+ : 0;
66
+ return { mode: "dock", inset: next.inset, railHeight };
67
+ }
68
+
45
69
  function buildIcon(paths) {
46
70
  const svg = document.createElementNS(SVG_NS, "svg");
47
71
  svg.setAttribute("viewBox", "0 0 24 24");
@@ -69,15 +93,18 @@ function buildIcon(paths) {
69
93
  //
70
94
  // Everything is optional, like attachRail: with no shell to manage this returns
71
95
  // a no-op handle, so a legacy id-only page or a host that lays the framework out
72
- // itself (embed-test.html) is unaffected — including its setHostPane, which
73
- // stays a callable no-op so mount()'s handle shape never varies.
74
- export function attachMobileTabs({ shell, stage, rail } = {}) {
96
+ // itself (embed-test.html) is unaffected — including its setHostPane and
97
+ // setRailLayout, which stay callable no-ops so mount()'s handle shape never
98
+ // varies. `toggle` is rail.js's own rail toggle, passed only so the overlay duck
99
+ // below can recognize it; omitting it costs the drawer its close button.
100
+ export function attachMobileTabs({ shell, stage, rail, toggle, onRailLayout } = {}) {
75
101
  if (!shell || !stage || !rail) {
76
- return { setHostPane: () => {}, detach: () => {} };
102
+ return { setHostPane: () => {}, setRailLayout: () => {}, detach: () => {} };
77
103
  }
78
104
 
79
105
  let pane = "stage"; // the standalone user's choice
80
106
  let hostPane = null; // a host's lease over that choice, while non-null
107
+ let railLayout = null; // a host's lease over where the rail sits, while non-null
81
108
 
82
109
  const bar = document.createElement("div");
83
110
  bar.className = "pf-tabbar";
@@ -107,9 +134,23 @@ export function attachMobileTabs({ shell, stage, rail } = {}) {
107
134
  for (const [p, button] of buttons) {
108
135
  button.setAttribute("aria-pressed", String(p === active));
109
136
  }
110
- // A host that owns pane selection draws its own control; ours would be a
111
- // second, competing one.
112
- bar.hidden = hostPane !== null;
137
+ // chrome.css keys the docked/overlay rail off these; the two lengths are
138
+ // zero in overlay mode, where the drawer floats over a full-width stage.
139
+ if (railLayout) {
140
+ shell.dataset.pfRailLayout = railLayout.mode;
141
+ shell.style.setProperty("--pf-rail-inset", `${railLayout.mode === "dock" ? railLayout.inset : 0}px`);
142
+ shell.style.setProperty("--pf-rail-dock-h", `${railLayout.mode === "dock" ? railLayout.railHeight : 0}px`);
143
+ } else {
144
+ delete shell.dataset.pfRailLayout;
145
+ shell.style.removeProperty("--pf-rail-inset");
146
+ shell.style.removeProperty("--pf-rail-dock-h");
147
+ }
148
+ // The drawer-open flag only means anything in overlay mode; entering any
149
+ // other layout must not leave a stale open state behind.
150
+ if (railLayout?.mode !== "overlay") shell.removeAttribute("data-pf-rail-open");
151
+ // A host that owns EITHER pane selection or the layout draws its own
152
+ // controls; ours would compete.
153
+ bar.hidden = hostPane !== null || railLayout !== null;
113
154
  }
114
155
 
115
156
  // Delegated, so the icon <svg>/<span> inside a button resolve to the button.
@@ -120,6 +161,31 @@ export function attachMobileTabs({ shell, stage, rail } = {}) {
120
161
  apply();
121
162
  };
122
163
  bar.addEventListener("click", onClick);
164
+
165
+ // Overlay mode's "duck": any interaction with the stage slides the drawer
166
+ // back. Capture phase, because the canvas consumes pointer events.
167
+ //
168
+ // Two things the plain "remove the attribute" version got wrong, both because
169
+ // the rail toggle is a DESCENDANT of the stage (it floats at the stage's
170
+ // top-right):
171
+ // * its own tap arrives here first, so this would close the drawer a moment
172
+ // before rail.js's click handler reopened it — the toggle could open the
173
+ // drawer but never shut it. Hence the exemption, which needs the element
174
+ // itself: nothing in the DOM marks it out for a class/closest() test.
175
+ // * a real duck has to be REPORTED. The toggle's chevron, aria-expanded and
176
+ // label are derived from the open flag in rail.js's apply(), so a close it
177
+ // was never told about leaves the button announcing an open drawer. The
178
+ // callback is the same one setRailLayout uses (the host wires it to
179
+ // railChrome.layoutChanged), so this is a re-look, not a new channel.
180
+ const onStagePointerDown = (e) => {
181
+ if (toggle && (e.target === toggle || toggle.contains?.(e.target))) return;
182
+ if (shell.dataset.pfRailLayout !== "overlay") return;
183
+ // Nothing to duck: stay silent rather than nudge on every stage pointerdown.
184
+ if (!shell.hasAttribute("data-pf-rail-open")) return;
185
+ shell.removeAttribute("data-pf-rail-open");
186
+ onRailLayout?.(railLayout);
187
+ };
188
+ stage.addEventListener("pointerdown", onStagePointerDown, true);
123
189
  apply();
124
190
 
125
191
  return {
@@ -129,10 +195,24 @@ export function attachMobileTabs({ shell, stage, rail } = {}) {
129
195
  hostPane = PANES.includes(next) ? next : null;
130
196
  apply();
131
197
  },
198
+ // A dock/overlay layout takes the lease; anything this module cannot read —
199
+ // null included — releases it and restores partforge's own layout. The
200
+ // callback reports what was actually applied, so a host never has to guess
201
+ // whether its request survived normalization.
202
+ setRailLayout: (next) => {
203
+ railLayout = normalizeRailLayout(next);
204
+ apply();
205
+ onRailLayout?.(railLayout);
206
+ },
132
207
  detach: () => {
133
208
  bar.removeEventListener("click", onClick);
209
+ stage.removeEventListener("pointerdown", onStagePointerDown, true);
134
210
  bar.remove();
135
211
  delete shell.dataset.pfPane;
212
+ delete shell.dataset.pfRailLayout;
213
+ shell.removeAttribute("data-pf-rail-open");
214
+ shell.style.removeProperty("--pf-rail-inset");
215
+ shell.style.removeProperty("--pf-rail-dock-h");
136
216
  },
137
217
  };
138
218
  }
@@ -62,7 +62,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
62
62
  // carries the worker's own error text. See the correlated "error" case below.
63
63
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
64
64
 
65
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker }) {
65
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker }) {
66
66
  return {
67
67
  ready, dispose, setParams,
68
68
  // Part-declared animation playback (spec 2026-08-02): animations are
@@ -122,6 +122,12 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
122
122
  // (partforge-cloud does, at the window level). Defaulted to a no-op so the
123
123
  // handle's shape never depends on whether this mount resolved a rail.
124
124
  setHostPane: setHostPane ?? (() => {}),
125
+ // Where the rail SITS, for a host that draws its own chrome around the
126
+ // frame: {mode:"dock", inset, railHeight} renders it into the host's bottom
127
+ // sheet, {mode:"overlay"} floats it as a right-edge drawer over the stage,
128
+ // and null hands the layout back to partforge. Same no-op default as
129
+ // setHostPane above, and the same lease semantics.
130
+ setRailLayout: setRailLayout ?? (() => {}),
125
131
  // Join host-owned chrome buttons to this mount's shared hover tooltip, so
126
132
  // a host's own viewbar/rail-foot buttons match the built-in ones. Entries
127
133
  // are [{ element, getLabel? }] (label falls back to the button's
@@ -211,6 +217,11 @@ function createCleanupStack() {
211
217
  // runtime.setHostPane("rail"); // narrow layout only: show just the controls
212
218
  // // rail ('stage' | 'rail'), suppressing the
213
219
  // // built-in tab bar. null hands selection back.
220
+ // runtime.setRailLayout({mode:"dock", inset, railHeight} | {mode:"overlay"} | null)
221
+ // // where the rail sits when the HOST draws chrome
222
+ // // over the frame: into its bottom sheet, or as a
223
+ // // right-edge drawer over the stage. null restores
224
+ // // partforge's own layout.
214
225
  // runtime.attachTooltips([{ element: myButton }]); // host chrome buttons join the
215
226
  // // mount's shared hover tooltip (the viewbar one).
216
227
  // // Label = the button's title (or aria-label), or a
@@ -379,6 +390,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
379
390
  shell: els.shell ?? els.rail?.parentElement,
380
391
  stage: els.viewer,
381
392
  rail: els.rail,
393
+ // The rail toggle floats INSIDE the stage, so the overlay duck has to know
394
+ // it by identity or the drawer's own close button would duck it shut and
395
+ // rail.js would reopen it on the same tap.
396
+ toggle: els.chrome.railToggle,
397
+ // rail.js reads the layout lazily in its handlers, but its toggle
398
+ // visibility (and, in overlay mode, its open/closed reporting) is written
399
+ // in apply() — tell it to re-look when the host changes the layout, or the
400
+ // duck closes the drawer. railChrome is created just above, so the closure
401
+ // is safe.
402
+ onRailLayout: () => railChrome.layoutChanged?.(),
382
403
  });
383
404
  cleanup.defer(() => paneTabs.detach());
384
405
  const hover = attachHoverLabels(viewer, { part, tooltip }); // always-on hover inspection (no-op on touch-only devices)
@@ -1154,6 +1175,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
1154
1175
  ready, dispose, viewer, setParams,
1155
1176
  attachTooltips: attachHostTooltips,
1156
1177
  setHostPane: paneTabs.setHostPane,
1178
+ setRailLayout: paneTabs.setRailLayout,
1157
1179
  getView: view, // () => tabsCtl.current()
1158
1180
  setView: (name) => tabsCtl.select(name),
1159
1181
  captureView,
@@ -96,7 +96,7 @@ function el(tag, className, text) {
96
96
  // The click path is dropped rather than hidden, deliberately: an invisible
97
97
  // overlay that still swallowed clicks would eat the button underneath it, which
98
98
  // is the one affordance ambient mode exists to protect.
99
- export function makeFileDrop({ kind, onSource, onError, onAssetUpload, ambient = false }) {
99
+ export function makeFileDrop({ kind, key, onSource, onError, onAssetUpload, ambient = false }) {
100
100
  const row = rowFor(kind);
101
101
  const wrap = el("div", ambient ? "file-drop file-drop-ambient" : "file-drop");
102
102
  if (!ambient) {
@@ -219,7 +219,14 @@ export function makeFileDrop({ kind, onSource, onError, onAssetUpload, ambient =
219
219
 
220
220
  if (onAssetUpload) {
221
221
  try {
222
- const source = await onAssetUpload(artifact.blob, { kind, filename: file.name });
222
+ // `key` is the PARAM the drop landed on. A host needs it to give a
223
+ // control a stable destination of its own — partforge-cloud derives
224
+ // one file path per key, so re-dropping replaces that control's
225
+ // artwork in place instead of accumulating files it must later
226
+ // reconcile against the part. Purely informational to partforge:
227
+ // the hook still answers with a source string and nothing here
228
+ // reads the key back.
229
+ const source = await onAssetUpload(artifact.blob, { kind, key, filename: file.name });
223
230
  if (stale()) return;
224
231
  // A host hook that resolves to anything but a non-empty string —
225
232
  // `undefined`, an object, `""` — is a contract violation, not a
@@ -329,6 +336,7 @@ export function mountDrop(kind, { params, node, onAssetUpload, onChange, onCommi
329
336
 
330
337
  const drop = makeFileDrop({
331
338
  kind,
339
+ key: node.key,
332
340
  ambient,
333
341
  onAssetUpload,
334
342
  onSource: (source) => {
@@ -90,12 +90,17 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
90
90
  // ?pickserver with no #panel/elements.rail hits this path.
91
91
  const root = document.documentElement;
92
92
  root.style.setProperty("--pf-rail-w", "0px");
93
- return { detach: () => root.style.removeProperty("--pf-rail-w") };
93
+ return { layoutChanged: () => {}, detach: () => root.style.removeProperty("--pf-rail-w") };
94
94
  }
95
95
 
96
96
  const root = document.documentElement;
97
97
  const shellBox = () => shell.getBoundingClientRect();
98
98
  const shellWidth = () => shellBox().width;
99
+ // A host that leases the rail's placement through attachMobileTabs'
100
+ // setRailLayout writes this ("dock" | "overlay"); absent means partforge's own
101
+ // layout. Read live rather than cached: the lease can move at any moment and
102
+ // layoutChanged() (below) is only a nudge to re-apply, not the source of truth.
103
+ const hostLayout = () => shell.dataset.pfRailLayout ?? null;
99
104
  let state = readRailPref(storage, shellWidth());
100
105
  // Captured before the first apply() mutates the toggle, so detach() can
101
106
  // hand back a plain, unwired button rather than a dead "Show controls" one.
@@ -142,11 +147,34 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
142
147
  // inert, invisible rail. `state` is deliberately NOT rewritten — the stored
143
148
  // preference applies again the moment the window widens.
144
149
  const narrow = window.innerWidth < RAIL_NARROW_BREAKPOINT;
145
- const collapsed = state.collapsed && !narrow;
146
- // Narrow: the rail is either the whole surface or absent, so it reserves no
147
- // width beside the viewer and anything centring itself against the rail
148
- // (app.css's #pf-pick-banner) must not be offset by a stale 288px.
149
- const width = collapsed || narrow ? 0 : clampRailWidth(state.width, sw);
150
+ // A host layout suspends collapse (and every resize gesture — see
151
+ // resizeRefused) for the same reason narrow does: the rail's placement is
152
+ // the host's to decide, so a collapse flag would only make it inert and
153
+ // invisible where the host is showing it. `state` is left untouched, so
154
+ // the preference returns intact when the lease ends.
155
+ const layout = hostLayout();
156
+ const overlay = layout === "overlay";
157
+ const suppressed = narrow || layout !== null;
158
+ const collapsed = state.collapsed && !suppressed;
159
+ // WIDTH is suppressed on a NARROWER rule than collapse, because only two of
160
+ // the three layouts stop needing --pf-rail-w:
161
+ // - narrow (any layout): chrome.css stacks the panes and shows exactly
162
+ // one, so the rail is the whole surface or absent — it reserves no
163
+ // width beside the viewer;
164
+ // - overlay (any width): the drawer sizes itself (min(288px, 85%)) and
165
+ // floats over the stage, reserving nothing;
166
+ // - dock at >= RAIL_NARROW_BREAKPOINT: the rail is STILL side by side with
167
+ // the stage — the host only leases the bottom inset — so zeroing here
168
+ // would leave the card with no controls at all. Keep the width.
169
+ // Anything centring itself against the rail (app.css's #pf-pick-banner)
170
+ // reads this token, so a stale 288px in the first two cases would offset it.
171
+ // A wide dock therefore renders as: rail visible at its remembered width,
172
+ // no resize, no collapse, and no toggle (hidden just below — a visible one
173
+ // would be a dead control while collapse is suppressed). That is the
174
+ // sheet-owns-layout stance: the host decides placement, we only supply the
175
+ // width it laid the rail out against.
176
+ const widthSuppressed = narrow || overlay;
177
+ const width = collapsed || widthSuppressed ? 0 : clampRailWidth(state.width, sw);
150
178
  // Written on :root, not the rail/shell, so body-appended overlays (the
151
179
  // pick banner, the ?debug overlay) inherit it — see spec §4.4. This
152
180
  // assumes ONE rail per document: attachRail is written for a single
@@ -162,16 +190,26 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
162
190
  seam.setAttribute("aria-valuemax", String(railMaxWidth(sw)));
163
191
  if (toggle) {
164
192
  // The tab bar owns pane selection below the breakpoint, so a second
165
- // collapse affordance in the viewbar would be a competing control.
166
- toggle.hidden = narrow;
167
- toggleChevron?.setAttribute("d", collapsed ? CHEVRON_RAIL_COLLAPSED : CHEVRON_RAIL_OPEN);
168
- const label = collapsed ? "Show controls" : "Hide controls";
169
- toggle.setAttribute("aria-expanded", String(!collapsed));
193
+ // collapse affordance in the viewbar would be a competing control. In
194
+ // overlay mode the toggle IS the drawer's open/close control, so it must
195
+ // stay visible even though the width says "narrow" and the host hides
196
+ // its own tab bar under a layout lease, making this the only way back to
197
+ // the controls. Under dock it stays hidden at EVERY width, including the
198
+ // wide band that keeps its --pf-rail-w above: collapse is suppressed
199
+ // there, so a visible toggle would be a control that does nothing.
200
+ toggle.hidden = suppressed && !overlay;
201
+ // One button, two meanings: the drawer's open state in overlay mode, the
202
+ // collapse everywhere else. `shut` is whichever of the two this layout
203
+ // asks the label, chevron and aria-expanded to report.
204
+ const shut = overlay ? !shell.hasAttribute("data-pf-rail-open") : collapsed;
205
+ toggleChevron?.setAttribute("d", shut ? CHEVRON_RAIL_COLLAPSED : CHEVRON_RAIL_OPEN);
206
+ const label = shut ? "Show controls" : "Hide controls";
207
+ toggle.setAttribute("aria-expanded", String(!shut));
170
208
  toggle.setAttribute("aria-label", label);
171
209
  // The shared tooltip reads the aria-label at show time, so a native
172
210
  // title would double up as a second, competing tooltip.
173
211
  if (!tooltipBinding) toggle.title = label;
174
- toggle.classList.toggle("on", collapsed);
212
+ toggle.classList.toggle("on", shut);
175
213
  tooltipBinding?.sync();
176
214
  }
177
215
  if (persist) writeRailPref(state, storage);
@@ -199,10 +237,18 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
199
237
  }
200
238
  const toggleCollapsed = () => commit({ collapsed: !state.collapsed, width: state.width });
201
239
 
240
+ // Neither gesture that sizes the rail may run when the seam is not what
241
+ // sizes it: chrome.css hides the vertical seam in the stacked layout (the
242
+ // rail is under the viewer, not beside it), and a host layout sizes the rail
243
+ // itself, where a width or a collapse we wrote would be invisible at best
244
+ // and an inert docked rail at worst. The toggle still works in both.
245
+ const resizeRefused = () => window.innerWidth < RAIL_NARROW_BREAKPOINT || hostLayout() !== null;
246
+
202
247
  // --- keyboard: move the SEPARATOR (standard role="separator" semantics), so
203
248
  // ArrowLeft widens a right-hand rail. Arrows clamp at the minimum and never
204
249
  // collapse; Enter/Space is the collapse gesture.
205
250
  function onKeyDown(e) {
251
+ if (resizeRefused()) return;
206
252
  // Cmd/Alt/Ctrl+Arrow are browser/OS reserved (back, tab switch, ...); don't
207
253
  // eat them just because the seam happens to hold focus.
208
254
  if (e.metaKey || e.altKey || e.ctrlKey) return;
@@ -247,15 +293,26 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
247
293
  }
248
294
 
249
295
  const onDoubleClick = () => commit({ collapsed: false, width: RAIL_DEFAULT_WIDTH });
250
- const onToggleClick = () => toggleCollapsed();
296
+ const onToggleClick = () => {
297
+ // Overlay: the rail is a drawer over the stage, so the toggle slides it in
298
+ // and out. It must not touch `state` — the collapse preference belongs to
299
+ // the wide layout the host may hand back at any moment, and persisting a
300
+ // drawer gesture into it would collapse the rail on a desktop next visit.
301
+ // The host clears this flag itself when the lease ends or the stage is
302
+ // touched (mobile-tabs.js), so nothing here has to unwind it.
303
+ if (hostLayout() === "overlay") {
304
+ shell.toggleAttribute("data-pf-rail-open");
305
+ apply();
306
+ return;
307
+ }
308
+ toggleCollapsed();
309
+ };
251
310
 
252
311
  // --- drag ---
253
312
  let grabOffset = 0;
254
313
  function onPointerDown(e) {
255
314
  if (e.button !== 0) return;
256
- // Stacked layout: the rail is under the viewer, so there is no vertical seam
257
- // to drag (chrome.css hides it). The toggle still works.
258
- if (window.innerWidth < RAIL_NARROW_BREAKPOINT) return;
315
+ if (resizeRefused()) return;
259
316
  e.preventDefault();
260
317
  // setPointerCapture is load-bearing: without it the pointer crosses into the
261
318
  // viewer (an iframe, in the cloud editor) whose document eats the move
@@ -321,6 +378,11 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
321
378
  apply();
322
379
 
323
380
  return {
381
+ // The host's data-pf-rail-layout / data-pf-rail-open attributes are read
382
+ // live by apply(), so re-running it is the whole of "the layout moved".
383
+ // Deliberately unconditional: attachMobileTabs reports every setRailLayout
384
+ // call, no-change ones included, and a bare re-apply is idempotent.
385
+ layoutChanged: () => apply(),
324
386
  detach: () => {
325
387
  settleKeys();
326
388
  seam.removeEventListener("pointerdown", onPointerDown);
@@ -52,6 +52,14 @@ import { unwrapModule } from "./asset-resolve.js";
52
52
  export const VECTOR_ALLOW_DEFAULT = ["https"];
53
53
 
54
54
  const ASSET_SCHEME = "pfc-asset:";
55
+ // Artwork that lives as a FILE IN THE PART ITSELF, addressed by its path
56
+ // rather than by a host-side storage id. Vector-only, and the asymmetry is
57
+ // structural rather than an omission: a part's files are text, so a JSON
58
+ // document can live in one and a PNG cannot. A host that stores parts as a
59
+ // file tree (partforge-cloud is the motivating case) resolves this scheme
60
+ // against that tree; partforge itself only decides whether a param may carry
61
+ // it, exactly as it does for `pfc-asset:`.
62
+ const TREE_SCHEME = "pfc-tree:";
55
63
 
56
64
  // The "unset" vector source. An empty value declares NO artwork for that
57
65
  // name — mirrors isNoFontSource/isNoImageSource exactly. Never a source to
@@ -94,6 +102,7 @@ export function vectorSourceAllowed(source, allow = VECTOR_ALLOW_DEFAULT) {
94
102
  // "pfc-asset://" must not pass, and neither must a lookalike host.
95
103
  if (kind === "https" && u.protocol === "https:") return true;
96
104
  if (kind === "asset" && u.protocol === ASSET_SCHEME) return true;
105
+ if (kind === "tree" && u.protocol === TREE_SCHEME) return true;
97
106
  }
98
107
  return false;
99
108
  }
package/types/index.d.ts CHANGED
@@ -13,6 +13,18 @@ export * from "./part.js";
13
13
  /** Which pane a narrow layout shows. `null` hands selection back to partforge. */
14
14
  export type HostPane = "stage" | "rail" | null;
15
15
 
16
+ /**
17
+ * Where a HOST wants the controls rail: docked into a bottom sheet the host
18
+ * draws over the frame (`inset` = the sheet's full height, so the stage clears
19
+ * it; `railHeight` = the bottom slice of that region the rail renders into,
20
+ * under the host's own chrome strip), or floated as a right-edge drawer over a
21
+ * full-width stage. `null` hands the layout back to partforge.
22
+ */
23
+ export type HostRailLayout =
24
+ | { mode: "dock"; inset: number; railHeight?: number }
25
+ | { mode: "overlay" }
26
+ | null;
27
+
16
28
  /** An export file format. STEP is routed to OCCT automatically. */
17
29
  export type ExportFormat = "stl" | "step" | "3mf";
18
30
 
@@ -494,6 +506,14 @@ export interface PartRuntime {
494
506
  * `null` hands selection back to partforge's built-in bar.
495
507
  */
496
508
  setHostPane(pane: HostPane): void;
509
+ /**
510
+ * Where the rail sits, for a host that draws its own chrome over the frame:
511
+ * docked into the host's bottom sheet (`inset` is the sheet's full height,
512
+ * `railHeight` the part of it the rail may paint into), or floated as a
513
+ * right-edge drawer over an unresized stage. `null` — or any shape partforge
514
+ * cannot read — restores partforge's own layout.
515
+ */
516
+ setRailLayout(layout: HostRailLayout): void;
497
517
  /**
498
518
  * Part-declared animation playback, or `null` when NO view declares an
499
519
  * `animations` block. Non-null while any view does — including while the