@setsquare/review-sdk 0.0.1 → 0.1.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.
Files changed (105) hide show
  1. package/NOTICE +3 -2
  2. package/README.md +220 -0
  3. package/dist/autoscan.d.ts +15 -1
  4. package/dist/autoscan.d.ts.map +1 -1
  5. package/dist/autoscan.js +168 -13
  6. package/dist/autoscan.js.map +1 -1
  7. package/dist/brand.d.ts +51 -2
  8. package/dist/brand.d.ts.map +1 -1
  9. package/dist/brand.js +560 -5
  10. package/dist/brand.js.map +1 -1
  11. package/dist/checks/context-change.d.ts +96 -0
  12. package/dist/checks/context-change.d.ts.map +1 -0
  13. package/dist/checks/context-change.js +655 -0
  14. package/dist/checks/context-change.js.map +1 -0
  15. package/dist/checks/focus.d.ts +154 -0
  16. package/dist/checks/focus.d.ts.map +1 -0
  17. package/dist/checks/focus.js +1123 -0
  18. package/dist/checks/focus.js.map +1 -0
  19. package/dist/checks/heuristics.d.ts +116 -0
  20. package/dist/checks/heuristics.d.ts.map +1 -0
  21. package/dist/checks/heuristics.js +931 -0
  22. package/dist/checks/heuristics.js.map +1 -0
  23. package/dist/checks/keyboard.d.ts +106 -0
  24. package/dist/checks/keyboard.d.ts.map +1 -0
  25. package/dist/checks/keyboard.js +452 -0
  26. package/dist/checks/keyboard.js.map +1 -0
  27. package/dist/checks/reflow.d.ts +75 -0
  28. package/dist/checks/reflow.d.ts.map +1 -0
  29. package/dist/checks/reflow.js +285 -0
  30. package/dist/checks/reflow.js.map +1 -0
  31. package/dist/checks/rule-pack.d.ts +1 -1
  32. package/dist/checks/rule-pack.d.ts.map +1 -1
  33. package/dist/checks/rule-pack.js +350 -45
  34. package/dist/checks/rule-pack.js.map +1 -1
  35. package/dist/checks/shortcuts.d.ts +71 -0
  36. package/dist/checks/shortcuts.d.ts.map +1 -0
  37. package/dist/checks/shortcuts.js +196 -0
  38. package/dist/checks/shortcuts.js.map +1 -0
  39. package/dist/checks/versions.d.ts.map +1 -1
  40. package/dist/checks/versions.js +33 -1
  41. package/dist/checks/versions.js.map +1 -1
  42. package/dist/contract.d.ts +18 -0
  43. package/dist/contract.d.ts.map +1 -1
  44. package/dist/covers.d.ts +56 -0
  45. package/dist/covers.d.ts.map +1 -0
  46. package/dist/covers.js +135 -0
  47. package/dist/covers.js.map +1 -0
  48. package/dist/dedupe.d.ts +23 -0
  49. package/dist/dedupe.d.ts.map +1 -1
  50. package/dist/dedupe.js +96 -2
  51. package/dist/dedupe.js.map +1 -1
  52. package/dist/document.d.ts.map +1 -1
  53. package/dist/document.js +144 -0
  54. package/dist/document.js.map +1 -1
  55. package/dist/envelope.d.ts +8 -0
  56. package/dist/envelope.d.ts.map +1 -1
  57. package/dist/envelope.js +28 -0
  58. package/dist/envelope.js.map +1 -1
  59. package/dist/fixture.d.ts.map +1 -1
  60. package/dist/fixture.js +12 -0
  61. package/dist/fixture.js.map +1 -1
  62. package/dist/html-report.d.ts +24 -7
  63. package/dist/html-report.d.ts.map +1 -1
  64. package/dist/html-report.js +608 -136
  65. package/dist/html-report.js.map +1 -1
  66. package/dist/index.d.ts +13 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +13 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/links.d.ts +43 -0
  71. package/dist/links.d.ts.map +1 -1
  72. package/dist/links.js +43 -0
  73. package/dist/links.js.map +1 -1
  74. package/dist/observe.d.ts +8 -0
  75. package/dist/observe.d.ts.map +1 -1
  76. package/dist/observe.js +45 -1
  77. package/dist/observe.js.map +1 -1
  78. package/dist/options.d.ts +53 -0
  79. package/dist/options.d.ts.map +1 -1
  80. package/dist/options.js +3 -0
  81. package/dist/options.js.map +1 -1
  82. package/dist/reading-level.d.ts +22 -1
  83. package/dist/reading-level.d.ts.map +1 -1
  84. package/dist/reading-level.js +122 -19
  85. package/dist/reading-level.js.map +1 -1
  86. package/dist/reporter.d.ts.map +1 -1
  87. package/dist/reporter.js +31 -15
  88. package/dist/reporter.js.map +1 -1
  89. package/dist/scan.d.ts +36 -1
  90. package/dist/scan.d.ts.map +1 -1
  91. package/dist/scan.js +16 -2
  92. package/dist/scan.js.map +1 -1
  93. package/dist/screens.d.ts +55 -4
  94. package/dist/screens.d.ts.map +1 -1
  95. package/dist/screens.js +243 -43
  96. package/dist/screens.js.map +1 -1
  97. package/dist/summary.d.ts +22 -0
  98. package/dist/summary.d.ts.map +1 -1
  99. package/dist/summary.js +197 -22
  100. package/dist/summary.js.map +1 -1
  101. package/dist/viewports.d.ts +105 -0
  102. package/dist/viewports.d.ts.map +1 -0
  103. package/dist/viewports.js +134 -0
  104. package/dist/viewports.js.map +1 -0
  105. package/package.json +5 -4
