Skip to content
Open
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
3 changes: 2 additions & 1 deletion packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"build": "vite build && tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
false,
);

/** Explicit per-operation-family canary selection; the master flag alone enables nothing. */
export const STUDIO_SDK_CUTOVER_FAMILIES = resolveEnabledSdkFamilies(
env,
STUDIO_SDK_CUTOVER_ENABLED,
);

// Resolver-parity tripwire (telemetry-only, decoupled from cutover).
// Runs the SDK resolver alongside any edit and emits sdk_resolver_shadow on
// divergence. Default true; disable via VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED=false.
Expand All @@ -98,3 +104,4 @@ export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
);

export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
import { resolveEnabledSdkFamilies } from "../../utils/sdkCutoverPolicy";
47 changes: 47 additions & 0 deletions packages/studio/src/utils/sdkCutover.familyGate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";

vi.mock("../components/editor/manualEditingAvailability", () => ({
STUDIO_SDK_CUTOVER_ENABLED: true,
STUDIO_SDK_CUTOVER_FAMILIES: new Set(["timing"]),
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
}));
vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));

import {
sdkCutoverPersist,
sdkDeletePersist,
sdkGsapTweenPersist,
sdkTimingPersist,
} from "./sdkCutover";

const deps = {
editHistory: { recordEdit: vi.fn() },
writeProjectFile: vi.fn(),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
} as never;

describe("SDK family gate mapping", () => {
it("enables timing independently while declining other operation families", async () => {
await expect(
sdkTimingPersist("hf", "/c.html", { start: 1 }, null, deps),
).resolves.toMatchObject({ status: "declined", reason: "session_unavailable" });
await expect(
sdkGsapTweenPersist("/c.html", { kind: "remove", animationId: "a" }, null, deps),
).resolves.toMatchObject({ status: "declined", reason: "feature_disabled" });
await expect(sdkDeletePersist("hf", "before", "/c.html", null, deps)).resolves.toMatchObject({
status: "declined",
reason: "feature_disabled",
});
await expect(
sdkCutoverPersist(
{ hfId: "hf" } as never,
[{ type: "inline-style", property: "color", value: "red" }],
"before",
"/c.html",
{} as never,
deps,
),
).resolves.toMatchObject({ status: "declined", reason: "ineligible_operation" });
});
});
133 changes: 95 additions & 38 deletions packages/studio/src/utils/sdkCutover.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Composition, GsapTweenSpec } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { PatchOperation } from "./sourcePatcher";
import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability";
import * as studioAvailability from "../components/editor/manualEditingAvailability";
import { trackStudioEvent } from "./studioTelemetry";
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
Expand All @@ -15,6 +15,7 @@ import {
type CutoverOptions,
type CutoverResult,
} from "./sdkEditTransaction";
import { isSdkFamilyEnabled, type StudioSdkOperationFamily } from "./sdkCutoverPolicy";

export { shouldUseSdkCutover } from "./sdkCutoverEligibility";
export {
Expand All @@ -24,6 +25,27 @@ export {
} from "./sdkEditTransaction";
export type { CutoverDeps, CutoverOptions, CutoverResult } from "./sdkEditTransaction";

function sdkFamilyEnabled(family: StudioSdkOperationFamily): boolean {
const configured = Object.prototype.hasOwnProperty.call(
studioAvailability,
"STUDIO_SDK_CUTOVER_FAMILIES",
)
? studioAvailability.STUDIO_SDK_CUTOVER_FAMILIES
: undefined;
return isSdkFamilyEnabled(studioAvailability.STUDIO_SDK_CUTOVER_ENABLED, configured, family);
}

function trackCutoverResult(
result: CutoverResult,
context: { hfId?: string | null; opCount: number },
): void {
if (result.status === "committed") {
trackStudioEvent("sdk_cutover_success", context);
} else if (result.status === "failed") {
trackStudioEvent("sdk_cutover_failed", { ...context, error: result.error.message });
}
}

/** True when targetPath isn't the composition the SDK session models. */
function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean {
return deps.compositionPath != null && targetPath !== deps.compositionPath;
Expand All @@ -38,7 +60,7 @@ export async function sdkCutoverPersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<CutoverResult> {
if (!shouldUseSdkCutover(STUDIO_SDK_CUTOVER_ENABLED, !!sdkSession, selection.hfId, ops))
if (!shouldUseSdkCutover(sdkFamilyEnabled("dom"), !!sdkSession, selection.hfId, ops))
return declinedCutover("ineligible_operation");
if (!sdkSession) return declinedCutover("session_unavailable");
const hfId = selection.hfId;
Expand All @@ -58,11 +80,7 @@ export async function sdkCutoverPersist(
},
options,
);
if (result.status === "committed") {
trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length });
} else if (result.status === "failed") {
trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message });
}
trackCutoverResult(result, { hfId, opCount: ops.length });
return result;
}

