fix(lint): detect GSAP animations targeting clip elements (tab crash) (#114)

* fix(lint): detect GSAP animations targeting clip elements (tab crash)

The runtime manages clip visibility via inline styles. When GSAP also
writes inline styles on the same element, both systems trigger style
recalculations every frame, creating a runaway loop that crashes the
browser tab.

New rule gsap_animates_clip_element (error severity):
- Builds map of all elements with class="clip" (by id and class)
- Checks if any GSAP selector resolves to a clip element
- Nested selectors like "#overlay .title" are correctly ignored
- Merged into existing GSAP script loop (no redundant parsing)

* fix: remove non-null assertions and add missing test coverage

- Replace `!` assertions with optional chaining in lint.ts and tests
- Add shouldBlockRender tests for --strict-all without --strict
- Add clip element test for class-only detection (no id)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use optional chaining for array access in lintProject tests

TypeScript's strict mode flags array indexing as possibly undefined.
Use optional chaining and fallbacks instead of non-null assertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-03-30 15:32:41 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent cf0ec5d27d
commit a97dc75702
5 changed files with 179 additions and 17 deletions
+2 -1
View File
@@ -27,7 +27,8 @@ export default defineCommand({
}
const fileCount = lintResult.results.length;
const fileLabel = fileCount === 1 ? lintResult.results[0]!.file : `${fileCount} files`;
const fileLabel =
fileCount === 1 ? (lintResult.results[0]?.file ?? "index.html") : `${fileCount} files`;
console.log(`${c.accent("◆")} Linting ${c.accent(project.name + "/" + fileLabel)}`);
console.log();
+27 -7
View File
@@ -67,7 +67,9 @@ describe("lintProject", () => {
expect(totalErrors).toBe(0);
expect(totalWarnings).toBe(0);
expect(results).toHaveLength(1);
expect(results[0]!.file).toBe("index.html");
const first = results[0];
expect(first).toBeDefined();
expect(first?.file).toBe("index.html");
});
it("detects errors in index.html", () => {
@@ -75,7 +77,9 @@ describe("lintProject", () => {
const { totalErrors, results } = lintProject(project);
expect(totalErrors).toBeGreaterThan(0);
const mediaFinding = results[0]!.result.findings.find((f) => f.code === "media_missing_id");
const first = results[0];
expect(first).toBeDefined();
const mediaFinding = first?.result.findings.find((f) => f.code === "media_missing_id");
expect(mediaFinding).toBeDefined();
});
@@ -86,9 +90,11 @@ describe("lintProject", () => {
const { totalErrors, results } = lintProject(project);
expect(results).toHaveLength(2);
expect(results[1]!.file).toBe("compositions/captions.html");
const second = results[1];
expect(second).toBeDefined();
expect(second?.file).toBe("compositions/captions.html");
expect(totalErrors).toBeGreaterThan(0);
const subFindings = results[1]!.result.findings;
const subFindings = second?.result.findings ?? [];
expect(subFindings.some((f) => f.code === "media_missing_id")).toBe(true);
});
@@ -99,9 +105,13 @@ describe("lintProject", () => {
const { totalErrors, results } = lintProject(project);
expect(results).toHaveLength(2);
const first = results[0];
const second = results[1];
expect(first).toBeDefined();
expect(second).toBeDefined();
// Both files have media_missing_id errors
const rootErrors = results[0]!.result.errorCount;
const subErrors = results[1]!.result.errorCount;
const rootErrors = first?.result.errorCount ?? 0;
const subErrors = second?.result.errorCount ?? 0;
expect(totalErrors).toBe(rootErrors + subErrors);
});
@@ -113,7 +123,9 @@ describe("lintProject", () => {
expect(results).toHaveLength(2);
expect(totalWarnings).toBeGreaterThan(0);
const preloadWarning = results[1]!.result.findings.find((f) => f.code === "media_preload_none");
const second = results[1];
expect(second).toBeDefined();
const preloadWarning = second?.result.findings.find((f) => f.code === "media_preload_none");
expect(preloadWarning).toBeDefined();
});
@@ -166,4 +178,12 @@ describe("shouldBlockRender", () => {
it("--strict-all: does not block when clean", () => {
expect(shouldBlockRender(true, true, 0, 0)).toBe(false);
});
it("--strict-all alone: blocks on errors", () => {
expect(shouldBlockRender(false, true, 1, 0)).toBe(true);
});
it("--strict-all alone: blocks on warnings", () => {
expect(shouldBlockRender(false, true, 0, 1)).toBe(true);
});
});
@@ -192,6 +192,112 @@ describe("lintHyperframeHtml", () => {
expect(finding).toBeUndefined();
});
it("reports error when GSAP targets a clip element by id", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
<h1>Hello</h1>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#overlay", { opacity: 0, duration: 0.5 }, 4.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#overlay");
expect(finding?.message).toContain("inner wrapper");
});
it("reports error when GSAP targets a clip element by class", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="card" class="clip my-card" data-start="0" data-duration="5" data-track-index="0">
<p>Content</p>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from(".my-card", { y: 100, duration: 0.3 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".my-card");
});
it("does NOT flag GSAP targeting a child of a clip element", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
<h1 class="title">Hello</h1>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to(".title", { opacity: 1, duration: 0.5 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("does NOT flag GSAP targeting a nested selector like '#overlay .title'", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
<h1 class="title">Hello</h1>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#overlay .title", { opacity: 1, duration: 0.5 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("reports error when GSAP targets a clip element with no id (class-only)", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div class="clip scene-card" data-start="0" data-duration="5" data-track-index="0">
<p>Content</p>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to(".scene-card", { y: -50, duration: 0.4 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".scene-card");
expect(finding?.elementId).toBeUndefined();
});
it("reports error for audio with data-start but no id", () => {
const html = `
<html><body>
+42 -7
View File
@@ -267,6 +267,24 @@ export function lintHyperframeHtml(
});
}
// Build clip element selector map for gsap_animates_clip_element check.
// The runtime manages clip visibility — GSAP writing to the same element causes
// a runaway style recalculation loop that crashes the browser tab.
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 classAttr = readAttr(tag.raw, "class") || "";
const classes = classAttr.split(/\s+/).filter(Boolean);
if (!classes.includes("clip")) continue;
const id = readAttr(tag.raw, "id");
const info: ClipInfo = { tag: tag.name, id: id || "", classes: classAttr };
if (id) clipIds.set(`#${id}`, info);
for (const cls of classes) {
if (cls !== "clip") clipClasses.set(`.${cls}`, info);
}
}
const classUsage = countClassUsage(tags);
for (const script of scripts) {
const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
@@ -310,24 +328,41 @@ export function lintHyperframeHtml(
}
}
// Check if any GSAP selector targets a clip element
for (const win of gsapWindows) {
const sel = win.targetSelector;
const clipInfo = clipIds.get(sel) || clipClasses.get(sel);
if (!clipInfo) continue;
const elDesc = `<${clipInfo.tag}${clipInfo.id ? ` id="${clipInfo.id}"` : ""} class="${clipInfo.classes}">`;
pushFinding({
code: "gsap_animates_clip_element",
severity: "error",
message: `GSAP animation targets a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility — animate an inner wrapper instead.`,
selector: sel,
elementId: clipInfo.id || undefined,
fixHint: "Wrap content in a child <div> and target that with GSAP.",
snippet: truncateSnippet(win.raw),
});
}
if (!localTimelineCompId || localTimelineCompId === rootCompositionId) {
continue;
}
for (const window of gsapWindows) {
if (!isSuspiciousGlobalSelector(window.targetSelector)) {
for (const win of gsapWindows) {
if (!isSuspiciousGlobalSelector(win.targetSelector)) {
continue;
}
const className = getSingleClassSelector(window.targetSelector);
const className = getSingleClassSelector(win.targetSelector);
if (className && (classUsage.get(className) || 0) < 2) {
continue;
}
pushFinding({
code: "unscoped_gsap_selector",
severity: "warning",
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${window.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
selector: window.targetSelector,
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${window.targetSelector}\` or use a unique id.`,
snippet: truncateSnippet(window.raw),
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),
});
}
}
+2 -2
View File
@@ -5,9 +5,9 @@ Defaults when no `visual-style.md` or animation direction is provided. These rai
## Before Writing HTML
1. **Interpret the prompt.** Generate real content for the topic — don't use the prompt text as body copy. A recipe lists real ingredients. A stats dashboard shows the actual numbers given. A product showcase names real features and specs. A sci-fi HUD has actual crosshairs and readouts, not a heading that says "sci-fi HUD."
2. **Pick a palette.** First decide: does this content call for a light or dark canvas? Food, weddings, children, wellness, education, lifestyle, nature, and celebrations → light palette (Warm/Editorial, Clean/Corporate, Nature/Earth, Pastel/Soft). Tech, finance, cinema, nightlife, horror, gaming, and premium → dark palette. Then load the file and pick one palette. Declare your bg, fg, and accent colors before writing any code.
2. **Pick a palette.** First decide: does this content call for a light or dark canvas? Then load the file most appropriate for the theme and pick one palette at random from the file. Declare your bg, fg, and accent colors before writing any code.
3. **Pick a typeface.** Don't reach for Sora, Space Grotesk, Outfit, Playfair Display, Cormorant Garamond, or Bodoni Moda — they're overused. Explore the full range of Google Fonts. Serif for editorial, mono for technical, display for impact, handwritten for personal.
4. **Pick a layout approach.** Don't default to the same structure every time. Options: full-bleed centered hero, left-aligned editorial column, split-frame (content left / visual right or vice versa), scattered/asymmetric positioning, grid-based with uneven cells, stacked vertical sections. Vary this across compositions.
4. **Pick a layout approach.** Don't default to the same structure every time.
5. **Pick your entrance patterns.** Plan how elements enter — never use the same entrance pattern twice in a composition.
## Motion