@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,580 @@
1
+ import { type ReactNode, useEffect, useRef, useState } from "react";
2
+ import { resolveEditingSections } from "@hyperframes/core/editing";
3
+ import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
4
+ import { slugifyDesignInput } from "../../utils/designInputTracking";
5
+ import type { DomEditSelection } from "./domEditing";
6
+ import { isTextEditableSelection } from "./domEditing";
7
+ import type { PropertyPanelProps } from "./propertyPanelHelpers";
8
+ import { formatPxMetricValue } from "./propertyPanelHelpers";
9
+ import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
10
+ import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
11
+ import { FlatGroupHeader } from "./propertyPanelFlatPrimitives";
12
+ import { FlatTextSection } from "./propertyPanelFlatTextSection";
13
+ import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
14
+ import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
15
+ import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
16
+ import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
17
+ import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
18
+ import { createGsapLivePreview } from "./gsapLivePreview";
19
+ import { formatTextFieldPreview } from "./propertyPanelSections";
20
+ import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
21
+ import { useColorGradingController } from "./useColorGradingController";
22
+ import {
23
+ FlatColorGradingAccessory,
24
+ FlatColorGradingSection,
25
+ } from "./propertyPanelFlatColorGradingSection";
26
+
27
+ type EditingSections = ReturnType<typeof resolveEditingSections>;
28
+
29
+ type FlatGroupDescriptor = {
30
+ id: string;
31
+ title: string;
32
+ summary?: string;
33
+ accessory?: ReactNode;
34
+ content: ReactNode;
35
+ };
36
+
37
+ // Type-only fallback for the Motion effect-card callbacks. Used solely to
38
+ // satisfy FlatMotionSection's required-callback shape when the effect list is
39
+ // gated off (showEffects === false, so none of these are ever invoked). Keeps
40
+ // the gated-off path free of `!` non-null assertions — the real, narrowed
41
+ // handlers flow through only when the double-gate below passes.
42
+ const EMPTY_GSAP_EFFECT_HANDLERS = {
43
+ onAddAnimation: () => {},
44
+ onUpdateProperty: () => {},
45
+ onUpdateMeta: () => {},
46
+ onDeleteAnimation: () => {},
47
+ onAddProperty: () => {},
48
+ onRemoveProperty: () => {},
49
+ };
50
+
51
+ /**
52
+ * The flat "Ledger" inspector shell (design_handoff_studio_inspector).
53
+ *
54
+ * Extracted from PropertyPanel so that file stays under the 600-LOC gate
55
+ * (same one-directional-import precedent as FlatTextSection). Rendered only
56
+ * when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open group state.
57
+ *
58
+ * The Text/Style/Layout/Motion/Media/Grade groups share the one-open accordion.
59
+ */
60
+ // fallow-ignore-next-line complexity
61
+ export function PropertyPanelFlat({
62
+ element,
63
+ styles,
64
+ sections,
65
+ sourceLabel,
66
+ gsapAnimations = [],
67
+ gsapBorderRadius,
68
+ fontAssets = [],
69
+ showEditableSections,
70
+ selectedElementHidden,
71
+ selectedElementId,
72
+ clipboardCopied,
73
+ onCopyElementInfo,
74
+ projectId,
75
+ projectDir,
76
+ assets,
77
+ previewIframeRef,
78
+ onClearSelection,
79
+ onUngroup,
80
+ onSetStyle,
81
+ onSetAttribute,
82
+ onSetAttributes,
83
+ onSetAttributeLive,
84
+ onApplyColorGradingScope,
85
+ onSetHtmlAttribute,
86
+ onRemoveBackground,
87
+ onSetText,
88
+ onSetTextFieldStyle,
89
+ onAddTextField,
90
+ onRemoveTextField,
91
+ onAskAgent,
92
+ onToggleElementHidden,
93
+ onImportAssets,
94
+ onImportFonts,
95
+ recordingState,
96
+ recordingDuration,
97
+ onToggleRecording,
98
+ displayX,
99
+ displayY,
100
+ displayW,
101
+ displayH,
102
+ displayR,
103
+ manualOffsetEditingDisabled,
104
+ manualSizeEditingDisabled,
105
+ manualRotationEditingDisabled,
106
+ commitManualOffset,
107
+ commitManualSize,
108
+ commitManualRotation,
109
+ gsapAnimId,
110
+ navKeyframes,
111
+ currentTime,
112
+ animIdForProp,
113
+ gsapRuntimeValues,
114
+ // Renamed: PropertyPanel.tsx still computes/passes these for its own legacy
115
+ // (non-flat) panel, but the flat path recomputes its own basis below via
116
+ // deriveElementTiming so it agrees with Motion's Timing row — ignore the
117
+ // parent's naive `elDuration ?? 1` fallback.
118
+ elStart: _elStart,
119
+ elDuration: _elDuration,
120
+ onCommitAnimatedProperty,
121
+ onCommitAnimatedProperties,
122
+ onSeekToTime,
123
+ onRemoveKeyframe,
124
+ onConvertToKeyframes,
125
+ gsapMultipleTimelines,
126
+ gsapUnsupportedTimelinePattern,
127
+ onUpdateGsapProperty,
128
+ onUpdateGsapMeta,
129
+ onDeleteGsapAnimation,
130
+ onAddGsapProperty,
131
+ onRemoveGsapProperty,
132
+ onUpdateGsapFromProperty,
133
+ onAddGsapFromProperty,
134
+ onRemoveGsapFromProperty,
135
+ onAddGsapAnimation,
136
+ onSetArcPath,
137
+ onUpdateArcSegment,
138
+ onUnroll,
139
+ onUpdateKeyframeEase,
140
+ onSetAllKeyframeEases,
141
+ }: Pick<
142
+ PropertyPanelProps,
143
+ | "projectId"
144
+ | "projectDir"
145
+ | "assets"
146
+ | "previewIframeRef"
147
+ | "onClearSelection"
148
+ | "onUngroup"
149
+ | "onSetStyle"
150
+ | "onSetAttribute"
151
+ | "onSetAttributes"
152
+ | "onSetAttributeLive"
153
+ | "onApplyColorGradingScope"
154
+ | "onSetHtmlAttribute"
155
+ | "onRemoveBackground"
156
+ | "onSetText"
157
+ | "onSetTextFieldStyle"
158
+ | "onAddTextField"
159
+ | "onRemoveTextField"
160
+ | "onAskAgent"
161
+ | "onToggleElementHidden"
162
+ | "onImportAssets"
163
+ | "onImportFonts"
164
+ | "fontAssets"
165
+ | "gsapAnimations"
166
+ | "gsapMultipleTimelines"
167
+ | "gsapUnsupportedTimelinePattern"
168
+ | "onUpdateGsapProperty"
169
+ | "onUpdateGsapMeta"
170
+ | "onDeleteGsapAnimation"
171
+ | "onAddGsapProperty"
172
+ | "onRemoveGsapProperty"
173
+ | "onUpdateGsapFromProperty"
174
+ | "onAddGsapFromProperty"
175
+ | "onRemoveGsapFromProperty"
176
+ | "onAddGsapAnimation"
177
+ | "onSetArcPath"
178
+ | "onUpdateArcSegment"
179
+ | "onUnroll"
180
+ | "onUpdateKeyframeEase"
181
+ | "onSetAllKeyframeEases"
182
+ | "recordingState"
183
+ | "recordingDuration"
184
+ | "onToggleRecording"
185
+ > &
186
+ // Layout-group values (Plan 3a Task 5). All are derived locals or handlers in
187
+ // PropertyPanel; compose their exact shapes from FlatLayoutSection's own props
188
+ // via Pick so a signature change there propagates here instead of drifting.
189
+ Pick<
190
+ Parameters<typeof FlatLayoutSection>[0],
191
+ | "displayX"
192
+ | "displayY"
193
+ | "displayW"
194
+ | "displayH"
195
+ | "displayR"
196
+ | "manualOffsetEditingDisabled"
197
+ | "manualSizeEditingDisabled"
198
+ | "manualRotationEditingDisabled"
199
+ | "commitManualOffset"
200
+ | "commitManualSize"
201
+ | "commitManualRotation"
202
+ | "gsapAnimId"
203
+ | "navKeyframes"
204
+ | "animIdForProp"
205
+ | "gsapRuntimeValues"
206
+ | "elStart"
207
+ | "elDuration"
208
+ | "onCommitAnimatedProperty"
209
+ | "onCommitAnimatedProperties"
210
+ | "onSeekToTime"
211
+ | "onRemoveKeyframe"
212
+ | "onConvertToKeyframes"
213
+ > & {
214
+ element: DomEditSelection;
215
+ styles: Record<string, string>;
216
+ sections: EditingSections;
217
+ sourceLabel: string;
218
+ gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null;
219
+ showEditableSections: boolean;
220
+ selectedElementHidden: boolean;
221
+ selectedElementId: string | null;
222
+ clipboardCopied: boolean;
223
+ onCopyElementInfo: () => void;
224
+ currentTime: number;
225
+ }) {
226
+ // Lazy initializer: pick whichever group actually renders for this element
227
+ // (Text if text-editable, else Style if style-editable, else none open) so a
228
+ // style-only element doesn't start with everything collapsed. Only runs on
229
+ // mount — PropertyPanel.tsx keys <PropertyPanelFlat> by element identity so
230
+ // switching the selection re-mounts this component and re-derives the
231
+ // default instead of preserving stale state across unrelated elements.
232
+ const [openGroupId, setOpenGroupId] = useState<string>(() =>
233
+ isTextEditableSelection(element)
234
+ ? "text"
235
+ : showEditableSections
236
+ ? "style"
237
+ : sections.media
238
+ ? "media"
239
+ : "layout",
240
+ );
241
+
242
+ // Tracks which group(s) are actively transitioning this toggle cycle, so
243
+ // their header/body gets the fast entrance animation (hf-flat-group-enter)
244
+ // and no one else's does. Deliberately NOT derived from remounting alone:
245
+ // FlatGroupHeader instances are keyed by group id and React normally
246
+ // preserves them across re-renders, but toggling a non-adjacent group still
247
+ // shifts the untouched collapsed siblings between the before/after-open
248
+ // slices below, and Chromium restarts a CSS animation on that kind of
249
+ // position shift even though nothing about the sibling actually changed.
250
+ // Gating on these ids (cleared shortly after the 120ms CSS animation
251
+ // finishes) keeps the animation scoped to only the groups that actually
252
+ // just toggled. Two ids, not one: the clicked (newly-opening/closing) group
253
+ // AND whichever group was open immediately before the click and got
254
+ // implicitly closed by it — both freshly-mounted headers need to animate.
255
+ const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
256
+ const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
257
+ useEffect(() => {
258
+ return () => {
259
+ if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current);
260
+ };
261
+ }, []);
262
+
263
+ // Grade group state. Called unconditionally (React rules-of-hooks) even when
264
+ // sections.colorGrading is false — unlike the legacy ColorGradingSection,
265
+ // which is only mounted when the section is active, PropertyPanelFlat is not
266
+ // remounted per-section so the hook must run every render. Shares one state
267
+ // object between the group's header accessory (compare/status/reset) and its
268
+ // body (the FlatColorGradingSection controls).
269
+ const colorGradingController = useColorGradingController({
270
+ projectId,
271
+ element,
272
+ previewIframeRef,
273
+ onSetAttributeLive,
274
+ onApplyScope: onApplyColorGradingScope,
275
+ });
276
+
277
+ const isTextEditable = isTextEditableSelection(element);
278
+ const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
279
+ const toggleOpen = (groupId: string) => {
280
+ // Capture what was open BEFORE this click (this render's closure over
281
+ // openGroupId), so the group that's about to be implicitly closed can be
282
+ // tracked too — not just the one the user clicked.
283
+ const previousOpenGroupId = openGroupId;
284
+ setOpenGroupId((current) => (current === groupId ? "" : groupId));
285
+ const implicitlyClosedId =
286
+ previousOpenGroupId && previousOpenGroupId !== groupId ? previousOpenGroupId : null;
287
+ setJustToggledIds(implicitlyClosedId ? [groupId, implicitlyClosedId] : [groupId]);
288
+ if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current);
289
+ justToggledTimeoutRef.current = setTimeout(() => setJustToggledIds([]), 200);
290
+ };
291
+ // Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) —
292
+ // must agree with Motion's Timing row (FlatTimingRow), which infers the
293
+ // range from animations when there's no explicit data-duration. Computed
294
+ // here (not threaded from PropertyPanel) both to keep that file under its
295
+ // 600-LOC gate and because element/gsapAnimations are already in scope.
296
+ const { start: elStart, duration: elDuration } = deriveElementTiming(element, gsapAnimations);
297
+ // Trivial percentage→time seek, derived here rather than threaded from
298
+ // PropertyPanel (keeps that file under its 600-LOC gate).
299
+ const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
300
+ // Playhead position within the SAME corrected elStart/elDuration basis as
301
+ // seekFromKfPct above — recomputed here (not threaded as `currentPct` from
302
+ // PropertyPanel, which still derives it against its own naive basis for the
303
+ // legacy panel) so KeyframeNavigation's diamond active-state and prev/next
304
+ // arrow targeting agree with where a keyframe click actually seeks to
305
+ // (follow-up fix to 684ec4e87, which corrected the seek basis but left this
306
+ // one still naive).
307
+ const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0;
308
+
309
+ // Motion group double-gate — reproduces the legacy PropertyPanel gate exactly:
310
+ // • Timing (sections.timing) shows via resolveEditingSections, same as today.
311
+ // • The effect-card list shows only when STUDIO_GSAP_PANEL_ENABLED is on AND
312
+ // all five edit handlers are present (identical to PropertyPanel's legacy
313
+ // `<GsapAnimationSection>` guard).
314
+ // Computing the narrowed handler bundle inside the `&&`-guarded ternary lets
315
+ // TypeScript prove each handler non-undefined without a `!` assertion; the
316
+ // noop bundle only fills the type when the gate is off (never invoked, since
317
+ // FlatMotionSection guards every call behind showEffects).
318
+ const showMotionTiming = Boolean(sections.timing);
319
+ const gsapEffectHandlers =
320
+ STUDIO_GSAP_PANEL_ENABLED &&
321
+ onUpdateGsapProperty &&
322
+ onUpdateGsapMeta &&
323
+ onDeleteGsapAnimation &&
324
+ onAddGsapProperty &&
325
+ onAddGsapAnimation
326
+ ? {
327
+ onAddAnimation: onAddGsapAnimation,
328
+ onUpdateProperty: onUpdateGsapProperty,
329
+ onUpdateMeta: onUpdateGsapMeta,
330
+ onDeleteAnimation: onDeleteGsapAnimation,
331
+ onAddProperty: onAddGsapProperty,
332
+ onRemoveProperty: onRemoveGsapProperty ?? (() => {}),
333
+ onUpdateFromProperty: onUpdateGsapFromProperty,
334
+ onAddFromProperty: onAddGsapFromProperty,
335
+ onRemoveFromProperty: onRemoveGsapFromProperty,
336
+ onSetArcPath,
337
+ onUpdateArcSegment,
338
+ onUnroll,
339
+ onUpdateKeyframeEase,
340
+ onSetAllKeyframeEases,
341
+ }
342
+ : null;
343
+ const showMotionEffects = gsapEffectHandlers !== null;
344
+ const showMotionGroup = showMotionTiming || showMotionEffects;
345
+
346
+ // Ordered group descriptors — one per FlatGroup this panel renders, gated by
347
+ // the same conditions the inline JSX used. Split below into before-open/
348
+ // open/after-open regions for the one-open accordion.
349
+ const groups: FlatGroupDescriptor[] = [];
350
+ if (isTextEditable) {
351
+ groups.push({
352
+ id: "text",
353
+ title: "Text",
354
+ summary: formatTextFieldPreview(element.textFields[0]?.value ?? ""),
355
+ content: (
356
+ <FlatTextSection
357
+ element={element}
358
+ styles={styles}
359
+ fontAssets={fontAssets}
360
+ onImportFonts={onImportFonts}
361
+ onSetText={onSetText}
362
+ onSetTextFieldStyle={onSetTextFieldStyle}
363
+ onAddTextField={onAddTextField}
364
+ onRemoveTextField={onRemoveTextField}
365
+ />
366
+ ),
367
+ });
368
+ }
369
+ if (showEditableSections) {
370
+ // Number.isFinite guard (not `|| 1`): opacity 0 is a real value — an
371
+ // invisible element must summarize as 0%, not 100%.
372
+ const opacityValue = parseFloat(styles.opacity ?? "1");
373
+ const opacityPct = Math.round((Number.isFinite(opacityValue) ? opacityValue : 1) * 100);
374
+ groups.push({
375
+ id: "style",
376
+ title: "Style",
377
+ summary: `fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${opacityPct}%`,
378
+ content: (
379
+ <FlatStyleSection
380
+ projectId={projectId}
381
+ element={element}
382
+ styles={styles}
383
+ assets={assets}
384
+ onSetStyle={onSetStyle}
385
+ onImportAssets={onImportAssets}
386
+ gsapBorderRadius={gsapBorderRadius}
387
+ />
388
+ ),
389
+ });
390
+ }
391
+ groups.push({
392
+ id: "layout",
393
+ title: "Layout",
394
+ // No scrub accessory: FlatRow/CommitField has no pointer-drag scrubbing
395
+ // (wheel/arrow keys only) — advertising "drag values to scrub" here lies.
396
+ summary: `${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`,
397
+ content: (
398
+ <FlatLayoutSection
399
+ element={element}
400
+ styles={styles}
401
+ onSetStyle={onSetStyle}
402
+ disabled={!element.capabilities.canEditStyles}
403
+ displayX={displayX}
404
+ displayY={displayY}
405
+ displayW={displayW}
406
+ displayH={displayH}
407
+ displayR={displayR}
408
+ manualOffsetEditingDisabled={manualOffsetEditingDisabled}
409
+ manualSizeEditingDisabled={manualSizeEditingDisabled}
410
+ manualRotationEditingDisabled={manualRotationEditingDisabled}
411
+ commitManualOffset={commitManualOffset}
412
+ commitManualSize={commitManualSize}
413
+ commitManualRotation={commitManualRotation}
414
+ gsapAnimId={gsapAnimId}
415
+ navKeyframes={navKeyframes}
416
+ currentPct={currentPct}
417
+ seekFromKfPct={seekFromKfPct}
418
+ animIdForProp={animIdForProp}
419
+ resolveAnimIdForProp={animIdForProp}
420
+ gsapRuntimeValues={gsapRuntimeValues}
421
+ gsapKeyframes={navKeyframes}
422
+ elStart={elStart}
423
+ elDuration={elDuration}
424
+ onCommitAnimatedProperty={onCommitAnimatedProperty}
425
+ onCommitAnimatedProperties={onCommitAnimatedProperties}
426
+ onSeekToTime={onSeekToTime}
427
+ onRemoveKeyframe={onRemoveKeyframe}
428
+ onConvertToKeyframes={onConvertToKeyframes}
429
+ onLivePreviewProps={createGsapLivePreview(previewIframeRef ?? { current: null })}
430
+ />
431
+ ),
432
+ });
433
+ if (showMotionGroup) {
434
+ groups.push({
435
+ id: "motion",
436
+ title: "Motion",
437
+ summary: `${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`,
438
+ content: (
439
+ <FlatMotionSection
440
+ element={element}
441
+ animations={gsapAnimations}
442
+ showTiming={showMotionTiming}
443
+ showEffects={showMotionEffects}
444
+ multipleTimelines={gsapMultipleTimelines}
445
+ unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
446
+ onSetAttribute={onSetAttribute}
447
+ onSetAttributes={onSetAttributes}
448
+ {...(gsapEffectHandlers ?? EMPTY_GSAP_EFFECT_HANDLERS)}
449
+ />
450
+ ),
451
+ });
452
+ }
453
+ if (sections.colorGrading) {
454
+ groups.push({
455
+ id: "grade",
456
+ title: "Grade",
457
+ accessory: <FlatColorGradingAccessory state={colorGradingController} />,
458
+ summary: `${colorGradingController.grading.preset ?? "neutral"} · ${Math.round(colorGradingController.grading.intensity * 100)}%`,
459
+ content: (
460
+ <FlatColorGradingSection
461
+ grading={colorGradingController.grading}
462
+ assets={assets}
463
+ onImportAssets={onImportAssets}
464
+ onCommitColorGrading={colorGradingController.commitColorGrading}
465
+ applyScope={colorGradingController.applyScope}
466
+ applyBusy={colorGradingController.applyBusy}
467
+ onSetApplyScope={colorGradingController.setApplyScope}
468
+ onApplyToScope={() => void colorGradingController.applyToScope()}
469
+ onApplyScopeAvailable={Boolean(onApplyColorGradingScope)}
470
+ mediaMetadata={colorGradingController.mediaMetadata}
471
+ />
472
+ ),
473
+ });
474
+ }
475
+ if (sections.media) {
476
+ groups.push({
477
+ id: "media",
478
+ title: "Media",
479
+ summary: element.tagName,
480
+ content: (
481
+ <FlatMediaSection
482
+ projectDir={projectDir}
483
+ element={element}
484
+ styles={styles}
485
+ onSetStyle={onSetStyle}
486
+ onSetAttribute={onSetAttribute}
487
+ onSetHtmlAttribute={onSetHtmlAttribute}
488
+ onRemoveBackground={onRemoveBackground}
489
+ />
490
+ ),
491
+ });
492
+ }
493
+
494
+ // Fixed-headers + scrollable-open-section layout (design_handoff
495
+ // scrollable-open-section, replaces the prior sticky-stacking mechanism):
496
+ // collapsed headers before/after the open group render in normal document
497
+ // flow and never move. Only the open group's own body content scrolls, in
498
+ // a dedicated region between the two fixed header stacks. When no group is
499
+ // open, every group is just a collapsed header — there's no scrollable
500
+ // middle region at all, since nothing is expanded.
501
+ const openIndex = groups.findIndex((g) => g.id === openGroupId);
502
+ const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex);
503
+ const openGroup = openIndex === -1 ? null : groups[openIndex];
504
+ const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1);
505
+
506
+ return (
507
+ <DesignPanelInputProvider ui="flat">
508
+ <div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
509
+ <DesignPanelInputProvider section="header">
510
+ <PropertyPanelFlatHeader
511
+ name={element.label}
512
+ meta={`${sourceLabel} · ${element.tagName}`}
513
+ elementKind={elementKind}
514
+ hidden={selectedElementHidden}
515
+ onToggleHidden={
516
+ selectedElementId && onToggleElementHidden
517
+ ? () => void onToggleElementHidden(selectedElementId, !selectedElementHidden)
518
+ : undefined
519
+ }
520
+ copied={clipboardCopied}
521
+ onCopy={onCopyElementInfo}
522
+ onClear={onClearSelection}
523
+ onUngroup={onUngroup}
524
+ showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
525
+ />
526
+ </DesignPanelInputProvider>
527
+ <div data-flat-panel-body="true" className="flex min-h-0 flex-1 flex-col overflow-y-auto">
528
+ {beforeOpen.map((g) => (
529
+ <DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
530
+ <FlatGroupHeader
531
+ title={g.title}
532
+ isOpen={false}
533
+ onToggleOpen={() => toggleOpen(g.id)}
534
+ summary={g.summary}
535
+ animateEntrance={justToggledIds.includes(g.id)}
536
+ />
537
+ </DesignPanelInputProvider>
538
+ ))}
539
+ {openGroup && (
540
+ <DesignPanelInputProvider section={slugifyDesignInput(openGroup.title)}>
541
+ <div data-flat-group-open="true" className="flex min-h-0 flex-1 flex-col">
542
+ <FlatGroupHeader
543
+ title={openGroup.title}
544
+ isOpen
545
+ onToggleOpen={() => toggleOpen(openGroup.id)}
546
+ accessory={openGroup.accessory}
547
+ animateEntrance={justToggledIds.includes(openGroup.id)}
548
+ />
549
+ <div
550
+ className={`${justToggledIds.includes(openGroup.id) ? "hf-flat-group-enter " : ""}min-h-0 flex-1 overflow-y-auto border-b border-panel-hairline bg-panel-bg-inset px-4 py-3 shadow-[inset_0_2px_4px_-1px_rgba(0,0,0,0.5)]`}
551
+ >
552
+ {openGroup.content}
553
+ </div>
554
+ </div>
555
+ </DesignPanelInputProvider>
556
+ )}
557
+ {afterOpen.map((g) => (
558
+ <DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
559
+ <FlatGroupHeader
560
+ title={g.title}
561
+ isOpen={false}
562
+ onToggleOpen={() => toggleOpen(g.id)}
563
+ summary={g.summary}
564
+ animateEntrance={justToggledIds.includes(g.id)}
565
+ />
566
+ </DesignPanelInputProvider>
567
+ ))}
568
+ </div>
569
+ <DesignPanelInputProvider section="footer">
570
+ <PropertyPanelFlatFooter
571
+ onAskAgent={onAskAgent}
572
+ recordingState={recordingState}
573
+ recordingDuration={recordingDuration}
574
+ onToggleRecording={onToggleRecording}
575
+ />
576
+ </DesignPanelInputProvider>
577
+ </div>
578
+ </DesignPanelInputProvider>
579
+ );
580
+ }
@@ -0,0 +1,83 @@
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 { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ });
13
+
14
+ function renderFooter(overrides: Partial<Parameters<typeof PropertyPanelFlatFooter>[0]> = {}) {
15
+ const host = document.createElement("div");
16
+ document.body.append(host);
17
+ const root = createRoot(host);
18
+ act(() => {
19
+ root.render(<PropertyPanelFlatFooter {...overrides} />);
20
+ });
21
+ return { host, root };
22
+ }
23
+
24
+ describe("PropertyPanelFlatFooter", () => {
25
+ it("renders the ask-agent affordance and fires onAskAgent on click", () => {
26
+ const onAskAgent = vi.fn();
27
+ const { host, root } = renderFooter({ onAskAgent });
28
+ expect(host.textContent).toContain("Ask agent about this element");
29
+ const askButton = host.querySelector<HTMLButtonElement>('[data-flat-footer-ask="true"]');
30
+ act(() => askButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
31
+ expect(onAskAgent).toHaveBeenCalledTimes(1);
32
+ act(() => root.unmount());
33
+ });
34
+
35
+ it("shows the idle record affordance and toggles recording on click", () => {
36
+ const onToggleRecording = vi.fn();
37
+ const { host, root } = renderFooter({ recordingState: "idle", onToggleRecording });
38
+ const recordButton = host.querySelector<HTMLButtonElement>('[data-flat-footer-record="true"]');
39
+ expect(recordButton?.title).toBe("Record gesture (R)");
40
+ act(() => recordButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
41
+ expect(onToggleRecording).toHaveBeenCalledTimes(1);
42
+ act(() => root.unmount());
43
+ });
44
+
45
+ it("shows the recording duration while recording", () => {
46
+ const { host, root } = renderFooter({
47
+ recordingState: "recording",
48
+ recordingDuration: 2.4,
49
+ onToggleRecording: vi.fn(),
50
+ });
51
+ const recordButton = host.querySelector<HTMLButtonElement>('[data-flat-footer-record="true"]');
52
+ expect(recordButton?.title).toBe("Stop recording 2.4s");
53
+ act(() => root.unmount());
54
+ });
55
+
56
+ // Plan 10 (sticky-footer-gap): the root must carry an opaque background —
57
+ // it previously had none at all, letting scrolled panel content show
58
+ // through. Regression coverage for the definite fix from the brief.
59
+ it("has an opaque bg-panel-bg background on its root element", () => {
60
+ const { host, root } = renderFooter({ onAskAgent: vi.fn() });
61
+ const footerRoot = host.firstElementChild as HTMLElement;
62
+ expect(footerRoot.className).toContain("bg-panel-bg");
63
+ act(() => root.unmount());
64
+ });
65
+
66
+ // Plan 11 (scrollable-open-section): the prior sticky-stacking mechanism —
67
+ // and the Plan 10 hairline-sealing hack it required at this exact boundary
68
+ // (an absolutely-positioned overlay patching a Chromium sticky-offset
69
+ // rounding gap) — is gone now that nothing above the footer is
70
+ // `position: sticky`. Live browser verification (p11 report) confirmed the
71
+ // boundary renders as a single clean hairline without it: whatever
72
+ // immediately precedes the footer (a collapsed FlatGroupHeader, or the open
73
+ // group's scrollable body wrapper) already draws its own border-b in normal
74
+ // document flow, so the footer needs no border or seal of its own.
75
+ it("renders no seal overlay and no border of its own — the boundary line comes from whatever precedes it", () => {
76
+ const { host, root } = renderFooter({ onAskAgent: vi.fn() });
77
+ const footerRoot = host.firstElementChild as HTMLElement;
78
+ expect(footerRoot.className).not.toContain("border-t");
79
+ expect(footerRoot.className).not.toContain("border-b");
80
+ expect(host.querySelector('[data-flat-footer-seal="true"]')).toBeNull();
81
+ act(() => root.unmount());
82
+ });
83
+ });