diff --git a/packages/cli/src/commands/keyframes.regression.test.ts b/packages/cli/src/commands/keyframes.regression.test.ts
new file mode 100644
index 0000000000..8a2d86206b
--- /dev/null
+++ b/packages/cli/src/commands/keyframes.regression.test.ts
@@ -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 = '
') =>
+ surfaceComposition(
+ `${dom}`,
+ "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);',
+ '',
+ );
+ 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});',
+ ''.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);',
+ ''.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);',
+ '',
+ );
+ 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 = ''.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});',
+ ''.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__");
+});
diff --git a/packages/cli/src/commands/keyframes.ts b/packages/cli/src/commands/keyframes.ts
index 4fa7f4cd84..ca55dd8627 100644
--- a/packages/cli/src/commands/keyframes.ts
+++ b/packages/cli/src/commands/keyframes.ts
@@ -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;
}
@@ -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): Record {
- const base: Record = {};
- 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): Record }>;
- 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;
+type StaggerTiming = NonNullable;
+type RawKeyframe = { percentage: number; properties: Record };
+
+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, 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();
- 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 {
.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`)}`,
diff --git a/packages/cli/src/commands/motionShot.test.ts b/packages/cli/src/commands/motionShot.test.ts
index 45207ce150..c4b525a077 100644
--- a/packages/cli/src/commands/motionShot.test.ts
+++ b/packages/cli/src/commands/motionShot.test.ts
@@ -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 = '';
+ 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 });
+});
diff --git a/packages/cli/src/commands/motionShot.ts b/packages/cli/src/commands/motionShot.ts
index 458adf1003..b70e59ffa0 100644
--- a/packages/cli/src/commands/motionShot.ts
+++ b/packages/cli/src/commands/motionShot.ts
@@ -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 };
diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts
index 0da514f310..220ba32a91 100644
--- a/packages/lint/src/rules/gsap.test.ts
+++ b/packages/lint/src/rules/gsap.test.ts
@@ -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 = `
+
+
+
+
+`;
+ 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 = `
diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts
index 6e3fbc912f..55f30d2460 100644
--- a/packages/lint/src/rules/gsap.ts
+++ b/packages/lint/src/rules/gsap.ts
@@ -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 ")
);
}
diff --git a/packages/parsers/src/__goldens__/complex.parsed.json b/packages/parsers/src/__goldens__/complex.parsed.json
index d1032f39b9..9de094187e 100644
--- a/packages/parsers/src/__goldens__/complex.parsed.json
+++ b/packages/parsers/src/__goldens__/complex.parsed.json
@@ -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"
},
diff --git a/packages/parsers/src/gsapParserAcorn.ts b/packages/parsers/src/gsapParserAcorn.ts
index bb0da7e174..3d38bfb727 100644
--- a/packages/parsers/src/gsapParserAcorn.ts
+++ b/packages/parsers/src/gsapParserAcorn.ts
@@ -73,7 +73,10 @@ function parseProgram(script: string): any {
type ScopeBindings = ReadonlyMap;
/** Per-scope element bindings: scopeNode → (variable name → selector). */
-type TargetBindings = Map>;
+type TargetBindings = {
+ byScope: Map>;
+ 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:`; 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 {
+ 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["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,
each: number,
diff --git a/packages/parsers/src/gsapSerialize.ts b/packages/parsers/src/gsapSerialize.ts
index 9fbb975bf3..69735928b2 100644
--- a/packages/parsers/src/gsapSerialize.ts
+++ b/packages/parsers/src/gsapSerialize.ts
@@ -66,6 +66,12 @@ export interface GsapAnimation {
ease?: string;
/** Non-editable GSAP config (stagger, yoyo, repeat, etc.) preserved for round-trips. */
extras?: Record;
+ 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. */