@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
@@ -1,10 +1,10 @@
1
1
  import type { MouseEvent } from "react";
2
- import { RotateCcw, RotateCw, Camera } from "../icons/SystemIcons";
3
- import { getHistoryShortcutLabel } from "../utils/studioHelpers";
2
+ import { Camera } from "../icons/SystemIcons";
4
3
  import { useStudioShellContext } from "../contexts/StudioContext";
5
4
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
6
5
  import { trackStudioEvent } from "../utils/studioTelemetry";
7
- import { Tooltip } from "./ui";
6
+ import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./ui";
7
+ import { Dock } from "./dock/Dock";
8
8
 
9
9
  export interface StudioHeaderProps {
10
10
  captureFrameHref: string;
@@ -40,8 +40,8 @@ function HyperframesLogo() {
40
40
  y2="37.482"
41
41
  gradientUnits="userSpaceOnUse"
42
42
  >
43
- <stop stopColor="#06E3FA" />
44
- <stop offset="1" stopColor="#4FDB5E" />
43
+ <stop stopColor="var(--color-brand-gradient-from)" />
44
+ <stop offset="1" stopColor="var(--color-brand-gradient-to)" />
45
45
  </linearGradient>
46
46
  <linearGradient
47
47
  id="hf-g1"
@@ -51,8 +51,8 @@ function HyperframesLogo() {
51
51
  y2="6.303"
52
52
  gradientUnits="userSpaceOnUse"
53
53
  >
54
- <stop stopColor="#06E3FA" />
55
- <stop offset="1" stopColor="#4FDB5E" />
54
+ <stop stopColor="var(--color-brand-gradient-from)" />
55
+ <stop offset="1" stopColor="var(--color-brand-gradient-to)" />
56
56
  </linearGradient>
57
57
  </defs>
58
58
  {/* heygen label */}
@@ -141,16 +141,15 @@ function HyperframesLogo() {
141
141
  /**
142
142
  * Does the header's Inspector button open the panel, or close it?
143
143
  *
144
- * Takes the EFFECTIVE collapse state, so a panel the window has railed away
145
- * counts as closed even though the user's stored intent still says open. The
146
- * argument name is the guard: passing raw intent here is the bug this exists
147
- * to keep out.
144
+ * The dock has no separate "railed by window width" state (it shrinks panels,
145
+ * never auto-hides the group), so `rightCollapsed` here is already the state
146
+ * that decides whether the panel is actually showing.
148
147
  */
149
148
  export function shouldOpenInspector(
150
- effectiveRightCollapsed: boolean,
149
+ rightCollapsed: boolean,
151
150
  inspectorPanelActive: boolean,
152
151
  ): boolean {
153
- return effectiveRightCollapsed || !inspectorPanelActive;
152
+ return rightCollapsed || !inspectorPanelActive;
154
153
  }
155
154
 
156
155
  // fallow-ignore-next-line complexity
@@ -164,163 +163,121 @@ export function StudioHeader({
164
163
  inspectorPanelActive,
165
164
  onExport,
166
165
  }: StudioHeaderProps) {
167
- const { projectId, editHistory, handleUndo, handleRedo, renderQueue } = useStudioShellContext();
168
- // effectiveRightCollapsed, not the raw intent: in the auto-railed state the
169
- // intent is still "open" while the panel is hidden, so branching on intent
170
- // made this button write rightCollapsed=true — and that value is synced into
171
- // the shareable Studio URL, so a dead click would rewrite a link.
172
- const { effectiveRightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
166
+ const { projectId, renderQueue } = useStudioShellContext();
167
+ const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
173
168
  const isRendering = renderQueue.isRendering;
174
169
  const ffmpegMissing = renderQueue.ffmpegMissing;
175
170
 
176
171
  return (
177
- <div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 shrink-0">
172
+ <div className="flex items-center justify-between h-10 px-3 bg-surface border-b border-border-strong shrink-0">
178
173
  {/* Left: logo + project name */}
179
174
  <div className="flex items-center gap-3">
180
175
  <HyperframesLogo />
181
- <span className="text-neutral-700 select-none" aria-hidden="true">
176
+ <span className="text-text-5 select-none" aria-hidden="true">
182
177
  |
183
178
  </span>
184
- <span className="text-[11px] font-medium text-neutral-300">{projectId}</span>
179
+ <span className="text-step-11 font-medium text-text-1">{projectId}</span>
185
180
  </div>
186
181
  {/* Right: toolbar buttons */}
187
- <div className="flex items-center gap-1.5">
188
- <Tooltip
189
- label={
190
- editHistory.undoLabel
191
- ? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
192
- : `Undo (${getHistoryShortcutLabel("undo")})`
193
- }
194
- side="bottom"
195
- >
196
- <button
197
- type="button"
198
- onClick={() => {
199
- trackStudioEvent("toolbar_action", { action: "undo" });
200
- void handleUndo();
201
- }}
202
- disabled={!editHistory.canUndo}
203
- className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors active:scale-[0.98] ${
204
- editHistory.canUndo
205
- ? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
206
- : "text-neutral-700 cursor-default"
207
- }`}
208
- aria-label="Undo"
209
- >
210
- <RotateCcw size={14} />
211
- </button>
212
- </Tooltip>
213
- <Tooltip
214
- label={
215
- editHistory.redoLabel
216
- ? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
217
- : `Redo (${getHistoryShortcutLabel("redo")})`
218
- }
219
- side="bottom"
220
- >
221
- <button
222
- type="button"
223
- onClick={() => {
224
- trackStudioEvent("toolbar_action", { action: "redo" });
225
- void handleRedo();
226
- }}
227
- disabled={!editHistory.canRedo}
228
- className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors active:scale-[0.98] ${
229
- editHistory.canRedo
230
- ? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
231
- : "text-neutral-700 cursor-default"
232
- }`}
233
- aria-label="Redo"
234
- >
235
- <RotateCw size={14} />
236
- </button>
237
- </Tooltip>
238
- <Tooltip label={capturing ? "Capturing frame…" : "Capture current frame"} side="bottom">
239
- <a
240
- href={captureFrameHref}
241
- download={captureFrameFilename}
242
- onClick={(e) => {
243
- if (capturing) {
244
- e.preventDefault();
245
- return;
246
- }
247
- trackStudioEvent("toolbar_action", { action: "capture_frame" });
248
- handleCaptureFrameClick(e);
249
- }}
250
- onFocus={refreshCaptureFrameTime}
251
- onPointerDown={refreshCaptureFrameTime}
252
- aria-disabled={capturing || undefined}
253
- className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium transition-colors ${
254
- capturing
255
- ? "text-neutral-600 cursor-default"
256
- : "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 active:scale-[0.98]"
257
- }`}
258
- aria-label={capturing ? "Capturing frame" : "Capture current frame"}
259
- >
260
- {capturing ? (
261
- <svg
262
- className="animate-spin motion-reduce:animate-none h-3.5 w-3.5"
263
- viewBox="0 0 24 24"
264
- fill="none"
265
- aria-hidden="true"
266
- >
267
- <circle
268
- className="opacity-25"
269
- cx="12"
270
- cy="12"
271
- r="10"
182
+ <div className="flex items-center gap-3">
183
+ <div className="flex h-ctl items-center divide-x divide-border-strong overflow-hidden rounded-md border border-border-strong bg-bg-2">
184
+ <Tooltip label={capturing ? "Capturing frame…" : "Capture current frame"} side="bottom">
185
+ {/* A real download link, so it wears Button's recipe rather than being
186
+ one: `download` on an <a> is what saves the frame, and no <button>
187
+ can do that. `enabled:` never matches a link, so the ghost
188
+ variant's hover look is repeated unprefixed here. */}
189
+ <a
190
+ href={captureFrameHref}
191
+ download={captureFrameFilename}
192
+ onClick={(e) => {
193
+ if (capturing) {
194
+ e.preventDefault();
195
+ return;
196
+ }
197
+ trackStudioEvent("toolbar_action", { action: "capture_frame" });
198
+ handleCaptureFrameClick(e);
199
+ }}
200
+ onFocus={refreshCaptureFrameTime}
201
+ onPointerDown={refreshCaptureFrameTime}
202
+ aria-disabled={capturing || undefined}
203
+ className={cn(
204
+ buttonBase,
205
+ buttonVariants.ghost,
206
+ buttonSizes.md,
207
+ "h-full rounded-none max-[1000px]:px-2",
208
+ capturing
209
+ ? "text-text-4 cursor-default"
210
+ : "hover:bg-hover hover:text-text-0 active:scale-[0.98]",
211
+ )}
212
+ aria-label={capturing ? "Capturing frame" : "Capture current frame"}
213
+ >
214
+ {capturing ? (
215
+ <svg
216
+ className="animate-spin motion-reduce:animate-none h-3.5 w-3.5"
217
+ viewBox="0 0 24 24"
218
+ fill="none"
219
+ aria-hidden="true"
220
+ >
221
+ <circle
222
+ className="opacity-25"
223
+ cx="12"
224
+ cy="12"
225
+ r="10"
226
+ stroke="currentColor"
227
+ strokeWidth="4"
228
+ />
229
+ <path
230
+ className="opacity-75"
231
+ fill="currentColor"
232
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
233
+ />
234
+ </svg>
235
+ ) : (
236
+ <Camera size={14} />
237
+ )}
238
+ <span className="max-[1000px]:hidden">{capturing ? "Capturing…" : "Capture"}</span>
239
+ </a>
240
+ </Tooltip>
241
+ <Tooltip label="Inspector" side="bottom">
242
+ <Button
243
+ variant="ghost"
244
+ aria-label="Inspector"
245
+ aria-pressed={inspectorButtonActive}
246
+ className={cn(
247
+ "h-full rounded-none",
248
+ inspectorButtonActive && "bg-hover text-accent enabled:hover:text-accent",
249
+ )}
250
+ icon={
251
+ <svg
252
+ width="12"
253
+ height="12"
254
+ viewBox="0 0 24 24"
255
+ fill="none"
272
256
  stroke="currentColor"
273
- strokeWidth="4"
274
- />
275
- <path
276
- className="opacity-75"
277
- fill="currentColor"
278
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
279
- />
280
- </svg>
281
- ) : (
282
- <Camera size={14} />
283
- )}
284
- <span>{capturing ? "Capturing…" : "Capture"}</span>
285
- </a>
286
- </Tooltip>
287
- <Tooltip label="Inspector" side="bottom">
288
- <button
289
- type="button"
290
- onClick={() => {
291
- if (shouldOpenInspector(effectiveRightCollapsed, inspectorPanelActive)) {
292
- trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
293
- setRightPanelTab("design");
294
- setRightCollapsed(false);
295
- return;
257
+ strokeWidth="2"
258
+ >
259
+ <circle cx="12" cy="12" r="10" />
260
+ <polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
261
+ </svg>
296
262
  }
297
- trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
298
- // Keep the current selection when collapsing the Inspector — closing
299
- // the panel shouldn't deselect the element.
300
- setRightCollapsed(true);
301
- }}
302
- aria-pressed={inspectorButtonActive}
303
- className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors active:scale-[0.98] ${
304
- inspectorButtonActive
305
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
306
- : "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
307
- }`}
308
- aria-label="Inspector"
309
- >
310
- <svg
311
- width="12"
312
- height="12"
313
- viewBox="0 0 24 24"
314
- fill="none"
315
- stroke="currentColor"
316
- strokeWidth="2"
263
+ onClick={() => {
264
+ if (shouldOpenInspector(rightCollapsed, inspectorPanelActive)) {
265
+ trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
266
+ setRightPanelTab("design");
267
+ setRightCollapsed(false);
268
+ return;
269
+ }
270
+ trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
271
+ // Keep the current selection when collapsing the Inspector — closing
272
+ // the panel shouldn't deselect the element.
273
+ setRightCollapsed(true);
274
+ }}
317
275
  >
318
- <circle cx="12" cy="12" r="10" />
319
- <polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
320
- </svg>
321
- Inspector
322
- </button>
323
- </Tooltip>
276
+ Inspector
277
+ </Button>
278
+ </Tooltip>
279
+ </div>
280
+ <Dock.WindowMenu />
324
281
  <Tooltip
325
282
  label={
326
283
  ffmpegMissing
@@ -331,8 +288,9 @@ export function StudioHeader({
331
288
  }
332
289
  side="bottom"
333
290
  >
334
- <button
335
- type="button"
291
+ <Button
292
+ variant="primary"
293
+ data-testid="header-export"
336
294
  disabled={isRendering}
337
295
  onClick={() => {
338
296
  if (isRendering) return;
@@ -346,10 +304,9 @@ export function StudioHeader({
346
304
  if (ffmpegMissing) return;
347
305
  onExport?.();
348
306
  }}
349
- className="h-7 flex items-center gap-1.5 px-3 rounded-md text-[11px] font-semibold bg-studio-accent text-[#09090B] enabled:hover:brightness-110 transition-[filter,transform] enabled:active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
350
307
  >
351
308
  {isRendering ? "Rendering…" : "Export"}
352
- </button>
309
+ </Button>
353
310
  </Tooltip>
354
311
  </div>
355
312
  </div>
@@ -0,0 +1,202 @@
1
+ import { useCallback, type ReactNode } from "react";
2
+ import { SourceEditor } from "./editor/SourceEditor";
3
+ import { FileTree } from "./editor/FileTree";
4
+ import { MediaPreview } from "./MediaPreview";
5
+ import { AssetsTab } from "./sidebar/AssetsTab";
6
+ import { BlocksTab, type BlockPreviewInfo } from "./sidebar/BlocksTab";
7
+ import { CompositionsPanel } from "./sidebar/CompositionsPanel";
8
+ import { SidebarLintButton } from "./sidebar/SidebarLintButton";
9
+ import { Dock } from "./dock/Dock";
10
+ import { useDockLayoutStore } from "./dock/dockLayoutStore";
11
+ import { isMediaFile } from "../utils/mediaTypes";
12
+ import { useStudioShellContext } from "../contexts/StudioContext";
13
+ import { useFileManagerContext } from "../contexts/FileManagerContext";
14
+ import { getPersistedRenderSettings } from "./renders/renderSettings";
15
+
16
+ interface StudioLeftPanelsProps {
17
+ onSelectComposition: (comp: string) => void;
18
+ onAddBlock: (blockName: string) => void;
19
+ onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
20
+ onLint: () => void;
21
+ linting: boolean;
22
+ lintFindingCount?: number;
23
+ lintHasError?: boolean;
24
+ lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
25
+ onAddAssetToTimeline?: (path: string) => void;
26
+ onAddCompositionToTimeline?: (path: string) => void;
27
+ }
28
+
29
+ function PanelColumn({ footer, children }: { footer: ReactNode; children: ReactNode }) {
30
+ return (
31
+ <div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-950">
32
+ <div className="flex min-h-0 flex-1 flex-col">{children}</div>
33
+ {footer}
34
+ </div>
35
+ );
36
+ }
37
+
38
+ // fallow-ignore-next-line complexity
39
+ export function StudioLeftPanels({
40
+ onSelectComposition,
41
+ onAddBlock,
42
+ onPreviewBlock,
43
+ onLint,
44
+ linting,
45
+ lintFindingCount,
46
+ lintHasError,
47
+ lintFindingsByFile,
48
+ onAddAssetToTimeline,
49
+ onAddCompositionToTimeline,
50
+ }: StudioLeftPanelsProps) {
51
+ const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioShellContext();
52
+ const {
53
+ compositions,
54
+ assets,
55
+ editingFile,
56
+ fileTree,
57
+ revealSourceOffset,
58
+ handleFileSelect,
59
+ handleCreateFile,
60
+ handleCreateFolder,
61
+ handleDeleteFile,
62
+ handleRenameFile,
63
+ handleDuplicateFile,
64
+ handleMoveFile,
65
+ handleImportFiles,
66
+ handleContentChange,
67
+ } = useFileManagerContext();
68
+
69
+ const handleRenderComposition = useCallback(
70
+ async (comp: string) => {
71
+ // startRender refuses without an encoder and reports why as a row in the
72
+ // Renders panel, which may be closed or behind another tab: bring it up.
73
+ if (renderQueue.ffmpegMissing) {
74
+ useDockLayoutStore.getState().activatePanel("renders");
75
+ return;
76
+ }
77
+ await waitForPendingDomEditSaves();
78
+ const { format, quality, fps } = getPersistedRenderSettings();
79
+ await renderQueue.startRender({ composition: comp, format, quality, fps });
80
+ },
81
+ [renderQueue, waitForPendingDomEditSaves],
82
+ );
83
+
84
+ const importFiles = async (files: FileList, dir?: string) => {
85
+ await handleImportFiles(files, dir);
86
+ };
87
+ const lintButton = (
88
+ <SidebarLintButton
89
+ onLint={onLint}
90
+ linting={linting}
91
+ findingCount={lintFindingCount}
92
+ hasError={lintHasError}
93
+ />
94
+ );
95
+
96
+ return (
97
+ <>
98
+ <Dock.Panel id="compositions">
99
+ <PanelColumn footer={lintButton}>
100
+ <CompositionsPanel
101
+ projectId={projectId}
102
+ compositions={compositions}
103
+ activeComposition={editingFile?.path ?? null}
104
+ onSelect={onSelectComposition}
105
+ onAddToTimeline={onAddCompositionToTimeline}
106
+ onRenderComposition={handleRenderComposition}
107
+ isRendering={renderQueue.isRendering}
108
+ lintFindingsByFile={lintFindingsByFile}
109
+ />
110
+ </PanelColumn>
111
+ </Dock.Panel>
112
+ <Dock.Panel id="assets">
113
+ <PanelColumn footer={lintButton}>
114
+ <AssetsTab
115
+ projectId={projectId}
116
+ assets={assets}
117
+ onImport={importFiles}
118
+ onDelete={handleDeleteFile}
119
+ onRename={handleRenameFile}
120
+ onAddAssetToTimeline={onAddAssetToTimeline}
121
+ />
122
+ </PanelColumn>
123
+ </Dock.Panel>
124
+ <Dock.Panel id="code">
125
+ <PanelColumn footer={lintButton}>
126
+ <div className="flex min-h-0 flex-1">
127
+ {fileTree.length > 0 && (
128
+ <div className="w-[160px] shrink-0 border-r border-neutral-800 overflow-y-auto">
129
+ <FileTree
130
+ files={fileTree}
131
+ activeFile={editingFile?.path ?? null}
132
+ onSelectFile={handleFileSelect}
133
+ onCreateFile={handleCreateFile}
134
+ onCreateFolder={handleCreateFolder}
135
+ onDeleteFile={handleDeleteFile}
136
+ onRenameFile={handleRenameFile}
137
+ onDuplicateFile={handleDuplicateFile}
138
+ onMoveFile={handleMoveFile}
139
+ onImportFiles={importFiles}
140
+ lintFindingsByFile={lintFindingsByFile}
141
+ />
142
+ </div>
143
+ )}
144
+ <div className="flex-1 overflow-hidden min-w-0">
145
+ <CodeBody
146
+ projectId={projectId}
147
+ editingFile={editingFile}
148
+ revealOffset={revealSourceOffset}
149
+ onChange={handleContentChange}
150
+ />
151
+ </div>
152
+ </div>
153
+ </PanelColumn>
154
+ </Dock.Panel>
155
+ <Dock.Panel id="catalog">
156
+ <PanelColumn footer={lintButton}>
157
+ <BlocksTab onAddBlock={onAddBlock} onPreviewBlock={onPreviewBlock} />
158
+ </PanelColumn>
159
+ </Dock.Panel>
160
+ </>
161
+ );
162
+ }
163
+
164
+ function CodeBody({
165
+ projectId,
166
+ editingFile,
167
+ revealOffset,
168
+ onChange,
169
+ }: {
170
+ projectId: string;
171
+ editingFile: { path: string; content: string | null } | null;
172
+ revealOffset: React.ComponentProps<typeof SourceEditor>["revealOffset"];
173
+ onChange: (content: string) => void;
174
+ }) {
175
+ if (!editingFile) {
176
+ return (
177
+ <div className="flex items-center justify-center h-full text-neutral-600 text-sm">
178
+ Select a file to edit
179
+ </div>
180
+ );
181
+ }
182
+ if (isMediaFile(editingFile.path)) {
183
+ return <MediaPreview projectId={projectId} filePath={editingFile.path} />;
184
+ }
185
+ // Never mount the editor on unloaded content: a keystroke would autosave an
186
+ // empty document over the real file.
187
+ if (editingFile.content == null) {
188
+ return (
189
+ <div className="flex h-full items-center justify-center text-[11px] text-neutral-600">
190
+ Loading {editingFile.path}…
191
+ </div>
192
+ );
193
+ }
194
+ return (
195
+ <SourceEditor
196
+ content={editingFile.content}
197
+ filePath={editingFile.path}
198
+ onChange={onChange}
199
+ revealOffset={revealOffset}
200
+ />
201
+ );
202
+ }