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