Skip to content

Commit 266403f

Browse files
vanceingallsclaude
andcommitted
feat(studio): stage 7 step 3b — SDK shadow dispatch parity mode
Wire onDomEditPersisted callback from useDomEditCommits into useDomEditSession, calling reportShadowDispatch (flag-gated via VITE_STUDIO_SDK_SHADOW_ENABLED) to dispatch equivalent SDK ops alongside the server patch path and emit sdk_shadow_dispatch telemetry with mismatch details. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 98d3e76 commit 266403f

6 files changed

Lines changed: 335 additions & 5 deletions

File tree

packages/studio/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ export function StudioApp() {
303303
openSourceForSelection: fileManager.openSourceForSelection,
304304
selectSidebarTab: selectSidebarTabStable,
305305
getSidebarTab: getSidebarTabStable,
306+
sdkSession,
306307
});
307308
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
308309
clearDomSelectionRef.current = domEditSession.clearDomSelection;

packages/studio/src/components/editor/manualEditingAvailability.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,13 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
9595

9696
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
9797

98+
// Stage 7 Step 3b: shadow dispatch parity mode — dispatches ops to the SDK
99+
// session alongside the server patch path and logs mismatches via telemetry.
100+
// Default false in production; enable via VITE_STUDIO_SDK_SHADOW_ENABLED=true.
101+
export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
102+
env,
103+
["VITE_STUDIO_SDK_SHADOW_ENABLED"],
104+
false,
105+
);
106+
98107
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";

packages/studio/src/hooks/useDomEditCommits.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditO
4141
import type { EditHistoryKind } from "../utils/editHistory";
4242
import { useDomEditTextCommits } from "./useDomEditTextCommits";
4343

44-
// ── Helpers ──
4544
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
4645

4746
// fallow-ignore-next-line complexity
@@ -75,8 +74,6 @@ function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLEl
7574
return false;
7675
}
7776

78-
// ── Types ──
79-
8077
interface RecordEditInput {
8178
label: string;
8279
kind: EditHistoryKind;
@@ -122,10 +119,10 @@ export interface UseDomEditCommitsParams {
122119
target: HTMLElement,
123120
options?: { preferClipAncestor?: boolean },
124121
) => Promise<DomEditSelection | null>;
122+
/** Stage 7 Step 3b: called after a successful server-side element patch. */
123+
onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
125124
}
126125

