@setsquare/review-sdk 0.0.1 → 0.1.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.
Files changed (116) hide show
  1. package/NOTICE +3 -2
  2. package/README.md +287 -3
  3. package/dist/autoscan.d.ts +23 -1
  4. package/dist/autoscan.d.ts.map +1 -1
  5. package/dist/autoscan.js +220 -15
  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 +117 -0
  20. package/dist/checks/heuristics.d.ts.map +1 -0
  21. package/dist/checks/heuristics.js +961 -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 +32 -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 +106 -16
  58. package/dist/envelope.js.map +1 -1
  59. package/dist/fixture.d.ts.map +1 -1
  60. package/dist/fixture.js +33 -4
  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/insights.d.ts.map +1 -1
  71. package/dist/insights.js +9 -12
  72. package/dist/insights.js.map +1 -1
  73. package/dist/links.d.ts +43 -0
  74. package/dist/links.d.ts.map +1 -1
  75. package/dist/links.js +43 -0
  76. package/dist/links.js.map +1 -1
  77. package/dist/observe.d.ts +8 -0
  78. package/dist/observe.d.ts.map +1 -1
  79. package/dist/observe.js +45 -1
  80. package/dist/observe.js.map +1 -1
  81. package/dist/options.d.ts +53 -0
  82. package/dist/options.d.ts.map +1 -1
  83. package/dist/options.js +3 -0
  84. package/dist/options.js.map +1 -1
  85. package/dist/reading-level.d.ts +22 -1
  86. package/dist/reading-level.d.ts.map +1 -1
  87. package/dist/reading-level.js +122 -19
  88. package/dist/reading-level.js.map +1 -1
  89. package/dist/reporter.d.ts.map +1 -1
  90. package/dist/reporter.js +31 -15
  91. package/dist/reporter.js.map +1 -1
  92. package/dist/scan.d.ts +36 -1
  93. package/dist/scan.d.ts.map +1 -1
  94. package/dist/scan.js +16 -2
  95. package/dist/scan.js.map +1 -1
  96. package/dist/screens.d.ts +55 -4
  97. package/dist/screens.d.ts.map +1 -1
  98. package/dist/screens.js +243 -43
  99. package/dist/screens.js.map +1 -1
  100. package/dist/settle.d.ts +35 -0
  101. package/dist/settle.d.ts.map +1 -1
  102. package/dist/settle.js +43 -2
  103. package/dist/settle.js.map +1 -1
  104. package/dist/summary.d.ts +35 -0
  105. package/dist/summary.d.ts.map +1 -1
  106. package/dist/summary.js +248 -28
  107. package/dist/summary.js.map +1 -1
  108. package/dist/unscannable-reasons.d.ts +30 -0
  109. package/dist/unscannable-reasons.d.ts.map +1 -0
  110. package/dist/unscannable-reasons.js +53 -0
  111. package/dist/unscannable-reasons.js.map +1 -0
  112. package/dist/viewports.d.ts +105 -0
  113. package/dist/viewports.d.ts.map +1 -0
  114. package/dist/viewports.js +134 -0
  115. package/dist/viewports.js.map +1 -0
  116. package/package.json +5 -4
