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
47 changes: 47 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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));
Expand Down
108 changes: 108 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CSSStyleDeclaration>,
text = "Headline copy",
): AuditIssue[] {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="headline">${text}</div>
</div>
`;
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<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("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();
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/utils/layoutAudit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading