a11y-loop 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 (36) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +409 -0
  3. package/THIRD-PARTY-NOTICES.md +32 -0
  4. package/package.json +51 -0
  5. package/skill/a11y-loop/SKILL.md +332 -0
  6. package/skill/a11y-loop/evals/evals.json +168 -0
  7. package/skill/a11y-loop/evals/trigger-evals.json +20 -0
  8. package/skill/a11y-loop/references/ai-failure-modes.md +272 -0
  9. package/skill/a11y-loop/references/apg-patterns.md +264 -0
  10. package/skill/a11y-loop/references/manual-testing.md +224 -0
  11. package/skill/a11y-loop/references/wcag22-quick-ref.md +224 -0
  12. package/src/cli.js +207 -0
  13. package/src/commands/audit.js +125 -0
  14. package/src/commands/contrast.js +141 -0
  15. package/src/commands/diff.js +65 -0
  16. package/src/lib/axe-runner.js +400 -0
  17. package/src/lib/browser-utils.js +221 -0
  18. package/src/lib/checks/dialog.js +341 -0
  19. package/src/lib/checks/div-button.js +87 -0
  20. package/src/lib/checks/focus-visible.js +296 -0
  21. package/src/lib/checks/keyboard.js +235 -0
  22. package/src/lib/checks/link-text.js +83 -0
  23. package/src/lib/checks/reduced-motion.js +139 -0
  24. package/src/lib/checks/reflow.js +101 -0
  25. package/src/lib/checks/target-size.js +128 -0
  26. package/src/lib/contrast-math.js +189 -0
  27. package/src/lib/diff.js +118 -0
  28. package/src/lib/finding.js +164 -0
  29. package/src/lib/fingerprint.js +0 -0
  30. package/src/lib/format/checklist.js +281 -0
  31. package/src/lib/format/human.js +175 -0
  32. package/src/lib/format/json.js +139 -0
  33. package/src/lib/format/sarif.js +111 -0
  34. package/src/lib/serve.js +189 -0
  35. package/src/lib/suggest-color.js +169 -0
  36. package/src/lib/wcag-map.js +271 -0
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Focus visibility (SC 2.4.7) and focus-indicator contrast (SC 1.4.11).
3
+ *
4
+ * Method: read the computed style of every focusable element in its resting
5
+ * state, focus it, read the computed style again, and diff. A focus indicator
6
+ * exists only if something visibly CHANGED — an element that looks identical
7
+ * focused and unfocused has no indicator, whatever its stylesheet claims.
8
+ *
9
+ * The ring's own contrast against the colour behind it is SC 1.4.11 territory
10
+ * that essentially nothing automates. Where the adjacent background cannot be
11
+ * determined (background images, gradients) the finding is needs-review rather
12
+ * than a guess.
13
+ */
14
+
15
+ import { makeFinding, SEVERITY } from '../finding.js';
16
+ import { checkContrast, parseColor, ColorParseError } from '../contrast-math.js';
17
+ import { resetFocusToDocumentStart, clearFocusSentinel } from './keyboard.js';
18
+
19
+ /** Computed properties compared between resting and focused state. */
20
+ export const WATCHED_PROPERTIES = [
21
+ 'outlineStyle',
22
+ 'outlineWidth',
23
+ 'outlineColor',
24
+ 'outlineOffset',
25
+ 'boxShadow',
26
+ 'borderTopWidth',
27
+ 'borderTopColor',
28
+ 'borderBottomWidth',
29
+ 'borderBottomColor',
30
+ 'backgroundColor',
31
+ 'color',
32
+ 'textDecorationLine',
33
+ 'textDecorationColor',
34
+ ];
35
+
36
+ /** Minimum contrast for a focus indicator, as a non-text visual object. */
37
+ export const RING_CONTRAST_MIN = 3;
38
+
39
+ const isTransparent = (value) =>
40
+ typeof value === 'string' &&
41
+ (value === 'transparent' || /rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)/.test(value));
42
+
43
+ const hasWidth = (value) => Number.parseFloat(value) > 0;
44
+
45
+ /**
46
+ * Which watched properties changed on focus, described in plain terms.
47
+ *
48
+ * @param {Record<string,string>} base computed style at rest
49
+ * @param {Record<string,string>} focused computed style while focused
50
+ * @returns {{visible:boolean, indicators:string[], changed:string[]}}
51
+ */
52
+ export function describeFocusChange(base = {}, focused = {}) {
53
+ const changed = WATCHED_PROPERTIES.filter((prop) => base[prop] !== focused[prop]);
54
+ const indicators = [];
55
+
56
+ const outlineAppeared =
57
+ focused.outlineStyle !== 'none' &&
58
+ hasWidth(focused.outlineWidth) &&
59
+ !isTransparent(focused.outlineColor) &&
60
+ (base.outlineStyle === 'none' ||
61
+ !hasWidth(base.outlineWidth) ||
62
+ isTransparent(base.outlineColor) ||
63
+ base.outlineColor !== focused.outlineColor ||
64
+ base.outlineWidth !== focused.outlineWidth);
65
+ if (outlineAppeared) indicators.push('outline');
66
+
67
+ if (
68
+ focused.boxShadow &&
69
+ focused.boxShadow !== 'none' &&
70
+ base.boxShadow !== focused.boxShadow
71
+ ) {
72
+ indicators.push('box-shadow');
73
+ }
74
+ if (
75
+ base.borderTopWidth !== focused.borderTopWidth ||
76
+ base.borderTopColor !== focused.borderTopColor ||
77
+ base.borderBottomWidth !== focused.borderBottomWidth ||
78
+ base.borderBottomColor !== focused.borderBottomColor
79
+ ) {
80
+ indicators.push('border');
81
+ }
82
+ if (base.backgroundColor !== focused.backgroundColor) indicators.push('background-color');
83
+ if (base.color !== focused.color) indicators.push('text colour');
84
+ if (
85
+ base.textDecorationLine !== focused.textDecorationLine ||
86
+ base.textDecorationColor !== focused.textDecorationColor
87
+ ) {
88
+ indicators.push('text-decoration');
89
+ }
90
+
91
+ return { visible: indicators.length > 0, indicators, changed };
92
+ }
93
+
94
+ /**
95
+ * The colour of the focus indicator itself, if one can be identified.
96
+ * @returns {string|null}
97
+ */
98
+ export function indicatorColor(focused = {}, indicators = []) {
99
+ if (indicators.includes('outline') && !isTransparent(focused.outlineColor)) {
100
+ return focused.outlineColor;
101
+ }
102
+ if (indicators.includes('box-shadow')) {
103
+ const match = /(rgba?\([^)]*\))/.exec(focused.boxShadow ?? '');
104
+ if (match && !isTransparent(match[1])) return match[1];
105
+ }
106
+ if (indicators.includes('border') && !isTransparent(focused.borderTopColor)) {
107
+ return focused.borderTopColor;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * Contrast of a focus ring against what sits behind it.
114
+ *
115
+ * @returns {{determinable:boolean, ratio?:number, ratioDisplay?:string, passes?:boolean}}
116
+ */
117
+ export function ringContrast(ringColor, adjacentBackground) {
118
+ if (!ringColor || !adjacentBackground) return { determinable: false };
119
+ try {
120
+ parseColor(ringColor);
121
+ parseColor(adjacentBackground);
122
+ } catch (error) {
123
+ if (error instanceof ColorParseError) return { determinable: false };
124
+ throw error;
125
+ }
126
+ const result = checkContrast(ringColor, adjacentBackground, { ui: true });
127
+ return {
128
+ determinable: true,
129
+ ratio: result.ratio,
130
+ ratioDisplay: result.ratioDisplay,
131
+ passes: result.passes.AA,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * @param {Array} samples from `surveyFocusVisibility`
137
+ * @param {{passes:string[], state:string|null}} ctx
138
+ */
139
+ export function focusVisibilityFindings(samples, ctx) {
140
+ const findings = [];
141
+ const { passes = [], state = null } = ctx ?? {};
142
+
143
+ for (const sample of samples) {
144
+ const change = describeFocusChange(sample.base, sample.focused);
145
+
146
+ if (!change.visible) {
147
+ findings.push(
148
+ makeFinding({
149
+ ruleId: 'focus-not-visible',
150
+ source: 'a11y-loop',
151
+ severity: SEVERITY.VIOLATION,
152
+ impact: 'serious',
153
+ sc: '2.4.7',
154
+ selector: sample.selector,
155
+ html: sample.html,
156
+ message:
157
+ 'Nothing about this element changes visually when it receives keyboard focus ' +
158
+ '(no outline, box-shadow, border, background or text change). Keyboard users ' +
159
+ 'cannot tell where they are.',
160
+ passes,
161
+ state,
162
+ }),
163
+ );
164
+ continue;
165
+ }
166
+
167
+ const ring = indicatorColor(sample.focused, change.indicators);
168
+ const contrast = ringContrast(ring, sample.adjacentBackground);
169
+
170
+ if (!contrast.determinable) {
171
+ findings.push(
172
+ makeFinding({
173
+ ruleId: 'focus-indicator-contrast',
174
+ source: 'a11y-loop',
175
+ severity: SEVERITY.NEEDS_REVIEW,
176
+ impact: 'moderate',
177
+ sc: '1.4.11',
178
+ selector: sample.selector,
179
+ html: sample.html,
180
+ message:
181
+ `Focus indicator present (${change.indicators.join(', ')}), but its contrast ` +
182
+ 'could not be computed — the colour behind it is an image, a gradient, or not ' +
183
+ 'resolvable. Confirm the indicator is clearly visible against its surroundings.',
184
+ passes,
185
+ state,
186
+ data: { indicators: change.indicators, indicatorColor: ring },
187
+ }),
188
+ );
189
+ continue;
190
+ }
191
+
192
+ if (!contrast.passes) {
193
+ findings.push(
194
+ makeFinding({
195
+ ruleId: 'focus-indicator-contrast',
196
+ source: 'a11y-loop',
197
+ severity: SEVERITY.NEEDS_REVIEW,
198
+ impact: 'moderate',
199
+ sc: '1.4.11',
200
+ selector: sample.selector,
201
+ html: sample.html,
202
+ message:
203
+ `The focus indicator (${change.indicators.join(', ')}, ${ring}) has a contrast ` +
204
+ `ratio of ${contrast.ratioDisplay}:1 against the adjacent background ` +
205
+ `${sample.adjacentBackground}, below the ${RING_CONTRAST_MIN}:1 minimum for ` +
206
+ 'non-text visual information.',
207
+ passes,
208
+ state,
209
+ data: {
210
+ indicators: change.indicators,
211
+ indicatorColor: ring,
212
+ adjacentBackground: sample.adjacentBackground,
213
+ ratio: contrast.ratio,
214
+ },
215
+ }),
216
+ );
217
+ }
218
+ }
219
+
220
+ return findings;
221
+ }
222
+
223
+ /** How many Tab presses the focus walk will make. */
224
+ export const MAX_FOCUS_STEPS = 40;
225
+
226
+ /**
227
+ * Record resting and focused computed styles for each focusable element.
228
+ *
229
+ * Focus is driven with the Tab KEY rather than `element.focus()`, because
230
+ * `:focus-visible` is exactly the selector that distinguishes the two: Chromium
231
+ * applies it on keyboard focus and withholds it on programmatic focus for most
232
+ * element types. Calling `.focus()` would report missing focus rings on elements
233
+ * that style `:focus-visible` correctly.
234
+ *
235
+ * The cost is a second Tab walk (the keyboard check does its own). Both walks are
236
+ * read-only, so they cannot contaminate each other.
237
+ *
238
+ * @param {import('playwright').Page} page
239
+ */
240
+ export async function surveyFocusVisibility(page) {
241
+ const resting = await page.evaluate((watched) => {
242
+ const helpers = window.__a11yLoop;
243
+ const nodes = window.tabbable ? window.tabbable.tabbable(helpers.tabbableRoot()) : [];
244
+ if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
245
+ const read = (el) => {
246
+ const style = getComputedStyle(el);
247
+ const out = {};
248
+ for (const prop of watched) out[prop] = style[prop];
249
+ return out;
250
+ };
251
+ const map = {};
252
+ for (const el of nodes) {
253
+ if (!helpers.isVisible(el)) continue;
254
+ map[helpers.cssPath(el)] = {
255
+ html: helpers.shortHtml(el),
256
+ style: read(el),
257
+ adjacentBackground: helpers.backdropColor(el.parentElement ?? el),
258
+ };
259
+ }
260
+ return map;
261
+ }, WATCHED_PROPERTIES);
262
+
263
+ await resetFocusToDocumentStart(page);
264
+
265
+ const samples = [];
266
+ const seen = new Set();
267
+ for (let i = 0; i < MAX_FOCUS_STEPS; i++) {
268
+ await page.keyboard.press('Tab');
269
+ const stop = await page.evaluate((watched) => {
270
+ const el = document.activeElement;
271
+ if (!el || el === document.body || el === document.documentElement) return null;
272
+ if (el.hasAttribute?.('data-a11y-loop-sentinel')) return null;
273
+ const style = getComputedStyle(el);
274
+ const focused = {};
275
+ for (const prop of watched) focused[prop] = style[prop];
276
+ return { selector: window.__a11yLoop.cssPath(el), focused };
277
+ }, WATCHED_PROPERTIES);
278
+
279
+ if (!stop) break;
280
+ if (seen.has(stop.selector)) break; // wrapped around
281
+ seen.add(stop.selector);
282
+
283
+ const base = resting[stop.selector];
284
+ if (!base) continue; // tabbable did not predict it; the keyboard check reports that
285
+ samples.push({
286
+ selector: stop.selector,
287
+ html: base.html,
288
+ base: base.style,
289
+ focused: stop.focused,
290
+ adjacentBackground: base.adjacentBackground,
291
+ });
292
+ }
293
+
294
+ await clearFocusSentinel(page);
295
+ return samples;
296
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Keyboard reachability and tab order (SC 2.1.1, 2.4.3).
3
+ *
4
+ * Two things axe cannot see, because it never presses a key:
5
+ * 1. Positive `tabindex` values — always a defect. They pull elements out of
6
+ * document order into a separate, brittle tab sequence.
7
+ * 2. The observed tab walk versus the set `tabbable()` says should be
8
+ * reachable. A mismatch means either something interactive is unreachable
9
+ * (a real failure) or the order diverges from DOM order (a judgment call
10
+ * for a human, so: needs review).
11
+ */
12
+
13
+ import { makeFinding, SEVERITY } from '../finding.js';
14
+
15
+ /** How many Tab presses to walk before giving up. */
16
+ export const MAX_TAB_STEPS = 60;
17
+
18
+ /**
19
+ * @param {Array<{selector:string, html:string, tabindex:string}>} elements
20
+ * @returns {Array<{selector:string, html:string, tabindex:number}>}
21
+ */
22
+ export function findPositiveTabindex(elements) {
23
+ return elements
24
+ .map((el) => ({ ...el, tabindex: Number.parseInt(el.tabindex, 10) }))
25
+ .filter((el) => Number.isFinite(el.tabindex) && el.tabindex > 0);
26
+ }
27
+
28
+ /**
29
+ * Compare what `tabbable()` says is reachable with what actually received focus.
30
+ *
31
+ * @param {{expected:string[], observed:string[]}} walk selectors, in order
32
+ * @returns {{unreachable:string[], unexpected:string[], divergesAt:number|null}}
33
+ */
34
+ export function diffTabOrder({ expected = [], observed = [] } = {}) {
35
+ const observedSet = new Set(observed);
36
+ const expectedSet = new Set(expected);
37
+
38
+ const unreachable = expected.filter((sel) => !observedSet.has(sel));
39
+ const unexpected = observed.filter((sel) => !expectedSet.has(sel));
40
+
41
+ // Compare the order of the elements the two lists agree on.
42
+ const commonExpected = expected.filter((sel) => observedSet.has(sel));
43
+ const commonObserved = observed.filter((sel) => expectedSet.has(sel));
44
+ let divergesAt = null;
45
+ for (let i = 0; i < Math.min(commonExpected.length, commonObserved.length); i++) {
46
+ if (commonExpected[i] !== commonObserved[i]) {
47
+ divergesAt = i;
48
+ break;
49
+ }
50
+ }
51
+
52
+ return { unreachable, unexpected, divergesAt };
53
+ }
54
+
55
+ /**
56
+ * Build findings from a completed keyboard survey.
57
+ *
58
+ * @param {object} survey
59
+ * @param {Array} survey.positiveTabindex
60
+ * @param {string[]} survey.expected
61
+ * @param {string[]} survey.observed
62
+ * @param {Array<{selector:string,html:string}>} [survey.expectedDetails]
63
+ * @param {{passes:string[], state:string|null}} ctx
64
+ */
65
+ export function keyboardFindings(survey, ctx) {
66
+ const findings = [];
67
+ const { passes = [], state = null } = ctx ?? {};
68
+
69
+ const positiveTabindex = findPositiveTabindex(survey.positiveTabindex ?? []);
70
+ const positiveTabindexSelectors = new Set(positiveTabindex.map((el) => el.selector));
71
+
72
+ for (const el of positiveTabindex) {
73
+ findings.push(
74
+ makeFinding({
75
+ ruleId: 'positive-tabindex',
76
+ source: 'a11y-loop',
77
+ severity: SEVERITY.VIOLATION,
78
+ impact: 'serious',
79
+ sc: '2.4.3',
80
+ selector: el.selector,
81
+ html: el.html,
82
+ message:
83
+ `tabindex="${el.tabindex}" forces this element out of document order into a ` +
84
+ 'separate tab sequence. Use tabindex="0" (or no tabindex) and order the DOM instead.',
85
+ passes,
86
+ state,
87
+ data: { tabindex: el.tabindex },
88
+ }),
89
+ );
90
+ }
91
+
92
+ const diff = diffTabOrder(survey);
93
+ const detailFor = (selector) =>
94
+ (survey.expectedDetails ?? []).find((d) => d.selector === selector)?.html ?? '';
95
+
96
+ // A positive-tabindex element sorts into an earlier phase of the browser's
97
+ // focus order than anything a forward-only walk anchored at document start
98
+ // can reach — the walk can never observe it, however reachable it really is,
99
+ // because there is no tabindex value that sorts before a positive one. That
100
+ // makes it look "unreachable" when the real defect (and the only one worth
101
+ // reporting) is already captured, more precisely, by positive-tabindex above.
102
+ // Reporting both would double-count the same element under two rule ids and
103
+ // mislabel a "reachable too early" problem as "unreachable".
104
+ for (const selector of diff.unreachable.filter((sel) => !positiveTabindexSelectors.has(sel))) {
105
+ findings.push(
106
+ makeFinding({
107
+ ruleId: 'keyboard-unreachable',
108
+ source: 'a11y-loop',
109
+ severity: SEVERITY.VIOLATION,
110
+ impact: 'critical',
111
+ sc: '2.1.1',
112
+ selector,
113
+ html: detailFor(selector),
114
+ message:
115
+ 'This element should be reachable by keyboard but never received focus during a ' +
116
+ `Tab walk of ${MAX_TAB_STEPS} steps. Check for aria-hidden, display:none on focus, ` +
117
+ 'a focus trap earlier in the page, or tabindex="-1".',
118
+ passes,
119
+ state,
120
+ }),
121
+ );
122
+ }
123
+
124
+ if (diff.divergesAt !== null) {
125
+ findings.push(
126
+ makeFinding({
127
+ ruleId: 'focus-order-diverges',
128
+ source: 'a11y-loop',
129
+ severity: SEVERITY.NEEDS_REVIEW,
130
+ impact: 'moderate',
131
+ sc: '2.4.3',
132
+ selector: survey.observed?.[diff.divergesAt] ?? 'html',
133
+ html: '',
134
+ message:
135
+ 'The observed tab order diverges from DOM order at position ' +
136
+ `${diff.divergesAt + 1}. That is not automatically a failure — whether the order is ` +
137
+ 'logical is a human judgment. Confirm the sequence still makes sense.',
138
+ passes,
139
+ state,
140
+ data: { expected: survey.expected, observed: survey.observed },
141
+ }),
142
+ );
143
+ }
144
+
145
+ return findings;
146
+ }
147
+
148
+ /**
149
+ * Put the sequential focus navigation starting point at the top of the
150
+ * currently reachable region.
151
+ *
152
+ * `blur()` alone is not enough: Chromium remembers the blurred element as the
153
+ * starting point, so the next Tab continues from there and the elements BEFORE it
154
+ * are never visited — which looks exactly like an unreachable control. This
155
+ * matters whenever a state setup function has clicked something, which is most of
156
+ * the time. A focused sentinel at the start of the body fixes the origin.
157
+ *
158
+ * "Top of the document" is not always `document.body`: if a native `<dialog>`
159
+ * is open, everything outside it is inert, so a sentinel inserted into `body`
160
+ * could never be focused at all. The sentinel goes into whichever element
161
+ * `tabbableRoot()` says is actually reachable right now.
162
+ *
163
+ * @param {import('playwright').Page} page
164
+ */
165
+ export async function resetFocusToDocumentStart(page) {
166
+ await page.evaluate(() => {
167
+ document.querySelector('[data-a11y-loop-sentinel]')?.remove();
168
+ const sentinel = document.createElement('span');
169
+ sentinel.tabIndex = 0;
170
+ sentinel.setAttribute('data-a11y-loop-sentinel', '');
171
+ sentinel.style.cssText =
172
+ 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;';
173
+ const root = window.__a11yLoop ? window.__a11yLoop.tabbableRoot() : document.body;
174
+ root.insertBefore(sentinel, root.firstChild);
175
+ sentinel.focus();
176
+ });
177
+ }
178
+
179
+ /** Remove the sentinel so it can never appear in a report or a later pass. */
180
+ export async function clearFocusSentinel(page) {
181
+ await page.evaluate(() => document.querySelector('[data-a11y-loop-sentinel]')?.remove());
182
+ }
183
+
184
+ /**
185
+ * Run the keyboard survey in a live page.
186
+ * @param {import('playwright').Page} page
187
+ */
188
+ export async function surveyKeyboard(page) {
189
+ const expectedDetails = await page.evaluate(() => {
190
+ const helpers = window.__a11yLoop;
191
+ const nodes = window.tabbable ? window.tabbable.tabbable(helpers.tabbableRoot()) : [];
192
+ return nodes.map((el) => ({
193
+ selector: helpers.cssPath(el),
194
+ html: helpers.shortHtml(el),
195
+ }));
196
+ });
197
+
198
+ const positiveTabindex = await page.evaluate(() => {
199
+ const helpers = window.__a11yLoop;
200
+ return Array.from(document.querySelectorAll('[tabindex]'))
201
+ .filter((el) => helpers.isVisible(el))
202
+ .map((el) => ({
203
+ selector: helpers.cssPath(el),
204
+ html: helpers.shortHtml(el),
205
+ tabindex: el.getAttribute('tabindex'),
206
+ }));
207
+ });
208
+
209
+ await resetFocusToDocumentStart(page);
210
+
211
+ const observed = [];
212
+ const seen = new Set();
213
+ for (let i = 0; i < MAX_TAB_STEPS; i++) {
214
+ await page.keyboard.press('Tab');
215
+ const current = await page.evaluate(() => {
216
+ const el = document.activeElement;
217
+ if (!el || el === document.body || el === document.documentElement) return null;
218
+ if (el.hasAttribute?.('data-a11y-loop-sentinel')) return null;
219
+ return window.__a11yLoop.cssPath(el);
220
+ });
221
+ if (current === null) break;
222
+ if (seen.has(current)) break; // wrapped around
223
+ seen.add(current);
224
+ observed.push(current);
225
+ }
226
+
227
+ await clearFocusSentinel(page);
228
+
229
+ return {
230
+ expected: expectedDetails.map((d) => d.selector),
231
+ expectedDetails,
232
+ observed,
233
+ positiveTabindex,
234
+ };
235
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Ambiguous link text (SC 2.4.4 Link Purpose (In Context), Level A).
3
+ *
4
+ * Cheap heuristic, deliberately narrow. "Read more" IS allowed by SC 2.4.4 when
5
+ * the surrounding context makes the destination clear, and only a human can say
6
+ * whether it does — so every finding here is needs-review, never a violation.
7
+ * The phrase list is kept short on purpose: a broad list produces noise, and
8
+ * noise is what makes agents ignore reports.
9
+ */
10
+
11
+ import { makeFinding, SEVERITY } from '../finding.js';
12
+
13
+ /** Exact accessible names treated as uninformative on their own. */
14
+ export const AMBIGUOUS_PHRASES = ['click here', 'read more', 'learn more', 'here', 'more'];
15
+
16
+ /**
17
+ * Normalise an accessible name for comparison: lowercase, strip surrounding
18
+ * punctuation and trailing chevrons/arrows, collapse whitespace.
19
+ */
20
+ export function normalizeLinkText(text) {
21
+ if (typeof text !== 'string') return '';
22
+ return text
23
+ .toLowerCase()
24
+ .replace(/[→⇒›»>]+/g, ' ')
25
+ .replace(/[.!?:,;…]+/g, ' ')
26
+ .replace(/\s+/g, ' ')
27
+ .trim();
28
+ }
29
+
30
+ /** @returns {boolean} */
31
+ export function isAmbiguousLinkText(text) {
32
+ return AMBIGUOUS_PHRASES.includes(normalizeLinkText(text));
33
+ }
34
+
35
+ /**
36
+ * @param {Array<{selector:string, html:string, name:string, context?:string}>} links
37
+ * @param {{passes:string[], state:string|null}} ctx
38
+ */
39
+ export function linkTextFindings(links = [], ctx) {
40
+ const { passes = [], state = null } = ctx ?? {};
41
+ return links
42
+ .filter((link) => isAmbiguousLinkText(link.name))
43
+ .map((link) =>
44
+ makeFinding({
45
+ ruleId: 'ambiguous-link-text',
46
+ source: 'a11y-loop',
47
+ severity: SEVERITY.NEEDS_REVIEW,
48
+ impact: 'moderate',
49
+ sc: '2.4.4',
50
+ selector: link.selector,
51
+ html: link.html,
52
+ message:
53
+ `Link text is "${link.name.trim()}", which does not describe the destination. ` +
54
+ 'SC 2.4.4 allows this when the surrounding context supplies the purpose, and only a ' +
55
+ 'human can judge that — so confirm the context, or name the destination in the link ' +
56
+ '(e.g. "Read more about the speaker line-up").',
57
+ passes,
58
+ state,
59
+ data: { name: link.name.trim(), context: link.context ?? null },
60
+ }),
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Collect visible links and their accessible names.
66
+ * @param {import('playwright').Page} page
67
+ */
68
+ export async function surveyLinks(page) {
69
+ return page.evaluate(() => {
70
+ const helpers = window.__a11yLoop;
71
+ return Array.from(document.querySelectorAll('a[href], [role="link"]'))
72
+ .filter((el) => helpers.isVisible(el))
73
+ .map((el) => ({
74
+ selector: helpers.cssPath(el),
75
+ html: helpers.shortHtml(el),
76
+ name: helpers.accessibleName(el),
77
+ context: (el.closest('li, p, td, h1, h2, h3, h4, section')?.textContent ?? '')
78
+ .replace(/\s+/g, ' ')
79
+ .trim()
80
+ .slice(0, 120),
81
+ }));
82
+ });
83
+ }