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 = `
+
+
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+
+
+`;
+ 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