@hyperframes/studio 0.8.51 → 0.8.53-desktop.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/assets/{hyperframes-player-Cn_PpHqP.js → hyperframes-player-PTEnAHhg.js} +1 -1
  2. package/dist/assets/{index-CPS3Cvj8.js → index--myXp8ui.js} +1 -1
  3. package/dist/assets/{index-Clrb11lj.js → index-BjslOS9g.js} +1 -1
  4. package/dist/assets/index-BnXZuIJD.css +1 -0
  5. package/dist/assets/index-CNyviK_l.js +390 -0
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +808 -12
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +8109 -7583
  11. package/dist/index.js.map +1 -1
  12. package/package.json +9 -7
  13. package/src/App.tsx +45 -50
  14. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  15. package/src/captions/components/CaptionOverlay.tsx +8 -4
  16. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  17. package/src/components/EditorShell.tsx +56 -56
  18. package/src/components/RightPanelTabs.test.tsx +151 -0
  19. package/src/components/RightPanelTabs.tsx +59 -0
  20. package/src/components/StudioHeader.dom.test.tsx +181 -0
  21. package/src/components/StudioHeader.test.ts +0 -12
  22. package/src/components/StudioHeader.tsx +117 -160
  23. package/src/components/StudioLeftPanels.tsx +202 -0
  24. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -194
  25. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  26. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  27. package/src/components/TimelineHistoryButtons.tsx +54 -0
  28. package/src/components/TimelineToolbar.test.tsx +8 -0
  29. package/src/components/TimelineToolbar.tsx +3 -7
  30. package/src/components/dock/Dock.test.tsx +100 -18
  31. package/src/components/dock/Dock.tsx +32 -5
  32. package/src/components/dock/dock.css +5 -0
  33. package/src/components/dock/dockAccessibility.test.ts +166 -0
  34. package/src/components/dock/dockAccessibility.ts +98 -0
  35. package/src/components/dock/dockLayout.test.ts +134 -0
  36. package/src/components/dock/dockLayout.ts +53 -6
  37. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  38. package/src/components/dock/dockLayoutStore.ts +21 -4
  39. package/src/components/dock/dockTestHarness.ts +11 -0
  40. package/src/components/editor/LayersPanel.test.ts +97 -1
  41. package/src/components/editor/LayersPanel.tsx +4 -2
  42. package/src/components/editor/PreviewGuides.tsx +1 -1
  43. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  44. package/src/components/editor/TopologyLens.test.tsx +28 -0
  45. package/src/components/editor/TopologyLens.tsx +3 -1
  46. package/src/components/editor/useColorGradingController.test.ts +38 -0
  47. package/src/components/editor/useColorGradingController.ts +9 -1
  48. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  49. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  50. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  51. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  52. package/src/components/nle/NLEContext.tsx +0 -43
  53. package/src/components/nle/NLEPreview.tsx +1 -1
  54. package/src/components/nle/PreviewPane.tsx +1 -1
  55. package/src/components/nle/TimelinePane.tsx +53 -67
  56. package/src/components/panels/SlideshowPanel.tsx +1 -1
  57. package/src/components/panels/VariablesPanel.tsx +1 -1
  58. package/src/components/pointerTargetSize.test.tsx +8 -0
  59. package/src/components/renders/RenderQueue.tsx +1 -1
  60. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  61. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  62. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  63. package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
  64. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  65. package/src/components/timelineToolbarStyles.ts +5 -0
  66. package/src/components/ui/Button.test.tsx +222 -30
  67. package/src/components/ui/Button.tsx +88 -125
  68. package/src/components/ui/IconButton.tsx +38 -0
  69. package/src/components/ui/Input.tsx +115 -0
  70. package/src/components/ui/Menu.test.tsx +374 -0
  71. package/src/components/ui/Menu.tsx +213 -0
  72. package/src/components/ui/NumberField.tsx +122 -0
  73. package/src/components/ui/Popover.tsx +74 -0
  74. package/src/components/ui/Select.tsx +100 -0
  75. package/src/components/ui/Slider.tsx +153 -0
  76. package/src/components/ui/Tabs.test.tsx +98 -0
  77. package/src/components/ui/Tabs.tsx +71 -0
  78. package/src/components/ui/Toggle.tsx +63 -0
  79. package/src/components/ui/Tooltip.test.tsx +110 -0
  80. package/src/components/ui/Tooltip.tsx +38 -112
  81. package/src/components/ui/cn.test.ts +30 -0
  82. package/src/components/ui/cn.ts +25 -0
  83. package/src/components/ui/index.ts +29 -2
  84. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  85. package/src/components/ui/valueControls.test.tsx +524 -0
  86. package/src/contexts/PanelLayoutContext.tsx +3 -57
  87. package/src/hooks/appHotkeysDispatch.ts +3 -4
  88. package/src/hooks/timelineEditPermission.ts +31 -0
  89. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  90. package/src/hooks/useAppHotkeys.test.ts +0 -1
  91. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  92. package/src/hooks/useAppHotkeys.ts +20 -83
  93. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  94. package/src/hooks/useCaptionDetection.ts +3 -1
  95. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  96. package/src/hooks/useDomEditSession.ts +0 -7
  97. package/src/hooks/useDomEditWiring.ts +4 -9
  98. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  99. package/src/hooks/useEditHistoryActions.ts +106 -0
  100. package/src/hooks/useFileManager.ts +21 -123
  101. package/src/hooks/usePanelLayout.test.ts +108 -296
  102. package/src/hooks/usePanelLayout.ts +50 -255
  103. package/src/hooks/usePersistentEditHistory.ts +1 -1
  104. package/src/hooks/useProjectFileWriter.ts +150 -0
  105. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  106. package/src/hooks/useRightPanelIntents.ts +63 -0
  107. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  108. package/src/hooks/useServerConnection.ts +43 -10
  109. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  110. package/src/hooks/useSlideshowTabState.ts +6 -19
  111. package/src/hooks/useStudioContextValue.test.ts +6 -18
  112. package/src/hooks/useStudioContextValue.ts +5 -26
  113. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  114. package/src/hooks/useTimelineEditing.test.tsx +322 -3
  115. package/src/hooks/useTimelineEditing.ts +103 -16
  116. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  117. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  118. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  119. package/src/hostMount.test.tsx +222 -0
  120. package/src/index.ts +87 -0
  121. package/src/player/components/AutomationSelectionMenu.tsx +1 -1
  122. package/src/player/components/Timeline.tsx +1 -1
  123. package/src/player/components/TimelineCanvas.tsx +4 -4
  124. package/src/player/components/TimelineClip.tsx +0 -1
  125. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  126. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  127. package/src/player/components/timelineTheme.test.ts +4 -4
  128. package/src/player/components/timelineTheme.ts +30 -30
  129. package/src/player/lib/playbackShortcuts.ts +2 -0
  130. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  131. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  132. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  133. package/src/player/store/previewIframeStore.test.tsx +29 -0
  134. package/src/player/store/previewIframeStore.ts +25 -0
  135. package/src/renderQueueExports.test.tsx +58 -0
  136. package/src/styles/classCandidates.test.ts +105 -0
  137. package/src/styles/classCandidates.ts +281 -0
  138. package/src/styles/hex-baseline.json +84 -0
  139. package/src/styles/hexRatchet.test.ts +201 -0
  140. package/src/styles/styleSources.test.ts +20 -0
  141. package/src/styles/styleSources.ts +47 -0
  142. package/src/styles/theme.css +57 -0
  143. package/src/styles/theme.test.ts +2 -15
  144. package/src/styles/tokenGate.test.ts +109 -0
  145. package/src/utils/sourcePatcher.ts +1 -1
  146. package/src/utils/studioHelpers.test.ts +18 -0
  147. package/src/utils/studioHelpers.ts +11 -8
  148. package/src/utils/studioUiPreferences.test.ts +10 -16
  149. package/src/utils/studioUiPreferences.ts +0 -16
  150. package/src/utils/typingTarget.ts +4 -0
  151. package/dist/assets/index-C05SNrvY.js +0 -360
  152. package/dist/assets/index-DUR9qzQM.css +0 -1
  153. package/src/components/PanelTabButton.tsx +0 -31
  154. package/src/components/StudioLeftSidebar.tsx +0 -207
  155. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  156. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  157. package/src/components/sidebar/LeftSidebar.tsx +0 -348
  158. package/src/hooks/useInspectorSplitResize.ts +0 -51
  159. package/src/utils/fitPanels.test.ts +0 -164
  160. package/src/utils/fitPanels.ts +0 -151
