@hyperframes/studio 0.7.57 → 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 (247) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-KsfE1bUu.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-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +27 -7
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +18029 -11563
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles/tailwind-preset.d.ts +5 -0
  14. package/dist/styles/tailwind-preset.js +8 -1
  15. package/dist/styles/tailwind-preset.js.map +1 -1
  16. package/package.json +7 -7
  17. package/src/App.tsx +6 -6
  18. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  19. package/src/components/StudioFeedbackBar.tsx +2 -2
  20. package/src/components/StudioRightPanel.tsx +29 -51
  21. package/src/components/editor/AnimationCard.test.tsx +134 -0
  22. package/src/components/editor/AnimationCard.tsx +19 -4
  23. package/src/components/editor/ArcPathControls.tsx +1 -0
  24. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  25. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  26. package/src/components/editor/DomEditOverlay.tsx +5 -2
  27. package/src/components/editor/GestureRecordControl.tsx +7 -1
  28. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  29. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  30. package/src/components/editor/LayersPanel.tsx +110 -99
  31. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  32. package/src/components/editor/PropertyPanel.tsx +144 -148
  33. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  34. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  35. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  36. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  37. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  38. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  39. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  40. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  41. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  42. package/src/components/editor/domEditingDom.ts +5 -6
  43. package/src/components/editor/domEditingElement.ts +15 -32
  44. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  45. package/src/components/editor/gsapLivePreview.ts +34 -0
  46. package/src/components/editor/layersPanelSort.ts +80 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  48. package/src/components/editor/manualEditingAvailability.ts +9 -0
  49. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  50. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  51. package/src/components/editor/propertyPanelColor.tsx +31 -1
  52. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  53. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  54. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  55. package/src/components/editor/propertyPanelFill.tsx +22 -5
  56. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  57. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  58. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  59. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  60. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  61. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  62. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  63. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  64. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  65. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  66. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  67. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  68. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  69. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  70. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  71. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  72. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  73. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  74. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  75. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  76. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  77. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  78. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  79. package/src/components/editor/propertyPanelFont.tsx +136 -95
  80. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  81. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  82. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  83. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  84. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  85. package/src/components/editor/propertyPanelSections.tsx +154 -90
  86. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  87. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  88. package/src/components/editor/propertyPanelTypes.ts +14 -1
  89. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  90. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  91. package/src/components/editor/useColorGradingController.test.ts +418 -0
  92. package/src/components/editor/useColorGradingController.ts +596 -0
  93. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  94. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  95. package/src/components/nle/PreviewOverlays.tsx +27 -2
  96. package/src/components/nle/TimelinePane.test.ts +3 -0
  97. package/src/components/nle/TimelinePane.tsx +11 -1
  98. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  99. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  100. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  101. package/src/components/nle/zLaneGesture.test.ts +176 -0
  102. package/src/components/nle/zLaneGesture.ts +77 -0
  103. package/src/components/panels/VariablesPanel.tsx +4 -0
  104. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  105. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  106. package/src/components/storyboard/FramePoster.tsx +24 -4
  107. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  108. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  109. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  110. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  111. package/src/components/storyboard/StoryboardView.tsx +9 -2
  112. package/src/components/storyboard/frameComments.test.ts +95 -0
  113. package/src/components/storyboard/frameComments.ts +115 -0
  114. package/src/components/storyboard/useFrameComments.ts +82 -0
  115. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  116. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  117. package/src/contexts/DomEditContext.tsx +4 -0
  118. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  119. package/src/contexts/VariablePromoteContext.tsx +10 -5
  120. package/src/hooks/domEditCommitRunner.ts +11 -0
  121. package/src/hooks/domEditCommitTypes.ts +17 -1
  122. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  123. package/src/hooks/fetchStubTestUtils.ts +16 -0
  124. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  125. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  126. package/src/hooks/timelineEditingHelpers.ts +32 -15
  127. package/src/hooks/timelineMoveAdapter.ts +2 -1
  128. package/src/hooks/timelineTimingSync.test.ts +703 -33
  129. package/src/hooks/timelineTimingSync.ts +282 -97
  130. package/src/hooks/timelineTrackVisibility.test.ts +55 -1
  131. package/src/hooks/timelineTrackVisibility.ts +31 -13
  132. package/src/hooks/useAppHotkeys.ts +9 -0
  133. package/src/hooks/useClipboard.ts +19 -17
  134. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  135. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  136. package/src/hooks/useDomEditCommits.ts +81 -162
  137. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  138. package/src/hooks/useDomEditSession.test.tsx +3 -1
  139. package/src/hooks/useDomEditSession.ts +18 -6
  140. package/src/hooks/useDomEditTextCommits.ts +14 -9
  141. package/src/hooks/useDomEditWiring.ts +10 -9
  142. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  143. package/src/hooks/useElementLifecycleOps.ts +182 -84
  144. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  145. package/src/hooks/useFileManager.ts +82 -60
  146. package/src/hooks/useGsapAnimationOps.ts +7 -6
  147. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  148. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  149. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  150. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  151. package/src/hooks/useGsapScriptCommits.ts +150 -50
  152. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  153. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  154. package/src/hooks/useInspectorSplitResize.ts +51 -0
  155. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  156. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  157. package/src/hooks/usePersistentEditHistory.ts +71 -31
  158. package/src/hooks/usePreviewPersistence.ts +2 -2
  159. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  160. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  161. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  162. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  163. package/src/hooks/useSdkSession.ts +155 -14
  164. package/src/hooks/useSlideshowPersist.ts +7 -1
  165. package/src/hooks/useStoryboard.ts +23 -4
  166. package/src/hooks/useTimelineEditing.test.tsx +306 -155
  167. package/src/hooks/useTimelineEditing.ts +111 -101
  168. package/src/hooks/useTimelineEditingTypes.ts +4 -1
  169. package/src/hooks/useTimelineGroupEditing.ts +51 -5
  170. package/src/hooks/useVariablesPersist.ts +9 -7
  171. package/src/icons/SystemIcons.tsx +2 -0
  172. package/src/player/components/Timeline.test.ts +31 -21
  173. package/src/player/components/Timeline.tsx +62 -91
  174. package/src/player/components/TimelineCanvas.tsx +36 -5
  175. package/src/player/components/TimelineLanes.tsx +32 -10
  176. package/src/player/components/TimelineOverlays.tsx +36 -0
  177. package/src/player/components/TimelineRuler.tsx +18 -5
  178. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  179. package/src/player/components/timelineCallbacks.ts +4 -1
  180. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  181. package/src/player/components/timelineClipDragCommit.ts +173 -71
  182. package/src/player/components/timelineGapCommit.test.ts +163 -0
  183. package/src/player/components/timelineGapCommit.ts +103 -0
  184. package/src/player/components/timelineGaps.test.ts +221 -0
  185. package/src/player/components/timelineGaps.ts +182 -0
  186. package/src/player/components/timelineLayout.test.ts +2 -1
  187. package/src/player/components/timelineLayout.ts +22 -6
  188. package/src/player/components/timelineMarquee.test.ts +20 -10
  189. package/src/player/components/timelineMarquee.ts +9 -2
  190. package/src/player/components/timelineStackingSync.test.ts +42 -1
  191. package/src/player/components/timelineStackingSync.ts +31 -16
  192. package/src/player/components/timelineZMirror.test.ts +667 -0
  193. package/src/player/components/timelineZMirror.ts +393 -0
  194. package/src/player/components/timelineZoom.test.ts +2 -2
  195. package/src/player/components/timelineZoom.ts +7 -3
  196. package/src/player/components/useTimelineClipDrag.ts +1 -1
  197. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  198. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  199. package/src/player/components/useTimelinePlayhead.ts +4 -3
  200. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  201. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  202. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  203. package/src/player/components/useTimelineStackingSync.ts +5 -3
  204. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  205. package/src/player/components/useTrackGapMenu.ts +155 -0
  206. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  207. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  208. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  209. package/src/player/lib/layerOrdering.test.ts +1 -15
  210. package/src/player/lib/layerOrdering.ts +5 -28
  211. package/src/player/lib/timelineDOM.test.ts +72 -0
  212. package/src/player/lib/timelineDOM.ts +3 -0
  213. package/src/player/lib/timelineElementHelpers.ts +35 -11
  214. package/src/player/store/playerStore.ts +12 -1
  215. package/src/styles/studio.css +22 -0
  216. package/src/styles/tailwind-preset.shared.js +7 -0
  217. package/src/telemetry/events.test.ts +10 -0
  218. package/src/telemetry/events.ts +1 -0
  219. package/src/utils/designInputTracking.test.ts +97 -0
  220. package/src/utils/designInputTracking.ts +80 -0
  221. package/src/utils/domEditSaveQueue.ts +4 -3
  222. package/src/utils/editHistory.test.ts +82 -0
  223. package/src/utils/editHistory.ts +14 -2
  224. package/src/utils/gsapSoftReload.test.ts +54 -115
  225. package/src/utils/gsapSoftReload.ts +42 -152
  226. package/src/utils/gsapUndoRestore.test.ts +269 -0
  227. package/src/utils/gsapUndoRestore.ts +269 -0
  228. package/src/utils/sdkCutover.gate.test.ts +7 -7
  229. package/src/utils/sdkCutover.test.ts +490 -57
  230. package/src/utils/sdkCutover.ts +181 -219
  231. package/src/utils/sdkEditTransaction.ts +292 -0
  232. package/src/utils/sdkResolverAttempts.ts +94 -0
  233. package/src/utils/sdkResolverShadow.test.ts +148 -0
  234. package/src/utils/sdkResolverShadow.ts +139 -106
  235. package/src/utils/setSlideshowManifest.test.ts +0 -9
  236. package/src/utils/setSlideshowManifest.ts +20 -27
  237. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  238. package/src/utils/studioFileHistory.test.ts +41 -0
  239. package/src/utils/studioFileHistory.ts +30 -27
  240. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  241. package/src/utils/studioHelpers.test.ts +45 -0
  242. package/src/utils/studioHelpers.ts +45 -14
  243. package/src/utils/studioSaveDiagnostics.ts +17 -0
  244. package/dist/assets/index-DfmYkU44.js +0 -423
  245. package/dist/assets/index-_pqzyxB1.css +0 -1
  246. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  247. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -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
+ });