/** * contrast-probe.mjs — 站点 chrome 的对比度探针(浏览器内测量,零依赖)。 * * 判据:正文 ≥4.5:1(WCAG 1.4.3 AA),大字(≥24px 或 ≥18.66px 粗体)与图标字形 ≥3:1(1.4.11)。 * 半透明背景沿父链合成后再算比值 —— 品牌浅底(`--kole-color-brand-bg`)就是半透明的, * 不合成会算错(实测差异可达 1 个数量级)。 * * 用途:`tools/verify-dark.mjs`(暗色 7 条路由)与 `tools/verify-theme.mjs`(亮/暗双模式) * 共用同一套判据,避免两处各写一份、改一处漏一处。 */ export const PROBE_SCOPE = '#sidebar *, #content *, .topbar *'; export async function probeContrast(page, { scope = PROBE_SCOPE, limit = 600 } = {}) { return page.evaluate( ({ scopeSel, maxNodes }) => { const bad = []; const toRGBA = (s) => { const m = (s || '').match(/rgba?\(([^)]+)\)/); if (!m) { if (/^#([0-9a-fA-F]{6})$/.test(s)) { const c = s.slice(1); return [parseInt(c.substr(0, 2), 16), parseInt(c.substr(2, 2), 16), parseInt(c.substr(4, 2), 16), 1]; } return null; } const p = m[1].split(',').map((x) => parseFloat(x)); return [p[0], p[1], p[2], p.length > 3 ? p[3] : 1]; }; const lumOf = (rgb) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]); }; const compBg = (el) => { let acc = null, p = el, d = 0; while (p && d < 10) { const rgba = toRGBA(getComputedStyle(p).backgroundColor); if (rgba) { if (!acc) acc = rgba.slice(); else { const a = rgba[3] + acc[3] * (1 - rgba[3]); acc = [ (rgba[0] * rgba[3] + acc[0] * acc[3] * (1 - rgba[3])) / (a || 1), (rgba[1] * rgba[3] + acc[1] * acc[3] * (1 - rgba[3])) / (a || 1), (rgba[2] * rgba[3] + acc[2] * acc[3] * (1 - rgba[3])) / (a || 1), a ]; } if (acc[3] >= 0.999) break; } p = p.parentElement; d++; } return acc && acc[3] > 0 ? lumOf(acc) : null; }; const isIcon = (t, cls) => /^[\s\u2000-\u3300\u2190-\u21FF\u2600-\u27BF\u2B00-\u2BFF×✕✓⚠⌄⌃‹›«»]+$/.test(t) || /\b(icon|arrow|caret|close|clear|toggle|sort|cross|star-|sep|bullet|chevron|indicator)\b/i.test(cls || ''); let checked = 0; for (const el of document.querySelectorAll(scopeSel)) { if (checked > maxNodes) break; const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) continue; const txt = (el.textContent || '').trim(); if (!txt) continue; let direct = false; for (const n of el.childNodes) if (n.nodeType === 3 && /\S/.test(n.nodeValue)) direct = true; if (!direct) continue; const rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) continue; if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true') continue; const fg = toRGBA(cs.color); const bg = compBg(el); if (!fg || bg === null) continue; const r = (Math.max(lumOf(fg), bg) + 0.05) / (Math.min(lumOf(fg), bg) + 0.05); const fs = parseFloat(cs.fontSize) || 14; const fw = parseInt(cs.fontWeight, 10) || (cs.fontWeight === 'bold' ? 700 : 400); const large = fs >= 24 || (fs >= 18.66 && fw >= 700); const min = isIcon(txt, el.className) ? 3.0 : large ? 3.0 : 4.5; checked++; if (r < min) { bad.push( el.tagName.toLowerCase() + '.' + String(el.className || '').trim().split(/\s+/)[0] + ' "' + txt.slice(0, 10) + '" ' + r.toFixed(2) + ':1<' + min ); } if (bad.length >= 5) break; } return { checked, bad }; }, { scopeSel: scope, maxNodes: limit } ); }