@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
@@ -0,0 +1,98 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react";
10
+
11
+ /**
12
+ * Top-level Studio view mode.
13
+ *
14
+ * `timeline` is the existing NLE/preview stage. `storyboard` replaces that stage
15
+ * with the storyboard contact sheet. The mode is mirrored to the `?view=` query
16
+ * param so it survives reloads and — importantly — so an agent can deep-link the
17
+ * user straight into the storyboard by navigating the tab to `?view=storyboard`.
18
+ */
19
+ export type StudioViewMode = "timeline" | "storyboard";
20
+
21
+ const VIEW_QUERY_PARAM = "view";
22
+
23
+ function readViewModeFromUrl(): StudioViewMode {
24
+ if (typeof window === "undefined") return "timeline";
25
+ return new URLSearchParams(window.location.search).get(VIEW_QUERY_PARAM) === "storyboard"
26
+ ? "storyboard"
27
+ : "timeline";
28
+ }
29
+
30
+ function writeViewModeToUrl(mode: StudioViewMode): void {
31
+ if (typeof window === "undefined") return;
32
+ const url = new URL(window.location.href);
33
+ if (mode === "storyboard") {
34
+ url.searchParams.set(VIEW_QUERY_PARAM, "storyboard");
35
+ } else {
36
+ url.searchParams.delete(VIEW_QUERY_PARAM);
37
+ }
38
+ window.history.replaceState(window.history.state, "", url);
39
+ }
40
+
41
+ export interface ViewModeValue {
42
+ viewMode: StudioViewMode;
43
+ setViewMode: (mode: StudioViewMode) => void;
44
+ }
45
+
46
+ /**
47
+ * Owns the view-mode state. When `enabled` is false (storyboard flag off) the
48
+ * mode is pinned to `timeline` and the URL is left untouched, so the feature is
49
+ * fully inert until the flag is on.
50
+ */
51
+ export function useViewModeState(enabled: boolean): ViewModeValue {
52
+ const [viewMode, setMode] = useState<StudioViewMode>(() =>
53
+ enabled ? readViewModeFromUrl() : "timeline",
54
+ );
55
+
56
+ // Reflect genuine browser back/forward between history entries with a different
57
+ // `?view=`. Note: our own writes use `replaceState` (below), which does NOT fire
58
+ // `popstate`, so this listener never sees them — `setViewMode` updates state directly.
59
+ // An agent deep-links by doing a full navigation to `?view=storyboard` (picked up by
60
+ // the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
61
+ // reflected here, by design.
62
+ useEffect(() => {
63
+ if (!enabled) return;
64
+ const onPopState = () => setMode(readViewModeFromUrl());
65
+ window.addEventListener("popstate", onPopState);
66
+ return () => window.removeEventListener("popstate", onPopState);
67
+ }, [enabled]);
68
+
69
+ const setViewMode = useCallback(
70
+ (mode: StudioViewMode) => {
71
+ if (!enabled) return;
72
+ setMode(mode);
73
+ writeViewModeToUrl(mode);
74
+ },
75
+ [enabled],
76
+ );
77
+
78
+ const effectiveMode = enabled ? viewMode : "timeline";
79
+ return useMemo(() => ({ viewMode: effectiveMode, setViewMode }), [effectiveMode, setViewMode]);
80
+ }
81
+
82
+ const ViewModeContext = createContext<ViewModeValue | null>(null);
83
+
84
+ export function useViewMode(): ViewModeValue {
85
+ const ctx = useContext(ViewModeContext);
86
+ if (!ctx) throw new Error("useViewMode must be used within ViewModeProvider");
87
+ return ctx;
88
+ }
89
+
90
+ export function ViewModeProvider({
91
+ value,
92
+ children,
93
+ }: {
94
+ value: ViewModeValue;
95
+ children: ReactNode;
96
+ }) {
97
+ return <ViewModeContext value={value}>{children}</ViewModeContext>;
98
+ }
@@ -2,8 +2,6 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
5
- import type { ShadowGsapOp } from "../utils/sdkShadow";
6
- import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
7
5
 
