@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,465 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useState } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { FlatTextLayerList, FlatTextSection } from "./propertyPanelFlatTextSection";
7
+ import type { DomEditSelection, DomEditTextField } from "./domEditingTypes";
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 renderInto(node: React.ReactElement) {
16
+ const host = document.createElement("div");
17
+ document.body.append(host);
18
+ const root = createRoot(host);
19
+ act(() => {
20
+ root.render(node);
21
+ });
22
+ return { host, root };
23
+ }
24
+
25
+ const FIELDS = [
26
+ {
27
+ key: "a",
28
+ label: "Text",
29
+ value: "Headline",
30
+ tagName: "div",
31
+ attributes: [],
32
+ inlineStyles: {},
33
+ computedStyles: {},
34
+ source: "self" as const,
35
+ },
36
+ {
37
+ key: "b",
38
+ label: "Text",
39
+ value: "Subhead",
40
+ tagName: "span",
41
+ attributes: [],
42
+ inlineStyles: {},
43
+ computedStyles: {},
44
+ source: "self" as const,
45
+ },
46
+ ];
47
+
48
+ describe("FlatTextLayerList", () => {
49
+ it("falls back to a numbered label per index for empty fields, not a bare 'Text'", () => {
50
+ const emptyFields = [
51
+ { ...FIELDS[0], value: "" },
52
+ { ...FIELDS[1], value: "" },
53
+ ];
54
+ const { host, root } = renderInto(
55
+ <FlatTextLayerList
56
+ fields={emptyFields as never}
57
+ activeFieldKey="a"
58
+ styles={{}}
59
+ onSelect={vi.fn()}
60
+ onAdd={vi.fn()}
61
+ onRemove={vi.fn()}
62
+ />,
63
+ );
64
+ expect(host.textContent).toContain("Text 1");
65
+ expect(host.textContent).toContain("Text 2");
66
+ act(() => root.unmount());
67
+ });
68
+
69
+ it("lists every field, highlights the active one, and fires onSelect/onAdd/onRemove", () => {
70
+ const onSelect = vi.fn();
71
+ const onAdd = vi.fn();
72
+ const onRemove = vi.fn();
73
+ const { host, root } = renderInto(
74
+ <FlatTextLayerList
75
+ fields={FIELDS as never}
76
+ activeFieldKey="a"
77
+ styles={{}}
78
+ onSelect={onSelect}
79
+ onAdd={onAdd}
80
+ onRemove={onRemove}
81
+ />,
82
+ );
83
+ expect(host.textContent).toContain("Headline");
84
+ expect(host.textContent).toContain("Subhead");
85
+
86
+ const rows = host.querySelectorAll('[data-flat-text-layer-row="true"]');
87
+ expect(rows).toHaveLength(2);
88
+ expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true");
89
+ expect((rows[1] as HTMLElement).getAttribute("data-active")).toBe("false");
90
+
91
+ act(() => rows[1].dispatchEvent(new MouseEvent("click", { bubbles: true })));
92
+ expect(onSelect).toHaveBeenCalledWith("b");
93
+
94
+ const addButton = host.querySelector<HTMLButtonElement>('[data-flat-text-layer-add="true"]');
95
+ act(() => addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
96
+ expect(onAdd).toHaveBeenCalledTimes(1);
97
+
98
+ const removeButton = host.querySelector<HTMLButtonElement>(
99
+ '[data-flat-text-layer-remove="true"]',
100
+ );
101
+ act(() => removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
102
+ expect(onRemove).toHaveBeenCalledWith("a");
103
+ // stopPropagation on the remove button must prevent the row's own onClick
104
+ // from also firing onSelect for the removed field's key.
105
+ expect(onSelect).toHaveBeenCalledTimes(1);
106
+ expect(onSelect).not.toHaveBeenCalledWith("a");
107
+ act(() => root.unmount());
108
+ });
109
+ });
110
+
111
+ function makeMultiFieldElement(): DomEditSelection {
112
+ return {
113
+ element: document.createElement("div"),
114
+ id: "multi",
115
+ selector: ".multi",
116
+ label: "Multi",
117
+ tagName: "div",
118
+ sourceFile: "index.html",
119
+ compositionPath: "index.html",
120
+ isCompositionHost: false,
121
+ isInsideLockedComposition: false,
122
+ boundingBox: { x: 0, y: 0, width: 100, height: 100 },
123
+ textContent: "Headline Subhead",
124
+ dataAttributes: {},
125
+ inlineStyles: {},
126
+ computedStyles: {},
127
+ textFields: [
128
+ {
129
+ key: "a",
130
+ label: "Text",
131
+ value: "Headline",
132
+ tagName: "div",
133
+ attributes: [],
134
+ inlineStyles: {},
135
+ computedStyles: {},
136
+ source: "self",
137
+ },
138
+ {
139
+ key: "b",
140
+ label: "Text",
141
+ value: "Subhead",
142
+ tagName: "span",
143
+ attributes: [],
144
+ inlineStyles: {},
145
+ computedStyles: {},
146
+ source: "self",
147
+ },
148
+ ],
149
+ capabilities: {
150
+ canSelect: true,
151
+ canEditStyles: true,
152
+ canCrop: true,
153
+ canMove: true,
154
+ canResize: true,
155
+ canApplyManualOffset: true,
156
+ canApplyManualSize: true,
157
+ canApplyManualRotation: true,
158
+ },
159
+ } as DomEditSelection;
160
+ }
161
+
162
+ function makeSingleFieldElement(overrides: Partial<DomEditTextField> = {}): DomEditSelection {
163
+ const base = makeMultiFieldElement();
164
+ return {
165
+ ...base,
166
+ textFields: [
167
+ {
168
+ key: "a",
169
+ label: "Text",
170
+ value: "Headline",
171
+ tagName: "div",
172
+ attributes: [],
173
+ inlineStyles: {},
174
+ computedStyles: {},
175
+ source: "self",
176
+ ...overrides,
177
+ },
178
+ ],
179
+ } as DomEditSelection;
180
+ }
181
+
182
+ function segmentedRowButtons(host: HTMLElement, label: string): HTMLButtonElement[] {
183
+ const labelSpan = Array.from(host.querySelectorAll("span")).find(
184
+ (el) => el.textContent === label,
185
+ );
186
+ const row = labelSpan?.parentElement;
187
+ return Array.from(row?.querySelectorAll<HTMLButtonElement>('[data-flat-segment="true"]') ?? []);
188
+ }
189
+
190
+ describe("FlatTextFieldEditor controls", () => {
191
+ it("commits text-transform: capitalize when the new 'Ag' case button is clicked", () => {
192
+ const onSetTextFieldStyle = vi.fn();
193
+ const { host, root } = renderInto(
194
+ <FlatTextSection
195
+ element={makeSingleFieldElement()}
196
+ styles={{}}
197
+ fontAssets={[]}
198
+ onSetText={vi.fn()}
199
+ onSetTextFieldStyle={onSetTextFieldStyle}
200
+ onAddTextField={vi.fn()}
201
+ onRemoveTextField={vi.fn()}
202
+ />,
203
+ );
204
+ const capitalizeButton = segmentedRowButtons(host, "Case · Style").find(
205
+ (button) => button.textContent === "Ag",
206
+ );
207
+ expect(capitalizeButton).not.toBeUndefined();
208
+ act(() => capitalizeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
209
+ expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "text-transform", "capitalize");
210
+ act(() => root.unmount());
211
+ });
212
+
213
+ it("lights up 'right' for text-align: end but re-clicking it is a no-op — preserves the logical value", () => {
214
+ const onSetTextFieldStyle = vi.fn();
215
+ const { host, root } = renderInto(
216
+ <FlatTextSection
217
+ element={makeSingleFieldElement({ computedStyles: { "text-align": "end" } })}
218
+ styles={{}}
219
+ fontAssets={[]}
220
+ onSetText={vi.fn()}
221
+ onSetTextFieldStyle={onSetTextFieldStyle}
222
+ onAddTextField={vi.fn()}
223
+ onRemoveTextField={vi.fn()}
224
+ />,
225
+ );
226
+ const alignButtons = segmentedRowButtons(host, "Align");
227
+ const rightButton = alignButtons.find((button) => button.textContent === "R");
228
+ expect(rightButton).not.toBeUndefined();
229
+ expect(rightButton?.className).toContain("border-panel-accent");
230
+ // Clicking the option that's already visually active for "end" must NOT
231
+ // rewrite it to the physical "right" — that would destroy the logical
232
+ // semantics and break RTL content, where "end" and "right" differ.
233
+ act(() => rightButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
234
+ expect(onSetTextFieldStyle).not.toHaveBeenCalled();
235
+ act(() => root.unmount());
236
+ });
237
+
238
+ it("commits a genuine align change away from a logical value", () => {
239
+ const onSetTextFieldStyle = vi.fn();
240
+ const { host, root } = renderInto(
241
+ <FlatTextSection
242
+ element={makeSingleFieldElement({ computedStyles: { "text-align": "end" } })}
243
+ styles={{}}
244
+ fontAssets={[]}
245
+ onSetText={vi.fn()}
246
+ onSetTextFieldStyle={onSetTextFieldStyle}
247
+ onAddTextField={vi.fn()}
248
+ onRemoveTextField={vi.fn()}
249
+ />,
250
+ );
251
+ const alignButtons = segmentedRowButtons(host, "Align");
252
+ const centerButton = alignButtons.find((button) => button.textContent === "C");
253
+ expect(centerButton).not.toBeUndefined();
254
+ act(() => centerButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
255
+ expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "text-align", "center");
256
+ act(() => root.unmount());
257
+ });
258
+
259
+ it("live-commits the Size field on input, without requiring blur/Enter", async () => {
260
+ const onSetTextFieldStyle = vi.fn();
261
+ const { host, root } = renderInto(
262
+ <FlatTextSection
263
+ element={makeSingleFieldElement()}
264
+ styles={{}}
265
+ fontAssets={[]}
266
+ onSetText={vi.fn()}
267
+ onSetTextFieldStyle={onSetTextFieldStyle}
268
+ onAddTextField={vi.fn()}
269
+ onRemoveTextField={vi.fn()}
270
+ />,
271
+ );
272
+ const sizeLabel = Array.from(host.querySelectorAll("span")).find(
273
+ (el) => el.textContent === "Size",
274
+ );
275
+ const input = sizeLabel?.parentElement?.querySelector<HTMLInputElement>("input");
276
+ if (!input) throw new Error("expected the Size row's input");
277
+ act(() => {
278
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
279
+ window.HTMLInputElement.prototype,
280
+ "value",
281
+ )?.set;
282
+ nativeInputValueSetter?.call(input, "24px");
283
+ input.dispatchEvent(new Event("input", { bubbles: true }));
284
+ });
285
+ // liveCommit debounces on a 120ms timer — no blur/Enter dispatched here.
286
+ await act(async () => {
287
+ await new Promise((resolve) => setTimeout(resolve, 160));
288
+ });
289
+ expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px");
290
+ act(() => root.unmount());
291
+ });
292
+ });
293
+
294
+ describe("FlatTextSection — multi-field", () => {
295
+ it("shows the layer list, switches the active field's rows on selection, and has no doubled heading (this component never renders its own heading — the parent FlatGroup does)", () => {
296
+ const host = document.createElement("div");
297
+ document.body.append(host);
298
+ const root = createRoot(host);
299
+ act(() => {
300
+ root.render(
301
+ <FlatTextSection
302
+ element={makeMultiFieldElement()}
303
+ styles={{}}
304
+ fontAssets={[]}
305
+ onSetText={vi.fn()}
306
+ onSetTextFieldStyle={vi.fn()}
307
+ onAddTextField={vi.fn()}
308
+ onRemoveTextField={vi.fn()}
309
+ />,
310
+ );
311
+ });
312
+ expect(host.textContent).toContain("Headline");
313
+ expect(host.textContent).toContain("Subhead");
314
+ // Active field's editor rows are visible (Font/Weight/etc. from FlatTextFieldEditor).
315
+ expect(host.textContent).toContain("Weight");
316
+ // Exactly one "Text layers" micro-label — this component doesn't duplicate its own list.
317
+ const layerLabels = Array.from(host.querySelectorAll("div")).filter(
318
+ (el) => el.textContent === "Text layers",
319
+ );
320
+ expect(layerLabels.length).toBeLessThanOrEqual(1);
321
+
322
+ const rows = host.querySelectorAll('[data-flat-text-layer-row="true"]');
323
+ act(() => rows[1].dispatchEvent(new MouseEvent("click", { bubbles: true })));
324
+ expect(host.textContent).toContain("Subhead");
325
+ act(() => root.unmount());
326
+ });
327
+
328
+ it("wires onAdd/onRemove end-to-end: async onAddTextField switches the active field once it appears in props, and the resync effect falls back to the first field when the active one disappears", async () => {
329
+ let addResolved = false;
330
+
331
+ function Harness() {
332
+ const [fields, setFields] = useState<DomEditTextField[]>(makeMultiFieldElement().textFields);
333
+ const element: DomEditSelection = { ...makeMultiFieldElement(), textFields: fields };
334
+ return (
335
+ <FlatTextSection
336
+ element={element}
337
+ styles={{}}
338
+ fontAssets={[]}
339
+ onSetText={vi.fn()}
340
+ onSetTextFieldStyle={vi.fn()}
341
+ onAddTextField={() =>
342
+ Promise.resolve().then(() => {
343
+ addResolved = true;
344
+ setFields((prev) => [
345
+ ...prev,
346
+ {
347
+ key: "c",
348
+ label: "Text",
349
+ value: "Third",
350
+ tagName: "div",
351
+ attributes: [],
352
+ inlineStyles: {},
353
+ computedStyles: {},
354
+ source: "self",
355
+ },
356
+ ]);
357
+ return "c";
358
+ })
359
+ }
360
+ onRemoveTextField={(fieldKey: string) =>
361
+ setFields((prev) => prev.filter((field) => field.key !== fieldKey))
362
+ }
363
+ />
364
+ );
365
+ }
366
+
367
+ const host = document.createElement("div");
368
+ document.body.append(host);
369
+ const root = createRoot(host);
370
+ act(() => {
371
+ root.render(<Harness />);
372
+ });
373
+
374
+ let rows = host.querySelectorAll('[data-flat-text-layer-row="true"]');
375
+ expect(rows).toHaveLength(2);
376
+ expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true");
377
+
378
+ const addButton = host.querySelector<HTMLButtonElement>('[data-flat-text-layer-add="true"]');
379
+ await act(async () => {
380
+ addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
381
+ await Promise.resolve();
382
+ await Promise.resolve();
383
+ });
384
+ expect(addResolved).toBe(true);
385
+ rows = host.querySelectorAll('[data-flat-text-layer-row="true"]');
386
+ expect(rows).toHaveLength(3);
387
+ expect((rows[2] as HTMLElement).getAttribute("data-active")).toBe("true");
388
+
389
+ // Remove the active field ("c") through the wired onRemoveTextField — the
390
+ // resync useEffect must fall back to the first remaining field ("a")
391
+ // since "c" no longer exists in element.textFields.
392
+ const removeButtons = host.querySelectorAll<HTMLButtonElement>(
393
+ '[data-flat-text-layer-remove="true"]',
394
+ );
395
+ act(() => {
396
+ removeButtons[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
397
+ });
398
+ rows = host.querySelectorAll('[data-flat-text-layer-row="true"]');
399
+ expect(rows).toHaveLength(2);
400
+ expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true");
401
+
402
+ act(() => root.unmount());
403
+ });
404
+
405
+ it("auto-focuses the Content textarea when a new text field is added", async () => {
406
+ let addResolved = false;
407
+
408
+ function Harness() {
409
+ const [fields, setFields] = useState<DomEditTextField[]>(makeMultiFieldElement().textFields);
410
+ const element: DomEditSelection = { ...makeMultiFieldElement(), textFields: fields };
411
+ return (
412
+ <FlatTextSection
413
+ element={element}
414
+ styles={{}}
415
+ fontAssets={[]}
416
+ onSetText={vi.fn()}
417
+ onSetTextFieldStyle={vi.fn()}
418
+ onAddTextField={() =>
419
+ Promise.resolve().then(() => {
420
+ addResolved = true;
421
+ setFields((prev) => [
422
+ ...prev,
423
+ {
424
+ key: "c",
425
+ label: "Text",
426
+ value: "",
427
+ tagName: "div",
428
+ attributes: [],
429
+ inlineStyles: {},
430
+ computedStyles: {},
431
+ source: "self",
432
+ },
433
+ ]);
434
+ return "c";
435
+ })
436
+ }
437
+ onRemoveTextField={vi.fn()}
438
+ />
439
+ );
440
+ }
441
+
442
+ const host = document.createElement("div");
443
+ document.body.append(host);
444
+ const root = createRoot(host);
445
+ act(() => {
446
+ root.render(<Harness />);
447
+ });
448
+
449
+ const addButton = host.querySelector<HTMLButtonElement>('[data-flat-text-layer-add="true"]');
450
+ // Wait for onAddTextField's promise to resolve (adds field "c" and makes it
451
+ // active) before checking focus, mirroring the async add-field pattern above.
452
+ await act(async () => {
453
+ addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
454
+ await Promise.resolve();
455
+ await Promise.resolve();
456
+ });
457
+ expect(addResolved).toBe(true);
458
+
459
+ const contentTextarea = host.querySelector("textarea");
460
+ expect(contentTextarea).not.toBeNull();
461
+ expect(document.activeElement).toBe(contentTextarea);
462
+
463
+ act(() => root.unmount());
464
+ });
465
+ });