@hyperframes/studio 0.8.49 → 0.8.51

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 (281) hide show
  1. package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-Cn_PpHqP.js} +3 -3
  2. package/dist/assets/index-C05SNrvY.js +360 -0
  3. package/dist/assets/{index-mIgSsKqs.js → index-CPS3Cvj8.js} +1 -1
  4. package/dist/assets/{index-BGovDksE.js → index-Clrb11lj.js} +1 -1
  5. package/dist/assets/index-DUR9qzQM.css +1 -0
  6. package/dist/index.d.ts +29 -16
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7283 -8606
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.js.map +1 -1
  11. package/package.json +11 -12
  12. package/src/App.tsx +132 -143
  13. package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
  14. package/src/captions/components/CaptionOverlayUtils.ts +2 -3
  15. package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
  16. package/src/captions/components/CaptionTimeline.tsx +1 -1
  17. package/src/captions/components/shared.tsx +2 -2
  18. package/src/components/AskAgentModal.tsx +5 -5
  19. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  20. package/src/components/EditorShell.tsx +28 -7
  21. package/src/components/ExternalFileConflictBanner.tsx +16 -16
  22. package/src/components/LintModal.tsx +6 -16
  23. package/src/components/MediaPreview.tsx +2 -2
  24. package/src/components/SaveQueuePausedBanner.tsx +2 -2
  25. package/src/components/StudioHeader.tsx +2 -62
  26. package/src/components/StudioLeftSidebar.tsx +7 -4
  27. package/src/components/StudioOverlays.tsx +2 -6
  28. package/src/components/StudioRightPanel.tsx +4 -4
  29. package/src/components/StudioToast.tsx +2 -2
  30. package/src/components/TimelineToolbar.tsx +7 -7
  31. package/src/components/dock/Dock.test.tsx +192 -0
  32. package/src/components/dock/Dock.tsx +264 -0
  33. package/src/components/dock/dock.css +23 -0
  34. package/src/components/dock/dockLayout.ts +58 -0
  35. package/src/components/dock/dockLayoutSchema.ts +66 -0
  36. package/src/components/dock/dockLayoutStore.ts +91 -0
  37. package/src/components/dock/panelRegistry.ts +67 -0
  38. package/src/components/editor/AnimationCard.tsx +4 -4
  39. package/src/components/editor/AnimationCardParts.tsx +6 -6
  40. package/src/components/editor/BlockParamsPanel.tsx +4 -4
  41. package/src/components/editor/BorderRadiusEditor.tsx +1 -1
  42. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  43. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  44. package/src/components/editor/DomEditOverlay.tsx +1 -1
  45. package/src/components/editor/EaseCurveSection.tsx +1 -1
  46. package/src/components/editor/EaseParamFields.tsx +5 -5
  47. package/src/components/editor/FileTree.tsx +4 -4
  48. package/src/components/editor/FileTreeIcons.tsx +1 -1
  49. package/src/components/editor/FileTreeNodes.tsx +13 -13
  50. package/src/components/editor/InlineTextToolbar.tsx +1 -1
  51. package/src/components/editor/InspectorHeaderActions.tsx +3 -3
  52. package/src/components/editor/KeyframeDiamond.tsx +1 -1
  53. package/src/components/editor/KeyframeEaseList.tsx +1 -1
  54. package/src/components/editor/LayersPanel.tsx +3 -3
  55. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  56. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  57. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  58. package/src/components/editor/PreviewGuides.tsx +125 -0
  59. package/src/components/editor/PromotableControl.tsx +2 -2
  60. package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
  61. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  62. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  63. package/src/components/editor/SnapToolbar.tsx +27 -3
  64. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  65. package/src/components/editor/TopologyLens.tsx +4 -4
  66. package/src/components/editor/Transform3DCube.tsx +2 -2
  67. package/src/components/editor/audioFxRevealTarget.ts +1 -0
  68. package/src/components/editor/domEditing.test.ts +1 -1
  69. package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
  70. package/src/components/editor/domEditingAgentPrompt.ts +9 -3
  71. package/src/components/editor/previewGuidesStore.ts +26 -0
  72. package/src/components/editor/propertyPanelColor.tsx +11 -11
  73. package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
  74. package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
  75. package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
  76. package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
  77. package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
  78. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  79. package/src/components/editor/propertyPanelFill.tsx +4 -4
  80. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  81. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
  82. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  83. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
  84. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
  85. package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
  86. package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
  87. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
  88. package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
  89. package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
  90. package/src/components/editor/propertyPanelFont.tsx +5 -5
  91. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  92. package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
  93. package/src/components/editor/propertyPanelFxControls.tsx +6 -6
  94. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  95. package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
  96. package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
  97. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  98. package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
  99. package/src/components/editor/propertyPanelSections.tsx +8 -8
  100. package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
  101. package/src/components/feedback/feedbackTrigger.ts +0 -1
  102. package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
  103. package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
  104. package/src/components/nle/NLEPreview.test.ts +13 -0
  105. package/src/components/nle/NLEPreview.tsx +12 -7
  106. package/src/components/nle/PreviewOverlays.tsx +6 -6
  107. package/src/components/nle/PreviewPane.test.tsx +52 -0
  108. package/src/components/nle/PreviewPane.tsx +4 -2
  109. package/src/components/nle/TimelinePane.tsx +17 -3
  110. package/src/components/nle/TimelineResizeDivider.tsx +2 -2
  111. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  112. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  113. package/src/components/panels/SlideshowPanel.tsx +2 -2
  114. package/src/components/panels/SlideshowSubPanels.tsx +20 -17
  115. package/src/components/panels/VariablesBindElement.tsx +2 -2
  116. package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
  117. package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
  118. package/src/components/panels/VariablesPanel.tsx +3 -3
  119. package/src/components/panels/VariablesValueControls.tsx +2 -2
  120. package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
  121. package/src/components/renders/RenderQueue.test.tsx +7 -0
  122. package/src/components/renders/RenderQueue.tsx +6 -6
  123. package/src/components/renders/RenderQueueItem.tsx +6 -6
  124. package/src/components/sidebar/AssetCard.tsx +8 -8
  125. package/src/components/sidebar/AssetContextMenu.tsx +8 -8
  126. package/src/components/sidebar/AssetsTab.tsx +2 -2
  127. package/src/components/sidebar/AudioRow.tsx +6 -6
  128. package/src/components/sidebar/BlocksTab.tsx +6 -6
  129. package/src/components/sidebar/CompositionsTab.tsx +5 -5
  130. package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
  131. package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
  132. package/src/components/sidebar/LeftSidebar.tsx +19 -5
  133. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  134. package/src/components/ui/Button.test.tsx +60 -0
  135. package/src/components/ui/Button.tsx +4 -4
  136. package/src/components/ui/SearchInput.tsx +2 -2
  137. package/src/components/ui/Tooltip.tsx +1 -1
  138. package/src/hooks/appHotkeysDispatch.ts +11 -0
  139. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  140. package/src/hooks/timelineTrackVisibility.ts +8 -11
  141. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  142. package/src/hooks/useAppHotkeys.ts +4 -1
  143. package/src/hooks/useAskAgentModal.ts +2 -0
  144. package/src/hooks/useClipboard.test.ts +234 -0
  145. package/src/hooks/useClipboard.ts +264 -70
  146. package/src/hooks/useGroupCommits.test.ts +0 -1
  147. package/src/hooks/useLintModal.test.tsx +65 -0
  148. package/src/hooks/useLintModal.ts +3 -0
  149. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  150. package/src/hooks/useRazorSplit.test.ts +0 -2
  151. package/src/hooks/useRazorSplit.test.tsx +0 -1
  152. package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
  153. package/src/hooks/useSdkSession.ts +43 -10
  154. package/src/hooks/useStudioContextValue.ts +5 -19
  155. package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
  156. package/src/hooks/useTimelineAssetDropOps.ts +52 -11
  157. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  158. package/src/main.tsx +9 -3
  159. package/src/player/components/AudioWaveform.tsx +1 -1
  160. package/src/player/components/AutomationSelectionMenu.tsx +2 -2
  161. package/src/player/components/ClipContextMenu.tsx +90 -10
  162. package/src/player/components/CompositionThumbnail.test.ts +0 -3
  163. package/src/player/components/CompositionThumbnail.tsx +3 -4
  164. package/src/player/components/EditModal.tsx +1 -1
  165. package/src/player/components/ImageThumbnail.tsx +1 -1
  166. package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
  167. package/src/player/components/LayerDisclosureRow.tsx +2 -2
  168. package/src/player/components/PlayerControls.tsx +1 -1
  169. package/src/player/components/ShortcutsPanel.tsx +9 -9
  170. package/src/player/components/SpeedMenu.tsx +3 -5
  171. package/src/player/components/Timeline.tsx +16 -15
  172. package/src/player/components/TimelineCanvas.tsx +45 -8
  173. package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
  174. package/src/player/components/TimelineEmptyState.tsx +5 -5
  175. package/src/player/components/TimelineFxButton.tsx +6 -6
  176. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  177. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  178. package/src/player/components/TimelineGroupHeader.tsx +5 -5
  179. package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
  180. package/src/player/components/TimelineGroupRow.tsx +1 -1
  181. package/src/player/components/TimelineLanes.test.tsx +54 -1
  182. package/src/player/components/TimelineLanes.tsx +2 -5
  183. package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
  184. package/src/player/components/TimelineOverlays.tsx +12 -0
  185. package/src/player/components/TimelineRuler.tsx +1 -1
  186. package/src/player/components/TimelineShortcutHint.tsx +1 -1
  187. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  188. package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
  189. package/src/player/components/TimelineTypes.ts +6 -1
  190. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  191. package/src/player/components/VideoThumbnail.tsx +4 -2
  192. package/src/player/components/VolumeControl.tsx +2 -2
  193. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  194. package/src/player/components/timelineCallbacks.ts +8 -8
  195. package/src/player/components/timelineClipChildren.test.ts +2 -2
  196. package/src/player/components/timelineClipChildren.tsx +2 -1
  197. package/src/player/components/timelineClipDragCommit.ts +2 -2
  198. package/src/player/components/timelineClipDragPreview.ts +51 -27
  199. package/src/player/components/timelineClipDragTypes.ts +3 -0
  200. package/src/player/components/timelineDragDrop.test.tsx +92 -1
  201. package/src/player/components/timelineDragDrop.ts +82 -33
  202. package/src/player/components/timelineEditCapabilities.ts +1 -2
  203. package/src/player/components/timelineEditing.test.ts +0 -16
  204. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  205. package/src/player/components/timelineGroupEditing.ts +0 -1
  206. package/src/player/components/timelineLaneProps.ts +3 -0
  207. package/src/player/components/timelineSnapping.test.ts +27 -0
  208. package/src/player/components/timelineSnapping.ts +14 -1
  209. package/src/player/components/timelineTrackDisplay.ts +1 -1
  210. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  211. package/src/player/components/trackHeaderLabelRows.tsx +4 -4
  212. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  213. package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
  214. package/src/player/components/useTimelineClipDrag.ts +30 -3
  215. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  216. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  217. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  218. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  219. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  220. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  221. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  222. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  224. package/src/player/lib/describeClips.test.ts +95 -0
  225. package/src/player/lib/describeClips.ts +52 -0
  226. package/src/player/lib/timelineDOM.test.ts +0 -80
  227. package/src/player/lib/timelineDOM.ts +1 -61
  228. package/src/player/lib/timelineElementHelpers.ts +2 -53
  229. package/src/player/store/playerStore.ts +5 -0
  230. package/src/player/store/timelineElement.ts +1 -3
  231. package/src/shims/canvasBrowserStub.js +50 -0
  232. package/src/styles/iconTokens.ts +20 -0
  233. package/src/styles/palette-reset.css +6 -0
  234. package/src/styles/studio.css +24 -21
  235. package/src/styles/tailwind-preset.shared.js +3 -0
  236. package/src/styles/theme.css +213 -0
  237. package/src/styles/theme.test.ts +196 -0
  238. package/src/utils/clipboardPayload.test.ts +42 -2
  239. package/src/utils/clipboardPayload.ts +57 -32
  240. package/src/utils/previewSafeMargins.test.ts +34 -0
  241. package/src/utils/previewSafeMargins.ts +10 -0
  242. package/src/utils/razorSplitTransaction.test.ts +0 -1
  243. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  244. package/src/utils/studioUiPreferences.test.ts +43 -0
  245. package/src/utils/studioUiPreferences.ts +34 -6
  246. package/src/utils/timelineDropTrackInsert.test.ts +134 -0
  247. package/src/utils/timelineDropTrackInsert.ts +83 -0
  248. package/src/utils/timelineElementSplit.test.ts +1 -2
  249. package/src/utils/timelineElementSplit.ts +0 -1
  250. package/src/webmcp/tools/lookTools.test.ts +40 -0
  251. package/src/webmcp/tools/lookTools.ts +32 -1
  252. package/dist/assets/index-B9LfWNyM.css +0 -1
  253. package/dist/assets/index-Bgmx2h7W.js +0 -441
  254. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  255. package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
  256. package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
  257. package/src/components/storyboard/FramePoster.test.tsx +0 -61
  258. package/src/components/storyboard/FramePoster.tsx +0 -86
  259. package/src/components/storyboard/StoryboardDirection.tsx +0 -45
  260. package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
  261. package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
  262. package/src/components/storyboard/StoryboardGrid.tsx +0 -55
  263. package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
  264. package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
  265. package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
  266. package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
  267. package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
  268. package/src/components/storyboard/StoryboardView.tsx +0 -169
  269. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
  270. package/src/components/storyboard/frameComments.test.ts +0 -95
  271. package/src/components/storyboard/frameComments.ts +0 -115
  272. package/src/components/storyboard/frameStatus.ts +0 -36
  273. package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
  274. package/src/components/storyboard/storyboardReviewStage.ts +0 -45
  275. package/src/components/storyboard/useFrameComments.ts +0 -98
  276. package/src/contexts/ViewModeContext.tsx +0 -142
  277. package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
  278. package/src/hooks/useProjectSignaturePoll.ts +0 -68
  279. package/src/hooks/useStoryboard.ts +0 -99
  280. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  281. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
