Files
MailUI4Agents/client/electron/test/theme.test.mjs
JianFeeeee d5cfcbdc9c fix(权限): 409 的第二种含义是「本档不该问」——四桥都补上;状态写入点不再兜默认档
线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了
workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮
工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。

四件事:

1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null`
   = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。"
   同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。

2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** ——
   所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径:
   已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full,
   plan 与"链上没有人类"照旧 fail closed。

3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js`
   都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改"
   —— 那句话是错的,我当时的搜索面只有 `<plugin>/src/*.mjs`。按 pi 的要求把这条
   **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有
   「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。

4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的
   (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑
   `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。
   共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。

另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`,
判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。

判据证据(每条都做过"能不能红"的变异):
- 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红;
- zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。

各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc);
electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0;
Go `go test ./...` 全 ok。
2026-09-14 16:21:27 +08:00

434 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 深色主题的结构性检查。
*
* 判据全部是「源码里存在/不存在某种形态」,不需要浏览器 ——
* 真正的视觉验收靠手工脚本test/manual/theme-verify.mjs
*
* 这些检查存在的理由:深色模式的 bug 形态是**白底白字**
* 它不报错、不影响构建、只有肉眼能发现,而且往往只出现在某个不常开的页面。
*/
import { code, prose } from './lib/read.mjs';
import { 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 => prose(join(here, p));
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');
/**
* 剥掉注释后的 CSS。
*
* 下面几处按 `indexOf(':root')` / `indexOf('.dark')` 切颜色块,而**注释里出现
* 选择器字面量会把块提前截断**:在 :root 的注释里写一句「深色主题里换值」
* (原文用了带点的选择器写法)就足以让浅色块在 color-scheme 之前被切开,
* 于是第 8 条真假失败。更危险的是反向情形:块被截短后变量集合变小,
* 「覆盖齐全」这类断言可能**真空通过**。
*
* 所以所有块切分都基于剥注释后的文本。字符串字面量里的 /* 在本文件里
* 不存在,直接用非贪婪匹配去掉块注释即可。
*/
const cssNoComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
// 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 >= 100 && hexVars.length === 0,
hexVars.length ? `${hexVars.length} 个变量是 hex${hexVars[0]}` : `只找到 ${varLines.length} 个变量`
);
// 4) 必须有 .dark 覆盖块,且覆盖了同样多的变量。
// 漏掉的那些会在深色下保持浅色值 —— 那正是白底白字的来源。
const lightBlock = cssNoComments.slice(
cssNoComments.indexOf(':root'),
cssNoComments.indexOf('.dark')
);
const darkBlock = cssNoComments.slice(cssNoComments.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 = prose(join(compDir, f));
// 只看 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) 每个被 Tailwind 实际使用的 CSS 变量都必须在 index.css 有定义。
//
// 这一条守的是本项目最贵的一次视觉 bugtailwind.config.js 里 `colors`
// 同时写了固定 hex 与 accent() 两份 red/green/amber/orange/yellow ——
// JS 对象字面量重复键**后者胜出**(不报错、不警告),而 index.css 里当时
// 没有对应的变量。`rgb(var(--c-red-600) / 1)` 里变量未定义会让整条
// background-color 声明失效,于是 bg-red-600 退回透明、text-white 的白字
// 落在白卡片上:**按钮看不见但点得动**。32 个变量全部缺失,
// 波及所有 red/green/amber/orange/yellow 的地方。
//
// 判据必须走 resolveConfig 而不是正则扫配置文本:真正出问题的那批变量名
// 是 accent('red') 这类**模板拼出来的**,配置源码里根本没有 `--c-red-600`
// 这个字面量 —— 扫文本会漏掉正是要防的那一类。
const resolveConfig = (await import('tailwindcss/resolveConfig.js')).default;
const resolved = resolveConfig((await import('../tailwind.config.js')).default);
const declared = new Set([...css.matchAll(/--[cs]-[a-z0-9-]+(?=\s*:)/g)].map(m => m[0]));
const usedVars = new Set();
const walk = (v) => {
if (typeof v === 'string') {
for (const m of v.matchAll(/var\((--[a-z0-9-]+)/g)) usedVars.add(m[1]);
} else if (v && typeof v === 'object') {
for (const k of Object.keys(v)) walk(v[k]);
}
};
// 只看颜色相关的 theme 段其余spacing/fontSize/...)不走变量
for (const key of ['colors', 'textColor', 'backgroundColor', 'borderColor', 'ringColor', 'divideColor']) {
walk(resolved.theme[key]);
}
const undef = [...usedVars].filter(v => !declared.has(v));
check(
'Tailwind 实际使用的每个变量都在 index.css 有定义',
usedVars.size >= 100 && undef.length === 0,
undef.length
? `${undef.length} 个未定义:${undef.slice(0, 6).join(', ')}`
: `只解析出 ${usedVars.size} 个变量引用`
);
// 18) colors 里每个颜色名只能定义一次。
// 重复键静默生效,是上一条那个 bug 的**成因**:读代码的人看到固定 hex
// 以为在用它,实际生效的是下面那份 accent()。
const colorsBlock = cfg.slice(cfg.indexOf('colors: {'));
const dupNames = [];
for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow', 'gray', 'chrome']) {
const n = (colorsBlock.match(new RegExp(`^\\s{8}${name}:`, 'gm')) || []).length;
if (n > 1) dupNames.push(`${name}×${n}`);
}
check('colors 里没有重复的颜色名', dupNames.length === 0, dupNames.join(' '));
// 19) 强调色的**表面段**50300深色下必须变暗。
// 照搬浅色值的话 red-50 (#fef2f2) 在深色页面上是一块近白亮斑 ——
// 那是错误提示条的底,结果比正文还抢眼,上面的红字反而读不动。
const surfaceOffenders = [];
for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow']) {
for (const shade of [50, 100, 200, 300]) {
const l = lum(lightBlock, `${name}-${shade}`);
const d = lum(darkBlock, `${name}-${shade}`);
if (l === null || d === null) { surfaceOffenders.push(`${name}-${shade}:缺失`); continue; }
if (d >= l) surfaceOffenders.push(`${name}-${shade}(${d}${l})`);
}
}
check(
'强调色表面段在深色下变暗',
surfaceOffenders.length === 0,
surfaceOffenders.slice(0, 6).join(' ')
);
// 20) 强调色的**前景段**400900在深色卡片上必须达到 WCAG AA 4.5:1。
// 照搬浅色值时 red-700 只有 2.67:1、amber-900 只有 1.90:1 ——
// 「看得见但读不动」跟看不见是同一类 bug。
const rgbOf = (block, name) => {
const m = block.match(new RegExp(`--c-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
};
const srgb = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
const relLum = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
const contrast = (a, b) => {
const l1 = relLum(a), l2 = relLum(b);
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
return (hi + 0.05) / (lo + 0.05);
};
const darkCard = rgbOf(darkBlock, 'white');
const lowContrast = [];
for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow']) {
for (const shade of [400, 500, 600, 700, 800, 900]) {
const fg = rgbOf(darkBlock, `${name}-${shade}`);
if (!fg) { lowContrast.push(`${name}-${shade}:缺失`); continue; }
const r = contrast(fg, darkCard);
if (r < 4.5) lowContrast.push(`${name}-${shade}:${r.toFixed(2)}`);
}
}
check(
'强调色前景段在深色卡片上达到 4.5:1',
darkCard !== null && lowContrast.length === 0,
lowContrast.slice(0, 6).join(' ')
);
// 21) 实心按钮底走独立的 --s-* 且**两种模式同值**。
//
// accent 的 400900 在深色下被提亮(为了 text-red-600 读得动),
// 而 `bg-red-600 text-white` 的白字落在那个浅红上只有 1.6:1。
// 一个名字服务两种语义必然坏掉一头 —— 与 text-white/bg-white 那次同理。
check(
'实心按钮底走 --s-* 并只覆盖 backgroundColor',
/const solid = \(name\) =>/.test(cfg) &&
/backgroundColor:\s*\{[^}]*red:\s*solid\('red'\)/s.test(cfg) &&
/--s-red-600:/.test(css)
);
// 22) --s-* 不能出现在 .dark 里:它必须两种模式同值。
// 在 .dark 覆盖等于把「危险操作是红的」这件事也一起反转了。
check(
'--s-* 未被 .dark 覆盖(实心底不随主题变)',
!/--s-[a-z]+-\d+:/.test(darkBlock)
);
// 23) 白字落在实心按钮底上必须达到 4.5:1。
//
// **两种模式都要算**。之前只拿浅色的 on-accent纯白 255去算而深色用的是
// 「近白」244 246 250为了不刺眼—— 同一个实心底,白字换暗一点点就从
// 4.83 掉到 4.46,路过了 AA 线。盲区在真实渲染里才被量出来(导航未读徒标
// 「12」
const solidBlock = cssNoComments.slice(
cssNoComments.indexOf(':root'),
cssNoComments.indexOf('.dark')
);
const sRgb = name => {
const m = solidBlock.match(new RegExp(`--s-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
};
// 代码里真正出现过的「实心底 + 白字」组合
const solidPairs = [
['blue-600'], ['blue-700'],
['red-600'], ['red-700'],
['green-700'], ['orange-700']
];
const onAccentValues = [
['浅色', rgbOf(lightBlock, 'on-accent')],
['深色', rgbOf(darkBlock, 'on-accent')]
];
const weakButtons = [];
for (const [mode, onAccent] of onAccentValues) {
if (!onAccent) { weakButtons.push(`${mode}:on-accent 缺失`); continue; }
for (const [name] of solidPairs) {
const bg = sRgb(name);
if (!bg) { weakButtons.push(`${name}:缺失`); continue; }
// 这些按钮文字只有 914px按普通文字执行 WCAG AA 4.5:1。
const r = contrast(onAccent, bg);
if (r < 4.5) weakButtons.push(`${mode}/${name}:${r.toFixed(2)}`);
}
}
check(
'白字在实心按钮底上达到 4.5:1两种模式',
weakButtons.length === 0,
weakButtons.join(' ')
);
// 24) 应用框架(侧栏 / 底部导航)必须有独立色阶。
// 它在浅色模式下本来就是深色的 —— 并入反转的 gray 之后深色模式下会变成
// 近白色,比内容区还亮,整个层次翻过来(实测 rgb(243,245,248))。
check(
'框架有独立的 chrome 色阶',
/chrome:\s*\{/.test(cfg) &&
/--c-chrome-900:/.test(lightBlock) &&
/--c-chrome-900:/.test(darkBlock)
);
// 25) 深色下框架必须比内容区更沉(保持浅色下就有的层次关系)。
const chromeDark = lum(darkBlock, 'chrome-900');
check(
'深色下框架比内容区更暗',
chromeDark !== null && darkG50 !== null && chromeDark < darkG50,
`chrome-900=${chromeDark} gray-50=${darkG50}`
);
// 26) 侧栏与底部导航里不该残留 slate-*(那条色阶指向反转的 gray
const chromeFiles = ['Sidebar', 'NarrowNav'];
const slateLeft = [];
for (const f of chromeFiles) {
const src = code(join(here, `../src/components/${f}.tsx`));
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(' | '));
// 27) 可逆灰阶不能作为白字实心按钮底。gray-700/800/900 在深色模式下会
// 被反转成近白色,`bg-gray-900 text-white` 因而只剩约 1:1。
const componentSources = readdirSync(compDir)
.filter(x => x.endsWith('.tsx'))
.map(f => [f, prose(join(compDir, f))]);
const graySolid = componentSources.flatMap(([f, src]) =>
(src.match(/(?:bg-gray-(?:700|800|900)[^'"\n]*text-white|text-white[^'"\n]*bg-gray-(?:700|800|900))/g) || [])
.map(hit => `${f}:${hit}`)
);
check('白字实心控件不使用可逆 gray 底色', graySolid.length === 0, graySolid.slice(0, 4).join(' | '));
// 28) 未映射色族会绕过 CSS 变量主题,浅色值会原样落进深色页面。
const unmapped = componentSources.flatMap(([f, src]) =>
(src.match(/(?:bg|text|border)-(?:emerald|purple)-\d+/g) || []).map(hit => `${f}:${hit}`)
);
check('组件未使用未映射的 emerald/purple 色族', unmapped.length === 0, unmapped.slice(0, 6).join(' | '));
// 29) 本地 markdown 层替代未安装 typography 插件时无效的 prose 类。
const inertProse = componentSources.flatMap(([f, src]) =>
(src.match(/\bprose(?:-sm)?\b/g) || []).map(hit => `${f}:${hit}`)
);
check('Markdown 内容使用本地 .markdown 排版层', inertProse.length === 0, inertProse.slice(0, 4).join(' | '));
// 30) gray-400 承担 912px 元数据与 placeholder白卡片上也必须达到 4.5:1。
const lightCard = rgbOf(lightBlock, 'white');
const lightSecondary = rgbOf(lightBlock, 'gray-400');
const secondaryContrast = lightCard && lightSecondary ? contrast(lightCard, lightSecondary) : 0;
check(
'浅色 gray-400 在白卡片上达到 4.5:1',
secondaryContrast >= 4.5,
secondaryContrast.toFixed(2)
);
console.log(`\n主题:${pass} 通过${fail ? `${fail} 失败` : ''}`);
/*
* 机器可读的汇总(契约):`run-all.mjs` 只认这一行来判"这条判据到底跑了几条"。
* 为什么需要它:光看"文件里存在 check("只能证明**有能红的路径**,不能证明**它跑过** ——
* 把 `check` 的实现换成空函数(合并冲突改坏实现的现实事故)时,文本证据照样成立。
* 计数在 check() **内部**自增,所以"实现被换空"会直接体现为 pass=0。
*/
console.log(`RESULT pass=${pass} fail=${fail}`);
process.exit(fail ? 1 : 0);