partforge 0.46.3 → 0.47.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.
@@ -132,9 +132,17 @@ export default {
132
132
  ## Animations
133
133
 
134
134
  A part may declare named animations — pure keyframe data that drives **existing
135
- params** over time. The viewer shows a transport bar (play/scrub/step); hosts
136
- drive the same engine via `runtime.animation`; `partforge render` can render
137
- stills at any position. The reference part is `src/parts/hinged-box.js`.
135
+ params** over time. The viewer shows a transport bar (play/scrub, with ‹ ›
136
+ pagers between animations); hosts drive the same engine via
137
+ `runtime.animation`; `partforge render` can render stills at any position. The
138
+ reference part is `src/parts/hinged-box.js`.
139
+
140
+ Step labels surface on the scrubber rather than in a readout: hovering or
141
+ dragging along the timeline names the chapter under the pointer, and with the
142
+ scrubber focused **PageUp / PageDown jump whole chapters** (PageUp forward,
143
+ matching the key's native slider direction). Screen readers get the same
144
+ information from the scrubber's `aria-valuetext`, which reads
145
+ `"<step label> — <percent>"`.
138
146
 
139
147
  ```js
140
148
  animations: {
@@ -149,7 +157,7 @@ animations: {
149
157
  },
150
158
  assemble: {
151
159
  label: "Assemble",
152
- steps: [ // steps play in order; prev/next navigate them
160
+ steps: [ // steps play in order; named on the scrubber as you hover/drag
153
161
  { label: "Lower the lid", camera: "left", duration: 1.0,
154
162
  tracks: { lidLift: [[0, 40], [1, 0]] } },
155
163
  { label: "Open", camera: "iso", duration: 1.0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.46.3",
3
+ "version": "0.47.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",
@@ -5,7 +5,7 @@
5
5
  // through the mount-supplied applyValues hook — the same path as a slider
6
6
  // edit, minus the regen debounce. Returns null when the part declares no
7
7
  // (valid) animations, so mount can wire it unconditionally.
8
- import { normalizeAnimations, createPlayback } from "./animation.js";
8
+ import { normalizeAnimations, createPlayback, stepIndexAt } from "./animation.js";
9
9
  import { createInfoPopover, attachInfo } from "./controls.js";
10
10
 
11
11
  function el(tag, className, text) {
@@ -14,14 +14,66 @@ function el(tag, className, text) {
14
14
  if (text != null) node.textContent = text;
15
15
  return node;
16
16
  }
17
+ function setBtnLabel(b, label) {
18
+ b.setAttribute("aria-label", label);
19
+ b.title = label;
20
+ }
17
21
  function btn(className, text, label) {
18
22
  const b = el("button", className, text);
19
23
  b.type = "button";
20
- b.setAttribute("aria-label", label);
21
- b.title = label;
24
+ setBtnLabel(b, label);
22
25
  return b;
23
26
  }
24
27
 
28
+ // Where the transport bar may sit, given the stage width, the bar's natural
29
+ // width, and the viewbar's left edge (all px, viewbarLeft stage-relative).
30
+ // null → the CSS default (centered) already clears the viewbar. Otherwise
31
+ // inline overrides: `left` slides the bar toward the stage's `margin`, and
32
+ // when even that isn't enough, `maxWidth` caps the bar so the `gap` holds.
33
+ export function planAnimBarPlacement({ stageWidth, barWidth, viewbarLeft }, { gap = 10, margin = 12 } = {}) {
34
+ const centeredLeft = (stageWidth - barWidth) / 2;
35
+ const limit = viewbarLeft - gap - barWidth;
36
+ if (centeredLeft <= limit) return null;
37
+ const left = Math.max(margin, limit);
38
+ const available = Math.max(0, viewbarLeft - gap - margin);
39
+ return barWidth > available ? { left, maxWidth: available } : { left };
40
+ }
41
+
42
+ // The scrubber's resolution: `t` is reported to the user as one of this many
43
+ // steps, and read back the same way.
44
+ export const SCRUB_STEPS = 1000;
45
+
46
+ // Snap a seek target UP onto the scrubber's grid.
47
+ //
48
+ // syncUi rounds `t` onto that grid to position the thumb, but a chapter
49
+ // boundary rarely lands on it: with three equal chapters, 1/3 rounds DOWN to
50
+ // 0.333 — which is in the chapter BEFORE the one the playhead is really in. The
51
+ // bar then reports a chapter it is not at, and the next arrow-key nudge reads
52
+ // the rounded value back and "moves" the user a chapter without the position
53
+ // changing. Rounding up keeps the grid value on the same side of the boundary
54
+ // as `t`, so the playhead and what the scrubber shows always agree.
55
+ //
56
+ // A chapter shorter than one step can't be represented at all; nothing here can
57
+ // fix that, and lint's minimum step duration keeps it out of reach.
58
+ export function snapUpToScrubGrid(t) {
59
+ const stepped = Math.ceil(t * SCRUB_STEPS - 1e-9) / SCRUB_STEPS;
60
+ return Math.min(1, Math.max(0, stepped));
61
+ }
62
+
63
+ // "Close enough to a chapter's start to count as being ON it." One scrubber
64
+ // step, for the same reason: finer than the grid is finer than anything the
65
+ // user can see or land on.
66
+ const AT_BOUNDARY = 1 / SCRUB_STEPS;
67
+
68
+ // Center-x for the chapter bubble, in px within the scrub wrap: the bubble
69
+ // tracks `fraction` along the timeline but never hangs past either end. A
70
+ // wrap narrower than the bubble has no legal band — park it in the middle.
71
+ export function clampBubbleX(fraction, wrapWidth, bubbleWidth) {
72
+ if (wrapWidth <= bubbleWidth) return wrapWidth / 2;
73
+ const half = bubbleWidth / 2;
74
+ return Math.min(Math.max(fraction * wrapWidth, half), wrapWidth - half);
75
+ }
76
+
25
77
  // A setter for an element's text that MUTATES its existing text node instead of
26
78
  // replacing it. `el.textContent = x` always replaces the node, and WebKit will
27
79
  // not dispatch a `click` on an element whose text node was replaced between
@@ -76,30 +128,133 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
76
128
  pick.append(o);
77
129
  }
78
130
  const title = el("span", "pf-anim-title", "");
79
- bar.append(animations.length > 1 ? pick : title);
131
+ // Multi-animation parts page with › at the card's outer edges — whole
132
+ // animations only, never chapters (chapters are the bubble + PageUp/Down).
133
+ const paged = animations.length > 1;
134
+ const prevAnimBtn = paged ? btn("pf-anim-page", "‹", "Previous animation") : null;
135
+ const nextAnimBtn = paged ? btn("pf-anim-page", "›", "Next animation") : null;
136
+ if (prevAnimBtn) bar.append(prevAnimBtn);
137
+ bar.append(paged ? pick : title);
80
138
  const infoSlot = el("span", "pf-anim-info");
81
139
  const playBtn = btn("pf-anim-play", "▶", "Play animation");
82
- const prevBtn = btn("pf-anim-step-btn", "‹", "Previous step");
83
- const stepLabel = el("span", "pf-anim-step", "");
84
- const nextBtn = btn("pf-anim-step-btn", "›", "Next step");
85
140
  const scrubWrap = el("span", "pf-anim-scrub-wrap");
86
141
  const scrub = document.createElement("input");
87
142
  scrub.type = "range";
88
- scrub.min = "0"; scrub.max = "1000"; scrub.step = "1"; scrub.value = "0";
143
+ scrub.min = "0"; scrub.max = String(SCRUB_STEPS); scrub.step = "1"; scrub.value = "0";
89
144
  scrub.className = "pf-anim-scrub";
90
145
  scrub.setAttribute("aria-label", "Animation position");
91
146
  scrubWrap.append(scrub);
92
147
  const resetBtn = btn("pf-anim-reset", "↺", "Reset animation");
93
- bar.append(infoSlot, playBtn, prevBtn, stepLabel, nextBtn, scrubWrap, resetBtn);
148
+ bar.append(infoSlot, playBtn, scrubWrap, resetBtn);
149
+ if (nextAnimBtn) bar.append(nextAnimBtn);
94
150
  container.append(bar);
151
+ // Chapter bubble: floats above the scrubber naming the chapter under the
152
+ // pointer (hover) or playhead (scrub).
153
+ //
154
+ // It is a child of the STAGE, not of the bar, for the same reason the ⓘ
155
+ // popover is a child of document.body: when the bar runs out of room it caps
156
+ // its width and sets overflow:hidden (see applyPlacement), and anything
157
+ // inside it is clipped — the bubble sits above the bar's content box, so it
158
+ // was clipped away entirely in exactly the narrow layouts where a shrunken
159
+ // timeline needs its labels most. Living on the stage puts it out of that
160
+ // clip, and out of the placement ResizeObserver's subtree as well.
161
+ //
162
+ // Non-interactive and aria-hidden: the accessible chapter channel is the
163
+ // scrubber's aria-valuetext, not this flag.
164
+ const chapterBubble = el("span", "pf-anim-chapter");
165
+ chapterBubble.setAttribute("aria-hidden", "true");
166
+ container.append(chapterBubble);
95
167
 
96
- // Per-animation chrome: title, description, step buttons, scrubber ticks.
168
+ // A reveal is either HOVER-owned or TRANSIENT. A hover reveal lives until the
169
+ // pointer leaves; a transient one (keyboard jump, scrub, touch) fades itself.
170
+ //
171
+ // `hoverInside` marks the pointer as the current owner, and while it is set a
172
+ // transient reveal deliberately arms no fade — a drag fires pointermove then
173
+ // an `input` on every step, so the transient timer would otherwise be the last
174
+ // one set and would blank the label under a finger that never left.
175
+ //
176
+ // Every path that hides the bubble clears the latch too, so it can never
177
+ // outlive the reveal it guards: an animation switch hides the bubble while the
178
+ // pointer sits still, and a gesture the browser steals for scrolling delivers
179
+ // pointercancel instead of pointerleave.
180
+ const BUBBLE_FADE_MS = 1000;
181
+ let bubbleFadeTimer = 0;
182
+ let hoverInside = false;
183
+ // The label's rendered width is re-measured only when the label itself
184
+ // changes: showChapterBubble runs on every pointermove, and offsetWidth
185
+ // forces a synchronous layout. Writing through textSetter keeps the text node
186
+ // stable per the WebKit rule above, rather than replacing it per move.
187
+ const setBubbleText = textSetter(chapterBubble);
188
+ let bubbleLabel = null;
189
+ let bubbleWidth = 0;
190
+ function showChapterBubble(fraction, { transient = false } = {}) {
191
+ if (current.steps.length <= 1) return;
192
+ const f = Math.min(1, Math.max(0, fraction));
193
+ const label = current.steps[stepIndexAt(current, f)].label;
194
+ if (label !== bubbleLabel) {
195
+ bubbleLabel = label;
196
+ setBubbleText(label);
197
+ bubbleWidth = chapterBubble.offsetWidth;
198
+ }
199
+ // Stage-relative, because the bubble lives on the stage rather than in the
200
+ // wrap: track the point along the timeline, then lift clear of the bar.
201
+ const wrapRect = scrubWrap.getBoundingClientRect();
202
+ const stageRect = container.getBoundingClientRect();
203
+ chapterBubble.style.left =
204
+ `${wrapRect.left - stageRect.left + clampBubbleX(f, wrapRect.width, bubbleWidth)}px`;
205
+ chapterBubble.style.bottom = `${stageRect.bottom - wrapRect.top + 8}px`;
206
+ chapterBubble.classList.add("pf-show");
207
+ clearTimeout(bubbleFadeTimer);
208
+ bubbleFadeTimer = 0;
209
+ if (transient && !hoverInside) bubbleFadeTimer = setTimeout(hideChapterBubble, BUBBLE_FADE_MS);
210
+ }
211
+ function fadeChapterBubble() {
212
+ if (!chapterBubble.classList.contains("pf-show")) return;
213
+ clearTimeout(bubbleFadeTimer);
214
+ bubbleFadeTimer = setTimeout(hideChapterBubble, BUBBLE_FADE_MS);
215
+ }
216
+ function hideChapterBubble() {
217
+ clearTimeout(bubbleFadeTimer);
218
+ bubbleFadeTimer = 0;
219
+ hoverInside = false;
220
+ chapterBubble.classList.remove("pf-show");
221
+ }
222
+ const onWrapPointerMove = (e) => {
223
+ if (current.steps.length <= 1) return; // no chapters: no bubble, and no layout read
224
+ const rect = scrubWrap.getBoundingClientRect();
225
+ if (!rect.width) return;
226
+ hoverInside = true;
227
+ showChapterBubble((e.clientX - rect.left) / rect.width);
228
+ };
229
+ // A touch pointer's leave arrives with the finger lift, so hiding outright
230
+ // would blank the label the tap just asked for — and touch has no hover to
231
+ // read it with afterwards. Let it fade like a keyboard reveal instead. A
232
+ // mouse leaving still hides at once: the pointer moving away IS the dismissal.
233
+ const onWrapPointerLeave = (e) => {
234
+ hoverInside = false;
235
+ if (e?.pointerType === "touch") fadeChapterBubble();
236
+ else hideChapterBubble();
237
+ };
238
+ scrubWrap.addEventListener("pointermove", onWrapPointerMove);
239
+ scrubWrap.addEventListener("pointerleave", onWrapPointerLeave);
240
+ scrubWrap.addEventListener("pointercancel", onWrapPointerLeave);
241
+
242
+ // Per-animation chrome: title, ⓘ description, pager labels, scrubber ticks.
97
243
  function syncStructure() {
98
244
  title.textContent = current.label;
245
+ hideChapterBubble();
246
+ if (paged) {
247
+ // Name the destination. Activating a pager keeps focus on it and leaves
248
+ // its glyph unchanged, so without this a screen reader re-announces the
249
+ // same generic "Next animation" and never says what is now selected.
250
+ const i = animations.indexOf(current);
251
+ const at = (d) => animations[(i + d + animations.length) % animations.length].label;
252
+ setBtnLabel(prevAnimBtn, `Previous animation: ${at(-1)}`);
253
+ setBtnLabel(nextAnimBtn, `Next animation: ${at(1)}`);
254
+ }
99
255
  infoSlot.replaceChildren();
100
256
  attachInfo(infoSlot, current.description ?? "", info);
101
257
  const stepped = current.steps.length > 1;
102
- prevBtn.hidden = nextBtn.hidden = stepLabel.hidden = !stepped;
103
258
  for (const n of scrubWrap.querySelectorAll(".pf-anim-tick")) n.remove();
104
259
  if (stepped) {
105
260
  for (const t of current.stepStarts.slice(1)) {
@@ -116,12 +271,13 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
116
271
  // 1. Text goes through textSetter (see above), so the button's text node is
117
272
  // mutated and never replaced. This is the one that matters, because it
118
273
  // holds even when the glyph legitimately changes under a held finger —
119
- // playback ending, or a step boundary crossing, mid-press.
274
+ // playback ending mid-press.
120
275
  // 2. Each element has its own renderer that leaves early when its own value
121
- // is unchanged, so a playing transport does no DOM work per frame at all.
122
- // Per ELEMENT, not per write: driving several elements off one shared
123
- // state key means a step change also redraws the button, which was still
124
- // enough to lose the click before rule 1 was in place.
276
+ // is unchanged, so a playing transport touches only what actually moved:
277
+ // the scrubber's value, and aria-valuetext when it crosses a reporting
278
+ // step. Per ELEMENT, not per write: driving several elements off one
279
+ // shared state key means a step change also redraws the button, which was
280
+ // still enough to lose the click before rule 1 was in place.
125
281
  //
126
282
  // Without rule 1 this cost the pause click outright: press, release, no click
127
283
  // event at all, and only ever while playing — the one moment the button is
@@ -129,9 +285,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
129
285
  // ~100ms; a synthetic 0ms one survives, which is why automated clicking never
130
286
  // saw it). Reset was never affected: nothing rewrites that button per frame.
131
287
  const setPlayGlyph = textSetter(playBtn);
132
- const setStepText = textSetter(stepLabel);
133
288
  let shownActive = null;
134
- let shownStep = null;
135
289
 
136
290
  function renderPlayButton(active) {
137
291
  if (active === shownActive) return;
@@ -142,26 +296,42 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
142
296
  playBtn.title = label;
143
297
  }
144
298
 
145
- function renderStepLabel(stepIndex) {
146
- if (current.steps.length <= 1 || stepIndex === shownStep) return;
147
- shownStep = stepIndex;
148
- const step = current.steps[stepIndex];
149
- setStepText(`${stepIndex + 1}/${current.steps.length} · ${step.label}`);
299
+ // aria-valuetext is the accessible chapter channel — the bubble is aria-hidden,
300
+ // so this is the only place the chapter name reaches assistive tech. An
301
+ // attribute write never eats clicks (see the WebKit note above), but a screen
302
+ // reader announces every CHANGE, and during playback the position moves on its
303
+ // own: an exact percentage would chatter ~100 times a run with the scrubber
304
+ // focused. So while playing the percentage is reported in 10% steps. A
305
+ // user-driven seek reports the exact position, which is when precision is the
306
+ // feedback the user asked for.
307
+ let shownValuetext = null;
308
+ function renderValuetext(t, stepIndex, playing) {
309
+ const pct = Math.round(t * 100);
310
+ const shown = playing ? Math.round(pct / 10) * 10 : pct;
311
+ const text = current.steps.length > 1
312
+ ? `${current.steps[stepIndex].label} — ${shown}%`
313
+ : `${shown}%`;
314
+ if (text === shownValuetext) return;
315
+ shownValuetext = text;
316
+ scrub.setAttribute("aria-valuetext", text);
150
317
  }
151
318
 
152
319
  function syncUi() {
153
320
  const { status, t, stepIndex } = playback.state();
154
- // The scrubber is the one thing that genuinely moves every frame. Assigning
155
- // `.value` updates a property rather than replacing a child node, so it
156
- // costs no clicks — and it is not what anyone reaches for mid-playback.
157
- scrub.value = String(Math.round(t * 1000));
158
- renderPlayButton(status === "playing" || status === "intro");
159
- renderStepLabel(stepIndex);
321
+ const playing = status === "playing" || status === "intro";
322
+ scrub.value = String(Math.round(t * SCRUB_STEPS));
323
+ renderPlayButton(playing);
324
+ renderValuetext(t, stepIndex, playing);
160
325
  }
161
326
 
162
- // selectAnimation swaps in a fresh playback whose state can coincide with the
163
- // outgoing one; the chrome still has to re-render for the new animation.
164
- function invalidateUi() { shownActive = null; shownStep = null; }
327
+ // Belt-and-braces for the animation swap. Both caches above key on the exact
328
+ // value written to the DOM, so today a stale one can only ever agree with what
329
+ // is already on screen this reset is currently redundant, and no test can
330
+ // pin it. It is kept because the cache it replaced (the old step readout) keyed
331
+ // on a step INDEX, which genuinely collided across animations at index 0: any
332
+ // future renderer keyed on a proxy rather than on its rendered value needs
333
+ // this hook to already exist.
334
+ function invalidateUi() { shownActive = null; shownValuetext = null; }
165
335
 
166
336
  // --- driver -----------------------------------------------------------------
167
337
  // A frame that throws — a malformed cue or track that slipped past lint, a
@@ -220,8 +390,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
220
390
  playback = createPlayback(current);
221
391
  if (animations.length > 1) pick.value = name;
222
392
  syncStructure();
223
- invalidateUi(); // a fresh playback starts idle at step 0 — the same key the
224
- // outgoing one may have been showing, but for another animation
393
+ invalidateUi();
225
394
  syncUi();
226
395
  }
227
396
 
@@ -245,21 +414,136 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
245
414
  guarded(() => playback.play());
246
415
  }
247
416
  };
248
- const onScrub = () => { disarmAutoplay(); guarded(() => playback.seek(Number(scrub.value) / 1000)); };
249
- const onPrev = () => { disarmAutoplay(); guarded(() => playback.stepPrev()); };
250
- const onNext = () => { disarmAutoplay(); guarded(() => playback.stepNext()); };
417
+ const onScrub = () => {
418
+ disarmAutoplay();
419
+ const f = Number(scrub.value) / SCRUB_STEPS;
420
+ showChapterBubble(f, { transient: true });
421
+ guarded(() => playback.seek(f));
422
+ };
423
+ // PageUp/PageDown jump whole chapters — the keyboard replacement for the
424
+ // removed step buttons. PageUp goes FORWARD, matching the key's native
425
+ // slider direction (it increases the value). Single-step animations keep
426
+ // the browser's native coarse seek instead.
427
+ //
428
+ // Targets are derived from the chapter the playhead is IN rather than by
429
+ // scanning stepStarts for the nearest boundary — same answers (verified
430
+ // equivalent across the whole 0..1 range), but it reads as the rule it
431
+ // implements and allocates nothing per keypress.
432
+ //
433
+ // AT_BOUNDARY decides "am I at this chapter's start or inside it", which is
434
+ // what makes PageDown step back rather than restart. It is one scrubber step
435
+ // because that is the finest position the user can see or reach: a tolerance
436
+ // finer than the grid would call a playhead that just landed on a boundary
437
+ // "inside" the chapter, and PageDown would restart it forever instead of
438
+ // walking back.
439
+ const onScrubKeydown = (e) => {
440
+ if (current.steps.length <= 1) return;
441
+ if (e.key !== "PageUp" && e.key !== "PageDown") return;
442
+ e.preventDefault();
443
+ disarmAutoplay();
444
+ const { t } = playback.state();
445
+ const starts = current.stepStarts;
446
+ const i = stepIndexAt(current, t);
447
+ const target = snapUpToScrubGrid(e.key === "PageUp"
448
+ ? (starts[i + 1] ?? 1)
449
+ // Inside a chapter, back up to its own start (restart it); already at the
450
+ // start, step to the chapter before — the video-player convention.
451
+ : (t > starts[i] + AT_BOUNDARY ? starts[i] : (starts[i - 1] ?? 0)));
452
+ // seek() abandons a pending cue but cannot touch the viewer's camera, so an
453
+ // in-flight tween would keep travelling to the position we just left.
454
+ viewer.cancelCameraTween();
455
+ showChapterBubble(target, { transient: true });
456
+ guarded(() => playback.seek(target));
457
+ };
458
+ const cycleAnimation = (dir) => {
459
+ disarmAutoplay();
460
+ const i = animations.indexOf(current);
461
+ selectAnimation(animations[(i + dir + animations.length) % animations.length].name);
462
+ };
463
+ const onPrevAnim = () => cycleAnimation(-1);
464
+ const onNextAnim = () => cycleAnimation(1);
251
465
  const onPick = () => { disarmAutoplay(); selectAnimation(pick.value); };
252
466
  const onResetClick = () => { disarmAutoplay(); doReset(); };
467
+ prevAnimBtn?.addEventListener("click", onPrevAnim);
468
+ nextAnimBtn?.addEventListener("click", onNextAnim);
253
469
  playBtn.addEventListener("click", onPlayClick);
254
470
  scrub.addEventListener("input", onScrub);
255
- prevBtn.addEventListener("click", onPrev);
256
- nextBtn.addEventListener("click", onNext);
471
+ scrub.addEventListener("keydown", onScrubKeydown);
257
472
  pick.addEventListener("change", onPick);
258
473
  resetBtn.addEventListener("click", onResetClick);
259
474
 
260
475
  syncStructure();
261
476
  syncUi();
262
477
 
478
+ // --- placement: keep clear of the viewbar ---------------------------------
479
+ // chrome.css centers the bar (left: 50% / translateX(-50%)), and nothing in
480
+ // CSS can stop that centered position sliding under #viewbar when the stage
481
+ // narrows — the viewbar's width is dynamic (cutaway's Flip/Reset appear and
482
+ // disappear), so a static reservation would either overlap or waste centre
483
+ // space. Measure instead: when the two bars' vertical bands intersect, clamp
484
+ // the bar's left so a 10px gap to the viewbar holds, capping its width if
485
+ // even the stage's 12px margin isn't enough. Overrides are inline and
486
+ // cleared at the top of every pass, so chrome.css (or a host that
487
+ // re-anchors either bar out of the shared band) stays authoritative the
488
+ // moment the constraint stops binding. The clear-measure-apply sequence is
489
+ // loop-safe: it settles within one frame, so ResizeObserver — which reports
490
+ // rendered sizes at frame boundaries — never sees the intermediate state.
491
+ const viewbarEl = container.querySelector("#viewbar");
492
+ let placementRaf = 0;
493
+ function applyPlacement() {
494
+ placementRaf = 0;
495
+ bar.style.left = "";
496
+ bar.style.transform = "";
497
+ bar.style.maxWidth = "";
498
+ bar.style.overflow = "";
499
+ bar.classList.remove("pf-squeezed");
500
+ const vb = viewbarEl?.getBoundingClientRect();
501
+ const barRect = bar.getBoundingClientRect();
502
+ if (!vb || barRect.top >= vb.bottom || barRect.bottom <= vb.top) return;
503
+ const stageRect = container.getBoundingClientRect();
504
+ const plan = planAnimBarPlacement({
505
+ stageWidth: stageRect.width,
506
+ barWidth: barRect.width,
507
+ viewbarLeft: vb.left - stageRect.left,
508
+ });
509
+ if (!plan) return;
510
+ bar.style.left = `${plan.left}px`;
511
+ bar.style.transform = "none";
512
+ // maxWidth is the last resort, only reached when even the margin can't
513
+ // hold the gap — and the bar's flex children have hard minimums that don't
514
+ // shrink to fit a tighter cap. overflow:hidden is applied here, inline,
515
+ // rather than as a static rule so it is scoped to the capped state and the
516
+ // (far more common) uncapped bar never clips anything.
517
+ //
518
+ // Nothing that floats above the bar may live inside it, or this clips it:
519
+ // the ⓘ popover is on document.body and the chapter bubble is on the stage,
520
+ // both for that reason.
521
+ if (plan.maxWidth != null) {
522
+ bar.style.maxWidth = `${plan.maxWidth}px`;
523
+ bar.style.overflow = "hidden";
524
+ // Under the cap the bar sheds the pagers (~40px with their gaps). They are
525
+ // pure convenience — the picker beside them reaches every animation — and
526
+ // spending that width on the timeline is what keeps the scrubber
527
+ // targetable instead of letting it collapse toward nothing.
528
+ bar.classList.add("pf-squeezed");
529
+ }
530
+ }
531
+ function schedulePlacement() {
532
+ if (typeof requestAnimationFrame !== "function") return applyPlacement();
533
+ if (!placementRaf) placementRaf = requestAnimationFrame(applyPlacement);
534
+ }
535
+ // Observing the bar itself catches content-driven width changes — the ⓘ glyph
536
+ // appearing or disappearing when only some animations declare a description;
537
+ // the viewbar, cutaway's actions; the stage, rail drags and window resizes.
538
+ const placementObserver = typeof ResizeObserver === "function"
539
+ ? new ResizeObserver(schedulePlacement) : null;
540
+ if (placementObserver) {
541
+ placementObserver.observe(container);
542
+ placementObserver.observe(bar);
543
+ if (viewbarEl) placementObserver.observe(viewbarEl);
544
+ }
545
+ schedulePlacement();
546
+
263
547
  const runtime = {
264
548
  // An unknown name is a host bug, not a request to play whatever happens to
265
549
  // be selected — say so and do nothing rather than silently animating
@@ -299,13 +583,21 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
299
583
  detach() {
300
584
  offFrame();
301
585
  offOrbit();
586
+ prevAnimBtn?.removeEventListener("click", onPrevAnim);
587
+ nextAnimBtn?.removeEventListener("click", onNextAnim);
302
588
  playBtn.removeEventListener("click", onPlayClick);
303
589
  scrub.removeEventListener("input", onScrub);
304
- prevBtn.removeEventListener("click", onPrev);
305
- nextBtn.removeEventListener("click", onNext);
590
+ scrub.removeEventListener("keydown", onScrubKeydown);
306
591
  pick.removeEventListener("change", onPick);
307
592
  resetBtn.removeEventListener("click", onResetClick);
308
593
  info.dispose();
594
+ placementObserver?.disconnect();
595
+ if (placementRaf && typeof cancelAnimationFrame === "function") cancelAnimationFrame(placementRaf);
596
+ scrubWrap.removeEventListener("pointermove", onWrapPointerMove);
597
+ scrubWrap.removeEventListener("pointerleave", onWrapPointerLeave);
598
+ scrubWrap.removeEventListener("pointercancel", onWrapPointerLeave);
599
+ hideChapterBubble(); // also clears hoverInside
600
+ chapterBubble.remove(); // a stage child, so the bar taking itself out misses it
309
601
  bar.remove();
310
602
  },
311
603
  __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
  }
@@ -236,19 +239,55 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
236
239
  .pf-anim-pick {
237
240
  background: var(--pf-input-bg); border: 1px solid var(--pf-border);
238
241
  border-radius: var(--pf-radius-control); padding: 3px 6px;
239
- }
240
- .pf-anim-step {
241
- font-family: var(--pf-mono); font-size: 10px; color: var(--pf-muted-2);
242
- min-width: 90px; text-align: center; white-space: nowrap;
243
- overflow: hidden; text-overflow: ellipsis;
244
- }
245
- .pf-anim-scrub-wrap { position: relative; display: inline-flex; align-items: center; width: 140px; }
242
+ /* The widest thing in the bar after the timeline, and the cheapest to give
243
+ up: a select's default `min-width: auto` floors it at its longest option,
244
+ so under pressure it would hold full width while the scrubber collapsed.
245
+ Let it yield first, and faster than everything else — but not to a bare
246
+ chevron: below this the control stops naming the animation it selects. */
247
+ min-width: 44px; flex-shrink: 4;
248
+ }
249
+ /* A child of the stage, not of the bar (the capped bar clips its contents, and
250
+ this sits above them) — animation-controls.js sets left/bottom per reveal,
251
+ stage-relative. z-index clears .pf-anim-bar's 15. */
252
+ .pf-anim-chapter {
253
+ position: absolute; transform: translateX(-50%); z-index: 16;
254
+ font-family: var(--pf-mono); font-size: 10px; color: var(--pf-text-2);
255
+ background: var(--pf-surface); border: 1px solid var(--pf-border);
256
+ border-radius: var(--pf-radius-control); box-shadow: var(--pf-shadow-float);
257
+ padding: 3px 8px; white-space: nowrap;
258
+ pointer-events: none;
259
+ /* Out of flow, so nothing else constrains it: a long chapter label would
260
+ otherwise run past both ends of the scrubber and off a narrow stage.
261
+ Capped at the wrap's nominal width and ellipsised instead. */
262
+ max-width: 220px; overflow: hidden; text-overflow: ellipsis;
263
+ opacity: 0; transition: opacity .12s ease;
264
+ }
265
+ .pf-anim-chapter.pf-show { opacity: 1; }
266
+ /* 220px when there is room. The floor is what matters under pressure: a
267
+ timeline is a pointing target, and below ~96px it stops being one — the
268
+ chapter ticks collapse onto each other and a drag can't resolve a chapter.
269
+ Everything else in the bar yields before this does. */
270
+ .pf-anim-scrub-wrap {
271
+ position: relative; display: inline-flex; align-items: center;
272
+ width: 220px; min-width: 80px;
273
+ }
274
+ /* Capped bar: the pagers go, and their width goes to the timeline. */
275
+ .pf-anim-bar.pf-squeezed .pf-anim-page { display: none; }
246
276
  .pf-anim-scrub { width: 100%; accent-color: var(--pf-accent); }
247
277
  .pf-anim-tick {
248
278
  position: absolute; top: 50%; width: 2px; height: 8px; margin-top: -4px;
249
279
  background: var(--pf-muted); pointer-events: none;
250
280
  }
251
281
 
282
+ @media (prefers-reduced-motion: reduce) {
283
+ .pf-anim-chapter { transition: none; }
284
+ }
285
+
286
+ @media (max-width: 360px) {
287
+ /* #viewbar's card is 40px here (30px buttons) — keep the floor in step. */
288
+ .pf-anim-bar { min-height: 40px; }
289
+ }
290
+
252
291
  /* Legacy id-only markup only: classed markup's viewbar lives inside .pf-stage
253
292
  (bottom-right, see chrome.css's .pf-float-viewbar) so it never meets the
254
293
  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);