/** * accessmate.ai frontend overlay — axe-core powered, no framework. * * Two modes: * 1. Interactive — slide-in panel for the current page (admin bar button) * 2. Auto-scan — headless iframe scan triggered by PageScanner admin view * (activated when ?aai_scan=1 is in the URL) */ import axe from 'axe-core'; // ── Types ───────────────────────────────────────────────────── interface OverlayConfig { nonce: string; restBase: string; pageUrl: string; } interface AaiIssueNode { element: string; selector: string; } interface AaiIssue { id: string; rule: string; wcag: string; severity: 'critical' | 'warning' | 'notice'; title: string; description: string; element: string; selector: string; nodes: AaiIssueNode[]; count: number; fixed: boolean; ai_fixable: boolean; help_url: string; // axe helpUrl — always a valid Deque University link } interface ScanPayload { url: string; score: number; grade: string; passed: number; failed: number; issues: AaiIssue[]; } declare global { interface Window { accessmateOverlayConfig: OverlayConfig; accessmateOverlay: { open(): void; close(): void }; } } // ── Bootstrap ───────────────────────────────────────────────── (function () { const cfg: OverlayConfig = window.accessmateOverlayConfig; if (!cfg) return; // Any non-empty aai_scan value means headless iframe mode. // The value is used as a scanId so the parent can match the response // without fragile URL comparison (URLs may differ in trailing slash, etc.). const scanId = new URLSearchParams(window.location.search).get('aai_scan') ?? ''; const autoScan = new URLSearchParams(window.location.search).get('accessmate_scan') ?? ''; if (scanId) { // Headless iframe mode — scan silently, store results, signal parent. runAutoScan(cfg, scanId); } else { // Interactive mode — slide-in panel for the current page. buildOverlay(cfg); // If accessmate_scan param is present, auto-open and start scanning immediately. if (autoScan) { requestAnimationFrame(() => window.accessmateOverlay?.open()); } } })(); // ── axe helpers ─────────────────────────────────────────────── /** Run axe-core against the whole document (WCAG 2.x A + AA). */ async function runAxe(): Promise { return axe.run( { include: [['html']], exclude: [['#wpadminbar'], ['#aai-ov-host']] }, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] }, resultTypes: ['violations', 'passes'] }, ); } /** Transform axe violations into our simplified Issue format. */ function axeToIssues(violations: axe.Result[]): AaiIssue[] { const aiFixableRules = new Set([ 'image-alt', 'label', 'color-contrast', 'link-name', 'button-name', 'input-image-alt', 'area-alt', ]); return violations.map((v) => { // Extract WCAG number from tags like 'wcag143' → '1.4.3' const wcagTag = v.tags.find((t) => /^wcag\d{3,}$/.test(t)); let wcag = ''; if (wcagTag) { const m = wcagTag.match(/^wcag(\d)(\d)(\d+)$/); if (m) wcag = `${m[1]}.${m[2]}.${m[3]}`; } const severity: AaiIssue['severity'] = v.impact === 'critical' || v.impact === 'serious' ? 'critical' : v.impact === 'moderate' ? 'warning' : 'notice'; const firstNode = v.nodes[0]; const nodes = v.nodes.map((n) => ({ element: n.html ?? '', selector: n.target?.join(', ') ?? '', })); return { id: `${v.id}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, rule: v.id, wcag, severity, title: v.help, description: v.description, element: firstNode?.html ?? '', selector: firstNode?.target?.join(', ') ?? '', nodes, count: v.nodes.length, fixed: false, ai_fixable: aiFixableRules.has(v.id), help_url: v.helpUrl ?? '', }; }); } function calcScore(issues: AaiIssue[]): number { if (!issues.length) return 100; let deductions = 0; for (const iss of issues) { const w = iss.severity === 'critical' ? 10 : 5; deductions += Math.min(w * iss.count, 25); } return Math.max(0, 100 - deductions); } function calcGrade(score: number): string { return score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 55 ? 'C' : score >= 35 ? 'D' : 'F'; } function scoreColor(s: number): string { return s >= 90 ? '#22c55e' : s >= 75 ? '#84cc16' : s >= 55 ? '#f59e0b' : s >= 35 ? '#f97316' : '#ef4444'; } /** POST computed scan payload to the /axe-scan REST endpoint. */ async function storeScan(cfg: OverlayConfig, payload: ScanPayload): Promise { await fetch(`${cfg.restBase}/axe-scan`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce }, body: JSON.stringify(payload), }); } // ── Auto-scan mode (iframe) ─────────────────────────────────── async function runAutoScan(cfg: OverlayConfig, scanId: string): Promise { // Wait for full page render before running axe. await new Promise((resolve) => { if (document.readyState === 'complete') resolve(); else window.addEventListener('load', () => resolve(), { once: true }); }); // Small delay so JS-rendered content settles. await new Promise((r) => setTimeout(r, 800)); try { const results = await runAxe(); const issues = axeToIssues(results.violations); const score = calcScore(issues); const grade = calcGrade(score); const payload: ScanPayload = { url: cfg.pageUrl, score, grade, passed: results.passes.length, failed: results.violations.length, issues, }; await storeScan(cfg, payload); window.parent.postMessage( { type: 'aai-scan-complete', scanId, url: cfg.pageUrl, score, grade, failed: results.violations.length }, window.location.origin, ); } catch (err: unknown) { window.parent.postMessage( { type: 'aai-scan-error', scanId, url: cfg.pageUrl, error: String(err) }, window.location.origin, ); } } // ── Interactive overlay UI ──────────────────────────────────── // ── Element highlight helpers ───────────────────────────────── function injectHighlightStyle(): void { if (document.getElementById('aai-hl-style')) return; const s = document.createElement('style'); s.id = 'aai-hl-style'; s.textContent = '.aai-hl{outline:3px solid #3858e9!important;outline-offset:3px!important;' + 'box-shadow:0 0 0 8px rgba(56,88,233,.18)!important;scroll-margin-top:80px!important}' + '@keyframes aai-hl-pulse{0%{box-shadow:0 0 0 4px rgba(56,88,233,.6)!important}' + '100%{box-shadow:0 0 0 18px rgba(56,88,233,0)!important}}' + '.aai-hl--pulse{animation:aai-hl-pulse .7s ease-out!important}'; document.head.appendChild(s); } let _hlEl: Element | null = null; let _hlTimer = 0; function highlightEl(selector: string, scroll = false): void { clearHl(); if (!selector) return; try { const el = document.querySelector(selector); if (!el) return; el.classList.add('aai-hl'); _hlEl = el; if (scroll) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.remove('aai-hl--pulse'); void (el as HTMLElement).offsetWidth; el.classList.add('aai-hl--pulse'); _hlTimer = window.setTimeout(() => el.classList.remove('aai-hl--pulse'), 800); } } catch { /* invalid selector — ignore */ } } function clearHl(): void { clearTimeout(_hlTimer); if (_hlEl) { _hlEl.classList.remove('aai-hl', 'aai-hl--pulse'); _hlEl = null; } } // ── Overlay UI ──────────────────────────────────────────────── function buildOverlay(cfg: OverlayConfig): void { const PANEL_W = 380; const adminBarH = (document.getElementById('wpadminbar') as HTMLElement | null)?.offsetHeight ?? 0; injectHighlightStyle(); // Smooth page-push transition — applied once to html element document.documentElement.style.transition = `margin-right .28s cubic-bezier(.4,0,.2,1)`; // ── Shadow host ─────────────────────────────────────────────── const host = document.createElement('div'); host.id = 'aai-ov-host'; host.style.cssText = `position:fixed;top:${adminBarH}px;right:0;width:${PANEL_W}px;height:calc(100dvh - ${adminBarH}px);z-index:2147483648;pointer-events:none`; document.body.appendChild(host); const shadow = host.attachShadow({ mode: 'open' }); // ── Styles ───────────────────────────────────────────────── const style = document.createElement('style'); style.textContent = [ ':host{display:block}', '*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}', 'button{all:unset;display:inline-flex;align-items:center;cursor:pointer;letter-spacing:normal;line-height:normal;text-transform:none;word-spacing:normal;font:inherit}', 'a{all:unset;cursor:pointer}', /* Panel fills host — host is fixed, panel slides in from right */ '#aai-ov{position:absolute;inset:0;background:#fff;display:flex;flex-direction:column;pointer-events:none;', 'box-shadow:-4px 0 28px rgba(0,0,0,.18);', 'transform:translateX(100%);transition:transform .28s cubic-bezier(.4,0,.2,1);', 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;color:#111827;', 'letter-spacing:normal;word-spacing:normal;text-transform:none;line-height:1.5}', '#aai-ov.open{transform:translateX(0);pointer-events:auto}', '#aai-ov-hd{padding:14px 18px;background:#3858e9;color:#fff;display:flex;justify-content:space-between;align-items:center;flex-shrink:0}', '#aai-ov-hd h2{margin:0;font-size:14px;font-weight:600;display:flex;align-items:center;gap:6px}', '#aai-ov-cls{color:#fff;font-size:22px;line-height:1;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:4px}', '#aai-ov-cls:hover{background:rgba(255,255,255,.15)}', '#aai-ov-body{flex:1;overflow:hidden;position:relative}', '#aai-ov-ft{padding:10px 16px;padding-bottom:max(10px,env(safe-area-inset-bottom));border-top:1px solid #e5e7eb;flex-shrink:0}', /* list / detail view panes — slide transition */ '#aai-ov-list-view{position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;padding:14px 16px;', 'transform:translateX(0);transition:transform .25s cubic-bezier(.4,0,.2,1)}', '#aai-ov-list-view.slide-out{transform:translateX(-100%)}', '#aai-ov-detail-view{position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;padding:14px 16px;', 'transform:translateX(100%);transition:transform .25s cubic-bezier(.4,0,.2,1)}', '#aai-ov-detail-view.slide-in{transform:translateX(0)}', /* back button */ '#aai-ov-back{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:#6b7280;margin-bottom:14px;cursor:pointer;padding:4px 0}', '#aai-ov-back:hover{color:#3858e9}', /* detail badge row */ '.aai-ov-d-badge-row{display:flex;align-items:center;gap:8px;margin-bottom:10px}', '.aai-ov-d-badge{display:inline-block;padding:2px 10px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase}', '.aai-ov-d-badge.critical{background:#fee2e2;color:#dc2626}', '.aai-ov-d-badge.warning{background:#fef3c7;color:#d97706}', '.aai-ov-d-badge.notice{background:#dbeafe;color:#2563eb}', '.aai-ov-d-wcag{font-size:11px;color:#6b7280;background:#f3f4f6;padding:2px 8px;border-radius:10px}', /* detail body */ '#aai-ov-detail-title{font-size:15px;font-weight:700;line-height:1.3;margin-bottom:8px;color:#111827}', '#aai-ov-detail-desc{font-size:13px;color:#374151;line-height:1.55;margin-bottom:14px}', '.aai-ov-d-section{margin-bottom:12px}', '.aai-ov-d-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#9ca3af;margin-bottom:4px}', '.aai-ov-d-code{display:block;background:#f3f4f6;padding:6px 8px;border-radius:4px;font-size:12px;font-family:ui-monospace,SFMono-Regular,monospace;word-break:break-all;color:#374151}', '.aai-ov-d-pre{display:block;background:#1e1e2e;color:#cdd6f4;padding:8px 10px;border-radius:6px;font-size:11px;font-family:ui-monospace,SFMono-Regular,monospace;overflow-x:auto;white-space:pre-wrap;word-break:break-word;margin:0}', '#aai-ov-detail-learn{display:inline-block;font-size:12px;color:#3858e9;margin-bottom:14px}', '#aai-ov-detail-learn:hover{text-decoration:underline}', '.aai-ov-d-actions{display:flex;gap:8px;padding-top:14px;border-top:1px solid #e5e7eb;flex-wrap:wrap}', '#aai-ov-detail-fix{padding:8px 14px;background:#3858e9;color:#fff;border-radius:6px;font-size:13px;font-weight:500;transition:background .15s}', '#aai-ov-detail-fix:hover{background:#2642c7}', '#aai-ov-detail-fix:disabled{opacity:.6;cursor:not-allowed}', '#aai-ov-detail-explain{padding:8px 14px;background:#fff;color:#3858e9;border:1.5px solid #3858e9;border-radius:6px;font-size:13px;font-weight:500;transition:all .15s}', '#aai-ov-detail-explain:hover{background:#eef2ff}', '#aai-ov-detail-explain:disabled{opacity:.6;cursor:not-allowed}', '#aai-ov-detail-ai-out{margin-top:12px;padding:12px;background:#f8f7ff;border:1px solid #ddd6fe;border-radius:6px;font-size:12px;line-height:1.55;color:#374151}', '.aai-ov-ai-err{color:#ef4444;font-size:12px}', '.aai-ov-score{display:flex;align-items:center;gap:12px;margin-bottom:14px;padding:12px;background:#f9fafb;border-radius:8px}', '.aai-ov-grade{font-size:26px;font-weight:700;width:48px;height:48px;border-radius:50%;', 'display:flex;align-items:center;justify-content:center;color:#fff;flex-shrink:0}', '.aai-ov-sv{font-size:22px;font-weight:700;line-height:1.1}', '.aai-ov-sv small{font-size:13px;font-weight:400;color:#6b7280}', '.aai-ov-sc{font-size:12px;color:#6b7280;margin-top:3px}', '.aai-ov-url{font-size:11px;color:#9ca3af;margin-bottom:14px;word-break:break-all}', /* skeleton loader */ '@keyframes aai-shimmer{0%{background-position:-400px 0}100%{background-position:400px 0}}', '.aai-ov-skel{background:linear-gradient(90deg,#f0f0f0 25%,#e8e8e8 50%,#f0f0f0 75%);', 'background-size:400px 100%;animation:aai-shimmer 1.4s ease-in-out infinite;border-radius:4px}', '.aai-ov-skel-score{display:flex;align-items:center;gap:12px;margin-bottom:18px;padding:12px;background:#f9fafb;border-radius:8px}', '.aai-ov-skel-circle{width:48px;height:48px;border-radius:50%;flex-shrink:0}', '.aai-ov-skel-lines{flex:1;display:flex;flex-direction:column;gap:7px}', '.aai-ov-skel-card{border-left:3px solid #e5e7eb;margin-bottom:8px;border-radius:0 6px 6px 0;', 'padding:10px 12px;background:rgba(0,0,0,.018);display:flex;flex-direction:column;gap:7px}', /* card entry animation */ '@keyframes aai-card-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}', '.aai-ov-issue{animation:aai-card-in .22s ease-out both}', '.aai-ov-pass-item{animation:aai-card-in .22s ease-out both}', /* issue cards — backend-matching design */ '.aai-ov-issue{border-left:3px solid #e5e7eb;margin-bottom:8px;border-radius:8px;display:flex;align-items:center;gap:10px;padding:12px 14px;cursor:pointer;transition:background .15s}', '.aai-ov-issue.critical{border-color:#ef4444;background:#fff5f5}', '.aai-ov-issue.warning{border-color:#f59e0b;background:#fffbeb}', '.aai-ov-issue.notice{border-color:#3b82f6;background:#eff6ff}', '.aai-ov-issue:hover{filter:brightness(.97)}', '.aai-ov-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}', '.aai-ov-dot.critical{background:#ef4444}', '.aai-ov-dot.warning{background:#f59e0b}', '.aai-ov-dot.notice{background:#3b82f6}', '.aai-ov-issue-text{flex:1;min-width:0}', '.aai-ov-issue-text strong{display:block;font-size:13px;font-weight:600;line-height:1.3}', '.aai-ov-meta{display:block;font-size:12px;color:#6b7280;margin-top:2px}', '.aai-ov-details-btn{flex-shrink:0;padding:4px 12px;border:1.5px solid #3858e9;color:#3858e9;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;background:#fff;transition:all .15s;white-space:nowrap}', '.aai-ov-details-btn:hover{background:#3858e9;color:#fff}', /* filters */ '.aai-ov-filter{display:flex;gap:6px;margin-bottom:14px;flex-wrap:wrap}', '.aai-ov-filter-btn{padding:3px 10px;border:1px solid #d1d5db;border-radius:20px;background:#f3f4f6;color:#374151;font-size:12px;cursor:pointer;transition:all .15s}', '.aai-ov-filter-btn:hover{border-color:#3858e9;color:#3858e9;background:#eaedfc}', '.aai-ov-filter-btn.active{background:#3858e9;color:#fff;border-color:#3858e9}', '.aai-ov-filter-btn--pass{background:#f0fdf4;border-color:#86efac;color:#15803d}', '.aai-ov-filter-btn--pass:hover{background:#dcfce7;border-color:#22c55e;color:#15803d}', '.aai-ov-filter-btn--pass.active{background:#22c55e;border-color:#22c55e;color:#fff}', /* passed items */ '.aai-ov-pass-item{animation:aai-card-in .2s ease-out both;border-left:3px solid #22c55e;margin-bottom:6px;border-radius:0 6px 6px 0;overflow:hidden;cursor:pointer}', '.aai-ov-pass-item-hd{display:flex;align-items:flex-start;gap:8px;padding:8px 10px;background:#f0fdf4;user-select:none}', '.aai-ov-pass-item-hd-text{flex:1}', '.aai-ov-pass-item-hd strong{display:block;font-size:12px;font-weight:600;color:#166534;line-height:1.3}', '.aai-ov-pass-item-meta{display:block;font-size:11px;color:#4ade80;margin-top:1px}', '.aai-ov-pass-item-body{display:none;padding:8px 10px;border-top:1px solid #bbf7d0;background:#fff;font-size:12px;color:#374151}', '.aai-ov-pass-item.open .aai-ov-pass-item-body{display:block}', '.aai-ov-pass-item.open .aai-ov-chevron::before{content:"▲"}', '.aai-ov-pass-item:not(.open) .aai-ov-chevron::before{content:"▼"}', /* misc */ '.aai-ov-msg{color:#6b7280;font-size:13px;text-align:center;padding:24px 0}', '.aai-ov-ok{color:#22c55e;font-size:13px;text-align:center;padding:24px 0}', '.aai-ov-btn{display:flex;align-items:center;justify-content:center;gap:6px;padding:9px 16px;background:#3858e9;color:#fff;border-radius:6px;cursor:pointer;font-size:13px;font-weight:500;width:100%;transition:background .15s}', '.aai-ov-btn:hover{background:#2642c7}', '.aai-ov-btn:disabled{opacity:.6;cursor:not-allowed}', '.aai-ov-pre{display:block;background:#1e1e2e;color:#cdd6f4;padding:8px 10px;border-radius:4px;font-size:11px;font-family:ui-monospace,SFMono-Regular,monospace;overflow-x:auto;white-space:pre-wrap;word-break:break-word;margin:0}', '@keyframes aai-spin{to{transform:rotate(360deg)}}', '.aai-ov-spin{display:inline-block;width:12px;height:12px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:aai-spin .7s linear infinite;vertical-align:middle;margin-right:5px}', /* affected-elements accordion */ '.aai-ov-node-list{display:flex;flex-direction:column;gap:6px;margin-top:6px}', '.aai-ov-node-item{border:1.5px solid #e5e7eb;border-radius:8px;overflow:hidden;transition:border-color .15s}', '.aai-ov-node-item.open{border-color:var(--nc,#ef4444)}', '.aai-ov-node-hd{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;background:#fff;user-select:none}', '.aai-ov-node-hd:hover{background:#fafafa}', '.aai-ov-node-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0;background:var(--nc,#ef4444)}', '.aai-ov-node-sel{flex:1;font-size:12px;font-family:ui-monospace,SFMono-Regular,monospace;color:#374151;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}', '.aai-ov-node-chevron{font-size:13px;color:#9ca3af;flex-shrink:0;transition:transform .2s;line-height:1}', '.aai-ov-node-item.open .aai-ov-node-chevron{transform:rotate(90deg)}', '.aai-ov-node-body{display:none;border-top:1px solid #e5e7eb}', '.aai-ov-node-item.open .aai-ov-node-body{display:block}', ].join(''); shadow.appendChild(style); // ── Markup ───────────────────────────────────────────────── const panel = document.createElement('div'); panel.id = 'aai-ov'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Page accessibility scan'); panel.setAttribute('aria-modal', 'true'); panel.innerHTML = '
' + '

