Files
MailUI4Agents/client/electron/test/background.test.mjs
JianFeeeee 65de1c3884 跨端对齐:授权栏 navigator_only + 组件按页拆分 + 服务器补 permission_options
用户两项裁定落地(均为 ask_user 明确选择):

① 授权栏口径 = navigator_only(照 WebUI 架构)
   · 新建 pages/PermissionPanel.ets —— 详情页的决策面板,
     对应 MailView.tsx:693 的 PermissionPanel(审批型 / 主动提问 / 已处理 三态)
   · 决策入口从授权栏移到 MailDetailPage;MailDetailPage 原来只显示一个
     「权限请求」小标签、根本没有决策入口(比 WebUI 少一整块,且反了:
     栏里能决策、点进详情反而不能)
   · PermissionTab 删掉内联「同意/拒绝」+ 备注框 + decide():
     整卡可点 → onOpenMail(对齐 WebUI PermissionList.tsx:81 的 pick())
   · PermissionRequest 补 source_account_id(客户端侧记来源,跳详情要定位网关)

② 服务器补 permission_options —— 修一条真实的、跨端共有的缺口
   · mails.permission_options 从 INSERT 起就写进去,但**从来没有任何读路径
     选过它** ⇒ 详情端点永远返回空。WebUI 的决策面板读 mail.permission_options,
     所以提问型的预设选项**两端全部落空**(审批型靠 ['同意','拒绝'] 兜底蒙混)
   · GetMailByID 补选该列 + JSON 反序列化(与 cc_list 同款)

③ 组件按页封装(用户要求「以便与 WebUI 一一对应」)
   MainPage.ets 4592 → 3192 行
   · pages/PermissionTab.ets    720 行  ↔ PermissionList.tsx
   · pages/ContactsTab.ets      796 行  ↔ ContactPanel.tsx
   · pages/NavDestinations.ets  181 行  ↔ Navigation 壳
   · pages/NavShared.ets         65 行  ↔ 跨栏共用件

④ 判据跟着组件搬家(否则静默失效,不是红)
   harmony-logic 的 pageCode / harmony-nav 的 navSrc 改为显式文件名单;
   harmony-appearance 的 PANE_SOURCES 补 ContactsTab;harmony-contacts 三个
   test 并入 ContactsTab;harmony-logic 的决策断言改指 PermissionPanel,
   并新增「授权栏不许再有内联决策」两条(navigator_only 的正形状)。

   animation-audit:共享元素转场判据从「同文件共址」改为「按 id 找驱动」。
   旧形状把 in/out 端必须在同一文件当成代理,而两端**天然在两处**;
   抽出写信页(NavDestinations 持有 in 端)后误报。新判据仍要求每个 id
   都有 Motion.morph 驱动 —— 变异实测:把驱动换成裸 animateTo 仍判红。

判据:files=34 checks=556 red=1(仅 build-stamp,产物待重构建)
2026-09-24 10:10:32 +08:00

600 lines
33 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.

