@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,524 @@
1
+ // @vitest-environment happy-dom
2
+ /** Value controls: one drag is one commit and one telemetry event; classes resolve; hotkey roles match. */
3
+ import { createRequire } from "node:module";
4
+ import { readFileSync } from "node:fs";
5
+ import path from "node:path";
6
+ import React, { act } from "react";
7
+ import { createRoot, type Root } from "react-dom/client";
8
+ import { compile } from "tailwindcss";
9
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
10
+
11
+ const trackStudioEvent = vi.fn();
12
+ vi.mock("../../utils/studioTelemetry", () => ({
13
+ trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
14
+ }));
15
+
16
+ import { Input } from "./Input";
17
+ import { NumberField } from "./NumberField";
18
+ import { Select } from "./Select";
19
+ import { Slider } from "./Slider";
20
+ import { Toggle } from "./Toggle";
21
+ import { isTypingTarget } from "../../utils/typingTarget";
22
+ import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
23
+ import { __resetDesignInputThrottle, trackDesignInput } from "../../utils/designInputTracking";
24
+
25
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
26
+
27
+ let mounted: { root: Root; host: HTMLElement } | null = null;
28
+
29
+ function render(element: React.ReactElement): HTMLElement {
30
+ const host = document.createElement("div");
31
+ document.body.append(host);
32
+ const root = createRoot(host);
33
+ mounted = { root, host };
34
+ act(() => root.render(element));
35
+ return host;
36
+ }
37
+
38
+ function unmount() {
39
+ if (!mounted) return;
40
+ const { root, host } = mounted;
41
+ mounted = null;
42
+ act(() => root.unmount());
43
+ host.remove();
44
+ }
45
+
46
+ beforeEach(() => {
47
+ trackStudioEvent.mockReset();
48
+ __resetDesignInputThrottle();
49
+ });
50
+ afterEach(unmount);
51
+
52
+ /** Base UI moves focus a task later than React renders; happy-dom is no faster. */
53
+ const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
54
+
55
+ function fire(el: Element, type: string, init: MouseEventInit & { key?: string } = {}) {
56
+ const event =
57
+ init.key === undefined
58
+ ? new MouseEvent(type, { bubbles: true, ...init })
59
+ : new KeyboardEvent(type, { bubbles: true, key: init.key });
60
+ act(() => void el.dispatchEvent(event));
61
+ }
62
+
63
+ /** Types into a React-controlled input the way the browser does. */
64
+ function type(input: HTMLInputElement, text: string) {
65
+ act(() => {
66
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.bind(
67
+ input,
68
+ );
69
+ setter(text);
70
+ input.dispatchEvent(new Event("input", { bubbles: true }));
71
+ });
72
+ }
73
+
74
+ /** One design-input telemetry call, through the real coalescing sink. */
75
+ const track = (control: string) => () =>
76
+ trackDesignInput({ ui: "flat", section: "style", control, name: "Opacity" });
77
+
78
+ const designInputCalls = () =>
79
+ trackStudioEvent.mock.calls.filter(([event]) => event === "design_input");
80
+
81
+ // -- the compiled stylesheet, built once for the whole file --
82
+
83
+ const STYLES_DIR = path.resolve(__dirname, "../../styles");
84
+ const require = createRequire(import.meta.url);
85
+ const TAILWIND_DIR = path.dirname(require.resolve("tailwindcss/package.json"));
86
+
87
+ async function loadStylesheet(id: string, base: string) {
88
+ const file = id === "tailwindcss" ? path.join(TAILWIND_DIR, "index.css") : path.resolve(base, id);
89
+ return { path: file, base: path.dirname(file), content: readFileSync(file, "utf8") };
90
+ }
91
+
92
+ /** How Tailwind escapes a candidate into a selector: `\` before each symbol. */
93
+ function asSelector(candidate: string): string {
94
+ return candidate.replace(/[^a-zA-Z0-9-]/g, (char) => `\\${char}`);
95
+ }
96
+
97
+ let compileStudioCss: (candidates: string[]) => string;
98
+
99
+ beforeAll(async () => {
100
+ const compiled = await compile(readFileSync(path.join(STYLES_DIR, "studio.css"), "utf8"), {
101
+ base: STYLES_DIR,
102
+ loadStylesheet,
103
+ });
104
+ compileStudioCss = (candidates) => compiled.build(candidates);
105
+ });
106
+
107
+ function unresolved(candidates: string[]): string[] {
108
+ const css = compileStudioCss(candidates);
109
+ return candidates.filter((candidate) => !css.includes(asSelector(candidate)));
110
+ }
111
+
112
+ /** All five, rendered together, so one sweep covers the set. */
113
+ function allFive(previewState?: "hover" | "active" | "focus") {
114
+ const noop = () => {};
115
+ return (
116
+ <>
117
+ <Input value="hello" onCommit={noop} aria-label="Name" data-preview-state={previewState} />
118
+ <NumberField
119
+ label="Size"
120
+ value={24}
121
+ unit="px"
122
+ onCommit={noop}
123
+ data-preview-state={previewState}
124
+ />
125
+ <Select
126
+ label="Weight"
127
+ value="400"
128
+ options={[
129
+ { label: "Regular", value: "400" },
130
+ { label: "Semibold", value: "600" },
131
+ ]}
132
+ onCommit={noop}
133
+ data-preview-state={previewState}
134
+ />
135
+ <Slider
136
+ label="Opacity"
137
+ value={10}
138
+ min={0}
139
+ max={100}
140
+ onCommit={noop}
141
+ data-preview-state={previewState}
142
+ />
143
+ <Toggle label="Visible" checked onCommit={noop} data-preview-state={previewState} />
144
+ </>
145
+ );
146
+ }
147
+
148
+ describe("value control classes", () => {
149
+ it("emits only classes Studio's stylesheet defines", () => {
150
+ const host = render(allFive());
151
+ const emitted = new Set<string>();
152
+ for (const el of host.querySelectorAll("*")) {
153
+ for (const cls of el.classList) emitted.add(cls);
154
+ }
155
+
156
+ expect(emitted.size).toBeGreaterThan(20);
157
+ expect(unresolved([...emitted])).toEqual([]);
158
+ });
159
+
160
+ it("fails an undefined class, so the check above is not vacuous", () => {
161
+ expect(unresolved(["bg-not-a-token", "text-step-999"])).toEqual([
162
+ "bg-not-a-token",
163
+ "text-step-999",
164
+ ]);
165
+ });
166
+
167
+ it("keeps every interactive look reachable without a pointer", () => {
168
+ // Same contract as Button: the `data-preview-state` half of each recipe
169
+ // repeats the real state's half exactly, so a gallery shot cannot drift
170
+ // away from what a pointer actually produces.
171
+ const realPrefix: Record<string, string[]> = {
172
+ hover: ["hover:"],
173
+ active: ["active:"],
174
+ focus: ["focus-visible:", "focus-within:", "has-[:focus-visible]:"],
175
+ };
176
+ const host = render(allFive());
177
+ const classes = [...host.querySelectorAll("*")].flatMap((el) => [...el.classList]);
178
+
179
+ for (const [state, prefixes] of Object.entries(realPrefix)) {
180
+ const previewPrefix = `data-[preview-state=${state}]:`;
181
+ const preview = classes
182
+ .filter((cls) => cls.startsWith(previewPrefix))
183
+ .map((cls) => cls.slice(previewPrefix.length));
184
+ const real = classes
185
+ .filter((cls) => prefixes.some((prefix) => cls.startsWith(prefix)))
186
+ .map((cls) => cls.slice(cls.lastIndexOf(":") + 1));
187
+
188
+ expect([...new Set(preview)].sort(), state).toEqual([...new Set(real)].sort());
189
+ expect(preview.length, state).toBeGreaterThan(0);
190
+ }
191
+ });
192
+
193
+ it("boxes every value control, so an input reads as an input", () => {
194
+ // R10. The three text-shaped controls carry a real border and a background
195
+ // that differs from the panel behind them.
196
+ const host = render(allFive());
197
+ const boxes = [...host.querySelectorAll(".border-border-input")];
198
+
199
+ expect(boxes.length).toBe(4); // Input, NumberField, Select trigger, Toggle
200
+ for (const box of boxes) {
201
+ expect([...box.classList]).toContain("bg-input");
202
+ }
203
+ });
204
+ });
205
+
206
+ describe("Slider", () => {
207
+ /** Gives the control a width so Base UI's pointer maths has a rect to use. */
208
+ function stubRect(el: Element, width = 100) {
209
+ el.getBoundingClientRect = () =>
210
+ ({ x: 0, y: 0, top: 0, left: 0, right: width, bottom: 10, width, height: 10 }) as DOMRect;
211
+ }
212
+
213
+ it("commits once and tracks once for a drag across five moves", () => {
214
+ const commits: number[] = [];
215
+ const previews: number[] = [];
216
+ // Counted at the wrapper as well as at the sink. The sink's 600 ms window
217
+ // would collapse a per-move `track()` into one event on its own, so
218
+ // asserting only the event count would pass even if the call moved to
219
+ // `onValueChange`. The raw count is what pins KTD11.
220
+ let trackCalls = 0;
221
+ const trackOnce = track("slider");
222
+ const host = render(
223
+ <Slider
224
+ label="Opacity"
225
+ value={10}
226
+ min={0}
227
+ max={100}
228
+ onPreview={(n) => previews.push(n)}
229
+ onCommit={(n) => commits.push(n)}
230
+ onTrack={() => {
231
+ trackCalls += 1;
232
+ trackOnce();
233
+ }}
234
+ />,
235
+ );
236
+ const control = host.querySelector("[data-slider-control]")!;
237
+ stubRect(control);
238
+
239
+ fire(control, "pointerdown", { clientX: 10, button: 0 });
240
+ // 10 to 40 over five moves. The release commits the last value a move
241
+ // delivered, not the pointerup coordinate, which is why the last move and
242
+ // the release sit at the same x.
243
+ for (const x of [15, 20, 25, 30, 40]) {
244
+ // `buttons: 1` is not decoration: Base UI reads it to tell a real drag
245
+ // from a move whose pointerup another element swallowed, and treats
246
+ // `buttons: 0` as the end of the gesture.
247
+ act(
248
+ () =>
249
+ void document.dispatchEvent(new MouseEvent("pointermove", { clientX: x, buttons: 1 })),
250
+ );
251
+ }
252
+ act(
253
+ () => void document.dispatchEvent(new MouseEvent("pointerup", { clientX: 40, buttons: 0 })),
254
+ );
255
+
256
+ expect(previews.length).toBeGreaterThan(1);
257
+ expect(commits).toEqual([40]);
258
+ expect(trackCalls).toBe(1);
259
+ expect(designInputCalls()).toHaveLength(1);
260
+ });
261
+
262
+ it("puts the value back when a drag is aborted with the right button", () => {
263
+ // KTD8. The pointer is still down: the abort has to stop Base UI applying
264
+ // any further move, not merely reset the number once.
265
+ const commits: number[] = [];
266
+ const host = render(
267
+ <Slider
268
+ label="Opacity"
269
+ value={10}
270
+ min={0}
271
+ max={100}
272
+ onCommit={(n) => commits.push(n)}
273
+ onTrack={track("slider")}
274
+ />,
275
+ );
276
+ const control = host.querySelector("[data-slider-control]")!;
277
+ stubRect(control);
278
+
279
+ fire(control, "pointerdown", { clientX: 10, button: 0 });
280
+ act(
281
+ () => void document.dispatchEvent(new MouseEvent("pointermove", { clientX: 60, buttons: 1 })),
282
+ );
283
+ fire(control, "contextmenu", { clientX: 60, button: 2 });
284
+ act(
285
+ () => void document.dispatchEvent(new MouseEvent("pointermove", { clientX: 90, buttons: 1 })),
286
+ );
287
+ act(
288
+ () => void document.dispatchEvent(new MouseEvent("pointerup", { clientX: 90, buttons: 0 })),
289
+ );
290
+
291
+ const input = host.querySelector<HTMLInputElement>('input[type="range"]')!;
292
+ expect(input.value).toBe("10");
293
+ expect(commits.at(-1) ?? 10).toBe(10);
294
+ expect(designInputCalls()).toHaveLength(0);
295
+ });
296
+
297
+ it("steps by one on ArrowRight and coalesces three fast presses into one event", () => {
298
+ const commits: number[] = [];
299
+ const host = render(
300
+ <Slider
301
+ label="Opacity"
302
+ value={10}
303
+ min={0}
304
+ max={100}
305
+ onCommit={(n) => commits.push(n)}
306
+ onTrack={track("slider")}
307
+ />,
308
+ );
309
+ const input = host.querySelector<HTMLInputElement>('input[type="range"]')!;
310
+
311
+ fire(input, "keydown", { key: "ArrowRight" });
312
+ fire(input, "keydown", { key: "ArrowRight" });
313
+ fire(input, "keydown", { key: "ArrowRight" });
314
+
315
+ expect(commits).toEqual([11, 12, 13]);
316
+ // Three commits, one event: the sink's 600 ms window is what turns a burst
317
+ // of keyboard steps into one "the user worked this control".
318
+ expect(designInputCalls()).toHaveLength(1);
319
+ });
320
+ });
321
+
322
+ describe("NumberField", () => {
323
+ it("commits a typed number on Enter and keeps the unit", () => {
324
+ const commits: number[] = [];
325
+ const host = render(
326
+ <NumberField
327
+ label="Size"
328
+ value={24}
329
+ unit="px"
330
+ onCommit={(n) => commits.push(n)}
331
+ onTrack={track("metric")}
332
+ />,
333
+ );
334
+ const input = host.querySelector("input")!;
335
+
336
+ type(input, "48");
337
+ expect(commits).toEqual([]); // a keystroke is not a commit
338
+
339
+ fire(input, "keydown", { key: "Enter" });
340
+
341
+ expect(commits).toEqual([48]);
342
+ expect(designInputCalls()).toHaveLength(1);
343
+ expect(host.textContent).toContain("px");
344
+ });
345
+
346
+ it("shows the error state for text it cannot parse, and commits nothing", () => {
347
+ // Not "abc": Base UI filters a non-numeral character out of the input
348
+ // before the text ever reaches this component, so that case is unreachable
349
+ // rather than untested. A lone minus sign is the reachable one. It is a
350
+ // legal character, it survives into the field, and it is not a number.
351
+ const commits: number[] = [];
352
+ const host = render(
353
+ <NumberField label="Size" value={24} unit="px" onCommit={(n) => commits.push(n)} />,
354
+ );
355
+ const input = host.querySelector("input")!;
356
+
357
+ type(input, "abc");
358
+ expect(input.value).toBe("24"); // rejected at the keystroke, nothing to commit
359
+
360
+ type(input, "-");
361
+ fire(input, "keydown", { key: "Enter" });
362
+
363
+ expect(commits).toEqual([]);
364
+ expect(input.getAttribute("aria-invalid")).toBe("true");
365
+ expect(host.textContent).toContain("px");
366
+ });
367
+
368
+ it("treats an emptied field as an error rather than as zero", () => {
369
+ const commits: number[] = [];
370
+ const host = render(
371
+ <NumberField label="Size" value={24} unit="px" onCommit={(n) => commits.push(n)} />,
372
+ );
373
+ const input = host.querySelector("input")!;
374
+
375
+ type(input, "");
376
+ fire(input, "keydown", { key: "Enter" });
377
+
378
+ expect(commits).toEqual([]);
379
+ expect(input.getAttribute("aria-invalid")).toBe("true");
380
+ });
381
+ });
382
+
383
+ describe("Input", () => {
384
+ it("commits a typed value on Enter, once, and not per keystroke", () => {
385
+ const commits: string[] = [];
386
+ const host = render(
387
+ <Input
388
+ value="hello"
389
+ aria-label="Name"
390
+ onCommit={(v) => commits.push(v)}
391
+ onTrack={track("text")}
392
+ />,
393
+ );
394
+ const input = host.querySelector("input")!;
395
+
396
+ type(input, "world");
397
+ expect(commits).toEqual([]);
398
+
399
+ fire(input, "keydown", { key: "Enter" });
400
+
401
+ expect(commits).toEqual(["world"]);
402
+ expect(designInputCalls()).toHaveLength(1);
403
+ });
404
+
405
+ it("abandons the draft on Escape", () => {
406
+ const commits: string[] = [];
407
+ const host = render(
408
+ <Input value="hello" aria-label="Name" onCommit={(v) => commits.push(v)} />,
409
+ );
410
+ const input = host.querySelector("input")!;
411
+
412
+ type(input, "world");
413
+ fire(input, "keydown", { key: "Escape" });
414
+
415
+ expect(input.value).toBe("hello");
416
+ expect(commits).toEqual([]);
417
+ });
418
+ });
419
+
420
+ describe("Select", () => {
421
+ it("opens with Space, moves the highlight, and commits on Enter", async () => {
422
+ const commits: string[] = [];
423
+ const host = render(
424
+ <Select
425
+ label="Weight"
426
+ value="400"
427
+ options={[
428
+ { label: "Regular", value: "400" },
429
+ { label: "Semibold", value: "600" },
430
+ ]}
431
+ onCommit={(v) => commits.push(v)}
432
+ onTrack={track("select")}
433
+ />,
434
+ );
435
+ const trigger = host.querySelector('[role="combobox"]')!;
436
+
437
+ // The trigger is a real `<button>`, so Space reaches it as keydown, keyup
438
+ // and then a click the browser synthesises. happy-dom does not synthesise
439
+ // that click, so the test dispatches the sequence a browser produces.
440
+ fire(trigger, "keydown", { key: " " });
441
+ fire(trigger, "keyup", { key: " " });
442
+ act(() => (trigger as HTMLElement).click());
443
+ await settle();
444
+ const options = [...document.querySelectorAll('[role="option"]')];
445
+ expect(options).toHaveLength(2);
446
+
447
+ fire(document.activeElement ?? document.body, "keydown", { key: "ArrowDown" });
448
+ await settle();
449
+ expect(document.querySelector("[data-highlighted]")?.textContent).toBe("Semibold");
450
+
451
+ fire(document.activeElement ?? document.body, "keydown", { key: "Enter" });
452
+ await settle();
453
+
454
+ expect(commits).toEqual(["600"]);
455
+ expect(designInputCalls()).toHaveLength(1);
456
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
457
+ });
458
+ });
459
+
460
+ describe("Toggle", () => {
461
+ it("flips on click and on Space, and reports aria-checked", () => {
462
+ const flips: boolean[] = [];
463
+ const host = render(
464
+ <Toggle
465
+ label="Visible"
466
+ checked={false}
467
+ onCommit={(v) => flips.push(v)}
468
+ onTrack={track("toggle")}
469
+ />,
470
+ );
471
+ const control = host.querySelector('[role="switch"]') as HTMLElement;
472
+
473
+ expect(control.getAttribute("aria-checked")).toBe("false");
474
+
475
+ act(() => control.click());
476
+ fire(control, "keydown", { key: " " });
477
+ fire(control, "keyup", { key: " " });
478
+
479
+ expect(flips).toEqual([true, true]);
480
+ expect(designInputCalls()).toHaveLength(1); // coalesced, same control
481
+ });
482
+ });
483
+
484
+ describe("hotkey classification (KTD13)", () => {
485
+ it("classifies each control the way the native input it replaces was", async () => {
486
+ // Native reference on the left, primitive on the right. Both selector lists
487
+ // have to agree on each pair, or a global shortcut starts behaving
488
+ // differently after a consumer migrates.
489
+ const host = render(
490
+ <>
491
+ <input type="text" data-testid="native-text" />
492
+ <input type="range" data-testid="native-range" />
493
+ <select data-testid="native-select">
494
+ <option>a</option>
495
+ </select>
496
+ <input type="checkbox" data-testid="native-checkbox" />
497
+ {allFive()}
498
+ </>,
499
+ );
500
+ await settle();
501
+
502
+ const native = (id: string) => host.querySelector(`[data-testid="${id}"]`)!;
503
+ const pairs: Array<[string, Element]> = [
504
+ ["native-text", host.querySelectorAll("input[type='text']")[1]!],
505
+ ["native-range", host.querySelector("input[type='range']")!],
506
+ ["native-select", host.querySelector('[role="combobox"]')!],
507
+ ["native-checkbox", host.querySelector('[role="switch"]')!],
508
+ ];
509
+
510
+ for (const [nativeId, primitive] of pairs) {
511
+ expect(isTypingTarget(primitive), `${nativeId}: typing target`).toBe(
512
+ isTypingTarget(native(nativeId)),
513
+ );
514
+ expect(shouldIgnorePlaybackShortcutTarget(primitive), `${nativeId}: playback`).toBe(
515
+ shouldIgnorePlaybackShortcutTarget(native(nativeId)),
516
+ );
517
+ }
518
+
519
+ // The slider thumb in particular: arrow keys must step the slider rather
520
+ // than nudge the canvas selection, which only holds while its input counts
521
+ // as a typing target.
522
+ expect(isTypingTarget(host.querySelector("input[type='range']"))).toBe(true);
523
+ });
524
+ });
@@ -3,7 +3,6 @@ import { usePlayerStore } from "../player";
3
3
  import type { TimelineElement } from "../player";
