@hyperframes/studio 0.8.51 → 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 (99) hide show
  1. package/dist/assets/{hyperframes-player-Cn_PpHqP.js → hyperframes-player-I5YRzB02.js} +1 -1
  2. package/dist/assets/{index-Clrb11lj.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-CPS3Cvj8.js → index-Du5x7KUR.js} +1 -1
  6. package/dist/index.d.ts +401 -7
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +6545 -5869
  9. package/dist/index.js.map +1 -1
  10. package/package.json +9 -7
  11. package/src/App.tsx +3 -2
  12. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  13. package/src/captions/components/CaptionOverlay.tsx +8 -4
  14. package/src/components/EditorShell.tsx +4 -0
  15. package/src/components/RightPanelTabs.test.tsx +151 -0
  16. package/src/components/RightPanelTabs.tsx +59 -0
  17. package/src/components/StudioHeader.dom.test.tsx +181 -0
  18. package/src/components/StudioHeader.tsx +109 -149
  19. package/src/components/StudioRightPanel.tsx +47 -35
  20. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  21. package/src/components/TimelineHistoryButtons.tsx +54 -0
  22. package/src/components/TimelineToolbar.test.tsx +8 -0
  23. package/src/components/TimelineToolbar.tsx +3 -7
  24. package/src/components/dock/Dock.test.tsx +84 -16
  25. package/src/components/dock/Dock.tsx +19 -2
  26. package/src/components/dock/dock.css +5 -0
  27. package/src/components/dock/dockAccessibility.test.ts +166 -0
  28. package/src/components/dock/dockAccessibility.ts +98 -0
  29. package/src/components/dock/dockLayout.test.ts +134 -0
  30. package/src/components/dock/dockLayout.ts +53 -6
  31. package/src/components/dock/dockTestHarness.ts +11 -0
  32. package/src/components/editor/LayersPanel.test.ts +97 -1
  33. package/src/components/editor/LayersPanel.tsx +4 -2
  34. package/src/components/editor/PreviewGuides.tsx +1 -1
  35. package/src/components/editor/TopologyLens.test.tsx +28 -0
  36. package/src/components/editor/TopologyLens.tsx +3 -1
  37. package/src/components/editor/useColorGradingController.test.ts +38 -0
  38. package/src/components/editor/useColorGradingController.ts +9 -1
  39. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  40. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  41. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  42. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  43. package/src/components/nle/NLEPreview.tsx +1 -1
  44. package/src/components/nle/PreviewPane.tsx +1 -1
  45. package/src/components/pointerTargetSize.test.tsx +8 -0
  46. package/src/components/renders/RenderQueue.tsx +1 -1
  47. package/src/components/timelineToolbarStyles.ts +5 -0
  48. package/src/components/ui/Button.test.tsx +222 -30
  49. package/src/components/ui/Button.tsx +88 -125
  50. package/src/components/ui/IconButton.tsx +38 -0
  51. package/src/components/ui/Input.tsx +115 -0
  52. package/src/components/ui/Menu.test.tsx +374 -0
  53. package/src/components/ui/Menu.tsx +213 -0
  54. package/src/components/ui/NumberField.tsx +122 -0
  55. package/src/components/ui/Popover.tsx +74 -0
  56. package/src/components/ui/Select.tsx +100 -0
  57. package/src/components/ui/Slider.tsx +153 -0
  58. package/src/components/ui/Tabs.test.tsx +98 -0
  59. package/src/components/ui/Tabs.tsx +71 -0
  60. package/src/components/ui/Toggle.tsx +63 -0
  61. package/src/components/ui/Tooltip.test.tsx +110 -0
  62. package/src/components/ui/Tooltip.tsx +38 -112
  63. package/src/components/ui/cn.test.ts +30 -0
  64. package/src/components/ui/cn.ts +25 -0
  65. package/src/components/ui/index.ts +29 -2
  66. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  67. package/src/components/ui/valueControls.test.tsx +524 -0
  68. package/src/hooks/useAppHotkeys.ts +20 -79
  69. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  70. package/src/hooks/useCaptionDetection.ts +3 -1
  71. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  72. package/src/hooks/useEditHistoryActions.ts +106 -0
  73. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  74. package/src/hooks/useServerConnection.ts +43 -10
  75. package/src/index.ts +51 -0
  76. package/src/player/components/AutomationSelectionMenu.tsx +1 -1
  77. package/src/player/components/TimelineCanvas.tsx +4 -4
  78. package/src/player/components/TimelineClip.tsx +0 -1
  79. package/src/player/lib/playbackShortcuts.ts +2 -0
  80. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  81. package/src/player/store/previewIframeStore.test.tsx +29 -0
  82. package/src/player/store/previewIframeStore.ts +25 -0
  83. package/src/renderQueueExports.test.tsx +58 -0
  84. package/src/styles/classCandidates.test.ts +105 -0
  85. package/src/styles/classCandidates.ts +281 -0
  86. package/src/styles/hex-baseline.json +84 -0
  87. package/src/styles/hexRatchet.test.ts +201 -0
  88. package/src/styles/styleSources.test.ts +20 -0
  89. package/src/styles/styleSources.ts +47 -0
  90. package/src/styles/theme.css +30 -0
  91. package/src/styles/theme.test.ts +2 -15
  92. package/src/styles/tokenGate.test.ts +109 -0
  93. package/src/utils/sourcePatcher.ts +1 -1
  94. package/src/utils/studioHelpers.test.ts +18 -0
  95. package/src/utils/studioHelpers.ts +11 -1
  96. package/src/utils/typingTarget.ts +4 -0
  97. package/dist/assets/index-C05SNrvY.js +0 -360
  98. package/dist/assets/index-DUR9qzQM.css +0 -1
  99. package/src/components/PanelTabButton.tsx +0 -31
