Files
MailUI4Agents/client/electron/test/narrow-layout.test.mjs
JianFeeeee 7ef3ea7306 fix(webui): 宽屏也同步折叠策略 —— 头部与回复框默认收起(正文 48% → 90%)
用户(2026-09-14):「我发现宽屏布局也有邮件内容显示区域过小的问题,
宽屏也同步窄屏的折叠策略吧」。

上一版是「窄屏默认收起 / 宽屏恒展开」,理由是「宽屏横向空间够、不该引入多余
点击」。那条理由只看了**横向**:实测 1280x800 下详情栏里头部 139px(17%)、
回复框 246px(31%),留给正文的只剩 385px(48%)—— 竖向一样不够用。

改法:两种宽度用同一套默认值(收起),点标题行 / 悬浮球展开。
- CollapsibleHeader:useState(false),toggle 不再以 narrow 为条件,展开箭头常显;
- ReplyBar:收起态恒为悬浮球(原来只有窄屏才收),「收起」按钮宽窄都有,
  autoFocus 不再以 narrow 为条件;
- MailView 两个根节点补 relative —— 否则宽屏下 `absolute` 会一路找到视口,
  悬浮球会挂到窗口右下角而不是这块详情栏里。

实测(真浏览器量盒子,1280x800,同一封正文撑满的长信):
  头部 139 → 48px,回复框 246 → 0(收起为球),正文 385 → 722px(占视口 48% → 90%);
  点标题展开 / 点球开输入框(带焦点)/「收起」收回,三条都通。
窄屏 390x844 同项通过,横向溢出 0。

判据:narrow-layout 里三条旧判据钉的是「窄屏专属」,已改成钉「宽窄同一套」,
并新增两条(折叠分支里不得再有 `narrow &&`;悬浮球必须锚在详情栏内)。
wide-regression 补 6 条宽屏实测 —— 此前宽屏的折叠行为**一条判据都没有**,
这正是它坏掉而没人发现的原因。

已知遗留(另行报告,本次未改):窄屏上回复框展开后,第一次点它上面的按钮
(抄送 / 回复全部 / 收起)会被吞掉 —— 焦点离开 textarea 会让 .form-editing
失效、底部导航重新占位 71px,按钮在 mousedown 与 mouseup 之间从指针下移走,
click 目标退化成公共祖先。HEAD 上已存在,与本次改动无关。
2026-09-14 15:38:49 +08:00

251 lines
12 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 { readFileSync } from 'node:fs';
import { check, finish } from './lib/checks.mjs';
const read = p => readFileSync(new URL(p, import.meta.url), 'utf8');
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')
);
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="收起回复框"') &&
// 焦点:原来是 autoFocus={narrow},宽屏点开悬浮球就不会聚焦
/autoFocus(?![={])/.test(mv)
);
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('窄屏布局');