diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index a4d0163de..6ce9ccca3 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 7b45f6e7f..ab52ba9f8 100644 --- a/packages/cli/src/commands/contrast-audit.browser.js +++ b/packages/cli/src/commands/contrast-audit.browser.js @@ -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; diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 510a55c96..7a1e011b5 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -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) { diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index b6917f00a..e084637f2 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; @@ -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 = ` +