@@ -0,0 +1,63 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { useDockLayoutStore, visiblePanelInZone } from "../components/dock/dockLayoutStore";
3
+
4
+ /** Opens the Slideshow panel when the file becomes a slideshow and closes it when it stops being one; a user's own close, and a restored layout's placement, stick. */
5
+ export function useSlideshowDockPanel(isSlideshowComposition: boolean) {
6
+ const controller = useDockLayoutStore((state) => state.controller);
7
+ const wasSlideshow = useRef(false);
8
+ useEffect(() => {
9
+ if (!controller) return;
10
+ const inDock = useDockLayoutStore.getState().openPanels.has("slideshow");
11
+ if (isSlideshowComposition && !inDock) controller.open("slideshow");
12
+ if (!isSlideshowComposition && wasSlideshow.current && inDock) controller.close("slideshow");
13
+ wasSlideshow.current = isSlideshowComposition;
14
+ }, [isSlideshowComposition, controller]);
15
+ }
16
+
17
+ /** Block params replace the Design body until Design stops showing; picking another tab dismisses them elsewhere. */
18
+ export function useBlockParamsDismissal({
19
+ hasBlockParams,
20
+ onDismiss,
21
+ }: {
22
+ hasBlockParams: boolean;
23
+ onDismiss: () => void;
24
+ }) {
25
+ const designVisible = useDockLayoutStore((state) => state.visiblePanels.has("design"));
26
+ useEffect(() => {
27
+ if (hasBlockParams && !designVisible) onDismiss();
28
+ }, [hasBlockParams, designVisible, onDismiss]);
29
+ }
30
+
31
+ /** Wraps a tab setter so any tab other than block-params first dismisses the block-params view. */
32
+ export function useDismissingTabSetter<Tab extends string>(
33
+ setTab: (tab: Tab) => void,
34
+ dismissBlockParams: () => void,
35
+ ) {
36
+ return useCallback(
37
+ (tab: Tab) => {
38
+ if (tab !== "block-params") dismissBlockParams();
39
+ setTab(tab);
40
+ },
41
+ [setTab, dismissBlockParams],
42
+ );
43
+ }
44
+
45
+ /** Caption edit mode owns the Design panel: whenever the right column shows something else, bring Design back. */
46
+ export function useCaptionDesignFocus(captionEditMode: boolean) {
47
+ const designVisible = useDockLayoutStore((state) => state.visiblePanels.has("design"));
48
+ const rightShown = useDockLayoutStore(
49
+ (state) => visiblePanelInZone("right", state.lastActive, state.visiblePanels) !== null,
50
+ );
51
+ useEffect(() => {
52
+ if (captionEditMode && rightShown && !designVisible) {
53
+ useDockLayoutStore.getState().activatePanel("design");
54
+ }
55
+ }, [captionEditMode, rightShown, designVisible]);
56
+ }
57
+
58
+ /** The right-column panel the user last focused, whether or not the column is currently showing. */
59
+ export function useRightPanelIntent() {
60
+ return useDockLayoutStore((state) =>
61
+ visiblePanelInZone("right", state.lastActive, state.openPanels),
62
+ );
63
+ }
@@ -0,0 +1,94 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { useServerConnection } from "./useServerConnection";
6
+
7
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
+
9
+ const captured: { projectId: string | null } = { projectId: null };
10
+
11
+ function Probe() {
12
+ captured.projectId = useServerConnection().projectId;
13
+ return null;
14
+ }
15
+
16
+ async function flush(): Promise<void> {
17
+ await act(async () => {
18
+ await new Promise((resolve) => setTimeout(resolve, 0));
19
+ });
20
+ }
21
+
22
+ /**
23
+ * The hash project id outlives the project it names — a renamed folder, or a
24
+ * bookmark from a project that is gone. It used to be trusted unconditionally,
25
+ * so every later /api/projects/<id>/... request 404'd for the life of the tab,
26
+ * including the composition read that opens the SDK session. Telemetry after
27
+ * the read-reason split: 120 http_error/404 reads across 5 users in 24h.
28
+ */
29
+ function stubFetch(projectRoute: { status: number } | "reject") {
30
+ vi.stubGlobal(
31
+ "fetch",
32
+ vi.fn(async (url: string) => {
33
+ if (url === "/api/projects") {
34
+ return { ok: true, json: async () => ({ projects: [{ id: "real-project" }] }) } as Response;
35
+ }
36
+ if (projectRoute === "reject") throw new TypeError("Failed to fetch");
37
+ return { ok: projectRoute.status === 200, status: projectRoute.status } as Response;
38
+ }),
39
+ );
40
+ }
41
+
42
+ async function renderWithHash(hash: string) {
43
+ window.location.hash = hash;
44
+ const root = createRoot(document.createElement("div"));
45
+ await act(async () => root.render(<Probe />));
46
+ await flush();
47
+ return root;
48
+ }
49
+
50
+ describe("useServerConnection hash project id", () => {
51
+ beforeEach(() => {
52
+ captured.projectId = null;
53
+ window.location.hash = "";
54
+ });
55
+
56
+ afterEach(() => {
57
+ vi.unstubAllGlobals();
58
+ });
59
+
60
+ it("keeps a hash id the server can resolve", async () => {
61
+ stubFetch({ status: 200 });
62
+ const root = await renderWithHash("#project/stale-or-not");
63
+
64
+ expect(captured.projectId).toBe("stale-or-not");
65
+ await act(async () => root.unmount());
66
+ });
67
+
68
+ it("falls back to the first project when the hash id is gone", async () => {
69
+ stubFetch({ status: 404 });
70
+ const root = await renderWithHash("#project/deleted-project");
71
+
72
+ expect(captured.projectId).toBe("real-project");
73
+ expect(window.location.hash).toContain("real-project");
74
+ await act(async () => root.unmount());
75
+ });
76
+
77
+ // The important one: a blip must not rewrite the user's hash out from under a
78
+ // project that is actually fine. Only a definite 404 is "missing".
79
+ it("keeps the hash id when the check itself fails", async () => {
80
+ stubFetch("reject");
81
+ const root = await renderWithHash("#project/unreachable-check");
82
+
83
+ expect(captured.projectId).toBe("unreachable-check");
84
+ await act(async () => root.unmount());
85
+ });
86
+
87
+ it("keeps the hash id on a non-404 error status", async () => {
88
+ stubFetch({ status: 500 });
89
+ const root = await renderWithHash("#project/server-erroring");
90
+
91
+ expect(captured.projectId).toBe("server-erroring");
92
+ await act(async () => root.unmount());
93
+ });
94
+ });
@@ -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();
@@ -4,7 +4,6 @@ import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it } from "vitest";
6
6
  import { useSlideshowTabState } from "./useSlideshowTabState";
