@hyperframes/studio 0.8.51 → 0.8.53-desktop.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/assets/{hyperframes-player-Cn_PpHqP.js → hyperframes-player-PTEnAHhg.js} +1 -1
  2. package/dist/assets/{index-CPS3Cvj8.js → index--myXp8ui.js} +1 -1
  3. package/dist/assets/{index-Clrb11lj.js → index-BjslOS9g.js} +1 -1
  4. package/dist/assets/index-BnXZuIJD.css +1 -0
  5. package/dist/assets/index-CNyviK_l.js +390 -0
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +808 -12
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +8109 -7583
  11. package/dist/index.js.map +1 -1
  12. package/package.json +9 -7
  13. package/src/App.tsx +45 -50
  14. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  15. package/src/captions/components/CaptionOverlay.tsx +8 -4
  16. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  17. package/src/components/EditorShell.tsx +56 -56
  18. package/src/components/RightPanelTabs.test.tsx +151 -0
  19. package/src/components/RightPanelTabs.tsx +59 -0
  20. package/src/components/StudioHeader.dom.test.tsx +181 -0
  21. package/src/components/StudioHeader.test.ts +0 -12
  22. package/src/components/StudioHeader.tsx +117 -160
  23. package/src/components/StudioLeftPanels.tsx +202 -0
  24. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -194
  25. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  26. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  27. package/src/components/TimelineHistoryButtons.tsx +54 -0
  28. package/src/components/TimelineToolbar.test.tsx +8 -0
  29. package/src/components/TimelineToolbar.tsx +3 -7
  30. package/src/components/dock/Dock.test.tsx +100 -18
  31. package/src/components/dock/Dock.tsx +32 -5
  32. package/src/components/dock/dock.css +5 -0
  33. package/src/components/dock/dockAccessibility.test.ts +166 -0
  34. package/src/components/dock/dockAccessibility.ts +98 -0
  35. package/src/components/dock/dockLayout.test.ts +134 -0
  36. package/src/components/dock/dockLayout.ts +53 -6
  37. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  38. package/src/components/dock/dockLayoutStore.ts +21 -4
  39. package/src/components/dock/dockTestHarness.ts +11 -0
  40. package/src/components/editor/LayersPanel.test.ts +97 -1
  41. package/src/components/editor/LayersPanel.tsx +4 -2
  42. package/src/components/editor/PreviewGuides.tsx +1 -1
  43. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  44. package/src/components/editor/TopologyLens.test.tsx +28 -0
  45. package/src/components/editor/TopologyLens.tsx +3 -1
  46. package/src/components/editor/useColorGradingController.test.ts +38 -0
  47. package/src/components/editor/useColorGradingController.ts +9 -1
  48. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  49. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  50. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  51. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  52. package/src/components/nle/NLEContext.tsx +0 -43
  53. package/src/components/nle/NLEPreview.tsx +1 -1
  54. package/src/components/nle/PreviewPane.tsx +1 -1
  55. package/src/components/nle/TimelinePane.tsx +53 -67
  56. package/src/components/panels/SlideshowPanel.tsx +1 -1
  57. package/src/components/panels/VariablesPanel.tsx +1 -1
  58. package/src/components/pointerTargetSize.test.tsx +8 -0
  59. package/src/components/renders/RenderQueue.tsx +1 -1
  60. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  61. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  62. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  63. package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
  64. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  65. package/src/components/timelineToolbarStyles.ts +5 -0
  66. package/src/components/ui/Button.test.tsx +222 -30
  67. package/src/components/ui/Button.tsx +88 -125
  68. package/src/components/ui/IconButton.tsx +38 -0
  69. package/src/components/ui/Input.tsx +115 -0
  70. package/src/components/ui/Menu.test.tsx +374 -0
  71. package/src/components/ui/Menu.tsx +213 -0
  72. package/src/components/ui/NumberField.tsx +122 -0
  73. package/src/components/ui/Popover.tsx +74 -0
  74. package/src/components/ui/Select.tsx +100 -0
  75. package/src/components/ui/Slider.tsx +153 -0
  76. package/src/components/ui/Tabs.test.tsx +98 -0
  77. package/src/components/ui/Tabs.tsx +71 -0
  78. package/src/components/ui/Toggle.tsx +63 -0
  79. package/src/components/ui/Tooltip.test.tsx +110 -0
  80. package/src/components/ui/Tooltip.tsx +38 -112
  81. package/src/components/ui/cn.test.ts +30 -0
  82. package/src/components/ui/cn.ts +25 -0
  83. package/src/components/ui/index.ts +29 -2
  84. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  85. package/src/components/ui/valueControls.test.tsx +524 -0
  86. package/src/contexts/PanelLayoutContext.tsx +3 -57
  87. package/src/hooks/appHotkeysDispatch.ts +3 -4
  88. package/src/hooks/timelineEditPermission.ts +31 -0
  89. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  90. package/src/hooks/useAppHotkeys.test.ts +0 -1
  91. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  92. package/src/hooks/useAppHotkeys.ts +20 -83
  93. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  94. package/src/hooks/useCaptionDetection.ts +3 -1
  95. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  96. package/src/hooks/useDomEditSession.ts +0 -7
  97. package/src/hooks/useDomEditWiring.ts +4 -9
  98. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  99. package/src/hooks/useEditHistoryActions.ts +106 -0
  100. package/src/hooks/useFileManager.ts +21 -123
  101. package/src/hooks/usePanelLayout.test.ts +108 -296
  102. package/src/hooks/usePanelLayout.ts +50 -255
  103. package/src/hooks/usePersistentEditHistory.ts +1 -1
  104. package/src/hooks/useProjectFileWriter.ts +150 -0
  105. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  106. package/src/hooks/useRightPanelIntents.ts +63 -0
  107. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  108. package/src/hooks/useServerConnection.ts +43 -10
  109. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  110. package/src/hooks/useSlideshowTabState.ts +6 -19
  111. package/src/hooks/useStudioContextValue.test.ts +6 -18
  112. package/src/hooks/useStudioContextValue.ts +5 -26
  113. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  114. package/src/hooks/useTimelineEditing.test.tsx +322 -3
  115. package/src/hooks/useTimelineEditing.ts +103 -16
  116. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  117. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  118. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  119. package/src/hostMount.test.tsx +222 -0
  120. package/src/index.ts +87 -0
  121. package/src/player/components/AutomationSelectionMenu.tsx +1 -1
  122. package/src/player/components/Timeline.tsx +1 -1
  123. package/src/player/components/TimelineCanvas.tsx +4 -4
  124. package/src/player/components/TimelineClip.tsx +0 -1
  125. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  126. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  127. package/src/player/components/timelineTheme.test.ts +4 -4
  128. package/src/player/components/timelineTheme.ts +30 -30
  129. package/src/player/lib/playbackShortcuts.ts +2 -0
  130. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  131. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  132. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  133. package/src/player/store/previewIframeStore.test.tsx +29 -0
  134. package/src/player/store/previewIframeStore.ts +25 -0
  135. package/src/renderQueueExports.test.tsx +58 -0
  136. package/src/styles/classCandidates.test.ts +105 -0
  137. package/src/styles/classCandidates.ts +281 -0
  138. package/src/styles/hex-baseline.json +84 -0
  139. package/src/styles/hexRatchet.test.ts +201 -0
  140. package/src/styles/styleSources.test.ts +20 -0
  141. package/src/styles/styleSources.ts +47 -0
  142. package/src/styles/theme.css +57 -0
  143. package/src/styles/theme.test.ts +2 -15
  144. package/src/styles/tokenGate.test.ts +109 -0
  145. package/src/utils/sourcePatcher.ts +1 -1
  146. package/src/utils/studioHelpers.test.ts +18 -0
  147. package/src/utils/studioHelpers.ts +11 -8
  148. package/src/utils/studioUiPreferences.test.ts +10 -16
  149. package/src/utils/studioUiPreferences.ts +0 -16
  150. package/src/utils/typingTarget.ts +4 -0
  151. package/dist/assets/index-C05SNrvY.js +0 -360
  152. package/dist/assets/index-DUR9qzQM.css +0 -1
  153. package/src/components/PanelTabButton.tsx +0 -31
  154. package/src/components/StudioLeftSidebar.tsx +0 -207
  155. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  156. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  157. package/src/components/sidebar/LeftSidebar.tsx +0 -348
  158. package/src/hooks/useInspectorSplitResize.ts +0 -51
  159. package/src/utils/fitPanels.test.ts +0 -164
  160. package/src/utils/fitPanels.ts +0 -151
