From 9ae84c652c15f64c74054ef040c44fdb193d50c8 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 11 Jul 2026 16:52:24 -0700 Subject: [PATCH 1/3] fix(cli): flag text with an effectively transparent fill in check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wild report (5th in cluster, CLI 0.7.53): snapshots omitted all text while check passed — text painting with a transparent -webkit-text-fill-color (which overrides `color` for the glyph fill AND inherits, so a parent's transparent fill silently blanks descendant text that has its own opaque `color`) renders invisible, but every geometry/occlusion/contrast audit missed it. Contrast in particular reads `color`, not the fill that actually paints, so white-`color` + transparent-fill text scored as high-contrast and passed. Add an invisible-text detector to the layout audit: flag any text element whose effective fill (computed -webkit-text-fill-color, which already resolves to `color` when unset) is transparent. Gradient/clipped text (background-clip:text) legitimately uses a transparent fill and is excluded. Verified: check now fails on an inherited-transparent-fill fixture (text_not_painted) while gradient text, body-inherited color, sub-composition color, and real registry examples stay clean. --- .../cli/src/commands/layout-audit.browser.js | 39 +++++++++++++++++++ packages/cli/src/utils/checkBrowser.ts | 1 + packages/cli/src/utils/layoutAudit.ts | 1 + 3 files changed, 41 insertions(+) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index e3d9e25f1f..f7d045ec30 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -796,6 +796,43 @@ }; } + // 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. Gradient/clipped text (`background-clip: + // text`) legitimately uses a transparent fill — the clipped background paints + // the glyphs — so exclude it. + function invisibleTextIssue(element, time) { + const textRect = textRectFor(element); + if (!textRect) return null; + const text = textContentFor(element); + if (!text) return null; + const cs = getComputedStyle(element); + const fill = cs.getPropertyValue("-webkit-text-fill-color") || cs.color; + if (colorAlpha(fill) > 0.05) return null; + const clip = + cs.getPropertyValue("-webkit-background-clip") || + cs.getPropertyValue("background-clip") || + ""; + if (/text/i.test(clip)) 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 +918,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/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 From a8fd8562a8e908c28f4e048e1c9c618a1d1dedc4 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 11 Jul 2026 17:00:40 -0700 Subject: [PATCH 2/3] fix(cli): read effective text fill by property, not getPropertyValue The layout-audit test mock returns computed styles as plain camelCase properties (no getPropertyValue), so the invisible-text detector threw 'cs.getPropertyValue is not a function' and broke the whole audit in CI. Read webkitTextFillColor/webkitBackgroundClip/backgroundClip by property to match the rest of the script (works in a real browser too). Adds tests: flags transparent -webkit-text-fill-color, ignores opaque color, ignores gradient text (background-clip:text). --- .../cli/src/commands/layout-audit.browser.js | 10 ++-- .../src/commands/layout-audit.browser.test.ts | 50 +++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index f7d045ec30..20e1a7fd59 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -812,12 +812,12 @@ const text = textContentFor(element); if (!text) return null; const cs = getComputedStyle(element); - const fill = cs.getPropertyValue("-webkit-text-fill-color") || cs.color; + // 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.getPropertyValue("-webkit-background-clip") || - cs.getPropertyValue("background-clip") || - ""; + const clip = cs.webkitBackgroundClip || cs.backgroundClip || ""; if (/text/i.test(clip)) return null; return { code: "text_not_painted", diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 821bad8c14..e6860cf8d0 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -346,6 +346,56 @@ 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(); + }); + + function invisibleTextScene(headlineStyle: Partial): AuditIssue[] { + document.body.innerHTML = ` +
+
Headline copy
+
+ `; + 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(); + } + + 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" }); + }); + + 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("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); + }); +}); + describe("layout-audit.browser content overlap", () => { afterEach(() => { vi.restoreAllMocks(); From fa902400c973df07f17c59aced9332bee33171a9 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 11 Jul 2026 17:04:30 -0700 Subject: [PATCH 3/3] fix(cli): only exclude clipped text when a background actually paints the glyphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../cli/src/commands/layout-audit.browser.js | 16 +++- .../src/commands/layout-audit.browser.test.ts | 96 +++++++++++++++---- 2 files changed, 89 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 20e1a7fd59..4f4977a4bf 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 e6860cf8d0..8ee4b79766 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); }); });