// 窄屏布局的结构性回归测试。 // // 不做视觉快照:那需要 headless 浏览器,且像素级比对在字体差异下极脆。 // 这里守住几条真正会坏掉的不变量。 import { readFileSync } from 'node:fs'; import { check, finish } from './lib/checks.mjs'; const read = p => readFileSync(new URL(p, import.meta.url), 'utf8'); 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 lg(1024px)启用。 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(/(? { 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(' 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(' 0 —— * 把 10% 改成 0% 时它照样绿(变异 B 抓到的):两个上限都生效才是真的收敛。 */ const cap = maskRule.match(/min\(\s*(\d+)px\s*,\s*(\d+)%\s*\)/); const [capPx, capPct] = cap ? [Number(cap[1]), Number(cap[2])] : [0, 0]; check( '上限是「像素与百分比取小」(高列表不变、矮容器收敛),且两个上限都 > 0', capPx > 0 && capPct > 0, `上限取到了 px=${capPx} / %=${capPct} —— 0 等于没收敛(矮容器仍被洗)或没淡出(硬截断回来了)` ); check( '弹层(控件档滚动区)不做边缘淡出', /\.overflow-y-auto\.glass-control\s*\{[\s\S]*?mask-image:\s*none/.test(css), '候选菜单这类弹层还在淡 —— 它高度只有几十像素,首尾项会被洗白' ); check( '淡出规则同时带 -webkit- 前缀(Electron 里以哪个为准不由我们定)', (maskRule.match(/-webkit-mask-image:/g) || []).length === 1 && (maskRule.match(/[^-]mask-image:/g) || []).length === 1 ); 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="[^"]*(? setOpen\(false\), \[narrow\]\)/.test(mv) && !/useState\(!narrow\)/.test(mv), '头部还在按窄/宽分叉默认值 —— 宽屏竖向空间一样不够' ); check( '头部收起态保留标题与状态点,展开才有完整信息', mv.includes('aria-expanded={open}') && mv.includes('min-w-0 flex-1 truncate text-sm font-semibold') && mv.includes('{open &&
{children}
}') ); check( '回复框默认收起为悬浮球(reply-fab),框自身不带 narrow 条件', mv.includes('data-testid="reply-fab"') && /if \(!open\) \{/.test(mv) && !/narrow && !open/.test(mv) && mv.includes('absolute bottom-4 right-4 z-20') && mv.includes('` 体再断言,不靠全文件搜关键字。 */ (() => { const blocks = [...mv.matchAll(//g)].map(m => m[0]); const reply = blocks.find(b => b.includes('density="roomy"')); return !!reply && /\bautoFocus\b/.test(reply) && !/autoFocus=\{/.test(reply); })() ); check( '折叠控件里没有一处只在窄屏生效(narrow && … 的折叠分支已清空)', !/narrow &&/.test(mv), 'MailView 里仍有 narrow && 的折叠分支 —— 宽屏会退化成“点不动”' ); check( '回复悬浮球锁定在**详情栏**内(两个根节点带 relative)', (mv.match(/className="relative flex-1 min-w-0 flex flex-col bg-/g) || []).length === 2, '少了 relative:宽屏下 absolute 会一路找到视口,球挂到窗口右下角而不是详情栏' ); 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*