design-agent
Version:
Universal AI Design Review Agent - CLI tool for scanning code for design drift
294 lines (253 loc) • 8.88 kB
JavaScript
/**
* Accessibility checking utilities for design agent
* Validates WCAG compliance and accessibility best practices
*/
export function checkAccessibility(content, filePath) {
const findings = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNumber = i + 1;
// Check for missing alt text
if (line.includes('<img') && !line.includes('alt=')) {
findings.push({
file: filePath,
line: lineNumber,
kind: 'accessibility',
value: line.trim(),
msg: 'Image missing alt text - required for screen readers',
severity: 'critical'
});
}
// Check for missing aria-label on interactive elements
if (line.includes('<button') && !line.includes('aria-label') && !line.includes('aria-labelledby')) {
findings.push({
file: filePath,
line: lineNumber,
kind: 'accessibility',
value: line.trim(),
msg: 'Button missing accessible name - add aria-label or visible text',
severity: 'major'
});
}
// Check for missing form labels
if (line.includes('<input') && !line.includes('aria-label') && !line.includes('aria-labelledby') && !line.includes('<label')) {
findings.push({
file: filePath,
line: lineNumber,
kind: 'accessibility',
value: line.trim(),
msg: 'Input missing label - add aria-label or associated label element',
severity: 'critical'
});
}
// Check for missing heading hierarchy
if (line.includes('<h') && !line.includes('h1') && !line.includes('h2') && !line.includes('h3')) {
const headingMatch = line.match(/<h([1-6])/);
if (headingMatch) {
const level = parseInt(headingMatch[1]);
if (level > 1) {
findings.push({
file: filePath,
line: lineNumber,
kind: 'accessibility',
value: line.trim(),
msg: `Heading h${level} should follow proper hierarchy - ensure h1 comes before h2, etc.`,
severity: 'minor'
});
}
}
}
// Check for missing focus indicators
if (line.includes('focus:') && !line.includes('outline') && !line.includes('ring')) {
findings.push({
file: filePath,
line: lineNumber,
kind: 'accessibility',
value: line.trim(),
msg: 'Focus styles should include visible focus indicators for keyboard navigation',
severity: 'major'
});
}
// Check for color-only information
if (line.includes('color:') && (line.includes('red') || line.includes('green') || line.includes('blue'))) {
findings.push({
file: filePath,
line: lineNumber,
msg: 'Information conveyed only through color - add additional visual indicators',
severity: 'major'
});
}
// Check for missing skip links
if (line.includes('<main') && !content.includes('skip') && !content.includes('skip-link')) {
findings.push({
file: filePath,
line: lineNumber,
kind: 'accessibility',
value: line.trim(),
msg: 'Consider adding skip links for keyboard navigation',
severity: 'minor'
});
}
}
return findings;
}
export function checkColorContrast(colors) {
const findings = [];
for (let i = 0; i < colors.length; i++) {
for (let j = i + 1; j < colors.length; j++) {
const contrast = calculateContrast(colors[i], colors[j]);
if (contrast.ratio < 4.5) {
findings.push({
kind: 'accessibility',
value: `${colors[i]} on ${colors[j]}`,
msg: `Insufficient color contrast ratio: ${contrast.ratio.toFixed(2)} (minimum 4.5:1)`,
severity: 'critical'
});
} else if (contrast.ratio < 7) {
findings.push({
kind: 'accessibility',
value: `${colors[i]} on ${colors[j]}`,
msg: `Color contrast ratio: ${contrast.ratio.toFixed(2)} (consider 7:1 for better accessibility)`,
severity: 'minor'
});
}
}
}
return findings;
}
export function checkKeyboardNavigation(content, filePath) {
const findings = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNumber = i + 1;
// Check for missing tabindex on interactive elements
if (line.includes('onClick') && !line.includes('tabIndex') && !line.includes('button') && !line.includes('a')) {
findings.push({
file: filePath,
line: lineNumber,
kind: 'accessibility',
value: line.trim(),
msg: 'Interactive element missing tabindex - ensure keyboard accessibility',
severity: 'major'
});
}
// Check for missing keyboard event handlers
if (line.includes('onClick') && !line.includes('onKeyDown') && !line.includes('onKeyPress')) {
findings.push({
file: filePath,
line: lineNumber,
kind: 'accessibility',
value: line.trim(),
msg: 'Interactive element missing keyboard event handlers',
severity: 'minor'
});
}
}
return findings;
}
export function checkScreenReaderSupport(content, filePath) {
const findings = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNumber = i + 1;
// Check for missing ARIA attributes
if (line.includes('role=') && !line.includes('aria-label') && !line.includes('aria-labelledby')) {
findings.push({
file: filePath,
line: lineNumber,
kind: 'accessibility',
value: line.trim(),
msg: 'Element with role missing accessible name',
severity: 'major'
});
}
// Check for missing ARIA states
if (line.includes('aria-expanded') && !line.includes('aria-controls')) {
findings.push({
file: filePath,
line: lineNumber,
kind: 'accessibility',
value: line.trim(),
msg: 'aria-expanded should be paired with aria-controls',
severity: 'minor'
});
}
// Check for missing ARIA live regions
if (line.includes('loading') || line.includes('error') || line.includes('success')) {
if (!line.includes('aria-live') && !line.includes('aria-atomic')) {
findings.push({
file: filePath,
line: lineNumber,
kind: 'accessibility',
value: line.trim(),
msg: 'Dynamic content should have ARIA live region attributes',
severity: 'minor'
});
}
}
}
return findings;
}
export function generateAccessibilityReport(findings) {
const critical = findings.filter(f => f.severity === 'critical').length;
const major = findings.filter(f => f.severity === 'major').length;
const minor = findings.filter(f => f.severity === 'minor').length;
let report = '## Accessibility Report\n\n';
report += `- **Critical Issues:** ${critical}\n`;
report += `- **Major Issues:** ${major}\n`;
report += `- **Minor Issues:** ${minor}\n\n`;
if (critical > 0) {
report += '### Critical Issues (Must Fix)\n\n';
findings.filter(f => f.severity === 'critical').forEach(f => {
report += `- **${f.file}:${f.line}** - ${f.msg}\n`;
});
report += '\n';
}
if (major > 0) {
report += '### Major Issues (Should Fix)\n\n';
findings.filter(f => f.severity === 'major').forEach(f => {
report += `- **${f.file}:${f.line}** - ${f.msg}\n`;
});
report += '\n';
}
if (minor > 0) {
report += '### Minor Issues (Consider Fixing)\n\n';
findings.filter(f => f.severity === 'minor').forEach(f => {
report += `- **${f.file}:${f.line}** - ${f.msg}\n`;
});
}
return report;
}
function calculateContrast(color1, color2) {
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
if (!rgb1 || !rgb2) {
return { ratio: 0, level: 'unknown' };
}
const lum1 = getLuminance(rgb1);
const lum2 = getLuminance(rgb2);
const ratio = (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05);
let level = 'fail';
if (ratio >= 7) level = 'AAA';
else if (ratio >= 4.5) level = 'AA';
else if (ratio >= 3) level = 'AA Large';
return { ratio, level };
}
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
function getLuminance(rgb) {
const [r, g, b] = [rgb.r, rgb.g, rgb.b].map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}