7
- import type { RightPanelTab } from "../utils/studioHelpers";
8
7
 
9
8
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
9
 
@@ -15,14 +14,10 @@ afterEach(() => {
15
14
  document.body.innerHTML = "";
16
15
  });
17
16
 
18
- function renderHook(params: {
19
- editingFileContent: string | null | undefined;
20
- rightPanelTab: RightPanelTab;
21
- }) {
17
+ function renderHook(params: { editingFileContent: string | null | undefined }) {
22
18
  const host = document.createElement("div");
23
19
  document.body.append(host);
24
20
  const root = createRoot(host);
25
- const setRightPanelTabCalls: RightPanelTab[] = [];
26
21
  let current: ReturnType<typeof useSlideshowTabState> | null = null;
27
22
 
28
23
  function Harness() {
@@ -30,8 +25,7 @@ function renderHook(params: {
30
25
  editingFileContent: params.editingFileContent,
31
26
  previewIframeRef: { current: null },
32
27
  refreshKey: 0,
33
- rightPanelTab: params.rightPanelTab,
34
- setRightPanelTab: (tab) => setRightPanelTabCalls.push(tab),
28
+ slideshowVisible: false,
35
29
  });
36
30
  return null;
37
31
  }
@@ -45,52 +39,36 @@ function renderHook(params: {
45
39
  if (!current) throw new Error("useSlideshowTabState did not render");
46
40
  return current;
47
41
  },
48
- setRightPanelTabCalls,
49
42
  unmount: () => act(() => root.unmount()),
50
43
  };
51
44
  }
52
45
 
46
+ // Whether "slideshow" stays the active dock tab across a file switch is
47
+ // StudioRightPanels' own effect now (it opens/closes the dock panel), not
48
+ // this hook's job — this only derives the two values that effect reads.
53
49
  describe("useSlideshowTabState", () => {
54
50
  it("detects a slideshow composition via the JSON island", () => {
55
- const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "design" });
51
+ const harness = renderHook({ editingFileContent: SLIDESHOW_HTML });
56
52
  expect(harness.getState().isSlideshowComposition).toBe(true);
57
53
  harness.unmount();
58
54
  });
59
55
 
60
56
  it("reports false for a plain (non-slideshow) composition", () => {
61
- const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "design" });
57
+ const harness = renderHook({ editingFileContent: PLAIN_HTML });
62
58
  expect(harness.getState().isSlideshowComposition).toBe(false);
63
59
  harness.unmount();
64
60
  });
