@hyperframes/studio 0.7.71 → 0.7.73

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 (79) hide show
  1. package/dist/assets/{hyperframes-player-C1X90psg.js → hyperframes-player-DGlzhf_s.js} +1 -1
  2. package/dist/assets/index-B37rWXo4.css +1 -0
  3. package/dist/assets/{index-DzxDHPR1.js → index-CbVTOJ-E.js} +1 -1
  4. package/dist/assets/{index-nY4lLBqM.js → index-CcA5WVJv.js} +1 -1
  5. package/dist/assets/index-DXcLI2wu.js +428 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7762 -4261
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +4 -4
  12. package/src/components/StudioLeftSidebar.tsx +2 -3
  13. package/src/components/StudioRightPanel.tsx +10 -4
  14. package/src/components/editor/AnimationCard.test.tsx +100 -0
  15. package/src/components/editor/AnimationCard.tsx +5 -2
  16. package/src/components/editor/EaseCurveSection.test.tsx +348 -0
  17. package/src/components/editor/EaseCurveSection.tsx +444 -237
  18. package/src/components/editor/EaseParamFields.test.tsx +188 -0
  19. package/src/components/editor/EaseParamFields.tsx +246 -0
  20. package/src/components/editor/PropertyPanel.test.tsx +9 -7
  21. package/src/components/editor/PropertyPanelFlat.tsx +75 -88
  22. package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
  23. package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
  24. package/src/components/editor/colorGradingScopePatch.test.ts +3 -3
  25. package/src/components/editor/easeCurveSvg.tsx +65 -0
  26. package/src/components/editor/easePresetLibrary.test.ts +114 -0
  27. package/src/components/editor/easePresetLibrary.ts +49 -0
  28. package/src/components/editor/gsapAnimationConstants.ts +15 -13
  29. package/src/components/editor/propertyPanelColorCurveGraph.tsx +549 -0
  30. package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
  31. package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
  32. package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
  33. package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
  34. package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
  35. package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
  36. package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
  37. package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
  38. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
  39. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +239 -41
  40. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +260 -300
  41. package/src/components/editor/propertyPanelFlatEffectControl.tsx +96 -0
  42. package/src/components/editor/propertyPanelFlatEffectSpecs.ts +305 -0
  43. package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +331 -0
  44. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +503 -0
  45. package/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx +113 -0
  46. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +108 -0
  47. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -0
  48. package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
  49. package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
  50. package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
  51. package/src/components/editor/propertyPanelTypes.ts +13 -0
  52. package/src/components/editor/useColorGradingController.test.ts +331 -10
  53. package/src/components/editor/useColorGradingController.ts +57 -90
  54. package/src/components/editor/useColorGradingPreviews.ts +350 -0
  55. package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
  56. package/src/components/editor/useColorGradingScopes.ts +62 -0
  57. package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
  58. package/src/components/nle/NLEContext.tsx +1 -1
  59. package/src/contexts/PanelLayoutContext.tsx +3 -6
  60. package/src/hooks/useBlockCatalog.ts +31 -13
  61. package/src/hooks/useBlockHandlers.ts +22 -0
  62. package/src/hooks/useGsapScriptCommits.test.tsx +15 -0
  63. package/src/hooks/useGsapScriptCommits.ts +14 -1
  64. package/src/hooks/usePanelLayout.test.ts +68 -1
  65. package/src/hooks/usePanelLayout.ts +72 -34
  66. package/src/icons/SystemIcons.tsx +4 -0
  67. package/src/player/components/PlayerControls.tsx +68 -229
  68. package/src/player/components/ShortcutsPanel.tsx +2 -4
  69. package/src/player/components/SpeedMenu.tsx +1 -2
  70. package/src/telemetry/events.test.ts +9 -0
  71. package/src/telemetry/events.ts +5 -0
  72. package/src/utils/blockInstaller.test.ts +67 -0
  73. package/src/utils/blockInstaller.ts +162 -109
  74. package/src/utils/studioUiPreferences.test.ts +5 -0
  75. package/src/utils/studioUiPreferences.ts +8 -0
  76. package/dist/assets/index-CSCh2Vrp.js +0 -428
  77. package/dist/assets/index-Ceyz8Qt2.css +0 -1
  78. package/src/player/components/PlayerControls.test.ts +0 -20
  79. package/src/player/components/useSeekBarDrag.ts +0 -169
