@hyperframes/studio 0.8.66 → 0.8.68

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.66",
3
+ "version": "0.8.68",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,11 +51,11 @@
51
51
  "dockview-react": "8.3.1",
52
52
  "gsap": "^3.13.0",
53
53
  "mediabunny": "^1.45.3",
54
- "@hyperframes/core": "0.8.66",
55
- "@hyperframes/player": "0.8.66",
56
- "@hyperframes/parsers": "0.8.66",
57
- "@hyperframes/sdk": "0.8.66",
58
- "@hyperframes/studio-server": "0.8.66"
54
+ "@hyperframes/core": "0.8.68",
55
+ "@hyperframes/parsers": "0.8.68",
56
+ "@hyperframes/sdk": "0.8.68",
57
+ "@hyperframes/player": "0.8.68",
58
+ "@hyperframes/studio-server": "0.8.68"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@tailwindcss/vite": "^4.3.3",
@@ -71,7 +71,7 @@
71
71
  "vite": "^6.4.2",
72
72
  "vitest": "^4.1.11",
73
73
  "zustand": "^5.0.0",
74
- "@hyperframes/producer": "0.8.66"
74
+ "@hyperframes/producer": "0.8.68"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "react": "19",
@@ -286,9 +286,7 @@ function EditorShellBody({
286
286
  return (
287
287
  <div
288
288
  ref={containerRef}
289
- // Shell canvas is a step LIGHTER than the near-black panel cards so the
290
- // gaps between panels read as visible seams (CapCut-style).
291
- className="flex flex-col flex-1 min-h-0 bg-panel-surface"
289
+ className="flex flex-col flex-1 min-h-0"
292
290
  onKeyDown={handleKeyDown}
293
291
  tabIndex={-1}
294
292
  >
@@ -28,7 +28,7 @@ interface StudioLeftPanelsProps {
28
28
 
29
29
  function PanelColumn({ footer, children }: { footer: ReactNode; children: ReactNode }) {
30
30
  return (
31
- <div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-950">
31
+ <div className="flex h-full min-h-0 flex-col overflow-hidden">
32
32
  <div className="flex min-h-0 flex-1 flex-col">{children}</div>
33
33
  {footer}
34
34
  </div>
@@ -16,6 +16,9 @@ import {
16
16
  } from "dockview-react";
17
17
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
18
18
  import { installDockAccessibility } from "./dockAccessibility";
19
+ import { DockStripActions } from "./DockStripActions";
20
+ import { DockTab } from "./DockTab";
21
+ import { installTabFill } from "./dockTabFill";
19
22
  import { addRegisteredPanel, applySideMinimums, buildEditLayout } from "./dockLayout";
20
23
  import { DOCK_PANEL_COMPONENT } from "./dockLayoutSchema";
21
24
  import { useDockLayoutStore, type DockController, type DockSnapshot } from "./dockLayoutStore";
@@ -150,6 +153,7 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
150
153
  applySideMinimums(api);
151
154
  const root = api.groups[0]?.element.closest<HTMLElement>(".hf-dock");
152
155
  const disposeAccessibility = root ? installDockAccessibility(api, root) : () => {};
156
+ const disposeTabFill = root ? installTabFill(api, root) : () => {};
153
157
  // The dock spans the window (buildEditLayout sizes against it too); its own box lags a resize.
154
158
  const resizeObserver = new ResizeObserver(() => applySideMinimums(api, window.innerWidth));
155
159
  if (root) resizeObserver.observe(root);
@@ -189,6 +193,7 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
189
193
  clearTimeout(timer);
190
194
  for (const subscription of subscriptions) subscription.dispose();
191
195
  disposeAccessibility();
196
+ disposeTabFill();
192
197
  resizeObserver.disconnect();
193
198
  useDockLayoutStore.getState().detach();
194
199
  };
@@ -202,8 +207,11 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
202
207
  key={projectId ?? ""}
203
208
  className="hf-dock flex-1 min-h-0"
204
209
  components={COMPONENTS}
210
+ defaultTabComponent={DockTab}
211
+ rightHeaderActionsComponent={DockStripActions}
205
212
  defaultRenderer="always"
206
213
  disableFloatingGroups
214
+ disableTabsOverflowList
207
215
  onReady={onReady}
208
216
  />
209
217
  {children}
@@ -0,0 +1,63 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+ import { CornersIn, CornersOut, DotsThree, X } from "@phosphor-icons/react";
3
+ import type { IDockviewHeaderActionsProps } from "dockview-react";
4
+ import { IconButton, Menu, MenuCheckboxItem, MenuItem, MenuSeparator } from "../ui";
5
+ import { useDockLayoutStore } from "./dockLayoutStore";
6
+ import { PANEL_DEFINITIONS, isPanelId, panelsInZone } from "./panelRegistry";
7
+
8
+ /** Panel menu, maximise and close group, drawn on the active group's strip only. */
9
+ export function DockStripActions({
10
+ api,
11
+ containerApi,
12
+ panels,
13
+ isGroupActive,
14
+ }: IDockviewHeaderActionsProps) {
15
+ const subscribe = useCallback(
16
+ (onChange: () => void) => {
17
+ const subscription = containerApi.onDidMaximizedGroupChange(onChange);
18
+ return () => subscription.dispose();
19
+ },
20
+ [containerApi],
21
+ );
22
+ const maximized = useSyncExternalStore(subscribe, () => api.isMaximized());
23
+ const openPanels = useDockLayoutStore((state) => state.openPanels);
24
+ const togglePanel = useDockLayoutStore((state) => state.togglePanel);
25
+ const resetLayout = useDockLayoutStore((state) => state.resetLayout);
26
+ if (!isGroupActive) return null;
27
+
28
+ const first = panels.find((panel) => isPanelId(panel.id))?.id;
29
+ const menuPanels = isPanelId(first) ? panelsInZone(PANEL_DEFINITIONS[first].zone) : [];
30
+ return (
31
+ <div className="hf-dock-strip-actions">
32
+ <Menu
33
+ align="end"
34
+ aria-label="Panel menu"
35
+ trigger={<IconButton size="sm" aria-label="Panel menu" icon={<DotsThree size={16} />} />}
36
+ >
37
+ {menuPanels.map((id) => (
38
+ <MenuCheckboxItem
39
+ key={id}
40
+ checked={openPanels.has(id)}
41
+ onCheckedChange={() => togglePanel(id)}
42
+ >
43
+ {PANEL_DEFINITIONS[id].title}
44
+ </MenuCheckboxItem>
45
+ ))}
46
+ <MenuSeparator />
47
+ <MenuItem onClick={resetLayout}>Reset layout</MenuItem>
48
+ </Menu>
49
+ <IconButton
50
+ size="sm"
51
+ aria-label={maximized ? "Restore panel" : "Maximize panel"}
52
+ icon={maximized ? <CornersIn size={16} /> : <CornersOut size={16} />}
53
+ onClick={() => (maximized ? api.exitMaximized() : api.maximize())}
54
+ />
55
+ <IconButton
56
+ size="sm"
57
+ aria-label="Close group"
58
+ icon={<X size={16} />}
59
+ onClick={() => api.close()}
60
+ />
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,68 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+ import {
3
+ BracketsCurly,
4
+ ChartBarHorizontal,
5
+ Code,
6
+ FilmSlate,
7
+ Image,
8
+ Layout,
9
+ Monitor,
10
+ Presentation,
11
+ SlidersHorizontal,
12
+ SquaresFour,
13
+ Stack,
14
+ X,
15
+ type Icon,
16
+ } from "@phosphor-icons/react";
17
+ import type { IDockviewPanelHeaderProps } from "dockview-react";
18
+ import { isPanelId, type PanelId } from "./panelRegistry";
19
+
20
+ const TAB_ICONS: Record<PanelId, Icon> = {
21
+ preview: Monitor,
22
+ timeline: ChartBarHorizontal,
23
+ compositions: Layout,
24
+ assets: Image,
25
+ code: Code,
26
+ catalog: SquaresFour,
27
+ design: SlidersHorizontal,
28
+ layers: Stack,
29
+ renders: FilmSlate,
30
+ variables: BracketsCurly,
31
+ slideshow: Presentation,
32
+ };
33
+
34
+ /**
35
+ * A dock tab. dock.css shows the type icon and close glyph on the shown tab only, keyed on
36
+ * dockview's own tab class, so they swap in the frame the tab changes rather than a render later.
37
+ */
38
+ export function DockTab({ api }: IDockviewPanelHeaderProps) {
39
+ const subscribe = useCallback(
40
+ (onChange: () => void) => {
41
+ const subscription = api.onDidTitleChange(onChange);
42
+ return () => subscription.dispose();
43
+ },
44
+ [api],
45
+ );
46
+ const title = useSyncExternalStore(subscribe, () => api.title ?? "");
47
+ const TypeIcon = isPanelId(api.id) ? TAB_ICONS[api.id] : null;
48
+ return (
49
+ <div className="hf-dock-tab">
50
+ {TypeIcon ? <TypeIcon className="hf-dock-tab-icon" size={14} aria-hidden /> : null}
51
+ <span className="hf-dock-tab-label">{title}</span>
52
+ {/* Same shape as dockview's own close control: a tab cannot hold a focusable button. */}
53
+ <div
54
+ role="button"
55
+ tabIndex={-1}
56
+ aria-label={`Close ${title}`}
57
+ className="hf-dock-tab-close"
58
+ onPointerDown={(event) => event.stopPropagation()}
59
+ onClick={(event) => {
60
+ event.stopPropagation();
61
+ api.close();
62
+ }}
63
+ >
64
+ <X size={12} aria-hidden />
65
+ </div>
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,255 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { act } from "react";
6
+ import { createRoot, type Root } from "react-dom/client";
7
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { Dock } from "./Dock";
9
+ import { useDockLayoutStore } from "./dockLayoutStore";
10
+ import { PANEL_IDS, type PanelId } from "./panelRegistry";
11
+
12
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ // happy-dom never resizes anything; a test calls `resizeAll` to stand in for a real resize.
15
+ const liveObservers = new Set<ResizeObserverStub>();
16
+ class ResizeObserverStub {
17
+ readonly callback: () => void;
18
+ constructor(callback: () => void) {
19
+ this.callback = callback;
20
+ liveObservers.add(this);
21
+ }
22
+ observe() {}
23
+ unobserve() {}
24
+ disconnect() {
25
+ liveObservers.delete(this);
26
+ }
27
+ }
28
+ const resizeAll = () => {
29
+ for (const observer of liveObservers) observer.callback();
30
+ };
31
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub;
32
+
33
+ // happy-dom has no layout: a tab sits at 100px per position and is 90px wide.
34
+ const TAB_STEP = 100;
35
+ const TAB_WIDTH = 90;
36
+ const tabIndex = (element: HTMLElement) =>
37
+ element.classList.contains("dv-tab")
38
+ ? [...(element.parentElement?.querySelectorAll(":scope > .dv-tab") ?? [])].indexOf(element)
39
+ : -1;
40
+ const layout = {
41
+ offsetLeft: {
42
+ get(this: HTMLElement) {
43
+ return Math.max(tabIndex(this), 0) * TAB_STEP;
44
+ },
45
+ },
46
+ offsetWidth: {
47
+ get(this: HTMLElement) {
48
+ return tabIndex(this) >= 0 ? TAB_WIDTH : 0;
49
+ },
50
+ },
51
+ };
52
+ const original = {
53
+ offsetLeft: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetLeft"),
54
+ offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"),
55
+ };
56
+ beforeAll(() => {
57
+ // The shown-tab-only rule lives in dock.css; apply it so computed display is what a user sees.
58
+ const style = document.createElement("style");
59
+ style.textContent = readFileSync(path.join(import.meta.dirname, "dock.css"), "utf8").replace(
60
+ /@import[^;]*;/,
61
+ "",
62
+ );
63
+ document.head.append(style);
64
+ Object.defineProperties(HTMLElement.prototype, {
65
+ offsetLeft: { ...layout.offsetLeft, configurable: true },
66
+ offsetWidth: { ...layout.offsetWidth, configurable: true },
67
+ });
68
+ });
69
+ afterAll(() => {
70
+ for (const [key, descriptor] of Object.entries(original)) {
71
+ if (descriptor) Object.defineProperty(HTMLElement.prototype, key, descriptor);
72
+ }
73
+ });
74
+
75
+ let root: Root | null = null;
76
+ let host: HTMLElement;
77
+
78
+ beforeEach(() => {
79
+ localStorage.clear();
80
+ vi.useFakeTimers();
81
+ host = document.createElement("div");
82
+ document.body.append(host);
83
+ root = createRoot(host);
84
+ act(() => {
85
+ root?.render(
86
+ <Dock.Root projectId="p1">
87
+ {PANEL_IDS.map((id) => (
88
+ <Dock.Panel key={id} id={id}>
89
+ <div>{id}</div>
90
+ </Dock.Panel>
91
+ ))}
92
+ </Dock.Root>,
93
+ );
94
+ });
95
+ });
96
+
97
+ afterEach(() => {
98
+ act(() => root?.unmount());
99
+ root = null;
100
+ document.body.innerHTML = "";
101
+ vi.useRealTimers();
102
+ });
103
+
104
+ const tab = (id: PanelId) => host.querySelector<HTMLElement>(`[data-tab-panel-id="${id}"]`);
105
+ const stripOf = (id: PanelId) => tab(id)?.closest<HTMLElement>(".dv-tabs-container");
106
+ const groupOf = (id: PanelId) => tab(id)?.closest<HTMLElement>(".dv-groupview");
107
+ const shows = (id: PanelId, part: "icon" | "close") => {
108
+ const element = tab(id)?.querySelector(`.hf-dock-tab-${part}`);
109
+ return element ? getComputedStyle(element).display !== "none" : false;
110
+ };
111
+
112
+ async function activate(id: PanelId) {
113
+ // dockview settles the active panel on a microtask; `act(async)` waits for it.
114
+ await act(async () => useDockLayoutStore.getState().activatePanel(id));
115
+ }
116
+
117
+ describe("dock tabs", () => {
118
+ it("draw the type icon and close glyph on the shown tab only", () => {
119
+ for (const id of ["design", "compositions"] as const) {
120
+ expect(shows(id, "icon")).toBe(true);
121
+ expect(shows(id, "close")).toBe(true);
122
+ }
123
+ for (const id of ["layers", "renders", "variables", "assets", "code", "catalog"] as const) {
124
+ expect(shows(id, "icon")).toBe(false);
125
+ expect(shows(id, "close")).toBe(false);
126
+ }
127
+ });
128
+
129
+ it("move the icon to the newly shown tab and drop it from the old one", async () => {
130
+ await activate("layers");
131
+ expect(shows("layers", "icon")).toBe(true);
132
+ expect(shows("design", "icon")).toBe(false);
133
+ expect(shows("design", "close")).toBe(false);
134
+ });
135
+
136
+ it("name the close control after the panel, and close only that panel", async () => {
137
+ const close = tab("design")?.querySelector<HTMLElement>(".hf-dock-tab-close");
138
+ expect(close?.getAttribute("aria-label")).toBe("Close Design");
139
+ await act(async () => close?.click());
140
+ expect(useDockLayoutStore.getState().openPanels.has("design")).toBe(false);
141
+ expect(useDockLayoutStore.getState().openPanels.has("layers")).toBe(true);
142
+ });
143
+ });
144
+
145
+ describe("dock strip actions", () => {
146
+ const groupsWithActions = () =>
147
+ [...host.querySelectorAll(".hf-dock-strip-actions")].map((actions) =>
148
+ actions.closest(".dv-groupview"),
149
+ );
150
+
151
+ it("sit on the active group's strip only, and follow the active group", async () => {
152
+ await activate("design");
153
+ expect(groupsWithActions()).toEqual([groupOf("design")]);
154
+ const labels = [...host.querySelectorAll(".hf-dock-strip-actions button")].map((button) =>
155
+ button.getAttribute("aria-label"),
156
+ );
157
+ expect(labels).toEqual(["Panel menu", "Maximize panel", "Close group"]);
158
+ await activate("assets");
159
+ expect(groupsWithActions()).toEqual([groupOf("assets")]);
160
+ });
161
+ });
162
+
163
+ describe("dock tab fill", () => {
164
+ it("is one element per strip, under the shown tab, and follows a switch", async () => {
165
+ for (const strip of host.querySelectorAll(".dv-tabs-container")) {
166
+ expect(strip.querySelectorAll(".hf-dock-tab-fill")).toHaveLength(1);
167
+ }
168
+ const fill = () => stripOf("design")?.querySelector<HTMLElement>(".hf-dock-tab-fill");
169
+ expect(fill()?.style.transform).toBe("translateX(0px)");
170
+ expect(fill()?.style.width).toBe(`${TAB_WIDTH}px`);
171
+ await activate("renders");
172
+ expect(stripOf("design")?.querySelectorAll(".hf-dock-tab-fill")).toHaveLength(1);
173
+ expect(fill()?.style.transform).toBe(`translateX(${2 * TAB_STEP}px)`);
174
+ });
175
+
176
+ it("keeps the shown tab whole when its strip narrows, as when the strip's actions appear", async () => {
177
+ const strip = stripOf("design");
178
+ if (!strip) throw new Error("no design strip");
179
+ await activate("variables");
180
+ // The four tabs fit, so the strip is unscrolled; then it narrows to two and a half tabs.
181
+ strip.scrollLeft = 0;
182
+ Object.defineProperty(strip, "clientWidth", { value: 1000, configurable: true });
183
+ act(() => resizeAll());
184
+ Object.defineProperty(strip, "clientWidth", { value: 250, configurable: true });
185
+ act(() => resizeAll());
186
+ expect(strip.scrollLeft).toBe(3 * TAB_STEP + TAB_WIDTH + 1 - 250);
187
+ });
188
+
189
+ it("leaves a strip the user scrolled alone when something else in the dock changes", async () => {
190
+ const strip = stripOf("design");
191
+ if (!strip) throw new Error("no design strip");
192
+ await activate("variables");
193
+ Object.defineProperty(strip, "clientWidth", { value: 250, configurable: true });
194
+ act(() => resizeAll());
195
+ // Scrolled back by hand to look at the first tabs, then another group takes focus.
196
+ strip.scrollLeft = 0;
197
+ await activate("assets");
198
+ act(() => resizeAll());
199
+ expect(strip.scrollLeft).toBe(0);
200
+ });
201
+
202
+ it("keeps a hand scroll through another group being maximised and restored", async () => {
203
+ const strip = stripOf("design");
204
+ if (!strip) throw new Error("no design strip");
205
+ await activate("variables");
206
+ Object.defineProperty(strip, "clientWidth", { value: 250, configurable: true });
207
+ act(() => resizeAll());
208
+ strip.scrollLeft = 0;
209
+ // Hidden while another group is maximised; the browser keeps a hidden strip's scroll.
210
+ Object.defineProperty(strip, "clientWidth", { value: 0, configurable: true });
211
+ act(() => resizeAll());
212
+ Object.defineProperty(strip, "clientWidth", { value: 250, configurable: true });
213
+ act(() => resizeAll());
214
+ expect(strip.scrollLeft).toBe(0);
215
+ });
216
+
217
+ it("leaves a hand scroll alone when its strip widens", async () => {
218
+ const strip = stripOf("design");
219
+ if (!strip) throw new Error("no design strip");
220
+ await activate("variables");
221
+ Object.defineProperty(strip, "clientWidth", { value: 150, configurable: true });
222
+ act(() => resizeAll());
223
+ strip.scrollLeft = 0;
224
+ // Wider, but the shown tab is still out of view where the user left the strip.
225
+ Object.defineProperty(strip, "clientWidth", { value: 200, configurable: true });
226
+ act(() => resizeAll());
227
+ expect(strip.scrollLeft).toBe(0);
228
+ });
229
+
230
+ it("reveals the shown tab when its strip comes back narrower than it left", async () => {
231
+ const strip = stripOf("design");
232
+ if (!strip) throw new Error("no design strip");
233
+ await activate("variables");
234
+ strip.scrollLeft = 0;
235
+ Object.defineProperty(strip, "clientWidth", { value: 1000, configurable: true });
236
+ act(() => resizeAll());
237
+ Object.defineProperty(strip, "clientWidth", { value: 0, configurable: true });
238
+ act(() => resizeAll());
239
+ Object.defineProperty(strip, "clientWidth", { value: 250, configurable: true });
240
+ act(() => resizeAll());
241
+ expect(strip.scrollLeft).toBe(3 * TAB_STEP + TAB_WIDTH + 1 - 250);
242
+ });
243
+
244
+ it("reveals the shown tab when a panel beside it closes and it moves", async () => {
245
+ const strip = stripOf("design");
246
+ if (!strip) throw new Error("no design strip");
247
+ await activate("variables");
248
+ Object.defineProperty(strip, "clientWidth", { value: 150, configurable: true });
249
+ act(() => resizeAll());
250
+ strip.scrollLeft = 0;
251
+ await act(async () => useDockLayoutStore.getState().togglePanel("design"));
252
+ // Variables moved from the fourth place to the third; its right edge is at 290.
253
+ expect(strip.scrollLeft).toBe(2 * TAB_STEP + TAB_WIDTH + 1 - 150);
254
+ });
255
+ });