@hyperframes/studio 0.7.78 → 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 (74) hide show
  1. package/dist/assets/{hyperframes-player-uiAAPKvw.js → hyperframes-player-mFah2TZE.js} +1 -1
  2. package/dist/assets/{index-CDSTMtUs.js → index-Bqj3h_1a.js} +1 -1
  3. package/dist/assets/{index-BSZrK0bx.js → index-DlZMDyYs.js} +194 -194
  4. package/dist/assets/index-gGVKuFg5.css +1 -0
  5. package/dist/assets/{index-r1tKKlU7.js → index-hmnoiSEV.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1303 -1121
  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.tsx +4 -1
  26. package/src/hooks/gsapDragCommit.ts +43 -10
  27. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  28. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
  29. package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
  30. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  31. package/src/hooks/gsapResizeIntercept.ts +2 -2
  32. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  33. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  34. package/src/hooks/gsapShared.test.ts +144 -0
  35. package/src/hooks/gsapShared.ts +264 -0
  36. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  37. package/src/hooks/keyframeCacheAstLoad.ts +5 -71
  38. package/src/hooks/newTweenTarget.test.ts +364 -0
  39. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  40. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  41. package/src/hooks/timelineTrackVisibility.ts +11 -1
  42. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  43. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  44. package/src/hooks/useEnableKeyframes.ts +18 -5
  45. package/src/hooks/useGestureCommit.ts +21 -4
  46. package/src/hooks/useGsapScriptCommits.ts +3 -0
  47. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  48. package/src/hooks/useGsapTweenCache.ts +4 -11
  49. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  50. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  51. package/src/player/components/ShortcutsPanel.tsx +11 -15
  52. package/src/player/components/Timeline.test.ts +3 -1
  53. package/src/player/components/TimelineCanvas.tsx +2 -1
  54. package/src/player/components/TimelineClipDiamonds.test.tsx +17 -2
  55. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  56. package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
  57. package/src/player/components/TimelineLanes.test.tsx +281 -0
  58. package/src/player/components/TimelineLanes.tsx +43 -97
  59. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  60. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  61. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  62. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  63. package/src/player/components/timelineLaneProps.ts +86 -0
  64. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  65. package/src/player/components/timelineTrackDisplay.ts +38 -0
  66. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  67. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  68. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  69. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  70. package/src/player/hooks/useExpandedTimelineElements.test.ts +151 -2
  71. package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
  72. package/src/telemetry/client.test.ts +12 -3
  73. package/src/telemetry/client.ts +3 -2
  74. package/dist/assets/index-DpkO2Hvw.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
+ });
@@ -317,7 +317,10 @@ function CompCard({
317
317
  e.stopPropagation();
318
318
  onRender();
319
319
  }}
320
- 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 ${
321
324
  isRendering
322
325
  ? "text-neutral-600 cursor-not-allowed"
323
326
  : "text-neutral-600 hover:text-studio-accent hover:bg-neutral-800"
@@ -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",
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
7
- import { idFromSelector, toClipKeyframes } from "./gsapShared";
7
+ import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
8
8
  import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
9
9
 
10
10
  export function updateKeyframeCacheFromParsed(
@@ -12,58 +12,65 @@ export function updateKeyframeCacheFromParsed(
12
12
  targetPath: string,
13
13
  selectionId: string | undefined,
14
14
  mutation: Record<string, unknown>,
15
+ doc?: Document | null,
15
16
  ): void {
16
- const { setKeyframeCache, elements } = usePlayerStore.getState();
17
+ const { setKeyframeCache, elements, domClipChildren } = usePlayerStore.getState();
17
18
  const idsWithKeyframes = new Set<string>();
18
19
  const merged = new Map<string, KeyframeCacheEntry>();
19
20
  const sourceAnimations = new Map<string, GsapAnimation[]>();
20
21
  for (const anim of animations) {
21
- const id = idFromSelector(anim.targetSelector);
22
22
  const kfSource =
23
23
  anim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(anim)?.keyframes ?? [];
24
- if (!id || kfSource.length === 0) continue;
25
- idsWithKeyframes.add(id);
26
- // Every tween that fed keyframeCache also lands in gsapAnimations, group or
27
- // not: a mixed-group tween (`{ x, opacity }` classifies to undefined) used to
28
- // cache diamonds with no source animation behind them, so the collapsed row
29
- // drew keyframes the expanded lanes couldn't render. Lane consumers do the
30
- // group filtering themselves (animationContributesLane).
31
- sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]);
24
+ if (kfSource.length === 0) continue;
25
+ // Attribute the tween to every element it actually animates. A leading-id
26
+ // match filed `#stat3 .block` under `#stat3`: the child's diamonds landed on
27
+ // its ancestor AND collided with the ancestor's own tween at the shared
28
+ // percentage, which the same-% merge then resolved by dropping the ease.
29
+ for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
30
+ idsWithKeyframes.add(id);
31
+ // Every tween that fed keyframeCache also lands in gsapAnimations, group or
32
+ // not: a mixed-group tween (`{ x, opacity }` classifies to undefined) used to
33
+ // cache diamonds with no source animation behind them, so the collapsed row
34
+ // drew keyframes the expanded lanes couldn't render. Lane consumers do the
35
+ // group filtering themselves (animationContributesLane).
36
+ sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]);
32
37
 
