@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,222 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ // Proves the package's public exports, not internal hook paths, are enough
4
+ // for a host to mount hand editing outside EditorShell: an edit writes
5
+ // through the history path, Undo restores it, and a 409 shows the banner.
6
+ import { act, createElement, useRef } from "react";
7
+ import { createRoot, type Root } from "react-dom/client";
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ // Backend/storage mocking only, standing in for a host's own server and
10
+ // persistence — not part of the host's integration code under test.
11
+ import { studioFileContentVersion } from "./utils/studioFileVersion";
12
+ import { createMemoryEditHistoryStorage } from "./utils/editHistoryStorage";
13
+ import {
14
+ flushStudioPendingEdits,
15
+ useTimelineEditing,
16
+ useProjectFileWriter,
17
+ usePersistentEditHistory,
18
+ useEditHistoryActions,
19
+ ExternalFileConflictBanner,
20
+ StudioFileConflictError,
21
+ type ExternalFileChangeCoordinatorHandle,
22
+ type TimelineElement,
23
+ } from "./index";
24
+
25
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
26
+
27
+ const ORIGINAL_HTML = '<div id="clip" data-start="0" data-track-index="0"></div>';
28
+
29
+ let root: Root | null = null;
30
+ afterEach(() => {
31
+ act(() => root?.unmount());
32
+ document.body.innerHTML = "";
33
+ vi.unstubAllGlobals();
34
+ });
35
+
36
+ /** A minimal disk with etag-guarded PUT, faithful enough to exercise a real
37
+ * conflict: a stale If-Match gets a 409 with the current version + content. */
38
+ function stubHostProjectDisk(
39
+ initial: Record<string, string>,
40
+ options?: { editElsewhereAfterFirstRead?: boolean },
41
+ ) {
42
+ const content = { ...initial };
43
+ const readsSeen = new Set<string>();
44
+ // The real backend's etag is a content hash (useProjectFileWriter derives
45
+ // If-Match the same way) — an arbitrary counter here would make every
46
+ // write from a host that hasn't just read the file look stale.
47
+ const versionOf = (path: string) => studioFileContentVersion(content[path]);
48
+
49
+ async function handlePut(path: string, init: RequestInit | undefined) {
50
+ const ifMatch = new Headers(init?.headers).get("If-Match");
51
+ const currentVersion = await versionOf(path);
52
+ if (ifMatch && ifMatch !== currentVersion) {
53
+ return new Response(JSON.stringify({ currentVersion, currentContent: content[path] }), {
54
+ status: 409,
55
+ });
56
+ }
57
+ content[path] = String(init?.body ?? "");
58
+ return new Response(JSON.stringify({ version: await versionOf(path) }), { status: 200 });
59
+ }
60
+
61
+ async function handleGet(path: string) {
62
+ const served = content[path];
63
+ // Simulates another writer landing between this read and the caller's
64
+ // own write: content moves on right after being served once, so the
65
+ // caller's hash of what it just read no longer matches disk.
66
+ if (options?.editElsewhereAfterFirstRead && !readsSeen.has(path)) {
67
+ readsSeen.add(path);
68
+ content[path] = `${served} <!--edited elsewhere-->`;
69
+ }
70
+ return new Response(
71
+ JSON.stringify({ content: served, version: await studioFileContentVersion(served) }),
72
+ { status: 200 },
73
+ );
74
+ }
75
+
76
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
77
+ const match = String(input).match(/\/files\/([^?]+)/);
78
+ const path = match ? decodeURIComponent(match[1]) : "";
79
+ return (init?.method ?? "GET") === "PUT" ? handlePut(path, init) : handleGet(path);
80
+ });
81
+ vi.stubGlobal("fetch", fetchMock);
82
+ return { content, fetchMock };
83
+ }
84
+
85
+ type TimelineEditingHandle = {
86
+ handleTimelineElementMove: (
87
+ element: TimelineElement,
88
+ updates: { start: number; track: number },
89
+ ) => Promise<void>;
90
+ };
91
+
92
+ function mountHost(
93
+ clip: TimelineElement,
94
+ iframe: HTMLIFrameElement,
95
+ ): {
96
+ timelineEditing: TimelineEditingHandle;
97
+ undo: () => Promise<void>;
98
+ bannerHost: HTMLDivElement;
99
+ } {
100
+ let timelineEditing: TimelineEditingHandle | null = null;
101
+ let undo: (() => Promise<void>) | null = null;
102
+ let conflict: ExternalFileChangeCoordinatorHandle = {
103
+ blocked: null,
104
+ retry: async () => {},
105
+ useExternalFile: async () => {},
106
+ keepStudioFile: async () => {},
107
+ };
108
+ const bannerHost = document.createElement("div");
109
+ document.body.append(bannerHost);
110
+ const bannerRoot = createRoot(bannerHost);
111
+ // Created once: a fresh storage object on every render would change
112
+ // usePersistentEditHistory's effect deps each time and loop forever.
113
+ const storage = createMemoryEditHistoryStorage();
114
+
115
+ function Harness() {
116
+ const writer = useProjectFileWriter({ projectId: "p1" });
117
+ const editHistory = usePersistentEditHistory({ projectId: "p1", storage });
118
+ const historyActions = useEditHistoryActions({
119
+ editHistory,
120
+ readOptionalProjectFile: writer.readOptionalProjectFile,
121
+ readProjectFile: writer.readProjectFile,
122
+ writeProjectFile: writer.writeProjectFile,
123
+ showToast: () => {},
124
+ syncHistoryPreviewAfterApply: async () => {},
125
+ // The pattern a host's own dom-edit queue would also drain through —
126
+ // this is the one gate that closes the undo/delete race (#4194).
127
+ waitForPendingDomEditSaves: async () => {
128
+ const result = await flushStudioPendingEdits();
129
+ if (result.status !== "clean") throw result.error;
130
+ },
131
+ });
132
+ undo = historyActions.undo;
133
+ const commitRef = useRef(null);
134
+ timelineEditing = useTimelineEditing({
135
+ projectId: "p1",
136
+ activeCompPath: "index.html",
137
+ timelineElements: [clip],
138
+ showToast: () => {},
139
+ writeProjectFile: async (path, content, expectedContent) => {
140
+ try {
141
+ await writer.writeProjectFile(path, content, expectedContent);
142
+ } catch (error) {
143
+ if (error instanceof StudioFileConflictError) {
144
+ conflict = {
145
+ ...conflict,
146
+ blocked: { status: "conflict", generation: 0, error, payload: null },
147
+ };
148
+ bannerRoot.render(createElement(ExternalFileConflictBanner, { coordinator: conflict }));
149
+ }
150
+ throw error;
151
+ }
152
+ },
153
+ observeProjectFileVersion: writer.observeProjectFileVersion,
154
+ recordEdit: editHistory.recordEdit,
155
+ reloadPreview: () => {},
156
+ previewIframeRef: { current: iframe },
157
+ pendingTimelineEditPathRef: { current: new Set<string>() },
158
+ uploadProjectFiles: async () => [],
159
+ handleDomZIndexReorderCommitRef: commitRef,
160
+ });
161
+ return null;
162
+ }
163
+
164
+ root = createRoot(document.createElement("div"));
165
+ act(() => root!.render(createElement(Harness)));
166
+ if (!timelineEditing) throw new Error("Expected hook to mount");
167
+ if (!undo) throw new Error("Expected undo to be exposed");
168
+ return { timelineEditing, undo, bannerHost };
169
+ }
170
+
171
+ describe("public export surface: a host mounting hand editing outside EditorShell", () => {
172
+ it("writes a move through the history path and undo restores it", async () => {
173
+ const disk = stubHostProjectDisk({ "index.html": ORIGINAL_HTML });
174
+ const iframe = document.createElement("iframe");
175
+ document.body.append(iframe);
176
+ iframe.contentDocument!.body.innerHTML = ORIGINAL_HTML;
177
+ const clip: TimelineElement = {
178
+ id: "clip",
179
+ domId: "clip",
180
+ tag: "div",
181
+ start: 0,
182
+ duration: 2,
183
+ track: 0,
184
+ };
185
+
186
+ const { timelineEditing, undo } = mountHost(clip, iframe);
187
+ await act(async () => {
188
+ await timelineEditing.handleTimelineElementMove(clip, { start: 5, track: 0 });
189
+ });
190
+ expect(disk.content["index.html"]).toContain('data-start="5"');
191
+
192
+ await act(async () => {
193
+ await undo();
194
+ });
195
+ expect(disk.content["index.html"]).toContain('data-start="0"');
196
+ });
197
+
198
+ it("surfaces the conflict banner on a 409", async () => {
199
+ stubHostProjectDisk({ "index.html": ORIGINAL_HTML }, { editElsewhereAfterFirstRead: true });
200
+ const iframe = document.createElement("iframe");
201
+ document.body.append(iframe);
202
+ iframe.contentDocument!.body.innerHTML = ORIGINAL_HTML;
203
+ const clip: TimelineElement = {
204
+ id: "clip",
205
+ domId: "clip",
206
+ tag: "div",
207
+ start: 0,
208
+ duration: 2,
209
+ track: 0,
210
+ };
211
+
212
+ const { timelineEditing, bannerHost } = mountHost(clip, iframe);
213
+
214
+ await act(async () => {
215
+ await expect(
216
+ timelineEditing.handleTimelineElementMove(clip, { start: 5, track: 0 }),
217
+ ).rejects.toBeInstanceOf(StudioFileConflictError);
218
+ });
219
+
220
+ expect(bannerHost.textContent).toContain("changed outside Studio");
221
+ });
222
+ });
package/src/index.ts CHANGED
@@ -1,5 +1,36 @@
1
+ // UI primitives
2
+ export { Button, buttonBase, buttonVariants } from "./components/ui/Button";
3
+ export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
4
+ export { IconButton } from "./components/ui/IconButton";
5
+ export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
6
+ export { Tooltip } from "./components/ui/Tooltip";
7
+ export { cn } from "./components/ui/cn";
8
+ export {
9
+ ContextMenu,
10
+ Menu,
11
+ MenuItem,
12
+ MenuRadioGroup,
13
+ MenuRadioItem,
14
+ MenuSeparator,
15
+ MenuShortcut,
16
+ popupSurface,
17
+ } from "./components/ui/Menu";
18
+ export type { MenuItemTone, PopupPreviewState } from "./components/ui/Menu";
19
+ export { Popover } from "./components/ui/Popover";
20
+ export { Input, fieldBase, fieldText } from "./components/ui/Input";
21
+ export type { InputProps } from "./components/ui/Input";
22
+ export { NumberField } from "./components/ui/NumberField";
23
+ export type { NumberFieldProps } from "./components/ui/NumberField";
24
+ export { Select } from "./components/ui/Select";
25
+ export type { SelectOption, SelectProps } from "./components/ui/Select";
26
+ export { Slider } from "./components/ui/Slider";
27
+ export type { SliderProps } from "./components/ui/Slider";
28
+ export { Toggle } from "./components/ui/Toggle";
29
+ export type { ToggleProps } from "./components/ui/Toggle";
30
+
1
31
  // NLE Layout
