UNPKG

design-agent

Version:

Universal AI Design Review Agent - CLI tool for scanning code for design drift

294 lines (253 loc) 8.88 kB
/** * 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; }