diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts
index d60992b2d..dc7bf823a 100644
--- a/packages/lint/src/rules/gsap.test.ts
+++ b/packages/lint/src/rules/gsap.test.ts
@@ -478,6 +478,27 @@ describe("GSAP rules", () => {
expect(finding?.fixHint).toMatch(/xPercent/);
});
+ it("does NOT warn when GSAP animates a canvas layout subtree with a CSS transform", async () => {
+ const html = `
+
+
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
+ expect(conflict).toBeUndefined();
+ });
+
it("warns when tl.to animates scale on an element with CSS scale transform", async () => {
const html = `
diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts
index 03fd0e621..f5175ef76 100644
--- a/packages/lint/src/rules/gsap.ts
+++ b/packages/lint/src/rules/gsap.ts
@@ -387,6 +387,27 @@ function tagSimpleSelectors(tag: OpenTag): string[] {
return selectors;
}
+function tagHasAttr(tag: OpenTag, attr: string): boolean {
+ const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|$)`, "i").test(tag.attrs);
+}
+
+function isLayoutSubtreeCanvas(tag: OpenTag): boolean {
+ return tag.name.toLowerCase() === "canvas" && tagHasAttr(tag, "layoutsubtree");
+}
+
+function tagMatchesSimpleSelector(tag: OpenTag, selector: string): boolean {
+ if (selector.startsWith("#")) return readAttr(tag.raw, "id") === selector.slice(1);
+ if (!selector.startsWith(".")) return false;
+ const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
+ return classes.includes(selector.slice(1));
+}
+
+function selectorOnlyMatchesLayoutSubtreeCanvas(selector: string, tags: OpenTag[]): boolean {
+ const matches = tags.filter((tag) => tagMatchesSimpleSelector(tag, selector));
+ return matches.length > 0 && matches.every(isLayoutSubtreeCanvas);
+}
+
function combinedTagStyle(tag: OpenTag, styleRules: Map): string {
const styles = [readAttr(tag.raw, "style") || ""];
for (const selector of tagSimpleSelectors(tag)) {
@@ -714,29 +735,26 @@ export const gsapRules: LintRule[] = [
for (const [, selector, body] of style.content.matchAll(
/([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g,
)) {
+ const normalizedSelector = (selector ?? "").trim();
+ if (selectorOnlyMatchesLayoutSubtreeCanvas(normalizedSelector, tags)) continue;
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);
+ cssTranslateSelectors.set(normalizedSelector, transformVal);
+ if (/scale/i.test(transformVal)) cssScaleSelectors.set(normalizedSelector, transformVal);
}
}
// Also check inline style="..." attributes on tags
for (const tag of tags) {
+ if (isLayoutSubtreeCanvas(tag)) continue;
const inlineStyle = readAttr(tag.raw, "style");
if (!inlineStyle) continue;
const tMatch = inlineStyle.match(/transform\s*:\s*([^;]+)/);
if (!tMatch || !tMatch[1]) continue;
const transformVal = tMatch[1].trim();
- // Derive selectors from the tag's id and all classes
- const id = readAttr(tag.raw, "id");
- const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
- const selectors: string[] = [];
- if (id) selectors.push(`#${id}`);
- for (const cls of classes) selectors.push(`.${cls}`);
+ const selectors = tagSimpleSelectors(tag);
if (selectors.length === 0) continue;
for (const sel of selectors) {
if (/translate/i.test(transformVal) && !cssTranslateSelectors.has(sel))