Expand All @@ -86,7 +104,7 @@ export async function sdkTimingPersist(
// Dark-launch gate: without this, timing cutover runs whenever an SDK session
// exists (it always does, for shadow/selection) — flipping the flag OFF would
// NOT disable it. Gate here so flag-off routes back to the legacy server path.
if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled");
if (!sdkSession) return declinedCutover("session_unavailable");
if (!sdkSession.getElement(hfId)) return declinedCutover("target_not_found");
if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
Expand All @@ -102,11 +120,7 @@ export async function sdkTimingPersist(
options,
serializedBefore,
);
if (result.status === "committed") {
trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
} else if (result.status === "failed") {
trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message });
}
trackCutoverResult(result, { hfId, opCount: 1 });
return result;
} catch (error) {
const failed = { status: "failed", error: asCutoverError(error) } as const;
Expand Down Expand Up @@ -134,7 +148,7 @@ export async function sdkTimingBatchPersist(
timingSrc ? () => timingSrc(targetPath) : undefined,
);
}
if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled");
if (!sdkSession) return declinedCutover("session_unavailable");
if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
if (changes.some((change) => !sdkSession.getElement(change.hfId)))
Expand Down Expand Up @@ -209,13 +223,14 @@ export function sdkGsapTweenPersist(
}
// Leading dark-launch gate so flag-off does no SDK touch (getElement) at all —
// matches the other three chokepoints' discipline.
if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(declinedCutover("feature_disabled"));
if (!sdkFamilyEnabled("gsap-animation"))
return Promise.resolve(declinedCutover("feature_disabled"));
if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target))
return Promise.resolve(declinedCutover("target_not_found"));
// dispatchGsapOpAndPersist declines on before===after — that catches stale
// animationIds and unsupported shapes (e.g. from-prop on a plain tween), falling
// back to the server path. This subsumes explicit existence guards for set/remove.
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => {
return dispatchGsapOpAndPersist("gsap-animation", targetPath, sdkSession, deps, options, (s) => {
s.batch(() => {
if (op.kind === "add") {
s.addGsapTween(op.target, op.spec);
Expand All @@ -229,6 +244,7 @@ export function sdkGsapTweenPersist(
}

async function dispatchGsapOpAndPersist(
family: "gsap-animation" | "gsap-keyframe",
targetPath: string,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
Expand All @@ -243,7 +259,7 @@ async function dispatchGsapOpAndPersist(
}
// Dark-launch gate (shared chokepoint for every GSAP-op cutover persist):
// flag OFF → explicit decline → caller falls back to the legacy server path.
if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
if (!sdkFamilyEnabled(family)) return declinedCutover("feature_disabled");
if (!sdkSession) return declinedCutover("session_unavailable");
if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
const session = sdkSession;
Expand Down Expand Up @@ -291,6 +307,7 @@ export function sdkGsapKeyframePersist(
options?: CutoverOptions,
): Promise<CutoverResult> {
return dispatchGsapOpAndPersist(
"gsap-keyframe",
targetPath,
sdkSession,
deps,
Expand All @@ -309,6 +326,7 @@ export function sdkGsapRemoveKeyframePersist(
options?: CutoverOptions,
): Promise<CutoverResult> {
return dispatchGsapOpAndPersist(
"gsap-keyframe",
targetPath,
sdkSession,
deps,
Expand All @@ -328,6 +346,7 @@ export function sdkGsapRemovePropertyPersist(
options?: CutoverOptions,
): Promise<CutoverResult> {
return dispatchGsapOpAndPersist(
"gsap-animation",
targetPath,
sdkSession,
deps,
Expand All @@ -344,7 +363,7 @@ export function sdkGsapDeleteAllForSelectorPersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<CutoverResult> {
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
return dispatchGsapOpAndPersist("gsap-animation", targetPath, sdkSession, deps, options, (s) =>
s.dispatch({ type: "deleteAllForSelector", selector }),
);
}
Expand All @@ -357,6 +376,7 @@ export function sdkGsapRemoveAllKeyframesPersist(
options?: CutoverOptions,
): Promise<CutoverResult> {
return dispatchGsapOpAndPersist(
"gsap-keyframe",
targetPath,
sdkSession,
deps,
Expand All @@ -375,6 +395,7 @@ export function sdkGsapConvertToKeyframesPersist(
options?: CutoverOptions,
): Promise<CutoverResult> {
return dispatchGsapOpAndPersist(
"gsap-keyframe",
targetPath,
sdkSession,
deps,
Expand All @@ -399,6 +420,16 @@ type KeyframesPayload = {
ease?: string;
};

function keyframesPayload(
targetSelector: string,
position: number,
duration: number,
keyframes: KeyframeSpec[],
ease: string | undefined,
): KeyframesPayload {
return { targetSelector, position, duration, keyframes, ...(ease ? { ease } : {}) };
}

/** Shared inner dispatch for addWithKeyframes / replaceWithKeyframes ops. */
function dispatchWithKeyframes(
s: Composition,
Expand All @@ -412,6 +443,38 @@ function dispatchWithKeyframes(
}
}

function persistKeyframesOperation(input: {
targetPath: string;
targetSelector: string;
position: number;
duration: number;
keyframes: KeyframeSpec[];
ease: string | undefined;
sdkSession: Composition | null | undefined;
deps: CutoverDeps;
options?: CutoverOptions;
animationId?: string;
}): Promise<CutoverResult> {
const payload = keyframesPayload(
input.targetSelector,
input.position,
input.duration,
input.keyframes,
input.ease,
);
return dispatchGsapOpAndPersist(
"gsap-keyframe",
input.targetPath,
input.sdkSession,
input.deps,
input.options,
(session) => dispatchWithKeyframes(session, payload, input.animationId),
input.animationId
? { animationId: input.animationId, opLabel: "replaceWithKeyframes" }
: undefined,
);
}

export function sdkAddWithKeyframesPersist(
targetPath: string,
targetSelector: string,
Expand All @@ -423,16 +486,17 @@ export function sdkAddWithKeyframesPersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<CutoverResult> {
const payload: KeyframesPayload = {
return persistKeyframesOperation({
targetPath,
targetSelector,
position,
duration,
keyframes,
...(ease ? { ease } : {}),
};
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
dispatchWithKeyframes(s, payload),
);
ease,
sdkSession,
deps,
options,
});
}

export function sdkReplaceWithKeyframesPersist(
Expand All @@ -447,21 +511,18 @@ export function sdkReplaceWithKeyframesPersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<CutoverResult> {
const payload: KeyframesPayload = {
return persistKeyframesOperation({
targetPath,
animationId,
targetSelector,
position,
duration,
keyframes,
...(ease ? { ease } : {}),
};
return dispatchGsapOpAndPersist(
targetPath,
ease,
sdkSession,
deps,
options,
(s) => dispatchWithKeyframes(s, payload, animationId),
{ animationId, opLabel: "replaceWithKeyframes" },
);
});
}

export async function sdkDeletePersist(
Expand All @@ -476,7 +537,7 @@ export async function sdkDeletePersist(
Promise.resolve(originalContent),
);
// Dark-launch gate: flag OFF → legacy server delete path.
if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
if (!sdkFamilyEnabled("lifecycle")) return declinedCutover("feature_disabled");
if (!sdkSession) return declinedCutover("session_unavailable");
if (!sdkSession.getElement(hfId)) return declinedCutover("target_not_found");
if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
Expand All @@ -488,10 +549,6 @@ export async function sdkDeletePersist(
(session) => session.removeElement(hfId),
{ label: "Delete element" },
);
if (result.status === "committed") {
trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
} else if (result.status === "failed") {
trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message });
}
trackCutoverResult(result, { hfId, opCount: 1 });
return result;
}
3 changes: 3 additions & 0 deletions packages/studio/src/utils/sdkCutoverPolicy.report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { renderStudioSdkCutoverReport } from "./sdkCutoverPolicy";

process.stdout.write(renderStudioSdkCutoverReport());
Loading
Loading