@hyperframes/studio 0.8.45 → 0.8.47
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-B7fsB8C2.js +464 -0
- package/dist/assets/{index-BRhRJaev.js → index-B50xQfSG.js} +1 -1
- package/dist/assets/{index-C_TAjvrq.js → index-j-4hCrZU.js} +130 -130
- package/dist/assets/{index-DWQFIQjN.js → index-ryL7q1QG.js} +1 -1
- package/dist/index.d.ts +17 -3
- package/dist/index.html +1 -1
- package/dist/index.js +103 -40
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/manualEditingAvailability.test.ts +19 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -5
- package/src/hooks/useSdkSession.lifecycle.test.tsx +76 -0
- package/src/hooks/useSdkSession.ts +21 -3
- package/src/player/hooks/useTimelinePlayer.ts +9 -4
- package/src/player/hooks/useTimelineSyncCallbacks.ts +7 -4
- package/src/player/store/playerStore.test.ts +42 -0
- package/src/player/store/playerStore.ts +9 -7
- package/src/player/store/readinessSlice.ts +42 -0
- package/src/utils/sdkCutover.gate.test.ts +5 -4
- package/src/utils/sdkCutover.test.ts +42 -0
- package/src/utils/sdkCutover.ts +46 -25
- package/src/utils/sdkCutoverEligibility.ts +21 -0
- package/src/utils/sdkCutoverPolicy.test.ts +11 -2
- package/src/utils/sdkCutoverPolicy.ts +5 -3
- package/src/utils/sdkEditTransaction.decline.test.ts +46 -0
- package/src/utils/sdkEditTransaction.ts +22 -1
- package/src/utils/sdkResolverShadow.ts +10 -1
- package/dist/assets/hyperframes-player-DMYc4Y9a.js +0 -464
package/src/utils/sdkCutover.ts
CHANGED
|
@@ -5,7 +5,11 @@ import * as studioAvailability from "../components/editor/manualEditingAvailabil
|
|
|
5
5
|
import { trackStudioEvent } from "./studioTelemetry";
|
|
6
6
|
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
|
7
7
|
import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
isResolverDisagreement,
|
|
10
|
+
shouldDeclineTextCutoverForTarget,
|
|
11
|
+
shouldUseSdkCutover,
|
|
12
|
+
} from "./sdkCutoverEligibility";
|
|
9
13
|
import {
|
|
10
14
|
asCutoverError,
|
|
11
15
|
declinedCutover,
|
|
@@ -78,15 +82,17 @@ export async function sdkCutoverPersist(
|
|
|
78
82
|
options?: CutoverOptions,
|
|
79
83
|
): Promise<CutoverResult> {
|
|
80
84
|
if (!shouldUseSdkCutover(sdkFamilyEnabled("dom"), !!sdkSession, selection.hfId, ops))
|
|
81
|
-
return declinedCutover("ineligible_operation");
|
|
82
|
-
if (!sdkSession) return declinedCutover("session_unavailable");
|
|
85
|
+
return declinedCutover("ineligible_operation", "dom");
|
|
86
|
+
if (!sdkSession) return declinedCutover("session_unavailable", "dom");
|
|
83
87
|
const hfId = selection.hfId;
|
|
84
|
-
if (!hfId) return declinedCutover("target_unaddressable");
|
|
88
|
+
if (!hfId) return declinedCutover("target_unaddressable", "dom");
|
|
85
89
|
const target = sdkSession.getElement(hfId);
|
|
86
|
-
if (!target)
|
|
90
|
+
if (!target)
|
|
91
|
+
return declinedCutover("target_not_found", "dom", isResolverDisagreement(sdkSession, hfId));
|
|
87
92
|
if (shouldDeclineTextCutoverForTarget(target, ops))
|
|
88
|
-
return declinedCutover("unsupported_text_target");
|
|
89
|
-
if (wrongCompositionFile(deps, targetPath))
|
|
93
|
+
return declinedCutover("unsupported_text_target", "dom");
|
|
94
|
+
if (wrongCompositionFile(deps, targetPath))
|
|
95
|
+
return declinedCutover("wrong_composition_file", "dom");
|
|
90
96
|
const result = await persistSdkCandidateMutation(
|
|
91
97
|
sdkSession,
|
|
92
98
|
targetPath,
|
|
@@ -122,10 +128,12 @@ export async function sdkTimingPersist(
|
|
|
122
128
|
// Dark-launch gate: without this, timing cutover runs whenever an SDK session
|
|
123
129
|
// exists (it always does, for shadow/selection) — flipping the flag OFF would
|
|
124
130
|
// NOT disable it. Gate here so flag-off routes back to the legacy server path.
|
|
125
|
-
if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled");
|
|
126
|
-
if (!sdkSession) return declinedCutover("session_unavailable");
|
|
127
|
-
if (!sdkSession.getElement(hfId))
|
|
128
|
-
|
|
131
|
+
if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled", "timing");
|
|
132
|
+
if (!sdkSession) return declinedCutover("session_unavailable", "timing");
|
|
133
|
+
if (!sdkSession.getElement(hfId))
|
|
134
|
+
return declinedCutover("target_not_found", "timing", isResolverDisagreement(sdkSession, hfId));
|
|
135
|
+
if (wrongCompositionFile(deps, targetPath))
|
|
136
|
+
return declinedCutover("wrong_composition_file", "timing");
|
|
129
137
|
try {
|
|
130
138
|
const serializedBefore = sdkSession.serialize();
|
|
131
139
|
const result = await persistSdkCandidateMutation(
|
|
@@ -166,11 +174,17 @@ export async function sdkTimingBatchPersist(
|
|
|
166
174
|
{ targetPath, compositionPath: deps.compositionPath },
|
|
167
175
|
);
|
|
168
176
|
}
|
|
169
|
-
if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled");
|
|
170
|
-
if (!sdkSession) return declinedCutover("session_unavailable");
|
|
171
|
-
if (wrongCompositionFile(deps, targetPath))
|
|
172
|
-
|
|
173
|
-
|
|
177
|
+
if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled", "timing");
|
|
178
|
+
if (!sdkSession) return declinedCutover("session_unavailable", "timing");
|
|
179
|
+
if (wrongCompositionFile(deps, targetPath))
|
|
180
|
+
return declinedCutover("wrong_composition_file", "timing");
|
|
181
|
+
const unresolved = changes.find((change) => !sdkSession.getElement(change.hfId));
|
|
182
|
+
if (unresolved)
|
|
183
|
+
return declinedCutover(
|
|
184
|
+
"target_not_found",
|
|
185
|
+
"timing",
|
|
186
|
+
isResolverDisagreement(sdkSession, unresolved.hfId),
|
|
187
|
+
);
|
|
174
188
|
try {
|
|
175
189
|
const serializedBefore = sdkSession.serialize();
|
|
176
190
|
const result = await persistSdkCandidateMutation(
|
|
@@ -244,9 +258,9 @@ export function sdkGsapTweenPersist(
|
|
|
244
258
|
// Leading dark-launch gate so flag-off does no SDK touch (getElement) at all —
|
|
245
259
|
// matches the other three chokepoints' discipline.
|
|
246
260
|
if (!sdkFamilyEnabled("gsap-animation"))
|
|
247
|
-
return Promise.resolve(declinedCutover("feature_disabled"));
|
|
261
|
+
return Promise.resolve(declinedCutover("feature_disabled", "gsap-animation"));
|
|
248
262
|
if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target))
|
|
249
|
-
return Promise.resolve(declinedCutover("target_not_found"));
|
|
263
|
+
return Promise.resolve(declinedCutover("target_not_found", "gsap-animation"));
|
|
250
264
|
// dispatchGsapOpAndPersist declines on before===after — that catches stale
|
|
251
265
|
// animationIds and unsupported shapes (e.g. from-prop on a plain tween), falling
|
|
252
266
|
// back to the server path. This subsumes explicit existence guards for set/remove.
|
|
@@ -285,9 +299,10 @@ async function dispatchGsapOpAndPersist(
|
|
|
285
299
|
}
|
|
286
300
|
// Dark-launch gate (shared chokepoint for every GSAP-op cutover persist):
|
|
287
301
|
// flag OFF → explicit decline → caller falls back to the legacy server path.
|
|
288
|
-
if (!sdkFamilyEnabled(family)) return declinedCutover("feature_disabled");
|
|
289
|
-
if (!sdkSession) return declinedCutover("session_unavailable");
|
|
290
|
-
if (wrongCompositionFile(deps, targetPath))
|
|
302
|
+
if (!sdkFamilyEnabled(family)) return declinedCutover("feature_disabled", family);
|
|
303
|
+
if (!sdkSession) return declinedCutover("session_unavailable", family);
|
|
304
|
+
if (wrongCompositionFile(deps, targetPath))
|
|
305
|
+
return declinedCutover("wrong_composition_file", family);
|
|
291
306
|
const session = sdkSession;
|
|
292
307
|
// persistSdkCandidateMutation owns the shared per-project/file transaction
|
|
293
308
|
// coordinator used by both SDK and legacy GSAP writes.
|
|
@@ -559,10 +574,16 @@ export async function sdkDeletePersist(
|
|
|
559
574
|
{ targetPath, compositionPath: deps.compositionPath },
|
|
560
575
|
);
|
|
561
576
|
// Dark-launch gate: flag OFF → legacy server delete path.
|
|
562
|
-
if (!sdkFamilyEnabled("lifecycle")) return declinedCutover("feature_disabled");
|
|
563
|
-
if (!sdkSession) return declinedCutover("session_unavailable");
|
|
564
|
-
if (!sdkSession.getElement(hfId))
|
|
565
|
-
|
|
577
|
+
if (!sdkFamilyEnabled("lifecycle")) return declinedCutover("feature_disabled", "lifecycle");
|
|
578
|
+
if (!sdkSession) return declinedCutover("session_unavailable", "lifecycle");
|
|
579
|
+
if (!sdkSession.getElement(hfId))
|
|
580
|
+
return declinedCutover(
|
|
581
|
+
"target_not_found",
|
|
582
|
+
"lifecycle",
|
|
583
|
+
isResolverDisagreement(sdkSession, hfId),
|
|
584
|
+
);
|
|
585
|
+
if (wrongCompositionFile(deps, targetPath))
|
|
586
|
+
return declinedCutover("wrong_composition_file", "lifecycle");
|
|
566
587
|
const result = await persistSdkCandidateMutation(
|
|
567
588
|
sdkSession,
|
|
568
589
|
targetPath,
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* sdkCutover.ts (which hit the packages/studio 600-line filesize cap) — this
|
|
5
5
|
* block has no dependency on the persist/dispatch functions there.
|
|
6
6
|
*/
|
|
7
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
7
8
|
import type { PatchOperation } from "./sourcePatcher";
|
|
8
9
|
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety";
|
|
10
|
+
import { resolveSnapshot } from "./sdkResolverShadow";
|
|
9
11
|
|
|
10
12
|
const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
|
|
11
13
|
"inline-style",
|
|
@@ -102,6 +104,25 @@ export function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOpe
|
|
|
102
104
|
return tag !== null && NON_HTML_CHILD_TAGS.has(tag);
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
/**
|
|
108
|
+
* On a `target_not_found`, decide whether the miss is the known benign class
|
|
109
|
+
* (node genuinely absent from the session — nothing dispatch could have done)
|
|
110
|
+
* or a resolver disagreement: the shadow's broader resolver finds it, so
|
|
111
|
+
* dispatch could have made the edit and `getElement` refused it. The shadow
|
|
112
|
+
* event stays silent in that case, so this flag is the only signal for it.
|
|
113
|
+
*
|
|
114
|
+
* Diagnostic context only — it must never turn a clean decline into a throw, so
|
|
115
|
+
* a session shape without `getElements` reports "no disagreement" and the edit
|
|
116
|
+
* falls back exactly as it otherwise would have.
|
|
117
|
+
*/
|
|
118
|
+
export function isResolverDisagreement(session: Composition, hfId: string): boolean {
|
|
119
|
+
try {
|
|
120
|
+
return resolveSnapshot(session, hfId) !== null;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
105
126
|
export function shouldUseSdkCutover(
|
|
106
127
|
flagEnabled: boolean,
|
|
107
128
|
hasSession: boolean,
|
|
@@ -7,8 +7,17 @@ import {
|
|
|
7
7
|
} from "./sdkCutoverPolicy";
|
|
8
8
|
|
|
9
9
|
describe("Studio SDK operation-family policy", () => {
|
|
10
|
-
it("enables
|
|
11
|
-
|
|
10
|
+
it("enables every family when the master switch is on and no family list is set", () => {
|
|
11
|
+
const all = new Set(STUDIO_SDK_OPERATION_FAMILIES);
|
|
12
|
+
expect(resolveEnabledSdkFamilies({}, true)).toEqual(all);
|
|
13
|
+
expect(resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: "" }, true)).toEqual(all);
|
|
14
|
+
expect(resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: " " }, true)).toEqual(
|
|
15
|
+
all,
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("enables no family when the master switch is off, regardless of the family list", () => {
|
|
20
|
+
expect(resolveEnabledSdkFamilies({}, false).size).toBe(0);
|
|
12
21
|
expect(resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom" }, false).size).toBe(
|
|
13
22
|
0,
|
|
14
23
|
);
|
|
@@ -54,7 +54,8 @@ export function resolveEnabledSdkFamilies(
|
|
|
54
54
|
): ReadonlySet<StudioSdkOperationFamily> {
|
|
55
55
|
if (!masterEnabled) return new Set();
|
|
56
56
|
const raw = env["VITE_STUDIO_SDK_CUTOVER_FAMILIES"];
|
|
57
|
-
|
|
57
|
+
// Unset/blank = every family. The list exists to RESTRICT (kill switch per family).
|
|
58
|
+
if (typeof raw !== "string" || raw.trim() === "") return new Set(STUDIO_SDK_OPERATION_FAMILIES);
|
|
58
59
|
const requested = raw
|
|
59
60
|
.split(",")
|
|
60
61
|
.map((family) => family.trim())
|
|
@@ -85,8 +86,9 @@ export function renderStudioSdkCutoverReport(): string {
|
|
|
85
86
|
return [
|
|
86
87
|
"# Studio SDK cutover report",
|
|
87
88
|
"",
|
|
88
|
-
"
|
|
89
|
-
"
|
|
89
|
+
"Default: SDK persist ON for every family. Kill switches:",
|
|
90
|
+
" `VITE_STUDIO_SDK_CUTOVER_ENABLED=false` (all families → server path)",
|
|
91
|
+
" `VITE_STUDIO_SDK_CUTOVER_FAMILIES=dom,timing` (only listed families → SDK; rest → server)",
|
|
90
92
|
"",
|
|
91
93
|
"A family graduates only after zero unexplained resolver/serialization divergences over its agreed corpus and soak window.",
|
|
92
94
|
"",
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
4
|
+
|
|
5
|
+
import { trackStudioEvent } from "./studioTelemetry";
|
|
6
|
+
import { declinedCutover } from "./sdkEditTransaction";
|
|
7
|
+
|
|
8
|
+
describe("declinedCutover telemetry", () => {
|
|
9
|
+
it("emits sdk_cutover_declined with reason and family, and returns the declined result", () => {
|
|
10
|
+
const result = declinedCutover("target_not_found", "timing");
|
|
11
|
+
expect(result).toEqual({ status: "declined", reason: "target_not_found" });
|
|
12
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("sdk_cutover_declined", {
|
|
13
|
+
reason: "target_not_found",
|
|
14
|
+
family: "timing",
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("emits family:null when the decline happens before a family is known", () => {
|
|
19
|
+
declinedCutover("no_change");
|
|
20
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("sdk_cutover_declined", {
|
|
21
|
+
reason: "no_change",
|
|
22
|
+
family: null,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("omits resolverDisagreement when it is not set, and carries it when it is", () => {
|
|
27
|
+
declinedCutover("target_not_found", "timing");
|
|
28
|
+
expect(trackStudioEvent).toHaveBeenLastCalledWith("sdk_cutover_declined", {
|
|
29
|
+
reason: "target_not_found",
|
|
30
|
+
family: "timing",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
declinedCutover("target_not_found", "timing", true);
|
|
34
|
+
expect(trackStudioEvent).toHaveBeenLastCalledWith("sdk_cutover_declined", {
|
|
35
|
+
reason: "target_not_found",
|
|
36
|
+
family: "timing",
|
|
37
|
+
resolverDisagreement: true,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
declinedCutover("target_not_found", "timing", false);
|
|
41
|
+
expect(trackStudioEvent).toHaveBeenLastCalledWith("sdk_cutover_declined", {
|
|
42
|
+
reason: "target_not_found",
|
|
43
|
+
family: "timing",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -3,6 +3,7 @@ import type { EditHistoryKind } from "./editHistory";
|
|
|
3
3
|
import { hashContent, markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
|
|
4
4
|
import { trackStudioEvent } from "./studioTelemetry";
|
|
5
5
|
import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
|
|
6
|
+
import type { StudioSdkOperationFamily } from "./sdkCutoverPolicy";
|
|
6
7
|
|
|
7
8
|
export type CutoverResult =
|
|
8
9
|
| { status: "declined"; reason: string }
|
|
@@ -66,7 +67,27 @@ interface CandidateEdit {
|
|
|
66
67
|
after: string;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Explicit fall-back to the legacy server path. Emits `sdk_cutover_declined` so
|
|
72
|
+
* post-flip we can distinguish "SDK took the edit" from "SDK bowed out"; payload
|
|
73
|
+
* is reason + family only (no hfId / path / content).
|
|
74
|
+
*
|
|
75
|
+
* `resolverDisagreement` is the one extra field with diagnostic value beyond the
|
|
76
|
+
* reason itself: on a `target_not_found`, the shadow's `resolveSnapshot` (what
|
|
77
|
+
* dispatch resolves) found the element that `getElement` could not. The shadow
|
|
78
|
+
* event stays silent in that case, so without this flag the case is invisible —
|
|
79
|
+
* and it is an adoption loss (a dispatchable edit refused), not a missing node.
|
|
80
|
+
*/
|
|
81
|
+
export function declinedCutover(
|
|
82
|
+
reason: string,
|
|
83
|
+
family?: StudioSdkOperationFamily,
|
|
84
|
+
resolverDisagreement?: boolean,
|
|
85
|
+
): CutoverResult {
|
|
86
|
+
trackStudioEvent("sdk_cutover_declined", {
|
|
87
|
+
reason,
|
|
88
|
+
family: family ?? null,
|
|
89
|
+
...(resolverDisagreement ? { resolverDisagreement: true } : {}),
|
|
90
|
+
});
|
|
70
91
|
return { status: "declined", reason };
|
|
71
92
|
}
|
|
72
93
|
|
|
@@ -96,7 +96,16 @@ function countHfIdInSource(source: string, id: string): number {
|
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Resolvability the SDK DISPATCH path actually has (mirrors `resolveScoped`):
|
|
101
|
+
* exact scoped-path match, then canonical bare match, then first bare match.
|
|
102
|
+
*
|
|
103
|
+
* Exported so the cutover path can distinguish its two `target_not_found` cases:
|
|
104
|
+
* `Composition.getElement` is narrower (canonical-only for a bare id) than what
|
|
105
|
+
* `dispatch` resolves, so an element can be genuinely dispatchable yet still
|
|
106
|
+
* fail `getElement` — a resolver disagreement worth fixing, not a missing node.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveSnapshot(session: Composition, id: string): FlatEl | null {
|
|
100
109
|
const els = session.getElements();
|
|
101
110
|
const exact = els.find((el) => el.scopedId === id);
|
|
102
111
|
if (exact) return exact;
|