@hyperframes/studio 0.8.54 → 0.8.55

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.54",
3
+ "version": "0.8.55",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,11 +51,11 @@
51
51
  "dockview-react": "8.3.1",
52
52
  "gsap": "^3.13.0",
53
53
  "mediabunny": "^1.45.3",
54
- "@hyperframes/core": "0.8.54",
55
- "@hyperframes/parsers": "0.8.54",
56
- "@hyperframes/player": "0.8.54",
57
- "@hyperframes/studio-server": "0.8.54",
58
- "@hyperframes/sdk": "0.8.54"
54
+ "@hyperframes/core": "0.8.55",
55
+ "@hyperframes/sdk": "0.8.55",
56
+ "@hyperframes/parsers": "0.8.55",
57
+ "@hyperframes/studio-server": "0.8.55",
58
+ "@hyperframes/player": "0.8.55"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@tailwindcss/vite": "^4.3.3",
@@ -71,7 +71,7 @@
71
71
  "vite": "^6.4.2",
72
72
  "vitest": "^4.1.11",
73
73
  "zustand": "^5.0.0",
74
- "@hyperframes/producer": "0.8.54"
74
+ "@hyperframes/producer": "0.8.55"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "react": "19",
@@ -135,7 +135,7 @@ export async function materializeIfDynamic(
135
135
  void iframe;
136
136
  void commitMutation;
137
137
  void selection;
138
- throw new GsapEditBlockedError("source-uneditable");
138
+ throw new GsapEditBlockedError("source-uneditable", "geometry-unresolved-source");
139
139
  }
140
140
 
141
141
  // ── Drag → GSAP position math ──────────────────────────────────────────────
@@ -0,0 +1,115 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import { describe, expect, it } from "vitest";
3
+ import { buildStudioSaveFailureProperties } from "../utils/studioSaveDiagnostics";
4
+ import {
5
+ GsapEditBlockedError,
6
+ assertGsapEditPersisted,
7
+ directEditOutcomeForProperties,
8
+ } from "./gsapEditOutcome";
9
+
10
+ function blockDetailFor(error: unknown): unknown {
11
+ return buildStudioSaveFailureProperties({ source: "gsap_commit", error }).block_detail;
12
+ }
13
+
14
+ /** Minimal animation that writes `x`, so the editability check actually runs. */
15
+ function animation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
16
+ return {
17
+ id: "a1",
18
+ targetSelector: "#box",
19
+ method: "to",
20
+ position: 0,
21
+ properties: { x: 100 },
22
+ ...overrides,
23
+ } as GsapAnimation;
24
+ }
25
+
26
+ const POSITION = new Set(["x"]);
27
+
28
+ describe("a blocked GSAP edit names which of the nine causes it hit", () => {
29
+ // The three causes inside assertGsapAnimationDirectlyEditable share one
30
+ // message, so only the detail distinguishes them. Asserted through the
31
+ // exported wrapper because that is the path a real edit takes — and the
32
+ // wrapper used to rebuild the outcome from `reason` alone, dropping it.
33
+ it("reports a runtime-dynamic provenance", () => {
34
+ const outcome = directEditOutcomeForProperties(
35
+ [animation({ provenance: { kind: "runtime-dynamic" } })],
36
+ POSITION,
37
+ );
38
+ expect(outcome).toEqual({
39
+ status: "blocked",
40
+ reason: "source-uneditable",
41
+ detail: "provenance-runtime-dynamic",
42
+ });
43
+ });
44
+
45
+ it("reports unresolved keyframes separately from provenance", () => {
46
+ const outcome = directEditOutcomeForProperties(
47
+ [animation({ hasUnresolvedKeyframes: true })],
48
+ POSITION,
49
+ );
50
+ expect(outcome).toEqual({
51
+ status: "blocked",
52
+ reason: "source-uneditable",
53
+ detail: "unresolved-keyframes",
54
+ });
55
+ });
56
+
57
+ it("reports an unresolved selector separately from provenance", () => {
58
+ const outcome = directEditOutcomeForProperties(
59
+ [animation({ hasUnresolvedSelector: true })],
60
+ POSITION,
61
+ );
62
+ expect(outcome).toEqual({
63
+ status: "blocked",
64
+ reason: "source-uneditable",
65
+ detail: "unresolved-selector",
66
+ });
67
+ });
68
+
69
+ it("still routes a helper or loop to unroll, which has its own message", () => {
70
+ const outcome = directEditOutcomeForProperties(
71
+ [animation({ provenance: { kind: "loop" } })],
72
+ POSITION,
73
+ );
74
+ expect(outcome).toEqual({ status: "blocked", reason: "unroll-required", detail: undefined });
75
+ });
76
+
77
+ it("leaves a literal tween editable", () => {
78
+ expect(directEditOutcomeForProperties([animation()], POSITION)).toEqual({
79
+ status: "persisted",
80
+ });
81
+ });
82
+
83
+ it("carries the detail through a returned outcome, not just a thrown one", () => {
84
+ // gsapRuntimeBridge returns blocked outcomes rather than throwing.
85
+ expect(() =>
86
+ assertGsapEditPersisted({
87
+ status: "blocked",
88
+ reason: "source-uneditable",
89
+ detail: "no-position-tween",
90
+ }),
91
+ ).toThrow(
92
+ expect.objectContaining({ name: "GsapEditBlockedError", detail: "no-position-tween" }),
93
+ );
94
+ });
95
+
96
+ it("surfaces the detail on the telemetry payload", () => {
97
+ expect(
98
+ blockDetailFor(
99
+ new GsapEditBlockedError("source-uneditable", "live-rotation-no-source-tween"),
100
+ ),
101
+ ).toBe("live-rotation-no-source-tween");
102
+ });
103
+
104
+ it("omits the detail for a block that has no specific cause", () => {
105
+ expect(blockDetailFor(new GsapEditBlockedError("no-selector"))).toBeUndefined();
106
+ });
107
+
108
+ it("ignores a detail-shaped field on an error that is not a block", () => {
109
+ // Guards the name check: any error can carry a `detail`, and reporting a
110
+ // save failure's detail as a block cause would be a silent mislabel.
111
+ const impostor = Object.assign(new Error("network down"), { detail: "unresolved-selector" });
112
+ expect(blockDetailFor(impostor)).toBeUndefined();
113
+ expect(blockDetailFor("not an error")).toBeUndefined();
114
+ });
115
+ });
@@ -2,6 +2,27 @@ import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/
2
2
 
