diff --git a/packages/cli/package.json b/packages/cli/package.json
index 7016b2411..0d4e76389 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -15,6 +15,7 @@
],
"type": "module",
"scripts": {
+ "test": "vitest run",
"dev": "tsx src/cli.ts",
"build": "bun run build:fonts && bun run build:studio && tsup && bun run build:runtime && bun run build:copy",
"build:fonts": "cd ../producer && tsx scripts/generate-font-data.ts",
@@ -52,7 +53,8 @@
"picocolors": "^1.1.1",
"tsup": "^8.0.0",
"tsx": "^4.0.0",
- "typescript": "^5.0.0"
+ "typescript": "^5.0.0",
+ "vitest": "^3.2.4"
},
"engines": {
"node": ">=22"
diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts
index d3be75aef..1f33c9286 100644
--- a/packages/cli/src/commands/dev.ts
+++ b/packages/cli/src/commands/dev.ts
@@ -7,6 +7,8 @@ import { createRequire } from "node:module";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { isDevMode } from "../utils/env.js";
+import { lintProject } from "../utils/lintProject.js";
+import { formatLintFindings } from "../utils/lintFormat.js";
/**
* Try to start a server on the given port, auto-incrementing up to maxAttempts
@@ -71,6 +73,19 @@ export default defineCommand({
const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./";
const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : basename(dir);
+ // Lint before starting — surface issues for the agent to fix.
+ // dev.ts doesn't use resolveProject() because it needs to proceed even without index.html.
+ const indexPath = join(dir, "index.html");
+ if (existsSync(indexPath)) {
+ const project = { dir, name: projectName, indexPath };
+ const lintResult = lintProject(project);
+ if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) {
+ console.log();
+ for (const line of formatLintFindings(lintResult)) console.log(line);
+ console.log();
+ }
+ }
+
if (isDevMode()) {
return runDevMode(dir, projectName);
}
diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts
index 8d4031f46..b108b4a05 100644
--- a/packages/cli/src/commands/lint.ts
+++ b/packages/cli/src/commands/lint.ts
@@ -1,11 +1,8 @@
import { defineCommand } from "citty";
-import { readFileSync } from "node:fs";
-import { join } from "node:path";
-import { lintHyperframeHtml } from "@hyperframes/core/lint";
-import type { HyperframeLintFinding } from "@hyperframes/core/lint";
-import { walkDir } from "@hyperframes/core/studio-api";
import { c } from "../ui/colors.js";
import { resolveProject } from "../utils/project.js";
+import { lintProject } from "../utils/lintProject.js";
+import { formatLintFindings } from "../utils/lintFormat.js";
import { withMeta } from "../utils/updateCheck.js";
export default defineCommand({
@@ -16,62 +13,32 @@ export default defineCommand({
},
async run({ args }) {
const project = resolveProject(args.dir);
- const htmlFiles = walkDir(project.dir).filter((f) => f.endsWith(".html"));
-
- const allFindings: (HyperframeLintFinding & { file: string })[] = [];
- let totalErrors = 0;
- let totalWarnings = 0;
-
- for (const file of htmlFiles) {
- const html = readFileSync(join(project.dir, file), "utf-8");
- const result = lintHyperframeHtml(html, { filePath: file });
- for (const f of result.findings) {
- allFindings.push({ ...f, file });
- }
- totalErrors += result.errorCount;
- totalWarnings += result.warningCount;
- }
+ const lintResult = lintProject(project);
if (args.json) {
- console.log(
- JSON.stringify(
- withMeta({
- ok: totalErrors === 0,
- findings: allFindings,
- errorCount: totalErrors,
- warningCount: totalWarnings,
- filesScanned: htmlFiles.length,
- }),
- null,
- 2,
- ),
- );
- process.exit(totalErrors > 0 ? 1 : 0);
+ const combined = {
+ ok: lintResult.totalErrors === 0,
+ errorCount: lintResult.totalErrors,
+ warningCount: lintResult.totalWarnings,
+ findings: lintResult.results.flatMap((r) => r.result.findings),
+ };
+ console.log(JSON.stringify(withMeta(combined), null, 2));
+ process.exit(combined.ok ? 0 : 1);
}
- console.log(
- `${c.accent("◆")} Linting ${c.accent(project.name)} (${htmlFiles.length} HTML files)`,
- );
+ const fileCount = lintResult.results.length;
+ const fileLabel = fileCount === 1 ? lintResult.results[0]!.file : `${fileCount} files`;
+ console.log(`${c.accent("◆")} Linting ${c.accent(project.name + "/" + fileLabel)}`);
console.log();
- if (allFindings.length === 0) {
+ if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) {
console.log(`${c.success("◇")} ${c.success("0 errors, 0 warnings")}`);
return;
}
- for (const finding of allFindings) {
- const prefix = finding.severity === "error" ? c.error("✗") : c.warn("⚠");
- const loc = finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : "";
- console.log(
- `${prefix} ${c.bold(finding.code)}${loc}: ${finding.message} ${c.dim(finding.file)}`,
- );
- if (finding.fixHint) {
- console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`);
- }
- }
+ const lines = formatLintFindings(lintResult, { showElementId: true, showSummary: true });
+ for (const line of lines) console.log(line);
- const summaryIcon = totalErrors > 0 ? c.error("◇") : c.success("◇");
- console.log(`\n${summaryIcon} ${totalErrors} error(s), ${totalWarnings} warning(s)`);
- process.exit(totalErrors > 0 ? 1 : 0);
+ process.exit(lintResult.totalErrors > 0 ? 1 : 0);
},
});
diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts
index 4f1ff6818..4bc73211c 100644
--- a/packages/cli/src/commands/render.ts
+++ b/packages/cli/src/commands/render.ts
@@ -3,6 +3,8 @@ import { existsSync, mkdirSync, statSync } from "node:fs";
import { cpus, freemem } from "node:os";
import { resolve, dirname, join } from "node:path";
import { resolveProject } from "../utils/project.js";
+import { lintProject, shouldBlockRender } from "../utils/lintProject.js";
+import { formatLintFindings } from "../utils/lintFormat.js";
import { loadProducer } from "../utils/producer.js";
import { c } from "../ui/colors.js";
import { formatBytes, formatDuration, errorBox } from "../ui/format.js";
@@ -75,6 +77,16 @@ Examples:
description: "Suppress verbose output",
default: false,
},
+ strict: {
+ type: "boolean",
+ description: "Fail render on lint errors",
+ default: false,
+ },
+ "strict-all": {
+ type: "boolean",
+ description: "Fail render on lint errors AND warnings",
+ default: false,
+ },
},
async run({ args }) {
// ── Resolve project ────────────────────────────────────────────────────
@@ -131,6 +143,8 @@ Examples:
const useDocker = args.docker ?? false;
const useGpu = args.gpu ?? false;
const quiet = args.quiet ?? false;
+ const strictAll = args["strict-all"] ?? false;
+ const strictErrors = (args.strict ?? false) || strictAll;
// ── Print render plan ─────────────────────────────────────────────────
const workerCount = workers ?? defaultWorkerCount();
@@ -193,6 +207,31 @@ Examples:
}
}
+ // ── Pre-render lint ──────────────────────────────────────────────────
+ {
+ const lintResult = lintProject(project);
+ if (!quiet && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) {
+ console.log("");
+ for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line);
+ if (
+ shouldBlockRender(
+ strictErrors,
+ strictAll,
+ lintResult.totalErrors,
+ lintResult.totalWarnings,
+ )
+ ) {
+ const mode = strictAll ? "--strict-all" : "--strict";
+ console.log("");
+ console.log(c.error(` Aborting render due to lint issues (${mode} mode).`));
+ console.log("");
+ process.exit(1);
+ }
+ console.log(c.dim(" Continuing render despite lint issues. Use --strict to block."));
+ console.log("");
+ }
+ }
+
// ── Render ────────────────────────────────────────────────────────────
if (useDocker) {
await renderDocker(project.dir, outputPath, {
diff --git a/packages/cli/src/utils/lintFormat.ts b/packages/cli/src/utils/lintFormat.ts
new file mode 100644
index 000000000..7410fc508
--- /dev/null
+++ b/packages/cli/src/utils/lintFormat.ts
@@ -0,0 +1,51 @@
+import { c } from "../ui/colors.js";
+import type { ProjectLintResult } from "./lintProject.js";
+
+export interface LintFormatOptions {
+ /** Show elementId in brackets after the code (default: true) */
+ showElementId?: boolean;
+ /** Show summary line with error/warning counts (default: false) */
+ showSummary?: boolean;
+ /** Group errors before warnings per file (default: false — interleaved) */
+ errorsFirst?: boolean;
+}
+
+/**
+ * Format lint findings for console output. Used by lint, render, and dev commands.
+ */
+export function formatLintFindings(
+ { results, totalErrors, totalWarnings }: ProjectLintResult,
+ options: LintFormatOptions = {},
+): string[] {
+ const { showElementId = true, showSummary = false, errorsFirst = false } = options;
+ const lines: string[] = [];
+ const multiFile = results.length > 1;
+
+ for (const { file, result } of results) {
+ if (result.findings.length === 0) continue;
+
+ const format = (finding: (typeof result.findings)[0]) => {
+ const prefix = finding.severity === "error" ? c.error("✗") : c.warn("⚠");
+ const fileLabel = multiFile ? c.dim(`[${file}] `) : "";
+ const loc =
+ showElementId && finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : "";
+ lines.push(` ${prefix} ${fileLabel}${c.bold(finding.code)}${loc}: ${finding.message}`);
+ if (finding.fixHint) lines.push(` ${c.dim(`Fix: ${finding.fixHint}`)}`);
+ };
+
+ if (errorsFirst) {
+ for (const f of result.findings) if (f.severity === "error") format(f);
+ for (const f of result.findings) if (f.severity === "warning") format(f);
+ } else {
+ for (const f of result.findings) format(f);
+ }
+ }
+
+ if (showSummary) {
+ const icon = totalErrors > 0 ? c.error("◇") : c.success("◇");
+ lines.push("");
+ lines.push(`${icon} ${totalErrors} error(s), ${totalWarnings} warning(s)`);
+ }
+
+ return lines;
+}
diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts
new file mode 100644
index 000000000..21dc05870
--- /dev/null
+++ b/packages/cli/src/utils/lintProject.test.ts
@@ -0,0 +1,169 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { mkdirSync, writeFileSync, rmSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { lintProject, shouldBlockRender } from "./lintProject.js";
+import type { ProjectDir } from "./project.js";
+
+function tmpProject(name: string): string {
+ const dir = join(tmpdir(), `hf-test-${name}-${Date.now()}`);
+ mkdirSync(dir, { recursive: true });
+ return dir;
+}
+
+function validHtml(compId = "main"): string {
+ return `
+
+
+`;
+}
+
+function htmlWithMissingMediaId(): string {
+ return `
+
+
+`;
+}
+
+function htmlWithPreloadNone(): string {
+ return `
+
+
+
+
+`;
+}
+
+let dirs: string[] = [];
+
+function makeProject(indexHtml: string, subComps?: Record): ProjectDir {
+ const dir = tmpProject("lint");
+ dirs.push(dir);
+ writeFileSync(join(dir, "index.html"), indexHtml);
+ if (subComps) {
+ const compsDir = join(dir, "compositions");
+ mkdirSync(compsDir, { recursive: true });
+ for (const [name, html] of Object.entries(subComps)) {
+ writeFileSync(join(compsDir, name), html);
+ }
+ }
+ return { dir, name: "test-project", indexPath: join(dir, "index.html") };
+}
+
+afterEach(() => {
+ for (const d of dirs) {
+ rmSync(d, { recursive: true, force: true });
+ }
+ dirs = [];
+});
+
+describe("lintProject", () => {
+ it("returns zero errors/warnings for a clean project", () => {
+ const project = makeProject(validHtml());
+ const { totalErrors, totalWarnings, results } = lintProject(project);
+
+ expect(totalErrors).toBe(0);
+ expect(totalWarnings).toBe(0);
+ expect(results).toHaveLength(1);
+ expect(results[0]!.file).toBe("index.html");
+ });
+
+ it("detects errors in index.html", () => {
+ const project = makeProject(htmlWithMissingMediaId());
+ const { totalErrors, results } = lintProject(project);
+
+ expect(totalErrors).toBeGreaterThan(0);
+ const mediaFinding = results[0]!.result.findings.find((f) => f.code === "media_missing_id");
+ expect(mediaFinding).toBeDefined();
+ });
+
+ it("lints sub-compositions in compositions/ directory", () => {
+ const project = makeProject(validHtml(), {
+ "captions.html": htmlWithMissingMediaId(),
+ });
+ const { totalErrors, results } = lintProject(project);
+
+ expect(results).toHaveLength(2);
+ expect(results[1]!.file).toBe("compositions/captions.html");
+ expect(totalErrors).toBeGreaterThan(0);
+ const subFindings = results[1]!.result.findings;
+ expect(subFindings.some((f) => f.code === "media_missing_id")).toBe(true);
+ });
+
+ it("aggregates errors across index.html and sub-compositions", () => {
+ const project = makeProject(htmlWithMissingMediaId(), {
+ "overlay.html": htmlWithMissingMediaId(),
+ });
+ const { totalErrors, results } = lintProject(project);
+
+ expect(results).toHaveLength(2);
+ // Both files have media_missing_id errors
+ const rootErrors = results[0]!.result.errorCount;
+ const subErrors = results[1]!.result.errorCount;
+ expect(totalErrors).toBe(rootErrors + subErrors);
+ });
+
+ it("aggregates warnings from sub-compositions", () => {
+ const project = makeProject(validHtml(), {
+ "captions.html": htmlWithPreloadNone(),
+ });
+ const { totalWarnings, results } = lintProject(project);
+
+ expect(results).toHaveLength(2);
+ expect(totalWarnings).toBeGreaterThan(0);
+ const preloadWarning = results[1]!.result.findings.find((f) => f.code === "media_preload_none");
+ expect(preloadWarning).toBeDefined();
+ });
+
+ it("handles project with no compositions/ directory", () => {
+ const project = makeProject(validHtml());
+ // No compositions/ dir created
+ const { results } = lintProject(project);
+
+ expect(results).toHaveLength(1);
+ });
+
+ it("ignores non-HTML files in compositions/", () => {
+ const project = makeProject(validHtml(), {
+ "captions.html": validHtml("captions"),
+ });
+ // Add a non-HTML file
+ writeFileSync(join(project.dir, "compositions", "readme.txt"), "not html");
+
+ const { results } = lintProject(project);
+
+ expect(results).toHaveLength(2); // index.html + captions.html, not readme.txt
+ });
+});
+
+describe("shouldBlockRender", () => {
+ it("default: does not block on errors", () => {
+ expect(shouldBlockRender(false, false, 5, 0)).toBe(false);
+ });
+
+ it("default: does not block on warnings", () => {
+ expect(shouldBlockRender(false, false, 0, 3)).toBe(false);
+ });
+
+ it("--strict: blocks on errors", () => {
+ expect(shouldBlockRender(true, false, 1, 0)).toBe(true);
+ });
+
+ it("--strict: does not block on warnings only", () => {
+ expect(shouldBlockRender(true, false, 0, 5)).toBe(false);
+ });
+
+ it("--strict-all: blocks on errors", () => {
+ expect(shouldBlockRender(true, true, 1, 0)).toBe(true);
+ });
+
+ it("--strict-all: blocks on warnings", () => {
+ expect(shouldBlockRender(true, true, 0, 1)).toBe(true);
+ });
+
+ it("--strict-all: does not block when clean", () => {
+ expect(shouldBlockRender(true, true, 0, 0)).toBe(false);
+ });
+});
diff --git a/packages/cli/src/utils/lintProject.ts b/packages/cli/src/utils/lintProject.ts
new file mode 100644
index 000000000..c612f33f8
--- /dev/null
+++ b/packages/cli/src/utils/lintProject.ts
@@ -0,0 +1,56 @@
+import { existsSync, readFileSync, readdirSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint";
+import type { ProjectDir } from "./project.js";
+
+export interface ProjectLintResult {
+ results: Array<{ file: string; result: HyperframeLintResult }>;
+ totalErrors: number;
+ totalWarnings: number;
+}
+
+/**
+ * Lint the root index.html and all sub-compositions in the compositions/ directory.
+ * Returns aggregated results across all files.
+ */
+export function lintProject(project: ProjectDir): ProjectLintResult {
+ const results: Array<{ file: string; result: HyperframeLintResult }> = [];
+ let totalErrors = 0;
+ let totalWarnings = 0;
+
+ // Lint root composition
+ const rootHtml = readFileSync(project.indexPath, "utf-8");
+ const rootResult = lintHyperframeHtml(rootHtml, { filePath: project.indexPath });
+ results.push({ file: "index.html", result: rootResult });
+ totalErrors += rootResult.errorCount;
+ totalWarnings += rootResult.warningCount;
+
+ // Lint sub-compositions in compositions/ directory
+ const compositionsDir = resolve(project.dir, "compositions");
+ if (existsSync(compositionsDir)) {
+ const files = readdirSync(compositionsDir).filter((f) => f.endsWith(".html"));
+ for (const file of files) {
+ const filePath = join(compositionsDir, file);
+ const html = readFileSync(filePath, "utf-8");
+ const result = lintHyperframeHtml(html, { filePath });
+ results.push({ file: `compositions/${file}`, result });
+ totalErrors += result.errorCount;
+ totalWarnings += result.warningCount;
+ }
+ }
+
+ return { results, totalErrors, totalWarnings };
+}
+
+/**
+ * Determine whether a render should be blocked based on lint results and strict mode.
+ * --strict blocks on errors; --strict-all blocks on errors or warnings.
+ */
+export function shouldBlockRender(
+ strictErrors: boolean,
+ strictAll: boolean,
+ totalErrors: number,
+ totalWarnings: number,
+): boolean {
+ return (strictErrors && totalErrors > 0) || (strictAll && (totalErrors > 0 || totalWarnings > 0));
+}
diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts
new file mode 100644
index 000000000..ae847ff6d
--- /dev/null
+++ b/packages/cli/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["src/**/*.test.ts"],
+ },
+});
diff --git a/packages/core/src/lint/hyperframeLinter.test.ts b/packages/core/src/lint/hyperframeLinter.test.ts
index 201c455bb..1a5d31758 100644
--- a/packages/core/src/lint/hyperframeLinter.test.ts
+++ b/packages/core/src/lint/hyperframeLinter.test.ts
@@ -1,5 +1,5 @@
-import { describe, it, expect } from "vitest";
-import { lintHyperframeHtml } from "./hyperframeLinter.js";
+import { describe, it, expect, vi } from "vitest";
+import { lintHyperframeHtml, lintScriptUrls } from "./hyperframeLinter.js";
describe("lintHyperframeHtml", () => {
const validComposition = `
@@ -130,4 +130,335 @@ describe("lintHyperframeHtml", () => {
);
expect(missing).toHaveLength(0);
});
+
+ it("reports error when timeline registry is assigned without initializing", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ expect(finding?.message).toContain("without initializing");
+ });
+
+ it("does not flag timeline assignment when init guard is present", () => {
+ const result = lintHyperframeHtml(validComposition);
+ const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
+ expect(finding).toBeUndefined();
+ });
+
+ it("reports error for audio with data-start but no id", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "media_missing_id");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ expect(finding?.message).toContain("SILENT");
+ });
+
+ it("reports error for video with data-start but no id", () => {
+ const html = `
+
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "media_missing_id");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ expect(finding?.message).toContain("FROZEN");
+ });
+
+ it("does not flag media elements that have id", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "media_missing_id");
+ expect(finding).toBeUndefined();
+ });
+
+ it("reports warning for media with preload=none", () => {
+ const html = `
+
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "media_preload_none");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("warning");
+ });
+
+ it("reports error for media with id but no src", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "media_missing_src");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ });
+});
+
+describe("lintScriptUrls", () => {
+ it("reports error for script URL returning non-2xx", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const html = `
+
+
+`;
+ const findings = await lintScriptUrls(html);
+ const finding = findings.find((f) => f.code === "inaccessible_script_url");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ expect(finding?.message).toContain("404");
+
+ vi.unstubAllGlobals();
+ });
+
+ it("reports error for unreachable script URL", async () => {
+ const mockFetch = vi.fn().mockRejectedValue(new Error("AbortError"));
+ vi.stubGlobal("fetch", mockFetch);
+
+ const html = `
+
+
+`;
+ const findings = await lintScriptUrls(html);
+ const finding = findings.find((f) => f.code === "inaccessible_script_url");
+ expect(finding).toBeDefined();
+
+ vi.unstubAllGlobals();
+ });
+
+ it("does not flag accessible script URLs", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const html = `
+
+
+`;
+ const findings = await lintScriptUrls(html);
+ expect(findings.length).toBe(0);
+
+ vi.unstubAllGlobals();
+ });
+
+ it("skips inline scripts without src", async () => {
+ const mockFetch = vi.fn();
+ vi.stubGlobal("fetch", mockFetch);
+
+ const html = `
+
+
+`;
+ const findings = await lintScriptUrls(html);
+ expect(findings.length).toBe(0);
+ expect(mockFetch).not.toHaveBeenCalled();
+
+ vi.unstubAllGlobals();
+ });
+
+ // ── gsap_css_transform_conflict ──────────────────────────────────────────
+
+ it("warns when tl.to animates x on an element with CSS translateX", () => {
+ const html = `
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("warning");
+ expect(finding?.selector).toBe("#title");
+ expect(finding?.fixHint).toMatch(/fromTo/);
+ expect(finding?.fixHint).toMatch(/xPercent/);
+ });
+
+ it("warns when tl.to animates scale on an element with CSS scale transform", () => {
+ const html = `
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("warning");
+ expect(finding?.selector).toBe("#hero");
+ });
+
+ it("does NOT warn when tl.to targets element without CSS transform", () => {
+ const html = `
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
+ expect(conflict).toBeUndefined();
+ });
+
+ it("does NOT warn when tl.fromTo targets element WITH CSS transform (author owns both ends)", () => {
+ const html = `
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
+ expect(conflict).toBeUndefined();
+ });
+
+ it("emits one warning when a combined CSS transform conflicts with multiple GSAP properties", () => {
+ const html = `
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const conflicts = result.findings.filter((f) => f.code === "gsap_css_transform_conflict");
+ expect(conflicts).toHaveLength(1);
+ expect(conflicts[0]?.message).toMatch(/x\/scale|scale\/x/);
+ });
+});
+
+describe("template_literal_selector rule", () => {
+ it("reports error when querySelector uses template literal variable", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "template_literal_selector");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ });
+
+ it("reports error for querySelectorAll with template literal variable", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "template_literal_selector");
+ expect(finding).toBeDefined();
+ });
+
+ it("does not report error for hardcoded querySelector strings", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "template_literal_selector");
+ expect(finding).toBeUndefined();
+ });
});
diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts
index 57bf8ce27..5936179e7 100644
--- a/packages/core/src/lint/hyperframeLinter.ts
+++ b/packages/core/src/lint/hyperframeLinter.ts
@@ -379,8 +379,8 @@ export function lintHyperframeHtml(
if (!parentClosePattern.test(between)) {
pushFinding({
code: "video_nested_in_timed_element",
- severity: "warning",
- message: `