跨端: 品牌蓝深色下没提亮(24 处字/图标看不见)+ 补深色可读性设备判据

## 一、真 bug:WebUI 深色下把品牌蓝**提亮**了,这边没有

WebUI 的强调色是**双通道**(`tailwind.config.js` 的 `backgroundColor`
/`textColor` 覆盖 + `index.css` 两段定义):

| 通道 | 用途 | 浅色 | 深色 |
|---|---|---|---|
| `--s-blue-600` | **实心按钮底** | `37 99 235` | `37 99 235`(**同值**)|
| `--c-blue-600` | 内容/交互的**蓝字与图标** | `37 99 235` | **`128 175 249`** |

`index.css:353` 写了理由:主按钮底跟着变「会让主按钮在深色页面上
失去『这是主操作』的视觉重量」;而蓝字必须提亮,否则深底上读不动。

鸿蒙只有一个 `Theme.accent = '#2563EB'` ⇒ 24 处字/图标在深色下
对比度 **2.61:1**(设备实测:管理页返回箭头 `‹`),低于 WCAG 图形下限 3:1。

**取证方式**:在跑着的 WebUI 上用 CDP 读**计算样式**(不是读 CSS 源)——
浅色 `37 99 235` / 深色 `128 175 249`,实测确认。

## 二、修法:加前景专用的深色档 + 唯一入口

- `Theme.accentDark = '#80AFF9'`(= WebUI `.dark --c-blue-600`)
- `Theme.accentFor(dark?)` 作为**前景**唯一入口
- **当背景的 20 处保持 `Theme.accent` 不动**(跟 WebUI 的 `--s-*` 一致)

24 处 `.fontColor/.iconColor(Theme.accent)` → `Theme.accentFor()`。

## 三、顺手修掉「转述一层就会漏」这个结构问题

`accentSoftFor(isDark)` 原本的约定是"页面算好深浅色传进来"。
给 `accentFor` 做准备时一数:**8 处**直接用了 `Theme.accentSoft`(没走入口)
—— 约定**已经漏了**,而漏掉的症状正是上一轮那个"深色下白底卡片刺眼"。

于是把两个 `For()` 的参数都改成**可选**:`AppStorage` 是 ArkTS 全局键值存储,
静态类可以直接读(原来"拿不到 Context"的理由不成立)。
⇒ `Theme.isDarkNow()` 成为唯一判断点,调用方不必再各自转述。

## 四、设备判据:深色可读性**扫一屏**

原来只有悬浮球那一条(单个点)。这个 bug 类一天撞到**两批**(12 处 + 24 处),
逐处写判据追不上 ⇒ 改成把当前页所有小段文字都量一遍对比度。

三个实现要点(第一版全踩了,都写进注释):
- **不能只取中心一个像素**:中心多半落在笔画之间 ⇒ 读到的是底色,
  报出一片 ratio=1.00 的假红。改成**框内网格扫描取极值**(最亮=底/最暗=墨)。
- **整屏解码一次**:每点 spawn 一次 ffmpeg 太慢 ⇒ 新增
  `readPixels()`(157ms 解整屏,比逐点快三个数量级)。
- **只判"有真实墨迹"的框**(`hi.L - lo.L >= 0.02`),否则跳过而不是判红。

实测:修前 1 处低对比(2.61:1),修后 **40 段文字全部 ≥3:1**。

## 五、判据自身的三个修正

- `accentSoftFor(dark:)` 的签名断言跟着放宽成 `dark?`,并**补上 `accentFor` 的**。
- **孤儿令牌判据从"一跳"改成"走整条链"**:`KEY_IS_DARK` ← `isDarkNow()`
  ← `accentFor()` ← 24 处页面。只查一跳时它假红 ——
  ★ **"有没有人用"是可达性问题,不是邻接问题**;加中间层(抽 `For()` 入口)
  恰恰是我们鼓励的写法,而旧判据会因此假红。
- 手写色登记表补 `accentDark` 一行理由。

## 六、`baseline.sha` 重算(先核过不是残留)

三个文件哈希对不上。逐个 `git diff --quiet HEAD -- <f>` 取证:
- `AdminUsersPage.ets` / `SettingsPage.ets` —— 本次**有意编辑**;
- `api/AppearanceApi.ets` —— **与 HEAD 逐字节相同** ⇒ 底本取完后被**合法改过**
  (提交 `f811c98`),属 `stale` 不是 `residue`。

