diff --git a/packages/core/src/lint/hyperframeLinter.test.ts b/packages/core/src/lint/hyperframeLinter.test.ts
index 1e4b6b61..201c455b 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, vi } from "vitest";
-import { lintHyperframeHtml, lintScriptUrls } from "./hyperframeLinter.js";
+import { describe, it, expect } from "vitest";
+import { lintHyperframeHtml } from "./hyperframeLinter.js";
describe("lintHyperframeHtml", () => {
const validComposition = `
@@ -111,281 +111,23 @@ describe("lintHyperframeHtml", () => {
expect(codes.length).toBe(uniqueCodes.length);
});
- it("detects timeline ID mismatch", () => {
- const html = `
-
-
-`;
- const result = lintHyperframeHtml(html);
- const mismatch = result.findings.find((f) => f.code === "timeline_id_mismatch");
- expect(mismatch).toBeDefined();
- expect(mismatch?.message).toContain("intro-anim");
- });
-
- it("does not flag matching timeline IDs", () => {
- const result = lintHyperframeHtml(validComposition);
- const mismatch = result.findings.find((f) => f.code === "timeline_id_mismatch");
- expect(mismatch).toBeUndefined();
- });
-
- it("reports error when timeline assignment has no init guard", () => {
- 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();
- });
-});
-
-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 = `
-
-
-
+ it("strips
wrapper before linting composition files", () => {
+ const html = `
+
-`;
- const result = lintHyperframeHtml(html);
- const finding = result.findings.find((f) => f.code === "template_literal_selector");
- expect(finding).toBeUndefined();
+`;
+ const result = lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
+ const missing = result.findings.filter(
+ (f) => f.code === "missing-composition-id" || f.code === "missing-dimensions",
+ );
+ expect(missing).toHaveLength(0);
});
});
diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts
index f97c79e9..57bf8ce2 100644
--- a/packages/core/src/lint/hyperframeLinter.ts
+++ b/packages/core/src/lint/hyperframeLinter.ts
@@ -42,7 +42,11 @@ export function lintHyperframeHtml(
html: string,
options: HyperframeLinterOptions = {},
): HyperframeLintResult {
- const source = html || "";
+ // Strip wrapper if present — composition files are often wrapped in
+ // tags that the runtime extracts at load time.
+ let source = html || "";
+ const templateMatch = source.match(/]*>([\s\S]*)<\/template>/i);
+ if (templateMatch?.[1]) source = templateMatch[1];
const filePath = options.filePath;
const findings: HyperframeLintFinding[] = [];
const seen = new Set();
@@ -456,21 +460,31 @@ export function lintHyperframeHtml(
}
// #4: Timed element missing visibility:hidden (no class="clip" or equivalent)
+ // Skip: elements with data-composition-id (managed by runtime), elements with
+ // opacity:0 in style (will be animated in by GSAP), and composition host elements.
+ // Most HyperFrames compositions use GSAP to manage element visibility via opacity
+ // animations, so this check is only relevant for elements that truly need to be
+ // hidden before the timeline starts.
for (const tag of tags) {
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
if (!readAttr(tag.raw, "data-start")) continue;
+ // Skip composition roots and hosts — the runtime manages their lifecycle
+ if (readAttr(tag.raw, "data-composition-id")) continue;
+ if (readAttr(tag.raw, "data-composition-src")) continue;
const classAttr = readAttr(tag.raw, "class") || "";
const styleAttr = readAttr(tag.raw, "style") || "";
const hasClip = classAttr.split(/\s+/).includes("clip");
- const hasHiddenStyle = /visibility\s*:\s*hidden/i.test(styleAttr);
+ const hasHiddenStyle =
+ /visibility\s*:\s*hidden/i.test(styleAttr) || /opacity\s*:\s*0/i.test(styleAttr);
if (!hasClip && !hasHiddenStyle) {
const elementId = readAttr(tag.raw, "id") || undefined;
pushFinding({
code: "timed_element_missing_visibility_hidden",
- severity: "warning",
- message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has data-start but no class="clip" or visibility:hidden. The framework needs elements to start hidden so it can manage their lifecycle.`,
+ severity: "info",
+ message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has data-start but no class="clip", visibility:hidden, or opacity:0. Consider adding initial hidden state if the element should not be visible before its start time.`,
elementId,
- fixHint: 'Add class="clip" to the element (with CSS: .clip { visibility: hidden; }).',
+ fixHint:
+ 'Add class="clip" (with CSS: .clip { visibility: hidden; }) or style="opacity:0" if the element should start hidden.',
snippet: truncateSnippet(tag.raw),
});
}
diff --git a/packages/core/src/lint/types.ts b/packages/core/src/lint/types.ts
index a612bcb3..30f85fbb 100644
--- a/packages/core/src/lint/types.ts
+++ b/packages/core/src/lint/types.ts
@@ -1,4 +1,4 @@
-export type HyperframeLintSeverity = "error" | "warning";
+export type HyperframeLintSeverity = "error" | "warning" | "info";
export type HyperframeLintFinding = {
code: string;