33
- // Convert tween-relative percentages to clip-relative so diamonds
34
- // render at the correct position within the timeline clip.
35
- const timelineEl = elements.find(
36
- (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
37
- );
38
- const clipKeyframes = toClipKeyframes(
39
- kfSource,
40
- anim,
41
- timelineEl?.start ?? 0,
42
- timelineEl?.duration ?? 1,
43
- );
38
+ // Convert tween-relative percentages to clip-relative so diamonds
39
+ // render at the correct position within the timeline clip. The basis comes
40
+ // from the shared resolver, so this writer agrees with the AST load on both
41
+ // the sub-comp host fallback and the tween's own time frame.
42
+ const { elStart, elDuration } = resolveClipTimingBasis(
43
+ id,
44
+ targetPath,
45
+ elements,
46
+ domClipChildren,
47
+ );
48
+ const clipKeyframes = toClipKeyframes(kfSource, anim, elStart, elDuration);
44
49
 
45
- const existing = merged.get(id);
46
- if (existing) {
47
- // deduplicateKeyframes owns the same-% merge (including the easeAmbiguous
48
- // flag downstream lanes read); a second copy of that rule here is how the
49
- // two writers drift.
50
- existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
51
- } else {
52
- merged.set(id, {
53
- ...anim.keyframes,
54
- format: anim.keyframes?.format ?? "percentage",
55
- keyframes: clipKeyframes,
56
- });
50
+ const existing = merged.get(id);
51
+ if (existing) {
52
+ // deduplicateKeyframes owns the same-% merge (including the easeAmbiguous
53
+ // flag downstream lanes read); a second copy of that rule here is how the
54
+ // two writers drift.
55
+ existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
56
+ } else {
57
+ merged.set(id, {
58
+ ...anim.keyframes,
59
+ format: anim.keyframes?.format ?? "percentage",
60
+ keyframes: clipKeyframes,
61
+ });
62
+ }
57
63
  }
58
64
  }
59
65
  for (const [id, entry] of merged) {
60
66
  for (const key of elementCacheKeys(targetPath, id)) setKeyframeCache(key, entry);
61
67
  writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id));
62
68
  }
63
- const targetId =
64
- idFromSelector((mutation as { targetSelector?: string }).targetSelector) ?? selectionId;
65
- if (targetId && !idsWithKeyframes.has(targetId)) {
66
- clearKeyframeCacheForElement(targetPath, targetId);
69
+ const mutationSelector = (mutation as { targetSelector?: string }).targetSelector;
70
+ const mutated = mutationSelector ? resolveSelectorElementIds(mutationSelector, doc) : [];
71
+ const targetIds = mutated.length > 0 ? mutated : selectionId ? [selectionId] : [];
72
+ for (const targetId of targetIds) {
73
+ if (!idsWithKeyframes.has(targetId)) clearKeyframeCacheForElement(targetPath, targetId);
67
74
  }
68
75
  }
69
76
 
@@ -1,7 +1,7 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
  import { absoluteToPercentageForAnimation, findTweenAtTime } from "../utils/globalTimeCompiler";
4
- import { PROPERTY_DEFAULTS, selectorFromSelection } from "./gsapShared";
4
+ import { PROPERTY_DEFAULTS, selectorFromSelection, writeTargetSelector } from "./gsapShared";
5
5
  import { roundToCenti } from "../utils/rounding";
6
6
 
