feat(studio): extend SDK shadow to delete/timing/gsap-add + default on (#1473)

* feat(studio): default SDK shadow dispatch on for parity telemetry

Shadow mode keeps the server patch path authoritative (no user-visible
change) and emits sdk_shadow_dispatch parity signal. Default it on so we
collect addressing/serialize-drift telemetry from all traffic before any
cutover. Disable via VITE_STUDIO_SDK_SHADOW_ENABLED=false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): shadow parity for delete/timing/gsap ops + wire delete

Extends shadow visibility past the property-edit path. Adds a can()-first
shadow core (pure addressing/validity pre-check, works even for GSAP which
has no snapshot value) plus runShadowDelete/runShadowTiming/runShadowGsapTween.
Parity coverage: delete = getElement null (full); timing = snapshot
start/duration/trackIndex (full); gsap = can()+dispatch+returned-id only
(animationIds is a stub, tween values are script-level — full fidelity needs
serialize() round-trip diffing, out of scope).

Wires the delete runner end-to-end via an onElementDeleted callback
(useDomEditSession → useDomEditCommits → useElementLifecycleOps), fired after
the server delete succeeds. Server stays authoritative. Timing/GSAP wiring
follows (each needs threading sdkSession into useTimelineEditing /
useGsapScriptCommits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): wire timing + GSAP-add shadow dispatch

Timing: thread sdkSession into useTimelineEditing; fire runShadowTiming after
move/resize persist (server authoritative). Moved the useSdkSession call above
useTimelineEditing so both share the single session (no duplicate).

GSAP: thread sdkSession through useGsapScriptCommits → useGsapAnimationOps;
shadow addGsapAnimation via runShadowGsapTween after the server add. Only the
add path is shadowed — delete/update key on the server's animationId, which
doesn't resolve in the SDK's independent id-space (would emit false
cannot_dispatch). "set" has no SDK method, so it's skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): address #1473 review — no-persist shadow session + fallow gate

Blocker (Rames): the shadow runners dispatched on the live persisted SDK
session, so each shadow op fired the persist queue → an HTTP write of the SDK's
serialize() output, clobbering the studio's authoritative write (default-on
shipped this). Fix: open the shadow session WITHOUT persist — it reads from the
server but never writes back. Shadow dispatches mutate the in-memory model only
and are discarded on the next reload-on-change. Cutover (Step 3c+) must re-add
persist together with self-write suppression. No persist consumer exists in
this stack (cutover is not in main), so this is safe and keeps default-on.

Fallow CI gate (Miguel):
- drop unused `export` on RecordEditInput (dead-type)
- suppress pre-existing CRAP with reasons: commitMutation, addGsapAnimation;
  file-level complexity on useTimelineEditing (shadow .then() branches nudge
  several callbacks over threshold — telemetry-only)
- suppress 3 pre-existing clones surfaced by adjacent edits (save-error
  formatter, prop-drilling passthrough, file-change reload handler)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): scrub user content from shadow property-path telemetry

Addresses #1473 review concern (Rames): inline-style and text-content edits
put user content into the sdk_shadow_dispatch mismatch expected/actual fields.
Redact before emit — text-content values fully redacted (length only), others
length-capped at 64. The in-memory parity result keeps raw values, so the
parity logic and tests are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-15 18:08:29 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2d48369c76
commit 8f15e9f09b
12 changed files with 398 additions and 26 deletions
@@ -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;
}
@@ -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<DomEditSelection | null>;
/** 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 {
@@ -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,
@@ -31,6 +31,8 @@ interface UseElementLifecycleOpsParams {
patches: PatchOperation[],
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
) => Promise<void>;
/** 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,
@@ -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 {
@@ -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<string, unknown>, 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 };
+15 -12
View File
@@ -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();
@@ -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<Set<string>>;
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
isRecordingRef?: React.RefObject<boolean>;
/** 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(