@@ -1,8 +1,10 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import React, { act } from "react";
3
+ import React, { act, type ComponentProps } from "react";
4
+ import type { DockviewApi } from "dockview-react";
4
5
  import { createRoot, type Root } from "react-dom/client";
5
6
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import * as dockLayout from "./dockLayout";
6
8
  import { Dock } from "./Dock";
7
9
  import { parseDockLayout } from "./dockLayoutSchema";
8
10
  import { useDockLayoutStore } from "./dockLayoutStore";
@@ -11,13 +13,45 @@ import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
11
13
 
12
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
15
 
16
+ let dockApi: DockviewApi | null = null;
17
+ vi.mock("dockview-react", async (importOriginal) => {
18
+ const actual = await importOriginal<typeof import("dockview-react")>();
19
+ return {
20
+ ...actual,
21
+ DockviewReact: (props: ComponentProps<typeof actual.DockviewReact>) =>
22
+ React.createElement(actual.DockviewReact, {
23
+ ...props,
24
+ onReady: (event) => {
25
+ dockApi = event.api;
26
+ props.onReady(event);
27
+ },
28
+ }),
29
+ };
30
+ });
31
+
32
+ const liveObservers = new Set<{ callback: () => void; target?: Element }>();
14
33
  class ResizeObserverStub {
15
- observe() {}
34
+ private readonly entry: { callback: () => void; target?: Element };
35
+ constructor(callback: () => void) {
36
+ this.entry = { callback };
37
+ }
38
+ observe(target: Element) {
39
+ this.entry.target = target;
40
+ liveObservers.add(this.entry);
41
+ }
16
42
  unobserve() {}
17
- disconnect() {}
43
+ disconnect() {
44
+ liveObservers.delete(this.entry);
45
+ }
18
46
  }
