diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 20e1a7fd5..4f4977a4b 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -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", diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index e6860cf8d..8ee4b7976 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -354,10 +354,16 @@ describe("layout-audit.browser invisible text", () => { clearGeometryCollector(); }); - function invisibleTextScene(headlineStyle: Partial): 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, + text = "Headline copy", + ): AuditIssue[] { document.body.innerHTML = `
-
Headline copy
+
${text}
`; 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); - 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) => s as unknown as Partial; + + 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); - 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); }); });