// 窄屏布局的结构性回归测试。 // // 不做视觉快照:那需要 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 里,必须各有一处)' ); /* * 「按钮长成页面」(2026-09-15 用户:「写邮件的按钮点击不应该是一个按钮扩大变成页面的 * 动画吗?」)。判据钉四件事,都是**接线**而不是数值: * ① 起点捕获存在,且在**捕获阶段**(React 的 onClick 在冒泡阶段,晚一步就取不到矩形); * ② 有 TTL —— 否则"点别的按钮 → 过一会儿程序化打开写信页"会从旧按钮长出来; * ③ morph 只动 transform/opacity/border-radius(合成器可做,不触发布局); * ④ 有起点时不挂 rise-in(两条动画都动 transform/opacity,同挂会打架), * 没起点时必须退回 rise-in(否则刷新后直接进写信页就彻底没动画)。 */ const originSrc = code('src/lib/composeOrigin.ts'); const composeSrc2 = code('src/components/ComposePage.tsx'); /* * 回复球也要 morph(2026-09-15 用户:「邮件页面那个蓝色的圆形是聊天图标点击没有任何动画」)。 * 上一版只给了它 150ms 淡入 —— 观感上等于没有;要的形态与写信页一致:球自己长成回复框。 * 判据钉:① 球带 morph 起点标记;② 起点就地取(onClick 里 currentTarget 最准); * ③ 用 useLayoutEffect(首帧前换掉,否则先闪淡入再跳);④ 让位给 morph 时把 CSS 动画关掉 * (两条动画都动 transform/opacity,同挂会打架);⑤ 只动合成器属性。 */ const mvSrc = code('src/components/MailView.tsx'); /* * 2026-09-15 用户两条窄屏反馈,各钉一条接线: * ① 「输入框弹出后,底部导航栏会消失」—— 详情/列表根节点缺 min-h-0(flex 子项默认 * min-height:auto ⇒ 压不下去),回复框一撑就把底部导航顶出可视区。导航自己有 * shrink-0,所以问题一定在**它上面那层能不能被压缩**。 * ② 「收起没有动画」—— 打开有 morph、收起瞬间消失。收起必须先反向播完再卸载。 */ const mvSrc2 = code('src/components/MailView.tsx'); /* * 2026-09-15 用户:「输入框弹出后,底部导航栏会消失,再次点击界面导航才会出现」。 * 根因是 `.form-editing .narrow-nav { display: none }` 撞上"回复框展开即聚焦输入框"。 * 判据钉住:窄屏导航不得因编辑态被隐藏(键盘由 interactive-widget=resizes-content 处理)。 */ check( '编辑态不隐藏底部导航', // 判"代码里还有没有这条规则"必须用**剥注释**版:解释性注释里会写到这条规则的原样 // (我第一版用原文判,被自己刚写的那段注释判红了 —— 与 read.mjs 里记的同一形态) !/\.form-editing\s+\.narrow-nav\s*\{[^}]*display:\s*none/.test(code('src/index.css')), '又出现"编辑态隐藏底部导航"——回复框一展开导航就消失,还得再点一下才回来' ); check( 'viewport 声明 resizes-content(键盘靠重排让位,不靠隐藏)', /interactive-widget=resizes-content/.test(prose('index.html')), 'index.html 里没有 interactive-widget=resizes-content —— 键盘会盖住底部内容,才逼着去隐藏导航' ); check( '窄屏可压缩:详情/列表根节点带 min-h-0', (mvSrc2.match(/relative flex-1 min-w-0 min-h-0 flex flex-col/g) || []).length >= 2, '根节点缺 min-h-0 —— 回复框弹出会把底部导航栏顶出可视区("点了才又出现")' ); check( '收起有反向 morph(先播完再卸载)', /const collapse = \(\) => \{/.test(mvSrc2) && /anim\.finished\.then\(\(\) => setOpen\(false\)\)/.test(mvSrc2) && /onClick=\{collapse\}/.test(mvSrc2), '收起仍是瞬间卸载 —— 打开有动画、收起没动画,不对称' ); check( '回复球带 morph 起点标记且就地取矩形', /data-morph-trigger/.test(mvSrc) && /rememberOrigin\(e\.currentTarget\)/.test(mvSrc), '蓝球没接 morph —— 点了只有一层几乎看不见的淡入' ); check( '回复框 morph 在首帧前接管(useLayoutEffect + 关掉 CSS 动画)', /useLayoutEffect\(\(\) => \{[\s\S]{0,900}?el\.style\.animation = 'none'/.test(mvSrc), '用 useEffect 或没关 CSS 动画 —— 会先闪一下淡入再跳一次' ); check( '写信页起点矩形:捕获阶段取 + 有时效', /addEventListener\(\s*'click'/.test(originSrc) && /,\s*true\s*\)/.test(originSrc) && /TTL_MS/.test(originSrc), '起点捕获要么没在捕获阶段(拿不到矩形),要么没有时效(会从不相干的旧按钮长出来)' ); check( '按钮→页面 morph 只动合成器属性', /el\.animate\(/.test(composeSrc2) && /transform:/.test(composeSrc2) && !/height:|width:\s*`/.test(composeSrc2), 'morph 动了布局属性(宽高)—— 那会逐帧重排,正是"卡顿"的来源' ); check( '有起点做 morph、没起点退回 rise-in', /fromRect \? '' : 'rise-in '/.test(composeSrc2) && /peekComposeOrigin/.test(composeSrc2), '两条动画会同时挂,或者丢了无起点时的兜底' ); 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(同步强制布局)—— 这笔记账正好落在动画第一帧上,就是卡顿的来源之一' ); /* * ★ 2026-09-15 用户:「回复邮件那个按钮还是没有动画」。我上一版给回复框/转发面板挂了 * `rise-in`,但那条规则写作 `html.view-switch .rise-in` —— 而 view-switch 窗口只由 * 视图/页签/窄屏/写信 四个状态触发,**回复/转发不触发它** ⇒ 类挂了、动画永远不播。 * 现在拆两档:`.rise-in` 挂载即播(点了才出现的面),`.pane-rise` 由窗口触发(常驻面板)。 * 判据必须钉住"这两档各自的作用域",否则同样的接线错误会再犯一次。 */ const bareRise = (css.match(/(^|\n)\.rise-in\s*\{[\s\S]*?\n\}/) || [''])[0]; check( '「点了才出现」的面必须挂载即播(.rise-in 不带 view-switch 前缀)', /animation:\s*rise-in/.test(bareRise), '回复框/转发面板的动画仍被绑在 view-switch 窗口上 —— 那两个动作根本不会触发窗口,等于没动画' ); /* * 点击卡顿(2026-09-15 用户:「主页点击按钮有明显的卡顿」)。 * 阴影是**绘制**属性:把它挂在"所有交互元素"的 transition 上,每次 hover/active 都要 * 重绘元素连同阴影区。判据钉"全局控件过渡不得含 box-shadow", * 并留出旁路(个别表面自己写 transition 仍可以动画阴影)。 */ const ctrlRule = (css.match(/button,\s*a,\s*input,\s*textarea,\s*select,\s*\[role='button'\]\s*\{[\s\S]*?\n\}/) || [''])[0]; check( '全局控件过渡不含 box-shadow(绘制属性)', ctrlRule.length > 0 && !/box-shadow/.test(ctrlRule), '全局 button/a/input 的 transition 里又出现了 box-shadow —— 每次 hover/active 都重绘阴影区,点击会卡' ); check( '日历用窗口触发的 pane-rise,**邮件列表不许挂**', /pane-rise/.test(code('src/components/CalendarView.tsx')) && !/pane-rise/.test(code('src/components/MailList.tsx')) && /html\.view-switch \.pane-rise\s*\{/.test(css), '邮件列表挂上了入场动画 —— view-switch 窗口在**选中邮件**时也会触发,' + '于是每点一封信整个列表重播一次,观感就是"邮件列表会闪"(2026-09-15 用户报的就是这个)' ); check( 'rise-in 也受 prefers-reduced-motion 管', // 必须行首就是 .rise-in(否则 `html.view-switch .rise-in,` 会把这个判断满足掉 —— // 第一版就是这么假绿的,变异测试抓到了) reducedBlocks.some(b => /(^|\n)\s*\.rise-in\s*,/.test(b)) && reducedBlocks.some(b => /view-switch \.rise-in/.test(b)) && reducedBlocks.some(b => /view-switch \.pane-rise/.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)', // 2026-09-15:这两个根节点后来又补了 min-h-0(窄屏可压缩),类名顺序变了 —— // 判据钉的是"两个根节点、都带 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*