@hyperframes/studio 0.8.50 → 0.8.51

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 (215) hide show
  1. package/dist/assets/{hyperframes-player-CxxDxHTW.js → hyperframes-player-Cn_PpHqP.js} +3 -3
  2. package/dist/assets/index-C05SNrvY.js +360 -0
  3. package/dist/assets/{index-DUAIWxkI.js → index-CPS3Cvj8.js} +1 -1
  4. package/dist/assets/{index-Dt3qBI-9.js → index-Clrb11lj.js} +1 -1
  5. package/dist/assets/index-DUR9qzQM.css +1 -0
  6. package/dist/index.d.ts +18 -13
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1867 -3335
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.js.map +1 -1
  11. package/package.json +11 -12
  12. package/src/App.tsx +128 -141
  13. package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
  14. package/src/captions/components/CaptionOverlayUtils.ts +2 -3
  15. package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
  16. package/src/captions/components/CaptionTimeline.tsx +1 -1
  17. package/src/captions/components/shared.tsx +2 -2
  18. package/src/components/AskAgentModal.tsx +5 -5
  19. package/src/components/EditorShell.tsx +4 -7
  20. package/src/components/ExternalFileConflictBanner.tsx +16 -16
  21. package/src/components/LintModal.tsx +6 -16
  22. package/src/components/MediaPreview.tsx +2 -2
  23. package/src/components/SaveQueuePausedBanner.tsx +2 -2
  24. package/src/components/StudioHeader.tsx +2 -62
  25. package/src/components/StudioLeftSidebar.tsx +7 -4
  26. package/src/components/StudioOverlays.tsx +1 -1
  27. package/src/components/StudioRightPanel.tsx +4 -4
  28. package/src/components/StudioToast.tsx +2 -2
  29. package/src/components/TimelineToolbar.tsx +7 -7
  30. package/src/components/dock/Dock.test.tsx +192 -0
  31. package/src/components/dock/Dock.tsx +264 -0
  32. package/src/components/dock/dock.css +23 -0
  33. package/src/components/dock/dockLayout.ts +58 -0
  34. package/src/components/dock/dockLayoutSchema.ts +66 -0
  35. package/src/components/dock/dockLayoutStore.ts +91 -0
  36. package/src/components/dock/panelRegistry.ts +67 -0
  37. package/src/components/editor/AnimationCard.tsx +4 -4
  38. package/src/components/editor/AnimationCardParts.tsx +6 -6
  39. package/src/components/editor/BlockParamsPanel.tsx +4 -4
  40. package/src/components/editor/BorderRadiusEditor.tsx +1 -1
  41. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  42. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  43. package/src/components/editor/DomEditOverlay.tsx +1 -1
  44. package/src/components/editor/EaseCurveSection.tsx +1 -1
  45. package/src/components/editor/EaseParamFields.tsx +5 -5
  46. package/src/components/editor/FileTree.tsx +4 -4
  47. package/src/components/editor/FileTreeIcons.tsx +1 -1
  48. package/src/components/editor/FileTreeNodes.tsx +13 -13
  49. package/src/components/editor/InlineTextToolbar.tsx +1 -1
  50. package/src/components/editor/InspectorHeaderActions.tsx +3 -3
  51. package/src/components/editor/KeyframeDiamond.tsx +1 -1
  52. package/src/components/editor/KeyframeEaseList.tsx +1 -1
  53. package/src/components/editor/LayersPanel.tsx +3 -3
  54. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  55. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  56. package/src/components/editor/PromotableControl.tsx +2 -2
  57. package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
  58. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  59. package/src/components/editor/SnapToolbar.tsx +2 -2
  60. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  61. package/src/components/editor/TopologyLens.tsx +4 -4
  62. package/src/components/editor/Transform3DCube.tsx +2 -2
  63. package/src/components/editor/audioFxRevealTarget.ts +1 -0
  64. package/src/components/editor/domEditing.test.ts +1 -1
  65. package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
  66. package/src/components/editor/domEditingAgentPrompt.ts +4 -3
  67. package/src/components/editor/propertyPanelColor.tsx +11 -11
  68. package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
  69. package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
  70. package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
  71. package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
  72. package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
  73. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  74. package/src/components/editor/propertyPanelFill.tsx +4 -4
  75. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  76. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
  77. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  78. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
  79. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
  80. package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
  81. package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
  82. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
  83. package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
  84. package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
  85. package/src/components/editor/propertyPanelFont.tsx +5 -5
  86. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  87. package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
  88. package/src/components/editor/propertyPanelFxControls.tsx +6 -6
  89. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  90. package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
  91. package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
  92. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  93. package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
  94. package/src/components/editor/propertyPanelSections.tsx +8 -8
  95. package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
  96. package/src/components/feedback/feedbackTrigger.ts +0 -1
  97. package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
  98. package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
  99. package/src/components/nle/NLEPreview.tsx +3 -3
  100. package/src/components/nle/PreviewOverlays.tsx +6 -6
  101. package/src/components/nle/PreviewPane.tsx +2 -2
  102. package/src/components/nle/TimelinePane.tsx +3 -3
  103. package/src/components/nle/TimelineResizeDivider.tsx +2 -2
  104. package/src/components/panels/SlideshowPanel.tsx +2 -2
  105. package/src/components/panels/SlideshowSubPanels.tsx +20 -17
  106. package/src/components/panels/VariablesBindElement.tsx +2 -2
  107. package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
  108. package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
  109. package/src/components/panels/VariablesPanel.tsx +3 -3
  110. package/src/components/panels/VariablesValueControls.tsx +2 -2
  111. package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
  112. package/src/components/renders/RenderQueue.test.tsx +7 -0
  113. package/src/components/renders/RenderQueue.tsx +6 -6
  114. package/src/components/renders/RenderQueueItem.tsx +6 -6
  115. package/src/components/sidebar/AssetCard.tsx +8 -8
  116. package/src/components/sidebar/AssetContextMenu.tsx +8 -8
  117. package/src/components/sidebar/AssetsTab.tsx +2 -2
  118. package/src/components/sidebar/AudioRow.tsx +6 -6
  119. package/src/components/sidebar/BlocksTab.tsx +6 -6
  120. package/src/components/sidebar/CompositionsTab.tsx +5 -5
  121. package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
  122. package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
  123. package/src/components/sidebar/LeftSidebar.tsx +19 -5
  124. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  125. package/src/components/ui/Button.test.tsx +60 -0
  126. package/src/components/ui/Button.tsx +4 -4
  127. package/src/components/ui/SearchInput.tsx +2 -2
  128. package/src/components/ui/Tooltip.tsx +1 -1
  129. package/src/hooks/useAppHotkeys.ts +1 -1
  130. package/src/hooks/useLintModal.test.tsx +65 -0
  131. package/src/hooks/useLintModal.ts +3 -0
  132. package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
  133. package/src/hooks/useSdkSession.ts +43 -10
  134. package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
  135. package/src/hooks/useTimelineAssetDropOps.ts +52 -11
  136. package/src/main.tsx +9 -3
  137. package/src/player/components/AudioWaveform.tsx +1 -1
  138. package/src/player/components/AutomationSelectionMenu.tsx +2 -2
  139. package/src/player/components/ClipContextMenu.tsx +3 -3
  140. package/src/player/components/CompositionThumbnail.test.ts +0 -3
  141. package/src/player/components/CompositionThumbnail.tsx +3 -4
  142. package/src/player/components/EditModal.tsx +1 -1
  143. package/src/player/components/ImageThumbnail.tsx +1 -1
  144. package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
  145. package/src/player/components/LayerDisclosureRow.tsx +2 -2
  146. package/src/player/components/PlayerControls.tsx +1 -1
  147. package/src/player/components/ShortcutsPanel.tsx +9 -9
  148. package/src/player/components/SpeedMenu.tsx +3 -5
  149. package/src/player/components/Timeline.tsx +2 -1
  150. package/src/player/components/TimelineCanvas.tsx +17 -10
  151. package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
  152. package/src/player/components/TimelineEmptyState.tsx +5 -5
  153. package/src/player/components/TimelineFxButton.tsx +6 -6
  154. package/src/player/components/TimelineGroupHeader.tsx +5 -5
  155. package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
  156. package/src/player/components/TimelineGroupRow.tsx +1 -1
  157. package/src/player/components/TimelineLanes.test.tsx +32 -0
  158. package/src/player/components/TimelineLanes.tsx +2 -5
  159. package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
  160. package/src/player/components/TimelineRuler.tsx +1 -1
  161. package/src/player/components/TimelineShortcutHint.tsx +1 -1
  162. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  163. package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
  164. package/src/player/components/TimelineTypes.ts +2 -1
  165. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  166. package/src/player/components/VideoThumbnail.tsx +4 -2
  167. package/src/player/components/VolumeControl.tsx +2 -2
  168. package/src/player/components/timelineCallbacks.ts +8 -8
  169. package/src/player/components/timelineClipDragPreview.ts +51 -27
  170. package/src/player/components/timelineClipDragTypes.ts +3 -0
  171. package/src/player/components/timelineDragDrop.test.tsx +61 -1
  172. package/src/player/components/timelineDragDrop.ts +57 -15
  173. package/src/player/components/timelineLaneProps.ts +3 -0
  174. package/src/player/components/timelineSnapping.test.ts +27 -0
  175. package/src/player/components/timelineSnapping.ts +14 -1
  176. package/src/player/components/trackHeaderLabelRows.tsx +4 -4
  177. package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
  178. package/src/player/components/useTimelineClipDrag.ts +30 -3
  179. package/src/player/lib/describeClips.ts +3 -54
  180. package/src/styles/iconTokens.ts +20 -0
  181. package/src/styles/palette-reset.css +6 -0
  182. package/src/styles/studio.css +24 -21
  183. package/src/styles/tailwind-preset.shared.js +3 -0
  184. package/src/styles/theme.css +213 -0
  185. package/src/styles/theme.test.ts +196 -0
  186. package/src/utils/studioUiPreferences.ts +7 -0
  187. package/src/utils/timelineDropTrackInsert.test.ts +134 -0
  188. package/src/utils/timelineDropTrackInsert.ts +83 -0
  189. package/dist/assets/index-n2ml4tUn.js +0 -442
  190. package/dist/assets/index-pO6p0H27.css +0 -1
  191. package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
  192. package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
  193. package/src/components/storyboard/FramePoster.test.tsx +0 -61
  194. package/src/components/storyboard/FramePoster.tsx +0 -86
  195. package/src/components/storyboard/StoryboardDirection.tsx +0 -45
  196. package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
  197. package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
  198. package/src/components/storyboard/StoryboardGrid.tsx +0 -55
  199. package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
  200. package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
  201. package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
  202. package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
  203. package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
  204. package/src/components/storyboard/StoryboardView.tsx +0 -169
  205. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
  206. package/src/components/storyboard/frameComments.test.ts +0 -95
  207. package/src/components/storyboard/frameComments.ts +0 -115
  208. package/src/components/storyboard/frameStatus.ts +0 -36
  209. package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
  210. package/src/components/storyboard/storyboardReviewStage.ts +0 -45
  211. package/src/components/storyboard/useFrameComments.ts +0 -98
  212. package/src/contexts/ViewModeContext.tsx +0 -142
  213. package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
  214. package/src/hooks/useProjectSignaturePoll.ts +0 -68
  215. package/src/hooks/useStoryboard.ts +0 -99
