From 5b60c1f4ca645262c09e82083f920619b91c1c99 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 10:46:48 -0700 Subject: [PATCH 01/11] feat(studio): add flat Layout geometry rows (X/Y/W/H/Angle) with keyframe gutters --- .../propertyPanelFlatLayoutSection.test.tsx | 109 ++++++++++++ .../editor/propertyPanelFlatLayoutSection.tsx | 158 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx new file mode 100644 index 0000000000..db80e32baf --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { LayoutGeometryRows } from "./propertyPanelFlatLayoutSection"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderInto(node: React.ReactElement) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return { host, root }; +} + +function getFlatRowInput(host: HTMLElement, label: string): HTMLInputElement { + const rows = Array.from(host.querySelectorAll(".group")); + const row = rows.find((el) => el.querySelector("span")?.textContent === label); + const input = row?.querySelector("input"); + if (!input) throw new Error(`expected an input for row "${label}"`); + return input; +} + +function baseGeometryProps(overrides: Partial[0]> = {}) { + return { + displayX: 0, + displayY: -24, + displayW: 257.4, + displayH: 29, + displayR: 0, + manualOffsetEditingDisabled: false, + manualSizeEditingDisabled: false, + manualRotationEditingDisabled: false, + commitManualOffset: vi.fn(), + commitManualSize: vi.fn(), + commitManualRotation: vi.fn(), + gsapAnimId: null, + navKeyframes: null, + currentPct: 0, + seekFromKfPct: vi.fn(), + animIdForProp: (prop: string) => prop, + onCommitAnimatedProperty: vi.fn(), + onRemoveKeyframe: vi.fn(), + onConvertToKeyframes: vi.fn(), + ...overrides, + }; +} + +describe("LayoutGeometryRows", () => { + it("renders X, Y, W, H, Angle labels and formatted values", () => { + const { host, root } = renderInto(); + expect(host.textContent).toContain("X"); + expect(host.textContent).toContain("Y"); + expect(host.textContent).toContain("W"); + expect(host.textContent).toContain("H"); + expect(host.textContent).toContain("Angle"); + expect(getFlatRowInput(host, "W").value).toBe("257.4px"); + expect(getFlatRowInput(host, "Y").value).toBe("-24px"); + act(() => root.unmount()); + }); + + it("commits an X edit through commitManualOffset", () => { + const commitManualOffset = vi.fn(); + const { host, root } = renderInto( + , + ); + const input = host.querySelectorAll("input")[0]; + if (!input) throw new Error("expected an X input"); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(input, "40px"); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("focusout", { bubbles: true })); + }); + expect(commitManualOffset).toHaveBeenCalledWith("x", "40px"); + act(() => root.unmount()); + }); + + it("wraps the keyframe gutter cluster at 30% opacity when the property has no keyframes", () => { + const { host, root } = renderInto( + , + ); + const dimmed = host.querySelectorAll('[data-flat-kf-gutter="true"][style*="opacity: 0.3"]'); + expect(dimmed.length).toBeGreaterThan(0); + act(() => root.unmount()); + }); + + it("does not dim the gutter cluster when the property has keyframes", () => { + const { host, root } = renderInto( + , + ); + const full = host.querySelectorAll('[data-flat-kf-gutter="true"][style*="opacity: 1"]'); + expect(full.length).toBeGreaterThan(0); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx new file mode 100644 index 0000000000..d5c8d5db9d --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx @@ -0,0 +1,158 @@ +import { FlatRow } from "./propertyPanelFlatPrimitives"; +import { KeyframeNavigation } from "./KeyframeNavigation"; +import { formatPxMetricValue } from "./propertyPanelHelpers"; +import { STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability"; + +type KeyframeEntry = Array<{ + percentage: number; + tweenPercentage?: number; + properties: Record; + ease?: string; +}> | null; + +interface GeometryRowsProps { + displayX: number; + displayY: number; + displayW: number; + displayH: number; + displayR: number; + manualOffsetEditingDisabled: boolean; + manualSizeEditingDisabled: boolean; + manualRotationEditingDisabled: boolean; + commitManualOffset: (axis: "x" | "y", value: string) => void; + commitManualSize: (dimension: "width" | "height", value: string) => void; + commitManualRotation: (value: string) => void; + gsapAnimId: string | null; + navKeyframes: KeyframeEntry; + currentPct: number; + seekFromKfPct: (pct: number) => void; + animIdForProp: (prop: string) => string; + onCommitAnimatedProperty?: ( + element: unknown, + property: string, + value: number, + ) => void | Promise; + onRemoveKeyframe?: (animId: string, pct: number) => void; + onConvertToKeyframes?: (animId: string) => void; +} + +function KeyframeGutter({ + property, + displayValue, + gsapAnimId, + navKeyframes, + currentPct, + seekFromKfPct, + animIdForProp, + onCommitAnimatedProperty, + onRemoveKeyframe, + onConvertToKeyframes, +}: { + property: string; + displayValue: number; +} & Pick< + GeometryRowsProps, + | "gsapAnimId" + | "navKeyframes" + | "currentPct" + | "seekFromKfPct" + | "animIdForProp" + | "onCommitAnimatedProperty" + | "onRemoveKeyframe" + | "onConvertToKeyframes" +>) { + if (!STUDIO_KEYFRAMES_ENABLED || !gsapAnimId) return null; + const hasKeyframesOnProp = Boolean(navKeyframes?.some((kf) => property in kf.properties)); + return ( + + + onCommitAnimatedProperty && void onCommitAnimatedProperty(null, property, displayValue) + } + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp(property), pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp(property))} + /> + + ); +} + +export function LayoutGeometryRows({ + displayX, + displayY, + displayW, + displayH, + displayR, + manualOffsetEditingDisabled, + manualSizeEditingDisabled, + manualRotationEditingDisabled, + commitManualOffset, + commitManualSize, + commitManualRotation, + gsapAnimId, + navKeyframes, + currentPct, + seekFromKfPct, + animIdForProp, + onCommitAnimatedProperty, + onRemoveKeyframe, + onConvertToKeyframes, +}: GeometryRowsProps) { + const gutterProps = { + gsapAnimId, + navKeyframes, + currentPct, + seekFromKfPct, + animIdForProp, + onCommitAnimatedProperty, + onRemoveKeyframe, + onConvertToKeyframes, + }; + return ( + <> + commitManualOffset("x", next)} + suffix={} + /> + commitManualOffset("y", next)} + suffix={} + /> + commitManualSize("width", next)} + suffix={} + /> + commitManualSize("height", next)} + suffix={} + /> + commitManualRotation(next.replace("°", ""))} + suffix={} + /> + + ); +} From 367d4267f8eac9f22d673b9c846894faf71691d7 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 11:00:33 -0700 Subject: [PATCH 02/11] feat(studio): add flat Layout Z-index row and Flex sub-block Co-Authored-By: Claude Sonnet 5 --- .../propertyPanelFlatLayoutSection.test.tsx | 58 ++++++++++++- .../editor/propertyPanelFlatLayoutSection.tsx | 81 ++++++++++++++++++- 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx index db80e32baf..827f9fad46 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx @@ -3,7 +3,11 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { LayoutGeometryRows } from "./propertyPanelFlatLayoutSection"; +import { + LayoutFlexBlock, + LayoutGeometryRows, + LayoutZIndexRow, +} from "./propertyPanelFlatLayoutSection"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -107,3 +111,55 @@ describe("LayoutGeometryRows", () => { act(() => root.unmount()); }); }); + +describe("LayoutZIndexRow", () => { + it("renders the current z-index at the default tier and commits edits", () => { + const onSetStyle = vi.fn(); + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Z-index"); + const input = host.querySelector("input"); + if (!input) throw new Error("expected an input"); + expect(input.value).toBe("3"); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(input, "5"); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("focusout", { bubbles: true })); + }); + expect(onSetStyle).toHaveBeenCalledWith("z-index", "5"); + act(() => root.unmount()); + }); +}); + +describe("LayoutFlexBlock", () => { + it("renders nothing when the element is not flex", () => { + const { host, root } = renderInto( + , + ); + expect(host.textContent).toBe(""); + act(() => root.unmount()); + }); + + it("renders direction/justify/align/gap and commits a direction change", () => { + const onSetStyle = vi.fn(); + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Flex"); + const columnOption = Array.from(host.querySelectorAll('[data-flat-segment="true"]')).find( + (el) => el.textContent === "Column", + ); + if (!columnOption) throw new Error("expected a Column segment option"); + act(() => + (columnOption as HTMLElement).dispatchEvent(new MouseEvent("click", { bubbles: true })), + ); + expect(onSetStyle).toHaveBeenCalledWith("flex-direction", "column"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx index d5c8d5db9d..477893d4ee 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx @@ -1,7 +1,8 @@ -import { FlatRow } from "./propertyPanelFlatPrimitives"; +import { FlatRow, FlatSegmentedRow, FlatSelectRow } from "./propertyPanelFlatPrimitives"; import { KeyframeNavigation } from "./KeyframeNavigation"; import { formatPxMetricValue } from "./propertyPanelHelpers"; import { STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability"; +import { resolveValueTier } from "./propertyPanelValueTier"; type KeyframeEntry = Array<{ percentage: number; @@ -156,3 +157,81 @@ export function LayoutGeometryRows({ ); } + +export function LayoutZIndexRow({ + styles, + onSetStyle, +}: { + styles: Record; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const zIndex = String(parseInt(styles["z-index"] || "auto", 10) || 0); + return ( + void onSetStyle("z-index", next)} + /> + ); +} + +export function LayoutFlexBlock({ + styles, + onSetStyle, + disabled, +}: { + styles: Record; + onSetStyle: (prop: string, value: string) => void | Promise; + disabled: boolean; +}) { + const isFlex = styles.display === "flex" || styles.display === "inline-flex"; + if (!isFlex) return null; + const direction = styles["flex-direction"] || "row"; + return ( +
+
+ Flex +
+ void onSetStyle("flex-direction", next)} + /> + void onSetStyle("justify-content", next)} + /> + void onSetStyle("align-items", next)} + /> + void onSetStyle("gap", next.endsWith("px") ? next : `${next}px`)} + /> +
+ ); +} From 933f87b304cd726a69b8d1e1e55931ed0905b566 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 11:09:24 -0700 Subject: [PATCH 03/11] feat(studio): nest the existing 3D transform sub-view in a flat wrapper --- .../propertyPanelFlatLayoutSection.test.tsx | 26 +++++++ .../editor/propertyPanelFlatLayoutSection.tsx | 69 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx index 827f9fad46..6ecad450fe 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { LayoutFlexBlock, LayoutGeometryRows, + LayoutTransform3DBlock, LayoutZIndexRow, } from "./propertyPanelFlatLayoutSection"; @@ -163,3 +164,28 @@ describe("LayoutFlexBlock", () => { act(() => root.unmount()); }); }); + +describe("LayoutTransform3DBlock", () => { + it("renders the nested 3D transform sub-view", () => { + const { host, root } = renderInto( + , + ); + // PropertyPanel3dTransform's own internals aren't this task's concern (it's + // reused unmodified) — just confirm the wrapper mounted something. + expect(host.children.length).toBeGreaterThan(0); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx index 477893d4ee..e8e58e77aa 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx @@ -3,6 +3,8 @@ import { KeyframeNavigation } from "./KeyframeNavigation"; import { formatPxMetricValue } from "./propertyPanelHelpers"; import { STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability"; import { resolveValueTier } from "./propertyPanelValueTier"; +import { PropertyPanel3dTransform } from "./propertyPanel3dTransform"; +import type { DomEditSelection } from "./domEditingTypes"; type KeyframeEntry = Array<{ percentage: number; @@ -235,3 +237,70 @@ export function LayoutFlexBlock({ ); } + +export function LayoutTransform3DBlock({ + gsapRuntimeValues, + gsapAnimId, + resolveAnimIdForProp, + gsapKeyframes, + currentPct, + elStart, + elDuration, + element, + onCommitAnimatedProperty, + onCommitAnimatedProperties, + onSeekToTime, + onRemoveKeyframe, + onConvertToKeyframes, + onLivePreviewProps, +}: { + gsapRuntimeValues: Record; + gsapAnimId: string | null; + resolveAnimIdForProp?: (prop: string) => string | null; + gsapKeyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }> | null; + currentPct: number; + elStart: number; + elDuration: number; + element: DomEditSelection; + onCommitAnimatedProperty?: ( + element: DomEditSelection, + property: string, + value: number, + ) => Promise; + onCommitAnimatedProperties?: ( + element: DomEditSelection, + props: Record, + ) => Promise; + onSeekToTime?: (time: number) => void; + onRemoveKeyframe?: (animId: string, pct: number) => void; + onConvertToKeyframes?: (animId: string, duration?: number) => void; + onLivePreviewProps?: (element: DomEditSelection, props: Record) => void; +}) { + return ( +
+
+ 3D Transform +
+ +
+ ); +} From 9536b99e96ba8dde5f5cd7380683b12cb71002d2 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 11:21:36 -0700 Subject: [PATCH 04/11] feat(studio): compose FlatLayoutSection from geometry, z-index, flex, and 3D blocks --- .../propertyPanelFlatLayoutSection.test.tsx | 102 ++++++++++++++++++ .../editor/propertyPanelFlatLayoutSection.tsx | 71 +++++++++++- 2 files changed, 170 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx index 6ecad450fe..ece488c962 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx @@ -4,6 +4,7 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + FlatLayoutSection, LayoutFlexBlock, LayoutGeometryRows, LayoutTransform3DBlock, @@ -36,6 +37,7 @@ function getFlatRowInput(host: HTMLElement, label: string): HTMLInputElement { function baseGeometryProps(overrides: Partial[0]> = {}) { return { + element: {} as never, displayX: 0, displayY: -24, displayW: 257.4, @@ -111,6 +113,32 @@ describe("LayoutGeometryRows", () => { expect(full.length).toBeGreaterThan(0); act(() => root.unmount()); }); + + it("passes the real element/selection (not null) to onCommitAnimatedProperty when adding a keyframe", () => { + const onCommitAnimatedProperty = vi.fn(); + const element = { id: "el-1" } as unknown as Parameters< + typeof LayoutGeometryRows + >[0]["element"]; + const { host, root } = renderInto( + , + ); + const addButton = host.querySelector('[title="Add x keyframe"]'); + if (!addButton) throw new Error("expected an Add x keyframe button"); + act(() => { + (addButton as HTMLElement).dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onCommitAnimatedProperty).toHaveBeenCalledWith(element, "x", 0); + expect(onCommitAnimatedProperty).not.toHaveBeenCalledWith(null, "x", 0); + act(() => root.unmount()); + }); }); describe("LayoutZIndexRow", () => { @@ -189,3 +217,77 @@ describe("LayoutTransform3DBlock", () => { act(() => root.unmount()); }); }); + +describe("FlatLayoutSection", () => { + it("renders geometry rows, z-index, flex (when applicable), and the 3D transform block in order", () => { + const { host, root } = renderInto( + p} + gsapRuntimeValues={{}} + gsapKeyframes={null} + elStart={0} + elDuration={0} + onSeekToTime={vi.fn()} + />, + ); + const text = host.textContent ?? ""; + expect(text).toContain("X"); + expect(text).toContain("Z-index"); + expect(text).toContain("Flex"); + expect(text).toContain("3D Transform"); + act(() => root.unmount()); + }); + + it("omits the Flex block for a non-flex element", () => { + const { host, root } = renderInto( + p} + gsapRuntimeValues={{}} + gsapKeyframes={null} + elStart={0} + elDuration={0} + onSeekToTime={vi.fn()} + />, + ); + expect(host.textContent).not.toContain("Flex"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx index e8e58e77aa..0c7b0a2e4d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx @@ -14,6 +14,7 @@ type KeyframeEntry = Array<{ }> | null; interface GeometryRowsProps { + element: DomEditSelection; displayX: number; displayY: number; displayW: number; @@ -31,15 +32,16 @@ interface GeometryRowsProps { seekFromKfPct: (pct: number) => void; animIdForProp: (prop: string) => string; onCommitAnimatedProperty?: ( - element: unknown, + element: DomEditSelection, property: string, value: number, - ) => void | Promise; + ) => Promise; onRemoveKeyframe?: (animId: string, pct: number) => void; onConvertToKeyframes?: (animId: string) => void; } function KeyframeGutter({ + element, property, displayValue, gsapAnimId, @@ -55,6 +57,7 @@ function KeyframeGutter({ displayValue: number; } & Pick< GeometryRowsProps, + | "element" | "gsapAnimId" | "navKeyframes" | "currentPct" @@ -74,7 +77,7 @@ function KeyframeGutter({ currentPercentage={currentPct} onSeek={seekFromKfPct} onAddKeyframe={() => - onCommitAnimatedProperty && void onCommitAnimatedProperty(null, property, displayValue) + onCommitAnimatedProperty && void onCommitAnimatedProperty(element, property, displayValue) } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp(property), pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp(property))} @@ -84,6 +87,7 @@ function KeyframeGutter({ } export function LayoutGeometryRows({ + element, displayX, displayY, displayW, @@ -105,6 +109,7 @@ export function LayoutGeometryRows({ onConvertToKeyframes, }: GeometryRowsProps) { const gutterProps = { + element, gsapAnimId, navKeyframes, currentPct, @@ -304,3 +309,63 @@ export function LayoutTransform3DBlock({ ); } + +interface FlatLayoutSectionProps + extends + Omit, + Pick< + Parameters[0], + | "gsapRuntimeValues" + | "resolveAnimIdForProp" + | "gsapKeyframes" + | "elStart" + | "elDuration" + | "onCommitAnimatedProperties" + | "onSeekToTime" + | "onLivePreviewProps" + > { + element: DomEditSelection; + styles: Record; + onSetStyle: (prop: string, value: string) => void | Promise; + disabled: boolean; +} + +export function FlatLayoutSection({ + element, + styles, + onSetStyle, + disabled, + gsapRuntimeValues, + resolveAnimIdForProp, + gsapKeyframes, + elStart, + elDuration, + onCommitAnimatedProperties, + onSeekToTime, + onLivePreviewProps, + ...geometry +}: FlatLayoutSectionProps) { + return ( +
+ + + + +
+ ); +} From ed80c3a54f772d4965090d2b462860e46f3e3e0a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 11:44:06 -0700 Subject: [PATCH 05/11] feat(studio): wire the flat Layout group into the one-open accordion Thread the Layout-group values through PropertyPanel -> PropertyPanelFlat and add the third FlatGroup to the one-open/pin accordion (unconditional, matching legacy Layout). Default-open Layout when neither Text nor Style applies. Fix the Flex double-render: the legacy StyleSections still renders its own Flex Section, and the new flat Layout group renders its own LayoutFlexBlock. Add an additive optional hideFlex prop to StyleSections and pass it on the flat path so Flex renders exactly once (from the flat Layout group). Non-flat callers omit it and are unchanged. Extract the shared onLivePreviewProps closure into gsapLivePreview.ts (it was duplicated inline in the legacy path) so PropertyPanel.tsx stays within the 600-LOC studio gate. Co-Authored-By: Claude Sonnet 5 --- .../components/editor/PropertyPanel.test.tsx | 63 +++++++++ .../src/components/editor/PropertyPanel.tsx | 30 ++-- .../components/editor/PropertyPanelFlat.tsx | 131 ++++++++++++++++-- .../src/components/editor/gsapLivePreview.ts | 22 +++ .../editor/propertyPanelStyleSections.tsx | 7 +- 5 files changed, 229 insertions(+), 24 deletions(-) create mode 100644 packages/studio/src/components/editor/gsapLivePreview.ts diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index d38f67b6dc..f43f16772d 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -114,6 +114,22 @@ function styleOnlyElement() { }; } +// Flex fixture (Plan 3a Task 5): display:flex drives BOTH the legacy +// StyleSections Flex `Section` AND the new flat Layout group's +// LayoutFlexBlock. Used to prove Flex renders exactly once on the flat path. +// styles are read from computedStyles (PropertyPanel line ~113), so set it +// there. +function flexElement() { + return { + ...baseElement(), + id: "flex-row", + selector: ".flex-row", + label: "Flex Row", + textFields: [], + computedStyles: { display: "flex" }, + }; +} + async function renderPanel( flatEnabled: boolean, elementOverride: ReturnType = baseElement(), @@ -266,3 +282,50 @@ describe("PropertyPanel — Style group (flag on)", () => { RENDER_TIMEOUT_MS, ); }); + +describe("PropertyPanel — Layout group (Plan 3a)", () => { + it( + "always renders the Layout group, and opening it closes whichever other group was open", + async () => { + const { host, root } = await renderPanel(true); + // Text group is open by default for the base text-editable fixture. + expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Text"); + + const layoutCollapsedRow = Array.from( + host.querySelectorAll('[data-flat-group-collapsed="true"]'), + ).find((el) => el.textContent?.includes("Layout")); + if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row"); + act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + const openGroup = host.querySelector('[data-flat-group-open="true"]'); + expect(openGroup?.textContent).toContain("Layout"); + expect(openGroup?.textContent).toContain("X"); + expect(openGroup?.textContent).not.toContain("Ask agent"); // sanity: not matching the footer + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); + + it( + "renders Flex exactly once on the flat path (flat Layout only, legacy suppressed)", + async () => { + const { host, root } = await renderPanel(true, flexElement()); + const layoutCollapsedRow = Array.from( + host.querySelectorAll('[data-flat-group-collapsed="true"]'), + ).find((el) => el.textContent?.includes("Layout")); + if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row"); + act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + // The legacy StyleSections Flex `Section` (data-panel-section="flex") must + // NOT render on the flat path — the only two Flex renderers are the legacy + // Section and the flat LayoutFlexBlock, so its absence + the flat block's + // presence proves Flex renders exactly once (not twice, not zero). + expect(host.querySelector('[data-panel-section="flex"]')).toBeNull(); + const openGroup = host.querySelector('[data-flat-group-open="true"]'); + expect(openGroup?.textContent).toContain("Layout"); + expect(openGroup?.textContent).toContain("Flex"); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 574a8d7785..97218c3901 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -31,6 +31,7 @@ import { STUDIO_KEYFRAMES_ENABLED, } from "./manualEditingAvailability"; import { PropertyPanelFlat } from "./PropertyPanelFlat"; +import { createGsapLivePreview } from "./gsapLivePreview"; import { usePlayerStore, liveTime } from "../../player"; import { TimingSection } from "./propertyPanelTimingSection"; import { type PropertyPanelProps } from "./propertyPanelHelpers"; @@ -279,6 +280,24 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro selectedElementId={selectedElementId} clipboardCopied={clipboardCopied} onCopyElementInfo={handleCopyElementInfo} + displayX={displayX} + displayY={displayY} + displayW={displayW} + displayH={displayH} + displayR={displayR} + manualOffsetEditingDisabled={manualOffsetEditingDisabled} + manualSizeEditingDisabled={manualSizeEditingDisabled} + manualRotationEditingDisabled={manualRotationEditingDisabled} + commitManualOffset={commitManualOffset} + commitManualSize={commitManualSize} + commitManualRotation={commitManualRotation} + gsapAnimId={gsapAnimId} + navKeyframes={navKeyframes} + currentPct={currentPct} + animIdForProp={animIdForProp} + gsapRuntimeValues={gsap3dValues} + elStart={elStart} + elDuration={elDuration} /> ); } @@ -522,16 +541,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro onSeekToTime={onSeekToTime} onRemoveKeyframe={onRemoveKeyframe} onConvertToKeyframes={onConvertToKeyframes} - onLivePreviewProps={(el, props) => { - const iframe = iframeRef.current; - const win = iframe?.contentWindow as - | { gsap?: { set: (t: Element, v: Record) => void } } - | null - | undefined; - const sel = el.id ? `#${el.id}` : el.selector; - const node = sel ? iframe?.contentDocument?.querySelector(sel) : null; - if (win?.gsap && node) win.gsap.set(node, props); - }} + onLivePreviewProps={createGsapLivePreview(iframeRef)} />
diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 53e4d31c8c..d606be1de9 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -3,11 +3,14 @@ import { resolveEditingSections } from "@hyperframes/core/editing"; import type { DomEditSelection } from "./domEditing"; import { isTextEditableSelection } from "./domEditing"; import type { PropertyPanelProps } from "./propertyPanelHelpers"; +import { formatPxMetricValue } from "./propertyPanelHelpers"; import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader"; import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter"; import { FlatGroup } from "./propertyPanelFlatPrimitives"; import { FlatTextSection } from "./propertyPanelFlatTextSection"; import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; +import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection"; +import { createGsapLivePreview } from "./gsapLivePreview"; import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections"; import { TimingSection } from "./propertyPanelTimingSection"; import { ColorGradingSection } from "./propertyPanelColorGradingSection"; @@ -64,6 +67,29 @@ export function PropertyPanelFlat({ recordingState, recordingDuration, onToggleRecording, + displayX, + displayY, + displayW, + displayH, + displayR, + manualOffsetEditingDisabled, + manualSizeEditingDisabled, + manualRotationEditingDisabled, + commitManualOffset, + commitManualSize, + commitManualRotation, + gsapAnimId, + navKeyframes, + currentPct, + animIdForProp, + gsapRuntimeValues, + elStart, + elDuration, + onCommitAnimatedProperty, + onCommitAnimatedProperties, + onSeekToTime, + onRemoveKeyframe, + onConvertToKeyframes, }: Pick< PropertyPanelProps, | "projectId" @@ -91,18 +117,47 @@ export function PropertyPanelFlat({ | "recordingState" | "recordingDuration" | "onToggleRecording" -> & { - element: DomEditSelection; - styles: Record; - sections: EditingSections; - sourceLabel: string; - gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null; - showEditableSections: boolean; - selectedElementHidden: boolean; - selectedElementId: string | null; - clipboardCopied: boolean; - onCopyElementInfo: () => void; -}) { +> & + // Layout-group values (Plan 3a Task 5). All are derived locals or handlers in + // PropertyPanel; compose their exact shapes from FlatLayoutSection's own props + // via Pick so a signature change there propagates here instead of drifting. + Pick< + Parameters[0], + | "displayX" + | "displayY" + | "displayW" + | "displayH" + | "displayR" + | "manualOffsetEditingDisabled" + | "manualSizeEditingDisabled" + | "manualRotationEditingDisabled" + | "commitManualOffset" + | "commitManualSize" + | "commitManualRotation" + | "gsapAnimId" + | "navKeyframes" + | "currentPct" + | "animIdForProp" + | "gsapRuntimeValues" + | "elStart" + | "elDuration" + | "onCommitAnimatedProperty" + | "onCommitAnimatedProperties" + | "onSeekToTime" + | "onRemoveKeyframe" + | "onConvertToKeyframes" + > & { + element: DomEditSelection; + styles: Record; + sections: EditingSections; + sourceLabel: string; + gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null; + showEditableSections: boolean; + selectedElementHidden: boolean; + selectedElementId: string | null; + clipboardCopied: boolean; + onCopyElementInfo: () => void; + }) { // Lazy initializer: pick whichever group actually renders for this element // (Text if text-editable, else Style if style-editable, else none open) so a // style-only element doesn't start with everything collapsed. Only runs on @@ -110,7 +165,7 @@ export function PropertyPanelFlat({ // switching the selection re-mounts this component and re-derives the // default instead of preserving stale state across unrelated elements. const [openGroupId, setOpenGroupId] = useState(() => - isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "", + isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "layout", ); const [pinnedGroupIds, setPinnedGroupIds] = useState([]); @@ -122,6 +177,9 @@ export function PropertyPanelFlat({ setPinnedGroupIds((current) => current.includes(groupId) ? current.filter((id) => id !== groupId) : [...current, groupId], ); + // Trivial percentage→time seek, derived here rather than threaded from + // PropertyPanel (keeps that file under its 600-LOC gate). + const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration); return (
@@ -185,6 +243,50 @@ export function PropertyPanelFlat({ )} + toggleOpen("layout")} + onTogglePin={() => togglePin("layout")} + accessory={drag values to scrub} + summary={`${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`} + > + + + {sections.timing && ( )}
diff --git a/packages/studio/src/components/editor/gsapLivePreview.ts b/packages/studio/src/components/editor/gsapLivePreview.ts new file mode 100644 index 0000000000..b6daaa5bc8 --- /dev/null +++ b/packages/studio/src/components/editor/gsapLivePreview.ts @@ -0,0 +1,22 @@ +import type { DomEditSelection } from "./domEditingTypes"; + +/** + * Build the "live preview" callback the 3D-transform sub-view fires while a + * value is being dragged: apply a gsap.set() to the matching node inside the + * preview iframe so the edit is reflected immediately, before it's committed. + * + * Extracted so the identical closure exists once — shared by the legacy + * PropertyPanel Layout section and the flat Layout group (PropertyPanelFlat). + */ +export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameElement | null }) { + return (el: DomEditSelection, props: Record) => { + const iframe = iframeRef.current; + const win = iframe?.contentWindow as + | { gsap?: { set: (t: Element, v: Record) => void } } + | null + | undefined; + const sel = el.id ? `#${el.id}` : el.selector; + const node = sel ? iframe?.contentDocument?.querySelector(sel) : null; + if (win?.gsap && node) win.gsap.set(node, props); + }; +} diff --git a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx index 94e8d57ebd..a6c3b91110 100644 --- a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx @@ -47,6 +47,7 @@ export function StyleSections({ onSetStyle, onImportAssets, gsapBorderRadius, + hideFlex = false, }: { projectId: string; element: DomEditSelection; @@ -55,6 +56,10 @@ export function StyleSections({ onSetStyle: (prop: string, value: string) => void | Promise; onImportAssets?: (files: FileList) => Promise; gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null; + // When true, the Flex `Section` is suppressed. The flat inspector renders + // its own Flex controls inside the Layout group (LayoutFlexBlock), so the + // flat path passes this to avoid a double-render. Non-flat callers omit it. + hideFlex?: boolean; }) { const styleEditingDisabled = !element.capabilities.canEditStyles; const isFlex = styles.display === "flex" || styles.display === "inline-flex"; @@ -145,7 +150,7 @@ export function StyleSections({ return ( <> - {isFlex && ( + {isFlex && !hideFlex && (
} defaultCollapsed>
Date: Thu, 9 Jul 2026 11:55:16 -0700 Subject: [PATCH 06/11] feat(studio): add FlatTimingRow for the flat Motion group --- .../propertyPanelFlatMotionSection.test.tsx | 104 ++++++++++++++++++ .../editor/propertyPanelFlatMotionSection.tsx | 81 ++++++++++++++ .../editor/propertyPanelTimingSection.tsx | 2 +- 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx new file mode 100644 index 0000000000..e1d87ed3c3 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatTimingRow } from "./propertyPanelFlatMotionSection"; +import type { DomEditSelection } from "./domEditing"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function baseElement(overrides: Partial = {}): DomEditSelection { + return { + element: document.createElement("div"), + id: "hero", + selector: "#hero", + label: "Hero", + tagName: "div", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 0, y: 0, width: 100, height: 100 }, + textContent: "", + dataAttributes: { start: "8", duration: "4" }, + inlineStyles: {}, + computedStyles: {}, + textFields: [], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + ...overrides, + } as DomEditSelection; +} + +function renderInto(node: React.ReactElement) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return { host, root }; +} + +describe("FlatTimingRow", () => { + it("renders Start, End, and Duration from the element's data attributes", () => { + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Start"); + expect(host.textContent).toContain("End"); + expect(host.textContent).toContain("Duration"); + // Values render inside s (CommitField), not as text nodes, so they + // don't show up in textContent — assert on the rendered input values, + // in the same Start/End/Duration order the row is built in. + const inputs = host.querySelectorAll("input"); + expect(inputs[0]?.value).toBe("8.00s"); + expect(inputs[1]?.value).toBe("12.00s"); + expect(inputs[2]?.value).toBe("4.00s"); + act(() => root.unmount()); + }); + + it("shows the inferred note when duration is derived from animations, not authored", () => { + const onSetAttribute = vi.fn(); + const element = baseElement({ dataAttributes: { start: "0", duration: "0" } }); + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Inferred"); + act(() => root.unmount()); + }); + + it("commits a Start edit through onSetAttribute", () => { + const onSetAttribute = vi.fn(); + const { host, root } = renderInto( + , + ); + const startInput = host.querySelectorAll("input")[0]; + if (!startInput) throw new Error("expected a Start input"); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(startInput, "10s"); + startInput.dispatchEvent(new Event("input", { bubbles: true })); + startInput.dispatchEvent(new Event("focusout", { bubbles: true })); + }); + expect(onSetAttribute).toHaveBeenCalledWith("start", "10.00"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx new file mode 100644 index 0000000000..8312f1ae2c --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx @@ -0,0 +1,81 @@ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "./domEditing"; +import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; +import { parseTimingValue } from "./propertyPanelTimingSection"; +import { CommitField } from "./propertyPanelPrimitives"; + +function deriveTimingFromAnimations( + animations: GsapAnimation[], +): { start: number; duration: number } | null { + let lo = Infinity; + let hi = -Infinity; + for (const a of animations) { + const s = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0); + const d = a.duration ?? 0; + lo = Math.min(lo, s); + hi = Math.max(hi, s + d); + } + if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return null; + return { start: lo, duration: hi - lo }; +} + +export function FlatTimingRow({ + element, + animations = [], + onSetAttribute, +}: { + element: DomEditSelection; + animations?: GsapAnimation[]; + onSetAttribute: (attr: string, value: string) => void | Promise; +}) { + const explicitStart = Number.parseFloat(element.dataAttributes.start ?? "0") || 0; + const explicitDuration = + Number.parseFloat( + element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0", + ) || 0; + + const derived = explicitDuration > 0 ? null : deriveTimingFromAnimations(animations); + const start = derived ? derived.start : explicitStart; + const duration = derived ? derived.duration : explicitDuration; + const end = start + duration; + + const commitStart = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null) return; + void onSetAttribute("start", parsed.toFixed(2)); + }; + + const commitDuration = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null || parsed <= 0) return; + void onSetAttribute("duration", parsed.toFixed(2)); + }; + + const commitEnd = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null || parsed <= start) return; + void onSetAttribute("duration", (parsed - start).toFixed(2)); + }; + + const cell = (label: string, value: string, onCommit: (next: string) => void) => ( +
+ {label} + + + +
+ ); + + return ( +
+ {cell("Start", formatTimingValue(start), commitStart)} + {cell("End", formatTimingValue(end), commitEnd)} + {cell("Duration", formatTimingValue(duration), commitDuration)} + {derived && ( +

+ Inferred from this element's animation — edit to pin an explicit clip range. +

+ )} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelTimingSection.tsx b/packages/studio/src/components/editor/propertyPanelTimingSection.tsx index 4b6f6adc9f..0b71bc5514 100644 --- a/packages/studio/src/components/editor/propertyPanelTimingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelTimingSection.tsx @@ -4,7 +4,7 @@ import type { DomEditSelection } from "./domEditing"; import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { MetricField, Section } from "./propertyPanelPrimitives"; -function parseTimingValue(input: string): number | null { +export function parseTimingValue(input: string): number | null { const cleaned = input.replace(/s$/i, "").trim(); const parsed = Number.parseFloat(cleaned); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; From 2ac9c3bfe8ea4a9dea65bea6de63266f3cc4728b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 12:06:31 -0700 Subject: [PATCH 07/11] feat(studio): add flat collapsed-header variant to AnimationCard --- .../components/editor/AnimationCard.test.tsx | 134 ++++++++++++++++++ .../src/components/editor/AnimationCard.tsx | 23 ++- 2 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 packages/studio/src/components/editor/AnimationCard.test.tsx diff --git a/packages/studio/src/components/editor/AnimationCard.test.tsx b/packages/studio/src/components/editor/AnimationCard.test.tsx new file mode 100644 index 0000000000..63562fe734 --- /dev/null +++ b/packages/studio/src/components/editor/AnimationCard.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AnimationCard } from "./AnimationCard"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function baseAnimation(overrides: Partial = {}): GsapAnimation { + return { + id: "anim-1", + method: "to", + position: 0.8, + duration: 1.2, + ease: "power2.out", + properties: { opacity: 1 }, + ...overrides, + } as GsapAnimation; +} + +const noop = () => {}; + +describe("AnimationCard flat branch", () => { + it("renders a mint border-left and panel-token colors when flat", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const card = host.querySelector('[data-flat-effect-card="true"]'); + expect(card).not.toBeNull(); + expect(card?.className).toContain("border-panel-accent"); + act(() => root.unmount()); + }); + + it("still renders the legacy (non-flat) appearance when flat is omitted", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.querySelector('[data-flat-effect-card="true"]')).toBeNull(); + expect(host.textContent).toContain("power2.out"); + act(() => root.unmount()); + }); + + it("toggles expanded state when the collapsed header button is clicked, in both modes", () => { + for (const flat of [false, true]) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.textContent).not.toContain("Remove"); + const button = host.querySelector("button"); + expect(button).not.toBeNull(); + act(() => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(host.textContent).toContain("Remove"); + act(() => root.unmount()); + } + }); + + it("invokes onDeleteAnimation with the animation id when Remove is clicked, in flat mode", () => { + const onDeleteAnimation = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const buttons = Array.from(host.querySelectorAll("button")); + const removeButton = buttons.find((b) => b.textContent === "Remove"); + expect(removeButton).not.toBeUndefined(); + act(() => { + removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onDeleteAnimation).toHaveBeenCalledWith("anim-1"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 0a0a327e7a..97f8efa404 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -21,12 +21,14 @@ import { interface AnimationCardProps extends GsapAnimationEditCallbacks { animation: GsapAnimation; defaultExpanded: boolean; + flat?: boolean; } // fallow-ignore-next-line complexity export const AnimationCard = memo(function AnimationCard({ animation, defaultExpanded, + flat, onUpdateProperty, onUpdateMeta, onDeleteAnimation, @@ -150,7 +152,14 @@ export const AnimationCard = memo(function AnimationCard({ ); return ( -
+
); } + +export function FlatMotionSection({ + element, + animations, + showTiming, + showEffects, + multipleTimelines, + unsupportedTimelinePattern, + onSetAttribute, + onAddAnimation, + ...callbacks +}: { + element: DomEditSelection; + animations: GsapAnimation[]; + showTiming: boolean; + showEffects: boolean; + multipleTimelines?: boolean; + unsupportedTimelinePattern?: boolean; + onSetAttribute: (attr: string, value: string) => void | Promise; + onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; +} & GsapAnimationEditCallbacks) { + const [addMenuOpen, setAddMenuOpen] = useState(false); + + return ( +
+ {showTiming && ( + + )} + {showEffects && ( + <> + {multipleTimelines && ( +

+ This file has multiple GSAP timelines. Animation editing is disabled to prevent data + loss — consolidate into a single timeline to enable editing. +

+ )} + {unsupportedTimelinePattern && ( +

+ This timeline uses a computed key the editor can't resolve statically. +

+ )} + {!multipleTimelines && !unsupportedTimelinePattern && ( +
+ {animations.map((anim, index) => ( + + ))} +
+ {addMenuOpen ? ( +
+ {ADD_METHODS.map((method) => ( + + ))} + +
+ ) : ( + + )} +
+
+ )} + + )} +
+ ); +} From 149a07711864438db234c63053f626afd4d34089 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 12:33:34 -0700 Subject: [PATCH 09/11] feat(studio): wire the flat Motion group into the one-open accordion --- .../components/editor/PropertyPanel.test.tsx | 89 +++++++++++++ .../components/editor/PropertyPanelFlat.tsx | 118 ++++++++++++++++-- 2 files changed, 196 insertions(+), 11 deletions(-) diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index f43f16772d..a86621c76b 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 d606be1de9..b1d57d097b 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 && ( Date: Thu, 9 Jul 2026 12:58:00 -0700 Subject: [PATCH 10/11] fix(studio): reconcile keyframe-seek timing basis between flat Layout and Motion groups Whole-plan coherence review (Plan 3a Layout + Plan 3b Motion) found that Layout's keyframe gutter and Motion's Timing row independently derived an element's start/duration and disagreed whenever an element had animations but no explicit data-duration: Motion correctly inferred the range from the element's GSAP tweens, while Layout's keyframe gutter fell back to a naive `duration ?? 1`, so clicking a keyframe percentage in Layout could seek to a different absolute time than what Motion's Timing row displayed. Extract deriveElementTiming (propertyPanelFlatTimingDerivation.ts) as the single shared basis both paths now consume: FlatTimingRow (Motion) and PropertyPanelFlat's own elStart/elDuration (Layout's keyframe gutter and 3D Transform block). PropertyPanelFlat now recomputes this basis itself from its own element/gsapAnimations props instead of trusting the parent's naive value, so PropertyPanel.tsx (and its legacy non-flat panel) is untouched. Co-Authored-By: Claude Sonnet 5 --- .../components/editor/PropertyPanel.test.tsx | 96 +++++++++++++++++++ .../components/editor/PropertyPanelFlat.tsx | 15 ++- .../editor/propertyPanelFlatMotionSection.tsx | 26 +---- .../propertyPanelFlatTimingDerivation.test.ts | 71 ++++++++++++++ .../propertyPanelFlatTimingDerivation.ts | 59 ++++++++++++ 5 files changed, 241 insertions(+), 26 deletions(-) create mode 100644 packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.test.ts create mode 100644 packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.ts diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index a86621c76b..27c1febc84 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -143,6 +143,40 @@ function animatedElement() { }; } +// Inferred-timing fixture (whole-plan coherence fix): NO explicit data-start +// or data-duration — sections.timing must turn on via animationCount (fed +// from gsapAnimations.length), not an authored attribute, so both the Motion +// Timing row and the Layout keyframe gutter are forced to infer the range +// from the element's own GSAP tween instead of reading it off an attribute. +function inferredMotionElement() { + return { + ...baseElement(), + id: "inferred-anim", + selector: "#inferred-anim", + label: "Inferred Anim", + }; +} + +// A single "to" tween running from t=2 to t=5 (position 2, duration 3), with +// keyframes on "x" at 0/50/100% — enough to drive both FlatTimingRow's +// inference and the Layout "x" row's keyframe-seek gutter. +const INFERRED_TIMING_ANIMATION = { + id: "a1", + targetSelector: "#inferred-anim", + method: "to", + position: 2, + duration: 3, + properties: { x: 100 }, + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 50 } }, + { percentage: 100, properties: { x: 100 } }, + ], + }, +} as never; + async function renderPanel( flatEnabled: boolean, elementOverride: ReturnType = baseElement(), @@ -418,3 +452,65 @@ describe("PropertyPanel — Motion group (Plan 3b)", () => { RENDER_TIMEOUT_MS, ); }); + +// Whole-plan coherence fix: Layout's keyframe-seek basis and Motion's Timing +// row basis must agree on the same start/duration for an element that has +// animations but no explicit data-duration — before the fix, Layout fell back +// to a naive `duration ?? 1` while Motion correctly inferred the range from +// the tween (position 2, duration 3 -> start 2 / duration 3 / end 5). +describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan coherence fix)", () => { + it( + "Motion's Timing row shows the inferred start/end/duration for an element with animations but no explicit duration", + async () => { + const { host, root } = await renderPanel(true, inferredMotionElement(), { + gsapAnimations: [INFERRED_TIMING_ANIMATION], + }); + openFlatGroup(host, "Motion"); + const motionGroup = host.querySelector('[data-flat-group-open="true"]'); + if (!motionGroup) throw new Error("expected the Motion group to be open"); + expect(motionGroup.textContent).toContain("Inferred"); + const inputs = motionGroup.querySelectorAll("input"); + // FlatTimingRow renders Start, End, Duration in that order. + expect(inputs[0]?.value).toBe("2.00s"); + expect(inputs[1]?.value).toBe("5.00s"); + expect(inputs[2]?.value).toBe("3.00s"); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); + + it( + "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)", + async () => { + const onSeekToTime = vi.fn(); + const { host, root } = await renderPanel(true, inferredMotionElement(), { + gsapAnimations: [INFERRED_TIMING_ANIMATION], + onSeekToTime, + }); + openFlatGroup(host, "Layout"); + const layoutGroup = host.querySelector('[data-flat-group-open="true"]'); + if (!layoutGroup) throw new Error("expected the Layout group to be open"); + + const xRow = Array.from(layoutGroup.querySelectorAll(".group")).find( + (el) => el.querySelector("span")?.textContent === "X", + ); + if (!xRow) throw new Error("expected an X row"); + const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]'); + if (!gutter) throw new Error("expected a keyframe gutter on the X row"); + // The diamond button always carries a `title`; the two plain arrow + // buttons don't. At currentPct=0 with keyframes at 0/50/100%, the prev + // arrow is disabled (no earlier keyframe) and the next arrow seeks to + // the 50% keyframe — exactly the case the coherence bug affected. + const nextArrow = Array.from(gutter.querySelectorAll("button")).find( + (b) => !b.title && !b.disabled, + ); + if (!nextArrow) throw new Error("expected an enabled next-keyframe arrow button"); + act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + // Same basis as the Timing row: start 2 + 50% * duration 3 = 3.5. + expect(onSeekToTime).toHaveBeenCalledWith(3.5); + 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 b1d57d097b..6c3ee483a1 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -11,6 +11,7 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection"; import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection"; import { FlatMotionSection } from "./propertyPanelFlatMotionSection"; +import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; import { createGsapLivePreview } from "./gsapLivePreview"; import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections"; import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability"; @@ -96,8 +97,12 @@ export function PropertyPanelFlat({ currentPct, animIdForProp, gsapRuntimeValues, - elStart, - elDuration, + // Renamed: PropertyPanel.tsx still computes/passes these for its own legacy + // (non-flat) panel, but the flat path recomputes its own basis below via + // deriveElementTiming so it agrees with Motion's Timing row — ignore the + // parent's naive `elDuration ?? 1` fallback. + elStart: _elStart, + elDuration: _elDuration, onCommitAnimatedProperty, onCommitAnimatedProperties, onSeekToTime, @@ -222,6 +227,12 @@ export function PropertyPanelFlat({ setPinnedGroupIds((current) => current.includes(groupId) ? current.filter((id) => id !== groupId) : [...current, groupId], ); + // Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) — + // must agree with Motion's Timing row (FlatTimingRow), which infers the + // range from animations when there's no explicit data-duration. Computed + // here (not threaded from PropertyPanel) both to keep that file under its + // 600-LOC gate and because element/gsapAnimations are already in scope. + const { start: elStart, duration: elDuration } = deriveElementTiming(element, gsapAnimations); // Trivial percentage→time seek, derived here rather than threaded from // PropertyPanel (keeps that file under its 600-LOC gate). const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx index 01fde1ffe4..0eb9147609 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx @@ -7,21 +7,7 @@ import { CommitField } from "./propertyPanelPrimitives"; import { AnimationCard } from "./AnimationCard"; import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants"; import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks"; - -function deriveTimingFromAnimations( - animations: GsapAnimation[], -): { start: number; duration: number } | null { - let lo = Infinity; - let hi = -Infinity; - for (const a of animations) { - const s = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0); - const d = a.duration ?? 0; - lo = Math.min(lo, s); - hi = Math.max(hi, s + d); - } - if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return null; - return { start: lo, duration: hi - lo }; -} +import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; export function FlatTimingRow({ element, @@ -32,15 +18,7 @@ export function FlatTimingRow({ animations?: GsapAnimation[]; onSetAttribute: (attr: string, value: string) => void | Promise; }) { - const explicitStart = Number.parseFloat(element.dataAttributes.start ?? "0") || 0; - const explicitDuration = - Number.parseFloat( - element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0", - ) || 0; - - const derived = explicitDuration > 0 ? null : deriveTimingFromAnimations(animations); - const start = derived ? derived.start : explicitStart; - const duration = derived ? derived.duration : explicitDuration; + const { start, duration, inferred: derived } = deriveElementTiming(element, animations); const end = start + duration; const commitStart = (nextValue: string) => { diff --git a/packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.test.ts b/packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.test.ts new file mode 100644 index 0000000000..910934f800 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; +import type { DomEditSelection } from "./domEditingTypes"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; + +function withDataAttributes( + dataAttributes: Record, +): Pick { + return { dataAttributes }; +} + +describe("deriveElementTiming", () => { + it("uses the explicit data-start/data-duration attributes when duration is authored", () => { + const result = deriveElementTiming(withDataAttributes({ start: "8", duration: "4" })); + expect(result).toEqual({ start: 8, duration: 4, inferred: false }); + }); + + it("infers start/duration from animations when there is no explicit data-duration", () => { + const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation]; + const result = deriveElementTiming( + withDataAttributes({ start: "0", duration: "0" }), + animations, + ); + expect(result).toEqual({ start: 2, duration: 3, inferred: true }); + }); + + it("spans the earliest tween start to the latest tween end across multiple animations", () => { + const animations = [ + { position: 1, duration: 2 } as unknown as GsapAnimation, // 1 -> 3 + { position: 2, duration: 4 } as unknown as GsapAnimation, // 2 -> 6 + ]; + const result = deriveElementTiming(withDataAttributes({}), animations); + expect(result).toEqual({ start: 1, duration: 5, inferred: true }); + }); + + it("prefers an explicit data-duration over inference even when animations exist", () => { + const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation]; + const result = deriveElementTiming( + withDataAttributes({ start: "0", duration: "10" }), + animations, + ); + expect(result).toEqual({ start: 0, duration: 10, inferred: false }); + }); + + it("falls back to hf-authored-duration when data-duration is absent", () => { + const result = deriveElementTiming( + withDataAttributes({ start: "1", "hf-authored-duration": "6" }), + ); + expect(result).toEqual({ start: 1, duration: 6, inferred: false }); + }); + + it("returns a zero-duration, non-inferred result with no attributes and no animations", () => { + const result = deriveElementTiming(withDataAttributes({})); + expect(result).toEqual({ start: 0, duration: 0, inferred: false }); + }); + + // This is the exact bug from the whole-plan coherence review: Layout's + // keyframe-seek basis must land on the same absolute time that Motion's + // Timing row displays as the element's midpoint. + it("agrees with a keyframe-percentage seek: 50% lands on the same midpoint the Timing row would show", () => { + const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation]; + const timing = deriveElementTiming( + withDataAttributes({ start: "0", duration: "0" }), + animations, + ); + const seekTimeAt50Pct = timing.start + (50 / 100) * timing.duration; + const timingRowMidpoint = timing.start + timing.duration / 2; + expect(seekTimeAt50Pct).toBe(timingRowMidpoint); + expect(seekTimeAt50Pct).toBe(3.5); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.ts b/packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.ts new file mode 100644 index 0000000000..a4cb30121c --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.ts @@ -0,0 +1,59 @@ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "./domEditingTypes"; + +/** + * The single source of truth for an element's clip start/duration in the flat + * inspector. Both the Motion group's Timing row (`FlatTimingRow`) and the + * Layout group's keyframe gutter (fed via `elStart`/`elDuration` from + * `PropertyPanel.tsx` through `PropertyPanelFlat.tsx`) must derive this the + * same way — otherwise a keyframe-percentage seek in Layout lands on a + * different absolute time than the range Motion displays for the same + * element (found by the Plan 3a+3b whole-plan coherence review). + * + * Precedence: an explicit `data-duration` (or `data-hf-authored-duration`) + * wins outright. Only when neither is present do we infer the range from the + * element's own GSAP tweens (earliest tween start → latest tween end). + * + * Scoped to the FLAT inspector only — the legacy (non-flat) panel keeps its + * own, unrelated `elStart`/`elDuration ?? 1` computation in `PropertyPanel.tsx` + * untouched. + */ +export interface ElementTiming { + start: number; + duration: number; + /** True when duration/start came from `deriveTimingFromAnimations`, not an authored attribute. */ + inferred: boolean; +} + +function deriveTimingFromAnimations( + animations: GsapAnimation[], +): { start: number; duration: number } | null { + let lo = Infinity; + let hi = -Infinity; + for (const a of animations) { + const s = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0); + const d = a.duration ?? 0; + lo = Math.min(lo, s); + hi = Math.max(hi, s + d); + } + if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return null; + return { start: lo, duration: hi - lo }; +} + +export function deriveElementTiming( + element: Pick, + animations: GsapAnimation[] = [], +): ElementTiming { + const explicitStart = Number.parseFloat(element.dataAttributes.start ?? "0") || 0; + const explicitDuration = + Number.parseFloat( + element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0", + ) || 0; + + const derived = explicitDuration > 0 ? null : deriveTimingFromAnimations(animations); + return { + start: derived ? derived.start : explicitStart, + duration: derived ? derived.duration : explicitDuration, + inferred: derived !== null, + }; +} From 906d74066c7e1e42819f7c5c0225852a2bd71d55 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 13:17:42 -0700 Subject: [PATCH 11/11] fix(studio): recompute keyframe currentPct from the corrected timing basis in the flat Layout group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 684ec4e87: that fix corrected the seek target for Layout's keyframe gutter via deriveElementTiming, but currentPct — which drives KeyframeNavigation's diamond active/inactive state and prev/next arrow targeting — still used PropertyPanel's naive elStart=0/elDuration=1 basis. For an element with animations but no explicit data-duration, seeking to a keyframe's real absolute time no longer lit that keyframe's diamond as active, and the prev/next arrows targeted the wrong keyframes. Thread currentTime into PropertyPanelFlat (swapping the now-redundant currentPct prop 1-for-1, so PropertyPanel.tsx's line count is unchanged) and recompute currentPct there from the same deriveElementTiming basis already used for the seek fix. Co-Authored-By: Claude Sonnet 5 --- .../components/editor/PropertyPanel.test.tsx | 111 +++++++++++++++++- .../src/components/editor/PropertyPanel.tsx | 2 +- .../components/editor/PropertyPanelFlat.tsx | 12 +- 3 files changed, 117 insertions(+), 8 deletions(-) diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index 27c1febc84..298773af51 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -181,6 +181,7 @@ async function renderPanel( flatEnabled: boolean, elementOverride: ReturnType = baseElement(), propsOverride: Partial = {}, + currentTime?: number, ) { vi.resetModules(); vi.doMock("./manualEditingAvailability", async () => { @@ -189,6 +190,13 @@ async function renderPanel( ); return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: flatEnabled }; }); + // Seed the playhead on the SAME store instance PropertyPanel.tsx will read via + // usePlayerStore (module-fresh since the resetModules() above) — must happen + // before PropertyPanel is imported/rendered so its initial render sees it. + if (currentTime !== undefined) { + const { usePlayerStore } = await import("../../player/store/playerStore"); + usePlayerStore.getState().setCurrentTime(currentTime); + } const { PropertyPanel } = await import("./PropertyPanel"); const host = document.createElement("div"); document.body.append(host); @@ -483,10 +491,20 @@ describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan cohe "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)", async () => { const onSeekToTime = vi.fn(); - const { host, root } = await renderPanel(true, inferredMotionElement(), { - gsapAnimations: [INFERRED_TIMING_ANIMATION], - onSeekToTime, - }); + // Seed the playhead at the clip's real start (t=2, the 0% keyframe's + // absolute time) — now that the follow-up fix also recomputes + // `currentPct` from the corrected elStart/elDuration basis, "current + // position is at the 0% keyframe" must be expressed as an actual t=2 + // seek rather than relying on the store's untouched t=0 default (which, + // post-fix, resolves to a currentPct of -66.7% — well outside the 0% + // keyframe's tolerance window, and no longer "the case the coherence + // bug affected" that this test documents). + const { host, root } = await renderPanel( + true, + inferredMotionElement(), + { gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime }, + 2, + ); openFlatGroup(host, "Layout"); const layoutGroup = host.querySelector('[data-flat-group-open="true"]'); if (!layoutGroup) throw new Error("expected the Layout group to be open"); @@ -498,7 +516,7 @@ describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan cohe const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]'); if (!gutter) throw new Error("expected a keyframe gutter on the X row"); // The diamond button always carries a `title`; the two plain arrow - // buttons don't. At currentPct=0 with keyframes at 0/50/100%, the prev + // buttons don't. At currentPct=0 (playhead on the 0% keyframe), the prev // arrow is disabled (no earlier keyframe) and the next arrow seeks to // the 50% keyframe — exactly the case the coherence bug affected. const nextArrow = Array.from(gutter.querySelectorAll("button")).find( @@ -514,3 +532,86 @@ describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan cohe RENDER_TIMEOUT_MS, ); }); + +// Follow-up fix (review of 684ec4e87): the seek-basis fix above corrected +// WHERE a keyframe click seeks to, but `currentPct` — the value that drives +// KeyframeNavigation's diamond active/inactive state and prev/next arrow +// targeting — still used the OLD naive basis. For an inferred-duration +// element, seeking to a keyframe's actual absolute time no longer lit that +// keyframe's diamond as active. Prove the round-trip here: seek to the exact +// absolute time of the 50% keyframe (2 + 0.5*3 = 3.5) and confirm its diamond +// renders "active" (title="Remove x keyframe"), not "inactive"/"ghost". +describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up fix)", () => { + it( + "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)", + async () => { + const { host, root } = await renderPanel( + true, + inferredMotionElement(), + { gsapAnimations: [INFERRED_TIMING_ANIMATION] }, + 3.5, + ); + openFlatGroup(host, "Layout"); + const layoutGroup = host.querySelector('[data-flat-group-open="true"]'); + if (!layoutGroup) throw new Error("expected the Layout group to be open"); + + const xRow = Array.from(layoutGroup.querySelectorAll(".group")).find( + (el) => el.querySelector("span")?.textContent === "X", + ); + if (!xRow) throw new Error("expected an X row"); + const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]'); + if (!gutter) throw new Error("expected a keyframe gutter on the X row"); + const diamond = gutter.querySelector("button[title]"); + if (!diamond) throw new Error("expected a keyframe diamond button"); + // KeyframeDiamond's title mapping: active -> "Remove ... keyframe", + // inactive -> "Add ... keyframe", ghost -> "Convert ... to keyframes". + // Before this fix, currentPct was computed against the naive + // elStart=0/elDuration=1 basis, so t=3.5 produced currentPct=350% — + // nowhere near the 50% keyframe within KeyframeNavigation's tolerance — + // and the diamond stayed "inactive" even though the playhead was + // exactly on that keyframe's real time. + expect(diamond.title).toBe("Remove x keyframe"); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); + + it( + "prev/next arrows re-center on the current keyframe once currentPct agrees with the corrected seek basis", + async () => { + const onSeekToTime = vi.fn(); + const { host, root } = await renderPanel( + true, + inferredMotionElement(), + { gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime }, + 3.5, + ); + openFlatGroup(host, "Layout"); + const layoutGroup = host.querySelector('[data-flat-group-open="true"]'); + if (!layoutGroup) throw new Error("expected the Layout group to be open"); + + const xRow = Array.from(layoutGroup.querySelectorAll(".group")).find( + (el) => el.querySelector("span")?.textContent === "X", + ); + if (!xRow) throw new Error("expected an X row"); + const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]'); + if (!gutter) throw new Error("expected a keyframe gutter on the X row"); + const buttons = Array.from(gutter.querySelectorAll("button")); + const [prevArrow, , nextArrow] = buttons; + if (!prevArrow || !nextArrow) throw new Error("expected prev/next arrow buttons"); + // At the 50% keyframe (t=3.5), prev should target the 0% keyframe + // (absolute t=2) and next should target the 100% keyframe (absolute + // t=5) — both only resolvable once currentPct agrees with elStart=2/ + // elDuration=3, the same basis the seek fix already uses. + expect(prevArrow.disabled).toBe(false); + act(() => prevArrow.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSeekToTime).toHaveBeenLastCalledWith(2); + + expect(nextArrow.disabled).toBe(false); + act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSeekToTime).toHaveBeenLastCalledWith(5); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 97218c3901..1a97fe8146 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -293,7 +293,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro commitManualRotation={commitManualRotation} gsapAnimId={gsapAnimId} navKeyframes={navKeyframes} - currentPct={currentPct} + currentTime={currentTime} animIdForProp={animIdForProp} gsapRuntimeValues={gsap3dValues} elStart={elStart} diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 6c3ee483a1..762d50c198 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -94,7 +94,7 @@ export function PropertyPanelFlat({ commitManualRotation, gsapAnimId, navKeyframes, - currentPct, + currentTime, animIdForProp, gsapRuntimeValues, // Renamed: PropertyPanel.tsx still computes/passes these for its own legacy @@ -186,7 +186,6 @@ export function PropertyPanelFlat({ | "commitManualRotation" | "gsapAnimId" | "navKeyframes" - | "currentPct" | "animIdForProp" | "gsapRuntimeValues" | "elStart" @@ -207,6 +206,7 @@ export function PropertyPanelFlat({ selectedElementId: string | null; clipboardCopied: boolean; onCopyElementInfo: () => void; + currentTime: number; }) { // Lazy initializer: pick whichever group actually renders for this element // (Text if text-editable, else Style if style-editable, else none open) so a @@ -236,6 +236,14 @@ export function PropertyPanelFlat({ // Trivial percentage→time seek, derived here rather than threaded from // PropertyPanel (keeps that file under its 600-LOC gate). const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration); + // Playhead position within the SAME corrected elStart/elDuration basis as + // seekFromKfPct above — recomputed here (not threaded as `currentPct` from + // PropertyPanel, which still derives it against its own naive basis for the + // legacy panel) so KeyframeNavigation's diamond active-state and prev/next + // arrow targeting agree with where a keyframe click actually seeks to + // (follow-up fix to 684ec4e87, which corrected the seek basis but left this + // one still naive). + const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0; // Motion group double-gate — reproduces the legacy PropertyPanel gate exactly: // • Timing (sections.timing) shows via resolveEditingSections, same as today.