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