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();
|
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", () => {
|
describe("template_literal_selector rule", () => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type GsapWindow = {
|
|||||||
end: number;
|
end: number;
|
||||||
properties: string[];
|
properties: string[];
|
||||||
overwriteAuto: boolean;
|
overwriteAuto: boolean;
|
||||||
|
method: string;
|
||||||
raw: 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 errorCount = findings.filter((finding) => finding.severity === "error").length;
|
||||||
const warningCount = findings.length - errorCount;
|
const warningCount = findings.length - errorCount;
|
||||||
|
|
||||||
@@ -681,6 +780,7 @@ function extractGsapWindows(script: string): GsapWindow[] {
|
|||||||
end: animation.position + meta.effectiveDuration,
|
end: animation.position + meta.effectiveDuration,
|
||||||
properties: meta.properties.length > 0 ? meta.properties : Object.keys(animation.properties),
|
properties: meta.properties.length > 0 ? meta.properties : Object.keys(animation.properties),
|
||||||
overwriteAuto: meta.overwriteAuto,
|
overwriteAuto: meta.overwriteAuto,
|
||||||
|
method: match[1] ?? "to",
|
||||||
raw,
|
raw,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user