@@ -0,0 +1,188 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { parseWiggleEase } from "@hyperframes/core/wiggle-ease";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { EaseBezierField, SpringBounceField, WiggleField } from "./EaseParamFields";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const roots: Root[] = [];
12
+
13
+ afterEach(() => {
14
+ act(() => roots.splice(0).forEach((root) => root.unmount()));
15
+ document.body.innerHTML = "";
16
+ });
17
+
18
+ function renderField(field: React.ReactNode): HTMLElement {
19
+ const host = document.createElement("div");
20
+ document.body.append(host);
21
+ const root = createRoot(host);
22
+ roots.push(root);
23
+ act(() => root.render(field));
24
+ return host;
25
+ }
26
+
27
+ function parsedWiggle(ease: string) {
28
+ const config = parseWiggleEase(ease);
29
+ expect(config).not.toBeNull();
30
+ if (!config) throw new Error(`Expected a valid wiggle ease: ${ease}`);
31
+ return config;
32
+ }
33
+
34
+ function expectWiggleCommit(onCommit: ReturnType<typeof vi.fn>, ease: string): void {
35
+ expect(onCommit).toHaveBeenLastCalledWith(ease);
36
+ const emitted = onCommit.mock.lastCall?.[0];
37
+ expect(typeof emitted === "string" ? parseWiggleEase(emitted) : null).not.toBeNull();
38
+ }
39
+
40
+ function inputValue(input: HTMLInputElement, value: string): void {
41
+ act(() => {
42
+ const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
43
+ valueSetter?.call(input, value);
44
+ input.dispatchEvent(new Event("input", { bubbles: true }));
45
+ });
46
+ }
47
+
48
+ function commitInputValue(input: HTMLInputElement, value: string): void {
49
+ act(() => input.focus());
50
+ inputValue(input, value);
51
+ act(() => input.blur());
52
+ }
53
+
54
+ // Render a default wiggle field, type `value` into the input labelled `label`,
55
+ // and return the commit spy — the shared body of the input-edit cases.
56
+ function editWiggle(label: string, value: string): ReturnType<typeof vi.fn> {
57
+ const onCommit = vi.fn();
58
+ const host = renderField(
59
+ <WiggleField config={parsedWiggle("wiggle(6,easeOut,0.26)")} onCommit={onCommit} />,
60
+ );
61
+ const input = host.querySelector<HTMLInputElement>(`[aria-label="${label}"]`);
62
+ expect(input).not.toBeNull();
63
+ if (input) commitInputValue(input, value);
64
+ return onCommit;
65
+ }
66
+
67
+ describe("WiggleField", () => {
68
+ it("reflects a parsed wiggle config", () => {
69
+ const host = renderField(
70
+ <WiggleField config={parsedWiggle("wiggle(6,easeOut,0.26)")} onCommit={vi.fn()} />,
71
+ );
72
+
73
+ expect(host.querySelector<HTMLInputElement>('[aria-label="Wiggle count"]')?.value).toBe("6");
74
+ expect(host.querySelector<HTMLSelectElement>('[aria-label="Wiggle type"]')?.value).toBe(
75
+ "easeOut",
76
+ );
77
+ expect(host.querySelector<HTMLInputElement>('[aria-label="Wiggle amplitude"]')?.value).toBe(
78
+ "0.26",
79
+ );
80
+ });
81
+
82
+ it("commits an edited count", () => {
83
+ expectWiggleCommit(editWiggle("Wiggle count", "4"), "wiggle(4,easeOut,0.26)");
84
+ });
85
+
86
+ it("commits an edited type", () => {
87
+ const onCommit = vi.fn();
88
+ const host = renderField(
89
+ <WiggleField config={parsedWiggle("wiggle(6,easeOut,0.26)")} onCommit={onCommit} />,
90
+ );
91
+
92
+ const select = host.querySelector<HTMLSelectElement>('[aria-label="Wiggle type"]');
93
+ expect(select).not.toBeNull();
94
+ act(() => {
95
+ if (!select) return;
96
+ select.value = "anticipate";
97
+ select.dispatchEvent(new Event("change", { bubbles: true }));
98
+ });
99
+
100
+ expectWiggleCommit(onCommit, "wiggle(6,anticipate,0.26)");
101
+ });
102
+
103
+ it("commits an edited amplitude", () => {
104
+ expectWiggleCommit(editWiggle("Wiggle amplitude", "0.1"), "wiggle(6,easeOut,0.1)");
105
+ });
106
+
107
+ it("seeds and explicitly commits the per-type default amplitude", () => {
108
+ const onCommit = vi.fn();
109
+ const host = renderField(
110
+ <WiggleField config={parsedWiggle("wiggle(3,easeInOut)")} onCommit={onCommit} />,
111
+ );
112
+
113
+ expect(host.querySelector<HTMLInputElement>('[aria-label="Wiggle amplitude"]')?.value).toBe(
114
+ "0.08",
115
+ );
116
+ const count = host.querySelector<HTMLInputElement>('[aria-label="Wiggle count"]');
117
+ expect(count).not.toBeNull();
118
+ if (count) commitInputValue(count, "4");
119
+
120
+ expectWiggleCommit(onCommit, "wiggle(4,easeInOut,0.08)");
121
+ });
122
+
123
+ it("ignores an empty amplitude", () => {
124
+ expect(editWiggle("Wiggle amplitude", "")).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it("rejects a count below one", () => {
128
+ expect(editWiggle("Wiggle count", "0")).not.toHaveBeenCalled();
129
+ });
130
+ });
131
+
132
+ describe("moved ease parameter fields", () => {
133
+ it("keeps the spring bounce commit behavior", () => {
134
+ const onCommit = vi.fn();
135
+ const host = renderField(<SpringBounceField springBounce={0.5} onCommit={onCommit} />);
136
+ const input = host.querySelector<HTMLInputElement>('[aria-label="Spring bounce"]');
137
+ expect(input).not.toBeNull();
138
+ if (input) commitInputValue(input, "0.333");
139
+
140
+ expect(onCommit).toHaveBeenLastCalledWith("spring(0.33)");
141
+ });
142
+
143
+ it("commits a numeric draft exactly once when Enter blurs the field", () => {
144
+ const onCommit = vi.fn();
145
+ const host = renderField(<SpringBounceField springBounce={0.5} onCommit={onCommit} />);
146
+ const input = host.querySelector<HTMLInputElement>('[aria-label="Spring bounce"]')!;
147
+
148
+ act(() => input.focus());
149
+ inputValue(input, "0.7");
150
+ act(() => input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })));
151
+
152
+ expect(onCommit).toHaveBeenCalledExactlyOnceWith("spring(0.7)");
153
+ });
154
+
155
+ it("keeps the cubic-bezier tuple commit behavior", () => {
156
+ const onCommit = vi.fn();
157
+ const host = renderField(<EaseBezierField tuple={[0.33, 0, 0.67, 1]} onCommit={onCommit} />);
158
+ const input = host.querySelector<HTMLInputElement>(
159
+ '[aria-label="Cubic bezier control points"]',
160
+ );
161
+ expect(input).not.toBeNull();
162
+ act(() => {
163
+ if (!input) return;
164
+ input.focus();
165
+ const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
166
+ valueSetter?.call(input, "0.111, 0.222, 0.777, 0.888");
167
+ input.dispatchEvent(new Event("input", { bubbles: true }));
168
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
169
+ });
170
+
171
+ expect(onCommit).toHaveBeenCalledExactlyOnceWith("custom(M0,0 C0.11,0.22 0.78,0.89 1,1)");
172
+ });
173
+
174
+ it("keeps the bezier input mounted and reports invalid values", () => {
175
+ const onCommit = vi.fn();
176
+ const host = renderField(<EaseBezierField tuple={[0.33, 0, 0.67, 1]} onCommit={onCommit} />);
177
+ const input = host.querySelector<HTMLInputElement>(
178
+ '[aria-label="Cubic bezier control points"]',
179
+ )!;
180
+
181
+ commitInputValue(input, "0.5, 1e9, 0.5, -1e9");
182
+
183
+ expect(input.isConnected).toBe(true);
184
+ expect(input.getAttribute("aria-invalid")).toBe("true");
185
+ expect(host.textContent).toContain("Y values must be between -1 and 2");
186
+ expect(onCommit).not.toHaveBeenCalled();
187
+ });
188
+ });
@@ -0,0 +1,246 @@
1
+ import { useEffect, useId, useRef, useState } from "react";
2
+ import { parseSpringBounce } from "@hyperframes/core/spring-ease";
3
+ import {
4
+ parseWiggleEase,
5
+ type WiggleEaseConfig,
6
+ type WiggleType,
7
+ } from "@hyperframes/core/wiggle-ease";
8
+ import { roundToCenti } from "../../utils/rounding";
9
+ import { MiniCurveSvg } from "./easeCurveSvg";
10
+
11
+ type Pts = [number, number, number, number];
12
+
13
+ const round2 = roundToCenti;
14
+ const BEZIER_Y_MIN = -1;
15
+ const BEZIER_Y_MAX = 2;
16
+ const WIGGLE_TYPES = ["easeOut", "easeInOut", "anticipate", "uniform"] as const;
17
+ const WIGGLE_DEFAULT_AMPLITUDE = {
18
+ easeOut: 0.16,
19
+ easeInOut: 0.08,
20
+ anticipate: 0.12,
21
+ uniform: 0.14,
22
+ } satisfies Record<WiggleType, number>;
23
+
24
+ function isWiggleType(value: string): value is WiggleType {
25
+ return WIGGLE_TYPES.some((type) => type === value);
26
+ }
27
+
28
+ function commitWiggle(
29
+ onCommit: (ease: string) => void,
30
+ count: number,
31
+ type: WiggleType,
32
+ amplitude: number,
33
+ ): void {
34
+ const ease = `wiggle(${count},${type},${roundToCenti(amplitude)})`;
35
+ if (parseWiggleEase(ease)) onCommit(ease);
36
+ }
37
+
38
+ // Editable cubic-bezier control points, Figma-style ("0.33, 0, 0, 1").
39
+ export function EaseBezierField({
40
+ tuple,
41
+ onCommit,
42
+ }: {
43
+ tuple: Pts;
44
+ onCommit: (ease: string) => void;
45
+ }) {
46
+ const text = tuple.join(", ");
47
+ const inputRef = useRef<HTMLInputElement>(null);
48
+ const errorId = useId();
49
+ const [error, setError] = useState<string | null>(null);
50
+
51
+ useEffect(() => {
52
+ if (inputRef.current) inputRef.current.value = text;
53
+ setError(null);
54
+ }, [text]);
55
+
56
+ const commit = (raw: string) => {
57
+ const tokens = raw.trim().split(/[\s,]+/);
58
+ const nums = tokens.map(Number);
59
+ if (tokens.length !== 4 || nums.some((value) => !Number.isFinite(value))) {
60
+ setError("Enter four finite numbers");
61
+ return;
62
+ }
63
+ const [x1, y1, x2, y2] = nums as [number, number, number, number];
64
+ if (y1 < BEZIER_Y_MIN || y1 > BEZIER_Y_MAX || y2 < BEZIER_Y_MIN || y2 > BEZIER_Y_MAX) {
65
+ setError(`Y values must be between ${BEZIER_Y_MIN} and ${BEZIER_Y_MAX}`);
66
+ return;
67
+ }
68
+ const cx = (v: number) => Math.max(0, Math.min(1, v));
69
+ setError(null);
70
+ onCommit(`custom(M0,0 C${round2(cx(x1))},${round2(y1)} ${round2(cx(x2))},${round2(y2)} 1,1)`);
71
+ };
72
+ return (
73
+ <div className="mt-1.5 px-0.5">
74
+ <div className="flex items-center gap-1.5">
75
+ <MiniCurveSvg
76
+ ease={`custom(M0,0 C${tuple[0]},${tuple[1]} ${tuple[2]},${tuple[3]} 1,1)`}
77
+ active
78
+ size={14}
79
+ />
80
+ <input
81
+ ref={inputRef}
82
+ type="text"
83
+ defaultValue={text}
84
+ aria-label="Cubic bezier control points"
85
+ aria-invalid={error !== null}
86
+ aria-describedby={error ? errorId : undefined}
87
+ onInput={() => setError(null)}
88
+ onKeyDown={(event) => {
89
+ if (event.key === "Enter") event.currentTarget.blur();
90
+ if (event.key === "Escape") {
91
+ event.currentTarget.value = text;
92
+ setError(null);
93
+ }
94
+ }}
95
+ onBlur={(event) => commit(event.currentTarget.value)}
96
+ className={`w-full rounded border bg-black/20 px-1.5 py-1 font-mono text-[10px] text-neutral-300 outline-none ${
97
+ error
98
+ ? "border-red-500/70 focus:border-red-400"
99
+ : "border-white/10 focus:border-panel-accent/50"
100
+ }`}
101
+ />
102
+ </div>
103
+ <p
104
+ id={errorId}
105
+ aria-live="polite"
106
+ className={`mt-1 text-[9px] text-red-400 ${error ? "" : "sr-only"}`}
107
+ >
108
+ {error ?? "Valid bezier values"}
109
+ </p>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ function NumericCommitInput({
115
+ label,
116
+ value,
117
+ min,
118
+ max,
119
+ step,
120
+ className,
121
+ onCommit,
122
+ }: {
123
+ label: string;
124
+ value: number;
125
+ min: number;
126
+ max?: number;
127
+ step: number;
128
+ className: string;
129
+ onCommit: (value: number) => void;
130
+ }) {
131
+ const [draft, setDraft] = useState(String(value));
132
+ useEffect(() => setDraft(String(value)), [value]);
133
+ const commit = () => {
134
+ if (draft.trim() === "") {
135
+ setDraft(String(value));
136
+ return;
137
+ }
138
+ const next = Number(draft);
139
+ if (!Number.isFinite(next) || next < min || (max !== undefined && next > max)) {
140
+ setDraft(String(value));
141
+ return;
142
+ }
143
+ onCommit(next);
144
+ };
145
+ return (
146
+ <input
147
+ type="number"
148
+ aria-label={label}
149
+ min={min}
150
+ max={max}
151
+ step={step}
152
+ value={draft}
153
+ onChange={(event) => setDraft(event.currentTarget.value)}
154
+ onBlur={commit}
155
+ onKeyDown={(event) => {
156
+ if (event.key === "Enter") event.currentTarget.blur();
157
+ if (event.key === "Escape") {
158
+ setDraft(String(value));
159
+ }
160
+ }}
161
+ className={className}
162
+ />
163
+ );
164
+ }
165
+
166
+ export function SpringBounceField({
167
+ springBounce,
168
+ onCommit,
169
+ }: {
170
+ springBounce: number;
171
+ onCommit: (ease: string) => void;
172
+ }) {
173
+ return (
174
+ <div className="mt-1.5 flex items-center gap-2 px-0.5 text-[10px] text-neutral-400">
175
+ <span aria-hidden="true">Bounce</span>
176
+ <NumericCommitInput
177
+ label="Spring bounce"
178
+ value={springBounce}
179
+ min={0}
180
+ max={1}
181
+ step={0.01}
182
+ onCommit={(value) => {
183
+ const bounce = parseSpringBounce(`spring(${value})`);
184
+ if (bounce !== null) onCommit(`spring(${round2(bounce)})`);
185
+ }}
186
+ className="w-16 rounded border border-white/10 bg-black/20 px-1.5 py-1 font-mono text-[10px] text-neutral-300 outline-none focus:border-panel-accent/50"
187
+ />
188
+ </div>
189
+ );
190
+ }
191
+
192
+ export function WiggleField({
193
+ config,
194
+ onCommit,
195
+ }: {
196
+ config: WiggleEaseConfig;
197
+ onCommit: (ease: string) => void;
198
+ }) {
199
+ const amplitude = config.amplitude ?? WIGGLE_DEFAULT_AMPLITUDE[config.type];
200
+ return (
201
+ <div className="mt-1.5 flex items-center gap-2 px-0.5 text-[10px] text-neutral-400">
202
+ <div className="flex items-center gap-1">
203
+ <span aria-hidden="true">Count</span>
204
+ <NumericCommitInput
205
+ label="Wiggle count"
206
+ value={config.wiggles}
207
+ min={1}
208
+ step={1}
209
+ onCommit={(value) => commitWiggle(onCommit, value, config.type, amplitude)}
210
+ className="w-14 rounded border border-white/10 bg-black/20 px-1.5 py-1 font-mono text-[10px] text-neutral-300 outline-none focus:border-panel-accent/50"
211
+ />
212
+ </div>
213
+ <div className="flex items-center gap-1">
214
+ <span aria-hidden="true">Type</span>
215
+ <select
216
+ aria-label="Wiggle type"
217
+ value={config.type}
218
+ onChange={(event) => {
219
+ if (isWiggleType(event.currentTarget.value)) {
220
+ commitWiggle(onCommit, config.wiggles, event.currentTarget.value, amplitude);
221
+ }
222
+ }}
223
+ className="rounded border border-white/10 bg-black/20 px-1.5 py-1 text-[10px] text-neutral-300 outline-none focus:border-panel-accent/50"
224
+ >
225
+ {WIGGLE_TYPES.map((type) => (
226
+ <option key={type} value={type}>
227
+ {type}
228
+ </option>
229
+ ))}
230
+ </select>
231
+ </div>
232
+ <div className="flex items-center gap-1">
233
+ <span aria-hidden="true">Amplitude</span>
234
+ <NumericCommitInput
235
+ label="Wiggle amplitude"
236
+ value={amplitude}
237
+ min={0}
238
+ max={1}
239
+ step={0.01}
240
+ onCommit={(value) => commitWiggle(onCommit, config.wiggles, config.type, value)}
241
+ className="w-16 rounded border border-white/10 bg-black/20 px-1.5 py-1 font-mono text-[10px] text-neutral-300 outline-none focus:border-panel-accent/50"
242
+ />
243
+ </div>
244
+ </div>
245
+ );
246
+ }
@@ -812,10 +812,10 @@ describe("PropertyPanel — Media group (Plan 4)", () => {
812
812
  // design_handoff scrollable-open-section: collapsed headers before/after the
813
813
  // open group render in normal document flow and never move (no sticky, no
814
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)
815
+ // dedicated region between the two fixed header stacks. Worked example: 7
816
+ // groups [text, style, layout, motion, grade, effects, media], motion open (index 3)
817
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
818
+ // renders as an open header + scrollable body, grade/effects/media render as fixed
819
819
  // collapsed headers after it — in exactly that DOM order, nothing sticky.
820
820
  describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)", () => {
821
821
  it(
@@ -839,13 +839,14 @@ describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)",
839
839
  });
