@hyperframes/studio 0.6.111 → 0.6.113

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/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-B7S86vK1.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-B8Qzwcpc.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-DP8pPIk2.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.111",
3
+ "version": "0.6.113",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,9 +36,9 @@
36
36
  "dompurify": "^3.2.4",
37
37
  "marked": "^14.1.4",
38
38
  "mediabunny": "^1.45.3",
39
- "@hyperframes/core": "0.6.111",
40
- "@hyperframes/sdk": "0.6.111",
41
- "@hyperframes/player": "0.6.111"
39
+ "@hyperframes/sdk": "0.6.113",
40
+ "@hyperframes/player": "0.6.113",
41
+ "@hyperframes/core": "0.6.113"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/react": "19",
@@ -52,7 +52,7 @@
52
52
  "vite": "^6.4.2",
53
53
  "vitest": "^3.2.4",
54
54
  "zustand": "^5.0.0",
55
- "@hyperframes/producer": "0.6.111"
55
+ "@hyperframes/producer": "0.6.113"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "react": "19",
@@ -82,6 +82,8 @@ export interface UseDomEditCommitsParams {
82
82
  ) => Promise<boolean>;
83
83
  /** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */
84
84
  onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
85
+ /** Resolver-shadow tripwire for z-index reorder targets (telemetry-only, decoupled from cutover). */
86
+ onReorderShadow?: (targets: string[]) => void;
85
87
  }
86
88
 
