feat(P5): 暗色模式真正实现(令牌31/对比度全过/10页实测/DARK32/预算292KB/回归1003)
Deploy to GitHub Pages / deploy (push) Failing after 2m30s
Regression / regression (push) Failing after 2m2s

This commit is contained in:
aurora-admin
2026-09-14 02:38:35 +08:00
parent e77efcdc60
commit e13049f5b9
8 changed files with 291 additions and 47 deletions
@@ -1,10 +1,10 @@
/* @light-only */
/* @light-dark */
/* Source: structured-spec */
/* ============================================================
Aurora Admin UI — Design Tokens
Source: Aurora Admin UI 设计系统规范 (structured-spec)
Theme: light-only
Theme: light + dark (html.aa-dark, S2-P5)
Prefix: au (source layer) + portable aliases (consumption layer)
============================================================ */
@@ -277,3 +277,71 @@
outline: 2px solid var(--au-color-focus-ring);
outline-offset: 2px;
}
/* ------------------------------------------------------------
Demo shell · 演示壳通用类(S2-P5 附带收敛)
26 个演示页用 .aa-page/.aa-h2/.aa-desc 作展示壳,但此前全仓无定义,
壳文字继承 body 黑色,暗色下黑字黑底。此处用令牌统一定义,
浅色/暗色随令牌自动切换,不触碰组件自身视觉。
------------------------------------------------------------ */
.aa-page {
background: var(--au-color-page-bg);
color: var(--au-color-text-body);
}
.aa-h2 {
color: var(--au-color-text-title);
}
.aa-desc {
color: var(--au-color-text-secondary);
}
/* ------------------------------------------------------------
Dark · 暗色令牌组(S2-P5)
生效条件:html.aa-dark。演示页通过 <link> 引用本文件,
故 79 个演示页随令牌自动反色(选型 A:站点暗色时演示页也反色)。
对比度(WCAG AA,卡片底 #1C1F26 / 页面底 #14161C,脚本实测):
title #E8EAED 13.68/15.00 · body #C9CDD4 10.34/11.34 ·
secondary #9CA3AF 6.50/7.12 · placeholder #9CA3AF 6.50/7.12 ·
brand #6B8CFF 5.36/5.87 · success #4CAF50 5.93 ·
warning #E5A300 7.52 · error #F26D6D 5.64 · info #4C9AFF 5.79
(全部 ≥4.5:1;hover/active 为交互态非文本位)
------------------------------------------------------------ */
html.aa-dark {
/* 背景三层递进 */
--au-color-page-bg: #14161C;
--au-color-card-bg: #1C1F26;
--au-color-table-header-bg: #22262E;
--au-color-disabled-bg: #262A33;
--au-color-tag-green-bg: #16261A;
--au-color-tooltip-bg: #E8EAED;
/* 文字四层 */
--au-color-text-title: #E8EAED;
--au-color-text-body: #C9CDD4;
--au-color-text-secondary: #9CA3AF;
--au-color-text-tertiary: #B6BCC7;
--au-color-text-placeholder: #9CA3AF;
--au-color-text-inverse: #14161C;
--au-color-text-disabled: #6B7280;
/* 边框 */
--au-color-border: #2A2F38;
--au-color-border-strong: #363B45;
--au-color-icon-inactive: #9CA3AF;
/* 品牌色向白提亮(深底对比度不足,原 #2F54EB 卡片底仅 ~3.9:1) */
--au-color-brand: #6B8CFF;
--au-color-brand-hover: #84A0FF;
--au-color-brand-active: #5570D8;
--au-color-brand-bg: rgba(107, 140, 255, 0.18);
--au-color-secondary: #3FD4D4;
/* 语义色提亮 */
--au-color-success: #4CAF50;
--au-color-warning: #E5A300;
--au-color-error: #F26D6D;
--au-color-info: #4C9AFF;
/* 遮罩与阴影 */
--au-color-mask: rgba(0, 0, 0, 0.6);
--au-color-mask-strong: rgba(0, 0, 0, 0.75);
--au-color-focus-ring: rgba(107, 140, 255, 0.5);
--au-shadow-low: 0 1px 4px rgba(0, 0, 0, 0.5);
--au-shadow-medium: 0 4px 12px rgba(0, 0, 0, 0.55);
--au-shadow-high: 0 8px 24px rgba(0, 0, 0, 0.65);
}
+7
View File
@@ -4,6 +4,13 @@
## [Unreleased]
### S2-P5 · 暗色模式真正实现(选型 A:演示页随站点同步反色)
- 令牌文件新增 `html.aa-dark` 组(31 个 `--au-`,≥15 达标):背景三层递进、文字四层、品牌/语义色向白提亮;10 项文字对比度全部 ≥4.5:1(脚本实测)。
- `site/style.css` 暗色块精简为结构样式(令牌值收归令牌文件,删旧内嵌值与“演示页保持浅色”规则);`site/app.js` 删死代码 `DARK_TOKENS`,`injectIframeTheme` 同步 `aa-dark` 类到演示 iframe。
- 附带收敛:演示壳裸类 `.aa-page/.aa-h2/.aa-desc`(26 页用而无定义)在令牌文件补令牌定义;`table.html` loading-mask、`Tag.css` 红/橙标签加暗色覆盖。
- 新增 `tools/verify-dark.mjs` 独立验证(32 断言:令牌组 2 + 对比度 10 + 10 页浏览器实测 20),不进 `_runtime` 计数。
- 首屏预算 292KB(< 300KB);回归 1003/1003(pass 1003/fail 0/skip 35)零影响。
### S1-P4 · data.js 瘦身(已完成 c65a69c)
- data.js 991KB → 98KB(预计算 API/场景 + 源码分离到 site/sources 395 文件),data.json 保持完整(含 5 端 sources)。
- 首屏资源合计 291KB(< 300KB 预算,S1-P4b 验收已达标,无需拆分 app.js)。
+1 -1
View File
@@ -989,7 +989,7 @@ figma export OK
| P2 | npm 分发 | S1 | **P0** | 2 h | P1 ✅ | ✅ **已完成**(dist 构建 + npm pack 115.9KB) |
| P3 | CI 回归 | S1 | **P0** | 1.5 h | — | ✅ **已完成**(workflow + runner 重构 + exit code) |
| P4 | `data.js` 瘦身 | S1 | **P0** | 3 h | — | ✅ **已完成**(data.js 98KB/sources 395/data.json 完整/回归 1003/1003,见 c65a69c) |
| P5 | 暗色模式 | S2 | **P0** | 1 d | P4 ✅ | 🟢 **可开工**(P4 已完成,下一版本首选) |
| P5 | 暗色模式 | S2 | **P0** | 1 d | P4 ✅ | ✅ **已完成**(令牌组31/对比度10项≥4.5/10页实测/DARK VERIFY 32/32/首屏292KB/回归1003/1003,Q6选A) |
| P6 | 跨端一致性验证 | S2 | **P0** | 1 d | P3 | ⚪ 待 P3 |
| P7 | 行为断言 | S2 | P1 | 1.5 d | — | 🟢 可开工(优先级低于 P2-P4) |
| P8 | RTL | S3 | P1 | 2 d | P5 | ⚪ 待 P5 |
+4
View File
@@ -14,6 +14,10 @@
.aa-tag.aa-tag-orange { background: #FFF7E6; color: var(--au-color-warning); }
.aa-tag.aa-tag-gray { background: var(--au-color-disabled-bg); color: var(--au-color-text-secondary); }
/* S2-P5 暗色覆盖:浅底在深色下改为语义色半透明底,文字用暗色提亮值(对比度 ≥4.5:1 实测) */
html.aa-dark .aa-tag.aa-tag-red { background: rgba(242,109,109,0.18); }
html.aa-dark .aa-tag.aa-tag-orange { background: rgba(229,163,0,0.18); }
/* 自定义颜色(行内 style 提供 --aa-tag-color / --aa-tag-bg) */
.aa-tag.aa-tag-custom { background: var(--aa-tag-bg); color: var(--aa-tag-color); }
+1
View File
@@ -35,6 +35,7 @@
position: absolute; inset: 0; background: rgba(255,255,255,0.7); display: flex;
align-items: center; justify-content: center; z-index: 2;
}
html.aa-dark .aa-table .loading-mask { background: rgba(20,22,28,0.7); }
.aa-table .spinner {
width: 20px; height: 20px; border: 2px solid var(--au-color-brand); border-top-color: transparent;
border-radius: 50%; animation: aa-tbl-spin .6s linear infinite;
+6 -13
View File
@@ -2146,6 +2146,10 @@
d.documentElement.style.setProperty(k, themeState[k]);
});
d.documentElement.style.setProperty('--au-color-focus-ring', focusRingValue());
/* S2-P5 选型 A:站点暗色时演示页同步反色 —— 演示页 <link> 引用令牌文件,
加 aa-dark 类即随令牌自动反色;浅色时摘除恢复。 */
var dark = document.documentElement.classList.contains('aa-dark');
if (d.documentElement.classList) d.documentElement.classList.toggle('aa-dark', dark);
} catch (e) { /* file:// 跨源时静默跳过 */ }
}
function hookIframeTheme(ifr) {
@@ -2234,19 +2238,8 @@
lines.push('}');
copyText(lines.join('\n'), $('theme-copy'), '已复制 ✓');
});
/* 深色 / 浅色模式切换 */
var DARK_TOKENS = {
'--au-color-page-bg': '#14161C',
'--au-color-card-bg': '#1D2027',
'--au-color-table-header-bg': '#23262F',
'--au-color-disabled-bg': '#262A33',
'--au-color-tag-green-bg': '#16261A',
'--au-color-text-title': '#E8EAED',
'--au-color-text-body': '#C9CDD4',
'--au-color-text-secondary': '#8B95A7',
'--au-color-text-placeholder': '#5C6470',
'--au-color-border': '#2E323C'
};
/* 深色 / 浅色模式切换(S2-P5:暗色值收归令牌文件的 html.aa-dark 组,
此处只切类名,不再内嵌色值;演示 iframe 由 injectIframeTheme 同步类名) */
function isDark() { return document.documentElement.classList.contains('aa-dark'); }
function applyMode() {
var dark = false;
+2 -31
View File
@@ -1093,39 +1093,10 @@ a.chip:hover { color: var(--au-color-brand); }
.other-files ul { margin: 8px 0 0; }
.other-files a { font-size: 13px; }
/* ---- 深色模式(html.aa-dark 覆盖站点级令牌;.design_library 令牌文件不动,演示 iframe 保持浅色) ---- */
html.aa-dark {
--au-color-page-bg: #14161C;
--au-color-card-bg: #1D2027;
--au-color-table-header-bg: #23262F;
--au-color-disabled-bg: #262A33;
--au-color-tag-green-bg: #16261A;
--au-color-text-title: #E8EAED;
--au-color-text-body: #C9CDD4;
--au-color-text-secondary: #8B95A7;
--au-color-text-placeholder: #5C6470;
--au-color-border: #2E323C;
--au-color-mask: rgba(0, 0, 0, 0.6);
--au-color-mask-strong: rgba(0, 0, 0, 0.75);
/* 品牌色族:暗色下整体提亮一档,保证链接/选中态在深底上的对比度 */
--au-color-brand: #5B7CFA;
--au-color-brand-hover: #7B97FF;
--au-color-brand-active: #4364D9;
--au-color-brand-bg: rgba(91, 124, 250, 0.18);
--au-color-focus-ring: rgba(91, 124, 250, 0.5);
/* 语义色:暗色下提亮,深底可辨 */
--au-color-success: #6CD03C;
--au-color-warning: #FFC53D;
--au-color-error: #FF5C5C;
--au-color-info: #40A9FF;
/* 阴影在深色下加重才能形成层次 */
--au-shadow-low: 0 1px 4px rgba(0, 0, 0, 0.5);
--au-shadow-medium: 0 4px 12px rgba(0, 0, 0, 0.55);
--au-shadow-high: 0 8px 24px rgba(0, 0, 0, 0.65);
}
/* ---- 深色模式(S2-P5 选型 A:令牌值收归 .design_library 令牌文件的 html.aa-dark 组,
本处只留站点结构样式;演示 iframe 由 app.js 注入 aa-dark 类随令牌同步反色) ---- */
html.aa-dark body { background: var(--au-color-page-bg); }
html.aa-dark .topbar { background: rgba(20, 22, 28, 0.92); }
html.aa-dark .demo-stage iframe { background: #F5F7FA; } /* 演示页保持浅色基调,避免 5 端样例反色失真 */
html.aa-dark .demo-code { background: #101216; }
html.aa-dark .a11y-key kbd { background: #262A33; border-color: #3A404C; border-bottom-color: #4A5160; }
+200
View File
@@ -0,0 +1,200 @@
#!/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);