partforge 0.46.2 → 0.46.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.46.2",
3
+ "version": "0.46.4",
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",
@@ -22,6 +22,38 @@ function btn(className, text, label) {
22
22
  return b;
23
23
  }
24
24
 
25
+ // Where the transport bar may sit, given the stage width, the bar's natural
26
+ // width, and the viewbar's left edge (all px, viewbarLeft stage-relative).
27
+ // null → the CSS default (centered) already clears the viewbar. Otherwise
28
+ // inline overrides: `left` slides the bar toward the stage's `margin`, and
29
+ // when even that isn't enough, `maxWidth` caps the bar so the `gap` holds.
30
+ export function planAnimBarPlacement({ stageWidth, barWidth, viewbarLeft }, { gap = 10, margin = 12 } = {}) {
31
+ const centeredLeft = (stageWidth - barWidth) / 2;
32
+ const limit = viewbarLeft - gap - barWidth;
33
+ if (centeredLeft <= limit) return null;
34
+ const left = Math.max(margin, limit);
35
+ const available = Math.max(0, viewbarLeft - gap - margin);
36
+ return barWidth > available ? { left, maxWidth: available } : { left };
37
+ }
38
+
39
+ // A setter for an element's text that MUTATES its existing text node instead of
40
+ // replacing it. `el.textContent = x` always replaces the node, and WebKit will
41
+ // not dispatch a `click` on an element whose text node was replaced between
42
+ // mousedown and mouseup — so a label that can change while the user is pressing
43
+ // it must be written this way or the press is silently swallowed. Isolated in
44
+ // WebKit on a 120ms press: `textContent = <identical string>` loses the click,
45
+ // `node.data = <same>` never does, and neither does an attribute write.
46
+ //
47
+ // This is what makes the transport's glyph safe even when it legitimately flips
48
+ // mid-press (playback ending under the user's finger), which no amount of
49
+ // skipping redundant writes can cover.
50
+ function textSetter(element) {
51
+ const node = element.firstChild?.nodeType === 3
52
+ ? element.firstChild
53
+ : element.appendChild(document.createTextNode(""));
54
+ return (value) => { if (node.data !== value) node.data = value; };
55
+ }
56
+
25
57
  export function attachAnimationControls(viewer, part, { container, applyValues, getParamValues }) {
26
58
  // A malformed animations block must degrade to "no transport bar", never a
27
59
  // crashed mount — lint reports the specifics; the viewer just goes without.
@@ -92,19 +124,59 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
92
124
  }
93
125
  }
94
126
 
127
+ // syncUi() runs on every playback frame. Two rules keep that from eating the
128
+ // user's clicks, and they are independent:
129
+ //
130
+ // 1. Text goes through textSetter (see above), so the button's text node is
131
+ // mutated and never replaced. This is the one that matters, because it
132
+ // holds even when the glyph legitimately changes under a held finger —
133
+ // playback ending, or a step boundary crossing, mid-press.
134
+ // 2. Each element has its own renderer that leaves early when its own value
135
+ // is unchanged, so a playing transport does no DOM work per frame at all.
136
+ // Per ELEMENT, not per write: driving several elements off one shared
137
+ // state key means a step change also redraws the button, which was still
138
+ // enough to lose the click before rule 1 was in place.
139
+ //
140
+ // Without rule 1 this cost the pause click outright: press, release, no click
141
+ // event at all, and only ever while playing — the one moment the button is
142
+ // for. Measured in WebKit, any press held >= 40ms lost it (a real click is
143
+ // ~100ms; a synthetic 0ms one survives, which is why automated clicking never
144
+ // saw it). Reset was never affected: nothing rewrites that button per frame.
145
+ const setPlayGlyph = textSetter(playBtn);
146
+ const setStepText = textSetter(stepLabel);
147
+ let shownActive = null;
148
+ let shownStep = null;
149
+
150
+ function renderPlayButton(active) {
151
+ if (active === shownActive) return;
152
+ shownActive = active;
153
+ setPlayGlyph(active ? "⏸" : "▶");
154
+ const label = active ? "Pause animation" : "Play animation";
155
+ playBtn.setAttribute("aria-label", label);
156
+ playBtn.title = label;
157
+ }
158
+
159
+ function renderStepLabel(stepIndex) {
160
+ if (current.steps.length <= 1 || stepIndex === shownStep) return;
161
+ shownStep = stepIndex;
162
+ const step = current.steps[stepIndex];
163
+ setStepText(`${stepIndex + 1}/${current.steps.length} · ${step.label}`);
164
+ }
165
+
95
166
  function syncUi() {
96
167
  const { status, t, stepIndex } = playback.state();
97
- const active = status === "playing" || status === "intro";
98
- playBtn.textContent = active ? "⏸" : "▶";
99
- playBtn.setAttribute("aria-label", active ? "Pause animation" : "Play animation");
100
- playBtn.title = playBtn.getAttribute("aria-label");
168
+ // The scrubber is the one thing that genuinely moves every frame. Assigning
169
+ // `.value` updates a property rather than replacing a child node, so it
170
+ // costs no clicks and it is not what anyone reaches for mid-playback.
101
171
  scrub.value = String(Math.round(t * 1000));
102
- if (current.steps.length > 1) {
103
- const step = current.steps[stepIndex];
104
- stepLabel.textContent = `${stepIndex + 1}/${current.steps.length} · ${step.label}`;
105
- }
172
+ renderPlayButton(status === "playing" || status === "intro");
173
+ renderStepLabel(stepIndex);
106
174
  }