87
89
  export function useDomEditCommits({
@@ -105,6 +107,7 @@ export function useDomEditCommits({
105
107
  forceReloadSdkSession,
106
108
  onTrySdkPersist,
107
109
  onTrySdkDelete,
110
+ onReorderShadow,
108
111
  }: UseDomEditCommitsParams) {
109
112
  const resolveImportedFontAsset = useCallback(
110
113
  (fontFamilyValue: string): ImportedFontAsset | null => {
@@ -324,6 +327,7 @@ export function useDomEditCommits({
324
327
  reloadPreview,
325
328
  clearDomSelection,
326
329
  onTrySdkDelete,
330
+ onReorderShadow,
327
331
  forceReloadSdkSession,
328
332
  commitPositionPatchToHtml,
329
333
  });
@@ -6,7 +6,7 @@ import type { PatchTarget } from "../utils/sourcePatcher";
6
6
  import type { SidebarTab } from "../components/sidebar/LeftSidebar";
7
7
  import type { Composition } from "@hyperframes/sdk";
8
8
  import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover";
9
- import { runResolverShadow } from "../utils/sdkResolverShadow";
9
+ import { runResolverShadow, recordResolverParity } from "../utils/sdkResolverShadow";
10
10
  import { useAskAgentModal } from "./useAskAgentModal";
11
11
  import { useDomSelection } from "./useDomSelection";
12
12
  import { usePreviewInteraction } from "./usePreviewInteraction";
@@ -270,6 +270,14 @@ export function useDomEditSession({
270
270
  compositionPath: activeCompPath,
271
271
  })
272
272
  : undefined,
273
+ // Resolver shadow for the z-index reorder edit: it takes the server path (no
274
+ // SDK persist), but the tripwire is decoupled from cutover — record whether
275
+ // the SDK resolves each reordered element (the reorderElements op's targets).
276
+ onReorderShadow: sdkSession
277
+ ? (targets: string[]) => {
278
+ for (const target of targets) recordResolverParity(sdkSession, target, "reorderElements");
279
+ }
280
+ : undefined,
273
281
  });
274
282
 
275
283
  // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
@@ -28,6 +28,8 @@ interface UseElementLifecycleOpsParams {
28
28
  clearDomSelection: () => void;
29
29
  /** Route delete through SDK when session resolves the hf-id; returns true if handled. */
30
30
  onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
31
+ /** Resolver-shadow tripwire for the reordered targets (telemetry-only, decoupled from cutover). */
32
+ onReorderShadow?: (targets: string[]) => void;
31
33
  /** Resync the SDK session after a server-fallback delete. */
32
34
  forceReloadSdkSession?: () => void;
33
35
  commitPositionPatchToHtml: (
@@ -49,6 +51,7 @@ export function useElementLifecycleOps({
49
51
  reloadPreview,
50
52
  clearDomSelection,
51
53
  onTrySdkDelete,
54
+ onReorderShadow,
52
55
  forceReloadSdkSession,
53
56
  commitPositionPatchToHtml,
54
57
  onElementDeleted,
@@ -168,6 +171,11 @@ export function useElementLifecycleOps({
168
171
  }>,
169
172
  ) => {
170
173
  if (entries.length === 0) return;
174
+ // Resolver shadow (telemetry-only, decoupled from cutover): record whether
175
+ // the SDK resolves each reordered element — the reorderElements op's targets.
176
+ onReorderShadow?.(
177
+ entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null),
178
+ );
171
179
  const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
172
180
  for (let i = 0; i < entries.length; i++) {
173
181
  const entry = entries[i];
@@ -202,7 +210,7 @@ export function useElementLifecycleOps({
202
210
  ).catch(() => undefined);
203
211
  }
204
212
  },
205
- [commitPositionPatchToHtml],
213
+ [commitPositionPatchToHtml, onReorderShadow],
206
214
  );
207
215
 
208
216
  return {
@@ -5,6 +5,8 @@ import { roundTo3 } from "../utils/rounding";
5
5
  import {
6
6
  sdkGsapTweenPersist,
7
7
  sdkGsapDeleteAllForSelectorPersist,
8
+ sdkAddWithKeyframesPersist,
9
+ sdkReplaceWithKeyframesPersist,
8
10
  type CutoverDeps,
9
11
  } from "../utils/sdkCutover";
10
12
  import {
@@ -181,10 +183,104 @@ export function useGsapAnimationOps({
181
183
  [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession, sdkDeps],
182
184
  );
183
185
 
186
+ type KeyframeEntry = {
187
+ percentage: number;
188
+ properties: Record<string, number | string>;
189
+ ease?: string;
190
+ auto?: boolean;
191
+ };
192
+
193
+ const addWithKeyframes = useCallback(
194
+ async (
195
+ selection: DomEditSelection,
196
+ targetSelector: string,
197
+ position: number,
198
+ duration: number,
199
+ keyframes: KeyframeEntry[],
200
+ ease?: string,
201
+ label = "Add animation with keyframes",
202
+ ) => {
203
+ if (sdkSession && sdkDeps) {
204
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
205
+ const handled = await sdkAddWithKeyframesPersist(
206
+ targetPath,
207
+ targetSelector,
208
+ position,
209
+ duration,
210
+ keyframes,
211
+ ease,
212
+ sdkSession,
213
+ sdkDeps,
214
+ { label },
215
+ );
216
+ if (handled) return;
217
+ }
218
+ void commitMutation(
219
+ selection,
220
+ {
221
+ type: "add-with-keyframes",
222
+ targetSelector,
223
+ position,
224
+ duration,
225
+ keyframes,
226
+ ...(ease ? { ease } : {}),
227
+ },
228
+ { label, softReload: true },
229
+ );
230
+ },
231
+ [commitMutation, activeCompPath, sdkSession, sdkDeps],
232
+ );
233
+
234
+ const replaceWithKeyframes = useCallback(
235
+ async (
236
+ selection: DomEditSelection,
237
+ animationId: string,
238
+ targetSelector: string,
239
+ position: number,
240
+ duration: number,
241
+ keyframes: KeyframeEntry[],
242
+ ease?: string,
243
+ label = "Replace animation with keyframes",
244
+ ) => {
245
+ if (sdkSession && sdkDeps) {
246
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
247
+ const handled = await sdkReplaceWithKeyframesPersist(
248
+ targetPath,
249
+ animationId,
250
+ targetSelector,
251
+ position,
252
+ duration,
253
+ keyframes,
254
+ ease,
255
+ sdkSession,
256
+ sdkDeps,
257
+ { label },
258
+ );
259
+ if (handled) return;
260
+ }
261
+ void commitMutation(
262
+ selection,
263
+ {
264
+ type: "replace-with-keyframes",
265
+ animationId,
266
+ targetSelector,
267
+ position,
268
+ duration,
269
+ keyframes,
270
+ ...(ease ? { ease } : {}),
271
+ },
272
+ { label, softReload: true },
273
+ );
274
+ },
275
+ [commitMutation, activeCompPath, sdkSession, sdkDeps],
276
+ );
277
+
184
278
  return {
185
279
  updateGsapMeta,
186
280
  deleteGsapAnimation,
187
281
  deleteAllForSelector,
188
282
  addGsapAnimation,
283
+ addWithKeyframes,
284
+ replaceWithKeyframes,
189
285
  };
190
286
  }
@@ -1,11 +1,32 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
2
  import type { MutableRefObject } from "react";
3
3
  import { openComposition } from "@hyperframes/sdk";
4
- import { createHttpAdapter } from "@hyperframes/sdk/adapters/http";
5
4
  import type { Composition } from "@hyperframes/sdk";
6
5
  import { readStudioFileChangePath } from "../components/editor/manualEdits";
7
6
  import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
8
7
 
8
+ /**
9
+ * Read a project file's content, or undefined on a non-2xx (optional read).
10
+ * Replaces the removed SDK http adapter's `read()` — the only thing Studio used
11
+ * it for (Studio is the sole writer, so the adapter's write path was dead).
12
+ */
13
+ async function readProjectFileOptional(
14
+ projectId: string,
15
+ path: string,
16
+ ): Promise<string | undefined> {
17
+ // Reject traversal / NUL before building the request URL — `path` is a
18
+ // user-influenced composition path (mirrors the guard in timelineEditingHelpers,
19
+ // and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
20
+ // already confines both values to single segments of this same-origin URL.
21
+ if (path.includes("\0") || path.includes("..")) return undefined;
22
+ const res = await fetch(
23
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
24
+ );
25
+ if (!res.ok) return undefined;
26
+ const data = (await res.json()) as { content?: string };
27
+ return typeof data.content === "string" ? data.content : undefined;
28
+ }
29
+
9
30
  /**
10
31
  * True when an external file-change payload targets the active composition and
11
32
  * the SDK session must be re-opened to pick up the new content.
@@ -18,8 +39,8 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
18
39
  /**
19
40
  * Stage 7 Step 3a — SDK session wired to the active composition.
20
41
  *
21
- * Creates an SDK Composition backed by createHttpAdapter on every
22
- * (projectId, activeCompPath) change, disposes the old one on cleanup, and
42
+ * Creates an SDK Composition (reading the file via the project files API) on
43
+ * every (projectId, activeCompPath) change, disposes the old one on cleanup, and
23
44
  * re-opens it when the active composition file changes on disk (code editor,
24
45
  * agent, or server-side patch) so the in-memory linkedom document never goes
25
46
  * stale. The session has NO persist queue — Studio is the sole file writer; see
@@ -81,10 +102,7 @@ export function useSdkSession(
81
102
  useEffect(() => {
82
103
  if (!activeCompPath) return;
83
104
  const compPath = activeCompPath;
84
- const readAdapter =
85
- projectId != null
86
- ? createHttpAdapter({ projectFilesUrl: `/api/projects/${projectId}` })
87
- : null;
105
+ const readProjectId = projectId ?? null;
88
106
  const handler = (payload?: unknown) => {
89
107
  if (!shouldReloadSdkSession(payload, compPath)) return;
90
108
  const withinWindow =
@@ -96,13 +114,12 @@ export function useSdkSession(
96
114
  const payloadContent = readFileChangeContent(payload);
97
115
  // Prefer payload content; otherwise re-read so the decision is by IDENTITY
98
116
  // (an undo's reverted bytes won't match a registered self-write → reload).
99
- if (payloadContent != null || !readAdapter) {
117
+ if (payloadContent != null || readProjectId == null) {
100
118
  decide(payloadContent);
101
119
  return;
102
120
  }
103
- readAdapter
104
- .read(compPath)
105
- .then((c) => decide(typeof c === "string" ? c : null))
121
+ readProjectFileOptional(readProjectId, compPath)
122
+ .then((c) => decide(c ?? null))
106
123
  .catch(() => decide(null));
107
124
  };
108
125
  if (import.meta.hot) {
@@ -126,11 +143,7 @@ export function useSdkSession(
126
143
  let cancelled = false;
127
144
  const compRef = { current: null as Composition | null };
128
145
 
129
- const adapter = createHttpAdapter({
130
- projectFilesUrl: `/api/projects/${projectId}`,
131
- });
132
- adapter
133
- .read(activeCompPath)
146
+ readProjectFileOptional(projectId, activeCompPath)
134
147
  .then(async (content) => {
135
148
  if (cancelled || typeof content !== "string") return;
136
149
  // No persist queue: Studio's writeProjectFile (via sdkCutover's
@@ -435,6 +435,86 @@ export function sdkGsapConvertToKeyframesPersist(
435
435
  );
436
436
  }
437
437
 
438
+ type KeyframeSpec = {
439
+ percentage: number;
440
+ properties: Record<string, number | string>;
441
+ ease?: string;
442
+ auto?: boolean;
443
+ };
444
+
445
+ type KeyframesPayload = {
446
+ targetSelector: string;
447
+ position: number;
448
+ duration: number;
449
+ keyframes: KeyframeSpec[];
450
+ ease?: string;
451
+ };
452
+
453
+ /** Shared inner dispatch for addWithKeyframes / replaceWithKeyframes ops. */
454
+ function dispatchWithKeyframes(
455
+ s: Composition,
456
+ payload: KeyframesPayload,
457
+ animationId?: string,
458
+ ): void {
459
+ if (animationId !== undefined) {
460
+ s.dispatch({ type: "replaceWithKeyframes", animationId, ...payload });
461
+ } else {
462
+ s.dispatch({ type: "addWithKeyframes", ...payload });
463
+ }
464
+ }
465
+
466
+ export function sdkAddWithKeyframesPersist(
467
+ targetPath: string,
468
+ targetSelector: string,
469
+ position: number,
470
+ duration: number,
471
+ keyframes: KeyframeSpec[],
472
+ ease: string | undefined,
473
+ sdkSession: Composition | null | undefined,
474
+ deps: CutoverDeps,
475
+ options?: CutoverOptions,
476
+ ): Promise<boolean> {
477
+ const payload: KeyframesPayload = {
478
+ targetSelector,
479
+ position,
480
+ duration,
481
+ keyframes,
482
+ ...(ease ? { ease } : {}),
483
+ };
484
+ return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
485
+ dispatchWithKeyframes(s, payload),
486
+ );
487
+ }
488
+
489
+ export function sdkReplaceWithKeyframesPersist(
490
+ targetPath: string,
491
+ animationId: string,
492
+ targetSelector: string,
493
+ position: number,
494
+ duration: number,
495
+ keyframes: KeyframeSpec[],
496
+ ease: string | undefined,
497
+ sdkSession: Composition | null | undefined,
498
+ deps: CutoverDeps,
499
+ options?: CutoverOptions,
500
+ ): Promise<boolean> {
501
+ const payload: KeyframesPayload = {
502
+ targetSelector,
503
+ position,
504
+ duration,
505
+ keyframes,
506
+ ...(ease ? { ease } : {}),
507
+ };
508
+ return dispatchGsapOpAndPersist(
509
+ targetPath,
510
+ sdkSession,
511
+ deps,
512
+ options,
513
+ (s) => dispatchWithKeyframes(s, payload, animationId),
514
+ { animationId, opLabel: "replaceWithKeyframes" },
515
+ );
516
+ }
517
+
438
518
  export async function sdkDeletePersist(
439
519
  hfId: string,
440
520
  originalContent: string,
@@ -189,7 +189,7 @@ describe("C. Resolver-parity detection", () => {
189
189
  it("C8: element_not_found fires when SDK resolver returns null (v0.6.110 class)", () => {
190
190
  // Simulate the regression: SDK session cannot resolve the hfId the server
191
191
  // would address (e.g. scoped-id mismatch, resolver bug).
192
- const session = { getElement: () => null } as unknown as Parameters<
192
+ const session = { getElement: () => null, getElements: () => [] } as unknown as Parameters<
193
193
  typeof sdkResolverShadowCheck
194
194
  >[0];
195
195
  const mismatches = sdkResolverShadowCheck(
@@ -364,3 +364,46 @@ describe("G. recordAnimationResolverParity", () => {
364
364
  expect(trackedEvents).toHaveLength(0);
365
365
  });
366
366
  });
367
+
368
+ // ─── H. Inlined sub-composition: bare leaf id resolves (regression) ───────────
369
+
370
+ // PostHog showed ~445 false `element_not_found` events, all on a bare leaf id
371
+ // (hf-0ytc / #subscribe-btn) inside an inlined sub-composition. The studio reads
372
+ // the bare data-hf-id off the DOM and the cutover dispatch resolves it via
373
+ // resolveScoped (which locates the leaf inside the host subtree). But the shadow
374
+ // resolved via Composition.getElement, which is canonical-only for a bare id and
375
+ // returns null for a scoped element — so it flagged a divergence the real
376
+ // dispatch path would not hit. The shadow now mirrors dispatch via resolveSnapshot.
377
+ describe("H. inlined sub-composition leaf", () => {
378
+ // host carries data-composition-file → new scope; leaf's scopedId is
379
+ // "hf-host/hf-leaf" but its raw data-hf-id (what the studio reads) is bare.
380
+ const INLINED_HTML = /* html */ `<!DOCTYPE html>
381
+ <html><body>
382
+ <div data-hf-id="hf-root" data-hf-root>
383
+ <div data-hf-id="hf-host" data-composition-file="sub.html">
384
+ <div data-hf-id="hf-leaf" style="color: red">Subscribe</div>
385
+ </div>
386
+ </div>
387
+ </body></html>`;
388
+
389
+ it("getElement(bareLeaf) is null (canonical-only) — the trap the shadow used to hit", async () => {
390
+ const session = await openComposition(INLINED_HTML);
391
+ expect(session.getElement("hf-leaf")).toBeNull();
392
+ expect(session.getElement("hf-host/hf-leaf")).not.toBeNull();
393
+ });
394
+
395
+ it("recordResolverParity emits NOTHING for a bare leaf inside a sub-comp", async () => {
396
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
397
+ const session = await openComposition(INLINED_HTML);
398
+ recordResolverParity(session, "hf-leaf", "setTiming");
399
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
400
+ });
401
+
402
+ it("sdkResolverShadowCheck does not flag element_not_found for a bare leaf in a sub-comp", async () => {
403
+ const session = await openComposition(INLINED_HTML);
404
+ const mismatches = sdkResolverShadowCheck(session, "hf-leaf", [
405
+ { type: "inline-style", property: "color", value: "blue" },
406
+ ]);
407
+ expect(mismatches.some((m) => m.kind === "element_not_found")).toBe(false);
408
+ });
409
+ });
@@ -65,6 +65,31 @@ function normalizeText(v: string | null | undefined): string | null {
65
65
  type FlatEl = NonNullable<ReturnType<Composition["getElement"]>>;
66
66
  type AttrMap = Record<string, string | null>;
67
67
 
68
+ /**
69
+ * Resolve an hf-id to its snapshot the SAME way the SDK dispatch path does
70
+ * (engine/model.ts resolveScoped), NOT via Composition.getElement.
71
+ *
72
+ * getElement is canonical-only for a bare id by design — it deliberately will
73
+ * not resolve a bare id to a non-canonical (sub-composition) element, so that
74
+ * removeElement(bareId) and getElement(bareId) agree on the same instance
75
+ * (session.subcomp.test "ambiguous bare id" suite). But the cutover persist
76
+ * path dispatches the studio's bare data-hf-id, and dispatch resolves it via
77
+ * resolveScoped, which locates the leaf anywhere (canonical preferred, else
78
+ * first match). So getElement under-resolves a bare leaf that lives inside an
79
+ * inlined sub-composition (scopedId "host/leaf") — exactly the false
80
+ * `element_not_found` this tripwire was emitting for inlined compositions.
81
+ *
82
+ * Mirror resolveScoped here: exact scoped-path match, then canonical bare
83
+ * match, then first bare match — the resolvability dispatch actually has.
84
+ */
85
+ function resolveSnapshot(session: Composition, id: string): FlatEl | null {
86
+ const els = session.getElements();
87
+ const exact = els.find((el) => el.scopedId === id);
88
+ if (exact) return exact;
89
+ const matches = els.filter((el) => el.id === id);
90
+ return matches.find((el) => el.scopedId === el.id) ?? matches[0] ?? null;
91
+ }
92
+
68
93
  function checkStyleOp(
69
94
  op: PatchOperation,
70
95
  el: FlatEl,
@@ -140,7 +165,7 @@ export function sdkResolverShadowCheck(
140
165
  hfId: string,
141
166
  ops: PatchOperation[],
142
167
  ): SdkResolverMismatch[] {
143
- if (!session.getElement(hfId)) {
168
+ if (!resolveSnapshot(session, hfId)) {
144
169
  return [{ kind: "element_not_found", hfId }];
145
170
  }
146
171
 
@@ -172,7 +197,7 @@ export function sdkResolverShadowCheck(
172
197
  return [{ kind: "dispatch_error", hfId, error: String(err) }];
173
198
  }
174
199
 
175
- const el = session.getElement(hfId);
200
+ const el = resolveSnapshot(session, hfId);
176
201
  if (!el) return [{ kind: "element_not_found", hfId }];
177
202
 
178
203
  return shadowable
@@ -253,7 +278,7 @@ export function recordResolverParity(
253
278
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
254
279
  if (!session || !hfId) return;
255
280
  try {
256
- if (session.getElement(hfId)) return; // resolves — parity, nothing to record
281
+ if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
257
282
  trackStudioEvent("sdk_resolver_shadow", {
258
283
  hfId,
259
284
  opLabel,