export function inspectDocument(options: { kind: "accessible" | "overflow"; ignore: string[]; disclosures: boolean; }) { const { document, getComputedStyle, innerWidth } = globalThis as any; const findings: { rule: string; selector: string; severity: "error" | "warning" | "review"; message: string; evidence: Record; }[] = []; const elements = [...document.querySelectorAll("body *")]; if (options.disclosures) { for (const element of elements) { if (element.tagName === "DETAILS") element.open = true; } } const selector = (node: any): string => { if (node.id) return `[id=${JSON.stringify(node.id)}]`; const parts: string[] = []; while (node && node.tagName !== "BODY") { const index = [...node.parentElement.children].indexOf(node) + 1; parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`); node = node.parentElement; } return `body > ${parts.join(" > ")}`; }; const add = ( node: any, rule: string, message: string, evidence: Record = {}, severity: "error" | "warning" | "review" = "error", ) => findings.push({ selector: selector(node), rule, message, evidence, severity, }); const canvas = document.createElement("canvas"); canvas.width = canvas.height = 1; const ctx = canvas.getContext("2d", { willReadFrequently: true }); const color = (value: string): number[] => { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = value; ctx.fillRect(0, 0, 1, 1); return [...ctx.getImageData(0, 0, 1, 1).data]; }; const composite = (front: number[], back: number[]): number[] => front .slice(0, 3) .map( (channel, index) => (channel * front[3]!) / 255 + back[index]! * (1 - front[3]! / 255), ) .concat(255); const luminance = (rgb: number[]): number => rgb .slice(0, 3) .map((value) => { const c = value / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }) .reduce( (sum, value, index) => sum + value * [0.2126, 0.7152, 0.0722][index]!, 0, ); let checked = 0; for (const node of elements) { if (options.ignore.some((ignore) => node.closest(ignore))) continue; const style = getComputedStyle(node); const box = node.getBoundingClientRect(); if ( !box.width || !box.height || style.display === "none" || style.visibility !== "visible" || node.closest("[hidden], [inert]") ) continue; checked++; if (options.kind === "overflow") { const clippedX = ["hidden", "clip"].includes(style.overflowX) && node.scrollWidth > node.clientWidth + 1; const clippedY = ["hidden", "clip"].includes(style.overflowY) && node.scrollHeight > node.clientHeight + 1; const truncated = style.textOverflow === "ellipsis" || (style.webkitLineClamp !== "none" && Number(style.webkitLineClamp) > 0); if ((clippedX || clippedY) && !truncated) { add(node, "clipping", "Content exceeds a clipping boundary", { overflowX: style.overflowX, overflowY: style.overflowY, clientWidth: node.clientWidth, scrollWidth: node.scrollWidth, clientHeight: node.clientHeight, scrollHeight: node.scrollHeight, }); } let managed = false; for ( let parent = node.parentElement; parent; parent = parent.parentElement ) { if ( ["auto", "scroll", "hidden", "clip"].includes( getComputedStyle(parent).overflowX, ) ) { managed = true; break; } } if (!managed && (box.right > innerWidth + 1 || box.left < -1)) { add( node, "viewport-overflow", "Element extends outside the viewport without a containing scroll region", { left: box.left, right: box.right, viewportWidth: innerWidth, }, ); } continue; } const label = node.getAttribute("aria-label") || (node.getAttribute("aria-labelledby") || "") .split(/\s+/) .map((id: string) => document.getElementById(id)?.textContent || "") .join("") .trim() || [...(node.labels || [])] .map((label: any) => label.textContent) .join("") .trim(); for (const id of (node.getAttribute("aria-labelledby") || "") .split(/\s+/) .filter(Boolean)) { if (!document.getElementById(id)) { add( node, "aria-reference", "aria-labelledby references a missing element", { id }, ); } } if ( ["A", "BUTTON", "INPUT", "SELECT"].includes(node.tagName) && !node.disabled && (box.width < 24 || box.height < 24) ) { add( node, "target-size", "Interactive target is smaller than 24 CSS pixels; spacing exceptions require review", { width: box.width, height: box.height }, "review", ); } if (node.tagName === "IMG" && !node.hasAttribute("alt")) { add(node, "image-alt", "Image lacks an alt attribute"); } if ( ["INPUT", "SELECT", "TEXTAREA"].includes(node.tagName) && !["hidden", "submit", "button", "reset"].includes(node.type) && !label ) add(node, "control-name", "Form control lacks a programmatic label"); if ( ["A", "BUTTON"].includes(node.tagName) && !label && !node.textContent.trim() && !node.querySelector("img[alt]") ) { add(node, "control-name", "Interactive element lacks an accessible name"); } const ownText = [...node.childNodes].some( (child: any) => child.nodeType === 3 && child.textContent.trim(), ); if (!ownText) continue; const chain: any[] = []; let opacity = 1; for (let parent = node; parent; parent = parent.parentElement) { chain.unshift(parent); opacity *= Number(getComputedStyle(parent).opacity); } let background = [255, 255, 255, 255]; const effects: Record[] = []; for (const parent of chain) { const css = getComputedStyle(parent); background = composite(color(css.backgroundColor), background); if ( css.filter !== "none" || css.backdropFilter !== "none" || css.mixBlendMode !== "normal" || css.backgroundImage !== "none" || css.clipPath !== "none" || css.maskImage !== "none" ) { effects.push({ selector: selector(parent), filter: css.filter, backdropFilter: css.backdropFilter, blend: css.mixBlendMode, backgroundImage: css.backgroundImage, clipPath: css.clipPath, maskImage: css.maskImage, }); } } const foreground = color(style.color); foreground[3] = foreground[3]! * opacity; const text = composite(foreground, background); const l1 = luminance(text), l2 = luminance(background); const contrast = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); const threshold = parseFloat(style.fontSize) >= 24 || (parseFloat(style.fontSize) >= 18.667 && Number(style.fontWeight) >= 700) ? 3 : 4.5; if (effects.length) { add( node, "visibility-effects", "Filters, backdrop effects or masking require pixel-level review; nominal contrast is not conclusive", { effects, opacity, nominalContrast: contrast }, "review", ); } if (contrast < threshold) { add( node, "text-contrast", "Estimated text contrast falls below the configured text threshold", { contrast, threshold, opacity, estimate: effects.length > 0 }, effects.length ? "review" : "error", ); } if (opacity < 0.5) { add( node, "low-opacity", "Effective ancestor opacity reduces text visibility", { opacity }, "warning", ); } } if (options.kind === "accessible") { const ids = new Set(); for (const node of document.querySelectorAll("[id]")) { if (ids.has(node.id)) { add( node, "duplicate-id", "Duplicate identifiers make references ambiguous", { id: node.id }, ); } ids.add(node.id); } let previous = 0; for (const heading of document.querySelectorAll("h1,h2,h3,h4,h5,h6")) { const level = Number(heading.tagName.slice(1)); if (previous && level > previous + 1) { add( heading, "heading-order", "Heading hierarchy skips a level", { previous, level }, "warning", ); } previous = level; } if (!document.documentElement.lang) { findings.push({ selector: "html", rule: "document-language", severity: "error", message: "Document language is missing", evidence: {}, }); } if (!document.title.trim()) { findings.push({ selector: "head", rule: "document-title", severity: "error", message: "Document title is missing", evidence: {}, }); } } return { findings, checked }; }