65
61
 
66
62
  it("reports false when there is no editing file yet", () => {
67
- const harness = renderHook({ editingFileContent: undefined, rightPanelTab: "design" });
63
+ const harness = renderHook({ editingFileContent: undefined });
68
64
  expect(harness.getState().isSlideshowComposition).toBe(false);
69
65
  harness.unmount();
70
66
  });
71
67
 
72
68
  it("still detects a malformed island — presence-only, not full manifest validation", () => {
73
69
  const malformed = `<html><body><script type="application/hyperframes-slideshow+json">{not valid json</script></body></html>`;
74
- const harness = renderHook({ editingFileContent: malformed, rightPanelTab: "design" });
70
+ const harness = renderHook({ editingFileContent: malformed });
75
71
  expect(harness.getState().isSlideshowComposition).toBe(true);
76
72
  harness.unmount();
77
73
  });
78
-
79
- it("bounces rightPanelTab off 'slideshow' to 'renders' on a non-slideshow composition", () => {
80
- const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "slideshow" });
81
- expect(harness.setRightPanelTabCalls).toEqual(["renders"]);
82
- harness.unmount();
83
- });
84
-
85
- it("does not bounce when the composition is a slideshow", () => {
86
- const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "slideshow" });
87
- expect(harness.setRightPanelTabCalls).toEqual([]);
88
- harness.unmount();
89
- });
90
-
91
- it("does not bounce a tab other than 'slideshow'", () => {
92
- const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "renders" });
93
- expect(harness.setRightPanelTabCalls).toEqual([]);
94
- harness.unmount();
95
- });
96
74
  });
