Skip to content
Merged
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
67 changes: 64 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,
(/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,65 @@
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,
style.clipPath,
style.webkitClipPath,
style.viewTransitionName,
].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|clip-path)$/.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
111 changes: 110 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 All @@ -964,6 +965,10 @@ describe("layout-audit.browser invisible text", () => {
const gradient = document.getElementById("gradient");
const headline = document.getElementById("headline");
if (!gradient || !headline) throw new Error("Missing gradient text fixture");
Object.defineProperty(document, "elementFromPoint", {
configurable: true,
value: () => headline,
});
Object.defineProperties(gradient, {
offsetLeft: { configurable: true, value: 40 },
offsetTop: { configurable: true, value: 140 },
Expand Down Expand Up @@ -997,6 +1002,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 +1040,53 @@ describe("layout-audit.browser invisible text", () => {
).toBe(false);
});

it.each([
{ name: "image background", css: { backgroundImage: 'url("texture.png")' } },
{
name: "image-set background",
css: { backgroundImage: 'image-set(url("texture.png") 1x)' },
},
{
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: "image-set on a different background layer",
css: {
backgroundClip: "border-box, text",
backgroundImage: 'image-set(url("texture.png") 1x), none',
},
},
{
name: "unclipped image",
css: { backgroundClip: "border-box", backgroundImage: 'url("texture.png")' },
},
{
name: "gradient on a different background layer",
css: {
Expand All @@ -1065,12 +1110,76 @@ 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.each([
{ name: "clip path", css: { clipPath: "inset(0px)" } },
{ name: "prefixed clip path", css: { webkitClipPath: "inset(0px)" } },
{ name: "clip path hint", css: { willChange: "clip-path" } },
{ name: "view transition", css: { viewTransitionName: "headline" } },
])("reports an invisible $name descendant only on older Chrome", ({ css }) => {
expect(flagged(ancestorGradientScene({}, css, 148))).toBe(true);
expect(flagged(ancestorGradientScene({}, css, 152))).toBe(false);
expect(flagged(ancestorGradientScene({}, {}, 148, false, undefined, css))).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
Loading
Loading