Files
MailUI4Agents/client/electron/test/manual/narrow-verify.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

249 lines
10 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.

/**
* 窄屏实测验收:390px 与 320px 下量真实盒子、真实命中。
*
* 每一条都对应一个曾经真实存在的问题(见 docs/PLAN.md §7.10.1):
* 抽屉盖住底部导航、工具按钮只有 16px 高、看不见却按得动的「归档」、
* 对话树缩进把卡片压成竖条、对话树没有返回出口。
*
* 用法:ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs
*/
import {
openApp,
overflowX,
tapTargets,
hitTest,
scrollHealth,
SMALL
} from './narrow-probe-helper.mjs';
import { inflateSync } from 'node:zlib';
/**
* 取屏幕上某个 CSS 坐标的像素颜色。
*
* # 为什么非要有“读像素”这个能力
*
* 「圆角到底有没有生效」是**只有像素能回答**的问题。
*
* 用户(2026-09-14):「我这边看还是方角」。当时所有别的检查都是绿的:
* - `getComputedStyle(pane).borderRadius` === `14px`(外壳那条规则确实加上了);
* - 角上 2px 处“谁在画”也指着那块面板本身(elementFromPoint 沿祖先链找底色)。
* 但实际渲染出来是方的 —— 因为那个 14px 加在一个**没有底色、也裁不住东西**的层上,
* 白底内层用自己的直角把它原地盖掉了。
*
* 实测两个数字就能分辨(390x844 日历页左上角):
* 方角:(255,255,255) 圆角:(249,250,251)=页面底色
* 所以这里截 1×1 再解 PNG —— 只用到 `zlib`,不引图像库。
*/
async function pixelAt(page, x, y) {
const buf = await page.screenshot({ clip: { x, y, width: 1, height: 1 } });
let p = 8;
const idat = [];
while (p < buf.length) {
const len = buf.readUInt32BE(p);
const type = buf.toString('ascii', p + 4, p + 8);
if (type === 'IDAT') idat.push(buf.subarray(p + 8, p + 8 + len));
p += 12 + len;
}
const raw = inflateSync(Buffer.concat(idat));
return [raw[1], raw[2], raw[3]]; // [filter, R, G, B, …]
}
const { browser, ctx, 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}` };
});
}
// 每个页面都必须有纵向滚动容器 —— 否则内容一超过视口就被裁掉看不到。
// 「我的」页曾经缺这个:390px 下内容需 860px、容器 795px,
// 「退出登录」按钮连同下面 65px 一起消失,滚也滚不到。
for (const label of ['收件', '发件', '联系人', '管理', '我的']) {
await check(`${label}页有纵向滚动容器`, async () => {
await page.click(`nav button:has-text("${label}")`);
await page.waitForTimeout(1200);
const h = await scrollHealth(page);
for (const c of h.clipped) console.log(' 被裁:', JSON.stringify(c));
return {
ok: h.hasScroller && h.clipped.length === 0,
note: h.hasScroller
? `${h.scrollers.length} 个容器${h.clipped.length ? ',但有内容被裁' : ''}`
: '没有滚动容器'
};
});
}
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 高` };
});
// 面板的圆角:只有像素能回答(见文件头 pixelAt 的说明)。
// 日历与收件箱详情都走 NarrowStack(窄屏的「页面覆盖」容器),
// 圆角必须加在它那一层 —— 它在外面那层透明 flex 容器上时,两个页面都是方角。
await check('日历页:面板左上角真的被切掉了(像素)', async () => {
await page.click('button:has-text("日历")');
await page.waitForTimeout(2500);
const pane = await page.evaluate(() => {
const el = [...document.querySelectorAll('div')].find(e =>
/flex-1 min-w-0 flex flex-col bg-white min-h-0/.test(String(e.className))
);
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: Math.round(r.x), y: Math.round(r.y), radius: getComputedStyle(el).borderRadius };
});
if (!pane) return { ok: false, note: '找不到日历主面板' };
const px = await pixelAt(page, pane.x + 1, pane.y + 1);
// 页面底色是 gray-50(249,250,251);方角那里会是纯白 255,255,255
const rounded = px[0] < 253;
return { ok: rounded, note: `角上像素=${px.join(',')}(圆角≈249,250,251;方角=255,255,255)` };
});
await check('收件箱详情:面板左上角真的被切掉了(像素)', async () => {
await page.click('button:has-text("通信")');
await page.waitForTimeout(1500);
const row = page.locator('button.w-full.text-left').first();
if ((await row.count()) === 0) return { ok: true, note: '收件箱是空的,跳过' };
await row.click();
await page.waitForTimeout(2000);
const pane = await page.evaluate(() => {
const el = document.querySelector('div.relative.flex-1.min-w-0.flex.flex-col.bg-white');
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: Math.round(r.x), y: Math.round(r.y) };
});
if (!pane) return { ok: false, note: '找不到详情面板' };
const px = await pixelAt(page, pane.x + 1, pane.y + 1);
return { ok: px[0] < 253, note: `角上像素=${px.join(',')}` };
});
console.log('\nissues:', issues.length ? issues : '无');
console.log(failed.length === 0 ? '\n窄屏实测:全部通过' : `\n窄屏实测:${failed.length} 项失败 — ${failed.join(', ')}`);
// ★ 只关自己开的上下文再断开:CDP 连的是共享 Chromium,
// browser.close() 会把**别人的**标签页也一起关掉(本机多个 agent 共用 9222)。
await page.close();
await ctx.close();
// 共享浏览器(CDP 9222)不能 close:那是所有 agent 共用的实例。
process.exit(failed.length === 0 ? 0 : 1);