From 8e4de38be1bdf098a7469a98ed654a932351f327 Mon Sep 17 00:00:00 2001 From: Xuanru Li <157947275+xuanruli@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:52:22 +0000 Subject: [PATCH] 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. --- .../src/commands/keyframes.regression.test.ts | 120 +++++++ packages/cli/src/commands/keyframes.ts | 310 +++++++++++++----- packages/cli/src/commands/motionShot.test.ts | 25 ++ packages/cli/src/commands/motionShot.ts | 13 + packages/lint/src/rules/gsap.test.ts | 18 + packages/lint/src/rules/gsap.ts | 2 +- .../src/__goldens__/complex.parsed.json | 16 + packages/parsers/src/gsapParserAcorn.ts | 110 +++++-- packages/parsers/src/gsapSerialize.ts | 6 + 9 files changed, 503 insertions(+), 117 deletions(-) create mode 100644 packages/cli/src/commands/keyframes.regression.test.ts 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. */