fix(keyframes): report unresolved GSAP timing instead of guessing rest poses (#5526)

* fix(keyframes): report unresolved GSAP timing instead of guessing rest poses

* fix(keyframes): drop later times when a delay cannot be resolved

Boolean(null) treated an unreadable delay as no delay,
so the next cursor-relative tween kept a guessed start.

* fix(keyframes): keep a staggered rest pose in Studio

The parser stops are what Studio holds, so a staggered from keeps its rest
pose there. The CLI reports that pose as unresolved, and an unread position
no longer seeds the next tween at zero.
This commit is contained in:
Xuanru Li
2026-10-13 04:52:22 +00:00
committed by GitHub
parent f25cfbd6bd
commit 8e4de38be1
9 changed files with 503 additions and 117 deletions
@@ -0,0 +1,120 @@
import { beforeAll, expect, it } from "vitest";
import { ensureDOMParser } from "../utils/dom.js";
import { surfaceComposition } from "./keyframes.js";
beforeAll(() => ensureDOMParser());
const surface = (script: string, dom = '<div id="dot"></div>') =>
surfaceComposition(
`<html><body>${dom}<script>const tl = gsap.timeline({paused:true});${script}</script></body></html>`,
"main",
"index.html",
);
it("preserves known starts and exposes unknown starts", () => {
const r = surface('tl.set("#dot",{scale:1.18},0);tl.to("#dot",{scale:.95,duration:.11},1.96);');
expect(r.tweens[1]?.keyframes[0]?.properties.scale).toBe(1.18);
expect(
surface('tl.to("#dot",{x:50,duration:1},0);').tweens[0]?.keyframes[0]?.properties.x,
).toBeUndefined();
});
it("does not turn an unresolved expression into time zero", () => {
const r = surface(
'tl.to("#a",{x:1,duration:1},ts[i]);tl.to("#b",{x:2,duration:1});tl.to("#b",{x:3,duration:1},4);',
'<div id="a"></div><div id="b"></div>',
);
expect(r.tweens[0]?.start).toBeNull();
expect(r.tweens[1]?.start).toBeNull();
expect(r.tweens[1]?.unresolved).toContain("start");
expect(r.tweens[2]?.start).toBe(4);
});
it("reports a staggered from rest pose as unresolved", () => {
const tween = surface(
'tl.from(".item",{y:40,opacity:0,duration:.5,stagger:.1});',
'<div class="item"></div>'.repeat(3),
).tweens[0];
expect(tween?.keyframes.at(-1)?.properties.y).toBeUndefined();
expect(tween?.keyframes.at(-1)?.properties.opacity).toBeUndefined();
expect(tween?.unresolved).toContain("endpointProperties");
});
it("resolves string selector constants", () => {
expect(surface('const b="#dot";tl.to(b,{x:50,duration:1},0);').tweens[0]?.target).toBe("#dot");
});
it("accounts for stagger and repetitions", () => {
const r = surface(
'tl.to(".dot",{opacity:0,duration:.2,stagger:.014,repeat:1,yoyo:true},1);',
'<div class="dot"></div>'.repeat(3),
);
expect(r.tweens[0]?.end).toBeCloseTo(1.456);
});
it("does not call a small parent scale change static", () => {
const r = surface(
'tl.fromTo("#parent",{scale:1},{scale:1.16,duration:1},0);tl.to("#dot",{x:50,duration:1},0);',
'<div id="parent"><div id="dot"></div></div>',
);
expect(r.tweens.find((t) => t.target === "#dot")?.composedWith?.[0]?.summary).toContain("1.16");
});
it("uses one point for a set and two endpoints for a flat tween", () => {
const r = surface('tl.set("#dot",{x:0},0);tl.to("#dot",{x:50,duration:1,ease:"elastic.out"},1);');
expect(r.tweens[0]?.keyframes).toHaveLength(1);
expect(r.tweens[1]?.keyframes).toHaveLength(2);
});
it("distinguishes amount, arithmetic, and dynamic staggers", () => {
const dom = '<div class="dot"></div>'.repeat(3);
expect(
surface('tl.to(".dot",{opacity:0,duration:.2,stagger:{amount:.6}},1);', dom).tweens[0]?.end,
).toBe(1.8);
expect(
surface('tl.to(".dot",{opacity:0,duration:.2,stagger:2/100},1);', dom).tweens[0]?.end,
).toBe(1.24);
expect(
surface('tl.to(".dot",{opacity:0,duration:.2,stagger:i=>i*.1},1);', dom).tweens[0]?.end,
).toBeNull();
});
it("does not invent a finite end for infinite repeats", () => {
expect(surface('tl.to("#dot",{x:50,duration:1,repeat:-1},0);').tweens[0]?.end).toBeNull();
});
it("preserves explicit intermediate keyframes", () => {
const r = surface(
'tl.to("#dot",{duration:1,keyframes:{"0%":{scale:0},"40%":{scale:1.18},"70%":{scale:.95},"100%":{scale:1}}},1);',
);
expect(r.tweens[0]?.keyframes.map((k) => k.time)).toEqual([1, 1.4, 1.7, 2]);
});
it("does not classify animated object proxies as a hold", () => {
expect(surface("const cam={x:0};tl.to(cam,{x:50,duration:1},0);").tweens[0]?.target).not.toBe(
"dwell/hold",
);
});
it("marks cursor-dependent timing unknown after a stagger span changes", () => {
const r = surface(
'tl.to(".dot",{x:1,duration:1,stagger:.1},0);tl.to(".dot",{opacity:0,duration:1});',
'<div class="dot"></div>'.repeat(3),
);
expect(r.tweens[1]?.start).toBeNull();
});
it("drops cursor-relative times after an unresolved delay", () => {
const script = (follow: string) =>
`tl.to("#dot",{x:1,duration:1,delay:getDelay()},0);tl.to("#dot",{x:2,duration:1}${follow});`;
const implicit = surface(script(""));
const relative = surface(script(',">"'));
const absolute = surface(script(",4"));
for (const tween of [implicit.tweens[1], relative.tweens[1]]) {
expect(tween?.start).toBeNull();
expect(tween?.end).toBeNull();
expect(tween?.keyframes.map((point) => point.time)).toEqual([null, null]);
expect(tween?.unresolved).toContain("start");
}
expect(absolute.tweens[1]?.start).toBe(4);
expect(absolute.tweens[1]?.keyframes.map((point) => point.time)).toEqual([4, 5]);
});
it("reports the GSAP default duration and explicit delay", () => {
expect(surface('tl.to("#dot",{x:50},0);').tweens[0]?.duration).toBe(0.5);
expect(surface('tl.to("#dot",{x:50,duration:1,delay:.2},1);').tweens[0]?.start).toBe(1.2);
});
it("does not fabricate stops for dynamic keyframes", () => {
const t = surface('tl.to("#dot",{duration:1,keyframes:makeFrames()},0);').tweens[0];
expect(t?.keyframes).toHaveLength(0);
expect(t?.unresolved).toContain("keyframes");
});
it("does not resolve a shadowed selector variable to an outer string constant", () => {
const r = surface('const b="#dot";{let b=chooseTarget();tl.to(b,{x:10,duration:1},0);}');
expect(r.tweens[0]?.target).toBe("__unresolved__");
});
+222 -88
View File
@@ -2,7 +2,11 @@ import { defineCommand } from "citty";
import { existsSync, readFileSync } from "node:fs";
import { readProjectFile } from "@hyperframes/parsers/asset-resolution";
import { resolve, dirname, basename, join, relative, sep } from "node:path";
import { parseGsapScript, type GsapAnimation } from "@hyperframes/core/gsap-parser";
import {
parseGsapScript,
GSAP_DEFAULT_DURATION,
type GsapAnimation,
} from "@hyperframes/core/gsap-parser";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { ensureDOMParser } from "../utils/dom.js";
@@ -23,7 +27,7 @@ interface KeyframePoint {
/** Tween-relative percentage (0–100). */
pct: number;
/** Absolute timeline time (seconds) = tweenStart + pct/100 * duration. */
time: number;
time: number | null;
properties: Record<string, number | string>;
}
@@ -32,11 +36,16 @@ interface SurfacedTween {
target: string;
method: string;
group?: string;
start: number;
duration: number;
end: number;
start: number | null;
duration: number | null;
end: number | null;
/** "keyframes" (array/object form), "flat" (to/from), or "motionPath". */
shape: "keyframes" | "flat" | "motionPath";
unresolved: string[];
keyframeBasis: "authored-stops" | "tween-endpoints";
position: number | string;
ease?: string;
totalDuration: number | null;
keyframes: KeyframePoint[];
/** x/y position points (gsap offsets) when this tween animates position. */
path: Array<{ x: number; y: number }> | null;
@@ -49,8 +58,8 @@ interface SurfacedTween {
/** One drawn stroke of a multi-stroke trace — a single position tween. */
interface TraceStroke {
id: string;
start: number;
end: number;
start: number | null;
end: number | null;
keyframes: KeyframePoint[];
points: Array<{ x: number; y: number }>;
}
@@ -132,7 +141,7 @@ function inlineStyleText(html: string): string {
function num(v: number | string | undefined): number | null {
if (typeof v === "number") return v;
if (typeof v === "string") {
const n = Number.parseFloat(v);
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
return null;
@@ -147,41 +156,13 @@ function isPositionTween(anim: GsapAnimation): boolean {
);
}
// The rest-state value for an animated property (what GSAP animates to/from when
// the other endpoint is the element's natural pose): 1 for scale/opacity, 0 for
// translate/rotation.
function baseProps(props: Record<string, number | string>): Record<string, number | string> {
const base: Record<string, number | string> = {};
for (const k of Object.keys(props)) {
if (k === "ease") continue;
base[k] = k === "opacity" || k.startsWith("scale") ? 1 : 0;
}
return base;
}
// Flat tweens carry no explicit keyframes — synthesize a 0%/100% pair against the
// element's rest pose so the surfaced keyframes are uniform. `from()` goes
// fromProperties → base; `to()` goes base → properties.
function flatKeyframes(anim: GsapAnimation): KeyframePoint[] {
if (anim.method === "fromTo") {
return [
{ pct: 0, time: 0, properties: anim.fromProperties ?? {} },
{ pct: 100, time: 0, properties: anim.properties ?? {} },
];
}
// to()/from() vars both live in anim.properties; from() plays them in reverse
// against the element's rest pose.
const vars = anim.properties ?? {};
const base = baseProps(vars);
return anim.method === "from"
? [
{ pct: 0, time: 0, properties: vars },
{ pct: 100, time: 0, properties: base },
]
: [
{ pct: 0, time: 0, properties: base },
{ pct: 100, time: 0, properties: vars },
];
if (anim.method === "set") return [{ pct: 100, time: 0, properties: vars }];
return [
{ pct: 0, time: 0, properties: anim.method === "from" ? vars : (anim.fromProperties ?? {}) },
{ pct: 100, time: 0, properties: anim.method === "from" ? {} : vars },
];
}
// Studio-internal markers that aren't user motion: the position-hold `set` GSAP
@@ -200,38 +181,149 @@ function cleanProps(props: Record<string, number | string>): Record<string, numb
return out;
}
function surfaceTween(anim: GsapAnimation): SurfacedTween {
const start =
typeof anim.resolvedStart === "number" ? anim.resolvedStart : (num(anim.position) ?? 0);
const duration = anim.duration ?? 0;
function round(value: number): number {
return Math.round(value * 1000) / 1000;
}
let shape: SurfacedTween["shape"];
let rawKfs: Array<{ percentage: number; properties: Record<string, number | string> }>;
if (anim.keyframes?.keyframes?.length) {
shape = "keyframes";
rawKfs = anim.keyframes.keyframes;
} else if (anim.arcPath?.enabled) {
shape = "motionPath";
rawKfs = [];
} else {
shape = "flat";
rawKfs = flatKeyframes(anim).map((k) => ({ percentage: k.pct, properties: k.properties }));
type InspectionTiming = NonNullable<GsapAnimation["inspectionTiming"]>;
type StaggerTiming = NonNullable<InspectionTiming["stagger"]>;
type RawKeyframe = { percentage: number; properties: Record<string, number | string> };
function knownRepeatCycle(
timing: InspectionTiming,
): { repeat: number; repeatDelay: number; stagger: StaggerTiming } | null {
const { repeat, repeatDelay, stagger } = timing;
if (repeat === null || repeat < 0 || repeatDelay === null || stagger === null) return null;
return { repeat, repeatDelay, stagger };
}
function targetCount(html: string, selector: string): number | null {
try {
return queryIncludingTemplates(html, selector).length;
} catch {
return null;
}
}
function staggerSpread(html: string, selector: string, stagger: StaggerTiming): number | null {
const count = targetCount(html, selector);
if (!count) return null;
if ("amount" in stagger) return count > 1 ? Math.abs(stagger.amount) : 0;
return Math.abs(stagger.each) * (count - 1);
}
function tweenSpan(anim: GsapAnimation, duration: number | null, html: string): number | null {
if (duration === null) return null;
if (!anim.inspectionTiming) return duration;
const cycle = knownRepeatCycle(anim.inspectionTiming);
if (!cycle) return null;
const spread =
anim.extras?.stagger === undefined
? 0
: staggerSpread(html, anim.targetSelector, cycle.stagger);
if (spread === null) return null;
return round((duration + spread) * (cycle.repeat + 1) + cycle.repeatDelay * cycle.repeat);
}
function tweenStart(anim: GsapAnimation): number | null {
if (followsTimelineCursor(anim) && typeof anim.resolvedStart !== "number") return null;
const position = typeof anim.resolvedStart === "number" ? anim.resolvedStart : num(anim.position);
const delay = anim.inspectionTiming ? anim.inspectionTiming.delay : 0;
return position === null || delay === null ? null : position + delay;
}
function tweenDuration(anim: GsapAnimation): number | null {
if (anim.method === "set") return 0;
if (anim.durationUnresolved) return null;
return anim.duration ?? GSAP_DEFAULT_DURATION;
}
function syntheticStaggerStops(anim: GsapAnimation): boolean {
const stops = anim.keyframes?.keyframes ?? [];
return stops.length > 0 && stops.every((stop) => "stagger" in stop.properties);
}
function tweenShape(anim: GsapAnimation): { shape: SurfacedTween["shape"]; rawKfs: RawKeyframe[] } {
if (anim.hasUnresolvedKeyframes) return { shape: "keyframes", rawKfs: [] };
if (anim.keyframes?.keyframes?.length && !syntheticStaggerStops(anim))
return { shape: "keyframes", rawKfs: anim.keyframes.keyframes };
if (anim.arcPath?.enabled) return { shape: "motionPath", rawKfs: [] };
return {
shape: "flat",
rawKfs: flatKeyframes(anim).map((k) => ({ percentage: k.pct, properties: k.properties })),
};
}
function keyframeTime(start: number | null, duration: number | null, pct: number): number | null {
if (start === null || duration === null) return null;
return round(start + (pct / 100) * duration);
}
function missesEndpointProperties(anim: GsapAnimation, keyframes: KeyframePoint[]): boolean {
if (anim.method === "set") return false;
const authored = Object.keys(anim.properties).filter((p) => p !== "ease" && p !== "data");
return keyframes.some((k) => authored.some((p) => !(p in k.properties)));
}
function hasRawExpressions(keyframes: KeyframePoint[]): boolean {
return keyframes.some((k) =>
Object.values(k.properties).some((v) => typeof v === "string" && v.startsWith("__raw:")),
);
}
function hasNoProperties(anim: GsapAnimation): boolean {
return !Object.keys(anim.properties).length && !anim.keyframes;
}
function unresolvedFields(
anim: GsapAnimation,
timing: { start: number | null; duration: number | null; totalDuration: number | null },
keyframes: KeyframePoint[],
): string[] {
const checks: Array<[string, boolean]> = [
["start", timing.start === null],
["duration", timing.duration === null],
["target", anim.hasUnresolvedSelector === true],
["keyframes", anim.hasUnresolvedKeyframes === true],
["properties", hasNoProperties(anim)],
["totalDuration", timing.totalDuration === null],
["endpointProperties", missesEndpointProperties(anim, keyframes)],
["propertyExpressions", hasRawExpressions(keyframes)],
];
return checks.filter(([, isUnresolved]) => isUnresolved).map(([field]) => field);
}
function keyframeBasis(
shape: SurfacedTween["shape"],
anim: GsapAnimation,
): SurfacedTween["keyframeBasis"] {
const staggered = anim.keyframes?.keyframes.some((k) => "stagger" in k.properties);
return shape === "flat" || staggered ? "tween-endpoints" : "authored-stops";
}
function surfaceTween(anim: GsapAnimation, html: string): SurfacedTween {
const start = tweenStart(anim);
const duration = tweenDuration(anim);
const totalDuration = tweenSpan(anim, duration, html);
const { shape, rawKfs } = tweenShape(anim);
const keyframes: KeyframePoint[] = rawKfs.map((kf) => ({
pct: kf.percentage,
time: Math.round((start + (kf.percentage / 100) * duration) * 1000) / 1000,
time: keyframeTime(start, duration, kf.percentage),
properties: cleanProps(kf.properties),
}));
return {
id: anim.id,
target: anim.targetSelector,
method: anim.method,
group: anim.propertyGroup,
start: Math.round(start * 1000) / 1000,
start: start === null ? null : round(start),
duration,
end: Math.round((start + duration) * 1000) / 1000,
end: start === null || totalDuration === null ? null : round(start + totalDuration),
totalDuration,
position: anim.position,
ease: anim.ease,
unresolved: unresolvedFields(anim, { start, duration, totalDuration }, keyframes),
keyframeBasis: keyframeBasis(shape, anim),
shape,
keyframes,
path: isPositionTween(anim) ? positionPath(keyframes) : null,
@@ -241,7 +333,11 @@ function surfaceTween(anim: GsapAnimation): SurfacedTween {
// Carry x/y forward across keyframes that only set one axis, so the path is
// continuous (GSAP holds the last value for an unspecified property).
function positionPath(keyframes: KeyframePoint[]): Array<{ x: number; y: number }> | null {
if (keyframes.length === 0) return null;
if (
keyframes.length === 0 ||
!keyframes.every((k) => num(k.properties.x) !== null && num(k.properties.y) !== null)
)
return null;
let lastX = 0;
let lastY = 0;
return keyframes.map((kf) => {
@@ -255,6 +351,24 @@ function positionPath(keyframes: KeyframePoint[]): Array<{ x: number; y: number
// ── Composition surfacing ────────────────────────────────────────────────────
function followsTimelineCursor(anim: GsapAnimation): boolean {
return !anim.global && (anim.implicitPosition === true || typeof anim.position !== "number");
}
function movesCursorUnpredictably(anim: GsapAnimation, tween: SurfacedTween): boolean {
if (anim.global) return false;
const delay = anim.inspectionTiming?.delay;
const delayUnknown = delay === null || (typeof delay === "number" && delay !== 0);
return tween.totalDuration === null || tween.totalDuration !== tween.duration || delayUnknown;
}
function forgetStart(tween: SurfacedTween): void {
tween.start = null;
tween.end = null;
for (const point of tween.keyframes) point.time = null;
if (!tween.unresolved.includes("start")) tween.unresolved.push("start");
}
export function surfaceComposition(
html: string,
label: string,
@@ -267,7 +381,15 @@ export function surfaceComposition(
} catch {
animations = [];
}
const tweens = animations.filter((a) => !isHoldMarker(a)).map(surfaceTween);
let cursorChanged = false;
const tweens = animations
.filter((a) => !isHoldMarker(a))
.map((anim) => {
const tween = surfaceTween(anim, html);
if (cursorChanged && followsTimelineCursor(anim)) forgetStart(tween);
if (movesCursorUnpredictably(anim, tween)) cursorChanged = true;
return tween;
});
attachComposedAncestors(tweens, html);
return {
composition: label,
@@ -307,7 +429,7 @@ function parseDeclarations(body: string): string[] {
function stripRanges(text: string, ranges: Array<{ start: number; end: number }>): string {
let out = "";
let cursor = 0;
for (const range of [...ranges].sort((a, b) => a.start - b.start)) {
for (const range of [...ranges].sort((a, b) => (a.start ?? 0) - (b.start ?? 0))) {
out += text.slice(cursor, range.start);
cursor = range.end;
}
@@ -520,9 +642,11 @@ function summarizeMotion(tweens: SurfacedTween[], sel: string): string {
}
}
const varying = [...ranges.entries()]
.filter(([, r]) => r.max - r.min > 0.5)
.map(([k, r]) => `${k} ${Math.round(r.min)}..${Math.round(r.max)}`);
return varying.length ? varying.join(", ") : "(static)";
.filter(([, r]) => r.max - r.min > 1e-6)
.map(([k, r]) => `${k} ${round(r.min)}..${round(r.max)}`);
return varying.length
? varying.join(", ")
: "(no resolved variation; runtime verification required)";
}
function bumpRange(ranges: Map<string, { min: number; max: number }>, k: string, n: number): void {
@@ -543,35 +667,38 @@ function pathTravels(points: Array<{ x: number; y: number }>): boolean {
return points.some((p) => Math.abs(p.x - first.x) > 0.5 || Math.abs(p.y - first.y) > 0.5);
}
function isDrawnStroke(t: SurfacedTween): boolean {
if (t.method === "set" || t.start === null || t.end === null) return false;
return !!t.path && t.path.length >= 2 && pathTravels(t.path); // in-place tween (e.g. opacity carrying a static y) is not a drawn stroke
}
function traceOf(target: string, list: SurfacedTween[]): SurfacedTrace {
const strokes = [...list]
.sort((a, b) => (a.start ?? 0) - (b.start ?? 0))
.map((t) => ({
id: t.id,
start: t.start,
end: t.end,
keyframes: t.keyframes,
points: t.path!,
}));
return { target, strokes };
}
// Group an element's DRAWN position strokes (to/from/fromTo/keyframes that carry
// a path) into one ordered trace. A `set` with x/y is a pen-up jump — excluded
// (not drawn). Only targets with ≥2 strokes become a composited trace; a single
// stroke stays on the normal per-tween path so existing output is unchanged.
function groupTraces(tweens: SurfacedTween[]): SurfacedTrace[] {
const byTarget = new Map<string, SurfacedTween[]>();
for (const t of tweens) {
if (t.method === "set") continue;
if (!t.path || t.path.length < 2) continue;
if (!pathTravels(t.path)) continue; // in-place tween (e.g. opacity carrying a static y) is not a drawn stroke
for (const t of tweens.filter(isDrawnStroke)) {
const list = byTarget.get(t.target);
if (list) list.push(t);
else byTarget.set(t.target, [t]);
}
const traces: SurfacedTrace[] = [];
for (const [target, list] of byTarget) {
if (list.length < 2) continue;
const strokes = [...list]
.sort((a, b) => a.start - b.start)
.map((t) => ({
id: t.id,
start: t.start,
end: t.end,
keyframes: t.keyframes,
points: t.path!,
}));
traces.push({ target, strokes });
}
return traces;
return [...byTarget]
.filter(([, list]) => list.length >= 2)
.map(([target, list]) => traceOf(target, list));
}
export function collectCompositions(indexPath: string): SurfacedComposition[] {
@@ -607,8 +734,14 @@ function fmtProps(props: Record<string, number | string>): string {
.join(" ");
}
function formatSeconds(value: number | null): string {
return value === null ? "unresolved" : `${value}s`;
}
function printTween(t: SurfacedTween): void {
const timing = c.dim(`@${t.start}s→${t.end}s (${t.duration}s)`);
const timing = c.dim(
`@${formatSeconds(t.start)}→${formatSeconds(t.end)} (${formatSeconds(t.duration)})`,
);
const group = t.group ? c.dim(` ${t.group}`) : "";
console.log(` ${c.accent(t.target)}${group} ${c.dim(t.method)}/${t.shape} ${timing}`);
if (t.shape === "motionPath") {
@@ -617,6 +750,7 @@ function printTween(t: SurfacedTween): void {
const kfLine = t.keyframes.map((k) => `${k.pct}% {${fmtProps(k.properties)}}`).join(" ");
console.log(` ${c.dim(kfLine)}`);
}
if (t.unresolved.length) console.log(c.dim(` unresolved: ${t.unresolved.join(", ")}`));
if (t.composedWith?.length) {
for (const a of t.composedWith) {
console.log(c.dim(` ↑ composed with ${c.accent(a.selector)}${c.dim(": " + a.summary)}`));
@@ -626,8 +760,8 @@ function printTween(t: SurfacedTween): void {
}
function printTrace(tr: SurfacedTrace): void {
const start = Math.min(...tr.strokes.map((s) => s.start));
const end = Math.max(...tr.strokes.map((s) => s.end));
const start = Math.min(...tr.strokes.flatMap((s) => (s.start === null ? [] : [s.start])));
const end = Math.max(...tr.strokes.flatMap((s) => (s.end === null ? [] : [s.end])));
const n = tr.strokes.length;
console.log(
` ${c.accent(tr.target)}${c.dim(" position")} ${c.dim("trace")} ${c.dim(`${n} strokes`)} ${c.dim(`@${start}s→${end}s`)}`,
@@ -206,3 +206,28 @@ describe("motion-shot adapter seeking", () => {
expect(settled).toBe(true);
});
});
it("updates marker coordinates after each seek, including the border origin", async () => {
document.body.innerHTML = '<div id="resizing"></div>';
const el = document.getElementById("resizing")!;
let width = 250;
Object.defineProperties(el, {
offsetWidth: { get: () => width },
offsetHeight: { get: () => 44 },
clientLeft: { get: () => 3 },
clientTop: { get: () => 3 },
});
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function (this: HTMLElement) {
const x = Number.parseFloat(this.style.left) + 3;
const y = Number.parseFloat(this.style.top) + 3;
return new DOMRect(x, y, 0, 0);
},
);
motionWindow.__hfSeekAllAdapters = async (t) => {
width = t === 0 ? 250 : 1050;
};
const [result] = await sampleMarkerOnionElements(["#resizing"], [0, 1]);
expect(result?.samples.map((s) => s.q[1]!.x - s.q[0]!.x)).toEqual([250, 1050]);
expect(result?.samples[0]?.q[0]).toEqual({ x: 0, y: 0 });
});
+13
View File
@@ -174,6 +174,19 @@ export async function sampleMarkerOnionElements(
y: matrix.b * point.x + matrix.d * point.y + matrix.f,
}));
} else {
const w = rig.el.offsetWidth;
const h = rig.el.offsetHeight;
const local = [
[0, 0],
[w, 0],
[w, h],
[0, h],
[w / 2, h / 2],
];
rig.markers!.forEach((marker, i) => {
marker.style.left = `${local[i]![0]! - rig.el.clientLeft + rig.el.scrollLeft}px`;
marker.style.top = `${local[i]![1]! - rig.el.clientTop + rig.el.scrollTop}px`;
});
points = rig.markers!.map((marker) => {
const rect = marker.getBoundingClientRect();
return { x: rect.left, y: rect.top };
+18
View File
@@ -1132,6 +1132,24 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
it("does NOT report overlapping_gsap_tweens for two inline object proxies with the same values", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to({ _: 0 }, { _: 1, duration: 5, ease: "none" }, 0);
tl.to({ _: 0 }, { _: 1, duration: 5, ease: "none" }, 1);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens");
expect(finding).toBeUndefined();
});
it("does NOT report overlapping_gsap_tweens for same-named constants in separate IIFEs", async () => {
const html = `
<html><body>
+1 -1
View File
@@ -79,7 +79,7 @@ const UNRESOLVED_TARGET = "__unresolved__";
function targetHasNoStableIdentity(selector: string, identity?: string): boolean {
if (identity) return false;
return (
selector === UNRESOLVED_TARGET || selector === "dwell/hold" || selector.startsWith("proxy → ")
selector === UNRESOLVED_TARGET || selector === "dwell/hold" || selector.startsWith("proxy ")
);
}
@@ -10,6 +10,14 @@
},
"duration": 0.38,
"ease": "back.out(1.35)",
"inspectionTiming": {
"repeat": 0,
"repeatDelay": 0,
"delay": 0,
"stagger": {
"each": 0.055
}
},
"extras": {
"stagger": "__raw:0.055"
},
@@ -73,6 +81,14 @@
},
"duration": 0.42,
"ease": "power3.out",
"inspectionTiming": {
"repeat": 0,
"repeatDelay": 0,
"delay": 0,
"stagger": {
"each": 0.08
}
},
"extras": {
"stagger": "__raw:0.08"
},
+82 -28
View File
@@ -73,7 +73,10 @@ function parseProgram(script: string): any {
type ScopeBindings = ReadonlyMap<string, number | string | boolean>;
/** Per-scope element bindings: scopeNode → (variable name → selector). */
type TargetBindings = Map<any, Map<string, string>>;
type TargetBindings = {
byScope: Map<unknown, Map<string, string>>;
identifiers: IdentifierBindingIndex;
};
type IdentifierDeclaration = {
node: any;
@@ -372,10 +375,10 @@ function addBinding(
name: string,
selector: string,
): void {
let scoped = bindings.get(scopeNode);
let scoped = bindings.byScope.get(scopeNode);
if (!scoped) {
scoped = new Map();
bindings.set(scopeNode, scoped);
bindings.byScope.set(scopeNode, scoped);
}
if (!scoped.has(name)) scoped.set(name, selector);
}
@@ -386,12 +389,12 @@ function lookupBindingFromAncestors(
bindings: TargetBindings,
): string | null {
for (const scopeNode of scopeChainFromAncestors(ancestors)) {
const selector = bindings.get(scopeNode)?.get(name);
const selector = bindings.byScope.get(scopeNode)?.get(name);
if (selector !== undefined) return selector;
}
// Program-scope bindings are stored under null (enclosingScopeNodeFromAncestors
// returns null when no function wrapper exists — the common case in HF scripts).
return bindings.get(null)?.get(name) ?? null;
return bindings.byScope.get(null)?.get(name) ?? null;
}
function isFunctionNode(node: any): boolean {
@@ -459,7 +462,7 @@ function collectTargetBindings(
scope: ScopeBindings,
identifierBindings: IdentifierBindingIndex,
): TargetBindings {
const bindings: TargetBindings = new Map();
const bindings: TargetBindings = { byScope: new Map(), identifiers: identifierBindings };
acornWalk.ancestor(ast, {
VariableDeclarator(node: any, _: unknown, ancestors: any[]) {
@@ -573,7 +576,21 @@ function resolveTargetSelector(
return typeof node.value === "string" ? node.value : null;
}
if (node.type === "Identifier") {
return lookupBindingFromAncestors(node.name, ancestors, bindings);
const bound = lookupBindingFromAncestors(node.name, ancestors, bindings);
if (bound) return bound;
const declaration = findVisibleIdentifierDeclaration(
node.name,
ancestors,
bindings.identifiers,
node.start,
);
if (
declaration?.kind !== "const" ||
bindings.identifiers.reassignedDeclarations.has(declaration.node)
)
return null;
const literal = resolveNode(declaration.node.init, scope);
return typeof literal === "string" ? literal : null;
}
if (node.type === "CallExpression") {
return selectorFromQueryCall(node, scope);
@@ -628,8 +645,9 @@ function describeProxyTarget(targetNode: any, varsNode: any, scope: ScopeBinding
const onUpdate = findPropertyNode(varsNode, "onUpdate");
const driven = onUpdate ? drivenDomChannel(onUpdate) : undefined;
if (driven) return `proxy → ${driven}`;
// Empty / proxy object with no resolvable DOM write ⇒ a timing spacer.
return "dwell/hold";
return objNode.properties.length === 0 && !onUpdate
? "dwell/hold"
: "proxy (unresolved DOM association)";
}
/** True for `el.style.foo = …` (nested-member style write). */
@@ -1421,6 +1439,10 @@ function tweenCallToAnimation(
duration,
ease,
};
if (
["stagger", "repeat", "repeatDelay", "delay"].some((key) => findPropertyNode(call.varsArg, key))
)
anim.inspectionTiming = inspectionTiming(call.varsArg, scope);
if (targetIdentity) anim.targetIdentity = targetIdentity;
if (!hasPositionArg) anim.implicitPosition = true;
let group = classifyTweenPropertyGroup(properties);
@@ -1446,35 +1468,67 @@ function tweenCallToAnimation(
// ── Stagger annotation (read path) ────────────────────────────────────────────
/**
* Pull the per-element stagger amount out of a captured `extras.stagger` value.
* GSAP staggers are either a number (`0.08`) or an object (`{ each: 0.012, ... }`).
* The value arrives here as the round-trip form `__raw:<source>`; we only need
* the leading numeric / `each:` figure for the annotation. Returns undefined for
* non-numeric (function) staggers — there's nothing honest to print.
*/
function inspectionTiming(
varsNode: any,
scope: ScopeBindings,
): NonNullable<GsapAnimation["inspectionTiming"]> {
const numeric = (key: string): number | null => {
const node = findPropertyNode(varsNode, key);
if (!node) return 0;
const value = resolveNode(node, scope);
return typeof value === "number" && Number.isFinite(value) ? value : null;
};
const node = findPropertyNode(varsNode, "stagger");
let stagger: NonNullable<GsapAnimation["inspectionTiming"]>["stagger"] = { each: 0 };
if (node) {
const value = resolveNode(node, scope);
stagger = typeof value === "number" ? { each: value } : null;
if (
node.type === "ObjectExpression" &&
!(node.properties ?? []).some(
(p: any) =>
!["each", "amount", "from", "ease", "axis", "grid"].includes(propKeyName(p) ?? ""),
)
) {
const each = resolveNode(findPropertyNode(node, "each"), scope);
const amount = resolveNode(findPropertyNode(node, "amount"), scope);
// Grid and axis distributions need runtime geometry.
if (!findPropertyNode(node, "grid") && !findPropertyNode(node, "axis")) {
if (typeof amount === "number") stagger = { amount };
else if (typeof each === "number") stagger = { each };
}
}
}
return {
repeat: numeric("repeat"),
repeatDelay: numeric("repeatDelay"),
delay: numeric("delay"),
stagger,
};
}
function staggerAmount(raw: unknown): number | undefined {
if (typeof raw === "number") return raw;
if (typeof raw !== "string") return undefined;
const src = raw.startsWith("__raw:") ? raw.slice(6) : raw;
const m = /(?:each\s*:\s*)?(-?\d+(?:\.\d+)?)/.exec(src);
if (!m) return undefined;
const n = Number.parseFloat(m[1]!);
return Number.isFinite(n) ? n : undefined;
try {
const node = acorn.parseExpressionAt(src, 0, { ecmaVersion: "latest" });
if (src.slice(node.end).trim()) return undefined;
const value = resolveNode(
node.type === "ObjectExpression" ? findPropertyNode(node, "each") : node,
new Map(),
);
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
} catch {
return undefined;
}
}
/** Rest-pose value GSAP animates to/from for an unspecified endpoint. */
function restValue(prop: string): number {
return prop === "opacity" || prop.startsWith("scale") ? 1 : 0;
}
/**
* Honest from/to keyframes for a flat staggered tween, mirroring how a flat
* tween renders: `from()` plays vars -> rest, `to()` plays rest -> vars, `fromTo`
* plays its authored endpoints. A synthetic `stagger` channel rides on both
* keyframes so the per-element cascade is visible even when the tween lands on
* the rest pose (a 1->1 collection that would otherwise read as a no-op).
*/
/** Studio reads these stops, including the rest pose. The CLI reports that pose as unresolved. */
function staggeredKeyframes(
anim: Omit<GsapAnimation, "id">,
each: number,
+6
View File
@@ -66,6 +66,12 @@ export interface GsapAnimation {
ease?: string;
/** Non-editable GSAP config (stagger, yoyo, repeat, etc.) preserved for round-trips. */
extras?: Record<string, unknown>;
inspectionTiming?: {
repeat: number | null;
repeatDelay: number | null;
delay: number | null;
stagger: { each: number } | { amount: number } | null;
};
/** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */
keyframes?: GsapKeyframesData;
/** Arc motion path config — present when the tween uses motionPath for curved position interpolation. */