/**
* 自定义背景的结构性检查。
*
* 与 theme.test.mjs 同一风格:判据是「源码里存在/不存在某种形态」,不需要浏览器。
* 真正的视觉验收靠手工脚本(test/manual/)。
*
* 这些检查存在的理由:背景是**装饰层叠加在内容之下**,它的 bug 形态是
* 「正文读不动」和「背景根本没出现」,两者都不报错、不影响构建。
*/
import { prose, bytes } from './lib/read.mjs';
import { readdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import postcss from 'postcss';
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').replace(/\/\*[\s\S]*?\*\//g, '');
const store = read('../src/stores/backgroundStore.ts');
const picker = read('../src/components/BackgroundPicker.tsx');
const main = read('../src/main.tsx');
const rootBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark'));
const darkBlock = css.slice(css.indexOf('.dark {'));
// 1) 遮罩颜色必须两种主题各一份。
// 只有一个值时,深色模式下用白色遮罩会把照片洗成一块亮斑,正文完全读不动。
check(
'--bg-scrim 在浅色与深色下都有定义',
/--bg-scrim:\s*\d+\s+\d+\s+\d+/.test(rootBlock) && /--bg-scrim:\s*\d+\s+\d+\s+\d+/.test(darkBlock),
'深色缺少遮罩色时浅色照片会压不住'
);
// 2) 遮罩色必须是 RGB 三元组(与色板同一约定),否则 rgb(var() / var()) 无效。
check(
'--bg-scrim 存 RGB 三元组',
/--bg-scrim:\s*\d+\s+\d+\s+\d+;/.test(rootBlock) && !/--bg-scrim:\s*#/.test(css)
);
// 3) 背景层必须画在内容**之下**。
// z-index:0 / auto 的定位元素会画在常规流内容之上,把整个界面盖住。
check(
'背景层用负 z-index 且不吃点击',
/\.app-backdrop\s*\{[^}]*z-index:\s*-1/.test(css) &&
/\.app-backdrop\s*\{[^}]*pointer-events:\s*none/.test(css),
'z-index 不为负会盖住界面'
);
// 4) 背景开启时必须让出不透明的页面底,否则背景永远看不见。
// 这是最容易漏的一条:写好了渐变、挂好了层,却被 bg-gray-50 挡住。
//
// 正则要写成「选择器列表 … { 声明体 }」而不是 [^{]* 直接跨到声明:
// [^{] 遇 { 即停,而这里要跨过的正是选择器后面的那个 {。
check(
'data-bg=on 时页面底变透明',
/html\[data-bg='on'\]\s+body/.test(css) &&
/html\[data-bg='on'\][^{}]*\.bg-gray-50/.test(css) &&
/html\[data-bg='on'\][^{}]*\{[^}]*background-color:\s*transparent/.test(css)
);
// 5) 玻璃化只应在背景开启时生效。
// 若无条件给 .bg-white 加半透明/模糊,关闭背景的用户会看到一层发灰的卡片。
const glassRules = css.match(/html\[data-bg='on'\][^{]*\{[^}]*backdrop-filter/g) || [];
const bareGlass = /(^|\n)\s*\.bg-white\s*\{[^}]*backdrop-filter/.test(css);
check(
'backdrop-filter 仅在 data-bg=on 下使用',
glassRules.length > 0 && !bareGlass,
bareGlass ? '存在无条件的 .bg-white 模糊规则' : '未找到玻璃化规则'
);
// 6) 悬停态也要接管。
// 不接管的话鼠标一进面板就从不透明闪回,观感是明显的跳动。
check(
'悬停态一并在背景模式下接管',
/html\[data-bg='on'\][^{]*\.hover\\:bg-gray-50:hover/.test(css)
);
// 7) 预设渐变只能由 CSS 提供色值,组件里不得写死颜色。
// 写死十六进制会绕过主题变量 —— 深色模式下会原样落下浅色渐变。
const presetClasses = (css.match(/\.bg-preset-[a-z]+\s*\{/g) || []).length;
const hexInPicker = picker.match(/#[0-9a-fA-F]{3,6}\b/g) || [];
check(
'预设渐变定义在 CSS 且组件无写死颜色',
presetClasses >= 4 && hexInPicker.length === 0,
hexInPicker.length ? `组件含 ${hexInPicker.join(',')}` : `只找到 ${presetClasses} 个预设`
);
// 8) 预设必须复用调色板变量(因此自动随主题变),而不是字面色值。
check(
'预设渐变复用调色板变量',
/\.bg-preset-aurora\s*\{[^}]*rgb\(var\(--c-/.test(css)
);
// 9) 背景必须在 render 之前套用,且在 App 之外挂载。
// 挂在 App 内会只存在于主界面分支上,登录页/加载页没有背景。
check(
'启动时套用背景且挂在 App 之外',
/initBackground\(\)/.test(main) && /className="app-backdrop"/.test(main),
'缺少 initBackground 或背景层不在 App 外'
);
// 10) 存储键与归一化入口存在(旧数据/脏数据不能让页面白屏)。
check(
'有独立存储键与脏数据归一化',
/STORAGE_KEY\s*=\s*'agentmail\.background'/.test(store) && /normalizeBackground/.test(store)
);
// 11) 图片必须压缩后再存,且有明确上限。
// 手机直出照片 4–8MB,直接塞 localStorage 会超配额并抛异常 ——
// 用户看到的是「选了图片没反应」。
check(
'图片有缩放与体积上限',
/MAX_EDGE\s*=\s*\d+/.test(store) &&
/MAX_DATA_URL_BYTES\s*=\s*[\d_]+/.test(store) &&
/drawScaled/.test(store)
);
// 12) 失败必须给出原因,不能静默。
check(
'图片处理失败返回原因',
/ok:\s*false;\s*reason:\s*string/.test(store) && /role="alert"/.test(picker)
);
// 13) 背景是装饰偏好,写 DOM 失败不得抛出打断操作。
check(
'背景写入 DOM 前有环境判断',
/typeof document === 'undefined'/.test(css.slice(0, 0) + store)
);
// 14) 动效必须尊重 prefers-reduced-motion。
check(
'尊重 prefers-reduced-motion',
/@media\s*\(prefers-reduced-motion:\s*reduce\)/.test(css)
);
// 15) 组件里不得出现未映射色族(emerald/purple 等会绕过主题)。
const compDir = join(here, '../src/components');
const unmapped = [];
for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) {
const src = prose(join(compDir, f));
if (/(?:bg|text|border)-(?:emerald|purple)-\d+/.test(src)) unmapped.push(f);
}
check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(' '));
// 16) ★ 玻璃不得层层相乘(2026-09-13 用户报:"壁纸底上叠了太多不透明层")。
//
// 判据用**算式**而不是感觉:每层都吃同一个 a,两层就是 1-(1-a)²。
// 缺陷时 a=0.82 ⇒ 两层 0.97、三层 0.995(壁纸在数学上被吃掉)。真实渲染实测
// (纯红壁纸读绿通道)同样印证:修复前 p95 82%,修复后 62%。
const varOf = name => {
const mm = css.match(new RegExp(`--${name}:\\s*([0-9.]+);`)); // 第一次出现 = 浅色那组
return mm ? Number(mm[1]) : NaN;
};
const glass = varOf('bg-glass');
const inner = varOf('bg-glass-inner');
const composite = (base, nested, layers) => {
let opaque = base;
for (let i = 1; i < layers; i++) opaque = opaque + nested * (1 - opaque);
return opaque;
};
const ctrl = Number((css.match(/--bg-glass-control:\s*([\d.]+)/) || [])[1]);
const stacked = composite(glass, inner, 2);
check('玻璃有"嵌套层"变量', Number.isFinite(glass) && Number.isFinite(inner), `glass=${glass} inner=${inner}`);
check('有"嵌套面板不再各叠一次"的规则', /\.bg-white \.bg-white \{/.test(css));
/*
* ★ 这三条 2026-09-14 重写(原先断言的是"越透越好",方向是错的)。
*
* 我第一版按"内容更通透"把正文面压到 0.45,用户当场指出:「你把大量需要打底的
* 场景(弹窗正文等)改为了透明。真正该透明的地方(空白区域)加了很浓的模糊」。
* 于是判据改成**两类面分开**:
* - 承载文字的面:必须够实(0.8–0.95),否则字压在壁纸上读不动;
* - 空白/页面底:透明且不模糊(另有用例)。
* 旧的"≤0.70 / ≤0.85"留着只会把我再拽回那个错误方向,所以连同理由一起改掉。
*/
check('正文面够实(0.80–0.95,读得清优先)', glass >= 0.8 && glass <= 0.95, `实际 ${glass}`);
check('嵌套面比外层透、但仍打底(0.70–外层)', inner >= 0.7 && inner <= glass, `glass=${glass} inner=${inner}`);
check('两层嵌套后仍接近实心(≥0.9)', composite(glass, inner, 2) >= 0.9, `实际 ${stacked.toFixed(3)}`);
check(
'控件档最透,且比嵌套面更透(复选框/地址建议)',
ctrl <= 0.6 && ctrl < inner,
`control=${ctrl} inner=${inner}`
);
check('判据自检:两档一样透是分不出层次的(必须算得出 ≥0.9)', composite(0.82, 0.82, 2) >= 0.9);
// 17) ★ 浅色表面类的接管清单必须跟着源码走。
//
// 缺陷的另一半:当时只接管了 white/gray-50/slate-100,而源码里在用的
// gray-100(24 处)、blue-50(17)、red-50(13)… 全是实心的,正好把壁纸盖住。
const gen = read('../src/background-takeover.generated.css');
/*
* ★ 生成一次、用一个来源(`GEN_BG_OUT` 指到临时路径)——**不给自己留兄弟副本**。
* (这一路的教训:`stripStrings` 曾经两份、`blurStyleFor` 曾经死而不删;
* 同一个事实有两份实现,下一轮只会被修在其中一份里。)
* 读**二进制原文**用 `bytes()`:这条判的正是"字节是否一致",不是"代码里有什么"。
*/
const generateFresh = () => {
const dir = mkdtempSync(join(tmpdir(), 'agentmail-genbg-'));
const out = join(dir, 'out.css');
try {
const r = spawnSync(process.execPath, [join(here, '../scripts/gen-background-takeover.mjs')],
{ encoding: 'utf8', env: { ...process.env, GEN_BG_OUT: out } });
if (r.status !== 0) {
return { err: `退出码 ${r.status}:${(r.stderr || '').trim().split('\n').slice(-2).join(' / ')}` };
}
return { text: bytes(out).toString('utf8') };
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
const used = new Set();
const walk = dir => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) walk(full);
else if (/\.(tsx?|jsx?)$/.test(e.name)) {
const src = prose(full);
for (const mm of src.matchAll(/bg-[a-z]+-\d{2,3}/g)) {
if (/^bg-[a-z]+-(50|100|200)$/.test(mm[0])) used.add(mm[0]);
}
}
}
};
walk(join(here, '../src'));
const PAGE_BASE = new Set(['bg-gray-50', 'bg-slate-100']); // 页面底必须保持全透明
const manifest = [...used].filter(c => !PAGE_BASE.has(c));
const missing = manifest.filter(c => !gen.includes(`.${c} {`));
check('源码里用到的浅色表面类全被接管', missing.length === 0, `漏了:${missing.join(', ')}`);
/*
* ★ 生成文件的**头注释不能提前闭合**(2026-09-14 修,与上面那条同一根因的两个后果)。
*
* 生成器原先在头注释里写了「src」加「/」加两颗星加「/」加「.tsx」,其中那对
* 「星号 + 斜杠」把注释**提前闭合**:尾巴变成 CSS 正文,跟第一条规则的选择器
* 连在一起成为非法选择器 ⇒ **那条规则被浏览器整条丢掉**(`bg-amber-100` 在
* 壁纸模式下不再变半透明)。构建只给一条 `[WARNING] Unexpected "14"`,
* 不报错、不影响构建 —— 正是这套判据存在的理由。
*
* 判据:把注释剥掉之后,文件必须以第一条规则的**选择器**起头。
*/
const stripped = gen.replace(/\/\*[\s\S]*?\*\//g, '').trim();
check(
'生成 CSS 的头注释没有提前闭合(否则第一条规则会被整条丢掉)',
stripped.startsWith("html[data-bg='on'] .bg-"),
`剥掉注释后以「${stripped.slice(0, 40)}…」起头`
);
// 反向对照:判据要真能抓到"注释里带星号+斜杠"这种写法
const bad = "/* 来自 src/**/*.tsx 的用法 */\nhtml[data-bg='on'] .bg-amber-100 { color: red }";
check(
'判据自检:注释里带「星号紧接斜杠」时必须判红',
!bad.replace(/\/\*[\s\S]*?\*\//g, '').trim().startsWith("html[data-bg='on'] .bg-"),
'自检失败 —— 这条判据抓不到它要抓的东西'
);
check('页面底没有被写进半透明清单', [...PAGE_BASE].every(b => !gen.includes(`.${b} {`)));
check('清单不是空跑(至少扫到 10 个类)', used.size >= 10, `实际 ${used.size}`);
/*
* 17b) ★★ 生成物必须与**当前源码**逐字节一致 —— 把上面那条判据**两个方向都补上**。
*
* pi 2026-09-15:上面那条 `missing.length === 0` **只判一个方向**
* ("源码里用到的类必须都在生成文件里");
* **反向没有判据**:生成文件里**多出来的、源码已不再用**的类,谁也看不见。
* 于是有这样一条路径:
* ① 有人删掉最后一处 `bg-blue-50` 用法、**没跑生成器**;
* ② 生成文件里留下不再需要的 `.bg-blue-50` 规则 ⇒ 上面那条**仍然绿**(它只看漏没漏);
* ③ `install.sh --check` 跑 `gen:bg` ⇒ **原地删掉那条规则** ⇒
* 一个自称干跑的命令**改了一个被跟踪文件**,还**擦掉了"有人忘了跑生成器"的证据**。
*
* ⇒ 判据:**重新生成到临时路径,与真身逐字节比**。它同时给出三件事:
* 反向那一半(多出来的过期规则)、"幂等"从**观察**变成**被检查的性质**、
* 以及"忘了跑生成器"**会被判红而不是被静默修好**。
*
* ⚠️ 只**读到**临时路径(`GEN_BG_OUT`),绝不写工作树里那个文件 ——
* 一条"判同步"的判据如果自己去同步,就永远绿。
* ⚠️ 早先我在这里用了裸 `readFileSync`,**被本仓自己的 `criteria-hygiene` 抓红**了
* ("判据目录里不得裸用 readFileSync")。这是它**正确工作**的一例:
* 我新写判据时没走 `test/lib/read.mjs` 的具名入口。现在读**二进制原文**用 `bytes()`
* (这条判的是"字节是否一致",不是"代码里有什么"),所以 `bytes()` 正是对的入口。
*/
const freshGen = generateFresh();
if (freshGen.err) {
check('生成器能跑起来(这条判据的前提)', false, freshGen.err);
} else {
const fresh = freshGen.text;
if (fresh === gen) {
check('生成物与当前源码逐字节一致(反向:也没有多出来的过期规则)', true);
} else {
const fl = fresh.split('\n');
const gl = gen.split('\n');
let i = 0;
while (i < Math.min(fl.length, gl.length) && fl[i] === gl[i]) i += 1;
const inFile = gl.filter(l => /^html\[data-bg='on'\] \./.test(l)).length;
const inFresh = fl.filter(l => /^html\[data-bg='on'\] \./.test(l)).length;
check('生成物与当前源码逐字节一致(反向:也没有多出来的过期规则)', false,
`真身 ${inFile} 条规则 vs 重新生成 ${inFresh} 条;首个不同在第 ${i + 1} 行:\n`
+ ` 真身:${(gl[i] ?? '(无)').trim().slice(0, 90)}\n`
+ ` 新生成:${(fl[i] ?? '(无)').trim().slice(0, 90)}\n`
+ ' 修法:跑 `npm run gen:bg` 并**把改动提交**(别让它在某次干跑里被静默改掉)。\n'
+ ' 注意这条判的是**两个方向**:漏了规则会红,**多了过期规则也会红**。');
}
}
/*
* ★ 生成产物"能被 CSS 解析器吃下"还是"只有浏览器能发现"(2026-09-14,pi 提议)。
*
* 上面那条查的是**文本**:剥掉注释后以第一条选择器起头。而"注释提前闭合"这个 bug
* 更狠的形态是:那条规则在文本里**还在**,甚至在 postcss 里**也还是一条规则**
* —— 但它的选择器前面粘上了注释的尾巴(当年构建只给一句
* `[WARNING] Unexpected "14"`),于是浏览器按"选择器不匹配任何元素"整条丢掉。
*
* 所以光"能解析"不够(实测:postcss 对坏产物照样解析出 1 条规则,只是选择器变了),
* 判据要卡的是**选择器的形状** + **规则条数**:
* - 每条规则的选择器必须**恰好**是 `html[data-bg='on'] .bg-xxx`(多一个字符都不行);
* - 规则条数必须等于源码里扫到的接管清单条数(多一条少一条都要红)。
* 收获:把"生成器写坏产物"从"只有浏览器能发现"变成"跑判据就红"。
*/
const RULE_SHAPE = /^html\[data-bg='on'\] \.bg-[a-z]+-\d{2,3}$/;
let parsedSelectors = null;
let parseErr = '';
try {
parsedSelectors = postcss
.parse(gen)
.nodes.filter(n => n.type === 'rule')
.map(n => n.selector);
} catch (e) {
parseErr = String(e && e.message ? e.message : e);
}
check('生成的接管 CSS 能被 CSS 解析器读下来', parseErr === '', parseErr);
if (parsedSelectors !== null) {
const malformed = parsedSelectors.filter(s => !RULE_SHAPE.test(s));
check(
'每条接管规则的选择器形状都与生成器一致(防注释尾巴粘进选择器)',
malformed.length === 0,
`异常选择器:${malformed.slice(0, 3).join(' | ')}`
);
check(
'接管规则条数与源码扫到的清单条数一致',
parsedSelectors.length === manifest.length,
`规则 ${parsedSelectors.length} 条 / 清单 ${manifest.length} 个`
);
// 反向对照:用生成器**当年**的坏写法(注释里带"星号紧接斜杠")验证判据真的会红
const broken = "/* 来自 src/**/*.tsx 的用法 */\nhtml[data-bg='on'] .bg-amber-100 { background: red }";
const brokenSels = postcss
.parse(broken)
.nodes.filter(n => n.type === 'rule')
.map(n => n.selector);
check(
'判据自检:注释提前闭合后,第一条规则的选择器必须被判为异常形状',
brokenSels.length === 0 || brokenSels.some(s => !RULE_SHAPE.test(s)),
`解析出:${brokenSels.join(' | ')}`
);
}
// 18) ★ 导航栏必须**完全不透明**、内容面板必须更通透、整体圆角玻璃化。
//
// 用户 2026-09-14 原话:「导航栏应当完全不透明……没有正文的位置过于不通透,
// 同时导航栏应当现代化一下,整个界面应当圆角化玻璃化」。
// 注意这是**两个方向**的要求:框架要实、内容要透 —— 写成一条规则就会互相打架。
/*
* ★ 导航栏:从"不透明"改成"深色玻璃"(2026-09-14 用户:「导航栏不也应该改为玻璃样式吗,
* 为什么还是黑色」)。
*
* 这两条判据原本断言的是**相反**的东西(不透明 + 不模糊),是更早一轮的要求
* (当时面板太透、壁纸从导航透出来显得脏)。要求反转后旧判据必须一起改 ——
* 留着它只会让下一次改动"要么违规、要么把缺陷写回去"。
*
* 现在的契约:**深色玻璃**(白字要对比度,所以不用白色玻璃),
* α 落在 [0.6, 0.9]:太透读不清,太实就退回那块黑 slab。
*/
/*
* ★ 导航这条改过三次,最后一次才对(2026-09-14 用户:「那你为什么不把白字换成
* 黑字或者自动反色或者描边呢?」):
* ① 不透明实心(最早)
* ② 深色玻璃(我为了"白字对比度"做的 —— 用错误的方式解决对比度,全页唯一一块黑)
* ③ **按主题走的令牌**:浅色=白玻璃+深字(深色主题尚未存在,见下)
* 判据因此不再断言某个固定颜色,而是断言"走令牌"这件事 + 对比度由
* test/manual/nav-contrast-verify.mjs 用 WCAG 比值来量。
*/
check('导航底色走 --nav-bg 令牌(不写死)',
/\.nav-rail \{[\s\S]{0,80}background-color: rgb\(var\(--nav-bg\)\)/.test(css) &&
/\.nav-item \{[\s\S]{0,80}color: rgb\(var\(--nav-fg-muted\)\)/.test(css));
/*
* ★ 下面两条 2026-09-14 重写:原先断言「浅色/深色两套 --nav-bg 都定义」与
* 「壁纸模式下 .nav-rail 里有 backdrop-filter」,两条编码的都是**已被有意撤掉的
* 设计**,于是 `npm test` 在 HEAD 上恒红。判据红成常态就不再是判据 —— 该改的是
* 判据本身,而**不是**把缺陷写回代码。
*
* ① 深色导航撤掉了(用户「导航栏为什么还是黑色」)。根因不是令牌抄错,而是这个
* 应用**还没有深色主题**:`darkMode:'class'` 配着,却没有任何组件写 `dark:`
* 变体 ⇒ 只把导航压深就得到「导航黑、正文白」,比全浅更割裂。现在的契约是
* 导航跟随内容的实际形态(浅色玻璃),所以这里断言的是**「没有深色主题之前,
* .dark 不许单独给导航换色」**。真做深色主题时,这条要连同 dark: 变体一起改。
*
* ② 导航不再自己模糊:模糊只由壁纸层负责(用户「你又犯了模糊叠模糊的毛病……
* 整体的模糊是由壁纸那一层模糊确定的」)。导航浮在壁纸上,背后是**已经模糊过**
* 的壁纸,再 backdrop-filter 一次只会更脏更掉帧。所以断言是反向的:壁纸层有模糊,
* 而壁纸模式下的 .nav-rail 没有。
*
* ★★ 2026-09-14 **标签缩回断言的真实范围**(pi 指出,我逐条变异确认)★★
*
* 我原先把这两条的名字写成了"导航**不得**变暗 / 不得自叠模糊",但它们各自只扫了
* **一条路**。我用 pi 预测的三条逃逸路各注了一次变异,**三条全部逃掉**(导航变黑/变模糊,
* 而两条判据照样全绿):
*
* | 逃逸路 | 变异注入 | 结果 |
* | --- | --- | --- |
* | A 选择器作用域 | `.dark .nav-rail { background-color: #0f172a }` | **绿**(`/\.dark\s*\{/` 匹配不到它) |
* | B 组件的 dark: 变体 | `className="nav-item dark:bg-slate-900 …"` | **绿**(Tailwind 产的也不是 `.dark{--nav-}`) |
* | C 组件的工具类模糊 | `className="nav-item backdrop-blur-lg …"` | **绿**(不在那条选择器下) |
*
* ⇒ 选择 (a) **把标签缩回它真正断言的东西**,并把它没守的路写在名字里之外的地方(本注释):
* **标签比断言宽的判据,会在它没测的那条路上被回滚时给绿,而人信的是标签。**
* (为什么不选 (b)「扩到扫全部 JSX 类名 + CSS 选择器」:成本高,且极易变成一刀切误红 ——
* `bg-chrome-600` plain 档徽标就是先例。)
*
* **未覆盖的路(各自另立判据时才补,不是"已经安全")**:A/B 两条深色来源、
* C 这条模糊来源。真要堵时按文件窄豁免写,别写成"导航目录不许出现 dark:"。
*/
const darkBodies = [...css.matchAll(/\.dark\s*\{([^}]*)\}/g)].map(m => m[1]);
/*
* ★ 2026-09-17 **方向反过来了**(用户报「深色模式可读性差」,实测导航合成
* rgb(188,189,190) 而文字只有 4.03:1):
*
* 旧断言是「.dark 里**不许**出现 --nav-*」,它编码的是**当时还没有深色主题**
* 这个前提 —— 只把导航压深确实会比全浅更割裂。现在深色主题真做了(灰阶令牌
* 本来就已反转,缺的只是手写 CSS 里那几处硬编码白色),导航就必须跟着换深,
* 否则它就是深色界面上的一条**残留浅灰条**。
*
* 断言因此从「不许有」改为「必须有,且字要亮、底要暗」——保留原来的意图
* (防止只压深导航的割裂),但反过来守现在这个正确形态。
* 为什么不只断「有」:只断存在的话,把深色令牌写成浅色值也算过。
*/
const darkNavBlock = darkBodies.find(b => /--nav-bg/.test(b));
const navBgLum = (() => {
const m = darkNavBlock && darkNavBlock.match(/--nav-bg:\s*(\d+)\s+(\d+)\s+(\d+)/);
return m ? (Number(m[1]) + Number(m[2]) + Number(m[3])) / 3 : null;
})();
const navFgLum = (() => {
const m = darkNavBlock && darkNavBlock.match(/--nav-fg:\s*(\d+)\s+(\d+)\s+(\d+)/);
return m ? (Number(m[1]) + Number(m[2]) + Number(m[3])) / 3 : null;
})();
check(
'深色主题里导航必须换深(否则是深色界面上残留的一条浅灰条)',
navBgLum !== null && navBgLum < 90,
navBgLum === null ? '.dark 里没有 --nav-bg —— 深色下导航仍是白玻璃(实测 4.03:1)' : `--nav-bg 平均亮度 ${navBgLum}(应 <90)`
);
check(
'深色导航的文字是亮字(底暗字亮,不是深底深字)',
navFgLum !== null && navFgLum > 180,
navFgLum === null ? '缺 --nav-fg' : `--nav-fg 平均亮度 ${navFgLum}(应 >180)`
);
/*
* ★★ 2026-09-23 **堵上两条逾期欠债**(`docs/DEBTS.json` 的
* `nav-dark-route-b-unguarded` 与 `nav-blur-route-c-unguarded`)。
*
* ── 为什么现在堵 ──
* 这两条欠债的 `due` 写的是「**引入深色主题(或第一次给导航组件加 dark: 变体)时**
* 必须一并堵」。而深色主题 **2026-09-17 就落地了**(见上面那段 `.dark` 的注释 ——
* 那一次把判据从「.dark 里不许有 --nav-*」翻转成「必须有深色导航令牌」)。
* ⇒ 到期条件**在当时就成立了**,但那一次只修了令牌这条路,A/B/C 三条逃逸路
* 一直没堵 —— 欠债逾期到现在。
*
* ── 三条逃逸路的现状(判据逐条看)──
* · A `.dark .nav-rail { background-color: … }`(选择器作用域)—— **上一条已堵**:
* 它搜 `.dark{}` 块体,而 A 是**另一条选择器**,落在块体之外 ⇒ 逃掉。
* 等等 —— 那一条现在也堵不了 A。见下面 A 条新判据。
* · B `className="nav-item dark:bg-slate-900"`(Tailwind 的 dark: 变体)——
* 产出的不是 `.dark{--nav-*}`,**当前完全无判据**。
* · C `className="nav-item backdrop-blur-lg"`(元素级工具类模糊)——
* 不在 `html[data-bg='on'] .nav-rail{…}` 那条选择器下,**当前完全无判据**。
*
* ── 堵法为什么是「窄豁免」而不是一刀切(欠债里点名警告过)──
*
* 欠债原文:「堵法按**文件窄豁免**写,不许写成『导航目录不许出现 dark:』
* (`bg-chrome-600` plain 档徽标那个先例我踩过一次)」。
*
* 那个先例的形状是:一条"某目录不许出现 X"的宽断言,会把**合法的**
* 非导航用途一起判红 —— 判据一红,下一个人就会去放宽它,最后什么都守不住。
*
* ⇒ 这两条**只扫 `className` 里出现 `nav-rail`/`nav-item` 的那些字符串**。
* 换句话说:问的不是"这个文件里有没有 dark: 背景",而是
* **"挂在导航那个元素上的那个类串里有没有"** —— 与逃逸路的形状同构。
* 同一个文件里的别的元素(徽标、头像…)写什么都不影响。
*/
const navComponents = ['../src/components/Sidebar.tsx', '../src/components/NarrowNav.tsx']
.map((p) => read(p));
/** 把所有 className 字符串里的"导航类串"取出来(支持 "…" 与 `…` 两种写法) */
const navClassStrings = [];
for (const src of navComponents) {
for (const m of src.matchAll(/className=(?:"([^"]*)"|\{`([^`]*)`\})/g)) {
const cls = m[1] || m[2] || '';
if (/\bnav-(?:rail|item)\b/.test(cls)) navClassStrings.push(cls);
}
}
check(
'B 条已堵:挂在导航元素上的类串里不许出现 `dark:bg-*`(Tailwind 深色变体绕过 --nav-bg)',
navClassStrings.length > 0 && !navClassStrings.some((c) => /\bdark:bg-/.test(c)),
navClassStrings.length === 0
? '一个导航类串都没扫到 —— 判据的锚点失效了(组件改名要一起改本判据)'
: `有导航类串写了 dark:bg-* ⇒ 深色下导航会绕过 --nav-bg 令牌自己变色:` +
navClassStrings.filter((c) => /\bdark:bg-/.test(c)).join(' | ')
);
check(
'C 条已堵:挂在导航元素上的类串里不许出现 `backdrop-blur-*`(元素级模糊叠在壁纸模糊上)',
navClassStrings.length > 0 && !navClassStrings.some((c) => /\bbackdrop-blur/.test(c)),
navClassStrings.length === 0
? '一个导航类串都没扫到 —— 判据的锚点失效了'
: `有导航类串写了 backdrop-blur-* ⇒ 壁纸已经模糊过,导航再糊一次(更脏更掉帧):` +
navClassStrings.filter((c) => /\bbackdrop-blur/.test(c)).join(' | ')
);
check(
'A 条已堵:不许用 `.dark .nav-rail` / `.dark .nav-item` 这类**选择器**给导航单独换色',
!/\.dark\s+[^{]*\.nav-(?:rail|item)/.test(css),
'深色下导航的底色只有一个来源(`.dark` 块里的 `--nav-bg` 令牌);' +
'另开一条 `.dark .nav-rail{…}` 选择器会让它绕过令牌 —— 那正是「导航黑、正文白」那个老问题的形状'
);
check(
'导航自叠模糊之一已堵:壁纸模式下 .nav-rail 规则里不许写 backdrop-filter(元素级工具类那条路未覆盖)',
/\.app-backdrop \{[\s\S]*?filter: blur\(/.test(css) &&
!/html\[data-bg='on'\] \.nav-rail[^{]*\{[^}]*backdrop-filter/.test(css),
'壁纸模式下 .nav-rail 自己 backdrop-filter 会把已经模糊过的壁纸再糊一次(元素级 backdrop-blur-* 工具类不在本判据范围内)'
);
check('内层面板自带圆角(不靠裁剪,否则与滚动冲突)',
/\.comm-pane > \*:not\(\[data-testid='comm-tabs'\]\) \{[\s\S]{0,80}border-radius: var\(--radius-card\)/.test(css));
/*
* ★ 浮动面板几何必须**无条件**生效(2026-09-14 用户:「通信页面大面积缺失圆角与
* 玻璃效果,所有有内容与无内容区域都是硬截断」)。
*
* 根因:这些规则原先全写在 `html[data-bg='on']` 里 ⇒ 没开壁纸的账号看到硬边面板。
* 所以判据不能再带 data-bg 前缀 —— 带前缀等于把缺陷写进判据。
*/
check('外壳留缝与圆角是无条件的(不只壁纸模式)',
/(?<!data-bg='on'\] )\.app-shell \{[\s\S]{0,80}padding: var\(--pane-gap\)/.test(css) &&
/(?<!data-bg='on'\] )\.app-shell > \* \{[\s\S]{0,160}border-radius: var\(--radius-card\)/.test(css));
check('窄屏内容面板也浮起来(圆角)',
/\.narrow-shell > \*:not\(\.narrow-nav\) \{[\s\S]{0,80}border-radius: var\(--radius-card\)/.test(css));
// 判据用 `[^}]*`(限定在 `.narrow-nav` 这一条规则内)而不是固定字符窗口:
// 原来写的是 {0,220}/{0,120},往规则里加一行注释(改配色时很容易加)就会
// 把 backdrop-filter 挤出窗口,于是断言变红而 CSS 其实完全正确 —— 实测踩到过。
// 要表达的语义是「这条规则里有这两个声明」,不是「它们在 N 个字符以内」。
check('窄屏底部导航是悬浮玻璃(留缝 + 模糊)',
/\.narrow-nav \{[^}]*backdrop-filter: blur\(/.test(css) &&
/\.narrow-nav \{[^}]*margin: 0 10px/.test(css));
/*
* ★ 玻璃是**白色材料**(2026-09-14 用户纠正我:「什么叫深色模式也是玻璃?
* 深色模式不应该是浅色玻璃吗?」)。
*
* 我原先把深色模式下的玻璃写成 `rgb(15 23 42 / .72)`(一整层深色)—— 那不是玻璃,
* 是把面板压黑;再加上 `.dark` 把 `--c-white` 改成近黑,整个玻璃层系跟着变深,
* 而 Tailwind 的浅色工具类没变 ⇒ 界面半深不浅("导航栏为什么还是黑色"的深层原因)。
*
* 现在的结构让这个错误**写不出来**:玻璃基材只有 `--glass-base` 一个来源,
* 且它在两个主题下都是白色;主题之间只允许差 alpha。
*/
const glassBase = (css.match(/--glass-base:\s*(\d+ \d+ \d+);/g) || []).map(m => m.match(/(\d+ \d+ \d+)/)[1]);
check('玻璃基材只有一处定义且是白色', glassBase.length === 1 && glassBase[0] === '255 255 255', `值=${glassBase.join('|')}`);
check(
'没有玻璃面再用 --c-white(它与深色主题冲突)',
!/rgb\(var\(--c-white\) \/ /.test(css),
'玻璃面必须走 --glass-base'
);
check(
'深色主题里没有把玻璃换成深色层',
!/\.dark \{[\s\S]{0,400}--glass-base:\s*(?!255 255 255)/.test(css),
'深色主题只允许降 alpha,不许换基材'
);
check('★ 判据自检:写成深色层必须判红', /rgb\(15 23 42 \/ \.7/.test('background: rgb(15 23 42 / .72)'));
/*
* ★ 上面这一组判据原先写在 `process.exit()` **之后**(并发写入时落到了文件末尾),
* 于是四条判据一条都不会执行:不报错、不计入通过数、也不计入失败数 —— 静默失效。
* 这类"判据自己不会跑"的问题比判据写错更难发现,因为输出看起来一切正常。
* 汇总与退出必须留在**文件最后**(下面这两行就是)。
*/
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);