用户:「动画卡顿严重,且绝大部分场景还是没有流畅的动画」。 ① 提层:`html.view-switch .rise-in` 里加 will-change: transform, opacity。 这一档只动 opacity+transform,但没提层时浏览器不保证合成器接管。 写在 view-switch 窗口里(只在切换的 ~400ms 内有效),不是写在元素上 —— 常驻 will-change 会把每个面板都变成常驻图层,白吃内存。 ② 去掉触发动画时那次强制同步重排:App.tsx 原来用 `void root.offsetWidth` 让 remove/add 分属两帧,代价是每次切视图都逼浏览器把整个文档布局算一遍, 而这笔账正好落在动画第一帧上。改成两次 rAF,同样分两帧,不付重排的钱。 ③ 覆盖:列表栏与日历根节点也穿 rise-in ⇒ 切 通信/日历/工作列表 都有入场, 仍只动"新出现的那一块",骨架不动(避免 09-14 那次"整屏闪"的老问题)。 判据(narrow-layout +3):will-change 必须在 view-switch 规则里; App.tsx 不得再有 `void root.offsetWidth`(且必须用 requestAnimationFrame); MailList/CalendarView 必须穿 rise-in。变异:抽掉 will-change → 红; 把强制重排放回去 → 红;复原 → 77 通过 0 失败。 ★ 顺带纠错:上一轮我报"整页 146 个元素带 backdrop-filter / 动画子树 76 个全带模糊" 是我探针自己的 bug(`webkitBackdropFilter` 取到 undefined,`undefined !== 'none'` 恒真, 连 <path>/<META> 都被算进去)。修正后实测:真模糊 0 个、同时动画 1 个、 帧间隔中位/最长 17ms(60fps)。所以"卡顿"不是我能在本机复现的形态 —— 需要知道你看的是哪一端/什么状态(见回信)。
480 lines
25 KiB
JavaScript
480 lines
25 KiB
JavaScript
// 窄屏布局的结构性回归测试。
|
||
//
|
||
// 不做视觉快照:那需要 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(/(?<![-\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')
|
||
);
|
||
|
||
// 5.9) 滚动容器的边缘淡出不能按**固定像素**给。
|
||
//
|
||
// 用户(2026-09-14):「你有的地方渐变用的过猛了,比如收件人候选那里」。
|
||
// 原来的写法是上下各固定 12px(拿长列表 10px 内边距当尺子量的),可它作用在所有
|
||
// `overflow-y-auto` 上 —— 收件人候选菜单整块才 58px(提示行 22 + 候选 34),
|
||
// 上下各淡 12px 共 24px,等于把小半个菜单洗掉。
|
||
// 根因不是 12px 太大,而是**固定像素用在了一个高度不固定的东西上**。
|
||
//
|
||
// 所以这里的判据不是「等于某个值」,而是三条**结构**约束:
|
||
// 1. 淡出宽度必须带一个上限(`min(…)`)—— 否则矮容器一定被洗掉;
|
||
// 2. 上限不能为 0(那等于没有淡出,用户上一轮的「硬截断」又回来了);
|
||
// 3. 弹层(控件档的滚动区)必须豁免 —— 它自己是圆角+边框的独立表面,
|
||
// 被边框截住已经「有交代」,而它高度小到淡出只剩负作用。
|
||
const maskRule = (css.match(/\.overflow-y-auto\s*\{[\s\S]*?\n\}/) || [''])[0];
|
||
check(
|
||
'滚动边缘淡出的宽度有上限(不是写死的固定像素)',
|
||
/min\(\s*\d+px\s*,/.test(maskRule),
|
||
'淡出宽度写成了固定 px —— 矮容器(候选菜单只有 58px)会被洗掉大半'
|
||
);
|
||
/*
|
||
* 上限本身也要验。第一版只取了 `min(` 里**第一个**数字(12px)就断言 > 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` 时用的是
|
||
* `<div className="flex-1 min-h-0 flex">{main}</div>`,`.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="[^"]*(?<![-:])\bpx-6\b/g) || [];
|
||
check(`${f} 没有裸 px-6(应为 px-4 md:px-6)`, bare.length === 0, `发现 ${bare.length} 处`);
|
||
}
|
||
|
||
// 7) 阅读态:顶部只留标题、底部收起为悬浮球(**窄屏与宽屏同一套**)
|
||
//
|
||
// 用户(2026-09-14):「窄屏页面阅读邮件时,顶部的邮件信息和底部的输入框等
|
||
// 占用了绝大部分页面,用户只能通过中间的一小块看邮件……上面缩为窄栏,只显示
|
||
// 邮件标题,点击展开显示完整信息,底部一个悬浮的聊天图标的球,点一下展开输入框」。
|
||
//
|
||
// ★ 同日追加:「我发现宽屏布局也有邮件内容显示区域过小的问题,宽屏也同步
|
||
// 窄屏的折叠策略吧」。上一版是「窄屏收起 / 宽屏恒展开」—— 那条只看了**横向**,
|
||
// 而实测 1280x800 下头部 139px(17%) + 回复框 246px(31%),正文只剩 385px(48%)。
|
||
// 所以现在两种宽度**同一套默认值**,判据也改成钉「不再按窄宽分叉」。
|
||
//
|
||
// 这两处坏掉的形态很隐蔽:组件照样渲染、只是可读区被挤成一条缝。
|
||
// 所以在结构层钉住「默认收起 + 宽窄同一套 + 有展开出口 + 有收回归口」。
|
||
const mv = read('../src/components/MailView.tsx');
|
||
|
||
check(
|
||
'头部默认收起,且**不再按窄宽分叉**(旧写法 useState(!narrow) 已消失)',
|
||
/const \[open, setOpen\] = useState\(false\)/.test(mv) &&
|
||
/useEffect\(\(\) => 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 && <div className="mt-1.5">{children}</div>}')
|
||
);
|
||
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('<ChatBubbleIcon')
|
||
);
|
||
check(
|
||
'回复框展开后有收回归口(宽窄都有,不再只在窄屏渲染),点开时焦点落到输入框',
|
||
mv.includes('aria-label="收起回复框"') &&
|
||
/*
|
||
* 焦点要钉在**回复框自己的 Composer** 上。
|
||
*
|
||
* 第一版写的是 `/autoFocus(?![={])/` —— 全文件搜一个裸 `autoFocus`,
|
||
* 而 MailView 里预算输入框、编辑器还有几处 `autoFocus`,于是把回复框改回
|
||
* `autoFocus={narrow}` 时它**照样绿**(变异测试抓到的)。
|
||
* 按 §1:取那个标签自己的 `{...}`/`/>` 体再断言,不靠全文件搜关键字。
|
||
*/
|
||
(() => {
|
||
const blocks = [...mv.matchAll(/<Composer\b[\s\S]*?\/>/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*<button/.test(mv)
|
||
);
|
||
|
||
// 共享 helper 打汇总与 marker(计数在 check() 内部,见 lib/checks.mjs 的说明)
|
||
finish('窄屏布局');
|