pour-engine 1.43.0 → 1.44.1

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.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.43.0',
9
+ version: '1.44.1',
10
10
  },
11
11
  };
@@ -143,6 +143,8 @@ let firstLineRulesCache = new WeakMap();
143
143
  let chainEffectCache = new WeakMap();
144
144
  let uncoveredEffectCache = new WeakMap();
145
145
  let blendBackdropCache = new WeakMap();
146
+ let labelReferrersCache = new WeakMap();
147
+ let stackingContextCache = new WeakMap();
146
148
  const HAS_IMAGE = Symbol('background-image in chain');
147
149
 
148
150
  export function resetAuditCaches() {
@@ -157,6 +159,35 @@ export function resetAuditCaches() {
157
159
  chainEffectCache = new WeakMap();
158
160
  uncoveredEffectCache = new WeakMap();
159
161
  blendBackdropCache = new WeakMap();
162
+ labelReferrersCache = new WeakMap();
163
+ stackingContextCache = new WeakMap();
164
+ }
165
+
166
+ /**
167
+ * Who names themselves from each id through aria-labelledby, per root
168
+ * (document or shadow root): how many of the referrers are inactive widgets
169
+ * and how many are anything else. Counted once per audit, because the
170
+ * contrast rule asks for every element with text of its own and a query
171
+ * over the whole page for each would make that walk quadratic. `isInactive`
172
+ * is the rule's own reading of a switched-off widget, so this library does
173
+ * not carry a second one.
174
+ */
175
+ export function labelReferrers(root, isInactive) {
176
+ let referrers = labelReferrersCache.get(root);
177
+ if (!referrers) {
178
+ referrers = new Map();
179
+ for (const referrer of root.querySelectorAll('[aria-labelledby]')) {
180
+ const inactive = isInactive(referrer);
181
+ for (const id of referrer.getAttribute('aria-labelledby').split(/\s+/)) {
182
+ if (!id) continue;
183
+ const count = referrers.get(id) ?? { inactive: 0, other: 0 };
184
+ count[inactive ? 'inactive' : 'other'] += 1;
185
+ referrers.set(id, count);
186
+ }
187
+ }
188
+ labelReferrersCache.set(root, referrers);
189
+ }
190
+ return referrers;
160
191
  }
161
192
 
162
193
  const flatParentOf = (node) => node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host ?? null;
@@ -778,6 +809,21 @@ function pseudoLayers(host) {
778
809
  continue;
779
810
  }
780
811
  if (rect.empty) continue; // placed, and covers nothing
812
+ // A mask or a clip-path cuts the box down to a shape, and how much of
813
+ // the box that shape leaves is not computed here. The box covering the
814
+ // text then proves nothing about the paint doing so: a struck-through
815
+ // price drawn as a text-sized red ::after masked to one diagonal stroke
816
+ // was read as black text on red, 3.21:1, where the pixels are black on
817
+ // white at 18.88:1 (twelve false failures on one retailer's home page,
818
+ // 2026-09-21). Where the box misses the text it still paints nothing
819
+ // there, so the rect is kept, and the walk turns the layer into a film
820
+ // only when it covers the sample point.
821
+ const mask = style.maskImage || style.webkitMaskImage;
822
+ if ((mask && mask !== 'none') || (style.clipPath && style.clipPath !== 'none')) {
823
+ const alphaBound = Math.min(1, opacityFactor * (style.backgroundImage !== 'none' ? 1 : color.a));
824
+ if (alphaBound > 0) layers.push({ rect, shaped: alphaBound });
825
+ continue;
826
+ }
781
827
  let layerColor = color;
782
828
  if (layerColor && opacityFactor < 1) layerColor = { ...layerColor, a: layerColor.a * opacityFactor };
783
829
  // Gradient and image paint rides along as CSS so callers can sample it,
@@ -829,6 +875,7 @@ export function pseudoBackdropForText(element) {
829
875
  let image = null;
830
876
  let settled = false; // an opaque colour or an image hit: deeper layers are covered
831
877
  let film = 0;
878
+ let shaped = false; // some of that film is a masked or clipped box over the text
832
879
  // Paint (an opaque background-color or a background-image) on an element
833
880
  // BETWEEN the text and a pseudo's host makes the pseudo's place in the
834
881
  // stack unprovable from here: CSS paint order can put an out-of-flow
@@ -859,6 +906,15 @@ export function pseudoBackdropForText(element) {
859
906
  if (settled) continue;
860
907
  const { rect, color, imageCss } = layer;
861
908
  if (point.x < rect.left || point.x > rect.right || point.y < rect.top || point.y > rect.bottom) continue;
909
+ // A masked or clipped box over the text: paint of unknown extent,
910
+ // bounded by its alpha like any other film. It is placed, so opaque
911
+ // paint nearer the text says nothing about it and `crossed` is not
912
+ // consulted.
913
+ if (layer.shaped) {
914
+ film = 1 - (1 - film) * (1 - layer.shaped);
915
+ shaped = true;
916
+ continue;
917
+ }
862
918
  // Gradient/image paint is bracketable by sampling, so hand it to the
863
919
  // caller rather than giving up on the whole element. The pseudo's box
864
920
  // and image geometry travel with it: covering the sample point is a
@@ -884,9 +940,10 @@ export function pseudoBackdropForText(element) {
884
940
  || (parseColor(style.backgroundColor)?.a ?? 0) >= 1) crossed = true;
885
941
  }
886
942
  }
887
- if (image) return { image, ...(film > 0 && { film }), ...(beyondPaint && { beyondPaint }) };
888
- if (acc) return { color: acc, ...(film > 0 && { film }), ...(beyondPaint && { beyondPaint }) };
889
- return film > 0 ? { film } : null;
943
+ const filmed = film > 0 && { film, ...(shaped && { shaped }) };
944
+ if (image) return { image, ...filmed, ...(beyondPaint && { beyondPaint }) };
945
+ if (acc) return { color: acc, ...filmed, ...(beyondPaint && { beyondPaint }) };
946
+ return filmed || null;
890
947
  }
