@hyperframes/studio 0.8.51 → 0.8.53-desktop.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/assets/{hyperframes-player-Cn_PpHqP.js → hyperframes-player-PTEnAHhg.js} +1 -1
  2. package/dist/assets/{index-CPS3Cvj8.js → index--myXp8ui.js} +1 -1
  3. package/dist/assets/{index-Clrb11lj.js → index-BjslOS9g.js} +1 -1
  4. package/dist/assets/index-BnXZuIJD.css +1 -0
  5. package/dist/assets/index-CNyviK_l.js +390 -0
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +808 -12
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +8109 -7583
  11. package/dist/index.js.map +1 -1
  12. package/package.json +9 -7
  13. package/src/App.tsx +45 -50
  14. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  15. package/src/captions/components/CaptionOverlay.tsx +8 -4
  16. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  17. package/src/components/EditorShell.tsx +56 -56
  18. package/src/components/RightPanelTabs.test.tsx +151 -0
  19. package/src/components/RightPanelTabs.tsx +59 -0
  20. package/src/components/StudioHeader.dom.test.tsx +181 -0
  21. package/src/components/StudioHeader.test.ts +0 -12
  22. package/src/components/StudioHeader.tsx +117 -160
  23. package/src/components/StudioLeftPanels.tsx +202 -0
  24. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -194
  25. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  26. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  27. package/src/components/TimelineHistoryButtons.tsx +54 -0
  28. package/src/components/TimelineToolbar.test.tsx +8 -0
  29. package/src/components/TimelineToolbar.tsx +3 -7
  30. package/src/components/dock/Dock.test.tsx +100 -18
  31. package/src/components/dock/Dock.tsx +32 -5
  32. package/src/components/dock/dock.css +5 -0
  33. package/src/components/dock/dockAccessibility.test.ts +166 -0
  34. package/src/components/dock/dockAccessibility.ts +98 -0
  35. package/src/components/dock/dockLayout.test.ts +134 -0
  36. package/src/components/dock/dockLayout.ts +53 -6
  37. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  38. package/src/components/dock/dockLayoutStore.ts +21 -4
  39. package/src/components/dock/dockTestHarness.ts +11 -0
  40. package/src/components/editor/LayersPanel.test.ts +97 -1
  41. package/src/components/editor/LayersPanel.tsx +4 -2
  42. package/src/components/editor/PreviewGuides.tsx +1 -1
  43. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  44. package/src/components/editor/TopologyLens.test.tsx +28 -0
  45. package/src/components/editor/TopologyLens.tsx +3 -1
  46. package/src/components/editor/useColorGradingController.test.ts +38 -0
  47. package/src/components/editor/useColorGradingController.ts +9 -1
  48. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  49. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  50. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  51. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  52. package/src/components/nle/NLEContext.tsx +0 -43
  53. package/src/components/nle/NLEPreview.tsx +1 -1
  54. package/src/components/nle/PreviewPane.tsx +1 -1
  55. package/src/components/nle/TimelinePane.tsx +53 -67
  56. package/src/components/panels/SlideshowPanel.tsx +1 -1
  57. package/src/components/panels/VariablesPanel.tsx +1 -1
  58. package/src/components/pointerTargetSize.test.tsx +8 -0
  59. package/src/components/renders/RenderQueue.tsx +1 -1
  60. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  61. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  62. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  63. package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
  64. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  65. package/src/components/timelineToolbarStyles.ts +5 -0
  66. package/src/components/ui/Button.test.tsx +222 -30
  67. package/src/components/ui/Button.tsx +88 -125
  68. package/src/components/ui/IconButton.tsx +38 -0
  69. package/src/components/ui/Input.tsx +115 -0
  70. package/src/components/ui/Menu.test.tsx +374 -0
  71. package/src/components/ui/Menu.tsx +213 -0
  72. package/src/components/ui/NumberField.tsx +122 -0
  73. package/src/components/ui/Popover.tsx +74 -0
  74. package/src/components/ui/Select.tsx +100 -0
  75. package/src/components/ui/Slider.tsx +153 -0
  76. package/src/components/ui/Tabs.test.tsx +98 -0
  77. package/src/components/ui/Tabs.tsx +71 -0
  78. package/src/components/ui/Toggle.tsx +63 -0
  79. package/src/components/ui/Tooltip.test.tsx +110 -0
  80. package/src/components/ui/Tooltip.tsx +38 -112
  81. package/src/components/ui/cn.test.ts +30 -0
  82. package/src/components/ui/cn.ts +25 -0
  83. package/src/components/ui/index.ts +29 -2
  84. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  85. package/src/components/ui/valueControls.test.tsx +524 -0
  86. package/src/contexts/PanelLayoutContext.tsx +3 -57
  87. package/src/hooks/appHotkeysDispatch.ts +3 -4
  88. package/src/hooks/timelineEditPermission.ts +31 -0
  89. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  90. package/src/hooks/useAppHotkeys.test.ts +0 -1
  91. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  92. package/src/hooks/useAppHotkeys.ts +20 -83
  93. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  94. package/src/hooks/useCaptionDetection.ts +3 -1
  95. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  96. package/src/hooks/useDomEditSession.ts +0 -7
  97. package/src/hooks/useDomEditWiring.ts +4 -9
  98. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  99. package/src/hooks/useEditHistoryActions.ts +106 -0
  100. package/src/hooks/useFileManager.ts +21 -123
  101. package/src/hooks/usePanelLayout.test.ts +108 -296
  102. package/src/hooks/usePanelLayout.ts +50 -255
  103. package/src/hooks/usePersistentEditHistory.ts +1 -1
  104. package/src/hooks/useProjectFileWriter.ts +150 -0
  105. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  106. package/src/hooks/useRightPanelIntents.ts +63 -0
  107. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  108. package/src/hooks/useServerConnection.ts +43 -10
  109. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  110. package/src/hooks/useSlideshowTabState.ts +6 -19
  111. package/src/hooks/useStudioContextValue.test.ts +6 -18
  112. package/src/hooks/useStudioContextValue.ts +5 -26
  113. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  114. package/src/hooks/useTimelineEditing.test.tsx +322 -3
  115. package/src/hooks/useTimelineEditing.ts +103 -16
  116. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  117. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  118. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  119. package/src/hostMount.test.tsx +222 -0
  120. package/src/index.ts +87 -0
  121. package/src/player/components/AutomationSelectionMenu.tsx +1 -1
  122. package/src/player/components/Timeline.tsx +1 -1
  123. package/src/player/components/TimelineCanvas.tsx +4 -4
  124. package/src/player/components/TimelineClip.tsx +0 -1
  125. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  126. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  127. package/src/player/components/timelineTheme.test.ts +4 -4
  128. package/src/player/components/timelineTheme.ts +30 -30
  129. package/src/player/lib/playbackShortcuts.ts +2 -0
  130. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  131. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  132. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  133. package/src/player/store/previewIframeStore.test.tsx +29 -0
  134. package/src/player/store/previewIframeStore.ts +25 -0
  135. package/src/renderQueueExports.test.tsx +58 -0
  136. package/src/styles/classCandidates.test.ts +105 -0
  137. package/src/styles/classCandidates.ts +281 -0
  138. package/src/styles/hex-baseline.json +84 -0
  139. package/src/styles/hexRatchet.test.ts +201 -0
  140. package/src/styles/styleSources.test.ts +20 -0
  141. package/src/styles/styleSources.ts +47 -0
  142. package/src/styles/theme.css +57 -0
  143. package/src/styles/theme.test.ts +2 -15
  144. package/src/styles/tokenGate.test.ts +109 -0
  145. package/src/utils/sourcePatcher.ts +1 -1
  146. package/src/utils/studioHelpers.test.ts +18 -0
  147. package/src/utils/studioHelpers.ts +11 -8
  148. package/src/utils/studioUiPreferences.test.ts +10 -16
  149. package/src/utils/studioUiPreferences.ts +0 -16
  150. package/src/utils/typingTarget.ts +4 -0
  151. package/dist/assets/index-C05SNrvY.js +0 -360
  152. package/dist/assets/index-DUR9qzQM.css +0 -1
  153. package/src/components/PanelTabButton.tsx +0 -31
  154. package/src/components/StudioLeftSidebar.tsx +0 -207
  155. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  156. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  157. package/src/components/sidebar/LeftSidebar.tsx +0 -348
  158. package/src/hooks/useInspectorSplitResize.ts +0 -51
  159. package/src/utils/fitPanels.test.ts +0 -164
  160. package/src/utils/fitPanels.ts +0 -151
