feat(lint): add gsap_css_transform_conflict warning

Detects elements whose CSS <style> block sets `transform: translate*` or
`transform: scale*` that are also targeted by a GSAP tl.to/tl.from tween
animating x, y, xPercent, yPercent, or scale. GSAP's transform properties
overwrite the *entire* CSS transform, silently discarding translateX(-50%)
centering and similar positioning tricks.

tl.fromTo is exempt: when the author provides explicit from/to states they
own both ends of the transform, so overwriting CSS is intentional.

Combined transforms (translateX(-50%) scale(0.8)) that conflict with
multiple tween properties produce a single deduplicated finding.

Adds a method field to GsapWindow so the rule can distinguish tl.to/from
(conflict) from tl.fromTo (exempt).

Known limitations noted in comments: inline style transforms are not
detected; CSS selector regex handles bare #id/.class only.

Tests: tl.to on CSS translateX → warn; tl.to on CSS scale → warn;
tl.fromTo on CSS translateX → no finding (exempt); tl.to without CSS
transform → no finding; combined transform → single finding.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-03-27 20:44:09 -04:00
co-authored by Claude Sonnet 4.6
parent f0a8644208
commit 59dddd9045
2 changed files with 214 additions and 0 deletions
@@ -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", () => {
+100
View File
@@ -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 <style> block sets `transform: translate*` or
// `transform: scale*` that are also targeted by a GSAP tl.to/tl.from tween
// animating x, y, xPercent, yPercent, or scale. GSAP's transform properties
// overwrite the *entire* CSS transform, silently discarding translateX(-50%)
// centering and similar positioning tricks.
//
// tl.fromTo is exempt: when the author provides explicit from/to states they
// own both ends of the transform, so overwriting CSS is intentional.
//
// Known limitations:
// - Only scans <style> blocks. Inline style="transform:..." on elements is
// not detected. This is common in AI-generated compositions and may cause
// false negatives. A follow-up could scan tag `style` attributes.
// - CSS selector regex matches bare #id and .class only. Compound selectors
// (#root .title), grouped selectors (#a, #b), and attribute selectors are
// not matched. Compositions typically use flat IDs so risk is low, but
// future maintainers should not assume full CSS parsing.
{
// selector → transform value (bare #id / .class only — see limitation above)
const cssTranslateSelectors = new Map<string, string>();
const cssScaleSelectors = new Map<string, string>();
for (const style of styles) {
for (const [, selector, body] of style.content.matchAll(
/([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g,
)) {
const tMatch = body?.match(/transform\s*:\s*([^;]+)/);
if (!tMatch || !tMatch[1]) continue;
const transformVal = tMatch[1].trim();
if (/translate/i.test(transformVal)) {
cssTranslateSelectors.set((selector ?? "").trim(), transformVal);
}
if (/scale/i.test(transformVal)) {
cssScaleSelectors.set((selector ?? "").trim(), transformVal);
}
}
}
if (cssTranslateSelectors.size > 0 || cssScaleSelectors.size > 0) {
for (const script of scripts) {
if (!/gsap\.timeline/.test(script.content)) continue;
const windows = extractGsapWindows(script.content);
// Collect all conflicting properties per selector before emitting, so
// a combined transform (translateX(-50%) scale(0.8)) with a tween that
// animates both x and scale produces one finding, not two.
type Conflict = { cssTransform: string; props: Set<string>; raw: string };
const conflicts = new Map<string, Conflict>();
for (const win of windows) {
// fromTo: author explicitly sets both ends — overwriting CSS is intentional
if (win.method === "fromTo") continue;
const sel = win.targetSelector;
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
const translateProps = win.properties.filter((p) =>
["x", "y", "xPercent", "yPercent"].includes(p),
);
const scaleProps = win.properties.filter((p) => p === "scale");
const cssFromTranslate =
translateProps.length > 0 ? cssTranslateSelectors.get(cssKey) : undefined;
const cssFromScale = scaleProps.length > 0 ? cssScaleSelectors.get(cssKey) : undefined;
if (!cssFromTranslate && !cssFromScale) continue;
const existing = conflicts.get(sel) ?? {
cssTransform: [cssFromTranslate, cssFromScale].filter(Boolean).join(" "),
props: new Set<string>(),
raw: win.raw,
};
for (const p of [...translateProps, ...scaleProps]) existing.props.add(p);
conflicts.set(sel, existing);
}
for (const [sel, { cssTransform, props, raw }] of conflicts) {
const propList = [...props].join("/");
pushFinding({
code: "gsap_css_transform_conflict",
severity: "warning",
message:
`"${sel}" has CSS \`transform: ${cssTransform}\` and a GSAP tween animates ` +
`${propList}. GSAP will overwrite the full CSS transform, discarding any ` +
`translateX(-50%) centering or CSS scale value.`,
selector: sel,
fixHint:
`Remove the transform from CSS and use tl.fromTo('${sel}', ` +
`{ xPercent: -50, x: -1000 }, { xPercent: -50, x: 0 }) so GSAP owns ` +
`the full transform state. tl.fromTo is exempt from this rule.`,
snippet: truncateSnippet(raw),
});
}
}
}
}
const errorCount = findings.filter((finding) => finding.severity === "error").length;
const warningCount = findings.length - errorCount;
@@ -681,6 +780,7 @@ function extractGsapWindows(script: string): GsapWindow[] {
end: animation.position + meta.effectiveDuration,
properties: meta.properties.length > 0 ? meta.properties : Object.keys(animation.properties),
overwriteAuto: meta.overwriteAuto,
method: match[1] ?? "to",
raw,
});
}