107
175
 
176
+ // selectAnimation swaps in a fresh playback whose state can coincide with the
177
+ // outgoing one; the chrome still has to re-render for the new animation.
178
+ function invalidateUi() { shownActive = null; shownStep = null; }
179
+
108
180
  // --- driver -----------------------------------------------------------------
109
181
  // A frame that throws — a malformed cue or track that slipped past lint, a
110
182
  // viewer that rejects a view name — must cost that frame, not the render
@@ -162,6 +234,8 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
162
234
  playback = createPlayback(current);
163
235
  if (animations.length > 1) pick.value = name;
164
236
  syncStructure();
237
+ invalidateUi(); // a fresh playback starts idle at step 0 — the same key the
238
+ // outgoing one may have been showing, but for another animation
165
239
  syncUi();
166
240
  }
167
241
 
@@ -200,6 +274,65 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
200
274
  syncStructure();
201
275
  syncUi();
202
276
 
277
+ // --- placement: keep clear of the viewbar ---------------------------------
278
+ // chrome.css centers the bar (left: 50% / translateX(-50%)), and nothing in
279
+ // CSS can stop that centered position sliding under #viewbar when the stage
280
+ // narrows — the viewbar's width is dynamic (cutaway's Flip/Reset appear and
281
+ // disappear), so a static reservation would either overlap or waste centre
282
+ // space. Measure instead: when the two bars' vertical bands intersect, clamp
283
+ // the bar's left so a 10px gap to the viewbar holds, capping its width if
284
+ // even the stage's 12px margin isn't enough. Overrides are inline and
285
+ // cleared at the top of every pass, so chrome.css (or a host that
286
+ // re-anchors either bar out of the shared band) stays authoritative the
287
+ // moment the constraint stops binding. The clear-measure-apply sequence is
288
+ // loop-safe: it settles within one frame, so ResizeObserver — which reports
289
+ // rendered sizes at frame boundaries — never sees the intermediate state.
290
+ const viewbarEl = container.querySelector("#viewbar");
291
+ let placementRaf = 0;
292
+ function applyPlacement() {
293
+ placementRaf = 0;
294
+ bar.style.left = "";
295
+ bar.style.transform = "";
296
+ bar.style.maxWidth = "";
297
+ bar.style.overflow = "";
298
+ const vb = viewbarEl?.getBoundingClientRect();
299
+ const barRect = bar.getBoundingClientRect();
300
+ if (!vb || barRect.top >= vb.bottom || barRect.bottom <= vb.top) return;
301
+ const stageRect = container.getBoundingClientRect();
302
+ const plan = planAnimBarPlacement({
303
+ stageWidth: stageRect.width,
304
+ barWidth: barRect.width,
305
+ viewbarLeft: vb.left - stageRect.left,
306
+ });
307
+ if (!plan) return;
308
+ bar.style.left = `${plan.left}px`;
309
+ bar.style.transform = "none";
310
+ // maxWidth is the last resort, only reached when even the margin can't
311
+ // hold the gap — and the bar's flex children have hard minimums (~320px)
312
+ // that don't shrink to fit a tighter cap. overflow:hidden only applies
313
+ // here, inline, because a static rule in app.css would also clip the ⓘ
314
+ // info popover in the (far more common) uncapped state.
315
+ if (plan.maxWidth != null) {
316
+ bar.style.maxWidth = `${plan.maxWidth}px`;
317
+ bar.style.overflow = "hidden";
318
+ }
319
+ }
320
+ function schedulePlacement() {
321
+ if (typeof requestAnimationFrame !== "function") return applyPlacement();
322
+ if (!placementRaf) placementRaf = requestAnimationFrame(applyPlacement);
323
+ }
324
+ // Observing the bar itself catches content-driven width changes (step label
325
+ // text, animation switch); the viewbar, cutaway's actions; the stage, rail
326
+ // drags and window resizes.
327
+ const placementObserver = typeof ResizeObserver === "function"
328
+ ? new ResizeObserver(schedulePlacement) : null;
329
+ if (placementObserver) {
330
+ placementObserver.observe(container);
331
+ placementObserver.observe(bar);
332
+ if (viewbarEl) placementObserver.observe(viewbarEl);
333
+ }
334
+ schedulePlacement();
335
+
203
336
  const runtime = {
204
337
  // An unknown name is a host bug, not a request to play whatever happens to
205
338
  // be selected — say so and do nothing rather than silently animating
@@ -246,6 +379,8 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
246
379
  pick.removeEventListener("change", onPick);
247
380
  resetBtn.removeEventListener("click", onResetClick);
248
381
  info.dispose();
382
+ placementObserver?.disconnect();
383
+ if (placementRaf && typeof cancelAnimationFrame === "function") cancelAnimationFrame(placementRaf);
249
384
  bar.remove();
250
385
  },
