Some checks failed
Architecture Lint / Lint Repository (push) Has been cancelled
Architecture Lint / Linter Tests (push) Has been cancelled
Smoke Test / smoke (push) Has been cancelled
Validate Config / YAML Lint (push) Has been cancelled
Validate Config / JSON Validate (push) Has been cancelled
Validate Config / Python Syntax & Import Check (push) Has been cancelled
Validate Config / Python Test Suite (push) Has been cancelled
Validate Config / Shell Script Lint (push) Has been cancelled
Validate Config / Cron Syntax Check (push) Has been cancelled
Validate Config / Deploy Script Dry Run (push) Has been cancelled
Validate Config / Playbook Schema Validation (push) Has been cancelled
Merge PR #556
152 lines
6.7 KiB
JavaScript
152 lines
6.7 KiB
JavaScript
// 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 <main> or role="main" found' });
|
|
}
|
|
if (landmarks.banner === 0) {
|
|
results.violations.push({ rule: '1.3.1', name: 'Banner Landmark', severity: 'low', detail: 'No <header> 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 <html>' });
|
|
}
|
|
|
|
// --- 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;
|
|
})();
|