@masup9/a11y-audit 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/CHANGELOG.md +34 -0
  2. package/README.ja.md +128 -0
  3. package/README.md +130 -0
  4. package/dist/constants.d.ts +88 -0
  5. package/dist/constants.js +184 -0
  6. package/dist/index.d.ts +13 -0
  7. package/dist/index.js +13 -0
  8. package/dist/playwright/index.d.ts +19 -0
  9. package/dist/playwright/index.js +19 -0
  10. package/dist/playwright/runAxeAudit.d.ts +28 -0
  11. package/dist/playwright/runAxeAudit.js +89 -0
  12. package/dist/playwright/runFocusIndicatorCheck.d.ts +30 -0
  13. package/dist/playwright/runFocusIndicatorCheck.js +740 -0
  14. package/dist/playwright/runReflowCheck.d.ts +36 -0
  15. package/dist/playwright/runReflowCheck.js +68 -0
  16. package/dist/playwright/runTargetSizeCheck.d.ts +35 -0
  17. package/dist/playwright/runTargetSizeCheck.js +389 -0
  18. package/dist/schemas/index.d.ts +34 -0
  19. package/dist/schemas/index.js +245 -0
  20. package/dist/test-entries/axe-audit.d.ts +13 -0
  21. package/dist/test-entries/axe-audit.js +20 -0
  22. package/dist/test-entries/focus-indicator-check.d.ts +9 -0
  23. package/dist/test-entries/focus-indicator-check.js +13 -0
  24. package/dist/test-entries/reflow-check.d.ts +9 -0
  25. package/dist/test-entries/reflow-check.js +21 -0
  26. package/dist/test-entries/target-size-check.d.ts +8 -0
  27. package/dist/test-entries/target-size-check.js +15 -0
  28. package/dist/types.d.ts +215 -0
  29. package/dist/types.js +4 -0
  30. package/dist/utils/annotations.d.ts +67 -0
  31. package/dist/utils/annotations.js +110 -0
  32. package/dist/utils/layout.d.ts +23 -0
  33. package/dist/utils/layout.js +144 -0
  34. package/dist/utils/test-harness.d.ts +76 -0
  35. package/dist/utils/test-harness.js +119 -0
  36. package/package.json +92 -0