@@ -122,12 +122,14 @@ function renderTimelineEditingHook(input: {
122
122
  forceReloadSdkSession?: () => void;
123
123
  invalidateGsapCache?: () => void;
124
124
  showToast?: (message: string, kind?: string) => void;
125
+ canEdit?: NonNullable<Parameters<typeof useTimelineEditing>[0]["canEdit"]>;
125
126
  }): {
126
127
  move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
127
128
  resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
128
129
  groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"];
129
130
  groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
130
131
  del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"];
132
+ elementsDelete: ReturnType<typeof useTimelineEditing>["handleTimelineElementsDelete"];
131
133
  unmount: () => void;
132
134
  } {
133
135
  let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
@@ -135,6 +137,8 @@ function renderTimelineEditingHook(input: {
135
137
  let groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"] | null = null;
136
138
  let groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"] | null = null;
137
139
  let del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"] | null = null;
140
+ let elementsDelete: ReturnType<typeof useTimelineEditing>["handleTimelineElementsDelete"] | null =
141
+ null;
138
142
 
139
143
  function Harness() {
140
144
  const commitRef = useRef(input.onZIndexCommit);
@@ -155,12 +159,14 @@ function renderTimelineEditingHook(input: {
155
159
  forceReloadSdkSession: input.forceReloadSdkSession,
156
160
  invalidateGsapCache: input.invalidateGsapCache,
157
161
  handleDomZIndexReorderCommitRef: commitRef,
162
+ canEdit: input.canEdit,
158
163
  });
159
164
  move = hook.handleTimelineElementMove;
160
165
  resize = hook.handleTimelineElementResize;
161
166
  groupMove = hook.handleTimelineGroupMove;
162
167
  groupResize = hook.handleTimelineGroupResize;
163
168
  del = hook.handleTimelineElementDelete;
169
+ elementsDelete = hook.handleTimelineElementsDelete;
164
170
  return null;
165
171
  }
166
172
 
@@ -170,7 +176,8 @@ function renderTimelineEditingHook(input: {
170
176
  if (!groupMove) throw new Error("Expected hook to expose group move handler");
171
177
  if (!groupResize) throw new Error("Expected hook to expose group resize handler");
172
178
  if (!del) throw new Error("Expected hook to expose delete handler");
173
- return { move, resize, groupMove, groupResize, del, unmount };
179
+ if (!elementsDelete) throw new Error("Expected hook to expose elements-delete handler");
180
+ return { move, resize, groupMove, groupResize, del, elementsDelete, unmount };
174
181
  }
175
182
 
176
183
  type TimelineRecordEdit = NonNullable<
@@ -289,6 +296,7 @@ function setupSingleClipHarness(options?: {
289
296
  source?: string;
290
297
  clipStyle?: string;
291
298
  onZIndexCommit?: (entries: ZIndexEntry[]) => Promise<void>;
299
+ canEdit?: NonNullable<Parameters<typeof useTimelineEditing>[0]["canEdit"]>;
292
300
  }) {
293
301
  const iframe = createPreviewIframe([{ id: "clip", track: 0, style: options?.clipStyle }]);
294
302
  const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
@@ -300,16 +308,30 @@ function setupSingleClipHarness(options?: {
300
308
  const fetchMock = stubProjectFetch(
301
309
  options?.source ?? '<div id="clip" data-start="0" data-track-index="0"></div>',
302
310
  );
311
+ const recordEdit = vi.fn(async () => {});
312
+ const showToast = vi.fn();
303
313
  const hook = renderTimelineEditingHook({
304
314
  timelineElements: [clip],
305
315
  iframe,
306
316
  onZIndexCommit: commit,
307
317
  projectId: "p1",
308
318
  writeProjectFile,
309
- recordEdit: vi.fn(async () => {}),
319
+ recordEdit,
310
320
  reloadPreview,
321
+ showToast,
322
+ canEdit: options?.canEdit,
311
323
  });
312
- return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook };
324
+ return {
325
+ iframe,
326
+ clip,
327
+ commit,
328
+ writeProjectFile,
329
+ recordEdit,
330
+ showToast,
331
+ reloadPreview,
332
+ fetchMock,
333
+ ...hook,
334
+ };
313
335
  }
314
336
 
315
337
  const SDK_KEYFRAMED_SOURCE = [
@@ -1431,3 +1453,300 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
1431
1453
  succeeding.unmount();
1432
1454
  });
1433
1455
  });
1456
+
1457
+ // Blocked means no fetch write, no recordEdit, and the host's reason
1458
+ // toasted. Absent canEdit behaves exactly as before (asserted above).
1459
+ describe("useTimelineEditing: canEdit gate", () => {
1460
+ it("refuses a move with the host's reason, writing nothing", async () => {
1461
+ const { clip, move, writeProjectFile, recordEdit, showToast, unmount } = setupSingleClipHarness(
1462
+ {
1463
+ canEdit: () => ({ blocked: true, reason: "Reserved by an agent" }),
1464
+ },
1465
+ );
1466
+
1467
+ await act(async () => {
1468
+ await move(clip, { start: 3, track: clip.track });
1469
+ await flushAsyncWork();
1470
+ });
1471
+
1472
+ expect(writeProjectFile).not.toHaveBeenCalled();
1473
+ expect(recordEdit).not.toHaveBeenCalled();
1474
+ expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1475
+ unmount();
1476
+ });
1477
+
1478
+ it("refuses a delete (single and multi) with nothing written", async () => {
1479
+ const { clip, del, elementsDelete, writeProjectFile, recordEdit, unmount } =
1480
+ setupSingleClipHarness({
1481
+ canEdit: () => ({ blocked: true, reason: "Reserved by an agent" }),
1482
+ });
1483
+
1484
+ await act(async () => {
1485
+ await del(clip);
1486
+ await elementsDelete([clip]);
1487
+ await flushAsyncWork();
1488
+ });
1489
+
1490
+ expect(writeProjectFile).not.toHaveBeenCalled();
1491
+ expect(recordEdit).not.toHaveBeenCalled();
1492
+ unmount();
1493
+ });
1494
+
1495
+ it("resolves toggle-track-hidden against the expanded rows, not the raw timelineElements prop", async () => {
1496
+ // Regression: canEdit must resolve against the expanded rows
1497
+ // (timelineTrackVisibility.ts's own invariant), not the raw
1498
+ // timelineElements prop — proven by making the two disagree below.
1499
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
1500
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
1501
+ usePlayerStore.getState().setElements([clip]);
1502
+ const showToast = vi.fn();
1503
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1504
+ let hook: ReturnType<typeof useTimelineEditing> | null = null;
1505
+ function Harness() {
1506
+ hook = useTimelineEditing({
1507
+ projectId: "p1",
1508
+ activeCompPath: "index.html",
1509
+ timelineElements: [], // deliberately does not contain `clip`
1510
+ showToast,
1511
+ writeProjectFile,
1512
+ recordEdit: vi.fn(),
1513
+ reloadPreview: vi.fn(),
1514
+ previewIframeRef: { current: iframe },
1515
+ pendingTimelineEditPathRef: { current: new Set<string>() },
1516
+ uploadProjectFiles: vi.fn(),
1517
+ canEdit: (element) =>
1518
+ element.id === "clip" ? { blocked: true, reason: "Reserved by an agent" } : true,
1519
+ });
1520
+ return null;
1521
+ }
1522
+ const { unmount } = mountHarness(<Harness />);
1523
+ if (!hook) throw new Error("Expected hook to mount");
1524
+
1525
+ await act(async () => {
1526
+ await hook!.handleToggleTrackHidden(0, true);
1527
+ await flushAsyncWork();
1528
+ });
1529
+
1530
+ expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1531
+ expect(writeProjectFile).not.toHaveBeenCalled();
1532
+ unmount();
1533
+ });
1534
+
1535
+ it("lets an unblocked element through while a blocked one is refused", async () => {
1536
+ const iframe = createPreviewIframe([
1537
+ { id: "free", track: 0 },
1538
+ { id: "locked", track: 1 },
1539
+ ]);
1540
+ const free = timelineElement({ id: "free", track: 0, zIndex: 0 });
1541
+ const locked = timelineElement({ id: "locked", track: 1, zIndex: 0 });
1542
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1543
+ const recordEdit = vi.fn(async () => {});
1544
+ stubProjectFetch(
1545
+ '<div id="free" data-start="0" data-track-index="0"></div>' +
1546
+ '<div id="locked" data-start="0" data-track-index="1"></div>',
1547
+ );
1548
+ const hook = renderTimelineEditingHook({
1549
+ timelineElements: [free, locked],
1550
+ iframe,
1551
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1552
+ projectId: "p1",
1553
+ writeProjectFile,
1554
+ recordEdit,
1555
+ canEdit: (element) =>
1556
+ element.id === "locked" ? { blocked: true, reason: "Reserved by an agent" } : true,
1557
+ });
1558
+
1559
+ await act(async () => {
1560
+ await hook.move(locked, { start: 3, track: locked.track });
1561
+ await flushAsyncWork();
1562
+ });
1563
+ expect(writeProjectFile).not.toHaveBeenCalled();
1564
+
1565
+ await act(async () => {
1566
+ await hook.move(free, { start: 3, track: free.track });
1567
+ await flushAsyncWork();
1568
+ });
1569
+ expect(writeProjectFile).toHaveBeenCalled();
1570
+ hook.unmount();
1571
+ });
1572
+
1573
+ it("refuses razor-split-all when it would split a blocked clip, writing nothing", async () => {
1574
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
1575
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 2 });
1576
+ usePlayerStore.getState().setElements([clip]);
1577
+ const showToast = vi.fn();
1578
+ const fetchMock = vi.fn(async () => {
1579
+ throw new Error("must not be called: canEdit should have refused the split");
1580
+ });
1581
+ vi.stubGlobal("fetch", fetchMock);
1582
+ let hook: ReturnType<typeof useTimelineEditing> | null = null;
1583
+ function Harness() {
1584
+ hook = useTimelineEditing({
1585
+ projectId: "p1",
1586
+ activeCompPath: "index.html",
1587
+ timelineElements: [clip],
1588
+ showToast,
1589
+ writeProjectFile: vi.fn(),
1590
+ recordEdit: vi.fn(),
1591
+ reloadPreview: vi.fn(),
1592
+ previewIframeRef: { current: iframe },
1593
+ pendingTimelineEditPathRef: { current: new Set<string>() },
1594
+ uploadProjectFiles: vi.fn(),
1595
+ canEdit: () => ({ blocked: true, reason: "Reserved by an agent" }),
1596
+ });
1597
+ return null;
1598
+ }
1599
+ const { unmount } = mountHarness(<Harness />);
1600
+ if (!hook) throw new Error("Expected hook to mount");
1601
+
1602
+ await act(async () => {
1603
+ await hook!.handleRazorSplitAll(1);
1604
+ await flushAsyncWork();
1605
+ });
1606
+
1607
+ expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1608
+ expect(fetchMock).not.toHaveBeenCalled();
1609
+ unmount();
1610
+ });
1611
+
1612
+ it("refuses a blocked group's audio attribute write, writing nothing", async () => {
1613
+ const iframe = createPreviewIframe([{ id: "member", track: 0 }]);
1614
+ const member = timelineElement({ id: "member", track: 0, zIndex: 0 });
1615
+ usePlayerStore.getState().setElements([{ ...member, audioGroup: "hf-group" }]);
1616
+ const showToast = vi.fn();
1617
+ const fetchMock = vi.fn(async () => {
1618
+ throw new Error("must not be called: canEdit should have refused the write");
1619
+ });
1620
+ vi.stubGlobal("fetch", fetchMock);
1621
+ let hook: ReturnType<typeof useTimelineEditing> | null = null;
1622
+ function Harness() {
1623
+ hook = useTimelineEditing({
1624
+ projectId: "p1",
1625
+ activeCompPath: "index.html",
1626
+ timelineElements: [member],
1627
+ showToast,
1628
+ writeProjectFile: vi.fn(),
1629
+ recordEdit: vi.fn(),
1630
+ reloadPreview: vi.fn(),
1631
+ previewIframeRef: { current: iframe },
1632
+ pendingTimelineEditPathRef: { current: new Set<string>() },
1633
+ uploadProjectFiles: vi.fn(),
1634
+ canEdit: (element) =>
1635
+ element.id === "member" ? { blocked: true, reason: "Reserved by an agent" } : true,
1636
+ });
1637
+ return null;
1638
+ }
1639
+ const { unmount } = mountHarness(<Harness />);
1640
+ if (!hook) throw new Error("Expected hook to mount");
1641
+
1642
+ await act(async () => {
1643
+ await hook!.setAudioGroupAttribute.setQuiet("hf-group", "data-volume", "0.5", "Set volume");
1644
+ await flushAsyncWork();
1645
+ });
1646
+
1647
+ expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1648
+ expect(fetchMock).not.toHaveBeenCalled();
1649
+ unmount();
1650
+ });
1651
+
1652
+ it("refuses a group write when its members live only inside a sub-composition", async () => {
1653
+ // syncStoredGroupAttribute mirrors into domClipChildren for a group with
1654
+ // no flat twin (timelineAudioGroupVolume.ts) — the resolver must check
1655
+ // that array too, or a sub-comp-only group's write goes ungated.
1656
+ const iframe = createPreviewIframe([]);
1657
+ usePlayerStore.getState().setElements([]);
1658
+ usePlayerStore.getState().setDomClipChildren([
1659
+ {
1660
+ id: "sub-member",
1661
+ parentId: "host",
1662
+ hostId: "host",
1663
+ label: "Sub member",
1664
+ stackingContextId: "root",
1665
+ audioGroup: "hf-group",
1666
+ },
1667
+ ]);
1668
+ const showToast = vi.fn();
1669
+ const fetchMock = vi.fn(async () => {
1670
+ throw new Error("must not be called: canEdit should have refused the write");
1671
+ });
1672
+ vi.stubGlobal("fetch", fetchMock);
1673
+ let hook: ReturnType<typeof useTimelineEditing> | null = null;
1674
+ function Harness() {
1675
+ hook = useTimelineEditing({
1676
+ projectId: "p1",
1677
+ activeCompPath: "index.html",
1678
+ timelineElements: [],
1679
+ showToast,
1680
+ writeProjectFile: vi.fn(),
1681
+ recordEdit: vi.fn(),
1682
+ reloadPreview: vi.fn(),
1683
+ previewIframeRef: { current: iframe },
1684
+ pendingTimelineEditPathRef: { current: new Set<string>() },
1685
+ uploadProjectFiles: vi.fn(),
1686
+ canEdit: (element) =>
1687
+ element.id === "sub-member" ? { blocked: true, reason: "Reserved by an agent" } : true,
1688
+ });
1689
+ return null;
1690
+ }
1691
+ const { unmount } = mountHarness(<Harness />);
1692
+ if (!hook) throw new Error("Expected hook to mount");
1693
+
1694
+ await act(async () => {
1695
+ await hook!.setAudioGroupAttribute.setQuiet("hf-group", "data-volume", "0.5", "Set volume");
1696
+ await flushAsyncWork();
1697
+ });
1698
+
1699
+ expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1700
+ expect(fetchMock).not.toHaveBeenCalled();
1701
+ unmount();
1702
+ });
1703
+ });
1704
+
1705
+ // Regression: track()/guard() must return the same wrapped handler across
1706
+ // renders for the same fn, or a consumer using it as a memo/effect
1707
+ // dependency re-runs on every render for nothing.
1708
+ describe("useTimelineEditing: handler identity is stable across renders", () => {
1709
+ it("returns the same handleTimelineElementMove reference on a re-render", () => {
1710
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
1711
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
1712
+ stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
1713
+ // Every option below is hoisted (created once), matching a real caller's
1714
+ // stable useCallback/selector inputs — a fresh vi.fn() per render would
1715
+ // change handleTimelineElementMove's own identity regardless of the fix.
1716
+ const timelineElements = [clip];
1717
+ const showToast = vi.fn();
1718
+ const writeProjectFile = vi.fn();
1719
+ const recordEdit = vi.fn();
1720
+ const reloadPreview = vi.fn();
1721
+ const uploadProjectFiles = vi.fn();
1722
+ const previewIframeRef = { current: iframe };
1723
+ const pendingTimelineEditPathRef = { current: new Set<string>() };
1724
+ const seen: unknown[] = [];
1725
+ let bumpTick = 0;
1726
+ let bump = () => {};
1727
+ function Harness() {
1728
+ const [, setTick] = React.useState(0);
1729
+ bump = () => setTick((t) => t + 1);
1730
+ bumpTick += 1;
1731
+ const hook = useTimelineEditing({
1732
+ projectId: "p1",
1733
+ activeCompPath: "index.html",
1734
+ timelineElements,
1735
+ showToast,
1736
+ writeProjectFile,
1737
+ recordEdit,
1738
+ reloadPreview,
1739
+ previewIframeRef,
1740
+ pendingTimelineEditPathRef,
1741
+ uploadProjectFiles,
1742
+ });
1743
+ seen.push(hook.handleTimelineElementMove);
1744
+ return null;
1745
+ }
1746
+ const { unmount } = mountHarness(<Harness />);
1747
+ act(() => bump());
1748
+ expect(bumpTick).toBeGreaterThan(1);
1749
+ expect(seen[0]).toBe(seen[1]);
1750
+ unmount();
1751
+ });
1752
+ });
@@ -1,7 +1,9 @@
1
1
  // fallow-ignore-file complexity
