@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.78",
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/parsers": "0.7.78",
50
- "@hyperframes/core": "0.7.78",
51
- "@hyperframes/sdk": "0.7.78",
52
- "@hyperframes/studio-server": "0.7.78",
53
- "@hyperframes/player": "0.7.78"
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.78"
68
+ "@hyperframes/producer": "0.7.80"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
@@ -154,10 +154,10 @@ export function StudioLeftSidebar({
154
154
  onAddAssetToTimeline={onAddAssetToTimeline}
155
155
  onAddCompositionToTimeline={onAddCompositionToTimeline}
156
156
  />
157
- {/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via
157
+ {/* Vertical resize divider: 3px visible seam, 13px pointer-capture zone via
158
158
  the absolutely-positioned inner hit area. The outer element is w-[3px] so
159
- it contributes only 3px of gap in the flex row; the inner -left-[2.5px]
160
- element widens the hit area to 8px without affecting layout. */}
159
+ it contributes only 3px of gap in the flex row; the inner -left-[2px]
160
+ element widens the hit area without affecting layout. */}
161
161
  <div
162
162
  role="separator"
163
163
  aria-label="Resize sidebar"
@@ -176,8 +176,13 @@ export function StudioLeftSidebar({
176
176
  adjustPanelWidth("left", delta);
177
177
  }}
178
178
  >
179
- {/* Expanded hit zone: 8px wide, centered on the 3px seam */}
180
- <div className="absolute inset-y-0 -left-[2.5px] w-2" />
179
+ {/* Expanded hit zone, deliberately asymmetric: 2px into the sidebar card,
180
+ the 3px seam, then 8px into the preview pane's p-2 stage gutter — the
181
+ only dead space adjacent to this seam. It stops at 13px rather than the
182
+ 24px WCAG 2.2 (2.5.8) target because the next pixel on either side is
183
+ live: the sidebar's scrolling tab content on the left, the preview
184
+ stage on the right. Silently stealing their clicks is the worse bug. */}
185
+ <div className="absolute inset-y-0 -left-[2px] w-[13px]" />
181
186
  {/* Visible hairline */}
182
187
  <div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
183
188
  </div>
@@ -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}
@@ -446,8 +447,7 @@ export function StudioRightPanel({
446
447
 
447
448
  return (
448
449
  <>
449
- {/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via
450
- the absolutely-positioned inner hit area. */}
450
+ {/* Vertical resize divider: 3px visible seam, 13px hit zone via the inner div. */}
451
451
  <div
452
452
  role="separator"
453
453
  aria-label="Resize inspector panel"
@@ -467,8 +467,10 @@ export function StudioRightPanel({
467
467
  adjustPanelWidth("right", delta);
468
468
  }}
469
469
  >
470
- {/* Expanded hit zone: 8px wide, centered on the 3px seam */}
471
- <div className="absolute inset-y-0 -left-[2.5px] w-2" />
470
+ {/* Asymmetric hit zone: 8px into the preview's p-2 gutter (the only dead
471
+ space), the 3px seam, 2px into the card. Stops short of the 24px WCAG
472
+ 2.5.8 target because the next pixel each way is live. */}
473
+ <div className="absolute inset-y-0 -left-[8px] w-[13px]" />
472
474
  {/* Visible hairline */}
473
475
  <div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
474
476
  </div>
@@ -449,7 +449,9 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
449
449
  setZoomMode("manual");
450
450
  setManualZoomPercent(timelineSliderToZoomPercent(Number(e.target.value)));
451
451
  }}
452
- className="mx-1 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
452
+ // h-6 on the input is the 24x24 WCAG 2.2 (2.5.8) target: the visible
453
+ // track stays 2px and the thumb 10px, only the pointer box grows.
454
+ className="mx-1 h-6 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
453
455
  />
454
456
  <Tooltip label="Zoom in">
455
457
  <button
@@ -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
  });