@hyperframes/studio 0.7.77 → 0.7.79

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.
Files changed (77) hide show
  1. package/dist/assets/{hyperframes-player-CEggaaxR.js → hyperframes-player-mFah2TZE.js} +1 -1
  2. package/dist/assets/{index-B1Tjjdse.js → index-Bqj3h_1a.js} +1 -1
  3. package/dist/assets/{index-Bp4jAYZG.js → index-DlZMDyYs.js} +194 -194
  4. package/dist/assets/index-gGVKuFg5.css +1 -0
  5. package/dist/assets/{index-B1INQNmj.js → index-hmnoiSEV.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1432 -1181
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioLeftSidebar.tsx +10 -5
  11. package/src/components/StudioRightPanel.tsx +5 -4
  12. package/src/components/TimelineToolbar.tsx +3 -1
  13. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +4 -1
  15. package/src/components/editor/PropertyPanel.tsx +3 -7
  16. package/src/components/editor/keyframeRetime.test.ts +42 -0
  17. package/src/components/editor/keyframeRetime.ts +4 -1
  18. package/src/components/editor/motionPathSelection.test.ts +65 -0
  19. package/src/components/editor/motionPathSelection.ts +13 -3
  20. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  21. package/src/components/editor/propertyPanelColor.tsx +32 -26
  22. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  23. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  24. package/src/components/pointerTargetSize.test.tsx +65 -0
  25. package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
  26. package/src/components/sidebar/CompositionsTab.tsx +70 -33
  27. package/src/hooks/gsapDragCommit.ts +43 -10
  28. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  29. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
  30. package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
  31. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  32. package/src/hooks/gsapResizeIntercept.ts +2 -2
  33. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  34. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  35. package/src/hooks/gsapShared.test.ts +144 -0
  36. package/src/hooks/gsapShared.ts +264 -0
  37. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  38. package/src/hooks/keyframeCacheAstLoad.ts +5 -71
  39. package/src/hooks/newTweenTarget.test.ts +364 -0
  40. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  41. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  42. package/src/hooks/timelineTrackVisibility.ts +11 -1
  43. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  44. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  45. package/src/hooks/useEnableKeyframes.ts +18 -5
  46. package/src/hooks/useGestureCommit.ts +21 -4
  47. package/src/hooks/useGsapScriptCommits.ts +3 -0
  48. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  49. package/src/hooks/useGsapTweenCache.ts +4 -11
  50. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  51. package/src/player/components/Player.test.ts +143 -12
  52. package/src/player/components/Player.tsx +84 -43
  53. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  54. package/src/player/components/ShortcutsPanel.tsx +11 -15
  55. package/src/player/components/Timeline.test.ts +3 -1
  56. package/src/player/components/TimelineCanvas.tsx +2 -1
  57. package/src/player/components/TimelineClipDiamonds.test.tsx +17 -2
  58. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  59. package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
  60. package/src/player/components/TimelineLanes.test.tsx +281 -0
  61. package/src/player/components/TimelineLanes.tsx +43 -97
  62. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  63. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  64. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  65. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  66. package/src/player/components/timelineLaneProps.ts +86 -0
  67. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  68. package/src/player/components/timelineTrackDisplay.ts +38 -0
  69. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  70. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  71. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  72. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  73. package/src/player/hooks/useExpandedTimelineElements.test.ts +151 -2
  74. package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
  75. package/src/telemetry/client.test.ts +12 -3
  76. package/src/telemetry/client.ts +3 -2
  77. package/dist/assets/index-CBAfprvx.css +0 -1
@@ -73,9 +73,9 @@ export function TimelineResizeDivider({
73
73
  );
74
74
 
75
75
  return (
76
- // Horizontal resize divider: 3px visible seam (h-[3px]), 8px pointer-capture
76
+ // Horizontal resize divider: 3px visible seam (h-[3px]), 10px pointer-capture
77
77
  // zone via the absolutely-positioned inner hit area so the layout gap stays
78
- // at 3px while draggability is preserved over the full 8px band.
78
+ // at 3px while draggability is preserved over the full band.
79
79
  <div
80
80
  role="separator"
81
81
  aria-orientation="horizontal"
@@ -94,8 +94,13 @@ export function TimelineResizeDivider({
94
94
  onPointerCancel={handlePointerUp}
95
95
  onKeyDown={handleKeyDown}
96
96
  >
97
- {/* Expanded hit zone: 8px tall, centered on the 3px seam */}
98
- <div className="absolute inset-x-0 -top-[2.5px] h-2" />
97
+ {/* Expanded hit zone, deliberately asymmetric: 4px up into the transport
98
+ row's vertical centring slack, the 3px seam, then 3px down into the
99
+ timeline card's border + toolbar padding. It stops at 10px rather than
100
+ the 24px WCAG 2.2 (2.5.8) target because that is all the dead space
101
+ there is: 28px transport buttons sit above and 28px toolbar buttons
102
+ below, and silently stealing their clicks is the worse bug. */}
103
+ <div className="absolute inset-x-0 -top-[4px] h-[10px]" />
99
104
  {/* Visible hairline — invisible at rest, subtle wash on hover/drag/focus */}
100
105
  <div className="h-[3px] w-full bg-transparent transition-colors group-hover:bg-white/12 group-active:bg-white/18 group-focus-visible:bg-studio-accent/60" />
101
106
  </div>
@@ -0,0 +1,65 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { CompositionsTab } from "./sidebar/CompositionsTab";
7
+ import { TimelineToolbar } from "./TimelineToolbar";
8
+
9
+ // WCAG 2.2 (2.5.8) requires a 24x24 CSS pixel pointer target. happy-dom has no
10
+ // CSS engine, so getBoundingClientRect() is 0x0 for everything here and the size
11
+ // itself cannot be measured — these assert the utility classes that PRODUCE the
12
+ // size instead, which is enough to fail if someone drops them. Real geometry
13
+ // still needs a browser check.
14
+
15
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
16
+ (
17
+ window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
18
+ ).happyDOM.settings.disableIframePageLoading = true;
19
+
20
+ let root: Root | null = null;
21
+
22
+ afterEach(() => {
23
+ if (root) act(() => root?.unmount());
24
+ root = null;
25
+ document.body.innerHTML = "";
26
+ });
27
+
28
+ function mount(element: React.ReactNode) {
29
+ const host = document.createElement("div");
30
+ document.body.append(host);
31
+ root = createRoot(host);
32
+ act(() => root?.render(element));
33
+ return host;
34
+ }
35
+
36
+ describe("pointer target sizing classes (proxy for WCAG 2.5.8, not a geometry check)", () => {
37
+ it("gives the timeline zoom slider a 24px box without changing its 2px track or 10px thumb", () => {
38
+ const host = mount(<TimelineToolbar />);
39
+ const slider = host.querySelector<HTMLInputElement>('input[aria-label="Timeline zoom"]');
40
+ if (!slider) throw new Error("zoom slider did not render");
41
+
42
+ expect(slider.className).toContain("h-6");
43
+ expect(slider.className).toContain("[&::-webkit-slider-runnable-track]:h-[2px]");
44
+ expect(slider.className).toContain("[&::-webkit-slider-thumb]:h-[10px]");
45
+ expect(slider.className).toContain("[&::-webkit-slider-thumb]:w-[10px]");
46
+ });
47
+
48
+ it("gives the composition card's render button a 24x24 box around its 14px glyph", () => {
49
+ const host = mount(
50
+ <CompositionsTab
51
+ projectId="demo"
52
+ compositions={["compositions/headline.html"]}
53
+ activeComposition={null}
54
+ onSelect={vi.fn()}
55
+ onRenderComposition={vi.fn()}
56
+ />,
57
+ );
58
+ const button = host.querySelector<HTMLButtonElement>('button[aria-label="Render headline"]');
59
+ if (!button) throw new Error("render button did not render");
60
+
61
+ expect(button.className).toContain("h-6");
62
+ expect(button.className).toContain("w-6");
63
+ expect(button.querySelector("svg")?.getAttribute("width")).toBe("14");
64
+ });
65
+ });
@@ -40,6 +40,47 @@ function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
40
40
  }
41
41
 
42
42
  describe("composition card drag", () => {
43
+ it("uses a cached image instead of eagerly mounting a live preview iframe", () => {
44
+ const { host } = mount();
45
+ const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
46
+ expect(thumbnail).not.toBeNull();
47
+ expect(new URL(thumbnail?.src ?? "").searchParams.get("t")).toBe("3.00");
48
+ expect(host.querySelector("iframe")).toBeNull();
49
+ });
50
+
51
+ it("shows a fallback when the cached thumbnail fails", () => {
52
+ const { host } = mount();
53
+ const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
54
+ if (!thumbnail) throw new Error("composition thumbnail did not render");
55
+
56
+ act(() => thumbnail.dispatchEvent(new Event("error")));
57
+
58
+ expect(host.textContent).toContain("Preview unavailable");
59
+ expect(host.querySelector('img[src*="/thumbnail/"]')).toBeNull();
60
+ });
61
+
62
+ it("mounts one live preview only after sustained hover and removes it on leave", () => {
63
+ vi.useFakeTimers();
64
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
65
+ try {
66
+ const { host, card } = mount();
67
+ act(() => {
68
+ card.dispatchEvent(new Event("pointerover", { bubbles: true }));
69
+ vi.advanceTimersByTime(300);
70
+ });
71
+ expect(host.querySelectorAll("iframe")).toHaveLength(1);
72
+
73
+ act(() => {
74
+ card.dispatchEvent(new Event("pointerout", { bubbles: true }));
75
+ });
76
+ expect(host.querySelector("iframe")).toBeNull();
77
+ expect(vi.getTimerCount()).toBe(0);
78
+ } finally {
79
+ consoleError.mockRestore();
80
+ vi.useRealTimers();
81
+ }
82
+ });
83
+
43
84
  it("keeps ordinary click navigation", () => {
44
85
  const { card, onSelect } = mount();
45
86
  act(() => card.click());
@@ -1,5 +1,6 @@
1
1
  import { memo, useCallback, useEffect, useRef, useState } from "react";
2
2
  import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
3
+ import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
3
4
  import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
4
5
 
5
6
  interface CompositionsTabProps {
@@ -130,6 +131,8 @@ function CompCard({
130
131
  }) {
131
132
  const [hovered, setHovered] = useState(false);
132
133
  const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
134
+ const [livePreviewLoaded, setLivePreviewLoaded] = useState(false);
135
+ const [thumbnailFailed, setThumbnailFailed] = useState(false);
133
136
  const iframeRef = useRef<HTMLIFrameElement | null>(null);
134
137
  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
135
138
  const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -158,10 +161,21 @@ function CompCard({
158
161
  clearTimeout(hoverTimer.current);
159
162
  hoverTimer.current = null;
160
163
  }
164
+ if (syncTimer.current) {
165
+ clearTimeout(syncTimer.current);
166
+ syncTimer.current = null;
167
+ }
161
168
  setHovered(false);
169
+ setLivePreviewLoaded(false);
162
170
  };
163
171
  const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
164
172
  const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
173
+ const thumbnailUrl = buildCompositionThumbnailUrl({
174
+ previewUrl,
175
+ seekTime: THUMBNAIL_SEEK_TIME_SECONDS,
176
+ duration: 0,
177
+ origin: window.location.origin,
178
+ });
165
179
  const previewScale = resolveCompositionPreviewScale({
166
180
  cardWidth: CARD_W,
167
181
  cardHeight: CARD_H,
@@ -172,7 +186,7 @@ function CompCard({
172
186
  const thumbnailOffsetY = (CARD_H - stageSize.height * previewScale) / 2;
173
187
 
174
188
  useEffect(() => {
175
- requestIframePlaybackSync(hovered);
189
+ if (hovered) requestIframePlaybackSync(true);
176
190
  }, [hovered, requestIframePlaybackSync]);
177
191
 
178
192
  useEffect(() => {
@@ -216,36 +230,56 @@ function CompCard({
216
230
  }`}
217
231
  >
218
232
  <div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
219
- <iframe
220
- ref={iframeRef}
221
- src={previewUrl}
222
- sandbox="allow-scripts allow-same-origin"
223
- loading="lazy"
224
- className="absolute border-none pointer-events-none"
225
- style={{
226
- transformOrigin: "0 0",
227
- width: stageSize.width,
228
- height: stageSize.height,
229
- left: thumbnailOffsetX,
230
- top: thumbnailOffsetY,
231
- transform: `scale(${previewScale})`,
232
- }}
233
- onLoad={(e) => {
234
- try {
235
- const iframe = e.currentTarget;
236
- const root = iframe.contentDocument?.querySelector("[data-composition-id]");
237
- const width = Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
238
- const height =
239
- Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
240
- setStageSize({ width, height });
241
- requestIframePlaybackSync(hovered);
242
- } catch {
243
- setStageSize(DEFAULT_PREVIEW_STAGE);
244
- }
245
- }}
246
- title={`${name} preview`}
247
- tabIndex={-1}
248
- />
233
+ {thumbnailFailed ? (
234
+ <div className="absolute inset-0 flex items-center justify-center px-1 text-center text-[8px] leading-tight text-neutral-600">
235
+ Preview unavailable
236
+ </div>
237
+ ) : (
238
+ <img
239
+ src={thumbnailUrl}
240
+ alt=""
241
+ draggable={false}
242
+ loading="lazy"
243
+ decoding="async"
244
+ onError={() => setThumbnailFailed(true)}
245
+ className={`absolute inset-0 h-full w-full object-contain transition-opacity ${
246
+ livePreviewLoaded ? "opacity-0" : "opacity-100"
247
+ }`}
248
+ />
249
+ )}
250
+ {hovered && (
251
+ <iframe
252
+ ref={iframeRef}
253
+ src={previewUrl}
254
+ sandbox="allow-scripts allow-same-origin"
255
+ className="absolute border-none pointer-events-none"
256
+ style={{
257
+ transformOrigin: "0 0",
258
+ width: stageSize.width,
259
+ height: stageSize.height,
260
+ left: thumbnailOffsetX,
261
+ top: thumbnailOffsetY,
262
+ transform: `scale(${previewScale})`,
263
+ }}
264
+ onLoad={(e) => {
265
+ try {
266
+ const iframe = e.currentTarget;
267
+ const root = iframe.contentDocument?.querySelector("[data-composition-id]");
268
+ const width =
269
+ Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
270
+ const height =
271
+ Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
272
+ setStageSize({ width, height });
273
+ setLivePreviewLoaded(true);
274
+ requestIframePlaybackSync(true);
275
+ } catch {
276
+ setStageSize(DEFAULT_PREVIEW_STAGE);
277
+ }
278
+ }}
279
+ title={`${name} preview`}
280
+ tabIndex={-1}
281
+ />
282
+ )}
249
283
  </div>
250
284
  <div
251
285
  className="min-w-0 flex-1"
@@ -283,7 +317,10 @@ function CompCard({
283
317
  e.stopPropagation();
284
318
  onRender();
285
319
  }}
286
- className={`flex-shrink-0 p-1 rounded transition-colors ${
320
+ // h-6 w-6 = the 24x24 WCAG 2.2 (2.5.8) minimum target; the 14px glyph
321
+ // is unchanged, only the box grows. The sibling "+" button is h-8 w-8,
322
+ // so the card row already has the room.
323
+ className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded transition-colors ${
287
324
  isRendering
288
325
  ? "text-neutral-600 cursor-not-allowed"
289
326
  : "text-neutral-600 hover:text-studio-accent hover:bg-neutral-800"
@@ -331,7 +368,7 @@ export const CompositionsTab = memo(function CompositionsTab({
331
368
  <div className="flex-1 overflow-y-auto">
332
369
  {compositions.map((comp) => (
333
370
  <CompCard
334
- key={comp}
371
+ key={`${projectId}:${comp}`}
335
372
  projectId={projectId}
336
373
  comp={comp}
337
374
  isActive={activeComposition === comp}
@@ -12,7 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore";
12
12
  import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
13
13
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
14
14
  import { roundTo3 } from "../utils/rounding";
15
- import { computeElementPercentage, idSelector } from "./gsapShared";
15
+ import { computeElementPercentage, idSelector, writeTargetSelector } from "./gsapShared";
16
16
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
17
17
  import type { RuntimeTweenChange } from "./gsapRuntimePatch";
18
18
  import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
@@ -44,6 +44,23 @@ export interface GsapDragCommitCallbacks {
44
44
  fetchAnimations?: () => Promise<GsapAnimation[]>;
45
45
  }
46
46
 
47
+ /**
48
+ * The target for a tween these helpers are about to CREATE. Callers derive
49
+ * `selector` with `selectorFromSelection`, which hands back a bare class for an
50
+ * id-less element: authoring that widens a one-element drag/resize/rotate into a
51
+ * write over every sibling sharing the class. Retargets of an EXISTING tween
52
+ * must NOT come through here (they keep `anim.targetSelector`, so a tween the
53
+ * author aimed at a group stays aimed at it).
54
+ *
55
+ * Null means no one-element form exists, and every caller drops the commit
56
+ * rather than falling back to `selector` (see writeTargetSelector): the drag
57
+ * reverts on the next reload, which is recoverable, where a `.group` write is
58
+ * not.
59
+ */
60
+ function newTweenTarget(selection: DomEditSelection): string | null {
61
+ return writeTargetSelector(selection);
62
+ }
63
+
47
64
  // Re-export for backward compatibility with existing imports.
48
65
  export function computeCurrentPercentage(
49
66
  selection: DomEditSelection,
@@ -65,17 +82,18 @@ export function parkPlayheadOnKeyframe(anim: GsapAnimation, pct: number): void {
65
82
 
66
83
  async function replaceKeyframedPositionHold(
67
84
  selection: DomEditSelection,
68
- selector: string,
69
85
  existingSet: GsapAnimation,
70
86
  properties: { x: number; y: number },
71
87
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
72
88
  ): Promise<void> {
89
+ const target = newTweenTarget(selection);
90
+ if (!target) return;
73
91
  const persist = async (commit: GsapDragCommitCallbacks["commitMutation"]) => {
74
92
  await commit(
75
93
  selection,
76
94
  {
77
95
  type: "add",
78
- targetSelector: selector,
96
+ targetSelector: target,
79
97
  method: "set",
80
98
  position: 0,
81
99
  properties,
@@ -174,7 +192,6 @@ export async function commitStaticGsapPosition(
174
192
  // least one hold on disk, then delete the corrupt tween in one transaction.
175
193
  await replaceKeyframedPositionHold(
176
194
  selection,
177
- selector,
178
195
  existingSet,
179
196
  { x: newX, y: newY },
180
197
  callbacks.commitMutation,
@@ -199,11 +216,15 @@ export async function commitStaticGsapPosition(
199
216
  }
200
217
  // New static hold → a base `gsap.set` (off-timeline, no 0% keyframe marker), with
201
218
  // an instant patch so the first nudge shows immediately (no soft-reload flash).
219
+ // The patch reuses the WRITTEN target so the runtime moves exactly the element
220
+ // the source write names.
221
+ const target = newTweenTarget(selection);
222
+ if (!target) return;
202
223
  await callbacks.commitMutation(
203
224
  selection,
204
225
  {
205
226
  type: "add",
206
- targetSelector: selector,
227
+ targetSelector: target,
207
228
  method: "set",
208
229
  position: 0,
209
230
  properties: { x: newX, y: newY },
@@ -212,7 +233,10 @@ export async function commitStaticGsapPosition(
212
233
  {
213
234
  label: "Move layer",
214
235
  softReload: true,
215
- instantPatch: { selector, change: { kind: "global-set", props: { x: newX, y: newY } } },
236
+ instantPatch: {
237
+ selector: target,
238
+ change: { kind: "global-set", props: { x: newX, y: newY } },
239
+ },
216
240
  },
217
241
  );
218
242
  }
@@ -252,11 +276,13 @@ export async function commitStaticGsapRotation(
252
276
  return;
253
277
  }
254
278
  // New static hold → off-timeline `gsap.set` (no 0% keyframe marker) + instant patch.
279
+ const target = newTweenTarget(selection);
280
+ if (!target) return;
255
281
  await callbacks.commitMutation(
256
282
  selection,
257
283
  {
258
284
  type: "add",
259
- targetSelector: selector,
285
+ targetSelector: target,
260
286
  method: "set",
261
287
  position: 0,
262
288
  properties: { rotation: newRotation },
@@ -265,7 +291,10 @@ export async function commitStaticGsapRotation(
265
291
  {
266
292
  label: "Rotate layer",
267
293
  softReload: true,
268
- instantPatch: { selector, change: { kind: "global-set", props: { rotation: newRotation } } },
294
+ instantPatch: {
295
+ selector: target,
296
+ change: { kind: "global-set", props: { rotation: newRotation } },
297
+ },
269
298
  },
270
299
  );
271
300
  }
@@ -301,11 +330,13 @@ export async function commitStaticGsapSize(
301
330
  );
302
331
  return;
303
332
  }
333
+ const target = newTweenTarget(selection);
334
+ if (!target) return;
304
335
  await callbacks.commitMutation(
305
336
  selection,
306
337
  {
307
338
  type: "add",
308
- targetSelector: selector,
339
+ targetSelector: target,
309
340
  method: "set",
310
341
  position: 0,
311
342
  properties: { width, height },
@@ -389,11 +420,13 @@ export async function commitKeyframedSizeFromResize(
389
420
  // transport applies both in one ordered batch; a plain commit fallback keeps the
390
421
  // same recoverable ordering. Only the transaction's result triggers the reload.
391
422
  const addLabel = `Resize (size keyframe ${pct.toFixed(0)}%)`;
423
+ const target = newTweenTarget(selection);
424
+ if (!target) return false;
392
425
  await callbacks.commitMutation(
393
426
  selection,
394
427
  {
395
428
  type: "add-with-keyframes",
396
- targetSelector: selector,
429
+ targetSelector: target,
397
430
  position: roundTo3(ts),
398
431
  duration: roundTo3(td),
399
432
  keyframes,
@@ -1,6 +1,6 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
  import type { RuntimeTweenChange, SetPatchProps } from "./gsapRuntimePatch";
3
- import { isInstantHold } from "./gsapShared";
3
+ import { isInstantHold, tweenTargetsElement } from "./gsapShared";
4
4
 
5
5
  /** The shape of an `update-property` mutation a static-set nudge POSTs. */
6
6
  interface UpdatePropertyMutation {
@@ -34,12 +34,13 @@ export function setPatchFromUpdateProperty(
34
34
  function findPositionSetAnimation(
35
35
  animations: GsapAnimation[],
36
36
  selector: string,
37
+ element?: Element | null,
37
38
  ): GsapAnimation | null {
38
39
  return (
39
40
  animations.find(
40
41
  (a) =>
41
42
  a.method === "set" &&
42
- a.targetSelector === selector &&
43
+ tweenTargetsElement(a.targetSelector, selector, element) &&
43
44
  ("x" in a.properties || "y" in a.properties),
44
45
  ) ?? null
45
46
  );
@@ -61,12 +62,16 @@ function findPositionSetAnimation(
61
62
  export function findExistingPositionWrite(
62
63
  animations: GsapAnimation[],
63
64
  selector: string,
65
+ element?: Element | null,
64
66
  ): GsapAnimation | null {
65
- const set = findPositionSetAnimation(animations, selector);
67
+ const set = findPositionSetAnimation(animations, selector, element);
66
68
  if (set) return set;
67
69
  return (
68
70
  animations.find(
69
- (a) => a.targetSelector === selector && a.propertyGroup === "position" && isInstantHold(a),
71
+ (a) =>
72
+ tweenTargetsElement(a.targetSelector, selector, element) &&
73
+ a.propertyGroup === "position" &&
74
+ isInstantHold(a),
70
75
  ) ?? null
71
76
  );
72
77
  }
@@ -74,10 +79,14 @@ export function findExistingPositionWrite(
74
79
  export function findRotationSetAnimation(
75
80
  animations: GsapAnimation[],
76
81
  selector: string,
82
+ element?: Element | null,
77
83
  ): GsapAnimation | null {
78
84
  return (
79
85
  animations.find(
80
- (a) => isInstantHold(a) && a.targetSelector === selector && "rotation" in a.properties,
86
+ (a) =>
87
+ isInstantHold(a) &&
88
+ tweenTargetsElement(a.targetSelector, selector, element) &&
89
+ "rotation" in a.properties,
81
90
  ) ?? null
82
91
  );
83
92
  }
@@ -85,12 +94,13 @@ export function findRotationSetAnimation(
85
94
  export function findSizeSetAnimation(
86
95
  animations: GsapAnimation[],
87
96
  selector: string,
97
+ element?: Element | null,
88
98
  ): GsapAnimation | null {
89
99
  return (
90
100
  animations.find(
91
101
  (a) =>
92
102
  isInstantHold(a) &&
93
- a.targetSelector === selector &&
103
+ tweenTargetsElement(a.targetSelector, selector, element) &&
94
104
  ("width" in a.properties || "height" in a.properties),
95
105
  ) ?? null
96
106
  );
@@ -276,6 +276,69 @@ describe("updateKeyframeCacheFromParsed", () => {
276
276
  expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]);
277
277
  });
278
278
 
279
+ // `#stat3 .block` animates the BLOCK inside #stat3, not #stat3. An unanchored
280
+ // `^#([\w-]+)/` prefix match filed it under "stat3", which both stole the
281
+ // child's diamonds and collided with #stat3's own tween at the shared
282
+ // percentage (dropping #stat3's ease as ambiguous).
283
+ it("attributes a descendant selector to the child, not to its ancestor", () => {
284
+ const parent: GsapAnimation = {
285
+ id: "stat3-fromTo",
286
+ targetSelector: "#stat3",
287
+ method: "fromTo",
288
+ position: 8.88,
289
+ resolvedStart: 8.88,
290
+ duration: 0.25,
291
+ fromProperties: { y: 20 },
292
+ properties: { y: 0 },
293
+ ease: "power2.out",
294
+ propertyGroup: "position",
295
+ };
296
+ const child: GsapAnimation = {
297
+ id: "block-from",
298
+ targetSelector: "#stat3 .block",
299
+ method: "from",
300
+ position: 9.13,
301
+ resolvedStart: 9.13,
302
+ duration: 0.3,
303
+ properties: { opacity: 0 },
304
+ ease: "power2.in",
305
+ propertyGroup: "visual",
306
+ };
307
+ usePlayerStore.setState({
308
+ elements: [
309
+ { id: "stat3-clip", domId: "stat3", tag: "div", start: 8.88, duration: 1, track: 0 },
310
+ ],
311
+ });
312
+ const doc = {
313
+ querySelectorAll: (selector: string) =>
314
+ (selector === "#stat3 .block"
315
+ ? [{ id: "stat3-block" }]
316
+ : []) as unknown as NodeListOf<Element>,
317
+ } as unknown as Document;
318
+
319
+ updateKeyframeCacheFromParsed([parent, child], "scene.html", "stat3", {}, doc);
320
+
321
+ expect(usePlayerStore.getState().gsapAnimations.get("scene.html#stat3")).toEqual([parent]);
322
+ expect(usePlayerStore.getState().gsapAnimations.get("scene.html#stat3-block")).toEqual([child]);
323
+ // With the collision gone, #stat3's own curve survives instead of being
324
+ // deleted as an ambiguous same-percentage merge.
325
+ const parentKeyframes = cache().get("scene.html#stat3")?.keyframes ?? [];
326
+ expect(parentKeyframes.at(-1)?.ease).toBe("power2.out");
327
+ expect(parentKeyframes.some((keyframe) => "easeAmbiguous" in keyframe)).toBe(false);
328
+ });
329
+
330
+ it("attributes a descendant selector to nothing when there is no document", () => {
331
+ const child: GsapAnimation = {
332
+ ...animWithKeyframes("block-from"),
333
+ targetSelector: "#stat3 .block",
334
+ };
335
+
336
+ updateKeyframeCacheFromParsed([child], "scene.html", "stat3", {});
337
+
338
+ expect(cache().has("scene.html#stat3")).toBe(false);
339
+ expect(usePlayerStore.getState().gsapAnimations.has("scene.html#stat3")).toBe(false);
340
+ });
341
+
279
342
  it("does not cache a flat tween without animatable numeric properties", () => {
280
343
  const animation: GsapAnimation = {
281
344
  id: "flat-box",