pour-engine 1.1.5 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/config/project.config.js
CHANGED
package/engine/lib/contrast.js
CHANGED
|
@@ -52,6 +52,7 @@ let opacityCache = new WeakMap();
|
|
|
52
52
|
let opacityAnimatorsCache = null;
|
|
53
53
|
let mediaRectsCache = null;
|
|
54
54
|
let panelRectsCache = null;
|
|
55
|
+
let pseudoCache = new WeakMap();
|
|
55
56
|
const HAS_IMAGE = Symbol('background-image in chain');
|
|
56
57
|
|
|
57
58
|
export function resetAuditCaches() {
|
|
@@ -60,6 +61,7 @@ export function resetAuditCaches() {
|
|
|
60
61
|
opacityAnimatorsCache = null;
|
|
61
62
|
mediaRectsCache = null;
|
|
62
63
|
panelRectsCache = null;
|
|
64
|
+
pseudoCache = new WeakMap();
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
function canvasColor(doc) {
|
|
@@ -278,6 +280,186 @@ function textSamplePoint(element) {
|
|
|
278
280
|
* missed. The ancestor walk (which callers still run) covers the common
|
|
279
281
|
* cases of that shape.
|
|
280
282
|
*/
|
|
283
|
+
const px = (value) => (typeof value === 'string' && value.endsWith('px') ? parseFloat(value) : NaN);
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The viewport rect a positioned pseudo-element paints into, or null when
|
|
287
|
+
* its geometry isn't statically resolvable.
|
|
288
|
+
*
|
|
289
|
+
* An absolutely positioned box resolves against its containing block's
|
|
290
|
+
* PADDING box, which is the nearest ancestor that establishes one — usually
|
|
291
|
+
* the host itself (authors nearly always set position: relative for exactly
|
|
292
|
+
* this), but not always, so the chain is walked rather than assumed.
|
|
293
|
+
* Non-px insets, rotations and scales return null, which makes callers
|
|
294
|
+
* abstain rather than guess a rect.
|
|
295
|
+
*/
|
|
296
|
+
function containingBlockFor(host) {
|
|
297
|
+
for (let node = host; node && node.nodeType === 1; node = node.parentElement) {
|
|
298
|
+
const style = getComputedStyle(node);
|
|
299
|
+
// Transforms, filters and containment make an element a containing block
|
|
300
|
+
// for its absolutely positioned descendants just as positioning does.
|
|
301
|
+
if (style.position !== 'static' || style.transform !== 'none'
|
|
302
|
+
|| style.filter !== 'none' || /paint|layout|strict|content/.test(style.contain ?? '')) return node;
|
|
303
|
+
}
|
|
304
|
+
return null; // the initial containing block — viewport-relative, not resolved here
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function pseudoRect(host, style) {
|
|
308
|
+
const block = containingBlockFor(host);
|
|
309
|
+
if (!block) return null;
|
|
310
|
+
const hostStyle = getComputedStyle(block);
|
|
311
|
+
// A zero-extent containing block is still a valid ORIGIN — a collapsed
|
|
312
|
+
// positioned wrapper whose pseudo paints outside it is ordinary CSS. Only
|
|
313
|
+
// the opposing-inset derivation needs a real extent, and the degenerate
|
|
314
|
+
// boxes that produces are caught by the sanity check at the end.
|
|
315
|
+
const hostRect = block.getBoundingClientRect();
|
|
316
|
+
|
|
317
|
+
// Absolute positioning resolves against the containing block's PADDING box.
|
|
318
|
+
const borderLeft = px(hostStyle.borderLeftWidth) || 0;
|
|
319
|
+
const borderTop = px(hostStyle.borderTopWidth) || 0;
|
|
320
|
+
const cbLeft = hostRect.left + borderLeft;
|
|
321
|
+
const cbTop = hostRect.top + borderTop;
|
|
322
|
+
const cbWidth = hostRect.width - borderLeft - (px(hostStyle.borderRightWidth) || 0);
|
|
323
|
+
const cbHeight = hostRect.height - borderTop - (px(hostStyle.borderBottomWidth) || 0);
|
|
324
|
+
|
|
325
|
+
// getComputedStyle reports the CONTENT box, so padding and border have to
|
|
326
|
+
// be added back to get the extent that actually paints.
|
|
327
|
+
const extraX = (px(style.paddingLeft) || 0) + (px(style.paddingRight) || 0)
|
|
328
|
+
+ (px(style.borderLeftWidth) || 0) + (px(style.borderRightWidth) || 0);
|
|
329
|
+
const extraY = (px(style.paddingTop) || 0) + (px(style.paddingBottom) || 0)
|
|
330
|
+
+ (px(style.borderTopWidth) || 0) + (px(style.borderBottomWidth) || 0);
|
|
331
|
+
|
|
332
|
+
// An opposing inset pair pins both edges directly; otherwise one inset plus
|
|
333
|
+
// the used size does. `auto` on both sides leaves the box where static flow
|
|
334
|
+
// would have put it — not resolvable here.
|
|
335
|
+
const span = (startValue, endValue, sizeValue, origin, extent, extra) => {
|
|
336
|
+
const start = px(startValue);
|
|
337
|
+
const end = px(endValue);
|
|
338
|
+
const size = px(sizeValue);
|
|
339
|
+
if (Number.isFinite(start) && Number.isFinite(end)) return [origin + start, origin + extent - end];
|
|
340
|
+
if (Number.isFinite(start) && Number.isFinite(size)) return [origin + start, origin + start + size + extra];
|
|
341
|
+
if (Number.isFinite(end) && Number.isFinite(size)) {
|
|
342
|
+
const far = origin + extent - end;
|
|
343
|
+
return [far - size - extra, far];
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
};
|
|
347
|
+
const x = span(style.left, style.right, style.width, cbLeft, cbWidth, extraX);
|
|
348
|
+
const y = span(style.top, style.bottom, style.height, cbTop, cbHeight, extraY);
|
|
349
|
+
if (!x || !y) return null;
|
|
350
|
+
|
|
351
|
+
let [left, right] = x;
|
|
352
|
+
let [top, bottom] = y;
|
|
353
|
+
if (style.transform && style.transform !== 'none') {
|
|
354
|
+
// Only a pure translation keeps the box axis-aligned and the same size;
|
|
355
|
+
// rotation/scale/skew would need real geometry, so they stay unresolved.
|
|
356
|
+
const matrix = /^matrix\(([^)]+)\)$/.exec(style.transform);
|
|
357
|
+
if (!matrix) return null;
|
|
358
|
+
const [a, b, c, d, e, f] = matrix[1].split(',').map(Number);
|
|
359
|
+
if (a !== 1 || b !== 0 || c !== 0 || d !== 1) return null;
|
|
360
|
+
left += e; right += e; top += f; bottom += f;
|
|
361
|
+
}
|
|
362
|
+
// A degenerate box is not unknowable — it is a box that paints nothing
|
|
363
|
+
// (collapsed separator rules are usually exactly this: 1px wide, 0 tall).
|
|
364
|
+
// Callers skip it rather than abstaining on the whole element.
|
|
365
|
+
return { left, top, right, bottom, empty: !(right > left) || !(bottom > top) };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The backdrop a host's own ::before/::after paints over `point`.
|
|
370
|
+
*
|
|
371
|
+
* This is the one resolution path that can see pseudo-element paint at all:
|
|
372
|
+
* they carry no DOM node, so the ancestor walk (which reads element computed
|
|
373
|
+
* styles) and elementsFromPoint (which retargets them to their host) are both
|
|
374
|
+
* structurally blind to them. The sliding indicator pill behind a selected
|
|
375
|
+
* segmented-control option is the canonical case — the text really does sit
|
|
376
|
+
* on the pill, not on the track colour both other paths report.
|
|
377
|
+
*
|
|
378
|
+
* Returns { color } (opaque or translucent paint over the point),
|
|
379
|
+
* 'unresolved' when something paints there that flat colour math can't
|
|
380
|
+
* follow, or null when no pseudo of this host paints over the point.
|
|
381
|
+
*/
|
|
382
|
+
/** The paint a host's pseudo-elements contribute, resolved once per audit.
|
|
383
|
+
* Two extra getComputedStyle calls per host would otherwise land on every
|
|
384
|
+
* text element on the page; cached, the per-point test below is rect math. */
|
|
385
|
+
function pseudoLayers(host) {
|
|
386
|
+
let layers = pseudoCache.get(host);
|
|
387
|
+
if (layers !== undefined) return layers;
|
|
388
|
+
layers = [];
|
|
389
|
+
// Paint order within a host: background, ::before, content, ::after — so a
|
|
390
|
+
// later entry covers an earlier one, and both sit above the host's own
|
|
391
|
+
// background but below its text.
|
|
392
|
+
for (const which of ['::before', '::after']) {
|
|
393
|
+
const style = getComputedStyle(host, which);
|
|
394
|
+
if (style.content === 'none' || style.display === 'none' || style.visibility === 'hidden') continue;
|
|
395
|
+
const paints = style.backgroundImage !== 'none' || (parseColor(style.backgroundColor)?.a ?? 0) > 0;
|
|
396
|
+
if (!paints) continue;
|
|
397
|
+
// In-flow pseudos (the overwhelming majority: bullets, icons, rules,
|
|
398
|
+
// badges) occupy their own box in the content flow beside the text
|
|
399
|
+
// rather than painting behind it. Only out-of-flow ones can be the
|
|
400
|
+
// backdrop, so everything else is simply not a layer here — treating
|
|
401
|
+
// them as unknowable would abstain on half the web.
|
|
402
|
+
if (style.position !== 'absolute' && style.position !== 'fixed') continue;
|
|
403
|
+
const rect = style.position === 'absolute' ? pseudoRect(host, style) : null;
|
|
404
|
+
// Paint we can't place can't be ruled in or out — say so rather than
|
|
405
|
+
// silently leaving it out of the stack.
|
|
406
|
+
if (!rect) { layers = 'unresolved'; break; }
|
|
407
|
+
if (rect.empty) continue; // placed, and covers nothing
|
|
408
|
+
let color = parseColor(style.backgroundColor);
|
|
409
|
+
const opacity = parseFloat(style.opacity);
|
|
410
|
+
if (color && Number.isFinite(opacity) && opacity < 1) color = { ...color, a: color.a * opacity };
|
|
411
|
+
// Gradient and image paint rides along as CSS so callers can sample it,
|
|
412
|
+
// the same way they sample an element's background-image — a gradient
|
|
413
|
+
// scrim over a card headline is a bracketable backdrop, not a mystery.
|
|
414
|
+
const imageCss = style.backgroundImage !== 'none' ? style.backgroundImage : null;
|
|
415
|
+
layers.push({ rect, color, imageCss });
|
|
416
|
+
}
|
|
417
|
+
pseudoCache.set(host, layers);
|
|
418
|
+
return layers;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The nearest pseudo-element paint covering this element's text, walking the
|
|
423
|
+
* flat tree upward: an out-of-flow ::before/::after paints above its host's
|
|
424
|
+
* background but below its host's descendants, so the first one that covers
|
|
425
|
+
* the text is the real backdrop.
|
|
426
|
+
*
|
|
427
|
+
* This is the only path that can see pseudo-element paint at all — they carry
|
|
428
|
+
* no DOM node, so the ancestor walk (element computed styles) and
|
|
429
|
+
* elementsFromPoint (which retargets them to their host) are both
|
|
430
|
+
* structurally blind. The sliding indicator behind a selected
|
|
431
|
+
* segmented-control option is the canonical case: the text really does sit on
|
|
432
|
+
* the pill, not on the track colour both other paths report.
|
|
433
|
+
*
|
|
434
|
+
* Pure geometry, deliberately: unlike the hit-test it needs no viewport, so
|
|
435
|
+
* it answers for text scrolled far off screen too.
|
|
436
|
+
*
|
|
437
|
+
* Returns { color } (opaque, or translucent paint to composite over the
|
|
438
|
+
* walked background), { image } for gradient/image paint the caller should
|
|
439
|
+
* sample, 'unresolved' when paint in the chain can't be placed, or null when
|
|
440
|
+
* no pseudo covers the text.
|
|
441
|
+
*/
|
|
442
|
+
export function pseudoBackdropForText(element) {
|
|
443
|
+
const point = textSamplePoint(element);
|
|
444
|
+
if (!point) return null;
|
|
445
|
+
let acc = null;
|
|
446
|
+
for (let node = element; node && node.nodeType === 1; node = node.parentElement ?? node.getRootNode()?.host) {
|
|
447
|
+
const layers = pseudoLayers(node);
|
|
448
|
+
// Paint we can't place can't be ruled in or out, in either direction.
|
|
449
|
+
if (layers === 'unresolved') return 'unresolved';
|
|
450
|
+
for (const { rect, color, imageCss } of layers) {
|
|
451
|
+
if (point.x < rect.left || point.x > rect.right || point.y < rect.top || point.y > rect.bottom) continue;
|
|
452
|
+
// Gradient/image paint is bracketable by sampling, so hand it to the
|
|
453
|
+
// caller rather than giving up on the whole element.
|
|
454
|
+
if (imageCss) return { image: { css: imageCss, element: node } };
|
|
455
|
+
if (!color || color.a === 0) continue;
|
|
456
|
+
acc = acc ? composite(acc, color) : color;
|
|
457
|
+
if (acc.a >= 1) return { color: acc };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return acc ? { color: acc } : null;
|
|
461
|
+
}
|
|
462
|
+
|
|
281
463
|
/** A layer qualifies as a scrim over `element` when it is translucent paint
|
|
282
464
|
* (or a backdrop-filter) covering most of the viewport and the whole
|
|
283
465
|
* element — the shape of a modal/loading veil, not a badge or header. */
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
backgroundImageSource, backgroundImagePaintRect, imageLuminanceRange, gradientLuminanceRange,
|
|
5
5
|
rangeVerdict, isLargeText, cumulativeOpacity, opacityAnimating, restingOpacity, mediaRects,
|
|
6
6
|
paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
|
|
7
|
+
pseudoBackdropForText,
|
|
7
8
|
} from '../../lib/contrast.js';
|
|
8
9
|
|
|
9
10
|
/** Display a ratio truncated (never rounded up): 4.495 must read "4.49",
|
|
@@ -302,7 +303,28 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
302
303
|
};
|
|
303
304
|
}
|
|
304
305
|
}
|
|
305
|
-
|
|
306
|
+
// Pseudo-element paint sits above everything the hit-test and the walk
|
|
307
|
+
// can report, and neither can see it — so it is resolved before them and
|
|
308
|
+
// wins. Geometry-only, so it settles offscreen text too, where the
|
|
309
|
+
// hit-test is blind and the walk would otherwise assert the track colour
|
|
310
|
+
// of a control the text visibly is not sitting on.
|
|
311
|
+
const pseudoBack = pseudoBackdropForText(element);
|
|
312
|
+
if (pseudoBack === 'unresolved') {
|
|
313
|
+
return {
|
|
314
|
+
status: 'incomplete',
|
|
315
|
+
message: 'A pseudo-element with its own background paints in this element\'s chain, but its position can\'t be computed — so whether it sits behind this text is unknown. Check the contrast by eye.',
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
// Gradient/image paint from a pseudo-element: bracket it by sampling,
|
|
319
|
+
// exactly as an element's own background-image is bracketed.
|
|
320
|
+
if (pseudoBack?.image) {
|
|
321
|
+
const sampled = await sampledVerdict(pseudoBack.image, foreground, required, doc);
|
|
322
|
+
return sampled ?? {
|
|
323
|
+
status: 'incomplete',
|
|
324
|
+
message: 'A pseudo-element paints an image or gradient behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (painted?.image && !pseudoBack) {
|
|
306
328
|
const sampled = await sampledVerdict(painted.image, foreground, required, doc);
|
|
307
329
|
return sampled ?? {
|
|
308
330
|
status: 'incomplete',
|
|
@@ -330,6 +352,15 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
330
352
|
};
|
|
331
353
|
}
|
|
332
354
|
}
|
|
355
|
+
if (pseudoBack?.color) {
|
|
356
|
+
// Opaque pill: it IS the backdrop. Translucent paint composites over
|
|
357
|
+
// whatever the walk resolved beneath it. Either way the geometry is
|
|
358
|
+
// computed, not guessed, so this counts as verified paint truth.
|
|
359
|
+
background = pseudoBack.color.a >= 1 || !background
|
|
360
|
+
? pseudoBack.color
|
|
361
|
+
: composite(pseudoBack.color, background);
|
|
362
|
+
backgroundVerified = true;
|
|
363
|
+
}
|
|
333
364
|
if (!background) {
|
|
334
365
|
return { status: 'incomplete', message: 'The background could not be determined — check contrast by eye.' };
|
|
335
366
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
-
//
|
|
2
|
+
// Concrete, usable roles — abstract roles are invalid in markup.
|
|
3
3
|
const VALID_ROLES = new Set([
|
|
4
4
|
'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button',
|
|
5
5
|
'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'complementary',
|
|
@@ -12,6 +12,14 @@ const VALID_ROLES = new Set([
|
|
|
12
12
|
'slider', 'spinbutton', 'status', 'strong', 'subscript', 'superscript', 'switch', 'tab',
|
|
13
13
|
'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer', 'toolbar', 'tooltip',
|
|
14
14
|
'tree', 'treegrid', 'treeitem',
|
|
15
|
+
// ARIA 1.3 additions. `image` is the spec's own primary spelling with
|
|
16
|
+
// `img` kept as the synonym — img is the one ARIA role that isn't a whole
|
|
17
|
+
// word, which is why authors reach for `image`, and Safari (2021),
|
|
18
|
+
// Firefox (116) and Chromium all map it. Flagging it was a false positive
|
|
19
|
+
// on real sites; verified against Chromium's accessibility tree, where
|
|
20
|
+
// role="image" resolves to image and only a genuinely bogus role falls
|
|
21
|
+
// back to generic.
|
|
22
|
+
'image', 'comment', 'mark', 'suggestion',
|
|
15
23
|
]);
|
|
16
24
|
|
|
17
25
|
export default {
|
package/package.json
CHANGED