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);