partforge 0.101.0 → 0.103.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.
@@ -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);
@@ -27,10 +27,14 @@ import plate from "./assets/plate.vector.json" with { type: "json" };
27
27
 
28
28
  export default {
29
29
  meta: { title: "Emblem", units: "mm", background: 0x15181d },
30
- vectors: {
31
- emblem: new URL("./assets/emblem.vector.json", import.meta.url),
30
+ // Function form, so the `type: "vector"` control below can drive the artwork.
31
+ // The bundled file is the default: dropping an SVG replaces it, clearing the
32
+ // control brings it back, and the part still builds with no network — which is
33
+ // what CI and `partforge measure` see.
34
+ vectors: (p) => ({
35
+ emblem: p.art || new URL("./assets/emblem.vector.json", import.meta.url),
32
36
  plate,
33
- },
37
+ }),
34
38
  parameters: [
35
39
  {
36
40
  id: "plate",
@@ -45,15 +49,24 @@ export default {
45
49
  id: "art",
46
50
  title: "Artwork",
47
51
  description: "The embossed vector art. `emblem.svg` carries a filled circle and a stroked bar, so both of ingest's geometry paths are exercised.",
48
- advanced: [
49
- { key: "emblem_w", label: "Emblem width", unit: "mm", min: 8, max: 70, step: 1,
50
- description: "Width of the artwork's **tight bounding box** in mm — not its `viewBox`. Stroke thickness scales with it." },
51
- { key: "emboss", label: "Emboss height", unit: "mm", min: 0.4, max: 4, step: 0.2,
52
- description: "How far the artwork stands proud of the plate." },
52
+ // Converted from the legacy `advanced` array to the new shape: a section is
53
+ // one or the other, never both, because mixing them makes render order
54
+ // arbitrary (partforge lint's `mixed-section-shape`). `advanced` becomes a
55
+ // nested group, which renders the same way.
56
+ controls: [
57
+ { key: "art", type: "vector", label: "Source",
58
+ description: "Drop an SVG to replace the bundled emblem — it is converted to "
59
+ + "partforge-vector once, in the browser, at drop time. Clear it to go back to the bundled file." },
60
+ { type: "group", title: "Advanced", collapsed: "auto", controls: [
61
+ { key: "emblem_w", label: "Emblem width", unit: "mm", min: 8, max: 70, step: 1,
62
+ description: "Width of the artwork's **tight bounding box** in mm — not its `viewBox`. Stroke thickness scales with it." },
63
+ { key: "emboss", label: "Emboss height", unit: "mm", min: 0.4, max: 4, step: 0.2,
64
+ description: "How far the artwork stands proud of the plate." },
65
+ ] },
53
66
  ],
54
67
  },
55
68
  ],
56
- defaults: { plate_t: 3, emblem_w: 30, emboss: 1 },
69
+ defaults: { art: "", plate_t: 3, emblem_w: 30, emboss: 1 },
57
70
  parts: {
58
71
  plate: {
59
72
  label: "Plate",
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