按该文件自己那条纪律(「重算必须是一次有记录的动作」)在文件里记了理由。

`run-all.mjs` → `checks=514 pass=514 fail=0 skip=0 red=0 broken=0 unreported=0`。
This commit is contained in:
2026-09-19 20:05:33 +08:00
parent 3123d83979
commit 13b557a742
12 changed files with 362 additions and 39 deletions

View File

@ -235,9 +235,16 @@ const SELF_OWNED_COLORS = [
* `chrome-600` 刻意不透明(`index.css:1097` 有注释:15px 小控件叠透明度
* 会让数字掉到 4.46:1,低于 WCAG AA)。
*
* 三者的取值理由都已写在 `Theme.ets` 各自的注释里(本判据的要求)。
* · `accentDark` #80AFF9 —— WebUI 的 `.dark --c-blue-600`(`index.css:481`)。
* **这是"前景专用"的品牌深色档**,不是 `accent` 的替代:
* WebUI 那边是双通道(`--s-blue-*` 实心按钮底两模式同值 /
* `--c-blue-*` 内容用蓝在 `.dark` 段提亮),这里跟着分。
* 不提亮的话深色下品牌蓝字在近黑底上只有 **2.61:1**(设备实测),
* 低于 WCAG 图形下限 3:1。使用入口是 `Theme.accentFor()`。
*
* 各条取值的理由都已写在 `Theme.ets` 各自的注释里(本判据的要求)。
*/
'navActiveBg', 'navBrandFg', 'badgePlain', 'accentSoftDark',
'navActiveBg', 'navBrandFg', 'badgePlain', 'accentSoftDark', 'accentDark',
/*
* SSE 连接指示器的四个状态色(`WideSidebar.ets` 的 `sseColorOf`)。
* 逐档对齐 WebUI 的 Tailwind 类(`ConnectionIndicator.tsx:22-25`):
@ -614,12 +621,49 @@ test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在
* 否则仍然算死:这正是 `navMaterial` 的形状 —— 它当时那个"内部消费者"是
* `MainPage` 里的一张**局部表**,不提供任何担保。
*/
/*
* ★★ 2026-09-19:改成**走整条链**,不只一跳。
*
* 原来的写法只问"读它的那个方法在外部有没有调用点"。加了 `KEY_IS_DARK`
* 之后立刻假红:它的链是
* KEY_IS_DARK ← Theme.isDarkNow() ← Theme.accentFor() ← 24 处页面
* `isDarkNow` 自己**只被 Theme 内部的两个 `For` 方法读**,外部一处都没有
* ⇒ 一跳就判成孤儿。而它显然不是孤儿(24 处页面在用)。
*
* ★ 这个坑值得记:**"有没有人用"是可达性问题,不是邻接问题。**
* 只查一跳的判据在"中间加了一层"时必然假红 —— 而加中间层
* (抽个 `For()` 入口)恰恰是我们鼓励的写法。
*/
if (internalUsers.length > 0) {
const ownersAlive = internalUsers.some((u) => {
const methodName = u.replace('Theme.', '').replace('()', '');
return othersSrc.some((o) => new RegExp(`Theme\\.${methodName}\\b`).test(o.src));
});
if (ownersAlive) continue;
const alive = new Set();
const isUsedExternally = (name) =>
othersSrc.some((o) => new RegExp(`Theme\\.${name}\\b`).test(o.src));
const queue = internalUsers.map((u) => u.replace('Theme.', '').replace('()', ''));
let hops = 0;
while (queue.length > 0 && hops < 12) { // 环/自引用保护
hops++;
const cur = queue.shift();
if (alive.has(cur)) continue;
alive.add(cur);
if (isUsedExternally(cur)) {
alive.add('__externally_reached__');
continue;
}
/*
* 它自己没人从外面调 —— 继续往上找"谁在 Theme.ets 内部读它",
* 直到某个环节在外部有调用点(或链条断掉)。
*/
const re3 = new RegExp(`Theme\\.${cur}\\b`, 'g');
let m3;
while ((m3 = re3.exec(themeSrc)) !== null) {
const before = themeSrc.slice(0, m3.index);
const owners = [...before.matchAll(/static\s+(?:readonly\s+)?(\w+)/g)];
if (owners.length === 0) continue;
const owner = owners[owners.length - 1][1];
if (owner !== cur && !alive.has(owner)) queue.push(owner);
}
}
if (alive.has('__externally_reached__')) continue;
deadTokens.push(`${name}(只在 ${internalUsers.join('、')} 内部被读,而那些方法**外部也没有调用点**)`);
continue;
}
@ -855,8 +899,23 @@ test('★ 品牌浅底必须有深色变体(深色下白底卡片 = 刺眼的
'深色下用写死的浅底会让选中卡片在深色页上刺眼');
/* ② 有按主题选值的入口 */
assert.match(themeSrc, /static accentSoftFor\(dark: boolean\): string/,
'★ 要有 `accentSoftFor(dark)` 入口 —— 页面各自写 `isDark ? a : b` 迟早漏一处');
/*
* ★ 2026-09-19:参数从 `dark: boolean` 改成 **`dark?: boolean`**(可选)——
* 因为 `Theme.isDarkNow()` 现在能直接读 `AppStorage` 的全局键,
* 调用方不必再各自把"当前是不是深色"转述一遍(那个约定已经漏过 8 处)。
* 判据跟着放宽成"参数可选",但**入口必须存在**这一点不变。
*/
assert.match(themeSrc, /static accentSoftFor\(dark\?: boolean\): string/,
'★ 要有 `accentSoftFor()` 入口 —— 页面各自写 `isDark ? a : b` 迟早漏一处');
/*
* ②b 品牌**前景**色的深色入口 —— 与 ② 同一理由(2026-09-19 加)。
* `accent` 是两用令牌,但只有"当前景"那一半需要在深色下提亮;
* 当背景时保持饱和(WebUI 的 `--s-blue-*` 两模式同值)。
*/
assert.match(themeSrc, /static accentFor\(dark\?: boolean\): string/,
'★ 要有 `accentFor()` 入口 —— 深色下品牌蓝字/图标必须提亮,' +
'否则近黑底上只有 2.61:1(设备实测)。页面各自写 `isDark ? a : b` 迟早漏一处。');
/* ③ 用到它的地方真的走入口(不是还在直接用浅色那个) */
const pages = readdirSync(HARMONY_ETS + '/pages').filter((f) => f.endsWith('.ets'));
@ -1163,3 +1222,116 @@ test('C|「会跟随主题翻转的 Resource」不得当前景色(含矛盾
' 若某个令牌**确实**两边都该用(有具体理由),加进 `ALLOW_BOTH_BG_AND_FG` 并写明理由。\n' +
conflicts.map((c) => ` · Theme.${c.name}\n 当背景:${c.bg}\n 当前景:${c.fg}`).join('\n'));
});
test('★ 设备:深色下短文本必须都读得动(扫一屏,不只查一个元素)', async (t) => {
/*
* ★★ 这条是上面"品牌色真的画成那个色"的**一般化**。
*
* 那条只钉住悬浮球一个点(品牌色 + 图标可分辨)。但"深色下看不见"
* 这个 bug 类**不止一处** —— 2026-09-19 一天里撞到两批:
* · 12 处 `Theme.surface` 当前景色(会翻转的面色当墨水)
* · 24 处 `Theme.accent` 当字色而**深色下没跟着调亮**
* (WebUI `.dark --c-blue-600` 是 `128 175 249`,这边还是 `#2563EB`
* ⇒ 近黑底上 **2.61:1**,低于 WCAG 图形下限 3:1)
*
* 逐处写判据是追不上的(每加一个页面就漏一处)。**扫一屏**才追得上:
* 把当前页面所有"小段文字"都量一遍对比度。
*
* ## 三个关键实现细节(第一版全踩了)
*
* ① **不能只取文字中心一个像素**:中心多半落在**笔画之间**,
* 读到的是底色 ⇒ ratio=1.00 的假红一片(第一版 20 个里报了 12 个)。
* 正确做法是**框内网格扫描取极值**:最亮=底、最暗=墨。
* ② **整屏解码一次**:`pixelAt()` 每点 spawn 一次 ffmpeg,
* 扫 40 段文字要几百次进程(一条判据几十秒)。用 `readPixels()`。
* ③ **只判"有真实墨迹"的框**:`hi.L - lo.L < 0.02` 说明框里全是底色
* (文字被裁掉、或采样的不是文字),那种**跳过而不是判红** ——
* 否则会报一堆"看不见"而其实是我没采到。
*
* 阈值取 **3:1**:这是 WCAG 对图形/大字的下限。小正文该 4.5:1,
* 但设备上有一堆 10–12px 的辅助文字(时间戳、计数),那档本身就在
* 4.5 附近晃 —— 先按 3:1 收住"真的看不见"这一类,别把噪声一次全报出来。
*/
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), '要能回到主界面');
await new Promise((r) => setTimeout(r, 1500));
/*
* ★ 只在**深色**下判。浅色下这些令牌本来就是为浅底配的,
* 量它们没有信息量(也不会红),但会拖慢判据。
*/
const img0 = D.readPixels(await D.screenshot(hdc, '/tmp/dark-legibility.png'));
assert.ok(img0, '要能截屏并解码');
const corner = img0.at(4, 4);
const isDark = corner && corner.r < 128 && corner.g < 128 && corner.b < 128;
if (!isDark) {
return t.skip(`当前不是深色主题(左上角像素 rgb(${corner?.r},${corner?.g},${corner?.b}))—— 本条只在深色下有意义`);
}
const lum = (c) => {
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(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
};
const root = D.dumpLayout(hdc);
let scanned = 0;
const bad = [];
for (const n of D.walk(root)) {
const a = n.attributes || {};
const txt = (a.text || '').trim();
if (!txt || txt.length > 14) continue;
const m = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(a.bounds || '');
if (!m) continue;
const [x1, y1, x2, y2] = m.slice(1).map(Number);
const w = x2 - x1;
const h = y2 - y1;
/* 只要"小段文字"的量级:太小的采不到笔画,太大的多半是容器 */
if (w < 20 || h < 14 || w > 700 || h > 130) continue;
if (x2 > img0.w - 4 || y2 > img0.h - 4) continue;
let lo = null;
let hi = null;
const sx = Math.max(2, Math.floor(w / 14));
const sy = Math.max(2, Math.floor(h / 7));
for (let y = y1 + 1; y < y2; y += sy) {
for (let x = x1 + 1; x < x2; x += sx) {
const p = img0.at(x, y);
if (!p) continue;
const L = lum(p);
if (!lo || L < lo.L) lo = { L, p };
if (!hi || L > hi.L) hi = { L, p };
}
}
/* 没有真实墨迹对比的框跳过(见上面 ③) */
if (!lo || !hi || hi.L - lo.L < 0.02) continue;
scanned++;
const ratio = (hi.L + 0.05) / (lo.L + 0.05);
if (ratio < 3) {
bad.push(`「${txt}」 对比度 ${ratio.toFixed(2)}:1 ` +
`墨 rgb(${lo.p.r},${lo.p.g},${lo.p.b}) / 底 rgb(${hi.p.r},${hi.p.g},${hi.p.b}) ${a.bounds}`);
}
}
/*
* ★ 自检:扫到的段数不能太少 —— 太少说明遍历/尺寸过滤写错了,
* 那样下面那条断言**恒绿**(判据静默失效的典型形状)。
*/
assert.ok(scanned >= 5,
`要扫到足够多的小段文字(实测 ${scanned} 段)—— ` +
'太少说明遍历或尺寸过滤写错了,那样下面的检查恒绿。');
assert.deepStrictEqual(bad, [],
`★ 深色下这些文字读不动(对比度 <3:1):\n ${bad.join('\n ')}\n` +
' 深色下品牌色/面色都要换深色变体:\n' +
' · 文字/图标用 `Theme.accentFor()`(深色自动给 `#80AFF9`,' +
'对齐 WebUI 的 `.dark --c-blue-600`)\n' +
' · 品牌浅底用 `Theme.accentSoftFor()`\n' +
' · **别**把 `Theme.surface` 这类"会翻转的面"当字色(用 `accentFg`)\n' +
' 2026-09-19 真撞到的两批:12 处 surface 当字色 + 24 处 accent 深色没调亮。');
});