mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user