127-
// ── Hook ──
128-
129126
export function useDomEditCommits({
130127
activeCompPath,
131128
previewIframeRef,
@@ -144,6 +141,7 @@ export function useDomEditCommits({
144141
clearDomSelection,
145142
refreshDomEditSelectionFromPreview,
146143
buildDomSelectionFromTarget,
144+
onDomEditPersisted,
147145
}: UseDomEditCommitsParams) {
148146
const resolveImportedFontAsset = useCallback(
149147
(fontFamilyValue: string): ImportedFontAsset | null => {
@@ -253,6 +251,7 @@ export function useDomEditCommits({
253251
coalesceKey: options?.coalesceKey,
254252
files: { [targetPath]: { before: originalContent, after: finalContent } },
255253
});
254+
onDomEditPersisted?.(selection, operations);
256255

257256
if (!options?.skipRefresh) {
258257
reloadPreview();
@@ -265,6 +264,7 @@ export function useDomEditCommits({
265264
projectIdRef,
266265
domEditSaveTimestampRef,
267266
reloadPreview,
267+
onDomEditPersisted,
268268
],
269269
);
270270

packages/studio/src/hooks/useDomEditSession.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useCallback, useEffect, useRef } from "react";
2+
import type { Composition } from "@hyperframes/sdk";
23
import type { TimelineElement } from "../player";
34
import { usePlayerStore } from "../player";
45
import {
@@ -16,6 +17,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
1617
import { useDomSelection } from "./useDomSelection";
1718
import { usePreviewInteraction } from "./usePreviewInteraction";
1819
import { useDomEditCommits } from "./useDomEditCommits";
20+
import { reportShadowDispatch } from "../utils/sdkShadow";
1921
import { useGsapScriptCommits } from "./useGsapScriptCommits";
2022
import {
2123
useGsapAnimationsForElement,
@@ -77,6 +79,8 @@ export interface UseDomEditSessionParams {
7779
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
7880
selectSidebarTab?: (tab: SidebarTab) => void;
7981
getSidebarTab?: () => SidebarTab;
82+
/** Stage 7 Step 3b: SDK session for shadow dispatch parity tracking. */
83+
sdkSession?: Composition | null;
8084
}
8185

8286
// ── Hook ──
@@ -116,6 +120,7 @@ export function useDomEditSession({
116120
openSourceForSelection,
117121
selectSidebarTab,
118122
getSidebarTab,
123+
sdkSession,
119124
}: UseDomEditSessionParams) {
120125
void _setRefreshKey;
121126

@@ -325,6 +330,9 @@ export function useDomEditSession({
325330
clearDomSelection,
326331
refreshDomEditSelectionFromPreview,
327332
buildDomSelectionFromTarget,
333+
onDomEditPersisted: sdkSession
334+
? (sel, ops) => reportShadowDispatch(sdkSession, sel, ops)
335+
: undefined,
328336
});
329337

330338
// GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, it } from "vitest";
2+
import { patchOpsToSdkEditOps, SdkShadowMismatch } from "./sdkShadow";
3+
import type { PatchOperation } from "./sourcePatcher";
4+
import { openComposition } from "@hyperframes/sdk";
5+
6+
const BASE_HTML = /* html */ `<!DOCTYPE html>
7+
<html><body>
8+
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
9+
</body></html>`;
10+
11+
describe("patchOpsToSdkEditOps", () => {
12+
it("maps inline-style ops to a single setStyle EditOp", () => {
13+
const ops: PatchOperation[] = [
14+
{ type: "inline-style", property: "color", value: "#00f" },
15+
{ type: "inline-style", property: "opacity", value: "0.5" },
16+
];
17+
const result = patchOpsToSdkEditOps("hf-box", ops);
18+
expect(result).toHaveLength(1);
19+
expect(result[0]).toEqual({
20+
type: "setStyle",
21+
target: "hf-box",
22+
styles: { color: "#00f", opacity: "0.5" },
23+
});
24+
});
25+
26+
it("maps text-content op to setText EditOp", () => {
27+
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World" }];
28+
const result = patchOpsToSdkEditOps("hf-box", ops);
29+
expect(result).toHaveLength(1);
30+
expect(result[0]).toEqual({ type: "setText", target: "hf-box", value: "World" });
31+
});
32+
33+
it("maps attribute op to setAttribute with data- prefix", () => {
34+
const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
35+
const result = patchOpsToSdkEditOps("hf-box", ops);
36+
expect(result).toHaveLength(1);
37+
expect(result[0]).toEqual({
38+
type: "setAttribute",
39+
target: "hf-box",
40+
name: "data-name",
41+
value: "hero",
42+
});
43+
});
44+
45+
it("maps html-attribute op to setAttribute without prefix", () => {
46+
const ops: PatchOperation[] = [
47+
{ type: "html-attribute", property: "contenteditable", value: "true" },
48+
];
49+
const result = patchOpsToSdkEditOps("hf-box", ops);
50+
expect(result).toHaveLength(1);
51+
expect(result[0]).toEqual({
52+
type: "setAttribute",
53+
target: "hf-box",
54+
name: "contenteditable",
55+
value: "true",
56+
});
57+
});
58+
59+
it("handles null value for attribute removal", () => {
60+
const ops: PatchOperation[] = [{ type: "html-attribute", property: "hidden", value: null }];
61+
const result = patchOpsToSdkEditOps("hf-box", ops);
62+
expect(result[0]).toEqual({
63+
type: "setAttribute",
64+
target: "hf-box",
65+
name: "hidden",
66+
value: null,
67+
});
68+
});
69+
70+
it("returns empty array for unknown op types", () => {
71+
const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[];
72+
expect(patchOpsToSdkEditOps("hf-box", ops)).toHaveLength(0);
73+
});
74+
});
75+
76+
describe("sdkShadowDispatch (integration)", () => {
77+
it("applies ops and returns no mismatches when SDK matches expected values", async () => {
78+
const { sdkShadowDispatch } = await import("./sdkShadow");
79+
const session = await openComposition(BASE_HTML);
80+
81+
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
82+
const result = sdkShadowDispatch(session, "hf-box", ops);
83+
84+
expect(result.dispatched).toBe(true);
85+
expect(result.mismatches).toHaveLength(0);
86+
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f");
87+
});
88+
89+
it("returns dispatched:false when hfId not found in session", async () => {
90+
const { sdkShadowDispatch } = await import("./sdkShadow");
91+
const session = await openComposition(BASE_HTML);
92+
93+
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
94+
const result = sdkShadowDispatch(session, "hf-missing", ops);
95+
96+
expect(result.dispatched).toBe(false);
97+
expect(result.mismatches).toHaveLength(1);
98+
expect(result.mismatches[0]).toMatchObject<SdkShadowMismatch>({
99+
kind: "element_not_found",
100+
hfId: "hf-missing",
101+
});
102+
});
103+
104+
it("applies text op and reads back via session.getElement", async () => {
105+
const { sdkShadowDispatch } = await import("./sdkShadow");
106+
const session = await openComposition(BASE_HTML);
107+
108+
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "Updated" }];
109+
sdkShadowDispatch(session, "hf-box", ops);
110+
111+
expect(session.getElement("hf-box")?.text).toBe("Updated");
112+
});
113+
114+
it("applies attribute op and reads back via session.getElement", async () => {
115+
const { sdkShadowDispatch } = await import("./sdkShadow");
116+
const session = await openComposition(BASE_HTML);
117+
118+
const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
119+
sdkShadowDispatch(session, "hf-box", ops);
120+
121+
expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero");
122+
});
123+
});

0 commit comments

Comments
 (0)