@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.
- package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-3XTTaVNf.js} +1 -1
- package/dist/assets/{index-C47jAC3Q.js → index-D6etaey-.js} +1 -1
- package/dist/assets/index-DXbu6IPT.css +1 -0
- package/dist/assets/{index-DeQPzqwH.js → index-Dh_WhagG.js} +1 -1
- package/dist/assets/index-cH6NfVV_.js +426 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.html +2 -2
- package/dist/index.js +13755 -8627
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.d.ts +5 -0
- package/dist/styles/tailwind-preset.js +8 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -4
- package/src/components/DesignPanelPromoteProvider.tsx +15 -2
- package/src/components/StudioRightPanel.tsx +42 -53
- package/src/components/editor/AnimationCard.test.tsx +134 -0
- package/src/components/editor/AnimationCard.tsx +19 -4
- package/src/components/editor/ArcPathControls.tsx +1 -0
- package/src/components/editor/GestureRecordControl.tsx +7 -1
- package/src/components/editor/GsapAnimationSection.tsx +109 -14
- package/src/components/editor/InspectorHeaderActions.tsx +42 -4
- package/src/components/editor/PropertyPanel.test.tsx +948 -0
- package/src/components/editor/PropertyPanel.tsx +144 -148
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
- package/src/components/editor/PropertyPanelFlat.tsx +580 -0
- package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
- package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
- package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
- package/src/components/editor/gsapLivePreview.ts +34 -0
- package/src/components/editor/manualEditingAvailability.test.ts +10 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
- package/src/components/editor/propertyPanelColor.test.tsx +28 -0
- package/src/components/editor/propertyPanelColor.tsx +31 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
- package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
- package/src/components/editor/propertyPanelFill.tsx +22 -5
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +107 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
- package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
- package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
- package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
- package/src/components/editor/propertyPanelFont.test.tsx +30 -0
- package/src/components/editor/propertyPanelFont.tsx +136 -95
- package/src/components/editor/propertyPanelHelpers.ts +73 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
- package/src/components/editor/propertyPanelSections.test.tsx +161 -0
- package/src/components/editor/propertyPanelSections.tsx +154 -90
- package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +14 -1
- package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
- package/src/components/editor/propertyPanelValueTier.ts +28 -0
- package/src/components/editor/useColorGradingController.test.ts +418 -0
- package/src/components/editor/useColorGradingController.ts +596 -0
- package/src/components/panels/VariablesPanel.tsx +4 -0
- package/src/components/sidebar/CompositionsTab.test.ts +28 -2
- package/src/components/sidebar/CompositionsTab.tsx +3 -1
- package/src/components/storyboard/FramePoster.tsx +24 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
- package/src/components/storyboard/StoryboardGrid.tsx +23 -1
- package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
- package/src/components/storyboard/StoryboardView.tsx +9 -2
- package/src/components/storyboard/frameComments.test.ts +95 -0
- package/src/components/storyboard/frameComments.ts +115 -0
- package/src/components/storyboard/useFrameComments.ts +82 -0
- package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
- package/src/contexts/DesignPanelInputContext.tsx +48 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/VariablePromoteContext.test.tsx +67 -0
- package/src/contexts/VariablePromoteContext.tsx +10 -5
- package/src/hooks/domEditCommitRunner.ts +11 -0
- package/src/hooks/gsapScriptCommitTypes.ts +6 -4
- package/src/hooks/timelineEditingHelpers.ts +2 -2
- package/src/hooks/timelineTrackVisibility.test.ts +52 -0
- package/src/hooks/timelineTrackVisibility.ts +27 -10
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomEditAttributeCommits.ts +123 -1
- package/src/hooks/useDomEditCommits.test.tsx +41 -1
- package/src/hooks/useDomEditCommits.ts +31 -177
- package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
- package/src/hooks/useDomEditSession.ts +18 -6
- package/src/hooks/useDomEditTextCommits.ts +14 -9
- package/src/hooks/useDomEditWiring.ts +10 -9
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +8 -3
- package/src/hooks/useFileManager.projectOwnership.test.tsx +109 -0
- package/src/hooks/useFileManager.ts +151 -65
- package/src/hooks/useGsapAnimationOps.ts +7 -6
- package/src/hooks/useGsapKeyframeOps.ts +6 -5
- package/src/hooks/useGsapPropertyDebounce.ts +43 -28
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
- package/src/hooks/useGsapScriptCommits.ts +150 -50
- package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
- package/src/hooks/useGsapSelectionHandlers.ts +94 -31
- package/src/hooks/useInspectorSplitResize.ts +51 -0
- package/src/hooks/usePanelLayout.test.ts +118 -0
- package/src/hooks/usePanelLayout.ts +24 -1
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
- package/src/hooks/usePersistentEditHistory.test.ts +49 -1
- package/src/hooks/usePersistentEditHistory.ts +71 -31
- package/src/hooks/usePreviewPersistence.ts +4 -1
- package/src/hooks/useProjectCompositionVariables.ts +10 -10
- package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
- package/src/hooks/useProjectSignaturePoll.ts +68 -0
- package/src/hooks/useRazorSplit.history.test.tsx +12 -4
- package/src/hooks/useRazorSplit.test.tsx +88 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
- package/src/hooks/useRazorSplit.ts +50 -16
- package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
- package/src/hooks/useSdkSession.ts +155 -14
- package/src/hooks/useSlideshowPersist.ts +7 -1
- package/src/hooks/useStoryboard.ts +23 -4
- package/src/hooks/useTimelineEditing.test.tsx +4 -1
- package/src/hooks/useTimelineEditing.ts +18 -10
- package/src/hooks/useTimelineEditingTypes.ts +5 -1
- package/src/hooks/useTimelineGroupEditing.ts +17 -3
- package/src/hooks/useVariablesPersist.ts +9 -7
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/styles/studio.css +22 -0
- package/src/styles/tailwind-preset.shared.js +7 -0
- package/src/utils/designInputTracking.test.ts +97 -0
- package/src/utils/designInputTracking.ts +80 -0
- package/src/utils/domEditSaveQueue.test.ts +19 -0
- package/src/utils/domEditSaveQueue.ts +2 -1
- package/src/utils/sdkCutover.gate.test.ts +7 -7
- package/src/utils/sdkCutover.test.ts +511 -62
- package/src/utils/sdkCutover.ts +181 -219
- package/src/utils/sdkEditTransaction.ts +293 -0
- package/src/utils/sdkResolverAttempts.ts +94 -0
- package/src/utils/sdkResolverShadow.test.ts +148 -0
- package/src/utils/sdkResolverShadow.ts +139 -106
- package/src/utils/setSlideshowManifest.test.ts +0 -9
- package/src/utils/setSlideshowManifest.ts +20 -27
- package/src/utils/studioFileHistory.test.ts +41 -0
- package/src/utils/studioFileHistory.ts +34 -29
- package/src/utils/studioFileMutationCoordinator.ts +45 -0
- package/src/utils/studioFileVersion.test.ts +36 -0
- package/src/utils/studioFileVersion.ts +23 -0
- package/src/utils/studioHelpers.test.ts +45 -0
- package/src/utils/studioHelpers.ts +45 -14
- package/src/utils/studioSaveDiagnostics.test.ts +18 -0
- package/src/utils/studioSaveDiagnostics.ts +38 -0
- package/dist/assets/index-B_UvTX3E.js +0 -423
- 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
|
+
});
|