@@ -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
+ });
@@ -13,69 +13,15 @@ export function usePanelLayoutContext(): PanelLayoutValue {
13
13
  }
14
14
 
15
15
  export function PanelLayoutProvider({
16
- value: {
17
- leftWidth,
18
- rightWidth,
19
- adjustPanelWidth,
20
- leftCollapsed,
21
- rightCollapsed,
22
- setRightCollapsed,
23
- effectiveLeftCollapsed,
24
- effectiveRightCollapsed,
25
- rightPanelTab,
26
- setRightPanelTab,
27
- rightInspectorPanes,
28
- toggleRightInspectorPane,
29
- setExclusiveRightInspectorPane,
30
- toggleLeftSidebar,
31
- handlePanelResizeStart,
32
- handlePanelResizeMove,
33
- handlePanelResizeEnd,
34
- },
16
+ value: { rightCollapsed, setRightCollapsed, rightPanelTab, setRightPanelTab },
35
17
  children,
36
18
  }: {
37
19
  value: PanelLayoutValue;
38
20
  children: ReactNode;
39
21
  }) {
40
22
  const stable = useMemo<PanelLayoutValue>(
41
- () => ({
42
- leftWidth,
43
- rightWidth,
44
- adjustPanelWidth,
45
- leftCollapsed,
46
- rightCollapsed,
47
- setRightCollapsed,
48
- effectiveLeftCollapsed,
49
- effectiveRightCollapsed,
50
- rightPanelTab,
51
- setRightPanelTab,
52
- rightInspectorPanes,
53
- toggleRightInspectorPane,
54
- setExclusiveRightInspectorPane,
55
- toggleLeftSidebar,
56
- handlePanelResizeStart,
57
- handlePanelResizeMove,
58
- handlePanelResizeEnd,
59
- }),
60
- [
61
- leftWidth,
62
- rightWidth,
63
- adjustPanelWidth,
64
- leftCollapsed,
65
- rightCollapsed,
66
- setRightCollapsed,
67
- effectiveLeftCollapsed,
68
- effectiveRightCollapsed,
69
- rightPanelTab,
70
- setRightPanelTab,
71
- rightInspectorPanes,
72
- toggleRightInspectorPane,
73
- setExclusiveRightInspectorPane,
74
- toggleLeftSidebar,
75
- handlePanelResizeStart,
76
- handlePanelResizeMove,
77
- handlePanelResizeEnd,
78
- ],
23
+ () => ({ rightCollapsed, setRightCollapsed, rightPanelTab, setRightPanelTab }),
24
+ [rightCollapsed, setRightCollapsed, rightPanelTab, setRightPanelTab],
79
25
  );
80
26
  return <PanelLayoutContext value={stable}>{children}</PanelLayoutContext>;
81
27
  }
