mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
co-authored by
Claude Opus 4.6
parent
cf0ec5d27d
commit
a97dc75702
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user