251
386
  __viewer: viewer, // test hook only
@@ -222,6 +222,9 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
222
222
  .pf-anim-bar {
223
223
  display: flex; align-items: center; gap: 8px;
224
224
  padding: 6px 10px;
225
+ /* Never shorter than #viewbar's card sharing the same bottom edge:
226
+ 34px button + 2×4px padding + 2×1px border (border-box). */
227
+ min-height: 44px;
225
228
  background: var(--pf-surface); border: 1px solid var(--pf-border);
226
229
  border-radius: var(--pf-radius-control); box-shadow: var(--pf-shadow-float);
227
230
  }
@@ -249,6 +252,11 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
249
252
  background: var(--pf-muted); pointer-events: none;
250
253
  }
251
254
 
255
+ @media (max-width: 360px) {
256
+ /* #viewbar's card is 40px here (30px buttons) — keep the floor in step. */
257
+ .pf-anim-bar { min-height: 40px; }
258
+ }
259
+
252
260
  /* Legacy id-only markup only: classed markup's viewbar lives inside .pf-stage
253
261
  (bottom-right, see chrome.css's .pf-float-viewbar) so it never meets the
254
262
  top-left floating #panel card. Legacy markup still floats #viewbar top-right
@@ -163,7 +163,10 @@
163
163
 
164
164
  /* --- animation transport bar (generated by animation-controls.js) ----------
165
165
  PLACEMENT ONLY, per the rule above; appearance lives in app.css next to
166
- #viewbar's, so a host that re-anchors this bar still inherits its chrome. */
166
+ #viewbar's, so a host that re-anchors this bar still inherits its chrome.
167
+ animation-controls.js may inline-override left/transform/max-width (and
168
+ overflow while width-capped) to hold a 10px gap to #viewbar, and clears
169
+ the overrides whenever centered placement fits. */
167
170
  .pf-anim-bar {
168
171
  position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
169
172
  z-index: 15; max-width: calc(100% - 24px);