Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion packages/cli/src/commands/contrast-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -259,13 +269,17 @@ window.__contrastAuditPrepare = function () {
origColorPriority: origColorPriority,
origFill: origFill,
origFillPriority: origFillPriority,
origStrokeColor: origStrokeColor,
origStrokeColorPriority: origStrokeColorPriority,
hasStroke: !!stroke,
isSvgText: isSvgText,
});

out.push({
selector: selectorOf(el),
text: (el.textContent || "").trim().slice(0, 50),
fg: fg,
stroke: stroke,
fontSize: fontSize,
fontWeight: fontWeight,
large: large,
Expand All @@ -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");
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down
119 changes: 114 additions & 5 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -455,7 +460,7 @@
const containerOverflow = overflowFor(textRect, containerRect, tolerance, verticalTolerance);
if (
containerOverflow &&
!hasAllowOverflowFlag(element) &&
!hasTextClipOptOut(element) &&
!clippedByAncestor(element, container)
) {
const style = elementStyle;
Expand All @@ -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",
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading