pour-engine 1.1.9 → 1.3.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.1.9',
9
+ version: '1.3.1',
10
10
  },
11
11
  };
@@ -5,6 +5,8 @@ Generated by `node scripts/wcag-coverage.js` — do not edit by hand.
5
5
  All 86 success criteria of WCAG 2.2, and how the native engine covers them.
6
6
  "Partially automated" means our rules catch common failures but cannot prove conformance;
7
7
  "Manual review" criteria need a human (most of the spec — true for every automated tool).
8
+ No criterion is listed as fully "Automated": every one of them ends in a question about
9
+ meaning that only a person can settle, even where a rule decides most real pages.
8
10
 
9
11
  ## Perceivable
10
12
 
@@ -58,7 +60,7 @@ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
58
60
  | 2.3.2 | [Three Flashes](https://www.w3.org/WAI/WCAG22/Understanding/three-flashes.html) | AAA | Manual review | — |
59
61
  | 2.3.3 | [Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) | AAA | Manual review | — |
60
62
  | 2.4.1 | [Bypass Blocks](https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html) | A | Partially automated | bypass-blocks |
61
- | 2.4.2 | [Page Titled](https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html) | A | Automated | document-title |
63
+ | 2.4.2 | [Page Titled](https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html) | A | Partially automated | document-title |
62
64
  | 2.4.3 | [Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) | A | Manual review | — |
63
65
  | 2.4.4 | [Link Purpose (In Context)](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html) | A | Partially automated | area-alt, link-name |
64
66
  | 2.4.5 | [Multiple Ways](https://www.w3.org/WAI/WCAG22/Understanding/multiple-ways.html) | AA | Manual review | — |
@@ -83,7 +85,7 @@ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
83
85
 
84
86
  | SC | Name | Level | Coverage | Rules |
85
87
  |---|---|---|---|---|
86
- | 3.1.1 | [Language of Page](https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html) | A | Automated | html-lang |
88
+ | 3.1.1 | [Language of Page](https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html) | A | Partially automated | html-lang |
87
89
  | 3.1.2 | [Language of Parts](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html) | AA | Partially automated | valid-lang-parts |
88
90
  | 3.1.3 | [Unusual Words](https://www.w3.org/WAI/WCAG22/Understanding/unusual-words.html) | AAA | Manual review | — |
89
91
  | 3.1.4 | [Abbreviations](https://www.w3.org/WAI/WCAG22/Understanding/abbreviations.html) | AAA | Manual review | — |
@@ -112,4 +114,4 @@ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
112
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 |
113
115
  | 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
114
116
 
115
- **Totals:** 2 automated · 27 partially automated · 57 manual — with 72 rules.
117
+ **Totals:** 29 partially automated · 57 manual — with 72 rules.
package/engine/README.md CHANGED
@@ -108,7 +108,7 @@ Nothing in the spec is silently skipped — that claim is the product.
108
108
 
109
109
  ## Honest current state
110
110
 
111
- 61 rules, hardened on real sites and the `testbeds/` pages. Known gaps:
111
+ 72 rules, hardened on real sites and the `testbeds/` pages. Known gaps:
112
112
  shadow DOM and iframe traversal, the full accname spec (ours is a close
113
113
  approximation), and the language subtag registry (BCP 47 shape check only).
114
114
  Grow it rule by rule; the test beds' `expect:` annotations are the
@@ -140,7 +140,7 @@ export function backgroundColorSource(element) {
140
140
  for (let node = element; node && node.nodeType === 1;
141
141
  node = node.parentElement ?? node.getRootNode()?.host) {
142
142
  const style = getComputedStyle(node);
143
- if (style.backgroundImage !== 'none') return null;
143
+ if (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage)) return null;
144
144
  const color = parseColor(style.backgroundColor);
145
145
  if (!color || color.a === 0) continue; // transparent: keep walking, as the resolver does
146
146
  return color.a >= 1 ? style.backgroundColor : null; // translucent: composited, no single source
@@ -148,6 +148,24 @@ export function backgroundColorSource(element) {
148
148
  return null;
149
149
  }
150
150
 
151
+ /**
152
+ * Absolute URLs of images that sampling found to have no opaque pixels.
153
+ * Populated by imageLuminanceRange (async) and read by the background walk
154
+ * (sync): by the time a rule falls back to the walk, the image over that
155
+ * text has already been sampled, so the fact is in hand. Lives as long as
156
+ * the sample cache does — it describes the image, not the page.
157
+ */
158
+ const transparentImages = new Set();
159
+
160
+ /** True when every layer of this computed background-image is a url() known
161
+ * to paint nothing — so the layer cannot change what is behind it. Any
162
+ * gradient, or any unsampled image, makes that unknowable. */
163
+ function paintsNothing(backgroundImage) {
164
+ if (backgroundImage.includes('gradient(')) return false;
165
+ const urls = [...backgroundImage.matchAll(/url\(["']?(.*?)["']?\)/g)].map((m) => m[1]);
166
+ return urls.length > 0 && urls.every((url) => transparentImages.has(url));
167
+ }
168
+
151
169
  function canvasColor(doc) {
152
170
  // Pages using color-scheme: dark get a dark canvas from the browser even
153
171
  // with no author background — assuming white there inverts every result.
@@ -169,7 +187,7 @@ function resolveBackground(element, doc) {
169
187
  }
170
188
  let result;
171
189
  const style = getComputedStyle(element);
172
- if (style.backgroundImage !== 'none') {
190
+ if (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage)) {
173
191
  result = null;
174
192
  } else {
175
193
  const color = parseColor(style.backgroundColor);
@@ -762,23 +780,45 @@ export function mediaRects(doc) {
762
780
  * Returns { css, element } or null.
763
781
  */
764
782
  export function backgroundImageSource(element) {
783
+ // Translucent background colours passed on the way up are painted BETWEEN
784
+ // the image and the text: the scrim over a hero photo is the everyday case.
785
+ // They are collected nearest-text first, so folding them back over a sampled
786
+ // image pixel means walking this list in reverse (see applyOverlays).
787
+ const overlays = [];
765
788
  for (let current = element; current; current = current.parentElement) {
766
789
  const style = getComputedStyle(current);
767
- if (style.backgroundImage !== 'none') return { css: style.backgroundImage, element: current };
790
+ // An element's own background-image paints ABOVE its own background
791
+ // colour, so the element carrying the image contributes no overlay.
792
+ if (style.backgroundImage !== 'none') return { css: style.backgroundImage, element: current, overlays };
768
793
  const color = parseColor(style.backgroundColor);
769
794
  if (color && color.a >= 1) return null;
795
+ if (color && color.a > 0) overlays.push(color);
770
796
  }
771
797
  return null;
772
798
  }
773
799
 
800
+ /** Paint `overlays` (nearest-text first) back over a base colour. */
801
+ export function applyOverlays(base, overlays) {
802
+ let result = base;
803
+ for (let i = overlays.length - 1; i >= 0; i--) result = composite(overlays[i], result);
804
+ return result;
805
+ }
806
+
807
+ const overlayKey = (overlays) =>
808
+ overlays.map((c) => `${c.r},${c.g},${c.b},${c.a}`).join(';');
809
+
774
810
  /**
775
811
  * Luminance range (min/max) of an image's pixels, downscaled for speed.
776
812
  * null when the image can't be read: cross-origin without CORS headers
777
- * (canvas tainting), load failure, or timeout. Cached per URL.
813
+ * (canvas tainting), load failure, or timeout. `{ transparent: true }` when
814
+ * the image reads fine but has no opaque pixels at all — it paints nothing,
815
+ * which is a very different fact from "unknown" and must not be reported as
816
+ * one. Cached per URL.
778
817
  */
779
818
  const imageRangeCache = new Map();
780
- export function imageLuminanceRange(url) {
781
- if (imageRangeCache.has(url)) return imageRangeCache.get(url);
819
+ export function imageLuminanceRange(url, overlays = []) {
820
+ const cacheKey = overlays.length ? `${url}|${overlayKey(overlays)}` : url;
821
+ if (imageRangeCache.has(cacheKey)) return imageRangeCache.get(cacheKey);
782
822
  const promise = new Promise((resolve) => {
783
823
  const img = new Image();
784
824
  img.crossOrigin = 'anonymous';
@@ -795,12 +835,33 @@ export function imageLuminanceRange(url) {
795
835
  const data = ctx.getImageData(0, 0, size, size).data;
796
836
  let min = 1;
797
837
  let max = 0;
838
+ let opaquePixels = 0;
798
839
  for (let i = 0; i < data.length; i += 4) {
799
840
  if (data[i + 3] < 128) continue; // mostly-transparent pixels reveal what's beneath — unknowable
800
- const l = luminance({ r: data[i], g: data[i + 1], b: data[i + 2] });
841
+ opaquePixels += 1;
842
+ // Composite each pixel through anything painted between the image
843
+ // and the text before measuring: a photo under a 75% black scrim is
844
+ // seen as the blend, never as the photo.
845
+ const pixel = { r: data[i], g: data[i + 1], b: data[i + 2], a: 1 };
846
+ const l = luminance(overlays.length ? applyOverlays(pixel, overlays) : pixel);
801
847
  if (l < min) min = l;
802
848
  if (l > max) max = l;
803
849
  }
850
+ // Not one opaque pixel: a spacer, a cleared sprite, or a decorative
851
+ // layer left in place with nothing in it. It covers the background
852
+ // without changing a single one of its pixels.
853
+ if (!opaquePixels) {
854
+ transparentImages.add(url);
855
+ // Any background the walk already gave up on ("there's an image in
856
+ // the chain") may have been given up on because of THIS image.
857
+ // Those answers are now wrong, and which ones is not worth
858
+ // tracking: a page has a handful of transparent images at most, so
859
+ // the walk simply re-runs. Without this the verdict would depend
860
+ // on which rule happened to reach the element first.
861
+ backgroundCache = new WeakMap();
862
+ resolve({ transparent: true, width: img.naturalWidth, height: img.naturalHeight });
863
+ return;
864
+ }
804
865
  resolve(min <= max ? { min, max, width: img.naturalWidth, height: img.naturalHeight } : null);
805
866
  } catch {
806
867
  resolve(null); // tainted canvas: cross-origin image without CORS headers
@@ -809,7 +870,7 @@ export function imageLuminanceRange(url) {
809
870
  img.onerror = () => { clearTimeout(timer); resolve(null); };
810
871
  img.src = url;
811
872
  });
812
- imageRangeCache.set(url, promise);
873
+ imageRangeCache.set(cacheKey, promise);
813
874
  return promise;
814
875
  }
815
876
 
@@ -857,10 +918,10 @@ export function backgroundImagePaintRect(element, intrinsic) {
857
918
  * Luminance range of a CSS gradient, from its colour stops. Interpolated
858
919
  * colours stay close enough to their stops' luminances to bracket.
859
920
  */
860
- export function gradientLuminanceRange(backgroundImageCss) {
921
+ export function gradientLuminanceRange(backgroundImageCss, overlays = []) {
861
922
  const stops = (backgroundImageCss.match(/rgba?\([^)]+\)/g) ?? []).map(parseColor).filter(Boolean);
862
923
  if (!stops.length || stops.some((c) => c.a < 1)) return null; // translucent stops reveal what's beneath
863
- const lums = stops.map(luminance);
924
+ const lums = stops.map((stop) => luminance(overlays.length ? applyOverlays(stop, overlays) : stop));
864
925
  return { min: Math.min(...lums), max: Math.max(...lums) };
865
926
  }
866
927
 
@@ -8,7 +8,14 @@ export default {
8
8
  selector: 'video',
9
9
  visibleOnly: false,
10
10
  evaluate(element) {
11
- if (element.querySelector('track[kind="captions"], track[kind="subtitles"]')) return { status: 'pass' };
11
+ // `kind` is an enumerated attribute: its values are ASCII
12
+ // case-insensitive, and the MISSING value default is the subtitles
13
+ // state — so a bare <track> is a subtitle track and counts. (An invalid
14
+ // value defaults to metadata, which does not, so `kind=""` and
15
+ // `kind="banana"` are deliberately left out.)
16
+ if (element.querySelector('track[kind="captions" i], track[kind="subtitles" i], track:not([kind])')) {
17
+ return { status: 'pass' };
18
+ }
12
19
  // Captions may be burned in or provided by the player — a human must check.
13
20
  return {
14
21
  status: 'incomplete',
@@ -40,10 +40,12 @@ export default {
40
40
 
41
41
  const id = element.id ? ` (it already has id="${element.id}")` : ' (give it an id first)';
42
42
 
43
- // A title-only name is published sufficient technique H65 — conformant,
44
- // but invisible until hover and ignored by some tools, so it's flagged
45
- // for eyes rather than failed.
46
- if (element.getAttribute('title') && !element.getAttribute('placeholder')) {
43
+ // A title name is published sufficient technique H65 — conformant, but
44
+ // invisible until hover and ignored by some tools, so it's flagged for
45
+ // eyes rather than failed. A placeholder alongside it is an example
46
+ // value, not a competing label: the title still names the field, so this
47
+ // check comes first and does not care whether one is present.
48
+ if (element.getAttribute('title')?.trim()) {
47
49
  return {
48
50
  status: 'incomplete',
49
51
  message: 'This field is named only by its title attribute (technique H65) — conformant, but the label is invisible until hover and some tools skip it. Check a visible label isn’t needed here.',
@@ -51,6 +51,10 @@ export default {
51
51
  help: 'Content must work in both portrait and landscape',
52
52
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/orientation.html',
53
53
  selector: 'html',
54
+ // The evidence is in the stylesheet, not in the root's own rendering — and
55
+ // the lock hides the root exactly when it is in force, so a visibility
56
+ // filter here would silence the rule in the only state that matters.
57
+ visibleOnly: false,
54
58
  evaluate(element) {
55
59
  const findings = [];
56
60
  for (const sheet of element.ownerDocument.styleSheets) {
@@ -7,12 +7,18 @@ export default {
7
7
  tags: ['wcag2a', 'wcag141'],
8
8
  help: 'Links inside text must be distinguishable by more than colour',
9
9
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html',
10
- // Only running prose counts as a "text block" — lists of links (navs,
11
- // blogrolls) are identified by their structure, not by colour.
12
- selector: 'p a[href], dd a[href], blockquote a[href], td a[href]',
10
+ // Only running prose counts as a "text block". <li> is included because
11
+ // footers and credits routinely write sentences as list items ("built by
12
+ // the <a>team</a> with help from <a>contributors</a>"), and those links are
13
+ // embedded in prose exactly as 1.4.1 means it. Pure link lists (navs,
14
+ // blogrolls) are still excluded, but by the ownText guard below rather than
15
+ // by tag: in <li><a>Home</a></li> the item has no text of its own.
16
+ // <div> was measured too and added nothing on any test site, so it stays
17
+ // out rather than widening the blast radius for no gain.
18
+ selector: 'p a[href], dd a[href], blockquote a[href], td a[href], li a[href]',
13
19
  visibility: 'visual', // colour distinction is a purely visual concern
14
20
  evaluate(element, { ownText }) {
15
- const parent = element.closest('p, dd, blockquote, td');
21
+ const parent = element.closest('p, dd, blockquote, td, li');
16
22
  if (!element.textContent.trim() || !parent) return { status: 'pass' };
17
23
  // Needs real surrounding text to blend into (1.4.1 is about links
18
24
  // embedded in prose, not a link that IS the content).
@@ -48,9 +54,17 @@ export default {
48
54
  // its own line (<br>-separated address blocks, one-link-per-line
49
55
  // footers) has no surrounding words to blend into — nothing is
50
56
  // conveyed by colour.
51
- const rect = element.getBoundingClientRect();
52
- if (rect.height) {
53
- const mid = rect.top + rect.height / 2;
57
+ //
58
+ // Measured per LINE FRAGMENT, never on the bounding box. A link that
59
+ // wraps has one fragment per line, and the middle of the box that
60
+ // encloses them falls in the leading BETWEEN the lines, where no text
61
+ // can ever be — so a box-midpoint test exempts every wrapped link in
62
+ // prose, which is the opposite of what the check is for. F73 scopes
63
+ // this criterion by whether a link sits within text, and says nothing
64
+ // about lines: one fragment sharing its line with words is enough.
65
+ const fragments = [...element.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
66
+ if (fragments.length) {
67
+ const mids = fragments.map((r) => r.top + r.height / 2);
54
68
  const walker = element.ownerDocument.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
55
69
  const range = element.ownerDocument.createRange();
56
70
  let sharesLine = false;
@@ -58,7 +72,7 @@ export default {
58
72
  if (!node.textContent.trim() || element.contains(node)) continue;
59
73
  range.selectNodeContents(node);
60
74
  for (const r of range.getClientRects()) {
61
- if (r.top <= mid && r.bottom >= mid && r.width > 0) { sharesLine = true; break; }
75
+ if (r.width > 0 && mids.some((mid) => r.top <= mid && r.bottom >= mid)) { sharesLine = true; break; }
62
76
  }
63
77
  }
64
78
  if (!sharesLine) return { status: 'pass' };
@@ -125,10 +125,68 @@ function verdictFromRange(range, foreground, required, what) {
125
125
  };
126
126
  }
127
127
 
128
+ /**
129
+ * Is there translucent paint over the background image that the ancestor walk
130
+ * could NOT have collected? Two shapes dominate real hero banners: a
131
+ * ::before/::after scrim, and an absolutely-positioned sibling laid over the
132
+ * photo. Neither is on the text's ancestor chain, so backgroundImageSource
133
+ * never sees them, and judging the raw image pixels would describe a page the
134
+ * user is not looking at. Ancestor scrims are excluded here because they are
135
+ * already composited into the sampled pixels.
136
+ */
137
+ function unaccountedScrim(element, imageCarrier) {
138
+ const pseudo = pseudoBackdropForText(element);
139
+ if (pseudo && pseudo !== 'unresolved' && pseudo.color?.a > 0) return true;
140
+ const doc = element.ownerDocument;
141
+ if (typeof doc.elementsFromPoint !== 'function') return false;
142
+ const rect = element.getBoundingClientRect();
143
+ const win = doc.defaultView;
144
+ const x = rect.left + rect.width / 2;
145
+ const y = rect.top + rect.height / 2;
146
+ if (x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) return false;
147
+ const stack = doc.elementsFromPoint(x, y);
148
+ const start = stack.indexOf(element);
149
+ if (start === -1) return false;
150
+ return stack.slice(start + 1).some((layer) => {
151
+ if (layer.contains(element) || layer === imageCarrier) return false; // ancestor chain: already composited
152
+ const color = parseColor(getComputedStyle(layer).backgroundColor);
153
+ return color && color.a > 0;
154
+ });
155
+ }
156
+
157
+ /** An image layer with no opaque pixels: it covers the background without
158
+ * changing it, so the callers step over it rather than reporting on it. */
159
+ const PAINTS_NOTHING = Symbol('fully transparent image layer');
160
+
128
161
  // Bracket the verdict by sampling what's actually painted: image pixels
129
- // (same-origin/CORS only) or gradient colour stops.
130
- async function sampledVerdict(source, foreground, required, doc) {
162
+ // (same-origin/CORS only) or gradient colour stops. Returns an outcome,
163
+ // PAINTS_NOTHING, or null when nothing here can be sampled at all (stacked
164
+ // layers, translucent gradient stops) and the caller must say so itself.
165
+ async function sampledVerdict(source, foreground, required, doc, overlays = []) {
131
166
  if (!source) return null;
167
+ let range = null;
168
+ let what = 'image';
169
+ if (source.tagName === 'IMG') {
170
+ range = await imageLuminanceRange(source.currentSrc || source.src, overlays);
171
+ } else {
172
+ const css = source.css ?? getComputedStyle(source).backgroundImage;
173
+ if (!css || css === 'none') return null;
174
+ if ((css.match(/url\(/g) ?? []).length + (css.match(/gradient\(/g) ?? []).length > 1) return null; // stacked layers: too complex
175
+ const url = css.match(/url\(["']?(.*?)["']?\)/)?.[1];
176
+ if (url) {
177
+ let absolute;
178
+ try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
179
+ range = await imageLuminanceRange(absolute, overlays);
180
+ } else if (css.includes('gradient(')) {
181
+ range = gradientLuminanceRange(css, overlays);
182
+ what = 'gradient';
183
+ } else {
184
+ return null;
185
+ }
186
+ }
187
+ // Checked before the text's own alpha: there is no blend to reason about
188
+ // when the layer paints nothing.
189
+ if (range?.transparent) return PAINTS_NOTHING;
132
190
  // Translucent text takes its presented colour from the very pixels
133
191
  // beneath it — a luminance range of the backdrop cannot bracket the
134
192
  // blend (dimmed white over a dark photo reads mid-grey, not white).
@@ -139,22 +197,15 @@ async function sampledVerdict(source, foreground, required, doc) {
139
197
  message: 'This text is translucent over an image or gradient, so its presented colour depends on the pixels beneath — contrast must be checked by eye.',
140
198
  };
141
199
  }
142
- if (source.tagName === 'IMG') {
143
- return verdictFromRange(await imageLuminanceRange(source.currentSrc || source.src), foreground, required, 'image');
144
- }
145
- const css = source.css ?? getComputedStyle(source).backgroundImage;
146
- if (!css || css === 'none') return null;
147
- if ((css.match(/url\(/g) ?? []).length + (css.match(/gradient\(/g) ?? []).length > 1) return null; // stacked layers: too complex
148
- const url = css.match(/url\(["']?(.*?)["']?\)/)?.[1];
149
- if (url) {
150
- let absolute;
151
- try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
152
- return verdictFromRange(await imageLuminanceRange(absolute), foreground, required, 'image');
153
- }
154
- if (css.includes('gradient(')) {
155
- return verdictFromRange(gradientLuminanceRange(css), foreground, required, 'gradient');
200
+ // An image we tried to read and couldn't: that is a specific, reportable
201
+ // fact, and the only one of these paths where cross-origin is the cause.
202
+ if (!range && what === 'image') {
203
+ return {
204
+ status: 'incomplete',
205
+ message: 'The image painted behind this text can’t be read: it is cross-origin, or it failed to load, so a script can’t sample its colours. Check the contrast by eye.',
206
+ };
156
207
  }
157
- return null;
208
+ return verdictFromRange(range, foreground, required, what);
158
209
  }
159
210
 
160
211
  /**
@@ -248,27 +299,44 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
248
299
  if (imageSource) {
249
300
  const { relation, intrinsic, dimensionless } = await imageVsText(imageSource, element, doc);
250
301
  if (relation !== 'clear') {
251
- const sampled = await sampledVerdict(imageSource, foreground, required, doc);
252
- // Icon-scale images: bullets, arrows, and decorations painted
253
- // beside or beneath a corner of the text — their pixels may flag
254
- // for eyes, never assert a failure. Icon-scale means: no reliable
255
- // intrinsic size, an intrinsically tiny image, OR a background
256
- // carrier whose own box is line-height-sized (contain/cover paint
257
- // can never exceed the carrier — a 512px nominal SVG painted on an
258
- // inline link is still an icon). Only backdrop-scale images earn a
259
- // definite fail from sampling.
260
- const carrierBox = imageSource.element.getBoundingClientRect();
261
- if (sampled?.status === 'fail'
262
- && (dimensionless || (intrinsic.width <= 32 && intrinsic.height <= 32) || carrierBox.height <= 40)) {
263
- return {
302
+ // imageSource.overlays are the translucent layers painted between the
303
+ // image and this text (hero scrims, tint panels). Judging the raw
304
+ // image pixels would describe a page the user never sees.
305
+ let sampled = await sampledVerdict(imageSource, foreground, required, doc, imageSource.overlays);
306
+ // A layer with no opaque pixels changes nothing about what is behind
307
+ // the glyphs, so it must not block the verdict: fall through and
308
+ // judge the background that actually paints.
309
+ if (sampled !== PAINTS_NOTHING) {
310
+ // Scrims the ancestor walk cannot reach (pseudo-elements, positioned
311
+ // siblings) leave the sampled range describing the wrong pixels, so
312
+ // neither verdict is safe to assert from it.
313
+ if (sampled && unaccountedScrim(element, imageSource.element)) {
314
+ sampled = {
315
+ status: 'incomplete',
316
+ message: 'This text sits over a background image with a translucent overlay painted on top of it (a pseudo-element or positioned scrim), so the colour behind the glyphs is the blend of the two — check the contrast by eye.',
317
+ };
318
+ }
319
+ // Icon-scale images: bullets, arrows, and decorations painted
320
+ // beside or beneath a corner of the text — their pixels may flag
321
+ // for eyes, never assert a failure. Icon-scale means: no reliable
322
+ // intrinsic size, an intrinsically tiny image, OR a background
323
+ // carrier whose own box is line-height-sized (contain/cover paint
324
+ // can never exceed the carrier — a 512px nominal SVG painted on an
325
+ // inline link is still an icon). Only backdrop-scale images earn a
326
+ // definite fail from sampling.
327
+ const carrierBox = imageSource.element.getBoundingClientRect();
328
+ if (sampled?.status === 'fail'
329
+ && (dimensionless || (intrinsic.width <= 32 && intrinsic.height <= 32) || carrierBox.height <= 40)) {
330
+ return {
331
+ status: 'incomplete',
332
+ message: 'A small background icon is painted on this element — if it sits beside the text rather than under it, judge the contrast against the plain background by eye.',
333
+ };
334
+ }
335
+ return sampled ?? {
264
336
  status: 'incomplete',
265
- message: 'A small background icon is painted on this element — if it sits beside the text rather than under it, judge the contrast against the plain background by eye.',
337
+ message: 'The background here is several stacked image or gradient layers, which a script can’t sample. Contrast must be checked by eye.',
266
338
  };
267
339
  }
268
- return sampled ?? {
269
- status: 'incomplete',
270
- message: 'The background here is an image that can’t be read (cross-origin) — contrast must be checked by eye.',
271
- };
272
340
  }
273
341
  }
274
342
 
@@ -319,17 +387,23 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
319
387
  // exactly as an element's own background-image is bracketed.
320
388
  if (pseudoBack?.image) {
321
389
  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
- };
390
+ // PAINTS_NOTHING: the pseudo-element's image is fully transparent, so
391
+ // it never displaced the background the walk resolved.
392
+ if (sampled !== PAINTS_NOTHING) {
393
+ return sampled ?? {
394
+ status: 'incomplete',
395
+ 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.',
396
+ };
397
+ }
326
398
  }
327
399
  if (painted?.image && !pseudoBack) {
328
400
  const sampled = await sampledVerdict(painted.image, foreground, required, doc);
329
- return sampled ?? {
330
- status: 'incomplete',
331
- message: 'An image or overlapping element is painted behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
332
- };
401
+ if (sampled !== PAINTS_NOTHING) {
402
+ return sampled ?? {
403
+ status: 'incomplete',
404
+ message: 'An image or overlapping element is painted behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
405
+ };
406
+ }
333
407
  }
334
408
  // Only a hit-test-resolved backdrop is VERIFIED paint truth. Anything
335
409
  // else (offscreen sample point, element invisible to its own hit-test —
@@ -346,10 +420,12 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
346
420
  const obscuring = backgroundObscured(element);
347
421
  if (obscuring && obscuring !== 'unverifiable') {
348
422
  const sampled = await sampledVerdict(obscuring, foreground, required, doc);
349
- return sampled ?? {
350
- status: 'incomplete',
351
- message: 'An image or overlapping element is painted behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
352
- };
423
+ if (sampled !== PAINTS_NOTHING) {
424
+ return sampled ?? {
425
+ status: 'incomplete',
426
+ message: 'An image or overlapping element is painted behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
427
+ };
428
+ }
353
429
  }
354
430
  }
355
431
  if (pseudoBack?.color) {
@@ -8,9 +8,23 @@ export default {
8
8
  selector: 'meta[http-equiv="refresh" i]',
9
9
  visibleOnly: false,
10
10
  evaluate(element) {
11
- const delay = parseInt(element.getAttribute('content') ?? '', 10);
12
- if (!(delay > 0)) return { status: 'pass' }; // 0 = immediate redirect, allowed
13
- if (delay >= 72000) return { status: 'pass' }; // 2.2.1 exempts limits over 20 hours
11
+ const content = element.getAttribute('content') ?? '';
12
+ const delay = parseInt(content, 10);
13
+ // A refresh with no destination reloads THIS page. At zero seconds that
14
+ // is not the allowed instant redirect — it is a loop that throws the
15
+ // user back to the top of the page over and over (failure F41).
16
+ const hasDestination = /;\s*url\s*=/i.test(content);
17
+ if (delay === 0 && !hasDestination) {
18
+ return {
19
+ status: 'fail',
20
+ message: 'The page reloads itself immediately and has no destination to move on to, so the reload repeats for as long as the page is open. Nobody can read or complete anything on it.',
21
+ fix: 'Remove the timed refresh. If this is meant to be a redirect, give it a url (content="0; url=…"); if the content needs updating, update it in place instead of reloading.',
22
+ };
23
+ }
24
+ if (!(delay > 0)) return { status: 'pass' }; // 0 with a url = immediate redirect, allowed
25
+ // 2.2.1 exempts a time limit "longer than 20 hours" — 20 hours exactly
26
+ // is still inside the criterion.
27
+ if (delay > 72000) return { status: 'pass' };
14
28
  return {
15
29
  status: 'fail',
16
30
  message: `The page refreshes/redirects after ${delay}s — slow readers lose their place (or the whole page) with no control.`,
@@ -1,25 +1,117 @@
1
1
  // WCAG SC 2.2.2 Pause, Stop, Hide (Level A)
2
+ // "For any moving, blinking or scrolling information that (1) starts
3
+ // automatically, (2) lasts more than five seconds, and (3) is presented in
4
+ // parallel with other content, there is a mechanism for the user to pause,
5
+ // stop, or hide it…"
6
+ //
7
+ // Two shapes are checkable, and they need different verdicts.
8
+ //
9
+ // <marquee> and <blink> exist only to move, ship no control of their own,
10
+ // and are obsolete: a definite failure.
11
+ //
12
+ // A CSS animation set to repeat for ever is the modern ticker. Clauses 1
13
+ // and 2 are settled by the CSS itself — it starts with the page and never
14
+ // ends — but clause 3's "mechanism" may be any button anywhere on the page,
15
+ // and no static pass can tell a pause control from any other button. So the
16
+ // finding goes to a human with the evidence attached, never asserted.
17
+ //
18
+ // Only motion carrying TEXT is reported. Spinners, drifting decorative
19
+ // blobs and shimmer effects move too, and flagging every loading indicator
20
+ // on the web would bury the finding that matters. Silence there is
21
+ // abstaining, not clearing.
22
+ const MOTION_PROPERTIES = new Set([
23
+ 'transform', 'translate', 'rotate', 'scale', 'offsetDistance', 'offsetPath', 'offsetRotate',
24
+ 'left', 'right', 'top', 'bottom',
25
+ 'marginLeft', 'marginRight', 'marginTop', 'marginBottom',
26
+ 'backgroundPosition', 'backgroundPositionX', 'backgroundPositionY', 'objectPosition',
27
+ ]);
28
+
29
+ /** Does this keyframe set move the element around, rather than merely
30
+ * restyle it? A hue cycle is not movement; a translate is. */
31
+ function keyframesMove(keyframes) {
32
+ return keyframes.some((frame) => Object.keys(frame).some((property) => MOTION_PROPERTIES.has(property)));
33
+ }
34
+
35
+ /** Blinking: the element is repeatedly taken away and put back. A gentle
36
+ * breathing pulse (0.6 → 1) is not what the criterion is about, so an
37
+ * opacity cycle only counts when it reaches near-invisible AND near-full. */
38
+ function keyframesBlink(keyframes) {
39
+ if (keyframes.some((frame) => frame.visibility === 'hidden')) return true;
40
+ const opacities = keyframes
41
+ .map((frame) => parseFloat(frame.opacity))
42
+ .filter((value) => !Number.isNaN(value));
43
+ return opacities.some((value) => value <= 0.25) && opacities.some((value) => value >= 0.75);
44
+ }
45
+
46
+ /**
47
+ * Elements that an endlessly repeating animation is currently moving,
48
+ * mapped to the evidence. One document-wide getAnimations() call, never one
49
+ * per element: per-node getAnimations() filters every animation on the page
50
+ * each time, which turns animation-heavy pages quadratic.
51
+ */
52
+ function endlesslyMoving(doc) {
53
+ const moving = new Map();
54
+ if (typeof doc.getAnimations !== 'function') return moving;
55
+ let animations;
56
+ try { animations = doc.getAnimations(); } catch { return moving; }
57
+ for (const animation of animations) {
58
+ // Paused, finished, or reversed-to-a-stop animations move nothing.
59
+ if (animation.playState !== 'running' || !animation.playbackRate) continue;
60
+ // Scroll- and view-driven animations advance only as the user scrolls,
61
+ // so they do not "start automatically" in the sense of the criterion.
62
+ if (animation.timeline && animation.timeline !== doc.timeline) continue;
63
+ const effect = animation.effect;
64
+ const target = effect?.target;
65
+ if (!target || target.nodeType !== 1) continue;
66
+ const timing = effect.getComputedTiming?.() ?? {};
67
+ if (timing.iterations !== Infinity || !(timing.duration > 0)) continue;
68
+ let keyframes;
69
+ try { keyframes = effect.getKeyframes(); } catch { continue; }
70
+ const moves = keyframesMove(keyframes);
71
+ if (!moves && !keyframesBlink(keyframes)) continue;
72
+ // A pseudo-element's motion is still the host element's motion.
73
+ if (!moving.has(target)) {
74
+ moving.set(target, { name: animation.animationName, verb: moves ? 'moves' : 'blinks' });
75
+ }
76
+ }
77
+ return moving;
78
+ }
79
+
2
80
  export default {
3
81
  id: 'pause-stop-hide',
4
82
  impact: 'serious',
5
83
  tags: ['wcag2a', 'wcag222'],
6
84
  help: 'Moving content must be pausable',
7
85
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html',
8
- selector: 'marquee, blink',
86
+ selector: '*',
9
87
  // The criterion is about VISIBLE movement — a display:none marquee moves
10
88
  // nothing. aria-hidden content still moves on screen, so the rendered
11
89
  // check (not the AT-exposure check) is the right filter.
12
90
  visibility: 'visual',
13
- evaluate(element) {
14
- // scrollamount="0" is the documented way to ship a <marquee> that
15
- // doesn't actually move.
16
- if (element.tagName === 'MARQUEE' && element.getAttribute('scrollamount') === '0') {
17
- return { status: 'pass' };
18
- }
19
- return {
20
- status: 'fail',
21
- message: `<${element.tagName.toLowerCase()}> scrolls or blinks with no way to pause it, which is unusable for people with attention or vestibular conditions.`,
22
- fix: 'Replace it with static content, or a CSS animation with a pause control that honours prefers-reduced-motion.',
23
- };
91
+ // One getAnimations() call for the whole page, then a lookup per element.
92
+ evaluateAll(elements) {
93
+ if (!elements.length) return [];
94
+ const moving = endlesslyMoving(elements[0].ownerDocument);
95
+ return elements.map((element) => {
96
+ if (element.tagName === 'MARQUEE' || element.tagName === 'BLINK') {
97
+ // scrollamount="0" is the documented way to ship a <marquee> that
98
+ // doesn't actually move.
99
+ if (element.tagName === 'MARQUEE' && element.getAttribute('scrollamount') === '0') {
100
+ return { status: 'pass' };
101
+ }
102
+ return {
103
+ status: 'fail',
104
+ message: `<${element.tagName.toLowerCase()}> scrolls or blinks with no way to pause it, which is unusable for people with attention or vestibular conditions.`,
105
+ fix: 'Replace it with static content, or a CSS animation with a pause control that honours prefers-reduced-motion.',
106
+ };
107
+ }
108
+ const animation = moving.get(element);
109
+ if (!animation || !element.textContent.trim()) return { status: 'pass' };
110
+ const named = animation.name ? ` (the “${animation.name}” animation)` : '';
111
+ return {
112
+ status: 'incomplete',
113
+ message: `This content ${animation.verb} continuously${named}: the animation repeats for ever, so it runs well past the five seconds at which WCAG 2.2.2 requires a way to pause, stop or hide it. Check the page offers one, and that it reaches this content.`,
114
+ };
115
+ });
24
116
  },
25
117
  };
@@ -1,4 +1,33 @@
1
1
  // WCAG SC 2.4.2 Page Titled (Level A)
2
+ // "Web pages have titles that describe topic or purpose." A title that is
3
+ // merely PRESENT satisfies the markup, not the criterion, so the two
4
+ // well-known undescriptive shapes are checked as well.
5
+ //
6
+ // Tier one, failed: strings that are a stand-in under every reading.
7
+ // "Untitled Document" is what an editor writes when the author wrote
8
+ // nothing, and no page is ever about that.
9
+ // Tier two, sent to a human: single generic words and framework scaffold
10
+ // defaults. Almost always a leftover, but a gallery page really can be about
11
+ // an artwork called "Untitled", and "Example Domain" really is the subject
12
+ // of example.com. Those are somebody's call to make, not the engine's.
13
+ //
14
+ // Deliberately NOT judged: whether a real-looking title matches the page it
15
+ // sits on. That needs someone who can read the page, and guessing at it
16
+ // would produce exactly the confident-but-wrong findings this engine avoids.
17
+ const PLACEHOLDER = new Set([
18
+ 'untitled document', 'untitled page', 'untitled-1', 'untitled 1',
19
+ 'new document', 'new page', 'new tab', 'no title',
20
+ 'insert title here', 'title here', 'page title', 'document title',
21
+ 'web page', 'webpage',
22
+ ]);
23
+ const UNDESCRIPTIVE = new Set([
24
+ 'untitled', 'document', 'page', 'title', 'default', 'index',
25
+ 'react app', 'create react app', 'vite app', 'vite + react', 'vite + vue', 'vite + svelte',
26
+ 'next app', 'create next app', 'vue app', 'nuxt app', 'svelte app', 'angular app',
27
+ 'my app', 'my site', 'my website', 'site title', 'your site title',
28
+ 'example domain', 'hello world',
29
+ ]);
30
+
2
31
  export default {
3
32
  id: 'document-title',
4
33
  impact: 'serious',
@@ -8,11 +37,29 @@ export default {
8
37
  selector: 'html',
9
38
  visibleOnly: false,
10
39
  evaluate(element) {
11
- if (element.ownerDocument.title.trim()) return { status: 'pass' };
12
- return {
13
- status: 'fail',
14
- message: 'The page has no title, so users of screen readers and browser tabs cannot tell what it is.',
15
- fix: 'Add <title>Page name — Site name</title> inside <head>.',
16
- };
40
+ const title = element.ownerDocument.title.trim();
41
+ if (!title) {
42
+ return {
43
+ status: 'fail',
44
+ message: 'The page has no title, so users of screen readers and browser tabs cannot tell what it is.',
45
+ fix: 'Add <title>Page name — Site name</title> inside <head>.',
46
+ };
47
+ }
48
+ // Collapsed and lowercased so "Untitled Document" is caught too.
49
+ const normalized = title.replace(/\s+/g, ' ').toLowerCase();
50
+ if (PLACEHOLDER.has(normalized) || /^untitled[\s-]*\d+$/.test(normalized)) {
51
+ return {
52
+ status: 'fail',
53
+ message: `“${title}” is a placeholder left by an editor or template. It gives no topic and no purpose, so a screen reader announces nothing useful when the page loads, and a row of open tabs, bookmarks or history entries becomes impossible to tell apart.`,
54
+ fix: 'Put this page\'s own subject in the title first, then the site name.',
55
+ };
56
+ }
57
+ if (UNDESCRIPTIVE.has(normalized)) {
58
+ return {
59
+ status: 'incomplete',
60
+ message: `“${title}” is a generic title, and usually a default nobody replaced. Unless the page really is about that, it describes neither topic nor purpose and 2.4.2 is not met. Check whether it was meant to be filled in.`,
61
+ };
62
+ }
63
+ return { status: 'pass' };
17
64
  },
18
65
  };
@@ -5,19 +5,43 @@
5
5
  // rule entirely — so this flags for review, it never asserts a fail.
6
6
  function focusSuppressors(doc) {
7
7
  const suspects = [];
8
+ const replacementIn = (style) => style.getPropertyValue('box-shadow') || style.getPropertyValue('border')
9
+ || style.getPropertyValue('border-color') || style.getPropertyValue('background')
10
+ || style.getPropertyValue('background-color') || style.getPropertyValue('text-decoration');
11
+
12
+ const inspect = (rule) => {
13
+ if (!rule.selectorText || !/:focus/.test(rule.selectorText)) return;
14
+ // `:focus:not(:focus-visible) { outline: none }` is the PUBLISHED pattern
15
+ // for showing the ring to keyboard users only: it strips the indicator
16
+ // precisely in the state where the browser has decided not to show focus,
17
+ // and leaves the keyboard state alone. Flagging it fails the pattern the
18
+ // guidance recommends, and it ships in most modern CSS resets.
19
+ if (/:not\(\s*:focus-visible\s*\)/.test(rule.selectorText)) return;
20
+ const style = rule.style;
21
+ if (!style) return;
22
+ const outline = `${style.getPropertyValue('outline')} ${style.getPropertyValue('outline-style')} ${style.getPropertyValue('outline-width')}`;
23
+ const removesOutline = /\bnone\b/.test(outline) || /(^|\s)0(px)?(\s|$)/.test(outline);
24
+ if (!removesOutline) return;
25
+ // A replacement indicator may not sit in the rule's own style block: any
26
+ // declaration written AFTER a nested rule is parsed into a separate
27
+ // CSSNestedDeclarations child (a style-bearing rule with no selector), so
28
+ // reading only rule.style would report a suppressor that is in fact
29
+ // replaced two lines further down.
30
+ const nested = [...(rule.cssRules ?? [])]
31
+ .filter((child) => child.style && !child.selectorText);
32
+ if (replacementIn(style) || nested.some((child) => replacementIn(child.style))) return;
33
+ suspects.push(rule.selectorText);
34
+ };
35
+ // Inspect each rule, THEN descend into any children. Testing `rule.cssRules`
36
+ // first and skipping used to walk straight past every plain style rule:
37
+ // since CSS Nesting shipped, a CSSStyleRule carries its own cssRules list,
38
+ // and an empty CSSRuleList is still a truthy object, so the check meant for
39
+ // @media/@supports swallowed the whole sheet and the rule could never fire.
40
+ // A style rule can both declare and nest, so both halves have to run.
8
41
  const scan = (rules) => {
9
42
  for (const rule of rules) {
10
- if (rule.cssRules) { scan(rule.cssRules); continue; } // @media/@supports
11
- if (!rule.selectorText || !/:focus/.test(rule.selectorText)) continue;
12
- const style = rule.style;
13
- if (!style) continue;
14
- const outline = `${style.getPropertyValue('outline')} ${style.getPropertyValue('outline-style')} ${style.getPropertyValue('outline-width')}`;
15
- const removesOutline = /\bnone\b/.test(outline) || /(^|\s)0(px)?(\s|$)/.test(outline);
16
- if (!removesOutline) continue;
17
- const replaced = style.getPropertyValue('box-shadow') || style.getPropertyValue('border')
18
- || style.getPropertyValue('border-color') || style.getPropertyValue('background')
19
- || style.getPropertyValue('background-color') || style.getPropertyValue('text-decoration');
20
- if (!replaced) suspects.push(rule.selectorText);
43
+ inspect(rule);
44
+ if (rule.cssRules?.length) scan(rule.cssRules); // @media/@supports/nesting
21
45
  }
22
46
  };
23
47
  for (const sheet of doc.styleSheets) {
@@ -1,6 +1,14 @@
1
1
  // WCAG SC 3.1.1 Language of Page (Level A)
2
- // Rough BCP 47 shape: primary subtag + optional extras ("en", "en-GB", "zh-Hans").
3
- const LANG_PATTERN = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
2
+ // Rough BCP 47 shape: primary subtag + optional extras ("en", "en-GB",
3
+ // "zh-Hans"). Subtags may be a SINGLE character: BCP 47 extension and
4
+ // private-use singletons are exactly that ("de-DE-u-co-phonebk",
5
+ // "zh-CN-x-private"), so a 2-character floor rejects legal tags. The second
6
+ // branch covers private-use-only and grandfathered tags ("x-klingon",
7
+ // "i-klingon"), whose primary subtag is itself one character.
8
+ // A 5-8 letter primary subtag is legal BCP 47 but unassigned in the IANA
9
+ // registry, so it stays rejected: that is what catches lang="english".
10
+ // Keep this in step with 3.1.2-valid-lang-parts.js.
11
+ const LANG_PATTERN = /^([a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*|[xXiI](-[a-zA-Z0-9]{1,8})+)$/;
4
12
 
5
13
  export default {
6
14
  id: 'html-lang',
@@ -1,5 +1,8 @@
1
1
  // WCAG SC 3.1.2 Language of Parts (Level AA)
2
- const LANG_PATTERN = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
2
+ // Same shape as 3.1.1-html-lang.js, and must stay in step with it: subtags
3
+ // may be a single character, because BCP 47 extension and private-use
4
+ // singletons ("-u-", "-t-", "-x-") are exactly one.
5
+ const LANG_PATTERN = /^([a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*|[xXiI](-[a-zA-Z0-9]{1,8})+)$/;
3
6
 
4
7
  export default {
5
8
  id: 'valid-lang-parts',
@@ -1,4 +1,6 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ import { cumulativeOpacity } from '../../lib/contrast.js';
3
+
2
4
  const FOCUSABLE = 'a[href], area[href], button, input, select, textarea, summary, iframe, '
3
5
  + 'audio[controls], video[controls], [contenteditable]:not([contenteditable="false"]), [tabindex]';
4
6
 
@@ -17,12 +19,15 @@ export default {
17
19
  // focus lands there, so the harm this rule describes (sighted keyboard
18
20
  // users interacting with SR-invisible content) doesn't materialise —
19
21
  // an invisible tab stop is a focus-order/visible-focus concern instead.
22
+ // Transparency is measured up the flat tree: opacity is not an inherited
23
+ // property, so a faded WRAPPER (the standard off-canvas drawer) leaves
24
+ // every control inside it computing opacity: 1 while none of them paint.
20
25
  const focusable = [element, ...element.querySelectorAll(FOCUSABLE)].filter(
21
26
  (el) => el.matches?.(FOCUSABLE) && el.getAttribute('tabindex') !== '-1' && !el.disabled
22
27
  // `inert` removes focusability outright — aria-hidden + inert is
23
28
  // the CORRECT modern pattern for off-canvas content, not a defect.
24
29
  && !el.closest('[inert]')
25
- && isRendered(el) && getComputedStyle(el).opacity !== '0',
30
+ && isRendered(el) && cumulativeOpacity(el) > 0,
26
31
  );
27
32
  if (!focusable.length) return { status: 'pass' };
28
33
  // While a modal dialog is open, the page behind it is DELIBERATELY
package/engine/wcag22.js CHANGED
@@ -68,7 +68,7 @@ export default [
68
68
  sc('2.3.2', 'Three Flashes', 'AAA', 'manual'),
69
69
  sc('2.3.3', 'Animation from Interactions', 'AAA', 'manual', '2.1'),
70
70
  sc('2.4.1', 'Bypass Blocks', 'A', 'partial'), // partial: "repeated across pages" can't be established from one page
71
- sc('2.4.2', 'Page Titled', 'A', 'auto'),
71
+ sc('2.4.2', 'Page Titled', 'A', 'partial'), // partial: document-title proves a title exists and catches known placeholders; whether a real one DESCRIBES the page needs a reader
72
72
  sc('2.4.3', 'Focus Order', 'A', 'manual'), // no rule automates this yet; order judgment needs humans
73
73
  sc('2.4.4', 'Link Purpose (In Context)', 'A', 'partial'),
74
74
  sc('2.4.5', 'Multiple Ways', 'AA', 'manual'),
@@ -89,7 +89,7 @@ export default [
89
89
  sc('2.5.7', 'Dragging Movements', 'AA', 'manual', '2.2'),
90
90
  sc('2.5.8', 'Target Size (Minimum)', 'AA', 'partial', '2.2'), // partial: size/spacing are computable; the equivalent-control and essential exceptions need judgment
91
91
  // 3. Understandable
92
- sc('3.1.1', 'Language of Page', 'A', 'auto'),
92
+ sc('3.1.1', 'Language of Page', 'A', 'partial'), // partial: html-lang proves the tag is present and well-formed; whether it names the language actually written needs a reader
93
93
  sc('3.1.2', 'Language of Parts', 'AA', 'partial'), // partial: invalid lang values are provable; UNMARKED foreign passages need language identification
94
94
  sc('3.1.3', 'Unusual Words', 'AAA', 'manual'),
95
95
  sc('3.1.4', 'Abbreviations', 'AAA', 'manual'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.1.9",
3
+ "version": "1.3.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",