@hyperframes/studio 0.8.55 → 0.8.57

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.
Files changed (145) hide show
  1. package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
  2. package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
  3. package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
  4. package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
  5. package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
  6. package/dist/index.d.ts +111 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +21534 -20683
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +18 -1
  13. package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
  14. package/src/components/EditorShell.selectionSync.test.tsx +57 -67
  15. package/src/components/EditorShell.tsx +62 -51
  16. package/src/components/EditorShellTestMocks.ts +46 -0
  17. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  18. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  19. package/src/components/editor/DomEditOverlay.tsx +19 -55
  20. package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
  21. package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
  22. package/src/components/editor/GridOverlay.tsx +12 -23
  23. package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
  24. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  25. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  26. package/src/components/editor/PreviewGuides.tsx +8 -10
  27. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  28. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  29. package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
  30. package/src/components/editor/PropertyPanel.tsx +4 -3
  31. package/src/components/editor/SnapToolbar.test.tsx +26 -12
  32. package/src/components/editor/SnapToolbar.tsx +30 -59
  33. package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
  34. package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
  35. package/src/components/editor/previewReadOnlyContext.tsx +59 -0
  36. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
  37. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  38. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  39. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
  40. package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
  41. package/src/components/editor/useDomEditNudge.ts +9 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
  43. package/src/components/editor/useOffCanvasIndicators.ts +50 -0
  44. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  45. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  46. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  47. package/src/components/editor/useVolumeAutomation.ts +57 -16
  48. package/src/components/nle/NLEContext.tsx +40 -4
  49. package/src/components/nle/NLEPreview.test.ts +66 -1
  50. package/src/components/nle/NLEPreview.tsx +88 -44
  51. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
  52. package/src/components/nle/PreviewOverlays.tsx +19 -24
  53. package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
  54. package/src/components/nle/PreviewPane.tsx +17 -2
  55. package/src/components/nle/TimelinePane.tsx +5 -1
  56. package/src/components/nle/previewReadOnly.test.tsx +129 -0
  57. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  58. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
  59. package/src/components/nle/usePreviewBlockDrop.ts +3 -1
  60. package/src/hooks/appHotkeysDispatch.ts +16 -1
  61. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  62. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  63. package/src/hooks/timelineTrackVisibility.ts +7 -8
  64. package/src/hooks/useAppHotkeys.test.ts +71 -3
  65. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  66. package/src/hooks/useAppHotkeys.ts +4 -0
  67. package/src/hooks/useCompositionDimensions.test.tsx +38 -0
  68. package/src/hooks/useCompositionDimensions.ts +17 -1
  69. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  70. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  71. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  72. package/src/hooks/useDomEditCommits.ts +3 -0
  73. package/src/hooks/useDomEditSession.test.tsx +1 -0
  74. package/src/hooks/useDomEditSession.ts +7 -4
  75. package/src/hooks/useDomEditWiring.ts +40 -40
  76. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  77. package/src/hooks/useDomGeometryCommits.ts +26 -20
  78. package/src/hooks/useFileTree.test.tsx +56 -0
  79. package/src/hooks/useFileTree.ts +8 -1
  80. package/src/hooks/useGestureCommit.test.tsx +29 -0
  81. package/src/hooks/useGestureCommit.ts +23 -0
  82. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  83. package/src/hooks/useInlineTextEdit.ts +11 -2
  84. package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
  85. package/src/hooks/useSdkSession.ts +46 -10
  86. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  87. package/src/hooks/useServerConnection.ts +81 -8
  88. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  89. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  90. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  91. package/src/hooks/useTimelineEditing.ts +2 -7
  92. package/src/index.ts +12 -0
  93. package/src/player/components/Player.test.ts +141 -13
  94. package/src/player/components/Player.tsx +58 -2
  95. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  96. package/src/player/components/Timeline.tsx +23 -573
  97. package/src/player/components/TimelineCanvas.tsx +9 -30
  98. package/src/player/components/TimelineClip.test.tsx +52 -0
  99. package/src/player/components/TimelineClip.tsx +13 -2
  100. package/src/player/components/TimelineOverlays.test.ts +67 -32
  101. package/src/player/components/TimelineOverlays.tsx +46 -107
  102. package/src/player/components/TimelineProvider.tsx +191 -0
  103. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  104. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  105. package/src/player/components/testRootHarness.ts +18 -0
  106. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  107. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  108. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  109. package/src/player/components/timelineTheme.test.ts +4 -4
  110. package/src/player/components/timelineTheme.ts +34 -30
  111. package/src/player/components/timelineTrackDisplay.ts +1 -4
  112. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  113. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  114. package/src/player/components/useTimelineProviderState.tsx +583 -0
  115. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  116. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  117. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  118. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  119. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  120. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  121. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  122. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  123. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  124. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -91
  126. package/src/player/store/playerStore.ts +0 -4
  127. package/src/player/store/timelineElement.ts +2 -4
  128. package/src/player/store/timelineResetState.ts +0 -1
  129. package/src/styles/hex-baseline.json +3 -4
  130. package/src/styles/studio.css +7 -7
  131. package/src/styles/theme.css +29 -0
  132. package/src/telemetry/events.test.ts +18 -0
  133. package/src/telemetry/events.ts +10 -0
  134. package/src/telemetry/system.test.ts +49 -0
  135. package/src/telemetry/system.ts +6 -0
  136. package/src/telemetry/tabId.test.ts +43 -0
  137. package/src/telemetry/tabId.ts +44 -0
  138. package/src/utils/previewCompositionSize.ts +26 -0
  139. package/src/utils/reloadDebug.ts +2 -4
  140. package/src/utils/sourcePatcher.ts +1 -1
  141. package/src/utils/studioTelemetry.test.ts +14 -0
  142. package/src/utils/studioTelemetry.ts +4 -0
  143. package/dist/assets/hyperframes-player-BjAGc2ID.js +0 -464
  144. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  145. package/src/player/hooks/useTimelineRowElements.ts +0 -17
