From ac0da3175985cbb1146d52dae727b1df5f7f61e6 Mon Sep 17 00:00:00 2001 From: Aayam Bansal Date: Sun, 26 Jul 2026 14:14:04 +0800 Subject: [PATCH] feat: target-spacing check (WCAG 2.5.8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small adjacent tap targets closer than 24px between centers get flagged — the fat-finger failure the 24px size check alone misses. --- src/preview/cdp.ts | 18 ++++++++++++++++++ test/cdp.test.ts | 2 ++ 2 files changed, 20 insertions(+) diff --git a/src/preview/cdp.ts b/src/preview/cdp.ts index 25e1589..1c50fee 100644 --- a/src/preview/cdp.ts +++ b/src/preview/cdp.ts @@ -147,6 +147,24 @@ const A11Y_AUDIT = `(() => { if (r.width > 0 && (r.width < 24 || r.height < 24)) tiny++; } if (tiny > 0) out.push(tiny + ' interactive element(s) smaller than 24x24px'); + // Target spacing (WCAG 2.5.8): small adjacent targets need 24px of + // clearance between centers, or fingers hit the wrong one. + const targets = [...document.querySelectorAll('a[href], button, [role="button"], input:not([type=hidden])')] + .map((el) => el.getBoundingClientRect()) + .filter((r) => r.width > 0 && r.height > 0 && (r.width < 24 || r.height < 24)); + let cramped = 0; + for (let i = 0; i < targets.length && cramped < 1; i++) { + for (let j = i + 1; j < targets.length; j++) { + const a = targets[i], b = targets[j]; + const dx = Math.abs((a.left + a.right) / 2 - (b.left + b.right) / 2); + const dy = Math.abs((a.top + a.bottom) / 2 - (b.top + b.bottom) / 2); + if (dx < 24 && dy < 24) { + out.push('target spacing: small tap targets sit closer than 24px apart (WCAG 2.5.8) — enlarge them or add spacing'); + cramped++; + break; + } + } + } for (const el of document.querySelectorAll('[tabindex]')) { if (Number(el.getAttribute('tabindex')) > 0) out.push('positive tabindex ' + short(el)); } diff --git a/test/cdp.test.ts b/test/cdp.test.ts index 6553c08..a61c8a1 100644 --- a/test/cdp.test.ts +++ b/test/cdp.test.ts @@ -107,6 +107,7 @@ describe.skipIf(!chrome || !hasWebSocket())('cdpCapture (requires Chrome + WebSo +
ab
x
y
@@ -174,6 +175,7 @@ describe.skipIf(!chrome || !hasWebSocket())('cdpCapture (requires Chrome + WebSo expect(findings).toContain('autocomplete="off" on an identity field') expect(findings).toContain('top-layer: is a hand-rolled full-screen modal') expect(findings).toMatch(/semantic gap: \d+ of \d+ interactive elements have no accessible name/) + expect(findings).toContain('target spacing: small tap targets sit closer than 24px apart') expect(findings).toContain('autocomplete="firstname" ends in "firstname", not a valid field token') expect(findings).not.toContain('autofill: input#goodtok') expect(findings).not.toContain('real-desc')