@hyperframes/studio 0.7.79 → 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 (50) hide show
  1. package/dist/assets/{hyperframes-player-mFah2TZE.js → hyperframes-player-Csai0_vh.js} +1 -1
  2. package/dist/assets/{index-Bqj3h_1a.js → index-B5aYTg-4.js} +1 -1
  3. package/dist/assets/{index-DlZMDyYs.js → index-BhdJN49y.js} +200 -200
  4. package/dist/assets/{index-hmnoiSEV.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 +652 -465
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +2 -1
  12. package/src/components/editor/AnimationCard.test.tsx +225 -18
  13. package/src/components/editor/AnimationCard.tsx +24 -3
  14. package/src/components/editor/EaseCurveSection.test.tsx +147 -2
  15. package/src/components/editor/EaseCurveSection.tsx +77 -15
  16. package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
  17. package/src/components/editor/GsapAnimationSection.tsx +6 -1
  18. package/src/components/editor/KeyframeEaseList.tsx +4 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +54 -19
  20. package/src/components/editor/PropertyPanel.tsx +4 -0
  21. package/src/components/editor/PropertyPanelFlat.tsx +6 -91
  22. package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
  23. package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
  24. package/src/components/editor/holdEaseSeek.test.ts +37 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
  27. package/src/components/editor/propertyPanelFlatProps.ts +91 -0
  28. package/src/components/editor/propertyPanelTypes.ts +2 -0
  29. package/src/contexts/DomEditContext.tsx +4 -0
  30. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +132 -43
  31. package/src/hooks/gsapKeyframeCacheHelpers.ts +150 -50
  32. package/src/hooks/gsapTweenSynth.test.ts +70 -15
  33. package/src/hooks/gsapTweenSynth.ts +50 -23
  34. package/src/hooks/keyframeCacheAstLoad.ts +4 -12
  35. package/src/hooks/useDomEditSession.test.tsx +152 -3
  36. package/src/hooks/useDomEditSession.ts +4 -53
  37. package/src/hooks/useGsapTweenCache.ts +40 -34
  38. package/src/hooks/useKeyframeEaseCommits.ts +78 -0
  39. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
  40. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
  41. package/src/player/components/TimelineClipDiamonds.test.tsx +18 -6
  42. package/src/player/components/TimelineDiamondConnectors.tsx +118 -85
  43. package/src/player/components/timelineDiamondTypes.ts +4 -3
  44. package/src/player/components/timelineKeyframeIdentity.ts +3 -0
  45. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
  46. package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
  47. package/src/player/hooks/useExpandedTimelineElements.test.ts +29 -0
  48. package/src/player/hooks/useExpandedTimelineElements.ts +24 -0
  49. package/src/player/store/keyframeSlice.ts +15 -5
  50. package/dist/assets/index-gGVKuFg5.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.79",
3
+ "version": "0.7.80",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.79",
50
- "@hyperframes/player": "0.7.79",
51
- "@hyperframes/sdk": "0.7.79",
52
- "@hyperframes/studio-server": "0.7.79",
53
- "@hyperframes/parsers": "0.7.79"
49
+ "@hyperframes/core": "0.7.80",
50
+ "@hyperframes/parsers": "0.7.80",
51
+ "@hyperframes/player": "0.7.80",
52
+ "@hyperframes/sdk": "0.7.80",
53
+ "@hyperframes/studio-server": "0.7.80"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.79"
68
+ "@hyperframes/producer": "0.7.80"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
@@ -19,7 +19,6 @@ import type { EditHistoryKind } from "../utils/editHistory";
19
19
  import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
20
20
  import { useSlideshowTabState } from "../hooks/useSlideshowTabState";
21
21
  import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";
22
-
23
22
  import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
24
23
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
25
24
  import { useFileManagerContext } from "../contexts/FileManagerContext";
@@ -156,6 +155,7 @@ export function StudioRightPanel({
156
155
  handleUpdateArcSegment,
157
156
  handleUnroll,
158
157
  handleUpdateKeyframeEase,
158
+ handleUpdateSegmentEase,
159
159
  handleSetAllKeyframeEases,
160
160
  handleGsapAddKeyframe,
161
161
  handleGsapRemoveKeyframe,
@@ -406,6 +406,7 @@ export function StudioRightPanel({
406
406
  onUnroll={handleUnroll}
407
407
  onUpdateKeyframeEase={handleUpdateKeyframeEase}
408
408
  onSetAllKeyframeEases={handleSetAllKeyframeEases}
409
+ onUpdateSegmentEase={handleUpdateSegmentEase}
409
410
  recordingState={recordingState}
410
411
  recordingDuration={recordingDuration}
411
412
  onToggleRecording={onToggleRecording}
@@ -2,39 +2,98 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
6
  import { afterEach, describe, expect, it, vi } from "vitest";
6
7
  import { AnimationCard } from "./AnimationCard";
7
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
8
8
  import { EASE_PRESETS } from "./easePresetLibrary";
9
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
9
10
 
10
11
  const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
11
12
  vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));
12
13
 
13
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
15
 
16
+ const ANIMATION: GsapAnimation = {
17
+ id: "position-tween",
18
+ targetSelector: "#clip-1",
19
+ method: "to",
20
+ position: 0,
21
+ duration: 2,
22
+ ease: "power1.out",
23
+ properties: { x: 200 },
24
+ keyframes: {
25
+ format: "percentage",
26
+ keyframes: [
27
+ { percentage: 0, properties: { x: 0 } },
28
+ { percentage: 50, properties: { x: 100 } },
29
+ { percentage: 100, properties: { x: 200 } },
30
+ ],
31
+ },
32
+ };
33
+
34
+ const FLAT_ANIMATION: GsapAnimation = {
35
+ ...ANIMATION,
36
+ id: "flat-position-tween",
37
+ keyframes: undefined,
38
+ };
39
+
15
40
  afterEach(() => {
16
41
  document.body.innerHTML = "";
17
42
  trackStudioSegmentEaseEdit.mockClear();
18
43
  });
19
44
 
20
- function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
21
- return {
22
- id: "anim-1",
23
- method: "to",
24
- position: 0.8,
25
- duration: 1.2,
26
- ease: "power2.out",
27
- properties: { opacity: 1 },
28
- ...overrides,
29
- } as GsapAnimation;
45
+ function renderFocusCard(
46
+ focusedSegment: {
47
+ tweenPercentage: number;
48
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
49
+ } | null,
50
+ onEaseCommit = vi.fn(),
51
+ defaultExpanded = false,
52
+ animation = ANIMATION,
53
+ onUpdateMeta = vi.fn(),
54
+ onUpdateSegmentEase = vi.fn(),
55
+ ) {
56
+ const host = document.createElement("div");
57
+ document.body.append(host);
58
+ const root = createRoot(host);
59
+ const render = (nextFocusedSegment: { tweenPercentage: number } | null) => {
60
+ act(() => {
61
+ root.render(
62
+ <AnimationCard
63
+ animation={animation}
64
+ defaultExpanded={defaultExpanded}
65
+ focusedSegment={nextFocusedSegment}
66
+ onFocusSegmentConsumed={vi.fn()}
67
+ onUpdateProperty={vi.fn()}
68
+ onUpdateMeta={onUpdateMeta}
69
+ onDeleteAnimation={vi.fn()}
70
+ onAddProperty={vi.fn()}
71
+ onRemoveProperty={vi.fn()}
72
+ onUpdateKeyframeEase={onEaseCommit}
73
+ onUpdateSegmentEase={onUpdateSegmentEase}
74
+ />,
75
+ );
76
+ });
77
+ };
78
+ render(focusedSegment);
79
+ return { host, root, render };
30
80
  }
31
81
 
32
- const noop = () => {};
82
+ function findButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
83
+ return Array.from(host.querySelectorAll("button")).find((button) =>
84
+ button.textContent?.includes(text),
85
+ );
86
+ }
87
+
88
+ function openSegment(host: HTMLElement, label: string): void {
89
+ const segment = findButton(host, label);
90
+ expect(segment).toBeDefined();
91
+ act(() => segment?.click());
92
+ }
33
93
 
34
94
  function selectPreset(host: HTMLElement, presetId: string): string {
35
95
  const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId);
36
96
  if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`);
37
-
38
97
  const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
39
98
  expect(dropdown).not.toBeNull();
40
99
  act(() => dropdown?.click());
@@ -45,6 +104,8 @@ function selectPreset(host: HTMLElement, presetId: string): string {
45
104
  return presetConfig.ease;
46
105
  }
47
106
 
107
+ const noop = () => {};
108
+
48
109
  /** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
49
110
  function renderCard({
50
111
  animation = baseAnimation(),
@@ -82,7 +143,157 @@ function renderCard({
82
143
  return { host, root };
83
144
  }
84
145
 
146
+ function restoreScrollIntoView(descriptor: PropertyDescriptor | undefined): void {
147
+ if (descriptor) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", descriptor);
148
+ else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
149
+ }
150
+
151
+ describe("AnimationCard", () => {
152
+ it("scrolls a focused segment into view but not a manually toggled segment", () => {
153
+ const originalScrollIntoView = Object.getOwnPropertyDescriptor(
154
+ HTMLElement.prototype,
155
+ "scrollIntoView",
156
+ );
157
+ const scrollIntoView = vi.fn();
158
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
159
+ configurable: true,
160
+ value: scrollIntoView,
161
+ });
162
+
163
+ const view = renderFocusCard({ tweenPercentage: 50 });
164
+ try {
165
+ expect(scrollIntoView).toHaveBeenCalledOnce();
166
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
167
+
168
+ view.render(null);
169
+ const manualToggle = findButton(view.host, "50% → 100%");
170
+ expect(manualToggle).toBeDefined();
171
+ act(() => manualToggle?.click());
172
+ expect(scrollIntoView).toHaveBeenCalledOnce();
173
+ } finally {
174
+ act(() => view.root.unmount());
175
+ restoreScrollIntoView(originalScrollIntoView);
176
+ }
177
+ });
178
+
179
+ it("tracks a committed segment ease alongside the existing update", () => {
180
+ const onEaseCommit = vi.fn();
181
+ const view = renderFocusCard(null, onEaseCommit, true);
182
+ openSegment(view.host, "0% → 50%");
183
+ const ease = selectPreset(view.host, "quad-out");
184
+
185
+ expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease);
186
+ expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "commit", ease });
187
+ act(() => view.root.unmount());
188
+ });
189
+
190
+ it("commits a focused multi-id segment ease through the bulk callback", () => {
191
+ const onUpdateKeyframeEase = vi.fn();
192
+ const onUpdateSegmentEase = vi.fn();
193
+ const collidingAnimationTargets = [
194
+ { animationId: ANIMATION.id, tweenPercentage: 50 },
195
+ { animationId: "scale-tween", tweenPercentage: 75 },
196
+ { animationId: "opacity-tween", tweenPercentage: 25 },
197
+ ];
198
+ const view = renderFocusCard(
199
+ { tweenPercentage: 50, collidingAnimationTargets },
200
+ onUpdateKeyframeEase,
201
+ false,
202
+ ANIMATION,
203
+ vi.fn(),
204
+ onUpdateSegmentEase,
205
+ );
206
+ const ease = selectPreset(view.host, "quad-out");
207
+
208
+ expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(collidingAnimationTargets, ease);
209
+ expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
210
+ act(() => view.root.unmount());
211
+ });
212
+
213
+ it("keeps a focused single-id segment ease on the single callback", () => {
214
+ const onUpdateKeyframeEase = vi.fn();
215
+ const onUpdateSegmentEase = vi.fn();
216
+ const view = renderFocusCard(
217
+ {
218
+ tweenPercentage: 50,
219
+ collidingAnimationTargets: [{ animationId: ANIMATION.id, tweenPercentage: 50 }],
220
+ },
221
+ onUpdateKeyframeEase,
222
+ false,
223
+ ANIMATION,
224
+ vi.fn(),
225
+ onUpdateSegmentEase,
226
+ );
227
+ const ease = selectPreset(view.host, "quad-out");
228
+
229
+ expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(ANIMATION.id, 50, ease);
230
+ expect(onUpdateSegmentEase).not.toHaveBeenCalled();
231
+ act(() => view.root.unmount());
232
+ });
233
+
234
+ it("commits a focused flat tween segment ease through tween metadata", () => {
235
+ const onUpdateMeta = vi.fn();
236
+ const onUpdateKeyframeEase = vi.fn();
237
+ const view = renderFocusCard(
238
+ { tweenPercentage: 100 },
239
+ onUpdateKeyframeEase,
240
+ false,
241
+ FLAT_ANIMATION,
242
+ onUpdateMeta,
243
+ );
244
+ const ease = selectPreset(view.host, "quad-out");
245
+
246
+ expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(FLAT_ANIMATION.id, { ease });
247
+ expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
248
+ act(() => view.root.unmount());
249
+ });
250
+ });
251
+
252
+ function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
253
+ return {
254
+ id: "anim-1",
255
+ method: "to",
256
+ position: 0.8,
257
+ duration: 1.2,
258
+ ease: "power2.out",
259
+ properties: { opacity: 1 },
260
+ ...overrides,
261
+ } as GsapAnimation;
262
+ }
263
+
85
264
  describe("AnimationCard ease editing", () => {
265
+ it.each([
266
+ ["spring", "power2.out", "spring(0.42)", "Spring bounce"],
267
+ ["wiggle", "power2.out", "wiggle(3,easeInOut,0.12)", "Wiggle count"],
268
+ ["curve", "spring(0.6)", "custom(M0,0 C0.16,1 0.3,1 1,1)", "Cubic bezier control points"],
269
+ ] as const)(
270
+ "commits and immediately displays the %s default when a keyframe segment switches mode",
271
+ (mode, currentEase, ease, fieldLabel) => {
272
+ const onUpdateKeyframeEase = vi.fn();
273
+ const animation = baseAnimation({
274
+ keyframes: {
275
+ format: "percentage",
276
+ keyframes: [
277
+ { percentage: 0, properties: { opacity: 0 } },
278
+ { percentage: 50, properties: { opacity: 0.5 }, ease: currentEase },
279
+ { percentage: 100, properties: { opacity: 1 } },
280
+ ],
281
+ },
282
+ });
283
+ const view = renderFocusCard(null, onUpdateKeyframeEase, true, animation);
284
+
285
+ openSegment(view.host, "0% → 50%");
286
+ const modeButton = view.host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
287
+ expect(modeButton).not.toBeNull();
288
+ act(() => modeButton?.click());
289
+
290
+ expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
291
+ expect(modeButton?.getAttribute("aria-checked")).toBe("true");
292
+ expect(view.host.querySelector(`[aria-label="${fieldLabel}"]`)).not.toBeNull();
293
+ act(() => view.root.unmount());
294
+ },
295
+ );
296
+
86
297
  it("commits one preset change to the selected keyframe segment", () => {
87
298
  const onUpdateKeyframeEase = vi.fn();
88
299
  const animation = baseAnimation({
@@ -97,11 +308,7 @@ describe("AnimationCard ease editing", () => {
97
308
  });
98
309
  const view = renderCard({ animation, onUpdateKeyframeEase });
99
310
 
100
- const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
101
- button.textContent?.includes("0% → 50%"),
102
- );
103
- expect(segment).toBeDefined();
104
- act(() => segment?.click());
311
+ openSegment(view.host, "0% → 50%");
105
312
  const ease = selectPreset(view.host, "quad-out");
106
313
 
107
314
  expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
@@ -18,12 +18,16 @@ import {
18
18
  parseNumericOrString,
19
19
  BOOLEAN_PROPS,
20
20
  } from "./AnimationCardParts";
21
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
21
22
 
22
23
  interface AnimationCardProps extends GsapAnimationEditCallbacks {
23
24
  animation: GsapAnimation;
24
25
  defaultExpanded: boolean;
25
26
  flat?: boolean;
26
- focusedSegment?: { tweenPercentage: number } | null;
27
+ focusedSegment?: {
28
+ tweenPercentage: number;
29
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
30
+ } | null;
27
31
  onFocusSegmentConsumed?: () => void;
28
32
  }
29
33
 
@@ -47,6 +51,7 @@ export const AnimationCard = memo(function AnimationCard({
47
51
  onSetArcPath,
48
52
  onUpdateArcSegment,
49
53
  onUpdateKeyframeEase,
54
+ onUpdateSegmentEase,
50
55
  onSetAllKeyframeEases,
51
56
  onUnroll,
52
57
  }: AnimationCardProps) {
@@ -54,6 +59,9 @@ export const AnimationCard = memo(function AnimationCard({
54
59
  const [addingProp, setAddingProp] = useState(false);
55
60
  const [addingFromProp, setAddingFromProp] = useState(false);
56
61
  const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
62
+ const [focusedCollidingAnimationTargets, setFocusedCollidingAnimationTargets] = useState<
63
+ AnimationKeyframeTarget[] | undefined
64
+ >();
57
65
  const cardRef = useRef<HTMLDivElement>(null);
58
66
  const pendingAutoScrollRef = useRef(false);
59
67
 
@@ -62,6 +70,7 @@ export const AnimationCard = memo(function AnimationCard({
62
70
  setExpanded(true);
63
71
  pendingAutoScrollRef.current = true;
64
72
  setExpandedKfPct(focusedSegment.tweenPercentage);
73
+ setFocusedCollidingAnimationTargets(focusedSegment.collidingAnimationTargets);
65
74
  onFocusSegmentConsumed?.();
66
75
  }, [focusedSegment, onFocusSegmentConsumed]);
67
76
 
@@ -288,9 +297,21 @@ export const AnimationCard = memo(function AnimationCard({
288
297
  keyframes={animation.keyframes.keyframes}
289
298
  globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
290
299
  expandedPct={expandedKfPct}
291
- onToggle={setExpandedKfPct}
300
+ collidingAnimationTargets={focusedCollidingAnimationTargets}
301
+ onToggle={(pct) => {
302
+ setExpandedKfPct(pct);
303
+ setFocusedCollidingAnimationTargets(undefined);
304
+ }}
292
305
  onEaseCommit={(pct, ease) => {
293
- onUpdateKeyframeEase(animation.id, pct, ease);
306
+ if (
307
+ focusedCollidingAnimationTargets &&
308
+ focusedCollidingAnimationTargets.length > 1 &&
309
+ onUpdateSegmentEase
310
+ ) {
311
+ onUpdateSegmentEase(focusedCollidingAnimationTargets, ease);
312
+ } else {
313
+ onUpdateKeyframeEase(animation.id, pct, ease);
314
+ }
294
315
  trackStudioSegmentEaseEdit({ action: "commit", ease });
295
316
  }}
296
317
  onApplyAll={
@@ -3,7 +3,11 @@
3
3
  import React, { act, useState } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { parseSpringBounce } from "@hyperframes/core/spring-ease";
7
+ import { parseWiggleEase } from "@hyperframes/core/wiggle-ease";
6
8
  import { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection";
9
+ import { resolveEaseCurveTuple } from "./gsapAnimationConstants";
10
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
7
11
 
8
12
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
13
 
@@ -11,12 +15,22 @@ afterEach(() => {
11
15
  document.body.innerHTML = "";
12
16
  });
13
17
 
14
- function renderSection(ease = "none", onCustomEaseCommit = vi.fn()) {
18
+ function renderSection(
19
+ ease = "none",
20
+ onCustomEaseCommit = vi.fn(),
21
+ collidingAnimationTargets?: AnimationKeyframeTarget[],
22
+ ) {
15
23
  const host = document.createElement("div");
16
24
  document.body.append(host);
17
25
  const root = createRoot(host);
18
26
  act(() => {
19
- root.render(<EaseCurveSection ease={ease} onCustomEaseCommit={onCustomEaseCommit} />);
27
+ root.render(
28
+ <EaseCurveSection
29
+ ease={ease}
30
+ onCustomEaseCommit={onCustomEaseCommit}
31
+ collidingAnimationTargets={collidingAnimationTargets}
32
+ />,
33
+ );
20
34
  });
21
35
  return { host, root, onCustomEaseCommit };
22
36
  }
@@ -65,6 +79,19 @@ function renderStatefulSection(initialEase = "none", onCustomEaseCommit = vi.fn(
65
79
  return { host, root, onCustomEaseCommit };
66
80
  }
67
81
 
82
+ function renderControlledSection(initialEase = "none", onCustomEaseCommit = vi.fn()) {
83
+ const host = document.createElement("div");
84
+ document.body.append(host);
85
+ const root = createRoot(host);
86
+ const renderEase = (ease: string) => {
87
+ act(() =>
88
+ root.render(<EaseCurveSection ease={ease} onCustomEaseCommit={onCustomEaseCommit} />),
89
+ );
90
+ };
91
+ renderEase(initialEase);
92
+ return { host, root, onCustomEaseCommit, renderEase };
93
+ }
94
+
68
95
  function clickMode(host: HTMLElement, mode: "curve" | "spring" | "wiggle"): void {
69
96
  const toggle = host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
70
97
  expect(toggle).not.toBeNull();
@@ -82,6 +109,29 @@ function editorLabel(host: HTMLElement): string | null {
82
109
  }
83
110
 
84
111
  describe("EaseCurveSection preset grid", () => {
112
+ it("shows the number of animations for a multi-id segment", () => {
113
+ const { host, root } = renderSection("power2.out", vi.fn(), [
114
+ { animationId: "move-x", tweenPercentage: 20 },
115
+ { animationId: "move-y", tweenPercentage: 50 },
116
+ { animationId: "fade", tweenPercentage: 80 },
117
+ ]);
118
+
119
+ expect(host.textContent).toContain("Applies to 3 animations");
120
+
121
+ act(() => root.unmount());
122
+ });
123
+
124
+ it.each([undefined, [{ animationId: "move-x", tweenPercentage: 20 }]])(
125
+ "does not show a property count for a non-colliding segment",
126
+ (collidingAnimationTargets) => {
127
+ const { host, root } = renderSection("power2.out", vi.fn(), collidingAnimationTargets);
128
+
129
+ expect(host.textContent).not.toContain("Applies to");
130
+
131
+ act(() => root.unmount());
132
+ },
133
+ );
134
+
85
135
  it.each([
86
136
  ["curve", "none", "linear", ["flow-7", "spring-bouncy"]],
87
137
  ["spring", "spring(0.42)", "spring-bouncy", ["linear", "flow-7"]],
@@ -257,12 +307,107 @@ describe("EaseCurveSection preset grid", () => {
257
307
 
258
308
  clickMode(host, "spring");
259
309
  expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.42)");
310
+ expect(parseSpringBounce(onCustomEaseCommit.mock.lastCall![0])).toBe(0.42);
260
311
 
261
312
  clickMode(host, "curve");
262
313
  expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.16,1 0.3,1 1,1)");
314
+ expect(resolveEaseCurveTuple(onCustomEaseCommit.mock.lastCall![0])).toEqual([0.16, 1, 0.3, 1]);
263
315
 
264
316
  clickMode(host, "wiggle");
265
317
  expect(onCustomEaseCommit).toHaveBeenLastCalledWith("wiggle(3,easeInOut,0.12)");
318
+ expect(parseWiggleEase(onCustomEaseCommit.mock.lastCall![0])).toEqual({
319
+ wiggles: 3,
320
+ type: "easeInOut",
321
+ amplitude: 0.12,
322
+ });
323
+ expect(onCustomEaseCommit).toHaveBeenCalledTimes(3);
324
+
325
+ act(() => root.unmount());
326
+ });
327
+
328
+ it("keeps an optimistic mode visible through its canonical prop round-trip", () => {
329
+ const { host, root, onCustomEaseCommit, renderEase } = renderControlledSection();
330
+
331
+ clickMode(host, "spring");
332
+ expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
333
+ "true",
334
+ );
335
+ expect(host.querySelector('[aria-label="Spring bounce"]')).not.toBeNull();
336
+
337
+ renderEase("spring(0.42)");
338
+ expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
339
+ "true",
340
+ );
341
+ expect(host.querySelector('[aria-label="Spring bounce"]')).not.toBeNull();
342
+ expect(onCustomEaseCommit).toHaveBeenCalledExactlyOnceWith("spring(0.42)");
343
+
344
+ act(() => root.unmount());
345
+ });
346
+
347
+ // Two switches before the first commit round-trips: the commits serialize, so
348
+ // the older value arrives while the newer one is still in flight. Repainting
349
+ // it would flash wiggle, spring, wiggle in the panel.
350
+ it("ignores an older in-flight commit arriving after a newer switch", () => {
351
+ const { host, root, renderEase } = renderControlledSection();
352
+
353
+ clickMode(host, "spring");
354
+ clickMode(host, "wiggle");
355
+ renderEase("spring(0.42)");
356
+
357
+ expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
358
+ "true",
359
+ );
360
+
361
+ renderEase("wiggle(3,easeInOut,0.12)");
362
+ expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
363
+ "true",
364
+ );
365
+
366
+ act(() => root.unmount());
367
+ });
368
+
369
+ // The commit is fire-and-forget: a rejected write or one that lands as a
370
+ // no-op never changes `ease`, so nothing else can retire the optimistic
371
+ // value and the panel would keep claiming a curve that was never saved.
372
+ it("falls back to the committed ease when the commit never round-trips", () => {
373
+ vi.useFakeTimers();
374
+ try {
375
+ const { host, root } = renderControlledSection("power2.out");
376
+
377
+ clickMode(host, "spring");
378
+ expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
379
+ "true",
380
+ );
381
+
382
+ act(() => vi.advanceTimersByTime(2000));
383
+
384
+ expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
385
+ "false",
386
+ );
387
+ expect(host.querySelector('[data-ease-mode="curve"]')?.getAttribute("aria-checked")).toBe(
388
+ "true",
389
+ );
390
+
391
+ act(() => root.unmount());
392
+ } finally {
393
+ vi.useRealTimers();
394
+ }
395
+ });
396
+
397
+ it("replaces an optimistic mode when the canonical prop changes externally", () => {
398
+ const { host, root, renderEase } = renderControlledSection();
399
+
400
+ clickMode(host, "spring");
401
+ renderEase("wiggle(2,uniform,0.3)");
402
+
403
+ expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
404
+ "false",
405
+ );
406
+ expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
407
+ "true",
408
+ );
409
+ expect(host.querySelector('[aria-label="Wiggle count"]')).not.toBeNull();
410
+ expect(host.querySelector('[aria-label="Spring bounce"]')).toBeNull();
266
411
 
267
412
  act(() => root.unmount());
268
413
  });