@@ -0,0 +1,65 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { useLintModal } from "./useLintModal";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ let root: Root | null = null;
11
+ let latest: ReturnType<typeof useLintModal> | null = null;
12
+
13
+ function Probe() {
14
+ latest = useLintModal("demo");
15
+ return null;
16
+ }
17
+
18
+ afterEach(() => {
19
+ if (root) act(() => root?.unmount());
20
+ root = null;
21
+ latest = null;
22
+ vi.unstubAllGlobals();
23
+ });
24
+
25
+ async function mountWithLint(response: () => Promise<unknown>) {
26
+ vi.stubGlobal("fetch", vi.fn(response));
27
+ root = createRoot(document.createElement("div"));
28
+ await act(async () => root?.render(<Probe />));
29
+ await act(async () => {
30
+ await latest?.handleLint();
31
+ });
32
+ }
33
+
34
+ const lintResponse = (findings: Array<{ severity: string; message: string }>) => async () => ({
35
+ json: async () => ({ findings }),
36
+ });
37
+
38
+ describe("useLintModal hasLintError", () => {
39
+ it("is true when a finding is an error and false for warnings only", async () => {
40
+ await mountWithLint(lintResponse([{ severity: "error", message: "nested" }]));
41
+ expect(latest?.hasLintError).toBe(true);
42
+ act(() => root?.unmount());
43
+ root = null;
44
+ await mountWithLint(lintResponse([{ severity: "warning", message: "soft" }]));
45
+ expect(latest?.hasLintError).toBe(false);
46
+ });
47
+
48
+ it("is true for a mixed error and warning list", async () => {
49
+ await mountWithLint(
50
+ lintResponse([
51
+ { severity: "warning", message: "soft" },
52
+ { severity: "error", message: "nested" },
53
+ ]),
54
+ );
55
+ expect(latest?.hasLintError).toBe(true);
56
+ });
57
+
58
+ it("is true when the manual lint itself fails, matching the badge count", async () => {
59
+ await mountWithLint(async () => {
60
+ throw new Error("offline");
61
+ });
62
+ expect(latest?.lintModal).toHaveLength(1);
63
+ expect(latest?.hasLintError).toBe(true);
64
+ });
65
+ });
@@ -96,6 +96,8 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
96
96
 
