From 59b0c7637547a31ab82c1b92ea775d26f63caa22 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Fri, 9 Oct 2026 18:01:29 +0800 Subject: [PATCH 1/2] fix(cli): recognize painted gradient children on older Chrome --- .../cli/src/commands/layout-audit.browser.js | 60 +++++++++++++++- .../src/commands/layout-audit.browser.test.ts | 20 ++++++ .../commands/layout-audit.chromium.test.ts | 70 ++++++++++++++++++- 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 3790c4ac8d4..753aa843347 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1181,7 +1181,8 @@ // Chromium 150+ includes independently painted descendants in text masks; // older builds drop their glyphs: https://github.com/heygen-com/hyperframes/issues/5117. const chromium = window.navigator.userAgent.match(/(?:HeadlessChrome|Chrome)\/(\d+)/); - if (!chromium || Number(chromium[1]) < 150 || !(element instanceof HTMLElement)) return false; + const chromiumVersion = chromium ? Number(chromium[1]) : 0; + if (!chromiumVersion || !(element instanceof HTMLElement)) return false; for ( let ancestor = element.parentElement; @@ -1193,6 +1194,7 @@ style.webkitBackgroundClip || style.backgroundClip || "border-box", ).map((clip) => clip.trim()); if (!clips.includes("text")) continue; + if (chromiumVersion < 150 && hasIndependentTextPaint(element, ancestor)) continue; // Chromium builds the mask before descendant transforms, so moving the // child must not move its glyphs out of the mask for this check. const offset = layoutOffset(element); @@ -1230,6 +1232,62 @@ return false; } + // Plain descendants share a text mask on older Chromium; independently + // painted descendants do not. Check the whole path to the mask, including + // wrappers: https://github.com/chromium/chromium/blob/148.0.7778.97/third_party/blink/renderer/core/layout/layout_inline.cc. + function hasIndependentTextPaint(element, background) { + for (let current = element; current !== background; current = current.parentElement) { + const style = getComputedStyle(current); + if (style.position && style.position !== "static") return true; + if (Number.parseFloat(style.opacity || "1") < 1) return true; + const isInline = style.display === "inline"; + if ( + [ + style.filter, + style.backdropFilter, + style.webkitBackdropFilter, + style.maskImage, + style.webkitMaskImage, + ].some((value) => value && value !== "none") + ) + return true; + if ( + !isInline && + [style.transform, style.translate, style.rotate, style.scale, style.perspective].some( + (value) => value && value !== "none", + ) + ) + return true; + if (style.isolation === "isolate") return true; + if (style.mixBlendMode && style.mixBlendMode !== "normal") return true; + if ( + !isInline && + (style.backfaceVisibility === "hidden" || style.transformStyle === "preserve-3d") + ) + return true; + if (!isInline && /\b(?:paint|layout|content|strict)\b/.test(style.contain || "")) return true; + if (style.contentVisibility && style.contentVisibility !== "visible") return true; + if ( + (style.willChange || "") + .split(",") + .some( + (property) => + /^(?:opacity|filter|backdrop-filter|position|isolation|mix-blend-mode|mask|mask-image)$/.test( + property.trim(), + ) || + (!isInline && + /^(?:transform|translate|rotate|scale|perspective)$/.test(property.trim())), + ) + ) + return true; + if (style.zIndex && style.zIndex !== "auto" && current.parentElement) { + const display = getComputedStyle(current.parentElement).display; + if (/^(?:inline-)?(?:flex|grid)$/.test(display)) return true; + } + } + return false; + } + // Text whose glyphs paint with an effectively transparent fill renders // invisibly even though the element, its box, opacity and color all read as // present — so geometry/occlusion/contrast audits miss it (contrast reads diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index cdfc8a72342..ae2d53928d3 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -952,6 +952,7 @@ describe("layout-audit.browser invisible text", () => { chromiumVersion = 152, outsideBackground = false, viewportTop?: number, + wrapperStyle: Partial = {}, ): AuditIssue[] { vi.spyOn(window.navigator, "userAgent", "get").mockReturnValue( `Mozilla/5.0 HeadlessChrome/${chromiumVersion}.0.0.0 Safari/537.36`, @@ -997,6 +998,7 @@ describe("layout-audit.browser invisible text", () => { backgroundImage: "linear-gradient(90deg, rgb(255, 0, 0), rgb(0, 0, 255))", ...ancestorStyle, }, + wrapper: wrapperStyle, headline: { webkitTextFillColor: "rgba(0, 0, 0, 0)", ...headlineStyle }, }, ); @@ -1071,6 +1073,24 @@ describe("layout-audit.browser invisible text", () => { ); }); + it("accepts ordinary nested text painted by an ancestor gradient on Chrome 148", () => { + expect(flagged(ancestorGradientScene({}, {}, 148))).toBe(false); + }); + + it("keeps reporting a plain child inside an independently painted wrapper on Chrome 148", () => { + expect( + flagged( + ancestorGradientScene({}, {}, 148, false, undefined, { + transform: "matrix(1, 0, 0, 1, 0, 0)", + }), + ), + ).toBe(true); + }); + + it("keeps reporting ancestor masks on an unknown browser", () => { + expect(flagged(ancestorGradientScene({}, {}, 0))).toBe(true); + }); + it("uses the untransformed layout position for a translated child", () => { expect( flagged( diff --git a/packages/cli/src/commands/layout-audit.chromium.test.ts b/packages/cli/src/commands/layout-audit.chromium.test.ts index 3964f54f343..e50f612e206 100644 --- a/packages/cli/src/commands/layout-audit.chromium.test.ts +++ b/packages/cli/src/commands/layout-audit.chromium.test.ts @@ -65,6 +65,41 @@ describe.runIf(executablePath)("layout audit in Chromium", () => { it.each([ { name: "inline child", content: "Gradient text", layered: false }, { name: "nested child", content: "
Gradient text
", layered: false }, + { + name: "inline-block child", + content: 'Gradient text', + layered: false, + }, + { + name: "flex child", + content: '
Gradient text
', + layered: false, + }, + { + name: "clipped child", + content: '
Gradient text
', + layered: false, + }, + { + name: "scroll hint", + content: '
Gradient text
', + layered: false, + }, + { + name: "inline transform", + content: 'Gradient text', + layered: false, + }, + { + name: "inline transform hint", + content: 'Gradient text', + layered: false, + }, + { + name: "inline containment", + content: 'Gradient text', + layered: false, + }, { name: "transformed child", content: '
Gradient text
', @@ -86,6 +121,31 @@ describe.runIf(executablePath)("layout audit in Chromium", () => { layered: true, }, { name: "faded child", content: '
Gradient text
', layered: true }, + { + name: "layered wrapper", + content: '
Gradient text
', + layered: true, + }, + { + name: "isolated child", + content: '
Gradient text
', + layered: true, + }, + { + name: "paint-contained child", + content: '
Gradient text
', + layered: true, + }, + { + name: "transform hint", + content: '
Gradient text
', + layered: true, + }, + { + name: "separately painted flex item", + content: '
Gradient text
', + layered: true, + }, ])("recognizes ancestor gradient paint for a $name", async ({ content, layered }) => { const direct = await gradientFrame(gradientHeading("Gradient text")); const descendant = await gradientFrame(gradientHeading(content)); @@ -95,9 +155,13 @@ describe.runIf(executablePath)("layout audit in Chromium", () => { expect(descendant.image).toEqual(direct.image); expect(descendant.codes).not.toContain("text_not_painted"); } else { - if (layered) expect(descendant.image).not.toEqual(direct.image); - else expect(descendant.image).toEqual(direct.image); - expect(descendant.codes).toContain("text_not_painted"); + if (layered) { + expect(descendant.image).not.toEqual(direct.image); + expect(descendant.codes).toContain("text_not_painted"); + } else { + expect(descendant.image).toEqual(direct.image); + expect(descendant.codes).not.toContain("text_not_painted"); + } } }); From f6723c82b7a5e37d287fa1ce55873ca9a6d8e589 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Fri, 9 Oct 2026 19:23:08 +0800 Subject: [PATCH 2/2] fix(cli): recognize ancestor image text masks --- .../cli/src/commands/layout-audit.browser.js | 4 +- .../src/commands/layout-audit.browser.test.ts | 65 ++++++++++++++++++- .../commands/layout-audit.chromium.test.ts | 59 +++++++++++++++++ 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 753aa843347..b22f6b22865 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1221,8 +1221,8 @@ const imagePaints = images.some( (image, index) => clips[index % clips.length] === "text" && - /(?:linear|radial|conic)-gradient\(/i.test(image) && - gradientMaxAlpha(image) > 0.05, + (/^\s*url\(/i.test(image) || + (/(?:linear|radial|conic)-gradient\(/i.test(image) && gradientMaxAlpha(image) > 0.05)), ); const colorPaints = clips[(images.length - 1) % clips.length] === "text" && diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index ae2d53928d3..5e60a4d50e3 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1036,6 +1036,27 @@ describe("layout-audit.browser invisible text", () => { ).toBe(false); }); + it.each([ + { name: "image background", css: { backgroundImage: 'url("texture.png")' } }, + { + name: "image on a second clipped layer", + css: { backgroundClip: "border-box, text", backgroundImage: 'none, url("texture.png")' }, + }, + { + name: "image URL containing a comma", + css: { + backgroundClip: "text, border-box", + backgroundImage: 'url("texture.png?palette=light,warm"), none', + }, + }, + { + name: "image with a repeated clip list", + css: { backgroundClip: "text", backgroundImage: 'none, url("texture.png")' }, + }, + ])("accepts ancestor $name consistently with directly clipped text", ({ css }) => { + expect(flagged(ancestorGradientScene(css))).toBe(false); + }); + it.each([ { name: "absent background", css: { backgroundImage: "none" } }, { @@ -1043,7 +1064,14 @@ describe("layout-audit.browser invisible text", () => { css: { backgroundImage: "linear-gradient(transparent, rgba(0, 0, 0, 0))" }, }, { name: "unclipped background", css: { backgroundClip: "border-box" } }, - { name: "image with unknown transparency", css: { backgroundImage: 'url("missing.png")' } }, + { + name: "image on a different background layer", + css: { backgroundClip: "border-box, text", backgroundImage: 'url("texture.png"), none' }, + }, + { + name: "unclipped image", + css: { backgroundClip: "border-box", backgroundImage: 'url("texture.png")' }, + }, { name: "gradient on a different background layer", css: { @@ -1067,6 +1095,12 @@ describe("layout-audit.browser invisible text", () => { expect(flagged(ancestorGradientScene({}, {}, 152, true))).toBe(true); }); + it("keeps reporting text outside an ancestor image's background box", () => { + expect( + flagged(ancestorGradientScene({ backgroundImage: 'url("texture.png")' }, {}, 152, true)), + ).toBe(true); + }); + it("keeps reporting independently painted descendants on Chrome 148", () => { expect(flagged(ancestorGradientScene({}, { transform: "matrix(1, 0, 0, 1, 0, 0)" }, 148))).toBe( true, @@ -1077,6 +1111,35 @@ describe("layout-audit.browser invisible text", () => { expect(flagged(ancestorGradientScene({}, {}, 148))).toBe(false); }); + it("accepts ordinary nested text painted by an ancestor image on Chrome 148", () => { + expect(flagged(ancestorGradientScene({ backgroundImage: 'url("texture.png")' }, {}, 148))).toBe( + false, + ); + }); + + it("keeps reporting image masks on an unknown browser", () => { + expect(flagged(ancestorGradientScene({ backgroundImage: 'url("texture.png")' }, {}, 0))).toBe( + true, + ); + }); + + it("keeps reporting an image-masked child in a layered wrapper on Chrome 148", () => { + expect( + flagged( + ancestorGradientScene( + { backgroundImage: 'url("texture.png")' }, + {}, + 148, + false, + undefined, + { + transform: "matrix(1, 0, 0, 1, 0, 0)", + }, + ), + ), + ).toBe(true); + }); + it("keeps reporting a plain child inside an independently painted wrapper on Chrome 148", () => { expect( flagged( diff --git a/packages/cli/src/commands/layout-audit.chromium.test.ts b/packages/cli/src/commands/layout-audit.chromium.test.ts index e50f612e206..e4c4d9b75e4 100644 --- a/packages/cli/src/commands/layout-audit.chromium.test.ts +++ b/packages/cli/src/commands/layout-audit.chromium.test.ts @@ -17,6 +17,8 @@ const WORDS_08 = '

