diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index e3d9e25f1f..4f4977a4bf 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -796,6 +796,51 @@ }; } + // 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 + // `color`, not the fill that actually paints). `-webkit-text-fill-color` + // 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. 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; + const text = textContentFor(element); + if (!text) return null; + const cs = getComputedStyle(element); + // Vendor computed-style props are read by property (camelCase), matching + // the rest of this script; `webkitTextFillColor` computes to `color` when + // unset, so it is the effective fill directly. + const fill = cs.webkitTextFillColor || cs.color; + if (colorAlpha(fill) > 0.05) return null; + const clip = cs.webkitBackgroundClip || cs.backgroundClip || ""; + 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", + time, + selector: selectorFor(element), + text, + message: + "Text paints with an effectively transparent fill (-webkit-text-fill-color / color), so its glyphs are invisible.", + rect: textRect, + fixHint: + "Set an explicit, opaque `color` on the text — and an explicit `-webkit-text-fill-color` if an ancestor makes the fill transparent. If the transparency is intentional gradient text, add `background-clip: text`.", + }; + } + function candidateAnchor(element) { const dataAttributes = {}; for (const attribute of Array.from(element.attributes)) { @@ -881,6 +926,8 @@ issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance)); const occluded = occludedTextIssue(element, time); if (occluded) issues.push(occluded); + const invisible = invisibleTextIssue(element, time); + if (invisible) issues.push(invisible); } issues.push(...containerOverflowIssues(root, time, tolerance)); diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 821bad8c14..8ee4b79766 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -346,6 +346,114 @@ it("uses the bridge opacity floor across the ancestor chain", () => { expect(candidates.some((candidate) => candidate.selector === "#stacked-copy")).toBe(true); }); +describe("layout-audit.browser invisible text", () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + clearGeometryCollector(); + }); + + // 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 = ` +
+
${text}
+
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + headline: rect({ left: 40, top: 150, width: 300, height: 56 }), + text: rect({ left: 40, top: 150, width: 300, height: 56 }), + }, + { headline: headlineStyle }, + ); + installAuditScript(); + return runAudit(); + } + + 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("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("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); + }); +}); + describe("layout-audit.browser content overlap", () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index d889e41b73..a3c8716a50 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -946,6 +946,7 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [ "container_overflow", "content_overlap", "text_occluded", + "text_not_painted", "caption_zone_collision", "frame_out_of_frame", "motion_appears_late", diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 6ba63ccb93..bcec7cb126 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -16,6 +16,7 @@ export type LayoutIssueCode = | "container_overflow" | "content_overlap" | "text_occluded" + | "text_not_painted" | "caption_zone_collision" | "frame_out_of_frame" // Frozen-sweep guard (#U10) — a whole-run meta-finding, not a per-sample