From d954ea7444f222a280584f17e9e6ed00eb13f69c Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Fri, 12 Jun 2026 20:00:35 -0700 Subject: [PATCH] feat(#1668): axe-core CI gate for WCAG AA color-contrast (M5) (#1696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partial fix for #1668 (M5 of 6). After M1 (audit), M2 (color tokens, #1676), M3 (typography floor, #1679), and M4 (per-route polish, #1681) cleared ~95% of contrast/typography violations, M5 **locks in the wins** by adding an axe-core CI gate that fails the build on any new WCAG AA color-contrast regression. ## What's in the box - `test-a11y-axe-1668.js` — Playwright + `@axe-core/playwright`. Runs every major CoreScope route × `{dark, light}` at 1200×900 desktop, injects axe, runs only the `color-contrast` rule, asserts net violations === 0. - `test-a11y-axe-1668-selftest.js` — fast, deterministic, browser-free unit test that exercises the YAML allowlist parser, the `violationAllowed` matcher, and the route/theme metadata. Runs in the JS unit block (no browser needed). - `tests/a11y-allowlist.yaml` — operator-flagged false-positive allowlist. **0 entries at M5 baseline.** ## Allowlist format Each entry MUST cite a GH issue # and an `expires_at` date. Missing fields = refused. Expired `expires_at` = refused (warning logged). This **forces a periodic revisit** — no permanent suppressions. ```yaml - route: /analytics?tab=channels selector: ".some-known-stale-element" rule: color-contrast issue: 1234 expires_at: 2026-09-01 ``` ## Routes covered (19 × 2 themes = 38 cells) `/`, `/packets`, `/nodes`, `/channels`, `/live`, `/map`, `/observers`, `/compare`, `/analytics?tab={overview,rf,topology,channels,hashsizes,collisions,roles,airtime}`, `/audio-lab`, `/customize`, `/replay`. ## TDD red→green - **RED** (`08adafdb`) — adds the gate + deliberately regresses `--text-muted` from `palette-gray-700` (~10:1) to `#9ca3af` (~2.4:1). axe-core fails on every light-theme cell. - **GREEN** (`f62fb1e0`) — restores the M2 token. Net violations = 0 across all 38 cells. ## Scope discipline - Only `color-contrast` (matches M2/M3/M4 scope). M6 owns `image-alt`, `aria-required-attr`, `label`, mobile viewports, and letsmesh A/B. - No new design tokens. - M2-M4 tokens untouched. ## CI wiring - `.github/workflows/deploy.yml:155` — selftest in JS unit block. - `.github/workflows/deploy.yml:367` — real axe browser run in the Playwright E2E block after the fixture server is up. ## Deps `@axe-core/playwright@4.11.3` + `axe-core@4.12.1` added to `devDependencies`. Pinned versions. --------- Co-authored-by: openclaw-bot Co-authored-by: clawbot --- .github/workflows/deploy.yml | 6 + package-lock.json | 39 +++- package.json | 4 +- public/analytics.js | 62 +++--- public/audio-lab.js | 8 +- public/customize-v2.js | 8 +- public/customize.js | 4 +- public/home.js | 8 +- public/live.js | 14 +- public/map.js | 6 +- public/node-analytics.js | 4 +- public/nodes.js | 14 +- public/packets.js | 2 +- public/roles.js | 47 +++- public/style.css | 135 +++++++---- public/traces.js | 4 +- test-a11y-axe-1668-selftest.js | 243 ++++++++++++++++++++ test-a11y-axe-1668.js | 332 ++++++++++++++++++++++++++++ test-issue-1281-location-row-e2e.js | 35 +-- tests/a11y-allowlist.yaml | 20 ++ 20 files changed, 870 insertions(+), 125 deletions(-) create mode 100644 test-a11y-axe-1668-selftest.js create mode 100644 test-a11y-axe-1668.js create mode 100644 tests/a11y-allowlist.yaml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a83cf363..e1d0609f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -151,6 +151,7 @@ jobs: node test-warmup-banner.js node test-issue-1633-hide-1byte-hops.js node test-issue-1668-m4-per-route.js + node test-a11y-axe-1668-selftest.js - name: 🛡️ Preflight XSS gate — actual --diff check (PR only) # The fixture self-test above (test-preflight-xss-gate.js) only @@ -362,6 +363,11 @@ jobs: - name: Run Playwright E2E tests (fail-fast) run: | BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt + # M5 of #1668 — axe-core CI gate (color-contrast AA). + # Real browser run; fails on any net violation (raw − allowlist). + # Allowlist: tests/a11y-allowlist.yaml (0 entries at M5 baseline). + BASE_URL=http://localhost:13581 AXE_SCREENSHOT_DIR=/tmp/axe-1668 \ + node test-a11y-axe-1668.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-channel-issue-1087-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-channel-issue-1111-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/package-lock.json b/package-lock.json index 2a938f20..5a3106af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meshcore-analyzer", - "version": "2.6.0", + "version": "0.0.0-use-git-tags", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meshcore-analyzer", - "version": "2.6.0", + "version": "0.0.0-use-git-tags", "license": "ISC", "dependencies": { "@michaelhart/meshcore-decoder": "^0.2.7", @@ -16,11 +16,36 @@ "ws": "^8.19.0" }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", + "axe-core": "^4.12.1", "nyc": "^18.0.0", "playwright": "^1.58.2", "supertest": "^7.2.2" } }, + "node_modules/@axe-core/playwright": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz", + "integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.4" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@axe-core/playwright/node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -566,6 +591,16 @@ "dev": true, "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", diff --git a/package.json b/package.json index ce49e309..9f588656 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,10 @@ "ws": "^8.19.0" }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", + "axe-core": "^4.12.1", "nyc": "^18.0.0", "playwright": "^1.58.2", "supertest": "^7.2.2" } -} \ No newline at end of file +} diff --git a/public/analytics.js b/public/analytics.js index 4b6fa032..63e22c2c 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -502,7 +502,7 @@ '
' + 'cnt ' + cpct.toFixed(1) + '%' + '  ·  ' + - 'air ' + apct.toFixed(1) + '%' + + 'air ' + apct.toFixed(1) + '%' + '
' + ''; }); @@ -1364,7 +1364,7 @@ var roleColor = (window.ROLE_COLORS || {})[r.role] || '#6b7280'; return '' + '' + esc(r.name) + '' + - '' + esc(r.role || 'unknown') + '' + + '' + esc(r.role || 'unknown') + '' + '' + (statusIcon[r.status] || '') + ' ' + (statusLabel[r.status] || 'Unknown') + '' + '' + r.hashSize + '-byte' + @@ -1458,19 +1458,19 @@ async function renderCollisionTab(el, data, collisionData) { el.innerHTML = ` -

Collisions actually observed in packet traffic — among repeaters grouped by their configured hash size. For theoretical address conflicts that would occur if all repeaters used a given hash size, see the Prefix Tool tab.

+

Collisions actually observed in packet traffic — among repeaters grouped by their configured hash size. For theoretical address conflicts that would occur if all repeaters used a given hash size, see the Prefix Tool tab.

Inconsistent Hash Sizes

↑ top
-

Repeaters and room servers sending adverts with varying hash sizes in the last 7 days. Originally caused by a firmware bug where automatic adverts ignored the configured multibyte path setting, fixed in repeater v1.14.1. Companion nodes are excluded.

+

Repeaters and room servers sending adverts with varying hash sizes in the last 7 days. Originally caused by a firmware bug where automatic adverts ignored the configured multibyte path setting, fixed in repeater v1.14.1. Companion nodes are excluded.

Loading…
@@ -1515,8 +1515,8 @@ }).join(' '); const stripe = i % 2 === 1 ? 'background:var(--row-stripe)' : ''; return ` - ${esc(n.name || n.public_key.slice(0, 12))} - ${n.role} + ${esc(n.name || n.public_key.slice(0, 12))} + ${n.role} ${prefix} (${n.hash_size || '?'}B) ${sizeBadges} `; @@ -1792,7 +1792,7 @@ if (bytes === 3) { el.innerHTML = hashStatCardsHtml(totalNodes, stats.using_this_size || 0, '3-byte', 16777216, stats.unique_prefixes || 0, stats.collision_count || 0) + `

The 3-byte prefix space (16.7M values) is too large to visualize as a grid.${(stats.collision_count || 0) > 0 ? ' See collision details below.' : ''}

` + - `

ℹ️ This tab only counts collisions among repeaters configured for this hash size. The Prefix Tool checks all repeaters regardless of configured hash size.

`; + `

ℹ️ This tab only counts collisions among repeaters configured for this hash size. The Prefix Tool checks all repeaters regardless of configured hash size.

`; return; } @@ -1892,7 +1892,7 @@ for (const [twoHex, nodes] of Object.entries(info.two_byte_map || {}).sort()) { const isCollision = nodes.length > 1; dhtml += `
`; - dhtml += `${twoHex}${isCollision ? ' COLLISION' : ''} `; + dhtml += `${twoHex}${isCollision ? ' COLLISION' : ''} `; dhtml += nodes.map(m => `${esc(m.name || m.public_key.slice(0,12))}`).join(', '); dhtml += `
`; } @@ -2228,7 +2228,7 @@ return `${esc(n.name || n.public_key.slice(0, 12))}`; } function claimedBadge(n) { - return myKeys.has(n.public_key) ? ' MINE' : ''; + return myKeys.has(n.public_key) ? ' MINE' : ''; } // ROLE_COLORS from shared roles.js @@ -2259,7 +2259,7 @@
${Object.entries(roleCounts).sort((a,b) => b[1]-a[1]).map(([role, count]) => { const c = ROLE_COLORS[role] || '#6b7280'; - return `${role}: ${count}`; + return `${role}: ${count}`; }).join('')}
@@ -2271,7 +2271,7 @@ const s = n.health.stats; return ` ${nodeLink(n)} - ${n.role} + ${n.role} ${s.totalTransmissions || s.totalPackets || 0} ${s.avgSnr != null ? s.avgSnr.toFixed(1) + ' dB' : '—'} ${n.health.observers?.length || 0} @@ -2288,7 +2288,7 @@ ${byPackets.slice(0, 15).map((n, i) => ` ${i + 1} ${nodeLink(n)}${claimedBadge(n)} - ${n.role} + ${n.role} ${n.health.stats.totalTransmissions || n.health.stats.totalPackets || 0} ${n.health.stats.packetsToday || 0} @@ -2303,7 +2303,7 @@ ${bySnr.slice(0, 15).map((n, i) => ` ${i + 1} ${nodeLink(n)}${claimedBadge(n)} - ${n.role} + ${n.role} ${n.health.stats.avgSnr.toFixed(1)} dB ${n.health.observers?.length || 0} @@ -2318,7 +2318,7 @@ ${byObservers.slice(0, 15).map((n, i) => ` ${i + 1} ${nodeLink(n)}${claimedBadge(n)} - ${n.role} + ${n.role} ${n.health.observers?.length || 0} ${n.health.stats.avgSnr != null ? n.health.stats.avgSnr.toFixed(1) + ' dB' : '—'} @@ -2332,7 +2332,7 @@ ${byRecent.slice(0, 15).map(n => ` ${nodeLink(n)}${claimedBadge(n)} - ${n.role} + ${n.role} ${timeAgo(n.health.stats.lastHeard)} ${n.health.stats.packetsToday || 0} @@ -3028,7 +3028,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = const rows = entries.map(([prefix, arr]) => { const nodeLinks = arr.map(n => { const label = esc(n.name || n.public_key.slice(0, 10)); - return `${label}`; + return `${label}`; }).join(', '); return ` ${esc(prefix)} @@ -3061,7 +3061,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = const initGenerate = hashParams.get('generate') || ''; const regionNote = regionLabel - ? `

Showing data for region: ${esc(regionLabel)}. Check all repeaters →

` + ? `

Showing data for region: ${esc(regionLabel)}. Check all repeaters →

` : ''; el.innerHTML = ` @@ -3104,7 +3104,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
@@ -3174,7 +3174,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = 0x00 and 0xFF excluded as a first byte — the MeshCore firmware keygen routine re-rolls identities whose pub_key[0] is 00 or FF, so by convention you should not see those prefixes on real nodes (see simple_repeater/main.cpp:83). + target="_blank" rel="noopener noreferrer" style="color:var(--link-color)">simple_repeater/main.cpp:83).