Files
MailUI4Agents/client/electron/test/cross-client-theme.test.mjs
JianFeeeee 5103e0aee3 跨端: 抽出基础面/玻璃组件(Motion + Surface)+ 三处硬弹的弹层补过渡
用户两条要求,各自都指向"决策被复制到各处"这个病根:
  ① 「应该定义一个基础玻璃容器给各个组件引用」
  ② 「很多页面还是不够精致……封装为基础组件供所有页面使用。基础组件包含动画」

★ 玻璃不透明的真根因(前几轮都没找到,这次是像素证据定的)
  实测模拟器 3184×2232、壁纸 aurora + 压暗 37:
      Navigation [229,140,3156,2204]   ← 255,255,255(整块内容区)
      y=2210(Navigation 之外那条缝)    ← 226,225,235(壁纸清楚可见)
  那条缝是决定性的:**壁纸层本身是好的**,白是因为上面盖了不透明的壳。
  链路上一共三层,逐层修:
    · `Navigation` 外壳:不设背景 ⇒ 系统默认不透明白,把里面已透明的窗格整片盖住;
    · navBar 内容层(列表栏根容器):同样没有背景;
    · **卡片**:铺的是 `Theme.surface`(实体系统色)。
  修后:缝隙 203,213,228 / 卡片 191,204,220 —— 壁纸透得出来。

★ 新增基础组件(common/)
  · `Surface.ets`  —— `GlassPane`(正文面)/ `GlassCard`(嵌套面)/ `PageHeader` / `Pressable`
  · `Motion.ets`   —— `Motion.dur()`:接系统「减弱动态效果」
    (`accessibility.isAnimationReduceEnabledSync()`,API 23 正好够用)。
    WebUI 侧有 `prefers-reduced-motion` 硬约束(index.css:1266),
    鸿蒙这边**改造前一次都没调过** —— 系统里关了动画,我们照样动。这是无障碍义务。
  · `Theme.cardMaterial` —— 卡片材质令牌。不是拍脑袋选的:
    `COMPONENT_THIN` 实测卡片 252,254,254(吃掉 94% 壁纸,就是用户看到的"白卡");
    `BACKGROUND_THIN` → 185,190,201,壁纸透得出来且文字对比度仍够。

★ 判定形状按"玻璃从例外变成默认"重写(判据 C 条)
  原来是"允许出现玻璃的位置"白名单,逐处登记。那个形状在"玻璃是少数例外"时成立,
  但用户要的是**全部玻璃化** ⇒ 白名单退化成"把每处抄一遍",且挡不住新写的裸枚举。
  改成**规则**:材质档次只能来自 `Theme.navMaterial` / `Theme.cardMaterial`
  (或 `BlurStyle.NONE`),页面里出现裸 `BlurStyle.XXX` 即红;
  辅助方法(`this.materialOf()`)体内也查,否则等于开了后门。两条变异都验证咬得住。

★ 判据 C 条自身的两个 bug(都被本次触发)
  · 嵌套判定**没比文件**:`c.at`/`o.at` 是各文件自己的字符下标,直接比大小
    于是判出"MainPage 内含 SettingsPage 的玻璃"这种物理上不可能的红。
    假红比漏报更坏 —— 会让人去改本来对的代码(本次差一点)。
  · 参数抽取用 `[^)]*`:`this.materialOf()` 里含一层 `()`,在内层括号处截断,
    把合法写法读成 `this.materialOf(` 判红。改成配对括号抽取。

★ 出现/消失动画:用户点名的三处硬弹
  这些挂在 `if (cond)` 上,条件一变整块出现/消失,原来一帧过渡都没有 ——
  而代码上"看着像挂了动画"(外层页面有 transition),所以最容易漏。
  · `MainPage` 账号选择器下拉 → `menuIn()`(从上往下落的浮层档)
  · `AdminUsersPage` 新建用户表单 → `paneRiseIn()`(就地展开档)
    ★ 过渡必须挂在**调用点的 Column**:`@Builder` 返回 void,链不上修饰符。
      第一版写进了 `CreateForm()` 内部,是新判据抓出来的。
  · `SettingsPage` 新增账号弹层 → `paneRiseIn()`(与回复/转发弹层同一挂法)
  新增判据「出现/消失的那类元素真的挂了过渡」:**枚举所有条件挂载的浮层/展开块**
  (不是数数 —— 数数挡不住"新增第四处又漏了"),变异验证过。

★ 顺手修的两处真嵌套玻璃
  `SettingsPage` 的账号列表与密钥列表都是"卡里面的一段列表",两层都铺材质 =
  WebUI 用 `.bg-white .bg-white { backdrop-filter: none }` 明确禁止的嵌套
  (alpha 相乘 0.82×0.82=0.97 把壁纸吃光)。去掉内层材质,玻璃只留一层。

★ 判据放宽一处(不是放水)
  `harmony-contacts` 那句写死 `pushUrl`,判的是"tryRestore 有没有跳转",
  与用哪个原语无关。修 LoginPage 返回栈时改成 `replaceUrl` 后它误报了;
  改成 `(pushUrl|replaceUrl)`,原语该用哪个由 `harmony-nav` 那条专门判。

判据:files=32 ran=32 checks=518 pass=518 fail=0 red=0;
baseline 7/7✓(第 6 次重算,两个文件逐个 `git diff --quiet HEAD` 取证为有意编辑)。
2026-09-20 07:37:16 +08:00

1561 lines
87 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.

