跨端: 12 处「深色下字看不见」的真 bug + 判据基建补上「看像素」这一层

## 一、判据基建:本目录终于能**看像素**了

此前只能靠 `dumpLayout` —— 那是**结构化描述**,报的是"组件声明了什么",
不是"屏幕上画成什么样"。两者会分叉,而观感类结论只能在像素上得出来。

新增 `lib/harmony-device.mjs`:`screenshot()` / `pixelAt()` /
`hexToRgb()` / `closeColor()`(用 ffmpeg 转 1×1 原始 RGB,不引依赖)。

**它当场证明了它的价值**:`cross-client-theme` 新增的设备判据
用真实像素抓到下面这个 bug —— 静态判据全绿时它藏得好好的。

## 二、真 bug:**12 处**把 `Theme.surface` 当前景色用

`Theme.surface` 是 `sys.color.ohos_id_color_list_card_bg` ——
一个**跟随系统主题翻转**的 Resource:浅色近白、**深色近黑**。

- 浅色下当白字用**碰巧对**(白字压蓝底)
- **深色下字变成黑的**,压在品牌蓝 / danger 红 / warn 琥珀上**几乎看不见**

设备现场:写邮件悬浮球是品牌蓝 `#2563EB`,截图里那个铅笔图标**几乎是隐形的**;
读圆心像素得到 `rgb(32,34,36)`。往左偏 50px 读到底色才见 `rgb(36,99,235)`。

`Theme.accentFg`(`#FFFFFF`)的注释原话就是「品牌底上的文字」—— 为这个场景存在,
却**一处都没用**。

修:12 处 `fontColor/iconColor(Theme.surface)` → `Theme.accentFg`
(`MainPage` 10 + `InboxPage` 1 + `SessionsPage` 1)。改完全仓 0 处残留。
另在 `Theme.ets` 给 `surface` / `accentFg` 都补上"能当什么、不能当什么"的注释。

## 三、判据(两条,都做了变异验证)

1. **设备条**(`cross-client-theme`):读悬浮球像素 ——
   ① 品牌色**真的画成** `#2563EB`(声明 ≠ 渲染);
   ② 球上图标与底色 **WCAG 对比度 ≥3:1**(压在上面的东西得看得见)。
   把 `.accentFg` 改回 `.surface` ⇒ **判红**;还原 ⇒ 绿。
2. **静态防线**(同文件):全局 grep「`fontColor/iconColor(Theme.surface)`」一处不许有。
   设备条只能看一处,而这个错法有 12 处 —— 静态防线管住整类。

## 四、判据自身踩的三个坑(都写进注释了)

- **采样点撞上图标**:第一版取球心,读到 `rgb(32,34,36)`,差点当成"品牌色没渲染"。
  截图一看球是蓝的,深色那点是**铅笔图标**。⇒ 往中心左偏 30% 球宽。
- **假设错了 FAB 的位置**:按"屏幕右下角"找(`x1 > 屏宽*0.6`),
  实测 `[942,1997]`(`x1=942` vs 阈值 1910)⇒ 永远找不到、**静默跳过**。
  原因是列表窗格是**左栏**,球在"左栏的右下角"。⇒ 形状只用站得住的那部分(下半部)。
- **设备判据要自己搭现场**:不加自导航时它**永远跳过**(前面的判据把前台留在管理页),
  而那看起来像"功能没了"。加自导航后立刻开始工作并抓到 bug。

## 五、欠账

- `harmony-maildetail-missing-three` → **count 0(结算)**:三块都做完了
  (转发 `b7c5d8b` / 改名建议 `c2f35d1`+`e79a86a` / 往返预算 `ac62daf`)。
  如实记着**未验**的那点:预算条的**点击**没在设备上走通
  (模拟器顶部 155px 是系统手势区,折叠头部恰在其中)。
- `static-criteria` 5:`cross-client-theme` **升级了一半**,仍留在名单里 ——
  `.ets` 那半只有悬浮球这一处上了设备,其余令牌仍是静态对齐。
