fix(lint): promote rules to errors with registry exemptions and false-positive fixes (#1495)

* fix(lint): promote rules to errors with registry exemptions and false-positive fixes

- Export isRegistrySourceFile/isRegistryInstalledFile from composition.ts
- Add registry exemptions to google_fonts_import and font_family_without_font_face
- Add registry exemption to requestanimationframe_in_composition
- Fix timed_element_missing_clip_class: data-track-index alone no longer triggers
- Fix caption_transcript_parse_error: balanced-bracket scanner replaces non-greedy regex
- Fix missing_timeline_registry: skips sub-compositions and template-wrapped files
- Fix scene_layer_missing_visibility_kill: strip JS comments before pattern matching
- Fix gsap_css_transform_conflict: exempt from() alongside fromTo()
- Fix gsap_from_opacity_noop: only fires when opacity value is actually 0
- Add regression test for data-track-index-only elements

* test(lint): add regression tests for false-positive fixes

Covers the 7 missing negative-case assertions flagged in PR review:
- registry marker suppresses google_fonts_import + font_family_without_font_face
- registry marker suppresses requestanimationframe_in_composition
- isSubComposition suppresses missing_timeline_registry
- scene_layer_missing_visibility_kill: fires, commented-kill fires, real kill suppresses
- gsap_css_transform_conflict: from() exempt alongside fromTo()
- gsap_from_opacity_noop: non-zero opacity (e.g. 0.5) is a valid reveal, not a noop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(examples): fix warm-grain template to pass promoted lint rules

- index.html: remove undeclared "Lexend" from font-family stack
- intro.html: replace Google Fonts @import with bundled Inter font
- captions.html: quote TRANSCRIPT keys for valid JSON + use Inter font

Fixes CLI smoke CI failure after google_fonts_import, font_family_without_font_face,
and caption_transcript_parse_error were promoted from warning to error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(cli): resolve warm-grain from repo registry in dev mode + bundle at build

getStaticTemplateDir now falls back to registry/examples/<id> in dev mode
so CI smoke tests use the PR-branch copy instead of fetching from main.
build-copy.mjs copies warm-grain to dist/templates/warm-grain at build time
so packed CLIs can scaffold it offline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(examples): remove trailing comma from warm-grain TRANSCRIPT array

JSON.parse rejects trailing commas (valid JS, invalid JSON).
caption_transcript_parse_error was still firing because of the comma
on the last entry after quoting all keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-16 21:13:00 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent cc7c206e9c
commit 386df23a74
17 changed files with 341 additions and 167 deletions
+7
View File
@@ -70,6 +70,13 @@ async function main() {
copyDir(join(CLI_ROOT, "src", "templates", tmpl), join(DIST, "templates", tmpl));
}
// Bundle warm-grain from the repo registry so the built CLI can scaffold it
// offline and CI smoke tests pick up PR-branch changes before merge to main.
const warmGrainSrc = join(REPO_ROOT, "registry", "examples", "warm-grain");
if (existsSync(warmGrainSrc)) {
copyDir(warmGrainSrc, join(DIST, "templates", "warm-grain"));
}
// Skills bundled into the published CLI. Branches don't all carry the same
// skills/ tree (it gets restructured), so each entry is existsSync-guarded:
// a missing skill dir warns + skips instead of crashing the build.
+9 -3
View File
@@ -182,10 +182,16 @@ function resolveAssetDir(devSegments: string[], builtSegments: string[]): string
// Resolves bundled templates shipped inside the CLI package
// (packages/cli/src/templates/<id> in dev, dist/templates/<id> when packed).
// Not to be confused with the repo-root registry/examples/ directory, which
// is fetched remotely via fetchRemoteTemplate.
// Dev-mode also checks registry/examples/<id> so that smoke CI tests pick up
// PR-branch template changes before the PR is merged to main.
function getStaticTemplateDir(templateId: string): string {
return resolveAssetDir(["..", "templates", templateId], ["templates", templateId]);
const base = dirname(fileURLToPath(import.meta.url));
const devPath = resolve(base, "..", "templates", templateId);
if (existsSync(devPath)) return devPath;
// fallback: repo-root registry/examples/<id> (4 levels up from src/commands/)
const registryPath = resolve(base, "..", "..", "..", "..", "registry", "examples", templateId);
if (existsSync(registryPath)) return registryPath;
return resolve(base, "templates", templateId);
}
function getSharedTemplateDir(): string {
@@ -5,7 +5,7 @@ describe("lintHyperframeHtml — orchestrator", () => {
const validComposition = `
<html>
<body>
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080">
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080" data-start="0">
<div id="stage"></div>
</div>
<script src="https://cdn.gsap.com/gsap.min.js"></script>
@@ -23,7 +23,7 @@ describe("caption rules", () => {
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.severity).toBe("error");
});
it("does not warn when caption exit has hard kill tl.set", async () => {
@@ -141,6 +141,6 @@ describe("caption rules", () => {
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_container_relative_position");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.severity).toBe("error");
});
});
+42 -10
View File
@@ -1,5 +1,33 @@
import type { LintContext, HyperframeLintFinding } from "../context";
/** Extract a bracket-balanced array literal starting at the `[` found by `varMatch`. */
// fallow-ignore-next-line complexity
function extractArrayLiteral(src: string, varMatch: RegExpExecArray): string | null {
const openIdx = varMatch.index + varMatch[0].length - 1;
let depth = 0;
let inStr = false;
let strChar = "";
for (let i = openIdx; i < src.length; i++) {
const c = src[i]!;
if (inStr) {
if (c === "\\") {
i++;
continue;
}
if (c === strChar) inStr = false;
} else if (c === '"' || c === "'") {
inStr = true;
strChar = c;
} else if (c === "[") {
depth++;
} else if (c === "]") {
depth--;
if (depth === 0) return src.slice(openIdx, i + 1);
}
}
return null;
}
export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// caption_exit_missing_hard_kill
({ scripts }) => {
@@ -17,7 +45,7 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
if (hasCaptionLoop && hasExitTween && !hasHardKill) {
findings.push({
code: "caption_exit_missing_hard_kill",
severity: "warning",
severity: "error",
message:
"Caption exit animations (tl.to with opacity: 0) detected without a hard tl.set kill. " +
"Exit tweens can fail when karaoke word-level tweens conflict, leaving captions stuck on screen.",
@@ -57,6 +85,7 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
},
// caption_transcript_not_inline
// fallow-ignore-next-line complexity
({ scripts, styles, options }) => {
const findings: HyperframeLintFinding[] = [];
// Only check files that look like caption compositions
@@ -74,7 +103,7 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
if (!hasInlineTranscript && hasFetchTranscript) {
findings.push({
code: "caption_transcript_not_inline",
severity: "warning",
severity: "error",
message:
"Captions composition loads transcript via fetch(). The studio caption editor " +
"requires an inline `var TRANSCRIPT = [...]` array to detect and edit captions.",
@@ -85,16 +114,18 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
}
if (hasInlineTranscript) {
// Verify the inline transcript can be parsed
const varPattern = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*(\[[\s\S]*?\]);/;
const match = allScript.match(varPattern);
if (match?.[1]) {
// Verify the inline transcript can be parsed.
// Use a balanced-bracket scan instead of a regex to correctly handle
// nested arrays (e.g. word-level timing arrays inside each entry).
const varStart = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.exec(allScript);
const transcriptJson = varStart ? extractArrayLiteral(allScript, varStart) : null;
if (transcriptJson) {
try {
JSON.parse(match[1]);
JSON.parse(transcriptJson);
} catch {
findings.push({
code: "caption_transcript_parse_error",
severity: "warning",
severity: "error",
message:
"Inline TRANSCRIPT array is not valid JSON. The studio caption editor may fail " +
"to parse it. Common cause: unquoted property keys with apostrophes in text.",
@@ -121,7 +152,7 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
if (/position\s*:\s*relative/i.test(body)) {
findings.push({
code: "caption_container_relative_position",
severity: "warning",
severity: "error",
selector: (selector ?? "").trim(),
message: `Caption selector "${(selector ?? "").trim()}" uses position: relative which causes overflow and breaks caption stacking.`,
fixHint: "Use position: absolute for all caption elements.",
@@ -149,7 +180,7 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
if (/overflow\s*:\s*hidden/i.test(body)) {
findings.push({
code: "caption_overflow_clips_scaled_words",
severity: "warning",
severity: "error",
selector: (selector ?? "").trim(),
message: `"${(selector ?? "").trim()}" has overflow: hidden but GSAP scales caption words above 1.0x. Scaled emphasis words and their glow effects will be clipped.`,
fixHint:
@@ -192,6 +223,7 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
},
// caption_fittext_scale_mismatch
// fallow-ignore-next-line complexity
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
@@ -189,44 +189,6 @@ describe("composition rules", () => {
});
});
it("reports info for composition with external CDN script dependency", async () => {
const html = `<template id="rockets-template">
<div data-composition-id="rockets" data-width="1920" data-height="1080">
<div id="rocket-container"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["rockets"] = gsap.timeline({ paused: true });
</script>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { filePath: "compositions/rockets.html" });
const finding = result.findings.find(
(f) => f.code === "external_script_dependency" && f.message.includes("cdnjs.cloudflare.com"),
);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("info");
// info findings do not count as errors — ok should still be true
expect(result.ok).toBe(true);
expect(result.errorCount).toBe(0);
});
it("does not report external_script_dependency for inline scripts", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<script>
window.__timelines = {};
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "external_script_dependency")).toBeUndefined();
});
it("reports error when querySelector uses template literal variable", async () => {
const html = `
<html><body>
@@ -320,7 +282,7 @@ describe("composition rules", () => {
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.severity).toBe("error");
});
it("does not flag element that has class='clip'", async () => {
@@ -350,6 +312,24 @@ describe("composition rules", () => {
window.__timelines = window.__timelines || {};
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
expect(finding).toBeUndefined();
});
it("does not flag element with only data-track-index (layer container, no timing)", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="layer" data-track-index="0">
<div id="box" class="clip" data-start="0" data-duration="2">Hello</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
@@ -534,7 +514,7 @@ describe("composition rules", () => {
(f) => f.code === "standalone_composition_wrapped_in_template",
);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.severity).toBe("error");
});
it("does not flag sub-compositions in template", async () => {
@@ -570,7 +550,7 @@ describe("composition rules", () => {
(f) => f.code === "requestanimationframe_in_composition",
);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.severity).toBe("error");
});
it("does not flag requestAnimationFrame in comments", async () => {
@@ -582,6 +562,24 @@ describe("composition rules", () => {
// requestAnimationFrame(() => { });
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "requestanimationframe_in_composition",
);
expect(finding).toBeUndefined();
});
it("does not flag installed registry blocks that use rAF (e.g. particle effects)", async () => {
const html =
`<!-- hyperframes-registry-item: particles -->\n` +
`<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
requestAnimationFrame(function loop() { requestAnimationFrame(loop); });
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
@@ -672,7 +670,7 @@ describe("composition rules", () => {
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.severity).toBe("error");
});
it("warns when data-variable-values is a JSON array (must be an object)", async () => {
+25 -39
View File
@@ -20,14 +20,14 @@ function countStructuralLines(source: string): number {
return countPhysicalLines(source.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "<style></style>"));
}
function isRegistrySourceFile(filePath?: string): boolean {
export function isRegistrySourceFile(filePath?: string): boolean {
if (!filePath) return false;
const normalized = filePath.replace(/\\/g, "/");
return /(?:^|\/)registry\/blocks\/([^/]+)\/\1\.html$/i.test(normalized);
}
function isRegistryInstalledFile(rawSource: string): boolean {
export function isRegistryInstalledFile(rawSource: string): boolean {
return /^\s*<!--\s*hyperframes-registry-item:[^>]*-->/i.test(rawSource.slice(0, 512));
}
@@ -80,6 +80,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
},
// timeline_track_too_dense
// fallow-ignore-next-line complexity
({ tags, options }) => {
const trackCounts = new Map<string, number>();
for (const tag of tags) {
@@ -110,6 +111,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
},
// timed_element_missing_visibility_hidden
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
@@ -139,6 +141,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
},
// deprecated_data_layer + deprecated_data_end
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
@@ -146,7 +149,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "deprecated_data_layer",
severity: "warning",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-layer instead of data-track-index.`,
elementId,
fixHint: "Replace data-layer with data-track-index. The runtime reads data-track-index.",
@@ -157,7 +160,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "deprecated_data_end",
severity: "warning",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-end without data-duration. Use data-duration in source HTML.`,
elementId,
fixHint:
@@ -221,29 +224,8 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},
// external_script_dependency
({ source }) => {
const findings: HyperframeLintFinding[] = [];
const externalScriptRe = /<script\b[^>]*\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi;
let match: RegExpExecArray | null;
const seen = new Set<string>();
while ((match = externalScriptRe.exec(source)) !== null) {
const src = match[1] ?? "";
if (seen.has(src)) continue;
seen.add(src);
findings.push({
code: "external_script_dependency",
severity: "info",
message: `This composition loads an external script from \`${src}\`. The HyperFrames bundler automatically hoists CDN scripts from sub-compositions into the parent document. In unbundled runtime mode, \`loadExternalCompositions\` re-injects them. If you're using a custom pipeline that bypasses both, you'll need to include this script manually.`,
fixHint:
"No action needed when using `hyperframes preview` or `hyperframes render`. If using a custom pipeline, add this script tag to your root composition or HTML page.",
snippet: truncateSnippet(match[0] ?? ""),
});
}
return findings;
},
// timed_element_missing_clip_class
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
const skipTags = new Set(["audio", "video", "script", "style", "template"]);
@@ -255,8 +237,8 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
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;
// data-track-index alone marks a layer container, not a time-bounded clip
if (!hasStart && !hasDuration) continue;
const classAttr = readAttr(tag.raw, "class") || "";
const hasClip = classAttr.split(/\s+/).includes("clip");
@@ -265,7 +247,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "timed_element_missing_clip_class",
severity: "warning",
severity: "error",
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:
@@ -277,6 +259,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
},
// overlapping_clips_same_track
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
@@ -338,7 +321,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
if (!hasStart) {
findings.push({
code: "root_composition_missing_data_start",
severity: "warning",
severity: "error",
message: `Root composition "${compId}" is missing data-start. The runtime needs data-start="0" on the root element to begin playback.`,
fixHint: 'Add data-start="0" to the root composition element.',
snippet: truncateSnippet(rootTag.raw),
@@ -355,7 +338,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
if (trimmed.startsWith("<template")) {
findings.push({
code: "standalone_composition_wrapped_in_template",
severity: "warning",
severity: "error",
message:
"Root index.html is wrapped in a <template> tag. " +
"Only sub-compositions loaded via data-composition-src should use <template> wrappers. " +
@@ -394,14 +377,15 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
},
// requestanimationframe_in_composition
({ scripts }) => {
({ scripts, rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const stripped = stripJsComments(script.content);
if (/requestAnimationFrame\s*\(/.test(stripped)) {
findings.push({
code: "requestanimationframe_in_composition",
severity: "warning",
severity: "error",
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:
@@ -418,6 +402,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
// `data-variable-values`. The runtime swallows JSON errors silently and
// falls back to declared defaults, which masks typos. This rule surfaces
// the parse failure so authors notice before render time.
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
@@ -431,7 +416,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
const reason = err instanceof Error ? err.message : "unknown";
findings.push({
code: "invalid_variable_values_json",
severity: "warning",
severity: "error",
message: `data-variable-values is not valid JSON (${reason}).`,
fixHint:
'Wrap the attribute value in single quotes and the JSON keys/values in double quotes, e.g. data-variable-values=\'{"title":"Hello"}\'.',
@@ -444,7 +429,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
findings.push({
code: "invalid_variable_values_json",
severity: "warning",
severity: "error",
message:
'data-variable-values must be a JSON object keyed by variable id (e.g. {"title":"Hello"}).',
fixHint:
@@ -462,6 +447,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
// on any structural problem. Surface JSON / shape failures so authors
// catch them at lint time rather than wondering why their `getVariables()`
// defaults aren't applied.
// fallow-ignore-next-line complexity
({ source }) => {
const htmlTag = findHtmlTag(source);
if (!htmlTag) return [];
@@ -476,7 +462,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return [
{
code: "invalid_composition_variables_declaration",
severity: "warning",
severity: "error",
message: `data-composition-variables is not valid JSON (${reason}).`,
fixHint:
'Provide a JSON array of variable declarations: data-composition-variables=\'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
@@ -489,7 +475,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return [
{
code: "invalid_composition_variables_declaration",
severity: "warning",
severity: "error",
message: "data-composition-variables must be a JSON array of variable declarations.",
fixHint:
'Wrap declarations in [] and give each an id, type, label, and default: \'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
@@ -505,7 +491,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
findings.push({
code: "invalid_composition_variables_declaration",
severity: "warning",
severity: "error",
message: `data-composition-variables entry [${i}] must be an object with id, type, label, and default.`,
snippet: truncateSnippet(htmlTag.raw),
});
@@ -520,7 +506,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
if (missing.length > 0) {
findings.push({
code: "invalid_composition_variables_declaration",
severity: "warning",
severity: "error",
message: `data-composition-variables entry [${i}] is missing or has invalid: ${missing.join(", ")}. Type must be one of string, number, color, boolean, enum.`,
snippet: truncateSnippet(htmlTag.raw),
});
+13
View File
@@ -39,6 +39,19 @@ describe("core rules", () => {
expect(finding).toBeDefined();
});
it("does not flag missing_timeline_registry on a sub-composition (inherits from host)", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
const tl = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "missing_timeline_registry");
expect(finding).toBeUndefined();
});
it("reports error for composition host missing data-composition-id", async () => {
const html = `
<html><body>
+6 -1
View File
@@ -85,7 +85,11 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
},
// missing_timeline_registry + timeline_registry_missing_init
({ source }) => {
({ source, rawSource, options }) => {
// Sub-compositions inherit window.__timelines from the host composition
if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
return [];
}
const findings: HyperframeLintFinding[] = [];
if (
!TIMELINE_REGISTRY_INIT_PATTERN.test(source) &&
@@ -325,6 +329,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
},
// pointer_events_none
// fallow-ignore-next-line complexity
({ tags, styles }) => {
const findings: HyperframeLintFinding[] = [];
const reported = new Set<string>();
+21 -1
View File
@@ -14,7 +14,7 @@ describe("font rules", () => {
</div>`;
const findings = await findByCode(html, "google_fonts_import");
expect(findings).toHaveLength(1);
expect(findings[0]!.severity).toBe("warning");
expect(findings[0]!.severity).toBe("error");
});
it("flags <link> to fonts.googleapis.com", async () => {
@@ -32,6 +32,16 @@ describe("font rules", () => {
const findings = await findByCode(html, "google_fonts_import");
expect(findings).toHaveLength(0);
});
it("does not flag installed registry blocks that bundle Google Fonts", async () => {
const html =
`<!-- hyperframes-registry-item: my-block -->\n` +
`<div data-composition-id="test" data-width="1920" data-height="1080">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
</div>`;
const findings = await findByCode(html, "google_fonts_import");
expect(findings).toHaveLength(0);
});
});
describe("system_font_will_alias", () => {
@@ -195,5 +205,15 @@ describe("font rules", () => {
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("does not flag installed registry blocks that declare fonts via Google Fonts", async () => {
const html =
`<!-- hyperframes-registry-item: my-block -->\n` +
`<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Poppins', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
});
});
+7 -4
View File
@@ -1,5 +1,6 @@
import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "../../fonts/aliases";
import type { LintContext, HyperframeLintFinding } from "../context";
import { isRegistrySourceFile, isRegistryInstalledFile } from "./composition";
const GENERIC_FAMILIES = new Set([
"serif",
@@ -76,7 +77,8 @@ function collectAliasedFonts(used: string[], declared: Set<string>): string[] {
export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// google_fonts_import
({ styles, source }) => {
({ styles, source, rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const findings: HyperframeLintFinding[] = [];
const googleFontsInLink = /<link\b[^>]*fonts\.googleapis\.com[^>]*>/i.test(source);
const googleFontsInImport = styles.some((s) =>
@@ -86,7 +88,7 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
if (googleFontsInLink || googleFontsInImport) {
findings.push({
code: "google_fonts_import",
severity: "warning",
severity: "error",
message:
"Composition loads fonts from fonts.googleapis.com. External font requests " +
"fail in sandboxed/offline renders and add latency. Use local @font-face " +
@@ -123,7 +125,8 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
},
// font_family_without_font_face
({ styles }) => {
({ styles, rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const findings: HyperframeLintFinding[] = [];
const declared = extractFontFaceFamilies(styles);
const used = extractUsedFontFamilies(styles);
@@ -133,7 +136,7 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
findings.push({
code: "font_family_without_font_face",
severity: "warning",
severity: "error",
message:
`Font ${undeclared.length === 1 ? "family" : "families"} used without @font-face declaration: ${undeclared.join(", ")}. ` +
"These are not in the auto-resolved font list, so the renderer cannot supply them automatically. " +
+104 -4
View File
@@ -266,7 +266,7 @@ describe("GSAP rules", () => {
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#title");
expect(finding?.fixHint).toMatch(/fromTo/);
expect(finding?.fixHint).toMatch(/xPercent/);
@@ -291,7 +291,7 @@ describe("GSAP rules", () => {
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#hero");
});
@@ -337,6 +337,27 @@ describe("GSAP rules", () => {
expect(conflict).toBeUndefined();
});
it("does NOT warn when tl.from targets element WITH CSS transform (from() owns start values)", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="badge"></div>
</div>
<style>
#badge { position: absolute; left: 50%; transform: translateX(-50%); }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from("#badge", { xPercent: -50, x: -200, opacity: 0, duration: 0.4 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await 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", async () => {
const html = `
<html><body>
@@ -676,7 +697,7 @@ describe("GSAP rules", () => {
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_exit_missing_hard_kill");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#headline");
expect(finding?.message).toContain("3.00s");
});
@@ -867,6 +888,24 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
it("does NOT error when gsap.from({opacity: 0.5}) — non-zero opacity is a valid reveal", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="ghost" style="opacity: 0;">Hello</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from("#ghost", { opacity: 0.5, duration: 0.4 }, 0.1);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop");
expect(finding).toBeUndefined();
});
it("warns when gsap.timeline is created but not registered in __timelines", async () => {
const html = `
<html><body>
@@ -882,7 +921,7 @@ describe("GSAP rules", () => {
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_timeline_not_registered");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.severity).toBe("error");
});
it("does NOT warn when timeline is registered in __timelines", async () => {
@@ -1038,4 +1077,65 @@ describe("GSAP rules", () => {
expect(finding?.message).toContain('"#title"');
expect(finding?.message).toContain('"#sub"');
});
it("scene_layer_missing_visibility_kill: fires when multi-scene exit lacks hard kill", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="scene1"></div>
<div id="scene2"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#scene1", { opacity: 0, duration: 0.5 }, 2.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "scene_layer_missing_visibility_kill");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("scene1");
});
it("scene_layer_missing_visibility_kill: DOES fire when kill is only in a comment (stripJsComments guard)", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="scene1"></div>
<div id="scene2"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// tl.set("#scene1", { visibility: "hidden" }, 2.5);
tl.to("#scene1", { opacity: 0, duration: 0.5 }, 2.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "scene_layer_missing_visibility_kill");
expect(finding).toBeDefined();
});
it("scene_layer_missing_visibility_kill: does NOT fire when hard kill is present", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="scene1"></div>
<div id="scene2"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#scene1", { opacity: 0, duration: 0.5 }, 2.0);
tl.set("#scene1", { visibility: "hidden" }, 2.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "scene_layer_missing_visibility_kill");
expect(finding).toBeUndefined();
});
});
+11 -7
View File
@@ -402,7 +402,7 @@ export const gsapRules: LintRule<LintContext>[] = [
findings.push({
code: "gsap_exit_missing_hard_kill",
severity: "warning",
severity: "error",
message:
`GSAP exit on "${win.targetSelector}" ends at the ${boundary.toFixed(2)}s clip start boundary ` +
"without a matching tl.set hard kill. Non-linear seeking can land after the fade and leave stale visibility state.",
@@ -445,7 +445,7 @@ export const gsapRules: LintRule<LintContext>[] = [
if (className && (classUsage.get(className) || 0) < 2) continue;
findings.push({
code: "unscoped_gsap_selector",
severity: "warning",
severity: "error",
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.`,
@@ -510,7 +510,9 @@ export const gsapRules: LintRule<LintContext>[] = [
const conflicts = new Map<string, Conflict>();
for (const win of windows) {
if (win.method === "fromTo") continue;
// from() and fromTo() both supply explicit start values so GSAP owns
// the full transform from t=0, making the CSS conflict moot
if (win.method === "fromTo" || win.method === "from") continue;
const sel = win.targetSelector;
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
const translateProps = win.properties.filter((p) =>
@@ -542,7 +544,7 @@ export const gsapRules: LintRule<LintContext>[] = [
`the full transform state. tl.fromTo is exempt from this rule.`;
findings.push({
code: "gsap_css_transform_conflict",
severity: "warning",
severity: "error",
message:
`"${sel}" has CSS \`transform: ${cssTransform}\` and a GSAP tween animates ` +
`${propList}. GSAP will overwrite the full CSS transform, discarding any ` +
@@ -711,7 +713,7 @@ export const gsapRules: LintRule<LintContext>[] = [
if (sceneElements.length < 2) return findings;
for (const script of scripts) {
const content = script.content;
const content = stripJsComments(script.content);
// For each scene, check if there's a visibility:hidden set after exit tweens
for (const tag of sceneElements) {
const id = readAttr(tag.raw, "id") || "";
@@ -726,7 +728,7 @@ export const gsapRules: LintRule<LintContext>[] = [
if (!hasKill) {
findings.push({
code: "scene_layer_missing_visibility_kill",
severity: "warning",
severity: "error",
elementId: id,
message:
`Scene layer "#${id}" exits via opacity tween but has no visibility: hidden hard kill. ` +
@@ -752,7 +754,7 @@ export const gsapRules: LintRule<LintContext>[] = [
if (hasRegistration || canInheritFromHost) continue;
findings.push({
code: "gsap_timeline_not_registered",
severity: "warning",
severity: "error",
message:
"GSAP timeline is created but never registered in window.__timelines. " +
"The runtime discovers timelines from this registry — without registration, " +
@@ -800,6 +802,8 @@ export const gsapRules: LintRule<LintContext>[] = [
for (const win of windows) {
if (win.method !== "from") continue;
if (!win.properties.includes("opacity")) continue;
// Only a noop when the tween animates FROM 0 (same as the CSS value)
if (win.propertyValues["opacity"] !== 0) continue;
const sel = win.targetSelector;
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
if (!cssOpacityZeroSelectors.has(cssKey)) continue;
+4 -1
View File
@@ -76,6 +76,7 @@ function collectTextureCss(styles: LintContext["styles"]): {
}
roots.push(root);
// fallow-ignore-next-line complexity
root.walkRules((rule) => {
const selectors = rule.selectors ?? [];
let hasMaskImage = false;
@@ -97,6 +98,7 @@ function collectTextureCss(styles: LintContext["styles"]): {
}
for (const root of roots) {
// fallow-ignore-next-line complexity
root.walkRules((rule) => {
const selectors = rule.selectors ?? [];
let hasDropShadow = false;
@@ -126,6 +128,7 @@ function collectTextureCss(styles: LintContext["styles"]): {
return { definedTextureClasses, dropShadowRules };
}
// fallow-ignore-next-line complexity
export const textureRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
({ tags, styles }) => {
const findings: HyperframeLintFinding[] = [];
@@ -179,7 +182,7 @@ export const textureRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
if (definedTextureClasses.has(textureClass)) continue;
findings.push({
code: "texture_class_unknown",
severity: "warning",
severity: "error",
message: `Texture material class \`${textureClass}\` is not defined by local CSS.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
+47 -47
View File
@@ -37,7 +37,7 @@
[data-composition-id="captions"] .caption-text {
color: #f5f0e0;
font-family: "Outfit", sans-serif;
font-family: "Inter", sans-serif;
font-size: 48px;
font-weight: 700;
text-align: center;
@@ -52,52 +52,52 @@
<script>
(function () {
const script = [
{ text: "We", start: 0.119, end: 0.259 },
{ text: "asked", start: 0.319, end: 0.479 },
{ text: "what", start: 0.519, end: 0.659 },
{ text: "you", start: 0.699, end: 0.819 },
{ text: "needed.", start: 0.859, end: 1.819 },
{ text: "Forty-seven", start: 1.86, end: 2.299 },
{ text: "percent", start: 2.399, end: 2.679 },
{ text: "of", start: 2.7, end: 2.799 },
{ text: "you", start: 2.839, end: 2.939 },
{ text: "said", start: 3.039, end: 3.179 },
{ text: "motion", start: 3.24, end: 3.559 },
{ text: "graphics,", start: 3.579, end: 4.599 },
{ text: "sixty-two", start: 4.679, end: 5.179 },
{ text: "percent", start: 5.299, end: 5.759 },
{ text: "said", start: 5.859, end: 5.98 },
{ text: "static", start: 6.079, end: 6.399 },
{ text: "content", start: 6.46, end: 6.879 },
{ text: "was", start: 6.92, end: 7.079 },
{ text: "costing", start: 7.099, end: 7.48 },
{ text: "you", start: 7.5, end: 7.579 },
{ text: "attention,", start: 7.679, end: 8.659 },
{ text: "and", start: 8.699, end: 8.86 },
{ text: "three", start: 8.88, end: 9.06 },
{ text: "out", start: 9.079, end: 9.18 },
{ text: "of", start: 9.199, end: 9.34 },
{ text: "four", start: 9.38, end: 9.799 },
{ text: "said", start: 9.84, end: 10.0 },
{ text: "you", start: 10.019, end: 10.159 },
{ text: "know", start: 10.179, end: 10.36 },
{ text: "the", start: 10.38, end: 10.42 },
{ text: "look", start: 10.519, end: 10.699 },
{ text: "you", start: 10.739, end: 10.859 },
{ text: "want", start: 10.98, end: 11.34 },
{ text: "but", start: 11.359, end: 11.52 },
{ text: "don't", start: 11.56, end: 11.779 },
{ text: "have", start: 11.819, end: 11.94 },
{ text: "the", start: 11.96, end: 12.06 },
{ text: "editing", start: 12.079, end: 12.4 },
{ text: "skills", start: 12.52, end: 12.86 },
{ text: "to", start: 12.88, end: 13.0 },
{ text: "get", start: 13.019, end: 13.18 },
{ text: "there.", start: 13.22, end: 14.22 },
{ text: "So", start: 14.239, end: 14.399 },
{ text: "we", start: 14.42, end: 14.52 },
{ text: "built", start: 14.619, end: 14.88 },
{ text: "Hyperframes.", start: 15.079, end: 16.02 },
{ "text": "We", "start": 0.119, "end": 0.259 },
{ "text": "asked", "start": 0.319, "end": 0.479 },
{ "text": "what", "start": 0.519, "end": 0.659 },
{ "text": "you", "start": 0.699, "end": 0.819 },
{ "text": "needed.", "start": 0.859, "end": 1.819 },
{ "text": "Forty-seven", "start": 1.86, "end": 2.299 },
{ "text": "percent", "start": 2.399, "end": 2.679 },
{ "text": "of", "start": 2.7, "end": 2.799 },
{ "text": "you", "start": 2.839, "end": 2.939 },
{ "text": "said", "start": 3.039, "end": 3.179 },
{ "text": "motion", "start": 3.24, "end": 3.559 },
{ "text": "graphics,", "start": 3.579, "end": 4.599 },
{ "text": "sixty-two", "start": 4.679, "end": 5.179 },
{ "text": "percent", "start": 5.299, "end": 5.759 },
{ "text": "said", "start": 5.859, "end": 5.98 },
{ "text": "static", "start": 6.079, "end": 6.399 },
{ "text": "content", "start": 6.46, "end": 6.879 },
{ "text": "was", "start": 6.92, "end": 7.079 },
{ "text": "costing", "start": 7.099, "end": 7.48 },
{ "text": "you", "start": 7.5, "end": 7.579 },
{ "text": "attention,", "start": 7.679, "end": 8.659 },
{ "text": "and", "start": 8.699, "end": 8.86 },
{ "text": "three", "start": 8.88, "end": 9.06 },
{ "text": "out", "start": 9.079, "end": 9.18 },
{ "text": "of", "start": 9.199, "end": 9.34 },
{ "text": "four", "start": 9.38, "end": 9.799 },
{ "text": "said", "start": 9.84, "end": 10.0 },
{ "text": "you", "start": 10.019, "end": 10.159 },
{ "text": "know", "start": 10.179, "end": 10.36 },
{ "text": "the", "start": 10.38, "end": 10.42 },
{ "text": "look", "start": 10.519, "end": 10.699 },
{ "text": "you", "start": 10.739, "end": 10.859 },
{ "text": "want", "start": 10.98, "end": 11.34 },
{ "text": "but", "start": 11.359, "end": 11.52 },
{ "text": "don't", "start": 11.56, "end": 11.779 },
{ "text": "have", "start": 11.819, "end": 11.94 },
{ "text": "the", "start": 11.96, "end": 12.06 },
{ "text": "editing", "start": 12.079, "end": 12.4 },
{ "text": "skills", "start": 12.52, "end": 12.86 },
{ "text": "to", "start": 12.88, "end": 13.0 },
{ "text": "get", "start": 13.019, "end": 13.18 },
{ "text": "there.", "start": 13.22, "end": 14.22 },
{ "text": "So", "start": 14.239, "end": 14.399 },
{ "text": "we", "start": 14.42, "end": 14.52 },
{ "text": "built", "start": 14.619, "end": 14.88 },
{ "text": "Hyperframes.", "start": 15.079, "end": 16.02 }
];
// Group words into lines (max 5 words per line)
+1 -4
View File
@@ -8,9 +8,6 @@
</div>
<style>
/* Import a rounded humanist sans-serif font */
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@400;600&display=swap");
[data-composition-id="intro"] .container {
width: 100%;
height: 100%;
@@ -18,7 +15,7 @@
justify-content: flex-start; /* Align to left for speaker card */
align-items: center;
padding-left: 5%;
font-family: "Outfit", sans-serif;
font-family: "Inter", sans-serif;
background: transparent;
}
+1 -1
View File
@@ -14,7 +14,7 @@
height: 1080px;
overflow: hidden;
background-color: #f5f0e0; /* Cream background */
font-family: "Outfit", "Lexend", sans-serif;
font-family: "Outfit", sans-serif;
}
#main-composition {