97
97
  const findingsByElement = useMemo(() => groupFindings((f) => f.elementId), [groupFindings]);
98
98
  const findingsByFile = useMemo(() => groupFindings((f) => f.file), [groupFindings]);
99
+ // Reads the list the badge count reads: the manual result when present, else the background one.
100
+ const hasLintError = (lintModal ?? backgroundFindings).some((f) => f.severity === "error");
99
101
 
100
102
  // Sync lint findings directly to the player store — eliminates the
101
103
  // mirroring useEffect that was previously in App.tsx.
@@ -111,5 +113,6 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
111
113
  backgroundFindings,
112
114
  findingsByElement,
113
115
  findingsByFile,
116
+ hasLintError,
114
117
  };
115
118
  }
@@ -164,17 +164,62 @@ describe("useSdkSession unavailable telemetry", () => {
164
164
 
165
165
  // Every cutover chokepoint silently takes the server path when there is no
166
166
  // session, and the shadow never runs either — so a missing session is a total,
167
- // otherwise-invisible SDK bypass. These three exits are its only origin.
168
- it("reports an unreadable composition", async () => {
167
+ // otherwise-invisible SDK bypass. These exits are its only origin.
168
+ //
169
+ // `stage: read` carries a reason because it was the largest remaining class
170
+ // and a single opaque string: 56 users hit it in a 7-day window and never
171
+ // landed one successful SDK edit between them, with no way to tell a genuinely
172
+ // absent file from a request that never reached one.
173
+ it("reports the HTTP status when the read fails", async () => {
169
174
  vi.stubGlobal(
170
175
  "fetch",
171
- vi.fn(async () => ({ ok: false, json: async () => ({}) }) as Response),
176
+ vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) }) as Response),
172
177
  );
173
178
  const root = createRoot(document.createElement("div"));
174
179
  await act(async () => root.render(<Probe projectId="project-a" />));
175
180
  await flushAsyncEffects();
176
181
 
177
- expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", { stage: "read" });
182
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
183
+ stage: "read",
184
+ reason: "http_error",
185
+ status: 404,
186
+ });
187
+ await act(async () => root.unmount());
188
+ });
189
+
190
+ it("separates an unexpected response shape from a failed request", async () => {
191
+ vi.stubGlobal(
192
+ "fetch",
193
+ vi.fn(async () => ({ ok: true, json: async () => ({}) }) as Response),
194
+ );
195
+ const root = createRoot(document.createElement("div"));
196
+ await act(async () => root.render(<Probe projectId="project-a" />));
197
+ await flushAsyncEffects();
198
+
199
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
200
+ stage: "read",
201
+ reason: "missing_content",
202
+ });
203
+ await act(async () => root.unmount());
204
+ });
205
+
206
+ // `optional=1` answers a file that is not on disk with 200 + an empty string,
207
+ // so this is what "the composition genuinely is not there" looks like on the
208
+ // wire — previously indistinguishable from a broken request. The shim and a
209
+ // real 0-byte file are the same response, hence the name.
210
+ it("separates a file that is not on disk", async () => {
211
+ vi.stubGlobal(
212
+ "fetch",
213
+ vi.fn(async () => ({ ok: true, json: async () => ({ content: "" }) }) as Response),
214
+ );
215
+ const root = createRoot(document.createElement("div"));
216
+ await act(async () => root.render(<Probe projectId="project-a" />));
217
+ await flushAsyncEffects();
218
+
219
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
220
+ stage: "read",
221
+ reason: "absent_or_empty",
222
+ });
178
223
  await act(async () => root.unmount());
