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 ( -
+
+ ))} + +
+ ) : ( + + )} +
+ + )} + + )} + + ); +} 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, + }; +} 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>
= 0 ? parsed : null;