4
4
  import type { DomEditSelection } from "../components/editor/domEditing";
5
5
  import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
6
- import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
7
6
  import { isTypingTarget } from "../utils/typingTarget";
8
7
  import { useCaptionStore } from "../captions/store";
9
8
  import {
@@ -11,13 +10,17 @@ import {
11
10
  isCaptionPreviewVisible,
12
11
  } from "../captions/components/CaptionOverlayUtils";
13
12
  import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
14
- import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
15
13
  import {
16
14
  type HotkeyCallbacks,
17
15
  dispatchModifierKey,
18
16
  dispatchPlainKey,
19
17
  handleUndoRedoKey,
20
18
  } from "./appHotkeysDispatch";
19
+ import {
20
+ useEditHistoryActions,
21
+ type EditHistoryHandle,
22
+ type UseEditHistoryActionsOptions,
23
+ } from "./useEditHistoryActions";
21
24
 
22
25
  function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
23
26
  try {
@@ -69,28 +72,6 @@ function tryApplyBeatHistory(
69
72
 
70
73
  // ── Types ──
71
74
 
72
- interface HistoryResult {
73
- ok: boolean;
74
- reason?: string;
75
- label?: string;
76
- paths?: string[];
77
- /** Per-file restored/previous content, used to soft-apply the preview. */
78
- files?: Record<string, { previous: string; restored: string }>;
79
- }
80
- interface HistoryFileCallbacks {
81
- readFile: (path: string) => Promise<string>;
82
- writeFile: (path: string, content: string) => Promise<void>;
83
- serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
84
- }
85
- interface EditHistoryHandle {
86
- undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
87
- redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
88
- state: {
89
- undo: ReadonlyArray<{ createdAt: number }>;
90
- redo: ReadonlyArray<{ createdAt: number }>;
91
- };
92
- }
93
-
94
75
  interface UseAppHotkeysParams {
95
76
  handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
96
77
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
@@ -105,10 +86,7 @@ interface UseAppHotkeysParams {
105
86
  readProjectFile: (path: string) => Promise<string>;
106
87
  writeProjectFile: (path: string, content: string) => Promise<void>;
107
88
  showToast: (message: string, tone?: "error" | "info") => void;
108
- syncHistoryPreviewAfterApply: (restore: {
109
- paths?: string[];
110
- files?: Record<string, { previous: string; restored: string }>;
111
- }) => Promise<void>;
89
+ syncHistoryPreviewAfterApply: UseEditHistoryActionsOptions["syncHistoryPreviewAfterApply"];
112
90
  waitForPendingDomEditSaves: () => Promise<void>;
113
91
  leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
114
92
  handleCopy: () => boolean;
@@ -165,22 +143,18 @@ export function useAppHotkeys({
165
143
 
166
144
  // ── Undo / Redo ──
167
145
 
168
- const readHistoryFile = useCallback(
169
- (path: string): Promise<string> =>
170
- path === STUDIO_MOTION_PATH ? readOptionalProjectFile(path) : readProjectFile(path),
171
- [readOptionalProjectFile, readProjectFile],
172
- );
173
- const writeHistoryFile = useCallback(
174
- async (path: string, content: string): Promise<void> => {
175
- await writeProjectFile(path, content);
176
- },
177
- [writeProjectFile],
178
- );
179
- const serializeHistoryFiles = useCallback(
180
- <T>(paths: readonly string[], task: () => Promise<T>) =>
181
- serializeStudioFileMutations(writeProjectFile, paths, task),
182
- [writeProjectFile],
183
- );
146
+ const fileHistory = useEditHistoryActions({
147
+ editHistory,
148
+ readOptionalProjectFile,
149
+ readProjectFile,
150
+ writeProjectFile,
151
+ showToast,
152
+ syncHistoryPreviewAfterApply,
153
+ waitForPendingDomEditSaves,
154
+ onAfterUndoRedo,
155
+ activeCompPath,
156
+ forceReloadSdkSession,
157
+ });
184
158
 
185
159
  const applyHistory = useCallback(
186
160
  async (direction: "undo" | "redo") => {
@@ -189,7 +163,7 @@ export function useAppHotkeys({
189
163
  // earlier file edit (which would ALSO leave the caption change intact).
190
164
  const captionState = useCaptionStore.getState();
191
165
  // Only when the caption preview is actually visible: isEditMode stays
192
- // true while the preview is hidden (storyboard view), and eating ⌘Z
166
+ // true while the preview is hidden, and eating ⌘Z
193
167
  // there would pop invisible caption edits instead of file history.
194
168
  if (captionState.isEditMode && isCaptionPreviewVisible()) {
195
169
  const restored = direction === "undo" ? captionState.undo() : captionState.redo();
@@ -204,42 +178,9 @@ export function useAppHotkeys({
204
178
  // Beat edits interleave with file history by timestamp; handle them first.
205
179
  if (tryApplyBeatHistory(direction, editHistory.state, showToast)) return;
206
180
 
207
- await waitForPendingDomEditSaves();
208
- const result = await editHistory[direction]({
209
- readFile: readHistoryFile,
210
- writeFile: writeHistoryFile,
211
- serialize: serializeHistoryFiles,
212
- });
213
- if (!result.ok && result.reason === "content-mismatch") {
214
- showToast(
215
- `File changed outside Studio. ${direction === "undo" ? "Undo" : "Redo"} history was not applied.`,
216
- "info",
217
- );
218
- return;
219
- }
220
- if (result.ok && result.label) {
221
- onAfterUndoRedo?.();
222
- // If the active composition was among the written files, force-reload
223
- // the SDK session so its in-memory doc matches the reverted content.
224
- if (activeCompPath && result.paths?.includes(activeCompPath)) {
225
- forceReloadSdkSession?.();
226
- }
227
- await syncHistoryPreviewAfterApply({ paths: result.paths, files: result.files });
228
- showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info");
229
- }
181
+ await fileHistory[direction]();
230
182
  },
231
- [
232
- editHistory,
233
- readHistoryFile,
234
- showToast,
235
- syncHistoryPreviewAfterApply,
236
- waitForPendingDomEditSaves,
237
- writeHistoryFile,
238
- serializeHistoryFiles,
239
- onAfterUndoRedo,
240
- activeCompPath,
241
- forceReloadSdkSession,
242
- ],
183
+ [editHistory.state, fileHistory, showToast],
243
184
  );
244
185
 
245
186
  const handleUndo = useCallback(() => applyHistory("undo"), [applyHistory]);