3
3
  export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
4
4
 
5
+ /**
6
+ * Which of the nine situations produced a block. The user-facing `reason` stays
7
+ * coarse — three messages — but "source-uneditable" alone covers nine distinct
8
+ * causes, and `edit_blocked` telemetry could not tell them apart. That matters
9
+ * because the copy ("This animation is computed at runtime") is only literally
10
+ * true for `provenance-runtime-dynamic`; the others are parser or source-match
11
+ * limits, where the animation may well be plain authored source.
12
+ *
13
+ * Telemetry only. Nothing branches on it.
14
+ */
15
+ export type GsapEditBlockDetail =
16
+ | "provenance-runtime-dynamic"
17
+ | "unresolved-keyframes"
18
+ | "unresolved-selector"
19
+ | "geometry-unresolved-source"
20
+ | "live-position-no-source-tween"
21
+ | "no-position-tween"
22
+ | "live-rotation-no-source-tween"
23
+ | "live-resize-no-source-tween"
24
+ | "zero-duration-tween";
25
+
5
26
  export type GsapEditOutcome =
6
27
  | {
7
28
  status: "persisted";
@@ -22,7 +43,7 @@ export type GsapEditOutcome =
22
43
  */
23
44
  ownsDragOffset?: boolean;
24
45
  }
25
- | { status: "blocked"; reason: GsapEditBlockReason };
46
+ | { status: "blocked"; reason: GsapEditBlockReason; detail?: GsapEditBlockDetail };
26
47
 
27
48
  const COPY: Record<GsapEditBlockReason, string> = {
28
49
  "no-selector": "This layer needs a stable selector before Studio can save the edit.",
@@ -32,25 +53,32 @@ const COPY: Record<GsapEditBlockReason, string> = {
32
53
  };
33
54
 
34
55
  export class GsapEditBlockedError extends Error {
35
- constructor(readonly reason: GsapEditBlockReason) {
56
+ constructor(
57
+ readonly reason: GsapEditBlockReason,
58
+ readonly detail?: GsapEditBlockDetail,
59
+ ) {
36
60
  super(COPY[reason]);
37
61
  this.name = "GsapEditBlockedError";
38
62
  }
39
63
  }
40
64
 
41
65
  export function assertGsapEditPersisted(outcome: GsapEditOutcome): void {
42
- if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason);
66
+ if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason, outcome.detail);
43
67
  }
44
68
 
