diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts index 2a44596ec..6e042c80b 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -1688,4 +1688,211 @@ describe("composition rules", () => { expect(find(result.findings)).toBeUndefined(); }); }); + + // composition_heavy_overlay_count_high — field signal ts=1784040753. + // See rule comments in ../rules/composition.ts for the black-frame repro + // story. Threshold: WARN at 25+ elements with filter:blur / clip-path + // (non-none) / radial-gradient. Presence-based: opacity:0 and + // visibility:hidden are counted-in, display:none is counted-out. + describe("composition_heavy_overlay_count_high", () => { + const wrap = (bodyInner: string, headInner = ""): string => + `${headInner} +
+ ${bodyInner} +
+ `; + + const repeat = (n: number, template: (i: number) => string): string => + Array.from({ length: n }, (_, i) => template(i)).join("\n"); + + it("warns when a composition has 40 blur-filtered overlays", async () => { + const overlays = repeat( + 40, + (i) => `
`, + ); + const result = await lintHyperframeHtml(wrap(overlays)); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + expect(finding?.message).toMatch(/40 elements/); + expect(finding?.message).toMatch(/filter:blur/); + expect(finding?.fixHint).toMatch(/ts=1784040753/); + }); + + it("warns when 30 elements share a clip-path class defined in a `; + const overlays = repeat(30, (i) => `
`); + const result = await lintHyperframeHtml(wrap(overlays, head)); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeDefined(); + expect(finding?.message).toMatch(/30 elements/); + }); + + it("warns when 25 mixed heavy overlays are present (blur + clip-path + radial-gradient)", async () => { + const head = ``; + const blur = repeat(9, (i) => `
`); + const clip = repeat(8, (i) => `
`); + const radial = repeat( + 8, + (i) => `
`, + ); + const result = await lintHyperframeHtml(wrap(blur + clip + radial, head)); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeDefined(); + expect(finding?.message).toMatch(/25 elements/); + }); + + it("does not warn when only 5 blur overlays are present (well below threshold)", async () => { + const overlays = repeat(5, (i) => `
`); + const result = await lintHyperframeHtml(wrap(overlays)); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeUndefined(); + }); + + it("does not warn on 40 plain non-overlay divs (no heavy CSS anywhere)", async () => { + const overlays = repeat(40, (i) => `
plain ${i}
`); + const result = await lintHyperframeHtml(wrap(overlays)); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeUndefined(); + }); + + it("counts opacity:0 blur overlays IN (presence alone matters per field signal)", async () => { + const overlays = repeat( + 30, + (i) => `
`, + ); + const result = await lintHyperframeHtml(wrap(overlays)); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeDefined(); + expect(finding?.message).toMatch(/30 elements/); + }); + + it("counts visibility:hidden blur overlays IN (presence alone matters)", async () => { + const overlays = repeat( + 30, + (i) => ``, + ); + const result = await lintHyperframeHtml(wrap(overlays)); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeDefined(); + }); + + it("counts display:none blur overlays OUT (element removed from render tree)", async () => { + const overlays = repeat( + 40, + (i) => ``, + ); + const result = await lintHyperframeHtml(wrap(overlays)); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeUndefined(); + }); + + it("does not warn on registry source files (block library authoring surface)", async () => { + const overlays = repeat(40, (i) => `
`); + const result = await lintHyperframeHtml(wrap(overlays), { + filePath: "/project/registry/blocks/blur-hero/blur-hero.html", + }); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeUndefined(); + }); + + it("does not warn on registry-installed block files (`hyperframes-registry-item` marker)", async () => { + const overlays = repeat(40, (i) => `
`); + const html = + "\n" + + ` +
+ ${overlays} +
+ `; + const result = await lintHyperframeHtml(html, { + filePath: "/project/compositions/blur-hero.html", + }); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeUndefined(); + }); + + it("does not warn when 24 heavy overlays are present (just below threshold)", async () => { + const overlays = repeat(24, (i) => `
`); + const result = await lintHyperframeHtml(wrap(overlays)); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeUndefined(); + }); + + it("ignores `clip-path: none` (does not count as a heavy overlay)", async () => { + const overlays = repeat(40, (i) => `
`); + const result = await lintHyperframeHtml(wrap(overlays)); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeUndefined(); + }); + + it("matches heavy selectors by leftmost id (e.g. `#hero { clip-path: ... }`)", async () => { + const head = ``; + // Single id selector wouldn't match 30 elements meaningfully, so use a + // class-based repro plus one id-hit to prove the id lookup runs. + const clipHead = `${head}`; + const clipped = repeat(29, (i) => `
`); + const idHit = `
`; + const result = await lintHyperframeHtml(wrap(clipped + idHit, clipHead)); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeDefined(); + expect(finding?.message).toMatch(/30 elements/); + }); + + it("uses sub-composition-flavored fix hint when isSubComposition is set", async () => { + const overlays = repeat(30, (i) => `
`); + const result = await lintHyperframeHtml(wrap(overlays), { + isSubComposition: true, + }); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeDefined(); + expect(finding?.fixHint).toMatch(/sub-composition further/); + }); + + it("does not double-count the composition root itself (only overlay children)", async () => { + // 25 blur overlays live inside a root that itself has `filter: blur(...)`. + // If we counted the root too, the count would be 26 (still fires); the + // message must report 25 to prove the root skip is working. + const overlays = repeat(25, (i) => `
`); + const html = ` +
+ ${overlays} +
+ `; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (f) => f.code === "composition_heavy_overlay_count_high", + ); + expect(finding).toBeDefined(); + expect(finding?.message).toMatch(/25 elements/); + }); + }); }); diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index a2ffa4f69..a1db1ecc8 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -18,6 +18,46 @@ const TRACK_DENSITY_EXEMPT_TAGS = new Set(["audio", "script", "style", "video"]) const CAPTION_CUE_TOKEN = /^(?:caption(?:[-_](?:group|word|line|block|cue|text))?|subtitle(?:[-_](?:group|line|cue|text))?|cg-.+)$/i; +// composition_heavy_overlay_count_high — warn when a composition carries this +// many or more elements whose CSS uses filter:blur, clip-path (non-none), or +// radial-gradient. Field signal ts=1784040753 (#hyperframes-cli-feedback): +// a composition with ~40 such elements captures solid-black for the first +// ~half of the render, recovering near the end. Presence alone matters — +// opacity:0 and visibility:hidden overlays still contribute — so the rule +// counts every one that isn't display:none-hidden. Threshold sits below the +// observed 40-element repro (25) so authors get lead time; adjust here if +// noise/signal shifts, since a per-rule config option would also require +// plumbing through HyperframeLinterOptions across every embedder. +const HEAVY_OVERLAY_ELEMENT_COUNT_WARN = 25; +const HEAVY_OVERLAY_EXEMPT_TAGS = new Set([ + "audio", + "body", + "br", + "defs", + "head", + "hr", + "html", + "link", + "meta", + "script", + "source", + "style", + "template", + "title", + "use", + "video", +]); +// Matches any of: `filter: <...>blur(...)`, `clip-path: `, +// or `radial-gradient(...)`. Property terminator is `;` or `}`; value class +// excludes both so we don't over-match into the next declaration. `clip-path` +// escapes when its value starts with a CSS-wide keyword that leaves the render +// tree unaffected (none / inherit / initial / unset) — the whitespace-eating +// `\s*` lives *inside* the negative lookahead so the engine can't backtrack +// `\s*` from outside to 0-width and slip past the keyword guard. +const HEAVY_OVERLAY_CSS_PATTERN = + /(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i; +const INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i; + // `parseFloat("0.1") + parseFloat("0.2") = 0.30000000000000004`. Sub-second // authored adjacencies survive parse + add as a value a few ulps above the // next clip's start; a strict `>` fires the overlap rule on adjacencies that @@ -106,6 +146,46 @@ function leftmostCompoundClasses(selector: string): string[] { return (leftmost.match(/\.([\w-]+)/g) ?? []).map((c) => c.slice(1)); } +// Id token in a selector's leftmost compound. `#hero .title` → "hero"; +// `.a#b > .c` → "b"; `.a .b` → null. Companion to leftmostCompoundClasses; +// splits on the same combinator set so the two agree on where "leftmost" ends. +function leftmostCompoundId(selector: string): string | null { + const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? ""; + return leftmost.match(/#([\w-]+)/)?.[1] ?? null; +} + +// Class tokens + ids whose rule body sets a "heavy overlay" property +// (filter:blur, clip-path non-none, or radial-gradient). Only top-level rules +// are scanned — the flat `[^{}]*` body class naturally skips @keyframes +// bodies (which contain nested `{...}` stops) and other @-rules, so keyframe +// selectors like `0%`/`100%` don't leak in. +function collectHeavyOverlayHooks(styles: ExtractedBlock[]): { + classes: Set; + ids: Set; +} { + const classes = new Set(); + const ids = new Set(); + for (const style of styles) { + const noComments = style.content.replace(/\/\*[\s\S]*?\*\//g, ""); + const ruleWithBody = /([^{}]+)\{([^{}]*)\}/g; + let m: RegExpExecArray | null; + while ((m = ruleWithBody.exec(noComments)) !== null) { + const header = (m[1] ?? "").trim(); + const body = m[2] ?? ""; + if (!header || header.startsWith("@")) continue; + if (!HEAVY_OVERLAY_CSS_PATTERN.test(body)) continue; + for (const sel of header.split(",")) { + const trimmed = sel.trim(); + if (!trimmed) continue; + for (const cls of leftmostCompoundClasses(trimmed)) classes.add(cls); + const idToken = leftmostCompoundId(trimmed); + if (idToken) ids.add(idToken); + } + } + } + return { classes, ids }; +} + // Distinct selectors across all