Launchfastershipsooner

'; const WORDS_04 = '

Launchfastershipsooner

'; +const OPAQUE_IMAGE = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP48uULAAW8At0b1v2OAAAAAElFTkSuQmCC"; describe.runIf(executablePath)("layout audit in Chromium", () => { let browser: Browser; @@ -165,6 +167,63 @@ describe.runIf(executablePath)("layout audit in Chromium", () => { } }); + it.each([ + { name: "inline child", content: "Gradient text", layered: false }, + { name: "nested child", content: "
Gradient text
", layered: false }, + { + name: "transformed child", + content: '
Gradient text
', + layered: true, + }, + { + name: "image on a second clipped layer", + content: "Gradient text", + backgroundStyle: "background-clip:border-box,text;background-image:none,", + layered: false, + }, + ])( + "recognizes ancestor image paint for a $name", + async ({ content, backgroundStyle, layered }) => { + const background = backgroundStyle + ? `${backgroundStyle}url('${OPAQUE_IMAGE}')` + : `background-image:url('${OPAQUE_IMAGE}')`; + const direct = await gradientFrame(gradientHeading("Gradient text", background)); + const descendant = await gradientFrame(gradientHeading(content, background)); + const empty = await gradientFrame(gradientHeading("", background)); + expect(direct.image).not.toEqual(empty.image); + expect(direct.codes).not.toContain("text_not_painted"); + const version = await browser.version(); + const chromiumVersion = Number(version.split("/")[1]?.split(".")[0]); + const dropsChild = chromiumVersion < 150 && layered; + expect(descendant.image).toEqual(dropsChild ? empty.image : direct.image); + expect(descendant.codes.includes("text_not_painted")).toBe(dropsChild); + }, + ); + + it.each([ + { name: "unclipped image", backgroundStyle: "background-clip:border-box" }, + { + name: "image on a different background layer", + backgroundStyle: "background-image:url('IMAGE'),none;background-clip:border-box,text", + }, + ])("reports invisible glyphs beneath an $name", async ({ backgroundStyle }) => { + const background = `background-image:url('${OPAQUE_IMAGE}');${backgroundStyle.replace("IMAGE", OPAQUE_IMAGE)}`; + expect(await auditCodes(gradientHeading("Gradient text", background))).toContain( + "text_not_painted", + ); + }); + + it("reports text positioned outside an ancestor image background", async () => { + expect( + await auditCodes( + gradientHeading( + 'Gradient text', + `background-image:url('${OPAQUE_IMAGE}')`, + ), + ), + ).toContain("text_not_painted"); + }); + it.each([ { name: "absent gradient", backgroundStyle: "background-image:none" }, {