@@ -1,29 +1,22 @@
1
- import { useEffect, useMemo, type MutableRefObject } from "react";
1
+ import { useMemo, type MutableRefObject } from "react";
2
2
  import { SLIDESHOW_ISLAND_TYPE, slideshowIslandRegex } from "@hyperframes/core/slideshow";
3
3
  import type { SceneInfo } from "../components/panels/SlideshowPanel";
4
4
  import type { IframeWindow } from "../player/lib/playbackTypes";
5
- import type { RightPanelTab } from "../utils/studioHelpers";
6
5
 
7
6
  /**
8
7
  * Derives whether the currently-edited composition is a slideshow (carries
9
8
  * the slideshow JSON island — the same definitive marker the CLI's `present`
10
9
  * command requires; it refuses to run without one) and the live scene list
11
- * for the Slideshow panel, and bounces `rightPanelTab` off "slideshow" the
12
- * moment it stops applying (e.g. the user switches to a non-slideshow file
13
- * while that tab was open) so the panel never shows a dangling active tab
14
- * whose button is no longer even rendered.
15
- *
16
- * Extracted from StudioRightPanel to keep that file under the 600-LOC gate.
10
+ * for the Slideshow panel. Extracted from StudioRightPanels to keep that file
11
+ * under the 600-LOC gate.
17
12
  */