2
32
  export { EditorShell } from "./components/EditorShell";
33
+ export type { EditorShellProps } from "./components/EditorShell";
3
34
  export { NLEPreview } from "./components/nle/NLEPreview";
4
35
  export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
5
36
  export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
@@ -19,6 +50,10 @@ export {
19
50
  } from "./player";
20
51
  export type { TimelineElement } from "./player";
21
52
 
53
+ // Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
54
+ export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
55
+ export type { PreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
56
+
22
57
  // Editor
23
58
  export { SourceEditor } from "./components/editor/SourceEditor";
24
59
  export { PropertyPanel } from "./components/editor/PropertyPanel";
@@ -37,6 +72,22 @@ export {
37
72
  } from "./components/editor/domEditingAgentPrompt";
38
73
  export type { AgentPromptElementInfo } from "./components/editor/domEditingAgentPrompt";
39
74
 
75
+ // Render queue
76
+ export { RenderQueue } from "./components/renders/RenderQueue";
77
+ export type { RenderQueueProps, CompositionDimensions } from "./components/renders/RenderQueue";
78
+ export { useRenderQueue } from "./components/renders/useRenderQueue";
79
+ export type { FfmpegStatus } from "./components/renders/useFfmpegStatus";
80
+ export type {
81
+ RenderJob,
82
+ ResolutionPreset,
83
+ StartRenderOptions,
84
+ } from "./components/renders/useRenderQueue";
85
+ export {
86
+ getPersistedRenderSettings,
87
+ persistRenderSettings,
88
+ } from "./components/renders/renderSettings";
89
+ export type { PersistedRenderSettings } from "./components/renders/renderSettings";
90
+
40
91
  // Hooks
41
92
  export { useElementPicker } from "./hooks/useElementPicker";
42
93
  export type { PickedElement } from "./hooks/useElementPicker";
@@ -45,3 +96,39 @@ export type { PickedElement } from "./hooks/useElementPicker";
45
96
  export { resolveSourceFile, applyPatch } from "./utils/sourcePatcher";
46
97
  export type { PatchOperation } from "./utils/sourcePatcher";
47
98
  export { parseStyleString, mergeStyleIntoTag, findElementBlock } from "./utils/htmlEditor";
99
+
100
+ // Timeline editing: Studio's own hand-edit path, undo/redo, the
101
+ // etag-guarded writer and the conflict banner, for a host mounting the
102
+ // timeline outside EditorShell.
103
+ export { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
104
+ export type { UsePersistentEditHistoryOptions } from "./hooks/usePersistentEditHistory";
105
+ export { useTimelineEditing } from "./hooks/useTimelineEditing";
106
+ export type { UseTimelineEditingOptions } from "./hooks/useTimelineEditingTypes";
107
+ // A host's own waitForPendingDomEditSaves must also call this, or undo/redo
108
+ // can race a write still in flight (see useTrackPendingTimelineEdit.ts).
109
+ export { flushStudioPendingEdits } from "./utils/studioPendingEdits";
110
+ export type { StudioPendingEditsDrainResult } from "./utils/studioPendingEdits";
111
+ export type {
112
+ CanEditTimelineElement,
113
+ TimelineEditPermission,
114
+ } from "./hooks/timelineEditPermission";
115
+ export { useEditHistoryActions } from "./hooks/useEditHistoryActions";
116
+ export type {
117
+ EditHistoryHandle,
118
+ UseEditHistoryActionsOptions,
119
+ } from "./hooks/useEditHistoryActions";
120
+ export { useProjectFileWriter } from "./hooks/useProjectFileWriter";
121
+ export type { UseProjectFileWriterOptions } from "./hooks/useProjectFileWriter";
122
+ // A host's writeProjectFile throws this on a 409; catch it to know when
123
+ // to show ExternalFileConflictBanner.
124
+ export { StudioFileConflictError } from "./utils/studioSaveDiagnostics";
125
+ export { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
126
+ export type {
127
+ ExternalFileChangeCoordinatorHandle,
128
+ ExternalFileChangeBlockedState,
129
+ } from "./hooks/useExternalFileChangeCoordinator";
130
+ export { TimelinePane } from "./components/nle/TimelinePane";
131
+ export type { TimelinePaneProps } from "./components/nle/TimelinePane";
132
+ export { TimelineEditProvider } from "./contexts/TimelineEditContext";
133
+ export type { TimelineEditCallbacks } from "./player/components/timelineCallbacks";
134
+ export type { BlockedTimelineEditIntent } from "./player/components/timelineEditing";
@@ -29,7 +29,7 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
29
29
  }: AutomationSelectionMenuProps) {
30
30
  const menuRef = useContextMenuDismiss(onClose);
31
31
  const row =
32
- "block w-full px-2 py-1 text-left text-[11px] text-panel-text-1 hover:bg-panel-bg-3 disabled:opacity-40";
32
+ "block w-full px-2 py-1 text-left text-[11px] text-panel-text-1 hover:bg-panel-hover disabled:opacity-40";
33
33
  // Same edge-clamping precedent as TrackGapContextMenu: without it a
34
34
  // right-click near the bottom/right of the timeline renders this menu
35
35
  // partially off-screen.
@@ -448,7 +448,7 @@ export const Timeline = memo(function Timeline({
448
448
  return (
449
449
  <div
450
450
  ref={setContainerRef}
451
- aria-label="Timeline"
451
+ aria-label="Timeline track view"
452
452
  data-timeline-element-count={expandedElements.length}
453
453
  className={`relative border-t select-none h-full overflow-hidden ${assetDrop.isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
454
454
  onMouseMove={updateRazorGuide}
@@ -179,8 +179,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
179
179
  left: props.contentOrigin + draggedClip.previewStart * props.pps,
180
180
  width: Math.max(draggedClip.element.duration * props.pps, 4),
181
181
  height: draggedClipHeight,
182
- border: "1px solid rgba(60,230,172,0.55)",
183
- background: "rgba(60,230,172,0.12)",
182
+ border: "1px solid color-mix(in srgb, var(--color-accent) 55%, transparent)",
183
+ background: "color-mix(in srgb, var(--color-accent) 12%, transparent)",
184
184
  borderRadius: 4,
185
185
  zIndex: 30,
186
186
  }}
@@ -199,8 +199,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
199
199
  left: props.contentOrigin + props.dropPreview.start * props.pps,
200
200
  width: DROP_PREVIEW_SECONDS * props.pps,
201
201
  height: TRACK_H - CLIP_Y * 2,
202
- border: "1px solid rgba(60,230,172,0.55)",
203
- background: "rgba(60,230,172,0.12)",
202
+ border: "1px solid color-mix(in srgb, var(--color-accent) 55%, transparent)",
203
+ background: "color-mix(in srgb, var(--color-accent) 12%, transparent)",
204
204
  borderRadius: 4,
205
205
  zIndex: 30,
206
206
  }}
@@ -71,7 +71,6 @@ export const TimelineClip = memo(function TimelineClip({
71
71
  isSelected ? "is-selected" : "",
72
72
  isHovered ? "is-hovered" : "",
73
73
  isDragging ? "is-dragging" : "",
74
- showDefaultText ? "" : "is-micro",
75
74
  isAudioTimelineElement(el) ? "is-audio" : "",
76
75
  ]
77
76
  .filter((className) => className.length > 0)
@@ -5,7 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
5
5
  import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
6
6
  import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
8
- import { TimelineTrackHeader } from "./TimelineTrackHeader";
8
+ import { TimelineTrackHeader, gutterFill } from "./TimelineTrackHeader";
9
9
  import { defaultTimelineTheme } from "./timelineTheme";
10
10
  import { type TimelineElement } from "../store/playerStore";
11
11
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -794,10 +794,6 @@ describe("TimelineTrackHeader", () => {
794
794
  });
795
795
  expect(header()?.style.paddingLeft).toBe("14px");
796
796
  expect(header()?.style.borderLeft).toContain("2px");
797
- // And a lighter gutter, so the row reads as sitting INSIDE its group
798
- // rather than beside it. Overlaid on the theme's own fill rather than a
799
- // hard-coded colour, so it follows whatever the gutter is.
800
- expect(header()?.style.background).toContain("linear-gradient");
801
797
  act(() => view.root.unmount());
802
798
  });
803
799
 
@@ -912,3 +908,28 @@ describe("TimelineTrackHeader", () => {
912
908
  });
913
909
  });
914
910
  });
911
+
912
+ // AD147: a host themes the timeline by overriding --timeline-* on theme.css,
913
+ // so the rendered gutter must carry the CSS variable, not a baked-in colour.
914
+ describe("theming", () => {
915
+ it("reads the gutter background and border from timeline theme tokens", () => {
916
+ const view = renderHeader({});
917
+ const header = view.host.querySelector<HTMLElement>('[role="rowheader"]');
918
+ expect(header?.style.background).toBe(defaultTimelineTheme.gutterBackground);
919
+ expect(header?.style.background).toContain("var(--timeline-gutter-bg)");
920
+ expect(header?.style.borderRight).toContain(defaultTimelineTheme.gutterBorder);
921
+ act(() => view.root.unmount());
922
+ });
923
+
924
+ // happy-dom's `background` shorthand garbles a `var()` layer next to
925
+ // `linear-gradient(...)` (verified), so this checks the same value through
926
+ // the pure function instead of the broken CSSOM roundtrip.
927
+ it("overlays the group-member tint on the themed gutter, not a hard-coded colour", () => {
928
+ const filled = gutterFill(defaultTimelineTheme.gutterBackground, true);
929
+ expect(filled).toContain("linear-gradient");
930
+ expect(filled.endsWith(defaultTimelineTheme.gutterBackground)).toBe(true);
931
+ expect(gutterFill(defaultTimelineTheme.gutterBackground, false)).toBe(
932
+ defaultTimelineTheme.gutterBackground,
933
+ );
934
+ });
935
+ });
@@ -34,7 +34,7 @@ const GROUP_MEMBER_INDENT = 14;
34
34
  const GROUP_MEMBER_TINT = "rgba(255,255,255,0.035)";
35
35
 
36
36
  /** The gutter fill for a row, tinted when it belongs to a group. */
37
- function gutterFill(base: string, isGroupMember: boolean): string {
37
+ export function gutterFill(base: string, isGroupMember: boolean): string {
38
38
  return isGroupMember
39
39
  ? `linear-gradient(${GROUP_MEMBER_TINT}, ${GROUP_MEMBER_TINT}), ${base}`
40
40
  : base;
@@ -9,10 +9,10 @@ import { getTrackStyle } from "./timelineIcons";
9
9
  describe("getTimelineTrackStyle", () => {
10
10
  it("uses one neutral clip style for every timeline tag", () => {
11
11
  const expectedStyle = {
12
- clip: "rgba(255,255,255,0.055)",
13
- clipActive: "rgba(60,230,172,0.16)",
14
- accent: "#3CE6AC",
15
- label: "rgba(255,255,255,0.5)",
12
+ clip: "var(--timeline-track-clip-fill)",
13
+ clipActive: "var(--timeline-track-clip-active)",
14
+ accent: "var(--color-accent)",
15
+ label: "var(--timeline-track-label)",
16
16
  };
17
17
 
18
18
  expect(getTimelineTrackStyle("video")).toEqual(expectedStyle);
@@ -36,43 +36,43 @@ export interface TimelineTheme {
36
36
  }
37
37
 
38
38
  const TRACK_STYLE: TimelineTrackStyle = {
39
- clip: "rgba(255,255,255,0.055)",
40
- clipActive: "rgba(60,230,172,0.16)",
41
- accent: "#3CE6AC",
42
- label: "rgba(255,255,255,0.5)",
39
+ clip: "var(--timeline-track-clip-fill)",
40
+ clipActive: "var(--timeline-track-clip-active)",
41
+ accent: "var(--color-accent)",
42
+ label: "var(--timeline-track-label)",
43
43
  };
44
44
 
45
+ // Every field reads a CSS custom property (declared in styles/theme.css)
46
+ // rather than a literal, so a host themes the timeline the same way it
47
+ // themes the rest of Studio: by overriding the token, not this object.
45
48
  export const defaultTimelineTheme: TimelineTheme = {
46
- // Near-black card surfaces: the panels sit dark while the shell canvas
47
- // between them is a step LIGHTER (#18181B), so the gaps read as visible
48
- // seams around dark cards (CapCut-style).
49
- shellBackground: "#0A0A0B",
50
- shellBorder: "rgba(255,255,255,0.05)",
51
- rulerBorder: "rgba(255,255,255,0.16)",
52
- // All track lanes use a single uniform color — one step lighter than the panel
53
- // surface (#0A0A0B) so lanes are visibly distinct from the ruler/shell.
54
- rowBackground: "#101014",
55
- rowBorder: "rgba(255,255,255,0.06)",
56
- gutterBackground: "#0E0F12",
57
- gutterBorder: "rgba(255,255,255,0.10)",
58
- textPrimary: "rgba(255,255,255,0.92)",
59
- textSecondary: "rgba(255,255,255,0.62)",
60
- tickText: "rgba(255,255,255,0.34)",
61
- tickMajor: "rgba(255,255,255,0.10)",
62
- tickMinor: "rgba(255,255,255,0.06)",
63
- clipBackground: "#141922",
64
- clipBackgroundActive: "#181e28",
65
- clipBorder: "rgba(255,255,255,0.10)",
66
- clipBorderHover: "rgba(255,255,255,0.18)",
67
- clipBorderActive: "rgba(255,255,255,0.24)",
49
+ shellBackground: "var(--timeline-shell-bg)",
50
+ shellBorder: "var(--timeline-shell-border)",
51
+ rulerBorder: "var(--timeline-ruler-border)",
52
+ rowBackground: "var(--timeline-row-bg)",
53
+ rowBorder: "var(--timeline-row-border)",
54
+ gutterBackground: "var(--timeline-gutter-bg)",
55
+ gutterBorder: "var(--timeline-gutter-border)",
56
+ textPrimary: "var(--timeline-text-primary)",
57
+ textSecondary: "var(--timeline-text-secondary)",
58
+ tickText: "var(--timeline-tick-text)",
59
+ tickMajor: "var(--timeline-tick-major)",
60
+ tickMinor: "var(--timeline-tick-minor)",
61
+ clipBackground: "var(--timeline-clip-bg)",
62
+ clipBackgroundActive: "var(--timeline-clip-bg-active)",
63
+ clipBorder: "var(--timeline-clip-border)",
64
+ clipBorderHover: "var(--timeline-clip-border-hover)",
65
+ clipBorderActive: "var(--timeline-clip-border-active)",
66
+ // Shadows stay literal: a light host still wants a dark contact shadow, so
67
+ // these are not part of the surface/hairline/text/clip-colour theming ask.
68
68
  clipShadow: "none",
69
69
  clipShadowHover: "0 2px 8px rgba(0,0,0,0.2)",
70
70
  clipShadowActive: "0 2px 8px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.04)",
71
71
  clipShadowDragging: "0 8px 24px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.06)",
72
- handleColor: "rgba(255,255,255,0.2)",
73
- panelResizeSeam: "rgba(255,255,255,0.12)",
74
- panelResizeActive: "rgba(255,255,255,0.24)",
75
- clipRadius: "8px",
72
+ handleColor: "var(--timeline-handle)",
73
+ panelResizeSeam: "var(--timeline-resize-seam)",
74
+ panelResizeActive: "var(--timeline-resize-active)",
75
+ clipRadius: "var(--radius-lg)",
76
76
  };
77
77
 
78
78
  export function getTimelineTrackStyle(_tag: string): TimelineTrackStyle {
@@ -17,6 +17,8 @@ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
17
17
  "[role='checkbox']",
18
18
  "[role='combobox']",
19
19
  "[role='menuitem']",
20
+ // Base UI's menu radio item is a `<div>`, so `button` above no longer catches it.
21
+ "[role='menuitemradio']",
20
22
  "[role='radio']",
21
23
  "[role='slider']",
22
24
  "[role='spinbutton']",
@@ -14,7 +14,7 @@ describe("timeline performance diagnostics", () => {
14
14
 
15
15
  it("reads mounted resources without mutating the timeline", () => {
16
16
  document.body.innerHTML = `
17
- <div aria-label="Timeline" data-timeline-scheduler-queued="3"
17
+ <div aria-label="Timeline track view" data-timeline-scheduler-queued="3"
18
18
  data-timeline-scheduler-active="2" data-timeline-cache-bytes="4096">
19
19
  <div data-timeline-row><div data-clip="true"></div><div data-clip="true"></div></div>
20
20
  <div data-timeline-row><div data-clip="true"></div></div>
@@ -41,7 +41,8 @@ describe("timeline performance diagnostics", () => {
41
41
  });
42
42
 
43
43
  it("returns the zero baseline after unmount or reset removes the DOM", () => {
44
- document.body.innerHTML = '<div aria-label="Timeline"><div data-clip="true"></div></div>';
44
+ document.body.innerHTML =
45
+ '<div aria-label="Timeline track view"><div data-clip="true"></div></div>';
45
46
  expect(readTimelinePerformanceDiagnostics().mountedClipRoots).toBe(1);
46
47
 
47
48
  document.body.replaceChildren();
@@ -69,7 +69,7 @@ function sumDataAttribute(root: ParentNode, selector: string, dataKey: string):
69
69
  export function readTimelinePerformanceDiagnostics(
70
70
  root: ParentNode = document,
71
71
  ): Readonly<TimelinePerformanceDiagnostics> {
72
- const timelineRoots = root.querySelectorAll<HTMLElement>('[aria-label="Timeline"]');
72
+ const timelineRoots = root.querySelectorAll<HTMLElement>('[aria-label="Timeline track view"]');
73
73
  let mountedTimelineDescendants = 0;
74
74
  for (const timelineRoot of timelineRoots) {
75
75
  mountedTimelineDescendants += timelineRoot.querySelectorAll("*").length;
@@ -0,0 +1,75 @@
1
+ // @vitest-environment jsdom
2
+ import { act, StrictMode, useCallback, useEffect, useRef } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { expect, it } from "vitest";
5
+ import { useOwnPreviewIframe, usePreviewIframeStore } from "./previewIframeStore";
6
+
7
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
8
+
9
+ // Mirrors NLEContext.tsx: a ref callback pushes the element on attach
10
+ // (~L126, onIframeLoad) and a separate mount/dep effect re-pushes the SAME
11
+ // ref value (~L304-306, deps include an unrelated counter like refreshKey).
12
+ function Child({ tick }: { tick: number }) {
13
+ const iframeRef = useRef<HTMLIFrameElement | null>(null);
14
+ const setIframe = usePreviewIframeStore((s) => s.setIframe);
15
+
16
+ const handleRef = useCallback(
17
+ (el: HTMLIFrameElement | null) => {
18
+ iframeRef.current = el;
19
+ setIframe(el);
20
+ },
21
+ [setIframe],
22
+ );
23
+
24
+ useEffect(() => {
25
+ setIframe(iframeRef.current);
26
+ }, [tick, setIframe]);
27
+
28
+ return <iframe ref={handleRef} />;
29
+ }
30
+
31
+ function Owner({ tick }: { tick: number }) {
32
+ useOwnPreviewIframe();
33
+ return <Child tick={tick} />;
34
+ }
35
+
36
+ it("does not notify raw subscribers for a repeated setIframe(sameValue)", () => {
37
+ let notifyCount = 0;
38
+ const unsubscribe = usePreviewIframeStore.subscribe(() => {
39
+ notifyCount += 1;
40
+ });
41
+
42
+ const container = document.createElement("div");
43
+ const root = createRoot(container);
44
+ act(() => {
45
+ root.render(
46
+ <StrictMode>
47
+ <Owner tick={0} />
48
+ </StrictMode>,
49
+ );
50
+ });
51
+ // A dep bump on the mount effect with the iframe DOM node unchanged -
52
+ // the shape NLEContext hits every time refreshKey moves without a new
53
+ // <iframe>.
54
+ act(() => {
55
+ root.render(
56
+ <StrictMode>
57
+ <Owner tick={1} />
58
+ </StrictMode>,
59
+ );
60
+ });
61
+
62
+ const iframe = usePreviewIframeStore.getState().iframe;
63
+ expect(iframe).not.toBeNull();
64
+
65
+ // 3 real Object.is-distinct transitions happen here: the ref-callback's
66
+ // null -> element attach, then StrictMode's mount-effect replay drives
67
+ // useOwnPreviewIframe's unmount cleanup (element -> null) and the
68
+ // re-run mount effect (null -> element) once each. Measured on the
69
+ // unfixed store: 7 (four of those are the SAME value re-pushed by the
70
+ // ref callback's own redundant effect run and the tick-bump update).
71
+ expect(notifyCount).toBe(3);
72
+
73
+ act(() => root.unmount());
74
+ unsubscribe();
75
+ });