feat(studio): add keyframe ease editor

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-25 14:12:17 +02:00
parent d84e999f72
commit c253dec23b
13 changed files with 1599 additions and 252 deletions
@@ -5,11 +5,16 @@ 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";
import { EASE_PRESETS } from "./easePresetLibrary";
const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
trackStudioSegmentEaseEdit.mockClear();
});
function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
@@ -26,6 +31,101 @@ function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
const noop = () => {};
function selectPreset(host: HTMLElement, presetId: string): string {
const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId);
if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`);
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
expect(dropdown).not.toBeNull();
act(() => dropdown?.click());
const preset = host.querySelector<HTMLButtonElement>(`[data-ease-preset-id="${presetId}"]`);
expect(preset).not.toBeNull();
act(() => preset?.click());
return presetConfig.ease;
}
function renderExpandedCard({
animation,
flat,
onUpdateMeta = vi.fn(),
onUpdateKeyframeEase = vi.fn(),
}: {
animation: GsapAnimation;
flat?: boolean;
onUpdateMeta?: ReturnType<typeof vi.fn>;
onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={animation}
defaultExpanded
flat={flat}
onUpdateProperty={noop}
onUpdateMeta={onUpdateMeta}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
onUpdateKeyframeEase={onUpdateKeyframeEase}
/>,
);
});
return { host, root };
}
describe("AnimationCard ease editing", () => {
it("commits one preset change to the selected keyframe segment", () => {
const onUpdateKeyframeEase = vi.fn();
const animation = baseAnimation({
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 50, properties: { opacity: 0.5 } },
{ percentage: 100, properties: { opacity: 1 } },
],
},
});
const view = renderExpandedCard({ animation, onUpdateKeyframeEase });
const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("0% → 50%"),
);
expect(segment).toBeDefined();
act(() => segment?.click());
const ease = selectPreset(view.host, "quad-out");
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({
ease,
});
act(() => view.root.unmount());
});
it("commits one preset change through flat tween metadata", () => {
const onUpdateMeta = vi.fn();
const onUpdateKeyframeEase = vi.fn();
const animation = baseAnimation({ id: "flat-tween" });
const view = renderExpandedCard({
animation,
flat: true,
onUpdateMeta,
onUpdateKeyframeEase,
});
const ease = selectPreset(view.host, "quad-out");
expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(animation.id, { ease });
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
expect(trackStudioSegmentEaseEdit).not.toHaveBeenCalled();
act(() => view.root.unmount());
});
});
describe("AnimationCard flat branch", () => {
it("renders a mint border-left and panel-token colors when flat", () => {
const host = document.createElement("div");