fix(cli): consolidate layout and contrast audit correctness (#2401)

* fix(cli): respect transparent image pixels in occlusion audit

* test(cli): cover contained image letterboxing

* fix(cli): account for text strokes in contrast checks

* fix(cli): honor text overflow opt-outs

* fix(cli): skip contrast on transparent backdrops

* chore(skills): refresh generated manifest
This commit is contained in:
Miguel Ángel
2026-07-14 01:44:58 -04:00
committed by GitHub
parent 6fc92308d6
commit b98463ae3a
7 changed files with 369 additions and 11 deletions
@@ -220,6 +220,9 @@ window.__contrastAuditPrepare = function () {
var isSvgText = isSvgTextElement(el);
var fg = isSvgText ? tryParseSolidColor(cs.fill) || parseColor(cs.color) : parseColor(cs.color);
if (fg[3] <= 0.01) continue;
var strokeWidth = parseFloat(cs.webkitTextStrokeWidth || "0");
var stroke = strokeWidth > 0 ? tryParseSolidColor(cs.webkitTextStrokeColor || "") : null;
if (stroke && stroke[3] <= 0.01) stroke = null;
var fontSize = parseFloat(cs.fontSize);
var fontWeight = Number(cs.fontWeight) || 400;
@@ -251,6 +254,13 @@ window.__contrastAuditPrepare = function () {
origFillPriority = el.style.getPropertyPriority("fill");
el.style.setProperty("fill", "transparent", "important");
}
var origStrokeColor = null,
origStrokeColorPriority = null;
if (stroke) {
origStrokeColor = el.style.getPropertyValue("-webkit-text-stroke-color");
origStrokeColorPriority = el.style.getPropertyPriority("-webkit-text-stroke-color");
el.style.setProperty("-webkit-text-stroke-color", "transparent", "important");
}
restores.push({
el: el,
origTransition: origTransition,
@@ -259,6 +269,9 @@ window.__contrastAuditPrepare = function () {
origColorPriority: origColorPriority,
origFill: origFill,
origFillPriority: origFillPriority,
origStrokeColor: origStrokeColor,
origStrokeColorPriority: origStrokeColorPriority,
hasStroke: !!stroke,
isSvgText: isSvgText,
});
@@ -266,6 +279,7 @@ window.__contrastAuditPrepare = function () {
selector: selectorOf(el),
text: (el.textContent || "").trim().slice(0, 50),
fg: fg,
stroke: stroke,
fontSize: fontSize,
fontWeight: fontWeight,
large: large,
@@ -287,6 +301,15 @@ function __contrastAuditRestoreAll() {
if (r.origFill) r.el.style.setProperty("fill", r.origFill, r.origFillPriority);
else r.el.style.removeProperty("fill");
}
if (r.hasStroke) {
if (r.origStrokeColor)
r.el.style.setProperty(
"-webkit-text-stroke-color",
r.origStrokeColor,
r.origStrokeColorPriority,
);
else r.el.style.removeProperty("-webkit-text-stroke-color");
}
if (r.origTransition)
r.el.style.setProperty("transition", r.origTransition, r.origTransitionPriority);
else r.el.style.removeProperty("transition");
@@ -371,17 +394,25 @@ window.__contrastAuditFinish = async function (imgBase64, time, candidates) {
var stepY = Math.max(1, Math.floor((y1 - y0) / 6));
var rr = [],
gg = [],
bb = [];
bb = [],
aa = [];
for (var y = y0; y <= y1; y += stepY) {
for (var x = x0; x <= x1; x += stepX) {
var idx = (y * w + x) * 4;
rr.push(px[idx]);
gg.push(px[idx + 1]);
bb.push(px[idx + 2]);
aa.push(px[idx + 3]);
}
}
if (rr.length === 0) continue;
// A transparent sampled backdrop is supplied by the downstream editor,
// not this composition. WCAG contrast cannot be inferred until that live
// video/image is known, so do not invent an opaque browser default and
// hard-fail an otherwise valid alpha overlay.
if (median(aa) < 255) continue;
var bgR = median(rr),
bgG = median(gg),
bgB = median(bb);
@@ -394,6 +425,23 @@ window.__contrastAuditFinish = async function (imgBase64, time, candidates) {
var ratio = +wcagRatio(compR, compG, compB, bgR, bgG, bgB).toFixed(2);
// A solid text stroke is part of the visible glyph paint. Use whichever
// of fill or stroke provides the stronger edge contrast, rather than
// failing readable outlined captions based on fill alone.
if (c.stroke) {
var stroke = c.stroke;
var strokeR = Math.round(stroke[0] * stroke[3] + bgR * (1 - stroke[3]));
var strokeG = Math.round(stroke[1] * stroke[3] + bgG * (1 - stroke[3]));
var strokeB = Math.round(stroke[2] * stroke[3] + bgB * (1 - stroke[3]));
var strokeRatio = +wcagRatio(strokeR, strokeG, strokeB, bgR, bgG, bgB).toFixed(2);
if (strokeRatio > ratio) {
compR = strokeR;
compG = strokeG;
compB = strokeB;
ratio = strokeRatio;
}
}
out.push({
time: time,
selector: c.selector,
@@ -105,6 +105,10 @@
return !!element.closest("[data-layout-allow-overflow]");
}
function hasTextClipOptOut(element) {
return hasAllowOverflowFlag(element) || element.hasAttribute("data-layout-bleed");
}
function opacityChain(element) {
let opacity = 1;
for (let current = element; current; current = current.parentElement) {
@@ -394,6 +398,7 @@
}
function clippedTextIssue(element, time, tolerance) {
if (hasTextClipOptOut(element)) return null;
const style = getComputedStyle(element);
if (!clipsOverflow(style)) return null;
const overflowX = element.scrollWidth - element.clientWidth;
@@ -455,7 +460,7 @@
const containerOverflow = overflowFor(textRect, containerRect, tolerance, verticalTolerance);
if (
containerOverflow &&
!hasAllowOverflowFlag(element) &&
!hasTextClipOptOut(element) &&
!clippedByAncestor(element, container)
) {
const style = elementStyle;
@@ -481,7 +486,7 @@
}
const canvasOverflow = overflowFor(textRect, rootRect, tolerance);
if (canvasOverflow && !hasAllowOverflowFlag(element)) {
if (canvasOverflow && !hasTextClipOptOut(element)) {
issues.push({
code: "canvas_overflow",
severity: "info",
@@ -730,13 +735,117 @@
const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]);
const imageAlphaCanvases = new WeakMap();
function objectPositionOffset(value, freeSpace) {
const token = String(value || "50%")
.trim()
.split(/\s+/)[0];
if (token === "left" || token === "top") return 0;
if (token === "right" || token === "bottom") return freeSpace;
if (token === "center") return freeSpace / 2;
if (token.endsWith("%")) return (freeSpace * parseFloat(token)) / 100;
const pixels = parseFloat(token);
return Number.isFinite(pixels) ? pixels : freeSpace / 2;
}
function objectPositionOffsets(value, freeX, freeY) {
const tokens = String(value || "50% 50%")
.trim()
.split(/\s+/)
.slice(0, 2);
let x = "50%";
let y = "50%";
if (tokens.length === 1) {
if (tokens[0] === "top" || tokens[0] === "bottom") y = tokens[0];
else x = tokens[0];
} else {
for (const token of tokens) {
if (token === "top" || token === "bottom") y = token;
else if (token === "left" || token === "right") x = token;
else if (x === "50%") x = token;
else y = token;
}
}
return { x: objectPositionOffset(x, freeX), y: objectPositionOffset(y, freeY) };
}
// Return the alpha painted by an <img> at a viewport point. `null` means the
// browser would not let us inspect the image (not loaded or cross-origin), in
// which case callers preserve the conservative opaque fallback.
function imageAlphaAt(element, x, y) {
const sourceWidth = element.naturalWidth;
const sourceHeight = element.naturalHeight;
const rect = element.getBoundingClientRect();
if (!sourceWidth || !sourceHeight || !rect.width || !rect.height) return null;
const style = getComputedStyle(element);
const fit = style.objectFit || "fill";
let scaleX = rect.width / sourceWidth;
let scaleY = rect.height / sourceHeight;
if (fit !== "fill") {
const contain = Math.min(scaleX, scaleY);
const cover = Math.max(scaleX, scaleY);
const scale =
fit === "cover"
? cover
: fit === "none"
? 1
: fit === "scale-down"
? Math.min(1, contain)
: contain;
scaleX = scale;
scaleY = scale;
}
const paintedWidth = sourceWidth * scaleX;
const paintedHeight = sourceHeight * scaleY;
const offsets = objectPositionOffsets(
style.objectPosition,
rect.width - paintedWidth,
rect.height - paintedHeight,
);
const localX = x - rect.left - offsets.x;
const localY = y - rect.top - offsets.y;
if (localX < 0 || localY < 0 || localX >= paintedWidth || localY >= paintedHeight) return 0;
try {
let cached = imageAlphaCanvases.get(element);
const source = element.currentSrc || element.src;
if (
!cached ||
cached.width !== sourceWidth ||
cached.height !== sourceHeight ||
cached.source !== source
) {
const canvas = document.createElement("canvas");
canvas.width = sourceWidth;
canvas.height = sourceHeight;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return null;
context.drawImage(element, 0, 0, sourceWidth, sourceHeight);
cached = { context, width: sourceWidth, height: sourceHeight, source };
imageAlphaCanvases.set(element, cached);
}
const sourceX = Math.min(sourceWidth - 1, Math.max(0, Math.floor(localX / scaleX)));
const sourceY = Math.min(sourceHeight - 1, Math.max(0, Math.floor(localY / scaleY)));
return cached.context.getImageData(sourceX, sourceY, 1, 1).data[3] / 255;
} catch {
return null;
}
}
// 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.
function isOpaqueOccluder(element) {
if (opacityChain(element) < 0.6) return false;
function isOpaqueOccluder(element, x, y) {
const opacity = opacityChain(element);
if (opacity < 0.6) return false;
if (IGNORE_TAGS.has(element.tagName)) return false;
if (element.tagName === "IMG") {
const alpha = imageAlphaAt(element, x, y);
if (alpha !== null) return alpha * opacity >= 0.6;
}
if (RASTER_TAGS.has(element.tagName)) return true;
return hasOpaqueBackground(getComputedStyle(element));
}
@@ -798,7 +907,7 @@
// Pair-specific exemptions excuse this hit only; keep walking for deeper occluders.
if (sharedPreserve3d(element, hit)) continue;
if (isCrossSceneTransitionOverlap(element, hit)) continue;
if (isOpaqueOccluder(hit)) return hit;
if (isOpaqueOccluder(hit, x, y)) return hit;
}
return null;
}
@@ -141,6 +141,49 @@ describe("layout-audit.browser", () => {
expect(runAudit()).toEqual([]);
});
it("suppresses intentional ellipsis clipping under overflow opt-outs", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="overflow-optout">
<div id="headline" style="width: 100px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">
Intentional long truncated label
</div>
</div>
</div>
`;
const headline = document.querySelector("#headline");
if (!(headline instanceof HTMLElement)) throw new Error("missing headline");
Object.defineProperties(headline, {
clientWidth: { configurable: true, value: 100 },
scrollWidth: { configurable: true, value: 240 },
clientHeight: { configurable: true, value: 20 },
scrollHeight: { configurable: true, value: 20 },
});
installGeometry(
{
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
headline: rect({ left: 40, top: 60, width: 100, height: 20 }),
text: rect({ left: 40, top: 60, width: 240, height: 20 }),
},
{
headline: { overflow: "hidden", overflowX: "hidden", overflowY: "hidden" },
},
);
installAuditScript();
const textOverflowCodes = () =>
runAudit()
.map((issue) => issue.code)
.filter((code) => code === "clipped_text" || code === "text_box_overflow");
expect(textOverflowCodes()).toEqual(["clipped_text", "text_box_overflow"]);
document.querySelector("#overflow-optout")?.setAttribute("data-layout-allow-overflow", "");
expect(textOverflowCodes()).toEqual([]);
document.querySelector("#overflow-optout")?.removeAttribute("data-layout-allow-overflow");
headline.setAttribute("data-layout-bleed", "true");
expect(textOverflowCodes()).toEqual([]);
});
it("does not flag glyph-ink vertical spill within the font-metric band on a non-clipping box", () => {
// A painted, non-clipping caption-word-like box whose glyph ink (text rect) exceeds its snug
// line-height box by a few px vertically — normal typography, nothing is clipped. (fontSize
@@ -1089,6 +1132,75 @@ describe("contrast-audit.browser background sampling", () => {
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ selector: "#label", wcagAA: true, bg: "rgb(10,10,10)" });
});
it("accepts outlined text when its stroke has adequate background contrast", async () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="caption">Outlined white caption</div>
</div>
`;
vi.spyOn(window, "getComputedStyle").mockImplementation(
() =>
({
display: "block",
visibility: "visible",
opacity: "1",
color: "rgb(255, 255, 255)",
webkitTextStrokeWidth: "8px",
webkitTextStrokeColor: "rgb(0, 0, 0)",
fontSize: "40px",
fontWeight: "700",
clipPath: "none",
}) as unknown as CSSStyleDeclaration,
);
vi.spyOn(document.getElementById("caption")!, "getBoundingClientRect").mockReturnValue(
rect({ left: 50, top: 50, width: 300, height: 60 }),
);
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
null;
installContrastScript();
const result = await runContrastAudit();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
selector: "#caption",
fg: "rgb(0,0,0)",
bg: "rgb(255,255,255)",
wcagAA: true,
});
});
it("skips text whose sampled backdrop remains transparent", async () => {
document.body.innerHTML = `
<div id="root" data-composition-id="overlay" data-width="640" data-height="360">
<span id="label">Live</span>
</div>
`;
vi.spyOn(window, "getComputedStyle").mockImplementation(
() =>
({
display: "block",
visibility: "visible",
opacity: "1",
color: "rgb(255, 255, 255)",
fontSize: "20px",
fontWeight: "700",
clipPath: "none",
}) as unknown as CSSStyleDeclaration,
);
vi.spyOn(document.getElementById("label")!, "getBoundingClientRect").mockReturnValue(
rect({ left: 50, top: 50, width: 100, height: 30 }),
);
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
null;
installContrastScript(new Uint8ClampedArray(640 * 360 * 4));
expect(await runContrastAudit()).toEqual([]);
});
});
// Both blocks overlap heavily; only the exemption on block A should suppress
@@ -1206,6 +1318,24 @@ describe("layout-audit.browser occlusion", () => {
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});
it("does not treat transparent pixels in an image as text occlusion", () => {
const issues = auditImageOcclusionScene(0);
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});
it("still treats opaque pixels in an image as text occlusion", () => {
const occluded = auditImageOcclusionScene(255).find((issue) => issue.code === "text_occluded");
expect(occluded).toMatchObject({ selector: "#headline", containerSelector: "#overlay" });
});
it("does not treat object-fit letterboxing as image occlusion", () => {
const issues = auditImageOcclusionScene(255, {
objectFit: "contain",
headlineTextRect: rect({ left: 50, top: 500, width: 200, height: 80 }),
});
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});
it("respects the data-layout-allow-occlusion opt-out", () => {
const issues = auditOcclusionScene({
headlineAttrs: "data-layout-allow-occlusion",
@@ -1476,6 +1606,39 @@ function auditOcclusionScene(options: {
return runAudit();
}
function auditImageOcclusionScene(
alpha: number,
options: { objectFit?: string; headlineTextRect?: DOMRect } = {},
): ReturnType<typeof runAudit> {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="headline">Headline copy</div>
<img id="overlay" src="paper.png" alt="" />
</div>
`;
const overlay = document.getElementById("overlay") as HTMLImageElement;
Object.defineProperties(overlay, {
naturalWidth: { configurable: true, value: 100 },
naturalHeight: { configurable: true, value: 100 },
});
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as {
mockReturnValue(value: CanvasRenderingContext2D): void;
};
getContextSpy.mockReturnValue({
drawImage: vi.fn(),
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([0, 0, 0, alpha]) })),
} as unknown as CanvasRenderingContext2D);
installOcclusionGeometry({
styleOverrides: { overlay: { objectFit: options.objectFit ?? "fill" } },
headlineTextRect:
options.headlineTextRect ?? rect({ left: 200, top: 500, width: 600, height: 80 }),
topmostId: "overlay",
});
overlay.getBoundingClientRect = () => rect({ left: 0, top: 0, width: 1920, height: 1080 });
installAuditScript();
return runAudit();
}
function installOcclusionGeometry(options: {
styleOverrides: Record<string, Partial<Record<string, string>>>;
headlineTextRect: DOMRect;
+5 -1
View File
@@ -276,7 +276,11 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise<Co
: [],
)) as ContrastCandidate[];
const screenshot = (await page.screenshot({ encoding: "base64", type: "png" })) as string;
const screenshot = (await page.screenshot({
encoding: "base64",
type: "png",
omitBackground: true,
})) as string;
const entries = await page.evaluate(
(b64: string, time: number, cands: ContrastCandidate[]) =>
typeof (window as unknown as Record<string, unknown>).__contrastAuditFinish === "function"
+5 -1
View File
@@ -510,7 +510,11 @@ async function collectContrast(
// This screenshot is the one contrast math is sampled from below — it must
// stay untouched by the annotation overlay (finishContrast reads real
// painted pixels), so annotation only ever happens on a SECOND shot.
const measurementShot = await page.screenshot({ encoding: "base64", type: "png" });
const measurementShot = await page.screenshot({
encoding: "base64",
type: "png",
omitBackground: true,
});
if (typeof measurementShot !== "string") throw new Error("Contrast screenshot was not base64");
const raw = await finishContrast(
page,