pour-engine 1.10.0 → 1.12.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/COVERAGE.md
CHANGED
|
@@ -111,7 +111,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
111
111
|
|
|
112
112
|
| SC | Name | Level | Coverage | Rules |
|
|
113
113
|
|---|---|---|---|---|
|
|
114
|
-
| 4.1.2 | [Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | A | Partially automated | input-image-alt, form-label, link-name, aria-attr-valid, aria-field-name, aria-hidden-focus, aria-valid-refs, button-name, dialog-name, frame-title, label-for-valid, nested-interactive, role-required-aria, valid-role |
|
|
114
|
+
| 4.1.2 | [Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | A | Partially automated | input-image-alt, form-label, link-name, aria-allowed-attr, aria-attr-valid, aria-field-name, aria-hidden-focus, aria-valid-refs, button-name, dialog-name, frame-title, label-for-valid, nested-interactive, role-required-aria, valid-role |
|
|
115
115
|
| 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
|
|
116
116
|
|
|
117
117
|
**Totals:** 30 partially automated · 56 manual — with 74 rules.
|
package/engine/lib/contrast.js
CHANGED
|
@@ -650,7 +650,24 @@ export function paintedBackdrop(element) {
|
|
|
650
650
|
if ((style.mixBlendMode && style.mixBlendMode !== 'normal')
|
|
651
651
|
|| (style.filter && style.filter !== 'none')
|
|
652
652
|
|| (style.backdropFilter && style.backdropFilter !== 'none')) return 'unresolved';
|
|
653
|
-
|
|
653
|
+
// An ancestor carrying a background-image is only this text's backdrop if
|
|
654
|
+
// that image actually paints where the text is. The element's own image is
|
|
655
|
+
// adjudicated upstream against its paint rect; ancestors were not, so a
|
|
656
|
+
// decorative 4px accent stripe down a callout's left edge counted as the
|
|
657
|
+
// backdrop for text sitting 44px to its right, and the stripe's colour
|
|
658
|
+
// then failed the whole paragraph. Adjudicate at the same point the hit
|
|
659
|
+
// test used: if the rect resolves and does not cover it, this image is not
|
|
660
|
+
// what is behind the glyphs, so fall through to the layer's own colour.
|
|
661
|
+
// An unresolvable rect keeps the old behaviour, which errs toward asking a
|
|
662
|
+
// human rather than asserting from geometry we could not compute.
|
|
663
|
+
if (style.backgroundImage !== 'none') {
|
|
664
|
+
const rects = backgroundImagePaintRects(layer);
|
|
665
|
+
// An unresolvable layer (null) counts as covering: not knowing where a
|
|
666
|
+
// layer paints is a reason to ask a human, never to rule it out.
|
|
667
|
+
const covers = (r) => !r
|
|
668
|
+
|| (point.x >= r.left && point.x < r.right && point.y >= r.top && point.y < r.bottom);
|
|
669
|
+
if (!rects.length || rects.some(covers)) return { image: layer, scrim };
|
|
670
|
+
}
|
|
654
671
|
let color = parseColor(style.backgroundColor);
|
|
655
672
|
if (!color) return 'unresolved';
|
|
656
673
|
// A layer's own opacity thins its paint. (Group opacity shared with the
|
|
@@ -918,6 +935,100 @@ export function imageLuminanceRange(url, overlays = []) {
|
|
|
918
935
|
* clear text that never overlaps the image — e.g. external-link icons
|
|
919
936
|
* painted in a link's right padding (Wikipedia-style).
|
|
920
937
|
*/
|
|
938
|
+
/**
|
|
939
|
+
* Paint rect for EVERY layer of a background-image list, one entry per layer,
|
|
940
|
+
* null where a layer's geometry cannot be resolved.
|
|
941
|
+
*
|
|
942
|
+
* background-size/position/repeat are comma-separated lists that pair up with
|
|
943
|
+
* the image list, so a four-layer background can be four separate 1px lines —
|
|
944
|
+
* which is how design systems draw a tile's border without a border. Judging
|
|
945
|
+
* such a background as one blob meant text in the middle of a tile was ruled
|
|
946
|
+
* unjudgeable because of four hairlines around its edge.
|
|
947
|
+
*
|
|
948
|
+
* A layer resolves to null (unknown, treat as covering) when it tiles, when
|
|
949
|
+
* cover/contain hands sizing to an intrinsic this function does not have, or
|
|
950
|
+
* when its position is not a plain length or percentage.
|
|
951
|
+
*/
|
|
952
|
+
export function backgroundImagePaintRects(element) {
|
|
953
|
+
const style = getComputedStyle(element);
|
|
954
|
+
const images = splitLayers(style.backgroundImage);
|
|
955
|
+
if (!images.length) return [];
|
|
956
|
+
const box = element.getBoundingClientRect();
|
|
957
|
+
const sizes = splitLayers(style.backgroundSize);
|
|
958
|
+
const positions = splitLayers(style.backgroundPosition);
|
|
959
|
+
const repeats = splitLayers(style.backgroundRepeat);
|
|
960
|
+
const per = (list, i) => (list.length ? list[i % list.length] : undefined);
|
|
961
|
+
|
|
962
|
+
return images.map((image, i) => {
|
|
963
|
+
if (image === 'none') return { left: 0, top: 0, right: 0, bottom: 0 }; // paints nothing
|
|
964
|
+
const repeat = per(repeats, i) ?? 'repeat';
|
|
965
|
+
if (repeat !== 'no-repeat') return null; // may tile under the text
|
|
966
|
+
const size = per(sizes, i) ?? 'auto';
|
|
967
|
+
if (size === 'cover' || size === 'contain') return null;
|
|
968
|
+
const parts = splitParts(size);
|
|
969
|
+
const extent = (value, total) => {
|
|
970
|
+
if (value?.endsWith('px')) return parseFloat(value);
|
|
971
|
+
if (value?.endsWith('%')) return (parseFloat(value) / 100) * total;
|
|
972
|
+
return null; // 'auto' on a gradient means the full box, but only for a
|
|
973
|
+
// single-layer background; per-layer we decline rather than guess.
|
|
974
|
+
};
|
|
975
|
+
const width = extent(parts[0], box.width);
|
|
976
|
+
const height = extent(parts[1] ?? parts[0], box.height);
|
|
977
|
+
if (width === null || height === null
|
|
978
|
+
|| Number.isNaN(width) || Number.isNaN(height)) return null;
|
|
979
|
+
const pos = splitParts(per(positions, i) ?? '0% 0%');
|
|
980
|
+
const offset = (value, total, span) => {
|
|
981
|
+
if (value?.endsWith('%')) return (parseFloat(value) / 100) * (total - span);
|
|
982
|
+
if (value?.endsWith('px')) return parseFloat(value);
|
|
983
|
+
return null;
|
|
984
|
+
};
|
|
985
|
+
const offsetX = offset(pos[0], box.width, width);
|
|
986
|
+
const offsetY = offset(pos[1] ?? '50%', box.height, height);
|
|
987
|
+
// NaN, not just null: `calc(100% - 1px)` is a real position a design
|
|
988
|
+
// system writes, and a NaN that escapes as a rect is worse than an
|
|
989
|
+
// unresolved one — it compares false against everything, so a genuine
|
|
990
|
+
// image backdrop would be silently ruled clear of the text.
|
|
991
|
+
if (offsetX === null || offsetY === null
|
|
992
|
+
|| Number.isNaN(offsetX) || Number.isNaN(offsetY)) return null;
|
|
993
|
+
const left = box.left + offsetX;
|
|
994
|
+
const top = box.top + offsetY;
|
|
995
|
+
return { left, top, right: left + width, bottom: top + height };
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** Split a space-separated CSS value, keeping function calls whole. Splitting
|
|
1000
|
+
* `calc(100% - 1px)` on whitespace yields `calc(100%`, which parseFloat reads
|
|
1001
|
+
* as 100 and every guard downstream then trusts. */
|
|
1002
|
+
function splitParts(value) {
|
|
1003
|
+
const out = [];
|
|
1004
|
+
let depth = 0;
|
|
1005
|
+
let current = '';
|
|
1006
|
+
for (const ch of value) {
|
|
1007
|
+
if (ch === '(') depth += 1;
|
|
1008
|
+
if (ch === ')') depth -= 1;
|
|
1009
|
+
if (/\s/.test(ch) && depth === 0) { if (current) out.push(current); current = ''; continue; }
|
|
1010
|
+
current += ch;
|
|
1011
|
+
}
|
|
1012
|
+
if (current) out.push(current);
|
|
1013
|
+
return out;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** Split a comma-separated CSS list, ignoring commas inside parentheses. */
|
|
1017
|
+
function splitLayers(value) {
|
|
1018
|
+
if (!value || value === 'none') return value === 'none' ? ['none'] : [];
|
|
1019
|
+
const out = [];
|
|
1020
|
+
let depth = 0;
|
|
1021
|
+
let current = '';
|
|
1022
|
+
for (const ch of value) {
|
|
1023
|
+
if (ch === '(') depth += 1;
|
|
1024
|
+
if (ch === ')') depth -= 1;
|
|
1025
|
+
if (ch === ',' && depth === 0) { out.push(current.trim()); current = ''; continue; }
|
|
1026
|
+
current += ch;
|
|
1027
|
+
}
|
|
1028
|
+
if (current.trim()) out.push(current.trim());
|
|
1029
|
+
return out;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
921
1032
|
export function backgroundImagePaintRect(element, intrinsic) {
|
|
922
1033
|
const style = getComputedStyle(element);
|
|
923
1034
|
if ((style.backgroundImage.match(/url\(|gradient\(/g) ?? []).length !== 1) return null;
|
package/engine/rules/index.js
CHANGED
|
@@ -42,10 +42,13 @@ import audioControl from './wcag/1.4.2-audio-control.js';
|
|
|
42
42
|
import pauseStopHide from './wcag/2.2.2-pause-stop-hide.js';
|
|
43
43
|
import mediaCaptions from './wcag/1.2.2-media-captions.js';
|
|
44
44
|
import ariaAttrValid from './wcag/4.1.2-aria-attr-valid.js';
|
|
45
|
-
//
|
|
45
|
+
// Reviews rather than asserts: an unsupported ARIA attribute breaks an ARIA
|
|
46
46
|
// author MUST (1.2 §8.6) but is INERT, so name, role and value all survive
|
|
47
|
-
// and no success criterion fails
|
|
48
|
-
|
|
47
|
+
// and no success criterion provably fails. It sits in the WCAG scope anyway,
|
|
48
|
+
// because a real authoring error that a browser silently discards should not
|
|
49
|
+
// be invisible in a default audit, and it asks whether the state is real
|
|
50
|
+
// instead of claiming a failure (see the note in the rule file).
|
|
51
|
+
import ariaAllowedAttr from './wcag/4.1.2-aria-allowed-attr.js';
|
|
49
52
|
import ariaFieldName from './wcag/4.1.2-aria-field-name.js';
|
|
50
53
|
// Demoted to best-practice: prohibited-but-inert ARIA on a generic element
|
|
51
54
|
// fails no success criterion (see the note in the rule file).
|
|
@@ -3,19 +3,25 @@
|
|
|
3
3
|
// state or property". An unsupported attribute breaks that MUST, so this is a
|
|
4
4
|
// real, provable authoring error and the rule asserts it.
|
|
5
5
|
//
|
|
6
|
-
// It is
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// is all 4.1.2 asks for.
|
|
6
|
+
// It is not, on its own, a WCAG failure. An unsupported attribute is INERT,
|
|
7
|
+
// measured in Chromium: aria-expanded on a button exposes expanded=true,
|
|
8
|
+
// while the same attribute on complementary, heading or list exposes no
|
|
9
|
+
// state at all. It is dropped, not misreported. So the role is still
|
|
10
|
+
// exposed, the name is still computed, and nothing is announced incorrectly.
|
|
11
|
+
// Name, role and value all survive, which is all 4.1.2 asks for.
|
|
13
12
|
//
|
|
14
13
|
// What CAN fail 4.1.2 is the thing underneath: if the element genuinely has
|
|
15
|
-
// the state the author reached for, that state is now invisible
|
|
16
|
-
// does is exactly what the DOM cannot establish
|
|
17
|
-
//
|
|
18
|
-
//
|
|
14
|
+
// the state the author reached for, that state is now invisible to a screen
|
|
15
|
+
// reader. Whether it does is exactly what the DOM cannot establish.
|
|
16
|
+
//
|
|
17
|
+
// So this sits in the WCAG scope and REVIEWS rather than asserts. It lived
|
|
18
|
+
// under best-practice before, which was defensible on the letter of 4.1.2
|
|
19
|
+
// and wrong in practice: teams act on what a tool marks as WCAG, so a real
|
|
20
|
+
// ARIA error that a browser silently discards went unseen in a default
|
|
21
|
+
// audit. Asserting a violation would state a criterion failure the browser
|
|
22
|
+
// does not actually cause; staying silent leaves a genuine authoring error
|
|
23
|
+
// unreported. Asking is the honest third option, and it is the one the
|
|
24
|
+
// engine is built around.
|
|
19
25
|
//
|
|
20
26
|
// Note "unsupported" is distinct from "prohibited" (§5.2.5), a separate
|
|
21
27
|
// category about naming that this rule does not cover.
|
|
@@ -34,9 +40,9 @@ const HANDLED_ELSEWHERE = new Set(['label', 'labelledby']);
|
|
|
34
40
|
export default {
|
|
35
41
|
id: 'aria-allowed-attr',
|
|
36
42
|
impact: 'moderate',
|
|
37
|
-
tags: ['
|
|
43
|
+
tags: ['wcag2a', 'wcag412'],
|
|
38
44
|
help: 'ARIA attributes must be supported by the element’s role',
|
|
39
|
-
helpUrl: 'https://www.w3.org/
|
|
45
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
40
46
|
selector: '*',
|
|
41
47
|
visibleOnly: false,
|
|
42
48
|
evaluate(element) {
|
|
@@ -60,8 +66,8 @@ export default {
|
|
|
60
66
|
if (!disallowed.length) return { status: 'pass' };
|
|
61
67
|
const names = disallowed.map((n) => `aria-${n}`);
|
|
62
68
|
return {
|
|
63
|
-
status: '
|
|
64
|
-
message: `${names.join(', ')} is not supported on role "${role}", so the browser drops it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all.
|
|
69
|
+
status: 'incomplete',
|
|
70
|
+
message: `${names.join(', ')} is not supported on role "${role}", so the browser drops it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. Does this element really have that state? If it does, it is invisible to a screen reader and this is a 4.1.2 failure. If it does not, the attribute is simply stray and no one is affected.`,
|
|
65
71
|
fix: `Move ${names.join('/')} to the element whose role supports it, usually the control that toggles this one, or remove it if the element has no such state. Adding a role to this element to make the attribute legal is rarely right: the host language restricts which roles each element may take.`,
|
|
66
72
|
};
|
|
67
73
|
},
|
package/package.json
CHANGED