@hyperframes/studio 0.7.62 → 0.7.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ import "../chunk-PZ5AY32C.js";
2
+
1
3
  // src/styles/tailwind-preset.shared.js
2
4
  var studioPreset = {
3
5
  theme: {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/styles/tailwind-preset.shared.js","../../src/styles/tailwind-preset.ts"],"sourcesContent":["const studioPreset = {\n theme: {\n extend: {\n colors: {\n studio: {\n bg: \"#0a0a0a\",\n surface: \"#141414\",\n border: \"#262626\",\n text: \"#e5e5e5\",\n muted: \"#737373\",\n accent: \"#3CE6AC\",\n },\n panel: {\n bg: \"#0C0C0E\",\n // Open inspector-section body — slightly lighter than headers (bg)\n // so the recessed scrollable region reads distinct.\n \"bg-inset\": \"#121214\",\n input: \"#161618\",\n surface: \"#18181B\",\n hover: \"#27272A\",\n border: \"#1E1E1E\",\n \"border-input\": \"#27272A\",\n hairline: \"#1A1A1C\",\n \"text-0\": \"#FAFAFA\",\n \"text-1\": \"#E4E4E7\",\n \"text-2\": \"#A1A1AA\",\n \"text-3\": \"#71717A\",\n \"text-4\": \"#52525B\",\n \"text-5\": \"#3F3F46\",\n accent: \"#3CE6AC\",\n danger: \"#EF4444\",\n media: \"#00E3FF\",\n container: \"#F5A623\",\n },\n },\n },\n },\n plugins: [],\n};\n\nexport default studioPreset;\n","import studioPreset from \"./tailwind-preset.shared.js\";\n\nexport default studioPreset;\n"],"mappings":";AAAA,IAAM,eAAe;AAAA,EACnB,OAAO;AAAA,IACL,QAAQ;AAAA,MACN,QAAQ;AAAA,QACN,QAAQ;AAAA,UACN,IAAI;AAAA,UACJ,SAAS;AAAA,UACT,QAAQ;AAAA,UACR,MAAM;AAAA,UACN,OAAO;AAAA,UACP,QAAQ;AAAA,QACV;AAAA,QACA,OAAO;AAAA,UACL,IAAI;AAAA;AAAA;AAAA,UAGJ,YAAY;AAAA,UACZ,OAAO;AAAA,UACP,SAAS;AAAA,UACT,OAAO;AAAA,UACP,QAAQ;AAAA,UACR,gBAAgB;AAAA,UAChB,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,QAAQ;AAAA,UACR,QAAQ;AAAA,UACR,OAAO;AAAA,UACP,WAAW;AAAA,QACb;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EACA,SAAS,CAAC;AACZ;AAEA,IAAO,iCAAQ;;;ACtCf,IAAO,0BAAQ;","names":[]}
1
+ {"version":3,"sources":["../../src/styles/tailwind-preset.shared.js","../../src/styles/tailwind-preset.ts"],"sourcesContent":["const studioPreset = {\n theme: {\n extend: {\n colors: {\n studio: {\n bg: \"#0a0a0a\",\n surface: \"#141414\",\n border: \"#262626\",\n text: \"#e5e5e5\",\n muted: \"#737373\",\n accent: \"#3CE6AC\",\n },\n panel: {\n bg: \"#0C0C0E\",\n // Open inspector-section body — slightly lighter than headers (bg)\n // so the recessed scrollable region reads distinct.\n \"bg-inset\": \"#121214\",\n input: \"#161618\",\n surface: \"#18181B\",\n hover: \"#27272A\",\n border: \"#1E1E1E\",\n \"border-input\": \"#27272A\",\n hairline: \"#1A1A1C\",\n \"text-0\": \"#FAFAFA\",\n \"text-1\": \"#E4E4E7\",\n \"text-2\": \"#A1A1AA\",\n \"text-3\": \"#71717A\",\n \"text-4\": \"#52525B\",\n \"text-5\": \"#3F3F46\",\n accent: \"#3CE6AC\",\n danger: \"#EF4444\",\n media: \"#00E3FF\",\n container: \"#F5A623\",\n },\n },\n },\n },\n plugins: [],\n};\n\nexport default studioPreset;\n","import studioPreset from \"./tailwind-preset.shared.js\";\n\nexport default studioPreset;\n"],"mappings":";;;AAAA,IAAM,eAAe;AAAA,EACnB,OAAO;AAAA,IACL,QAAQ;AAAA,MACN,QAAQ;AAAA,QACN,QAAQ;AAAA,UACN,IAAI;AAAA,UACJ,SAAS;AAAA,UACT,QAAQ;AAAA,UACR,MAAM;AAAA,UACN,OAAO;AAAA,UACP,QAAQ;AAAA,QACV;AAAA,QACA,OAAO;AAAA,UACL,IAAI;AAAA;AAAA;AAAA,UAGJ,YAAY;AAAA,UACZ,OAAO;AAAA,UACP,SAAS;AAAA,UACT,OAAO;AAAA,UACP,QAAQ;AAAA,UACR,gBAAgB;AAAA,UAChB,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,QAAQ;AAAA,UACR,QAAQ;AAAA,UACR,OAAO;AAAA,UACP,WAAW;AAAA,QACb;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EACA,SAAS,CAAC;AACZ;AAEA,IAAO,iCAAQ;;;ACtCf,IAAO,0BAAQ;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.62",
3
+ "version": "0.7.64",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.62",
50
- "@hyperframes/player": "0.7.62",
51
- "@hyperframes/studio-server": "0.7.62",
52
- "@hyperframes/parsers": "0.7.62",
53
- "@hyperframes/sdk": "0.7.62"
49
+ "@hyperframes/core": "0.7.64",
50
+ "@hyperframes/player": "0.7.64",
51
+ "@hyperframes/parsers": "0.7.64",
52
+ "@hyperframes/studio-server": "0.7.64",
53
+ "@hyperframes/sdk": "0.7.64"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.62"
68
+ "@hyperframes/producer": "0.7.64"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
@@ -77,6 +77,7 @@
77
77
  "build": "vite build && tsup",
78
78
  "typecheck": "tsc --noEmit",
79
79
  "test": "vitest run",
80
- "test:watch": "vitest"
80
+ "test:watch": "vitest",
81
+ "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts"
81
82
  }
82
83
  }
@@ -87,6 +87,12 @@ export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
87
87
  false,
88
88
  );
89
89
 
90
+ /** Explicit per-operation-family canary selection; the master flag alone enables nothing. */
91
+ export const STUDIO_SDK_CUTOVER_FAMILIES = resolveEnabledSdkFamilies(
92
+ env,
93
+ STUDIO_SDK_CUTOVER_ENABLED,
94
+ );
95
+
90
96
  // Resolver-parity tripwire (telemetry-only, decoupled from cutover).
91
97
  // Runs the SDK resolver alongside any edit and emits sdk_resolver_shadow on
92
98
  // divergence. Default true; disable via VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED=false.
@@ -109,3 +115,4 @@ export const STUDIO_FLAT_INSPECTOR_ENABLED = resolveStudioBooleanEnvFlag(
109
115
  );
110
116
 
111
117
  export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
118
+ import { resolveEnabledSdkFamilies } from "../../utils/sdkCutoverPolicy";
@@ -5,7 +5,7 @@ export {
5
5
  STUDIO_WIDTH_PROP,
6
6
  STUDIO_HEIGHT_PROP,
7
7
  STUDIO_MANUAL_EDIT_GESTURE_ATTR,
8
- } from "@hyperframes/studio-server/draft-markers";
8
+ } from "@hyperframes/core/editing/draft-markers";
9
9
  export const STUDIO_ROTATION_PROP = "--hf-studio-rotation";
10
10
 
11
11
  /* ── Internal DOM attribute names ─────────────────────────────────── */
@@ -142,6 +142,32 @@ describe("applyTimelineStackingReorder", () => {
142
142
  });
143
143
 
144
144
  describe("patchIframeDomTiming", () => {
145
+ it("patches a top-level composition host in its parent source file", () => {
146
+ const iframe = makeIframeWith(`
147
+ <div data-composition-id="root">
148
+ <div
149
+ id="scene-host"
150
+ data-composition-id="scene"
151
+ data-composition-src="compositions/scene.html"
152
+ data-start="2"
153
+ ></div>
154
+ </div>
155
+ `);
156
+ const host = iframe.contentDocument?.getElementById("scene-host");
157
+ const target = el({
158
+ id: "scene-host",
159
+ tag: "div",
160
+ kind: "composition",
161
+ domId: "scene-host",
162
+ sourceFile: "index.html",
163
+ compositionSrc: "compositions/scene.html",
164
+ });
165
+
166
+ patchIframeDomTiming(iframe, target, [["data-start", "9"]], "index.html");
167
+
168
+ expect(host?.getAttribute("data-start")).toBe("9");
169
+ });
170
+
145
171
  it("resolves selectorIndex within the element's source file", () => {
146
172
  const iframe = makeIframeWith(`
147
173
  <div data-composition-id="root">
@@ -13,7 +13,10 @@ import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/stud
13
13
  import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
14
14
  import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
15
15
  import { readFileContent } from "./timelineTimingSync";
16
- import { findElementForSelection } from "../components/editor/domEditingElement";
16
+ import {
17
+ findElementForSelection,
18
+ findElementForTimelineElement,
19
+ } from "../components/editor/domEditingElement";
17
20
  export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes";
18
21
  export { readFileContent };
19
22
  function isHTMLElement(element: Element | null): element is HTMLElement {
@@ -137,6 +140,12 @@ export function findTimelineElementInIframe(
137
140
  try {
138
141
  const doc = iframe?.contentDocument;
139
142
  if (!doc) return null;
143
+ if (element.kind === "composition" && element.compositionSrc) {
144
+ return findElementForTimelineElement(doc, element, {
145
+ activeCompositionPath,
146
+ isMasterView: true,
147
+ });
148
+ }
140
149
  return findElementForSelection(
141
150
  doc,
142
151
  {
@@ -11,8 +11,6 @@ export { resolveIframe } from "./lib/timelineDOM";
11
11
 
12
12
  // Store
13
13
  export { usePlayerStore, liveTime } from "./store/playerStore";
14
- // Public library surface; external consumers are invisible to the workspace analyzer.
15
- // fallow-ignore-next-line unused-exports
16
14
  export type { SelectElementOptions, TimelineElement, ZoomMode } from "./store/playerStore";
17
15
 
18
16
  // Utils
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("../components/editor/manualEditingAvailability", () => ({
4
+ STUDIO_SDK_CUTOVER_ENABLED: true,
5
+ STUDIO_SDK_CUTOVER_FAMILIES: new Set(["timing"]),
6
+ STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
7
+ }));
8
+ vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
9
+
10
+ import {
11
+ sdkCutoverPersist,
12
+ sdkDeletePersist,
13
+ sdkGsapTweenPersist,
14
+ sdkTimingPersist,
15
+ } from "./sdkCutover";
16
+
17
+ const deps = {
18
+ editHistory: { recordEdit: vi.fn() },
19
+ writeProjectFile: vi.fn(),
20
+ reloadPreview: vi.fn(),
21
+ domEditSaveTimestampRef: { current: 0 },
22
+ } as never;
23
+
24
+ describe("SDK family gate mapping", () => {
25
+ it("enables timing independently while declining other operation families", async () => {
26
+ await expect(
27
+ sdkTimingPersist("hf", "/c.html", { start: 1 }, null, deps),
28
+ ).resolves.toMatchObject({ status: "declined", reason: "session_unavailable" });
29
+ await expect(
30
+ sdkGsapTweenPersist("/c.html", { kind: "remove", animationId: "a" }, null, deps),
31
+ ).resolves.toMatchObject({ status: "declined", reason: "feature_disabled" });
32
+ await expect(sdkDeletePersist("hf", "before", "/c.html", null, deps)).resolves.toMatchObject({
33
+ status: "declined",
34
+ reason: "feature_disabled",
35
+ });
36
+ await expect(
37
+ sdkCutoverPersist(
38
+ { hfId: "hf" } as never,
39
+ [{ type: "inline-style", property: "color", value: "red" }],
40
+ "before",
41
+ "/c.html",
42
+ {} as never,
43
+ deps,
44
+ ),
45
+ ).resolves.toMatchObject({ status: "declined", reason: "ineligible_operation" });
46
+ });
47
+ });
@@ -1,7 +1,7 @@
1
1
  import type { Composition, GsapTweenSpec } from "@hyperframes/sdk";
2
2
  import type { DomEditSelection } from "../components/editor/domEditing";
3
3
  import type { PatchOperation } from "./sourcePatcher";
4
- import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability";
4
+ import * as studioAvailability from "../components/editor/manualEditingAvailability";
5
5
  import { trackStudioEvent } from "./studioTelemetry";
6
6
  import { patchOpsToSdkEditOps } from "./sdkOpMapping";
7
7
  import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
@@ -14,6 +14,7 @@ import {
14
14
  type CutoverOptions,
15
15
  type CutoverResult,
16
16
  } from "./sdkEditTransaction";
17
+ import { isSdkFamilyEnabled, type StudioSdkOperationFamily } from "./sdkCutoverPolicy";
17
18
 
18
19
  export { shouldUseSdkCutover } from "./sdkCutoverEligibility";
19
20
  export {
@@ -28,6 +29,27 @@ export type {
28
29
  PublishSdkSession,
29
30
  } from "./sdkEditTransaction";
30
31
 
32
+ function sdkFamilyEnabled(family: StudioSdkOperationFamily): boolean {
33
+ const configured = Object.prototype.hasOwnProperty.call(
34
+ studioAvailability,
35
+ "STUDIO_SDK_CUTOVER_FAMILIES",
36
+ )
37
+ ? studioAvailability.STUDIO_SDK_CUTOVER_FAMILIES
38
+ : undefined;
39
+ return isSdkFamilyEnabled(studioAvailability.STUDIO_SDK_CUTOVER_ENABLED, configured, family);
40
+ }
41
+
42
+ function trackCutoverResult(
43
+ result: CutoverResult,
44
+ context: { hfId?: string | null; opCount: number },
45
+ ): void {
46
+ if (result.status === "committed") {
47
+ trackStudioEvent("sdk_cutover_success", context);
48
+ } else if (result.status === "failed") {
49
+ trackStudioEvent("sdk_cutover_failed", { ...context, error: result.error.message });
50
+ }
51
+ }
52
+
31
53
  /** True when targetPath isn't the composition the SDK session models. */
32
54
  function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean {
33
55
  return deps.compositionPath != null && targetPath !== deps.compositionPath;
@@ -55,7 +77,7 @@ export async function sdkCutoverPersist(
55
77
  deps: CutoverDeps,
56
78
  options?: CutoverOptions,
57
79
  ): Promise<CutoverResult> {
58
- if (!shouldUseSdkCutover(STUDIO_SDK_CUTOVER_ENABLED, !!sdkSession, selection.hfId, ops))
80
+ if (!shouldUseSdkCutover(sdkFamilyEnabled("dom"), !!sdkSession, selection.hfId, ops))
59
81
  return declinedCutover("ineligible_operation");
60
82
  if (!sdkSession) return declinedCutover("session_unavailable");
61
83
  const hfId = selection.hfId;
@@ -75,11 +97,7 @@ export async function sdkCutoverPersist(
75
97
  },
76
98
  options,
77
99
  );
78
- if (result.status === "committed") {
79
- trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length });
80
- } else if (result.status === "failed") {
81
- trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message });
82
- }
100
+ trackCutoverResult(result, { hfId, opCount: ops.length });
83
101
  return result;
84
102
  }
85
103
 
@@ -104,7 +122,7 @@ export async function sdkTimingPersist(
104
122
  // Dark-launch gate: without this, timing cutover runs whenever an SDK session
105
123
  // exists (it always does, for shadow/selection) — flipping the flag OFF would
106
124
  // NOT disable it. Gate here so flag-off routes back to the legacy server path.
107
- if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
125
+ if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled");
108
126
  if (!sdkSession) return declinedCutover("session_unavailable");
109
127
  if (!sdkSession.getElement(hfId)) return declinedCutover("target_not_found");
110
128
  if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
@@ -119,11 +137,7 @@ export async function sdkTimingPersist(
119
137
  options,
120
138
  serializedBefore,
121
139
  );
122
- if (result.status === "committed") {
123
- trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
124
- } else if (result.status === "failed") {
125
- trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message });
126
- }
140
+ trackCutoverResult(result, { hfId, opCount: 1 });
127
141
  return result;
128
142
  } catch (error) {
129
143
  const failed = { status: "failed", error: asCutoverError(error) } as const;
@@ -152,7 +166,7 @@ export async function sdkTimingBatchPersist(
152
166
  { targetPath, compositionPath: deps.compositionPath },
153
167
  );
154
168
  }
155
- if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
169
+ if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled");
156
170
  if (!sdkSession) return declinedCutover("session_unavailable");
157
171
  if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
158
172
  if (changes.some((change) => !sdkSession.getElement(change.hfId)))
@@ -229,13 +243,14 @@ export function sdkGsapTweenPersist(
229
243
  }
230
244
  // Leading dark-launch gate so flag-off does no SDK touch (getElement) at all —
231
245
  // matches the other three chokepoints' discipline.
232
- if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(declinedCutover("feature_disabled"));
246
+ if (!sdkFamilyEnabled("gsap-animation"))
247
+ return Promise.resolve(declinedCutover("feature_disabled"));
233
248
  if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target))
234
249
  return Promise.resolve(declinedCutover("target_not_found"));
235
250
  // dispatchGsapOpAndPersist declines on before===after — that catches stale
236
251
  // animationIds and unsupported shapes (e.g. from-prop on a plain tween), falling
237
252
  // back to the server path. This subsumes explicit existence guards for set/remove.
238
- return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => {
253
+ return dispatchGsapOpAndPersist("gsap-animation", targetPath, sdkSession, deps, options, (s) => {
239
254
  s.batch(() => {
240
255
  if (op.kind === "add") {
241
256
  s.addGsapTween(op.target, op.spec);
@@ -249,6 +264,7 @@ export function sdkGsapTweenPersist(
249
264
  }
250
265
 
251
266
  async function dispatchGsapOpAndPersist(
267
+ family: "gsap-animation" | "gsap-keyframe",
252
268
  targetPath: string,
253
269
  sdkSession: Composition | null | undefined,
254
270
  deps: CutoverDeps,
@@ -269,7 +285,7 @@ async function dispatchGsapOpAndPersist(
269
285
  }
270
286
  // Dark-launch gate (shared chokepoint for every GSAP-op cutover persist):
271
287
  // flag OFF → explicit decline → caller falls back to the legacy server path.
272
- if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
288
+ if (!sdkFamilyEnabled(family)) return declinedCutover("feature_disabled");
273
289
  if (!sdkSession) return declinedCutover("session_unavailable");
274
290
  if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
275
291
  const session = sdkSession;
@@ -309,6 +325,7 @@ export function sdkGsapKeyframePersist(
309
325
  options?: CutoverOptions,
310
326
  ): Promise<CutoverResult> {
311
327
  return dispatchGsapOpAndPersist(
328
+ "gsap-keyframe",
312
329
  targetPath,
313
330
  sdkSession,
314
331
  deps,
@@ -327,6 +344,7 @@ export function sdkGsapRemoveKeyframePersist(
327
344
  options?: CutoverOptions,
328
345
  ): Promise<CutoverResult> {
329
346
  return dispatchGsapOpAndPersist(
347
+ "gsap-keyframe",
330
348
  targetPath,
331
349
  sdkSession,
332
350
  deps,
@@ -346,6 +364,7 @@ export function sdkGsapRemovePropertyPersist(
346
364
  options?: CutoverOptions,
347
365
  ): Promise<CutoverResult> {
348
366
  return dispatchGsapOpAndPersist(
367
+ "gsap-animation",
349
368
  targetPath,
350
369
  sdkSession,
351
370
  deps,
@@ -362,7 +381,7 @@ export function sdkGsapDeleteAllForSelectorPersist(
362
381
  deps: CutoverDeps,
363
382
  options?: CutoverOptions,
364
383
  ): Promise<CutoverResult> {
365
- return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
384
+ return dispatchGsapOpAndPersist("gsap-animation", targetPath, sdkSession, deps, options, (s) =>
366
385
  s.dispatch({ type: "deleteAllForSelector", selector }),
367
386
  );
368
387
  }
@@ -375,6 +394,7 @@ export function sdkGsapRemoveAllKeyframesPersist(
375
394
  options?: CutoverOptions,
376
395
  ): Promise<CutoverResult> {
377
396
  return dispatchGsapOpAndPersist(
397
+ "gsap-keyframe",
378
398
  targetPath,
379
399
  sdkSession,
380
400
  deps,
@@ -393,6 +413,7 @@ export function sdkGsapConvertToKeyframesPersist(
393
413
  options?: CutoverOptions,
394
414
  ): Promise<CutoverResult> {
395
415
  return dispatchGsapOpAndPersist(
416
+ "gsap-keyframe",
396
417
  targetPath,
397
418
  sdkSession,
398
419
  deps,
@@ -417,6 +438,16 @@ type KeyframesPayload = {
417
438
  ease?: string;
418
439
  };
419
440
 
441
+ function keyframesPayload(
442
+ targetSelector: string,
443
+ position: number,
444
+ duration: number,
445
+ keyframes: KeyframeSpec[],
446
+ ease: string | undefined,
447
+ ): KeyframesPayload {
448
+ return { targetSelector, position, duration, keyframes, ...(ease ? { ease } : {}) };
449
+ }
450
+
420
451
  /** Shared inner dispatch for addWithKeyframes / replaceWithKeyframes ops. */
421
452
  function dispatchWithKeyframes(
422
453
  s: Composition,
@@ -430,6 +461,38 @@ function dispatchWithKeyframes(
430
461
  }
431
462
  }
432
463
 
464
+ function persistKeyframesOperation(input: {
465
+ targetPath: string;
466
+ targetSelector: string;
467
+ position: number;
468
+ duration: number;
469
+ keyframes: KeyframeSpec[];
470
+ ease: string | undefined;
471
+ sdkSession: Composition | null | undefined;
472
+ deps: CutoverDeps;
473
+ options?: CutoverOptions;
474
+ animationId?: string;
475
+ }): Promise<CutoverResult> {
476
+ const payload = keyframesPayload(
477
+ input.targetSelector,
478
+ input.position,
479
+ input.duration,
480
+ input.keyframes,
481
+ input.ease,
482
+ );
483
+ return dispatchGsapOpAndPersist(
484
+ "gsap-keyframe",
485
+ input.targetPath,
486
+ input.sdkSession,
487
+ input.deps,
488
+ input.options,
489
+ (session) => dispatchWithKeyframes(session, payload, input.animationId),
490
+ input.animationId
491
+ ? { animationId: input.animationId, opLabel: "replaceWithKeyframes" }
492
+ : undefined,
493
+ );
494
+ }
495
+
433
496
  export function sdkAddWithKeyframesPersist(
434
497
  targetPath: string,
435
498
  targetSelector: string,
@@ -441,16 +504,17 @@ export function sdkAddWithKeyframesPersist(
441
504
  deps: CutoverDeps,
442
505
  options?: CutoverOptions,
443
506
  ): Promise<CutoverResult> {
444
- const payload: KeyframesPayload = {
507
+ return persistKeyframesOperation({
508
+ targetPath,
445
509
  targetSelector,
446
510
  position,
447
511
  duration,
448
512
  keyframes,
449
- ...(ease ? { ease } : {}),
450
- };
451
- return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
452
- dispatchWithKeyframes(s, payload),
453
- );
513
+ ease,
514
+ sdkSession,
515
+ deps,
516
+ options,
517
+ });
454
518
  }
455
519
 
456
520
  export function sdkReplaceWithKeyframesPersist(
@@ -465,21 +529,18 @@ export function sdkReplaceWithKeyframesPersist(
465
529
  deps: CutoverDeps,
466
530
  options?: CutoverOptions,
467
531
  ): Promise<CutoverResult> {
468
- const payload: KeyframesPayload = {
532
+ return persistKeyframesOperation({
533
+ targetPath,
534
+ animationId,
469
535
  targetSelector,
470
536
  position,
471
537
  duration,
472
538
  keyframes,
473
- ...(ease ? { ease } : {}),
474
- };
475
- return dispatchGsapOpAndPersist(
476
- targetPath,
539
+ ease,
477
540
  sdkSession,
478
541
  deps,
479
542
  options,
480
- (s) => dispatchWithKeyframes(s, payload, animationId),
481
- { animationId, opLabel: "replaceWithKeyframes" },
482
- );
543
+ });
483
544
  }
484
545
 
485
546
  export async function sdkDeletePersist(
@@ -498,7 +559,7 @@ export async function sdkDeletePersist(
498
559
  { targetPath, compositionPath: deps.compositionPath },
499
560
  );
500
561
  // Dark-launch gate: flag OFF → legacy server delete path.
501
- if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
562
+ if (!sdkFamilyEnabled("lifecycle")) return declinedCutover("feature_disabled");
502
563
  if (!sdkSession) return declinedCutover("session_unavailable");
503
564
  if (!sdkSession.getElement(hfId)) return declinedCutover("target_not_found");
504
565
  if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
@@ -510,10 +571,6 @@ export async function sdkDeletePersist(
510
571
  (session) => session.removeElement(hfId),
511
572
  { label: "Delete element" },
512
573
  );
513
- if (result.status === "committed") {
514
- trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
515
- } else if (result.status === "failed") {
516
- trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message });
517
- }
574
+ trackCutoverResult(result, { hfId, opCount: 1 });
518
575
  return result;
519
576
  }
@@ -0,0 +1,3 @@
1
+ import { renderStudioSdkCutoverReport } from "./sdkCutoverPolicy";
2
+
3
+ process.stdout.write(renderStudioSdkCutoverReport());
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ STUDIO_SDK_OPERATION_FAMILIES,
4
+ isSdkFamilyEnabled,
5
+ renderStudioSdkCutoverReport,
6
+ resolveEnabledSdkFamilies,
7
+ } from "./sdkCutoverPolicy";
8
+
9
+ describe("Studio SDK operation-family policy", () => {
10
+ it("enables no family from the master switch alone", () => {
11
+ expect(resolveEnabledSdkFamilies({}, true).size).toBe(0);
12
+ expect(resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom" }, false).size).toBe(
13
+ 0,
14
+ );
15
+ });
16
+
17
+ it("enables only explicitly selected families", () => {
18
+ const enabled = resolveEnabledSdkFamilies(
19
+ { VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom, gsap-keyframe" },
20
+ true,
21
+ );
22
+ expect(isSdkFamilyEnabled(true, enabled, "dom")).toBe(true);
23
+ expect(isSdkFamilyEnabled(true, enabled, "gsap-keyframe")).toBe(true);
24
+ expect(isSdkFamilyEnabled(true, enabled, "timing")).toBe(false);
25
+ });
26
+
27
+ it("rejects misspelled family configuration", () => {
28
+ expect(() =>
29
+ resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom,gsap-twen" }, true),
30
+ ).toThrow("Unknown Studio SDK cutover families: gsap-twen");
31
+ });
32
+
33
+ it("reports every family with owner, deadline, evidence, and graduation state", () => {
34
+ const report = renderStudioSdkCutoverReport();
35
+ for (const family of STUDIO_SDK_OPERATION_FAMILIES) expect(report).toContain(`| ${family} |`);
36
+ expect(report).toContain("Graduated");
37
+ });
38
+ });
@@ -0,0 +1,98 @@
1
+ export const STUDIO_SDK_OPERATION_FAMILIES = [
2
+ "dom",
3
+ "timing",
4
+ "gsap-animation",
5
+ "gsap-keyframe",
6
+ "lifecycle",
7
+ ] as const;
8
+
9
+ export type StudioSdkOperationFamily = (typeof STUDIO_SDK_OPERATION_FAMILIES)[number];
10
+
11
+ interface StudioSdkFamilyStatus {
12
+ owner: string;
13
+ deadline: string;
14
+ parityEvidence: string;
15
+ graduated: boolean;
16
+ }
17
+
18
+ const STUDIO_SDK_FAMILY_STATUS = {
19
+ dom: {
20
+ owner: "studio-foundations",
21
+ deadline: "2026-09-30",
22
+ parityEvidence: "sdkCutoverParity.test.ts",
23
+ graduated: false,
24
+ },
25
+ timing: {
26
+ owner: "studio-foundations",
27
+ deadline: "2026-10-15",
28
+ parityEvidence: "sdkCutover.test.ts timing corpus",
29
+ graduated: false,
30
+ },
31
+ "gsap-animation": {
32
+ owner: "studio-foundations",
33
+ deadline: "2026-10-31",
34
+ parityEvidence: "sdkCutover.test.ts GSAP animation corpus",
35
+ graduated: false,
36
+ },
37
+ "gsap-keyframe": {
38
+ owner: "studio-foundations",
39
+ deadline: "2026-11-15",
40
+ parityEvidence: "sdkCutover.test.ts GSAP keyframe corpus",
41
+ graduated: false,
42
+ },
43
+ lifecycle: {
44
+ owner: "studio-foundations",
45
+ deadline: "2026-11-30",
46
+ parityEvidence: "sdkCutover.test.ts lifecycle corpus",
47
+ graduated: false,
48
+ },
49
+ } as const satisfies Record<StudioSdkOperationFamily, StudioSdkFamilyStatus>;
50
+
51
+ export function resolveEnabledSdkFamilies(
52
+ env: Record<string, boolean | string | undefined>,
53
+ masterEnabled: boolean,
54
+ ): ReadonlySet<StudioSdkOperationFamily> {
55
+ if (!masterEnabled) return new Set();
56
+ const raw = env["VITE_STUDIO_SDK_CUTOVER_FAMILIES"];
57
+ if (typeof raw !== "string" || raw.trim() === "") return new Set();
58
+ const requested = raw
59
+ .split(",")
60
+ .map((family) => family.trim())
61
+ .filter(Boolean);
62
+ const known = new Set<string>(STUDIO_SDK_OPERATION_FAMILIES);
63
+ const unknown = requested.filter((family) => !known.has(family));
64
+ if (unknown.length > 0) {
65
+ throw new Error(`Unknown Studio SDK cutover families: ${unknown.join(", ")}`);
66
+ }
67
+ return new Set(requested as StudioSdkOperationFamily[]);
68
+ }
69
+
70
+ export function isSdkFamilyEnabled(
71
+ masterEnabled: boolean,
72
+ configured: ReadonlySet<StudioSdkOperationFamily> | undefined,
73
+ family: StudioSdkOperationFamily,
74
+ ): boolean {
75
+ // `undefined` preserves compatibility for isolated tests/embedders that mock
76
+ // only the historical master flag. Production always supplies an explicit set.
77
+ return masterEnabled && (configured?.has(family) ?? true);
78
+ }
79
+
80
+ export function renderStudioSdkCutoverReport(): string {
81
+ const rows = STUDIO_SDK_OPERATION_FAMILIES.map((family) => {
82
+ const status = STUDIO_SDK_FAMILY_STATUS[family];
83
+ return `| ${family} | ${status.owner} | ${status.deadline} | ${status.parityEvidence} | ${status.graduated ? "yes" : "no"} |`;
84
+ });
85
+ return [
86
+ "# Studio SDK cutover report",
87
+ "",
88
+ "Master flag: `VITE_STUDIO_SDK_CUTOVER_ENABLED=true`",
89
+ "Family flag: `VITE_STUDIO_SDK_CUTOVER_FAMILIES=dom,timing,...`",
90
+ "",
91
+ "A family graduates only after zero unexplained resolver/serialization divergences over its agreed corpus and soak window.",
92
+ "",
93
+ "| Family | Owner | Deadline | Parity evidence | Graduated |",
94
+ "| --- | --- | --- | --- | --- |",
95
+ ...rows,
96
+ "",
97
+ ].join("\n");
98
+ }