pi 的三条增量,前两条落地: 1. **错误信息自带修法**:受众不只是读过规范的人 —— 并发写 WebUI 的 agent 新加判据时不会打开 CRITERIA.md,看到红的第一反应可能是"套件坏了"。所以把可照抄的修法写进那条错误本身 (共享 helper 的用法 + 样板文件路径),并说明 node:test 的判据不用管。 **red 是 ta 一定会看到的,文档不一定被打开。** 2. **marker 由共享 helper 打印**:新增 test/lib/checks.mjs(导出 check/finish), 计数只可能在该模块内发生 → "漏打 marker"与"计数写错位置"这两类在新文件上不可能发生。 为避免"写了没人用"(本仓踩过的坑),同时把两个手工计数的判据改用它: narrow-layout(原来只有 failed 计数)与 markdown-xss(原来根本没有计数器)—— 条数不变(52 / 9),套件仍全绿。 未回改其余 10 个文件:run-all 的 marker 检查已经覆盖它们。
231 lines
11 KiB
JavaScript
231 lines
11 KiB
JavaScript
// 窄屏布局的结构性回归测试。
|
||
//
|
||
// 不做视觉快照:那需要 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 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')
|
||
);
|
||
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):「窄屏页面阅读邮件时,顶部的邮件信息和底部的输入框等
|
||
// 占用了绝大部分页面,用户只能通过中间的一小块看邮件……上面缩为窄栏,只显示
|
||
// 邮件标题,点击展开显示完整信息,底部一个悬浮的聊天图标的球,点一下展开输入框」。
|
||
//
|
||
// 这两处坏掉的形态很隐蔽:组件照样渲染、宽屏一切正常,只是窄屏可读区被挤成一条缝。
|
||
// 所以在结构层钉住「窄屏默认收起 + 有展开出口 + 有收回归口」。
|
||
const mv = read('../src/components/MailView.tsx');
|
||
|
||
check(
|
||
'头部窄屏默认收起(useState(!narrow) + 断点变化时重置)',
|
||
/useState\(!narrow\)/.test(mv) && /setOpen\(!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),点开才展开',
|
||
mv.includes('data-testid="reply-fab"') &&
|
||
mv.includes('narrow && !open') &&
|
||
mv.includes('absolute bottom-4 right-4 z-20') &&
|
||
mv.includes('<ChatBubbleIcon')
|
||
);
|
||
check(
|
||
'回复框展开后有收回归口,且点开时焦点落到输入框',
|
||
mv.includes('aria-label="收起回复框"') && mv.includes('autoFocus={narrow}')
|
||
);
|
||
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('窄屏布局');
|