diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 35adcdca..f23dc97a 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -149,6 +149,7 @@ jobs:
node test-mqtt-status-panel.js
node test-warmup-banner.js
node test-issue-1633-hide-1byte-hops.js
+ node test-issue-1668-m4-per-route.js
- name: π‘οΈ Preflight XSS gate β actual --diff check (PR only)
# The fixture self-test above (test-preflight-xss-gate.js) only
diff --git a/public/analytics.js b/public/analytics.js
index 99cec728..4b6fa032 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -1718,7 +1718,13 @@
if (!isConfirmedCollision && !isPossibleConflict) return { cls: 'hash-cell-taken', bg: '' };
if (isPossibleConflict) return { cls: 'hash-cell-possible', bg: '' };
const t = Math.min((count - 2) / 4, 1);
- return { cls: 'hash-cell-collision', bg: `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);` };
+ // #1668-M4 r1: gradient endpoints darkened from orange-500βred-500 (3.11:1/3.96:1 vs #fff)
+ // to orange-800βred-800 (7.31:1β8.31:1 vs #fff) β passes WCAG AA across the whole range.
+ // Encoding stays distinguishable from green-700 (taken) and yellow-800 (possible).
+ const cr = Math.round(154 + (-1) * t); // 154 β 153
+ const cg = Math.round(52 + (-25) * t); // 52 β 27
+ const cb = Math.round(18 + 9 * t); // 18 β 27
+ return { cls: 'hash-cell-collision', bg: `background:rgb(${cr},${cg},${cb});` };
}
// hashCellTd β emits a hash-matrix
. Tooltip data rides as separate
@@ -1737,7 +1743,7 @@
const linesAttr = (spec.lines && spec.lines.length)
? ' data-tip-lines="' + esc(spec.lines.join('\u001f')) + '"'
: '';
- return ` | ${hex} | `;
+ return `${hex} | `;
}
// hashTooltipSpec β returns a plain-data tooltip descriptor consumed by
@@ -1819,7 +1825,7 @@
{cls: 'hash-cell-empty', style: 'border:1px solid var(--border)', text: 'Available'},
{cls: 'hash-cell-taken', text: 'One node'},
{cls: 'hash-cell-possible', text: 'Possible conflict'},
- {cls: 'hash-cell-collision', style: 'background:rgb(220,80,30)', text: 'Collision'}
+ {cls: 'hash-cell-collision', style: 'background:rgb(154,40,22)', text: 'Collision'}
],
(td) => {
const hex = td.dataset.hex.toUpperCase();
@@ -1866,7 +1872,7 @@
{cls: 'hash-cell-empty', style: 'border:1px solid var(--border)', text: 'No nodes in group'},
{cls: 'hash-cell-taken', text: 'Nodes present, no collision'},
{cls: 'hash-cell-possible', text: 'Possible conflict'},
- {cls: 'hash-cell-collision', style: 'background:rgb(220,80,30)', text: 'Collision'}
+ {cls: 'hash-cell-collision', style: 'background:rgb(154,40,22)', text: 'Collision'}
],
(td) => {
const hex = td.dataset.hex.toUpperCase();
diff --git a/public/live.css b/public/live.css
index ac5497d7..92e94121 100644
--- a/public/live.css
+++ b/public/live.css
@@ -57,6 +57,7 @@
.live-feed-empty {
color: var(--text-muted);
font-size: 12px;
+ font-weight: 500; /* #1668-M4 β 12px must pair with >=500 weight per M3 floor */
padding: 12px 8px;
text-align: center;
}
@@ -961,7 +962,8 @@ body.live-fullscreen #liveFullscreenToggle:hover,
color: var(--text);
border-radius: 6px;
padding: 6px 14px;
- font-size: 0.9rem;
+ font-size: 14px; /* #1668-M4 β was 0.9rem (~14.4px) but with 400 weight; lock to 14px floor */
+ font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
@@ -1021,13 +1023,16 @@ input.live-node-filter-input:focus {
}
.vcr-live-btn {
- background: rgba(239, 68, 68, 0.2);
- color: var(--status-red);
+ /* #1668-M4 r1: --status-red (#ef4444) vs #fff = 3.76:1 β fails AA. Carve-out to red-700
+ (#b91c1c, 6.47:1 vs #fff) on this single surface to avoid touching --status-red token
+ which is consumed by other surfaces (dots, borders, etc) where it's used as fg/accent. */
+ background: #b91c1c;
+ color: #fff;
font-weight: 700;
- font-size: 0.7rem;
+ font-size: 12px; /* #1668-M4 β 0.7rem (~11.2px) is below floor; lock to 12px+700 */
letter-spacing: 0.05em;
}
-.vcr-live-btn:hover { background: rgba(239, 68, 68, 0.35); }
+.vcr-live-btn:hover { background: color-mix(in srgb, #b91c1c 85%, #000); }
.vcr-mode {
font-size: 0.75rem;
@@ -1119,7 +1124,8 @@ input.live-node-filter-input:focus {
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
- font-size: 0.75rem;
+ font-size: 12px; /* #1668-M4 β was 0.75rem (~12px) but with 400 weight (thin-small); lock 12px+500 */
+ font-weight: 500;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
@@ -1134,9 +1140,10 @@ input.live-node-filter-input:focus {
transition: color 0.15s, transform 0.15s;
}
.vcr-scope-btn.active {
- background: var(--accent-bg, rgba(59,130,246,0.2));
- color: var(--text);
- border-color: var(--accent-border, rgba(59,130,246,0.3));
+ background: var(--accent-strong); /* #1668-M4 β was --accent-bg wash (2.98:1 BLOCKER); --accent-strong + on-accent locks AA */
+ color: var(--text-on-accent);
+ border-color: var(--accent-strong);
+ font-weight: 600;
}
.vcr-timeline-container {
@@ -1259,6 +1266,9 @@ input.live-node-filter-input:focus {
* Level AAA recommends 44px but Level AA accepts 24px; here we keep
* 32px which still meets AA on mobile while letting all controls
* occupy one row at the 375px breakpoint called out in #1244. */
+ /* #1668-M4 r3 (#1221 regression): r0βr2 attempts to bump VCR label fs to 12px on mobile
+ * (with padding tightening) all clipped the LCD at vw=375. Reverted to pre-PR mobile
+ * values; desktop M4 typography wins (above this @media block) remain. */
.vcr-btn { padding: 4px 6px; font-size: 0.7rem; min-height: 32px; min-width: 32px; }
.vcr-scope-btn { font-size: 0.6rem; padding: 2px 5px; min-height: 32px; min-width: 0; }
.vcr-prompt { order: 5; width: 100%; font-size: 0.7rem; }
diff --git a/public/roles.js b/public/roles.js
index 2a425e57..940412f0 100644
--- a/public/roles.js
+++ b/public/roles.js
@@ -178,16 +178,58 @@
UNKNOWN: 'unknown'
};
- // Generate badge CSS from TYPE_COLORS β single source of truth
+ // Generate badge CSS from TYPE_COLORS β single source of truth.
+ // #1668-M4: pick a readable foreground per badge background, and DARKEN
+ // the bg (only for the badge β TYPE_COLORS itself is untouched, so map
+ // dots / markers / live-feed dots keep their full-saturation hue) until
+ // the fg/bg pair clears WCAG AA (β₯4.5:1). The legacy translucent wash
+ // `${color}20` with `color: ${color}` failed AA for all 14 payload types
+ // (M1 audit; ratios 1.0β4.25, BLOCKER).
window.syncBadgeColors = function() {
var el = document.getElementById('type-color-badges');
if (!el) { el = document.createElement('style'); el.id = 'type-color-badges'; document.head.appendChild(el); }
+ function hexToRgb(hex) {
+ var h = hex.replace('#','');
+ return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)];
+ }
+ function rgbToHex(rgb) {
+ return '#' + rgb.map(function(v){ var s = Math.max(0,Math.min(255,Math.round(v))).toString(16); return s.length===1?'0'+s:s; }).join('');
+ }
+ function relLum(rgb) {
+ var lin = function(v){ var s=v/255; return s<=0.03928 ? s/12.92 : Math.pow((s+0.055)/1.055, 2.4); };
+ return 0.2126*lin(rgb[0]) + 0.7152*lin(rgb[1]) + 0.0722*lin(rgb[2]);
+ }
+ function contrast(a, b) {
+ var l1 = relLum(a), l2 = relLum(b);
+ var hi = l1>l2?l1:l2, lo = l1>l2?l2:l1;
+ return (hi+0.05) / (lo+0.05);
+ }
+ function readableFg(rgb) {
+ // perceived luminance (Rec.601) β matches the M4 test's pick
+ var lum = (0.299*rgb[0] + 0.587*rgb[1] + 0.114*rgb[2]) / 255;
+ return lum > 0.55 ? [26,26,46] : [255,255,255];
+ }
+ function adjustForAA(bg, fg) {
+ // Darken (when white fg) or lighten (when dark fg) the bg until
+ // contrast >= 4.5, max 12 steps of 8%. Preserves hue.
+ var c = contrast(fg, bg);
+ if (c >= 4.5) return bg;
+ var darkening = fg[0] > 128; // white fg β darken bg
+ var out = bg.slice();
+ for (var i=0; i<12 && contrast(fg, out) < 4.5; i++) {
+ out = out.map(function(v){ return darkening ? v*0.92 : v + (255-v)*0.08; });
+ }
+ return out.map(function(v){ return Math.round(v); });
+ }
var css = '';
for (var type in TYPE_BADGE_MAP) {
var color = window.TYPE_COLORS[type];
if (!color) continue;
var cls = TYPE_BADGE_MAP[type];
- css += '.badge-' + cls + ' { background: ' + color + '20; color: ' + color + '; }\n';
+ var bg = hexToRgb(color);
+ var fg = readableFg(bg);
+ var adj = adjustForAA(bg, fg);
+ css += '.badge-' + cls + ' { background: ' + rgbToHex(adj) + '; color: rgb(' + fg.join(',') + '); }\n';
}
el.textContent = css;
};
diff --git a/public/style.css b/public/style.css
index 092f2cef..1f5e381b 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1454,7 +1454,7 @@ body.scroll-locked { overflow: hidden; }
= 2.75:1 on dark theme (BLOCKER). Now uses --accent-strong (#2563eb)
with --text-on-accent (gray-50) for 4.83:1 β WCAG AA body pass in
both themes. */
-.path-hops .hop-named { color: var(--text-on-accent); background: var(--accent-strong); padding: 1px 6px; border-radius: 3px; font-family: var(--font); font-weight: 600; cursor: default; flex: 0 0 auto; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 18px; }
+.path-hops .hop-named { color: var(--text-on-accent); background: var(--accent-strong); padding: 1px 6px; border-radius: 3px; font-family: var(--font); font-size: 12px; font-weight: 600; cursor: default; flex: 0 0 auto; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 18px; }
.path-hops .arrow { color: var(--text-muted); flex: 0 0 auto; line-height: 18px; }
/* #1122/#1128: bound the row height contributed by the path column.
* `max-height` on a is widely ignored by browsers (table layout
@@ -2690,10 +2690,23 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.hash-bar-fill { height: 100%; border-radius: 4px; transition: width .3s; }
.hash-cell.hash-active:hover { outline: 2px solid var(--accent); outline-offset: -2px; }
.hash-cell.hash-selected { outline: 2px solid var(--accent); outline-offset: -2px; box-shadow: 0 0 6px var(--accent); }
-.hash-cell-empty { background: var(--card-bg); color: var(--text-muted); }
-.hash-cell-taken { background: var(--status-green); color: #fff; }
-.hash-cell-possible { background: var(--status-yellow); color: #fff; }
-.hash-cell-collision { color: #fff; }
+.hash-cell-empty { background: var(--card-bg); color: var(--text-muted); font-size: 12px; font-weight: 500; }
+/* #1668-M4 r1: hash-cell semantic bgs carve out darker shades than the
+ --status-green / --status-yellow tokens so #fff text passes WCAG AA.
+ The status tokens themselves stay light (operator semantic "good"/"warn"
+ against page bg). Cell bgs need their own contrast budget vs #fff fg.
+ taken: #22c55e (2.28:1) -> #15803d (green-700, 5.02:1)
+ possible: #eab308 (1.92:1) -> #854d0e (yellow-800, 6.85:1)
+ See test-issue-1668-m4-per-route.js hash-cell contrast block. */
+.hash-cell-taken { background: #15803d; color: #fff; font-size: 12px; font-weight: 700; }
+.hash-cell-possible { background: #854d0e; color: #fff; font-size: 12px; font-weight: 700; }
+.hash-cell-collision { color: #fff; font-size: 12px; font-weight: 700; }
+/* #1668-M4 β per-route carve-outs.
+ Hash-grid cells use a SEMANTIC bg palette (green=taken, yellow=possible,
+ red-orange=collision). M2/M3 didn't fix these because the BG is the
+ encoding. We don't flatten it β instead we bump the TEXT weight to 600+
+ so 12px white-on-color clears AA (was 11px/400, M1 ~302 violations). */
+.hash-cell { font-size: 12px; font-weight: 600; }
/* #1473 β First-byte 0x00 / 0xFF pubkeys are avoided by the MeshCore
firmware keygen convention (NOT a protocol-level rejection):
https://github.com/meshcore-dev/MeshCore/blob/8ede7641/examples/simple_repeater/main.cpp#L83
diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js
index 45100ff8..72b2adc8 100644
--- a/test-e2e-playwright.js
+++ b/test-e2e-playwright.js
@@ -3404,28 +3404,29 @@ async function run() {
}
});
- await test('#1528 .vcr-scope-btn.active background tracks --accent-bg (token swap, not blue literal)', async () => {
+ await test('#1528 .vcr-scope-btn.active background tracks --accent-strong (token swap, not blue literal)', async () => {
+ // M4 #1668 swapped this rule from --accent-bg β --accent-strong to clear an
+ // AA contrast BLOCKER (2.98:1 on the wash). Both background and border now
+ // consume --accent-strong, so the test overrides that single token and
+ // asserts both properties follow it. Intent is unchanged: prove the rule
+ // tracks a token, not a hardcoded rgba(59,130,246,...) literal.
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.vcr-scope-btn.active', { timeout: 8000 });
const result = await page.evaluate(() => {
const el = document.querySelector('.vcr-scope-btn.active');
- // Override --accent-bg with a clearly non-blue sentinel so we can detect
- // whether the rule actually consumes the token (good) or a hardcoded
- // rgba(59,130,246,...) literal (bad β the theming illusion this fix targets).
- // Use !important so we beat any customizer-injected :root override.
- document.documentElement.style.setProperty('--accent-bg', 'rgb(255, 0, 0)', 'important');
- document.documentElement.style.setProperty('--accent-border', 'rgb(0, 200, 0)', 'important');
+ // Override --accent-strong with a clearly non-blue sentinel. Use !important
+ // so we beat any customizer-injected :root override.
+ document.documentElement.style.setProperty('--accent-strong', 'rgb(255, 0, 0)', 'important');
const bg = window.getComputedStyle(el).backgroundColor;
const border = window.getComputedStyle(el).borderColor;
- document.documentElement.style.removeProperty('--accent-bg');
- document.documentElement.style.removeProperty('--accent-border');
+ document.documentElement.style.removeProperty('--accent-strong');
return { bg, border };
});
- // Background should reflect our sentinel red, not blue.
+ // Both background and border should reflect our sentinel red, not blue.
assert(/^rgb\(255,\s*0,\s*0\)/.test(result.bg),
- `.vcr-scope-btn.active bg should track --accent-bg, got ${result.bg}`);
- assert(/^rgb\(0,\s*200,\s*0\)/.test(result.border),
- `.vcr-scope-btn.active border should track --accent-border, got ${result.border}`);
+ `.vcr-scope-btn.active bg should track --accent-strong, got ${result.bg}`);
+ assert(/^rgb\(255,\s*0,\s*0\)/.test(result.border),
+ `.vcr-scope-btn.active border should track --accent-strong, got ${result.border}`);
});
await browser.close();
diff --git a/test-issue-1668-m4-per-route.js b/test-issue-1668-m4-per-route.js
new file mode 100644
index 00000000..e5eada22
--- /dev/null
+++ b/test-issue-1668-m4-per-route.js
@@ -0,0 +1,463 @@
+/**
+ * test-issue-1668-m4-per-route.js
+ *
+ * Milestone 4 of #1668 β per-route polish. Targets the route-specific
+ * residuals that M2 (color tokens) and M3 (typography floor) didn't catch:
+ *
+ * - td.hash-cell + variants (collision / taken / possible / empty)
+ * - span.badge.badge-${type} (generated by public/roles.js syncBadgeColors)
+ * - .path-hops .hop-named (chip floor + on-accent token)
+ * - .alab-pkt (font-weight floor against alab background)
+ * - /live controls: .vcr-btn, .vcr-scope-btn, .vcr-live-btn,
+ * .live-title, .live-feed-empty, .nav-drawer-title
+ *
+ * Two-layer check:
+ * 1) CSS-rule scan β fs/fw floor (mirrors M3 helpers).
+ * 2) Badge contrast β extract TYPE_COLORS from public/roles.js and
+ * verify each emitted badge class meets WCAG AA against the FG/BG
+ * that syncBadgeColors() will write.
+ */
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const assert = require('assert');
+
+const repoRoot = __dirname;
+const cssPath = path.join(repoRoot, 'public', 'style.css');
+const liveCssPath = path.join(repoRoot, 'public', 'live.css');
+const audioLabJsPath = path.join(repoRoot, 'public', 'audio-lab.js');
+const rolesJsPath = path.join(repoRoot, 'public', 'roles.js');
+const css = fs.readFileSync(cssPath, 'utf8');
+const liveCss = fs.readFileSync(liveCssPath, 'utf8');
+const audioLabJs = fs.readFileSync(audioLabJsPath, 'utf8');
+const rolesJs = fs.readFileSync(rolesJsPath, 'utf8');
+
+// --- helpers (mirrors M3 test) ---------------------------------------------
+
+function resolveFontSizePx(value, allCss) {
+ if (!value) return NaN;
+ const v = value.trim();
+ let m = v.match(/^([0-9.]+)px$/);
+ if (m) return parseFloat(m[1]);
+ m = v.match(/^([0-9.]+)rem$/);
+ if (m) return parseFloat(m[1]) * 16;
+ m = v.match(/^clamp\(\s*([0-9.]+)(px|rem)\s*,/);
+ if (m) return m[2] === 'rem' ? parseFloat(m[1]) * 16 : parseFloat(m[1]);
+ m = v.match(/^var\(\s*(--[a-z0-9-]+)\s*\)$/i);
+ if (m) {
+ const tok = m[1];
+ const re = new RegExp(`${tok}\\s*:\\s*([^;]+);`);
+ const def = allCss.match(re);
+ if (def) return resolveFontSizePx(def[1].trim(), allCss);
+ }
+ return NaN;
+}
+
+function resolveFontWeight(value, allCss) {
+ if (!value) return NaN;
+ const v = value.trim();
+ if (/^\d+$/.test(v)) return parseInt(v, 10);
+ if (v === 'normal') return 400;
+ if (v === 'bold') return 700;
+ const m = v.match(/^var\(\s*(--[a-z0-9-]+)\s*\)$/i);
+ if (m) {
+ const tok = m[1];
+ const re = new RegExp(`${tok}\\s*:\\s*([^;]+);`);
+ const def = allCss.match(re);
+ if (def) return resolveFontWeight(def[1].trim(), allCss);
+ }
+ return NaN;
+}
+
+function parseDecl(body, prop) {
+ const re = new RegExp(`(?:^|;|\\{|\\s)${prop}\\s*:\\s*([^;{}]+)`, 'i');
+ const m = body.match(re);
+ return m ? m[1].trim() : null;
+}
+
+function stripMediaQueries(cssText) {
+ // Remove @media blocks (viewport-scoped overrides). M4 typography floor
+ // asserts the BASE/desktop rule; per-viewport overrides (e.g. .vcr-btn
+ // mobile carve-out at max-width:640px, see #1668-M4 r3 / #1221) are out
+ // of scope for the desktop-base contrast/typography gate. Strip CSS
+ // comments first so '@media' text in comments doesn't confuse matching.
+ cssText = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
+ let out = '';
+ let i = 0;
+ while (i < cssText.length) {
+ const idx = cssText.indexOf('@media', i);
+ if (idx === -1) { out += cssText.slice(i); break; }
+ out += cssText.slice(i, idx);
+ const open = cssText.indexOf('{', idx);
+ if (open === -1) { out += cssText.slice(idx); break; }
+ let depth = 1;
+ let j = open + 1;
+ while (j < cssText.length && depth > 0) {
+ const c = cssText[j];
+ if (c === '{') depth++;
+ else if (c === '}') depth--;
+ j++;
+ }
+ i = j;
+ }
+ return out;
+}
+
+function effective(selector, cssText) {
+ cssText = stripMediaQueries(cssText);
+ const re = /([^{}]*)\{([^}]*)\}/g;
+ let fsRaw = null;
+ let fwRaw = null;
+ let m;
+ while ((m = re.exec(cssText)) !== null) {
+ let selList = m[1].trim();
+ selList = selList.replace(/\/\*[\s\S]*?\*\//g, '').trim();
+ if (!selList || selList.startsWith('@')) continue;
+ const sels = selList.split(',').map((s) => s.trim());
+ const hit = sels.some((s) => {
+ if (s === selector) return true;
+ if (s.startsWith(selector + ':')) return true;
+ return false;
+ });
+ if (!hit) continue;
+ const fs2 = parseDecl(m[2], 'font-size');
+ const fw2 = parseDecl(m[2], 'font-weight');
+ if (fs2) fsRaw = fs2;
+ if (fw2) fwRaw = fw2;
+ }
+ return {
+ fontSize: resolveFontSizePx(fsRaw, cssText),
+ fontWeight: resolveFontWeight(fwRaw, cssText),
+ };
+}
+
+function passesFloor(fs, fw) {
+ if (Number.isNaN(fs)) return false;
+ if (fs >= 14) return true;
+ if (fs >= 12 && !Number.isNaN(fw) && fw >= 500) return true;
+ return false;
+}
+
+// --- contrast helpers ------------------------------------------------------
+
+function hexToRgb(hex) {
+ const h = hex.replace('#', '');
+ return [
+ parseInt(h.slice(0, 2), 16),
+ parseInt(h.slice(2, 4), 16),
+ parseInt(h.slice(4, 6), 16),
+ ];
+}
+
+function relLum([r, g, b]) {
+ const lin = (v) => {
+ const s = v / 255;
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
+ };
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
+}
+
+function contrast(fg, bg) {
+ const l1 = relLum(fg);
+ const l2 = relLum(bg);
+ const [a, b] = l1 > l2 ? [l1, l2] : [l2, l1];
+ return (a + 0.05) / (b + 0.05);
+}
+
+// --- 1) CSS rule floor checks ---------------------------------------------
+
+const RULE_TARGETS = [
+ // hash-cell carve-outs M4 adds β base rule must lock font-weight floor
+ ['.hash-cell', css, 'analytics hash-grid base β needs >=500 weight'],
+ ['.hash-cell-collision', css, 'analytics collision cell β semantic carve-out'],
+ ['.hash-cell-taken', css, 'analytics taken cell β semantic carve-out'],
+ ['.hash-cell-possible', css, 'analytics possible cell β semantic carve-out'],
+ // alab packet rows β M3 cleared font-size 12, M4 enforces weight floor
+ ['.alab-pkt', audioLabJs, 'audio-lab packet row β needs >=500 weight'],
+ // /live controls
+ ['.vcr-btn', liveCss, 'live VCR transport buttons'],
+ ['.vcr-scope-btn', liveCss, 'live VCR scope chips (1h/6h/12h/24h)'],
+ ['.vcr-live-btn', liveCss, 'live LIVE indicator chip'],
+ ['.live-title', liveCss, 'MESH LIVE title'],
+ ['.live-feed-empty', liveCss, 'live empty-feed placeholder'],
+ ['.nav-drawer-title', css, 'nav drawer title'],
+ // path-hops chips β M2 token reuse: verify floor still holds
+ ['.path-hops .hop-named', css, 'path-hop named chip β base floor'],
+];
+
+let ruleFails = 0;
+const ruleRows = [];
+for (const [sel, src, note] of RULE_TARGETS) {
+ const e = effective(sel, src);
+ const ok = passesFloor(e.fontSize, e.fontWeight);
+ ruleRows.push({ sel, fs: e.fontSize, fw: e.fontWeight, ok, note });
+ if (!ok) ruleFails++;
+}
+
+console.log('M4 per-route typography floor:');
+console.log('--------------------------------------------------');
+for (const r of ruleRows) {
+ const status = r.ok ? 'PASS' : 'FAIL';
+ console.log(
+ ` [${status}] ${r.sel.padEnd(28)} fs=${String(r.fs).padEnd(6)} fw=${String(r.fw).padEnd(5)} (${r.note})`
+ );
+}
+console.log('--------------------------------------------------');
+
+// --- 2) Badge contrast check from roles.js TYPE_COLORS --------------------
+
+const tcMatch = rolesJs.match(/TYPE_COLORS\s*=\s*\{([\s\S]*?)\};/);
+assert.ok(tcMatch, 'TYPE_COLORS not found in roles.js');
+const typeColors = {};
+const entryRe = /(\w+)\s*:\s*'(#[0-9a-f]{6})'/gi;
+let mm;
+while ((mm = entryRe.exec(tcMatch[1])) !== null) {
+ typeColors[mm[1]] = mm[2];
+}
+
+// Anti-tautology: actually EXECUTE syncBadgeColors in a sandbox and read
+// the emitted CSS, rather than pattern-matching the source. The contract
+// is: for every TYPE in TYPE_BADGE_MAP, the emitted `.badge-${cls}` rule
+// must produce a (fg,bg) pair that clears WCAG AA (>=4.5:1).
+
+const vm = require('vm');
+
+// Minimal DOM stub β syncBadgeColors only touches document.getElementById
+// / createElement / head.appendChild and sets textContent on the |