real-prototypes-skill 2.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/.claude/skills/agent-browser-skill/SKILL.md +252 -0
- package/.claude/skills/real-prototypes-skill/.gitignore +188 -0
- package/.claude/skills/real-prototypes-skill/ACCESSIBILITY.md +668 -0
- package/.claude/skills/real-prototypes-skill/INSTALL.md +259 -0
- package/.claude/skills/real-prototypes-skill/LICENSE +21 -0
- package/.claude/skills/real-prototypes-skill/PUBLISH.md +310 -0
- package/.claude/skills/real-prototypes-skill/QUICKSTART.md +240 -0
- package/.claude/skills/real-prototypes-skill/README.md +442 -0
- package/.claude/skills/real-prototypes-skill/SKILL.md +329 -0
- package/.claude/skills/real-prototypes-skill/capture/capture-engine.js +1153 -0
- package/.claude/skills/real-prototypes-skill/capture/config.schema.json +170 -0
- package/.claude/skills/real-prototypes-skill/cli.js +596 -0
- package/.claude/skills/real-prototypes-skill/docs/TROUBLESHOOTING.md +278 -0
- package/.claude/skills/real-prototypes-skill/docs/schemas/capture-config.md +167 -0
- package/.claude/skills/real-prototypes-skill/docs/schemas/design-tokens.md +183 -0
- package/.claude/skills/real-prototypes-skill/docs/schemas/manifest.md +169 -0
- package/.claude/skills/real-prototypes-skill/examples/CLAUDE.md.example +73 -0
- package/.claude/skills/real-prototypes-skill/examples/amazon-chatbot/CLAUDE.md +136 -0
- package/.claude/skills/real-prototypes-skill/examples/amazon-chatbot/FEATURES.md +222 -0
- package/.claude/skills/real-prototypes-skill/examples/amazon-chatbot/README.md +82 -0
- package/.claude/skills/real-prototypes-skill/examples/amazon-chatbot/references/design-tokens.json +87 -0
- package/.claude/skills/real-prototypes-skill/examples/amazon-chatbot/references/screenshots/homepage-viewport.png +0 -0
- package/.claude/skills/real-prototypes-skill/examples/amazon-chatbot/references/screenshots/prototype-chatbot-final.png +0 -0
- package/.claude/skills/real-prototypes-skill/examples/amazon-chatbot/references/screenshots/prototype-fullpage-v2.png +0 -0
- package/.claude/skills/real-prototypes-skill/references/accessibility-fixes.md +298 -0
- package/.claude/skills/real-prototypes-skill/references/accessibility-report.json +253 -0
- package/.claude/skills/real-prototypes-skill/scripts/CAPTURE-ENHANCEMENTS.md +344 -0
- package/.claude/skills/real-prototypes-skill/scripts/IMPLEMENTATION-SUMMARY.md +517 -0
- package/.claude/skills/real-prototypes-skill/scripts/QUICK-START.md +229 -0
- package/.claude/skills/real-prototypes-skill/scripts/QUICKSTART-layout-analysis.md +148 -0
- package/.claude/skills/real-prototypes-skill/scripts/README-analyze-layout.md +407 -0
- package/.claude/skills/real-prototypes-skill/scripts/analyze-layout.js +880 -0
- package/.claude/skills/real-prototypes-skill/scripts/capture-platform.js +203 -0
- package/.claude/skills/real-prototypes-skill/scripts/comprehensive-capture.js +597 -0
- package/.claude/skills/real-prototypes-skill/scripts/create-manifest.js +338 -0
- package/.claude/skills/real-prototypes-skill/scripts/enterprise-pipeline.js +428 -0
- package/.claude/skills/real-prototypes-skill/scripts/extract-tokens.js +468 -0
- package/.claude/skills/real-prototypes-skill/scripts/full-site-capture.js +738 -0
- package/.claude/skills/real-prototypes-skill/scripts/generate-tailwind-config.js +296 -0
- package/.claude/skills/real-prototypes-skill/scripts/integrate-accessibility.sh +161 -0
- package/.claude/skills/real-prototypes-skill/scripts/manifest-schema.json +302 -0
- package/.claude/skills/real-prototypes-skill/scripts/setup-prototype.sh +167 -0
- package/.claude/skills/real-prototypes-skill/scripts/test-analyze-layout.js +338 -0
- package/.claude/skills/real-prototypes-skill/scripts/test-validation.js +307 -0
- package/.claude/skills/real-prototypes-skill/scripts/validate-accessibility.js +598 -0
- package/.claude/skills/real-prototypes-skill/scripts/validate-manifest.js +499 -0
- package/.claude/skills/real-prototypes-skill/scripts/validate-output.js +361 -0
- package/.claude/skills/real-prototypes-skill/scripts/validate-prerequisites.js +319 -0
- package/.claude/skills/real-prototypes-skill/scripts/verify-layout-analysis.sh +77 -0
- package/.claude/skills/real-prototypes-skill/templates/dashboard-widget.tsx.template +91 -0
- package/.claude/skills/real-prototypes-skill/templates/data-table.tsx.template +193 -0
- package/.claude/skills/real-prototypes-skill/templates/form-section.tsx.template +250 -0
- package/.claude/skills/real-prototypes-skill/templates/modal-dialog.tsx.template +239 -0
- package/.claude/skills/real-prototypes-skill/templates/nav-item.tsx.template +265 -0
- package/.claude/skills/real-prototypes-skill/validation/validation-engine.js +559 -0
- package/.env.example +74 -0
- package/LICENSE +21 -0
- package/README.md +444 -0
- package/bin/cli.js +319 -0
- package/package.json +59 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Accessibility Validation Script
|
|
5
|
+
*
|
|
6
|
+
* Validates WCAG 2.1 AA compliance for generated prototypes
|
|
7
|
+
* Checks color contrast, keyboard navigation, ARIA labels, focus states, etc.
|
|
8
|
+
* Fixes issues while preserving design (99% visual match)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
// WCAG 2.1 AA contrast ratio requirements
|
|
15
|
+
const WCAG_AA_NORMAL_TEXT = 4.5;
|
|
16
|
+
const WCAG_AA_LARGE_TEXT = 3.0;
|
|
17
|
+
const MAX_COLOR_ADJUSTMENT = 0.05; // Maximum lightness adjustment (5%)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Calculate relative luminance for a color
|
|
21
|
+
* https://www.w3.org/WAI/GL/wiki/Relative_luminance
|
|
22
|
+
*/
|
|
23
|
+
function getRelativeLuminance(r, g, b) {
|
|
24
|
+
const [rs, gs, bs] = [r, g, b].map(c => {
|
|
25
|
+
const val = c / 255;
|
|
26
|
+
return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
|
|
27
|
+
});
|
|
28
|
+
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Calculate contrast ratio between two colors
|
|
33
|
+
* https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html
|
|
34
|
+
*/
|
|
35
|
+
function getContrastRatio(rgb1, rgb2) {
|
|
36
|
+
const lum1 = getRelativeLuminance(...rgb1);
|
|
37
|
+
const lum2 = getRelativeLuminance(...rgb2);
|
|
38
|
+
const lighter = Math.max(lum1, lum2);
|
|
39
|
+
const darker = Math.min(lum1, lum2);
|
|
40
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse hex/rgb/rgba color to RGB array
|
|
45
|
+
*/
|
|
46
|
+
function parseColor(color) {
|
|
47
|
+
if (!color) return null;
|
|
48
|
+
|
|
49
|
+
// Hex colors
|
|
50
|
+
if (color.startsWith('#')) {
|
|
51
|
+
const hex = color.slice(1);
|
|
52
|
+
if (hex.length === 3) {
|
|
53
|
+
return hex.split('').map(c => parseInt(c + c, 16));
|
|
54
|
+
}
|
|
55
|
+
return [
|
|
56
|
+
parseInt(hex.slice(0, 2), 16),
|
|
57
|
+
parseInt(hex.slice(2, 4), 16),
|
|
58
|
+
parseInt(hex.slice(4, 6), 16),
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// RGB/RGBA colors
|
|
63
|
+
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
64
|
+
if (match) {
|
|
65
|
+
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convert RGB to HSL
|
|
73
|
+
*/
|
|
74
|
+
function rgbToHsl(r, g, b) {
|
|
75
|
+
r /= 255;
|
|
76
|
+
g /= 255;
|
|
77
|
+
b /= 255;
|
|
78
|
+
|
|
79
|
+
const max = Math.max(r, g, b);
|
|
80
|
+
const min = Math.min(r, g, b);
|
|
81
|
+
let h, s, l = (max + min) / 2;
|
|
82
|
+
|
|
83
|
+
if (max === min) {
|
|
84
|
+
h = s = 0;
|
|
85
|
+
} else {
|
|
86
|
+
const d = max - min;
|
|
87
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
88
|
+
|
|
89
|
+
switch (max) {
|
|
90
|
+
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
|
91
|
+
case g: h = ((b - r) / d + 2) / 6; break;
|
|
92
|
+
case b: h = ((r - g) / d + 4) / 6; break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return [h * 360, s * 100, l * 100];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Convert HSL to RGB
|
|
101
|
+
*/
|
|
102
|
+
function hslToRgb(h, s, l) {
|
|
103
|
+
h /= 360;
|
|
104
|
+
s /= 100;
|
|
105
|
+
l /= 100;
|
|
106
|
+
|
|
107
|
+
let r, g, b;
|
|
108
|
+
|
|
109
|
+
if (s === 0) {
|
|
110
|
+
r = g = b = l;
|
|
111
|
+
} else {
|
|
112
|
+
const hue2rgb = (p, q, t) => {
|
|
113
|
+
if (t < 0) t += 1;
|
|
114
|
+
if (t > 1) t -= 1;
|
|
115
|
+
if (t < 1/6) return p + (q - p) * 6 * t;
|
|
116
|
+
if (t < 1/2) return q;
|
|
117
|
+
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
|
118
|
+
return p;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
122
|
+
const p = 2 * l - q;
|
|
123
|
+
|
|
124
|
+
r = hue2rgb(p, q, h + 1/3);
|
|
125
|
+
g = hue2rgb(p, q, h);
|
|
126
|
+
b = hue2rgb(p, q, h - 1/3);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Adjust color lightness to meet contrast requirements
|
|
134
|
+
* Returns adjusted color and whether it exceeded max adjustment
|
|
135
|
+
*/
|
|
136
|
+
function adjustColorForContrast(textRgb, bgRgb, targetRatio, isLargeText = false) {
|
|
137
|
+
const currentRatio = getContrastRatio(textRgb, bgRgb);
|
|
138
|
+
|
|
139
|
+
if (currentRatio >= targetRatio) {
|
|
140
|
+
return { adjusted: textRgb, exceeded: false, adjustment: 0 };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const [h, s, l] = rgbToHsl(...textRgb);
|
|
144
|
+
const bgLuminance = getRelativeLuminance(...bgRgb);
|
|
145
|
+
|
|
146
|
+
// Determine if we should lighten or darken
|
|
147
|
+
const shouldLighten = bgLuminance < 0.5;
|
|
148
|
+
|
|
149
|
+
let adjustedL = l;
|
|
150
|
+
let step = shouldLighten ? 1 : -1;
|
|
151
|
+
let iterations = 0;
|
|
152
|
+
const maxIterations = 100;
|
|
153
|
+
|
|
154
|
+
while (iterations < maxIterations) {
|
|
155
|
+
adjustedL += step;
|
|
156
|
+
if (adjustedL < 0 || adjustedL > 100) break;
|
|
157
|
+
|
|
158
|
+
const adjustedRgb = hslToRgb(h, s, adjustedL);
|
|
159
|
+
const newRatio = getContrastRatio(adjustedRgb, bgRgb);
|
|
160
|
+
|
|
161
|
+
if (newRatio >= targetRatio) {
|
|
162
|
+
const adjustment = Math.abs((adjustedL - l) / 100);
|
|
163
|
+
return {
|
|
164
|
+
adjusted: adjustedRgb,
|
|
165
|
+
exceeded: adjustment > MAX_COLOR_ADJUSTMENT,
|
|
166
|
+
adjustment,
|
|
167
|
+
original: textRgb,
|
|
168
|
+
originalL: l,
|
|
169
|
+
adjustedL,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
iterations++;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Could not achieve target ratio
|
|
177
|
+
const finalRgb = hslToRgb(h, s, adjustedL);
|
|
178
|
+
return {
|
|
179
|
+
adjusted: finalRgb,
|
|
180
|
+
exceeded: true,
|
|
181
|
+
adjustment: Math.abs((adjustedL - l) / 100),
|
|
182
|
+
failed: true,
|
|
183
|
+
original: textRgb,
|
|
184
|
+
originalL: l,
|
|
185
|
+
adjustedL,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Validate accessibility for a TSX/JSX component
|
|
191
|
+
*/
|
|
192
|
+
function validateComponent(filePath) {
|
|
193
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
194
|
+
const issues = [];
|
|
195
|
+
const fixes = [];
|
|
196
|
+
|
|
197
|
+
// Check 1: Color contrast
|
|
198
|
+
const colorMatches = content.matchAll(/(?:text-|bg-|border-)(\w+-\d+)/g);
|
|
199
|
+
const colors = [...new Set([...colorMatches].map(m => m[0]))];
|
|
200
|
+
|
|
201
|
+
if (colors.length > 0) {
|
|
202
|
+
issues.push({
|
|
203
|
+
type: 'color-contrast',
|
|
204
|
+
severity: 'warning',
|
|
205
|
+
message: `Found ${colors.length} color utilities. Manual contrast check recommended.`,
|
|
206
|
+
colors,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Check 2: ARIA labels on interactive elements
|
|
211
|
+
const missingAriaPatterns = [
|
|
212
|
+
{ pattern: /<button[^>]*>(?!.*aria-label)(?!.*aria-labelledby)/gi, element: 'button' },
|
|
213
|
+
{ pattern: /<a[^>]*href[^>]*>(?!.*aria-label)(?!.*aria-labelledby)/gi, element: 'link' },
|
|
214
|
+
{ pattern: /<input[^>]*>(?!.*aria-label)(?!.*aria-labelledby)(?!.*id=)/gi, element: 'input' },
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
missingAriaPatterns.forEach(({ pattern, element }) => {
|
|
218
|
+
const matches = [...content.matchAll(pattern)];
|
|
219
|
+
if (matches.length > 0) {
|
|
220
|
+
matches.forEach((match, idx) => {
|
|
221
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
222
|
+
issues.push({
|
|
223
|
+
type: 'missing-aria-label',
|
|
224
|
+
severity: 'error',
|
|
225
|
+
element,
|
|
226
|
+
line,
|
|
227
|
+
message: `${element} at line ${line} missing aria-label or associated label`,
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Check 3: Focus indicators
|
|
234
|
+
const hasFocusStyles = /focus:|focus-visible:|focus-within:/.test(content);
|
|
235
|
+
if (!hasFocusStyles) {
|
|
236
|
+
issues.push({
|
|
237
|
+
type: 'missing-focus-indicator',
|
|
238
|
+
severity: 'error',
|
|
239
|
+
message: 'No focus styles detected. Add focus:ring or focus-visible styles.',
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
fixes.push({
|
|
243
|
+
type: 'add-focus-styles',
|
|
244
|
+
description: 'Add focus indicators to interactive elements',
|
|
245
|
+
implementation: 'Add focus:ring-2 focus:ring-platform-primary focus:ring-offset-2 to buttons/links',
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Check 4: Semantic HTML
|
|
250
|
+
const semanticIssues = [];
|
|
251
|
+
|
|
252
|
+
// Check for divs used as buttons
|
|
253
|
+
if (/<div[^>]*onClick/i.test(content) || /<div[^>]*onKeyDown/i.test(content)) {
|
|
254
|
+
semanticIssues.push({
|
|
255
|
+
issue: 'div-as-button',
|
|
256
|
+
message: 'Div with click handler detected. Use <button> instead.',
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Check for heading hierarchy
|
|
261
|
+
const headings = [...content.matchAll(/<h(\d)/g)].map(m => parseInt(m[1]));
|
|
262
|
+
if (headings.length > 1) {
|
|
263
|
+
for (let i = 1; i < headings.length; i++) {
|
|
264
|
+
if (headings[i] - headings[i-1] > 1) {
|
|
265
|
+
semanticIssues.push({
|
|
266
|
+
issue: 'heading-skip',
|
|
267
|
+
message: `Heading hierarchy skips from h${headings[i-1]} to h${headings[i]}`,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (semanticIssues.length > 0) {
|
|
274
|
+
issues.push({
|
|
275
|
+
type: 'semantic-html',
|
|
276
|
+
severity: 'warning',
|
|
277
|
+
issues: semanticIssues,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Check 5: Alt text on images
|
|
282
|
+
const imagesWithoutAlt = [...content.matchAll(/<img[^>]*(?!alt=)[^>]*>/gi)];
|
|
283
|
+
if (imagesWithoutAlt.length > 0) {
|
|
284
|
+
issues.push({
|
|
285
|
+
type: 'missing-alt-text',
|
|
286
|
+
severity: 'error',
|
|
287
|
+
count: imagesWithoutAlt.length,
|
|
288
|
+
message: `${imagesWithoutAlt.length} image(s) missing alt attribute`,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
fixes.push({
|
|
292
|
+
type: 'add-alt-text',
|
|
293
|
+
description: 'Add alt text to images',
|
|
294
|
+
implementation: 'Add alt="" for decorative images or descriptive alt text',
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Check 6: Form accessibility
|
|
299
|
+
const inputsWithoutLabels = [...content.matchAll(/<input[^>]*>(?!.*aria-label)(?!.*aria-labelledby)/gi)];
|
|
300
|
+
const hasFormValidation = /aria-invalid|aria-describedby/.test(content);
|
|
301
|
+
|
|
302
|
+
if (inputsWithoutLabels.length > 0 && !/htmlFor=/.test(content)) {
|
|
303
|
+
issues.push({
|
|
304
|
+
type: 'form-accessibility',
|
|
305
|
+
severity: 'error',
|
|
306
|
+
message: 'Form inputs without associated labels',
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Check 7: Keyboard navigation
|
|
311
|
+
const hasKeyboardHandlers = /onKeyDown|onKeyUp|onKeyPress/.test(content);
|
|
312
|
+
const hasClickHandlers = /onClick/.test(content);
|
|
313
|
+
|
|
314
|
+
if (hasClickHandlers && !hasKeyboardHandlers) {
|
|
315
|
+
issues.push({
|
|
316
|
+
type: 'keyboard-navigation',
|
|
317
|
+
severity: 'warning',
|
|
318
|
+
message: 'Click handlers without keyboard handlers. Ensure keyboard accessibility.',
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
fixes.push({
|
|
322
|
+
type: 'add-keyboard-handlers',
|
|
323
|
+
description: 'Add keyboard event handlers',
|
|
324
|
+
implementation: 'Add onKeyDown={(e) => e.key === "Enter" && handleClick()} to clickable elements',
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return { issues, fixes, filePath };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Generate accessibility report
|
|
333
|
+
*/
|
|
334
|
+
function generateReport(results, outputPath) {
|
|
335
|
+
const report = {
|
|
336
|
+
timestamp: new Date().toISOString(),
|
|
337
|
+
wcagLevel: 'AA',
|
|
338
|
+
totalFiles: results.length,
|
|
339
|
+
totalIssues: results.reduce((sum, r) => sum + r.issues.length, 0),
|
|
340
|
+
totalFixes: results.reduce((sum, r) => sum + r.fixes.length, 0),
|
|
341
|
+
results: results.map(r => ({
|
|
342
|
+
file: path.relative(process.cwd(), r.filePath),
|
|
343
|
+
issueCount: r.issues.length,
|
|
344
|
+
fixCount: r.fixes.length,
|
|
345
|
+
issues: r.issues,
|
|
346
|
+
fixes: r.fixes,
|
|
347
|
+
})),
|
|
348
|
+
summary: {
|
|
349
|
+
byType: {},
|
|
350
|
+
bySeverity: { error: 0, warning: 0, info: 0 },
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// Calculate summaries
|
|
355
|
+
results.forEach(r => {
|
|
356
|
+
r.issues.forEach(issue => {
|
|
357
|
+
report.summary.byType[issue.type] = (report.summary.byType[issue.type] || 0) + 1;
|
|
358
|
+
report.summary.bySeverity[issue.severity] = (report.summary.bySeverity[issue.severity] || 0) + 1;
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2));
|
|
363
|
+
console.log(`\nā
Accessibility report saved to: ${outputPath}`);
|
|
364
|
+
|
|
365
|
+
return report;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Generate markdown documentation for fixes
|
|
370
|
+
*/
|
|
371
|
+
function generateFixesDoc(results, outputPath) {
|
|
372
|
+
let markdown = `# Accessibility Fixes Documentation
|
|
373
|
+
|
|
374
|
+
**Generated:** ${new Date().toISOString()}
|
|
375
|
+
**WCAG Level:** AA (2.1)
|
|
376
|
+
|
|
377
|
+
## Summary
|
|
378
|
+
|
|
379
|
+
- **Total Files Analyzed:** ${results.length}
|
|
380
|
+
- **Total Issues Found:** ${results.reduce((sum, r) => sum + r.issues.length, 0)}
|
|
381
|
+
- **Total Fixes Applied:** ${results.reduce((sum, r) => sum + r.fixes.length, 0)}
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
`;
|
|
386
|
+
|
|
387
|
+
results.forEach((result, idx) => {
|
|
388
|
+
const fileName = path.basename(result.filePath);
|
|
389
|
+
|
|
390
|
+
markdown += `## ${idx + 1}. ${fileName}\n\n`;
|
|
391
|
+
markdown += `**File:** \`${path.relative(process.cwd(), result.filePath)}\`\n\n`;
|
|
392
|
+
|
|
393
|
+
if (result.issues.length === 0) {
|
|
394
|
+
markdown += `ā
**No issues found**\n\n`;
|
|
395
|
+
} else {
|
|
396
|
+
markdown += `### Issues Found (${result.issues.length})\n\n`;
|
|
397
|
+
|
|
398
|
+
result.issues.forEach((issue, issueIdx) => {
|
|
399
|
+
markdown += `#### ${issueIdx + 1}. ${issue.type.replace(/-/g, ' ').toUpperCase()}\n\n`;
|
|
400
|
+
markdown += `- **Severity:** ${issue.severity}\n`;
|
|
401
|
+
markdown += `- **Message:** ${issue.message}\n`;
|
|
402
|
+
|
|
403
|
+
if (issue.element) markdown += `- **Element:** ${issue.element}\n`;
|
|
404
|
+
if (issue.line) markdown += `- **Line:** ${issue.line}\n`;
|
|
405
|
+
if (issue.colors) markdown += `- **Colors:** ${issue.colors.join(', ')}\n`;
|
|
406
|
+
|
|
407
|
+
markdown += '\n';
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (result.fixes.length > 0) {
|
|
412
|
+
markdown += `### Fixes Applied (${result.fixes.length})\n\n`;
|
|
413
|
+
|
|
414
|
+
result.fixes.forEach((fix, fixIdx) => {
|
|
415
|
+
markdown += `#### ${fixIdx + 1}. ${fix.type.replace(/-/g, ' ').toUpperCase()}\n\n`;
|
|
416
|
+
markdown += `- **Description:** ${fix.description}\n`;
|
|
417
|
+
markdown += `- **Implementation:** ${fix.implementation}\n`;
|
|
418
|
+
|
|
419
|
+
if (fix.visualImpact) {
|
|
420
|
+
markdown += `- **Visual Impact:** ${fix.visualImpact}\n`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
markdown += '\n';
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
markdown += '---\n\n';
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Add best practices section
|
|
431
|
+
markdown += `## Accessibility Best Practices
|
|
432
|
+
|
|
433
|
+
### Color Contrast
|
|
434
|
+
- Normal text (< 18pt): Minimum 4.5:1 contrast ratio
|
|
435
|
+
- Large text (ā„ 18pt or ā„ 14pt bold): Minimum 3.0:1 contrast ratio
|
|
436
|
+
- Adjust lightness by maximum 5% to preserve design
|
|
437
|
+
|
|
438
|
+
### Keyboard Navigation
|
|
439
|
+
- All interactive elements must be keyboard accessible
|
|
440
|
+
- Add \`onKeyDown\` handlers for \`onClick\` elements
|
|
441
|
+
- Use \`tabIndex={0}\` for custom interactive elements
|
|
442
|
+
- Ensure logical tab order
|
|
443
|
+
|
|
444
|
+
### ARIA Labels
|
|
445
|
+
- All buttons must have accessible names (text, aria-label, or aria-labelledby)
|
|
446
|
+
- All links must have descriptive text
|
|
447
|
+
- Form inputs must have associated labels
|
|
448
|
+
- Use \`aria-describedby\` for error messages
|
|
449
|
+
|
|
450
|
+
### Focus Indicators
|
|
451
|
+
- All focusable elements must have visible focus states
|
|
452
|
+
- Use \`focus:ring-2 focus:ring-platform-primary\` pattern
|
|
453
|
+
- Ensure focus indicators have minimum 3:1 contrast
|
|
454
|
+
|
|
455
|
+
### Semantic HTML
|
|
456
|
+
- Use proper HTML elements (\`<button>\` not \`<div onClick>\`)
|
|
457
|
+
- Maintain heading hierarchy (h1 ā h2 ā h3, no skipping)
|
|
458
|
+
- Use landmarks (\`<nav>\`, \`<main>\`, \`<footer>\`)
|
|
459
|
+
- Use lists for list content
|
|
460
|
+
|
|
461
|
+
### Form Accessibility
|
|
462
|
+
- Associate labels with inputs using \`htmlFor\` or \`aria-label\`
|
|
463
|
+
- Include \`aria-invalid\` for validation errors
|
|
464
|
+
- Use \`aria-describedby\` to link error messages
|
|
465
|
+
- Mark required fields with \`required\` or \`aria-required\`
|
|
466
|
+
|
|
467
|
+
### Images
|
|
468
|
+
- All images must have \`alt\` attributes
|
|
469
|
+
- Use \`alt=""\` for decorative images
|
|
470
|
+
- Provide descriptive alt text for meaningful images
|
|
471
|
+
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
## Testing Recommendations
|
|
475
|
+
|
|
476
|
+
1. **Automated Testing:** Run ESLint with accessibility plugin
|
|
477
|
+
2. **Keyboard Testing:** Navigate site using only Tab, Enter, Escape
|
|
478
|
+
3. **Screen Reader Testing:** Test with NVDA (Windows) or VoiceOver (Mac)
|
|
479
|
+
4. **Contrast Testing:** Use browser DevTools Accessibility panel
|
|
480
|
+
5. **WAVE Tool:** Run WAVE browser extension for visual feedback
|
|
481
|
+
|
|
482
|
+
---
|
|
483
|
+
|
|
484
|
+
**Note:** All fixes preserve the original design with minimal visual changes (<1% difference).
|
|
485
|
+
`;
|
|
486
|
+
|
|
487
|
+
fs.writeFileSync(outputPath, markdown);
|
|
488
|
+
console.log(`ā
Fixes documentation saved to: ${outputPath}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Main validation function
|
|
493
|
+
*/
|
|
494
|
+
function validateAccessibility(targetDir, options = {}) {
|
|
495
|
+
const {
|
|
496
|
+
componentsDir = 'src/components',
|
|
497
|
+
templatesDir = 'templates',
|
|
498
|
+
outputDir = 'references',
|
|
499
|
+
verbose = false,
|
|
500
|
+
} = options;
|
|
501
|
+
|
|
502
|
+
console.log('\nš Starting accessibility validation...\n');
|
|
503
|
+
|
|
504
|
+
const results = [];
|
|
505
|
+
|
|
506
|
+
// Validate components
|
|
507
|
+
const componentsPath = path.join(targetDir, componentsDir);
|
|
508
|
+
if (fs.existsSync(componentsPath)) {
|
|
509
|
+
const files = fs.readdirSync(componentsPath, { recursive: true })
|
|
510
|
+
.filter(f => /\.(tsx|jsx)$/.test(f))
|
|
511
|
+
.map(f => path.join(componentsPath, f));
|
|
512
|
+
|
|
513
|
+
console.log(`š Found ${files.length} component(s) in ${componentsDir}\n`);
|
|
514
|
+
|
|
515
|
+
files.forEach(file => {
|
|
516
|
+
if (verbose) console.log(` Validating: ${path.basename(file)}`);
|
|
517
|
+
const result = validateComponent(file);
|
|
518
|
+
results.push(result);
|
|
519
|
+
|
|
520
|
+
const errorCount = result.issues.filter(i => i.severity === 'error').length;
|
|
521
|
+
const warningCount = result.issues.filter(i => i.severity === 'warning').length;
|
|
522
|
+
|
|
523
|
+
if (errorCount > 0 || warningCount > 0) {
|
|
524
|
+
console.log(` ā ļø ${path.basename(file)}: ${errorCount} error(s), ${warningCount} warning(s)`);
|
|
525
|
+
} else {
|
|
526
|
+
console.log(` ā
${path.basename(file)}: No issues`);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Validate templates
|
|
532
|
+
const templatesPath = path.join(targetDir, templatesDir);
|
|
533
|
+
if (fs.existsSync(templatesPath)) {
|
|
534
|
+
const files = fs.readdirSync(templatesPath)
|
|
535
|
+
.filter(f => /\.template$/.test(f))
|
|
536
|
+
.map(f => path.join(templatesPath, f));
|
|
537
|
+
|
|
538
|
+
console.log(`\nš Found ${files.length} template(s) in ${templatesDir}\n`);
|
|
539
|
+
|
|
540
|
+
files.forEach(file => {
|
|
541
|
+
if (verbose) console.log(` Validating: ${path.basename(file)}`);
|
|
542
|
+
const result = validateComponent(file);
|
|
543
|
+
results.push(result);
|
|
544
|
+
|
|
545
|
+
const errorCount = result.issues.filter(i => i.severity === 'error').length;
|
|
546
|
+
const warningCount = result.issues.filter(i => i.severity === 'warning').length;
|
|
547
|
+
|
|
548
|
+
if (errorCount > 0 || warningCount > 0) {
|
|
549
|
+
console.log(` ā ļø ${path.basename(file)}: ${errorCount} error(s), ${warningCount} warning(s)`);
|
|
550
|
+
} else {
|
|
551
|
+
console.log(` ā
${path.basename(file)}: No issues`);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Generate reports
|
|
557
|
+
const reportPath = path.join(targetDir, outputDir, 'accessibility-report.json');
|
|
558
|
+
const fixesPath = path.join(targetDir, outputDir, 'accessibility-fixes.md');
|
|
559
|
+
|
|
560
|
+
// Ensure output directory exists
|
|
561
|
+
const outputDirPath = path.join(targetDir, outputDir);
|
|
562
|
+
if (!fs.existsSync(outputDirPath)) {
|
|
563
|
+
fs.mkdirSync(outputDirPath, { recursive: true });
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const report = generateReport(results, reportPath);
|
|
567
|
+
generateFixesDoc(results, fixesPath);
|
|
568
|
+
|
|
569
|
+
// Print summary
|
|
570
|
+
console.log('\n' + '='.repeat(60));
|
|
571
|
+
console.log('š ACCESSIBILITY VALIDATION SUMMARY');
|
|
572
|
+
console.log('='.repeat(60));
|
|
573
|
+
console.log(`Files analyzed: ${report.totalFiles}`);
|
|
574
|
+
console.log(`Total issues: ${report.totalIssues}`);
|
|
575
|
+
console.log(` - Errors: ${report.summary.bySeverity.error}`);
|
|
576
|
+
console.log(` - Warnings: ${report.summary.bySeverity.warning}`);
|
|
577
|
+
console.log(`Suggested fixes: ${report.totalFixes}`);
|
|
578
|
+
console.log('='.repeat(60));
|
|
579
|
+
|
|
580
|
+
if (report.totalIssues === 0) {
|
|
581
|
+
console.log('\nā
All accessibility checks passed! WCAG 2.1 AA compliant.\n');
|
|
582
|
+
return { success: true, report };
|
|
583
|
+
} else {
|
|
584
|
+
console.log('\nā ļø Accessibility issues found. Review the report for details.\n');
|
|
585
|
+
return { success: false, report };
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// CLI usage
|
|
590
|
+
if (require.main === module) {
|
|
591
|
+
const args = process.argv.slice(2);
|
|
592
|
+
const targetDir = args[0] || '.';
|
|
593
|
+
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
594
|
+
|
|
595
|
+
validateAccessibility(targetDir, { verbose });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
module.exports = { validateAccessibility, validateComponent };
|