@@ -2,14 +2,23 @@ import { useCallback, useRef } from "react";
2
2
  import type { TimelineElement } from "../player";
3
3
  import { usePlayerStore } from "../player";
4
4
  import type { DomEditSelection } from "../components/editor/domEditing";
5
- import { type ClipboardPayload, deduplicateIds, insertAsSibling } from "../utils/clipboardPayload";
5
+ import {
6
+ type ClipboardPayload,
7
+ type TimelineClipboardClip,
8
+ ID_ATTR_RE,
9
+ deduplicateIds,
10
+ insertAsSibling,
11
+ } from "../utils/clipboardPayload";
6
12
  import { collectHtmlIds } from "../utils/studioHelpers";
7
13
  import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
14
+ import { extendRootDurationInSource } from "../utils/rootDuration";
8
15
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
9
16
  import type { EditHistoryKind } from "../utils/editHistory";
10
17
  import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
11
18
  import { findElementForSelection } from "../components/editor/domEditingElement";
12
- import { readFileContent } from "./timelineEditingHelpers";
19
+ import { findTimelineElementInIframe, readFileContent } from "./timelineEditingHelpers";
20
+ import { buildTimelineElementKey } from "../player/lib/timelineElementHelpers";
21
+ import { timeRangesOverlap } from "../player/components/timelineCollision";
13
22
 
