fix(cli): only exclude clipped text when a background actually paints the glyphs

Addresses review on #2266: the gradient-text exclusion was too broad — any
background-clip:text skipped the invisible-text check, so a broken/missing
gradient (clip:text with no image and a transparent background, which paints
nothing) went unreported. Now exclude only when a real background fills the
glyphs (background-image != none, or an opaque background-color). Expands the
test suite to the reviewer's full case set: direct transparent fill, inherited
transparent fill over an opaque child color, color:transparent fallback, opaque
baseline, gradient-over-real-background exclusion, broken-gradient still flagged,
and empty-text no-op.
This commit is contained in:
Vance Ingalls
2026-07-11 17:04:30 -07:00
parent a8fd8562a8
commit fa902400c9
2 changed files with 89 additions and 23 deletions
@@ -803,9 +803,10 @@
// overrides `color` for the glyph fill AND inherits, so a parent's
// `transparent` fill silently blanks descendant text that has its own opaque
// `color`. Its computed value already resolves to `color` when unset, so it
// is the effective fill directly. Gradient/clipped text (`background-clip:
// text`) legitimately uses a transparent fill — the clipped background paints
// the glyphs — so exclude it.
// is the effective fill directly. Clipped text (`background-clip: text`)
// legitimately uses a transparent fill — BUT only when a background actually
// paints the glyphs; a `background-clip: text` with no gradient/image and no
// opaque background-color paints nothing, so it stays reportable.
function invisibleTextIssue(element, time) {
const textRect = textRectFor(element);
if (!textRect) return null;
@@ -818,7 +819,14 @@
const fill = cs.webkitTextFillColor || cs.color;
if (colorAlpha(fill) > 0.05) return null;
const clip = cs.webkitBackgroundClip || cs.backgroundClip || "";
if (/text/i.test(clip)) return null;
if (/text/i.test(clip)) {
const bgImage = cs.backgroundImage || "none";
const paintsGlyphs =
bgImage !== "none" || colorAlpha(cs.backgroundColor || "rgba(0, 0, 0, 0)") > 0.05;
// A usable clipped background fills the glyphs — legitimate gradient/solid
// clipped text. If nothing paints, fall through and report it.
if (paintsGlyphs) return null;
}
return {
code: "text_not_painted",
severity: "error",
@@ -354,10 +354,16 @@ describe("layout-audit.browser invisible text", () => {
clearGeometryCollector();
});
function invisibleTextScene(headlineStyle: Partial<CSSStyleDeclaration>): AuditIssue[] {
// The mock resolves computed style per element, so setting `webkitTextFillColor`
// on the headline models exactly what the browser computes there — whether the
// value was authored on the element or inherited from an ancestor.
function invisibleTextScene(
headlineStyle: Partial<CSSStyleDeclaration>,
text = "Headline copy",
): AuditIssue[] {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="headline">Headline copy</div>
<div id="headline">${text}</div>
</div>
`;
installGeometry(
@@ -372,27 +378,79 @@ describe("layout-audit.browser invisible text", () => {
return runAudit();
}
it("flags text whose -webkit-text-fill-color is transparent", () => {
const issues = invisibleTextScene({
color: "rgb(255, 255, 255)",
webkitTextFillColor: "rgba(0, 0, 0, 0)",
} as unknown as Partial<CSSStyleDeclaration>);
const invisible = issues.find((issue) => issue.code === "text_not_painted");
expect(invisible).toMatchObject({ selector: "#headline", severity: "error" });
const flagged = (issues: AuditIssue[]) =>
issues.some((issue) => issue.code === "text_not_painted");
const style = (s: Record<string, string>) => s as unknown as Partial<CSSStyleDeclaration>;
it("flags a directly transparent -webkit-text-fill-color", () => {
const issues = invisibleTextScene(
style({ color: "rgb(255, 255, 255)", webkitTextFillColor: "rgba(0, 0, 0, 0)" }),
);
expect(issues.find((i) => i.code === "text_not_painted")).toMatchObject({
selector: "#headline",
severity: "error",
});
});
it("does not flag text with an opaque color and default fill", () => {
const issues = invisibleTextScene({ color: "rgb(255, 255, 255)" });
expect(issues.some((issue) => issue.code === "text_not_painted")).toBe(false);
it("flags an inherited transparent fill overriding the child's opaque color", () => {
// getComputedStyle on the child resolves the inherited fill to transparent
// (browsers always return the rgba() form), even though the child sets its
// own opaque `color`.
expect(
flagged(
invisibleTextScene(
style({ color: "rgb(255, 255, 255)", webkitTextFillColor: "rgba(0, 0, 0, 0)" }),
),
),
).toBe(true);
});
it("does not flag gradient text (transparent fill clipped to the glyphs)", () => {
const issues = invisibleTextScene({
color: "rgb(255, 255, 255)",
webkitTextFillColor: "rgba(0, 0, 0, 0)",
webkitBackgroundClip: "text",
} as unknown as Partial<CSSStyleDeclaration>);
expect(issues.some((issue) => issue.code === "text_not_painted")).toBe(false);
it("flags color:transparent when no explicit fill is set (color fallback)", () => {
// -webkit-text-fill-color unset → resolves to `color`; a transparent color
// must still be caught via the `|| cs.color` fallback.
expect(flagged(invisibleTextScene(style({ color: "rgba(0, 0, 0, 0)" })))).toBe(true);
});
it("does not flag opaque text with a default fill", () => {
expect(flagged(invisibleTextScene(style({ color: "rgb(255, 255, 255)" })))).toBe(false);
});
it("does not flag gradient text (transparent fill clipped over a real background)", () => {
expect(
flagged(
invisibleTextScene(
style({
color: "rgb(255, 255, 255)",
webkitTextFillColor: "rgba(0, 0, 0, 0)",
webkitBackgroundClip: "text",
backgroundImage: "linear-gradient(90deg, rgb(255, 0, 0), rgb(0, 0, 255))",
}),
),
),
).toBe(false);
});
it("still flags background-clip:text when no background actually paints the glyphs", () => {
// A clipped-to-text fill with no gradient/image and a transparent background
// paints nothing — a broken gradient must remain reportable.
expect(
flagged(
invisibleTextScene(
style({
webkitTextFillColor: "rgba(0, 0, 0, 0)",
webkitBackgroundClip: "text",
backgroundImage: "none",
backgroundColor: "rgba(0, 0, 0, 0)",
}),
),
),
).toBe(true);
});
it("does not flag an element with a transparent fill but no text content", () => {
expect(
flagged(invisibleTextScene(style({ webkitTextFillColor: "rgba(0, 0, 0, 0)" }), "")),
).toBe(false);
});
});