- `debt-visibility` 登记 `cross-client-theme` 1 处边界声明(带出处)。

`run-all.mjs` → `checks=513 pass=513 fail=0 skip=0 red=0 broken=0 unreported=0`;
Go 侧 `./internal/repo/...` 通过。
This commit is contained in:
2026-09-19 19:29:49 +08:00
parent ac62dafde1
commit 21132647bc
9 changed files with 345 additions and 18 deletions

View File

@ -878,3 +878,220 @@ test('★ 品牌浅底必须有深色变体(深色下白底卡片 = 刺眼的
offenders.join('\n ') +
'\n改成 Theme.accentSoftFor(this.isDarkNow)(页面要有一个 isDarkNow 状态)');
});
test('★ 设备:品牌色**真的画成那个色**(令牌写对了 ≠ 渲染对了)', async (t) => {
/*
* 补的是 `run-all.mjs` 的 `STATIC_ONLY` 登记里说的那个缺口:
* 「跨端令牌与玻璃分工:一端是 `.ets`,只能静态对齐」
*
* 上面那些判据判的都是**声明**(`Theme.accent` 等于 `#2563EB`、
* 两端令牌同名…)。它们全绿时有一件事从未验过:
* **屏幕上真的画成那个色吗**。
*
* ★ 为什么这不是多余的(本仓的实证):
* · `Slider` 节点的 `text='56.000000'` 是**无障碍文本**,屏幕上根本没那串字
* —— 我为它追了很久,最后靠截图才发现。
* · 「多账号选中卡片是白底」那个 bug:令牌写对了(`accentSoft` 确实是浅蓝),
* 但深色下**渲染出来**是刺眼的白 —— 静态判据全绿。
* ⇒ 声明与渲染会分叉,而"观感类"结论只能靠**像素**。
*
* 判据形状:找一个**品牌色实心元素**(写意最明确的那个:悬浮球的 `Theme.accent`),
* 读它中心像素,与 `Theme.accent` 的取值比对。
*/
const D = await import('./lib/harmony-device.mjs');
const hdc = D.findHdc();
if (!hdc || !D.hasTarget(hdc)) {
return t.skip('设备不在 —— 本条的设备半边本次不跑(上面静态层仍把住声明)');
}
assert.ok(await D.launchOurApp(hdc), '要能拉起应用');
assert.ok(await D.backToMain(hdc), '要能回到主界面');
/*
* ★★ **自己导到列表页** —— 悬浮球只在通信页的列表窗格内。
* 第一版没做这一步,于是套件里它**永远跳过**(前面判据把前台留在管理页),
* 而那看起来像"功能没了"。这正是本仓那条纪律:
* **设备判据要自己搭现场**,不能等人摆好。
*/
const inListPane = () => [...D.walk(D.dumpLayout(hdc))]
.some((n) => (n.attributes?.text || '').includes('收件箱'));
if (!inListPane()) {
/* 通信是底栏/侧栏第一项 —— 用文案点(不写死坐标) */
if (!D.tapText(hdc, '通信')) {
return t.skip('点不到「通信」入口 —— 无法导到列表页');
}
let landed = false;
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
if (inListPane()) { landed = true; break; }
}
assert.ok(landed, '要能导到通信页的列表窗格(悬浮球只在那一屏)');
}
await new Promise((r) => setTimeout(r, 1200));
/* 品牌色的**预期取值**(从 `Theme.ets` 读,不在判据里再写一份) */
const themeSrc = prose(join(HARMONY_ETS, 'common/Theme.ets'));
const accentHex = /static readonly accent: string = '(#[0-9A-Fa-f]{6})'/.exec(themeSrc);
assert.ok(accentHex, '要能从 Theme.ets 读到 `accent` 的取值');
const want = D.hexToRgb(accentHex[1]);
/*
* 找那个品牌色实心元素。**用形状找**(不写死坐标):
* 悬浮球是"圆形 + 尺寸 56vp 左右 + 在屏幕右下"。实测(密度 2.875)
* 它是约 161×161px 的 `Button`。
*
* ★ 用形状而不是"找某个文案":球上没有文字(只有图标)——
* 按文案找会找不到,然后我会误以为"功能没了"。
*/
const root = D.dumpLayout(hdc);
let screenW = 0;
let screenH = 0;
for (const n of D.walk(root)) {
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
if (m) {
screenW = Math.max(screenW, Number(m[1]));
screenH = Math.max(screenH, Number(m[2]));
}
}
/*
* ★★ 第二版(第一版假设错了):我按"右下角"找球(`x1 > 屏宽*0.6`),
* 实测它是 `Button [942,1997][1103,2158]` —— `x1=942`,
* 而 60% 屏宽是 1910 ⇒ **永远找不到**。
*
* 原因:通信页的列表窗格是**左栏**(宽屏下右栏是详情),
* 所以悬浮球在"左栏的右下角",不是"屏幕的右下角"。
* ⇒ 形状判据只该用**能站得住的那部分**:圆形(宽高相等)
* + 在屏幕**下半部**。左侧/右侧是布局决定的,不该写进形状。
*/
const fab = [...D.walk(root)].find((n) => {
const a = n.attributes || {};
const m = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [x1, y1, x2, y2] = m.slice(1).map(Number);
const w = x2 - x1;
const h = y2 - y1;
return Math.abs(w - h) <= 10 && w > 120 && w < 220
&& y1 > screenH * 0.6; // 只要求在下半部
});
if (!fab) {
return t.skip('找不到右下角的悬浮球(可能不在列表页)—— 本次不跑,但不假装通过');
}
const c = D.boundsCenter(fab.attributes.bounds);
assert.ok(c, '要能解析出悬浮球的坐标');
/* `boundsCenter` 只给圆心 —— 宽度自己从 bounds 里算(采样偏移要用它) */
const fb = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(fab.attributes.bounds).slice(1).map(Number);
const fabW = fb[2] - fb[0];
const png = D.screenshot(hdc, '/tmp/theme-accent-shot.png');
assert.ok(png, '要能截屏(观感类判据没有截屏就没有依据)');
/*
* 读**中心**像素。为什么不是别处:球的边缘是圆角/抗锯齿,
* 取边缘会读到背景混色。
*/
/*
* ★★ 采样点必须**避开图标**!
* 第一版我取球的正中心,实测读到 `rgb(32,34,36)`(深灰)——
* 差点当成"品牌色没渲染"。截图一看:球是**蓝的**,中心那个深色是
* **铅笔图标**(`compose`)。圆心正是图标所在。
*
* ⇒ 往中心**左侧**偏 50px(图标宽 24vp≈69px,偏 50px 仍在圆内、
* 但在图标之外)。半径 ~80px,所以 50px 是安全的。
* 这个偏移量要**小于半径、大于图标的半宽**:写死一个值不如
* 按半径算(下同)。
*/
const off = Math.max(30, Math.round(fabW * 0.3));
const got = D.pixelAt(png, c.cx - off, c.cy);
assert.ok(got, '要能读到像素');
assert.ok(D.closeColor(got, want),
`★ 悬浮球应是品牌色 ${accentHex[1]}(${JSON.stringify(want)}),` +
`实测 rgb(${got.r},${got.g},${got.b})。\n` +
' 两者不符说明**声明与渲染分叉了** —— 令牌写对了但没画成那个色' +
'(被父层覆盖 / 透明度抹掉 / 换了别的色)。这正是静态判据看不到的那一层。');
/*
* ★★ 第二半:图标**必须与底色可分辨**。
*
* 这是本轮真撞到的 bug:12 处把 `Theme.surface` 当**前景色**用
* (`sys.color.ohos_id_color_list_card_bg`,一个**会跟随系统主题翻转**的
* Resource)—— 浅色下是白的(碰巧对),**深色下变成近黑** ⇒
* 深色铅笔压在蓝球上,看起来像"图标消失了"。
*
* `Theme.accentFg`(#FFFFFF)就是为"品牌底上的文字/图标"存在的,
* 但没人用它。
*
* 判据形状:读**圆心**的像素(那里是图标),要求它与底色**可分辨**
* (对比度足够)。这条不指定图标必须是白 —— 只要"看得见"。
* 用相对亮度算 WCAG 对比度,阈值取 3:1(图形元素的下限)。
*/
const onIcon = D.pixelAt(png, c.cx, c.cy);
assert.ok(onIcon, '要能读到图标处的像素');
const lum = (c1) => {
const f = (v) => { const x = v / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
return 0.2126 * f(c1.r) + 0.7152 * f(c1.g) + 0.0722 * f(c1.b);
};
const l1 = lum(onIcon);
const l2 = lum(want);
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
/*
* 若圆心读到的**就是**底色,说明图标没画(或采样的不是图标位置)——
* 那两种都该问一句,但今天的现场是真有图标(截图可见),
* 所以这里只判"可分辨",并附上实测值。
*/
assert.ok(ratio >= 3,
`★ 悬浮球上的图标要对底色**可分辨**(WCAG 图形对比度 ≥3:1)。\n` +
` 实测:图标 rgb(${onIcon.r},${onIcon.g},${onIcon.b}),` +
`底色 rgb(${got.r},${got.g},${got.b}),对比度 **${ratio.toFixed(2)}:1**。\n` +
' 本轮撞到的真 bug:12 处把 `Theme.surface` 当**前景色**用 ——\n' +
' 它是 `sys.color.ohos_id_color_list_card_bg`,一个**跟随系统主题翻转**的 Resource:\n' +
' 浅色下是白的(碰巧对),**深色下变成近黑** ⇒ 深色图标压在品牌蓝上,' +
'看起来像图标没了。\n' +
' 正确的前景色是 `Theme.accentFg`(#FFFFFF,它的注释原话就是"品牌底上的文字")。');
});
test('C|`Theme.surface` 不得当代的前景色(它是会翻转的「面」)', () => {
/*
* ★★ 这是设备判据(上面那条)抓到的 bug 的**静态防线** ——
* 设备条只能看一处(悬浮球),而这个错法当时有 **12 处**。
*
* 错法:`Theme.surface` 是 `sys.color.ohos_id_color_list_card_bg`,
* **跟随系统主题翻转**(浅色近白 / 深色近黑)。把它当
* `fontColor` / `iconColor`("压在彩色底上的字"):
* · 浅色下碰巧对(白字压蓝底)
* · **深色下字变黑**,压在蓝/红/绿底上几乎看不见
*
* 正确:`Theme.accentFg`(#FFFFFF,注释原话就是"品牌底上的字/图标")。
*
* 判据形状:全局 grep「`fontColor(Theme.surface)` / `iconColor: Theme.surface`」,
* 一处都不许有。`backgroundColor(Theme.surface)` 是合法用法(那才是"面")。
*/
const files = [];
const walkDir = (d) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
const p = join(d, e.name);
if (e.isDirectory()) walkDir(p);
else if (e.name.endsWith('.ets')) files.push(p);
}
};
walkDir(HARMONY_ETS);
const bad = [];
for (const f of files) {
const src = prose(f); // 保留注释:注释里举例不算错
const lines = src.split('\n');
lines.forEach((ln, i) => {
if (/\.fontColor\(Theme\.surface\)/.test(ln) || /iconColor:\s*Theme\.surface/.test(ln)) {
bad.push(`${f.replace(HARMONY_ETS, '')}:${i + 1} ${ln.trim().slice(0, 90)}`);
}
});
}
assert.deepStrictEqual(bad, [],
'★ `Theme.surface` 是**会跟随系统主题翻转的面色**(浅色近白 / 深色近黑),' +
'只能当背景,不能当字/图标色。\n' +
' 压在彩色底上的前景要用 `Theme.accentFg`(#FFFFFF)。\n' +
' 深色下症状:字/图标几乎看不见(2026-09-19 真撞到 12 处)。\n' +
' 违规处:\n ' + bad.join('\n '));
});