diff --git a/packages/cli/src/commands/contrast-audit.browser.js b/packages/cli/src/commands/contrast-audit.browser.js
index e11c50947b..2402abc0d2 100644
--- a/packages/cli/src/commands/contrast-audit.browser.js
+++ b/packages/cli/src/commands/contrast-audit.browser.js
@@ -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,
diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js
index 1755e4973e..81ca084f96 100644
--- a/packages/cli/src/commands/layout-audit.browser.js
+++ b/packages/cli/src/commands/layout-audit.browser.js
@@ -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 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;
}
diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts
index 3034df101a..8494847fa4 100644
--- a/packages/cli/src/commands/layout-audit.browser.test.ts
+++ b/packages/cli/src/commands/layout-audit.browser.test.ts
@@ -141,6 +141,49 @@ describe("layout-audit.browser", () => {
expect(runAudit()).toEqual([]);
});
+ it("suppresses intentional ellipsis clipping under overflow opt-outs", () => {
+ document.body.innerHTML = `
+
+