diff --git a/packages/core/src/lint/rules/composition.test.ts b/packages/core/src/lint/rules/composition.test.ts
index b1c09861f..34c0fffcd 100644
--- a/packages/core/src/lint/rules/composition.test.ts
+++ b/packages/core/src/lint/rules/composition.test.ts
@@ -94,4 +94,147 @@ describe("composition rules", () => {
const finding = result.findings.find((f) => f.code === "template_literal_selector");
expect(finding).toBeUndefined();
});
+
+ describe("timed_element_missing_clip_class", () => {
+ it("flags element with data-start but no class='clip'", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("warning");
+ });
+
+ it("does not flag element that has class='clip'", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
+ expect(finding).toBeUndefined();
+ });
+
+ it("does not flag audio or video elements", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
+ expect(finding).toBeUndefined();
+ });
+ });
+
+ describe("overlapping_clips_same_track", () => {
+ it("flags overlapping clips on the same track", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ });
+
+ it("does not flag clips on different tracks", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
+ expect(finding).toBeUndefined();
+ });
+
+ it("does not flag sequential clips on the same track", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
+ expect(finding).toBeUndefined();
+ });
+ });
+
+ describe("requestanimationframe_in_composition", () => {
+ it("flags requestAnimationFrame usage in script content", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find(
+ (f) => f.code === "requestanimationframe_in_composition",
+ );
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("warning");
+ });
+
+ it("does not flag requestAnimationFrame in comments", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find(
+ (f) => f.code === "requestanimationframe_in_composition",
+ );
+ expect(finding).toBeUndefined();
+ });
+ });
});
diff --git a/packages/core/src/lint/rules/composition.ts b/packages/core/src/lint/rules/composition.ts
index cbfa2b310..702a081a4 100644
--- a/packages/core/src/lint/rules/composition.ts
+++ b/packages/core/src/lint/rules/composition.ts
@@ -106,4 +106,109 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
}
return findings;
},
+
+ // timed_element_missing_clip_class
+ ({ tags }) => {
+ const findings: HyperframeLintFinding[] = [];
+ const skipTags = new Set(["audio", "video", "script", "style", "template"]);
+ for (const tag of tags) {
+ if (skipTags.has(tag.name)) continue;
+ // Skip composition hosts
+ if (readAttr(tag.raw, "data-composition-id")) continue;
+ if (readAttr(tag.raw, "data-composition-src")) continue;
+
+ const hasStart = readAttr(tag.raw, "data-start") !== null;
+ const hasDuration = readAttr(tag.raw, "data-duration") !== null;
+ const hasTrackIndex = readAttr(tag.raw, "data-track-index") !== null;
+ if (!hasStart && !hasDuration && !hasTrackIndex) continue;
+
+ const classAttr = readAttr(tag.raw, "class") || "";
+ const hasClip = classAttr.split(/\s+/).includes("clip");
+ if (hasClip) continue;
+
+ const elementId = readAttr(tag.raw, "id") || undefined;
+ findings.push({
+ code: "timed_element_missing_clip_class",
+ severity: "warning",
+ message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has timing attributes but no class="clip". The element will be visible for the entire composition instead of only during its scheduled time range.`,
+ elementId,
+ fixHint:
+ 'Add class="clip" to the element. The HyperFrames runtime uses .clip to control visibility based on data-start/data-duration.',
+ snippet: truncateSnippet(tag.raw),
+ });
+ }
+ return findings;
+ },
+
+ // overlapping_clips_same_track
+ ({ tags }) => {
+ const findings: HyperframeLintFinding[] = [];
+
+ type ClipInfo = { start: number; end: number; elementId?: string; snippet: string };
+ const trackMap = new Map();
+
+ for (const tag of tags) {
+ const startStr = readAttr(tag.raw, "data-start");
+ const durationStr = readAttr(tag.raw, "data-duration");
+ const trackStr = readAttr(tag.raw, "data-track-index");
+ if (!startStr || !durationStr || !trackStr) continue;
+
+ const start = Number(startStr);
+ const duration = Number(durationStr);
+ const track = trackStr;
+
+ // Skip non-numeric (relative timing references like "intro-comp")
+ if (Number.isNaN(start) || Number.isNaN(duration)) continue;
+
+ const clips = trackMap.get(track) || [];
+ clips.push({
+ start,
+ end: start + duration,
+ elementId: readAttr(tag.raw, "id") || undefined,
+ snippet: truncateSnippet(tag.raw) || "",
+ });
+ trackMap.set(track, clips);
+ }
+
+ for (const [track, clips] of trackMap) {
+ clips.sort((a, b) => a.start - b.start);
+ for (let i = 0; i < clips.length - 1; i++) {
+ const current = clips[i];
+ const next = clips[i + 1];
+ if (!current || !next) continue;
+ if (current.end > next.start) {
+ findings.push({
+ code: "overlapping_clips_same_track",
+ severity: "error",
+ message: `Track ${track}: clip ending at ${current.end}s overlaps with clip starting at ${next.start}s. Overlapping clips on the same track cause rendering conflicts.`,
+ fixHint:
+ "Adjust data-start or data-duration so clips on the same track do not overlap, or move one clip to a different data-track-index.",
+ });
+ }
+ }
+ }
+
+ return findings;
+ },
+
+ // requestanimationframe_in_composition
+ ({ scripts }) => {
+ const findings: HyperframeLintFinding[] = [];
+ for (const script of scripts) {
+ // Strip comments to avoid false positives
+ const stripped = script.content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
+ if (/requestAnimationFrame\s*\(/.test(stripped)) {
+ findings.push({
+ code: "requestanimationframe_in_composition",
+ severity: "warning",
+ message:
+ "`requestAnimationFrame` runs on wall-clock time, not the GSAP timeline. It will not sync with frame capture and may cause flickering or missed frames during rendering.",
+ fixHint:
+ "Use GSAP tweens or onUpdate callbacks instead of requestAnimationFrame for animation logic.",
+ snippet: truncateSnippet(script.content),
+ });
+ }
+ }
+ return findings;
+ },
];
diff --git a/packages/core/src/lint/rules/core.test.ts b/packages/core/src/lint/rules/core.test.ts
index 7f0cb9899..6c0d0f26a 100644
--- a/packages/core/src/lint/rules/core.test.ts
+++ b/packages/core/src/lint/rules/core.test.ts
@@ -91,4 +91,56 @@ describe("core rules", () => {
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeUndefined();
});
+
+ describe("non_deterministic_code", () => {
+ it("detects Math.random() in script content", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "non_deterministic_code");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ expect(finding?.message).toContain("Math.random");
+ });
+
+ it("detects Date.now() in script content", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "non_deterministic_code");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ expect(finding?.message).toContain("Date.now");
+ });
+
+ it("does not flag non-deterministic calls inside single-line comments", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "non_deterministic_code");
+ expect(finding).toBeUndefined();
+ });
+ });
});
diff --git a/packages/core/src/lint/rules/core.ts b/packages/core/src/lint/rules/core.ts
index 4c7d9510f..dd5a2300c 100644
--- a/packages/core/src/lint/rules/core.ts
+++ b/packages/core/src/lint/rules/core.ts
@@ -166,4 +166,53 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
}
return findings;
},
+
+ // non_deterministic_code
+ ({ scripts }) => {
+ const findings: HyperframeLintFinding[] = [];
+ const patterns: Array<{ pattern: RegExp; label: string; hint: string }> = [
+ {
+ pattern: /Math\.random\s*\(/,
+ label: "Math.random()",
+ hint: "Use a seeded PRNG (e.g. a simple mulberry32) so renders are deterministic across frames.",
+ },
+ {
+ pattern: /Date\.now\s*\(/,
+ label: "Date.now()",
+ hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
+ },
+ {
+ pattern: /new\s+Date\s*\(/,
+ label: "new Date()",
+ hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
+ },
+ {
+ pattern: /performance\.now\s*\(/,
+ label: "performance.now()",
+ hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
+ },
+ {
+ pattern: /crypto\.getRandomValues\s*\(/,
+ label: "crypto.getRandomValues()",
+ hint: "Remove time-dependent code. Use a seeded PRNG for deterministic renders.",
+ },
+ ];
+
+ for (const script of scripts) {
+ // Strip comments to avoid false positives
+ const stripped = script.content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
+ for (const { pattern, label, hint } of patterns) {
+ if (pattern.test(stripped)) {
+ findings.push({
+ code: "non_deterministic_code",
+ severity: "error",
+ message: `Script contains \`${label}\` which produces non-deterministic output. Renders may differ between frames or runs.`,
+ fixHint: hint,
+ snippet: truncateSnippet(script.content),
+ });
+ }
+ }
+ }
+ return findings;
+ },
];