Files
MailUI4Agents/client/electron/test/narrow-layout.test.mjs
JianFeeeee 6702cc2f5e fix(webui): 窄屏日历/详情的圆角——圆角要给到「能裁剪的那一层」
用户(2026-09-14):「我这边看还是方角」。上一轮我只修了宽屏那条分支,
窄屏(手机/平板,<1024px)当时仍然是方角 —— 这次按像素量到了。

## 实测(真浏览器读渲染像素,不是读声明)

390x844 日历页左上角:
  改前 (255,255,255) 纯白 → 方角;面板 computed radius = 0px
  改后 (249,250,251) 页面底色 → 圆角;上边缘内缩 8px

## 根因:圆角落在**裁不住东西**的那层上

窄屏走 NarrowStack(`list`+`main` 的覆盖式容器,日历与收件箱详情都用它):

    .narrow-shell > *          ← 14px 加在这里(App.tsx 的 flex-1 min-h-0 flex)
      NarrowStack 根            ← overflow:hidden,但 radius 0px
        absolute inset-0 flex   ← 透明
          gridPane (bg-white)   ← 0px,直角原样露出来

`overflow: visible` 的透明 flex 容器拿到圆角 = 什么也没发生。
NarrowStack 的根节点是**唯一**同时具备「自己 overflow:hidden」与「包住有底色那一层」
的层,圆角必须加在它上面 —— 加给它,底层页与滑入的覆盖层就一起被裁到圆角里。

顺带修好一处同类问题:**收件箱详情在窄屏也是方角**(同一个容器),
以前没人报过,这次一起圆了(实测同上)。

## 判据

narrow-layout 新增 2 条,分开钉两件事(分开一条都不算修好):
  ① 那层自己带上面板圆角(类名与 CSS 规则接得上);
  ② 那一层同时具备裁剪能力(overflow:hidden)。
三组变异全部红在对的那条上(去掉类名 / 去掉 border-radius / 去掉 overflow-hidden)。

narrow-verify(手动探针)新增**读像素**的能力与两条判据 —— 这件事只有像素能回答:
computed radius 是 14px、角上 2px 处"谁在画"也指着面板本身,**但渲染出来仍是方的**
(圆角加在没底色的层上,被内层直角原地盖掉)。所以这里截 1x1 再解 PNG,
只用 zlib,不引图像库。方角=255,255,255 / 圆角=249,250,251,两个数字就能分辨。
2026-09-14 16:51:31 +08:00

362 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 窄屏布局的结构性回归测试。
//
// 不做视觉快照:那需要 headless 浏览器,且像素级比对在字体差异下极脆。
// 这里守住几条真正会坏掉的不变量。
import { 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 lg1024px启用。
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\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
);
// 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'
);
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('窄屏布局');