14
23
  interface RecordEditInput {
15
24
  label: string;
@@ -26,11 +35,34 @@ interface UseClipboardOptions {
26
35
  writeProjectFile: (path: string, content: string) => Promise<void>;
27
36
  recordEdit: (input: RecordEditInput) => Promise<void>;
28
37
  reloadPreview: () => void;
29
- handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
38
+ handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
30
39
  handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
31
40
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
32
41
  }
33
42
 
43
+ /** The timeline element(s) a copy/cut/duplicate acts on: the multi-selection
44
+ * when one is active, else the single primary selection. */
45
+ function getSelectedElements(): TimelineElement[] {
46
+ const { selectedElementId, selectedElementIds, elements } = usePlayerStore.getState();
47
+ if (!selectedElementId) return [];
48
+ const ids = selectedElementIds.size > 1 ? selectedElementIds : new Set([selectedElementId]);
49
+ return elements.filter((el) => ids.has(el.key ?? el.id));
50
+ }
51
+
52
+ function elementKey(domId: string, sourceFile: string): string {
53
+ return buildTimelineElementKey({ id: domId, fallbackIndex: 0, domId, sourceFile });
54
+ }
55
+
56
+ /** "Paste clip" / "Paste clips" — the edit-history label, no count shown. */
57
+ function clipLabel(verb: string, clipCount: number): string {
58
+ return `${verb} ${clipCount > 1 ? "clips" : "clip"}`;
59
+ }
60
+
61
+ /** "Pasted clip" / "Pasted 3 clips" — the toast message, count shown once plural. */
62
+ function clipToast(verbPast: string, clipCount: number): string {
63
+ return clipCount > 1 ? `${verbPast} ${clipCount} clips` : `${verbPast} clip`;
64
+ }
65
+
34
66
  function getElementOuterHtml(
35
67
  iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
36
68
  selection: DomEditSelection,
@@ -47,6 +79,94 @@ function getElementOuterHtml(
47
79
  return findElementForSelection(doc, selection, activeCompositionPath)?.outerHTML ?? null;
48
80
  }
49
81
 
82
+ export interface PlacedClip {
83
+ track: number;
84
+ start: number;
85
+ duration: number;
86
+ }
87
+
88
+ function tracksOverlap(a: PlacedClip, b: PlacedClip): boolean {
89
+ return (
90
+ a.track === b.track &&
91
+ timeRangesOverlap(a.start, a.start + a.duration, b.start, b.start + b.duration)
92
+ );
93
+ }
94
+
95
+ /** CapCut: keeps the preferred track if free at the new time, else the next
96
+ * unused track index. Exported so placement is tested with values directly. */
97
+ export function resolveFreeTrack(preferred: PlacedClip, taken: readonly PlacedClip[]): number {
98
+ if (!taken.some((t) => tracksOverlap(preferred, t))) return preferred.track;
99
+ const maxTrack = taken.reduce((max, t) => Math.max(max, t.track), preferred.track);
100
+ return maxTrack + 1;
101
+ }
102
+
103
+ /** Strips data-hf-id from the root and every descendant so a clone re-mints
104
+ * its own. DOMParser, not a bracket-scoped regex, so a `>` inside an earlier
105
+ * attribute value or a single-quoted id can't defeat the strip. */
106
+ function stripHfIds(html: string, parser: DOMParser): string {
107
+ const root = parser.parseFromString(html, "text/html").body.firstElementChild;
108
+ if (!root) {
109
+ // A tag the HTML parser hoists out of <body> (title/meta/style/base/link)
110
+ // never reaches the walk above — fall back to a direct strip so
111
+ // data-hf-id still can't survive, even though no real clip root is one
112
+ // of these tags today.
113
+ return html.replace(/\sdata-hf-id=("[^"]*"|'[^']*')/g, "");
114
+ }
115
+ root.querySelectorAll("[data-hf-id]").forEach((el) => el.removeAttribute("data-hf-id"));
116
+ root.removeAttribute("data-hf-id");
117
+ return root.outerHTML;
118
+ }
119
+
120
+ /** Shared insertion path for paste and duplicate, anchored at the playhead or
121
+ * the selection's end respectively. Returns the final ids so the caller can
122
+ * select what it just placed, and the furthest end any clip lands at so the
123
+ * caller can grow the root composition's duration to cover it. */
124
+ export function pasteTimelineClips(
125
+ content: string,
126
+ clips: readonly TimelineClipboardClip[],
127
+ anchorTime: number,
128
+ liveElements: readonly TimelineElement[],
129
+ ): { content: string; ids: string[]; requiredEnd: number } {
130
+ const groupMinStart = Math.min(...clips.map((c) => c.start));
131
+ let existingIds = collectHtmlIds(content);
132
+ const taken: PlacedClip[] = liveElements.map((el) => ({
133
+ track: el.authoredTrack ?? el.track,
134
+ start: el.start,
135
+ duration: el.duration,
136
+ }));
137
+ const ids: string[] = [];
138
+ let result = content;
139
+ let requiredEnd = 0;
140
+ const domParser = new DOMParser();
141
+ for (const clip of clips) {
142
+ const stripped = stripHfIds(clip.html, domParser);
143
+ const deduped = deduplicateIds(stripped, existingIds);
144
+ existingIds = existingIds.concat(collectHtmlIds(deduped));
145
+ const newStart = anchorTime + (clip.start - groupMinStart);
146
+ const newTrack = resolveFreeTrack(
147
+ { track: clip.track, start: newStart, duration: clip.duration },
148
+ taken,
149
+ );
150
+ taken.push({ track: newTrack, start: newStart, duration: clip.duration });
151
+ requiredEnd = Math.max(requiredEnd, newStart + clip.duration);
152
+
153
+ // Only rewrite the outermost opening tag. The non-global regex matches
154
+ // the first occurrence, which is always in the root tag since outerHTML
155
+ // starts with it. Nested clips keep their own timing and track.
156
+ const rootTagEnd = deduped.indexOf(">");
157
+ const rootTag = rootTagEnd >= 0 ? deduped.slice(0, rootTagEnd + 1) : deduped;
158
+ const patchedRootTag = rootTag
159
+ .replace(/data-start="[^"]*"/, `data-start="${formatTimelineAttributeNumber(newStart)}"`)
160
+ .replace(/data-track-index="[^"]*"/, `data-track-index="${newTrack}"`);
161
+ const withPatched = patchedRootTag + deduped.slice(rootTagEnd + 1);
162
+ result = insertTimelineAssetIntoSource(result, withPatched);
163
+
164
+ const id = patchedRootTag.match(ID_ATTR_RE)?.[1];
165
+ if (id) ids.push(id);
166
+ }
167
+ return { content: result, ids, requiredEnd };
168
+ }
169
+
50
170
  export function useClipboard({
51
171
  projectId,
52
172
  activeCompPath,
@@ -55,7 +175,7 @@ export function useClipboard({
55
175
  writeProjectFile,
56
176
  recordEdit,
57
177
  reloadPreview,
58
- handleTimelineElementDelete,
178
+ handleTimelineElementsDelete,
59
179
  handleDomEditElementDelete,
60
180
  previewIframeRef,
61
181
  }: UseClipboardOptions) {
@@ -63,47 +183,50 @@ export function useClipboard({
63
183
  const projectIdRef = useRef(projectId);
64
184
  projectIdRef.current = projectId;
65
185
 
66
- // The copy-mode branches predate this change; this diff only replaces its
67
- // duplicated DOM lookup with the canonical composition-aware resolver.
68
- // fallow-ignore-next-line complexity
69
- const handleCopy = useCallback((): boolean => {
70
- const { selectedElementId, elements } = usePlayerStore.getState();
71
-
72
- // Timeline clip copy
73
- if (selectedElementId) {
74
- const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
75
- if (!element) return false;
76
- const targetPath = element.sourceFile || activeCompPath || "index.html";
77
-
78
- let html: string | null = null;
79
- try {
80
- const doc = previewIframeRef.current?.contentDocument;
81
- if (doc) {
82
- html =
83
- findElementForSelection(
84
- doc,
85
- {
86
- hfId: element.hfId,
87
- id: element.domId,
88
- selector: element.selector,
89
- selectorIndex: element.selectorIndex,
90
- sourceFile: targetPath,
91
- },
92
- activeCompPath,
93
- )?.outerHTML ?? null;
94
- }
95
- } catch {
96
- // cross-origin frame
97
- }
186
+ // Resolved through findTimelineElementInIframe, the same composition-aware
187
+ // lookup every other timeline editor uses — unlike findElementForSelection,
188
+ // it can address a composition-instance clip's root.
189
+ const collectSelectedClips = useCallback((): {
190
+ elements: TimelineElement[];
191
+ clips: TimelineClipboardClip[];
192
+ } | null => {
193
+ const selected = getSelectedElements();
194
+ if (selected.length === 0) return null;
98
195
 
196
+ const clips: TimelineClipboardClip[] = [];
197
+ for (const element of selected) {
198
+ const html =
199
+ findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath)?.outerHTML ??
200
+ null;
99
201
  if (!html) {
100
- showToast("Unable to copy this element.", "info");
101
- return false;
202
+ showToast(`Unable to copy "${element.label ?? element.id}".`, "info");
203
+ return null;
102
204
  }
205
+ clips.push({
206
+ html,
207
+ start: element.start,
208
+ duration: element.duration,
209
+ // authoredTrack, not track: `track` can be a display-lane number
210
+ // remapped by normalizeToZones, and writing THAT into data-track-index
211
+ // re-targets the wrong track in the sparse authored file.
212
+ track: element.authoredTrack ?? element.track,
213
+ });
214
+ }
215
+ return { elements: selected, clips };
216
+ }, [activeCompPath, previewIframeRef, showToast]);
103
217
 
104
- const payload: ClipboardPayload = { kind: "timeline-clip", html, sourceFile: targetPath };
105
- clipboardRef.current = payload;
106
- showToast("Copied clip", "info");
218
+ // Two independent copy modes (timeline clip vs DOM element) in one handler.
219
+ // fallow-ignore-next-line complexity
220
+ const handleCopy = useCallback((): boolean => {
221
+ if (usePlayerStore.getState().selectedElementId) {
222
+ const result = collectSelectedClips();
223
+ if (!result) return false;
224
+ const targetPath = result.elements[0]?.sourceFile || activeCompPath || "index.html";
225
+ clipboardRef.current = { kind: "timeline-clip", clips: result.clips, sourceFile: targetPath };
226
+ showToast(
227
+ result.clips.length > 1 ? `Copied ${result.clips.length} clips` : "Copied clip",
228
+ "info",
229
+ );
107
230
  return true;
108
231
  }
109
232
 
@@ -116,21 +239,23 @@ export function useClipboard({
116
239
  return false;
117
240
  }
118
241
  const targetPath = domSelection.sourceFile || activeCompPath || "index.html";
119
- const payload: ClipboardPayload = {
242
+ clipboardRef.current = {
120
243
  kind: "dom-element",
121
244
  html,
122
245
  sourceFile: targetPath,
123
246
  originSelector: domSelection.selector,
124
247
  originSelectorIndex: domSelection.selectorIndex,
125
248
  };
126
- clipboardRef.current = payload;
127
249
  showToast("Copied element", "info");
128
250
  return true;
129
251
  }
130
252
 
253
+ showToast("Nothing selected to copy.", "info");
131
254
  return false;
132
- }, [activeCompPath, domEditSelectionRef, previewIframeRef, showToast]);
255
+ }, [activeCompPath, collectSelectedClips, domEditSelectionRef, previewIframeRef, showToast]);
133
256
 
257
+ // Two independent paste modes (timeline clip vs DOM element) behind one guarded save.
258
+ // fallow-ignore-next-line complexity
134
259
  const handlePaste = useCallback(async () => {
135
260
  const payload = clipboardRef.current;
136
261
  if (!payload) {
@@ -143,24 +268,19 @@ export function useClipboard({
143
268
  const targetPath = activeCompPath || "index.html";
144
269
  try {
145
270
  const originalContent = await readFileContent(pid, targetPath);
146
- const existingIds = collectHtmlIds(originalContent);
147
- const deduped = deduplicateIds(payload.html, existingIds);
148
-
149
271
  let patchedContent: string;
272
+ let pastedIds: string[] = [];
273
+
150
274
  if (payload.kind === "timeline-clip") {
151
- // Only rewrite data-start on the outermost opening tag. The non-global
152
- // regex matches the first occurrence, which is always in the root tag
153
- // since outerHTML starts with it. Nested clips keep their own timing.
154
- const { currentTime } = usePlayerStore.getState();
155
- const rootTagEnd = deduped.indexOf(">");
156
- const rootTag = rootTagEnd >= 0 ? deduped.slice(0, rootTagEnd + 1) : deduped;
157
- const patchedRootTag = rootTag.replace(
158
- /data-start="[^"]*"/,
159
- `data-start="${formatTimelineAttributeNumber(currentTime)}"`,
160
- );
161
- const withNewStart = patchedRootTag + deduped.slice(rootTagEnd + 1);
162
- patchedContent = insertTimelineAssetIntoSource(originalContent, withNewStart);
275
+ const { currentTime, elements } = usePlayerStore.getState();
276
+ const pasted = pasteTimelineClips(originalContent, payload.clips, currentTime, elements);
277
+ // A clip pasted past the current composition end would exist in the
278
+ // file but never appear on the timeline or in playback/export (the
279
+ // root's data-duration is what actually bounds the render).
280
+ patchedContent = extendRootDurationInSource(pasted.content, pasted.requiredEnd);
281
+ pastedIds = pasted.ids;
163
282
  } else {
283
+ const deduped = deduplicateIds(payload.html, collectHtmlIds(originalContent));
164
284
  patchedContent = insertAsSibling(
165
285
  originalContent,
166
286
  deduped,
@@ -169,9 +289,14 @@ export function useClipboard({
169
289
  );
170
290
  }
171
291
 
292
+ const label =
293
+ payload.kind === "timeline-clip"
294
+ ? clipLabel("Paste", payload.clips.length)
295
+ : "Paste element";
296
+
172
297
  await saveProjectFilesWithHistory({
173
298
  projectId: pid,
174
- label: payload.kind === "timeline-clip" ? "Paste clip" : "Paste element",
299
+ label,
175
300
  kind: "timeline" as EditHistoryKind,
176
301
  files: { [targetPath]: patchedContent },
177
302
  readFile: async () => originalContent,
@@ -179,25 +304,92 @@ export function useClipboard({
179
304
  recordEdit,
180
305
  });
181
306
 
307
+ // CapCut: the pasted clip(s) become the selection; the playhead does not
308
+ // move. reloadPreview is a bare refresh-key bump with no selection
309
+ // snapshot of its own; calling setSelection before it is what makes the
310
+ // post-reload store state land on the pasted ids instead of stale ones.
311
+ if (pastedIds.length > 0) {
312
+ usePlayerStore.getState().setSelection(pastedIds.map((id) => elementKey(id, targetPath)));
313
+ }
182
314
  reloadPreview();
183
- showToast(payload.kind === "timeline-clip" ? "Pasted clip" : "Pasted element", "info");
315
+ showToast(
316
+ payload.kind === "timeline-clip"
317
+ ? clipToast("Pasted", payload.clips.length)
318
+ : "Pasted element",
319
+ "info",
320
+ );
184
321
  } catch (error) {
185
322
  const message = error instanceof Error ? error.message : "Failed to paste";
186
323
  showToast(message);
187
324
  }
188
325
  }, [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile]);
189
326
 
327
+ // Duplicates the current selection in place, immediately after it, without
328
+ // touching the clipboard — a pending copy must survive a Cmd+D. Shares
329
+ // pasteTimelineClips with handlePaste; only the anchor and clip source
330
+ // differ (the selection's own end here, the playhead there).
331
+ const handleDuplicate = useCallback(async (): Promise<boolean> => {
332
+ const result = collectSelectedClips();
333
+ if (!result) return false;
334
+ const pid = projectIdRef.current;
335
+ if (!pid) return false;
336
+
337
+ const { elements, clips } = result;
338
+ const targetPath = elements[0]?.sourceFile || activeCompPath || "index.html";
339
+ const anchorTime = Math.max(...elements.map((el) => el.start + el.duration));
340
+
341
+ try {
342
+ const originalContent = await readFileContent(pid, targetPath);
343
+ const liveElements = usePlayerStore.getState().elements;
344
+ const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
345
+ const patchedContent = extendRootDurationInSource(pasted.content, pasted.requiredEnd);
346
+ const ids = pasted.ids;
347
+
348
+ await saveProjectFilesWithHistory({
349
+ projectId: pid,
350
+ label: clipLabel("Duplicate", clips.length),
351
+ kind: "timeline" as EditHistoryKind,
352
+ files: { [targetPath]: patchedContent },
353
+ readFile: async () => originalContent,
354
+ writeFile: writeProjectFile,
355
+ recordEdit,
356
+ });
357
+
358
+ // The duplicate becomes the selection, mirroring CapCut's own paste
359
+ // convention — there is no CapCut Duplicate to match directly (Cmd+D is
360
+ // a no-op there); this is our own choice for consistency with paste.
361
+ // Select before reloading, same reason as handlePaste above.
362
+ if (ids.length > 0) {
363
+ usePlayerStore.getState().setSelection(ids.map((id) => elementKey(id, targetPath)));
364
+ }
365
+ reloadPreview();
366
+ showToast(clipToast("Duplicated", clips.length), "info");
367
+ return true;
368
+ } catch (error) {
369
+ const message = error instanceof Error ? error.message : "Failed to duplicate";
370
+ showToast(message);
371
+ return false;
372
+ }
373
+ }, [
374
+ activeCompPath,
375
+ collectSelectedClips,
376
+ recordEdit,
377
+ reloadPreview,
378
+ showToast,
379
+ writeProjectFile,
380
+ ]);
381
+
190
382
  const handleCut = useCallback(async (): Promise<boolean> => {
383
+ const selected = getSelectedElements();
191
384
  const copied = handleCopy();
192
385
  if (!copied) return false;
193
386
 
194
- const { selectedElementId, elements } = usePlayerStore.getState();
195
- if (selectedElementId) {
196
- const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
197
- if (element) {
198
- await handleTimelineElementDelete(element);
199
- return true;
200
- }
387
+ if (selected.length > 0) {
388
+ // One call for the whole selection, not one per element: the batched
389
+ // delete writes and records history once, so a multi-clip Cmd+X undoes
390
+ // in a single Cmd+Z instead of needing one per clip.
391
+ await handleTimelineElementsDelete(selected);
392
+ return true;
201
393
  }
202
394
 
203
395
  const domSelection = domEditSelectionRef.current;
@@ -206,7 +398,9 @@ export function useClipboard({
206
398
  return true;
207
399
  }
208
400
  return true;
209
- }, [handleCopy, domEditSelectionRef, handleTimelineElementDelete, handleDomEditElementDelete]);
401
+ }, [handleCopy, domEditSelectionRef, handleTimelineElementsDelete, handleDomEditElementDelete]);
402
+
403
+ const canPaste = useCallback(() => clipboardRef.current !== null, []);
210
404
 
211
- return { handleCopy, handlePaste, handleCut };
405
+ return { handleCopy, handlePaste, handleCut, handleDuplicate, canPaste };
212
406
  }
@@ -11,7 +11,6 @@ const timelineElement = (over: Partial<TimelineElement> = {}): TimelineElement =
11
11
  start: 0,
12
12
  duration: 4,
13
13
  track: 0,
14
- timingSource: "authored",
15
14
  sourceFile: "index.html",
16
15
  ...over,
17
16
  });
@@ -0,0 +1,65 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { useLintModal } from "./useLintModal";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ let root: Root | null = null;
11
+ let latest: ReturnType<typeof useLintModal> | null = null;
12
+
13
+ function Probe() {
14
+ latest = useLintModal("demo");
15
+ return null;
16
+ }
17
+
18
+ afterEach(() => {
19
+ if (root) act(() => root?.unmount());
20
+ root = null;
21
+ latest = null;
22
+ vi.unstubAllGlobals();
23
+ });
24
+
25
+ async function mountWithLint(response: () => Promise<unknown>) {
26
+ vi.stubGlobal("fetch", vi.fn(response));
27
+ root = createRoot(document.createElement("div"));
28
+ await act(async () => root?.render(<Probe />));
29
+ await act(async () => {
30
+ await latest?.handleLint();
31
+ });
32
+ }
33
+
34
+ const lintResponse = (findings: Array<{ severity: string; message: string }>) => async () => ({
35
+ json: async () => ({ findings }),
36
+ });
37
+
38
+ describe("useLintModal hasLintError", () => {
39
+ it("is true when a finding is an error and false for warnings only", async () => {
40
+ await mountWithLint(lintResponse([{ severity: "error", message: "nested" }]));
41
+ expect(latest?.hasLintError).toBe(true);
42
+ act(() => root?.unmount());
43
+ root = null;
44
+ await mountWithLint(lintResponse([{ severity: "warning", message: "soft" }]));
45
+ expect(latest?.hasLintError).toBe(false);
46
+ });
47
+
48
+ it("is true for a mixed error and warning list", async () => {
49
+ await mountWithLint(
50
+ lintResponse([
51
+ { severity: "warning", message: "soft" },
52
+ { severity: "error", message: "nested" },
53
+ ]),
54
+ );
55
+ expect(latest?.hasLintError).toBe(true);
56
+ });
57
+
58
+ it("is true when the manual lint itself fails, matching the badge count", async () => {
59
+ await mountWithLint(async () => {
60
+ throw new Error("offline");
61
+ });
62
+ expect(latest?.lintModal).toHaveLength(1);
63
+ expect(latest?.hasLintError).toBe(true);
64
+ });
65
+ });
@@ -96,6 +96,8 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
96
96
 
97
97
  const findingsByElement = useMemo(() => groupFindings((f) => f.elementId), [groupFindings]);
98
98
  const findingsByFile = useMemo(() => groupFindings((f) => f.file), [groupFindings]);
99
+ // Reads the list the badge count reads: the manual result when present, else the background one.
100
+ const hasLintError = (lintModal ?? backgroundFindings).some((f) => f.severity === "error");
99
101
 
100
102
  // Sync lint findings directly to the player store — eliminates the
101
103
  // mirroring useEffect that was previously in App.tsx.
@@ -111,5 +113,6 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
111
113
  backgroundFindings,
112
114
  findingsByElement,
113
115
  findingsByFile,
116
+ hasLintError,
114
117
  };
115
118
  }
@@ -29,7 +29,6 @@ const element: TimelineElement = {
29
29
  track: 0,
30
30
  domId: "clip1",
31
31
  sourceFile: "index.html",
32
- timingSource: "authored",
33
32
  };
34
33
 
35
34
  type Split = (element: TimelineElement, splitTime: number) => Promise<void>;
@@ -24,7 +24,6 @@ const rootElement: TimelineElement = {
24
24
  track: 0,
25
25
  domId: "root-clip",
26
26
  sourceFile: ROOT_FILE,
27
- timingSource: "authored",
28
27
  };
29
28
 
30
29
  // An expanded sub-comp child: `start` is in MASTER coordinates (offset by the
@@ -38,7 +37,6 @@ const expandedChild: TimelineElement = {
38
37
  track: 1,
39
38
  domId: "child-clip",
40
39
  sourceFile: SUBCOMP_FILE,
41
- timingSource: "authored",
42
40
  expandedParentStart: 2,
43
41
  };
44
42
 
@@ -82,7 +82,6 @@ describe("useRazorSplit mutation versions", () => {
82
82
  start: 0,
83
83
  duration: 4,
84
84
  track: 0,
85
- timingSource: "authored",
86
85
  },
87
86
  2,
88
87
  );
@@ -164,17 +164,62 @@ describe("useSdkSession unavailable telemetry", () => {
164
164
 
165
165
  // Every cutover chokepoint silently takes the server path when there is no
166
166
  // session, and the shadow never runs either — so a missing session is a total,
167
- // otherwise-invisible SDK bypass. These three exits are its only origin.
168
- it("reports an unreadable composition", async () => {
167
+ // otherwise-invisible SDK bypass. These exits are its only origin.
168
+ //
169
+ // `stage: read` carries a reason because it was the largest remaining class
170
+ // and a single opaque string: 56 users hit it in a 7-day window and never
171
+ // landed one successful SDK edit between them, with no way to tell a genuinely
172
+ // absent file from a request that never reached one.
173
+ it("reports the HTTP status when the read fails", async () => {
169
174
  vi.stubGlobal(
170
175
  "fetch",
171
- vi.fn(async () => ({ ok: false, json: async () => ({}) }) as Response),
176
+ vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) }) as Response),
172
177
  );
173
178
  const root = createRoot(document.createElement("div"));
174
179
  await act(async () => root.render(<Probe projectId="project-a" />));
175
180
  await flushAsyncEffects();
176
181
 
177
- expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", { stage: "read" });
182
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
183
+ stage: "read",
184
+ reason: "http_error",
185
+ status: 404,
186
+ });
187
+ await act(async () => root.unmount());
188
+ });
189
+
190
+ it("separates an unexpected response shape from a failed request", async () => {
191
+ vi.stubGlobal(
192
+ "fetch",
193
+ vi.fn(async () => ({ ok: true, json: async () => ({}) }) as Response),
194
+ );
195
+ const root = createRoot(document.createElement("div"));
196
+ await act(async () => root.render(<Probe projectId="project-a" />));
197
+ await flushAsyncEffects();
198
+
199
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
200
+ stage: "read",
201
+ reason: "missing_content",
202
+ });
203
+ await act(async () => root.unmount());
204
+ });
205
+
206
+ // `optional=1` answers a file that is not on disk with 200 + an empty string,
207
+ // so this is what "the composition genuinely is not there" looks like on the
208
+ // wire — previously indistinguishable from a broken request. The shim and a
209
+ // real 0-byte file are the same response, hence the name.
210
+ it("separates a file that is not on disk", async () => {
211
+ vi.stubGlobal(
212
+ "fetch",
213
+ vi.fn(async () => ({ ok: true, json: async () => ({ content: "" }) }) as Response),
214
+ );
215
+ const root = createRoot(document.createElement("div"));
216
+ await act(async () => root.render(<Probe projectId="project-a" />));
217
+ await flushAsyncEffects();
218
+
219
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
220
+ stage: "read",
221
+ reason: "absent_or_empty",
222
+ });
178
223
  await act(async () => root.unmount());
179
224
  });
180
225