45
69
  function assertGsapAnimationDirectlyEditable(animation: GsapAnimation): void {
46
70
  const editability = editabilityForProvenance(animation.provenance);
47
71
  if (editability === "unroll") throw new GsapEditBlockedError("unroll-required");
48
- if (
49
- editability === "source" ||
50
- animation.hasUnresolvedKeyframes ||
51
- animation.hasUnresolvedSelector
52
- ) {
53
- throw new GsapEditBlockedError("source-uneditable");
72
+ // Same message for all three, but they are different problems: only the first
73
+ // is genuinely a runtime-computed value.
74
+ if (editability === "source") {
75
+ throw new GsapEditBlockedError("source-uneditable", "provenance-runtime-dynamic");
76
+ }
77
+ if (animation.hasUnresolvedKeyframes) {
78
+ throw new GsapEditBlockedError("source-uneditable", "unresolved-keyframes");
79
+ }
80
+ if (animation.hasUnresolvedSelector) {
81
+ throw new GsapEditBlockedError("source-uneditable", "unresolved-selector");
54
82
  }
55
83
  }
56
84
 
@@ -84,7 +112,8 @@ export function directEditOutcomeForProperties(
84
112
  }
85
113
  return { status: "persisted" };
86
114
  } catch (error) {
87
- if (isGsapEditBlockedError(error)) return { status: "blocked", reason: error.reason };
115
+ if (isGsapEditBlockedError(error))
116
+ return { status: "blocked", reason: error.reason, detail: error.detail };
88
117
  throw error;
89
118
  }
90
119
  }
@@ -180,11 +180,15 @@ it("reuses the ownership parse instead of fetching a resolved size group twice",
180
180
  );
181
181
  });
182
182
 
183
- it("blocks when runtime size motion exists but the authored tween cannot be resolved", async () => {
183
+ function makeBoxSelection(): { el: HTMLElement; selection: DomEditSelection } {
184
184
  const el = document.createElement("div");
185
185
  el.id = "box";
186
186
  document.body.append(el);
187
- const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
187
+ return { el, selection: { id: "box", selector: "#box", element: el } as DomEditSelection };
188
+ }
189
+
190
+ it("blocks when runtime size motion exists but the authored tween cannot be resolved", async () => {
191
+ const { el, selection } = makeBoxSelection();
188
192
  const liveSizeTween = {
189
193
  targets: () => [el],
190
194
  vars: { width: 300, duration: 1 },
@@ -199,7 +203,42 @@ it("blocks when runtime size motion exists but the authored tween cannot be reso
199
203
 
200
204
  await expect(
201
205
  tryGsapResizeIntercept(selection, { width: 344, height: 344 }, [], iframe, commitMutation),
202
- ).resolves.toEqual({ status: "blocked", reason: "source-uneditable" });
206
+ ).resolves.toEqual({
207
+ status: "blocked",
208
+ reason: "source-uneditable",
209
+ detail: "live-resize-no-source-tween",
210
+ });
211
+ expect(commitMutation).not.toHaveBeenCalled();
212
+ });
213
+
214
+ it("blocks a tween with no positive duration instead of committing into it", async () => {
215
+ const { selection } = makeBoxSelection();
216
+ // A zero-duration `from` is not an instant hold (those are `set`/`to`), so it
217
+ // reaches the duration check: a resize has no timeline position to land on.
218
+ const zeroDurationTween = {
219
+ id: "#box-size",
220
+ targetSelector: "#box",
221
+ propertyGroup: "size",
222
+ method: "from",
223
+ position: 0,
224
+ duration: 0,
225
+ properties: { width: 150, height: 150 },
226
+ } as unknown as GsapAnimation;
227
+ const commitMutation = vi.fn();
228
+
229
+ await expect(
230
+ tryGsapResizeIntercept(
231
+ selection,
232
+ { width: 344, height: 344 },
233
+ [zeroDurationTween],
234
+ null,
235
+ commitMutation,
236
+ ),
237
+ ).resolves.toEqual({
238
+ status: "blocked",
239
+ reason: "source-uneditable",
240
+ detail: "zero-duration-tween",
241
+ });
203
242
  expect(commitMutation).not.toHaveBeenCalled();
204
243
  });
205
244
 
@@ -137,7 +137,12 @@ export async function tryGsapResizeIntercept(
137
137
  ? hasNonHoldTweenForElement(iframe, liveSelector, undefined, [...resizeProperties])
138
138
  : false;
139
139
  if (!anim && hasLiveResizeTween) {
140
- return { status: "blocked", reason: "source-uneditable" };
140
+ // Third twin of the position/rotation cases: a live tween with no source match.
141
+ return {
142
+ status: "blocked",
143
+ reason: "source-uneditable",
144
+ detail: "live-resize-no-source-tween",
145
+ };
141
146
  }
142
147
  logResize("intercept-enter", {
143
148
  hasScaleGroup,
@@ -194,7 +199,12 @@ export async function tryGsapResizeIntercept(
194
199
  }
195
200
 
196
201
  const tweenDuration = resolveTweenDuration(anim);
197
- if (tweenDuration <= 0) return { status: "blocked", reason: "source-uneditable" };
202
+ if (tweenDuration <= 0) {
203
+ // The tween exists in source but has no positive duration, so there is no
204
+ // timeline position at which a resize could land — a different cause from
205
+ // the live-tween-without-a-source-match cases above.
206
+ return { status: "blocked", reason: "source-uneditable", detail: "zero-duration-tween" };
207
+ }
198
208
 
199
209
  const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
200
210
  const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
@@ -94,7 +94,11 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
94
94
  vi.fn().mockResolvedValue([]),
95
95
  );
96
96
 
97
- expect(result).toEqual({ status: "blocked", reason: "source-uneditable" });
97
+ expect(result).toEqual({
98
+ status: "blocked",
99
+ reason: "source-uneditable",
100
+ detail: "live-position-no-source-tween",
101
+ });
98
102
  expect(commitMutation).not.toHaveBeenCalled();
99
103
  });
100
104
 
@@ -354,7 +358,11 @@ describe("tryGsapRotationIntercept — instant holds", () => {
354
358
  fakeIframe("puck-b", [liveRotation]),
355
359
  commitMutation,
356
360
  ),
357
- ).resolves.toEqual({ status: "blocked", reason: "source-uneditable" });
361
+ ).resolves.toEqual({
362
+ status: "blocked",
363
+ reason: "source-uneditable",
364
+ detail: "live-rotation-no-source-tween",
365
+ });
358
366
  expect(commitMutation).not.toHaveBeenCalled();
359
367
  });
