fix(studio): switch keyframe ease modes optimistically

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-29 03:44:31 +02:00
parent 6b11d37433
commit 659e22656e
7 changed files with 275 additions and 25 deletions
+6
View File
@@ -110,6 +110,12 @@
"types": "./dist/runtime/clipTree.d.ts", "types": "./dist/runtime/clipTree.d.ts",
"environments": ["browser", "bun", "node"] "environments": ["browser", "bun", "node"]
}, },
"./runtime/custom-ease": {
"source": "./src/runtime/customEase.ts",
"runtime": "./dist/runtime/customEase.js",
"types": "./dist/runtime/customEase.d.ts",
"environments": ["browser", "bun", "node"]
},
"./runtime/start-expression": { "./runtime/start-expression": {
"source": "./src/runtime/startExpression.ts", "source": "./src/runtime/startExpression.ts",
"runtime": "./dist/runtime/startExpression.js", "runtime": "./dist/runtime/startExpression.js",
+10
View File
@@ -119,6 +119,12 @@
"import": "./src/runtime/clipTree.ts", "import": "./src/runtime/clipTree.ts",
"types": "./src/runtime/clipTree.ts" "types": "./src/runtime/clipTree.ts"
}, },
"./runtime/custom-ease": {
"bun": "./src/runtime/customEase.ts",
"node": "./dist/runtime/customEase.js",
"import": "./src/runtime/customEase.ts",
"types": "./src/runtime/customEase.ts"
},
"./runtime/start-expression": { "./runtime/start-expression": {
"bun": "./src/runtime/startExpression.ts", "bun": "./src/runtime/startExpression.ts",
"node": "./dist/runtime/startExpression.js", "node": "./dist/runtime/startExpression.js",
@@ -353,6 +359,10 @@
"import": "./dist/runtime/clipTree.js", "import": "./dist/runtime/clipTree.js",
"types": "./dist/runtime/clipTree.d.ts" "types": "./dist/runtime/clipTree.d.ts"
}, },
"./runtime/custom-ease": {
"import": "./dist/runtime/customEase.js",
"types": "./dist/runtime/customEase.d.ts"
},
"./runtime/start-expression": { "./runtime/start-expression": {
"import": "./dist/runtime/startExpression.js", "import": "./dist/runtime/startExpression.js",
"types": "./dist/runtime/startExpression.d.ts" "types": "./dist/runtime/startExpression.d.ts"
+1
View File
@@ -15,6 +15,7 @@
}, },
"files": [ "files": [
"src/runtime/clipTree.ts", "src/runtime/clipTree.ts",
"src/runtime/customEase.ts",
"src/runtime/mediaVolumeEnvelope.ts", "src/runtime/mediaVolumeEnvelope.ts",
"src/runtime/positionEdits.ts", "src/runtime/positionEdits.ts",
"src/runtime/protocol.ts", "src/runtime/protocol.ts",
@@ -85,10 +85,15 @@ function findButton(host: HTMLElement, text: string): HTMLButtonElement | undefi
); );
} }
function openSegment(host: HTMLElement, label: string): void {
const segment = findButton(host, label);
expect(segment).toBeDefined();
act(() => segment?.click());
}
function selectPreset(host: HTMLElement, presetId: string): string { function selectPreset(host: HTMLElement, presetId: string): string {
const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId); const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId);
if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`); if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`);
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]"); const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
expect(dropdown).not.toBeNull(); expect(dropdown).not.toBeNull();
act(() => dropdown?.click()); act(() => dropdown?.click());
@@ -174,9 +179,7 @@ describe("AnimationCard", () => {
it("tracks a committed segment ease alongside the existing update", () => { it("tracks a committed segment ease alongside the existing update", () => {
const onEaseCommit = vi.fn(); const onEaseCommit = vi.fn();
const view = renderFocusCard(null, onEaseCommit, true); const view = renderFocusCard(null, onEaseCommit, true);
const segment = findButton(view.host, "0% → 50%"); openSegment(view.host, "0% → 50%");
expect(segment).toBeDefined();
act(() => segment?.click());
const ease = selectPreset(view.host, "quad-out"); const ease = selectPreset(view.host, "quad-out");
expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease); expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease);
@@ -221,7 +224,6 @@ describe("AnimationCard", () => {
vi.fn(), vi.fn(),
onUpdateSegmentEase, onUpdateSegmentEase,
); );
const ease = selectPreset(view.host, "quad-out"); const ease = selectPreset(view.host, "quad-out");
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(ANIMATION.id, 50, ease); expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(ANIMATION.id, 50, ease);
@@ -258,7 +260,40 @@ function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
...overrides, ...overrides,
} as GsapAnimation; } as GsapAnimation;
} }
describe("AnimationCard ease editing", () => { describe("AnimationCard ease editing", () => {
it.each([
["spring", "power2.out", "spring(0.42)", "Spring bounce"],
["wiggle", "power2.out", "wiggle(3,easeInOut,0.12)", "Wiggle count"],
["curve", "spring(0.6)", "custom(M0,0 C0.16,1 0.3,1 1,1)", "Cubic bezier control points"],
] as const)(
"commits and immediately displays the %s default when a keyframe segment switches mode",
(mode, currentEase, ease, fieldLabel) => {
const onUpdateKeyframeEase = vi.fn();
const animation = baseAnimation({
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 50, properties: { opacity: 0.5 }, ease: currentEase },
{ percentage: 100, properties: { opacity: 1 } },
],
},
});
const view = renderFocusCard(null, onUpdateKeyframeEase, true, animation);
openSegment(view.host, "0% → 50%");
const modeButton = view.host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
expect(modeButton).not.toBeNull();
act(() => modeButton?.click());
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
expect(modeButton?.getAttribute("aria-checked")).toBe("true");
expect(view.host.querySelector(`[aria-label="${fieldLabel}"]`)).not.toBeNull();
act(() => view.root.unmount());
},
);
it("commits one preset change to the selected keyframe segment", () => { it("commits one preset change to the selected keyframe segment", () => {
const onUpdateKeyframeEase = vi.fn(); const onUpdateKeyframeEase = vi.fn();
const animation = baseAnimation({ const animation = baseAnimation({
@@ -273,11 +308,7 @@ describe("AnimationCard ease editing", () => {
}); });
const view = renderCard({ animation, onUpdateKeyframeEase }); const view = renderCard({ animation, onUpdateKeyframeEase });
const segment = Array.from(view.host.querySelectorAll("button")).find((button) => openSegment(view.host, "0% → 50%");
button.textContent?.includes("0% → 50%"),
);
expect(segment).toBeDefined();
act(() => segment?.click());
const ease = selectPreset(view.host, "quad-out"); const ease = selectPreset(view.host, "quad-out");
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease); expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
@@ -3,7 +3,10 @@
import React, { act, useState } from "react"; import React, { act, useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { parseSpringBounce } from "@hyperframes/core/spring-ease";
import { parseWiggleEase } from "@hyperframes/core/wiggle-ease";
import { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection"; import { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection";
import { resolveEaseCurveTuple } from "./gsapAnimationConstants";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth"; import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -76,6 +79,19 @@ function renderStatefulSection(initialEase = "none", onCustomEaseCommit = vi.fn(
return { host, root, onCustomEaseCommit }; return { host, root, onCustomEaseCommit };
} }
function renderControlledSection(initialEase = "none", onCustomEaseCommit = vi.fn()) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const renderEase = (ease: string) => {
act(() =>
root.render(<EaseCurveSection ease={ease} onCustomEaseCommit={onCustomEaseCommit} />),
);
};
renderEase(initialEase);
return { host, root, onCustomEaseCommit, renderEase };
}
function clickMode(host: HTMLElement, mode: "curve" | "spring" | "wiggle"): void { function clickMode(host: HTMLElement, mode: "curve" | "spring" | "wiggle"): void {
const toggle = host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`); const toggle = host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
expect(toggle).not.toBeNull(); expect(toggle).not.toBeNull();
@@ -291,12 +307,107 @@ describe("EaseCurveSection preset grid", () => {
clickMode(host, "spring"); clickMode(host, "spring");
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.42)"); expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.42)");
expect(parseSpringBounce(onCustomEaseCommit.mock.lastCall![0])).toBe(0.42);
clickMode(host, "curve"); clickMode(host, "curve");
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.16,1 0.3,1 1,1)"); expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.16,1 0.3,1 1,1)");
expect(resolveEaseCurveTuple(onCustomEaseCommit.mock.lastCall![0])).toEqual([0.16, 1, 0.3, 1]);
clickMode(host, "wiggle"); clickMode(host, "wiggle");
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("wiggle(3,easeInOut,0.12)"); expect(onCustomEaseCommit).toHaveBeenLastCalledWith("wiggle(3,easeInOut,0.12)");
expect(parseWiggleEase(onCustomEaseCommit.mock.lastCall![0])).toEqual({
wiggles: 3,
type: "easeInOut",
amplitude: 0.12,
});
expect(onCustomEaseCommit).toHaveBeenCalledTimes(3);
act(() => root.unmount());
});
it("keeps an optimistic mode visible through its canonical prop round-trip", () => {
const { host, root, onCustomEaseCommit, renderEase } = renderControlledSection();
clickMode(host, "spring");
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
"true",
);
expect(host.querySelector('[aria-label="Spring bounce"]')).not.toBeNull();
renderEase("spring(0.42)");
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
"true",
);
expect(host.querySelector('[aria-label="Spring bounce"]')).not.toBeNull();
expect(onCustomEaseCommit).toHaveBeenCalledExactlyOnceWith("spring(0.42)");
act(() => root.unmount());
});
// Two switches before the first commit round-trips: the commits serialize, so
// the older value arrives while the newer one is still in flight. Repainting
// it would flash wiggle, spring, wiggle in the panel.
it("ignores an older in-flight commit arriving after a newer switch", () => {
const { host, root, renderEase } = renderControlledSection();
clickMode(host, "spring");
clickMode(host, "wiggle");
renderEase("spring(0.42)");
expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
"true",
);
renderEase("wiggle(3,easeInOut,0.12)");
expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
"true",
);
act(() => root.unmount());
});
// The commit is fire-and-forget: a rejected write or one that lands as a
// no-op never changes `ease`, so nothing else can retire the optimistic
// value and the panel would keep claiming a curve that was never saved.
it("falls back to the committed ease when the commit never round-trips", () => {
vi.useFakeTimers();
try {
const { host, root } = renderControlledSection("power2.out");
clickMode(host, "spring");
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
"true",
);
act(() => vi.advanceTimersByTime(2000));
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
"false",
);
expect(host.querySelector('[data-ease-mode="curve"]')?.getAttribute("aria-checked")).toBe(
"true",
);
act(() => root.unmount());
} finally {
vi.useRealTimers();
}
});
it("replaces an optimistic mode when the canonical prop changes externally", () => {
const { host, root, renderEase } = renderControlledSection();
clickMode(host, "spring");
renderEase("wiggle(2,uniform,0.3)");
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
"false",
);
expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
"true",
);
expect(host.querySelector('[aria-label="Wiggle count"]')).not.toBeNull();
expect(host.querySelector('[aria-label="Spring bounce"]')).toBeNull();
act(() => root.unmount()); act(() => root.unmount());
}); });
@@ -321,6 +321,13 @@ function EaseParameterField({
return <EaseBezierField tuple={tuple} onCommit={onCommit} />; return <EaseBezierField tuple={tuple} onCommit={onCommit} />;
} }
/**
* How long an optimistically painted ease may outlive its commit. Long enough
* for a normal write-reparse-rerender round trip, short enough that a dropped
* write self-corrects while the author is still looking at the panel.
*/
const PENDING_EASE_TIMEOUT_MS = 2000;
export function EaseCurveSection({ export function EaseCurveSection({
ease, ease,
onCustomEaseCommit, onCustomEaseCommit,
@@ -330,12 +337,20 @@ export function EaseCurveSection({
onCustomEaseCommit: (ease: string) => void; onCustomEaseCommit: (ease: string) => void;
collidingAnimationTargets?: AnimationKeyframeTarget[]; collidingAnimationTargets?: AnimationKeyframeTarget[];
}) { }) {
const springBounce = parseSpringBounce(ease); // The ease this section painted optimistically, still waiting for its commit
// to round-trip back through the `ease` prop.
const [pendingEase, setPendingEase] = useState<string | null>(null);
// Every value committed and not yet seen coming back, oldest first. It takes
// the whole queue, not just the latest, to tell an older commit echoing back
// apart from an edit made somewhere else.
const inFlightEasesRef = useRef<string[]>([]);
const displayedEase = pendingEase ?? ease;
const springBounce = parseSpringBounce(displayedEase);
const isSpring = springBounce !== null; const isSpring = springBounce !== null;
const wiggleConfig = parseWiggleEase(ease); const wiggleConfig = parseWiggleEase(displayedEase);
const isWiggle = wiggleConfig !== null; const isWiggle = wiggleConfig !== null;
const mode: EaseMode = isSpring ? "spring" : isWiggle ? "wiggle" : "curve"; const mode: EaseMode = isSpring ? "spring" : isWiggle ? "wiggle" : "curve";
const curve = resolveEditableCurve(ease, springBounce); const curve = resolveEditableCurve(displayedEase, springBounce);
const [draft, setDraft] = useState<Pts | null>(null); const [draft, setDraft] = useState<Pts | null>(null);
const [hover, setHover] = useState<"p1" | "p2" | null>(null); const [hover, setHover] = useState<"p1" | "p2" | null>(null);
@@ -349,8 +364,43 @@ export function EaseCurveSection({
// `ease` changes, `curve` already equals the draft, so the handoff is seamless. // `ease` changes, `curve` already equals the draft, so the handoff is seamless.
useEffect(() => { useEffect(() => {
setDraft(null); setDraft(null);
const inFlight = inFlightEasesRef.current;
const landed = inFlight.indexOf(ease);
if (landed < 0) {
// A value this section never sent: someone else edited the ease, so the
// real value wins over anything optimistic still on screen.
inFlight.length = 0;
setPendingEase(null);
return;
}
// One of this section's own commits came back. Everything sent before it
// is settled with it, but a NEWER commit may still be in flight, and
// repainting this older value while waiting for that one is the
// wiggle-then-spring-then-wiggle flicker of a fast double switch.
inFlight.splice(0, landed + 1);
if (inFlight.length === 0) setPendingEase(null);
}, [ease]); }, [ease]);
// A commit is fire-and-forget, so a write that is rejected or lands as a
// no-op never changes `ease`, and the optimistic value would sit on screen
// claiming a curve the composition does not have. Nothing downstream reports
// that failure, so the display is time-bounded instead: fall back to the
// committed truth when the round trip does not arrive.
useEffect(() => {
if (pendingEase === null) return;
const timer = setTimeout(() => {
inFlightEasesRef.current.length = 0;
setPendingEase(null);
}, PENDING_EASE_TIMEOUT_MS);
return () => clearTimeout(timer);
}, [pendingEase]);
const commitEase = (nextEase: string) => {
inFlightEasesRef.current.push(nextEase);
setPendingEase(nextEase);
onCustomEaseCommit(nextEase);
};
const activeTuple = draft ?? curve; const activeTuple = draft ?? curve;
const displayTuple = activeTuple ?? DEFAULT_CURVE; const displayTuple = activeTuple ?? DEFAULT_CURVE;
const [x1, y1, x2, y2] = displayTuple; const [x1, y1, x2, y2] = displayTuple;
@@ -361,8 +411,12 @@ export function EaseCurveSection({
const a1 = { x: xToSvg(1), y: yToSvg(1) }; const a1 = { x: xToSvg(1), y: yToSvg(1) };
const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) }; const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) };
const p2 = { x: xToSvg(x2), y: yToSvg(clampView(y2)) }; const p2 = { x: xToSvg(x2), y: yToSvg(clampView(y2)) };
const curvePath = curvePathFor(ease, springBounce, wiggleConfig, displayTuple); // Read the OPTIMISTIC ease everywhere the graph is derived, so a mode switch
const showGraph = activeTuple !== null || isWiggle || ease === "hold"; // paints immediately instead of waiting for the committed prop to come back.
const curvePath = curvePathFor(displayedEase, springBounce, wiggleConfig, displayTuple);
const showGraph = activeTuple !== null || isWiggle || displayedEase === "hold";
// `curve !== null` is what keeps Hold handle-free: it draws a graph (a flat
// step) but has no editable control points to drag.
const showHandles = curve !== null && !isSpring && !isWiggle; const showHandles = curve !== null && !isSpring && !isWiggle;
const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => { const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => {
@@ -397,10 +451,9 @@ export function EaseCurveSection({
if (!draggingRef.current || !draft) return; if (!draggingRef.current || !draft) return;
draggingRef.current = null; draggingRef.current = null;
const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`; const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`;
// Clear after the synchronous parent commit settles. This also clears a // Commit only — the draft stays on screen and is cleared by the effect above
// same-string commit, where the `ease` dependency effect would not run. // once the committed `ease` prop comes back, so the curve never flickers.
onCustomEaseCommit(`custom(${path})`); commitEase(`custom(${path})`);
queueMicrotask(() => setDraft(null));
}; };
const handleKeyDown = (handle: "p1" | "p2", event: React.KeyboardEvent<SVGCircleElement>) => { const handleKeyDown = (handle: "p1" | "p2", event: React.KeyboardEvent<SVGCircleElement>) => {
@@ -409,25 +462,26 @@ export function EaseCurveSection({
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setDraft(next); setDraft(next);
onCustomEaseCommit(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`); // Same no-flicker contract as the pointer path: commit and let the effect
queueMicrotask(() => setDraft(null)); // clear the draft, rather than dropping it on the next microtask.
commitEase(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`);
}; };
const top = yToSvg(1); const top = yToSvg(1);
const bottom = yToSvg(0); const bottom = yToSvg(0);
const left = xToSvg(0); const left = xToSvg(0);
const right = xToSvg(1); const right = xToSvg(1);
const label = resolveEditorLabel(ease, springBounce, isWiggle); const label = resolveEditorLabel(displayedEase, springBounce, isWiggle);
return ( return (
<div className="rounded-lg bg-neutral-900/50 p-2"> <div className="rounded-lg bg-neutral-900/50 p-2">
<EaseTypeDropdown kind={mode} ease={ease} label={label} onSelect={onCustomEaseCommit} /> <EaseTypeDropdown kind={mode} ease={displayedEase} label={label} onSelect={commitEase} />
{collidingAnimationTargets && collidingAnimationTargets.length > 1 && ( {collidingAnimationTargets && collidingAnimationTargets.length > 1 && (
<p className="mb-1 text-[9px] text-neutral-500"> <p className="mb-1 text-[9px] text-neutral-500">
Applies to {collidingAnimationTargets.length} animations Applies to {collidingAnimationTargets.length} animations
</p> </p>
)} )}
<EaseModeToggle mode={mode} onCommit={onCustomEaseCommit} /> <EaseModeToggle mode={mode} onCommit={commitEase} />
<span className="sr-only" aria-live="polite"> <span className="sr-only" aria-live="polite">
{MODE_LABELS[mode]} ease editor selected {MODE_LABELS[mode]} ease editor selected
</span> </span>
@@ -568,7 +622,7 @@ export function EaseCurveSection({
springBounce={springBounce} springBounce={springBounce}
wiggleConfig={wiggleConfig} wiggleConfig={wiggleConfig}
tuple={displayTuple} tuple={displayTuple}
onCommit={onCustomEaseCommit} onCommit={commitEase}
/> />
</> </>
) : ( ) : (
@@ -0,0 +1,37 @@
// Real gsap, not a parseEase stub: this pins the seek behaviour of the `hold`
// ease against the engine that actually runs it, and gsap is a studio
// dependency. core's own customEase.test.ts covers the resolver in isolation.
import { installStudioCustomEase } from "@hyperframes/core/runtime/custom-ease";
import { gsap } from "gsap";
import { describe, expect, it } from "vitest";
describe("Studio hold ease", () => {
it("holds the start value under seek until the destination time", () => {
const runtimeGsap = { parseEase: gsap.parseEase.bind(gsap) };
expect(installStudioCustomEase(runtimeGsap)).toBe(true);
const hold = runtimeGsap.parseEase("hold");
expect(hold).toBeTypeOf("function");
if (typeof hold !== "function") return;
const target = { value: 0 };
const timeline = gsap.timeline({ paused: true }).to(
target,
{
value: 100,
duration: 2,
ease: hold,
},
0,
);
timeline.seek(0.5);
expect(target.value).toBe(0);
timeline.seek(1);
expect(target.value).toBe(0);
timeline.seek(1.99);
expect(target.value).toBe(0);
timeline.seek(2);
expect(target.value).toBe(100);
timeline.kill();
});
});