179
224
  });
180
225
 
@@ -7,25 +7,53 @@ import type { PublishSdkSession } from "../utils/sdkCutover";
7
7
  import { addExternalFileReloadListener } from "./externalFileReloadBus";
8
8
 
9
9
  /**
10
- * Read a project file's content, or undefined on a non-2xx (optional read).
11
- * Replaces the removed SDK http adapter's `read()` — the only thing Studio used
12
- * it for (Studio is the sole writer, so the adapter's write path was dead).
10
+ * Why an optional project-file read produced no usable content. `stage: "read"`
11
+ * was a single opaque reason covering all of these, which made the largest
12
+ * remaining class of SDK-session failures undiagnosable: 56 users in a 7-day
13
+ * window hit it and, between them, never landed a single successful SDK edit.
14
+ * Knowing which branch fired is the difference between "the file legitimately
15
+ * is not there" and "the request never reached the file".
16
+ *
17
+ * Every reason lives in this union so the full surface is readable from one
18
+ * place — `absent_or_empty` included, even though it is a 2xx.
19
+ */
20
+ type ProjectFileReadFailure =
21
+ | { ok: false; reason: "unsafe_path" }
22
+ | { ok: false; reason: "http_error"; status: number }
23
+ | { ok: false; reason: "missing_content" }
24
+ | { ok: false; reason: "absent_or_empty" };
25
+
26
+ type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
27
+
28
+ /**
29
+ * Read a project file's content (optional read — a missing file is not an
30
+ * error). Replaces the removed SDK http adapter's `read()` — the only thing
31
+ * Studio used it for (Studio is the sole writer, so the adapter's write path
32
+ * was dead).
13
33
  */
