/** * a11y Audit — Basic WCAG Accessibility Checker * * Simple HTML string audit for common accessibility violations. * Uses regex-based detection (lightweight, no DOM parser required). * * @module shared/utils/a11yAudit */ /** WCAG rule identifiers */ export const WCAG_RULES = { IMAGE_ALT: "image-alt", ARIA_LABEL: "aria-label", DIALOG_ROLE: "dialog-role", COLOR_CONTRAST: "color-contrast", FOCUS_TRAP: "focus-trap", HEADING_ORDER: "heading-order", }; /** * @typedef {Object} Violation * @property {string} id - Rule identifier from WCAG_RULES * @property {string} description - Human-readable description * @property {string} impact - "critical" | "serious" | "moderate" | "minor" * @property {string} help - Remediation guidance * @property {Array<{html: string}>} nodes - Offending elements */ /** * Audit an HTML string for common accessibility violations. * * @param {string} html - HTML string to audit * @returns {Violation[]} List of violations found */ export function auditHTML(html) { const violations = []; // Check images without alt text const imgMatches = html.match(/]*>/gi) || []; for (const img of imgMatches) { if (!/\balt\s*=/i.test(img)) { violations.push({ id: WCAG_RULES.IMAGE_ALT, description: "Images must have alternate text", impact: "critical", help: "Add an alt attribute to the element", nodes: [{ html: img }], }); } } // Check dialogs/modals without role const modalMatches = html.match(/]*class="[^"]*modal[^"]*"[^>]*>/gi) || []; for (const modal of modalMatches) { if (!/\brole\s*=/i.test(modal)) { violations.push({ id: WCAG_RULES.DIALOG_ROLE, description: 'Dialogs must have role="dialog"', impact: "serious", help: 'Add role="dialog" and aria-modal="true" to the modal container', nodes: [{ html: modal }], }); } } return violations; } /** * Parse a hex color string to RGB components. * @param {string} hex - Color in #RGB, #RRGGBB, or #RRGGBBAA format * @returns {{ r: number, g: number, b: number }|null} */ function parseHexColor(hex) { if (!hex || typeof hex !== "string") return null; const clean = hex.replace(/^#/, ""); let r, g, b; if (clean.length === 3) { r = parseInt(clean[0] + clean[0], 16); g = parseInt(clean[1] + clean[1], 16); b = parseInt(clean[2] + clean[2], 16); } else if (clean.length === 6 || clean.length === 8) { r = parseInt(clean.slice(0, 2), 16); g = parseInt(clean.slice(2, 4), 16); b = parseInt(clean.slice(4, 6), 16); } else { return null; } return { r, g, b }; } /** * Compute relative luminance per WCAG 2.x specification. * @param {{ r: number, g: number, b: number }} rgb * @returns {number} Relative luminance (0..1) */ function relativeLuminance({ r, g, b }) { const [sR, sG, sB] = [r, g, b].map((c) => { const v = c / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126 * sR + 0.7152 * sG + 0.0722 * sB; } /** * Get the contrast ratio between two hex colors. * @param {string} fgHex - Foreground color (#RRGGBB) * @param {string} bgHex - Background color (#RRGGBB) * @returns {number} Contrast ratio (1..21) */ export function getContrastRatio(fgHex, bgHex) { const fg = parseHexColor(fgHex); const bg = parseHexColor(bgHex); if (!fg || !bg) return 0; const l1 = relativeLuminance(fg); const l2 = relativeLuminance(bg); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); } /** * Check WCAG AA contrast compliance between foreground and background colors. * * @param {string} fgHex - Foreground color (#RRGGBB) * @param {string} bgHex - Background color (#RRGGBB) * @param {{ largeText?: boolean }} [options={}] - Options * @returns {{ ratio: number, aa: boolean, aaa: boolean }} */ export function checkContrast(fgHex, bgHex, options: any = {}) { const ratio = getContrastRatio(fgHex, bgHex); const minAA = options.largeText ? 3 : 4.5; const minAAA = options.largeText ? 4.5 : 7; return { ratio: Math.round(ratio * 100) / 100, aa: ratio >= minAA, aaa: ratio >= minAAA, }; } /** * Generate a summary report from a list of violations. * * @param {Violation[]} violations * @returns {{ total: number, critical: number, serious: number, moderate: number, minor: number, passed: boolean }} */ export function generateReport(violations) { return { total: violations.length, critical: violations.filter((v) => v.impact === "critical").length, serious: violations.filter((v) => v.impact === "serious").length, moderate: violations.filter((v) => v.impact === "moderate").length, minor: violations.filter((v) => v.impact === "minor").length, passed: violations.length === 0, }; }