891
948
 
892
949
  /**
@@ -1221,7 +1278,7 @@ export function textShadowNegligible(cssText, fontSize) {
1221
1278
  * uncertain path keeps the FULL box, erring toward review, never past a
1222
1279
  * real overlap. Returns null when the clip leaves nothing paintable.
1223
1280
  */
1224
- function paintableRect(element, rect) {
1281
+ export function paintableRect(element, rect) {
1225
1282
  let { left, top, right, bottom } = rect;
1226
1283
  let mode = getComputedStyle(element).position;
1227
1284
  for (let a = element.parentElement; a; a = a.parentElement) {
@@ -1270,6 +1327,163 @@ export function mediaRects(doc) {
1270
1327
  return mediaRectsCache;
1271
1328
  }
1272
1329
 
1330
+ /** Properties whose non-initial value makes a stacking context, and which
1331
+ * will-change makes one for by being named. */
1332
+ const CONTEXT_PROPERTIES = ['transform', 'translate', 'rotate', 'scale', 'filter', 'backdropFilter', 'perspective',
1333
+ 'clipPath', 'maskImage', 'webkitMaskImage', 'maskBorderSource', 'viewTransitionName'];
1334
+ const WILL_CHANGE_CONTEXTS = /(^|,\s*)(transform|translate|rotate|scale|opacity|filter|backdrop-filter|perspective|clip-path|mask|mask-image|mask-border|isolation|mix-blend-mode|z-index|contain|view-transition-name)\s*(,|$)/;
1335
+
1336
+ /**
1337
+ * Does this element create a stacking context? The list is CSS's own (CSS
1338
+ * Positioned Layout 3, and the properties that say so in their own
1339
+ * specifications): the root; fixed and sticky; a positioned element, or a
1340
+ * flex or grid item, with a z-index; opacity under 1; a transform, filter,
1341
+ * perspective, clip-path or mask; a blend mode or isolation; containment
1342
+ * of layout or paint, a container, content-visibility; a view transition
1343
+ * name; and will-change naming any of those. It is kept complete on
1344
+ * purpose: missing one and inventing one both put an element in the wrong
1345
+ * layer, so beneathOpaqueAncestor asks for a whole chain it can read.
1346
+ */
1347
+ function createsStackingContext(element) {
1348
+ let known = stackingContextCache.get(element);
1349
+ if (known !== undefined) return known;
1350
+ const parent = element.parentElement;
1351
+ const style = getComputedStyle(element);
1352
+ known = !parent
1353
+ || style.position === 'fixed' || style.position === 'sticky'
1354
+ || (style.zIndex !== 'auto' && (style.position !== 'static'
1355
+ || /flex|grid/.test(getComputedStyle(parent).display)))
1356
+ || parseFloat(style.opacity) < 1
1357
+ || CONTEXT_PROPERTIES.some((name) => style[name] && style[name] !== 'none')
1358
+ || (style.mixBlendMode && style.mixBlendMode !== 'normal')
1359
+ || style.isolation === 'isolate'
1360
+ || /layout|paint|strict|content/.test(style.contain ?? '')
1361
+ || (style.containerType && style.containerType !== 'normal')
1362
+ || (style.contentVisibility && style.contentVisibility !== 'visible')
1363
+ || WILL_CHANGE_CONTEXTS.test(style.willChange ?? '');
1364
+ stackingContextCache.set(element, known);
1365
+ return known;
1366
+ }
1367
+
1368
+ /** The top layer paints over every stacking context and is outside the
1369
+ * ordering read below. */
1370
+ function inTopLayer(element) {
1371
+ for (const selector of [':modal', ':popover-open', ':fullscreen']) {
1372
+ try { if (element.closest(selector)) return true; }
1373
+ catch { /* a selector this browser does not know matches nothing */ }
1374
+ }
1375
+ return false;
1376
+ }
1377
+
1378
+ /**
1379
+ * Where an element paints within the stacking context `root`, as a rank to
1380
+ * compare (CSS 2.1 Appendix E): child contexts with a negative z-index,
1381
+ * then in-flow content, then positioned content and z-index 0 contexts in
1382
+ * tree order, then positive z-index contexts. A context nested in another
1383
+ * is carried by the outermost, which is the one `root` orders; with no
1384
+ * context between, a positioned box is ordered by itself, so the nearest
1385
+ * positioned ancestor speaks for its in-flow content. Null when the walk
1386
+ * leaves the tree before reaching `root` (a shadow boundary).
1387
+ */
1388
+ function paintRank(element, root) {
1389
+ let context = null;
1390
+ let positioned = null;
1391
+ let node = element;
1392
+ for (; node && node !== root; node = node.parentElement) {
1393
+ if (createsStackingContext(node)) context = node;
1394
+ else if (!positioned && getComputedStyle(node).position !== 'static') positioned = node;
1395
+ }
1396
+ if (node !== root) return null;
1397
+ if (context) {
1398
+ // z-index orders only a positioned box or a flex or grid item. A
1399
+ // context made some other way (a transform, say) sits at zero.
1400
+ const style = getComputedStyle(context);
1401
+ const applies = style.zIndex !== 'auto' && (style.position !== 'static'
1402
+ || /flex|grid/.test(getComputedStyle(context.parentElement ?? context).display));
1403
+ const z = applies ? parseInt(style.zIndex, 10) || 0 : 0;
1404
+ return { node: context, layer: z < 0 ? 0 : z > 0 ? 3 : 2, z };
1405
+ }
1406
+ return positioned ? { node: positioned, layer: 2, z: 0 } : { node: null, layer: 1, z: 0 };
1407
+ }
1408
+
1409
+ /** Is the whole box inside the ancestor's border box, rounded corners
1410
+ * allowed for? Null radii that cannot be read are taken as not inside. */
1411
+ function insideRoundedBox(box, outer, style) {
1412
+ if (box.left < outer.left || box.right > outer.right || box.top < outer.top || box.bottom > outer.bottom) return false;
1413
+ const radius = (css, width) => {
1414
+ const first = (css ?? '').split(' ')[0];
1415
+ const value = parseFloat(first);
1416
+ if (!Number.isFinite(value)) return null;
1417
+ return first.endsWith('%') ? (value / 100) * width : value;
1418
+ };
1419
+ const corners = [
1420
+ [style.borderTopLeftRadius, box.left - outer.left, box.top - outer.top],
1421
+ [style.borderTopRightRadius, outer.right - box.right, box.top - outer.top],
1422
+ [style.borderBottomLeftRadius, box.left - outer.left, outer.bottom - box.bottom],
1423
+ [style.borderBottomRightRadius, outer.right - box.right, outer.bottom - box.bottom],
1424
+ ];
1425
+ for (const [css, dx, dy] of corners) {
1426
+ const r = radius(css, outer.width);
1427
+ if (r === null) return false;
1428
+ // The box's corner lies in this corner's square and outside its arc.
1429
+ if (dx < r && dy < r && (r - dx) ** 2 + (r - dy) ** 2 > r ** 2) return false;
1430
+ }
1431
+ return true;
1432
+ }
1433
+
1434
+ /**
1435
+ * Can this media not be the text's backdrop, because an opaque ancestor of
1436
+ * the text provably paints over it? Hit-test-blind media (pointer-events:
1437
+ * none) sharing the text's space is a hazard the browser cannot be asked
1438
+ * about, and the rule abstains on it. But a solid card laid over a
1439
+ * decorative graphic in a z-index -1 layer sent every line of the card to a
1440
+ * person (a consultancy's landing page, fifteen reviews that all pass by
1441
+ * eye, 2026-09-21). Two facts of CSS paint order settle it:
1442
+ * - an ancestor that creates a stacking context paints as one unit, its
1443
+ * background first and its descendants over it, so nothing from
1444
+ * outside can come between that background and the text;
1445
+ * - media outside that ancestor is then wholly over it or wholly under
1446
+ * it, and which is read from their ranks in the lowest stacking
1447
+ * context they share.
1448
+ * Under it, and with the ancestor's background opaque, unclipped, at full
1449
+ * opacity, unblended and covering the text's box, the media cannot show
1450
+ * behind the text. Everything else (media inside the ancestor, an order
1451
+ * that comes down to in-flow interleaving, the top layer, a shadow
1452
+ * boundary, a radius that cannot be read) is left unproven, and the rule
1453
+ * abstains as before.
1454
+ */
1455
+ export function beneathOpaqueAncestor(element, media) {
1456
+ if (inTopLayer(element) || inTopLayer(media)) return false;
1457
+ const box = element.getBoundingClientRect();
1458
+ for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {
1459
+ if (ancestor.contains(media)) return false; // from here up the media is inside
1460
+ if (!createsStackingContext(ancestor)) continue;
1461
+ const style = getComputedStyle(ancestor);
1462
+ const color = parseColor(style.backgroundColor);
1463
+ if (!color || color.a < 1) continue;
1464
+ if (cumulativeOpacity(ancestor) < 1) continue;
1465
+ if (style.mixBlendMode !== 'normal') continue;
1466
+ if ((style.clipPath && style.clipPath !== 'none')
1467
+ || [style.maskImage, style.webkitMaskImage].some((mask) => mask && mask !== 'none')) continue;
1468
+ if (!/^(border-box|padding-box)$/.test(style.backgroundClip)) continue;
1469
+ if (!insideRoundedBox(box, ancestor.getBoundingClientRect(), style)) continue;
1470
+ // The lowest stacking context holding both.
1471
+ let shared = ancestor.parentElement;
1472
+ while (shared && !(createsStackingContext(shared) && shared.contains(media))) shared = shared.parentElement;
1473
+ if (!shared) return false;
1474
+ const over = paintRank(ancestor, shared);
1475
+ const under = paintRank(media, shared);
1476
+ if (!over || !under || !over.node || over.node === under.node) return false;
1477
+ if (under.layer !== over.layer) return under.layer < over.layer;
1478
+ if (under.layer === 1) return false; // both in flow: interleaving, unproven
1479
+ if (under.z !== over.z) return under.z < over.z;
1480
+ // Same layer and z-index: tree order, earlier paints first.
1481
+ return Boolean(under.node.compareDocumentPosition(over.node) & 4 /* FOLLOWING */)
1482
+ && !under.node.contains(over.node);
1483
+ }
1484
+ return false;
1485
+ }
1486
+
1273
1487
  /**
1274
1488
  * The nearest background-image being painted behind this element (walking
1275
1489
  * up until an opaque background colour would hide anything deeper).
@@ -6,7 +6,7 @@ import {
6
6
  opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
7
7
  paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
8
8
  pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
9
- showRatio, asRgb, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT, hasPaintEffects, isolatedBlendBackdrop } from '../../lib/contrast.js';
9
+ showRatio, asRgb, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT, hasPaintEffects, isolatedBlendBackdrop, labelReferrers, beneathOpaqueAncestor } from '../../lib/contrast.js';
10
10
 
11
11
  /** The first url() among a background-image list's layers, or null. */
12
12
  const firstLayerUrl = (css) => splitBackgroundLayers(css ?? '').map(backgroundLayerUrl).find(Boolean) ?? null;
@@ -74,6 +74,116 @@ function inactiveComponentText(element) {
74
74
  return Boolean(ariaDisabled && ariaDisabled.matches(ARIA_DISABLED_HOSTS));
75
75
  }
76
76
 
77
+ /** A widget switched off: natively disabled, which :disabled sees through
78
+ * an ancestor <fieldset disabled> where .disabled does not, or
79
+ * aria-disabled="true" on a role that honours it. Groups are left out: a
80
+ * disabled fieldset keeps its first legend active (above), and a caption
81
+ * it takes through aria-labelledby is read the same way. */
82
+ function inactiveWidget(widget) {
83
+ if (widget.matches('fieldset, optgroup, [role="group"], [role="toolbar"], [role="application"]')) return false;
84
+ if (widget.matches(':disabled')) return true;
85
+ const ariaDisabled = widget.closest('[aria-disabled="true"]');
86
+ return Boolean(ariaDisabled && ariaDisabled.matches(ARIA_DISABLED_HOSTS));
87
+ }
88
+
89
+ /** What a label does not hold: blocks of content, or controls of its own. */
90
+ const MORE_THAN_A_LABEL = 'p, div, ul, ol, dl, table, section, article, aside, nav, header, footer, form, '
91
+ + 'h1, h2, h3, h4, h5, h6, a[href], button, input, select, textarea';
92
+
93
+ /**
94
+ * Is this text the label of an inactive control? The browser greys
95
+ * <label>My name <input disabled></label> as one unit, and that unit is
96
+ * what the glossary calls a user interface component: "a part of the
97
+ * content that is perceived by users as a single control for a distinct
98
+ * function". So the label is "part of an inactive user interface
99
+ * component" with its control. ACT afw4f7 and 09o5cg say so in their
100
+ * applicability, which leaves out text whose ancestor "is used in the
101
+ * accessible name of an inheriting semantic widget that is disabled"
102
+ * (engine repository issue #5, reported with the fixtures by Jeff Witt).
103
+ *
104
+ * A label reaches its widget two ways: the <label> element's own
105
+ * association, and aria-labelledby on the widget. Both are held to the
106
+ * same two limits, because the exemption is for a label and nothing wider:
107
+ * - every widget that takes its name from this element is inactive. A
108
+ * caption shared by a disabled field and a live one still labels the
109
+ * live one, and a heading that also names a region is still a heading.
110
+ * - a referenced ancestor must be label-sized. aria-labelledby aimed at a
111
+ * whole card does not make the card's paragraphs part of the control.
112
+ * Text inside the widget itself is inactiveComponentText's, above.
113
+ */
114
+ function labelsInactiveComponent(element) {
115
+ const referrers = labelReferrers(element.getRootNode(), inactiveWidget);
116
+ const sharedWithLive = (node) => Boolean(node.id && referrers.get(node.id)?.other);
117
+ const label = element.closest('label');
118
+ if (label?.control && inactiveWidget(label.control) && !sharedWithLive(label)) return true;
119
+ if (!referrers.size) return false;
120
+ for (let node = element; node; node = node.parentElement) {
121
+ const count = node.id && referrers.get(node.id);
122
+ if (!count || !count.inactive || count.other) continue;
123
+ if (node === element || !node.querySelector(MORE_THAN_A_LABEL)) return true;
124
+ }
125
+ return false;
126
+ }
127
+
128
+ /** User-perceived characters, so a flag or a skin-toned emoji counts as
129
+ * one. Falls back to code points where Intl.Segmenter is missing. */
130
+ function graphemes(text) {
131
+ if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
132
+ return [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text)].map((part) => part.segment);
133
+ }
134
+ return [...text];
135
+ }
136
+
137
+ /** A character the browser draws from a colour font: one whose default
138
+ * presentation is emoji, or any emoji asked for in colour with U+FE0F.
139
+ * A check mark or a telephone with no selector is drawn as text, in the
140
+ * element's colour, and is judged as text. */
141
+ const drawnInColour = (grapheme) => /\p{Emoji_Presentation}/u.test(grapheme)
142
+ || (grapheme.includes('️') && /\p{Emoji}/u.test(grapheme));
143
+
144
+ /** The controls a lone glyph stands in as an icon for. */
145
+ const ICON_HOSTS = 'button, a[href], summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"]';
146
+
147
+ /**
148
+ * Is this text a glyph and not words? WCAG defines text as characters
149
+ * "expressing something in human language", and its note on non-text
150
+ * content names emoticons. Three shapes are provable from the markup, and
151
+ * no others are taken: punctuation and symbols are part of written
152
+ * language (a price band in pound signs, the asterisk on a required field,
153
+ * the prompt in a code sample), so text without a letter in it is still
154
+ * judged as text.
155
+ * - colour emoji only. Beyond the definition, the measurement is void:
156
+ * a colour glyph paints its own colours and ignores `color`, so the
157
+ * ratio describes paint that is not there.
158
+ * - private-use characters only: an icon-font glyph. Unicode gives these
159
+ * no meaning in any language.
160
+ * - a single Latin letter or symbol that is all a control shows, where
161
+ * the author named the control through aria-label or aria-labelledby
162
+ * and the name does not contain the character. The X in <button
163
+ * aria-label="Close">X</button> is a drawn cross, and the author's own
164
+ * name for it is the evidence (ACT afw4f7 Passed Example 7). A letter
165
+ * from another script is left alone: one CJK character is a word.
166
+ * Returns what the glyph is, and whether it is the whole face of a
167
+ * control, which decides what it owes under 1.4.11 (see the wrapper).
168
+ */
169
+ function glyphNotWords(element, text, accessibleName) {
170
+ const trimmed = text.trim();
171
+ if (!trimmed) return null;
172
+ const parts = graphemes(trimmed).filter((part) => /\S/.test(part));
173
+ if (parts.every(drawnInColour)) return { emoji: true, what: 'this text is emoji, which the browser draws in its own colours whatever the text colour is' };
174
+ const host = element.closest(ICON_HOSTS);
175
+ const faceOfControl = Boolean(host && host.textContent.trim() === trimmed);
176
+ if (/^[\p{Co}\s]+$/u.test(trimmed)) {
177
+ return { faceOfControl, what: 'this text is an icon-font glyph (a private-use character), not words' };
178
+ }
179
+ if (parts.length !== 1 || !faceOfControl) return null;
180
+ if (!/^(\p{Script=Latin}|[^\p{L}\p{N}])/u.test(trimmed)) return null;
181
+ if (!host.hasAttribute('aria-label') && !host.hasAttribute('aria-labelledby')) return null;
182
+ const name = accessibleName(host);
183
+ if (!name || name.toLowerCase().includes(trimmed.toLowerCase())) return null;
184
+ return { faceOfControl, what: `the single character "${trimmed}" stands in for an icon: the ${host.tagName.toLowerCase()} is named "${name}", which does not contain it` };
185
+ }
186
+
77
187
  /** A control the keyboard cannot reach either: with pointer-events: none
78
188
  * on top, nothing can operate it, which is what "inactive" means. Native
79
189
  * controls are focusable unless told otherwise; a role-only widget is
@@ -516,8 +626,9 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
516
626
  async evaluate(element, { ownText }) {
517
627
  if (!ownText(element)) return { status: 'pass' }; // no text of its own to judge
518
628
 
519
- // The criterion exempts "inactive user interface components".
520
- if (inactiveComponentText(element)) return { status: 'pass' };
629
+ // The criterion exempts "inactive user interface components": the text
630
+ // inside one, and the label that is greyed out with it.
631
+ if (inactiveComponentText(element) || labelsInactiveComponent(element)) return { status: 'pass' };
521
632
 
522
633
  // A shadow HOST's own text renders through its shadow tree: when a
523
634
  // <slot> projects it, the text inherits styles from the slot's
@@ -974,7 +1085,9 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
974
1085
  }
975
1086
  const filmIncomplete = {
976
1087
  status: 'incomplete',
977
- 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.',
1088
+ message: pseudoResolved?.shaped
1089
+ ? 'A pseudo-element with its own background covers this text, but a mask or clip-path cuts it down to a shape, so how much of it paints over or behind the text can\'t be computed. Check the contrast by eye.'
1090
+ : '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.',
978
1091
  };
979
1092
  // Sampled pixels, veils and text-shadow halos can't be film-bracketed
980
1093
  // (the film may sit between the sample and the glyphs): those verdicts
@@ -1144,10 +1257,13 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
1144
1257
  const box = element.getBoundingClientRect();
1145
1258
  const near = (r) => Math.min(box.right, r.right) - Math.max(box.left, r.left) >= 3
1146
1259
  && Math.min(box.bottom, r.bottom) - Math.max(box.top, r.top) >= 3;
1260
+ // Asked last, and only of media that would otherwise send this text
1261
+ // to a person: can paint order prove an opaque ancestor lies over it?
1147
1262
  const overMedia = mediaRects(doc).some(({ element: media, rect: mediaRect, hitTestBlind }) =>
1148
1263
  (!blindOnly || hitTestBlind) && near(mediaRect)
1149
1264
  && !media.contains(element) && !element.contains(media)
1150
- && textIntersects(element, mediaRect));
1265
+ && textIntersects(element, mediaRect)
1266
+ && !beneathOpaqueAncestor(element, media));
1151
1267
  if (overMedia) {
1152
1268
  return {
1153
1269
  status: 'incomplete',
@@ -1388,7 +1504,30 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
1388
1504
  const judge = rule.evaluate.bind(rule);
1389
1505
  rule.evaluate = async (element, helpers) => {
1390
1506
  const verdict = await judge(element, helpers);
1391
- if (verdict.status === 'fail' && element.closest('[aria-hidden="true"]')) {
1507
+ if (verdict.status !== 'fail') return verdict;
1508
+ // A miss on a glyph that is not words: see glyphNotWords. Every fail
1509
+ // path passes through here, so this is said once. A glyph still owes
1510
+ // 3:1 under 1.4.11 when it is needed to identify a control, so one that
1511
+ // is the whole face of a control and measures under 3:1 fails whichever
1512
+ // criterion it is read under, and keeps its failure. Anything else is a
1513
+ // question of which criterion applies, and goes to a person. Emoji
1514
+ // always does, its ratio being a number for paint that is not there.
1515
+ const glyph = glyphNotWords(element, helpers.ownText(element), helpers.accessibleName);
1516
+ if (glyph) {
1517
+ const ratio = verdict.data?.ratio;
1518
+ if (!glyph.emoji && glyph.faceOfControl && ratio < 3) {
1519
+ verdict.message += ' Read as an icon and not as text, it still falls short: 1.4.11 asks 3:1 of what identifies a control.';
1520
+ }
1521
+ else {
1522
+ return {
1523
+ status: 'incomplete',
1524
+ message: glyph.emoji
1525
+ ? `The text colour misses the contrast minimum, but ${glyph.what}, so that ratio does not describe what is seen. If the emoji carries meaning, judge it by eye against 1.4.11 non-text contrast (3:1).`
1526
+ : `Contrast is ${ratio ? `${ratio}:1, below the ${verdict.data.required}:1 minimum for text` : 'below the minimum for text'}, but ${glyph.what}. 1.4.3 covers text in a human language; a glyph that works as a graphic is judged under 1.4.11 non-text contrast (3:1) where it is needed to understand the content. Decide by eye which this is.`,
1527
+ };
1528
+ }
1529
+ }
1530
+ if (element.closest('[aria-hidden="true"]')) {
1392
1531
  verdict.message += ' aria-hidden hides this from screen readers, not from sighted users; contrast is judged for the people who see it.';
1393
1532
  }
1394
1533
  return verdict;
@@ -2,8 +2,135 @@
2
2
  // Same shape as 3.1.1-html-lang.js, and must stay in step with it: subtags
3
3
  // may be a single character, because BCP 47 extension and private-use
4
4
  // singletons ("-u-", "-t-", "-x-") are exactly one.
5
+ import { paintableRect } from '../../lib/contrast.js';
6
+
5
7
  const LANG_PATTERN = /^([a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*|[xXiI](-[a-zA-Z0-9]{1,8})+)$/;
6
8
 
9
+ /** Attributes whose value is spoken and never shown. */
10
+ const SPOKEN_ATTRIBUTES = ['aria-label', 'aria-description', 'aria-placeholder', 'aria-valuetext', 'aria-roledescription'];
11
+
12
+ /** Attributes whose value is shown, and spoken too. */
13
+ const SHOWN_ATTRIBUTES = ['title', 'placeholder'];
14
+
15
+ /** Input types whose value attribute is words the page wrote. Submit and
16
+ * reset are here without one too: the browser writes their label. */
17
+ const WORDED_INPUT_TYPES = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'button', 'submit', 'reset']);
18
+
19
+ const spoken = (node, name) => Boolean(node.getAttribute(name)?.trim());
20
+
21
+ /**
22
+ * Does this element carry words of its own, outside its text nodes? The
23
+ * accessible name and description count as much as visible text: a
24
+ * placeholder or a title is a phrase, and it is spoken in whatever
25
+ * language the nearest lang says. Inside aria-hidden content (`unspoken`)
26
+ * nothing is spoken, so only the words that are shown count there: an alt
27
+ * or an aria-label under aria-hidden reaches no one.
28
+ */
29
+ function ownWords(node, unspoken) {
30
+ if (!unspoken) {
31
+ if (SPOKEN_ATTRIBUTES.some((name) => spoken(node, name))) return true;
32
+ if (node.matches('img, area, input[type="image"]') && spoken(node, 'alt')) return true;
33
+ if (node.matches('optgroup, track') && spoken(node, 'label')) return true;
34
+ }
35
+ if (SHOWN_ATTRIBUTES.some((name) => spoken(node, name))) return true;
36
+ if (node.tagName === 'OPTION' && spoken(node, 'label')) return true;
37
+ if (node.tagName === 'INPUT') {
38
+ const type = (node.getAttribute('type') ?? '').trim().toLowerCase();
39
+ if (type === 'submit' || type === 'reset') return true;
40
+ if (WORDED_INPUT_TYPES.has(type) && spoken(node, 'value')) return true;
41
+ }
42
+ // A nested document with no lang of its own takes this one, and what it
43
+ // holds cannot always be read from here, so it counts as words.
44
+ if (node.matches('iframe, frame, object, embed')) return true;
45
+ for (const pseudo of ['::before', '::after']) {
46
+ const content = getComputedStyle(node, pseudo).content;
47
+ if (/"[^"]*\S[^"]*"/.test(content)) return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * Can this box be seen? Asked only inside aria-hidden content, where being
54
+ * seen is the one way left for words to reach anyone: a carousel slide that
55
+ * is aria-hidden and clipped out of its strip is neither spoken nor seen,
56
+ * and the tag on it misleads no one. Outside aria-hidden the question is
57
+ * never put, since off-screen text is still spoken. The clip walk keeps the
58
+ * whole box wherever it is unsure, so a doubtful case counts as seen and
59
+ * keeps its failure; so does everything where there is no layout to ask
60
+ * (the editor's static lane), which the root's empty box gives away.
61
+ */
62
+ function seen(box, element) {
63
+ const view = element.ownerDocument.defaultView;
64
+ if (!element.ownerDocument.documentElement.getBoundingClientRect().width) return true;
65
+ if (!box.width || !box.height) return false;
66
+ if (box.right + view.scrollX <= 0 || box.bottom + view.scrollY <= 0) return false;
67
+ return Boolean(paintableRect(element, box));
68
+ }
69
+
70
+ /**
71
+ * Does any text take its language from this element? 3.1.2 asks that "the
72
+ * human language of each passage or phrase in the content can be
73
+ * programmatically determined", so a lang value can only fail it through a
74
+ * passage it governs. A lang governs its subtree down to the next element
75
+ * with a non-empty lang of its own. When every word beneath an invalid tag
76
+ * is re-tagged (<article lang="invalid"><div lang="en">...</div></article>),
77
+ * or there are no words at all (an empty element, a decorative image, text
78
+ * that is display:none), each passage on the page still has a language and
79
+ * the criterion is met. ACT de46e4 draws the same line in its
80
+ * applicability: "some text inheriting its programmatic language from the
81
+ * element which is neither empty nor only whitespace" (issue #6 on the
82
+ * engine repository, reported with the fixtures by Jeff Witt).
83
+ *
84
+ * Words are rendered text nodes and everything ownWords() counts. The walk
85
+ * is over the flat tree: shadow content in place of a host's light
86
+ * children, assigned nodes in place of a slot. display:none ends a branch.
87
+ * visibility:hidden silences that level only, since a descendant can turn
88
+ * itself back on. aria-hidden text still counts where it is seen, and
89
+ * off-screen text where it is spoken; text that is both aria-hidden and out
90
+ * of sight reaches no one and does not. Anything the walk cannot rule on counts as
91
+ * words, so a doubtful case keeps its failure.
92
+ */
93
+ function governsText(element) {
94
+ // The rule is handed every [lang] element, rendered or not, so nothing
95
+ // beneath an ancestor that is display:none counts: the walk below only
96
+ // looks down.
97
+ for (let node = element.parentElement ?? element.getRootNode()?.host; node; node = node.parentElement ?? node.getRootNode()?.host) {
98
+ if (getComputedStyle(node).display === 'none') return false;
99
+ }
100
+ const stack = [[element, Boolean(element.closest('[aria-hidden="true"]'))]];
101
+ while (stack.length) {
102
+ const [node, above] = stack.pop();
103
+ const unspoken = above || node.getAttribute('aria-hidden') === 'true';
104
+ if (node !== element && spoken(node, 'lang')) continue;
105
+ if (node.matches('script, style, noscript, template')) continue;
106
+ const style = getComputedStyle(node);
107
+ if (style.display === 'none') continue;
108
+ const shown = style.visibility !== 'hidden' && style.visibility !== 'collapse';
109
+ if (shown && ownWords(node, unspoken) && (!unspoken || seen(node.getBoundingClientRect(), node))) return true;
110
+ let children = node.childNodes;
111
+ if (node.tagName === 'SLOT') {
112
+ const assigned = node.assignedNodes?.() ?? [];
113
+ if (assigned.length) children = assigned;
114
+ }
115
+ else if (node.shadowRoot) children = node.shadowRoot.childNodes;
116
+ for (const child of children) {
117
+ if (child.nodeType === 3 /* TEXT_NODE */) {
118
+ if (!shown || !/\S/.test(child.textContent)) continue;
119
+ if (!unspoken) return true;
120
+ // A range's box is the text's own, where the parent's may be a
121
+ // block far wider than its words. Where ranges have no geometry
122
+ // (the static lane) the parent's box is asked, and says "seen".
123
+ const range = node.ownerDocument.createRange();
124
+ range.selectNodeContents(child);
125
+ const box = typeof range.getBoundingClientRect === 'function' ? range.getBoundingClientRect() : node.getBoundingClientRect();
126
+ if (seen(box, node)) return true;
127
+ }
128
+ else if (child.nodeType === 1 /* ELEMENT_NODE */) stack.push([child, unspoken]);
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+
7
134
  export default {
8
135
  id: 'valid-lang-parts',
9
136
  name: 'Part language tags',
@@ -12,12 +139,28 @@ export default {
12
139
  help: 'lang attributes on page parts must be valid',
13
140
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html',
14
141
  selector: '[lang]:not(html)',
142
+ // Not the default filter, which drops an element that is aria-hidden or
143
+ // has no box of its own. Neither settles whether words inherit the tag:
144
+ // aria-hidden text is still seen, and the language of seen text is used
145
+ // by more than screen readers (hyphenation, font choice, translation,
146
+ // spelling); visibility:hidden on the element leaves a descendant free to
147
+ // show itself. governsText rules on all of it (ACT de46e4 Failed
148
+ // Example 4 generalised to the element itself).
149
+ visibleOnly: false,
15
150
  evaluate(element) {
16
151
  const lang = element.getAttribute('lang').trim();
17
152
  if (lang === '' || LANG_PATTERN.test(lang)) return { status: 'pass' }; // empty resets to page language
153
+ // An invalid tag no passage inherits leaves every passage with a
154
+ // language. Asked after the syntax test, so only invalid tags pay for
155
+ // the walk.
156
+ if (!governsText(element)) return { status: 'pass' };
157
+ // Where no screen reader will read it, say who else the tag is for.
158
+ const unspoken = element.closest('[aria-hidden="true"]');
18
159
  return {
19
160
  status: 'fail',
20
- message: `lang="${lang}" is not a valid language tag, so screen readers may switch to the wrong pronunciation.`,
161
+ message: unspoken
162
+ ? `lang="${lang}" is not a valid language tag. aria-hidden keeps this text from screen readers, but it is still seen, and browsers and translation tools use the tag to hyphenate, pick fonts and translate it.`
163
+ : `lang="${lang}" is not a valid language tag, so screen readers may switch to the wrong pronunciation.`,
21
164
  fix: 'Use a BCP 47 tag such as lang="fr" or lang="de-AT".',
22
165
  };
23
166
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.43.0",
3
+ "version": "1.44.1",
4
4
  "description": "Accessibility (WCAG 2.2) audit engine written from the W3C specifications — runs entirely in the browser, no dependencies.",
5
5
  "type": "module",
6
6
  "main": "engine/index.js",