@hyperframes/studio 0.7.58 → 0.7.59

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 (156) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13514 -8528
  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 +3 -3
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +29 -51
  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 +101 -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/VariablePromoteContext.test.tsx +67 -0
  96. package/src/contexts/VariablePromoteContext.tsx +10 -5
  97. package/src/hooks/domEditCommitRunner.ts +11 -0
  98. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  99. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  100. package/src/hooks/timelineTrackVisibility.ts +27 -10
  101. package/src/hooks/useAppHotkeys.ts +9 -0
  102. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  103. package/src/hooks/useDomEditCommits.ts +29 -175
  104. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  105. package/src/hooks/useDomEditSession.ts +17 -5
  106. package/src/hooks/useDomEditTextCommits.ts +14 -9
  107. package/src/hooks/useDomEditWiring.ts +10 -9
  108. package/src/hooks/useElementLifecycleOps.ts +8 -3
  109. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  110. package/src/hooks/useFileManager.ts +82 -60
  111. package/src/hooks/useGsapAnimationOps.ts +7 -6
  112. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  113. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  114. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  115. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  116. package/src/hooks/useGsapScriptCommits.ts +150 -50
  117. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  118. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  119. package/src/hooks/useInspectorSplitResize.ts +51 -0
  120. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  121. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  122. package/src/hooks/usePersistentEditHistory.ts +71 -31
  123. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  124. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  125. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  126. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  127. package/src/hooks/useSdkSession.ts +155 -14
  128. package/src/hooks/useSlideshowPersist.ts +7 -1
  129. package/src/hooks/useStoryboard.ts +23 -4
  130. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  131. package/src/hooks/useTimelineEditing.ts +16 -10
  132. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  133. package/src/hooks/useTimelineGroupEditing.ts +16 -2
  134. package/src/hooks/useVariablesPersist.ts +9 -7
  135. package/src/icons/SystemIcons.tsx +2 -0
  136. package/src/styles/studio.css +22 -0
  137. package/src/styles/tailwind-preset.shared.js +7 -0
  138. package/src/utils/designInputTracking.test.ts +97 -0
  139. package/src/utils/designInputTracking.ts +80 -0
  140. package/src/utils/sdkCutover.gate.test.ts +7 -7
  141. package/src/utils/sdkCutover.test.ts +490 -57
  142. package/src/utils/sdkCutover.ts +181 -219
  143. package/src/utils/sdkEditTransaction.ts +292 -0
  144. package/src/utils/sdkResolverAttempts.ts +94 -0
  145. package/src/utils/sdkResolverShadow.test.ts +148 -0
  146. package/src/utils/sdkResolverShadow.ts +139 -106
  147. package/src/utils/setSlideshowManifest.test.ts +0 -9
  148. package/src/utils/setSlideshowManifest.ts +20 -27
  149. package/src/utils/studioFileHistory.test.ts +41 -0
  150. package/src/utils/studioFileHistory.ts +30 -27
  151. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  152. package/src/utils/studioHelpers.test.ts +45 -0
  153. package/src/utils/studioHelpers.ts +45 -14
  154. package/src/utils/studioSaveDiagnostics.ts +17 -0
  155. package/dist/assets/index-B_UvTX3E.js +0 -423
  156. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -0,0 +1,102 @@
