#!/usr/bin/env node /** * check-a11y-button-names.mjs — every button must have an ACCESSIBLE NAME. * * THE BUG THIS EXISTS TO PREVENT (real, 2026-09-21) * Every icon-only map control (zoom in/out, fullscreen, location, help, share, hamburger, theme) * carried ONLY `title`. A `title` is a TOOLTIP, not an accessible name: it is unreliably announced * by screen readers and never surfaced to touch users at all. So the accessible name was EMPTY and * assistive technology announced "button" with no indication of what it does — on 34 controls * across 7 map bundles, shipped and live. * * ★ SOURCE GREP CANNOT ANSWER THIS. `grep ' { window.MAP_TOKEN = v }, tk) const url = `${BASE}${t.bundle}/${t.profile}/index.html?cb=${Date.now()}` + (tk ? `&tk=${encodeURIComponent(tk)}` : '') try { await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 }) } catch { await page.close(); continue } await page.waitForTimeout(8000) reachable++ const r = await page.evaluate(() => { const deep = (root, sel, out = []) => { out.push(...root.querySelectorAll(sel)) for (const e of root.querySelectorAll('*')) if (e.shadowRoot) deep(e.shadowRoot, sel, out) return out } const host = [...document.querySelectorAll('*')].find((e) => e.tagName.includes('-') && e.shadowRoot) if (!host) return { mounted: false } const btns = deep(host.shadowRoot, 'button') const bad = [] for (const b of btns) { const aria = (b.getAttribute('aria-label') || '').trim() if (aria) continue const by = (b.getAttribute('aria-labelledby') || '').trim() if (by) { // an id reference resolves within the SAME root, not across a shadow boundary const rootNode = b.getRootNode() const txt = by.split(/\s+/) .map((id) => (rootNode.getElementById ? rootNode.getElementById(id) : null)) .filter(Boolean).map((e) => (e.textContent || '').trim()).join(' ').trim() if (txt) continue } if ((b.textContent || '').trim()) continue // visible text IS an accessible name // hidden buttons cannot be reached by AT either; do not report them const rect = b.getBoundingClientRect() if (rect.width === 0 || rect.height === 0) continue bad.push((b.title || '').trim() || '(no title either)') } return { mounted: true, total: btns.length, bad } }) if (r.mounted) { checked += r.total named += r.total - r.bad.length if (r.bad.length) offenders.push({ where: `${t.bundle}/${t.profile}`, names: r.bad }) } await page.close() } await browser.close() console.log(`A11Y-BUTTON-NAMES: ${reachable} leaf page(s) checked, ${checked} button(s), ` + `${named} with an accessible name.`) if (!tk) console.log(' i no Mapbox token — map controls may not have mounted on some leaves.') if (offenders.length) { const n = offenders.reduce((a, o) => a + o.names.length, 0) console.error(`\nA11Y-BUTTON-NAMES: FAIL — ${n} visible button(s) have NO accessible name:`) for (const o of offenders) console.error(` ✗ ${o.where}: ${o.names.join(', ')}`) console.error( '\n A `title` is a TOOLTIP, not an accessible name — unreliably announced, and never shown to\n' + ' touch users. Give the button an aria-label (mirroring title is usually right), or visible\n' + ' text. For shared controls, fix the BASE component once: one binding in MapControlButton\n' + ' covered 8 control types across 7 bundles.' ) process.exit(1) } console.log('A11Y-BUTTON-NAMES: PASS — every visible button has an accessible name.')