@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,27 @@
1
+ // Mirrors legacy `propertyPanelStyleSections.tsx`'s `SelectField` "Style" options —
2
+ // the single source of truth for which border-style tokens are valid.
3
+ export const STROKE_STYLE_OPTIONS: string[] = [
4
+ "none",
5
+ "solid",
6
+ "dashed",
7
+ "dotted",
8
+ "double",
9
+ "hidden",
10
+ "groove",
11
+ "ridge",
12
+ "inset",
13
+ "outset",
14
+ ];
15
+
16
+ export function formatStrokeSummary(widthPx: number, style: string): string {
17
+ return `${widthPx}px ${style}`;
18
+ }
19
+
20
+ export function parseStrokeSummary(text: string): { widthPx: number; style: string } | null {
21
+ const match = /^\s*(-?\d+(?:\.\d+)?)px\s+(\S+)\s*$/.exec(text);
22
+ if (!match) return null;
23
+ const widthPx = Number.parseFloat(match[1]);
24
+ const style = match[2];
25
+ if (!Number.isFinite(widthPx) || !style) return null;
26
+ return { widthPx, style };
27
+ }
@@ -0,0 +1,598 @@
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 { FlatStyleSection } from "./propertyPanelFlatStyleSections";
7
+ import type { DomEditSelection } from "./domEditing";
8
+ import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ afterEach(() => {
13
+ document.body.innerHTML = "";
14
+ });
15
+
16
+ function makeElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
17
+ return {
18
+ element: document.createElement("div"),
19
+ id: "stat-card",
20
+ selector: ".stat-card",
21
+ label: "Stat Card",
22
+ tagName: "div",
23
+ sourceFile: "index.html",
24
+ compositionPath: "index.html",
25
+ isCompositionHost: false,
26
+ isInsideLockedComposition: false,
27
+ boundingBox: { x: 24, y: 120, width: 420, height: 260 },
28
+ textContent: "",
29
+ dataAttributes: {},
30
+ inlineStyles: { "background-color": "#0D0C09" },
31
+ computedStyles: {},
32
+ textFields: [],
33
+ capabilities: {
34
+ canSelect: true,
35
+ canEditStyles: true,
36
+ canCrop: true,
37
+ canMove: true,
38
+ canResize: true,
39
+ canApplyManualOffset: true,
40
+ canApplyManualSize: true,
41
+ canApplyManualRotation: true,
42
+ },
43
+ ...overrides,
44
+ } as DomEditSelection;
45
+ }
46
+
47
+ function renderSection(
48
+ styles: Record<string, string> = {},
49
+ overrides: Partial<DomEditSelection> = {},
50
+ gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = null,
51
+ ) {
52
+ const host = document.createElement("div");
53
+ document.body.append(host);
54
+ const root = createRoot(host);
55
+ const element = makeElement(overrides);
56
+ const onSetStyle = vi.fn();
57
+ const mergedStyles = { "background-color": "#0D0C09", "border-width": "0px", ...styles };
58
+ act(() => {
59
+ root.render(
60
+ <FlatStyleSection
61
+ projectId="proj-1"
62
+ element={element}
63
+ styles={mergedStyles}
64
+ assets={[]}
65
+ onSetStyle={onSetStyle}
66
+ gsapBorderRadius={gsapBorderRadius}
67
+ />,
68
+ );
69
+ });
70
+ return { host, root, onSetStyle };
71
+ }
72
+
73
+ function clickSegment(host: HTMLElement, label: string) {
74
+ const segment = Array.from(host.querySelectorAll('[data-flat-segment="true"]')).find(
75
+ (el) => el.textContent === label,
76
+ );
77
+ act(() => segment?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
78
+ }
79
+
80
+ describe("FlatStyleSection — Fill", () => {
81
+ it("renders the Fill segmented control defaulting to Solid, and a mint Color row when a color is set", () => {
82
+ const { host, root } = renderSection();
83
+ expect(host.textContent).toContain("Fill");
84
+ expect(host.textContent).toContain("Solid");
85
+ const swatch = host.querySelector('[data-flat-color-trigger="true"]');
86
+ expect(swatch).not.toBeNull();
87
+ act(() => root.unmount());
88
+ });
89
+
90
+ it("switches to the Gradient field when Gradient is selected", () => {
91
+ const { host, root } = renderSection({
92
+ "background-image": "linear-gradient(90deg, #000, #fff)",
93
+ });
94
+ const gradientSegment = Array.from(host.querySelectorAll('[data-flat-segment="true"]')).find(
95
+ (el) => el.textContent === "Gradient",
96
+ );
97
+ expect(gradientSegment?.className).toContain("text-panel-text-0");
98
+ act(() => root.unmount());
99
+ });
100
+
101
+ it("clicking Gradient commits a serialized default gradient built from the current fill color", () => {
102
+ const { host, root, onSetStyle } = renderSection();
103
+ clickSegment(host, "Gradient");
104
+ const expectedGradient = serializeGradient(buildDefaultGradientModel("#0D0C09"));
105
+ expect(onSetStyle).toHaveBeenCalledWith("background-image", expectedGradient);
106
+ act(() => root.unmount());
107
+ });
108
+
109
+ it("clicking Solid clears the background-image back to none", () => {
110
+ const { host, root, onSetStyle } = renderSection({
111
+ "background-image": "linear-gradient(90deg, #000, #fff)",
112
+ });
113
+ clickSegment(host, "Solid");
114
+ expect(onSetStyle).toHaveBeenCalledWith("background-image", "none");
115
+ act(() => root.unmount());
116
+ });
117
+
118
+ it("clicking Image switches to the image-fill field without committing a style", () => {
119
+ const { host, root, onSetStyle } = renderSection();
120
+ clickSegment(host, "Image");
121
+ expect(host.textContent).toContain("Upload image");
122
+ expect(onSetStyle).not.toHaveBeenCalledWith("background-image", expect.anything());
123
+ act(() => root.unmount());
124
+ });
125
+ });
126
+
127
+ function getFlatRowInput(host: HTMLElement, label: string): HTMLInputElement {
128
+ const rows = Array.from(host.querySelectorAll<HTMLElement>(".group"));
129
+ const row = rows.find((el) => el.querySelector("span")?.textContent === label);
130
+ const input = row?.querySelector<HTMLInputElement>("input");
131
+ if (!input) throw new Error(`expected an input for row "${label}"`);
132
+ return input;
133
+ }
134
+
135
+ async function commitFlatRowInput(host: HTMLElement, label: string, nextValue: string) {
136
+ const input = getFlatRowInput(host, label);
137
+ act(() => {
138
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
139
+ window.HTMLInputElement.prototype,
140
+ "value",
141
+ )?.set;
142
+ nativeInputValueSetter?.call(input, nextValue);
143
+ input.dispatchEvent(new Event("input", { bubbles: true }));
144
+ });
145
+ await act(async () => {
146
+ input.dispatchEvent(new Event("focusout", { bubbles: true }));
147
+ await Promise.resolve();
148
+ await Promise.resolve();
149
+ await Promise.resolve();
150
+ });
151
+ }
152
+
153
+ const STROKE_STYLES = {
154
+ "border-width": "1px",
155
+ "border-style": "solid",
156
+ "border-color": "rgba(255,255,255,.12)",
157
+ };
158
+
159
+ function getMetricFieldInput(host: HTMLElement, label: string): HTMLInputElement {
160
+ const spans = Array.from(host.querySelectorAll("span")).filter((el) => el.textContent === label);
161
+ for (const span of spans) {
162
+ const input = span.parentElement?.querySelector<HTMLInputElement>("input");
163
+ if (input) return input;
164
+ }
165
+ throw new Error(`expected a metric field input for "${label}"`);
166
+ }
167
+
168
+ function setInputValue(input: HTMLInputElement, nextValue: string) {
169
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
170
+ window.HTMLInputElement.prototype,
171
+ "value",
172
+ )?.set;
173
+ nativeInputValueSetter?.call(input, nextValue);
174
+ input.dispatchEvent(new Event("input", { bubbles: true }));
175
+ }
176
+
177
+ describe("FlatStyleSection — Stroke and Radius", () => {
178
+ it("renders the combined stroke row and commits width+style together on blur", () => {
179
+ const { host, root } = renderSection(STROKE_STYLES);
180
+ expect(host.textContent).toContain("Stroke");
181
+ expect(getFlatRowInput(host, "Stroke").value).toBe("1px solid");
182
+ act(() => root.unmount());
183
+ });
184
+
185
+ it("commits the stroke row's new width and style together on blur", async () => {
186
+ const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
187
+ await commitFlatRowInput(host, "Stroke", "2px dashed");
188
+ expect(onSetStyle).toHaveBeenCalledWith("border-width", "2px");
189
+ expect(onSetStyle).toHaveBeenCalledWith("border-style", "dashed");
190
+ act(() => root.unmount());
191
+ });
192
+
193
+ it("clamps an out-of-range stroke width commit to 200px (fix 2)", async () => {
194
+ const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
195
+ await commitFlatRowInput(host, "Stroke", "9999px solid");
196
+ expect(onSetStyle).toHaveBeenCalledWith("border-width", "200px");
197
+ act(() => root.unmount());
198
+ });
199
+
200
+ it("rejects a stroke commit whose style token is not a valid border-style (fix 2)", async () => {
201
+ const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
202
+ await commitFlatRowInput(host, "Stroke", "12px bogus");
203
+ expect(onSetStyle).not.toHaveBeenCalled();
204
+ act(() => root.unmount());
205
+ });
206
+
207
+ it("commits a stroke style change through the discoverable Stroke style select (fix 2)", () => {
208
+ const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
209
+ changeFlatSelectRow(host, "Stroke style", "dashed");
210
+ expect(onSetStyle).toHaveBeenCalledWith("border-style", "dashed");
211
+ act(() => root.unmount());
212
+ });
213
+
214
+ it("commits a new stroke color through the flat ColorField (fix 1)", () => {
215
+ const { host, root, onSetStyle } = renderSection({
216
+ "border-width": "1px",
217
+ "border-style": "solid",
218
+ "border-color": "rgb(10, 20, 30)",
219
+ });
220
+ const trigger = Array.from(
221
+ host.querySelectorAll<HTMLButtonElement>('[data-flat-color-trigger="true"]'),
222
+ ).find((btn) => btn.getAttribute("aria-label") === "Pick stroke color color");
223
+ if (!trigger) throw new Error("expected the stroke color trigger");
224
+ act(() => trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })));
225
+ const hexInput = Array.from(document.querySelectorAll<HTMLInputElement>("input")).find(
226
+ (input) => !host.contains(input),
227
+ );
228
+ if (!hexInput) throw new Error("expected the color picker's hex input");
229
+ act(() => setInputValue(hexInput, "#112233"));
230
+ expect(onSetStyle).toHaveBeenCalledWith("border-color", "rgb(17, 34, 51)");
231
+ act(() => root.unmount());
232
+ });
233
+
234
+ it("uses BorderRadiusEditor for radius, linked by default, even when corners are uniform (fix 3)", () => {
235
+ const { host, root } = renderSection({ "border-radius": "12px" });
236
+ const unlinkButton = host.querySelector<HTMLButtonElement>('button[title="Unlink corners"]');
237
+ expect(unlinkButton).not.toBeNull();
238
+ expect(getMetricFieldInput(host, "All").value).toBe("12");
239
+ act(() => root.unmount());
240
+ });
241
+
242
+ it("commits a uniform radius value through BorderRadiusEditor's linked All field", () => {
243
+ const { host, root, onSetStyle } = renderSection({ "border-radius": "12px" });
244
+ const allInput = getMetricFieldInput(host, "All");
245
+ act(() => setInputValue(allInput, "20"));
246
+ act(() => allInput.dispatchEvent(new Event("focusout", { bubbles: true })));
247
+ expect(onSetStyle).toHaveBeenCalledWith("border-radius", "20px");
248
+ act(() => root.unmount());
249
+ });
250
+
251
+ it("commits a single-corner radius update after unlinking a uniform radius (fix 3)", () => {
252
+ const { host, root, onSetStyle } = renderSection({ "border-radius": "12px" });
253
+ const unlinkButton = host.querySelector<HTMLButtonElement>('button[title="Unlink corners"]');
254
+ if (!unlinkButton) throw new Error("expected the unlink toggle button");
255
+ act(() => unlinkButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
256
+ const trInput = getMetricFieldInput(host, "TR");
257
+ act(() => setInputValue(trInput, "18"));
258
+ act(() => trInput.dispatchEvent(new Event("focusout", { bubbles: true })));
259
+ expect(onSetStyle).toHaveBeenCalledWith("border-top-right-radius", "18px");
260
+ expect(onSetStyle).not.toHaveBeenCalledWith("border-radius", expect.anything());
261
+ act(() => root.unmount());
262
+ });
263
+
264
+ it("falls back to the legacy BorderRadiusEditor when corners are not uniform", () => {
265
+ const { host, root } = renderSection({}, {}, { tl: 4, tr: 12, br: 4, bl: 4 });
266
+ expect(host.textContent).not.toContain("Linked");
267
+ act(() => root.unmount());
268
+ });
269
+
270
+ it("commits a per-corner radius update through the legacy BorderRadiusEditor when unlinked", () => {
271
+ const { host, root, onSetStyle } = renderSection({}, {}, { tl: 4, tr: 12, br: 4, bl: 4 });
272
+ const trInput = Array.from(host.querySelectorAll<HTMLInputElement>("input")).find(
273
+ (el) => el.value === "12",
274
+ );
275
+ if (!trInput) throw new Error("expected the TR corner input");
276
+ act(() => setInputValue(trInput, "18"));
277
+ act(() => {
278
+ trInput.dispatchEvent(new Event("focusout", { bubbles: true }));
279
+ });
280
+ expect(onSetStyle).toHaveBeenCalledWith("border-top-right-radius", "18px");
281
+ act(() => root.unmount());
282
+ });
283
+ });
284
+
285
+ function getFlatSelectRow(host: HTMLElement, label: string) {
286
+ const rows = Array.from(host.querySelectorAll<HTMLElement>(".group"));
287
+ const row = rows.find((el) => el.querySelector("span")?.textContent === label);
288
+ if (!row) throw new Error(`expected a select row for "${label}"`);
289
+ const select = row.querySelector<HTMLSelectElement>("select");
290
+ if (!select) throw new Error(`expected a select for "${label}"`);
291
+ const resetButton = row.querySelector<HTMLButtonElement>('[data-flat-select-reset="true"]');
292
+ return { row, select, resetButton };
293
+ }
294
+
295
+ function changeFlatSelectRow(host: HTMLElement, label: string, nextValue: string) {
296
+ const { select } = getFlatSelectRow(host, label);
297
+ act(() => {
298
+ select.value = nextValue;
299
+ select.dispatchEvent(new Event("change", { bubbles: true }));
300
+ });
301
+ }
302
+
303
+ describe("FlatStyleSection — Shadow and Blend", () => {
304
+ it("renders Shadow with the inferred preset and a reset when set, Blend with a plain select", () => {
305
+ const { host, root } = renderSection({ "box-shadow": "0 8px 24px rgba(0,0,0,.35)" });
306
+ expect(host.textContent).toContain("Shadow");
307
+ expect(host.textContent).toContain("Blend");
308
+ const selects = host.querySelectorAll("select");
309
+ expect(selects.length).toBeGreaterThanOrEqual(2);
310
+ act(() => root.unmount());
311
+ });
312
+
313
+ it("commits a shadow preset change through onSetStyle", () => {
314
+ const { host, root, onSetStyle } = renderSection({});
315
+ changeFlatSelectRow(host, "Shadow", "soft");
316
+ expect(onSetStyle).toHaveBeenCalledWith("box-shadow", expect.any(String));
317
+ act(() => root.unmount());
318
+ });
319
+
320
+ it("commits a blend mode change through onSetStyle", () => {
321
+ const { host, root, onSetStyle } = renderSection({});
322
+ changeFlatSelectRow(host, "Blend", "multiply");
323
+ expect(onSetStyle).toHaveBeenCalledWith("mix-blend-mode", "multiply");
324
+ act(() => root.unmount());
325
+ });
326
+
327
+ it("resets the shadow preset back to none via the reset button", () => {
328
+ const { host, root, onSetStyle } = renderSection({
329
+ "box-shadow": "0 12px 36px rgba(0, 0, 0, 0.28)",
330
+ });
331
+ const { resetButton } = getFlatSelectRow(host, "Shadow");
332
+ if (!resetButton) throw new Error("expected the shadow reset button");
333
+ act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
334
+ expect(onSetStyle).toHaveBeenCalledWith("box-shadow", "none");
335
+ act(() => root.unmount());
336
+ });
337
+
338
+ it("resets the blend mode back to normal via the reset button", () => {
339
+ const { host, root, onSetStyle } = renderSection({ "mix-blend-mode": "multiply" });
340
+ const { resetButton } = getFlatSelectRow(host, "Blend");
341
+ if (!resetButton) throw new Error("expected the blend reset button");
342
+ act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
343
+ expect(onSetStyle).toHaveBeenCalledWith("mix-blend-mode", "normal");
344
+ act(() => root.unmount());
345
+ });
346
+ });
347
+
348
+ describe("FlatStyleSection — blur sliders", () => {
349
+ it("renders Layer blur and Backdrop sliders and commits through onSetStyle", () => {
350
+ const onSetStyle = vi.fn();
351
+ const host = document.createElement("div");
352
+ document.body.append(host);
353
+ const root = createRoot(host);
354
+ act(() => {
355
+ root.render(
356
+ <FlatStyleSection
357
+ projectId="proj-1"
358
+ element={makeElement()}
359
+ styles={{ filter: "blur(4px)" }}
360
+ assets={[]}
361
+ onSetStyle={onSetStyle}
362
+ gsapBorderRadius={null}
363
+ />,
364
+ );
365
+ });
366
+ expect(host.textContent).toContain("Layer blur");
367
+ expect(host.textContent).toContain("Backdrop");
368
+ expect(host.textContent).toContain("4px");
369
+ const track = host.querySelectorAll('[data-flat-slider-track="true"]')[0];
370
+ Object.defineProperty(track, "getBoundingClientRect", {
371
+ value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
372
+ });
373
+ act(() => {
374
+ track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
375
+ track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
376
+ });
377
+ // filterBlurValue=4 -> max=Math.max(40, 4)=40; clientX=50 of width 100 -> ratio 0.5 -> 20px.
378
+ expect(onSetStyle).toHaveBeenCalledWith("filter", "blur(20px)");
379
+ act(() => root.unmount());
380
+ });
381
+
382
+ it("renders the Backdrop slider from backdrop-filter and commits a new blur value on track click", () => {
383
+ const { host, root, onSetStyle } = renderSection({ "backdrop-filter": "blur(6px)" });
384
+ expect(host.textContent).toContain("Backdrop");
385
+ expect(host.textContent).toContain("6px");
386
+ const tracks = host.querySelectorAll('[data-flat-slider-track="true"]');
387
+ // Track order is Layer blur, Backdrop, Opacity — Backdrop is the second track.
388
+ const backdropTrack = tracks[1];
389
+ Object.defineProperty(backdropTrack, "getBoundingClientRect", {
390
+ value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
391
+ });
392
+ act(() => {
393
+ backdropTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
394
+ backdropTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
395
+ });
396
+ // backdropBlurValue=6 -> max=Math.max(60, 6)=60; clientX=50 of width 100 -> ratio 0.5 -> 30px.
397
+ expect(onSetStyle).toHaveBeenCalledWith("backdrop-filter", "blur(30px)");
398
+ act(() => root.unmount());
399
+ });
400
+
401
+ it("does not render a fill/knob highlight for a zero-value blur (default tier)", () => {
402
+ const { host, root } = renderSection({});
403
+ const tracks = host.querySelectorAll('[data-flat-slider-track="true"]');
404
+ // Only the first two tracks are the blur sliders (Layer blur, Backdrop); Opacity
405
+ // (the third track) always renders a fill by design, so it's excluded here.
406
+ const blurTracks = Array.from(tracks).slice(0, 2);
407
+ for (const track of blurTracks) {
408
+ expect(track.querySelectorAll('[data-flat-slider-fill="true"]')).toHaveLength(0);
409
+ }
410
+ act(() => root.unmount());
411
+ });
412
+ });
413
+
414
+ function getInsetSideInputOrNull(host: HTMLElement, label: "T" | "R" | "B" | "L") {
415
+ const span = Array.from(host.querySelectorAll("span")).find((el) => el.textContent === label);
416
+ return span?.parentElement?.querySelector<HTMLInputElement>("input") ?? null;
417
+ }
418
+
419
+ function getInsetSideInput(host: HTMLElement, label: "T" | "R" | "B" | "L"): HTMLInputElement {
420
+ const input = getInsetSideInputOrNull(host, label);
421
+ if (!input) throw new Error(`expected an inset side input for "${label}"`);
422
+ return input;
423
+ }
424
+
425
+ async function commitInsetSideInput(
426
+ host: HTMLElement,
427
+ label: "T" | "R" | "B" | "L",
428
+ nextValue: string,
429
+ ) {
430
+ const input = getInsetSideInput(host, label);
431
+ act(() => {
432
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
433
+ window.HTMLInputElement.prototype,
434
+ "value",
435
+ )?.set;
436
+ nativeInputValueSetter?.call(input, nextValue);
437
+ input.dispatchEvent(new Event("input", { bubbles: true }));
438
+ });
439
+ await act(async () => {
440
+ input.dispatchEvent(new Event("focusout", { bubbles: true }));
441
+ await Promise.resolve();
442
+ });
443
+ }
444
+
445
+ describe("FlatStyleSection — Overflow and Mask", () => {
446
+ it("renders Overflow and Mask selects, and inset-side rows when the mask is an inset", () => {
447
+ const { host, root } = renderSection({
448
+ overflow: "hidden",
449
+ "clip-path": "inset(8px round 4px)",
450
+ });
451
+ expect(host.textContent).toContain("Overflow");
452
+ expect(host.textContent).toContain("Mask");
453
+ expect(host.textContent).toContain("hidden");
454
+ act(() => root.unmount());
455
+ });
456
+
457
+ it("commits an overflow change through onSetStyle", () => {
458
+ const onSetStyle = vi.fn();
459
+ const host = document.createElement("div");
460
+ document.body.append(host);
461
+ const root = createRoot(host);
462
+ act(() => {
463
+ root.render(
464
+ <FlatStyleSection
465
+ projectId="proj-1"
466
+ element={makeElement()}
467
+ styles={{}}
468
+ assets={[]}
469
+ onSetStyle={onSetStyle}
470
+ gsapBorderRadius={null}
471
+ />,
472
+ );
473
+ });
474
+ const overflowSelect = Array.from(host.querySelectorAll("select")).find((s) =>
475
+ Array.from(s.options).some((o) => o.value === "scroll"),
476
+ );
477
+ if (!overflowSelect) throw new Error("expected the overflow select");
478
+ act(() => {
479
+ overflowSelect.value = "hidden";
480
+ overflowSelect.dispatchEvent(new Event("change", { bubbles: true }));
481
+ });
482
+ expect(onSetStyle).toHaveBeenCalledWith("overflow", "hidden");
483
+ act(() => root.unmount());
484
+ });
485
+
486
+ it("resets overflow back to visible via the reset button", () => {
487
+ const { host, root, onSetStyle } = renderSection({ overflow: "scroll" });
488
+ const { resetButton } = getFlatSelectRow(host, "Overflow");
489
+ if (!resetButton) throw new Error("expected the overflow reset button");
490
+ act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
491
+ expect(onSetStyle).toHaveBeenCalledWith("overflow", "visible");
492
+ act(() => root.unmount());
493
+ });
494
+
495
+ it("commits a mask preset change through onSetStyle, building an inset() clip-path", () => {
496
+ const { host, root, onSetStyle } = renderSection({});
497
+ changeFlatSelectRow(host, "Mask", "inset");
498
+ expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(0 round 0px)");
499
+ act(() => root.unmount());
500
+ });
501
+
502
+ it("resets the mask back to none via the reset button", () => {
503
+ const { host, root, onSetStyle } = renderSection({ "clip-path": "circle(50% at 50% 50%)" });
504
+ const { resetButton } = getFlatSelectRow(host, "Mask");
505
+ if (!resetButton) throw new Error("expected the mask reset button");
506
+ act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
507
+ expect(onSetStyle).toHaveBeenCalledWith("clip-path", "none");
508
+ act(() => root.unmount());
509
+ });
510
+
511
+ it("does not render inset-side rows when the mask is not an inset", () => {
512
+ const { host, root } = renderSection({ "clip-path": "circle(50% at 50% 50%)" });
513
+ expect(getInsetSideInputOrNull(host, "T")).toBeNull();
514
+ act(() => root.unmount());
515
+ });
516
+
517
+ it("commits a T inset-side edit through onSetStyle, preserving the other sides and radius", async () => {
518
+ const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" });
519
+ await commitInsetSideInput(host, "T", "10");
520
+ expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(10px 8px 8px 8px round 4px)");
521
+ act(() => root.unmount());
522
+ });
523
+
524
+ it("commits an L inset-side edit through onSetStyle", async () => {
525
+ const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" });
526
+ await commitInsetSideInput(host, "L", "2");
527
+ expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 8px 8px 2px round 4px)");
528
+ act(() => root.unmount());
529
+ });
530
+
531
+ it("commits an R inset-side edit through onSetStyle", async () => {
532
+ const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" });
533
+ await commitInsetSideInput(host, "R", "3");
534
+ expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 3px 8px 8px round 4px)");
535
+ act(() => root.unmount());
536
+ });
537
+
538
+ it("commits a B inset-side edit through onSetStyle", async () => {
539
+ const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" });
540
+ await commitInsetSideInput(host, "B", "5");
541
+ expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 8px 5px 8px round 4px)");
542
+ act(() => root.unmount());
543
+ });
544
+
545
+ it("renders a uniform Mask inset slider and commits clip-path via buildInsetClipPathValue (fix 4)", () => {
546
+ const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" });
547
+ expect(host.textContent).toContain("Mask inset");
548
+ const tracks = host.querySelectorAll('[data-flat-slider-track="true"]');
549
+ // Track order: Layer blur, Backdrop, Mask inset, Opacity.
550
+ const maskInsetTrack = tracks[2];
551
+ Object.defineProperty(maskInsetTrack, "getBoundingClientRect", {
552
+ value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
553
+ });
554
+ act(() => {
555
+ maskInsetTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
556
+ maskInsetTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
557
+ });
558
+ // clipInsetValue=8 -> max=Math.max(120, 8)=120; clientX=50 of width 100 -> ratio 0.5 -> 60px.
559
+ // border-radius is unset here, so the clip-path's own `round 4px` is not reused — radiusValue
560
+ // (read from the `border-radius` style, matching legacy) is 0.
561
+ expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(60px round 0px)");
562
+ act(() => root.unmount());
563
+ });
564
+ });
565
+
566
+ describe("FlatStyleSection — Opacity", () => {
567
+ it("renders the Opacity slider at 100% by default and commits a change through onSetStyle", () => {
568
+ const onSetStyle = vi.fn();
569
+ const host = document.createElement("div");
570
+ document.body.append(host);
571
+ const root = createRoot(host);
572
+ act(() => {
573
+ root.render(
574
+ <FlatStyleSection
575
+ projectId="proj-1"
576
+ element={makeElement()}
577
+ styles={{}}
578
+ assets={[]}
579
+ onSetStyle={onSetStyle}
580
+ gsapBorderRadius={null}
581
+ />,
582
+ );
583
+ });
584
+ expect(host.textContent).toContain("Opacity");
585
+ expect(host.textContent).toContain("100%");
586
+ const tracks = host.querySelectorAll('[data-flat-slider-track="true"]');
587
+ const opacityTrack = tracks[tracks.length - 1];
588
+ Object.defineProperty(opacityTrack, "getBoundingClientRect", {
589
+ value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
590
+ });
591
+ act(() => {
592
+ opacityTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
593
+ opacityTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
594
+ });
595
+ expect(onSetStyle).toHaveBeenCalledWith("opacity", "0.5");
596
+ act(() => root.unmount());
597
+ });
598
+ });