Files
hyperframes/packages/lint/src/rules/gsap.ts
T

2516 lines
113 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>;
resolvedStart?: number;
/** True for an off-timeline `gsap.set(...)` (applied once at load). */
global?: boolean;
}>;
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;
}
async function loadGsapScriptMotionPathFirstUseIndex(): Promise<(script: string) => number | null> {
const mod = await import("@hyperframes/parsers/gsap-parser-acorn");
return mod.gsapScriptMotionPathFirstUseIndex;
}
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;
immediateRender: boolean;
method: string;
/** True for an off-timeline `gsap.set(...)` (applied once at load). */
global?: boolean;
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) {
const start =
animation.resolvedStart ??
(typeof animation.position === "number" ? animation.position : null);
if (start === null) continue;
const repeat = extrasNumber(animation.extras?.repeat);
const infiniteRepeat = repeat < 0;
const cycleCount = infiniteRepeat ? 1 : repeat > 0 ? repeat + 1 : 1;
const effectiveDuration =
animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount;
windows.push({
targetSelector: animation.targetSelector,
targetIdentity: animation.targetIdentity,
position: start,
end:
infiniteRepeat && animation.method !== "set"
? Number.POSITIVE_INFINITY
: start + effectiveDuration,
properties: Object.keys(animation.properties),
propertyValues: animation.properties,
fromPropertyValues: animation.fromProperties,
overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto",
immediateRender: unwrapRaw(animation.extras?.immediateRender) === "true",
method: animation.method,
global: animation.global,
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 functionRanges = collectFunctionBodyRanges(source);
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) {
// Skip callback/handler bodies; keep IIFEs (they run at parse time).
if (indexInsideNonIifeRange(match.index, source, functionRanges)) continue;
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;
}
// ── Seek-order safety helpers ───────────────────────────────────────────────
//
// The renderer distributes frames across workers; cold render workers seek
// non-linearly straight into their range instead of playing sequentially from 0.
// Any state that depends on seek ORDER — relative tween bases, callback-measured
// geometry, per-init random values — renders differently per worker, visible as
// position jumps or dead animation at chunk boundaries.
const RELATIVE_TWEEN_VALUE = /^[+-]=/;
function isRelativeTweenValue(value: string | number | undefined): boolean {
return typeof value === "string" && RELATIVE_TWEEN_VALUE.test(value.trim());
}
// DOM reads split by transform sensitivity. Transform-sensitive reads report
// live animated geometry, so their result depends on the worker's own seek
// order. Transform-invariant layout reads (intrinsic size, path geometry) give
// the same answer on every worker as long as layout itself is not animated.
const TRANSFORM_SENSITIVE_READ =
/\.getBoundingClientRect\s*\(|\bgetComputedStyle\s*\(|\bgsap\.getProperty\s*\(/;
const TRANSFORM_INVARIANT_READ =
/\.(?:getTotalLength|getBBox)\s*\(|\.(?:offsetWidth|offsetHeight|clientWidth|clientHeight)\b/;
// Measurement set for CALLBACK analysis: gsap.getProperty is deliberately
// excluded — callbacks that read animated values to drive derived output
// (scramble text, typewriter cursors) are per-frame deterministic and
// seek-idempotent, so they render the same on every worker.
const CALLBACK_MEASUREMENT_PATTERN =
/\.(?:getBoundingClientRect|getTotalLength|getBBox)\s*\(|\bgetComputedStyle\s*\(|\.(?:offsetWidth|offsetHeight|clientWidth|clientHeight)\b/;
function indexTagsByToken(tags: OpenTag[]): Map<string, OpenTag[]> {
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);
}
return tagsByToken;
}
function resolveSelectorTagIndexes(
selector: string,
tagsByToken: Map<string, OpenTag[]>,
): Set<number> {
const indexes = new Set<number>();
for (const token of targetedSelectorTokens(selector)) {
for (const tag of tagsByToken.get(token) ?? []) indexes.add(tag.index);
}
return indexes;
}
// A selector whose comma groups are each a single simple compound (no
// combinators, no attribute selectors) — the only shape that resolves
// faithfully through simple #id/.class tokens. Descendant selectors
// (".card-a .icon") and composition-scoped selectors
// ('[data-composition-id="a"] .dot') would mis-join across elements or
// compositions, so token-based matching must bail on them.
function selectorResolvesFaithfully(selector: string): boolean {
return selector.split(",").every((group) => {
const token = group.trim();
if (!token || token.includes("[")) return false;
return !/[\s>+~]/.test(token);
});
}
// Two GSAP targets provably hit the same element when their stable identities
// are equal, or when their (faithfully resolvable) selectors resolve to
// intersecting element sets — an id selector and a class selector can name the
// same node. Selectors with combinators or attribute parts are skipped rather
// than guessed at.
function targetsShareElement(
a: { selector: string; identity?: string },
b: { selector: string; identity?: string },
tagsByToken: Map<string, OpenTag[]>,
): boolean {
if (
!targetHasNoStableIdentity(a.selector, a.identity) &&
!targetHasNoStableIdentity(b.selector, b.identity) &&
(a.identity ?? a.selector) === (b.identity ?? b.selector)
) {
return true;
}
if (!selectorResolvesFaithfully(a.selector) || !selectorResolvesFaithfully(b.selector)) {
return false;
}
const aTags = resolveSelectorTagIndexes(a.selector, tagsByToken);
if (aTags.size === 0) return false;
const bTags = resolveSelectorTagIndexes(b.selector, tagsByToken);
for (const index of bTags) if (aTags.has(index)) return true;
return false;
}
/** Source from the delimiter at `openIndex` to its matching closer, inclusive. */
function matchBalanced(
source: string,
openIndex: number,
open: string,
close: string,
): string | null {
let depth = 0;
for (let i = openIndex; i < source.length; i++) {
const ch = source[i];
if (ch === open) depth++;
else if (ch === close) {
depth--;
if (depth === 0) return source.slice(openIndex, i + 1);
}
}
return null;
}
/** The nearest object literal `{...}` enclosing `index` (comment-stripped source). */
function enclosingObjectLiteral(source: string, index: number): string | null {
let depth = 0;
for (let i = index; i >= 0; i--) {
const ch = source[i];
if (ch === "}") depth++;
else if (ch === "{") {
if (depth === 0) return matchBalanced(source, i, "{", "}");
depth--;
}
}
return null;
}
function objectLiteralHasTopLevelRelativeValue(objectLiteral: string): boolean {
let depth = 0;
let inString: '"' | "'" | "`" | null = null;
for (let i = 0; i < objectLiteral.length; i++) {
const ch = objectLiteral[i] ?? "";
const prev = objectLiteral[i - 1] ?? "";
if (inString) {
if (ch === inString && prev !== "\\") inString = null;
continue;
}
if (ch === '"' || ch === "'" || ch === "`") {
inString = ch;
if (depth === 1 && /^[+-]=/.test(objectLiteral.slice(i + 1))) return true;
continue;
}
if (ch === "{" || ch === "(" || ch === "[") depth++;
else if (ch === "}" || ch === ")" || ch === "]") depth--;
}
return false;
}
function isInsideGsapTweenVars(source: string, index: number, timelineVars: string[]): boolean {
let depth = 0;
for (let i = index; i >= 0; i--) {
const ch = source[i];
if (ch === "}") depth++;
else if (ch === "{") {
if (depth === 0) {
const before = source.slice(Math.max(0, i - 240), i).replace(/\s+/g, " ");
const receivers = ["gsap", ...timelineVars].map(escapeRegExp).join("|");
return new RegExp(`(?:${receivers})\\.(?:set|to|from|fromTo|timeline)\\b[\\s\\S]*$`).test(
before,
);
}
depth--;
}
}
return false;
}
/** An expression starting at `start`, ending at the first `,` / closer at depth 0. */
function sliceExpression(source: string, start: number): string {
let depth = 0;
for (let i = start; i < source.length; i++) {
const ch = source[i] ?? "";
if ("({[".includes(ch)) depth++;
else if (")}]".includes(ch)) {
if (depth === 0) return source.slice(start, i);
depth--;
} else if (ch === "," && depth === 0) return source.slice(start, i);
}
return source.slice(start);
}
type ParsedFunctionValue = { firstParam: string | null; body: string };
function normalizeFirstParam(raw: string): string | null {
let param = raw.trim().replace(/=.*$/, "").trim();
param = param.replace(/\s*:\s*[\w$|<>,\s[\].]+$/, "").trim();
if (!param || /^[[{]/.test(param)) return null;
if (!/^[A-Za-z_$][\w$]*$/.test(param)) return null;
return param;
}
/** Parse a function-shaped source string into its first parameter and body. */
function parseFunctionValueSource(code: string): ParsedFunctionValue | null {
const src = code.trim();
const match =
src.match(/^(?:async\s+)?function\s*[\w$]*\s*\(([^)]*)\)/) ??
src.match(/^(?:async\s*)?\(([^)]*)\)\s*=>/) ??
src.match(/^(?:async\s*)?([A-Za-z_$][\w$]*)\s*=>/);
if (!match) return null;
const firstParam = normalizeFirstParam((match[1] ?? "").split(",")[0] ?? "");
return { firstParam, body: src.slice(match[0].length) };
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// Methods that exist on numbers: calling them on the (index) first parameter of
// a GSAP function value is valid and must not be flagged.
const NUMBER_METHODS = new Set([
"toFixed",
"toString",
"toPrecision",
"toExponential",
"toLocaleString",
"valueOf",
]);
// Index is a NUMBER — non-number member access on the first param throws at init.
function firstParamMemberAccessHazard(fn: ParsedFunctionValue): string | null {
if (!fn.firstParam) return null;
const pattern = new RegExp(
`\\b${escapeRegExp(fn.firstParam)}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`,
"g",
);
let match: RegExpExecArray | null;
while ((match = pattern.exec(fn.body)) !== null) {
const member = match[1] ?? "";
const after = fn.body.slice(match.index + match[0].length);
const isCall = /^\s*\(/.test(after);
if (isCall && NUMBER_METHODS.has(member)) continue;
return member;
}
return null;
}
/** Names of timeline variables (`const tl = gsap.timeline(...)`) in a script. */
function collectTimelineVarNames(source: string): string[] {
return [...source.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*gsap\.timeline\b/g)]
.map((m) => m[1] ?? "")
.filter(Boolean);
}
// Named function bodies in a script (declarations plus `const f = ...` function
// expressions and arrows). Expression-bodied arrows keep their single line.
function collectNamedFunctionBodies(source: string): Map<string, string> {
const bodies = new Map<string, string>();
const declPattern = /(?:^|[^.\w$])function\s+([A-Za-z_$][\w$]*)\s*\(/g;
let match: RegExpExecArray | null;
while ((match = declPattern.exec(source)) !== null) {
const braceIndex = source.indexOf("{", declPattern.lastIndex);
if (braceIndex < 0) continue;
const body = matchBalanced(source, braceIndex, "{", "}");
if (body) bodies.set(match[1] ?? "", body);
}
const assignPattern =
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b[^{]*|\([^)]*\)\s*=>\s*|[A-Za-z_$][\w$]*\s*=>\s*)/g;
while ((match = assignPattern.exec(source)) !== null) {
const bodyStart = assignPattern.lastIndex;
const body =
source[bodyStart] === "{"
? matchBalanced(source, bodyStart, "{", "}")
: sliceExpression(source, bodyStart);
if (body) bodies.set(match[1] ?? "", body);
}
return bodies;
}
// Two-hop closure: functions whose body measures the DOM directly, plus
// functions that call one of those (bounded fixpoint — no deep recursion).
function collectMeasuringFunctionNames(bodies: Map<string, string>): Set<string> {
const measuring = new Set<string>();
for (const [name, body] of bodies) {
if (CALLBACK_MEASUREMENT_PATTERN.test(body)) measuring.add(name);
}
for (let pass = 0; pass < 3; pass++) {
let grew = false;
for (const [name, body] of bodies) {
if (measuring.has(name)) continue;
for (const measured of measuring) {
if (new RegExp(`\\b${escapeRegExp(measured)}\\s*\\(`).test(body)) {
measuring.add(name);
grew = true;
break;
}
}
}
if (!grew) break;
}
return measuring;
}
function expressionReachesMeasurement(expression: string, measuring: Set<string>): boolean {
if (CALLBACK_MEASUREMENT_PATTERN.test(expression)) return true;
for (const name of measuring) {
if (new RegExp(`\\b${escapeRegExp(name)}\\b`).test(expression)) return true;
}
return false;
}
// Resolve script-level element variables to the simple selector tokens they can
// denote: literal getElementById/querySelector lookups, template-literal ids
// matched against the document's actual ids, and script-assigned class names
// (createElementNS + setAttribute("class", ...)). Anything else stays unresolved.
function resolveScriptElementTokens(source: string, tags: OpenTag[]): Map<string, Set<string>> {
const documentIds = tags.map((tag) => readAttr(tag.raw, "id")).filter((id) => id !== null);
const tokensByVar = new Map<string, Set<string>>();
const add = (name: string, token: string): void => {
const tokens = tokensByVar.get(name) ?? new Set<string>();
tokens.add(token);
tokensByVar.set(name, tokens);
};
for (const match of source.matchAll(
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*document\.getElementById\(\s*(["'])([^"'`]+)\2/g,
)) {
add(match[1] ?? "", `#${match[3] ?? ""}`);
}
for (const match of source.matchAll(
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*document\.getElementById\(\s*`([^`]*)`/g,
)) {
const template = match[2] ?? "";
const staticParts = template.split(/\$\{[^}]*\}/);
// A template with no literal segments (`getElementById(\`${name}\`)`) would
// match EVERY id in the document — treat it as unresolved instead.
if (staticParts.every((part) => part === "")) continue;
const idPattern = new RegExp(`^${staticParts.map(escapeRegExp).join(".*")}$`);
for (const id of documentIds) {
if (idPattern.test(id)) add(match[1] ?? "", `#${id}`);
}
}
for (const match of source.matchAll(
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*document\.querySelector\(\s*(["'])([^"'`]+)\2/g,
)) {
for (const token of targetedSelectorTokens(match[3] ?? "")) add(match[1] ?? "", token);
}
for (const match of source.matchAll(
/\b([A-Za-z_$][\w$]*)\.setAttribute\(\s*(["'])class\2\s*,\s*(["'])([^"'`]*)\3/g,
)) {
for (const cls of (match[4] ?? "").split(/\s+/).filter(Boolean)) add(match[1] ?? "", `.${cls}`);
}
for (const match of source.matchAll(/\b([A-Za-z_$][\w$]*)\.className\s*=\s*(["'])([^"'`]*)\2/g)) {
for (const cls of (match[3] ?? "").split(/\s+/).filter(Boolean)) add(match[1] ?? "", `.${cls}`);
}
return tokensByVar;
}
/** Expand selector tokens to the FULL token sets of the elements they resolve to. */
function elementLevelTokens(
tokens: Iterable<string>,
tagsByToken: Map<string, OpenTag[]>,
): Set<string> {
const expanded = new Set<string>(tokens);
for (const token of [...expanded]) {
for (const tag of tagsByToken.get(token) ?? []) {
for (const own of tagSimpleSelectors(tag)) expanded.add(own);
}
}
return expanded;
}
function isMultiComponentDasharray(value: string): boolean {
const normalized = value.replace(/!important\s*$/i, "").trim();
if (!normalized || /^none$/i.test(normalized)) return false;
return normalized.split(/[\s,]+/).filter(Boolean).length >= 2;
}
// A GSAP strokeDasharray value that is a static string/template with >= 2
// components is the explicit "L L" fix form — safe. Variables and numbers are
// the common single-component draw-on form (the pathLength trick).
function gsapDasharrayValueLooksMultiComponent(valueSource: string): boolean {
const literal = valueSource.trim().match(/^(["'`])([\s\S]*)\1$/)?.[2];
if (literal === undefined) return false;
return isMultiComponentDasharray(literal.replace(/\$\{[^}]*\}/g, "0"));
}
/** Byte ranges of every function body (declarations, expressions, block arrows). */
function collectFunctionBodyRanges(source: string): Array<{ start: number; end: number }> {
const ranges: Array<{ start: number; end: number }> = [];
const openerPatterns = [/\bfunction\b[^{;()]*\([^)]*\)\s*\{/g, /=>\s*\{/g];
for (const pattern of openerPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
const braceIndex = match.index + match[0].length - 1;
const body = matchBalanced(source, braceIndex, "{", "}");
if (body) ranges.push({ start: braceIndex, end: braceIndex + body.length });
}
}
return ranges;
}
function indexInsideAnyRange(
index: number,
ranges: Array<{ start: number; end: number }>,
): boolean {
return ranges.some((range) => index > range.start && index < range.end);
}
function isIifeBody(source: string, range: { start: number; end: number }): boolean {
let j = range.end;
while (j < source.length && /\s/.test(source[j]!)) j++;
if (source[j] !== ")") return false;
j++;
while (j < source.length && /\s/.test(source[j]!)) j++;
return source[j] === "(" || source.startsWith(".call", j) || source.startsWith(".apply", j);
}
function indexInsideNonIifeRange(
index: number,
source: string,
ranges: Array<{ start: number; end: number }>,
): boolean {
return ranges.some(
(range) => index > range.start && index < range.end && !isIifeBody(source, range),
);
}
// Simple selectors whose authored CSS (style blocks or inline styles) sets
// opacity to EXACTLY zero. The declaration regex is boundary-anchored so
// `opacity: 0.98` never matches; it ends at `;` or end of input, which also
// catches a final declaration without a trailing semicolon.
function collectCssOpacityZeroSelectors(
styles: LintContext["styles"],
tags: OpenTag[],
): Set<string> {
const selectors = new Set<string>();
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)) {
selectors.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");
if (id) selectors.add(`#${id}`);
for (const cls of readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? []) {
selectors.add(`.${cls}`);
}
}
return selectors;
}
// ── 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,
// gsap_timeline_set_initial_hide warns on `tl.set(..., 0)` initial hides
// (a zero-duration set at 0 does not render at exactly t=0), so this hint
// must not recommend that pattern — advise authored CSS or an immediate
// gsap.set() instead, keeping the two rules' advice consistent.
fixHint:
`Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add an immediate ` +
`\`gsap.set("${selector}", { opacity: 0 })\` (outside the timeline) 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.',
},
];
},
// missing_gsap_plugin
async ({ scripts, rawSource, options }) => {
const canInheritPluginFromHost =
options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");
if (canInheritPluginFromHost) return [];
const gsapScriptMotionPathFirstUseIndex = await loadGsapScriptMotionPathFirstUseIndex();
const motionPathUseIndices = scripts.map((script) =>
gsapScriptMotionPathFirstUseIndex(script.content),
);
const firstMotionPathScriptIndex = motionPathUseIndices.findIndex((index) => index !== null);
const firstMotionPathUseIndex = motionPathUseIndices[firstMotionPathScriptIndex] ?? null;
const firstUseScript = scripts[firstMotionPathScriptIndex];
const executionMode = (attrs: string): "blocking" | "defer" | "module" | "async" => {
const tag = `<script ${attrs}>`;
const isModule = (readDecodedAttr(tag, "type") ?? "").toLowerCase() === "module";
const hasSrc = readDecodedAttr(tag, "src") !== null;
const hasAsync = readDecodedAttr(tag, "async") !== null;
const hasDefer = readDecodedAttr(tag, "defer") !== null;
if ((isModule || hasSrc) && hasAsync) return "async";
if (isModule) return "module";
if (hasSrc && hasDefer) return "defer";
return "blocking";
};
const firstUseMode = firstUseScript ? executionMode(firstUseScript.attrs) : "blocking";
const hasMotionPathPlugin = scripts
.slice(0, firstMotionPathScriptIndex + 1)
.some((script, candidateIndex) => {
const candidateMode = executionMode(script.attrs);
const sameScript = candidateIndex === firstMotionPathScriptIndex;
const candidateIsPostParse = candidateMode === "defer" || candidateMode === "module";
const firstUseIsPostParse = firstUseMode === "defer" || firstUseMode === "module";
const executesBeforeFirstUse =
sameScript ||
candidateMode === "blocking" ||
(candidateIsPostParse && firstUseIsPostParse);
if (!executesBeforeFirstUse || (!sameScript && candidateMode === "async")) return false;
const src = readAttr(`<script ${script.attrs}>`, "src") ?? "";
const uncommented = stripJsComments(script.content);
const hasStaticImport =
/\bimport\s+(?:[\s\S]*?\sfrom\s*)?["'][^"']*\bMotionPathPlugin\b[^"']*["']/.test(
uncommented,
) ||
/\bimport\s+(?:[\w$]+\s*,\s*)?\{[^}]*\bMotionPathPlugin\b[^}]*\}\s+from\s*["'][^"']+["']/.test(
uncommented,
) ||
/\bimport\s+MotionPathPlugin\s+from\s*["'][^"']+["']/.test(uncommented);
const inlinedMarkerIndex = script.content.search(/\/\*\s*inlined:.*MotionPathPlugin/i);
const definitionIndex = uncommented.search(
/\b(?:const|let|var|class|function)\s+MotionPathPlugin\b/,
);
if (sameScript) {
if (hasStaticImport) return true;
if (firstMotionPathUseIndex === null) return false;
return (
(inlinedMarkerIndex >= 0 && inlinedMarkerIndex < firstMotionPathUseIndex) ||
(definitionIndex >= 0 && definitionIndex < firstMotionPathUseIndex)
);
}
return (
/MotionPathPlugin/i.test(src) ||
hasStaticImport ||
inlinedMarkerIndex >= 0 ||
definitionIndex >= 0
);
});
if (firstMotionPathScriptIndex < 0 || hasMotionPathPlugin) return [];
return [
{
code: "missing_gsap_plugin",
severity: "error",
message:
"A GSAP tween uses motionPath, but MotionPathPlugin is not loaded. Core GSAP ignores this plugin-specific property, so the intended motion will not render.",
fixHint:
"Load MotionPathPlugin before the animation script and register it with gsap.registerPlugin(MotionPathPlugin), or replace motionPath with core GSAP x/y tweens.",
},
];
},
// 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, rootTag }) => {
const findings: HyperframeLintFinding[] = [];
const declaredDuration = Number.parseFloat(
rootTag ? (readAttr(rootTag.raw, "data-duration") ?? "") : "",
);
const hasFiniteCompositionWindow = Number.isFinite(declaredDuration) && declaredDuration > 0;
// 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: hasFiniteCompositionWindow ? "warning" : "error",
message: hasFiniteCompositionWindow
? `GSAP tween uses \`repeat: -1\` (infinite), but the composition declares a finite ${declaredDuration}s window. ` +
"HyperFrames clips deterministic seeking and export to that explicit duration."
: "GSAP tween uses `repeat: -1` (infinite) without a finite composition `data-duration`. " +
"The timeline can report an unbounded duration and make render planning fail.",
fixHint: hasFiniteCompositionWindow
? "Keep the explicit finite composition `data-duration`. Use a finite repeat count only when the loop itself must end before the composition does."
: "Add a finite composition `data-duration`, or replace `repeat: -1` with " +
"`repeat: Math.max(0, Math.floor(totalDuration / singleCycleDuration) - 1)`.",
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.max(0, Math.floor(totalDuration / cycleDuration) - 1)`. " +
"Math.floor(10.5 / 2) - 1 = 4 repeats → 5 cycles × 2s = 10s ✓",
snippet: truncateSnippet(snippet),
});
}
return findings;
},
// gsap_repeat_floor_unclamped
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
// A direct floor-minus-one expression becomes GSAP's infinite -1 sentinel when
// the visible duration is shorter than one full cycle. Math.max-wrapped forms
// intentionally do not match because `repeat:` is followed by Math.max, not Math.floor.
const pattern = /repeat\s*:\s*Math\.floor\s*\([^)]+\)\s*-\s*1/g;
for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
stripComments: false,
contextBefore: 40,
contextAfter: 40,
})) {
findings.push({
code: "gsap_repeat_floor_unclamped",
severity: "warning",
message:
"GSAP repeat calculation can evaluate to -1 when the composition is shorter than one cycle, " +
"which GSAP interprets as an infinite repeat.",
fixHint:
"Clamp the finite repeat count at zero: " +
"`repeat: Math.max(0, Math.floor(totalDuration / cycleDuration) - 1)`.",
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 = collectCssOpacityZeroSelectors(styles, tags);
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 = indexTagsByToken(tags);
// 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_relative_value_second_writer — a relative tween value ("+=..."/"-=...") on a
// property that another writer is still ACTIVE on when the relative tween starts.
// The relative tween captures its base at tween INIT, which happens on first render:
// the sequential path inits it mid-flight of the other writer, a cold render worker
// landing later inits it with the other writer's end state — the same frame then
// renders at two different positions (a visible snap at chunk boundaries).
// GSAP renders children in start-time order within a single seek pass, so a writer
// that completes strictly BEFORE the relative tween's start yields identical bases
// on every seek path and is never flagged. Single-writer relative values are
// seek-stable. from()/fromTo() resolve their values at build (immediateRender), so
// they are exempt. The position PARAMETER ("+=0.5") is not a tween value — the
// parser keeps it out of properties — so it can never be flagged here.
async ({ scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];
const tagsByToken = indexTagsByToken(tags);
for (const script of scripts) {
if (!/gsap\.timeline/.test(script.content)) continue;
const windows = await cachedExtractGsapWindows(script.content);
for (const win of windows) {
if (win.method === "from" || win.method === "fromTo") continue;
if (win.overwriteAuto) continue;
if (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue;
const relativeProps = Object.entries(win.propertyValues)
.filter(([, value]) => isRelativeTweenValue(value))
.map(([prop]) => prop);
if (relativeProps.length === 0) continue;
const target = { selector: win.targetSelector, identity: win.targetIdentity };
for (const other of windows) {
if (other === win) continue;
if (other.position > win.position || other.end <= win.position) continue;
const sharedProps = relativeProps.filter((prop) => other.properties.includes(prop));
if (sharedProps.length === 0) continue;
if (
!targetsShareElement(
target,
{ selector: other.targetSelector, identity: other.targetIdentity },
tagsByToken,
)
) {
continue;
}
const values = sharedProps
.map((prop) => `${prop}: "${win.propertyValues[prop]}"`)
.join(", ");
const overlapEnd = Math.min(win.end, other.end);
const formatTime = (t: number): string => (Number.isFinite(t) ? `${t.toFixed(2)}s` : "∞");
findings.push({
code: "gsap_relative_value_second_writer",
severity: "error",
message:
`Relative value(s) ${values} on "${win.targetSelector}" start while another writer for the same ` +
`propert${sharedProps.length > 1 ? "ies" : "y"} is active between ${formatTime(win.position)} and ${formatTime(overlapEnd)}. ` +
"Relative tweens capture their base at tween init: the sequential path inits mid-flight of the other " +
"writer, a cold render worker landing later inits with its end state — the same frame renders at two " +
"different positions (snap at chunk boundaries).",
selector: win.targetSelector,
fixHint:
`Use absolute values for ${sharedProps.join(", ")}, or a fromTo() with explicit endpoints, so every seek ` +
"path resolves the same state. Single-writer relative values are safe; the conflict is the second writer.",
snippet: truncateSnippet(`${win.raw}\n${other.raw}`),
});
}
}
}
return findings;
},
// gsap_repeat_refresh_relative_value — repeatRefresh re-resolves the tween's values
// on every repeat iteration, so a relative value ACCUMULATES per cycle. A cold render
// worker seeking non-linearly into iteration N skips the accumulation a sequential
// playhead performed, so workers disagree on where the element is.
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const source = stripJsComments(script.content);
const pattern = /repeatRefresh\s*:\s*true\b/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
const objectLiteral = enclosingObjectLiteral(source, match.index);
if (!objectLiteral || !objectLiteralHasTopLevelRelativeValue(objectLiteral)) continue;
findings.push({
code: "gsap_repeat_refresh_relative_value",
severity: "error",
message:
'`repeatRefresh: true` combined with a relative value ("+="/"-=") accumulates per repeat iteration. ' +
"A cold render worker seeking non-linearly into iteration N never performed the earlier iterations' " +
"accumulation, so its rendered position diverges from the sequential path.",
fixHint:
"Remove `repeatRefresh: true`, or replace the relative value with absolute endpoints (e.g. a fromTo()) " +
"so each iteration resolves to the same state on every seek path.",
snippet: truncateSnippet(objectLiteral),
});
}
}
return findings;
},
// gsap_function_value_hazard — function-valued tween vars re-run at tween INIT,
// which is seek-order-dependent. A value reading transform-SENSITIVE geometry
// (getBoundingClientRect/getComputedStyle/gsap.getProperty) captures whatever state
// the worker's own seek order produced — error. Transform-INVARIANT layout reads
// (offsetWidth, getTotalLength, ...) are deterministic across cold render workers
// unless the measured layout itself animates — warning. GSAP function values receive
// (index, target, targets) — index is a NUMBER, so a method call on the first
// parameter (assuming it is the element) throws at init — error. Pure-index
// arithmetic, gsap.utils.wrap/distribute, and closures over constants are statically
// opaque or safe and are never flagged.
//
// Uses the raw parser output instead of the windows machinery: windows drop tweens
// with string positions ("+=0.5", labels), and position is irrelevant to whether a
// VALUE is hazardous.
async ({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
const parseGsapScript = await loadParseGsapScript();
for (const script of scripts) {
if (!/gsap\.timeline/.test(script.content)) continue;
const parsed = parseGsapScript(script.content);
for (const anim of parsed.animations) {
const raw = synthesizeWindowRaw(parsed.timelineVar, anim);
const entries = [
...Object.entries(anim.properties),
...Object.entries(anim.fromProperties ?? {}),
];
for (const [prop, value] of entries) {
if (typeof value !== "string" || !value.startsWith("__raw:")) continue;
const fn = parseFunctionValueSource(value.slice(6));
// Non-function raw values (gsap.utils.wrap(...), identifiers, arithmetic)
// are statically opaque — conservatively skipped.
if (!fn) continue;
const readsSensitive = TRANSFORM_SENSITIVE_READ.test(fn.body);
const readsInvariant = TRANSFORM_INVARIANT_READ.test(fn.body);
const badMember = firstParamMemberAccessHazard(fn);
if (!readsSensitive && !readsInvariant && !badMember) continue;
const reason = readsSensitive
? "reads transform-sensitive geometry, so its result depends on the worker's own seek order"
: badMember
? `accesses .${badMember} on its first parameter — GSAP function values receive (index, target, targets), ` +
"so the first parameter is a NUMBER and this throws at tween init"
: "measures layout at tween init, which is deterministic across cold render workers only while the measured layout never animates";
findings.push({
code: "gsap_function_value_hazard",
severity: readsSensitive || badMember ? "error" : "warning",
message: `Function-valued tween var for ${prop} on "${anim.targetSelector}" ${reason}. Each render worker initializes tweens independently.`,
selector: anim.targetSelector,
fixHint: badMember
? "Use the SECOND parameter for the element: (index, target) => ... — or index arithmetic like (i) => i * 20."
: "Compute the value once at build time (before the timeline is registered) and pass a constant, or derive it from fixed composition coordinates.",
snippet: truncateSnippet(raw),
});
}
}
}
return findings;
},
// gsap_callback_dom_measurement — DOM measurement reachable from timeline callbacks
// (tl.add(fn) / tl.call(fn) / eventCallback / onStart-style vars). The capture path
// seeks with suppressEvents=false (core/src/adapters/gsap.ts), so callbacks re-fire
// on EVERY seek, including rewinds — and a cold render worker executes them against
// whatever DOM state its own non-linear seek order produced. Geometry measured
// inside a callback is therefore seek-order-dependent, and anything measured before
// the callback ran (e.g. a build-time getTotalLength() on a path whose `d` the
// callback assigns) is stale or zero. Warning, not error: gsap.getProperty-style
// derived-output callbacks were excluded, but the remaining reads can still be
// legitimate when the measured layout is static.
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const source = stripJsComments(script.content);
if (!/gsap\.timeline/.test(source)) continue;
const bodies = collectNamedFunctionBodies(source);
const measuring = collectMeasuringFunctionNames(bodies);
// A callback argument is hazardous when it is an inline function whose body
// reaches a measurement, or a bare reference to a measuring function. Call
// expressions (`tl.add(build())`) execute at BUILD time, not as callbacks —
// conservatively skipped.
const callbackExpressionHazard = (expression: string): boolean => {
const trimmed = expression.trim();
const inline = parseFunctionValueSource(trimmed);
if (inline) return expressionReachesMeasurement(inline.body, measuring);
if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) return measuring.has(trimmed);
return false;
};
// The callback site goes into the structured `selector` field: the linter
// dedupes on code+selector+message, and a constant message would collapse
// distinct callback sites into a single finding.
const report = (site: string, snippet: string): void => {
findings.push({
code: "gsap_callback_dom_measurement",
severity: "warning",
message:
"Timeline callback reaches DOM measurement (getBoundingClientRect/getTotalLength/getComputedStyle/...). " +
"The renderer seeks with suppressEvents=false, so callbacks re-fire on every seek — and a cold render " +
"worker runs them against whatever DOM state its own non-linear seek order produced. Measured geometry is " +
"seek-order-dependent, and values measured at build time (before the callback ran) are stale or zero.",
selector: truncateSnippet(site, 120),
fixHint:
"Do all measurement and DOM setup synchronously at build time, before registering the timeline — " +
"or derive geometry from fixed composition coordinates instead of measuring.",
snippet: truncateSnippet(snippet),
});
};
const timelineVars = collectTimelineVarNames(source);
for (const timelineVar of timelineVars) {
const callPattern = new RegExp(
`\\b${escapeRegExp(timelineVar)}\\.(?:add|call)\\s*\\(`,
"g",
);
let match: RegExpExecArray | null;
while ((match = callPattern.exec(source)) !== null) {
const parenIndex = match.index + match[0].length - 1;
const argsWithParens = matchBalanced(source, parenIndex, "(", ")");
if (!argsWithParens) continue;
const firstArg = sliceExpression(argsWithParens.slice(1, -1), 0);
const site = match[0] + firstArg + ", ...)";
if (callbackExpressionHazard(firstArg)) report(site, site);
}
const eventCallbackPattern = new RegExp(
`\\b${escapeRegExp(timelineVar)}\\.eventCallback\\s*\\(\\s*["']on[A-Za-z]+["']\\s*,`,
"g",
);
while ((match = eventCallbackPattern.exec(source)) !== null) {
const expression = sliceExpression(source, eventCallbackPattern.lastIndex);
const site = match[0] + expression + ")";
if (callbackExpressionHazard(expression)) report(site, site);
}
}
const varsCallbackPattern =
/\bon(?:Start|Update|Complete|Repeat|ReverseComplete|Interrupt|Overwrite)\s*:\s*/g;
let match: RegExpExecArray | null;
while ((match = varsCallbackPattern.exec(source)) !== null) {
if (!isInsideGsapTweenVars(source, match.index, timelineVars)) continue;
const expression = sliceExpression(source, varsCallbackPattern.lastIndex);
const site = match[0] + expression;
if (callbackExpressionHazard(expression)) report(site, site);
}
}
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;
},
// svg_drawon_css_dasharray_conflict — GSAP sets/tweens strokeDasharray on an element
// whose CSS declares a MULTI-component stroke-dasharray (e.g. `10 10`). GSAP merges
// dash lists per component, so `strokeDasharray: 641.4` over CSS `10 10` computes to
// "641.4px, 10px" — the gap stays 10px and the hide-then-draw-on trick silently
// fails: the line is visible the whole scene. A static two-component GSAP value is
// the explicit fix form and is not flagged.
// fallow-ignore-next-line complexity
({ scripts, styles, tags }) => {
const findings: HyperframeLintFinding[] = [];
const tagsByToken = indexTagsByToken(tags);
const multiDashTokens = new Set<string>();
for (const style of styles) {
for (const [, selectorList, body] of style.content.matchAll(/([^{}]+)\{([^}]+)\}/g)) {
if (!selectorList || !body) continue;
const value = readStyleProperty(body, "stroke-dasharray");
if (!value || !isMultiComponentDasharray(value)) continue;
// Skip combinator groups — scope-dependent, unsafe to correlate by leaf token.
for (const group of selectorList.split(",")) {
const trimmed = group.trim();
if (!trimmed || /[\s>+~]/.test(trimmed)) continue;
for (const token of targetedSelectorTokens(trimmed)) multiDashTokens.add(token);
}
}
}
for (const tag of tags) {
const inlineValue = readStyleProperty(readAttr(tag.raw, "style") ?? "", "stroke-dasharray");
if (!inlineValue || !isMultiComponentDasharray(inlineValue)) continue;
for (const token of tagSimpleSelectors(tag)) multiDashTokens.add(token);
}
if (multiDashTokens.size === 0) return findings;
for (const script of scripts) {
const source = stripJsComments(script.content);
const varTokens = resolveScriptElementTokens(source, tags);
const reported = new Set<string>();
const writerPattern =
/\b[\w$]+\.(set|to|fromTo)\s*\(\s*(?:(["'])([^"'`]+)\2|([A-Za-z_$][\w$]*))\s*,\s*\{/g;
let match: RegExpExecArray | null;
while ((match = writerPattern.exec(source)) !== null) {
const method = match[1] ?? "";
const braceIndex = match.index + match[0].length - 1;
const firstVars = matchBalanced(source, braceIndex, "{", "}");
if (!firstVars) continue;
const varsObjects = [firstVars];
if (method === "fromTo") {
const afterFirst = source.slice(braceIndex + firstVars.length);
const secondOpen = /^\s*,\s*\{/.exec(afterFirst);
if (secondOpen) {
const secondBrace = braceIndex + firstVars.length + secondOpen[0].length - 1;
const secondVars = matchBalanced(source, secondBrace, "{", "}");
if (secondVars) varsObjects.push(secondVars);
}
}
const quotedSelector = match[3];
const targetTokens = quotedSelector
? targetedSelectorTokens(quotedSelector)
: (varTokens.get(match[4] ?? "") ?? new Set<string>());
if (targetTokens.size === 0) continue;
const expanded = elementLevelTokens(targetTokens, tagsByToken);
for (const varsObject of varsObjects) {
const propMatch =
varsObject.match(/\bstrokeDasharray\s*:\s*/) ??
varsObject.match(/["']stroke-dasharray["']\s*:\s*/);
if (!propMatch || propMatch.index === undefined) continue;
const valueSource = sliceExpression(varsObject, propMatch.index + propMatch[0].length);
if (gsapDasharrayValueLooksMultiComponent(valueSource)) continue;
const conflictToken = [...expanded].find((token) => multiDashTokens.has(token));
if (!conflictToken) continue;
const targetLabel = quotedSelector ?? match[4] ?? "";
if (reported.has(targetLabel + conflictToken)) continue;
reported.add(targetLabel + conflictToken);
findings.push({
code: "svg_drawon_css_dasharray_conflict",
severity: "error",
message:
`GSAP writes strokeDasharray on "${targetLabel}", but its CSS ("${conflictToken}") declares a multi-component ` +
'stroke-dasharray. GSAP merges dash lists per component, so the CSS gap survives (e.g. "641.4px, 10px") — ' +
"the draw-on hide only hides one gap's worth and the line stays visible the whole scene.",
selector: quotedSelector ?? undefined,
fixHint:
`Remove the CSS stroke-dasharray from "${conflictToken}" (decorative dashes belong on a separate element), ` +
'or set the full two-component value in GSAP: strokeDasharray: "${len} ${len}".',
snippet: truncateSnippet(match[0] + firstVars.slice(1)),
});
}
}
}
return findings;
},
// gsap_timeline_set_initial_hide — a zero-duration tl.set(...) at position 0 inside
// the paused timeline does NOT render while the playhead sits exactly at 0 (verified
// against this repo's GSAP: tl.time(0) leaves the target untouched; only a seek past
// 0 applies it). Frame 0 therefore shows the UN-hidden state, then the element pops
// hidden on frame 1 — and only for the worker that renders frame 0. Targets already
// hidden by authored CSS/inline styles or by a standalone gsap.set are exempt: the
// tl.set is then a defensive re-assertion and frame 0 is hidden anyway.
//
// Only sets that precede every tween in source order qualify: the parser resolves a
// mutated position variable (`var t = 0; ...; tl.set(sel, vars, t)`) to its INITIAL
// binding, so late hard-kills can masquerade as position-0 sets. Genuine
// initial-state hides are authored before the timeline's tweens.
async ({ scripts, styles, tags }) => {
const findings: HyperframeLintFinding[] = [];
const cssHiddenSelectors = collectCssOpacityZeroSelectors(styles, tags);
const tagsByToken = indexTagsByToken(tags);
for (const script of scripts) {
if (!/gsap\.timeline/.test(script.content)) continue;
const windows = await cachedExtractGsapWindows(script.content);
const alreadyHidden = new Set([
...cssHiddenSelectors,
...extractStandaloneHiddenSelectors(script.content),
]);
const isInstantHold = (win: GsapWindow): boolean =>
win.method === "set" ||
((win.method === "to" || win.method === "fromTo") && win.end === win.position);
const firstTweenIndex = windows.findIndex((win) => !isInstantHold(win));
const initialHolds = firstTweenIndex < 0 ? windows : windows.slice(0, firstTweenIndex);
for (const win of initialHolds) {
if (!isInstantHold(win) || win.position !== 0) continue;
if (win.global || win.immediateRender) continue;
if (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue;
const targetTokens = [...targetedSelectorTokens(win.targetSelector)];
const hiddenByToken =
targetTokens.length > 0 && targetTokens.every((token) => alreadyHidden.has(token));
const resolvedTags = targetTokens.flatMap((token) => tagsByToken.get(token) ?? []);
const hiddenByElement =
resolvedTags.length > 0 &&
resolvedTags.every((tag) =>
tagSimpleSelectors(tag).some((token) => alreadyHidden.has(token)),
);
if (hiddenByToken || hiddenByElement) continue;
const offset = win.propertyValues["strokeDashoffset"];
const hidesByOffset = numberValue(offset) !== null && !zeroValue(offset);
const hides =
isHiddenGsapState(win.propertyValues) ||
zeroValue(win.propertyValues["scale"]) ||
hidesByOffset;
if (!hides) continue;
findings.push({
code: "gsap_timeline_set_initial_hide",
severity: "warning",
message:
`Initial hidden state for "${win.targetSelector}" is set via tl.set(...) at position 0 inside the paused ` +
"timeline. A zero-duration set at 0 does not render while the playhead sits exactly at 0, so frame 0 " +
"shows the un-hidden state.",
selector: win.targetSelector,
fixHint:
"Use gsap.set(...) (immediate, outside the timeline) for initial states, or author the hidden state " +
"directly in CSS/attributes.",
snippet: truncateSnippet(win.raw),
});
}
}
return findings;
},
// svg_measure_before_path_d — getTotalLength() on a <path> that has no static `d`
// attribute in the HTML. In Chrome getTotalLength() on a d-less path returns 0,
// silently killing dash animations (offset 0 == length 0 == nothing to draw). If a
// d assignment exists but only inside a function body, execution order is statically
// undecidable — WARNING; if NO d assignment exists anywhere — ERROR. Element
// identity is resolved conservatively (literal / template getElementById,
// querySelector); createElementNS-built paths and unresolved variables are skipped.
// fallow-ignore-next-line complexity
({ scripts, styles, tags }) => {
const findings: HyperframeLintFinding[] = [];
const tagsByToken = indexTagsByToken(tags);
// CSS `d: path(...)` supplies geometry statically — treat like a static attribute.
const cssProvidesD = styles.some((style) => /\bd\s*:\s*path\(/.test(style.content));
for (const script of scripts) {
const source = stripJsComments(script.content);
const varTokens = resolveScriptElementTokens(source, tags);
const functionRanges = collectFunctionBodyRanges(source);
const createdVars = new Set(
[...source.matchAll(/([A-Za-z_$][\w$]*)\s*=\s*document\.createElementNS\(/g)].map(
(m) => m[1] ?? "",
),
);
// `d` assignments come in two forms: direct setAttribute('d', ...) and the
// GSAP attr plugin (`gsap.set(wire, { attr: { d: "..." } })`). Both count,
// with the same lexical-order semantics.
const dAssignments = [
...[...source.matchAll(/\b([A-Za-z_$][\w$]*)\.setAttribute\(\s*["']d["']\s*,/g)].map(
(m) => ({ varName: m[1] ?? "", index: m.index ?? 0 }),
),
...[
...source.matchAll(
/\.(?:set|to|fromTo)\s*\(\s*([A-Za-z_$][\w$]*)\s*,\s*\{[^{}]*\battr\s*:\s*\{[^{}]*\bd\s*:/g,
),
].map((m) => ({ varName: m[1] ?? "", index: m.index ?? 0 })),
];
const reported = new Set<string>();
const measurePattern = /\b([A-Za-z_$][\w$]*)\.getTotalLength\s*\(/g;
let match: RegExpExecArray | null;
while ((match = measurePattern.exec(source)) !== null) {
const varName = match[1] ?? "";
if (createdVars.has(varName)) continue;
const tokens = varTokens.get(varName);
if (!tokens || tokens.size === 0) continue;
// Only <path> elements without a static d attribute qualify.
const resolvedTags = [...tokens].flatMap((token) => tagsByToken.get(token) ?? []);
const dLessPaths = resolvedTags.filter(
(tag) => tag.name.toLowerCase() === "path" && readAttr(tag.raw, "d") === null,
);
if (dLessPaths.length === 0 || dLessPaths.length !== resolvedTags.length) continue;
if (cssProvidesD) continue;
// A same-variable d assignment lexically before the measure, in scope of the
// measure (top-level, or a function body containing the measure), is the
// legitimate synchronous assign-then-measure pattern.
const measureIndex = match.index;
const assignedBeforeInScope = dAssignments.some(
(assign) =>
assign.varName === varName &&
assign.index < measureIndex &&
(!indexInsideAnyRange(assign.index, functionRanges) ||
functionRanges.some(
(range) =>
assign.index > range.start &&
assign.index < range.end &&
measureIndex > range.start &&
measureIndex < range.end,
)),
);
if (assignedBeforeInScope) continue;
const sameVarAssignmentExists = dAssignments.some((a) => a.varName === varName);
const tokenLabel = [...tokens].join(", ");
if (reported.has(tokenLabel)) continue;
reported.add(tokenLabel);
findings.push({
code: "svg_measure_before_path_d",
severity: sameVarAssignmentExists ? "warning" : "error",
message: sameVarAssignmentExists
? `getTotalLength() is called on "${tokenLabel}", whose \`d\` is only assigned inside a function body — ` +
"if the measure runs before that function (e.g. the function is a timeline callback), the length is 0 " +
"and the dash animation is dead."
: `getTotalLength() is called on "${tokenLabel}", but the path has no static \`d\` attribute and no d ` +
"assignment exists anywhere — getTotalLength() returns 0 in Chrome, silently killing dash animations.",
selector: tokenLabel,
fixHint:
"Assign the path's `d` synchronously at build time (top level, before measuring), or author a static " +
"d attribute in the HTML.",
snippet: truncateSnippet(match[0] + ")"),
});
}
}
return findings;
},
];