@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
@@ -0,0 +1,281 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { createPortal } from "react-dom";
11
+ import {
12
+ DockviewReact,
13
+ type DockviewApi,
14
+ type DockviewReadyEvent,
15
+ type IDockviewPanelProps,
16
+ } from "dockview-react";
17
+ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
18
+ import { installDockAccessibility } from "./dockAccessibility";
19
+ import { addRegisteredPanel, applySideMinimums, buildEditLayout } from "./dockLayout";
20
+ import { DOCK_PANEL_COMPONENT } from "./dockLayoutSchema";
21
+ import { useDockLayoutStore, type DockController, type DockSnapshot } from "./dockLayoutStore";
22
+ import {
23
+ PANEL_DEFINITIONS,
24
+ PANEL_IDS,
25
+ isPanelId,
26
+ panelsInZone,
27
+ type PanelDefinition,
28
+ type PanelId,
29
+ } from "./panelRegistry";
30
+ import "./dock.css";
31
+
32
+ const PERSIST_DEBOUNCE_MS = 250;
33
+
34
+ type Slots = Partial<Record<PanelId, HTMLElement>>;
35
+
36
+ interface SlotsContextValue {
37
+ slots: Slots;
38
+ registerSlot: (id: PanelId, element: HTMLElement | null) => void;
39
+ }
40
+
41
+ const SlotsContext = createContext<SlotsContextValue | null>(null);
42
+
43
+ function useSlots(): SlotsContextValue {
44
+ const value = useContext(SlotsContext);
45
+ if (!value) throw new Error("Dock.Panel must be rendered inside Dock.Root");
46
+ return value;
47
+ }
48
+
49
+ /** The element dockview mounts for a panel; Dock.Panel portals the real content into it. */
50
+ function PanelSlot({ api }: IDockviewPanelProps) {
51
+ const { registerSlot } = useSlots();
52
+ const id = api.id;
53
+ const ref = useCallback(
54
+ (element: HTMLDivElement | null) => {
55
+ if (isPanelId(id)) registerSlot(id, element);
56
+ },
57
+ [id, registerSlot],
58
+ );
59
+ return <div ref={ref} className="h-full w-full min-h-0 min-w-0 overflow-hidden" />;
60
+ }
61
+
62
+ const COMPONENTS = { [DOCK_PANEL_COMPONENT]: PanelSlot };
63
+
64
+ function snapshot(api: DockviewApi): DockSnapshot {
65
+ const openPanels = new Set<PanelId>();
66
+ const visiblePanels = new Set<PanelId>();
67
+ for (const panel of api.panels) {
68
+ if (!isPanelId(panel.id)) continue;
69
+ openPanels.add(panel.id);
70
+ if (panel.api.isVisible && panel.group.api.isVisible) visiblePanels.add(panel.id);
71
+ }
72
+ const active = api.activePanel?.id;
73
+ return { openPanels, visiblePanels, activePanel: isPanelId(active) ? active : null };
74
+ }
75
+
76
+ function createController(api: DockviewApi): DockController {
77
+ const open = (id: PanelId) => {
78
+ if (api.getPanel(id)) return;
79
+ const { zone, reopen } = PANEL_DEFINITIONS[id];
80
+ // Side columns are tab groups; preview and timeline are separate groups in the centre.
81
+ const sibling =
82
+ zone === "center"
83
+ ? undefined
84
+ : panelsInZone(zone).find((other) => other !== id && api.getPanel(other));
85
+ if (sibling) {
86
+ addRegisteredPanel(api, id, { referencePanel: sibling, direction: "within" });
87
+ return;
88
+ }
89
+ const hasAnchor = api.getPanel(reopen.near) !== undefined;
90
+ const position = { referencePanel: reopen.near, direction: reopen.direction };
91
+ addRegisteredPanel(api, id, hasAnchor ? position : undefined);
92
+ };
93
+ return {
94
+ open,
95
+ activate: (id) => {
96
+ open(id);
97
+ api.getPanel(id)?.api.setActive();
98
+ },
99
+ close: (id) => {
100
+ const panel = api.getPanel(id);
101
+ if (panel) api.removePanel(panel);
102
+ },
103
+ setTitle: (id, title) => api.getPanel(id)?.api.setTitle(title),
104
+ setGroupVisible: (id, visible) => api.getPanel(id)?.group.api.setVisible(visible),
105
+ reset: () => buildEditLayout(api, window.innerWidth),
106
+ };
107
+ }
108
+
109
+ function restoreOrBuild(api: DockviewApi, projectId: string | null) {
110
+ const stored = readStudioUiPreferences(undefined, projectId).dockLayout;
111
+ if (stored) {
112
+ try {
113
+ api.fromJSON(stored);
114
+ return;
115
+ } catch {
116
+ /* a layout the schema accepted but dockview cannot load: start over */
117
+ }
118
+ }
119
+ buildEditLayout(api, window.innerWidth);
120
+ }
121
+
122
+ function Root({ projectId, children }: { projectId: string | null; children: ReactNode }) {
123
+ const [slots, setSlots] = useState<Slots>({});
124
+ const registerSlot = useCallback((id: PanelId, element: HTMLElement | null) => {
125
+ setSlots((prev) => {
126
+ if (prev[id] === (element ?? undefined)) return prev;
127
+ const next = { ...prev };
128
+ if (element) next[id] = element;
129
+ else delete next[id];
130
+ return next;
131
+ });
132
+ }, []);
133
+
134
+ const disposeRef = useRef<() => void>(() => {});
135
+ useEffect(() => () => disposeRef.current(), []);
136
+
137
+ const onReady = useCallback(
138
+ ({ api }: DockviewReadyEvent) => {
139
+ disposeRef.current();
140
+ restoreOrBuild(api, projectId);
141
+ applySideMinimums(api);
142
+ const root = api.groups[0]?.element.closest<HTMLElement>(".hf-dock");
143
+ const disposeAccessibility = root ? installDockAccessibility(api, root) : () => {};
144
+ // The dock spans the window (buildEditLayout sizes against it too); its own box lags a resize.
145
+ const resizeObserver = new ResizeObserver(() => applySideMinimums(api, window.innerWidth));
146
+ if (root) resizeObserver.observe(root);
147
+ const store = useDockLayoutStore.getState();
148
+ store.attach(createController(api));
149
+ store.sync(snapshot(api));
150
+ const pending = store.takePendingActivation();
151
+ if (pending) api.getPanel(pending)?.api.setActive();
152
+
153
+ let timer: ReturnType<typeof setTimeout> | undefined;
154
+ const persist = () => {
155
+ clearTimeout(timer);
156
+ timer = setTimeout(() => {
157
+ writeStudioUiPreferences({ dockLayout: api.toJSON() }, undefined, projectId);
158
+ }, PERSIST_DEBOUNCE_MS);
159
+ };
160
+ // dockview does not fire onDidLayoutChange for add/remove/activate,
161
+ // so every event class is wired to the same sync+persist pair.
162
+ const onDockChange = () => {
163
+ useDockLayoutStore.getState().sync(snapshot(api));
164
+ persist();
165
+ };
166
+ const subscriptions = [
167
+ api.onDidAddPanel(() => {
168
+ applySideMinimums(api);
169
+ onDockChange();
170
+ }),
171
+ api.onDidMovePanel(() => {
172
+ applySideMinimums(api);
173
+ onDockChange();
174
+ }),
175
+ api.onDidRemovePanel(onDockChange),
176
+ api.onDidActivePanelChange(onDockChange),
177
+ api.onDidLayoutChange(onDockChange),
178
+ ];
179
+ disposeRef.current = () => {
180
+ clearTimeout(timer);
181
+ for (const subscription of subscriptions) subscription.dispose();
182
+ disposeAccessibility();
183
+ resizeObserver.disconnect();
184
+ useDockLayoutStore.getState().detach();
185
+ };
186
+ },
187
+ [projectId],
188
+ );
189
+
190
+ return (
191
+ <SlotsContext.Provider value={{ slots, registerSlot }}>
192
+ <DockviewReact
193
+ key={projectId ?? ""}
194
+ className="hf-dock flex-1 min-h-0"
195
+ components={COMPONENTS}
196
+ defaultRenderer="always"
197
+ disableFloatingGroups
198
+ onReady={onReady}
199
+ />
200
+ {children}
201
+ </SlotsContext.Provider>
202
+ );
203
+ }
204
+
205
+ function Panel({ id, title, children }: { id: PanelId; title?: string; children: ReactNode }) {
206
+ const element = useSlots().slots[id];
207
+ const visible = useDockLayoutStore((state) => state.visiblePanels.has(id));
208
+ const controller = useDockLayoutStore((state) => state.controller);
209
+ const label = title ?? PANEL_DEFINITIONS[id].title;
210
+ useEffect(() => {
211
+ controller?.setTitle(id, label);
212
+ }, [controller, id, label]);
213
+ const definition: PanelDefinition = PANEL_DEFINITIONS[id];
214
+ const shown = visible || definition.keepMounted;
215
+ return element && shown ? createPortal(children, element) : null;
216
+ }
217
+
218
+ function WindowMenu() {
219
+ const openPanels = useDockLayoutStore((state) => state.openPanels);
220
+ const togglePanel = useDockLayoutStore((state) => state.togglePanel);
221
+ const resetLayout = useDockLayoutStore((state) => state.resetLayout);
222
+ const [open, setOpen] = useState(false);
223
+ const rootRef = useRef<HTMLDivElement>(null);
224
+
225
+ useEffect(() => {
226
+ if (!open) return;
227
+ const close = (event: PointerEvent) => {
228
+ if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
229
+ };
230
+ document.addEventListener("pointerdown", close);
231
+ return () => document.removeEventListener("pointerdown", close);
232
+ }, [open]);
233
+
234
+ return (
235
+ <div ref={rootRef} className="relative">
236
+ <button
237
+ type="button"
238
+ aria-haspopup="menu"
239
+ aria-expanded={open}
240
+ onClick={() => setOpen((value) => !value)}
241
+ className="h-7 px-2.5 rounded-md text-[11px] font-medium text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
242
+ >
243
+ Window
244
+ </button>
245
+ {open && (
246
+ <div
247
+ role="menu"
248
+ className="absolute right-0 top-8 z-50 w-44 rounded-md border border-neutral-800 bg-neutral-900 py-1 shadow-lg"
249
+ >
250
+ {PANEL_IDS.map((id) => (
251
+ <button
252
+ key={id}
253
+ type="button"
254
+ role="menuitemcheckbox"
255
+ aria-checked={openPanels.has(id)}
256
+ onClick={() => togglePanel(id)}
257
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-neutral-300 hover:bg-neutral-800"
258
+ >
259
+ <span className="w-3 text-panel-accent">{openPanels.has(id) ? "✓" : ""}</span>
260
+ {PANEL_DEFINITIONS[id].title}
261
+ </button>
262
+ ))}
263
+ <div className="my-1 border-t border-neutral-800" />
264
+ <button
265
+ type="button"
266
+ role="menuitem"
267
+ onClick={() => {
268
+ resetLayout();
269
+ setOpen(false);
270
+ }}
271
+ className="w-full px-3 py-1.5 text-left text-[11px] text-neutral-300 hover:bg-neutral-800"
272
+ >
273
+ Reset layout
274
+ </button>
275
+ </div>
276
+ )}
277
+ </div>
278
+ );
279
+ }
280
+
281
+ export const Dock = { Root, Panel, WindowMenu };
@@ -0,0 +1,28 @@
1
+ @import "dockview-react/dist/styles/dockview.css";
2
+
3
+ .hf-dock {
4
+ --dv-group-view-background-color: var(--color-panel-bg);
5
+ --dv-tabs-and-actions-container-background-color: var(--color-panel-surface);
6
+ --dv-activegroup-visiblepanel-tab-background-color: var(--color-panel-bg);
7
+ --dv-activegroup-hiddenpanel-tab-background-color: var(--color-panel-surface);
8
+ --dv-inactivegroup-visiblepanel-tab-background-color: var(--color-panel-bg);
9
+ --dv-inactivegroup-hiddenpanel-tab-background-color: var(--color-panel-surface);
10
+ --dv-activegroup-visiblepanel-tab-color: var(--color-panel-text-1);
11
+ --dv-activegroup-hiddenpanel-tab-color: var(--color-panel-text-3);
12
+ --dv-inactivegroup-visiblepanel-tab-color: var(--color-panel-text-2);
13
+ --dv-inactivegroup-hiddenpanel-tab-color: var(--color-panel-text-4);
14
+ --dv-tab-divider-color: var(--color-panel-border);
15
+ --dv-separator-border: var(--color-panel-border);
16
+ --dv-paneview-header-border-color: var(--color-panel-border);
17
+ --dv-sash-color: var(--color-panel-hover);
18
+ --dv-active-sash-color: var(--color-panel-accent);
19
+ --dv-drag-over-background-color: color-mix(in srgb, var(--color-panel-accent) 12%, transparent);
20
+ --dv-drag-over-border-color: var(--color-panel-accent);
21
+ --dv-tab-font-size: 11px;
22
+ --dv-tabs-and-actions-container-font-size: 11px;
23
+ }
24
+
25
+ .hf-dock .dv-sash:focus-visible {
26
+ background-color: var(--dv-active-sash-color);
27
+ outline: none;
28
+ }
@@ -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
+ }