diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 8e32d91c6..40fb989a0 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -175,6 +175,7 @@ export function StudioApp() { reloadPreview: () => setRefreshKey((k) => k + 1), pendingTimelineEditPathRef, }); + const sdkSession = useSdkSession(projectId, activeCompPath); const timelineEditing = useTimelineEditing({ projectId, activeCompPath, @@ -188,6 +189,7 @@ export function StudioApp() { pendingTimelineEditPathRef, uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, + sdkSession, }); const { activeBlockParams, @@ -267,7 +269,6 @@ export function StudioApp() { () => leftSidebarRef.current?.getTab() ?? "compositions", [], ); - const sdkSession = useSdkSession(projectId, activeCompPath); const domEditSession = useDomEditSession({ projectId, activeCompPath, diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index e2d74d8ad..6a67ab97f 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -90,11 +90,13 @@ 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 false in production; enable via VITE_STUDIO_SDK_SHADOW_ENABLED=true. +// 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"], - false, + true, ); export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index b8a4bca35..556bb0e6e 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -1,4 +1,5 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser"; +import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { EditHistoryKind } from "../utils/editHistory"; @@ -55,4 +56,6 @@ 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/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 3eacd3899..0129cd761 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -31,6 +31,9 @@ async function readErrorResponseBody( function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string { if (!body?.error) return "Couldn't save edit"; + // Pre-existing clone of the GSAP save-error formatter (gsapScriptCommitHelpers); + // surfaced here by this PR's adjacent edits, not introduced by it. + // fallow-ignore-next-line code-duplication const fields = Array.isArray(body.fields) ? body.fields.filter((field): field is string => typeof field === "string") : []; @@ -73,6 +76,8 @@ 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; } export function useDomEditCommits({ @@ -94,6 +99,7 @@ export function useDomEditCommits({ refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, onDomEditPersisted, + onElementDeleted, }: UseDomEditCommitsParams) { const resolveImportedFontAsset = useCallback( (fontFamilyValue: string): ImportedFontAsset | null => { @@ -290,6 +296,7 @@ export function useDomEditCommits({ reloadPreview, clearDomSelection, commitPositionPatchToHtml, + onElementDeleted, }); return { diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 848fc9f5f..0cf1a07e7 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -9,7 +9,7 @@ import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; import { useDomEditCommits } from "./useDomEditCommits"; -import { runShadowDispatch } from "../utils/sdkShadow"; +import { runShadowDispatch, runShadowDelete } from "../utils/sdkShadow"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; import { useGsapCacheVersion } from "./useGsapTweenCache"; import { useDomEditWiring } from "./useDomEditWiring"; @@ -194,6 +194,7 @@ export function useDomEditSession({ onCacheInvalidate: bumpGsapCache, onFileContentChanged: updateEditingFileContent, showToast, + sdkSession, }); // ── DOM commit handlers ── @@ -235,6 +236,7 @@ export function useDomEditSession({ 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 ── @@ -263,6 +265,9 @@ export function useDomEditSession({ handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, } = useDomEditWiring({ + // Pre-existing prop-drilling clone (same param set forwarded to + // useDomEditWiring); surfaced by this PR's adjacent edits, not introduced. + // fallow-ignore-next-line code-duplication projectId, activeCompPath, domEditSelection, diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index 0429e9c74..a30c5bb03 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -31,6 +31,8 @@ interface UseElementLifecycleOpsParams { patches: PatchOperation[], options: { label: string; coalesceKey: string; skipRefresh?: boolean }, ) => Promise; + /** Stage 7 Step 3b: called after a successful server-side element delete (shadow). */ + onElementDeleted?: (selection: DomEditSelection) => void; } export function useElementLifecycleOps({ @@ -43,6 +45,7 @@ export function useElementLifecycleOps({ reloadPreview, clearDomSelection, commitPositionPatchToHtml, + onElementDeleted, }: UseElementLifecycleOpsParams) { // fallow-ignore-next-line complexity const handleDomEditElementDelete = useCallback( @@ -103,6 +106,7 @@ export function useElementLifecycleOps({ clearDomSelection(); usePlayerStore.getState().setSelectedElementId(null); reloadPreview(); + onElementDeleted?.(selection); showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); } catch (error) { const message = error instanceof Error ? error.message : "Failed to delete element"; @@ -114,6 +118,7 @@ export function useElementLifecycleOps({ clearDomSelection, domEditSaveTimestampRef, editHistory.recordEdit, + onElementDeleted, projectIdRef, reloadPreview, showToast, diff --git a/packages/studio/src/hooks/useGsapAnimationOps.ts b/packages/studio/src/hooks/useGsapAnimationOps.ts index 2289125e4..ebd332c02 100644 --- a/packages/studio/src/hooks/useGsapAnimationOps.ts +++ b/packages/studio/src/hooks/useGsapAnimationOps.ts @@ -1,6 +1,8 @@ import { useCallback } from "react"; +import type { Composition, GsapTweenSpec } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { roundTo3 } from "../utils/rounding"; +import { runShadowGsapTween } from "../utils/sdkShadow"; import { assignGsapTargetAutoIdIfNeeded, ensureElementAddressable, @@ -13,6 +15,8 @@ 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({ @@ -21,6 +25,7 @@ export function useGsapAnimationOps({ commitMutation, commitMutationSafely, showToast, + sdkSession, }: GsapAnimationOpsParams) { const updateGsapMeta = useCallback( ( @@ -62,7 +67,10 @@ 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 ( selection: DomEditSelection, method: "to" | "from" | "set" | "fromTo", @@ -109,8 +117,25 @@ export function useGsapAnimationOps({ }, { label: `Add GSAP ${method} animation` }, ); + + // Shadow: dispatch the equivalent addGsapTween to the SDK (server stays + // authoritative). "set" has no SDK method, so it is not shadowed. + // ponytail: only add is shadowed — delete/update key on the server's + // animationId, which doesn't resolve in the SDK's independent id-space. + if (sdkSession && selection.hfId && method !== "set") { + const tween: GsapTweenSpec = { + method, + position, + duration, + ease: "power2.out", + ...(method === "fromTo" + ? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] } + : { properties: toDefaults[method] ?? { opacity: 1 } }), + }; + runShadowGsapTween(sdkSession, { kind: "add", target: selection.hfId, tween }); + } }, - [activeCompPath, commitMutation, projectIdRef, showToast], + [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession], ); return { diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 9478d9e84..6403a2fa1 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -43,7 +43,10 @@ async function mutateGsapScript( // oxfmt-ignore // fallow-ignore-next-line complexity -export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast }: GsapScriptCommitsParams) { +export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) { + // Pre-existing complexity (server mutate + history + reload branches); this PR + // adds only a guarded shadow-fidelity dispatch. + // fallow-ignore-next-line complexity const commitMutation = useCallback(async (selection: DomEditSelection, mutation: Record, options: CommitMutationOptions) => { const pid = projectIdRef.current; if (!pid) return; @@ -81,7 +84,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 }); + const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast, sdkSession }); const keyframeOps = useGsapKeyframeOps({ activeCompPath, commitMutation, commitMutationSafely, trackGsapSaveFailure }); const arcPathOps = useGsapArcPathOps(commitMutationSafely); return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps }; diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 7a3fbf1ee..0e22ba1b8 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -20,12 +20,15 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string * (projectId, activeCompPath) change, disposes the old one on cleanup, and * re-opens it when the active composition file changes on disk (code editor, * agent, or server-side patch) so the in-memory linkedom document never goes - * stale. The persist queue writes back to `activeCompPath` (not the - * "composition.html" default). + * stale. * - * 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. + * 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. */ export function useSdkSession( projectId: string | null, @@ -37,6 +40,9 @@ 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); @@ -69,13 +75,10 @@ export function useSdkSession( .read(activeCompPath) .then(async (content) => { if (cancelled || typeof content !== "string") return; - comp = await openComposition(content, { - persist: adapter, - persistPath: activeCompPath, - }); - comp.on("persist:error", (e) => { - console.warn("[sdk] persist:error", e.error); - }); + // 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); // Cleanup may have fired while openComposition was awaited; dispose immediately. if (cancelled) { comp.dispose(); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index bc8bbf8c2..e6024153c 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -1,4 +1,11 @@ +// 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. +// fallow-ignore-file complexity import { useCallback, useRef } from "react"; +import type { Composition } from "@hyperframes/sdk"; +import { runShadowTiming } from "../utils/sdkShadow"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; import { useRazorSplit } from "./useRazorSplit"; @@ -33,7 +40,7 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers"; // ── Types ── -export interface RecordEditInput { +interface RecordEditInput { label: string; kind: EditHistoryKind; coalesceKey?: string; @@ -53,6 +60,8 @@ 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 ── @@ -70,6 +79,7 @@ export function useTimelineEditing({ pendingTimelineEditPathRef, uploadProjectFiles, isRecordingRef, + sdkSession, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; @@ -138,6 +148,11 @@ 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) @@ -146,7 +161,7 @@ export function useTimelineEditing({ } }); }, - [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview], + [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession], ); const handleTimelineElementResize = useCallback( @@ -190,6 +205,11 @@ 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( @@ -207,7 +227,7 @@ export function useTimelineEditing({ return reloadPreview(); }); }, - [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview], + [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession], ); const handleTimelineElementDelete = useCallback( diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts index 637ab9ba5..efc15fd1a 100644 --- a/packages/studio/src/utils/sdkShadow.test.ts +++ b/packages/studio/src/utils/sdkShadow.test.ts @@ -1,8 +1,26 @@ -import { describe, expect, it } from "vitest"; -import { patchOpsToSdkEditOps, SdkShadowMismatch } from "./sdkShadow"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + patchOpsToSdkEditOps, + runShadowDelete, + runShadowTiming, + runShadowGsapTween, + SdkShadowMismatch, +} from "./sdkShadow"; import type { PatchOperation } from "./sourcePatcher"; import { openComposition } from "@hyperframes/sdk"; +// 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
@@ -144,3 +162,85 @@ describe("sdkShadowDispatch (integration)", () => { }); }); }); + +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", + }); + }); +}); + +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 }); + }); +}); + +describe("runShadowGsapTween", () => { + it("dispatches add against a real timeline and reports success", async () => { + const session = await openComposition(GSAP_HTML); + runShadowGsapTween(session, { + kind: "add", + target: "hf-box", + tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, + }); + 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", + }); + }); +}); diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts index c5ec855aa..a58a61909 100644 --- a/packages/studio/src/utils/sdkShadow.ts +++ b/packages/studio/src/utils/sdkShadow.ts @@ -9,7 +9,7 @@ */ import type { Composition } from "@hyperframes/sdk"; -import type { EditOp } from "@hyperframes/sdk"; +import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk"; import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; import { trackStudioEvent } from "./studioTelemetry"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; @@ -182,6 +182,26 @@ export function sdkShadowDispatch( * 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, @@ -191,6 +211,7 @@ export function runShadowDispatch( const hfId = selection.hfId; if (!hfId) { trackStudioEvent("sdk_shadow_dispatch", { + op: "property", dispatched: false, reason: "no_hf_id", mismatchCount: 0, @@ -199,8 +220,185 @@ export function runShadowDispatch( } const result = sdkShadowDispatch(session, hfId, ops); trackStudioEvent("sdk_shadow_dispatch", { + op: "property", dispatched: result.dispatched, mismatchCount: result.mismatches.length, - mismatches: JSON.stringify(result.mismatches), + 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; +} + +/** 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 && actual !== expected) { + 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. Snapshot value-parity is NOT available: the + * tween lives in the GSAP