@hyperframes/studio 0.7.58 → 0.7.60

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 (173) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-3XTTaVNf.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-D6etaey-.js} +1 -1
  3. package/dist/assets/index-DXbu6IPT.css +1 -0
  4. package/dist/assets/{index-DeQPzqwH.js → index-Dh_WhagG.js} +1 -1
  5. package/dist/assets/index-cH6NfVV_.js +426 -0
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13755 -8627
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +4 -4
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +42 -53
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +107 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/FileManagerContext.tsx +3 -0
  96. package/src/contexts/PanelLayoutContext.tsx +3 -0
  97. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  98. package/src/contexts/VariablePromoteContext.tsx +10 -5
  99. package/src/hooks/domEditCommitRunner.ts +11 -0
  100. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  101. package/src/hooks/timelineEditingHelpers.ts +2 -2
  102. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  103. package/src/hooks/timelineTrackVisibility.ts +27 -10
  104. package/src/hooks/useAppHotkeys.ts +9 -0
  105. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  106. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  107. package/src/hooks/useDomEditCommits.ts +31 -177
  108. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  109. package/src/hooks/useDomEditSession.ts +18 -6
  110. package/src/hooks/useDomEditTextCommits.ts +14 -9
  111. package/src/hooks/useDomEditWiring.ts +10 -9
  112. package/src/hooks/useEditorSave.ts +1 -1
  113. package/src/hooks/useElementLifecycleOps.ts +8 -3
  114. package/src/hooks/useFileManager.projectOwnership.test.tsx +109 -0
  115. package/src/hooks/useFileManager.ts +151 -65
  116. package/src/hooks/useGsapAnimationOps.ts +7 -6
  117. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  118. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  119. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  120. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  121. package/src/hooks/useGsapScriptCommits.ts +150 -50
  122. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  123. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  124. package/src/hooks/useInspectorSplitResize.ts +51 -0
  125. package/src/hooks/usePanelLayout.test.ts +118 -0
  126. package/src/hooks/usePanelLayout.ts +24 -1
  127. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  128. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  129. package/src/hooks/usePersistentEditHistory.ts +71 -31
  130. package/src/hooks/usePreviewPersistence.ts +4 -1
  131. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  132. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  133. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  134. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  135. package/src/hooks/useRazorSplit.test.tsx +88 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  137. package/src/hooks/useRazorSplit.ts +50 -16
  138. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  139. package/src/hooks/useSdkSession.ts +155 -14
  140. package/src/hooks/useSlideshowPersist.ts +7 -1
  141. package/src/hooks/useStoryboard.ts +23 -4
  142. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  143. package/src/hooks/useTimelineEditing.ts +18 -10
  144. package/src/hooks/useTimelineEditingTypes.ts +5 -1
  145. package/src/hooks/useTimelineGroupEditing.ts +17 -3
  146. package/src/hooks/useVariablesPersist.ts +9 -7
  147. package/src/icons/SystemIcons.tsx +2 -0
  148. package/src/styles/studio.css +22 -0
  149. package/src/styles/tailwind-preset.shared.js +7 -0
  150. package/src/utils/designInputTracking.test.ts +97 -0
  151. package/src/utils/designInputTracking.ts +80 -0
  152. package/src/utils/domEditSaveQueue.test.ts +19 -0
  153. package/src/utils/domEditSaveQueue.ts +2 -1
  154. package/src/utils/sdkCutover.gate.test.ts +7 -7
  155. package/src/utils/sdkCutover.test.ts +511 -62
  156. package/src/utils/sdkCutover.ts +181 -219
  157. package/src/utils/sdkEditTransaction.ts +293 -0
  158. package/src/utils/sdkResolverAttempts.ts +94 -0
  159. package/src/utils/sdkResolverShadow.test.ts +148 -0
  160. package/src/utils/sdkResolverShadow.ts +139 -106
  161. package/src/utils/setSlideshowManifest.test.ts +0 -9
  162. package/src/utils/setSlideshowManifest.ts +20 -27
  163. package/src/utils/studioFileHistory.test.ts +41 -0
  164. package/src/utils/studioFileHistory.ts +34 -29
  165. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  166. package/src/utils/studioFileVersion.test.ts +36 -0
  167. package/src/utils/studioFileVersion.ts +23 -0
  168. package/src/utils/studioHelpers.test.ts +45 -0
  169. package/src/utils/studioHelpers.ts +45 -14
  170. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  171. package/src/utils/studioSaveDiagnostics.ts +38 -0
  172. package/dist/assets/index-B_UvTX3E.js +0 -423
  173. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -0,0 +1,81 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ const trackDesignInput = vi.fn();
