ally-a11y 1.0.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 (84) hide show
  1. package/ACCESSIBILITY.md +205 -0
  2. package/LICENSE +21 -0
  3. package/README.md +940 -0
  4. package/dist/cli.d.ts +7 -0
  5. package/dist/cli.js +528 -0
  6. package/dist/commands/audit-palette.d.ts +18 -0
  7. package/dist/commands/audit-palette.js +613 -0
  8. package/dist/commands/auto-pr.d.ts +19 -0
  9. package/dist/commands/auto-pr.js +434 -0
  10. package/dist/commands/badge.d.ts +11 -0
  11. package/dist/commands/badge.js +143 -0
  12. package/dist/commands/completion.d.ts +4 -0
  13. package/dist/commands/completion.js +185 -0
  14. package/dist/commands/crawl.d.ts +12 -0
  15. package/dist/commands/crawl.js +249 -0
  16. package/dist/commands/doctor.d.ts +5 -0
  17. package/dist/commands/doctor.js +233 -0
  18. package/dist/commands/explain.d.ts +12 -0
  19. package/dist/commands/explain.js +233 -0
  20. package/dist/commands/fix.d.ts +13 -0
  21. package/dist/commands/fix.js +668 -0
  22. package/dist/commands/health.d.ts +11 -0
  23. package/dist/commands/health.js +367 -0
  24. package/dist/commands/history.d.ts +10 -0
  25. package/dist/commands/history.js +191 -0
  26. package/dist/commands/init.d.ts +9 -0
  27. package/dist/commands/init.js +164 -0
  28. package/dist/commands/learn.d.ts +8 -0
  29. package/dist/commands/learn.js +592 -0
  30. package/dist/commands/pr-check.d.ts +12 -0
  31. package/dist/commands/pr-check.js +270 -0
  32. package/dist/commands/report.d.ts +11 -0
  33. package/dist/commands/report.js +375 -0
  34. package/dist/commands/scan-storybook.d.ts +18 -0
  35. package/dist/commands/scan-storybook.js +402 -0
  36. package/dist/commands/scan.d.ts +25 -0
  37. package/dist/commands/scan.js +673 -0
  38. package/dist/commands/stats.d.ts +5 -0
  39. package/dist/commands/stats.js +137 -0
  40. package/dist/commands/tree.d.ts +12 -0
  41. package/dist/commands/tree.js +635 -0
  42. package/dist/commands/triage.d.ts +13 -0
  43. package/dist/commands/triage.js +327 -0
  44. package/dist/commands/watch.d.ts +17 -0
  45. package/dist/commands/watch.js +302 -0
  46. package/dist/types/index.d.ts +60 -0
  47. package/dist/types/index.js +4 -0
  48. package/dist/utils/baseline.d.ts +62 -0
  49. package/dist/utils/baseline.js +169 -0
  50. package/dist/utils/browser.d.ts +78 -0
  51. package/dist/utils/browser.js +239 -0
  52. package/dist/utils/cache.d.ts +76 -0
  53. package/dist/utils/cache.js +178 -0
  54. package/dist/utils/config.d.ts +102 -0
  55. package/dist/utils/config.js +237 -0
  56. package/dist/utils/converters.d.ts +77 -0
  57. package/dist/utils/converters.js +200 -0
  58. package/dist/utils/copilot.d.ts +36 -0
  59. package/dist/utils/copilot.js +139 -0
  60. package/dist/utils/detect.d.ts +22 -0
  61. package/dist/utils/detect.js +197 -0
  62. package/dist/utils/enhanced-errors.d.ts +46 -0
  63. package/dist/utils/enhanced-errors.js +295 -0
  64. package/dist/utils/errors.d.ts +31 -0
  65. package/dist/utils/errors.js +149 -0
  66. package/dist/utils/fix-patterns.d.ts +56 -0
  67. package/dist/utils/fix-patterns.js +529 -0
  68. package/dist/utils/history-tracking.d.ts +94 -0
  69. package/dist/utils/history-tracking.js +230 -0
  70. package/dist/utils/history.d.ts +42 -0
  71. package/dist/utils/history.js +255 -0
  72. package/dist/utils/impact-scores.d.ts +44 -0
  73. package/dist/utils/impact-scores.js +257 -0
  74. package/dist/utils/retry.d.ts +24 -0
  75. package/dist/utils/retry.js +76 -0
  76. package/dist/utils/scanner.d.ts +74 -0
  77. package/dist/utils/scanner.js +606 -0
  78. package/dist/utils/scanner.test.d.ts +4 -0
  79. package/dist/utils/scanner.test.js +162 -0
  80. package/dist/utils/ui.d.ts +44 -0
  81. package/dist/utils/ui.js +276 -0
  82. package/mcp-server/dist/index.d.ts +8 -0
  83. package/mcp-server/dist/index.js +1923 -0
  84. package/package.json +88 -0
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Auto-fix patterns for common accessibility violations
3
+ *
4
+ * Each pattern takes the violation node HTML and returns a fixed version,
5
+ * or null if it can't be auto-fixed.
6
+ */
7
+ /**
8
+ * Helper to extract attribute value from HTML string
9
+ */
10
+ export function getAttr(html, attr) {
11
+ const match = html.match(new RegExp(`${attr}=["']([^"']*)["']`, 'i'));
12
+ return match ? match[1] : null;
13
+ }
14
+ /**
15
+ * Helper to check if element has an attribute
16
+ */
17
+ export function hasAttr(html, attr) {
18
+ return new RegExp(`\\s${attr}(=|\\s|>|/>)`, 'i').test(html);
19
+ }
20
+ /**
21
+ * Helper to add attribute to opening tag
22
+ */
23
+ export function addAttr(html, tag, attr, value) {
24
+ const tagRegex = new RegExp(`<${tag}(\\s|>)`, 'i');
25
+ return html.replace(tagRegex, `<${tag} ${attr}="${value}"$1`);
26
+ }
27
+ /**
28
+ * Helper to extract tag name from HTML
29
+ */
30
+ export function getTagName(html) {
31
+ const match = html.match(/<([a-z][a-z0-9-]*)/i);
32
+ return match ? match[1].toLowerCase() : null;
33
+ }
34
+ /**
35
+ * Helper to infer a description from existing attributes or content
36
+ */
37
+ export function inferDescription(html, fallback) {
38
+ // Try to get description from common attributes
39
+ const title = getAttr(html, 'title');
40
+ if (title)
41
+ return title;
42
+ const name = getAttr(html, 'name');
43
+ if (name)
44
+ return name.replace(/[-_]/g, ' ');
45
+ const id = getAttr(html, 'id');
46
+ if (id)
47
+ return id.replace(/[-_]/g, ' ');
48
+ const placeholder = getAttr(html, 'placeholder');
49
+ if (placeholder)
50
+ return placeholder;
51
+ // Try to extract text content
52
+ const textMatch = html.match(/>([^<]+)</);
53
+ if (textMatch && textMatch[1].trim())
54
+ return textMatch[1].trim();
55
+ return fallback;
56
+ }
57
+ /**
58
+ * Confidence scores for each fix pattern (0.0 to 1.0)
59
+ *
60
+ * High confidence (0.9+): Simple attribute additions (alt, aria-label, lang)
61
+ * Medium confidence (0.7-0.9): Structural changes, role assignments
62
+ * Lower confidence (0.5-0.7): Complex fixes requiring context
63
+ */
64
+ export const FIX_CONFIDENCE = {
65
+ // === IMAGE AND MEDIA ===
66
+ 'image-alt': 0.95, // Simple, reliable - just adds alt attribute
67
+ 'image-redundant-alt': 0.85, // Usually correct but may need human review
68
+ // === INTERACTIVE ELEMENTS ===
69
+ 'button-name': 0.85, // Usually correct but may need human review
70
+ 'link-name': 0.80, // Infers from href, may need adjustment
71
+ 'input-button-name': 0.90, // Simple value addition
72
+ 'aria-command-name': 0.75, // Role-based inference, needs context
73
+ // === FORM ELEMENTS ===
74
+ 'label': 0.85, // Infers from name attribute
75
+ 'select-name': 0.85, // Infers from name attribute
76
+ 'autocomplete-valid': 0.80, // Map-based, may not match all cases
77
+ 'form-field-multiple-labels': 0.50, // Just a comment, needs manual work
78
+ // === DOCUMENT STRUCTURE ===
79
+ 'html-has-lang': 0.99, // Trivial fix, very reliable
80
+ 'document-title': 0.90, // Simple addition but needs real title
81
+ 'meta-viewport': 0.95, // Regex replacement is reliable
82
+ // === HEADING STRUCTURE ===
83
+ 'heading-order': 0.65, // Suggests h2, but context-dependent
84
+ 'empty-heading': 0.70, // Adds placeholder, needs real content
85
+ // === LANDMARKS ===
86
+ 'landmark-one-main': 0.75, // Wrapping is correct but may affect layout
87
+ 'region': 0.80, // Simple role addition
88
+ 'bypass': 0.60, // Adds skip link template, needs integration
89
+ // === TABLES ===
90
+ 'td-headers-attr': 0.70, // Adds placeholder, needs real header IDs
91
+ 'th-has-data-cells': 0.85, // Scope addition is usually correct
92
+ // === ARIA ===
93
+ 'aria-required-children': 0.65, // Complex, depends on content structure
94
+ 'aria-required-parent': 0.70, // Wrapping may affect layout
95
+ 'aria-hidden-focus': 0.90, // Tabindex fix is straightforward
96
+ 'aria-valid-attr-value': 0.85, // Regex replacement is reliable
97
+ // === KEYBOARD NAVIGATION ===
98
+ 'tabindex': 0.95, // Simple value change
99
+ 'focus-visible': 0.55, // Just a CSS comment, needs manual work
100
+ 'focus-order-semantics': 0.95, // Simple tabindex fix
101
+ // === LISTS ===
102
+ 'list': 0.70, // Wrapping may affect layout
103
+ 'listitem': 0.70, // Wrapping may affect layout
104
+ // === IFRAMES ===
105
+ 'frame-title': 0.90, // Infers from src, usually good
106
+ 'frame-focusable-content': 0.85, // Simple tabindex addition
107
+ // === COLOR AND CONTRAST ===
108
+ 'color-contrast': 0.50, // Just a comment, needs manual work
109
+ 'link-in-text-block': 0.80, // Adds underline style
110
+ // === IDS ===
111
+ 'duplicate-id': 0.75, // Generates unique ID, but may break references
112
+ 'duplicate-id-active': 0.75, // Same as above
113
+ 'duplicate-id-aria': 0.75, // Same as above
114
+ // === SVG ===
115
+ 'svg-img-alt': 0.85, // Adds role and aria-label
116
+ // === SCROLLABLE REGIONS ===
117
+ 'scrollable-region-focusable': 0.90, // Simple tabindex addition
118
+ // === VIDEO/AUDIO ===
119
+ 'video-caption': 0.60, // Adds template, needs real captions file
120
+ 'audio-caption': 0.50, // Just a comment, needs transcript
121
+ };
122
+ /**
123
+ * Get confidence score for a fix pattern
124
+ * @returns Confidence score between 0 and 1, or null if pattern not found
125
+ */
126
+ export function getFixConfidence(patternId) {
127
+ return FIX_CONFIDENCE[patternId] ?? null;
128
+ }
129
+ /**
130
+ * Get confidence level category
131
+ */
132
+ export function getConfidenceLevel(confidence) {
133
+ if (confidence >= 0.9)
134
+ return 'high';
135
+ if (confidence >= 0.7)
136
+ return 'medium';
137
+ return 'low';
138
+ }
139
+ /**
140
+ * Auto-fix patterns for common accessibility violations
141
+ */
142
+ export const FIX_PATTERNS = {
143
+ // === IMAGE AND MEDIA ===
144
+ 'image-alt': (html) => {
145
+ if (hasAttr(html, 'alt'))
146
+ return null;
147
+ const src = getAttr(html, 'src');
148
+ const inferredAlt = src
149
+ ? src.split('/').pop()?.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ') || '[describe image]'
150
+ : '[describe image]';
151
+ return addAttr(html, 'img', 'alt', inferredAlt);
152
+ },
153
+ 'image-redundant-alt': (html) => {
154
+ // Remove redundant words like "image of", "picture of" from alt text
155
+ const alt = getAttr(html, 'alt');
156
+ if (!alt)
157
+ return null;
158
+ const cleaned = alt.replace(/^(image|picture|photo|graphic|icon)\s+(of|showing)\s+/i, '');
159
+ if (cleaned === alt)
160
+ return null;
161
+ return html.replace(/alt=["'][^"']*["']/, `alt="${cleaned}"`);
162
+ },
163
+ // === INTERACTIVE ELEMENTS ===
164
+ 'button-name': (html) => {
165
+ if (hasAttr(html, 'aria-label') || hasAttr(html, 'aria-labelledby'))
166
+ return null;
167
+ const desc = inferDescription(html, '[action description]');
168
+ return addAttr(html, 'button', 'aria-label', desc);
169
+ },
170
+ 'link-name': (html) => {
171
+ if (hasAttr(html, 'aria-label') || hasAttr(html, 'aria-labelledby'))
172
+ return null;
173
+ const href = getAttr(html, 'href');
174
+ const desc = href && href !== '#'
175
+ ? href.split('/').pop()?.replace(/[-_]/g, ' ') || '[link description]'
176
+ : '[link description]';
177
+ return addAttr(html, 'a', 'aria-label', desc);
178
+ },
179
+ 'input-button-name': (html) => {
180
+ // Add value to submit/button inputs
181
+ if (hasAttr(html, 'value') || hasAttr(html, 'aria-label'))
182
+ return null;
183
+ const type = getAttr(html, 'type');
184
+ const defaultValue = type === 'submit' ? 'Submit' : type === 'reset' ? 'Reset' : 'Button';
185
+ return addAttr(html, 'input', 'value', defaultValue);
186
+ },
187
+ 'aria-command-name': (html) => {
188
+ // Add accessible name to ARIA buttons/links/menuitems
189
+ if (hasAttr(html, 'aria-label') || hasAttr(html, 'aria-labelledby'))
190
+ return null;
191
+ const role = getAttr(html, 'role');
192
+ const desc = role ? `[${role} description]` : '[command description]';
193
+ const tag = getTagName(html);
194
+ return tag ? addAttr(html, tag, 'aria-label', desc) : null;
195
+ },
196
+ // === FORM ELEMENTS ===
197
+ 'label': (html) => {
198
+ // Add aria-label to inputs without labels
199
+ if (hasAttr(html, 'aria-label') || hasAttr(html, 'aria-labelledby') || hasAttr(html, 'id'))
200
+ return null;
201
+ const type = getAttr(html, 'type') || 'text';
202
+ const name = getAttr(html, 'name');
203
+ const desc = name ? name.replace(/[-_]/g, ' ') : `${type} input`;
204
+ return addAttr(html, 'input', 'aria-label', desc);
205
+ },
206
+ 'select-name': (html) => {
207
+ if (hasAttr(html, 'aria-label') || hasAttr(html, 'aria-labelledby'))
208
+ return null;
209
+ const name = getAttr(html, 'name');
210
+ const desc = name ? name.replace(/[-_]/g, ' ') : 'Select option';
211
+ return addAttr(html, 'select', 'aria-label', desc);
212
+ },
213
+ 'autocomplete-valid': (html) => {
214
+ // Add valid autocomplete attribute based on common input names/types
215
+ if (hasAttr(html, 'autocomplete'))
216
+ return null;
217
+ const name = getAttr(html, 'name')?.toLowerCase() || '';
218
+ const type = getAttr(html, 'type')?.toLowerCase() || '';
219
+ const autocompleteMap = {
220
+ 'email': 'email',
221
+ 'phone': 'tel',
222
+ 'tel': 'tel',
223
+ 'name': 'name',
224
+ 'fname': 'given-name',
225
+ 'firstname': 'given-name',
226
+ 'lname': 'family-name',
227
+ 'lastname': 'family-name',
228
+ 'address': 'street-address',
229
+ 'city': 'address-level2',
230
+ 'state': 'address-level1',
231
+ 'zip': 'postal-code',
232
+ 'postal': 'postal-code',
233
+ 'country': 'country-name',
234
+ 'username': 'username',
235
+ 'password': 'current-password',
236
+ };
237
+ const autocomplete = autocompleteMap[name] || autocompleteMap[type] || 'off';
238
+ return addAttr(html, 'input', 'autocomplete', autocomplete);
239
+ },
240
+ 'form-field-multiple-labels': (html) => {
241
+ // Suggest removing duplicate labels by adding aria-labelledby
242
+ // This is complex - just add a comment suggestion
243
+ return `<!-- FIX: Remove duplicate labels or use aria-labelledby -->\n${html}`;
244
+ },
245
+ // === DOCUMENT STRUCTURE ===
246
+ 'html-has-lang': (html) => {
247
+ if (hasAttr(html, 'lang'))
248
+ return null;
249
+ return addAttr(html, 'html', 'lang', 'en');
250
+ },
251
+ 'document-title': (html) => {
252
+ // Add title element to head
253
+ if (html.includes('<title>'))
254
+ return null;
255
+ if (html.includes('</head>')) {
256
+ return html.replace('</head>', ' <title>[Page Title]</title>\n</head>');
257
+ }
258
+ return `<title>[Page Title]</title>`;
259
+ },
260
+ 'meta-viewport': (html) => {
261
+ // Fix viewport to allow user scaling
262
+ if (!html.includes('user-scalable=no') && !html.includes('maximum-scale=1')) {
263
+ return null;
264
+ }
265
+ return html
266
+ .replace(/,?\s*user-scalable\s*=\s*no/gi, '')
267
+ .replace(/,?\s*maximum-scale\s*=\s*1(\.0)?/gi, '')
268
+ .replace(/content="([^"]*),\s*,/g, 'content="$1,')
269
+ .replace(/,\s*"/g, '"');
270
+ },
271
+ // === HEADING STRUCTURE ===
272
+ 'heading-order': (html) => {
273
+ // Fix heading hierarchy - suggest the correct level
274
+ const match = html.match(/<h([1-6])/i);
275
+ if (!match)
276
+ return null;
277
+ const currentLevel = parseInt(match[1], 10);
278
+ // Suggest h2 as a safe default (most common fix)
279
+ const suggestedLevel = currentLevel > 2 ? currentLevel - 1 : 2;
280
+ return html
281
+ .replace(/<h[1-6]/gi, `<h${suggestedLevel}`)
282
+ .replace(/<\/h[1-6]>/gi, `</h${suggestedLevel}>`);
283
+ },
284
+ 'empty-heading': (html) => {
285
+ // Add placeholder text to empty headings
286
+ const match = html.match(/<(h[1-6])([^>]*)>(\s*)<\/h[1-6]>/i);
287
+ if (!match)
288
+ return null;
289
+ return html.replace(/<(h[1-6])([^>]*)>(\s*)<\/h[1-6]>/i, '<$1$2>[Heading text]</$1>');
290
+ },
291
+ // === LANDMARKS ===
292
+ 'landmark-one-main': (html) => {
293
+ // Wrap content in main landmark
294
+ return `<main>\n${html}\n</main>`;
295
+ },
296
+ 'region': (html) => {
297
+ // Add role="region" with aria-label
298
+ const tag = getTagName(html);
299
+ if (!tag)
300
+ return null;
301
+ if (hasAttr(html, 'role'))
302
+ return null;
303
+ return addAttr(html, tag, 'role', 'region');
304
+ },
305
+ 'bypass': (html) => {
306
+ // Suggest skip link
307
+ return `<!-- Add skip link at start of body -->\n<a href="#main-content" class="skip-link">Skip to main content</a>\n${html}`;
308
+ },
309
+ // === TABLES ===
310
+ 'td-headers-attr': (html) => {
311
+ // Add headers attribute to td
312
+ if (hasAttr(html, 'headers'))
313
+ return null;
314
+ return addAttr(html, 'td', 'headers', '[header-id]');
315
+ },
316
+ 'th-has-data-cells': (html) => {
317
+ // Add scope to th
318
+ if (hasAttr(html, 'scope'))
319
+ return null;
320
+ return addAttr(html, 'th', 'scope', 'col');
321
+ },
322
+ // === ARIA ===
323
+ 'aria-required-children': (html, violation) => {
324
+ // Add required ARIA children based on role
325
+ const role = getAttr(html, 'role');
326
+ const childRoles = {
327
+ 'menu': 'menuitem',
328
+ 'menubar': 'menuitem',
329
+ 'list': 'listitem',
330
+ 'listbox': 'option',
331
+ 'grid': 'row',
332
+ 'table': 'row',
333
+ 'tree': 'treeitem',
334
+ 'tablist': 'tab',
335
+ 'radiogroup': 'radio',
336
+ };
337
+ if (!role || !childRoles[role])
338
+ return null;
339
+ const childRole = childRoles[role];
340
+ // Check if it's a self-closing or empty element
341
+ if (html.includes('/>') || html.match(/<[^>]+>\s*<\/[^>]+>/)) {
342
+ const tag = getTagName(html);
343
+ return html.replace(/(\/>|>\s*<\/[^>]+>)/, `>\n <div role="${childRole}">[content]</div>\n</${tag}>`);
344
+ }
345
+ return `<!-- Add role="${childRole}" to child elements -->\n${html}`;
346
+ },
347
+ 'aria-required-parent': (html) => {
348
+ // Wrap in required parent role
349
+ const role = getAttr(html, 'role');
350
+ const parentRoles = {
351
+ 'menuitem': 'menu',
352
+ 'option': 'listbox',
353
+ 'row': 'table',
354
+ 'tab': 'tablist',
355
+ 'treeitem': 'tree',
356
+ 'listitem': 'list',
357
+ };
358
+ if (!role || !parentRoles[role])
359
+ return null;
360
+ const parentRole = parentRoles[role];
361
+ return `<div role="${parentRole}">\n ${html}\n</div>`;
362
+ },
363
+ 'aria-hidden-focus': (html) => {
364
+ // Remove tabindex from aria-hidden elements or add tabindex="-1"
365
+ if (hasAttr(html, 'aria-hidden')) {
366
+ // Add tabindex="-1" to remove from tab order
367
+ if (hasAttr(html, 'tabindex')) {
368
+ return html.replace(/tabindex=["'][^"']*["']/, 'tabindex="-1"');
369
+ }
370
+ const tag = getTagName(html);
371
+ return tag ? addAttr(html, tag, 'tabindex', '-1') : null;
372
+ }
373
+ return null;
374
+ },
375
+ 'aria-valid-attr-value': (html) => {
376
+ // Fix common invalid ARIA values
377
+ // aria-expanded, aria-checked, etc. should be "true" or "false"
378
+ return html
379
+ .replace(/aria-expanded=["'](?!true|false)[^"']*["']/gi, 'aria-expanded="false"')
380
+ .replace(/aria-checked=["'](?!true|false|mixed)[^"']*["']/gi, 'aria-checked="false"')
381
+ .replace(/aria-selected=["'](?!true|false)[^"']*["']/gi, 'aria-selected="false"')
382
+ .replace(/aria-pressed=["'](?!true|false|mixed)[^"']*["']/gi, 'aria-pressed="false"')
383
+ .replace(/aria-hidden=["'](?!true|false)[^"']*["']/gi, 'aria-hidden="true"');
384
+ },
385
+ // === KEYBOARD NAVIGATION ===
386
+ 'tabindex': (html) => {
387
+ // Fix positive tabindex values
388
+ const tabindex = getAttr(html, 'tabindex');
389
+ if (!tabindex)
390
+ return null;
391
+ const value = parseInt(tabindex, 10);
392
+ if (isNaN(value) || value <= 0)
393
+ return null;
394
+ // Replace positive tabindex with 0
395
+ return html.replace(/tabindex=["']\d+["']/, 'tabindex="0"');
396
+ },
397
+ 'focus-visible': (html) => {
398
+ // Add CSS comment for focus styles (can't fix inline, but can suggest)
399
+ return `<!-- Add CSS: ${getTagName(html)}:focus-visible { outline: 2px solid #005fcc; } -->\n${html}`;
400
+ },
401
+ 'focus-order-semantics': (html) => {
402
+ // Similar to tabindex fix
403
+ const tabindex = getAttr(html, 'tabindex');
404
+ if (tabindex && parseInt(tabindex, 10) > 0) {
405
+ return html.replace(/tabindex=["']\d+["']/, 'tabindex="0"');
406
+ }
407
+ return null;
408
+ },
409
+ // === LISTS ===
410
+ 'list': (html) => {
411
+ // Fix list structure - ensure proper nesting
412
+ const tag = getTagName(html);
413
+ if (tag === 'li') {
414
+ return `<ul>\n ${html}\n</ul>`;
415
+ }
416
+ return null;
417
+ },
418
+ 'listitem': (html) => {
419
+ // Ensure li is in ul/ol
420
+ if (html.includes('<li')) {
421
+ return `<ul>\n${html}\n</ul>`;
422
+ }
423
+ return null;
424
+ },
425
+ // === IFRAMES ===
426
+ 'frame-title': (html) => {
427
+ if (hasAttr(html, 'title'))
428
+ return null;
429
+ const src = getAttr(html, 'src');
430
+ const title = src
431
+ ? src.split('/').pop()?.replace(/[-_]/g, ' ').replace(/\.[^.]+$/, '') || 'Embedded content'
432
+ : 'Embedded content';
433
+ return addAttr(html, 'iframe', 'title', title);
434
+ },
435
+ 'frame-focusable-content': (html) => {
436
+ // Add tabindex to iframe if needed
437
+ if (hasAttr(html, 'tabindex'))
438
+ return null;
439
+ return addAttr(html, 'iframe', 'tabindex', '0');
440
+ },
441
+ // === COLOR AND CONTRAST ===
442
+ 'color-contrast': (html) => {
443
+ // Can't auto-fix colors, but add helpful comment
444
+ return `<!-- CONTRAST FIX: Increase color contrast ratio to at least 4.5:1 for normal text, 3:1 for large text -->\n<!-- Suggested: Use darker text (#333) on light backgrounds or lighter text (#fff) on dark backgrounds -->\n${html}`;
445
+ },
446
+ 'link-in-text-block': (html) => {
447
+ // Add underline to links for non-color differentiation
448
+ const tag = getTagName(html);
449
+ if (tag !== 'a')
450
+ return null;
451
+ if (html.includes('style=')) {
452
+ return html.replace(/style=["']/, 'style="text-decoration: underline; ');
453
+ }
454
+ return addAttr(html, 'a', 'style', 'text-decoration: underline');
455
+ },
456
+ // === IDS ===
457
+ 'duplicate-id': (html) => {
458
+ // Suggest unique ID
459
+ const id = getAttr(html, 'id');
460
+ if (!id)
461
+ return null;
462
+ const uniqueId = `${id}-${Math.random().toString(36).substr(2, 4)}`;
463
+ return html.replace(new RegExp(`id=["']${id}["']`), `id="${uniqueId}"`);
464
+ },
465
+ 'duplicate-id-active': (html) => {
466
+ const id = getAttr(html, 'id');
467
+ if (!id)
468
+ return null;
469
+ const uniqueId = `${id}-${Math.random().toString(36).substr(2, 4)}`;
470
+ return html.replace(new RegExp(`id=["']${id}["']`), `id="${uniqueId}"`);
471
+ },
472
+ 'duplicate-id-aria': (html) => {
473
+ const id = getAttr(html, 'id');
474
+ if (!id)
475
+ return null;
476
+ const uniqueId = `${id}-${Math.random().toString(36).substr(2, 4)}`;
477
+ return html.replace(new RegExp(`id=["']${id}["']`), `id="${uniqueId}"`);
478
+ },
479
+ // === SVG ===
480
+ 'svg-img-alt': (html) => {
481
+ // Add accessible name to SVG
482
+ if (hasAttr(html, 'aria-label') || hasAttr(html, 'aria-labelledby'))
483
+ return null;
484
+ if (hasAttr(html, 'role') && getAttr(html, 'role') === 'img') {
485
+ return addAttr(html, 'svg', 'aria-label', '[SVG description]');
486
+ }
487
+ // Add role="img" and aria-label
488
+ let fixed = addAttr(html, 'svg', 'role', 'img');
489
+ fixed = addAttr(fixed, 'svg', 'aria-label', '[SVG description]');
490
+ return fixed;
491
+ },
492
+ // === SCROLLABLE REGIONS ===
493
+ 'scrollable-region-focusable': (html) => {
494
+ // Add tabindex to scrollable regions
495
+ if (hasAttr(html, 'tabindex'))
496
+ return null;
497
+ const tag = getTagName(html);
498
+ return tag ? addAttr(html, tag, 'tabindex', '0') : null;
499
+ },
500
+ // === VIDEO/AUDIO ===
501
+ 'video-caption': (html) => {
502
+ // Suggest adding captions track
503
+ if (html.includes('<track'))
504
+ return null;
505
+ if (html.includes('</video>')) {
506
+ return html.replace('</video>', ' <track kind="captions" src="[captions.vtt]" srclang="en" label="English">\n</video>');
507
+ }
508
+ return `<!-- Add captions track: <track kind="captions" src="captions.vtt" srclang="en"> -->\n${html}`;
509
+ },
510
+ 'audio-caption': (html) => {
511
+ // Suggest transcript for audio
512
+ return `<!-- Provide a transcript for this audio content -->\n${html}`;
513
+ },
514
+ };
515
+ /**
516
+ * Generate a suggested fix for a violation
517
+ */
518
+ export function generateSuggestedFix(violation, html) {
519
+ const fixer = FIX_PATTERNS[violation.id];
520
+ if (!fixer)
521
+ return null;
522
+ try {
523
+ return fixer(html, violation);
524
+ }
525
+ catch {
526
+ // If pattern matching fails, return null to let Copilot handle it
527
+ return null;
528
+ }
529
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Historical Tracking System
3
+ *
4
+ * Tracks scan history over time to show progress and trends.
5
+ * Quick win: 3 days effort, HIGH ROI - shows teams their improvement.
6
+ */
7
+ import type { AllyReport } from '../types/index.js';
8
+ export interface HistoryEntry {
9
+ timestamp: string;
10
+ score: number;
11
+ totalViolations: number;
12
+ bySeverity: {
13
+ critical: number;
14
+ serious: number;
15
+ moderate: number;
16
+ minor: number;
17
+ };
18
+ filesScanned: number;
19
+ command: string;
20
+ branch?: string;
21
+ commit?: string;
22
+ }
23
+ export interface HistoryData {
24
+ entries: HistoryEntry[];
25
+ firstScan: string;
26
+ lastScan: string;
27
+ totalScans: number;
28
+ }
29
+ /**
30
+ * Load history from file
31
+ */
32
+ export declare function loadHistory(): Promise<HistoryData>;
33
+ /**
34
+ * Save history entry
35
+ */
36
+ export declare function saveHistoryEntry(report: AllyReport, command?: string): Promise<void>;
37
+ /**
38
+ * Get trend direction (improving, declining, stable)
39
+ */
40
+ export declare function getTrend(history: HistoryData, lookback?: number): 'improving' | 'declining' | 'stable';
41
+ /**
42
+ * Get score change from previous scan
43
+ */
44
+ export declare function getScoreChange(history: HistoryData): number | null;
45
+ /**
46
+ * Get best score ever achieved
47
+ */
48
+ export declare function getBestScore(history: HistoryData): number | null;
49
+ /**
50
+ * Get worst score ever recorded
51
+ */
52
+ export declare function getWorstScore(history: HistoryData): number | null;
53
+ /**
54
+ * Get average score over time
55
+ */
56
+ export declare function getAverageScore(history: HistoryData): number | null;
57
+ /**
58
+ * Get total violations fixed since first scan
59
+ */
60
+ export declare function getTotalFixed(history: HistoryData): number | null;
61
+ /**
62
+ * Get streak (consecutive scans with improving or stable scores)
63
+ */
64
+ export declare function getStreak(history: HistoryData): number;
65
+ /**
66
+ * Format time ago (e.g., "2 hours ago", "3 days ago")
67
+ */
68
+ export declare function timeAgo(timestamp: string): string;
69
+ /**
70
+ * Get recent entries (last N scans)
71
+ */
72
+ export declare function getRecentEntries(history: HistoryData, count?: number): HistoryEntry[];
73
+ /**
74
+ * Get entries by branch
75
+ */
76
+ export declare function getEntriesByBranch(history: HistoryData, branch: string): HistoryEntry[];
77
+ /**
78
+ * Get summary stats for display
79
+ */
80
+ export interface HistoryStats {
81
+ currentScore: number;
82
+ previousScore: number | null;
83
+ scoreChange: number | null;
84
+ trend: 'improving' | 'declining' | 'stable';
85
+ bestScore: number | null;
86
+ worstScore: number | null;
87
+ averageScore: number | null;
88
+ totalScans: number;
89
+ totalFixed: number | null;
90
+ streak: number;
91
+ lastScan: string;
92
+ firstScan: string;
93
+ }
94
+ export declare function getStats(history: HistoryData): HistoryStats | null;