7
7
  type CommitFn = (
@@ -22,6 +22,9 @@ export async function commitKeyframeAtTimeImpl(
22
22
  properties: Record<string, number | string>,
23
23
  commitMutation: CommitFn,
24
24
  ): Promise<void> {
25
+ // Matching an authored tween is a string compare against what the author
26
+ // wrote, so it keeps using the selection's own selector; the NEW tween below
27
+ // is authored with the one-element form instead.
25
28
  const selector = selectorFromSelection(selection);
26
29
  if (!selector) return;
27
30
 
@@ -60,12 +63,18 @@ export async function commitKeyframeAtTimeImpl(
60
63
  },
61
64
  );
62
65
  } else {
66
+ // Null means the live DOM could not prove any one-element form. Falling
67
+ // back to the author's own selector would write the group-collapsing
68
+ // target this narrowing exists to prevent, so the keyframe is dropped
69
+ // instead (see writeTargetSelector).
70
+ const target = writeTargetSelector(selection);
71
+ if (!target) return;
63
72
  const defaultDuration = 0.5;
64
73
  await commitMutation(
65
74
  selection,
66
75
  {
67
76
  type: "add-with-keyframes" as const,
68
- targetSelector: selector,
77
+ targetSelector: target,
69
78
  position: absoluteTime,
70
79
  duration: defaultDuration,
71
80
  keyframes: [
@@ -79,7 +79,7 @@ export async function tryGsapResizeIntercept(
79
79
  if (!anim || isInstantHold(anim)) {
80
80
  const sel = selectorFromSelection(selection);
81
81
  if (!sel) return false;
82
- const sizeSet = anim ?? findSizeSetAnimation(animations, sel);
82
+ const sizeSet = anim ?? findSizeSetAnimation(animations, sel, selection.element);
83
83
 
84
84
  // If the element is animated (has a real tween, not just a static size
85
85
  // hold), keyframe the size at the playhead so other keyframes keep theirs —
@@ -242,7 +242,7 @@ export async function tryGsapResizeIntercept(
242
242
  const currentAnimations = fetchFallbackAnimations
243
243
  ? await fetchFallbackAnimations()
244
244
  : (resolved?.animations ?? animations);
245
- const existingSet = findExistingPositionWrite(currentAnimations, selector);
245
+ const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
246
246
  // Delta chosen so the drag-path math composes back to exactly `corrected`
247
247
  // (no drag scratch attrs exist during a resize, so base = gsapPos).
248
248
  await commitStaticGsapPosition(
@@ -213,7 +213,7 @@ export async function tryGsapDragIntercept(
213
213
  const existingSet =
214
214
  posAnim && isInstantHold(posAnim) && posAnim.targetSelector === selector
215
215
  ? posAnim
216
- : findExistingPositionWrite(resolvedAnimations, selector);
216
+ : findExistingPositionWrite(resolvedAnimations, selector, selection.element);
217
217
  await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
218
218
  commitMutation,
219
219
  fetchAnimations: fetchFallbackAnimations,
@@ -301,7 +301,8 @@ export async function tryGsapRotationIntercept(
301
301
  // rotation set in place, else add a new one. This replaces the old
302
302
  // `--hf-studio-rotation` CSS-var fallback (the same dual-channel bug class).
303
303
  if (!anim || isInstantHold(anim)) {
304
- const existingSet = anim ?? findRotationSetAnimation(resolvedAnimations, selector);
304
+ const existingSet =
305
+ anim ?? findRotationSetAnimation(resolvedAnimations, selector, selection.element);
305
306
  await commitStaticGsapRotation(selection, newRotation, selector, existingSet, {
306
307
  commitMutation,
307
308
  fetchAnimations: fetchFallbackAnimations,
@@ -2,17 +2,32 @@ import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mu
2
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
 
4
4
  export { PROPERTY_DEFAULTS } from "./gsapShared";
5
- import { idSelector } from "./gsapShared";
5
+ import { idSelector, matchesExactlyOne } from "./gsapShared";
6
6
 
7
+ /**
8
+ * The selector to author a NEW tween against, minting an id on the element when
9
+ * it has no address of its own.
10
+ *
11
+ * `selection.selector` is only usable when it addresses ONE element:
12
+ * `buildStableSelector` hands back a bare class for an id-less element, so
13
+ * returning it unconditionally aimed "add animation" at every sibling sharing
14
+ * the class (the attribution blow-up that collapsed the timeline to one row,
15
+ * see writeTargetSelector). A non-unique selector falls through to the id mint
16
+ * below, which is the stronger fix here than a structural path: the id it writes
17
+ * back to the source also makes every later lookup for this element exact.
18
+ */
7
19
  export function ensureElementAddressable(selection: DomEditSelection): {
8
20
  selector: string;
9
21
  autoId?: string;
10
22
  } {
11
23
  if (selection.id) return { selector: idSelector(selection.id) };
12
- if (selection.selector) return { selector: selection.selector };
13
24
 
14
25
  const el = selection.element;
15
26
  const doc = el.ownerDocument;
27
+ if (selection.selector && matchesExactlyOne(doc, selection.selector, el)) {
28
+ return { selector: selection.selector };
29
+ }
30
+
16
31
  const tag = el.tagName.toLowerCase();
17
32
  let id = tag;
18
33
  let n = 1;