@hyperframes/studio 0.7.92 → 0.7.93

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 (108) hide show
  1. package/dist/assets/{hyperframes-player-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CDTN2ZVa.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +20 -20
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7055 -5321
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  16. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  17. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  18. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  19. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  20. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  21. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  22. package/src/components/editor/propertyPanelTypes.ts +9 -3
  23. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  24. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  25. package/src/components/sidebar/AssetCard.tsx +4 -3
  26. package/src/components/sidebar/AudioRow.tsx +4 -3
  27. package/src/contexts/FileManagerContext.tsx +12 -0
  28. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  29. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  30. package/src/hooks/externalFileReloadBus.ts +18 -0
  31. package/src/hooks/gsapDragCommit.test.ts +39 -1
  32. package/src/hooks/gsapDragCommit.ts +12 -40
  33. package/src/hooks/gsapEditOutcome.ts +72 -0
  34. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  35. package/src/hooks/gsapResizeIntercept.ts +44 -16
  36. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  37. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  38. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  39. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  40. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  41. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  42. package/src/hooks/useDomGeometryCommits.ts +20 -0
  43. package/src/hooks/useEditorSave.test.tsx +131 -0
  44. package/src/hooks/useEditorSave.ts +113 -39
  45. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  46. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  47. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  48. package/src/hooks/useFileManager.ts +32 -3
  49. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  50. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  51. package/src/hooks/useGsapAwareEditing.ts +88 -27
  52. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  53. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  54. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  55. package/src/hooks/usePreviewPersistence.ts +22 -58
  56. package/src/hooks/useSdkSession.ts +8 -52
  57. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  58. package/src/hooks/useStudioSdkSessions.ts +2 -7
  59. package/src/hooks/useStudioSessionStart.ts +15 -0
  60. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  61. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  62. package/src/player/components/Timeline.test.ts +24 -5
  63. package/src/player/components/Timeline.tsx +40 -40
  64. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  65. package/src/player/components/TimelineClip.test.tsx +17 -2
  66. package/src/player/components/TimelineClip.tsx +15 -3
  67. package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
  68. package/src/player/components/TimelineClipDiamonds.tsx +53 -13
  69. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  70. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  71. package/src/player/components/TimelineLanes.test.tsx +64 -6
  72. package/src/player/components/TimelineLanes.tsx +81 -64
  73. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  74. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  75. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  76. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  77. package/src/player/components/TimelineTrackRow.tsx +58 -7
  78. package/src/player/components/timelineDiamondTypes.ts +14 -7
  79. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  80. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  81. package/src/player/components/timelineLaneProps.ts +3 -0
  82. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  83. package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
  84. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  85. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  86. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  87. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  88. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  89. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  90. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  91. package/src/player/components/useTimelineRowVirtualization.ts +16 -26
  92. package/src/player/store/liveTime.ts +11 -0
  93. package/src/player/store/playerStore.test.ts +29 -22
  94. package/src/player/store/playerStore.ts +24 -29
  95. package/src/player/store/timelineFocusState.ts +15 -0
  96. package/src/utils/domEditSaveQueue.test.ts +37 -1
  97. package/src/utils/domEditSaveQueue.ts +22 -4
  98. package/src/utils/externalConflictStorage.test.ts +95 -0
  99. package/src/utils/externalConflictStorage.ts +172 -0
  100. package/src/utils/studioFileVersion.test.ts +28 -1
  101. package/src/utils/studioFileVersion.ts +30 -0
  102. package/src/utils/studioPendingEdits.test.ts +108 -1
  103. package/src/utils/studioPendingEdits.ts +39 -2
  104. package/src/utils/studioSaveDiagnostics.ts +5 -0
  105. package/dist/assets/index-BXkzp_OU.css +0 -1
  106. package/dist/assets/index-DIntkrQl.js +0 -428
  107. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  108. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -41,19 +41,45 @@ type Commit = (
41
41
  /** Renders the hook and hands its commit function to the caller via a ref callback. */
42
42
  function renderHookWith(
43
43
  animations: GsapAnimation[],
44
- onMutation: (mutation: Record<string, unknown>, label: string) => void,
44
+ onMutation: (mutation: Record<string, unknown>, label: string) => unknown | Promise<unknown>,
45
45
  onReady: (commit: Commit) => void,
46
+ bumpGsapCache = vi.fn(),
47
+ onBatch?: (
48
+ calls: Array<{ mutation: Record<string, unknown>; options: { label: string } }>,
49
+ label: string,
50
+ ) => unknown | Promise<unknown>,
46
51
  ) {
47
52
  function Harness() {
53
+ const gsapCommitMutation = Object.assign(
54
+ async (
55
+ _sel: DomEditSelection,
56
+ mutation: Record<string, unknown>,
57
+ options: { label: string },
58
+ ) => {
59
+ await onMutation(mutation, options.label);
60
+ },
61
+ onBatch
62
+ ? {
63
+ batch: async (
64
+ calls: Array<{
65
+ selection: DomEditSelection;
66
+ mutation: Record<string, unknown>;
67
+ options: { label: string };
68
+ }>,
69
+ options: { label: string },
70
+ ) => {
71
+ await onBatch(calls, options.label);
72
+ },
73
+ }
74
+ : {},
75
+ );
48
76
  const { commitAnimatedProperties } = useAnimatedPropertyCommit({
49
77
  selectedGsapAnimations: animations,
50
- gsapCommitMutation: async (_sel, mutation, options) => {
51
- onMutation(mutation, options.label);
52
- },
78
+ gsapCommitMutation,
53
79
  addGsapAnimation: vi.fn(),
54
80
  convertToKeyframes: vi.fn(),
55
81
  previewIframeRef: { current: null },
56
- bumpGsapCache: vi.fn(),
82
+ bumpGsapCache,
57
83
  });
58
84
  onReady(commitAnimatedProperties);
59
85
  return null;
@@ -61,11 +87,106 @@ function renderHookWith(
61
87
  return mountReactHarness(<Harness />);
62
88
  }
63
89
 
90
+ describe("useAnimatedPropertyCommit — ownership and rejection propagation", () => {
91
+ it("rejects a helper-authored property before sending a mutation", async () => {
92
+ const helperRotation = {
93
+ id: "#box-to-rotation",
94
+ targetSelector: "#box",
95
+ propertyGroup: "rotation",
96
+ method: "to",
97
+ properties: { rotationX: 10 },
98
+ provenance: { kind: "helper", fn: "spin", callSite: 1 },
99
+ } as unknown as GsapAnimation;
100
+ const mutations: Array<Record<string, unknown>> = [];
101
+ let commit!: Commit;
102
+ const root = renderHookWith(
103
+ [helperRotation],
104
+ (mutation) => mutations.push(mutation),
105
+ (ready) => (commit = ready),
106
+ );
107
+
108
+ await expect(commit(selection, { rotationX: 12 })).rejects.toMatchObject({
109
+ name: "GsapEditBlockedError",
110
+ reason: "unroll-required",
111
+ });
112
+ expect(mutations).toHaveLength(0);
113
+ act(() => root.unmount());
114
+ });
115
+
116
+ it("rejects runtime-computed property ownership before sending a mutation", async () => {
117
+ const runtimePosition = {
118
+ ...keyframedAnim,
119
+ hasUnresolvedKeyframes: true,
120
+ } as GsapAnimation;
121
+ const mutations: Array<Record<string, unknown>> = [];
122
+ let commit!: Commit;
123
+ const root = renderHookWith(
124
+ [runtimePosition],
125
+ (mutation) => mutations.push(mutation),
126
+ (ready) => (commit = ready),
127
+ );
128
+
129
+ await expect(commit(selection, { x: 50 })).rejects.toMatchObject({
130
+ reason: "source-uneditable",
131
+ });
132
+ expect(mutations).toHaveLength(0);
133
+ act(() => root.unmount());
134
+ });
135
+
136
+ it("rejects every property before a mixed-group commit can partially persist", async () => {
137
+ const helperOpacity = {
138
+ id: "#box-to-visual",
139
+ targetSelector: "#box",
140
+ propertyGroup: "visual",
141
+ method: "to",
142
+ properties: { opacity: 0.5 },
143
+ provenance: { kind: "helper", fn: "fade", callSite: 1 },
144
+ } as unknown as GsapAnimation;
145
+ const mutations: Array<Record<string, unknown>> = [];
146
+ let commit!: Commit;
147
+ const root = renderHookWith(
148
+ [helperOpacity],
149
+ (mutation) => mutations.push(mutation),
150
+ (ready) => (commit = ready),
151
+ );
152
+
153
+ await expect(commit(selection, { x: 50, opacity: 0.8 })).rejects.toMatchObject({
154
+ reason: "unroll-required",
155
+ });
156
+ expect(mutations).toHaveLength(0);
157
+ act(() => root.unmount());
158
+ });
159
+
160
+ it("rethrows a persistence failure to the telemetry wrapper", async () => {
161
+ const failure = new Error("save failed");
162
+ const bumpGsapCache = vi.fn();
163
+ let commit!: Commit;
164
+ const root = renderHookWith(
165
+ [keyframedAnim],
166
+ async () => {
167
+ throw failure;
168
+ },
169
+ (ready) => (commit = ready),
170
+ bumpGsapCache,
171
+ );
172
+
173
+ await expect(commit(selection, { x: 50 })).rejects.toBe(failure);
174
+ expect(bumpGsapCache).toHaveBeenCalledTimes(1);
175
+ act(() => root.unmount());
176
+ });
177
+ });
178
+
64
179
  function renderCommitHook(
65
180
  mutations: Array<Record<string, unknown>>,
66
181
  onReady: (commit: Commit) => void,
67
182
  ) {
68
- return renderHookWith([keyframedAnim], (mutation) => mutations.push(mutation), onReady);
183
+ return renderHookWith(
184
+ [keyframedAnim],
185
+ (mutation) => {
186
+ mutations.push(mutation);
187
+ },
188
+ onReady,
189
+ );
69
190
  }
70
191
 
71
192
  // Regression (#1808): a "3D transform" / design-panel property edit on an
@@ -74,35 +195,28 @@ function renderCommitHook(
74
195
  // off, it must shift the whole tween instead of adding/updating a keyframe
75
196
  // at the playhead.
76
197
  describe("useAnimatedPropertyCommit — autoKeyframeEnabled toggle (#1808)", () => {
77
- it("shifts the whole tween instead of updating a keyframe when the toggle is off", async () => {
78
- usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 0 });
198
+ async function runCommitWithAutoKeyframe(enabled: boolean) {
199
+ usePlayerStore.setState({ autoKeyframeEnabled: enabled, currentTime: 0 });
79
200
  const mutations: Array<Record<string, unknown>> = [];
80
201
  let commit: Commit | undefined;
81
202
  const root = renderCommitHook(mutations, (fn) => (commit = fn));
203
+ await act(async () => commit!(selection, { x: 50 }));
204
+ act(() => root.unmount());
205
+ return mutations;
206
+ }
82
207
 
83
- await act(async () => {
84
- await commit!(selection, { x: 50 });
85
- });
86
-
208
+ it("shifts the whole tween instead of updating a keyframe when the toggle is off", async () => {
209
+ const mutations = await runCommitWithAutoKeyframe(false);
87
210
  expect(mutations).toHaveLength(1);
88
211
  expect(mutations[0]!.type).toBe("replace-with-keyframes");
89
- act(() => root.unmount());
90
212
  });
91
213
 
92
214
  it("still updates a keyframe at the playhead when the toggle is on (default)", async () => {
93
- const mutations: Array<Record<string, unknown>> = [];
94
- let commit: Commit | undefined;
95
- const root = renderCommitHook(mutations, (fn) => (commit = fn));
96
-
97
- await act(async () => {
98
- await commit!(selection, { x: 50 });
99
- });
100
-
215
+ const mutations = await runCommitWithAutoKeyframe(true);
101
216
  expect(mutations.some((m) => m.type === "update-keyframe" || m.type === "add-keyframe")).toBe(
102
217
  true,
103
218
  );
104
219
  expect(mutations.some((m) => m.type === "replace-with-keyframes")).toBe(false);
105
- act(() => root.unmount());
106
220
  });
107
221
  });
108
222
 
@@ -125,7 +239,9 @@ describe("commitStaticSet group routing", () => {
125
239
  ) {
126
240
  return renderHookWith(
127
241
  [positionSet],
128
- (mutation, label) => committed.push({ mutation, label }),
242
+ (mutation, label) => {
243
+ committed.push({ mutation, label });
244
+ },
129
245
  onReady,
130
246
  );
131
247
  }
@@ -173,7 +289,9 @@ describe("commitStaticSet group routing", () => {
173
289
  let commit!: Commit;
174
290
  renderHookWith(
175
291
  [positionSet, instantSizeHold],
176
- (mutation, label) => committed.push({ mutation, label }),
292
+ (mutation, label) => {
293
+ committed.push({ mutation, label });
294
+ },
177
295
  (c) => (commit = c),
178
296
  );
179
297
 
@@ -191,4 +309,56 @@ describe("commitStaticSet group routing", () => {
191
309
  expect(committed.some(({ mutation }) => mutation.type === "add")).toBe(false);
192
310
  expect(committed[0]!.mutation.animationId).not.toBe(positionSet.id);
193
311
  });
312
+
313
+ it("persists multiple property groups in one atomic batch", async () => {
314
+ const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
315
+ const batches: Array<{
316
+ calls: Array<{ mutation: Record<string, unknown>; options: { label: string } }>;
317
+ label: string;
318
+ }> = [];
319
+ let commit!: Commit;
320
+ const root = renderHookWith(
321
+ [positionSet],
322
+ (mutation, label) => committed.push({ mutation, label }),
323
+ (ready) => (commit = ready),
324
+ vi.fn(),
325
+ (calls, label) => batches.push({ calls, label }),
326
+ );
327
+
328
+ await act(async () => {
329
+ await commit(selection, { x: 400, width: 500 });
330
+ });
331
+
332
+ expect(committed).toHaveLength(0);
333
+ expect(batches).toHaveLength(1);
334
+ expect(batches[0]!.label).toBe("Set properties");
335
+ expect(batches[0]!.calls.map(({ mutation }) => mutation)).toEqual([
336
+ {
337
+ type: "update-properties",
338
+ animationId: positionSet.id,
339
+ properties: { x: 400 },
340
+ },
341
+ {
342
+ type: "add",
343
+ targetSelector: "#box",
344
+ method: "set",
345
+ position: 0,
346
+ properties: { width: 500 },
347
+ global: true,
348
+ },
349
+ ]);
350
+ act(() => root.unmount());
351
+ });
352
+
353
+ it("fails before sending anything when an atomic multi-group batch is unavailable", async () => {
354
+ const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
355
+ let commit!: Commit;
356
+ const root = renderStaticHook(committed, (ready) => (commit = ready));
357
+
358
+ await expect(commit(selection, { x: 400, width: 500 })).rejects.toThrow(
359
+ "Atomic GSAP property batch is unavailable",
360
+ );
361
+ expect(committed).toHaveLength(0);
362
+ act(() => root.unmount());
363
+ });
194
364
  });
@@ -24,21 +24,16 @@ import {
24
24
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
25
25
  import { roundTo3 } from "../utils/rounding";
26
26
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
27
+ import {
28
+ assertGsapEditPersisted,
29
+ directEditOutcomeForProperties,
30
+ GsapEditBlockedError,
31
+ } from "./gsapEditOutcome";
32
+ import type { CommitMutation, CommitMutationCall } from "./gsapScriptCommitTypes";
27
33
 
28
34
  interface CommitAnimatedPropertyDeps {
29
35
  selectedGsapAnimations: GsapAnimation[];
30
- gsapCommitMutation:
31
- | ((
32
- selection: DomEditSelection,
33
- mutation: Record<string, unknown>,
34
- options: {
35
- label: string;
36
- coalesceKey?: string;
37
- softReload?: boolean;
38
- skipReload?: boolean;
39
- },
40
- ) => Promise<void>)
41
- | null;
36
+ gsapCommitMutation: CommitMutation | null;
42
37
  addGsapAnimation: (
43
38
  selection: DomEditSelection,
44
39
  method: "to" | "from" | "set" | "fromTo",
@@ -68,6 +63,8 @@ function pickBestAnimation(
68
63
  if (candidates.length === 0) return undefined;
69
64
  if (candidates.length === 1) return candidates[0];
70
65
  const currentTime = usePlayerStore.getState().currentTime;
66
+ // Intentional multi-signal ranking: group match, selector specificity, and playhead overlap.
67
+ // fallow-ignore-next-line complexity
71
68
  const scored = candidates.map((a) => {
72
69
  let score = 0;
73
70
  if (a.keyframes) score += 10;
@@ -107,7 +104,7 @@ async function maybeAutoKeyframeSet(
107
104
  );
108
105
  }
109
106
 
110
- type Commit = NonNullable<CommitAnimatedPropertyDeps["gsapCommitMutation"]>;
107
+ type Commit = CommitMutation;
111
108
 
112
109
  /** Undo-history label for a static-set commit, from the group it writes. */
113
110
  const STATIC_SET_LABELS: Partial<Record<ReturnType<typeof classifyPropertyGroup>, string>> = {
@@ -137,6 +134,17 @@ async function commitSetProps(
137
134
  animations: GsapAnimation[],
138
135
  commit: Commit,
139
136
  ): Promise<void> {
137
+ const call = buildSetPropsCall(selection, setAnim, propEntries, selector);
138
+ await commit(call.selection, call.mutation, call.options);
139
+ await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
140
+ }
141
+
142
+ function buildSetPropsCall(
143
+ selection: DomEditSelection,
144
+ setAnim: GsapAnimation,
145
+ propEntries: [string, number | string][],
146
+ selector: string | null,
147
+ ): CommitMutationCall {
140
148
  const properties = Object.fromEntries(propEntries);
141
149
  const numericProps: SetPatchProps = {};
142
150
  for (const [k, v] of propEntries) {
@@ -152,16 +160,15 @@ async function commitSetProps(
152
160
  },
153
161
  }
154
162
  : undefined;
155
- await commit(
163
+ return {
156
164
  selection,
157
- { type: "update-properties", animationId: setAnim.id, properties },
158
- {
165
+ mutation: { type: "update-properties", animationId: setAnim.id, properties },
166
+ options: {
159
167
  label: staticSetLabel(propEntries),
160
168
  softReload: true,
161
169
  ...(instantPatch ? { instantPatch } : {}),
162
170
  },
163
- );
164
- await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
171
+ };
165
172
  }
166
173
 
167
174
  /**
@@ -177,7 +184,25 @@ async function commitStaticSet(
177
184
  animations: GsapAnimation[],
178
185
  commit: Commit,
179
186
  ): Promise<void> {
180
- if (!selector) return;
187
+ const calls = planStaticSetCalls(selection, propEntries, selector, animations);
188
+ const only = calls[0];
189
+ if (!only) return;
190
+ if (calls.length === 1) {
191
+ await commit(only.selection, only.mutation, only.options);
192
+ return;
193
+ }
194
+ if (!commit.batch) {
195
+ throw new Error("Atomic GSAP property batch is unavailable");
196
+ }
197
+ await commit.batch(calls, {
198
+ label: staticSetLabel(propEntries),
199
+ softReload: true,
200
+ });
201
+ }
202
+
203
+ function groupStaticSetEntries(
204
+ propEntries: [string, number | string][],
205
+ ): Map<string, [string, number | string][]> {
181
206
  // One commit per PROPERTY GROUP, each into a static write that owns that group —
182
207
  // never a live tween, and never a foreign-group write (a width edit used to
183
208
  // merge into the element's position set, producing a mixed write the split
@@ -191,9 +216,22 @@ async function commitStaticSet(
191
216
  batch.push(entry);
192
217
  byGroup.set(group, batch);
193
218
  }
194
- const staticWrites = animations.filter(
195
- (a) => isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
196
- );
219
+ return byGroup;
220
+ }
221
+
222
+ function planStaticSetCalls(
223
+ selection: DomEditSelection,
224
+ propEntries: [string, number | string][],
225
+ selector: string | null,
226
+ animations: GsapAnimation[],
227
+ ): CommitMutationCall[] {
228
+ const byGroup = groupStaticSetEntries(propEntries);
229
+ const staticWrites = selector
230
+ ? animations.filter(
231
+ (a) =>
232
+ isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
233
+ )
234
+ : [];
197
235
  // Resolve every group's target BEFORE committing anything, and coalesce
198
236
  // groups that land on the SAME write into one commit: the snapshot is captured
199
237
  // once, so if two groups resolved to one legacy mixed write, a first
@@ -209,13 +247,12 @@ async function commitStaticSet(
209
247
  newSetBatches.push(batch);
210
248
  }
211
249
  }
212
- for (const [targetWrite, batch] of byTargetWrite) {
213
- await commitSetProps(selection, targetWrite, batch, selector, animations, commit);
214
- }
215
- // Fresh adds don't reshape existing sets, so their ids can't go stale.
216
- for (const batch of newSetBatches) {
217
- await addGlobalStaticSet(selection, batch, commit);
218
- }
250
+ return [
251
+ ...[...byTargetWrite].map(([targetWrite, batch]) =>
252
+ buildSetPropsCall(selection, targetWrite, batch, selector),
253
+ ),
254
+ ...newSetBatches.map((batch) => buildGlobalStaticSetCall(selection, batch)),
255
+ ];
219
256
  }
220
257
 
221
258
  /**
@@ -241,11 +278,10 @@ function findGroupOwningStaticWrite(
241
278
  * the timeline (matches the manual-drag UX). The global-set instant patch applies
242
279
  * it straight to the element so the first edit shows with no soft-reload flash.
243
280
  */
244
- async function addGlobalStaticSet(
281
+ function buildGlobalStaticSetCall(
245
282
  selection: DomEditSelection,
246
283
  batch: [string, number | string][],
247
- commit: Commit,
248
- ): Promise<void> {
284
+ ): CommitMutationCall {
249
285
  const numericProps: SetPatchProps = {};
250
286
  for (const [k, v] of batch) {
251
287
  if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
@@ -254,10 +290,10 @@ async function addGlobalStaticSet(
254
290
  // selector is the bare class an id-less element yields, which would hold every
255
291
  // sibling. No one-element form means no write at all (see writeTargetSelector).
256
292
  const target = writeTargetSelector(selection);
257
- if (!target) return;
258
- await commit(
293
+ if (!target) throw new GsapEditBlockedError("no-selector");
294
+ return {
259
295
  selection,
260
- {
296
+ mutation: {
261
297
  type: "add",
262
298
  targetSelector: target,
263
299
  method: "set",
@@ -265,7 +301,7 @@ async function addGlobalStaticSet(
265
301
  properties: Object.fromEntries(batch),
266
302
  global: true,
267
303
  },
268
- {
304
+ options: {
269
305
  label: staticSetLabel(batch),
270
306
  softReload: true,
271
307
  ...(Object.keys(numericProps).length > 0
@@ -277,7 +313,7 @@ async function addGlobalStaticSet(
277
313
  }
278
314
  : {}),
279
315
  },
280
- );
316
+ };
281
317
  }
282
318
 
283
319
  /** Convert-if-flat, then write ALL props into ONE keyframe at the playhead. */
@@ -393,11 +429,19 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
393
429
  const { selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache } = deps;
394
430
 
395
431
  const commitAnimatedProperties = useCallback(
432
+ // This is the single routing boundary for set, keyframe, whole-tween, and first-group writes.
433
+ // fallow-ignore-next-line complexity
396
434
  async (selection: DomEditSelection, props: Record<string, number | string>): Promise<void> => {
397
435
  if (!gsapCommitMutation) return;
398
436
  const propEntries = Object.entries(props);
399
437
  if (propEntries.length === 0) return;
400
438
  const primaryProp = propEntries[0]![0];
439
+ assertGsapEditPersisted(
440
+ directEditOutcomeForProperties(
441
+ selectedGsapAnimations,
442
+ new Set(propEntries.map(([property]) => property)),
443
+ ),
444
+ );
401
445
 
402
446
  const iframe = previewIframeRef.current;
403
447
  const selector = selectorFromSelection(selection);
@@ -407,6 +451,9 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
407
451
  selector,
408
452
  primaryProp,
409
453
  );
454
+ if (!anim && !writeTargetSelector(selection)) {
455
+ throw new GsapEditBlockedError("no-selector");
456
+ }
410
457
  // Whether the element is animated at all. A 3D edit only creates/edits
411
458
  // keyframes when it IS — a static element (no keyframes on any of its tweens)
412
459
  // gets a `tl.set`, never new keyframes (matches manual drag / resize / rotate).
@@ -461,12 +508,15 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
461
508
  return;
462
509
  }
463
510
 
464
- // Existing static hold on a NON-animated element merge the props into the
465
- // same write (maybeAutoKeyframeSet no-ops when nothing else is keyframed).
466
- if (anim && isInstantHold(anim)) {
467
- await commitSetProps(
511
+ // Static element (no keyframes anywhere)persist as a `tl.set`, never
512
+ // keyframes (incl. the no-animation case, which creates a fresh set).
513
+ // Route the complete property set through the group-aware planner even
514
+ // when pickBestAnimation found one existing set: a mixed X+width edit
515
+ // must update the position set AND create a size set atomically rather
516
+ // than contaminating the first set with a foreign property group.
517
+ if (!elementHasKeyframes) {
518
+ await commitStaticSet(
468
519
  selection,
469
- anim,
470
520
  propEntries,
471
521
  selector,
472
522
  selectedGsapAnimations,
@@ -475,11 +525,12 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
475
525
  return;
476
526
  }
477
527
 
478
- // Static element (no keyframes anywhere) persist as a `tl.set`, never
479
- // keyframes (incl. the no-animation case, which creates a fresh set).
480
- if (!elementHasKeyframes) {
481
- await commitStaticSet(
528
+ // Existing static hold on an otherwise animated element merge the props
529
+ // into the same write, then auto-keyframe it against the sibling tween.
530
+ if (anim && isInstantHold(anim)) {
531
+ await commitSetProps(
482
532
  selection,
533
+ anim,
483
534
  propEntries,
484
535
  selector,
485
536
  selectedGsapAnimations,
@@ -498,7 +549,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
498
549
  // one-element form the edit is dropped rather than written onto every
499
550
  // class sibling (see writeTargetSelector).
500
551
  const newTweenTarget = writeTargetSelector(selection);
501
- if (selector && newTweenTarget) {
552
+ if (newTweenTarget) {
502
553
  const template = selectedGsapAnimations.find((a) => !!a.keyframes);
503
554
  const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
504
555
  const tDur = template ? resolveTweenDuration(template) || 1 : 1;
@@ -528,9 +579,10 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
528
579
  );
529
580
  return;
530
581
  }
582
+ throw new GsapEditBlockedError("no-selector");
583
+ } catch (error) {
531
584
  bumpGsapCache();
532
- } catch {
533
- bumpGsapCache();
585
+ throw error;
534
586
  }
535
587
  },
536
588
  [selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache],