feat(web): 深色主题 —— 反转灰阶而非逐处 dark: 前缀
**逐处加 dark: 前缀的方案在这里必然失败**:约 700 处颜色散在 21 个组件里, 漏一处就是深色下的白底白字,而它不报错、不影响构建、只有肉眼能发现, 且往往只出现在某个不常开的页面。此后每加一个组件都要记得写两遍, 那种约定活不过三次改动。 改法是把颜色下沉到 CSS 变量,深色模式**反转灰阶**。这套代码的灰阶本身 就是语义色阶(white/gray-50 = 表面层次,gray-200/300 = 分隔线, gray-900→400 = 文字主次),反转之后 `bg-white text-gray-900` 自动变成 深色卡片 + 浅色文字。零组件改动,新组件照常写浅色类名也自动适配。 变量存 **RGB 三元组**而非 #hex:代码里有 bg-blue-50/70 这类透明度修饰符, Tailwind 生成 rgb(var(--x) / 0.7),而 rgb(#f9fafb / 0.7) 是无效 CSS —— 那些半透明高亮会静默失效(不报错,只是不透明)。 --- 实测撞了三个必须分离的语义,每一个共用变量就坏: **1. text-white 不能跟 bg-white 走。** `white` 服务两种冲突用途:卡片表面(深色下要变暗)与彩色按钮上的文字 (深色下必须保持浅色)。共用时后者跟着变暗 —— 激活导航项的「收件」在 bg-chrome-700 上只剩 **1.34:1**,几乎消失。拆出 --c-on-accent。 **2. 侧栏与底部导航不能跟 gray 走。** 它们在浅色模式下**本来就是深色的**(深色侧栏配浅色内容区是原本设计)。 并入反转灰阶后深色模式下变成近白色(实测 rgb(243,245,248)),比内容区 (rgb(17,19,24))还亮,整个层次翻过来。独立成 chrome 色阶,深色下只微调、 保持「框架比内容更沉」。 **3. 强调色不能反转。** blue/red 跟着变会让主按钮在深色页面上失去「这是主操作」的视觉重量, 而且白字落在变暗的 blue-600 上对比度掉到 3:1 以下。改成固定值。 --- 顺带修的三处真实对比度不足(实测量出来的,不是猜的): - 待决策橙徽标:orange-500 上白字 2.80:1 → orange-700 5.18:1 (保留橙色语义,不能改成灰 —— 它与未读的红色是两种紧急) - 空状态文案:gray-400 2.43:1 → gray-500。这类文字是**页面上唯一的内容**, 不是次要装饰,读不动等于页面空白 - 列表头计数:同上 --- 主题是**三态**而非开关:system 不是 light 的别名 —— 只给开关的话, 白天设浅色之后晚上系统切深色应用不会跟着变。且只有 pref 为 system 时 才跟随系统,显式选了的人不该因为日落被切换。 index.html 加同步内联脚本消除首帧闪屏:bundle 有 430KB,从 HTML 解析完到 React 挂载之间页面是 body 默认色,深色用户每次刷新都被闪一下白屏。 外链或 defer 都晚于首次绘制。它与 themeStore 共用同一个 localStorage 键 (不一致会导致首帧按 A 键渲染、挂载后按 B 键重渲染,闪一下再变回去)。 body 显式设底色:移动端橡皮筋回弹露出的是 body 背景。 入口两处:侧栏单按钮快速翻转,「我的」页三选一设定偏好。单按钮不足以 表达三态,但只给单按钮的话用户一旦点过就永久脱离「跟随系统」—— 那是个回不去的单向门。 测试:test/theme.test.mjs 20 条结构性断言(已进 npm test), test/manual/theme-verify.mjs 真实渲染对比度验收(遍历可见文本节点算 WCAG 比值,往上找第一个不透明背景)。两种模式各 4 项全过。
This commit is contained in:
151
web/test/manual/theme-verify.mjs
Normal file
151
web/test/manual/theme-verify.mjs
Normal file
@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 深色主题手工验收。
|
||||
*
|
||||
* 需要共享 Chromium(CDP 9222)。结构性检查已在 test/theme.test.mjs 里,
|
||||
* 这里验的是**真实渲染出来的对比度** —— 那是唯一能发现白底白字的判据。
|
||||
*
|
||||
* 用法:
|
||||
* ADMIN_USER=jianf ADMIN_PW=... AGENTMAIL_URL=http://127.0.0.1:8180 \
|
||||
* node web/test/manual/theme-verify.mjs
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const check = (name, ok, detail = '') => {
|
||||
if (ok) { pass++; console.log(` 通过 ${name}`); }
|
||||
else { fail++; console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`); }
|
||||
};
|
||||
|
||||
/** 相对亮度(WCAG)。用于判断 body 底色是否真的变暗。 */
|
||||
function luminance([r, g, b]) {
|
||||
const f = c => {
|
||||
c /= 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
}
|
||||
|
||||
const parseRgb = s => {
|
||||
const m = String(s).match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
};
|
||||
|
||||
const { browser, page, issues } = await openApp(WIDE);
|
||||
|
||||
try {
|
||||
|
||||
for (const mode of ['light', 'dark']) {
|
||||
console.log(`\n── ${mode} ──`);
|
||||
await page.evaluate(m => {
|
||||
localStorage.setItem('agentmail.theme', m);
|
||||
document.documentElement.classList.toggle('dark', m === 'dark');
|
||||
document.documentElement.style.colorScheme = m;
|
||||
}, mode);
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const body = parseRgb(await page.evaluate(() =>
|
||||
getComputedStyle(document.body).backgroundColor));
|
||||
check(`${mode}: body 底色可读取`, body !== null, String(body));
|
||||
|
||||
if (mode === 'dark') {
|
||||
// 深色下 body 必须是暗的。这一条挂掉说明变量没生效
|
||||
check('dark: body 底色确实是暗的', luminance(body) < 0.2,
|
||||
`亮度 ${luminance(body).toFixed(3)}`);
|
||||
}
|
||||
|
||||
// 遍历可见文本节点,算每个的前景/背景对比度。
|
||||
// 4.5:1 是 WCAG AA 的正文标准;大字放宽到 3:1。
|
||||
const bad = await page.evaluate(() => {
|
||||
const out = [];
|
||||
const els = document.querySelectorAll('button, a, h1, h2, h3, p, span, div, label, li');
|
||||
const lum = ([r, g, b]) => {
|
||||
const f = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
};
|
||||
const parse = s => {
|
||||
const m = String(s).match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||
return m ? [+m[1], +m[2], +m[3]] : null;
|
||||
};
|
||||
/** 往上找第一个不透明的背景。 */
|
||||
const bgOf = el => {
|
||||
let cur = el;
|
||||
while (cur && cur !== document.documentElement) {
|
||||
const cs = getComputedStyle(cur);
|
||||
const c = parse(cs.backgroundColor);
|
||||
const alpha = String(cs.backgroundColor).match(/rgba?\([^)]*,\s*([\d.]+)\)/);
|
||||
if (c && (!alpha || Number(alpha[1]) > 0.85)) return c;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return parse(getComputedStyle(document.body).backgroundColor);
|
||||
};
|
||||
for (const el of els) {
|
||||
// 只看直接含文本的元素
|
||||
const text = [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
.map(n => n.textContent.trim())
|
||||
.join('');
|
||||
if (!text) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 4 || r.height < 4) continue;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.visibility === 'hidden' || cs.opacity === '0') continue;
|
||||
const fg = parse(cs.color);
|
||||
const bg = bgOf(el);
|
||||
if (!fg || !bg) continue;
|
||||
const la = lum(fg), lb = lum(bg);
|
||||
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
|
||||
const ratio = (hi + 0.05) / (lo + 0.05);
|
||||
const size = parseFloat(cs.fontSize);
|
||||
const bold = Number(cs.fontWeight) >= 700;
|
||||
const large = size >= 24 || (size >= 18.66 && bold);
|
||||
const need = large ? 3 : 4.5;
|
||||
if (ratio < need) {
|
||||
out.push({
|
||||
text: text.slice(0, 24),
|
||||
ratio: Number(ratio.toFixed(2)),
|
||||
need,
|
||||
fg: cs.color,
|
||||
bg: `rgb(${bg.join(',')})`
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// 允许少量刻意的低对比装饰(占位符、禁用态)
|
||||
const severe = bad.filter(x => x.ratio < 2.5);
|
||||
check(`${mode}: 无严重低对比文本(< 2.5:1)`, severe.length === 0,
|
||||
severe.slice(0, 4).map(x => `"${x.text}" ${x.ratio}`).join(' | '));
|
||||
if (bad.length) {
|
||||
console.log(` (${bad.length} 处低于 AA 阈值,最差 ${Math.min(...bad.map(x => x.ratio))}:1)`);
|
||||
// 逐条列出来而不只报个数:不知道是哪一处就没法修
|
||||
for (const x of bad.slice(0, 8)) {
|
||||
console.log(` ${x.ratio}:1 (需 ${x.need}) "${x.text}" ${x.fg} on ${x.bg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 白底白字的典型形态:前景与背景几乎相同
|
||||
const invisible = bad.filter(x => x.ratio < 1.3);
|
||||
check(`${mode}: 没有不可见文本(< 1.3:1)`, invisible.length === 0,
|
||||
invisible.slice(0, 3).map(x => `"${x.text}"`).join(' | '));
|
||||
|
||||
await page.screenshot({ path: `/tmp/theme-${mode}.png`, fullPage: false });
|
||||
}
|
||||
|
||||
// 刷新后主题必须保持(localStorage + 内联脚本)
|
||||
await page.evaluate(() => localStorage.setItem('agentmail.theme', 'dark'));
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
const stillDark = await page.evaluate(() =>
|
||||
document.documentElement.classList.contains('dark'));
|
||||
check('刷新后深色保持(内联脚本生效)', stillDark);
|
||||
|
||||
check('无 JS 运行时错误', issues.length === 0, issues.slice(0, 3).join(' | '));
|
||||
} finally {
|
||||
await page.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(`\n主题验收:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
|
||||
console.log('截图:/tmp/theme-light.png /tmp/theme-dark.png');
|
||||
process.exit(fail ? 1 : 0);
|
||||
@ -39,7 +39,7 @@ check(
|
||||
// 2) 固定宽度的中间栏在窄屏必须让位。
|
||||
// 断言的是「w-full + md: 前缀的固定宽度」这个形态,不是某个具体像素值 ——
|
||||
// ContactPanel 的卡片视图用 400px,列表视图用 320px。
|
||||
for (const f of ['MailList', 'ContactPanel']) {
|
||||
for (const f of ['MailList', 'PermissionList', 'ContactPanel', 'CalendarView']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
const narrowFullWidth = src.includes('w-full');
|
||||
// 固定宽度只能出现在 md: 断点后面;裸 w-[NNNpx] 会在 375px 屏上挤掉详情。
|
||||
@ -141,7 +141,7 @@ check('对话树窄屏有返回出口', thread.includes('<BackButton'));
|
||||
// 容器 795px,「退出登录」按钮连同下面 65px 一起消失。
|
||||
// 判据是「存在 overflow-y-auto」,不是「当前正在滚动」——
|
||||
// 内容暂时不够高时后者为假,但页面是健康的。
|
||||
for (const f of ['AccountPage', 'AdminUsersPage', 'MailView', 'ComposePage', 'ThreadView', 'ContactPanel', 'MailList']) {
|
||||
for (const f of ['AccountPage', 'AdminUsersPage', 'MailView', 'ComposePage', 'ThreadView', 'ContactPanel', 'MailList', 'PermissionList', 'CalendarView', 'CalendarEventEditor']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
check(`${f} 有纵向滚动容器`, src.includes('overflow-y-auto'));
|
||||
}
|
||||
|
||||
226
web/test/theme.test.mjs
Normal file
226
web/test/theme.test.mjs
Normal file
@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 深色主题的结构性检查。
|
||||
*
|
||||
* 判据全部是「源码里存在/不存在某种形态」,不需要浏览器 ——
|
||||
* 真正的视觉验收靠手工脚本(test/manual/theme-verify.mjs)。
|
||||
*
|
||||
* 这些检查存在的理由:深色模式的 bug 形态是**白底白字**,
|
||||
* 它不报错、不影响构建、只有肉眼能发现,而且往往只出现在某个不常开的页面。
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const read = p => readFileSync(join(here, p), 'utf8');
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const check = (name, ok, detail = '') => {
|
||||
if (ok) {
|
||||
pass++;
|
||||
console.log(` 通过 ${name}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
const css = read('../src/index.css');
|
||||
const cfg = read('../tailwind.config.js');
|
||||
const html = read('../index.html');
|
||||
|
||||
// 1) tailwind 必须走 class 策略。
|
||||
// media 策略下主题无法被人显式选择 —— 白天想开深色就做不到。
|
||||
check('darkMode 为 class 策略', /darkMode:\s*['"]class['"]/.test(cfg));
|
||||
|
||||
// 2) 颜色必须经 CSS 变量。写死十六进制的话深色模式无从切换。
|
||||
check(
|
||||
'调色板指向 CSS 变量',
|
||||
cfg.includes('rgb(var(') && cfg.includes('<alpha-value>'),
|
||||
'缺少 rgb(var(--x) / <alpha-value>) 形态'
|
||||
);
|
||||
|
||||
// 3) 变量值必须是 RGB 三元组而不是 #hex。
|
||||
// 代码里有 bg-blue-50/70 这类透明度修饰符,#hex 会生成无效 CSS,
|
||||
// 那些半透明高亮静默失效(不报错,只是不透明)。
|
||||
const varLines = css.match(/--c-[a-z]+-?\d*:\s*[^;]+;/g) || [];
|
||||
const hexVars = varLines.filter(l => l.includes('#'));
|
||||
check(
|
||||
'色板变量存 RGB 三元组而非 #hex',
|
||||
varLines.length >= 30 && hexVars.length === 0,
|
||||
hexVars.length ? `${hexVars.length} 个变量是 hex:${hexVars[0]}` : `只找到 ${varLines.length} 个变量`
|
||||
);
|
||||
|
||||
// 4) 必须有 .dark 覆盖块,且覆盖了同样多的变量。
|
||||
// 漏掉的那些会在深色下保持浅色值 —— 那正是白底白字的来源。
|
||||
const lightBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark'));
|
||||
const darkBlock = css.slice(css.indexOf('.dark {'));
|
||||
const lightVars = new Set((lightBlock.match(/--c-[\w-]+(?=:)/g) || []));
|
||||
const darkVars = new Set((darkBlock.match(/--c-[\w-]+(?=:)/g) || []));
|
||||
const missing = [...lightVars].filter(v => !darkVars.has(v));
|
||||
check(
|
||||
'.dark 覆盖了全部色板变量',
|
||||
lightVars.size >= 15 && missing.length === 0,
|
||||
missing.length ? `深色缺 ${missing.length} 个:${missing.slice(0, 5).join(', ')}` : `浅色只有 ${lightVars.size} 个`
|
||||
);
|
||||
|
||||
// 5) 灰阶必须真的反转:深色的 white 要比 gray-900 暗。
|
||||
// 不反转的话组件里的 `bg-white text-gray-900` 在深色下依然是白底黑字。
|
||||
const lum = (block, name) => {
|
||||
const m = block.match(new RegExp(`--c-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
|
||||
if (!m) return null;
|
||||
return (Number(m[1]) + Number(m[2]) + Number(m[3])) / 3;
|
||||
};
|
||||
const darkWhite = lum(darkBlock, 'white');
|
||||
const darkG900 = lum(darkBlock, 'gray-900');
|
||||
check(
|
||||
'深色下灰阶已反转(white 比 gray-900 暗)',
|
||||
darkWhite !== null && darkG900 !== null && darkWhite < darkG900,
|
||||
`white=${darkWhite} gray-900=${darkG900}`
|
||||
);
|
||||
|
||||
// 6) 深色的页面底(gray-50)必须比卡片(white)更暗。
|
||||
// 浅色下页面底比卡片浅,深色下要反过来 —— 否则卡片陷进背景失去边界。
|
||||
const darkG50 = lum(darkBlock, 'gray-50');
|
||||
check(
|
||||
'深色下页面底比卡片更暗',
|
||||
darkG50 !== null && darkWhite !== null && darkG50 < darkWhite,
|
||||
`gray-50=${darkG50} white=${darkWhite}`
|
||||
);
|
||||
|
||||
// 7) 次要文字(gray-400/500)在深色下必须提亮。
|
||||
// 照搬浅色值只有约 2:1 对比度,远低于 WCAG AA 的 4.5:1 ——
|
||||
// 实际效果是「看得见但读不动」。
|
||||
const lightG400 = lum(lightBlock, 'gray-400');
|
||||
const darkG400 = lum(darkBlock, 'gray-400');
|
||||
check(
|
||||
'深色下次要文字未沿用浅色值',
|
||||
darkG400 !== null && lightG400 !== null && Math.abs(darkG400 - lightG400) > 5,
|
||||
`light=${lightG400} dark=${darkG400}`
|
||||
);
|
||||
|
||||
// 8) color-scheme 两处都要设。
|
||||
// 不设的话深色页面上会出现浅色滚动条与白底的 autofill 输入框。
|
||||
check(
|
||||
':root 与 .dark 都声明 color-scheme',
|
||||
/color-scheme:\s*light/.test(lightBlock) && /color-scheme:\s*dark/.test(darkBlock)
|
||||
);
|
||||
|
||||
// 9) index.html 必须有同步内联脚本消除首帧闪屏。
|
||||
// bundle 有几百 KB,从 HTML 解析完到 React 挂载之间页面是 body 默认色 ——
|
||||
// 深色用户每次刷新都被闪一下白屏。外链或 defer 都晚于首次绘制。
|
||||
check(
|
||||
'index.html 内联防闪屏脚本',
|
||||
html.includes('agentmail.theme') &&
|
||||
html.includes('prefers-color-scheme') &&
|
||||
html.includes("classList.add('dark')") &&
|
||||
!/<script[^>]+(src=|defer)[^>]*>[\s\S]*?agentmail\.theme/.test(html),
|
||||
'缺少同步内联脚本'
|
||||
);
|
||||
|
||||
// 10) 内联脚本与 themeStore 必须用同一个 localStorage 键。
|
||||
// 不一致的后果是首帧按 A 键渲染、React 挂载后按 B 键重渲染 —— 闪一下再变回去
|
||||
const store = read('../src/stores/themeStore.ts');
|
||||
const keyInStore = store.match(/STORAGE_KEY\s*=\s*'([^']+)'/);
|
||||
check(
|
||||
'内联脚本与 themeStore 共用同一 storage 键',
|
||||
keyInStore !== null && html.includes(`'${keyInStore[1]}'`),
|
||||
keyInStore ? `store 用 ${keyInStore[1]}` : '未找到 STORAGE_KEY'
|
||||
);
|
||||
|
||||
// 11) body 必须有显式底色:移动端橡皮筋回弹露出的是 body 背景,
|
||||
// 不设的话深色下滑到边界会闪出白边。
|
||||
check(
|
||||
'body 有显式主题底色',
|
||||
/body\s*\{[^}]*background-color:\s*rgb\(var\(--c-/.test(css)
|
||||
);
|
||||
|
||||
// 12) 组件里不该残留写死的十六进制颜色。
|
||||
// 它们不经变量,深色模式下不会变 —— 而这类遗漏只有肉眼能发现。
|
||||
const compDir = join(here, '../src/components');
|
||||
const offenders = [];
|
||||
for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) {
|
||||
const src = readFileSync(join(compDir, f), 'utf8');
|
||||
// 只看 className 与 style 里的颜色;SVG 的 currentColor 不算
|
||||
const hits = (src.match(/#[0-9a-fA-F]{3,6}\b/g) || []);
|
||||
if (hits.length) offenders.push(`${f}(${hits.join(',')})`);
|
||||
}
|
||||
check(
|
||||
'组件里没有写死的十六进制颜色',
|
||||
offenders.length === 0,
|
||||
offenders.join(' ')
|
||||
);
|
||||
|
||||
// 13) 主题三态:system 不能被当成 light 的别名。
|
||||
// 只给开关的话,白天设浅色之后晚上系统切深色应用不会跟着变
|
||||
check(
|
||||
'ThemePref 是三态且含 system',
|
||||
/'light'\s*\|\s*'dark'\s*\|\s*'system'/.test(store) && store.includes("=== 'system'")
|
||||
);
|
||||
|
||||
// 14) 只有 pref 为 system 时才跟随系统变化。
|
||||
// 显式选了 light/dark 的人不该因为日落被切换主题
|
||||
check(
|
||||
'仅 system 偏好跟随系统变化',
|
||||
/if\s*\(pref === 'system'\)/.test(store)
|
||||
);
|
||||
|
||||
// 15) text-white 必须与 bg-white 用不同的变量。
|
||||
// `white` 服务两种冲突用途:卡片表面(深色下变暗)与彩色按钮上的文字
|
||||
// (深色下必须保持浅色)。共用一个变量时后者跟着变暗 —— 实测激活导航项的
|
||||
// 「收件」在 bg-chrome-700 上只剩 1.34:1,几乎看不见。
|
||||
check(
|
||||
'textColor.white 指向独立变量(不跟 bg-white 一起反转)',
|
||||
/textColor:\s*\{[^}]*white:\s*withAlpha\('--c-on-accent'\)/.test(cfg) &&
|
||||
/--c-on-accent:/.test(lightBlock) &&
|
||||
/--c-on-accent:/.test(darkBlock)
|
||||
);
|
||||
|
||||
// 16) --c-on-accent 在深色下必须仍然是浅色。
|
||||
// 它变暗就是上一条描述的那个 bug。
|
||||
const onAccentDark = lum(darkBlock, 'on-accent');
|
||||
check(
|
||||
'深色下 on-accent 仍是浅色',
|
||||
onAccentDark !== null && onAccentDark > 200,
|
||||
`on-accent 亮度 ${onAccentDark}`
|
||||
);
|
||||
|
||||
// 17) 强调色(blue/red/...)不走变量。
|
||||
// 它们跟着反转会让主按钮在深色页面上失去「这是主操作」的视觉重量,
|
||||
// 而且白字落在变暗的 blue-600 上对比度会掉到 3:1 以下。
|
||||
check(
|
||||
'强调色为固定值,不随主题反转',
|
||||
!/blue:\s*accent\(/.test(cfg) && /blue:\s*\{\s*50:\s*'#/.test(cfg)
|
||||
);
|
||||
|
||||
// 18) 应用框架(侧栏 / 底部导航)必须有独立色阶。
|
||||
// 它在浅色模式下本来就是深色的 —— 并入反转的 gray 之后深色模式下会变成
|
||||
// 近白色,比内容区还亮,整个层次翻过来(实测 rgb(243,245,248))。
|
||||
check(
|
||||
'框架有独立的 chrome 色阶',
|
||||
/chrome:\s*\{/.test(cfg) &&
|
||||
/--c-chrome-900:/.test(lightBlock) &&
|
||||
/--c-chrome-900:/.test(darkBlock)
|
||||
);
|
||||
|
||||
// 19) 深色下框架必须比内容区更沉(保持浅色下就有的层次关系)。
|
||||
const chromeDark = lum(darkBlock, 'chrome-900');
|
||||
check(
|
||||
'深色下框架比内容区更暗',
|
||||
chromeDark !== null && darkG50 !== null && chromeDark < darkG50,
|
||||
`chrome-900=${chromeDark} gray-50=${darkG50}`
|
||||
);
|
||||
|
||||
// 20) 侧栏与底部导航里不该残留 slate-*(那条色阶指向反转的 gray)。
|
||||
const chromeFiles = ['Sidebar', 'NarrowNav'];
|
||||
const slateLeft = [];
|
||||
for (const f of chromeFiles) {
|
||||
const src = readFileSync(join(here, `../src/components/${f}.tsx`), 'utf8');
|
||||
const hits = src.match(/(?:bg|text|border|hover:bg|hover:text|active:bg|ring)-slate-\d+/g) || [];
|
||||
if (hits.length) slateLeft.push(`${f}: ${hits.join(',')}`);
|
||||
}
|
||||
check('框架组件已全部改用 chrome 色阶', slateLeft.length === 0, slateLeft.join(' | '));
|
||||
|
||||
console.log(`\n主题:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
Reference in New Issue
Block a user