Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 61 additions & 3 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -1219,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" &&
Expand All @@ -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
Expand Down
85 changes: 84 additions & 1 deletion packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,7 @@ describe("layout-audit.browser invisible text", () => {
chromiumVersion = 152,
outsideBackground = false,
viewportTop?: number,
wrapperStyle: Partial<CSSStyleDeclaration> = {},
): AuditIssue[] {
vi.spyOn(window.navigator, "userAgent", "get").mockReturnValue(
`Mozilla/5.0 HeadlessChrome/${chromiumVersion}.0.0.0 Safari/537.36`,
Expand Down Expand Up @@ -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 },
},
);
Expand Down Expand Up @@ -1034,14 +1036,42 @@ 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" } },
{
name: "transparent gradient",
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: {
Expand All @@ -1065,12 +1095,65 @@ 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,
);
});

it("accepts ordinary nested text painted by an ancestor gradient on Chrome 148", () => {
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(
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(
Expand Down
129 changes: 126 additions & 3 deletions packages/cli/src/commands/layout-audit.chromium.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const WORDS_08 =
'<h1 style="position:absolute;left:120px;top:200px;width:900px;margin:0;font:700 140px/0.8 Arial"><span style="display:inline-block;margin-right:.25em">Launch</span><span style="display:inline-block;margin-right:.25em">faster</span><span style="display:inline-block;margin-right:.25em">ship</span><span style="display:inline-block;margin-right:.25em">sooner</span></h1>';
const WORDS_04 =
'<h1 style="position:absolute;left:120px;top:200px;width:900px;margin:0;font:700 140px/0.4 Arial"><span style="display:inline-block;margin-right:.25em">Launch</span><span style="display:inline-block;margin-right:.25em">faster</span><span style="display:inline-block;margin-right:.25em">ship</span><span style="display:inline-block;margin-right:.25em">sooner</span></h1>';
const OPAQUE_IMAGE =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP48uULAAW8At0b1v2OAAAAAElFTkSuQmCC";

describe.runIf(executablePath)("layout audit in Chromium", () => {
let browser: Browser;
Expand Down Expand Up @@ -65,6 +67,41 @@ describe.runIf(executablePath)("layout audit in Chromium", () => {
it.each([
{ name: "inline child", content: "<span>Gradient text</span>", layered: false },
{ name: "nested child", content: "<div><span>Gradient text</span></div>", layered: false },
{
name: "inline-block child",
content: '<span style="display:inline-block">Gradient text</span>',
layered: false,
},
{
name: "flex child",
content: '<div style="display:flex">Gradient text</div>',
layered: false,
},
{
name: "clipped child",
content: '<div style="overflow:hidden">Gradient text</div>',
layered: false,
},
{
name: "scroll hint",
content: '<div style="will-change:scroll-position">Gradient text</div>',
layered: false,
},
{
name: "inline transform",
content: '<span style="transform:translateY(0)">Gradient text</span>',
layered: false,
},
{
name: "inline transform hint",
content: '<span style="will-change:transform">Gradient text</span>',
layered: false,
},
{
name: "inline containment",
content: '<span style="contain:paint">Gradient text</span>',
layered: false,
},
{
name: "transformed child",
content: '<div style="transform:translateY(0)">Gradient text</div>',
Expand All @@ -86,6 +123,31 @@ describe.runIf(executablePath)("layout audit in Chromium", () => {
layered: true,
},
{ name: "faded child", content: '<div style="opacity:.9">Gradient text</div>', layered: true },
{
name: "layered wrapper",
content: '<div style="transform:translateY(0)"><span>Gradient text</span></div>',
layered: true,
},
{
name: "isolated child",
content: '<div style="isolation:isolate">Gradient text</div>',
layered: true,
},
{
name: "paint-contained child",
content: '<div style="contain:paint">Gradient text</div>',
layered: true,
},
{
name: "transform hint",
content: '<div style="will-change:transform">Gradient text</div>',
layered: true,
},
{
name: "separately painted flex item",
content: '<div style="display:flex"><div style="z-index:1">Gradient text</div></div>',
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));
Expand All @@ -95,12 +157,73 @@ 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");
}
}
});

it.each([
{ name: "inline child", content: "<span>Gradient text</span>", layered: false },
{ name: "nested child", content: "<div><span>Gradient text</span></div>", layered: false },
{
name: "transformed child",
content: '<div style="transform:translateY(0)">Gradient text</div>',
layered: true,
},
{
name: "image on a second clipped layer",
content: "<span>Gradient text</span>",
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("<span>Gradient text</span>", background))).toContain(
"text_not_painted",
);
});

it("reports text positioned outside an ancestor image background", async () => {
expect(
await auditCodes(
gradientHeading(
'<span style="position:relative;top:150px">Gradient text</span>',
`background-image:url('${OPAQUE_IMAGE}')`,
),
),
).toContain("text_not_painted");
});

it.each([
{ name: "absent gradient", backgroundStyle: "background-image:none" },
{
Expand Down
Loading