@hyperframes/studio 0.8.50 → 0.8.53-desktop.0

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 (330) hide show
  1. package/dist/assets/{hyperframes-player-CxxDxHTW.js → hyperframes-player-PTEnAHhg.js} +3 -3
  2. package/dist/assets/{index-DUAIWxkI.js → index--myXp8ui.js} +1 -1
  3. package/dist/assets/{index-Dt3qBI-9.js → index-BjslOS9g.js} +1 -1
  4. package/dist/assets/index-BnXZuIJD.css +1 -0
  5. package/dist/assets/index-CNyviK_l.js +390 -0
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +825 -24
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +8911 -9853
  11. package/dist/index.js.map +1 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +13 -12
  14. package/src/App.tsx +145 -163
  15. package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
  16. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  17. package/src/captions/components/CaptionOverlay.tsx +8 -4
  18. package/src/captions/components/CaptionOverlayUtils.ts +2 -3
  19. package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
  20. package/src/captions/components/CaptionTimeline.tsx +1 -1
  21. package/src/captions/components/shared.tsx +2 -2
  22. package/src/components/AskAgentModal.tsx +5 -5
  23. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  24. package/src/components/EditorShell.tsx +59 -62
  25. package/src/components/ExternalFileConflictBanner.tsx +16 -16
  26. package/src/components/LintModal.tsx +6 -16
  27. package/src/components/MediaPreview.tsx +2 -2
  28. package/src/components/RightPanelTabs.test.tsx +151 -0
  29. package/src/components/RightPanelTabs.tsx +59 -0
  30. package/src/components/SaveQueuePausedBanner.tsx +2 -2
  31. package/src/components/StudioHeader.dom.test.tsx +181 -0
  32. package/src/components/StudioHeader.test.ts +0 -12
  33. package/src/components/StudioHeader.tsx +118 -221
  34. package/src/components/StudioLeftPanels.tsx +202 -0
  35. package/src/components/StudioOverlays.tsx +1 -1
  36. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -194
  37. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  38. package/src/components/StudioToast.tsx +2 -2
  39. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  40. package/src/components/TimelineHistoryButtons.tsx +54 -0
  41. package/src/components/TimelineToolbar.test.tsx +8 -0
  42. package/src/components/TimelineToolbar.tsx +8 -12
  43. package/src/components/dock/Dock.test.tsx +274 -0
  44. package/src/components/dock/Dock.tsx +291 -0
  45. package/src/components/dock/dock.css +28 -0
  46. package/src/components/dock/dockAccessibility.test.ts +166 -0
  47. package/src/components/dock/dockAccessibility.ts +98 -0
  48. package/src/components/dock/dockLayout.test.ts +134 -0
  49. package/src/components/dock/dockLayout.ts +105 -0
  50. package/src/components/dock/dockLayoutSchema.ts +66 -0
  51. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  52. package/src/components/dock/dockLayoutStore.ts +108 -0
  53. package/src/components/dock/dockTestHarness.ts +11 -0
  54. package/src/components/dock/panelRegistry.ts +67 -0
  55. package/src/components/editor/AnimationCard.tsx +4 -4
  56. package/src/components/editor/AnimationCardParts.tsx +6 -6
  57. package/src/components/editor/BlockParamsPanel.tsx +4 -4
  58. package/src/components/editor/BorderRadiusEditor.tsx +1 -1
  59. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  60. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  61. package/src/components/editor/DomEditOverlay.tsx +1 -1
  62. package/src/components/editor/EaseCurveSection.tsx +1 -1
  63. package/src/components/editor/EaseParamFields.tsx +5 -5
  64. package/src/components/editor/FileTree.tsx +4 -4
  65. package/src/components/editor/FileTreeIcons.tsx +1 -1
  66. package/src/components/editor/FileTreeNodes.tsx +13 -13
  67. package/src/components/editor/InlineTextToolbar.tsx +1 -1
  68. package/src/components/editor/InspectorHeaderActions.tsx +3 -3
  69. package/src/components/editor/KeyframeDiamond.tsx +1 -1
  70. package/src/components/editor/KeyframeEaseList.tsx +1 -1
  71. package/src/components/editor/LayersPanel.test.ts +97 -1
  72. package/src/components/editor/LayersPanel.tsx +7 -5
  73. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  74. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  75. package/src/components/editor/PreviewGuides.tsx +1 -1
  76. package/src/components/editor/PromotableControl.tsx +2 -2
  77. package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
  78. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  79. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  80. package/src/components/editor/SnapToolbar.tsx +2 -2
  81. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  82. package/src/components/editor/TopologyLens.test.tsx +28 -0
  83. package/src/components/editor/TopologyLens.tsx +7 -5
  84. package/src/components/editor/Transform3DCube.tsx +2 -2
  85. package/src/components/editor/audioFxRevealTarget.ts +1 -0
  86. package/src/components/editor/domEditing.test.ts +1 -1
  87. package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
  88. package/src/components/editor/domEditingAgentPrompt.ts +4 -3
  89. package/src/components/editor/propertyPanelColor.tsx +11 -11
  90. package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
  91. package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
  92. package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
  93. package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
  94. package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
  95. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  96. package/src/components/editor/propertyPanelFill.tsx +4 -4
  97. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  98. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
  99. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  100. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
  101. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
  102. package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
  103. package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
  104. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
  105. package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
  106. package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
  107. package/src/components/editor/propertyPanelFont.tsx +5 -5
  108. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  109. package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
  110. package/src/components/editor/propertyPanelFxControls.tsx +6 -6
  111. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  112. package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
  113. package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
  114. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  115. package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
  116. package/src/components/editor/propertyPanelSections.tsx +8 -8
  117. package/src/components/editor/useColorGradingController.test.ts +38 -0
  118. package/src/components/editor/useColorGradingController.ts +9 -1
  119. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  120. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  121. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  122. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  123. package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
  124. package/src/components/feedback/feedbackTrigger.ts +0 -1
  125. package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
  126. package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
  127. package/src/components/nle/NLEContext.tsx +0 -43
  128. package/src/components/nle/NLEPreview.tsx +3 -3
  129. package/src/components/nle/PreviewOverlays.tsx +6 -6
  130. package/src/components/nle/PreviewPane.tsx +3 -3
  131. package/src/components/nle/TimelinePane.tsx +53 -67
  132. package/src/components/panels/SlideshowPanel.tsx +3 -3
  133. package/src/components/panels/SlideshowSubPanels.tsx +20 -17
  134. package/src/components/panels/VariablesBindElement.tsx +2 -2
  135. package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
  136. package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
  137. package/src/components/panels/VariablesPanel.tsx +4 -4
  138. package/src/components/panels/VariablesValueControls.tsx +2 -2
  139. package/src/components/pointerTargetSize.test.tsx +8 -0
  140. package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
  141. package/src/components/renders/RenderQueue.test.tsx +7 -0
  142. package/src/components/renders/RenderQueue.tsx +7 -7
  143. package/src/components/renders/RenderQueueItem.tsx +6 -6
  144. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  145. package/src/components/sidebar/AssetCard.tsx +8 -8
  146. package/src/components/sidebar/AssetContextMenu.tsx +8 -8
  147. package/src/components/sidebar/AssetsTab.tsx +2 -2
  148. package/src/components/sidebar/AudioRow.tsx +6 -6
  149. package/src/components/sidebar/BlocksTab.tsx +6 -6
  150. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  151. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  152. package/src/components/sidebar/CompositionsTab.tsx +5 -5
  153. package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
  154. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  155. package/src/components/sidebar/SidebarLintButton.test.tsx +53 -0
  156. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  157. package/src/components/timelineToolbarStyles.ts +5 -0
  158. package/src/components/ui/Button.test.tsx +252 -0
  159. package/src/components/ui/Button.tsx +89 -126
  160. package/src/components/ui/IconButton.tsx +38 -0
  161. package/src/components/ui/Input.tsx +115 -0
  162. package/src/components/ui/Menu.test.tsx +374 -0
  163. package/src/components/ui/Menu.tsx +213 -0
  164. package/src/components/ui/NumberField.tsx +122 -0
  165. package/src/components/ui/Popover.tsx +74 -0
  166. package/src/components/ui/SearchInput.tsx +2 -2
  167. package/src/components/ui/Select.tsx +100 -0
  168. package/src/components/ui/Slider.tsx +153 -0
  169. package/src/components/ui/Tabs.test.tsx +98 -0
  170. package/src/components/ui/Tabs.tsx +71 -0
  171. package/src/components/ui/Toggle.tsx +63 -0
  172. package/src/components/ui/Tooltip.test.tsx +110 -0
  173. package/src/components/ui/Tooltip.tsx +38 -112
  174. package/src/components/ui/cn.test.ts +30 -0
  175. package/src/components/ui/cn.ts +25 -0
  176. package/src/components/ui/index.ts +29 -2
  177. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  178. package/src/components/ui/valueControls.test.tsx +524 -0
  179. package/src/contexts/PanelLayoutContext.tsx +3 -57
  180. package/src/hooks/appHotkeysDispatch.ts +3 -4
  181. package/src/hooks/timelineEditPermission.ts +31 -0
  182. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  183. package/src/hooks/useAppHotkeys.test.ts +0 -1
  184. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  185. package/src/hooks/useAppHotkeys.ts +21 -84
  186. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  187. package/src/hooks/useCaptionDetection.ts +3 -1
  188. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  189. package/src/hooks/useDomEditSession.ts +0 -7
  190. package/src/hooks/useDomEditWiring.ts +4 -9
  191. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  192. package/src/hooks/useEditHistoryActions.ts +106 -0
  193. package/src/hooks/useFileManager.ts +21 -123
  194. package/src/hooks/useLintModal.test.tsx +65 -0
  195. package/src/hooks/useLintModal.ts +3 -0
  196. package/src/hooks/usePanelLayout.test.ts +108 -296
  197. package/src/hooks/usePanelLayout.ts +50 -255
  198. package/src/hooks/usePersistentEditHistory.ts +1 -1
  199. package/src/hooks/useProjectFileWriter.ts +150 -0
  200. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  201. package/src/hooks/useRightPanelIntents.ts +63 -0
  202. package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
  203. package/src/hooks/useSdkSession.ts +43 -10
  204. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  205. package/src/hooks/useServerConnection.ts +43 -10
  206. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  207. package/src/hooks/useSlideshowTabState.ts +6 -19
  208. package/src/hooks/useStudioContextValue.test.ts +6 -18
  209. package/src/hooks/useStudioContextValue.ts +5 -26
  210. package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
  211. package/src/hooks/useTimelineAssetDropOps.ts +52 -11
  212. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  213. package/src/hooks/useTimelineEditing.test.tsx +322 -3
  214. package/src/hooks/useTimelineEditing.ts +103 -16
  215. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  216. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  217. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  218. package/src/hostMount.test.tsx +222 -0
  219. package/src/index.ts +87 -0
  220. package/src/main.tsx +9 -3
  221. package/src/player/components/AudioWaveform.tsx +1 -1
  222. package/src/player/components/AutomationSelectionMenu.tsx +3 -3
  223. package/src/player/components/ClipContextMenu.tsx +3 -3
  224. package/src/player/components/CompositionThumbnail.test.ts +0 -3
  225. package/src/player/components/CompositionThumbnail.tsx +3 -4
  226. package/src/player/components/EditModal.tsx +1 -1
  227. package/src/player/components/ImageThumbnail.tsx +1 -1
  228. package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
  229. package/src/player/components/LayerDisclosureRow.tsx +2 -2
  230. package/src/player/components/PlayerControls.tsx +1 -1
  231. package/src/player/components/ShortcutsPanel.tsx +9 -9
  232. package/src/player/components/SpeedMenu.tsx +3 -5
  233. package/src/player/components/Timeline.tsx +3 -2
  234. package/src/player/components/TimelineCanvas.tsx +21 -14
  235. package/src/player/components/TimelineClip.tsx +0 -1
  236. package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
  237. package/src/player/components/TimelineEmptyState.tsx +5 -5
  238. package/src/player/components/TimelineFxButton.tsx +6 -6
  239. package/src/player/components/TimelineGroupHeader.tsx +5 -5
  240. package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
  241. package/src/player/components/TimelineGroupRow.tsx +1 -1
  242. package/src/player/components/TimelineLanes.test.tsx +32 -0
  243. package/src/player/components/TimelineLanes.tsx +2 -5
  244. package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
  245. package/src/player/components/TimelineRuler.tsx +1 -1
  246. package/src/player/components/TimelineShortcutHint.tsx +1 -1
  247. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  248. package/src/player/components/TimelineTrackHeader.tsx +2 -2
  249. package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
  250. package/src/player/components/TimelineTypes.ts +2 -1
  251. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  252. package/src/player/components/VideoThumbnail.tsx +4 -2
  253. package/src/player/components/VolumeControl.tsx +2 -2
  254. package/src/player/components/timelineCallbacks.ts +8 -8
  255. package/src/player/components/timelineClipDragPreview.ts +51 -27
  256. package/src/player/components/timelineClipDragTypes.ts +3 -0
  257. package/src/player/components/timelineDragDrop.test.tsx +61 -1
  258. package/src/player/components/timelineDragDrop.ts +57 -15
  259. package/src/player/components/timelineLaneProps.ts +3 -0
  260. package/src/player/components/timelineSnapping.test.ts +27 -0
  261. package/src/player/components/timelineSnapping.ts +14 -1
  262. package/src/player/components/timelineTheme.test.ts +4 -4
  263. package/src/player/components/timelineTheme.ts +30 -30
  264. package/src/player/components/trackHeaderLabelRows.tsx +4 -4
  265. package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
  266. package/src/player/components/useTimelineClipDrag.ts +30 -3
  267. package/src/player/lib/describeClips.ts +3 -54
  268. package/src/player/lib/playbackShortcuts.ts +2 -0
  269. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  270. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  271. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  272. package/src/player/store/previewIframeStore.test.tsx +29 -0
  273. package/src/player/store/previewIframeStore.ts +25 -0
  274. package/src/renderQueueExports.test.tsx +58 -0
  275. package/src/styles/classCandidates.test.ts +105 -0
  276. package/src/styles/classCandidates.ts +281 -0
  277. package/src/styles/hex-baseline.json +84 -0
  278. package/src/styles/hexRatchet.test.ts +201 -0
  279. package/src/styles/iconTokens.ts +20 -0
  280. package/src/styles/palette-reset.css +6 -0
  281. package/src/styles/studio.css +24 -21
  282. package/src/styles/styleSources.test.ts +20 -0
  283. package/src/styles/styleSources.ts +47 -0
  284. package/src/styles/tailwind-preset.shared.js +3 -0
  285. package/src/styles/theme.css +270 -0
  286. package/src/styles/theme.test.ts +183 -0
  287. package/src/styles/tokenGate.test.ts +109 -0
  288. package/src/utils/sourcePatcher.ts +1 -1
  289. package/src/utils/studioHelpers.test.ts +18 -0
  290. package/src/utils/studioHelpers.ts +11 -8
  291. package/src/utils/studioUiPreferences.test.ts +10 -16
  292. package/src/utils/studioUiPreferences.ts +7 -16
  293. package/src/utils/timelineDropTrackInsert.test.ts +134 -0
  294. package/src/utils/timelineDropTrackInsert.ts +83 -0
  295. package/src/utils/typingTarget.ts +4 -0
  296. package/dist/assets/index-n2ml4tUn.js +0 -442
  297. package/dist/assets/index-pO6p0H27.css +0 -1
  298. package/src/components/PanelTabButton.tsx +0 -31
  299. package/src/components/StudioLeftSidebar.tsx +0 -204
  300. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  301. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  302. package/src/components/sidebar/LeftSidebar.tsx +0 -334
  303. package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
  304. package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
  305. package/src/components/storyboard/FramePoster.test.tsx +0 -61
  306. package/src/components/storyboard/FramePoster.tsx +0 -86
  307. package/src/components/storyboard/StoryboardDirection.tsx +0 -45
  308. package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
  309. package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
  310. package/src/components/storyboard/StoryboardGrid.tsx +0 -55
  311. package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
  312. package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
  313. package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
  314. package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
  315. package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
  316. package/src/components/storyboard/StoryboardView.tsx +0 -169
  317. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
  318. package/src/components/storyboard/frameComments.test.ts +0 -95
  319. package/src/components/storyboard/frameComments.ts +0 -115
  320. package/src/components/storyboard/frameStatus.ts +0 -36
  321. package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
  322. package/src/components/storyboard/storyboardReviewStage.ts +0 -45
  323. package/src/components/storyboard/useFrameComments.ts +0 -98
  324. package/src/contexts/ViewModeContext.tsx +0 -142
  325. package/src/hooks/useInspectorSplitResize.ts +0 -51
  326. package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
  327. package/src/hooks/useProjectSignaturePoll.ts +0 -68
  328. package/src/hooks/useStoryboard.ts +0 -99
  329. package/src/utils/fitPanels.test.ts +0 -164
  330. package/src/utils/fitPanels.ts +0 -151
