diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 5c832dbf78..cec24bbc87 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1276,6 +1276,33 @@ // An element hides text beneath it when it paints opaque pixels at near-full // opacity: raster content (img/video/canvas), a background image, or a solid // background colour. Low-opacity overlays (grain, scrims) do not occlude. + const canvasProbe = document.createElement("canvas"); + canvasProbe.width = 1; + canvasProbe.height = 1; + + // The drawn alpha under the point, or null when the canvas cannot be read (then it counts as opaque). + function canvasAlphaAt(element, x, y) { + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0 || !element.width || !element.height) return null; + const px = Math.min( + element.width - 1, + Math.max(0, Math.floor(((x - rect.left) / rect.width) * element.width)), + ); + const py = Math.min( + element.height - 1, + Math.max(0, Math.floor(((y - rect.top) / rect.height) * element.height)), + ); + try { + const context = canvasProbe.getContext("2d", { willReadFrequently: true }); + if (!context) return null; + context.clearRect(0, 0, 1, 1); + context.drawImage(element, px, py, 1, 1, 0, 0, 1, 1); + return context.getImageData(0, 0, 1, 1).data[3] / 255; + } catch { + return null; + } + } + function isOpaqueOccluder(element, x, y) { const opacity = opacityChain(element); if (opacity < OCCLUDING_ALPHA) return false; @@ -1284,6 +1311,11 @@ const alpha = imageAlphaAt(element, x, y); if (alpha !== null) return alpha * opacity >= OCCLUDING_ALPHA; } + if (element.tagName === "CANVAS") { + const alpha = canvasAlphaAt(element, x, y); + if (alpha !== null) + return alpha * opacity >= OCCLUDING_ALPHA || hasOpaqueBackground(getComputedStyle(element)); + } const matte = matteFrameFor(element); if (matte) return matteOccludesAt(matte, x, y); if (RASTER_TAGS.has(element.tagName)) return true; diff --git a/packages/cli/src/commands/layout-audit.chromium.test.ts b/packages/cli/src/commands/layout-audit.chromium.test.ts index bcb8ed2eb6..4e0318b38f 100644 --- a/packages/cli/src/commands/layout-audit.chromium.test.ts +++ b/packages/cli/src/commands/layout-audit.chromium.test.ts @@ -427,6 +427,34 @@ describe.runIf(executablePath)("layout audit in Chromium", () => { expect(codes.includes("content_overlap")).toBe(overlap); }); + it.each([ + { name: "transparent", fill: "", css: "", covered: false }, + { + name: "opaque", + fill: "ctx.fillStyle='#123';ctx.fillRect(0,0,1920,1080);", + css: "", + covered: true, + }, + { + name: "glyph-only with an opaque CSS background", + fill: "", + css: "background:#112233;", + covered: true, + }, + ])( + "judges a full-frame $name canvas over a caption by what it draws", + async ({ fill, css, covered }) => { + const codes = await auditCodes(` +
+
+

Caption

+ + +
`); + expect(codes.includes("canvas_covered")).toBe(covered); + }, + ); + describe("craft sampling", () => { async function sample(body: string) { const page = await browser.newPage();