@@ -2,7 +2,7 @@ import { automationOwnsKey } from "./useAutomationSelectionKeyboard";
2
2
  import { usePlayerStore } from "../player";
3
3
  import type { TimelineElement } from "../player";
4
4
  import type { DomEditSelection } from "../components/editor/domEditing";
5
- import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
5
+ import { useDockLayoutStore } from "../components/dock/dockLayoutStore";
6
6
  import { isTypingTarget } from "../utils/typingTarget";
7
7
  import { isEditableTarget } from "../utils/timelineDiscovery";
8
8
  import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
@@ -52,7 +52,6 @@ export interface HotkeyCallbacks {
52
52
  onToggleRecording?: () => void;
53
53
  onGroupSelection?: () => void;
54
54
  onUngroupSelection?: () => void;
55
- leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
56
55
  domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
57
56
  showToast: (message: string, tone?: "error" | "info") => void;
58
57
  }
@@ -84,13 +83,13 @@ export function dispatchModifierKey(
84
83
  if (event.key === "1") {
85
84
  event.preventDefault();
86
85
  trackStudioEvent("keyboard_shortcut", { action: "tab_compositions" });
87
- cb.leftSidebarRef.current?.selectTab("compositions");
86
+ useDockLayoutStore.getState().activatePanel("compositions");
88
87
  return true;
89
88
  }
90
89
  if (event.key === "2") {
91
90
  event.preventDefault();
92
91
  trackStudioEvent("keyboard_shortcut", { action: "tab_assets" });
93
- cb.leftSidebarRef.current?.selectTab("assets");
92
+ useDockLayoutStore.getState().activatePanel("assets");
94
93
  return true;
95
94
  }
96
95
 
@@ -0,0 +1,31 @@
1
+ import { useCallback } from "react";
2
+ import type { TimelineElement } from "../player";
3
+
4
+ /** A host's verdict on one element: editable, or blocked with a reason to show. */
5
+ export type TimelineEditPermission = true | { blocked: true; reason: string };
6
+
7
+ export type CanEditTimelineElement = (element: TimelineElement) => TimelineEditPermission;
8
+
9
+ /**
10
+ * Refuses a write when any target element is blocked, toasting the host's
11
+ * reason. Absent `canEdit` never refuses, so Studio itself is unchanged.
12
+ */
13
+ export function useTimelineEditGate(
14
+ canEdit: CanEditTimelineElement | undefined,
15
+ showToast: (message: string, tone?: "error" | "info") => void,
16
+ ) {
17
+ return useCallback(
18
+ (targets: readonly TimelineElement[]): boolean => {
19
+ if (!canEdit) return true;
20
+ for (const element of targets) {
21
+ const verdict = canEdit(element);
22
+ if (verdict !== true) {
23
+ showToast(verdict.reason, "error");
24
+ return false;
25
+ }
26
+ }
27
+ return true;
28
+ },
29
+ [canEdit, showToast],
30
+ );
31
+ }
@@ -4,7 +4,6 @@ import React, { act, useRef } from "react";
4
4
  import type { Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import type { DomEditSelection } from "../components/editor/domEditing";
7
- import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
8
7
  import { usePlayerStore } from "../player/store/playerStore";
9
8
  import { useAppHotkeys } from "./useAppHotkeys";
10
9
  import { mountReactHarness } from "./domSelectionTestHarness";
@@ -46,7 +45,6 @@ function Harness() {
46
45
  showToast: vi.fn(),
47
46
  syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
48
47
  waitForPendingDomEditSaves: vi.fn(async () => undefined),
49
- leftSidebarRef: useRef<LeftSidebarHandle | null>(null),
50
48
  handleCopy: vi.fn(() => false),
51
49
  handlePaste: vi.fn(() => false),
52
50
  handleCut: vi.fn(() => false),
@@ -33,7 +33,6 @@ function callbacks() {
33
33
  onResetKeyframes: vi.fn(() => true),
34
34
  onDeleteSelectedKeyframes: vi.fn(),
35
35
  showToast: vi.fn(),
36
- leftSidebarRef: { current: null },
37
36
  domEditSelectionRef: { current: null },
38
37
  };
39
38
  }