@@ -25,7 +25,13 @@ import { LINK_PURPOSE_LINK_ONLY_HELP_URL, TARGET_SIZE_ENHANCED_HELP_URL, VISUAL_
25
25
  * boundary. Version every behavioural change via SETSQUARE_RULE_PACK_VERSION — it
26
26
  * feeds the ruleset digest.
27
27
  */
28
- export const SETSQUARE_RULE_PACK_VERSION = 'setsquare-rules@1';
28
+ // @2 measures the character count with the real `ch` metric rather than a
29
+ // half-em guess, scopes line spacing to paragraphs that have lines, and puts
30
+ // the measurements in the message. @3 scopes the rule to blocks of text —
31
+ // WCAG's own "more than one sentence" — and reports a VIOLATION rather than a
32
+ // judgement, for consistency with how 2.1.2 already handles the same
33
+ // mechanism clause.
34
+ export const SETSQUARE_RULE_PACK_VERSION = 'setsquare-rules@6';
29
35
  export const SETSQUARE_RULE_IDS = [
30
36
  'setsquare-target-size-aaa',
31
37
  'setsquare-visual-presentation',
@@ -65,36 +71,131 @@ export function rulePackSource() {
65
71
  // because a rule author's care is not a boundary (D13).
66
72
  var href = (node.getAttribute('href') || '').split('#')[0].split('?')[0];
67
73
 
74
+ /*
75
+ * A DIGEST of the accessible name, never the name itself.
76
+ *
77
+ * The after() phase has to know whether two controls share a name,
78
+ * and for a while it read a data.name this check does not set — so
79
+ * the equivalent-by-name branch below could not fire at all, and
80
+ * every same-named small control was reported as a flat failure with
81
+ * the exception never considered. The fixture lane found it the day
82
+ * it first ran in a browser.
83
+ *
84
+ * The fix is not to start carrying the name: the rule above still
85
+ * holds, and this finding reads the same without the page's text. A
86
+ * 32-bit FNV-1a of the normalised name answers "same or not" without
87
+ * the value being recoverable from it, which is the only question
88
+ * after() asks.
89
+ */
90
+ var label = (node.getAttribute('aria-label') || node.textContent || '')
91
+ .replace(/\s+/g, ' ')
92
+ .trim()
93
+ .toLowerCase();
94
+ var nameKey = '';
95
+ if (label) {
96
+ var hash = 2166136261;
97
+ for (var c = 0; c < label.length; c += 1) {
98
+ hash ^= label.charCodeAt(c);
99
+ hash = (hash * 16777619) >>> 0;
100
+ }
101
+ nameKey = hash.toString(16);
102
+ }
103
+
68
104
  this.data({
69
105
  width: width,
70
106
  height: height,
71
107
  href: href,
108
+ nameKey: nameKey,
72
109
  });
73
110
 
74
111
  if (width >= 44 && height >= 44) return true;
75
112
 
76
- // Inline exception: a target in a sentence or line of text is exempt.
113
+ /*
114
+ * INLINE EXCEPTION: the target is in a sentence or block of text.
115
+ *
116
+ * This PASSES rather than asking a person, and the difference is the
117
+ * whole point of the exception. An inline element with a text
118
+ * sibling in its parent IS in a sentence — that is what the phrase
119
+ * describes, and the DOM says so plainly. The check used to detect
120
+ * exactly this condition and then return needs-review with the
121
+ * comment "a person decides", which sent every reference link in
122
+ * every paragraph of this repository's own site to a human with
123
+ * nothing to decide: the exception it had just matched exempts them.
124
+ */
77
125
  var display = window.getComputedStyle(node).display;
78
126
  if (display === 'inline' || display === 'inline-block') {
79
127
  var parent = node.parentElement;
80
128
  if (parent) {
81
129
  for (var i = 0; i < parent.childNodes.length; i += 1) {
82
130
  var sibling = parent.childNodes[i];
83
- if (sibling.nodeType === 3 && sibling.textContent && sibling.textContent.trim()) {
84
- // Sits amongst text: the inline exception may apply — a person decides.
85
- return undefined;
131
+ if (
132
+ sibling !== node &&
133
+ sibling.nodeType === 3 &&
134
+ sibling.textContent &&
135
+ sibling.textContent.trim()
136
+ ) {
137
+ this.data({
138
+ width: width,
139
+ height: height,
140
+ href: href,
141
+ nameKey: nameKey,
142
+ exception: 'inline',
143
+ });
144
+ return true;
86
145
  }
87
146
  }
88
147
  }
89
148
  }
90
149
 
91
- // User-agent exception: unstyled native checkboxes/radios are the
92
- // browser's own size, which the SC exempts.
150
+ /*
151
+ * USER-AGENT EXCEPTION: the size is the browser's and the author has
152
+ * not modified it.
153
+ *
154
+ * Also decidable, by asking the browser. An unstyled control of the
155
+ * same kind is rendered once, measured, and removed; a control
156
+ * matching that size is the user agent's own and is exempt, and one
157
+ * that does not match has been resized by somebody and is not. The
158
+ * check used to return needs-review for every native checkbox and
159
+ * radio without looking, which exempts the author's 20px checkbox
160
+ * along with the browser's.
161
+ */
93
162
  if (
94
163
  node.tagName === 'INPUT' &&
95
164
  (node.type === 'checkbox' || node.type === 'radio')
96
165
  ) {
97
- return undefined;
166
+ var cache = (window.__setsquareUaSize = window.__setsquareUaSize || {});
167
+ var reference = cache[node.type];
168
+ if (reference === undefined) {
169
+ reference = null;
170
+ try {
171
+ var probe = document.createElement('input');
172
+ probe.type = node.type;
173
+ probe.setAttribute('aria-hidden', 'true');
174
+ probe.style.position = 'absolute';
175
+ probe.style.left = '-9999px';
176
+ document.body.appendChild(probe);
177
+ var probeRect = probe.getBoundingClientRect();
178
+ reference = { w: Math.round(probeRect.width), h: Math.round(probeRect.height) };
179
+ probe.remove();
180
+ } catch (e) {
181
+ reference = null;
182
+ }
183
+ cache[node.type] = reference;
184
+ }
185
+
186
+ if (reference && width === reference.w && height === reference.h) {
187
+ this.data({
188
+ width: width,
189
+ height: height,
190
+ href: href,
191
+ nameKey: nameKey,
192
+ exception: 'user-agent',
193
+ });
194
+ return true;
195
+ }
196
+ // Resized by the author, so the exception does not apply and the
197
+ // measurement stands.
198
+ return false;
98
199
  }
99
200
 
100
201
  // Deterministically small; the Equivalent exception is ruled out (or
@@ -102,22 +203,52 @@ export function rulePackSource() {
102
203
  return false;
103
204
  },
104
205
  after: function (results) {
105
- // Equivalent exception: another target with the same function
106
- // (approximated by accessible name / destination) that IS large
107
- // enough. Cannot be decided per-node, so it is decided here, and it
108
- // downgrades a failure to needs-review rather than deleting it the
109
- // heuristic sees a name match, not a genuine functional equivalent.
110
- var bigEnough = {};
206
+ /*
207
+ * EQUIVALENT EXCEPTION, split by how certain it is.
208
+ *
209
+ * The SC exempts a target reachable through an equivalent control on
210
+ * the same page that IS big enough. Two ways to match one, and they
211
+ * do not deserve the same answer:
212
+ *
213
+ * same destination — the same normalised href, big enough
214
+ * somewhere else on the page. Same href is the same link; that
215
+ * is the exception, and it PASSES.
216
+ *
217
+ * same name only — a control with the same accessible name and a
218
+ * different destination. That may be a genuine equivalent and
219
+ * may be a coincidence of wording, and nothing in the DOM says
220
+ * which. Needs-review, which is what this exception is actually
221
+ * uncertain about.
222
+ *
223
+ * Both used to collapse into needs-review. Only the second one is a
224
+ * judgement.
225
+ */
226
+ var bigByHref = {};
227
+ var bigByName = {};
111
228
  results.forEach(function (result) {
112
- if (result.result === true && result.data) {
113
- var key = result.data.href || result.data.name;
114
- if (key) bigEnough[key] = true;
229
+ /*
230
+ * Passed ON SIZE, not passed for any reason. A row carrying an
231
+ * exception passed BECAUSE it is small and exempt — an inline link
232
+ * in a sentence, a 13px browser checkbox — and letting one of those
233
+ * stand as the "equivalent control that is big enough" would exempt
234
+ * every other small control sharing its name or destination on the
235
+ * strength of something smaller still.
236
+ */
237
+ if (result.result === true && result.data && !result.data.exception) {
238
+ if (result.data.href) bigByHref[result.data.href] = true;
239
+ if (result.data.nameKey) bigByName[result.data.nameKey] = true;
115
240
  }
116
241
  });
117
242
  results.forEach(function (result) {
118
- if (result.result === false && result.data) {
119
- var key = result.data.href || result.data.name;
120
- if (key && bigEnough[key]) result.result = undefined;
243
+ if (result.result !== false || !result.data) return;
244
+ if (result.data.href && bigByHref[result.data.href]) {
245
+ result.data.exception = 'equivalent-href';
246
+ result.result = true;
247
+ return;
248
+ }
249
+ if (result.data.nameKey && bigByName[result.data.nameKey]) {
250
+ result.data.exception = 'equivalent-name';
251
+ result.result = undefined;
121
252
  }
122
253
  });
123
254
  return results;
@@ -125,10 +256,17 @@ export function rulePackSource() {
125
256
  metadata: {
126
257
  impact: 'moderate',
127
258
  messages: {
128
- pass: 'Target is at least 44 by 44 CSS pixels',
129
- fail: 'Target is smaller than 44 by 44 CSS pixels and no exception applies',
259
+ pass: 'Target is at least 44 by 44 CSS pixels, or an exception applies',
260
+ fail:
261
+ 'Target measures \${data.width} by \${data.height} CSS pixels against the 44 by 44 ' +
262
+ 'WCAG 2.5.5 asks for, and none of the SC exceptions applies: it is not in a ' +
263
+ 'sentence, it is not a control the browser sized, and nothing else on the page ' +
264
+ 'goes to the same place at a larger size.',
130
265
  incomplete:
131
- 'Target is smaller than 44 by 44 CSS pixels; an exception (inline, user-agent default, or an equivalent control) may apply — needs a person',
266
+ 'Target measures \${data.width} by \${data.height} CSS pixels. Another control on ' +
267
+ 'the page shares its name at a larger size, which MAY be the equivalent-control ' +
268
+ 'exception — same wording is not the same function, and nothing in the markup ' +
269
+ 'says which. A person decides that one.',
132
270
  },
133
271
  },
134
272
  },
@@ -136,51 +274,149 @@ export function rulePackSource() {
136
274
  // WCAG 1.4.8 Visual Presentation, AAA. Implemented from the W3C
137
275
  // Understanding document for 1.4.8 (linked as this rule's helpUrl).
138
276
  // Scoped to the prongs an author actively defeats: justified text,
139
- // fixed line-height below 1.5, and measures past 80 characters
140
- // (40 for CJK). Always needs-review the SC requires a MECHANISM be
141
- // available, so a conflicting default is a signal, never a verdict.
277
+ // fixed line spacing below 1.5, and measures past 80 characters
278
+ // (40 for CJK) — and only on a BLOCK of text, which WCAG defines as
279
+ // more than one sentence.
142
280
  id: 'setsquare-visual-presentation-defaults',
143
281
  evaluate: function (node) {
144
282
  var style = window.getComputedStyle(node);
145
283
  var problems = [];
146
284
 
147
- if (style.textAlign === 'justify') problems.push('justified');
285
+ /*
286
+ * A BLOCK OF TEXT IS MORE THAN ONE SENTENCE. That is WCAG's own
287
+ * definition, and applying the SC without it is why this rule used
288
+ * to fire on card titles, standfirsts and one-line labels — a
289
+ * two-line heading styled as a paragraph has no leading to be
290
+ * insufficient and no measure to be too wide.
291
+ *
292
+ * Counted as sentence-ending punctuation followed by a space or the
293
+ * end of the text, which over-counts an abbreviation and under-counts
294
+ * a sentence ending in a bracket. Both errors are small and both
295
+ * point the same way — towards saying nothing.
296
+ *
297
+ * Note the DOUBLE backslash. This file is one long template literal,
298
+ * so a single one is eaten on the way out: the first version emitted
299
+ * a class followed by (s|$), which matches a full stop followed by a
300
+ * literal letter s, so nothing counted as a sentence and the whole
301
+ * rule silently passed everything on the site it was written for.
302
+ * Check the emitted source, not this one.
303
+ *
304
+ * And no backticks in this comment, for the reason the file header
305
+ * gives — one ends the string, and the parse error lands three
306
+ * checks away from the character that caused it. Which is how this
307
+ * note came to be rewritten.
308
+ */
309
+ var text = (node.textContent || '').trim();
310
+ var sentences = (text.match(/[.!?…](\\s|$)/g) || []).length;
311
+ if (sentences < 2) return true;
312
+
313
+ if (style.textAlign === 'justify') problems.push('justified text');
148
314
 
149
315
  var fontSize = parseFloat(style.fontSize);
150
316
  var lineHeight = parseFloat(style.lineHeight);
317
+ var rect = node.getBoundingClientRect();
318
+
319
+ // Line spacing is a property of a paragraph that HAS lines. A
320
+ // one-line paragraph — a standfirst, a label, a caption — has no
321
+ // leading to be insufficient, and flagging it reported a design
322
+ // decision as an accessibility signal.
323
+ var wraps =
324
+ isFinite(lineHeight) && lineHeight > 0 ? rect.height > lineHeight * 1.5 : false;
325
+
151
326
  if (
327
+ wraps &&
152
328
  style.lineHeight !== 'normal' &&
153
329
  isFinite(lineHeight) &&
154
330
  isFinite(fontSize) &&
155
331
  fontSize > 0 &&
156
332
  lineHeight / fontSize < 1.5
157
333
  ) {
158
- problems.push('line-height ' + (Math.round((lineHeight / fontSize) * 100) / 100));
334
+ problems.push(
335
+ 'line spacing ' +
336
+ (Math.round((lineHeight / fontSize) * 100) / 100) +
337
+ ' (needs 1.5)'
338
+ );
159
339
  }
160
340
 
161
341
  var lang = (node.closest('[lang]') || document.documentElement)
162
342
  .getAttribute('lang');
163
343
  var cjk = /^(ja|zh|ko)/i.test(lang || '');
164
- var width = node.getBoundingClientRect().width;
165
- // Approximate rendered measure: CJK glyphs are square (one em); Latin
166
- // averages about half an em.
167
- var chars = cjk ? width / fontSize : width / (fontSize * 0.5);
344
+
345
+ /*
346
+ * ONE CHARACTER IS THE WIDTH OF "0" IN THIS ELEMENT'S OWN FONT.
347
+ *
348
+ * That is what the CSS \`ch\` unit means and it is the conventional
349
+ * proxy for a character. The first version assumed half an em, which
350
+ * is wrong for every real typeface and wrong in the direction that
351
+ * manufactures findings: a measure set to 68ch by hand reported as
352
+ * ~90 characters and was flagged on every prose block of this
353
+ * repository's own site. The probe is memoised on the font string,
354
+ * because a page asks about the same three fonts a hundred times.
355
+ */
356
+ var probes = (window.__setsquareChProbe = window.__setsquareChProbe || {});
357
+ var font =
358
+ style.fontStyle + ' ' + style.fontWeight + ' ' + style.fontSize + ' ' +
359
+ style.fontFamily;
360
+ var chWidth = probes[font];
361
+ if (chWidth === undefined) {
362
+ chWidth = 0;
363
+ try {
364
+ var probeCtx = (probes.__ctx =
365
+ probes.__ctx || document.createElement('canvas').getContext('2d'));
366
+ if (probeCtx) {
367
+ probeCtx.font = font;
368
+ chWidth = probeCtx.measureText('0').width;
369
+ }
370
+ } catch (e) {
371
+ chWidth = 0;
372
+ }
373
+ probes[font] = chWidth;
374
+ }
375
+
376
+ // CJK glyphs are square — one em each — so they need no probe.
377
+ var perChar = cjk ? fontSize : chWidth;
378
+ var chars = perChar > 0 ? rect.width / perChar : 0;
168
379
  var limit = cjk ? 40 : 80;
169
- if (isFinite(chars) && chars > limit) {
170
- problems.push('measure ~' + Math.round(chars) + ' chars (limit ' + limit + ')');
380
+ if (isFinite(chars) && chars > 0 && chars > limit) {
381
+ problems.push('measure ' + Math.round(chars) + ' characters (limit ' + limit + ')');
171
382
  }
172
383
 
173
384
  if (problems.length === 0) return true;
174
385
  this.data({ problems: problems.join('; ') });
175
- return undefined;
386
+
387
+ /*
388
+ * A VIOLATION, not needs-review, and the change is about being
389
+ * consistent with our own precedent rather than about the SC moving.
390
+ *
391
+ * 1.4.8 asks that a MECHANISM be available to achieve these, and no
392
+ * check can see a mechanism — which is exactly the shape of 2.1.2,
393
+ * where the SC also accepts a mechanism the page documents in text
394
+ * and we report the trap as a violation while saying so. Reporting
395
+ * one as a failure and the other as a judgement was incoherent.
396
+ *
397
+ * The stronger reason: a needs-review row on every block of prose is
398
+ * a number nobody acts on, and the measurement here is not in doubt.
399
+ * The page's default presentation either meets the SC's numbers or
400
+ * it does not; whether something elsewhere lets a user override it
401
+ * is a question the finding raises rather than one it pretends to
402
+ * have answered.
403
+ */
404
+ return false;
176
405
  },
177
406
  metadata: {
178
407
  impact: 'minor',
179
408
  messages: {
180
- pass: 'Text block defaults do not conflict with 1.4.8',
181
- fail: 'Text block presentation fails 1.4.8',
182
- incomplete:
183
- 'Defaults conflict with 1.4.8; a user mechanism may still satisfy it needs a person',
409
+ pass: 'Text block presentation meets the 1.4.8 numbers',
410
+ fail:
411
+ 'Measured \${data.problems}. WCAG 1.4.8 asks for text that is not justified, ' +
412
+ 'line spacing of at least 1.5 within paragraphs, and a measure of no more than ' +
413
+ '80 characters. Line spacing INHERITS, so a run of these usually has one cause ' +
414
+ 'in a type scale or a container rather than one per block — worth checking ' +
415
+ 'before editing anything. The SC is also met by a mechanism the page offers to ' +
416
+ 'change the presentation, which this check cannot see, so a reading-preferences ' +
417
+ 'control would change the answer.',
418
+ // Kept for completeness; the check no longer returns undefined.
419
+ incomplete: 'Text block presentation against 1.4.8 could not be measured',
184
420
  },
185
421
  },
186
422
  },
@@ -207,17 +443,86 @@ export function rulePackSource() {
207
443
  ];
208
444
  var isVague = vague.indexOf(name) !== -1 || /^https?:\\/\\//.test(name);
209
445
 
210
- if (!isVague) return true;
211
- this.data({ name: name.slice(0, 80) });
212
- return undefined;
446
+ /*
447
+ * A DIGEST of the destination, for the after() phase below. Never
448
+ * the href itself: this rule already carries the page's own link
449
+ * text, which D13 allowed because for 2.4.9 the name IS the finding,
450
+ * and that permission does not extend to a second page-derived
451
+ * value. The only question after() asks is "same destination or
452
+ * not", and a hash answers it.
453
+ */
454
+ var href = (node.getAttribute('href') || '').split('#')[0].split('?')[0];
455
+ var hash = 2166136261;
456
+ for (var c = 0; c < href.length; c += 1) {
457
+ hash ^= href.charCodeAt(c);
458
+ hash = (hash * 16777619) >>> 0;
459
+ }
460
+
461
+ this.data({ name: name.slice(0, 80), hrefKey: hash.toString(16) });
462
+
463
+ /*
464
+ * A FAILURE, not a question.
465
+ *
466
+ * 2.4.9 asks that the purpose be identifiable from the link text
467
+ * ALONE — that is the whole difference between it and 2.4.4, which
468
+ * is the AA criterion and does allow context. "Click here" does not
469
+ * identify a purpose in any context, and the SC's exception is for
470
+ * links whose purpose is ambiguous to users in general, which is
471
+ * about the destination being genuinely unknowable rather than
472
+ * permission for lazy text.
473
+ *
474
+ * Reported at minor impact because the vague list is ours: the SC
475
+ * names no phrases,
476
+ * so this is a closed set of English wordings standing in for "does
477
+ * not describe its purpose", and the name is in the message so an
478
+ * author can see what we matched.
479
+ */
480
+ return !isVague;
481
+ },
482
+ after: function (results) {
483
+ /*
484
+ * The other half of 2.4.9, which nothing here was doing: TWO links
485
+ * with the same text going to different places.
486
+ *
487
+ * The text cannot identify the purpose of either, because it is the
488
+ * same text for two purposes. axe's own identical-links-same-purpose
489
+ * finds this and reports incomplete — reasonably, since it is a
490
+ * generic rule — but the SC's requirement is readable: same name,
491
+ * different destination, so the name alone does not distinguish
492
+ * them.
493
+ *
494
+ * Same name AND same destination is not a finding at all. A
495
+ * "Documentation" link in a header and another in a footer are the
496
+ * same link twice, which is how sites are built.
497
+ */
498
+ var destinations = {};
499
+ results.forEach(function (result) {
500
+ if (!result.data || !result.data.name) return;
501
+ var seen = destinations[result.data.name];
502
+ if (!seen) destinations[result.data.name] = { key: result.data.hrefKey, many: false };
503
+ else if (seen.key !== result.data.hrefKey) seen.many = true;
504
+ });
505
+ results.forEach(function (result) {
506
+ if (result.result === false || !result.data || !result.data.name) return;
507
+ var seen = destinations[result.data.name];
508
+ if (seen && seen.many) {
509
+ result.data.ambiguous = true;
510
+ result.result = false;
511
+ }
512
+ });
513
+ return results;
213
514
  },
214
515
  metadata: {
215
516
  impact: 'minor',
216
517
  messages: {
217
518
  pass: 'Link text describes its purpose',
218
- fail: 'Link text does not describe its purpose',
219
- incomplete:
220
- 'Link text alone may not describe its purpose ("click here", "read more", a bare URL) needs a person',
519
+ fail:
520
+ 'Link text does not identify the purpose of the link on its own. 2.4.9 asks for ' +
521
+ 'the purpose to be identifiable from the text ALONE that is what separates it ' +
522
+ 'from 2.4.4, which is the AA criterion and does allow surrounding context. ' +
523
+ 'Resolve it by saying where the link goes: "Read the 2.4.9 criterion" rather ' +
524
+ 'than "read more".',
525
+ incomplete: 'Link purpose could not be assessed',
221
526
  },
222
527
  },
223
528
  },
@@ -1 +1 @@
1
- {"version":3,"file":"rule-pack.js","sourceRoot":"","sources":["../../src/checks/rule-pack.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,+BAA+B,EAC/B,6BAA6B,EAC7B,4BAA4B,GAC7B,MAAM,aAAa,CAAC;AAErB;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,MAAM,CAAC,MAAM,2BAA2B,GAAG,mBAAmB,CAAC;AAE/D,MAAM,CAAC,MAAM,kBAAkB,GAAG;IAChC,2BAA2B;IAC3B,+BAA+B;IAC/B,wBAAwB;CAChB,CAAC;AAEX;;;GAGG;AACH,MAAM,UAAU,cAAc;IAC5B,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;sBAuMa,6BAA6B;;;;;;;;;;;;;sBAa7B,4BAA4B;;;;;;;;;;;;sBAY5B,+BAA+B;;;;;GAKlD,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"rule-pack.js","sourceRoot":"","sources":["../../src/checks/rule-pack.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,+BAA+B,EAC/B,6BAA6B,EAC7B,4BAA4B,GAC7B,MAAM,aAAa,CAAC;AAErB;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,0EAA0E;AAC1E,6EAA6E;AAC7E,0EAA0E;AAC1E,8EAA8E;AAC9E,qEAAqE;AACrE,oBAAoB;AACpB,MAAM,CAAC,MAAM,2BAA2B,GAAG,mBAAmB,CAAC;AAE/D,MAAM,CAAC,MAAM,kBAAkB,GAAG;IAChC,2BAA2B;IAC3B,+BAA+B;IAC/B,wBAAwB;CAChB,CAAC;AAEX;;;GAGG;AACH,MAAM,UAAU,cAAc;IAC5B,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;sBAkfa,6BAA6B;;;;;;;;;;;;;sBAa7B,4BAA4B;;;;;;;;;;;;sBAY5B,+BAA+B;;;;;GAKlD,CAAC;AACJ,CAAC"}
@@ -0,0 +1,71 @@
1
+ import type { Page } from '@playwright/test';
2
+ import type { DynamicCheckEvidence } from './dynamic.js';
3
+ /**
4
+ * WCAG 2.1.4 Character Key Shortcuts (A): a single-character shortcut with no
5
+ * way to turn it off or remap it makes a page unusable for anyone using speech
6
+ * input, because every dictated word is a volley of shortcuts.
7
+ *
8
+ * A small rule and a real failure — and the only one in this package that
9
+ * needs to run BEFORE the customer's own code. There is no way to enumerate a
10
+ * page's event listeners after the fact: `getEventListeners` is a DevTools
11
+ * API and does not exist in page script. So the only route is to wrap
12
+ * `addEventListener` at init time and watch what gets registered.
13
+ *
14
+ * ---------------------------------------------------------------------------
15
+ * **Opt-in, and off by default, because of the footprint.**
16
+ *
17
+ * Everything else this SDK does happens after a page has loaded and can be
18
+ * undone. This patches a DOM method before any of the customer's scripts run,
19
+ * for the whole life of the page. Even proven inert, that is a larger presence
20
+ * in someone's application than a scanner has any business having by default —
21
+ * and "it is only a pass-through" is exactly the reasoning that precedes a
22
+ * subtle break in someone else's product.
23
+ *
24
+ * So it is a choice, `keyShortcuts: true`, and while it is off the criterion
25
+ * reports as a check that exists and did not run here, which is what it is.
26
+ *
27
+ * **Inertness, concretely.** The wrapper records `(type, target)` and the key
28
+ * literals it can read out of the handler's own source, then delegates with
29
+ * the ORIGINAL arguments — never a wrapped listener. That last part is what
30
+ * keeps `removeEventListener` working: a wrapped listener is a different
31
+ * function object and would never match, so a page that adds and removes a
32
+ * handler would silently keep it forever. The fixture asserts exactly that.
33
+ * ---------------------------------------------------------------------------
34
+ */
35
+ export declare const SETSQUARE_SHORTCUTS_CHECK_VERSION = "setsquare-shortcuts@1";
36
+ export declare const KEY_SHORTCUTS_RULE_ID = "setsquare-key-shortcuts";
37
+ /**
38
+ * Installed before any page script. See the class comment for why this is
39
+ * opt-in; see `fixture.ts` for where it is installed.
40
+ *
41
+ * A source string rather than a function, because `addInitScript` serialises
42
+ * it and a closure over anything in this module would not survive.
43
+ */
44
+ export declare const KEY_LISTENER_SCRIPT = "\n(() => {\n const found = [];\n Object.defineProperty(window, '__setsquareKeyListeners', {\n value: found,\n // Non-enumerable so it does not appear in a customer's own iteration over\n // window, and non-writable so page code cannot clobber it by accident.\n enumerable: false,\n writable: false,\n configurable: true,\n });\n\n const KEY_EVENTS = new Set(['keydown', 'keypress', 'keyup']);\n const MODIFIER = /\\b(ctrlKey|metaKey|altKey|shiftKey|getModifierState)\\b/;\n\n /*\n * Which keys a handler reacts to, read out of its own source.\n *\n * The instrumentation can see THAT a key listener exists and never which key\n * it cares about \u2014 so without this the finding would be \"this page has a\n * keydown handler\", which is true of most of the web and useful to nobody.\n * Reading the source is a heuristic and is treated as one: everything this\n * rule reports is needs-review.\n *\n * A handler that consults a modifier is not what 2.1.4 is about, so it is\n * left alone. That is most of the false positives.\n */\n const keysIn = (source) => {\n if (typeof source !== 'string' || source.length === 0) return [];\n if (source.indexOf('[native code]') !== -1) return [];\n if (MODIFIER.test(source)) return [];\n\n const keys = new Set();\n const literal = /(?:\\.key\\s*={2,3}\\s*|case\\s+)['\"]([^'\"\\\\]{1})['\"]/g;\n let match;\n while ((match = literal.exec(source)) !== null) {\n const key = match[1];\n // Printable, single character, not whitespace. Enter, Escape and Tab are\n // multi-character names and fall out here, which is right: 2.1.4 is\n // about character keys.\n if (key.trim().length === 1) keys.add(key);\n }\n\n const code = /(?:keyCode|which)\\s*={2,3}\\s*(\\d{2,3})/g;\n while ((match = code.exec(source)) !== null) {\n const value = Number(match[1]);\n // Letters and digits only. The named keys have codes too, and reporting\n // Escape as a character shortcut would be wrong.\n if (value >= 48 && value <= 57) keys.add(String(value - 48));\n else if (value >= 65 && value <= 90) keys.add(String.fromCharCode(value).toLowerCase());\n }\n\n return [...keys];\n };\n\n const original = EventTarget.prototype.addEventListener;\n EventTarget.prototype.addEventListener = function (type, listener, options) {\n try {\n if (KEY_EVENTS.has(type) && (this === document || this === window) && found.length < 40) {\n let source = '';\n try {\n source = typeof listener === 'function' ? Function.prototype.toString.call(listener) : '';\n } catch {}\n const keys = keysIn(source);\n if (keys.length > 0) {\n found.push({ type, target: this === window ? 'window' : 'document', keys });\n }\n }\n } catch {}\n\n /*\n * The original arguments, never a wrapper. A wrapped listener is a\n * different function object, so removeEventListener would never match it\n * and a page that adds and removes a handler would silently keep it for\n * ever. That is the failure this line exists to prevent.\n */\n return original.call(this, type, listener, options);\n };\n})();\n";
45
+ export interface KeyShortcutRow {
46
+ /** `keydown`, `keypress` or `keyup`. */
47
+ type: string;
48
+ /** `document` or `window`. */
49
+ target: string;
50
+ /** The single characters the handler's own source compares against. */
51
+ keys: string[];
52
+ }
53
+ /**
54
+ * Read what the instrumentation saw. Returns nothing when it was never
55
+ * installed, which is the honest answer for an opt-in check that is off.
56
+ */
57
+ export declare function runKeyShortcuts(page: Page): Promise<DynamicCheckEvidence>;
58
+ /**
59
+ * The verdict, and it is always needs-review or inapplicable — never a
60
+ * violation, and the reason is worth stating rather than assuming.
61
+ *
62
+ * 2.1.4 is satisfied if the shortcut can be turned off, remapped, or is only
63
+ * active when a specific component has focus. All three are page-level
64
+ * affordances: the first two live in a settings screen we are not looking at,
65
+ * and the third is a property of the handler's own logic. We can see that a
66
+ * handler compares against `j`; we cannot see whether the user was ever
67
+ * offered a way to stop it. Reporting a violation from what we CAN see would
68
+ * be asserting the part we cannot.
69
+ */
70
+ export declare function keyShortcutEvidence(rows: readonly KeyShortcutRow[]): DynamicCheckEvidence;
71
+ //# sourceMappingURL=shortcuts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shortcuts.d.ts","sourceRoot":"","sources":["../../src/checks/shortcuts.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAI7C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAEzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,eAAO,MAAM,iCAAiC,0BAA0B,CAAC;AAEzE,eAAO,MAAM,qBAAqB,4BAA4B,CAAC;AAK/D;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,2qGA+E/B,CAAC;AAEF,MAAM,WAAW,cAAc;IAC7B,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,8BAA8B;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB;AAED;;;GAGG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAW/E;AAID;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,SAAS,cAAc,EAAE,GAAG,oBAAoB,CA0CzF"}