// 窄屏布局的结构性回归测试。 // // 不做视觉快照:那需要 headless 浏览器,且像素级比对在字体差异下极脆。 // 这里守住几条真正会坏掉的不变量。 import { code, prose } from './lib/read.mjs'; import { check, finish } from './lib/checks.mjs'; const read = p => prose(new URL(p, import.meta.url)); 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|popup-surface)\s*\{[\s\S]*?mask-image:\s*none/.test(css), '候选菜单这类弹层还在淡 —— 它高度只有几十像素,首尾项会被洗白' ); /* * 候选/转发两个选择框的菜单必须是**不透明**表面(2026-09-15 用户: * 「你在选择框的模糊逻辑上用力过猛,导致只能显示一条信息」)。 * * 判据钉的是**结构**而不是某个具体颜色值: * ① 弹层类必须存在,且用 rgb(var(--c-white)) 而不是带 alpha 的 rgb(... / α); * ② 弹层规则里不允许出现 backdrop-filter(背后的表单被模糊正是那份乱); * ③ 壁纸开着时也得另有规则压回不透明(`html[data-bg='on'] .bg-white` 会抢); * ④ AddressInput 的菜单必须穿这个类,不许再穿 .glass-control。 */ const popupRule = (css.match(/\.popup-surface\s*\{[\s\S]*?\n\}/) || [''])[0]; const popupBgOn = (css.match(/html\[data-bg='on'\]\s*\.popup-surface\s*\{[\s\S]*?\n\}/) || [''])[0]; const addrSrc = code('src/components/AddressInput.tsx'); // 判「代码里穿了哪个类」→ 用剥注释版 check( '弹层表面存在且不透明', /\.popup-surface\s*\{/.test(css) && /background-color:\s*rgb\(var\(--c-white\)\)/.test(popupRule), '弹层没有独立的不透明表面 —— 半透明列表叠在表单上,下面的行会被背后控件糊掉' ); check( '弹层不取背景模糊', !/backdrop-filter:\s*blur/.test(popupRule), '弹层又开了 background blur —— 背后的控件被糊住后和候选行叠在一起' ); check( '壁纸开时弹层也不透明', /background-color:\s*rgb\(var\(--c-white\)\)/.test(popupBgOn) && !/backdrop-filter:\s*blur/.test(popupBgOn), "`html[data-bg='on'] .bg-white` 会把弹层拉回半透明那档(0.88),必须单独压回去" ); check( '候选菜单穿的是弹层类', /absolute[^`]*popup-surface/.test(addrSrc) && !/absolute[^`]*glass-control/.test(addrSrc), 'AddressInput 的菜单还穿着控件档(.glass-control) —— 那档是给贴着面板的小控件用的' ); /* * 局部入场动画(2026-09-15 用户:「从发信按钮到发信页面,从聊天按钮到聊天输入, * 以及转发拉起输入框都没有对应的动画,所有的动画都消失了」)。 * * 9aa702b 把 `html.view-switch .app-shell > *, .narrow-shell > *` 整条删掉、 * 换成 `.pane-enter`,而 .pane-enter **没有任何组件在穿** ⇒ 这一档成了死规则。 * * 判据钉三件事:① 局部入场类存在且有独立的 keyframes;② 三处目标面真的穿了 * 它(发信页 / 回复框 / 转发面板,去掉任一处必红);③ `prefers-reduced-motion` * 里必须也有它 —— 否则"关动画就一点都不动"这句承诺对新增的这档不成立。 */ const composeSrc = code('src/components/ComposePage.tsx'); const mailViewSrc = code('src/components/MailView.tsx'); // 文件里有**多个** reduced-motion 块(还有一个全局的把 animation-duration 压到 0.01ms), // 所以必须逐块看,不能只取第一个 —— 第一版就取错了块,判据自己假红。 const reducedBlocks = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\n\}/g) || []; check( '局部入场动画存在(rise-in)', /@keyframes rise-in\s*\{/.test(css) && /html\.view-switch \.rise-in\s*\{/.test(css), '局部入场动画不见了 —— 写信页/回复框/转发面板会"啪"地出现' ); check( '发信页、回复框、转发面板三处都穿 rise-in', /rise-in/.test(composeSrc) && (mailViewSrc.match(/rise-in/g) || []).length >= 2, '三处目标面没都穿上 rise-in(回复框与转发面板在 MailView 里,必须各有一处)' ); check( '动画走合成器(view-switch 窗口内提层)', /html\.view-switch \.rise-in\s*\{[\s\S]*?will-change:\s*transform,\s*opacity/.test(css), '缺少 will-change —— 没提层时 opacity/transform 仍可能在主线程上抖' ); const appMotionSrc = code('src/App.tsx'); check( '切视图不再用强制重排触发动画', !/void\s+root\.offsetWidth/.test(appMotionSrc) && /requestAnimationFrame/.test(appMotionSrc), '触发动画还在读 offsetWidth(同步强制布局)—— 这笔记账正好落在动画第一帧上,就是卡顿的来源之一' ); check( '主要视图也穿 rise-in(列表/日历)', /rise-in/.test(code('src/components/MailList.tsx')) && /rise-in/.test(code('src/components/CalendarView.tsx')), '切到列表/日历时没有入场动画 —— "绝大部分场景还是没有流畅的动画"就是这一条' ); check( 'rise-in 也受 prefers-reduced-motion 管', reducedBlocks.some(b => /view-switch \.rise-in/.test(b)), 'reduced-motion 块里漏了 rise-in —— 关掉动画的人仍会被它位移' ); check( '淡出规则同时带 -webkit- 前缀(Electron 里以哪个为准不由我们定)', (maskRule.match(/-webkit-mask-image:/g) || []).length === 1 && (maskRule.match(/[^-]mask-image:/g) || []).length === 1 ); // 5.10) 面板圆角必须给到**有底色的那一层**。 // // 用户(2026-09-14):「日历页面还没有添加圆角」。 // // 这一族的形态最骗人:`getComputedStyle` 里 `border-radius` **就是 14px** // (外壳那条 `.app-shell > *` 确实加上了),可**看上去是方角** —— // 因为外壳节点是 `flex-1 min-w-0 flex min-h-0`、**自己没有底色**, // 真正白底的是里面那两块面板,它们的直角从透明外壳里戳出来。 // 所以判据不能只查「有没有 border-radius」,要钉住: // ① 规则与 JSX 是否真接上了(两张表按**类名**联接,不按相邻关系); // ② 给的是**哪几条边**; // ③ 中间那层容器自己不上色时,圆角得**再往下给一层**。 const cal = read('../src/components/CalendarView.tsx'); const calFirst = (css.match(/\.cal-panes\s*>\s*:first-child\s*\{[\s\S]*?\n\}/) || [''])[0]; const calLast = (css.match(/\.cal-panes\s*>\s*:last-child[\s\S]*?\n\}/) || [''])[0]; check( '日历根节点挂上了面板栈标记(CSS 选择器与 JSX 类名接得上)', calFirst !== '' && /className="cal-panes[ "]/.test(cal), 'CSS 里有规则但 JSX 没挂类名(或反之)—— 两边各自看都“有”,圆角却不生效' ); check( '日历外沿两角分两侧给:首元素给起始两角、末元素给结束两角', /border-start-start-radius/.test(calFirst) && /border-end-start-radius/.test(calFirst) && /border-start-end-radius/.test(calLast) && /border-end-end-radius/.test(calLast), '这两块面板中间是**分隔线**不是外壳间距:四角全给会在分隔线两边露出底色缺口' ); check( '右栏容器里面的面板也拿到圆角(否则内层直角把圆角原地盖掉)', /\.cal-panes\s*>\s*:last-child\s*>\s*\*/.test(css), '右栏外面那层只管宽度与分隔线、自己不上色 —— 只给这层加圆角=无效果' ); check( '左栏规则直接落在面板本身(不再往下给)', calFirst !== '' && !/\.cal-panes\s*>\s*:first-child\s*>\s*\*/.test(css), '左栏本身就是白底面板;再往下就给到了第一个子元素(工具条),圆到不该圆的东西上' ); // 5.11) 窄屏「页面覆盖」容器(NarrowStack)自己必须带圆角,而且它得是**能裁剪**的那一层。 // // 用户(2026-09-14):「我这边看还是方角」—— 上一轮我只修了宽屏那条分支。 // 实测 390x844:日历页左上角像素是**纯白 255,255,255**(方角),面板 radius 0px; // 而同页面的 `.narrow-stack` 祖先拿到的 radius 恰好是 14px。 // // 因为 `.narrow-shell > *` 那条圆角落在 NarrowStack **外面**那层透明 flex 容器上 // (App.tsx 的 `flex-1 min-h-0 flex`),它 `overflow: visible`,裁不住任何东西; // 真正包住白底页面、又自带 `overflow: hidden` 的是 NarrowStack 的根节点。 // 所以这两件事必须同时成立,分开一条都不算修好 —— 所以分开钉。 check( '窄屏覆盖容器(NarrowStack 根)自己带上面板圆角', /className="narrow-stack[ "]/.test(stack) && /\.narrow-stack\s*\{[^}]*border-radius:\s*var\(--radius-card\)/.test(css), '圆角只加在外面那层透明 flex 容器上 —— 它裁不住东西,白底直角照样露出来' ); check( '那一层同时具备裁剪能力(overflow:hidden)', /className="narrow-stack[^"]*overflow-hidden/.test(stack), '圆角落在了裁不住东西的层上:观感仍是方角(computed radius 却是 14px)' ); /* * 5.12) 窄屏「单页」容器(没有列表栏的页面:我的 / 管理 / 写信) * * 用户(2026-09-14):「webui的『我的』页面,还是没有圆角」。 * * 与 5.11 同一个根因、**另一条分支**:App.tsx 在 `!hasList` 时用的是 * `
{main}
`,`.narrow-shell > *` 那条圆角 * 落在它身上(透明、overflow: visible),而真正有底色的是里面的页面根节点。 * 实测 390×844「我的」页:面板 radius **0px**,四角都是白像素(255,255,255), * 而页面底色是 249,250,251。 * * 修法照 `cal-panes`:**圆角给到有底色的那一层**(`> *`),不写 overflow:hidden * (这一层下面的子元素自己就是滚动容器,见 app-shell 那段)。 */ const appSrc = read('../src/App.tsx'); const solo = (css.match(/\.narrow-solo\s*>\s*\*\s*\{[\s\S]*?\n\}/) || [''])[0]; check( '窄屏单页容器挂上了标记,且圆角给到**有底色的那一层**', /className="narrow-solo[ "]/.test(appSrc) && /border-radius:\s*var\(--radius-card\)/.test(solo), 'CSS 有规则但 JSX 没挂类名(或反之);或圆角又落在了透明外壳上——两种都是"computed 正常、看着还是方角"' ); check( '那一层不写 overflow:hidden(它的子元素自己就是滚动容器)', solo !== '' && !/overflow:\s*hidden/.test(solo), '在这一层裁会杀掉滚动(与 app-shell 那次同一个坑)' ); const duoFirst = (css.match(/\.narrow-duo\s*>\s*:first-child\s*\{[\s\S]*?\n\}/) || [''])[0]; const duoLast = (css.match(/\.narrow-duo\s*>\s*:last-child\s*\{[\s\S]*?\n\}/) || [''])[0]; check( '横屏紧凑两栏:外沿分左右给角(内侧不圆,否则分隔线两边露底色)', /className="narrow-duo[ "]/.test(appSrc) && /border-start-start-radius/.test(duoFirst) && /border-start-end-radius/.test(duoLast), '两栏并排是"一张卡两块面板",四角全给或全不给都不对' ); 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*