@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.
- package/dist/assets/{hyperframes-player-CWpmBjzv.js → hyperframes-player-D4jpio27.js} +1 -1
- package/dist/assets/{index-vDRVQtTK.js → index-BOMWJSjh.js} +175 -175
- package/dist/assets/{index-BJzSiIBj.js → index-CqjWmIbE.js} +1 -1
- package/dist/assets/{index-DJQPB78e.js → index-srObT-DA.js} +1 -1
- package/dist/chunk-PZ5AY32C.js +10 -0
- package/dist/{domEditingLayers-2CKWGEHS.js → domEditingLayers-AT7G6F4L.js} +2 -1
- package/dist/domEditingLayers-AT7G6F4L.js.map +1 -0
- package/dist/index.html +1 -1
- package/dist/index.js +121 -42
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.js +2 -0
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +9 -8
- package/src/components/editor/manualEditingAvailability.ts +7 -0
- package/src/components/editor/manualEditsTypes.ts +1 -1
- package/src/hooks/timelineEditingHelpers.test.ts +26 -0
- package/src/hooks/timelineEditingHelpers.ts +10 -1
- package/src/player/index.ts +0 -2
- package/src/utils/sdkCutover.familyGate.test.ts +47 -0
- package/src/utils/sdkCutover.ts +95 -38
- package/src/utils/sdkCutoverPolicy.report.ts +3 -0
- package/src/utils/sdkCutoverPolicy.test.ts +38 -0
- package/src/utils/sdkCutoverPolicy.ts +98 -0
- /package/dist/{domEditingLayers-2CKWGEHS.js.map → chunk-PZ5AY32C.js.map} +0 -0
|
@@ -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":"
|
|
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.
|
|
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.
|
|
50
|
-
"@hyperframes/player": "0.7.
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/sdk": "0.7.
|
|
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.
|
|
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/
|
|
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 {
|
|
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
|
{
|
package/src/player/index.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/utils/sdkCutover.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
507
|
+
return persistKeyframesOperation({
|
|
508
|
+
targetPath,
|
|
445
509
|
targetSelector,
|
|
446
510
|
position,
|
|
447
511
|
duration,
|
|
448
512
|
keyframes,
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
532
|
+
return persistKeyframesOperation({
|
|
533
|
+
targetPath,
|
|
534
|
+
animationId,
|
|
469
535
|
targetSelector,
|
|
470
536
|
position,
|
|
471
537
|
duration,
|
|
472
538
|
keyframes,
|
|
473
|
-
|
|
474
|
-
};
|
|
475
|
-
return dispatchGsapOpAndPersist(
|
|
476
|
-
targetPath,
|
|
539
|
+
ease,
|
|
477
540
|
sdkSession,
|
|
478
541
|
deps,
|
|
479
542
|
options,
|
|
480
|
-
|
|
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 (!
|
|
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
|
-
|
|
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,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
|
+
}
|
|
File without changes
|