diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index f43f16772..a86621c76 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -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 = baseElement(), + propsOverride: Partial = {}, ) { 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(); @@ -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, + ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index d606be1de..b1d57d097 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -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; +// 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; * (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 3–4). 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 + // `` 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 (
- {sections.timing && ( - + {showMotionGroup && ( + toggleOpen("motion")} + onTogglePin={() => togglePin("motion")} + summary={`${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`} + > + + )} {sections.colorGrading && (