' + '' + 'accessmate.ai Scan' + '

' + '' + '
' + '
' + /* List view — initial prompt message, replaced with results after scan */ '
' + '

Click Scan for issues to run automated accessibility checks on this page.

' + '
' + /* Detail view — shown when Details button clicked, hidden by default */ '
' + '' + '
' + '' + '' + '
' + '

' + '

' + '
' + '
' + '
' + '
' + '
Selector
' + '' + '
' + '
' + '
Instances
' + '' + '
' + '' + '
' + '' + '' + '
' + '' + '
' + '
' + '
' + '' + '
'; shadow.appendChild(panel); const ovBody = shadow.querySelector('#aai-ov-body')!; const listView = shadow.querySelector('#aai-ov-list-view')!; const detailView = shadow.querySelector('#aai-ov-detail-view')!; const btn = shadow.querySelector('#aai-ov-scan')!; const close = shadow.querySelector('#aai-ov-cls')!; // ── Panel control ────────────────────────────────────────── let scanStarted = false; function openPanel(): void { document.documentElement.style.marginRight = PANEL_W + 'px'; panel.classList.add('open'); close.focus(); if (!scanStarted) { scanStarted = true; setTimeout(triggerScan, 350); } } function closePanel(): void { document.documentElement.style.marginRight = ''; panel.classList.remove('open'); clearHl(); } close.addEventListener('click', closePanel); document.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Escape' && panel.classList.contains('open')) closePanel(); }); // ── Scan state ──────────────────────────────────────────── let lastIssues: AaiIssue[] = []; let lastPasses: axe.Result[] = []; let activeFilter = 'all'; // ── Detail view logic ───────────────────────────────────── let currentIssue: AaiIssue | null = null; function openDetail(iss: AaiIssue): void { currentIssue = iss; const badge = shadow.querySelector('#aai-ov-detail-badge')!; badge.textContent = iss.severity; badge.className = `aai-ov-d-badge ${iss.severity}`; const wcagEl = shadow.querySelector('#aai-ov-detail-wcag')!; wcagEl.textContent = iss.wcag ? `WCAG ${iss.wcag}` : ''; wcagEl.style.display = iss.wcag ? '' : 'none'; shadow.querySelector('#aai-ov-detail-title')!.textContent = iss.title; shadow.querySelector('#aai-ov-detail-desc')!.textContent = iss.description; const elSec = shadow.querySelector('#aai-ov-detail-el-sec')!; const allNodes = iss.nodes.length > 0 ? iss.nodes : (iss.element ? [{ element: iss.element, selector: iss.selector }] : []); const nodeColor = iss.severity === 'critical' ? '#ef4444' : iss.severity === 'warning' ? '#f59e0b' : '#3b82f6'; if (allNodes.length > 0) { const label = allNodes.length > 1 ? `Affected elements (${allNodes.length})` : 'Affected element'; shadow.querySelector('#aai-ov-detail-el')!.innerHTML = `

