@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,166 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import type { DockviewApi } from "dockview-react";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { mountBareDockview } from "./dockTestHarness";
6
+ import { installDockAccessibility } from "./dockAccessibility";
7
+ import { applySideMinimums, buildEditLayout } from "./dockLayout";
8
+
9
+ class ResizeObserverStub {
10
+ observe() {}
11
+ unobserve() {}
12
+ disconnect() {}
13
+ }
14
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub;
15
+
16
+ let host: HTMLElement;
17
+ let api: DockviewApi;
18
+ let dispose: () => void;
19
+
20
+ beforeEach(() => {
21
+ ({ host, api } = mountBareDockview());
22
+ api.layout(1200, 700);
23
+ buildEditLayout(api, 1200);
24
+ applySideMinimums(api, 1200);
25
+ dispose = installDockAccessibility(api, host);
26
+ });
27
+
28
+ afterEach(() => {
29
+ dispose();
30
+ api.dispose();
31
+ document.body.innerHTML = "";
32
+ });
33
+
34
+ const sashes = () => [...host.querySelectorAll<HTMLElement>(".dv-sash")];
35
+ const columnSash = (index: number) =>
36
+ sashes().filter((sash) => sash.getAttribute("aria-orientation") === "vertical")[
37
+ index
38
+ ] as HTMLElement;
39
+ const widthOf = (panel: string) => api.getPanel(panel)?.group.width;
40
+ const press = (target: Element, key: string, init: KeyboardEventInit = {}) =>
41
+ target.dispatchEvent(
42
+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }),
43
+ );
44
+
45
+ describe("dock sashes", () => {
46
+ it("are focusable separators with an orientation and a label", () => {
47
+ expect(sashes()).toHaveLength(3);
48
+ for (const sash of sashes()) {
49
+ expect(sash.getAttribute("role")).toBe("separator");
50
+ expect(sash.tabIndex).toBe(0);
51
+ const label = sash.getAttribute("aria-label");
52
+ const orientation = sash.getAttribute("aria-orientation");
53
+ expect(label).toBe(orientation === "vertical" ? "Resize columns" : "Resize rows");
54
+ }
55
+ expect(
56
+ sashes()
57
+ .map((sash) => sash.getAttribute("aria-orientation"))
58
+ .sort(),
59
+ ).toEqual(["horizontal", "vertical", "vertical"]);
60
+ });
61
+
62
+ it("are decorated again after a layout restore rebuilds them", () => {
63
+ api.fromJSON(api.toJSON());
64
+ expect(sashes()).toHaveLength(3);
65
+ for (const sash of sashes()) expect(sash.getAttribute("role")).toBe("separator");
66
+ });
67
+
68
+ it("resize the neighbouring groups by 16px per arrow press, 64px with Shift", () => {
69
+ const left = widthOf("compositions") as number;
70
+ const preview = widthOf("preview") as number;
71
+ const sash = columnSash(0);
72
+ press(sash, "ArrowRight");
73
+ expect(widthOf("compositions")).toBe(left + 16);
74
+ expect(widthOf("preview")).toBe(preview - 16);
75
+ press(sash, "ArrowRight", { shiftKey: true });
76
+ expect(widthOf("compositions")).toBe(left + 80);
77
+ press(sash, "ArrowLeft");
78
+ expect(widthOf("compositions")).toBe(left + 64);
79
+ });
80
+
81
+ it("stop at the panels' minimum sizes", () => {
82
+ const sash = columnSash(0);
83
+ for (let i = 0; i < 20; i++) press(sash, "ArrowLeft", { shiftKey: true });
84
+ expect(widthOf("compositions")).toBe(200);
85
+ for (let i = 0; i < 40; i++) press(sash, "ArrowRight", { shiftKey: true });
86
+ expect(widthOf("preview")).toBe(360);
87
+ });
88
+
89
+ it("ignore the arrow keys of the other axis and keys pressed anywhere else", () => {
90
+ const left = widthOf("compositions");
91
+ press(columnSash(0), "ArrowDown");
92
+ press(host, "ArrowRight");
93
+ press(document.body, "ArrowRight");
94
+ expect(widthOf("compositions")).toBe(left);
95
+ });
96
+
97
+ it("resize rows with the vertical arrows", () => {
98
+ const timeline = api.getPanel("timeline")?.group.height as number;
99
+ const rows = sashes().find((sash) => sash.getAttribute("aria-orientation") === "horizontal");
100
+ press(rows as HTMLElement, "ArrowUp");
101
+ expect(api.getPanel("timeline")?.group.height).toBe(timeline + 16);
102
+ });
103
+ });
104
+
105
+ describe("dock tab strips", () => {
106
+ const tabsOf = (panel: string) => [
107
+ ...(api.getPanel(panel)?.group.element.querySelectorAll<HTMLElement>('[role="tab"]') ?? []),
108
+ ];
109
+
110
+ it("keep dockview's tablist, tab and selected roles with one tab stop", () => {
111
+ const tabs = tabsOf("compositions");
112
+ expect(tabs).toHaveLength(4);
113
+ expect(tabs[0]?.closest('[role="tablist"]')).not.toBeNull();
114
+ expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual([
115
+ "true",
116
+ "false",
117
+ "false",
118
+ "false",
119
+ ]);
120
+ expect(tabs.map((tab) => tab.tabIndex)).toEqual([0, -1, -1, -1]);
121
+ });
122
+
123
+ it("leave browser shortcuts alone when Alt, Ctrl or Meta is held", () => {
124
+ const sash = columnSash(0);
125
+ const left = widthOf("compositions");
126
+ const tab = tabsOf("compositions")[0] as HTMLElement;
127
+ const active = () => api.getPanel("compositions")?.group.activePanel?.id;
128
+ for (const init of [{ altKey: true }, { ctrlKey: true }, { metaKey: true }]) {
129
+ const onSash = new KeyboardEvent("keydown", {
130
+ key: "ArrowRight",
131
+ cancelable: true,
132
+ bubbles: true,
133
+ ...init,
134
+ });
135
+ sash.dispatchEvent(onSash);
136
+ press(tab, "ArrowRight", init);
137
+ expect(onSash.defaultPrevented).toBe(false);
138
+ expect(active()).toBe("compositions");
139
+ }
140
+ expect(widthOf("compositions")).toBe(left);
141
+ });
142
+
143
+ it("move focus and activate the neighbouring tab, wrapping at both ends", () => {
144
+ const tabs = tabsOf("compositions");
145
+ const active = () => api.getPanel("compositions")?.group.activePanel?.id;
146
+ (tabs[0] as HTMLElement).focus();
147
+ press(tabs[0] as HTMLElement, "ArrowRight");
148
+ expect(active()).toBe("assets");
149
+ expect(document.activeElement).toBe(tabs[1]);
150
+ press(tabs[1] as HTMLElement, "ArrowLeft");
151
+ press(tabs[0] as HTMLElement, "ArrowLeft");
152
+ expect(active()).toBe("catalog");
153
+ expect(document.activeElement).toBe(tabs[3]);
154
+ press(tabs[3] as HTMLElement, "ArrowRight");
155
+ expect(active()).toBe("compositions");
156
+ });
157
+
158
+ it("jump to the first and last tab with Home and End", () => {
159
+ const tabs = tabsOf("design");
160
+ press(tabs[0] as HTMLElement, "End");
161
+ expect(api.getPanel("design")?.group.activePanel?.id).toBe("variables");
162
+ expect(document.activeElement).toBe(tabs[3]);
163
+ press(tabs[3] as HTMLElement, "Home");
164
+ expect(api.getPanel("design")?.group.activePanel?.id).toBe("design");
165
+ });
166
+ });
@@ -0,0 +1,98 @@
1
+ import type { DockviewApi } from "dockview-react";
2
+
3
+ const SASH_STEP = 16;
4
+ const SASH_STEP_SHIFT = 64;
5
+
6
+ type Axis = "horizontal" | "vertical";
7
+
8
+ /** A horizontal split lays views left to right, so its sashes are vertical bars. */
9
+ function splitAxis(sash: HTMLElement): Axis {
10
+ return sash.closest(".dv-split-view-container")?.classList.contains("dv-horizontal")
11
+ ? "horizontal"
12
+ : "vertical";
13
+ }
14
+
15
+ /** Dockview draws sashes as bare divs; make each one a focusable separator. */
16
+ function decorateSashes(root: HTMLElement) {
17
+ for (const sash of root.querySelectorAll<HTMLElement>(".dv-sash")) {
18
+ const axis = splitAxis(sash);
19
+ sash.tabIndex = 0;
20
+ sash.setAttribute("role", "separator");
21
+ sash.setAttribute("aria-orientation", axis === "horizontal" ? "vertical" : "horizontal");
22
+ sash.setAttribute("aria-label", axis === "horizontal" ? "Resize columns" : "Resize rows");
23
+ }
24
+ }
25
+
26
+ /** Replays a pointer drag on the sash so dockview's own clamping to minimum sizes applies. */
27
+ function nudgeSash(sash: HTMLElement, axis: Axis, delta: number) {
28
+ const at = (value: number) =>
29
+ axis === "horizontal" ? { clientX: value, clientY: 0 } : { clientX: 0, clientY: value };
30
+ const doc = sash.ownerDocument;
31
+ const fire = (target: EventTarget, type: string, value: number) =>
32
+ target.dispatchEvent(new MouseEvent(type, { ...at(value), bubbles: true, cancelable: true }));
33
+ fire(sash, "pointerdown", 0);
34
+ fire(doc, "pointermove", delta);
35
+ fire(doc, "pointerup", delta);
36
+ }
37
+
38
+ /** Browser and OS shortcuts (Alt+Left is Back) keep their meaning. */
39
+ function hasShortcutModifier(event: KeyboardEvent) {
40
+ return event.altKey || event.ctrlKey || event.metaKey;
41
+ }
42
+
43
+ function onSashKeyDown(event: KeyboardEvent, sash: HTMLElement) {
44
+ if (hasShortcutModifier(event)) return;
45
+ const axis = splitAxis(sash);
46
+ const [less, more] =
47
+ axis === "horizontal" ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"];
48
+ if (event.key !== less && event.key !== more) return;
49
+ event.preventDefault();
50
+ const step = event.shiftKey ? SASH_STEP_SHIFT : SASH_STEP;
51
+ nudgeSash(sash, axis, event.key === more ? step : -step);
52
+ }
53
+
54
+ /** Wraps around and activates the tab it lands on, unlike dockview's focus-only arrows. */
55
+ function onTabKeyDown(event: KeyboardEvent, tab: HTMLElement, api: DockviewApi) {
56
+ if (hasShortcutModifier(event)) return;
57
+ const tabs = [
58
+ ...(tab.closest('[role="tablist"]')?.querySelectorAll<HTMLElement>('[role="tab"]') ?? []),
59
+ ];
60
+ const index = tabs.indexOf(tab);
61
+ const last = tabs.length - 1;
62
+ const targets: Record<string, number> = {
63
+ ArrowRight: index === last ? 0 : index + 1,
64
+ ArrowLeft: index === 0 ? last : index - 1,
65
+ Home: 0,
66
+ End: last,
67
+ };
68
+ const target = tabs[targets[event.key] ?? -1];
69
+ if (!target) return;
70
+ event.preventDefault();
71
+ event.stopPropagation();
72
+ const panelId = target.dataset.tabPanelId;
73
+ if (panelId) api.getPanel(panelId)?.api.setActive();
74
+ target.focus();
75
+ }
76
+
77
+ /** Keyboard and ARIA support dockview 8.3 lacks for sashes and tab arrows. Returns a disposer. */
78
+ export function installDockAccessibility(api: DockviewApi, root: HTMLElement): () => void {
79
+ const decorate = () => decorateSashes(root);
80
+ decorate();
81
+ const subscriptions = [
82
+ api.onDidLayoutChange(decorate),
83
+ api.onDidLayoutFromJSON(decorate),
84
+ api.onDidAddPanel(decorate),
85
+ api.onDidRemovePanel(decorate),
86
+ ];
87
+ // Capture phase so the tab handler runs before dockview's own focus-only arrows.
88
+ const onKeyDown = (event: KeyboardEvent) => {
89
+ if (!(event.target instanceof HTMLElement)) return;
90
+ if (event.target.classList.contains("dv-sash")) onSashKeyDown(event, event.target);
91
+ else if (event.target.getAttribute("role") === "tab") onTabKeyDown(event, event.target, api);
92
+ };
93
+ root.addEventListener("keydown", onKeyDown, true);
94
+ return () => {
95
+ root.removeEventListener("keydown", onKeyDown, true);
96
+ for (const subscription of subscriptions) subscription.dispose();
97
+ };
98
+ }
@@ -0,0 +1,134 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import type { DockviewApi } from "dockview-react";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import {
6
+ applySideMinimums,
7
+ buildEditLayout,
8
+ defaultSideWidths,
9
+ sideMinimumWidth,
10
+ } from "./dockLayout";
11
+ import { mountBareDockview } from "./dockTestHarness";
12
+
13
+ class ResizeObserverStub {
14
+ observe() {}
15
+ unobserve() {}
16
+ disconnect() {}
17
+ }
18
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub;
19
+
20
+ // Expected values are hand-derived from the old fitPanels rule: the preview keeps 360 first,
21
+ // then each side column takes half of what remains, never above 200 or below 120.
22
+ describe("sideMinimumWidth", () => {
23
+ it.each([
24
+ [1680, 200],
25
+ [1200, 200],
26
+ [860, 200],
27
+ [760, 200],
28
+ [700, 170],
29
+ [600, 120],
30
+ [560, 120],
31
+ [400, 120],
32
+ ])("is %ipx wide -> sides shrink to %ipx at most", (viewport, expected) => {
33
+ expect(sideMinimumWidth(viewport)).toBe(expected);
34
+ });
35
+ });
36
+
37
+ describe("defaultSideWidths", () => {
38
+ it.each([
39
+ [1680, 384, 424],
40
+ [1280, 329, 364],
41
+ [860, 221, 244],
42
+ [560, 120, 120],
43
+ ])("at %ipx gives %i / %i", (viewport, left, right) => {
44
+ expect(defaultSideWidths(viewport)).toEqual({ left, right });
45
+ });
46
+
47
+ it.each([1680, 1280, 1000, 860, 760, 700, 600, 560, 480])(
48
+ "leaves the preview its 360px floor at %ipx whenever the sides can shrink enough",
49
+ (viewport) => {
50
+ const { left, right } = defaultSideWidths(viewport);
51
+ const floor = sideMinimumWidth(viewport);
52
+ const overflow = left + right + 360 - viewport;
53
+ expect(overflow <= 0 || (left === floor && right === floor)).toBe(true);
54
+ },
55
+ );
56
+ });
57
+
58
+ describe("applySideMinimums", () => {
59
+ let api: DockviewApi;
60
+ beforeEach(() => {
61
+ ({ api } = mountBareDockview());
62
+ });
63
+ afterEach(() => {
64
+ api.dispose();
65
+ document.body.innerHTML = "";
66
+ });
67
+
68
+ it("lowers side groups' minimum on a narrow window and shrinks them, never touching the preview", () => {
69
+ api.layout(1200, 700);
70
+ buildEditLayout(api, 1200);
71
+ applySideMinimums(api, 560);
72
+ expect(api.getPanel("compositions")?.group.minimumWidth).toBe(120);
73
+ expect(api.getPanel("design")?.group.minimumWidth).toBe(120);
74
+ expect(api.getPanel("compositions")?.group.width).toBe(120);
75
+ expect(api.getPanel("design")?.group.width).toBe(120);
76
+ expect(api.getPanel("preview")?.group.minimumWidth).toBe(360);
77
+ });
78
+
79
+ it("leaves a wide window's user-chosen widths alone", () => {
80
+ api.layout(1200, 700);
81
+ buildEditLayout(api, 1200);
82
+ api.getPanel("compositions")?.group.api.setSize({ width: 300 });
83
+ applySideMinimums(api, 1200);
84
+ expect(api.getPanel("compositions")?.group.width).toBe(300);
85
+ });
86
+
87
+ it("keeps the preview's floor when a side panel is tabbed into the preview group", () => {
88
+ api.layout(1200, 700);
89
+ buildEditLayout(api, 1200);
90
+ const preview = api.getPanel("preview");
91
+ const design = api.getPanel("design");
92
+ if (!preview || !design) throw new Error("default layout is missing panels");
93
+ design.api.moveTo({ group: preview.group, position: "center", index: 0 });
94
+ preview.api.setActive();
95
+ applySideMinimums(api, 560);
96
+ expect(preview.group.minimumWidth).toBe(360);
97
+ });
98
+
99
+ it("pins the preview floor when a side panel that was showing gets the preview dragged in", () => {
100
+ api.layout(1200, 700);
101
+ buildEditLayout(api, 1200);
102
+ applySideMinimums(api, 560);
103
+ const preview = api.getPanel("preview");
104
+ const design = api.getPanel("design");
105
+ if (!preview || !design) throw new Error("default layout is missing panels");
106
+ preview.api.moveTo({ group: design.group });
107
+ applySideMinimums(api, 560);
108
+ expect(preview.group.minimumWidth).toBe(360);
109
+ });
110
+
111
+ it("rewrites the minimum of a group that is neither the preview nor side-only", () => {
112
+ api.layout(1200, 700);
113
+ buildEditLayout(api, 1200);
114
+ const timeline = api.getPanel("timeline");
115
+ const compositions = api.getPanel("compositions");
116
+ if (!timeline || !compositions) throw new Error("default layout is missing panels");
117
+ timeline.api.moveTo({ group: compositions.group });
118
+ applySideMinimums(api, 560);
119
+ expect(compositions.group.minimumWidth).toBe(120);
120
+ });
121
+
122
+ it("drops the preview floor from a group the preview has left", () => {
123
+ api.layout(1200, 700);
124
+ buildEditLayout(api, 1200);
125
+ const preview = api.getPanel("preview");
126
+ const timeline = api.getPanel("timeline");
127
+ if (!preview || !timeline) throw new Error("default layout is missing panels");
128
+ preview.api.moveTo({ group: timeline.group });
129
+ applySideMinimums(api, 560);
130
+ preview.api.moveTo({ position: "right", group: api.getPanel("design")?.group });
131
+ applySideMinimums(api, 560);
132
+ expect(timeline.group.minimumWidth).toBe(120);
133
+ });
134
+ });
@@ -0,0 +1,105 @@
1
+ import type { Direction, DockviewApi } from "dockview-react";
2
+ import { DOCK_PANEL_COMPONENT } from "./dockLayoutSchema";
3
+ import { PANEL_DEFINITIONS, isPanelId, type PanelId } from "./panelRegistry";
4
+
5
+ const MIN_PREVIEW_W = 360;
6
+ const MIN_PREVIEW_H = 200;
7
+ const MIN_TIMELINE_H = 100;
8
+ const MIN_SIDE_W = 200;
9
+ const MIN_SIDE_W_FLOOR = 120;
10
+ const DEFAULT_TIMELINE_H = 360;
11
+
12
+ /** Preferred side widths; when they overflow the preview's floor the right yields first, then the left. */
13
+ export function defaultSideWidths(viewportWidth: number) {
14
+ const floor = sideMinimumWidth(viewportWidth);
15
+ const left = Math.max(floor, Math.min(384, Math.round(viewportWidth * 0.257)));
16
+ const right = Math.max(Math.min(280, floor), Math.min(424, Math.round(viewportWidth * 0.284)));
17
+ const excess = left + right - Math.max(0, viewportWidth - MIN_PREVIEW_W);
18
+ if (excess <= 0) return { left, right };
19
+ const shrunkRight = Math.max(floor, right - excess);
20
+ return { left: Math.max(floor, left - (excess - (right - shrunkRight))), right: shrunkRight };
21
+ }
22
+
23
+ /**
24
+ * Side groups keep MIN_SIDE_W while both fit beside the preview's floor, then shrink down to
25
+ * MIN_SIDE_W_FLOOR; below that the dock overflows and clips rather than squeezing the preview.
26
+ */
27
+ export function sideMinimumWidth(dockWidth: number): number {
28
+ const fair = Math.floor((dockWidth - MIN_PREVIEW_W) / 2);
29
+ return Math.min(MIN_SIDE_W, Math.max(MIN_SIDE_W_FLOOR, fair));
30
+ }
31
+
32
+ function holdsPreview(group: DockviewApi["groups"][number]) {
33
+ return group.panels.some((panel) => panel.id === "preview");
34
+ }
35
+
36
+ /** The zone of a group whose every tab is a side panel, else null. */
37
+ function sideZone(group: DockviewApi["groups"][number]): "left" | "right" | null {
38
+ const zones = group.panels.map((panel) =>
39
+ isPanelId(panel.id) ? PANEL_DEFINITIONS[panel.id].zone : "center",
40
+ );
41
+ const first = zones[0];
42
+ const allSide = zones.every((zone) => zone !== "center");
43
+ return allSide && first && first !== "center" ? first : null;
44
+ }
45
+
46
+ /** Idempotent; rewrites every group's minimum, since dockview keeps a constraint once set. */
47
+ export function applySideMinimums(api: DockviewApi, dockWidth = window.innerWidth) {
48
+ const minimumWidth = sideMinimumWidth(dockWidth);
49
+ const cap = defaultSideWidths(dockWidth);
50
+ for (const group of api.groups) {
51
+ if (holdsPreview(group)) {
52
+ group.api.setConstraints({ minimumWidth: MIN_PREVIEW_W });
53
+ continue;
54
+ }
55
+ group.api.setConstraints({ minimumWidth });
56
+ const zone = sideZone(group);
57
+ const limit = zone === "right" ? cap.right : cap.left;
58
+ if (zone && minimumWidth < MIN_SIDE_W && group.width > limit) {
59
+ group.api.setSize({ width: limit });
60
+ }
61
+ }
62
+ }
63
+
64
+ function minimumSize(id: PanelId) {
65
+ if (id === "preview") return { minimumWidth: MIN_PREVIEW_W, minimumHeight: MIN_PREVIEW_H };
66
+ if (id === "timeline") return { minimumHeight: MIN_TIMELINE_H };
67
+ return { minimumWidth: MIN_SIDE_W };
68
+ }
69
+
70
+ export function addRegisteredPanel(
71
+ api: DockviewApi,
72
+ id: PanelId,
73
+ position?: { referencePanel: PanelId; direction: Direction },
74
+ ) {
75
+ return api.addPanel({
76
+ id,
77
+ component: DOCK_PANEL_COMPONENT,
78
+ title: PANEL_DEFINITIONS[id].title,
79
+ renderer: "always",
80
+ ...minimumSize(id),
81
+ ...(position ? { position } : {}),
82
+ });
83
+ }
84
+
85
+ /** The default Edit layout: [library | preview | inspector] over a full-width timeline. */
86
+ export function buildEditLayout(api: DockviewApi, viewportWidth: number) {
87
+ api.clear();
88
+ const widths = defaultSideWidths(viewportWidth);
89
+ addRegisteredPanel(api, "preview");
90
+ addRegisteredPanel(api, "timeline", { referencePanel: "preview", direction: "below" });
91
+ addRegisteredPanel(api, "compositions", { referencePanel: "preview", direction: "left" });
92
+ for (const id of ["assets", "code", "catalog"] as const) {
93
+ addRegisteredPanel(api, id, { referencePanel: "compositions", direction: "within" });
94
+ }
95
+ addRegisteredPanel(api, "design", { referencePanel: "preview", direction: "right" });
96
+ for (const id of ["layers", "renders", "variables"] as const) {
97
+ addRegisteredPanel(api, id, { referencePanel: "design", direction: "within" });
98
+ }
99
+ api.getPanel("compositions")?.api.setActive();
100
+ api.getPanel("design")?.api.setActive();
101
+ applySideMinimums(api, viewportWidth);
102
+ api.getPanel("compositions")?.group.api.setSize({ width: widths.left });
103
+ api.getPanel("design")?.group.api.setSize({ width: widths.right });
104
+ api.getPanel("timeline")?.group.api.setSize({ height: DEFAULT_TIMELINE_H });
105
+ }
@@ -0,0 +1,66 @@
1
+ import type { SerializedDockview } from "dockview-react";
2
+ import { isPanelId } from "./panelRegistry";
3
+
4
+ export const DOCK_PANEL_COMPONENT = "panel";
5
+
6
+ function isRecord(value: unknown): value is Record<string, unknown> {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+
10
+ function collectLeafViews(node: unknown, into: unknown[]): boolean {
11
+ if (!isRecord(node)) return false;
12
+ if (node.type === "leaf") {
13
+ if (!isRecord(node.data) || !Array.isArray(node.data.views)) return false;
14
+ into.push(...node.data.views);
15
+ return true;
16
+ }
17
+ if (node.type === "branch") {
18
+ return Array.isArray(node.data) && node.data.every((child) => collectLeafViews(child, into));
19
+ }
20
+ return false;
21
+ }
22
+
23
+ function hasGridShape(grid: Record<string, unknown>): boolean {
24
+ const oriented = grid.orientation === "HORIZONTAL" || grid.orientation === "VERTICAL";
25
+ return oriented && typeof grid.width === "number" && typeof grid.height === "number";
26
+ }
27
+
28
+ function panelsAreRegistered(panels: Record<string, unknown>): boolean {
29
+ const ids = Object.keys(panels);
30
+ return (
31
+ ids.length > 0 &&
32
+ ids.every((id) => {
33
+ const state = panels[id];
34
+ return (
35
+ isPanelId(id) &&
36
+ isRecord(state) &&
37
+ state.id === id &&
38
+ state.contentComponent === DOCK_PANEL_COMPONENT
39
+ );
40
+ })
41
+ );
42
+ }
43
+
44
+ function collectFloatingViews(floating: unknown, into: unknown[]): boolean {
45
+ if (floating === undefined) return true;
46
+ if (!Array.isArray(floating)) return false;
47
+ return floating.every((group) => {
48
+ if (!isRecord(group) || !isRecord(group.data) || !Array.isArray(group.data.views)) return false;
49
+ into.push(...group.data.views);
50
+ return true;
51
+ });
52
+ }
53
+
54
+ /** Parses a stored layout at the trust boundary; null (stale or foreign shape) means use the default preset. */
55
+ export function parseDockLayout(value: unknown): SerializedDockview | null {
56
+ if (!isRecord(value) || !isRecord(value.grid) || !isRecord(value.panels)) return null;
57
+ const { grid, panels } = value;
58
+ if (!hasGridShape(grid) || !panelsAreRegistered(panels)) return null;
59
+
60
+ const views: unknown[] = [];
61
+ if (!collectLeafViews(grid.root, views)) return null;
62
+ if (!collectFloatingViews(value.floatingGroups, views)) return null;
63
+ const everyViewIsAPanel = views.every((id) => typeof id === "string" && id in panels);
64
+ const everyPanelIsPlaced = Object.keys(panels).every((id) => views.includes(id));
65
+ return everyViewIsAPanel && everyPanelIsPlaced ? (value as unknown as SerializedDockview) : null;
66
+ }
@@ -0,0 +1,28 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { useDockLayoutStore } from "./dockLayoutStore";
3
+ import type { PanelId } from "./panelRegistry";
4
+
5
+ const snapshot = (groupActivePanels: PanelId[], activePanel: PanelId | null = null) => ({
6
+ openPanels: new Set<PanelId>(["design", "renders", "compositions"]),
7
+ visiblePanels: new Set<PanelId>(groupActivePanels),
8
+ activePanel,
9
+ groupActivePanels,
10
+ });
11
+
12
+ describe("dock store sync", () => {
13
+ beforeEach(() => useDockLayoutStore.setState({ lastActive: {} }));
14
+
15
+ it("records what every side group shows, not only the globally active panel", () => {
16
+ useDockLayoutStore.getState().sync(snapshot(["compositions", "renders"], "compositions"));
17
+ expect(useDockLayoutStore.getState().lastActive).toEqual({
18
+ left: "compositions",
19
+ right: "renders",
20
+ });
21
+ });
22
+
23
+ it("forgets the previous dock's memory on detach", () => {
24
+ useDockLayoutStore.getState().sync(snapshot(["renders"], "renders"));
25
+ useDockLayoutStore.getState().detach();
26
+ expect(useDockLayoutStore.getState().lastActive).toEqual({});
27
+ });
28
+ });