14
34
  async function readProjectFileOptional(
15
35
  projectId: string,
16
36
  path: string,
17
- ): Promise<string | undefined> {
37
+ ): Promise<ProjectFileReadResult> {
18
38
  // Reject traversal / NUL before building the request URL — `path` is a
19
39
  // user-influenced composition path (mirrors the guard in timelineEditingHelpers,
20
40
  // and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
21
41
  // already confines both values to single segments of this same-origin URL.
22
- if (path.includes("\0") || path.includes("..")) return undefined;
42
+ if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
23
43
  const res = await fetch(
24
44
  `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
25
45
  );
26
- if (!res.ok) return undefined;
46
+ if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
27
47
  const data = (await res.json()) as { content?: string };
28
- return typeof data.content === "string" ? data.content : undefined;
48
+ // `optional=1` answers a missing file with 200 + `content: ""`, so a
49
+ // non-string here means a response shape we did not expect, not absence.
50
+ if (typeof data.content !== "string") return { ok: false, reason: "missing_content" };
51
+ // An empty body parses into a session with no elements, which declines every
52
+ // edit wholesale — not a session worth opening. The absent-file shim and a
53
+ // genuinely 0-byte file are the same 200 on the wire and cannot be told
54
+ // apart here, hence the name; for a composition it is always the former.
55
+ if (data.content === "") return { ok: false, reason: "absent_or_empty" };
56
+ return { ok: true, content: data.content };
29
57
  }
30
58
 
31
59
  /**
@@ -180,16 +208,21 @@ export function useSdkSession(
180
208
  };
181
209
 
182
210
  readProjectFileOptional(projectId, activeCompPath)
183
- .then(async (content) => {
211
+ .then(async (read) => {
184
212
  if (cancelled) return;
185
- if (typeof content !== "string") {
213
+ if (!read.ok) {
186
214
  // No SDK session follows, so EVERY cutover chokepoint below takes the
187
215
  // server path and emits nothing — the shadow never runs either. This
188
216
  // is the only place a missing session can originate, so a broken read
189
217
  // would otherwise be a silent, total SDK bypass.
190
- trackStudioEvent("sdk_session_unavailable", { stage: "read" });
218
+ trackStudioEvent("sdk_session_unavailable", {
219
+ stage: "read",
220
+ reason: read.reason,
221
+ ...(read.reason === "http_error" ? { status: read.status } : {}),
222
+ });
191
223
  return;
192
224
  }
225
+ const content = read.content;
193
226
  // No persist queue: Studio's writeProjectFile (via sdkCutover's
194
227
  // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
195
228
  // queue too would double-write the file (queue auto-writes on every
@@ -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/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>
@@ -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"}