/**
* 两个客户端必须用**同一份设计词表**。
*
* 用户下一步要求:「同步 ui 设计到客户端」。同步的第一件事不是把每个页面重画一遍,
* 而是两边共用同一套令牌(颜色/圆角/玻璃透明度)—— 否则每加一个页面就重抄一遍色值,
* 两个客户端会越走越远,而且这种漂移**没有任何判据会红**。
*
* 这里只断言"两边对同一件事的取值一致",不断言实现方式(WebUI 用 CSS 变量、
* 鸿蒙用 ArkTS 常量,本来就该不同)。
*/
import { code, prose, stripComments } from './lib/read.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const web = code(join(ROOT, 'client/electron/src/index.css'));
const harmonyPath = join(HARMONY_ETS, 'common/Theme.ets');
/** 判「代码里有什么」用这个 */
const harmony = code(harmonyPath);
/** 判「注释/文档里写了什么」用这个 —— 两条判据各取所需,别混用 */
const harmonyDoc = prose(harmonyPath);
/**
* 裸色值的**类**(不只 `#RRGGBB`):四种写法一起扫 ——
* `#RRGGBB(AA)`、`rgba(...)`、`0xRRGGBBAA`(`Color(0x…)` 与渐变数组都用它)。
* 注释先剥掉:注释里引用旧写法是常有的事,而"诚实的注释"不该把判据判红。
*/
const RAW_COLOR = /#[0-9A-Fa-f]{6,8}\b|\brgba?\s*\(|\b0x[0-9A-Fa-f]{6,8}\b/g;
const rawColors = src => stripComments(src).match(RAW_COLOR) || [];
/**
* 文档里的「有意差异」表(§7.12)。
*
* 这是**弱判据**用的材料:它防的是"改了做法没改记录"(下一个人会当成漏改),
* 不是"证明做法对"。所以跨端那几条判据的主体仍落在代码上
* (来源必须是系统资源 / 品牌色必须是那个值),文档只做第三只手。
*/
const plan = prose(join(ROOT, 'docs/HARMONY-ALIGN-PLAN.md'));
/**
* 取「有意差异」那一小节。
*
* ⚠️ **按标题定位,不按关键词**(pi 读出来的第三条):
* 原来写的是 `plan.indexOf('有意差异')` —— 只要别的段落正文里出现过这四个字,
* 切片就从**那一处**开始,后面的 `includes('圆角')`、行数断言全是在**别的段落**上判,
* 可能照样绿。这个坑在本仓库记过一次:`index.css` 的注释里写了深色选择器字面量,
* `theme.test.mjs` 的 `indexOf` 就被提前截断(那条注释现在还留着当教训)。
*/
const diffSection = () => {
const lines = plan.split('\n');
const start = lines.findIndex(l => /^#{2,4}\s/.test(l) && l.includes('有意差异'));
assert.ok(start >= 0, '文档里应有「有意差异」小节(§7.12),且要是一行标题');
const headLevel = (lines[start].match(/^#+/) || ['#'])[0].length;
/*
* 用**行**切,不用字符偏移。
*
* 第一版是算偏移的(`acc += 每行长度`,再 `slice`),而偏移算术一旦差一个字符
* 就会把最后一行**拦腰截断** —— 表现是"品牌色那一行只剩 57 个字符、找不到'不允许差异'"
* 这种看着像文案问题、其实是切片问题的怪现象。行切法没有这种自由度。
*/
const body = [];
for (let i = start + 1; i < lines.length; i++) {
const h = lines[i].match(/^(#{1,6}) /);
if (h && h[1].length <= headLevel) break;
body.push(lines[i]);
}
return body.join('\n');
};
/** 递归收集鸿蒙源码(.ets / .ts) */
const collectEts = (dir, acc = []) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) collectEts(full, acc);
else if (/\.(ets|ts)$/.test(e.name)) acc.push(full);
}
return acc;
};
const hex = h => h.toUpperCase();
test('品牌蓝一致', () => {
// WebUI: --c-blue-600 应该是 37 99 235 = #2563EB
const webBlue = web.match(/--c-blue-600:\s*(\d+)\s+(\d+)\s+(\d+)/);
assert.ok(webBlue, 'WebUI 要有 --c-blue-600');
const asHex = hex(
'#' +
[webBlue[1], webBlue[2], webBlue[3]]
.map(n => Number(n).toString(16).padStart(2, '0'))
.join('')
);
const harmonyBlue = harmony.match(/accent: string = '(#[0-9A-Fa-f]{6})'/);
assert.ok(harmonyBlue, '鸿蒙要有 accent');
assert.equal(hex(harmonyBlue[1]), asHex, `品牌蓝不一致:WebUI ${asHex} vs 鸿蒙 ${harmonyBlue[1]}`);
});
test('圆角:WebUI 有它自己的令牌,鸿蒙跟随系统 —— 差异被记录(不再钉"取值相同")', () => {
/*
* 这一条**改过口径**(2026-09-14,jianf 要求「鸿蒙用系统方案」,与 pi 对齐后改)。
*
* 旧口径钉的是"两边都 14px"。改用系统资源后它必然红 —— 而这次红是**预期**的:
* 圆角交给系统就意味着它**允许**与 WebUI 不同(系统圆角会随设备/主题/无障碍设置变)。
* 所以口径从"取值相同"换成"**意图相同**":
* ① WebUI 侧仍须自己声明一个圆角令牌(它没有系统可跟随);
* ② 鸿蒙侧圆角来自系统(`sys.float.*`);
* ③ 这条差异被**记录**在文档的"有意差异"表里 —— 否则下一个人会以为是漏改。
* 注意 ③ 是**弱判据**(防遗忘),不是"证明做法对"。主体是 ①②,它们落在代码上。
*/
assert.match(web, /--radius-card:\s*0\.875rem/, 'WebUI 侧要保留自己的圆角令牌');
assert.match(stripComments(harmony), /radiusCard: Resource = \$r\('sys\.float\./, '鸿蒙卡片圆角要来自系统');
assert.match(diffSection(), /圆角/, '圆角是"有意差异",要写进文档的差异表(否则会被当成漏改)');
});
test('材质:WebUI 声明透明度令牌,鸿蒙用系统材质 —— 差异被记录(不再钉 0.72)', () => {
// 同上:旧口径钉"两边都 0.72"。鸿蒙改用 `BlurStyle` 后,"0.72 的白色"这件事
// 由系统按主题决定(深浅各一套),所以取值**允许**不同,只要求"材质来自系统"且差异被记录。
assert.match(web, /--nav-bg:\s*255 255 255 \/ 0\.72/, 'WebUI 侧要保留自己的导航底令牌');
assert.match(stripComments(harmony), /navMaterial: BlurStyle = BlurStyle\./, '鸿蒙导航材质要来自系统');
assert.match(diffSection(), /材质/, '材质是"有意差异",要写进文档的差异表');
});
test('★ 判据自检:把鸿蒙的品牌蓝改成别的必须判红', () => {
const mutated = harmony.replace(/accent: string = '#2563EB'/, "accent: string = '#FF0000'");
const m = mutated.match(/accent: string = '(#[0-9A-Fa-f]{6})'/);
assert.notEqual(hex(m[1]), '#2563EB');
});
test('鸿蒙的令牌文件说明了与 WebUI 的对应关系(不是凭空一套)', () => {
assert.match(harmonyDoc, /与 WebUI 的令牌\*\*一一对应|对应 WebUI/, '这条判的是**注释里写的对应关系**,所以要读原文(prose),不是剥离版');
});
test('★ 鸿蒙页面里不得出现任何裸色值(枚举挡不住漂移,"类"才能挡)', () => {
/*
* 上一版这条判据只列了 8 个旧色值(#1A73E8 / #333333…),于是判据全绿的
* 同时,pages/ 里还留着 14 处**另一套**写死的色:Google/Material 的
* #E8F0FE、#E8F5E9、#FFF3E0、#D93025 与 #777777/#555555/#444444/
* #cccccc/#aaaaaa、遮罩 #80000000、透明 #00000000。
* 枚举只能挡住"列出来的实例",挡不住漂移本身 —— 改成按类挡。
*
* ## 2026-09-14:按类挡得**挡全**(pi 指出的洞)
*
* 原来只扫 `#RRGGBB(AA)` —— 抓不到 `rgba(...)`、`Color(0x…)`、`0xRRGGBBAA` 这几种写法,
* 而"改用系统材质/颜色"的过程恰恰最容易混进这几种形态(`0xB8FFFFFF` 就是手写玻璃)。
* 现在四种形态一起扫,并且**先剥注释**再扫:注释里正当地引用旧写法是常有的事
* (本文件上面两段注释里就各有一个 #B8FFFFFF),不剥的话判据会被"诚实的注释"判红。
*/
const dir = join(ROOT, 'client/harmony/entry/src/main/ets/pages');
const files = readdirSync(dir).filter(f => f.endsWith('.ets')).sort();
assert.ok(files.length >= 8, `pages/ 下只扫到 ${files.length} 个 .ets,判据大概扫错了目录`);
for (const f of files) {
const hits = rawColors(prose(join(dir, f)));
assert.equal(hits.length, 0, `${f} 里有裸色值 ${hits.join('、')}(应改用 Theme 令牌或系统资源)`);
}
// 反向对照:四种形态都要抓得到(否则写错了正则也是一片绿)
assert.deepEqual(rawColors("fontColor('#E8F0FE')"), ['#E8F0FE']);
assert.deepEqual(rawColors("color('#80000000')"), ['#80000000']);
assert.deepEqual(rawColors('linearGradient({ colors: [[0xB8FFFFFF, 0]] })'), ['0xB8FFFFFF']);
assert.deepEqual(rawColors("backgroundColor('rgba(255,255,255,0.72)')"), ['rgba(']);
// 注释里的旧写法不算(否则这条判据会逼着人删掉"为什么不能这么写"的解释)
assert.deepEqual(rawColors("// 旧写法是 '#B8FFFFFF'\ncolor(Theme.surface)"), []);
// 令牌文件本身是**唯一**的颜色来源,否则上面那条会因为"哪儿都没有色值"而空转
assert.ok(/#[0-9A-Fa-f]{6,8}/.test(harmony), 'Theme.ets 里应该有真正的色值');
});
// ─────────────── 「用系统方案」的意图判据(A/B/C + 品牌色防线) ───────────────
test('A|系统拥有的维度,鸿蒙侧的唯一来源是系统资源(不是手抄的值)', () => {
/*
* 「一个维度只允许一个机制来源」。表面/文字/分隔/遮罩/圆角这些**系统有语义**的维度
* 只能来自 `$r('sys.*')` —— 于是它们自动跟随深色模式,也不会再和系统打架。
*
* 说明一处**与 pi 原话的有意差异**:他写的是"页面不再引用 Theme.pageBg/surface/…",
* 我保留了 `Theme.` 这层名字,但把它的**值**换成了系统资源。理由是"唯一来源"这条性质
* 靠这个也能拿到(值只有一处、且那一处指向系统),而 149 处调用点不用动 ——
* 少动 149 处就少 149 次改错的机会。真正的防线是下面这条判据本身:
* 只要有人把这些格子改回手写色值,它就红。
*/
const sysOwned = {
pageBg: 'color', surface: 'color', surfaceMuted: 'color', border: 'color',
textPrimary: 'color', textMuted: 'color', textSubtle: 'color',
overlay: 'color', navFg: 'color',
radiusCard: 'float', radiusControl: 'float'
};
for (const name of Object.keys(sysOwned)) {
const kind = sysOwned[name];
assert.match(
harmony,
new RegExp(`static readonly ${name}: Resource = \\$r\\('sys\\.${kind}\\.[A-Za-z0-9_]+'\\)`),
`${name} 必须来自系统资源($r('sys.${kind}.*')),而不是手写的值`
);
}
// 反向断言:这些格子不得退回 string/number 类型(退回 = 又开始自己定值了)
for (const name of Object.keys(sysOwned)) {
assert.ok(
!new RegExp(`${name}: (string|number) =`).test(stripComments(harmony)),
`${name} 不再是系统资源了 —— 这是"用系统方案"被回退的信号`
);
}
});
/**
* Theme.ets 里**允许自己写**的色值名单(与文件里的「手写色登记表」逐字一致)。
*
* pi 读出来的真缺口:A 条只枚举了"**必须**来自系统资源"的 11 个名字,
* 于是新加一个手写色(`static readonly brandSecondary: string = '#123456'`)
* **三条判据都碰不到它** —— A(不在名单里)、B(六位、不是半透明)、
* 裸色值那条(只管 `pages/` 目录)。
*
* 「枚举挡实例,类才挡漂移」:这次的枚举单位是**名字**,所以名单本身就是防线。
* 想加一个手写色 → 先登记在这里 + 在 `Theme.ets` 的登记表里写一行理由;
* 否则它本来就该走 `$r('sys.*')`。
*/
const SELF_OWNED_COLORS = [
// 品牌(跨客户端身份,必须与 WebUI 逐字一致的那一个 + 它的前景/浅底/深色变体)
'accent', 'accentFg', 'accentSoft', 'accentStrong',
// 业务语义色:系统没有对应物(同意 / 拒绝 / 警示)
'approve', 'danger', 'approveBg', 'approveFg', 'dangerBg', 'warnBg', 'warnFg',
// 权限档位与预算档位的胶囊配色(档位是产品语义,系统不认识"plan/workspace/full")
'chipNeutralBg', 'chipNeutralFg', 'chipSpentBg', 'chipSpentFg', 'chipWarnBg', 'chipWarnFg',
/*
* ★★ 2026-09-19 补登记(对应用户「底栏数字为什么显示在图标下面?」与
* 用户「你写的app和webui大面积不符」那两轮改动)。
*
* 三个值都是**跨端身份色**,系统语义色里没有对应物:
* · `navActiveBg` #DBEAFE —— WebUI `index.css:1595-1606` 的 `--nav-active-bg`,
* 侧栏选中项的**浅蓝底块**(宽屏侧栏的选中线索就是它,不是文字变色)。
* · `navBrandFg` #475569 —— WebUI 的 `--nav-fg-muted`,侧栏未选中项的图标色。
* · `badgePlain` #475569 —— WebUI `bg-chrome-600`(`index.css:92`),
* "plain" 档徽标底色(联系人数)。**它必须是石板灰而不是红**:
* 三档被压成两档时,`'plain'` 会走 `danger`,看起来像"有未读"。
* `chrome-600` 刻意不透明(`index.css:1097` 有注释:15px 小控件叠透明度
* 会让数字掉到 4.46:1,低于 WCAG AA)。
*
* · `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', 'accentDark',
/*
* 语义色的深色档(2026-09-19 加)—— 与 `accentDark` 同一个问题、同一批证据:
* WebUI 的语义色也是**双通道**,`.dark` 段把 red/green/amber-700 换成浅色,
* 否则深底上的红字/绿字读不动(设备实测「归档」按钮 2.47:1)。
* · `dangerDark` #F8A4A4 ← WebUI `.dark --c-red-700`(`248 164 164`)
* · `approveDark` #76DB9B ← WebUI `.dark --c-green-700`(`118 219 155`)
* · `warnFgDark` #F9C368 ← WebUI `.dark --c-amber-700`(`249 195 104`)
* 入口是 `Theme.dangerFor()` / `approveFor()` / `warnFgFor()`。
*/
'dangerDark', 'approveDark', 'warnFgDark',
/*
* SSE 连接指示器的四个状态色(`WideSidebar.ets` 的 `sseColorOf`)。
* 逐档对齐 WebUI 的 Tailwind 类(`ConnectionIndicator.tsx:22-25`):
* connected `bg-green-500` / connecting `bg-yellow-400` /
* reconnecting `bg-orange-400` / disconnected `bg-red-400`。
*
* 为什么不复用 `warnFg`/`danger`:那两个是**文字色**,而状态点是 8px 实心圆,
* 用文字色会发脏、深色底上不够跳。WebUI 也是分开的两套(`text-*` vs `bg-*`)。
*/
'sseConnecting', 'sseConnected', 'sseReconnecting', 'sseDisconnected'
];
test('A2|Theme.ets 里"自己写的色"必须**登记过**:新写死一个色不该默默通过', () => {
const themeSrc = harmony; // 声明在代码里:读剥离版就够了
const declared = [...themeSrc.matchAll(/static readonly (\w+): string = '(#[0-9A-Fa-f]{6,8})'/g)].map(m => m[1]);
assert.ok(declared.length >= 10, `要从 Theme.ets 里读到那些手写色,实际读到 ${declared.length} 个`);
// ① 未登记的手写色 → 红(这是补上的那一枪)
const extra = declared.filter(n => !SELF_OWNED_COLORS.includes(n));
assert.deepEqual(extra, [],
`Theme.ets 里出现未登记的手写色:${extra.join('、')} —— ` +
'属于品牌/业务语义色就登记进 SELF_OWNED_COLORS 并在 Theme.ets 的登记表里写一行理由,否则该走 $r(\'sys.*\')');
// ② 名单不能烂成"曾经"的化石:登记了却已经不存在的名字 → 红
const stale = SELF_OWNED_COLORS.filter(n => !declared.includes(n));
assert.deepEqual(stale, [], `名单里这些名字在 Theme.ets 里已不存在(名单要跟着改):${stale.join('、')}`);
/*
* ③ 登记表本身要写在文件里(理由靠记忆是不可靠的,要靠登记):
* 每个登记项都要在那一段**注释**里出现 —— 所以这里读**原文**(`harmony`),
* 不是剥过注释的源码(剥注释会把登记表本身剥掉,第一版就踩了这个:
* `indexOf` 返回 -1,切片拿到一段不相干的东西)。
*/
const regStart = harmonyDoc.indexOf('手写色**登记表**');
assert.ok(regStart > 0, 'Theme.ets 里要有「手写色登记表」那一段');
const regEnd = harmonyDoc.indexOf('品牌色:跨客户端身份', regStart);
assert.ok(regEnd > regStart, '登记表那一段要有明确的结束边界(下一个分节标题)');
const registry = harmonyDoc.slice(regStart, regEnd);
assert.ok(registry.length > 200, `登记表太短,可能切错了段落(${registry.length} 字符)`);
for (const name of SELF_OWNED_COLORS) {
assert.ok(registry.includes(name), `登记表里要提到 ${name}(否则理由只存在于写它那个人的记忆里)`);
}
/*
* 自检:把一个**新的**手写色塞进去,确认①真的抓得到。
* 这条自检是"判据能判红"的证据(不依赖外部变异测试也能看出它有效)。
*/
const mutated = themeSrc.replace(
"static readonly accent: string = '#2563EB';",
"static readonly accent: string = '#2563EB';\n static readonly brandSecondary: string = '#123456';"
);
assert.notEqual(mutated, themeSrc, '自检:变异没打上');
const mutatedNames = [...mutated.matchAll(/static readonly (\w+): string = '(#[0-9A-Fa-f]{6,8})'/g)].map(m => m[1]);
const mutatedExtra = mutatedNames.filter(n => !SELF_OWNED_COLORS.includes(n));
assert.deepEqual(mutatedExtra, ['brandSecondary'], '自检:新加的手写色必须被判据抓到');
});
test('B|旧机制不得回来:手写玻璃 alpha、替系统猜深色、与 WebUI 绑死的圆角数字', () => {
const codeOnly = stripComments(harmony);
/*
* `navBgLight` / `navBgDark` 这两个名字本身就是罪证:浅色一个、深色一个手写玻璃,
* 等于"我们替系统猜了深色该怎么做"。pi 在 WebUI 侧撤掉 `.dark` 导航令牌、
* 我在这侧撤掉这两个常量,是**同一个判断**,只是答案相反:
* WebUI 没有深色主题所以不该猜,鸿蒙有系统主题所以**不该手写**。
*/
assert.ok(!/navBg(Light|Dark)/.test(codeOnly), 'navBgLight/navBgDark 不该再出现(玻璃交给系统材质)');
// 半透明色(8 位 #AARRGGBB)= 手写玻璃/手写遮罩那一类,一律不许
const translucent = rawColors(codeOnly).filter(h => h.startsWith('#') && h.length === 9);
assert.deepEqual(translucent, [], '源码里出现 8 位半透明色 —— 半透明属于系统材质/语义色的职责');
// 鸿蒙侧不写 CSS 式颜色函数
assert.ok(!/\brgba?\s*\(/.test(codeOnly), '鸿蒙源码里不该出现 rgb()/rgba()(那是 WebUI 的写法)');
// 圆角不得再与 WebUI 的 14/8 绑死
assert.ok(!/radiusCard: number = 14/.test(codeOnly), 'radiusCard 又变回写死的 14 了');
assert.ok(!/radiusControl: number = 8/.test(codeOnly), 'radiusControl 又变回写死的 8 了');
});
/**
* 允许出现玻璃的位置**名单**(改判据时一起改这里)。
*
* pi 读出来的第二条:原来那条判据写的是 `assert.equal(glassCalls.length, 1)`,
* 断言的是"全仓一共一处 `backgroundBlurStyle`" —— 它**不是**"没有嵌套":
* 同一页面上两个**并列**的玻璃面(不嵌套,没问题)会让它红,而真正的嵌套它没在判。
* 现在只有导航条一处,所以红得对;但 P5 正是"悬浮玻璃导航",很可能撞上第二处。
*
* 到那时要**改判定形状**,不是把 1 改成 2 —— 改成 2 这条就退化成"最多两处"(等于不判)。
* 所以现在就把形状改成它真正想说的两件事:
* ① **不许嵌套**(模糊叠模糊,视觉上互相打架、性能也白花);
* ② 每一处玻璃都要**登记**(新开一处玻璃面必须显式过一道,而不是悄悄多出来)。
*/
/*
* ★★ 2026-09-19 重写(用户:「应该定义一个基础玻璃容器给各个组件引用」)。
*
* 这张名单**原来**是"允许出现玻璃的位置"白名单,逐处登记、附一句理由。
* 那个形状在"玻璃是少数例外"时成立(当时全仓只有导航条一处)。
* 但用户的要求是**全部玻璃化** —— 玻璃成了**默认**,于是白名单变成了
* "把每一处都抄一遍"的负担,而且它挡不住真正该挡的东西:
* 一个人**新写** `.backgroundBlurStyle(BlurStyle.COMPONENT_THICK)` 直接就过了,
* 因为白名单只检查"登记过的位置还在不在",不检查"材质档次从哪来"。
*
* 换成**规则**(这才是"基础组件"该有的防线):
* ① 材质档次**只能**来自 `Theme` 的两个令牌(`navMaterial` / `cardMaterial`),
* 不许在页面里直接出现 `BlurStyle.XXX` —— 否则深浅两套浓度就没人统一管了;
* ② `BlurStyle.NONE` 是**开关的另一半**(壁纸关时退回实体面),不在此限;
* ③ 仍不许嵌套(上一段那两条断言)。
*
* 「枚举挡实例,类才挡漂移」—— 这条从枚举位置改成了枚举**来源**。
*/
const MATERIAL_TOKENS = ['Theme.navMaterial', 'Theme.cardMaterial'];
/**
* 往回找"这次材质作用在哪个组件块上",返回块尾 `}` 的下标(找不到返回 -1)。
*
* ⚠️ 这里**不能只看前一个字符是不是 `}`**:ArkUI 的修饰符是链式的,
* `Row() { ... }.backgroundColor(x).backgroundBlurStyle(A)` 里 `backgroundBlurStyle` 前面是 `)`。
* 第一版就只看了一个字符,于是这种写法下"嵌套"检查**静默失效**(判 get 到 null 就放过去了)——
* 这正是"断言形状和它声称的东西不是一回事"那一类毛病,判据自检把它抓了出来。
* 现在往回扫时跳过成对的括号组(含修饰符参数),遇到 `}` 才算块尾。
*/
const blockEndBefore = (src, at) => {
let i = at - 1;
let depth = 0;
while (i >= 0) {
const c = src[i];
if (c === ')') { depth++; i--; continue; }
if (c === '(') { depth--; i--; continue; }
if (depth === 0) {
if (c === '}') return i;
if (c === '{' || c === ';') return -1; // 走到了别的结构:这次材质没作用在块上
}
i--;
}
return -1;
};
/** 用花括号配对取 span(剥过注释的源码上做;字符串里的花括号在这份代码里不出现) */
const braceSpans = (src) => {
const spans = [];
const stack = [];
for (let i = 0; i < src.length; i++) {
const c = src[i];
if (c === '{') stack.push(i);
else if (c === '}' && stack.length) spans.push([stack.pop(), i]);
}
return spans;
};
test('C|玻璃:位置用系统材质、不许叠、每一处都要登记(形状判据,不是数数)', () => {
/*
* 「用系统方案」里最容易被写歪的一处:`#B8FFFFFF` 看着也能出玻璃效果,
* 但它不跟随深色模式、也不跟随系统的模糊半径。所以钉三件事:
* ① 材质档次来自 `BlurStyle`(系统枚举);② 该玻璃化的位置真的调了 `backgroundBlurStyle`;
* ③ **不许叠**(同一组件叠两次 / 套在另一层玻璃的子树里)+ 每一处都登记。
*
* ③ 的形状是 pi 读出来的第二条:原来写的是 `assert.equal(glassCalls.length, 1)` ——
* 那断言的是"全仓一共一处",**不是**"没有嵌套":同一页面两个**并列**玻璃面会让它红
* (并列本身没问题),而真正的嵌套它没在判。P5 正是"悬浮玻璃导航",很可能撞上第二处;
* 到那时若把 1 改成 2,这条就退化成"最多两处"(等于不判)。所以现在就把形状改对。
*/
const codeOnly = stripComments(harmony);
assert.match(codeOnly, /static readonly navMaterial: BlurStyle = BlurStyle\.[A-Z_]+/, '导航材质要声明成系统材质档次');
const navMaterial = codeOnly.match(/navMaterial: BlurStyle = BlurStyle\.([A-Z_]+)/)[1];
assert.notEqual(navMaterial, 'NONE', 'NONE 等于没有材质,"玻璃"就名存实亡');
const etsRoot = join(ROOT, 'client/harmony/entry/src/main/ets');
/** 每个调用点:哪一处(文件#组件)、调用下标、它作用的**组件块**(配对出来的) */
const found = [];
for (const f of collectEts(etsRoot)) {
const src = code(f);
const spans = braceSpans(src);
for (const m of src.matchAll(/backgroundBlurStyle\(/g)) {
const at = m.index;
const bEnd = blockEndBefore(src, at);
const span = bEnd >= 0 ? spans.find(sp => sp[1] === bEnd) : undefined;
const owner = [...src.slice(0, at).matchAll(/(?:struct|@Builder\s+)\s*(\w+)/g)].pop();
found.push({
key: `${f.slice(etsRoot.length + 1)}#${owner ? owner[1] : '(未识别)'}`,
/* 所属文件:嵌套/叠用判定必须先比它(见下面那段注释) */
file: f.slice(etsRoot.length + 1),
at,
block: span ? { start: span[0], end: span[1] } : null
});
}
}
assert.ok(found.length > 0, '至少导航条要用系统材质(不是手写 alpha)');
/*
* 叠用判定(两种形态都判红):
* · 同一组件:两处调用解析到**同一个块**(`X.blur(A).blur(B)` 就是这种,ArkUI 的修饰符链
* 作用在同一个节点上 —— 注意它前面是 `)` 不是 `}`,所以按字符相邻判会漏);
* · 子树:一处的调用点落在另一处的块**内部**。
*/
const doubled = new Set();
const nested = new Set();
for (const c of found) {
for (const o of found) {
if (o === c) continue;
/*
* ★★ 2026-09-19 修:先比**文件**,再比偏移。
*
* `c.at` / `o.at` 是**各文件自己的字符下标**,之间没有可比性。
* 原来缺了这一层,于是 `MainPage.ets` 的偏移被拿去和 `SettingsPage.ets`
* 的偏移比大小 —— 判出 `MainPage#GroupHeader 内含 SettingsPage#SettingsPane 的玻璃`
* 这种**物理上不可能**的结论(一个文件不可能内含另一个文件的块)。
*
* 这一条正是本仓反复出现的形状:**判据报出来了、但它声称的东西是假的**。
* 那种假红比漏报更坏 —— 会让人去改本来对的代码(本次差一点)。
*
* WebUI 用后代选择器天然只在**同一棵 DOM** 里成立;鸿蒙没有那个机制,
* 所以文件边界就是它的等价物。
*/
if (c.file !== o.file) continue;
if (c.block && o.block && c.block.start === o.block.start && c.block.end === o.block.end) {
const pair = [c.at, o.at].sort((a, b) => a - b).join('-');
doubled.add(`${c.key}@${pair}`);
} else if (c.block && o.at > c.block.start && o.at < c.block.end) {
nested.add(`${c.key}(内含 ${o.key} 的玻璃)`);
}
}
}
assert.deepEqual([...doubled], [], `同一个组件上不该叠多层模糊:${[...doubled].join('、')}`);
assert.deepEqual([...nested], [], `玻璃不该套在另一层玻璃的子树里:${[...nested].join('、')}`);
/*
* 材质来源检查:每个 `backgroundBlurStyle(...)` 的实参要么是设计令牌,
* 要么是 `BlurStyle.NONE`("+关闭"的那一半)。
*/
const rawMaterial = [];
for (const f of collectEts(etsRoot)) {
const src = code(f);
const rel = f.slice(etsRoot.length + 1);
/*
* 参数用"配对括号"抽取,不能用 `[^)]*` ——
* `this.materialOf()` 里含一层 `()`,`[^)]*` 会在内层 `(` 处截断,
* 于是把合法写法读成 `this.materialOf(` 并判红(本次就是这么被误伤了一次)。
*/
for (const m of src.matchAll(/\.backgroundBlurStyle\(/g)) {
let i = m.index + m[0].length, d = 1, j = i;
while (j < src.length && d > 0) {
if (src[j] === '(') d++;
else if (src[j] === ')') d--;
j++;
}
const arg = src.slice(i, j - 1).trim();
/*
* 三种合法形态:
* · 直接引令牌(`Theme.cardMaterial`);
* · `BlurStyle.NONE`("关闭"的那一半);
* · 调**基础组件内部**的取值方法(`this.materialOf()`)—— 那是设计系统
* 把"选哪档"收敛到一处的手段,本身就该允许;但要求该方法体内
* 只出现令牌与 `NONE`(下面单独校验),否则等于借方法绕开这条判据。
*/
const isHelper = /^this\.\w+\(\)$/.test(arg);
const ok = MATERIAL_TOKENS.some(t => arg.includes(t)) || arg === 'BlurStyle.NONE' || isHelper;
if (!ok) rawMaterial.push(`${rel}:${src.slice(0, m.index).split('\n').length} → ${arg}`);
}
}
assert.deepEqual(rawMaterial, [],
'这些地方直接写了 `BlurStyle.XXX`,没走设计令牌:' + rawMaterial.join(' | ') +
' —— 材质档次必须来自 Theme(navMaterial / cardMaterial):' +
'深浅两套浓度由令牌统一管,写在页面里就等于没人管(本仓为此重写过一次)');
/*
* 辅助方法也要查:`this.materialOf()` 这种形态若不查它体内,
* 就等于判据开了个后门 —— 把裸 `BlurStyle.COMPONENT_THICK` 塞进方法里照样过。
* 这正是本仓反复出现的形状:**判据给了放行口,放行口本身没人管**。
*/
for (const f of collectEts(etsRoot)) {
const src = code(f);
const rel = f.slice(etsRoot.length + 1);
for (const m of src.matchAll(/(\w+)\s*\(\s*\)\s*:\s*BlurStyle\s*\{([\s\S]*?)\n \}/g)) {
const body = m[2];
for (const b of body.matchAll(/BlurStyle\.([A-Z_]+)/g)) {
const v = b[1];
if (v === 'NONE') continue;
const line = src.slice(0, m.index).split('\n').length +
body.slice(0, b.index).split('\n').length - 1;
assert.fail(
`${rel}:${line} 的 ${m[1]}() 里直接写了 BlurStyle.${v} —— ` +
'返回材质的方法体内只许引令牌或 NONE;写裸枚举等于绕开"材质来自设计令牌"这条');
}
}
}
// 导航条那一处必须真的还在(形状判定之外,位置本身也要在)
const main = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'));
assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条要用系统材质(不是手写 alpha)');
/*
* 自检:造一次**链式叠用**(`X.blur().blur()`),确认上面的判定抓得到。
* 这一枪放在判据里,是为了以后改这段扫描逻辑时它自己会被检验 ——
* 第一版只看"前一个字符是不是 }",对这种写法**静默失效**,正是这条自检抓出来的。
*/
// 样本要与**真实写法同形**(否则自检会变成"拿一段判据认不出来的代码去验判据")
const sample = 'Row() { Text("x") }\n .backgroundBlurStyle(Theme.navMaterial)\n' +
' .backgroundBlurStyle(Theme.navMaterial)';
const sampleEnds = [...sample.matchAll(/backgroundBlurStyle\(/g)].map(m => blockEndBefore(sample, m.index));
assert.ok(sampleEnds.every(e => e >= 0), '自检:链式写法要能解析出所作用的块');
assert.equal(new Set(sampleEnds).size, 1, '自检:同一组件的两处调用必须解析到同一个块(否则叠用判不出来)');
const sampleSpans = braceSpans(sample);
assert.ok(sampleSpans.some(sp => sp[1] === sampleEnds[0]), '自检:块的配对要能对上');
});
test('★ 品牌色防线:主操作色不得退化成系统强调色', () => {
/*
* 这是 pi 最担心的语义事故,我同意:改用系统方案时**顺手**把 `accent` 换成
* 系统的 emphasize 色,界面看着还挺协调 —— 但品牌蓝是**跨客户端身份**
* ("两个客户端是同一个产品"),系统强调色会随主题/厂商皮肤变,换过去这件事就靠不住了。
* 所以这条不是"取值好看",是钉住唯一必须与 WebUI 逐字一致的那一个值。
*/
assert.match(harmony, /accent: string = '#2563EB'/, '品牌蓝必须仍是 WebUI 的那个值');
assert.ok(
!/accent: Resource/.test(stripComments(harmony)),
'accent 变成系统资源了 —— 品牌色跟着系统变就不再是同一个产品的标识'
);
// 主操作/选中态仍引用它(否则"没退化"只是因为它没被用 —— 值留着也没意义)
const login = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/LoginPage.ets'));
assert.match(login, /backgroundColor\(Theme\.accent\)/, '登录按钮仍是品牌色');
const main = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'));
assert.match(main, /backgroundColor\(Theme\.accent\)/, '主操作按钮/选中态仍是品牌色');
});
test('权限档位徽标两边同一套色(plan=蓝 / workspace=绿 / full=琥珀)', () => {
// WebUI 的映射写在 PermissionChip.tsx 的类名里;鸿蒙的映射是 Theme.permBg/permFg。
const chip = code(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'));
assert.match(chip, /plan'[\s\S]{0,80}bg-blue-50 text-blue-700/, 'WebUI plan 档应是蓝');
assert.match(chip, /full'[\s\S]{0,80}bg-amber-50 text-amber-700/, 'WebUI full 档应是琥珀');
assert.match(chip, /bg-green-50 text-green-700/, 'WebUI workspace 档应是绿');
// 鸿蒙侧取的是同一批值 —— 直接和 WebUI 的 :root 变量比,防的是"看起来差不多"
const cssVar = (name, src) => {
const m = src.match(new RegExp(`--${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
assert.ok(m, `WebUI 要有 --${name}`);
return hex('#' + [m[1], m[2], m[3]].map(n => Number(n).toString(16).padStart(2, '0')).join(''));
};
const token = name => {
const m = harmony.match(new RegExp(`${name}: string = '(#[0-9A-Fa-f]{6})'`));
assert.ok(m, `鸿蒙要有令牌 ${name}`);
return hex(m[1]);
};
assert.equal(token('accentSoft'), cssVar('c-blue-50', web), 'plan 底色应与 blue-50 一致');
assert.equal(token('accentStrong'), cssVar('c-blue-700', web), 'plan 字色应与 blue-700 一致');
assert.equal(token('approveBg'), cssVar('c-green-50', web), 'workspace 底色应与 green-50 一致');
assert.equal(token('approveFg'), cssVar('c-green-700', web), 'workspace 字色应与 green-700 一致');
assert.equal(token('warnBg'), cssVar('c-amber-50', web), 'full 底色应与 amber-50 一致');
assert.equal(token('warnFg'), cssVar('c-amber-700', web), 'full 字色应与 amber-700 一致');
/*
* 往返预算条的三个档位(P2a 新加)。
*
* WebUI 的 `BudgetChip` 用的是 gray-100/gray-500(普通)、red-100/red-700(用尽)、
* orange-100/orange-700(将尽);鸿蒙的卡片视图要显示同一条预算,
* 就得取**同一批值** —— 而且这批值只在这条判据里和 WebUI 对齐,
* 否则"鸿蒙那边自己挑了个接近的红"没人会发现。
*
* ⚠️ 注意:index.css 里 `--c-gray-100` 等变量在 `.dark` 段里还有第二处定义,
* 上面的 cssVar 取的是**第一处**(`:root`,浅色主题)—— 与 `Theme.ets` 是浅色一套对应。
*/
assert.equal(token('chipNeutralBg'), cssVar('c-gray-100', web), '预算普通档底色应与 gray-100 一致');
assert.equal(token('chipNeutralFg'), cssVar('c-gray-500', web), '预算普通档字色应与 gray-500 一致');
assert.equal(token('chipSpentBg'), cssVar('c-red-100', web), '预算用尽底色应与 red-100 一致');
assert.equal(token('chipSpentFg'), cssVar('c-red-700', web), '预算用尽字色应与 red-700 一致');
assert.equal(token('chipWarnBg'), cssVar('c-orange-100', web), '预算将尽底色应与 orange-100 一致');
assert.equal(token('chipWarnFg'), cssVar('c-orange-700', web), '预算将尽字色应与 orange-700 一致');
// 反向对照:判据要真能抓到"自己挑了个接近的颜色"
const off = harmony.replace("chipSpentBg: string = '#FEE2E2'", "chipSpentBg: string = '#FEE3E3'");
assert.notEqual(
hex(off.match(/chipSpentBg: string = '(#[0-9A-Fa-f]{6})'/)[1]),
cssVar('c-red-100', web),
'自检:差一个色阶判据却还是绿的'
);
});
test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在由系统做)', () => {
/*
* 这一条也**改过口径**。旧口径要求鸿蒙照 WebUI 那样把遮罩拆成"色 + 透明度"两个令牌 ——
* 理由是遮罩色必须**随主题换向**:浅色主题用白把图案洗淡,深色主题必须换黑,
* 否则浅色照片在深色界面里糊成一块亮斑、正文读不动(WebUI 侧实测踩过)。
*
* 但"随主题换向"正是一个**系统语义色**能表达的东西:`ohos_id_color_mask_regular`
* 深浅两套值由系统给,而且不会再漏配一边 —— 比我们自己维护两个常量更不容易错。
* 所以口径改成"遮罩来自系统"(这条是硬的),WebUI 侧仍然两段式(它没有系统可跟随)。
*/
assert.match(stripComments(harmony), /overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/, '鸿蒙遮罩要用系统遮罩色');
// 不许再自己维护"色 + 透明度"两个常量(那正是系统已经替我们做掉的事)
assert.ok(!/overlayColor: string/.test(stripComments(harmony)), '遮罩色不该再由我们自己定');
assert.ok(!/overlayAlpha: number/.test(stripComments(harmony)), '遮罩透明度不该再由我们自己定');
/*
* pi 读出来的第四条:判据只断言 `overlay` **存在**,没断言它**被用** ——
* 立一个没人用的令牌是自证(判据只能验"它还在",验不了它有用)。
* 所以这里补一条:它必须在 `Theme.ets` **之外**有真实使用点,
* 否则要么删掉、要么说明它为什么该留着。
*/
const etsRoot = join(ROOT, 'client/harmony/entry/src/main/ets');
const themePath = join(HARMONY_ETS, 'common/Theme.ets');
const overlayUsers = collectEts(etsRoot)
.filter(f => f !== themePath)
.filter(f => /Theme\.overlay\b/.test(code(f)))
.map(f => f.slice(etsRoot.length + 1));
assert.ok(overlayUsers.length > 0,
'Theme.overlay 声明了却没有任何使用点 —— 那就是个死令牌(要么删掉,要么写出它的使用处)');
/*
* ★ pi 2026-09-15 的第二刀:上面这条**只覆盖了 `overlay` 一个令牌**。
* 我当时把 `MainPage` 里的 `Theme.navMaterial` 换成了另一个表达式,
* `navMaterial` 就**再没有任何使用点**了 —— 而它上面那条判据
* ("declaration 存在且不是 NONE")**照样绿**:它守的是声明,
* 坏的是那条活的调用路径。**判据名替实现作证**,我们这一路反复在消的形状。
* ⇒ 把这条规则**铺到 Theme 的每一个令牌**上(同一个文件里早就写着正确的形状,
* 只是覆盖面只有一处)。令牌只在自己的文件里被别的方法读**不算**(那是内部实现细节,
* 由那个方法自己的使用点担保)。
*/
const themeSrc = code(themePath);
const declared = [...themeSrc.matchAll(/static readonly (\w+)\s*[:=]/g)].map(m => m[1]);
assert.ok(declared.length > 20, `要从 Theme.ets 里读到令牌清单(读到 ${declared.length} 个)`);
const others = collectEts(etsRoot).filter(f => f !== themePath);
const othersSrc = others.map(f => ({ f: f.slice(etsRoot.length + 1), src: code(f) }));
/*
* ⚠️ **量的是"外部引用数"**,这一点是踩出来的:第一版我数"任何引用",
* 结果是 `chipSpentBg`/`chipSpentFg` 被判死 —— 而它们**不是**死的:它们被
* `Theme.budgetBg()` 返回,而 `budgetBg()` 在 `MainPage` 里用着
* (`Theme.budgetBg(budgetState(...))`,那个 `budgetState` 本身有真值判据)。
* 也就是说"只在 Theme 内部被别的方法读"**不算死** —— 那是内部实现细节,
* 由那个方法的**外部**使用点担保。
* 但 `navMaterial` 必须仍然**被抓**:它当时唯一的消费者是我在 `MainPage` 里
* 写的一张**局部只读表**(`BLUR_STYLE_OF`),而那张表可以整体删掉/改写
* (我真删过一次)—— 它不提供任何"担保"。
* ⇒ 区分这两者的唯一办法是**量外部引用数**:页面/组件里引用 0 次,就记一笔,
* 连同它在 Theme 内部被哪些方法读(供人判断"那个方法自己有没有人用")。
*/
const deadTokens = [];
for (const name of declared) {
const re = new RegExp(`Theme\\.${name}\\b`);
const users = othersSrc.filter(o => re.test(o.src)).map(o => o.f);
if (users.length > 0) continue;
// 在 Theme.ets 内部找"读它的那个成员":看每个 `Theme.<name>` 出现处**前面最近**的成员声明
const internalUsers = [];
const re2 = new RegExp(`Theme\\.${name}\\b`, 'g');
let m2;
while ((m2 = re2.exec(themeSrc)) !== null) {
const before = themeSrc.slice(0, m2.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 !== name && !internalUsers.includes(`Theme.${owner}`)) internalUsers.push(`Theme.${owner}`);
}
/*
* 只被 Theme 内部的方法读 **且那个方法自己在外部有调用点** ⇒ 不算死
* (`chipSpentBg` ← `Theme.budgetBg()` ← `MainPage` 的 `Theme.budgetBg(budgetState(…))`)。
* 否则仍然算死:这正是 `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 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;
}
deadTokens.push(`${name}(任何地方都没读)`);
}
assert.deepEqual(deadTokens, [],
`★ 这些 Theme 令牌在**页面/组件里一次都没被引用**:\n ${deadTokens.join('\n ')}\n` +
' 要么删掉,要么写出它的使用处;若只是被 Theme 内部的方法读,确认那个方法自己还有外部调用点。\n' +
' **这条要抓的形状**:把一根线接到别处,让某个令牌**意外变成孤儿** —— ' +
'`navMaterial` 就这么变成过孤儿(它当时唯一的消费者是 MainPage 里一张可以整体删掉的局部表),' +
'而只盯声明的判据("声明了且不是 NONE")**照样绿**。');
// 用它的必须是**自绘遮罩**的地方(系统自带遮罩的弹窗不需要它)
assert.ok(overlayUsers.every(f => f.endsWith('.ets')), `遮罩使用点应该是页面:${overlayUsers.join('、')}`);
assert.ok(!/#[0-9A-Fa-f]{8}/.test(stripComments(harmony)), '遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值');
// WebUI 侧同构:颜色两套(浅/深,同一个变量名换向)+ 透明度独立
assert.match(web, /--bg-scrim: 255 255 255/, 'WebUI 浅色遮罩色');
assert.match(web, /--bg-scrim: 0 0 0/, 'WebUI 深色遮罩色');
assert.match(web, /--bg-dim:/, 'WebUI 的透明度是独立变量');
assert.match(diffSection(), /遮罩/, '遮罩是"有意差异",要写进文档的差异表');
// 反向对照:判据要真能抓到"焊死单值"
assert.ok(rawColors("backgroundColor('#80000000')").includes('#80000000'), '自检:正则抓不到焊死的单值');
});
test('C|「有意差异」表列全了允许不同的维度,并点名品牌色**不在内**(弱判据,防遗忘)', () => {
/*
* pi 把这条定为**辅助**:文档表会过时,而过时的表照样能判绿 —— 所以它防的是
* "改了做法没改记录"(下一个人会当成漏改),不是"证明做法对"。
* 主体在代码上(A/B + 品牌色防线 + 材质位置),这张表是第三只手。
*/
const section = diffSection();
for (const dim of ['圆角', '材质', '动效', '遮罩']) {
assert.ok(section.includes(dim), `「有意差异」表里要列 ${dim}(它现在是"允许不同"的维度)`);
}
/*
* 品牌色那一行:**按行取**,不用"关键词 + 窗口"。
* 原来写的是 `/品牌色[\s\S]{0,80}不允许差异/` —— 窗口宽度是在赌表格单元格的字符数
* (品牌色行里"品牌色"与"不允许差异"隔着 WebUI/鸿蒙 两格,正好 > 80)。
* 窗口型断言和 `indexOf` 是同一类毛病:看着断言了,其实在赌排版。
*/
const brandRow = section.split('\n').find(l => /^\|\s*\**品牌色/.test(l));
assert.ok(brandRow, '「有意差异」表里应有品牌色一行(它要显式写明"不允许差异")');
assert.match(brandRow, /不允许差异/, '品牌色必须被点名"不允许差异",否则下一个人会以为它也在表内');
// 表头列名要对(这也让"拿错段落"自己红出来:拿错段落时表头不会是这个形状)
const header = section.split('\n').find(l => l.startsWith('|') && !l.includes('---'));
assert.ok(header, '差异表要有表头');
for (const col of ['WebUI', '鸿蒙', '为什么']) {
assert.ok(header.includes(col), `差异表表头要有「${col}」列,实际:${header}`);
}
// 反向对照:表里必须真的写了"为什么允许不同",不是只列个名字
const rows = section.split('\n').filter(l => l.startsWith('|') && !l.includes('---'));
assert.ok(rows.length >= 5, `差异表应有表头 + 至少 4 行,实际 ${rows.length} 行`);
assert.ok(rows.every(r => r.split('|').filter(c => c.trim()).length >= 4), '差异表每行要说清"WebUI / 鸿蒙 / 为什么"');
});
// ───────── 手写色清册**跨文件**(pi 2026-09-14:别一个文件一套枚举) ─────────
/** 从 index.css 的某个段(:root 或 .dark)里读出调色板变量 */
function cssPaletteOf(selector) {
const at = selector === ':root' ? web.indexOf(':root') : web.indexOf('.dark {');
assert.ok(at >= 0, `CSS 里要有 ${selector} 段`);
let depth = 0;
let end = at;
for (let i = web.indexOf('{', at); i < web.length; i++) {
if (web[i] === '{') depth++;
else if (web[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
}
const body = web.slice(web.indexOf('{', at), end);
const read = (name) => {
const m = new RegExp(`${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+);`).exec(body);
assert.ok(m, `${selector} 里要有 ${name}`);
return '#' + [m[1], m[2], m[3]].map(n => Number(n).toString(16).padStart(2, '0').toUpperCase()).join('');
};
return {
gray100: read('--c-gray-100'), gray200: read('--c-gray-200'),
blue100: read('--c-blue-100'), blue200: read('--c-blue-200'),
green100: read('--c-green-100'), amber100: read('--c-amber-100'), orange100: read('--c-orange-100')
};
}
test('★ 手写色清册**跨文件**:全 ets 树里每个 `X: string = \'#RRGGBB\'` 都要登记(不在某个文件里各搞一套枚举)', () => {
/*
* pi 的观察:A2 保护的是 `Theme.ets`,而 `Wallpaper.ts` 也有手写色(预设色板)——
* 那儿靠"从 CSS 读出来逐个对照"抓到了 `#BFDCFE`,手法对;
* 但如果那份对照是**按名字枚举**的,第 8 个预设色就会逃掉:
* 这正是 A2 要防的同一件事,只是换了个文件。
* 所以并成**一份清册、按类扫**:全树里每个手写色都得登记 ——
* 要么是 Theme 的品牌/业务语义色,要么是"预设色板(值由 CSS 两段比对负责)"。
* 这样"哪儿还能写死颜色"的答案是一处清册,而不是"看情况"。
*/
const tree = [];
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) walk(full);
else if (/\.(ets|ts)$/.test(e.name)) tree.push(full);
}
};
walk(HARMONY_ETS);
assert.ok(tree.length >= 20, `要扫整个 ets 树(至少 20 个文件),实际 ${tree.length}`);
const declared = [];
for (const f of tree) {
const rel = f.slice(HARMONY_ETS.length + 1);
const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
for (const m of src.matchAll(/(\w+)\s*:\s*string\s*=\s*'(#[0-9A-Fa-f]{6})'/g)) {
declared.push({ file: rel, name: m[1], value: m[2] });
}
}
assert.ok(declared.length >= 20, `应当扫到一批手写色(Theme 17 + 预设 14),实际 ${declared.length}`);
const presetNames = new Set([
'LIGHT_GRAY_100', 'LIGHT_GRAY_200', 'LIGHT_BLUE_100', 'LIGHT_BLUE_200',
'LIGHT_GREEN_100', 'LIGHT_AMBER_100', 'LIGHT_ORANGE_100',
'DARK_GRAY_100', 'DARK_GRAY_200', 'DARK_BLUE_100', 'DARK_BLUE_200',
'DARK_GREEN_100', 'DARK_AMBER_100', 'DARK_ORANGE_100'
]);
const themeNames = new Set(SELF_OWNED_COLORS);
const unregistered = declared.filter(d =>
!(d.file === 'common/Theme.ets' && themeNames.has(d.name)) &&
!(d.file === 'model/Wallpaper.ts' && presetNames.has(d.name))
);
assert.deepEqual(unregistered.map(d => `${d.file}:${d.name}=${d.value}`), [],
'这些手写色不在任何清册里 —— 要么挂进 Theme(品牌/业务语义色),要么挂进预设色板');
const byName = new Map(declared.map(d => [d.name, d]));
for (const n of themeNames) assert.ok(byName.has(n), `清册里的 ${n} 已不存在(清册过期)`);
for (const n of presetNames) assert.ok(byName.has(n), `预设色板清册里的 ${n} 已不存在`);
// 预设色板的每个值都要由 CSS 的两段兜住(LIGHT_ → :root,DARK_ → .dark)
const lightCss = cssPaletteOf(':root');
const darkCss = cssPaletteOf('.dark');
const keyOf = { GRAY_100: 'gray100', GRAY_200: 'gray200', BLUE_100: 'blue100', BLUE_200: 'blue200', GREEN_100: 'green100', AMBER_100: 'amber100', ORANGE_100: 'orange100' };
for (const n of presetNames) {
const m = /^(LIGHT|DARK)_(.+)$/.exec(n);
assert.ok(m, `预设色板常量名要带 LIGHT_/DARK_ 前缀(否则判据不知道跟哪一段比):${n}`);
const css = m[1] === 'DARK' ? darkCss : lightCss;
assert.equal(byName.get(n).value, css[keyOf[m[2]]],
`${n} 与 CSS 的 ${m[1] === 'DARK' ? '.dark' : ':root'} 段不一致`);
}
});
test('★ 断点:两端各是多少、含义是什么、差异被登记(不是"两边必须一样")', () => {
/*
* 2026-09-19 由审计发现:**两端的宽屏断点不同,而且此前没有任何地方记录过**。
*
* WebUI: `useIsNarrow.ts` 的 `NARROW_QUERY = '(max-width: 1023px)'`
* 含义 = 「三栏(60 导航 + 320 列表 + ≥520 详情 ≈ 900px,再加余量)
* 放不下就退化单栏」
* 鸿蒙: `MainPage.ets` 的 `isWide = width >= 768`
* 含义 = 「要不要显示**侧栏**」(鸿蒙的内容区是一个窗格,没有并排三栏)
*
* ★ 这条判据**不**要求两端取值相同 —— 那会是错的:它们判的本来就不是同一件事
* (一个是"三栏放不下",一个是"要不要侧栏")。这与手势阈值同一个口径:
* **语义各自成立时,数值不必强求一致**。
*
* 它要求的是三件事:
* ① 两端的取值能被**读出来**(而不是散落在魔法数字里);
* ② 两端的**含义**在注释里说清了(后人不必猜"为什么不一样");
* ③ 「两者不同」这个事实被**登记**(`docs/DEBTS.json`),
* 否则下一个人只会当成漏改 —— 这正是它被发现时的状态。
*/
const narrowHook = prose(join(ROOT, 'client/electron/src/hooks/useIsNarrow.ts'));
/*
* ★ 读**原文**(`prose`)而不是剥注释版:本条要判的正是"理由有没有写在代码旁",
* 而理由天然在注释里。用 `code()` 会把注释剥掉 ⇒ 永远红。
* (这与 `criteria-hygiene` 那条"判代码用 code、判理由用 prose"是同一条纪律,
* 我在本文件里又踩了一次。)
*/
const mainPage = prose(join(HARMONY_ETS, 'pages/MainPage.ets'));
/* ① 两端取值可读 */
const webMq = /NARROW_QUERY\s*=\s*'\(max-width:\s*(\d+)px\)'/.exec(narrowHook);
assert.ok(webMq, 'WebUI 的窄屏断点要能从 `NARROW_QUERY` 读出来');
const webMax = Number(webMq[1]);
const hWide = /this\.isWide\s*=\s*\(newValue\.width as number\)\s*>=\s*(\d+)/.exec(mainPage);
assert.ok(hWide, '鸿蒙的宽屏阈值要能从 `onAreaChange` 里读出来');
const hMin = Number(hWide[1]);
assert.ok(webMax > 0 && hMin > 0, '两端断点都应是正数');
/* ② 含义写清了(这是本条判据的主要价值:把"为什么不同"钉在代码旁) */
assert.match(narrowHook, /三栏|导航.*列表.*详情/,
'★ WebUI 侧要说明这个断点的**依据**(它是按"三栏放不下"定的)');
assert.match(mainPage, /侧栏|宽屏模式/,
'★ 鸿蒙侧要说明这个阈值判的是什么(「要不要显示侧栏」)');
/* ③ 差异被登记 */
const debts = prose(join(ROOT, 'docs/DEBTS.json'));
assert.match(debts, /wide-breakpoint-divergence/,
'★ 「两端断点不同」必须登记在 docs/DEBTS.json —— '
+ '它被发现时**没有任何地方记录**,下一个人只会当成漏改');
/* ④ 反向断言:万一以后有人"统一"了,登记不该变成化石 */
if (webMax - 1 === hMin || hMin === 1024) {
assert.match(debts, /wide-breakpoint-divergence[\s\S]{0,400}?(已统一|统一到)/,
'★ 两端断点看起来已经一致了 —— 那就该在登记里写明"已统一",'
+ '否则这条登记会变成没人在核的化石(登记也该跟着事实走)');
}
});
test('★ 品牌浅底必须有深色变体(深色下白底卡片 = 刺眼的 bug)', () => {
/*
* ★★ 2026-09-19 设备实测撞出来的真 bug:
* 深色主题下「多账号」里**选中**的那张卡片仍是接近纯白的浅蓝
* (`Theme.accentSoft = #EFF6FF` 是写死的),在深色页面上刺眼得像渲染错误。
*
* 根因:WebUI 靠 **CSS 变量在 `.dark` 段反转发**解决
* (`index.css:113` 的 `--c-blue-50: 239 246 255` → `:475` 的 `28 37 54`),
* 而 ArkTS 的 `static readonly` **没有那层机制** —— 一个常量一个值。
*
* 修法:显式提供深色取值 + 一个按当前主题选值的入口
* (`Theme.accentSoftFor(dark)`),**不让页面各自 `isDark ? a : b`**
* —— 那样每处都会各写一遍,迟早漏一处。
*
* 判据断三件事:
* ① 深色变体存在,且**取值来自 WebUI 的 `.dark` 段**(不是随手挑一个深色);
* ② 有按主题选值的入口(页面不该自己写三元);
* ③ 用到它的地方**真的走那个入口**(定义了不接 = 那处深色下照样刺眼)。
*/
const themeSrc = prose(join(HARMONY_ETS, 'common/Theme.ets'));
const webCss = prose(join(ROOT, 'client/electron/src/index.css'));
/* ① WebUI 深色下的 blue-50 是权威值 */
const darkBlue50 = /\.dark\s*\{[\s\S]*?--c-blue-50:\s*(\d+)\s+(\d+)\s+(\d+)/.exec(webCss);
assert.ok(darkBlue50, 'WebUI `.dark` 段要有 `--c-blue-50`(品牌浅底的深色取值)');
const [r, g, b] = [darkBlue50[1], darkBlue50[2], darkBlue50[3]].map(Number);
const hex = '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0').toUpperCase()).join('');
const darkVar = new RegExp(`static readonly accentSoftDark: string = '${hex}'`, 'i');
assert.match(themeSrc, darkVar,
'★ 鸿蒙要有 accentSoftDark = ' + hex + '(对齐 WebUI 的 .dark --c-blue-50)—— ' +
'深色下用写死的浅底会让选中卡片在深色页上刺眼');
/* ② 有按主题选值的入口 */
/*
* ★ 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'));
const offenders = [];
for (const f of pages) {
const src = code(join(HARMONY_ETS, 'pages', f));
/*
* 找"把 accentSoft 当背景色用"的地方。用 accentSoftFor 的**不算**。
* 只看 backgroundColor(...),因为 accentSoft 也可以当文字色
* (那种场景下深浅色差异不刺眼,不在本条范围)。
*/
for (const m of src.matchAll(/backgroundColor\(([^)]*Theme\.accentSoft\b[^)]*)\)/g)) {
const line = src.slice(0, m.index).split('\n').length;
offenders.push(`${f}:${line}`);
}
}
assert.deepEqual(offenders, [],
'★ 这些地方还在直接把浅色 accentSoft 当背景(深色下会刺眼):\n ' +
offenders.join('\n ') +
'\n改成 Theme.accentSoftFor(this.isDarkNow)(页面要有一个 isDarkNow 状态)');
});
test('C2|`*For()` 入口只能用于**前景**,不得当背景(我批量替换时真犯过)', () => {
/*
* ★★ 这条是给**我自己**写的 —— 2026-09-19 我批量把裸 `Theme.accent`
* 换成 `Theme.accentFor()` 时,把**6 处 `backgroundColor` 也一起换了**。
*
* 为什么那是 bug:`accentFor()` 在深色下返回**浅蓝** `#80AFF9`,
* 而它的搭档前景是 `accentFg`(白)——
* 白字压浅蓝底 ≈ 1.4:1,主按钮上的文字会**彻底看不见**。
*
* ★ 修法不是"下次小心点":批量替换一定会再犯。
* 在 `backgroundColor(Theme.XFor(...))` 这个形状上直接判红,
* 代价一行,收益是这类错误不可能再出现。
*
* 适用对象:所有 `*For()` 主题自适应入口 —— 它们的返回值都是
* **为前景调过亮的**(`accentFor` / `dangerFor` / `approveFor` / `warnFgFor`)。
* `accentSoftFor` 是例外:它本来就是**面**(浅底),当背景才是对的。
*/
const FG_ONLY = ['accentFor', 'dangerFor', 'approveFor', 'warnFgFor', 'textSubtleFor'];
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 rel = f.replace(HARMONY_ETS, '');
src.split('\n').forEach((ln, i) => {
for (const name of FG_ONLY) {
if (new RegExp(`backgroundColor\\(\\s*Theme\\.${name}\\(`).test(ln)) {
bad.push(`${rel}:${i + 1} ${ln.trim().slice(0, 90)}`);
}
}
});
}
assert.deepStrictEqual(bad, [],
'★ 这些地方把**为前景调亮的** `*For()` 入口当成背景色用了:\n ' + bad.join('\n ') + '\n' +
' 它们返回的是"压在这类底上的字色"(深色下更亮),当**底**会把上面的文字吞掉\n' +
' (实测形状:`accentFor()` 深色给 #80AFF9,白字压上去 ≈1.4:1)。\n' +
' 当背景用**不带 For 的那个**:`Theme.accent` / `danger` / `approve` / `warnFg`\n' +
' (WebUI 的实心按钮底 `--s-*` 两个主题同值,跟着变会让主按钮失去视觉重量)。');
});
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,它的注释原话就是"品牌底上的文字")。');
});
/**
* 两边都用**且都合理**的令牌 —— 逐条给理由,别只列名字。
*
* ★ 目前**空**:这类令牌满足"会翻转的 Resource"且两边都用的组合,
* 本身就很可疑(那正是 `Theme.surface` 出问题的形状)。
*
* ★ 已审过、**不需要**列在这里的(说明为什么它们不该进这张表):
* · `accent` / `danger` / `approve` / `warnFg` —— 两边都用是正常设计
* (蓝底白字主按钮 + 白底蓝字返回箭头),而且它们是**字符串常量**,
* 不跟随主题翻转 ⇒ 不满足条件 A,判据本来就不会碰它们。
* · `textPrimary` / `textMuted` / `textSubtle` —— 只当字色,不满足条件 B。
*/
const ALLOW_BOTH_BG_AND_FG = new Set([]);
test('C|「会跟随主题翻转的 Resource」不得当前景色(含矛盾信号)', () => {
/*
* ★★ 这是设备判据(上面那条)抓到的 bug 的**静态防线** ——
* 设备条只能看一处(悬浮球),而这个错法当时有 **12 处**。
*
* 错法(真实现场):`Theme.surface` 是
* `sys.color.ohos_id_color_list_card_bg` —— 一个**跟随系统主题翻转**的
* Resource(浅色近白 / **深色近黑**)。它被当成"压在彩色底上的字/图标色"用:
* · 浅色下碰巧对(白字压蓝底)
* · **深色下字变黑**,压在品牌蓝/红/绿底上几乎看不见
* 正确的前景色是 `Theme.accentFg`(`'#FFFFFF'`,字符串常量,不翻转)。
*
* ★★ 判据形状的**三次迭代**(都写下来,因为每一次都错得很典型):
*
* ① grep `fontColor(Theme.surface)` —— **一个名字**。
* 错在这是死名单:以后加了新令牌它不会跟上(本仓已记录过多次)。
*
* ② 从 `Theme.ets` 读"所有 `sys.color.*` 的 Resource",一律不许当前景色。
* **太宽** —— 实测立刻报出 20+ 处"违规",全是
* `Theme.textMuted` / `textSubtle` / `textPrimary`。
* 那些**本来就该**当前景色(它们的语义就是文字色,跟随主题翻转是对的)。
* ⇒ 只按"是不是 Resource"判,把正确用法也判红 ⇒ 假红一片。
*
* ③(本版)判**矛盾**:同一令牌既当 `backgroundColor` 又当
* `fontColor/iconColor` ⇒ 它同时被当成"面"和"字",其中一种必然错。
* 这个信号从**实际用法**推出,不依赖任何名单。
*
* ★ 但④实测又发现它**太宽**:`accent` / `danger` / `approve` / `warnFg`
* 这些**饱和的字符串常量**色两边都用是正常设计(蓝底白字的主按钮 +
* 白底蓝字返回箭头)。它们和 `surface` 的**关键区别**是:
*
* · `surface`(危险):**中性面**(近白/近黑,"承载内容的底板")
* + **跟随主题翻转**。当字色 = 把底板当墨水 ⇒ 深色下看不见。
* · `accent`(正常):**饱和色**(品牌/语义身份)+ **字符串常量**
* (不翻转)。两边都站得住。
*
* ⇒ 最终判据 = **翻转的 Resource** ∩ **既当背景又当前景**。
* 两个条件都要,缺一个都会假红:
* · 只要"翻转" ⇒ 把 `textPrimary` 判红(假红)
* · 只要"矛盾" ⇒ 把 `accent` 判红(假红)
* 两个都要 ⇒ 恰好命中 `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);
/*
* 条件 A:从 `Theme.ets` 读出**会翻转的**令牌 —— 值是 `Resource`
* 且指向 `sys.color.*`(跟随系统主题)。
*/
const themeSrc = prose(join(HARMONY_ETS, 'common/Theme.ets'));
const flip = new Set();
for (const m of themeSrc.matchAll(
/static readonly ([A-Za-z0-9_]+)\s*:\s*Resource\s*=\s*\$r\('(sys\.color\.[^']+)'/g)) {
flip.add(m[1]);
}
assert.ok(flip.size >= 5,
`要从 Theme.ets 读出「会翻转的令牌」名单(实测 ${flip.size} 个)—— ` +
'读出 0 个说明正则是错的,那样下面的检查恒绿。');
/* 条件 B:收集每个令牌"当背景 / 当前景"的首个出处 */
const asBg = new Map();
const asFg = new Map();
for (const f of files) {
const src = prose(f); // 保留注释:注释里举例不算错
const rel = f.replace(HARMONY_ETS, '');
src.split('\n').forEach((ln, i) => {
const where = `${rel}:${i + 1} ${ln.trim().slice(0, 80)}`;
for (const m of ln.matchAll(/backgroundColor\(Theme\.([A-Za-z0-9_]+)\)/g)) {
if (!asBg.has(m[1])) asBg.set(m[1], where);
}
/*
* ★★ 提取时**不能要求令牌是唯一实参** —— 这一版原来用
* `\.fontColor\(Theme\.(\w+)\)`,于是**三元里的令牌全被漏掉**:
*
* .fontColor(this.appearanceTheme === t ? Theme.surface : Theme.textPrimary)
*
* 实测代价:`SettingsPage.ets:834` 正是这个形状(`surface` 当字色压在
* `accent` 蓝底上),而判据**照样绿** —— 直到设备扫描报出
* `深色 2.93:1 ink rgb(32,34,36) bg rgb(34,96,228)` 才发现。
*
* ⇒ 改成"在 `fontColor(` 之后的**整段实参**里找所有 `Theme.X`"。
* 判据漏报的形状往往就是它**自己的正则太窄**,而不是被测代码太隐蔽。
*/
for (const m of ln.matchAll(/\.fontColor\(/g)) {
const rest = ln.slice(m.index + m[0].length);
/* 只取到本行结束(这些调用都在一行内闭合) */
for (const t of rest.matchAll(/Theme\.([A-Za-z0-9_]+)/g)) {
if (!asFg.has(t[1])) asFg.set(t[1], where);
}
}
/* `iconColor: X` 同理 —— 冒号后整段都可能有三元 */
for (const m of ln.matchAll(/iconColor:/g)) {
const rest = ln.slice(m.index + m[0].length);
for (const t of rest.matchAll(/Theme\.([A-Za-z0-9_]+)/g)) {
if (!asFg.has(t[1])) asFg.set(t[1], where);
}
}
});
}
assert.ok(asBg.size >= 5 && asFg.size >= 5,
`要能从源码读出背景色/前景色两边的用法(实测 ${asBg.size} / ${asFg.size})—— ` +
'有一边读出 0 个说明正则是错的,那样下面的"交集"恒空、判据恒绿。');
/*
* 判决 = A ∩ B ∩(不在允许清单里)。
* 允许清单目前**空**:修完 `surface` 之后,没有任何"翻转的 Resource"
* 被当成前景色用过。留空表比留理由表更好读 —— 它直接说明"没有例外"。
*/
const conflicts = [];
for (const name of asFg.keys()) {
if (!flip.has(name)) continue; // 条件 A:必须是会翻转的 Resource
if (!asBg.has(name)) continue; // 条件 B:必须也被当背景用过(矛盾)
if (ALLOW_BOTH_BG_AND_FG.has(name)) continue;
conflicts.push({ name, bg: asBg.get(name), fg: asFg.get(name) });
}
assert.deepStrictEqual(conflicts, [],
'★ 这些令牌**同时**满足两个条件 ⇒ 是 `surface` 那一类错:\n' +
' A. 会跟随系统主题翻转的 `sys.color.*` Resource(浅色近白 / 深色近黑)\n' +
' B. 既当 `backgroundColor` 又当 `fontColor/iconColor` —— 被当成"面"也被当成"字"\n' +
' 浅色下碰巧对,**深色下字/图标几乎看不见**(2026-09-19 真撞到 12 处)。\n' +
' 压在彩色底上的前景请用 `Theme.accentFg`(#FFFFFF,字符串常量、不翻转)。\n' +
' 若某个令牌**确实**两边都该用(有具体理由),加进 `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));
/*
* ★★ 两个主题**都要判**(第一版只判深色,理由写的是"浅色下这些令牌本来就是
* 为浅底配的,量它们没有信息量" —— **那个理由是错的**)。
*
* 切到浅色复扫才发现的实例:浅色 `textSubtle` = `rgb(153,153,153)` 压白底
* = **2.85:1**,而 WebUI 浅色的 gray-400 是 4.83:1。
* ⇒ "浅色下没问题"是我自己推的,不是量出来的。
*
* ★ 纪律:**色令牌的可读性是两个主题各自的事**,不能只验一半。
* 现在把当前主题**记下来**,两个主题都扫;判据只看"有没有低对比",
* 不关心是哪个主题(两种都是错)。
*/
const img0 = D.readPixels(await D.screenshot(hdc, '/tmp/theme-legibility.png'));
assert.ok(img0, '要能截屏并解码');
const corner = img0.at(4, 4);
const isDark = corner && corner.r < 128 && corner.g < 128 && corner.b < 128;
const themeName = isDark ? '深色' : '浅色';
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;
/*
* ★★ 第三版取样法(前两版都错,记下来):
*
* ① 只取**中心一个像素** ⇒ 多半落在笔画之间,读到的是底色 ⇒
* ratio≈1.00 的假红一片。
* ② **全局最暗/最亮**(我上一版)⇒ 会采到**两个不同东西**:
* 细字的抗锯齿**中间色**(既非墨也非底)。
* 实测假红:「日」按钮报 ink `rgb(32,34,35)` / bg `rgb(98,100,101)`
* 2.69:1 —— 而截图显示那个按钮要么是蓝底白字、要么是深底浅字,
* 两种都远高于 3:1。`rgb(98,100,101)` 是白字压在深色上的**边缘混色**。
* ★ 反复核对才确认是**判据自己的错**,不是界面的错 ——
* 这正是"假红比漏报更费时间"的又一例。
* ③(本版)**众数 = 底色**,再找离它最远的颜色当墨。
* 理由:一个文字框里**面积最大的一定是底**(笔画只占少数像素),
* 而"离底色最远"就是笔画最实的那部分 —— 不会再被边缘混色骗。
* 这一版对"细字 + 粗采样"稳,且不依赖步长调得多细。
*/
/*
* ★★ 第四版:**逐像素**(不再抽步长)。
*
* 第三版虽然避免了"取中心/取全局极值",但仍然**步长采样** ——
* 于是第三次假红:周表头的「一」报 2.03:1,而逐像素一看,
* 真正的墨是 `rgb(166,167,167)`(**6.6:1,完全没问题**),
* 我的稀疏网格**整个跳过了笔画**,只采到抗锯齿的中间色。
*
* ⇒ 教训:**小字 + 稀疏采样 = 采不到字**。笔画宽度可能只有 2–3px,
* 而步长 9 会稳定地跨过去。这不是调参能修好的(字越小越糟),
* 只能逐像素。
*
* ★ 逐像素现在是**负担得起的**:整屏已经解码在内存里(`readPixels`),
* 一个框最大 700×130=9.1 万像素,几十个框也就几百万次数组下标读取 ——
* 毫秒级。**不要**为了省这点开销再退回抽样(前两版都因此假红)。
*/
const counts = new Map();
for (let y = y1 + 1; y < y2; y++) {
for (let x = x1 + 1; x < x2; x++) {
const p = img0.at(x, y);
if (!p) continue;
/* 量化到 8 级,避免抗锯齿把众数切碎(同色系会被合成一档) */
const key = `${p.r >> 5},${p.g >> 5},${p.b >> 5}`;
const e = counts.get(key);
if (e) { e.n++; }
else { counts.set(key, { n: 1, r: p.r, g: p.g, b: p.b }); }
}
}
if (counts.size < 2) continue;
const sorted = [...counts.values()].sort((a, b) => b.n - a.n);
const bg = sorted[0];
const bgL = lum(bg);
/* 离底色最远的那个(按对比度算,而不是按亮度差 —— 亮度差会偏向同向的色) */
let ink = null;
let best = 0;
for (const c of sorted.slice(1)) {
const L = lum(c);
const r = (Math.max(L, bgL) + 0.05) / (Math.min(L, bgL) + 0.05);
if (r > best) { best = r; ink = { L, p: c }; }
}
/* 没有真实墨迹对比的框跳过(见上面 ③) */
if (!ink || best < 1.2) continue;
scanned++;
const ratio = best;
const lo = { L: Math.min(ink.L, bgL), p: ink.L < bgL ? ink.p : bg };
const hi = { L: Math.max(ink.L, bgL), p: ink.L < bgL ? bg : ink.p };
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, [],
`★ ${themeName}主题下这些文字读不动(对比度 <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 深色没调亮。');
});