201 lines
8.1 KiB
JavaScript
201 lines
8.1 KiB
JavaScript
#!/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);
|