Spaces:
Runtime error
Runtime error
| /** | |
| * 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(/<img\b[^>]*>/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 <img> element", | |
| nodes: [{ html: img }], | |
| }); | |
| } | |
| } | |
| // Check dialogs/modals without role | |
| const modalMatches = html.match(/<div\b[^>]*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, | |
| }; | |
| } | |