18
13
  export function useSlideshowTabState(params: {
19
14
  editingFileContent: string | null | undefined;
20
15
  previewIframeRef: MutableRefObject<HTMLIFrameElement | null>;
21
16
  refreshKey: number;
22
- rightPanelTab: RightPanelTab;
23
- setRightPanelTab: (tab: RightPanelTab) => void;
17
+ slideshowVisible: boolean;
24
18
  }): { isSlideshowComposition: boolean; slideshowScenes: SceneInfo[] } {
25
- const { editingFileContent, previewIframeRef, refreshKey, rightPanelTab, setRightPanelTab } =
26
- params;
19
+ const { editingFileContent, previewIframeRef, refreshKey, slideshowVisible } = params;
27
20
 
28
21
  // Presence-only (not full manifest validation): a malformed island should
29
22
  // still surface the Slideshow tab so the user can see/fix it, rather than
@@ -49,13 +42,7 @@ export function useSlideshowTabState(params: {
49
42
  return [];
50
43
  }
51
44
  // eslint-disable-next-line react-hooks/exhaustive-deps
52
- }, [previewIframeRef, rightPanelTab, refreshKey]);
53
-
54
- useEffect(() => {
55
- if (rightPanelTab === "slideshow" && !isSlideshowComposition) {
56
- setRightPanelTab("renders");
57
- }
58
- }, [rightPanelTab, isSlideshowComposition, setRightPanelTab]);
45
+ }, [previewIframeRef, slideshowVisible, refreshKey]);
59
46
 
60
47
  return { isSlideshowComposition, slideshowScenes };
61
48
  }
@@ -4,14 +4,12 @@ import React from "react";
4
4
  import { renderToStaticMarkup } from "react-dom/server";
5
5
  import { describe, expect, it } from "vitest";
6
6
  import type { DomEditSelection } from "../components/editor/domEditing";
7
- import type { RightInspectorPanes } from "../utils/studioHelpers";
7
+ import type { PanelId } from "../components/dock/panelRegistry";
8
8
  import { makeSelection } from "./domSelectionTestHarness";
9
9
  import { useInspectorState, type InspectorState } from "./useStudioContextValue";
10
10
 
