#!/usr/bin/env node /** * verify-dark.mjs — S2-P5 暗色模式独立验证(不进 _runtime 计数,守住 1003 基数) * * 1) 令牌组存在且完整(html.aa-dark ≥15 个 --au-) * 2) 暗色文字/品牌/语义色在深底上的 WCAG 对比度(文本 ≥4.5:1) * 3) 浏览器实测:抽样 10 个组件演示页,切 aa-dark 后无白底残留、无黑字黑底,对比度可读 * * 用法:node tools/verify-dark.mjs [--slugs button,input,...] [--shots] * 退出码:0 全过;1 有失败。 */ import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311'; const arg = (k) => { const i = process.argv.indexOf(k); return i > -1 ? process.argv[i + 1] : null; }; const SAMPLE = (arg('--slugs') || 'button,input,select,table,card,modal,tabs,formmodal,sidemenu,tag').split(','); const SHOTS = process.argv.includes('--shots'); function lum(hex) { const c = hex.replace('#', ''); const f = (i) => { const v = parseInt(c.substr(i, 2), 16) / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(0) + 0.7152 * f(2) + 0.0722 * f(4); } function ratio(a, b) { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); } let fail = 0; const ok = (cond, msg) => { console.log((cond ? 'PASS' : 'FAIL') + ' ' + msg); if (!cond) fail++; }; /* ---- 1) 令牌组 ---- */ const css = readFileSync(join(ROOT, '.design_library/aurora-admin/colors_and_type.css'), 'utf8'); ok(/html\.aa-dark/.test(css), '1) 令牌文件含 html.aa-dark 组'); const m = css.match(/html\.aa-dark\s*\{([^}]+)\}/s); const n = m ? (m[1].match(/--au-/g) || []).length : 0; ok(n >= 15, '1) 暗色令牌数 ' + n + '(预期 ≥15)'); /* ---- 2) 对比度 ---- */ const checks = [ ['E8EAED', '1C1F26', 'title/卡片底'], ['C9CDD4', '1C1F26', 'body/卡片底'], ['9CA3AF', '1C1F26', 'secondary/卡片底'], ['9CA3AF', '14161C', 'placeholder/页面底'], ['6B8CFF', '1C1F26', 'brand/卡片底'], ['6B8CFF', '14161C', 'brand/页面底'], ['4CAF50', '1C1F26', 'success'], ['E5A300', '1C1F26', 'warning'], ['F26D6D', '1C1F26', 'error'], ['4C9AFF', '1C1F26', 'info'] ]; for (const [f, b, label] of checks) { const r = ratio('#' + f, '#' + b); ok(r >= 4.5, '2) #' + f + '/#' + b + ' ' + r.toFixed(2) + ':1 ' + label); } /* ---- 3) 浏览器实测 ---- */ let chromium; try { ({ chromium } = await import('playwright')); } catch { console.error('[FATAL] 未安装 playwright'); process.exit(2); } const browser = await chromium.launch(); /* slug → 演示文件名:data.json 的 files.html 即权威路径(小写 slug.html) */ let prefixOf = null; try { const idx = await (await fetch(BASE + '/site/data.json')).json(); const map = {}; (idx.components || []).forEach((c) => { map[c.slug] = (c.files && c.files.html || '').split('/').pop() || (c.slug + '.html'); }); prefixOf = (slug) => map[slug] || (slug + '.html'); } catch (e) { prefixOf = (slug) => slug + '.html'; } try { for (const slug of SAMPLE) { const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); const url = BASE + '/frameworks/' + prefixOf(slug); let status = 0; try { const resp = await page.goto(url, { waitUntil: 'load', timeout: 15000 }); status = resp ? resp.status() : 0; } catch (e) { ok(false, '3) ' + slug + ' 页面加载失败 ' + e.message); await page.close(); continue; } if (status !== 200) { ok(false, '3) ' + slug + ' 演示页 HTTP ' + status); await page.close(); continue; } await page.evaluate(() => document.documentElement.classList.add('aa-dark')); await page.waitForTimeout(400); const res = await page.evaluate(() => { const bad = []; const els = document.querySelectorAll('body, body *'); const lumH = (rgb) => { const m = rgb.match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(',').map((x) => parseFloat(x)); 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(p[0]) + 0.7152 * f(p[1]) + 0.0722 * f(p[2]); }; const parse = (s) => { if (/^#([0-9a-fA-F]{6})$/.test(s)) { const c = s.slice(1); const f = (i) => { const v = parseInt(c.substr(i, 2), 16) / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(0) + 0.7152 * f(2) + 0.0722 * f(4); } return lumH(s); }; let checked = 0; /* rgba 解析为 [r,g,b,a];半透明背景沿父链合成后算对比度 */ 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 < 8) { 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; }; for (const el of els) { if (checked > 400) break; const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) continue; const rect = el.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) continue; const txt = (el.textContent || '').trim(); if (!txt || el.children.length > 0) continue; const fgRGBA = toRGBA(cs.color); if (!fgRGBA) continue; const fg = lumOf(fgRGBA); const bg = compBg(el); if (bg === null) continue; const r = (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05); checked++; if (r < 3.0) bad.push(el.tagName + '.' + (el.className || '').toString().slice(0, 20) + ' "' + txt.slice(0, 12) + '" ' + r.toFixed(2)); if (bad.length >= 5) break; } // 白底残留:暗色下仍有大面积 #fff 背景 const whites = []; for (const el of document.querySelectorAll('body, div, section, main, table, thead')) { const bg = getComputedStyle(el).backgroundColor; if (/255,\s*255,\s*255/.test(bg)) { const r = el.getBoundingClientRect(); if (r.width > 300 && r.height > 100) whites.push(el.tagName + ' ' + Math.round(r.width) + 'x' + Math.round(r.height)); if (whites.length >= 3) break; } } return { checked, bad, whites }; }); ok(res.bad.length === 0, '3) ' + slug + ' 暗色对比度抽查 ' + res.checked + ' 节点' + (res.bad.length ? ' 不足:' + res.bad.join(';') : '')); ok(res.whites.length === 0, '3) ' + slug + ' 无大面积白底残留' + (res.whites.length ? ':' + res.whites.join(';') : '')); if (SHOTS) await page.screenshot({ path: 'dark-' + slug + '.png' }); await page.close(); } } finally { await browser.close(); } console.log(fail ? 'DARK VERIFY FAIL (' + fail + ')' : 'DARK VERIFY OK'); process.exit(fail ? 1 : 0);