Files
MailUI4Agents/client/electron/test/narrow-layout.test.mjs
JianFeeeee 9aa702b8cc fix(webui): 导航改"按主题的玻璃"(浅色白玻璃+深字)+ 去掉整屏闪的入场动画
用户两句话:
  「那你为什么不把白字换成黑字或者自动反色或者描边呢?」
  「部分动画十分不合理,会导致页面大范围的闪动,且不能让人自然的把注意力集中在
    将要出现的页面上」

## ① 导航:他说得对,我之前是用错误的方式解决对比度

这个应用是**浅色底 + 深字**,导航却是全页唯一一块黑的 —— 那才是"割裂"。我上一轮
为了"白字对比度"把它做成深玻璃,等于**把一块地方永久压黑**来回避问题。

现在导航底色/文字全部走令牌,按主题切换:

    浅色:白玻璃 rgb(255 255 255 / .72) + 深字 #475569   → 对比度 7.48:1
    深色:深玻璃 rgb(15 23 42 / .72)   + 亮字 #94a3b8   → 自动反色

组件侧改成 `.nav-rail` / `.nav-item[data-active]` 令牌类(Sidebar 与 NarrowNav 同一套)。
同时删掉壁纸模式里写死的深玻璃规则 —— 那条正是"为什么还是黑色"。

## ② 动画:整面板入场删掉

先是从"所有面板一起动"改成"只动主内容区",试下来仍然不对:页面级淡入会把
**已经在那儿的框架**也一起暗一下,观感还是闪。所以这一档整体删掉,只保留局部、
有明确语义的动效(日历翻页、菜单展开)。实测切视图时 `animatedOnSwitch = 0`。
骨架不动,注意力自然落在变化的那块内容上。

## ③ 一条我自己的误判(值得记下)

深色主题下我量到导航文字对比度只有 2.36,一度以为是变量/级联的问题,还写死了一份
深色字面值。**那是误判**:`.nav-item` 有 `transition: color .15s`,我在切主题后
**立刻**读 computed color,拿到的是过渡的**起点**(浅色值)。决定性证据是连内联
`style.color` 都"改不动"它 —— 级联不可能这样,只可能是还在过渡中。等 400ms 再量就正常。
写死的那份已撤掉,并在 CSS 里留下说明;新判据 `test/manual/nav-contrast-verify.mjs`
用 WCAG 比值量对比度(不是"颜色是不是黑的"),每次读之前等 400ms。

背景套件 32 条全绿(含"导航走令牌 + 两套令牌都在")。
2026-09-14 11:57:41 +08:00

239 lines
11 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) 手机与竖屏平板统一用单栏;三栏只在 Tailwind lg1024px启用。
const narrowHook = read('../src/hooks/useIsNarrow.ts');
check('JS 单栏断点与 lg 一致', narrowHook.includes('(max-width: 1023px)'));
for (const f of ['MailList', 'PermissionList', 'ContactPanel', 'CalendarView']) {
const src = read(`../src/components/${f}.tsx`);
const narrowFullWidth = src.includes('w-full');
const bareFixed = (src.match(/(?<![-\w])w-\[(\d+)px\]/g) || []).filter(m => {
const px = Number(m.match(/\d+/)?.[0] || 0);
return px >= 200 && !src.includes('lg:' + m);
});
check(
`${f} 平板单栏全宽,固定宽度仅在 lg 之后`,
narrowFullWidth && bareFixed.length === 0 && !/md:w-\[/.test(src),
bareFixed.length ? `裸固定宽度:${bareFixed.join(', ')}` : '存在 md 固定栏或缺少 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 手势条
//
// 注意查的位置2026-09-14 底部导航改成「悬浮玻璃条」后,贴底通栏的写法
// (组件里 padding-bottom换成了 index.css 里 .narrow-nav 的下外边距。
// 断言只看组件文件就恒为假 —— 这是一个**过期断言**,会让整份套件一直红着,
// 而红着的套件挡不住真回归。所以两处都认。
const nav = read('../src/components/NarrowNav.tsx');
check(
'底部导航留了安全区内边距(组件或 .narrow-nav 样式)',
nav.includes('safe-area-inset-bottom') ||
/\.narrow-nav[\s\S]{0,400}safe-area-inset-bottom/.test(read('../src/index.css'))
);
// 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*1023px/.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)
);
}
// 5.8) 写信页不能靠 flex 把正文压成一行。
// 软键盘出现时可见高度骤减:顶部字段和底部附件/按钮都是固定内容,原先唯一
// 可收缩的正文区只有 min-h-0于是会被压到接近 0。窄屏改为整页可滚正文
// 保留明确的最小高度;宽屏仍使用 flex 填满剩余空间。
check(
'写信页窄屏整页可滚,正文有明确最小高度',
compose.includes('overflow-y-auto lg:overflow-hidden') &&
compose.includes('min-h-[12rem]') &&
compose.includes('lg:flex-1')
);
check(
'写信页附件与操作栏不参与正文压缩',
compose.includes('shrink-0 px-4 md:px-6 pb-20 lg:pb-3') &&
compose.includes('sticky bottom-0')
);
check('根视口使用 100dvh 跟随软键盘', css.includes('@supports (height: 100dvh)'));
// 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}`);
}
// 7) 窄屏阅读:顶部只留标题、底部收起为悬浮球
//
// 用户2026-09-14「窄屏页面阅读邮件时顶部的邮件信息和底部的输入框等
// 占用了绝大部分页面,用户只能通过中间的一小块看邮件……上面缩为窄栏,只显示
// 邮件标题,点击展开显示完整信息,底部一个悬浮的聊天图标的球,点一下展开输入框」。
//
// 这两处坏掉的形态很隐蔽:组件照样渲染、宽屏一切正常,只是窄屏可读区被挤成一条缝。
// 所以在结构层钉住「窄屏默认收起 + 有展开出口 + 有收回归口」。
const mv = read('../src/components/MailView.tsx');
check(
'头部窄屏默认收起useState(!narrow) + 断点变化时重置)',
/useState\(!narrow\)/.test(mv) && /setOpen\(!narrow\)/.test(mv),
'缺少「窄屏默认收起」或跨断点重置 —— 自动隐藏只在首次生效'
);
check(
'头部收起态保留标题与状态点,展开才有完整信息',
mv.includes('aria-expanded={open}') &&
mv.includes('min-w-0 flex-1 truncate text-sm font-semibold') &&
mv.includes('{open && <div className="mt-1.5">{children}</div>}')
);
check(
'回复框窄屏收起为悬浮球reply-fab点开才展开',
mv.includes('data-testid="reply-fab"') &&
mv.includes('narrow && !open') &&
mv.includes('absolute bottom-4 right-4 z-20') &&
mv.includes('<ChatBubbleIcon')
);
check(
'回复框展开后有收回归口,且点开时焦点落到输入框',
mv.includes('aria-label="收起回复框"') && mv.includes('autoFocus={narrow}')
);
check(
'悬浮球与列表页新建球同一套观感w-14 h-14 rounded-full',
(mv.match(/w-14 h-14 rounded-full/g) || []).length >= 1
);
// 顶部标题行不能把返回键套进 toggle 按钮里button 嵌 button 是无效 HTML
// 且屏幕阅读器会读出两个可点区域)
check(
'返回键在 toggle 按钮之外(避免 button 嵌套)',
/\{lead\}\s*<button/.test(mv)
);
console.log(failed === 0 ? '\n窄屏布局全部通过' : `\n窄屏布局:${failed} 项失败`);
process.exit(failed === 0 ? 0 : 1);