@@ -0,0 +1,961 @@
1
+ import { ABBREVIATIONS_HELP_URL, CAPTIONS_PRERECORDED_HELP_URL, HEADINGS_AND_LABELS_HELP_URL, LOCATION_HELP_URL, MEANINGFUL_SEQUENCE_HELP_URL, SECTION_HEADINGS_HELP_URL, SENSORY_CHARACTERISTICS_HELP_URL, } from '../links.js';
2
+ import { sanitiseSelectors } from '../sanitise.js';
3
+ /**
4
+ * Seven heuristics for criteria no rule can decide.
5
+ *
6
+ * Every one follows the shape `reading-level.ts` established, and following it
7
+ * is the point of grouping them:
8
+ *
9
+ * 1. **A judgement is `incomplete`; a measurement is a `violation`.** This
10
+ * rule used to read "never a violation, always incomplete", and it was
11
+ * too broad. It is right for the heuristics that read English or guess
12
+ * intent — 1.3.2, 1.3.3, 2.4.6, 3.1.4 — where a customer who disproves
13
+ * one would stop trusting all of them, including the rules that are not
14
+ * heuristics at all.
15
+ *
16
+ * It was wrong for the ones that do arithmetic or look for a mechanism.
17
+ * 2.4.8 names four sufficient techniques and every one of them is a thing
18
+ * in the DOM; a page carrying none of them has a fact reported about it,
19
+ * not an opinion. Those use `fail()` rather than `note()`, at `minor`
20
+ * where the measure is a correlate and `moderate` where it is the thing
21
+ * the SC names. The caveat stays in the message either way — the change
22
+ * is which column it is answered in, and "needs a person", repeated
23
+ * across every page of a site, is a column nobody ever works through.
24
+ * 2. **Fire only above a margin**, and the margin is argued in a comment
25
+ * rather than chosen. Every one of these is a judgement about where the
26
+ * signal stops being noise, and a number nobody defended is a number
27
+ * somebody will quietly change.
28
+ * 3. **Say what would resolve it.** "This might be a problem" is not a
29
+ * finding; "add a `<track kind="captions">` or confirm the media has no
30
+ * speech" is.
31
+ * 4. **Language-gated where the heuristic is language-specific**, and silent
32
+ * rather than guessing otherwise. Three of these read English words. A
33
+ * page in Finnish gets nothing from them, and a phrase list applied to a
34
+ * language it was not written for manufactures findings.
35
+ *
36
+ * **Individually switchable**, because a customer who disagrees with one of
37
+ * these must not have to turn off the lot. They are read-only and can never
38
+ * fail a build, so they are on by default.
39
+ *
40
+ * The seven share ONE page evaluate. They are all DOM reads, and seven round
41
+ * trips per scan for seven cheap questions would be the wrong trade.
42
+ */
43
+ export const SETSQUARE_HEURISTICS_VERSION = 'setsquare-heuristics@3';
44
+ export const HEURISTIC_RULE_IDS = {
45
+ meaningfulSequence: 'setsquare-meaningful-sequence',
46
+ sensoryCharacteristics: 'setsquare-sensory-characteristics',
47
+ headingsAndLabels: 'setsquare-headings-and-labels',
48
+ sectionHeadings: 'setsquare-section-headings',
49
+ abbreviations: 'setsquare-abbreviations',
50
+ location: 'setsquare-location',
51
+ captions: 'setsquare-captions',
52
+ };
53
+ /**
54
+ * The margins, together, because they are the part of this file most likely to
55
+ * be argued with and they should be arguable in one place.
56
+ */
57
+ const MARGINS = {
58
+ /**
59
+ * Three re-ordered items, not one. A single `order` on a flex child is a
60
+ * routine layout tweak (a logo before a nav on mobile); three or more is a
61
+ * layout whose reading order has been deliberately rearranged, which is when
62
+ * DOM order and visual order start to disagree in ways a screen-reader user
63
+ * notices.
64
+ */
65
+ reorderedItems: 3,
66
+ /**
67
+ * Two thousand characters. A run of prose that long with no heading is past
68
+ * what anyone scans by ear — a screen-reader user navigating by heading has
69
+ * nothing to navigate to. Below it, headings are a style choice.
70
+ */
71
+ unheadedChars: 2_000,
72
+ /**
73
+ * Three distinct all-caps tokens that have no expansion and no `<abbr>`. One
74
+ * or two are almost always a brand or a currency; three starts to look like
75
+ * jargon.
76
+ *
77
+ * The margin does less work than it used to, because the check now excludes
78
+ * anything the page expands in its own prose and anything already carrying
79
+ * an `<abbr>` — so what reaches here is already the residue rather than
80
+ * every capitalised token on the page.
81
+ */
82
+ abbreviations: 3,
83
+ /**
84
+ * Two generic labels, or one empty heading. An empty heading is
85
+ * unambiguous — there is nothing to read — so it needs no margin. "More" or
86
+ * "Section" appearing once is usually fine in context; twice on one page and
87
+ * the headings have stopped describing anything.
88
+ */
89
+ genericLabels: 2,
90
+ };
91
+ /**
92
+ * The level A time-based-media criteria a page with no media does not engage.
93
+ *
94
+ * Only level A here: a finding carries one level, and the AA and AAA media
95
+ * criteria (1.2.4, 1.2.5, 1.2.7–1.2.9) need their own row rather than being
96
+ * smuggled under an A label. `captionsHigherLevels` below emits those.
97
+ */
98
+ const MEDIA_CRITERIA_A = ['1.2.1', '1.2.2', '1.2.3'];
99
+ const MEDIA_CRITERIA_AA = ['1.2.4', '1.2.5'];
100
+ const MEDIA_CRITERIA_AAA = ['1.2.6', '1.2.7', '1.2.8', '1.2.9'];
101
+ /** English-only heuristics. See rule 4 above. */
102
+ const ENGLISH_ONLY = new Set([
103
+ 'sensoryCharacteristics',
104
+ 'headingsAndLabels',
105
+ 'abbreviations',
106
+ ]);
107
+ /**
108
+ * Read every signal in one pass. Returns undefined on any failure — a page we
109
+ * could not read produces no heuristic findings rather than empty ones.
110
+ */
111
+ export async function readHeuristicSignals(page) {
112
+ try {
113
+ return await page.evaluate(collectSignals);
114
+ }
115
+ catch {
116
+ return undefined;
117
+ }
118
+ }
119
+ /**
120
+ * The seven verdicts, as a pure function of the signals.
121
+ *
122
+ * Separated from the reading for the reason `focus.ts` records at length: the
123
+ * unit lane cannot see a computed style, and the half of a check that decides
124
+ * what to CLAIM is the half where the honesty rules live.
125
+ */
126
+ /**
127
+ * Heuristics whose finding is "this page carries none of the mechanisms".
128
+ *
129
+ * A claim about what is ABSENT is only as good as the page being finished, and
130
+ * these two read the whole document to make it: 2.4.8 asks whether anything —
131
+ * breadcrumb, `aria-current`, site map, `rel` link — says where you are, and
132
+ * 3.1.4 asks whether anything expands an abbreviation. Every other heuristic
133
+ * here reasons about content it can SEE, and a half-rendered page gives it less
134
+ * to look at rather than a different answer.
135
+ *
136
+ * Found by friction record run 4: the 2.4.8 cue fired on one run and not the
137
+ * next, on identical code, because an SPA's router had or had not painted its
138
+ * active state by the time the page was scanned. This file's own first rule is
139
+ * that a heuristic fires only above a margin, and one that flips on render
140
+ * timing does not clear it — so on a page we already know never went quiet, the
141
+ * honest answer is silence rather than a finding we cannot reproduce.
142
+ */
143
+ const PAGE_ABSENCE_CLAIMS = new Set(['location', 'abbreviations']);
144
+ export function heuristicEvidence(signals, target, switches = {},
145
+ /**
146
+ * Had the page met the quiet definition when these signals were read?
147
+ *
148
+ * Defaults to true, so nothing is suppressed unless a caller can actually say
149
+ * otherwise — an explicit scan suppresses nothing, because the customer chose
150
+ * the moment and it is not ours to second-guess.
151
+ */
152
+ pageWentQuiet = true) {
153
+ const english = signals.lang.toLowerCase().startsWith('en');
154
+ const enabled = (id) => {
155
+ if (switches[id] === false)
156
+ return false;
157
+ // Silent, not a pass: the page never finished, so we did not look at the
158
+ // whole of it, and "we saw no mechanism" would be a claim about our timing.
159
+ if (!pageWentQuiet && PAGE_ABSENCE_CLAIMS.has(id))
160
+ return false;
161
+ // Silent rather than guessing: a phrase list applied to a language it was
162
+ // not written for manufactures findings, and a missing `lang` is already
163
+ // an axe violation (3.1.1) rather than this check's problem to solve.
164
+ if (ENGLISH_ONLY.has(id) && !english)
165
+ return false;
166
+ return true;
167
+ };
168
+ const evidence = [];
169
+ const add = (id, build) => {
170
+ if (!enabled(id))
171
+ return;
172
+ const result = build();
173
+ if (result)
174
+ evidence.push(result);
175
+ };
176
+ add('meaningfulSequence', () => meaningfulSequence(signals));
177
+ add('sensoryCharacteristics', () => sensoryCharacteristics(signals));
178
+ add('headingsAndLabels', () => headingsAndLabels(signals));
179
+ add('captions', () => captions(signals));
180
+ add('captions', () => mediaLevel(signals, MEDIA_CRITERIA_AA, 'AA'));
181
+ if (target === 'AAA') {
182
+ add('captions', () => mediaLevel(signals, MEDIA_CRITERIA_AAA, 'AAA'));
183
+ add('sectionHeadings', () => sectionHeadings(signals));
184
+ add('abbreviations', () => abbreviations(signals));
185
+ add('location', () => locationCue(signals));
186
+ }
187
+ return evidence;
188
+ }
189
+ function note(ruleId, criteria, level, helpUrl, failureSummary, selectors) {
190
+ const finding = {
191
+ ruleId,
192
+ // A judgement, so it asks. `fail()` is the sibling for the ones that
193
+ // measure. See rule 1 at the top of this file.
194
+ status: 'incomplete',
195
+ impact: 'moderate',
196
+ wcag: { criteria, level, versionTags: [] },
197
+ helpUrl,
198
+ evidence: { failureSummary },
199
+ targets: { nodeCount: Math.max(selectors.length, 1), ...sanitiseSelectors(selectors) },
200
+ };
201
+ return { finding };
202
+ }
203
+ /**
204
+ * The same finding as `note()`, reported as a failure.
205
+ *
206
+ * Separate function rather than a flag on `note()`, so that the choice is
207
+ * visible at every call site: reading a check, you can see in one word whether
208
+ * it decides or asks, and a check that quietly changed from one to the other
209
+ * shows up in a diff as what it is.
210
+ */
211
+ function fail(ruleId, criteria, level, helpUrl, impact, failureSummary, selectors) {
212
+ const finding = {
213
+ ruleId,
214
+ status: 'violation',
215
+ impact,
216
+ wcag: { criteria, level, versionTags: [] },
217
+ helpUrl,
218
+ evidence: { failureSummary },
219
+ targets: { nodeCount: Math.max(selectors.length, 1), ...sanitiseSelectors(selectors) },
220
+ };
221
+ return { finding };
222
+ }
223
+ /** 1.3.2 Meaningful Sequence (A). */
224
+ function meaningfulSequence(signals) {
225
+ if (signals.reordered.length < MARGINS.reorderedItems)
226
+ return undefined;
227
+ return note(HEURISTIC_RULE_IDS.meaningfulSequence, ['1.3.2'], 'A', MEANINGFUL_SEQUENCE_HELP_URL, `${signals.reordered.length} elements are moved by CSS \`order\` or a reversed flex or grid ` +
228
+ `container, so what a sighted reader sees is not the order a screen reader announces. ` +
229
+ `Whether that changes the MEANING is a judgement about the content, which is why this is ` +
230
+ `not reported as a failure. Resolve it by reading the page with the stylesheet off, or by ` +
231
+ `putting the DOM in the order the page reads in.`, signals.reordered);
232
+ }
233
+ /** 1.3.3 Sensory Characteristics (A). */
234
+ /**
235
+ * 1.3.3 Sensory Characteristics (A) — a failure, because "solely" is readable.
236
+ *
237
+ * The extraction above applies the criterion's own two conditions: the sentence
238
+ * is an instruction, and the sensory phrase is the only thing in it identifying
239
+ * the target. A sentence failing either test never reaches here. So what is
240
+ * left is an instruction nobody can follow without seeing the layout, which is
241
+ * the criterion rather than a suspicion about it.
242
+ */
243
+ function sensoryCharacteristics(signals) {
244
+ if (signals.sensory.length === 0)
245
+ return undefined;
246
+ const first = signals.sensory[0];
247
+ return fail(HEURISTIC_RULE_IDS.sensoryCharacteristics, ['1.3.3'], 'A', SENSORY_CHARACTERISTICS_HELP_URL, 'moderate', `An instruction identifies its target by "${first.phrase}" and by nothing else — ` +
248
+ `"${first.sentence}". ${signals.sensory.length} found. The sentence reads as an ` +
249
+ `instruction and carries no name, no quoted label and no code for the thing it is ` +
250
+ `pointing at, so somebody who cannot see the layout cannot follow it; a sentence that ` +
251
+ `names its target, or that describes rather than instructs, is not reported. Resolve it ` +
252
+ `by adding the name: "the Save button on the right" rather than "the button on the right".`, signals.sensory.map((row) => row.selector));
253
+ }
254
+ /** 2.4.6 Headings and Labels (AA). */
255
+ function headingsAndLabels(signals) {
256
+ const empty = signals.weakHeadings.filter((row) => row.why === 'empty');
257
+ const rest = signals.weakHeadings.filter((row) => row.why !== 'empty');
258
+ if (empty.length === 0 && rest.length < MARGINS.genericLabels)
259
+ return undefined;
260
+ const rows = [...empty, ...rest];
261
+ const first = rows[0];
262
+ return note(HEURISTIC_RULE_IDS.headingsAndLabels, ['2.4.6'], 'AA', HEADINGS_AND_LABELS_HELP_URL, `${rows.length} heading(s) or label(s) do not describe what they head: ${first.selector} is ` +
263
+ `${first.why}. 2.4.6 asks that they be descriptive, which is a judgement about the content ` +
264
+ `they introduce — so this points, and a person decides. Resolve it by naming the thing: ` +
265
+ `"Billing address" rather than "Section", "Read the release notes" rather than "More".`, rows.map((row) => row.selector));
266
+ }
267
+ /** 2.4.10 Section Headings (AAA). */
268
+ /**
269
+ * 2.4.10 Section Headings (AAA) — two answers, and only one of them used to
270
+ * exist.
271
+ *
272
+ * **A declared section with no name is a failure.** `<section>`, `<article>`
273
+ * and `role="region"` are the author stating that the content divides into
274
+ * sections. Once that is stated, "where content is organized into sections"
275
+ * is not a judgement any more and the criterion applies directly. This was
276
+ * not checked at all; the whole criterion rested on the character count below.
277
+ *
278
+ * **A long unheaded run is a warning.** Nothing in the SC names a character
279
+ * count — 2,000 is ours — so this is a correlate and reports at `minor` with
280
+ * that said in the message. It is still reported rather than asked, because a
281
+ * question repeated on every long page of a site is one nobody answers.
282
+ */
283
+ function sectionHeadings(signals) {
284
+ if (signals.unheadedSections.length > 0) {
285
+ return fail(HEURISTIC_RULE_IDS.sectionHeadings, ['2.4.10'], 'AAA', SECTION_HEADINGS_HELP_URL, 'moderate', `${signals.unheadedSections.length} element(s) declare themselves a section — ` +
286
+ `\`<section>\`, \`<article>\` or \`role="region"\` — and carry no heading and no ` +
287
+ `\`aria-label\`. The markup has already divided this content into sections, so the ` +
288
+ `judgement 2.4.10 normally turns on does not arise here: the sections exist and are ` +
289
+ `unnamed. Resolve it with a heading inside each, or an \`aria-label\` where the ` +
290
+ `section genuinely has no visible title — or use a \`<div>\`, if it was never a ` +
291
+ `section.`, signals.unheadedSections);
292
+ }
293
+ if (signals.unheadedChars < MARGINS.unheadedChars)
294
+ return undefined;
295
+ return fail(HEURISTIC_RULE_IDS.sectionHeadings, ['2.4.10'], 'AAA', SECTION_HEADINGS_HELP_URL, 'minor', `A run of roughly ${signals.unheadedChars} characters carries no heading, so somebody ` +
296
+ `navigating by heading has nothing to navigate to across it. Reported as a warning ` +
297
+ `rather than a plain failure because the character count is ours, not the SC's: 2.4.10 ` +
298
+ `applies where content divides into sections, and a single continuous argument ` +
299
+ `legitimately has none. Resolve it by adding headings where the subject changes, or by ` +
300
+ `recording that this page is one section.`, ['main']);
301
+ }
302
+ /**
303
+ * 3.1.4 Abbreviations (AAA) — a warning, because the mechanism half is exact.
304
+ *
305
+ * The criterion asks for A MECHANISM, and every mechanism it accepts is a thing
306
+ * in the page: an `<abbr>`, an expansion in the prose, or a glossary. All three
307
+ * are now looked for, so "this token has no mechanism" is a fact rather than a
308
+ * suspicion. The check used to decline on the grounds that "3.1.4 also accepts
309
+ * a glossary this check cannot see", which was the 2.4.8 excuse one criterion
310
+ * along.
311
+ *
312
+ * What stays a correlate is the other half: an all-capital token STANDS IN for
313
+ * an abbreviation, and a page can capitalise something that is neither an
314
+ * abbreviation nor an acronym. That is why this is `minor` and why the tokens
315
+ * are named in the message — an author who thinks it is wrong can see in one
316
+ * line which words we meant, which is a different conversation from being told
317
+ * a criterion needs a person.
318
+ */
319
+ function abbreviations(signals) {
320
+ if (signals.abbreviations.length < MARGINS.abbreviations)
321
+ return undefined;
322
+ // A glossary answers the criterion for every abbreviation at once, which is
323
+ // exactly why the SC lists it: it is one mechanism covering the page.
324
+ if (signals.hasGlossary)
325
+ return undefined;
326
+ return fail(HEURISTIC_RULE_IDS.abbreviations, ['3.1.4'], 'AAA', ABBREVIATIONS_HELP_URL, 'minor', `${signals.abbreviations.length} all-capital token(s) appear with no expansion anywhere in ` +
327
+ `the page's text, no \`<abbr>\` of their own, and no glossary linked from the page: ` +
328
+ `${signals.abbreviations.slice(0, 6).join(', ')}. Tokens the prose expands — ` +
329
+ `"Web Content Accessibility Guidelines (WCAG)" — and tokens already wrapped in ` +
330
+ `\`<abbr>\` are excluded, so these are the ones with no mechanism at all. Reported as a ` +
331
+ `warning rather than a plain failure because an all-capital token is a correlate for an ` +
332
+ `abbreviation and a page may capitalise something that is neither — the tokens are named ` +
333
+ `above so you can see in one line whether we have that wrong. This is usually a small ` +
334
+ `fix. Resolve it with one \`<abbr title="…">\` anywhere on the page, which clears that ` +
335
+ `token for the whole page — or, once, with a glossary linked from a shared footer, which ` +
336
+ `clears every token on every page including the pages not written yet.`, ['body']);
337
+ }
338
+ /**
339
+ * 2.4.8 Location (AAA) — a failure, because every mechanism is visible.
340
+ *
341
+ * The SC's sufficient techniques are a breadcrumb, a site map, the current
342
+ * item marked in a navigation bar, and `rel` links stating the page's place in
343
+ * a collection. All four are things in the DOM, and the signal extraction now
344
+ * looks for all four rather than two. A page that is inside a set of pages and
345
+ * carries none of them does not make its location available — that is a
346
+ * statement about the markup, not an opinion about it.
347
+ *
348
+ * The one judgement left is whether the page is part of a set, and `hasNav`
349
+ * stands in for it: a page with navigation to other pages is in a set, and the
350
+ * front door of a site does not need to say where you are.
351
+ */
352
+ function locationCue(signals) {
353
+ const { hasNav, hasBreadcrumb, hasCurrent, hasSiteMap, hasRelLink, isRoot } = signals.location;
354
+ if (!hasNav || isRoot)
355
+ return undefined;
356
+ if (hasBreadcrumb || hasCurrent || hasSiteMap || hasRelLink)
357
+ return undefined;
358
+ return fail(HEURISTIC_RULE_IDS.location, ['2.4.8'], 'AAA', LOCATION_HELP_URL, 'moderate', `This page has navigation to other pages but carries none of the mechanisms 2.4.8 is met ` +
359
+ `by: no breadcrumb trail, no \`aria-current\` on any navigation item, no link to a site ` +
360
+ `map, and no \`rel\` stating where the page sits in a collection. Somebody arriving here ` +
361
+ `from a search result has nothing telling them where they are. Resolve it with ` +
362
+ `\`aria-current="page"\` on the navigation item for this page, which is usually already ` +
363
+ `styled as the active one — the styling is the part that is generally there and the ` +
364
+ `attribute is the part that is generally missing.`, ['nav']);
365
+ }
366
+ /**
367
+ * The time-based-media criteria: 1.2.1 through 1.2.9.
368
+ *
369
+ * Two answers, and the second one was missing for long enough to matter.
370
+ *
371
+ * **A page with media and no caption track** is the finding: whether captions
372
+ * are correct is not something any check can judge, whether there are none is,
373
+ * and "no automated result" was a much weaker answer than the one available.
374
+ *
375
+ * **A page with no media at all** is INAPPLICABLE, not silence. The page
376
+ * contains nothing these criteria regulate, we looked, and that is a real
377
+ * observation — the same distinction as "scanned and found nothing" versus
378
+ * "never scanned", which this repository cares about everywhere else. Without
379
+ * it, seven criteria read "no automated result" on a site that has never had a
380
+ * video on it, which is the least useful true thing we could have said.
381
+ *
382
+ * An iframe, object or embed stops the claim. An embedded player is media we
383
+ * cannot see, and inapplicability asserted over a thing we did not look inside
384
+ * is exactly the confident wrong answer this file's discipline exists to
385
+ * prevent.
386
+ */
387
+ function captions(signals) {
388
+ if (signals.uncaptioned.length === 0) {
389
+ if (signals.media.elements > 0 || signals.media.frames > 0)
390
+ return undefined;
391
+ return {
392
+ inapplicable: {
393
+ ruleId: HEURISTIC_RULE_IDS.captions,
394
+ wcag: { criteria: [...MEDIA_CRITERIA_A], level: 'A', versionTags: [] },
395
+ },
396
+ };
397
+ }
398
+ return note(HEURISTIC_RULE_IDS.captions, ['1.2.1', '1.2.2'], 'A', CAPTIONS_PRERECORDED_HELP_URL, `${signals.uncaptioned.length} media element(s) carry no \`<track kind="captions">\` or ` +
399
+ `\`kind="subtitles">\`. Whether captions are CORRECT is not something any check can ` +
400
+ `judge; whether there are none is, and there are none here. Media with no speech and no ` +
401
+ `meaningful sound needs no captions, which is the case a person confirms. Resolve it by ` +
402
+ `adding a caption track, or by confirming the media is silent.`, signals.uncaptioned);
403
+ }
404
+ /**
405
+ * The AA and AAA media criteria, for a page with no media.
406
+ *
407
+ * A separate row per level because a finding carries ONE level, and reporting
408
+ * 1.2.5 under a level A label would misstate what was assessed. Emits nothing
409
+ * where media exists: whether an audio description is adequate is a judgement
410
+ * no check makes, so there is nothing to say beyond the caption-track finding
411
+ * above.
412
+ */
413
+ function mediaLevel(signals, criteria, level) {
414
+ if (signals.media.elements > 0 || signals.media.frames > 0)
415
+ return undefined;
416
+ return {
417
+ inapplicable: {
418
+ ruleId: `${HEURISTIC_RULE_IDS.captions}-${level.toLowerCase()}`,
419
+ wcag: { criteria: [...criteria], level, versionTags: [] },
420
+ },
421
+ };
422
+ }
423
+ /**
424
+ * The in-page half. One evaluate, seven questions, no writes.
425
+ *
426
+ * Kept as a named function passed to `evaluate` rather than a source string so
427
+ * it typechecks; it must not close over anything in this module.
428
+ */
429
+ function collectSignals() {
430
+ const step = (el) => {
431
+ const tag = el.tagName.toLowerCase();
432
+ if (el.id)
433
+ return `${tag}#${el.id}`;
434
+ if (el.classList.length > 0)
435
+ return `${tag}.${el.classList[0]}`;
436
+ const parent = el.parentElement;
437
+ if (!parent)
438
+ return tag;
439
+ return `${tag}:nth-child(${Array.prototype.indexOf.call(parent.children, el) + 1})`;
440
+ };
441
+ const describe = (el) => {
442
+ const parts = [];
443
+ let node = el;
444
+ for (let depth = 0; node && depth < 3; depth += 1) {
445
+ parts.unshift(step(node));
446
+ if (node.id || node.classList.length > 0)
447
+ break;
448
+ node = node.parentElement;
449
+ if (!node || node === document.body)
450
+ break;
451
+ }
452
+ return parts.join(' > ');
453
+ };
454
+ const textOf = (el) => (el.innerText ?? el.textContent ?? '').replace(/\s+/g, ' ').trim();
455
+ /* --- 1.3.2: DOM order versus visual order --- */
456
+ const reordered = [];
457
+ for (const el of Array.from(document.body.querySelectorAll('*')).slice(0, 2000)) {
458
+ if (reordered.length >= 10)
459
+ break;
460
+ const cs = window.getComputedStyle(el);
461
+ const order = cs.order;
462
+ if (order && order !== '0' && order !== 'normal') {
463
+ reordered.push(describe(el));
464
+ continue;
465
+ }
466
+ if (cs.flexDirection === 'row-reverse' || cs.flexDirection === 'column-reverse') {
467
+ reordered.push(describe(el));
468
+ }
469
+ }
470
+ /* --- 1.3.3: instructions relying on shape, size or position alone --- */
471
+ const SENSORY = [
472
+ 'the button on the right',
473
+ 'the button on the left',
474
+ 'on the right',
475
+ 'on the left',
476
+ 'the round button',
477
+ 'the round icon',
478
+ 'the square icon',
479
+ 'click below',
480
+ 'see below',
481
+ 'the box above',
482
+ 'the link above',
483
+ 'the green button',
484
+ 'the red button',
485
+ 'in the top right',
486
+ 'in the top left',
487
+ 'the icon shaped like',
488
+ ];
489
+ /*
490
+ * The two tests the SC actually states, applied to the SENTENCE.
491
+ *
492
+ * 1.3.3 is about instructions that rely SOLELY on a sensory characteristic.
493
+ * The check used to find the phrase and stop, then say "that is only a
494
+ * failure if the instruction cannot be followed without seeing the layout,
495
+ * which depends on what else the sentence says, so a person decides" — while
496
+ * its own next line told the author the answer: *"the Save button on the
497
+ * right" rather than "the button on the right"*. What the sentence says is
498
+ * readable, and the resolution it printed is the test it declined to run.
499
+ *
500
+ * So, per phrase found:
501
+ *
502
+ * 1. Is the sentence an INSTRUCTION? The criterion governs instructions for
503
+ * understanding and operating content, not description. "Review, on the
504
+ * left, runs now" is a statement about a product, and an imperative is
505
+ * how an instruction reads in English.
506
+ * 2. Does it rely SOLELY on the sensory characteristic? A quoted label, a
507
+ * capitalised name that is not the first word, or a `code`/`kbd` in the
508
+ * sentence is another way to identify the thing — and the SC's word is
509
+ * "solely".
510
+ *
511
+ * Both true is a failure. Either false is silence, not needs-review: a
512
+ * description is out of scope and a named control satisfies the criterion.
513
+ * Still English-only, which is why the whole group is language-gated.
514
+ */
515
+ const IMPERATIVES = 'click|select|press|choose|tap|use|see|open|enter|type|find|look|go|pick|drag|drop|tick|check|scroll|follow|read|expand|collapse|copy|paste|set|add|remove|switch|toggle';
516
+ /*
517
+ * An imperative opens a sentence, follows a comma, or follows a connective.
518
+ *
519
+ * The position is the whole test: it is what separates "press the button on
520
+ * the right" from "the column on the right is what that looks like". A bare
521
+ * word-boundary match on the verb list would catch every descriptive
522
+ * sentence containing "use" or "see", which is how a heuristic earns its
523
+ * reputation. "To continue, press …" is why the comma is in here — the first
524
+ * version had only sentence-start and connectives, and missed the commonest
525
+ * shape an instruction takes.
526
+ */
527
+ const INSTRUCTION = new RegExp(`(^|[.;:!?,—]\\s*|\\b(?:to|then|and|please|now|first|next|you can|you must)\\s+)(?:${IMPERATIVES})\\b`, 'i');
528
+ /*
529
+ * Is there another way to identify the thing? A quoted label, or a
530
+ * capitalised word that is not the sentence's first word — "press the Save
531
+ * button on the right" names its target and "press the button on the right"
532
+ * does not.
533
+ *
534
+ * Written as a word scan rather than one regex because the regex version was
535
+ * wrong in a way that read as correct: `\b[a-z]` requires the lowercase
536
+ * character to START a word, so "the Save" never matched — the `e` it needed
537
+ * is the end of "the". Every named control was reported as unnamed.
538
+ *
539
+ * It errs toward silence: a proper noun anywhere in the sentence counts, so
540
+ * "click the button on the right to open Settings" is not reported even
541
+ * though Settings is the destination rather than the control. On a heuristic
542
+ * that is the right direction to be wrong in.
543
+ */
544
+ const named = (sentence) => {
545
+ if (/["“'‘][^"”'’]{2,}["”'’]/.test(sentence))
546
+ return true;
547
+ const words = sentence.split(/\s+/).slice(1);
548
+ return words.some((word) => /^[A-Z][a-zA-Z]/.test(word.replace(/^[^A-Za-z]+/, '')));
549
+ };
550
+ const sensory = [];
551
+ for (const el of Array.from(document.querySelectorAll('p, li, label, td, div')).slice(0, 800)) {
552
+ if (sensory.length >= 10)
553
+ break;
554
+ // Leaf-ish only, so a wrapper does not report its children's sentence.
555
+ if (el.querySelector('p, li, label, td'))
556
+ continue;
557
+ const full = textOf(el);
558
+ if (full.length === 0 || full.length > 400)
559
+ continue;
560
+ const lower = full.toLowerCase();
561
+ const phrase = SENSORY.find((candidate) => lower.includes(candidate));
562
+ if (!phrase)
563
+ continue;
564
+ // The sentence carrying the phrase, not the whole paragraph: a name three
565
+ // sentences away is not what identifies this control.
566
+ const sentence = full.split(/(?<=[.!?])\s+/).find((part) => part.toLowerCase().includes(phrase)) ?? full;
567
+ if (!INSTRUCTION.test(sentence))
568
+ continue;
569
+ if (named(sentence) || el.querySelector('code, kbd'))
570
+ continue;
571
+ sensory.push({ selector: describe(el), phrase, sentence: sentence.slice(0, 160) });
572
+ }
573
+ /* --- 2.4.6: headings and labels that say nothing --- */
574
+ const GENERIC = new Set([
575
+ 'more',
576
+ 'section',
577
+ 'input',
578
+ 'field',
579
+ 'details',
580
+ 'info',
581
+ 'information',
582
+ 'text',
583
+ 'title',
584
+ 'label',
585
+ 'heading',
586
+ 'untitled',
587
+ 'read more',
588
+ 'learn more',
589
+ 'click here',
590
+ ]);
591
+ const weakHeadings = [];
592
+ const seenHeading = new Map();
593
+ const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6, legend, label'));
594
+ for (const el of headings.slice(0, 200)) {
595
+ if (weakHeadings.length >= 10)
596
+ break;
597
+ const text = textOf(el);
598
+ if (text.length === 0) {
599
+ weakHeadings.push({ selector: describe(el), why: 'empty' });
600
+ continue;
601
+ }
602
+ const lower = text.toLowerCase();
603
+ if (GENERIC.has(lower)) {
604
+ weakHeadings.push({ selector: describe(el), why: `generic ("${text}")` });
605
+ continue;
606
+ }
607
+ const key = `${el.tagName}:${lower}`;
608
+ const count = (seenHeading.get(key) ?? 0) + 1;
609
+ seenHeading.set(key, count);
610
+ if (count === 2)
611
+ weakHeadings.push({ selector: describe(el), why: `a duplicate of "${text}"` });
612
+ }
613
+ /* --- 2.4.10: sections the author declared and did not head --- */
614
+ /*
615
+ * The half this check was not doing at all, and the only half that needs no
616
+ * judgement. `<section>` and `role="region"` exist to say "this content is a
617
+ * section" — the author has already made the division the SC asks about. So
618
+ * "content is organized into sections" is not in doubt for these, and the
619
+ * criterion's requirement lands squarely: the section gets a heading.
620
+ *
621
+ * `<article>` counts too, for the same reason. `<nav>`, `<aside>`, `<form>`
622
+ * and `<header>`/`<footer>` do not: they are landmarks with their own naming
623
+ * conventions, and a nav without a visible heading is normal markup rather
624
+ * than an unheaded section of content.
625
+ *
626
+ * An `aria-label` or `aria-labelledby` satisfies it as well as a heading
627
+ * does. The requirement is that the section is named, not that the name is
628
+ * an `<h2>`.
629
+ */
630
+ const unheadedSections = [];
631
+ for (const el of Array.from(document.querySelectorAll('section, article, [role="region"]'))) {
632
+ if (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby'))
633
+ continue;
634
+ if (el.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]'))
635
+ continue;
636
+ if (unheadedSections.length < 20)
637
+ unheadedSections.push(describe(el));
638
+ }
639
+ /* --- 2.4.10: the longest run of text with no heading --- */
640
+ const main = document.querySelector('main, [role="main"]') ?? document.body;
641
+ let unheadedChars = 0;
642
+ let run = 0;
643
+ /*
644
+ * UNLABELLED prose, which is not the same as everything with text in it.
645
+ *
646
+ * The run used to count `td` and `dd`, and both are wrong for the same
647
+ * reason: they already carry a label. A `<td>` is named by its column and
648
+ * row headers, a `<dd>` by its `<dt>`, and a screen reader announces both —
649
+ * so that content is organised and labelled, which is the shape 2.4.10 asks
650
+ * for rather than a breach of it.
651
+ *
652
+ * What it cost: on this repository's own site three of the four findings
653
+ * were this. The pricing capability table and the evidence page's surface
654
+ * table each read as two and a half thousand characters of unheaded prose,
655
+ * and the evidence page's definition list of criteria read as another three
656
+ * thousand. None of them wants a heading between its rows.
657
+ *
658
+ * `reading-level.ts` already drops tables for the same reason, which is the
659
+ * tell that this was one mistake made twice in different units. Excluded by
660
+ * ancestor rather than by tag, because the text sits in a `<p>` inside the
661
+ * cell as often as in the cell itself.
662
+ *
663
+ * What is left is `p` and `li`: running prose with nothing naming it but the
664
+ * heading above it, which is exactly what the criterion is about.
665
+ */
666
+ const labelled = (el) => el.closest('table, [role="table"], [role="grid"], dl, [role="term"], [role="definition"]') !==
667
+ null;
668
+ for (const el of Array.from(main.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li'))) {
669
+ if (/^H[1-6]$/.test(el.tagName)) {
670
+ unheadedChars = Math.max(unheadedChars, run);
671
+ run = 0;
672
+ continue;
673
+ }
674
+ if (labelled(el))
675
+ continue;
676
+ run += textOf(el).length;
677
+ }
678
+ unheadedChars = Math.max(unheadedChars, run);
679
+ /* --- 3.1.4: abbreviations with no expansion and no <abbr> --- */
680
+ const abbreviations = [];
681
+ {
682
+ /*
683
+ * AUTHORED text, not rendered text.
684
+ *
685
+ * `innerText` returns what CSS painted, so an eyebrow set in
686
+ * `text-transform: uppercase` comes back as an abbreviation. On this
687
+ * repository's own site that reported THE, GAP, REVIEW and US as
688
+ * abbreviations needing expansion — ordinary words the stylesheet had
689
+ * shouted. `textContent` is what somebody typed.
690
+ */
691
+ /*
692
+ * Text nodes joined with a SPACE, not `main.textContent`.
693
+ *
694
+ * `textContent` concatenates across element boundaries with no separator,
695
+ * so it invents tokens that are not on the page. On this repository's own
696
+ * docs a heading ending "…at AAA rather than AA" ran into a paragraph
697
+ * starting "AA is the default…" and the check reported `AAAA` — a string
698
+ * nobody wrote, in a finding telling the author to expand it. The same
699
+ * glue turned an ordinal into `02Assess`, which destroys the word boundary
700
+ * the token regex depends on.
701
+ *
702
+ * Still authored text rather than `innerText`: the point of reading
703
+ * `textContent` at all is that CSS `text-transform` must not manufacture
704
+ * an abbreviation out of a shouted ordinary word.
705
+ */
706
+ const walker = document.createTreeWalker(main, NodeFilter.SHOW_TEXT);
707
+ const pieces = [];
708
+ let length = 0;
709
+ for (let node = walker.nextNode(); node && length < 20_000; node = walker.nextNode()) {
710
+ /*
711
+ * Not code. 3.1.4 is about the page's TEXT, and a token inside a
712
+ * snippet is an identifier the reader is meant to type verbatim — you
713
+ * cannot expand it on first use and it would be wrong to.
714
+ *
715
+ * Found on our own docs, where the locale `en-GB` in a config example
716
+ * was reported as the abbreviation `GB`, and the fix it asked for would
717
+ * have broken the snippet.
718
+ */
719
+ if (node.parentElement?.closest('code, pre, kbd, samp'))
720
+ continue;
721
+ const piece = (node.nodeValue ?? '').replace(/\s+/g, ' ').trim();
722
+ if (!piece)
723
+ continue;
724
+ pieces.push(piece);
725
+ length += piece.length + 1;
726
+ }
727
+ const authored = pieces.join(' ').slice(0, 20_000);
728
+ /** Tokens already carrying an `<abbr>`, which need nothing further. */
729
+ const marked = new Set();
730
+ for (const el of Array.from(document.querySelectorAll('abbr'))) {
731
+ const token = (el.textContent ?? '').trim();
732
+ if (token)
733
+ marked.add(token);
734
+ }
735
+ /*
736
+ * Ordinary English words somebody wrote in capitals, which are not
737
+ * abbreviations however emphatic they are. Reported `TO` and `AN` on this
738
+ * repository's own evidence page, in a finding asking us to expand them.
739
+ *
740
+ * Short and closed on purpose: it holds the function words and the few
741
+ * content words that turn up in a shouted sentence, and nothing that could
742
+ * plausibly also be an acronym. `IT` is deliberately absent — it is the
743
+ * department at least as often as it is the pronoun, and the wrong call
744
+ * there is a missed finding rather than a manufactured one.
745
+ */
746
+ const WORDS = new Set([
747
+ 'THE',
748
+ 'AND',
749
+ 'BUT',
750
+ 'FOR',
751
+ 'NOR',
752
+ 'YET',
753
+ 'SO',
754
+ 'OR',
755
+ 'AS',
756
+ 'AT',
757
+ 'BY',
758
+ 'IN',
759
+ 'OF',
760
+ 'ON',
761
+ 'TO',
762
+ 'UP',
763
+ 'AN',
764
+ 'A',
765
+ 'IS',
766
+ 'ARE',
767
+ 'WAS',
768
+ 'WERE',
769
+ 'BE',
770
+ 'BEEN',
771
+ 'DO',
772
+ 'DOES',
773
+ 'DID',
774
+ 'HAS',
775
+ 'HAVE',
776
+ 'HAD',
777
+ 'CAN',
778
+ 'WILL',
779
+ 'NOT',
780
+ 'NO',
781
+ 'ALL',
782
+ 'ANY',
783
+ 'ONE',
784
+ 'TWO',
785
+ 'YOU',
786
+ 'YOUR',
787
+ 'WE',
788
+ 'OUR',
789
+ 'US',
790
+ 'THIS',
791
+ 'THAT',
792
+ 'THEM',
793
+ 'THEY',
794
+ 'WHAT',
795
+ 'WHEN',
796
+ 'WHY',
797
+ 'HOW',
798
+ 'WHO',
799
+ 'IF',
800
+ 'THEN',
801
+ 'ELSE',
802
+ 'FROM',
803
+ 'WITH',
804
+ 'INTO',
805
+ 'OVER',
806
+ 'ONLY',
807
+ 'EVERY',
808
+ 'NEVER',
809
+ 'NOW',
810
+ 'NEW',
811
+ 'OLD',
812
+ 'SAME',
813
+ 'MORE',
814
+ 'MOST',
815
+ 'LESS',
816
+ 'EACH',
817
+ 'BOTH',
818
+ 'SOME',
819
+ 'SUCH',
820
+ 'HERE',
821
+ 'THERE',
822
+ 'WHERE',
823
+ 'WHICH',
824
+ 'WHILE',
825
+ 'ABOUT',
826
+ 'AFTER',
827
+ 'AGAIN',
828
+ 'STILL',
829
+ ]);
830
+ /*
831
+ * Not a run of capitals inside an identifier.
832
+ *
833
+ * `en-GB` is one token — a language tag — and the word-boundary regex cuts
834
+ * `GB` out of it. Expanding that is not what 3.1.4 asks for and the
835
+ * "resolution" would corrupt the value. Same for a dotted or slashed
836
+ * identifier. Found on our own home page, where a consent manifest figure
837
+ * lists `locales en-GB, cy, fr`.
838
+ */
839
+ const tokens = [...authored.matchAll(/\b[A-Z]{2,6}\b/g)].filter((match) => {
840
+ const before = authored[match.index - 1] ?? ' ';
841
+ const after = authored[match.index + match[0].length] ?? ' ';
842
+ return !'-_/.'.includes(before) && !'-_/'.includes(after);
843
+ });
844
+ const counted = new Set();
845
+ for (const [token] of tokens) {
846
+ // Roman numerals are not abbreviations, and `I` is not either.
847
+ if (/^[IVXLCDM]+$/.test(token))
848
+ continue;
849
+ if (WORDS.has(token))
850
+ continue;
851
+ if (counted.has(token))
852
+ continue;
853
+ counted.add(token);
854
+ // Already explained: an `<abbr>` anywhere for THIS token. The first
855
+ // version asked whether the page had ANY `<abbr>` at all, so one
856
+ // expanded abbreviation silenced the check for every other one.
857
+ if (marked.has(token))
858
+ continue;
859
+ /*
860
+ * Expanded in the prose is a mechanism too, and the commonest one:
861
+ * "Web Content Accessibility Guidelines (WCAG)" or "WCAG (Web Content
862
+ * Accessibility Guidelines)". Detected rather than ignored, because
863
+ * reporting a term the page defines in its first sentence is how a
864
+ * check gets switched off.
865
+ */
866
+ const expanded = new RegExp('(\\([^)]*\\b' + token + '\\b[^)]*\\)|\\b' + token + '\\b\\s*\\([^)]{4,}\\))').test(authored);
867
+ if (expanded)
868
+ continue;
869
+ if (abbreviations.length < 12)
870
+ abbreviations.push(token);
871
+ }
872
+ }
873
+ /*
874
+ * --- 3.1.4: a glossary, which is a mechanism like any other ---
875
+ *
876
+ * The check used to report needs-review partly on the grounds that "3.1.4
877
+ * also accepts a glossary this check cannot see". It can see one. That is
878
+ * the same excuse 2.4.8 was making about site maps, one criterion along:
879
+ * naming a sufficient technique and then declining to look for it.
880
+ *
881
+ * G62 is a glossary; a link to one is how a page carries it. Matched on the
882
+ * link rather than on any `<dl>` in the page, because a definition list is
883
+ * the markup for a glossary and also the markup for a dozen other things —
884
+ * this repository's own evidence page has three of them and none is a
885
+ * glossary. A false silence here switches the criterion off completely,
886
+ * which is worse than a finding somebody dismisses.
887
+ */
888
+ const hasGlossary = Array.from(document.querySelectorAll('a[href]')).some((a) => {
889
+ const href = (a.getAttribute('href') ?? '').toLowerCase();
890
+ const text = (a.textContent ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
891
+ return /glossary|definitions/.test(href) || /^(a |the )?(glossary|definitions)$/.test(text);
892
+ });
893
+ /* --- 2.4.8: does anything say where you are? --- */
894
+ /*
895
+ * Every sufficient technique the SC lists, not two of them.
896
+ *
897
+ * This used to look for a breadcrumb and an `aria-current`, find neither,
898
+ * and report needs-review on the grounds that "2.4.8 can also be met by a
899
+ * site map or by the page's own heading structure, neither of which this
900
+ * check can judge". A site map is a link and is perfectly visible; `rel`
901
+ * links are an attribute. Naming two mechanisms, failing to look for the
902
+ * rest, and calling the gap a judgement is the pattern this file keeps
903
+ * getting caught by.
904
+ *
905
+ * G65 breadcrumb · G63 site map · G128 current item in a nav bar ·
906
+ * H59 link rel. What survives as a genuine judgement is only whether a page
907
+ * is part of a set at all, which `hasNav` stands in for.
908
+ */
909
+ const navs = Array.from(document.querySelectorAll('nav, [role="navigation"]'));
910
+ const hasBreadcrumb = document.querySelector('[aria-label*="readcrumb"], .breadcrumb, .breadcrumbs, ol.breadcrumb') !== null;
911
+ const hasCurrent = navs.some((nav) => nav.querySelector('[aria-current]') !== null);
912
+ /*
913
+ * A site map, by href or by link text. Both, because a site links to its
914
+ * map as `/sitemap` far more often than it labels it, and labels it
915
+ * "Site map" far more often than it uses a recognisable href.
916
+ */
917
+ const hasSiteMap = Array.from(document.querySelectorAll('a[href]')).some((a) => {
918
+ const href = (a.getAttribute('href') ?? '').toLowerCase();
919
+ const text = (a.textContent ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
920
+ return /sitemap|site-map/.test(href) || /^site\s?map$/.test(text);
921
+ });
922
+ /*
923
+ * H59: `rel` stating the page's place in a collection. On `<link>` in the
924
+ * head, and on `<a>` — `rel="up"` on a parent link is the same statement.
925
+ */
926
+ const REL = /\b(index|contents|up|start|prev|previous|next)\b/i;
927
+ const hasRelLink = Array.from(document.querySelectorAll('link[rel], a[rel]')).some((el) => REL.test(el.getAttribute('rel') ?? ''));
928
+ const path = window.location.pathname.replace(/\/+$/, '');
929
+ /* --- 1.2.x: media with no caption track --- */
930
+ const mediaElements = Array.from(document.querySelectorAll('video, audio'));
931
+ const uncaptioned = [];
932
+ for (const media of mediaElements.slice(0, 20)) {
933
+ const track = media.querySelector('track[kind="captions"], track[kind="subtitles"]');
934
+ if (!track)
935
+ uncaptioned.push(describe(media));
936
+ }
937
+ return {
938
+ lang: document.documentElement.getAttribute('lang') ?? '',
939
+ reordered,
940
+ sensory,
941
+ weakHeadings,
942
+ unheadedSections,
943
+ unheadedChars,
944
+ abbreviations,
945
+ hasGlossary,
946
+ location: {
947
+ hasNav: navs.length > 0,
948
+ hasBreadcrumb,
949
+ hasCurrent,
950
+ hasSiteMap,
951
+ hasRelLink,
952
+ isRoot: path === '' || path === '/index.html',
953
+ },
954
+ uncaptioned,
955
+ media: {
956
+ elements: mediaElements.length,
957
+ frames: document.querySelectorAll('iframe, object, embed').length,
958
+ },
959
+ };
960
+ }
961
+ //# sourceMappingURL=heuristics.js.map