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