Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/lint/src/rules/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ function collectDeclaredVariableIds(htmlTagRaw: string): Set<string> | 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<string> | null {
export function collectAllDeclaredVariableIds(tags: readonly OpenTag[]): Set<string> | null {
const all = new Set<string>();
for (const tag of tags) {
if (!readAttr(tag.raw, "data-composition-variables")) continue;
Expand Down
94 changes: 94 additions & 0 deletions packages/lint/src/rules/gsap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<h1 id="title">Visible text</h1>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#title", { color: "var(--accent2)", duration: 0.5 }, 0);
window.__timelines = { c1: tl };
</script>
</body></html>`;

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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><h1 id="title">Text</h1></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.fromTo("#title", { color: "var(--missing-from)" }, { color: "#fff", duration: 0.5 }, 0);
window.__timelines = { c1: tl };
</script>
</body></html>`;

const result = await lintHyperframeHtml(html);
expect(
result.findings.find((finding) => finding.code === "gsap_undefined_css_variable")?.message,
).toContain("--missing-from");
});

it.each([
["style block", `<style>:root { --accent2: #ff3366; }</style>`],
["inline style", `<div style="--accent2: #ff3366"></div>`],
])("accepts a GSAP CSS variable defined in a %s", async (_source, definition) => {
const html = `
<html><head>${definition}</head><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><h1 id="title">Text</h1></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#title", { color: "var(--accent2)", duration: 0.5 }, 0);
window.__timelines = { c1: tl };
</script>
</body></html>`;

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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><h1 id="title">Text</h1></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#title", { color: "var(--accent2, #fff)", duration: 0.5 }, 0);
window.__timelines = { c1: tl };
</script>
</body></html>`;

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 = `
<html data-composition-variables='[{"id":"accent2","type":"color","label":"Accent","default":"#ff3366"}]'>
<body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><h1 id="title">Text</h1></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#title", { color: "var(--accent2)", duration: 0.5 }, 0);
window.__timelines = { c1: tl };
</script>
</body></html>`;

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 = `
<html><body>
Expand Down
70 changes: 70 additions & 0 deletions packages/lint/src/rules/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
WINDOW_TIMELINE_ASSIGN_PATTERN,
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN,
} from "../utils";
import { collectAllDeclaredVariableIds } from "./composition";

// ── GSAP-specific types ────────────────────────────────────────────────────

Expand Down Expand Up @@ -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<string> {
const definitions = new Set<string>();
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<string, string | number>): boolean {
const visibility = stringValue(values.visibility)?.toLowerCase();
const display = stringValue(values.display)?.toLowerCase();
Expand Down Expand Up @@ -1040,6 +1079,37 @@ function collectCssOpacityZeroSelectors(

// fallow-ignore-next-line complexity
export const gsapRules: LintRule<LintContext>[] = [
// gsap_undefined_css_variable
async ({ tags, styles, scripts }) => {
const definedVariables = collectStaticCssVariableDefinitions(tags, styles);
const findings: HyperframeLintFinding[] = [];
const reported = new Set<string>();
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 }) => {
Expand Down
Loading