@@ -0,0 +1,63 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { useDockLayoutStore, visiblePanelInZone } from "../components/dock/dockLayoutStore";
3
+
4
+ /** Opens the Slideshow panel when the file becomes a slideshow and closes it when it stops being one; a user's own close, and a restored layout's placement, stick. */
5
+ export function useSlideshowDockPanel(isSlideshowComposition: boolean) {
6
+ const controller = useDockLayoutStore((state) => state.controller);
7
+ const wasSlideshow = useRef(false);
8
+ useEffect(() => {
9
+ if (!controller) return;
10
+ const inDock = useDockLayoutStore.getState().openPanels.has("slideshow");
11
+ if (isSlideshowComposition && !inDock) controller.open("slideshow");
12
+ if (!isSlideshowComposition && wasSlideshow.current && inDock) controller.close("slideshow");
13
+ wasSlideshow.current = isSlideshowComposition;
14
+ }, [isSlideshowComposition, controller]);
15
+ }
16
+
17
+ /** Block params replace the Design body until Design stops showing; picking another tab dismisses them elsewhere. */
18
+ export function useBlockParamsDismissal({
19
+ hasBlockParams,
20
+ onDismiss,
21
+ }: {
22
+ hasBlockParams: boolean;
23
+ onDismiss: () => void;
24
+ }) {
25
+ const designVisible = useDockLayoutStore((state) => state.visiblePanels.has("design"));
26
+ useEffect(() => {
27
+ if (hasBlockParams && !designVisible) onDismiss();
28
+ }, [hasBlockParams, designVisible, onDismiss]);
29
+ }
30
+
31
+ /** Wraps a tab setter so any tab other than block-params first dismisses the block-params view. */
32
+ export function useDismissingTabSetter<Tab extends string>(
33
+ setTab: (tab: Tab) => void,
34
+ dismissBlockParams: () => void,
35
+ ) {
36
+ return useCallback(
37
+ (tab: Tab) => {
38
+ if (tab !== "block-params") dismissBlockParams();
39
+ setTab(tab);
40
+ },
41
+ [setTab, dismissBlockParams],
42
+ );
43
+ }
44
+
45
+ /** Caption edit mode owns the Design panel: whenever the right column shows something else, bring Design back. */
46
+ export function useCaptionDesignFocus(captionEditMode: boolean) {
47
+ const designVisible = useDockLayoutStore((state) => state.visiblePanels.has("design"));
48
+ const rightShown = useDockLayoutStore(
49
+ (state) => visiblePanelInZone("right", state.lastActive, state.visiblePanels) !== null,
50
+ );
51
+ useEffect(() => {
52
+ if (captionEditMode && rightShown && !designVisible) {
53
+ useDockLayoutStore.getState().activatePanel("design");
54
+ }
55
+ }, [captionEditMode, rightShown, designVisible]);
56
+ }
57
+
58
+ /** The right-column panel the user last focused, whether or not the column is currently showing. */
59
+ export function useRightPanelIntent() {
60
+ return useDockLayoutStore((state) =>
61
+ visiblePanelInZone("right", state.lastActive, state.openPanels),
62
+ );
63
+ }
@@ -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
 
