diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index d3c0204cd1..41b59fb3a0 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -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 () => { diff --git a/packages/lint/src/project.test.ts b/packages/lint/src/project.test.ts index 67aa45b126..440974e09c 100644 --- a/packages/lint/src/project.test.ts +++ b/packages/lint/src/project.test.ts @@ -245,22 +245,31 @@ describe("template shell style sources", () => {
`); 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); }); }); diff --git a/packages/lint/src/rules/captions.ts b/packages/lint/src/rules/captions.ts index 21af66d0e7..410024257c 100644 --- a/packages/lint/src/rules/captions.ts +++ b/packages/lint/src/rules/captions.ts @@ -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; }, diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index cbc8799aa3..61a558ee9e 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -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 }) => { diff --git a/packages/lint/src/rules/core.test.ts b/packages/lint/src/rules/core.test.ts index bcfd5f94aa..604c0afc25 100644 --- a/packages/lint/src/rules/core.test.ts +++ b/packages/lint/src/rules/core.test.ts @@ -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 = ` - -
- -

Hello

-
- -`; - 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 = ` - -
- -`; - 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 = ` - -
- -
- -`; - const result = await lintHyperframeHtml(html); - const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector"); - - expect(finding).toBeUndefined(); - }); - }); }); diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts index 15bc8f8fbb..bb3eecaeff 100644 --- a/packages/lint/src/rules/core.ts +++ b/packages/lint/src/rules/core.ts @@ -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(); - 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(); - - 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; - }, ]; diff --git a/packages/lint/src/rules/fonts.test.ts b/packages/lint/src/rules/fonts.test.ts index 8facee61c9..6afa663bbe 100644 --- a/packages/lint/src/rules/fonts.test.ts +++ b/packages/lint/src/rules/fonts.test.ts @@ -6,55 +6,21 @@ async function findByCode(html: string, code: string, isSubComposition = true) { return result.findings.filter((f) => f.code === code); } -describe("font rules", () => { - describe("google_fonts_import", () => { - it("warns on @import url with fonts.googleapis.com without failing lint", async () => { - const html = `
- -
`; - 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 to fonts.googleapis.com", async () => { - const html = `
- -
`; - 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 = `
- -
`; - 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 = - `\n` + - `
- -
`; - const findings = await findByCode(html, "google_fonts_import"); - expect(findings).toHaveLength(0); - }); - }); +/** 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("system_font_will_alias", () => { it("flags SF Mono as aliased to JetBrains Mono", async () => { const html = `
`; - 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 = `
`; - 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 = `
`; - 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 = `
`; - 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 = `
`; - 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; } `; - 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 = `
`; - 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; } `; - 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 = `
+ +
`; + 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", () => { `; 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", () => { `; 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", () => { `; 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); diff --git a/packages/lint/src/rules/fonts.ts b/packages/lint/src/rules/fonts.ts index cba97865f2..bede023cf8 100644 --- a/packages/lint/src/rules/fonts.ts +++ b/packages/lint/src/rules/fonts.ts @@ -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 = /]*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 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.", }, ]; }, diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 1ef53fa7b8..b39ca5d2fc 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -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 = ` - -
-
-
-
- -`; - 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 = ` - -
-
-
-
- -`; - 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 = ` - -
-
-
-
- -`; - 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 = ` - -
-
-
-
- -`; - 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 = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 92290d9cf8..05a56934a6 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -88,18 +88,6 @@ function targetHasNoStableIdentity(selector: string, identity?: string): boolean // ── GSAP parsing utilities ───────────────────────────────────────────────── -function countClassUsage(tags: OpenTag[]): Map { - const counts = new Map(); - 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(/^\.(?[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[] = [ - // 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[] = [ } } - const classUsage = countClassUsage(tags); const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags); const styleRules = collectSimpleStyleRules(styles); const reportedVisibleOverlayKeys = new Set(); @@ -1252,22 +1227,6 @@ export const gsapRules: LintRule[] = [ 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[] = [ 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
, move the exit tween and the hard kill " + - '(`tl.set("", { visibility: "hidden" }, )`) onto that wrapper instead.' - : `Add \`tl.set("#${id}", { visibility: "hidden" }, )\` 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[] = [];