@hyperframes/studio 0.8.50 → 0.8.52

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 (280) hide show
  1. package/dist/assets/{hyperframes-player-CxxDxHTW.js → hyperframes-player-I5YRzB02.js} +3 -3
  2. package/dist/assets/{index-Dt3qBI-9.js → index-BTAfgesM.js} +1 -1
  3. package/dist/assets/index-CxAu_Okg.css +1 -0
  4. package/dist/assets/index-DocJlW2v.js +376 -0
  5. package/dist/assets/{index-DUAIWxkI.js → index-Du5x7KUR.js} +1 -1
  6. package/dist/index.d.ts +419 -20
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7496 -8288
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.js.map +1 -1
  11. package/package.json +13 -12
  12. package/src/App.tsx +131 -143
  13. package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
  14. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  15. package/src/captions/components/CaptionOverlay.tsx +8 -4
  16. package/src/captions/components/CaptionOverlayUtils.ts +2 -3
  17. package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
  18. package/src/captions/components/CaptionTimeline.tsx +1 -1
  19. package/src/captions/components/shared.tsx +2 -2
  20. package/src/components/AskAgentModal.tsx +5 -5
  21. package/src/components/EditorShell.tsx +8 -7
  22. package/src/components/ExternalFileConflictBanner.tsx +16 -16
  23. package/src/components/LintModal.tsx +6 -16
  24. package/src/components/MediaPreview.tsx +2 -2
  25. package/src/components/RightPanelTabs.test.tsx +151 -0
  26. package/src/components/RightPanelTabs.tsx +59 -0
  27. package/src/components/SaveQueuePausedBanner.tsx +2 -2
  28. package/src/components/StudioHeader.dom.test.tsx +181 -0
  29. package/src/components/StudioHeader.tsx +110 -210
  30. package/src/components/StudioLeftSidebar.tsx +7 -4
  31. package/src/components/StudioOverlays.tsx +1 -1
  32. package/src/components/StudioRightPanel.tsx +51 -39
  33. package/src/components/StudioToast.tsx +2 -2
  34. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  35. package/src/components/TimelineHistoryButtons.tsx +54 -0
  36. package/src/components/TimelineToolbar.test.tsx +8 -0
  37. package/src/components/TimelineToolbar.tsx +8 -12
  38. package/src/components/dock/Dock.test.tsx +260 -0
  39. package/src/components/dock/Dock.tsx +281 -0
  40. package/src/components/dock/dock.css +28 -0
  41. package/src/components/dock/dockAccessibility.test.ts +166 -0
  42. package/src/components/dock/dockAccessibility.ts +98 -0
  43. package/src/components/dock/dockLayout.test.ts +134 -0
  44. package/src/components/dock/dockLayout.ts +105 -0
  45. package/src/components/dock/dockLayoutSchema.ts +66 -0
  46. package/src/components/dock/dockLayoutStore.ts +91 -0
  47. package/src/components/dock/dockTestHarness.ts +11 -0
  48. package/src/components/dock/panelRegistry.ts +67 -0
  49. package/src/components/editor/AnimationCard.tsx +4 -4
  50. package/src/components/editor/AnimationCardParts.tsx +6 -6
  51. package/src/components/editor/BlockParamsPanel.tsx +4 -4
  52. package/src/components/editor/BorderRadiusEditor.tsx +1 -1
  53. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  54. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  55. package/src/components/editor/DomEditOverlay.tsx +1 -1
  56. package/src/components/editor/EaseCurveSection.tsx +1 -1
  57. package/src/components/editor/EaseParamFields.tsx +5 -5
  58. package/src/components/editor/FileTree.tsx +4 -4
  59. package/src/components/editor/FileTreeIcons.tsx +1 -1
  60. package/src/components/editor/FileTreeNodes.tsx +13 -13
  61. package/src/components/editor/InlineTextToolbar.tsx +1 -1
  62. package/src/components/editor/InspectorHeaderActions.tsx +3 -3
  63. package/src/components/editor/KeyframeDiamond.tsx +1 -1
  64. package/src/components/editor/KeyframeEaseList.tsx +1 -1
  65. package/src/components/editor/LayersPanel.test.ts +97 -1
  66. package/src/components/editor/LayersPanel.tsx +7 -5
  67. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  68. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  69. package/src/components/editor/PreviewGuides.tsx +1 -1
  70. package/src/components/editor/PromotableControl.tsx +2 -2
  71. package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
  72. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  73. package/src/components/editor/SnapToolbar.tsx +2 -2
  74. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  75. package/src/components/editor/TopologyLens.test.tsx +28 -0
  76. package/src/components/editor/TopologyLens.tsx +7 -5
  77. package/src/components/editor/Transform3DCube.tsx +2 -2
  78. package/src/components/editor/audioFxRevealTarget.ts +1 -0
  79. package/src/components/editor/domEditing.test.ts +1 -1
  80. package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
  81. package/src/components/editor/domEditingAgentPrompt.ts +4 -3
  82. package/src/components/editor/propertyPanelColor.tsx +11 -11
  83. package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
  84. package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
  85. package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
  86. package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
  87. package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
  88. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  89. package/src/components/editor/propertyPanelFill.tsx +4 -4
  90. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  91. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
  92. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  93. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
  94. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
  95. package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
  96. package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
  97. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
  98. package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
  99. package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
  100. package/src/components/editor/propertyPanelFont.tsx +5 -5
  101. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  102. package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
  103. package/src/components/editor/propertyPanelFxControls.tsx +6 -6
  104. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  105. package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
  106. package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
  107. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  108. package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
  109. package/src/components/editor/propertyPanelSections.tsx +8 -8
  110. package/src/components/editor/useColorGradingController.test.ts +38 -0
  111. package/src/components/editor/useColorGradingController.ts +9 -1
  112. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  113. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  114. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  115. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  116. package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
  117. package/src/components/feedback/feedbackTrigger.ts +0 -1
  118. package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
  119. package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
  120. package/src/components/nle/NLEPreview.tsx +3 -3
  121. package/src/components/nle/PreviewOverlays.tsx +6 -6
  122. package/src/components/nle/PreviewPane.tsx +3 -3
  123. package/src/components/nle/TimelinePane.tsx +3 -3
  124. package/src/components/nle/TimelineResizeDivider.tsx +2 -2
  125. package/src/components/panels/SlideshowPanel.tsx +2 -2
  126. package/src/components/panels/SlideshowSubPanels.tsx +20 -17
  127. package/src/components/panels/VariablesBindElement.tsx +2 -2
  128. package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
  129. package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
  130. package/src/components/panels/VariablesPanel.tsx +3 -3
  131. package/src/components/panels/VariablesValueControls.tsx +2 -2
  132. package/src/components/pointerTargetSize.test.tsx +8 -0
  133. package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
  134. package/src/components/renders/RenderQueue.test.tsx +7 -0
  135. package/src/components/renders/RenderQueue.tsx +7 -7
  136. package/src/components/renders/RenderQueueItem.tsx +6 -6
  137. package/src/components/sidebar/AssetCard.tsx +8 -8
  138. package/src/components/sidebar/AssetContextMenu.tsx +8 -8
  139. package/src/components/sidebar/AssetsTab.tsx +2 -2
  140. package/src/components/sidebar/AudioRow.tsx +6 -6
  141. package/src/components/sidebar/BlocksTab.tsx +6 -6
  142. package/src/components/sidebar/CompositionsTab.tsx +5 -5
  143. package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
  144. package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
  145. package/src/components/sidebar/LeftSidebar.tsx +19 -5
  146. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  147. package/src/components/timelineToolbarStyles.ts +5 -0
  148. package/src/components/ui/Button.test.tsx +252 -0
  149. package/src/components/ui/Button.tsx +89 -126
  150. package/src/components/ui/IconButton.tsx +38 -0
  151. package/src/components/ui/Input.tsx +115 -0
  152. package/src/components/ui/Menu.test.tsx +374 -0
  153. package/src/components/ui/Menu.tsx +213 -0
  154. package/src/components/ui/NumberField.tsx +122 -0
  155. package/src/components/ui/Popover.tsx +74 -0
  156. package/src/components/ui/SearchInput.tsx +2 -2
  157. package/src/components/ui/Select.tsx +100 -0
  158. package/src/components/ui/Slider.tsx +153 -0
  159. package/src/components/ui/Tabs.test.tsx +98 -0
  160. package/src/components/ui/Tabs.tsx +71 -0
  161. package/src/components/ui/Toggle.tsx +63 -0
  162. package/src/components/ui/Tooltip.test.tsx +110 -0
  163. package/src/components/ui/Tooltip.tsx +38 -112
  164. package/src/components/ui/cn.test.ts +30 -0
  165. package/src/components/ui/cn.ts +25 -0
  166. package/src/components/ui/index.ts +29 -2
  167. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  168. package/src/components/ui/valueControls.test.tsx +524 -0
  169. package/src/hooks/useAppHotkeys.ts +21 -80
  170. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  171. package/src/hooks/useCaptionDetection.ts +3 -1
  172. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  173. package/src/hooks/useEditHistoryActions.ts +106 -0
  174. package/src/hooks/useLintModal.test.tsx +65 -0
  175. package/src/hooks/useLintModal.ts +3 -0
  176. package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
  177. package/src/hooks/useSdkSession.ts +43 -10
  178. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  179. package/src/hooks/useServerConnection.ts +43 -10
  180. package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
  181. package/src/hooks/useTimelineAssetDropOps.ts +52 -11
  182. package/src/index.ts +51 -0
  183. package/src/main.tsx +9 -3
  184. package/src/player/components/AudioWaveform.tsx +1 -1
  185. package/src/player/components/AutomationSelectionMenu.tsx +3 -3
  186. package/src/player/components/ClipContextMenu.tsx +3 -3
  187. package/src/player/components/CompositionThumbnail.test.ts +0 -3
  188. package/src/player/components/CompositionThumbnail.tsx +3 -4
  189. package/src/player/components/EditModal.tsx +1 -1
  190. package/src/player/components/ImageThumbnail.tsx +1 -1
  191. package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
  192. package/src/player/components/LayerDisclosureRow.tsx +2 -2
  193. package/src/player/components/PlayerControls.tsx +1 -1
  194. package/src/player/components/ShortcutsPanel.tsx +9 -9
  195. package/src/player/components/SpeedMenu.tsx +3 -5
  196. package/src/player/components/Timeline.tsx +2 -1
  197. package/src/player/components/TimelineCanvas.tsx +21 -14
  198. package/src/player/components/TimelineClip.tsx +0 -1
  199. package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
  200. package/src/player/components/TimelineEmptyState.tsx +5 -5
  201. package/src/player/components/TimelineFxButton.tsx +6 -6
  202. package/src/player/components/TimelineGroupHeader.tsx +5 -5
  203. package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
  204. package/src/player/components/TimelineGroupRow.tsx +1 -1
  205. package/src/player/components/TimelineLanes.test.tsx +32 -0
  206. package/src/player/components/TimelineLanes.tsx +2 -5
  207. package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
  208. package/src/player/components/TimelineRuler.tsx +1 -1
  209. package/src/player/components/TimelineShortcutHint.tsx +1 -1
  210. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  211. package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
  212. package/src/player/components/TimelineTypes.ts +2 -1
  213. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  214. package/src/player/components/VideoThumbnail.tsx +4 -2
  215. package/src/player/components/VolumeControl.tsx +2 -2
  216. package/src/player/components/timelineCallbacks.ts +8 -8
  217. package/src/player/components/timelineClipDragPreview.ts +51 -27
  218. package/src/player/components/timelineClipDragTypes.ts +3 -0
  219. package/src/player/components/timelineDragDrop.test.tsx +61 -1
  220. package/src/player/components/timelineDragDrop.ts +57 -15
  221. package/src/player/components/timelineLaneProps.ts +3 -0
  222. package/src/player/components/timelineSnapping.test.ts +27 -0
  223. package/src/player/components/timelineSnapping.ts +14 -1
  224. package/src/player/components/trackHeaderLabelRows.tsx +4 -4
  225. package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
  226. package/src/player/components/useTimelineClipDrag.ts +30 -3
  227. package/src/player/lib/describeClips.ts +3 -54
  228. package/src/player/lib/playbackShortcuts.ts +2 -0
  229. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  230. package/src/player/store/previewIframeStore.test.tsx +29 -0
  231. package/src/player/store/previewIframeStore.ts +25 -0
  232. package/src/renderQueueExports.test.tsx +58 -0
  233. package/src/styles/classCandidates.test.ts +105 -0
  234. package/src/styles/classCandidates.ts +281 -0
  235. package/src/styles/hex-baseline.json +84 -0
  236. package/src/styles/hexRatchet.test.ts +201 -0
  237. package/src/styles/iconTokens.ts +20 -0
  238. package/src/styles/palette-reset.css +6 -0
  239. package/src/styles/studio.css +24 -21
  240. package/src/styles/styleSources.test.ts +20 -0
  241. package/src/styles/styleSources.ts +47 -0
  242. package/src/styles/tailwind-preset.shared.js +3 -0
  243. package/src/styles/theme.css +243 -0
  244. package/src/styles/theme.test.ts +183 -0
  245. package/src/styles/tokenGate.test.ts +109 -0
  246. package/src/utils/sourcePatcher.ts +1 -1
  247. package/src/utils/studioHelpers.test.ts +18 -0
  248. package/src/utils/studioHelpers.ts +11 -1
  249. package/src/utils/studioUiPreferences.ts +7 -0
  250. package/src/utils/timelineDropTrackInsert.test.ts +134 -0
  251. package/src/utils/timelineDropTrackInsert.ts +83 -0
  252. package/src/utils/typingTarget.ts +4 -0
  253. package/dist/assets/index-n2ml4tUn.js +0 -442
  254. package/dist/assets/index-pO6p0H27.css +0 -1
  255. package/src/components/PanelTabButton.tsx +0 -31
  256. package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
  257. package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
  258. package/src/components/storyboard/FramePoster.test.tsx +0 -61
  259. package/src/components/storyboard/FramePoster.tsx +0 -86
  260. package/src/components/storyboard/StoryboardDirection.tsx +0 -45
  261. package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
  262. package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
  263. package/src/components/storyboard/StoryboardGrid.tsx +0 -55
  264. package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
  265. package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
  266. package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
  267. package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
  268. package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
  269. package/src/components/storyboard/StoryboardView.tsx +0 -169
  270. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
  271. package/src/components/storyboard/frameComments.test.ts +0 -95
  272. package/src/components/storyboard/frameComments.ts +0 -115
  273. package/src/components/storyboard/frameStatus.ts +0 -36
  274. package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
  275. package/src/components/storyboard/storyboardReviewStage.ts +0 -45
  276. package/src/components/storyboard/useFrameComments.ts +0 -98
  277. package/src/contexts/ViewModeContext.tsx +0 -142
  278. package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
  279. package/src/hooks/useProjectSignaturePoll.ts +0 -68
  280. package/src/hooks/useStoryboard.ts +0 -99