@@ -7,25 +7,53 @@ import type { PublishSdkSession } from "../utils/sdkCutover";
7
7
  import { addExternalFileReloadListener } from "./externalFileReloadBus";
8
8
 
9
9
  /**
10
- * Read a project file's content, or undefined on a non-2xx (optional read).
11
- * Replaces the removed SDK http adapter's `read()` — the only thing Studio used
12
- * it for (Studio is the sole writer, so the adapter's write path was dead).
10
+ * Why an optional project-file read produced no usable content. `stage: "read"`
11
+ * was a single opaque reason covering all of these, which made the largest
12
+ * remaining class of SDK-session failures undiagnosable: 56 users in a 7-day
13
+ * window hit it and, between them, never landed a single successful SDK edit.
14
+ * Knowing which branch fired is the difference between "the file legitimately
15
+ * is not there" and "the request never reached the file".
16
+ *
17
+ * Every reason lives in this union so the full surface is readable from one
18
+ * place — `absent_or_empty` included, even though it is a 2xx.
19
+ */
20
+ type ProjectFileReadFailure =
21
+ | { ok: false; reason: "unsafe_path" }
22
+ | { ok: false; reason: "http_error"; status: number }
23
+ | { ok: false; reason: "missing_content" }
24
+ | { ok: false; reason: "absent_or_empty" };
25
+
26
+ type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
27
+
28
+ /**
29
+ * Read a project file's content (optional read — a missing file is not an
30
+ * error). Replaces the removed SDK http adapter's `read()` — the only thing
31
+ * Studio used it for (Studio is the sole writer, so the adapter's write path
32
+ * was dead).
13
33
  */