${label}

` + `
` + allNodes.map((n, i) => { const displayLabel = esc(n.selector || n.element.slice(0, 60)); return ( `
` + `
` + `` + `${displayLabel}` + `` + `
` + `
` + `
${esc(n.element)}
` + `
` + `
` ); }).join('') + `
`; elSec.style.display = ''; } else { elSec.style.display = 'none'; } const selSec = shadow.querySelector('#aai-ov-detail-sel-sec')!; selSec.style.display = 'none'; shadow.querySelector('#aai-ov-detail-count')!.textContent = `${iss.count} element${iss.count !== 1 ? 's' : ''}`; const learn = shadow.querySelector('#aai-ov-detail-learn')!; if (iss.help_url) { learn.href = iss.help_url; learn.textContent = (iss.wcag ? `WCAG ${iss.wcag} — ` : '') + 'View guidance ↗'; learn.style.display = 'inline-block'; } else { learn.style.display = 'none'; } // Reset AI section const aiOut = shadow.querySelector('#aai-ov-detail-ai-out')!; aiOut.style.display = 'none'; aiOut.innerHTML = ''; const fixBtn = shadow.querySelector('#aai-ov-detail-fix')!; const explainBtn = shadow.querySelector('#aai-ov-detail-explain')!; fixBtn.innerHTML = 'Generate fix with AI'; explainBtn.innerHTML = 'Explain'; fixBtn.disabled = explainBtn.disabled = false; // Switch views with slide animation listView.classList.add('slide-out'); detailView.classList.add('slide-in'); detailView.scrollTop = 0; // Highlight the first node by default const firstSelector = allNodes[0]?.selector ?? iss.selector; if (firstSelector) highlightEl(firstSelector, true); } function closeDetail(): void { listView.classList.remove('slide-out'); detailView.classList.remove('slide-in'); clearHl(); } async function callDetailAI(task: 'fix' | 'explain'): Promise { const iss = currentIssue; if (!iss) return; const fixBtn = shadow.querySelector('#aai-ov-detail-fix')!; const explainBtn = shadow.querySelector('#aai-ov-detail-explain')!; const aiOut = shadow.querySelector('#aai-ov-detail-ai-out')!; fixBtn.disabled = explainBtn.disabled = true; if (task === 'fix') fixBtn.innerHTML = 'Thinking…'; else explainBtn.innerHTML = 'Working…'; aiOut.style.display = 'none'; aiOut.innerHTML = ''; try { const res = await fetch(`${cfg.restBase}/ai/proxy`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce }, body: JSON.stringify({ task, params: { title: iss.title, wcag: iss.wcag, html: iss.element, context: iss.description } }), }); const data = await res.json() as { text?: string; error?: string }; aiOut.innerHTML = data.error ? `${esc(data.error)}` : ovMd(data.text ?? ''); aiOut.style.display = 'block'; } catch (err) { aiOut.innerHTML = `${esc(String(err))}`; aiOut.style.display = 'block'; } finally { fixBtn.disabled = explainBtn.disabled = false; fixBtn.innerHTML = 'Generate fix with AI'; explainBtn.innerHTML = 'Explain'; } } shadow.querySelector('#aai-ov-back')!.addEventListener('click', closeDetail); shadow.querySelector('#aai-ov-detail-fix')!.addEventListener('click', () => callDetailAI('fix')); shadow.querySelector('#aai-ov-detail-explain')!.addEventListener('click', () => callDetailAI('explain')); // Accordion toggle for affected-elements nodes — also highlights the element on page detailView.addEventListener('click', (e: Event) => { const hd = (e.target as Element).closest('.aai-ov-node-hd'); if (!hd) return; const item = hd.closest('.aai-ov-node-item'); if (!item) return; const list = item.closest('.aai-ov-node-list'); const isOpen = item.classList.contains('open'); list?.querySelectorAll('.aai-ov-node-item').forEach((el) => el.classList.remove('open')); if (!isOpen) { item.classList.add('open'); const selector = item.dataset.selector ?? ''; if (selector) highlightEl(selector, true); } else { clearHl(); } }); // ── Render helpers ──────────────────────────────────────── function esc(s: string): string { return String(s) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } /** Minimal markdown → HTML for AI output in the overlay. */ function ovMd(text: string): string { const blocks: string[] = []; const wp = text.replace(/```([^\n]*)\n?([\s\S]*?)```/g, (_m, _l, code: string) => { const i = blocks.length; blocks.push(`
${esc(code.replace(/\n$/, ''))}
`); return `\x00C${i}\x00`; }); const inl = (s: string) => s .replace(/`([^`\n]+)`/g, '$1') .replace(/\*\*([^*\n]+)\*\*/g, '$1') .replace(/\*([^*\n]+)\*/g, '$1'); const out: string[] = []; for (const block of wp.split(/\n{2,}/)) { const b = block.trim(); if (!b) continue; const cp = b.match(/^\x00C(\d+)\x00$/); if (cp) { out.push(blocks[+cp[1]]); continue; } const h3 = b.match(/^###\s+(.+)/); if (h3) { out.push(`

${inl(esc(h3[1]))}

`); continue; } const h2 = b.match(/^##\s+(.+)/); if (h2) { out.push(`

${inl(esc(h2[1]))}

`); continue; } const h1 = b.match(/^#\s+(.+)/); if (h1) { out.push(`

${inl(esc(h1[1]))}

`); continue; } const lines = b.split('\n'); const parts: string[] = []; let lt: 'ul' | 'ol' | null = null; const cl = () => { if (lt) { parts.push(lt === 'ul' ? '' : ''); lt = null; } }; for (const line of lines) { const t = line.trim(); const icp = t.match(/^\x00C(\d+)\x00$/); if (icp) { cl(); parts.push(blocks[+icp[1]]); continue; } if (/^[-*+]\s/.test(t)) { if (lt !== 'ul') { cl(); parts.push('
    '); lt = 'ul'; } parts.push(`
  • ${inl(esc(t.replace(/^[-*+]\s+/, '')))}
  • `); } else if (/^\d+\.\s/.test(t)) { if (lt !== 'ol') { cl(); parts.push('
      '); lt = 'ol'; } parts.push(`
    1. ${inl(esc(t.replace(/^\d+\.\s+/, '')))}
    2. `); } else { cl(); if (t) parts.push(`

      ${inl(esc(t))}

      `); } } cl(); out.push(parts.join('')); } return out.join(''); } function buildCard(iss: AaiIssue, idx: number): string { const sev = esc(iss.severity); const metaParts = [ iss.severity.charAt(0).toUpperCase() + iss.severity.slice(1), iss.wcag ? `WCAG ${esc(iss.wcag)}` : '', `${iss.count} element${iss.count !== 1 ? 's' : ''}`, ].filter(Boolean).join(' · '); return ( `
      ` + `` + `
      ` + `${esc(iss.title)}` + `${metaParts}` + `
      ` + `` + `
      ` ); } function buildPassCard(pass: axe.Result): string { const count = pass.nodes.length; const bodyParts = [`

      ${esc(pass.description)}

      `]; if (pass.helpUrl) bodyParts.push( `Learn more ↗`, ); return ( `
      ` + `
      ` + `
      ` + `${esc(pass.help)}` + `${count} element${count !== 1 ? 's' : ''} checked` + `
      ` + `` + `
      ` + `
      ${bodyParts.join('')}
      ` + `
      ` ); } function staggerCards(container: Element): void { container.querySelectorAll('.aai-ov-issue,.aai-ov-pass-item').forEach((card, i) => { card.style.animationDelay = `${i * 35}ms`; }); } function renderIssueList(): void { const list = shadow.querySelector('#aai-ov-issue-list'); if (!list) return; if (activeFilter === 'passed') { list.innerHTML = lastPasses.length === 0 ? '

      ✓ All automated checks passed.

      ' : lastPasses.map(buildPassCard).join(''); staggerCards(list); return; } const filtered = activeFilter === 'all' ? lastIssues : lastIssues.filter((i) => i.severity === activeFilter); list.innerHTML = filtered.length === 0 ? '

      ✓ No issues in this category!

      ' : filtered.map((iss, i) => buildCard(iss, i)).join(''); staggerCards(list); } function renderResults(issues: AaiIssue[], score: number, grade: string): void { const color = scoreColor(score); const critical = issues.filter((i) => i.severity === 'critical').length; const warning = issues.filter((i) => i.severity === 'warning').length; const notice = issues.filter((i) => i.severity === 'notice').length; const initialList = issues.length === 0 ? '

      ✓ No issues detected

      Some issues may require manual testing.

      ' : issues.map((iss, i) => buildCard(iss, i)).join(''); listView.innerHTML = `
      ` + `
      ${esc(grade)}
      ` + `
      ` + `
      ${score}/100
      ` + `
      ${issues.length} issue${issues.length !== 1 ? 's' : ''} · ${lastPasses.length} passed
      ` + `
      ` + `
      ` + `
      ${esc(cfg.pageUrl)}
      ` + `
      ` + `` + (critical ? `` : '') + (warning ? `` : '') + (notice ? `` : '') + `` + `
      ` + `
      ${initialList}
      `; const listEl = shadow.querySelector('#aai-ov-issue-list'); if (listEl) staggerCards(listEl); } // ── Event delegation on listView (registered once) ──────── // Filter tabs listView.addEventListener('click', (e: Event) => { const fbtn = (e.target as Element).closest('.aai-ov-filter-btn'); if (fbtn) { listView.querySelectorAll('.aai-ov-filter-btn').forEach((b) => b.classList.remove('active')); fbtn.classList.add('active'); activeFilter = fbtn.dataset.filter ?? 'all'; renderIssueList(); return; } // Pass item accordion const passHd = (e.target as Element).closest('.aai-ov-pass-item-hd'); if (passHd) { passHd.closest('.aai-ov-pass-item')?.classList.toggle('open'); return; } // Details button → open detail view const detailsBtn = (e.target as Element).closest('.aai-ov-details-btn'); if (detailsBtn) { e.stopPropagation(); const idx = Number(detailsBtn.dataset.idx); if (lastIssues[idx]) openDetail(lastIssues[idx]); return; } // Card click (not Details button) → scroll to + highlight element const card = (e.target as Element).closest('.aai-ov-issue[data-selector]'); if (card) highlightEl(card.dataset.selector ?? '', true); }); // ── Scan trigger ────────────────────────────────────────── async function triggerScan(): Promise { btn.disabled = true; btn.textContent = 'Scanning…'; // Reset to list view during scan listView.classList.remove('slide-out'); detailView.classList.remove('slide-in'); const skCard = (w1: number, w2: number, color: string) => `
      ` + `
      ` + `
      ` + `
      `; listView.innerHTML = `
      ` + `
      ` + `
      ` + `
      ` + skCard(72, 48, '#fca5a5') + skCard(65, 55, '#fca5a5') + skCard(80, 42, '#fcd34d') + skCard(60, 50, '#93c5fd') + skCard(75, 38, '#93c5fd'); try { const minDelay = new Promise((r) => setTimeout(r, 3000 + Math.random() * 1000)); const [results] = await Promise.all([runAxe(), minDelay]); lastIssues = axeToIssues(results.violations); lastPasses = results.passes; activeFilter = 'all'; const score = calcScore(lastIssues); const grade = calcGrade(score); renderResults(lastIssues, score, grade); storeScan(cfg, { url: cfg.pageUrl, score, grade, passed: results.passes.length, failed: results.violations.length, issues: lastIssues, }).catch(() => {}); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'axe-core failed'; listView.innerHTML = `

      Error: ${esc(msg)}

      `; } finally { btn.disabled = false; btn.textContent = 'Re-scan page'; } } btn.addEventListener('click', triggerScan); // ── Public API ──────────────────────────────────────────── window.accessmateOverlay = { open: openPanel, close: closePanel }; function hookAdminBar(): void { const node = document.getElementById('wp-admin-bar-accessmate-scan'); if (!node) return; // Target the directly so preventDefault stops navigation before the browser acts on href="#" const anchor = node.querySelector('a.ab-item') ?? node; anchor.addEventListener('click', (e) => { e.preventDefault(); openPanel(); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', hookAdminBar); } else { hookAdminBar(); } }