Merge pull request #2266 from heygen-com/07-11-fix-cli-check-detect-invisible-text

fix(cli): flag text with an effectively transparent fill in check
This commit is contained in:
Vance Ingalls
2026-07-11 17:10:44 -07:00
committed by GitHub
4 changed files with 157 additions and 0 deletions
@@ -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));
@@ -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();
+1
View File
@@ -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",
+1
View File
@@ -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