@hyperframes/studio 0.6.98 → 0.6.100

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 +1 @@
1
- import{g as P}from"./index-Ce3pBm_I.js";function j(c,d){for(var s=0;s<d.length;s++){const a=d[s];if(typeof a!="string"&&!Array.isArray(a)){for(const i in a)if(i!=="default"&&!(i in c)){const l=Object.getOwnPropertyDescriptor(a,i);l&&Object.defineProperty(c,i,l.get?l:{enumerable:!0,get:()=>a[i]})}}}return Object.freeze(Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}))}var v={},w;function k(){if(w)return v;w=1,Object.defineProperty(v,"__esModule",{value:!0}),v.default=d;var c=window.OfflineAudioContext||window.webkitOfflineAudioContext;function d(e){var r=a(e);return r.start(0),[i,y,O(e.sampleRate),s].reduce(function(t,o){return o(t)},r.buffer.getChannelData(0))}function s(e){return e.sort(function(r,t){return t.count-r.count}).splice(0,5)[0].tempo}function a(e){var r=e.length,t=e.numberOfChannels,o=e.sampleRate,n=new c(t,r,o),u=n.createBufferSource();u.buffer=e;var f=n.createBiquadFilter();return f.type="lowpass",u.connect(f),f.connect(n.destination),u}function i(e){for(var r=[],t=.9,o=.3,n=15;r.length<n&&t>=o;)r=l(e,t),t-=.05;if(r.length<n)throw new Error("Could not find enough samples for a reliable detection.");return r}function l(e,r){for(var t=[],o=0,n=e.length;o<n;o+=1)e[o]>r&&(t.push(o),o+=1e4);return t}function y(e){var r=[];return e.forEach(function(t,o){for(var n=function(x){var g=e[o+x]-t,_=r.some(function(h){if(h.interval===g)return h.count+=1});_||r.push({interval:g,count:1})},u=0;u<10;u+=1)n(u)}),r}function O(e){return function(r){var t=[];return r.forEach(function(o){if(o.interval!==0){for(var n=60/(o.interval/e);n<90;)n*=2;for(;n>180;)n/=2;n=Math.round(n);var u=t.some(function(f){if(f.tempo===n)return f.count+=o.count});u||t.push({tempo:n,count:o.count})}}),t}}return v}var p,b;function q(){return b||(b=1,p=k().default),p}var m=q();const A=P(m),D=j({__proto__:null,default:A},[m]);export{D as i};
1
+ import{g as P}from"./index-BkT9VKwz.js";function j(c,d){for(var s=0;s<d.length;s++){const a=d[s];if(typeof a!="string"&&!Array.isArray(a)){for(const i in a)if(i!=="default"&&!(i in c)){const l=Object.getOwnPropertyDescriptor(a,i);l&&Object.defineProperty(c,i,l.get?l:{enumerable:!0,get:()=>a[i]})}}}return Object.freeze(Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}))}var v={},w;function k(){if(w)return v;w=1,Object.defineProperty(v,"__esModule",{value:!0}),v.default=d;var c=window.OfflineAudioContext||window.webkitOfflineAudioContext;function d(e){var r=a(e);return r.start(0),[i,y,O(e.sampleRate),s].reduce(function(t,o){return o(t)},r.buffer.getChannelData(0))}function s(e){return e.sort(function(r,t){return t.count-r.count}).splice(0,5)[0].tempo}function a(e){var r=e.length,t=e.numberOfChannels,o=e.sampleRate,n=new c(t,r,o),u=n.createBufferSource();u.buffer=e;var f=n.createBiquadFilter();return f.type="lowpass",u.connect(f),f.connect(n.destination),u}function i(e){for(var r=[],t=.9,o=.3,n=15;r.length<n&&t>=o;)r=l(e,t),t-=.05;if(r.length<n)throw new Error("Could not find enough samples for a reliable detection.");return r}function l(e,r){for(var t=[],o=0,n=e.length;o<n;o+=1)e[o]>r&&(t.push(o),o+=1e4);return t}function y(e){var r=[];return e.forEach(function(t,o){for(var n=function(x){var g=e[o+x]-t,_=r.some(function(h){if(h.interval===g)return h.count+=1});_||r.push({interval:g,count:1})},u=0;u<10;u+=1)n(u)}),r}function O(e){return function(r){var t=[];return r.forEach(function(o){if(o.interval!==0){for(var n=60/(o.interval/e);n<90;)n*=2;for(;n>180;)n/=2;n=Math.round(n);var u=t.some(function(f){if(f.tempo===n)return f.count+=o.count});u||t.push({tempo:n,count:o.count})}}),t}}return v}var p,b;function q(){return b||(b=1,p=k().default),p}var m=q();const A=P(m),D=j({__proto__:null,default:A},[m]);export{D as i};
@@ -1,4 +1,4 @@
1
- import{n as Qi}from"./index-Ce3pBm_I.js";/*!
1
+ import{n as Qi}from"./index-BkT9VKwz.js";/*!
2
2
  * Copyright (c) 2026-present, Vanilagy and contributors
3
3
  *
4
4
  * This Source Code Form is subject to the terms of the Mozilla Public
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-Ce3pBm_I.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-BkT9VKwz.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-B62bDCQv.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.98",
3
+ "version": "0.6.100",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,8 +33,9 @@
33
33
  "@phosphor-icons/react": "^2.1.10",
34
34
  "bpm-detective": "^2.0.5",
35
35
  "mediabunny": "^1.45.3",
36
- "@hyperframes/core": "0.6.98",
37
- "@hyperframes/player": "0.6.98"
36
+ "@hyperframes/core": "0.6.100",
37
+ "@hyperframes/sdk": "0.6.100",
38
+ "@hyperframes/player": "0.6.100"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@types/react": "19",
@@ -48,7 +49,7 @@
48
49
  "vite": "^6.4.2",
49
50
  "vitest": "^3.2.4",
50
51
  "zustand": "^5.0.0",
51
- "@hyperframes/producer": "0.6.98"
52
+ "@hyperframes/producer": "0.6.100"
52
53
  },
53
54
  "peerDependencies": {
54
55
  "react": "19",
package/src/App.tsx CHANGED
@@ -13,6 +13,8 @@ import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
13
13
  import { useTimelineEditing } from "./hooks/useTimelineEditing";
14
14
  import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
15
15
  import { useDomEditSession } from "./hooks/useDomEditSession";
16
+ import { useSdkSession } from "./hooks/useSdkSession";
17
+ import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
16
18
  import { useBlockHandlers } from "./hooks/useBlockHandlers";
17
19
  import { useAppHotkeys } from "./hooks/useAppHotkeys";
18
20
  import { useClipboard } from "./hooks/useClipboard";
@@ -265,6 +267,7 @@ export function StudioApp() {
265
267
  () => leftSidebarRef.current?.getTab() ?? "compositions",
266
268
  [],
267
269
  );
270
+ const sdkSession = useSdkSession(projectId, activeCompPath);
268
271
  const domEditSession = useDomEditSession({
269
272
  projectId,
270
273
  activeCompPath,
@@ -299,6 +302,7 @@ export function StudioApp() {
299
302
  openSourceForSelection: fileManager.openSourceForSelection,
300
303
  selectSidebarTab: selectSidebarTabStable,
301
304
  getSidebarTab: getSidebarTabStable,
305
+ sdkSession,
302
306
  });
303
307
  domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
304
308
  clearDomSelectionRef.current = domEditSession.clearDomSelection;
@@ -314,6 +318,12 @@ export function StudioApp() {
314
318
  domEditSession.handleGsapRemoveKeyframe(a.id, p);
315
319
  }
316
320
  };
321
+ useSdkSelectionSync(
322
+ sdkSession,
323
+ domEditSession.domEditSelection,
324
+ domEditSession.domEditGroupSelections,
325
+ );
326
+
317
327
  useCaptionDetection({
318
328
  projectId,
319
329
  activeCompPath,
@@ -419,17 +429,6 @@ export function StudioApp() {
419
429
  applyDomSelection: domEditSession.applyDomSelection,
420
430
  initialState: initialUrlStateRef.current,
421
431
  });
422
- const { jobs, isRendering, deleteRender, clearCompleted, startRender } = renderQueue;
423
- const stableRenderQueue = useMemo(
424
- () => ({
425
- jobs,
426
- isRendering,
427
- deleteRender,
428
- clearCompleted,
429
- startRender: startRender as (options: unknown) => Promise<void>,
430
- }),
431
- [jobs, isRendering, deleteRender, clearCompleted, startRender],
432
- );
433
432
  const studioCtxValue = buildStudioContextValue({
434
433
  projectId: projectId!,
435
434
  activeCompPath,
@@ -445,7 +444,7 @@ export function StudioApp() {
445
444
  editHistory,
446
445
  handleUndo: appHotkeys.handleUndo,
447
446
  handleRedo: appHotkeys.handleRedo,
448
- renderQueue: stableRenderQueue,
447
+ renderQueue,
449
448
  compositionDimensions,
450
449
  waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
451
450
  handlePreviewIframeRef,
@@ -485,7 +484,7 @@ export function StudioApp() {
485
484
  refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
486
485
  inspectorButtonActive={inspectorButtonActive}
487
486
  inspectorPanelActive={inspectorPanelActive}
488
- onExport={() => void renderQueue.startRender()}
487
+ onExport={() => void renderQueue.startRender(undefined)}
489
488
  />
490
489
  {previewPersistence.domEditSaveQueuePaused && (
491
490
  <SaveQueuePausedBanner
@@ -88,4 +88,13 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
88
88
 
89
89
  export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
90
90
 
91
+ // Stage 7 Step 3b: shadow dispatch parity mode — dispatches ops to the SDK
92
+ // session alongside the server patch path and logs mismatches via telemetry.
93
+ // Default false in production; enable via VITE_STUDIO_SDK_SHADOW_ENABLED=true.
94
+ export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
95
+ env,
96
+ ["VITE_STUDIO_SDK_SHADOW_ENABLED"],
97
+ false,
98
+ );
99
+
91
100
  export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback, useRef } from "react";
1
+ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
2
  import { trackStudioRenderStart } from "../../telemetry/events";
3
3
 
4
4
  export interface RenderJob {
@@ -238,11 +238,15 @@ export function useRenderQueue(projectId: string | null) {
238
238
  };
239
239
  }, [projectId]);
240
240
 
241
- return {
242
- jobs,
243
- startRender,
244
- deleteRender,
245
- clearCompleted,
246
- isRendering: jobs.some((j) => j.status === "rendering"),
247
- };
241
+ const isRendering = jobs.some((j) => j.status === "rendering");
242
+ return useMemo(
243
+ () => ({
244
+ jobs,
245
+ isRendering,
246
+ deleteRender,
247
+ clearCompleted,
248
+ startRender: startRender as (options: unknown) => Promise<void>,
249
+ }),
250
+ [jobs, isRendering, deleteRender, clearCompleted, startRender],
251
+ );
248
252
  }
@@ -9,14 +9,12 @@ import { buildDomEditPatchTarget, type DomEditSelection } from "../components/ed
9
9
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
10
10
  import type { EditHistoryKind } from "../utils/editHistory";
11
11
  import type { PersistDomEditOperations } from "./domEditCommitTypes";
12
+ import type { PatchOperation } from "../utils/sourcePatcher";
12
13
  import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
13
14
  import { useDomEditTextCommits } from "./useDomEditTextCommits";
14
15
  import { useDomGeometryCommits } from "./useDomGeometryCommits";
15
16
  import { useElementLifecycleOps } from "./useElementLifecycleOps";
16
17
 
17
- // Re-export so existing consumers keep their import path
18
- export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits";
19
-
20
18
  // ── Helpers ──
21
19
 
22
20
  function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
@@ -40,8 +38,6 @@ function formatPatchRejectionMessage(body: { error?: string; fields?: string[] }
40
38
  return `Couldn't save edit: ${body.error}${suffix}`;
41
39
  }
42
40
 
43
- // ── Types ──
44
-
45
41
  interface RecordEditInput {
46
42
  label: string;
47
43
  kind: EditHistoryKind;
@@ -49,8 +45,6 @@ interface RecordEditInput {
49
45
  files: Record<string, { before: string; after: string }>;
50
46
  }
51
47
 
52
- export type { PersistDomEditOperations } from "./domEditCommitTypes";
53
-
54
48
  export interface UseDomEditCommitsParams {
55
49
  activeCompPath: string | null;
56
50
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
@@ -77,10 +71,10 @@ export interface UseDomEditCommitsParams {
77
71
  target: HTMLElement,
78
72
  options?: { preferClipAncestor?: boolean },
79
73
  ) => Promise<DomEditSelection | null>;
74
+ /** Stage 7 Step 3b: called after a successful server-side element patch. */
75
+ onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
80
76
  }
