| |
| |
| |
| |
| |
| |
| |
|
|
|
|
|
|
| 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",
|
| };
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| export function auditHTML(html) {
|
| const violations = [];
|
|
|
|
|
| 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 }],
|
| });
|
| }
|
| }
|
|
|
|
|
| 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;
|
| }
|
|
|
| |
| |
| |
| |
|
|
| 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 };
|
| }
|
|
|
| |
| |
| |
| |
|
|
| 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;
|
| }
|
|
|
| |
| |
| |
| |
| |
|
|
| 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);
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| 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,
|
| };
|
| }
|
|
|
| |
| |
| |
| |
| |
|
|
| 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,
|
| };
|
| }
|
|
|