---
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