Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/sdk-playground/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createFileAdapter } from "./fileAdapter.js";
import type { Composition, GsapTweenSpec, PreviewAdapter, FindQuery } from "@hyperframes/sdk";
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
import type { GsapAnimation } from "@hyperframes/core";
// fallow-ignore-next-line unresolved-imports
import gsapRaw from "gsap/dist/gsap.min.js?raw";

// ── Demo composition ──────────────────────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ export function StudioApp() {
openSourceForSelection: fileManager.openSourceForSelection,
selectSidebarTab: selectSidebarTabStable,
getSidebarTab: getSidebarTabStable,
sdkSession,
});
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
clearDomSelectionRef.current = domEditSession.clearDomSelection;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,13 @@ 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 false in production; enable via VITE_STUDIO_SDK_SHADOW_ENABLED=true.
export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_SDK_SHADOW_ENABLED"],
false,
);

export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
15 changes: 6 additions & 9 deletions packages/studio/src/hooks/useDomEditCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,12 @@ import { buildDomEditPatchTarget, type DomEditSelection } from "../components/ed
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
import type { EditHistoryKind } from "../utils/editHistory";
import type { PersistDomEditOperations } from "./domEditCommitTypes";
import type { PatchOperation } from "../utils/sourcePatcher";
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
import { useDomEditTextCommits } from "./useDomEditTextCommits";
import { useDomGeometryCommits } from "./useDomGeometryCommits";
import { useElementLifecycleOps } from "./useElementLifecycleOps";

// Re-export so existing consumers keep their import path
export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits";

// ── Helpers ──

function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
Expand All @@ -40,17 +38,13 @@ function formatPatchRejectionMessage(body: { error?: string; fields?: string[] }
return `Couldn't save edit: ${body.error}${suffix}`;
}

// ── Types ──

interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}

export type { PersistDomEditOperations } from "./domEditCommitTypes";

export interface UseDomEditCommitsParams {
activeCompPath: string | null;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
Expand All @@ -77,10 +71,10 @@ export interface UseDomEditCommitsParams {
target: HTMLElement,
options?: { preferClipAncestor?: boolean },
) => Promise<DomEditSelection | null>;
/** Stage 7 Step 3b: called after a successful server-side element patch. */
onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
}

// ── Hook ──

export function useDomEditCommits({
activeCompPath,
previewIframeRef,
Expand All @@ -99,6 +93,7 @@ export function useDomEditCommits({
clearDomSelection,
refreshDomEditSelectionFromPreview,
buildDomSelectionFromTarget,
onDomEditPersisted,
}: UseDomEditCommitsParams) {
const resolveImportedFontAsset = useCallback(
(fontFamilyValue: string): ImportedFontAsset | null => {
Expand Down Expand Up @@ -220,6 +215,7 @@ export function useDomEditCommits({
coalesceKey: options?.coalesceKey,
files: { [targetPath]: { before: originalContent, after: finalContent } },
});
onDomEditPersisted?.(selection, operations);

if (!options?.skipRefresh) {
reloadPreview();
Expand All @@ -233,6 +229,7 @@ export function useDomEditCommits({
domEditSaveTimestampRef,
reloadPreview,
showToast,
onDomEditPersisted,
],
);

Expand Down
8 changes: 8 additions & 0 deletions packages/studio/src/hooks/useDomEditSession.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Composition } from "@hyperframes/sdk";
import type { TimelineElement } from "../player";
import type { ImportedFontAsset } from "../components/editor/fontAssets";
import type { EditHistoryKind } from "../utils/editHistory";
Expand All @@ -8,6 +9,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
import { useDomSelection } from "./useDomSelection";
import { usePreviewInteraction } from "./usePreviewInteraction";
import { useDomEditCommits } from "./useDomEditCommits";
import { runShadowDispatch } from "../utils/sdkShadow";
import { useGsapScriptCommits } from "./useGsapScriptCommits";
import { useGsapCacheVersion } from "./useGsapTweenCache";
import { useDomEditWiring } from "./useDomEditWiring";
Expand Down Expand Up @@ -58,6 +60,8 @@ 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 ──
Expand Down Expand Up @@ -96,6 +100,7 @@ export function useDomEditSession({
openSourceForSelection,
selectSidebarTab,
getSidebarTab,
sdkSession,
}: UseDomEditSessionParams) {
void _setRefreshKey;
void _readProjectFile;
Expand Down Expand Up @@ -227,6 +232,9 @@ export function useDomEditSession({
clearDomSelection,
refreshDomEditSelectionFromPreview,
buildDomSelectionFromTarget,
onDomEditPersisted: sdkSession
? (sel, ops) => runShadowDispatch(sdkSession, sel, ops)
: undefined,
});

// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
Expand Down
146 changes: 146 additions & 0 deletions packages/studio/src/utils/sdkShadow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import { patchOpsToSdkEditOps, SdkShadowMismatch } from "./sdkShadow";
import type { PatchOperation } from "./sourcePatcher";
import { openComposition } from "@hyperframes/sdk";

const BASE_HTML = /* html */ `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
</body></html>`;

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");
});

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<SdkShadowMismatch>({
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");
});

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");
});

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<typeof sdkShadowDispatch> | 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<SdkShadowMismatch>({
kind: "dispatch_error",
hfId: "hf-box",
error: expect.stringContaining("sdk internal error"),
});
});
});
Loading
Loading