Skip to content
Merged
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
11 changes: 11 additions & 0 deletions packages/core/src/lint/rules/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ describe("core rules", () => {
expect(finding?.severity).toBe("error");
});

it("accepts body as the composition root", async () => {
const html = `
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay-flash"></div>
<script>window.__timelines = window.__timelines || {};</script>
</body></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 = `
<html><body>
Expand Down
165 changes: 165 additions & 0 deletions packages/core/src/lint/rules/gsap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
<section class="clip" data-start="8" data-duration="8"><h1>Scene 2</h1></section>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
tl.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;opacity:0;pointer-events:none;z-index:990"></div>
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
<section class="clip" data-start="8" data-duration="8"><h1>Scene 2</h1></section>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
tl.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
<section class="clip" data-start="8" data-duration="8"><h1>Scene 2</h1></section>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.set("#tr-flash-1", { opacity: 0 }, 0);
tl.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
tl.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
<section class="clip" data-start="8" data-duration="8"><h1>Scene 2</h1></section>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
tl.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
window.__timelines["c1"] = tl;
</script>
<script>
const tl2 = gsap.timeline({ paused: true });
tl2.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 9.92);
tl2.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 10.00);
</script>
</body></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 = `
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from("#tr-flash-1", { opacity: 0, duration: 0.18 }, 7.92);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
<div id="unused"></div>
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#unused, #tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
tl.to("#unused, #tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
<style>
.tr-flash { position: fixed; inset: 0; background: #fff; pointer-events: none; z-index: 990; }
</style>
<div id="tr-flash-1" class="tr-flash"></div>
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to(".tr-flash", { opacity: 1, duration: 0.08 }, 7.92);
tl.to(".tr-flash", { opacity: 0, duration: 0.18 }, 8.00);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
Expand Down
165 changes: 164 additions & 1 deletion packages/core/src/lint/rules/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,39 @@ function isHiddenGsapState(values: Record<string, string | number>): boolean {
);
}

function oneValue(
values: Record<string, string | number>,
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<string, string | number>): 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;
Expand Down Expand Up @@ -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<string, string> {
const rules = new Map<string, string>();
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, string>): 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[] = [];
Expand Down Expand Up @@ -399,7 +507,7 @@ function extractStandaloneGsapTransformCalls(script: string): GsapTransformCall[
export const gsapRules: LintRule<LintContext>[] = [
// 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
Expand All @@ -424,6 +532,8 @@ export const gsapRules: LintRule<LintContext>[] = [

const classUsage = countClassUsage(tags);
const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags);
const styleRules = collectSimpleStyleRules(styles);
const reportedVisibleOverlayKeys = new Set<string>();

for (const script of scripts) {
const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
Expand Down Expand Up @@ -486,6 +596,59 @@ export const gsapRules: LintRule<LintContext>[] = [
}
}

// 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;
Expand Down
Loading
Loading