@hyperframes/studio 0.8.50 → 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 (280) hide show
  1. package/dist/assets/{hyperframes-player-CxxDxHTW.js → hyperframes-player-I5YRzB02.js} +3 -3
  2. package/dist/assets/{index-Dt3qBI-9.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-DUAIWxkI.js → index-Du5x7KUR.js} +1 -1
  6. package/dist/index.d.ts +419 -20
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7496 -8288
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.js.map +1 -1
  11. package/package.json +13 -12
  12. package/src/App.tsx +131 -143
  13. package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
  14. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  15. package/src/captions/components/CaptionOverlay.tsx +8 -4
  16. package/src/captions/components/CaptionOverlayUtils.ts +2 -3
  17. package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
  18. package/src/captions/components/CaptionTimeline.tsx +1 -1
  19. package/src/captions/components/shared.tsx +2 -2
  20. package/src/components/AskAgentModal.tsx +5 -5
  21. package/src/components/EditorShell.tsx +8 -7
  22. package/src/components/ExternalFileConflictBanner.tsx +16 -16
  23. package/src/components/LintModal.tsx +6 -16
  24. package/src/components/MediaPreview.tsx +2 -2
  25. package/src/components/RightPanelTabs.test.tsx +151 -0
  26. package/src/components/RightPanelTabs.tsx +59 -0
  27. package/src/components/SaveQueuePausedBanner.tsx +2 -2
  28. package/src/components/StudioHeader.dom.test.tsx +181 -0
  29. package/src/components/StudioHeader.tsx +110 -210
  30. package/src/components/StudioLeftSidebar.tsx +7 -4
  31. package/src/components/StudioOverlays.tsx +1 -1
  32. package/src/components/StudioRightPanel.tsx +51 -39
  33. package/src/components/StudioToast.tsx +2 -2
  34. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  35. package/src/components/TimelineHistoryButtons.tsx +54 -0
  36. package/src/components/TimelineToolbar.test.tsx +8 -0
  37. package/src/components/TimelineToolbar.tsx +8 -12
  38. package/src/components/dock/Dock.test.tsx +260 -0
  39. package/src/components/dock/Dock.tsx +281 -0
  40. package/src/components/dock/dock.css +28 -0
  41. package/src/components/dock/dockAccessibility.test.ts +166 -0
  42. package/src/components/dock/dockAccessibility.ts +98 -0
  43. package/src/components/dock/dockLayout.test.ts +134 -0
  44. package/src/components/dock/dockLayout.ts +105 -0
  45. package/src/components/dock/dockLayoutSchema.ts +66 -0
  46. package/src/components/dock/dockLayoutStore.ts +91 -0
  47. package/src/components/dock/dockTestHarness.ts +11 -0
  48. package/src/components/dock/panelRegistry.ts +67 -0
  49. package/src/components/editor/AnimationCard.tsx +4 -4
  50. package/src/components/editor/AnimationCardParts.tsx +6 -6
  51. package/src/components/editor/BlockParamsPanel.tsx +4 -4
  52. package/src/components/editor/BorderRadiusEditor.tsx +1 -1
  53. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  54. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  55. package/src/components/editor/DomEditOverlay.tsx +1 -1
  56. package/src/components/editor/EaseCurveSection.tsx +1 -1
  57. package/src/components/editor/EaseParamFields.tsx +5 -5
  58. package/src/components/editor/FileTree.tsx +4 -4
  59. package/src/components/editor/FileTreeIcons.tsx +1 -1
  60. package/src/components/editor/FileTreeNodes.tsx +13 -13
  61. package/src/components/editor/InlineTextToolbar.tsx +1 -1
  62. package/src/components/editor/InspectorHeaderActions.tsx +3 -3
  63. package/src/components/editor/KeyframeDiamond.tsx +1 -1
  64. package/src/components/editor/KeyframeEaseList.tsx +1 -1
  65. package/src/components/editor/LayersPanel.test.ts +97 -1
  66. package/src/components/editor/LayersPanel.tsx +7 -5
  67. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  68. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  69. package/src/components/editor/PreviewGuides.tsx +1 -1
  70. package/src/components/editor/PromotableControl.tsx +2 -2
  71. package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
  72. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  73. package/src/components/editor/SnapToolbar.tsx +2 -2
  74. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  75. package/src/components/editor/TopologyLens.test.tsx +28 -0
  76. package/src/components/editor/TopologyLens.tsx +7 -5
  77. package/src/components/editor/Transform3DCube.tsx +2 -2
  78. package/src/components/editor/audioFxRevealTarget.ts +1 -0
  79. package/src/components/editor/domEditing.test.ts +1 -1
  80. package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
  81. package/src/components/editor/domEditingAgentPrompt.ts +4 -3
  82. package/src/components/editor/propertyPanelColor.tsx +11 -11
  83. package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
  84. package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
  85. package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
  86. package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
  87. package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
  88. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  89. package/src/components/editor/propertyPanelFill.tsx +4 -4
  90. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  91. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
  92. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  93. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
  94. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
  95. package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
  96. package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
  97. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
  98. package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
  99. package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
  100. package/src/components/editor/propertyPanelFont.tsx +5 -5
  101. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  102. package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
  103. package/src/components/editor/propertyPanelFxControls.tsx +6 -6
  104. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  105. package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
  106. package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
  107. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  108. package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
  109. package/src/components/editor/propertyPanelSections.tsx +8 -8
  110. package/src/components/editor/useColorGradingController.test.ts +38 -0
  111. package/src/components/editor/useColorGradingController.ts +9 -1
  112. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  113. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  114. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  115. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  116. package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
  117. package/src/components/feedback/feedbackTrigger.ts +0 -1
  118. package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
  119. package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
  120. package/src/components/nle/NLEPreview.tsx +3 -3
  121. package/src/components/nle/PreviewOverlays.tsx +6 -6
  122. package/src/components/nle/PreviewPane.tsx +3 -3
  123. package/src/components/nle/TimelinePane.tsx +3 -3
  124. package/src/components/nle/TimelineResizeDivider.tsx +2 -2
  125. package/src/components/panels/SlideshowPanel.tsx +2 -2
  126. package/src/components/panels/SlideshowSubPanels.tsx +20 -17
  127. package/src/components/panels/VariablesBindElement.tsx +2 -2
  128. package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
  129. package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
  130. package/src/components/panels/VariablesPanel.tsx +3 -3
  131. package/src/components/panels/VariablesValueControls.tsx +2 -2
  132. package/src/components/pointerTargetSize.test.tsx +8 -0
  133. package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
  134. package/src/components/renders/RenderQueue.test.tsx +7 -0
  135. package/src/components/renders/RenderQueue.tsx +7 -7
  136. package/src/components/renders/RenderQueueItem.tsx +6 -6
  137. package/src/components/sidebar/AssetCard.tsx +8 -8
  138. package/src/components/sidebar/AssetContextMenu.tsx +8 -8
  139. package/src/components/sidebar/AssetsTab.tsx +2 -2
  140. package/src/components/sidebar/AudioRow.tsx +6 -6
  141. package/src/components/sidebar/BlocksTab.tsx +6 -6
  142. package/src/components/sidebar/CompositionsTab.tsx +5 -5
  143. package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
  144. package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
  145. package/src/components/sidebar/LeftSidebar.tsx +19 -5
  146. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  147. package/src/components/timelineToolbarStyles.ts +5 -0
  148. package/src/components/ui/Button.test.tsx +252 -0
  149. package/src/components/ui/Button.tsx +89 -126
  150. package/src/components/ui/IconButton.tsx +38 -0
  151. package/src/components/ui/Input.tsx +115 -0
  152. package/src/components/ui/Menu.test.tsx +374 -0
  153. package/src/components/ui/Menu.tsx +213 -0
  154. package/src/components/ui/NumberField.tsx +122 -0
  155. package/src/components/ui/Popover.tsx +74 -0
  156. package/src/components/ui/SearchInput.tsx +2 -2
  157. package/src/components/ui/Select.tsx +100 -0
  158. package/src/components/ui/Slider.tsx +153 -0
  159. package/src/components/ui/Tabs.test.tsx +98 -0
  160. package/src/components/ui/Tabs.tsx +71 -0
  161. package/src/components/ui/Toggle.tsx +63 -0
  162. package/src/components/ui/Tooltip.test.tsx +110 -0
  163. package/src/components/ui/Tooltip.tsx +38 -112
  164. package/src/components/ui/cn.test.ts +30 -0
  165. package/src/components/ui/cn.ts +25 -0
  166. package/src/components/ui/index.ts +29 -2
  167. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  168. package/src/components/ui/valueControls.test.tsx +524 -0
  169. package/src/hooks/useAppHotkeys.ts +21 -80
  170. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  171. package/src/hooks/useCaptionDetection.ts +3 -1
  172. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  173. package/src/hooks/useEditHistoryActions.ts +106 -0
  174. package/src/hooks/useLintModal.test.tsx +65 -0
  175. package/src/hooks/useLintModal.ts +3 -0
  176. package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
  177. package/src/hooks/useSdkSession.ts +43 -10
  178. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  179. package/src/hooks/useServerConnection.ts +43 -10
  180. package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
  181. package/src/hooks/useTimelineAssetDropOps.ts +52 -11
  182. package/src/index.ts +51 -0
  183. package/src/main.tsx +9 -3
  184. package/src/player/components/AudioWaveform.tsx +1 -1
  185. package/src/player/components/AutomationSelectionMenu.tsx +3 -3
  186. package/src/player/components/ClipContextMenu.tsx +3 -3
  187. package/src/player/components/CompositionThumbnail.test.ts +0 -3
  188. package/src/player/components/CompositionThumbnail.tsx +3 -4
  189. package/src/player/components/EditModal.tsx +1 -1
  190. package/src/player/components/ImageThumbnail.tsx +1 -1
  191. package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
  192. package/src/player/components/LayerDisclosureRow.tsx +2 -2
  193. package/src/player/components/PlayerControls.tsx +1 -1
  194. package/src/player/components/ShortcutsPanel.tsx +9 -9
  195. package/src/player/components/SpeedMenu.tsx +3 -5
  196. package/src/player/components/Timeline.tsx +2 -1
  197. package/src/player/components/TimelineCanvas.tsx +21 -14
  198. package/src/player/components/TimelineClip.tsx +0 -1
  199. package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
  200. package/src/player/components/TimelineEmptyState.tsx +5 -5
  201. package/src/player/components/TimelineFxButton.tsx +6 -6
  202. package/src/player/components/TimelineGroupHeader.tsx +5 -5
  203. package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
  204. package/src/player/components/TimelineGroupRow.tsx +1 -1
  205. package/src/player/components/TimelineLanes.test.tsx +32 -0
  206. package/src/player/components/TimelineLanes.tsx +2 -5
  207. package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
  208. package/src/player/components/TimelineRuler.tsx +1 -1
  209. package/src/player/components/TimelineShortcutHint.tsx +1 -1
  210. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  211. package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
  212. package/src/player/components/TimelineTypes.ts +2 -1
  213. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  214. package/src/player/components/VideoThumbnail.tsx +4 -2
  215. package/src/player/components/VolumeControl.tsx +2 -2
  216. package/src/player/components/timelineCallbacks.ts +8 -8
  217. package/src/player/components/timelineClipDragPreview.ts +51 -27
  218. package/src/player/components/timelineClipDragTypes.ts +3 -0
  219. package/src/player/components/timelineDragDrop.test.tsx +61 -1
  220. package/src/player/components/timelineDragDrop.ts +57 -15
  221. package/src/player/components/timelineLaneProps.ts +3 -0
  222. package/src/player/components/timelineSnapping.test.ts +27 -0
  223. package/src/player/components/timelineSnapping.ts +14 -1
  224. package/src/player/components/trackHeaderLabelRows.tsx +4 -4
  225. package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
  226. package/src/player/components/useTimelineClipDrag.ts +30 -3
  227. package/src/player/lib/describeClips.ts +3 -54
  228. package/src/player/lib/playbackShortcuts.ts +2 -0
  229. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  230. package/src/player/store/previewIframeStore.test.tsx +29 -0
  231. package/src/player/store/previewIframeStore.ts +25 -0
  232. package/src/renderQueueExports.test.tsx +58 -0
  233. package/src/styles/classCandidates.test.ts +105 -0
  234. package/src/styles/classCandidates.ts +281 -0
  235. package/src/styles/hex-baseline.json +84 -0
  236. package/src/styles/hexRatchet.test.ts +201 -0
  237. package/src/styles/iconTokens.ts +20 -0
  238. package/src/styles/palette-reset.css +6 -0
  239. package/src/styles/studio.css +24 -21
  240. package/src/styles/styleSources.test.ts +20 -0
  241. package/src/styles/styleSources.ts +47 -0
  242. package/src/styles/tailwind-preset.shared.js +3 -0
  243. package/src/styles/theme.css +243 -0
  244. package/src/styles/theme.test.ts +183 -0
  245. package/src/styles/tokenGate.test.ts +109 -0
  246. package/src/utils/sourcePatcher.ts +1 -1
  247. package/src/utils/studioHelpers.test.ts +18 -0
  248. package/src/utils/studioHelpers.ts +11 -1
  249. package/src/utils/studioUiPreferences.ts +7 -0
  250. package/src/utils/timelineDropTrackInsert.test.ts +134 -0
  251. package/src/utils/timelineDropTrackInsert.ts +83 -0
  252. package/src/utils/typingTarget.ts +4 -0
  253. package/dist/assets/index-n2ml4tUn.js +0 -442
  254. package/dist/assets/index-pO6p0H27.css +0 -1
  255. package/src/components/PanelTabButton.tsx +0 -31
  256. package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
  257. package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
  258. package/src/components/storyboard/FramePoster.test.tsx +0 -61
  259. package/src/components/storyboard/FramePoster.tsx +0 -86
  260. package/src/components/storyboard/StoryboardDirection.tsx +0 -45
  261. package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
  262. package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
  263. package/src/components/storyboard/StoryboardGrid.tsx +0 -55
  264. package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
  265. package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
  266. package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
  267. package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
  268. package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
  269. package/src/components/storyboard/StoryboardView.tsx +0 -169
  270. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
  271. package/src/components/storyboard/frameComments.test.ts +0 -95
  272. package/src/components/storyboard/frameComments.ts +0 -115
  273. package/src/components/storyboard/frameStatus.ts +0 -36
  274. package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
  275. package/src/components/storyboard/storyboardReviewStage.ts +0 -45
  276. package/src/components/storyboard/useFrameComments.ts +0 -98
  277. package/src/contexts/ViewModeContext.tsx +0 -142
  278. package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
  279. package/src/hooks/useProjectSignaturePoll.ts +0 -68
  280. package/src/hooks/useStoryboard.ts +0 -99
