--- name: qa-detect-a11y section: accessibility description: "Detects unnamed buttons, missing lang attribute, absent skip-to-content link, and missing/disabled viewport meta." model: haiku applyOn: all needsSetup: false viewportSensitive: false --- ## What it checks - Button or `[role=button]` with no accessible name (text/aria-label/title/aria-labelledby) - `` missing `lang` attribute - No skip-to-content link (`` with skip/jump/content text) - Icon-sized `` (<48px or src matches icon pattern) without alt - **`viewportMetaMissing`** — `` missing, OR has `user-scalable=no`, OR `maximum-scale=1` (zoom disabled). Catastrophic — breaks all mobile rendering. ## Probe (browser_evaluate) ```js () => { const sel = el => { const cls = (el.className && typeof el.className === 'string') ? '.' + el.className.trim().split(/\s+/)[0] : ''; return (el.id ? `#${el.id}` : el.tagName.toLowerCase() + cls).slice(0,120); }; const out = []; const bb = el => { const r = el.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) }; }; // viewportMetaMissing const vp = document.querySelector('meta[name="viewport"]'); if (!vp) { out.push({ issueType:'viewportMetaMissing', severity:'high', selector:'head', description:'No tag — page will not scale correctly on mobile devices. Add ' }); } else { const content = (vp.getAttribute('content') || '').toLowerCase(); if (content.includes('user-scalable=no') || content.includes('user-scalable=0')) { out.push({ issueType:'viewportMetaMissing', severity:'high', selector:'meta[name="viewport"]', description:`Viewport meta has user-scalable=no — users cannot zoom (accessibility violation). content="${content}"` }); } else if (/maximum-scale\s*=\s*1(?![\d.])/.test(content)) { out.push({ issueType:'viewportMetaMissing', severity:'high', selector:'meta[name="viewport"]', description:`Viewport meta has maximum-scale=1 — restricts pinch zoom (accessibility violation). content="${content}"` }); } } if (!document.documentElement.hasAttribute('lang')) { out.push({ issueType:'missingLang', severity:'high', selector:'html', description:" element is missing the lang attribute — add lang='en' (or appropriate language code)" }); } let skipFound = false; for (const a of document.querySelectorAll('a[href^="#"]')) { if (/skip|jump|main.?content|content/i.test(a.innerText)) { skipFound = true; break; } } if (!skipFound) { out.push({ issueType:'noSkipLink', severity:'medium', selector:null, description:"Page has no skip-to-content link — add Skip to content as the first focusable element" }); } for (const el of document.querySelectorAll('button, [role="button"]')) { if (out.length >= 20) break; // Skip buttons that are not rendered — inside collapsed sidebars, hidden panels, // or framework-internal ghost elements (BBox 0×0 = user never sees or interacts with it) const br = el.getBoundingClientRect(); if (br.width === 0 && br.height === 0) continue; // Also skip if any ancestor is display:none / visibility:hidden let hidden = false; let node = el.parentElement; while (node && node !== document.documentElement) { const s = getComputedStyle(node); if (s.display === 'none' || s.visibility === 'hidden') { hidden = true; break; } node = node.parentElement; } if (hidden) continue; // Skip elements positioned ENTIRELY OUTSIDE the viewport — off-screen drawers / settings // panels that overflow past the edge (their toggles sit at e.g. x=1465 on a 1280 viewport). // The user can't see or reach them, so flagging them as "no accessible name" is noise and // the annotation lands off-screen. (The off-screen panel itself is a separate layout finding.) const vw = window.innerWidth, vh = window.innerHeight; if (br.right <= 0 || br.bottom <= 0 || br.left >= vw || br.top >= vh) continue; // Skip password show/hide toggle buttons — qa-form-a11y owns password field accessibility. // Pattern: small button (≤40px) inside a container that also holds input[type="password"]. const isSmall = br.width <= 40 && br.height <= 40; if (isSmall) { const pwdParent = el.closest('div, fieldset, form, .input-group, .input-wrapper, .p-input-icon-right, .p-password'); if (pwdParent && pwdParent.querySelector('input[type="password"]')) continue; } // Skip chart library interactive elements — ApexCharts, Chart.js, Recharts, Highcharts, and // similar libraries attach role="button" to chart segments, slices, and legend items for // keyboard interactivity. These are NOT real buttons and have no innerText by design. // Flagging them produces false positives (e.g. "BUTTON NO NAME" on a donut chart slice). const inChartContainer = el.closest( '[class*="apexchart"], [class*="ApexChart"], [class*="recharts"], [class*="Recharts"],' + '[class*="highchart"], [class*="Highchart"], [class*="chartjs"], [class*="ChartJs"],' + '[class*="chart-container"], [class*="chart-wrap"], [class*="chart-widget"],' + 'svg, canvas' ); if (inChartContainer) continue; // Also skip any element whose tag is an SVG element (path, circle, g, rect — chart segments) if (el.ownerSVGElement || el.tagName === 'path' || el.tagName === 'circle' || el.tagName === 'g' || el.tagName === 'rect' || el.tagName === 'polygon') continue; const text = (el.innerText || el.value || '').trim(); const aria = (el.getAttribute('aria-label') || '').trim(); const title = (el.getAttribute('title') || '').trim(); // aria-labelledby may be a SPACE-SEPARATED list of IDs (Material slide-toggle, etc.) — resolve ALL. const labelledBy = el.getAttribute('aria-labelledby'); const labelText = labelledBy ? labelledBy.split(/\s+/).map(id => ((document.getElementById(id) || {}).innerText || '').trim()).join(' ').trim() : ''; // A wrapping labeled component (mat-slide-toggle, a