From d01c30040daef1ddb7e26b808c40eeb85041be21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 23 Jun 2026 20:14:12 -0400 Subject: [PATCH 1/2] fix: flag visible GSAP transition overlays --- packages/core/src/lint/rules/core.test.ts | 11 ++ packages/core/src/lint/rules/gsap.test.ts | 70 +++++++++++++ packages/core/src/lint/rules/gsap.ts | 121 ++++++++++++++++++++++ packages/core/src/lint/utils.ts | 15 ++- 4 files changed, 216 insertions(+), 1 deletion(-) diff --git a/packages/core/src/lint/rules/core.test.ts b/packages/core/src/lint/rules/core.test.ts index 4ac76b32d7..5d2f65a55e 100644 --- a/packages/core/src/lint/rules/core.test.ts +++ b/packages/core/src/lint/rules/core.test.ts @@ -26,6 +26,17 @@ describe("core rules", () => { expect(finding?.severity).toBe("error"); }); + it("accepts body as the composition root", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined(); + expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined(); + }); + it("reports error when timeline registry is missing", async () => { const html = ` diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts index 98cf75ba12..2bdf9ce71d 100644 --- a/packages/core/src/lint/rules/gsap.test.ts +++ b/packages/core/src/lint/rules/gsap.test.ts @@ -144,6 +144,76 @@ describe("GSAP rules", () => { expect(finding).toBeUndefined(); }); + it("errors when a full-frame transition flash starts visible before GSAP controls it", async () => { + const html = ` + +
+

Scene 1

+

Scene 2

+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (f) => f.code === "gsap_fullscreen_overlay_starts_visible", + ); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.selector).toBe("#tr-flash-1"); + expect(finding?.message).toContain("blank/white video"); + }); + + it("does not error when a full-frame transition flash is initially hidden", async () => { + const html = ` + +
+

Scene 1

+

Scene 2

+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (f) => f.code === "gsap_fullscreen_overlay_starts_visible", + ); + expect(finding).toBeUndefined(); + }); + + it("does not error when GSAP hides a full-frame transition flash at frame zero", async () => { + const html = ` + +
+

Scene 1

+

Scene 2

+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (f) => f.code === "gsap_fullscreen_overlay_starts_visible", + ); + expect(finding).toBeUndefined(); + }); + it("does NOT error when GSAP animates opacity on a clip element", async () => { const html = ` diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index 2d311dbfb3..1e2485fa58 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -170,6 +170,34 @@ function isHiddenGsapState(values: Record): boolean { ); } +function oneValue( + values: Record, + keys: string[], +): string | number | undefined { + for (const key of keys) { + const value = values[key]; + if (value !== undefined) return value; + } + return undefined; +} + +function isVisibleGsapState(values: Record): boolean { + const opacity = oneValue(values, ["opacity", "autoAlpha"]); + if (typeof opacity === "number") return opacity > 0; + if (typeof opacity === "string" && opacity.trim()) { + const numeric = Number(opacity); + if (Number.isFinite(numeric)) return numeric > 0; + } + + const visibility = stringValue(values.visibility)?.toLowerCase(); + if (visibility === "visible" || visibility === "inherit") return true; + + const display = stringValue(values.display)?.toLowerCase(); + if (display && display !== "none") return true; + + return false; +} + function isSceneBoundaryExit(win: GsapWindow): boolean { if (win.end <= win.position) return false; if (win.method !== "to" && win.method !== "fromTo") return false; @@ -282,6 +310,53 @@ function getSingleClassSelector(selector: string): string | null { return match?.groups?.name || null; } +function getSingleIdSelector(selector: string): string | null { + const match = selector.trim().match(/^#(?[A-Za-z0-9_-]+)$/); + return match?.groups?.name || null; +} + +function readStyleProperty(style: string, property: string): string | null { + const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = style.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i")); + return match?.[1]?.trim() || null; +} + +function cssZero(value: string | null): boolean { + if (!value) return false; + return /^0(?:\.0+)?(?:px|%|vw|vh|rem|em)?$/i.test(value.trim()); +} + +function styleHasHiddenInitialState(style: string): boolean { + const opacity = readStyleProperty(style, "opacity"); + if (opacity && Number(opacity) === 0) return true; + if (readStyleProperty(style, "visibility")?.toLowerCase() === "hidden") return true; + if (readStyleProperty(style, "display")?.toLowerCase() === "none") return true; + return false; +} + +function styleHasOpaqueBackground(style: string): boolean { + const background = + readStyleProperty(style, "background") || readStyleProperty(style, "background-color"); + if (!background) return false; + const normalized = background.toLowerCase().replace(/\s+/g, ""); + if (normalized === "transparent" || normalized === "none") return false; + if (/rgba?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false; + if (/hsla?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false; + return true; +} + +function styleLooksFullFrameOverlay(style: string): boolean { + const position = readStyleProperty(style, "position")?.toLowerCase(); + if (position !== "fixed" && position !== "absolute") return false; + const coversFrame = + cssZero(readStyleProperty(style, "inset")) || + (cssZero(readStyleProperty(style, "top")) && + cssZero(readStyleProperty(style, "right")) && + cssZero(readStyleProperty(style, "bottom")) && + cssZero(readStyleProperty(style, "left"))); + return coversFrame && styleHasOpaqueBackground(style); +} + // fallow-ignore-next-line complexity function cssTransformToGsapProps(cssTransform: string): string | null { const parts: string[] = []; @@ -486,6 +561,52 @@ export const gsapRules: LintRule[] = [ } } + // gsap_fullscreen_overlay_starts_visible + for (const tag of tags) { + const id = readAttr(tag.raw, "id"); + if (!id) continue; + const inlineStyle = readAttr(tag.raw, "style"); + if (!inlineStyle || !styleLooksFullFrameOverlay(inlineStyle)) continue; + if (styleHasHiddenInitialState(inlineStyle)) continue; + + const selector = `#${id}`; + const visibilityWindows = gsapWindows + .filter((win) => { + if (getSingleIdSelector(win.targetSelector) !== id) return false; + return win.properties.some((prop) => + ["opacity", "autoAlpha", "visibility", "display"].includes(prop), + ); + }) + .sort((a, b) => a.position - b.position); + const startsHiddenAtZero = visibilityWindows.some( + (win) => + win.position <= SCENE_BOUNDARY_EPSILON_SECONDS && isHiddenGsapState(win.propertyValues), + ); + if (startsHiddenAtZero) continue; + const firstVisible = visibilityWindows.find((win) => + isVisibleGsapState(win.propertyValues), + ); + if (!firstVisible) continue; + const laterHidden = visibilityWindows.some( + (win) => win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues), + ); + if (!laterHidden) continue; + + findings.push({ + code: "gsap_fullscreen_overlay_starts_visible", + severity: "error", + message: + `Full-frame overlay "${selector}" starts visible before its first GSAP opacity tween at ` + + `${firstVisible.position.toFixed(2)}s. It will cover earlier render frames, often as a blank/white video.`, + selector, + elementId: id, + fixHint: + `Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add ` + + `\`tl.set("${selector}", { opacity: 0 }, 0)\` before the reveal tween.`, + snippet: truncateSnippet(firstVisible.raw), + }); + } + // gsap_animates_clip_element — only error when GSAP animates visibility/display for (const win of gsapWindows) { const sel = win.targetSelector; diff --git a/packages/core/src/lint/utils.ts b/packages/core/src/lint/utils.ts index 2030a0658b..b0901758a6 100644 --- a/packages/core/src/lint/utils.ts +++ b/packages/core/src/lint/utils.ts @@ -80,8 +80,21 @@ export function findHtmlTag(source: string): OpenTag | null { } export function findRootTag(source: string): OpenTag | null { - const bodyOpenMatch = /]*>/i.exec(source); + const bodyOpenMatch = /]*)>/i.exec(source); const bodyCloseMatch = /<\/body>/i.exec(source); + if ( + bodyOpenMatch && + (readAttr(bodyOpenMatch[0], "data-composition-id") || + readAttr(bodyOpenMatch[0], "data-width") || + readAttr(bodyOpenMatch[0], "data-height")) + ) { + return { + raw: bodyOpenMatch[0], + name: "body", + attrs: bodyOpenMatch[1] ?? "", + index: bodyOpenMatch.index, + }; + } const bodyStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0; const bodyEnd = bodyOpenMatch && bodyCloseMatch && bodyCloseMatch.index > bodyStart From 10be0b0c79af128d2e303fdeb5b1ad56825b40ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 23 Jun 2026 20:31:49 -0400 Subject: [PATCH 2/2] fix: cover GSAP overlay lint review cases --- packages/core/src/lint/rules/gsap.test.ts | 95 +++++++++++++++++++++++ packages/core/src/lint/rules/gsap.ts | 78 ++++++++++++++----- 2 files changed, 155 insertions(+), 18 deletions(-) diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts index 2bdf9ce71d..e9b46325a0 100644 --- a/packages/core/src/lint/rules/gsap.test.ts +++ b/packages/core/src/lint/rules/gsap.test.ts @@ -214,6 +214,101 @@ describe("GSAP rules", () => { expect(finding).toBeUndefined(); }); + it("reports one full-frame transition flash finding when multiple scripts touch it", async () => { + const html = ` + +
+

Scene 1

+

Scene 2

+ + + +`; + const result = await lintHyperframeHtml(html); + expect( + result.findings.filter((f) => f.code === "gsap_fullscreen_overlay_starts_visible"), + ).toHaveLength(1); + }); + + it("errors when a full-frame transition flash uses a GSAP from reveal", async () => { + const html = ` + +
+

Scene 1

+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (f) => f.code === "gsap_fullscreen_overlay_starts_visible", + ); + expect(finding).toBeDefined(); + expect(finding?.selector).toBe("#tr-flash-1"); + }); + + it("errors when a grouped GSAP selector targets a visible full-frame flash", async () => { + const html = ` + +
+
+

Scene 1

+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (f) => f.code === "gsap_fullscreen_overlay_starts_visible", + ); + expect(finding).toBeDefined(); + expect(finding?.selector).toBe("#tr-flash-1"); + }); + + it("errors when full-frame transition flash styles come from a style block", async () => { + const html = ` + + +
+

Scene 1

+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (f) => f.code === "gsap_fullscreen_overlay_starts_visible", + ); + expect(finding).toBeDefined(); + expect(finding?.selector).toBe(".tr-flash"); + }); + it("does NOT error when GSAP animates opacity on a clip element", async () => { const html = ` diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index 1e2485fa58..570146d1c6 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -198,6 +198,11 @@ function isVisibleGsapState(values: Record): boolean { return false; } +function makesOverlayVisible(win: GsapWindow): boolean { + if (win.method === "from" && isHiddenGsapState(win.propertyValues)) return true; + return isVisibleGsapState(win.propertyValues); +} + function isSceneBoundaryExit(win: GsapWindow): boolean { if (win.end <= win.position) return false; if (win.method !== "to" && win.method !== "fromTo") return false; @@ -310,11 +315,6 @@ function getSingleClassSelector(selector: string): string | null { return match?.groups?.name || null; } -function getSingleIdSelector(selector: string): string | null { - const match = selector.trim().match(/^#(?[A-Za-z0-9_-]+)$/); - return match?.groups?.name || null; -} - function readStyleProperty(style: string, property: string): string | null { const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = style.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i")); @@ -357,6 +357,39 @@ function styleLooksFullFrameOverlay(style: string): boolean { return coversFrame && styleHasOpaqueBackground(style); } +function collectSimpleStyleRules(styles: LintContext["styles"]): Map { + const rules = new Map(); + for (const style of styles) { + for (const [, selectorList, body] of style.content.matchAll(/([^{}]+)\{([^}]+)\}/g)) { + if (!selectorList || !body) continue; + for (const selector of selectorList.split(",")) { + const token = selector.trim(); + if (!/^[#.][A-Za-z0-9_-]+$/.test(token)) continue; + rules.set(token, `${rules.get(token) || ""};${body}`); + } + } + } + return rules; +} + +function tagSimpleSelectors(tag: OpenTag): string[] { + const selectors: string[] = []; + const id = readAttr(tag.raw, "id"); + if (id) selectors.push(`#${id}`); + const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? []; + for (const className of classes) selectors.push(`.${className}`); + return selectors; +} + +function combinedTagStyle(tag: OpenTag, styleRules: Map): string { + const styles = [readAttr(tag.raw, "style") || ""]; + for (const selector of tagSimpleSelectors(tag)) { + const ruleStyle = styleRules.get(selector); + if (ruleStyle) styles.push(ruleStyle); + } + return styles.filter(Boolean).join(";"); +} + // fallow-ignore-next-line complexity function cssTransformToGsapProps(cssTransform: string): string | null { const parts: string[] = []; @@ -474,7 +507,7 @@ function extractStandaloneGsapTransformCalls(script: string): GsapTransformCall[ export const gsapRules: LintRule[] = [ // overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector // fallow-ignore-next-line complexity - async ({ source, tags, scripts, rootCompositionId }) => { + async ({ source, tags, scripts, styles, rootCompositionId }) => { const findings: HyperframeLintFinding[] = []; // Build clip element selector map @@ -499,6 +532,8 @@ export const gsapRules: LintRule[] = [ const classUsage = countClassUsage(tags); const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags); + const styleRules = collectSimpleStyleRules(styles); + const reportedVisibleOverlayKeys = new Set(); for (const script of scripts) { const localTimelineCompId = readRegisteredTimelineCompositionId(script.content); @@ -563,16 +598,18 @@ export const gsapRules: LintRule[] = [ // gsap_fullscreen_overlay_starts_visible for (const tag of tags) { - const id = readAttr(tag.raw, "id"); - if (!id) continue; - const inlineStyle = readAttr(tag.raw, "style"); - if (!inlineStyle || !styleLooksFullFrameOverlay(inlineStyle)) continue; - if (styleHasHiddenInitialState(inlineStyle)) continue; + const selectors = tagSimpleSelectors(tag); + if (selectors.length === 0) continue; + const overlayKey = readAttr(tag.raw, "id") || String(tag.index); + if (reportedVisibleOverlayKeys.has(overlayKey)) continue; + const authoredStyle = combinedTagStyle(tag, styleRules); + if (!authoredStyle || !styleLooksFullFrameOverlay(authoredStyle)) continue; + if (styleHasHiddenInitialState(authoredStyle)) continue; - const selector = `#${id}`; const visibilityWindows = gsapWindows .filter((win) => { - if (getSingleIdSelector(win.targetSelector) !== id) return false; + const tokens = targetedSelectorTokens(win.targetSelector); + if (!selectors.some((selector) => tokens.has(selector))) return false; return win.properties.some((prop) => ["opacity", "autoAlpha", "visibility", "display"].includes(prop), ); @@ -583,15 +620,20 @@ export const gsapRules: LintRule[] = [ win.position <= SCENE_BOUNDARY_EPSILON_SECONDS && isHiddenGsapState(win.propertyValues), ); if (startsHiddenAtZero) continue; - const firstVisible = visibilityWindows.find((win) => - isVisibleGsapState(win.propertyValues), - ); + const firstVisible = visibilityWindows.find((win) => makesOverlayVisible(win)); if (!firstVisible) continue; + const selector = + selectors.find((candidate) => + targetedSelectorTokens(firstVisible.targetSelector).has(candidate), + ) || + selectors[0] || + tag.name; const laterHidden = visibilityWindows.some( (win) => win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues), ); - if (!laterHidden) continue; + if (firstVisible.method !== "from" && !laterHidden) continue; + reportedVisibleOverlayKeys.add(overlayKey); findings.push({ code: "gsap_fullscreen_overlay_starts_visible", severity: "error", @@ -599,7 +641,7 @@ export const gsapRules: LintRule[] = [ `Full-frame overlay "${selector}" starts visible before its first GSAP opacity tween at ` + `${firstVisible.position.toFixed(2)}s. It will cover earlier render frames, often as a blank/white video.`, selector, - elementId: id, + elementId: readAttr(tag.raw, "id") || undefined, fixHint: `Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add ` + `\`tl.set("${selector}", { opacity: 0 }, 0)\` before the reveal tween.`,