@hyperframes/studio 0.7.78 → 0.7.80

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 (103) hide show
  1. package/dist/assets/{hyperframes-player-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
  2. package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
  3. package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
  4. package/dist/assets/{index-r1tKKlU7.js → index-By-YiRxq.js} +1 -1
  5. package/dist/assets/index-D379YOKT.css +1 -0
  6. package/dist/index.d.ts +45 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1891 -1522
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioLeftSidebar.tsx +10 -5
  12. package/src/components/StudioRightPanel.tsx +7 -5
  13. package/src/components/TimelineToolbar.tsx +3 -1
  14. package/src/components/editor/AnimationCard.test.tsx +225 -18
  15. package/src/components/editor/AnimationCard.tsx +24 -3
  16. package/src/components/editor/EaseCurveSection.test.tsx +147 -2
  17. package/src/components/editor/EaseCurveSection.tsx +77 -15
  18. package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
  19. package/src/components/editor/GsapAnimationSection.tsx +6 -1
  20. package/src/components/editor/KeyframeEaseList.tsx +4 -0
  21. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +58 -20
  23. package/src/components/editor/PropertyPanel.tsx +7 -7
  24. package/src/components/editor/PropertyPanelFlat.tsx +6 -91
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
  26. package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
  27. package/src/components/editor/holdEaseSeek.test.ts +37 -0
  28. package/src/components/editor/keyframeRetime.test.ts +42 -0
  29. package/src/components/editor/keyframeRetime.ts +4 -1
  30. package/src/components/editor/motionPathSelection.test.ts +65 -0
  31. package/src/components/editor/motionPathSelection.ts +13 -3
  32. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  33. package/src/components/editor/propertyPanelColor.tsx +32 -26
  34. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
  35. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
  36. package/src/components/editor/propertyPanelFlatProps.ts +91 -0
  37. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  40. package/src/components/pointerTargetSize.test.tsx +65 -0
  41. package/src/components/sidebar/CompositionsTab.tsx +4 -1
  42. package/src/contexts/DomEditContext.tsx +4 -0
  43. package/src/hooks/gsapDragCommit.ts +43 -10
  44. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  45. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
  46. package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
  47. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  48. package/src/hooks/gsapResizeIntercept.ts +2 -2
  49. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  50. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  51. package/src/hooks/gsapShared.test.ts +144 -0
  52. package/src/hooks/gsapShared.ts +264 -0
  53. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  54. package/src/hooks/gsapTweenSynth.test.ts +70 -15
  55. package/src/hooks/gsapTweenSynth.ts +50 -23
  56. package/src/hooks/keyframeCacheAstLoad.ts +9 -83
  57. package/src/hooks/newTweenTarget.test.ts +364 -0
  58. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  59. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  60. package/src/hooks/timelineTrackVisibility.ts +11 -1
  61. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  62. package/src/hooks/useDomEditSession.test.tsx +152 -3
  63. package/src/hooks/useDomEditSession.ts +4 -53
  64. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  65. package/src/hooks/useEnableKeyframes.ts +18 -5
  66. package/src/hooks/useGestureCommit.ts +21 -4
  67. package/src/hooks/useGsapScriptCommits.ts +3 -0
  68. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  69. package/src/hooks/useGsapTweenCache.ts +44 -45
  70. package/src/hooks/useKeyframeEaseCommits.ts +78 -0
  71. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
  72. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
  73. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  74. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  75. package/src/player/components/ShortcutsPanel.tsx +11 -15
  76. package/src/player/components/Timeline.test.ts +3 -1
  77. package/src/player/components/TimelineCanvas.tsx +2 -1
  78. package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
  79. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  80. package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
  81. package/src/player/components/TimelineLanes.test.tsx +281 -0
  82. package/src/player/components/TimelineLanes.tsx +43 -97
  83. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  84. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  85. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  86. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  87. package/src/player/components/timelineDiamondTypes.ts +4 -3
  88. package/src/player/components/timelineKeyframeIdentity.ts +3 -0
  89. package/src/player/components/timelineLaneProps.ts +86 -0
  90. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  91. package/src/player/components/timelineTrackDisplay.ts +38 -0
  92. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
  93. package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
  94. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  95. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  96. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  97. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  98. package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
  99. package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
  100. package/src/player/store/keyframeSlice.ts +15 -5
  101. package/src/telemetry/client.test.ts +12 -3
  102. package/src/telemetry/client.ts +3 -2
  103. package/dist/assets/index-DpkO2Hvw.css +0 -1
@@ -0,0 +1,364 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Sibling writers of the "add keyframe at playhead" path fixed in
4
+ * gsapShared.writeTarget.test.ts. Every mutation that authors a NEW tween must
5
+ * address ONE element; a bare class attributes the write to every sibling that
6
+ * shares it, which is what collapsed the timeline to a single row.
7
+ *
8
+ * Same round trip as the U3 test: author through the real writer, re-parse with
9
+ * the real parser, resolve through the very function that feeds the keyframe
10
+ * cache and the lanes.
11
+ */
12
+ import { afterEach, describe, expect, it, vi } from "vitest";
13
+ import { parseGsapScript } from "@hyperframes/core/gsap-parser";
14
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
15
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
16
+ import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
17
+ import { resolveSelectorElementIds } from "./gsapShared";
18
+ import { ensureElementAddressable } from "./gsapScriptCommitHelpers";
19
+ import {
20
+ commitStaticGsapPosition,
21
+ commitStaticGsapRotation,
22
+ commitStaticGsapSize,
23
+ commitKeyframedSizeFromResize,
24
+ commitWholePathOffset,
25
+ findExistingPositionWrite,
26
+ } from "./gsapDragCommit";
27
+ import { promoteSetToKeyframes } from "./useEnableKeyframes";
28
+
29
+ afterEach(() => {
30
+ document.body.innerHTML = "";
31
+ });
32
+
33
+ function selectionFor(el: HTMLElement): DomEditSelection {
34
+ const selector = buildStableSelector(el);
35
+ return {
36
+ element: el,
37
+ id: el.id || undefined,
38
+ hfId: el.getAttribute("data-hf-id") || undefined,
39
+ selector,
40
+ selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
41
+ sourceFile: "index.html",
42
+ dataAttributes: { start: "0", duration: "2" },
43
+ } as unknown as DomEditSelection;
44
+ }
45
+
46
+ /** Five class-only siblings, each with an id so attribution is nameable. */
47
+ function mountGroupSiblings(): HTMLElement[] {
48
+ document.body.innerHTML = `
49
+ <div id="scene" class="clip" data-start="0" data-duration="2">
50
+ <div class="group" id="group-0"></div>
51
+ <div class="group" id="group-1"></div>
52
+ <div class="group" id="group-2"></div>
53
+ <div class="group" id="group-3"></div>
54
+ <div class="group" id="group-4"></div>
55
+ </div>
56
+ `;
57
+ return Array.from(document.querySelectorAll<HTMLElement>(".group"));
58
+ }
59
+
60
+ /**
61
+ * The selection production hands these writers: the element HAS an id in the
62
+ * DOM (so resolveSelectorElementIds can name it) but the SELECTION carries none,
63
+ * which is the id-less shape buildStableSelector answers with a bare class.
64
+ */
65
+ function classOnlySelection(el: HTMLElement): DomEditSelection {
66
+ return { ...selectionFor(el), id: undefined, selector: ".group" } as DomEditSelection;
67
+ }
68
+
69
+ /** The elements a written targetSelector actually attributes the tween to. */
70
+ function attributedTo(targetSelector: string): string[] {
71
+ return resolveSelectorElementIds(targetSelector, document);
72
+ }
73
+
74
+ function recorder() {
75
+ const mutations: Array<Record<string, unknown>> = [];
76
+ const commitMutation = vi.fn(async (_sel, mutation, _opts) => {
77
+ mutations.push(mutation as Record<string, unknown>);
78
+ });
79
+ return { mutations, callbacks: { commitMutation } as never };
80
+ }
81
+
82
+ /** Every `targetSelector` a run of mutations wrote. */
83
+ function writtenTargets(mutations: Array<Record<string, unknown>>): string[] {
84
+ return mutations.map((m) => m.targetSelector).filter((s): s is string => typeof s === "string");
85
+ }
86
+
87
+ describe("ensureElementAddressable — add-animation button", () => {
88
+ it("addresses one element when the selection's only identity is a shared class", () => {
89
+ // Truly id-less siblings: the shape production reaches this path with (an
90
+ // element WITH an id never gets here, selection.id short-circuits above).
91
+ document.body.innerHTML = `
92
+ <div id="scene" class="clip">
93
+ <div class="group"></div><div class="group"></div><div class="group"></div>
94
+ </div>
95
+ `;
96
+ const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
97
+ const selection = selectionFor(el);
98
+ expect(selection.selector).toBe(".group");
99
+
100
+ const { selector, autoId } = ensureElementAddressable(selection);
101
+
102
+ expect(autoId).toBeTruthy();
103
+ expect(document.querySelectorAll(selector)).toHaveLength(1);
104
+ expect(document.querySelector(selector)).toBe(el);
105
+ expect(attributedTo(selector)).toEqual([autoId]);
106
+ });
107
+
108
+ it("keeps a unique #id target", () => {
109
+ document.body.innerHTML = `<div id="box" class="card"></div>`;
110
+ const el = document.querySelector<HTMLElement>("#box")!;
111
+
112
+ expect(ensureElementAddressable(selectionFor(el)).selector).toBe("#box");
113
+ });
114
+
115
+ it("keeps an already-unique class selector as authored", () => {
116
+ document.body.innerHTML = `<div id="scene"><div class="header"></div></div>`;
117
+ const el = document.querySelector<HTMLElement>(".header")!;
118
+
119
+ expect(ensureElementAddressable(selectionFor(el)).selector).toBe(".header");
120
+ });
121
+
122
+ it("still mints an id when there is no live element to disambiguate against", () => {
123
+ document.body.innerHTML = `<div id="scene"><div></div></div>`;
124
+ const el = document.querySelector<HTMLElement>("#scene > div")!;
125
+ const selection = { ...selectionFor(el), selector: undefined } as DomEditSelection;
126
+
127
+ const { selector, autoId } = ensureElementAddressable(selection);
128
+
129
+ expect(autoId).toBe("div");
130
+ expect(selector).toBe("#div");
131
+ });
132
+ });
133
+
134
+ describe("gsapDragCommit — new-tween targets", () => {
135
+ it("commitStaticGsapPosition authors the new set against one element", async () => {
136
+ const groups = mountGroupSiblings();
137
+ const { mutations, callbacks } = recorder();
138
+
139
+ await commitStaticGsapPosition(
140
+ classOnlySelection(groups[2]!),
141
+ { x: 10, y: 10 },
142
+ { x: 0, y: 0 },
143
+ ".group",
144
+ null,
145
+ callbacks,
146
+ );
147
+
148
+ expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-2"]);
149
+ });
150
+
151
+ it("commitStaticGsapRotation authors the new set against one element", async () => {
152
+ const groups = mountGroupSiblings();
153
+ const { mutations, callbacks } = recorder();
154
+
155
+ await commitStaticGsapRotation(classOnlySelection(groups[1]!), 42, ".group", null, callbacks);
156
+
157
+ expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-1"]);
158
+ });
159
+
160
+ it("commitStaticGsapSize authors the new set against one element", async () => {
161
+ const groups = mountGroupSiblings();
162
+ const { mutations, callbacks } = recorder();
163
+
164
+ await commitStaticGsapSize(
165
+ classOnlySelection(groups[4]!),
166
+ { width: 100, height: 50 },
167
+ ".group",
168
+ null,
169
+ callbacks,
170
+ );
171
+
172
+ expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-4"]);
173
+ });
174
+
175
+ it("commitKeyframedSizeFromResize authors the new keyframe tween against one element", async () => {
176
+ const groups = mountGroupSiblings();
177
+ const { mutations, callbacks } = recorder();
178
+ const animatedTween = {
179
+ id: "t1",
180
+ targetSelector: ".group",
181
+ method: "to",
182
+ properties: {},
183
+ resolvedStart: 0,
184
+ duration: 2,
185
+ keyframes: { keyframes: [{ percentage: 0, properties: { x: 0 } }] },
186
+ } as unknown as GsapAnimation;
187
+
188
+ const handled = await commitKeyframedSizeFromResize(
189
+ classOnlySelection(groups[3]!),
190
+ { width: 80, height: 40 },
191
+ ".group",
192
+ null,
193
+ animatedTween,
194
+ callbacks,
195
+ );
196
+
197
+ expect(handled).toBe(true);
198
+ expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-3"]);
199
+ });
200
+
201
+ it("commitStaticGsapPosition replaces a corrupt keyframed hold against one element", async () => {
202
+ const groups = mountGroupSiblings();
203
+ const { mutations, callbacks } = recorder();
204
+ const corruptHold = {
205
+ id: "hold-1",
206
+ targetSelector: ".group",
207
+ method: "to",
208
+ properties: {},
209
+ duration: 0,
210
+ keyframes: { keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }] },
211
+ } as unknown as GsapAnimation;
212
+
213
+ await commitStaticGsapPosition(
214
+ classOnlySelection(groups[0]!),
215
+ { x: 5, y: 5 },
216
+ { x: 0, y: 0 },
217
+ ".group",
218
+ corruptHold,
219
+ callbacks,
220
+ );
221
+
222
+ expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-0"]);
223
+ });
224
+ });
225
+
226
+ describe("gsapDragCommit — retargeting an existing tween is left alone", () => {
227
+ it("commitWholePathOffset keeps the tween's own group target", async () => {
228
+ const groups = mountGroupSiblings();
229
+ const { mutations, callbacks } = recorder();
230
+ const groupTween = {
231
+ id: "t-group",
232
+ targetSelector: ".group",
233
+ method: "to",
234
+ properties: { x: 100 },
235
+ resolvedStart: 0,
236
+ duration: 2,
237
+ } as unknown as GsapAnimation;
238
+
239
+ await commitWholePathOffset(
240
+ classOnlySelection(groups[2]!),
241
+ groupTween,
242
+ { x: 10, y: 0 },
243
+ { x: 0, y: 0 },
244
+ null,
245
+ ".group",
246
+ callbacks,
247
+ );
248
+
249
+ // A tween the author aimed at all five siblings must STAY aimed at all five:
250
+ // narrowing it here would silently drop four elements out of the animation.
251
+ expect(writtenTargets(mutations)[0]).toBe(".group");
252
+ expect(attributedTo(writtenTargets(mutations)[0]!)).toHaveLength(5);
253
+ });
254
+ });
255
+
256
+ /**
257
+ * The write selector and the "is there already a write for this element?"
258
+ * lookup are two halves of one contract. Narrowing only the write half would
259
+ * make the next nudge miss its own previous write and append a second one — the
260
+ * duplicate-position-write bug findExistingPositionWrite exists to prevent.
261
+ */
262
+ describe("a re-nudge updates its own previous write instead of stacking a second one", () => {
263
+ it("finds the write the first nudge authored", async () => {
264
+ const groups = mountGroupSiblings();
265
+ const selection = classOnlySelection(groups[2]!);
266
+ const first = recorder();
267
+
268
+ await commitStaticGsapPosition(
269
+ selection,
270
+ { x: 10, y: 10 },
271
+ { x: 0, y: 0 },
272
+ ".group",
273
+ null,
274
+ first.callbacks,
275
+ );
276
+ const written = writtenTargets(first.mutations)[0]!;
277
+
278
+ // Read the first write back the way the next drag does: parse the source,
279
+ // then run the production lookup for this element's position write.
280
+ const script = `
281
+ const tl = gsap.timeline({ paused: true });
282
+ gsap.set(${JSON.stringify(written)}, { x: 10, y: 10 });
283
+ `.trim();
284
+ const animations = parseGsapScript(script).animations;
285
+ const existing = findExistingPositionWrite(animations, ".group", selection.element);
286
+ expect(existing).toBeTruthy();
287
+
288
+ const second = recorder();
289
+ await commitStaticGsapPosition(
290
+ selection,
291
+ { x: 5, y: 0 },
292
+ { x: 10, y: 10 },
293
+ ".group",
294
+ existing,
295
+ second.callbacks,
296
+ );
297
+
298
+ expect(second.mutations[0]!.type).toBe("update-properties");
299
+ expect(second.mutations[0]!.animationId).toBe(existing!.id);
300
+ });
301
+ });
302
+
303
+ /**
304
+ * Candidate C. Every `replace-with-keyframes` in useEnableKeyframes names an
305
+ * `animationId` parsed out of the CURRENT SOURCE (the anims list comes from
306
+ * tryFetchAnimationsForElement), so each one rewrites a tween the author already
307
+ * has. Narrowing those to one element would silently drop the other four
308
+ * siblings out of an animation that was aimed at the group on purpose.
309
+ */
310
+ describe("useEnableKeyframes — rewriting an existing tween keeps its group target", () => {
311
+ it("promoteSetToKeyframes leaves a group-authored set aimed at the group", async () => {
312
+ const groups = mountGroupSiblings();
313
+ const mutations: Array<Record<string, unknown>> = [];
314
+ const setAnim = {
315
+ id: "set-group",
316
+ targetSelector: ".group",
317
+ method: "set",
318
+ properties: { x: 0, y: 0 },
319
+ resolvedStart: 0,
320
+ duration: 0,
321
+ } as unknown as GsapAnimation;
322
+ const session = {
323
+ commitMutation: async (mutation: Record<string, unknown>) => {
324
+ mutations.push(mutation);
325
+ },
326
+ handleGsapRemoveKeyframe: vi.fn(),
327
+ };
328
+
329
+ // Playhead at the set: the branch that replaces it with a single keyframe,
330
+ // which can source its value from the set itself (no live iframe needed).
331
+ await promoteSetToKeyframes(session as never, classOnlySelection(groups[2]!), setAnim, 0, null);
332
+
333
+ expect(mutations[0]!.type).toBe("replace-with-keyframes");
334
+ expect(mutations[0]!.animationId).toBe("set-group");
335
+ expect(mutations[0]!.targetSelector).toBe(".group");
336
+ expect(attributedTo(mutations[0]!.targetSelector as string)).toHaveLength(5);
337
+ });
338
+ });
339
+
340
+ describe("the written selector survives the real writer and parser", () => {
341
+ it("re-parses to a tween attributed to the one element it targeted", async () => {
342
+ const groups = mountGroupSiblings();
343
+ const { mutations, callbacks } = recorder();
344
+
345
+ await commitStaticGsapPosition(
346
+ classOnlySelection(groups[2]!),
347
+ { x: 10, y: 10 },
348
+ { x: 0, y: 0 },
349
+ ".group",
350
+ null,
351
+ callbacks,
352
+ );
353
+ const written = writtenTargets(mutations)[0]!;
354
+
355
+ const script = `
356
+ const tl = gsap.timeline({ paused: true });
357
+ gsap.set(${JSON.stringify(written)}, { x: 10, y: 10 });
358
+ `.trim();
359
+ const parsed = parseGsapScript(script).animations.find((a) => a.targetSelector === written);
360
+
361
+ expect(parsed).toBeTruthy();
362
+ expect(resolveSelectorElementIds(parsed!.targetSelector, document)).toEqual(["group-2"]);
363
+ });
364
+ });
@@ -0,0 +1,255 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Hook-level siblings of newTweenTarget.test.ts: the property panel and the
4
+ * gesture recorder also author NEW tweens, so they face the same bare-class
5
+ * blow-up. Driven through the real hooks so the classification under test
6
+ * (new tween vs retarget of an existing one) is the production one.
7
+ */
8
+ import React, { act } from "react";
9
+ import { afterEach, describe, expect, it, vi } from "vitest";
10
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
11
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
12
+ import { usePlayerStore } from "../player/store/playerStore";
13
+ import { resolveSelectorElementIds } from "./gsapShared";
14
+ import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
15
+ import { useGestureCommit } from "./useGestureCommit";
16
+ import { mountReactHarness, installReactActEnvironment } from "./domSelectionTestHarness";
17
+
18
+ installReactActEnvironment();
19
+
20
+ const frozenSamples = [
21
+ { time: 0, properties: { x: 0, y: 0 } },
22
+ { time: 0.5, properties: { x: 60, y: 10 } },
23
+ { time: 1, properties: { x: 120, y: 40 } },
24
+ ];
25
+
26
+ vi.mock("./useGestureRecording", () => ({
27
+ useGestureRecording: () => ({
28
+ startRecording: vi.fn(),
29
+ stopRecording: () => frozenSamples,
30
+ clearSamples: vi.fn(),
31
+ samples: frozenSamples,
32
+ }),
33
+ }));
34
+
35
+ afterEach(() => {
36
+ document.body.innerHTML = "";
37
+ usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 0 });
38
+ });
39
+
40
+ function mountGroupSiblings(): HTMLElement[] {
41
+ document.body.innerHTML = `
42
+ <div id="scene" class="clip" data-start="0" data-duration="2">
43
+ <div class="group" id="group-0"></div>
44
+ <div class="group" id="group-1"></div>
45
+ <div class="group" id="group-2"></div>
46
+ <div class="group" id="group-3"></div>
47
+ <div class="group" id="group-4"></div>
48
+ </div>
49
+ `;
50
+ return Array.from(document.querySelectorAll<HTMLElement>(".group"));
51
+ }
52
+
53
+ /** The id-less shape buildStableSelector answers with a bare class. */
54
+ function classOnlySelection(el: HTMLElement): DomEditSelection {
55
+ return {
56
+ element: el,
57
+ id: undefined,
58
+ selector: ".group",
59
+ selectorIndex: Array.prototype.indexOf.call(el.parentElement!.children, el),
60
+ sourceFile: "index.html",
61
+ dataAttributes: { start: "0", duration: "2" },
62
+ } as unknown as DomEditSelection;
63
+ }
64
+
65
+ function attributedTo(targetSelector: unknown): string[] {
66
+ return resolveSelectorElementIds(String(targetSelector), document);
67
+ }
68
+
69
+ type Commit = (
70
+ selection: DomEditSelection,
71
+ props: Record<string, number | string>,
72
+ ) => Promise<void>;
73
+
74
+ function renderPropertyCommit(
75
+ animations: GsapAnimation[],
76
+ mutations: Array<Record<string, unknown>>,
77
+ onReady: (commit: Commit) => void,
78
+ ) {
79
+ function Harness() {
80
+ const { commitAnimatedProperties } = useAnimatedPropertyCommit({
81
+ selectedGsapAnimations: animations,
82
+ gsapCommitMutation: async (_sel: DomEditSelection, mutation: Record<string, unknown>) => {
83
+ mutations.push(mutation);
84
+ },
85
+ addGsapAnimation: vi.fn(),
86
+ convertToKeyframes: vi.fn(),
87
+ previewIframeRef: { current: null },
88
+ bumpGsapCache: vi.fn(),
89
+ } as never);
90
+ onReady(commitAnimatedProperties);
91
+ return null;
92
+ }
93
+ return mountReactHarness(<Harness />);
94
+ }
95
+
96
+ describe("useAnimatedPropertyCommit — new-tween targets", () => {
97
+ it("authors a new static set against one element", async () => {
98
+ const groups = mountGroupSiblings();
99
+ const mutations: Array<Record<string, unknown>> = [];
100
+ let commit: Commit | undefined;
101
+ const root = renderPropertyCommit([], mutations, (fn) => (commit = fn));
102
+
103
+ await act(async () => {
104
+ await commit!(classOnlySelection(groups[2]!), { rotationX: 30 });
105
+ });
106
+
107
+ const added = mutations.find((m) => m.type === "add");
108
+ expect(added).toBeTruthy();
109
+ expect(attributedTo(added!.targetSelector)).toEqual(["group-2"]);
110
+ act(() => root.unmount());
111
+ });
112
+
113
+ it("authors the first same-group keyframe tween against one element", async () => {
114
+ const groups = mountGroupSiblings();
115
+ // A position tween exists, so the element IS animated; a rotation edit has
116
+ // no same-group tween to join and must author a fresh one.
117
+ const positionTween = {
118
+ id: "t-pos",
119
+ targetSelector: ".group",
120
+ propertyGroup: "position",
121
+ method: "to",
122
+ properties: { x: 0 },
123
+ resolvedStart: 0,
124
+ duration: 2,
125
+ keyframes: {
126
+ keyframes: [
127
+ { percentage: 0, properties: { x: 0 } },
128
+ { percentage: 100, properties: { x: 50 } },
129
+ ],
130
+ },
131
+ } as unknown as GsapAnimation;
132
+ const mutations: Array<Record<string, unknown>> = [];
133
+ let commit: Commit | undefined;
134
+ const root = renderPropertyCommit([positionTween], mutations, (fn) => (commit = fn));
135
+
136
+ await act(async () => {
137
+ await commit!(classOnlySelection(groups[1]!), { rotationY: 15 });
138
+ });
139
+
140
+ const added = mutations.find((m) => m.type === "add-with-keyframes");
141
+ expect(added).toBeTruthy();
142
+ expect(attributedTo(added!.targetSelector)).toEqual(["group-1"]);
143
+ act(() => root.unmount());
144
+ });
145
+
146
+ it("leaves an existing group tween aimed at its whole group", async () => {
147
+ const groups = mountGroupSiblings();
148
+ usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 0 });
149
+ const groupTween = {
150
+ id: "t-pos",
151
+ targetSelector: ".group",
152
+ propertyGroup: "position",
153
+ method: "to",
154
+ properties: { x: 0 },
155
+ resolvedStart: 0,
156
+ duration: 2,
157
+ keyframes: {
158
+ keyframes: [
159
+ { percentage: 0, properties: { x: 0, y: 0 } },
160
+ { percentage: 100, properties: { x: 50, y: 0 } },
161
+ ],
162
+ },
163
+ } as unknown as GsapAnimation;
164
+ const mutations: Array<Record<string, unknown>> = [];
165
+ let commit: Commit | undefined;
166
+ const root = renderPropertyCommit([groupTween], mutations, (fn) => (commit = fn));
167
+
168
+ await act(async () => {
169
+ await commit!(classOnlySelection(groups[2]!), { x: 90 });
170
+ });
171
+
172
+ const replaced = mutations.find((m) => m.type === "replace-with-keyframes");
173
+ expect(replaced).toBeTruthy();
174
+ expect(replaced!.targetSelector).toBe(".group");
175
+ act(() => root.unmount());
176
+ });
177
+ });
178
+
179
+ function renderGestureCommit(
180
+ animations: GsapAnimation[],
181
+ mutations: Array<Record<string, unknown>>,
182
+ selection: DomEditSelection,
183
+ ) {
184
+ let toggle: (() => void) | undefined;
185
+ function Harness() {
186
+ const { handleToggleRecording } = useGestureCommit({
187
+ domEditSessionRef: {
188
+ current: {
189
+ domEditSelection: selection,
190
+ selectedGsapAnimations: animations,
191
+ commitMutation: async (mutation: Record<string, unknown>) => {
192
+ mutations.push(mutation);
193
+ },
194
+ },
195
+ } as never,
196
+ previewIframeRef: { current: document.createElement("iframe") },
197
+ showToast: vi.fn(),
198
+ isGestureRecordingRef: { current: false },
199
+ });
200
+ toggle = handleToggleRecording;
201
+ return null;
202
+ }
203
+ const root = mountReactHarness(<Harness />);
204
+ return { root, toggle: () => toggle!() };
205
+ }
206
+
207
+ describe("useGestureCommit — new-tween targets", () => {
208
+ it("authors the recorded tween against one element", async () => {
209
+ const groups = mountGroupSiblings();
210
+ const mutations: Array<Record<string, unknown>> = [];
211
+ const { root, toggle } = renderGestureCommit([], mutations, classOnlySelection(groups[3]!));
212
+
213
+ act(() => toggle()); // start
214
+ await act(async () => {
215
+ toggle(); // stop + commit
216
+ await Promise.resolve();
217
+ });
218
+
219
+ const added = mutations.find((m) => m.type === "add-with-keyframes");
220
+ expect(added).toBeTruthy();
221
+ expect(attributedTo(added!.targetSelector)).toEqual(["group-3"]);
222
+ act(() => root.unmount());
223
+ });
224
+
225
+ it("leaves a merged existing group tween aimed at its whole group", async () => {
226
+ const groups = mountGroupSiblings();
227
+ const existing = {
228
+ id: "t-pos",
229
+ targetSelector: ".group",
230
+ propertyGroup: "position",
231
+ method: "to",
232
+ properties: { x: 0 },
233
+ resolvedStart: 0,
234
+ duration: 2,
235
+ keyframes: { keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }] },
236
+ } as unknown as GsapAnimation;
237
+ const mutations: Array<Record<string, unknown>> = [];
238
+ const { root, toggle } = renderGestureCommit(
239
+ [existing],
240
+ mutations,
241
+ classOnlySelection(groups[3]!),
242
+ );
243
+
244
+ act(() => toggle());
245
+ await act(async () => {
246
+ toggle();
247
+ await Promise.resolve();
248
+ });
249
+
250
+ const replaced = mutations.find((m) => m.type === "replace-with-keyframes");
251
+ expect(replaced).toBeTruthy();
252
+ expect(replaced!.targetSelector).toBe(".group");
253
+ act(() => root.unmount());
254
+ });
255
+ });
@@ -95,7 +95,9 @@ describe("toggleTimelineTrackHidden", () => {
95
95
  expect(pendingRef.current).toEqual(new Set(["index.html", "scene.html"]));
96
96
  expect(timestampRef.current).toBeGreaterThan(0);
97
97
  expect(recordEdit).toHaveBeenCalledTimes(1);
98
- expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 0");
98
+ // Display row, not the raw key: track 0 is the first row, so it reads "1",
99
+ // the same number the track header announces for that row.
100
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 1");
99
101
  expect(Object.keys(recordEdit.mock.calls[0]?.[0]?.files ?? {}).sort()).toEqual([
100
102
  "index.html",
101
103
  "scene.html",
@@ -134,6 +136,64 @@ describe("toggleTimelineTrackHidden", () => {
134
136
 
135
137
  expect(writes.get("index.html")).not.toContain("data-hidden");
136
138
  });
139
+
140
+ // An expanded sub-comp child gets a synthesized FRACTIONAL track key
141
+ // (`display.track + n / (siblings + 2)`), so a label built from the raw key
142
+ // reads "Hide track 0.16666666666666666" in the undo history. Track 0 would
143
+ // format cleanly and prove nothing, hence 1 / 6.
144
+ it("labels the undo entry with the display row, not the fractional track key", async () => {
145
+ const files = new Map([
146
+ ["index.html", `<div id="child" data-start="0" data-duration="2"></div>`],
147
+ ]);
148
+ stubProjectFiles(files);
149
+
150
+ const recordEdit = vi.fn();
151
+
152
+ await toggleTimelineTrackHidden({
153
+ projectId: "project-1",
154
+ activeCompPath: "index.html",
155
+ timelineElements: [
156
+ element({ id: "host", domId: "host", track: 0 }),
157
+ element({ id: "child", domId: "child", track: 1 / 6 }),
158
+ ],
159
+ track: 1 / 6,
160
+ hidden: true,
161
+ previewIframe: null,
162
+ writeProjectFile: async () => {},
163
+ recordEdit,
164
+ domEditSaveTimestampRef: { current: 0 },
165
+ pendingTimelineEditPathRef: { current: new Set() },
166
+ });
167
+
168
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 2");
169
+ });
170
+
171
+ it("labels a show back with the display row too", async () => {
172
+ const files = new Map([
173
+ ["index.html", `<div id="child" data-start="0" data-duration="2" data-hidden=""></div>`],
174
+ ]);
175
+ stubProjectFiles(files);
176
+
177
+ const recordEdit = vi.fn();
178
+
179
+ await toggleTimelineTrackHidden({
180
+ projectId: "project-1",
181
+ activeCompPath: "index.html",
182
+ timelineElements: [
183
+ element({ id: "host", domId: "host", track: 0 }),
184
+ element({ id: "child", domId: "child", track: 1 / 6, hidden: true }),
185
+ ],
186
+ track: 1 / 6,
187
+ hidden: false,
188
+ previewIframe: null,
189
+ writeProjectFile: async () => {},
190
+ recordEdit,
191
+ domEditSaveTimestampRef: { current: 0 },
192
+ pendingTimelineEditPathRef: { current: new Set() },
193
+ });
194
+
195
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Show track 2");
196
+ });
137
197
  });
138
198
 
139
199
  describe("toggleTimelineElementHidden", () => {