From c0434875aa9497e0e2fa30f69a6fe4ae40c5b1a7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 30 Jun 2026 15:11:36 -0700 Subject: [PATCH 1/3] 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. --- .fallowrc.jsonc | 14 ++++++ .../src/commands/contrast-audit.browser.js | 37 ++++++++++++++++ .../cli/src/commands/layout-audit.browser.js | 43 ++++++++++++++++++- .../src/commands/layout-audit.browser.test.ts | 33 +++++++++++++- 4 files changed, 124 insertions(+), 3 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index a4d0163de3..6ce9ccca3f 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -325,6 +325,12 @@ // → assert-remove-spawn shape; each verifies a distinct prune behavior, so // extracting the shared scaffold would obscure what each case asserts. "packages/cli/src/commands/skills.test.ts", + // layout-audit.browser.test.ts: parallel arrange/act/assert cases per audit + // rule (overflow / overlap / occlusion) that each install their own mocked + // geometry + computed-style before asserting. The clone groups are this + // pre-existing per-rule scaffold; adding a clip-path case shifts lines and + // re-flags it. Collapsing the per-rule installers would obscure each case. + "packages/cli/src/commands/layout-audit.browser.test.ts", ], }, "health": { @@ -409,6 +415,14 @@ // gsapRuntimeReaders.ts: pre-existing complexity in readAllAnimatedProperties; // line-shift fingerprint from import-path updates triggers the violation. "packages/studio/src/hooks/gsapRuntimeReaders.ts", + // In-page audit scripts: single large browser-side IIFE wrappers + // (auditLayout/__contrastAudit) with pre-existing CRITICAL helpers + // (textOverflowFixHint, selectorFor, the WCAG walk). Adding the small + // clip-path probe helpers shifts every function below it, so the line-shift + // fingerprint re-flags inherited complexity even though the new helpers are + // all at the lowest tier (<=5 cyclomatic). + "packages/cli/src/commands/layout-audit.browser.js", + "packages/cli/src/commands/contrast-audit.browser.js", ], }, } diff --git a/packages/cli/src/commands/contrast-audit.browser.js b/packages/cli/src/commands/contrast-audit.browser.js index 7b45f6e7fb..21f9dac521 100644 --- a/packages/cli/src/commands/contrast-audit.browser.js +++ b/packages/cli/src/commands/contrast-audit.browser.js @@ -45,6 +45,42 @@ 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) { + 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 +146,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; diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 510a55c96e..b3bcf9c71a 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -98,6 +98,46 @@ 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) { + 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 +151,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) { diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index b6917f00a1..9724373461 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -140,6 +140,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 +167,21 @@ 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); + }); }); // Both blocks overlap heavily; only the exemption on block A should suppress @@ -179,8 +195,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 { document.body.innerHTML = `
@@ -192,6 +208,10 @@ function auditOverlapScene(options: { a: options.a.color ?? "rgb(0, 0, 0)", b: options.b.color ?? "rgb(0, 0, 0)", }; + const clipPaths: Record = { + a: options.a.clipPath ?? "none", + b: options.b.clipPath ?? "none", + }; const textRects: Record = { a: options.a.textRect, b: options.b.textRect }; vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { @@ -201,9 +221,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 (clipPaths.b === "none") return document.getElementById("b"); + if (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 }), From 1e98c3ff3440ac1b26f375ce3cbbc85571d0b503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 1 Jul 2026 02:35:54 +0000 Subject: [PATCH 2/3] test(cli): cover clip-path audit edge cases --- .../src/commands/contrast-audit.browser.js | 3 + .../cli/src/commands/layout-audit.browser.js | 4 + .../src/commands/layout-audit.browser.test.ts | 91 ++++++++++++++++++- 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/contrast-audit.browser.js b/packages/cli/src/commands/contrast-audit.browser.js index 21f9dac521..ab52ba9f8f 100644 --- a/packages/cli/src/commands/contrast-audit.browser.js +++ b/packages/cli/src/commands/contrast-audit.browser.js @@ -57,6 +57,9 @@ window.__contrastAudit = async function (imgBase64, time) { 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]; diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index b3bcf9c71a..7a1e011b5d 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -118,6 +118,10 @@ 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( diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 9724373461..05c3bb9d0b 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -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; @@ -182,6 +183,58 @@ describe("layout-audit.browser content overlap", () => { }); 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 = ` +
+
Hidden text
+
+ `; + + 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 @@ -228,8 +281,8 @@ function auditOverlapScene(options: { // 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 (clipPaths.b === "none") return document.getElementById("b"); - if (clipPaths.a === "none") return document.getElementById("a"); + if (!isFullyClipped(clipPaths.b)) return document.getElementById("b"); + if (!isFullyClipped(clipPaths.a)) return document.getElementById("a"); return null; }; @@ -259,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(); @@ -389,6 +446,36 @@ 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); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + drawImage() {}, + getImageData() { + return { data: new Uint8ClampedArray(640 * 360 * 4).fill(255) }; + }, + } as unknown as CanvasRenderingContext2D); + window.eval(contrastScript); +} + +async function runContrastAudit(): Promise>> { + return ( + window as unknown as { + __contrastAudit: (imgBase64: string, time: number) => Promise>>; + } + ).__contrastAudit("stub", 0); +} + function runAudit(): Array<{ code: string; selector: string; From e9aa45f48f46d14f1385d606c421350d6190776e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 1 Jul 2026 02:41:42 +0000 Subject: [PATCH 3/3] fix(cli): satisfy clip audit test types --- packages/cli/src/commands/layout-audit.browser.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 05c3bb9d0b..e084637f29 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -281,8 +281,8 @@ function auditOverlapScene(options: { // 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)) return document.getElementById("b"); - if (!isFullyClipped(clipPaths.a)) return document.getElementById("a"); + if (!isFullyClipped(clipPaths.b ?? "none")) return document.getElementById("b"); + if (!isFullyClipped(clipPaths.a ?? "none")) return document.getElementById("a"); return null; }; @@ -459,7 +459,10 @@ function installContrastScript(): void { } vi.stubGlobal("Image", MockImage); - vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + 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) };