feat(studio): wire the flat Motion group into the one-open accordion

This commit is contained in:
Vance Ingalls
2026-07-09 12:33:34 -07:00
parent 59e178019c
commit 710a2488f1
2 changed files with 196 additions and 11 deletions
@@ -130,9 +130,23 @@ function flexElement() {
};
}
// Motion fixture (Plan 3b Task 4): an authored clip range (data-start present)
// makes resolveEditingSections turn on `sections.timing`, so the Motion group
// renders via its Timing gate even with no GSAP edit handlers wired.
function animatedElement() {
return {
...baseElement(),
id: "anim-clip",
selector: ".anim-clip",
label: "Anim Clip",
dataAttributes: { start: "0", duration: "4" },
};
}
async function renderPanel(
flatEnabled: boolean,
elementOverride: ReturnType<typeof baseElement> = baseElement(),
propsOverride: Partial<PropertyPanelProps> = {},
) {
vi.resetModules();
vi.doMock("./manualEditingAvailability", async () => {
@@ -154,6 +168,7 @@ async function renderPanel(
onSetStyle: vi.fn(),
onSetText: vi.fn(),
onSetAttributeLive: vi.fn(),
...propsOverride,
} as unknown as PropertyPanelProps;
act(() => {
root.render(<PropertyPanel {...props} />);
@@ -166,6 +181,18 @@ async function renderPanel(
// default under heavy parallel full-suite load, so give these a wider margin.
const RENDER_TIMEOUT_MS = 20_000;
// Find the collapsed accordion row whose title matches and click it open.
function openFlatGroup(host: HTMLElement, title: string) {
const row = Array.from(host.querySelectorAll('[data-flat-group-collapsed="true"]')).find((el) =>
el.textContent?.includes(title),
);
if (!row) throw new Error(`expected a collapsed ${title} row`);
act(() => row.dispatchEvent(new MouseEvent("click", { bubbles: true })));
}
const openGroupText = (host: HTMLElement) =>
host.querySelector('[data-flat-group-open="true"]')?.textContent ?? "";
describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED off", () => {
it(
"renders the legacy header, not the flat header",
@@ -329,3 +356,65 @@ describe("PropertyPanel — Layout group (Plan 3a)", () => {
RENDER_TIMEOUT_MS,
);
});
describe("PropertyPanel — Motion group (Plan 3b)", () => {
it(
"renders the Motion group with Timing, and opening it closes the previously open group (4-way exclusivity)",
async () => {
const { host, root } = await renderPanel(true, animatedElement());
// Text is open by default for the text-editable fixture.
expect(openGroupText(host)).toContain("Text");
openFlatGroup(host, "Motion");
const openGroup = openGroupText(host);
expect(openGroup).toContain("Motion");
// FlatTimingRow (Start/End/Duration) renders inside the Motion group.
expect(openGroup).toContain("Start");
expect(openGroup).toContain("Duration");
// One-open accordion: opening Motion closed the Text group.
expect(openGroup).not.toContain("Text");
// Reverse direction: opening Layout closes Motion.
openFlatGroup(host, "Layout");
const openAfter = openGroupText(host);
expect(openAfter).toContain("Layout");
expect(openAfter).not.toContain("Motion");
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
it(
"hides the effect list (showEffects off) when the GSAP edit handlers are absent",
async () => {
// STUDIO_GSAP_PANEL_ENABLED defaults on, but none of the five required
// edit handlers are supplied here, so the effect-list half of the
// double-gate stays closed — only the Timing row shows.
const { host, root } = await renderPanel(true, animatedElement());
openFlatGroup(host, "Motion");
const openGroup = openGroupText(host);
expect(openGroup).toContain("Motion");
expect(openGroup).toContain("Duration"); // Timing still shows
expect(openGroup).not.toContain("Add effect"); // effects gated off
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
it(
"shows the effect list (showEffects on) when the flag and all five handlers are present",
async () => {
const { host, root } = await renderPanel(true, animatedElement(), {
onUpdateGsapProperty: vi.fn(),
onUpdateGsapMeta: vi.fn(),
onDeleteGsapAnimation: vi.fn(),
onAddGsapProperty: vi.fn(),
onAddGsapAnimation: vi.fn(),
});
openFlatGroup(host, "Motion");
expect(openGroupText(host)).toContain("Add effect");
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
});
@@ -10,14 +10,29 @@ import { FlatGroup } from "./propertyPanelFlatPrimitives";
import { FlatTextSection } from "./propertyPanelFlatTextSection";
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
import { createGsapLivePreview } from "./gsapLivePreview";
import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
import { TimingSection } from "./propertyPanelTimingSection";
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
import { MediaSection } from "./propertyPanelMediaSection";
type EditingSections = ReturnType<typeof resolveEditingSections>;
// Type-only fallback for the Motion effect-card callbacks. Used solely to
// satisfy FlatMotionSection's required-callback shape when the effect list is
// gated off (showEffects === false, so none of these are ever invoked). Keeps
// the gated-off path free of `!` non-null assertions — the real, narrowed
// handlers flow through only when the double-gate below passes.
const EMPTY_GSAP_EFFECT_HANDLERS = {
onAddAnimation: () => {},
onUpdateProperty: () => {},
onUpdateMeta: () => {},
onDeleteAnimation: () => {},
onAddProperty: () => {},
onRemoveProperty: () => {},
};
/**
* The flat "Ledger" inspector shell (design_handoff_studio_inspector).
*
@@ -25,10 +40,8 @@ type EditingSections = ReturnType<typeof resolveEditingSections>;
* (same one-directional-import precedent as FlatTextSection). Rendered only
* when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open/pin group state.
*
* Intentionally omits the Layout `Section` and `GsapAnimationSection` (Motion)
* — flattening those is Layout/Motion plan territory (plans 34). A text
* element with the flag on will not show Layout/Motion controls; that
* regression is scoped and acceptable for an unreleased, flag-gated feature.
* The Text/Style/Layout/Motion groups share the one-open accordion. The legacy
* Media and Color-Grading sections render unchanged below the flat groups.
*/
// fallow-ignore-next-line complexity
export function PropertyPanelFlat({
@@ -90,6 +103,22 @@ export function PropertyPanelFlat({
onSeekToTime,
onRemoveKeyframe,
onConvertToKeyframes,
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
onUpdateGsapProperty,
onUpdateGsapMeta,
onDeleteGsapAnimation,
onAddGsapProperty,
onRemoveGsapProperty,
onUpdateGsapFromProperty,
onAddGsapFromProperty,
onRemoveGsapFromProperty,
onAddGsapAnimation,
onSetArcPath,
onUpdateArcSegment,
onUnroll,
onUpdateKeyframeEase,
onSetAllKeyframeEases,
}: Pick<
PropertyPanelProps,
| "projectId"
@@ -114,6 +143,22 @@ export function PropertyPanelFlat({
| "onImportFonts"
| "fontAssets"
| "gsapAnimations"
| "gsapMultipleTimelines"
| "gsapUnsupportedTimelinePattern"
| "onUpdateGsapProperty"
| "onUpdateGsapMeta"
| "onDeleteGsapAnimation"
| "onAddGsapProperty"
| "onRemoveGsapProperty"
| "onUpdateGsapFromProperty"
| "onAddGsapFromProperty"
| "onRemoveGsapFromProperty"
| "onAddGsapAnimation"
| "onSetArcPath"
| "onUpdateArcSegment"
| "onUnroll"
| "onUpdateKeyframeEase"
| "onSetAllKeyframeEases"
| "recordingState"
| "recordingDuration"
| "onToggleRecording"
@@ -181,6 +226,43 @@ export function PropertyPanelFlat({
// PropertyPanel (keeps that file under its 600-LOC gate).
const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
// Motion group double-gate — reproduces the legacy PropertyPanel gate exactly:
// • Timing (sections.timing) shows via resolveEditingSections, same as today.
// • The effect-card list shows only when STUDIO_GSAP_PANEL_ENABLED is on AND
// all five edit handlers are present (identical to PropertyPanel's legacy
// `<GsapAnimationSection>` guard).
// Computing the narrowed handler bundle inside the `&&`-guarded ternary lets
// TypeScript prove each handler non-undefined without a `!` assertion; the
// noop bundle only fills the type when the gate is off (never invoked, since
// FlatMotionSection guards every call behind showEffects).
const showMotionTiming = Boolean(sections.timing);
const gsapEffectHandlers =
STUDIO_GSAP_PANEL_ENABLED &&
onUpdateGsapProperty &&
onUpdateGsapMeta &&
onDeleteGsapAnimation &&
onAddGsapProperty &&
onAddGsapAnimation
? {
onAddAnimation: onAddGsapAnimation,
onUpdateProperty: onUpdateGsapProperty,
onUpdateMeta: onUpdateGsapMeta,
onDeleteAnimation: onDeleteGsapAnimation,
onAddProperty: onAddGsapProperty,
onRemoveProperty: onRemoveGsapProperty ?? (() => {}),
onUpdateFromProperty: onUpdateGsapFromProperty,
onAddFromProperty: onAddGsapFromProperty,
onRemoveFromProperty: onRemoveGsapFromProperty,
onSetArcPath,
onUpdateArcSegment,
onUnroll,
onUpdateKeyframeEase,
onSetAllKeyframeEases,
}
: null;
const showMotionEffects = gsapEffectHandlers !== null;
const showMotionGroup = showMotionTiming || showMotionEffects;
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
<PropertyPanelFlatHeader
@@ -287,12 +369,26 @@ export function PropertyPanelFlat({
/>
</FlatGroup>
{sections.timing && (
<TimingSection
element={element}
animations={gsapAnimations}
onSetAttribute={onSetAttribute}
/>
{showMotionGroup && (
<FlatGroup
title="Motion"
isOpen={openGroupId === "motion" || pinnedGroupIds.includes("motion")}
isPinned={pinnedGroupIds.includes("motion")}
onToggleOpen={() => toggleOpen("motion")}
onTogglePin={() => togglePin("motion")}
summary={`${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`}
>
<FlatMotionSection
element={element}
animations={gsapAnimations}
showTiming={showMotionTiming}
showEffects={showMotionEffects}
multipleTimelines={gsapMultipleTimelines}
unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
onSetAttribute={onSetAttribute}
{...(gsapEffectHandlers ?? EMPTY_GSAP_EFFECT_HANDLERS)}
/>
</FlatGroup>
)}
{sections.colorGrading && (
<ColorGradingSection