81
77
 
82
- // ── Hook ──
83
-
84
78
  export function useDomEditCommits({
85
79
  activeCompPath,
86
80
  previewIframeRef,
@@ -99,6 +93,7 @@ export function useDomEditCommits({
99
93
  clearDomSelection,
100
94
  refreshDomEditSelectionFromPreview,
101
95
  buildDomSelectionFromTarget,
96
+ onDomEditPersisted,
102
97
  }: UseDomEditCommitsParams) {
103
98
  const resolveImportedFontAsset = useCallback(
104
99
  (fontFamilyValue: string): ImportedFontAsset | null => {
@@ -220,6 +215,7 @@ export function useDomEditCommits({
220
215
  coalesceKey: options?.coalesceKey,
221
216
  files: { [targetPath]: { before: originalContent, after: finalContent } },
222
217
  });
218
+ onDomEditPersisted?.(selection, operations);
223
219
 
224
220
  if (!options?.skipRefresh) {
225
221
  reloadPreview();
@@ -233,6 +229,7 @@ export function useDomEditCommits({
233
229
  domEditSaveTimestampRef,
234
230
  reloadPreview,
235
231
  showToast,
232
+ onDomEditPersisted,
236
233
  ],
237
234
  );
238
235
 
@@ -1,3 +1,4 @@
1
+ import type { Composition } from "@hyperframes/sdk";
1
2
  import type { TimelineElement } from "../player";
2
3
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
3
4
  import type { EditHistoryKind } from "../utils/editHistory";
@@ -8,6 +9,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
8
9
  import { useDomSelection } from "./useDomSelection";
9
10
  import { usePreviewInteraction } from "./usePreviewInteraction";
10
11
  import { useDomEditCommits } from "./useDomEditCommits";
12
+ import { runShadowDispatch } from "../utils/sdkShadow";
11
13
  import { useGsapScriptCommits } from "./useGsapScriptCommits";
12
14
  import { useGsapCacheVersion } from "./useGsapTweenCache";
13
15
  import { useDomEditWiring } from "./useDomEditWiring";
@@ -58,6 +60,8 @@ export interface UseDomEditSessionParams {
58
60
  openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
59
61
  selectSidebarTab?: (tab: SidebarTab) => void;
60
62
  getSidebarTab?: () => SidebarTab;
63
+ /** Stage 7 Step 3b: SDK session for shadow dispatch parity tracking. */
64
+ sdkSession?: Composition | null;
61
65
  }
62
66
 
63
67
  // ── Hook ──
@@ -96,6 +100,7 @@ export function useDomEditSession({
96
100
  openSourceForSelection,
97
101
  selectSidebarTab,
98
102
  getSidebarTab,
103
+ sdkSession,
99
104
  }: UseDomEditSessionParams) {
100
105
  void _setRefreshKey;
101
106
  void _readProjectFile;
@@ -227,6 +232,9 @@ export function useDomEditSession({
227
232
  clearDomSelection,
228
233
  refreshDomEditSelectionFromPreview,
229
234
  buildDomSelectionFromTarget,
235
+ onDomEditPersisted: sdkSession
236
+ ? (sel, ops) => runShadowDispatch(sdkSession, sel, ops)
237
+ : undefined,
230
238
  });
231
239
 
232
240
  // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
@@ -0,0 +1,25 @@
1
+ import { useEffect } from "react";
2
+ import type { Composition } from "@hyperframes/sdk";
3
+ import type { DomEditSelection } from "../components/editor/domEditing";
4
+
5
+ function toHfIds(group: DomEditSelection[], primary: DomEditSelection | null): string[] {
6
+ const source = group.length > 0 ? group : primary ? [primary] : [];
7
+ return source.flatMap((s) => (s.hfId ? [s.hfId] : []));
8
+ }
9
+
10
+ /**
11
+ * Stage 7 Step 2 — mirrors Studio canvas selection into the SDK session.
12
+ *
13
+ * Calls session.setSelection(hfIds) whenever domEditSelection or
14
+ * domEditGroupSelections changes. Pure effect; no existing hook modified.
15
+ */
16
+ export function useSdkSelectionSync(
17
+ session: Composition | null,
18
+ domEditSelection: DomEditSelection | null,
19
+ domEditGroupSelections: DomEditSelection[],
20
+ ): void {
21
+ useEffect(() => {
22
+ if (!session) return;
23
+ session.setSelection(toHfIds(domEditGroupSelections, domEditSelection));
24
+ }, [session, domEditSelection, domEditGroupSelections]);
25
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldReloadSdkSession } from "./useSdkSession";
3
+
4
+ describe("shouldReloadSdkSession", () => {
5
+ it("reloads when the changed file is the active composition", () => {
6
+ expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, "scenes/intro.html")).toBe(true);
7
+ });
8
+
9
+ it("ignores changes to other files", () => {
10
+ expect(shouldReloadSdkSession({ path: "styles/main.css" }, "scenes/intro.html")).toBe(false);
11
+ });
12
+
13
+ it("ignores changes when no composition is active", () => {
14
+ expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, null)).toBe(false);
15
+ });
16
+
17
+ it("ignores payloads with no resolvable path", () => {
18
+ expect(shouldReloadSdkSession({}, "scenes/intro.html")).toBe(false);
19
+ });
20
+ });
@@ -0,0 +1,98 @@
1
+ import { useState, useEffect } from "react";
2
+ import { openComposition } from "@hyperframes/sdk";
3
+ import { createHttpAdapter } from "@hyperframes/sdk/adapters/http";
4
+ import type { Composition } from "@hyperframes/sdk";
5
+ import { readStudioFileChangePath } from "../components/editor/manualEdits";
6
+
7
+ /**
8
+ * True when an external file-change payload targets the active composition and
9
+ * the SDK session must be re-opened to pick up the new content.
10
+ */
11
+ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string | null): boolean {
12
+ if (!activeCompPath) return false;
13
+ return readStudioFileChangePath(payload) === activeCompPath;
14
+ }
15
+
16
+ /**
17
+ * Stage 7 Step 3a — SDK session wired to the active composition.
18
+ *
19
+ * Creates an SDK Composition backed by createHttpAdapter on every
20
+ * (projectId, activeCompPath) change, disposes the old one on cleanup, and
21
+ * re-opens it when the active composition file changes on disk (code editor,
22
+ * agent, or server-side patch) so the in-memory linkedom document never goes
23
+ * stale. The persist queue writes back to `activeCompPath` (not the
24
+ * "composition.html" default).
25
+ *
26
+ * The session is idle until Step 3c routes dispatch ops through it; re-opening
27
+ * is therefore purely additive — no SDK self-write exists yet, so there is no
28
+ * persist echo. Step 3c must add self-write suppression once dispatch writes.
29
+ */
30
+ export function useSdkSession(
31
+ projectId: string | null,
32
+ activeCompPath: string | null,
33
+ ): Composition | null {
34
+ const [session, setSession] = useState<Composition | null>(null);
35
+ const [reloadToken, setReloadToken] = useState(0);
36
+
37
+ // ── Re-open on external change to the active composition ──
38
+ useEffect(() => {
39
+ if (!activeCompPath) return;
40
+ const handler = (payload?: unknown) => {
41
+ if (shouldReloadSdkSession(payload, activeCompPath)) {
42
+ setReloadToken((t) => t + 1);
43
+ }
44
+ };
45
+ if (import.meta.hot) {
46
+ import.meta.hot.on("hf:file-change", handler);
47
+ return () => import.meta.hot?.off?.("hf:file-change", handler);
48
+ }
49
+ // SSE fallback for the embedded studio server.
50
+ const es = new EventSource("/api/events");
51
+ es.addEventListener("file-change", handler);
52
+ return () => es.close();
53
+ }, [activeCompPath]);
54
+
55
+ // ── Open / re-open the session ──
56
+ useEffect(() => {
57
+ if (!projectId || !activeCompPath) {
58
+ setSession(null);
59
+ return;
60
+ }
61
+
62
+ let cancelled = false;
63
+ let comp: Composition | null = null;
64
+
65
+ const adapter = createHttpAdapter({
66
+ projectFilesUrl: `/api/projects/${projectId}`,
67
+ });
68
+ adapter
69
+ .read(activeCompPath)
70
+ .then(async (content) => {
71
+ if (cancelled || typeof content !== "string") return;
72
+ comp = await openComposition(content, {
73
+ persist: adapter,
74
+ persistPath: activeCompPath,
75
+ });
76
+ comp.on("persist:error", (e) => {
77
+ console.warn("[sdk] persist:error", e.error);
78
+ });
79
+ // Cleanup may have fired while openComposition was awaited; dispose immediately.
80
+ if (cancelled) {
81
+ comp.dispose();
82
+ return;
83
+ }
84
+ setSession(comp);
85
+ })
86
+ .catch(() => {
87
+ if (!cancelled) setSession(null);
88
+ });
89
+
90
+ return () => {
91
+ cancelled = true;
92
+ const c = comp;
93
+ if (c) void c.flush().finally(() => c.dispose());
94
+ };
95
+ }, [projectId, activeCompPath, reloadToken]);
96
+
97
+ return session;
98
+ }
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { patchOpsToSdkEditOps, SdkShadowMismatch } from "./sdkShadow";
3
+ import type { PatchOperation } from "./sourcePatcher";
4
+ import { openComposition } from "@hyperframes/sdk";
5
+
6
+ const BASE_HTML = /* html */ `<!DOCTYPE html>
7
+ <html><body>
8
+ <div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
9
+ </body></html>`;
10
+
11
+ describe("patchOpsToSdkEditOps", () => {
12
+ it("maps inline-style ops to a single setStyle EditOp", () => {
13
+ const ops: PatchOperation[] = [
14
+ { type: "inline-style", property: "color", value: "#00f" },
15
+ { type: "inline-style", property: "opacity", value: "0.5" },
16
+ ];
17
+ const result = patchOpsToSdkEditOps("hf-box", ops);
18
+ expect(result).toHaveLength(1);
19
+ expect(result[0]).toEqual({
20
+ type: "setStyle",
21
+ target: "hf-box",
22
+ styles: { color: "#00f", opacity: "0.5" },
23
+ });
24
+ });
25
+
26
+ it("maps text-content op to setText EditOp", () => {
27
+ const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World" }];
28
+ const result = patchOpsToSdkEditOps("hf-box", ops);
29
+ expect(result).toHaveLength(1);
30
+ expect(result[0]).toEqual({ type: "setText", target: "hf-box", value: "World" });
31
+ });
32
+
33
+ it("maps attribute op to setAttribute with data- prefix", () => {
34
+ const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
35
+ const result = patchOpsToSdkEditOps("hf-box", ops);
36
+ expect(result).toHaveLength(1);
37
+ expect(result[0]).toEqual({
38
+ type: "setAttribute",
39
+ target: "hf-box",
40
+ name: "data-name",
41
+ value: "hero",
42
+ });
43
+ });
44
+
45
+ it("maps html-attribute op to setAttribute without prefix", () => {
46
+ const ops: PatchOperation[] = [
47
+ { type: "html-attribute", property: "contenteditable", value: "true" },
48
+ ];
49
+ const result = patchOpsToSdkEditOps("hf-box", ops);
50
+ expect(result).toHaveLength(1);
51
+ expect(result[0]).toEqual({
52
+ type: "setAttribute",
53
+ target: "hf-box",
54
+ name: "contenteditable",
55
+ value: "true",
56
+ });
57
+ });
58
+
59
+ it("handles null value for attribute removal", () => {
60
+ const ops: PatchOperation[] = [{ type: "html-attribute", property: "hidden", value: null }];
61
+ const result = patchOpsToSdkEditOps("hf-box", ops);
62
+ expect(result[0]).toEqual({
63
+ type: "setAttribute",
64
+ target: "hf-box",
65
+ name: "hidden",
66
+ value: null,
67
+ });
68
+ });
69
+
70
+ it("returns empty array for unknown op types", () => {
71
+ const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[];
72
+ expect(patchOpsToSdkEditOps("hf-box", ops)).toHaveLength(0);
73
+ });
74
+ });
75
+
76
+ describe("sdkShadowDispatch (integration)", () => {
77
+ it("applies ops and returns no mismatches when SDK matches expected values", async () => {
78
+ const { sdkShadowDispatch } = await import("./sdkShadow");
79
+ const session = await openComposition(BASE_HTML);
80
+
81
+ const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
82
+ const result = sdkShadowDispatch(session, "hf-box", ops);
83
+
84
+ expect(result.dispatched).toBe(true);
85
+ expect(result.mismatches).toHaveLength(0);
86
+ expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f");
87
+ });
88
+
89
+ it("returns dispatched:false when hfId not found in session", async () => {
90
+ const { sdkShadowDispatch } = await import("./sdkShadow");
91
+ const session = await openComposition(BASE_HTML);
92
+
93
+ const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
94
+ const result = sdkShadowDispatch(session, "hf-missing", ops);
95
+
96
+ expect(result.dispatched).toBe(false);
97
+ expect(result.mismatches).toHaveLength(1);
98
+ expect(result.mismatches[0]).toMatchObject<SdkShadowMismatch>({
99
+ kind: "element_not_found",
100
+ hfId: "hf-missing",
101
+ });
102
+ });
103
+
104
+ it("applies text op and reads back via session.getElement", async () => {
105
+ const { sdkShadowDispatch } = await import("./sdkShadow");
106
+ const session = await openComposition(BASE_HTML);
107
+
108
+ const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "Updated" }];
109
+ sdkShadowDispatch(session, "hf-box", ops);
110
+
111
+ expect(session.getElement("hf-box")?.text).toBe("Updated");
112
+ });
113
+
114
+ it("applies attribute op and reads back via session.getElement", async () => {
115
+ const { sdkShadowDispatch } = await import("./sdkShadow");
116
+ const session = await openComposition(BASE_HTML);
117
+
118
+ const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
119
+ sdkShadowDispatch(session, "hf-box", ops);
120
+
121
+ expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero");
122
+ });
123
+
124
+ it("returns dispatch_error when dispatch throws — does not propagate", async () => {
125
+ const { sdkShadowDispatch } = await import("./sdkShadow");
126
+ const session = await openComposition(BASE_HTML);
127
+ // Poison dispatch so it throws on any call
128
+ session.dispatch = () => {
129
+ throw new Error("sdk internal error");
130
+ };
131
+
132
+ const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "red" }];
133
+ let result: ReturnType<typeof sdkShadowDispatch> | undefined;
134
+ expect(() => {
135
+ result = sdkShadowDispatch(session, "hf-box", ops);
136
+ }).not.toThrow();
137
+
138
+ expect(result!.dispatched).toBe(false);
139
+ expect(result!.mismatches).toHaveLength(1);
140
+ expect(result!.mismatches[0]).toMatchObject<SdkShadowMismatch>({
141
+ kind: "dispatch_error",
142
+ hfId: "hf-box",
143
+ error: expect.stringContaining("sdk internal error"),
144
+ });
145
+ });
146
+ });