diff --git a/packages/core/src/lint/rules/core.test.ts b/packages/core/src/lint/rules/core.test.ts index 4ac76b32d..5d2f65a55 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 98cf75ba1..e9b46325a 100644 --- a/packages/core/src/lint/rules/gsap.test.ts +++ b/packages/core/src/lint/rules/gsap.test.ts @@ -144,6 +144,171 @@ 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("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 2d311dbfb..570146d1c 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -170,6 +170,39 @@ 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 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; @@ -282,6 +315,81 @@ function getSingleClassSelector(selector: string): string | null { 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); +} + +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[] = []; @@ -399,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 @@ -424,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); @@ -486,6 +596,59 @@ export const gsapRules: LintRule[] = [ } } + // gsap_fullscreen_overlay_starts_visible + for (const tag of tags) { + 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 visibilityWindows = gsapWindows + .filter((win) => { + 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), + ); + }) + .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) => 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 (firstVisible.method !== "from" && !laterHidden) continue; + + reportedVisibleOverlayKeys.add(overlayKey); + 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: 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.`, + 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 2030a0658..b0901758a 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