Regression / regression (push) Canceled after 0s
## 品牌标识(本次会话) 起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」, 那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」, 移动端站已是「K」;移动端文档站则完全没有 favicon)。 - 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边; 描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」 - 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark); 顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28)) - 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg) - 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留) - 移动端 7 个模板补 favicon(此前计数 0) 验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/ theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致; 两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。 ## 并行会话成果(本次一并入库) - 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具 + IconPreview 预览页 + ICON-SPEC.md 冻结规格 - 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁 - PC 组件:103 个大后台组件 / 组件11 批次 - uni-app:PC 端试点 + 移动端端实现 + 真实编译验证 ## 工程 - .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库) - CHANGELOG 补品牌标识条目 - ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
231 lines
10 KiB
JavaScript
231 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* verify-dark.mjs — S2-P5 暗色模式独立验证(不进 _runtime 计数,守住 1003 基数)
|
||
*
|
||
* 1) 令牌组存在且完整(html.kole-dark ≥15 个 --kole-)
|
||
* 2) 暗色文字/品牌/语义色在深底上的 WCAG 对比度(文本 ≥4.5:1)
|
||
* 3) 浏览器实测:抽样 10 个组件演示页,切 kole-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';
|
||
import { probeContrast } from './lib/contrast-probe.mjs';
|
||
|
||
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/kole-ui/colors_and_type.css'), 'utf8');
|
||
ok(/html\.kole-dark/.test(css), '1) 令牌文件含 html.kole-dark 组');
|
||
const m = css.match(/html\.kole-dark\s*\{([^}]+)\}/s);
|
||
const n = m ? (m[1].match(/--kole-/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('kole-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();
|
||
}
|
||
|
||
/* ---- 4) 站点自身的 chrome:暗色对比度(含选中态与悬停态)----
|
||
第 3 组只抽查组件演示页(frameworks/*),文档站自己的侧栏选中项、chips、API 表悬停行
|
||
不在覆盖内 —— 2026-09-20 的 S6-P39 就是从这个缺口漏过去的(暗色选中态实测 3.59:1)。
|
||
这里按 AA 判据走:正文 ≥4.5:1,大字(≥24px 或 ≥18.66px 粗体)≥3:1。 */
|
||
const SITE_ROUTES = [
|
||
['/site/overview', null],
|
||
['/site/guide', null],
|
||
['/site/component/button', '#sidebar .side-item.active'], /* 侧栏选中项:常驻状态,必须达标 */
|
||
['/site/component/table', null], /* 有必填 prop 的页面:覆盖 .badge-req */
|
||
['/site/design', null],
|
||
['/site/faq', null],
|
||
['/site/changelog', null]
|
||
];
|
||
const chromePage = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||
await chromePage.goto(BASE + '/site/', { waitUntil: 'domcontentloaded' });
|
||
await chromePage.evaluate(() => localStorage.setItem('kole-mode', 'dark'));
|
||
for (const [route, hoverSel] of SITE_ROUTES) {
|
||
const resp = await chromePage.goto(BASE + route, { waitUntil: 'networkidle', timeout: 20000 }).catch(() => null);
|
||
const status = resp ? resp.status() : 0;
|
||
ok(status === 200, '4) ' + route + ' 暗色可访问(HTTP ' + status + ')');
|
||
if (status !== 200) continue;
|
||
if (hoverSel) { await chromePage.hover(hoverSel).catch(() => {}); await chromePage.waitForTimeout(150); }
|
||
const res = await probeContrast(chromePage);
|
||
res.isDark = await chromePage.evaluate(() => document.documentElement.classList.contains('kole-dark'));
|
||
ok(res.isDark, '4) ' + route + ' 处于暗色(kole-dark 生效)');
|
||
ok(res.bad.length === 0, '4) ' + route + ' chrome 暗色对比度抽查 ' + res.checked + ' 节点' + (res.bad.length ? ' 不足:' + res.bad.join(';') : ''));
|
||
}
|
||
await chromePage.close();
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
console.log(fail ? 'DARK VERIFY FAIL (' + fail + ')' : 'DARK VERIFY OK');
|
||
process.exit(fail ? 1 : 0);
|