14
34
  async function readProjectFileOptional(
15
35
  projectId: string,
16
36
  path: string,
17
- ): Promise<string | undefined> {
37
+ ): Promise<ProjectFileReadResult> {
18
38
  // Reject traversal / NUL before building the request URL — `path` is a
19
39
  // user-influenced composition path (mirrors the guard in timelineEditingHelpers,
20
40
  // and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
21
41
  // already confines both values to single segments of this same-origin URL.
22
- if (path.includes("\0") || path.includes("..")) return undefined;
42
+ if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
23
43
  const res = await fetch(
24
44
  `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
25
45
  );
26
- if (!res.ok) return undefined;
46
+ if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
27
47
  const data = (await res.json()) as { content?: string };
28
- return typeof data.content === "string" ? data.content : undefined;
48
+ // `optional=1` answers a missing file with 200 + `content: ""`, so a
49
+ // non-string here means a response shape we did not expect, not absence.
50
+ if (typeof data.content !== "string") return { ok: false, reason: "missing_content" };
51
+ // An empty body parses into a session with no elements, which declines every
52
+ // edit wholesale — not a session worth opening. The absent-file shim and a
53
+ // genuinely 0-byte file are the same 200 on the wire and cannot be told
54
+ // apart here, hence the name; for a composition it is always the former.
55
+ if (data.content === "") return { ok: false, reason: "absent_or_empty" };
56
+ return { ok: true, content: data.content };
29
57
  }
30
58
 
31
59
  /**
@@ -180,16 +208,21 @@ export function useSdkSession(
180
208
  };
181
209
 
182
210
  readProjectFileOptional(projectId, activeCompPath)
183
- .then(async (content) => {
211
+ .then(async (read) => {
184
212
  if (cancelled) return;
185
- if (typeof content !== "string") {
213
+ if (!read.ok) {
186
214
  // No SDK session follows, so EVERY cutover chokepoint below takes the
187
215
  // server path and emits nothing — the shadow never runs either. This
188
216
  // is the only place a missing session can originate, so a broken read
189
217
  // would otherwise be a silent, total SDK bypass.
190
- trackStudioEvent("sdk_session_unavailable", { stage: "read" });
218
+ trackStudioEvent("sdk_session_unavailable", {
219
+ stage: "read",
220
+ reason: read.reason,
221
+ ...(read.reason === "http_error" ? { status: read.status } : {}),
222
+ });
191
223
  return;
192
224
  }
225
+ const content = read.content;
193
226
  // No persist queue: Studio's writeProjectFile (via sdkCutover's
194
227
  // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
195
228
  // queue too would double-write the file (queue auto-writes on every
@@ -0,0 +1,94 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { useServerConnection } from "./useServerConnection";
6
+
7
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
+
9
+ const captured: { projectId: string | null } = { projectId: null };
10
+
11
+ function Probe() {
12
+ captured.projectId = useServerConnection().projectId;
13
+ return null;
14
+ }
15
+
16
+ async function flush(): Promise<void> {
17
+ await act(async () => {
18
+ await new Promise((resolve) => setTimeout(resolve, 0));
19
+ });
20
+ }
21
+
22
+ /**
23
+ * The hash project id outlives the project it names — a renamed folder, or a
24
+ * bookmark from a project that is gone. It used to be trusted unconditionally,
25
+ * so every later /api/projects/<id>/... request 404'd for the life of the tab,
26
+ * including the composition read that opens the SDK session. Telemetry after
27
+ * the read-reason split: 120 http_error/404 reads across 5 users in 24h.
28
+ */
29
+ function stubFetch(projectRoute: { status: number } | "reject") {
30
+ vi.stubGlobal(
31
+ "fetch",
32
+ vi.fn(async (url: string) => {
33
+ if (url === "/api/projects") {
34
+ return { ok: true, json: async () => ({ projects: [{ id: "real-project" }] }) } as Response;
35
+ }
36
+ if (projectRoute === "reject") throw new TypeError("Failed to fetch");
37
+ return { ok: projectRoute.status === 200, status: projectRoute.status } as Response;
38
+ }),
39
+ );
40
+ }
41
+
42
+ async function renderWithHash(hash: string) {
43
+ window.location.hash = hash;
44
+ const root = createRoot(document.createElement("div"));
45
+ await act(async () => root.render(<Probe />));
46
+ await flush();
47
+ return root;
48
+ }
49
+
50
+ describe("useServerConnection hash project id", () => {
51
+ beforeEach(() => {
52
+ captured.projectId = null;
53
+ window.location.hash = "";
54
+ });
55
+
56
+ afterEach(() => {
57
+ vi.unstubAllGlobals();
58
+ });
59
+
60
+ it("keeps a hash id the server can resolve", async () => {
61
+ stubFetch({ status: 200 });
62
+ const root = await renderWithHash("#project/stale-or-not");
63
+
64
+ expect(captured.projectId).toBe("stale-or-not");
65
+ await act(async () => root.unmount());
66
+ });
67
+
68
+ it("falls back to the first project when the hash id is gone", async () => {
69
+ stubFetch({ status: 404 });
70
+ const root = await renderWithHash("#project/deleted-project");
71
+
72
+ expect(captured.projectId).toBe("real-project");
73
+ expect(window.location.hash).toContain("real-project");
74
+ await act(async () => root.unmount());
75
+ });
76
+
77
+ // The important one: a blip must not rewrite the user's hash out from under a
78
+ // project that is actually fine. Only a definite 404 is "missing".
79
+ it("keeps the hash id when the check itself fails", async () => {
80
+ stubFetch("reject");
81
+ const root = await renderWithHash("#project/unreachable-check");
82
+
83
+ expect(captured.projectId).toBe("unreachable-check");
84
+ await act(async () => root.unmount());
85
+ });
86
+
87
+ it("keeps the hash id on a non-404 error status", async () => {
88
+ stubFetch({ status: 500 });
89
+ const root = await renderWithHash("#project/server-erroring");
90
+
91
+ expect(captured.projectId).toBe("server-erroring");
92
+ await act(async () => root.unmount());
93
+ });
94
+ });
@@ -18,6 +18,26 @@ interface ServerConnectionState {
18
18
  * Polls every 2 s until the server responds, then transitions automatically.
19
19
  * Cleans up pending timers on unmount so it is safe under React StrictMode.
20
20
  */
21
+ /**
22
+ * Whether a hash-supplied project id still names something the server can
23
+ * resolve. Three answers, not two: a failed request must NOT be read as
24
+ * "missing", or one network blip would discard a perfectly good deep link.
25
+ *
26
+ * Resolved through `/api/projects/:id`, which calls the same
27
+ * `adapter.resolveProject` the file routes use. A match against the
28
+ * `/api/projects` list would be wrong twice over: that list omits session ids
29
+ * (which resolve fine) and skips project dirs without an `index.html`.
30
+ */
31
+ async function resolveHashProject(id: string): Promise<"ok" | "missing" | "unknown"> {
32
+ try {
33
+ const res = await fetch(`/api/projects/${encodeURIComponent(id)}`);
34
+ if (res.ok) return "ok";
35
+ return res.status === 404 ? "missing" : "unknown";
36
+ } catch {
37
+ return "unknown";
38
+ }
39
+ }
40
+
21
41
  export function useServerConnection(): ServerConnectionState {
22
42
  const [projectId, setProjectId] = useState<string | null>(null);
23
43
  const [resolving, setResolving] = useState(true);
@@ -39,21 +59,34 @@ export function useServerConnection(): ServerConnectionState {
39
59
  function tryConnect() {
40
60
  fetch("/api/projects")
41
61
  .then((r) => r.json())
42
- .then((data) => {
62
+ .then(async (data) => {
43
63
  if (cancelled) return;
64
+ // A hash project id outlives the project it names — a renamed folder,
65
+ // or a bookmark from a project that is gone. Trusting it blindly made
66
+ // every later /api/projects/<id>/... request 404 for the life of the
67
+ // tab, including the composition read that opens the SDK session, so
68
+ // every edit fell back to the server path with nothing to show why.
69
+ // Telemetry after the read-reason split: 120 http_error/404 reads
70
+ // across 5 users in 24h, ~24 each, never recovering.
44
71
  if (hashProjectId) {
45
- setProjectId(hashProjectId);
46
- setWaitingForServer(false);
47
- } else {
48
- const first = (data.projects ?? [])[0];
49
- if (first) {
50
- setProjectId(first.id);
72
+ const state = await resolveHashProject(hashProjectId);
73
+ if (cancelled) return;
74
+ // "unknown" keeps the old behaviour: a transient failure must not
75
+ // rewrite the user's hash out from under a valid project.
76
+ if (state !== "missing") {
77
+ setProjectId(hashProjectId);
51
78
  setWaitingForServer(false);
52
- window.location.hash = buildProjectHash(first.id);
53
- } else {
54
- scheduleRetry();
79
+ return;
55
80
  }
56
81
  }
82
+ const first = (data.projects ?? [])[0];
83
+ if (first) {
84
+ setProjectId(first.id);
85
+ setWaitingForServer(false);
86
+ window.location.hash = buildProjectHash(first.id);
87
+ } else {
88
+ scheduleRetry();
89
+ }
57
90
  })
58
91
  .catch(() => {
59
92
  if (!cancelled) scheduleRetry();
@@ -4,7 +4,6 @@ import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it } from "vitest";
6
6
  import { useSlideshowTabState } from "./useSlideshowTabState";
7
- import type { RightPanelTab } from "../utils/studioHelpers";
8
7
 
9
8
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
9
 
@@ -15,14 +14,10 @@ afterEach(() => {
15
14
  document.body.innerHTML = "";
16
15
  });
17
16
 
18
- function renderHook(params: {
19
- editingFileContent: string | null | undefined;
20
- rightPanelTab: RightPanelTab;
21
- }) {
17
+ function renderHook(params: { editingFileContent: string | null | undefined }) {
22
18
  const host = document.createElement("div");
23
19
  document.body.append(host);
24
20
  const root = createRoot(host);
25
- const setRightPanelTabCalls: RightPanelTab[] = [];
26
21
  let current: ReturnType<typeof useSlideshowTabState> | null = null;
27
22
 
28
23
  function Harness() {
@@ -30,8 +25,7 @@ function renderHook(params: {
30
25
  editingFileContent: params.editingFileContent,
31
26
  previewIframeRef: { current: null },
32
27
  refreshKey: 0,
33
- rightPanelTab: params.rightPanelTab,
34
- setRightPanelTab: (tab) => setRightPanelTabCalls.push(tab),
28
+ slideshowVisible: false,
35
29
  });
36
30
  return null;
37
31
  }
@@ -45,52 +39,36 @@ function renderHook(params: {
45
39
  if (!current) throw new Error("useSlideshowTabState did not render");
46
40
  return current;
47
41
  },
48
- setRightPanelTabCalls,
49
42
  unmount: () => act(() => root.unmount()),
50
43
  };
51
44
  }
52
45
 
46
+ // Whether "slideshow" stays the active dock tab across a file switch is
47
+ // StudioRightPanels' own effect now (it opens/closes the dock panel), not
48
+ // this hook's job — this only derives the two values that effect reads.
53
49
  describe("useSlideshowTabState", () => {
54
50
  it("detects a slideshow composition via the JSON island", () => {
55
- const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "design" });
51
+ const harness = renderHook({ editingFileContent: SLIDESHOW_HTML });
56
52
  expect(harness.getState().isSlideshowComposition).toBe(true);
57
53
  harness.unmount();
58
54
  });
59
55
 
60
56
  it("reports false for a plain (non-slideshow) composition", () => {
61
- const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "design" });
57
+ const harness = renderHook({ editingFileContent: PLAIN_HTML });
62
58
  expect(harness.getState().isSlideshowComposition).toBe(false);
63
59
  harness.unmount();
64
60
  });
65
61
 
66
62
  it("reports false when there is no editing file yet", () => {
67
- const harness = renderHook({ editingFileContent: undefined, rightPanelTab: "design" });
63
+ const harness = renderHook({ editingFileContent: undefined });
68
64
  expect(harness.getState().isSlideshowComposition).toBe(false);
69
65
  harness.unmount();
70
66
  });
71
67
 
72
68
  it("still detects a malformed island — presence-only, not full manifest validation", () => {
73
69
  const malformed = `<html><body><script type="application/hyperframes-slideshow+json">{not valid json</script></body></html>`;
74
- const harness = renderHook({ editingFileContent: malformed, rightPanelTab: "design" });
70
+ const harness = renderHook({ editingFileContent: malformed });
75
71
  expect(harness.getState().isSlideshowComposition).toBe(true);
76
72
  harness.unmount();
77
73
  });
78
-
79
- it("bounces rightPanelTab off 'slideshow' to 'renders' on a non-slideshow composition", () => {
80
- const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "slideshow" });
81
- expect(harness.setRightPanelTabCalls).toEqual(["renders"]);
82
- harness.unmount();
83
- });
84
-
85
- it("does not bounce when the composition is a slideshow", () => {
86
- const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "slideshow" });
87
- expect(harness.setRightPanelTabCalls).toEqual([]);
88
- harness.unmount();
89
- });
90
-
91
- it("does not bounce a tab other than 'slideshow'", () => {
92
- const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "renders" });
93
- expect(harness.setRightPanelTabCalls).toEqual([]);
94
- harness.unmount();
95
- });
96
74
  });
@@ -1,29 +1,22 @@
1
- import { useEffect, useMemo, type MutableRefObject } from "react";
1
+ import { useMemo, type MutableRefObject } from "react";
2
2
  import { SLIDESHOW_ISLAND_TYPE, slideshowIslandRegex } from "@hyperframes/core/slideshow";
3
3
  import type { SceneInfo } from "../components/panels/SlideshowPanel";
4
4
  import type { IframeWindow } from "../player/lib/playbackTypes";
5
- import type { RightPanelTab } from "../utils/studioHelpers";
6
5
 
7
6
  /**
8
7
  * Derives whether the currently-edited composition is a slideshow (carries
9
8
  * the slideshow JSON island — the same definitive marker the CLI's `present`
10
9
  * command requires; it refuses to run without one) and the live scene list
11
- * for the Slideshow panel, and bounces `rightPanelTab` off "slideshow" the
12
- * moment it stops applying (e.g. the user switches to a non-slideshow file
13
- * while that tab was open) so the panel never shows a dangling active tab
14
- * whose button is no longer even rendered.
15
- *
16
- * Extracted from StudioRightPanel to keep that file under the 600-LOC gate.
10
+ * for the Slideshow panel. Extracted from StudioRightPanels to keep that file
11
+ * under the 600-LOC gate.
17
12
  */
18
13
  export function useSlideshowTabState(params: {
19
14
  editingFileContent: string | null | undefined;
20
15
  previewIframeRef: MutableRefObject<HTMLIFrameElement | null>;
21
16
  refreshKey: number;
22
- rightPanelTab: RightPanelTab;
23
- setRightPanelTab: (tab: RightPanelTab) => void;
17
+ slideshowVisible: boolean;
24
18
  }): { isSlideshowComposition: boolean; slideshowScenes: SceneInfo[] } {
25
- const { editingFileContent, previewIframeRef, refreshKey, rightPanelTab, setRightPanelTab } =
26
- params;
19
+ const { editingFileContent, previewIframeRef, refreshKey, slideshowVisible } = params;
27
20
 
28
21
  // Presence-only (not full manifest validation): a malformed island should
29
22
  // still surface the Slideshow tab so the user can see/fix it, rather than
@@ -49,13 +42,7 @@ export function useSlideshowTabState(params: {
49
42
  return [];
50
43
  }
51
44
  // eslint-disable-next-line react-hooks/exhaustive-deps
52
- }, [previewIframeRef, rightPanelTab, refreshKey]);
53
-
54
- useEffect(() => {
55
- if (rightPanelTab === "slideshow" && !isSlideshowComposition) {
56
- setRightPanelTab("renders");
57
- }
58
- }, [rightPanelTab, isSlideshowComposition, setRightPanelTab]);
45
+ }, [previewIframeRef, slideshowVisible, refreshKey]);
59
46
 
60
47
  return { isSlideshowComposition, slideshowScenes };
61
48
  }
@@ -4,14 +4,12 @@ import React from "react";
4
4
  import { renderToStaticMarkup } from "react-dom/server";
5
5
  import { describe, expect, it } from "vitest";
6
6
  import type { DomEditSelection } from "../components/editor/domEditing";
7
- import type { RightInspectorPanes } from "../utils/studioHelpers";
7
+ import type { PanelId } from "../components/dock/panelRegistry";
8
8
  import { makeSelection } from "./domSelectionTestHarness";
9
9
  import { useInspectorState, type InspectorState } from "./useStudioContextValue";
10
10
 
11
11
  interface HarnessProps {
12
- rightPanelTab: string;
13
- rightInspectorPanes: RightInspectorPanes;
14
- rightCollapsed: boolean;
12
+ rightPanel: PanelId | null;
15
13
  isPlaying: boolean;
16
14
  isGestureRecording: boolean;
17
15
  domEditSelection: DomEditSelection | null;
@@ -22,9 +20,7 @@ function renderInspectorState(props: HarnessProps): InspectorState {
22
20
 
23
21
  function Harness() {
24
22
  state = useInspectorState(
25
- props.rightPanelTab,
26
- props.rightInspectorPanes,
27
- props.rightCollapsed,
23
+ props.rightPanel,
28
24
  props.isPlaying,
29
25
  props.domEditSelection,
30
26
  props.isGestureRecording,
@@ -42,9 +38,7 @@ function selectedProps(
42
38
  ): HarnessProps & { domEditSelection: DomEditSelection } {
43
39
  const element = document.createElement("div");
44
40
  return {
45
- rightPanelTab: "renders",
46
- rightInspectorPanes: { layers: false, design: false },
47
- rightCollapsed: true,
41
+ rightPanel: "renders",
48
42
  isPlaying: false,
49
43
  isGestureRecording: false,
50
44
  domEditSelection: makeSelection("Selected", element),
@@ -78,16 +72,10 @@ describe("useInspectorState", () => {
78
72
  it("keeps selected DOM bounds coupled to the inspector or variables panel", () => {
79
73
  expect(renderInspectorState(selectedProps()).shouldShowSelectedDomBounds).toBe(false);
80
74
  expect(
81
- renderInspectorState(
82
- selectedProps({
83
- rightPanelTab: "design",
84
- rightInspectorPanes: { layers: false, design: true },
85
- }),
86
- ).shouldShowSelectedDomBounds,
75
+ renderInspectorState(selectedProps({ rightPanel: "design" })).shouldShowSelectedDomBounds,
87
76
  ).toBe(true);
88
77
  expect(
89
- renderInspectorState(selectedProps({ rightPanelTab: "variables" }))
90
- .shouldShowSelectedDomBounds,
78
+ renderInspectorState(selectedProps({ rightPanel: "variables" })).shouldShowSelectedDomBounds,
91
79
  ).toBe(true);
92
80
  });
93
81
  });
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useMemo, type DragEvent } from "react";
2
2
  import type { DomEditSelection } from "../components/editor/domEditing";
3
3
  import type { StudioContextValue } from "../contexts/StudioContext";
4
- import type { RightInspectorPanes } from "../utils/studioHelpers";
4
+ import type { PanelId } from "../components/dock/panelRegistry";
5
5
  import type { TimelineFileDropHandler } from "./useTimelineEditingTypes";
6
6
  import { usePlayerStore } from "../player";
7
7
 
@@ -66,33 +66,21 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex
66
66
  }
67
67
 
68
68
  export interface InspectorState {
69
- layersPanelActive: boolean;
70
- designPanelActive: boolean;
71
69
  inspectorPanelActive: boolean;
72
- inspectorButtonActive: boolean;
73
70
  shouldShowMotionPath: boolean;
74
71
  shouldShowSelectedDomBounds: boolean;
75
72
  }
76
73
 
77
74
  export function useInspectorState(
78
- rightPanelTab: string,
79
- rightInspectorPanes: RightInspectorPanes,
80
- rightCollapsed: boolean,
75
+ rightPanel: PanelId | null,
81
76
  isPlaying: boolean,
82
77
  domEditSelection: DomEditSelection | null,
83
78
  isGestureRecording?: boolean,
84
79
  ): InspectorState {
85
- // fallow-ignore-next-line complexity
86
80
  return useMemo(() => {
87
- const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
88
- const layersPanelActive = inspectorTabActive && rightInspectorPanes.layers;
89
- const designPanelActive = inspectorTabActive && rightInspectorPanes.design;
90
- const inspectorPanelActive = layersPanelActive || designPanelActive;
81
+ const inspectorPanelActive = rightPanel === "layers" || rightPanel === "design";
91
82
  return {
92
- layersPanelActive,
93
- designPanelActive,
94
83
  inspectorPanelActive,
95
- inspectorButtonActive: !rightCollapsed && inspectorPanelActive,
96
84
  // Deliberately wider than shouldShowSelectedDomBounds: the on-canvas path
97
85
  // handles ARE the arc-drag affordance, so gating them on an open Inspector
98
86
  // would make keyframe path editing reachable only from a side panel.
@@ -102,18 +90,9 @@ export function useInspectorState(
102
90
  // The Variables tab also works against the canvas selection (bind card),
103
91
  // so the selection outline stays visible there too.
104
92
  shouldShowSelectedDomBounds:
105
- (inspectorPanelActive || rightPanelTab === "variables") &&
106
- !isPlaying &&
107
- !isGestureRecording,
93
+ (inspectorPanelActive || rightPanel === "variables") && !isPlaying && !isGestureRecording,
108
94
  };
109
- }, [
110
- rightPanelTab,
111
- rightInspectorPanes,
112
- rightCollapsed,
113
- isPlaying,
114
- isGestureRecording,
115
- domEditSelection,
116
- ]);
95
+ }, [rightPanel, isPlaying, isGestureRecording, domEditSelection]);
117
96
  }
118
97
 
119
98
  /** Lets an OS file drop reach `onDrop` anywhere in the shell; the timeline shows its own landing preview. */