refactor(lint): drop seven rules that fire on correct compositions (#3366)

Each rule below either reports a hazard the compiler or runtime already
prevents, duplicates another rule's invariant with a weaker detector, or
cannot be cleared by its own fixHint. Measured over the 643 shipped
registry HTML files, this cuts lint output from 1740 findings to 507
(-70.9%) and removes 40 errors, with no new codes introduced.

- scene_layer_missing_visibility_kill: regex heuristic keyed on `#sceneN`
  ids. It only accepts the literal string `visibility: "hidden"`, so the
  canonical GSAP hard kill (`tl.set(el, { autoAlpha: 0 })`, which sets
  visibility hidden at runtime) never clears it — an unfixable error. It
  also matched the `0` inside `opacity: 0.5` and treated `.from({opacity:
  0})` entrances as exits. gsap_exit_missing_hard_kill owns this invariant
  using parsed tween timing and real clip boundaries, and accepts every
  hidden encoding.
- unscoped_gsap_selector: wrapScopedCompositionScript already rewrites
  string GSAP targets to the composition root for every sub-composition
  script (pinned by compositionScoping.test.ts "executes document and GSAP
  selectors inside the composition root"). The rule also never fired on a
  standalone sub-composition file or a <template> sub-comp.
- caption_transcript_parse_error: required the inline TRANSCRIPT array to
  be strict JSON so Studio could read it, but Studio's parseTranscriptArray
  already normalizes unquoted keys, single quotes, and trailing commas. It
  errored on ten shipped caption components whose transcripts Studio parses.
- composition_self_attribute_selector: warned that
  `[data-composition-id="x"] .y` leaks across instances, but
  scopeCssToComposition rewrites that selector to each instance's runtime
  scope. It was also the pattern the rest of the toolchain prescribes.
- timed_element_missing_visibility_hidden: strict subset of
  timed_element_missing_clip_class, which reports the same condition as an
  error, so it only ever added a second line saying the same thing.
- pointer_events_none: Studio selection ergonomics only, no render impact,
  on 124 of 211 shipped blocks.
- google_fonts_import: the producer resolves Google Fonts during
  compile/render, as the message itself said.

system_font_will_alias is narrowed to distributed/Lambda renders, where
system-font capture is off and the fallback is a real defect. Under a local
render the substitution is the renderer working as designed, so the info
tier is gone.

The three tests that used composition_self_attribute_selector as a probe
for "this style source was collected" now use scoped_css_missing_wrapper,
which still fires once per source.
This commit is contained in:
Miguel Ángel
2026-08-20 18:10:24 -04:00
committed by GitHub
parent d1482b0129
commit 83ceaeb902
10 changed files with 56 additions and 518 deletions
+10 -6
View File
@@ -142,17 +142,19 @@ describe("lintProject", () => {
});
writeFileSync(
join(project, "compositions", "scene.css"),
'[data-composition-id="scene"] .title { opacity: 0; }',
'[data-composition-id="no-such-comp"] .title { opacity: 0; }',
);
const { results } = await lintProject(project);
const subResult = results.find((result) => result.file === "compositions/scene.html");
// The linked stylesheet scopes CSS to a composition id that has no wrapper
// here, so this finding can only come from the linked file being read.
const finding = subResult?.result.findings.find(
(item) => item.code === "composition_self_attribute_selector",
(item) => item.code === "scoped_css_missing_wrapper",
);
expect(finding).toBeDefined();
expect(finding?.selector).toBe('[data-composition-id="scene"] .title');
expect(finding?.selector).toBe('[data-composition-id="no-such-comp"]');
});
it("lints percent-encoded linked CSS filenames that exist decoded on disk", async () => {
@@ -165,17 +167,19 @@ describe("lintProject", () => {
});
writeFileSync(
join(project, "compositions", decodeURIComponent(encodedFilename)),
'[data-composition-id="scene"] .title { opacity: 0; }',
'[data-composition-id="no-such-comp"] .title { opacity: 0; }',
);
const { results } = await lintProject(project);
const subResult = results.find((result) => result.file === "compositions/scene.html");
// The linked stylesheet scopes CSS to a composition id that has no wrapper
// here, so this finding can only come from the linked file being read.
const finding = subResult?.result.findings.find(
(item) => item.code === "composition_self_attribute_selector",
(item) => item.code === "scoped_css_missing_wrapper",
);
expect(finding).toBeDefined();
expect(finding?.selector).toBe('[data-composition-id="scene"] .title');
expect(finding?.selector).toBe('[data-composition-id="no-such-comp"]');
});
it("aggregates errors across index.html and sub-compositions", async () => {
+14 -5
View File
@@ -245,22 +245,31 @@ describe("template shell style sources", () => {
<div id="scene" data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
<template data-composition-id="shell">
<link rel="stylesheet" href="shell.css">
<style>[data-composition-id="main"] .title { opacity: 0; }</style>
<style>[data-composition-id="from-style-block"] .title { opacity: 0; }</style>
<div style="mask-image: url(missing-inline-mask.png)"></div>
<template><style>[data-composition-id="main"] .nested { opacity: 0; }</style></template>
<template><style>[data-composition-id="from-nested-template"] .nested { opacity: 0; }</style></template>
</template>
<script>window.__timelines = {};</script>
</body></html>`);
writeFileSync(
join(project, "shell.css"),
'[data-composition-id="main"] .from-link { opacity: 0; }',
'[data-composition-id="from-link"] .from-link { opacity: 0; }',
);
const { results } = await lintProject(project);
const findings = results.flatMap((entry) => entry.result.findings);
// Each style source scopes CSS to a composition id that has no wrapper, so
// one scoped_css_missing_wrapper per source proves all three were collected.
expect(
findings.filter((finding) => finding.code === "composition_self_attribute_selector"),
).toHaveLength(3);
findings
.filter((finding) => finding.code === "scoped_css_missing_wrapper")
.map((finding) => finding.selector)
.sort(),
).toEqual([
'[data-composition-id="from-link"]',
'[data-composition-id="from-nested-template"]',
'[data-composition-id="from-style-block"]',
]);
expect(findings.some((finding) => finding.code === "texture_mask_asset_not_found")).toBe(true);
});
});
-52
View File
@@ -1,33 +1,5 @@
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, styles, options, rootCompositionId }) => {
@@ -122,30 +94,6 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
});
}
if (hasInlineTranscript) {
// 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(transcriptJson);
} catch {
findings.push({
code: "caption_transcript_parse_error",
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.",
fixHint:
'Use JSON-quoted keys: { "text": "don\'t", "start": 0, "end": 1 } instead of ' +
'{ text: "don\'t", start: 0, end: 1 }.',
});
}
}
}
return findings;
},
-30
View File
@@ -416,36 +416,6 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},
// timed_element_missing_visibility_hidden
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
if (!readAttr(tag.raw, "data-start")) continue;
if (readDecodedAttr(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) || /opacity\s*:\s*0/i.test(styleAttr);
if (!hasClip && !hasHiddenStyle) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "timed_element_missing_visibility_hidden",
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" (with CSS: .clip { visibility: hidden; }) or style="opacity:0" if the element should start hidden.',
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
// deprecated_data_layer + deprecated_data_end
// fallow-ignore-next-line complexity
({ tags }) => {
-60
View File
@@ -1096,64 +1096,4 @@ body {
expect(finding).toBeUndefined();
});
});
describe("composition_self_attribute_selector", () => {
it("warns when inline CSS targets the root composition id", async () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080">
<style>
[data-composition-id="scene"] .title { opacity: 0; }
[data-composition-id="other"] .title { color: red; }
</style>
<h1 class="title">Hello</h1>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const findings = result.findings.filter(
(f) => f.code === "composition_self_attribute_selector",
);
expect(findings).toHaveLength(1);
expect(findings[0]?.severity).toBe("warning");
expect(findings[0]?.selector).toBe('[data-composition-id="scene"] .title');
expect(findings[0]?.fixHint).toContain("#scene");
expect(findings[0]?.fixHint).not.toContain("#556");
});
it("warns when external CSS targets the root composition id", async () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html, {
externalStyles: [
{
href: "scene.css",
content: '[data-composition-id="scene"] .title { opacity: 0; }',
},
],
});
const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector");
expect(finding).toBeDefined();
expect(finding?.selector).toBe('[data-composition-id="scene"] .title');
});
it("does not warn when CSS targets a different composition id", async () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080">
<style>[data-composition-id="other"] .title { opacity: 0; }</style>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector");
expect(finding).toBeUndefined();
});
});
});
-98
View File
@@ -15,17 +15,6 @@ import {
INVALID_SCRIPT_CLOSE_PATTERN,
} from "../utils";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function selectorTargetsCompositionId(selector: string, compositionId: string): boolean {
const escaped = escapeRegExp(compositionId);
return new RegExp(
String.raw`\[\s*data-composition-id\s*=\s*(?:"${escaped}"|'${escaped}')\s*\]`,
).test(selector);
}
function repeatedDescendantId(selector: string): string | null {
let repeated: string | null = null;
@@ -512,40 +501,6 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
return findings;
},
// composition_self_attribute_selector
({ styles, rootCompositionId, rootTag }) => {
const findings: HyperframeLintFinding[] = [];
if (!rootCompositionId) return findings;
const seenSelectors = new Set<string>();
const rootId = readAttr(rootTag?.raw || "", "id");
for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
root.walkRules((rule) => {
for (const selector of rule.selectors) {
if (!selectorTargetsCompositionId(selector, rootCompositionId)) continue;
if (seenSelectors.has(selector)) continue;
seenSelectors.add(selector);
findings.push({
code: "composition_self_attribute_selector",
severity: "warning",
message:
"Selector matches the block's own id; will leak to sibling instances when the block is embedded twice.",
selector,
fixHint: rootId
? `Use #${rootId} for clearer authoring intent and instance-isolated styling.`
: "Add a stable id to the composition root and use that id selector for clearer authoring intent and instance-isolated styling.",
});
}
});
}
return findings;
},
// studio_missing_editable_id
({ tags, rootTag }) => {
const findings: HyperframeLintFinding[] = [];
@@ -628,57 +583,4 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
}
return findings;
},
// pointer_events_none
// fallow-ignore-next-line complexity
({ tags, styles }) => {
const findings: HyperframeLintFinding[] = [];
const reported = new Set<string>();
for (const tag of tags) {
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) continue;
const inlineStyle = readAttr(tag.raw, "style") ?? "";
if (!/pointer-events\s*:\s*none/i.test(inlineStyle)) continue;
const id = readAttr(tag.raw, "id");
const key = id ?? tag.raw;
if (reported.has(key)) continue;
reported.add(key);
findings.push({
code: "pointer_events_none",
severity: "info",
message: `<${tag.name}${id ? ` id="${id}"` : ""}> has \`pointer-events: none\` in its inline style. Elements with this property are harder to select in the Studio preview.`,
elementId: id || undefined,
fixHint:
"If this element should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
snippet: truncateSnippet(tag.raw),
});
}
for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
root.walkDecls("pointer-events", (decl) => {
if (decl.value.trim().toLowerCase() !== "none") return;
const rule = decl.parent;
if (!rule || rule.type !== "rule") return;
const selector = (rule as postcss.Rule).selector;
if (reported.has(selector)) return;
reported.add(selector);
findings.push({
code: "pointer_events_none",
severity: "info",
message: `\`${selector}\` sets \`pointer-events: none\`. Elements matching this selector are harder to select in the Studio preview.`,
selector,
fixHint:
"If these elements should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
});
});
}
return findings;
},
];
+22 -52
View File
@@ -6,55 +6,21 @@ async function findByCode(html: string, code: string, isSubComposition = true) {
return result.findings.filter((f) => f.code === code);
}
/** system_font_will_alias only applies to distributed / Lambda renders. */
async function findAliasFindings(html: string) {
const result = await lintHyperframeHtml(html, { isSubComposition: true, distributed: true });
return result.findings.filter((f) => f.code === "system_font_will_alias");
}
describe("font rules", () => {
describe("google_fonts_import", () => {
it("warns on @import url with fonts.googleapis.com without failing lint", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap');</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const findings = result.findings.filter((f) => f.code === "google_fonts_import");
expect(findings).toHaveLength(1);
expect(findings[0]!.severity).toBe("warning");
expect(result.errorCount).toBe(0);
});
it("warns on <link> to fonts.googleapis.com", async () => {
const html = `<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(1);
expect(findings[0]!.severity).toBe("warning");
});
it("does not flag local @font-face usage", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>@font-face { font-family: 'Inter'; src: url('../capture/assets/fonts/Inter.woff2'); }</style>
</div>`;
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", () => {
it("flags SF Mono as aliased to JetBrains Mono", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>code { font-family: 'SF Mono', monospace; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
const findings = await findAliasFindings(html);
expect(findings).toHaveLength(1);
expect(findings[0]!.severity).toBe("info");
expect(findings[0]!.severity).toBe("warning");
expect(findings[0]!.message).toContain("JetBrains Mono");
});
@@ -62,7 +28,7 @@ describe("font rules", () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Helvetica Neue', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
const findings = await findAliasFindings(html);
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("Inter");
});
@@ -71,7 +37,7 @@ describe("font rules", () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Inter', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
const findings = await findAliasFindings(html);
expect(findings).toHaveLength(0);
});
@@ -79,7 +45,7 @@ describe("font rules", () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Roboto', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
const findings = await findAliasFindings(html);
expect(findings).toHaveLength(0);
});
@@ -87,7 +53,7 @@ describe("font rules", () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Comic Sans MS', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
const findings = await findAliasFindings(html);
expect(findings).toHaveLength(0);
});
@@ -98,7 +64,7 @@ describe("font rules", () => {
code { font-family: 'Menlo', monospace; }
</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
const findings = await findAliasFindings(html);
expect(findings).toHaveLength(0);
});
@@ -106,7 +72,7 @@ describe("font rules", () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'VERDANA', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
const findings = await findAliasFindings(html);
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("Inter");
});
@@ -118,11 +84,18 @@ describe("font rules", () => {
code { font-family: 'Consolas', monospace; }
</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
const findings = await findAliasFindings(html);
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("Inter");
expect(findings[0]!.message).toContain("JetBrains Mono");
});
it("stays silent on a local render, where the renderer really does supply the alias", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>code { font-family: 'SF Mono', monospace; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(0);
});
});
describe("font_family_without_font_face", () => {
@@ -226,7 +199,6 @@ describe("font rules", () => {
<style>body { font-family: 'Geist', sans-serif; }</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(result.findings.filter((f) => f.code === "google_fonts_import")).toHaveLength(1);
expect(
result.findings.filter((f) => f.code === "font_family_without_font_face"),
).toHaveLength(0);
@@ -239,7 +211,6 @@ describe("font rules", () => {
<style>body { font-family: 'Geist', sans-serif; }</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(result.findings.filter((f) => f.code === "google_fonts_import")).toHaveLength(1);
expect(
result.findings.filter((f) => f.code === "font_family_without_font_face"),
).toHaveLength(0);
@@ -255,7 +226,6 @@ describe("font rules", () => {
</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(result.findings.filter((f) => f.code === "google_fonts_import")).toHaveLength(1);
expect(
result.findings.filter((f) => f.code === "font_family_without_font_face"),
).toHaveLength(0);
+9 -34
View File
@@ -163,50 +163,25 @@ function collectGoogleFontFamilies(
}
export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// google_fonts_import
({ 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) =>
/@import\s+url\s*\(\s*['"]?[^)]*fonts\.googleapis\.com/i.test(s.content),
);
if (googleFontsInLink || googleFontsInImport) {
findings.push({
code: "google_fonts_import",
severity: "warning",
message:
"Composition loads fonts from fonts.googleapis.com. The producer resolves Google Fonts " +
"during compile/render, but raw external font requests add latency and can fail before " +
"canonicalization. Prefer mapped family names or local @font-face declarations when possible.",
fixHint:
"For bundled fonts, remove the Google Fonts <link> or @import and keep the font-family " +
"declaration. For custom fonts, use @font-face { font-family: '...'; src: url('...woff2'); }.",
});
}
return findings;
},
// system_font_will_alias — inform when a font will be silently substituted
// system_font_will_alias — only for distributed / Lambda renders, where
// system-font capture is disabled and the alias substitution does NOT happen,
// so the font silently falls back to whatever the OS provides. Under a local
// render the substitution is the renderer working as designed, not a defect,
// so there is nothing for the author to act on.
({ styles, options }) => {
if (!options.distributed) return [];
const declared = extractFontFaceFamilies(styles);
const used = extractUsedFontFamilies(styles);
const aliased = collectAliasedFonts(used, declared);
if (aliased.length === 0) return [];
// In distributed / Lambda renders system-font capture is disabled, so
// the alias substitution does NOT happen — elevate to a warning.
const severity = options.distributed ? ("warning" as const) : ("info" as const);
return [
{
code: "system_font_will_alias",
severity,
severity: "warning",
message:
`Font ${aliased.length === 1 ? "family" : "families"} will be substituted at render time: ${aliased.join(", ")}. ` +
(options.distributed
? "In distributed/Lambda rendering system-font capture is disabled — these fonts will fall back to OS defaults. Embed explicit @font-face declarations instead."
: "The renderer maps these to bundled fonts for cross-platform consistency. " +
"Use the target font name directly for consistent preview and render results."),
"In distributed/Lambda rendering system-font capture is disabled — these fonts will fall " +
"back to OS defaults. Embed explicit @font-face declarations instead.",
},
];
},
-87
View File
@@ -1917,93 +1917,6 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
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 points at the inner-wrapper pattern when the scene element is a clip", async () => {
// Same contradiction as gsap_exit_missing_hard_kill above, via the older
// id-pattern-based rule: `tl.set("#scene1", { visibility: "hidden" }, ...)`
// on a class="clip" scene element is exactly what gsap_animates_clip_element
// then errors on.
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="scene1" class="clip"></div>
<div id="scene2" class="clip"></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?.fixHint).toContain("clip element");
expect(finding?.fixHint).toContain("inner");
expect(finding?.fixHint).not.toContain('tl.set("#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();
});
it("gsap_non_transform_motion: errors on layout-prop tweens (left/marginLeft) and roundProps", async () => {
const html = `
<html><body>
+1 -94
View File
@@ -88,18 +88,6 @@ function targetHasNoStableIdentity(selector: string, identity?: string): boolean
// ── GSAP parsing utilities ─────────────────────────────────────────────────
function countClassUsage(tags: OpenTag[]): Map<string, number> {
const counts = new Map<string, number>();
for (const tag of tags) {
const classAttr = readAttr(tag.raw, "class");
if (!classAttr) continue;
for (const className of classAttr.split(/\s+/).filter(Boolean)) {
counts.set(className, (counts.get(className) || 0) + 1);
}
}
return counts;
}
function readRegisteredTimelineCompositionId(script: string): string | null {
const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
return match?.[1] || match?.[2] || null;
@@ -383,18 +371,6 @@ function findMatchingSceneBoundary(time: number, boundaries: number[]): number |
return null;
}
function isSuspiciousGlobalSelector(selector: string): boolean {
if (!selector) return false;
if (selector.includes("[data-composition-id=")) return false;
if (selector.startsWith("#")) return false;
return selector.startsWith(".") || /^[a-z]/i.test(selector);
}
function getSingleClassSelector(selector: string): string | null {
const match = selector.trim().match(/^\.(?<name>[A-Za-z0-9_-]+)$/);
return match?.groups?.name || null;
}
function readStyleProperty(style: string, property: string): string | null {
const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = style.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i"));
@@ -1064,7 +1040,7 @@ function collectCssOpacityZeroSelectors(
// fallow-ignore-next-line complexity
export const gsapRules: LintRule<LintContext>[] = [
// overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
// overlapping_gsap_tweens + gsap_animates_clip_element
// fallow-ignore-next-line complexity
async ({ source, tags, scripts, styles, rootCompositionId }) => {
const findings: HyperframeLintFinding[] = [];
@@ -1088,7 +1064,6 @@ export const gsapRules: LintRule<LintContext>[] = [
}
}
const classUsage = countClassUsage(tags);
const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags);
const styleRules = collectSimpleStyleRules(styles);
const reportedVisibleOverlayKeys = new Set<string>();
@@ -1252,22 +1227,6 @@ export const gsapRules: LintRule<LintContext>[] = [
snippet: truncateSnippet(win.raw),
});
}
// unscoped_gsap_selector
if (!localTimelineCompId || localTimelineCompId === rootCompositionId) continue;
for (const win of gsapWindows) {
if (!isSuspiciousGlobalSelector(win.targetSelector)) continue;
const className = getSingleClassSelector(win.targetSelector);
if (className && (classUsage.get(className) || 0) < 2) continue;
findings.push({
code: "unscoped_gsap_selector",
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.`,
snippet: truncateSnippet(win.raw),
});
}
}
return findings;
},
@@ -1635,58 +1594,6 @@ export const gsapRules: LintRule<LintContext>[] = [
return findings;
},
// scene_layer_missing_visibility_kill
({ scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];
// Detect multi-scene compositions: multiple elements with "scene" in their id
const sceneElements = tags.filter((t) => {
const id = readAttr(t.raw, "id") || "";
return /^scene\d+$/i.test(id);
});
if (sceneElements.length < 2) return findings;
for (const script of scripts) {
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") || "";
// Check if this scene has exit tweens (opacity: 0)
const exitPattern = new RegExp(`["']#${id}["'][^)]*opacity\\s*:\\s*0`);
const hasExit = exitPattern.test(content);
if (!hasExit) continue;
// Check if there's a hard visibility kill
const killPattern = new RegExp(`["']#${id}["'][^)]*visibility\\s*:\\s*["']hidden["']`);
const hasKill = killPattern.test(content);
if (!hasKill) {
// A tl.set on "#id" is only safe advice when the scene element isn't
// itself a clip — otherwise gsap_animates_clip_element errors on that
// exact tl.set, since the framework already owns visibility/display on
// clip elements. Point at the inner-wrapper pattern instead.
const classes = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
const isClip = classes.includes("clip");
const fixHint = isClip
? `"#${id}" is a clip element — the framework already manages its visibility. ` +
"Wrap the scene's content in an inner non-clip <div>, move the exit tween and the hard kill " +
'(`tl.set("<inner-selector>", { visibility: "hidden" }, <exit-end-time>)`) onto that wrapper instead.'
: `Add \`tl.set("#${id}", { visibility: "hidden" }, <exit-end-time>)\` after the scene's exit tweens.`;
findings.push({
code: "scene_layer_missing_visibility_kill",
severity: "error",
elementId: id,
message:
`Scene layer "#${id}" exits via opacity tween but has no visibility: hidden hard kill. ` +
"When scrubbing or when tweens conflict, the scene may remain partially visible and overlap the next scene.",
fixHint,
});
}
}
}
return findings;
},
// gsap_timeline_not_registered
({ scripts, rawSource, options }) => {
const findings: HyperframeLintFinding[] = [];