11
11
  interface HarnessProps {
12
- rightPanelTab: string;
13
- rightInspectorPanes: RightInspectorPanes;
14
- rightCollapsed: boolean;
12
+ rightPanel: PanelId | null;
15
13
  isPlaying: boolean;
16
14
  isGestureRecording: boolean;
17
15
  domEditSelection: DomEditSelection | null;
@@ -22,9 +20,7 @@ function renderInspectorState(props: HarnessProps): InspectorState {
22
20
 
23
21
  function Harness() {
24
22
  state = useInspectorState(
25
- props.rightPanelTab,
26
- props.rightInspectorPanes,
27
- props.rightCollapsed,
23
+ props.rightPanel,
28
24
  props.isPlaying,
29
25
  props.domEditSelection,
30
26
  props.isGestureRecording,
@@ -42,9 +38,7 @@ function selectedProps(
42
38
  ): HarnessProps & { domEditSelection: DomEditSelection } {
43
39
  const element = document.createElement("div");
44
40
  return {
45
- rightPanelTab: "renders",
46
- rightInspectorPanes: { layers: false, design: false },
47
- rightCollapsed: true,
41
+ rightPanel: "renders",
48
42
  isPlaying: false,
49
43
  isGestureRecording: false,
50
44
  domEditSelection: makeSelection("Selected", element),
@@ -78,16 +72,10 @@ describe("useInspectorState", () => {
78
72
  it("keeps selected DOM bounds coupled to the inspector or variables panel", () => {
79
73
  expect(renderInspectorState(selectedProps()).shouldShowSelectedDomBounds).toBe(false);
80
74
  expect(
81
- renderInspectorState(
82
- selectedProps({
83
- rightPanelTab: "design",
84
- rightInspectorPanes: { layers: false, design: true },
85
- }),
86
- ).shouldShowSelectedDomBounds,
75
+ renderInspectorState(selectedProps({ rightPanel: "design" })).shouldShowSelectedDomBounds,
87
76
  ).toBe(true);
88
77
  expect(
89
- renderInspectorState(selectedProps({ rightPanelTab: "variables" }))
90
- .shouldShowSelectedDomBounds,
78
+ renderInspectorState(selectedProps({ rightPanel: "variables" })).shouldShowSelectedDomBounds,
91
79
  ).toBe(true);
92
80
  });
93
81
  });
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useMemo, type DragEvent } from "react";
2
2
  import type { DomEditSelection } from "../components/editor/domEditing";
3
3
  import type { StudioContextValue } from "../contexts/StudioContext";
4
- import type { RightInspectorPanes } from "../utils/studioHelpers";
4
+ import type { PanelId } from "../components/dock/panelRegistry";
5
5
  import type { TimelineFileDropHandler } from "./useTimelineEditingTypes";
6
6
  import { usePlayerStore } from "../player";
7
7
 
@@ -66,33 +66,21 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex
66
66
  }
67
67
 
68
68
  export interface InspectorState {
69
- layersPanelActive: boolean;
70
- designPanelActive: boolean;
71
69
  inspectorPanelActive: boolean;
72
- inspectorButtonActive: boolean;
73
70
  shouldShowMotionPath: boolean;
74
71
  shouldShowSelectedDomBounds: boolean;
75
72
  }
76
73
 
77
74
  export function useInspectorState(
78
- rightPanelTab: string,
79
- rightInspectorPanes: RightInspectorPanes,
80
- rightCollapsed: boolean,
75
+ rightPanel: PanelId | null,
81
76
  isPlaying: boolean,
82
77
  domEditSelection: DomEditSelection | null,
83
78
  isGestureRecording?: boolean,
84
79
  ): InspectorState {
85
- // fallow-ignore-next-line complexity
86
80
  return useMemo(() => {
87
- const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
88
- const layersPanelActive = inspectorTabActive && rightInspectorPanes.layers;
89
- const designPanelActive = inspectorTabActive && rightInspectorPanes.design;
90
- const inspectorPanelActive = layersPanelActive || designPanelActive;
81
+ const inspectorPanelActive = rightPanel === "layers" || rightPanel === "design";
91
82
  return {
92
- layersPanelActive,
93
- designPanelActive,
94
83
  inspectorPanelActive,
95
- inspectorButtonActive: !rightCollapsed && inspectorPanelActive,
96
84
  // Deliberately wider than shouldShowSelectedDomBounds: the on-canvas path
97
85
  // handles ARE the arc-drag affordance, so gating them on an open Inspector
98
86
  // would make keyframe path editing reachable only from a side panel.
@@ -102,18 +90,9 @@ export function useInspectorState(
102
90
  // The Variables tab also works against the canvas selection (bind card),
103
91
  // so the selection outline stays visible there too.
104
92
  shouldShowSelectedDomBounds:
105
- (inspectorPanelActive || rightPanelTab === "variables") &&
106
- !isPlaying &&
107
- !isGestureRecording,
93
+ (inspectorPanelActive || rightPanel === "variables") && !isPlaying && !isGestureRecording,
108
94
  };
109
- }, [
110
- rightPanelTab,
111
- rightInspectorPanes,
112
- rightCollapsed,
113
- isPlaying,
114
- isGestureRecording,
115
- domEditSelection,
116
- ]);
95
+ }, [rightPanel, isPlaying, isGestureRecording, domEditSelection]);
117
96
  }
118
97
 
119
98
  /** Lets an OS file drop reach `onDrop` anywhere in the shell; the timeline shows its own landing preview. */
@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import type { TimelineElement } from "../player";
6
6
  import { applyRippleShifts, useTimelineDeleteOps } from "./useTimelineDeleteOps";
7
7
  import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
8
+ import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
9
+ import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
8
10
 
9
11
  installReactActEnvironment();
10
12
 
@@ -117,3 +119,112 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
117
119
  );
118
120
  });
119
121
  });
