diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index b108b4a05..1d10bce3b 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -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(); diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index 21dc05870..c7cf05f6f 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -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); + }); }); diff --git a/packages/core/src/lint/hyperframeLinter.test.ts b/packages/core/src/lint/hyperframeLinter.test.ts index a5ef30ce4..a051b1f44 100644 --- a/packages/core/src/lint/hyperframeLinter.test.ts +++ b/packages/core/src/lint/hyperframeLinter.test.ts @@ -192,6 +192,112 @@ describe("lintHyperframeHtml", () => { expect(finding).toBeUndefined(); }); + it("reports error when GSAP targets a clip element by id", () => { + const html = ` + +
+
+

Hello

+
+
+ +`; + 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 = ` + +
+
+

Content

+
+
+ +`; + 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 = ` + +
+
+

Hello

+
+
+ +`; + 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 = ` + +
+
+

Hello

+
+
+ +`; + 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 = ` + +
+
+

Content

+
+
+ +`; + 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 = ` diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts index ab77c9537..ef50a9e14 100644 --- a/packages/core/src/lint/hyperframeLinter.ts +++ b/packages/core/src/lint/hyperframeLinter.ts @@ -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(); + const clipClasses = 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 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
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), }); } } diff --git a/skills/hyperframes-compose/house-style.md b/skills/hyperframes-compose/house-style.md index c50c9f893..695dd4615 100644 --- a/skills/hyperframes-compose/house-style.md +++ b/skills/hyperframes-compose/house-style.md @@ -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