diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts
index 3bbc59bc8..9798776de 100644
--- a/packages/lint/src/rules/gsap.test.ts
+++ b/packages/lint/src/rules/gsap.test.ts
@@ -1393,6 +1393,49 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
+ it("errors when CSS-hidden content has a fromTo reveal without a destination opacity", async () => {
+ const html = `
+
+
+
Visible after entrance
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find(
+ (f) => f.code === "gsap_cold_seek_hidden_fromto_missing_reveal",
+ );
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ expect(finding?.selector).toBe("#card");
+ });
+
+ it("errors when standalone gsap.set hides a fromTo target with no destination opacity", async () => {
+ const html = `
+
+
+
Visible after entrance
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find(
+ (f) => f.code === "gsap_cold_seek_hidden_fromto_missing_reveal",
+ );
+ expect(finding).toBeDefined();
+ });
+
it("does NOT error when gsap.to() uses opacity:0 (exit animation)", async () => {
const html = `
diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts
index df5f516c6..08a2d6990 100644
--- a/packages/lint/src/rules/gsap.ts
+++ b/packages/lint/src/rules/gsap.ts
@@ -45,6 +45,7 @@ type GsapWindow = {
end: number;
properties: string[];
propertyValues: Record;
+ fromPropertyValues?: Record;
overwriteAuto: boolean;
method: string;
raw: string;
@@ -155,6 +156,7 @@ async function extractGsapWindows(script: string): Promise {
end: animation.position + effectiveDuration,
properties: Object.keys(animation.properties),
propertyValues: animation.properties,
+ fromPropertyValues: animation.fromProperties,
overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto",
method: animation.method,
raw: synthesizeWindowRaw(parsed.timelineVar, animation),
@@ -195,6 +197,29 @@ function isHiddenGsapState(values: Record): boolean {
);
}
+function extractStandaloneHiddenSelectors(script: string): Set {
+ const selectors = new Set();
+ const source = stripJsComments(script);
+ const aliases = new Map();
+ for (const match of source.matchAll(
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(["'`])([^"'`]+)\2\s*;/g,
+ )) {
+ aliases.set(match[1] ?? "", match[3] ?? "");
+ }
+ const pattern = /gsap\.set\s*\(\s*([^,]+?)\s*,\s*\{([\s\S]*?)\}\s*\)/g;
+ let match: RegExpExecArray | null;
+ while ((match = pattern.exec(source)) !== null) {
+ const target = (match[1] ?? "").trim();
+ const selector = /^(["'`])([^"'`]+)\1$/.exec(target)?.[2] ?? aliases.get(target);
+ if (!selector) continue;
+ const body = match[2] ?? "";
+ if (/(?:opacity|autoAlpha)\s*:\s*0(?:\.0+)?\s*(?:,|$)/.test(body)) {
+ selectors.add(selector);
+ }
+ }
+ return selectors;
+}
+
function oneValue(
values: Record,
keys: string[],
@@ -1138,7 +1163,10 @@ export const gsapRules: LintRule[] = [
return findings;
},
- // gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
+ // CSS/GSAP-hidden reveal safety. A fromTo() whose from-vars make an element
+ // visible but whose destination omits opacity works during sequential seeks,
+ // yet cold render workers restore the authored hidden state and encode it
+ // permanently invisible.
// fallow-ignore-next-line complexity
async ({ styles, scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];
@@ -1170,20 +1198,42 @@ export const gsapRules: LintRule[] = [
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 = await cachedExtractGsapWindows(script.content);
+ const hiddenSelectors = new Set([
+ ...cssOpacityZeroSelectors,
+ ...extractStandaloneHiddenSelectors(script.content),
+ ]);
for (const win of windows) {
+ const sel = win.targetSelector;
+ const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
+ if (!hiddenSelectors.has(cssKey)) continue;
+
+ if (
+ win.method === "fromTo" &&
+ win.fromPropertyValues &&
+ isVisibleGsapState(win.fromPropertyValues) &&
+ !win.properties.some((property) => property === "opacity" || property === "autoAlpha")
+ ) {
+ findings.push({
+ code: "gsap_cold_seek_hidden_fromto_missing_reveal",
+ severity: "error",
+ message:
+ `"${sel}" starts hidden, but its gsap.fromTo() makes it visible only in the from-vars ` +
+ "and omits opacity/autoAlpha from the destination. Cold render workers restore the hidden authored state, so the encoded element can stay invisible even when sequential snapshots look correct.",
+ selector: sel,
+ fixHint: `Add \`opacity: 1\` (or \`autoAlpha: 1\`) to the destination vars for "${sel}" so every seek path establishes the visible end state explicitly.`,
+ snippet: truncateSnippet(win.raw),
+ });
+ continue;
+ }
+
if (win.method !== "from") continue;
if (!win.properties.includes("opacity")) continue;
// Only a noop when the tween animates FROM 0 (same as the CSS value)
if (win.propertyValues["opacity"] !== 0) 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",