@@ -18,6 +18,26 @@ interface ServerConnectionState {
18
18
  * Polls every 2 s until the server responds, then transitions automatically.
19
19
  * Cleans up pending timers on unmount so it is safe under React StrictMode.
20
20
  */
21
+ /**
22
+ * Whether a hash-supplied project id still names something the server can
23
+ * resolve. Three answers, not two: a failed request must NOT be read as
24
+ * "missing", or one network blip would discard a perfectly good deep link.
25
+ *
26
+ * Resolved through `/api/projects/:id`, which calls the same
27
+ * `adapter.resolveProject` the file routes use. A match against the
28
+ * `/api/projects` list would be wrong twice over: that list omits session ids
29
+ * (which resolve fine) and skips project dirs without an `index.html`.
30
+ */
31
+ async function resolveHashProject(id: string): Promise<"ok" | "missing" | "unknown"> {
32
+ try {
33
+ const res = await fetch(`/api/projects/${encodeURIComponent(id)}`);
34
+ if (res.ok) return "ok";
35
+ return res.status === 404 ? "missing" : "unknown";
36
+ } catch {
37
+ return "unknown";
38
+ }
39
+ }
40
+
21
41
  export function useServerConnection(): ServerConnectionState {
22
42
  const [projectId, setProjectId] = useState<string | null>(null);
23
43
  const [resolving, setResolving] = useState(true);
@@ -39,21 +59,34 @@ export function useServerConnection(): ServerConnectionState {
39
59
  function tryConnect() {
40
60
  fetch("/api/projects")
41
61
  .then((r) => r.json())
42
- .then((data) => {
62
+ .then(async (data) => {
43
63
  if (cancelled) return;
64
+ // A hash project id outlives the project it names — a renamed folder,
65
+ // or a bookmark from a project that is gone. Trusting it blindly made
66
+ // every later /api/projects/<id>/... request 404 for the life of the
67
+ // tab, including the composition read that opens the SDK session, so
68
+ // every edit fell back to the server path with nothing to show why.
69
+ // Telemetry after the read-reason split: 120 http_error/404 reads
70
+ // across 5 users in 24h, ~24 each, never recovering.
44
71
  if (hashProjectId) {
45
- setProjectId(hashProjectId);
46
- setWaitingForServer(false);
47
- } else {
48
- const first = (data.projects ?? [])[0];
49
- if (first) {
50
- setProjectId(first.id);
72
+ const state = await resolveHashProject(hashProjectId);
73
+ if (cancelled) return;
74
+ // "unknown" keeps the old behaviour: a transient failure must not
75
+ // rewrite the user's hash out from under a valid project.
76
+ if (state !== "missing") {
77
+ setProjectId(hashProjectId);
51
78
  setWaitingForServer(false);
52
- window.location.hash = buildProjectHash(first.id);
53
- } else {
54
- scheduleRetry();
79
+ return;
55
80
  }
56
81
  }
82
+ const first = (data.projects ?? [])[0];
83
+ if (first) {
84
+ setProjectId(first.id);
85
+ setWaitingForServer(false);
86
+ window.location.hash = buildProjectHash(first.id);
87
+ } else {
88
+ scheduleRetry();
89
+ }
57
90
  })
58
91
  .catch(() => {
59
92
  if (!cancelled) scheduleRetry();
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
6
6
  import { mountReactHarness } from "./domSelectionTestHarness";
7
7
  import { usePlayerStore } from "../player/store/playerStore";
8
+ import type { TimelineElement } from "../player";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
@@ -19,6 +20,7 @@ type DropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineAssetDro
19
20
  function renderDropHook(
20
21
  sourceContent: string,
21
22
  writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
23
+ timelineElements: TimelineElement[] = [],
22
24
  ) {
23
25
  vi.stubGlobal(
24
26
  "fetch",
@@ -29,7 +31,7 @@ function renderDropHook(
29
31
  const { handleTimelineAssetDrop } = useTimelineAssetDropOps({
30
32
  projectIdRef: { current: "project" },
31
33
  activeCompPath: "index.html",
32
- timelineElements: [],
34
+ timelineElements,
33
35
  showToast: vi.fn(),
34
36
  writeProjectFile,
35
37
  recordEdit: vi.fn().mockResolvedValue(undefined),
@@ -84,4 +86,42 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
84
86
 
85
87
  expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip");
86
88
  });
89
+
90
+ it("measures the insert row against the visible rows, not clips the timeline hides", async () => {
91
+ const clip = (id: string, track: number): TimelineElement => ({
92
+ id,
93
+ key: id,
94
+ tag: "div",
95
+ start: 0,
96
+ duration: 2,
97
+ track,
98
+ authoredTrack: track,
99
+ hfId: `hf-${id}`,
100
+ domId: id,
101
+ });
102
+ const source = [
103
+ '<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080">',
104
+ ...["a:0", "hidden:1", "b:2"].map((t) => {
105
+ const [id, track] = t.split(":");
106
+ return `<div data-hf-id="hf-${id}" id="${id}" data-start="0" data-track-index="${track}"></div>`;
107
+ }),
108
+ "</main>",
109
+ ].join("\n");
110
+ usePlayerStore.getState().setTopLevelIds(new Set(["a", "b"]));
111
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
112
+ const getDrop = renderDropHook(source, writeProjectFile, [
113
+ clip("a", 0),
114
+ clip("hidden", 1),
115
+ clip("b", 2),
116
+ ]);
117
+
118
+ await act(async () => {
119
+ await getDrop()("clip.mp4", { start: 1, track: 1, insertRow: 1, trackOrder: [0, 2] }, 2);
120
+ });
121
+
122
+ const [, written] = writeProjectFile.mock.calls[0] as [string, string];
123
+ expect(written).toContain('id="b" data-start="0" data-track-index="2"');
124
+ expect(written).toContain('id="hidden" data-start="0" data-track-index="1"');
125
+ expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
126
+ });
87
127
  });
@@ -4,6 +4,8 @@
4
4
  // 600-line cap.
5
5
  import { useCallback, type MutableRefObject, type RefObject } from "react";
6
6
  import type { TimelineElement } from "../player";
7
+ import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
8
+ import { resolveDropTrack } from "../utils/timelineDropTrackInsert";
7
9
  import {
8
10
  buildTimelineAssetId,
9
11
  buildTimelineAssetInsertHtml,
@@ -23,6 +25,18 @@ import { commitTimelineCompositionInsertion } from "../utils/timelineComposition
23
25
  import { extendRootDurationInSource } from "../utils/rootDuration";
24
26
  import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
25
27
  import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
28
+ import { selectTimelineRowElements } from "../player/hooks/useTimelineRowElements";
29
+ import { usePlayerStore } from "../player/store/playerStore";
30
+
31
+ /** The first uploaded file opens the new track (if asked); the rest land on the lane it landed on. */
32
+ function fileDropPlacement(
33
+ index: number,
34
+ next: { start: number; track: number },
35
+ dropped: TimelineDropPlacement | undefined,
36
+ landedTrack: number | undefined,
37
+ ): TimelineDropPlacement {
38
+ return index === 0 ? { ...dropped, ...next } : { ...next, track: landedTrack ?? next.track };
39
+ }
26
40
 
27
41
  interface UseTimelineAssetDropOpsOptions {
28
42
  projectIdRef: MutableRefObject<string | null>;
@@ -52,16 +66,16 @@ export function useTimelineAssetDropOps({
52
66
  observeProjectFileVersion,
53
67
  }: UseTimelineAssetDropOpsOptions) {
54
68
  // fallow-ignore-next-line complexity
55
- const handleTimelineAssetDrop = useCallback(
69
+ const dropAssetAt = useCallback(
56
70
  // fallow-ignore-next-line complexity
57
71
  async (
58
72
  assetPath: string,
59
- placement: Pick<TimelineElement, "start" | "track">,
73
+ placement: TimelineDropPlacement,
60
74
  durationOverride?: number,
61
- ) => {
75
+ ): Promise<number | undefined> => {
62
76
  if (isRecordingRef?.current) {
63
77
  showToast("Cannot edit timeline while recording", "error");
64
- return;
78
+ return undefined;
65
79
  }
66
80
  const pid = projectIdRef.current;
67
81
  if (!pid) throw new Error("No active project");
@@ -69,7 +83,7 @@ export function useTimelineAssetDropOps({
69
83
  const kind = getTimelineAssetKind(assetPath);
70
84
  if (!kind) {
71
85
  showToast("Only image, video, and audio assets can be dropped onto the timeline.");
72
- return;
86
+ return undefined;
73
87
  }
74
88
 
75
89
  const targetPath = activeCompPath || "index.html";
@@ -91,9 +105,24 @@ export function useTimelineAssetDropOps({
91
105
  );
92
106
  const newElementZIndex = Math.max(1, relevantElements.length + 1);
93
107
 
108
+ const { source: sourceWithRoom, track } = resolveDropTrack({
109
+ source: originalContent,
110
+ // insertRow counts the rows the timeline shows, so plan against those.
111
+ elements: selectTimelineRowElements(
112
+ relevantElements,
113
+ usePlayerStore.getState().topLevelIds,
114
+ ),
115
+ placement,
116
+ dropped: {
117
+ id: newId,
118
+ tag: kind === "image" ? "img" : kind,
119
+ start: normalizedStart,
120
+ duration: normalizedDuration,
121
+ },
122
+ });
94
123
  const patchedContent = extendRootDurationInSource(
95
124
  insertTimelineAssetIntoSource(
96
- originalContent,
125
+ sourceWithRoom,
97
126
  buildTimelineAssetInsertHtml({
98
127
  id: newId,
99
128
  hfId: `hf-${generateId()}`,
@@ -101,7 +130,7 @@ export function useTimelineAssetDropOps({
101
130
  kind,
102
131
  start: normalizedStart,
103
132
  duration: normalizedDuration,
104
- track: placement.track,
133
+ track,
105
134
  zIndex: newElementZIndex,
106
135
  geometry: fitTimelineAssetGeometry(
107
136
  null,
@@ -125,10 +154,12 @@ export function useTimelineAssetDropOps({
125
154
  selectAndRevealTimelineElement(deriveTimelineStoreKeyForDomId(newId, targetPath));
126
155
  forceReloadSdkSession?.();
127
156
  reloadPreview();
157
+ return track;
128
158
  } catch (error) {
129
159
  const message =
130
160
  error instanceof Error ? error.message : "Failed to drop asset onto timeline";
131
161
  showToast(message);
162
+ return undefined;
132
163
  }
133
164
  },
134
165
  [
@@ -144,10 +175,17 @@ export function useTimelineAssetDropOps({
144
175
  ],
145
176
  );
146
177
 
178
+ const handleTimelineAssetDrop = useCallback(
179
+ async (assetPath: string, placement: TimelineDropPlacement, durationOverride?: number) => {
180
+ await dropAssetAt(assetPath, placement, durationOverride);
181
+ },
182
+ [dropAssetAt],
183
+ );
184
+
147
185
  // fallow-ignore-next-line complexity
148
186
  const handleTimelineFileDrop = useCallback(
149
187
  // fallow-ignore-next-line complexity
150
- async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
188
+ async (files: File[], placement?: TimelineDropPlacement) => {
151
189
  if (isRecordingRef?.current) {
152
190
  showToast("Cannot edit timeline while recording", "error");
153
191
  return;
@@ -166,15 +204,18 @@ export function useTimelineAssetDropOps({
166
204
  placement ?? { start: 0, track: 0 },
167
205
  durations,
168
206
  );
207
+ let landedTrack: number | undefined;
169
208
  for (const [index, assetPath] of uploaded.entries()) {
170
- await handleTimelineAssetDrop(
209
+ const next = placements[index] ?? placements[0];
210
+ const track = await dropAssetAt(
171
211
  assetPath,
172
- placements[index] ?? placements[0],
212
+ fileDropPlacement(index, next, placement, landedTrack),
173
213
  durations[index],
174
214
  );
215
+ if (index === 0) landedTrack = track;
175
216
  }
176
217
  },
177
- [handleTimelineAssetDrop, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
218
+ [dropAssetAt, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
178
219
  );
179
220
 
180
221
  const handleTimelineCompositionDrop = useCallback(
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";
package/src/main.tsx CHANGED
@@ -1,7 +1,9 @@
1
1
  import { StrictMode } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
+ import { IconContext } from "@phosphor-icons/react";
3
4
  import { StudioApp } from "./App";
4
5
  import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
6
+ import { readIconTokens } from "./styles/iconTokens";
5
7
  import { trackStudioEvent } from "./utils/studioTelemetry";
6
8
  import "./styles/studio.css";
7
9
 
@@ -93,8 +95,12 @@ window.addEventListener("unhandledrejection", (event) => {
93
95
 
94
96
  createRoot(document.getElementById("root")!).render(
95
97
  <StrictMode>
96
- <StudioErrorBoundary>
97
- <StudioApp />
98
- </StudioErrorBoundary>
98
+ {/* One icon size and weight for the whole app, taken from the theme file.
99
+ Icons that pass their own size or weight still win. */}
100
+ <IconContext.Provider value={readIconTokens()}>
101
+ <StudioErrorBoundary>
102
+ <StudioApp />
103
+ </StudioErrorBoundary>
104
+ </IconContext.Provider>
99
105
  </StrictMode>,
100
106
  );
@@ -191,7 +191,7 @@ export const AudioWaveform = memo(function AudioWaveform({
191
191
  "repeating-linear-gradient(90deg, rgba(75,163,210,0.35) 0 2px, transparent 2px 5px)",
192
192
  }}
193
193
  />
194
- <span className="relative rounded bg-black/50 px-1 text-[8px] text-neutral-500">
194
+ <span className="relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
195
195
  waveform unavailable
196
196
  </span>
197
197
  </div>
@@ -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.
@@ -41,11 +41,11 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
41
41
  return createPortal(
42
42
  <div
43
43
  ref={menuRef}
44
- // z-[200] for the same reason the timeline's FX popover uses it: this is
44
+ // z-200 for the same reason the timeline's FX popover uses it: this is
45
45
  // portaled to `document.body`, but the ruler's sticky header sits at z-70
46
46
  // in the SAME root stacking context, so a z-50 menu opened near the top of
47
47
  // the timeline is painted through by the ruler and the playhead.
48
- className="hf-automation-menu fixed z-[200] min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
48
+ className="hf-automation-menu fixed z-200 min-w-[140px] rounded-sm border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
49
49
  style={{ left: adjustedX, top: adjustedY }}
50
50
  >
51
51
  {AUTOMATION_SHAPES.map((shape) => (
@@ -78,7 +78,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
78
78
  ref={menuRef}
79
79
  role="menu"
80
80
  aria-label="Clip actions"
81
- className="fixed z-[200] bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
81
+ className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
82
82
  style={{ left: adjustedX, top: adjustedY }}
83
83
  >
84
84
  {splitLabel && (
@@ -86,7 +86,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
86
86
  <button
87
87
  type="button"
88
88
  role="menuitem"
89
- className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none focus-visible:bg-neutral-800 ${
89
+ className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-hidden focus-visible:bg-neutral-800 ${
90
90
  canSplit
91
91
  ? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
92
92
  : "text-neutral-600 cursor-not-allowed"
@@ -159,7 +159,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
159
159
  <button
160
160
  type="button"
161
161
  role="menuitem"
162
- className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left"
162
+ className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left"
163
163
  onClick={() => {
164
164
  onDelete(element);
165
165
  onClose();
@@ -87,9 +87,6 @@ describe("buildCompositionThumbnailUrl", () => {
87
87
 
88
88
  expect(buildCompositionThumbnailUrl(base)).not.toContain("output=");
89
89
  expect(buildCompositionThumbnailUrl({ ...base, output: "source" })).toContain("output=source");
90
- expect(buildCompositionThumbnailUrl({ ...base, output: "storyboard" })).toContain(
91
- "output=storyboard",
92
- );
93
90
  });
94
91
 
95
92
  it("includes the persisted content revision in the cache identity", () => {
@@ -44,10 +44,9 @@ export function buildCompositionThumbnailUrl({
44
44
  /**
45
45
  * Capture density. Omitted, the route bounds the image to its preview cap —
46
46
  * right for the timeline, where thumbnails are small and numerous and their
47
- * decoded bytes are budgeted. `"storyboard"` caps the longest side at a
48
- * high-density review size; `"source"` uses the composition's own dimensions.
47
+ * decoded bytes are budgeted. `"source"` uses the composition's own dimensions.
49
48
  */
50
- output?: "source" | "storyboard";
49
+ output?: "source";
51
50
  contentRevision?: number;
52
51
  }): string {
53
52
  const thumbnailBase = previewUrl
@@ -159,7 +158,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
159
158
  {Array.from({ length: frameCount }, (_, index) => (
160
159
  <div
161
160
  key={index}
162
- className="relative h-full flex-shrink-0 overflow-hidden"
161
+ className="relative h-full shrink-0 overflow-hidden"
163
162
  style={{ width: frameW }}
164
163
  >
165
164
  <img
@@ -168,7 +168,7 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
168
168
  }}
169
169
  placeholder="What should change?"
170
170
  rows={2}
171
- className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-studio-accent/40 transition-colors"
171
+ className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-hidden focus:border-studio-accent/40 transition-colors"
172
172
  />
173
173
  </div>
174
174
 
@@ -73,7 +73,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
73
73
  {Array.from({ length: frameCount }, (_, index) => (
74
74
  <div
75
75
  key={index}
76
- className="relative h-full flex-shrink-0 overflow-hidden bg-neutral-900"
76
+ className="relative h-full shrink-0 overflow-hidden bg-neutral-900"
77
77
  style={{ width: frameW }}
78
78
  >
79
79
  <img
@@ -40,9 +40,9 @@ interface KeyframeDiamondContextMenuProps {
40
40
  }
41
41
 
42
42
  const ITEM_CLS =
43
- "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left";
43
+ "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left";
44
44
  const DESTRUCTIVE_ITEM_CLS =
45
- "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left";
45
+ "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left";
46
46
 
47
47
  export function KeyframeDiamondContextMenu({
48
48
  state,
@@ -96,7 +96,7 @@ export function KeyframeDiamondContextMenu({
96
96
  ref={menuRef}
97
97
  role="menu"
98
98
  aria-label="Keyframe actions"
99
- className="fixed z-[200] bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
99
+ className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
100
100
  style={{ left: adjustedX, top: adjustedY, maxHeight: `calc(100vh - ${adjustedY + 8}px)` }}
101
101
  >
102
102
  {onMoveToPlayhead && (
@@ -172,7 +172,7 @@ export function KeyframeDiamondContextMenu({
172
172
  <button
173
173
  type="button"
174
174
  role="menuitem"
175
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-950/40 focus-visible:bg-red-950/40 outline-none cursor-pointer text-left"
175
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-950/40 focus-visible:bg-red-950/40 outline-hidden cursor-pointer text-left"
176
176
  onClick={() => {
177
177
  onDeleteAll(state.element, state.animationId);
178
178
  onClose();
@@ -37,7 +37,7 @@ export function LaneToggleButton({
37
37
  title={`${isExpanded ? "Hide" : "Show"} lanes`}
38
38
  // h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
39
39
  // only the hit box grows.
40
- className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
40
+ className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
41
41
  isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
42
42
  }`}
43
43
  onPointerDown={(event) => event.stopPropagation()}
@@ -97,7 +97,7 @@ export function LayerDisclosureRow({
97
97
  </span>
98
98
  {/* Wraps rather than truncating: a truncated name needs a hover to be
99
99
  read, which a scanned column cannot rely on. */}
100
- <span className="min-w-0 flex-1 break-words font-medium leading-tight">{name}</span>
100
+ <span className="min-w-0 flex-1 wrap-break-word font-medium leading-tight">{name}</span>
101
101
  <TrackClipCount clipCount={clipCount} />
102
102
  {children}
103
103
  {/* Anchored right, on every header that has one: the lane toggle is the
@@ -116,7 +116,7 @@ const FullscreenButton = memo(function FullscreenButton({
116
116
  trackStudioEvent("playback", { action: "fullscreen_toggle", active: !isFullscreen });
117
117
  onToggleFullscreen();
118
118
  }}
119
- className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md transition-colors ${
119
+ className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors ${
120
120
  isFullscreen ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
121
121
  }`}
122
122
  aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
@@ -153,7 +153,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
153
153
  );
154
154
 
155
155
  return (
156
- <div ref={shortcutsPanelRef} className="relative flex-shrink-0">
156
+ <div ref={shortcutsPanelRef} className="relative shrink-0">
157
157
  <Tooltip label="Shortcuts and tools">
158
158
  <button
159
159
  ref={triggerRef}
@@ -192,7 +192,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
192
192
  // Deliberately NOT aria-modal. This is a non-modal disclosure: focus is
193
193
  // not trapped and the rest of the editor stays operable, so claiming
194
194
  // modality would make assistive tech treat the whole app as inert.
195
- className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto outline-none"
195
+ className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto outline-hidden"
196
196
  style={{
197
197
  background: "#161618",
198
198
  border: "1px solid rgba(255,255,255,0.08)",
@@ -212,7 +212,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
212
212
  pattern="[0-9]*"
213
213
  aria-label="Jump to frame"
214
214
  placeholder="frame number"
215
- className="h-6 flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60"
215
+ className="h-6 flex-1 rounded-sm border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-hidden transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60"
216
216
  onKeyDown={handleJumpKeyDown}
217
217
  onBlur={commitJumpFrame}
218
218
  />
@@ -220,7 +220,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
220
220
  <button
221
221
  type="submit"
222
222
  disabled={disabled}
223
- className="h-6 px-2 rounded border border-neutral-700 text-[10px] text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800 disabled:opacity-40"
223
+ className="h-6 px-2 rounded-sm border border-neutral-700 text-[10px] text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800 disabled:opacity-40"
224
224
  >
225
225
  Go
226
226
  </button>
@@ -236,7 +236,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
236
236
  <div className="flex items-center justify-between gap-2">
237
237
  <div className="flex items-center gap-2">
238
238
  <span
239
- className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
239
+ className="font-mono text-[10px] rounded-sm border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
240
240
  style={{ background: "rgba(255,255,255,0.05)" }}
241
241
  >
242
242
  I
@@ -253,7 +253,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
253
253
  <button
254
254
  type="button"
255
255
  onClick={() => setInPoint(null)}
256
- className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
256
+ className="w-4 h-4 flex items-center justify-center rounded-sm text-neutral-500 hover:text-neutral-200 transition-colors"
257
257
  aria-label="Clear in-point"
258
258
  >
259
259
  <svg
@@ -277,7 +277,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
277
277
  <div className="flex items-center justify-between gap-2">
278
278
  <div className="flex items-center gap-2">
279
279
  <span
280
- className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
280
+ className="font-mono text-[10px] rounded-sm border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
281
281
  style={{ background: "rgba(255,255,255,0.05)" }}
282
282
  >
283
283
  O
@@ -294,7 +294,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
294
294
  <button
295
295
  type="button"
296
296
  onClick={() => setOutPoint(null)}
297
- className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
297
+ className="w-4 h-4 flex items-center justify-center rounded-sm text-neutral-500 hover:text-neutral-200 transition-colors"
298
298
  aria-label="Clear out-point"
299
299
  >
300
300
  <svg
@@ -328,7 +328,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
328
328
  {section.hints.map((hint) => (
329
329
  <div key={hint.key} className="flex items-center gap-3">
330
330
  <span
331
- className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[36px] text-center"
331
+ className="font-mono text-[10px] rounded-sm border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[36px] text-center"
332
332
  style={{ background: "rgba(255,255,255,0.05)" }}
333
333
  >
334
334
  {hint.key}