122
+
123
+ // Regression: a delete's write can reach disk before its history entry is
124
+ // recorded, so an Undo fired in that window found nothing to restore.
125
+ // Fails without useTrackPendingTimelineEdit's wrap (see that file).
126
+ describe("useTimelineDeleteOps: undo race", () => {
127
+ // data-composition-id (readRootCompositionDuration's key) plus a
128
+ // remove-element mock that actually drops hf-c: needed for a real diff.
129
+ const html = `<!DOCTYPE html><html data-composition-variables='[]'><body>
130
+ <div data-hf-id="hf-stage" data-composition-id="main" data-duration="6">
131
+ <div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
132
+ <div data-hf-id="hf-b" data-start="2" data-duration="2"></div>
133
+ <div data-hf-id="hf-c" data-start="4" data-duration="2"></div>
134
+ </div>
135
+ </body></html>`;
136
+ const htmlAfterDeletingC = `<!DOCTYPE html><html data-composition-variables='[]'><body>
137
+ <div data-hf-id="hf-stage" data-composition-id="main" data-duration="6">
138
+ <div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
139
+ <div data-hf-id="hf-b" data-start="2" data-duration="2"></div>
140
+ </div>
141
+ </body></html>`;
142
+
143
+ beforeEach(() => {
144
+ vi.stubGlobal(
145
+ "fetch",
146
+ vi.fn(async (input: RequestInfo | URL) => {
147
+ const url = String(input);
148
+ if (url.includes("/file-mutations/remove-element/")) {
149
+ return new Response(JSON.stringify({ changed: true, content: htmlAfterDeletingC }), {
150
+ status: 200,
151
+ });
152
+ }
153
+ return new Response(JSON.stringify({ content: html }), { status: 200 });
154
+ }),
155
+ );
156
+ });
157
+
158
+ afterEach(() => {
159
+ document.body.innerHTML = "";
160
+ vi.unstubAllGlobals();
161
+ });
162
+
163
+ // A manually-resolved gate stands in for the delete's write landing — a
164
+ // real timer raced the fix's own microtasks unpredictably (verified: it
165
+ // kept passing even with the fix's tracking call deleted).
166
+ function deferred<T>() {
167
+ let resolve!: (value: T) => void;
168
+ const promise = new Promise<T>((r) => (resolve = r));
169
+ return { promise, resolve };
170
+ }
171
+
172
+ function mountRaceHarness() {
173
+ const elements = [el("hf-a", 0, 2), el("hf-b", 2, 2), el("hf-c", 4, 2)];
174
+ const gate = deferred<void>();
175
+ const recordEdit = vi.fn(() => gate.promise);
176
+ let tracked: ((elements: TimelineElement[]) => Promise<void>) | null = null;
177
+ function Harness() {
178
+ const track = useTrackPendingTimelineEdit();
179
+ const hook = useTimelineDeleteOps({
180
+ projectIdRef: { current: "test-project" },
181
+ activeCompPath: "index.html",
182
+ timelineElements: elements,
183
+ showToast: vi.fn(),
184
+ writeProjectFile: vi.fn().mockResolvedValue(undefined),
185
+ recordEdit,
186
+ reloadPreview: vi.fn(),
187
+ previewIframeRef: { current: null },
188
+ handleTimelineGroupMove: vi.fn().mockResolvedValue(undefined),
189
+ });
190
+ tracked = track(hook.handleTimelineElementsDelete);
191
+ return null;
192
+ }
193
+ mountReactHarness(<Harness />);
194
+ // Deletes the LAST clip (not the middle one): only this shrinks the
195
+ // composition's furthest-clip-end duration, so the write actually
196
+ // differs from the original content and recordEdit is reached — the
197
+ // middle clip's delete leaves duration unchanged and is a no-op write.
198
+ return { gate, recordEdit, deleteSelection: () => tracked!([elements[2]]) };
199
+ }
200
+
201
+ it("the pending-edit flush waits while the delete's history write is still landing", async () => {
202
+ const { gate, recordEdit, deleteSelection } = mountRaceHarness();
203
+ const order: string[] = [];
204
+ const deletePromise = deleteSelection().then(() => order.push("deleted"));
205
+ // Let the delete run up to (and call) recordEdit, which is now gated —
206
+ // this is the exact window a fast Undo used to race into.
207
+ await vi.waitFor(() => expect(recordEdit).toHaveBeenCalled());
208
+ // No React state changes happen inside the flush itself here (no
209
+ // pending-edit-flush listeners are registered in this harness), so it
210
+ // does not need act()'s wrapping — only awaiting the delete's own
211
+ // resolution below does.
212
+ const flushPromise = flushStudioPendingEdits().then(() => order.push("flushed"));
213
+ await Promise.resolve();
214
+ await Promise.resolve();
215
+ expect(order).toEqual([]);
216
+ gate.resolve();
217
+ await deletePromise;
218
+ await flushPromise;
219
+ expect(order).toEqual(["deleted", "flushed"]);
220
+ });
221
+
222
+ it("the pending-edit flush resolves immediately once the delete has already finished", async () => {
223
+ const { gate, deleteSelection } = mountRaceHarness();
224
+ gate.resolve();
225
+ await deleteSelection();
226
+ await act(async () => {
227
+ await flushStudioPendingEdits();
228
+ });
229
+ });
230
+ });