用户(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,两个数字就能分辨。
249 lines
10 KiB
JavaScript
249 lines
10 KiB
JavaScript
/**
|
||
* 窄屏实测验收: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);
|