8
+ vi.mock("../utils/designInputTracking", () => ({
9
+ trackDesignInput: (...args: unknown[]) => trackDesignInput(...args),
10
+ }));
11
+
12
+ import { DesignPanelInputProvider, useTrackDesignInput } from "./DesignPanelInputContext";
13
+
14
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ beforeEach(() => trackDesignInput.mockReset());
17
+ afterEach(() => {
18
+ document.body.innerHTML = "";
19
+ });
20
+
21
+ function FireButton({ control, name }: { control: string; name: string }) {
22
+ const track = useTrackDesignInput();
23
+ return (
24
+ <button type="button" onClick={() => track(control, name)}>
25
+ fire
26
+ </button>
27
+ );
28
+ }
29
+
30
+ function renderAndClick(tree: React.ReactElement) {
31
+ const host = document.createElement("div");
32
+ document.body.appendChild(host);
33
+ const root = createRoot(host);
34
+ act(() => root.render(tree));
35
+ act(() => {
36
+ host.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
37
+ });
38
+ act(() => root.unmount());
39
+ }
40
+
41
+ describe("DesignPanelInputContext", () => {
42
+ it("binds the tracker to the enclosing ui + section", () => {
43
+ renderAndClick(
44
+ <DesignPanelInputProvider ui="flat" section="style">
45
+ <FireButton control="metric" name="Opacity" />
46
+ </DesignPanelInputProvider>,
47
+ );
48
+ expect(trackDesignInput).toHaveBeenCalledWith({
49
+ ui: "flat",
50
+ section: "style",
51
+ control: "metric",
52
+ name: "Opacity",
53
+ });
54
+ });
55
+
56
+ it("nested provider overrides section but inherits ui from parent", () => {
57
+ renderAndClick(
58
+ <DesignPanelInputProvider ui="flat" section="outer">
59
+ <DesignPanelInputProvider section="color-grading">
60
+ <FireButton control="slider" name="Exposure" />
61
+ </DesignPanelInputProvider>
62
+ </DesignPanelInputProvider>,
63
+ );
64
+ expect(trackDesignInput).toHaveBeenCalledWith({
65
+ ui: "flat",
66
+ section: "color-grading",
67
+ control: "slider",
68
+ name: "Exposure",
69
+ });
70
+ });
71
+
72
+ it("defaults to classic/unknown with no provider", () => {
73
+ renderAndClick(<FireButton control="button" name="Reset" />);
74
+ expect(trackDesignInput).toHaveBeenCalledWith({
75
+ ui: "classic",
76
+ section: "unknown",
77
+ control: "button",
78
+ name: "Reset",
79
+ });
80
+ });
81
+ });
@@ -0,0 +1,48 @@
1
+ import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
2
+ import { trackDesignInput, type DesignInputUi } from "../utils/designInputTracking";
3
+
4
+ // Carries which inspector UI and which section the currently-rendered design-panel
5
+ // inputs belong to, so commit sites only pass { control, name } and never thread
6
+ // section/ui through every call. Providers nest: PropertyPanel sets `ui` once at the
7
+ // top; each Section sets `section` and inherits `ui` from the parent.
8
+
9
+ interface DesignPanelInputContextValue {
10
+ ui: DesignInputUi;
11
+ section: string;
12
+ }
13
+
14
+ const DesignPanelInputContext = createContext<DesignPanelInputContextValue>({
15
+ ui: "classic",
16
+ section: "unknown",
17
+ });
18
+
19
+ export function DesignPanelInputProvider({
20
+ ui,
21
+ section,
22
+ children,
23
+ }: {
24
+ ui?: DesignInputUi;
25
+ section?: string;
26
+ children: ReactNode;
27
+ }) {
28
+ const parent = useContext(DesignPanelInputContext);
29
+ const value = useMemo(
30
+ () => ({ ui: ui ?? parent.ui, section: section ?? parent.section }),
31
+ [ui, section, parent.ui, parent.section],
32
+ );
33
+ return (
34
+ <DesignPanelInputContext.Provider value={value}>{children}</DesignPanelInputContext.Provider>
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Returns a stable `track(control, name)` fn bound to the current UI + section.
40
+ * Call it from any input's commit handler.
41
+ */
42
+ export function useTrackDesignInput(): (control: string, name: string) => void {
43
+ const { ui, section } = useContext(DesignPanelInputContext);
44
+ return useCallback(
45
+ (control: string, name: string) => trackDesignInput({ ui, section, control, name }),
46
+ [ui, section],
47
+ );
48
+ }
@@ -16,6 +16,7 @@ export interface DomEditActionsValue extends Pick<
16
16
  | "handleDomAttributeCommit"
17
17
  | "handleDomAttributeLiveCommit"
18
18
  | "handleDomHtmlAttributeCommit"
19
+ | "handleDomAttributesCommit"
19
20
  | "handleDomPathOffsetCommit"
20
21
  | "handleDomGroupPathOffsetCommit"
21
22
  | "handleDomZIndexReorderCommit"
@@ -138,6 +139,7 @@ export function DomEditProvider({
138
139
  handleDomAttributeCommit,
139
140
  handleDomAttributeLiveCommit,
140
141
  handleDomHtmlAttributeCommit,
142
+ handleDomAttributesCommit,
141
143
  handleDomPathOffsetCommit,
142
144
  handleDomGroupPathOffsetCommit,
143
145
  handleDomZIndexReorderCommit,
@@ -224,6 +226,7 @@ export function DomEditProvider({
224
226
  handleDomAttributeCommit,
225
227
  handleDomAttributeLiveCommit,
226
228
  handleDomHtmlAttributeCommit,
229
+ handleDomAttributesCommit,
227
230
  handleDomPathOffsetCommit,
228
231
  handleDomGroupPathOffsetCommit,
229
232
  handleDomZIndexReorderCommit,
@@ -291,6 +294,7 @@ export function DomEditProvider({
291
294
  handleDomAttributeCommit,
292
295
  handleDomAttributeLiveCommit,
293
296
  handleDomHtmlAttributeCommit,
297
+ handleDomAttributesCommit,
294
298
  handleDomPathOffsetCommit,
295
299
  handleDomGroupPathOffsetCommit,
296
300
  handleDomZIndexReorderCommit,
@@ -30,6 +30,7 @@ export function FileManagerProvider({
30
30
  readProjectFile,
31
31
  writeProjectFile,
32
32
  readOptionalProjectFile,
33
+ observeProjectFileVersion,
33
34
  updateEditingFileContent,
34
35
  revealSourceOffset,
35
36
  openSourceForSelection,
@@ -69,6 +70,7 @@ export function FileManagerProvider({
69
70
  readProjectFile,
70
71
  writeProjectFile,
71
72
  readOptionalProjectFile,
73
+ observeProjectFileVersion,
72
74
  updateEditingFileContent,
73
75
  revealSourceOffset,
74
76
  openSourceForSelection,
@@ -102,6 +104,7 @@ export function FileManagerProvider({
102
104
  readProjectFile,
103
105
  writeProjectFile,
104
106
  readOptionalProjectFile,
107
+ observeProjectFileVersion,
105
108
  updateEditingFileContent,
106
109
  revealSourceOffset,
107
110
  openSourceForSelection,
@@ -25,6 +25,7 @@ export function PanelLayoutProvider({
25
25
  setRightPanelTab,
26
26
  rightInspectorPanes,
27
27
  toggleRightInspectorPane,
28
+ setExclusiveRightInspectorPane,
28
29
  toggleLeftSidebar,
29
30
  handlePanelResizeStart,
30
31
  handlePanelResizeMove,
@@ -49,6 +50,7 @@ export function PanelLayoutProvider({
49
50
  setRightPanelTab,
50
51
  rightInspectorPanes,
51
52
  toggleRightInspectorPane,
53
+ setExclusiveRightInspectorPane,
52
54
  toggleLeftSidebar,
53
55
  handlePanelResizeStart,
54
56
  handlePanelResizeMove,
@@ -67,6 +69,7 @@ export function PanelLayoutProvider({
67
69
  setRightPanelTab,
68
70
  rightInspectorPanes,
69
71
  toggleRightInspectorPane,
72
+ setExclusiveRightInspectorPane,
70
73
  toggleLeftSidebar,
71
74
  handlePanelResizeStart,
72
75
  handlePanelResizeMove,
@@ -0,0 +1,67 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { Composition } from "@hyperframes/sdk";
6
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
+ import {
8
+ VariablePromoteProvider,
9
+ useVariablePromoteChannel,
10
+ type ChannelPromote,
11
+ } from "./VariablePromoteContext";
12
+
13
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ describe("VariablePromoteProvider save failures", () => {
16
+ it("reports a rejected promote instead of leaving an unhandled promise", async () => {
17
+ const snapshot = {
18
+ attributes: { id: "box" },
19
+ inlineStyles: {},
20
+ text: "",
21
+ children: [],
22
+ };
23
+ const session = {
24
+ getElement: vi.fn().mockReturnValue(snapshot),
25
+ getVariableDeclarations: vi.fn().mockReturnValue([]),
26
+ on: vi.fn().mockReturnValue(() => {}),
27
+ } as unknown as Composition;
28
+ const selection = {
29
+ hfId: "hf-box",
30
+ tagName: "div",
31
+ label: "Box",
32
+ capabilities: { canEditStyles: true },
33
+ computedStyles: { color: "rgb(255, 0, 0)" },
34
+ } as unknown as DomEditSelection;
35
+ const error = new Error("write failed");
36
+ const persist = vi.fn().mockRejectedValue(error);
37
+ const onPersistError = vi.fn();
38
+ let channel: ChannelPromote | null = null;
39
+
40
+ function Consumer() {
41
+ channel = useVariablePromoteChannel({ kind: "style", prop: "color" });
42
+ return null;
43
+ }
44
+
45
+ const root = createRoot(document.createElement("div"));
46
+ act(() => {
47
+ root.render(
48
+ <VariablePromoteProvider
49
+ session={session}
50
+ selection={selection}
51
+ persist={persist}
52
+ onPersistError={onPersistError}
53
+ >
54
+ <Consumer />
55
+ </VariablePromoteProvider>,
56
+ );
57
+ });
58
+ act(() => channel?.promote());
59
+ await act(async () => {
60
+ await Promise.resolve();
61
+ });
62
+
63
+ expect(persist).toHaveBeenCalledOnce();
64
+ expect(onPersistError).toHaveBeenCalledWith(error);
65
+ act(() => root.unmount());
66
+ });
67
+ });
@@ -44,6 +44,7 @@ interface VariablePromoteContextValue {
44
44
  actions: BindAction[];
45
45
  declarations: CompositionVariable[];
46
46
  persist: (label: string, mutate: (session: Composition) => void) => Promise<boolean>;
47
+ onPersistError: (error: unknown) => void;
47
48
  }
48
49
 
49
50
  const VariablePromoteContext = createContext<VariablePromoteContextValue | null>(null);
@@ -58,11 +59,13 @@ export function VariablePromoteProvider({
58
59
  session,
59
60
  selection,
60
61
  persist,
62
+ onPersistError,
61
63
  children,
62
64
  }: {
63
65
  session: Composition | null;
64
66
  selection: DomEditSelection | null;
65
67
  persist: (label: string, mutate: (session: Composition) => void) => Promise<boolean>;
68
+ onPersistError: (error: unknown) => void;
66
69
  children: React.ReactNode;
67
70
  }) {
68
71
  // Re-derive actions/bindings after each persisted schema edit.
@@ -85,8 +88,8 @@ export function VariablePromoteProvider({
85
88
  }, [session, revision]);
86
89
 
87
90
  const value = useMemo<VariablePromoteContextValue>(
88
- () => ({ session, selection, actions, declarations, persist }),
89
- [session, selection, actions, declarations, persist],
91
+ () => ({ session, selection, actions, declarations, persist, onPersistError }),
92
+ [session, selection, actions, declarations, persist, onPersistError],
90
93
  );
91
94
 
92
95
  return (
@@ -105,7 +108,7 @@ export function useVariablePromoteChannel(channel: PromoteChannel): ChannelPromo
105
108
 
106
109
  return useMemo(() => {
107
110
  if (!ctx || !ctx.session || !ctx.selection?.hfId) return null;
108
- const { session, selection, actions, declarations, persist } = ctx;
111
+ const { session, selection, actions, declarations, persist, onPersistError } = ctx;
109
112
  const hfId = selection.hfId!;
110
113
  const action = matchAction(actions, channel);
111
114
  const boundId = readBinding(session, hfId, channel);
@@ -125,12 +128,14 @@ export function useVariablePromoteChannel(channel: PromoteChannel): ChannelPromo
125
128
  const id = uniqueId(action.suggestedId, declarations);
126
129
  void persist(`Bind ${action.label.toLowerCase()} to variable "${id}"`, (s) =>
127
130
  applyBind(s, hfId, action, id),
128
- );
131
+ ).catch(onPersistError);
129
132
  },
130
133
  setDefault: (raw: string) => {
131
134
  if (!boundId || !declaration) return;
132
135
  const next = declaration.type === "color" ? rgbToHex(raw) : raw;
133
- void persist(`Set default for "${boundId}"`, (s) => s.setVariableValue(boundId, next));
136
+ void persist(`Set default for "${boundId}"`, (s) =>
137
+ s.setVariableValue(boundId, next),
138
+ ).catch(onPersistError);
134
139
  },
135
140
  };
136
141
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -7,6 +7,15 @@ interface DomEditCommitRunnerConfig {
7
7
  onError: (error: unknown) => void;
8
8
  shouldResync: () => boolean;
9
9
  resync: () => void | Promise<void>;
10
+ /**
11
+ * Reports success/failure without changing this function's own resolve-
12
+ * always contract — `persist` failures are handled here (revert + onError)
13
+ * and never rethrown, so callers awaiting `runDomEditCommit` can't observe
14
+ * failure via rejection. A caller that needs to react to a specific
15
+ * commit's outcome (e.g. reverting its OWN optimistic state) can pass this
16
+ * instead of relying on a rejection that will never come.
17
+ */
18
+ onSettled?: (ok: boolean) => void;
10
19
  }
11
20
 
12
21
  interface CommitVersionRef {
@@ -34,11 +43,13 @@ export async function runDomEditCommit(config: DomEditCommitRunnerConfig): Promi
34
43
 
35
44
  try {
36
45
  await config.persist();
46
+ config.onSettled?.(true);
37
47
  } catch (error) {
38
48
  if (config.shouldRevert(error)) {
39
49
  config.revert();
40
50
  }
41
51
  config.onError(error);
52
+ config.onSettled?.(false);
42
53
  }
43
54
 
44
55
  if (!config.shouldResync()) return;
@@ -2,6 +2,7 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
5
+ import type { PublishSdkSession } from "../utils/sdkCutover";
5
6
  import type { RuntimeTweenChange } from "./gsapRuntimePatch";
6
7
 
7
8
  export interface MutationResult {
@@ -22,10 +23,9 @@ export interface CommitMutationOptions {
22
23
  beforeReload?: () => void;
23
24
  /**
24
25
  * Serialize this commit against others sharing the same key. Used to chain
25
- * per-animationId GSAP meta updates so overlapping read-modify-write POSTs to
26
- * one file can't interleave which would pair the shadow fidelity diff with a
27
- * stale server result and report false ease mismatches. Commits without a key
28
- * (and under distinct keys) run concurrently as before.
26
+ * per-animationId GSAP meta updates. Every commit independently takes the
27
+ * project/file mutation lock, so this key only adds ordering and can never
28
+ * bypass whole-file serialization.
29
29
  */
30
30
  serializeKey?: string;
31
31
  /**
@@ -87,6 +87,8 @@ export interface GsapScriptCommitsParams {
87
87
  showToast: (message: string, tone?: "error" | "info") => void;
88
88
  /** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */
89
89
  sdkSession?: Composition | null;
90
+ /** Publish a fully persisted candidate SDK session. */
91
+ publishSdkSession?: PublishSdkSession;
90
92
  writeProjectFile?: (path: string, content: string) => Promise<void>;
91
93
  /** Resync the in-memory SDK session after a server-authoritative write. */
92
94
  forceReloadSdkSession?: () => void;
@@ -262,7 +262,7 @@ export interface PersistTimelineEditInput {
262
262
  activeCompPath: string | null;
263
263
  label: string;
264
264
  buildPatches: (original: string, target: PatchTarget) => string;
265
- writeProjectFile: (path: string, content: string) => Promise<void>;
265
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
266
266
  recordEdit: (input: RecordEditInput) => Promise<void>;
267
267
  domEditSaveTimestampRef: React.MutableRefObject<number>;
268
268
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
@@ -308,7 +308,7 @@ export interface PersistTimelineBatchEditInput {
308
308
  activeCompPath: string | null;
309
309
  label: string;
310
310
  changes: PersistTimelineBatchChange[];
311
- writeProjectFile: (path: string, content: string) => Promise<void>;
311
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
312
312
  recordEdit: (input: RecordEditInput) => Promise<void>;
313
313
  domEditSaveTimestampRef: React.MutableRefObject<number>;
314
314
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
@@ -201,4 +201,56 @@ describe("toggleTimelineElementHidden", () => {
201
201
  usePlayerStore.getState().elements.find((el) => el.key === "index.html:#track-mate")?.hidden,
202
202
  ).toBeUndefined();
203
203
  });
204
+
205
+ it("hides several elements in ONE atomic write when given an array of keys", async () => {
206
+ const files = new Map([
207
+ [
208
+ "index.html",
209
+ `<div id="hero" data-start="0" data-duration="2"></div>
210
+ <div id="caption" data-start="1" data-duration="2"></div>
211
+ <div id="badge" data-start="2" data-duration="2"></div>`,
212
+ ],
213
+ ]);
214
+ stubProjectFiles(files);
215
+
216
+ const hero = element({ id: "hero", key: "index.html:#hero", domId: "hero", track: 0 });
217
+ const caption = element({
218
+ id: "caption",
219
+ key: "index.html:#caption",
220
+ domId: "caption",
221
+ track: 1,
222
+ });
223
+ const badge = element({ id: "badge", key: "index.html:#badge", domId: "badge", track: 2 });
224
+
225
+ const writes: Array<{ path: string; content: string }> = [];
226
+ const recordEdit = vi.fn();
227
+
228
+ await toggleTimelineElementHidden({
229
+ projectId: "project-1",
230
+ activeCompPath: "index.html",
231
+ timelineElements: [hero, caption, badge],
232
+ elementKey: ["index.html:#hero", "index.html:#caption"],
233
+ hidden: true,
234
+ previewIframe: null,
235
+ writeProjectFile: async (path, content) => {
236
+ writes.push({ path, content });
237
+ },
238
+ recordEdit,
239
+ domEditSaveTimestampRef: { current: 0 },
240
+ pendingTimelineEditPathRef: { current: new Set() },
241
+ });
242
+
243
+ // One write carrying BOTH hides — per-element writes would clobber each
244
+ // other (each starts from the original file content).
245
+ expect(writes).toHaveLength(1);
246
+ expect(writes[0]?.content).toContain(
247
+ 'id="hero" data-start="0" data-duration="2" data-hidden=""',
248
+ );
249
+ expect(writes[0]?.content).toContain(
250
+ 'id="caption" data-start="1" data-duration="2" data-hidden=""',
251
+ );
252
+ expect(writes[0]?.content).toContain('id="badge" data-start="2" data-duration="2"></div>');
253
+ expect(recordEdit).toHaveBeenCalledTimes(1);
254
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide 2 elements");
255
+ });
204
256
  });
@@ -33,7 +33,8 @@ interface ToggleTimelineTrackHiddenInput {
33
33
  }
34
34
 
35
35
  interface ToggleTimelineElementHiddenInput extends Omit<ToggleTimelineTrackHiddenInput, "track"> {
36
- elementKey: string;
36
+ /** One timeline key, or several to hide/show in a single atomic file write. */
37
+ elementKey: string | readonly string[];
37
38
  }
38
39
 
39
40
  interface SetElementsHiddenInput {
@@ -62,7 +63,7 @@ interface UseTimelineTrackVisibilityEditingInput extends Omit<
62
63
 
63
64
  interface UseTimelineElementVisibilityEditingInput extends Omit<
64
65
  ToggleTimelineElementHiddenInput,
65
- "projectId" | "elementKey" | "hidden" | "previewIframe"
66
+ "projectId" | "elementKey" | "hidden" | "previewIframe" | "timelineElements"
66
67
  > {
67
68
  projectIdRef: ReadonlyRef<string | null>;
68
69
  previewIframeRef: ReadonlyRef<HTMLIFrameElement | null>;
@@ -233,13 +234,21 @@ export async function toggleTimelineElementHidden({
233
234
  domEditSaveTimestampRef,
234
235
  pendingTimelineEditPathRef,
235
236
  }: ToggleTimelineElementHiddenInput): Promise<string[]> {
236
- const element = timelineElements.find((item) => (item.key ?? item.id) === elementKey);
237
+ const keys = new Set(typeof elementKey === "string" ? [elementKey] : elementKey);
238
+ const elements = timelineElements.filter((item) => keys.has(item.key ?? item.id));
237
239
  return setElementsHidden({
238
240
  projectId,
239
241
  activeCompPath,
240
- elements: element ? [element] : [],
242
+ elements,
241
243
  hidden,
242
- label: hidden ? "Hide element" : "Show element",
244
+ label:
245
+ elements.length > 1
246
+ ? hidden
247
+ ? `Hide ${elements.length} elements`
248
+ : `Show ${elements.length} elements`
249
+ : hidden
250
+ ? "Hide element"
251
+ : "Show element",
243
252
  previewIframe,
244
253
  writeProjectFile,
245
254
  recordEdit,
@@ -313,7 +322,6 @@ export function useTimelineTrackVisibilityEditing({
313
322
  export function useTimelineElementVisibilityEditing({
314
323
  projectIdRef,
315
324
  activeCompPath,
316
- timelineElements,
317
325
  showToast,
318
326
  writeProjectFile,
319
327
  recordEdit,
@@ -323,11 +331,20 @@ export function useTimelineElementVisibilityEditing({
323
331
  isRecordingRef,
324
332
  forceReloadSdkSession,
325
333
  }: UseTimelineElementVisibilityEditingInput): (
326
- elementKey: string,
334
+ elementKey: string | readonly string[],
327
335
  hidden: boolean,
328
336
  ) => Promise<void> {
337
+ // Resolve against the EXPANDED rows, not the raw store list — a nested
338
+ // sub-composition child has no entry of its own in the raw list (only its
339
+ // host does), so an elementKey for such a child (the
340
+ // `sourceFile#domId`-shaped virtual key `resolveTimelineIdForSelection`
341
+ // falls back to) would never match anything there and Hide All would
342
+ // silently no-op for it. The expanded list synthesizes a real, patchable
343
+ // TimelineElement (with matching key/domId/sourceFile) for each visible
344
+ // child whenever its host is currently expanded.
345
+ const expandedElements = useExpandedTimelineElements();
329
346
  return useCallback(
330
- async (elementKey: string, hidden: boolean) => {
347
+ async (elementKey: string | readonly string[], hidden: boolean) => {
331
348
  if (isRecordingRef?.current) {
332
349
  showToast("Cannot edit timeline while recording", "error");
333
350
  return;
@@ -338,7 +355,7 @@ export function useTimelineElementVisibilityEditing({
338
355
  await toggleTimelineElementHidden({
339
356
  projectId: pid,
340
357
  activeCompPath,
341
- timelineElements,
358
+ timelineElements: expandedElements,
342
359
  elementKey,
343
360
  hidden,
344
361
  previewIframe: previewIframeRef.current,
@@ -357,7 +374,7 @@ export function useTimelineElementVisibilityEditing({
357
374
  },
358
375
  [
359
376
  activeCompPath,
360
- timelineElements,
377
+ expandedElements,
361
378
  previewIframeRef,
362
379
  writeProjectFile,
363
380
  recordEdit,
@@ -9,6 +9,7 @@ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
9
  import { canSplitElement } from "../utils/timelineElementSplit";
10
10
  import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
11
11
  import { trackStudioEvent } from "../utils/studioTelemetry";
12
+ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
12
13
 
13
14
  function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
14
15
  try {
@@ -87,6 +88,7 @@ interface HistoryResult {
87
88
  interface HistoryFileCallbacks {
88
89
  readFile: (path: string) => Promise<string>;
89
90
  writeFile: (path: string, content: string) => Promise<void>;
91
+ serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
90
92
  }
91
93
  interface EditHistoryHandle {
92
94
  undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
@@ -367,6 +369,11 @@ export function useAppHotkeys({
367
369
  },
368
370
  [domEditSaveTimestampRef, writeProjectFile],
369
371
  );
372
+ const serializeHistoryFiles = useCallback(
373
+ <T>(paths: readonly string[], task: () => Promise<T>) =>
374
+ serializeStudioFileMutations(writeProjectFile, paths, task),
375
+ [writeProjectFile],
376
+ );
370
377
 
371
378
  const applyHistory = useCallback(
372
379
  async (direction: "undo" | "redo") => {
@@ -377,6 +384,7 @@ export function useAppHotkeys({
377
384
  const result = await editHistory[direction]({
378
385
  readFile: readHistoryFile,
379
386
  writeFile: writeHistoryFile,
387
+ serialize: serializeHistoryFiles,
380
388
  });
381
389
  if (!result.ok && result.reason === "content-mismatch") {
382
390
  showToast(
@@ -406,6 +414,7 @@ export function useAppHotkeys({
406
414
  syncHistoryPreviewAfterApply,
407
415
  waitForPendingDomEditSaves,
408
416
  writeHistoryFile,
417
+ serializeHistoryFiles,
409
418
  onAfterUndoRedo,
410
419
  activeCompPath,
411
420
  forceReloadSdkSession,