@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,418 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
7
+ import { useColorGradingController } from "./useColorGradingController";
8
+ import type { DomEditSelection } from "./domEditing";
9
+
10
+ function freshPopGrading() {
11
+ const next = normalizeHfColorGrading({ preset: "fresh-pop", intensity: 1 });
12
+ if (!next) throw new Error("expected fresh-pop preset to normalize");
13
+ return next;
14
+ }
15
+
16
+ function naturalLiftGrading() {
17
+ const next = normalizeHfColorGrading({ preset: "natural-lift", intensity: 1 });
18
+ if (!next) throw new Error("expected natural-lift preset to normalize");
19
+ return next;
20
+ }
21
+
22
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
23
+
24
+ afterEach(() => {
25
+ document.body.innerHTML = "";
26
+ });
27
+
28
+ function makeElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
29
+ return {
30
+ element: document.createElement("video"),
31
+ id: "s1-bg",
32
+ selector: "#s1-bg",
33
+ label: "S1 Background",
34
+ tagName: "video",
35
+ sourceFile: "index.html",
36
+ compositionPath: "index.html",
37
+ isCompositionHost: false,
38
+ isInsideLockedComposition: false,
39
+ boundingBox: { x: 0, y: 0, width: 1920, height: 1080 },
40
+ textContent: "",
41
+ dataAttributes: {},
42
+ inlineStyles: {},
43
+ computedStyles: {},
44
+ textFields: [],
45
+ capabilities: {
46
+ canSelect: true,
47
+ canEditStyles: true,
48
+ canCrop: true,
49
+ canMove: true,
50
+ canResize: true,
51
+ canApplyManualOffset: true,
52
+ canApplyManualSize: true,
53
+ canApplyManualRotation: true,
54
+ },
55
+ ...overrides,
56
+ } as DomEditSelection;
57
+ }
58
+
59
+ function HookHost({
60
+ onState,
61
+ onSetAttributeLive,
62
+ element,
63
+ }: {
64
+ onState: (state: ReturnType<typeof useColorGradingController>) => void;
65
+ onSetAttributeLive: (attr: string, value: string | null) => void;
66
+ element: DomEditSelection;
67
+ }) {
68
+ const state = useColorGradingController({
69
+ projectId: "proj",
70
+ element,
71
+ onSetAttributeLive,
72
+ });
73
+ onState(state);
74
+ return null;
75
+ }
76
+
77
+ function renderHook(
78
+ onSetAttributeLive: (attr: string, value: string | null) => void,
79
+ initialElement: DomEditSelection = makeElement(),
80
+ ) {
81
+ const host = document.createElement("div");
82
+ document.body.append(host);
83
+ const root = createRoot(host);
84
+ let latest: ReturnType<typeof useColorGradingController> | undefined;
85
+ const renderWith = (element: DomEditSelection) => {
86
+ act(() => {
87
+ root.render(
88
+ React.createElement(HookHost, {
89
+ onState: (s: ReturnType<typeof useColorGradingController>) => (latest = s),
90
+ onSetAttributeLive,
91
+ element,
92
+ }),
93
+ );
94
+ });
95
+ };
96
+ renderWith(initialElement);
97
+ return {
98
+ root,
99
+ rerenderWithElement: renderWith,
100
+ // A method, not a getter — `const { state } = renderHook(...)` would
101
+ // destructure a getter into a one-time snapshot, silently going stale
102
+ // after the first state change. Call `.getState()` fresh every time.
103
+ getState(): ReturnType<typeof useColorGradingController> {
104
+ if (!latest) throw new Error("hook did not render");
105
+ return latest;
106
+ },
107
+ };
108
+ }
109
+
110
+ describe("useColorGradingController", () => {
111
+ it("starts with the neutral (inactive) grading and idle compare state", () => {
112
+ const { root, getState } = renderHook(vi.fn());
113
+ expect(getState().grading.preset).toBe("neutral");
114
+ expect(getState().compareEnabled).toBe(false);
115
+ act(() => root.unmount());
116
+ });
117
+
118
+ it("commitColorGrading updates grading state synchronously and schedules a debounced persist", async () => {
119
+ vi.useFakeTimers();
120
+ const onSetAttributeLive = vi.fn();
121
+ const { root, getState } = renderHook(onSetAttributeLive);
122
+ act(() => {
123
+ getState().commitColorGrading(freshPopGrading());
124
+ });
125
+ expect(getState().grading.preset).toBe("fresh-pop");
126
+ expect(onSetAttributeLive).not.toHaveBeenCalled();
127
+ act(() => {
128
+ vi.advanceTimersByTime(400);
129
+ });
130
+ expect(onSetAttributeLive).toHaveBeenCalledTimes(1);
131
+ const [attr, value] = onSetAttributeLive.mock.calls[0] as [string, string];
132
+ expect(attr).toBe("color-grading");
133
+ expect(value).toContain("fresh-pop");
134
+ act(() => root.unmount());
135
+ vi.useRealTimers();
136
+ });
137
+
138
+ it("reverts to the last confirmed-good grading via the real onSettled(false) signal (matches runDomEditCommit, which never rejects)", async () => {
139
+ // The actual Studio commit runner (runDomEditCommit) catches persist
140
+ // failures internally and always resolves — it reports outcome only
141
+ // through the onSettled callback passed as the 3rd argument. A mock
142
+ // that only rejects would validate a path the real callback never takes.
143
+ vi.useFakeTimers();
144
+ const onSetAttributeLive = vi.fn(
145
+ (_attr: string, _value: string | null, onSettled?: (ok: boolean) => void) => {
146
+ onSettled?.(false);
147
+ return Promise.resolve();
148
+ },
149
+ );
150
+ const { root, getState } = renderHook(onSetAttributeLive);
151
+ act(() => {
152
+ getState().commitColorGrading(freshPopGrading());
153
+ });
154
+ expect(getState().grading.preset).toBe("fresh-pop");
155
+ act(() => {
156
+ vi.advanceTimersByTime(400);
157
+ });
158
+ await act(async () => {
159
+ await Promise.resolve();
160
+ await Promise.resolve();
161
+ });
162
+ expect(getState().grading.preset).toBe("neutral");
163
+ expect(getState().runtimeStatus.state).toBe("unavailable");
164
+ act(() => root.unmount());
165
+ vi.useRealTimers();
166
+ });
167
+
168
+ it("reverts to the last confirmed-good grading when a persist rejects (fallback for a non-onSettled implementation)", async () => {
169
+ vi.useFakeTimers();
170
+ const onSetAttributeLive = vi.fn().mockRejectedValue(new Error("disk full"));
171
+ const { root, getState } = renderHook(onSetAttributeLive);
172
+ act(() => {
173
+ getState().commitColorGrading(freshPopGrading());
174
+ });
175
+ expect(getState().grading.preset).toBe("fresh-pop");
176
+ act(() => {
177
+ vi.advanceTimersByTime(400);
178
+ });
179
+ // The rejection settles on a microtask, not a timer — flush it.
180
+ await act(async () => {
181
+ await Promise.resolve();
182
+ await Promise.resolve();
183
+ });
184
+ // Reverted to "neutral" (the last confirmed-good value, from before this
185
+ // commit) instead of permanently showing "fresh-pop" as if it had saved.
186
+ expect(getState().grading.preset).toBe("neutral");
187
+ expect(getState().runtimeStatus.state).toBe("unavailable");
188
+ act(() => root.unmount());
189
+ vi.useRealTimers();
190
+ });
191
+
192
+ it("a stale in-flight persist result does not touch state after selection has moved on to a THIRD element", async () => {
193
+ vi.useFakeTimers();
194
+ let resolveA: (() => void) | undefined;
195
+ let capturedOnSettledA: ((ok: boolean) => void) | undefined;
196
+ const onSetAttributeLive = vi.fn(
197
+ (_attr: string, _value: string | null, onSettled?: (ok: boolean) => void) => {
198
+ capturedOnSettledA = onSettled;
199
+ return new Promise<void>((resolve) => {
200
+ resolveA = resolve;
201
+ });
202
+ },
203
+ );
204
+ const { root, getState, rerenderWithElement } = renderHook(
205
+ onSetAttributeLive,
206
+ makeElement({ id: "s1-bg" }),
207
+ );
208
+ act(() => {
209
+ getState().commitColorGrading(freshPopGrading());
210
+ });
211
+ // Let the debounce fire while still on s1-bg — the persist call is now
212
+ // genuinely in flight (its promise won't settle until resolveA() below).
213
+ act(() => {
214
+ vi.advanceTimersByTime(400);
215
+ });
216
+ expect(onSetAttributeLive).toHaveBeenCalledTimes(1);
217
+
218
+ // Selection moves twice more while s1-bg's persist is still pending.
219
+ rerenderWithElement(makeElement({ id: "s2-bg" }));
220
+ rerenderWithElement(makeElement({ id: "s3-bg" }));
221
+ expect(getState().grading.preset).toBe("neutral"); // s3-bg's own fresh state
222
+
223
+ // NOW the stale s1-bg persist finally settles as a failure.
224
+ act(() => {
225
+ capturedOnSettledA?.(false);
226
+ resolveA?.();
227
+ });
228
+ await act(async () => {
229
+ await Promise.resolve();
230
+ await Promise.resolve();
231
+ });
232
+
233
+ // s3-bg's state must be untouched by a result that belongs to s1-bg.
234
+ expect(getState().grading.preset).toBe("neutral");
235
+ expect(getState().runtimeStatus.state).not.toBe("unavailable");
236
+ act(() => root.unmount());
237
+ vi.useRealTimers();
238
+ });
239
+
240
+ it("a stale in-flight persist for edit A does not clobber edit B's state — SAME element, no selection change", async () => {
241
+ vi.useFakeTimers();
242
+ let resolveA: (() => void) | undefined;
243
+ let capturedOnSettledA: ((ok: boolean) => void) | undefined;
244
+ const onSetAttributeLive = vi
245
+ .fn()
246
+ // Edit A (fresh-pop): captures its onSettled and never resolves until
247
+ // resolveA() is called below — simulates a slow persist.
248
+ .mockImplementationOnce(
249
+ (_attr: string, _value: string | null, onSettled?: (ok: boolean) => void) => {
250
+ capturedOnSettledA = onSettled;
251
+ return new Promise<void>((resolve) => {
252
+ resolveA = resolve;
253
+ });
254
+ },
255
+ )
256
+ // Edit B (natural-lift): settles immediately and successfully.
257
+ .mockImplementationOnce(
258
+ (_attr: string, _value: string | null, onSettled?: (ok: boolean) => void) => {
259
+ onSettled?.(true);
260
+ return Promise.resolve();
261
+ },
262
+ );
263
+ const { root, getState } = renderHook(onSetAttributeLive);
264
+
265
+ act(() => {
266
+ getState().commitColorGrading(freshPopGrading());
267
+ });
268
+ act(() => {
269
+ vi.advanceTimersByTime(400);
270
+ });
271
+ expect(onSetAttributeLive).toHaveBeenCalledTimes(1); // A's persist now in flight
272
+
273
+ // B commits on the SAME element before A's persist has settled.
274
+ act(() => {
275
+ getState().commitColorGrading(naturalLiftGrading());
276
+ });
277
+ act(() => {
278
+ vi.advanceTimersByTime(400);
279
+ });
280
+ expect(onSetAttributeLive).toHaveBeenCalledTimes(2); // B's persist has already settled (mock resolves sync)
281
+ expect(getState().grading.preset).toBe("natural-lift");
282
+
283
+ // NOW A's stale persist finally settles as a FAILURE — must not revert
284
+ // `grading` (which now correctly shows B's newer edit) back to the
285
+ // pre-A baseline ("neutral"), and must not stamp confirmedGradingRef
286
+ // with A's now-superseded attempt on success either.
287
+ act(() => {
288
+ capturedOnSettledA?.(false);
289
+ resolveA?.();
290
+ });
291
+ await act(async () => {
292
+ await Promise.resolve();
293
+ await Promise.resolve();
294
+ });
295
+ expect(getState().grading.preset).toBe("natural-lift");
296
+ act(() => root.unmount());
297
+ vi.useRealTimers();
298
+ });
299
+
300
+ it("resetGrading returns to the neutral preset", () => {
301
+ const { root, getState } = renderHook(vi.fn());
302
+ act(() => {
303
+ getState().commitColorGrading(freshPopGrading());
304
+ });
305
+ act(() => {
306
+ getState().resetGrading();
307
+ });
308
+ expect(getState().grading.preset).toBe("neutral");
309
+ act(() => root.unmount());
310
+ });
311
+
312
+ it("resets grading/compare state when selection changes to a different element", () => {
313
+ const { root, getState, rerenderWithElement } = renderHook(
314
+ vi.fn(),
315
+ makeElement({ id: "s1-bg" }),
316
+ );
317
+ act(() => {
318
+ getState().commitColorGrading(freshPopGrading());
319
+ });
320
+ expect(getState().grading.preset).toBe("fresh-pop");
321
+ // A different element, with no persisted grading of its own — without a
322
+ // reset, this hook (unlike the legacy component it was extracted from,
323
+ // which remounts via a `key={selectionIdentityKey}`) would keep showing
324
+ // the previous element's grading.
325
+ rerenderWithElement(makeElement({ id: "s2-bg" }));
326
+ expect(getState().grading.preset).toBe("neutral");
327
+ act(() => root.unmount());
328
+ });
329
+
330
+ it("also resets when the same local id/selector recurs in a different source file", () => {
331
+ // Same id, same selector, same selectorIndex — only sourceFile differs.
332
+ // Without sourceFile in the identity key, this would collide with the
333
+ // first element (e.g. host composition vs. an inlined sub-composition,
334
+ // or two unrelated sub-comps that happen to share a local id).
335
+ const { root, getState, rerenderWithElement } = renderHook(
336
+ vi.fn(),
337
+ makeElement({ id: "bg", sourceFile: "index.html" }),
338
+ );
339
+ act(() => {
340
+ getState().commitColorGrading(freshPopGrading());
341
+ });
342
+ expect(getState().grading.preset).toBe("fresh-pop");
343
+ rerenderWithElement(makeElement({ id: "bg", sourceFile: "sub-comp.html" }));
344
+ expect(getState().grading.preset).toBe("neutral");
345
+ act(() => root.unmount());
346
+ });
347
+
348
+ it("flushes — rather than discards — a pending persist for the previous element when selection changes before it fires", () => {
349
+ vi.useFakeTimers();
350
+ const onSetAttributeLive = vi.fn();
351
+ const { root, getState, rerenderWithElement } = renderHook(
352
+ onSetAttributeLive,
353
+ makeElement({ id: "s1-bg" }),
354
+ );
355
+ act(() => {
356
+ getState().commitColorGrading(freshPopGrading());
357
+ });
358
+ // Switch selection before the 350ms debounce fires — the in-flight edit
359
+ // must be written immediately (targeting the OUTGOING element's own
360
+ // commit callback), not silently dropped just because a debounce timer
361
+ // hadn't elapsed yet.
362
+ act(() => {
363
+ vi.advanceTimersByTime(200);
364
+ });
365
+ rerenderWithElement(makeElement({ id: "s2-bg" }));
366
+ expect(onSetAttributeLive).toHaveBeenCalledTimes(1);
367
+ const [attr, value] = onSetAttributeLive.mock.calls[0] as [string, string];
368
+ expect(attr).toBe("color-grading");
369
+ expect(value).toContain("fresh-pop");
370
+ // And it must not ALSO fire again once the (now-cleared) original timer
371
+ // window would have elapsed.
372
+ act(() => {
373
+ vi.advanceTimersByTime(400);
374
+ });
375
+ expect(onSetAttributeLive).toHaveBeenCalledTimes(1);
376
+ act(() => root.unmount());
377
+ vi.useRealTimers();
378
+ });
379
+
380
+ it("does not permanently cache a non-OK media/metadata response — the next mount retries", async () => {
381
+ const videoWithSrc = () => {
382
+ const el = document.createElement("video");
383
+ el.setAttribute("src", "clip.mp4");
384
+ return el;
385
+ };
386
+ const fetchMock = vi
387
+ .fn()
388
+ .mockResolvedValueOnce({ ok: false } as Response)
389
+ .mockResolvedValueOnce({
390
+ ok: true,
391
+ json: () =>
392
+ Promise.resolve({ metadata: { kind: "video", color: { dynamicRange: "hdr" } } }),
393
+ } as unknown as Response);
394
+ vi.stubGlobal("fetch", fetchMock);
395
+
396
+ const first = renderHook(vi.fn(), makeElement({ id: "retry-asset", element: videoWithSrc() }));
397
+ await act(async () => {
398
+ await Promise.resolve();
399
+ });
400
+ expect(first.getState().mediaMetadata).toBeNull();
401
+ act(() => first.root.unmount());
402
+
403
+ // A second, independent mount for the SAME asset path — if the failed
404
+ // response had been cached, this would never re-fetch and mediaMetadata
405
+ // would stay null forever.
406
+ const second = renderHook(
407
+ vi.fn(),
408
+ makeElement({ id: "retry-asset-2", element: videoWithSrc() }),
409
+ );
410
+ await act(async () => {
411
+ await Promise.resolve();
412
+ });
413
+ expect(fetchMock).toHaveBeenCalledTimes(2);
414
+ expect(second.getState().mediaMetadata?.color.dynamicRange).toBe("hdr");
415
+ act(() => second.root.unmount());
416
+ vi.unstubAllGlobals();
417
+ });
418
+ });