2
2
  import { useCallback, useRef } from "react";
3
3
  import type { TimelineElement } from "../player";
4
+ import { usePlayerStore } from "../player";
4
5
  import { useRazorSplit } from "./useRazorSplit";
6
+ import { selectSplittableElements } from "../utils/timelineElementSplit";
5
7
  import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
6
8
  import {
7
9
  applyTimelineStackingReorder,
@@ -23,6 +25,8 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
23
25
  import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
24
26
  import { useSetElementAttribute } from "./timelineElementFxAttribute";
25
27
  import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
28
+ import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
29
+ import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
26
30
  import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
27
31
  import {
28
32
  useTimelineElementVisibilityEditing,
@@ -30,6 +34,7 @@ import {
30
34
  } from "./timelineTrackVisibility";
31
35
  import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
32
36
  import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
37
+ import { useTimelineEditGate } from "./timelineEditPermission";
33
38
  import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
34
39
  import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
35
40
  import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
@@ -53,10 +58,38 @@ export function useTimelineEditing({
53
58
  forceReloadSdkSession,
54
59
  invalidateGsapCache,
55
60
  handleDomZIndexReorderCommitRef,
61
+ canEdit,
56
62
  }: UseTimelineEditingOptions) {
57
63
  const projectIdRef = useRef(projectId);
58
64
  projectIdRef.current = projectId;
59
65
  const editQueueRef = useRef(Promise.resolve());
66
+ const track = useTrackPendingTimelineEdit();
67
+ const checkEditable = useTimelineEditGate(canEdit, showToast);
68
+ // Same expanded-row source the hide handlers themselves resolve against
69
+ // (timelineTrackVisibility.ts) — a virtual sub-comp child's track/key
70
+ // only exists here, not in the raw store list canEdit would otherwise miss.
71
+ const timelineRowElements = useTimelineRowElements();
72
+ const guardedRef = useRef(
73
+ new WeakMap<(...args: never[]) => Promise<void>, (...args: never[]) => Promise<void>>(),
74
+ );
75
+ // Refuses (no call, no write, no history entry) when any target is
76
+ // blocked; otherwise runs fn as before. Cached by fn identity — like
77
+ // track() — so a fresh closure here doesn't defeat track's own cache.
78
+ const guard = useCallback(
79
+ <H extends (...args: never[]) => Promise<void>>(
80
+ resolveTargets: (...args: Parameters<H>) => readonly TimelineElement[],
81
+ fn: H,
82
+ ): H => {
83
+ const key = fn as unknown as (...args: never[]) => Promise<void>;
84
+ const cached = guardedRef.current.get(key);
85
+ if (cached) return cached as H;
86
+ const wrapped = ((...args: Parameters<H>) =>
87
+ checkEditable(resolveTargets(...args)) ? fn(...args) : Promise.resolve()) as H;
88
+ guardedRef.current.set(key, wrapped as unknown as (...args: never[]) => Promise<void>);
89
+ return wrapped;
90
+ },
91
+ [checkEditable],
92
+ );
60
93
 
61
94
  const enqueueEdit = useCallback(
62
95
  (
@@ -445,23 +478,77 @@ export function useTimelineEditing({
445
478
  forceReloadSdkSession,
446
479
  });
447
480
 
481
+ // Every write-handler is tracked here, the one place all hand edits
482
+ // converge, so undo never races a write; canEdit gates the same point.
483
+ // Coverage boundary: see the PR body, not every kind resolves an element.
484
+ const trackedRazorSplit = track(guard((element) => [element], handleRazorSplit));
448
485
  return {
449
- handleTimelineElementMove,
450
- handleTimelineElementResize,
451
- handleToggleTrackHidden,
452
- handleToggleElementHidden,
453
- handleAutoGroupCarveSources,
454
- setAudioGroupAttribute,
455
- setElementFxAttribute,
456
- handleTimelineElementDelete,
457
- handleTimelineElementsDelete,
458
- handleTimelineElementSplit: handleRazorSplit,
459
- handleRazorSplit,
460
- handleRazorSplitAll,
461
- handleTimelineAssetDrop,
462
- handleTimelineFileDrop,
463
- handleTimelineCompositionDrop,
486
+ handleTimelineElementMove: track(guard((element) => [element], handleTimelineElementMove)),
487
+ handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
488
+ handleToggleTrackHidden: track(
489
+ guard(
490
+ (trackIndex) => timelineRowElements.filter((el) => el.track === trackIndex),
491
+ handleToggleTrackHidden,
492
+ ),
493
+ ),
494
+ handleToggleElementHidden: track(
495
+ guard((elementKey) => {
496
+ const keys = new Set(Array.isArray(elementKey) ? elementKey : [elementKey]);
497
+ return timelineRowElements.filter((el) => keys.has(el.key ?? el.id));
498
+ }, handleToggleElementHidden),
499
+ ),
500
+ handleAutoGroupCarveSources: track(handleAutoGroupCarveSources),
501
+ setAudioGroupAttribute: {
502
+ ...setAudioGroupAttribute,
503
+ // Same two-array member lookup syncStoredGroupAttribute mirrors into
504
+ // (timelineAudioGroupVolume.ts): a sub-composition's group members have
505
+ // no flat twin, only a domClipChildren entry, so both are checked.
506
+ setQuiet: track(
507
+ guard((groupId) => {
508
+ const state = usePlayerStore.getState();
509
+ const flatMembers = state.elements.filter((el) => el.audioGroup === groupId);
510
+ const domMembers = state.domClipChildren
511
+ .filter((child) => child.audioGroup === groupId)
512
+ .map(
513
+ (child): TimelineElement => ({
514
+ id: child.id,
515
+ domId: child.id,
516
+ tag: "div",
517
+ start: 0,
518
+ duration: 0,
519
+ track: -1,
520
+ }),
521
+ );
522
+ return [...flatMembers, ...domMembers];
523
+ }, setAudioGroupAttribute.setQuiet),
524
+ ),
525
+ },
526
+ setElementFxAttribute: {
527
+ ...setElementFxAttribute,
528
+ setQuiet: track(guard((element) => [element], setElementFxAttribute.setQuiet)),
529
+ },
530
+ handleTimelineElementDelete: track(guard((element) => [element], handleTimelineElementDelete)),
531
+ handleTimelineElementsDelete: track(
532
+ guard((elements) => elements, handleTimelineElementsDelete),
533
+ ),
534
+ handleTimelineElementSplit: trackedRazorSplit,
535
+ handleRazorSplit: trackedRazorSplit,
536
+ // Same selection the handler itself splits (useRazorSplit.ts).
537
+ handleRazorSplitAll: track(
538
+ guard(
539
+ (splitTime) => selectSplittableElements(usePlayerStore.getState().elements, splitTime),
540
+ handleRazorSplitAll,
541
+ ),
542
+ ),
543
+ handleTimelineAssetDrop: track(handleTimelineAssetDrop),
544
+ handleTimelineFileDrop: track(handleTimelineFileDrop),
545
+ handleTimelineCompositionDrop: track(handleTimelineCompositionDrop),
464
546
  handleBlockedTimelineEdit,
465
- ...groupEditing,
547
+ handleTimelineGroupMove: track(
548
+ guard((changes) => changes.map((c) => c.element), groupEditing.handleTimelineGroupMove),
549
+ ),
550
+ handleTimelineGroupResize: track(
551
+ guard((changes) => changes.map((c) => c.element), groupEditing.handleTimelineGroupResize),
552
+ ),
466
553
  };
467
554
  }
@@ -4,6 +4,7 @@ import type { TimelineElement } from "../player";
4
4
  import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
5
5
  import type { EditHistoryKind } from "../utils/editHistory";
6
6
  import type { PublishSdkSession } from "../utils/sdkCutover";
7
+ import type { CanEditTimelineElement } from "./timelineEditPermission";
7
8
 
8
9
  interface RecordEditInput {
9
10
  label: string;
@@ -49,6 +50,8 @@ export interface UseTimelineEditingOptions {
49
50
  /** Reparse authored animations after a timing rewrite changes their positions. */
50
51
  invalidateGsapCache?: () => void;
51
52
  handleDomZIndexReorderCommitRef?: MutableRefObject<TimelineZIndexReorderCommit | null>;
53
+ /** Refuses a hand edit at the persist boundary when set; absent = Studio unchanged. */
54
+ canEdit?: CanEditTimelineElement;
52
55
  }
53
56
 
54
57
  export type TimelineFileDropHandler = (
@@ -0,0 +1,62 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act, createElement } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
7
+ import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ let root: Root | null = null;
12
+ afterEach(() => act(() => root?.unmount()));
13
+
14
+ function mount() {
15
+ let track!: ReturnType<typeof useTrackPendingTimelineEdit>;
16
+ function Probe() {
17
+ track = useTrackPendingTimelineEdit();
18
+ return null;
19
+ }
20
+ root = createRoot(document.createElement("div"));
21
+ act(() => root!.render(createElement(Probe)));
22
+ return track;
23
+ }
24
+
25
+ const deferred = <T,>() => {
26
+ let resolve!: (value: T) => void;
27
+ const promise = new Promise<T>((r) => (resolve = r));
28
+ return { promise, resolve };
29
+ };
30
+
31
+ describe("useTrackPendingTimelineEdit", () => {
32
+ it("feeds Studio's shared pending-edit registry, so the app-wide flush waits for it", async () => {
33
+ const track = mount();
34
+ const gate = deferred<void>();
35
+ const order: string[] = [];
36
+ const tracked = track(() => gate.promise.then(() => order.push("edit-settled")));
37
+ const call = tracked();
38
+ const flushPromise = flushStudioPendingEdits().then(() => order.push("flushed"));
39
+ // Give the flush's own microtasks a turn: if tracking did nothing, it
40
+ // would already show "flushed" here, before the edit ever settles.
41
+ await Promise.resolve();
42
+ await Promise.resolve();
43
+ expect(order).toEqual([]);
44
+ gate.resolve();
45
+ await call;
46
+ await flushPromise;
47
+ expect(order).toEqual(["edit-settled", "flushed"]);
48
+ });
49
+
50
+ it("returns the same wrapper for the same handler across calls", () => {
51
+ const track = mount();
52
+ const handler = () => Promise.resolve();
53
+ expect(track(handler)).toBe(track(handler));
54
+ });
55
+
56
+ it("returns a different wrapper for a different handler", () => {
57
+ const track = mount();
58
+ const a = () => Promise.resolve();
59
+ const b = () => Promise.resolve();
60
+ expect(track(a)).not.toBe(track(b));
61
+ });
62
+ });
@@ -0,0 +1,22 @@
1
+ import { useCallback, useRef } from "react";
2
+ import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
3
+
4
+ type AsyncFn = (...args: never[]) => Promise<unknown>;
5
+
6
+ // Tracks a hand-edit handler's write so undo/redo (already draining this
7
+ // registry) waits for it. Same `fn` in, same wrapper out, so memoized deps stay stable.
8
+ export function useTrackPendingTimelineEdit() {
9
+ const wrappedRef = useRef(new WeakMap<AsyncFn, AsyncFn>());
10
+ return useCallback(<Args extends unknown[], R>(fn: (...args: Args) => Promise<R>) => {
11
+ const key = fn as unknown as AsyncFn;
12
+ const cached = wrappedRef.current.get(key);
13
+ if (cached) return cached as unknown as (...args: Args) => Promise<R>;
14
+ const wrapped = (...args: Args): Promise<R> => {
15
+ const result = fn(...args);
16
+ trackStudioPendingEdit(result);
17
+ return result;
18
+ };
19
+ wrappedRef.current.set(key, wrapped as unknown as AsyncFn);
20
+ return wrapped;
21
+ }, []);
22
+ }