a11y-pilot 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.
@@ -0,0 +1,281 @@
1
+ import chalk from 'chalk';
2
+ import gradientString from 'gradient-string';
3
+ import logSymbols from 'log-symbols';
4
+ import boxen from 'boxen';
5
+
6
+ // ─── Color Palette ───────────────────────────────────────────────────────────
7
+ const colors = {
8
+ error: chalk.red,
9
+ warning: chalk.yellow,
10
+ success: chalk.green,
11
+ info: chalk.cyan,
12
+ dim: chalk.dim,
13
+ bold: chalk.bold,
14
+ file: chalk.cyan.bold,
15
+ line: chalk.dim,
16
+ rule: chalk.magenta,
17
+ fix: chalk.green,
18
+ count: chalk.white.bold,
19
+ };
20
+
21
+ // Custom gradient for the banner
22
+ const bannerGradient = gradientString('#00d4ff', '#7b2ff7', '#ff2d95');
23
+
24
+ // ─── Banner ──────────────────────────────────────────────────────────────────
25
+
26
+ const BANNER = `
27
+ __ _ __ __ __ _ __ __
28
+ / _\` /_ /_ \\/ / ___ ___(_) /___ / /_
29
+ / /_| || || / _ \\/ _ \\/ / | / / _ \\/ __/
30
+ \\__,_|\\_,_/ / .__/\\___/_/ |_/_/\\___/\\__/
31
+ /_/
32
+ `;
33
+
34
+ /**
35
+ * Print the startup banner
36
+ */
37
+ export function printBanner() {
38
+ console.log(bannerGradient(BANNER));
39
+ console.log(
40
+ chalk.dim(' v1.0.0 — AI-powered accessibility scanner')
41
+ );
42
+ console.log(
43
+ chalk.dim(' Powered by GitHub Copilot CLI ✦\n')
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Print scanning progress
49
+ * @param {number} fileCount - Number of files to scan
50
+ */
51
+ export function printScanStart(fileCount) {
52
+ console.log(
53
+ ` ${chalk.cyan('⟳')} Scanning ${colors.count(fileCount)} file${fileCount !== 1 ? 's' : ''}...\n`
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Format and print issues for a single file
59
+ * @param {string} filePath - Relative file path
60
+ * @param {object[]} issues - Issues found in this file
61
+ */
62
+ export function printFileIssues(filePath, issues) {
63
+ if (issues.length === 0) return;
64
+
65
+ console.log(` ${colors.file(filePath)}`);
66
+
67
+ for (const issue of issues) {
68
+ const icon = issue.severity === 'error'
69
+ ? chalk.red('✖')
70
+ : chalk.yellow('⚠');
71
+
72
+ const lineNum = colors.line(`L${String(issue.line).padEnd(4)}`);
73
+ const ruleId = colors.rule(issue.ruleId.padEnd(16));
74
+ const message = issue.severity === 'error'
75
+ ? colors.error(issue.message)
76
+ : colors.warning(issue.message);
77
+
78
+ console.log(` ${icon} ${lineNum} ${ruleId} ${message}`);
79
+ }
80
+
81
+ console.log('');
82
+ }
83
+
84
+ /**
85
+ * Print the summary bar at the end
86
+ * @param {object} summary - {errors, warnings, files, totalFiles, fixable}
87
+ */
88
+ export function printSummary(summary) {
89
+ const { errors, warnings, files, totalFiles } = summary;
90
+ const total = errors + warnings;
91
+
92
+ console.log(chalk.dim(' ' + '─'.repeat(65)));
93
+
94
+ if (total === 0) {
95
+ console.log('');
96
+ console.log(
97
+ boxen(
98
+ chalk.green.bold('✨ No accessibility issues found! ✨') +
99
+ '\n\n' +
100
+ chalk.dim(`Scanned ${totalFiles} file${totalFiles !== 1 ? 's' : ''} — all clear!`),
101
+ {
102
+ padding: 1,
103
+ margin: { left: 2 },
104
+ borderColor: 'green',
105
+ borderStyle: 'round',
106
+ }
107
+ )
108
+ );
109
+ return;
110
+ }
111
+
112
+ const parts = [];
113
+ if (errors > 0) parts.push(colors.error(`${errors} error${errors !== 1 ? 's' : ''}`));
114
+ if (warnings > 0) parts.push(colors.warning(`${warnings} warning${warnings !== 1 ? 's' : ''}`));
115
+
116
+ console.log('');
117
+ console.log(
118
+ ` ${logSymbols.error} Found ${colors.count(total)} issue${total !== 1 ? 's' : ''} ` +
119
+ `(${parts.join(', ')}) ` +
120
+ `in ${colors.count(files)} file${files !== 1 ? 's' : ''} ` +
121
+ colors.dim(`(${totalFiles} scanned)`)
122
+ );
123
+ console.log('');
124
+ }
125
+
126
+ /**
127
+ * Print the fix prompt for a single issue (used with --fix flag)
128
+ * @param {string} filePath
129
+ * @param {object} issue
130
+ */
131
+ export function printFixPrompt(filePath, issue) {
132
+ const icon = issue.severity === 'error' ? chalk.red('✖') : chalk.yellow('⚠');
133
+
134
+ console.log(` ${colors.file(filePath)}:${issue.line} ${colors.rule(issue.ruleId)}`);
135
+ console.log(` ${icon} ${issue.message}`);
136
+ console.log('');
137
+ const prompt = `In file "${filePath}" at line ${issue.line}, fix this accessibility issue: ${issue.message}. ${issue.copilotPrompt} Only modify the minimum code necessary.`;
138
+ console.log(
139
+ boxen(
140
+ chalk.cyan('copilot ') + chalk.dim('"') +
141
+ chalk.white(prompt) +
142
+ chalk.dim('"'),
143
+ {
144
+ padding: { left: 1, right: 1, top: 0, bottom: 0 },
145
+ margin: { left: 2 },
146
+ borderColor: 'cyan',
147
+ borderStyle: 'round',
148
+ title: '💡 Copilot CLI Fix',
149
+ titleAlignment: 'left',
150
+ }
151
+ )
152
+ );
153
+ console.log('');
154
+ }
155
+
156
+ /**
157
+ * Print auto-fix progress
158
+ * @param {string} filePath
159
+ * @param {object} issue
160
+ * @param {'start'|'success'|'error'} status
161
+ * @param {string} [errorMsg]
162
+ */
163
+ export function printAutoFixStatus(filePath, issue, status, errorMsg) {
164
+ const prefix = ` ${colors.file(filePath)}:${issue.line}`;
165
+
166
+ switch (status) {
167
+ case 'start':
168
+ console.log(` ${chalk.cyan('⟳')} Fixing ${colors.rule(issue.ruleId)} in ${colors.file(filePath)}:${issue.line}...`);
169
+ break;
170
+ case 'success':
171
+ console.log(` ${chalk.green('✔')} Fixed ${colors.rule(issue.ruleId)} in ${colors.file(filePath)}:${issue.line}`);
172
+ break;
173
+ case 'error':
174
+ console.log(` ${chalk.red('✘')} Failed to fix ${colors.rule(issue.ruleId)} in ${colors.file(filePath)}:${issue.line}`);
175
+ if (errorMsg) {
176
+ console.log(` ${colors.dim(errorMsg)}`);
177
+ }
178
+ break;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Print auto-fix summary
184
+ * @param {number} fixed
185
+ * @param {number} failed
186
+ * @param {number} total
187
+ */
188
+ export function printAutoFixSummary(fixed, failed, total) {
189
+ console.log('');
190
+ console.log(chalk.dim(' ' + '─'.repeat(65)));
191
+ console.log('');
192
+
193
+ if (failed === 0) {
194
+ console.log(
195
+ boxen(
196
+ chalk.green.bold(`✨ All ${fixed} issue${fixed !== 1 ? 's' : ''} fixed with Copilot CLI! ✨`) +
197
+ '\n\n' +
198
+ chalk.dim('Review the changes and commit when satisfied.'),
199
+ {
200
+ padding: 1,
201
+ margin: { left: 2 },
202
+ borderColor: 'green',
203
+ borderStyle: 'round',
204
+ }
205
+ )
206
+ );
207
+ } else {
208
+ console.log(
209
+ ` ${chalk.green('✔')} ${colors.count(fixed)} fixed ` +
210
+ `${chalk.red('✘')} ${colors.count(failed)} failed ` +
211
+ colors.dim(`(${total} total)`)
212
+ );
213
+ }
214
+ console.log('');
215
+ }
216
+
217
+ /**
218
+ * Print the rules list
219
+ * @param {object[]} rules
220
+ */
221
+ export function printRulesList(rules) {
222
+ printBanner();
223
+ console.log(chalk.bold(' Available Rules:\n'));
224
+
225
+ for (const rule of rules) {
226
+ const severityBadge = rule.severity === 'error'
227
+ ? chalk.bgRed.white.bold(` ${rule.severity.toUpperCase()} `)
228
+ : chalk.bgYellow.black.bold(` ${rule.severity.toUpperCase()} `);
229
+
230
+ console.log(` ${colors.rule(rule.id.padEnd(18))} ${severityBadge}`);
231
+ console.log(` ${chalk.white(rule.description)}`);
232
+ console.log(` ${colors.dim(`WCAG ${rule.wcag} — ${rule.impact}`)}`);
233
+ console.log(` ${colors.dim(rule.url)}`);
234
+ console.log('');
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Print a JSON report
240
+ * @param {object} report
241
+ */
242
+ export function printJSON(report) {
243
+ console.log(JSON.stringify(report, null, 2));
244
+ }
245
+
246
+ /**
247
+ * Print error message
248
+ * @param {string} message
249
+ */
250
+ export function printError(message) {
251
+ console.error(`\n ${logSymbols.error} ${chalk.red(message)}\n`);
252
+ }
253
+
254
+ /**
255
+ * Print info message
256
+ * @param {string} message
257
+ */
258
+ export function printInfo(message) {
259
+ console.log(` ${logSymbols.info} ${chalk.cyan(message)}`);
260
+ }
261
+
262
+ /**
263
+ * Print the copilot bridge header
264
+ */
265
+ export function printCopilotBridgeHeader() {
266
+ console.log('');
267
+ console.log(
268
+ boxen(
269
+ bannerGradient.multiline(' 🤖 Copilot CLI Auto-Fix Mode ') +
270
+ '\n' +
271
+ chalk.dim(' Invoking GitHub Copilot CLI to fix accessibility issues...'),
272
+ {
273
+ padding: { left: 1, right: 1, top: 0, bottom: 0 },
274
+ margin: { left: 2 },
275
+ borderColor: 'cyan',
276
+ borderStyle: 'double',
277
+ }
278
+ )
279
+ );
280
+ console.log('');
281
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Rule: anchor-content
3
+ * Anchor links must have discernible text.
4
+ * WCAG 2.4.4 — Link Purpose in Context (Level A)
5
+ * WCAG 4.1.2 — Name, Role, Value (Level A)
6
+ */
7
+ export default {
8
+ id: 'anchor-content',
9
+ description: '<a> elements must have text content or aria-label',
10
+ severity: 'error',
11
+ wcag: '2.4.4, 4.1.2',
12
+ impact: 'Screen readers announce "link" without a name, making navigation impossible',
13
+ url: 'https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html',
14
+
15
+ check(element) {
16
+ if (element.name !== 'a') return null;
17
+ if (element.hasAttributes['...spread']) return null;
18
+
19
+ // Must have href to be a link (otherwise it might be an anchor target)
20
+ const hasHref = 'href' in element.attributes;
21
+ if (!hasHref) return null;
22
+
23
+ const hasAriaLabel = 'aria-label' in element.attributes || 'ariaLabel' in element.attributes;
24
+ const hasAriaLabelledBy = 'aria-labelledby' in element.attributes || 'ariaLabelledby' in element.attributes;
25
+ const hasTitle = 'title' in element.attributes;
26
+ const hasText = element.hasTextChildren;
27
+ const hasAriaHidden = element.attributes['aria-hidden'] === 'true' || element.attributes['ariaHidden'] === 'true';
28
+
29
+ // Skip if aria-hidden (intentionally hidden from screen readers)
30
+ if (hasAriaHidden) return null;
31
+
32
+ if (!hasAriaLabel && !hasAriaLabelledBy && !hasTitle && !hasText) {
33
+ return {
34
+ ruleId: this.id,
35
+ severity: this.severity,
36
+ message: '<a> element has no accessible name (no text content, aria-label, or title)',
37
+ line: element.line,
38
+ sourceLine: element.sourceLine,
39
+ fix: 'Add text content inside the link, or add an aria-label attribute describing the link destination',
40
+ copilotPrompt: `Look at line ${element.line}. There is an <a> (anchor/link) element without any accessible text. Screen readers will announce it as just "link" with no context. Add an aria-label attribute that describes where the link goes or what it does. If it wraps an image, make sure the image has appropriate alt text. If it's an icon link, add aria-label like "Open in new tab" or "Go to homepage".`,
41
+ };
42
+ }
43
+
44
+ return null;
45
+ },
46
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Rule: button-content
3
+ * Buttons must have discernible text for screen readers.
4
+ * WCAG 4.1.2 — Name, Role, Value (Level A)
5
+ */
6
+ export default {
7
+ id: 'button-content',
8
+ description: '<button> elements must have text content or aria-label',
9
+ severity: 'error',
10
+ wcag: '4.1.2',
11
+ impact: 'Screen readers announce the button without a name, making it impossible to understand its purpose',
12
+ url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
13
+
14
+ check(element) {
15
+ if (element.name !== 'button') return null;
16
+ if (element.hasAttributes['...spread']) return null;
17
+
18
+ const hasAriaLabel = 'aria-label' in element.attributes || 'ariaLabel' in element.attributes;
19
+ const hasAriaLabelledBy = 'aria-labelledby' in element.attributes || 'ariaLabelledby' in element.attributes;
20
+ const hasTitle = 'title' in element.attributes;
21
+ const hasText = element.hasTextChildren;
22
+
23
+ if (!hasAriaLabel && !hasAriaLabelledBy && !hasTitle && !hasText) {
24
+ return {
25
+ ruleId: this.id,
26
+ severity: this.severity,
27
+ message: '<button> has no accessible name (no text content, aria-label, or title)',
28
+ line: element.line,
29
+ sourceLine: element.sourceLine,
30
+ fix: 'Add text content inside the button, or add an aria-label attribute',
31
+ copilotPrompt: `Look at line ${element.line}. There is a <button> element with no accessible name — it has no text content, no aria-label, and no title attribute. Add an appropriate aria-label based on the button's purpose (look at its onClick handler, icon, or surrounding context for clues). If the button contains an icon, add aria-label describing the action.`,
32
+ };
33
+ }
34
+
35
+ return null;
36
+ },
37
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Rule: form-label
3
+ * Form inputs must have associated labels.
4
+ * WCAG 1.3.1 — Info and Relationships (Level A)
5
+ * WCAG 4.1.2 — Name, Role, Value (Level A)
6
+ */
7
+ export default {
8
+ id: 'form-label',
9
+ description: 'Form inputs must have an associated <label>, aria-label, or aria-labelledby',
10
+ severity: 'error',
11
+ wcag: '1.3.1, 4.1.2',
12
+ impact: 'Screen reader users cannot determine the purpose of the input field',
13
+ url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
14
+
15
+ check(element) {
16
+ const inputTypes = ['input', 'select', 'textarea'];
17
+ if (!inputTypes.includes(element.name)) return null;
18
+ if (element.hasAttributes['...spread']) return null;
19
+
20
+ // Hidden inputs don't need labels
21
+ const type = element.attributes['type'] || '';
22
+ if (type === 'hidden' || type === 'submit' || type === 'button' || type === 'reset') return null;
23
+
24
+ const hasAriaLabel = 'aria-label' in element.attributes || 'ariaLabel' in element.attributes;
25
+ const hasAriaLabelledBy = 'aria-labelledby' in element.attributes || 'ariaLabelledby' in element.attributes;
26
+ const hasId = 'id' in element.attributes; // might be referenced by a <label for="...">
27
+ const hasTitle = 'title' in element.attributes;
28
+ const hasPlaceholder = 'placeholder' in element.attributes;
29
+
30
+ // We can't verify label-for association statically across elements easily,
31
+ // so we check for inline labelling mechanisms
32
+ if (!hasAriaLabel && !hasAriaLabelledBy && !hasTitle) {
33
+ // Placeholder alone is not sufficient
34
+ return {
35
+ ruleId: this.id,
36
+ severity: this.severity,
37
+ message: `<${element.rawName}> has no accessible label (missing aria-label, aria-labelledby, or title)${hasPlaceholder ? '. Note: placeholder is NOT a substitute for a label' : ''}`,
38
+ line: element.line,
39
+ sourceLine: element.sourceLine,
40
+ fix: 'Add aria-label="Description" to the input, or wrap it with a <label> element',
41
+ copilotPrompt: `Look at line ${element.line}. There is a <${element.rawName}> element without an accessible label. Add an appropriate aria-label attribute based on the context (look at nearby text, placeholder, or variable names for clues about the field's purpose). If there is a placeholder, the aria-label should match or expand on it. Alternatively, add a visible <label> element associated via htmlFor/id.`,
42
+ };
43
+ }
44
+
45
+ return null;
46
+ },
47
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Rule: heading-order
3
+ * Heading levels should not skip (e.g., h1 → h3 without h2).
4
+ * WCAG 1.3.1 — Info and Relationships (Level A)
5
+ *
6
+ * This rule is special — it operates on ALL headings in a file, not individual elements.
7
+ */
8
+ export default {
9
+ id: 'heading-order',
10
+ description: 'Heading levels should increase sequentially without skipping',
11
+ severity: 'warning',
12
+ wcag: '1.3.1',
13
+ impact: 'Screen reader users rely on heading hierarchy to navigate and understand page structure',
14
+ url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
15
+
16
+ /**
17
+ * This rule doesn't check individual elements — it uses checkFile instead.
18
+ */
19
+ check() {
20
+ return null;
21
+ },
22
+
23
+ /**
24
+ * Check heading order across an entire file
25
+ * @param {object[]} headings - Array of {level, line, sourceLine}
26
+ * @returns {object[]} Array of issues
27
+ */
28
+ checkHeadings(headings) {
29
+ if (headings.length === 0) return [];
30
+
31
+ const issues = [];
32
+ let prevLevel = 0;
33
+
34
+ for (const heading of headings) {
35
+ if (prevLevel > 0 && heading.level > prevLevel + 1) {
36
+ issues.push({
37
+ ruleId: this.id,
38
+ severity: this.severity,
39
+ message: `Heading level skipped: <h${prevLevel}> → <h${heading.level}> (missing <h${prevLevel + 1}>)`,
40
+ line: heading.line,
41
+ sourceLine: heading.sourceLine,
42
+ fix: `Change this to <h${prevLevel + 1}> or add the missing heading levels`,
43
+ copilotPrompt: `Look at line ${heading.line}. The heading level jumps from h${prevLevel} to h${heading.level}, skipping h${prevLevel + 1}. This breaks the document outline for screen reader users. Adjust the heading level to h${prevLevel + 1} to maintain a proper heading hierarchy. Check the surrounding headings in the file to ensure the entire heading structure is sequential.`,
44
+ });
45
+ }
46
+ prevLevel = heading.level;
47
+ }
48
+
49
+ return issues;
50
+ },
51
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Rule: img-alt
3
+ * Images must have an alt attribute for screen readers.
4
+ * WCAG 1.1.1 — Non-text Content (Level A)
5
+ */
6
+ export default {
7
+ id: 'img-alt',
8
+ description: '<img> elements must have an alt attribute',
9
+ severity: 'error',
10
+ wcag: '1.1.1',
11
+ impact: 'Screen readers cannot describe the image to visually impaired users',
12
+ url: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
13
+
14
+ /**
15
+ * @param {object} element - Parsed element info
16
+ * @returns {object|null} Issue object or null
17
+ */
18
+ check(element) {
19
+ if (element.name !== 'img') return null;
20
+
21
+ // Skip if spread attributes (might contain alt)
22
+ if (element.hasAttributes['...spread']) return null;
23
+
24
+ const hasAlt = 'alt' in element.attributes;
25
+
26
+ if (!hasAlt) {
27
+ return {
28
+ ruleId: this.id,
29
+ severity: this.severity,
30
+ message: '<img> is missing the `alt` attribute',
31
+ line: element.line,
32
+ sourceLine: element.sourceLine,
33
+ fix: 'Add alt="descriptive text" or alt="" if the image is decorative',
34
+ copilotPrompt: `Look at line ${element.line}. There is an <img> tag without an alt attribute. Add a descriptive alt attribute based on the surrounding context. If the image appears to be decorative (like an icon next to text), use alt="". Make sure the alt text is meaningful and concise.`,
35
+ };
36
+ }
37
+
38
+ return null;
39
+ },
40
+ };
@@ -0,0 +1,43 @@
1
+ import imgAlt from './img-alt.js';
2
+ import buttonContent from './button-content.js';
3
+ import noDivButton from './no-div-button.js';
4
+ import formLabel from './form-label.js';
5
+ import headingOrder from './heading-order.js';
6
+ import anchorContent from './anchor-content.js';
7
+ import noAutofocus from './no-autofocus.js';
8
+ import semanticNav from './semantic-nav.js';
9
+
10
+ /**
11
+ * All available a11y rules.
12
+ * Each rule exports: id, description, severity, check(element), and optional file-level checks.
13
+ */
14
+ export const allRules = [
15
+ imgAlt,
16
+ buttonContent,
17
+ noDivButton,
18
+ formLabel,
19
+ headingOrder,
20
+ anchorContent,
21
+ noAutofocus,
22
+ semanticNav,
23
+ ];
24
+
25
+ /**
26
+ * Get rules filtered by IDs
27
+ * @param {string[]|null} ruleIds - Rule IDs to include. null = all rules.
28
+ * @returns {object[]}
29
+ */
30
+ export function getRules(ruleIds = null) {
31
+ if (!ruleIds) return allRules;
32
+ const ids = new Set(ruleIds);
33
+ return allRules.filter(r => ids.has(r.id));
34
+ }
35
+
36
+ /**
37
+ * Get a single rule by ID
38
+ * @param {string} id
39
+ * @returns {object|undefined}
40
+ */
41
+ export function getRule(id) {
42
+ return allRules.find(r => r.id === id);
43
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Rule: no-autofocus
3
+ * Avoid using autoFocus — it disrupts screen readers and keyboard users.
4
+ * WCAG 3.2.1 — On Focus (Level A)
5
+ */
6
+ export default {
7
+ id: 'no-autofocus',
8
+ description: 'Avoid using autoFocus attribute — it disrupts screen readers and keyboard navigation',
9
+ severity: 'warning',
10
+ wcag: '3.2.1',
11
+ impact: 'autoFocus moves focus unexpectedly, disorienting screen reader users and disrupting keyboard navigation flow',
12
+ url: 'https://www.w3.org/WAI/WCAG21/Understanding/on-focus.html',
13
+
14
+ check(element) {
15
+ const hasAutoFocus = 'autoFocus' in element.attributes ||
16
+ 'autofocus' in element.attributes ||
17
+ 'autoFocus' in element.hasAttributes ||
18
+ 'autofocus' in element.hasAttributes;
19
+
20
+ if (!hasAutoFocus) return null;
21
+
22
+ return {
23
+ ruleId: this.id,
24
+ severity: this.severity,
25
+ message: `<${element.rawName}> uses autoFocus which disrupts screen reader and keyboard navigation`,
26
+ line: element.line,
27
+ sourceLine: element.sourceLine,
28
+ fix: 'Remove the autoFocus attribute. If focus management is needed, use a ref with useEffect for controlled focus.',
29
+ copilotPrompt: `Look at line ${element.line}. The <${element.rawName}> element uses autoFocus which is an accessibility anti-pattern — it disrupts screen reader announcements and confuses keyboard-only users who expect focus to start at the top of the page. Remove the autoFocus attribute. If you need to manage focus (e.g., in a modal or after navigation), replace it with a React ref and useEffect to focus the element after mount, with a comment explaining why focus management is needed.`,
30
+ };
31
+ },
32
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Rule: no-div-button
3
+ * Interactive elements should use semantic HTML, not divs/spans with click handlers.
4
+ * WCAG 4.1.2 — Name, Role, Value (Level A)
5
+ * WCAG 2.1.1 — Keyboard (Level A)
6
+ */
7
+ export default {
8
+ id: 'no-div-button',
9
+ description: 'Non-interactive elements (<div>, <span>) with click handlers should use <button> or <a>',
10
+ severity: 'error',
11
+ wcag: '4.1.2, 2.1.1',
12
+ impact: 'Keyboard users cannot interact with div/span elements. Screen readers do not announce them as interactive.',
13
+ url: 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html',
14
+
15
+ check(element) {
16
+ const nonInteractive = ['div', 'span', 'section', 'article', 'li', 'td'];
17
+ if (!nonInteractive.includes(element.name)) return null;
18
+ if (element.hasAttributes['...spread']) return null;
19
+
20
+ // Check for click handler (JSX: onClick, HTML: onclick)
21
+ const hasClick = 'onClick' in element.attributes ||
22
+ 'onclick' in element.attributes ||
23
+ 'onKeyDown' in element.attributes ||
24
+ 'onkeydown' in element.attributes ||
25
+ 'onKeyUp' in element.attributes ||
26
+ 'onkeyup' in element.attributes ||
27
+ 'onKeyPress' in element.attributes ||
28
+ 'onkeypress' in element.attributes;
29
+
30
+ if (!hasClick) return null;
31
+
32
+ const hasRole = 'role' in element.attributes;
33
+ const hasTabIndex = 'tabIndex' in element.attributes || 'tabindex' in element.attributes;
34
+
35
+ // Issue if has click but missing role AND tabIndex
36
+ if (!hasRole || !hasTabIndex) {
37
+ const missing = [];
38
+ if (!hasRole) missing.push('role');
39
+ if (!hasTabIndex) missing.push('tabIndex');
40
+
41
+ return {
42
+ ruleId: this.id,
43
+ severity: this.severity,
44
+ message: `<${element.rawName}> has a click handler but is missing ${missing.join(' and ')}. Use a <button> instead.`,
45
+ line: element.line,
46
+ sourceLine: element.sourceLine,
47
+ fix: `Replace this <${element.rawName}> with a <button> element, or add role="button" and tabIndex={0}`,
48
+ copilotPrompt: `Look at line ${element.line}. There is a <${element.rawName}> element with a click handler (onClick) but it is not keyboard-accessible. This is a serious accessibility violation. Replace the <${element.rawName}> with a <button> element. Move the onClick to the button. Remove any role or tabIndex attributes — native buttons handle this automatically. Make sure to preserve the styling by adding className or style if needed. If the element is meant to navigate, use <a> instead.`,
49
+ };
50
+ }
51
+
52
+ return null;
53
+ },
54
+ };