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
+102 -2
View File
@@ -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<string, unknown> }> = [];
vi.mock("./studioTelemetry", () => ({
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
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 */ `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
@@ -144,3 +162,85 @@ describe("sdkShadowDispatch (integration)", () => {
});
});
});
const TIMING_HTML = /* html */ `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-clip" data-start="0" data-duration="1" data-track="0">clip</div>
</body></html>`;
const GSAP_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<div data-hf-id="hf-box" style="opacity:0"></div>
<script>var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;</script>
</div>`;
const NO_TIMELINE_HTML = `<div data-hf-id="hf-stage" data-hf-root>
<div data-hf-id="hf-box"></div>
<script>gsap.defaults({ ease: "power1.out" });
window.__timelines = {};</script>
</div>`;
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",
});
});
});
+200 -2
View File
@@ -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<GsapTweenSpec> }
| { kind: "remove"; animationId: string };
/**
* Shadow a GSAP tween mutation. Snapshot value-parity is NOT available: the
* tween lives in the GSAP <script>, and ElementSnapshot.animationIds is a stub
* (always [] — see sdk document.ts). So the signal here is can() addressing /
* validity + dispatch-didn't-throw, plus (for add) that the SDK returned a
* non-empty tween id. Full fidelity needs serialize()-script round-trip diffing,
* out of scope for shadow. // ponytail: upgrade when animationIds is populated.
*/
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 };
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" && !newId) {
return [
{
kind: "value_mismatch",
hfId: gsapOp.target,
property: "tweenId",
expected: "non-empty",
actual: null,
},
];
}
return [];
});
}