import axe from 'axe-core'; // Accessibility and observation projections computed from a faithfully rendered // same-origin page document (the hidden-iframe render in v1BrowserCommands). // // The accessibility projection runs an automated engine (axe-core) over the // rendered document and is deliberately framed as the automated-detectable // subset of WCAG. It never asserts full conformance: automated checks catch a // minority of accessibility barriers, so the payload always carries // conformance_claim:false and a caveat the Assistant must surface. const A11Y_AUTOMATED_CAVEAT = 'Automated checks detect only a subset of WCAG success criteria (commonly cited as a minority of all issues). ' + 'A clean automated pass is not a determination of full accessibility or legal/ADA compliance, and findings still ' + 'need human judgement. Report these as automated findings, never as a conformance or "fully compliant" claim.'; const A11Y_RUN_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice']; const MAX_NODES_PER_FINDING = 5; const MAX_FINDINGS = 50; const MAX_HTML_CHARS = 400; const MAX_OBSERVATION_NODES = 100; const MAX_TEXT_PREVIEW = 120; const MAX_RESOURCE_TIMING_ENTRIES = 80; const COMPUTED_STYLE_PROPERTIES = [ 'color', 'background-color', 'font-size', 'font-weight', 'line-height', 'font-family', 'display', 'visibility', 'opacity', 'text-align', ]; const NAMED_REGIONS: Record = { main: 'main, [role="main"]', nav: 'nav, [role="navigation"]', header: 'header, [role="banner"]', footer: 'footer, [role="contentinfo"]', aside: 'aside, [role="complementary"]', search: '[role="search"]', }; export interface PageProjectionParams { selector?: string; region?: string; } export interface RenderedTargetMismatch { detected: true; reason: string; evaluated_url: string; rendered_title: string; } // Detect that the rendered document is not the requested content — most // commonly the WordPress 404 template served when a non-public post (e.g. a // draft loaded via its public permalink) or a missing post is requested. // Returns undefined when the page looks like real content. Callers surface the // mismatch instead of accessibility/observation findings, which would otherwise // faithfully describe the 404 page rather than the intended target. export function detectRenderedTargetMismatch( frameDocument: Document, evaluatedUrl: string, ): RenderedTargetMismatch | undefined { const body = frameDocument.body; const is404Body = !!body && body.classList.contains('error404'); const title = (frameDocument.title || '').trim(); const titleLooksNotFound = /\b(page not found|not found|404)\b/i.test(title); if (!is404Body && !titleLooksNotFound) return undefined; return { detected: true, reason: is404Body ? 'The rendered page is the WordPress 404 template (body.error404), not the requested content. A non-public post (e.g. a draft) must be analyzed via its preview URL, or the post may not exist.' : `The rendered page title ("${title}") indicates a not-found page rather than the requested content.`, evaluated_url: evaluatedUrl, rendered_title: title, }; } export interface A11yFinding { id: string; impact: string | null; help: string; description: string; help_url: string; wcag_tags: string[]; source?: string; nodes: Array<{ target: string[]; html: string; failure_summary: string }>; } export interface A11yProjection { engine: { name: 'axe-core'; version: string }; scope: string; conformance_claim: false; caveat: string; summary: { violations: number; needs_review: number; passes: number; inapplicable: number; }; violations: A11yFinding[]; needs_review: A11yFinding[]; } export interface ResourceTimingProjection { summary: { count: number; total_transfer_size: number; total_encoded_body_size: number; total_decoded_body_size: number; slowest_duration_ms: number; }; entries: Array<{ url: string; initiator_type: string; duration_ms: number; transfer_size: number; encoded_body_size: number; decoded_body_size: number; start_time_ms: number; }>; } type AxeContext = Document | Element; function isElementNode(node: unknown): node is Element { return Boolean( node && typeof node === 'object' && (node as Node).nodeType === 1 && typeof (node as ParentNode).querySelectorAll === 'function', ); } function isDocumentNode(node: unknown): node is Document { return Boolean( node && typeof node === 'object' && (node as Node).nodeType === 9 && typeof (node as ParentNode).querySelectorAll === 'function', ); } function regionSelector(region: string): string { return NAMED_REGIONS[region] || region; } function resolveContext(doc: Document, params: PageProjectionParams): AxeContext { const selector = params.selector || (params.region ? regionSelector(params.region) : undefined); if (!selector) return doc; const scoped = doc.querySelector(selector); return scoped || doc; } function truncate(value: string, max: number): string { const text = value.replace(/\s+/g, ' ').trim(); return text.length > max ? `${text.slice(0, max)}…` : text; } function wcagTags(tags: string[]): string[] { return tags.filter(tag => tag.startsWith('wcag') || tag === 'best-practice'); } function mapFindings(results: axe.Result[]): A11yFinding[] { return results.slice(0, MAX_FINDINGS).map(result => ({ id: result.id, impact: result.impact ?? null, help: result.help, description: result.description, help_url: result.helpUrl, wcag_tags: wcagTags(result.tags), nodes: result.nodes.slice(0, MAX_NODES_PER_FINDING).map(node => ({ target: node.target.map(String), html: truncate(node.html, MAX_HTML_CHARS), failure_summary: node.failureSummary ? truncate(node.failureSummary, MAX_HTML_CHARS) : '', })), })); } // axe must run in the same JS realm as the document it analyses: a node from a // different realm (e.g. an iframe's contentDocument) fails axe's instanceof // checks. Callers analysing an iframe inject axe into that frame and pass its // `axe.run`; the default runs the bundled axe in the current realm (unit tests). export type AxeRunner = (context: AxeContext, options: axe.RunOptions) => Promise; const defaultAxeRunner: AxeRunner = (context, options) => axe.run(context, options); export async function analyzeAccessibility( context: AxeContext, params: PageProjectionParams = {}, runAxe: AxeRunner = defaultAxeRunner, ): Promise { const runContext = resolveContextFromNode(context, params); const results = await runAxe(runContext, { runOnly: { type: 'tag', values: A11Y_RUN_TAGS }, resultTypes: ['violations', 'incomplete'], }); const violations = mapFindings(results.violations); for (const finding of supplementalAccessibilityFindings(runContext)) { if (!violations.some(item => item.id === finding.id)) { violations.push(finding); } } return { engine: { name: 'axe-core', version: axe.version }, scope: `Automated WCAG 2.1 A/AA + best-practice checks (${A11Y_RUN_TAGS.join(', ')}) plus rendered-DOM explicit-label supplement; automated-detectable subset only.`, conformance_claim: false, caveat: A11Y_AUTOMATED_CAVEAT, summary: { violations: violations.length, needs_review: results.incomplete.length, passes: results.passes.length, inapplicable: results.inapplicable.length, }, violations, needs_review: mapFindings(results.incomplete), }; } function resolveContextFromNode(context: AxeContext, params: PageProjectionParams): AxeContext { if (isElementNode(context)) { return context; } return resolveContext(context, params); } function elementRoot(context: AxeContext, params: PageProjectionParams): ParentNode { if (isElementNode(context)) return context; return resolveContext(context, params); } function ownerWindow(node: ParentNode): (Window & typeof globalThis) | null { const doc = isDocumentNode(node) ? node : (node as Element).ownerDocument; return (doc?.defaultView as (Window & typeof globalThis) | null) ?? null; } function cssEscape(value: string): string { const css = typeof CSS !== 'undefined' ? CSS : undefined; return typeof css?.escape === 'function' ? css.escape(value) : value.replace(/["\\]/g, '\\$&'); } function accessibleName(element: Element): string { const ariaLabel = element.getAttribute('aria-label'); if (ariaLabel && ariaLabel.trim()) return ariaLabel.trim(); const labelledBy = element.getAttribute('aria-labelledby'); if (labelledBy) { const doc = element.ownerDocument; const names = labelledBy .split(/\s+/) .map(id => doc?.getElementById(id)?.textContent?.trim() || '') .filter(Boolean); if (names.length) return names.join(' '); } const tag = element.tagName.toLowerCase(); if (tag === 'img') { const alt = element.getAttribute('alt'); if (alt !== null) return alt.trim(); } if (element.id) { const label = element.ownerDocument?.querySelector(`label[for="${cssEscape(element.id)}"]`); if (label?.textContent?.trim()) return label.textContent.trim(); } const text = element.textContent?.trim(); if (text) return text; const title = element.getAttribute('title'); if (title && title.trim()) return title.trim(); return ''; } function describeElement(element: Element): Record { const tag = element.tagName.toLowerCase(); const name = accessibleName(element); const node: Record = { tag }; if (element.id) node.id = element.id; const role = element.getAttribute('role'); if (role) node.role = role; if (/^h[1-6]$/.test(tag)) node.level = Number(tag.slice(1)); if (tag === 'img') { node.src = element.getAttribute('src') || ''; node.alt = element.getAttribute('alt'); node.has_alt = element.hasAttribute('alt'); } if (tag === 'a') { node.href = element.getAttribute('href') || ''; } if (tag === 'input' || tag === 'textarea' || tag === 'select') { node.type = element.getAttribute('type') || tag; node.has_label = Boolean(name); } if (name) node.name = truncate(name, MAX_TEXT_PREVIEW); return node; } const DOM_OUTLINE_SELECTOR = 'h1, h2, h3, h4, h5, h6, main, nav, header, footer, aside, [role], img, a[href], button, input, select, textarea, form'; function selectedElements(root: ParentNode, selector: string, max: number = MAX_OBSERVATION_NODES): Element[] { const matches: Element[] = []; if (isElementNode(root)) { try { if (root.matches(selector)) { matches.push(root); } } catch { return matches; } } for (const element of Array.from(root.querySelectorAll(selector))) { if (!matches.includes(element)) { matches.push(element); if (matches.length >= max) break; } } return matches.slice(0, max); } const EXPLICIT_LABEL_CONTROL_SELECTOR = 'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="reset"]), select, textarea'; function hasExplicitControlLabel(element: Element): boolean { if (element.getAttribute('aria-label')?.trim()) return true; if (element.getAttribute('title')?.trim()) return true; const labelledBy = element.getAttribute('aria-labelledby'); if (labelledBy) { const hasReference = labelledBy .split(/\s+/) .some(id => Boolean(element.ownerDocument?.getElementById(id)?.textContent?.trim())); if (hasReference) return true; } if (element.closest('label')?.textContent?.trim()) return true; if (element.id) { const label = element.ownerDocument?.querySelector(`label[for="${cssEscape(element.id)}"]`); if (label?.textContent?.trim()) return true; } return false; } function elementTarget(element: Element): string[] { const tag = element.tagName.toLowerCase(); if (element.id) return [`#${element.id}`]; const name = element.getAttribute('name'); return [name ? `${tag}[name="${name}"]` : tag]; } function supplementalAccessibilityFindings(context: AxeContext): A11yFinding[] { const root = elementRoot(context, {}); const unlabeledControls = selectedElements(root, EXPLICIT_LABEL_CONTROL_SELECTOR) .filter(element => !hasExplicitControlLabel(element)) .slice(0, MAX_NODES_PER_FINDING); if (!unlabeledControls.length) return []; return [ { id: 'label', impact: 'critical', help: 'Form elements must have explicit labels', description: 'Rendered form controls need an explicit label, aria-label, aria-labelledby, or title; placeholder text alone is not treated as an explicit label by this projection.', help_url: 'https://dequeuniversity.com/rules/axe/4.12/label', wcag_tags: ['wcag2a', 'wcag412'], source: 'actionpanel-rendered-dom-explicit-label-check', nodes: unlabeledControls.map(element => ({ target: elementTarget(element), html: truncate(element.outerHTML, MAX_HTML_CHARS), failure_summary: 'Control has no explicit label. Add a visible label or an aria-label/aria-labelledby/title.', })), }, ]; } export function projectDom(context: AxeContext, params: PageProjectionParams = {}): Record { const root = elementRoot(context, params); const elements = selectedElements(root, DOM_OUTLINE_SELECTOR, MAX_OBSERVATION_NODES + 1); const outline = elements.slice(0, MAX_OBSERVATION_NODES).map(describeElement); return { node_count: elements.length, truncated: elements.length > MAX_OBSERVATION_NODES, outline, }; } export function projectComputedStyles(context: AxeContext, params: PageProjectionParams = {}): Record { const root = elementRoot(context, params); const win = ownerWindow(root); const selector = params.selector || 'body, h1, h2, h3, p, a, button'; const matches = selectedElements(root, selector); const seen = new Set(); const nodes = matches .filter(element => !seen.has(element) && Boolean(seen.add(element))) .slice(0, MAX_OBSERVATION_NODES) .map(element => { const computed = win?.getComputedStyle(element); const styles: Record = {}; if (computed) { for (const property of COMPUTED_STYLE_PROPERTIES) { const value = computed.getPropertyValue(property); if (value) styles[property] = value.trim(); } } return { tag: element.tagName.toLowerCase(), id: element.id || undefined, styles }; }); return { selector, node_count: nodes.length, nodes }; } export function projectLayout(context: AxeContext, params: PageProjectionParams = {}): Record { const root = elementRoot(context, params); const win = ownerWindow(root); const selector = params.selector || 'main, header, footer, nav, section, h1, img'; const matches = selectedElements(root, selector); const boxes = matches.map(element => { const rect = element.getBoundingClientRect(); return { tag: element.tagName.toLowerCase(), id: element.id || undefined, x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height), }; }); return { viewport: { width: win?.innerWidth ?? 0, height: win?.innerHeight ?? 0 }, selector, node_count: boxes.length, boxes, }; } function parseColor(value: string): [number, number, number, number] | null { const match = value.match(/rgba?\(([^)]+)\)/i); if (!match) return null; const parts = match[1].split(',').map(part => parseFloat(part.trim())); if (parts.length < 3 || parts.some(Number.isNaN)) return null; return [parts[0], parts[1], parts[2], parts.length >= 4 ? parts[3] : 1]; } function relativeLuminance([r, g, b]: [number, number, number]): number { const channel = (value: number) => { const c = value / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); } function contrastRatio(fg: [number, number, number], bg: [number, number, number]): number { const l1 = relativeLuminance(fg); const l2 = relativeLuminance(bg); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return Math.round(((lighter + 0.05) / (darker + 0.05)) * 100) / 100; } function effectiveBackground(element: Element, win: Window): [number, number, number] { let current: Element | null = element; while (current) { const color = parseColor(win.getComputedStyle(current).backgroundColor); if (color && color[3] > 0) return [color[0], color[1], color[2]]; current = current.parentElement; } return [255, 255, 255]; } export function projectContrast(context: AxeContext, params: PageProjectionParams = {}): Record { const root = elementRoot(context, params); const win = ownerWindow(root); const selector = params.selector || 'p, span, a, h1, h2, h3, h4, h5, h6, li, button, label'; const matches = selectedElements(root, selector); const pairs: Array> = []; if (win) { for (const element of matches) { if (!element.textContent || !element.textContent.trim()) continue; const computed = win.getComputedStyle(element); const fg = parseColor(computed.color); if (!fg) continue; const bg = effectiveBackground(element, win); const ratio = contrastRatio([fg[0], fg[1], fg[2]], bg); const fontSize = parseFloat(computed.fontSize) || 16; const isBold = Number(computed.fontWeight) >= 700; const isLarge = fontSize >= 24 || (isBold && fontSize >= 18.66); const threshold = isLarge ? 3 : 4.5; pairs.push({ tag: element.tagName.toLowerCase(), text: truncate(element.textContent, 60), foreground: computed.color, background: `rgb(${bg.join(', ')})`, ratio, font_size_px: Math.round(fontSize * 100) / 100, large_text: isLarge, wcag_aa_threshold: threshold, passes_aa: ratio >= threshold, }); if (pairs.length >= MAX_OBSERVATION_NODES) break; } } return { selector, node_count: pairs.length, pairs }; } const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]'; export function projectFocusOrder(context: AxeContext, params: PageProjectionParams = {}): Record { const root = elementRoot(context, params); const candidates = selectedElements(root, FOCUSABLE_SELECTOR).filter(element => { const tabindex = element.getAttribute('tabindex'); return tabindex === null || Number(tabindex) >= 0; }); // Positive tabindex precedes DOM order; 0/none follow in document order. const ordered = [...candidates].sort((a, b) => { const ta = Number(a.getAttribute('tabindex') || '0'); const tb = Number(b.getAttribute('tabindex') || '0'); if (ta > 0 && tb > 0) return ta - tb; if (ta > 0) return -1; if (tb > 0) return 1; return 0; }); const order = ordered.slice(0, MAX_OBSERVATION_NODES).map((element, index) => { const name = accessibleName(element); return { position: index + 1, tag: element.tagName.toLowerCase(), tabindex: element.getAttribute('tabindex'), name: truncate(name, MAX_TEXT_PREVIEW) || null, has_accessible_name: Boolean(name), }; }); return { note: 'DOM/tabindex focus order approximation. Interactive keyboard traversal (focus traps, roving tabindex) is not simulated.', node_count: order.length, order, }; } export function projectResourceTiming(win: Window): ResourceTimingProjection { const entries = win.performance?.getEntriesByType ? win.performance.getEntriesByType('resource') as PerformanceResourceTiming[] : []; const projected = entries.map(entry => ({ url: entry.name, initiator_type: entry.initiatorType || '', duration_ms: Math.round(entry.duration), transfer_size: Number(entry.transferSize || 0), encoded_body_size: Number(entry.encodedBodySize || 0), decoded_body_size: Number(entry.decodedBodySize || 0), start_time_ms: Math.round(entry.startTime), })); const summary = projected.reduce( (acc, entry) => ({ count: acc.count + 1, total_transfer_size: acc.total_transfer_size + entry.transfer_size, total_encoded_body_size: acc.total_encoded_body_size + entry.encoded_body_size, total_decoded_body_size: acc.total_decoded_body_size + entry.decoded_body_size, slowest_duration_ms: Math.max(acc.slowest_duration_ms, entry.duration_ms), }), { count: 0, total_transfer_size: 0, total_encoded_body_size: 0, total_decoded_body_size: 0, slowest_duration_ms: 0, }, ); projected.sort((left, right) => ( right.duration_ms - left.duration_ms || right.transfer_size - left.transfer_size || left.url.localeCompare(right.url) )); return { summary, entries: projected.slice(0, MAX_RESOURCE_TIMING_ENTRIES), }; }