// a11y-check.js — Automated accessibility audit script for Foundation web properties // Run in browser console or via Playwright/Puppeteer // // Usage: Paste into DevTools console, or include in automated test suite. // Returns a JSON object with pass/fail for WCAG 2.1 AA checks. (function a11yAudit() { const results = { timestamp: new Date().toISOString(), url: window.location.href, title: document.title, violations: [], passes: [], warnings: [] }; // --- 2.4.1 Skip Navigation --- const skipLink = document.querySelector('a[href="#main"], a[href="#content"], .skip-nav, .skip-link'); if (skipLink) { results.passes.push({ rule: '2.4.1', name: 'Skip Navigation', detail: 'Skip link found' }); } else { results.violations.push({ rule: '2.4.1', name: 'Skip Navigation', severity: 'medium', detail: 'No skip-to-content link found' }); } // --- 1.3.1 / 3.3.2 Form Labels --- const unlabeledInputs = Array.from(document.querySelectorAll('input, select, textarea')).filter(el => { if (el.type === 'hidden') return false; const id = el.id; const hasLabel = id && document.querySelector(`label[for="${id}"]`); const hasAriaLabel = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby'); const hasTitle = el.getAttribute('title'); const hasPlaceholder = el.getAttribute('placeholder'); // placeholder alone is NOT sufficient return !hasLabel && !hasAriaLabel && !hasTitle; }); if (unlabeledInputs.length === 0) { results.passes.push({ rule: '3.3.2', name: 'Form Labels', detail: 'All inputs have labels' }); } else { results.violations.push({ rule: '3.3.2', name: 'Form Labels', severity: 'high', detail: `${unlabeledInputs.length} inputs without programmatic labels`, elements: unlabeledInputs.map(el => ({ tag: el.tagName, type: el.type, name: el.name, id: el.id })) }); } // --- 1.4.3 Contrast (heuristic: very light text colors) --- const lowContrast = Array.from(document.querySelectorAll('p, span, a, li, td, th, label, small, footer *')).filter(el => { const style = getComputedStyle(el); const color = style.color; // Check for very light RGB values (r/g/b < 120) const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (!match) return false; const [, r, g, b] = match.map(Number); return r < 120 && g < 120 && b < 120 && (r + g + b) < 200; }); if (lowContrast.length === 0) { results.passes.push({ rule: '1.4.3', name: 'Contrast', detail: 'No obviously low-contrast text found' }); } else { results.warnings.push({ rule: '1.4.3', name: 'Contrast', detail: `${lowContrast.length} elements with potentially low contrast (manual verification needed)` }); } // --- 1.3.1 Heading Hierarchy --- const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')).map(h => ({ level: parseInt(h.tagName[1]), text: h.textContent.trim().substring(0, 80) })); let headingIssues = []; let lastLevel = 0; for (const h of headings) { if (h.level > lastLevel + 1 && lastLevel > 0) { headingIssues.push(`Skipped h${lastLevel} to h${h.level}: "${h.text}"`); } lastLevel = h.level; } if (headingIssues.length === 0 && headings.length > 0) { results.passes.push({ rule: '1.3.1', name: 'Heading Hierarchy', detail: `${headings.length} headings, proper nesting` }); } else if (headingIssues.length > 0) { results.violations.push({ rule: '1.3.1', name: 'Heading Hierarchy', severity: 'low', detail: headingIssues.join('; ') }); } // --- 1.3.1 Landmarks --- const landmarks = { main: document.querySelectorAll('main, [role="main"]').length, nav: document.querySelectorAll('nav, [role="navigation"]').length, banner: document.querySelectorAll('header, [role="banner"]').length, contentinfo: document.querySelectorAll('footer, [role="contentinfo"]').length }; if (landmarks.main > 0) { results.passes.push({ rule: '1.3.1', name: 'Main Landmark', detail: 'Found' }); } else { results.violations.push({ rule: '1.3.1', name: 'Main Landmark', severity: 'medium', detail: 'No
or role="main" found' }); } if (landmarks.banner === 0) { results.violations.push({ rule: '1.3.1', name: 'Banner Landmark', severity: 'low', detail: 'No
or role="banner" found' }); } // --- 3.3.1 Required Fields --- const requiredInputs = document.querySelectorAll('input[required], input[aria-required="true"]'); if (requiredInputs.length > 0) { results.passes.push({ rule: '3.3.1', name: 'Required Fields', detail: `${requiredInputs.length} inputs marked as required` }); } else { const visualRequired = document.querySelector('.required, [class*="required"], label .text-danger'); if (visualRequired) { results.warnings.push({ rule: '3.3.1', name: 'Required Fields', detail: 'Visual indicators found but no aria-required attributes' }); } } // --- 2.4.2 Page Title --- if (document.title && document.title.trim().length > 0) { results.passes.push({ rule: '2.4.2', name: 'Page Title', detail: document.title }); } else { results.violations.push({ rule: '2.4.2', name: 'Page Title', severity: 'medium', detail: 'Page has no title' }); } // --- 3.1.1 Language --- const lang = document.documentElement.lang; if (lang) { results.passes.push({ rule: '3.1.1', name: 'Language', detail: lang }); } else { results.violations.push({ rule: '3.1.1', name: 'Language', severity: 'medium', detail: 'No lang attribute on ' }); } // --- Images without alt --- const imgsNoAlt = Array.from(document.querySelectorAll('img:not([alt])')); if (imgsNoAlt.length === 0) { results.passes.push({ rule: '1.1.1', name: 'Image Alt Text', detail: 'All images have alt attributes' }); } else { results.violations.push({ rule: '1.1.1', name: 'Image Alt Text', severity: 'high', detail: `${imgsNoAlt.length} images without alt attributes` }); } // --- Buttons without accessible names --- const emptyButtons = Array.from(document.querySelectorAll('button')).filter(b => { return !b.textContent.trim() && !b.getAttribute('aria-label') && !b.getAttribute('aria-labelledby') && !b.getAttribute('title'); }); if (emptyButtons.length === 0) { results.passes.push({ rule: '4.1.2', name: 'Button Names', detail: 'All buttons have accessible names' }); } else { results.violations.push({ rule: '4.1.2', name: 'Button Names', severity: 'medium', detail: `${emptyButtons.length} buttons without accessible names` }); } // Summary results.summary = { violations: results.violations.length, passes: results.passes.length, warnings: results.warnings.length }; console.log(JSON.stringify(results, null, 2)); return results; })();