1
+ import {
2
+ buildInsetClipPathSides,
3
+ buildInsetClipPathValue,
4
+ formatNumericValue,
5
+ formatPxMetricValue,
6
+ getClipPathInsetPx,
7
+ inferClipPathPreset,
8
+ parseInsetClipPathSides,
9
+ parsePxMetricValue,
10
+ type ClipPathInsetSides,
11
+ } from "./propertyPanelHelpers";
12
+ import { FlatSlider } from "./propertyPanelFlatPrimitives";
13
+ import { MetricField } from "./propertyPanelPrimitives";
14
+
15
+ /* ------------------------------------------------------------------ */
16
+ /* Flat Mask inset — uniform slider + per-side fields */
17
+ /* (split out of propertyPanelFlatStyleSections.tsx to stay under the */
18
+ /* 600-line file-size gate) */
19
+ /* ------------------------------------------------------------------ */
20
+
21
+ export function FlatMaskInsetRows({
22
+ clipPathValue,
23
+ radiusValue,
24
+ disabled,
25
+ onSetStyle,
26
+ }: {
27
+ clipPathValue: string;
28
+ radiusValue: number;
29
+ disabled: boolean;
30
+ onSetStyle: (prop: string, value: string) => void | Promise<void>;
31
+ }) {
32
+ const clipPathPreset = inferClipPathPreset(clipPathValue);
33
+ const parsedClipInsets = parseInsetClipPathSides(clipPathValue);
34
+ const clipInsetValue = getClipPathInsetPx(clipPathValue);
35
+ const clipInsetSides = parsedClipInsets ?? {
36
+ top: clipInsetValue,
37
+ right: clipInsetValue,
38
+ bottom: clipInsetValue,
39
+ left: clipInsetValue,
40
+ radius: radiusValue,
41
+ };
42
+ const showClipInsetSides = clipPathPreset === "inset" || parsedClipInsets != null;
43
+
44
+ const commitClipInsetSide = (side: keyof ClipPathInsetSides, nextValue: string) => {
45
+ const next = parsePxMetricValue(nextValue);
46
+ if (next == null) return;
47
+ const sides: ClipPathInsetSides = {
48
+ top: clipInsetSides.top,
49
+ right: clipInsetSides.right,
50
+ bottom: clipInsetSides.bottom,
51
+ left: clipInsetSides.left,
52
+ };
53
+ sides[side] = next;
54
+ void onSetStyle("clip-path", buildInsetClipPathSides(sides, clipInsetSides.radius));
55
+ };
56
+
57
+ return (
58
+ <>
59
+ <FlatSlider
60
+ label="Mask inset"
61
+ value={clipInsetValue}
62
+ min={0}
63
+ max={Math.max(120, Math.ceil(clipInsetValue))}
64
+ step={1}
65
+ tier={clipInsetValue > 0 ? "explicitCustom" : "default"}
66
+ displayValue={`${formatNumericValue(clipInsetValue)}px`}
67
+ disabled={disabled}
68
+ onCommit={(next) =>
69
+ void onSetStyle("clip-path", buildInsetClipPathValue(next, radiusValue))
70
+ }
71
+ />
72
+ {showClipInsetSides && (
73
+ <div className="grid grid-cols-4 gap-2">
74
+ <MetricField
75
+ label="T"
76
+ value={formatPxMetricValue(clipInsetSides.top)}
77
+ disabled={disabled}
78
+ onCommit={(next) => commitClipInsetSide("top", next)}
79
+ />
80
+ <MetricField
81
+ label="R"
82
+ value={formatPxMetricValue(clipInsetSides.right)}
83
+ disabled={disabled}
84
+ onCommit={(next) => commitClipInsetSide("right", next)}
85
+ />
86
+ <MetricField
87
+ label="B"
88
+ value={formatPxMetricValue(clipInsetSides.bottom)}
89
+ disabled={disabled}
90
+ onCommit={(next) => commitClipInsetSide("bottom", next)}
91
+ />
92
+ <MetricField
93
+ label="L"
94
+ value={formatPxMetricValue(clipInsetSides.left)}
95
+ disabled={disabled}
96
+ onCommit={(next) => commitClipInsetSide("left", next)}
97
+ />
98
+ </div>
99
+ )}
100
+ </>
101
+ );
102
+ }
@@ -0,0 +1,436 @@
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 { FlatMediaSection } from "./propertyPanelFlatMediaSection";
7
+ import type { DomEditSelection } from "./domEditing";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ function makeVideoElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
16
+ const el = document.createElement("video");
17
+ el.setAttribute("src", "assets/intro-loop.mp4");
18
+ return {
19
+ element: el,
20
+ id: "s1-bg",
21
+ selector: "#s1-bg",
22
+ label: "S1 Background",
23
+ tagName: "video",
24
+ sourceFile: "index.html",
25
+ compositionPath: "index.html",
26
+ isCompositionHost: false,
27
+ isInsideLockedComposition: false,
28
+ boundingBox: { x: 0, y: 0, width: 1920, height: 1080 },
29
+ textContent: "",
30
+ dataAttributes: {},
31
+ inlineStyles: {},
32
+ computedStyles: {},
33
+ textFields: [],
34
+ capabilities: {
35
+ canSelect: true,
36
+ canEditStyles: true,
37
+ canCrop: true,
38
+ canMove: true,
39
+ canResize: true,
40
+ canApplyManualOffset: true,
41
+ canApplyManualSize: true,
42
+ canApplyManualRotation: true,
43
+ },
44
+ ...overrides,
45
+ } as DomEditSelection;
46
+ }
47
+
48
+ function renderSection(overrides: Partial<DomEditSelection> = {}) {
49
+ const host = document.createElement("div");
50
+ document.body.append(host);
51
+ const root = createRoot(host);
52
+ const element = makeVideoElement(overrides);
53
+ act(() => {
54
+ root.render(
55
+ <FlatMediaSection
56
+ projectDir={null}
57
+ element={element}
58
+ styles={{}}
59
+ onSetStyle={vi.fn()}
60
+ onSetAttribute={vi.fn()}
61
+ onSetHtmlAttribute={vi.fn()}
62
+ />,
63
+ );
64
+ });
65
+ return { host, root };
66
+ }
67
+
68
+ describe("FlatMediaSection — source row", () => {
69
+ it("renders the source path and copies it to clipboard on click", () => {
70
+ Object.defineProperty(navigator, "clipboard", {
71
+ value: { writeText: vi.fn().mockResolvedValue(undefined) },
72
+ configurable: true,
73
+ });
74
+ const { host, root } = renderSection();
75
+ expect(host.textContent).toContain("assets/intro-loop.mp4");
76
+ const copyButton = host.querySelector<HTMLButtonElement>('[data-flat-media-copy="true"]');
77
+ expect(copyButton).not.toBeNull();
78
+ act(() => copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
79
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith("assets/intro-loop.mp4");
80
+ act(() => root.unmount());
81
+ });
82
+ });
83
+
84
+ describe("FlatMediaSection — cutout", () => {
85
+ it("shows the WebM label for video and fires background removal on click", async () => {
86
+ const onRemoveBackground = vi.fn().mockResolvedValue({ outputPath: "assets/intro-loop.webm" });
87
+ const onSetHtmlAttribute = vi.fn();
88
+ const onSetAttribute = vi.fn();
89
+ const host = document.createElement("div");
90
+ document.body.append(host);
91
+ const root = createRoot(host);
92
+ const element = makeVideoElement();
93
+ act(() => {
94
+ root.render(
95
+ <FlatMediaSection
96
+ projectDir={null}
97
+ element={element}
98
+ styles={{}}
99
+ onSetStyle={vi.fn()}
100
+ onSetAttribute={onSetAttribute}
101
+ onSetHtmlAttribute={onSetHtmlAttribute}
102
+ onRemoveBackground={onRemoveBackground}
103
+ />,
104
+ );
105
+ });
106
+ expect(host.textContent).toContain("transparent WebM");
107
+ const removeBgButton = host.querySelector<HTMLButtonElement>(
108
+ '[data-flat-media-remove-bg="true"]',
109
+ );
110
+ expect(removeBgButton).not.toBeNull();
111
+ await act(async () => {
112
+ removeBgButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
113
+ await Promise.resolve();
114
+ await Promise.resolve();
115
+ });
116
+ expect(onRemoveBackground).toHaveBeenCalled();
117
+ act(() => root.unmount());
118
+ });
119
+
120
+ it("toggles BG plate via FlatToggle", () => {
121
+ const { host, root } = renderSection();
122
+ const plateToggle = host.querySelector<HTMLButtonElement>(
123
+ '[data-flat-toggle="true"][aria-label="BG plate"]',
124
+ );
125
+ expect(plateToggle).not.toBeNull();
126
+ expect(plateToggle?.getAttribute("aria-checked")).toBe("false");
127
+ act(() => plateToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
128
+ expect(plateToggle?.getAttribute("aria-checked")).toBe("true");
129
+ act(() => root.unmount());
130
+ });
131
+ });
132
+
133
+ describe("FlatMediaSection — volume/rate/media-start", () => {
134
+ it("renders volume at its stored percentage and commits a new value on drag", () => {
135
+ const onSetAttribute = vi.fn();
136
+ const element = makeVideoElement({ dataAttributes: { volume: "0.5" } });
137
+ const host = document.createElement("div");
138
+ document.body.append(host);
139
+ const root = createRoot(host);
140
+ act(() => {
141
+ root.render(
142
+ <FlatMediaSection
143
+ projectDir={null}
144
+ element={element}
145
+ styles={{}}
146
+ onSetStyle={vi.fn()}
147
+ onSetAttribute={onSetAttribute}
148
+ onSetHtmlAttribute={vi.fn()}
149
+ />,
150
+ );
151
+ });
152
+ expect(host.textContent).toContain("50%");
153
+ act(() => root.unmount());
154
+ });
155
+
156
+ it("commits a new volume value on slider track pointerdown", () => {
157
+ const onSetAttribute = vi.fn();
158
+ const element = makeVideoElement({ dataAttributes: { volume: "0.2" } });
159
+ const host = document.createElement("div");
160
+ document.body.append(host);
161
+ const root = createRoot(host);
162
+ act(() => {
163
+ root.render(
164
+ <FlatMediaSection
165
+ projectDir={null}
166
+ element={element}
167
+ styles={{}}
168
+ onSetStyle={vi.fn()}
169
+ onSetAttribute={onSetAttribute}
170
+ onSetHtmlAttribute={vi.fn()}
171
+ />,
172
+ );
173
+ });
174
+ const volumeTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[0];
175
+ Object.defineProperty(volumeTrack, "getBoundingClientRect", {
176
+ value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
177
+ });
178
+ act(() => {
179
+ volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
180
+ volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
181
+ });
182
+ // starting volume 0.2 (draft=20); min=0, max=100, ratio=0.5 -> raw=50 -> commit(50) -> 50/100=0.5 -> "0.5"
183
+ expect(onSetAttribute).toHaveBeenCalledWith("volume", "0.5");
184
+ act(() => root.unmount());
185
+ });
186
+
187
+ it("commits a new rate value on slider track pointerdown", () => {
188
+ const onSetAttribute = vi.fn();
189
+ const element = makeVideoElement();
190
+ const host = document.createElement("div");
191
+ document.body.append(host);
192
+ const root = createRoot(host);
193
+ act(() => {
194
+ root.render(
195
+ <FlatMediaSection
196
+ projectDir={null}
197
+ element={element}
198
+ styles={{}}
199
+ onSetStyle={vi.fn()}
200
+ onSetAttribute={onSetAttribute}
201
+ onSetHtmlAttribute={vi.fn()}
202
+ />,
203
+ );
204
+ });
205
+ const rateTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[1];
206
+ Object.defineProperty(rateTrack, "getBoundingClientRect", {
207
+ value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
208
+ });
209
+ act(() => {
210
+ rateTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
211
+ rateTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
212
+ });
213
+ // min=25, max=300, ratio=1.0 -> raw=300 -> commit(300) -> 300/100=3 -> "3"
214
+ expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "3");
215
+ act(() => root.unmount());
216
+ });
217
+
218
+ it("commits a new media-start value on slider track pointerdown", () => {
219
+ const onSetAttribute = vi.fn();
220
+ const element = makeVideoElement();
221
+ const host = document.createElement("div");
222
+ document.body.append(host);
223
+ const root = createRoot(host);
224
+ act(() => {
225
+ root.render(
226
+ <FlatMediaSection
227
+ projectDir={null}
228
+ element={element}
229
+ styles={{}}
230
+ onSetStyle={vi.fn()}
231
+ onSetAttribute={onSetAttribute}
232
+ onSetHtmlAttribute={vi.fn()}
233
+ />,
234
+ );
235
+ });
236
+ const mediaStartTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[2];
237
+ Object.defineProperty(mediaStartTrack, "getBoundingClientRect", {
238
+ value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
239
+ });
240
+ act(() => {
241
+ mediaStartTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
242
+ mediaStartTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
243
+ });
244
+ // no source-duration set -> mediaStartMax=Math.max(30, Math.ceil(0+10))=30 -> max=3000
245
+ // ratio=1.0 -> raw=3000 -> commit(3000) -> (3000/100).toFixed(2) = "30.00"
246
+ expect(onSetAttribute).toHaveBeenCalledWith("media-start", "30.00");
247
+ act(() => root.unmount());
248
+ });
249
+ });
250
+
251
+ describe("FlatMediaSection — loop/muted/has-audio", () => {
252
+ it("toggles loop via onSetHtmlAttribute and shows has-audio-track for video", () => {
253
+ const onSetHtmlAttribute = vi.fn();
254
+ const onSetAttribute = vi.fn();
255
+ const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } });
256
+ const host = document.createElement("div");
257
+ document.body.append(host);
258
+ const root = createRoot(host);
259
+ act(() => {
260
+ root.render(
261
+ <FlatMediaSection
262
+ projectDir={null}
263
+ element={element}
264
+ styles={{}}
265
+ onSetStyle={vi.fn()}
266
+ onSetAttribute={onSetAttribute}
267
+ onSetHtmlAttribute={onSetHtmlAttribute}
268
+ />,
269
+ );
270
+ });
271
+ const loopToggle = host.querySelector<HTMLButtonElement>(
272
+ '[data-flat-toggle="true"][aria-label="Loop"]',
273
+ );
274
+ act(() => loopToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
275
+ expect(onSetHtmlAttribute).toHaveBeenCalledWith("loop", "true");
276
+
277
+ const hasAudioToggle = host.querySelector<HTMLButtonElement>(
278
+ '[data-flat-toggle="true"][aria-label="Has audio track"]',
279
+ );
280
+ expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("true");
281
+ act(() => root.unmount());
282
+ });
283
+
284
+ it("toggles muted via onSetHtmlAttribute", () => {
285
+ const onSetHtmlAttribute = vi.fn();
286
+ const onSetAttribute = vi.fn();
287
+ const element = makeVideoElement();
288
+ const host = document.createElement("div");
289
+ document.body.append(host);
290
+ const root = createRoot(host);
291
+ act(() => {
292
+ root.render(
293
+ <FlatMediaSection
294
+ projectDir={null}
295
+ element={element}
296
+ styles={{}}
297
+ onSetStyle={vi.fn()}
298
+ onSetAttribute={onSetAttribute}
299
+ onSetHtmlAttribute={onSetHtmlAttribute}
300
+ />,
301
+ );
302
+ });
303
+ const mutedToggle = host.querySelector<HTMLButtonElement>(
304
+ '[data-flat-toggle="true"][aria-label="Muted"]',
305
+ );
306
+ expect(mutedToggle?.getAttribute("aria-checked")).toBe("false");
307
+ act(() => mutedToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
308
+ expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", "true");
309
+ act(() => root.unmount());
310
+ });
311
+
312
+ it("enables has-audio-track and clears muted on click", () => {
313
+ const onSetHtmlAttribute = vi.fn();
314
+ const onSetAttribute = vi.fn();
315
+ const element = makeVideoElement();
316
+ const host = document.createElement("div");
317
+ document.body.append(host);
318
+ const root = createRoot(host);
319
+ act(() => {
320
+ root.render(
321
+ <FlatMediaSection
322
+ projectDir={null}
323
+ element={element}
324
+ styles={{}}
325
+ onSetStyle={vi.fn()}
326
+ onSetAttribute={onSetAttribute}
327
+ onSetHtmlAttribute={onSetHtmlAttribute}
328
+ />,
329
+ );
330
+ });
331
+ const hasAudioToggle = host.querySelector<HTMLButtonElement>(
332
+ '[data-flat-toggle="true"][aria-label="Has audio track"]',
333
+ );
334
+ expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("false");
335
+ act(() => hasAudioToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
336
+ expect(onSetAttribute).toHaveBeenCalledWith("has-audio", "true");
337
+ expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", null);
338
+ act(() => root.unmount());
339
+ });
340
+
341
+ it("disables has-audio-track and sets muted on click", () => {
342
+ const onSetHtmlAttribute = vi.fn();
343
+ const onSetAttribute = vi.fn();
344
+ const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } });
345
+ const host = document.createElement("div");
346
+ document.body.append(host);
347
+ const root = createRoot(host);
348
+ act(() => {
349
+ root.render(
350
+ <FlatMediaSection
351
+ projectDir={null}
352
+ element={element}
353
+ styles={{}}
354
+ onSetStyle={vi.fn()}
355
+ onSetAttribute={onSetAttribute}
356
+ onSetHtmlAttribute={onSetHtmlAttribute}
357
+ />,
358
+ );
359
+ });
360
+ const hasAudioToggle = host.querySelector<HTMLButtonElement>(
361
+ '[data-flat-toggle="true"][aria-label="Has audio track"]',
362
+ );
363
+ expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("true");
364
+ act(() => hasAudioToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
365
+ expect(onSetAttribute).toHaveBeenCalledWith("has-audio", "");
366
+ expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", "true");
367
+ act(() => root.unmount());
368
+ });
369
+ });
370
+
371
+ describe("FlatMediaSection — fit/position", () => {
372
+ it("commits object-fit and object-position changes", () => {
373
+ const onSetStyle = vi.fn();
374
+ const { host, root } = (() => {
375
+ const element = makeVideoElement();
376
+ const host = document.createElement("div");
377
+ document.body.append(host);
378
+ const root = createRoot(host);
379
+ act(() => {
380
+ root.render(
381
+ <FlatMediaSection
382
+ projectDir={null}
383
+ element={element}
384
+ styles={{ "object-fit": "cover", "object-position": "center" }}
385
+ onSetStyle={onSetStyle}
386
+ onSetAttribute={vi.fn()}
387
+ onSetHtmlAttribute={vi.fn()}
388
+ />,
389
+ );
390
+ });
391
+ return { host, root };
392
+ })();
393
+ const selects = host.querySelectorAll("select");
394
+ const fitSelect = Array.from(selects).find((s) => s.value === "cover");
395
+ expect(fitSelect).not.toBeUndefined();
396
+ act(() => {
397
+ if (fitSelect) {
398
+ fitSelect.value = "contain";
399
+ fitSelect.dispatchEvent(new Event("change", { bubbles: true }));
400
+ }
401
+ });
402
+ expect(onSetStyle).toHaveBeenCalledWith("object-fit", "contain");
403
+ act(() => root.unmount());
404
+ });
405
+
406
+ it("commits an object-position change", () => {
407
+ const onSetStyle = vi.fn();
408
+ const element = makeVideoElement();
409
+ const host = document.createElement("div");
410
+ document.body.append(host);
411
+ const root = createRoot(host);
412
+ act(() => {
413
+ root.render(
414
+ <FlatMediaSection
415
+ projectDir={null}
416
+ element={element}
417
+ styles={{ "object-fit": "cover", "object-position": "center" }}
418
+ onSetStyle={onSetStyle}
419
+ onSetAttribute={vi.fn()}
420
+ onSetHtmlAttribute={vi.fn()}
421
+ />,
422
+ );
423
+ });
424
+ const selects = host.querySelectorAll("select");
425
+ const positionSelect = Array.from(selects).find((s) => s.value === "center");
426
+ expect(positionSelect).not.toBeUndefined();
427
+ act(() => {
428
+ if (positionSelect) {
429
+ positionSelect.value = "left top";
430
+ positionSelect.dispatchEvent(new Event("change", { bubbles: true }));
431
+ }
432
+ });
433
+ expect(onSetStyle).toHaveBeenCalledWith("object-position", "left top");
434
+ act(() => root.unmount());
435
+ });
436
+ });