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.
- package/ACCESSIBILITY.md +205 -0
- package/LICENSE +21 -0
- package/README.md +940 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +528 -0
- package/dist/commands/audit-palette.d.ts +18 -0
- package/dist/commands/audit-palette.js +613 -0
- package/dist/commands/auto-pr.d.ts +19 -0
- package/dist/commands/auto-pr.js +434 -0
- package/dist/commands/badge.d.ts +11 -0
- package/dist/commands/badge.js +143 -0
- package/dist/commands/completion.d.ts +4 -0
- package/dist/commands/completion.js +185 -0
- package/dist/commands/crawl.d.ts +12 -0
- package/dist/commands/crawl.js +249 -0
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/doctor.js +233 -0
- package/dist/commands/explain.d.ts +12 -0
- package/dist/commands/explain.js +233 -0
- package/dist/commands/fix.d.ts +13 -0
- package/dist/commands/fix.js +668 -0
- package/dist/commands/health.d.ts +11 -0
- package/dist/commands/health.js +367 -0
- package/dist/commands/history.d.ts +10 -0
- package/dist/commands/history.js +191 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +164 -0
- package/dist/commands/learn.d.ts +8 -0
- package/dist/commands/learn.js +592 -0
- package/dist/commands/pr-check.d.ts +12 -0
- package/dist/commands/pr-check.js +270 -0
- package/dist/commands/report.d.ts +11 -0
- package/dist/commands/report.js +375 -0
- package/dist/commands/scan-storybook.d.ts +18 -0
- package/dist/commands/scan-storybook.js +402 -0
- package/dist/commands/scan.d.ts +25 -0
- package/dist/commands/scan.js +673 -0
- package/dist/commands/stats.d.ts +5 -0
- package/dist/commands/stats.js +137 -0
- package/dist/commands/tree.d.ts +12 -0
- package/dist/commands/tree.js +635 -0
- package/dist/commands/triage.d.ts +13 -0
- package/dist/commands/triage.js +327 -0
- package/dist/commands/watch.d.ts +17 -0
- package/dist/commands/watch.js +302 -0
- package/dist/types/index.d.ts +60 -0
- package/dist/types/index.js +4 -0
- package/dist/utils/baseline.d.ts +62 -0
- package/dist/utils/baseline.js +169 -0
- package/dist/utils/browser.d.ts +78 -0
- package/dist/utils/browser.js +239 -0
- package/dist/utils/cache.d.ts +76 -0
- package/dist/utils/cache.js +178 -0
- package/dist/utils/config.d.ts +102 -0
- package/dist/utils/config.js +237 -0
- package/dist/utils/converters.d.ts +77 -0
- package/dist/utils/converters.js +200 -0
- package/dist/utils/copilot.d.ts +36 -0
- package/dist/utils/copilot.js +139 -0
- package/dist/utils/detect.d.ts +22 -0
- package/dist/utils/detect.js +197 -0
- package/dist/utils/enhanced-errors.d.ts +46 -0
- package/dist/utils/enhanced-errors.js +295 -0
- package/dist/utils/errors.d.ts +31 -0
- package/dist/utils/errors.js +149 -0
- package/dist/utils/fix-patterns.d.ts +56 -0
- package/dist/utils/fix-patterns.js +529 -0
- package/dist/utils/history-tracking.d.ts +94 -0
- package/dist/utils/history-tracking.js +230 -0
- package/dist/utils/history.d.ts +42 -0
- package/dist/utils/history.js +255 -0
- package/dist/utils/impact-scores.d.ts +44 -0
- package/dist/utils/impact-scores.js +257 -0
- package/dist/utils/retry.d.ts +24 -0
- package/dist/utils/retry.js +76 -0
- package/dist/utils/scanner.d.ts +74 -0
- package/dist/utils/scanner.js +606 -0
- package/dist/utils/scanner.test.d.ts +4 -0
- package/dist/utils/scanner.test.js +162 -0
- package/dist/utils/ui.d.ts +44 -0
- package/dist/utils/ui.js +276 -0
- package/mcp-server/dist/index.d.ts +8 -0
- package/mcp-server/dist/index.js +1923 -0
- package/package.json +88 -0
|
@@ -0,0 +1,1923 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Ally MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Exposes project-specific accessibility patterns to GitHub Copilot CLI.
|
|
6
|
+
* This enables Copilot to generate fixes that are consistent with your codebase.
|
|
7
|
+
*/
|
|
8
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { readFile, readdir } from "fs/promises";
|
|
12
|
+
import { join, extname } from "path";
|
|
13
|
+
import { existsSync } from "fs";
|
|
14
|
+
import { APCAcontrast, sRGBtoY } from "apca-w3";
|
|
15
|
+
// Create server instance
|
|
16
|
+
const server = new McpServer({
|
|
17
|
+
name: "ally-patterns",
|
|
18
|
+
version: "1.0.0",
|
|
19
|
+
});
|
|
20
|
+
// Telemetry to track tool usage
|
|
21
|
+
const telemetry = {
|
|
22
|
+
calls: new Map(),
|
|
23
|
+
lastCalled: new Map(),
|
|
24
|
+
log(toolName) {
|
|
25
|
+
const count = this.calls.get(toolName) || 0;
|
|
26
|
+
this.calls.set(toolName, count + 1);
|
|
27
|
+
this.lastCalled.set(toolName, new Date());
|
|
28
|
+
console.error(`[MCP Telemetry] ${new Date().toISOString()} | ${toolName} called (${count + 1}x total)`);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
// Cache for analyzed patterns
|
|
32
|
+
const patternCache = new Map();
|
|
33
|
+
const tokenCache = new Map();
|
|
34
|
+
// Tool: Get component ARIA patterns from codebase
|
|
35
|
+
server.tool("get_component_patterns", "Analyze existing ARIA patterns in React/Vue components to ensure consistent accessibility fixes", {
|
|
36
|
+
directory: z.string().optional().describe("Directory to analyze (defaults to current directory)"),
|
|
37
|
+
component: z.string().optional().describe("Specific component name to analyze"),
|
|
38
|
+
}, async ({ directory, component }) => {
|
|
39
|
+
telemetry.log('get_component_patterns');
|
|
40
|
+
const targetDir = directory || process.cwd();
|
|
41
|
+
try {
|
|
42
|
+
const patterns = await analyzeComponentPatterns(targetDir, component);
|
|
43
|
+
if (patterns.length === 0) {
|
|
44
|
+
return {
|
|
45
|
+
content: [
|
|
46
|
+
{
|
|
47
|
+
type: "text",
|
|
48
|
+
text: "No component patterns found. This might be a new project or use a different framework.",
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const summary = patterns.map((p) => {
|
|
54
|
+
const features = [];
|
|
55
|
+
if (p.patterns.hasAriaLabel)
|
|
56
|
+
features.push("aria-label");
|
|
57
|
+
if (p.patterns.hasAriaDescribedBy)
|
|
58
|
+
features.push("aria-describedby");
|
|
59
|
+
if (p.patterns.hasRole)
|
|
60
|
+
features.push("role attributes");
|
|
61
|
+
if (p.patterns.hasFocusManagement)
|
|
62
|
+
features.push("focus management");
|
|
63
|
+
if (p.patterns.hasKeyboardHandlers)
|
|
64
|
+
features.push("keyboard handlers");
|
|
65
|
+
return `## ${p.component} (${p.file})
|
|
66
|
+
ARIA Features: ${features.length > 0 ? features.join(", ") : "none found"}
|
|
67
|
+
${p.examples.length > 0 ? `\nExamples:\n${p.examples.slice(0, 2).join("\n")}` : ""}`;
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
content: [
|
|
71
|
+
{
|
|
72
|
+
type: "text",
|
|
73
|
+
text: `# Component ARIA Patterns\n\n${summary.join("\n\n")}`,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return {
|
|
80
|
+
content: [
|
|
81
|
+
{
|
|
82
|
+
type: "text",
|
|
83
|
+
text: `Error analyzing patterns: ${error instanceof Error ? error.message : String(error)}`,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
isError: true,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
// Tool: Get design tokens for contrast-safe color suggestions
|
|
91
|
+
server.tool("get_design_tokens", "Extract design tokens (colors, spacing) from CSS/SCSS/Tailwind config for WCAG-compliant fixes", {
|
|
92
|
+
directory: z.string().optional().describe("Directory to search for design tokens"),
|
|
93
|
+
}, async ({ directory }) => {
|
|
94
|
+
telemetry.log('get_design_tokens');
|
|
95
|
+
const targetDir = directory || process.cwd();
|
|
96
|
+
try {
|
|
97
|
+
const tokens = await extractDesignTokens(targetDir);
|
|
98
|
+
if (!tokens.colors.length && !tokens.spacing.length) {
|
|
99
|
+
return {
|
|
100
|
+
content: [
|
|
101
|
+
{
|
|
102
|
+
type: "text",
|
|
103
|
+
text: "No design tokens found. Consider using a CSS variables file or Tailwind config.",
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const wcagCompliantColors = tokens.colors.filter((c) => c.wcagAACompliant);
|
|
109
|
+
let response = "# Design Tokens\n\n";
|
|
110
|
+
if (wcagCompliantColors.length > 0) {
|
|
111
|
+
response += "## WCAG AA Compliant Colors\n";
|
|
112
|
+
response += wcagCompliantColors
|
|
113
|
+
.map((c) => `- ${c.name}: ${c.value} (contrast: ${c.contrastRatio?.toFixed(1) || "unknown"})`)
|
|
114
|
+
.join("\n");
|
|
115
|
+
response += "\n\n";
|
|
116
|
+
}
|
|
117
|
+
if (tokens.colors.length > 0) {
|
|
118
|
+
response += "## All Colors\n";
|
|
119
|
+
response += tokens.colors.map((c) => `- ${c.name}: ${c.value}`).join("\n");
|
|
120
|
+
response += "\n\n";
|
|
121
|
+
}
|
|
122
|
+
if (tokens.spacing.length > 0) {
|
|
123
|
+
response += "## Spacing\n";
|
|
124
|
+
response += tokens.spacing.map((s) => `- ${s}`).join("\n");
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
content: [
|
|
128
|
+
{
|
|
129
|
+
type: "text",
|
|
130
|
+
text: response,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return {
|
|
137
|
+
content: [
|
|
138
|
+
{
|
|
139
|
+
type: "text",
|
|
140
|
+
text: `Error extracting tokens: ${error instanceof Error ? error.message : String(error)}`,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
isError: true,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
// Tool: Get previous fix history for consistency
|
|
148
|
+
server.tool("get_fix_history", "Retrieve previously applied accessibility fixes to maintain consistency", {
|
|
149
|
+
issueType: z.string().optional().describe("Filter by issue type (e.g., 'image-alt', 'button-name')"),
|
|
150
|
+
}, async ({ issueType }) => {
|
|
151
|
+
telemetry.log('get_fix_history');
|
|
152
|
+
try {
|
|
153
|
+
const historyPath = join(process.cwd(), ".ally", "fix-history.json");
|
|
154
|
+
if (!existsSync(historyPath)) {
|
|
155
|
+
return {
|
|
156
|
+
content: [
|
|
157
|
+
{
|
|
158
|
+
type: "text",
|
|
159
|
+
text: "No fix history found. Run 'ally fix' to start building a history of applied fixes.",
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const historyContent = await readFile(historyPath, "utf-8");
|
|
165
|
+
const history = JSON.parse(historyContent);
|
|
166
|
+
let filtered = history;
|
|
167
|
+
if (issueType) {
|
|
168
|
+
filtered = history.filter((h) => h.issueType === issueType);
|
|
169
|
+
}
|
|
170
|
+
if (filtered.length === 0) {
|
|
171
|
+
return {
|
|
172
|
+
content: [
|
|
173
|
+
{
|
|
174
|
+
type: "text",
|
|
175
|
+
text: issueType
|
|
176
|
+
? `No fixes found for issue type: ${issueType}`
|
|
177
|
+
: "No fixes in history yet.",
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const summary = filtered.slice(-10).map((h) => {
|
|
183
|
+
return `- **${h.issueType}** in ${h.file}
|
|
184
|
+
Before: \`${h.before.slice(0, 60)}...\`
|
|
185
|
+
After: \`${h.after.slice(0, 60)}...\``;
|
|
186
|
+
});
|
|
187
|
+
return {
|
|
188
|
+
content: [
|
|
189
|
+
{
|
|
190
|
+
type: "text",
|
|
191
|
+
text: `# Recent Fixes\n\n${summary.join("\n\n")}`,
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
return {
|
|
198
|
+
content: [
|
|
199
|
+
{
|
|
200
|
+
type: "text",
|
|
201
|
+
text: `Error reading fix history: ${error instanceof Error ? error.message : String(error)}`,
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
isError: true,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
// Tool: Get scan results summary
|
|
209
|
+
server.tool("get_scan_summary", "Get a summary of current accessibility issues from the latest scan", {}, async () => {
|
|
210
|
+
telemetry.log('get_scan_summary');
|
|
211
|
+
try {
|
|
212
|
+
const scanPath = join(process.cwd(), ".ally", "scan.json");
|
|
213
|
+
if (!existsSync(scanPath)) {
|
|
214
|
+
return {
|
|
215
|
+
content: [
|
|
216
|
+
{
|
|
217
|
+
type: "text",
|
|
218
|
+
text: "No scan results found. Run 'ally scan' first.",
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const scanContent = await readFile(scanPath, "utf-8");
|
|
224
|
+
const scan = JSON.parse(scanContent);
|
|
225
|
+
const summary = `# Accessibility Scan Summary
|
|
226
|
+
|
|
227
|
+
**Score:** ${scan.summary.score}/100
|
|
228
|
+
**Total Issues:** ${scan.summary.totalViolations}
|
|
229
|
+
|
|
230
|
+
## By Severity
|
|
231
|
+
- Critical: ${scan.summary.bySeverity.critical || 0}
|
|
232
|
+
- Serious: ${scan.summary.bySeverity.serious || 0}
|
|
233
|
+
- Moderate: ${scan.summary.bySeverity.moderate || 0}
|
|
234
|
+
- Minor: ${scan.summary.bySeverity.minor || 0}
|
|
235
|
+
|
|
236
|
+
## Top Issues
|
|
237
|
+
${scan.summary.topIssues.map((i) => `- ${i.description} (${i.count} occurrences)`).join("\n")}`;
|
|
238
|
+
return {
|
|
239
|
+
content: [
|
|
240
|
+
{
|
|
241
|
+
type: "text",
|
|
242
|
+
text: summary,
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
return {
|
|
249
|
+
content: [
|
|
250
|
+
{
|
|
251
|
+
type: "text",
|
|
252
|
+
text: `Error reading scan results: ${error instanceof Error ? error.message : String(error)}`,
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
isError: true,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
const wcagCriteria = {
|
|
260
|
+
"1.1.1": {
|
|
261
|
+
id: "1.1.1",
|
|
262
|
+
title: "Non-text Content",
|
|
263
|
+
level: "A",
|
|
264
|
+
description: "All non-text content that is presented to the user has a text alternative that serves the equivalent purpose, except for controls, input, time-based media, tests, sensory experiences, CAPTCHA, and decoration.",
|
|
265
|
+
techniques: [
|
|
266
|
+
"G94: Providing short text alternative for non-text content",
|
|
267
|
+
"G95: Providing short text alternatives that identify non-text content",
|
|
268
|
+
"H37: Using alt attributes on img elements",
|
|
269
|
+
"H67: Using null alt text and no title attribute for images decoration",
|
|
270
|
+
"ARIA6: Using aria-label to provide labels for objects",
|
|
271
|
+
"ARIA10: Using aria-labelledby to provide a text alternative for non-text content"
|
|
272
|
+
],
|
|
273
|
+
failures: [
|
|
274
|
+
"F3: Using CSS to include images that convey important information",
|
|
275
|
+
"F20: Not updating text alternatives when non-text content changes",
|
|
276
|
+
"F30: Using text alternatives that are not alternatives",
|
|
277
|
+
"F38: Not marking up decorative images to be ignored by assistive technology",
|
|
278
|
+
"F39: Providing a text alternative that is not null for images that should be ignored",
|
|
279
|
+
"F65: Omitting the alt attribute or text alternative on img elements"
|
|
280
|
+
],
|
|
281
|
+
relatedViolations: ["image-alt", "input-image-alt", "area-alt", "object-alt", "svg-img-alt"]
|
|
282
|
+
},
|
|
283
|
+
"1.3.1": {
|
|
284
|
+
id: "1.3.1",
|
|
285
|
+
title: "Info and Relationships",
|
|
286
|
+
level: "A",
|
|
287
|
+
description: "Information, structure, and relationships conveyed through presentation can be programmatically determined or are available in text.",
|
|
288
|
+
techniques: [
|
|
289
|
+
"G115: Using semantic elements to mark up structure",
|
|
290
|
+
"G140: Separating information and structure from presentation",
|
|
291
|
+
"H42: Using h1-h6 to identify headings",
|
|
292
|
+
"H44: Using label elements to associate text labels with form controls",
|
|
293
|
+
"H48: Using ol, ul and dl for lists",
|
|
294
|
+
"H51: Using table markup for tabular information",
|
|
295
|
+
"ARIA11: Using ARIA landmarks to identify regions of a page",
|
|
296
|
+
"ARIA12: Using role=heading to identify headings"
|
|
297
|
+
],
|
|
298
|
+
failures: [
|
|
299
|
+
"F2: Using changes in text presentation to convey information without semantic markup",
|
|
300
|
+
"F33: Using white space characters to create visual lists",
|
|
301
|
+
"F34: Using white space characters to format tables",
|
|
302
|
+
"F43: Failing to identify content using ARIA semantic roles",
|
|
303
|
+
"F46: Failing to identify content using proper heading markup"
|
|
304
|
+
],
|
|
305
|
+
relatedViolations: ["heading-order", "list", "listitem", "table-fake-caption", "td-headers-attr", "th-has-data-cells"]
|
|
306
|
+
},
|
|
307
|
+
"1.3.5": {
|
|
308
|
+
id: "1.3.5",
|
|
309
|
+
title: "Identify Input Purpose",
|
|
310
|
+
level: "AA",
|
|
311
|
+
description: "The purpose of each input field collecting information about the user can be programmatically determined when the input field serves a purpose identified in the Input Purposes for User Interface Components section.",
|
|
312
|
+
techniques: [
|
|
313
|
+
"H98: Using HTML5.2 autocomplete attributes",
|
|
314
|
+
"Identify the purpose of contact information fields using autocomplete"
|
|
315
|
+
],
|
|
316
|
+
failures: [
|
|
317
|
+
"Failure to use autocomplete attribute when collecting user information"
|
|
318
|
+
],
|
|
319
|
+
relatedViolations: ["autocomplete-valid"]
|
|
320
|
+
},
|
|
321
|
+
"1.4.1": {
|
|
322
|
+
id: "1.4.1",
|
|
323
|
+
title: "Use of Color",
|
|
324
|
+
level: "A",
|
|
325
|
+
description: "Color is not used as the only visual means of conveying information, indicating an action, prompting a response, or distinguishing a visual element.",
|
|
326
|
+
techniques: [
|
|
327
|
+
"G14: Using a non-text contrast ratio of at least 3:1",
|
|
328
|
+
"G182: Ensuring that additional visual cues are available when text color differences are used",
|
|
329
|
+
"G183: Using a contrast ratio of 3:1 with surrounding text and providing additional visual cues on focus for links or controls"
|
|
330
|
+
],
|
|
331
|
+
failures: [
|
|
332
|
+
"F13: Failure due to having a text alternative that does not include information conveyed by color differences",
|
|
333
|
+
"F73: Failure due to creating links that are not visually evident without color vision",
|
|
334
|
+
"F81: Failure due to identifying required or error fields using color differences only"
|
|
335
|
+
],
|
|
336
|
+
relatedViolations: ["link-in-text-block"]
|
|
337
|
+
},
|
|
338
|
+
"1.4.3": {
|
|
339
|
+
id: "1.4.3",
|
|
340
|
+
title: "Contrast (Minimum)",
|
|
341
|
+
level: "AA",
|
|
342
|
+
description: "The visual presentation of text and images of text has a contrast ratio of at least 4.5:1, except for large text (3:1), incidental text, and logotypes.",
|
|
343
|
+
techniques: [
|
|
344
|
+
"G18: Ensuring a contrast ratio of at least 4.5:1 between text and background",
|
|
345
|
+
"G145: Ensuring a contrast ratio of at least 3:1 for large text",
|
|
346
|
+
"G174: Providing a control with sufficient contrast ratio to toggle high contrast",
|
|
347
|
+
"G148: Not specifying background color and not specifying text color"
|
|
348
|
+
],
|
|
349
|
+
failures: [
|
|
350
|
+
"F24: Failure due to specifying foreground without specifying background colors",
|
|
351
|
+
"F83: Failure due to using background images that do not provide sufficient contrast"
|
|
352
|
+
],
|
|
353
|
+
relatedViolations: ["color-contrast", "color-contrast-enhanced"]
|
|
354
|
+
},
|
|
355
|
+
"1.4.11": {
|
|
356
|
+
id: "1.4.11",
|
|
357
|
+
title: "Non-text Contrast",
|
|
358
|
+
level: "AA",
|
|
359
|
+
description: "The visual presentation of user interface components and graphical objects have a contrast ratio of at least 3:1 against adjacent colors.",
|
|
360
|
+
techniques: [
|
|
361
|
+
"G195: Using an author-supplied, visible focus indicator",
|
|
362
|
+
"G207: Ensuring that a contrast ratio of 3:1 is provided for icons",
|
|
363
|
+
"G209: Providing sufficient contrast at the boundaries between adjoining colors"
|
|
364
|
+
],
|
|
365
|
+
failures: [
|
|
366
|
+
"F78: Failure due to using purely decorative elements with insufficient contrast"
|
|
367
|
+
],
|
|
368
|
+
relatedViolations: ["focus-visible", "link-in-text-block"]
|
|
369
|
+
},
|
|
370
|
+
"2.1.1": {
|
|
371
|
+
id: "2.1.1",
|
|
372
|
+
title: "Keyboard",
|
|
373
|
+
level: "A",
|
|
374
|
+
description: "All functionality of the content is operable through a keyboard interface without requiring specific timings for individual keystrokes.",
|
|
375
|
+
techniques: [
|
|
376
|
+
"G202: Ensuring keyboard control for all functionality",
|
|
377
|
+
"H91: Using HTML form controls and links",
|
|
378
|
+
"SCR2: Using redundant keyboard and mouse event handlers",
|
|
379
|
+
"SCR20: Using both keyboard and other device-specific functions"
|
|
380
|
+
],
|
|
381
|
+
failures: [
|
|
382
|
+
"F42: Failure due to using scripting events to emulate links without proper keyboard access",
|
|
383
|
+
"F54: Failure due to using only pointing-device-specific event handlers",
|
|
384
|
+
"F55: Failure due to using script to remove focus when focus is received"
|
|
385
|
+
],
|
|
386
|
+
relatedViolations: ["accesskeys", "focus-order-semantics", "scrollable-region-focusable"]
|
|
387
|
+
},
|
|
388
|
+
"2.1.2": {
|
|
389
|
+
id: "2.1.2",
|
|
390
|
+
title: "No Keyboard Trap",
|
|
391
|
+
level: "A",
|
|
392
|
+
description: "If keyboard focus can be moved to a component using a keyboard interface, then focus can be moved away from that component using only a keyboard interface.",
|
|
393
|
+
techniques: [
|
|
394
|
+
"G21: Ensuring users are not trapped in content",
|
|
395
|
+
"FLASH17: Providing keyboard access to a Flash object and avoiding a keyboard trap"
|
|
396
|
+
],
|
|
397
|
+
failures: [
|
|
398
|
+
"F10: Failure due to combining multiple content formats in a way that traps users inside one format type"
|
|
399
|
+
],
|
|
400
|
+
relatedViolations: ["focus-trap"]
|
|
401
|
+
},
|
|
402
|
+
"2.4.1": {
|
|
403
|
+
id: "2.4.1",
|
|
404
|
+
title: "Bypass Blocks",
|
|
405
|
+
level: "A",
|
|
406
|
+
description: "A mechanism is available to bypass blocks of content that are repeated on multiple Web pages.",
|
|
407
|
+
techniques: [
|
|
408
|
+
"G1: Adding a skip link at the top of each page",
|
|
409
|
+
"G123: Adding a link at the beginning of a block of repeated content",
|
|
410
|
+
"G124: Adding links at the top of the page to each area of the content",
|
|
411
|
+
"ARIA11: Using ARIA landmarks to identify regions of a page",
|
|
412
|
+
"H69: Providing heading elements at the beginning of each section of content"
|
|
413
|
+
],
|
|
414
|
+
failures: [
|
|
415
|
+
"No specific failures defined"
|
|
416
|
+
],
|
|
417
|
+
relatedViolations: ["bypass", "region", "landmark-one-main"]
|
|
418
|
+
},
|
|
419
|
+
"2.4.2": {
|
|
420
|
+
id: "2.4.2",
|
|
421
|
+
title: "Page Titled",
|
|
422
|
+
level: "A",
|
|
423
|
+
description: "Web pages have titles that describe topic or purpose.",
|
|
424
|
+
techniques: [
|
|
425
|
+
"G88: Providing descriptive titles for Web pages",
|
|
426
|
+
"H25: Providing a title using the title element"
|
|
427
|
+
],
|
|
428
|
+
failures: [
|
|
429
|
+
"F25: Failure due to the title of a Web page not identifying the contents"
|
|
430
|
+
],
|
|
431
|
+
relatedViolations: ["document-title", "page-has-heading-one"]
|
|
432
|
+
},
|
|
433
|
+
"2.4.3": {
|
|
434
|
+
id: "2.4.3",
|
|
435
|
+
title: "Focus Order",
|
|
436
|
+
level: "A",
|
|
437
|
+
description: "If a Web page can be navigated sequentially and the navigation sequences affect meaning or operation, focusable components receive focus in an order that preserves meaning and operability.",
|
|
438
|
+
techniques: [
|
|
439
|
+
"G59: Placing the interactive elements in an order that follows sequences and relationships within the content",
|
|
440
|
+
"H4: Creating a logical tab order through links, form controls, and objects",
|
|
441
|
+
"C27: Making the DOM order match the visual order"
|
|
442
|
+
],
|
|
443
|
+
failures: [
|
|
444
|
+
"F44: Failure due to using tabindex to create a tab order that does not preserve meaning and operability",
|
|
445
|
+
"F85: Failure due to using dialogs or menus that are not adjacent to their trigger control in the sequence"
|
|
446
|
+
],
|
|
447
|
+
relatedViolations: ["tabindex", "focus-order-semantics"]
|
|
448
|
+
},
|
|
449
|
+
"2.4.4": {
|
|
450
|
+
id: "2.4.4",
|
|
451
|
+
title: "Link Purpose (In Context)",
|
|
452
|
+
level: "A",
|
|
453
|
+
description: "The purpose of each link can be determined from the link text alone or from the link text together with its programmatically determined link context.",
|
|
454
|
+
techniques: [
|
|
455
|
+
"G91: Providing link text that describes the purpose of a link",
|
|
456
|
+
"H30: Providing link text that describes the purpose of a link for anchor elements",
|
|
457
|
+
"H77: Identifying the purpose of a link using link text combined with its enclosing list item",
|
|
458
|
+
"ARIA7: Using aria-labelledby for link purpose",
|
|
459
|
+
"ARIA8: Using aria-label for link purpose"
|
|
460
|
+
],
|
|
461
|
+
failures: [
|
|
462
|
+
"F63: Failure due to providing link context only in content that is not related to the link",
|
|
463
|
+
"F89: Failure due to not providing an accessible name for an image which is the only content in a link"
|
|
464
|
+
],
|
|
465
|
+
relatedViolations: ["link-name", "empty-links"]
|
|
466
|
+
},
|
|
467
|
+
"2.4.6": {
|
|
468
|
+
id: "2.4.6",
|
|
469
|
+
title: "Headings and Labels",
|
|
470
|
+
level: "AA",
|
|
471
|
+
description: "Headings and labels describe topic or purpose.",
|
|
472
|
+
techniques: [
|
|
473
|
+
"G130: Providing descriptive headings",
|
|
474
|
+
"G131: Providing descriptive labels"
|
|
475
|
+
],
|
|
476
|
+
failures: [
|
|
477
|
+
"No specific failures defined"
|
|
478
|
+
],
|
|
479
|
+
relatedViolations: ["empty-heading", "heading-order", "label"]
|
|
480
|
+
},
|
|
481
|
+
"2.4.7": {
|
|
482
|
+
id: "2.4.7",
|
|
483
|
+
title: "Focus Visible",
|
|
484
|
+
level: "AA",
|
|
485
|
+
description: "Any keyboard operable user interface has a mode of operation where the keyboard focus indicator is visible.",
|
|
486
|
+
techniques: [
|
|
487
|
+
"G149: Using user interface components that are highlighted by the user agent when they receive focus",
|
|
488
|
+
"G165: Using the default focus indicator for the platform",
|
|
489
|
+
"G195: Using an author-supplied, visible focus indicator",
|
|
490
|
+
"C15: Using CSS to change the presentation of a user interface component when it receives focus"
|
|
491
|
+
],
|
|
492
|
+
failures: [
|
|
493
|
+
"F55: Failure due to using script to remove focus when focus is received",
|
|
494
|
+
"F78: Failure due to styling element outlines and borders in a way that removes or renders non-visible the visible focus indicator"
|
|
495
|
+
],
|
|
496
|
+
relatedViolations: ["focus-visible"]
|
|
497
|
+
},
|
|
498
|
+
"3.1.1": {
|
|
499
|
+
id: "3.1.1",
|
|
500
|
+
title: "Language of Page",
|
|
501
|
+
level: "A",
|
|
502
|
+
description: "The default human language of each Web page can be programmatically determined.",
|
|
503
|
+
techniques: [
|
|
504
|
+
"H57: Using the language attribute on the HTML element"
|
|
505
|
+
],
|
|
506
|
+
failures: [
|
|
507
|
+
"No specific failures defined"
|
|
508
|
+
],
|
|
509
|
+
relatedViolations: ["html-has-lang", "html-lang-valid", "html-xml-lang-mismatch"]
|
|
510
|
+
},
|
|
511
|
+
"3.1.2": {
|
|
512
|
+
id: "3.1.2",
|
|
513
|
+
title: "Language of Parts",
|
|
514
|
+
level: "AA",
|
|
515
|
+
description: "The human language of each passage or phrase in the content can be programmatically determined except for proper names, technical terms, words of indeterminate language, and words or phrases that have become part of the vernacular.",
|
|
516
|
+
techniques: [
|
|
517
|
+
"H58: Using language attributes to identify changes in the human language"
|
|
518
|
+
],
|
|
519
|
+
failures: [
|
|
520
|
+
"No specific failures defined"
|
|
521
|
+
],
|
|
522
|
+
relatedViolations: ["valid-lang"]
|
|
523
|
+
},
|
|
524
|
+
"3.3.1": {
|
|
525
|
+
id: "3.3.1",
|
|
526
|
+
title: "Error Identification",
|
|
527
|
+
level: "A",
|
|
528
|
+
description: "If an input error is automatically detected, the item that is in error is identified and the error is described to the user in text.",
|
|
529
|
+
techniques: [
|
|
530
|
+
"G83: Providing text descriptions to identify required fields that were not completed",
|
|
531
|
+
"G84: Providing a text description when the user provides information that is not in the list of allowed values",
|
|
532
|
+
"G85: Providing a text description when user input falls outside the required format or values",
|
|
533
|
+
"ARIA21: Using aria-invalid to indicate an error field"
|
|
534
|
+
],
|
|
535
|
+
failures: [
|
|
536
|
+
"No specific failures defined"
|
|
537
|
+
],
|
|
538
|
+
relatedViolations: ["aria-valid-attr-value", "aria-input-field-name"]
|
|
539
|
+
},
|
|
540
|
+
"3.3.2": {
|
|
541
|
+
id: "3.3.2",
|
|
542
|
+
title: "Labels or Instructions",
|
|
543
|
+
level: "A",
|
|
544
|
+
description: "Labels or instructions are provided when content requires user input.",
|
|
545
|
+
techniques: [
|
|
546
|
+
"G131: Providing descriptive labels",
|
|
547
|
+
"H44: Using label elements to associate text labels with form controls",
|
|
548
|
+
"H71: Providing a description for groups of form controls using fieldset and legend elements",
|
|
549
|
+
"ARIA1: Using the aria-describedby property to provide a descriptive label for user interface controls",
|
|
550
|
+
"ARIA9: Using aria-labelledby to concatenate a label from several text nodes"
|
|
551
|
+
],
|
|
552
|
+
failures: [
|
|
553
|
+
"F82: Failure due to visually formatting a set of phone number fields but not including a text label"
|
|
554
|
+
],
|
|
555
|
+
relatedViolations: ["label", "form-field-multiple-labels", "select-name"]
|
|
556
|
+
},
|
|
557
|
+
"4.1.1": {
|
|
558
|
+
id: "4.1.1",
|
|
559
|
+
title: "Parsing",
|
|
560
|
+
level: "A",
|
|
561
|
+
description: "In content implemented using markup languages, elements have complete start and end tags, elements are nested according to their specifications, elements do not contain duplicate attributes, and any IDs are unique.",
|
|
562
|
+
techniques: [
|
|
563
|
+
"G134: Validating Web pages",
|
|
564
|
+
"G192: Fully conforming to specifications",
|
|
565
|
+
"H74: Ensuring that opening and closing tags are used according to specification",
|
|
566
|
+
"H93: Ensuring that id attributes are unique on a Web page",
|
|
567
|
+
"H94: Ensuring that elements do not contain duplicate attributes"
|
|
568
|
+
],
|
|
569
|
+
failures: [
|
|
570
|
+
"F70: Failure due to incorrect use of start and end tags or attribute markup",
|
|
571
|
+
"F77: Failure due to duplicate values of type ID"
|
|
572
|
+
],
|
|
573
|
+
relatedViolations: ["duplicate-id", "duplicate-id-active", "duplicate-id-aria"]
|
|
574
|
+
},
|
|
575
|
+
"4.1.2": {
|
|
576
|
+
id: "4.1.2",
|
|
577
|
+
title: "Name, Role, Value",
|
|
578
|
+
level: "A",
|
|
579
|
+
description: "For all user interface components, the name and role can be programmatically determined; states, properties, and values that can be set by the user can be programmatically set; and notification of changes to these items is available to user agents, including assistive technologies.",
|
|
580
|
+
techniques: [
|
|
581
|
+
"G108: Using markup features to expose the name and role",
|
|
582
|
+
"H64: Using the title attribute of the iframe element",
|
|
583
|
+
"H91: Using HTML form controls and links",
|
|
584
|
+
"ARIA4: Using a WAI-ARIA role to expose the role of a user interface component",
|
|
585
|
+
"ARIA5: Using WAI-ARIA state and property attributes to expose the state of a user interface component",
|
|
586
|
+
"ARIA14: Using aria-label to provide an accessible name",
|
|
587
|
+
"ARIA16: Using aria-labelledby to provide a name for user interface controls"
|
|
588
|
+
],
|
|
589
|
+
failures: [
|
|
590
|
+
"F15: Failure due to implementing custom controls that do not use an accessibility API",
|
|
591
|
+
"F20: Failure due to not updating text alternatives when changes occur",
|
|
592
|
+
"F59: Failure due to using script to make div or span a user interface control without first adding the role",
|
|
593
|
+
"F68: Failure due to the association of label and user interface controls not being programmatically determined",
|
|
594
|
+
"F79: Failure due to focus not being set when the state of a radio button, checkbox or select list is changed"
|
|
595
|
+
],
|
|
596
|
+
relatedViolations: ["aria-allowed-attr", "aria-allowed-role", "aria-required-attr", "aria-required-children", "aria-required-parent", "aria-roles", "button-name", "input-button-name", "role-img-alt"]
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
// Mapping from violation IDs to WCAG criteria
|
|
600
|
+
const violationToWcag = {
|
|
601
|
+
"image-alt": "1.1.1",
|
|
602
|
+
"input-image-alt": "1.1.1",
|
|
603
|
+
"area-alt": "1.1.1",
|
|
604
|
+
"object-alt": "1.1.1",
|
|
605
|
+
"svg-img-alt": "1.1.1",
|
|
606
|
+
"heading-order": "1.3.1",
|
|
607
|
+
"list": "1.3.1",
|
|
608
|
+
"listitem": "1.3.1",
|
|
609
|
+
"table-fake-caption": "1.3.1",
|
|
610
|
+
"td-headers-attr": "1.3.1",
|
|
611
|
+
"th-has-data-cells": "1.3.1",
|
|
612
|
+
"autocomplete-valid": "1.3.5",
|
|
613
|
+
"link-in-text-block": "1.4.1",
|
|
614
|
+
"color-contrast": "1.4.3",
|
|
615
|
+
"color-contrast-enhanced": "1.4.3",
|
|
616
|
+
"focus-visible": "1.4.11",
|
|
617
|
+
"accesskeys": "2.1.1",
|
|
618
|
+
"focus-order-semantics": "2.1.1",
|
|
619
|
+
"scrollable-region-focusable": "2.1.1",
|
|
620
|
+
"focus-trap": "2.1.2",
|
|
621
|
+
"bypass": "2.4.1",
|
|
622
|
+
"region": "2.4.1",
|
|
623
|
+
"landmark-one-main": "2.4.1",
|
|
624
|
+
"document-title": "2.4.2",
|
|
625
|
+
"page-has-heading-one": "2.4.2",
|
|
626
|
+
"tabindex": "2.4.3",
|
|
627
|
+
"link-name": "2.4.4",
|
|
628
|
+
"empty-links": "2.4.4",
|
|
629
|
+
"empty-heading": "2.4.6",
|
|
630
|
+
"label": "2.4.6",
|
|
631
|
+
"duplicate-id": "4.1.1",
|
|
632
|
+
"duplicate-id-active": "4.1.1",
|
|
633
|
+
"duplicate-id-aria": "4.1.1",
|
|
634
|
+
"aria-allowed-attr": "4.1.2",
|
|
635
|
+
"aria-allowed-role": "4.1.2",
|
|
636
|
+
"aria-required-attr": "4.1.2",
|
|
637
|
+
"aria-required-children": "4.1.2",
|
|
638
|
+
"aria-required-parent": "4.1.2",
|
|
639
|
+
"aria-roles": "4.1.2",
|
|
640
|
+
"button-name": "4.1.2",
|
|
641
|
+
"input-button-name": "4.1.2",
|
|
642
|
+
"role-img-alt": "4.1.2",
|
|
643
|
+
"html-has-lang": "3.1.1",
|
|
644
|
+
"html-lang-valid": "3.1.1",
|
|
645
|
+
"html-xml-lang-mismatch": "3.1.1",
|
|
646
|
+
"valid-lang": "3.1.2",
|
|
647
|
+
"aria-valid-attr-value": "3.3.1",
|
|
648
|
+
"aria-input-field-name": "3.3.1",
|
|
649
|
+
"form-field-multiple-labels": "3.3.2",
|
|
650
|
+
"select-name": "3.3.2"
|
|
651
|
+
};
|
|
652
|
+
const ariaPatterns = {
|
|
653
|
+
"modal": {
|
|
654
|
+
name: "Modal Dialog",
|
|
655
|
+
description: "A dialog is a window overlaid on the primary window. Focus is trapped within the dialog until it is dismissed.",
|
|
656
|
+
roles: ["dialog", "alertdialog"],
|
|
657
|
+
states: ["aria-modal='true'"],
|
|
658
|
+
properties: ["aria-labelledby", "aria-describedby"],
|
|
659
|
+
keyboardInteractions: [
|
|
660
|
+
{ key: "Tab", action: "Move focus to next focusable element inside dialog (wraps to first when at end)" },
|
|
661
|
+
{ key: "Shift+Tab", action: "Move focus to previous focusable element inside dialog (wraps to last when at start)" },
|
|
662
|
+
{ key: "Escape", action: "Close the dialog" }
|
|
663
|
+
],
|
|
664
|
+
codeExample: `<div
|
|
665
|
+
role="dialog"
|
|
666
|
+
aria-modal="true"
|
|
667
|
+
aria-labelledby="dialog-title"
|
|
668
|
+
aria-describedby="dialog-description"
|
|
669
|
+
>
|
|
670
|
+
<h2 id="dialog-title">Dialog Title</h2>
|
|
671
|
+
<p id="dialog-description">Dialog description text.</p>
|
|
672
|
+
<button>Action</button>
|
|
673
|
+
<button>Cancel</button>
|
|
674
|
+
</div>
|
|
675
|
+
|
|
676
|
+
// Focus management
|
|
677
|
+
const dialog = document.querySelector('[role="dialog"]');
|
|
678
|
+
const firstFocusable = dialog.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
679
|
+
firstFocusable.focus();
|
|
680
|
+
|
|
681
|
+
// Trap focus within dialog
|
|
682
|
+
dialog.addEventListener('keydown', (e) => {
|
|
683
|
+
if (e.key === 'Tab') {
|
|
684
|
+
const focusable = dialog.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
685
|
+
const first = focusable[0];
|
|
686
|
+
const last = focusable[focusable.length - 1];
|
|
687
|
+
|
|
688
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
689
|
+
e.preventDefault();
|
|
690
|
+
last.focus();
|
|
691
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
692
|
+
e.preventDefault();
|
|
693
|
+
first.focus();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (e.key === 'Escape') closeDialog();
|
|
697
|
+
});`
|
|
698
|
+
},
|
|
699
|
+
"dialog": {
|
|
700
|
+
name: "Modal Dialog",
|
|
701
|
+
description: "A dialog is a window overlaid on the primary window. Focus is trapped within the dialog until it is dismissed.",
|
|
702
|
+
roles: ["dialog", "alertdialog"],
|
|
703
|
+
states: ["aria-modal='true'"],
|
|
704
|
+
properties: ["aria-labelledby", "aria-describedby"],
|
|
705
|
+
keyboardInteractions: [
|
|
706
|
+
{ key: "Tab", action: "Move focus to next focusable element inside dialog (wraps to first when at end)" },
|
|
707
|
+
{ key: "Shift+Tab", action: "Move focus to previous focusable element inside dialog (wraps to last when at start)" },
|
|
708
|
+
{ key: "Escape", action: "Close the dialog" }
|
|
709
|
+
],
|
|
710
|
+
codeExample: `<div
|
|
711
|
+
role="dialog"
|
|
712
|
+
aria-modal="true"
|
|
713
|
+
aria-labelledby="dialog-title"
|
|
714
|
+
aria-describedby="dialog-description"
|
|
715
|
+
>
|
|
716
|
+
<h2 id="dialog-title">Dialog Title</h2>
|
|
717
|
+
<p id="dialog-description">Dialog description text.</p>
|
|
718
|
+
<button>Action</button>
|
|
719
|
+
<button>Cancel</button>
|
|
720
|
+
</div>`
|
|
721
|
+
},
|
|
722
|
+
"tabs": {
|
|
723
|
+
name: "Tabs",
|
|
724
|
+
description: "A set of layered sections of content, where one panel is displayed at a time.",
|
|
725
|
+
roles: ["tablist", "tab", "tabpanel"],
|
|
726
|
+
states: ["aria-selected='true|false'"],
|
|
727
|
+
properties: ["aria-controls", "aria-labelledby"],
|
|
728
|
+
keyboardInteractions: [
|
|
729
|
+
{ key: "Tab", action: "Move focus into the tab list, then to the active tabpanel" },
|
|
730
|
+
{ key: "ArrowLeft/ArrowRight", action: "Move focus between tabs (horizontal tablist)" },
|
|
731
|
+
{ key: "ArrowUp/ArrowDown", action: "Move focus between tabs (vertical tablist)" },
|
|
732
|
+
{ key: "Home", action: "Move focus to first tab" },
|
|
733
|
+
{ key: "End", action: "Move focus to last tab" },
|
|
734
|
+
{ key: "Space/Enter", action: "Activate the focused tab (if activation is manual)" }
|
|
735
|
+
],
|
|
736
|
+
codeExample: `<div role="tablist" aria-label="Entertainment options">
|
|
737
|
+
<button
|
|
738
|
+
role="tab"
|
|
739
|
+
aria-selected="true"
|
|
740
|
+
aria-controls="panel-1"
|
|
741
|
+
id="tab-1"
|
|
742
|
+
tabindex="0"
|
|
743
|
+
>
|
|
744
|
+
Tab 1
|
|
745
|
+
</button>
|
|
746
|
+
<button
|
|
747
|
+
role="tab"
|
|
748
|
+
aria-selected="false"
|
|
749
|
+
aria-controls="panel-2"
|
|
750
|
+
id="tab-2"
|
|
751
|
+
tabindex="-1"
|
|
752
|
+
>
|
|
753
|
+
Tab 2
|
|
754
|
+
</button>
|
|
755
|
+
</div>
|
|
756
|
+
|
|
757
|
+
<div
|
|
758
|
+
role="tabpanel"
|
|
759
|
+
id="panel-1"
|
|
760
|
+
aria-labelledby="tab-1"
|
|
761
|
+
tabindex="0"
|
|
762
|
+
>
|
|
763
|
+
Panel 1 content
|
|
764
|
+
</div>
|
|
765
|
+
|
|
766
|
+
<div
|
|
767
|
+
role="tabpanel"
|
|
768
|
+
id="panel-2"
|
|
769
|
+
aria-labelledby="tab-2"
|
|
770
|
+
tabindex="0"
|
|
771
|
+
hidden
|
|
772
|
+
>
|
|
773
|
+
Panel 2 content
|
|
774
|
+
</div>
|
|
775
|
+
|
|
776
|
+
// Keyboard navigation
|
|
777
|
+
tablist.addEventListener('keydown', (e) => {
|
|
778
|
+
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
|
|
779
|
+
const currentIndex = tabs.indexOf(document.activeElement);
|
|
780
|
+
|
|
781
|
+
if (e.key === 'ArrowRight') {
|
|
782
|
+
const nextIndex = (currentIndex + 1) % tabs.length;
|
|
783
|
+
tabs[nextIndex].focus();
|
|
784
|
+
} else if (e.key === 'ArrowLeft') {
|
|
785
|
+
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
|
786
|
+
tabs[prevIndex].focus();
|
|
787
|
+
}
|
|
788
|
+
});`
|
|
789
|
+
},
|
|
790
|
+
"tabpanel": {
|
|
791
|
+
name: "Tabs",
|
|
792
|
+
description: "A set of layered sections of content, where one panel is displayed at a time.",
|
|
793
|
+
roles: ["tablist", "tab", "tabpanel"],
|
|
794
|
+
states: ["aria-selected='true|false'"],
|
|
795
|
+
properties: ["aria-controls", "aria-labelledby"],
|
|
796
|
+
keyboardInteractions: [
|
|
797
|
+
{ key: "Tab", action: "Move focus into the tab list, then to the active tabpanel" },
|
|
798
|
+
{ key: "ArrowLeft/ArrowRight", action: "Move focus between tabs (horizontal tablist)" },
|
|
799
|
+
{ key: "Home", action: "Move focus to first tab" },
|
|
800
|
+
{ key: "End", action: "Move focus to last tab" }
|
|
801
|
+
],
|
|
802
|
+
codeExample: `See 'tabs' pattern for full implementation.`
|
|
803
|
+
},
|
|
804
|
+
"menu": {
|
|
805
|
+
name: "Menu",
|
|
806
|
+
description: "A menu offers a list of choices to the user, such as actions or functions.",
|
|
807
|
+
roles: ["menu", "menuitem", "menuitemcheckbox", "menuitemradio"],
|
|
808
|
+
states: ["aria-expanded='true|false'", "aria-checked='true|false' (for menuitemcheckbox/radio)"],
|
|
809
|
+
properties: ["aria-haspopup='menu'", "aria-labelledby"],
|
|
810
|
+
keyboardInteractions: [
|
|
811
|
+
{ key: "Enter/Space", action: "Activate menu item and close menu" },
|
|
812
|
+
{ key: "ArrowDown", action: "Move focus to next menu item" },
|
|
813
|
+
{ key: "ArrowUp", action: "Move focus to previous menu item" },
|
|
814
|
+
{ key: "ArrowRight", action: "Open submenu (if available) and move focus to first item" },
|
|
815
|
+
{ key: "ArrowLeft", action: "Close submenu and return focus to parent menu item" },
|
|
816
|
+
{ key: "Escape", action: "Close the menu" },
|
|
817
|
+
{ key: "Home", action: "Move focus to first menu item" },
|
|
818
|
+
{ key: "End", action: "Move focus to last menu item" }
|
|
819
|
+
],
|
|
820
|
+
codeExample: `<button
|
|
821
|
+
aria-haspopup="menu"
|
|
822
|
+
aria-expanded="false"
|
|
823
|
+
aria-controls="menu-1"
|
|
824
|
+
>
|
|
825
|
+
Menu
|
|
826
|
+
</button>
|
|
827
|
+
|
|
828
|
+
<ul role="menu" id="menu-1" aria-labelledby="menu-button">
|
|
829
|
+
<li role="menuitem" tabindex="-1">Action 1</li>
|
|
830
|
+
<li role="menuitem" tabindex="-1">Action 2</li>
|
|
831
|
+
<li role="menuitem" tabindex="-1">Action 3</li>
|
|
832
|
+
</ul>
|
|
833
|
+
|
|
834
|
+
// Open menu and manage focus
|
|
835
|
+
button.addEventListener('click', () => {
|
|
836
|
+
const expanded = button.getAttribute('aria-expanded') === 'true';
|
|
837
|
+
button.setAttribute('aria-expanded', !expanded);
|
|
838
|
+
if (!expanded) {
|
|
839
|
+
menu.querySelector('[role="menuitem"]').focus();
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
menu.addEventListener('keydown', (e) => {
|
|
844
|
+
const items = Array.from(menu.querySelectorAll('[role="menuitem"]'));
|
|
845
|
+
const currentIndex = items.indexOf(document.activeElement);
|
|
846
|
+
|
|
847
|
+
switch (e.key) {
|
|
848
|
+
case 'ArrowDown':
|
|
849
|
+
e.preventDefault();
|
|
850
|
+
items[(currentIndex + 1) % items.length].focus();
|
|
851
|
+
break;
|
|
852
|
+
case 'ArrowUp':
|
|
853
|
+
e.preventDefault();
|
|
854
|
+
items[(currentIndex - 1 + items.length) % items.length].focus();
|
|
855
|
+
break;
|
|
856
|
+
case 'Escape':
|
|
857
|
+
button.setAttribute('aria-expanded', 'false');
|
|
858
|
+
button.focus();
|
|
859
|
+
break;
|
|
860
|
+
}
|
|
861
|
+
});`
|
|
862
|
+
},
|
|
863
|
+
"menubar": {
|
|
864
|
+
name: "Menubar",
|
|
865
|
+
description: "A horizontal menu typically used as a main navigation element.",
|
|
866
|
+
roles: ["menubar", "menuitem", "menu"],
|
|
867
|
+
states: ["aria-expanded='true|false'"],
|
|
868
|
+
properties: ["aria-haspopup='menu'", "aria-labelledby"],
|
|
869
|
+
keyboardInteractions: [
|
|
870
|
+
{ key: "ArrowLeft/ArrowRight", action: "Move focus between menubar items" },
|
|
871
|
+
{ key: "ArrowDown", action: "Open submenu and move focus to first item" },
|
|
872
|
+
{ key: "ArrowUp", action: "Open submenu and move focus to last item" },
|
|
873
|
+
{ key: "Enter/Space", action: "Open submenu or activate item" },
|
|
874
|
+
{ key: "Escape", action: "Close submenu" }
|
|
875
|
+
],
|
|
876
|
+
codeExample: `<nav aria-label="Main">
|
|
877
|
+
<ul role="menubar">
|
|
878
|
+
<li role="none">
|
|
879
|
+
<button
|
|
880
|
+
role="menuitem"
|
|
881
|
+
aria-haspopup="menu"
|
|
882
|
+
aria-expanded="false"
|
|
883
|
+
>
|
|
884
|
+
File
|
|
885
|
+
</button>
|
|
886
|
+
<ul role="menu">
|
|
887
|
+
<li role="menuitem" tabindex="-1">New</li>
|
|
888
|
+
<li role="menuitem" tabindex="-1">Open</li>
|
|
889
|
+
<li role="menuitem" tabindex="-1">Save</li>
|
|
890
|
+
</ul>
|
|
891
|
+
</li>
|
|
892
|
+
<li role="none">
|
|
893
|
+
<button role="menuitem">Edit</button>
|
|
894
|
+
</li>
|
|
895
|
+
</ul>
|
|
896
|
+
</nav>`
|
|
897
|
+
},
|
|
898
|
+
"accordion": {
|
|
899
|
+
name: "Accordion",
|
|
900
|
+
description: "Vertically stacked headings that reveal or hide associated content.",
|
|
901
|
+
roles: ["button (for headers)", "region (optional for panels)"],
|
|
902
|
+
states: ["aria-expanded='true|false'"],
|
|
903
|
+
properties: ["aria-controls", "aria-labelledby"],
|
|
904
|
+
keyboardInteractions: [
|
|
905
|
+
{ key: "Enter/Space", action: "Toggle the accordion panel" },
|
|
906
|
+
{ key: "ArrowDown", action: "Move focus to next accordion header" },
|
|
907
|
+
{ key: "ArrowUp", action: "Move focus to previous accordion header" },
|
|
908
|
+
{ key: "Home", action: "Move focus to first accordion header" },
|
|
909
|
+
{ key: "End", action: "Move focus to last accordion header" }
|
|
910
|
+
],
|
|
911
|
+
codeExample: `<div class="accordion">
|
|
912
|
+
<h3>
|
|
913
|
+
<button
|
|
914
|
+
aria-expanded="true"
|
|
915
|
+
aria-controls="panel-1"
|
|
916
|
+
id="header-1"
|
|
917
|
+
>
|
|
918
|
+
Section 1
|
|
919
|
+
</button>
|
|
920
|
+
</h3>
|
|
921
|
+
<div
|
|
922
|
+
id="panel-1"
|
|
923
|
+
role="region"
|
|
924
|
+
aria-labelledby="header-1"
|
|
925
|
+
>
|
|
926
|
+
<p>Content for section 1</p>
|
|
927
|
+
</div>
|
|
928
|
+
|
|
929
|
+
<h3>
|
|
930
|
+
<button
|
|
931
|
+
aria-expanded="false"
|
|
932
|
+
aria-controls="panel-2"
|
|
933
|
+
id="header-2"
|
|
934
|
+
>
|
|
935
|
+
Section 2
|
|
936
|
+
</button>
|
|
937
|
+
</h3>
|
|
938
|
+
<div
|
|
939
|
+
id="panel-2"
|
|
940
|
+
role="region"
|
|
941
|
+
aria-labelledby="header-2"
|
|
942
|
+
hidden
|
|
943
|
+
>
|
|
944
|
+
<p>Content for section 2</p>
|
|
945
|
+
</div>
|
|
946
|
+
</div>
|
|
947
|
+
|
|
948
|
+
// Toggle accordion
|
|
949
|
+
button.addEventListener('click', () => {
|
|
950
|
+
const expanded = button.getAttribute('aria-expanded') === 'true';
|
|
951
|
+
button.setAttribute('aria-expanded', !expanded);
|
|
952
|
+
panel.hidden = expanded;
|
|
953
|
+
});`
|
|
954
|
+
},
|
|
955
|
+
"combobox": {
|
|
956
|
+
name: "Combobox / Autocomplete",
|
|
957
|
+
description: "A composite widget with a text input and a popup listbox for selecting a value.",
|
|
958
|
+
roles: ["combobox", "listbox", "option"],
|
|
959
|
+
states: ["aria-expanded='true|false'", "aria-selected='true|false'", "aria-activedescendant"],
|
|
960
|
+
properties: ["aria-controls", "aria-autocomplete='list|both|inline'", "aria-haspopup='listbox'"],
|
|
961
|
+
keyboardInteractions: [
|
|
962
|
+
{ key: "ArrowDown", action: "Open listbox (if closed) or move focus to next option" },
|
|
963
|
+
{ key: "ArrowUp", action: "Move focus to previous option" },
|
|
964
|
+
{ key: "Enter", action: "Select the focused option and close listbox" },
|
|
965
|
+
{ key: "Escape", action: "Close the listbox without selecting" },
|
|
966
|
+
{ key: "Home", action: "Move focus to first option" },
|
|
967
|
+
{ key: "End", action: "Move focus to last option" },
|
|
968
|
+
{ key: "Type characters", action: "Filter options or jump to matching option" }
|
|
969
|
+
],
|
|
970
|
+
codeExample: `<div class="combobox-wrapper">
|
|
971
|
+
<label id="label" for="combobox">Choose a fruit:</label>
|
|
972
|
+
<input
|
|
973
|
+
type="text"
|
|
974
|
+
id="combobox"
|
|
975
|
+
role="combobox"
|
|
976
|
+
aria-controls="listbox"
|
|
977
|
+
aria-expanded="false"
|
|
978
|
+
aria-haspopup="listbox"
|
|
979
|
+
aria-autocomplete="list"
|
|
980
|
+
aria-activedescendant=""
|
|
981
|
+
/>
|
|
982
|
+
<ul
|
|
983
|
+
role="listbox"
|
|
984
|
+
id="listbox"
|
|
985
|
+
aria-labelledby="label"
|
|
986
|
+
hidden
|
|
987
|
+
>
|
|
988
|
+
<li role="option" id="opt-1">Apple</li>
|
|
989
|
+
<li role="option" id="opt-2">Banana</li>
|
|
990
|
+
<li role="option" id="opt-3">Cherry</li>
|
|
991
|
+
</ul>
|
|
992
|
+
</div>
|
|
993
|
+
|
|
994
|
+
// Manage active descendant
|
|
995
|
+
input.addEventListener('keydown', (e) => {
|
|
996
|
+
if (e.key === 'ArrowDown') {
|
|
997
|
+
e.preventDefault();
|
|
998
|
+
listbox.hidden = false;
|
|
999
|
+
input.setAttribute('aria-expanded', 'true');
|
|
1000
|
+
// Update aria-activedescendant to next option
|
|
1001
|
+
const currentId = input.getAttribute('aria-activedescendant');
|
|
1002
|
+
const options = Array.from(listbox.querySelectorAll('[role="option"]'));
|
|
1003
|
+
const currentIndex = options.findIndex(o => o.id === currentId);
|
|
1004
|
+
const nextOption = options[(currentIndex + 1) % options.length];
|
|
1005
|
+
input.setAttribute('aria-activedescendant', nextOption.id);
|
|
1006
|
+
}
|
|
1007
|
+
if (e.key === 'Enter') {
|
|
1008
|
+
const activeId = input.getAttribute('aria-activedescendant');
|
|
1009
|
+
const activeOption = document.getElementById(activeId);
|
|
1010
|
+
input.value = activeOption.textContent;
|
|
1011
|
+
listbox.hidden = true;
|
|
1012
|
+
input.setAttribute('aria-expanded', 'false');
|
|
1013
|
+
}
|
|
1014
|
+
if (e.key === 'Escape') {
|
|
1015
|
+
listbox.hidden = true;
|
|
1016
|
+
input.setAttribute('aria-expanded', 'false');
|
|
1017
|
+
}
|
|
1018
|
+
});`
|
|
1019
|
+
},
|
|
1020
|
+
"autocomplete": {
|
|
1021
|
+
name: "Combobox / Autocomplete",
|
|
1022
|
+
description: "A composite widget with a text input and a popup listbox for selecting a value.",
|
|
1023
|
+
roles: ["combobox", "listbox", "option"],
|
|
1024
|
+
states: ["aria-expanded='true|false'", "aria-selected='true|false'", "aria-activedescendant"],
|
|
1025
|
+
properties: ["aria-controls", "aria-autocomplete='list|both|inline'", "aria-haspopup='listbox'"],
|
|
1026
|
+
keyboardInteractions: [
|
|
1027
|
+
{ key: "ArrowDown", action: "Open listbox or move focus to next option" },
|
|
1028
|
+
{ key: "ArrowUp", action: "Move focus to previous option" },
|
|
1029
|
+
{ key: "Enter", action: "Select the focused option" },
|
|
1030
|
+
{ key: "Escape", action: "Close the listbox" }
|
|
1031
|
+
],
|
|
1032
|
+
codeExample: `See 'combobox' pattern for full implementation.`
|
|
1033
|
+
},
|
|
1034
|
+
"tooltip": {
|
|
1035
|
+
name: "Tooltip",
|
|
1036
|
+
description: "A popup displaying a description for an element when the element receives keyboard focus or hover.",
|
|
1037
|
+
roles: ["tooltip"],
|
|
1038
|
+
states: [],
|
|
1039
|
+
properties: ["aria-describedby"],
|
|
1040
|
+
keyboardInteractions: [
|
|
1041
|
+
{ key: "Escape", action: "Dismiss the tooltip" },
|
|
1042
|
+
{ key: "Focus/Blur", action: "Show/hide tooltip on keyboard focus" }
|
|
1043
|
+
],
|
|
1044
|
+
codeExample: `<button aria-describedby="tooltip-1">
|
|
1045
|
+
Save
|
|
1046
|
+
</button>
|
|
1047
|
+
<div role="tooltip" id="tooltip-1" hidden>
|
|
1048
|
+
Save your changes (Ctrl+S)
|
|
1049
|
+
</div>
|
|
1050
|
+
|
|
1051
|
+
// Show tooltip on focus/hover
|
|
1052
|
+
button.addEventListener('focus', () => {
|
|
1053
|
+
tooltip.hidden = false;
|
|
1054
|
+
});
|
|
1055
|
+
button.addEventListener('blur', () => {
|
|
1056
|
+
tooltip.hidden = true;
|
|
1057
|
+
});
|
|
1058
|
+
button.addEventListener('mouseenter', () => {
|
|
1059
|
+
tooltip.hidden = false;
|
|
1060
|
+
});
|
|
1061
|
+
button.addEventListener('mouseleave', () => {
|
|
1062
|
+
tooltip.hidden = true;
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
// Hide on Escape
|
|
1066
|
+
button.addEventListener('keydown', (e) => {
|
|
1067
|
+
if (e.key === 'Escape') {
|
|
1068
|
+
tooltip.hidden = true;
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
/* CSS for positioning */
|
|
1073
|
+
[role="tooltip"] {
|
|
1074
|
+
position: absolute;
|
|
1075
|
+
background: #333;
|
|
1076
|
+
color: white;
|
|
1077
|
+
padding: 4px 8px;
|
|
1078
|
+
border-radius: 4px;
|
|
1079
|
+
font-size: 14px;
|
|
1080
|
+
}`
|
|
1081
|
+
},
|
|
1082
|
+
"alert": {
|
|
1083
|
+
name: "Alert",
|
|
1084
|
+
description: "A live region containing important, time-sensitive information. Alerts are assertive and announced immediately.",
|
|
1085
|
+
roles: ["alert", "alertdialog", "status"],
|
|
1086
|
+
states: [],
|
|
1087
|
+
properties: ["aria-live='assertive'", "aria-atomic='true'"],
|
|
1088
|
+
keyboardInteractions: [
|
|
1089
|
+
{ key: "None required", action: "Alerts do not require keyboard interaction - they are announced automatically" }
|
|
1090
|
+
],
|
|
1091
|
+
codeExample: `<!-- Simple alert (auto-announced) -->
|
|
1092
|
+
<div role="alert">
|
|
1093
|
+
Your session will expire in 5 minutes.
|
|
1094
|
+
</div>
|
|
1095
|
+
|
|
1096
|
+
<!-- Alert with live region (for dynamic content) -->
|
|
1097
|
+
<div
|
|
1098
|
+
role="alert"
|
|
1099
|
+
aria-live="assertive"
|
|
1100
|
+
aria-atomic="true"
|
|
1101
|
+
>
|
|
1102
|
+
Error: Please enter a valid email address.
|
|
1103
|
+
</div>
|
|
1104
|
+
|
|
1105
|
+
<!-- Status message (polite, less urgent) -->
|
|
1106
|
+
<div role="status" aria-live="polite">
|
|
1107
|
+
File uploaded successfully.
|
|
1108
|
+
</div>
|
|
1109
|
+
|
|
1110
|
+
// Dynamically add alert
|
|
1111
|
+
function showAlert(message) {
|
|
1112
|
+
const alert = document.createElement('div');
|
|
1113
|
+
alert.setAttribute('role', 'alert');
|
|
1114
|
+
alert.textContent = message;
|
|
1115
|
+
document.body.appendChild(alert);
|
|
1116
|
+
|
|
1117
|
+
// Remove after announcement
|
|
1118
|
+
setTimeout(() => alert.remove(), 5000);
|
|
1119
|
+
}`
|
|
1120
|
+
},
|
|
1121
|
+
"tree": {
|
|
1122
|
+
name: "Tree View",
|
|
1123
|
+
description: "A hierarchical list that allows users to expand and collapse nested groups of items.",
|
|
1124
|
+
roles: ["tree", "treeitem", "group"],
|
|
1125
|
+
states: ["aria-expanded='true|false'", "aria-selected='true|false'"],
|
|
1126
|
+
properties: ["aria-level", "aria-setsize", "aria-posinset", "aria-owns"],
|
|
1127
|
+
keyboardInteractions: [
|
|
1128
|
+
{ key: "ArrowDown", action: "Move focus to next visible treeitem" },
|
|
1129
|
+
{ key: "ArrowUp", action: "Move focus to previous visible treeitem" },
|
|
1130
|
+
{ key: "ArrowRight", action: "Expand node (if collapsed) or move to first child" },
|
|
1131
|
+
{ key: "ArrowLeft", action: "Collapse node (if expanded) or move to parent" },
|
|
1132
|
+
{ key: "Enter", action: "Activate the focused treeitem" },
|
|
1133
|
+
{ key: "Home", action: "Move focus to first treeitem" },
|
|
1134
|
+
{ key: "End", action: "Move focus to last visible treeitem" },
|
|
1135
|
+
{ key: "* (asterisk)", action: "Expand all siblings at the current level" }
|
|
1136
|
+
],
|
|
1137
|
+
codeExample: `<ul role="tree" aria-label="File browser">
|
|
1138
|
+
<li
|
|
1139
|
+
role="treeitem"
|
|
1140
|
+
aria-expanded="true"
|
|
1141
|
+
aria-level="1"
|
|
1142
|
+
aria-setsize="2"
|
|
1143
|
+
aria-posinset="1"
|
|
1144
|
+
tabindex="0"
|
|
1145
|
+
>
|
|
1146
|
+
Documents
|
|
1147
|
+
<ul role="group">
|
|
1148
|
+
<li
|
|
1149
|
+
role="treeitem"
|
|
1150
|
+
aria-level="2"
|
|
1151
|
+
aria-setsize="2"
|
|
1152
|
+
aria-posinset="1"
|
|
1153
|
+
tabindex="-1"
|
|
1154
|
+
>
|
|
1155
|
+
report.pdf
|
|
1156
|
+
</li>
|
|
1157
|
+
<li
|
|
1158
|
+
role="treeitem"
|
|
1159
|
+
aria-level="2"
|
|
1160
|
+
aria-setsize="2"
|
|
1161
|
+
aria-posinset="2"
|
|
1162
|
+
tabindex="-1"
|
|
1163
|
+
>
|
|
1164
|
+
notes.txt
|
|
1165
|
+
</li>
|
|
1166
|
+
</ul>
|
|
1167
|
+
</li>
|
|
1168
|
+
<li
|
|
1169
|
+
role="treeitem"
|
|
1170
|
+
aria-expanded="false"
|
|
1171
|
+
aria-level="1"
|
|
1172
|
+
aria-setsize="2"
|
|
1173
|
+
aria-posinset="2"
|
|
1174
|
+
tabindex="-1"
|
|
1175
|
+
>
|
|
1176
|
+
Images
|
|
1177
|
+
<ul role="group" hidden>
|
|
1178
|
+
<li role="treeitem" aria-level="2" tabindex="-1">photo.jpg</li>
|
|
1179
|
+
</ul>
|
|
1180
|
+
</li>
|
|
1181
|
+
</ul>
|
|
1182
|
+
|
|
1183
|
+
// Keyboard navigation
|
|
1184
|
+
tree.addEventListener('keydown', (e) => {
|
|
1185
|
+
const current = document.activeElement;
|
|
1186
|
+
const items = Array.from(tree.querySelectorAll('[role="treeitem"]:not([hidden])'));
|
|
1187
|
+
const index = items.indexOf(current);
|
|
1188
|
+
|
|
1189
|
+
switch (e.key) {
|
|
1190
|
+
case 'ArrowDown':
|
|
1191
|
+
e.preventDefault();
|
|
1192
|
+
if (index < items.length - 1) items[index + 1].focus();
|
|
1193
|
+
break;
|
|
1194
|
+
case 'ArrowUp':
|
|
1195
|
+
e.preventDefault();
|
|
1196
|
+
if (index > 0) items[index - 1].focus();
|
|
1197
|
+
break;
|
|
1198
|
+
case 'ArrowRight':
|
|
1199
|
+
if (current.getAttribute('aria-expanded') === 'false') {
|
|
1200
|
+
current.setAttribute('aria-expanded', 'true');
|
|
1201
|
+
current.querySelector('[role="group"]').hidden = false;
|
|
1202
|
+
}
|
|
1203
|
+
break;
|
|
1204
|
+
case 'ArrowLeft':
|
|
1205
|
+
if (current.getAttribute('aria-expanded') === 'true') {
|
|
1206
|
+
current.setAttribute('aria-expanded', 'false');
|
|
1207
|
+
current.querySelector('[role="group"]').hidden = true;
|
|
1208
|
+
}
|
|
1209
|
+
break;
|
|
1210
|
+
}
|
|
1211
|
+
});`
|
|
1212
|
+
},
|
|
1213
|
+
"treeitem": {
|
|
1214
|
+
name: "Tree View",
|
|
1215
|
+
description: "A hierarchical list that allows users to expand and collapse nested groups of items.",
|
|
1216
|
+
roles: ["tree", "treeitem", "group"],
|
|
1217
|
+
states: ["aria-expanded='true|false'", "aria-selected='true|false'"],
|
|
1218
|
+
properties: ["aria-level", "aria-setsize", "aria-posinset"],
|
|
1219
|
+
keyboardInteractions: [
|
|
1220
|
+
{ key: "ArrowDown", action: "Move focus to next visible treeitem" },
|
|
1221
|
+
{ key: "ArrowUp", action: "Move focus to previous visible treeitem" },
|
|
1222
|
+
{ key: "ArrowRight", action: "Expand node or move to first child" },
|
|
1223
|
+
{ key: "ArrowLeft", action: "Collapse node or move to parent" }
|
|
1224
|
+
],
|
|
1225
|
+
codeExample: `See 'tree' pattern for full implementation.`
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
// Tool: Get WCAG success criterion details
|
|
1229
|
+
server.tool("get_wcag_guideline", "Get full WCAG success criterion details for a violation", {
|
|
1230
|
+
criterionId: z.string().describe("WCAG criterion like '1.1.1' or violation id like 'image-alt'"),
|
|
1231
|
+
}, async ({ criterionId }) => {
|
|
1232
|
+
telemetry.log('get_wcag_guideline');
|
|
1233
|
+
try {
|
|
1234
|
+
// Check if it's a violation ID first
|
|
1235
|
+
let wcagId = criterionId;
|
|
1236
|
+
if (violationToWcag[criterionId]) {
|
|
1237
|
+
wcagId = violationToWcag[criterionId];
|
|
1238
|
+
}
|
|
1239
|
+
// Look up the criterion
|
|
1240
|
+
const criterion = wcagCriteria[wcagId];
|
|
1241
|
+
if (!criterion) {
|
|
1242
|
+
// Try to find partial matches
|
|
1243
|
+
const matches = Object.keys(wcagCriteria).filter((k) => k.includes(criterionId));
|
|
1244
|
+
if (matches.length > 0) {
|
|
1245
|
+
return {
|
|
1246
|
+
content: [
|
|
1247
|
+
{
|
|
1248
|
+
type: "text",
|
|
1249
|
+
text: `No exact match for "${criterionId}". Did you mean one of these?\n${matches.map((m) => `- ${m}: ${wcagCriteria[m].title}`).join("\n")}`,
|
|
1250
|
+
},
|
|
1251
|
+
],
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
return {
|
|
1255
|
+
content: [
|
|
1256
|
+
{
|
|
1257
|
+
type: "text",
|
|
1258
|
+
text: `WCAG criterion "${criterionId}" not found. Try a criterion ID like "1.1.1" or a violation ID like "image-alt".`,
|
|
1259
|
+
},
|
|
1260
|
+
],
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
const response = `# WCAG ${criterion.id}: ${criterion.title}
|
|
1264
|
+
|
|
1265
|
+
**Level:** ${criterion.level}
|
|
1266
|
+
|
|
1267
|
+
## Description
|
|
1268
|
+
${criterion.description}
|
|
1269
|
+
|
|
1270
|
+
## Techniques
|
|
1271
|
+
${criterion.techniques.map((t) => `- ${t}`).join("\n")}
|
|
1272
|
+
|
|
1273
|
+
## Common Failures
|
|
1274
|
+
${criterion.failures.map((f) => `- ${f}`).join("\n")}
|
|
1275
|
+
|
|
1276
|
+
## Related Violations
|
|
1277
|
+
${criterion.relatedViolations.map((v) => `- ${v}`).join("\n")}`;
|
|
1278
|
+
return {
|
|
1279
|
+
content: [
|
|
1280
|
+
{
|
|
1281
|
+
type: "text",
|
|
1282
|
+
text: response,
|
|
1283
|
+
},
|
|
1284
|
+
],
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
catch (error) {
|
|
1288
|
+
return {
|
|
1289
|
+
content: [
|
|
1290
|
+
{
|
|
1291
|
+
type: "text",
|
|
1292
|
+
text: `Error looking up WCAG criterion: ${error instanceof Error ? error.message : String(error)}`,
|
|
1293
|
+
},
|
|
1294
|
+
],
|
|
1295
|
+
isError: true,
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
// Tool: Suggest ARIA pattern for a component type
|
|
1300
|
+
server.tool("suggest_aria_pattern", "Suggest ARIA pattern for a component type", {
|
|
1301
|
+
componentType: z.string().describe("e.g., 'modal', 'tabs', 'menu', 'accordion'"),
|
|
1302
|
+
}, async ({ componentType }) => {
|
|
1303
|
+
telemetry.log('suggest_aria_pattern');
|
|
1304
|
+
try {
|
|
1305
|
+
// Normalize the component type
|
|
1306
|
+
const normalizedType = componentType.toLowerCase().trim();
|
|
1307
|
+
// Look up the pattern
|
|
1308
|
+
const pattern = ariaPatterns[normalizedType];
|
|
1309
|
+
if (!pattern) {
|
|
1310
|
+
// Try to find partial matches
|
|
1311
|
+
const matches = Object.keys(ariaPatterns).filter((k) => k.includes(normalizedType) || normalizedType.includes(k));
|
|
1312
|
+
if (matches.length > 0) {
|
|
1313
|
+
return {
|
|
1314
|
+
content: [
|
|
1315
|
+
{
|
|
1316
|
+
type: "text",
|
|
1317
|
+
text: `No exact match for "${componentType}". Did you mean one of these?\n${matches.map((m) => `- ${m}: ${ariaPatterns[m].name}`).join("\n")}\n\nAvailable patterns: ${Object.keys(ariaPatterns).join(", ")}`,
|
|
1318
|
+
},
|
|
1319
|
+
],
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
return {
|
|
1323
|
+
content: [
|
|
1324
|
+
{
|
|
1325
|
+
type: "text",
|
|
1326
|
+
text: `ARIA pattern for "${componentType}" not found.\n\nAvailable patterns: ${Object.keys(ariaPatterns).join(", ")}`,
|
|
1327
|
+
},
|
|
1328
|
+
],
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
const response = `# ${pattern.name}
|
|
1332
|
+
|
|
1333
|
+
## Description
|
|
1334
|
+
${pattern.description}
|
|
1335
|
+
|
|
1336
|
+
## ARIA Roles
|
|
1337
|
+
${pattern.roles.map((r) => `- \`${r}\``).join("\n")}
|
|
1338
|
+
|
|
1339
|
+
## States
|
|
1340
|
+
${pattern.states.length > 0 ? pattern.states.map((s) => `- \`${s}\``).join("\n") : "- None required"}
|
|
1341
|
+
|
|
1342
|
+
## Properties
|
|
1343
|
+
${pattern.properties.length > 0 ? pattern.properties.map((p) => `- \`${p}\``).join("\n") : "- None required"}
|
|
1344
|
+
|
|
1345
|
+
## Keyboard Interactions
|
|
1346
|
+
| Key | Action |
|
|
1347
|
+
|-----|--------|
|
|
1348
|
+
${pattern.keyboardInteractions.map((k) => `| ${k.key} | ${k.action} |`).join("\n")}
|
|
1349
|
+
|
|
1350
|
+
## Code Example
|
|
1351
|
+
\`\`\`html
|
|
1352
|
+
${pattern.codeExample}
|
|
1353
|
+
\`\`\``;
|
|
1354
|
+
return {
|
|
1355
|
+
content: [
|
|
1356
|
+
{
|
|
1357
|
+
type: "text",
|
|
1358
|
+
text: response,
|
|
1359
|
+
},
|
|
1360
|
+
],
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
catch (error) {
|
|
1364
|
+
return {
|
|
1365
|
+
content: [
|
|
1366
|
+
{
|
|
1367
|
+
type: "text",
|
|
1368
|
+
text: `Error looking up ARIA pattern: ${error instanceof Error ? error.message : String(error)}`,
|
|
1369
|
+
},
|
|
1370
|
+
],
|
|
1371
|
+
isError: true,
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
// Tool: Check color contrast
|
|
1376
|
+
server.tool("check_color_contrast", "Calculate WCAG contrast ratio and APCA Lc value between two colors and check compliance", {
|
|
1377
|
+
foreground: z.string().describe("Foreground color (hex, rgb, or named)"),
|
|
1378
|
+
background: z.string().describe("Background color (hex, rgb, or named)"),
|
|
1379
|
+
fontSize: z.number().optional().describe("Font size in pixels (optional, for AA/AAA thresholds)"),
|
|
1380
|
+
isBold: z.boolean().optional().describe("Whether text is bold (optional)"),
|
|
1381
|
+
}, async ({ foreground, background, fontSize, isBold }) => {
|
|
1382
|
+
telemetry.log('check_color_contrast');
|
|
1383
|
+
try {
|
|
1384
|
+
// Parse colors
|
|
1385
|
+
const fgColor = parseColor(foreground);
|
|
1386
|
+
const bgColor = parseColor(background);
|
|
1387
|
+
if (!fgColor) {
|
|
1388
|
+
return {
|
|
1389
|
+
content: [
|
|
1390
|
+
{
|
|
1391
|
+
type: "text",
|
|
1392
|
+
text: `Unable to parse foreground color: "${foreground}". Supported formats: hex (#RGB, #RRGGBB), rgb(r, g, b), or named colors (e.g., "red", "blue").`,
|
|
1393
|
+
},
|
|
1394
|
+
],
|
|
1395
|
+
isError: true,
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
if (!bgColor) {
|
|
1399
|
+
return {
|
|
1400
|
+
content: [
|
|
1401
|
+
{
|
|
1402
|
+
type: "text",
|
|
1403
|
+
text: `Unable to parse background color: "${background}". Supported formats: hex (#RGB, #RRGGBB), rgb(r, g, b), or named colors (e.g., "red", "blue").`,
|
|
1404
|
+
},
|
|
1405
|
+
],
|
|
1406
|
+
isError: true,
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
// Calculate WCAG 2.x contrast ratio
|
|
1410
|
+
const contrastRatio = calculateContrastRatio(fgColor, bgColor);
|
|
1411
|
+
const roundedRatio = Math.round(contrastRatio * 100) / 100;
|
|
1412
|
+
// Calculate APCA Lc value (Advanced Perceptual Contrast Algorithm)
|
|
1413
|
+
const fgArray = [fgColor.r, fgColor.g, fgColor.b];
|
|
1414
|
+
const bgArray = [bgColor.r, bgColor.g, bgColor.b];
|
|
1415
|
+
const apcaLc = APCAcontrast(sRGBtoY(fgArray), sRGBtoY(bgArray));
|
|
1416
|
+
const roundedLc = Math.round(apcaLc * 10) / 10;
|
|
1417
|
+
const absLc = Math.abs(roundedLc);
|
|
1418
|
+
// APCA thresholds (based on WCAG 3.0 draft guidelines)
|
|
1419
|
+
// Lc 90+ : Preferred for body text
|
|
1420
|
+
// Lc 75+ : Minimum for body text
|
|
1421
|
+
// Lc 60+ : Large text and headlines
|
|
1422
|
+
// Lc 45+ : Non-text elements, graphics
|
|
1423
|
+
const apcaCompliance = {
|
|
1424
|
+
bodyTextPreferred: absLc >= 90,
|
|
1425
|
+
bodyTextMinimum: absLc >= 75,
|
|
1426
|
+
largeText: absLc >= 60,
|
|
1427
|
+
nonText: absLc >= 45,
|
|
1428
|
+
};
|
|
1429
|
+
// Determine if text is large
|
|
1430
|
+
const largeText = isLargeText(fontSize, isBold);
|
|
1431
|
+
// WCAG 2.x thresholds
|
|
1432
|
+
// AA: 4.5:1 for normal text, 3:1 for large text
|
|
1433
|
+
// AAA: 7:1 for normal text, 4.5:1 for large text
|
|
1434
|
+
const wcagAA = {
|
|
1435
|
+
normalText: contrastRatio >= 4.5,
|
|
1436
|
+
largeText: contrastRatio >= 3,
|
|
1437
|
+
};
|
|
1438
|
+
const wcagAAA = {
|
|
1439
|
+
normalText: contrastRatio >= 7,
|
|
1440
|
+
largeText: contrastRatio >= 4.5,
|
|
1441
|
+
};
|
|
1442
|
+
// Generate recommendation
|
|
1443
|
+
let recommendation = "";
|
|
1444
|
+
const passesAA = largeText ? wcagAA.largeText : wcagAA.normalText;
|
|
1445
|
+
const passesAAA = largeText ? wcagAAA.largeText : wcagAAA.normalText;
|
|
1446
|
+
if (passesAAA) {
|
|
1447
|
+
recommendation = "Excellent! This color combination meets WCAG AAA standards for all text sizes.";
|
|
1448
|
+
}
|
|
1449
|
+
else if (passesAA) {
|
|
1450
|
+
recommendation = "Good. This color combination meets WCAG AA standards.";
|
|
1451
|
+
if (!wcagAAA.largeText) {
|
|
1452
|
+
recommendation += " To achieve AAA compliance, increase contrast to at least 7:1.";
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
else if (wcagAA.largeText) {
|
|
1456
|
+
recommendation = `This combination only passes for large text (18pt+ or 14pt+ bold). For normal text, increase contrast to at least 4.5:1. Current ratio: ${roundedRatio}:1.`;
|
|
1457
|
+
}
|
|
1458
|
+
else {
|
|
1459
|
+
const requiredRatio = largeText ? 3 : 4.5;
|
|
1460
|
+
recommendation = `Insufficient contrast (${roundedRatio}:1). Minimum required: ${requiredRatio}:1 for ${largeText ? "large" : "normal"} text. Consider:\n`;
|
|
1461
|
+
recommendation += "- Darkening the foreground color\n";
|
|
1462
|
+
recommendation += "- Lightening the background color\n";
|
|
1463
|
+
recommendation += "- Choosing colors with greater luminance difference";
|
|
1464
|
+
}
|
|
1465
|
+
// Build result object
|
|
1466
|
+
const result = {
|
|
1467
|
+
contrastRatio: roundedRatio,
|
|
1468
|
+
apcaLc: roundedLc,
|
|
1469
|
+
wcagAA,
|
|
1470
|
+
wcagAAA,
|
|
1471
|
+
apcaCompliance,
|
|
1472
|
+
recommendation,
|
|
1473
|
+
};
|
|
1474
|
+
// Format response
|
|
1475
|
+
const passStatus = passesAA
|
|
1476
|
+
? passesAAA
|
|
1477
|
+
? "PASS (AAA)"
|
|
1478
|
+
: "PASS (AA)"
|
|
1479
|
+
: wcagAA.largeText
|
|
1480
|
+
? "PARTIAL (large text only)"
|
|
1481
|
+
: "FAIL";
|
|
1482
|
+
// APCA status string
|
|
1483
|
+
let apcaStatus = "";
|
|
1484
|
+
if (apcaCompliance.bodyTextPreferred) {
|
|
1485
|
+
apcaStatus = "Preferred for body text";
|
|
1486
|
+
}
|
|
1487
|
+
else if (apcaCompliance.bodyTextMinimum) {
|
|
1488
|
+
apcaStatus = "Minimum for body text";
|
|
1489
|
+
}
|
|
1490
|
+
else if (apcaCompliance.largeText) {
|
|
1491
|
+
apcaStatus = "Large text/headlines only";
|
|
1492
|
+
}
|
|
1493
|
+
else if (apcaCompliance.nonText) {
|
|
1494
|
+
apcaStatus = "Non-text elements only";
|
|
1495
|
+
}
|
|
1496
|
+
else {
|
|
1497
|
+
apcaStatus = "Insufficient";
|
|
1498
|
+
}
|
|
1499
|
+
const response = `# Color Contrast Check
|
|
1500
|
+
|
|
1501
|
+
**Foreground:** ${foreground} → rgb(${fgColor.r}, ${fgColor.g}, ${fgColor.b})
|
|
1502
|
+
**Background:** ${background} → rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})
|
|
1503
|
+
${fontSize ? `**Text size:** ${fontSize}px${isBold ? " (bold)" : ""}` : ""}
|
|
1504
|
+
|
|
1505
|
+
## Result: ${passStatus}
|
|
1506
|
+
|
|
1507
|
+
**WCAG 2.x Contrast Ratio:** ${roundedRatio}:1
|
|
1508
|
+
**APCA Lc Value:** ${roundedLc} (${apcaStatus})
|
|
1509
|
+
|
|
1510
|
+
### WCAG 2.x Compliance
|
|
1511
|
+
| Level | Normal Text (< 18pt) | Large Text (≥ 18pt) |
|
|
1512
|
+
|-------|---------------------|---------------------|
|
|
1513
|
+
| AA | ${wcagAA.normalText ? "✓ Pass (≥ 4.5:1)" : "✗ Fail (< 4.5:1)"} | ${wcagAA.largeText ? "✓ Pass (≥ 3:1)" : "✗ Fail (< 3:1)"} |
|
|
1514
|
+
| AAA | ${wcagAAA.normalText ? "✓ Pass (≥ 7:1)" : "✗ Fail (< 7:1)"} | ${wcagAAA.largeText ? "✓ Pass (≥ 4.5:1)" : "✗ Fail (< 4.5:1)"} |
|
|
1515
|
+
|
|
1516
|
+
### APCA Compliance (Experimental - WCAG 3.0 Draft)
|
|
1517
|
+
| Use Case | Threshold | Status |
|
|
1518
|
+
|----------|-----------|--------|
|
|
1519
|
+
| Body text (preferred) | Lc ≥ 90 | ${apcaCompliance.bodyTextPreferred ? "✓ Pass" : "✗ Fail"} |
|
|
1520
|
+
| Body text (minimum) | Lc ≥ 75 | ${apcaCompliance.bodyTextMinimum ? "✓ Pass" : "✗ Fail"} |
|
|
1521
|
+
| Large text/headlines | Lc ≥ 60 | ${apcaCompliance.largeText ? "✓ Pass" : "✗ Fail"} |
|
|
1522
|
+
| Non-text elements | Lc ≥ 45 | ${apcaCompliance.nonText ? "✓ Pass" : "✗ Fail"} |
|
|
1523
|
+
|
|
1524
|
+
*Note: APCA uses signed values. Negative Lc indicates light text on dark background.*
|
|
1525
|
+
|
|
1526
|
+
## Recommendation
|
|
1527
|
+
${recommendation}
|
|
1528
|
+
|
|
1529
|
+
---
|
|
1530
|
+
*Data (JSON):*
|
|
1531
|
+
\`\`\`json
|
|
1532
|
+
${JSON.stringify(result, null, 2)}
|
|
1533
|
+
\`\`\``;
|
|
1534
|
+
return {
|
|
1535
|
+
content: [
|
|
1536
|
+
{
|
|
1537
|
+
type: "text",
|
|
1538
|
+
text: response,
|
|
1539
|
+
},
|
|
1540
|
+
],
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
catch (error) {
|
|
1544
|
+
return {
|
|
1545
|
+
content: [
|
|
1546
|
+
{
|
|
1547
|
+
type: "text",
|
|
1548
|
+
text: `Error checking color contrast: ${error instanceof Error ? error.message : String(error)}`,
|
|
1549
|
+
},
|
|
1550
|
+
],
|
|
1551
|
+
isError: true,
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
// Helper: Analyze component patterns
|
|
1556
|
+
async function analyzeComponentPatterns(directory, componentFilter) {
|
|
1557
|
+
const patterns = [];
|
|
1558
|
+
const extensions = [".jsx", ".tsx", ".vue", ".svelte"];
|
|
1559
|
+
async function scanDir(dir) {
|
|
1560
|
+
try {
|
|
1561
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1562
|
+
for (const entry of entries) {
|
|
1563
|
+
const fullPath = join(dir, entry.name);
|
|
1564
|
+
if (entry.isDirectory()) {
|
|
1565
|
+
if (!entry.name.startsWith(".") && entry.name !== "node_modules" && entry.name !== "dist") {
|
|
1566
|
+
await scanDir(fullPath);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
else if (extensions.includes(extname(entry.name))) {
|
|
1570
|
+
if (componentFilter && !entry.name.toLowerCase().includes(componentFilter.toLowerCase())) {
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
try {
|
|
1574
|
+
const content = await readFile(fullPath, "utf-8");
|
|
1575
|
+
const pattern = analyzeComponent(entry.name, fullPath, content);
|
|
1576
|
+
if (pattern) {
|
|
1577
|
+
patterns.push(pattern);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
catch {
|
|
1581
|
+
// Skip files that can't be read
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
catch {
|
|
1587
|
+
// Skip directories that can't be read
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
await scanDir(directory);
|
|
1591
|
+
return patterns;
|
|
1592
|
+
}
|
|
1593
|
+
function analyzeComponent(name, file, content) {
|
|
1594
|
+
const hasAriaLabel = /aria-label/i.test(content);
|
|
1595
|
+
const hasAriaDescribedBy = /aria-describedby/i.test(content);
|
|
1596
|
+
const hasRole = /role=["']/i.test(content);
|
|
1597
|
+
const hasFocusManagement = /focus\(\)|useRef|tabIndex|onFocus|onBlur/i.test(content);
|
|
1598
|
+
const hasKeyboardHandlers = /onKeyDown|onKeyUp|onKeyPress|@keydown|@keyup/i.test(content);
|
|
1599
|
+
// Extract examples of ARIA usage
|
|
1600
|
+
const examples = [];
|
|
1601
|
+
const ariaLabelMatches = content.match(/aria-label=["'][^"']+["']/gi);
|
|
1602
|
+
if (ariaLabelMatches) {
|
|
1603
|
+
examples.push(...ariaLabelMatches.slice(0, 3));
|
|
1604
|
+
}
|
|
1605
|
+
const roleMatches = content.match(/role=["'][^"']+["']/gi);
|
|
1606
|
+
if (roleMatches) {
|
|
1607
|
+
examples.push(...roleMatches.slice(0, 3));
|
|
1608
|
+
}
|
|
1609
|
+
return {
|
|
1610
|
+
component: name.replace(/\.(jsx|tsx|vue|svelte)$/, ""),
|
|
1611
|
+
file,
|
|
1612
|
+
patterns: {
|
|
1613
|
+
hasAriaLabel,
|
|
1614
|
+
hasAriaDescribedBy,
|
|
1615
|
+
hasRole,
|
|
1616
|
+
hasFocusManagement,
|
|
1617
|
+
hasKeyboardHandlers,
|
|
1618
|
+
},
|
|
1619
|
+
examples,
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
// Helper: Extract design tokens
|
|
1623
|
+
async function extractDesignTokens(directory) {
|
|
1624
|
+
const tokens = {
|
|
1625
|
+
colors: [],
|
|
1626
|
+
spacing: [],
|
|
1627
|
+
typography: [],
|
|
1628
|
+
};
|
|
1629
|
+
// Look for common token files
|
|
1630
|
+
const tokenFiles = [
|
|
1631
|
+
"tailwind.config.js",
|
|
1632
|
+
"tailwind.config.ts",
|
|
1633
|
+
"theme.js",
|
|
1634
|
+
"theme.ts",
|
|
1635
|
+
"tokens.js",
|
|
1636
|
+
"tokens.ts",
|
|
1637
|
+
"variables.css",
|
|
1638
|
+
"tokens.css",
|
|
1639
|
+
"_variables.scss",
|
|
1640
|
+
"design-tokens.json",
|
|
1641
|
+
];
|
|
1642
|
+
for (const filename of tokenFiles) {
|
|
1643
|
+
const filePath = join(directory, filename);
|
|
1644
|
+
if (existsSync(filePath)) {
|
|
1645
|
+
try {
|
|
1646
|
+
const content = await readFile(filePath, "utf-8");
|
|
1647
|
+
extractTokensFromContent(content, filename, tokens);
|
|
1648
|
+
}
|
|
1649
|
+
catch {
|
|
1650
|
+
// Skip files that can't be read
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
// Also check src directory
|
|
1655
|
+
const srcPath = join(directory, "src");
|
|
1656
|
+
if (existsSync(srcPath)) {
|
|
1657
|
+
for (const filename of tokenFiles) {
|
|
1658
|
+
const filePath = join(srcPath, filename);
|
|
1659
|
+
if (existsSync(filePath)) {
|
|
1660
|
+
try {
|
|
1661
|
+
const content = await readFile(filePath, "utf-8");
|
|
1662
|
+
extractTokensFromContent(content, filename, tokens);
|
|
1663
|
+
}
|
|
1664
|
+
catch {
|
|
1665
|
+
// Skip files that can't be read
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
return tokens;
|
|
1671
|
+
}
|
|
1672
|
+
function extractTokensFromContent(content, filename, tokens) {
|
|
1673
|
+
// Extract CSS custom properties
|
|
1674
|
+
const cssVarMatches = content.matchAll(/--([a-zA-Z0-9-]+):\s*([^;]+);/g);
|
|
1675
|
+
for (const match of cssVarMatches) {
|
|
1676
|
+
const name = match[1];
|
|
1677
|
+
const value = match[2].trim();
|
|
1678
|
+
if (name.includes("color") || value.startsWith("#") || value.startsWith("rgb") || value.startsWith("hsl")) {
|
|
1679
|
+
tokens.colors.push({ name: `--${name}`, value });
|
|
1680
|
+
}
|
|
1681
|
+
else if (name.includes("space") || name.includes("gap") || name.includes("margin") || name.includes("padding")) {
|
|
1682
|
+
tokens.spacing.push(`--${name}: ${value}`);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
// Extract Tailwind colors
|
|
1686
|
+
const tailwindColorMatches = content.matchAll(/'([a-zA-Z]+)':\s*['"]?(#[a-fA-F0-9]{3,8})['"]?/g);
|
|
1687
|
+
for (const match of tailwindColorMatches) {
|
|
1688
|
+
tokens.colors.push({ name: match[1], value: match[2] });
|
|
1689
|
+
}
|
|
1690
|
+
// Calculate contrast ratios (simplified - assuming white background)
|
|
1691
|
+
tokens.colors = tokens.colors.map((color) => {
|
|
1692
|
+
const ratio = estimateContrastRatio(color.value);
|
|
1693
|
+
return {
|
|
1694
|
+
...color,
|
|
1695
|
+
contrastRatio: ratio,
|
|
1696
|
+
wcagAACompliant: ratio >= 4.5,
|
|
1697
|
+
wcagAAACompliant: ratio >= 7,
|
|
1698
|
+
};
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Named CSS colors to hex mapping
|
|
1703
|
+
*/
|
|
1704
|
+
const namedColors = {
|
|
1705
|
+
black: "#000000",
|
|
1706
|
+
white: "#ffffff",
|
|
1707
|
+
red: "#ff0000",
|
|
1708
|
+
green: "#008000",
|
|
1709
|
+
blue: "#0000ff",
|
|
1710
|
+
yellow: "#ffff00",
|
|
1711
|
+
cyan: "#00ffff",
|
|
1712
|
+
magenta: "#ff00ff",
|
|
1713
|
+
gray: "#808080",
|
|
1714
|
+
grey: "#808080",
|
|
1715
|
+
silver: "#c0c0c0",
|
|
1716
|
+
maroon: "#800000",
|
|
1717
|
+
olive: "#808000",
|
|
1718
|
+
lime: "#00ff00",
|
|
1719
|
+
aqua: "#00ffff",
|
|
1720
|
+
teal: "#008080",
|
|
1721
|
+
navy: "#000080",
|
|
1722
|
+
fuchsia: "#ff00ff",
|
|
1723
|
+
purple: "#800080",
|
|
1724
|
+
orange: "#ffa500",
|
|
1725
|
+
pink: "#ffc0cb",
|
|
1726
|
+
brown: "#a52a2a",
|
|
1727
|
+
coral: "#ff7f50",
|
|
1728
|
+
crimson: "#dc143c",
|
|
1729
|
+
darkblue: "#00008b",
|
|
1730
|
+
darkgray: "#a9a9a9",
|
|
1731
|
+
darkgreen: "#006400",
|
|
1732
|
+
darkred: "#8b0000",
|
|
1733
|
+
gold: "#ffd700",
|
|
1734
|
+
indigo: "#4b0082",
|
|
1735
|
+
ivory: "#fffff0",
|
|
1736
|
+
khaki: "#f0e68c",
|
|
1737
|
+
lavender: "#e6e6fa",
|
|
1738
|
+
lightblue: "#add8e6",
|
|
1739
|
+
lightgray: "#d3d3d3",
|
|
1740
|
+
lightgreen: "#90ee90",
|
|
1741
|
+
lightyellow: "#ffffe0",
|
|
1742
|
+
midnightblue: "#191970",
|
|
1743
|
+
mintcream: "#f5fffa",
|
|
1744
|
+
mistyrose: "#ffe4e1",
|
|
1745
|
+
moccasin: "#ffe4b5",
|
|
1746
|
+
navajowhite: "#ffdead",
|
|
1747
|
+
oldlace: "#fdf5e6",
|
|
1748
|
+
olivedrab: "#6b8e23",
|
|
1749
|
+
orangered: "#ff4500",
|
|
1750
|
+
orchid: "#da70d6",
|
|
1751
|
+
palegoldenrod: "#eee8aa",
|
|
1752
|
+
palegreen: "#98fb98",
|
|
1753
|
+
paleturquoise: "#afeeee",
|
|
1754
|
+
palevioletred: "#db7093",
|
|
1755
|
+
papayawhip: "#ffefd5",
|
|
1756
|
+
peachpuff: "#ffdab9",
|
|
1757
|
+
peru: "#cd853f",
|
|
1758
|
+
plum: "#dda0dd",
|
|
1759
|
+
powderblue: "#b0e0e6",
|
|
1760
|
+
rosybrown: "#bc8f8f",
|
|
1761
|
+
royalblue: "#4169e1",
|
|
1762
|
+
saddlebrown: "#8b4513",
|
|
1763
|
+
salmon: "#fa8072",
|
|
1764
|
+
sandybrown: "#f4a460",
|
|
1765
|
+
seagreen: "#2e8b57",
|
|
1766
|
+
seashell: "#fff5ee",
|
|
1767
|
+
sienna: "#a0522d",
|
|
1768
|
+
skyblue: "#87ceeb",
|
|
1769
|
+
slateblue: "#6a5acd",
|
|
1770
|
+
slategray: "#708090",
|
|
1771
|
+
snow: "#fffafa",
|
|
1772
|
+
springgreen: "#00ff7f",
|
|
1773
|
+
steelblue: "#4682b4",
|
|
1774
|
+
tan: "#d2b48c",
|
|
1775
|
+
thistle: "#d8bfd8",
|
|
1776
|
+
tomato: "#ff6347",
|
|
1777
|
+
turquoise: "#40e0d0",
|
|
1778
|
+
violet: "#ee82ee",
|
|
1779
|
+
wheat: "#f5deb3",
|
|
1780
|
+
whitesmoke: "#f5f5f5",
|
|
1781
|
+
yellowgreen: "#9acd32",
|
|
1782
|
+
rebeccapurple: "#663399",
|
|
1783
|
+
transparent: "#00000000",
|
|
1784
|
+
};
|
|
1785
|
+
/**
|
|
1786
|
+
* Parse a color string (hex, rgb, or named) to RGB values
|
|
1787
|
+
*/
|
|
1788
|
+
function parseColor(color) {
|
|
1789
|
+
const trimmed = color.trim().toLowerCase();
|
|
1790
|
+
// Check named colors
|
|
1791
|
+
if (namedColors[trimmed]) {
|
|
1792
|
+
return parseColor(namedColors[trimmed]);
|
|
1793
|
+
}
|
|
1794
|
+
// Hex format: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
|
|
1795
|
+
if (trimmed.startsWith("#")) {
|
|
1796
|
+
const hex = trimmed.slice(1);
|
|
1797
|
+
if (hex.length === 3) {
|
|
1798
|
+
// #RGB -> #RRGGBB
|
|
1799
|
+
return {
|
|
1800
|
+
r: parseInt(hex[0] + hex[0], 16),
|
|
1801
|
+
g: parseInt(hex[1] + hex[1], 16),
|
|
1802
|
+
b: parseInt(hex[2] + hex[2], 16),
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
else if (hex.length === 4) {
|
|
1806
|
+
// #RGBA -> #RRGGBBAA (ignore alpha)
|
|
1807
|
+
return {
|
|
1808
|
+
r: parseInt(hex[0] + hex[0], 16),
|
|
1809
|
+
g: parseInt(hex[1] + hex[1], 16),
|
|
1810
|
+
b: parseInt(hex[2] + hex[2], 16),
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
else if (hex.length >= 6) {
|
|
1814
|
+
// #RRGGBB or #RRGGBBAA
|
|
1815
|
+
return {
|
|
1816
|
+
r: parseInt(hex.slice(0, 2), 16),
|
|
1817
|
+
g: parseInt(hex.slice(2, 4), 16),
|
|
1818
|
+
b: parseInt(hex.slice(4, 6), 16),
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
// RGB format: rgb(r, g, b) or rgba(r, g, b, a)
|
|
1823
|
+
const rgbMatch = trimmed.match(/rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
1824
|
+
if (rgbMatch) {
|
|
1825
|
+
return {
|
|
1826
|
+
r: parseInt(rgbMatch[1], 10),
|
|
1827
|
+
g: parseInt(rgbMatch[2], 10),
|
|
1828
|
+
b: parseInt(rgbMatch[3], 10),
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
// RGB percentage format: rgb(r%, g%, b%)
|
|
1832
|
+
const rgbPercentMatch = trimmed.match(/rgba?\s*\(\s*(\d+(?:\.\d+)?)%\s*,\s*(\d+(?:\.\d+)?)%\s*,\s*(\d+(?:\.\d+)?)%/);
|
|
1833
|
+
if (rgbPercentMatch) {
|
|
1834
|
+
return {
|
|
1835
|
+
r: Math.round((parseFloat(rgbPercentMatch[1]) / 100) * 255),
|
|
1836
|
+
g: Math.round((parseFloat(rgbPercentMatch[2]) / 100) * 255),
|
|
1837
|
+
b: Math.round((parseFloat(rgbPercentMatch[3]) / 100) * 255),
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
return null;
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* Calculate relative luminance per WCAG 2.1 specification
|
|
1844
|
+
* https://www.w3.org/WAI/GL/wiki/Relative_luminance
|
|
1845
|
+
*/
|
|
1846
|
+
function getLuminance(r, g, b) {
|
|
1847
|
+
// Convert 8-bit RGB to sRGB
|
|
1848
|
+
const rSrgb = r / 255;
|
|
1849
|
+
const gSrgb = g / 255;
|
|
1850
|
+
const bSrgb = b / 255;
|
|
1851
|
+
// Apply gamma correction
|
|
1852
|
+
const gammaCorrect = (c) => {
|
|
1853
|
+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
1854
|
+
};
|
|
1855
|
+
const rLin = gammaCorrect(rSrgb);
|
|
1856
|
+
const gLin = gammaCorrect(gSrgb);
|
|
1857
|
+
const bLin = gammaCorrect(bSrgb);
|
|
1858
|
+
// Calculate relative luminance
|
|
1859
|
+
return 0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin;
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Calculate contrast ratio between two colors per WCAG 2.1
|
|
1863
|
+
* https://www.w3.org/WAI/GL/wiki/Contrast_ratio
|
|
1864
|
+
*/
|
|
1865
|
+
function calculateContrastRatio(fg, bg) {
|
|
1866
|
+
const fgLum = getLuminance(fg.r, fg.g, fg.b);
|
|
1867
|
+
const bgLum = getLuminance(bg.r, bg.g, bg.b);
|
|
1868
|
+
const lighter = Math.max(fgLum, bgLum);
|
|
1869
|
+
const darker = Math.min(fgLum, bgLum);
|
|
1870
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* Determine if text is "large" per WCAG definition
|
|
1874
|
+
* Large text: 18pt (24px) or 14pt (18.67px) bold
|
|
1875
|
+
*/
|
|
1876
|
+
function isLargeText(fontSize, isBold) {
|
|
1877
|
+
if (!fontSize)
|
|
1878
|
+
return false;
|
|
1879
|
+
// 18pt = 24px
|
|
1880
|
+
if (fontSize >= 24)
|
|
1881
|
+
return true;
|
|
1882
|
+
// 14pt bold = 18.67px bold
|
|
1883
|
+
if (fontSize >= 18.67 && isBold)
|
|
1884
|
+
return true;
|
|
1885
|
+
return false;
|
|
1886
|
+
}
|
|
1887
|
+
/**
|
|
1888
|
+
* Calculate contrast ratio using WCAG 2.1 formula with proper sRGB gamma correction
|
|
1889
|
+
* (Legacy function for backward compatibility with design token analysis)
|
|
1890
|
+
*/
|
|
1891
|
+
function estimateContrastRatio(color) {
|
|
1892
|
+
if (!color.startsWith("#"))
|
|
1893
|
+
return 0;
|
|
1894
|
+
const hex = color.replace("#", "");
|
|
1895
|
+
if (hex.length < 6)
|
|
1896
|
+
return 0;
|
|
1897
|
+
// Parse RGB values
|
|
1898
|
+
const rRaw = parseInt(hex.slice(0, 2), 16) / 255;
|
|
1899
|
+
const gRaw = parseInt(hex.slice(2, 4), 16) / 255;
|
|
1900
|
+
const bRaw = parseInt(hex.slice(4, 6), 16) / 255;
|
|
1901
|
+
// Apply sRGB gamma correction (WCAG 2.1 specification)
|
|
1902
|
+
const gammaCorrect = (c) => {
|
|
1903
|
+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
1904
|
+
};
|
|
1905
|
+
const r = gammaCorrect(rRaw);
|
|
1906
|
+
const g = gammaCorrect(gRaw);
|
|
1907
|
+
const b = gammaCorrect(bRaw);
|
|
1908
|
+
// Calculate relative luminance
|
|
1909
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
1910
|
+
// Contrast ratio against white background (luminance = 1)
|
|
1911
|
+
const ratio = (1 + 0.05) / (luminance + 0.05);
|
|
1912
|
+
return Math.round(ratio * 10) / 10;
|
|
1913
|
+
}
|
|
1914
|
+
// Run the server
|
|
1915
|
+
async function main() {
|
|
1916
|
+
const transport = new StdioServerTransport();
|
|
1917
|
+
await server.connect(transport);
|
|
1918
|
+
console.error("Ally MCP Server running on stdio");
|
|
1919
|
+
}
|
|
1920
|
+
main().catch((error) => {
|
|
1921
|
+
console.error("Fatal error:", error);
|
|
1922
|
+
process.exit(1);
|
|
1923
|
+
});
|