@hyperframes/studio 0.7.58 → 0.7.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13514 -8528
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +29 -51
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  96. package/src/contexts/VariablePromoteContext.tsx +10 -5
  97. package/src/hooks/domEditCommitRunner.ts +11 -0
  98. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  99. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  100. package/src/hooks/timelineTrackVisibility.ts +27 -10
  101. package/src/hooks/useAppHotkeys.ts +9 -0
  102. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  103. package/src/hooks/useDomEditCommits.ts +29 -175
  104. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  105. package/src/hooks/useDomEditSession.ts +17 -5
  106. package/src/hooks/useDomEditTextCommits.ts +14 -9
  107. package/src/hooks/useDomEditWiring.ts +10 -9
  108. package/src/hooks/useElementLifecycleOps.ts +8 -3
  109. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  110. package/src/hooks/useFileManager.ts +82 -60
  111. package/src/hooks/useGsapAnimationOps.ts +7 -6
  112. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  113. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  114. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  115. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  116. package/src/hooks/useGsapScriptCommits.ts +150 -50
  117. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  118. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  119. package/src/hooks/useInspectorSplitResize.ts +51 -0
  120. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  121. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  122. package/src/hooks/usePersistentEditHistory.ts +71 -31
  123. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  124. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  125. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  126. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  127. package/src/hooks/useSdkSession.ts +155 -14
  128. package/src/hooks/useSlideshowPersist.ts +7 -1
  129. package/src/hooks/useStoryboard.ts +23 -4
  130. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  131. package/src/hooks/useTimelineEditing.ts +16 -10
  132. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  133. package/src/hooks/useTimelineGroupEditing.ts +16 -2
  134. package/src/hooks/useVariablesPersist.ts +9 -7
  135. package/src/icons/SystemIcons.tsx +2 -0
  136. package/src/styles/studio.css +22 -0
  137. package/src/styles/tailwind-preset.shared.js +7 -0
  138. package/src/utils/designInputTracking.test.ts +97 -0
  139. package/src/utils/designInputTracking.ts +80 -0
  140. package/src/utils/sdkCutover.gate.test.ts +7 -7
  141. package/src/utils/sdkCutover.test.ts +490 -57
  142. package/src/utils/sdkCutover.ts +181 -219
  143. package/src/utils/sdkEditTransaction.ts +292 -0
  144. package/src/utils/sdkResolverAttempts.ts +94 -0
  145. package/src/utils/sdkResolverShadow.test.ts +148 -0
  146. package/src/utils/sdkResolverShadow.ts +139 -106
  147. package/src/utils/setSlideshowManifest.test.ts +0 -9
  148. package/src/utils/setSlideshowManifest.ts +20 -27
  149. package/src/utils/studioFileHistory.test.ts +41 -0
  150. package/src/utils/studioFileHistory.ts +30 -27
  151. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  152. package/src/utils/studioHelpers.test.ts +45 -0
  153. package/src/utils/studioHelpers.ts +45 -14
  154. package/src/utils/studioSaveDiagnostics.ts +17 -0
  155. package/dist/assets/index-B_UvTX3E.js +0 -423
  156. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -0,0 +1,948 @@
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 type { PropertyPanelProps } from "./propertyPanelHelpers";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ // PropertyPanel calls useStudioShellContext() unconditionally; supply the one
11
+ // field it reads (showToast) so the component can mount without the full shell.
12
+ vi.mock("../../contexts/StudioContext", async () => {
13
+ const actual = await vi.importActual<typeof import("../../contexts/StudioContext")>(
14
+ "../../contexts/StudioContext",
15
+ );
16
+ return { ...actual, useStudioShellContext: () => ({ showToast: vi.fn() }) };
17
+ });
18
+
19
+ afterEach(() => {
20
+ document.body.innerHTML = "";
21
+ vi.doUnmock("./manualEditingAvailability");
22
+ vi.resetModules();
23
+ });
24
+
25
+ function baseElement() {
26
+ return {
27
+ element: document.createElement("div"),
28
+ id: "mono-label",
29
+ selector: ".mono-label",
30
+ label: "Mono Label",
31
+ tagName: "div",
32
+ sourceFile: "index.html",
33
+ compositionPath: "index.html",
34
+ isCompositionHost: false,
35
+ isInsideLockedComposition: false,
36
+ boundingBox: { x: 0, y: -24, width: 257, height: 29 },
37
+ textContent: "PACKETS / FRAME",
38
+ dataAttributes: {},
39
+ inlineStyles: {},
40
+ computedStyles: {},
41
+ textFields: [
42
+ {
43
+ key: "field-0",
44
+ label: "Text",
45
+ value: "PACKETS / FRAME",
46
+ tagName: "div",
47
+ attributes: [],
48
+ inlineStyles: {},
49
+ computedStyles: {},
50
+ source: "self",
51
+ },
52
+ ],
53
+ capabilities: {
54
+ canSelect: true,
55
+ canEditStyles: true,
56
+ canCrop: true,
57
+ canMove: true,
58
+ canResize: true,
59
+ canApplyManualOffset: true,
60
+ canApplyManualSize: true,
61
+ canApplyManualRotation: true,
62
+ },
63
+ };
64
+ }
65
+
66
+ // Bug 1 fixture: no text fields at all, so isTextEditableSelection(element) is
67
+ // false — the Text FlatGroup must not render (not even empty/collapsed).
68
+ function nonTextElement() {
69
+ return {
70
+ ...baseElement(),
71
+ id: "image-clip",
72
+ selector: "#image-clip",
73
+ label: "Image Clip",
74
+ tagName: "img",
75
+ textContent: "",
76
+ textFields: [],
77
+ };
78
+ }
79
+
80
+ // Bug 2 fixture: 2+ text fields, which routes FlatTextSection to its own
81
+ // flat multi-field layer list (FlatTextLayerList + FlatTextFieldEditor) —
82
+ // must not double-render the "Text" heading (FlatGroup's own heading; this
83
+ // component never renders one of its own).
84
+ function multiFieldTextElement() {
85
+ const base = baseElement();
86
+ return {
87
+ ...base,
88
+ textFields: [
89
+ base.textFields[0],
90
+ {
91
+ key: "field-1",
92
+ label: "Text",
93
+ value: "SECOND FIELD",
94
+ tagName: "div",
95
+ attributes: [],
96
+ inlineStyles: {},
97
+ computedStyles: {},
98
+ source: "self",
99
+ },
100
+ ],
101
+ };
102
+ }
103
+
104
+ // Style-only fixture: no text fields (Text group must not render), but
105
+ // canEditStyles stays true (inherited from baseElement()) so the Style group
106
+ // is gated in.
107
+ function styleOnlyElement() {
108
+ return {
109
+ ...baseElement(),
110
+ id: "stat-card",
111
+ selector: ".stat-card",
112
+ label: "Stat Card",
113
+ textFields: [],
114
+ inlineStyles: { "background-color": "#0D0C09" },
115
+ };
116
+ }
117
+
118
+ // Flex fixture (Plan 3a Task 5): display:flex drives BOTH the legacy
119
+ // StyleSections Flex `Section` AND the new flat Layout group's
120
+ // LayoutFlexBlock. Used to prove Flex renders exactly once on the flat path.
121
+ // styles are read from computedStyles (PropertyPanel line ~113), so set it
122
+ // there.
123
+ function flexElement() {
124
+ return {
125
+ ...baseElement(),
126
+ id: "flex-row",
127
+ selector: ".flex-row",
128
+ label: "Flex Row",
129
+ textFields: [],
130
+ computedStyles: { display: "flex" },
131
+ };
132
+ }
133
+
134
+ // Motion fixture (Plan 3b Task 4): an authored clip range (data-start present)
135
+ // makes resolveEditingSections turn on `sections.timing`, so the Motion group
136
+ // renders via its Timing gate even with no GSAP edit handlers wired.
137
+ function animatedElement() {
138
+ return {
139
+ ...baseElement(),
140
+ id: "anim-clip",
141
+ selector: ".anim-clip",
142
+ label: "Anim Clip",
143
+ dataAttributes: { start: "0", duration: "4" },
144
+ };
145
+ }
146
+
147
+ // Inferred-timing fixture (whole-plan coherence fix): NO explicit data-start
148
+ // or data-duration — sections.timing must turn on via animationCount (fed
149
+ // from gsapAnimations.length), not an authored attribute, so both the Motion
150
+ // Timing row and the Layout keyframe gutter are forced to infer the range
151
+ // from the element's own GSAP tween instead of reading it off an attribute.
152
+ function inferredMotionElement() {
153
+ return {
154
+ ...baseElement(),
155
+ id: "inferred-anim",
156
+ selector: "#inferred-anim",
157
+ label: "Inferred Anim",
158
+ };
159
+ }
160
+
161
+ // A single "to" tween running from t=2 to t=5 (position 2, duration 3), with
162
+ // keyframes on "x" at 0/50/100% — enough to drive both FlatTimingRow's
163
+ // inference and the Layout "x" row's keyframe-seek gutter.
164
+ // Six-group fixture (fixed-headers + scrollable-open-section worked example):
165
+ // tagName "img" turns on both Media and Grade (resolveEditingSections), on top
166
+ // of baseElement()'s text-editable + style-editable + timing-eligible
167
+ // (data-start) defaults — yielding all six groups in a known order:
168
+ // [text, style, layout, motion, grade, media].
169
+ function sixGroupElement() {
170
+ return {
171
+ ...baseElement(),
172
+ id: "six-group",
173
+ selector: "#six-group",
174
+ label: "Six Group",
175
+ tagName: "img",
176
+ dataAttributes: { start: "0", duration: "4" },
177
+ };
178
+ }
179
+
180
+ const INFERRED_TIMING_ANIMATION = {
181
+ id: "a1",
182
+ targetSelector: "#inferred-anim",
183
+ method: "to",
184
+ position: 2,
185
+ duration: 3,
186
+ properties: { x: 100 },
187
+ keyframes: {
188
+ format: "percentage",
189
+ keyframes: [
190
+ { percentage: 0, properties: { x: 0 } },
191
+ { percentage: 50, properties: { x: 50 } },
192
+ { percentage: 100, properties: { x: 100 } },
193
+ ],
194
+ },
195
+ } as never;
196
+
197
+ async function renderPanel(
198
+ flatEnabled: boolean,
199
+ elementOverride: ReturnType<typeof baseElement> = baseElement(),
200
+ propsOverride: Partial<PropertyPanelProps> = {},
201
+ currentTime?: number,
202
+ ) {
203
+ vi.resetModules();
204
+ vi.doMock("./manualEditingAvailability", async () => {
205
+ const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
206
+ "./manualEditingAvailability",
207
+ );
208
+ return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: flatEnabled };
209
+ });
210
+ // Seed the playhead on the SAME store instance PropertyPanel.tsx will read via
211
+ // usePlayerStore (module-fresh since the resetModules() above) — must happen
212
+ // before PropertyPanel is imported/rendered so its initial render sees it.
213
+ if (currentTime !== undefined) {
214
+ const { usePlayerStore } = await import("../../player/store/playerStore");
215
+ usePlayerStore.getState().setCurrentTime(currentTime);
216
+ }
217
+ const { PropertyPanel } = await import("./PropertyPanel");
218
+ const host = document.createElement("div");
219
+ document.body.append(host);
220
+ const root = createRoot(host);
221
+ // Only the props the render path touches are supplied; the rest are unused at
222
+ // mount (handlers fire on interaction), so cast a minimal object to the full
223
+ // props shape rather than stubbing all ~15 required fields.
224
+ const props = {
225
+ element: elementOverride,
226
+ assets: [],
227
+ onSetStyle: vi.fn(),
228
+ onSetText: vi.fn(),
229
+ onSetAttributeLive: vi.fn(),
230
+ ...propsOverride,
231
+ } as unknown as PropertyPanelProps;
232
+ act(() => {
233
+ root.render(<PropertyPanel {...props} />);
234
+ });
235
+ return { host, root };
236
+ }
237
+
238
+ // renderPanel resetModules()+dynamic-imports PropertyPanel (needed for a fresh
239
+ // flag read); transforming the full section graph uncached can exceed the 5s
240
+ // default under heavy parallel full-suite load, so give these a wider margin.
241
+ const RENDER_TIMEOUT_MS = 20_000;
242
+
243
+ // Find the collapsed accordion row whose title matches and click it open.
244
+ function openFlatGroup(host: HTMLElement, title: string) {
245
+ const row = Array.from(host.querySelectorAll('[data-flat-group-collapsed="true"]')).find((el) =>
246
+ el.textContent?.includes(title),
247
+ );
248
+ if (!row) throw new Error(`expected a collapsed ${title} row`);
249
+ act(() => row.dispatchEvent(new MouseEvent("click", { bubbles: true })));
250
+ }
251
+
252
+ const openGroupText = (host: HTMLElement) =>
253
+ host.querySelector('[data-flat-group-open="true"]')?.textContent ?? "";
254
+
255
+ describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED off", () => {
256
+ it(
257
+ "renders the legacy header, not the flat header",
258
+ async () => {
259
+ const { host, root } = await renderPanel(false);
260
+ expect(host.querySelector('[data-flat-header-icon="true"]')).toBeNull();
261
+ expect(host.textContent).toContain("Mono Label");
262
+ act(() => root.unmount());
263
+ },
264
+ RENDER_TIMEOUT_MS,
265
+ );
266
+ });
267
+
268
+ describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => {
269
+ it(
270
+ "renders the flat header, the Text group open by default, and the flat footer",
271
+ async () => {
272
+ const { host, root } = await renderPanel(true);
273
+ expect(host.querySelector('[data-flat-header-icon="true"]')).not.toBeNull();
274
+ expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
275
+ expect(host.textContent).toContain("Ask agent about this element");
276
+ act(() => root.unmount());
277
+ },
278
+ RENDER_TIMEOUT_MS,
279
+ );
280
+
281
+ it(
282
+ "collapses the Text group on caret click and can reopen it",
283
+ async () => {
284
+ const { host, root } = await renderPanel(true);
285
+ const collapseButton = host.querySelector<HTMLButtonElement>(
286
+ '[data-flat-group-open="true"] button[title="Collapse"]',
287
+ );
288
+ act(() => collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
289
+ expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
290
+ const collapsedRow = host.querySelector<HTMLButtonElement>(
291
+ '[data-flat-group-collapsed="true"]',
292
+ );
293
+ expect(collapsedRow).not.toBeNull();
294
+ act(() => collapsedRow?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
295
+ expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
296
+ act(() => root.unmount());
297
+ },
298
+ RENDER_TIMEOUT_MS,
299
+ );
300
+
301
+ it(
302
+ "renders no Text group at all for a non-text element (bug 1)",
303
+ async () => {
304
+ // nonTextElement() inherits canEditStyles: true from baseElement(), so
305
+ // the Style group (Task 10) renders and opens by default here — the
306
+ // invariant under test is narrower than "no flat group at all": no
307
+ // group titled "Text" may appear, open or collapsed.
308
+ const { host, root } = await renderPanel(true, nonTextElement());
309
+ const openTitle = host.querySelector(
310
+ '[data-flat-group-open="true"] .text-panel-text-0',
311
+ )?.textContent;
312
+ const collapsedTitles = Array.from(
313
+ host.querySelectorAll('[data-flat-group-collapsed="true"] .text-panel-text-2'),
314
+ ).map((el) => el.textContent);
315
+ expect(openTitle).not.toBe("Text");
316
+ expect(collapsedTitles).not.toContain("Text");
317
+ act(() => root.unmount());
318
+ },
319
+ RENDER_TIMEOUT_MS,
320
+ );
321
+
322
+ it(
323
+ "renders exactly one Text heading for a multi-field text element (bug 2)",
324
+ async () => {
325
+ const { host, root } = await renderPanel(true, multiFieldTextElement());
326
+ // The FlatGroup's own "Text" heading is the only one that should exist —
327
+ // the legacy TextSection's internal Section heading (data-panel-section
328
+ // ="text") must never appear, since the flat multi-field path no longer
329
+ // delegates to that component at all.
330
+ expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
331
+ expect(host.querySelector('[data-panel-section="text"]')).toBeNull();
332
+ // Content from the flat multi-field layer list must render.
333
+ expect(host.textContent).toContain("Text layers");
334
+ act(() => root.unmount());
335
+ },
336
+ RENDER_TIMEOUT_MS,
337
+ );
338
+ });
339
+
340
+ describe("PropertyPanel — Style group (flag on)", () => {
341
+ it(
342
+ "renders the Style group for a style-editable, non-text element",
343
+ async () => {
344
+ const { host, root } = await renderPanel(true, styleOnlyElement());
345
+ expect(host.textContent).toContain("Style");
346
+ expect(host.textContent).toContain("Fill");
347
+ act(() => root.unmount());
348
+ },
349
+ RENDER_TIMEOUT_MS,
350
+ );
351
+
352
+ it(
353
+ "one-open accordion: opening Style closes Text",
354
+ async () => {
355
+ // baseElement() is text-editable and has capabilities.canEditStyles:
356
+ // true, so both the Text and Style groups render for it.
357
+ const { host, root } = await renderPanel(true);
358
+ const textGroup = () => host.querySelector('[data-flat-group-open="true"]');
359
+ expect(textGroup()?.textContent).toContain("Text");
360
+ const styleCollapsedRow = Array.from(
361
+ host.querySelectorAll('[data-flat-group-collapsed="true"]'),
362
+ ).find((el) => el.textContent?.includes("Style"));
363
+ if (!styleCollapsedRow) throw new Error("expected a collapsed Style row");
364
+ act(() => styleCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
365
+ expect(textGroup()?.textContent).not.toContain("Text");
366
+ expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Style");
367
+ act(() => root.unmount());
368
+ },
369
+ RENDER_TIMEOUT_MS,
370
+ );
371
+ });
372
+
373
+ describe("PropertyPanel — Layout group (Plan 3a)", () => {
374
+ it(
375
+ "always renders the Layout group, and opening it closes whichever other group was open",
376
+ async () => {
377
+ const { host, root } = await renderPanel(true);
378
+ // Text group is open by default for the base text-editable fixture.
379
+ expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Text");
380
+
381
+ const layoutCollapsedRow = Array.from(
382
+ host.querySelectorAll('[data-flat-group-collapsed="true"]'),
383
+ ).find((el) => el.textContent?.includes("Layout"));
384
+ if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row");
385
+ act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
386
+
387
+ const openGroup = host.querySelector('[data-flat-group-open="true"]');
388
+ expect(openGroup?.textContent).toContain("Layout");
389
+ expect(openGroup?.textContent).toContain("X");
390
+ expect(openGroup?.textContent).not.toContain("Ask agent"); // sanity: not matching the footer
391
+ act(() => root.unmount());
392
+ },
393
+ RENDER_TIMEOUT_MS,
394
+ );
395
+
396
+ it(
397
+ "renders Flex exactly once on the flat path (flat Layout only, legacy suppressed)",
398
+ async () => {
399
+ const { host, root } = await renderPanel(true, flexElement());
400
+ const layoutCollapsedRow = Array.from(
401
+ host.querySelectorAll('[data-flat-group-collapsed="true"]'),
402
+ ).find((el) => el.textContent?.includes("Layout"));
403
+ if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row");
404
+ act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
405
+
406
+ // The legacy StyleSections Flex `Section` (data-panel-section="flex") must
407
+ // NOT render on the flat path — the only two Flex renderers are the legacy
408
+ // Section and the flat LayoutFlexBlock, so its absence + the flat block's
409
+ // presence proves Flex renders exactly once (not twice, not zero).
410
+ expect(host.querySelector('[data-panel-section="flex"]')).toBeNull();
411
+ const openGroup = host.querySelector('[data-flat-group-open="true"]');
412
+ expect(openGroup?.textContent).toContain("Layout");
413
+ expect(openGroup?.textContent).toContain("Flex");
414
+ act(() => root.unmount());
415
+ },
416
+ RENDER_TIMEOUT_MS,
417
+ );
418
+ });
419
+
420
+ describe("PropertyPanel — Motion group (Plan 3b)", () => {
421
+ it(
422
+ "renders the Motion group with Timing, and opening it closes the previously open group (4-way exclusivity)",
423
+ async () => {
424
+ const { host, root } = await renderPanel(true, animatedElement());
425
+ // Text is open by default for the text-editable fixture.
426
+ expect(openGroupText(host)).toContain("Text");
427
+
428
+ openFlatGroup(host, "Motion");
429
+ const openGroup = openGroupText(host);
430
+ expect(openGroup).toContain("Motion");
431
+ // FlatTimingRow (Start/End/Duration) renders inside the Motion group.
432
+ expect(openGroup).toContain("Start");
433
+ expect(openGroup).toContain("Duration");
434
+ // One-open accordion: opening Motion closed the Text group.
435
+ expect(openGroup).not.toContain("Text");
436
+
437
+ // Reverse direction: opening Layout closes Motion.
438
+ openFlatGroup(host, "Layout");
439
+ const openAfter = openGroupText(host);
440
+ expect(openAfter).toContain("Layout");
441
+ expect(openAfter).not.toContain("Motion");
442
+ act(() => root.unmount());
443
+ },
444
+ RENDER_TIMEOUT_MS,
445
+ );
446
+
447
+ it(
448
+ "hides the effect list (showEffects off) when the GSAP edit handlers are absent",
449
+ async () => {
450
+ // STUDIO_GSAP_PANEL_ENABLED defaults on, but none of the five required
451
+ // edit handlers are supplied here, so the effect-list half of the
452
+ // double-gate stays closed — only the Timing row shows.
453
+ const { host, root } = await renderPanel(true, animatedElement());
454
+ openFlatGroup(host, "Motion");
455
+ const openGroup = openGroupText(host);
456
+ expect(openGroup).toContain("Motion");
457
+ expect(openGroup).toContain("Duration"); // Timing still shows
458
+ expect(openGroup).not.toContain("Add effect"); // effects gated off
459
+ act(() => root.unmount());
460
+ },
461
+ RENDER_TIMEOUT_MS,
462
+ );
463
+
464
+ it(
465
+ "shows the effect list (showEffects on) when the flag and all five handlers are present",
466
+ async () => {
467
+ const { host, root } = await renderPanel(true, animatedElement(), {
468
+ onUpdateGsapProperty: vi.fn(),
469
+ onUpdateGsapMeta: vi.fn(),
470
+ onDeleteGsapAnimation: vi.fn(),
471
+ onAddGsapProperty: vi.fn(),
472
+ onAddGsapAnimation: vi.fn(),
473
+ });
474
+ openFlatGroup(host, "Motion");
475
+ expect(openGroupText(host)).toContain("Add effect");
476
+ act(() => root.unmount());
477
+ },
478
+ RENDER_TIMEOUT_MS,
479
+ );
480
+ });
481
+
482
+ // Whole-plan coherence fix: Layout's keyframe-seek basis and Motion's Timing
483
+ // row basis must agree on the same start/duration for an element that has
484
+ // animations but no explicit data-duration — before the fix, Layout fell back
485
+ // to a naive `duration ?? 1` while Motion correctly inferred the range from
486
+ // the tween (position 2, duration 3 -> start 2 / duration 3 / end 5).
487
+ describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan coherence fix)", () => {
488
+ it(
489
+ "Motion's Timing row shows the inferred start/end/duration for an element with animations but no explicit duration",
490
+ async () => {
491
+ const { host, root } = await renderPanel(true, inferredMotionElement(), {
492
+ gsapAnimations: [INFERRED_TIMING_ANIMATION],
493
+ });
494
+ openFlatGroup(host, "Motion");
495
+ const motionGroup = host.querySelector('[data-flat-group-open="true"]');
496
+ if (!motionGroup) throw new Error("expected the Motion group to be open");
497
+ expect(motionGroup.textContent).toContain("Inferred");
498
+ const inputs = motionGroup.querySelectorAll<HTMLInputElement>("input");
499
+ // FlatTimingRow renders Start, End, Duration in that order.
500
+ expect(inputs[0]?.value).toBe("2.00s");
501
+ expect(inputs[1]?.value).toBe("5.00s");
502
+ expect(inputs[2]?.value).toBe("3.00s");
503
+ act(() => root.unmount());
504
+ },
505
+ RENDER_TIMEOUT_MS,
506
+ );
507
+
508
+ it(
509
+ "Layout's X-row keyframe gutter seeks to the SAME absolute time Motion's Timing row shows as the midpoint (50% of an inferred 2s-5s range = 3.5s)",
510
+ async () => {
511
+ const onSeekToTime = vi.fn();
512
+ // Seed the playhead at the clip's real start (t=2, the 0% keyframe's
513
+ // absolute time) — now that the follow-up fix also recomputes
514
+ // `currentPct` from the corrected elStart/elDuration basis, "current
515
+ // position is at the 0% keyframe" must be expressed as an actual t=2
516
+ // seek rather than relying on the store's untouched t=0 default (which,
517
+ // post-fix, resolves to a currentPct of -66.7% — well outside the 0%
518
+ // keyframe's tolerance window, and no longer "the case the coherence
519
+ // bug affected" that this test documents).
520
+ const { host, root } = await renderPanel(
521
+ true,
522
+ inferredMotionElement(),
523
+ { gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
524
+ 2,
525
+ );
526
+ openFlatGroup(host, "Layout");
527
+ const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
528
+ if (!layoutGroup) throw new Error("expected the Layout group to be open");
529
+
530
+ const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
531
+ (el) => el.querySelector("span")?.textContent === "X",
532
+ );
533
+ if (!xRow) throw new Error("expected an X row");
534
+ const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
535
+ if (!gutter) throw new Error("expected a keyframe gutter on the X row");
536
+ // The diamond button always carries a `title`; the two plain arrow
537
+ // buttons don't. At currentPct=0 (playhead on the 0% keyframe), the prev
538
+ // arrow is disabled (no earlier keyframe) and the next arrow seeks to
539
+ // the 50% keyframe — exactly the case the coherence bug affected.
540
+ const nextArrow = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button")).find(
541
+ (b) => !b.title && !b.disabled,
542
+ );
543
+ if (!nextArrow) throw new Error("expected an enabled next-keyframe arrow button");
544
+ act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
545
+
546
+ // Same basis as the Timing row: start 2 + 50% * duration 3 = 3.5.
547
+ expect(onSeekToTime).toHaveBeenCalledWith(3.5);
548
+ act(() => root.unmount());
549
+ },
550
+ RENDER_TIMEOUT_MS,
551
+ );
552
+ });
553
+
554
+ // Follow-up fix (review of 684ec4e87): the seek-basis fix above corrected
555
+ // WHERE a keyframe click seeks to, but `currentPct` — the value that drives
556
+ // KeyframeNavigation's diamond active/inactive state and prev/next arrow
557
+ // targeting — still used the OLD naive basis. For an inferred-duration
558
+ // element, seeking to a keyframe's actual absolute time no longer lit that
559
+ // keyframe's diamond as active. Prove the round-trip here: seek to the exact
560
+ // absolute time of the 50% keyframe (2 + 0.5*3 = 3.5) and confirm its diamond
561
+ // renders "active" (title="Remove x keyframe"), not "inactive"/"ghost".
562
+ describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up fix)", () => {
563
+ it(
564
+ "lights the X-row keyframe diamond as active when the playhead is seeked to that keyframe's real absolute time (inferred 2s-5s range, 50% keyframe = 3.5s)",
565
+ async () => {
566
+ const { host, root } = await renderPanel(
567
+ true,
568
+ inferredMotionElement(),
569
+ { gsapAnimations: [INFERRED_TIMING_ANIMATION] },
570
+ 3.5,
571
+ );
572
+ openFlatGroup(host, "Layout");
573
+ const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
574
+ if (!layoutGroup) throw new Error("expected the Layout group to be open");
575
+
576
+ const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
577
+ (el) => el.querySelector("span")?.textContent === "X",
578
+ );
579
+ if (!xRow) throw new Error("expected an X row");
580
+ const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
581
+ if (!gutter) throw new Error("expected a keyframe gutter on the X row");
582
+ const diamond = gutter.querySelector<HTMLButtonElement>("button[title]");
583
+ if (!diamond) throw new Error("expected a keyframe diamond button");
584
+ // KeyframeDiamond's title mapping: active -> "Remove ... keyframe",
585
+ // inactive -> "Add ... keyframe", ghost -> "Convert ... to keyframes".
586
+ // Before this fix, currentPct was computed against the naive
587
+ // elStart=0/elDuration=1 basis, so t=3.5 produced currentPct=350% —
588
+ // nowhere near the 50% keyframe within KeyframeNavigation's tolerance —
589
+ // and the diamond stayed "inactive" even though the playhead was
590
+ // exactly on that keyframe's real time.
591
+ expect(diamond.title).toBe("Remove x keyframe");
592
+ act(() => root.unmount());
593
+ },
594
+ RENDER_TIMEOUT_MS,
595
+ );
596
+
597
+ it(
598
+ "prev/next arrows re-center on the current keyframe once currentPct agrees with the corrected seek basis",
599
+ async () => {
600
+ const onSeekToTime = vi.fn();
601
+ const { host, root } = await renderPanel(
602
+ true,
603
+ inferredMotionElement(),
604
+ { gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
605
+ 3.5,
606
+ );
607
+ openFlatGroup(host, "Layout");
608
+ const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
609
+ if (!layoutGroup) throw new Error("expected the Layout group to be open");
610
+
611
+ const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
612
+ (el) => el.querySelector("span")?.textContent === "X",
613
+ );
614
+ if (!xRow) throw new Error("expected an X row");
615
+ const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
616
+ if (!gutter) throw new Error("expected a keyframe gutter on the X row");
617
+ const buttons = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button"));
618
+ const [prevArrow, , nextArrow] = buttons;
619
+ if (!prevArrow || !nextArrow) throw new Error("expected prev/next arrow buttons");
620
+ // At the 50% keyframe (t=3.5), prev should target the 0% keyframe
621
+ // (absolute t=2) and next should target the 100% keyframe (absolute
622
+ // t=5) — both only resolvable once currentPct agrees with elStart=2/
623
+ // elDuration=3, the same basis the seek fix already uses.
624
+ expect(prevArrow.disabled).toBe(false);
625
+ act(() => prevArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
626
+ expect(onSeekToTime).toHaveBeenLastCalledWith(2);
627
+
628
+ expect(nextArrow.disabled).toBe(false);
629
+ act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
630
+ expect(onSeekToTime).toHaveBeenLastCalledWith(5);
631
+ act(() => root.unmount());
632
+ },
633
+ RENDER_TIMEOUT_MS,
634
+ );
635
+ });
636
+
637
+ // Media fixtures (Plan 4 Task 7): the three tag values resolveEditingSections
638
+ // turns `media` on for (video/audio/img). Each carries no text fields (so the
639
+ // Text group never renders) and sets `element` to a real media node so the
640
+ // FlatMediaSection reads a live media element. `as never` casts around the
641
+ // element-type mismatch with baseElement()'s HTMLDivElement.
642
+ function videoElement() {
643
+ return {
644
+ ...baseElement(),
645
+ id: "s1-bg",
646
+ selector: "#s1-bg",
647
+ label: "S1 Background",
648
+ tagName: "video",
649
+ textFields: [],
650
+ element: document.createElement("video"),
651
+ };
652
+ }
653
+
654
+ function imageElement() {
655
+ return {
656
+ ...baseElement(),
657
+ id: "s1-img",
658
+ selector: "#s1-img",
659
+ label: "S1 Image",
660
+ tagName: "img",
661
+ textFields: [],
662
+ element: document.createElement("img"),
663
+ };
664
+ }
665
+
666
+ function audioElement() {
667
+ return {
668
+ ...baseElement(),
669
+ id: "s1-audio",
670
+ selector: "#s1-audio",
671
+ label: "S1 Audio",
672
+ tagName: "audio",
673
+ textFields: [],
674
+ element: document.createElement("audio"),
675
+ };
676
+ }
677
+
678
+ // All FlatGroup titles currently mounted (open row + every collapsed row).
679
+ function flatGroupTitles(host: HTMLElement): string[] {
680
+ const open = Array.from(
681
+ host.querySelectorAll('[data-flat-group-open="true"] .text-panel-text-0'),
682
+ ).map((el) => el.textContent ?? "");
683
+ const collapsed = Array.from(
684
+ host.querySelectorAll('[data-flat-group-collapsed="true"] .text-panel-text-2'),
685
+ ).map((el) => el.textContent ?? "");
686
+ return [...open, ...collapsed];
687
+ }
688
+
689
+ describe("PropertyPanel — Grade group (flag on)", () => {
690
+ it(
691
+ "renders the Grade group with its accessory for a grade-editable (video) element",
692
+ async () => {
693
+ const { host, root } = await renderPanel(true, {
694
+ ...baseElement(),
695
+ tagName: "video",
696
+ textFields: [],
697
+ });
698
+ const gradeCollapsedOrOpen =
699
+ host.querySelector('[data-flat-group-collapsed="true"]') ||
700
+ host.querySelector('[data-flat-group-open="true"]');
701
+ expect(host.textContent).toContain("Grade");
702
+ expect(gradeCollapsedOrOpen).not.toBeNull();
703
+ act(() => root.unmount());
704
+ },
705
+ RENDER_TIMEOUT_MS,
706
+ );
707
+
708
+ it(
709
+ "does not render the legacy Style/Grade Section duplicates for a style-and-grade-editable element",
710
+ async () => {
711
+ // A <video> with no text fields is both style-editable (inherited
712
+ // capabilities.canEditStyles: true) and grade-editable (tag === "video"),
713
+ // so both the flat Style and Grade groups render — the exact shape that
714
+ // used to also mount the legacy ColorGradingSection + StyleSections below
715
+ // them (the hybrid-duplication bug this task retires).
716
+ const { host, root } = await renderPanel(true, {
717
+ ...baseElement(),
718
+ tagName: "video",
719
+ textFields: [],
720
+ });
721
+ expect(host.textContent).toContain("Style");
722
+ expect(host.textContent).toContain("Grade");
723
+ // The legacy `Section` primitive (propertyPanelStyleSections.tsx /
724
+ // propertyPanelColorGradingSection.tsx) renders a `<section
725
+ // data-panel-section="<slugified-title>">` for each of its sections. A
726
+ // bare textContent check can't tell a legacy Section title apart from a
727
+ // flat row label with the same word — "Fill" is both the legacy Fill
728
+ // `Section` title AND a row label inside FlatStyleSection, which is
729
+ // supposed to still be there — so assert on the legacy Section's actual
730
+ // DOM shape instead of a substring match.
731
+ for (const slug of [
732
+ "radius",
733
+ "stroke",
734
+ "effects",
735
+ "clip",
736
+ "transparency",
737
+ "fill",
738
+ "color-grading",
739
+ ]) {
740
+ expect(host.querySelector(`[data-panel-section="${slug}"]`)).toBeNull();
741
+ }
742
+ act(() => root.unmount());
743
+ },
744
+ RENDER_TIMEOUT_MS,
745
+ );
746
+ });
747
+
748
+ describe("PropertyPanel — Media group (Plan 4)", () => {
749
+ it(
750
+ "renders the flat Media group and not the legacy MediaSection, for a video element",
751
+ async () => {
752
+ const { host, root } = await renderPanel(true, videoElement() as never);
753
+ // A Media FlatGroup exists (open or collapsed).
754
+ expect(flatGroupTitles(host)).toContain("Media");
755
+ // The legacy MediaSection renders its rows inside a `Section` whose
756
+ // data-panel-section slug is the media title ("video"/"image"/"audio").
757
+ // On the flat path it's fully replaced, so none of those may appear.
758
+ expect(host.querySelector('[data-panel-section="video"]')).toBeNull();
759
+ expect(host.querySelector('[data-panel-section="image"]')).toBeNull();
760
+ expect(host.querySelector('[data-panel-section="audio"]')).toBeNull();
761
+ act(() => root.unmount());
762
+ },
763
+ RENDER_TIMEOUT_MS,
764
+ );
765
+
766
+ it(
767
+ "one-open accordion: opening Media closes whichever other group was open, and vice versa (5-way exclusivity)",
768
+ async () => {
769
+ // videoElement() has canEditStyles: true and no text fields, so Style is
770
+ // the default-open group; Layout and Media render collapsed alongside it.
771
+ const { host, root } = await renderPanel(true, videoElement() as never);
772
+ expect(openGroupText(host)).toContain("Style");
773
+
774
+ // Opening Media closes Style.
775
+ openFlatGroup(host, "Media");
776
+ const afterMedia = openGroupText(host);
777
+ expect(afterMedia).toContain("Media");
778
+ expect(afterMedia).not.toContain("Style");
779
+
780
+ // Reverse direction: opening Layout closes Media — same shared openGroupId.
781
+ openFlatGroup(host, "Layout");
782
+ const afterLayout = openGroupText(host);
783
+ expect(afterLayout).toContain("Layout");
784
+ expect(afterLayout).not.toContain("Media");
785
+ act(() => root.unmount());
786
+ },
787
+ RENDER_TIMEOUT_MS,
788
+ );
789
+
790
+ it(
791
+ "gates the Media group exactly like the legacy MediaSection: present for video/img/audio, absent for a plain div/text element",
792
+ async () => {
793
+ for (const fixture of [videoElement, imageElement, audioElement]) {
794
+ const { host, root } = await renderPanel(true, fixture() as never);
795
+ expect(flatGroupTitles(host)).toContain("Media");
796
+ act(() => root.unmount());
797
+ }
798
+
799
+ // baseElement() is a plain <div> with text — sections.media is false, so
800
+ // no Media group (flat or legacy) may render.
801
+ const { host, root } = await renderPanel(true);
802
+ expect(flatGroupTitles(host)).not.toContain("Media");
803
+ expect(host.querySelector('[data-panel-section="video"]')).toBeNull();
804
+ expect(host.querySelector('[data-panel-section="image"]')).toBeNull();
805
+ expect(host.querySelector('[data-panel-section="audio"]')).toBeNull();
806
+ act(() => root.unmount());
807
+ },
808
+ RENDER_TIMEOUT_MS,
809
+ );
810
+ });
811
+
812
+ // design_handoff scrollable-open-section: collapsed headers before/after the
813
+ // open group render in normal document flow and never move (no sticky, no
814
+ // stacking offsets) — only the open group's own body content scrolls, in a
815
+ // dedicated region between the two fixed header stacks. Worked example: 6
816
+ // groups [text, style, layout, motion, grade, media], motion open (index 3)
817
+ // -> text/style/layout render as fixed collapsed headers before it, motion
818
+ // renders as an open header + scrollable body, grade/media render as fixed
819
+ // collapsed headers after it — in exactly that DOM order, nothing sticky.
820
+ describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)", () => {
821
+ it(
822
+ "renders before-open headers, the open group (header + scrollable body), then after-open headers, in that exact order, with nothing sticky",
823
+ async () => {
824
+ const { host, root } = await renderPanel(true, sixGroupElement());
825
+ // sixGroupElement() opens Text by default; open Motion (index 3) to
826
+ // match the worked example.
827
+ openFlatGroup(host, "Motion");
828
+ expect(openGroupText(host)).toContain("Motion");
829
+
830
+ const body = host.querySelector('[data-flat-panel-body="true"]');
831
+ if (!body) throw new Error("expected the flat panel body container");
832
+
833
+ // Titles in DOM order: each child is either a collapsed header button
834
+ // (before/after the open group) or the open-group wrapper div.
835
+ const titles = Array.from(body.children).map((child) => {
836
+ if (child.matches('[data-flat-group-collapsed="true"]')) return child.textContent ?? "";
837
+ if (child.matches('[data-flat-group-open="true"]')) return child.textContent ?? "";
838
+ return null;
839
+ });
840
+ // Filter to just the group entries (drop any non-group nulls).
841
+ const groupTitles = titles.filter((t): t is string => t !== null);
842
+ expect(groupTitles).toHaveLength(6);
843
+ expect(groupTitles[0]).toContain("Text");
844
+ expect(groupTitles[1]).toContain("Style");
845
+ expect(groupTitles[2]).toContain("Layout");
846
+ expect(groupTitles[3]).toContain("Motion");
847
+ expect(groupTitles[4]).toContain("Grade");
848
+ expect(groupTitles[5]).toContain("Media");
849
+
850
+ // The open group (Motion, index 3) is the one wrapped in
851
+ // data-flat-group-open, sitting between the before/after collapsed
852
+ // headers — and it must contain a dedicated scrollable body.
853
+ const openWrapper = host.querySelector('[data-flat-group-open="true"]');
854
+ if (!openWrapper) throw new Error("expected the open-group wrapper");
855
+ expect(openWrapper.textContent).toContain("Motion");
856
+ expect(openWrapper.querySelector(".overflow-y-auto")).not.toBeNull();
857
+
858
+ // Nothing anywhere in the panel body carries inline sticky positioning
859
+ // — the entire sticky-stacking mechanism is gone.
860
+ const stickyEls = Array.from(body.querySelectorAll<HTMLElement>("[style]")).filter(
861
+ (el) => el.style.position === "sticky",
862
+ );
863
+ expect(stickyEls).toHaveLength(0);
864
+ act(() => root.unmount());
865
+ },
866
+ RENDER_TIMEOUT_MS,
867
+ );
868
+
869
+ it(
870
+ "renders every group as a plain collapsed header with no scrollable middle region when nothing is open",
871
+ async () => {
872
+ const { host, root } = await renderPanel(true, sixGroupElement());
873
+ // Collapse the default-open Text group so openGroupId becomes "".
874
+ const collapseButton = host.querySelector<HTMLButtonElement>(
875
+ '[data-flat-group-open="true"] button[title="Collapse"]',
876
+ );
877
+ act(() => collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
878
+ expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
879
+
880
+ const collapsedRows = Array.from(
881
+ host.querySelectorAll<HTMLButtonElement>('[data-flat-group-collapsed="true"]'),
882
+ );
883
+ expect(collapsedRows).toHaveLength(6);
884
+ const titlesInOrder = collapsedRows.map((el) => el.textContent ?? "");
885
+ expect(titlesInOrder[0]).toContain("Text");
886
+ expect(titlesInOrder[1]).toContain("Style");
887
+ expect(titlesInOrder[2]).toContain("Layout");
888
+ expect(titlesInOrder[3]).toContain("Motion");
889
+ expect(titlesInOrder[4]).toContain("Grade");
890
+ expect(titlesInOrder[5]).toContain("Media");
891
+
892
+ const body = host.querySelector('[data-flat-panel-body="true"]');
893
+ expect(body?.querySelector(".overflow-y-auto")).toBeNull();
894
+ for (const row of collapsedRows) {
895
+ expect(row.style.position).toBe("");
896
+ }
897
+ act(() => root.unmount());
898
+ },
899
+ RENDER_TIMEOUT_MS,
900
+ );
901
+ });
902
+
903
+ describe("PropertyPanel — flat group entrance animation scoping (fix round)", () => {
904
+ it(
905
+ "animates only the opening group and the implicitly-closed group on a non-adjacent toggle, never untouched siblings",
906
+ async () => {
907
+ const { host, root } = await renderPanel(true, sixGroupElement());
908
+ // sixGroupElement() opens Text by default; jump straight to Motion
909
+ // (skipping over Style/Layout) first, matching the Plan 11 worked
910
+ // example, then jump back to Text — non-adjacent from Motion, again
911
+ // skipping over Style/Layout. This is the exact array-slice-position-
912
+ // shift scenario the justToggledIds mechanism exists to guard: Style
913
+ // and Layout shift position in the before/after-open slices on both
914
+ // toggles even though neither of them is the group being toggled.
915
+ openFlatGroup(host, "Motion");
916
+ expect(openGroupText(host)).toContain("Motion");
917
+ openFlatGroup(host, "Text");
918
+ expect(openGroupText(host)).toContain("Text");
919
+
920
+ const collapsedRowByTitle = (title: string) => {
921
+ const row = Array.from(host.querySelectorAll('[data-flat-group-collapsed="true"]')).find(
922
+ (el) => el.textContent?.includes(title),
923
+ );
924
+ if (!row) throw new Error(`expected a collapsed ${title} row`);
925
+ return row;
926
+ };
927
+
928
+ // Untouched, non-adjacent siblings must NOT receive the entrance class,
929
+ // even though they shifted position in the collapsed-header list.
930
+ expect(collapsedRowByTitle("Style").classList.contains("hf-flat-group-enter")).toBe(false);
931
+ expect(collapsedRowByTitle("Layout").classList.contains("hf-flat-group-enter")).toBe(false);
932
+ expect(collapsedRowByTitle("Grade").classList.contains("hf-flat-group-enter")).toBe(false);
933
+ expect(collapsedRowByTitle("Media").classList.contains("hf-flat-group-enter")).toBe(false);
934
+
935
+ // Motion — open a moment ago, just implicitly closed by the click on
936
+ // Text — must still play its own collapse-entrance animation (Finding 1).
937
+ expect(collapsedRowByTitle("Motion").classList.contains("hf-flat-group-enter")).toBe(true);
938
+
939
+ // Text — the group actually clicked open — must animate too.
940
+ const openWrapper = host.querySelector('[data-flat-group-open="true"]');
941
+ if (!openWrapper) throw new Error("expected the open-group wrapper");
942
+ expect(openWrapper.querySelector(".hf-flat-group-enter")).not.toBeNull();
943
+
944
+ act(() => root.unmount());
945
+ },
946
+ RENDER_TIMEOUT_MS,
947
+ );
948
+ });