From 9e51f5cfaacfdd26bf09a70fa921f48b7fbe886a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 21 May 2026 17:58:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(lint):=20add=20gsap=5Ffrom=5Fopacity=5Fnoo?= =?UTF-8?q?p=20rule=20=E2=80=94=20catch=20invisible=20elements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects when an element has CSS `opacity: 0` (inline or style block) AND is targeted by gsap.from({opacity: 0}). Since from() animates FROM the specified value TO the CSS value, this produces a 0→0 animation where the element never becomes visible. Root cause of all-black renders from the product-launch-video skill: every text element had opacity:0 in CSS + gsap.from({opacity:0}), making all text permanently invisible despite the timeline "working." Fires as error (not warning) to block the render pipeline. Includes actionable fix hint. 4 test cases: inline style, style block, clean code (no false positive), and gsap.to() exit (no false positive). Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/lint/rules/gsap.test.ts | 77 +++++++++++++++++++++++ packages/core/src/lint/rules/gsap.ts | 56 +++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts index 7e989c976..4ec961842 100644 --- a/packages/core/src/lint/rules/gsap.test.ts +++ b/packages/core/src/lint/rules/gsap.test.ts @@ -746,4 +746,81 @@ describe("GSAP rules", () => { const finding = result.findings.find((f) => f.code === "gsap_infinite_repeat"); expect(finding).toBeUndefined(); }); + + it("errors when CSS opacity:0 + gsap.from({opacity:0}) — invisible forever", () => { + const html = ` + +
+
Hello
+
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop"); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe("error"); + expect(finding!.selector).toBe("#title"); + }); + + it("errors when style block has opacity:0 + gsap.from({opacity:0})", () => { + const html = ` + +
+
Hello
+
+ + +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop"); + expect(finding).toBeDefined(); + }); + + it("does NOT error when gsap.from({opacity:0}) and CSS has no opacity:0", () => { + const html = ` + +
+
Hello
+
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop"); + expect(finding).toBeUndefined(); + }); + + it("does NOT error when gsap.to() uses opacity:0 (exit animation)", () => { + const html = ` + +
+
Hello
+
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop"); + expect(finding).toBeUndefined(); + }); }); diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index 26c224818..76ac506ca 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -843,4 +843,60 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ } return findings; }, + + // gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever + ({ styles, scripts, tags }) => { + const findings: HyperframeLintFinding[] = []; + const cssOpacityZeroSelectors = new Set(); + + for (const style of styles) { + for (const [, selector, body] of style.content.matchAll( + /([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g, + )) { + if (body && /opacity\s*:\s*0\s*[;}]/.test(body)) { + cssOpacityZeroSelectors.add((selector ?? "").trim()); + } + } + } + + for (const tag of tags) { + const inlineStyle = readAttr(tag.raw, "style"); + if (!inlineStyle || !/opacity\s*:\s*0/.test(inlineStyle)) continue; + const id = readAttr(tag.raw, "id"); + const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? []; + if (id) cssOpacityZeroSelectors.add(`#${id}`); + for (const cls of classes) cssOpacityZeroSelectors.add(`.${cls}`); + } + + if (cssOpacityZeroSelectors.size === 0) return findings; + + for (const script of scripts) { + if (!/gsap\.timeline/.test(script.content)) continue; + const windows = extractGsapWindows(script.content); + + for (const win of windows) { + if (win.method !== "from" && win.method !== "fromTo") continue; + if (!win.properties.includes("opacity")) continue; + const sel = win.targetSelector; + const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`; + if (!cssOpacityZeroSelectors.has(cssKey)) continue; + + findings.push({ + code: "gsap_from_opacity_noop", + severity: "error", + message: + `"${sel}" has CSS \`opacity: 0\` and a gsap.${win.method}() that also sets opacity to 0. ` + + `gsap.from() animates FROM the specified value TO the current CSS value — ` + + `since CSS is already 0, the element animates from 0→0 and never becomes visible.`, + selector: sel, + fixHint: + `Remove \`opacity: 0\` from the CSS/inline style on "${sel}". ` + + `Let gsap.from({opacity: 0}) handle the initial hidden state — ` + + `it will animate FROM 0 TO the CSS value (1 by default).`, + snippet: truncateSnippet(win.raw), + }); + } + } + return findings; + }, ];