Files
hyperframes/packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.test.ts
T
Vance IngallsandClaude Sonnet 5 b155ed46b6 fix(studio): reconcile keyframe-seek timing basis between flat Layout and Motion groups
Whole-plan coherence review (Plan 3a Layout + Plan 3b Motion) found that
Layout's keyframe gutter and Motion's Timing row independently derived an
element's start/duration and disagreed whenever an element had animations
but no explicit data-duration: Motion correctly inferred the range from the
element's GSAP tweens, while Layout's keyframe gutter fell back to a naive
`duration ?? 1`, so clicking a keyframe percentage in Layout could seek to a
different absolute time than what Motion's Timing row displayed.

Extract deriveElementTiming (propertyPanelFlatTimingDerivation.ts) as the
single shared basis both paths now consume: FlatTimingRow (Motion) and
PropertyPanelFlat's own elStart/elDuration (Layout's keyframe gutter and 3D
Transform block). PropertyPanelFlat now recomputes this basis itself from
its own element/gsapAnimations props instead of trusting the parent's naive
value, so PropertyPanel.tsx (and its legacy non-flat panel) is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 15:50:43 -07:00

72 lines
3.1 KiB
TypeScript

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);
});
});