fix(core): warn on GSAP boundary exits without hard kill (#474)

Fixes #473.

## Problem

The HyperFrames skill now tells agents to add deterministic `tl.set()` hard-kills after elements fade out at beat / scene boundaries, but the linter did not enforce that rule outside the narrow caption-specific check.

That made the rule easy for sub-agents to ignore: an element could fade to `opacity: 0` exactly as the next clip starts, with no explicit hidden-state set at the boundary. During non-linear seeking or frame capture, that leaves the final visibility state dependent on tween interpolation instead of an authored deterministic kill.

## What this fixes

This PR adds a generalized GSAP lint warning for scene-boundary exits:

- detects GSAP `to` / `fromTo` exit tweens that end at or near a clip `data-start` boundary
- treats `opacity: 0`, `autoAlpha: 0`, `visibility: "hidden"`, and `display: "none"` as hidden exit states
- requires a matching same-selector `tl.set(...)` hidden state at the same boundary
- scopes clip-boundary matching to the timeline's registered composition so sub-composition exits do not match unrelated root boundaries
- reports `gsap_exit_missing_hard_kill` with the selector, boundary time, source snippet, and a fix hint that preserves the authored hidden property when possible
- keeps valid compositions quiet when the boundary hard-kill already exists

## Why

Clip boundaries are the exact points where rendered frames are most sensitive to stale DOM state. A fade-out tween describes a transition, but it does not give the linter or the authoring model an explicit deterministic state to land on when seeking around the boundary.

The existing caption rule proved the class of bug was worth catching, but it only applied to caption-loop patterns. The issue in #473 is broader: any element inside a timed composition can exit at a scene boundary and need the same deterministic cleanup.

## Root cause

The GSAP lint rule parser already calculated tween windows and clip metadata existed in the lint context, but no rule connected those two facts:

- clip `data-start` values were not used as scene-boundary checkpoints for GSAP exits
- parsed GSAP windows tracked property names, but not enough property values to tell whether a tween ended in a hidden state
- hard-kill detection only existed as a caption-specific regex, so normal scene elements were missed

This PR extends the existing GSAP window metadata with parsed property values, then checks hidden-state exits against same-composition clip start boundaries and same-selector `tl.set` calls.

## Verification

### Local checks

- `bun run --filter @hyperframes/core test src/lint/rules/gsap.test.ts`
- `bunx oxlint packages/core/src/lint/rules/gsap.ts packages/core/src/lint/rules/gsap.test.ts`
- `bunx oxfmt --check packages/core/src/lint/rules/gsap.ts packages/core/src/lint/rules/gsap.test.ts`
- `bun run --filter @hyperframes/core typecheck`
- `bun run --filter @hyperframes/core test`
- `bun run --filter @hyperframes/core build`

### CLI verification

Verified against local fixtures where `#headline` exits at the next clip boundary without a hard kill:

- opacity fixture reports `gsap_exit_missing_hard_kill` for `#headline` at `3.00s`
- autoAlpha fixture reports the same warning and suggests `tl.set("#headline", { autoAlpha: 0 }, 3.00)`
- sub-composition regression test confirms a `sub` timeline exit no longer matches an unrelated root composition boundary

### Browser verification

Verified the Studio lint flow with `agent-browser` against the autoAlpha fixture:

- opened Studio at `http://127.0.0.1:43174/#project/issue-473-autoalpha`
- clicked the real `Lint` button
- confirmed the lint modal shows the new warning and the property-preserving `{ autoAlpha: 0 }` fix hint
- saved local proof artifacts under `qa-artifacts/issue-473/`

## Notes

- the `tmp/issue-473-*` fixtures and `qa-artifacts/issue-473` browser proof are local-only and are not part of this PR
- this intentionally stays heuristic-based: it warns near clip start boundaries instead of trying to build a full GSAP execution model
- expression-valued GSAP props and deeper regex-parser limitations remain outside this PR's scope; those are parser-hardening work, not required for the bug in #473
This commit is contained in:
Miguel Ángel
2026-04-24 18:07:02 +02:00
committed by GitHub
parent e9c56961fb
commit fbec7bb1c4
2 changed files with 271 additions and 3 deletions
+101
View File
@@ -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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0" data-duration="6">
<div id="scene-a" class="clip" data-start="0" data-duration="3" data-track-index="0">
<h1 id="headline">First beat</h1>
</div>
<div id="scene-b" class="clip" data-start="3" data-duration="3" data-track-index="0">
<h1>Second beat</h1>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#headline", { opacity: 0, duration: 0.3 }, 2.7);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0" data-duration="6">
<div id="scene-a" class="clip" data-start="0" data-duration="3" data-track-index="0">
<h1 id="headline">First beat</h1>
</div>
<div id="scene-b" class="clip" data-start="3" data-duration="3" data-track-index="0">
<h1>Second beat</h1>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#headline", { opacity: 0, duration: 0.3 }, 2.7);
tl.set("#headline", { opacity: 0, visibility: "hidden" }, 3);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
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 = `
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080" data-start="0" data-duration="6">
<div id="root-a" class="clip" data-start="0" data-duration="3" data-track-index="0"></div>
<div id="root-b" class="clip" data-start="3" data-duration="3" data-track-index="0"></div>
</div>
<div data-composition-id="sub" data-width="1920" data-height="1080" data-start="0" data-duration="4">
<div id="sub-a" class="clip" data-start="0" data-duration="2" data-track-index="0">
<h1 id="sub-title">Sub scene</h1>
</div>
<div id="sub-b" class="clip" data-start="2" data-duration="2" data-track-index="0"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#sub-title", { opacity: 0, duration: 0.3 }, 2.7);
window.__timelines["sub"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0" data-duration="6">
<div id="scene-a" class="clip" data-start="0" data-duration="3" data-track-index="0">
<h1 id="headline">First beat</h1>
</div>
<div id="scene-b" class="clip" data-start="3" data-duration="3" data-track-index="0">
<h1>Second beat</h1>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#headline", { autoAlpha: 0, duration: 0.3 }, 2.7);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
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 = `
<html><body>
+170 -3
View File
@@ -10,12 +10,20 @@ type GsapWindow = {
position: number;
end: number;
properties: string[];
propertyValues: Record<string, string | number>;
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<string, string | number>;
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<string, string | number> = {};
@@ -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<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 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 = 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<string, number[]> {
const ranges = collectCompositionRanges(source, tags);
const boundaries = new Map<string, Set<number>>();
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<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;
@@ -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;