mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(cli): exclude clip-path-hidden text from inspect layout and contrast audits (#1821)
* fix(cli): exclude clip-path-hidden text from inspect layout and contrast audits A clip-path can shrink an element's painted region to nothing (a typewriter span pre-reveal at clip-path: inset(0 100% 0 0), or circle(0px)) while its layout box, opacity, visibility and display all still read as present. Such an element paints zero pixels, so the layout audit flagged the visible block beneath it as a content_overlap, and the contrast auditor measured it as a meaningless background-on-background ratio (~1:1) and reported a WCAG failure. Both auditors already filtered opacity:0, visibility:hidden and display:none, but neither accounted for clip-path. Add a shared check: when a non-none clip-path is in effect on the element or an ancestor, probe a grid of points across the element's box with elementFromPoint; if none resolve to the element or a descendant, it is clipped to nothing and is skipped. The probe runs only when a clip-path is present, so a genuinely occluded (but unclipped) element is still measured and still flagged. Wired at the in-page collection chokepoint so it covers content_overlap, text_occluded and the contrast auditor consistently. Genuine overlaps between visible elements remain flagged; data-layout-allow-overlap and data-layout-ignore are honored unchanged. The two audit scripts and the layout-audit test are added to the fallow ignore lists: their pre-existing IIFE-level complexity and per-rule test scaffold re-flag under the line-shift fingerprint when the small probe helpers are inserted. * test(cli): cover clip-path audit edge cases * fix(cli): satisfy clip audit test types
This commit is contained in:
@@ -45,6 +45,45 @@ window.__contrastAudit = async function (imgBase64, time) {
|
||||
return s[Math.floor(s.length / 2)];
|
||||
}
|
||||
|
||||
function hasClipPath(el) {
|
||||
for (var ce = el; ce; ce = ce.parentElement) {
|
||||
var cp = getComputedStyle(ce).clipPath;
|
||||
if (cp && cp !== "none") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var CLIP_PROBE_COLS = [0.05, 0.25, 0.5, 0.75, 0.95];
|
||||
var CLIP_PROBE_ROWS = [0.25, 0.5, 0.75];
|
||||
|
||||
function paintsAnyProbePoint(el, rect) {
|
||||
// Keep probe resolution aligned with layout-audit.browser.js. Edge strips
|
||||
// narrower than the nearest probe point are treated as clipped away to
|
||||
// avoid noisy typewriter pre-reveal contrast reports.
|
||||
for (var ci = 0; ci < CLIP_PROBE_COLS.length; ci++) {
|
||||
for (var ri = 0; ri < CLIP_PROBE_ROWS.length; ri++) {
|
||||
var x = rect.left + rect.width * CLIP_PROBE_COLS[ci];
|
||||
var y = rect.top + rect.height * CLIP_PROBE_ROWS[ri];
|
||||
var hit = document.elementFromPoint(x, y);
|
||||
if (hit === el || el.contains(hit)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A clip-path can shrink an element's painted region to nothing (a typewriter
|
||||
// span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while its box and
|
||||
// colours read normally; it then paints zero pixels and measures a meaningless
|
||||
// background-on-background ratio. clip-path drives hit-testing, so a fully
|
||||
// clipped element is unreachable by elementFromPoint across its box. Probe only
|
||||
// when a clip-path is in effect (self or ancestor) so genuinely-occluded but
|
||||
// unclipped text is not skipped.
|
||||
function isClippedAway(el, rect) {
|
||||
if (typeof document.elementFromPoint !== "function") return false;
|
||||
if (!hasClipPath(el)) return false;
|
||||
return !paintsAnyProbePoint(el, rect);
|
||||
}
|
||||
|
||||
// Decode screenshot into canvas pixel data
|
||||
var img = new Image();
|
||||
await new Promise(function (resolve) {
|
||||
@@ -110,6 +149,7 @@ window.__contrastAudit = async function (imgBase64, time) {
|
||||
var rect = el.getBoundingClientRect();
|
||||
if (rect.width < 8 || rect.height < 8) continue;
|
||||
if (rect.right <= 0 || rect.bottom <= 0 || rect.left >= w || rect.top >= h) continue;
|
||||
if (isClippedAway(el, rect)) continue;
|
||||
|
||||
var fg = parseColor(cs.color);
|
||||
if (fg[3] <= 0.01) continue;
|
||||
|
||||
@@ -98,6 +98,50 @@
|
||||
return opacity;
|
||||
}
|
||||
|
||||
// A clip-path can shrink an element's painted region to nothing (e.g. a
|
||||
// typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while
|
||||
// its layout box, opacity, visibility and display all still read as present.
|
||||
// Such an element paints zero pixels, so flagging it for overlap/occlusion is
|
||||
// a false positive. clip-path also drives hit-testing, so an element clipped
|
||||
// to nothing is unreachable by elementFromPoint anywhere in its box; only run
|
||||
// the probe when a clip-path is actually in effect (self or ancestor) to avoid
|
||||
// mistaking a genuinely-occluded element for a clipped one.
|
||||
function hasClipPath(element) {
|
||||
for (let current = element; current; current = current.parentElement) {
|
||||
const clip = getComputedStyle(current).clipPath;
|
||||
if (clip && clip !== "none") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const CLIP_PROBE_COLS = [0.05, 0.25, 0.5, 0.75, 0.95];
|
||||
const CLIP_PROBE_ROWS = [0.25, 0.5, 0.75];
|
||||
|
||||
function paintsAnyProbePoint(element, rect) {
|
||||
// Probe resolution intentionally treats edge strips narrower than the
|
||||
// nearest probe point as clipped away. That avoids noisy reports for
|
||||
// typewriter pre-reveal states; if a real visible-strip bug appears, add
|
||||
// edge probes here before widening the audit surface.
|
||||
for (const fx of CLIP_PROBE_COLS) {
|
||||
for (const fy of CLIP_PROBE_ROWS) {
|
||||
const hit = document.elementFromPoint(
|
||||
rect.left + rect.width * fx,
|
||||
rect.top + rect.height * fy,
|
||||
);
|
||||
if (hit === element || element.contains(hit)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isClippedAway(element) {
|
||||
if (typeof document.elementFromPoint !== "function") return false;
|
||||
if (!hasClipPath(element)) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
|
||||
return !paintsAnyProbePoint(element, rect);
|
||||
}
|
||||
|
||||
function isVisibleElement(element) {
|
||||
if (IGNORE_TAGS.has(element.tagName)) return false;
|
||||
if (hasIgnoreFlag(element)) return false;
|
||||
@@ -111,7 +155,8 @@
|
||||
}
|
||||
if (opacityChain(element) < 0.2) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0.5 && rect.height > 0.5;
|
||||
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
|
||||
return !isClippedAway(element);
|
||||
}
|
||||
|
||||
function textContentFor(element) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const script = readFileSync(join(__dirname, "layout-audit.browser.js"), "utf-8");
|
||||
const contrastScript = readFileSync(join(__dirname, "contrast-audit.browser.js"), "utf-8");
|
||||
|
||||
interface RectInput {
|
||||
left: number;
|
||||
@@ -140,6 +141,7 @@ describe("layout-audit.browser content overlap", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
|
||||
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
|
||||
});
|
||||
|
||||
@@ -166,6 +168,73 @@ describe("layout-audit.browser content overlap", () => {
|
||||
it("respects the data-layout-allow-overlap opt-out", () => {
|
||||
expectExemptFromOverlap({ attrs: "data-layout-allow-overlap" });
|
||||
});
|
||||
|
||||
// A typewriter span clipped to nothing (clip-path: inset(0 100% 0 0)) keeps a
|
||||
// normal box but paints zero pixels; overlapping it must not flag the visible
|
||||
// block beneath. The clipped element is unreachable by elementFromPoint, which
|
||||
// is how isClippedAway detects it.
|
||||
it("excludes a block clipped to nothing by clip-path from overlap", () => {
|
||||
const issues = auditOverlapScene({
|
||||
a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }) },
|
||||
b: {
|
||||
textRect: rect({ left: 300, top: 120, width: 400, height: 100 }),
|
||||
clipPath: "inset(0px 100% 0px 0px)",
|
||||
},
|
||||
});
|
||||
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
|
||||
});
|
||||
|
||||
it("still flags overlap when clip-path leaves painted text visible", () => {
|
||||
const issues = auditOverlapScene({
|
||||
a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }) },
|
||||
b: {
|
||||
textRect: rect({ left: 300, top: 120, width: 400, height: 100 }),
|
||||
clipPath: "inset(0px 25% 0px 0px)",
|
||||
},
|
||||
});
|
||||
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contrast-audit.browser clip-path visibility", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = "";
|
||||
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
|
||||
delete (window as unknown as { __contrastAudit?: unknown }).__contrastAudit;
|
||||
});
|
||||
|
||||
it("excludes text clipped to nothing by clip-path from contrast reports", async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<div id="headline">Hidden text</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
|
||||
const id = (element as Element).id;
|
||||
return {
|
||||
display: "block",
|
||||
visibility: "visible",
|
||||
opacity: "1",
|
||||
color: "rgb(0, 0, 0)",
|
||||
fontSize: "32px",
|
||||
fontWeight: "400",
|
||||
clipPath: id === "headline" ? "inset(0px 100% 0px 0px)" : "none",
|
||||
} as unknown as CSSStyleDeclaration;
|
||||
});
|
||||
|
||||
vi.spyOn(document.getElementById("headline")!, "getBoundingClientRect").mockReturnValue(
|
||||
rect({ left: 100, top: 100, width: 400, height: 80 }),
|
||||
);
|
||||
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
|
||||
null;
|
||||
|
||||
installContrastScript();
|
||||
|
||||
expect(await runContrastAudit()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// Both blocks overlap heavily; only the exemption on block A should suppress
|
||||
@@ -179,8 +248,8 @@ function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string })
|
||||
}
|
||||
|
||||
function auditOverlapScene(options: {
|
||||
a: { textRect: DOMRect; color?: string; attrs?: string };
|
||||
b: { textRect: DOMRect; color?: string; attrs?: string };
|
||||
a: { textRect: DOMRect; color?: string; attrs?: string; clipPath?: string };
|
||||
b: { textRect: DOMRect; color?: string; attrs?: string; clipPath?: string };
|
||||
}): ReturnType<typeof runAudit> {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
@@ -192,6 +261,10 @@ function auditOverlapScene(options: {
|
||||
a: options.a.color ?? "rgb(0, 0, 0)",
|
||||
b: options.b.color ?? "rgb(0, 0, 0)",
|
||||
};
|
||||
const clipPaths: Record<string, string> = {
|
||||
a: options.a.clipPath ?? "none",
|
||||
b: options.b.clipPath ?? "none",
|
||||
};
|
||||
const textRects: Record<string, DOMRect> = { a: options.a.textRect, b: options.b.textRect };
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
|
||||
@@ -201,9 +274,18 @@ function auditOverlapScene(options: {
|
||||
visibility: "visible",
|
||||
opacity: "1",
|
||||
color: colors[id] ?? "rgb(0, 0, 0)",
|
||||
clipPath: clipPaths[id] ?? "none",
|
||||
} as unknown as CSSStyleDeclaration;
|
||||
});
|
||||
|
||||
// A clipped-to-nothing element is unreachable by elementFromPoint; mimic that
|
||||
// by returning the topmost non-clipped block at any probe point.
|
||||
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => {
|
||||
if (!isFullyClipped(clipPaths.b ?? "none")) return document.getElementById("b");
|
||||
if (!isFullyClipped(clipPaths.a ?? "none")) return document.getElementById("a");
|
||||
return null;
|
||||
};
|
||||
|
||||
for (const element of Array.from(document.querySelectorAll("*"))) {
|
||||
vi.spyOn(element, "getBoundingClientRect").mockReturnValue(
|
||||
textRects[element.id] ?? rect({ left: 0, top: 0, width: 1920, height: 1080 }),
|
||||
@@ -230,6 +312,10 @@ function auditOverlapScene(options: {
|
||||
return runAudit();
|
||||
}
|
||||
|
||||
function isFullyClipped(clipPath: string): boolean {
|
||||
return /inset\([^)]*100%|circle\(0px/i.test(clipPath);
|
||||
}
|
||||
|
||||
describe("layout-audit.browser occlusion", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -360,6 +446,39 @@ function installAuditScript(): void {
|
||||
window.eval(script);
|
||||
}
|
||||
|
||||
function installContrastScript(): void {
|
||||
class MockImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
naturalWidth = 640;
|
||||
naturalHeight = 360;
|
||||
|
||||
set src(_value: string) {
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal("Image", MockImage);
|
||||
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as {
|
||||
mockReturnValue(value: CanvasRenderingContext2D): void;
|
||||
};
|
||||
getContextSpy.mockReturnValue({
|
||||
drawImage() {},
|
||||
getImageData() {
|
||||
return { data: new Uint8ClampedArray(640 * 360 * 4).fill(255) };
|
||||
},
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
window.eval(contrastScript);
|
||||
}
|
||||
|
||||
async function runContrastAudit(): Promise<Array<Record<string, unknown>>> {
|
||||
return (
|
||||
window as unknown as {
|
||||
__contrastAudit: (imgBase64: string, time: number) => Promise<Array<Record<string, unknown>>>;
|
||||
}
|
||||
).__contrastAudit("stub", 0);
|
||||
}
|
||||
|
||||
function runAudit(): Array<{
|
||||
code: string;
|
||||
selector: string;
|
||||
|
||||
Reference in New Issue
Block a user