From 8f939243613ee5888cab3c76b94eee8a61aa1aee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 6 Sep 2026 00:22:03 +0000 Subject: [PATCH] fix(lint): warn on undefined GSAP color variables --- packages/lint/src/rules/composition.ts | 2 +- packages/lint/src/rules/gsap.test.ts | 94 ++++++++++++++++++++++++++ packages/lint/src/rules/gsap.ts | 70 +++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index d72a10575a..09f8b1018d 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -222,7 +222,7 @@ function collectDeclaredVariableIds(htmlTagRaw: string): Set | null { * template/fragment sub-comps hold it on their composition root div. Returns * null if any occurrence has unparseable JSON. */ -function collectAllDeclaredVariableIds(tags: readonly OpenTag[]): Set | null { +export function collectAllDeclaredVariableIds(tags: readonly OpenTag[]): Set | null { const all = new Set(); for (const tag of tags) { if (!readAttr(tag.raw, "data-composition-variables")) continue; diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 97511a7cff..4c56e792d5 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -3,6 +3,100 @@ import { describe, it, expect } from "vitest"; import { lintHyperframeHtml } from "../hyperframeLinter.js"; describe("GSAP rules", () => { + it("warns when a GSAP color tween uses an undefined CSS variable", async () => { + const html = ` + +
+

Visible text

+
+ +`; + + const result = await lintHyperframeHtml(html); + expect( + result.findings.find((finding) => finding.code === "gsap_undefined_css_variable"), + ).toMatchObject({ + severity: "warning", + selector: "#title", + }); + }); + + it("checks both ends of a fromTo color tween", async () => { + const html = ` + +

Text

+ +`; + + const result = await lintHyperframeHtml(html); + expect( + result.findings.find((finding) => finding.code === "gsap_undefined_css_variable")?.message, + ).toContain("--missing-from"); + }); + + it.each([ + ["style block", ``], + ["inline style", `
`], + ])("accepts a GSAP CSS variable defined in a %s", async (_source, definition) => { + const html = ` +${definition} +

Text

+ +`; + + const result = await lintHyperframeHtml(html); + expect( + result.findings.find((finding) => finding.code === "gsap_undefined_css_variable"), + ).toBeUndefined(); + }); + + it("accepts an undefined CSS variable with a var() fallback", async () => { + const html = ` + +

Text

+ +`; + + const result = await lintHyperframeHtml(html); + expect( + result.findings.find((finding) => finding.code === "gsap_undefined_css_variable"), + ).toBeUndefined(); + }); + + it("accepts a CSS variable declared through data-composition-variables", async () => { + const html = ` + + +

Text

+ +`; + + const result = await lintHyperframeHtml(html); + expect( + result.findings.find((finding) => finding.code === "gsap_undefined_css_variable"), + ).toBeUndefined(); + }); + it("errors when window.__timelines is registered BEFORE the fonts.ready build", async () => { const html = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 6cfa54b17d..a23aeda3b7 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -43,6 +43,7 @@ import { WINDOW_TIMELINE_ASSIGN_PATTERN, TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN, } from "../utils"; +import { collectAllDeclaredVariableIds } from "./composition"; // ── GSAP-specific types ──────────────────────────────────────────────────── @@ -192,6 +193,44 @@ function zeroValue(value: string | number | undefined): boolean { return Number(value.trim()) === 0; } +function isGsapColorProperty(property: string): boolean { + const normalized = property.toLowerCase(); + return ( + !normalized.startsWith("--") && + (normalized.endsWith("color") || normalized === "fill" || normalized === "stroke") + ); +} + +function collectStaticCssVariableDefinitions( + tags: readonly OpenTag[], + styles: LintContext["styles"], +): Set { + const definitions = new Set(); + const sources = [ + ...styles.map((style) => style.content), + ...tags.map((tag) => readDecodedAttr(tag.raw, "style") ?? ""), + ]; + const definitionPattern = /(?:^|[;{])\s*(--[A-Za-z0-9_-]+)\s*:/gm; + for (const source of sources) { + const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, " "); + for (const match of withoutComments.matchAll(definitionPattern)) { + if (match[1]) definitions.add(match[1]); + } + } + for (const id of collectAllDeclaredVariableIds(tags) ?? []) definitions.add(`--${id}`); + return definitions; +} + +function cssVariableReferencesWithoutFallback(value: unknown): string[] { + const text = unwrapRaw(value); + if (typeof text !== "string") return []; + const variables: string[] = []; + for (const match of text.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)\s*(,)?/g)) { + if (match[1] && !match[2]) variables.push(match[1]); + } + return variables; +} + function isHiddenGsapState(values: Record): boolean { const visibility = stringValue(values.visibility)?.toLowerCase(); const display = stringValue(values.display)?.toLowerCase(); @@ -1040,6 +1079,37 @@ function collectCssOpacityZeroSelectors( // fallow-ignore-next-line complexity export const gsapRules: LintRule[] = [ + // gsap_undefined_css_variable + async ({ tags, styles, scripts }) => { + const definedVariables = collectStaticCssVariableDefinitions(tags, styles); + const findings: HyperframeLintFinding[] = []; + const reported = new Set(); + for (const script of scripts) { + for (const win of await cachedExtractGsapWindows(script.content)) { + for (const values of [win.fromPropertyValues, win.propertyValues]) { + for (const [property, value] of Object.entries(values ?? {})) { + if (!isGsapColorProperty(property)) continue; + for (const variable of cssVariableReferencesWithoutFallback(value)) { + if (definedVariables.has(variable)) continue; + const key = `${win.targetSelector}|${property}|${variable}`; + if (reported.has(key)) continue; + reported.add(key); + findings.push({ + code: "gsap_undefined_css_variable", + severity: "warning", + message: `GSAP ${property} on "${win.targetSelector}" uses ${variable}, but no static CSS or composition-variable declaration defines it. The computed color may become invalid or transparent.`, + selector: win.targetSelector, + fixHint: `Define ${variable} in applicable CSS, declare "${variable.slice(2)}" in data-composition-variables, or add a var() fallback such as var(${variable}, #fff).`, + snippet: truncateSnippet(win.raw), + }); + } + } + } + } + } + return findings; + }, + // overlapping_gsap_tweens + gsap_animates_clip_element // fallow-ignore-next-line complexity async ({ source, tags, scripts, styles, rootCompositionId }) => {