@@ -0,0 +1,740 @@
1
+ /**
2
+ * Focus Indicator Visibility Check
3
+ *
4
+ * WCAG 2.4.7 (Focus Visible) / 2.4.12 (Focus Not Obscured) / 3.2.1 (On Focus)
5
+ *
6
+ * Unlike the other checks, this one owns its `BrowserContext`: it tabs through
7
+ * every focusable element and, when focus triggers a navigation (3.2.1), it
8
+ * restarts in a fresh context with the offending element skipped. For that
9
+ * reason it accepts a `browser` (not a `page`) and navigates internally.
10
+ *
11
+ * The context this function creates is closed before it returns.
12
+ */
13
+ import { FOCUSABLE_SELECTOR, FOCUS_STYLE_PROPERTIES, EXTRA_TAB_ITERATIONS, DEFAULT_FOCUS_RESULT_FILE, DEFAULT_FOCUS_SCREENSHOT_FILE, FOCUS_OBSCURED_MIN_OVERLAP_RATIO, FOCUS_OBSCURED_MIN_OVERLAP_PX, FOCUS_OBSCURED_EXCLUDE_SELECTORS, } from '../constants.js';
14
+ import { resolveOutputPath, resolveScreenshotPath, saveAuditResult, takeAuditScreenshot, requireTargetUrl, logAuditHeader, logOutputPaths, } from '../utils/test-harness.js';
15
+ // =============================================================================
16
+ // Browser-injected styles for marking elements
17
+ // =============================================================================
18
+ // Overlay-based annotation styles (no content DOM modification)
19
+ const WARNING_STYLES = `
20
+ #focus-audit-overlay {
21
+ position: absolute;
22
+ top: 0;
23
+ left: 0;
24
+ width: 100%;
25
+ height: 100%;
26
+ pointer-events: none;
27
+ z-index: 99999;
28
+ }
29
+ .focus-audit-box {
30
+ position: absolute;
31
+ border: 3px solid #dc2626;
32
+ box-sizing: border-box;
33
+ pointer-events: none;
34
+ }
35
+ .focus-audit-box.navigation-violation {
36
+ border-color: #7c3aed;
37
+ }
38
+ .focus-audit-box.has-focus-style {
39
+ border-color: #16a34a;
40
+ }
41
+ .focus-audit-label {
42
+ position: absolute;
43
+ top: -22px;
44
+ left: -3px;
45
+ background: #dc2626;
46
+ color: white;
47
+ font-size: 11px;
48
+ font-weight: bold;
49
+ padding: 2px 6px;
50
+ border-radius: 3px;
51
+ white-space: nowrap;
52
+ font-family: system-ui, sans-serif;
53
+ }
54
+ .focus-audit-box.navigation-violation .focus-audit-label {
55
+ background: #7c3aed;
56
+ }
57
+ .focus-audit-box.has-focus-style .focus-audit-label {
58
+ background: #16a34a;
59
+ }
60
+ .focus-audit-box.focus-obscured {
61
+ border-color: #ea580c;
62
+ border-style: dashed;
63
+ }
64
+ .focus-audit-box.focus-obscured .focus-audit-label {
65
+ background: #ea580c;
66
+ }
67
+ `;
68
+ // Maximum retry attempts to avoid infinite loops
69
+ const MAX_RETRIES = 5;
70
+ /**
71
+ * Run the focus indicator check, write the result JSON (and optionally a
72
+ * screenshot), and return the parsed result.
73
+ */
74
+ export async function runFocusIndicatorCheck(options) {
75
+ const { browser, targetUrl: targetUrlOption, screenshot = false, contextOptions, ...location } = options;
76
+ const targetUrl = requireTargetUrl(targetUrlOption);
77
+ const resolvedResultPath = resolveOutputPath({
78
+ ...location,
79
+ defaultFile: DEFAULT_FOCUS_RESULT_FILE,
80
+ });
81
+ const resolvedScreenshotPath = resolveScreenshotPath(resolvedResultPath, DEFAULT_FOCUS_SCREENSHOT_FILE);
82
+ const onFocusViolations = [];
83
+ const focusObscuredIssues = [];
84
+ const skipSelectors = [];
85
+ let finalFocusHistory = [];
86
+ let retryCount = 0;
87
+ let finalPage = null;
88
+ // The context for the current attempt. Held outside the loop so it is always
89
+ // closed in `finally`, including if an attempt throws mid-way.
90
+ let context = null;
91
+ try {
92
+ // Retry loop - restart test when navigation violation is detected
93
+ while (retryCount < MAX_RETRIES) {
94
+ // Create a fresh context for each attempt to reset init scripts
95
+ context = await browser.newContext(contextOptions);
96
+ const page = await context.newPage();
97
+ const focusHistory = [];
98
+ let lastFocusedElement = null;
99
+ let navigationDetected = false;
100
+ // Expose function to receive focus reports from browser context
101
+ await page.exposeFunction('reportFocus', (data) => {
102
+ focusHistory.push(data);
103
+ lastFocusedElement = {
104
+ tag: data.tag,
105
+ role: data.role,
106
+ name: data.name,
107
+ selector: data.selector ||
108
+ `${data.tag.toLowerCase()}:nth-of-type(${data.id + 1})`,
109
+ };
110
+ });
111
+ // Expose function to receive focus obscured reports (WCAG 2.4.12)
112
+ await page.exposeFunction('reportFocusObscured', (data) => {
113
+ focusObscuredIssues.push(data);
114
+ });
115
+ // Inject focus tracking script with current skip list
116
+ await page.addInitScript(createFocusTrackerScript, {
117
+ focusableSelector: FOCUSABLE_SELECTOR,
118
+ styleProperties: [...FOCUS_STYLE_PROPERTIES],
119
+ warningStyles: WARNING_STYLES,
120
+ skipSelectors: [...skipSelectors],
121
+ obscuredConfig: {
122
+ minOverlapRatio: FOCUS_OBSCURED_MIN_OVERLAP_RATIO,
123
+ minOverlapPx: FOCUS_OBSCURED_MIN_OVERLAP_PX,
124
+ excludeSelectors: [...FOCUS_OBSCURED_EXCLUDE_SELECTORS],
125
+ },
126
+ });
127
+ // Navigate to target page
128
+ await page.goto(targetUrl, { waitUntil: 'networkidle' });
129
+ // Initialize focus tracker and get element count
130
+ const count = await page.evaluate(() => window.initFocusTracker());
131
+ // Mark elements that caused navigation violations (from previous attempts)
132
+ if (skipSelectors.length > 0) {
133
+ await page.evaluate((selectors) => {
134
+ selectors.forEach((sel) => {
135
+ const el = document.querySelector(sel);
136
+ if (el) {
137
+ el.setAttribute('data-focus-navigation-violation', '');
138
+ el.tabIndex = -1; // Remove from tab order
139
+ // Add annotation box to overlay (uses function from initFocusTracker)
140
+ const overlay = document.getElementById('focus-audit-overlay');
141
+ if (overlay) {
142
+ const rect = el.getBoundingClientRect();
143
+ const box = document.createElement('div');
144
+ box.className = 'focus-audit-box navigation-violation';
145
+ box.style.left = `${rect.left + window.scrollX}px`;
146
+ box.style.top = `${rect.top + window.scrollY}px`;
147
+ box.style.width = `${rect.width}px`;
148
+ box.style.height = `${rect.height}px`;
149
+ const labelEl = document.createElement('span');
150
+ labelEl.className = 'focus-audit-label';
151
+ labelEl.textContent = '⚠ 3.2.1 Navigation on Focus';
152
+ box.appendChild(labelEl);
153
+ overlay.appendChild(box);
154
+ }
155
+ }
156
+ });
157
+ }, skipSelectors);
158
+ }
159
+ // Tab through all elements with navigation detection
160
+ for (let i = 0; i < count + EXTRA_TAB_ITERATIONS; i++) {
161
+ const urlBeforeTab = page.url();
162
+ await page.keyboard.press('Tab');
163
+ // Get currently focused element IMMEDIATELY after Tab
164
+ let currentFocusedElement = null;
165
+ try {
166
+ currentFocusedElement = await page.evaluate(() => {
167
+ const el = document.activeElement;
168
+ if (!el || el === document.body)
169
+ return null;
170
+ const getSelector = (element) => {
171
+ if (element.id)
172
+ return `#${element.id}`;
173
+ const tag = element.tagName.toLowerCase();
174
+ const parent = element.parentElement;
175
+ if (!parent)
176
+ return tag;
177
+ const siblings = [...parent.children].filter((c) => c.tagName === element.tagName);
178
+ if (siblings.length === 1)
179
+ return `${getSelector(parent)} > ${tag}`;
180
+ const index = siblings.indexOf(element) + 1;
181
+ return `${getSelector(parent)} > ${tag}:nth-of-type(${index})`;
182
+ };
183
+ return {
184
+ tag: el.tagName,
185
+ role: el.getAttribute('role'),
186
+ name: el.getAttribute('aria-label') ||
187
+ el.textContent?.slice(0, 30) ||
188
+ '',
189
+ selector: getSelector(el),
190
+ };
191
+ });
192
+ }
193
+ catch {
194
+ // Page might have navigated, use lastFocusedElement as fallback
195
+ currentFocusedElement = lastFocusedElement;
196
+ }
197
+ // Small wait to allow any navigation to start
198
+ await page.waitForTimeout(50);
199
+ // Check if navigation occurred
200
+ const urlAfterTab = page.url();
201
+ if (urlAfterTab !== urlBeforeTab) {
202
+ // 3.2.1 violation detected!
203
+ navigationDetected = true;
204
+ const culprit = currentFocusedElement || lastFocusedElement;
205
+ if (culprit && !skipSelectors.includes(culprit.selector)) {
206
+ onFocusViolations.push({
207
+ element: culprit,
208
+ fromUrl: urlBeforeTab,
209
+ toUrl: urlAfterTab,
210
+ changeType: 'navigation',
211
+ });
212
+ skipSelectors.push(culprit.selector);
213
+ console.warn(`\n⚠️ WCAG 3.2.1 Violation: Focus on element caused navigation!`);
214
+ console.warn(` Element: <${culprit.tag}> "${culprit.name}"`);
215
+ console.warn(` Selector: ${culprit.selector}`);
216
+ console.warn(` From: ${urlBeforeTab}`);
217
+ console.warn(` To: ${urlAfterTab}`);
218
+ console.warn(` Restarting test with this element skipped...`);
219
+ }
220
+ break;
221
+ }
222
+ }
223
+ if (!navigationDetected) {
224
+ // Test completed successfully without navigation
225
+ finalFocusHistory = focusHistory;
226
+ finalPage = page;
227
+ break;
228
+ }
229
+ retryCount++;
230
+ if (retryCount >= MAX_RETRIES) {
231
+ console.warn(`\n⚠️ Max retries (${MAX_RETRIES}) reached. Some elements may not have been tested.`);
232
+ // Keep this page with its annotations for the screenshot
233
+ finalFocusHistory = focusHistory;
234
+ finalPage = page;
235
+ break;
236
+ }
237
+ // Close context before retry (only if we're going to retry)
238
+ await context.close();
239
+ context = null;
240
+ }
241
+ // Ensure finalPage is set (should always be set by this point)
242
+ if (!finalPage) {
243
+ throw new Error('No page available for screenshot');
244
+ }
245
+ // Report results
246
+ const elementsWithoutFocusStyle = finalFocusHistory.filter((f) => !f.hasFocusStyle);
247
+ if (elementsWithoutFocusStyle.length > 0) {
248
+ console.warn('Elements without visible focus indicator:', elementsWithoutFocusStyle);
249
+ }
250
+ // Build result
251
+ const result = {
252
+ url: finalPage.url(),
253
+ totalFocusableElements: finalFocusHistory.length,
254
+ elementsWithFocusStyle: finalFocusHistory.length - elementsWithoutFocusStyle.length,
255
+ elementsWithoutFocusStyle: elementsWithoutFocusStyle.length,
256
+ issues: elementsWithoutFocusStyle.map((el) => ({
257
+ tag: el.tag,
258
+ role: el.role,
259
+ name: el.name,
260
+ })),
261
+ onFocusViolations,
262
+ focusObscuredIssues,
263
+ elementsWithObscuredFocus: focusObscuredIssues.length,
264
+ allElements: finalFocusHistory,
265
+ interrupted: false,
266
+ screenshotPath: screenshot ? resolvedScreenshotPath : '',
267
+ };
268
+ // Output results
269
+ logAuditHeader('Focus Indicator Check Results', 'WCAG 2.4.7 / 2.4.12 / 3.2.1', result.url);
270
+ console.log(`Total focusable elements: ${result.totalFocusableElements}`);
271
+ console.log(`Elements with focus style: ${result.elementsWithFocusStyle}`);
272
+ console.log(`Elements WITHOUT focus style: ${result.elementsWithoutFocusStyle}`);
273
+ console.log(`Elements with OBSCURED focus: ${result.elementsWithObscuredFocus}`);
274
+ if (retryCount > 0) {
275
+ console.log(`\nTest restarted ${retryCount} time(s) due to navigation violations`);
276
+ }
277
+ // WCAG 2.4.7 summary
278
+ if (elementsWithoutFocusStyle.length > 0) {
279
+ console.log('\n--- WCAG 2.4.7: Elements Missing Focus Indicator ---');
280
+ elementsWithoutFocusStyle.forEach((el, i) => {
281
+ console.log(` ${i + 1}. <${el.tag}> "${el.name}" (role: ${el.role || 'none'})`);
282
+ });
283
+ }
284
+ // WCAG 2.4.12 summary
285
+ if (focusObscuredIssues.length > 0) {
286
+ console.log('\n--- WCAG 2.4.12: Focus Obscured by Fixed/Sticky Elements ---');
287
+ focusObscuredIssues.forEach((issue, i) => {
288
+ console.log(` ${i + 1}. <${issue.element.tag}> "${issue.element.name}"`);
289
+ console.log(` Selector: ${issue.element.selector}`);
290
+ console.log(` Obscured ratio: ${(issue.obscuredRatio * 100).toFixed(1)}%`);
291
+ issue.overlaps.forEach((overlap) => {
292
+ console.log(` Obscured by: <${overlap.obscuredBy.tag}> "${overlap.obscuredBy.name}"`);
293
+ });
294
+ });
295
+ }
296
+ // WCAG 3.2.1 summary
297
+ if (onFocusViolations.length > 0) {
298
+ console.log('\n--- WCAG 3.2.1: Focus Triggered Context Change ---');
299
+ onFocusViolations.forEach((v, i) => {
300
+ console.log(` ${i + 1}. <${v.element.tag}> "${v.element.name}"`);
301
+ console.log(` Selector: ${v.element.selector}`);
302
+ console.log(` Navigated to: ${v.toUrl}`);
303
+ });
304
+ }
305
+ let writtenScreenshotPath;
306
+ if (screenshot) {
307
+ writtenScreenshotPath = await takeAuditScreenshot(finalPage, {
308
+ path: resolvedScreenshotPath,
309
+ });
310
+ }
311
+ const writtenResultPath = saveAuditResult(result, {
312
+ ...location,
313
+ defaultFile: DEFAULT_FOCUS_RESULT_FILE,
314
+ });
315
+ logOutputPaths(writtenResultPath, writtenScreenshotPath);
316
+ return result;
317
+ }
318
+ finally {
319
+ // Close the context for the final/in-flight attempt. Contexts from earlier
320
+ // retries are already closed inside the loop; this also covers the case
321
+ // where an attempt throws mid-way.
322
+ if (context) {
323
+ await context.close();
324
+ }
325
+ }
326
+ }
327
+ // =============================================================================
328
+ // Browser-injected script factory
329
+ // =============================================================================
330
+ function createFocusTrackerScript(args) {
331
+ const { focusableSelector, styleProperties, warningStyles, skipSelectors, obscuredConfig } = args;
332
+ // Add warning styles
333
+ const styleSheet = new CSSStyleSheet();
334
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, styleSheet];
335
+ warningStyles
336
+ .split('}')
337
+ .filter((r) => r.trim())
338
+ .forEach((rule, i) => {
339
+ try {
340
+ styleSheet.insertRule(rule + '}', i);
341
+ }
342
+ catch {
343
+ // Ignore invalid rules
344
+ }
345
+ });
346
+ const baseStyles = new Map();
347
+ const elementSelectors = new Map();
348
+ let focusId = 0;
349
+ /**
350
+ * Generate a CSS selector for an element
351
+ */
352
+ const getSelector = (el) => {
353
+ if (el.id)
354
+ return `#${el.id}`;
355
+ const tag = el.tagName.toLowerCase();
356
+ const parent = el.parentElement;
357
+ if (!parent)
358
+ return tag;
359
+ const siblings = [...parent.children].filter((c) => c.tagName === el.tagName);
360
+ if (siblings.length === 1)
361
+ return `${getSelector(parent)} > ${tag}`;
362
+ const index = siblings.indexOf(el) + 1;
363
+ return `${getSelector(parent)} > ${tag}:nth-of-type(${index})`;
364
+ };
365
+ /**
366
+ * Capture computed style for focus-related properties
367
+ */
368
+ const captureStyle = (el) => {
369
+ const style = window.getComputedStyle(el);
370
+ const result = {};
371
+ for (const prop of styleProperties) {
372
+ result[prop] = style[prop] ?? '';
373
+ }
374
+ return result;
375
+ };
376
+ /**
377
+ * Convert camelCase to kebab-case
378
+ */
379
+ const toKebab = (str) => str.replace(/([A-Z])/g, '-$1').toLowerCase();
380
+ /**
381
+ * Check if element is visible
382
+ */
383
+ const isVisible = (el) => {
384
+ const style = window.getComputedStyle(el);
385
+ return (style.display !== 'none' &&
386
+ style.visibility !== 'hidden' &&
387
+ !el.closest('[inert]'));
388
+ };
389
+ /**
390
+ * Check if style changes represent a visible focus indicator
391
+ * outline-offset alone is not enough - need actual outline to be visible
392
+ */
393
+ const isVisibleFocusChange = (diff, focusedStyle) => {
394
+ const diffKeys = Object.keys(diff);
395
+ if (diffKeys.length === 0)
396
+ return false;
397
+ // Filter out changes that don't result in visible indicators
398
+ const visibleChanges = diffKeys.filter((key) => {
399
+ // outline-offset alone is not visible without outline
400
+ if (key === 'outlineOffset') {
401
+ const outlineStyle = focusedStyle['outlineStyle'];
402
+ const outlineWidth = focusedStyle['outlineWidth'];
403
+ if (outlineStyle === 'none' || outlineWidth === '0px') {
404
+ return false;
405
+ }
406
+ }
407
+ // outline-color change alone is not visible without outline
408
+ if (key === 'outlineColor') {
409
+ const outlineStyle = focusedStyle['outlineStyle'];
410
+ const outlineWidth = focusedStyle['outlineWidth'];
411
+ if (outlineStyle === 'none' || outlineWidth === '0px') {
412
+ return false;
413
+ }
414
+ }
415
+ // Check outline-style/outline-width actually creates visible outline
416
+ if (key === 'outlineStyle' || key === 'outlineWidth') {
417
+ const outlineStyle = focusedStyle['outlineStyle'];
418
+ const outlineWidth = focusedStyle['outlineWidth'];
419
+ if (outlineStyle === 'none' || outlineWidth === '0px') {
420
+ return false;
421
+ }
422
+ }
423
+ // box-shadow: none is not visible
424
+ if (key === 'boxShadow' && diff[key] === 'none') {
425
+ return false;
426
+ }
427
+ return true;
428
+ });
429
+ return visibleChanges.length > 0;
430
+ };
431
+ /**
432
+ * Check if element should be skipped (caused navigation in previous attempt)
433
+ */
434
+ const shouldSkip = (el) => {
435
+ const selector = getSelector(el);
436
+ return skipSelectors.includes(selector);
437
+ };
438
+ /**
439
+ * Create overlay container for annotations
440
+ */
441
+ const createOverlay = () => {
442
+ let overlay = document.getElementById('focus-audit-overlay');
443
+ if (!overlay) {
444
+ overlay = document.createElement('div');
445
+ overlay.id = 'focus-audit-overlay';
446
+ document.body.appendChild(overlay);
447
+ }
448
+ return overlay;
449
+ };
450
+ /**
451
+ * Add annotation box to overlay for an element
452
+ */
453
+ const addAnnotationBox = (el, label, cssClass) => {
454
+ const overlay = createOverlay();
455
+ const rect = el.getBoundingClientRect();
456
+ const box = document.createElement('div');
457
+ box.className = `focus-audit-box ${cssClass}`;
458
+ box.style.left = `${rect.left + window.scrollX}px`;
459
+ box.style.top = `${rect.top + window.scrollY}px`;
460
+ box.style.width = `${rect.width}px`;
461
+ box.style.height = `${rect.height}px`;
462
+ const labelEl = document.createElement('span');
463
+ labelEl.className = 'focus-audit-label';
464
+ labelEl.textContent = label;
465
+ box.appendChild(labelEl);
466
+ overlay.appendChild(box);
467
+ };
468
+ /**
469
+ * Calculate rectangle intersection
470
+ */
471
+ const getIntersection = (rect1, rect2) => {
472
+ const left = Math.max(rect1.left, rect2.left);
473
+ const top = Math.max(rect1.top, rect2.top);
474
+ const right = Math.min(rect1.right, rect2.right);
475
+ const bottom = Math.min(rect1.bottom, rect2.bottom);
476
+ if (left < right && top < bottom) {
477
+ return {
478
+ left,
479
+ top,
480
+ width: right - left,
481
+ height: bottom - top,
482
+ };
483
+ }
484
+ return null;
485
+ };
486
+ /**
487
+ * Get all fixed/sticky positioned elements that could obscure focus
488
+ */
489
+ const getObscurerCandidates = () => {
490
+ const all = document.querySelectorAll('*');
491
+ const candidates = [];
492
+ all.forEach((el) => {
493
+ // Skip excluded selectors
494
+ if (obscuredConfig.excludeSelectors.some((sel) => el.matches(sel))) {
495
+ return;
496
+ }
497
+ const style = window.getComputedStyle(el);
498
+ const position = style.position;
499
+ // Only fixed and sticky positioned elements can obscure
500
+ if (position !== 'fixed' && position !== 'sticky') {
501
+ return;
502
+ }
503
+ // Must be visible
504
+ if (style.display === 'none' ||
505
+ style.visibility === 'hidden' ||
506
+ parseFloat(style.opacity) === 0) {
507
+ return;
508
+ }
509
+ // Must have area
510
+ const rect = el.getBoundingClientRect();
511
+ if (rect.width === 0 || rect.height === 0) {
512
+ return;
513
+ }
514
+ candidates.push(el);
515
+ });
516
+ return candidates;
517
+ };
518
+ /**
519
+ * Check if an obscurer is actually in front of the focused element
520
+ */
521
+ const isActuallyObscuring = (focusedEl, obscurer, intersection) => {
522
+ // Calculate safe sample offsets (clamped to intersection bounds)
523
+ const marginX = Math.min(2, intersection.width / 4);
524
+ const marginY = Math.min(2, intersection.height / 4);
525
+ // For very small intersections, only use center point
526
+ const useOnlyCenter = intersection.width < 6 || intersection.height < 6;
527
+ // Sample points within the intersection area (clamped to bounds)
528
+ const samplePoints = useOnlyCenter
529
+ ? [
530
+ {
531
+ x: intersection.left + intersection.width / 2,
532
+ y: intersection.top + intersection.height / 2,
533
+ },
534
+ ]
535
+ : [
536
+ {
537
+ x: intersection.left + intersection.width / 2,
538
+ y: intersection.top + intersection.height / 2,
539
+ }, // center
540
+ { x: intersection.left + marginX, y: intersection.top + marginY }, // top-left
541
+ {
542
+ x: intersection.left + intersection.width - marginX,
543
+ y: intersection.top + marginY,
544
+ }, // top-right
545
+ {
546
+ x: intersection.left + marginX,
547
+ y: intersection.top + intersection.height - marginY,
548
+ }, // bottom-left
549
+ {
550
+ x: intersection.left + intersection.width - marginX,
551
+ y: intersection.top + intersection.height - marginY,
552
+ }, // bottom-right
553
+ ];
554
+ let obscuringCount = 0;
555
+ let nullCount = 0;
556
+ for (const point of samplePoints) {
557
+ const topEl = document.elementFromPoint(point.x, point.y);
558
+ if (topEl) {
559
+ if (topEl === obscurer || obscurer.contains(topEl)) {
560
+ obscuringCount++;
561
+ }
562
+ }
563
+ else {
564
+ nullCount++;
565
+ }
566
+ }
567
+ // For small intersections (center only), require the center to confirm obscuring
568
+ if (useOnlyCenter) {
569
+ if (obscuringCount >= 1)
570
+ return true;
571
+ if (nullCount === 1) {
572
+ return checkZIndexOrder(focusedEl, obscurer);
573
+ }
574
+ return false;
575
+ }
576
+ // For normal intersections: require at least 2/5 points
577
+ if (obscuringCount >= 2)
578
+ return true;
579
+ if (nullCount >= 3) {
580
+ return checkZIndexOrder(focusedEl, obscurer);
581
+ }
582
+ return false;
583
+ };
584
+ /**
585
+ * Fallback z-index comparison for elements with pointer-events:none
586
+ */
587
+ const checkZIndexOrder = (focusedEl, obscurer) => {
588
+ const focusedStyle = window.getComputedStyle(focusedEl);
589
+ const obscurerStyle = window.getComputedStyle(obscurer);
590
+ const focusedZ = parseInt(focusedStyle.zIndex, 10) || 0;
591
+ const obscurerZ = parseInt(obscurerStyle.zIndex, 10) || 0;
592
+ if (obscurerZ > focusedZ)
593
+ return true;
594
+ const focusedPos = focusedStyle.position;
595
+ const obscurerPos = obscurerStyle.position;
596
+ if (obscurerPos === 'fixed' &&
597
+ (focusedPos === 'static' || focusedPos === 'relative')) {
598
+ return true;
599
+ }
600
+ return false;
601
+ };
602
+ /**
603
+ * Check if focused element is obscured by fixed/sticky elements (WCAG 2.4.12)
604
+ */
605
+ const checkFocusObscured = (focusedEl) => {
606
+ const focusedRect = focusedEl.getBoundingClientRect();
607
+ const focusedArea = focusedRect.width * focusedRect.height;
608
+ if (focusedArea === 0)
609
+ return;
610
+ const obscurers = getObscurerCandidates();
611
+ const overlaps = [];
612
+ let totalOverlapArea = 0;
613
+ obscurers.forEach((obscurer) => {
614
+ // Don't check against self or ancestors/descendants
615
+ if (obscurer === focusedEl ||
616
+ obscurer.contains(focusedEl) ||
617
+ focusedEl.contains(obscurer)) {
618
+ return;
619
+ }
620
+ const obscurerRect = obscurer.getBoundingClientRect();
621
+ const intersection = getIntersection(focusedRect, obscurerRect);
622
+ if (!intersection)
623
+ return;
624
+ const overlapArea = intersection.width * intersection.height;
625
+ // Check minimum thresholds
626
+ if (overlapArea < obscuredConfig.minOverlapPx)
627
+ return;
628
+ // Verify that the obscurer is actually in front using elementFromPoint
629
+ if (!isActuallyObscuring(focusedEl, obscurer, intersection)) {
630
+ return;
631
+ }
632
+ totalOverlapArea += overlapArea;
633
+ overlaps.push({
634
+ obscuredBy: {
635
+ tag: obscurer.tagName,
636
+ role: obscurer.getAttribute('role'),
637
+ name: obscurer.getAttribute('aria-label') ||
638
+ obscurer.textContent?.slice(0, 30) ||
639
+ '',
640
+ selector: getSelector(obscurer),
641
+ },
642
+ overlapRect: intersection,
643
+ overlapArea,
644
+ });
645
+ });
646
+ // Clamp ratio to max 1.0 to handle overlapping obscurers covering same region
647
+ const obscuredRatio = Math.min(totalOverlapArea / focusedArea, 1.0);
648
+ // Only report if obscured ratio exceeds threshold
649
+ if (obscuredRatio >= obscuredConfig.minOverlapRatio && overlaps.length > 0) {
650
+ // Add visual annotation
651
+ addAnnotationBox(focusedEl, `⚠ 2.4.12 Obscured (${(obscuredRatio * 100).toFixed(0)}%)`, 'focus-obscured');
652
+ // Report to test
653
+ window.reportFocusObscured({
654
+ element: {
655
+ tag: focusedEl.tagName,
656
+ role: focusedEl.getAttribute('role'),
657
+ name: focusedEl.getAttribute('aria-label') ||
658
+ focusedEl.textContent?.slice(0, 30) ||
659
+ '',
660
+ selector: getSelector(focusedEl),
661
+ },
662
+ elementRect: {
663
+ left: focusedRect.left,
664
+ top: focusedRect.top,
665
+ width: focusedRect.width,
666
+ height: focusedRect.height,
667
+ },
668
+ overlaps,
669
+ obscuredRatio,
670
+ });
671
+ }
672
+ };
673
+ /**
674
+ * Initialize focus tracker - called after page load
675
+ */
676
+ window.initFocusTracker = () => {
677
+ // Create overlay container
678
+ createOverlay();
679
+ const elements = [...document.querySelectorAll(focusableSelector)]
680
+ .filter(isVisible)
681
+ .filter((el) => !shouldSkip(el));
682
+ elements.forEach((el) => {
683
+ baseStyles.set(el, captureStyle(el));
684
+ elementSelectors.set(el, getSelector(el));
685
+ });
686
+ return elements.length;
687
+ };
688
+ /**
689
+ * Handle focus events
690
+ */
691
+ document.addEventListener('focusin', (e) => {
692
+ const el = e.target;
693
+ if (el.hasAttribute('data-focus-visited'))
694
+ return;
695
+ if (el.hasAttribute('data-focus-navigation-violation'))
696
+ return;
697
+ const pre = baseStyles.get(el);
698
+ const focused = captureStyle(el);
699
+ const diff = {};
700
+ if (pre) {
701
+ for (const p of Object.keys(pre)) {
702
+ const focusedValue = focused[p];
703
+ if (pre[p] !== focusedValue && focusedValue !== undefined) {
704
+ diff[p] = focusedValue;
705
+ }
706
+ }
707
+ }
708
+ const id = focusId++;
709
+ el.setAttribute('data-focus-visited', String(id));
710
+ // Check if changes are actually visible (not just outline-offset without outline)
711
+ const hasFocusStyle = isVisibleFocusChange(diff, focused);
712
+ if (hasFocusStyle) {
713
+ // Preserve focus appearance for screenshot
714
+ const cssText = Object.entries(diff)
715
+ .map(([p, v]) => `${toKebab(p)}: ${v}`)
716
+ .join('; ');
717
+ styleSheet.insertRule(`[data-focus-visited="${id}"] { ${cssText} }`, styleSheet.cssRules.length);
718
+ // Add green annotation box for elements with focus style
719
+ addAnnotationBox(el, '✓ Focus Style', 'has-focus-style');
720
+ }
721
+ else {
722
+ // Mark element as missing focus style (data attribute for tracking)
723
+ el.setAttribute('data-focus-missing', '');
724
+ // Add red annotation box to overlay (no content DOM modification)
725
+ addAnnotationBox(el, '⚠ No Focus Style', '');
726
+ }
727
+ // Report to test
728
+ window.reportFocus({
729
+ id,
730
+ tag: el.tagName,
731
+ role: el.getAttribute('role'),
732
+ name: el.getAttribute('aria-label') || el.textContent?.slice(0, 30),
733
+ hasFocusStyle,
734
+ diff,
735
+ selector: elementSelectors.get(el) || getSelector(el),
736
+ });
737
+ // Check for WCAG 2.4.12 - focus obscured by fixed/sticky elements
738
+ checkFocusObscured(el);
739
+ });
740
+ }