Files
hyperframes/packages/lint/src/rules/gsap.ts
T
Miguel Ángel 55ee559e40 fix(lint): catch cold-seek opacity reveals (#2503)
* fix(lint): catch cold-seek opacity reveals

* fix(lint): resolve hidden selector aliases

* style(lint): format gsap rule
2026-07-16 12:03:25 -04:00

1462 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
interface LintParsedGsap {
animations: Array<{
targetSelector: string;
targetIdentity?: string;
method: string;
position: number | string;
properties: Record<string, number | string>;
// fromTo() exposes its first ("from") vars object separately; a layout/reflow prop
// that appears only here still animates and must be checked.
fromProperties?: Record<string, number | string>;
duration?: number;
ease?: string;
extras?: Record<string, unknown>;
}>;
timelineVar: string;
}
// Use the acorn read parser: it resolves computed timelines (helpers, bounded
// loops) so lint findings like overlapping_gsap_tweens reflect true positions
// instead of all-collapsed-at-0. It's also browser-safe, so this keeps recast
// out of the lint graph entirely. Dynamic import preserves the lazy load.
async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap> {
const mod = await import("@hyperframes/parsers/gsap-parser-acorn");
return mod.parseGsapScriptAcorn as unknown as (script: string) => LintParsedGsap;
}
import type { LintContext } from "../context";
import type { HyperframeLintFinding, LintRule } from "../types";
import type { OpenTag } from "../utils";
import {
readAttr,
readDecodedAttr,
truncateSnippet,
stripJsComments,
hasCaptionStyles,
WINDOW_TIMELINE_ASSIGN_PATTERN,
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN,
} from "../utils";
// ── GSAP-specific types ────────────────────────────────────────────────────
type GsapWindow = {
targetSelector: string;
targetIdentity?: string;
position: number;
end: number;
properties: string[];
propertyValues: Record<string, string | number>;
fromPropertyValues?: Record<string, string | number>;
overwriteAuto: boolean;
method: string;
raw: string;
};
type CompositionRange = {
id: string;
start: number;
end: number;
};
const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
// Sentinel the GSAP parser assigns to a tween whose target it cannot statically
// resolve to a concrete element (a computed variable, a helper call, etc.). It is
// NOT an identity: two distinct unresolved selectors are not the same element, so
// overlap analysis must never treat them as one.
const UNRESOLVED_TARGET = "__unresolved__";
// Parser labels for object-proxy tweens describe their role, not target
// identity. Two independent proxies can both be labelled `dwell/hold` (or the
// same driven DOM channel), so equality cannot prove they conflict.
function targetHasNoStableIdentity(selector: string, identity?: string): boolean {
if (identity) return false;
return (
selector === UNRESOLVED_TARGET || selector === "dwell/hold" || selector.startsWith("proxy → ")
);
}
// ── GSAP parsing utilities ─────────────────────────────────────────────────
function countClassUsage(tags: OpenTag[]): Map<string, number> {
const counts = new Map<string, number>();
for (const tag of tags) {
const classAttr = readAttr(tag.raw, "class");
if (!classAttr) continue;
for (const className of classAttr.split(/\s+/).filter(Boolean)) {
counts.set(className, (counts.get(className) || 0) + 1);
}
}
return counts;
}
function readRegisteredTimelineCompositionId(script: string): string | null {
const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
return match?.[1] || match?.[2] || null;
}
/** Strip a `__raw:` prefix the parser adds to unresolvable values. */
function unwrapRaw(value: unknown): string | number | undefined {
if (typeof value === "number") return value;
if (typeof value !== "string") return undefined;
const code = value.startsWith("__raw:") ? value.slice(6) : value;
return code.replace(/^\s*["']|["']\s*$/g, "");
}
function extrasNumber(value: unknown): number {
const unwrapped = unwrapRaw(value);
const numeric = typeof unwrapped === "number" ? unwrapped : Number(unwrapped);
return Number.isFinite(numeric) ? numeric : 0;
}
/** A readable single-line snippet of a tween for finding messages. */
function synthesizeWindowRaw(
timelineVar: string,
anim: LintParsedGsap["animations"][number],
): string {
const entries = Object.entries(anim.properties).map(([k, v]) => {
if (typeof v === "string" && v.startsWith("__raw:")) return `${k}: ${v.slice(6)}`;
return `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`;
});
if (anim.duration !== undefined) entries.push(`duration: ${anim.duration}`);
if (anim.ease) entries.push(`ease: ${JSON.stringify(anim.ease)}`);
const pos = typeof anim.position === "number" ? anim.position : JSON.stringify(anim.position);
return `${timelineVar}.${anim.method}("${anim.targetSelector}", { ${entries.join(", ")} }, ${pos})`;
}
const gsapWindowsCache = new Map<string, GsapWindow[]>();
async function cachedExtractGsapWindows(scriptContent: string): Promise<GsapWindow[]> {
const cached = gsapWindowsCache.get(scriptContent);
if (cached) return cached;
const windows = await extractGsapWindows(scriptContent);
gsapWindowsCache.set(scriptContent, windows);
return windows;
}
// fallow-ignore-next-line complexity
async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
if (!/gsap\.timeline/.test(script)) return [];
const parseGsapScript = await loadParseGsapScript();
const parsed = parseGsapScript(script);
if (parsed.animations.length === 0) return [];
const windows: GsapWindow[] = [];
for (const animation of parsed.animations) {
// Skip animations with string positions (e.g. "+=1", "<") — their absolute
// timing depends on runtime evaluation and can't be statically linted.
if (typeof animation.position !== "number") continue;
const repeat = extrasNumber(animation.extras?.repeat);
const cycleCount = repeat > 0 ? repeat + 1 : 1;
const effectiveDuration =
animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount;
windows.push({
targetSelector: animation.targetSelector,
targetIdentity: animation.targetIdentity,
position: animation.position,
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),
});
}
return windows;
}
function numberValue(value: string | number | undefined): number | null {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim()) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
return null;
}
function stringValue(value: string | number | undefined): string | null {
if (typeof value === "string") return value;
if (typeof value === "number") return String(value);
return null;
}
function zeroValue(value: string | number | undefined): boolean {
if (typeof value === "number") return value === 0;
if (typeof value !== "string") return false;
return Number(value.trim()) === 0;
}
function isHiddenGsapState(values: Record<string, string | number>): boolean {
const visibility = stringValue(values.visibility)?.toLowerCase();
const display = stringValue(values.display)?.toLowerCase();
return (
zeroValue(values.opacity) ||
zeroValue(values.autoAlpha) ||
visibility === "hidden" ||
display === "none"
);
}
function extractStandaloneHiddenSelectors(script: string): Set<string> {
const selectors = new Set<string>();
const source = stripJsComments(script);
const aliases = new Map<string, string>();
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<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;
return isHiddenGsapState(win.propertyValues);
}
function isHardKillSet(win: GsapWindow, selector: string, boundary: number): boolean {
return (
win.method === "set" &&
win.targetSelector === selector &&
Math.abs(win.position - boundary) <= SCENE_BOUNDARY_EPSILON_SECONDS &&
isHiddenGsapState(win.propertyValues)
);
}
function hiddenStateLiteral(values: Record<string, string | number>): string {
if (zeroValue(values.autoAlpha)) return "{ autoAlpha: 0 }";
if (zeroValue(values.opacity)) return "{ opacity: 0 }";
if (stringValue(values.visibility)?.toLowerCase() === "hidden") return '{ visibility: "hidden" }';
if (stringValue(values.display)?.toLowerCase() === "none") return '{ display: "none" }';
return "{ opacity: 0 }";
}
function findTagEnd(source: string, tag: OpenTag): number {
const escapedTagName = tag.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`<\\/?${escapedTagName}\\b[^>]*>`, "gi");
pattern.lastIndex = tag.index;
let depth = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
const raw = match[0];
const isClosing = /^<\s*\//.test(raw);
const isSelfClosing = /\/\s*>$/.test(raw);
if (!isClosing && !isSelfClosing) depth += 1;
if (isClosing) depth -= 1;
if (depth === 0) return pattern.lastIndex;
}
return source.length;
}
function collectCompositionRanges(source: string, tags: OpenTag[]): CompositionRange[] {
return tags
.map((tag) => {
const id = readDecodedAttr(tag.raw, "data-composition-id");
if (!id) return null;
return {
id,
start: tag.index,
end: findTagEnd(source, tag),
};
})
.filter((range) => range !== null);
}
function findContainingCompositionId(tag: OpenTag, ranges: CompositionRange[]): string | null {
let match: CompositionRange | null = null;
for (const range of ranges) {
if (tag.index < range.start || tag.index >= range.end) continue;
if (!match || range.start >= match.start) match = range;
}
return match?.id || null;
}
// A tag's `class` attribute, split into tokens, but only when it carries the
// `clip` marker class — the common "is this a clip element?" filter used by
// several rules that walk every tag looking for clips.
type ClipTagClasses = { classAttr: string; classes: string[] };
function getClipTagClasses(tag: OpenTag): ClipTagClasses | null {
const classAttr = readAttr(tag.raw, "class") || "";
const classes = classAttr.split(/\s+/).filter(Boolean);
return classes.includes("clip") ? { classAttr, classes } : null;
}
function collectClipStartBoundariesByComposition(
source: string,
tags: OpenTag[],
): Map<string, number[]> {
const ranges = collectCompositionRanges(source, tags);
const boundaries = new Map<string, Set<number>>();
for (const tag of tags) {
if (!getClipTagClasses(tag)) continue;
const compositionId = findContainingCompositionId(tag, ranges);
if (!compositionId) continue;
const start = numberValue(readAttr(tag.raw, "data-start") ?? undefined);
if (start == null || start <= 0) continue;
const compositionBoundaries = boundaries.get(compositionId) ?? new Set<number>();
compositionBoundaries.add(start);
boundaries.set(compositionId, compositionBoundaries);
}
return new Map(
[...boundaries.entries()].map(([compositionId, values]) => [
compositionId,
[...values].sort((a, b) => a - b),
]),
);
}
function findMatchingSceneBoundary(time: number, boundaries: number[]): number | null {
for (const boundary of boundaries) {
if (Math.abs(time - boundary) <= SCENE_BOUNDARY_EPSILON_SECONDS) return boundary;
}
return null;
}
function isSuspiciousGlobalSelector(selector: string): boolean {
if (!selector) return false;
if (selector.includes("[data-composition-id=")) return false;
if (selector.startsWith("#")) return false;
return selector.startsWith(".") || /^[a-z]/i.test(selector);
}
function getSingleClassSelector(selector: string): string | null {
const match = selector.trim().match(/^\.(?<name>[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);
}
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[] = [];
// translate(-50%, -50%) or translate(X, Y)
const translateMatch = cssTransform.match(
/translate\(\s*(-?[\d.]+)(%|px)?\s*,\s*(-?[\d.]+)(%|px)?\s*\)/,
);
if (translateMatch) {
const [, xVal, xUnit, yVal, yUnit] = translateMatch;
if (xUnit === "%") parts.push(`xPercent: ${xVal}`);
else parts.push(`x: ${xVal}`);
if (yUnit === "%") parts.push(`yPercent: ${yVal}`);
else parts.push(`y: ${yVal}`);
}
// translateX(-50%) or translateX(px)
const txMatch = cssTransform.match(/translateX\(\s*(-?[\d.]+)(%|px)?\s*\)/);
if (txMatch) {
const [, val, unit] = txMatch;
parts.push(unit === "%" ? `xPercent: ${val}` : `x: ${val}`);
}
// translateY(-50%) or translateY(px)
const tyMatch = cssTransform.match(/translateY\(\s*(-?[\d.]+)(%|px)?\s*\)/);
if (tyMatch) {
const [, val, unit] = tyMatch;
parts.push(unit === "%" ? `yPercent: ${val}` : `y: ${val}`);
}
// scale(N)
const scaleMatch = cssTransform.match(/scale\(\s*([\d.]+)\s*\)/);
if (scaleMatch) {
parts.push(`scale: ${scaleMatch[1]}`);
}
return parts.length > 0 ? parts.join(", ") : null;
}
// ── CSS-transform ↔ GSAP-transform conflict matching ─────────────────────────
// Transform components that COMBINE with a CSS translate/scale on the same
// element. GSAP bakes the element's existing CSS transform in when it seeks, so
// these stack rather than override in the capture path (e.g. CSS translateX(-50%)
// + xPercent:-50 renders as -100% — off-centre). `rotation` is excluded: it maps
// to CSS rotate(), which this rule treats separately (no false positive on spin).
const CONFLICTING_TRANSLATE_PROPS = ["x", "y", "xPercent", "yPercent"];
const CONFLICTING_SCALE_PROPS = ["scale", "scaleX", "scaleY"];
type GsapTransformCall = {
method: string;
selector: string;
properties: string[];
raw: string;
};
// Decompose a (possibly grouped / descendant / compound) GSAP target selector
// into the simple `#id` / `.class` tokens of the elements it actually targets —
// the RIGHTMOST compound of each comma group is the targeted element. This lets a
// CSS rule keyed by a simple selector (`.m04-label`) match a scoped GSAP selector
// (`"#root .m04-label, #root .m04-sub"`), which the prior exact-string lookup
// missed — so every scoped/grouped selector slipped past the rule entirely.
function targetedSelectorTokens(selector: string): Set<string> {
const tokens = new Set<string>();
for (const group of selector.split(",")) {
const compounds = group
.trim()
.split(/[\s>+~]+/)
.filter(Boolean);
const last = compounds[compounds.length - 1];
if (!last) continue;
const simple = last.match(/[#.][A-Za-z0-9_-]+/g);
if (simple) for (const token of simple) tokens.add(token);
}
return tokens;
}
// Find a CSS transform conflicting with a GSAP target selector: exact-string
// match first (fast path + back-compat with the original behaviour), then a
// token match so scoped/grouped/descendant selectors resolve to their class/id.
function matchCssTransform(gsapSelector: string, cssMap: Map<string, string>): string | undefined {
if (cssMap.size === 0) return undefined;
const direct = cssMap.get(gsapSelector);
if (direct) return direct;
const tokens = targetedSelectorTokens(gsapSelector);
for (const [cssSelector, value] of cssMap) {
if (tokens.has(cssSelector)) return value;
}
return undefined;
}
// Scan for STANDALONE `gsap.set/to/from/fromTo("selector", { ...props })` calls.
// The acorn timeline parser only captures calls rooted on the timeline var
// (`tl.to`, `tl.set`, …); a top-level `gsap.set("#root .label", { xPercent: -50 })`
// — a common way to seat shared base transforms before the timeline runs — is
// invisible to it, so the conflict rule never saw it. Variable selectors
// (`gsap.set(kicker, …)`) can't be resolved statically and are skipped.
function extractStandaloneGsapTransformCalls(script: string): GsapTransformCall[] {
const calls: GsapTransformCall[] = [];
const pattern = /gsap\.(set|to|from|fromTo)\s*\(\s*(["'])([^"']+)\2\s*,\s*\{([^{}]*)\}/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(script)) !== null) {
const method = match[1] ?? "set";
const selector = match[3] ?? "";
const propsBody = match[4] ?? "";
const properties = [...propsBody.matchAll(/([A-Za-z_$][\w$]*)\s*:/g)].map((m) => m[1] ?? "");
calls.push({ method, selector, properties, raw: truncateSnippet(match[0]) ?? match[0] });
}
return calls;
}
// Run a global regex over every script's content, yielding each match plus a
// context-padded snippet around it. Shared by the repeat-count and
// group-selector-keyframes rules below, which differ only in the pattern,
// whether comments are stripped first, and the context window size.
function scanScriptsForRegexMatches(
scripts: LintContext["scripts"],
pattern: RegExp,
options: { stripComments: boolean; contextBefore: number; contextAfter: number },
): Array<{ match: RegExpExecArray; snippet: string }> {
const hits: Array<{ match: RegExpExecArray; snippet: string }> = [];
for (const script of scripts) {
const content = options.stripComments ? stripJsComments(script.content) : script.content;
const regex = new RegExp(pattern.source, pattern.flags);
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
const contextStart = Math.max(0, match.index - options.contextBefore);
const contextEnd = Math.min(
content.length,
match.index + match[0].length + options.contextAfter,
);
hits.push({ match, snippet: content.slice(contextStart, contextEnd) });
}
}
return hits;
}
// ── GSAP rules ─────────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
export const gsapRules: LintRule<LintContext>[] = [
// overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
// fallow-ignore-next-line complexity
async ({ source, tags, scripts, styles, rootCompositionId }) => {
const findings: HyperframeLintFinding[] = [];
// Build clip element selector map
type ClipInfo = { tag: string; id: string; classes: string };
const clipIds = new Map<string, ClipInfo>();
const clipClasses = new Map<string, ClipInfo>();
for (const tag of tags) {
const clipTag = getClipTagClasses(tag);
if (!clipTag) continue;
const id = readAttr(tag.raw, "id");
const info: ClipInfo = {
tag: tag.name,
id: id || "",
classes: clipTag.classAttr,
};
if (id) clipIds.set(`#${id}`, info);
for (const cls of clipTag.classes) {
if (cls !== "clip") clipClasses.set(`.${cls}`, info);
}
}
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);
const gsapWindows = await cachedExtractGsapWindows(script.content);
const clipStartBoundaries =
clipStartBoundariesByComposition.get(localTimelineCompId || rootCompositionId || "") ?? [];
// overlapping_gsap_tweens
for (let i = 0; i < gsapWindows.length; i++) {
const left = gsapWindows[i];
if (!left) continue;
if (left.end <= left.position) continue;
// Unresolved targets are unknown elements: two of them are not provably
// the same element, so an overlap between them cannot be asserted.
if (targetHasNoStableIdentity(left.targetSelector, left.targetIdentity)) continue;
for (let j = i + 1; j < gsapWindows.length; j++) {
const right = gsapWindows[j];
if (!right) continue;
if (right.end <= right.position) continue;
const leftIdentity = left.targetIdentity ?? left.targetSelector;
const rightIdentity = right.targetIdentity ?? right.targetSelector;
if (leftIdentity !== rightIdentity) continue;
const overlapStart = Math.max(left.position, right.position);
const overlapEnd = Math.min(left.end, right.end);
if (overlapEnd <= overlapStart) continue;
if (left.overwriteAuto || right.overwriteAuto) continue;
const sharedProperties = left.properties.filter((prop) =>
right.properties.includes(prop),
);
if (sharedProperties.length === 0) continue;
findings.push({
code: "overlapping_gsap_tweens",
severity: "warning",
message: `GSAP tweens overlap on "${left.targetSelector}" for ${sharedProperties.join(", ")} between ${overlapStart.toFixed(2)}s and ${overlapEnd.toFixed(2)}s.`,
selector: left.targetSelector,
fixHint: 'Shorten the earlier tween, move the later tween, or add `overwrite: "auto"`.',
snippet: truncateSnippet(`${left.raw}\n${right.raw}`),
});
}
}
// gsap_exit_missing_hard_kill
if (clipStartBoundaries.length > 0) {
for (const win of gsapWindows) {
// Unresolved targets are unknown elements: you cannot assert a missing
// hard kill on one, and a `tl.set("__unresolved__", ...)` hint is meaningless.
if (win.targetSelector === UNRESOLVED_TARGET) continue;
if (!isSceneBoundaryExit(win)) continue;
const boundary = findMatchingSceneBoundary(win.end, clipStartBoundaries);
if (boundary == null) continue;
const hasHardKill = gsapWindows.some((candidate) =>
isHardKillSet(candidate, win.targetSelector, boundary),
);
if (hasHardKill) continue;
// A tl.set hard kill on the exiting selector itself is the fix — unless
// that selector IS a clip element, in which case gsap_animates_clip_element
// (below) errors on that exact tl.set: the framework already owns
// visibility/display on clip elements. Point at the inner-wrapper
// pattern instead so the two rules' advice doesn't contradict.
const exitClipInfo =
clipIds.get(win.targetSelector) || clipClasses.get(win.targetSelector);
const fixHint = exitClipInfo
? `"${win.targetSelector}" is a clip element — the framework already manages its visibility. ` +
"Wrap the scene's content in an inner non-clip <div>, move the exit tween and the hard kill " +
`(\`tl.set("<inner-selector>", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\`) onto that wrapper instead.`
: `Add \`tl.set("${win.targetSelector}", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\` ` +
"after the exit tween.";
findings.push({
code: "gsap_exit_missing_hard_kill",
severity: "error",
message:
`GSAP exit on "${win.targetSelector}" ends at the ${boundary.toFixed(2)}s clip start boundary ` +
"without a matching tl.set hard kill. Non-linear seeking can land after the fade and leave stale visibility state.",
selector: win.targetSelector,
fixHint,
snippet: truncateSnippet(win.raw),
});
}
}
// 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;
const clipInfo = clipIds.get(sel) || clipClasses.get(sel);
if (!clipInfo) continue;
const conflictingProps = win.properties.filter(
(p) => p === "visibility" || p === "display",
);
if (conflictingProps.length === 0) continue;
const elDesc = `<${clipInfo.tag}${clipInfo.id ? ` id="${clipInfo.id}"` : ""} class="${clipInfo.classes}">`;
findings.push({
code: "gsap_animates_clip_element",
severity: "error",
message: `GSAP animation sets ${conflictingProps.join(", ")} on a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility via ${conflictingProps.join("/")} — do not animate these properties on clip elements.`,
selector: sel,
elementId: clipInfo.id || undefined,
fixHint:
"Remove the visibility/display tween, or move the content into a child <div> and target that instead.",
snippet: truncateSnippet(win.raw),
});
}
// unscoped_gsap_selector
if (!localTimelineCompId || localTimelineCompId === rootCompositionId) continue;
for (const win of gsapWindows) {
if (!isSuspiciousGlobalSelector(win.targetSelector)) continue;
const className = getSingleClassSelector(win.targetSelector);
if (className && (classUsage.get(className) || 0) < 2) continue;
findings.push({
code: "unscoped_gsap_selector",
severity: "error",
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
selector: win.targetSelector,
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`,
snippet: truncateSnippet(win.raw),
});
}
}
return findings;
},
// gsap_css_transform_conflict
// fallow-ignore-next-line complexity
async ({ styles, scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];
const cssTranslateSelectors = new Map<string, string>();
const cssScaleSelectors = new Map<string, string>();
// Check <style> blocks for transform rules
for (const style of styles) {
for (const [, selector, body] of style.content.matchAll(
/([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g,
)) {
const tMatch = body?.match(/transform\s*:\s*([^;]+)/);
if (!tMatch || !tMatch[1]) continue;
const transformVal = tMatch[1].trim();
if (/translate/i.test(transformVal))
cssTranslateSelectors.set((selector ?? "").trim(), transformVal);
if (/scale/i.test(transformVal))
cssScaleSelectors.set((selector ?? "").trim(), transformVal);
}
}
// Also check inline style="..." attributes on tags
for (const tag of tags) {
const inlineStyle = readAttr(tag.raw, "style");
if (!inlineStyle) continue;
const tMatch = inlineStyle.match(/transform\s*:\s*([^;]+)/);
if (!tMatch || !tMatch[1]) continue;
const transformVal = tMatch[1].trim();
// Derive selectors from the tag's id and all classes
const id = readAttr(tag.raw, "id");
const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
const selectors: string[] = [];
if (id) selectors.push(`#${id}`);
for (const cls of classes) selectors.push(`.${cls}`);
if (selectors.length === 0) continue;
for (const sel of selectors) {
if (/translate/i.test(transformVal) && !cssTranslateSelectors.has(sel))
cssTranslateSelectors.set(sel, transformVal);
if (/scale/i.test(transformVal) && !cssScaleSelectors.has(sel))
cssScaleSelectors.set(sel, transformVal);
}
}
if (cssTranslateSelectors.size === 0 && cssScaleSelectors.size === 0) return findings;
for (const script of scripts) {
if (!/gsap\.timeline/.test(script.content)) continue;
const windows = await cachedExtractGsapWindows(script.content);
// Two sources of transform-setting calls: timeline-rooted tweens (from the
// acorn parser) and standalone gsap.* calls (regex — the parser ignores
// these). Normalize both into one shape and run the same conflict check.
const calls: GsapTransformCall[] = [
...windows.map((win) => ({
method: win.method,
selector: win.targetSelector,
properties: win.properties,
raw: win.raw,
})),
...extractStandaloneGsapTransformCalls(stripJsComments(script.content)),
];
type Conflict = { cssTransform: string; props: Set<string>; raw: string };
const conflicts = new Map<string, Conflict>();
for (const call of calls) {
// from() and fromTo() both supply explicit start values so GSAP owns
// the full transform from t=0, making the CSS conflict moot
if (call.method === "fromTo" || call.method === "from") continue;
const sel = call.selector;
const translateProps = call.properties.filter((p) =>
CONFLICTING_TRANSLATE_PROPS.includes(p),
);
const scaleProps = call.properties.filter((p) => CONFLICTING_SCALE_PROPS.includes(p));
const cssFromTranslate =
translateProps.length > 0 ? matchCssTransform(sel, cssTranslateSelectors) : undefined;
const cssFromScale =
scaleProps.length > 0 ? matchCssTransform(sel, cssScaleSelectors) : undefined;
if (!cssFromTranslate && !cssFromScale) continue;
const existing = conflicts.get(sel) ?? {
cssTransform: [cssFromTranslate, cssFromScale].filter(Boolean).join(" "),
props: new Set<string>(),
raw: call.raw,
};
for (const p of [...translateProps, ...scaleProps]) existing.props.add(p);
conflicts.set(sel, existing);
}
for (const [sel, { cssTransform, props, raw }] of conflicts) {
const propList = [...props].join("/");
const gsapEquivalent = cssTransformToGsapProps(cssTransform);
const fixHint = gsapEquivalent
? `Remove \`transform: ${cssTransform}\` from CSS and replace with GSAP properties: ${gsapEquivalent}. ` +
`Example: tl.fromTo('${sel}', { ${gsapEquivalent} }, { ${gsapEquivalent}, ...yourAnimation }). ` +
`tl.fromTo is exempt from this rule.`
: `Remove the transform from CSS and use tl.fromTo('${sel}', ` +
`{ xPercent: -50, x: -1000 }, { xPercent: -50, x: 0 }) so GSAP owns ` +
`the full transform state. tl.fromTo is exempt from this rule.`;
findings.push({
code: "gsap_css_transform_conflict",
severity: "error",
message:
`"${sel}" has CSS \`transform: ${cssTransform}\` and a GSAP tween animates ` +
`${propList}. GSAP will overwrite the full CSS transform, discarding any ` +
`translateX(-50%) centering or CSS scale value.`,
selector: sel,
fixHint,
snippet: truncateSnippet(raw),
});
}
}
return findings;
},
// missing_gsap_script
({ scripts, rawSource, options }) => {
const allScriptTexts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
const allScriptSrcs = scripts
.map((s) => readAttr(`<script ${s.attrs}>`, "src") || "")
.filter(Boolean);
const canInheritGsapFromHost =
options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");
const usesGsap = allScriptTexts.some((t) =>
/gsap\.(to|from|fromTo|timeline|set|registerPlugin)\b/.test(t),
);
const hasGsapScript = allScriptSrcs.some((src) => /gsap/i.test(src));
// Detect GSAP bundled inline (no src attribute). Match:
// - Producer's CDN-inlining comment: /* inlined: ...gsap... */
// - GSAP library internals: _gsScope, GreenSock, gsap.config
// - Large inline scripts (>5KB) that reference gsap (likely bundled library)
const hasInlineGsap = allScriptTexts.some(
(t) =>
/\/\*\s*inlined:.*gsap/i.test(t) ||
/\b_gsScope\b/.test(t) ||
/\bGreenSock\b/.test(t) ||
/\bgsap\.(config|defaults|version)\b/.test(t) ||
(t.length > 5000 && /\bgsap\b/i.test(t)),
);
if (!usesGsap || hasGsapScript || hasInlineGsap || canInheritGsapFromHost) return [];
return [
{
code: "missing_gsap_script",
severity: "error",
message: "Composition uses GSAP but no GSAP script is loaded. The animation will not run.",
fixHint:
'Add <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script> before your animation script.',
},
];
},
// audio_reactive_single_tween_per_group
// fallow-ignore-next-line complexity
({ scripts, styles }) => {
const findings: HyperframeLintFinding[] = [];
if (!hasCaptionStyles(styles)) return findings;
for (const script of scripts) {
const content = script.content;
// Detect audio data loading
const hasAudioData = /AUDIO|audio[-_]?data|bands\[/.test(content);
if (!hasAudioData) continue;
// Detect caption group loop
const hasCaptionLoop = /forEach/.test(content) && /caption|group|cg-/.test(content);
if (!hasCaptionLoop) continue;
// Check if audio-reactive tweens are created at intervals (loop inside the group loop)
// vs a single tween per group (no inner time-sampling loop)
const hasInnerSamplingLoop =
/for\s*\(\s*var\s+\w+\s*=\s*group\.start/.test(content) ||
/for\s*\(\s*var\s+at\s*=/.test(content) ||
/while\s*\(\s*\w+\s*<\s*group\.end/.test(content);
if (!hasInnerSamplingLoop) {
// Check if there's at least a peak-based single tween (the minimal pattern)
const hasPeakTween =
/peak(?:Bass|Treble|Energy)/.test(content) && /group\.start/.test(content);
if (hasPeakTween) {
findings.push({
code: "audio_reactive_single_tween_per_group",
severity: "warning",
message:
"Audio-reactive captions use a single tween per group based on peak values. " +
"This sets one static value at group.start — not perceptible as audio reactivity.",
fixHint:
"Sample audio data at 100-200ms intervals throughout each group's lifetime " +
"(for loop from group.start to group.end) and create a tween at each sample " +
"point for visible pulsing.",
});
}
}
}
return findings;
},
// gsap_infinite_repeat
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
// Match repeat: -1 in GSAP tweens or timeline configs
const pattern = /repeat\s*:\s*-1(?!\d)/g;
for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
stripComments: true,
contextBefore: 60,
contextAfter: 60,
})) {
findings.push({
code: "gsap_infinite_repeat",
severity: "error",
message:
"GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic " +
"capture engine which seeks to exact frame times. Use a finite repeat count calculated " +
"from the composition duration: `repeat: Math.floor(duration / cycleDuration) - 1`.",
fixHint:
"Replace `repeat: -1` with a finite count, e.g. `repeat: Math.floor(totalDuration / singleCycleDuration) - 1`. " +
"Use Math.floor (not Math.ceil) to ensure the animation fits within the total duration.",
snippet: truncateSnippet(snippet),
});
}
return findings;
},
// gsap_repeat_ceil_overshoot
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
// Match patterns like: repeat: Math.ceil(duration / X) - 1
// or repeat: Math.ceil(totalDuration / cycleDuration) - 1
const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
stripComments: false,
contextBefore: 40,
contextAfter: 40,
})) {
findings.push({
code: "gsap_repeat_ceil_overshoot",
severity: "warning",
message:
"GSAP repeat calculation uses `Math.ceil` which can overshoot the composition duration. " +
"For example, Math.ceil(10.5 / 2) - 1 = 5 repeats → 6 cycles × 2s = 12s, exceeding 10.5s.",
fixHint:
"Use `Math.floor` instead of `Math.ceil` to ensure the animation fits within the duration: " +
"`repeat: Math.floor(totalDuration / cycleDuration) - 1`. " +
"Math.floor(10.5 / 2) - 1 = 4 repeats → 5 cycles × 2s = 10s ✓",
snippet: truncateSnippet(snippet),
});
}
return findings;
},
// scene_layer_missing_visibility_kill
({ scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];
// Detect multi-scene compositions: multiple elements with "scene" in their id
const sceneElements = tags.filter((t) => {
const id = readAttr(t.raw, "id") || "";
return /^scene\d+$/i.test(id);
});
if (sceneElements.length < 2) return findings;
for (const script of scripts) {
const content = stripJsComments(script.content);
// For each scene, check if there's a visibility:hidden set after exit tweens
for (const tag of sceneElements) {
const id = readAttr(tag.raw, "id") || "";
// Check if this scene has exit tweens (opacity: 0)
const exitPattern = new RegExp(`["']#${id}["'][^)]*opacity\\s*:\\s*0`);
const hasExit = exitPattern.test(content);
if (!hasExit) continue;
// Check if there's a hard visibility kill
const killPattern = new RegExp(`["']#${id}["'][^)]*visibility\\s*:\\s*["']hidden["']`);
const hasKill = killPattern.test(content);
if (!hasKill) {
// A tl.set on "#id" is only safe advice when the scene element isn't
// itself a clip — otherwise gsap_animates_clip_element errors on that
// exact tl.set, since the framework already owns visibility/display on
// clip elements. Point at the inner-wrapper pattern instead.
const classes = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
const isClip = classes.includes("clip");
const fixHint = isClip
? `"#${id}" is a clip element — the framework already manages its visibility. ` +
"Wrap the scene's content in an inner non-clip <div>, move the exit tween and the hard kill " +
'(`tl.set("<inner-selector>", { visibility: "hidden" }, <exit-end-time>)`) onto that wrapper instead.'
: `Add \`tl.set("#${id}", { visibility: "hidden" }, <exit-end-time>)\` after the scene's exit tweens.`;
findings.push({
code: "scene_layer_missing_visibility_kill",
severity: "error",
elementId: id,
message:
`Scene layer "#${id}" exits via opacity tween but has no visibility: hidden hard kill. ` +
"When scrubbing or when tweens conflict, the scene may remain partially visible and overlap the next scene.",
fixHint,
});
}
}
}
return findings;
},
// gsap_timeline_not_registered
({ scripts, rawSource, options }) => {
const findings: HyperframeLintFinding[] = [];
const canInheritFromHost =
options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");
for (const script of scripts) {
const content = script.content;
if (!/gsap\.timeline/.test(content)) continue;
const hasRegistration =
WINDOW_TIMELINE_ASSIGN_PATTERN.test(content) ||
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(content);
if (hasRegistration || canInheritFromHost) continue;
findings.push({
code: "gsap_timeline_not_registered",
severity: "error",
message:
"GSAP timeline is created but never registered in window.__timelines. " +
"The runtime discovers timelines from this registry — without registration, " +
"animations will not play during preview or render.",
fixHint:
"Add `window.__timelines = window.__timelines || {};` and " +
'`window.__timelines["root"] = tl;` after creating the timeline (use the ' +
"composition's data-composition-id as the key).",
});
}
return findings;
},
// gsap_timeline_registered_before_async_build — registering window.__timelines[id]
// BEFORE the timeline is built inside document.fonts.ready (or any async callback)
// leaves an EMPTY timeline registered. The runtime's sub-composition readiness gate
// treats "key present" as "ready" and nests the child ONCE, while still empty — so the
// animation never renders when this composition is mounted as a sub-composition.
// Register only AFTER the build completes (the documented async-setup contract).
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const content = stripJsComments(script.content);
const regIdx = content.search(/window\s*\.\s*__timelines\s*\[/);
if (regIdx < 0) continue;
const fontsReadyIdx = content.search(/document\s*\.\s*fonts\s*\.\s*ready/);
if (fontsReadyIdx < 0) continue;
// Registering after the async boundary is the correct pattern — skip it.
if (regIdx >= fontsReadyIdx) continue;
// Confirm the build is actually deferred past the boundary (a tween/build call
// appears after document.fonts.ready), i.e. the registered timeline starts empty.
const tail = content.slice(fontsReadyIdx);
if (!/\.(?:to|from|fromTo)\s*\(|buildEffect\s*\(/.test(tail)) continue;
findings.push({
code: "gsap_timeline_registered_before_async_build",
severity: "error",
message:
"window.__timelines is assigned BEFORE the timeline is built inside " +
"document.fonts.ready. An empty timeline registered early gets nested empty " +
"when this composition is used as a sub-composition (the readiness gate treats " +
'"key present" as "ready" and never re-nests), so the animation renders blank.',
fixHint:
"Move the `window.__timelines[id] = tl;` assignment to the END of the " +
"document.fonts.ready callback, after the tweens are added. Optionally call " +
"window.__hfForceTimelineRebind() right after, to re-nest the populated timeline.",
});
}
return findings;
},
// 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[] = [];
const cssOpacityZeroSelectors = new Set<string>();
// Single owner of "this declaration list sets opacity to EXACTLY zero" —
// boundary-anchored so `opacity: 0.98` never matches. Works for both a CSS
// block body (brace already stripped by the block regex) and an inline
// style attribute: the declaration ends at `;` or at end of input, which
// also catches a final declaration without a trailing semicolon.
const opacityExactlyZero = /opacity\s*:\s*0(?:\.0+)?\s*(?:;|$)/;
for (const style of styles) {
for (const [, selector, body] of style.content.matchAll(
/([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g,
)) {
if (body && opacityExactlyZero.test(body)) {
cssOpacityZeroSelectors.add((selector ?? "").trim());
}
}
}
for (const tag of tags) {
const inlineStyle = readAttr(tag.raw, "style");
if (!inlineStyle || !opacityExactlyZero.test(inlineStyle)) continue;
const id = readAttr(tag.raw, "id");
const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
if (id) cssOpacityZeroSelectors.add(`#${id}`);
for (const cls of classes) cssOpacityZeroSelectors.add(`.${cls}`);
}
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;
findings.push({
code: "gsap_from_opacity_noop",
severity: "error",
message:
`"${sel}" has CSS \`opacity: 0\` and a gsap.${win.method}() that also sets opacity to 0. ` +
`gsap.from() animates FROM the specified value TO the current CSS value — ` +
`since CSS is already 0, the element animates from 0→0 and never becomes visible.`,
selector: sel,
fixHint:
`Remove \`opacity: 0\` from the CSS/inline style on "${sel}". ` +
`Let gsap.from({opacity: 0}) handle the initial hidden state — ` +
`it will animate FROM 0 TO the CSS value (1 by default).`,
snippet: truncateSnippet(win.raw),
});
}
}
return findings;
},
// gsap_non_transform_motion — animating layout props (left/top/right/bottom/margin*)
// or using roundProps snaps motion to integer device pixels. On the seek-by-frame
// capture engine this looks smooth at high per-frame deltas (fast tweens) but visibly
// stutters at low deltas (slow tweens / ease-out tails): sub-pixel movement rounds to
// the same pixel for several frames, then jumps a whole pixel. Transforms (x/y/scale)
// interpolate sub-pixel and stay smooth.
//
// EXEMPTION: elements rasterized via the html-in-canvas API — those under a
// `<canvas layoutsubtree>` ancestor (e.g. the liquid-glass blocks) — are NOT laid out
// by the browser compositor. The canvas lib reads getComputedStyle().left/top (a
// sub-pixel value) and draws the element to a bitmap, so animating a layout prop on
// them does not integer-snap and does not stutter. We resolve each tween's target to
// its element(s) and skip the finding only when EVERY target is html-in-canvas; a
// grouped tween that also touches a plain-DOM element (which does stutter) still fires.
//
// No suppression by design: there is intentionally no per-line/per-file opt-out (unlike
// eslint-disable). The stance is fix-the-motion, not silence-the-rule — a plain-DOM
// layout-prop animation always has a faithful transform equivalent (per-glyph x for
// spacing, scale for size, x/y for position). An author who has consciously accepted a
// stutter still has no flag to flip; that is deliberate, not a missing feature.
async ({ scripts, tags, source }) => {
const findings: HyperframeLintFinding[] = [];
// Byte-ranges of every <canvas layoutsubtree>. An element whose open-tag index falls
// inside one of these ranges is html-in-canvas composited.
const layoutSubtreeRanges = tags
.filter((t) => t.name.toLowerCase() === "canvas" && /\blayoutsubtree\b/i.test(t.raw))
.map((t) => ({ start: t.index, end: findTagEnd(source, t) }));
const isHtmlInCanvas = (tag: OpenTag): boolean =>
layoutSubtreeRanges.some((r) => tag.index > r.start && tag.index < r.end);
// Resolve a simple #id / .class token to the element tag(s) it matches.
const tagsByToken = new Map<string, OpenTag[]>();
const addToken = (token: string, tag: OpenTag): void => {
const list = tagsByToken.get(token);
if (list) list.push(tag);
else tagsByToken.set(token, [tag]);
};
for (const tag of tags) {
const id = readAttr(tag.raw, "id");
if (id) addToken(`#${id}`, tag);
for (const cls of readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [])
addToken(`.${cls}`, tag);
}
// True only when the selector resolves to at least one element AND every resolved
// element is html-in-canvas. Unresolvable selectors (no match) are NOT exempt — we
// stay conservative and let the finding fire rather than risk a false negative.
const allTargetsHtmlInCanvas = (selector: string): boolean => {
if (layoutSubtreeRanges.length === 0) return false;
const matched = [...targetedSelectorTokens(selector)].flatMap(
(token) => tagsByToken.get(token) ?? [],
);
return matched.length > 0 && matched.every(isHtmlInCanvas);
};
// Positional layout props → each maps to its transform replacement axis (x/y).
const LAYOUT_FIX: Record<string, string[]> = {
left: ["x"],
right: ["x"],
top: ["y"],
bottom: ["y"],
margin: ["x", "y"],
marginLeft: ["x"],
marginRight: ["x"],
marginTop: ["y"],
marginBottom: ["y"],
};
// Text-reflow props: animating them reflows text and snaps glyph positions to the
// pixel grid, stuttering on slow motion exactly like positional props. They have no
// transform replacement (the fix is to not animate them — settle via scale or hold the
// value), and the snap happens during browser layout, UPSTREAM of any canvas raster, so
// they are never html-in-canvas-exempt. (width/height are deliberately omitted: they
// have legitimate animated uses — progress bars, reveals — and would over-report.)
const REFLOW_PROPS = ["letterSpacing", "wordSpacing", "fontSize"];
// Resolve the parser once, above the loop (the other async rules in this file do the
// same); the dynamic-import cache makes per-iteration calls equivalent, but hoisting
// keeps the placement from reading as load-bearing.
const parseGsapScript = await loadParseGsapScript();
for (const script of scripts) {
if (!/gsap\.timeline/.test(script.content)) continue;
// Two sources: timeline-rooted tweens (tl.to/from/fromTo) and standalone
// gsap.to/from/fromTo calls the acorn parser ignores.
//
// Timeline tweens come straight from the acorn parser's animation list — NOT
// cachedExtractGsapWindows, which drops every tween with a non-numeric timeline
// position (a string label or `+=`/`-=` offset, e.g. `tl.to("#x",{left:9},"hold6")`).
// Position is irrelevant to whether a tween animates a layout prop, so dropping
// those would let real stutter-prone tweens escape. The parser also gives real AST
// keys, so a nested `{}` value (an onComplete body, modifiers) and a layout-prop
// name appearing inside a string value can't be misread — both hazards of a raw scan.
const parsed = parseGsapScript(script.content);
const calls: GsapTransformCall[] = [
...parsed.animations.map((anim) => ({
method: anim.method,
selector: anim.targetSelector,
// Union the from-vars: a fromTo() can animate a layout/reflow prop that appears
// only in its first ("from") object, which is just as stutter-prone as the to-vars.
properties: [
...new Set([
...Object.keys(anim.properties),
...Object.keys(anim.fromProperties ?? {}),
]),
],
raw: synthesizeWindowRaw(parsed.timelineVar, anim),
})),
...extractStandaloneGsapTransformCalls(stripJsComments(script.content)),
];
for (const call of calls) {
// set() is instantaneous — it never animates, so it cannot stutter. A set() that
// seats an integer-snapped layout position (e.g. tl.set("#x",{left:100})) before a
// later transform tween is a single from-state frame, not motion; intentionally skipped.
if (call.method === "set") continue;
// Object.hasOwn, not `in`: a tween property named `toString`/`constructor` would
// match the prototype chain and resolve LAYOUT_FIX[p] to an inherited function.
let layoutProps = call.properties.filter((p) => Object.hasOwn(LAYOUT_FIX, p));
const reflowProps = call.properties.filter((p) => REFLOW_PROPS.includes(p));
const usesRoundProps = call.properties.includes("roundProps");
// Only positional props are html-in-canvas-exempt: the canvas positions the draw
// from sub-pixel computed left/top. Reflow props (glyph layout) and roundProps
// (value rounding) snap upstream of the raster, so they always fire.
if (layoutProps.length > 0 && allTargetsHtmlInCanvas(call.selector)) layoutProps = [];
if (layoutProps.length === 0 && reflowProps.length === 0 && !usesRoundProps) continue;
const flagged = [...layoutProps, ...reflowProps, ...(usesRoundProps ? ["roundProps"] : [])];
const message =
`GSAP tween on "${call.selector}" uses motion that snaps to integer device pixels: ` +
`${flagged.join(", ")}. Layout and text-reflow properties snap during browser layout; ` +
"roundProps rounds the tween value. Slow motion or an ease-out tail then stutters under " +
"the seek-by-frame capture engine — animate transforms (x/y/scale/opacity) instead.";
const fixes: string[] = [];
if (layoutProps.length > 0) {
const tokens = [...new Set(layoutProps.flatMap((p) => LAYOUT_FIX[p] ?? []))];
fixes.push(
`replace ${layoutProps.join("/")} with the transform equivalent (${tokens.join(", ")}) — ` +
`e.g. tl.fromTo("${call.selector}", { x: -1300 }, { x: 0, ...yourAnimation })`,
);
}
if (reflowProps.length > 0) {
// Faithful fix differs by property: fontSize maps to scale (same visual), but
// letterSpacing/wordSpacing do NOT — uniform scale resizes glyphs, it does not
// change the gaps between them. The smooth equivalent of a spacing tween is a
// per-glyph split with an x transform per character.
const sizing = reflowProps.filter((p) => p === "fontSize");
const spacing = reflowProps.filter((p) => p !== "fontSize");
const parts: string[] = [];
if (sizing.length > 0) {
parts.push(`replace ${sizing.join("/")} with scale (same visual, no reflow)`);
}
if (spacing.length > 0) {
parts.push(
`for ${spacing.join("/")}, split the text into per-character elements and animate ` +
"each glyph's x (the spread) — uniform scale is NOT equivalent — or hold the final value statically",
);
}
fixes.push(
`do not animate ${reflowProps.join("/")} (they reflow text and snap glyph positions): ` +
parts.join("; "),
);
}
if (usesRoundProps) fixes.push("remove roundProps");
const fixHint = `${fixes.join("; ")}. Transforms interpolate sub-pixel and stay smooth at any speed.`;
findings.push({
code: "gsap_non_transform_motion",
severity: "error",
message,
selector: call.selector,
fixHint,
snippet: truncateSnippet(call.raw),
});
}
}
return findings;
},
// gsap_group_selector_keyframes
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
const pattern = /\.(?:to|from|fromTo)\(\s*["']([^"']+,\s*[^"']+)["']\s*,\s*\{[^}]*keyframes/g;
for (const { match, snippet } of scanScriptsForRegexMatches(scripts, pattern, {
stripComments: true,
contextBefore: 20,
contextAfter: 40,
})) {
const selector = match[1]!;
const count = selector.split(",").length;
findings.push({
code: "gsap_group_selector_keyframes",
severity: "warning",
message:
`GSAP tween targets ${count} elements with shared keyframes ("${truncateSnippet(selector, 60)}"). ` +
`Editing one element's keyframes in Studio will affect all ${count} elements. ` +
`Split into individual tweens for per-element keyframe control.`,
fixHint:
`Replace the group selector with individual tl.to() calls per element, ` +
`each with their own keyframes object.`,
snippet: truncateSnippet(snippet),
});
}
return findings;
},
];