用 playwright 连本机共享 Chromium,在 390px(iPhone 14 Pro)与 320px (iPhone SE)量真实盒子。之前的窄屏适配是「照着规则写对」,实测发现 一个功能性 bug 加五处可用性问题。 ## 抽屉式侧栏遮挡底部导航(真 bug) 抽屉是 `fixed left-0 top-0 bottom-0 z-50`,铺满整个视口高度;底部导航没有 z-index。抽屉打开时点最左那一项「收件」,elementFromPoint 命中的是抽屉里的 SVG,不是导航按钮 —— 按钮在那里、尺寸也够、CSS 规则也没写错,只有命中测试 才能发现。 **删掉抽屉而不是给导航加 z-index**:抽屉装的六项(收件/发件/联系/用户/新建/ 管理)与底部导航完全重复,唯一独有的是退出登录。为一个按钮维护一套 fixed 层级加遮罩不划算,而它还附带「Esc 关不掉」「底层未锁滚」两个毛病。 退出登录移到「我的」页 —— 它与密码、密钥同属「账号自身」,而那页此前根本 没有退出入口。`NavToggle` 与 uiStore 的 navOpen/toggleNav/closeNav 一并删除。 ## 触摸命中区:新增 .tap 详情页那排工具按钮视觉高度只有 15-16px(实测「标记已读」48x16、「对话树」 54x16、「转发」42x16、「抄送」20x15),移动端下限是 44x44。 直接加 padding 会把本来就挤的头部撑散、320px 下换行,因此用居中的透明伪元素 扩大命中区:**视觉一像素不动**。只在 max-width:767px 生效 —— 桌面用鼠标精度 足够,而扩大后的命中区在密排工具栏里会互相重叠,点一个可能命中隔壁那个。 覆盖 MailView / ContactPanel / WorkCard / ModelScopePanel / AdminUsersPage / ComposePage / ThreadView / KeyPanel / QuotaPanel / Attachments / BackButton。 ## 看不见却按得动的按钮:新增 .reveal `opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远是 opacity:0, **但仍然接收点击** —— 实测联系人列表里 elementFromPoint 命中的就是那个看不见的 「归档」。一个看不见却按得动的破坏性按钮比没有按钮更糟:人以为点的是卡片, 实际归档了一条会话。 改为默认可见,只在 `(hover: hover) and (pointer: fine)` 时隐藏。 单看 hover 会把带触摸板的平板算进去。 ## 对话树 - 缩进随屏宽自适应:固定「每级 20px、上限 8 级」= 最多 160px,320px 屏还要 去掉 px-4 的 32px 与连接线 18px,卡片只剩 110px,发件人一行直接被 truncate 吃掉。窄屏改为每级 10px、上限 5 级 - 补返回出口:原先只有「关闭」。两者语义不同 —— 返回退出整个详情栏回到列表, 关闭只收起树、留在这封邮件上 ## 把实测脚本留进仓库 `web/test/manual/`(`npm run test:narrow` / `test:wide`),不进 npm test —— 要一个跑着的浏览器加一个活的 Gateway。 留着而不是用完即删,是因为结构性断言守不住「按钮实际多大、点下去命中谁」, 而这次最严重的 bug 恰好只有 elementFromPoint 能发现。helper 里两个函数专门 为此:tapTargets() 量 .tap 的真实命中区(伪元素尺寸,不是 boundingBox), hitTest() 验每个元素点下去是否命中自己。 ## 验证 - 窄屏 13 项 + 宽屏 5 项全通过。宽屏回归特意验了两件只该在窄屏生效的事: .tap 伪元素 content 为 none、没有返回按钮 - narrow-layout.test.mjs 从 20 条扩到 28 条,逐条钉住上面每个修复 - 无横向溢出:390px 与 320px 下 scrollWidth === clientWidth - 生产已部署
150 lines
6.0 KiB
JavaScript
150 lines
6.0 KiB
JavaScript
/**
|
||
* 窄屏实测验收:390px 与 320px 下量真实盒子、真实命中。
|
||
*
|
||
* 每一条都对应一个曾经真实存在的问题(见 docs/PLAN.md §7.10.1):
|
||
* 抽屉盖住底部导航、工具按钮只有 16px 高、看不见却按得动的「归档」、
|
||
* 对话树缩进把卡片压成竖条、对话树没有返回出口。
|
||
*
|
||
* 用法:ADMIN_PW=<密码> node web/test/manual/narrow-verify.mjs
|
||
*/
|
||
import { openApp, overflowX, tapTargets, hitTest, SMALL } from './narrow-probe-helper.mjs';
|
||
|
||
const { browser, page, issues } = await openApp();
|
||
const failed = [];
|
||
|
||
async function check(name, fn) {
|
||
try {
|
||
const r = await fn();
|
||
console.log(` ${r.ok ? '通过' : '失败'} ${name}${r.note ? ' — ' + r.note : ''}`);
|
||
if (!r.ok) failed.push(name);
|
||
} catch (e) {
|
||
console.log(` 错误 ${name} — ${e.message.slice(0, 90)}`);
|
||
failed.push(name);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 打开收件箱里的第一封邮件。
|
||
*
|
||
* 邮件行是 `<button class="w-full text-left ...">`,不是带 cursor-pointer 的 div
|
||
* —— 按后者找会一直等到超时。
|
||
*/
|
||
async function openFirstMail(page) {
|
||
await page.click('nav button:has-text("收件")');
|
||
await page.waitForTimeout(1000);
|
||
const rows = page.locator('button.w-full.text-left');
|
||
const n = await rows.count();
|
||
if (n === 0) throw new Error('收件箱是空的,没有邮件可点');
|
||
await rows.first().click();
|
||
await page.waitForTimeout(1200);
|
||
}
|
||
|
||
console.log('窄屏实测(390px):');
|
||
|
||
// 抽屉已删。它是 fixed z-50 铺满视口高度,把底部导航最左那项盖住点不到。
|
||
await check('抽屉入口已移除', async () => {
|
||
const n = await page.locator('button[aria-label="打开导航"]').count();
|
||
return { ok: n === 0, note: n ? `仍有 ${n} 个` : '' };
|
||
});
|
||
|
||
// 删抽屉时退出登录是它唯一的独有入口,必须有新去处
|
||
await check('「我的」页有退出登录', async () => {
|
||
await page.click('nav button:has-text("我的")');
|
||
await page.waitForTimeout(1200);
|
||
const btn = page.locator('button:has-text("退出登录")');
|
||
const n = await btn.count();
|
||
const b = n ? await btn.first().boundingBox() : null;
|
||
return {
|
||
ok: n === 1 && b.height >= 36,
|
||
note: b ? `${Math.round(b.width)}x${Math.round(b.height)}` : '找不到'
|
||
};
|
||
});
|
||
|
||
// 核心回归:底部导航每一项都要命中自己
|
||
await check('底部导航每项都命中自己', async () => {
|
||
const r = await hitTest(page, 'nav button');
|
||
const miss = r.filter(x => !x.hit);
|
||
return {
|
||
ok: r.length > 0 && miss.length === 0,
|
||
note: miss.length ? '未命中: ' + miss.map(m => m.text).join(',') : `${r.length} 项全部命中`
|
||
};
|
||
});
|
||
|
||
// 详情页工具按钮:视觉 15-16px,命中区必须补到 44
|
||
await check('详情页工具按钮命中区 >= 44px', async () => {
|
||
await openFirstMail(page);
|
||
|
||
const r = await tapTargets(page, ['标记已读', '对话树', '转发', '抄送', '发送', '清空']);
|
||
for (const x of r) console.log(' ', JSON.stringify(x));
|
||
const small = r.filter(x => x.hitH < 44 || x.hitW < 44);
|
||
return {
|
||
ok: r.length > 0 && small.length === 0,
|
||
note: small.length ? '仍偏小: ' + small.map(s => s.t).join(',') : `${r.length} 个都达标`
|
||
};
|
||
});
|
||
|
||
// 次要动作在触摸设备上必须可见(没有 hover 时曾经永远透明却接收点击)
|
||
await check('联系人页次要动作默认可见', async () => {
|
||
await page.click('nav button:has-text("联系人")');
|
||
await page.waitForTimeout(1200);
|
||
const r = await page.evaluate(() =>
|
||
[...document.querySelectorAll('.reveal')].slice(0, 3).map(d => getComputedStyle(d).opacity)
|
||
);
|
||
return { ok: r.length > 0 && r.every(o => o === '1'), note: `opacity=${r.join(',')}` };
|
||
});
|
||
|
||
// 对话树:窄屏要有返回出口
|
||
await check('对话树有返回出口', async () => {
|
||
await openFirstMail(page);
|
||
const t = page.locator('button:has-text("对话树")');
|
||
if ((await t.count()) === 0) return { ok: false, note: '找不到对话树入口' };
|
||
await t.first().click();
|
||
await page.waitForTimeout(1600);
|
||
const n = await page.locator('button[aria-label="返回"]').count();
|
||
return { ok: n >= 1, note: `${n} 个` };
|
||
});
|
||
|
||
// 各页无横向溢出
|
||
for (const label of ['收件', '联系人', '管理', '我的']) {
|
||
await check(`${label}页无横向溢出`, async () => {
|
||
await page.click(`nav button:has-text("${label}")`);
|
||
await page.waitForTimeout(1100);
|
||
const of = await overflowX(page);
|
||
for (const o of of.offenders) console.log(' 超出:', JSON.stringify(o));
|
||
return { ok: of.scrollWidth <= of.docWidth, note: `doc=${of.docWidth} scroll=${of.scrollWidth}` };
|
||
});
|
||
}
|
||
|
||
console.log('\n最窄(320px):');
|
||
await page.setViewportSize(SMALL);
|
||
await page.waitForTimeout(800);
|
||
|
||
await check('320px 收件箱无横向溢出', async () => {
|
||
await page.click('nav button:has-text("收件")');
|
||
await page.waitForTimeout(1000);
|
||
const of = await overflowX(page);
|
||
return { ok: of.scrollWidth <= of.docWidth, note: `doc=${of.docWidth} scroll=${of.scrollWidth}` };
|
||
});
|
||
|
||
await check('320px 模型范围排序按钮命中区达标', async () => {
|
||
await page.click('nav button:has-text("管理")');
|
||
await page.waitForTimeout(900);
|
||
await page.click('button:has-text("模型范围")');
|
||
await page.waitForTimeout(1000);
|
||
const first = page.locator('button:has-text("dsh")').first();
|
||
if ((await first.count()) === 0) return { ok: false, note: '没有 Agent 可展开' };
|
||
await first.click();
|
||
await page.waitForTimeout(1600);
|
||
const r = await tapTargets(page, ['↑', '↓', '×']);
|
||
if (r.length === 0) return { ok: true, note: '当前没有已选模型,跳过' };
|
||
const small = r.filter(x => x.hitH < 44 || x.hitW < 44);
|
||
return { ok: small.length === 0, note: `${r.length} 个,最小 ${Math.min(...r.map(x => x.hitH))}px 高` };
|
||
});
|
||
|
||
console.log('\nissues:', issues.length ? issues : '无');
|
||
console.log(failed.length === 0 ? '\n窄屏实测:全部通过' : `\n窄屏实测:${failed.length} 项失败 — ${failed.join(', ')}`);
|
||
|
||
await page.close();
|
||
await browser.close();
|
||
process.exit(failed.length === 0 ? 0 : 1);
|