360
368
 
@@ -156,7 +156,13 @@ async function preflightGsapDragIntercept(
156
156
  const hasLivePosition = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
157
157
 
158
158
  if (hasLivePosition && !posAnim) {
159
- return { status: "blocked", reason: "source-uneditable" };
159
+ // GSAP is visibly moving this element but the parser found no position
160
+ // tween for it — a source-match gap, not necessarily computed source.
161
+ return {
162
+ status: "blocked",
163
+ reason: "source-uneditable",
164
+ detail: "live-position-no-source-tween",
165
+ };
160
166
  }
161
167
  if (!posAnim && !writeTargetSelector(selection)) {
162
168
  return { status: "blocked", reason: "no-selector" };
@@ -268,7 +274,7 @@ export async function tryGsapDragIntercept(
268
274
  }
269
275
 
270
276
  if (!posAnim) {
271
- return { status: "blocked", reason: "source-uneditable" };
277
+ return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" };
272
278
  }
273
279
 
274
280
  // Verify the anim ID is still valid in the current file. The React-state
@@ -353,7 +359,12 @@ export async function tryGsapRotationIntercept(
353
359
  ? hasNonHoldTweenForElement(iframe, liveSelector, undefined, ROTATION_CHANNELS)
354
360
  : false;
355
361
  if (!anim && hasLiveRotationTween) {
356
- return { status: "blocked", reason: "source-uneditable" };
362
+ // Rotation twin of the position case above: live tween, no source match.
363
+ return {
364
+ status: "blocked",
365
+ reason: "source-uneditable",
366
+ detail: "live-rotation-no-source-tween",
367
+ };
357
368
  }
358
369
 
359
370
  // `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
@@ -165,9 +165,21 @@ export function buildStudioSaveFailureProperties(
165
165
  target_id: input.targetId ?? undefined,
166
166
  target_selector: input.targetSelector ?? undefined,
167
167
  target_source_file: input.targetSourceFile ?? undefined,
168
+ block_detail: gsapBlockDetail(input.error),
168
169
  };
169
170
  }
170
171
 
172
+ /**
173
+ * The specific cause behind a blocked GSAP edit. `error_message` only carries
174
+ * the coarse copy, and one of those strings covers six different situations,
175
+ * so the message alone cannot say which one a user hit.
176
+ */
177
+ function gsapBlockDetail(error: unknown): string | undefined {
178
+ if (!(error instanceof Error) || error.name !== "GsapEditBlockedError") return undefined;
179
+ const detail = (error as { detail?: unknown }).detail;
180
+ return typeof detail === "string" ? detail : undefined;
181
+ }
182
+
171
183
  export function trackStudioSaveFailure(input: StudioSaveFailureInput): void {
172
184
  trackStudioEvent("save_failure", buildStudioSaveFailureProperties(input));
173
185
  }