@hyperframes/studio 0.6.110 → 0.6.111

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 (58) hide show
  1. package/dist/assets/index-B7S86vK1.js +370 -0
  2. package/dist/assets/index-DP8pPIk2.css +1 -0
  3. package/dist/assets/{index-CfiyaR7G.js → index-_IV-vm9l.js} +1 -1
  4. package/dist/assets/{index-CXy_mWnP.js → index-x0c2-zQN.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +8 -5
  7. package/src/App.tsx +133 -141
  8. package/src/components/StudioHeader.tsx +44 -0
  9. package/src/components/StudioOverlays.tsx +70 -0
  10. package/src/components/editor/SourceEditor.tsx +19 -16
  11. package/src/components/editor/manualEditingAvailability.ts +25 -7
  12. package/src/components/storyboard/FramePoster.tsx +56 -0
  13. package/src/components/storyboard/StoryboardDirection.tsx +45 -0
  14. package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
  15. package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
  16. package/src/components/storyboard/StoryboardGrid.tsx +33 -0
  17. package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
  18. package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
  19. package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
  20. package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
  21. package/src/components/storyboard/StoryboardView.tsx +78 -0
  22. package/src/components/storyboard/frameStatus.ts +36 -0
  23. package/src/contexts/ViewModeContext.tsx +98 -0
  24. package/src/hooks/gsapScriptCommitTypes.ts +4 -7
  25. package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
  26. package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
  27. package/src/hooks/timelineEditingHelpers.ts +2 -0
  28. package/src/hooks/useAppHotkeys.ts +20 -0
  29. package/src/hooks/useDomEditCommits.ts +43 -14
  30. package/src/hooks/useDomEditSession.test.ts +41 -0
  31. package/src/hooks/useDomEditSession.ts +38 -6
  32. package/src/hooks/useDomGeometryCommits.ts +5 -9
  33. package/src/hooks/useElementLifecycleOps.ts +30 -0
  34. package/src/hooks/useGsapAnimationOps.ts +78 -51
  35. package/src/hooks/useGsapKeyframeOps.ts +127 -43
  36. package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
  37. package/src/hooks/useGsapPropertyDebounce.ts +201 -23
  38. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
  39. package/src/hooks/useGsapScriptCommits.ts +95 -40
  40. package/src/hooks/useSdkSession.test.ts +12 -0
  41. package/src/hooks/useSdkSession.ts +91 -25
  42. package/src/hooks/useStoryboard.ts +80 -0
  43. package/src/hooks/useTimelineEditing.ts +163 -68
  44. package/src/utils/gsapSoftReload.ts +14 -0
  45. package/src/utils/sdkCutover.gate.test.ts +61 -0
  46. package/src/utils/sdkCutover.test.ts +839 -0
  47. package/src/utils/sdkCutover.ts +465 -0
  48. package/src/utils/sdkOpMapping.ts +43 -0
  49. package/src/utils/sdkResolverShadow.test.ts +366 -0
  50. package/src/utils/sdkResolverShadow.ts +313 -0
  51. package/dist/assets/index-1C8oFiPi.css +0 -1
  52. package/dist/assets/index-D-3sGz65.js +0 -296
  53. package/src/utils/sdkShadow.test.ts +0 -606
  54. package/src/utils/sdkShadow.ts +0 -517
  55. package/src/utils/sdkShadowGsapFidelity.ts +0 -296
  56. package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
  57. package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
  58. package/src/utils/sdkShadowNumeric.ts +0 -11
@@ -26,6 +26,10 @@ interface UseElementLifecycleOpsParams {
26
26
  projectIdRef: React.MutableRefObject<string | null>;
27
27
  reloadPreview: () => void;
28
28
  clearDomSelection: () => void;
29
+ /** Route delete through SDK when session resolves the hf-id; returns true if handled. */
30
+ onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
31
+ /** Resync the SDK session after a server-fallback delete. */
32
+ forceReloadSdkSession?: () => void;
29
33
  commitPositionPatchToHtml: (
30
34
  selection: DomEditSelection,
31
35
  patches: PatchOperation[],
@@ -44,6 +48,8 @@ export function useElementLifecycleOps({
44
48
  projectIdRef,
45
49
  reloadPreview,
46
50
  clearDomSelection,
51
+ onTrySdkDelete,
52
+ forceReloadSdkSession,
47
53
  commitPositionPatchToHtml,
48
54
  onElementDeleted,
49
55
  }: UseElementLifecycleOpsParams) {
@@ -74,6 +80,16 @@ export function useElementLifecycleOps({
74
80
  throw new Error("Selected element has no patchable target");
75
81
  }
76
82
 
83
+ if (onTrySdkDelete && selection.hfId) {
84
+ const handled = await onTrySdkDelete(selection.hfId, originalContent, targetPath);
85
+ if (handled) {
86
+ clearDomSelection();
87
+ usePlayerStore.getState().setSelectedElementId(null);
88
+ showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
89
+ return;
90
+ }
91
+ }
92
+
77
93
  domEditSaveTimestampRef.current = Date.now();
78
94
  const removeResponse = await fetch(
79
95
  `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
@@ -93,6 +109,12 @@ export function useElementLifecycleOps({
93
109
  const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
94
110
  const patchedContent =
95
111
  typeof removeData.content === "string" ? removeData.content : originalContent;
112
+ // ponytail: the server remove-element route (removeElementFromHtml) strips
113
+ // only the element node — it does NOT cascade-remove GSAP tweens targeting
114
+ // it, unlike the SDK path (removeElement → cascadeRemoveAnimations). This
115
+ // fallback runs only when the element isn't in the SDK doc (e.g. runtime-
116
+ // generated / unaddressable), where targeting tweens are unlikely. Upgrade
117
+ // path: cascade in removeElementFromHtml by selector/hf-id to fully match.
96
118
  await saveProjectFilesWithHistory({
97
119
  projectId: pid,
98
120
  label: "Delete element",
@@ -105,6 +127,9 @@ export function useElementLifecycleOps({
105
127
 
106
128
  clearDomSelection();
107
129
  usePlayerStore.getState().setSelectedElementId(null);
130
+ // Server wrote the file; resync the stale in-memory SDK doc so a later
131
+ // SDK edit doesn't resurrect the deleted element.
132
+ forceReloadSdkSession?.();
108
133
  reloadPreview();
109
134
  onElementDeleted?.(selection);
110
135
  showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
@@ -118,7 +143,9 @@ export function useElementLifecycleOps({
118
143
  clearDomSelection,
119
144
  domEditSaveTimestampRef,
120
145
  editHistory.recordEdit,
146
+ onTrySdkDelete,
121
147
  onElementDeleted,
148
+ forceReloadSdkSession,
122
149
  projectIdRef,
123
150
  reloadPreview,
124
151
  showToast,
@@ -126,6 +153,9 @@ export function useElementLifecycleOps({
126
153
  ],
127
154
  );
128
155
 
156
+ // ponytail: z-index reorder writes inline-style patches via commitPositionPatchToHtml →
157
+ // persistDomEditOperations → onTrySdkPersist, so it is already SDK-cut-over as setStyle.
158
+ // No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed.
129
159
  const handleDomZIndexReorderCommit = useCallback(
130
160
  (
131
161
  entries: Array<{
@@ -2,21 +2,28 @@ import { useCallback } from "react";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import { roundTo3 } from "../utils/rounding";
5
- import { runShadowGsapTween, type ShadowGsapOp } from "../utils/sdkShadow";
5
+ import {
6
+ sdkGsapTweenPersist,
7
+ sdkGsapDeleteAllForSelectorPersist,
8
+ type CutoverDeps,
9
+ } from "../utils/sdkCutover";
6
10
  import {
7
11
  assignGsapTargetAutoIdIfNeeded,
8
12
  ensureElementAddressable,
9
13
  } from "./gsapScriptCommitHelpers";
10
14
  import type { CommitMutation, SafeGsapCommitMutation } from "./gsapScriptCommitTypes";
11
15
 
12
- interface GsapAnimationOpsParams {
16
+ interface SdkAnimationDeps {
17
+ sdkSession?: Composition | null;
18
+ sdkDeps?: CutoverDeps | null;
19
+ }
20
+
21
+ interface GsapAnimationOpsParams extends SdkAnimationDeps {
13
22
  projectIdRef: React.MutableRefObject<string | null>;
14
23
  activeCompPath: string | null;
15
24
  commitMutation: CommitMutation;
16
25
  commitMutationSafely: SafeGsapCommitMutation;
17
26
  showToast: (message: string, tone?: "error" | "info") => void;
18
- /** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */
19
- sdkSession?: Composition | null;
20
27
  }
21
28
 
22
29
  export function useGsapAnimationOps({
@@ -26,60 +33,79 @@ export function useGsapAnimationOps({
26
33
  commitMutationSafely,
27
34
  showToast,
28
35
  sdkSession,
36
+ sdkDeps,
29
37
  }: GsapAnimationOpsParams) {
30
38
  const updateGsapMeta = useCallback(
31
- (
39
+ async (
32
40
  selection: DomEditSelection,
33
41
  animationId: string,
34
42
  updates: { duration?: number; ease?: string; position?: number },
35
43
  ) => {
36
- // Shadow op (server animationId shares the SDK id-space): existence via
37
- // runShadowGsapTween (live session) + value fidelity via the chokepoint.
38
- const shadowGsapOp: ShadowGsapOp = {
39
- kind: "set",
40
- animationId,
41
- properties: { duration: updates.duration, ease: updates.ease, position: updates.position },
42
- };
43
- // coalesceKey groups rapid meta edits into one history entry. Request
44
- // serialization is now handled per-file at the commitMutation chokepoint
45
- // (useGsapScriptCommits), so no per-op serializeKey is needed here.
46
- const metaKey = `gsap:${animationId}:meta`;
44
+ if (sdkSession && sdkDeps) {
45
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
46
+ const handled = await sdkGsapTweenPersist(
47
+ targetPath,
48
+ { kind: "set", animationId, properties: updates },
49
+ sdkSession,
50
+ sdkDeps,
51
+ { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` },
52
+ );
53
+ if (handled) return;
54
+ }
47
55
  commitMutationSafely(
48
56
  selection,
49
57
  { type: "update-meta", animationId, updates },
50
- { label: "Edit GSAP animation", coalesceKey: metaKey, shadowGsapOp },
58
+ { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` },
51
59
  );
52
- if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
53
60
  },
54
- [commitMutationSafely, sdkSession],
61
+ [commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
55
62
  );
56
63
 
57
64
  const deleteGsapAnimation = useCallback(
58
- (selection: DomEditSelection, animationId: string) => {
59
- const shadowGsapOp: ShadowGsapOp = { kind: "remove", animationId };
65
+ async (selection: DomEditSelection, animationId: string) => {
66
+ if (sdkSession && sdkDeps) {
67
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
68
+ const handled = await sdkGsapTweenPersist(
69
+ targetPath,
70
+ { kind: "remove", animationId },
71
+ sdkSession,
72
+ sdkDeps,
73
+ { label: "Delete GSAP animation" },
74
+ );
75
+ if (handled) return;
76
+ }
60
77
  commitMutationSafely(
61
78
  selection,
62
79
  { type: "delete", animationId, stripStudioEdits: true },
63
- { label: "Delete GSAP animation", shadowGsapOp },
80
+ { label: "Delete GSAP animation" },
64
81
  );
65
- if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
66
82
  },
67
- [commitMutationSafely, sdkSession],
83
+ [commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
68
84
  );
69
85
 
70
86
  const deleteAllForSelector = useCallback(
71
- (selection: DomEditSelection, targetSelector: string) => {
87
+ async (selection: DomEditSelection, targetSelector: string) => {
88
+ if (sdkSession && sdkDeps) {
89
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
90
+ const handled = await sdkGsapDeleteAllForSelectorPersist(
91
+ targetPath,
92
+ targetSelector,
93
+ sdkSession,
94
+ sdkDeps,
95
+ { label: "Delete all animations for element" },
96
+ );
97
+ if (handled) return;
98
+ }
72
99
  void commitMutation(
73
100
  selection,
74
101
  { type: "delete-all-for-selector", targetSelector },
75
102
  { label: "Delete all animations for element" },
76
103
  );
77
104
  },
78
- [commitMutation],
105
+ [commitMutation, activeCompPath, sdkSession, sdkDeps],
79
106
  );
80
107
 
81
- // Pre-existing complexity (auto-id assignment + per-method defaults); this PR
82
- // adds only a guarded shadow-op construction at the tail.
108
+ // fallow-ignore-next-line complexity
83
109
  const addGsapAnimation = useCallback(
84
110
  // fallow-ignore-next-line complexity
85
111
  async (
@@ -114,25 +140,28 @@ export function useGsapAnimationOps({
114
140
  fromTo: { x: 0, y: 0, opacity: 1 },
115
141
  };
116
142
 
117
- // Shadow op (server stays authoritative). "set" has no SDK method, so it
118
- // is not shadowed; otherwise: existence via runShadowGsapTween (live) +
119
- // value fidelity via the chokepoint (shadowGsapOp in options).
120
- const shadowGsapOp: ShadowGsapOp | undefined =
121
- selection.hfId && method !== "set"
122
- ? {
123
- kind: "add",
124
- target: selection.hfId,
125
- tween: {
126
- method,
127
- position,
128
- duration,
129
- ease: "power2.out",
130
- ...(method === "fromTo"
131
- ? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] }
132
- : { properties: toDefaults[method] ?? { opacity: 1 } }),
133
- },
134
- }
135
- : undefined;
143
+ // Skip SDK path when an id was just assigned server-side (autoId): the
144
+ // SDK session hasn't reloaded that write yet, so persisting its
145
+ // serialization would clobber the new id let the server add the tween
146
+ // atomically with the id it wrote.
147
+ if (!autoId && selection.hfId && sdkSession && sdkDeps) {
148
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
149
+ const spec = {
150
+ method,
151
+ position,
152
+ ...(method !== "set" ? { duration, ease: "power2.out" as const } : {}),
153
+ properties: toDefaults[method] ?? { opacity: 1 },
154
+ ...(method === "fromTo" ? { fromProperties: { opacity: 0 } } : {}),
155
+ };
156
+ const handled = await sdkGsapTweenPersist(
157
+ targetPath,
158
+ { kind: "add", target: selection.hfId, spec },
159
+ sdkSession,
160
+ sdkDeps,
161
+ { label: `Add GSAP ${method} animation` },
162
+ );
163
+ if (handled) return;
164
+ }
136
165
 
137
166
  await commitMutation(
138
167
  selection,
@@ -146,12 +175,10 @@ export function useGsapAnimationOps({
146
175
  properties: toDefaults[method] ?? { opacity: 1 },
147
176
  fromProperties: method === "fromTo" ? { opacity: 0 } : undefined,
148
177
  },
149
- { label: `Add GSAP ${method} animation`, shadowGsapOp },
178
+ { label: `Add GSAP ${method} animation` },
150
179
  );
151
-
152
- if (sdkSession && shadowGsapOp) runShadowGsapTween(sdkSession, shadowGsapOp);
153
180
  },
154
- [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession],
181
+ [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession, sdkDeps],
155
182
  );
156
183
 
157
184
  return {
@@ -1,8 +1,15 @@
1
1
  import { useCallback } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
- import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
3
+ import type { Composition } from "@hyperframes/sdk";
4
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
5
  import { executeOptimistic } from "../utils/optimisticUpdate";
6
+ import {
7
+ sdkGsapKeyframePersist,
8
+ sdkGsapRemoveKeyframePersist,
9
+ sdkGsapRemoveAllKeyframesPersist,
10
+ sdkGsapConvertToKeyframesPersist,
11
+ type CutoverDeps,
12
+ } from "../utils/sdkCutover";
6
13
  import type { KeyframeCacheEntry } from "../player/store/playerStore";
7
14
  import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
8
15
  import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers";
@@ -31,7 +38,12 @@ function executeOptimisticKeyframeCacheUpdate(options: {
31
38
  });
32
39
  }
33
40
 
34
- interface GsapKeyframeOpsParams {
41
+ interface SdkKeyframeDeps {
42
+ sdkSession?: Composition | null;
43
+ sdkDeps?: CutoverDeps | null;
44
+ }
45
+
46
+ interface GsapKeyframeOpsParams extends SdkKeyframeDeps {
35
47
  activeCompPath: string | null;
36
48
  commitMutation: CommitMutation;
37
49
  commitMutationSafely: SafeGsapCommitMutation;
@@ -43,6 +55,8 @@ export function useGsapKeyframeOps({
43
55
  commitMutation,
44
56
  commitMutationSafely,
45
57
  trackGsapSaveFailure,
58
+ sdkSession,
59
+ sdkDeps,
46
60
  }: GsapKeyframeOpsParams) {
47
61
  const addKeyframe = useCallback(
48
62
  (
@@ -59,111 +73,181 @@ export function useGsapKeyframeOps({
59
73
  percentage,
60
74
  properties: { [property]: value },
61
75
  };
62
- // Shadow op (gsap_keyframe): SDK equivalent diffed via the commit chokepoint.
63
- const shadowKeyframeOp: ShadowKeyframeOp = {
64
- kind: "add",
65
- animationId,
66
- percentage,
67
- properties: { [property]: value },
68
- };
69
76
  void executeOptimisticKeyframeCacheUpdate({
70
77
  sourceFile,
71
78
  elementId: selection.id,
72
- apply: (prev) => ({
73
- ...prev,
74
- keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort(
75
- (a, b) => a.percentage - b.percentage,
76
- ),
77
- }),
78
- persist: () =>
79
- commitMutation(selection, mutation, {
79
+ // Merge into an existing keyframe at this percentage rather than
80
+ // appending a duplicate — matches addKeyframeToScript, which writes one
81
+ // keyframe per percentage (merging properties).
82
+ apply: (prev) => {
83
+ // Match addKeyframeToScript's merge tolerance (PCT_TOLERANCE = 2 in
84
+ // gsapWriterAcorn): a keyframe added within 2% of an existing one
85
+ // merges on disk, so the optimistic cache must merge it too — else the
86
+ // UI shows a phantom keyframe that vanishes on the next reload.
87
+ const idx = prev.keyframes.findIndex(
88
+ (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) <= 2,
89
+ );
90
+ if (idx >= 0) {
91
+ const keyframes = prev.keyframes.slice();
92
+ keyframes[idx] = {
93
+ ...keyframes[idx],
94
+ properties: { ...keyframes[idx].properties, [property]: value },
95
+ };
96
+ return { ...prev, keyframes };
97
+ }
98
+ return {
99
+ ...prev,
100
+ keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort(
101
+ (a, b) => a.percentage - b.percentage,
102
+ ),
103
+ };
104
+ },
105
+ persist: async () => {
106
+ if (sdkSession && sdkDeps) {
107
+ const handled = await sdkGsapKeyframePersist(
108
+ sourceFile,
109
+ animationId,
110
+ percentage,
111
+ { [property]: value },
112
+ sdkSession,
113
+ sdkDeps,
114
+ {
115
+ label: `Add keyframe at ${percentage}%`,
116
+ coalesceKey: `gsap:${animationId}:kf:${percentage}`,
117
+ },
118
+ );
119
+ if (handled) return;
120
+ }
121
+ await commitMutation(selection, mutation, {
80
122
  label: `Add keyframe at ${percentage}%`,
81
123
  softReload: true,
82
- shadowKeyframeOp,
83
- }),
124
+ });
125
+ },
84
126
  }).catch((error) => {
85
127
  trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
86
128
  });
87
129
  },
88
- [activeCompPath, commitMutation, trackGsapSaveFailure],
130
+ [activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps],
89
131
  );
90
132
 
91
133
  const addKeyframeBatch = useCallback(
92
- (
134
+ async (
93
135
  selection: DomEditSelection,
94
136
  animationId: string,
95
137
  percentage: number,
96
138
  properties: Record<string, number | string>,
97
139
  ) => {
98
- const shadowKeyframeOp: ShadowKeyframeOp = {
99
- kind: "add",
100
- animationId,
101
- percentage,
102
- properties,
103
- };
140
+ if (sdkSession && sdkDeps) {
141
+ const sourceFile = selection.sourceFile || activeCompPath || "index.html";
142
+ const handled = await sdkGsapKeyframePersist(
143
+ sourceFile,
144
+ animationId,
145
+ percentage,
146
+ properties,
147
+ sdkSession,
148
+ sdkDeps,
149
+ { label: `Add keyframe at ${percentage}%` },
150
+ );
151
+ if (handled) return;
152
+ }
104
153
  return commitMutation(
105
154
  selection,
106
155
  { type: "add-keyframe", animationId, percentage, properties },
107
- { label: `Add keyframe at ${percentage}%`, softReload: true, shadowKeyframeOp },
156
+ { label: `Add keyframe at ${percentage}%`, softReload: true },
108
157
  );
109
158
  },
110
- [commitMutation],
159
+ [commitMutation, activeCompPath, sdkSession, sdkDeps],
111
160
  );
112
161
 
113
162
  const removeKeyframe = useCallback(
114
163
  (selection: DomEditSelection, animationId: string, percentage: number) => {
115
164
  const sourceFile = selection.sourceFile || activeCompPath || "index.html";
116
165
  const mutation = { type: "remove-keyframe", animationId, percentage };
117
- // Shadow op (gsap_keyframe): SDK has no %-based removeGsapKeyframe on main,
118
- // so the runner resolves percentage → keyframeIndex against the pre-op
119
- // script and no-ops on ambiguity (duplicate-percentage keyframes).
120
- const shadowKeyframeOp: ShadowKeyframeOp = { kind: "remove", animationId, percentage };
121
166
  void executeOptimisticKeyframeCacheUpdate({
122
167
  sourceFile,
123
168
  elementId: selection.id,
124
169
  apply: (prev) => ({
125
170
  ...prev,
171
+ // Match the writer's removal tolerance (PCT_TOLERANCE = 2 in
172
+ // gsapWriterAcorn): removing at e.g. 49% drops a keyframe at 50% on
173
+ // disk, so the optimistic cache must drop it too — else the stranded
174
+ // entry is a phantom that vanishes on the next reload (mirror of the
175
+ // add-path tolerance fix).
126
176
  keyframes: prev.keyframes.filter(
127
- (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2,
177
+ (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 2,
128
178
  ),
129
179
  }),
130
- persist: () =>
131
- commitMutation(selection, mutation, {
180
+ persist: async () => {
181
+ if (sdkSession && sdkDeps) {
182
+ const handled = await sdkGsapRemoveKeyframePersist(
183
+ sourceFile,
184
+ animationId,
185
+ percentage,
186
+ sdkSession,
187
+ sdkDeps,
188
+ { label: `Remove keyframe at ${percentage}%` },
189
+ );
190
+ if (handled) return;
191
+ }
192
+ await commitMutation(selection, mutation, {
132
193
  label: `Remove keyframe at ${percentage}%`,
133
194
  softReload: true,
134
- shadowKeyframeOp,
135
- }),
195
+ });
196
+ },
136
197
  }).catch((error) => {
137
198
  trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
138
199
  });
139
200
  },
140
- [activeCompPath, commitMutation, trackGsapSaveFailure],
201
+ [activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps],
141
202
  );
142
203
 
143
204
  const convertToKeyframes = useCallback(
144
- (
205
+ async (
145
206
  selection: DomEditSelection,
146
207
  animationId: string,
147
208
  resolvedFromValues?: Record<string, number | string>,
148
209
  ) => {
210
+ if (sdkSession && sdkDeps) {
211
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
212
+ const handled = await sdkGsapConvertToKeyframesPersist(
213
+ targetPath,
214
+ animationId,
215
+ resolvedFromValues,
216
+ sdkSession,
217
+ sdkDeps,
218
+ { label: "Convert to keyframes" },
219
+ );
220
+ if (handled) return;
221
+ }
149
222
  return commitMutation(
150
223
  selection,
151
224
  { type: "convert-to-keyframes", animationId, resolvedFromValues },
152
225
  { label: "Convert to keyframes" },
153
226
  );
154
227
  },
155
- [commitMutation],
228
+ [commitMutation, activeCompPath, sdkSession, sdkDeps],
156
229
  );
157
230
 
158
231
  const removeAllKeyframes = useCallback(
159
- (selection: DomEditSelection, animationId: string) => {
232
+ async (selection: DomEditSelection, animationId: string) => {
233
+ if (sdkSession && sdkDeps) {
234
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
235
+ const handled = await sdkGsapRemoveAllKeyframesPersist(
236
+ targetPath,
237
+ animationId,
238
+ sdkSession,
239
+ sdkDeps,
240
+ { label: "Remove all keyframes" },
241
+ );
242
+ if (handled) return;
243
+ }
160
244
  commitMutationSafely(
161
245
  selection,
162
246
  { type: "remove-all-keyframes", animationId },
163
247
  { label: "Remove all keyframes", softReload: true },
164
248
  );
165
249
  },
166
- [commitMutationSafely],
250
+ [commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
167
251
  );
168
252
 
169
253
  const commitKeyframeAtTime = useCallback(
@@ -0,0 +1,70 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect } from "vitest";
3
+ import { openComposition } from "@hyperframes/sdk";
4
+ import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
5
+ import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
6
+ import { mergeTweenProperties } from "./useGsapPropertyDebounce";
7
+ import { extractGsapScriptText } from "../utils/gsapSoftReload";
8
+
9
+ const HTML = `<!DOCTYPE html><html><head></head><body>
10
+ <div id="box" data-hf-id="hf-box" style="opacity:1"></div>
11
+ <script data-hf-gsap>
12
+ const tl = gsap.timeline({ paused: true });
13
+ window.__timelines = { main: tl };
14
+ tl.to('#box', { duration: 1, x: 100, y: 50, opacity: 1 });
15
+ </script>
16
+ </body></html>`;
17
+
18
+ const FROMTO_HTML = `<!DOCTYPE html><html><head></head><body>
19
+ <div id="box" data-hf-id="hf-box" style="opacity:1"></div>
20
+ <script data-hf-gsap>
21
+ const tl = gsap.timeline({ paused: true });
22
+ window.__timelines = { main: tl };
23
+ tl.fromTo('#box', { x: 0, y: 0 }, { duration: 1, x: 100, y: 50 });
24
+ </script>
25
+ </body></html>`;
26
+
27
+ function tweenProps(comp: { serialize(): string }) {
28
+ const parsed = parseGsapScriptAcorn(extractGsapScriptText(comp.serialize()) ?? "");
29
+ const anim = parsed.animations[0];
30
+ return { id: anim?.id, properties: anim?.properties, fromProperties: anim?.fromProperties };
31
+ }
32
+
33
+ describe("setGsapTween replace semantics (finding #1)", () => {
34
+ it("REGRESSION: a single-key set drops the tween's other animated props", async () => {
35
+ // This documents the bug the merge fixes: setGsapTween REPLACES the property
36
+ // set, so sending only the edited key loses the siblings.
37
+ const comp = await openComposition(HTML, { persist: createMemoryAdapter() });
38
+ const id = tweenProps(comp).id ?? "";
39
+ comp.setGsapTween(id, { properties: { x: 200 } });
40
+ const after = tweenProps(comp);
41
+ expect(after.properties).toEqual({ x: 200 });
42
+ expect(after.properties).not.toHaveProperty("y");
43
+ expect(after.properties).not.toHaveProperty("opacity");
44
+ });
45
+ });
46
+
47
+ describe("mergeTweenProperties (finding #1)", () => {
48
+ it("editing x preserves y and opacity through a real SDK write", async () => {
49
+ const comp = await openComposition(HTML, { persist: createMemoryAdapter() });
50
+ const id = tweenProps(comp).id ?? "";
51
+ // Mirror the send site: merge the single edited prop into the existing set.
52
+ const merged = mergeTweenProperties(comp, id, { x: 200 }, "to");
53
+ expect(merged).toEqual({ x: 200, y: 50, opacity: 1 });
54
+ comp.setGsapTween(id, { properties: merged });
55
+ const after = tweenProps(comp);
56
+ expect(after.properties).toMatchObject({ x: 200, y: 50, opacity: 1 });
57
+ });
58
+
59
+ it("editing a from-property preserves the other from-properties", async () => {
60
+ const comp = await openComposition(FROMTO_HTML, { persist: createMemoryAdapter() });
61
+ const id = tweenProps(comp).id ?? "";
62
+ const merged = mergeTweenProperties(comp, id, { x: 25 }, "from");
63
+ expect(merged).toEqual({ x: 25, y: 0 });
64
+ });
65
+
66
+ it("returns the single edit unchanged when the tween id is unknown", async () => {
67
+ const comp = await openComposition(HTML, { persist: createMemoryAdapter() });
68
+ expect(mergeTweenProperties(comp, "no-such-id", { x: 5 }, "to")).toEqual({ x: 5 });
69
+ });
70
+ });