From d781398813d16ed8d471df3d099b4b497c95c892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 28 Mar 2026 01:44:52 +0100 Subject: [PATCH] feat(lint): add gsap_css_transform_conflict (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes Added a new lint rule `gsap_css_transform_conflict` that detects when GSAP animations will silently overwrite CSS transforms. **`gsap_css_transform_conflict` (error)** — fires when an element has `transform: translateX(-50%)` or `transform: scale()` in CSS and a GSAP `tl.to/from` tween animates `x`, `y`, `xPercent`, `yPercent`, or `scale`. GSAP silently overwrites the full CSS transform, discarding centering tricks like `translateX(-50%)`. Fix hint guides authors to the safe `fromTo` + `xPercent` pattern. ## Root cause This bug surfaced while building compositions where title reveals were placed off-center because `tl.to("#title", { x: 0 })` stripped the `translateX(-50%)` centering from CSS. ## Test coverage - [x] `gsap_css_transform_conflict` — `tl.to` with `x` on CSS `translateX` element → error - [x] `gsap_css_transform_conflict` — `tl.to` with `scale` on CSS `scale()` element → error - [x] `gsap_css_transform_conflict` — `tl.fromTo` without CSS transform → no finding --- .../core/src/lint/hyperframeLinter.test.ts | 114 ++++++++++++++++++ packages/core/src/lint/hyperframeLinter.ts | 100 +++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/packages/core/src/lint/hyperframeLinter.test.ts b/packages/core/src/lint/hyperframeLinter.test.ts index a3fe608e2..1e4b6b618 100644 --- a/packages/core/src/lint/hyperframeLinter.test.ts +++ b/packages/core/src/lint/hyperframeLinter.test.ts @@ -217,6 +217,120 @@ describe("lintScriptUrls", () => { vi.unstubAllGlobals(); }); + + // ── gsap_css_transform_conflict ────────────────────────────────────────── + + it("warns when tl.to animates x on an element with CSS translateX", () => { + const html = ` + +
+
+
+ + +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + expect(finding?.selector).toBe("#title"); + expect(finding?.fixHint).toMatch(/fromTo/); + expect(finding?.fixHint).toMatch(/xPercent/); + }); + + it("warns when tl.to animates scale on an element with CSS scale transform", () => { + const html = ` + +
+
+
+ + +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + expect(finding?.selector).toBe("#hero"); + }); + + it("does NOT warn when tl.to targets element without CSS transform", () => { + const html = ` + +
+
+
+ + +`; + const result = lintHyperframeHtml(html); + const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict"); + expect(conflict).toBeUndefined(); + }); + + it("does NOT warn when tl.fromTo targets element WITH CSS transform (author owns both ends)", () => { + const html = ` + +
+
+
+ + +`; + const result = lintHyperframeHtml(html); + const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict"); + expect(conflict).toBeUndefined(); + }); + + it("emits one warning when a combined CSS transform conflicts with multiple GSAP properties", () => { + const html = ` + +
+
+
+ + +`; + const result = lintHyperframeHtml(html); + const conflicts = result.findings.filter((f) => f.code === "gsap_css_transform_conflict"); + expect(conflicts).toHaveLength(1); + expect(conflicts[0]?.message).toMatch(/x\/scale|scale\/x/); + }); }); describe("template_literal_selector rule", () => { diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts index f43deb70c..f97c79e9f 100644 --- a/packages/core/src/lint/hyperframeLinter.ts +++ b/packages/core/src/lint/hyperframeLinter.ts @@ -21,6 +21,7 @@ type GsapWindow = { end: number; properties: string[]; overwriteAuto: boolean; + method: string; raw: string; }; @@ -522,6 +523,104 @@ export function lintHyperframeHtml( } } + // ── Rule: gsap_css_transform_conflict ───────────────────────────────────── + // Detects elements whose CSS