@@ -12,10 +12,30 @@ import {
12
12
  readStudioPathOffset,
13
13
  readStudioRotation,
14
14
  } from "../components/editor/manualEdits";
15
- import { useDomGeometryCommits } from "./useDomGeometryCommits";
15
+ import { useDomGeometryCommits, type UseDomGeometryCommitsParams } from "./useDomGeometryCommits";
16
16
 
17
17
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
18
18
 
19
+ function mountCommits(
20
+ commitPositionPatchToHtml: UseDomGeometryCommitsParams["commitPositionPatchToHtml"],
21
+ readOnlyPreview = false,
22
+ ) {
23
+ let commits: ReturnType<typeof useDomGeometryCommits> | null = null;
24
+ function Probe() {
25
+ commits = useDomGeometryCommits({
26
+ previewIframeRef: { current: null },
27
+ showToast: vi.fn(),
28
+ commitPositionPatchToHtml,
29
+ readOnlyPreview,
30
+ });
31
+ return null;
32
+ }
33
+ const host = document.createElement("div");
34
+ const root = createRoot(host);
35
+ act(() => root.render(<Probe />));
36
+ return { commits: () => commits!, unmount: () => act(() => root.unmount()) };
37
+ }
38
+
19
39
  describe("useDomGeometryCommits rollback", () => {
20
40
  it("restores every optimistic geometry mutation when persistence rejects", async () => {
21
41
  const element = document.createElement("div");
@@ -30,7 +50,9 @@ describe("useDomGeometryCommits rollback", () => {
30
50
  element,
31
51
  } as unknown as DomEditSelection;
32
52
  const failure = new Error("save failed");
33
- const commitPositionPatchToHtml = vi.fn().mockRejectedValue(failure);
53
+ const commitPositionPatchToHtml = vi
54
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
55
+ .mockRejectedValue(failure);
34
56
  let commits: ReturnType<typeof useDomGeometryCommits> | null = null;
35
57
  const host = document.createElement("div");
36
58
  const root = createRoot(host);
@@ -40,6 +62,7 @@ describe("useDomGeometryCommits rollback", () => {
40
62
  previewIframeRef: { current: null },
41
63
  showToast: vi.fn(),
42
64
  commitPositionPatchToHtml,
65
+ readOnlyPreview: false,
43
66
  });
44
67
  return null;
45
68
  }
@@ -60,3 +83,63 @@ describe("useDomGeometryCommits rollback", () => {
60
83
  act(() => root.unmount());
61
84
  });
62
85
  });
86
+
87
+ describe("useDomGeometryCommits read-only preview", () => {
88
+ const selectionOn = (element: HTMLElement) =>
89
+ ({ id: element.id, selector: `#${element.id}`, element }) as unknown as DomEditSelection;
90
+
91
+ it("refuses a manual offset commit: no write, no history entry", async () => {
92
+ const element = document.createElement("div");
93
+ element.id = "ro-offset";
94
+ document.body.append(element);
95
+ applyStudioPathOffset(element, { x: 1, y: 2 });
96
+ const commitPositionPatchToHtml =
97
+ vi.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>();
98
+ const { commits, unmount } = mountCommits(commitPositionPatchToHtml, true);
99
+ await commits().handleDomPathOffsetCommit(selectionOn(element), { x: 99, y: 99 });
100
+ expect(readStudioPathOffset(element)).toEqual({ x: 1, y: 2 });
101
+ expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
102
+ unmount();
103
+ });
104
+
105
+ it("refuses a manual box-size commit: no write, no history entry", async () => {
106
+ const element = document.createElement("div");
107
+ element.id = "ro-size";
108
+ document.body.append(element);
109
+ applyStudioBoxSize(element, { width: 10, height: 20 });
110
+ const commitPositionPatchToHtml =
111
+ vi.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>();
112
+ const { commits, unmount } = mountCommits(commitPositionPatchToHtml, true);
113
+ await commits().handleDomBoxSizeCommit(selectionOn(element), { width: 999, height: 999 });
114
+ expect(readStudioBoxSize(element)).toEqual({ width: 10, height: 20 });
115
+ expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
116
+ unmount();
117
+ });
118
+
119
+ it("refuses a manual rotation commit: no write, no history entry", async () => {
120
+ const element = document.createElement("div");
121
+ element.id = "ro-rotate";
122
+ document.body.append(element);
123
+ applyStudioRotation(element, { angle: 5 });
124
+ const commitPositionPatchToHtml =
125
+ vi.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>();
126
+ const { commits, unmount } = mountCommits(commitPositionPatchToHtml, true);
127
+ await commits().handleDomRotationCommit(selectionOn(element), { angle: 350 });
128
+ expect(readStudioRotation(element)).toEqual({ angle: 5 });
129
+ expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
130
+ unmount();
131
+ });
132
+
133
+ it("still commits an offset with the flag off", async () => {
134
+ const element = document.createElement("div");
135
+ element.id = "rw-offset";
136
+ document.body.append(element);
137
+ const commitPositionPatchToHtml = vi
138
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
139
+ .mockResolvedValue(undefined);
140
+ const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
141
+ await commits().handleDomPathOffsetCommit(selectionOn(element), { x: 5, y: 6 });
142
+ expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(1);
143
+ unmount();
144
+ });
145
+ });
@@ -28,9 +28,20 @@ import { isElementGsapTargeted } from "./gsapTargetCache";
28
28
  const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
29
29
  "This element is GSAP-animated — dragging via CSS would corrupt keyframes";
30
30
 
31
+ function rejectGsapCssFallback(
32
+ selection: DomEditSelection,
33
+ previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
34
+ showToast: (message: string, tone?: "error" | "info") => void,
35
+ ): Promise<never> | null {
36
+ if (!isElementGsapTargeted(previewIframeRef.current, selection.element)) return null;
37
+ const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
38
+ showToast(error.message, "error");
39
+ return Promise.reject(error);
40
+ }
41
+
31
42
  // ── Hook ──
32
43
 
33
- interface UseDomGeometryCommitsParams {
44
+ export interface UseDomGeometryCommitsParams {
34
45
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
35
46
  showToast: (message: string, tone?: "error" | "info") => void;
36
47
  commitPositionPatchToHtml: (
@@ -38,25 +49,24 @@ interface UseDomGeometryCommitsParams {
38
49
  patches: PatchOperation[],
39
50
  options: { label: string; coalesceKey: string; skipRefresh?: boolean },
40
51
  ) => Promise<void>;
52
+ readOnlyPreview: boolean;
41
53
  }
42
54
 
43
55
  export function useDomGeometryCommits({
44
56
  previewIframeRef,
45
57
  showToast,
46
58
  commitPositionPatchToHtml,
59
+ readOnlyPreview,
47
60
  }: UseDomGeometryCommitsParams) {
48
61
  const handleDomPathOffsetCommit = useCallback(
49
62
  (selection: DomEditSelection, next: { x: number; y: number }) => {
63
+ if (readOnlyPreview) return Promise.resolve();
50
64
  // ponytail: GSAP-targeted elements are blocked (no SDK position-in-script op); CSS-path
51
65
  // elements fall through to commitPositionPatchToHtml → persistDomEditOperations →
52
66
  // onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done).
53
67
  // Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR.
54
- const gsapTargeted = isElementGsapTargeted(previewIframeRef.current, selection.element);
55
- if (gsapTargeted) {
56
- const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
57
- showToast(error.message, "error");
58
- return Promise.reject(error);
59
- }
68
+ const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
69
+ if (gsapFallback) return gsapFallback;
60
70
  const before = captureStudioPathOffset(selection.element);
61
71
  applyStudioPathOffset(selection.element, next);
62
72
  return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
@@ -67,7 +77,7 @@ export function useDomGeometryCommits({
67
77
  throw error;
68
78
  });
69
79
  },
70
- [commitPositionPatchToHtml, previewIframeRef, showToast],
80
+ [commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
71
81
  );
72
82
 
73
83
  const handleDomBoxSizeCommit = useCallback(
@@ -76,11 +86,9 @@ export function useDomGeometryCommits({
76
86
  next: { width: number; height: number },
77
87
  offset?: { x: number; y: number },
78
88
  ) => {
79
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
80
- const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
81
- showToast(error.message, "error");
82
- return Promise.reject(error);
83
- }
89
+ if (readOnlyPreview) return Promise.resolve();
90
+ const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
91
+ if (gsapFallback) return gsapFallback;
84
92
  const beforeSize = captureStudioBoxSize(selection.element);
85
93
  const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
86
94
  applyStudioBoxSize(selection.element, next);
@@ -104,16 +112,14 @@ export function useDomGeometryCommits({
104
112
  throw error;
105
113
  });
106
114
  },
107
- [commitPositionPatchToHtml, previewIframeRef, showToast],
115
+ [commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
108
116
  );
109
117
 
110
118
  const handleDomRotationCommit = useCallback(
111
119
  (selection: DomEditSelection, next: { angle: number }) => {
112
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
113
- const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
114
- showToast(error.message, "error");
115
- return Promise.reject(error);
116
- }
120
+ if (readOnlyPreview) return Promise.resolve();
121
+ const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
122
+ if (gsapFallback) return gsapFallback;
117
123
  const before = captureStudioRotation(selection.element);
118
124
  applyStudioRotation(selection.element, next);
119
125
  return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
@@ -124,7 +130,7 @@ export function useDomGeometryCommits({
124
130
  throw error;
125
131
  });
126
132
  },
127
- [commitPositionPatchToHtml, previewIframeRef, showToast],
133
+ [commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
128
134
  );
129
135
 
130
136
  const handleDomManualEditsReset = useCallback(
@@ -5,6 +5,12 @@ import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { useFileTree } from "./useFileTree";
7
7
 
8
+ vi.mock("../components/feedback/projectProvenance", () => ({
9
+ captureProjectProvenance: vi.fn(),
10
+ }));
11
+
12
+ import { captureProjectProvenance } from "../components/feedback/projectProvenance";
13
+
8
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
15
 
10
16
  let root: Root | null = null;
@@ -52,6 +58,56 @@ async function renderHarness(projectId: string): Promise<{ current: Handle | nul
52
58
  return handleRef;
53
59
  }
54
60
 
61
+ describe("useFileTree unresolved project", () => {
62
+ const provenance = vi.mocked(captureProjectProvenance);
63
+
64
+ afterEach(() => {
65
+ provenance.mockClear();
66
+ });
67
+
68
+ /** Render the tree for `projectId` against one canned response. */
69
+ async function renderWithResponse(
70
+ projectId: string,
71
+ build: () => Response,
72
+ ): Promise<{ current: Handle | null }> {
73
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => build());
74
+ const handleRef = await renderHarness(projectId);
75
+ fetchSpy.mockRestore();
76
+ return handleRef;
77
+ }
78
+
79
+ const notFound = () =>
80
+ new Response(JSON.stringify({ error: "not found" }), {
81
+ status: 404,
82
+ headers: { "content-type": "application/json" },
83
+ });
84
+ const emptyListing = () =>
85
+ new Response(JSON.stringify({ files: [], compositions: [] }), { status: 200 });
86
+
87
+ // An unresolvable project answers 404 with a JSON body. Without an `r.ok`
88
+ // check that parsed cleanly and took the SUCCESS branch, recording an empty
89
+ // tree and an empty provenance snapshot for a project that was never read —
90
+ // so a bug report filed from that tab described a project that does not
91
+ // exist. The tree itself stays empty either way; the provenance lie is the
92
+ // part that leaves the browser.
93
+ it("does not snapshot provenance for a project the server could not resolve", async () => {
94
+ const handleRef = await renderWithResponse("gone-project", notFound);
95
+
96
+ expect(handleRef.current?.compositions).toEqual([]);
97
+ expect(provenance).not.toHaveBeenCalled();
98
+ });
99
+
100
+ // The counterpart, and the reason the check above cannot be collapsed into
101
+ // "skip provenance whenever the tree is empty": a project with no
102
+ // compositions yet is a real, successful read and must still be snapshotted.
103
+ it("still snapshots provenance when a resolved project has no compositions", async () => {
104
+ const handleRef = await renderWithResponse("empty-project", emptyListing);
105
+
106
+ expect(handleRef.current?.compositions).toEqual([]);
107
+ expect(provenance).toHaveBeenCalledWith("empty-project", [], []);
108
+ });
109
+ });
110
+
55
111
  describe("useFileTree.refreshFileTree", () => {
56
112
  it("updates compositions, not just the raw file list, on refresh", async () => {
57
113
  let call = 0;
@@ -39,7 +39,14 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
39
39
  let cancelled = false;
40
40
  setFetched({ projectId, loaded: false, fileTree: [], compositionPaths: [], projectDir: null });
41
41
  fetch(buildProjectApiPath(projectId))
42
- .then((r) => r.json())
42
+ // An unresolvable project answers 404 with a JSON body, so without this
43
+ // the error path parsed cleanly and the success branch below recorded an
44
+ // empty tree — and an empty provenance snapshot — for a project that was
45
+ // never read. Throwing hands it to the catch instead.
46
+ .then((r) => {
47
+ if (!r.ok) throw new Error(`tree fetch failed: ${r.status}`);
48
+ return r.json();
49
+ })
43
50
  .then((data: { files?: string[]; dir?: string; compositions?: string[] }) => {
44
51
  if (cancelled) return;
45
52
  setFetched({
@@ -107,6 +107,7 @@ describe("useGestureCommit", () => {
107
107
  previewIframeRef: { current: iframe },
108
108
  showToast: vi.fn(),
109
109
  isGestureRecordingRef: { current: false },
110
+ readOnlyPreview: false,
110
111
  });
111
112
  return null;
112
113
  }
@@ -127,4 +128,32 @@ describe("useGestureCommit", () => {
127
128
  expect(options[1]).toEqual(expect.objectContaining({ coalesceMs: Infinity, softReload: true }));
128
129
  expect(options[1]).not.toHaveProperty("skipReload");
129
130
  });
131
+
132
+ it("does not start a recording while the preview is read-only", () => {
133
+ const element = document.createElement("div");
134
+ const commitMutation = vi.fn(async () => {});
135
+ const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
136
+ function Probe() {
137
+ captured.hook = useGestureCommit({
138
+ domEditSessionRef: {
139
+ current: {
140
+ domEditSelection: makeSelection(element),
141
+ selectedGsapAnimations: [],
142
+ commitMutation,
143
+ },
144
+ },
145
+ previewIframeRef: { current: document.createElement("iframe") },
146
+ showToast: vi.fn(),
147
+ isGestureRecordingRef: { current: false },
148
+ readOnlyPreview: true,
149
+ });
150
+ return null;
151
+ }
152
+ const root = mountReactHarness(<Probe />);
153
+ cleanup = () => act(() => root.unmount());
154
+
155
+ act(() => captured.hook?.handleToggleRecording());
156
+ expect(gestureRecording.startRecording).not.toHaveBeenCalled();
157
+ expect(commitMutation).not.toHaveBeenCalled();
158
+ });
130
159
  });
@@ -87,6 +87,7 @@ interface UseGestureCommitParams {
87
87
  previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
88
88
  showToast: (message: string, tone?: "error" | "info") => void;
89
89
  isGestureRecordingRef: React.MutableRefObject<boolean>;
90
+ readOnlyPreview: boolean;
90
91
  }
91
92
 
92
93
  export interface UseGestureCommitResult {
@@ -101,6 +102,7 @@ export function useGestureCommit({
101
102
  previewIframeRef,
102
103
  showToast,
103
104
  isGestureRecordingRef,
105
+ readOnlyPreview,
104
106
  }: UseGestureCommitParams): UseGestureCommitResult {
105
107
  const gestureRecording = useGestureRecording();
106
108
  const [gestureState, setGestureState] = useState<"idle" | "recording">("idle");
@@ -115,6 +117,20 @@ export function useGestureCommit({
115
117
  // Unmount: clear auto-stop interval
116
118
  useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
117
119
 
120
+ const cancelRecording = useCallback(() => {
121
+ clearInterval(recordingAutoStopRef.current);
122
+ gestureRecording.stopRecording();
123
+ gestureRecording.clearSamples();
124
+ gestureStateRef.current = "idle";
125
+ isGestureRecordingRef.current = false;
126
+ capturedSelectionRef.current = null;
127
+ setGestureState("idle");
128
+ }, [gestureRecording, isGestureRecordingRef]);
129
+
130
+ useEffect(() => {
131
+ if (readOnlyPreview && gestureStateRef.current === "recording") cancelRecording();
132
+ }, [cancelRecording, readOnlyPreview]);
133
+
118
134
  // fallow-ignore-next-line complexity
119
135
  const stopAndCommitRecording = useCallback(async () => {
120
136
  clearInterval(recordingAutoStopRef.current);
@@ -335,9 +351,14 @@ export function useGestureCommit({
335
351
  // fallow-ignore-next-line complexity
336
352
  const handleToggleRecording = useCallback(() => {
337
353
  if (gestureStateRef.current === "recording") {
354
+ if (readOnlyPreview) {
355
+ cancelRecording();
356
+ return;
357
+ }
338
358
  void stopAndCommitRecording();
339
359
  return;
340
360
  }
361
+ if (readOnlyPreview) return;
341
362
  const sel = domEditSessionRef.current.domEditSelection;
342
363
  if (!sel) {
343
364
  showToast("Select an element first", "error");
@@ -373,9 +394,11 @@ export function useGestureCommit({
373
394
  gestureRecording,
374
395
  showToast,
375
396
  stopAndCommitRecording,
397
+ cancelRecording,
376
398
  previewIframeRef,
377
399
  domEditSessionRef,
378
400
  isGestureRecordingRef,
401
+ readOnlyPreview,
379
402
  ]);
380
403
 
381
404
  return { gestureState, gestureRecording, handleToggleRecording };
@@ -3,6 +3,7 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { PreviewReadOnlyProvider } from "../components/editor/previewReadOnlyContext";
6
7
  import { useInlineTextEdit, type InlineTextEditControls } from "./useInlineTextEdit";
7
8
 
8
9
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -20,7 +21,7 @@ function heading(text = "Motion Playground"): HTMLElement {
20
21
  return element;
21
22
  }
22
23
 
23
- function mount(onCommit = vi.fn(), onPause = vi.fn()) {
24
+ function mount(onCommit = vi.fn(), onPause = vi.fn(), readOnly = false) {
24
25
  const controls: { current: InlineTextEditControls | null } = { current: null };
25
26
  function Probe() {
26
27
  controls.current = useInlineTextEdit({ onCommit, onPause });
@@ -29,7 +30,13 @@ function mount(onCommit = vi.fn(), onPause = vi.fn()) {
29
30
  const host = document.createElement("div");
30
31
  document.body.append(host);
31
32
  const root = createRoot(host);
32
- act(() => root.render(<Probe />));
33
+ act(() =>
34
+ root.render(
35
+ <PreviewReadOnlyProvider readOnly={readOnly}>
36
+ <Probe />
37
+ </PreviewReadOnlyProvider>,
38
+ ),
39
+ );
33
40
  return { controls: () => controls.current!, root, onCommit, onPause };
34
41
  }
35
42
 
@@ -552,4 +559,18 @@ describe("useInlineTextEdit with styled runs", () => {
552
559
  expect(insertText).toHaveBeenCalledWith("insertText", false, "plain words");
553
560
  act(() => root.unmount());
554
561
  });
562
+
563
+ it("refuses to open an edit while the preview is read-only", () => {
564
+ const element = heading();
565
+ const { controls, root, onCommit, onPause } = mount(vi.fn(), vi.fn(), true);
566
+ let opened = true;
567
+ act(() => {
568
+ opened = controls().start(element);
569
+ });
570
+ expect(opened).toBe(false);
571
+ expect(element.hasAttribute("contenteditable")).toBe(false);
572
+ expect(onPause).not.toHaveBeenCalled();
573
+ expect(onCommit).not.toHaveBeenCalled();
574
+ act(() => root.unmount());
575
+ });
555
576
  });
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { sanitizeRichTextChildren } from "@hyperframes/core/rich-text-sanitize";
3
+ import { usePreviewReadOnly } from "../components/editor/previewReadOnlyContext";
3
4
 
4
5
  /**
5
6
  * Editing an element's text where it sits, in the composition itself.
@@ -89,6 +90,7 @@ export function useInlineTextEdit({
89
90
  /** Stop playback, so the element is not animating under the caret. */
90
91
  onPause?: () => void;
91
92
  }): InlineTextEditControls {
93
+ const readOnly = usePreviewReadOnly();
92
94
  const [session, setSession] = useState<InlineTextEditSession | null>(null);
93
95
  // The teardown reads this rather than the state, so an exit path that runs
94
96
  // before React re-renders still sees the element it has to clean up.
@@ -123,7 +125,7 @@ export function useInlineTextEdit({
123
125
 
124
126
  const start = useCallback(
125
127
  (element: HTMLElement, caretAt?: { x: number; y: number }): boolean => {
126
- if (openRef.current) return false;
128
+ if (openRef.current || readOnly) return false;
127
129
 
128
130
  const open = {
129
131
  element,
@@ -156,7 +158,7 @@ export function useInlineTextEdit({
156
158
  framesRef.current = raf ?? null;
157
159
  return true;
158
160
  },
159
- [onPause],
161
+ [onPause, readOnly],
160
162
  );
161
163
 
162
164
  const commit = useCallback(() => {
@@ -179,6 +181,13 @@ export function useInlineTextEdit({
179
181
  teardown();
180
182
  }, [teardown]);
181
183
 
184
+ // Read-only can be enabled while an edit is already open. Close that
185
+ // session as a cancellation so the preview cannot keep committing through
186
+ // the now-disabled editing surface.
187
+ useEffect(() => {
188
+ if (readOnly && openRef.current) cancel();
189
+ }, [cancel, readOnly]);
190
+
182
191
  // The keys belong to the element, not to the document: the element lives in
183
192
  // the preview's own document, so a listener on Studio's would never see them.
184
193
  useEffect(() => {
@@ -147,6 +147,86 @@ describe("useSdkSession ownership", () => {
147
147
  });
148
148
  });
149
149
 
150
+ describe("useSdkSession unreachable project", () => {
151
+ beforeEach(() => {
152
+ openComposition.mockReset();
153
+ trackMock.mockClear();
154
+ class FakeEventSource {
155
+ addEventListener(): void {}
156
+ close(): void {}
157
+ }
158
+ vi.stubGlobal("EventSource", FakeEventSource);
159
+ });
160
+
161
+ afterEach(() => {
162
+ vi.unstubAllGlobals();
163
+ });
164
+
165
+ function probeHandle(projectId: string) {
166
+ const captured: { handle: SdkSessionHandle | null } = { handle: null };
167
+ function HandleProbe() {
168
+ captured.handle = useSdkSession(projectId, "index.html");
169
+ return null;
170
+ }
171
+ return { captured, HandleProbe };
172
+ }
173
+
174
+ const failedRead = (status: number) => async () =>
175
+ ({ ok: false, status, json: async () => ({}) }) as Response;
176
+
177
+ /** Render the hook for `projectId` against a stubbed read and let it settle. */
178
+ async function readOutcome(
179
+ projectId: string,
180
+ read: () => Promise<Response>,
181
+ ): Promise<{ captured: { handle: SdkSessionHandle | null }; unmount: () => Promise<void> }> {
182
+ vi.stubGlobal("fetch", vi.fn(read));
183
+ const { captured, HandleProbe } = probeHandle(projectId);
184
+ const root = createRoot(document.createElement("div"));
185
+ await act(async () => root.render(<HandleProbe />));
186
+ await flushAsyncEffects();
187
+ return { captured, unmount: () => act(async () => root.unmount()) };
188
+ }
189
+
190
+ // A 404 on the composition read is the one failure that says something about
191
+ // the PROJECT rather than the request: under the CLI host it means this
192
+ // Studio serves a different one. Every edit then fails silently, so the UI
193
+ // needs the id to explain that.
194
+ it("names the project when the read is a 404", async () => {
195
+ const { captured, unmount } = await readOutcome("gone-project", failedRead(404));
196
+
197
+ expect(captured.handle?.unreachableProject).toBe("gone-project");
198
+ await unmount();
199
+ });
200
+
201
+ // A 500 says the request failed, not that the project is elsewhere. Claiming
202
+ // otherwise would tell a user their tab is pointed at the wrong project when
203
+ // the server is merely unwell.
204
+ it("stays quiet on a failure that says nothing about the project", async () => {
205
+ const { captured, unmount } = await readOutcome("project-a", failedRead(500));
206
+
207
+ expect(captured.handle?.unreachableProject).toBeNull();
208
+ await unmount();
209
+ });
210
+
211
+ it("clears the state once the project resolves", async () => {
212
+ const { captured, unmount } = await readOutcome("gone-project", failedRead(404));
213
+ expect(captured.handle?.unreachableProject).toBe("gone-project");
214
+
215
+ openComposition.mockResolvedValue(fakeSession());
216
+ vi.stubGlobal(
217
+ "fetch",
218
+ vi.fn(async () => response("PROJECT_A")),
219
+ );
220
+ await act(async () => {
221
+ captured.handle?.forceReload();
222
+ });
223
+ await flushAsyncEffects();
224
+
225
+ expect(captured.handle?.unreachableProject).toBeNull();
226
+ await unmount();
227
+ });
228
+ });
229
+
150
230
  describe("useSdkSession unavailable telemetry", () => {
151
231
  beforeEach(() => {
152
232
  openComposition.mockReset();