@@ -9,7 +9,7 @@ import { BlockParamsPanel } from "./editor/BlockParamsPanel";
9
9
  import { RenderQueuePanel } from "./renders/RenderQueuePanel";
10
10
  import { SlideshowPanel } from "./panels/SlideshowPanel";
11
11
  import { VariablesPanel } from "./panels/VariablesPanel";
12
- import { PanelTabButton } from "./PanelTabButton";
12
+ import { RightPanelTabs, type RightPanelTabDescriptor } from "./RightPanelTabs";
13
13
  import type { RenderJob } from "./renders/useRenderQueue";
14
14
  import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./editor/manualEditingAvailability";
15
15
  import { useSlideshowPersist } from "../hooks/useSlideshowPersist";
@@ -339,6 +339,51 @@ export function StudioRightPanel({
339
339
 
340
340
  const renderQueuePanel = <RenderQueuePanel />;
341
341
 
342
+ // Slideshow appears only for a slideshow composition, so the strip is built
343
+ // rather than written out: a tab that is not in this list is not reachable by
344
+ // an arrow key either.
345
+ const inspectorTabs: RightPanelTabDescriptor[] = [
346
+ {
347
+ id: "design",
348
+ label: "Design",
349
+ tooltip: "Element styles and properties",
350
+ active: designPaneOpen,
351
+ onSelect: () => handleInspectorPaneButtonClick("design"),
352
+ },
353
+ {
354
+ id: "layers",
355
+ label: "Layers",
356
+ tooltip: "Composition layer stack",
357
+ active: layersPaneOpen,
358
+ onSelect: () => handleInspectorPaneButtonClick("layers"),
359
+ },
360
+ {
361
+ id: "renders",
362
+ label: renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders",
363
+ tooltip: "Render queue and exports",
364
+ active: rightPanelTab === "renders",
365
+ onSelect: () => setRightPanelTab("renders"),
366
+ },
367
+ ...(isSlideshowComposition
368
+ ? [
369
+ {
370
+ id: "slideshow",
371
+ label: "Slideshow",
372
+ tooltip: "Slideshow branching editor",
373
+ active: rightPanelTab === "slideshow",
374
+ onSelect: () => setRightPanelTab("slideshow"),
375
+ },
376
+ ]
377
+ : []),
378
+ {
379
+ id: "variables",
380
+ label: "Variables",
381
+ tooltip: "Template variables — declare, preview with values",
382
+ active: rightPanelTab === "variables",
383
+ onSelect: () => setRightPanelTab("variables"),
384
+ },
385
+ ];
386
+
342
387
  return (
343
388
  <>
344
389
  {/* Vertical resize divider: 3px visible seam, 13px hit zone via the inner div. */}
@@ -347,7 +392,7 @@ export function StudioRightPanel({
347
392
  aria-label="Resize inspector panel"
348
393
  aria-orientation="vertical"
349
394
  tabIndex={0}
350
- className="group relative w-[3px] flex-shrink-0 cursor-col-resize outline-none focus-visible:bg-studio-accent/20"
395
+ className="group relative w-[3px] shrink-0 cursor-col-resize outline-hidden focus-visible:bg-studio-accent/20"
351
396
  style={{ touchAction: "none" }}
352
397
  onPointerDown={(e) => handlePanelResizeStart("right", e)}
353
398
  onPointerMove={handlePanelResizeMove}
@@ -364,52 +409,19 @@ export function StudioRightPanel({
364
409
  {/* Asymmetric hit zone: 8px into the preview's p-2 gutter (the only dead
365
410
  space), the 3px seam, 2px into the card. Stops short of the 24px WCAG
366
411
  2.5.8 target because the next pixel each way is live. */}
367
- <div className="absolute inset-y-0 -left-[8px] w-[13px]" />
412
+ <div className="absolute inset-y-0 left-[-8px] w-[13px]" />
368
413
  {/* Visible hairline */}
369
414
  <div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
370
415
  </div>
371
416
  <div
372
- className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-800 bg-neutral-950"
417
+ className="flex min-w-0 shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-800 bg-neutral-950"
373
418
  style={{ width: rightWidth }}
374
419
  >
375
420
  {captionEditMode ? (
376
421
  <CaptionPropertyPanel iframeRef={previewIframeRef} />
377
422
  ) : (
378
423
  <>
379
- <div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
380
- <PanelTabButton
381
- label="Design"
382
- tooltip="Element styles and properties"
383
- active={designPaneOpen}
384
- onClick={() => handleInspectorPaneButtonClick("design")}
385
- />
386
- <PanelTabButton
387
- label="Layers"
388
- tooltip="Composition layer stack"
389
- active={layersPaneOpen}
390
- onClick={() => handleInspectorPaneButtonClick("layers")}
391
- />
392
- <PanelTabButton
393
- label={renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
394
- tooltip="Render queue and exports"
395
- active={rightPanelTab === "renders"}
396
- onClick={() => setRightPanelTab("renders")}
397
- />
398
- {isSlideshowComposition && (
399
- <PanelTabButton
400
- label="Slideshow"
401
- tooltip="Slideshow branching editor"
402
- active={rightPanelTab === "slideshow"}
403
- onClick={() => setRightPanelTab("slideshow")}
404
- />
405
- )}
406
- <PanelTabButton
407
- label="Variables"
408
- tooltip="Template variables — declare, preview with values"
409
- active={rightPanelTab === "variables"}
410
- onClick={() => setRightPanelTab("variables")}
411
- />
412
- </div>
424
+ <RightPanelTabs tabs={inspectorTabs} activateOnFocus={STUDIO_FLAT_INSPECTOR_ENABLED} />
413
425
  <div className="min-h-0 min-w-0 flex-1 overflow-hidden">
414
426
  {rightPanelTab === "block-params" && activeBlockParams ? (
415
427
  <BlockParamsPanel
@@ -444,7 +456,7 @@ export function StudioRightPanel({
444
456
  role="separator"
445
457
  aria-label="Resize Layers and Design panes"
446
458
  aria-orientation="horizontal"
447
- className="group flex h-2 flex-shrink-0 cursor-row-resize items-center justify-center border-y border-neutral-800 bg-neutral-900"
459
+ className="group flex h-2 shrink-0 cursor-row-resize items-center justify-center border-y border-neutral-800 bg-neutral-900"
448
460
  style={{ touchAction: "none" }}
449
461
  onPointerDown={handleInspectorSplitResizeStart}
450
462
  onPointerMove={handleInspectorSplitResizeMove}
@@ -30,7 +30,7 @@ export function StudioToast({ message, tone, leaving, onDismiss }: StudioToastPr
30
30
  }}
31
31
  >
32
32
  <span
33
- className={`min-w-0 break-words leading-5 ${isError ? "text-red-200" : "text-neutral-200"}`}
33
+ className={`min-w-0 wrap-break-word leading-5 ${isError ? "text-red-200" : "text-neutral-200"}`}
34
34
  >
35
35
  {message}
36
36
  </span>
@@ -38,7 +38,7 @@ export function StudioToast({ message, tone, leaving, onDismiss }: StudioToastPr
38
38
  <button
39
39
  type="button"
40
40
  onClick={onDismiss}
41
- className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-300"
41
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-300"
42
42
  aria-label="Dismiss"
43
43
  >
44
44
  <svg
@@ -0,0 +1,117 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
5
+ import { cleanupMounted, mountHost } from "./ui/mountHost.testHelpers";
6
+ import { isTypingTarget } from "../utils/typingTarget";
7
+ import { shouldIgnorePlaybackShortcutTarget } from "../player/lib/playbackShortcuts";
8
+
9
+ const editHistory = {
10
+ canUndo: false,
11
+ canRedo: false,
12
+ undoLabel: undefined as string | undefined,
13
+ redoLabel: undefined as string | undefined,
14
+ };
15
+ const handleUndo = vi.fn();
16
+ const handleRedo = vi.fn();
17
+ const trackStudioEvent = vi.fn();
18
+
19
+ vi.mock("../contexts/StudioContext", () => ({
20
+ useStudioShellContext: () => ({ editHistory, handleUndo, handleRedo }),
21
+ }));
22
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent }));
23
+
24
+ const { TimelineHistoryButtons } = await import("./TimelineHistoryButtons");
25
+ const { TimelineToolbar } = await import("./TimelineToolbar");
26
+
27
+ beforeEach(() => {
28
+ Object.assign(editHistory, {
29
+ canUndo: false,
30
+ canRedo: false,
31
+ undoLabel: undefined,
32
+ redoLabel: undefined,
33
+ });
34
+ vi.clearAllMocks();
35
+ });
36
+
37
+ afterEach(cleanupMounted);
38
+
39
+ const mount = mountHost;
40
+
41
+ function button(host: HTMLElement, label: string): HTMLButtonElement {
42
+ const el = host.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
43
+ if (!el) throw new Error(`not rendered: ${label}`);
44
+ return el;
45
+ }
46
+
47
+ function click(el: HTMLElement): void {
48
+ act(() => {
49
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
50
+ });
51
+ }
52
+
53
+ it("calls handleUndo once and tracks the event when Undo is enabled", () => {
54
+ editHistory.canUndo = true;
55
+ const host = mount(<TimelineHistoryButtons />);
56
+
57
+ click(button(host, "Undo"));
58
+
59
+ expect(handleUndo).toHaveBeenCalledTimes(1);
60
+ expect(handleRedo).not.toHaveBeenCalled();
61
+ expect(trackStudioEvent).toHaveBeenCalledWith("toolbar_action", { action: "undo" });
62
+ });
63
+
64
+ it("calls handleRedo once and tracks the event when Redo is enabled", () => {
65
+ editHistory.canRedo = true;
66
+ const host = mount(<TimelineHistoryButtons />);
67
+
68
+ click(button(host, "Redo"));
69
+
70
+ expect(handleRedo).toHaveBeenCalledTimes(1);
71
+ expect(trackStudioEvent).toHaveBeenCalledWith("toolbar_action", { action: "redo" });
72
+ });
73
+
74
+ it("disables both buttons and ignores clicks on an empty history", () => {
75
+ const host = mount(<TimelineHistoryButtons />);
76
+
77
+ for (const label of ["Undo", "Redo"]) {
78
+ const el = button(host, label);
79
+ expect(el.disabled).toBe(true);
80
+ expect(el.className).toContain("cursor-not-allowed");
81
+ click(el);
82
+ }
83
+ expect(handleUndo).not.toHaveBeenCalled();
84
+ expect(handleRedo).not.toHaveBeenCalled();
85
+ expect(trackStudioEvent).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it("leaves enabled buttons without the disabled attribute", () => {
89
+ editHistory.canUndo = true;
90
+ const el = button(mount(<TimelineHistoryButtons />), "Undo");
91
+
92
+ expect(el.disabled).toBe(false);
93
+ expect(el.className).not.toContain("cursor-not-allowed");
94
+ });
95
+
96
+ it("orders the toolbar Undo, Redo, Select, Razor", () => {
97
+ const host = mount(<TimelineToolbar />);
98
+ const labels = Array.from(host.querySelectorAll("button"))
99
+ .map((b) => b.getAttribute("aria-label"))
100
+ .filter((l) => l !== null)
101
+ .slice(0, 4);
102
+
103
+ expect(labels).toEqual(["Undo", "Redo", "Selection tool", "Razor tool"]);
104
+ });
105
+
106
+ it("classifies Undo and Redo for the hotkey filters at their new location (KTD13)", () => {
107
+ // The header's own version of this check dropped Undo/Redo when they moved
108
+ // here; a <button> is never a typing target and is always claimed by the
109
+ // playback filter, same as every other toolbar tool.
110
+ const host = mount(<TimelineHistoryButtons />);
111
+
112
+ for (const label of ["Undo", "Redo"]) {
113
+ const el = button(host, label);
114
+ expect(isTypingTarget(el), label).toBe(false);
115
+ expect(shouldIgnorePlaybackShortcutTarget(el), label).toBe(true);
116
+ }
117
+ });
@@ -0,0 +1,54 @@
1
+ import { useStudioShellContext } from "../contexts/StudioContext";
2
+ import { RotateCcw, RotateCw } from "../icons/SystemIcons";
3
+ import { trackStudioEvent } from "../utils/studioTelemetry";
4
+ import { historyTooltipLabel } from "../utils/studioHelpers";
5
+ import { flatDisabled, flatIdle } from "./timelineToolbarStyles";
6
+ import { Tooltip } from "./ui";
7
+
8
+ interface HistoryButtonProps {
9
+ action: "undo" | "redo";
10
+ enabled: boolean;
11
+ label: string | undefined;
12
+ onClick: () => Promise<void> | void;
13
+ }
14
+
15
+ function HistoryButton({ action, enabled, label, onClick }: HistoryButtonProps) {
16
+ const Icon = action === "undo" ? RotateCcw : RotateCw;
17
+ return (
18
+ <Tooltip label={historyTooltipLabel(action, label)}>
19
+ <button
20
+ type="button"
21
+ aria-label={action === "undo" ? "Undo" : "Redo"}
22
+ disabled={!enabled}
23
+ className={enabled ? flatIdle : flatDisabled}
24
+ onClick={() => {
25
+ trackStudioEvent("toolbar_action", { action });
26
+ void onClick();
27
+ }}
28
+ >
29
+ <Icon size={16} />
30
+ </button>
31
+ </Tooltip>
32
+ );
33
+ }
34
+
35
+ /** Undo and Redo, on the one edit-history path the shell already owns. */
36
+ export function TimelineHistoryButtons() {
37
+ const { editHistory, handleUndo, handleRedo } = useStudioShellContext();
38
+ return (
39
+ <>
40
+ <HistoryButton
41
+ action="undo"
42
+ enabled={editHistory.canUndo}
43
+ label={editHistory.undoLabel}
44
+ onClick={handleUndo}
45
+ />
46
+ <HistoryButton
47
+ action="redo"
48
+ enabled={editHistory.canRedo}
49
+ label={editHistory.redoLabel}
50
+ onClick={handleRedo}
51
+ />
52
+ </>
53
+ );
54
+ }
@@ -8,6 +8,14 @@ import { usePlayerStore } from "../player/store/playerStore";
8
8
  import { makeSelection } from "../hooks/domSelectionTestHarness";
9
9
  import { TimelineToolbar } from "./TimelineToolbar";
10
10
 
11
+ vi.mock("../contexts/StudioContext", () => ({
12
+ useStudioShellContext: () => ({
13
+ editHistory: { canUndo: false, canRedo: false },
14
+ handleUndo: vi.fn(),
15
+ handleRedo: vi.fn(),
16
+ }),
17
+ }));
18
+
11
19
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
20
 
13
21
  afterEach(() => {
@@ -22,6 +22,8 @@ import {
22
22
  import { useTimelineZoom } from "../player/components/useTimelineZoom";
23
23
  import { usePlayerStore, type TimelineElement } from "../player";
24
24
  import { Tooltip } from "./ui";
25
+ import { flatActive, flatBtn, flatDisabled, flatIdle } from "./timelineToolbarStyles";
26
+ import { TimelineHistoryButtons } from "./TimelineHistoryButtons";
25
27
  import { Scissors } from "../icons/SystemIcons";
26
28
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
27
29
  import type { DomEditSelection } from "./editor/domEditingTypes";
@@ -186,19 +188,13 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
186
188
  return () => window.removeEventListener("keydown", onKeyDown);
187
189
  }, []);
188
190
 
189
- // CapCut-flat icon buttons: no per-button border/box chrome — a transparent
190
- // 28px hit area with a subtle rounded hover wash, consistent 16px glyphs.
191
- const flatBtn = "flex h-7 w-7 items-center justify-center rounded-md transition-colors";
192
- const flatIdle = `${flatBtn} text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200 active:scale-[0.98]`;
193
- const flatActive = `${flatBtn} bg-white/[0.08] text-neutral-100 active:scale-[0.98]`;
194
- const flatDisabled = `${flatBtn} text-neutral-700 cursor-not-allowed`;
195
-
196
191
  return (
197
192
  // The "TIMELINE" label is dropped for CapCut-like density — the pane's
198
193
  // position (tracks right below) makes it self-evident.
199
194
  <div className="border-b border-neutral-800/60">
200
195
  <div className="flex items-center justify-between px-2 py-0.5">
201
196
  <div className="flex items-center gap-0.5">
197
+ <TimelineHistoryButtons />
202
198
  <Tooltip label="Selection tool (V)">
203
199
  <button
204
200
  type="button"
@@ -297,7 +293,7 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
297
293
  className={
298
294
  !onToggleKeyframe
299
295
  ? flatDisabled
300
- : `${flatBtn} active:scale-[0.98] hover:bg-white/[0.06] ${
296
+ : `${flatBtn} active:scale-[0.98] hover:bg-white/6 ${
301
297
  keyframeState === "active"
302
298
  ? "text-studio-accent"
303
299
  : keyframeState === "inactive"
@@ -332,7 +328,7 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
332
328
  onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
333
329
  aria-label="Auto-record manual edits as keyframes"
334
330
  aria-pressed={autoKeyframeEnabled}
335
- className={`${flatBtn} active:scale-[0.98] hover:bg-white/[0.06] ${
331
+ className={`${flatBtn} active:scale-[0.98] hover:bg-white/6 ${
336
332
  autoKeyframeEnabled
337
333
  ? "text-red-400 hover:text-red-300"
338
334
  : "text-neutral-600 hover:text-neutral-400"
@@ -429,7 +425,7 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
429
425
  }}
430
426
  className={
431
427
  canAdd
432
- ? `${flatBtn} text-neutral-400 hover:bg-white/[0.06] hover:text-[#22c55e] active:scale-[0.98]`
428
+ ? `${flatBtn} text-neutral-400 hover:bg-white/6 hover:text-[#22c55e] active:scale-[0.98]`
433
429
  : flatDisabled
434
430
  }
435
431
  >
@@ -467,7 +463,7 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
467
463
  className={`h-7 px-2 rounded-md text-[11px] font-medium transition-colors ${
468
464
  thumbnailsVisible
469
465
  ? "bg-studio-accent/10 text-studio-accent"
470
- : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"
466
+ : "text-neutral-400 hover:bg-white/6 hover:text-neutral-200"
471
467
  }`}
472
468
  >
473
469
  <Image size={16} aria-hidden="true" />
@@ -480,7 +476,7 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
480
476
  className={`h-7 px-2 rounded-md text-[11px] font-medium transition-colors ${
481
477
  zoomMode === "fit"
482
478
  ? "bg-studio-accent/10 text-studio-accent"
483
- : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"
479
+ : "text-neutral-400 hover:bg-white/6 hover:text-neutral-200"
484
480
  }`}
485
481
  >
486
482
  Fit
@@ -0,0 +1,260 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, type ComponentProps } from "react";
4
+ import type { DockviewApi } from "dockview-react";
5
+ import { createRoot, type Root } from "react-dom/client";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import * as dockLayout from "./dockLayout";
8
+ import { Dock } from "./Dock";
9
+ import { parseDockLayout } from "./dockLayoutSchema";
10
+ import { useDockLayoutStore } from "./dockLayoutStore";
11
+ import { PANEL_IDS } from "./panelRegistry";
12
+ import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
13
+
14
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ let dockApi: DockviewApi | null = null;
17
+ vi.mock("dockview-react", async (importOriginal) => {
18
+ const actual = await importOriginal<typeof import("dockview-react")>();
19
+ return {
20
+ ...actual,
21
+ DockviewReact: (props: ComponentProps<typeof actual.DockviewReact>) =>
22
+ React.createElement(actual.DockviewReact, {
23
+ ...props,
24
+ onReady: (event) => {
25
+ dockApi = event.api;
26
+ props.onReady(event);
27
+ },
28
+ }),
29
+ };
30
+ });
31
+
32
+ const liveObservers = new Set<{ callback: () => void; target?: Element }>();
33
+ class ResizeObserverStub {
34
+ private readonly entry: { callback: () => void; target?: Element };
35
+ constructor(callback: () => void) {
36
+ this.entry = { callback };
37
+ }
38
+ observe(target: Element) {
39
+ this.entry.target = target;
40
+ liveObservers.add(this.entry);
41
+ }
42
+ unobserve() {}
43
+ disconnect() {
44
+ liveObservers.delete(this.entry);
45
+ }
46
+ }
47
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub;
48
+
49
+ vi.mock("./dockLayout", async (importOriginal) => {
50
+ const actual = await importOriginal<typeof import("./dockLayout")>();
51
+ return { ...actual, applySideMinimums: vi.fn(actual.applySideMinimums) };
52
+ });
53
+ const applySideMinimums = vi.mocked(dockLayout.applySideMinimums);
54
+
55
+ let root: Root | null = null;
56
+
57
+ function mount(projectId: string | null) {
58
+ const host = document.createElement("div");
59
+ document.body.append(host);
60
+ root = createRoot(host);
61
+ act(() => {
62
+ root?.render(
63
+ <Dock.Root projectId={projectId}>
64
+ {PANEL_IDS.map((id) => (
65
+ <Dock.Panel key={id} id={id}>
66
+ <div data-testid={`content-${id}`}>{id}</div>
67
+ </Dock.Panel>
68
+ ))}
69
+ </Dock.Root>,
70
+ );
71
+ });
72
+ return host;
73
+ }
74
+
75
+ /** The persisted views of the group holding `id`, after the debounced write lands. */
76
+ function persistedGroupOf(id: string): string | undefined {
77
+ act(() => {
78
+ vi.advanceTimersByTime(1000);
79
+ });
80
+ const grid = JSON.stringify(readStudioUiPreferences(undefined, "p1").dockLayout?.grid);
81
+ const groups = [...grid.matchAll(/"views":\[([^\]]*)\]/g)].map((m) => m[1]);
82
+ return groups.find((views) => views.includes(`"${id}"`));
83
+ }
84
+
85
+ beforeEach(() => {
86
+ localStorage.clear();
87
+ vi.useFakeTimers();
88
+ });
89
+
90
+ afterEach(() => {
91
+ act(() => root?.unmount());
92
+ root = null;
93
+ document.body.innerHTML = "";
94
+ vi.useRealTimers();
95
+ });
96
+
97
+ // The default Edit layout tabs [compositions|assets|code|catalog] into one
98
+ // group and [design|layers|renders|variables] into another; dockview shows
99
+ // only the active tab's content per group. `slideshow` is never part of the
100
+ // default build — StudioRightPanels opens it itself when the file is one.
101
+ const DEFAULT_OPEN = PANEL_IDS.filter((id) => id !== "slideshow");
102
+ const DEFAULT_VISIBLE = ["preview", "timeline", "compositions", "design"];
103
+
104
+ describe("Dock on React 19", () => {
105
+ it("mounts the default layout's ten panels, showing only each group's active tab", () => {
106
+ const host = mount("p1");
107
+ for (const id of DEFAULT_VISIBLE) {
108
+ expect(host.querySelector(`[data-testid="content-${id}"]`)).not.toBeNull();
109
+ }
110
+ for (const id of DEFAULT_OPEN.filter((id) => !DEFAULT_VISIBLE.includes(id))) {
111
+ expect(host.querySelector(`[data-testid="content-${id}"]`)).toBeNull();
112
+ }
113
+ expect(host.querySelector('[data-testid="content-slideshow"]')).toBeNull();
114
+ expect(useDockLayoutStore.getState().openPanels).toEqual(new Set(DEFAULT_OPEN));
115
+ });
116
+
117
+ it("persists a layout change per project and reads it back through the schema", () => {
118
+ mount("p1");
119
+ act(() => useDockLayoutStore.getState().closePanel("renders"));
120
+ act(() => {
121
+ vi.advanceTimersByTime(400);
122
+ });
123
+ const stored = readStudioUiPreferences(undefined, "p1").dockLayout;
124
+ const expected = DEFAULT_OPEN.filter((id) => id !== "renders");
125
+ expect(Object.keys(stored?.panels ?? {}).sort()).toEqual([...expected].sort());
126
+ expect(readStudioUiPreferences(undefined, "p2").dockLayout).toBeUndefined();
127
+ });
128
+
129
+ it("closes a panel and reopens it from the store, becoming its group's visible tab", async () => {
130
+ const host = mount("p1");
131
+ act(() => useDockLayoutStore.getState().closePanel("renders"));
132
+ expect(useDockLayoutStore.getState().openPanels.has("renders")).toBe(false);
133
+ expect(host.querySelector('[data-testid="content-renders"]')).toBeNull();
134
+ // dockview's own panel-active event dispatch resolves on a microtask, one
135
+ // tick after the synchronous store call returns; `act(async ...)` is what
136
+ // actually waits for it instead of asserting against pre-flush DOM.
137
+ await act(async () => {
138
+ useDockLayoutStore.getState().togglePanel("renders");
139
+ await Promise.resolve();
140
+ });
141
+ expect(useDockLayoutStore.getState().openPanels.has("renders")).toBe(true);
142
+ expect(host.querySelector('[data-testid="content-renders"]')).not.toBeNull();
143
+ });
144
+
145
+ it("reopens a closed panel as a tab of its zone's group, not a new group", () => {
146
+ mount("p1");
147
+ act(() => useDockLayoutStore.getState().closePanel("compositions"));
148
+ act(() => useDockLayoutStore.getState().togglePanel("compositions"));
149
+ expect(persistedGroupOf("compositions")).toContain('"assets"');
150
+ });
151
+
152
+ it("reopens a side panel next to the preview when its whole column was closed", () => {
153
+ mount("p1");
154
+ const { closePanel, togglePanel } = useDockLayoutStore.getState();
155
+ for (const id of ["compositions", "assets", "code", "catalog"] as const)
156
+ act(() => closePanel(id));
157
+ act(() => togglePanel("compositions"));
158
+ expect(useDockLayoutStore.getState().openPanels.has("compositions")).toBe(true);
159
+ expect(useDockLayoutStore.getState().visiblePanels.has("compositions")).toBe(true);
160
+ });
161
+
162
+ it("reopens the timeline as its own group, never as a tab of the preview", () => {
163
+ mount("p1");
164
+ act(() => useDockLayoutStore.getState().closePanel("timeline"));
165
+ act(() => useDockLayoutStore.getState().togglePanel("timeline"));
166
+ expect(persistedGroupOf("timeline")).not.toContain('"preview"');
167
+ });
168
+
169
+ it("restores the stored layout on the next mount instead of rebuilding the default", () => {
170
+ mount("p1");
171
+ act(() => useDockLayoutStore.getState().closePanel("renders"));
172
+ act(() => {
173
+ vi.advanceTimersByTime(1000);
174
+ });
175
+ act(() => root?.unmount());
176
+ root = null;
177
+ document.body.innerHTML = "";
178
+
179
+ mount("p1");
180
+ expect(useDockLayoutStore.getState().openPanels.has("renders")).toBe(false);
181
+ });
182
+
183
+ it("falls back to the default layout when the stored one names an unknown panel", () => {
184
+ localStorage.setItem(
185
+ "hf-studio-ui-preferences:p1",
186
+ JSON.stringify({ dockLayout: { grid: {}, panels: { nope: {} } } }),
187
+ );
188
+ const host = mount("p1");
189
+ expect(host.querySelector('[data-testid="content-preview"]')).not.toBeNull();
190
+ });
191
+ });
192
+
193
+ describe("Dock wiring", () => {
194
+ it("re-applies the side minimums when a panel is dragged to another group", () => {
195
+ mount(null);
196
+ applySideMinimums.mockClear();
197
+ const preview = dockApi?.getPanel("preview");
198
+ const design = dockApi?.getPanel("design");
199
+ if (!preview || !design) throw new Error("default layout is missing panels");
200
+ act(() => design.api.moveTo({ group: preview.group }));
201
+ expect(applySideMinimums).toHaveBeenCalled();
202
+ });
203
+
204
+ it("makes the sashes keyboard-focusable separators", () => {
205
+ const host = mount(null);
206
+ const sashes = host.querySelectorAll<HTMLElement>('.dv-sash[role="separator"]');
207
+ expect(sashes.length).toBeGreaterThan(0);
208
+ for (const sash of sashes) expect(sash.tabIndex).toBe(0);
209
+ });
210
+
211
+ it("re-fits the side columns to the window width when it resizes", () => {
212
+ mount(null);
213
+ const innerWidth = window.innerWidth;
214
+ Object.defineProperty(window, "innerWidth", { value: 560, configurable: true });
215
+ try {
216
+ applySideMinimums.mockClear();
217
+ const dockObservers = [...liveObservers].filter(({ target }) =>
218
+ target?.classList.contains("hf-dock"),
219
+ );
220
+ act(() => dockObservers.forEach(({ callback }) => callback()));
221
+ expect(applySideMinimums).toHaveBeenCalledWith(expect.anything(), 560);
222
+ } finally {
223
+ Object.defineProperty(window, "innerWidth", { value: innerWidth, configurable: true });
224
+ }
225
+ });
226
+ });
227
+
228
+ describe("parseDockLayout", () => {
229
+ it("rejects shapes that are not a dock layout", () => {
230
+ expect(parseDockLayout(null)).toBeNull();
231
+ expect(parseDockLayout({ grid: {}, panels: {} })).toBeNull();
232
+ expect(
233
+ parseDockLayout({
234
+ grid: {
235
+ width: 1,
236
+ height: 1,
237
+ orientation: "HORIZONTAL",
238
+ root: { type: "leaf", data: { views: ["ghost"] } },
239
+ },
240
+ panels: { ghost: { id: "ghost", contentComponent: "panel" } },
241
+ }),
242
+ ).toBeNull();
243
+ });
244
+
245
+ const placed = (views: string[], panelIds: string[]) => ({
246
+ grid: {
247
+ width: 1,
248
+ height: 1,
249
+ orientation: "HORIZONTAL",
250
+ root: { type: "leaf", data: { views } },
251
+ },
252
+ panels: Object.fromEntries(panelIds.map((id) => [id, { id, contentComponent: "panel" }])),
253
+ });
254
+
255
+ it("rejects a view that has no panel entry and a panel that no view places", () => {
256
+ expect(parseDockLayout(placed(["preview", "design"], ["preview"]))).toBeNull();
257
+ expect(parseDockLayout(placed(["preview"], ["preview", "design"]))).toBeNull();
258
+ expect(parseDockLayout(placed(["preview", "design"], ["preview", "design"]))).not.toBeNull();
259
+ });
260
+ });