diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts
index 2715ff5a9..66c0486a6 100644
--- a/packages/lint/src/rules/gsap.test.ts
+++ b/packages/lint/src/rules/gsap.test.ts
@@ -978,6 +978,91 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
+ it("warns when repeated tl.fromTo calls target one selector without a tl.set baseline", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_repeated_fromto_without_baseline");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("warning");
+ expect(finding?.selector).toBe("#overlay .cell");
+ });
+
+ it("does not warn when repeated tl.fromTo calls have an earlier tl.set baseline", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_repeated_fromto_without_baseline");
+ expect(finding).toBeUndefined();
+ });
+
+ it("does not warn for a single tl.fromTo call on a selector", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_repeated_fromto_without_baseline");
+ expect(finding).toBeUndefined();
+ });
+
+ it("does not warn when tl.fromTo calls target different selectors", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_repeated_fromto_without_baseline");
+ expect(finding).toBeUndefined();
+ });
+
it("warns when an opacity exit ends at a clip start boundary without a hard kill", async () => {
const html = `
diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts
index b54b7c873..0d929d0f6 100644
--- a/packages/lint/src/rules/gsap.ts
+++ b/packages/lint/src/rules/gsap.ts
@@ -618,6 +618,57 @@ export const gsapRules: LintRule[] = [
}
}
+ // gsap_repeated_fromto_without_baseline
+ const fromToWindowsBySelector = new Map();
+ for (const win of gsapWindows) {
+ if (win.method !== "fromTo") continue;
+ if (win.targetSelector === UNRESOLVED_TARGET) continue;
+ const windows = fromToWindowsBySelector.get(win.targetSelector) ?? [];
+ windows.push(win);
+ fromToWindowsBySelector.set(win.targetSelector, windows);
+ }
+
+ const repeatedFromToSelectors = [...fromToWindowsBySelector.values()].filter(
+ (windows) => windows.length >= 2,
+ );
+ const standaloneSetSelectors =
+ repeatedFromToSelectors.length > 0
+ ? new Set(
+ extractStandaloneGsapTransformCalls(stripJsComments(script.content))
+ .filter((call) => call.method === "set")
+ .map((call) => call.selector),
+ )
+ : new Set();
+
+ for (const fromToWindows of repeatedFromToSelectors) {
+ const selector = fromToWindows[0]?.targetSelector;
+ if (!selector) continue;
+ const firstFromToPosition = Math.min(...fromToWindows.map((win) => win.position));
+ const hasTimelineBaseline = gsapWindows.some(
+ (candidate) =>
+ candidate.method === "set" &&
+ candidate.targetSelector === selector &&
+ candidate.position <= firstFromToPosition,
+ );
+ if (hasTimelineBaseline || standaloneSetSelectors.has(selector)) continue;
+
+ findings.push({
+ code: "gsap_repeated_fromto_without_baseline",
+ severity: "warning",
+ message:
+ `${fromToWindows.length} tl.fromTo() calls target "${selector}" with no earlier tl.set() baseline. ` +
+ `The last-authored fromTo "from" values become the element's resting state for any seek before ` +
+ `the first tween actually runs, because GSAP applies fromTo from-values at authoring time ` +
+ `(immediateRender), not at tween position.`,
+ selector,
+ fixHint:
+ `Add \`tl.set("${selector}", { ...safe resting values... }, 0)\` as an explicit baseline before ` +
+ `the first fromTo, so pre-first-tween seeks have a defined, deterministic resting state instead ` +
+ `of inheriting whichever fromTo call happened to author last.`,
+ snippet: truncateSnippet(fromToWindows.map((win) => win.raw).join("\n")),
+ });
+ }
+
// gsap_exit_missing_hard_kill
if (clipStartBoundaries.length > 0) {
for (const win of gsapWindows) {