mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
feat(lint): add gsap_css_transform_conflict (#106)
## 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
This commit is contained in:
@@ -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 = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="title" style=""></div>
|
||||
</div>
|
||||
<style>
|
||||
#title { position: absolute; top: 240px; left: 50%; transform: translateX(-50%); }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#title", { x: 0, opacity: 1, duration: 0.4 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></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 = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="hero"></div>
|
||||
</div>
|
||||
<style>
|
||||
#hero { transform: scale(0.8); opacity: 0; }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { opacity: 1, scale: 1, duration: 0.5 }, 1.0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></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 = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="card"></div>
|
||||
</div>
|
||||
<style>
|
||||
#card { position: absolute; top: 100px; left: 100px; opacity: 0; }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#card", { x: 0, opacity: 1, duration: 0.3 }, 0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></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 = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="title"></div>
|
||||
</div>
|
||||
<style>
|
||||
#title { position: absolute; left: 50%; transform: translateX(-50%); }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#title", { xPercent: -50, x: -1000, opacity: 0 }, { xPercent: -50, x: 0, opacity: 1, duration: 0.4 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></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 = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="hero"></div>
|
||||
</div>
|
||||
<style>
|
||||
#hero { transform: translateX(-50%) scale(0.8); }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { x: 0, scale: 1, opacity: 1, duration: 0.5 }, 1.0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></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", () => {
|
||||
|
||||
Reference in New Issue
Block a user