Merge pull request #2122 from heygen-com/studio-flat-03-layout-motion

feat(studio): flat inspector — Layout + Motion groups
This commit is contained in:
Vance Ingalls
2026-07-14 16:03:05 -07:00
committed by GitHub
14 changed files with 1964 additions and 38 deletions
@@ -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> = {}): 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(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
flat
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
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(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
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(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
flat={flat || undefined}
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
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(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={true}
flat
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={onDeleteAnimation}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
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());
});
});
@@ -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 (
<div className="border-b border-neutral-800 pb-3">
<div
data-flat-effect-card={flat ? "true" : undefined}
className={
flat
? "border-b border-l-2 border-panel-accent/40 border-b-panel-hairline pb-3 pl-2"
: "border-b border-neutral-800 pb-3"
}
>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
@@ -162,13 +171,19 @@ export const AnimationCard = memo(function AnimationCard({
>
{methodLabel}
</span>
<span className="text-[11px] font-medium text-neutral-400" title="When this effect plays">
<span
className={`text-[11px] font-medium ${flat ? "text-panel-text-3" : "text-neutral-400"}`}
title="When this effect plays"
>
{typeof animation.position === "number"
? `${parseFloat(animation.position.toFixed(3))}s`
: animation.position}{" "}
{typeof endTime === "number" ? `${parseFloat(endTime.toFixed(3))}s` : endTime}
</span>
<span className="ml-auto text-[10px] text-neutral-500" title={easeName}>
<span
className={`ml-auto text-[10px] ${flat ? "text-panel-text-3" : "text-neutral-500"}`}
title={easeName}
>
{easeLabel}
</span>
<svg
@@ -176,7 +191,7 @@ export const AnimationCard = memo(function AnimationCard({
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className={`flex-shrink-0 text-neutral-500 transition-transform ${expanded ? "" : "-rotate-90"}`}
className={`flex-shrink-0 transition-transform ${flat ? "text-panel-text-5" : "text-neutral-500"} ${expanded ? "" : "-rotate-90"}`}
>
<path d="M2 3l3 4 3-4z" />
</svg>
@@ -114,9 +114,74 @@ 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" },
};
}
// 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" },
};
}
// 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<typeof baseElement> = baseElement(),
propsOverride: Partial<PropertyPanelProps> = {},
currentTime?: number,
) {
vi.resetModules();
vi.doMock("./manualEditingAvailability", async () => {
@@ -125,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);
@@ -138,6 +210,7 @@ async function renderPanel(
onSetStyle: vi.fn(),
onSetText: vi.fn(),
onSetAttributeLive: vi.fn(),
...propsOverride,
} as unknown as PropertyPanelProps;
act(() => {
root.render(<PropertyPanel {...props} />);
@@ -150,6 +223,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",
@@ -266,3 +351,267 @@ 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,
);
});
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,
);
});
// 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<HTMLInputElement>("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();
// 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");
const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".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 (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<HTMLButtonElement>("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,
);
});
// 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<HTMLElement>(".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<HTMLButtonElement>("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<HTMLElement>(".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<HTMLButtonElement>("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,
);
});
@@ -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}
currentTime={currentTime}
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<string, number>) => 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)}
/>
<div className="mt-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
@@ -3,18 +3,37 @@ 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 { FlatMotionSection } from "./propertyPanelFlatMotionSection";
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
import { createGsapLivePreview } from "./gsapLivePreview";
import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
import { TimingSection } from "./propertyPanelTimingSection";
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
import { MediaSection } from "./propertyPanelMediaSection";
type EditingSections = ReturnType<typeof resolveEditingSections>;
// Type-only fallback for the Motion effect-card callbacks. Used solely to
// satisfy FlatMotionSection's required-callback shape when the effect list is
// gated off (showEffects === false, so none of these are ever invoked). Keeps
// the gated-off path free of `!` non-null assertions — the real, narrowed
// handlers flow through only when the double-gate below passes.
const EMPTY_GSAP_EFFECT_HANDLERS = {
onAddAnimation: () => {},
onUpdateProperty: () => {},
onUpdateMeta: () => {},
onDeleteAnimation: () => {},
onAddProperty: () => {},
onRemoveProperty: () => {},
};
/**
* The flat "Ledger" inspector shell (design_handoff_studio_inspector).
*
@@ -22,10 +41,8 @@ type EditingSections = ReturnType<typeof resolveEditingSections>;
* (same one-directional-import precedent as FlatTextSection). Rendered only
* when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open/pin group state.
*
* Intentionally omits the Layout `Section` and `GsapAnimationSection` (Motion)
* — flattening those is Layout/Motion plan territory (plans 34). A text
* element with the flag on will not show Layout/Motion controls; that
* regression is scoped and acceptable for an unreleased, flag-gated feature.
* The Text/Style/Layout/Motion groups share the one-open accordion. The legacy
* Media and Color-Grading sections render unchanged below the flat groups.
*/
// fallow-ignore-next-line complexity
export function PropertyPanelFlat({
@@ -64,6 +81,49 @@ export function PropertyPanelFlat({
recordingState,
recordingDuration,
onToggleRecording,
displayX,
displayY,
displayW,
displayH,
displayR,
manualOffsetEditingDisabled,
manualSizeEditingDisabled,
manualRotationEditingDisabled,
commitManualOffset,
commitManualSize,
commitManualRotation,
gsapAnimId,
navKeyframes,
currentTime,
animIdForProp,
gsapRuntimeValues,
// 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,
onRemoveKeyframe,
onConvertToKeyframes,
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
onUpdateGsapProperty,
onUpdateGsapMeta,
onDeleteGsapAnimation,
onAddGsapProperty,
onRemoveGsapProperty,
onUpdateGsapFromProperty,
onAddGsapFromProperty,
onRemoveGsapFromProperty,
onAddGsapAnimation,
onSetArcPath,
onUpdateArcSegment,
onUnroll,
onUpdateKeyframeEase,
onSetAllKeyframeEases,
}: Pick<
PropertyPanelProps,
| "projectId"
@@ -88,21 +148,66 @@ 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"
> & {
element: DomEditSelection;
styles: Record<string, string>;
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<typeof FlatLayoutSection>[0],
| "displayX"
| "displayY"
| "displayW"
| "displayH"
| "displayR"
| "manualOffsetEditingDisabled"
| "manualSizeEditingDisabled"
| "manualRotationEditingDisabled"
| "commitManualOffset"
| "commitManualSize"
| "commitManualRotation"
| "gsapAnimId"
| "navKeyframes"
| "animIdForProp"
| "gsapRuntimeValues"
| "elStart"
| "elDuration"
| "onCommitAnimatedProperty"
| "onCommitAnimatedProperties"
| "onSeekToTime"
| "onRemoveKeyframe"
| "onConvertToKeyframes"
> & {
element: DomEditSelection;
styles: Record<string, string>;
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;
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
// style-only element doesn't start with everything collapsed. Only runs on
@@ -110,7 +215,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<string>(() =>
isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "",
isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "layout",
);
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
@@ -122,6 +227,60 @@ 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);
// 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.
// • The effect-card list shows only when STUDIO_GSAP_PANEL_ENABLED is on AND
// all five edit handlers are present (identical to PropertyPanel's legacy
// `<GsapAnimationSection>` guard).
// Computing the narrowed handler bundle inside the `&&`-guarded ternary lets
// TypeScript prove each handler non-undefined without a `!` assertion; the
// noop bundle only fills the type when the gate is off (never invoked, since
// FlatMotionSection guards every call behind showEffects).
const showMotionTiming = Boolean(sections.timing);
const gsapEffectHandlers =
STUDIO_GSAP_PANEL_ENABLED &&
onUpdateGsapProperty &&
onUpdateGsapMeta &&
onDeleteGsapAnimation &&
onAddGsapProperty &&
onAddGsapAnimation
? {
onAddAnimation: onAddGsapAnimation,
onUpdateProperty: onUpdateGsapProperty,
onUpdateMeta: onUpdateGsapMeta,
onDeleteAnimation: onDeleteGsapAnimation,
onAddProperty: onAddGsapProperty,
onRemoveProperty: onRemoveGsapProperty ?? (() => {}),
onUpdateFromProperty: onUpdateGsapFromProperty,
onAddFromProperty: onAddGsapFromProperty,
onRemoveFromProperty: onRemoveGsapFromProperty,
onSetArcPath,
onUpdateArcSegment,
onUnroll,
onUpdateKeyframeEase,
onSetAllKeyframeEases,
}
: null;
const showMotionEffects = gsapEffectHandlers !== null;
const showMotionGroup = showMotionTiming || showMotionEffects;
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
@@ -185,12 +344,70 @@ export function PropertyPanelFlat({
</FlatGroup>
)}
{sections.timing && (
<TimingSection
<FlatGroup
title="Layout"
isOpen={openGroupId === "layout" || pinnedGroupIds.includes("layout")}
isPinned={pinnedGroupIds.includes("layout")}
onToggleOpen={() => toggleOpen("layout")}
onTogglePin={() => togglePin("layout")}
accessory={<span className="text-[9px] text-panel-text-5">drag values to scrub</span>}
summary={`${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`}
>
<FlatLayoutSection
element={element}
animations={gsapAnimations}
onSetAttribute={onSetAttribute}
styles={styles}
onSetStyle={onSetStyle}
disabled={!element.capabilities.canEditStyles}
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}
seekFromKfPct={seekFromKfPct}
animIdForProp={animIdForProp}
resolveAnimIdForProp={animIdForProp}
gsapRuntimeValues={gsapRuntimeValues}
gsapKeyframes={navKeyframes}
elStart={elStart}
elDuration={elDuration}
onCommitAnimatedProperty={onCommitAnimatedProperty}
onCommitAnimatedProperties={onCommitAnimatedProperties}
onSeekToTime={onSeekToTime}
onRemoveKeyframe={onRemoveKeyframe}
onConvertToKeyframes={onConvertToKeyframes}
onLivePreviewProps={createGsapLivePreview(previewIframeRef ?? { current: null })}
/>
</FlatGroup>
{showMotionGroup && (
<FlatGroup
title="Motion"
isOpen={openGroupId === "motion" || pinnedGroupIds.includes("motion")}
isPinned={pinnedGroupIds.includes("motion")}
onToggleOpen={() => toggleOpen("motion")}
onTogglePin={() => togglePin("motion")}
summary={`${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`}
>
<FlatMotionSection
element={element}
animations={gsapAnimations}
showTiming={showMotionTiming}
showEffects={showMotionEffects}
multipleTimelines={gsapMultipleTimelines}
unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
onSetAttribute={onSetAttribute}
{...(gsapEffectHandlers ?? EMPTY_GSAP_EFFECT_HANDLERS)}
/>
</FlatGroup>
)}
{sections.colorGrading && (
<ColorGradingSection
@@ -229,6 +446,9 @@ export function PropertyPanelFlat({
onSetStyle={onSetStyle}
onImportAssets={onImportAssets}
gsapBorderRadius={gsapBorderRadius}
// Flex now lives in the flat Layout group (LayoutFlexBlock); suppress
// the legacy StyleSections Flex `Section` so it renders exactly once.
hideFlex
/>
)}
</div>
@@ -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<string, number>) => {
const iframe = iframeRef.current;
const win = iframe?.contentWindow as
| { gsap?: { set: (t: Element, v: Record<string, number>) => 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);
};
}
@@ -0,0 +1,293 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
FlatLayoutSection,
LayoutFlexBlock,
LayoutGeometryRows,
LayoutTransform3DBlock,
LayoutZIndexRow,
} 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<HTMLElement>(".group"));
const row = rows.find((el) => el.querySelector("span")?.textContent === label);
const input = row?.querySelector<HTMLInputElement>("input");
if (!input) throw new Error(`expected an input for row "${label}"`);
return input;
}
function baseGeometryProps(overrides: Partial<Parameters<typeof LayoutGeometryRows>[0]> = {}) {
return {
element: {} as never,
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(<LayoutGeometryRows {...baseGeometryProps()} />);
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(
<LayoutGeometryRows {...baseGeometryProps({ commitManualOffset })} />,
);
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(
<LayoutGeometryRows {...baseGeometryProps({ gsapAnimId: "anim-1", navKeyframes: null })} />,
);
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(
<LayoutGeometryRows
{...baseGeometryProps({
gsapAnimId: "anim-1",
navKeyframes: [{ percentage: 0, properties: { x: 0 } }],
})}
/>,
);
const full = host.querySelectorAll('[data-flat-kf-gutter="true"][style*="opacity: 1"]');
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(
<LayoutGeometryRows
{...baseGeometryProps({
element,
gsapAnimId: "anim-1",
navKeyframes: [{ percentage: 50, properties: { x: 5 } }],
currentPct: 0,
onCommitAnimatedProperty,
})}
/>,
);
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", () => {
it("renders the current z-index at the default tier and commits edits", () => {
const onSetStyle = vi.fn();
const { host, root } = renderInto(
<LayoutZIndexRow styles={{ "z-index": "3" }} onSetStyle={onSetStyle} />,
);
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(
<LayoutFlexBlock styles={{ display: "block" }} onSetStyle={vi.fn()} disabled={false} />,
);
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(
<LayoutFlexBlock
styles={{ display: "flex", "flex-direction": "row", gap: "8px" }}
onSetStyle={onSetStyle}
disabled={false}
/>,
);
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());
});
});
describe("LayoutTransform3DBlock", () => {
it("renders the nested 3D transform sub-view", () => {
const { host, root } = renderInto(
<LayoutTransform3DBlock
gsapRuntimeValues={{}}
gsapAnimId={null}
gsapKeyframes={null}
currentPct={0}
elStart={0}
elDuration={0}
element={{} as never}
onCommitAnimatedProperty={vi.fn()}
onCommitAnimatedProperties={vi.fn()}
onSeekToTime={vi.fn()}
onRemoveKeyframe={vi.fn()}
onConvertToKeyframes={vi.fn()}
/>,
);
// 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());
});
});
describe("FlatLayoutSection", () => {
it("renders geometry rows, z-index, flex (when applicable), and the 3D transform block in order", () => {
const { host, root } = renderInto(
<FlatLayoutSection
element={{} as never}
styles={{ display: "flex", "flex-direction": "row" }}
onSetStyle={vi.fn()}
disabled={false}
displayX={0}
displayY={0}
displayW={100}
displayH={100}
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={(p) => 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(
<FlatLayoutSection
element={{} as never}
styles={{ display: "block" }}
onSetStyle={vi.fn()}
disabled={false}
displayX={0}
displayY={0}
displayW={100}
displayH={100}
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={(p) => p}
gsapRuntimeValues={{}}
gsapKeyframes={null}
elStart={0}
elDuration={0}
onSeekToTime={vi.fn()}
/>,
);
expect(host.textContent).not.toContain("Flex");
act(() => root.unmount());
});
});
@@ -0,0 +1,371 @@
import { FlatRow, FlatSegmentedRow, FlatSelectRow } from "./propertyPanelFlatPrimitives";
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;
tweenPercentage?: number;
properties: Record<string, number | string>;
ease?: string;
}> | null;
interface GeometryRowsProps {
element: DomEditSelection;
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: DomEditSelection,
property: string,
value: number,
) => Promise<void>;
onRemoveKeyframe?: (animId: string, pct: number) => void;
onConvertToKeyframes?: (animId: string) => void;
}
function KeyframeGutter({
element,
property,
displayValue,
gsapAnimId,
navKeyframes,
currentPct,
seekFromKfPct,
animIdForProp,
onCommitAnimatedProperty,
onRemoveKeyframe,
onConvertToKeyframes,
}: {
property: string;
displayValue: number;
} & Pick<
GeometryRowsProps,
| "element"
| "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 (
<span data-flat-kf-gutter="true" style={{ opacity: hasKeyframesOnProp ? 1 : 0.3 }}>
<KeyframeNavigation
property={property}
keyframes={navKeyframes}
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty && void onCommitAnimatedProperty(element, property, displayValue)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp(property), pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp(property))}
/>
</span>
);
}
export function LayoutGeometryRows({
element,
displayX,
displayY,
displayW,
displayH,
displayR,
manualOffsetEditingDisabled,
manualSizeEditingDisabled,
manualRotationEditingDisabled,
commitManualOffset,
commitManualSize,
commitManualRotation,
gsapAnimId,
navKeyframes,
currentPct,
seekFromKfPct,
animIdForProp,
onCommitAnimatedProperty,
onRemoveKeyframe,
onConvertToKeyframes,
}: GeometryRowsProps) {
const gutterProps = {
element,
gsapAnimId,
navKeyframes,
currentPct,
seekFromKfPct,
animIdForProp,
onCommitAnimatedProperty,
onRemoveKeyframe,
onConvertToKeyframes,
};
return (
<>
<FlatRow
label="X"
value={formatPxMetricValue(displayX)}
tier={displayX === 0 ? "default" : "explicitCustom"}
disabled={manualOffsetEditingDisabled}
onCommit={(next) => commitManualOffset("x", next)}
suffix={<KeyframeGutter property="x" displayValue={displayX} {...gutterProps} />}
/>
<FlatRow
label="Y"
value={formatPxMetricValue(displayY)}
tier={displayY === 0 ? "default" : "explicitCustom"}
disabled={manualOffsetEditingDisabled}
onCommit={(next) => commitManualOffset("y", next)}
suffix={<KeyframeGutter property="y" displayValue={displayY} {...gutterProps} />}
/>
<FlatRow
label="W"
value={formatPxMetricValue(displayW)}
tier="default"
disabled={manualSizeEditingDisabled}
onCommit={(next) => commitManualSize("width", next)}
suffix={<KeyframeGutter property="width" displayValue={displayW} {...gutterProps} />}
/>
<FlatRow
label="H"
value={formatPxMetricValue(displayH)}
tier="default"
disabled={manualSizeEditingDisabled}
onCommit={(next) => commitManualSize("height", next)}
suffix={<KeyframeGutter property="height" displayValue={displayH} {...gutterProps} />}
/>
<FlatRow
label="Angle"
value={`${displayR}°`}
tier="default"
disabled={manualRotationEditingDisabled}
onCommit={(next) => commitManualRotation(next.replace("°", ""))}
suffix={<KeyframeGutter property="rotation" displayValue={displayR} {...gutterProps} />}
/>
</>
);
}
export function LayoutZIndexRow({
styles,
onSetStyle,
}: {
styles: Record<string, string>;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
}) {
const zIndex = String(parseInt(styles["z-index"] || "auto", 10) || 0);
return (
<FlatRow
label="Z-index"
value={zIndex}
tier="default"
onCommit={(next) => void onSetStyle("z-index", next)}
/>
);
}
export function LayoutFlexBlock({
styles,
onSetStyle,
disabled,
}: {
styles: Record<string, string>;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
disabled: boolean;
}) {
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
if (!isFlex) return null;
const direction = styles["flex-direction"] || "row";
return (
<div className="border-l-2 border-panel-border-input py-0.5 pl-[10px]">
<div className="mb-[3px] text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Flex
</div>
<FlatSegmentedRow
label="Direction"
options={[
{ key: "row", node: "→ Row", active: direction === "row" },
{ key: "column", node: "Column", active: direction === "column" },
]}
disabled={disabled}
onChange={(next) => void onSetStyle("flex-direction", next)}
/>
<FlatSelectRow
label="Justify"
value={styles["justify-content"] || "flex-start"}
tier={resolveValueTier(styles["justify-content"], "flex-start")}
disabled={disabled}
options={[
"flex-start",
"center",
"space-between",
"space-around",
"space-evenly",
"flex-end",
]}
onChange={(next) => void onSetStyle("justify-content", next)}
/>
<FlatSelectRow
label="Align"
value={styles["align-items"] || "stretch"}
tier={resolveValueTier(styles["align-items"], "stretch")}
disabled={disabled}
options={["stretch", "flex-start", "center", "flex-end", "baseline"]}
onChange={(next) => void onSetStyle("align-items", next)}
/>
<FlatRow
label="Gap"
value={styles.gap ?? "0px"}
tier={resolveValueTier(styles.gap, "0px")}
disabled={disabled}
onCommit={(next) => void onSetStyle("gap", next.endsWith("px") ? next : `${next}px`)}
/>
</div>
);
}
export function LayoutTransform3DBlock({
gsapRuntimeValues,
gsapAnimId,
resolveAnimIdForProp,
gsapKeyframes,
currentPct,
elStart,
elDuration,
element,
onCommitAnimatedProperty,
onCommitAnimatedProperties,
onSeekToTime,
onRemoveKeyframe,
onConvertToKeyframes,
onLivePreviewProps,
}: {
gsapRuntimeValues: Record<string, number>;
gsapAnimId: string | null;
resolveAnimIdForProp?: (prop: string) => string | null;
gsapKeyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}> | null;
currentPct: number;
elStart: number;
elDuration: number;
element: DomEditSelection;
onCommitAnimatedProperty?: (
element: DomEditSelection,
property: string,
value: number,
) => Promise<void>;
onCommitAnimatedProperties?: (
element: DomEditSelection,
props: Record<string, number | string>,
) => Promise<void>;
onSeekToTime?: (time: number) => void;
onRemoveKeyframe?: (animId: string, pct: number) => void;
onConvertToKeyframes?: (animId: string, duration?: number) => void;
onLivePreviewProps?: (element: DomEditSelection, props: Record<string, number>) => void;
}) {
return (
<div className="border-t border-panel-hairline pt-2.5">
<div className="mb-[3px] text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
3D Transform
</div>
<PropertyPanel3dTransform
gsapRuntimeValues={gsapRuntimeValues}
gsapAnimId={gsapAnimId}
resolveAnimIdForProp={resolveAnimIdForProp}
gsapKeyframes={gsapKeyframes}
currentPct={currentPct}
elStart={elStart}
elDuration={elDuration}
element={element}
onCommitAnimatedProperty={onCommitAnimatedProperty}
onCommitAnimatedProperties={onCommitAnimatedProperties}
onSeekToTime={onSeekToTime}
onRemoveKeyframe={onRemoveKeyframe}
onConvertToKeyframes={onConvertToKeyframes}
onLivePreviewProps={onLivePreviewProps}
/>
</div>
);
}
interface FlatLayoutSectionProps
extends
Omit<GeometryRowsProps, never>,
Pick<
Parameters<typeof LayoutTransform3DBlock>[0],
| "gsapRuntimeValues"
| "resolveAnimIdForProp"
| "gsapKeyframes"
| "elStart"
| "elDuration"
| "onCommitAnimatedProperties"
| "onSeekToTime"
| "onLivePreviewProps"
> {
element: DomEditSelection;
styles: Record<string, string>;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
disabled: boolean;
}
export function FlatLayoutSection({
element,
styles,
onSetStyle,
disabled,
gsapRuntimeValues,
resolveAnimIdForProp,
gsapKeyframes,
elStart,
elDuration,
onCommitAnimatedProperties,
onSeekToTime,
onLivePreviewProps,
...geometry
}: FlatLayoutSectionProps) {
return (
<div className="space-y-1.5">
<LayoutGeometryRows element={element} {...geometry} />
<LayoutZIndexRow styles={styles} onSetStyle={onSetStyle} />
<LayoutFlexBlock styles={styles} onSetStyle={onSetStyle} disabled={disabled} />
<LayoutTransform3DBlock
gsapRuntimeValues={gsapRuntimeValues}
gsapAnimId={geometry.gsapAnimId}
resolveAnimIdForProp={resolveAnimIdForProp}
gsapKeyframes={gsapKeyframes}
currentPct={geometry.currentPct}
elStart={elStart}
elDuration={elDuration}
element={element}
onCommitAnimatedProperty={geometry.onCommitAnimatedProperty}
onCommitAnimatedProperties={onCommitAnimatedProperties}
onSeekToTime={onSeekToTime}
onRemoveKeyframe={geometry.onRemoveKeyframe}
onConvertToKeyframes={geometry.onConvertToKeyframes}
onLivePreviewProps={onLivePreviewProps}
/>
</div>
);
}
@@ -0,0 +1,219 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FlatMotionSection, 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> = {}): 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(
<FlatTimingRow element={baseElement()} onSetAttribute={vi.fn()} />,
);
expect(host.textContent).toContain("Start");
expect(host.textContent).toContain("End");
expect(host.textContent).toContain("Duration");
// Values render inside <input>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<HTMLInputElement>("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(
<FlatTimingRow
element={element}
animations={[{ position: 2, duration: 3 } as never]}
onSetAttribute={onSetAttribute}
/>,
);
expect(host.textContent).toContain("Inferred");
act(() => root.unmount());
});
it("commits a Start edit through onSetAttribute", () => {
const onSetAttribute = vi.fn();
const { host, root } = renderInto(
<FlatTimingRow element={baseElement()} onSetAttribute={onSetAttribute} />,
);
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());
});
});
describe("FlatMotionSection", () => {
it("renders Timing when showTiming is true and the effect list when showEffects is true", () => {
const { host, root } = renderInto(
<FlatMotionSection
element={baseElement()}
animations={[
{
id: "a1",
method: "to",
position: 0.8,
duration: 1.2,
ease: "power2.out",
properties: { opacity: 1 },
} as never,
]}
showTiming
showEffects
onSetAttribute={vi.fn()}
onAddAnimation={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={vi.fn()}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
/>,
);
expect(host.textContent).toContain("Start");
expect(host.textContent).toContain("power2.out");
act(() => root.unmount());
});
it("omits Timing entirely when showTiming is false", () => {
const { host, root } = renderInto(
<FlatMotionSection
element={baseElement()}
animations={[]}
showTiming={false}
showEffects
onSetAttribute={vi.fn()}
onAddAnimation={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={vi.fn()}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
/>,
);
expect(host.textContent).not.toContain("Start");
act(() => root.unmount());
});
it("omits the effect list entirely when showEffects is false", () => {
const { host, root } = renderInto(
<FlatMotionSection
element={baseElement()}
animations={[
{
id: "a1",
method: "to",
position: 0.8,
duration: 1.2,
ease: "power2.out",
properties: { opacity: 1 },
} as never,
]}
showTiming
showEffects={false}
onSetAttribute={vi.fn()}
onAddAnimation={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={vi.fn()}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
/>,
);
expect(host.textContent).not.toContain("power2.out");
act(() => root.unmount());
});
it("opens the add-method menu on '+ Add effect' and calls onAddAnimation with the chosen method", () => {
const onAddAnimation = vi.fn();
const { host, root } = renderInto(
<FlatMotionSection
element={baseElement()}
animations={[]}
showTiming
showEffects
onSetAttribute={vi.fn()}
onAddAnimation={onAddAnimation}
onUpdateProperty={vi.fn()}
onUpdateMeta={vi.fn()}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
/>,
);
const buttons = () => Array.from(host.querySelectorAll("button"));
const addTrigger = buttons().find((b) => b.textContent === "+ Add effect");
if (!addTrigger) throw new Error("expected an '+ Add effect' trigger button");
act(() => {
addTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const animateButton = buttons().find((b) => b.textContent === "Animate");
if (!animateButton) throw new Error("expected an 'Animate' method button");
act(() => {
animateButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAddAnimation).toHaveBeenCalledWith("to");
// The menu closes back to the trigger after a selection.
expect(buttons().some((b) => b.textContent === "+ Add effect")).toBe(true);
act(() => root.unmount());
});
});
@@ -0,0 +1,158 @@
import { useState } from "react";
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";
import { AnimationCard } from "./AnimationCard";
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
export function FlatTimingRow({
element,
animations = [],
onSetAttribute,
}: {
element: DomEditSelection;
animations?: GsapAnimation[];
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
}) {
const { start, duration, inferred: derived } = deriveElementTiming(element, animations);
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) => (
<div className="grid gap-px">
<span className="text-[9px] text-panel-text-4">{label}</span>
<span className="border-b border-transparent font-mono text-[11px] text-panel-text-0 hover:border-panel-border-input">
<CommitField value={value} onCommit={onCommit} />
</span>
</div>
);
return (
<div className={RESPONSIVE_GRID}>
{cell("Start", formatTimingValue(start), commitStart)}
{cell("End", formatTimingValue(end), commitEnd)}
{cell("Duration", formatTimingValue(duration), commitDuration)}
{derived && (
<p className="col-span-3 mt-1 text-[10px] leading-snug text-panel-text-3">
Inferred from this element's animation edit to pin an explicit clip range.
</p>
)}
</div>
);
}
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<void>;
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
} & GsapAnimationEditCallbacks) {
const [addMenuOpen, setAddMenuOpen] = useState(false);
return (
<div className="space-y-3">
{showTiming && (
<FlatTimingRow element={element} animations={animations} onSetAttribute={onSetAttribute} />
)}
{showEffects && (
<>
{multipleTimelines && (
<p className="rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
This file has multiple GSAP timelines. Animation editing is disabled to prevent data
loss consolidate into a single timeline to enable editing.
</p>
)}
{unsupportedTimelinePattern && (
<p className="rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
This timeline uses a computed key the editor can&apos;t resolve statically.
</p>
)}
{!multipleTimelines && !unsupportedTimelinePattern && (
<div className="space-y-2">
{animations.map((anim, index) => (
<AnimationCard
key={anim.id}
animation={anim}
defaultExpanded={index === 0}
flat
{...callbacks}
/>
))}
<div className="relative pt-1">
{addMenuOpen ? (
<div className="flex gap-1.5">
{ADD_METHODS.map((method) => (
<button
key={method}
type="button"
title={METHOD_TOOLTIPS[method]}
onClick={() => {
onAddAnimation(method);
setAddMenuOpen(false);
}}
className="rounded-lg border border-panel-border-input bg-panel-input px-2.5 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:border-panel-text-4 hover:text-panel-text-0"
>
{ADD_METHOD_LABELS[method] ?? method}
</button>
))}
<button
type="button"
onClick={() => setAddMenuOpen(false)}
className="px-1.5 text-[11px] text-panel-text-3 hover:text-panel-text-1"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setAddMenuOpen(true)}
className="text-[11px] font-medium text-panel-text-3 transition-colors hover:text-panel-text-1"
title="Add a new animation effect to this element"
>
+ Add effect
</button>
)}
</div>
</div>
)}
</>
)}
</div>
);
}
@@ -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<string, string>,
): Pick<DomEditSelection, "dataAttributes"> {
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);
});
});
@@ -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<DomEditSelection, "dataAttributes">,
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,
};
}
@@ -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<void>;
onImportAssets?: (files: FileList) => Promise<string[]>;
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 && (
<Section title="Flex" icon={<Layers size={15} />} defaultCollapsed>
<div className="space-y-4">
<SegmentedControl
@@ -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;