diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts
index af1547d0b..d4d1d6f45 100644
--- a/packages/core/src/lint/rules/gsap.test.ts
+++ b/packages/core/src/lint/rules/gsap.test.ts
@@ -608,6 +608,107 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
+ it("warns when an opacity exit ends at a clip start boundary without a hard kill", () => {
+ const html = `
+
+
+
+
First beat
+
+
+
Second beat
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_exit_missing_hard_kill");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("warning");
+ expect(finding?.selector).toBe("#headline");
+ expect(finding?.message).toContain("3.00s");
+ });
+
+ it("does not warn when a boundary exit has a matching hard kill", () => {
+ const html = `
+
+
+
+
First beat
+
+
+
Second beat
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_exit_missing_hard_kill");
+ expect(finding).toBeUndefined();
+ });
+
+ it("does not match sub-composition exits against root clip boundaries", () => {
+ const html = `
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_exit_missing_hard_kill");
+ expect(finding).toBeUndefined();
+ });
+
+ it("uses the authored hidden property in hard-kill fix hints", () => {
+ const html = `
+
+
+
+
First beat
+
+
+
Second beat
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_exit_missing_hard_kill");
+ expect(finding?.fixHint).toContain("{ autoAlpha: 0 }");
+ });
+
it("does not false-positive on repeat: -10 (invalid GSAP but not infinite)", () => {
const html = `
diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts
index 7a6c7b588..8e83dbd42 100644
--- a/packages/core/src/lint/rules/gsap.ts
+++ b/packages/core/src/lint/rules/gsap.ts
@@ -10,12 +10,20 @@ type GsapWindow = {
position: number;
end: number;
properties: string[];
+ propertyValues: Record;
overwriteAuto: boolean;
method: string;
raw: string;
};
+type CompositionRange = {
+ id: string;
+ start: number;
+ end: number;
+};
+
const META_GSAP_KEYS = new Set(["duration", "ease", "repeat", "yoyo", "overwrite", "delay"]);
+const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
// ── GSAP parsing utilities ─────────────────────────────────────────────────
@@ -68,6 +76,7 @@ function extractGsapWindows(script: string): GsapWindow[] {
position: animation.position,
end: animation.position + meta.effectiveDuration,
properties: meta.properties.length > 0 ? meta.properties : Object.keys(animation.properties),
+ propertyValues: meta.propertyValues,
overwriteAuto: meta.overwriteAuto,
method: match[1] ?? "to",
raw,
@@ -79,9 +88,20 @@ function extractGsapWindows(script: string): GsapWindow[] {
function parseGsapWindowMeta(
method: string,
argsStr: string,
-): { effectiveDuration: number; properties: string[]; overwriteAuto: boolean } {
+): {
+ effectiveDuration: number;
+ properties: string[];
+ propertyValues: Record;
+ overwriteAuto: boolean;
+} {
+ const emptyMeta = {
+ effectiveDuration: 0,
+ properties: [],
+ propertyValues: {},
+ overwriteAuto: false,
+ };
const selectorMatch = argsStr.match(/^\s*["']([^"']+)["']\s*,/);
- if (!selectorMatch) return { effectiveDuration: 0, properties: [], overwriteAuto: false };
+ if (!selectorMatch) return emptyMeta;
const afterSelector = argsStr.slice(selectorMatch[0].length);
let properties: Record = {};
@@ -124,6 +144,7 @@ function parseGsapWindowMeta(
return {
effectiveDuration: method === "set" ? 0 : effectiveDuration,
properties: [...propertyNames],
+ propertyValues: properties,
overwriteAuto,
};
}
@@ -179,6 +200,123 @@ function stringValue(value: string | number | undefined): string | null {
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): 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 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 {
+ 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 = readAttr(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;
+}
+
+function collectClipStartBoundariesByComposition(
+ source: string,
+ tags: OpenTag[],
+): Map {
+ const ranges = collectCompositionRanges(source, tags);
+ const boundaries = new Map>();
+
+ for (const tag of tags) {
+ const classAttr = readAttr(tag.raw, "class") || "";
+ const classes = classAttr.split(/\s+/).filter(Boolean);
+ if (!classes.includes("clip")) 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();
+ 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;
@@ -233,7 +371,7 @@ function cssTransformToGsapProps(cssTransform: string): string | null {
export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
- ({ tags, scripts, rootCompositionId }) => {
+ ({ source, tags, scripts, rootCompositionId }) => {
const findings: HyperframeLintFinding[] = [];
// Build clip element selector map
@@ -257,10 +395,13 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
}
const classUsage = countClassUsage(tags);
+ const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags);
for (const script of scripts) {
const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
const gsapWindows = extractGsapWindows(script.content);
+ const clipStartBoundaries =
+ clipStartBoundariesByComposition.get(localTimelineCompId || rootCompositionId || "") ?? [];
// overlapping_gsap_tweens
for (let i = 0; i < gsapWindows.length; i++) {
@@ -291,6 +432,32 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
}
}
+ // gsap_exit_missing_hard_kill
+ if (clipStartBoundaries.length > 0) {
+ for (const win of gsapWindows) {
+ 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;
+
+ findings.push({
+ code: "gsap_exit_missing_hard_kill",
+ severity: "warning",
+ 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:
+ `Add \`tl.set("${win.targetSelector}", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\` ` +
+ "after the exit tween.",
+ snippet: truncateSnippet(win.raw),
+ });
+ }
+ }
+
// gsap_animates_clip_element — only error when GSAP animates visibility/display
for (const win of gsapWindows) {
const sel = win.targetSelector;