19
47
  (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub;
20
48
 
49
+ vi.mock("./dockLayout", async (importOriginal) => {
50
+ const actual = await importOriginal<typeof import("./dockLayout")>();
51
+ return { ...actual, applySideMinimums: vi.fn(actual.applySideMinimums) };
52
+ });
53
+ const applySideMinimums = vi.mocked(dockLayout.applySideMinimums);
54
+
21
55
  let root: Root | null = null;
22
56
 
23
57
  function mount(projectId: string | null) {
@@ -38,6 +72,16 @@ function mount(projectId: string | null) {
38
72
  return host;
39
73
  }
40
74
 
75
+ /** The persisted views of the group holding `id`, after the debounced write lands. */
76
+ function persistedGroupOf(id: string): string | undefined {
77
+ act(() => {
78
+ vi.advanceTimersByTime(1000);
79
+ });
80
+ const grid = JSON.stringify(readStudioUiPreferences(undefined, "p1").dockLayout?.grid);
81
+ const groups = [...grid.matchAll(/"views":\[([^\]]*)\]/g)].map((m) => m[1]);
82
+ return groups.find((views) => views.includes(`"${id}"`));
83
+ }
84
+
41
85
  beforeEach(() => {
42
86
  localStorage.clear();
43
87
  vi.useFakeTimers();
@@ -102,13 +146,7 @@ describe("Dock on React 19", () => {
102
146
  mount("p1");
103
147
  act(() => useDockLayoutStore.getState().closePanel("compositions"));
104
148
  act(() => useDockLayoutStore.getState().togglePanel("compositions"));
105
- act(() => {
106
- vi.advanceTimersByTime(1000);
107
- });
108
- const grid = JSON.stringify(readStudioUiPreferences(undefined, "p1").dockLayout?.grid);
109
- const groups = [...grid.matchAll(/"views":\[([^\]]*)\]/g)].map((m) => m[1]);
110
- const home = groups.find((views) => views.includes('"compositions"'));
111
- expect(home).toContain('"assets"');
149
+ expect(persistedGroupOf("compositions")).toContain('"assets"');
112
150
  });
113
151
 
114
152
  it("reopens a side panel next to the preview when its whole column was closed", () => {
@@ -125,12 +163,7 @@ describe("Dock on React 19", () => {
125
163
  mount("p1");
126
164
  act(() => useDockLayoutStore.getState().closePanel("timeline"));
127
165
  act(() => useDockLayoutStore.getState().togglePanel("timeline"));
128
- act(() => {
129
- vi.advanceTimersByTime(1000);
130
- });
131
- const grid = JSON.stringify(readStudioUiPreferences(undefined, "p1").dockLayout?.grid);
132
- const groups = [...grid.matchAll(/"views":\[([^\]]*)\]/g)].map((m) => m[1]);
133
- expect(groups.find((views) => views.includes('"timeline"'))).not.toContain('"preview"');
166
+ expect(persistedGroupOf("timeline")).not.toContain('"preview"');
134
167
  });
135
168
 
136
169
  it("restores the stored layout on the next mount instead of rebuilding the default", () => {
@@ -157,6 +190,41 @@ describe("Dock on React 19", () => {
157
190
  });
158
191
  });
159
192
 
193
+ describe("Dock wiring", () => {
194
+ it("re-applies the side minimums when a panel is dragged to another group", () => {
195
+ mount(null);
196
+ applySideMinimums.mockClear();
197
+ const preview = dockApi?.getPanel("preview");
198
+ const design = dockApi?.getPanel("design");
199
+ if (!preview || !design) throw new Error("default layout is missing panels");
200
+ act(() => design.api.moveTo({ group: preview.group }));
201
+ expect(applySideMinimums).toHaveBeenCalled();
202
+ });
203
+
204
+ it("makes the sashes keyboard-focusable separators", () => {
205
+ const host = mount(null);
206
+ const sashes = host.querySelectorAll<HTMLElement>('.dv-sash[role="separator"]');
207
+ expect(sashes.length).toBeGreaterThan(0);
208
+ for (const sash of sashes) expect(sash.tabIndex).toBe(0);
209
+ });
210
+
211
+ it("re-fits the side columns to the window width when it resizes", () => {
212
+ mount(null);
213
+ const innerWidth = window.innerWidth;
214
+ Object.defineProperty(window, "innerWidth", { value: 560, configurable: true });
215
+ try {
216
+ applySideMinimums.mockClear();
217
+ const dockObservers = [...liveObservers].filter(({ target }) =>
218
+ target?.classList.contains("hf-dock"),
219
+ );
220
+ act(() => dockObservers.forEach(({ callback }) => callback()));
221
+ expect(applySideMinimums).toHaveBeenCalledWith(expect.anything(), 560);
222
+ } finally {
223
+ Object.defineProperty(window, "innerWidth", { value: innerWidth, configurable: true });
224
+ }
225
+ });
226
+ });
227
+
160
228
  describe("parseDockLayout", () => {
161
229
  it("rejects shapes that are not a dock layout", () => {
162
230
  expect(parseDockLayout(null)).toBeNull();
@@ -15,7 +15,8 @@ import {
15
15
  type IDockviewPanelProps,
16
16
  } from "dockview-react";
17
17
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
18
- import { addRegisteredPanel, buildEditLayout } from "./dockLayout";
18
+ import { installDockAccessibility } from "./dockAccessibility";
19
+ import { addRegisteredPanel, applySideMinimums, buildEditLayout } from "./dockLayout";
19
20
  import { DOCK_PANEL_COMPONENT } from "./dockLayoutSchema";
20
21
  import { useDockLayoutStore, type DockController, type DockSnapshot } from "./dockLayoutStore";
21
22
  import {
@@ -135,7 +136,14 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
135
136
 
136
137
  const onReady = useCallback(
137
138
  ({ api }: DockviewReadyEvent) => {
139
+ disposeRef.current();
138
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);
139
147
  const store = useDockLayoutStore.getState();
140
148
  store.attach(createController(api));
141
149
  store.sync(snapshot(api));
@@ -156,7 +164,14 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
156
164
  persist();
157
165
  };
158
166
  const subscriptions = [
159
- api.onDidAddPanel(onDockChange),
167
+ api.onDidAddPanel(() => {
168
+ applySideMinimums(api);
169
+ onDockChange();
170
+ }),
171
+ api.onDidMovePanel(() => {
172
+ applySideMinimums(api);
173
+ onDockChange();
174
+ }),
160
175
  api.onDidRemovePanel(onDockChange),
161
176
  api.onDidActivePanelChange(onDockChange),
162
177
  api.onDidLayoutChange(onDockChange),
@@ -164,6 +179,8 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
164
179
  disposeRef.current = () => {
165
180
  clearTimeout(timer);
166
181
  for (const subscription of subscriptions) subscription.dispose();
182
+ disposeAccessibility();
183
+ resizeObserver.disconnect();
167
184
  useDockLayoutStore.getState().detach();
168
185
  };
169
186
  },
@@ -21,3 +21,8 @@
21
21
  --dv-tab-font-size: 11px;
22
22
  --dv-tabs-and-actions-container-font-size: 11px;
23
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
+ }
@@ -0,0 +1,134 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import type { DockviewApi } from "dockview-react";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import {
6
+ applySideMinimums,
7
+ buildEditLayout,
8
+ defaultSideWidths,
9
+ sideMinimumWidth,
10
+ } from "./dockLayout";
11
+ import { mountBareDockview } from "./dockTestHarness";
12
+
13
+ class ResizeObserverStub {
14
+ observe() {}
15
+ unobserve() {}
16
+ disconnect() {}
17
+ }
18
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub;
19
+
20
+ // Expected values are hand-derived from the old fitPanels rule: the preview keeps 360 first,
21
+ // then each side column takes half of what remains, never above 200 or below 120.
22
+ describe("sideMinimumWidth", () => {
23
+ it.each([
24
+ [1680, 200],
25
+ [1200, 200],
26
+ [860, 200],
27
+ [760, 200],
28
+ [700, 170],
29
+ [600, 120],
30
+ [560, 120],
31
+ [400, 120],
32
+ ])("is %ipx wide -> sides shrink to %ipx at most", (viewport, expected) => {
33
+ expect(sideMinimumWidth(viewport)).toBe(expected);
34
+ });
35
+ });
36
+
37
+ describe("defaultSideWidths", () => {
38
+ it.each([
39
+ [1680, 384, 424],
40
+ [1280, 329, 364],
41
+ [860, 221, 244],
42
+ [560, 120, 120],
43
+ ])("at %ipx gives %i / %i", (viewport, left, right) => {
44
+ expect(defaultSideWidths(viewport)).toEqual({ left, right });
45
+ });
46
+
47
+ it.each([1680, 1280, 1000, 860, 760, 700, 600, 560, 480])(
48
+ "leaves the preview its 360px floor at %ipx whenever the sides can shrink enough",
49
+ (viewport) => {
50
+ const { left, right } = defaultSideWidths(viewport);
51
+ const floor = sideMinimumWidth(viewport);
52
+ const overflow = left + right + 360 - viewport;
53
+ expect(overflow <= 0 || (left === floor && right === floor)).toBe(true);
54
+ },
55
+ );
56
+ });
57
+
58
+ describe("applySideMinimums", () => {
59
+ let api: DockviewApi;
60
+ beforeEach(() => {
61
+ ({ api } = mountBareDockview());
62
+ });
63
+ afterEach(() => {
64
+ api.dispose();
65
+ document.body.innerHTML = "";
66
+ });
67
+
68
+ it("lowers side groups' minimum on a narrow window and shrinks them, never touching the preview", () => {
69
+ api.layout(1200, 700);
70
+ buildEditLayout(api, 1200);
71
+ applySideMinimums(api, 560);
72
+ expect(api.getPanel("compositions")?.group.minimumWidth).toBe(120);
73
+ expect(api.getPanel("design")?.group.minimumWidth).toBe(120);
74
+ expect(api.getPanel("compositions")?.group.width).toBe(120);
75
+ expect(api.getPanel("design")?.group.width).toBe(120);
76
+ expect(api.getPanel("preview")?.group.minimumWidth).toBe(360);
77
+ });
78
+
79
+ it("leaves a wide window's user-chosen widths alone", () => {
80
+ api.layout(1200, 700);
81
+ buildEditLayout(api, 1200);
82
+ api.getPanel("compositions")?.group.api.setSize({ width: 300 });
83
+ applySideMinimums(api, 1200);
84
+ expect(api.getPanel("compositions")?.group.width).toBe(300);
85
+ });
86
+
87
+ it("keeps the preview's floor when a side panel is tabbed into the preview group", () => {
88
+ api.layout(1200, 700);
89
+ buildEditLayout(api, 1200);
90
+ const preview = api.getPanel("preview");
91
+ const design = api.getPanel("design");
92
+ if (!preview || !design) throw new Error("default layout is missing panels");
93
+ design.api.moveTo({ group: preview.group, position: "center", index: 0 });
94
+ preview.api.setActive();
95
+ applySideMinimums(api, 560);
96
+ expect(preview.group.minimumWidth).toBe(360);
97
+ });
98
+
99
+ it("pins the preview floor when a side panel that was showing gets the preview dragged in", () => {
100
+ api.layout(1200, 700);
101
+ buildEditLayout(api, 1200);
102
+ applySideMinimums(api, 560);
103
+ const preview = api.getPanel("preview");
104
+ const design = api.getPanel("design");
105
+ if (!preview || !design) throw new Error("default layout is missing panels");
106
+ preview.api.moveTo({ group: design.group });
107
+ applySideMinimums(api, 560);
108
+ expect(preview.group.minimumWidth).toBe(360);
109
+ });
110
+
111
+ it("rewrites the minimum of a group that is neither the preview nor side-only", () => {
112
+ api.layout(1200, 700);
113
+ buildEditLayout(api, 1200);
114
+ const timeline = api.getPanel("timeline");
115
+ const compositions = api.getPanel("compositions");
116
+ if (!timeline || !compositions) throw new Error("default layout is missing panels");
117
+ timeline.api.moveTo({ group: compositions.group });
118
+ applySideMinimums(api, 560);
119
+ expect(compositions.group.minimumWidth).toBe(120);
120
+ });
121
+
122
+ it("drops the preview floor from a group the preview has left", () => {
123
+ api.layout(1200, 700);
124
+ buildEditLayout(api, 1200);
125
+ const preview = api.getPanel("preview");
126
+ const timeline = api.getPanel("timeline");
127
+ if (!preview || !timeline) throw new Error("default layout is missing panels");
128
+ preview.api.moveTo({ group: timeline.group });
129
+ applySideMinimums(api, 560);
130
+ preview.api.moveTo({ position: "right", group: api.getPanel("design")?.group });
131
+ applySideMinimums(api, 560);
132
+ expect(timeline.group.minimumWidth).toBe(120);
133
+ });
134
+ });
@@ -1,18 +1,64 @@
1
1
  import type { Direction, DockviewApi } from "dockview-react";
2
2
  import { DOCK_PANEL_COMPONENT } from "./dockLayoutSchema";
3
- import { PANEL_DEFINITIONS, type PanelId } from "./panelRegistry";
3
+ import { PANEL_DEFINITIONS, isPanelId, type PanelId } from "./panelRegistry";
4
4
 
5
5
  const MIN_PREVIEW_W = 360;
6
6
  const MIN_PREVIEW_H = 200;
7
7
  const MIN_TIMELINE_H = 100;
8
8
  const MIN_SIDE_W = 200;
9
+ const MIN_SIDE_W_FLOOR = 120;
9
10
  const DEFAULT_TIMELINE_H = 360;
10
11
 
11
- function defaultSideWidths(viewportWidth: number) {
12
- return {
13
- left: Math.max(MIN_SIDE_W, Math.min(384, Math.round(viewportWidth * 0.257))),
14
- right: Math.max(280, Math.min(424, Math.round(viewportWidth * 0.284))),
15
- };
12
+ /** Preferred side widths; when they overflow the preview's floor the right yields first, then the left. */
13
+ export function defaultSideWidths(viewportWidth: number) {
14
+ const floor = sideMinimumWidth(viewportWidth);
15
+ const left = Math.max(floor, Math.min(384, Math.round(viewportWidth * 0.257)));
16
+ const right = Math.max(Math.min(280, floor), Math.min(424, Math.round(viewportWidth * 0.284)));
17
+ const excess = left + right - Math.max(0, viewportWidth - MIN_PREVIEW_W);
18
+ if (excess <= 0) return { left, right };
19
+ const shrunkRight = Math.max(floor, right - excess);
20
+ return { left: Math.max(floor, left - (excess - (right - shrunkRight))), right: shrunkRight };
21
+ }
22
+
23
+ /**
24
+ * Side groups keep MIN_SIDE_W while both fit beside the preview's floor, then shrink down to
25
+ * MIN_SIDE_W_FLOOR; below that the dock overflows and clips rather than squeezing the preview.
26
+ */
27
+ export function sideMinimumWidth(dockWidth: number): number {
28
+ const fair = Math.floor((dockWidth - MIN_PREVIEW_W) / 2);
29
+ return Math.min(MIN_SIDE_W, Math.max(MIN_SIDE_W_FLOOR, fair));
30
+ }
31
+
32
+ function holdsPreview(group: DockviewApi["groups"][number]) {
33
+ return group.panels.some((panel) => panel.id === "preview");
34
+ }
35
+
36
+ /** The zone of a group whose every tab is a side panel, else null. */
37
+ function sideZone(group: DockviewApi["groups"][number]): "left" | "right" | null {
38
+ const zones = group.panels.map((panel) =>
39
+ isPanelId(panel.id) ? PANEL_DEFINITIONS[panel.id].zone : "center",
40
+ );
41
+ const first = zones[0];
42
+ const allSide = zones.every((zone) => zone !== "center");
43
+ return allSide && first && first !== "center" ? first : null;
44
+ }
45
+
46
+ /** Idempotent; rewrites every group's minimum, since dockview keeps a constraint once set. */
47
+ export function applySideMinimums(api: DockviewApi, dockWidth = window.innerWidth) {
48
+ const minimumWidth = sideMinimumWidth(dockWidth);
49
+ const cap = defaultSideWidths(dockWidth);
50
+ for (const group of api.groups) {
51
+ if (holdsPreview(group)) {
52
+ group.api.setConstraints({ minimumWidth: MIN_PREVIEW_W });
53
+ continue;
54
+ }
55
+ group.api.setConstraints({ minimumWidth });
56
+ const zone = sideZone(group);
57
+ const limit = zone === "right" ? cap.right : cap.left;
58
+ if (zone && minimumWidth < MIN_SIDE_W && group.width > limit) {
59
+ group.api.setSize({ width: limit });
60
+ }
61
+ }
16
62
  }
17
63
 
18
64
  function minimumSize(id: PanelId) {
@@ -52,6 +98,7 @@ export function buildEditLayout(api: DockviewApi, viewportWidth: number) {
52
98
  }
53
99
  api.getPanel("compositions")?.api.setActive();
54
100
  api.getPanel("design")?.api.setActive();
101
+ applySideMinimums(api, viewportWidth);
55
102
  api.getPanel("compositions")?.group.api.setSize({ width: widths.left });
56
103
  api.getPanel("design")?.group.api.setSize({ width: widths.right });
57
104
  api.getPanel("timeline")?.group.api.setSize({ height: DEFAULT_TIMELINE_H });