Files
MailUI4Agents/web/test/narrow-layout.test.mjs
JianFeeeee d74f356f41 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 项全过。
2026-09-04 06:30:26 +08:00

172 lines
8.1 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.

// 窄屏布局的结构性回归测试。
//
// 不做视觉快照:那需要 headless 浏览器,且像素级比对在字体差异下极脆。
// 这里守住几条真正会坏掉的不变量。
import { readFileSync } from 'node:fs';
const read = p => readFileSync(new URL(p, import.meta.url), 'utf8');
let failed = 0;
const check = (name, cond, detail = '') => {
if (cond) {
console.log(` 通过 ${name}`);
} else {
console.error(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
failed++;
}
};
console.log('窄屏布局回归:');
// 1) 覆盖式而非分栏NarrowStack 必须同时挂载 base 与 overlay
const stack = read('../src/components/NarrowStack.tsx');
check(
'覆盖层与底层同时在 DOM 里(底层不卸载,滚动位置与选中态才能保留)',
stack.includes('{base}') && stack.includes('{overlay}') && stack.includes('absolute inset-0')
);
check(
'关闭时延迟卸载,退出动画才有东西可播',
/setTimeout\(/.test(stack) && stack.includes('setMounted(false)')
);
check(
'入场用双层 rAF避免与挂载合帧导致 transition 不触发',
(stack.match(/requestAnimationFrame/g) || []).length >= 2
);
check(
'尊重 prefers-reduced-motion',
stack.includes('motion-reduce:transition-none')
);
// 2) 固定宽度的中间栏在窄屏必须让位。
// 断言的是「w-full + md: 前缀的固定宽度」这个形态,不是某个具体像素值 ——
// ContactPanel 的卡片视图用 400px列表视图用 320px。
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 屏上挤掉详情。
// 只看 >=200px 的min-w-[16px] 之类的徽标尺寸与布局无关
// (前置 (?<![-\w]) 排除 min-w- / max-w-,它们是约束不是宽度)。
const bareFixed = (src.match(/(?<![-\w])w-\[(\d+)px\]/g) || []).filter(m => {
const px = Number(m.match(/\d+/)[0]);
return px >= 200 && !src.includes('md:' + m);
});
check(
`${f} 中间栏窄屏全宽,固定宽度仅在 md: 之后`,
narrowFullWidth && bareFixed.length === 0,
bareFixed.length ? `裸固定宽度:${bareFixed.join(', ')}` : '缺少 w-full'
);
}
// 3) 详情页必须有返回出口,否则窄屏进去就出不来
const view = read('../src/components/MailView.tsx');
check('邮件详情有返回按钮', view.includes('<BackButton'));
const compose = read('../src/components/ComposePage.tsx');
check('写信页有返回出口', compose.includes('cancelCompose') && compose.includes('NarrowOnly'));
// 4) 窄屏专属控件不能只靠 CSS 隐藏 —— 那样宽屏 Tab 会聚焦到看不见的按钮。
// 注释里提到 md:hidden 是在解释「为什么不用它」,所以先剥掉注释再查。
const stripComments = src =>
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
for (const f of ['BackButton', 'NarrowOnly']) {
const src = read(`../src/components/${f}.tsx`);
const code = stripComments(src);
check(
`${f} 用 useIsNarrow 条件渲染而非 md:hidden`,
src.includes('useIsNarrow') && /\bnull\b/.test(code) && !code.includes('md:hidden')
);
}
// 5) 底部导航要避开 iPhone 手势条
const nav = read('../src/components/NarrowNav.tsx');
check('底部导航留了安全区内边距', nav.includes('safe-area-inset-bottom'));
// 5.1) 抽屉式侧栏已删。
// 它装的六项与底部导航完全重复,唯一独有的是退出登录;代价是 z-50 的
// fixed 层铺满视口高度,把底部导航最左那一项盖住点不到
// (实测 elementFromPoint 命中抽屉里的 SVG
const app = read('../src/App.tsx');
check(
'窄屏没有抽屉式侧栏(它曾遮挡底部导航)',
!app.includes('navOpen') && !app.includes('bg-black/40')
);
const ui = read('../src/stores/uiStore.ts');
check('uiStore 不再有抽屉状态', !ui.includes('navOpen') && !ui.includes('toggleNav'));
// 5.2) 退出登录必须还有地方可点 —— 删抽屉时它是唯一的独有入口
const account = read('../src/components/AccountPage.tsx');
check(
'退出登录已移到账号页(窄屏唯一出口)',
account.includes('logout') && account.includes('退出登录')
);
// 5.3) 触摸命中区44x44 是移动端下限,而这些按钮视觉高度只有 15-24px。
// .tap 用居中的透明伪元素扩大命中区,视觉尺寸不变。
const css = read('../src/index.css');
check(
'.tap 提供 44px 触摸命中区且只在窄屏生效',
/\.tap::after/.test(css) && css.includes('min-width: 44px') &&
css.includes('min-height: 44px') && /max-width:\s*767px/.test(css)
);
// 详情页那排工具按钮是实测最小的一组(「抄送」只有 20x15
const viewSrc = read('../src/components/MailView.tsx');
for (const label of ['标记已读', '对话树', '转发']) {
const re = new RegExp('className="tap[^"]*"[^>]*>[\\s\\S]{0,120}' + label);
check(`详情页「${label}」有 .tap 命中区`, re.test(viewSrc));
}
// 5.4) 悬停才显形的次要动作在触摸设备上必须默认可见。
// `opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远透明,
// 却仍然接收点击 —— 一个看不见却按得动的「归档」比没有按钮更糟。
check(
'.reveal 只在支持悬停的设备上隐藏',
css.includes('.reveal') && /@media\s*\(hover:\s*hover\)\s*and\s*\(pointer:\s*fine\)/.test(css)
);
for (const f of ['ContactPanel', 'WorkCard']) {
const src = read(`../src/components/${f}.tsx`);
check(
`${f} 用 .reveal 而非裸 opacity-0 group-hover`,
src.includes('reveal') && !src.includes('opacity-0 group-hover:opacity-100')
);
}
// 5.5) 对话树:缩进随屏宽变,且窄屏要有返回出口。
// 固定「每级 20px、上限 8 级」在 320px 屏上把卡片压到 110px 可用宽度。
const thread = read('../src/components/ThreadView.tsx');
check('对话树缩进随屏宽自适应', thread.includes('useIsNarrow') && /narrow \? 10 : 20/.test(thread));
check('对话树窄屏有返回出口', thread.includes('<BackButton'));
// 5.6) 每个页面级组件都要有纵向滚动容器。
// 窄屏外壳是 `h-full flex flex-col overflow-hidden`,页面本身是
// `flex-1 min-w-0 flex flex-col` —— 内容超过视口时**没有任何办法滚到**
// 超出那段直接被裁。AccountPage 曾经就缺这个390px 下内容需 860px、
// 容器 795px「退出登录」按钮连同下面 65px 一起消失。
// 判据是「存在 overflow-y-auto」不是「当前正在滚动」——
// 内容暂时不够高时后者为假,但页面是健康的。
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'));
}
// 5.7) 居中的单卡片页(登录 / 初始化)在矮屏必须能滚到底。
// `items-center` 在内容超高时让卡片上下同时溢出,而溢出到顶部那段
// 滚不到scrollTop 最小是 0—— 实测 568x280 下「登录」按钮完全在
// 视口外。改用卡片自己的 my-auto空间不足时 auto margin 退化为 0。
for (const f of ['LoginPage', 'SetupPage']) {
const src = read(`../src/components/${f}.tsx`);
check(
`${f} 矮屏可滚且不用 items-center 居中`,
src.includes('overflow-y-auto') && src.includes('my-auto') &&
!/h-full[^"]*items-center/.test(src)
);
}
// 6) 横向内边距在窄屏收窄px-6 在 375px 屏上白吃 48px
const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage'];
for (const f of wide) {
const src = read(`../src/components/${f}.tsx`);
const bare = src.match(/className="[^"]*(?<![-:])\bpx-6\b/g) || [];
check(`${f} 没有裸 px-6应为 px-4 md:px-6`, bare.length === 0, `发现 ${bare.length}`);
}
console.log(failed === 0 ? '\n窄屏布局全部通过' : `\n窄屏布局:${failed} 项失败`);
process.exit(failed === 0 ? 0 : 1);