pour-engine 1.14.1 → 1.15.0
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
|
@@ -479,8 +479,9 @@ function pseudoRect(host, style) {
|
|
|
479
479
|
* on the pill, not on the track colour both other paths report.
|
|
480
480
|
*
|
|
481
481
|
* Returns { color } (opaque or translucent paint over the point),
|
|
482
|
-
*
|
|
483
|
-
*
|
|
482
|
+
* or null when no pseudo of this host paints over the point. Paint that
|
|
483
|
+
* can't be placed becomes a { film } entry — an upper bound on its alpha —
|
|
484
|
+
* so callers can bracket its effect instead of giving up on the element.
|
|
484
485
|
*/
|
|
485
486
|
/** The paint a host's pseudo-elements contribute, resolved once per audit.
|
|
486
487
|
* Two extra getComputedStyle calls per host would otherwise land on every
|
|
@@ -495,7 +496,8 @@ function pseudoLayers(host) {
|
|
|
495
496
|
for (const which of ['::before', '::after']) {
|
|
496
497
|
const style = getComputedStyle(host, which);
|
|
497
498
|
if (style.content === 'none' || style.display === 'none' || style.visibility === 'hidden') continue;
|
|
498
|
-
const
|
|
499
|
+
const color = parseColor(style.backgroundColor);
|
|
500
|
+
const paints = style.backgroundImage !== 'none' || (color?.a ?? 0) > 0;
|
|
499
501
|
if (!paints) continue;
|
|
500
502
|
// In-flow pseudos (the overwhelming majority: bullets, icons, rules,
|
|
501
503
|
// badges) occupy their own box in the content flow beside the text
|
|
@@ -503,19 +505,28 @@ function pseudoLayers(host) {
|
|
|
503
505
|
// backdrop, so everything else is simply not a layer here — treating
|
|
504
506
|
// them as unknowable would abstain on half the web.
|
|
505
507
|
if (style.position !== 'absolute' && style.position !== 'fixed') continue;
|
|
508
|
+
const opacity = parseFloat(style.opacity);
|
|
509
|
+
const opacityFactor = Number.isFinite(opacity) ? opacity : 1;
|
|
506
510
|
const rect = style.position === 'absolute' ? pseudoRect(host, style) : null;
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
511
|
+
if (!rect) {
|
|
512
|
+
// Paint we can't place still can't paint MORE than its alpha allows:
|
|
513
|
+
// whatever colour it is and wherever it sits, it moves any channel
|
|
514
|
+
// beneath it by at most alpha × 255. Record that bound as a "film"
|
|
515
|
+
// so callers can bracket the verdict — a 3.5%-opacity grain overlay
|
|
516
|
+
// (position: fixed, the common full-page-texture pattern) must not
|
|
517
|
+
// send every element on the page to a human.
|
|
518
|
+
const alphaBound = Math.min(1, opacityFactor * (style.backgroundImage !== 'none' ? 1 : color.a));
|
|
519
|
+
if (alphaBound > 0) layers.push({ film: alphaBound });
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
510
522
|
if (rect.empty) continue; // placed, and covers nothing
|
|
511
|
-
let
|
|
512
|
-
|
|
513
|
-
if (color && Number.isFinite(opacity) && opacity < 1) color = { ...color, a: color.a * opacity };
|
|
523
|
+
let layerColor = color;
|
|
524
|
+
if (layerColor && opacityFactor < 1) layerColor = { ...layerColor, a: layerColor.a * opacityFactor };
|
|
514
525
|
// Gradient and image paint rides along as CSS so callers can sample it,
|
|
515
526
|
// the same way they sample an element's background-image — a gradient
|
|
516
527
|
// scrim over a card headline is a bracketable backdrop, not a mystery.
|
|
517
528
|
const imageCss = style.backgroundImage !== 'none' ? style.backgroundImage : null;
|
|
518
|
-
layers.push({ rect, color, imageCss });
|
|
529
|
+
layers.push({ rect, color: layerColor, imageCss });
|
|
519
530
|
}
|
|
520
531
|
pseudoCache.set(host, layers);
|
|
521
532
|
return layers;
|
|
@@ -539,28 +550,75 @@ function pseudoLayers(host) {
|
|
|
539
550
|
*
|
|
540
551
|
* Returns { color } (opaque, or translucent paint to composite over the
|
|
541
552
|
* walked background), { image } for gradient/image paint the caller should
|
|
542
|
-
* sample,
|
|
543
|
-
*
|
|
553
|
+
* sample, or null when no pseudo covers the text. Whenever the chain also
|
|
554
|
+
* holds paint that CAN'T be placed, the result carries `film`: an upper
|
|
555
|
+
* bound on that paint's alpha, accumulated across every unplaceable layer.
|
|
556
|
+
* The caller brackets the verdict against it — a film of alpha a moves any
|
|
557
|
+
* channel by at most a × 255 whether it sits behind the text or over it,
|
|
558
|
+
* and paint hidden beneath an opaque layer moves nothing, so the bound
|
|
559
|
+
* holds regardless of paint order.
|
|
544
560
|
*/
|
|
545
561
|
export function pseudoBackdropForText(element) {
|
|
546
562
|
const point = textSamplePoint(element);
|
|
547
563
|
if (!point) return null;
|
|
548
564
|
let acc = null;
|
|
565
|
+
let image = null;
|
|
566
|
+
let settled = false; // an opaque colour or an image hit: deeper layers are covered
|
|
567
|
+
let film = 0;
|
|
549
568
|
for (let node = element; node && node.nodeType === 1; node = node.parentElement ?? node.getRootNode()?.host) {
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
569
|
+
for (const layer of pseudoLayers(node)) {
|
|
570
|
+
// Films accumulate over the WHOLE chain — even past an opaque hit,
|
|
571
|
+
// because an ancestor's ::after can paint above a descendant's pill.
|
|
572
|
+
if (layer.film) { film = 1 - (1 - film) * (1 - layer.film); continue; }
|
|
573
|
+
if (settled) continue;
|
|
574
|
+
const { rect, color, imageCss } = layer;
|
|
554
575
|
if (point.x < rect.left || point.x > rect.right || point.y < rect.top || point.y > rect.bottom) continue;
|
|
555
576
|
// Gradient/image paint is bracketable by sampling, so hand it to the
|
|
556
577
|
// caller rather than giving up on the whole element.
|
|
557
|
-
if (imageCss)
|
|
578
|
+
if (imageCss) { image = { css: imageCss, element: node }; settled = true; continue; }
|
|
558
579
|
if (!color || color.a === 0) continue;
|
|
559
580
|
acc = acc ? composite(acc, color) : color;
|
|
560
|
-
if (acc.a >= 1)
|
|
581
|
+
if (acc.a >= 1) settled = true;
|
|
561
582
|
}
|
|
562
583
|
}
|
|
563
|
-
return
|
|
584
|
+
if (image) return film > 0 ? { image, film } : { image };
|
|
585
|
+
if (acc) return film > 0 ? { color: acc, film } : { color: acc };
|
|
586
|
+
return film > 0 ? { film } : null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The range the contrast ratio can occupy once an unplaceable translucent
|
|
591
|
+
* layer (a "film" of alpha ≤ film) may be painting somewhere in the stack.
|
|
592
|
+
*
|
|
593
|
+
* Two physical scenarios bound everything: the film behind the glyphs
|
|
594
|
+
* (shifting only the background) or over the whole element (shifting text
|
|
595
|
+
* and background alike, as t3-style full-page grain overlays do). Within
|
|
596
|
+
* each, luminance is monotone per colour channel and the ratio's derivative
|
|
597
|
+
* carries one sign across channels, so the extremes sit at an all-black or
|
|
598
|
+
* all-white film — five evaluations bound the true range.
|
|
599
|
+
*
|
|
600
|
+
* `crossed` flags the one case the endpoints can't bound from below: a film
|
|
601
|
+
* behind the text whose extremes land the background's luminance on OPPOSITE
|
|
602
|
+
* sides of the text's, where an intermediate colour collapses the ratio
|
|
603
|
+
* toward 1:1. Callers must not assert a pass when it is set; a fail needs
|
|
604
|
+
* only `max`, which the endpoints do bound.
|
|
605
|
+
*/
|
|
606
|
+
export function filmedContrastBounds(foreground, background, film) {
|
|
607
|
+
const ratios = [contrastRatio(foreground, background)];
|
|
608
|
+
const textLum = luminance(foreground);
|
|
609
|
+
const sides = [];
|
|
610
|
+
for (const channel of [0, 255]) {
|
|
611
|
+
const paint = { r: channel, g: channel, b: channel, a: film };
|
|
612
|
+
const shiftedBackground = composite(paint, background);
|
|
613
|
+
ratios.push(contrastRatio(foreground, shiftedBackground)); // film behind the text
|
|
614
|
+
ratios.push(contrastRatio(composite(paint, foreground), shiftedBackground)); // film over text and backdrop alike
|
|
615
|
+
sides.push(Math.sign(textLum - luminance(shiftedBackground)));
|
|
616
|
+
}
|
|
617
|
+
return {
|
|
618
|
+
min: Math.min(...ratios),
|
|
619
|
+
max: Math.max(...ratios),
|
|
620
|
+
crossed: sides[0] !== sides[1],
|
|
621
|
+
};
|
|
564
622
|
}
|
|
565
623
|
|
|
566
624
|
/** A layer qualifies as a scrim over `element` when it is translucent paint
|
|
@@ -61,10 +61,20 @@ export default {
|
|
|
61
61
|
// Only NEW clipping counts: content already truncated by design
|
|
62
62
|
// (ellipsis patterns) is judged as-authored, not under the probe.
|
|
63
63
|
if (before[i] || !after[i]) return { status: 'pass' };
|
|
64
|
+
// Review, not fail: the probe proves the box OVERFLOWS under the
|
|
65
|
+
// override, not that glyphs are lost. The genuine violation is a
|
|
66
|
+
// fixed-height overflow-hidden container that doesn't grow with its
|
|
67
|
+
// text, hiding whole lines (measured live: a 2-line card clamped at
|
|
68
|
+
// 37px whose text needs 61px spaced — the last line vanishes). But
|
|
69
|
+
// the same scroll-size delta also fires on benign overflow: the
|
|
70
|
+
// 0.12em letter-spacing lands AFTER the final glyph, so a snug box
|
|
71
|
+
// can overflow by trailing empty spacing that hides no ink, and a
|
|
72
|
+
// clamp pattern that swaps hidden words for an ellipsis is arguably
|
|
73
|
+
// truncation-by-design. Proving ink loss would need per-glyph rects
|
|
74
|
+
// against the clip box under the probe; until then, a human call.
|
|
64
75
|
return {
|
|
65
|
-
status: '
|
|
66
|
-
message: 'With the WCAG text-spacing overrides applied (line height 1.5, letter/word spacing bumps),
|
|
67
|
-
fix: 'Let the container grow (min-height instead of height, avoid overflow:hidden on text), so spacing changes reflow instead of clip.',
|
|
76
|
+
status: 'incomplete',
|
|
77
|
+
message: 'With the WCAG text-spacing overrides applied (line height 1.5, letter/word spacing bumps), this container overflows instead of growing — if that hides text rather than empty trailing spacing, users who need wider spacing lose content. Check with the overrides applied; to be safe, let the container grow (min-height instead of height, avoid overflow:hidden on text).',
|
|
68
78
|
};
|
|
69
79
|
});
|
|
70
80
|
},
|
|
@@ -4,7 +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, backgroundColorSource, scrimPaint, applyOverlays,
|
|
7
|
+
pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
|
|
8
8
|
} from '../../lib/contrast.js';
|
|
9
9
|
|
|
10
10
|
/** Display a ratio truncated (never rounded up): 4.495 must read "4.49",
|
|
@@ -165,7 +165,7 @@ function verdictFromRange(range, foreground, required, what) {
|
|
|
165
165
|
*/
|
|
166
166
|
function unaccountedScrim(element, imageCarrier) {
|
|
167
167
|
const pseudo = pseudoBackdropForText(element);
|
|
168
|
-
if (pseudo
|
|
168
|
+
if (pseudo?.color?.a > 0) return true;
|
|
169
169
|
const doc = element.ownerDocument;
|
|
170
170
|
if (typeof doc.elementsFromPoint !== 'function') return false;
|
|
171
171
|
const rect = element.getBoundingClientRect();
|
|
@@ -430,12 +430,27 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
430
430
|
// wins. Geometry-only, so it settles offscreen text too, where the
|
|
431
431
|
// hit-test is blind and the walk would otherwise assert the track colour
|
|
432
432
|
// of a control the text visibly is not sitting on.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
433
|
+
//
|
|
434
|
+
// Unplaceable pseudo paint no longer abstains on sight: it arrives as
|
|
435
|
+
// `film`, an upper bound on its alpha, and the flat-colour verdict below
|
|
436
|
+
// is bracketed against it — asserted only when black and white films,
|
|
437
|
+
// behind the text or over it, all land the same side of the threshold.
|
|
438
|
+
// A 3.5%-opacity fixed grain overlay (the full-page texture pattern)
|
|
439
|
+
// otherwise sent every element on the page to the review lane, and a
|
|
440
|
+
// genuine 4.1:1 failure drowned among 163 siblings that were fine.
|
|
441
|
+
const pseudoResolved = pseudoBackdropForText(element);
|
|
442
|
+
const film = pseudoResolved?.film ?? 0;
|
|
443
|
+
const pseudoBack = pseudoResolved?.color || pseudoResolved?.image ? pseudoResolved : null;
|
|
444
|
+
const filmIncomplete = {
|
|
445
|
+
status: 'incomplete',
|
|
446
|
+
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.',
|
|
447
|
+
};
|
|
448
|
+
// Sampled pixels, veils and text-shadow halos can't be film-bracketed
|
|
449
|
+
// (the film may sit between the sample and the glyphs): those verdicts
|
|
450
|
+
// keep the human call they had when any unplaceable paint abstained.
|
|
451
|
+
if (film > 0 && (pseudoBack?.image || painted?.image || painted === 'unresolved'
|
|
452
|
+
|| veilPaint || (style.textShadow && style.textShadow !== 'none'))) {
|
|
453
|
+
return filmIncomplete;
|
|
439
454
|
}
|
|
440
455
|
// An image backdrop under a veil would have to be sampled a second time,
|
|
441
456
|
// dimmed, to answer whether the two states agree. Not worth a second pass
|
|
@@ -545,6 +560,17 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
545
560
|
};
|
|
546
561
|
|
|
547
562
|
const ratio = contrastRatio(foreground, background);
|
|
563
|
+
// Bracket the verdict against any unplaceable pseudo paint: only when
|
|
564
|
+
// every colour and placement the film could take leaves the ratio on one
|
|
565
|
+
// side of the threshold is the verdict provable. A 3.5% grain film can
|
|
566
|
+
// never rescue 4.1:1, so that fails outright; a 50% veil could rescue or
|
|
567
|
+
// ruin anything, so it stays the human call it always was.
|
|
568
|
+
if (film > 0) {
|
|
569
|
+
const bounds = filmedContrastBounds(foreground, background, film);
|
|
570
|
+
const decided = (bounds.min >= required) === (bounds.max >= required)
|
|
571
|
+
&& !(bounds.crossed && bounds.min >= required);
|
|
572
|
+
if (!decided) return filmIncomplete;
|
|
573
|
+
}
|
|
548
574
|
// The dimmed state, when a veil is up: it paints over the glyphs and
|
|
549
575
|
// their background alike, so both composite through the same layers.
|
|
550
576
|
// Dimming is not monotonic on ratio (it can rescue or ruin a pairing),
|
package/package.json
CHANGED