// 窄屏布局的结构性回归测试。 // // 不做视觉快照:那需要 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 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('{children}}') ); check( '回复框窄屏收起为悬浮球(reply-fab),点开才展开', mv.includes('data-testid="reply-fab"') && mv.includes('narrow && !open') && mv.includes('absolute bottom-4 right-4 z-20') && mv.includes('= 1 ); // 顶部标题行不能把返回键套进 toggle 按钮里(button 嵌 button 是无效 HTML, // 且屏幕阅读器会读出两个可点区域) check( '返回键在 toggle 按钮之外(避免 button 嵌套)', /\{lead\}\s*