840
840
  // Filter to just the group entries (drop any non-group nulls).
841
841
  const groupTitles = titles.filter((t): t is string => t !== null);
842
- expect(groupTitles).toHaveLength(6);
842
+ expect(groupTitles).toHaveLength(7);
843
843
  expect(groupTitles[0]).toContain("Text");
844
844
  expect(groupTitles[1]).toContain("Style");
845
845
  expect(groupTitles[2]).toContain("Layout");
846
846
  expect(groupTitles[3]).toContain("Motion");
847
847
  expect(groupTitles[4]).toContain("Grade");
848
- expect(groupTitles[5]).toContain("Media");
848
+ expect(groupTitles[5]).toContain("Effects");
849
+ expect(groupTitles[6]).toContain("Media");
849
850
 
850
851
  // The open group (Motion, index 3) is the one wrapped in
851
852
  // data-flat-group-open, sitting between the before/after collapsed
@@ -880,14 +881,15 @@ describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)",
880
881
  const collapsedRows = Array.from(
881
882
  host.querySelectorAll<HTMLButtonElement>('[data-flat-group-collapsed="true"]'),
882
883
  );
883
- expect(collapsedRows).toHaveLength(6);
884
+ expect(collapsedRows).toHaveLength(7);
884
885
  const titlesInOrder = collapsedRows.map((el) => el.textContent ?? "");
885
886
  expect(titlesInOrder[0]).toContain("Text");
886
887
  expect(titlesInOrder[1]).toContain("Style");
887
888
  expect(titlesInOrder[2]).toContain("Layout");
888
889
  expect(titlesInOrder[3]).toContain("Motion");
889
890
  expect(titlesInOrder[4]).toContain("Grade");
890
- expect(titlesInOrder[5]).toContain("Media");
891
+ expect(titlesInOrder[5]).toContain("Effects");
892
+ expect(titlesInOrder[6]).toContain("Media");
891
893
 
892
894
  const body = host.querySelector('[data-flat-panel-body="true"]');
893
895
  expect(body?.querySelector(".overflow-y-auto")).toBeNull();