From 62c042aa4dd6506b3bfe396cad153c44c98f94c6 Mon Sep 17 00:00:00 2001 From: Hellings Date: Fri, 23 Oct 2026 09:12:49 -0400 Subject: [PATCH] better formatting --- README.md | 2 +- index.ts | 78 +++++++++++-- inspect.ts | 307 ++++++++++++++++++++++++++++++++++++++++++++------- package.json | 13 ++- 4 files changed, 345 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 95336bf..2d10e69 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# Static rendered analysis \ No newline at end of file +# Static rendered analysis diff --git a/index.ts b/index.ts index 2207c76..9612c70 100644 --- a/index.ts +++ b/index.ts @@ -24,33 +24,87 @@ export interface AuditOptions { includeDisclosures?: boolean; } -export async function analyzeHtml(html: string, kind: "accessible" | "overflow", options: AuditOptions = {}): Promise { +export async function analyzeHtml( + html: string, + kind: "accessible" | "overflow", + options: AuditOptions = {}, +): Promise { const browser = await chromium.launch(); try { const reports: AuditReport[] = []; - for (const viewport of options.viewports ?? [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) { - const context = await browser.newContext({ viewport, javaScriptEnabled: false }); + for (const viewport of options.viewports ?? [ + { width: 1440, height: 900 }, + { width: 390, height: 844 }, + ]) { + const context = await browser.newContext({ + viewport, + javaScriptEnabled: false, + }); try { const page = await context.newPage(); await page.route("**/*", (route) => route.abort()); await page.setContent(html, { waitUntil: "load" }); - const result = await page.evaluate(inspectDocument, { kind, ignore: options.ignore ?? [], disclosures: options.includeDisclosures ?? false }); + const result = await page.evaluate(inspectDocument, { + kind, + ignore: options.ignore ?? [], + disclosures: options.includeDisclosures ?? false, + }); const findings = result.findings as AuditFinding[]; - const score = Math.max(0, Math.round(100 - findings.reduce((sum, finding) => sum + (finding.severity === "error" ? 10 : finding.severity === "warning" ? 4 : 2), 0))); - const report: AuditReport = { viewport, findings, score, checked: result.checked, method: "static-rendered-heuristic" }; + const score = Math.max( + 0, + Math.round( + 100 - + findings.reduce( + (sum, finding) => + sum + + (finding.severity === "error" + ? 10 + : finding.severity === "warning" + ? 4 + : 2), + 0, + ), + ), + ); + const report: AuditReport = { + viewport, + findings, + score, + checked: result.checked, + method: "static-rendered-heuristic", + }; reports.push(report); - if (options.failBelow !== undefined && score < options.failBelow) throw new Error(`${kind} score ${score} below ${options.failBelow} at ${viewport.width}px`); - if (options.failOnError && findings.some((finding) => finding.severity === "error")) throw new Error(`${kind} audit found errors at ${viewport.width}px`); - } finally { await context.close(); } + if (options.failBelow !== undefined && score < options.failBelow) { + throw new Error( + `${kind} score ${score} below ${options.failBelow} at ${viewport.width}px`, + ); + } + if ( + options.failOnError && + findings.some((finding) => finding.severity === "error") + ) { + throw new Error(`${kind} audit found errors at ${viewport.width}px`); + } + } finally { + await context.close(); + } } return reports; - } finally { await browser.close(); } + } finally { + await browser.close(); + } } export function accessibleIntegration(options: AuditOptions = {}): Integration { - return { name: "accessible", analyze: (page) => analyzeHtml(page.html, "accessible", options) }; + return { + name: "accessible", + analyze: (page) => analyzeHtml(page.html, "accessible", options), + }; } export function overflowIntegration(options: AuditOptions = {}): Integration { - return { name: "overflow", analyze: (page) => analyzeHtml(page.html, "overflow", options) }; + return { + name: "overflow", + analyze: (page) => analyzeHtml(page.html, "overflow", options), + }; } diff --git a/inspect.ts b/inspect.ts index d159e94..4fbea91 100644 --- a/inspect.ts +++ b/inspect.ts @@ -1,9 +1,22 @@ -/** Runs inside the browser against static DOM and resolved CSS, without executing page scripts. */ -export function inspectDocument(options: { kind: "accessible" | "overflow"; ignore: string[]; disclosures: boolean }) { +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 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; + 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[] = []; @@ -14,62 +27,282 @@ export function inspectDocument(options: { kind: "accessible" | "overflow"; igno } 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 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 <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4; }).reduce((sum, value, index) => sum + value * [.2126, .7152, .0722][index]!, 0); + 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; + 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 }); + 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 }); + 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()); + 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); } + 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 }); + 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 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) + .05) / (Math.min(l1, l2) + .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 < .5) add(node, "low-opacity", "Effective ancestor opacity reduces text visibility", { opacity }, "warning"); + 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); } + 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: {} }); + 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 }; } diff --git a/package.json b/package.json index 8062ef9..066a29b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,10 @@ { - "name": "@muddle/analysis", - "version": "0.1.0", - "type": "module", - "exports": "./index.ts", - "peerDependencies": { "muddle": "*", "playwright": "^1.0.0" } + "name": "@muddle/analysis", + "version": "0.1.0", + "type": "module", + "exports": "./index.ts", + "peerDependencies": { + "muddle": "*", + "playwright": "^1.0.0" + } }