Files
aurora-admin/tools/verify-dark.mjs
T
aurora-admin e13049f5b9
Deploy to GitHub Pages / deploy (push) Failing after 2m30s
Regression / regression (push) Failing after 2m2s
feat(P5): 暗色模式真正实现(令牌31/对比度全过/10页实测/DARK32/预算292KB/回归1003)
2026-09-14 02:38:35 +08:00

201 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);