8
6
  export interface MutationResult {
9
7
  ok: boolean;
@@ -28,10 +26,6 @@ export interface CommitMutationOptions {
28
26
  * (and under distinct keys) run concurrently as before.
29
27
  */
30
28
  serializeKey?: string;
31
- /** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */
32
- shadowGsapOp?: ShadowGsapOp;
33
- /** Typed SDK equivalent of a keyframe mutation for keyframe value-fidelity shadow (gsap_keyframe). */
34
- shadowKeyframeOp?: ShadowKeyframeOp;
35
29
  }
36
30
 
37
31
  export type CommitMutation = (
@@ -70,6 +64,9 @@ export interface GsapScriptCommitsParams {
70
64
  onCacheInvalidate: () => void;
71
65
  onFileContentChanged?: (path: string, content: string) => void;
72
66
  showToast: (message: string, tone?: "error" | "info") => void;
73
- /** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */
67
+ /** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */
74
68
  sdkSession?: Composition | null;
69
+ writeProjectFile?: (path: string, content: string) => Promise<void>;
70
+ /** Resync the in-memory SDK session after a server-authoritative write. */
71
+ forceReloadSdkSession?: () => void;
75
72
  }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import {
3
+ hashContent,
4
+ markSelfWrite,
5
+ isSelfWriteEcho,
6
+ resetSelfWriteRegistry,
7
+ } from "./sdkSelfWriteRegistry";
8
+ import { shouldReloadOnFileChange } from "./useSdkSession";
9
+
10
+ describe("sdkSelfWriteRegistry (finding #14)", () => {
11
+ beforeEach(() => resetSelfWriteRegistry());
12
+
13
+ it("recognizes the echo of bytes we just wrote", () => {
14
+ markSelfWrite("/comp.html", "<html>A</html>");
15
+ expect(isSelfWriteEcho("/comp.html", "<html>A</html>")).toBe(true);
16
+ });
17
+
18
+ it("does NOT match different content on the same path (an undo's reverted bytes)", () => {
19
+ markSelfWrite("/comp.html", "<html>A</html>");
20
+ expect(isSelfWriteEcho("/comp.html", "<html>REVERTED</html>")).toBe(false);
21
+ });
22
+
23
+ it("is keyed per file — a self-write to one file can't mask a change to another", () => {
24
+ markSelfWrite("/a.html", "<html>A</html>");
25
+ expect(isSelfWriteEcho("/b.html", "<html>A</html>")).toBe(false);
26
+ });
27
+
28
+ it("consumes a matched entry so a later genuine external write isn't suppressed", () => {
29
+ markSelfWrite("/comp.html", "<html>A</html>");
30
+ expect(isSelfWriteEcho("/comp.html", "<html>A</html>")).toBe(true);
31
+ // A second arrival of identical bytes is NOT our echo — must reload.
32
+ expect(isSelfWriteEcho("/comp.html", "<html>A</html>")).toBe(false);
33
+ });
34
+
35
+ it("expires entries past the TTL so a stale self-write can't suppress forever", () => {
36
+ const t0 = 1_000_000;
37
+ markSelfWrite("/comp.html", "<html>A</html>", t0);
38
+ // 3 s later (> 2 s TTL) the entry is gone.
39
+ expect(isSelfWriteEcho("/comp.html", "<html>A</html>", t0 + 3000)).toBe(false);
40
+ });
41
+
42
+ it("hashes are stable and distinguish different content", () => {
43
+ expect(hashContent("x")).toBe(hashContent("x"));
44
+ expect(hashContent("x")).not.toBe(hashContent("y"));
45
+ });
46
+ });
47
+
48
+ describe("shouldReloadOnFileChange (finding #14)", () => {
49
+ beforeEach(() => resetSelfWriteRegistry());
50
+
51
+ it("suppresses the reload when content matches a registered self-write (cutover echo)", () => {
52
+ markSelfWrite("/comp.html", "<html>SELF</html>");
53
+ expect(shouldReloadOnFileChange("/comp.html", "<html>SELF</html>", true)).toBe(false);
54
+ });
55
+
56
+ it("reloads on an undo write even inside the suppress window (content differs)", () => {
57
+ // The cutover registered SELF; the undo writes REVERTED bytes within the
58
+ // same 2 s window. Time-only suppression dropped this; identity reloads it.
59
+ markSelfWrite("/comp.html", "<html>SELF</html>");
60
+ expect(shouldReloadOnFileChange("/comp.html", "<html>REVERTED</html>", true)).toBe(true);
61
+ });
62
+
63
+ it("falls back to the time window only when content is unavailable", () => {
64
+ expect(shouldReloadOnFileChange("/comp.html", null, true)).toBe(false);
65
+ expect(shouldReloadOnFileChange("/comp.html", null, false)).toBe(true);
66
+ });
67
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Self-write identity registry — discriminates an SDK cutover ECHO from a genuine
3
+ * external write (notably undo/redo) in the file-change reload-suppression path.
4
+ *
5
+ * The old suppression was purely time-based: any file-change within 2 s of the
6
+ * shared `domEditSaveTimestampRef` was swallowed. But BOTH an SDK cutover
7
+ * self-write AND an undo write set that same timestamp, so the window could not
8
+ * tell "the echo of the bytes I just wrote" (suppress) from "the reverted bytes
9
+ * an undo just wrote" (must reload). An undo that landed inside the window was
10
+ * silently dropped, leaving the in-memory SDK doc on stale pre-undo content.
11
+ *
12
+ * Fix: tag each cutover self-write with the CONTENT it wrote (by hash). A
13
+ * file-change reload is suppressed only when the new on-disk content matches a
14
+ * recently-registered self-write hash — i.e. it is provably our own echo. Undo
15
+ * writes are never registered (they don't flow through persistSdkSerialize), so
16
+ * their content won't match and the reload always fires. Identity, not a clock.
17
+ */
18
+
19
+ const SELF_WRITE_TTL_MS = 2000;
20
+
21
+ interface SelfWriteEntry {
22
+ hash: string;
23
+ at: number;
24
+ }
25
+
26
+ // Module-scoped: the studio process has a single SDK session lifecycle at a time
27
+ // and persists are funnelled through one persistSdkSerialize. Keyed by file path
28
+ // so a self-write to one file can't mask a real external change to another.
29
+ const registry = new Map<string, SelfWriteEntry[]>();
30
+
31
+ /**
32
+ * Stable 32-bit FNV-1a hash of content. Collisions only risk SUPPRESSING a real
33
+ * reload, and only within the 2 s TTL for the exact same file — negligible, and
34
+ * strictly safer than the prior time-only window it replaces.
35
+ */
36
+ export function hashContent(content: string): string {
37
+ let h = 0x811c9dc5;
38
+ for (let i = 0; i < content.length; i++) {
39
+ h ^= content.charCodeAt(i);
40
+ h = Math.imul(h, 0x01000193);
41
+ }
42
+ return (h >>> 0).toString(16);
43
+ }
44
+
45
+ function prune(entries: SelfWriteEntry[], now: number): SelfWriteEntry[] {
46
+ return entries.filter((e) => now - e.at < SELF_WRITE_TTL_MS);
47
+ }
48
+
49
+ /** Record that WE wrote `content` to `path` (an SDK cutover self-write). */
50
+ export function markSelfWrite(path: string, content: string, now: number = Date.now()): void {
51
+ const next = prune(registry.get(path) ?? [], now);
52
+ next.push({ hash: hashContent(content), at: now });
53
+ registry.set(path, next);
54
+ }
55
+
56
+ /**
57
+ * True when `content` matches a self-write registered for `path` within the TTL.
58
+ * Consumes the matched entry so a later genuinely-external write of identical
59
+ * bytes isn't suppressed forever.
60
+ */
61
+ export function isSelfWriteEcho(path: string, content: string, now: number = Date.now()): boolean {
62
+ const entries = prune(registry.get(path) ?? [], now);
63
+ const hash = hashContent(content);
64
+ const idx = entries.findIndex((e) => e.hash === hash);
65
+ if (idx === -1) {
66
+ registry.set(path, entries);
67
+ return false;
68
+ }
69
+ entries.splice(idx, 1);
70
+ registry.set(path, entries);
71
+ return true;
72
+ }
73
+
74
+ /** Test-only: drop all registered self-writes. */
75
+ export function resetSelfWriteRegistry(): void {
76
+ registry.clear();
77
+ }
@@ -97,6 +97,7 @@ export interface PersistTimelineEditInput {
97
97
  recordEdit: (input: RecordEditInput) => Promise<void>;
98
98
  domEditSaveTimestampRef: React.MutableRefObject<number>;
99
99
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
100
+ coalesceKey?: string;
100
101
  }
101
102
 
102
103
  export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
@@ -119,6 +120,7 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
119
120
  projectId: input.projectId,
120
121
  label: input.label,
121
122
  kind: "timeline",
123
+ coalesceKey: input.coalesceKey,
122
124
  files: { [targetPath]: patchedContent },
123
125
  readFile: async () => originalContent,
124
126
  writeFile: input.writeProjectFile,
@@ -117,6 +117,14 @@ interface UseAppHotkeysParams {
117
117
  onDeleteSelectedKeyframes: () => void;
118
118
  onAfterUndoRedo?: () => void;
119
119
  onToggleRecording?: () => void;
120
+ /** Active composition path — used to decide whether undo/redo must resync the SDK session. */
121
+ activeCompPath?: string | null;
122
+ /**
123
+ * Force-reload the SDK session after undo/redo reverts the active comp file,
124
+ * bypassing the self-write suppress window. Without this, the suppress window
125
+ * blocks the file-change reload and the SDK session stays on pre-undo content.
126
+ */
127
+ forceReloadSdkSession?: () => void;
120
128
  }
121
129
 
122
130
  // ── Extracted keydown dispatch (pure function, no hooks) ──
@@ -302,6 +310,8 @@ export function useAppHotkeys({
302
310
  onDeleteSelectedKeyframes,
303
311
  onAfterUndoRedo,
304
312
  onToggleRecording,
313
+ activeCompPath,
314
+ forceReloadSdkSession,
305
315
  }: UseAppHotkeysParams) {
306
316
  const previewHotkeyWindowRef = useRef<Window | null>(null);
307
317
  const previewHistoryCleanupRef = useRef<(() => void) | null>(null);
@@ -349,6 +359,14 @@ export function useAppHotkeys({
349
359
  }
350
360
  if (result.ok && result.label) {
351
361
  onAfterUndoRedo?.();
362
+ // If the active composition was among the written files, force-reload
363
+ // the SDK session so its in-memory doc matches the reverted content.
364
+ // writeHistoryFile sets domEditSaveTimestampRef which activates the
365
+ // 2 s suppress window — without this call the file-change event would
366
+ // be swallowed and the SDK session would stay on stale pre-undo content.
367
+ if (activeCompPath && result.paths?.includes(activeCompPath)) {
368
+ forceReloadSdkSession?.();
369
+ }
352
370
  await syncHistoryPreviewAfterApply(result.paths);
353
371
  showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info");
354
372
  }
@@ -361,6 +379,8 @@ export function useAppHotkeys({
361
379
  waitForPendingDomEditSaves,
362
380
  writeHistoryFile,
363
381
  onAfterUndoRedo,
382
+ activeCompPath,
383
+ forceReloadSdkSession,
364
384
  ],
365
385
  );
366
386
 
@@ -16,9 +16,6 @@ import { useDomGeometryCommits } from "./useDomGeometryCommits";
16
16
  import { useElementLifecycleOps } from "./useElementLifecycleOps";
17
17
  import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
18
18
 
19
- // Re-export so existing consumers keep their import path
20
- export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits";
21
-
22
19
  // ── Helpers ──
23
20
 
24
21
  function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
@@ -45,8 +42,6 @@ interface RecordEditInput {
45
42
  files: Record<string, { before: string; after: string }>;
46
43
  }
47
44
 
48
- export type { PersistDomEditOperations } from "./domEditCommitTypes";
49
-
50
45
  export interface UseDomEditCommitsParams {
51
46
  activeCompPath: string | null;
52
47
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
@@ -73,10 +68,20 @@ export interface UseDomEditCommitsParams {
73
68
  target: HTMLElement,
74
69
  options?: { preferClipAncestor?: boolean },
75
70
  ) => Promise<DomEditSelection | null>;
76
- /** Stage 7 Step 3b: called after a successful server-side element patch. */
77
- onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
78
- /** Stage 7 Step 3b: called after a successful server-side element delete. */
79
- onElementDeleted?: (selection: DomEditSelection) => void;
71
+ /** Resync the in-memory SDK session after a SERVER-side write (NOT the SDK
72
+ * path, whose session is already current) so a later SDK edit doesn't
73
+ * serialize the pre-write doc and revert the server's change. */
74
+ forceReloadSdkSession?: () => void;
75
+ /** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */
76
+ onTrySdkPersist?: (
77
+ selection: DomEditSelection,
78
+ operations: PatchOperation[],
79
+ originalContent: string,
80
+ targetPath: string,
81
+ options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean },
82
+ ) => Promise<boolean>;
83
+ /** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */
84
+ onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
80
85
  }
81
86
 
82
87
  export function useDomEditCommits({
@@ -97,8 +102,9 @@ export function useDomEditCommits({
97
102
  clearDomSelection,
98
103
  refreshDomEditSelectionFromPreview,
99
104
  buildDomSelectionFromTarget,
100
- onDomEditPersisted,
101
- onElementDeleted,
105
+ forceReloadSdkSession,
106
+ onTrySdkPersist,
107
+ onTrySdkDelete,
102
108
  }: UseDomEditCommitsParams) {
103
109
  const resolveImportedFontAsset = useCallback(
104
110
  (fontFamilyValue: string): ImportedFontAsset | null => {
@@ -149,6 +155,10 @@ export function useDomEditCommits({
149
155
 
150
156
  if (options?.shouldSave && !options.shouldSave()) return;
151
157
 
158
+ // Validate layout values BEFORE any persist path runs. The SDK cutover
159
+ // path (onTrySdkPersist) returns early on success, so leaving this check
160
+ // after it let invalid numeric values bypass the guard whenever the
161
+ // cutover flag was on.
152
162
  const patchTarget = buildDomEditPatchTarget(selection);
153
163
  const patchBody = { target: patchTarget, operations };
154
164
  const unsafeFields = findUnsafeDomPatchValues(patchBody);
@@ -158,6 +168,23 @@ export function useDomEditCommits({
158
168
  throw new Error(`DOM patch contains unsafe values: ${fields}`);
159
169
  }
160
170
 
171
+ // Skip the SDK path when prepareContent is set (e.g. @font-face injection
172
+ // for a custom font): sdkCutoverPersist serializes only the patched DOM
173
+ // and would drop the injected content. Let the server path run prepareContent.
174
+ if (
175
+ onTrySdkPersist &&
176
+ !options?.prepareContent &&
177
+ (await onTrySdkPersist(selection, operations, originalContent, targetPath, {
178
+ label: options?.label,
179
+ coalesceKey: options?.coalesceKey,
180
+ skipRefresh: options?.skipRefresh,
181
+ }))
182
+ ) {
183
+ // SDK handled it — its in-memory doc is already current, so do NOT
184
+ // forceReload (that would echo-reload the session we just wrote).
185
+ return;
186
+ }
187
+
161
188
  // Mark the save timestamp before the file write so the SSE file-change
162
189
  // handler suppresses the reload even if the event arrives before the
163
190
  // response (the server writes the file and emits SSE during the fetch).
@@ -220,7 +247,7 @@ export function useDomEditCommits({
220
247
  coalesceKey: options?.coalesceKey,
221
248
  files: { [targetPath]: { before: originalContent, after: finalContent } },
222
249
  });
223
- onDomEditPersisted?.(selection, operations);
250
+ forceReloadSdkSession?.();
224
251
 
225
252
  if (!options?.skipRefresh) {
226
253
  reloadPreview();
@@ -234,7 +261,8 @@ export function useDomEditCommits({
234
261
  domEditSaveTimestampRef,
235
262
  reloadPreview,
236
263
  showToast,
237
- onDomEditPersisted,
264
+ forceReloadSdkSession,
265
+ onTrySdkPersist,
238
266
  ],
239
267
  );
240
268
 
@@ -295,8 +323,9 @@ export function useDomEditCommits({
295
323
  projectIdRef,
296
324
  reloadPreview,
297
325
  clearDomSelection,
326
+ onTrySdkDelete,
327
+ forceReloadSdkSession,
298
328
  commitPositionPatchToHtml,
299
- onElementDeleted,
300
329
  });
301
330
 
302
331
  return {
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldUseSdkCutover } from "../utils/sdkCutover";
3
+ import type { PatchOperation } from "../utils/sourcePatcher";
4
+
5
+ const styleOp = (property: string, value: string): PatchOperation => ({
6
+ type: "inline-style",
7
+ property,
8
+ value,
9
+ });
10
+
11
+ const attrOp = (property: string, value: string): PatchOperation => ({
12
+ type: "attribute",
13
+ property,
14
+ value,
15
+ });
16
+
17
+ describe("shouldUseSdkCutover", () => {
18
+ it("returns false when flag is disabled", () => {
19
+ expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false);
20
+ });
21
+
22
+ it("returns false when no SDK session", () => {
23
+ expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false);
24
+ });
25
+
26
+ it("returns false when selection has no hfId", () => {
27
+ expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false);
28
+ expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false);
29
+ });
30
+
31
+ it("returns false when ops array is empty", () => {
32
+ expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false);
33
+ });
34
+
35
+ it("returns true when all conditions met with supported op types", () => {
36
+ expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true);
37
+ expect(
38
+ shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red"), attrOp("data-x", "1")]),
39
+ ).toBe(true);
40
+ });
41
+ });
@@ -1,15 +1,16 @@
1
- import type { Composition } from "@hyperframes/sdk";
2
1
  import type { TimelineElement } from "../player";
3
2
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
4
3
  import type { EditHistoryKind } from "../utils/editHistory";
5
4
  import type { RightPanelTab } from "../utils/studioHelpers";
6
5
  import type { PatchTarget } from "../utils/sourcePatcher";
7
6
  import type { SidebarTab } from "../components/sidebar/LeftSidebar";
7
+ import type { Composition } from "@hyperframes/sdk";
8
+ import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover";
9
+ import { runResolverShadow } from "../utils/sdkResolverShadow";
8
10
  import { useAskAgentModal } from "./useAskAgentModal";
9
11
  import { useDomSelection } from "./useDomSelection";
10
12
  import { usePreviewInteraction } from "./usePreviewInteraction";
11
13
  import { useDomEditCommits } from "./useDomEditCommits";
12
- import { runShadowDispatch, runShadowDelete } from "../utils/sdkShadow";
13
14
  import { useGsapScriptCommits } from "./useGsapScriptCommits";
14
15
  import { useGsapCacheVersion } from "./useGsapTweenCache";
15
16
  import { useDomEditWiring } from "./useDomEditWiring";
@@ -60,8 +61,8 @@ export interface UseDomEditSessionParams {
60
61
  openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
61
62
  selectSidebarTab?: (tab: SidebarTab) => void;
62
63
  getSidebarTab?: () => SidebarTab;
63
- /** Stage 7 Step 3b: SDK session for shadow dispatch parity tracking. */
64
64
  sdkSession?: Composition | null;
65
+ forceReloadSdkSession?: () => void;
65
66
  }
66
67
 
67
68
  // ── Hook ──
@@ -101,6 +102,7 @@ export function useDomEditSession({
101
102
  selectSidebarTab,
102
103
  getSidebarTab,
103
104
  sdkSession,
105
+ forceReloadSdkSession,
104
106
  }: UseDomEditSessionParams) {
105
107
  void _setRefreshKey;
106
108
  void _readProjectFile;
@@ -195,6 +197,8 @@ export function useDomEditSession({
195
197
  onFileContentChanged: updateEditingFileContent,
196
198
  showToast,
197
199
  sdkSession,
200
+ writeProjectFile,
201
+ forceReloadSdkSession,
198
202
  });
199
203
 
200
204
  // ── DOM commit handlers ──
@@ -234,10 +238,38 @@ export function useDomEditSession({
234
238
  clearDomSelection,
235
239
  refreshDomEditSelectionFromPreview,
236
240
  buildDomSelectionFromTarget,
237
- onDomEditPersisted: sdkSession
238
- ? (sel, ops) => runShadowDispatch(sdkSession, sel, ops)
241
+ forceReloadSdkSession,
242
+ onTrySdkPersist: sdkSession
243
+ ? (selection, operations, originalContent, targetPath, options) => {
244
+ // Resolver shadow runs regardless of the cutover flag — decoupled tripwire.
245
+ runResolverShadow(sdkSession, selection.hfId, operations);
246
+ return sdkCutoverPersist(
247
+ selection,
248
+ operations,
249
+ originalContent,
250
+ targetPath,
251
+ sdkSession,
252
+ {
253
+ editHistory,
254
+ writeProjectFile,
255
+ reloadPreview,
256
+ domEditSaveTimestampRef,
257
+ compositionPath: activeCompPath,
258
+ },
259
+ options,
260
+ );
261
+ }
262
+ : undefined,
263
+ onTrySdkDelete: sdkSession
264
+ ? (hfId, originalContent, targetPath) =>
265
+ sdkDeletePersist(hfId, originalContent, targetPath, sdkSession, {
266
+ editHistory,
267
+ writeProjectFile,
268
+ reloadPreview,
269
+ domEditSaveTimestampRef,
270
+ compositionPath: activeCompPath,
271
+ })
239
272
  : undefined,
240
- onElementDeleted: sdkSession ? (sel) => runShadowDelete(sdkSession, sel.hfId) : undefined,
241
273
  });
242
274
 
243
275
  // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
@@ -42,15 +42,11 @@ export function useDomGeometryCommits({
42
42
  }: UseDomGeometryCommitsParams) {
43
43
  const handleDomPathOffsetCommit = useCallback(
44
44
  (selection: DomEditSelection, next: { x: number; y: number }) => {
45
- const gsapBlocked = isElementGsapTargeted(previewIframeRef.current, selection.element);
46
- console.log(
47
- "[drag:7] handleDomPathOffsetCommit (CSS path)",
48
- JSON.stringify({
49
- sel: selection.id,
50
- gsapBlocked,
51
- }),
52
- );
53
- if (gsapBlocked) {
45
+ // ponytail: GSAP-targeted elements are blocked (no SDK position-in-script op); CSS-path
46
+ // elements fall through to commitPositionPatchToHtml → persistDomEditOperations →
47
+ // onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done).
48
+ // Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR.
49
+ if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
54
50
  const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
55
51
  showToast(error.message, "error");
56
52
  return Promise.reject(error);