From f079020128170a0b44fa9d2aad38af2da69bb266 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 16 Jun 2026 21:33:27 -0700 Subject: [PATCH 01/43] =?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 fd0bd2f7c0..da5a528234 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 52ac92e694..3a4724eca9 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 0000000000..040d83b3b0 --- /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 0e22ba1b86..c75632479d 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 0000000000..489113cbe0 --- /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 0000000000..6bb3afee02 --- /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/43] 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 da5a528234..8348ff785d 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 c62b9f20cd..56cfffd98f 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 b4b81b49b5..27155fdd4d 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 c75632479d..9bfd64f71e 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/43] =?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 8348ff785d..fe789a28c9 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 3a4724eca9..5148ca6e47 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 afc949256b..20f0565e83 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 7b85e4c302..df08968f2f 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 aa953fb2d0..b0e253e188 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 809363c4de..44b6635407 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 1e4b4c7d91..6c63669ab1 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 260bbb3105..66d1d64809 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 6bb3afee02..19fd0dfd16 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 462b8d2dc5..0000000000 --- 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 08ed05b08a..0000000000 --- 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 45f800d7f2..0000000000 --- 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 20f08313a2..0000000000 --- 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 38a6331622..0000000000 --- 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 bf8ecd136b..0000000000 --- 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/43] 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 fe789a28c9..b1a64ae572 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 7e97930b03..9292a8c3e9 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 df08968f2f..d51743483a 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/43] =?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 9292a8c3e9..9d8072492e 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 d51743483a..b22332c4a5 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 a30c5bb035..fe1dc3a2f5 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 489113cbe0..397c7dd5ab 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 19fd0dfd16..a2f3a155f9 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/43] =?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 b1a64ae572..8080928433 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 66d1d64809..f2b2ebc395 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 397c7dd5ab..69e1a0d2e7 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 a2f3a155f9..40e5a9b9aa 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/43] =?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 42d0fc2a85..ffe3822da4 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/43] =?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 20f0565e83..db24652de3 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 b22332c4a5..06bb9c85c1 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 b0e253e188..07898a3230 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 26f00e2a28..103b9892d6 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 6c63669ab1..7b092737ed 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 69e1a0d2e7..6694ec6d56 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 40e5a9b9aa..84c715ee2e 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/43] =?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 d015b92a8b..839b1079b1 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 a936e37e6c..2f9f298f60 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 44b6635407..b7179cd862 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 7b092737ed..287431d75b 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 6694ec6d56..e4f758e6d4 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 84c715ee2e..78d156a638 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/43] 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 8080928433..de6ed6a47f 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 db24652de3..60a4b4ba32 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 9d8072492e..a3cb8f2628 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 06bb9c85c1..1fa260c0fc 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 fe1dc3a2f5..1ee885a627 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 07898a3230..66cec59fb7 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 b7179cd862..6f550fbfba 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 103b9892d6..218397b654 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 287431d75b..936e1fb8b0 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 9bfd64f71e..2c0e205011 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 f2b2ebc395..b9d85d4cac 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 584658a765..e60e001d86 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 e4f758e6d4..9e32d1f3c9 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 78d156a638..c4f0b70b52 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/43] =?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 0000000000..f0d351c8c7 --- /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 0000000000..50fedc05ab --- /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 1271103471..60acf256d7 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"; From ec90fd92de108e5ec81dbfd8a9f7488049ab91e2 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 22:58:50 -0700 Subject: [PATCH 12/43] =?UTF-8?q?feat(sdk):=20ws-a2=20=E2=80=94=20applyDra?= =?UTF-8?q?ft/commitPreview/cancelPreview=20=E2=86=92=20moveElement=20op?= 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 | 207 +++++++++++++++++++++-- packages/sdk/src/adapters/iframe.ts | 112 +++++++++++- 2 files changed, 297 insertions(+), 22 deletions(-) diff --git a/packages/sdk/src/adapters/iframe.test.ts b/packages/sdk/src/adapters/iframe.test.ts index f0d351c8c7..879a96186f 100644 --- a/packages/sdk/src/adapters/iframe.test.ts +++ b/packages/sdk/src/adapters/iframe.test.ts @@ -1,14 +1,23 @@ /** - * Unit tests for resolveNearestHfElement (pure resolver — no browser needed). + * Unit tests for the pure functions in iframe.ts (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). + * elementFromPoint requires a real layout engine — the adapter's elementAtPoint() + * is NOT tested here. Cover it with an integration test mounting a same-origin + * iframe (WS-A1 follow-on). + * + * applyDraft / commitPreview / cancelPreview require HTMLElement.style + querySelector + * which are also browser-only. They are tested via a lightweight fake-DOM helper + * that simulates style.setProperty / getAttribute / removeProperty. */ import { describe, it, expect, vi } from "vitest"; -import { resolveNearestHfElement } from "./iframe.js"; +import { + resolveNearestHfElement, + computeDraftPosition, + createIframePreviewAdapter, +} from "./iframe.js"; import type { ElementAtPointResult } from "./types.js"; +import type { EditOp } from "../types.js"; // ─── Minimal fake element ──────────────────────────────────────────────────── @@ -41,7 +50,7 @@ function fakeEl( const visible = () => true; const invisible = () => false; -// ─── Tests ──────────────────────────────────────────────────────────────────── +// ─── resolveNearestHfElement ────────────────────────────────────────────────── describe("resolveNearestHfElement", () => { it("returns null for a null input", () => { @@ -78,16 +87,13 @@ describe("resolveNearestHfElement", () => { }); 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); }); @@ -113,11 +119,31 @@ describe("resolveNearestHfElement", () => { }); }); -// ─── select + on('selection') wiring ───────────────────────────────────────── -// These cover the adapter-level selection state without needing a real iframe. -// We import createIframePreviewAdapter and pass a stub iframe. +// ─── computeDraftPosition ───────────────────────────────────────────────────── + +describe("computeDraftPosition", () => { + it("applies delta to base data-x/data-y", () => { + expect(computeDraftPosition("100", "200", 30, -10)).toEqual({ x: 130, y: 190 }); + }); + + it("defaults missing data-x/data-y to 0", () => { + expect(computeDraftPosition(null, null, 50, 25)).toEqual({ x: 50, y: 25 }); + }); + + it("defaults non-numeric data-x/data-y to 0", () => { + expect(computeDraftPosition("abc", "xyz", 10, 5)).toEqual({ x: 10, y: 5 }); + }); + + it("works with zero delta (no-move commit)", () => { + expect(computeDraftPosition("40", "80", 0, 0)).toEqual({ x: 40, y: 80 }); + }); + + it("handles negative base positions", () => { + expect(computeDraftPosition("-20", "0", 5, 10)).toEqual({ x: -15, y: 10 }); + }); +}); -import { createIframePreviewAdapter } from "./iframe.js"; +// ─── IframePreviewAdapter selection ────────────────────────────────────────── function stubIframe() { return {} as HTMLIFrameElement; @@ -170,3 +196,158 @@ describe("IframePreviewAdapter selection", () => { expect(cb2).toHaveBeenCalledOnce(); }); }); + +// ─── applyDraft / commitPreview / cancelPreview ─────────────────────────────── +// Tests use a fake iframe+element because HTMLElement.style requires a browser. + +interface FakeStyle { + _props: Record; + setProperty(name: string, value: string): void; + getPropertyValue(name: string): string; + removeProperty(name: string): void; +} + +interface FakeDomEl { + "data-hf-id": string; + "data-x": string | null; + "data-y": string | null; + style: FakeStyle; + getAttribute(name: string): string | null; + querySelector(sel: string): FakeDomEl | null; +} + +function fakeDomEl(id: string, dataX: string | null, dataY: string | null): FakeDomEl { + const style: FakeStyle = { + _props: {}, + setProperty(name, value) { + this._props[name] = value; + }, + getPropertyValue(name) { + return this._props[name] ?? ""; + }, + removeProperty(name) { + delete this._props[name]; + }, + }; + const el: FakeDomEl = { + "data-hf-id": id, + "data-x": dataX, + "data-y": dataY, + style, + getAttribute(name) { + if (name === "data-x") return this["data-x"]; + if (name === "data-y") return this["data-y"]; + if (name === "data-hf-id") return this["data-hf-id"]; + return null; + }, + querySelector(_sel: string) { + return null; + }, + }; + return el; +} + +function fakeIframe(el: FakeDomEl | null): HTMLIFrameElement { + return { + contentDocument: { + querySelector(_sel: string) { + return el; + }, + }, + } as unknown as HTMLIFrameElement; +} + +describe("IframePreviewAdapter draft / commit / cancel", () => { + it("commitPreview without applyDraft is a no-op", () => { + const dispatch = vi.fn(); + const adapter = createIframePreviewAdapter(stubIframe(), dispatch); + adapter.commitPreview(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("cancelPreview without applyDraft is a no-op", () => { + const dispatch = vi.fn(); + const adapter = createIframePreviewAdapter(stubIframe(), dispatch); + adapter.cancelPreview(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("commitPreview dispatches moveElement with correct absolute position", () => { + const dispatch = vi.fn(); + const el = fakeDomEl("hf-abc", "100", "200"); + const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch); + + adapter.applyDraft("hf-abc", { dx: 30, dy: -20 }); + adapter.commitPreview(); + + expect(dispatch).toHaveBeenCalledWith<[EditOp]>({ + type: "moveElement", + target: "hf-abc", + x: 130, + y: 180, + }); + }); + + it("commitPreview with missing data-x/data-y defaults base to 0", () => { + const dispatch = vi.fn(); + const el = fakeDomEl("hf-abc", null, null); + const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch); + + adapter.applyDraft("hf-abc", { dx: 50, dy: 25 }); + adapter.commitPreview(); + + expect(dispatch).toHaveBeenCalledWith<[EditOp]>({ + type: "moveElement", + target: "hf-abc", + x: 50, + y: 25, + }); + }); + + it("commitPreview without a dispatch callback is a no-op", () => { + const el = fakeDomEl("hf-abc", "0", "0"); + const adapter = createIframePreviewAdapter(fakeIframe(el)); + + adapter.applyDraft("hf-abc", { dx: 10, dy: 10 }); + // should not throw + adapter.commitPreview(); + }); + + it("cancelPreview clears draft vars without dispatching", () => { + const dispatch = vi.fn(); + const el = fakeDomEl("hf-abc", "100", "200"); + const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch); + + adapter.applyDraft("hf-abc", { dx: 30, dy: 20 }); + adapter.cancelPreview(); + + expect(dispatch).not.toHaveBeenCalled(); + // CSS vars cleared + expect(el.style.getPropertyValue("--hf-studio-dx")).toBe(""); + expect(el.style.getPropertyValue("--hf-studio-dy")).toBe(""); + }); + + it("commitPreview clears draft vars after dispatching", () => { + const dispatch = vi.fn(); + const el = fakeDomEl("hf-abc", "0", "0"); + const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch); + + adapter.applyDraft("hf-abc", { dx: 10, dy: 5 }); + adapter.commitPreview(); + + expect(el.style.getPropertyValue("--hf-studio-dx")).toBe(""); + expect(el.style.getPropertyValue("--hf-studio-dy")).toBe(""); + }); + + it("second commitPreview after first is a no-op (draft cleared)", () => { + const dispatch = vi.fn(); + const el = fakeDomEl("hf-abc", "0", "0"); + const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch); + + adapter.applyDraft("hf-abc", { dx: 10, dy: 5 }); + adapter.commitPreview(); + adapter.commitPreview(); + + expect(dispatch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/src/adapters/iframe.ts b/packages/sdk/src/adapters/iframe.ts index 50fedc05ab..139f689cbb 100644 --- a/packages/sdk/src/adapters/iframe.ts +++ b/packages/sdk/src/adapters/iframe.ts @@ -1,14 +1,20 @@ /** - * Same-origin iframe PreviewAdapter — WS-A1 (hit-test + selection). + * Same-origin iframe PreviewAdapter — WS-A1 (hit-test + selection) + + * WS-A2 (applyDraft / commitPreview / cancelPreview → moveElement). * * 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"; +import type { EditOp } from "../types.js"; + +// ─── CSS var names written onto elements during drag ───────────────────────── + +const VAR_DX = "--hf-studio-dx"; +const VAR_DY = "--hf-studio-dy"; // ─── Pure resolver (testable without a browser) ─────────────────────────────── @@ -41,6 +47,26 @@ export function resolveNearestHfElement( return null; } +// ─── Draft position math (pure — testable without a browser) ───────────────── + +/** + * Compute the new absolute x/y for a moveElement op given: + * - the element's current `data-x` / `data-y` string values (may be null) + * - the accumulated drag delta (dx, dy) from applyDraft calls + * + * `data-x` / `data-y` default to 0 when absent or non-numeric. + */ +export function computeDraftPosition( + dataX: string | null, + dataY: string | null, + dx: number, + dy: number, +): { x: number; y: number } { + const baseX = parseFloat(dataX ?? "0") || 0; + const baseY = parseFloat(dataY ?? "0") || 0; + return { x: baseX + dx, y: baseY + dy }; +} + // ─── Visibility check ───────────────────────────────────────────────────────── /** @@ -69,11 +95,18 @@ type SelectionHandler = (ids: string[]) => void; class IframePreviewAdapter implements PreviewAdapter { private readonly iframe: HTMLIFrameElement; + private readonly _dispatch: ((op: EditOp) => void) | undefined; + private _selection: string[] = []; private _handlers: SelectionHandler[] = []; - constructor(iframe: HTMLIFrameElement) { + /** Tracked id and element for the in-progress drag. */ + private _draftId: string | null = null; + private _draftEl: Element | null = null; + + constructor(iframe: HTMLIFrameElement, dispatch?: (op: EditOp) => void) { this.iframe = iframe; + this._dispatch = dispatch; } /** @@ -94,13 +127,71 @@ class IframePreviewAdapter implements PreviewAdapter { return resolveNearestHfElement(hit, (el) => isOpacityVisible(el, win)); } - // WS-A2 stubs — commitPreview / applyDraft derive the moveElement op -------- + /** + * Write draft CSS custom properties (`--hf-studio-dx`, `--hf-studio-dy`) onto + * the target element inside the iframe at 60fps. The composition's CSS uses + * these vars to visually translate the element without touching the model. + * + * Calling applyDraft with a new id replaces the tracked element (does not + * cancel the prior draft — call cancelPreview first if switching targets). + * + * width/height in DraftProps are not yet wired (resize → setStyle, future op). + */ + applyDraft(id: string, props: DraftProps): void { + const doc = this.iframe.contentDocument; + if (!doc) return; - applyDraft(_id: string, _props: DraftProps): void {} + const el = doc.querySelector( + `[data-hf-id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`, + ); + if (!el) return; - commitPreview(): void {} + this._draftId = id; + this._draftEl = el; - cancelPreview(): void {} + if (props.dx !== undefined) el.style.setProperty(VAR_DX, String(props.dx)); + if (props.dy !== undefined) el.style.setProperty(VAR_DY, String(props.dy)); + } + + /** + * Read the accumulated draft deltas, derive a moveElement op, dispatch it, + * then clear the CSS vars and draft state. + * + * No-ops when: + * - No applyDraft was called (nothing to commit) + * - No dispatch callback was provided at construction + */ + commitPreview(): void { + if (!this._draftId || !this._draftEl || !this._dispatch) { + this._clearDraft(); + return; + } + + const el = this._draftEl as HTMLElement; + const dx = parseFloat(el.style.getPropertyValue(VAR_DX) || "0") || 0; + const dy = parseFloat(el.style.getPropertyValue(VAR_DY) || "0") || 0; + const dataX = (this._draftEl as Element).getAttribute("data-x"); + const dataY = (this._draftEl as Element).getAttribute("data-y"); + const { x, y } = computeDraftPosition(dataX, dataY, dx, dy); + + this._dispatch({ type: "moveElement", target: this._draftId, x, y }); + this._clearDraft(); + } + + /** Revert draft CSS vars without dispatching any op. */ + cancelPreview(): void { + this._clearDraft(); + } + + private _clearDraft(): void { + if (this._draftEl) { + const el = this._draftEl as HTMLElement; + el.style.removeProperty(VAR_DX); + el.style.removeProperty(VAR_DY); + } + this._draftId = null; + this._draftEl = null; + } // Selection ----------------------------------------------------------------- @@ -128,6 +219,9 @@ class IframePreviewAdapter implements PreviewAdapter { } } -export function createIframePreviewAdapter(iframe: HTMLIFrameElement): PreviewAdapter { - return new IframePreviewAdapter(iframe); +export function createIframePreviewAdapter( + iframe: HTMLIFrameElement, + dispatch?: (op: EditOp) => void, +): PreviewAdapter { + return new IframePreviewAdapter(iframe, dispatch); } From 884b2442dd4010fb04fd338b2a432d90d64216d2 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 23:13:03 -0700 Subject: [PATCH 13/43] =?UTF-8?q?feat(sdk,studio):=20ws-4=20=E2=80=94=20ad?= =?UTF-8?q?d=20history:false=20option;=20disable=20unused=20sdk=20undo=20i?= =?UTF-8?q?n=20studio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Miguel Ángel --- packages/sdk/src/session.ts | 8 +++++++- packages/studio/src/hooks/useSdkSession.ts | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index 149b770b64..50cc5e680b 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -48,6 +48,12 @@ export interface OpenCompositionOptions { trackedOrigins?: unknown[]; /** Auto-coalesce window for history entries (ms). Default: 300. */ coalesceMs?: number; + /** + * Pass `false` to skip attaching the history module (undo/redo). + * Default: history is attached in standalone (non-embedded) mode. + * Use when the host owns the undo stack and SDK undo is dead weight. + */ + history?: false; } // ─── Implementation ─────────────────────────────────────────────────────────── @@ -495,7 +501,7 @@ export async function openComposition( const isEmbedded = opts?.overrides !== undefined; - if (!isEmbedded) { + if (!isEmbedded && opts?.history !== false) { const history = createHistory(session, { coalesceMs: opts?.coalesceMs ?? 300, trackedOrigins: opts?.trackedOrigins, diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 2c0e205011..1743814fb6 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -97,7 +97,9 @@ export function useSdkSession( // '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); + // Studio's editHistory is the authoritative undo stack — SDK history + // is unused dead weight here (forceReloadSdkSession discards it on undo). + const comp = await openComposition(content, { history: false }); // Cleanup may have fired while openComposition was awaited; dispose immediately. if (cancelled) { comp.dispose(); From fe1de56f46ed05189501ebe91f3fba6cd61dc5da Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 23:14:37 -0700 Subject: [PATCH 14/43] =?UTF-8?q?feat(sdk,studio):=20ws-1.1=20=E2=80=94=20?= =?UTF-8?q?add=20set=20method=20to=20GsapTweenSpec;=20route=20addGsapAnima?= =?UTF-8?q?tion(set)=20through=20sdk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Miguel Ángel --- packages/sdk/src/types.ts | 2 +- .../studio/src/hooks/useGsapAnimationOps.ts | 18 ++++++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 4220acc8f2..7b9a507ec0 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -113,7 +113,7 @@ export interface ElasticHold { } export interface GsapTweenSpec { - method: "from" | "to" | "fromTo"; + method: "from" | "to" | "fromTo" | "set"; position?: number | string; duration?: number; ease?: string; diff --git a/packages/studio/src/hooks/useGsapAnimationOps.ts b/packages/studio/src/hooks/useGsapAnimationOps.ts index 66cec59fb7..b5318bcd31 100644 --- a/packages/studio/src/hooks/useGsapAnimationOps.ts +++ b/packages/studio/src/hooks/useGsapAnimationOps.ts @@ -126,20 +126,18 @@ export function useGsapAnimationOps({ fromTo: { x: 0, y: 0, opacity: 1 }, }; - // 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) { + // Skip 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 && selection.hfId && sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const spec = { - method: method as "to" | "from" | "fromTo", + method, position, - duration, - ease: "power2.out" as const, + ...(method !== "set" ? { duration, ease: "power2.out" as const } : {}), properties: toDefaults[method] ?? { opacity: 1 }, - fromProperties: method === "fromTo" ? { opacity: 0 } : undefined, + ...(method === "fromTo" ? { fromProperties: { opacity: 0 } } : {}), }; const handled = await sdkGsapTweenPersist( targetPath, From 8437d9204b2bbaaedade3ed270afbde4b948b72d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 23:20:12 -0700 Subject: [PATCH 15/43] =?UTF-8?q?feat(sdk,studio):=20ws-1.2=20=E2=80=94=20?= =?UTF-8?q?percentage-based=20removeGsapKeyframe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Miguel Ángel --- packages/sdk/src/engine/mutate.ts | 26 +++++++++++++++- packages/sdk/src/types.ts | 1 + .../studio/src/hooks/useGsapKeyframeOps.ts | 31 +++++++++++++------ packages/studio/src/utils/sdkCutover.ts | 24 ++++++++++++++ 4 files changed, 72 insertions(+), 10 deletions(-) diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index f21f6a1e68..380aab3a86 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -178,7 +178,9 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { case "addGsapKeyframe": return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value); case "removeGsapKeyframe": - return handleRemoveGsapKeyframe(parsed, op.animationId, op.keyframeIndex); + return "percentage" in op + ? handleRemoveGsapKeyframeByPercentage(parsed, op.animationId, op.percentage) + : handleRemoveGsapKeyframe(parsed, op.animationId, op.keyframeIndex); case "addLabel": return handleAddLabel(parsed, op.name, op.position); case "removeLabel": @@ -772,6 +774,28 @@ function handleAddGsapKeyframe( return gsapScriptChange(script, newScript); } +function handleRemoveGsapKeyframeByPercentage( + parsed: ParsedDocument, + animationId: string, + percentage: number, +): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const parsedForWrite = parseGsapScriptAcornForWrite(script); + const located = parsedForWrite?.located.find((l) => l.id === animationId); + const kfs = located?.animation.keyframes?.keyframes; + if (!kfs) return EMPTY; + // No-op on ambiguity: duplicate-percentage keyframes can't be disambiguated. + const TOLERANCE = 0.001; + const matches = kfs.filter((k) => Math.abs(k.percentage - percentage) <= TOLERANCE); + if (matches.length !== 1) return EMPTY; + const pct = matches[0]!.percentage; + const newScript = removeKeyframeFromScript(script, animationId, pct); + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + return gsapScriptChange(script, newScript); +} + function handleRemoveGsapKeyframe( parsed: ParsedDocument, animationId: string, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 7b9a507ec0..c246cffa67 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -102,6 +102,7 @@ export type EditOp = value: Record; } | { type: "removeGsapKeyframe"; animationId: string; keyframeIndex: number } + | { type: "removeGsapKeyframe"; animationId: string; percentage: number } | { type: "removeGsapTween"; animationId: string } | { type: "addLabel"; name: string; position: number } | { type: "removeLabel"; name: string }; diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.ts b/packages/studio/src/hooks/useGsapKeyframeOps.ts index 6f550fbfba..c049d92f8f 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.ts +++ b/packages/studio/src/hooks/useGsapKeyframeOps.ts @@ -3,7 +3,11 @@ 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, type CutoverDeps } from "../utils/sdkCutover"; +import { + sdkGsapKeyframePersist, + sdkGsapRemoveKeyframePersist, + type CutoverDeps, +} from "../utils/sdkCutover"; import type { KeyframeCacheEntry } from "../player/store/playerStore"; import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit"; import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers"; @@ -151,9 +155,6 @@ export function useGsapKeyframeOps({ 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({ @@ -162,19 +163,31 @@ export function useGsapKeyframeOps({ apply: (prev) => ({ ...prev, keyframes: prev.keyframes.filter( - (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2, + (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.001, ), }), - persist: () => - commitMutation(selection, mutation, { + persist: async () => { + if (sdkSession && sdkDeps) { + const handled = await sdkGsapRemoveKeyframePersist( + sourceFile, + animationId, + percentage, + sdkSession, + sdkDeps, + { label: `Remove keyframe at ${percentage}%` }, + ); + if (handled) return; + } + await commitMutation(selection, mutation, { label: `Remove keyframe at ${percentage}%`, softReload: true, - }), + }); + }, }).catch((error) => { trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`); }); }, - [activeCompPath, commitMutation, trackGsapSaveFailure], + [activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps], ); const convertToKeyframes = useCallback( diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index c4f0b70b52..1bc864c4b1 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -255,6 +255,30 @@ export async function sdkGsapKeyframePersist( } } +export async function sdkGsapRemoveKeyframePersist( + targetPath: string, + animationId: string, + percentage: number, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + if (!sdkSession) return false; + if (wrongCompositionFile(deps, targetPath)) return false; + try { + const before = sdkSession.serialize(); + sdkSession.dispatch({ type: "removeGsapKeyframe", animationId, percentage }); + 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) { + trackStudioEvent("sdk_cutover_fallback", { error: String(err) }); + return false; + } +} + export async function sdkDeletePersist( hfId: string, originalContent: string, From c5df306ff445d4684952d690a523c94bda11e7cd Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 23:44:01 -0700 Subject: [PATCH 16/43] =?UTF-8?q?feat(sdk,studio):=20ws-1.3=20=E2=80=94=20?= =?UTF-8?q?removeGsapProperty=20SDK=20op=20+=20Studio=20hook=20cutover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- packages/core/src/parsers/gsapWriterAcorn.ts | 21 +++++ packages/sdk/src/engine/mutate.ts | 82 +++++++++++----- packages/sdk/src/types.ts | 1 + .../studio/src/hooks/useDomEditCommits.ts | 5 - .../src/hooks/useGsapPropertyDebounce.ts | 67 +++++++++---- packages/studio/src/utils/sdkCutover.ts | 94 ++++++++++--------- 6 files changed, 177 insertions(+), 93 deletions(-) diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 2f9f298f60..fb011ef14d 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -878,6 +878,27 @@ export function removeKeyframeFromScript( return ms.toString(); } +export function removePropertyFromAnimation( + script: string, + animationId: string, + property: string, + from = false, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const { call } = target; + const objNode = from ? (call.method === "fromTo" ? call.fromArg : null) : call.varsArg; + if (!objNode) return script; + const propNode = findPropertyNode(objNode, property); + if (!propNode) return script; + const allProps = (objNode.properties ?? []).filter((p: any) => isObjectProperty(p)); + const ms = new MagicString(script); + removeProp(ms, propNode, allProps); + return ms.toString(); +} + // ── Label write ops ─────────────────────────────────────────────────────────── export function addLabelToScript(script: string, name: string, position: number): string { diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 380aab3a86..e0ce13a0d1 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -47,6 +47,7 @@ import { addAnimationToScript, updateAnimationInScript, removeAnimationFromScript, + removePropertyFromAnimation, addKeyframeToScript, removeKeyframeFromScript, updateKeyframeInScript, @@ -136,7 +137,46 @@ function targets(target: HfId | HfId[]): HfId[] { // ─── Op dispatch ──────────────────────────────────────────────────────────── +function dispatchRemoveGsapKeyframe( + parsed: ParsedDocument, + op: Extract, +): MutationResult { + return "percentage" in op + ? handleRemoveGsapKeyframeByPercentage(parsed, op.animationId, op.percentage) + : handleRemoveGsapKeyframe(parsed, op.animationId, op.keyframeIndex); +} + +function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined { + switch (op.type) { + case "addGsapTween": + return handleAddGsapTween(parsed, op.target, op.tween); + case "setGsapTween": + return handleSetGsapTween(parsed, op.animationId, op.properties); + case "removeGsapProperty": + return handleRemoveGsapProperty(parsed, op.animationId, op.property, op.from); + case "removeGsapTween": + return handleRemoveGsapTween(parsed, op.animationId); + case "setGsapKeyframe": + return handleSetGsapKeyframe( + parsed, + op.animationId, + op.keyframeIndex, + op.position, + op.value, + op.ease, + ); + case "addGsapKeyframe": + return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value); + case "removeGsapKeyframe": + return dispatchRemoveGsapKeyframe(parsed, op); + default: + return undefined; + } +} + export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { + const gsap = applyGsapOp(parsed, op); + if (gsap !== undefined) return gsap; switch (op.type) { case "setStyle": return handleSetStyle(parsed, targets(op.target), op.styles); @@ -160,33 +200,14 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { return handleSetCompositionMetadata(parsed, op); case "setVariableValue": return handleSetVariableValue(parsed, op.id, op.value); - case "addGsapTween": - return handleAddGsapTween(parsed, op.target, op.tween); - case "setGsapTween": - return handleSetGsapTween(parsed, op.animationId, op.properties); - case "removeGsapTween": - return handleRemoveGsapTween(parsed, op.animationId); - case "setGsapKeyframe": - return handleSetGsapKeyframe( - parsed, - op.animationId, - op.keyframeIndex, - op.position, - op.value, - op.ease, - ); - case "addGsapKeyframe": - return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value); - case "removeGsapKeyframe": - return "percentage" in op - ? handleRemoveGsapKeyframeByPercentage(parsed, op.animationId, op.percentage) - : handleRemoveGsapKeyframe(parsed, op.animationId, op.keyframeIndex); + case "setClassStyle": + return handleSetClassStyle(parsed, op.selector, op.styles); case "addLabel": return handleAddLabel(parsed, op.name, op.position); case "removeLabel": return handleRemoveLabel(parsed, op.name); - case "setClassStyle": - return handleSetClassStyle(parsed, op.selector, op.styles); + default: + throw new UnsupportedOpError((op as EditOp).type); } } @@ -691,6 +712,20 @@ function handleSetGsapTween( return gsapScriptChange(script, newScript); } +function handleRemoveGsapProperty( + parsed: ParsedDocument, + animationId: string, + property: string, + from: boolean | undefined, +): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const newScript = removePropertyFromAnimation(script, animationId, property, from ?? false); + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + return gsapScriptChange(script, newScript); +} + function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): MutationResult { const script = getGsapScript(parsed.document); if (!script) return EMPTY; @@ -897,6 +932,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "setGsapKeyframe": case "addGsapKeyframe": case "removeGsapKeyframe": + case "removeGsapProperty": case "removeGsapTween": case "removeLabel": if (getGsapScript(parsed.document) === null) diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index c246cffa67..9b7ecc158d 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -103,6 +103,7 @@ export type EditOp = } | { type: "removeGsapKeyframe"; animationId: string; keyframeIndex: number } | { type: "removeGsapKeyframe"; animationId: string; percentage: number } + | { type: "removeGsapProperty"; animationId: string; property: string; from?: boolean } | { type: "removeGsapTween"; animationId: string } | { type: "addLabel"; name: string; position: number } | { type: "removeLabel"; name: string }; diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index a3cb8f2628..aae11d62d1 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -16,9 +16,6 @@ import { useDomGeometryCommits } from "./useDomGeometryCommits"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; import { formatFieldsSuffix } from "./gsapScriptCommitHelpers"; -// Re-export so existing consumers keep their import path -export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits"; - // ── Helpers ── function formatUnsafeFieldList(fields: Array<{ path: string }>): string { @@ -45,8 +42,6 @@ interface RecordEditInput { files: Record; } -export type { PersistDomEditOperations } from "./domEditCommitTypes"; - export interface UseDomEditCommitsParams { activeCompPath: string | null; previewIframeRef: React.MutableRefObject; diff --git a/packages/studio/src/hooks/useGsapPropertyDebounce.ts b/packages/studio/src/hooks/useGsapPropertyDebounce.ts index 218397b654..0d408e02b7 100644 --- a/packages/studio/src/hooks/useGsapPropertyDebounce.ts +++ b/packages/studio/src/hooks/useGsapPropertyDebounce.ts @@ -1,7 +1,11 @@ import { useCallback, useEffect, useRef } from "react"; import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import { sdkGsapTweenPersist, type CutoverDeps } from "../utils/sdkCutover"; +import { + sdkGsapTweenPersist, + sdkGsapRemovePropertyPersist, + type CutoverDeps, +} from "../utils/sdkCutover"; import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers"; import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; @@ -110,16 +114,47 @@ export function useGsapPropertyDebounce( [commitMutationSafely, sdk], ); - 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 }, - { label: `Remove GSAP ${property}` }, - ); + const removeProperty = useCallback( + async (selection: DomEditSelection, animationId: string, property: string, from: boolean) => { + const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapRemovePropertyPersist( + targetPath, + animationId, + property, + from, + sdkSession, + sdkDeps, + { label: `Remove GSAP ${from ? `from-${property}` : property}` }, + ); + if (handled) return; + } + if (from) { + commitMutationSafely( + selection, + { type: "remove-from-property", animationId, property }, + { + label: `Remove GSAP from-${property}`, + }, + ); + } else { + commitMutationSafely( + selection, + { type: "remove-property", animationId, property }, + { + label: `Remove GSAP ${property}`, + }, + ); + } }, - [commitMutationSafely], + [commitMutationSafely, sdk], + ); + + const removeGsapProperty = useCallback( + (selection: DomEditSelection, animationId: string, property: string) => + removeProperty(selection, animationId, property, false), + [removeProperty], ); const updateGsapFromProperty = useCallback( @@ -185,15 +220,9 @@ export function useGsapPropertyDebounce( ); 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 }, - { label: `Remove GSAP from-${property}` }, - ); - }, - [commitMutationSafely], + (selection: DomEditSelection, animationId: string, property: string) => + removeProperty(selection, animationId, property, true), + [removeProperty], ); return { diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 1bc864c4b1..3d8ee7d3e0 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -192,58 +192,43 @@ type SdkGsapTweenOp = | { kind: "set"; animationId: string; properties: Partial } | { kind: "remove"; animationId: string }; -export async function sdkGsapTweenPersist( +export function sdkGsapTweenPersist( targetPath: string, op: SdkGsapTweenOp, sdkSession: Composition | null | undefined, deps: CutoverDeps, 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(); - sdkSession.batch(() => { + if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target)) + return Promise.resolve(false); + // dispatchGsapOpAndPersist returns false on before===after — that catches stale + // animationIds and unsupported shapes (e.g. from-prop on a plain tween), falling + // back to the server path. This subsumes explicit existence guards for set/remove. + return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => { + s.batch(() => { if (op.kind === "add") { - sdkSession.addGsapTween(op.target, op.spec); + s.addGsapTween(op.target, op.spec); } else if (op.kind === "set") { - sdkSession.setGsapTween(op.animationId, op.properties); + s.setGsapTween(op.animationId, op.properties); } else { - sdkSession.removeGsapTween(op.animationId); + s.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) { - trackStudioEvent("sdk_cutover_fallback", { error: String(err) }); - return false; - } + }); } -export async function sdkGsapKeyframePersist( +async function dispatchGsapOpAndPersist( targetPath: string, - animationId: string, - position: number, - value: Record, sdkSession: Composition | null | undefined, deps: CutoverDeps, - options?: CutoverOptions, + options: CutoverOptions | undefined, + dispatch: (s: Composition) => void, ): Promise { if (!sdkSession) return false; if (wrongCompositionFile(deps, targetPath)) return false; try { const before = sdkSession.serialize(); - sdkSession.batch(() => - sdkSession.dispatch({ type: "addGsapKeyframe", animationId, position, value }), - ); + dispatch(sdkSession); const after = sdkSession.serialize(); if (after === before) return false; await persistSdkSerialize(after, targetPath, before, deps, options); @@ -255,7 +240,21 @@ export async function sdkGsapKeyframePersist( } } -export async function sdkGsapRemoveKeyframePersist( +export function sdkGsapKeyframePersist( + targetPath: string, + animationId: string, + position: number, + value: Record, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => + s.batch(() => s.dispatch({ type: "addGsapKeyframe", animationId, position, value })), + ); +} + +export function sdkGsapRemoveKeyframePersist( targetPath: string, animationId: string, percentage: number, @@ -263,20 +262,23 @@ export async function sdkGsapRemoveKeyframePersist( deps: CutoverDeps, options?: CutoverOptions, ): Promise { - if (!sdkSession) return false; - if (wrongCompositionFile(deps, targetPath)) return false; - try { - const before = sdkSession.serialize(); - sdkSession.dispatch({ type: "removeGsapKeyframe", animationId, percentage }); - 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) { - trackStudioEvent("sdk_cutover_fallback", { error: String(err) }); - return false; - } + return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => + s.dispatch({ type: "removeGsapKeyframe", animationId, percentage }), + ); +} + +export function sdkGsapRemovePropertyPersist( + targetPath: string, + animationId: string, + property: string, + from: boolean, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => + s.dispatch({ type: "removeGsapProperty", animationId, property, from }), + ); } export async function sdkDeletePersist( From 00209722f32d1337338ad62c9f4758f227544c5b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 23:48:23 -0700 Subject: [PATCH 17/43] =?UTF-8?q?feat(sdk,studio):=20ws-1.4=20=E2=80=94=20?= =?UTF-8?q?deleteAllForSelector=20SDK=20op=20+=20Studio=20hook=20cutover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- packages/sdk/src/engine/mutate.ts | 21 ++++++++++++++++++ packages/sdk/src/types.ts | 1 + .../studio/src/hooks/useGsapAnimationOps.ts | 22 +++++++++++++++---- packages/studio/src/utils/sdkCutover.ts | 12 ++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index e0ce13a0d1..7a1e965cbd 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -156,6 +156,8 @@ function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undef return handleRemoveGsapProperty(parsed, op.animationId, op.property, op.from); case "removeGsapTween": return handleRemoveGsapTween(parsed, op.animationId); + case "deleteAllForSelector": + return handleDeleteAllForSelector(parsed, op.selector); case "setGsapKeyframe": return handleSetGsapKeyframe( parsed, @@ -735,6 +737,24 @@ function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): Mut return gsapScriptChange(script, newScript); } +function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const parsedForWrite = parseGsapScriptAcornForWrite(script); + if (!parsedForWrite) return EMPTY; + const matching = parsedForWrite.located.filter((l) => l.animation.targetSelector === selector); + if (matching.length === 0) return EMPTY; + let newScript = script; + for (const m of [...matching].reverse()) { + newScript = removeAnimationFromScript(newScript, m.id); + } + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + // ponytail: skips stripStudioEditsFromTarget (data-hf-studio-path-offset cleanup) — + // studio path offset is cosmetic once all animations are gone; session reloads after write + return gsapScriptChange(script, newScript); +} + function resolveKeyframe(parsed: ParsedDocument, animationId: string, keyframeIndex: number) { const script = getGsapScript(parsed.document); if (!script) return null; @@ -934,6 +954,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "removeGsapKeyframe": case "removeGsapProperty": case "removeGsapTween": + case "deleteAllForSelector": case "removeLabel": if (getGsapScript(parsed.document) === null) return canErr( diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 9b7ecc158d..269a69fbe4 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -105,6 +105,7 @@ export type EditOp = | { type: "removeGsapKeyframe"; animationId: string; percentage: number } | { type: "removeGsapProperty"; animationId: string; property: string; from?: boolean } | { type: "removeGsapTween"; animationId: string } + | { type: "deleteAllForSelector"; selector: string } | { type: "addLabel"; name: string; position: number } | { type: "removeLabel"; name: string }; diff --git a/packages/studio/src/hooks/useGsapAnimationOps.ts b/packages/studio/src/hooks/useGsapAnimationOps.ts index b5318bcd31..701576e018 100644 --- a/packages/studio/src/hooks/useGsapAnimationOps.ts +++ b/packages/studio/src/hooks/useGsapAnimationOps.ts @@ -2,7 +2,11 @@ import { useCallback } from "react"; import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { roundTo3 } from "../utils/rounding"; -import { sdkGsapTweenPersist, type CutoverDeps } from "../utils/sdkCutover"; +import { + sdkGsapTweenPersist, + sdkGsapDeleteAllForSelectorPersist, + type CutoverDeps, +} from "../utils/sdkCutover"; import { assignGsapTargetAutoIdIfNeeded, ensureElementAddressable, @@ -80,15 +84,25 @@ export function useGsapAnimationOps({ ); const deleteAllForSelector = useCallback( - (selection: DomEditSelection, targetSelector: string) => { - // ponytail: no SDK op for delete-all-for-selector; stays server-authoritative + async (selection: DomEditSelection, targetSelector: string) => { + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapDeleteAllForSelectorPersist( + targetPath, + targetSelector, + sdkSession, + sdkDeps, + { label: "Delete all animations for element" }, + ); + if (handled) return; + } void commitMutation( selection, { type: "delete-all-for-selector", targetSelector }, { label: "Delete all animations for element" }, ); }, - [commitMutation], + [commitMutation, activeCompPath, sdkSession, sdkDeps], ); // fallow-ignore-next-line complexity diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 3d8ee7d3e0..1de7b96a16 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -281,6 +281,18 @@ export function sdkGsapRemovePropertyPersist( ); } +export function sdkGsapDeleteAllForSelectorPersist( + targetPath: string, + selector: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => + s.dispatch({ type: "deleteAllForSelector", selector }), + ); +} + export async function sdkDeletePersist( hfId: string, originalContent: string, From 2c368d1f2431750b25f2a2448ad6ac4d6ef56a0b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 23:52:51 -0700 Subject: [PATCH 18/43] fix(core): cascade-remove GSAP tweens in removeElementFromHtml (WS-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- packages/core/src/parsers/htmlParser.ts | 39 +++++++++++++++++++++---- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/core/src/parsers/htmlParser.ts b/packages/core/src/parsers/htmlParser.ts index 7a9d6c2d27..df02653af0 100644 --- a/packages/core/src/parsers/htmlParser.ts +++ b/packages/core/src/parsers/htmlParser.ts @@ -12,6 +12,8 @@ import type { } from "../core.types"; import { validateCompositionGsap } from "./gsapSerialize"; import { ensureHfIds } from "./hfIds.js"; +import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js"; +import { removeAnimationFromScript } from "./gsapWriterAcorn.js"; import type { ValidationResult } from "../core.types"; const MEDIA_TYPES = new Set(["video", "image", "audio"]); @@ -672,15 +674,40 @@ export function addElementToHtml( }; } -export function removeElementFromHtml(html: string, elementId: string): string { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, "text/html"); +function selectorTargetsId(selector: string, id: string): boolean { + return ( + selector === `#${id}` || + selector === `[data-hf-id="${id}"]` || + selector === `[data-hf-id='${id}']` + ); +} - const el = doc.getElementById(elementId); - if (el) { - el.remove(); +function stripGsapForId(script: string, elementId: string): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + let current = script; + for (const { id: animId, animation } of parsed.located) { + if (selectorTargetsId(animation.targetSelector, elementId)) { + current = removeAnimationFromScript(current, animId); + } } + return current; +} +function cascadeRemoveGsapById(doc: Document, elementId: string): void { + for (const script of Array.from(doc.querySelectorAll("script"))) { + const text = script.textContent ?? ""; + if (!text.includes("gsap") && !text.includes("ScrollTrigger")) continue; + const updated = stripGsapForId(text, elementId); + if (updated !== text) script.textContent = updated; + } +} + +export function removeElementFromHtml(html: string, elementId: string): string { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + doc.getElementById(elementId)?.remove(); + cascadeRemoveGsapById(doc, elementId); return "\n" + doc.documentElement.outerHTML; } From a60eef0b60b61ac1854cc9d544f58ba794c323fe Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 16 Jun 2026 00:09:05 -0700 Subject: [PATCH 19/43] =?UTF-8?q?feat(sdk,core):=20ws-3=20prerequisites=20?= =?UTF-8?q?=E2=80=94=20acorn=20keyframe-collapse=20foundation=20+=20remove?= =?UTF-8?q?AllKeyframes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: gsapWriter.parity.test.ts — recast-vs-acorn parity harness (reparse-equivalence). P2: move pure keyframe-conversion transforms (resolveConversionProps, cssIdentityValue) to recast-free gsapSerialize.ts so the acorn/SDK path can share them. P3: MagicString splice primitives in gsapWriterAcorn.ts (buildVarsObjectCode, overwriteVarsArg). P4: reference vertical slice — removeAllKeyframesFromScript ported to acorn writer + removeAllKeyframes SDK op (types/mutate/can) + Studio cutover (useGsapKeyframeOps), replacing the server-authoritative ponytail stub. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/parsers/gsapParser.ts | 57 +---------- packages/core/src/parsers/gsapSerialize.ts | 62 ++++++++++++ .../src/parsers/gsapWriter.parity.test.ts | 97 +++++++++++++++++++ packages/core/src/parsers/gsapWriterAcorn.ts | 46 +++++++++ packages/sdk/src/engine/mutate.gsap.test.ts | 26 +++++ packages/sdk/src/engine/mutate.ts | 44 ++++++--- packages/sdk/src/types.ts | 1 + .../studio/src/hooks/useGsapKeyframeOps.ts | 17 +++- packages/studio/src/utils/sdkCutover.ts | 12 +++ 9 files changed, 292 insertions(+), 70 deletions(-) create mode 100644 packages/core/src/parsers/gsapWriter.parity.test.ts diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index e0e9498de8..e274ee1bb4 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -20,6 +20,7 @@ import { type ParsedGsap, serializeValue as valueToCode, safeJsKey as safeKey, + resolveConversionProps, } from "./gsapSerialize"; export type { @@ -2009,62 +2010,6 @@ export function updateKeyframeInScript( return recast.print(loc.parsed.ast).code; } -/** Resolve from/to property maps for a tween being converted to keyframes. */ -const CSS_IDENTITY: Record = { - opacity: 1, - autoAlpha: 1, - scale: 1, - scaleX: 1, - scaleY: 1, -}; - -function cssIdentityValue(prop: string): number { - return CSS_IDENTITY[prop] ?? 0; -} - -/** - * Resolve the 0% (from) and 100% (to) property maps for a tween being - * converted to percentage keyframes. - * - * @param resolvedFromValues — Despite the "from" in the name (historical), these - * are runtime-captured DOM values that override the conversion endpoint: - * - For to(): overrides fromProps (the 0% state / where the element is now). - * - For from(): overrides toProps (the 100% state / where the element rests). - * - For fromTo(): merges into toProps (the 100% endpoint the user is editing). - */ -function resolveConversionProps( - anim: GsapAnimation, - resolvedFromValues?: Record, -): { fromProps: Record; toProps: Record } { - if (anim.method === "to") { - const identityFrom: Record = {}; - for (const [key, val] of Object.entries(anim.properties)) { - if (val != null) identityFrom[key] = typeof val === "number" ? cssIdentityValue(key) : val; - } - const fromProps = resolvedFromValues - ? { ...identityFrom, ...resolvedFromValues } - : identityFrom; - return { fromProps, toProps: { ...anim.properties } }; - } - if (anim.method === "from") { - const identityTo: Record = {}; - for (const [key, val] of Object.entries(anim.properties)) { - if (val != null) identityTo[key] = typeof val === "number" ? cssIdentityValue(key) : val; - } - const toProps = resolvedFromValues ? { ...identityTo, ...resolvedFromValues } : identityTo; - return { fromProps: { ...anim.properties }, toProps }; - } - // fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state), - // anim.properties = toVars (100% state). resolvedFromValues contains the - // current DOM position from a drag — it represents the NEW destination, so - // it merges into toProps (the 100% endpoint the user is editing), NOT into - // fromProps. This is intentional and not inverted. - const toProps = resolvedFromValues - ? { ...anim.properties, ...resolvedFromValues } - : { ...anim.properties }; - return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps }; -} - /** Strip editable properties and ease/keyframes keys from a varsArg. */ function stripEditableAndEase(varsArg: AstNode): void { // ease is a BUILTIN_VAR_KEY (not editable), so filterEditableKeys won't remove it — diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index 9f3ea1f04c..291c515f6f 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -413,3 +413,65 @@ export function gsapAnimationsToKeyframes( .filter((kf): kf is NonNullable => kf !== null) ); } + +// ── Keyframe-conversion transforms (pure; shared by recast + acorn writers) ──── + +/** + * CSS identity values for properties whose "rest" state isn't 0 — used to + * synthesize the missing endpoint when converting a flat tween to keyframes. + */ +const CSS_IDENTITY: Record = { + opacity: 1, + autoAlpha: 1, + scale: 1, + scaleX: 1, + scaleY: 1, +}; + +function cssIdentityValue(prop: string): number { + return CSS_IDENTITY[prop] ?? 0; +} + +/** Build the identity-endpoint map for a flat tween's properties. */ +function buildIdentityMap(props: Record): Record { + const identity: Record = {}; + for (const [key, val] of Object.entries(props)) { + if (val != null) identity[key] = typeof val === "number" ? cssIdentityValue(key) : val; + } + return identity; +} + +/** + * Resolve the 0% (from) and 100% (to) property maps for a tween being + * converted to percentage keyframes. + * + * @param resolvedFromValues — Despite the "from" in the name (historical), these + * are runtime-captured DOM values that override the conversion endpoint: + * - For to(): overrides fromProps (the 0% state / where the element is now). + * - For from(): overrides toProps (the 100% state / where the element rests). + * - For fromTo(): merges into toProps (the 100% endpoint the user is editing). + */ +export function resolveConversionProps( + anim: GsapAnimation, + resolvedFromValues?: Record, +): { fromProps: Record; toProps: Record } { + if (anim.method === "to") { + const identity = buildIdentityMap(anim.properties); + const fromProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity; + return { fromProps, toProps: { ...anim.properties } }; + } + if (anim.method === "from") { + const identity = buildIdentityMap(anim.properties); + const toProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity; + return { fromProps: { ...anim.properties }, toProps }; + } + // fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state), + // anim.properties = toVars (100% state). resolvedFromValues contains the + // current DOM position from a drag — it represents the NEW destination, so + // it merges into toProps (the 100% endpoint the user is editing), NOT into + // fromProps. This is intentional and not inverted. + const toProps = resolvedFromValues + ? { ...anim.properties, ...resolvedFromValues } + : { ...anim.properties }; + return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps }; +} diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/core/src/parsers/gsapWriter.parity.test.ts new file mode 100644 index 0000000000..33b0587056 --- /dev/null +++ b/packages/core/src/parsers/gsapWriter.parity.test.ts @@ -0,0 +1,97 @@ +/** + * Parity harness — recast writer (gsapParser.ts) vs acorn writer + * (gsapWriterAcorn.ts). Both must produce scripts that REPARSE to the same + * animation model. Byte-equality is not expected (recast pretty-prints, acorn + * splices), so parity is asserted on the parsed GsapAnimation, not raw text. + * + * This is the safety net for porting WS-3 ops one at a time: each ported op + * gets a fixture row here proving it matches the battle-tested original. + */ +import { describe, expect, it } from "vitest"; +import { parseGsapScript, removeAllKeyframesFromScript as removeAllRecast } from "./gsapParser.js"; +import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite } from "./gsapParserAcorn.js"; +import { removeAllKeyframesFromScript as removeAllAcorn } from "./gsapWriterAcorn.js"; + +function acornId(script: string): string { + const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite; + return parsed.located[0]!.id; +} + +/** Reparse a written script and return the first animation's editable shape. */ +function shapeOf(script: string) { + const anim = parseGsapScript(script).animations[0]!; + return { + method: anim.method, + properties: anim.properties, + keyframes: anim.keyframes, + duration: anim.duration, + ease: anim.ease, + }; +} + +const REMOVE_ALL_FIXTURES: Array<{ name: string; script: string }> = [ + { + name: "to() — collapses to last keyframe", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200, opacity: 1 } }, + duration: 2 + }, 0); + `, + }, + { + name: "to() — single keyframe + ease", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { + keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, + duration: 1, + ease: "none" + }, 0.5); + `, + }, + { + name: "to() — easeEach dropped on collapse", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#card", { + keyframes: { "0%": { y: 0 }, "100%": { y: -40 }, easeEach: "power2.inOut" }, + duration: 1.5 + }, 0); + `, + }, +]; + +describe("parity: removeAllKeyframesFromScript (recast vs acorn)", () => { + for (const { name, script } of REMOVE_ALL_FIXTURES) { + it(name, () => { + const id = acornId(script); + // Sanity: recast and acorn agree on the id for this tween. + expect(parseGsapScript(script).animations[0]!.id).toBe(id); + + const recastOut = removeAllRecast(script, id); + const acornOut = removeAllAcorn(script, id); + + const recastShape = shapeOf(recastOut); + const acornShape = shapeOf(acornOut); + + expect(acornShape.keyframes).toBeUndefined(); + expect(acornShape).toEqual(recastShape); + }); + } + + it("no-op when id not found", () => { + const script = REMOVE_ALL_FIXTURES[0]!.script; + expect(removeAllAcorn(script, "nonexistent-id")).toBe(script); + }); + + it("no-op when tween has no keyframes", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#flat", { x: 100, duration: 1 }, 0); + `; + const id = acornId(script); + expect(removeAllAcorn(script, id)).toBe(script); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index fb011ef14d..042183725a 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -127,6 +127,18 @@ function removeProp(ms: MagicString, propNode: any, editableProps: any[]): void } } +/** Serialize a vars record to an object-literal source: `{ k: v, ... }`. */ +function buildVarsObjectCode(record: Record): string { + const entries = Object.entries(record).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}"; +} + +/** Overwrite a tween call's vars ObjectExpression with freshly-built source. */ +function overwriteVarsArg(ms: MagicString, call: TweenCallInfo, objCode: string): void { + if (!call.varsArg) return; + ms.overwrite(call.varsArg.start, call.varsArg.end, objCode); +} + /** * Update a property value if it exists, or append a new key: val before the * closing `}`. Call with the full ObjectExpression node. @@ -899,6 +911,40 @@ export function removePropertyFromAnimation( return ms.toString(); } +/** + * Remove all keyframes from a tween, collapsing to a flat tween with one + * keyframe's properties: the first for `from()`, the last otherwise (the + * destination = the visible resting state). + */ +export function removeAllKeyframesFromScript(script: string, animationId: string): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const kfs = target.animation.keyframes?.keyframes; + if (!kfs || kfs.length === 0) return script; + + const sorted = [...kfs].sort((a, b) => a.percentage - b.percentage); + const collapse = target.call.method === "from" ? sorted[0]! : sorted[sorted.length - 1]!; + + // Flat vars = existing top-level props, then collapse-keyframe props (these + // win; skip the per-keyframe `ease` key), then duration/ease/extras. Drops + // keyframes + easeEach by reconstruction. + const flat: Record = { ...target.animation.properties }; + for (const [k, v] of Object.entries(collapse.properties)) { + if (k !== "ease") flat[k] = v; + } + if (target.animation.duration !== undefined) flat.duration = target.animation.duration; + if (target.animation.ease) flat.ease = target.animation.ease; + for (const [k, v] of Object.entries(target.animation.extras ?? {})) { + if (typeof v === "number" || typeof v === "string") flat[k] = v; + } + + const ms = new MagicString(script); + overwriteVarsArg(ms, target.call, buildVarsObjectCode(flat)); + return ms.toString(); +} + // ── Label write ops ─────────────────────────────────────────────────────────── export function addLabelToScript(script: string, name: string, position: number): string { diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index 771eff2360..0e127723e1 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -510,6 +510,32 @@ describe("removeGsapKeyframe", () => { }); }); +describe("removeAllKeyframes", () => { + it("collapses keyframed to() tween to last keyframe's props", () => { + const parsed = fresh(KF_SCRIPT); + const animId = `[data-hf-id="hf-box"]-to-0-visual`; + const result = applyOp(parsed, { type: "removeAllKeyframes", animationId: animId }); + expect(result.forward).toHaveLength(1); + const newScript = String(result.forward[0]?.value ?? ""); + expect(newScript).not.toContain("keyframes"); + expect(newScript).not.toContain('"50%"'); + expect(newScript).toContain("opacity: 1"); + }); + + it("no-op (empty patch) when animation id not found", () => { + const parsed = fresh(KF_SCRIPT); + const result = applyOp(parsed, { type: "removeAllKeyframes", animationId: "nope" }); + expect(result.forward).toHaveLength(0); + }); + + it("no-op when tween has no keyframes", () => { + const parsed = fresh(GSAP_SCRIPT); + const animId = `[data-hf-id="hf-box"]-to-0-visual`; + const result = applyOp(parsed, { type: "removeAllKeyframes", animationId: animId }); + expect(result.forward).toHaveLength(0); + }); +}); + // ─── Label ops ──────────────────────────────────────────────────────────────── describe("addLabel", () => { diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 7a1e965cbd..054f3f6638 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -50,6 +50,7 @@ import { removePropertyFromAnimation, addKeyframeToScript, removeKeyframeFromScript, + removeAllKeyframesFromScript, updateKeyframeInScript, addLabelToScript, removeLabelFromScript, @@ -146,18 +147,8 @@ function dispatchRemoveGsapKeyframe( : handleRemoveGsapKeyframe(parsed, op.animationId, op.keyframeIndex); } -function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined { +function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined { switch (op.type) { - case "addGsapTween": - return handleAddGsapTween(parsed, op.target, op.tween); - case "setGsapTween": - return handleSetGsapTween(parsed, op.animationId, op.properties); - case "removeGsapProperty": - return handleRemoveGsapProperty(parsed, op.animationId, op.property, op.from); - case "removeGsapTween": - return handleRemoveGsapTween(parsed, op.animationId); - case "deleteAllForSelector": - return handleDeleteAllForSelector(parsed, op.selector); case "setGsapKeyframe": return handleSetGsapKeyframe( parsed, @@ -171,6 +162,27 @@ function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undef return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value); case "removeGsapKeyframe": return dispatchRemoveGsapKeyframe(parsed, op); + case "removeAllKeyframes": + return handleRemoveAllKeyframes(parsed, op.animationId); + default: + return undefined; + } +} + +function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined { + const kf = applyGsapKeyframeOp(parsed, op); + if (kf !== undefined) return kf; + switch (op.type) { + case "addGsapTween": + return handleAddGsapTween(parsed, op.target, op.tween); + case "setGsapTween": + return handleSetGsapTween(parsed, op.animationId, op.properties); + case "removeGsapProperty": + return handleRemoveGsapProperty(parsed, op.animationId, op.property, op.from); + case "removeGsapTween": + return handleRemoveGsapTween(parsed, op.animationId); + case "deleteAllForSelector": + return handleDeleteAllForSelector(parsed, op.selector); default: return undefined; } @@ -737,6 +749,15 @@ function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): Mut return gsapScriptChange(script, newScript); } +function handleRemoveAllKeyframes(parsed: ParsedDocument, animationId: string): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const newScript = removeAllKeyframesFromScript(script, animationId); + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + return gsapScriptChange(script, newScript); +} + function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult { const script = getGsapScript(parsed.document); if (!script) return EMPTY; @@ -954,6 +975,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "removeGsapKeyframe": case "removeGsapProperty": case "removeGsapTween": + case "removeAllKeyframes": case "deleteAllForSelector": case "removeLabel": if (getGsapScript(parsed.document) === null) diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 269a69fbe4..9c04bd9be2 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -105,6 +105,7 @@ export type EditOp = | { type: "removeGsapKeyframe"; animationId: string; percentage: number } | { type: "removeGsapProperty"; animationId: string; property: string; from?: boolean } | { type: "removeGsapTween"; animationId: string } + | { type: "removeAllKeyframes"; animationId: string } | { type: "deleteAllForSelector"; selector: string } | { type: "addLabel"; name: string; position: number } | { type: "removeLabel"; name: string }; diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.ts b/packages/studio/src/hooks/useGsapKeyframeOps.ts index c049d92f8f..b5c56f6967 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.ts +++ b/packages/studio/src/hooks/useGsapKeyframeOps.ts @@ -6,6 +6,7 @@ import { executeOptimistic } from "../utils/optimisticUpdate"; import { sdkGsapKeyframePersist, sdkGsapRemoveKeyframePersist, + sdkGsapRemoveAllKeyframesPersist, type CutoverDeps, } from "../utils/sdkCutover"; import type { KeyframeCacheEntry } from "../player/store/playerStore"; @@ -207,15 +208,25 @@ export function useGsapKeyframeOps({ ); const removeAllKeyframes = useCallback( - (selection: DomEditSelection, animationId: string) => { - // ponytail: no SDK equivalent for remove-all-keyframes; stays server-authoritative + async (selection: DomEditSelection, animationId: string) => { + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapRemoveAllKeyframesPersist( + targetPath, + animationId, + sdkSession, + sdkDeps, + { label: "Remove all keyframes" }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "remove-all-keyframes", animationId }, { label: "Remove all keyframes", softReload: true }, ); }, - [commitMutationSafely], + [commitMutationSafely, activeCompPath, sdkSession, sdkDeps], ); const commitKeyframeAtTime = useCallback( diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 1de7b96a16..afe85db4be 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -293,6 +293,18 @@ export function sdkGsapDeleteAllForSelectorPersist( ); } +export function sdkGsapRemoveAllKeyframesPersist( + targetPath: string, + animationId: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => + s.dispatch({ type: "removeAllKeyframes", animationId }), + ); +} + export async function sdkDeletePersist( hfId: string, originalContent: string, From 761221e58374ed03eb4ce47b014a1aadee8bc2c2 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 16 Jun 2026 00:29:01 -0700 Subject: [PATCH 20/43] =?UTF-8?q?feat(sdk,core):=20ws-3=20=E2=80=94=20conv?= =?UTF-8?q?ertToKeyframes=20acorn=20port=20+=20SDK=20op=20+=20Studio=20cut?= =?UTF-8?q?over?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- .../src/parsers/gsapWriter.parity.test.ts | 85 ++++++++++++++++++- packages/core/src/parsers/gsapWriterAcorn.ts | 56 +++++++++++- packages/sdk/src/engine/mutate.gsap.test.ts | 47 ++++++++++ packages/sdk/src/engine/mutate.ts | 17 ++++ packages/sdk/src/types.ts | 5 ++ .../studio/src/hooks/useGsapKeyframeOps.ts | 18 +++- packages/studio/src/utils/sdkCutover.ts | 13 +++ 7 files changed, 234 insertions(+), 7 deletions(-) diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/core/src/parsers/gsapWriter.parity.test.ts index 33b0587056..3da54f199c 100644 --- a/packages/core/src/parsers/gsapWriter.parity.test.ts +++ b/packages/core/src/parsers/gsapWriter.parity.test.ts @@ -8,9 +8,16 @@ * gets a fixture row here proving it matches the battle-tested original. */ import { describe, expect, it } from "vitest"; -import { parseGsapScript, removeAllKeyframesFromScript as removeAllRecast } from "./gsapParser.js"; +import { + parseGsapScript, + removeAllKeyframesFromScript as removeAllRecast, + convertToKeyframesInScript as convertRecast, +} from "./gsapParser.js"; import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite } from "./gsapParserAcorn.js"; -import { removeAllKeyframesFromScript as removeAllAcorn } from "./gsapWriterAcorn.js"; +import { + removeAllKeyframesFromScript as removeAllAcorn, + convertToKeyframesFromScript as convertAcorn, +} from "./gsapWriterAcorn.js"; function acornId(script: string): string { const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite; @@ -95,3 +102,77 @@ describe("parity: removeAllKeyframesFromScript (recast vs acorn)", () => { expect(removeAllAcorn(script, id)).toBe(script); }); }); + +const CONVERT_FIXTURES: Array<{ + name: string; + script: string; + resolvedFromValues?: Record; +}> = [ + { + name: "to() — builds 0%/100% keyframes with identity from", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 200, opacity: 0.5, duration: 1.5 }, 0); + `, + }, + { + name: "to() — with ease becomes easeEach + ease: none", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { x: 100, duration: 1, ease: "power2.out" }, 0); + `, + }, + { + name: "from() — method renamed to to()", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.from("#card", { y: -50, opacity: 0, duration: 0.8 }, 0); + `, + }, + { + name: "fromTo() — method renamed, fromArg removed", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.fromTo("#text", { x: 0 }, { x: 300, duration: 2 }, 0); + `, + }, + { + name: "to() — with resolvedFromValues overrides 0%", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { x: 100, duration: 1 }, 0); + `, + resolvedFromValues: { x: 42 }, + }, +]; + +describe("parity: convertToKeyframesFromScript (recast vs acorn)", () => { + for (const { name, script, resolvedFromValues } of CONVERT_FIXTURES) { + it(name, () => { + const id = acornId(script); + const recastOut = convertRecast(script, id, resolvedFromValues); + const acornOut = convertAcorn(script, id, resolvedFromValues); + + const recastShape = shapeOf(recastOut); + const acornShape = shapeOf(acornOut); + + expect(acornShape.keyframes).toBeDefined(); + expect(acornShape.method).toBe("to"); + expect(acornShape).toEqual(recastShape); + }); + } + + it("no-op when id not found", () => { + const script = CONVERT_FIXTURES[0]!.script; + expect(convertAcorn(script, "nonexistent-id")).toBe(script); + }); + + it("no-op when tween already has keyframes", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, duration: 1 }, 0); + `; + const id = acornId(script); + expect(convertAcorn(script, id)).toBe(script); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 042183725a..03567a6dc6 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -7,7 +7,8 @@ * pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts. */ import MagicString from "magic-string"; -import { serializeValue, safeJsKey, type GsapAnimation } from "./gsapSerialize.js"; +import type { GsapAnimation } from "./gsapSerialize.js"; +import { resolveConversionProps } from "./gsapSerialize.js"; import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite, @@ -519,7 +520,7 @@ function percentageFromKey(key: string): number { /** Serialize a final keyframe property record (number|string values) to code. */ function recordToCode(record: Record): string { - const entries = Object.entries(record).map(([k, v]) => `${safeJsKey(k)}: ${serializeValue(v)}`); + const entries = Object.entries(record).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); return `{ ${entries.join(", ")} }`; } @@ -945,6 +946,57 @@ export function removeAllKeyframesFromScript(script: string, animationId: string return ms.toString(); } +/** Build the full replacement vars object for a tween being converted to keyframes. */ +function buildKeyframesVarsCode( + animation: GsapAnimation, + fromProps: Record, + toProps: Record, +): string { + const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + const easeEntry = animation.ease ? `, easeEach: ${JSON.stringify(animation.ease)}` : ""; + const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} }${easeEntry} }`; + const parts: string[] = [`keyframes: ${kfCode}`]; + if (animation.duration !== undefined) parts.push(`duration: ${valueToCode(animation.duration)}`); + if (animation.ease) parts.push(`ease: "none"`); + for (const [k, v] of Object.entries(animation.extras ?? {})) { + if (typeof v === "number" || typeof v === "string") + parts.push(`${safeKey(k)}: ${valueToCode(v)}`); + } + return `{ ${parts.join(", ")} }`; +} + +/** + * Convert a flat tween (to/from/fromTo) to percentage-keyframes format. + * `resolvedFromValues` supplies the current DOM state: overrides the 0% endpoint + * for `to()`, the 100% endpoint for `from()`, or merges into toProps for `fromTo()`. + */ +export function convertToKeyframesFromScript( + script: string, + animationId: string, + resolvedFromValues?: Record, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const { animation, call } = target; + if (animation.keyframes || call.method === "set") return script; + + const { fromProps, toProps } = resolveConversionProps(animation, resolvedFromValues); + const ms = new MagicString(script); + + if (call.method === "from" || call.method === "fromTo") { + ms.overwrite(call.node.callee.property.start, call.node.callee.property.end, "to"); + } + if (call.method === "fromTo" && call.fromArg) { + ms.remove(call.fromArg.start, call.varsArg.start); + } + overwriteVarsArg(ms, call, buildKeyframesVarsCode(animation, fromProps, toProps)); + + return ms.toString(); +} + // ── Label write ops ─────────────────────────────────────────────────────────── export function addLabelToScript(script: string, name: string, position: number): string { diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index 0e127723e1..1af854d98e 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -536,6 +536,53 @@ describe("removeAllKeyframes", () => { }); }); +// ─── convertToKeyframes ──────────────────────────────────────────────────────── + +describe("convertToKeyframes", () => { + // GSAP_SCRIPT: position 0.2 → id suffix "200"; opacity = visual group + it("converts flat to() tween to percentage keyframes", () => { + const parsed = fresh(); + const result = applyOp(parsed, { type: "convertToKeyframes", animationId: TWEEN_ANIM_ID }); + expect(result.forward).toHaveLength(1); + const newScript = String(result.forward[0]?.value ?? ""); + expect(newScript).toContain("keyframes"); + expect(newScript).toContain('"0%"'); + expect(newScript).toContain('"100%"'); + expect(newScript).toContain("easeEach"); + expect(newScript).toContain('ease: "none"'); + }); + + it("passes resolvedFromValues into 0% endpoint", () => { + const script = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { x: 200, duration: 1 }, 0); +window.__timelines["t"] = tl;`; + const parsed = fresh(script); + // position 0 → "0"; x = position group + const animId = `[data-hf-id="hf-box"]-to-0-position`; + const result = applyOp(parsed, { + type: "convertToKeyframes", + animationId: animId, + resolvedFromValues: { x: 42 }, + }); + expect(result.forward).toHaveLength(1); + const newScript = String(result.forward[0]?.value ?? ""); + expect(newScript).toContain("42"); + }); + + it("no-op when animation already has keyframes", () => { + const parsed = fresh(KF_SCRIPT); + const animId = `[data-hf-id="hf-box"]-to-0-visual`; + const result = applyOp(parsed, { type: "convertToKeyframes", animationId: animId }); + expect(result.forward).toHaveLength(0); + }); + + it("no-op when animation id not found", () => { + const parsed = fresh(); + const result = applyOp(parsed, { type: "convertToKeyframes", animationId: "nope" }); + expect(result.forward).toHaveLength(0); + }); +}); + // ─── Label ops ──────────────────────────────────────────────────────────────── describe("addLabel", () => { diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 054f3f6638..23eb6ccbc8 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -51,6 +51,7 @@ import { addKeyframeToScript, removeKeyframeFromScript, removeAllKeyframesFromScript, + convertToKeyframesFromScript, updateKeyframeInScript, addLabelToScript, removeLabelFromScript, @@ -164,6 +165,8 @@ function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult return dispatchRemoveGsapKeyframe(parsed, op); case "removeAllKeyframes": return handleRemoveAllKeyframes(parsed, op.animationId); + case "convertToKeyframes": + return handleConvertToKeyframes(parsed, op.animationId, op.resolvedFromValues); default: return undefined; } @@ -758,6 +761,19 @@ function handleRemoveAllKeyframes(parsed: ParsedDocument, animationId: string): return gsapScriptChange(script, newScript); } +function handleConvertToKeyframes( + parsed: ParsedDocument, + animationId: string, + resolvedFromValues?: Record, +): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const newScript = convertToKeyframesFromScript(script, animationId, resolvedFromValues); + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + return gsapScriptChange(script, newScript); +} + function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult { const script = getGsapScript(parsed.document); if (!script) return EMPTY; @@ -976,6 +992,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "removeGsapProperty": case "removeGsapTween": case "removeAllKeyframes": + case "convertToKeyframes": case "deleteAllForSelector": case "removeLabel": if (getGsapScript(parsed.document) === null) diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 9c04bd9be2..439edc160c 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -106,6 +106,11 @@ export type EditOp = | { type: "removeGsapProperty"; animationId: string; property: string; from?: boolean } | { type: "removeGsapTween"; animationId: string } | { type: "removeAllKeyframes"; animationId: string } + | { + type: "convertToKeyframes"; + animationId: string; + resolvedFromValues?: Record; + } | { type: "deleteAllForSelector"; selector: string } | { type: "addLabel"; name: string; position: number } | { type: "removeLabel"; name: string }; diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.ts b/packages/studio/src/hooks/useGsapKeyframeOps.ts index b5c56f6967..8ff6c2aa48 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.ts +++ b/packages/studio/src/hooks/useGsapKeyframeOps.ts @@ -7,6 +7,7 @@ import { sdkGsapKeyframePersist, sdkGsapRemoveKeyframePersist, sdkGsapRemoveAllKeyframesPersist, + sdkGsapConvertToKeyframesPersist, type CutoverDeps, } from "../utils/sdkCutover"; import type { KeyframeCacheEntry } from "../player/store/playerStore"; @@ -192,19 +193,30 @@ export function useGsapKeyframeOps({ ); const convertToKeyframes = useCallback( - ( + async ( selection: DomEditSelection, animationId: string, resolvedFromValues?: Record, ) => { - // ponytail: no SDK equivalent; convertToKeyframes stays server-authoritative (T6f scope) + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapConvertToKeyframesPersist( + targetPath, + animationId, + resolvedFromValues, + sdkSession, + sdkDeps, + { label: "Convert to keyframes" }, + ); + if (handled) return; + } return commitMutation( selection, { type: "convert-to-keyframes", animationId, resolvedFromValues }, { label: "Convert to keyframes" }, ); }, - [commitMutation], + [commitMutation, activeCompPath, sdkSession, sdkDeps], ); const removeAllKeyframes = useCallback( diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index afe85db4be..a6df2ce4bb 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -305,6 +305,19 @@ export function sdkGsapRemoveAllKeyframesPersist( ); } +export function sdkGsapConvertToKeyframesPersist( + targetPath: string, + animationId: string, + resolvedFromValues: Record | undefined, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => + s.dispatch({ type: "convertToKeyframes", animationId, resolvedFromValues }), + ); +} + export async function sdkDeletePersist( hfId: string, originalContent: string, From dc604365055639ccc605fb129f1fe334e9974c1a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 16 Jun 2026 00:46:10 -0700 Subject: [PATCH 21/43] =?UTF-8?q?feat(sdk,core):=20ws-3=20=E2=80=94=20mate?= =?UTF-8?q?rializeKeyframes=20+=20splitIntoPropertyGroups=20acorn=20ports?= =?UTF-8?q?=20+=20SDK=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - acorn: buildKeyframeObjectCode, materializeKeyframesFromScript, addAnimationWithKeyframesToScript - acorn: splitIntoPropertyGroupsFromScript with filterGroupKeyframes/filterGroupProperties helpers - parity tests: materialize (2 positive + 1 no-op) and split (2 positive + 2 no-op) suites - SDK types: materializeKeyframes + splitIntoPropertyGroups EditOp variants - mutate.ts: handlers + can() gates for both new ops - mutate.gsap.test.ts: 6 new tests (53 total passing) Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- .../src/parsers/gsapWriter.parity.test.ts | 125 +++++++++ packages/core/src/parsers/gsapWriterAcorn.ts | 260 +++++++++++++++++- packages/sdk/src/engine/mutate.gsap.test.ts | 83 ++++++ packages/sdk/src/engine/mutate.ts | 51 ++++ packages/sdk/src/types.ts | 12 + 5 files changed, 530 insertions(+), 1 deletion(-) diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/core/src/parsers/gsapWriter.parity.test.ts index 3da54f199c..4ca25d7878 100644 --- a/packages/core/src/parsers/gsapWriter.parity.test.ts +++ b/packages/core/src/parsers/gsapWriter.parity.test.ts @@ -12,11 +12,15 @@ import { parseGsapScript, removeAllKeyframesFromScript as removeAllRecast, convertToKeyframesInScript as convertRecast, + materializeKeyframesInScript as materializeRecast, + splitIntoPropertyGroups as splitGroupsRecast, } from "./gsapParser.js"; import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite } from "./gsapParserAcorn.js"; import { removeAllKeyframesFromScript as removeAllAcorn, convertToKeyframesFromScript as convertAcorn, + materializeKeyframesFromScript as materializeAcorn, + splitIntoPropertyGroupsFromScript as splitGroupsAcorn, } from "./gsapWriterAcorn.js"; function acornId(script: string): string { @@ -176,3 +180,124 @@ describe("parity: convertToKeyframesFromScript (recast vs acorn)", () => { expect(convertAcorn(script, id)).toBe(script); }); }); + +// ── materializeKeyframes parity ─────────────────────────────────────────────── + +const MATERIALIZE_KFS = [ + { percentage: 0, properties: { x: 0, opacity: 1 } }, + { percentage: 50, properties: { x: 150, opacity: 0.5 } }, + { percentage: 100, properties: { x: 300, opacity: 0 } }, +]; + +const MATERIALIZE_FIXTURES: Array<{ + name: string; + script: string; + kfs: typeof MATERIALIZE_KFS; + easeEach?: string; +}> = [ + { + name: "flat tween — adds keyframes property", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 300, duration: 2 }, 0); + `, + kfs: MATERIALIZE_KFS, + }, + { + name: "with easeEach", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { opacity: 0, duration: 1 }, 0); + `, + kfs: [ + { percentage: 0, properties: { opacity: 1 } }, + { percentage: 100, properties: { opacity: 0 } }, + ], + easeEach: "power2.inOut", + }, +]; + +describe("parity: materializeKeyframesFromScript (recast vs acorn)", () => { + for (const { name, script, kfs, easeEach } of MATERIALIZE_FIXTURES) { + it(name, () => { + const id = acornId(script); + const recastOut = materializeRecast(script, id, kfs, easeEach); + const acornOut = materializeAcorn(script, id, kfs, easeEach); + const recastShape = shapeOf(recastOut); + const acornShape = shapeOf(acornOut); + expect(acornShape.keyframes).toBeDefined(); + expect(acornShape).toEqual(recastShape); + }); + } + + it("no-op when id not found", () => { + const script = MATERIALIZE_FIXTURES[0]!.script; + expect(materializeAcorn(script, "nope", MATERIALIZE_KFS)).toBe(script); + }); +}); + +// ── splitIntoPropertyGroups parity ──────────────────────────────────────────── + +function shapesOf(script: string) { + return parseGsapScript(script).animations.map((a) => ({ + method: a.method, + properties: a.properties, + keyframes: a.keyframes, + duration: a.duration, + ease: a.ease, + selector: a.targetSelector, + propertyGroup: a.propertyGroup, + })); +} + +const SPLIT_FIXTURES: Array<{ name: string; script: string }> = [ + { + name: "flat mixed tween — splits into position + visual groups", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 100, y: 50, opacity: 0.5, duration: 1 }, 0); + `, + }, + { + name: "keyframed mixed tween — splits per group", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { x: 0, opacity: 1 }, "100%": { x: 200, opacity: 0 } }, duration: 1 }, 0); + `, + }, +]; + +describe("parity: splitIntoPropertyGroupsFromScript (recast vs acorn)", () => { + for (const { name, script } of SPLIT_FIXTURES) { + it(name, () => { + const id = acornId(script); + const { script: recastOut } = splitGroupsRecast(script, id); + const { script: acornOut } = splitGroupsAcorn(script, id); + const recastShapes = shapesOf(recastOut); + const acornShapes = shapesOf(acornOut); + expect(acornShapes).toHaveLength(recastShapes.length); + expect(acornShapes.length).toBeGreaterThan(1); + // Each produced group should match its counterpart by propertyGroup + const sortByGroup = (arr: typeof recastShapes) => + arr.slice().sort((a, b) => (a.propertyGroup ?? "").localeCompare(b.propertyGroup ?? "")); + expect(sortByGroup(acornShapes)).toEqual(sortByGroup(recastShapes)); + }); + } + + it("no-op when id not found", () => { + const script = SPLIT_FIXTURES[0]!.script; + const { script: out, ids } = splitGroupsAcorn(script, "nope"); + expect(out).toBe(script); + expect(ids).toEqual(["nope"]); + }); + + it("no-op when single-group tween", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { x: 100, y: 50, duration: 1 }, 0); + `; + const id = acornId(script); + const { script: out } = splitGroupsAcorn(script, id); + expect(out).toBe(script); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 03567a6dc6..d7d2e3ff3f 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -7,13 +7,15 @@ * pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts. */ import MagicString from "magic-string"; -import type { GsapAnimation } from "./gsapSerialize.js"; +import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapSerialize.js"; import { resolveConversionProps } from "./gsapSerialize.js"; import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite, type TweenCallInfo, } from "./gsapParserAcorn.js"; +import { classifyPropertyGroup } from "./gsapConstants.js"; +import type { PropertyGroupName } from "./gsapConstants.js"; import * as acornWalk from "acorn-walk"; // ── Code generation helpers ────────────────────────────────────────────────── @@ -997,6 +999,262 @@ export function convertToKeyframesFromScript( return ms.toString(); } +// ── Keyframe-object code builder ───────────────────────────────────────────── + +/** Build a percentage-keyframes object literal: `{ "0%": { x: 0 }, "100%": { x: 100 } }`. */ +function buildKeyframeObjectCode( + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>, + easeEach?: string, +): string { + const entries = keyframes.map((kf) => { + const props = Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`); + return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`; + }); + if (easeEach) entries.push(`easeEach: ${JSON.stringify(easeEach)}`); + return `{ ${entries.join(", ")} }`; +} + +// ── Materialize keyframes ──────────────────────────────────────────────────── + +/** + * Replace a dynamic or static keyframes expression with a fully-resolved + * percentage-keyframes object. Called when a user first edits a dynamically- + * generated keyframe in the studio so it becomes statically editable. + */ +export function materializeKeyframesFromScript( + script: string, + animationId: string, + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>, + easeEach?: string, + resolvedSelector?: string, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const { call } = target; + const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage); + const kfObjCode = buildKeyframeObjectCode(sorted, easeEach); + const ms = new MagicString(script); + + if (resolvedSelector) { + const selectorArg = call.node.arguments[0]; + if (selectorArg) + ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(resolvedSelector)); + } + + const kfProp = findPropertyNode(call.varsArg, "keyframes"); + if (kfProp) { + ms.overwrite(kfProp.value.start, kfProp.value.end, kfObjCode); + } else if (call.varsArg?.type === "ObjectExpression") { + const vars = call.varsArg; + if (vars.properties.length > 0) { + ms.prependLeft(vars.properties[0].start, `keyframes: ${kfObjCode}, `); + } else { + ms.appendLeft(vars.end - 1, `keyframes: ${kfObjCode}`); + } + } + + const eachProp = findPropertyNode(call.varsArg, "easeEach"); + if (eachProp) { + const allProps = (call.varsArg.properties ?? []).filter((p: any) => isObjectProperty(p)); + removeProp(ms, eachProp, allProps); + } + + return ms.toString(); +} + +// ── Add animation with keyframes ────────────────────────────────────────────── + +/** Insert a new keyframed `to()` call and return the new animation ID. */ +export function addAnimationWithKeyframesToScript( + script: string, + targetSelector: string, + position: number, + duration: number, + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>, + ease?: string, +): { script: string; id: string } { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return { script, id: "" }; + const insertionPoint = findInsertionPoint(parsed); + if (insertionPoint === null) return { script, id: "" }; + + const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage); + const kfObjCode = buildKeyframeObjectCode(sorted); + const varParts = [`keyframes: ${kfObjCode}`, `duration: ${valueToCode(duration)}`]; + if (ease) varParts.push(`ease: ${JSON.stringify(ease)}`); + const stmtCode = `${parsed.timelineVar}.to(${JSON.stringify(targetSelector)}, { ${varParts.join(", ")} }, ${valueToCode(position)});`; + + const ms = new MagicString(script); + ms.appendLeft(insertionPoint, "\n" + stmtCode); + + const result = ms.toString(); + const reParsed = parseGsapScriptAcornForWrite(result); + const newId = reParsed?.located[reParsed.located.length - 1]?.id ?? ""; + return { script: result, id: newId }; +} + +// ── Split into property groups ──────────────────────────────────────────────── + +function collectPropertyKeys(anim: GsapAnimation): Set { + const keys = new Set(); + if (anim.keyframes) { + for (const kf of anim.keyframes.keyframes) { + for (const k of Object.keys(kf.properties)) keys.add(k); + } + } else { + for (const k of Object.keys(anim.properties)) keys.add(k); + } + return keys; +} + +function partitionPropertyGroups(keys: Set): Map { + const groups = new Map(); + for (const key of keys) { + if (key === "transformOrigin") continue; + const group = classifyPropertyGroup(key); + let arr = groups.get(group); + if (!arr) { + arr = []; + groups.set(group, arr); + } + arr.push(key); + } + return groups; +} + +function assignTransformOrigin(groupProps: Map): void { + let largestGroup: PropertyGroupName | undefined; + let largestCount = 0; + for (const [group, props] of groupProps) { + if (props.length > largestCount) { + largestCount = props.length; + largestGroup = group; + } + } + if (largestGroup) groupProps.get(largestGroup)!.push("transformOrigin"); +} + +function filterGroupKeyframes( + kfs: GsapPercentageKeyframe[], + propSet: Set, +): Array<{ percentage: number; properties: Record; ease?: string }> { + const result: Array<{ + percentage: number; + properties: Record; + ease?: string; + }> = []; + for (const kf of kfs) { + const filtered: Record = {}; + for (const [k, v] of Object.entries(kf.properties)) { + if (propSet.has(k)) filtered[k] = v; + } + if (Object.keys(filtered).length > 0) { + result.push({ + percentage: kf.percentage, + properties: filtered, + ...(kf.ease ? { ease: kf.ease } : {}), + }); + } + } + return result; +} + +function filterGroupProperties( + properties: Record, + propSet: Set, +): Record { + const result: Record = {}; + for (const [k, v] of Object.entries(properties)) { + if (propSet.has(k)) result[k] = v; + } + return result; +} + +function addGroupAnimToScript( + script: string, + anim: GsapAnimation, + propSet: Set, +): { script: string; id: string } { + if (anim.keyframes) { + const groupKeyframes = filterGroupKeyframes(anim.keyframes.keyframes, propSet); + if (groupKeyframes.length === 0) return { script, id: "" }; + const pos = typeof anim.position === "number" ? anim.position : 0; + return addAnimationWithKeyframesToScript( + script, + anim.targetSelector, + pos, + anim.duration ?? 0.5, + groupKeyframes, + anim.keyframes.easeEach ?? anim.ease, + ); + } + const groupProperties = filterGroupProperties(anim.properties, propSet); + if (Object.keys(groupProperties).length === 0) return { script, id: "" }; + const fromProperties = + anim.method === "fromTo" && anim.fromProperties + ? filterGroupProperties(anim.fromProperties, propSet) + : undefined; + return addAnimationToScript(script, { + targetSelector: anim.targetSelector, + method: anim.method, + position: anim.position, + duration: anim.duration, + ease: anim.ease, + properties: groupProperties, + fromProperties, + extras: anim.extras, + }); +} + +/** + * Split a mixed-property tween into one tween per property group (position, + * scale, visual, etc.) so each group can be edited independently. + * Returns the updated script and the IDs of the newly-created tweens. + */ +export function splitIntoPropertyGroupsFromScript( + script: string, + animationId: string, +): { script: string; ids: string[] } { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return { script, ids: [animationId] }; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return { script, ids: [animationId] }; + const { animation } = target; + + const allPropKeys = collectPropertyKeys(animation); + const groupProps = partitionPropertyGroups(allPropKeys); + if (groupProps.size <= 1) return { script, ids: [animationId] }; + if (allPropKeys.has("transformOrigin")) assignTransformOrigin(groupProps); + + let result = removeAnimationFromScript(script, animationId); + for (const [, props] of groupProps) { + const { script: next, id } = addGroupAnimToScript(result, animation, new Set(props)); + if (id) result = next; + } + + const reParsed = parseGsapScriptAcornForWrite(result); + const newIds = (reParsed?.located ?? []) + .filter((l) => l.animation.targetSelector === animation.targetSelector) + .map((l) => l.id); + return { script: result, ids: newIds }; +} + // ── Label write ops ─────────────────────────────────────────────────────────── export function addLabelToScript(script: string, name: string, position: number): string { diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index 1af854d98e..064709b73b 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -583,6 +583,89 @@ window.__timelines["t"] = tl;`; }); }); +// ─── materializeKeyframes ───────────────────────────────────────────────────── + +describe("materializeKeyframes", () => { + it("adds keyframes property to flat tween", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "materializeKeyframes", + animationId: TWEEN_ANIM_ID, + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ], + }); + expect(result.forward).toHaveLength(1); + const newScript = String(result.forward[0]?.value ?? ""); + expect(newScript).toContain("keyframes"); + expect(newScript).toContain('"0%"'); + expect(newScript).toContain('"100%"'); + }); + + it("injects easeEach into keyframes object", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "materializeKeyframes", + animationId: TWEEN_ANIM_ID, + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ], + easeEach: "power2.out", + }); + const newScript = String(result.forward[0]?.value ?? ""); + expect(newScript).toContain("easeEach"); + expect(newScript).toContain("power2.out"); + }); + + it("no-op when animation id not found", () => { + const parsed = fresh(); + const result = applyOp(parsed, { + type: "materializeKeyframes", + animationId: "nope", + keyframes: [{ percentage: 0, properties: { opacity: 0 } }], + }); + expect(result.forward).toHaveLength(0); + }); +}); + +// ─── splitIntoPropertyGroups ────────────────────────────────────────────────── + +describe("splitIntoPropertyGroups", () => { + it("splits mixed tween into multiple group tweens", () => { + const script = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, opacity: 0.5, duration: 1 }, 0); +window.__timelines["t"] = tl;`; + const parsed = fresh(script); + // mixed tween has no propertyGroup → no group suffix in id + const animId = `[data-hf-id="hf-box"]-to-0`; + const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: animId }); + expect(result.forward).toHaveLength(1); + const newScript = String(result.forward[0]?.value ?? ""); + // x is position group, opacity is visual group — expect 2 tweens + const toCount = (newScript.match(/\.to\(/g) ?? []).length; + expect(toCount).toBe(2); + }); + + it("no-op when animation id not found", () => { + const parsed = fresh(); + const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: "nope" }); + expect(result.forward).toHaveLength(0); + }); + + it("no-op when tween has only one property group", () => { + // x + y = same "position" group → nothing to split + const script = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, y: 50, duration: 1 }, 0); +window.__timelines["t"] = tl;`; + const parsed = fresh(script); + const animId = `[data-hf-id="hf-box"]-to-0-position`; + const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: animId }); + expect(result.forward).toHaveLength(0); + }); +}); + // ─── Label ops ──────────────────────────────────────────────────────────────── describe("addLabel", () => { diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 23eb6ccbc8..c820749bda 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -52,6 +52,8 @@ import { removeKeyframeFromScript, removeAllKeyframesFromScript, convertToKeyframesFromScript, + materializeKeyframesFromScript, + splitIntoPropertyGroupsFromScript, updateKeyframeInScript, addLabelToScript, removeLabelFromScript, @@ -167,6 +169,16 @@ function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult return handleRemoveAllKeyframes(parsed, op.animationId); case "convertToKeyframes": return handleConvertToKeyframes(parsed, op.animationId, op.resolvedFromValues); + case "materializeKeyframes": + return handleMaterializeKeyframes( + parsed, + op.animationId, + op.keyframes, + op.easeEach, + op.resolvedSelector, + ); + case "splitIntoPropertyGroups": + return handleSplitIntoPropertyGroups(parsed, op.animationId); default: return undefined; } @@ -774,6 +786,43 @@ function handleConvertToKeyframes( return gsapScriptChange(script, newScript); } +function handleMaterializeKeyframes( + parsed: ParsedDocument, + animationId: string, + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>, + easeEach?: string, + resolvedSelector?: string, +): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const newScript = materializeKeyframesFromScript( + script, + animationId, + keyframes, + easeEach, + resolvedSelector, + ); + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + return gsapScriptChange(script, newScript); +} + +function handleSplitIntoPropertyGroups( + parsed: ParsedDocument, + animationId: string, +): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const { script: newScript } = splitIntoPropertyGroupsFromScript(script, animationId); + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + return gsapScriptChange(script, newScript); +} + function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult { const script = getGsapScript(parsed.document); if (!script) return EMPTY; @@ -993,6 +1042,8 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "removeGsapTween": case "removeAllKeyframes": case "convertToKeyframes": + case "materializeKeyframes": + case "splitIntoPropertyGroups": case "deleteAllForSelector": case "removeLabel": if (getGsapScript(parsed.document) === null) diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 439edc160c..c71cb8138c 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -112,6 +112,18 @@ export type EditOp = resolvedFromValues?: Record; } | { type: "deleteAllForSelector"; selector: string } + | { + type: "materializeKeyframes"; + animationId: string; + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>; + easeEach?: string; + resolvedSelector?: string; + } + | { type: "splitIntoPropertyGroups"; animationId: string } | { type: "addLabel"; name: string; position: number } | { type: "removeLabel"; name: string }; From a29a0c6a19b47e3626e3826247627e3854ef96bd Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 16 Jun 2026 00:52:47 -0700 Subject: [PATCH 22/43] =?UTF-8?q?feat(sdk,core):=20ws-3=20=E2=80=94=20spli?= =?UTF-8?q?tAnimationsInScript=20acorn=20port=20+=20SDK=20op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - acorn: updateAnimationSelectorInScript, insertInheritedStateSetInScript helpers - acorn: splitAnimationsInScript exported (parity with recast version) - parity: 4 new fixtures (3 cases + no-op) — 23 total parity tests - SDK types: splitAnimations EditOp variant - mutate.ts: handleSplitAnimations + can() gate - mutate.gsap.test.ts: 3 new tests (56 total passing) Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- .../src/parsers/gsapWriter.parity.test.ts | 89 ++++++++++ packages/core/src/parsers/gsapWriterAcorn.ts | 167 ++++++++++++++++++ packages/sdk/src/engine/mutate.gsap.test.ts | 58 ++++++ packages/sdk/src/engine/mutate.ts | 22 +++ packages/sdk/src/types.ts | 8 + 5 files changed, 344 insertions(+) diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/core/src/parsers/gsapWriter.parity.test.ts index 4ca25d7878..579a00f17e 100644 --- a/packages/core/src/parsers/gsapWriter.parity.test.ts +++ b/packages/core/src/parsers/gsapWriter.parity.test.ts @@ -14,6 +14,8 @@ import { convertToKeyframesInScript as convertRecast, materializeKeyframesInScript as materializeRecast, splitIntoPropertyGroups as splitGroupsRecast, + splitAnimationsInScript as splitAnimsRecast, + type SplitAnimationsOptions, } from "./gsapParser.js"; import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite } from "./gsapParserAcorn.js"; import { @@ -21,6 +23,7 @@ import { convertToKeyframesFromScript as convertAcorn, materializeKeyframesFromScript as materializeAcorn, splitIntoPropertyGroupsFromScript as splitGroupsAcorn, + splitAnimationsInScript as splitAnimsAcorn, } from "./gsapWriterAcorn.js"; function acornId(script: string): string { @@ -301,3 +304,89 @@ describe("parity: splitIntoPropertyGroupsFromScript (recast vs acorn)", () => { expect(out).toBe(script); }); }); + +// ── splitAnimationsInScript parity ──────────────────────────────────────────── + +function animShapesOf(script: string) { + return parseGsapScript(script).animations.map((a) => ({ + method: a.method, + selector: a.targetSelector, + properties: a.properties, + fromProperties: a.fromProperties, + duration: a.duration, + position: a.position, + })); +} + +const SPLIT_ANIM_CASES: Array<{ name: string; script: string; opts: SplitAnimationsOptions }> = [ + { + name: "all tweens before split — retargets none", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 100, duration: 1 }, 0); + `, + opts: { + originalId: "hero", + newId: "hero-2", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }, + }, + { + name: "tween entirely after split — retargeted to newId", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { opacity: 0, duration: 0.5 }, 3); + `, + opts: { + originalId: "hero", + newId: "hero-2", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }, + }, + { + name: "tween spanning split — truncated first half + fromTo second half", + script: ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 200, duration: 4 }, 0); + `, + opts: { + originalId: "hero", + newId: "hero-2", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }, + }, +]; + +describe("parity: splitAnimationsInScript (recast vs acorn)", () => { + for (const { name, script, opts } of SPLIT_ANIM_CASES) { + it(name, () => { + const { script: recastOut } = splitAnimsRecast(script, opts); + const { script: acornOut } = splitAnimsAcorn(script, opts); + const sortByPos = (arr: ReturnType) => + arr.slice().sort((a, b) => { + const pa = typeof a.position === "number" ? a.position : 0; + const pb = typeof b.position === "number" ? b.position : 0; + return pa - pb || (a.selector ?? "").localeCompare(b.selector ?? ""); + }); + expect(sortByPos(animShapesOf(acornOut))).toEqual(sortByPos(animShapesOf(recastOut))); + }); + } + + it("no-op when originalId not found in script", () => { + const script = SPLIT_ANIM_CASES[0]!.script; + const opts: SplitAnimationsOptions = { + originalId: "nonexistent", + newId: "x", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }; + expect(splitAnimsAcorn(script, opts).script).toBe(script); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index d7d2e3ff3f..7aa82fa59a 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -16,6 +16,7 @@ import { } from "./gsapParserAcorn.js"; import { classifyPropertyGroup } from "./gsapConstants.js"; import type { PropertyGroupName } from "./gsapConstants.js"; +import type { SplitAnimationsOptions, SplitAnimationsResult } from "./gsapParser.js"; import * as acornWalk from "acorn-walk"; // ── Code generation helpers ────────────────────────────────────────────────── @@ -1302,3 +1303,169 @@ export function removeLabelFromScript(script: string, name: string): string { } return ms.toString(); } + +// ── splitAnimationsInScript helpers ────────────────────────────────────────── + +/** Overwrite the selector (first arg) of a tween call. */ +function updateAnimationSelectorInScript( + script: string, + animationId: string, + newSelector: string, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const selectorArg = target.call.node.arguments?.[0]; + if (!selectorArg) return script; + const ms = new MagicString(script); + ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(newSelector)); + return ms.toString(); +} + +/** + * Insert a `tl.set()` call immediately after the timeline declaration + * (before existing tweens) to establish inherited state on a new element. + */ +function insertInheritedStateSetInScript( + script: string, + selector: string, + position: number, + properties: Record, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const props = Object.entries(properties) + .map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`) + .join(", "); + const code = `${parsed.timelineVar}.set(${JSON.stringify(selector)}, { ${props} }, ${position});`; + const ms = new MagicString(script); + const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar); + if (tlDecl) { + ms.appendLeft(tlDecl.end, "\n" + code); + } else if (parsed.located.length > 0) { + const firstCall = parsed.located[0]!.call; + const exprStmt = findEnclosingExpressionStatement(firstCall.ancestors); + const insertAt = exprStmt?.start ?? firstCall.node.start; + ms.prependLeft(insertAt, code + "\n"); + } else { + ms.append("\n" + code); + } + return ms.toString(); +} + +// fallow-ignore-next-line complexity +export function splitAnimationsInScript( + script: string, + opts: SplitAnimationsOptions, +): SplitAnimationsResult { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return { script, skippedSelectors: [] }; + + const originalSelector = `#${opts.originalId}`; + const newSelector = `#${opts.newId}`; + + const animations = parsed.located.map((l) => l.animation); + const skippedSelectors: string[] = []; + + for (const a of animations) { + if (a.targetSelector !== originalSelector && a.targetSelector.includes(opts.originalId)) { + skippedSelectors.push(a.targetSelector); + } + } + + const matching = animations.filter((a) => a.targetSelector === originalSelector); + if (matching.length === 0) return { script, skippedSelectors }; + + let result = script; + const newElementStart = opts.splitTime; + const inheritedProps: Record = {}; + + // Reverse iteration: updateAnimationSelectorInScript mutates selectors which + // can shift count-based ID suffixes for later animations. + for (let i = matching.length - 1; i >= 0; i--) { + const anim = matching[i]!; + const pos = typeof anim.position === "number" ? anim.position : 0; + const dur = anim.duration ?? 0; + const animEnd = pos + dur; + + if (anim.keyframes) { + if (pos >= opts.splitTime) { + result = updateAnimationSelectorInScript(result, anim.id, newSelector); + } else if (animEnd > opts.splitTime) { + skippedSelectors.push(`${originalSelector} (keyframes spanning split)`); + const kfs = anim.keyframes.keyframes; + for (const kf of kfs) { + const kfTime = pos + (kf.percentage / 100) * dur; + if (kfTime <= opts.splitTime) { + for (const [k, v] of Object.entries(kf.properties)) { + inheritedProps[k] = v; + } + } + } + } else { + const kfs = anim.keyframes.keyframes; + if (kfs.length > 0) { + for (const [k, v] of Object.entries(kfs[kfs.length - 1]!.properties)) { + inheritedProps[k] = v; + } + } + } + continue; + } + + if (animEnd <= opts.splitTime) { + for (const [k, v] of Object.entries(anim.properties)) { + inheritedProps[k] = v; + } + continue; + } + + if (pos >= opts.splitTime) { + result = updateAnimationSelectorInScript(result, anim.id, newSelector); + continue; + } + + // Spans the split — linear interpolation to compute mid-values. + const progress = dur > 0 ? (opts.splitTime - pos) / dur : 0; + const fromSource = anim.fromProperties ?? inheritedProps; + const midProps: Record = {}; + for (const [k, v] of Object.entries(anim.properties)) { + if (typeof v !== "number") { + midProps[k] = v; + continue; + } + const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0; + midProps[k] = fromVal + (v - fromVal) * progress; + } + + const firstHalfDuration = opts.splitTime - pos; + result = updateAnimationInScript(result, anim.id, { + duration: firstHalfDuration, + properties: midProps, + }); + + const secondHalfDuration = animEnd - opts.splitTime; + const addResult = addAnimationToScript(result, { + targetSelector: newSelector, + method: "fromTo", + position: newElementStart, + duration: secondHalfDuration, + properties: { ...anim.properties }, + fromProperties: { ...midProps }, + ease: anim.ease, + extras: anim.extras, + }); + result = addResult.script; + + for (const [k, v] of Object.entries(midProps)) { + inheritedProps[k] = v; + } + } + + if (Object.keys(inheritedProps).length > 0) { + result = insertInheritedStateSetInScript(result, newSelector, newElementStart, inheritedProps); + } + + return { script: result, skippedSelectors }; +} diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index 064709b73b..ac6ec501ba 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -666,6 +666,64 @@ window.__timelines["t"] = tl;`; }); }); +// ─── splitAnimations ────────────────────────────────────────────────────────── + +describe("splitAnimations", () => { + const SPLIT_SCRIPT = `var tl = gsap.timeline({ paused: true }); +tl.to("#hero", { x: 200, duration: 4 }, 0); +window.__timelines["t"] = tl;`; + + function freshSplit() { + return parseMutable(`
+
+ +
`); + } + + it("retargets post-split tween to newId", () => { + const parsed = freshSplit(); + const result = applyOp(parsed, { + type: "splitAnimations", + originalId: "hero", + newId: "hero-2", + splitTime: 3, + elementStart: 0, + elementDuration: 4, + }); + expect(result.forward).toHaveLength(1); + const newScript = String(result.forward[0]?.value ?? ""); + expect(newScript).toContain("#hero-2"); + }); + + it("spanning tween produces fromTo on new element", () => { + const parsed = freshSplit(); + const result = applyOp(parsed, { + type: "splitAnimations", + originalId: "hero", + newId: "hero-2", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }); + const newScript = String(result.forward[0]?.value ?? ""); + expect(newScript).toContain(".fromTo("); + expect(newScript).toContain("#hero-2"); + }); + + it("no-op when originalId not found", () => { + const parsed = freshSplit(); + const result = applyOp(parsed, { + type: "splitAnimations", + originalId: "nonexistent", + newId: "x", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }); + expect(result.forward).toHaveLength(0); + }); +}); + // ─── Label ops ──────────────────────────────────────────────────────────────── describe("addLabel", () => { diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index c820749bda..05a2b1460d 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -54,6 +54,7 @@ import { convertToKeyframesFromScript, materializeKeyframesFromScript, splitIntoPropertyGroupsFromScript, + splitAnimationsInScript, updateKeyframeInScript, addLabelToScript, removeLabelFromScript, @@ -179,6 +180,8 @@ function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult ); case "splitIntoPropertyGroups": return handleSplitIntoPropertyGroups(parsed, op.animationId); + case "splitAnimations": + return handleSplitAnimations(parsed, op); default: return undefined; } @@ -823,6 +826,24 @@ function handleSplitIntoPropertyGroups( return gsapScriptChange(script, newScript); } +function handleSplitAnimations( + parsed: ParsedDocument, + op: Extract, +): MutationResult { + const script = getGsapScript(parsed.document); + if (!script) return EMPTY; + const { script: newScript } = splitAnimationsInScript(script, { + originalId: op.originalId, + newId: op.newId, + splitTime: op.splitTime, + elementStart: op.elementStart, + elementDuration: op.elementDuration, + }); + if (newScript === script) return EMPTY; + setGsapScript(parsed.document, newScript); + return gsapScriptChange(script, newScript); +} + function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult { const script = getGsapScript(parsed.document); if (!script) return EMPTY; @@ -1044,6 +1065,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "convertToKeyframes": case "materializeKeyframes": case "splitIntoPropertyGroups": + case "splitAnimations": case "deleteAllForSelector": case "removeLabel": if (getGsapScript(parsed.document) === null) diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index c71cb8138c..8e60d13363 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -124,6 +124,14 @@ export type EditOp = resolvedSelector?: string; } | { type: "splitIntoPropertyGroups"; animationId: string } + | { + type: "splitAnimations"; + originalId: string; + newId: string; + splitTime: number; + elementStart: number; + elementDuration: number; + } | { type: "addLabel"; name: string; position: number } | { type: "removeLabel"; name: string }; From 262854ce3d45a8b83d94c6b5667e8e7a5c10be45 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 16 Jun 2026 02:25:38 -0700 Subject: [PATCH 23/43] =?UTF-8?q?feat(sdk):=20stage=206=20=E2=80=94=20arc?= =?UTF-8?q?=20path=20ops=20(setArcPath,=20updateArcSegment,=20removeArcPat?= =?UTF-8?q?h)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port arc path trio from recast to browser-safe acorn+MagicString writer. Add SDK op types and mutate.ts handlers for setArcPath / updateArcSegment / removeArcPath. Decompose buildMotionPathObjectCode into small sub-functions in gsapSerialize.ts to stay within fallow complexity thresholds. Tests verify acorn output re-parses to correct arcPath shape. Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- packages/core/src/parsers/gsapSerialize.ts | 77 +++++++ .../src/parsers/gsapWriter.parity.test.ts | 72 +++++- packages/core/src/parsers/gsapWriterAcorn.ts | 167 +++++++++++++- packages/sdk/src/engine/mutate.gsap.test.ts | 213 ++++++++++-------- packages/sdk/src/engine/mutate.test.ts | 16 +- packages/sdk/src/engine/mutate.ts | 49 +++- packages/sdk/src/types.ts | 26 ++- 7 files changed, 510 insertions(+), 110 deletions(-) diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index 291c515f6f..28301a653b 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -475,3 +475,80 @@ export function resolveConversionProps( : { ...anim.properties }; return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps }; } + +// ── Arc path serialization helpers (shared by recast + acorn writers) ───────── + +function numericXY(props: Record): { x: number; y: number } | null { + const vx = props.x; + const vy = props.y; + return typeof vx === "number" && typeof vy === "number" ? { x: vx, y: vy } : null; +} + +export function extractArcWaypoints(anim: GsapAnimation): Array<{ x: number; y: number }> { + const keyframeWps = (anim.keyframes?.keyframes ?? []) + .map((kf) => numericXY(kf.properties)) + .filter((pt): pt is { x: number; y: number } => pt !== null); + if (keyframeWps.length >= 2) return keyframeWps; + const propX = anim.properties.x; + const propY = anim.properties.y; + if (typeof propX !== "number" && typeof propY !== "number") return keyframeWps; + const destX = typeof propX === "number" ? propX : 0; + const destY = typeof propY === "number" ? propY : 0; + return [ + { x: 0, y: 0 }, + { x: destX, y: destY }, + ]; +} + +function autoRotateSuffix(autoRotate: boolean | number): string { + if (autoRotate === true) return ", autoRotate: true"; + if (typeof autoRotate === "number") return `, autoRotate: ${autoRotate}`; + return ""; +} + +function cubicControlPoints( + seg: ArcPathSegment, + wp: { x: number; y: number }, + nextWp: { x: number; y: number }, +): string[] { + if (seg.cp1 && seg.cp2) { + return [`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`, `{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`]; + } + const dx = nextWp.x - wp.x; + const dy = nextWp.y - wp.y; + const c = seg.curviness ?? 1; + return [ + `{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`, + `{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`, + ]; +} + +function buildCubicPathEntries( + waypoints: Array<{ x: number; y: number }>, + segments: ArcPathSegment[], +): string[] { + const entries = [`{x: ${waypoints[0]!.x}, y: ${waypoints[0]!.y}}`]; + for (let i = 0; i < segments.length; i++) { + const nextWp = waypoints[i + 1]!; + entries.push(...cubicControlPoints(segments[i]!, waypoints[i]!, nextWp)); + entries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`); + } + return entries; +} + +export function buildMotionPathObjectCode(config: { + waypoints: Array<{ x: number; y: number }>; + segments: ArcPathSegment[]; + autoRotate: boolean | number; +}): string { + const { waypoints, segments, autoRotate } = config; + const arSuffix = autoRotateSuffix(autoRotate); + if (segments.some((s) => s.cp1 && s.cp2) && waypoints.length >= 2) { + const pathStr = buildCubicPathEntries(waypoints, segments).join(", "); + return `{ path: [${pathStr}], type: "cubic"${arSuffix} }`; + } + const pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`); + const curviness = segments[0]?.curviness ?? 1; + const curvPart = curviness !== 1 ? `, curviness: ${curviness}` : ""; + return `{ path: [${pathEntries.join(", ")}]${curvPart}${arSuffix} }`; +} diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/core/src/parsers/gsapWriter.parity.test.ts index 579a00f17e..e8f8bed99b 100644 --- a/packages/core/src/parsers/gsapWriter.parity.test.ts +++ b/packages/core/src/parsers/gsapWriter.parity.test.ts @@ -24,13 +24,20 @@ import { materializeKeyframesFromScript as materializeAcorn, splitIntoPropertyGroupsFromScript as splitGroupsAcorn, splitAnimationsInScript as splitAnimsAcorn, + setArcPathInScript as setArcAcorn, + updateArcSegmentInScript as updateArcSegmentAcorn, + removeArcPathFromScript as removeArcAcorn, } from "./gsapWriterAcorn.js"; - function acornId(script: string): string { const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite; return parsed.located[0]!.id; } +function arcShapeOf(script: string) { + const anim = parseGsapScript(script).animations[0]!; + return { arcPath: anim.arcPath, properties: anim.properties }; +} + /** Reparse a written script and return the first animation's editable shape. */ function shapeOf(script: string) { const anim = parseGsapScript(script).animations[0]!; @@ -390,3 +397,66 @@ describe("parity: splitAnimationsInScript (recast vs acorn)", () => { expect(splitAnimsAcorn(script, opts).script).toBe(script); }); }); + +// ─── arc path parity ────────────────────────────────────────────────────────── + +const ARC_FLAT_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 100, y: 50, duration: 2 }, 0); +`; +const ARC_CFG = { + enabled: true as const, + autoRotate: false as const, + segments: [{ curviness: 1 }], +}; +const DISABLE_CFG = { + enabled: false as const, + autoRotate: false as const, + segments: [] as never[], +}; + +function arcFixture() { + const id = acornId(ARC_FLAT_SCRIPT); + const enabled = setArcAcorn(ARC_FLAT_SCRIPT, id, ARC_CFG); + return { id, enabled }; +} + +describe("setArcPathInScript: acorn output correctness", () => { + it("enable: arcPath.enabled=true, segments preserved", () => { + const id = acornId(ARC_FLAT_SCRIPT); + const shape = arcShapeOf(setArcAcorn(ARC_FLAT_SCRIPT, id, ARC_CFG)); + expect(shape.arcPath?.enabled).toBe(true); + expect(shape.arcPath?.segments).toHaveLength(1); + }); + + it("disable: arcPath=undefined, x/y restored", () => { + const { id, enabled } = arcFixture(); + const shape = arcShapeOf(setArcAcorn(enabled, id, DISABLE_CFG)); + expect(shape.arcPath).toBeUndefined(); + expect(typeof shape.properties.x).toBe("number"); + }); + + it("no-op when animation not found", () => { + expect(setArcAcorn(ARC_FLAT_SCRIPT, "nope", ARC_CFG)).toBe(ARC_FLAT_SCRIPT); + }); +}); + +describe("updateArcSegmentInScript: acorn output correctness", () => { + it("curviness update reflected in parsed shape", () => { + const { id, enabled } = arcFixture(); + const shape = arcShapeOf(updateArcSegmentAcorn(enabled, id, 0, { curviness: 2 })); + expect(shape.arcPath?.segments[0]?.curviness).toBe(2); + }); + + it("no-op when index out of range", () => { + const { id, enabled } = arcFixture(); + expect(updateArcSegmentAcorn(enabled, id, 99, { curviness: 2 })).toBe(enabled); + }); +}); + +describe("removeArcPathFromScript: acorn output correctness", () => { + it("arcPath=undefined after removal", () => { + const { id, enabled } = arcFixture(); + expect(arcShapeOf(removeArcAcorn(enabled, id)).arcPath).toBeUndefined(); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 7aa82fa59a..7ec9bd9756 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -7,8 +7,17 @@ * pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts. */ import MagicString from "magic-string"; -import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapSerialize.js"; -import { resolveConversionProps } from "./gsapSerialize.js"; +import type { + GsapAnimation, + GsapPercentageKeyframe, + ArcPathConfig, + ArcPathSegment, +} from "./gsapSerialize.js"; +import { + resolveConversionProps, + extractArcWaypoints, + buildMotionPathObjectCode, +} from "./gsapSerialize.js"; import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite, @@ -1304,6 +1313,160 @@ export function removeLabelFromScript(script: string, name: string): string { return ms.toString(); } +// ── Arc path helpers ───────────────────────────────────────────────────────── + +/** + * Remove a set of properties from an ObjectExpression in a single pass. + * Groups consecutive marked props into blocks to avoid overlapping remove ranges. + */ +function removePropsByKey(ms: MagicString, objNode: any, keys: Set): void { + if (objNode?.type !== "ObjectExpression") return; + const allProps = (objNode.properties ?? []).filter(isObjectProperty); + const marked = allProps.map((p: any) => keys.has(propKeyName(p) ?? "")); + let i = 0; + while (i < allProps.length) { + if (!marked[i]) { + i++; + continue; + } + const blockStart = i; + while (i < allProps.length && marked[i]) i++; + ms.remove(...blockRemoveRange(allProps, blockStart, i)); + } +} + +function blockRemoveRange(allProps: any[], blockStart: number, blockEnd: number): [number, number] { + if (blockStart === 0 && blockEnd === allProps.length) + return [allProps[0].start, allProps[allProps.length - 1].end]; + if (blockStart === 0) return [allProps[0].start, allProps[blockEnd].start]; + return [allProps[blockStart - 1].end, allProps[blockEnd - 1].end]; +} + +// fallow-ignore-next-line complexity +function readLastWaypointXY(mpVal: any): { x: number | null; y: number | null } { + if (mpVal?.type !== "ObjectExpression") return { x: null, y: null }; + const pathProp = findPropertyNode(mpVal, "path"); + if (pathProp?.value?.type !== "ArrayExpression") return { x: null, y: null }; + const elems: any[] = pathProp.value.elements ?? []; + const last = elems[elems.length - 1]; + if (last?.type !== "ObjectExpression") return { x: null, y: null }; + const xRaw = findPropertyNode(last, "x")?.value?.value; + const yRaw = findPropertyNode(last, "y")?.value?.value; + return { x: typeof xRaw === "number" ? xRaw : null, y: typeof yRaw === "number" ? yRaw : null }; +} + +function disableArcPath(ms: MagicString, call: TweenCallInfo): boolean { + const mpProp = findPropertyNode(call.varsArg, "motionPath"); + if (!mpProp) return false; + const { x, y } = readLastWaypointXY(mpProp.value); + if (x === null && y === null) { + const allProps = (call.varsArg.properties ?? []).filter(isObjectProperty); + removeProp(ms, mpProp, allProps); + return true; + } + // Overwrite the entire motionPath property with the recovered x/y pair — avoids + // the appendLeft+remove range-boundary issue in MagicString. + const parts: string[] = []; + if (x !== null) parts.push(`x: ${x}`); + if (y !== null) parts.push(`y: ${y}`); + ms.overwrite(mpProp.start, mpProp.end, parts.join(", ")); + return true; +} + +function stripXYFromKeyframes(ms: MagicString, kfPropNode: any): void { + if (kfPropNode?.value?.type !== "ObjectExpression") return; + const xyKeys = new Set(["x", "y"]); + for (const pctProp of (kfPropNode.value.properties ?? []).filter(isObjectProperty)) { + const k = propKeyName(pctProp); + if (typeof k === "string" && k.endsWith("%") && pctProp.value?.type === "ObjectExpression") { + removePropsByKey(ms, pctProp.value, xyKeys); + } + } +} + +function enableArcPath( + ms: MagicString, + call: TweenCallInfo, + animation: GsapAnimation, + config: ArcPathConfig, +): boolean { + const waypoints = extractArcWaypoints(animation); + if (waypoints.length < 2) return false; + const segments: ArcPathSegment[] = + config.segments.length === waypoints.length - 1 + ? config.segments + : Array.from({ length: waypoints.length - 1 }, () => ({ curviness: 1 })); + const motionPathCode = buildMotionPathObjectCode({ + waypoints, + segments, + autoRotate: config.autoRotate, + }); + upsertProp(ms, call.varsArg, "motionPath", `__raw:${motionPathCode}`); + stripXYFromKeyframes(ms, findPropertyNode(call.varsArg, "keyframes")); + removePropsByKey(ms, call.varsArg, new Set(["x", "y"])); + return true; +} + +export function setArcPathInScript( + script: string, + animationId: string, + config: ArcPathConfig, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const ms = new MagicString(script); + const handled = config.enabled + ? enableArcPath(ms, target.call, target.animation, config) + : disableArcPath(ms, target.call); + return handled ? ms.toString() : script; +} + +export function updateArcSegmentInScript( + script: string, + animationId: string, + segmentIndex: number, + update: Partial, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const { call, animation } = target; + if (!animation.arcPath?.enabled) return script; + + const segments = [...animation.arcPath.segments]; + if (segmentIndex < 0 || segmentIndex >= segments.length) return script; + + segments[segmentIndex] = { ...segments[segmentIndex]!, ...update }; + + const waypoints = extractArcWaypoints(animation); + if (waypoints.length < 2) return script; + + const motionPathCode = buildMotionPathObjectCode({ + waypoints, + segments, + autoRotate: animation.arcPath.autoRotate, + }); + + const mpProp = findPropertyNode(call.varsArg, "motionPath"); + if (!mpProp) return script; + + const ms = new MagicString(script); + ms.overwrite(mpProp.value.start, mpProp.value.end, motionPathCode); + return ms.toString(); +} + +export function removeArcPathFromScript(script: string, animationId: string): string { + return setArcPathInScript(script, animationId, { + enabled: false, + autoRotate: false, + segments: [], + }); +} + // ── splitAnimationsInScript helpers ────────────────────────────────────────── /** Overwrite the selector (first arg) of a tween call. */ diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index ac6ec501ba..8fc18d73a7 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -32,17 +32,6 @@ function fresh(script = GSAP_SCRIPT) { return parseMutable(makeHtml(script)); } -// A sub-composition host: data-hf-id="hf-host" (its own leaf id) AND -// data-composition-id="sub-1" (the id studio passes when targeting the root). -function freshSubComp(script = GSAP_SCRIPT) { - return parseMutable( - `
-
- -
`.trim(), - ); -} - function getScript(parsed: ReturnType): string { const doc = serializeDocument(parsed); const m = / +`); + } + + function gsapPatch(result: ReturnType): string { + const v = result.forward + .map((p) => p.value) + .find((val) => typeof val === "string" && val.includes("tl.")); + return typeof v === "string" ? v : ""; + } + + it("moving the clip +1 shifts BOTH tweens by the delta, preserving the stagger", () => { + const result = applyOp(freshStagger(), { type: "setTiming", target: "hf-box", start: 3 }); + const script = gsapPatch(result); + // 2.0 → 3.0 and 5.0 → 6.0 — NOT both collapsed onto the new absolute start. + expect(script).toContain("{ x: 100, duration: 1 }, 3)"); + expect(script).toContain("{ x: 200, duration: 1 }, 6)"); + // The stagger gap (3s) is preserved; durations are untouched. + expect(script).not.toContain("duration: 5"); + }); + + it("resizing the clip x2 scales each tween's duration by the ratio (not full clip)", () => { + // duration 5 → 10 (ratio 2); positions remap about the clip start (2). + const result = applyOp(freshStagger(), { type: "setTiming", target: "hf-box", duration: 10 }); + const script = gsapPatch(result); + // pos 2 (offset 0) stays 2; pos 5 → 2 + (5-2)*2 = 8. durations 1 → 2. + expect(script).toContain("{ x: 100, duration: 2 }, 2)"); + expect(script).toContain("{ x: 200, duration: 2 }, 8)"); + // The bug blew every duration up to the full clip duration (10). + expect(script).not.toContain("duration: 10"); + }); +}); + // ─── Label ops ──────────────────────────────────────────────────────────────── describe("addLabel", () => { diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index d7aaff5163..6809b1ae33 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -444,12 +444,30 @@ function handleSetTiming( // tweens are stored under) so a comp-root target ("sub-1") whose tween lives // at [data-hf-id="hf-host"] still syncs. const matchId = el.getAttribute("data-hf-id") ?? id; - if (parsedGsap && currentScript) { + if (parsedGsap && currentScript && oldStart !== null) { + // Per-tween shift/scale (mirrors shiftGsapPositions/scaleGsapPositions): a + // multi-tween stagger maps each tween's own intra-clip position by the + // start DELTA and scales its duration by the clip-duration RATIO. Writing + // the absolute newStart/newDuration onto every tween would collapse the + // stagger onto one point and blow each tween's duration to the full clip. + const startChanged = timing.start !== undefined && newStart !== null; + const durChanged = timing.duration !== undefined && newDuration !== null; + const ratio = + durChanged && oldDuration !== null && oldDuration > 0 && newDuration !== null + ? newDuration / oldDuration + : 1; + const remapStart = startChanged && newStart !== null ? newStart : oldStart; for (const { id: animId, animation } of parsedGsap.located) { if (!selectorMatchesId(animation.targetSelector, matchId)) continue; + if (typeof animation.position !== "number") continue; const updates: Partial = {}; - if (timing.start !== undefined && newStart !== null) updates.position = newStart; - if (timing.duration !== undefined && newDuration !== null) updates.duration = newDuration; + if (startChanged || durChanged) { + const shifted = remapStart + (animation.position - oldStart) * ratio; + updates.position = Math.max(0, Math.round(shifted * 1000) / 1000); + } + if (durChanged && typeof animation.duration === "number" && animation.duration > 0) { + updates.duration = Math.max(0.001, Math.round(animation.duration * ratio * 1000) / 1000); + } if (Object.keys(updates).length === 0) continue; currentScript = updateAnimationInScript(currentScript, animId, updates); } @@ -911,8 +929,9 @@ function resolveKeyframe(parsed: ParsedDocument, animationId: string, keyframeIn const parsedForWrite = parseGsapScriptAcornForWrite(script); const located = parsedForWrite?.located.find((l) => l.id === animationId); const kfs = located?.animation.keyframes?.keyframes; - if (!kfs || keyframeIndex < 0 || keyframeIndex >= kfs.length) return null; - return { script, kf: kfs[keyframeIndex]!, kfs }; + const kf = kfs?.[keyframeIndex]; + if (!kfs || !kf || keyframeIndex < 0) return null; + return { script, kf, kfs }; } // fallow-ignore-next-line complexity @@ -993,8 +1012,9 @@ function handleRemoveGsapKeyframeByPercentage( // No-op on ambiguity: duplicate-percentage keyframes can't be disambiguated. const TOLERANCE = 0.001; const matches = kfs.filter((k) => Math.abs(k.percentage - percentage) <= TOLERANCE); - if (matches.length !== 1) return EMPTY; - const pct = matches[0]!.percentage; + const sole = matches[0]; + if (matches.length !== 1 || !sole) return EMPTY; + const pct = sole.percentage; const newScript = removeKeyframeFromScript(script, animationId, pct); if (newScript === script) return EMPTY; setGsapScript(parsed.document, newScript); From bcc0a44a66e8745066ea09c8b13310d3916da94c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 12:44:31 -0700 Subject: [PATCH 31/43] =?UTF-8?q?fix(studio):=20SDK=20cutover=20review=20f?= =?UTF-8?q?ixes=20=E2=80=94=20merge=20tween=20props,=20stabilize=20debounc?= =?UTF-8?q?e,=20serialize=20gsap=20writes,=20on-disk=20undo=20baseline,=20?= =?UTF-8?q?self-write=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 5 SDK-cutover review findings (studio-only): - #1 useGsapPropertyDebounce: editing one GSAP tween property no longer drops the tween's other animated props. setGsapTween REPLACES the property set, so merge the single edit into the tween's CURRENT properties (read from the SDK doc) before dispatching, mirroring the legacy server merge. - #7 useGsapPropertyDebounce: stabilize the flush callback by reading sdk deps from a ref instead of an unmemoized literal, so a parent re-render mid-edit no longer tears down + flushes the debounce (one commit/undo entry per render). - #8 sdkCutover/useGsapScriptCommits: route SDK gsap-write persists through the same per-file keyed serializer the legacy commitMutation uses, so concurrent same-file read-modify-writes can't interleave and lose an edit. - #12 sdkCutover/useTimelineEditing: capture the exact on-disk bytes as the undo 'before' for timing/GSAP persists (matching the style/delete paths) instead of a normalized SDK serialize() re-emit that reformatted the whole file on undo. - #14 useSdkSession/sdkSelfWriteRegistry: discriminate a cutover echo from an undo write by CONTENT identity (registered self-write hash), not just the 2 s timestamp window — an undo write always reloads the SDK session. Tests: useGsapPropertyDebounce(.test), useGsapPropertyDebounceFlush.test, sdkSelfWriteRegistry.test, and new sdkCutover.test cases; each reproduces the review scenario and asserts the corrected behavior (verified red before fix). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/hooks/sdkSelfWriteRegistry.test.ts | 67 ++++++++ .../studio/src/hooks/sdkSelfWriteRegistry.ts | 77 +++++++++ .../src/hooks/useGsapPropertyDebounce.test.ts | 70 +++++++++ .../src/hooks/useGsapPropertyDebounce.ts | 102 ++++++++++-- .../useGsapPropertyDebounceFlush.test.ts | 85 ++++++++++ .../studio/src/hooks/useGsapScriptCommits.ts | 26 ++++ packages/studio/src/hooks/useSdkSession.ts | 70 +++++++-- .../studio/src/hooks/useTimelineEditing.ts | 6 + packages/studio/src/utils/sdkCutover.test.ts | 147 ++++++++++++++++++ packages/studio/src/utils/sdkCutover.ts | 93 ++++++++--- 10 files changed, 695 insertions(+), 48 deletions(-) create mode 100644 packages/studio/src/hooks/sdkSelfWriteRegistry.test.ts create mode 100644 packages/studio/src/hooks/sdkSelfWriteRegistry.ts create mode 100644 packages/studio/src/hooks/useGsapPropertyDebounce.test.ts create mode 100644 packages/studio/src/hooks/useGsapPropertyDebounceFlush.test.ts diff --git a/packages/studio/src/hooks/sdkSelfWriteRegistry.test.ts b/packages/studio/src/hooks/sdkSelfWriteRegistry.test.ts new file mode 100644 index 0000000000..3f6ee63544 --- /dev/null +++ b/packages/studio/src/hooks/sdkSelfWriteRegistry.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + hashContent, + markSelfWrite, + isSelfWriteEcho, + resetSelfWriteRegistry, +} from "./sdkSelfWriteRegistry"; +import { shouldReloadOnFileChange } from "./useSdkSession"; + +describe("sdkSelfWriteRegistry (finding #14)", () => { + beforeEach(() => resetSelfWriteRegistry()); + + it("recognizes the echo of bytes we just wrote", () => { + markSelfWrite("/comp.html", "A"); + expect(isSelfWriteEcho("/comp.html", "A")).toBe(true); + }); + + it("does NOT match different content on the same path (an undo's reverted bytes)", () => { + markSelfWrite("/comp.html", "A"); + expect(isSelfWriteEcho("/comp.html", "REVERTED")).toBe(false); + }); + + it("is keyed per file — a self-write to one file can't mask a change to another", () => { + markSelfWrite("/a.html", "A"); + expect(isSelfWriteEcho("/b.html", "A")).toBe(false); + }); + + it("consumes a matched entry so a later genuine external write isn't suppressed", () => { + markSelfWrite("/comp.html", "A"); + expect(isSelfWriteEcho("/comp.html", "A")).toBe(true); + // A second arrival of identical bytes is NOT our echo — must reload. + expect(isSelfWriteEcho("/comp.html", "A")).toBe(false); + }); + + it("expires entries past the TTL so a stale self-write can't suppress forever", () => { + const t0 = 1_000_000; + markSelfWrite("/comp.html", "A", t0); + // 3 s later (> 2 s TTL) the entry is gone. + expect(isSelfWriteEcho("/comp.html", "A", t0 + 3000)).toBe(false); + }); + + it("hashes are stable and distinguish different content", () => { + expect(hashContent("x")).toBe(hashContent("x")); + expect(hashContent("x")).not.toBe(hashContent("y")); + }); +}); + +describe("shouldReloadOnFileChange (finding #14)", () => { + beforeEach(() => resetSelfWriteRegistry()); + + it("suppresses the reload when content matches a registered self-write (cutover echo)", () => { + markSelfWrite("/comp.html", "SELF"); + expect(shouldReloadOnFileChange("/comp.html", "SELF", true)).toBe(false); + }); + + it("reloads on an undo write even inside the suppress window (content differs)", () => { + // The cutover registered SELF; the undo writes REVERTED bytes within the + // same 2 s window. Time-only suppression dropped this; identity reloads it. + markSelfWrite("/comp.html", "SELF"); + expect(shouldReloadOnFileChange("/comp.html", "REVERTED", true)).toBe(true); + }); + + it("falls back to the time window only when content is unavailable", () => { + expect(shouldReloadOnFileChange("/comp.html", null, true)).toBe(false); + expect(shouldReloadOnFileChange("/comp.html", null, false)).toBe(true); + }); +}); diff --git a/packages/studio/src/hooks/sdkSelfWriteRegistry.ts b/packages/studio/src/hooks/sdkSelfWriteRegistry.ts new file mode 100644 index 0000000000..3b6976d1f9 --- /dev/null +++ b/packages/studio/src/hooks/sdkSelfWriteRegistry.ts @@ -0,0 +1,77 @@ +/** + * Self-write identity registry — discriminates an SDK cutover ECHO from a genuine + * external write (notably undo/redo) in the file-change reload-suppression path. + * + * The old suppression was purely time-based: any file-change within 2 s of the + * shared `domEditSaveTimestampRef` was swallowed. But BOTH an SDK cutover + * self-write AND an undo write set that same timestamp, so the window could not + * tell "the echo of the bytes I just wrote" (suppress) from "the reverted bytes + * an undo just wrote" (must reload). An undo that landed inside the window was + * silently dropped, leaving the in-memory SDK doc on stale pre-undo content. + * + * Fix: tag each cutover self-write with the CONTENT it wrote (by hash). A + * file-change reload is suppressed only when the new on-disk content matches a + * recently-registered self-write hash — i.e. it is provably our own echo. Undo + * writes are never registered (they don't flow through persistSdkSerialize), so + * their content won't match and the reload always fires. Identity, not a clock. + */ + +const SELF_WRITE_TTL_MS = 2000; + +interface SelfWriteEntry { + hash: string; + at: number; +} + +// Module-scoped: the studio process has a single SDK session lifecycle at a time +// and persists are funnelled through one persistSdkSerialize. Keyed by file path +// so a self-write to one file can't mask a real external change to another. +const registry = new Map(); + +/** + * Stable 32-bit FNV-1a hash of content. Collisions only risk SUPPRESSING a real + * reload, and only within the 2 s TTL for the exact same file — negligible, and + * strictly safer than the prior time-only window it replaces. + */ +export function hashContent(content: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < content.length; i++) { + h ^= content.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(16); +} + +function prune(entries: SelfWriteEntry[], now: number): SelfWriteEntry[] { + return entries.filter((e) => now - e.at < SELF_WRITE_TTL_MS); +} + +/** Record that WE wrote `content` to `path` (an SDK cutover self-write). */ +export function markSelfWrite(path: string, content: string, now: number = Date.now()): void { + const next = prune(registry.get(path) ?? [], now); + next.push({ hash: hashContent(content), at: now }); + registry.set(path, next); +} + +/** + * True when `content` matches a self-write registered for `path` within the TTL. + * Consumes the matched entry so a later genuinely-external write of identical + * bytes isn't suppressed forever. + */ +export function isSelfWriteEcho(path: string, content: string, now: number = Date.now()): boolean { + const entries = prune(registry.get(path) ?? [], now); + const hash = hashContent(content); + const idx = entries.findIndex((e) => e.hash === hash); + if (idx === -1) { + registry.set(path, entries); + return false; + } + entries.splice(idx, 1); + registry.set(path, entries); + return true; +} + +/** Test-only: drop all registered self-writes. */ +export function resetSelfWriteRegistry(): void { + registry.clear(); +} diff --git a/packages/studio/src/hooks/useGsapPropertyDebounce.test.ts b/packages/studio/src/hooks/useGsapPropertyDebounce.test.ts new file mode 100644 index 0000000000..4c2a67fa9f --- /dev/null +++ b/packages/studio/src/hooks/useGsapPropertyDebounce.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from "vitest"; +import { openComposition } from "@hyperframes/sdk"; +import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory"; +import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn"; +import { mergeTweenProperties } from "./useGsapPropertyDebounce"; +import { extractGsapScriptText } from "../utils/gsapSoftReload"; + +const HTML = ` +
+ +`; + +const FROMTO_HTML = ` +
+ +`; + +function tweenProps(comp: { serialize(): string }) { + const parsed = parseGsapScriptAcorn(extractGsapScriptText(comp.serialize()) ?? ""); + const anim = parsed.animations[0]; + return { id: anim?.id, properties: anim?.properties, fromProperties: anim?.fromProperties }; +} + +describe("setGsapTween replace semantics (finding #1)", () => { + it("REGRESSION: a single-key set drops the tween's other animated props", async () => { + // This documents the bug the merge fixes: setGsapTween REPLACES the property + // set, so sending only the edited key loses the siblings. + const comp = await openComposition(HTML, { persist: createMemoryAdapter() }); + const id = tweenProps(comp).id ?? ""; + comp.setGsapTween(id, { properties: { x: 200 } }); + const after = tweenProps(comp); + expect(after.properties).toEqual({ x: 200 }); + expect(after.properties).not.toHaveProperty("y"); + expect(after.properties).not.toHaveProperty("opacity"); + }); +}); + +describe("mergeTweenProperties (finding #1)", () => { + it("editing x preserves y and opacity through a real SDK write", async () => { + const comp = await openComposition(HTML, { persist: createMemoryAdapter() }); + const id = tweenProps(comp).id ?? ""; + // Mirror the send site: merge the single edited prop into the existing set. + const merged = mergeTweenProperties(comp, id, { x: 200 }, "to"); + expect(merged).toEqual({ x: 200, y: 50, opacity: 1 }); + comp.setGsapTween(id, { properties: merged }); + const after = tweenProps(comp); + expect(after.properties).toMatchObject({ x: 200, y: 50, opacity: 1 }); + }); + + it("editing a from-property preserves the other from-properties", async () => { + const comp = await openComposition(FROMTO_HTML, { persist: createMemoryAdapter() }); + const id = tweenProps(comp).id ?? ""; + const merged = mergeTweenProperties(comp, id, { x: 25 }, "from"); + expect(merged).toEqual({ x: 25, y: 0 }); + }); + + it("returns the single edit unchanged when the tween id is unknown", async () => { + const comp = await openComposition(HTML, { persist: createMemoryAdapter() }); + expect(mergeTweenProperties(comp, "no-such-id", { x: 5 }, "to")).toEqual({ x: 5 }); + }); +}); diff --git a/packages/studio/src/hooks/useGsapPropertyDebounce.ts b/packages/studio/src/hooks/useGsapPropertyDebounce.ts index 0d408e02b7..60e031c81b 100644 --- a/packages/studio/src/hooks/useGsapPropertyDebounce.ts +++ b/packages/studio/src/hooks/useGsapPropertyDebounce.ts @@ -1,16 +1,46 @@ import { useCallback, useEffect, useRef } from "react"; import type { Composition } from "@hyperframes/sdk"; +import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { sdkGsapTweenPersist, sdkGsapRemovePropertyPersist, type CutoverDeps, } from "../utils/sdkCutover"; +import { extractGsapScriptText } from "../utils/gsapSoftReload"; import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers"; import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; const DEBOUNCE_MS = 150; +/** + * The SDK `setGsapTween` 'set' path REPLACES a tween's editable property set + * (engine `handleSetGsapTween` → `updateAnimationInScript`), so sending only the + * single edited key would silently drop the tween's other animated props. Mirror + * the legacy server path (`{ ...anim.properties, [property]: val }`): read the + * tween's CURRENT properties from the in-memory SDK doc and merge the one edit in, + * so REPLACE semantics preserve siblings. Returns the single-key map unchanged + * when the tween/script can't be found (best-effort; before===after then falls + * back to the server path). + */ +export function mergeTweenProperties( + sdkSession: Composition, + animationId: string, + edited: Record, + kind: "to" | "from", +): Record { + try { + const script = extractGsapScriptText(sdkSession.serialize()); + if (!script) return { ...edited }; + const anim = parseGsapScriptAcorn(script).animations.find((a) => a.id === animationId); + if (!anim) return { ...edited }; + const existing = kind === "from" ? (anim.fromProperties ?? {}) : anim.properties; + return { ...existing, ...edited }; + } catch { + return { ...edited }; + } +} + interface SdkPropertyDeps { sdkSession?: Composition | null; sdkDeps?: CutoverDeps | null; @@ -29,17 +59,32 @@ export function useGsapPropertyDebounce( } | null>(null); const debounceTimerRef = useRef | null>(null); + // The caller passes `sdk` as a fresh object literal every render. Keying any + // callback's deps on it (esp. flushPendingPropertyEdit, whose identity drives + // the unmount-flush cleanup effect) re-fires the cleanup on EVERY parent + // re-render — so a playhead tick mid-slider-drag would flush + record an undo + // entry per render. Hold the latest value in a ref instead so every callback + // reads current deps without re-subscribing on identity churn. + const sdkRef = useRef(sdk); + sdkRef.current = sdk; + 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 ?? {}; + const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, - { kind: "set", animationId, properties: { properties: { [property]: value } } }, + { + kind: "set", + animationId, + properties: { + properties: mergeTweenProperties(sdkSession, animationId, { [property]: value }, "to"), + }, + }, sdkSession, sdkDeps, { label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` }, @@ -55,7 +100,7 @@ export function useGsapPropertyDebounce( softReload: true, }, ); - }, [commitMutationSafely, sdk]); + }, [commitMutationSafely]); const updateGsapProperty = useCallback( ( @@ -93,12 +138,23 @@ export function useGsapPropertyDebounce( const cs = el.ownerDocument.defaultView?.getComputedStyle(el); defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1; } - const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, - { kind: "set", animationId, properties: { properties: { [property]: defaultValue } } }, + { + kind: "set", + animationId, + properties: { + properties: mergeTweenProperties( + sdkSession, + animationId, + { [property]: defaultValue }, + "to", + ), + }, + }, sdkSession, sdkDeps, { label: `Add GSAP ${property}` }, @@ -111,12 +167,12 @@ export function useGsapPropertyDebounce( { label: `Add GSAP ${property}` }, ); }, - [commitMutationSafely, sdk], + [commitMutationSafely], ); const removeProperty = useCallback( async (selection: DomEditSelection, animationId: string, property: string, from: boolean) => { - const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapRemovePropertyPersist( @@ -148,7 +204,7 @@ export function useGsapPropertyDebounce( ); } }, - [commitMutationSafely, sdk], + [commitMutationSafely], ); const removeGsapProperty = useCallback( @@ -164,12 +220,23 @@ export function useGsapPropertyDebounce( property: string, value: number | string, ) => { - const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, - { kind: "set", animationId, properties: { fromProperties: { [property]: value } } }, + { + kind: "set", + animationId, + properties: { + fromProperties: mergeTweenProperties( + sdkSession, + animationId, + { [property]: value }, + "from", + ), + }, + }, sdkSession, sdkDeps, { @@ -188,13 +255,13 @@ export function useGsapPropertyDebounce( }, ); }, - [commitMutationSafely, sdk], + [commitMutationSafely], ); const addGsapFromProperty = useCallback( async (selection: DomEditSelection, animationId: string, property: string) => { const defaultValue = PROPERTY_DEFAULTS[property] ?? 0; - const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( @@ -202,7 +269,14 @@ export function useGsapPropertyDebounce( { kind: "set", animationId, - properties: { fromProperties: { [property]: defaultValue } }, + properties: { + fromProperties: mergeTweenProperties( + sdkSession, + animationId, + { [property]: defaultValue }, + "from", + ), + }, }, sdkSession, sdkDeps, @@ -216,7 +290,7 @@ export function useGsapPropertyDebounce( { label: `Add GSAP from-${property}` }, ); }, - [commitMutationSafely, sdk], + [commitMutationSafely], ); const removeGsapFromProperty = useCallback( diff --git a/packages/studio/src/hooks/useGsapPropertyDebounceFlush.test.ts b/packages/studio/src/hooks/useGsapPropertyDebounceFlush.test.ts new file mode 100644 index 0000000000..7890f0d007 --- /dev/null +++ b/packages/studio/src/hooks/useGsapPropertyDebounceFlush.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom +import React, { act, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useGsapPropertyDebounce } from "./useGsapPropertyDebounce"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +// The SDK path is gated on STUDIO_SDK_CUTOVER_ENABLED; keep it OFF so the flush +// routes through commitMutationSafely (the spy we count), keeping the test about +// flush TIMING, not the SDK write path. +vi.mock("../components/editor/manualEditingAvailability", () => ({ + STUDIO_SDK_CUTOVER_ENABLED: false, +})); +vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); + +const selection = { sourceFile: "index.html" } as unknown as DomEditSelection; + +describe("useGsapPropertyDebounce flush stability (finding #7)", () => { + let container: HTMLDivElement; + beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + afterEach(() => { + vi.useRealTimers(); + container.remove(); + }); + + it("re-rendering the parent while an edit is pending does NOT flush early or duplicate commits", () => { + const commitMutationSafely = vi.fn(); + let queueEdit: (() => void) | null = null; + let forceRerender: (() => void) | null = null; + + function Harness() { + const [tick, setTick] = useState(0); + forceRerender = () => setTick((t) => t + 1); + // A FRESH sdk wrapper literal every render — the exact churn that, before + // the ref-stabilization fix, re-fired the unmount-flush cleanup effect. + const ops = useGsapPropertyDebounce(commitMutationSafely, { + sdkSession: null, + sdkDeps: null, + activeCompPath: "index.html", + }); + queueEdit = () => ops.updateGsapProperty(selection, "tw-1", "x", tick + 1); + return React.createElement("div", null, String(tick)); + } + + const root = createRoot(container); + act(() => { + root.render(React.createElement(Harness)); + }); + + // Queue one pending edit. + act(() => { + queueEdit?.(); + }); + expect(commitMutationSafely).not.toHaveBeenCalled(); + + // Re-render the parent several times BEFORE the debounce elapses. The bug + // flushed (and recorded a commit) on every re-render via the cleanup effect. + act(() => { + forceRerender?.(); + }); + act(() => { + forceRerender?.(); + }); + act(() => { + forceRerender?.(); + }); + expect(commitMutationSafely).not.toHaveBeenCalled(); + + // The debounce fires exactly once. + act(() => { + vi.advanceTimersByTime(200); + }); + expect(commitMutationSafely).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 936e1fb8b0..1c15af70d5 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -115,6 +115,28 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra }, [previewIframeRef, reloadPreview, onCacheInvalidate], ); + // Reuse the SAME per-file serializer the legacy commitMutation path uses, so + // SDK gsap-write flushes serialize against legacy commits AND each other — + // overlapping same-file read-modify-writes can't interleave and lose an edit. + const serializeByFile = useCallback( + (key: string, task: () => Promise): Promise => serializerRef.current(key, task), + [], + ); + // Read the on-disk bytes of targetPath so the SDK GSAP persist captures the + // exact prior content as its undo `before` (matching the style/delete paths), + // instead of a normalized full-DOM re-emit that would reformat the whole file. + const readProjectFileContent = useCallback( + async (path: string): Promise => { + const pid = projectIdRef.current; + if (!pid) throw new Error("No active project"); + const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`); + if (!res.ok) throw new Error(`Failed to read ${path}`); + const data = (await res.json()) as { content?: string }; + if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`); + return data.content; + }, + [projectIdRef], + ); const sdkDeps = useMemo( () => writeProjectFile @@ -125,6 +147,8 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra domEditSaveTimestampRef, refresh: sdkRefresh, compositionPath: activeCompPath, + serialize: serializeByFile, + readProjectFile: readProjectFileContent, } : null, [ @@ -134,6 +158,8 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra domEditSaveTimestampRef, sdkRefresh, activeCompPath, + serializeByFile, + readProjectFileContent, ], ); diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 1743814fb6..e7b8c7e23e 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -4,6 +4,7 @@ import { openComposition } from "@hyperframes/sdk"; import { createHttpAdapter } from "@hyperframes/sdk/adapters/http"; import type { Composition } from "@hyperframes/sdk"; import { readStudioFileChangePath } from "../components/editor/manualEdits"; +import { isSelfWriteEcho } from "./sdkSelfWriteRegistry"; /** * True when an external file-change payload targets the active composition and @@ -24,14 +25,40 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string * 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. -// 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. +// Reload-suppression baseline: a file-change within this window of our own SDK +// cutover write is a CANDIDATE echo, but the decision is content-identity based +// (isSelfWriteEcho) not time-only — so an undo write that lands inside the window +// still reloads (its reverted bytes were never registered as a self-write). The +// window only bounds how long a registered self-write stays suppressible. const SELF_WRITE_SUPPRESS_MS = 2000; +/** Best-effort read of the changed file's content from a file-change payload. */ +function readFileChangeContent(payload: unknown): string | null { + if (!payload || typeof payload !== "object") return null; + const record = payload as Record; + if (typeof record.content === "string") return record.content; + if ("data" in record) return readFileChangeContent(record.data); + return null; +} + +/** + * Decide whether a file-change for the active composition should reload the SDK + * session. `content` is the new on-disk bytes (from the payload or a re-read); + * pass null when unavailable. Content-identity wins: a change whose bytes match a + * registered self-write is our own echo (suppress). Without content we can't prove + * identity, so we fall back to the time window ONLY to suppress an echo — an undo + * write outside the window (or any non-self-write) still reloads. Exported for test. + */ +export function shouldReloadOnFileChange( + activeCompPath: string, + content: string | null, + withinSuppressWindow: boolean, +): boolean { + if (content != null) return !isSelfWriteEcho(activeCompPath, content); + // No content to compare — preserve the old time-window echo suppression. + return !withinSuppressWindow; +} + export interface SdkSessionHandle { session: Composition | null; /** @@ -53,15 +80,30 @@ export function useSdkSession( // ── Re-open on external change to the active composition ── useEffect(() => { if (!activeCompPath) return; + const compPath = activeCompPath; + const readAdapter = + projectId != null + ? createHttpAdapter({ projectFilesUrl: `/api/projects/${projectId}` }) + : null; const handler = (payload?: unknown) => { - if (!shouldReloadSdkSession(payload, activeCompPath)) return; - // Suppress reload triggered by our own SDK cutover write. - if ( - domEditSaveTimestampRef && - Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS - ) + if (!shouldReloadSdkSession(payload, compPath)) return; + const withinWindow = + !!domEditSaveTimestampRef && + Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS; + const decide = (content: string | null) => { + if (shouldReloadOnFileChange(compPath, content, withinWindow)) setReloadToken((t) => t + 1); + }; + const payloadContent = readFileChangeContent(payload); + // Prefer payload content; otherwise re-read so the decision is by IDENTITY + // (an undo's reverted bytes won't match a registered self-write → reload). + if (payloadContent != null || !readAdapter) { + decide(payloadContent); return; - setReloadToken((t) => t + 1); + } + readAdapter + .read(compPath) + .then((c) => decide(typeof c === "string" ? c : null)) + .catch(() => decide(null)); }; if (import.meta.hot) { import.meta.hot.on("hf:file-change", handler); @@ -72,7 +114,7 @@ export function useSdkSession( es.addEventListener("file-change", handler); return () => es.close(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeCompPath]); + }, [activeCompPath, projectId]); // ── Open / re-open the session ── useEffect(() => { diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index ffe5c7a45f..688d4127a5 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -188,6 +188,9 @@ export function useTimelineEditing({ reloadPreview, domEditSaveTimestampRef, compositionPath: activeCompPath, + // Capture on-disk bytes as the undo `before` so undoing a timing move + // restores the file verbatim, not a normalized full-DOM re-emit. + readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), }, { label: "Move timeline clip", coalesceKey }, ).then((handled) => { @@ -297,6 +300,9 @@ export function useTimelineEditing({ reloadPreview, domEditSaveTimestampRef, compositionPath: activeCompPath, + // Capture on-disk bytes as the undo `before` so undoing a timing + // resize restores the file verbatim, not a normalized full-DOM re-emit. + readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), }, { label: "Resize timeline clip", coalesceKey }, ).then((handled) => { diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 9e32d1f3c9..17279a8903 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -455,6 +455,153 @@ describe("sdkTimingPersist", () => { expect(result).toBe(false); expect(deps.writeProjectFile).not.toHaveBeenCalled(); }); + + // Finding #12: undo baseline must be the EXACT on-disk bytes (matching the + // style/delete paths), not a normalized SDK serialize() re-emit — otherwise + // undoing a timing edit reformats the whole file. + it("records the on-disk content (not serialize()) as the undo before when a reader is provided", async () => { + const deps = { + ...makeDeps(), + readProjectFile: vi.fn().mockResolvedValue("EXACT ON-DISK BYTES"), + }; + const session = makeSession(true); + await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps); + expect(deps.readProjectFile).toHaveBeenCalledWith("/comp.html"); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ + files: { + "/comp.html": { before: "EXACT ON-DISK BYTES", after: "after" }, + }, + }), + ); + }); + + it("falls back to serialize() before when the reader throws", async () => { + const deps = { + ...makeDeps(), + readProjectFile: vi.fn().mockRejectedValue(new Error("read failed")), + }; + 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" } }, + }), + ); + }); +}); + +describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeSession = () => + ({ + getElement: vi.fn().mockReturnValue({ id: "hf-box" }), + setGsapTween: vi.fn(), + serialize: vi + .fn() + .mockReturnValueOnce("serialized-before") + .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), + }) as unknown as Parameters[2]; + + it("records the on-disk content as the undo before, not serialize()", async () => { + const deps = { + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + readProjectFile: vi.fn().mockResolvedValue("on-disk gsap bytes"), + }; + const session = makeSession(); + await sdkGsapTweenPersist( + "/comp.html", + { kind: "set", animationId: "tw-1", properties: { ease: "power3.in" } }, + session, + deps, + ); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ + files: { + "/comp.html": { before: "on-disk gsap bytes", after: "after" }, + }, + }), + ); + }); +}); + +describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + + it("routes the read-modify-write through the keyed serializer so same-file flushes can't interleave", async () => { + const order: string[] = []; + let writeResolve: (() => void) | null = null; + const deps = { + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + // First write blocks until we release it, so without serialization the + // second op's serialize()/dispatch would interleave ahead of it. + writeProjectFile: vi.fn().mockImplementation((_p: string, content: string) => { + order.push(`write-start:${content}`); + if (content === "after-1") { + return new Promise((res) => { + writeResolve = () => { + order.push(`write-done:${content}`); + res(); + }; + }); + } + order.push(`write-done:${content}`); + return Promise.resolve(); + }), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + // A real per-key serializer: tasks under the same key run strictly in order. + serialize: (() => { + const inFlight = new Map>(); + return (key: string, task: () => Promise): Promise => { + const prior = inFlight.get(key) ?? Promise.resolve(); + const next = prior.then(task, task); + inFlight.set(key, next); + return next as Promise; + }; + })(), + }; + + let serializeCall = 0; + const session = { + getElement: vi.fn().mockReturnValue({ id: "hf-box" }), + setGsapTween: vi.fn(() => order.push("dispatch")), + serialize: vi.fn(() => { + serializeCall++; + // before-1, after-1, before-2, after-2 + return `${serializeCall % 2 === 1 ? "before" : "after"}-${Math.ceil(serializeCall / 2)}`; + }), + batch: vi.fn((fn: () => void) => fn()), + } as unknown as Parameters[2]; + + const p1 = sdkGsapTweenPersist( + "/comp.html", + { kind: "set", animationId: "tw-1", properties: { ease: "a" } }, + session, + deps, + ); + const p2 = sdkGsapTweenPersist( + "/comp.html", + { kind: "set", animationId: "tw-1", properties: { ease: "b" } }, + session, + deps, + ); + // Let the first op reach its (blocked) write before releasing it. + await Promise.resolve(); + await Promise.resolve(); + writeResolve?.(); + await Promise.all([p1, p2]); + + // The second op's write must NOT start before the first op's write completes. + const firstWriteDone = order.indexOf("write-done:after-1"); + const secondWriteStart = order.indexOf("write-start:after-2"); + expect(firstWriteDone).toBeGreaterThanOrEqual(0); + expect(secondWriteStart).toBeGreaterThan(firstWriteDone); + }); }); describe("sdkGsapTweenPersist", () => { diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index e6f38dac86..fcd95f12d4 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -5,6 +5,7 @@ import type { EditHistoryKind } from "./editHistory"; import type { PatchOperation } from "./sourcePatcher"; import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability"; import { trackStudioEvent } from "./studioTelemetry"; +import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry"; const CUTOVER_OP_TYPES = new Set([ "inline-style", @@ -90,6 +91,42 @@ export interface CutoverDeps { * — otherwise we'd write the full active-comp serialization into that file. */ compositionPath?: string | null; + /** + * Optional per-key task serializer (the same `gsap-file:${file}` serializer the + * legacy `commitMutation` uses). When provided, every GSAP-op persist routes its + * read-serialize → dispatch → serialize → write through it so two concurrent + * same-file flushes can't interleave their read-modify-write and lose an edit. + * Absent (e.g. in unit tests) → ops run unserialized as before. + */ + serialize?: (key: string, task: () => Promise) => Promise; + /** + * Optional reader for the on-disk content of targetPath. Timing/GSAP persists + * use it to capture the EXACT prior bytes as the undo-history `before`, so undo + * restores the file verbatim instead of a normalized SDK re-emit (which would + * reformat the whole file). The style/delete paths already thread originalContent + * in explicitly; this gives timing/GSAP parity without touching every call site. + * Absent → falls back to the SDK's pre-edit serialize() (the prior behavior). + */ + readProjectFile?: (path: string) => Promise; +} + +/** + * Capture the undo-history `before` baseline for timing/GSAP persists: the exact + * on-disk bytes when a reader is available (so undo restores them verbatim), + * falling back to the SDK's pre-edit serialization when it isn't. Never throws — + * a failed read degrades to the serialized fallback rather than aborting the edit. + */ +async function captureOnDiskBefore( + deps: CutoverDeps, + targetPath: string, + serializedFallback: string, +): Promise { + if (!deps.readProjectFile) return serializedFallback; + try { + return await deps.readProjectFile(targetPath); + } catch { + return serializedFallback; + } } /** True when targetPath isn't the composition the SDK session models. */ @@ -115,6 +152,11 @@ async function persistSdkSerialize( options?: CutoverOptions, ): Promise { deps.domEditSaveTimestampRef.current = Date.now(); + // Tag this write with the exact content (by hash) so the file-change + // reload-suppression can recognize its own echo by IDENTITY, not just a 2 s + // clock — an undo write (different bytes, not registered here) then always + // reloads instead of being swallowed by the time window. + markSelfWrite(targetPath, after); await deps.writeProjectFile(targetPath, after); await deps.editHistory.recordEdit({ label: options?.label ?? "Edit layer", @@ -178,16 +220,15 @@ export async function sdkTimingPersist( if (!sdkSession || !sdkSession.getElement(hfId)) return false; if (wrongCompositionFile(deps, targetPath)) return false; try { - // `before` is the SDK's serialized state, which is the true pre-edit - // content only while every edit routes through this session. During the - // dark-launch transition (server still writes some paths) the in-memory - // SDK doc can drift from disk, so this `before` may not match the file's - // actual prior bytes. Acceptable for v1; revisit once cutover is always-on. - const before = sdkSession.serialize(); + const serializedBefore = sdkSession.serialize(); sdkSession.batch(() => sdkSession.setTiming(hfId, timingUpdate)); const after = sdkSession.serialize(); - if (after === before) return false; - await persistSdkSerialize(after, targetPath, before, deps, options); + if (after === serializedBefore) return false; + // Undo baseline = exact on-disk bytes (matching the style/delete paths), so + // undoing a timing edit restores the file verbatim instead of a normalized + // full-DOM re-emit. Falls back to serializedBefore when no reader is wired. + const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore); + await persistSdkSerialize(after, targetPath, undoBefore, deps, options); trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 }); return true; } catch (err) { @@ -238,18 +279,30 @@ async function dispatchGsapOpAndPersist( if (!STUDIO_SDK_CUTOVER_ENABLED) return false; if (!sdkSession) return false; if (wrongCompositionFile(deps, targetPath)) return false; - try { - const before = sdkSession.serialize(); - dispatch(sdkSession); - 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) { - trackStudioEvent("sdk_cutover_fallback", { error: String(err) }); - return false; - } + const session = sdkSession; + // Route the whole read-serialize → dispatch → serialize → write through the + // per-file serializer (when provided) so overlapping same-file flushes can't + // interleave their read-modify-write and drop an edit, matching the legacy + // commitMutation path's `gsap-file:${file}` serialization. + const run = async (): Promise => { + try { + const serializedBefore = session.serialize(); + dispatch(session); + const after = session.serialize(); + if (after === serializedBefore) return false; + // Undo baseline = exact on-disk bytes (matching the style/delete paths), so + // undoing a GSAP edit restores the file verbatim instead of a normalized + // full-DOM re-emit. Falls back to serializedBefore when no reader is wired. + const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore); + await persistSdkSerialize(after, targetPath, undoBefore, deps, options); + trackStudioEvent("sdk_cutover_success", { opCount: 1 }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { error: String(err) }); + return false; + } + }; + return deps.serialize ? deps.serialize(`gsap-file:${targetPath}`, run) : run(); } export function sdkGsapKeyframePersist( From fd002ad34a4c86353c220622037f15642efbb0d7 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 12:55:32 -0700 Subject: [PATCH 32/43] refactor(core): extract split/collapse helpers to satisfy no-fallow-ignore rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #5 (split) and #15 (no-bang guards) fixes pushed splitAnimationsInScript and removeAllKeyframesFromScript over fallow's complexity threshold, and a fallow-ignore had been added to splitAnimationsInScript. Per the hard rule (never ignore — fix), extracted buildSpanningSplit + applyTweenSplit (split) and buildCollapsedFlatVars (collapse), and removed the ignore. Both functions now under threshold; fallow new-only gate reports 0 new findings. Behavior unchanged — core 1811 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/parsers/gsapWriterAcorn.ts | 171 +++++++++++-------- 1 file changed, 101 insertions(+), 70 deletions(-) diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 385b2aa992..4067b4f3e7 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -1022,22 +1022,32 @@ export function removeAllKeyframesFromScript(script: string, animationId: string const collapse = target.call.method === "from" ? sorted[0] : sorted[sorted.length - 1]; if (!collapse) return script; - // Flat vars = existing top-level props, then collapse-keyframe props (these - // win; skip the per-keyframe `ease` key), then duration/ease/extras. Drops - // keyframes + easeEach by reconstruction. - const flat: Record = { ...target.animation.properties }; + const ms = new MagicString(script); + overwriteVarsArg( + ms, + target.call, + buildVarsObjectCode(buildCollapsedFlatVars(target.animation, collapse)), + ); + return ms.toString(); +} + +// Flat vars for a tween collapsing its keyframes onto one stop: existing +// top-level props, then the collapse keyframe's props (skip per-keyframe +// `ease`), then duration/ease/extras. Drops keyframes + easeEach by omission. +function buildCollapsedFlatVars( + animation: GsapAnimation, + collapse: { properties: Record }, +): Record { + const flat: Record = { ...animation.properties }; for (const [k, v] of Object.entries(collapse.properties)) { if (k !== "ease") flat[k] = v; } - if (target.animation.duration !== undefined) flat.duration = target.animation.duration; - if (target.animation.ease) flat.ease = target.animation.ease; - for (const [k, v] of Object.entries(target.animation.extras ?? {})) { + if (animation.duration !== undefined) flat.duration = animation.duration; + if (animation.ease) flat.ease = animation.ease; + for (const [k, v] of Object.entries(animation.extras ?? {})) { if (typeof v === "number" || typeof v === "string") flat[k] = v; } - - const ms = new MagicString(script); - overwriteVarsArg(ms, target.call, buildVarsObjectCode(flat)); - return ms.toString(); + return flat; } /** Build the full replacement vars object for a tween being converted to keyframes. */ @@ -1697,7 +1707,84 @@ function computeForwardBaselines( return { before, final: { ...acc } }; } -// fallow-ignore-next-line complexity +// Split one tween that straddles the split point: trim the original to the +// first half (interpolated midpoint as its new end) and add a fromTo for the +// second half on the new element. `fromSource` is the forward baseline. +function buildSpanningSplit( + result: string, + anim: GsapAnimation, + pos: number, + dur: number, + fromSource: Record, + ctx: { splitTime: number; newSelector: string; newElementStart: number }, +): string { + const progress = dur > 0 ? (ctx.splitTime - pos) / dur : 0; + const midProps: Record = {}; + for (const [k, v] of Object.entries(anim.properties)) { + if (typeof v !== "number") { + midProps[k] = v; + continue; + } + const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0; + midProps[k] = fromVal + (v - fromVal) * progress; + } + const trimmed = updateAnimationInScript(result, anim.id, { + duration: ctx.splitTime - pos, + properties: midProps, + }); + return addAnimationToScript(trimmed, { + targetSelector: ctx.newSelector, + method: "fromTo", + position: ctx.newElementStart, + duration: pos + dur - ctx.splitTime, + properties: { ...anim.properties }, + fromProperties: { ...midProps }, + ease: anim.ease, + extras: anim.extras, + }).script; +} + +type SplitCtx = { + splitTime: number; + originalSelector: string; + newSelector: string; + newElementStart: number; +}; + +// Decide what one matching tween does at the split point: move to the new +// element (wholly after), stay (wholly before / keyframes before), get skipped +// (keyframes spanning), or get interpolated in half (spanning). Returns the +// updated script; pushes any skip reason into `skippedSelectors`. +function applyTweenSplit( + result: string, + anim: GsapAnimation, + baselineBefore: Record, + ctx: SplitCtx, + skippedSelectors: string[], +): string { + const pos = typeof anim.position === "number" ? anim.position : 0; + const dur = anim.duration ?? 0; + const animEnd = pos + dur; + + if (anim.keyframes) { + if (pos >= ctx.splitTime) + return updateAnimationSelectorInScript(result, anim.id, ctx.newSelector); + if (animEnd > ctx.splitTime) { + skippedSelectors.push(`${ctx.originalSelector} (keyframes spanning split)`); + } + // Inherited-state for kf tweens is handled by computeForwardBaselines. + return result; + } + // Wholly before the split — kept on the original element. + if (animEnd <= ctx.splitTime) return result; + // Wholly after — move to the new element. + if (pos >= ctx.splitTime) + return updateAnimationSelectorInScript(result, anim.id, ctx.newSelector); + // Spans the split — interpolate the midpoint from the FORWARD baseline. + const fromSource = anim.fromProperties ?? baselineBefore; + return buildSpanningSplit(result, anim, pos, dur, fromSource, ctx); +} + export function splitAnimationsInScript( script: string, opts: SplitAnimationsOptions, @@ -1736,67 +1823,11 @@ export function splitAnimationsInScript( // Reverse iteration: updateAnimationSelectorInScript mutates selectors which // can shift count-based ID suffixes for later animations. + const ctx = { splitTime: opts.splitTime, originalSelector, newSelector, newElementStart }; for (let i = matching.length - 1; i >= 0; i--) { const anim = matching[i]; if (!anim) continue; - const pos = typeof anim.position === "number" ? anim.position : 0; - const dur = anim.duration ?? 0; - const animEnd = pos + dur; - - if (anim.keyframes) { - if (pos >= opts.splitTime) { - result = updateAnimationSelectorInScript(result, anim.id, newSelector); - } else if (animEnd > opts.splitTime) { - skippedSelectors.push(`${originalSelector} (keyframes spanning split)`); - } - // Inherited-state accumulation for kf tweens is handled in the forward - // pre-pass (computeForwardBaselines). - continue; - } - - if (animEnd <= opts.splitTime) { - // Wholly before the split — kept on the original element; its contribution - // to the inherited baseline is computed in the forward pre-pass. - continue; - } - - if (pos >= opts.splitTime) { - result = updateAnimationSelectorInScript(result, anim.id, newSelector); - continue; - } - - // Spans the split — linear interpolation to compute mid-values, using the - // FORWARD baseline (props from earlier tweens), not a reverse accumulator. - const progress = dur > 0 ? (opts.splitTime - pos) / dur : 0; - const fromSource = anim.fromProperties ?? baselineBefore[i] ?? {}; - const midProps: Record = {}; - for (const [k, v] of Object.entries(anim.properties)) { - if (typeof v !== "number") { - midProps[k] = v; - continue; - } - const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0; - midProps[k] = fromVal + (v - fromVal) * progress; - } - - const firstHalfDuration = opts.splitTime - pos; - result = updateAnimationInScript(result, anim.id, { - duration: firstHalfDuration, - properties: midProps, - }); - - const secondHalfDuration = animEnd - opts.splitTime; - const addResult = addAnimationToScript(result, { - targetSelector: newSelector, - method: "fromTo", - position: newElementStart, - duration: secondHalfDuration, - properties: { ...anim.properties }, - fromProperties: { ...midProps }, - ease: anim.ease, - extras: anim.extras, - }); - result = addResult.script; + result = applyTweenSplit(result, anim, baselineBefore[i] ?? {}, ctx, skippedSelectors); } if (Object.keys(finalInheritedProps).length > 0) { From 508be317d4cc133f85fc84f7ff815610f8f9e148 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 13:05:54 -0700 Subject: [PATCH 33/43] test(studio): pin dark-launch flag-gate contract (review #1539, Rames/Via) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flag OFF ⇒ sdkTimingPersist / sdkGsapTweenPersist (GSAP-op chokepoint) / sdkDeletePersist all return false even with a valid session → legacy fallback. The prod flag-flip rests on this contract; sdkCutover.test.ts only mocks the flag TRUE, so a future gate refactor could silently re-enable cutover on flag-off without failing CI. This sibling file mocks it FALSE and locks the three guards. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../studio/src/utils/sdkCutover.gate.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 packages/studio/src/utils/sdkCutover.gate.test.ts diff --git a/packages/studio/src/utils/sdkCutover.gate.test.ts b/packages/studio/src/utils/sdkCutover.gate.test.ts new file mode 100644 index 0000000000..c8592dba88 --- /dev/null +++ b/packages/studio/src/utils/sdkCutover.gate.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; + +// Dark-launch contract: with STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover +// persist chokepoint must return false so the caller takes the legacy server +// path — even when a valid SDK session exists (one always does, for +// shadow/selection). This is the contract the prod flag-flip rests on; a future +// refactor of the gate guards that silently re-enables cutover on flag-off +// turns these red. (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.) +vi.mock("../components/editor/manualEditingAvailability", () => ({ + STUDIO_SDK_CUTOVER_ENABLED: false, +})); +vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); + +import { sdkTimingPersist, sdkGsapTweenPersist, sdkDeletePersist } from "./sdkCutover"; + +const makeSession = () => + ({ + getElement: () => ({ inlineStyles: {} }), + serialize: () => "", + batch: (fn: () => void) => fn(), + setTiming: vi.fn(), + dispatch: vi.fn(), + }) as never; + +const makeDeps = () => + ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: { current: 0 }, + }) as never; + +describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist returns false", () => { + it("sdkTimingPersist falls back without writing", async () => { + const deps = makeDeps(); + expect(await sdkTimingPersist("hf-a", "/c.html", { start: 1 }, makeSession(), deps)).toBe( + false, + ); + expect( + (deps as unknown as { writeProjectFile: ReturnType }).writeProjectFile, + ).not.toHaveBeenCalled(); + }); + + it("sdkGsapTweenPersist (shared GSAP-op chokepoint) falls back", async () => { + expect( + await sdkGsapTweenPersist( + "/c.html", + { kind: "remove", animationId: "a" }, + makeSession(), + makeDeps(), + ), + ).toBe(false); + }); + + it("sdkDeletePersist falls back", async () => { + expect( + await sdkDeletePersist("hf-a", "", "/c.html", makeSession(), makeDeps()), + ).toBe(false); + }); +}); From 057e0d0e63ceea1ce4980177adc73b077e602ffc Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 13:07:26 -0700 Subject: [PATCH 34/43] fix(studio): leading flag-gate on sdkGsapTweenPersist (review #1539 nit, Via) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The add-op getElement existence check ran before the inner gate, so flag-off did an SDK touch before falling back. Lead with the flag guard to match the other three chokepoints — flag-off is now a clean no-op at every entry point. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/studio/src/utils/sdkCutover.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index fcd95f12d4..09668be604 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -249,6 +249,9 @@ export function sdkGsapTweenPersist( deps: CutoverDeps, options?: CutoverOptions, ): Promise { + // Leading dark-launch gate so flag-off does no SDK touch (getElement) at all — + // matches the other three chokepoints' discipline. + if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(false); if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target)) return Promise.resolve(false); // dispatchGsapOpAndPersist returns false on before===after — that catches stale From f9bdd18fd589bb1a406360b95570c093f3ff6612 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 13:18:37 -0700 Subject: [PATCH 35/43] =?UTF-8?q?fix(core):=20unroll-preservation=20regres?= =?UTF-8?q?sions=20=E2=80=94=20non-for=20loops=20+=20AST=20index=20substit?= =?UTF-8?q?ution=20(review=20R2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #9 unroll-preservation fix had two confirmed regressions: - Non-for loops (forEach/for-of/for-in/while): loopIndexVarName returns null, so substitution no-op'd and preserved siblings kept a now-undefined loop variable (e.g. `item`) → ReferenceError at render. Now returns null for those forms → caller falls back to the blanket loop overwrite (drops siblings, valid code). The #9 fixture only used `for(let i…)` so it never caught this. - substituteLoopIndex did a \bvar\b regex over raw source including string literals, corrupting selectors like ".row-i" → ".row-0". Now AST-based: substitutes only real Identifier uses, skipping string literals and non-computed member/key positions (extracted isIndexBindingPosition helper to stay under the fallow complexity threshold — no ignore added). Two regression tests added (forEach no-dangling-var; for-loop string-literal intact). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../parsers/gsapWriter.reviewFixes.test.ts | 51 +++++++++++++++++++ packages/core/src/parsers/gsapWriterAcorn.ts | 49 ++++++++++++++---- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts b/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts index aba0a1ae2e..ef60dc9a7c 100644 --- a/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts +++ b/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts @@ -131,6 +131,57 @@ for (let i = 0; i < 2; i++) { }); }); +// ── R2 #1 — non-`for` loops must not leave preserved siblings with an unbound index var ── + +describe("R2 — unroll on a forEach does not emit an unbound loop variable", () => { + const FOREACH = `var tl = gsap.timeline({ paused: true }); +items.forEach((item, i) => { + tl.set(item, { autoAlpha: 0 }, 0); + tl.to(item, { opacity: 1, duration: 1 }, 0); +});`; + + it("falls back to the blanket overwrite (valid code, no dangling `item`)", () => { + const parsed = parseGsapScriptAcornForWrite(FOREACH); + const targetId = parsed?.located.find((l) => l.animation.method === "to")?.id ?? ""; + const out = unrollDynamicAnimations(FOREACH, targetId, [ + { selector: "#a", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] }, + { selector: "#b", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] }, + ]); + // The forEach (and its `item` param) is gone — no preserved sibling can + // reference a now-undefined `item` (the bug emitted `tl.set(item, …)` with + // `item` unbound → ReferenceError at render). + expect(out).not.toContain("item"); + expect(out).not.toContain("forEach"); + expect(out).toContain('tl.to("#a"'); + expect(out).toContain('tl.to("#b"'); + }); +}); + +// ── R2 #5 — index substitution is AST-based: string literals are never corrupted ── + +describe("R2 — unroll substitutes real index uses but not the index char in strings", () => { + const LOOP_STR = `var tl = gsap.timeline({ paused: true }); +for (let i = 0; i < 2; i++) { + tl.set(items[i], { id: "row-i" }, 0); + tl.to(items[i], { opacity: 1, duration: 1 }, 0); +}`; + + it('rewrites items[i] per iteration but leaves the "row-i" string intact', () => { + const parsed = parseGsapScriptAcornForWrite(LOOP_STR); + const targetId = parsed?.located.find((l) => l.animation.method === "to")?.id ?? ""; + const out = unrollDynamicAnimations(LOOP_STR, targetId, [ + { selector: "#a", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] }, + { selector: "#b", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] }, + ]); + // Real uses of the index are substituted… + expect(out).toContain("items[0]"); + expect(out).toContain("items[1]"); + // …but the literal "row-i" is untouched (the regex bug rewrote it to "row-0"). + expect(out).toContain('"row-i"'); + expect(out).not.toContain('"row-0"'); + }); +}); + // ── #10 — per-segment curviness survives serialization ── describe("#10 — updateArcSegment on a non-first segment reflects its curviness", () => { diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 4067b4f3e7..a8ec5c5829 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -1895,14 +1895,39 @@ function loopIndexVarName(loopNode: any): string | null { } /** - * Rewrite one body statement's source for iteration `idx`: replace whole-word - * occurrences of the loop index variable with the literal index. Keeps non-target - * statements (e.g. `tl.set(items[i], …)`) alive instead of discarding them. + * Rewrite one body statement's source for iteration `idx`: replace USES of the + * loop index variable (AST Identifier nodes) with the literal index. AST-based, + * not a text regex, so the index name appearing inside a string literal (e.g. a + * selector ".row-i") or as a non-computed member/key (`obj.i`, `{ i: … }`) is + * left untouched — only real references to the variable are substituted. */ -function substituteLoopIndex(stmtSource: string, indexVar: string | null, idx: number): string { - if (!indexVar) return stmtSource; - const re = new RegExp(`\\b${indexVar.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"); - return stmtSource.replace(re, String(idx)); +// An identifier in "binding position" is a name, not a value reference: a +// non-computed member property (`obj.i`) or object-literal key (`{ i: … }`). +// Those must NOT be substituted with the iteration index. +function isIndexBindingPosition(node: any, parent: any): boolean { + if (parent?.type === "MemberExpression") return parent.property === node && !parent.computed; + if (parent?.type === "Property" || parent?.type === "ObjectProperty") { + return parent.key === node && !parent.computed; + } + return false; +} + +function substituteLoopIndex(stmt: any, indexVar: string, idx: number, script: string): string { + const base = stmt.start as number; + const src = script.slice(base, stmt.end as number); + const ranges: Array<[number, number]> = []; + acornWalk.ancestor(stmt, { + Identifier(node: any, _state: unknown, ancestors: any[]) { + if (node.name !== indexVar) return; + if (isIndexBindingPosition(node, ancestors[ancestors.length - 2])) return; + ranges.push([(node.start as number) - base, (node.end as number) - base]); + }, + }); + if (ranges.length === 0) return src; + ranges.sort((a, b) => b[0] - a[0]); + let out = src; + for (const [s, e] of ranges) out = out.slice(0, s) + String(idx) + out.slice(e); + return out; } function buildUnrollReplacement( @@ -1966,6 +1991,13 @@ function buildLoopUnrollPreserving( const stmts = loopBodyStatements(loopNode); if (!stmts || !stmts.includes(targetStmt)) return null; const indexVar = loopIndexVarName(loopNode); + // Only preserve siblings when we have a bound numeric index to substitute + // (`for (let i …)`). For forEach/for-of/for-in/while the iteration variable + // isn't a numeric index we can splice, so substituting would leave preserved + // siblings referencing a now-undefined loop variable (ReferenceError at + // render). Return null → caller falls back to the blanket loop overwrite, + // which drops the siblings but emits valid code. + if (!indexVar) return null; const lines: string[] = []; for (let idx = 0; idx < elements.length; idx++) { const el = elements[idx]; @@ -1974,8 +2006,7 @@ function buildLoopUnrollPreserving( if (stmt === targetStmt) { lines.push(buildUnrollCallForElement(timelineVar, animation, el)); } else { - const src = script.slice(stmt.start as number, stmt.end as number); - lines.push(substituteLoopIndex(src, indexVar, idx)); + lines.push(substituteLoopIndex(stmt, indexVar, idx, script)); } } } From 5b3db8de62e2085978f79f837da5211432185a2f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 14:39:30 -0700 Subject: [PATCH 36/43] fix(sdk,core): unrollDynamicAnimations rejects empty element list (R1 #1501b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty `elements` array has no unrolled form — the writer would overwrite the loop/statement with zero tween calls, silently deleting the animation. - gsapWriterAcorn: unrollDynamicAnimations returns the script verbatim on an empty list (no-op instead of a destructive overwrite). - validateOp: reject unrollDynamicAnimations with empty elements as E_INVALID_ARGS so callers get a clean error rather than silent corruption. - Tests: writer no-op on []; validateOp E_INVALID_ARGS on []. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/parsers/gsapWriter.reviewFixes.test.ts | 8 ++++++++ packages/core/src/parsers/gsapWriterAcorn.ts | 3 +++ packages/sdk/src/engine/mutate.test.ts | 14 ++++++++++++++ packages/sdk/src/engine/mutate.ts | 15 ++++++++++++++- 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts b/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts index ef60dc9a7c..7a465d1a24 100644 --- a/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts +++ b/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts @@ -129,6 +129,14 @@ for (let i = 0; i < 2; i++) { expect(out).toContain('tl.to("#a"'); expect(out).toContain('tl.to("#b"'); }); + + it("an empty element list is a no-op, not an animation-deleting overwrite", () => { + const parsed = parseGsapScriptAcornForWrite(LOOP); + const targetId = parsed?.located.find((l) => l.animation.method === "to")?.id ?? ""; + // Empty elements has no unrolled form — overwriting the loop with zero calls + // would silently delete the animation. Writer must return the script verbatim. + expect(unrollDynamicAnimations(LOOP, targetId, [])).toBe(LOOP); + }); }); // ── R2 #1 — non-`for` loops must not leave preserved siblings with an unbound index var ── diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index a8ec5c5829..474833f760 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -2024,6 +2024,9 @@ export function unrollDynamicAnimations( animationId: string, elements: UnrollElement[], ): string { + // An empty element list has no unrolled form — replacing the loop/statement + // with zero calls would silently delete the animation. No-op instead. + if (elements.length === 0) return script; const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return script; const target = parsed.located.find((l) => l.id === animationId); diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts index 2ef850f4df..0b002c3a33 100644 --- a/packages/sdk/src/engine/mutate.test.ts +++ b/packages/sdk/src/engine/mutate.test.ts @@ -492,6 +492,20 @@ describe("Phase 3b ops", () => { if (!r2.ok) expect(r2.code).toBe("E_NO_GSAP_SCRIPT"); }); + it("unrollDynamicAnimations rejects an empty element list (would delete the animation)", () => { + const parsed = parseMutable( + `
` + + ``, + ); + const r = validateOp(parsed, { + type: "unrollDynamicAnimations", + animationId: "tw-1", + elements: [], + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS"); + }); + it("setClassStyle no longer throws — implemented in Phase 3b", () => { expect(() => applyOp(fresh(), { diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 6809b1ae33..d5b9e3e6e3 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -1114,7 +1114,6 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "setArcPath": case "updateArcSegment": case "removeArcPath": - case "unrollDynamicAnimations": case "deleteAllForSelector": case "removeLabel": if (getGsapScript(parsed.document) === null) @@ -1124,6 +1123,20 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { "This composition does not use GSAP animations.", ); return CAN_OK; + case "unrollDynamicAnimations": + if (getGsapScript(parsed.document) === null) + return canErr( + "E_NO_GSAP_SCRIPT", + "No GSAP script block found in the composition.", + "This composition does not use GSAP animations.", + ); + if (op.elements.length === 0) + return canErr( + "E_INVALID_ARGS", + "unrollDynamicAnimations requires at least one element.", + "An empty element list would delete the animation; pass the resolved element list.", + ); + return CAN_OK; default: return canErr("E_UNKNOWN_OP", `Unknown op type: "${(op as EditOp).type}".`); } From 5dcd168a28837ab1a60bb25b3d8dea761408219e Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 14:55:41 -0700 Subject: [PATCH 37/43] perf(sdk): cache draft element in applyDraft, drop HTMLElement casts (R1 #1490a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyDraft runs at 60fps during a drag but re-ran doc.querySelector on every call — the _draftEl/_draftId fields were only consumed by commit/cancel, never to skip the query. Reuse the tracked element when the id matches and the node is still connected; re-query only on id change or detach (iframe reload). Retypes _draftEl to HTMLElement | null (only ever set from querySelector), which removes the `as HTMLElement` casts in commitPreview / _clearDraft. Test asserts a repeated same-id drag queries once. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/sdk/src/adapters/iframe.test.ts | 21 +++++++++++++++++++++ packages/sdk/src/adapters/iframe.ts | 24 ++++++++++++++---------- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/sdk/src/adapters/iframe.test.ts b/packages/sdk/src/adapters/iframe.test.ts index 879a96186f..c018619004 100644 --- a/packages/sdk/src/adapters/iframe.test.ts +++ b/packages/sdk/src/adapters/iframe.test.ts @@ -212,6 +212,7 @@ interface FakeDomEl { "data-x": string | null; "data-y": string | null; style: FakeStyle; + isConnected: boolean; getAttribute(name: string): string | null; querySelector(sel: string): FakeDomEl | null; } @@ -234,6 +235,7 @@ function fakeDomEl(id: string, dataX: string | null, dataY: string | null): Fake "data-x": dataX, "data-y": dataY, style, + isConnected: true, getAttribute(name) { if (name === "data-x") return this["data-x"]; if (name === "data-y") return this["data-y"]; @@ -304,6 +306,25 @@ describe("IframePreviewAdapter draft / commit / cancel", () => { }); }); + it("applyDraft reuses the cached element across repeated calls (no re-query)", () => { + const el = fakeDomEl("hf-abc", "0", "0"); + let queryCount = 0; + const iframe = { + contentDocument: { + querySelector(_sel: string) { + queryCount++; + return el; + }, + }, + } as unknown as HTMLIFrameElement; + const adapter = createIframePreviewAdapter(iframe); + adapter.applyDraft("hf-abc", { dx: 1, dy: 1 }); + adapter.applyDraft("hf-abc", { dx: 2, dy: 2 }); + adapter.applyDraft("hf-abc", { dx: 3, dy: 3 }); + // Queried once on the first call; the next two reuse the connected cache. + expect(queryCount).toBe(1); + }); + it("commitPreview without a dispatch callback is a no-op", () => { const el = fakeDomEl("hf-abc", "0", "0"); const adapter = createIframePreviewAdapter(fakeIframe(el)); diff --git a/packages/sdk/src/adapters/iframe.ts b/packages/sdk/src/adapters/iframe.ts index 139f689cbb..a0af067f94 100644 --- a/packages/sdk/src/adapters/iframe.ts +++ b/packages/sdk/src/adapters/iframe.ts @@ -102,7 +102,7 @@ class IframePreviewAdapter implements PreviewAdapter { /** Tracked id and element for the in-progress drag. */ private _draftId: string | null = null; - private _draftEl: Element | null = null; + private _draftEl: HTMLElement | null = null; constructor(iframe: HTMLIFrameElement, dispatch?: (op: EditOp) => void) { this.iframe = iframe; @@ -141,9 +141,14 @@ class IframePreviewAdapter implements PreviewAdapter { const doc = this.iframe.contentDocument; if (!doc) return; - const el = doc.querySelector( - `[data-hf-id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`, - ); + // Reuse the tracked element across the 60fps drag; only re-query when the id + // changes or the cached node detached (e.g. an iframe reload mid-drag). + const cached = id === this._draftId && this._draftEl?.isConnected ? this._draftEl : null; + const el = + cached ?? + doc.querySelector( + `[data-hf-id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`, + ); if (!el) return; this._draftId = id; @@ -167,11 +172,11 @@ class IframePreviewAdapter implements PreviewAdapter { return; } - const el = this._draftEl as HTMLElement; + const el = this._draftEl; const dx = parseFloat(el.style.getPropertyValue(VAR_DX) || "0") || 0; const dy = parseFloat(el.style.getPropertyValue(VAR_DY) || "0") || 0; - const dataX = (this._draftEl as Element).getAttribute("data-x"); - const dataY = (this._draftEl as Element).getAttribute("data-y"); + const dataX = el.getAttribute("data-x"); + const dataY = el.getAttribute("data-y"); const { x, y } = computeDraftPosition(dataX, dataY, dx, dy); this._dispatch({ type: "moveElement", target: this._draftId, x, y }); @@ -185,9 +190,8 @@ class IframePreviewAdapter implements PreviewAdapter { private _clearDraft(): void { if (this._draftEl) { - const el = this._draftEl as HTMLElement; - el.style.removeProperty(VAR_DX); - el.style.removeProperty(VAR_DY); + this._draftEl.style.removeProperty(VAR_DX); + this._draftEl.style.removeProperty(VAR_DY); } this._draftId = null; this._draftEl = null; From 3e17dfa64ef2cebef9f963be6216049c63946356 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 15:13:34 -0700 Subject: [PATCH 38/43] =?UTF-8?q?fix(sdk,core):=20round-3=20correctness=20?= =?UTF-8?q?=E2=80=94=20unroll=20AST=20safety,=20single-dispatch=20undo,=20?= =?UTF-8?q?empty-arg=20guards,=20persist=20decouple?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the highest-severity round-3 review findings: - gsapWriterAcorn unroll (R3 #1/#2/#9): the round-2 AST-substitution fix emitted invalid GSAP for object shorthand `{ i }` (→ `{ 0 }`) and shadowed inner bindings (→ `for(let i=0;0<3;0++)`), and silently dropped sibling statements on non-`for` loops (forEach/for-of). The unroll now REFUSES (no-ops, leaving the dynamic loop intact) whenever siblings can't be safely reproduced — a non-`for` loop, an unmodeled statement, or an unsafe index use — instead of dropping or corrupting. Plain `for` loops with safe siblings still unroll. - session single-dispatch undo (R3 #5/#11): _dispatch now reverses the inverse patch list (parity with batch()). A single op emitting order-dependent inverse patches — a nested parent+child removeElement, an aliased multi-target — undid forward and dropped the child subtree / landed on an intermediate value. - materializeKeyframes empty-array (R3 #10): the unguarded twin of the just-fixed unrollDynamicAnimations. Writer no-ops on an empty keyframe list; validateOp rejects it as E_INVALID_ARGS (shared gsapScriptMissing helper). - history:false persist decouple (R3 #4): persist (auto-save) no longer lives inside the history-enable block, so opting out of SDK undo no longer silently disables all disk writes (data-loss trap for #1496's flag consumers). Tests: unroll refuse cases (shorthand/shadow/forEach) + safe-for-loop regression; nested removeElement undo; materializeKeyframes writer no-op + validateOp reject; history:false-still-persists. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../parsers/gsapWriter.reviewFixes.test.ts | 79 +++++++--- packages/core/src/parsers/gsapWriterAcorn.ts | 136 ++++++++++++++---- packages/sdk/src/engine/mutate.gsap.test.ts | 20 +++ packages/sdk/src/engine/mutate.test.ts | 14 ++ packages/sdk/src/engine/mutate.ts | 54 ++++--- packages/sdk/src/session.test.ts | 20 +++ packages/sdk/src/session.ts | 25 +++- packages/sdk/src/smoke.test.ts | 14 ++ 8 files changed, 293 insertions(+), 69 deletions(-) diff --git a/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts b/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts index 7a465d1a24..2165366331 100644 --- a/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts +++ b/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts @@ -139,29 +139,74 @@ for (let i = 0; i < 2; i++) { }); }); -// ── R2 #1 — non-`for` loops must not leave preserved siblings with an unbound index var ── +// ── R3 — unsafe sibling reproduction must refuse (no-op), never corrupt/drop ── -describe("R2 — unroll on a forEach does not emit an unbound loop variable", () => { - const FOREACH = `var tl = gsap.timeline({ paused: true }); +const TWO_EL = [ + { selector: "#a", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] }, + { selector: "#b", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] }, +]; +function targetToId(script: string): string { + return ( + parseGsapScriptAcornForWrite(script)?.located.find((l) => l.animation.method === "to")?.id ?? "" + ); +} +function parses(src: string): boolean { + try { + new Function(src); + return true; + } catch { + return false; + } +} + +describe("R3 — unroll refuses (no-ops) when siblings can't be safely reproduced", () => { + // R2 carried a forEach WITH a sibling tl.set to the blanket overwrite, which + // dropped the tl.set (elements start visible instead of hidden). The numeric + // index a `for` loop provides isn't available, so we now refuse instead. + it("forEach with a sibling statement is left untouched, not flattened-and-dropped", () => { + const FOREACH = `var tl = gsap.timeline({ paused: true }); items.forEach((item, i) => { tl.set(item, { autoAlpha: 0 }, 0); tl.to(item, { opacity: 1, duration: 1 }, 0); });`; + expect(unrollDynamicAnimations(FOREACH, targetToId(FOREACH), TWO_EL)).toBe(FOREACH); + }); - it("falls back to the blanket overwrite (valid code, no dangling `item`)", () => { - const parsed = parseGsapScriptAcornForWrite(FOREACH); - const targetId = parsed?.located.find((l) => l.animation.method === "to")?.id ?? ""; - const out = unrollDynamicAnimations(FOREACH, targetId, [ - { selector: "#a", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] }, - { selector: "#b", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] }, - ]); - // The forEach (and its `item` param) is gone — no preserved sibling can - // reference a now-undefined `item` (the bug emitted `tl.set(item, …)` with - // `item` unbound → ReferenceError at render). - expect(out).not.toContain("item"); - expect(out).not.toContain("forEach"); - expect(out).toContain('tl.to("#a"'); - expect(out).toContain('tl.to("#b"'); + // R3 #1 — object shorthand { i }: substituting the value yields `{ 0 }` (invalid). + it("object shorthand using the index refuses rather than emit invalid `{ 0 }`", () => { + const SHORTHAND = `var tl = gsap.timeline({ paused: true }); +for (let i = 0; i < 2; i++) { + tl.set(items[i], { data: { i } }, 0); + tl.to(items[i], { opacity: 1, duration: 1 }, 0); +}`; + const out = unrollDynamicAnimations(SHORTHAND, targetToId(SHORTHAND), TWO_EL); + expect(out).toBe(SHORTHAND); + expect(parses(out)).toBe(true); + }); + + // R3 #2 — a sibling that re-declares the index (nested for / shadowing). + it("a sibling shadowing the index refuses rather than rewrite the inner binding", () => { + const SHADOW = `var tl = gsap.timeline({ paused: true }); +for (let i = 0; i < 2; i++) { + tl.set(items[i], { onStart() { for (let i = 0; i < 3; i++) log(i); } }, 0); + tl.to(items[i], { opacity: 1, duration: 1 }, 0); +}`; + const out = unrollDynamicAnimations(SHADOW, targetToId(SHADOW), TWO_EL); + expect(out).toBe(SHADOW); + expect(parses(out)).toBe(true); + }); + + // The safe for-loop sibling case must still unroll (regression guard). + it("a plain for-loop with an items[i] sibling still unrolls and preserves it", () => { + const SAFE = `var tl = gsap.timeline({ paused: true }); +for (let i = 0; i < 2; i++) { + tl.set(items[i], { autoAlpha: 0 }, 0); + tl.to(items[i], { opacity: 1, duration: 1 }, 0); +}`; + const out = unrollDynamicAnimations(SAFE, targetToId(SAFE), TWO_EL); + expect((out.match(/tl\.set\(/g) ?? []).length).toBe(2); + expect(out).not.toContain("for ("); + expect(parses(out)).toBe(true); }); }); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 474833f760..88a0cedcbe 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -1139,6 +1139,9 @@ export function materializeKeyframesFromScript( easeEach?: string, resolvedSelector?: string, ): string { + // An empty keyframe list has no materialized form — rebuilding vars with an + // empty keyframes object would empty the animation. No-op instead. + if (keyframes.length === 0) return script; const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return script; const target = parsed.located.find((l) => l.id === animationId); @@ -1972,13 +1975,111 @@ function buildUnrollCallForElement( return `${timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`; } +/** Sentinel: the unroll cannot safely reproduce the loop body — caller no-ops. */ +const REFUSE_UNROLL = Symbol("refuse-unroll"); + +/** Every statement in a loop's body block (unfiltered), or [] when not a block. */ +function loopBodyRawStatements(loopNode: any): any[] { + const body = + loopNode?.type === "ExpressionStatement" + ? loopNode.expression?.arguments?.[0]?.body + : loopNode?.body; + return body?.type === "BlockStatement" ? (body.body ?? []) : []; +} + +/** A node that re-binds `indexVar`: a re-declaration or a function param. */ +function rebindsIndex(node: any, indexVar: string): boolean { + if (node.type === "VariableDeclarator") return node.id?.name === indexVar; + if ( + node.type === "FunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "ArrowFunctionExpression" + ) { + return (node.params ?? []).some((p: any) => p?.name === indexVar); + } + return false; +} + +/** Object shorthand `{ i }` — substituting the value would yield invalid `{ 0 }`. */ +function isShorthandIndexUse(node: any, indexVar: string): boolean { + return ( + (node.type === "Property" || node.type === "ObjectProperty") && + node.shorthand === true && + propKeyName(node) === indexVar + ); +} + +/** + * A sibling statement can't be safely index-substituted when it re-binds the + * loop index (shadowing — a nested `for (let i …)`, a callback param `i`) or + * uses it in object shorthand (`{ i }`, which would splice to the invalid + * `{ 0 }`). substituteLoopIndex has no scope analysis, so in these cases it + * would emit broken or wrong code — the unroll must refuse instead. + */ +function hasUnsafeLoopIndexUse(stmt: any, indexVar: string): boolean { + let unsafe = false; + acornWalk.full(stmt, (node: any) => { + if (!unsafe && (isShorthandIndexUse(node, indexVar) || rebindsIndex(node, indexVar))) { + unsafe = true; + } + }); + return unsafe; +} + +/** How to handle the loop body's non-target siblings when unrolling. */ +function unrollSiblingStrategy( + loopNode: any, + targetStmt: any, + stmts: any[], + indexVar: string | null, +): "blanket" | "refuse" | "preserve" { + const siblings = stmts.filter((s) => s !== targetStmt); + // A sibling the filtered statement list doesn't model (non-ExpressionStatement) + // would be silently lost by either path — refuse if any exists. + const hasUnmodeledSibling = loopBodyRawStatements(loopNode).some( + (s) => s !== targetStmt && !stmts.includes(s), + ); + if (siblings.length === 0 && !hasUnmodeledSibling) return "blanket"; + if (hasUnmodeledSibling || !indexVar) return "refuse"; + return siblings.some((s) => hasUnsafeLoopIndexUse(s, indexVar)) ? "refuse" : "preserve"; +} + +/** Emit the per-iteration unrolled lines (target → static tl.to, siblings → index-substituted). */ +function emitUnrolledLines( + stmts: any[], + targetStmt: any, + elements: UnrollElement[], + timelineVar: string, + animation: GsapAnimation, + indexVar: string, + script: string, +): string { + const lines: string[] = []; + for (let idx = 0; idx < elements.length; idx++) { + const el = elements[idx]; + if (!el) continue; + for (const stmt of stmts) { + lines.push( + stmt === targetStmt + ? buildUnrollCallForElement(timelineVar, animation, el) + : substituteLoopIndex(stmt, indexVar, idx, script), + ); + } + } + return lines.join("\n "); +} + /** * Unroll the loop body, preserving every statement that is NOT the target tween. * For each iteration, emit each non-target statement with the loop index * substituted (e.g. `tl.set(items[i], …)` → `tl.set(items[0], …)`), and replace - * the target tween statement with that element's static `tl.to()` call. Without - * this, a blanket overwrite of the loop body discards sibling statements such as - * an initial-state `tl.set(...)`. + * the target tween statement with that element's static `tl.to()` call. + * + * Returns null when a blanket overwrite is lossless (no sibling statements), and + * REFUSE_UNROLL when siblings exist but can't be safely reproduced — a non-`for` + * loop (no numeric index to splice), a statement we don't model, or an unsafe + * index use (shadowing / shorthand). Refusing no-ops the unroll, which is safe: + * the dynamic loop keeps rendering correctly, just un-flattened. */ function buildLoopUnrollPreserving( script: string, @@ -1987,30 +2088,14 @@ function buildLoopUnrollPreserving( elements: UnrollElement[], loopNode: any, targetStmt: any, -): string | null { +): string | null | typeof REFUSE_UNROLL { const stmts = loopBodyStatements(loopNode); if (!stmts || !stmts.includes(targetStmt)) return null; const indexVar = loopIndexVarName(loopNode); - // Only preserve siblings when we have a bound numeric index to substitute - // (`for (let i …)`). For forEach/for-of/for-in/while the iteration variable - // isn't a numeric index we can splice, so substituting would leave preserved - // siblings referencing a now-undefined loop variable (ReferenceError at - // render). Return null → caller falls back to the blanket loop overwrite, - // which drops the siblings but emits valid code. - if (!indexVar) return null; - const lines: string[] = []; - for (let idx = 0; idx < elements.length; idx++) { - const el = elements[idx]; - if (!el) continue; - for (const stmt of stmts) { - if (stmt === targetStmt) { - lines.push(buildUnrollCallForElement(timelineVar, animation, el)); - } else { - lines.push(substituteLoopIndex(stmt, indexVar, idx, script)); - } - } - } - return lines.join("\n "); + const strategy = unrollSiblingStrategy(loopNode, targetStmt, stmts, indexVar); + if (strategy === "blanket") return null; + if (strategy === "refuse" || !indexVar) return REFUSE_UNROLL; + return emitUnrolledLines(stmts, targetStmt, elements, timelineVar, animation, indexVar, script); } /** @@ -2046,6 +2131,9 @@ export function unrollDynamicAnimations( targetStmt, ) : null; + // Siblings exist but can't be safely reproduced — leave the loop untouched + // rather than drop or corrupt them. The op no-ops (before === after). + if (preserving === REFUSE_UNROLL) return script; // Fall back to the simple whole-body replacement when the body isn't a plain // block of statements we can preserve. const replacement = diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index 23bd039a94..0b5382dc37 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -442,6 +442,26 @@ describe("removeAllKeyframes", () => { }); }); +// ─── materializeKeyframes ────────────────────────────────────────────────────── + +describe("materializeKeyframes", () => { + // dispatch bypasses validateOp, so the writer guard is the protection: an empty + // keyframe list must no-op rather than rebuild vars with an empty keyframes + // object (which would empty the animation). Uses the real anim id so the no-op + // is attributable to the empty list, not an unresolved id. + it("empty keyframe list no-ops on the dispatch path (writer guard)", () => { + const parsed = fresh(); + const before = getScript(parsed); + const result = applyOp(parsed, { + type: "materializeKeyframes", + animationId: TWEEN_ANIM_ID, + keyframes: [], + }); + expect(result.forward).toHaveLength(0); + expect(getScript(parsed)).toBe(before); + }); +}); + // ─── convertToKeyframes ──────────────────────────────────────────────────────── describe("convertToKeyframes", () => { diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts index 0b002c3a33..c087fe8ab3 100644 --- a/packages/sdk/src/engine/mutate.test.ts +++ b/packages/sdk/src/engine/mutate.test.ts @@ -506,6 +506,20 @@ describe("Phase 3b ops", () => { if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS"); }); + it("materializeKeyframes rejects an empty keyframe list (would empty the animation)", () => { + const parsed = parseMutable( + `
` + + ``, + ); + const r = validateOp(parsed, { + type: "materializeKeyframes", + animationId: "tw-1", + keyframes: [], + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS"); + }); + it("setClassStyle no longer throws — implemented in Phase 3b", () => { expect(() => applyOp(fresh(), { diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index d5b9e3e6e3..e0d29a552c 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -1047,6 +1047,17 @@ function canErr(code: string, message: string, hint?: string): CanResult { return hint ? { ok: false, code, message, hint } : { ok: false, code, message }; } +/** E_NO_GSAP_SCRIPT CanResult when the composition has no GSAP script, else null. */ +function gsapScriptMissing(parsed: ParsedDocument): CanResult | null { + return getGsapScript(parsed.document) === null + ? canErr( + "E_NO_GSAP_SCRIPT", + "No GSAP script block found in the composition.", + "This composition does not use GSAP animations.", + ) + : null; +} + /** Dry-run validation — returns CanResult for the given op against current document state. */ // fallow-ignore-next-line complexity export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { @@ -1108,7 +1119,6 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "removeGsapTween": case "removeAllKeyframes": case "convertToKeyframes": - case "materializeKeyframes": case "splitIntoPropertyGroups": case "splitAnimations": case "setArcPath": @@ -1116,27 +1126,29 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "removeArcPath": case "deleteAllForSelector": case "removeLabel": - if (getGsapScript(parsed.document) === null) - return canErr( - "E_NO_GSAP_SCRIPT", - "No GSAP script block found in the composition.", - "This composition does not use GSAP animations.", - ); - return CAN_OK; + return gsapScriptMissing(parsed) ?? CAN_OK; case "unrollDynamicAnimations": - if (getGsapScript(parsed.document) === null) - return canErr( - "E_NO_GSAP_SCRIPT", - "No GSAP script block found in the composition.", - "This composition does not use GSAP animations.", - ); - if (op.elements.length === 0) - return canErr( - "E_INVALID_ARGS", - "unrollDynamicAnimations requires at least one element.", - "An empty element list would delete the animation; pass the resolved element list.", - ); - return CAN_OK; + return ( + gsapScriptMissing(parsed) ?? + (op.elements.length === 0 + ? canErr( + "E_INVALID_ARGS", + "unrollDynamicAnimations requires at least one element.", + "An empty element list would delete the animation; pass the resolved element list.", + ) + : CAN_OK) + ); + case "materializeKeyframes": + return ( + gsapScriptMissing(parsed) ?? + (op.keyframes.length === 0 + ? canErr( + "E_INVALID_ARGS", + "materializeKeyframes requires at least one keyframe.", + "An empty keyframe list would empty the animation; pass the resolved keyframes.", + ) + : CAN_OK) + ); default: return canErr("E_UNKNOWN_OP", `Unknown op type: "${(op as EditOp).type}".`); } diff --git a/packages/sdk/src/session.test.ts b/packages/sdk/src/session.test.ts index d5a3b543a3..326d1dfcc8 100644 --- a/packages/sdk/src/session.test.ts +++ b/packages/sdk/src/session.test.ts @@ -299,6 +299,26 @@ describe("override-set orphan cleanup on removeElement", () => { }); }); +describe("single-dispatch undo reverses the inverse patch list", () => { + // A single dispatch that emits order-dependent inverse patches (here a nested + // parent+child removeElement) must undo in reverse application order. Without + // the reverse, undo replays 'add child' before 'add parent' → the child has no + // parent to attach to and is dropped. + it("removeElement([child, parent]) undo restores both, child included", async () => { + const NESTED = `
+
x
+
`; + const comp = await openComposition(NESTED); + comp.dispatch({ type: "removeElement", target: ["hf-child", "hf-parent"] }); + expect(comp.getElement("hf-parent")).toBeNull(); + expect(comp.getElement("hf-child")).toBeNull(); + + comp.undo(); + expect(comp.getElement("hf-parent")).not.toBeNull(); + expect(comp.getElement("hf-child")).not.toBeNull(); + }); +}); + // ─── setSelection / getSelection / selectionchange ─────────────────────────── describe("setSelection", () => { diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index 50cc5e680b..b8d897f97c 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -297,7 +297,13 @@ class CompositionImpl implements Composition { this.batchInverse.push(...inverse); if (!this.batchOpTypes.includes(op.type)) this.batchOpTypes.push(op.type); } else { - const event = buildPatchEvent(forward, inverse, origin, [op.type]); + // Reverse the inverse list (parity with batch() below): an op that emits + // multiple patches whose undo order matters — same path (reorderElements + // with a duplicate target), an aliased multi-target, or a nested + // parent+child removeElement — must undo in reverse application order, or + // undo lands on an intermediate value / drops a subtree. Harmless for the + // common single-patch / independent-path case. + const event = buildPatchEvent(forward, [...inverse].reverse(), origin, [op.type]); this.patchHandlers.forEach((h) => h(event)); this.changeHandlers.forEach((h) => h()); } @@ -501,12 +507,17 @@ export async function openComposition( const isEmbedded = opts?.overrides !== undefined; - if (!isEmbedded && opts?.history !== false) { - const history = createHistory(session, { - coalesceMs: opts?.coalesceMs ?? 300, - trackedOrigins: opts?.trackedOrigins, - }); - session.attachHistory(history); + if (!isEmbedded) { + // history:false opts out of the SDK undo stack ONLY. Persist (auto-save) is + // independent — gating it on the history flag too would silently drop every + // disk write for a caller that just wanted to disable undo (data loss). + if (opts?.history !== false) { + const history = createHistory(session, { + coalesceMs: opts?.coalesceMs ?? 300, + trackedOrigins: opts?.trackedOrigins, + }); + session.attachHistory(history); + } if (opts?.persist) { const pq = createPersistQueue(session, opts.persist, { diff --git a/packages/sdk/src/smoke.test.ts b/packages/sdk/src/smoke.test.ts index dbf69d746a..c1d038df65 100644 --- a/packages/sdk/src/smoke.test.ts +++ b/packages/sdk/src/smoke.test.ts @@ -227,6 +227,20 @@ describe("persist adapter", () => { expect(content).toContain("color: #f00"); }); + it("still persists when history:false (undo opt-out must not disable auto-save)", async () => { + const adapter = createMemoryAdapter(); + const writeSpy = vi.spyOn(adapter, "write"); + + const comp = await openComposition(BASE_HTML, { persist: adapter, history: false }); + expect(comp.canUndo()).toBe(false); // undo is off… + comp.setStyle("hf-title", { color: "#f00" }); + await comp.flush(); + + expect(writeSpy).toHaveBeenCalled(); // …but the write still happened + const [, content] = writeSpy.mock.calls[0] as [string, string]; + expect(content).toContain("color: #f00"); + }); + it("surfaces persist errors via on('persist:error')", async () => { const adapter = createMemoryAdapter(); const errors: unknown[] = []; From d924a9be11794e6d52ec331757abf6b8452e5780 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 15:16:17 -0700 Subject: [PATCH 39/43] fix(core): stripGsapForId re-parses per removal so all tweens for a deleted element are stripped (R3 #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Animation ids are count-based (positional), so removing one tween renumbers the survivors. stripGsapForId captured every matching id from a single up-front parse then removed against the mutating script — after the first removal the later ids were stale and silently no-op'd, leaving an orphaned tl.to() referencing the just-deleted element. Now re-parse after each removal and strip the first still-matching animation until none remain. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/parsers/htmlParser.test.ts | 23 ++++++++++++++++++++ packages/core/src/parsers/htmlParser.ts | 23 ++++++++++++++------ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/core/src/parsers/htmlParser.test.ts b/packages/core/src/parsers/htmlParser.test.ts index 2d786a7d35..cd75431091 100644 --- a/packages/core/src/parsers/htmlParser.test.ts +++ b/packages/core/src/parsers/htmlParser.test.ts @@ -536,6 +536,29 @@ describe("removeElementFromHtml", () => { expect(updated).not.toContain('id="el1"'); expect(updated).toContain('id="el2"'); }); + + it("strips ALL gsap tweens for the removed element, not just the first", () => { + // Two tweens on the same element → count-based ids renumber when the first is + // removed, so a single up-front parse left the second tween orphaned. + const html = ` + +
+
box
+
+ +`; + + const updated = removeElementFromHtml(html, "box"); + + expect(updated).not.toContain('data-hf-id="box"'); + // Neither tween may survive — the orphaned second tl.to referenced a deleted element. + expect(updated).not.toContain("x: 100"); + expect(updated).not.toContain("x: 200"); + }); }); describe("validateCompositionHtml", () => { diff --git a/packages/core/src/parsers/htmlParser.ts b/packages/core/src/parsers/htmlParser.ts index df02653af0..5a90825446 100644 --- a/packages/core/src/parsers/htmlParser.ts +++ b/packages/core/src/parsers/htmlParser.ts @@ -683,15 +683,24 @@ function selectorTargetsId(selector: string, id: string): boolean { } function stripGsapForId(script: string, elementId: string): string { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; + // Re-parse after every removal. Animation ids are count-based (positional), so + // removing one tween renumbers the survivors — ids captured from a single + // up-front parse go stale and silently no-op, orphaning later tweens on the + // now-deleted element. Always remove the FIRST still-matching animation in a + // freshly-parsed script until none remain. let current = script; - for (const { id: animId, animation } of parsed.located) { - if (selectorTargetsId(animation.targetSelector, elementId)) { - current = removeAnimationFromScript(current, animId); - } + for (;;) { + const parsed = parseGsapScriptAcornForWrite(current); + if (!parsed) return current; + const match = parsed.located.find((l) => + selectorTargetsId(l.animation.targetSelector, elementId), + ); + if (!match) return current; + const updated = removeAnimationFromScript(current, match.id); + // Guard against a non-removing match (would otherwise loop forever). + if (updated === current) return current; + current = updated; } - return current; } function cascadeRemoveGsapById(doc: Document, elementId: string): void { From 86afc0aca9f0bcb174e456a1fc438824d2b447c8 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 15:31:47 -0700 Subject: [PATCH 40/43] =?UTF-8?q?fix(core):=20gsap=20writer=20=E2=80=94=20?= =?UTF-8?q?keyframe=20ease=20routing,=20convert=20preserves=20delay,=20add?= =?UTF-8?q?Label=20dedup=20(R3=20#7/#8/#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #7: updateAnimationInScript routes an ease update on a keyframe tween to keyframes.easeEach (per-keyframe), not a top-level ease that GSAP ignores — the user's keyframe-easing edit was silently a no-op. - #8: convertToKeyframesFromScript now preserves every non-editable vars key (delay/callbacks/stagger/yoyo/…) verbatim via preservedVarsEntries instead of rebuilding from the GsapAnimation object, which had no `delay` field and dropped it — shifting the tween's start time. - #12: addLabelToScript moves an existing same-named label (overwrites its position) instead of appending a duplicate; duplicates made removeLabel over-remove (it deletes every match, including a pre-existing label). Tests: easeEach routing, delay preservation, addLabel move-not-duplicate + hand-authored-dup removal. Updated the old "no dedup contract" corpus test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../parsers/gsapWriter.reviewFixes.test.ts | 32 +++++++ packages/core/src/parsers/gsapWriterAcorn.ts | 94 ++++++++++++++----- .../parsers/gsapWriterParity.corpus.test.ts | 16 ++-- 3 files changed, 109 insertions(+), 33 deletions(-) diff --git a/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts b/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts index 2165366331..11ed3c460e 100644 --- a/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts +++ b/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts @@ -15,6 +15,8 @@ import { updateArcSegmentInScript, splitAnimationsInScript, unrollDynamicAnimations, + updateAnimationInScript, + convertToKeyframesFromScript, } from "./gsapWriterAcorn.js"; import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js"; @@ -283,3 +285,33 @@ tl.to("#h", { x: -120, y: -40, duration: 1 }, 0);`; expect(disabled).not.toContain("motionPath"); }); }); + +// ── #7 — updating ease on a keyframe tween routes to easeEach, not top-level ── + +describe("#7 — ease update on a keyframe tween targets keyframes.easeEach", () => { + const KF = `var tl = gsap.timeline({ paused: true }); +tl.to(".a", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, duration: 1, ease: "none" }, 0);`; + + it("writes easeEach (per-keyframe), not a no-op top-level ease", () => { + const id = parseGsapScriptAcornForWrite(KF)?.located[0]?.id ?? ""; + const out = updateAnimationInScript(KF, id, { ease: "power2.inOut" }); + expect(out).toContain('easeEach: "power2.inOut"'); + // The original top-level `ease: "none"` is untouched (no second top-level ease). + expect((out.match(/ease: "power2.inOut"/g) ?? []).length).toBe(0); + }); +}); + +// ── #8 — convertToKeyframes preserves builtin vars like `delay` ── + +describe("#8 — convertToKeyframes keeps delay (was dropped, shifting start time)", () => { + const DELAY = `var tl = gsap.timeline({ paused: true }); +tl.to(".a", { x: 100, duration: 1, delay: 0.3 }, 0);`; + + it("preserves delay on the converted vars object", () => { + const id = parseGsapScriptAcornForWrite(DELAY)?.located[0]?.id ?? ""; + const out = convertToKeyframesFromScript(DELAY, id); + expect(out).toContain("keyframes:"); + expect(out).toContain("delay: 0.3"); // was lost → tween started 0.3s early + expect(out).toContain("duration: 1"); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 88a0cedcbe..60006b7c3b 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -90,6 +90,12 @@ function findPropertyNode(varsArgNode: any, key: string): any | undefined { return undefined; } +/** The `keyframes` property's ObjectExpression value, or null when not a keyframe tween. */ +function keyframesObjectNode(varsNode: any): any | null { + const kfProp = findPropertyNode(varsNode, "keyframes"); + return kfProp?.value?.type === "ObjectExpression" ? kfProp.value : null; +} + function findEnclosingExpressionStatement(ancestors: any[]): any | null { for (let i = ancestors.length - 2; i >= 0; i--) { if (ancestors[i]?.type === "ExpressionStatement") return ancestors[i]; @@ -315,7 +321,12 @@ export function updateAnimationInScript( upsertProp(ms, call.varsArg, "duration", updates.duration); } if (updates.ease !== undefined) { - upsertProp(ms, call.varsArg, "ease", updates.ease); + // For a keyframe tween, easing lives at keyframes.easeEach (per-keyframe), + // not a top-level ease. Writing top-level ease would leave the per-keyframe + // easing unchanged — the user's edit would silently do nothing. + const kfNode = keyframesObjectNode(call.varsArg); + if (kfNode) upsertProp(ms, kfNode, "easeEach", updates.ease); + else upsertProp(ms, call.varsArg, "ease", updates.ease); } if (updates.extras) { for (const [key, value] of Object.entries(updates.extras)) { @@ -1055,18 +1066,18 @@ function buildKeyframesVarsCode( animation: GsapAnimation, fromProps: Record, toProps: Record, + varsNode: any, + source: string, ): string { const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); const easeEntry = animation.ease ? `, easeEach: ${JSON.stringify(animation.ease)}` : ""; const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} }${easeEntry} }`; - const parts: string[] = [`keyframes: ${kfCode}`]; - if (animation.duration !== undefined) parts.push(`duration: ${valueToCode(animation.duration)}`); + // Preserve every non-editable key (duration/delay/callbacks/stagger/yoyo/…) + // verbatim from source — rebuilding from the animation object alone dropped + // `delay` (not a GsapAnimation field), shifting the tween's start time. + const parts: string[] = [`keyframes: ${kfCode}`, ...preservedVarsEntries(varsNode, source)]; if (animation.ease) parts.push(`ease: "none"`); - for (const [k, v] of Object.entries(animation.extras ?? {})) { - if (typeof v === "number" || typeof v === "string") - parts.push(`${safeKey(k)}: ${valueToCode(v)}`); - } return `{ ${parts.join(", ")} }`; } @@ -1096,7 +1107,11 @@ export function convertToKeyframesFromScript( if (call.method === "fromTo" && call.fromArg) { ms.remove(call.fromArg.start, call.varsArg.start); } - overwriteVarsArg(ms, call, buildKeyframesVarsCode(animation, fromProps, toProps)); + overwriteVarsArg( + ms, + call, + buildKeyframesVarsCode(animation, fromProps, toProps, call.varsArg, script), + ); return ms.toString(); } @@ -1363,10 +1378,54 @@ export function splitIntoPropertyGroupsFromScript( // ── Label write ops ─────────────────────────────────────────────────────────── +/** True when `expr` is `tl.(…)` rooted at the timeline var. */ +function isTimelineMethodCall(expr: any, timelineVar: string, method: string): boolean { + return ( + expr?.type === "CallExpression" && + expr.callee?.type === "MemberExpression" && + isTimelineRooted(expr.callee.object, timelineVar) && + expr.callee.property?.name === method + ); +} + +/** True when `expr` is `tl.addLabel("", …)` rooted at the timeline var. */ +function isAddLabelCall(expr: any, timelineVar: string, name: string): boolean { + const firstArg = expr?.arguments?.[0]; + return ( + isTimelineMethodCall(expr, timelineVar, "addLabel") && + firstArg?.type === "Literal" && + firstArg.value === name + ); +} + +/** Every `tl.addLabel("", …)` ExpressionStatement in the script. */ +function findLabelStatements(parsed: ParsedGsapAcornForWrite, name: string): any[] { + const targets: any[] = []; + acornWalk.simple(parsed.ast, { + ExpressionStatement(node: any) { + if (isAddLabelCall(node.expression, parsed.timelineVar, name)) targets.push(node); + }, + }); + return targets; +} + export function addLabelToScript(script: string, name: string, position: number): string { const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return script; + // If the label already exists, MOVE it (overwrite its position) rather than + // appending a duplicate. Two same-named addLabel statements make removeLabel + // over-remove — it deletes every match, including a pre-existing label the + // user never touched. + const existing = findLabelStatements(parsed, name)[0]; + if (existing) { + const ms = new MagicString(script); + const posArg = existing.expression.arguments?.[1]; + if (posArg) ms.overwrite(posArg.start, posArg.end, valueToCode(position)); + else ms.appendLeft(existing.expression.end - 1, `, ${valueToCode(position)}`); + return ms.toString(); + } + const insertionPoint = findInsertionPoint(parsed); if (insertionPoint === null) return script; @@ -1380,24 +1439,7 @@ export function removeLabelFromScript(script: string, name: string): string { const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return script; - const targets: any[] = []; - acornWalk.simple(parsed.ast, { - // fallow-ignore-next-line complexity - ExpressionStatement(node: any) { - const expr = node.expression; - if ( - expr?.type === "CallExpression" && - expr.callee?.type === "MemberExpression" && - isTimelineRooted(expr.callee.object, parsed.timelineVar) && - expr.callee.property?.name === "addLabel" && - expr.arguments?.[0]?.type === "Literal" && - expr.arguments[0].value === name - ) { - targets.push(node); - } - }, - }); - + const targets = findLabelStatements(parsed, name); if (!targets.length) return script; const ms = new MagicString(script); diff --git a/packages/core/src/parsers/gsapWriterParity.corpus.test.ts b/packages/core/src/parsers/gsapWriterParity.corpus.test.ts index 8270056ba0..cb272c9f10 100644 --- a/packages/core/src/parsers/gsapWriterParity.corpus.test.ts +++ b/packages/core/src/parsers/gsapWriterParity.corpus.test.ts @@ -616,17 +616,19 @@ describe("correctness — addLabelToScript / removeLabelFromScript", () => { expect(removeLabelFromScript(SYN_SINGLE, "nope")).toBe(SYN_SINGLE); }); - it("adding the same label twice yields two addLabel calls (no dedup contract)", () => { + it("adding the same label twice MOVES it instead of duplicating (dedup contract)", () => { + // A second addLabel for an existing name must not append a duplicate — + // duplicates make removeLabel over-remove. It moves the label's position. const once = addLabelToScript(SYN_SINGLE, "mid", 1.0); const twice = addLabelToScript(once, "mid", 2.0); - expect(labelCallCount(twice, "mid")).toBe(2); + expect(labelCallCount(twice, "mid")).toBe(1); + expect(twice).toContain('tl.addLabel("mid", 2)'); }); - it("removeLabel deletes ALL matching addLabel calls for the name", () => { - const once = addLabelToScript(SYN_SINGLE, "mid", 1.0); - const twice = addLabelToScript(once, "mid", 2.0); - const cleared = removeLabelFromScript(twice, "mid"); - expect(labelCallCount(cleared, "mid")).toBe(0); + it("removeLabel deletes ALL matching addLabel calls for the name (hand-authored dups)", () => { + const dup = `var tl = gsap.timeline({ paused: true });\ntl.addLabel("mid", 1);\ntl.addLabel("mid", 2);\nwindow.__timelines["t"] = tl;`; + expect(labelCallCount(dup, "mid")).toBe(2); + expect(labelCallCount(removeLabelFromScript(dup, "mid"), "mid")).toBe(0); }); it("the added label is observable by the parser when a tween references it", () => { From 2dcfd3a93dc800259ad7e8f8d308e8ed6954418f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 15:40:32 -0700 Subject: [PATCH 41/43] fix(sdk): handleSetTiming #domId + data-duration sync; validateOp resolves ids + arc/selector (R3 #6/#13, CF2 #15/#16) CF2 #15: handleSetTiming re-synced GSAP tweens only when the selector matched the element's hf-id. The common #domId-targeted tween (authored by the Studio panel) never matched, so moving/resizing a clip via the SDK timing path left its animations unsynced. Now match the tween selector against the DOM id too. CF2 #16: handleSetTiming read/wrote only data-end. Clips authored with data-duration (what the runtime prefers) got a fresh data-end beside a stale data-duration (no playback change) and oldDuration=null collapsed the GSAP duration-scale ratio to 1. Now read duration preferring data-duration, and write back to whichever attribute the clip uses (timingPath gains a "duration" field). R3 #13b: deleteAllForSelector compared selectors with strict === and missed the alternate quote style ([data-hf-id='x'] vs "x"); now quote-insensitive. R3 #6/#13a: validateOp now resolves the animationId for id-bearing GSAP ops (E_TARGET_NOT_FOUND instead of a misleading ok that no-ops at apply), and updateArcSegment validates the arc is enabled + the segment index is in range. Tests: #domId move sync, data-duration resize + scale, quote-insensitive delete, unresolved-id rejection, arc-segment preconditions. Updated the loose-can() test. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/sdk/src/engine/mutate.gsap.test.ts | 95 +++++++++++++++++- packages/sdk/src/engine/mutate.test.ts | 4 +- packages/sdk/src/engine/mutate.ts | 106 ++++++++++++++++++-- packages/sdk/src/engine/patches.ts | 2 +- 4 files changed, 192 insertions(+), 15 deletions(-) diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index 0b5382dc37..416214a19a 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -90,8 +90,16 @@ describe("validateOp with GSAP script", () => { ).toBe(true); }); - it("removeGsapTween → ok:true", () => { - expect(validateOp(fresh(), { type: "removeGsapTween", animationId: "some-id" }).ok).toBe(true); + it("removeGsapTween → ok:true for a resolvable id", () => { + expect(validateOp(fresh(), { type: "removeGsapTween", animationId: TWEEN_ANIM_ID }).ok).toBe( + true, + ); + }); + + it("removeGsapTween → E_TARGET_NOT_FOUND for an unresolved id (can/apply agreement)", () => { + const r = validateOp(fresh(), { type: "removeGsapTween", animationId: "no-such-id" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.code).toBe("E_TARGET_NOT_FOUND"); }); it("addLabel → ok:true", () => { @@ -922,3 +930,86 @@ describe("removeArcPath", () => { expect(getScript(parsed)).not.toContain("motionPath"); }); }); + +// ─── R3 #6 — validateOp rejects unappliable arc-segment edits ───────────────── + +describe("validateOp updateArcSegment (R3 #6)", () => { + it("E_ARC_NOT_ENABLED when the tween has no enabled arc path", () => { + const r = validateOp(freshArc(), { + type: "updateArcSegment", + animationId: ARC_ANIM_ID, + segmentIndex: 0, + update: { curviness: 2 }, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.code).toBe("E_ARC_NOT_ENABLED"); + }); + + it("E_INVALID_ARGS when the segment index is out of range", () => { + const parsed = freshArc(); + enableArc(parsed); + const r = validateOp(parsed, { + type: "updateArcSegment", + animationId: ARC_ANIM_ID, + segmentIndex: 9, + update: { curviness: 2 }, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS"); + }); +}); + +// ─── R3 #13b — deleteAllForSelector matches across quote styles ──────────────── + +describe("deleteAllForSelector quote-insensitive match (R3 #13b)", () => { + it("removes a tween authored with double quotes when given a single-quoted selector", () => { + const html = `
+
+ +
`; + const parsed = parseMutable(html); + const result = applyOp(parsed, { + type: "deleteAllForSelector", + selector: `[data-hf-id='hf-box']`, + }); + expect(result.forward.length).toBeGreaterThan(0); + expect(getScript(parsed)).not.toContain("tl.to("); + }); +}); + +// ─── CF2 #15/#16 — handleSetTiming syncs #domId tweens + resizes data-duration ─ + +describe("handleSetTiming GSAP sync (CF2 #15/#16)", () => { + function timingDoc(attrs: string, tween: string) { + return parseMutable( + `
+
+ +
`, + ); + } + + it("#15: a #domId-targeted tween shifts when the clip moves", () => { + const parsed = timingDoc( + `data-start="2" data-end="5"`, + `tl.to("#box", { x: 100, duration: 1 }, 2);`, + ); + applyOp(parsed, { type: "setTiming", target: "hf-box", start: 5 }); + // position remapped 2 → 5 (delta +3); the bug left it at 2. + expect(getScript(parsed)).toMatch(/tl\.to\("#box",[^)]*\}, 5\)/); + }); + + it("#16: a data-duration clip updates data-duration and scales its tween", () => { + const parsed = timingDoc( + `data-start="2" data-duration="4"`, + `tl.to("#box", { x: 100, duration: 4 }, 2);`, + ); + applyOp(parsed, { type: "setTiming", target: "hf-box", duration: 8 }); + const el = parsed.document.querySelector('[data-hf-id="hf-box"]'); + // data-duration updated (not a stale value beside a fresh data-end). + expect(el?.getAttribute("data-duration")).toBe("8"); + expect(el?.getAttribute("data-end")).toBeNull(); + // tween duration scaled 4 → 8 (ratio 2). + expect(getScript(parsed)).toContain("duration: 8"); + }); +}); diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts index c087fe8ab3..03b8f209de 100644 --- a/packages/sdk/src/engine/mutate.test.ts +++ b/packages/sdk/src/engine/mutate.test.ts @@ -499,7 +499,7 @@ describe("Phase 3b ops", () => { ); const r = validateOp(parsed, { type: "unrollDynamicAnimations", - animationId: "tw-1", + animationId: "#x-to-0-position", elements: [], }); expect(r.ok).toBe(false); @@ -513,7 +513,7 @@ describe("Phase 3b ops", () => { ); const r = validateOp(parsed, { type: "materializeKeyframes", - animationId: "tw-1", + animationId: "#x-to-0-position", keyframes: [], }); expect(r.ok).toBe(false); diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index e0d29a552c..3667752c7e 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -395,11 +395,22 @@ function handleSetTiming( const oldStartStr = el.getAttribute("data-start"); const oldEndStr = el.getAttribute("data-end"); + const oldDurationStr = el.getAttribute("data-duration"); const oldTrackStr = el.getAttribute("data-track-index"); const oldStart = oldStartStr !== null ? parseFloat(oldStartStr) : null; const oldEnd = oldEndStr !== null ? parseFloat(oldEndStr) : null; - const oldDuration = oldStart !== null && oldEnd !== null ? oldEnd - oldStart : null; + const oldDurationAttr = oldDurationStr !== null ? parseFloat(oldDurationStr) : null; + // Prefer an explicit data-duration — the attribute clips are authored with and + // the runtime reads — falling back to data-end − data-start. Reading only + // data-end left oldDuration null for duration-authored clips, collapsing the + // GSAP duration-scale ratio to 1 and scaling nothing. + const oldDuration = + oldDurationAttr !== null + ? oldDurationAttr + : oldStart !== null && oldEnd !== null + ? oldEnd - oldStart + : null; const oldTrack = oldTrackStr !== null ? parseInt(oldTrackStr, 10) : null; const newStart = timing.start ?? oldStart; @@ -413,7 +424,20 @@ function handleSetTiming( el.setAttribute("data-start", String(newStart)); } - if ( + // Write to whichever timing attribute the clip actually uses. A data-duration + // clip updates data-duration only on a real resize (duration is invariant + // under a move); a data-end clip updates data-end whenever start or duration + // changes (end = start + duration). Writing a fresh data-end beside a stale + // data-duration had no playback effect. + if (oldDurationStr !== null) { + if (timing.duration !== undefined && newDuration !== null) { + const path = timingPath(id, "duration"); + const p = scalarChange(path, oldDurationAttr, newDuration); + result.forward.push(p.forward); + result.inverse.push(p.inverse); + el.setAttribute("data-duration", String(newDuration)); + } + } else if ( (timing.duration !== undefined || timing.start !== undefined) && newStart !== null && newDuration !== null @@ -440,10 +464,12 @@ function handleSetTiming( // Sync GSAP tween positions: the GSAP script is the source of truth at play time — // the timeline rebuilds from it on every seek. Without this, DOM attribute edits // have zero playback effect; the script's position/duration silently overrides them. - // Match against the resolved element's own data-hf-id (the canonical form - // tweens are stored under) so a comp-root target ("sub-1") whose tween lives - // at [data-hf-id="hf-host"] still syncs. - const matchId = el.getAttribute("data-hf-id") ?? id; + // Match against BOTH the element's data-hf-id (the canonical form) AND its DOM + // id: the Studio GSAP panel / ensureElementAddressable author tweens as + // `#domId`, which selectorMatchesId(hfId) never matched — so moving/resizing + // those clips left their tweens unsynced. + const matchHfId = el.getAttribute("data-hf-id") ?? id; + const matchDomId = el.getAttribute("id"); if (parsedGsap && currentScript && oldStart !== null) { // Per-tween shift/scale (mirrors shiftGsapPositions/scaleGsapPositions): a // multi-tween stagger maps each tween's own intra-clip position by the @@ -458,7 +484,10 @@ function handleSetTiming( : 1; const remapStart = startChanged && newStart !== null ? newStart : oldStart; for (const { id: animId, animation } of parsedGsap.located) { - if (!selectorMatchesId(animation.targetSelector, matchId)) continue; + const matches = + selectorMatchesId(animation.targetSelector, matchHfId) || + (matchDomId !== null && selectorMatchesId(animation.targetSelector, matchDomId)); + if (!matches) continue; if (typeof animation.position !== "number") continue; const updates: Partial = {}; if (startChanged || durChanged) { @@ -910,7 +939,13 @@ function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): M if (!script) return EMPTY; const parsedForWrite = parseGsapScriptAcornForWrite(script); if (!parsedForWrite) return EMPTY; - const matching = parsedForWrite.located.filter((l) => l.animation.targetSelector === selector); + // Compare quote-insensitively: [data-hf-id='x'] and [data-hf-id="x"] are the + // same selector. A strict === missed the alternate quote style and matched + // nothing while can() reported ok. + const wanted = selector.replace(/'/g, '"'); + const matching = parsedForWrite.located.filter( + (l) => l.animation.targetSelector.replace(/'/g, '"') === wanted, + ); if (matching.length === 0) return EMPTY; let newScript = script; for (const m of [...matching].reverse()) { @@ -1058,6 +1093,49 @@ function gsapScriptMissing(parsed: ParsedDocument): CanResult | null { : null; } +/** The located GSAP animation for `animationId`, or undefined. */ +function locateGsapAnimation(parsed: ParsedDocument, animationId: string) { + const script = getGsapScript(parsed.document); + if (!script) return undefined; + return parseGsapScriptAcornForWrite(script)?.located.find((l) => l.id === animationId); +} + +/** + * E_TARGET_NOT_FOUND CanResult when no GSAP animation resolves to `animationId`, + * else null. Without this, can() returned ok for stale/positional ids that then + * no-op'd at apply — the caller believed the edit would land. + */ +function gsapAnimationMissing(parsed: ParsedDocument, animationId: string): CanResult | null { + if (getGsapScript(parsed.document) === null) return null; // reported by gsapScriptMissing + return locateGsapAnimation(parsed, animationId) + ? null + : canErr( + "E_TARGET_NOT_FOUND", + `No GSAP animation found with id "${animationId}".`, + "Animation ids are positional and shift after edits — re-read them from comp before dispatching.", + ); +} + +/** Validate updateArcSegment: the tween must have an enabled arc with that segment. */ +function validateArcSegment( + parsed: ParsedDocument, + op: Extract, +): CanResult { + const arc = locateGsapAnimation(parsed, op.animationId)?.animation.arcPath; + if (!arc?.enabled) + return canErr( + "E_ARC_NOT_ENABLED", + `Animation "${op.animationId}" has no enabled arc path.`, + "Call setArcPath({ enabled: true }) before updating a segment.", + ); + if (op.segmentIndex < 0 || op.segmentIndex >= arc.segments.length) + return canErr( + "E_INVALID_ARGS", + `Segment index ${op.segmentIndex} is out of range (0..${arc.segments.length - 1}).`, + ); + return CAN_OK; +} + /** Dry-run validation — returns CanResult for the given op against current document state. */ // fallow-ignore-next-line complexity export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { @@ -1120,16 +1198,23 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "removeAllKeyframes": case "convertToKeyframes": case "splitIntoPropertyGroups": - case "splitAnimations": case "setArcPath": - case "updateArcSegment": case "removeArcPath": + return gsapScriptMissing(parsed) ?? gsapAnimationMissing(parsed, op.animationId) ?? CAN_OK; + case "updateArcSegment": + return ( + gsapScriptMissing(parsed) ?? + gsapAnimationMissing(parsed, op.animationId) ?? + validateArcSegment(parsed, op) + ); + case "splitAnimations": case "deleteAllForSelector": case "removeLabel": return gsapScriptMissing(parsed) ?? CAN_OK; case "unrollDynamicAnimations": return ( gsapScriptMissing(parsed) ?? + gsapAnimationMissing(parsed, op.animationId) ?? (op.elements.length === 0 ? canErr( "E_INVALID_ARGS", @@ -1141,6 +1226,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "materializeKeyframes": return ( gsapScriptMissing(parsed) ?? + gsapAnimationMissing(parsed, op.animationId) ?? (op.keyframes.length === 0 ? canErr( "E_INVALID_ARGS", diff --git a/packages/sdk/src/engine/patches.ts b/packages/sdk/src/engine/patches.ts index a1776d9287..02c0b7c47b 100644 --- a/packages/sdk/src/engine/patches.ts +++ b/packages/sdk/src/engine/patches.ts @@ -59,7 +59,7 @@ export function attrPath(id: string, name: string): string { return `/elements/${escapeIdForPath(id)}/attributes/${escapedName}`; } -export function timingPath(id: string, field: "start" | "end" | "trackIndex"): string { +export function timingPath(id: string, field: "start" | "end" | "duration" | "trackIndex"): string { return `/elements/${escapeIdForPath(id)}/timing/${field}`; } From 4f1eb96067f42f5f08ce6bd0361e10c376ccc05d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 15:43:56 -0700 Subject: [PATCH 42/43] refactor(core,sdk): name the acorn-node type alias; keyToPath round-trips timing.duration (R3 #14) - gsapWriterAcorn: replace the bare `: any` AST-node annotations with the named `type Node = any` alias, matching the established convention in gsapParserAcorn.ts / gsapInline.ts ("acorn ESTree nodes are structurally untyped"). Documents intent and is greppable; type-identical (zero runtime change). A full ESTree typing is a deliberate architecture decision the codebase has not taken and is out of scope here. - patches: keyToPath/timingPath now include the "duration" timing field added for the data-duration resize fix, so a timing.duration override round-trips on T3 replay instead of being dropped. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/parsers/gsapWriterAcorn.ts | 162 ++++++++++--------- packages/sdk/src/engine/patches.ts | 7 +- 2 files changed, 89 insertions(+), 80 deletions(-) diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 60006b7c3b..cfd459cc77 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -28,6 +28,10 @@ import type { PropertyGroupName } from "./gsapConstants.js"; import type { SplitAnimationsOptions, SplitAnimationsResult } from "./gsapParser.js"; import * as acornWalk from "acorn-walk"; +// acorn ESTree nodes are structurally untyped here; mirror gsapParserAcorn.ts / +// gsapInline.ts rather than re-deriving the full ESTree union for every access. +type Node = any; + // ── Code generation helpers ────────────────────────────────────────────────── // Local serializer for the tween-statement path, which may carry boolean/object @@ -73,15 +77,15 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit= 0; i--) { if (ancestors[i]?.type === "ExpressionStatement") return ancestors[i]; } @@ -104,11 +108,11 @@ function findEnclosingExpressionStatement(ancestors: any[]): any | null { } /** Find the VariableDeclaration statement for `tl = gsap.timeline(...)`. */ -function findTimelineDeclarationStatement(ast: any, timelineVar: string): any | null { - let found: any = null; +function findTimelineDeclarationStatement(ast: Node, timelineVar: string): Node | null { + let found: Node = null; acornWalk.simple(ast, { // fallow-ignore-next-line complexity - VariableDeclaration(node: any) { + VariableDeclaration(node: Node) { if (found) return; for (const decl of node.declarations ?? []) { if ( @@ -132,7 +136,7 @@ function findTimelineDeclarationStatement(ast: any, timelineVar: string): any | * Remove a property from a properties array, handling its comma. * `editableProps` must be the isObjectProperty-filtered subset in source order. */ -function removeProp(ms: MagicString, propNode: any, editableProps: any[]): void { +function removeProp(ms: MagicString, propNode: Node, editableProps: Node[]): void { const idx = editableProps.indexOf(propNode); if (idx === -1) return; if (editableProps.length === 1) { @@ -162,7 +166,7 @@ function overwriteVarsArg(ms: MagicString, call: TweenCallInfo, objCode: string) * Update a property value if it exists, or append a new key: val before the * closing `}`. Call with the full ObjectExpression node. */ -function upsertProp(ms: MagicString, objNode: any, key: string, value: unknown): void { +function upsertProp(ms: MagicString, objNode: Node, key: string, value: unknown): void { if (objNode?.type !== "ObjectExpression") return; const existing = findPropertyNode(objNode, key); if (existing) { @@ -214,7 +218,7 @@ function isEditableVarKey(key: string): boolean { * the entries plus the set of keys it kept, so callers can append new keys. */ function preservedEntries( - objNode: any, + objNode: Node, source: string, drop: (key: string) => boolean, overrides: Record, @@ -246,7 +250,7 @@ function preservedEntries( */ function reconcileEditableProps( ms: MagicString, - objNode: any, + objNode: Node, source: string, newProps: Record, nonEditableOverrides?: Record, @@ -266,7 +270,7 @@ function reconcileEditableProps( // ── Insertion helpers ───────────────────────────────────────────────────────── /** Traverse callee.object chain to check if a call ultimately roots at timelineVar. */ -function isTimelineRooted(node: any, timelineVar: string): boolean { +function isTimelineRooted(node: Node, timelineVar: string): boolean { if (node?.type === "Identifier") return node.name === timelineVar; if (node?.type === "CallExpression") return isTimelineRooted(node.callee?.object, timelineVar); return false; @@ -539,7 +543,7 @@ function conversionEndpoints(animation: GsapAnimation): { } /** Collect preserved (non-editable) `key: value` entries from the original vars node. */ -function preservedVarsEntries(varsNode: any, source: string): string[] { +function preservedVarsEntries(varsNode: Node, source: string): string[] { const entries: string[] = []; if (varsNode?.type !== "ObjectExpression") return entries; for (const prop of varsNode.properties ?? []) { @@ -552,7 +556,7 @@ function preservedVarsEntries(varsNode: any, source: string): string[] { } /** Build the rebuilt vars-object code for a converted flat tween. */ -function buildConvertedVarsCode(animation: GsapAnimation, varsNode: any, source: string): string { +function buildConvertedVarsCode(animation: GsapAnimation, varsNode: Node, source: string): string { const { fromProps, toProps } = conversionEndpoints(animation); const easeEach = animation.ease; const easeEachEntry = easeEach ? `, easeEach: ${JSON.stringify(easeEach)}` : ""; @@ -566,8 +570,8 @@ function buildConvertedVarsCode(animation: GsapAnimation, varsNode: any, source: function convertMethodToTo( ms: MagicString, animation: GsapAnimation, - call: any, - varsNode: any, + call: Node, + varsNode: Node, ): void { if (animation.method !== "from" && animation.method !== "fromTo") return; const calleeProp = call.node.callee?.property; @@ -576,7 +580,7 @@ function convertMethodToTo( if (animation.method === "fromTo" && call.fromArg) ms.remove(call.fromArg.start, varsNode.start); } -function convertFlatTweenToKeyframes(script: string, target: any): string { +function convertFlatTweenToKeyframes(script: string, target: Node): string { const animation: GsapAnimation = target.animation; if (animation.keyframes || animation.method === "set") return script; const call = target.call; @@ -618,8 +622,8 @@ function recordToCode(record: Record): string { } /** Percentage-keyed property nodes of a keyframes ObjectExpression, in source order. */ -function percentagePropsOf(kfNode: any): any[] { - return (kfNode.properties ?? []).filter((p: any) => { +function percentagePropsOf(kfNode: Node): Node[] { + return (kfNode.properties ?? []).filter((p: Node) => { if (!isObjectProperty(p)) return false; const key = propKeyName(p); return typeof key === "string" && PERCENTAGE_KEY_RE.test(key); @@ -630,7 +634,7 @@ const LITERAL_NODE_TYPES = new Set(["Literal", "NumericLiteral", "StringLiteral" /** Read one value node: a number/string literal, a negative number, or raw source. */ // fallow-ignore-next-line complexity -function readValueNode(v: any, source: string): number | string { +function readValueNode(v: Node, source: string): number | string { if ( LITERAL_NODE_TYPES.has(v?.type) && (typeof v.value === "number" || typeof v.value === "string") @@ -653,7 +657,7 @@ function readValueNode(v: any, source: string): number | string { * preserved as `__raw:` so serializeValue round-trips it verbatim. * Keyframe values are literals in practice, so the raw fallback is rarely hit. */ -function valueNodeToRecord(valueNode: any, source: string): Record { +function valueNodeToRecord(valueNode: Node, source: string): Record { const record: Record = {}; if (valueNode?.type !== "ObjectExpression") return record; for (const prop of valueNode.properties ?? []) { @@ -678,7 +682,7 @@ function recordHasAuto(record: Record): boolean { * records (never a separate splice). */ function autoEndpointOverwrites( - kfNode: any, + kfNode: Node, source: string, percentage: number, properties: Record, @@ -687,7 +691,7 @@ function autoEndpointOverwrites( if (percentage <= 0 || percentage >= 100) return result; const pctProps = percentagePropsOf(kfNode); const allPcts = pctProps - .map((p: any) => percentageFromKey(propKeyName(p) ?? "")) + .map((p: Node) => percentageFromKey(propKeyName(p) ?? "")) .filter((n: number) => !Number.isNaN(n) && n !== percentage) .sort((a: number, b: number) => a - b); const leftNeighbor = allPcts.filter((p: number) => p < percentage).pop(); @@ -695,7 +699,7 @@ function autoEndpointOverwrites( for (const endPct of [0, 100]) { const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100; if (!isNeighbor) continue; - const endProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === endPct); + const endProp = pctProps.find((p: Node) => percentageFromKey(propKeyName(p) ?? "") === endPct); if (!endProp) continue; const rec = valueNodeToRecord(endProp.value, source); if (!recordHasAuto(rec)) continue; @@ -704,14 +708,14 @@ function autoEndpointOverwrites( return result; } -function findKfPropByPct(kfNode: any, percentage: number): { prop: any; idx: number } | null { +function findKfPropByPct(kfNode: Node, percentage: number): { prop: Node; idx: number } | null { // Match the CLOSEST keyframe within tolerance, not the first one within range. // Keyframes at e.g. 0/49/50/100 are all valid (the SDK dedups to a unique // match at TOLERANCE=0.001 upstream); picking the first-within-PCT_TOLERANCE=2 // would hit 49% when the caller meant 50%. Tie-break on the earliest index so // the choice stays deterministic. const props = kfNode.properties ?? []; - let best: { prop: any; idx: number } | null = null; + let best: { prop: Node; idx: number } | null = null; let bestDist = Number.POSITIVE_INFINITY; for (let i = 0; i < props.length; i++) { const prop = props[i]; @@ -759,7 +763,7 @@ export function updateKeyframeInScript( * ease when the op omits one); otherwise it's just the new props. */ function buildTargetRecord( - existing: { prop: any; idx: number } | null, + existing: { prop: Node; idx: number } | null, source: string, properties: Record, ease: string | undefined, @@ -785,7 +789,7 @@ function buildTargetRecord( * nothing changes (so the caller emits no overwrite for it). */ function backfilledSiblingRecord( - valueNode: any, + valueNode: Node, source: string, newPropKeys: string[], backfillDefaults: Record, @@ -806,7 +810,7 @@ function backfilledSiblingRecord( function locateWithKeyframes( script: string, animationId: string, -): { script: string; parsed: ParsedGsapAcornForWrite; target: any; kfNode: any } | null { +): { script: string; parsed: ParsedGsapAcornForWrite; target: Node; kfNode: Node } | null { const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return null; // Converting from()/fromTo() to to() rewrites the content-derived id; match @@ -825,7 +829,7 @@ function locateWithKeyframes( function ensureKeyframesNode( script: string, animationId: string, -): { script: string; parsed: ParsedGsapAcornForWrite; target: any; kfNode: any } | null { +): { script: string; parsed: ParsedGsapAcornForWrite; target: Node; kfNode: Node } | null { const direct = locateWithKeyframes(script, animationId); if (direct) return direct; @@ -843,11 +847,11 @@ function ensureKeyframesNode( * target keyframe and any node already being overwritten as an `_auto` endpoint. */ function collectBackfillOverwrites( - kfNode: any, + kfNode: Node, src: string, properties: Record, backfillDefaults: Record | undefined, - skip: { existingProp: any; endpoints: Map }, + skip: { existingProp: Node; endpoints: Map }, ): Map> { const result = new Map>(); if (!backfillDefaults) return result; @@ -914,13 +918,13 @@ export function addKeyframeToScript( /** Insert a brand-new `"pct%": {...}` property in sorted order. */ function insertNewKeyframe( ms: MagicString, - kfNode: any, + kfNode: Node, percentage: number, pctKey: string, valueCode: string, ): void { - const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p)); - let insertBeforeProp: any = null; + const allProps = (kfNode.properties ?? []).filter((p: Node) => isObjectProperty(p)); + let insertBeforeProp: Node = null; for (const prop of allProps) { const key = propKeyName(prop); if (typeof key === "string" && percentageFromKey(key) > percentage) { @@ -946,7 +950,7 @@ function insertNewKeyframe( */ function collapseKeyframesToFlat( ms: MagicString, - varsNode: any, + varsNode: Node, source: string, remainingRecord: Record, ): void { @@ -990,7 +994,7 @@ export function removeKeyframeFromScript( return ms.toString(); } - const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p)); + const allProps = (kfNode.properties ?? []).filter((p: Node) => isObjectProperty(p)); removeProp(ms, match.prop, allProps); return ms.toString(); } @@ -1010,7 +1014,7 @@ export function removePropertyFromAnimation( if (!objNode) return script; const propNode = findPropertyNode(objNode, property); if (!propNode) return script; - const allProps = (objNode.properties ?? []).filter((p: any) => isObjectProperty(p)); + const allProps = (objNode.properties ?? []).filter((p: Node) => isObjectProperty(p)); const ms = new MagicString(script); removeProp(ms, propNode, allProps); return ms.toString(); @@ -1066,7 +1070,7 @@ function buildKeyframesVarsCode( animation: GsapAnimation, fromProps: Record, toProps: Record, - varsNode: any, + varsNode: Node, source: string, ): string { const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); @@ -1187,7 +1191,7 @@ export function materializeKeyframesFromScript( const eachProp = findPropertyNode(call.varsArg, "easeEach"); if (eachProp) { - const allProps = (call.varsArg.properties ?? []).filter((p: any) => isObjectProperty(p)); + const allProps = (call.varsArg.properties ?? []).filter((p: Node) => isObjectProperty(p)); removeProp(ms, eachProp, allProps); } @@ -1379,7 +1383,7 @@ export function splitIntoPropertyGroupsFromScript( // ── Label write ops ─────────────────────────────────────────────────────────── /** True when `expr` is `tl.(…)` rooted at the timeline var. */ -function isTimelineMethodCall(expr: any, timelineVar: string, method: string): boolean { +function isTimelineMethodCall(expr: Node, timelineVar: string, method: string): boolean { return ( expr?.type === "CallExpression" && expr.callee?.type === "MemberExpression" && @@ -1389,7 +1393,7 @@ function isTimelineMethodCall(expr: any, timelineVar: string, method: string): b } /** True when `expr` is `tl.addLabel("", …)` rooted at the timeline var. */ -function isAddLabelCall(expr: any, timelineVar: string, name: string): boolean { +function isAddLabelCall(expr: Node, timelineVar: string, name: string): boolean { const firstArg = expr?.arguments?.[0]; return ( isTimelineMethodCall(expr, timelineVar, "addLabel") && @@ -1399,10 +1403,10 @@ function isAddLabelCall(expr: any, timelineVar: string, name: string): boolean { } /** Every `tl.addLabel("", …)` ExpressionStatement in the script. */ -function findLabelStatements(parsed: ParsedGsapAcornForWrite, name: string): any[] { - const targets: any[] = []; +function findLabelStatements(parsed: ParsedGsapAcornForWrite, name: string): Node[] { + const targets: Node[] = []; acornWalk.simple(parsed.ast, { - ExpressionStatement(node: any) { + ExpressionStatement(node: Node) { if (isAddLabelCall(node.expression, parsed.timelineVar, name)) targets.push(node); }, }); @@ -1457,10 +1461,10 @@ export function removeLabelFromScript(script: string, name: string): string { * Remove a set of properties from an ObjectExpression in a single pass. * Groups consecutive marked props into blocks to avoid overlapping remove ranges. */ -function removePropsByKey(ms: MagicString, objNode: any, keys: Set): void { +function removePropsByKey(ms: MagicString, objNode: Node, keys: Set): void { if (objNode?.type !== "ObjectExpression") return; const allProps = (objNode.properties ?? []).filter(isObjectProperty); - const marked = allProps.map((p: any) => keys.has(propKeyName(p) ?? "")); + const marked = allProps.map((p: Node) => keys.has(propKeyName(p) ?? "")); let i = 0; while (i < allProps.length) { if (!marked[i]) { @@ -1473,7 +1477,11 @@ function removePropsByKey(ms: MagicString, objNode: any, keys: Set): voi } } -function blockRemoveRange(allProps: any[], blockStart: number, blockEnd: number): [number, number] { +function blockRemoveRange( + allProps: Node[], + blockStart: number, + blockEnd: number, +): [number, number] { if (blockStart === 0 && blockEnd === allProps.length) return [allProps[0].start, allProps[allProps.length - 1].end]; if (blockStart === 0) return [allProps[0].start, allProps[blockEnd].start]; @@ -1481,11 +1489,11 @@ function blockRemoveRange(allProps: any[], blockStart: number, blockEnd: number) } // fallow-ignore-next-line complexity -function readLastWaypointXY(mpVal: any): { x: number | null; y: number | null } { +function readLastWaypointXY(mpVal: Node): { x: number | null; y: number | null } { if (mpVal?.type !== "ObjectExpression") return { x: null, y: null }; const pathProp = findPropertyNode(mpVal, "path"); if (pathProp?.value?.type !== "ArrayExpression") return { x: null, y: null }; - const elems: any[] = pathProp.value.elements ?? []; + const elems: Node[] = pathProp.value.elements ?? []; const last = elems[elems.length - 1]; if (last?.type !== "ObjectExpression") return { x: null, y: null }; return { @@ -1500,7 +1508,7 @@ function readLastWaypointXY(mpVal: any): { x: number | null; y: number | null } * UnaryExpression branch, negative waypoint coords (parsed as a UnaryExpression * with no `.value`) would be lost when disabling an arc path. */ -function readNumericLiteralNode(v: any): number | null { +function readNumericLiteralNode(v: Node): number | null { if (LITERAL_NODE_TYPES.has(v?.type) && typeof v.value === "number") return v.value; if ( v?.type === "UnaryExpression" && @@ -1530,7 +1538,7 @@ function disableArcPath(ms: MagicString, call: TweenCallInfo): boolean { return true; } -function stripXYFromKeyframes(ms: MagicString, kfPropNode: any): void { +function stripXYFromKeyframes(ms: MagicString, kfPropNode: Node): void { if (kfPropNode?.value?.type !== "ObjectExpression") return; const xyKeys = new Set(["x", "y"]); for (const pctProp of (kfPropNode.value.properties ?? []).filter(isObjectProperty)) { @@ -1566,7 +1574,7 @@ function enableArcPath( // remove-range that ends at the same offset when x/y are the only props — // MagicString then discards the append and the output loses everything. const editable = (vars.properties ?? []).filter(isObjectProperty); - const survivesRemoval = editable.some((p: any) => { + const survivesRemoval = editable.some((p: Node) => { const k = propKeyName(p); return k !== "x" && k !== "y"; }); @@ -1889,7 +1897,7 @@ export function splitAnimationsInScript( // ── Unroll dynamic animations ──────────────────────────────────────────────── -function isLoopNode(node: any): boolean { +function isLoopNode(node: Node): boolean { const t = node?.type; return ( t === "ForStatement" || @@ -1899,7 +1907,7 @@ function isLoopNode(node: any): boolean { ); } -function isForEachStatement(node: any): boolean { +function isForEachStatement(node: Node): boolean { return ( node?.type === "ExpressionStatement" && node.expression?.type === "CallExpression" && @@ -1908,7 +1916,7 @@ function isForEachStatement(node: any): boolean { } /** The nearest enclosing loop / forEach AST node (not just its byte range). */ -function findEnclosingLoopNode(ancestors: any[]): any | null { +function findEnclosingLoopNode(ancestors: Node[]): Node | null { for (let i = ancestors.length - 2; i >= 0; i--) { const node = ancestors[i]; if (isLoopNode(node) || isForEachStatement(node)) return node; @@ -1917,8 +1925,8 @@ function findEnclosingLoopNode(ancestors: any[]): any | null { } /** Statements making up a loop's body block, or null when not a simple block. */ -function loopBodyStatements(loopNode: any): any[] | null { - let body: any; +function loopBodyStatements(loopNode: Node): Node[] | null { + let body: Node; if (loopNode?.type === "ExpressionStatement") { // forEach(cb): body is the callback's block. const cb = loopNode.expression?.arguments?.[0]; @@ -1927,11 +1935,11 @@ function loopBodyStatements(loopNode: any): any[] | null { body = loopNode?.body; } if (body?.type !== "BlockStatement") return null; - return (body.body ?? []).filter((s: any) => s?.type === "ExpressionStatement"); + return (body.body ?? []).filter((s: Node) => s?.type === "ExpressionStatement"); } /** The loop's index identifier name (`for (let i …)`), used for per-iteration substitution. */ -function loopIndexVarName(loopNode: any): string | null { +function loopIndexVarName(loopNode: Node): string | null { if (loopNode?.type === "ForStatement") { const decl = loopNode.init?.declarations?.[0]; return typeof decl?.id?.name === "string" ? decl.id.name : null; @@ -1949,7 +1957,7 @@ function loopIndexVarName(loopNode: any): string | null { // An identifier in "binding position" is a name, not a value reference: a // non-computed member property (`obj.i`) or object-literal key (`{ i: … }`). // Those must NOT be substituted with the iteration index. -function isIndexBindingPosition(node: any, parent: any): boolean { +function isIndexBindingPosition(node: Node, parent: Node): boolean { if (parent?.type === "MemberExpression") return parent.property === node && !parent.computed; if (parent?.type === "Property" || parent?.type === "ObjectProperty") { return parent.key === node && !parent.computed; @@ -1957,12 +1965,12 @@ function isIndexBindingPosition(node: any, parent: any): boolean { return false; } -function substituteLoopIndex(stmt: any, indexVar: string, idx: number, script: string): string { +function substituteLoopIndex(stmt: Node, indexVar: string, idx: number, script: string): string { const base = stmt.start as number; const src = script.slice(base, stmt.end as number); const ranges: Array<[number, number]> = []; acornWalk.ancestor(stmt, { - Identifier(node: any, _state: unknown, ancestors: any[]) { + Identifier(node: Node, _state: unknown, ancestors: Node[]) { if (node.name !== indexVar) return; if (isIndexBindingPosition(node, ancestors[ancestors.length - 2])) return; ranges.push([(node.start as number) - base, (node.end as number) - base]); @@ -2021,7 +2029,7 @@ function buildUnrollCallForElement( const REFUSE_UNROLL = Symbol("refuse-unroll"); /** Every statement in a loop's body block (unfiltered), or [] when not a block. */ -function loopBodyRawStatements(loopNode: any): any[] { +function loopBodyRawStatements(loopNode: Node): Node[] { const body = loopNode?.type === "ExpressionStatement" ? loopNode.expression?.arguments?.[0]?.body @@ -2030,20 +2038,20 @@ function loopBodyRawStatements(loopNode: any): any[] { } /** A node that re-binds `indexVar`: a re-declaration or a function param. */ -function rebindsIndex(node: any, indexVar: string): boolean { +function rebindsIndex(node: Node, indexVar: string): boolean { if (node.type === "VariableDeclarator") return node.id?.name === indexVar; if ( node.type === "FunctionExpression" || node.type === "FunctionDeclaration" || node.type === "ArrowFunctionExpression" ) { - return (node.params ?? []).some((p: any) => p?.name === indexVar); + return (node.params ?? []).some((p: Node) => p?.name === indexVar); } return false; } /** Object shorthand `{ i }` — substituting the value would yield invalid `{ 0 }`. */ -function isShorthandIndexUse(node: any, indexVar: string): boolean { +function isShorthandIndexUse(node: Node, indexVar: string): boolean { return ( (node.type === "Property" || node.type === "ObjectProperty") && node.shorthand === true && @@ -2058,9 +2066,9 @@ function isShorthandIndexUse(node: any, indexVar: string): boolean { * `{ 0 }`). substituteLoopIndex has no scope analysis, so in these cases it * would emit broken or wrong code — the unroll must refuse instead. */ -function hasUnsafeLoopIndexUse(stmt: any, indexVar: string): boolean { +function hasUnsafeLoopIndexUse(stmt: Node, indexVar: string): boolean { let unsafe = false; - acornWalk.full(stmt, (node: any) => { + acornWalk.full(stmt, (node: Node) => { if (!unsafe && (isShorthandIndexUse(node, indexVar) || rebindsIndex(node, indexVar))) { unsafe = true; } @@ -2070,9 +2078,9 @@ function hasUnsafeLoopIndexUse(stmt: any, indexVar: string): boolean { /** How to handle the loop body's non-target siblings when unrolling. */ function unrollSiblingStrategy( - loopNode: any, - targetStmt: any, - stmts: any[], + loopNode: Node, + targetStmt: Node, + stmts: Node[], indexVar: string | null, ): "blanket" | "refuse" | "preserve" { const siblings = stmts.filter((s) => s !== targetStmt); @@ -2088,8 +2096,8 @@ function unrollSiblingStrategy( /** Emit the per-iteration unrolled lines (target → static tl.to, siblings → index-substituted). */ function emitUnrolledLines( - stmts: any[], - targetStmt: any, + stmts: Node[], + targetStmt: Node, elements: UnrollElement[], timelineVar: string, animation: GsapAnimation, @@ -2128,8 +2136,8 @@ function buildLoopUnrollPreserving( timelineVar: string, animation: GsapAnimation, elements: UnrollElement[], - loopNode: any, - targetStmt: any, + loopNode: Node, + targetStmt: Node, ): string | null | typeof REFUSE_UNROLL { const stmts = loopBodyStatements(loopNode); if (!stmts || !stmts.includes(targetStmt)) return null; diff --git a/packages/sdk/src/engine/patches.ts b/packages/sdk/src/engine/patches.ts index 02c0b7c47b..8cfd4afcad 100644 --- a/packages/sdk/src/engine/patches.ts +++ b/packages/sdk/src/engine/patches.ts @@ -5,7 +5,7 @@ * /elements/{hfId}/inlineStyles/{camelCaseProp} * /elements/{hfId}/text * /elements/{hfId}/attributes/{name} - * /elements/{hfId}/timing/{start|end|trackIndex} ← end = computed absolute data-end + * /elements/{hfId}/timing/{start|end|duration|trackIndex} ← end = computed absolute data-end * /elements/{hfId}/hold/{start|end|fill} * /elements/{hfId} ← whole subtree (removeElement) * /variables/{variableId} @@ -154,8 +154,9 @@ export function keyToPath(key: string): string | null { // the already-encoded attr segment. Reconstruct manually. if (attr?.[1] && attr[2]) return `/elements/${escapeIdForPath(attr[1])}/attributes/${attr[2]}`; - const timing = /^([^.]+)\.timing\.(start|end|trackIndex)$/.exec(key); - if (timing?.[1]) return timingPath(timing[1], timing[2] as "start" | "end" | "trackIndex"); + const timing = /^([^.]+)\.timing\.(start|end|duration|trackIndex)$/.exec(key); + if (timing?.[1]) + return timingPath(timing[1], timing[2] as "start" | "end" | "duration" | "trackIndex"); const hold = /^([^.]+)\.hold\.(start|end|fill)$/.exec(key); if (hold?.[1]) return holdPath(hold[1], hold[2] as "start" | "end" | "fill"); From 6c2d668923067c0d33ae39639b6413719b6e79cd Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 16:02:27 -0700 Subject: [PATCH 43/43] =?UTF-8?q?fix(sdk):=20cascadeRemoveAnimations=20re-?= =?UTF-8?q?parses=20per=20removal=20(R4=20=E2=80=94=20SDK=20twin=20of=20#3?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cascadeRemoveAnimations captured every matching animation id from a single up-front parse, then removed against the mutating script — the SDK-side twin of the stripGsapForId bug (R3 #3). Animation ids are positional, so removing the first tween for an element renumbered the survivors and the stale later ids no-op'd, orphaning those tweens on the just-removed element. Now re-parse after each removal and strip the first still-matching animation until none remain. Also adds the reviewer's defense-in-depth test: an aliased multi-target setStyle (same id twice) undoes to the original, not the intermediate (exercises the single-dispatch inverse reversal from R3 #5/#11). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/sdk/src/engine/mutate.gsap.test.ts | 15 +++++++++++++++ packages/sdk/src/engine/mutate.ts | 19 ++++++++++++------- packages/sdk/src/session.test.ts | 15 +++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index 416214a19a..5d60d6b594 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -810,6 +810,21 @@ window.__timelines["t"] = tl;`; expect(newScript).not.toContain("hf-box"); expect(newScript).toContain("hf-stage"); }); + + it("strips ALL tweens for the element, not just the first (positional-id renumber)", () => { + // Two tweens on the same element: removing the first renumbers the survivor's + // count-based id, so a single up-front parse left the second tween orphaned. + const twoOwnTweens = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 0); +tl.to("[data-hf-id=\\"hf-box\\"]", { x: 200, duration: 1 }, 1); +window.__timelines["t"] = tl;`; + const parsed = fresh(twoOwnTweens); + const result = applyOp(parsed, { type: "removeElement", target: "hf-box" }); + const newScript = String(result.forward[1]?.value ?? ""); + expect(newScript).not.toContain("hf-box"); + expect(newScript).not.toContain("x: 100"); + expect(newScript).not.toContain("x: 200"); + }); }); // ─── GSAP ops on composition with no script block ──────────────────────────── diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 3667752c7e..6b5051d7a6 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -684,15 +684,20 @@ function collectSubtreeHfIds(el: Element): string[] { } function cascadeRemoveAnimations(script: string, id: HfId): string { - const parsedGsap = parseGsapScriptAcornForWrite(script); - if (!parsedGsap) return script; + // Re-parse after each removal: animation ids are positional, so removing one + // tween renumbers the survivors — ids from a single up-front parse go stale and + // no-op, orphaning later tweens on the removed element. Same fix as + // stripGsapForId in htmlParser.ts (R3 #3); this is its SDK-side twin. let current = script; - for (const { id: animId, animation } of parsedGsap.located) { - if (selectorMatchesId(animation.targetSelector, id)) { - current = removeAnimationFromScript(current, animId); - } + for (;;) { + const parsedGsap = parseGsapScriptAcornForWrite(current); + if (!parsedGsap) return current; + const match = parsedGsap.located.find((l) => selectorMatchesId(l.animation.targetSelector, id)); + if (!match) return current; + const next = removeAnimationFromScript(current, match.id); + if (next === current) return current; // guard against a non-removing match + current = next; } - return current; } // ─── setClassStyle handler ──────────────────────────────────────────────────── diff --git a/packages/sdk/src/session.test.ts b/packages/sdk/src/session.test.ts index 326d1dfcc8..e6e1ccc32c 100644 --- a/packages/sdk/src/session.test.ts +++ b/packages/sdk/src/session.test.ts @@ -317,6 +317,21 @@ describe("single-dispatch undo reverses the inverse patch list", () => { expect(comp.getElement("hf-parent")).not.toBeNull(); expect(comp.getElement("hf-child")).not.toBeNull(); }); + + // Defense-in-depth: an aliased multi-target (the same element twice) makes the + // 2nd id capture the value the 1st already wrote; undo must replay the inverse + // in reverse to land on the ORIGINAL, not the intermediate. + it("setStyle with a duplicate target undoes to the original, not the intermediate", async () => { + const comp = await openComposition(BASE_HTML); + comp.dispatch({ + type: "setStyle", + target: ["hf-title", "hf-title"], + styles: { fontSize: "96px" }, + }); + expect(comp.getElement("hf-title")?.inlineStyles.fontSize).toBe("96px"); + comp.undo(); + expect(comp.getElement("hf-title")?.inlineStyles.fontSize).toBe("64px"); + }); }); // ─── setSelection / getSelection / selectionchange ───────────────────────────