diff --git a/.design_library/aurora-admin/colors_and_type.css b/.design_library/aurora-admin/colors_and_type.css
index a57ec60..246dc5f 100644
--- a/.design_library/aurora-admin/colors_and_type.css
+++ b/.design_library/aurora-admin/colors_and_type.css
@@ -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。演示页通过 引用本文件,
+ 故 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);
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d8acd5..7e770f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)。
diff --git a/ROADMAP.md b/ROADMAP.md
index 42cea48..db9aa45 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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 |
diff --git a/frameworks/Tag.css b/frameworks/Tag.css
index 1274c10..ab2f5e3 100644
--- a/frameworks/Tag.css
+++ b/frameworks/Tag.css
@@ -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); }
diff --git a/frameworks/table.html b/frameworks/table.html
index 4b66003..43c4375 100644
--- a/frameworks/table.html
+++ b/frameworks/table.html
@@ -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;
diff --git a/site/app.js b/site/app.js
index a33b1cf..6bb1fc2 100644
--- a/site/app.js
+++ b/site/app.js
@@ -2146,6 +2146,10 @@
d.documentElement.style.setProperty(k, themeState[k]);
});
d.documentElement.style.setProperty('--au-color-focus-ring', focusRingValue());
+ /* S2-P5 选型 A:站点暗色时演示页同步反色 —— 演示页 引用令牌文件,
+ 加 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;
diff --git a/site/style.css b/site/style.css
index 06f9d73..49e01e6 100644
--- a/site/style.css
+++ b/site/style.css
@@ -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; }
diff --git a/tools/verify-dark.mjs b/tools/verify-dark.mjs
new file mode 100644
index 0000000..53fb9c4
--- /dev/null
+++ b/tools/verify-dark.mjs
@@ -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);