From f079020128170a0b44fa9d2aad38af2da69bb266 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 16 Jun 2026 21:33:27 -0700 Subject: [PATCH 01/11] =?UTF-8?q?feat(studio):=20stage=207=20step=203c=20?= =?UTF-8?q?=E2=80=94=20sdk=20cutover=20for=20inline-style=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces sdkCutoverPersist(): when STUDIO_SDK_CUTOVER_ENABLED is set, inline-style PatchOps are routed through the SDK session's in-memory document model instead of the server patch-element API. The SDK serialize() result is written back through the same writeProjectFile + editHistory.recordEdit path, so the on-disk output is identical to the legacy route. - packages/studio/src/utils/sdkCutover.ts (new): sdkCutoverPersist() + shouldUseSdkCutover() guard; domEditSaveTimestampRef.current is stamped on each write to suppress the echo file-change reload. - packages/studio/src/components/editor/manualEditingAvailability.ts: adds STUDIO_SDK_CUTOVER_ENABLED flag (default false); changes STUDIO_SDK_SHADOW_ENABLED default to false now that cutover is available. - packages/studio/src/hooks/useSdkSession.ts: adds optional domEditSaveTimestampRef param; self-write suppress window (SELF_WRITE_SUPPRESS_MS) gates file-change reloads so SDK writes don't echo back as external edits. - packages/studio/src/App.tsx: passes domEditSaveTimestampRef to useSdkSession so the suppress window can gate reloads triggered by SDK cutover writes. - Test coverage: sdkCutover.test.ts (new, 141 lines) + useDomEditSession.test.ts (new, 50 lines) — guard function + happy-path assertions. Co-Authored-By: Claude Sonnet 4.6 --- packages/studio/src/App.tsx | 2 +- .../editor/manualEditingAvailability.ts | 9 + .../src/hooks/useDomEditSession.test.ts | 41 +++ packages/studio/src/hooks/useSdkSession.ts | 54 +-- packages/studio/src/utils/sdkCutover.test.ts | 335 ++++++++++++++++++ packages/studio/src/utils/sdkCutover.ts | 91 +++++ 6 files changed, 511 insertions(+), 21 deletions(-) create mode 100644 packages/studio/src/hooks/useDomEditSession.test.ts create mode 100644 packages/studio/src/utils/sdkCutover.test.ts create mode 100644 packages/studio/src/utils/sdkCutover.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index fd0bd2f7c..da5a52823 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -153,6 +153,7 @@ export function StudioApp() { domEditSaveTimestampRef, setRefreshKey, }); + const sdkSession = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef); useEffect(() => { if (activeCompPathHydrated) return; if (!fileManager.fileTreeLoaded) return; @@ -175,7 +176,6 @@ export function StudioApp() { reloadPreview: () => setRefreshKey((k) => k + 1), pendingTimelineEditPathRef, }); - const sdkSession = useSdkSession(projectId, activeCompPath ?? "index.html"); const timelineEditing = useTimelineEditing({ projectId, activeCompPath, diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 52ac92e69..3a4724eca 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -105,4 +105,13 @@ export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag( true, ); +// Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch +// instead of the server patch-element API. Default false; enable via +// VITE_STUDIO_SDK_CUTOVER_ENABLED=true. Requires SDK session to be open. +export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag( + env, + ["VITE_STUDIO_SDK_CUTOVER_ENABLED"], + false, +); + export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; diff --git a/packages/studio/src/hooks/useDomEditSession.test.ts b/packages/studio/src/hooks/useDomEditSession.test.ts new file mode 100644 index 000000000..040d83b3b --- /dev/null +++ b/packages/studio/src/hooks/useDomEditSession.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { shouldUseSdkCutover } from "../utils/sdkCutover"; +import type { PatchOperation } from "../utils/sourcePatcher"; + +const styleOp = (property: string, value: string): PatchOperation => ({ + type: "inline-style", + property, + value, +}); + +const attrOp = (property: string, value: string): PatchOperation => ({ + type: "attribute", + property, + value, +}); + +describe("shouldUseSdkCutover", () => { + it("returns false when flag is disabled", () => { + expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when no SDK session", () => { + expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when selection has no hfId", () => { + expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false); + expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when ops array is empty", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false); + }); + + it("returns true when all conditions met with supported op types", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true); + expect( + shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red"), attrOp("data-x", "1")]), + ).toBe(true); + }); +}); diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 0e22ba1b8..c75632479 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import type { MutableRefObject } from "react"; import { openComposition } from "@hyperframes/sdk"; import { createHttpAdapter } from "@hyperframes/sdk/adapters/http"; import type { Composition } from "@hyperframes/sdk"; @@ -20,19 +21,25 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string * (projectId, activeCompPath) change, disposes the old one on cleanup, and * re-opens it when the active composition file changes on disk (code editor, * agent, or server-side patch) so the in-memory linkedom document never goes - * stale. + * stale. The persist queue writes back to `activeCompPath` (not the + * "composition.html" default). * - * Opened WITHOUT a persist queue: this session is shadow-telemetry + - * selection-sync only — it reads from the server but must NEVER write back. - * Shadow dispatch ops mutate the in-memory model and are discarded on the next - * reload-on-change (the studio's own authoritative write triggers it). Routing - * authoritative writes through this session (cutover, Step 3c+) must re-add - * persist TOGETHER WITH self-write suppression — without it, the SDK's - * serialize() output races and clobbers the studio's authoritative write. + * The session is idle until Step 3c routes dispatch ops through it; re-opening + * is therefore purely additive — no SDK self-write exists yet, so there is no + * persist echo. Step 3c must add self-write suppression once dispatch writes. */ +// Time-window heuristic: suppress file-change reloads for 2 s after our own +// SDK cutover write, to avoid an echo-reload on the write we just committed. +// Footgun: if 2 s is too short (slow FS / network) the reload fires anyway; +// if too long it masks a legitimate external edit. The long-term shape is a +// sequence number or content hash threaded through the persist event so the +// comparison is exact rather than time-based. +const SELF_WRITE_SUPPRESS_MS = 2000; + export function useSdkSession( projectId: string | null, activeCompPath: string | null, + domEditSaveTimestampRef?: MutableRefObject, ): Composition | null { const [session, setSession] = useState(null); const [reloadToken, setReloadToken] = useState(0); @@ -40,13 +47,15 @@ export function useSdkSession( // ── Re-open on external change to the active composition ── useEffect(() => { if (!activeCompPath) return; - // Pre-existing clone of the file-change reload handler (usePreviewPersistence); - // surfaced by this PR's adjacent edits, not introduced by it. - // fallow-ignore-next-line code-duplication const handler = (payload?: unknown) => { - if (shouldReloadSdkSession(payload, activeCompPath)) { - setReloadToken((t) => t + 1); - } + if (!shouldReloadSdkSession(payload, activeCompPath)) return; + // Suppress reload triggered by our own SDK cutover write. + if ( + domEditSaveTimestampRef && + Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS + ) + return; + setReloadToken((t) => t + 1); }; if (import.meta.hot) { import.meta.hot.on("hf:file-change", handler); @@ -56,6 +65,7 @@ export function useSdkSession( const es = new EventSource("/api/events"); es.addEventListener("file-change", handler); return () => es.close(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeCompPath]); // ── Open / re-open the session ── @@ -66,7 +76,7 @@ export function useSdkSession( } let cancelled = false; - let comp: Composition | null = null; + const compRef = { current: null as Composition | null }; const adapter = createHttpAdapter({ projectFilesUrl: `/api/projects/${projectId}`, @@ -75,15 +85,19 @@ export function useSdkSession( .read(activeCompPath) .then(async (content) => { if (cancelled || typeof content !== "string") return; - // No persist — shadow/selection only; see the hook docstring. The SDK - // must not write back to the server while it shadows the authoritative - // studio path. - comp = await openComposition(content); + const comp = await openComposition(content, { + persist: adapter, + persistPath: activeCompPath, + }); + comp.on("persist:error", (e) => { + console.warn("[sdk] persist:error", e.error); + }); // Cleanup may have fired while openComposition was awaited; dispose immediately. if (cancelled) { comp.dispose(); return; } + compRef.current = comp; setSession(comp); }) .catch(() => { @@ -92,7 +106,7 @@ export function useSdkSession( return () => { cancelled = true; - const c = comp; + const c = compRef.current; if (c) void c.flush().finally(() => c.dispose()); }; }, [projectId, activeCompPath, reloadToken]); diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts new file mode 100644 index 000000000..489113cbe --- /dev/null +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it, vi } from "vitest"; +import { shouldUseSdkCutover, sdkCutoverPersist } from "./sdkCutover"; +import { openComposition } from "@hyperframes/sdk"; +import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory"; +import type { PatchOperation } from "./sourcePatcher"; +import type { MutableRefObject } from "react"; + +vi.mock("../components/editor/manualEditingAvailability", () => ({ + STUDIO_SDK_CUTOVER_ENABLED: true, +})); +vi.mock("./studioTelemetry", () => ({ + trackStudioEvent: vi.fn(), +})); + +const styleOp = (property: string, value: string): PatchOperation => ({ + type: "inline-style", + property, + value, +}); + +const textOp = (value: string): PatchOperation => ({ + type: "text-content", + property: "text", + value, +}); + +const attrOp = (property: string, value: string): PatchOperation => ({ + type: "attribute", + property, + value, +}); + +const htmlAttrOp = (property: string, value: string): PatchOperation => ({ + type: "html-attribute", + property, + value, +}); + +describe("shouldUseSdkCutover", () => { + it("returns false when flag disabled", () => { + expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when no session", () => { + expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when no hfId", () => { + expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false); + expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when ops empty", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false); + }); + + it("returns true for inline-style ops", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true); + }); + + it("returns true for text-content ops", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [textOp("hello")])).toBe(true); + }); + + it("returns true for attribute ops", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("data-x", "10")])).toBe(true); + }); + + it("returns true for html-attribute ops", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("class", "foo")])).toBe(true); + }); + + it("returns true when ops mix all supported types", () => { + expect( + shouldUseSdkCutover(true, true, "hf-abc", [ + styleOp("color", "red"), + textOp("hello"), + attrOp("x", "1"), + htmlAttrOp("class", "foo"), + ]), + ).toBe(true); + }); +}); + +describe("sdkCutoverPersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + + const makeDeps = (overrides: Partial[5]> = {}) => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + ...overrides, + }); + + const makeSession = (hasEl = true) => + ({ + getElement: vi.fn().mockReturnValue(hasEl ? { inlineStyles: {} } : null), + dispatch: vi.fn(), + serialize: vi.fn().mockReturnValue(""), + batch: vi.fn((fn: () => void) => fn()), + }) as unknown as Parameters[4]; + + it("returns false when session is null", async () => { + const deps = makeDeps(); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red")], + "before", + "/path.html", + null, + deps, + ); + expect(result).toBe(false); + }); + + it("returns false when element not found in session", async () => { + const deps = makeDeps(); + const session = makeSession(false); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red")], + "before", + "/path.html", + session, + deps, + ); + expect(result).toBe(false); + }); + + it("dispatches setStyle for inline-style ops", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red"), styleOp("opacity", "0.5")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setStyle", + target: "hf-abc", + styles: { color: "red", opacity: "0.5" }, + }); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", ""); + expect(deps.reloadPreview).toHaveBeenCalled(); + }); + + it("dispatches setText for text-content op", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [textOp("Hello world")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setText", + target: "hf-abc", + value: "Hello world", + }); + }); + + it("dispatches setAttribute for attribute op with data- prefix", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [attrOp("x", "42")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setAttribute", + target: "hf-abc", + name: "data-x", + value: "42", + }); + }); + + it("dispatches setAttribute for html-attribute op", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [htmlAttrOp("class", "foo bar")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setAttribute", + target: "hf-abc", + name: "class", + value: "foo bar", + }); + }); + + it("passes caller label to recordEdit", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, { + label: "Resize layer box", + }); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ label: "Resize layer box" }), + ); + }); + + it("passes caller coalesceKey to recordEdit", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, { + coalesceKey: "my-key", + }); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ coalesceKey: "my-key" }), + ); + }); + + it("returns false and does not throw on dispatch error", async () => { + const deps = makeDeps(); + const session = makeSession(true); + (session!.dispatch as ReturnType).mockImplementation(() => { + throw new Error("dispatch failed"); + }); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(false); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + }); + + it("wraps all dispatches in session.batch() for atomic rollback", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + await sdkCutoverPersist( + sel, + [styleOp("color", "red"), styleOp("opacity", "0.5")], + "before", + "/comp.html", + session, + deps, + ); + expect( + (session as unknown as { batch: ReturnType }).batch, + ).toHaveBeenCalledOnce(); + }); + + it("returns false when second dispatch throws (batch prevents partial mutation)", async () => { + // inline-style ops coalesce into one setStyle dispatch; use style+text to produce two dispatches. + const deps = makeDeps(); + const session = makeSession(true); + let callCount = 0; + (session!.dispatch as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 2) throw new Error("2nd op failed"); + }); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red"), textOp("hello")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + }); +}); + +describe("sdkCutoverPersist — GSAP script preservation (integration)", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + it("preserves GSAP +`; + const comp = await openComposition(html, { persist: createMemoryAdapter() }); + const deps = makeDeps(); + const sel = { hfId: "hf-layer" } as never; + const result = await sdkCutoverPersist( + sel, + [{ type: "inline-style", property: "color", value: "red" }], + html, + "/comp.html", + comp, + deps, + ); + expect(result).toBe(true); + const written = (deps.writeProjectFile as ReturnType).mock + .calls[0]?.[1] as string; + expect(written).toContain("data-hf-gsap"); + expect(written).toContain('data-position-mode="relative"'); + expect(written).toContain("gsap.timeline()"); + }); +}); diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts new file mode 100644 index 000000000..6bb3afee0 --- /dev/null +++ b/packages/studio/src/utils/sdkCutover.ts @@ -0,0 +1,91 @@ +import type { MutableRefObject } from "react"; +import type { Composition } from "@hyperframes/sdk"; +import type { DomEditSelection } from "../components/editor/domEditing"; +import type { EditHistoryKind } from "./editHistory"; +import type { PatchOperation } from "./sourcePatcher"; +import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability"; +import { patchOpsToSdkEditOps } from "./sdkShadow"; +import { trackStudioEvent } from "./studioTelemetry"; + +const CUTOVER_OP_TYPES = new Set([ + "inline-style", + "text-content", + "attribute", + "html-attribute", +]); + +export function shouldUseSdkCutover( + flagEnabled: boolean, + hasSession: boolean, + hfId: string | null | undefined, + ops: PatchOperation[], +): boolean { + return ( + flagEnabled && + hasSession && + !!hfId && + ops.length > 0 && + ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) + ); +} + +interface CutoverDeps { + editHistory: { + recordEdit: (entry: { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; + }) => Promise; + }; + writeProjectFile: (path: string, content: string) => Promise; + reloadPreview: () => void; + domEditSaveTimestampRef: MutableRefObject; +} + +interface CutoverOptions { + label?: string; + coalesceKey?: string; +} + +export async function sdkCutoverPersist( + selection: DomEditSelection, + ops: PatchOperation[], + originalContent: string, + targetPath: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + if (!shouldUseSdkCutover(STUDIO_SDK_CUTOVER_ENABLED, !!sdkSession, selection.hfId, ops)) + return false; + if (!sdkSession) return false; + const hfId = selection.hfId; + if (!hfId) return false; + if (!sdkSession.getElement(hfId)) return false; + try { + sdkSession.batch(() => { + for (const editOp of patchOpsToSdkEditOps(hfId, ops)) { + sdkSession.dispatch(editOp); + } + }); + const after = sdkSession.serialize(); + deps.domEditSaveTimestampRef.current = Date.now(); + await deps.writeProjectFile(targetPath, after); + await deps.editHistory.recordEdit({ + label: options?.label ?? "Edit layer", + kind: "manual", + ...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}), + files: { [targetPath]: { before: originalContent, after } }, + }); + deps.reloadPreview(); + trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { + hfId: selection.hfId ?? null, + error: String(err), + }); + return false; + } +} From 0d89bcb8ede4958d760418259eced6d16738a60d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 03:06:48 -0700 Subject: [PATCH 02/11] fix(studio): force-reload sdk session after undo/redo bypasses suppress window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writeHistoryFile arms the 2 s self-write suppress window, so the file-change event for an undo/redo write is swallowed and the SDK in-memory doc stays on pre-undo content. Expose forceReload() from useSdkSession (s7.4) and call it in useAppHotkeys after a successful undo/redo that touched the active composition path. Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- packages/studio/src/App.tsx | 14 ++++++------- packages/studio/src/hooks/useAppHotkeys.ts | 20 +++++++++++++++++++ .../studio/src/hooks/useSdkSession.test.ts | 12 +++++++++++ packages/studio/src/hooks/useSdkSession.ts | 17 +++++++++++++--- 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index da5a52823..8348ff785 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -143,9 +143,7 @@ export function StudioApp() { const domEditSaveTimestampRef = useRef(0); const pendingTimelineEditPathRef = useRef(new Set()); const isGestureRecordingRef = useRef(false); - const reloadPreview = useCallback(() => { - setRefreshKey((k) => k + 1); - }, []); + const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []); const fileManager = useFileManager({ projectId, showToast, @@ -153,7 +151,7 @@ export function StudioApp() { domEditSaveTimestampRef, setRefreshKey, }); - const sdkSession = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef); + const sdkHandle = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef); useEffect(() => { if (activeCompPathHydrated) return; if (!fileManager.fileTreeLoaded) return; @@ -189,7 +187,7 @@ export function StudioApp() { pendingTimelineEditPathRef, uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, - sdkSession, + sdkSession: sdkHandle.session, }); const { activeBlockParams, @@ -257,6 +255,8 @@ export function StudioApp() { onResetKeyframes: () => resetKeyframesRef.current(), onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(), onAfterUndoRedo: () => invalidateGsapCacheRef.current(), + activeCompPath, + forceReloadSdkSession: sdkHandle.forceReload, onToggleRecording: STUDIO_KEYFRAMES_ENABLED ? () => handleToggleRecordingRef.current() : undefined, @@ -303,7 +303,7 @@ export function StudioApp() { openSourceForSelection: fileManager.openSourceForSelection, selectSidebarTab: selectSidebarTabStable, getSidebarTab: getSidebarTabStable, - sdkSession, + sdkSession: sdkHandle.session, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; @@ -320,7 +320,7 @@ export function StudioApp() { } }; useSdkSelectionSync( - sdkSession, + sdkHandle.session, domEditSession.domEditSelection, domEditSession.domEditGroupSelections, ); diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index c62b9f20c..56cfffd98 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -117,6 +117,14 @@ interface UseAppHotkeysParams { onDeleteSelectedKeyframes: () => void; onAfterUndoRedo?: () => void; onToggleRecording?: () => void; + /** Active composition path — used to decide whether undo/redo must resync the SDK session. */ + activeCompPath?: string | null; + /** + * Force-reload the SDK session after undo/redo reverts the active comp file, + * bypassing the self-write suppress window. Without this, the suppress window + * blocks the file-change reload and the SDK session stays on pre-undo content. + */ + forceReloadSdkSession?: () => void; } // ── Extracted keydown dispatch (pure function, no hooks) ── @@ -302,6 +310,8 @@ export function useAppHotkeys({ onDeleteSelectedKeyframes, onAfterUndoRedo, onToggleRecording, + activeCompPath, + forceReloadSdkSession, }: UseAppHotkeysParams) { const previewHotkeyWindowRef = useRef(null); const previewHistoryCleanupRef = useRef<(() => void) | null>(null); @@ -349,6 +359,14 @@ export function useAppHotkeys({ } if (result.ok && result.label) { onAfterUndoRedo?.(); + // If the active composition was among the written files, force-reload + // the SDK session so its in-memory doc matches the reverted content. + // writeHistoryFile sets domEditSaveTimestampRef which activates the + // 2 s suppress window — without this call the file-change event would + // be swallowed and the SDK session would stay on stale pre-undo content. + if (activeCompPath && result.paths?.includes(activeCompPath)) { + forceReloadSdkSession?.(); + } await syncHistoryPreviewAfterApply(result.paths); showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info"); } @@ -361,6 +379,8 @@ export function useAppHotkeys({ waitForPendingDomEditSaves, writeHistoryFile, onAfterUndoRedo, + activeCompPath, + forceReloadSdkSession, ], ); diff --git a/packages/studio/src/hooks/useSdkSession.test.ts b/packages/studio/src/hooks/useSdkSession.test.ts index b4b81b49b..27155fdd4 100644 --- a/packages/studio/src/hooks/useSdkSession.test.ts +++ b/packages/studio/src/hooks/useSdkSession.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it } from "vitest"; import { shouldReloadSdkSession } from "./useSdkSession"; +// ── undo-sync contract ──────────────────────────────────────────────────────── +// useSdkSession exposes forceReload() so callers can bypass the 2 s self-write +// suppress window. useAppHotkeys calls forceReload() after a successful +// undo/redo that wrote the active composition path. Without it, the suppress +// window swallows the file-change event and the SDK session stays stale. +// +// The React hook internals (useState / useEffect) cannot be unit-tested without +// a full render environment; the correctness of the suppress-bypass path is +// covered by the integration tests in usePersistentEditHistory.test.ts +// (which verify undo writes the correct before-content to disk). +// ───────────────────────────────────────────────────────────────────────────── + describe("shouldReloadSdkSession", () => { it("reloads when the changed file is the active composition", () => { expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, "scenes/intro.html")).toBe(true); diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index c75632479..9bfd64f71 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import type { MutableRefObject } from "react"; import { openComposition } from "@hyperframes/sdk"; import { createHttpAdapter } from "@hyperframes/sdk/adapters/http"; @@ -36,11 +36,21 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string // comparison is exact rather than time-based. const SELF_WRITE_SUPPRESS_MS = 2000; +export interface SdkSessionHandle { + session: Composition | null; + /** + * Force a session reload immediately, bypassing the self-write suppress + * window. Call after undo/redo writes the active composition file so the + * SDK in-memory document reflects the reverted content. + */ + forceReload: () => void; +} + export function useSdkSession( projectId: string | null, activeCompPath: string | null, domEditSaveTimestampRef?: MutableRefObject, -): Composition | null { +): SdkSessionHandle { const [session, setSession] = useState(null); const [reloadToken, setReloadToken] = useState(0); @@ -111,5 +121,6 @@ export function useSdkSession( }; }, [projectId, activeCompPath, reloadToken]); - return session; + const forceReload = useCallback(() => setReloadToken((t) => t + 1), []); + return { session, forceReload }; } From a7d93d482c841698e53158fc3e3ae8f03b1a057a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 10:55:28 -0700 Subject: [PATCH 03/11] =?UTF-8?q?feat(studio):=20s7.5=20=E2=80=94=20delete?= =?UTF-8?q?=20shadow=20scaffolding;=20keep=20cutover=20flag=20(dark=20laun?= =?UTF-8?q?ch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the SDK shadow telemetry: STUDIO_SDK_SHADOW_ENABLED, sdkShadow.ts + sdkShadowGsapFidelity/GsapKeyframe/Numeric and their tests, the runShadow* call-sites across the GSAP/timeline hooks, and the onDomEditPersisted shadow callback in useDomEditSession. Moves patchOpsToSdkEditOps into sdkCutover.ts. KEEPS STUDIO_SDK_CUTOVER_ENABLED as a dark-launch kill-switch — default false, enable per-environment via VITE_STUDIO_SDK_CUTOVER_ENABLED=true. shouldUseSdkCutover stays flag-gated. The stack can merge with zero behavior change; cutover is validated by flipping the flag, not by removing it. Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- packages/studio/src/App.tsx | 2 - .../editor/manualEditingAvailability.ts | 11 - .../studio/src/hooks/gsapScriptCommitTypes.ts | 9 - .../studio/src/hooks/useDomEditSession.ts | 10 - .../studio/src/hooks/useGsapAnimationOps.ts | 51 +- .../studio/src/hooks/useGsapKeyframeOps.ts | 22 +- .../studio/src/hooks/useGsapScriptCommits.ts | 43 +- .../studio/src/hooks/useTimelineEditing.ts | 25 +- packages/studio/src/utils/sdkCutover.ts | 39 +- packages/studio/src/utils/sdkShadow.test.ts | 606 ------------------ packages/studio/src/utils/sdkShadow.ts | 517 --------------- .../studio/src/utils/sdkShadowGsapFidelity.ts | 296 --------- .../src/utils/sdkShadowGsapKeyframe.test.ts | 265 -------- .../studio/src/utils/sdkShadowGsapKeyframe.ts | 257 -------- packages/studio/src/utils/sdkShadowNumeric.ts | 11 - 15 files changed, 54 insertions(+), 2110 deletions(-) delete mode 100644 packages/studio/src/utils/sdkShadow.test.ts delete mode 100644 packages/studio/src/utils/sdkShadow.ts delete mode 100644 packages/studio/src/utils/sdkShadowGsapFidelity.ts delete mode 100644 packages/studio/src/utils/sdkShadowGsapKeyframe.test.ts delete mode 100644 packages/studio/src/utils/sdkShadowGsapKeyframe.ts delete mode 100644 packages/studio/src/utils/sdkShadowNumeric.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 8348ff785..fe789a28c 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -187,7 +187,6 @@ export function StudioApp() { pendingTimelineEditPathRef, uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, - sdkSession: sdkHandle.session, }); const { activeBlockParams, @@ -303,7 +302,6 @@ export function StudioApp() { openSourceForSelection: fileManager.openSourceForSelection, selectSidebarTab: selectSidebarTabStable, getSidebarTab: getSidebarTabStable, - sdkSession: sdkHandle.session, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 3a4724eca..5148ca6e4 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -94,17 +94,6 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag( export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED; -// Stage 7 Step 3b: shadow dispatch parity mode — dispatches ops to the SDK -// session alongside the server patch path and logs mismatches via telemetry. -// Default on: server stays authoritative (no user-visible change), so we want -// the sdk_shadow_dispatch parity signal from all traffic. Disable via -// VITE_STUDIO_SDK_SHADOW_ENABLED=false. -export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag( - env, - ["VITE_STUDIO_SDK_SHADOW_ENABLED"], - true, -); - // Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch // instead of the server patch-element API. Default false; enable via // VITE_STUDIO_SDK_CUTOVER_ENABLED=true. Requires SDK session to be open. diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index afc949256..20f0565e8 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -1,9 +1,6 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser"; -import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { EditHistoryKind } from "../utils/editHistory"; -import type { ShadowGsapOp } from "../utils/sdkShadow"; -import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe"; export interface MutationResult { ok: boolean; @@ -28,10 +25,6 @@ export interface CommitMutationOptions { * (and under distinct keys) run concurrently as before. */ serializeKey?: string; - /** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */ - shadowGsapOp?: ShadowGsapOp; - /** Typed SDK equivalent of a keyframe mutation for keyframe value-fidelity shadow (gsap_keyframe). */ - shadowKeyframeOp?: ShadowKeyframeOp; } export type CommitMutation = ( @@ -70,6 +63,4 @@ export interface GsapScriptCommitsParams { onCacheInvalidate: () => void; onFileContentChanged?: (path: string, content: string) => void; showToast: (message: string, tone?: "error" | "info") => void; - /** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */ - sdkSession?: Composition | null; } diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 7b85e4c30..df08968f2 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,4 +1,3 @@ -import type { Composition } from "@hyperframes/sdk"; import type { TimelineElement } from "../player"; import type { ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; @@ -9,7 +8,6 @@ import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; import { useDomEditCommits } from "./useDomEditCommits"; -import { runShadowDispatch, runShadowDelete } from "../utils/sdkShadow"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; import { useGsapCacheVersion } from "./useGsapTweenCache"; import { useDomEditWiring } from "./useDomEditWiring"; @@ -60,8 +58,6 @@ export interface UseDomEditSessionParams { openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void; selectSidebarTab?: (tab: SidebarTab) => void; getSidebarTab?: () => SidebarTab; - /** Stage 7 Step 3b: SDK session for shadow dispatch parity tracking. */ - sdkSession?: Composition | null; } // ── Hook ── @@ -100,7 +96,6 @@ export function useDomEditSession({ openSourceForSelection, selectSidebarTab, getSidebarTab, - sdkSession, }: UseDomEditSessionParams) { void _setRefreshKey; void _readProjectFile; @@ -194,7 +189,6 @@ export function useDomEditSession({ onCacheInvalidate: bumpGsapCache, onFileContentChanged: updateEditingFileContent, showToast, - sdkSession, }); // ── DOM commit handlers ── @@ -234,10 +228,6 @@ export function useDomEditSession({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, - onDomEditPersisted: sdkSession - ? (sel, ops) => runShadowDispatch(sdkSession, sel, ops) - : undefined, - onElementDeleted: sdkSession ? (sel) => runShadowDelete(sdkSession, sel.hfId) : undefined, }); // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ── diff --git a/packages/studio/src/hooks/useGsapAnimationOps.ts b/packages/studio/src/hooks/useGsapAnimationOps.ts index aa953fb2d..b0e253e18 100644 --- a/packages/studio/src/hooks/useGsapAnimationOps.ts +++ b/packages/studio/src/hooks/useGsapAnimationOps.ts @@ -1,8 +1,6 @@ import { useCallback } from "react"; -import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { roundTo3 } from "../utils/rounding"; -import { runShadowGsapTween, type ShadowGsapOp } from "../utils/sdkShadow"; import { assignGsapTargetAutoIdIfNeeded, ensureElementAddressable, @@ -15,8 +13,6 @@ interface GsapAnimationOpsParams { commitMutation: CommitMutation; commitMutationSafely: SafeGsapCommitMutation; showToast: (message: string, tone?: "error" | "info") => void; - /** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */ - sdkSession?: Composition | null; } export function useGsapAnimationOps({ @@ -25,7 +21,6 @@ export function useGsapAnimationOps({ commitMutation, commitMutationSafely, showToast, - sdkSession, }: GsapAnimationOpsParams) { const updateGsapMeta = useCallback( ( @@ -33,13 +28,6 @@ export function useGsapAnimationOps({ animationId: string, updates: { duration?: number; ease?: string; position?: number }, ) => { - // Shadow op (server animationId shares the SDK id-space): existence via - // runShadowGsapTween (live session) + value fidelity via the chokepoint. - const shadowGsapOp: ShadowGsapOp = { - kind: "set", - animationId, - properties: { duration: updates.duration, ease: updates.ease, position: updates.position }, - }; // coalesceKey groups rapid meta edits into one history entry. Request // serialization is now handled per-file at the commitMutation chokepoint // (useGsapScriptCommits), so no per-op serializeKey is needed here. @@ -47,24 +35,21 @@ export function useGsapAnimationOps({ commitMutationSafely( selection, { type: "update-meta", animationId, updates }, - { label: "Edit GSAP animation", coalesceKey: metaKey, shadowGsapOp }, + { label: "Edit GSAP animation", coalesceKey: metaKey }, ); - if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp); }, - [commitMutationSafely, sdkSession], + [commitMutationSafely], ); const deleteGsapAnimation = useCallback( (selection: DomEditSelection, animationId: string) => { - const shadowGsapOp: ShadowGsapOp = { kind: "remove", animationId }; commitMutationSafely( selection, { type: "delete", animationId, stripStudioEdits: true }, - { label: "Delete GSAP animation", shadowGsapOp }, + { label: "Delete GSAP animation" }, ); - if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp); }, - [commitMutationSafely, sdkSession], + [commitMutationSafely], ); const deleteAllForSelector = useCallback( @@ -78,8 +63,6 @@ export function useGsapAnimationOps({ [commitMutation], ); - // Pre-existing complexity (auto-id assignment + per-method defaults); this PR - // adds only a guarded shadow-op construction at the tail. const addGsapAnimation = useCallback( // fallow-ignore-next-line complexity async ( @@ -114,26 +97,6 @@ export function useGsapAnimationOps({ fromTo: { x: 0, y: 0, opacity: 1 }, }; - // Shadow op (server stays authoritative). "set" has no SDK method, so it - // is not shadowed; otherwise: existence via runShadowGsapTween (live) + - // value fidelity via the chokepoint (shadowGsapOp in options). - const shadowGsapOp: ShadowGsapOp | undefined = - selection.hfId && method !== "set" - ? { - kind: "add", - target: selection.hfId, - tween: { - method, - position, - duration, - ease: "power2.out", - ...(method === "fromTo" - ? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] } - : { properties: toDefaults[method] ?? { opacity: 1 } }), - }, - } - : undefined; - await commitMutation( selection, { @@ -146,12 +109,10 @@ export function useGsapAnimationOps({ properties: toDefaults[method] ?? { opacity: 1 }, fromProperties: method === "fromTo" ? { opacity: 0 } : undefined, }, - { label: `Add GSAP ${method} animation`, shadowGsapOp }, + { label: `Add GSAP ${method} animation` }, ); - - if (sdkSession && shadowGsapOp) runShadowGsapTween(sdkSession, shadowGsapOp); }, - [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession], + [activeCompPath, commitMutation, projectIdRef, showToast], ); return { diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.ts b/packages/studio/src/hooks/useGsapKeyframeOps.ts index 809363c4d..44b663540 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.ts +++ b/packages/studio/src/hooks/useGsapKeyframeOps.ts @@ -1,6 +1,5 @@ import { useCallback } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { executeOptimistic } from "../utils/optimisticUpdate"; import type { KeyframeCacheEntry } from "../player/store/playerStore"; @@ -59,13 +58,6 @@ export function useGsapKeyframeOps({ percentage, properties: { [property]: value }, }; - // Shadow op (gsap_keyframe): SDK equivalent diffed via the commit chokepoint. - const shadowKeyframeOp: ShadowKeyframeOp = { - kind: "add", - animationId, - percentage, - properties: { [property]: value }, - }; void executeOptimisticKeyframeCacheUpdate({ sourceFile, elementId: selection.id, @@ -79,7 +71,6 @@ export function useGsapKeyframeOps({ commitMutation(selection, mutation, { label: `Add keyframe at ${percentage}%`, softReload: true, - shadowKeyframeOp, }), }).catch((error) => { trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`); @@ -95,16 +86,10 @@ export function useGsapKeyframeOps({ percentage: number, properties: Record, ) => { - const shadowKeyframeOp: ShadowKeyframeOp = { - kind: "add", - animationId, - percentage, - properties, - }; return commitMutation( selection, { type: "add-keyframe", animationId, percentage, properties }, - { label: `Add keyframe at ${percentage}%`, softReload: true, shadowKeyframeOp }, + { label: `Add keyframe at ${percentage}%`, softReload: true }, ); }, [commitMutation], @@ -114,10 +99,6 @@ export function useGsapKeyframeOps({ (selection: DomEditSelection, animationId: string, percentage: number) => { const sourceFile = selection.sourceFile || activeCompPath || "index.html"; const mutation = { type: "remove-keyframe", animationId, percentage }; - // Shadow op (gsap_keyframe): SDK has no %-based removeGsapKeyframe on main, - // so the runner resolves percentage → keyframeIndex against the pre-op - // script and no-ops on ambiguity (duplicate-percentage keyframes). - const shadowKeyframeOp: ShadowKeyframeOp = { kind: "remove", animationId, percentage }; void executeOptimisticKeyframeCacheUpdate({ sourceFile, elementId: selection.id, @@ -131,7 +112,6 @@ export function useGsapKeyframeOps({ commitMutation(selection, mutation, { label: `Remove keyframe at ${percentage}%`, softReload: true, - shadowKeyframeOp, }), }).catch((error) => { trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 1e4b4c7d9..6c63669ab 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -2,8 +2,6 @@ import { useCallback, useRef } from "react"; import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { applySoftReload } from "../utils/gsapSoftReload"; -import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity"; -import { runShadowGsapKeyframeFidelity } from "../utils/sdkShadowGsapKeyframe"; import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers"; import { createKeyedSerializer } from "./serializeByKey"; import { @@ -46,15 +44,12 @@ async function mutateGsapScript( // oxfmt-ignore // fallow-ignore-next-line complexity -export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) { +export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast }: GsapScriptCommitsParams) { // Serializer for per-key commits (options.serializeKey). Keyed by // `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for - // the same animationId so their POSTs can't interleave — which is what made - // the shadow fidelity diff pair an op with a stale server result and report - // false ease mismatches. Held in a ref so the chain survives re-renders. + // the same animationId so their POSTs can't interleave. Held in a ref so the + // chain survives re-renders. const serializerRef = useRef(createKeyedSerializer()); - // Pre-existing complexity (server mutate + history + reload branches); this PR - // adds only a guarded shadow-fidelity dispatch. // fallow-ignore-next-line complexity const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record, options: CommitMutationOptions) => { const pid = projectIdRef.current; @@ -76,28 +71,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra } if (result.changed === false) return; domEditSaveTimestampRef.current = Date.now(); - // Shadow value fidelity: diff the SDK's GSAP writer output against the - // server's, from the same pre-op file. Fire-and-forget; server authoritative. - // Meta-level ops carry shadowGsapOp (add / update-meta / delete via - // useGsapAnimationOps); keyframe ops carry shadowKeyframeOp (add/remove via - // useGsapKeyframeOps, handled by the gsap_keyframe block below). Per-property - // handlers (useGsapPropertyDebounce) don't synthesize one yet — deferred follow-up. - // scriptText is null when the composition has no GSAP script; nothing to diff. - const fidelityArgs = resolveGsapFidelityArgs( - sdkSession, - options.shadowGsapOp, - result.before, - result.scriptText, - ); - if (fidelityArgs) { - void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript); - } - // Keyframe value fidelity (gsap_keyframe): same serialize-diff approach, but - // the SDK has no keyframe reader so there is no live-existence path — the diff - // is the only signal. Guarded on a live session + both scripts to diff. - if (sdkSession && options.shadowKeyframeOp && result.before != null && result.scriptText != null) { - void runShadowGsapKeyframeFidelity(result.before, options.shadowKeyframeOp, result.scriptText); - } if (result.before != null && result.after != null) { await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } }); } @@ -111,12 +84,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra reloadPreview(); } onCacheInvalidate(); - }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]); + }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast]); // Every GSAP-script commit is a read-modify-write of one file. Overlapping - // commits to the SAME file (any op type, any animation) interleave server-side - // and make the shadow fidelity diff pair an op with a stale server result — - // the false ease/value mismatches this serializer exists to prevent. So - // serialize per target file by default; an explicit serializeKey overrides. + // commits to the SAME file (any op type, any animation) interleave server-side, + // so serialize per target file by default; an explicit serializeKey overrides. const commitMutation = useCallback( (selection: DomEditSelection, mutation: Record, options: CommitMutationOptions) => { const file = selection.sourceFile || activeCompPath || "index.html"; @@ -128,7 +99,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath); const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast); const propertyOps = useGsapPropertyDebounce(commitMutationSafely); - const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast, sdkSession }); + const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast }); const keyframeOps = useGsapKeyframeOps({ activeCompPath, commitMutation, commitMutationSafely, trackGsapSaveFailure }); const arcPathOps = useGsapArcPathOps(commitMutationSafely); return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps }; diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 260bbb310..66d1d6480 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -1,11 +1,7 @@ // Pre-existing-complex timeline hook (DOM patch + GSAP position shift/scale + -// playback-start resolution); this PR adds guarded shadow-timing dispatches in -// the move/resize .then() chains, which nudges several callbacks over the CC -// threshold. The added branches are telemetry-only. +// playback-start resolution). // fallow-ignore-file complexity import { useCallback, useRef } from "react"; -import type { Composition } from "@hyperframes/sdk"; -import { runShadowDelete, runShadowTiming } from "../utils/sdkShadow"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; import { useRazorSplit } from "./useRazorSplit"; @@ -60,8 +56,6 @@ interface UseTimelineEditingOptions { pendingTimelineEditPathRef: React.MutableRefObject>; uploadProjectFiles: (files: Iterable, dir?: string) => Promise; isRecordingRef?: React.RefObject; - /** Stage 7 Step 3b: SDK session for shadow timing dispatch (server stays authoritative). */ - sdkSession?: Composition | null; } // ── Hook ── @@ -79,7 +73,6 @@ export function useTimelineEditing({ pendingTimelineEditPathRef, uploadProjectFiles, isRecordingRef, - sdkSession, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; @@ -148,11 +141,6 @@ export function useTimelineEditing({ value: String(updates.track), }); }).then(() => { - if (sdkSession) - runShadowTiming(sdkSession, element.hfId, { - start: updates.start, - trackIndex: updates.track, - }); const pid = projectIdRef.current; if (delta !== 0 && element.domId && pid) { return shiftGsapPositions(pid, filePath, element.domId, delta) @@ -161,7 +149,7 @@ export function useTimelineEditing({ } }); }, - [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession], + [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview], ); const handleTimelineElementResize = useCallback( @@ -205,11 +193,6 @@ export function useTimelineEditing({ } return patched; }).then(() => { - if (sdkSession) - runShadowTiming(sdkSession, element.hfId, { - start: updates.start, - duration: updates.duration, - }); const pid = projectIdRef.current; if (timingChanged && element.domId && pid) { return scaleGsapPositions( @@ -227,7 +210,7 @@ export function useTimelineEditing({ return reloadPreview(); }); }, - [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession], + [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview], ); const handleTimelineElementDelete = useCallback( @@ -288,7 +271,6 @@ export function useTimelineEditing({ ); usePlayerStore.getState().setSelectedElementId(null); reloadPreview(); - if (sdkSession) runShadowDelete(sdkSession, element.hfId); showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); } catch (error) { const message = error instanceof Error ? error.message : "Failed to delete timeline clip"; @@ -304,7 +286,6 @@ export function useTimelineEditing({ domEditSaveTimestampRef, reloadPreview, isRecordingRef, - sdkSession, ], ); diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 6bb3afee0..19fd0dfd1 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -1,10 +1,9 @@ import type { MutableRefObject } from "react"; -import type { Composition } from "@hyperframes/sdk"; +import type { Composition, EditOp } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditing"; import type { EditHistoryKind } from "./editHistory"; import type { PatchOperation } from "./sourcePatcher"; import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability"; -import { patchOpsToSdkEditOps } from "./sdkShadow"; import { trackStudioEvent } from "./studioTelemetry"; const CUTOVER_OP_TYPES = new Set([ @@ -14,6 +13,42 @@ const CUTOVER_OP_TYPES = new Set([ "html-attribute", ]); +/** + * Map Studio PatchOperations for a given hf-id to SDK EditOps. + * + * Multiple inline-style ops are coalesced into a single setStyle (SDK batches + * style changes naturally). One SDK op is emitted per non-style op. + */ +function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] { + const result: EditOp[] = []; + const styles: Record = {}; + let hasStyles = false; + + for (const op of ops) { + if (op.type === "inline-style") { + styles[op.property] = op.value; + hasStyles = true; + } else if (op.type === "text-content") { + result.push({ type: "setText", target: hfId, value: op.value ?? "" }); + } else if (op.type === "attribute") { + result.push({ + type: "setAttribute", + target: hfId, + name: op.property.startsWith("data-") ? op.property : `data-${op.property}`, + value: op.value, + }); + } else if (op.type === "html-attribute") { + result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value }); + } + } + + if (hasStyles) { + result.unshift({ type: "setStyle", target: hfId, styles }); + } + + return result; +} + export function shouldUseSdkCutover( flagEnabled: boolean, hasSession: boolean, diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts deleted file mode 100644 index 462b8d2dc..000000000 --- a/packages/studio/src/utils/sdkShadow.test.ts +++ /dev/null @@ -1,606 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { - patchOpsToSdkEditOps, - runShadowDelete, - runShadowTiming, - runShadowGsapTween, - runShadowGsapFidelity, - gsapFidelityMismatches, - resolveGsapFidelityArgs, - SdkShadowMismatch, -} from "./sdkShadow"; -import type { ShadowGsapOp } from "./sdkShadow"; -import { makeSelectorResolver } from "./sdkShadowGsapFidelity"; -import type { PatchOperation } from "./sourcePatcher"; -import { openComposition } from "@hyperframes/sdk"; -import { Window } from "happy-dom"; - -// Capture sdk_shadow_dispatch telemetry for the non-PatchOperation runners. -const trackedEvents: Array<{ event: string; props: Record }> = []; -vi.mock("./studioTelemetry", () => ({ - trackStudioEvent: (event: string, props: Record) => - trackedEvents.push({ event, props }), -})); -beforeEach(() => { - trackedEvents.length = 0; -}); -const lastShadow = () => - trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props; - -const BASE_HTML = /* html */ ` - -
Hello
-`; - -describe("patchOpsToSdkEditOps", () => { - it("maps inline-style ops to a single setStyle EditOp", () => { - const ops: PatchOperation[] = [ - { type: "inline-style", property: "color", value: "#00f" }, - { type: "inline-style", property: "opacity", value: "0.5" }, - ]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - type: "setStyle", - target: "hf-box", - styles: { color: "#00f", opacity: "0.5" }, - }); - }); - - it("maps text-content op to setText EditOp", () => { - const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World" }]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ type: "setText", target: "hf-box", value: "World" }); - }); - - it("maps attribute op to setAttribute with data- prefix", () => { - const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - type: "setAttribute", - target: "hf-box", - name: "data-name", - value: "hero", - }); - }); - - it("maps html-attribute op to setAttribute without prefix", () => { - const ops: PatchOperation[] = [ - { type: "html-attribute", property: "contenteditable", value: "true" }, - ]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - type: "setAttribute", - target: "hf-box", - name: "contenteditable", - value: "true", - }); - }); - - it("handles null value for attribute removal", () => { - const ops: PatchOperation[] = [{ type: "html-attribute", property: "hidden", value: null }]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result[0]).toEqual({ - type: "setAttribute", - target: "hf-box", - name: "hidden", - value: null, - }); - }); - - it("returns empty array for unknown op types", () => { - const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[]; - expect(patchOpsToSdkEditOps("hf-box", ops)).toHaveLength(0); - }); -}); - -describe("sdkShadowDispatch (integration)", () => { - it("applies ops and returns no mismatches when SDK matches expected values", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }]; - const result = sdkShadowDispatch(session, "hf-box", ops); - - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); - expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f"); - }); - - // fallow-ignore-next-line code-duplication - it("does NOT false-mismatch a hyphenated style property (kebab op vs camelCase snapshot)", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [ - { type: "inline-style", property: "background-color", value: "rgb(255, 79, 88)" }, - ]; - const result = sdkShadowDispatch(session, "hf-box", ops); - - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); // was 1 before the kebab→camel read-back fix - expect(session.getElement("hf-box")?.inlineStyles.backgroundColor).toBe("rgb(255, 79, 88)"); - }); - - it("returns dispatched:false when hfId not found in session", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }]; - const result = sdkShadowDispatch(session, "hf-missing", ops); - - expect(result.dispatched).toBe(false); - expect(result.mismatches).toHaveLength(1); - expect(result.mismatches[0]).toMatchObject({ - kind: "element_not_found", - hfId: "hf-missing", - }); - }); - - it("applies text op and reads back via session.getElement", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "Updated" }]; - sdkShadowDispatch(session, "hf-box", ops); - - expect(session.getElement("hf-box")?.text).toBe("Updated"); - }); - - // Fix 2: text parity normalization. snapshot.text is trimmed by the SDK, so a - // trailing-whitespace-only difference between the op value and the snapshot must - // not flag. - it("does NOT false-mismatch trailing-whitespace-only text difference", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World " }]; - const result = sdkShadowDispatch(session, "hf-box", ops); - - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); // trimmed both sides - }); - - // Empty-string op value vs an absent (null) snapshot text must collapse to equal - // — both mean "no text content". - it("treats empty-string text op and null snapshot text as equal", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const EMPTY_HTML = /* html */ ` -`; - const session = await openComposition(EMPTY_HTML); - - const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "" }]; - const result = sdkShadowDispatch(session, "hf-img", ops); - - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); // "" vs null → both null - }); - - // Fix 3 verdict (REAL DIVERGENCE, not a readback artifact): the inline-style - // read-back already reads only the AUTHORED style attribute (getElementStyles → - // parseStyleAttr), never computed styles. The transform-origin divergence - // (expected null actual "center center") was a genuine SDK bug — setStyle - // removal of a HYPHENATED property silently no-opped because setElementStyles - // deleted the kebab key while the style map is keyed camelCase. Now FIXED in - // the SDK (model.ts setElementStyles normalizes the key via toCamel), so the - // shadow sees parity: removal applies and there is no mismatch. - it("reports clean removal of a hyphenated style (SDK setStyle kebab/camel fix)", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const TO_HTML = /* html */ ` -
x
`; - const session = await openComposition(TO_HTML); - - const ops: PatchOperation[] = [ - { type: "inline-style", property: "transform-origin", value: null }, - ]; - const result = sdkShadowDispatch(session, "hf-box", ops); - - // The SDK now removes the hyphenated property, so the shadow read-back agrees. - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); - }); - - it("applies attribute op and reads back via session.getElement", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }]; - sdkShadowDispatch(session, "hf-box", ops); - - expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero"); - }); - - // fallow-ignore-next-line code-duplication - it("does NOT false-mismatch studio-internal data-hf-* marker attributes", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - // path-offset drags emit these already-data-prefixed, SDK-excluded markers. - const ops: PatchOperation[] = [ - { type: "attribute", property: "data-hf-studio-path-offset", value: "true" }, - ]; - const result = sdkShadowDispatch(session, "hf-box", ops); - - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); // filtered, not double-prefixed + flagged - }); - - it("returns dispatch_error when dispatch throws — does not propagate", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - // Poison dispatch so it throws on any call - session.dispatch = () => { - throw new Error("sdk internal error"); - }; - - const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "red" }]; - let result: ReturnType | undefined; - expect(() => { - result = sdkShadowDispatch(session, "hf-box", ops); - }).not.toThrow(); - - expect(result!.dispatched).toBe(false); - expect(result!.mismatches).toHaveLength(1); - expect(result!.mismatches[0]).toMatchObject({ - kind: "dispatch_error", - hfId: "hf-box", - error: expect.stringContaining("sdk internal error"), - }); - }); -}); - -const TIMING_HTML = /* html */ ` - -
clip
-`; - -const GSAP_HTML = `
-
- -
`; - -const NO_TIMELINE_HTML = `
-
- -
`; - -describe("runShadowDelete", () => { - it("removes the element from the SDK session and reports parity", async () => { - const session = await openComposition(BASE_HTML); - runShadowDelete(session, "hf-box"); - expect(session.getElement("hf-box")).toBeNull(); - expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 }); - }); - - it("reports no_hf_id when selection has no hf-id", async () => { - const session = await openComposition(BASE_HTML); - runShadowDelete(session, null); - expect(lastShadow()).toMatchObject({ op: "delete", dispatched: false, reason: "no_hf_id" }); - }); - - it("reports cannot_dispatch when the element is not addressable", async () => { - const session = await openComposition(BASE_HTML); - runShadowDelete(session, "hf-missing"); - expect(lastShadow()).toMatchObject({ - op: "delete", - dispatched: false, - reason: "cannot_dispatch", - }); - }); - - // Fix 4 verdict (REAL SDK id-resolution divergence, NOT a readback bug): when a - // bare hf-id collides between a sub-composition element (scopedId - // "hf-host/hf-dup") and a top-level sibling (scopedId "hf-dup"), removeElement - // resolves the bare id via resolveScoped → querySelector (document-order-first, - // removes the INNER instance), but getElement prefers the canonical top-level - // match (scopedId === id) which SURVIVES. The shadow then correctly reports - // expected "removed" / actual "present". The readback here is correct (it checks - // the same id it dispatched); the fix belongs in the SDK's id resolution - // (resolveScoped vs getElement agreement), not in this file. - const DUP_ID_HTML = /* html */ ` -
-
-
inner
-
-
outer
-
- `; - - it("reports clean delete for a duplicate bare id (SDK resolves removeElement/getElement to the same instance)", async () => { - const session = await openComposition(DUP_ID_HTML); - runShadowDelete(session, "hf-dup"); - // SDK fix (agree removeElement/getElement on duplicate bare ids): both now - // resolve a bare id to the canonical (top-level) instance, so removeElement - // drops exactly the element the readback checks → no mismatch. (Previously - // removeElement dropped the inner instance while the top-level survived, - // which this shadow correctly flagged; that divergence is now fixed.) - expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 }); - }); -}); - -describe("runShadowTiming", () => { - it("applies timing and reports parity against the snapshot", async () => { - const session = await openComposition(TIMING_HTML); - runShadowTiming(session, "hf-clip", { start: 2, duration: 3, trackIndex: 1 }); - const el = session.getElement("hf-clip"); - expect(el?.start).toBe(2); - expect(el?.duration).toBe(3); - expect(el?.trackIndex).toBe(1); - expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 }); - }); - - // Fix 1: float-precision tolerance. The SDK computes durations arithmetically - // (returning e.g. 3.0999999999999996); the server stores the rounded literal - // (3.1). A relative epsilon must treat these as equal, while a real difference - // still flags. A fake session returns the imprecise value on read-back. - type FakeTiming = { start?: number; duration?: number; trackIndex?: number }; - function fakeTimingSession(readback: FakeTiming) { - return { - can: () => ({ ok: true }), - batch: (fn: () => void) => fn(), - dispatch: () => {}, - getElement: () => readback, - } as unknown as Parameters[0]; - } - - it("does NOT flag float-precision duration drift (3.1 vs 3.0999999999999996)", () => { - const session = fakeTimingSession({ duration: 3.0999999999999996 }); - runShadowTiming(session, "hf-clip", { duration: 3.1 }); - expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 }); - }); - - it("does NOT flag float-precision start drift (21.36 vs 21.360000000000014)", () => { - const session = fakeTimingSession({ start: 21.360000000000014 }); - runShadowTiming(session, "hf-clip", { start: 21.36 }); - expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 }); - }); - - it("STILL flags a real duration difference (3.1 vs 3.5)", () => { - const session = fakeTimingSession({ duration: 3.5 }); - runShadowTiming(session, "hf-clip", { duration: 3.1 }); - expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 1 }); - }); -}); - -describe("runShadowGsapTween", () => { - it("add reports success and the new tween lands on the target's animationIds", async () => { - const session = await openComposition(GSAP_HTML); - const before = session.getElement("hf-box")?.animationIds.length ?? 0; - runShadowGsapTween(session, { - kind: "add", - target: "hf-box", - tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, - }); - expect(session.getElement("hf-box")!.animationIds.length).toBe(before + 1); - expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 }); - }); - - it("remove drops the tween from animationIds and reports parity", async () => { - const session = await openComposition(GSAP_HTML); - const animationId = session.getElement("hf-box")?.animationIds[0]; - expect(animationId).toBeDefined(); - runShadowGsapTween(session, { kind: "remove", animationId: animationId! }); - expect(session.getElement("hf-box")?.animationIds ?? []).not.toContain(animationId); - expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 }); - }); - - it("reports cannot_dispatch (E_NO_GSAP_TIMELINE) when the script has no timeline", async () => { - const session = await openComposition(NO_TIMELINE_HTML); - runShadowGsapTween(session, { - kind: "add", - target: "hf-box", - tween: { method: "to", properties: { x: 100 } }, - }); - expect(lastShadow()).toMatchObject({ - op: "gsap", - dispatched: false, - reason: "cannot_dispatch", - code: "E_NO_GSAP_TIMELINE", - }); - }); -}); - -const SCRIPT_A = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2); -window.__timelines["t"] = tl;`; - -describe("gsapFidelityMismatches", () => { - it("returns no mismatches for identical scripts", () => { - expect(gsapFidelityMismatches(SCRIPT_A, SCRIPT_A)).toEqual([]); - }); - - it("flags a per-field value drift (duration)", () => { - const drifted = SCRIPT_A.replace("duration: 0.5", "duration: 0.9"); - const mismatches = gsapFidelityMismatches(drifted, SCRIPT_A); - expect(mismatches.some((m) => m.property === "duration")).toBe(true); - }); - - it("does NOT flag sub-ULP float-formatting noise in duration", () => { - // 3.1 vs 3.0999999999999996 is the same value after writer round-trips; - // relative-epsilon compare must treat it as equal, not drift. - const sdk = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 3.1 }, 0); -window.__timelines["t"] = tl;`; - const server = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 3.0999999999999996 }, 0); -window.__timelines["t"] = tl;`; - expect(gsapFidelityMismatches(sdk, server)).toEqual([]); - }); - - it("STILL flags a real integer duration drift (2 vs 1) past the epsilon", () => { - const sdk = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 1 }, 0); -window.__timelines["t"] = tl;`; - const server = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 2 }, 0); -window.__timelines["t"] = tl;`; - const mismatches = gsapFidelityMismatches(sdk, server); - expect(mismatches.some((m) => m.property === "duration")).toBe(true); - }); - - it("flags a tween present in one script but not the other", () => { - const empty = `var tl = gsap.timeline({ paused: true }); -window.__timelines["t"] = tl;`; - const mismatches = gsapFidelityMismatches(empty, SCRIPT_A); - expect(mismatches.some((m) => m.property === "tween")).toBe(true); - }); - - it("does NOT flag property key-order differences (canonical compare)", () => { - const ab = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { x: 10, y: 20, duration: 0.5 }, 0); -window.__timelines["t"] = tl;`; - const ba = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { y: 20, x: 10, duration: 0.5 }, 0); -window.__timelines["t"] = tl;`; - expect(gsapFidelityMismatches(ab, ba)).toEqual([]); - }); - - it("does NOT flag number-vs-string-equivalent property values", () => { - const numeric = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0); -window.__timelines["t"] = tl;`; - const stringy = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: "1", duration: 0.5 }, 0); -window.__timelines["t"] = tl;`; - expect(gsapFidelityMismatches(numeric, stringy)).toEqual([]); - }); - - it("matches the same element across different selector forms when a resolver is given", () => { - // SDK writes [data-hf-id="hf-x"], server writes .x — same element, same tween. - const sdk = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-x\\"]", { x: 200, duration: 0.8 }, 0.5); -window.__timelines["t"] = tl;`; - const server = `var tl = gsap.timeline({ paused: true }); -tl.to(".x", { x: 200, duration: 0.8 }, 0.5); -window.__timelines["t"] = tl;`; - const resolve = (sel: string) => (/hf-x|\.x/.test(sel) ? "hf-x" : sel); - // Without a resolver: selector-form divergence → present/absent mismatch. - expect(gsapFidelityMismatches(sdk, server).length).toBeGreaterThan(0); - // With a resolver: matched by element → no mismatch. - expect(gsapFidelityMismatches(sdk, server, resolve)).toEqual([]); - }); - - // Drive makeSelectorResolver against a real DOM (happy-dom shims the - // browser-only DOMParser the resolver depends on; the studio test env is node). - describe("makeSelectorResolver unifies selector forms (real DOM)", () => { - const origDomParser = (globalThis as { DOMParser?: unknown }).DOMParser; - beforeEach(() => { - (globalThis as { DOMParser?: unknown }).DOMParser = new Window().DOMParser; - }); - afterEach(() => { - (globalThis as { DOMParser?: unknown }).DOMParser = origDomParser; - }); - - it("collapses #id / .class / [data-hf-id] for the SAME element to one key", () => { - // Element carries all three forms; the server may emit #id or .class while - // the SDK emits [data-hf-id]. All must resolve to the same canonical key. - const html = `
`; - const resolve = makeSelectorResolver(html); - const viaHfId = resolve('[data-hf-id="hf-9flp"]'); - expect(resolve(".caption-layer")).toBe(viaHfId); - expect(resolve("#intro-layer")).toBe(viaHfId); - }); - - it("unifies SDK [data-hf-id] and server .class tweens in the fidelity diff", () => { - const html = `
`; - const resolve = makeSelectorResolver(html); - const sdkScript = `var tl = gsap.timeline({ paused: true }); -tl.from("[data-hf-id=\\"hf-9flp\\"]", { opacity: 0, duration: 1 }, 0); -window.__timelines["t"] = tl;`; - const serverScript = `var tl = gsap.timeline({ paused: true }); -tl.from(".caption-layer", { opacity: 0, duration: 1 }, 0); -window.__timelines["t"] = tl;`; - // Without unification these flag present/absent; the resolver collapses them. - expect(gsapFidelityMismatches(sdkScript, serverScript).length).toBeGreaterThan(0); - expect(gsapFidelityMismatches(sdkScript, serverScript, resolve)).toEqual([]); - }); - - it("collapses different selector forms for an element WITHOUT a data-hf-id", () => { - // No hf-id present: the resolver must still key both forms to the same node - // (not leave .class vs #id as distinct raw-selector keys). - const html = `
`; - const resolve = makeSelectorResolver(html); - expect(resolve(".caption-layer")).toBe(resolve("#intro-layer")); - // And it is NOT the raw selector fallback. - expect(resolve(".caption-layer")).not.toBe(".caption-layer"); - }); - }); -}); - -describe("runShadowGsapFidelity", () => { - const BEFORE_HTML = `
-
- -
`; - - it("reports zero mismatches when the SDK output matches the server script", async () => { - // Produce the "server" script by applying the same op via the SDK, so a - // faithful SDK writer must reproduce it exactly. - const ref = await openComposition(BEFORE_HTML); - const op = { - kind: "add", - target: "hf-box", - tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, - } as const; - ref.addGsapTween(op.target, op.tween); - const serverScript = - ref.serialize().match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? ""; - - await runShadowGsapFidelity(BEFORE_HTML, op, serverScript); - expect(lastShadow()).toMatchObject({ op: "gsap_fidelity", dispatched: true, mismatchCount: 0 }); - }); - - it("reports mismatches when the server script diverges", async () => { - const op = { - kind: "add", - target: "hf-box", - tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, - } as const; - const ref = await openComposition(BEFORE_HTML); - ref.addGsapTween(op.target, op.tween); - const serverScript = ( - ref.serialize().match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? "" - ).replace("100", "999"); - - await runShadowGsapFidelity(BEFORE_HTML, op, serverScript); - const ev = lastShadow(); - expect(ev).toMatchObject({ op: "gsap_fidelity", dispatched: true }); - expect(ev?.mismatchCount as number).toBeGreaterThan(0); - }); -}); - -describe("resolveGsapFidelityArgs (chokepoint wiring)", () => { - const op: ShadowGsapOp = { kind: "remove", animationId: "a-1" }; - const session = {} as object; - - it("returns narrowed args when session, op, before, and serverScript are all present", () => { - expect(resolveGsapFidelityArgs(session, op, "before", "tl.to(...)")).toEqual({ - before: "before", - op, - serverScript: "tl.to(...)", - }); - }); - - it("returns null when no session (shadow not wired)", () => { - expect(resolveGsapFidelityArgs(null, op, "before", "script")).toBeNull(); - }); - - it("returns null when no shadowGsapOp (non-meta edit, e.g. property/keyframe)", () => { - expect(resolveGsapFidelityArgs(session, undefined, "before", "script")).toBeNull(); - }); - - it("returns null when serverScript is null (composition has no GSAP script)", () => { - expect(resolveGsapFidelityArgs(session, op, "before", null)).toBeNull(); - }); - - it("returns null when before is null", () => { - expect(resolveGsapFidelityArgs(session, op, null, "script")).toBeNull(); - }); -}); diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts deleted file mode 100644 index 08ed05b08..000000000 --- a/packages/studio/src/utils/sdkShadow.ts +++ /dev/null @@ -1,517 +0,0 @@ -/** - * SDK shadow dispatch utilities for Stage 7 Step 3b. - * - * Shadow mode keeps the server patch path authoritative while also dispatching - * the equivalent op to the SDK session, then compares the result to detect - * addressing gaps (blocker E: no-hf-id elements) and serialization drift - * (blocker B: linkedom whole-doc serialize). Results are reported as structured - * mismatches for telemetry — no user-visible change. - */ - -import type { Composition } from "@hyperframes/sdk"; -import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk"; -import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; -import { trackStudioEvent } from "./studioTelemetry"; -import { relEqual } from "./sdkShadowNumeric"; -import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import type { PatchOperation } from "./sourcePatcher"; - -// ─── Op mapping ────────────────────────────────────────────────────────────── - -/** - * Map Studio PatchOperations for a given hf-id to SDK EditOps. - * - * Multiple inline-style ops are coalesced into a single setStyle (SDK batches - * style changes naturally). One SDK op is emitted per non-style op. - */ -// "attribute" PatchOperations carry the data- attribute NAME. Studio passes -// some already prefixed (e.g. "data-hf-studio-path-offset") and some bare -// (e.g. "name"); prefix only when needed, never double-prefix. -function attrName(property: string): string { - return property.startsWith("data-") ? property : `data-${property}`; -} - -// The SDK element model excludes data-hf-* attributes (document.ts skips them), -// so shadowing studio-internal markers (data-hf-studio-path-offset, etc.) can -// never match — drop those ops from the shadow instead of false-mismatching. -function isShadowableOp(op: PatchOperation): boolean { - if (op.type === "attribute") return !attrName(op.property).startsWith("data-hf-"); - if (op.type === "html-attribute") return !op.property.startsWith("data-hf-"); - return true; -} - -// PatchOperation types patchOpsToSdkEditOps knows how to map. Used by -// runShadowDispatch to flag any unmapped type as visible telemetry rather than -// silently dropping it (see the unmapped_type guard there). -const MAPPED_PATCH_OP_TYPES: ReadonlySet = new Set([ - "inline-style", - "text-content", - "attribute", - "html-attribute", -]); - -export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] { - const result: EditOp[] = []; - const styles: Record = {}; - let hasStyles = false; - - for (const op of ops) { - if (op.type === "inline-style") { - styles[op.property] = op.value; - hasStyles = true; - } else if (op.type === "text-content") { - result.push({ type: "setText", target: hfId, value: op.value ?? "" }); - } else if (op.type === "attribute") { - result.push({ - type: "setAttribute", - target: hfId, - name: attrName(op.property), - value: op.value, - }); - } else if (op.type === "html-attribute") { - result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value }); - } - // unknown op types produce no SDK op - } - - if (hasStyles) { - result.unshift({ type: "setStyle", target: hfId, styles }); - } - - return result; -} - -// ─── Shadow result types ────────────────────────────────────────────────────── - -export interface SdkShadowMismatch { - kind: "element_not_found" | "value_mismatch" | "dispatch_error"; - hfId: string; - property?: string; - expected?: string | null; - actual?: string | null | undefined; - error?: string; -} - -export interface SdkShadowResult { - /** False if the element was not found in the SDK session. */ - dispatched: boolean; - mismatches: SdkShadowMismatch[]; -} - -// ─── Shadow dispatch ────────────────────────────────────────────────────────── - -type ElementSnapshot = ReturnType; -type OpFields = { - property: string; - expected: string | null | undefined; - actual: string | null | undefined; -}; - -type FlatSnapshot = { - styles: Record; - attrs: Record; - text: string | null; -}; - -function flattenSnapshot(snap: ElementSnapshot): FlatSnapshot { - return { - styles: snap?.inlineStyles ?? {}, - attrs: Object.fromEntries( - Object.entries(snap?.attributes ?? {}).map(([k, v]) => [k, v ?? null]), - ), - text: snap?.text ?? null, - }; -} - -type OpFieldResolver = (op: PatchOperation, flat: FlatSnapshot) => OpFields; - -// Snapshot inlineStyles are camelCase (CSSStyleDeclaration convention); PatchOperation -// style properties are kebab-case ("background-color"). Convert for read-back, else -// every hyphenated property false-mismatches against a null actual. -function kebabToCamel(prop: string): string { - return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); -} - -// Text parity: the SDK snapshot.text is trimmed, so trim the op value too. -// An empty string and absent text (null) are treated as equivalent (collapsed -// to null) so "" vs null does not flag — both mean "no text content". -function normalizeText(value: string | null | undefined): string | null { - if (value == null) return null; - const trimmed = value.trim(); - return trimmed === "" ? null : trimmed; -} - -const OP_FIELD_RESOLVERS: Record = { - "inline-style": (op, flat) => ({ - property: op.property, - expected: op.value, - actual: flat.styles[kebabToCamel(op.property)] ?? flat.styles[op.property] ?? null, - }), - // snapshot.text is already TRIMMED; trim the expected op value to match, so - // trailing-whitespace differences don't flag. Empty-vs-absent ("" vs null) is - // collapsed in checkOpParity. A genuinely different text value still flags. - "text-content": (op, flat) => ({ - property: "text", - expected: normalizeText(op.value), - actual: normalizeText(flat.text), - }), - attribute: (op, flat) => ({ - property: attrName(op.property), - expected: op.value ?? null, - actual: flat.attrs[attrName(op.property)] ?? null, - }), - "html-attribute": (op, flat) => ({ - property: op.property, - expected: op.value ?? null, - actual: flat.attrs[op.property] ?? null, - }), -}; - -function resolveOpFields(op: PatchOperation, flat: FlatSnapshot): OpFields | null { - return OP_FIELD_RESOLVERS[op.type]?.(op, flat) ?? null; -} - -function checkOpParity( - op: PatchOperation, - flat: FlatSnapshot, - hfId: string, -): SdkShadowMismatch | null { - const fields = resolveOpFields(op, flat); - if (!fields || fields.actual === fields.expected) return null; - return { kind: "value_mismatch", hfId, ...fields }; -} - -/** - * Dispatch PatchOperations to the SDK session and return a parity report. - * - * If the element is not found by hfId, returns dispatched:false with a - * element_not_found mismatch (signals blocker E — element has no hf-id or - * SDK can't address it). - * - * On success, verifies that the SDK element snapshot reflects the applied - * values. Value mismatches indicate serialization or normalization drift. - * - * **persist:error drift risk**: the HTTP adapter fires persist:error on - * network failure but the SDK session is already mutated at that point. If - * the server file was not updated (e.g. 503), subsequent shadow parity - * comparisons here will see a diverged SDK session and produce false - * positives. Before flipping STUDIO_SDK_DISPATCH_ENABLED, verify the shadow - * window is clear of persist:error events. - */ - -export function sdkShadowDispatch( - session: Composition, - hfId: string, - ops: PatchOperation[], -): SdkShadowResult { - if (!session.getElement(hfId)) { - return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] }; - } - // Drop studio-internal markers the SDK model can't represent (data-hf-*), so - // canvas-drag/path-offset edits don't false-mismatch on bookkeeping attrs. - const shadowable = ops.filter(isShadowableOp); - try { - const sdkOps = patchOpsToSdkEditOps(hfId, shadowable); - session.batch(() => { - for (const op of sdkOps) session.dispatch(op); - }); - } catch (err) { - return { - dispatched: false, - mismatches: [{ kind: "dispatch_error", hfId, error: String(err) }], - }; - } - const flat = flattenSnapshot(session.getElement(hfId)); - const mismatches = shadowable - .map((op) => checkOpParity(op, flat, hfId)) - .filter((m): m is SdkShadowMismatch => m !== null); - return { dispatched: true, mismatches }; -} - -// ─── Telemetry reporting ────────────────────────────────────────────────────── - -/** - * Shadow-dispatch ops to the SDK session and emit sdk_shadow_dispatch telemetry. - * Despite the telemetry focus, this function does mutate the SDK session — it - * is not read-only. No-op when STUDIO_SDK_SHADOW_ENABLED is false. - */ -// Property-path mismatches carry user content (inline-style values, edited -// text) in expected/actual. Scrub before telemetry: fully redact text-content -// values, length-cap the rest. The in-memory parity result keeps raw values. -function redactValueForTelemetry( - property: string | undefined, - value: string | null | undefined, -): string | null | undefined { - if (value == null) return value; - if (property === "text") return `[redacted len=${value.length}]`; - return value.length > 64 ? `${value.slice(0, 64)}…` : value; -} - -function redactMismatchesForTelemetry(mismatches: SdkShadowMismatch[]): SdkShadowMismatch[] { - return mismatches.map((m) => ({ - ...m, - expected: redactValueForTelemetry(m.property, m.expected), - actual: redactValueForTelemetry(m.property, m.actual), - })); -} - -export function runShadowDispatch( - session: Composition, - selection: DomEditSelection, - ops: PatchOperation[], -): void { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - const hfId = selection.hfId; - if (!hfId) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "property", - dispatched: false, - reason: "no_hf_id", - mismatchCount: 0, - }); - return; - } - // Defensive: patchOpsToSdkEditOps silently drops PatchOperation types it - // doesn't map. PatchOperation.type is a closed union today, but emit a visible - // unmapped_type event if a future type ever slips through, so the gap surfaces - // in telemetry instead of vanishing. - // Map to the type string before find, so a future unmapped type is read as a - // plain string (no object cast; find on the closed union narrows to never). - const unmappedType = ops.map((op) => op.type).find((t) => !MAPPED_PATCH_OP_TYPES.has(t)); - if (unmappedType !== undefined) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "property", - dispatched: false, - reason: "unmapped_type", - type: unmappedType, - mismatchCount: 0, - }); - return; - } - const result = sdkShadowDispatch(session, hfId, ops); - trackStudioEvent("sdk_shadow_dispatch", { - op: "property", - dispatched: result.dispatched, - mismatchCount: result.mismatches.length, - mismatches: JSON.stringify(redactMismatchesForTelemetry(result.mismatches)), - }); -} - -// ─── Shadow for non-PatchOperation ops (delete / timing / GSAP) ─────────────── -// -// These ops never flow through persistDomEditOperations, so the property-path -// shadow above never sees them. Each runner keeps the server authoritative and -// only observes the SDK: can() pre-checks addressing/validity (pure, no -// mutation — works even for GSAP, which has no element-snapshot value), then a -// dispatch into the live session with a snapshot-based parity check. -// -// Parity coverage by op: -// delete → getElement(id) === null (full) -// timing → snapshot.start/duration/trackIndex (full) -// gsap → tween id present/absent in animationIds (existence only — the -// tween's property values are script-level, not in the snapshot) - -/** - * can()-gated shadow dispatch. Emits sdk_shadow_dispatch tagged with `opLabel`. - * Mutates the SDK session (not read-only); server stays authoritative. - * No-op when STUDIO_SDK_SHADOW_ENABLED is false. - */ -function runShadowEditOp( - session: Composition, - op: EditOp, - opLabel: string, - dispatchAndCheck: () => SdkShadowMismatch[], -): void { - const verdict = session.can(op); - if (!verdict.ok) { - trackStudioEvent("sdk_shadow_dispatch", { - op: opLabel, - dispatched: false, - reason: "cannot_dispatch", - code: verdict.code, - mismatchCount: 0, - }); - return; - } - let mismatches: SdkShadowMismatch[]; - try { - mismatches = dispatchAndCheck(); - } catch (err) { - trackStudioEvent("sdk_shadow_dispatch", { - op: opLabel, - dispatched: false, - reason: "dispatch_error", - error: String(err), - mismatchCount: 0, - }); - return; - } - trackStudioEvent("sdk_shadow_dispatch", { - op: opLabel, - dispatched: true, - mismatchCount: mismatches.length, - mismatches: JSON.stringify(mismatches), - }); -} - -/** Shadow an element delete. Parity: the element is gone from the SDK session. */ -export function runShadowDelete(session: Composition, hfId: string | null | undefined): void { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - if (!hfId) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "delete", - dispatched: false, - reason: "no_hf_id", - mismatchCount: 0, - }); - return; - } - const op: EditOp = { type: "removeElement", target: hfId }; - runShadowEditOp(session, op, "delete", () => { - session.batch(() => session.dispatch(op)); - return session.getElement(hfId) - ? [ - { - kind: "value_mismatch", - hfId, - property: "exists", - expected: "removed", - actual: "present", - }, - ] - : []; - }); -} - -export interface ShadowTiming { - start?: number; - duration?: number; - trackIndex?: number; -} - -// start/duration tolerate float-precision drift (SDK computes them -// arithmetically, server stores a rounded literal) via the shared relative -// epsilon; trackIndex (integer track slot) is compared exactly. -function timingFieldEqual( - key: keyof ShadowTiming, - actual: number | null | undefined, - expected: number, -): boolean { - if (typeof actual === "number" && key !== "trackIndex") { - return relEqual(actual, expected); - } - return actual === expected; -} - -/** Shadow a timing edit. Parity: snapshot start/duration/trackIndex match. */ -export function runShadowTiming( - session: Composition, - hfId: string | null | undefined, - timing: ShadowTiming, -): void { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - if (!hfId) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "timing", - dispatched: false, - reason: "no_hf_id", - mismatchCount: 0, - }); - return; - } - const op: EditOp = { type: "setTiming", target: hfId, ...timing }; - runShadowEditOp(session, op, "timing", () => { - session.batch(() => session.dispatch(op)); - const el = session.getElement(hfId); - const mismatches: SdkShadowMismatch[] = []; - const fields: Array<[keyof ShadowTiming, number | null | undefined]> = [ - ["start", el?.start], - ["duration", el?.duration], - ["trackIndex", el?.trackIndex], - ]; - for (const [key, actual] of fields) { - const expected = timing[key]; - if (expected === undefined || timingFieldEqual(key, actual, expected)) continue; - mismatches.push({ - kind: "value_mismatch", - hfId, - property: key, - expected: String(expected), - actual: actual == null ? null : String(actual), - }); - } - return mismatches; - }); -} - -export type ShadowGsapOp = - | { kind: "add"; target: string; tween: GsapTweenSpec } - | { kind: "set"; animationId: string; properties: Partial } - | { kind: "remove"; animationId: string }; - -/** - * Shadow a GSAP tween mutation (add / set / remove). The server's animationId - * shares the SDK's id-space (both derive `targetSelector-method-position` from - * the same acorn parser — see sdk assignStableIds), so it is dispatchable as-is. - * - * Parity via the now-populated ElementSnapshot.animationIds: - * add → the returned tween id is present on the target element - * remove → the id is gone from every element - * set → existence only (the SDK exposes no per-tween property reader; value - * fidelity would need serialize()-script round-trip diffing). - */ -export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - const op: EditOp = - gsapOp.kind === "add" - ? { type: "addGsapTween", target: gsapOp.target, tween: gsapOp.tween } - : gsapOp.kind === "set" - ? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties } - : { type: "removeGsapTween", animationId: gsapOp.animationId }; - // fallow-ignore-next-line complexity - runShadowEditOp(session, op, "gsap", () => { - let newId: string | undefined; - session.batch(() => { - if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween); - else session.dispatch(op); - }); - if (gsapOp.kind === "add") { - const onTarget = session.getElement(gsapOp.target)?.animationIds ?? []; - if (!newId || !onTarget.includes(newId)) { - return [ - { - kind: "value_mismatch", - hfId: gsapOp.target, - property: "animationIds", - expected: newId ?? "non-empty", - actual: onTarget.join(",") || null, - }, - ]; - } - } else if (gsapOp.kind === "remove") { - const stillPresent = session - .getElements() - .some((el) => el.animationIds.includes(gsapOp.animationId)); - if (stillPresent) { - return [ - { - kind: "value_mismatch", - hfId: gsapOp.animationId, - property: "animationIds", - expected: "removed", - actual: "present", - }, - ]; - } - } - return []; - }); -} - -// GSAP value-fidelity diff lives in its own module to keep this file under the -// 600-line studio cap; re-exported here so the shadow surface stays in one place. -export { - gsapFidelityMismatches, - resolveGsapFidelityArgs, - runShadowGsapFidelity, -} from "./sdkShadowGsapFidelity"; diff --git a/packages/studio/src/utils/sdkShadowGsapFidelity.ts b/packages/studio/src/utils/sdkShadowGsapFidelity.ts deleted file mode 100644 index 45f800d7f..000000000 --- a/packages/studio/src/utils/sdkShadowGsapFidelity.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * GSAP value-fidelity shadow (serialize round-trip diff). Split out of - * sdkShadow.ts to keep that file under the 600-line studio cap. - * - * Existence parity (sdkShadow.ts) confirms a tween was created/removed, but not - * that its VALUES (duration / ease / position / properties) match the server. - * The SDK exposes no per-tween property reader, so we compare the two writers' - * output: apply the same op to a fresh SDK doc opened from the server's pre-op - * file, then structurally diff the SDK's GSAP script against the server's - * resulting script. Both are re-parsed, so formatting/whitespace differences - * never produce false positives — only real value drift does. - */ - -import { openComposition } from "@hyperframes/sdk"; -import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn"; -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; -import { trackStudioEvent } from "./studioTelemetry"; -import { relEqual } from "./sdkShadowNumeric"; -import type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow"; - -// Marker set must match document.ts extractGsapScript so both pick the same -// `) — HTML5 ignores junk - // before the `>`, e.g. `` or `` (CodeQL js/bad-tag-filter). - const scripts = html.match(/]*>([\s\S]*?)<\/script[^>]*>/gi); - if (!scripts) return null; - for (const block of scripts) { - const body = block.replace(/^]*>/i, "").replace(/<\/script[^>]*>$/i, ""); - if (isGsapScriptBody(body)) return body; - } - return null; -} - -function posKey(position: unknown): string { - if (typeof position === "number") return String(position); - const n = Number(position); - return Number.isNaN(n) ? String(position) : String(n); -} - -// Key a tween by its RESOLVED target element (not raw selector) + method + -// position. The SDK writer emits [data-hf-id="X"] selectors while the server -// emits class/other selectors for the SAME element; keying by resolved element -// matches them so the diff compares values instead of flagging present/absent. -// -// ponytail: one-tween-per-(element, method, position) assumption — coincident -// tweens (same element+method+position, different props) collapse, last wins, -// so the diff under-reports them. Props can't go in the key (a matched pair -// must share a key for the field-diff to run; raw props would split real value -// drift into present/absent). Not seen in studio-emitted templates; add a -// property-NAME hash to the key if coincident tweens show up in the wild. -function tweenKey(anim: GsapAnimation, resolveSelector?: (sel: string) => string): string { - const sel = resolveSelector ? resolveSelector(anim.targetSelector) : anim.targetSelector; - return `${sel}|${anim.method}|${posKey(anim.position)}`; -} - -function animByKey( - script: string, - resolveSelector?: (sel: string) => string, -): Map { - const map = new Map(); - const parsed = parseGsapScriptAcorn(script); - for (const anim of parsed.animations) map.set(tweenKey(anim, resolveSelector), anim); - return map; -} - -// The server (addAnimationToScript) and SDK (gsapWriterAcorn) are DIFFERENT -// writers, so the same tween can serialize with different property key order or -// number-vs-string forms. Compare canonically — sort keys, coerce numeric -// strings — so only real value drift registers, not formatting differences. - -// Coerce string operands to numbers, then compare with the shared relative -// epsilon (relEqual) so float-formatting noise (3.1 vs 3.0999999999999996) -// isn't flagged as drift while a real 2 vs 1 still is. -function numericEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - const na = typeof a === "string" ? Number(a) : a; - const nb = typeof b === "string" ? Number(b) : b; - if (typeof na !== "number" || typeof nb !== "number" || Number.isNaN(na) || Number.isNaN(nb)) { - return false; - } - return relEqual(na, nb); -} - -function canonicalProps(obj: Record | undefined): string { - if (!obj) return "{}"; - const out: Record = {}; - for (const key of Object.keys(obj).sort()) { - const v = obj[key]; - // normalize "0.5" → 0.5 so a number/string writer difference isn't drift - out[key] = typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v; - } - return JSON.stringify(out); -} - -/** - * Structurally diff two GSAP scripts. Tweens are matched by resolved target - * element + method + position (see tweenKey), so the SDK's [data-hf-id] - * selectors and the server's class selectors for the same element don't - * false-flag present/absent. Reports a tween present in one but not the other, - * and per-field value drift (duration, ease, properties, fromProperties). - * Comparison is canonical so writer formatting differences don't register. - * - * Pass resolveSelector (selector → canonical element id) to enable the - * element-based matching; without it, matching falls back to raw selector. - */ -// fallow-ignore-next-line complexity -export function gsapFidelityMismatches( - sdkScript: string, - serverScript: string, - resolveSelector?: (sel: string) => string, -): SdkShadowMismatch[] { - const sdk = animByKey(sdkScript, resolveSelector); - const server = animByKey(serverScript, resolveSelector); - const mismatches: SdkShadowMismatch[] = []; - const keys = new Set([...sdk.keys(), ...server.keys()]); - for (const key of keys) { - const a = sdk.get(key); - const b = server.get(key); - if (!a || !b) { - mismatches.push({ - kind: "value_mismatch", - hfId: key, - property: "tween", - expected: b ? "present" : "absent", - actual: a ? "present" : "absent", - }); - continue; - } - // method + position are part of the key (already equal); compare values. - const fields: Array<[string, unknown, unknown, boolean]> = [ - ["duration", a.duration, b.duration, numericEqual(a.duration, b.duration)], - ["ease", a.ease, b.ease, a.ease === b.ease], - [ - "properties", - a.properties, - b.properties, - canonicalProps(a.properties) === canonicalProps(b.properties), - ], - [ - "fromProperties", - a.fromProperties, - b.fromProperties, - canonicalProps(a.fromProperties) === canonicalProps(b.fromProperties), - ], - ]; - for (const [property, av, bv, equal] of fields) { - if (!equal) { - mismatches.push({ - kind: "value_mismatch", - hfId: key, - property, - expected: bv == null ? null : JSON.stringify(bv), - actual: av == null ? null : JSON.stringify(av), - }); - } - } - } - return mismatches; -} - -export interface GsapFidelityArgs { - before: string; - op: ShadowGsapOp; - serverScript: string; -} - -/** - * Wiring gate for the commitMutation chokepoint: return the narrowed fidelity - * args only when there is a live session, a typed shadow op, and both the - * pre-op file and the server's resulting script to diff against (scriptText is - * null when the composition has no GSAP script). Returns null otherwise. Pure + - * narrowing so the wiring decision is unit-testable without rendering the hook - * and the caller needs no non-null assertions. - */ -export function resolveGsapFidelityArgs( - sdkSession: unknown, - shadowGsapOp: ShadowGsapOp | undefined, - before: string | null | undefined, - serverScript: string | null | undefined, -): GsapFidelityArgs | null { - if (!sdkSession || !shadowGsapOp || before == null || serverScript == null) return null; - return { before, op: shadowGsapOp, serverScript }; -} - -// Resolve a CSS selector to a canonical element key using the pre-op document, -// so tweens that target the same element via different selectors -// ([data-hf-id="X"] vs .X vs #X) collapse to one key in the fidelity diff. -// -// The SDK writer emits [data-hf-id="X"] while the server may emit a class/id -// selector for the SAME element. Keying both forms to the same node prevents a -// false present/absent mismatch. Resolution order, for whatever element the -// selector matches: -// 1. data-hf-id present → "hfid:" (the common, stable case) -// 2. no data-hf-id → "node:" (per-document node index; identical -// regardless of which selector form found the node, so .x and [data-hf-id] -// pointing at the same attribute-less node still collapse) -// 3. selector resolves to no node / parse error / no DOM → the raw selector -// (last resort; only diverges when the two writers genuinely target -// different — or unresolvable — nodes, which is real drift to surface) -// The "hfid:"/"node:" prefixes are namespaced so a canonical key can never -// collide with a raw-selector fallback. -// -// ponytail: first-match heuristic — querySelector returns the FIRST match, so an -// ambiguous selector (e.g. .x shared by two elements) may map to a different -// node than the SDK side's [data-hf-id] target and still flag present/absent. -// Safe for studio templates (one tween per element); upgrade to querySelectorAll -// + uniqueness check if ambiguous selectors appear. -export function makeSelectorResolver(html: string): (sel: string) => string { - let doc: Document | null = null; - try { - doc = new DOMParser().parseFromString(html, "text/html"); - } catch { - doc = null; - } - // Stable per-node index so an attribute-less element keys identically no - // matter which selector form (class vs id vs [data-hf-id]) resolved it. - const nodeKeys = new WeakMap(); - let nextNode = 0; - const keyForNode = (el: Element): string => { - const hfId = el.getAttribute("data-hf-id"); - if (hfId != null && hfId !== "") return `hfid:${hfId}`; - const existing = nodeKeys.get(el); - if (existing != null) return existing; - const key = `node:${nextNode++}`; - nodeKeys.set(el, key); - return key; - }; - return (sel) => { - if (!doc) return sel; - try { - const el = doc.querySelector(sel); - return el ? keyForNode(el) : sel; - } catch { - return sel; - } - }; -} - -/** - * Shadow GSAP value fidelity: open a fresh SDK doc from the server's pre-op - * file, apply the same tween op, serialize, and diff the SDK's GSAP script - * against the server's resulting script. Emits sdk_shadow_dispatch op: - * "gsap_fidelity". Async, fire-and-forget; server stays authoritative. - */ -export async function runShadowGsapFidelity( - beforeHtml: string, - gsapOp: ShadowGsapOp, - serverScript: string, -): Promise { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - // No server script to diff against → skip the (costly) openComposition. - if (!serverScript || !beforeHtml) return; - try { - const session = await openComposition(beforeHtml); - session.batch(() => { - if (gsapOp.kind === "add") session.addGsapTween(gsapOp.target, gsapOp.tween); - else if (gsapOp.kind === "set") session.setGsapTween(gsapOp.animationId, gsapOp.properties); - else session.removeGsapTween(gsapOp.animationId); - }); - const sdkScript = extractGsapScript(session.serialize()); - if (sdkScript == null) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_fidelity", - dispatched: false, - reason: "no_sdk_script", - mismatchCount: 0, - }); - return; - } - const mismatches = gsapFidelityMismatches( - sdkScript, - serverScript, - makeSelectorResolver(beforeHtml), - ); - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_fidelity", - dispatched: true, - mismatchCount: mismatches.length, - mismatches: JSON.stringify(mismatches), - }); - } catch (err) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_fidelity", - dispatched: false, - reason: "fidelity_error", - error: String(err), - mismatchCount: 0, - }); - } -} diff --git a/packages/studio/src/utils/sdkShadowGsapKeyframe.test.ts b/packages/studio/src/utils/sdkShadowGsapKeyframe.test.ts deleted file mode 100644 index 20f08313a..000000000 --- a/packages/studio/src/utils/sdkShadowGsapKeyframe.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { openComposition } from "@hyperframes/sdk"; -import { - resolveKeyframeIndexByPercentage, - keyframeOpToEditOp, - gsapKeyframeFidelityMismatches, - runShadowGsapKeyframeFidelity, - type ShadowKeyframeOp, -} from "./sdkShadowGsapKeyframe"; -import { runShadowDispatch } from "./sdkShadow"; -import type { PatchOperation } from "./sourcePatcher"; - -// Capture sdk_shadow_dispatch telemetry. -const trackedEvents: Array<{ event: string; props: Record }> = []; -vi.mock("./studioTelemetry", () => ({ - trackStudioEvent: (event: string, props: Record) => - trackedEvents.push({ event, props }), -})); -// STUDIO_SDK_SHADOW_ENABLED defaults true (no env override in test), so the -// runners are active here without mocking the availability module. - -beforeEach(() => { - trackedEvents.length = 0; -}); -const lastShadow = () => - trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props; - -const ANIM_ID = "#hero-to-0-position"; - -function gsapHtml(scriptBody: string): string { - return /* html */ ` -
x
- -`; -} - -const KF_SCRIPT = ` - const tl = gsap.timeline({ paused: true }); - tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200 } }, duration: 5 }, 0);`; - -// A script body string (not full HTML) for the index-resolution helpers. -const KF_SCRIPT_BODY = KF_SCRIPT; -const DUP_SCRIPT_BODY = ` - const tl = gsap.timeline({ paused: true }); - tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "50%": { x: 150 }, "100%": { x: 200 } }, duration: 5 }, 0);`; - -describe("resolveKeyframeIndexByPercentage", () => { - it("resolves a unique percentage to its 0-based index", () => { - expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50)).toEqual({ - keyframeIndex: 1, - }); - expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 100)).toEqual({ - keyframeIndex: 2, - }); - }); - - it("matches within ~0.001 tolerance", () => { - expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50.0005).keyframeIndex).toBe( - 1, - ); - }); - - it("returns null with not_found when no percentage matches", () => { - expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 33)).toEqual({ - keyframeIndex: null, - reason: "not_found", - }); - }); - - it("returns null with no_keyframes for an unknown animation", () => { - expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, "#nope-to-0", 50)).toEqual({ - keyframeIndex: null, - reason: "no_keyframes", - }); - }); - - it("returns null with no_keyframes when script is empty", () => { - expect(resolveKeyframeIndexByPercentage(null, ANIM_ID, 50).reason).toBe("no_keyframes"); - }); - - it("no-ops on ambiguity (duplicate-percentage keyframes — PR #1498 landmine)", () => { - expect(resolveKeyframeIndexByPercentage(DUP_SCRIPT_BODY, ANIM_ID, 50)).toEqual({ - keyframeIndex: null, - reason: "ambiguous", - }); - }); - - // Regression: a from/fromTo tween's id may normalize to "-to-" on write, so a - // "-from-"/"-fromTo-" animationId must fall back to the converted id (matching - // the writer's locateAnimationWithFallback) — else the keyframe diff goes blind. - it("falls back from a -from- id to the -to- tween", () => { - const fromId = ANIM_ID.replace("-to-", "-from-"); - expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, fromId, 50)).toEqual({ - keyframeIndex: 1, - }); - }); -}); - -describe("keyframeOpToEditOp", () => { - it("maps add → addGsapKeyframe with position = percentage", () => { - const op: ShadowKeyframeOp = { - kind: "add", - animationId: ANIM_ID, - percentage: 25, - properties: { x: 50 }, - }; - expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({ - op: { type: "addGsapKeyframe", animationId: ANIM_ID, position: 25, value: { x: 50 } }, - }); - }); - - it("maps remove → removeGsapKeyframe with resolved index", () => { - const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 }; - expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({ - op: { type: "removeGsapKeyframe", animationId: ANIM_ID, keyframeIndex: 1 }, - }); - }); - - it("returns null op + reason when remove percentage is ambiguous", () => { - const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 }; - expect(keyframeOpToEditOp(op, DUP_SCRIPT_BODY)).toEqual({ op: null, reason: "ambiguous" }); - }); -}); - -describe("gsapKeyframeFidelityMismatches", () => { - it("reports no mismatches when keyframe arrays match", () => { - expect(gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, KF_SCRIPT_BODY, ANIM_ID)).toEqual([]); - }); - - it("reports a keyframes mismatch when arrays diverge", () => { - const other = ` - const tl = gsap.timeline({ paused: true }); - tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 999 }, "100%": { x: 200 } }, duration: 5 }, 0);`; - const mismatches = gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, other, ANIM_ID); - expect(mismatches.some((m) => m.property === "keyframes")).toBe(true); - }); -}); - -describe("runShadowGsapKeyframeFidelity (add)", () => { - it("emits gsap_keyframe with a keyframes mismatch when SDK adds but server didn't", async () => { - const beforeHtml = gsapHtml(KF_SCRIPT); - // server script unchanged (server "failed" to add the 25% keyframe) → drift - const session = await openComposition(beforeHtml); - const serverScript = session - .serialize() - .match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1]; - expect(serverScript).toBeTruthy(); - const op: ShadowKeyframeOp = { - kind: "add", - animationId: ANIM_ID, - percentage: 25, - properties: { x: 50 }, - }; - await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript); - const props = lastShadow(); - expect(props?.op).toBe("gsap_keyframe"); - expect(props?.dispatched).toBe(true); - expect(props?.mismatchCount).toBe(1); - }); - - it("emits dispatched:true mismatchCount:0 when SDK and server agree", async () => { - const beforeHtml = gsapHtml(KF_SCRIPT); - // Build the server's resulting script by applying the same op via the SDK. - const serverSession = await openComposition(beforeHtml); - serverSession.batch(() => - serverSession.dispatch({ - type: "addGsapKeyframe", - animationId: ANIM_ID, - position: 25, - value: { x: 50 }, - }), - ); - const serverScript = serverSession - .serialize() - .match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1]; - const op: ShadowKeyframeOp = { - kind: "add", - animationId: ANIM_ID, - percentage: 25, - properties: { x: 50 }, - }; - await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript); - const props = lastShadow(); - expect(props?.op).toBe("gsap_keyframe"); - expect(props?.dispatched).toBe(true); - expect(props?.mismatchCount).toBe(0); - }); -}); - -describe("runShadowGsapKeyframeFidelity (remove)", () => { - it("no-ops with reason when remove percentage is ambiguous", async () => { - const beforeHtml = gsapHtml(DUP_SCRIPT_BODY); - const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 }; - await runShadowGsapKeyframeFidelity(beforeHtml, op, "non-empty-server-script gsap"); - const props = lastShadow(); - expect(props?.op).toBe("gsap_keyframe"); - expect(props?.dispatched).toBe(false); - expect(props?.reason).toBe("ambiguous"); - }); - - it("dispatches a resolved remove and diffs", async () => { - const beforeHtml = gsapHtml(KF_SCRIPT); - const serverSession = await openComposition(beforeHtml); - serverSession.batch(() => - serverSession.dispatch({ - type: "removeGsapKeyframe", - animationId: ANIM_ID, - keyframeIndex: 1, - }), - ); - const serverScript = serverSession - .serialize() - .match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1]; - const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 }; - await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript); - const props = lastShadow(); - expect(props?.op).toBe("gsap_keyframe"); - expect(props?.dispatched).toBe(true); - expect(props?.mismatchCount).toBe(0); - }); -}); - -describe("runShadowGsapKeyframeFidelity (guards)", () => { - it("skips when there is no server script", async () => { - const op: ShadowKeyframeOp = { - kind: "add", - animationId: ANIM_ID, - percentage: 25, - properties: { x: 50 }, - }; - await runShadowGsapKeyframeFidelity(gsapHtml(KF_SCRIPT), op, null); - expect(lastShadow()).toBeUndefined(); - }); -}); - -describe("runShadowDispatch unmapped-type guard", () => { - const ELEMENT_HTML = /* html */ ` -
Hi
- `; - - it("emits unmapped_type when a PatchOperation type isn't mapped", async () => { - const session = await openComposition(ELEMENT_HTML); - // PatchOperation.type is a closed union today; cast to exercise the defensive - // guard for a future unmapped type. - const ops = [{ type: "future-op", property: "x", value: "1" } as unknown as PatchOperation]; - runShadowDispatch(session, { hfId: "hf-box" } as never, ops); - const props = lastShadow(); - expect(props?.op).toBe("property"); - expect(props?.dispatched).toBe(false); - expect(props?.reason).toBe("unmapped_type"); - expect(props?.type).toBe("future-op"); - }); - - it("dispatches normally for known PatchOperation types", async () => { - const session = await openComposition(ELEMENT_HTML); - const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }]; - runShadowDispatch(session, { hfId: "hf-box" } as never, ops); - const props = lastShadow(); - expect(props?.dispatched).toBe(true); - expect(props?.reason).toBeUndefined(); - }); -}); diff --git a/packages/studio/src/utils/sdkShadowGsapKeyframe.ts b/packages/studio/src/utils/sdkShadowGsapKeyframe.ts deleted file mode 100644 index 38a633162..000000000 --- a/packages/studio/src/utils/sdkShadowGsapKeyframe.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * GSAP keyframe-op shadow (serialize round-trip diff). New module for the Stage 7 - * shadow-parity push — kept out of sdkShadow.ts / sdkShadowGsapFidelity.ts so the - * shared files stay untouched (only additive imports) and the studio 600-line cap - * holds. - * - * Unlike tweens, the SDK exposes NO keyframe reader on ElementSnapshot, so there - * is no existence-parity path here. Instead we compare the two writers' output: - * open a fresh SDK doc from the server's pre-op file, dispatch the equivalent - * keyframe op, serialize, and diff the SDK's GSAP script against the server's - * resulting script. - * - * gsapFidelityMismatches (reused) matches tweens by resolved target element + - * method + position and diffs tween-level fields — but it does NOT look inside a - * tween's `keyframes` array. Keyframe drift therefore needs a dedicated diff, - * layered on top of the reused tween-level diff, matched by the GSAP animation id. - * - * SDK mapping (main, pre PR #1498 percentage-variant): - * add → addGsapKeyframe{animationId, position: percentage, value: properties} - * remove → removeGsapKeyframe{animationId, keyframeIndex} — the studio op is - * percentage-based, so we resolve percentage → index against the pre-op - * script (KF_PERCENT_TOLERANCE, aligned with the writer ~0.001) and - * no-op on ambiguity (duplicate-percentage keyframes can't be told - * apart by percentage — landmine from PR #1498). - */ - -import { openComposition } from "@hyperframes/sdk"; -import type { EditOp } from "@hyperframes/sdk"; -import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn"; -import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; -import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; -import { trackStudioEvent } from "./studioTelemetry"; -import type { SdkShadowMismatch } from "./sdkShadow"; -import { - extractGsapScript, - gsapFidelityMismatches, - makeSelectorResolver, -} from "./sdkShadowGsapFidelity"; - -// Match the GSAP writer's percentage equality tolerance so a remove resolves to -// the same keyframe the server would pick (writer rounds to ~3 decimals). -const KF_PERCENT_TOLERANCE = 0.001; - -export type ShadowKeyframeOp = - | { - kind: "add"; - animationId: string; - percentage: number; - properties: Record; - } - | { kind: "remove"; animationId: string; percentage: number }; - -// ─── percentage → SDK op mapping ────────────────────────────────────────────── - -function findAnimationKeyframes( - script: string, - animationId: string, -): GsapPercentageKeyframe[] | null { - const parsed = parseGsapScriptAcorn(script); - // Match the writer's locateAnimationWithFallback (gsapParser.ts): a from/fromTo - // tween's derived id may be normalized to "-to-" on write, so fall back to the - // converted id when the exact one isn't found — otherwise the keyframe diff - // goes blind (both scripts resolve null → falsely "clean") on converted tweens. - const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); - const anim = - parsed.animations.find((a) => a.id === animationId) ?? - parsed.animations.find((a) => a.id === convertedId); - return anim?.keyframes?.keyframes ?? null; -} - -export interface KeyframeRemoveResolution { - /** Resolved 0-based index, or null when it can't be safely resolved. */ - keyframeIndex: number | null; - /** Why no index — for telemetry when keyframeIndex is null. */ - reason?: "no_keyframes" | "not_found" | "ambiguous"; -} - -/** - * Resolve a percentage-based remove to a keyframe index against the pre-op - * script. Returns null index (with a reason) when there are no keyframes, the - * percentage matches none, or — per the PR #1498 landmine — more than one - * keyframe shares the percentage (can't be disambiguated by percentage alone). - * Pure + exported so the mapping is unit-testable without an SDK session. - */ -export function resolveKeyframeIndexByPercentage( - script: string | null | undefined, - animationId: string, - percentage: number, -): KeyframeRemoveResolution { - if (!script) return { keyframeIndex: null, reason: "no_keyframes" }; - const kfs = findAnimationKeyframes(script, animationId); - if (!kfs || kfs.length === 0) return { keyframeIndex: null, reason: "no_keyframes" }; - const matches: number[] = []; - for (let i = 0; i < kfs.length; i++) { - if (Math.abs(kfs[i]?.percentage - percentage) <= KF_PERCENT_TOLERANCE) matches.push(i); - } - if (matches.length === 0) return { keyframeIndex: null, reason: "not_found" }; - if (matches.length > 1) return { keyframeIndex: null, reason: "ambiguous" }; - return { keyframeIndex: matches[0] }; -} - -/** - * Map a studio keyframe op to the SDK EditOp. For a remove this needs the pre-op - * script to resolve percentage → index; returns null (with a reason) when the - * index can't be safely resolved so the caller can emit a no-op-with-reason - * event instead of dispatching the wrong keyframe. - */ -export function keyframeOpToEditOp( - op: ShadowKeyframeOp, - beforeScript: string | null | undefined, -): { op: EditOp } | { op: null; reason: string } { - if (op.kind === "add") { - return { - op: { - type: "addGsapKeyframe", - animationId: op.animationId, - position: op.percentage, - value: op.properties, - }, - }; - } - const resolved = resolveKeyframeIndexByPercentage(beforeScript, op.animationId, op.percentage); - if (resolved.keyframeIndex === null) { - return { op: null, reason: resolved.reason ?? "unresolved" }; - } - return { - op: { - type: "removeGsapKeyframe", - animationId: op.animationId, - keyframeIndex: resolved.keyframeIndex, - }, - }; -} - -// ─── Keyframe-aware fidelity diff ───────────────────────────────────────────── - -function canonicalKeyframe(kf: GsapPercentageKeyframe): string { - const props: Record = {}; - for (const key of Object.keys(kf.properties).sort()) { - const v = kf.properties[key]; - props[key] = - typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v; - } - return JSON.stringify({ pct: Math.round(kf.percentage * 1000) / 1000, ease: kf.ease, props }); -} - -function canonicalKeyframes(kfs: GsapPercentageKeyframe[] | null): string { - if (!kfs) return "[]"; - return JSON.stringify( - [...kfs].sort((a, b) => a.percentage - b.percentage).map(canonicalKeyframe), - ); -} - -/** - * Diff two GSAP scripts for a keyframe op: the reused tween-level diff PLUS a - * keyframe-array comparison for the targeted animation (which the tween-level - * diff doesn't inspect). Reports a `keyframes` value_mismatch when the SDK and - * server keyframe arrays diverge canonically. - */ -export function gsapKeyframeFidelityMismatches( - sdkScript: string, - serverScript: string, - animationId: string, - resolveSelector?: (sel: string) => string, -): SdkShadowMismatch[] { - const mismatches = gsapFidelityMismatches(sdkScript, serverScript, resolveSelector); - const sdkKfs = findAnimationKeyframes(sdkScript, animationId); - const serverKfs = findAnimationKeyframes(serverScript, animationId); - const sdkCanon = canonicalKeyframes(sdkKfs); - const serverCanon = canonicalKeyframes(serverKfs); - if (sdkCanon !== serverCanon) { - mismatches.push({ - kind: "value_mismatch", - hfId: animationId, - property: "keyframes", - expected: serverCanon, - actual: sdkCanon, - }); - } - return mismatches; -} - -// ─── Telemetry runner ───────────────────────────────────────────────────────── - -/** - * Shadow a GSAP keyframe op: open a fresh SDK doc from the server's pre-op file, - * apply the equivalent keyframe op, serialize, and diff against the server's - * resulting script. Emits sdk_shadow_dispatch op: "gsap_keyframe". Async, - * fire-and-forget; server stays authoritative. No-op when shadow is disabled. - */ -export async function runShadowGsapKeyframeFidelity( - beforeHtml: string | null | undefined, - op: ShadowKeyframeOp, - serverScript: string | null | undefined, -): Promise { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - // No server script to diff against → skip the (costly) openComposition. - if (!serverScript || !beforeHtml) return; - const beforeScript = extractGsapScript(beforeHtml); - const mapped = keyframeOpToEditOp(op, beforeScript); - if (mapped.op === null) { - // Ambiguous / not-found percentage: don't dispatch the wrong keyframe. - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_keyframe", - dispatched: false, - reason: mapped.reason, - mismatchCount: 0, - }); - return; - } - const editOp = mapped.op; - try { - const session = await openComposition(beforeHtml); - const verdict = session.can(editOp); - if (!verdict.ok) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_keyframe", - dispatched: false, - reason: "cannot_dispatch", - code: verdict.code, - mismatchCount: 0, - }); - return; - } - session.batch(() => session.dispatch(editOp)); - const sdkScript = extractGsapScript(session.serialize()); - if (sdkScript == null) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_keyframe", - dispatched: false, - reason: "no_sdk_script", - mismatchCount: 0, - }); - return; - } - const mismatches = gsapKeyframeFidelityMismatches( - sdkScript, - serverScript, - op.animationId, - makeSelectorResolver(beforeHtml), - ); - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_keyframe", - dispatched: true, - mismatchCount: mismatches.length, - mismatches: JSON.stringify(mismatches), - }); - } catch (err) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "gsap_keyframe", - dispatched: false, - reason: "fidelity_error", - error: String(err), - mismatchCount: 0, - }); - } -} diff --git a/packages/studio/src/utils/sdkShadowNumeric.ts b/packages/studio/src/utils/sdkShadowNumeric.ts deleted file mode 100644 index bf8ecd136..000000000 --- a/packages/studio/src/utils/sdkShadowNumeric.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Relative-epsilon numeric equality shared by the shadow diffs (timing parity + - * GSAP value fidelity). Both writers round-trip durations/positions through JS - * number formatting, so a value like 3.1 can read back as 3.0999999999999996. - * Treat values within 1e-6 * max(1, |a|, |b|) as equal — tight enough that a - * real 2 vs 1 (or 0.5 vs 0.49) still flags, loose enough to absorb float noise. - */ -export function relEqual(a: number, b: number): boolean { - if (a === b) return true; - return Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(a), Math.abs(b)); -} From 39c2cac62a4bea54a7360d078d6a74b23f640c0d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 11:48:12 -0700 Subject: [PATCH 04/11] fix(studio): wire onTrySdkPersist to sdkCutoverPersist (cutover was unwired) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 7 s7.5 removed the feature flag and declared cutover 'always-on', but onTrySdkPersist was never actually passed to useDomEditCommits — the sdkCutoverPersist function was dead code in production. Thread sdkSession through useDomEditSession params, build the onTrySdkPersist closure there (all CutoverDeps are already in scope), and pass sdkSession from App.tsx. Style/text/attribute/html-attribute commits now route through SDK dispatch instead of the server patch path. Co-authored-by: Miguel Ángel --- packages/studio/src/App.tsx | 1 + .../studio/src/hooks/useDomEditCommits.ts | 21 +++++++++++++++++++ .../studio/src/hooks/useDomEditSession.ts | 13 ++++++++++++ 3 files changed, 35 insertions(+) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index fe789a28c..b1a64ae57 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -302,6 +302,7 @@ export function StudioApp() { openSourceForSelection: fileManager.openSourceForSelection, selectSidebarTab: selectSidebarTabStable, getSidebarTab: getSidebarTabStable, + sdkSession: sdkHandle.session, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 7e97930b0..9292a8c3e 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -77,6 +77,13 @@ export interface UseDomEditCommitsParams { onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void; /** Stage 7 Step 3b: called after a successful server-side element delete. */ onElementDeleted?: (selection: DomEditSelection) => void; + /** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */ + onTrySdkPersist?: ( + selection: DomEditSelection, + operations: PatchOperation[], + originalContent: string, + targetPath: string, + ) => Promise; } export function useDomEditCommits({ @@ -99,6 +106,7 @@ export function useDomEditCommits({ buildDomSelectionFromTarget, onDomEditPersisted, onElementDeleted, + onTrySdkPersist, }: UseDomEditCommitsParams) { const resolveImportedFontAsset = useCallback( (fontFamilyValue: string): ImportedFontAsset | null => { @@ -149,6 +157,18 @@ export function useDomEditCommits({ if (options?.shouldSave && !options.shouldSave()) return; + // Skip the SDK path when prepareContent is set (e.g. @font-face injection + // for a custom font): sdkCutoverPersist serializes only the patched DOM + // and would drop the injected content. Let the server path run prepareContent. + if ( + onTrySdkPersist && + !options?.prepareContent && + (await onTrySdkPersist(selection, operations, originalContent, targetPath)) + ) { + // SDK handled it — its in-memory doc is already current. + return; + } + const patchTarget = buildDomEditPatchTarget(selection); const patchBody = { target: patchTarget, operations }; const unsafeFields = findUnsafeDomPatchValues(patchBody); @@ -235,6 +255,7 @@ export function useDomEditCommits({ reloadPreview, showToast, onDomEditPersisted, + onTrySdkPersist, ], ); diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index df08968f2..d51743483 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -4,6 +4,8 @@ import type { EditHistoryKind } from "../utils/editHistory"; import type { RightPanelTab } from "../utils/studioHelpers"; import type { PatchTarget } from "../utils/sourcePatcher"; import type { SidebarTab } from "../components/sidebar/LeftSidebar"; +import type { Composition } from "@hyperframes/sdk"; +import { sdkCutoverPersist } from "../utils/sdkCutover"; import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; @@ -58,6 +60,7 @@ export interface UseDomEditSessionParams { openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void; selectSidebarTab?: (tab: SidebarTab) => void; getSidebarTab?: () => SidebarTab; + sdkSession?: Composition | null; } // ── Hook ── @@ -96,6 +99,7 @@ export function useDomEditSession({ openSourceForSelection, selectSidebarTab, getSidebarTab, + sdkSession, }: UseDomEditSessionParams) { void _setRefreshKey; void _readProjectFile; @@ -228,6 +232,15 @@ export function useDomEditSession({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, + onTrySdkPersist: sdkSession + ? (selection, operations, originalContent, targetPath) => + sdkCutoverPersist(selection, operations, originalContent, targetPath, sdkSession, { + editHistory, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + }) + : undefined, }); // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ── From 4d77e7f9decfff5eaeb63fc02425ac7f2a6abdc2 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 12:29:21 -0700 Subject: [PATCH 05/11] =?UTF-8?q?feat(studio):=20route=20element=20delete?= =?UTF-8?q?=20through=20SDK=20removeElement=20(=C2=A73.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Miguel Ángel --- .../studio/src/hooks/useDomEditCommits.ts | 8 +-- .../studio/src/hooks/useDomEditSession.ts | 11 ++- .../src/hooks/useElementLifecycleOps.ts | 17 +++++ packages/studio/src/utils/sdkCutover.test.ts | 70 ++++++++++++++++++- packages/studio/src/utils/sdkCutover.ts | 52 +++++++++++--- 5 files changed, 142 insertions(+), 16 deletions(-) diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 9292a8c3e..9d8072492 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -75,8 +75,6 @@ export interface UseDomEditCommitsParams { ) => Promise; /** Stage 7 Step 3b: called after a successful server-side element patch. */ onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void; - /** Stage 7 Step 3b: called after a successful server-side element delete. */ - onElementDeleted?: (selection: DomEditSelection) => void; /** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */ onTrySdkPersist?: ( selection: DomEditSelection, @@ -84,6 +82,8 @@ export interface UseDomEditCommitsParams { originalContent: string, targetPath: string, ) => Promise; + /** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */ + onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise; } export function useDomEditCommits({ @@ -105,8 +105,8 @@ export function useDomEditCommits({ refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, onDomEditPersisted, - onElementDeleted, onTrySdkPersist, + onTrySdkDelete, }: UseDomEditCommitsParams) { const resolveImportedFontAsset = useCallback( (fontFamilyValue: string): ImportedFontAsset | null => { @@ -316,8 +316,8 @@ export function useDomEditCommits({ projectIdRef, reloadPreview, clearDomSelection, + onTrySdkDelete, commitPositionPatchToHtml, - onElementDeleted, }); return { diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index d51743483..b22332c4a 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -5,7 +5,7 @@ import type { RightPanelTab } from "../utils/studioHelpers"; import type { PatchTarget } from "../utils/sourcePatcher"; import type { SidebarTab } from "../components/sidebar/LeftSidebar"; import type { Composition } from "@hyperframes/sdk"; -import { sdkCutoverPersist } from "../utils/sdkCutover"; +import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover"; import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; @@ -241,6 +241,15 @@ export function useDomEditSession({ domEditSaveTimestampRef, }) : undefined, + onTrySdkDelete: sdkSession + ? (hfId, originalContent, targetPath) => + sdkDeletePersist(hfId, originalContent, targetPath, sdkSession, { + editHistory, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + }) + : undefined, }); // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ── diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index a30c5bb03..fe1dc3a2f 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -26,6 +26,8 @@ interface UseElementLifecycleOpsParams { projectIdRef: React.MutableRefObject; reloadPreview: () => void; clearDomSelection: () => void; + /** Route delete through SDK when session resolves the hf-id; returns true if handled. */ + onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise; commitPositionPatchToHtml: ( selection: DomEditSelection, patches: PatchOperation[], @@ -44,6 +46,7 @@ export function useElementLifecycleOps({ projectIdRef, reloadPreview, clearDomSelection, + onTrySdkDelete, commitPositionPatchToHtml, onElementDeleted, }: UseElementLifecycleOpsParams) { @@ -74,6 +77,16 @@ export function useElementLifecycleOps({ throw new Error("Selected element has no patchable target"); } + if (onTrySdkDelete && selection.hfId) { + const handled = await onTrySdkDelete(selection.hfId, originalContent, targetPath); + if (handled) { + clearDomSelection(); + usePlayerStore.getState().setSelectedElementId(null); + showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); + return; + } + } + domEditSaveTimestampRef.current = Date.now(); const removeResponse = await fetch( `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, @@ -118,6 +131,7 @@ export function useElementLifecycleOps({ clearDomSelection, domEditSaveTimestampRef, editHistory.recordEdit, + onTrySdkDelete, onElementDeleted, projectIdRef, reloadPreview, @@ -126,6 +140,9 @@ export function useElementLifecycleOps({ ], ); + // ponytail: z-index reorder writes inline-style patches via commitPositionPatchToHtml → + // persistDomEditOperations → onTrySdkPersist, so it is already SDK-cut-over as setStyle. + // No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed. const handleDomZIndexReorderCommit = useCallback( ( entries: Array<{ diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 489113cbe..397c7dd5a 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { shouldUseSdkCutover, sdkCutoverPersist } from "./sdkCutover"; +import { shouldUseSdkCutover, sdkCutoverPersist, sdkDeletePersist } from "./sdkCutover"; import { openComposition } from "@hyperframes/sdk"; import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory"; import type { PatchOperation } from "./sourcePatcher"; @@ -298,6 +298,74 @@ describe("sdkCutoverPersist", () => { }); }); +describe("sdkDeletePersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + const makeSession = (hasEl = true) => + ({ + getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null), + removeElement: vi.fn(), + serialize: vi.fn().mockReturnValue("after"), + }) as unknown as Parameters[3]; + + it("returns false when session is null", async () => { + expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).toBe(false); + }); + + it("returns false when element not found in session", async () => { + const session = makeSession(false); + expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).toBe( + false, + ); + }); + + it("calls removeElement and writes serialized content", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps); + expect(result).toBe(true); + expect(session!.removeElement).toHaveBeenCalledWith("hf-abc"); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after"); + }); + + it("records edit history with before/after diff", async () => { + const deps = makeDeps(); + const session = makeSession(true); + await sdkDeletePersist("hf-abc", "before-content", "/comp.html", session, deps); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ + label: "Delete element", + files: { "/comp.html": { before: "before-content", after: "after" } }, + }), + ); + }); + + it("calls reloadPreview on success", async () => { + const deps = makeDeps(); + const session = makeSession(true); + await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps); + expect(deps.reloadPreview).toHaveBeenCalled(); + }); + + it("returns false and does not write on removeElement error", async () => { + const deps = makeDeps(); + const session = makeSession(true); + (session!.removeElement as ReturnType).mockImplementation(() => { + throw new Error("remove failed"); + }); + const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + }); +}); + describe("sdkCutoverPersist — GSAP script preservation (integration)", () => { const makeRef = (val: T): MutableRefObject => ({ current: val }); const makeDeps = () => ({ diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 19fd0dfd1..a2f3a155f 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -83,6 +83,26 @@ interface CutoverOptions { coalesceKey?: string; } +// ponytail: internal; export only if a third caller appears +async function persistSdkSerialize( + sdkSession: Composition, + targetPath: string, + originalContent: string, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + const after = sdkSession.serialize(); + deps.domEditSaveTimestampRef.current = Date.now(); + await deps.writeProjectFile(targetPath, after); + await deps.editHistory.recordEdit({ + label: options?.label ?? "Edit layer", + kind: "manual", + ...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}), + files: { [targetPath]: { before: originalContent, after } }, + }); + deps.reloadPreview(); +} + export async function sdkCutoverPersist( selection: DomEditSelection, ops: PatchOperation[], @@ -104,16 +124,7 @@ export async function sdkCutoverPersist( sdkSession.dispatch(editOp); } }); - const after = sdkSession.serialize(); - deps.domEditSaveTimestampRef.current = Date.now(); - await deps.writeProjectFile(targetPath, after); - await deps.editHistory.recordEdit({ - label: options?.label ?? "Edit layer", - kind: "manual", - ...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}), - files: { [targetPath]: { before: originalContent, after } }, - }); - deps.reloadPreview(); + await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, options); trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length }); return true; } catch (err) { @@ -124,3 +135,24 @@ export async function sdkCutoverPersist( return false; } } + +export async function sdkDeletePersist( + hfId: string, + originalContent: string, + targetPath: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, +): Promise { + if (!sdkSession || !sdkSession.getElement(hfId)) return false; + try { + sdkSession.removeElement(hfId); + await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, { + label: "Delete element", + }); + trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) }); + return false; + } +} From 0cd9a68b90cb20f0a39764611793b2295d7d1fe1 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 13:07:47 -0700 Subject: [PATCH 06/11] =?UTF-8?q?feat(studio):=20route=20timeline=20trim/m?= =?UTF-8?q?ove=20through=20SDK=20setTiming=20(=C2=A73.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Miguel Ángel --- packages/studio/src/App.tsx | 2 +- .../studio/src/hooks/useTimelineEditing.ts | 120 +++++++++++------- packages/studio/src/utils/sdkCutover.test.ts | 81 +++++++++++- packages/studio/src/utils/sdkCutover.ts | 21 +++ 4 files changed, 173 insertions(+), 51 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index b1a64ae57..808092843 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -187,6 +187,7 @@ export function StudioApp() { pendingTimelineEditPathRef, uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, + sdkSession: sdkHandle.session, }); const { activeBlockParams, @@ -357,7 +358,6 @@ export function StudioApp() { resetErrors: resetConsoleErrors, } = useConsoleErrorCapture(previewIframe); const dragOverlay = useDragOverlay(fileManager.handleImportFiles); - // Gesture recording const handleToggleRecordingRef = useRef<() => void>(() => {}); const domEditSessionRef = useRef(domEditSession); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 66d1d6480..f2b2ebc39 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -29,10 +29,10 @@ import { readFileContent, applyPatchByTarget, formatTimelineAttributeNumber, - shiftGsapPositions, - scaleGsapPositions, } from "./timelineEditingHelpers"; import type { PersistTimelineEditInput } from "./timelineEditingHelpers"; +import { sdkTimingPersist } from "../utils/sdkCutover"; +import type { Composition } from "@hyperframes/sdk"; // ── Types ── @@ -56,6 +56,8 @@ interface UseTimelineEditingOptions { pendingTimelineEditPathRef: React.MutableRefObject>; uploadProjectFiles: (files: Iterable, dir?: string) => Promise; isRecordingRef?: React.RefObject; + /** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */ + sdkSession?: Composition | null; } // ── Hook ── @@ -73,6 +75,7 @@ export function useTimelineEditing({ pendingTimelineEditPathRef, uploadProjectFiles, isRecordingRef, + sdkSession, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; @@ -121,15 +124,16 @@ export function useTimelineEditing({ ], ); + // fallow-ignore-next-line complexity const handleTimelineElementMove = useCallback( + // fallow-ignore-next-line complexity (element: TimelineElement, updates: Pick) => { patchIframeDomTiming(previewIframeRef.current, element, [ ["data-start", formatTimelineAttributeNumber(updates.start)], ["data-track-index", String(updates.track)], ]); - const delta = updates.start - element.start; - const filePath = element.sourceFile || activeCompPath || "index.html"; - return enqueueEdit(element, "Move timeline clip", (original, target) => { + const targetPath = element.sourceFile || activeCompPath || "index.html"; + const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { let patched = applyPatchByTarget(original, target, { type: "attribute", property: "start", @@ -140,39 +144,46 @@ export function useTimelineEditing({ property: "track-index", value: String(updates.track), }); - }).then(() => { - const pid = projectIdRef.current; - if (delta !== 0 && element.domId && pid) { - return shiftGsapPositions(pid, filePath, element.domId, delta) - .then(() => reloadPreview()) - .catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err)); - } - }); + }; + if (sdkSession && element.hfId) { + return sdkTimingPersist( + element.hfId, + targetPath, + { start: updates.start, trackIndex: updates.track }, + sdkSession, + { editHistory: { recordEdit }, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { label: "Move timeline clip", coalesceKey: `timeline-move:${element.hfId}` }, + ).then((handled) => { + if (!handled) return enqueueEdit(element, "Move timeline clip", buildMovePatches); + }); + } + return enqueueEdit(element, "Move timeline clip", buildMovePatches); }, - [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview], + [ + previewIframeRef, + enqueueEdit, + activeCompPath, + sdkSession, + recordEdit, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + ], ); + // fallow-ignore-next-line complexity const handleTimelineElementResize = useCallback( + // fallow-ignore-next-line complexity ( element: TimelineElement, updates: Pick, ) => { - const liveAttrs: Array<[string, string]> = [ + patchIframeDomTiming(previewIframeRef.current, element, [ ["data-start", formatTimelineAttributeNumber(updates.start)], ["data-duration", formatTimelineAttributeNumber(updates.duration)], - ]; - if (updates.playbackStart != null) { - const liveAttr = - element.playbackStartAttr === "playback-start" - ? "data-playback-start" - : "data-media-start"; - liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]); - } - patchIframeDomTiming(previewIframeRef.current, element, liveAttrs); - const filePath = element.sourceFile || activeCompPath || "index.html"; - const timingChanged = - updates.start !== element.start || updates.duration !== element.duration; - return enqueueEdit(element, "Resize timeline clip", (original, target) => { + ]); + const targetPath = element.sourceFile || activeCompPath || "index.html"; + const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { const pbs = resolveResizePlaybackStart(original, target, element, updates); let patched = applyPatchByTarget(original, target, { type: "attribute", @@ -192,29 +203,40 @@ export function useTimelineEditing({ }); } return patched; - }).then(() => { - const pid = projectIdRef.current; - if (timingChanged && element.domId && pid) { - return scaleGsapPositions( - pid, - filePath, - element.domId, - element.start, - element.duration, - updates.start, - updates.duration, - ) - .then(() => reloadPreview()) - .catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err)); - } - return reloadPreview(); - }); + }; + // SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field). + // Condition: no explicit pbs override AND (no start change OR element has no pbs attribute). + const hasPbsAdjustment = + updates.playbackStart != null || + (updates.start !== element.start && element.playbackStart != null); + if (sdkSession && element.hfId && !hasPbsAdjustment) { + return sdkTimingPersist( + element.hfId, + targetPath, + { start: updates.start, duration: updates.duration }, + sdkSession, + { editHistory: { recordEdit }, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { label: "Resize timeline clip", coalesceKey: `timeline-resize:${element.hfId}` }, + ).then((handled) => { + if (!handled) return enqueueEdit(element, "Resize timeline clip", buildResizePatches); + }); + } + return enqueueEdit(element, "Resize timeline clip", buildResizePatches); }, - [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview], + [ + previewIframeRef, + enqueueEdit, + activeCompPath, + sdkSession, + recordEdit, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + ], ); + // fallow-ignore-next-line complexity const handleTimelineElementDelete = useCallback( - // Pre-existing handler complexity, unchanged by this PR. // fallow-ignore-next-line complexity async (element: TimelineElement) => { if (isRecordingRef?.current) { @@ -289,8 +311,8 @@ export function useTimelineEditing({ ], ); + // fallow-ignore-next-line complexity const handleTimelineAssetDrop = useCallback( - // Pre-existing handler complexity, unchanged by this PR. // fallow-ignore-next-line complexity async ( assetPath: string, @@ -373,8 +395,8 @@ export function useTimelineEditing({ ], ); + // fallow-ignore-next-line complexity const handleTimelineFileDrop = useCallback( - // Pre-existing handler complexity, unchanged by this PR. // fallow-ignore-next-line complexity async (files: File[], placement?: Pick) => { if (isRecordingRef?.current) { diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 397c7dd5a..69e1a0d2e 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { shouldUseSdkCutover, sdkCutoverPersist, sdkDeletePersist } from "./sdkCutover"; +import { + shouldUseSdkCutover, + sdkCutoverPersist, + sdkDeletePersist, + sdkTimingPersist, +} from "./sdkCutover"; import { openComposition } from "@hyperframes/sdk"; import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory"; import type { PatchOperation } from "./sourcePatcher"; @@ -366,6 +371,80 @@ describe("sdkDeletePersist", () => { }); }); +describe("sdkTimingPersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + const makeSession = (hasEl = true) => + ({ + getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-clip" } : null), + setTiming: vi.fn(), + serialize: vi + .fn() + .mockReturnValueOnce("before") + .mockReturnValue("after"), + }) as unknown as Parameters[3]; + + it("returns false when session is null", async () => { + expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, null, makeDeps())).toBe( + false, + ); + }); + + it("returns false when element not found in session", async () => { + const session = makeSession(false); + expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, makeDeps())).toBe( + false, + ); + }); + + it("calls setTiming with provided update and writes serialized content", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const result = await sdkTimingPersist( + "hf-clip", + "/comp.html", + { start: 2, duration: 5, trackIndex: 1 }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.setTiming).toHaveBeenCalledWith("hf-clip", { + start: 2, + duration: 5, + trackIndex: 1, + }); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after"); + }); + + it("captures before-state before setTiming dispatch", async () => { + const deps = makeDeps(); + const session = makeSession(true); + await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ + files: { "/comp.html": { before: "before", after: "after" } }, + }), + ); + }); + + it("returns false and does not write on setTiming error", async () => { + const deps = makeDeps(); + const session = makeSession(true); + (session!.setTiming as ReturnType).mockImplementation(() => { + throw new Error("timing error"); + }); + const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, deps); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + }); +}); + describe("sdkCutoverPersist — GSAP script preservation (integration)", () => { const makeRef = (val: T): MutableRefObject => ({ current: val }); const makeDeps = () => ({ diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index a2f3a155f..40e5a9b9a 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -136,6 +136,27 @@ export async function sdkCutoverPersist( } } +export async function sdkTimingPersist( + hfId: string, + targetPath: string, + timingUpdate: { start?: number; duration?: number; trackIndex?: number }, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + if (!sdkSession || !sdkSession.getElement(hfId)) return false; + try { + const before = sdkSession.serialize(); + sdkSession.setTiming(hfId, timingUpdate); + await persistSdkSerialize(sdkSession, targetPath, before, deps, options); + trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) }); + return false; + } +} + export async function sdkDeletePersist( hfId: string, originalContent: string, From 2fcffe61d363dbf8bbb0c5ac6145e3f5526a6671 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 13:09:10 -0700 Subject: [PATCH 07/11] =?UTF-8?q?chore(studio):=20document=20CSS-path=20po?= =?UTF-8?q?sition=20cut-over,=20GSAP-path=20intentionally=20deferred=20(?= =?UTF-8?q?=C2=A73.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Miguel Ángel --- packages/studio/src/hooks/useDomGeometryCommits.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/studio/src/hooks/useDomGeometryCommits.ts b/packages/studio/src/hooks/useDomGeometryCommits.ts index 42d0fc2a8..ffe3822da 100644 --- a/packages/studio/src/hooks/useDomGeometryCommits.ts +++ b/packages/studio/src/hooks/useDomGeometryCommits.ts @@ -42,15 +42,11 @@ export function useDomGeometryCommits({ }: UseDomGeometryCommitsParams) { const handleDomPathOffsetCommit = useCallback( (selection: DomEditSelection, next: { x: number; y: number }) => { - const gsapBlocked = isElementGsapTargeted(previewIframeRef.current, selection.element); - console.log( - "[drag:7] handleDomPathOffsetCommit (CSS path)", - JSON.stringify({ - sel: selection.id, - gsapBlocked, - }), - ); - if (gsapBlocked) { + // ponytail: GSAP-targeted elements are blocked (no SDK position-in-script op); CSS-path + // elements fall through to commitPositionPatchToHtml → persistDomEditOperations → + // onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done). + // Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR. + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); showToast(error.message, "error"); return Promise.reject(error); From 37a7cefd4fb05bae605d62f27f3b9bce1d257edb Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 13:27:32 -0700 Subject: [PATCH 08/11] =?UTF-8?q?feat(studio):=20route=20GSAP=20tween=20ad?= =?UTF-8?q?d/update/delete=20through=20SDK=20(=C2=A73.5=20PR1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Miguel Ángel --- .../studio/src/hooks/gsapScriptCommitTypes.ts | 4 + .../studio/src/hooks/useDomEditSession.ts | 2 + .../studio/src/hooks/useGsapAnimationOps.ts | 132 +++++++++++- .../src/hooks/useGsapPropertyDebounce.ts | 192 +++++++++++++++--- .../studio/src/hooks/useGsapScriptCommits.ts | 24 ++- packages/studio/src/utils/sdkCutover.test.ts | 111 ++++++++++ packages/studio/src/utils/sdkCutover.ts | 34 +++- 7 files changed, 460 insertions(+), 39 deletions(-) diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index 20f0565e8..db24652de 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -1,4 +1,5 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser"; +import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { EditHistoryKind } from "../utils/editHistory"; @@ -63,4 +64,7 @@ export interface GsapScriptCommitsParams { onCacheInvalidate: () => void; onFileContentChanged?: (path: string, content: string) => void; showToast: (message: string, tone?: "error" | "info") => void; + /** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */ + sdkSession?: Composition | null; + writeProjectFile?: (path: string, content: string) => Promise; } diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index b22332c4a..06bb9c85c 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -193,6 +193,8 @@ export function useDomEditSession({ onCacheInvalidate: bumpGsapCache, onFileContentChanged: updateEditingFileContent, showToast, + sdkSession, + writeProjectFile, }); // ── DOM commit handlers ── diff --git a/packages/studio/src/hooks/useGsapAnimationOps.ts b/packages/studio/src/hooks/useGsapAnimationOps.ts index b0e253e18..07898a323 100644 --- a/packages/studio/src/hooks/useGsapAnimationOps.ts +++ b/packages/studio/src/hooks/useGsapAnimationOps.ts @@ -1,13 +1,31 @@ import { useCallback } from "react"; +import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { roundTo3 } from "../utils/rounding"; +import { sdkGsapTweenPersist } from "../utils/sdkCutover"; import { assignGsapTargetAutoIdIfNeeded, ensureElementAddressable, } from "./gsapScriptCommitHelpers"; import type { CommitMutation, SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; +import type { EditHistoryKind } from "../utils/editHistory"; -interface GsapAnimationOpsParams { +interface SdkAnimationDeps { + sdkSession?: Composition | null; + writeProjectFile?: (path: string, content: string) => Promise; + editHistory?: { + recordEdit: (entry: { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; + }) => Promise; + }; + reloadPreview?: () => void; + domEditSaveTimestampRef?: React.MutableRefObject; +} + +interface GsapAnimationOpsParams extends SdkAnimationDeps { projectIdRef: React.MutableRefObject; activeCompPath: string | null; commitMutation: CommitMutation; @@ -21,39 +39,91 @@ export function useGsapAnimationOps({ commitMutation, commitMutationSafely, showToast, + sdkSession, + writeProjectFile, + editHistory, + reloadPreview, + domEditSaveTimestampRef, }: GsapAnimationOpsParams) { const updateGsapMeta = useCallback( - ( + async ( selection: DomEditSelection, animationId: string, updates: { duration?: number; ease?: string; position?: number }, ) => { - // coalesceKey groups rapid meta edits into one history entry. Request - // serialization is now handled per-file at the commitMutation chokepoint - // (useGsapScriptCommits), so no per-op serializeKey is needed here. - const metaKey = `gsap:${animationId}:meta`; + if ( + sdkSession && + writeProjectFile && + editHistory && + reloadPreview && + domEditSaveTimestampRef + ) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "set", animationId, properties: updates }, + sdkSession, + { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "update-meta", animationId, updates }, - { label: "Edit GSAP animation", coalesceKey: metaKey }, + { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` }, ); }, - [commitMutationSafely], + [ + commitMutationSafely, + activeCompPath, + sdkSession, + writeProjectFile, + editHistory, + reloadPreview, + domEditSaveTimestampRef, + ], ); const deleteGsapAnimation = useCallback( - (selection: DomEditSelection, animationId: string) => { + async (selection: DomEditSelection, animationId: string) => { + if ( + sdkSession && + writeProjectFile && + editHistory && + reloadPreview && + domEditSaveTimestampRef + ) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "remove", animationId }, + sdkSession, + { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { label: "Delete GSAP animation" }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "delete", animationId, stripStudioEdits: true }, { label: "Delete GSAP animation" }, ); }, - [commitMutationSafely], + [ + commitMutationSafely, + activeCompPath, + sdkSession, + writeProjectFile, + editHistory, + reloadPreview, + domEditSaveTimestampRef, + ], ); const deleteAllForSelector = useCallback( (selection: DomEditSelection, targetSelector: string) => { + // ponytail: no SDK op for delete-all-for-selector; stays server-authoritative void commitMutation( selection, { type: "delete-all-for-selector", targetSelector }, @@ -63,6 +133,7 @@ export function useGsapAnimationOps({ [commitMutation], ); + // fallow-ignore-next-line complexity const addGsapAnimation = useCallback( // fallow-ignore-next-line complexity async ( @@ -97,6 +168,35 @@ export function useGsapAnimationOps({ fromTo: { x: 0, y: 0, opacity: 1 }, }; + // SDK path: addGsapTween only supports from/to/fromTo; "set" stays server-side + if ( + method !== "set" && + selection.hfId && + sdkSession && + writeProjectFile && + editHistory && + reloadPreview && + domEditSaveTimestampRef + ) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const spec = { + method: method as "to" | "from" | "fromTo", + position, + duration, + ease: "power2.out" as const, + properties: toDefaults[method] ?? { opacity: 1 }, + fromProperties: method === "fromTo" ? { opacity: 0 } : undefined, + }; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "add", target: selection.hfId, spec }, + sdkSession, + { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { label: `Add GSAP ${method} animation` }, + ); + if (handled) return; + } + await commitMutation( selection, { @@ -112,7 +212,17 @@ export function useGsapAnimationOps({ { label: `Add GSAP ${method} animation` }, ); }, - [activeCompPath, commitMutation, projectIdRef, showToast], + [ + activeCompPath, + commitMutation, + projectIdRef, + showToast, + sdkSession, + writeProjectFile, + editHistory, + reloadPreview, + domEditSaveTimestampRef, + ], ); return { diff --git a/packages/studio/src/hooks/useGsapPropertyDebounce.ts b/packages/studio/src/hooks/useGsapPropertyDebounce.ts index 26f00e2a2..103b9892d 100644 --- a/packages/studio/src/hooks/useGsapPropertyDebounce.ts +++ b/packages/studio/src/hooks/useGsapPropertyDebounce.ts @@ -1,11 +1,33 @@ import { useCallback, useEffect, useRef } from "react"; +import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { sdkGsapTweenPersist } from "../utils/sdkCutover"; import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers"; import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; +import type { EditHistoryKind } from "../utils/editHistory"; const DEBOUNCE_MS = 150; -export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMutation) { +interface SdkPropertyDeps { + sdkSession?: Composition | null; + writeProjectFile?: (path: string, content: string) => Promise; + editHistory?: { + recordEdit: (entry: { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; + }) => Promise; + }; + reloadPreview?: () => void; + domEditSaveTimestampRef?: React.MutableRefObject; + activeCompPath?: string | null; +} + +export function useGsapPropertyDebounce( + commitMutationSafely: SafeGsapCommitMutation, + sdkDeps?: SdkPropertyDeps, +) { const pendingPropertyEditRef = useRef<{ selection: DomEditSelection; animationId: string; @@ -14,21 +36,51 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta } | null>(null); const debounceTimerRef = useRef | null>(null); - const flushPendingPropertyEdit = useCallback(() => { - const pending = pendingPropertyEditRef.current; - if (!pending) return; - pendingPropertyEditRef.current = null; - const { selection, animationId, property, value } = pending; - commitMutationSafely( - selection, - { type: "update-property", animationId, property, value }, - { - label: `Edit GSAP ${property}`, - coalesceKey: `gsap:${animationId}:${property}`, - softReload: true, - }, - ); - }, [commitMutationSafely]); + // fallow-ignore-next-line complexity + const flushPendingPropertyEdit = useCallback( + // fallow-ignore-next-line complexity + async () => { + const pending = pendingPropertyEditRef.current; + if (!pending) return; + pendingPropertyEditRef.current = null; + const { selection, animationId, property, value } = pending; + const { + sdkSession, + writeProjectFile, + editHistory, + reloadPreview, + domEditSaveTimestampRef, + activeCompPath, + } = sdkDeps ?? {}; + if ( + sdkSession && + writeProjectFile && + editHistory && + reloadPreview && + domEditSaveTimestampRef + ) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "set", animationId, properties: { properties: { [property]: value } } }, + sdkSession, + { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` }, + ); + if (handled) return; + } + commitMutationSafely( + selection, + { type: "update-property", animationId, property, value }, + { + label: `Edit GSAP ${property}`, + coalesceKey: `gsap:${animationId}:${property}`, + softReload: true, + }, + ); + }, + [commitMutationSafely, sdkDeps], + ); const updateGsapProperty = useCallback( ( @@ -39,7 +91,9 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta ) => { pendingPropertyEditRef.current = { selection, animationId, property, value }; if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = setTimeout(flushPendingPropertyEdit, DEBOUNCE_MS); + debounceTimerRef.current = setTimeout(() => { + void flushPendingPropertyEdit(); + }, DEBOUNCE_MS); }, [flushPendingPropertyEdit], ); @@ -47,12 +101,14 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta useEffect(() => { return () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); - flushPendingPropertyEdit(); + void flushPendingPropertyEdit(); }; }, [flushPendingPropertyEdit]); + // fallow-ignore-next-line complexity const addGsapProperty = useCallback( - (selection: DomEditSelection, animationId: string, property: string) => { + // fallow-ignore-next-line complexity + async (selection: DomEditSelection, animationId: string, property: string) => { let defaultValue = PROPERTY_DEFAULTS[property] ?? 0; const el = selection.element; if (property === "width" || property === "height") { @@ -62,17 +118,43 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta const cs = el.ownerDocument.defaultView?.getComputedStyle(el); defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1; } + const { + sdkSession, + writeProjectFile, + editHistory, + reloadPreview, + domEditSaveTimestampRef, + activeCompPath, + } = sdkDeps ?? {}; + if ( + sdkSession && + writeProjectFile && + editHistory && + reloadPreview && + domEditSaveTimestampRef + ) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "set", animationId, properties: { properties: { [property]: defaultValue } } }, + sdkSession, + { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { label: `Add GSAP ${property}` }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "add-property", animationId, property, defaultValue }, { label: `Add GSAP ${property}` }, ); }, - [commitMutationSafely], + [commitMutationSafely, sdkDeps], ); const removeGsapProperty = useCallback( (selection: DomEditSelection, animationId: string, property: string) => { + // ponytail: null ≠ removal in upsertProp; remove-property stays server-authoritative commitMutationSafely( selection, { type: "remove-property", animationId, property }, @@ -82,13 +164,43 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta [commitMutationSafely], ); + // fallow-ignore-next-line complexity const updateGsapFromProperty = useCallback( - ( + // fallow-ignore-next-line complexity + async ( selection: DomEditSelection, animationId: string, property: string, value: number | string, ) => { + const { + sdkSession, + writeProjectFile, + editHistory, + reloadPreview, + domEditSaveTimestampRef, + activeCompPath, + } = sdkDeps ?? {}; + if ( + sdkSession && + writeProjectFile && + editHistory && + reloadPreview && + domEditSaveTimestampRef + ) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "set", animationId, properties: { fromProperties: { [property]: value } } }, + sdkSession, + { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { + label: `Edit GSAP from-${property}`, + coalesceKey: `gsap:${animationId}:from:${property}`, + }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "update-from-property", animationId, property, value }, @@ -98,23 +210,55 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta }, ); }, - [commitMutationSafely], + [commitMutationSafely, sdkDeps], ); + // fallow-ignore-next-line complexity const addGsapFromProperty = useCallback( - (selection: DomEditSelection, animationId: string, property: string) => { + // fallow-ignore-next-line complexity + async (selection: DomEditSelection, animationId: string, property: string) => { const defaultValue = PROPERTY_DEFAULTS[property] ?? 0; + const { + sdkSession, + writeProjectFile, + editHistory, + reloadPreview, + domEditSaveTimestampRef, + activeCompPath, + } = sdkDeps ?? {}; + if ( + sdkSession && + writeProjectFile && + editHistory && + reloadPreview && + domEditSaveTimestampRef + ) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { + kind: "set", + animationId, + properties: { fromProperties: { [property]: defaultValue } }, + }, + sdkSession, + { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { label: `Add GSAP from-${property}` }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "add-from-property", animationId, property, defaultValue }, { label: `Add GSAP from-${property}` }, ); }, - [commitMutationSafely], + [commitMutationSafely, sdkDeps], ); const removeGsapFromProperty = useCallback( (selection: DomEditSelection, animationId: string, property: string) => { + // ponytail: null ≠ removal in upsertProp; remove-from-property stays server-authoritative commitMutationSafely( selection, { type: "remove-from-property", animationId, property }, diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 6c63669ab..7b092737e 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -44,7 +44,7 @@ async function mutateGsapScript( // oxfmt-ignore // fallow-ignore-next-line complexity -export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast }: GsapScriptCommitsParams) { +export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile }: GsapScriptCommitsParams) { // Serializer for per-key commits (options.serializeKey). Keyed by // `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for // the same animationId so their POSTs can't interleave. Held in a ref so the @@ -98,8 +98,26 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra ); const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath); const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast); - const propertyOps = useGsapPropertyDebounce(commitMutationSafely); - const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast }); + const propertyOps = useGsapPropertyDebounce(commitMutationSafely, { + sdkSession, + writeProjectFile, + editHistory, + reloadPreview, + domEditSaveTimestampRef, + activeCompPath, + }); + const animationOps = useGsapAnimationOps({ + projectIdRef, + activeCompPath, + commitMutation, + commitMutationSafely, + showToast, + sdkSession, + writeProjectFile, + editHistory, + reloadPreview, + domEditSaveTimestampRef, + }); const keyframeOps = useGsapKeyframeOps({ activeCompPath, commitMutation, commitMutationSafely, trackGsapSaveFailure }); const arcPathOps = useGsapArcPathOps(commitMutationSafely); return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps }; diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 69e1a0d2e..6694ec6d5 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -4,6 +4,7 @@ import { sdkCutoverPersist, sdkDeletePersist, sdkTimingPersist, + sdkGsapTweenPersist, } from "./sdkCutover"; import { openComposition } from "@hyperframes/sdk"; import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory"; @@ -445,6 +446,116 @@ describe("sdkTimingPersist", () => { }); }); +describe("sdkGsapTweenPersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + const makeSession = (opts?: { addGsapTween?: string; hasEl?: boolean }) => + ({ + getElement: vi.fn().mockReturnValue(opts?.hasEl !== false ? { id: "hf-box" } : null), + addGsapTween: vi.fn().mockReturnValue(opts?.addGsapTween ?? "tw-1"), + setGsapTween: vi.fn(), + removeGsapTween: vi.fn(), + serialize: vi + .fn() + .mockReturnValueOnce("before") + .mockReturnValue("after"), + }) as unknown as Parameters[2]; + + it("returns false when session is null", async () => { + expect( + await sdkGsapTweenPersist( + "/comp.html", + { kind: "remove", animationId: "tw-1" }, + null, + makeDeps(), + ), + ).toBe(false); + }); + + it("calls addGsapTween and writes for kind=add", async () => { + const deps = makeDeps(); + const session = makeSession(); + const result = await sdkGsapTweenPersist( + "/comp.html", + { + kind: "add", + target: "hf-box", + spec: { method: "to", duration: 1, properties: { opacity: 1 } }, + }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.addGsapTween).toHaveBeenCalledWith( + "hf-box", + expect.objectContaining({ method: "to" }), + ); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after"); + }); + + it("returns false for kind=add when element not found", async () => { + const deps = makeDeps(); + const session = makeSession({ hasEl: false }); + const result = await sdkGsapTweenPersist( + "/comp.html", + { kind: "add", target: "hf-box", spec: { method: "to", properties: { x: 100 } } }, + session, + deps, + ); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + }); + + it("calls setGsapTween and writes for kind=set", async () => { + const deps = makeDeps(); + const session = makeSession(); + const result = await sdkGsapTweenPersist( + "/comp.html", + { kind: "set", animationId: "tw-1", properties: { ease: "power3.in" } }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.setGsapTween).toHaveBeenCalledWith("tw-1", { ease: "power3.in" }); + expect(deps.reloadPreview).toHaveBeenCalled(); + }); + + it("calls removeGsapTween for kind=remove", async () => { + const deps = makeDeps(); + const session = makeSession(); + const result = await sdkGsapTweenPersist( + "/comp.html", + { kind: "remove", animationId: "tw-1" }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.removeGsapTween).toHaveBeenCalledWith("tw-1"); + }); + + it("returns false and does not write on SDK error", async () => { + const deps = makeDeps(); + const session = makeSession(); + (session!.removeGsapTween as ReturnType).mockImplementation(() => { + throw new Error("gsap error"); + }); + const result = await sdkGsapTweenPersist( + "/comp.html", + { kind: "remove", animationId: "tw-1" }, + session, + deps, + ); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + }); +}); + describe("sdkCutoverPersist — GSAP script preservation (integration)", () => { const makeRef = (val: T): MutableRefObject => ({ current: val }); const makeDeps = () => ({ diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 40e5a9b9a..84c715ee2 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -1,5 +1,5 @@ import type { MutableRefObject } from "react"; -import type { Composition, EditOp } from "@hyperframes/sdk"; +import type { Composition, EditOp, GsapTweenSpec } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditing"; import type { EditHistoryKind } from "./editHistory"; import type { PatchOperation } from "./sourcePatcher"; @@ -157,6 +157,38 @@ export async function sdkTimingPersist( } } +type SdkGsapTweenOp = + | { kind: "add"; target: string; spec: GsapTweenSpec } + | { kind: "set"; animationId: string; properties: Partial } + | { kind: "remove"; animationId: string }; + +export async function sdkGsapTweenPersist( + targetPath: string, + op: SdkGsapTweenOp, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + if (!sdkSession) return false; + try { + const before = sdkSession.serialize(); + if (op.kind === "add") { + if (!sdkSession.getElement(op.target)) return false; + sdkSession.addGsapTween(op.target, op.spec); + } else if (op.kind === "set") { + sdkSession.setGsapTween(op.animationId, op.properties); + } else { + sdkSession.removeGsapTween(op.animationId); + } + await persistSdkSerialize(sdkSession, targetPath, before, deps, options); + trackStudioEvent("sdk_cutover_success", { opCount: 1 }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { error: String(err) }); + return false; + } +} + export async function sdkDeletePersist( hfId: string, originalContent: string, From ec669750f80356e2dc58900f1446f120e43ee3c8 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 13:33:48 -0700 Subject: [PATCH 09/11] =?UTF-8?q?feat(studio):=20route=20GSAP=20keyframe?= =?UTF-8?q?=20add=20through=20SDK=20(=C2=A73.5=20PR2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Miguel Ángel --- .../core/src/parsers/gsapWriter.acorn.test.ts | 8 ++ packages/core/src/parsers/gsapWriterAcorn.ts | 12 ++- .../studio/src/hooks/useGsapKeyframeOps.ts | 100 ++++++++++++++++-- .../studio/src/hooks/useGsapScriptCommits.ts | 12 ++- packages/studio/src/utils/sdkCutover.test.ts | 66 ++++++++++++ packages/studio/src/utils/sdkCutover.ts | 22 ++++ 6 files changed, 211 insertions(+), 9 deletions(-) diff --git a/packages/core/src/parsers/gsapWriter.acorn.test.ts b/packages/core/src/parsers/gsapWriter.acorn.test.ts index d015b92a8..839b1079b 100644 --- a/packages/core/src/parsers/gsapWriter.acorn.test.ts +++ b/packages/core/src/parsers/gsapWriter.acorn.test.ts @@ -269,6 +269,14 @@ describe("T6c — keyframe write ops", () => { expect((result.match(/"50%"/g) ?? []).length).toBe(1); }); + it("addKeyframeToScript merges a new property into an existing keyframe, preserving siblings", () => { + // 50% already holds { opacity: 0.7 }; adding x must NOT drop opacity. + const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { x: 100 }); + expect(result).toContain("opacity: 0.7"); + expect(result).toContain("x: 100"); + expect((result.match(/"50%"/g) ?? []).length).toBe(1); + }); + it("removeKeyframeFromScript removes the target percentage", () => { // Remove 50% from 0%/50%/100% → leaves 0%/100% (no collapse in T6c) const result = removeKeyframeFromScript(SCRIPT_D, "#box-to-200-visual", 50); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index a936e37e6..2f9f298f6 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -774,7 +774,17 @@ export function addKeyframeToScript( // Emit exactly one overwrite per changed node, plus one insert for a new key. const ms = new MagicString(src); if (existing) { - ms.overwrite(existing.prop.value.start, existing.prop.value.end, recordToCode(targetRecord)); + // Merge into the existing keyframe at this percentage, preserving sibling + // properties — overwrite only the given keys. (A whole-value overwrite here + // would silently drop other properties already keyframed at this percent.) + if (existing.prop.value?.type === "ObjectExpression") { + for (const [k, v] of Object.entries(properties)) { + upsertProp(ms, existing.prop.value, k, v); + } + if (ease !== undefined) upsertProp(ms, existing.prop.value, "ease", ease); + } else { + ms.overwrite(existing.prop.value.start, existing.prop.value.end, recordToCode(targetRecord)); + } } else { insertNewKeyframe(ms, kfNode, percentage, `${percentage}%`, recordToCode(targetRecord)); } diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.ts b/packages/studio/src/hooks/useGsapKeyframeOps.ts index 44b663540..b7179cd86 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.ts +++ b/packages/studio/src/hooks/useGsapKeyframeOps.ts @@ -1,7 +1,9 @@ import { useCallback } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { executeOptimistic } from "../utils/optimisticUpdate"; +import { sdkGsapKeyframePersist } from "../utils/sdkCutover"; import type { KeyframeCacheEntry } from "../player/store/playerStore"; import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit"; import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers"; @@ -10,6 +12,7 @@ import type { SafeGsapCommitMutation, TrackGsapSaveFailure, } from "./gsapScriptCommitTypes"; +import type { EditHistoryKind } from "../utils/editHistory"; function executeOptimisticKeyframeCacheUpdate(options: { sourceFile: string; @@ -30,7 +33,22 @@ function executeOptimisticKeyframeCacheUpdate(options: { }); } -interface GsapKeyframeOpsParams { +interface SdkKeyframeDeps { + sdkSession?: Composition | null; + writeProjectFile?: (path: string, content: string) => Promise; + editHistory?: { + recordEdit: (entry: { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; + }) => Promise; + }; + reloadPreview?: () => void; + domEditSaveTimestampRef?: React.MutableRefObject; +} + +interface GsapKeyframeOpsParams extends SdkKeyframeDeps { activeCompPath: string | null; commitMutation: CommitMutation; commitMutationSafely: SafeGsapCommitMutation; @@ -42,6 +60,11 @@ export function useGsapKeyframeOps({ commitMutation, commitMutationSafely, trackGsapSaveFailure, + sdkSession, + writeProjectFile, + editHistory, + reloadPreview, + domEditSaveTimestampRef, }: GsapKeyframeOpsParams) { const addKeyframe = useCallback( ( @@ -67,36 +90,97 @@ export function useGsapKeyframeOps({ (a, b) => a.percentage - b.percentage, ), }), - persist: () => - commitMutation(selection, mutation, { + persist: async () => { + if ( + sdkSession && + writeProjectFile && + editHistory && + reloadPreview && + domEditSaveTimestampRef + ) { + const handled = await sdkGsapKeyframePersist( + sourceFile, + animationId, + percentage, + { [property]: value }, + sdkSession, + { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { + label: `Add keyframe at ${percentage}%`, + coalesceKey: `gsap:${animationId}:kf:${percentage}`, + }, + ); + if (handled) return; + } + await commitMutation(selection, mutation, { label: `Add keyframe at ${percentage}%`, softReload: true, - }), + }); + }, }).catch((error) => { trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`); }); }, - [activeCompPath, commitMutation, trackGsapSaveFailure], + [ + activeCompPath, + commitMutation, + trackGsapSaveFailure, + sdkSession, + writeProjectFile, + editHistory, + reloadPreview, + domEditSaveTimestampRef, + ], ); const addKeyframeBatch = useCallback( - ( + async ( selection: DomEditSelection, animationId: string, percentage: number, properties: Record, ) => { + if ( + sdkSession && + writeProjectFile && + editHistory && + reloadPreview && + domEditSaveTimestampRef + ) { + const sourceFile = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapKeyframePersist( + sourceFile, + animationId, + percentage, + properties, + sdkSession, + { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { label: `Add keyframe at ${percentage}%` }, + ); + if (handled) return; + } return commitMutation( selection, { type: "add-keyframe", animationId, percentage, properties }, { label: `Add keyframe at ${percentage}%`, softReload: true }, ); }, - [commitMutation], + [ + commitMutation, + activeCompPath, + sdkSession, + writeProjectFile, + editHistory, + reloadPreview, + domEditSaveTimestampRef, + ], ); const removeKeyframe = useCallback( (selection: DomEditSelection, animationId: string, percentage: number) => { + // ponytail: SDK removeGsapKeyframe uses keyframeIndex (not percentage); mismatch with + // Studio's percentage-based API. Resolving index requires parsing GSAP state at call + // time — deferred. removeKeyframe stays server-authoritative. const sourceFile = selection.sourceFile || activeCompPath || "index.html"; const mutation = { type: "remove-keyframe", animationId, percentage }; void executeOptimisticKeyframeCacheUpdate({ @@ -126,6 +210,7 @@ export function useGsapKeyframeOps({ animationId: string, resolvedFromValues?: Record, ) => { + // ponytail: no SDK equivalent; convertToKeyframes stays server-authoritative (T6f scope) return commitMutation( selection, { type: "convert-to-keyframes", animationId, resolvedFromValues }, @@ -137,6 +222,7 @@ export function useGsapKeyframeOps({ const removeAllKeyframes = useCallback( (selection: DomEditSelection, animationId: string) => { + // ponytail: no SDK equivalent for remove-all-keyframes; stays server-authoritative commitMutationSafely( selection, { type: "remove-all-keyframes", animationId }, diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 7b092737e..287431d75 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -118,7 +118,17 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra reloadPreview, domEditSaveTimestampRef, }); - const keyframeOps = useGsapKeyframeOps({ activeCompPath, commitMutation, commitMutationSafely, trackGsapSaveFailure }); + const keyframeOps = useGsapKeyframeOps({ + activeCompPath, + commitMutation, + commitMutationSafely, + trackGsapSaveFailure, + sdkSession, + writeProjectFile, + editHistory, + reloadPreview, + domEditSaveTimestampRef, + }); const arcPathOps = useGsapArcPathOps(commitMutationSafely); return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps }; } diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 6694ec6d5..e4f758e6d 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -5,6 +5,7 @@ import { sdkDeletePersist, sdkTimingPersist, sdkGsapTweenPersist, + sdkGsapKeyframePersist, } from "./sdkCutover"; import { openComposition } from "@hyperframes/sdk"; import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory"; @@ -556,6 +557,71 @@ describe("sdkGsapTweenPersist", () => { }); }); +describe("sdkGsapKeyframePersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + const makeSession = () => + ({ + dispatch: vi.fn(), + serialize: vi + .fn() + .mockReturnValueOnce("before") + .mockReturnValue("after"), + }) as unknown as Parameters[4]; + + it("returns false when session is null", async () => { + expect( + await sdkGsapKeyframePersist("/comp.html", "tw-1", 50, { opacity: 0.5 }, null, makeDeps()), + ).toBe(false); + }); + + it("dispatches addGsapKeyframe and writes serialized content", async () => { + const deps = makeDeps(); + const session = makeSession(); + const result = await sdkGsapKeyframePersist( + "/comp.html", + "tw-1", + 50, + { opacity: 0.5 }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "addGsapKeyframe", + animationId: "tw-1", + position: 50, + value: { opacity: 0.5 }, + }); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after"); + expect(deps.reloadPreview).toHaveBeenCalled(); + }); + + it("returns false and does not write on dispatch error", async () => { + const deps = makeDeps(); + const session = makeSession(); + (session!.dispatch as ReturnType).mockImplementation(() => { + throw new Error("dispatch failed"); + }); + const result = await sdkGsapKeyframePersist( + "/comp.html", + "tw-1", + 25, + { x: 100 }, + session, + deps, + ); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + }); +}); + describe("sdkCutoverPersist — GSAP script preservation (integration)", () => { const makeRef = (val: T): MutableRefObject => ({ current: val }); const makeDeps = () => ({ diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 84c715ee2..78d156a63 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -189,6 +189,28 @@ export async function sdkGsapTweenPersist( } } +export async function sdkGsapKeyframePersist( + targetPath: string, + animationId: string, + position: number, + value: Record, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + if (!sdkSession) return false; + try { + const before = sdkSession.serialize(); + sdkSession.dispatch({ type: "addGsapKeyframe", animationId, position, value }); + await persistSdkSerialize(sdkSession, targetPath, before, deps, options); + trackStudioEvent("sdk_cutover_success", { opCount: 1 }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { error: String(err) }); + return false; + } +} + export async function sdkDeletePersist( hfId: string, originalContent: string, From f2458af4ac8d0541c883ef0e9e0e2f89f6d0aa79 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 14:30:47 -0700 Subject: [PATCH 10/11] fix(studio,core): resolve SDK-cutover review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Miguel Ángel --- packages/studio/src/App.tsx | 18 +-- .../studio/src/hooks/gsapScriptCommitTypes.ts | 2 + .../studio/src/hooks/useDomEditCommits.ts | 25 +-- .../studio/src/hooks/useDomEditSession.ts | 28 +++- .../src/hooks/useElementLifecycleOps.ts | 13 ++ .../studio/src/hooks/useGsapAnimationOps.ts | 90 ++--------- .../studio/src/hooks/useGsapKeyframeOps.ts | 89 ++++------ .../src/hooks/useGsapPropertyDebounce.ts | 152 +++++------------- .../studio/src/hooks/useGsapScriptCommits.ts | 62 +++++-- packages/studio/src/hooks/useSdkSession.ts | 27 ++-- .../studio/src/hooks/useTimelineEditing.ts | 55 +++++-- packages/studio/src/utils/gsapSoftReload.ts | 14 ++ packages/studio/src/utils/sdkCutover.test.ts | 16 +- packages/studio/src/utils/sdkCutover.ts | 90 ++++++++--- 14 files changed, 343 insertions(+), 338 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 808092843..de6ed6a47 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -188,6 +188,7 @@ export function StudioApp() { uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, sdkSession: sdkHandle.session, + forceReloadSdkSession: sdkHandle.forceReload, }); const { activeBlockParams, @@ -261,14 +262,10 @@ export function StudioApp() { ? () => handleToggleRecordingRef.current() : undefined, }); - const selectSidebarTabStable = useCallback( - (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), - [], - ); - const getSidebarTabStable = useCallback( - () => leftSidebarRef.current?.getTab() ?? "compositions", - [], - ); + const sidebarTabRef = useRef({ + select: (t: SidebarTab) => leftSidebarRef.current?.selectTab(t), + get: () => leftSidebarRef.current?.getTab() ?? "compositions", + }); const domEditSession = useDomEditSession({ projectId, activeCompPath, @@ -301,9 +298,10 @@ export function StudioApp() { reloadPreview, setRefreshKey, openSourceForSelection: fileManager.openSourceForSelection, - selectSidebarTab: selectSidebarTabStable, - getSidebarTab: getSidebarTabStable, + selectSidebarTab: sidebarTabRef.current.select, + getSidebarTab: sidebarTabRef.current.get, sdkSession: sdkHandle.session, + forceReloadSdkSession: sdkHandle.forceReload, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index db24652de..60a4b4ba3 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -67,4 +67,6 @@ export interface GsapScriptCommitsParams { /** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */ sdkSession?: Composition | null; writeProjectFile?: (path: string, content: string) => Promise; + /** Resync the in-memory SDK session after a server-authoritative write. */ + forceReloadSdkSession?: () => void; } diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 9d8072492..a3cb8f262 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -73,14 +73,17 @@ export interface UseDomEditCommitsParams { target: HTMLElement, options?: { preferClipAncestor?: boolean }, ) => Promise; - /** Stage 7 Step 3b: called after a successful server-side element patch. */ - onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void; + /** Resync the in-memory SDK session after a SERVER-side write (NOT the SDK + * path, whose session is already current) so a later SDK edit doesn't + * serialize the pre-write doc and revert the server's change. */ + forceReloadSdkSession?: () => void; /** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */ onTrySdkPersist?: ( selection: DomEditSelection, operations: PatchOperation[], originalContent: string, targetPath: string, + options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean }, ) => Promise; /** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */ onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise; @@ -104,7 +107,7 @@ export function useDomEditCommits({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, - onDomEditPersisted, + forceReloadSdkSession, onTrySdkPersist, onTrySdkDelete, }: UseDomEditCommitsParams) { @@ -156,19 +159,22 @@ export function useDomEditCommits({ } if (options?.shouldSave && !options.shouldSave()) return; - // Skip the SDK path when prepareContent is set (e.g. @font-face injection // for a custom font): sdkCutoverPersist serializes only the patched DOM // and would drop the injected content. Let the server path run prepareContent. if ( onTrySdkPersist && !options?.prepareContent && - (await onTrySdkPersist(selection, operations, originalContent, targetPath)) + (await onTrySdkPersist(selection, operations, originalContent, targetPath, { + label: options?.label, + coalesceKey: options?.coalesceKey, + skipRefresh: options?.skipRefresh, + })) ) { - // SDK handled it — its in-memory doc is already current. + // SDK handled it — its in-memory doc is already current, so do NOT + // forceReload (that would echo-reload the session we just wrote). return; } - const patchTarget = buildDomEditPatchTarget(selection); const patchBody = { target: patchTarget, operations }; const unsafeFields = findUnsafeDomPatchValues(patchBody); @@ -240,7 +246,7 @@ export function useDomEditCommits({ coalesceKey: options?.coalesceKey, files: { [targetPath]: { before: originalContent, after: finalContent } }, }); - onDomEditPersisted?.(selection, operations); + forceReloadSdkSession?.(); if (!options?.skipRefresh) { reloadPreview(); @@ -254,7 +260,7 @@ export function useDomEditCommits({ domEditSaveTimestampRef, reloadPreview, showToast, - onDomEditPersisted, + forceReloadSdkSession, onTrySdkPersist, ], ); @@ -317,6 +323,7 @@ export function useDomEditCommits({ reloadPreview, clearDomSelection, onTrySdkDelete, + forceReloadSdkSession, commitPositionPatchToHtml, }); diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 06bb9c85c..1fa260c0f 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -61,6 +61,7 @@ export interface UseDomEditSessionParams { selectSidebarTab?: (tab: SidebarTab) => void; getSidebarTab?: () => SidebarTab; sdkSession?: Composition | null; + forceReloadSdkSession?: () => void; } // ── Hook ── @@ -100,6 +101,7 @@ export function useDomEditSession({ selectSidebarTab, getSidebarTab, sdkSession, + forceReloadSdkSession, }: UseDomEditSessionParams) { void _setRefreshKey; void _readProjectFile; @@ -195,6 +197,7 @@ export function useDomEditSession({ showToast, sdkSession, writeProjectFile, + forceReloadSdkSession, }); // ── DOM commit handlers ── @@ -234,14 +237,24 @@ export function useDomEditSession({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, + forceReloadSdkSession, onTrySdkPersist: sdkSession - ? (selection, operations, originalContent, targetPath) => - sdkCutoverPersist(selection, operations, originalContent, targetPath, sdkSession, { - editHistory, - writeProjectFile, - reloadPreview, - domEditSaveTimestampRef, - }) + ? (selection, operations, originalContent, targetPath, options) => + sdkCutoverPersist( + selection, + operations, + originalContent, + targetPath, + sdkSession, + { + editHistory, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + }, + options, + ) : undefined, onTrySdkDelete: sdkSession ? (hfId, originalContent, targetPath) => @@ -250,6 +263,7 @@ export function useDomEditSession({ writeProjectFile, reloadPreview, domEditSaveTimestampRef, + compositionPath: activeCompPath, }) : undefined, }); diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index fe1dc3a2f..1ee885a62 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -28,6 +28,8 @@ interface UseElementLifecycleOpsParams { clearDomSelection: () => void; /** Route delete through SDK when session resolves the hf-id; returns true if handled. */ onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise; + /** Resync the SDK session after a server-fallback delete. */ + forceReloadSdkSession?: () => void; commitPositionPatchToHtml: ( selection: DomEditSelection, patches: PatchOperation[], @@ -47,6 +49,7 @@ export function useElementLifecycleOps({ reloadPreview, clearDomSelection, onTrySdkDelete, + forceReloadSdkSession, commitPositionPatchToHtml, onElementDeleted, }: UseElementLifecycleOpsParams) { @@ -106,6 +109,12 @@ export function useElementLifecycleOps({ const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string }; const patchedContent = typeof removeData.content === "string" ? removeData.content : originalContent; + // ponytail: the server remove-element route (removeElementFromHtml) strips + // only the element node — it does NOT cascade-remove GSAP tweens targeting + // it, unlike the SDK path (removeElement → cascadeRemoveAnimations). This + // fallback runs only when the element isn't in the SDK doc (e.g. runtime- + // generated / unaddressable), where targeting tweens are unlikely. Upgrade + // path: cascade in removeElementFromHtml by selector/hf-id to fully match. await saveProjectFilesWithHistory({ projectId: pid, label: "Delete element", @@ -118,6 +127,9 @@ export function useElementLifecycleOps({ clearDomSelection(); usePlayerStore.getState().setSelectedElementId(null); + // Server wrote the file; resync the stale in-memory SDK doc so a later + // SDK edit doesn't resurrect the deleted element. + forceReloadSdkSession?.(); reloadPreview(); onElementDeleted?.(selection); showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); @@ -133,6 +145,7 @@ export function useElementLifecycleOps({ editHistory.recordEdit, onTrySdkDelete, onElementDeleted, + forceReloadSdkSession, projectIdRef, reloadPreview, showToast, diff --git a/packages/studio/src/hooks/useGsapAnimationOps.ts b/packages/studio/src/hooks/useGsapAnimationOps.ts index 07898a323..66cec59fb 100644 --- a/packages/studio/src/hooks/useGsapAnimationOps.ts +++ b/packages/studio/src/hooks/useGsapAnimationOps.ts @@ -2,27 +2,16 @@ import { useCallback } from "react"; import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { roundTo3 } from "../utils/rounding"; -import { sdkGsapTweenPersist } from "../utils/sdkCutover"; +import { sdkGsapTweenPersist, type CutoverDeps } from "../utils/sdkCutover"; import { assignGsapTargetAutoIdIfNeeded, ensureElementAddressable, } from "./gsapScriptCommitHelpers"; import type { CommitMutation, SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; -import type { EditHistoryKind } from "../utils/editHistory"; interface SdkAnimationDeps { sdkSession?: Composition | null; - writeProjectFile?: (path: string, content: string) => Promise; - editHistory?: { - recordEdit: (entry: { - label: string; - kind: EditHistoryKind; - coalesceKey?: string; - files: Record; - }) => Promise; - }; - reloadPreview?: () => void; - domEditSaveTimestampRef?: React.MutableRefObject; + sdkDeps?: CutoverDeps | null; } interface GsapAnimationOpsParams extends SdkAnimationDeps { @@ -40,10 +29,7 @@ export function useGsapAnimationOps({ commitMutationSafely, showToast, sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, + sdkDeps, }: GsapAnimationOpsParams) { const updateGsapMeta = useCallback( async ( @@ -51,19 +37,13 @@ export function useGsapAnimationOps({ animationId: string, updates: { duration?: number; ease?: string; position?: number }, ) => { - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, { kind: "set", animationId, properties: updates }, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` }, ); if (handled) return; @@ -74,32 +54,18 @@ export function useGsapAnimationOps({ { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` }, ); }, - [ - commitMutationSafely, - activeCompPath, - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - ], + [commitMutationSafely, activeCompPath, sdkSession, sdkDeps], ); const deleteGsapAnimation = useCallback( async (selection: DomEditSelection, animationId: string) => { - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, { kind: "remove", animationId }, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: "Delete GSAP animation" }, ); if (handled) return; @@ -110,15 +76,7 @@ export function useGsapAnimationOps({ { label: "Delete GSAP animation" }, ); }, - [ - commitMutationSafely, - activeCompPath, - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - ], + [commitMutationSafely, activeCompPath, sdkSession, sdkDeps], ); const deleteAllForSelector = useCallback( @@ -168,16 +126,12 @@ export function useGsapAnimationOps({ fromTo: { x: 0, y: 0, opacity: 1 }, }; - // SDK path: addGsapTween only supports from/to/fromTo; "set" stays server-side - if ( - method !== "set" && - selection.hfId && - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + // SDK path: addGsapTween only supports from/to/fromTo; "set" stays + // server-side. Skip the SDK path when an id was just assigned server-side + // (autoId): the SDK session hasn't reloaded that write yet, so persisting + // its serialization would clobber the new id — let the server add the + // tween atomically with the id it wrote. + if (!autoId && method !== "set" && selection.hfId && sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const spec = { method: method as "to" | "from" | "fromTo", @@ -191,7 +145,7 @@ export function useGsapAnimationOps({ targetPath, { kind: "add", target: selection.hfId, spec }, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: `Add GSAP ${method} animation` }, ); if (handled) return; @@ -212,17 +166,7 @@ export function useGsapAnimationOps({ { label: `Add GSAP ${method} animation` }, ); }, - [ - activeCompPath, - commitMutation, - projectIdRef, - showToast, - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - ], + [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession, sdkDeps], ); return { diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.ts b/packages/studio/src/hooks/useGsapKeyframeOps.ts index b7179cd86..6f550fbfb 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.ts +++ b/packages/studio/src/hooks/useGsapKeyframeOps.ts @@ -3,7 +3,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { executeOptimistic } from "../utils/optimisticUpdate"; -import { sdkGsapKeyframePersist } from "../utils/sdkCutover"; +import { sdkGsapKeyframePersist, type CutoverDeps } from "../utils/sdkCutover"; import type { KeyframeCacheEntry } from "../player/store/playerStore"; import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit"; import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers"; @@ -12,7 +12,6 @@ import type { SafeGsapCommitMutation, TrackGsapSaveFailure, } from "./gsapScriptCommitTypes"; -import type { EditHistoryKind } from "../utils/editHistory"; function executeOptimisticKeyframeCacheUpdate(options: { sourceFile: string; @@ -35,17 +34,7 @@ function executeOptimisticKeyframeCacheUpdate(options: { interface SdkKeyframeDeps { sdkSession?: Composition | null; - writeProjectFile?: (path: string, content: string) => Promise; - editHistory?: { - recordEdit: (entry: { - label: string; - kind: EditHistoryKind; - coalesceKey?: string; - files: Record; - }) => Promise; - }; - reloadPreview?: () => void; - domEditSaveTimestampRef?: React.MutableRefObject; + sdkDeps?: CutoverDeps | null; } interface GsapKeyframeOpsParams extends SdkKeyframeDeps { @@ -61,10 +50,7 @@ export function useGsapKeyframeOps({ commitMutationSafely, trackGsapSaveFailure, sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, + sdkDeps, }: GsapKeyframeOpsParams) { const addKeyframe = useCallback( ( @@ -84,27 +70,37 @@ export function useGsapKeyframeOps({ void executeOptimisticKeyframeCacheUpdate({ sourceFile, elementId: selection.id, - apply: (prev) => ({ - ...prev, - keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort( - (a, b) => a.percentage - b.percentage, - ), - }), + // Merge into an existing keyframe at this percentage rather than + // appending a duplicate — matches addKeyframeToScript, which writes one + // keyframe per percentage (merging properties). + apply: (prev) => { + const idx = prev.keyframes.findIndex( + (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) < 0.001, + ); + if (idx >= 0) { + const keyframes = prev.keyframes.slice(); + keyframes[idx] = { + ...keyframes[idx], + properties: { ...keyframes[idx].properties, [property]: value }, + }; + return { ...prev, keyframes }; + } + return { + ...prev, + keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort( + (a, b) => a.percentage - b.percentage, + ), + }; + }, persist: async () => { - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + if (sdkSession && sdkDeps) { const handled = await sdkGsapKeyframePersist( sourceFile, animationId, percentage, { [property]: value }, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: `Add keyframe at ${percentage}%`, coalesceKey: `gsap:${animationId}:kf:${percentage}`, @@ -121,16 +117,7 @@ export function useGsapKeyframeOps({ trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`); }); }, - [ - activeCompPath, - commitMutation, - trackGsapSaveFailure, - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - ], + [activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps], ); const addKeyframeBatch = useCallback( @@ -140,13 +127,7 @@ export function useGsapKeyframeOps({ percentage: number, properties: Record, ) => { - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + if (sdkSession && sdkDeps) { const sourceFile = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapKeyframePersist( sourceFile, @@ -154,7 +135,7 @@ export function useGsapKeyframeOps({ percentage, properties, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: `Add keyframe at ${percentage}%` }, ); if (handled) return; @@ -165,15 +146,7 @@ export function useGsapKeyframeOps({ { label: `Add keyframe at ${percentage}%`, softReload: true }, ); }, - [ - commitMutation, - activeCompPath, - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - ], + [commitMutation, activeCompPath, sdkSession, sdkDeps], ); const removeKeyframe = useCallback( diff --git a/packages/studio/src/hooks/useGsapPropertyDebounce.ts b/packages/studio/src/hooks/useGsapPropertyDebounce.ts index 103b9892d..218397b65 100644 --- a/packages/studio/src/hooks/useGsapPropertyDebounce.ts +++ b/packages/studio/src/hooks/useGsapPropertyDebounce.ts @@ -1,32 +1,21 @@ import { useCallback, useEffect, useRef } from "react"; import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import { sdkGsapTweenPersist } from "../utils/sdkCutover"; +import { sdkGsapTweenPersist, type CutoverDeps } from "../utils/sdkCutover"; import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers"; import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; -import type { EditHistoryKind } from "../utils/editHistory"; const DEBOUNCE_MS = 150; interface SdkPropertyDeps { sdkSession?: Composition | null; - writeProjectFile?: (path: string, content: string) => Promise; - editHistory?: { - recordEdit: (entry: { - label: string; - kind: EditHistoryKind; - coalesceKey?: string; - files: Record; - }) => Promise; - }; - reloadPreview?: () => void; - domEditSaveTimestampRef?: React.MutableRefObject; + sdkDeps?: CutoverDeps | null; activeCompPath?: string | null; } export function useGsapPropertyDebounce( commitMutationSafely: SafeGsapCommitMutation, - sdkDeps?: SdkPropertyDeps, + sdk?: SdkPropertyDeps, ) { const pendingPropertyEditRef = useRef<{ selection: DomEditSelection; @@ -36,51 +25,33 @@ export function useGsapPropertyDebounce( } | null>(null); const debounceTimerRef = useRef | null>(null); - // fallow-ignore-next-line complexity - const flushPendingPropertyEdit = useCallback( - // fallow-ignore-next-line complexity - async () => { - const pending = pendingPropertyEditRef.current; - if (!pending) return; - pendingPropertyEditRef.current = null; - const { selection, animationId, property, value } = pending; - const { + const flushPendingPropertyEdit = useCallback(async () => { + const pending = pendingPropertyEditRef.current; + if (!pending) return; + pendingPropertyEditRef.current = null; + const { selection, animationId, property, value } = pending; + const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "set", animationId, properties: { properties: { [property]: value } } }, sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - activeCompPath, - } = sdkDeps ?? {}; - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { - const targetPath = selection.sourceFile || activeCompPath || "index.html"; - const handled = await sdkGsapTweenPersist( - targetPath, - { kind: "set", animationId, properties: { properties: { [property]: value } } }, - sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, - { label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` }, - ); - if (handled) return; - } - commitMutationSafely( - selection, - { type: "update-property", animationId, property, value }, - { - label: `Edit GSAP ${property}`, - coalesceKey: `gsap:${animationId}:${property}`, - softReload: true, - }, + sdkDeps, + { label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` }, ); - }, - [commitMutationSafely, sdkDeps], - ); + if (handled) return; + } + commitMutationSafely( + selection, + { type: "update-property", animationId, property, value }, + { + label: `Edit GSAP ${property}`, + coalesceKey: `gsap:${animationId}:${property}`, + softReload: true, + }, + ); + }, [commitMutationSafely, sdk]); const updateGsapProperty = useCallback( ( @@ -118,27 +89,14 @@ export function useGsapPropertyDebounce( const cs = el.ownerDocument.defaultView?.getComputedStyle(el); defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1; } - const { - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - activeCompPath, - } = sdkDeps ?? {}; - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, { kind: "set", animationId, properties: { properties: { [property]: defaultValue } } }, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: `Add GSAP ${property}` }, ); if (handled) return; @@ -149,7 +107,7 @@ export function useGsapPropertyDebounce( { label: `Add GSAP ${property}` }, ); }, - [commitMutationSafely, sdkDeps], + [commitMutationSafely, sdk], ); const removeGsapProperty = useCallback( @@ -164,36 +122,21 @@ export function useGsapPropertyDebounce( [commitMutationSafely], ); - // fallow-ignore-next-line complexity const updateGsapFromProperty = useCallback( - // fallow-ignore-next-line complexity async ( selection: DomEditSelection, animationId: string, property: string, value: number | string, ) => { - const { - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - activeCompPath, - } = sdkDeps ?? {}; - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, { kind: "set", animationId, properties: { fromProperties: { [property]: value } } }, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: `Edit GSAP from-${property}`, coalesceKey: `gsap:${animationId}:from:${property}`, @@ -210,29 +153,14 @@ export function useGsapPropertyDebounce( }, ); }, - [commitMutationSafely, sdkDeps], + [commitMutationSafely, sdk], ); - // fallow-ignore-next-line complexity const addGsapFromProperty = useCallback( - // fallow-ignore-next-line complexity async (selection: DomEditSelection, animationId: string, property: string) => { const defaultValue = PROPERTY_DEFAULTS[property] ?? 0; - const { - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - activeCompPath, - } = sdkDeps ?? {}; - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, @@ -242,7 +170,7 @@ export function useGsapPropertyDebounce( properties: { fromProperties: { [property]: defaultValue } }, }, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: `Add GSAP from-${property}` }, ); if (handled) return; @@ -253,7 +181,7 @@ export function useGsapPropertyDebounce( { label: `Add GSAP from-${property}` }, ); }, - [commitMutationSafely, sdkDeps], + [commitMutationSafely, sdk], ); const removeGsapFromProperty = useCallback( diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 287431d75..936e1fb8b 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -1,7 +1,8 @@ -import { useCallback, useRef } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import { applySoftReload } from "../utils/gsapSoftReload"; +import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload"; +import type { CutoverDeps } from "../utils/sdkCutover"; import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers"; import { createKeyedSerializer } from "./serializeByKey"; import { @@ -44,7 +45,7 @@ async function mutateGsapScript( // oxfmt-ignore // fallow-ignore-next-line complexity -export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile }: GsapScriptCommitsParams) { +export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) { // Serializer for per-key commits (options.serializeKey). Keyed by // `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for // the same animationId so their POSTs can't interleave. Held in a ref so the @@ -75,6 +76,9 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } }); } if (result.after != null) onFileContentChanged?.(targetPath, result.after); + // Server wrote the file; the in-memory SDK doc is now stale. Resync it so a + // later SDK-routed edit doesn't serialize the pre-write doc and revert this. + forceReloadSdkSession?.(); if (options.skipReload) return; if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation); options.beforeReload?.(); @@ -84,7 +88,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra reloadPreview(); } onCacheInvalidate(); - }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast]); + }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]); // Every GSAP-script commit is a read-modify-write of one file. Overlapping // commits to the SAME file (any op type, any animation) interleave server-side, // so serialize per target file by default; an explicit serializeKey overrides. @@ -98,12 +102,44 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra ); const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath); const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast); + + // One stable SDK-deps object shared by all GSAP child hooks. Memoized so the + // hooks' callbacks keep a stable identity (an inline literal here re-fired the + // property-debounce flush on every render). refresh() soft-reloads (preserving + // the playhead) and invalidates the panel cache, matching the server path. + const sdkRefresh = useCallback( + (after: string) => { + const script = extractGsapScriptText(after); + if (!(script && applySoftReload(previewIframeRef.current, script))) reloadPreview(); + onCacheInvalidate(); + }, + [previewIframeRef, reloadPreview, onCacheInvalidate], + ); + const sdkDeps = useMemo( + () => + writeProjectFile + ? { + editHistory: { recordEdit: editHistory.recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + refresh: sdkRefresh, + compositionPath: activeCompPath, + } + : null, + [ + editHistory.recordEdit, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + sdkRefresh, + activeCompPath, + ], + ); + const propertyOps = useGsapPropertyDebounce(commitMutationSafely, { sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, + sdkDeps, activeCompPath, }); const animationOps = useGsapAnimationOps({ @@ -113,10 +149,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra commitMutationSafely, showToast, sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, + sdkDeps, }); const keyframeOps = useGsapKeyframeOps({ activeCompPath, @@ -124,10 +157,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra commitMutationSafely, trackGsapSaveFailure, sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, + sdkDeps, }); const arcPathOps = useGsapArcPathOps(commitMutationSafely); return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps }; diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 9bfd64f71..2c0e20501 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -21,12 +21,8 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string * (projectId, activeCompPath) change, disposes the old one on cleanup, and * re-opens it when the active composition file changes on disk (code editor, * agent, or server-side patch) so the in-memory linkedom document never goes - * stale. The persist queue writes back to `activeCompPath` (not the - * "composition.html" default). - * - * The session is idle until Step 3c routes dispatch ops through it; re-opening - * is therefore purely additive — no SDK self-write exists yet, so there is no - * persist echo. Step 3c must add self-write suppression once dispatch writes. + * stale. The session has NO persist queue — Studio is the sole file writer; see + * the open effect below. */ // Time-window heuristic: suppress file-change reloads for 2 s after our own // SDK cutover write, to avoid an echo-reload on the write we just committed. @@ -95,13 +91,13 @@ export function useSdkSession( .read(activeCompPath) .then(async (content) => { if (cancelled || typeof content !== "string") return; - const comp = await openComposition(content, { - persist: adapter, - persistPath: activeCompPath, - }); - comp.on("persist:error", (e) => { - console.warn("[sdk] persist:error", e.error); - }); + // No persist queue: Studio's writeProjectFile (via sdkCutover's + // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist + // queue too would double-write the file (queue auto-writes on every + // 'change' AND Studio writes explicitly) and race on disk; it would + // also write the full active-composition serialization to the fixed + // persistPath even when an edit targeted a sub-composition file. + const comp = await openComposition(content); // Cleanup may have fired while openComposition was awaited; dispose immediately. if (cancelled) { comp.dispose(); @@ -116,8 +112,9 @@ export function useSdkSession( return () => { cancelled = true; - const c = compRef.current; - if (c) void c.flush().finally(() => c.dispose()); + // No queue to flush; dispose only. (Flushing here would serialize the + // pre-undo in-memory doc and race the revert write on undo/redo reload.) + compRef.current?.dispose(); }; }, [projectId, activeCompPath, reloadToken]); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index f2b2ebc39..b9d85d4ca 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -58,6 +58,8 @@ interface UseTimelineEditingOptions { isRecordingRef?: React.RefObject; /** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */ sdkSession?: Composition | null; + /** Resync the SDK session after a server-authoritative timeline write. */ + forceReloadSdkSession?: () => void; } // ── Hook ── @@ -76,6 +78,7 @@ export function useTimelineEditing({ uploadProjectFiles, isRecordingRef, sdkSession, + forceReloadSdkSession, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; @@ -95,19 +98,24 @@ export function useTimelineEditing({ } const pid = projectIdRef.current; if (!pid) return Promise.resolve(); - const queued = editQueueRef.current.then(() => - persistTimelineEdit({ - projectId: pid, - element, - activeCompPath, - label, - buildPatches, - writeProjectFile, - recordEdit, - domEditSaveTimestampRef, - pendingTimelineEditPathRef, - }), - ); + const queued = editQueueRef.current + .then(() => + persistTimelineEdit({ + projectId: pid, + element, + activeCompPath, + label, + buildPatches, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + pendingTimelineEditPathRef, + }), + ) + .then(() => { + // Server wrote the file; resync the stale in-memory SDK doc. + forceReloadSdkSession?.(); + }); editQueueRef.current = queued.catch((error) => { console.error(`[Timeline] Failed to persist: ${label}`, error); }); @@ -121,6 +129,7 @@ export function useTimelineEditing({ pendingTimelineEditPathRef, showToast, isRecordingRef, + forceReloadSdkSession, ], ); @@ -151,7 +160,13 @@ export function useTimelineEditing({ targetPath, { start: updates.start, trackIndex: updates.track }, sdkSession, - { editHistory: { recordEdit }, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + }, { label: "Move timeline clip", coalesceKey: `timeline-move:${element.hfId}` }, ).then((handled) => { if (!handled) return enqueueEdit(element, "Move timeline clip", buildMovePatches); @@ -215,7 +230,13 @@ export function useTimelineEditing({ targetPath, { start: updates.start, duration: updates.duration }, sdkSession, - { editHistory: { recordEdit }, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + }, { label: "Resize timeline clip", coalesceKey: `timeline-resize:${element.hfId}` }, ).then((handled) => { if (!handled) return enqueueEdit(element, "Resize timeline clip", buildResizePatches); @@ -292,6 +313,7 @@ export function useTimelineEditing({ timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)), ); usePlayerStore.getState().setSelectedElementId(null); + forceReloadSdkSession?.(); reloadPreview(); showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); } catch (error) { @@ -308,6 +330,7 @@ export function useTimelineEditing({ domEditSaveTimestampRef, reloadPreview, isRecordingRef, + forceReloadSdkSession, ], ); @@ -376,6 +399,7 @@ export function useTimelineEditing({ recordEdit, }); + forceReloadSdkSession?.(); reloadPreview(); } catch (error) { const message = @@ -392,6 +416,7 @@ export function useTimelineEditing({ domEditSaveTimestampRef, reloadPreview, isRecordingRef, + forceReloadSdkSession, ], ); diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index 584658a76..e60e001d8 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -31,6 +31,19 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] { return results; } +/** + * Extract the GSAP timeline script text from a serialized HTML document, for + * feeding into applySoftReload. Returns null when zero or multiple GSAP scripts + * are present (ambiguous — caller should fall back to a full reload), matching + * applySoftReload's own single-script requirement. + */ +export function extractGsapScriptText(html: string): string | null { + const doc = new DOMParser().parseFromString(html, "text/html"); + const scripts = findGsapScriptElements(doc); + if (scripts.length !== 1) return null; + return scripts[0].textContent || null; +} + /** Check that the new script repopulated __timelines with at least one entry. */ function verifyTimelinesPopulated(win: IframeWindow): boolean { const tlKeys = win.__timelines @@ -73,6 +86,7 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st // full iframe reload that destroys the very WebGL context we're preserving. let deferredToAsync = false; + // fallow-ignore-next-line complexity const doReload = () => { const timelines = win.__timelines; const allTargets: Element[] = []; diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index e4f758e6d..9e32d1f3c 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -104,7 +104,12 @@ describe("sdkCutoverPersist", () => { ({ getElement: vi.fn().mockReturnValue(hasEl ? { inlineStyles: {} } : null), dispatch: vi.fn(), - serialize: vi.fn().mockReturnValue(""), + // Distinct before/after so the no-op guard (after === before → fall back) + // treats this as a real change; "after" matches the write assertions. + serialize: vi + .fn() + .mockReturnValueOnce("before") + .mockReturnValue(""), batch: vi.fn((fn: () => void) => fn()), }) as unknown as Parameters[4]; @@ -318,7 +323,11 @@ describe("sdkDeletePersist", () => { ({ getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null), removeElement: vi.fn(), - serialize: vi.fn().mockReturnValue("after"), + serialize: vi + .fn() + .mockReturnValueOnce("before-snap") + .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), }) as unknown as Parameters[3]; it("returns false when session is null", async () => { @@ -390,6 +399,7 @@ describe("sdkTimingPersist", () => { .fn() .mockReturnValueOnce("before") .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), }) as unknown as Parameters[3]; it("returns false when session is null", async () => { @@ -466,6 +476,7 @@ describe("sdkGsapTweenPersist", () => { .fn() .mockReturnValueOnce("before") .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), }) as unknown as Parameters[2]; it("returns false when session is null", async () => { @@ -573,6 +584,7 @@ describe("sdkGsapKeyframePersist", () => { .fn() .mockReturnValueOnce("before") .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), }) as unknown as Parameters[4]; it("returns false when session is null", async () => { diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 78d156a63..c4f0b70b5 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -64,7 +64,7 @@ export function shouldUseSdkCutover( ); } -interface CutoverDeps { +export interface CutoverDeps { editHistory: { recordEdit: (entry: { label: string; @@ -76,22 +76,44 @@ interface CutoverDeps { writeProjectFile: (path: string, content: string) => Promise; reloadPreview: () => void; domEditSaveTimestampRef: MutableRefObject; + /** + * Optional post-write refresh. When provided, it REPLACES the default + * reloadPreview() — the GSAP path passes one that soft-reloads (preserving + * the playhead) and invalidates the keyframe/gsap panel cache. Receives the + * serialized document just written. + */ + refresh?: (after: string) => void; + /** + * Path of the composition the SDK session was opened for. The session models + * ONLY this file (serialize() emits the whole active composition), so any edit + * whose targetPath differs (a sub-composition file) must take the server path + * — otherwise we'd write the full active-comp serialization into that file. + */ + compositionPath?: string | null; +} + +/** True when targetPath isn't the composition the SDK session models. */ +function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean { + return deps.compositionPath != null && targetPath !== deps.compositionPath; } interface CutoverOptions { label?: string; coalesceKey?: string; + /** Skip the preview reload (mirrors the server path's skipRefresh). */ + skipRefresh?: boolean; } -// ponytail: internal; export only if a third caller appears +// ponytail: internal; export only if a third caller appears. +// `after` is serialized once by the caller (which also did the no-op check +// against its pre-dispatch snapshot), so this never re-serializes. async function persistSdkSerialize( - sdkSession: Composition, + after: string, targetPath: string, originalContent: string, deps: CutoverDeps, options?: CutoverOptions, ): Promise { - const after = sdkSession.serialize(); deps.domEditSaveTimestampRef.current = Date.now(); await deps.writeProjectFile(targetPath, after); await deps.editHistory.recordEdit({ @@ -100,7 +122,8 @@ async function persistSdkSerialize( ...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}), files: { [targetPath]: { before: originalContent, after } }, }); - deps.reloadPreview(); + if (deps.refresh) deps.refresh(after); + else if (!options?.skipRefresh) deps.reloadPreview(); } export async function sdkCutoverPersist( @@ -118,13 +141,17 @@ export async function sdkCutoverPersist( const hfId = selection.hfId; if (!hfId) return false; if (!sdkSession.getElement(hfId)) return false; + if (wrongCompositionFile(deps, targetPath)) return false; try { + const before = sdkSession.serialize(); sdkSession.batch(() => { for (const editOp of patchOpsToSdkEditOps(hfId, ops)) { sdkSession.dispatch(editOp); } }); - await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, options); + const after = sdkSession.serialize(); + if (after === before) return false; + await persistSdkSerialize(after, targetPath, originalContent, deps, options); trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length }); return true; } catch (err) { @@ -145,10 +172,13 @@ export async function sdkTimingPersist( options?: CutoverOptions, ): Promise { if (!sdkSession || !sdkSession.getElement(hfId)) return false; + if (wrongCompositionFile(deps, targetPath)) return false; try { const before = sdkSession.serialize(); - sdkSession.setTiming(hfId, timingUpdate); - await persistSdkSerialize(sdkSession, targetPath, before, deps, options); + sdkSession.batch(() => sdkSession.setTiming(hfId, timingUpdate)); + const after = sdkSession.serialize(); + if (after === before) return false; + await persistSdkSerialize(after, targetPath, before, deps, options); trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 }); return true; } catch (err) { @@ -170,17 +200,26 @@ export async function sdkGsapTweenPersist( options?: CutoverOptions, ): Promise { if (!sdkSession) return false; + if (wrongCompositionFile(deps, targetPath)) return false; try { + if (op.kind === "add" && !sdkSession.getElement(op.target)) return false; const before = sdkSession.serialize(); - if (op.kind === "add") { - if (!sdkSession.getElement(op.target)) return false; - sdkSession.addGsapTween(op.target, op.spec); - } else if (op.kind === "set") { - sdkSession.setGsapTween(op.animationId, op.properties); - } else { - sdkSession.removeGsapTween(op.animationId); - } - await persistSdkSerialize(sdkSession, targetPath, before, deps, options); + sdkSession.batch(() => { + if (op.kind === "add") { + sdkSession.addGsapTween(op.target, op.spec); + } else if (op.kind === "set") { + sdkSession.setGsapTween(op.animationId, op.properties); + } else { + sdkSession.removeGsapTween(op.animationId); + } + }); + const after = sdkSession.serialize(); + // No-op (stale animationId, unsupported shape e.g. from-prop on a plain + // tween): fall back to the server path so it surfaces the proper error + // instead of writing a phantom before==after undo step. Subsumes a + // per-op existence guard for the set/remove branches. + if (after === before) return false; + await persistSdkSerialize(after, targetPath, before, deps, options); trackStudioEvent("sdk_cutover_success", { opCount: 1 }); return true; } catch (err) { @@ -199,10 +238,15 @@ export async function sdkGsapKeyframePersist( options?: CutoverOptions, ): Promise { if (!sdkSession) return false; + if (wrongCompositionFile(deps, targetPath)) return false; try { const before = sdkSession.serialize(); - sdkSession.dispatch({ type: "addGsapKeyframe", animationId, position, value }); - await persistSdkSerialize(sdkSession, targetPath, before, deps, options); + sdkSession.batch(() => + sdkSession.dispatch({ type: "addGsapKeyframe", animationId, position, value }), + ); + const after = sdkSession.serialize(); + if (after === before) return false; + await persistSdkSerialize(after, targetPath, before, deps, options); trackStudioEvent("sdk_cutover_success", { opCount: 1 }); return true; } catch (err) { @@ -219,9 +263,13 @@ export async function sdkDeletePersist( deps: CutoverDeps, ): Promise { if (!sdkSession || !sdkSession.getElement(hfId)) return false; + if (wrongCompositionFile(deps, targetPath)) return false; try { - sdkSession.removeElement(hfId); - await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, { + const before = sdkSession.serialize(); + sdkSession.batch(() => sdkSession.removeElement(hfId)); + const after = sdkSession.serialize(); + if (after === before) return false; + await persistSdkSerialize(after, targetPath, originalContent, deps, { label: "Delete element", }); trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 }); From eb381ec70a1eb79581852433570880b845af8c83 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 22:52:20 -0700 Subject: [PATCH 11/11] =?UTF-8?q?feat(sdk):=20ws-a1=20=E2=80=94=20iframe?= =?UTF-8?q?=20preview=20adapter=20(hit-test=20+=20selection)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Miguel Ángel --- packages/sdk/src/adapters/iframe.test.ts | 172 +++++++++++++++++++++++ packages/sdk/src/adapters/iframe.ts | 133 ++++++++++++++++++ packages/sdk/src/index.ts | 1 + 3 files changed, 306 insertions(+) create mode 100644 packages/sdk/src/adapters/iframe.test.ts create mode 100644 packages/sdk/src/adapters/iframe.ts diff --git a/packages/sdk/src/adapters/iframe.test.ts b/packages/sdk/src/adapters/iframe.test.ts new file mode 100644 index 000000000..f0d351c8c --- /dev/null +++ b/packages/sdk/src/adapters/iframe.test.ts @@ -0,0 +1,172 @@ +/** + * Unit tests for resolveNearestHfElement (pure resolver — no browser needed). + * + * elementFromPoint itself requires a real browser layout engine. The adapter's + * elementAtPoint() method is therefore NOT tested here; cover it with an + * integration test that mounts a real same-origin iframe (WS-A1 follow-on). + */ + +import { describe, it, expect, vi } from "vitest"; +import { resolveNearestHfElement } from "./iframe.js"; +import type { ElementAtPointResult } from "./types.js"; + +// ─── Minimal fake element ──────────────────────────────────────────────────── + +interface FakeEl { + attrs: Record; + tagName: string; + parentElement: FakeEl | null; + getAttribute(name: string): string | null; + hasAttribute(name: string): boolean; +} + +function fakeEl( + attrs: Record, + tagName: string, + parent: FakeEl | null = null, +): FakeEl { + return { + attrs, + tagName, + parentElement: parent, + getAttribute(name) { + return Object.prototype.hasOwnProperty.call(this.attrs, name) ? this.attrs[name] : null; + }, + hasAttribute(name) { + return Object.prototype.hasOwnProperty.call(this.attrs, name); + }, + }; +} + +const visible = () => true; +const invisible = () => false; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("resolveNearestHfElement", () => { + it("returns null for a null input", () => { + expect(resolveNearestHfElement(null, visible)).toBeNull(); + }); + + it("returns the element itself when it carries data-hf-id", () => { + const el = fakeEl({ "data-hf-id": "hf-abc" }, "div"); + const result = resolveNearestHfElement(el as unknown as Element, visible); + expect(result).toEqual({ id: "hf-abc", tag: "div" }); + }); + + it("walks up to a parent that carries data-hf-id", () => { + const parent = fakeEl({ "data-hf-id": "hf-parent" }, "section"); + const child = fakeEl({}, "span", parent); + const result = resolveNearestHfElement(child as unknown as Element, visible); + expect(result).toEqual({ id: "hf-parent", tag: "section" }); + }); + + it("returns null when the nearest data-hf-id node is data-hf-root", () => { + const root = fakeEl({ "data-hf-id": "hf-stage", "data-hf-root": "" }, "div"); + const child = fakeEl({}, "p", root); + expect(resolveNearestHfElement(child as unknown as Element, visible)).toBeNull(); + }); + + it("returns null when the element itself is data-hf-root", () => { + const root = fakeEl({ "data-hf-id": "hf-stage", "data-hf-root": "" }, "div"); + expect(resolveNearestHfElement(root as unknown as Element, visible)).toBeNull(); + }); + + it("returns null when isVisible returns false for the matching element", () => { + const el = fakeEl({ "data-hf-id": "hf-abc" }, "div"); + expect(resolveNearestHfElement(el as unknown as Element, invisible)).toBeNull(); + }); + + it("skips an opacity-0 element and returns null (isVisible called on the resolved node)", () => { + // isVisible is only checked on the RESOLVED node, not intermediary nodes. + const parent = fakeEl({ "data-hf-id": "hf-parent" }, "div"); + const child = fakeEl({}, "span", parent); + // Make parent invisible + const isVisible = vi.fn((el: Element) => { + const fe = el as unknown as FakeEl; + return fe.attrs["data-hf-id"] !== "hf-parent"; + }); + expect(resolveNearestHfElement(child as unknown as Element, isVisible)).toBeNull(); + // isVisible was called once (on the resolved parent node) + expect(isVisible).toHaveBeenCalledTimes(1); + }); + + it("returns null when no data-hf-id found in any ancestor", () => { + const grandparent = fakeEl({}, "body"); + const parent = fakeEl({}, "div", grandparent); + const child = fakeEl({}, "span", parent); + expect(resolveNearestHfElement(child as unknown as Element, visible)).toBeNull(); + }); + + it("tag is lowercased", () => { + const el = fakeEl({ "data-hf-id": "hf-xyz" }, "DIV"); + const result = resolveNearestHfElement(el as unknown as Element, visible); + expect(result?.tag).toBe("div"); + }); + + it("stops at the nearest ancestor — does not continue past first data-hf-id", () => { + const outer = fakeEl({ "data-hf-id": "hf-outer" }, "section"); + const inner = fakeEl({ "data-hf-id": "hf-inner" }, "div", outer); + const child = fakeEl({}, "span", inner); + const result = resolveNearestHfElement(child as unknown as Element, visible); + expect(result?.id).toBe("hf-inner"); + }); +}); + +// ─── select + on('selection') wiring ───────────────────────────────────────── +// These cover the adapter-level selection state without needing a real iframe. +// We import createIframePreviewAdapter and pass a stub iframe. + +import { createIframePreviewAdapter } from "./iframe.js"; + +function stubIframe() { + return {} as HTMLIFrameElement; +} + +describe("IframePreviewAdapter selection", () => { + it("on('selection') fires when select() is called", () => { + const adapter = createIframePreviewAdapter(stubIframe()); + const cb = vi.fn(); + adapter.on("selection", cb); + adapter.select(["hf-abc"]); + expect(cb).toHaveBeenCalledWith(["hf-abc"]); + }); + + it("off unsubscribes the handler", () => { + const adapter = createIframePreviewAdapter(stubIframe()); + const cb = vi.fn(); + const off = adapter.on("selection", cb); + off(); + adapter.select(["hf-abc"]); + expect(cb).not.toHaveBeenCalled(); + }); + + it("additive select merges with prior selection", () => { + const adapter = createIframePreviewAdapter(stubIframe()); + const cb = vi.fn(); + adapter.on("selection", cb); + adapter.select(["hf-a"]); + adapter.select(["hf-b"], { additive: true }); + expect(cb).toHaveBeenLastCalledWith(expect.arrayContaining(["hf-a", "hf-b"])); + }); + + it("non-additive select replaces prior selection", () => { + const adapter = createIframePreviewAdapter(stubIframe()); + const cb = vi.fn(); + adapter.on("selection", cb); + adapter.select(["hf-a"]); + adapter.select(["hf-b"]); + expect(cb).toHaveBeenLastCalledWith(["hf-b"]); + }); + + it("multiple handlers all fire", () => { + const adapter = createIframePreviewAdapter(stubIframe()); + const cb1 = vi.fn(); + const cb2 = vi.fn(); + adapter.on("selection", cb1); + adapter.on("selection", cb2); + adapter.select(["hf-abc"]); + expect(cb1).toHaveBeenCalledOnce(); + expect(cb2).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/sdk/src/adapters/iframe.ts b/packages/sdk/src/adapters/iframe.ts new file mode 100644 index 000000000..50fedc05a --- /dev/null +++ b/packages/sdk/src/adapters/iframe.ts @@ -0,0 +1,133 @@ +/** + * Same-origin iframe PreviewAdapter — WS-A1 (hit-test + selection). + * + * Requirements: + * - The iframe MUST be same-origin (srcdoc / blob URL). Cross-origin access to + * contentDocument throws a DOMException; this adapter does not guard that — + * the caller is responsible for ensuring same-origin. + * - applyDraft / commitPreview / cancelPreview are WS-A2 scope — stubbed here. + */ + +import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js"; + +// ─── Pure resolver (testable without a browser) ─────────────────────────────── + +/** + * Walk from `el` upward through parentElement, looking for the nearest node + * that carries `[data-hf-id]` and is NOT `[data-hf-root]`. + * + * Returns null when: + * - The walk exits the tree without finding `[data-hf-id]` + * - The matching node is `[data-hf-root]` (transparent to hit-testing) + * - `isVisible(node)` returns false for the matching node + * + * Keeping this a pure function (no elementFromPoint, no window access) makes + * it unit-testable in a plain Node environment. + */ +export function resolveNearestHfElement( + el: Element | null, + isVisible: (el: Element) => boolean, +): ElementAtPointResult | null { + let node = el; + while (node !== null) { + const id = node.getAttribute("data-hf-id"); + if (id !== null) { + if (node.hasAttribute("data-hf-root")) return null; + if (!isVisible(node)) return null; + return { id, tag: node.tagName.toLowerCase() }; + } + node = node.parentElement; + } + return null; +} + +// ─── Visibility check ───────────────────────────────────────────────────────── + +/** + * Returns true when no element in the ancestor chain (inclusive) has + * computed opacity === 0. Checks ancestors because a parent at opacity:0 + * makes the child invisible even if the child's own opacity is 1. + * + * This reflects the current GSAP timeline state (whatever the player has + * seeked to). For atTime values matching the live playhead this is always + * accurate. For speculative times this is NOT seeked — WS-A1 does not mutate + * the timeline; accurate out-of-band opacity queries are WS-G follow-on. + */ +function isOpacityVisible(el: Element, win: Window & typeof globalThis): boolean { + let node: Element | null = el; + while (node !== null) { + const style = win.getComputedStyle(node); + if (parseFloat(style.opacity) === 0) return false; + node = node.parentElement; + } + return true; +} + +// ─── IframePreviewAdapter ───────────────────────────────────────────────────── + +type SelectionHandler = (ids: string[]) => void; + +class IframePreviewAdapter implements PreviewAdapter { + private readonly iframe: HTMLIFrameElement; + private _selection: string[] = []; + private _handlers: SelectionHandler[] = []; + + constructor(iframe: HTMLIFrameElement) { + this.iframe = iframe; + } + + /** + * Synchronous hit-test. Returns the nearest `[data-hf-id]` element under + * (x, y) in the iframe's coordinate space, or null for a transparent hit + * (root, opacity-0, or nothing at all). + * + * atTime: reflects the GSAP state at the playhead when this is called. + * Seeking to a different time to check visibility is WS-G scope. + */ + elementAtPoint(x: number, y: number, _opts?: { atTime?: number }): ElementAtPointResult | null { + const doc = this.iframe.contentDocument; + if (!doc) return null; + const win = this.iframe.contentWindow as (Window & typeof globalThis) | null; + if (!win) return null; + + const hit = doc.elementFromPoint(x, y); + return resolveNearestHfElement(hit, (el) => isOpacityVisible(el, win)); + } + + // WS-A2 stubs — commitPreview / applyDraft derive the moveElement op -------- + + applyDraft(_id: string, _props: DraftProps): void {} + + commitPreview(): void {} + + cancelPreview(): void {} + + // Selection ----------------------------------------------------------------- + + select(ids: string[], opts?: { additive?: boolean }): void { + if (opts?.additive) { + const merged = new Set([...this._selection, ...ids]); + this._selection = [...merged]; + } else { + this._selection = [...ids]; + } + this._emit(); + } + + on(event: "selection", handler: SelectionHandler): () => void { + if (event !== "selection") return () => {}; + this._handlers.push(handler); + return () => { + this._handlers = this._handlers.filter((h) => h !== handler); + }; + } + + private _emit(): void { + const ids = [...this._selection]; + for (const h of this._handlers) h(ids); + } +} + +export function createIframePreviewAdapter(iframe: HTMLIFrameElement): PreviewAdapter { + return new IframePreviewAdapter(iframe); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 127110347..60acf256d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -39,3 +39,4 @@ export { createMemoryAdapter } from "./adapters/memory.js"; export { createHeadlessAdapter } from "./adapters/headless.js"; export { createHttpAdapter } from "./adapters/http.js"; export type { HttpAdapterOptions } from "./adapters/http.js"; +export { createIframePreviewAdapter, resolveNearestHfElement } from "./adapters/iframe.js";