/** * 两个客户端必须用**同一份设计词表**。 * * 用户下一步要求:「同步 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', // textSubtle 已移除:系统三级色浅色下 2.85:1,且本机二级色同值 // (见 SELF_OWNED_COLORS 里 textSubtleLight/Dark 那段),小字走 textSubtleFor()。 textPrimary: 'color', textMuted: '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', /* * ★★ 2026-09-20 补登记(对齐 WebUI 的「选中态」两个 token)。 * * WebUI `MailList.tsx:280` 的选中态是 **底 + 边成对**: * active ? 'bg-blue-50 border-blue-200' : 'border-transparent ...' * · 底 blue-50 = #EFF6FF(= 我们的 `accentSoft`) * · 边 blue-200 = #BFDBFE(= 这里的 `accentEdge`) * * 为什么需要单独的"边"token:选中与未读的**底色相同**(都是 accentSoft), * 仅靠底色的话"选中一封未读邮件"看不出任何变化 ⇒ 必须靠那圈边区分。 * 我第一版只搬了底忘了边,选中态淡到在浅壁纸上几乎看不见。 * 深色变体 `accentEdgeDark` 对齐 WebUI `.dark` 段的 `--c-blue-200`。 */ 'accentEdge', 'accentEdgeDark', /* * ★★ 2026-09-21 补登记:`glassCard` / `glassCardWall`。 * * 这两个是卡片底 —— 与 WebUI `.glass-card` **逐字同源**: * index.css:1629 .glass-card { background-color: rgb(255 255 255 / 0.92) } * index.css html[data-bg='on'] … { background-color: rgb(255 255 255 / 0.78) } * ⇒ glassCard = #EBFFFFFF(0.92×255 ≈ 235 = 0xEB) * ⇒ glassCardWall = #C7FFFFFF(0.78×255 ≈ 199 = 0xC7) * * 为什么必须是**自己写**的色而不是 `$r('sys.*')`: * 系统材质(`bgMaterial*`/`compBackground*`)是**不透明**的整块色板, * 没有"白 92% 叠在壁纸上"这一档。而 WebUI 的玻璃观感**正是**这个 alpha * —— 它不是模糊(`.glass-card` 全仓没有 `backdrop-filter`)。 * 换成系统材质 ⇒ 壁纸被整块盖死,就是用户报的「玻璃不透明」。 * * 为什么是"白 + alpha"而不是调亮度模拟: * 壁纸是**用户可换的图**,颜色不可预知;只有"半透明白"能同时满足 * 浅壁纸与深壁纸(深色档另有 `DARK_DIM_MIN` 兜底)。 */ 'glassCard', 'glassCardWall', /* * ★★ 2026-09-21 补登记:深色档的玻璃 alpha。 * * 与上面两个是**同一个理由的正反两面**:WebUI 深色下把白纱 alpha * 从 0.92 降到 0.06(`.dark { --glass-card-a: 0.06; }`), * 因为"白基材恒白、主题之间只差 alpha"。 * * 不登记的话界面会彻底变暗 —— 而这件事**实测抓到过**: * 深色 + aurora 壁纸下,卡片缝隙实测 `rgb(199,199,199)` * (= 0.78×255,那层白纱把黑壁纸糊成浅灰), * 用户看到的"没有玻璃效果"就是这个。 */ 'glassCardDark', 'glassCardWallDark', // 业务语义色:系统没有对应物(同意 / 拒绝 / 警示) 'approve', 'danger', 'approveBg', 'approveFg', 'dangerBg', 'warnBg', 'warnFg', /* * ★★ 2026-09-21 补登记:语义**底**色的深色档(与上面的前景变体同一族)。 * * 这是用户「你看看这可读性」那轮撞出来的:`dangerBg`(#FEF2F2)/`approveBg`(#F0FDF4)/ * `warnBg`(#FFFBEB)/`chipSpentBg`(#FEE2E2)/`chipWarnBg`(#FFEDD5) * 全是**只有浅色档**的手写色 —— 深色下这些淡底依然是亮的, * 而上面的前景已经被提亮成浅色 ⇒ 「浅底 + 浅字」。 * * 实测同形的另一处:邮件详情的元信息行(`chipNeutralBg` #F3F4F6) * 压深色背景,字与底 **1.02:1**。 * * 为什么是「自己写」而不是 `$r('sys.*')`: * 系统语义色(`sys.color.warning` 等)是**前景/强调**色,没有 * "淡底"这一档,更没有深浅两套淡底。而 WebUI 的 `.dark` 段 * (`index.css:486-520`)**明确**给这些淡底换了深色值 * (red-50 → `46 31 37` 等)⇒ 这是跨端身份,必须逐字对齐。 * * 值取自 WebUI `.dark` 段(括号内是对应的 tailwind 档): * dangerBgDark #2E1F25 = red-50 46 31 37 * approveBgDark #192C27 = green-50 25 44 39 * warnBgDark #2E281F = amber-50 46 40 31 * chipSpentBgDark #3A2227 = red-100 58 34 39 * chipWarnBgDark #3C291F = orange-100 60 41 31 * chipNeutralBgDark #20242C、chipNeutralFgDark #9BA3B0 * chipSpentFgDark #F8A4A4、chipWarnFgDark #FBA86F */ 'dangerBgDark', 'approveBgDark', 'warnBgDark', 'chipSpentBgDark', 'chipWarnBgDark', 'chipNeutralBgDark', 'chipNeutralFgDark', 'chipSpentFgDark', 'chipWarnFgDark', // 权限档位与预算档位的胶囊配色(档位是产品语义,系统不认识"plan/workspace/full") 'chipNeutralBg', 'chipNeutralFg', 'chipSpentBg', 'chipSpentFg', 'chipWarnBg', 'chipWarnFg', /* * ★★ 2026-09-26 补登记:小字那一档(`textSubtleLight` / `textSubtleDark`)。 * * 为什么必须是**自己写**的色,而不是 `$r('sys.color.ohos_id_color_text_*')`: * * 2026-09-19 这条设备判据已经抓到过「浅色三级色 = rgb(153,153,153) 压白底 * = 2.85:1」,当时的修法是让 `textSubtleFor()` 两个主题都返回 `textMuted` * (`ohos_id_color_text_secondary`),理由是"二级色语义对得上"。 * * ★★ 但 2026-09-26 复扫证明**那个前提不成立**:本机 HarmonyOS 6.1.1 上 * `ohos_id_color_text_secondary` 实测**也是 rgb(153,153,153)**,与三级色同值 * ⇒ 换过去之后**一点没变**,判据继续红。 * * 系统二级/三级色只保证「比一级淡」这层**层次关系**,不保证 WCAG 下限; * 同一个坑在本文件已经犯过三次(`surface` 当前景色、accent 深色没调亮、 * 这一次)。而 WebUI 那边 gray-400/gray-500 是**两套主题各自适配过**的值 * (浅 4.83:1 / 深 5.51:1),不是同一个色翻个主题。 * * ⇒ 这一档的"跨端身份"就是 WebUI 的 gray-400,所以按 gray-400 逐字对齐, * 并给出两个主题各自实测 ≥3:1 的值。 */ 'textSubtleLight', 'textSubtleDark', /* * ★★ 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)= 手写玻璃/手写遮罩那一类,**原则上**不许。 * * ★★ 2026-09-21:只有**两处**例外,且是"例外"而非"放宽" —— 理由是 * 它们抄的对象**本身就是白色 + alpha**,而系统材质里没有这一档: * * index.css:1629 .glass-card → rgb(255 255 255 / 0.92) * index.css html[data-bg='on'] … → rgb(255 255 255 / 0.78) * * 系统材质(`bgMaterial*` / `compBackground*`)是**不透明**的整块色板。 * 若把它们换成系统材质,壁纸会被整块盖死 —— 那正是用户报的 * 「界面完全不透明,不显示背景」「玻璃也不透明」。 * * ★ 为什么不允许"随手加一个":例外是**枚举**的(下面这个白名单), * 不是"只要是玻璃就放行"。加第三个半透明色 ⇒ 这条判据照样红, * 而那时得先回答"系统为什么没有对应物"这个问题。 * * ★★ 2026-09-21 补登记:`glassCardDark` / `glassCardWallDark`。 * * 这一条判据**真的抓到过东西**:我给深色加两个更薄的 alpha 时它立刻变红, * 逼我把"为什么系统没有对应物"重新回答一遍(而不是默默放行)。那个回答是: * * · 系统材质(`bgMaterial*` / `compBackground*`)是**不透明**整块色板, * 没有"白 6% 叠在壁纸上"这一档; * · 而 WebUI 深色档的玻璃观感**正是这个 alpha** * (`.dark` 里 `--glass-card-a: 0.06`,`--glass-card-wall-a: 0.04`)—— * 浅色是 0.92 / 0.78,**深浅差 15 倍**。 * * 所以深色这两个值与浅色那两个是**同一个理由**(同一条豁免的正反两面), * 不是"又开了个口子"。 * * ⇒ glassCardDark = #0FFFFFFF(0.06×255 ≈ 15 = 0x0F) * ⇒ glassCardWallDark = #0AFFFFFF(0.04×255 ≈ 10 = 0x0A) * * ★ 白名单项与 `SELF_OWNED_COLORS` 是**两个不同的问题**: * 那个问"色值有没有登记理由",这个问"半透明这件事合不合法"。 */ const TRANSLUCENT_OK = ['#EBFFFFFF', '#C7FFFFFF', '#0FFFFFFF', '#0AFFFFFF']; // 玻璃卡 浅/深 两档 const translucent = rawColors(codeOnly) .filter(h => h.startsWith('#') && h.length === 9) .filter(h => !TRANSLUCENT_OK.includes(h.toUpperCase())); assert.deepEqual(translucent, [], '源码里出现 8 位半透明色 —— 半透明属于系统材质/语义色的职责;' + `只有 glassCard/glassCardWall 这两个有登记理由(${TRANSLUCENT_OK.join(' ')})`); // 鸿蒙侧不写 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` 是**开关的另一半**(壁纸关时退回实体面),不在此限; * ③ 仍不许嵌套(上一段那两条断言)。 * * 「枚举挡实例,类才挡漂移」—— 这条从枚举位置改成了枚举**来源**。 */ /* * 合法令牌**前缀**(不是逐字清单):材质档次那两个 + 参数化玻璃那一组 * (`glassCardRadius` / `glassCardSaturation` / `glassPane*`)。 * 用前缀匹配而不是枚举全名:加了新的玻璃令牌(`glassCardBrightness` 之类) * 不该因为"忘了同步清单"而判红 —— 那会让判据变成维护负担, * 而它真正要挡的是**裸枚举/裸数字**。 */ const MATERIAL_TOKEN_PREFIXES = ['Theme.navMaterial', 'Theme.cardMaterial', 'Theme.glassCard', 'Theme.glassPane']; /** * 往回找"这次材质作用在哪个组件块上",返回块尾 `}` 的下标(找不到返回 -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(` 并判红(本次就是这么被误伤了一次)。 */ /* * ★★ 2026-09-19 修漏洞:这条原来只扫 `backgroundBlurStyle`, * 而玻璃改成 `backgroundEffect({radius, saturation})` 之后(对齐 WebUI 的 * `blur(18px) saturate(1.5)` —— 那个 saturate 才是玻璃感的来源), * **整类新写法都不在这条判据的视野里**。 * * 实测证据:往 `GlassCardModifier` 里塞一行 * instance.backgroundEffect({ radius: 99, saturation: 9.9 }); * 判据**照样全绿**(同时红的两条是"不该有 rgb()"与"令牌没被引用", * 与本条无关)⇒ 这正是一条**自称在管材质来源、实际只管了一半**的判据。 * * 本仓反复出现这个形状:"判据锚在**某一处实现**上,而不是**那条规则**上" —— * 所以实现一换(哪怕换得更对),防线就静默消失了。 * 现在两个 API 一起扫。 */ for (const m of src.matchAll(/\.(?:backgroundBlurStyle|backgroundEffect)\(/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++; } /* * ★ 参数可能是**对象字面量**(`backgroundEffect({ radius: … })`), * 直接 `trim()` 会把「{」也算进实参,于是"没走设计令牌"那条会误报 * (本次就是这么被误伤:`common/Surface.ets:416 → {`)。 * 规整掉外层花括号再看里面引了什么。 */ let arg = src.slice(i, j - 1).trim(); if (arg.startsWith('{') && arg.endsWith('}')) arg = arg.slice(1, -1).trim(); /* * 三种合法形态: * · 直接引令牌(`Theme.cardMaterial`); * · `BlurStyle.NONE`("关闭"的那一半); * · 调**基础组件内部**的取值方法(`this.materialOf()`)—— 那是设计系统 * 把"选哪档"收敛到一处的手段,本身就该允许;但要求该方法体内 * 只出现令牌与 `NONE`(下面单独校验),否则等于借方法绕开这条判据。 */ const isHelper = /^this\.\w+\(\)$/.test(arg); const ok = MATERIAL_TOKEN_PREFIXES.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)'); /* * ══════════════════════════════════════════════════════════════════════ * ★★ 2026-09-21 **反转**:卡片**不该**有模糊;模糊只属于导航/控件层 * ══════════════════════════════════════════════════════════════════════ * * 这条原先断言的是反面: * assert.ok(eff.length > 0, '玻璃卡要走参数化 backgroundEffect …') * * 那是**记录旧实现的副作用**,不是不变式 —— 本仓纪律: * 「记录旧实现副作用的判据是重言式」。旧实现确实给卡片挂了 * `backgroundEffect({radius:18, saturation:1.5})`,于是判据照着它写。 * * 但那个参数是**底部导航条那一档**(WebUI `.narrow-nav` 的 * `blur(18px) saturate(1.5)`,`index.css:1204`),被我错安到了卡片上。 * * ── WebUI 的事实(逐字读它的 CSS)── * * 全仓 `backdrop-filter` **只有两处**: * html[data-bg='on'] .glass-control → blur(8px) saturate(1.1) * .narrow-nav → blur(18px) saturate(1.5) * 而 `.glass-card`(`index.css:1629`)**完全没有 backdrop-filter**: * .glass-card { background-color: rgb(255 255 255 / 0.92); } * html[data-bg='on'] … { background-color: rgb(255 255 255 / 0.78); } * 它只有 radius + border + bg + transition。 * * ⇒ 卡片的"玻璃感"来源是**半透明白**,不是模糊。给卡片加模糊的后果 * 已实测:叠加成灰雾 + 掉帧,且用户报「邮件看着没有玻璃效果」。 * * ── 所以现在钉的是这个方向 ── * * ① 卡片层(Surface.ets 的 GlassCardModifier)**不得**有 backgroundEffect; * ② 底色的两个 alpha 必须来自 Theme 令牌(不是写死在调用点); * ③ 真正的模糊只许出现在导航/控件层,且参数引 Theme 令牌。 * * ★ 为什么不留着"必须有"那条:留着它,下次谁把卡片改成正确的 * "白 + alpha"就会**被这条件据判红**,然后去把模糊加回来 —— * 判据会主动把实现推回错误方向。这比没有判据更坏。 */ const surfaceSrc = code(join(HARMONY_ETS, 'common', 'Surface.ets')); /* ① 卡片构造器里不得出现模糊 */ const cardAt = surfaceSrc.indexOf('class GlassCardModifier'); assert.ok(cardAt > 0, '要能找到 GlassCardModifier'); const cardBody = surfaceSrc.slice(cardAt, surfaceSrc.indexOf('\nclass ', cardAt + 10)); assert.ok(!/\.backgroundEffect\(/.test(cardBody), '卡片层不得挂 backgroundEffect —— WebUI `.glass-card` 没有 backdrop-filter,' + '卡片的玻璃感来自半透明白。加模糊会叠成灰雾并掉帧(已实测)。'); /* * ★★ 2026-09-21 修:卡片现在读的是 `Theme.glassCardFor(this.active)` —— * **主题感知**的那个入口,它内部再分派到四个令牌: * glassCard / glassCardWall (浅色:白 0.92 / 0.78) * glassCardDark / glassCardWallDark (深色:白 0.06 / 0.04) * * 原来这里钉的是 `Theme.glassCard` / `Theme.glassCardWall` 两个字面量。 * 而 2026-09-21 设备实测证明**单一值是不对的**: * 深色 + aurora 壁纸下,卡片缝隙实测 `rgb(199,199,199)` * (= 0.78×255,那层厚白纱把黑壁纸糊成浅灰), * 用户报的「没有玻璃效果」正是这个。WebUI 深色把 alpha 降到 0.06(差 15 倍)。 * * ★ 为什么不改成 `assert.match(cardBody, /Theme\.glassCardDark/)`: * 那就把"用哪一档"钉死了 —— 而"壁纸档用 wall、否则用 card"是**行为**, * 应该由 `glassCardFor` 的一处实现决定,而不是由判据数调用点。 * 所以这里改为:**入口存在**,且**四个令牌在 Theme 里都存在且被入口读到**。 * 真正的行为验证在下面 ②' 那一段(深浅两个值必须不同)。 */ assert.match(cardBody, /Theme\.glassCardFor\(/, '卡片要经 Theme.glassCardFor(...) 取底色 —— 它按当前深浅分派四档 alpha'); /* ②' 四个令牌都得在 Theme 里真的存在,且入口真的按深浅分派 */ const themeSrcForGlass = code(join(HARMONY_ETS, 'common', 'Theme.ets')); for (const name of ['glassCard', 'glassCardWall', 'glassCardDark', 'glassCardWallDark']) { assert.match(themeSrcForGlass, new RegExp(`static readonly ${name}: string = '#[0-9A-Fa-f]{8}'`), `Theme 里要有 ${name}(四档 alpha 缺一不可)`); } /* * ★ 深色档必须**真的不同**于浅色档 —— 否则 "主题感知" 是假的。 * 这条不依赖具体数值(那些数值已在 `C2` 与`SELF_OWNED_COLORS` 那边登记), * 只钉"变了"这件事: * 浅色 0.92 / 0.78 vs 深色 0.06 / 0.04 * 若哪个改成与浅色同值,这条红。 */ const glassAlpha = (name) => { const m = themeSrcForGlass.match(new RegExp(`static readonly ${name}: string = '#([0-9A-Fa-f]{2})`)); return m ? m[1].toUpperCase() : ''; }; assert.notEqual(glassAlpha('glassCardDark'), glassAlpha('glassCard'), '深色档 glassCardDark 不得与浅色 glassCard 同值 —— 同值等于没有主题感知'); assert.notEqual(glassAlpha('glassCardWallDark'), glassAlpha('glassCardWall'), '深色档 glassCardWallDark 不得与浅色 glassCardWall 同值;' + '实测教训:深色下白纱 0.78 会把黑壁纸糊成 rgb(199,199,199) 的深灰纸板'); /* * ③ **不许**用 `backgroundEffect` —— 2026-09-21 实测否掉了我上一版的推断。 * * 我曾把底栏改成 `backgroundEffect({radius:18, saturation:1.5})`,理由是 * 「WebUI `.narrow-nav` 有 `saturate(1.5)`,而固定材质档没这个旋钮」。 * * 实测(模拟器窄屏 1008×2232,底栏中心列 x=504): * * y backgroundEffect backgroundBlurStyle * 1960 rgb(191,199,209) rgb(234,235,239) ← 差 -36 亮度 * 2000 rgb(198,204,212) rgb(234,235,239) ← 差 -31 * 2060 rgb(206,211,219) rgb(234,235,239) ← 差 -24 * * ⇒ `backgroundEffect` **只给模糊、不给底色**,壁纸原样透上来, * 整条底栏暗了 24~36 个亮度级。而 WebUI 的 `.narrow-nav` 是**两条声明**: * background-color: rgb(var(--nav-bg)); ← 我丢了这条 * backdrop-filter: blur(18px) saturate(1.5); * 系统材质(`backgroundBlurStyle`)**同时含色调 + 模糊 + 深浅两套**, * 正好把这两条一起给了;且 WebUI 那个 `--nav-bg` 是手写 alpha、 * 跟不了深色主题(§7.12)。⇒ 系统材质是这里**唯一**能兼顾深浅的方案。 * * ★ 为什么判据要**反向**钉住它:不留这条,下一个人看到 WebUI 那行 * `saturate(1.5)` 会再想一遍同样的事,而他要重跑一遍模拟器才能知道答案。 */ const effSites = [...surfaceSrc.matchAll(/\.backgroundEffect\(/g)]; assert.equal(effSites.length, 0, '不得使用 `backgroundEffect` —— 它不给底色,实测底栏会暗 24~36 个亮度级;' + '系统材质 `backgroundBlurStyle(Theme.navMaterial)` 同时含色调 + 模糊 + 深浅两套'); /* * 自检:造一次**链式叠用**(`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); /* * ★★ 2026-09-21 补:清单从"只有 `static readonly` 字段"扩到 **`static` 方法**。 * * ── 怎么发现的 ── * 我把两个账号选择器换成官方 `bindMenu`(系统自带转场)之后, * `Theme.menuIn()` 就**没有任何调用点了** —— 它是个 `static` 方法, * 而这份清单当时只匹配 `static readonly NAME`,于是它**从来不在这份清单里**, * 这条判据一路绿着看它变成死代码。 * * 讽刺的是本条判据的出身正是同一个形状:pi 2026-09-15 那一刀是为了 * "`navMaterial` 没人用了却没被抓"(一个**字段**)。今天同一个洞 * 长在**方法**上 —— 覆盖面按"声明种类"划,就必然漏掉另一种声明。 * ⇒ 清单改成"字段 + 方法",两者用同一套可达性规则。 * * ★ 方法名不带 `()`:比对用的是 `Theme.\b`, * 而调用点写 `Theme.menuIn()` —— `\b` 在 `n` 与 `(` 之间成立,匹配得上。 */ const fieldTokens = [...themeSrc.matchAll(/static readonly (\w+)\s*[:=]/g)].map(m => m[1]); const methodTokens = [...themeSrc.matchAll(/^\s*static (\w+)\(/gm)].map(m => m[1]); const declared = [...new Set([...fieldTokens, ...methodTokens])]; assert.ok(fieldTokens.length > 20, `要从 Theme.ets 里读到令牌清单(读到 ${fieldTokens.length} 个)`); assert.ok(methodTokens.length > 10, `也要读到 static 方法清单(读到 ${methodTokens.length} 个)—— ` + '少了它,`menuIn` 那种"方法型的死代码"就永远不在射程内'); 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.` 出现处**前面最近**的成员声明 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('、')}`); /* * 遮罩不该写成 `#AARRGGBB` 单值(色与透明度焊死,跟不了主题换向)。 * * ★★ 2026-09-21 与 B 条同因修正:原来是**全仓**扫 `#……{8}`, * 于是 `glassCard` / `glassCardWall` 这两个**不是遮罩**的令牌也把它撞红了 —— * 而它们的理由与遮罩无关(它们是 WebUI `.glass-card` 的 * `rgb(255 255 255 / 0.92)` 与 `/ 0.78`,是卡片底、不是遮罩)。 * * ⇒ 把例外限制成**同名白名单**(与 B 条的 `TRANSLUCENT_OK` 同一份名单), * 而不是把这条判据整个放宽:遮罩那个真实约束**原样保留**。 * `overlay` / `wallpaperScrim` 若被写成 8 位单值,这条照样红。 */ /* * ★★ 2026-09-21 再修正:白名单里要加上**深色档**那两个。 * * 上面这轮修正把名单限制成"同名白名单",但写的是**两个**名字; * 而 WebUI 的玻璃 alpha 本来就是**深浅两套** * (`.dark` 里 `--glass-card-a: 0.06` / `--glass-card-wall-a: 0.04`)—— * 我先前只搬了浅色那两个,正是「玻璃在深色下变成灰纸板」的成因。 * * ⇒ 现在四个一起列,与 B 条的 `TRANSLUCENT_OK` **同一份名单**。 * 遮罩那个真实约束**原样保留**:`overlay` / `wallpaperScrim` * 若被写成 8 位单值,这条照样红(下面这句断言就是干这个的)。 */ const glassAlphaOk = ['#EBFFFFFF', '#C7FFFFFF', '#0FFFFFFF', '#0AFFFFFF']; // 玻璃卡浅/深两档 const all8 = (stripComments(harmony).match(/#[0-9A-Fa-f]{8}/g) ?? []) .filter(h => !glassAlphaOk.includes(h.toUpperCase())); assert.deepEqual(all8, [], `遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值;只有 glassCard/glassCardWall 有登记理由(实见 ${all8.join(' ')})`); // 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, ''); /* * ★★ 2026-09-21 **正则太窄**(本仓反复出现的同一形状:判据自己的正则 * 比被判的东西窄 ⇒ 静默漏报,然后以"清册过期"的错误面貌报出来)。 * * 原来写 `{6}` —— 只认 6 位。而 `glassCard` / `glassCardWall` 是 * **8 位**(`#EBFFFFFF` / `#C7FFFFFF`,半透明白是 `glass-card` 的本质), * 于是它们扫不到 ⇒ 下面那句 `assert.ok(byName.has(n))` 报 * 「清册里的 glassCard 已不存在(清册过期)」—— * **报的是错的病因**:不是清册过期,是这个正则看不见 8 位色。 * * ⇒ 改成 `{6}(?:[0-9A-Fa-f]{2})?`:6 位必有、8 位可选。 * 注意**不能**写成 `{6,8}` —— 那会连 7 位这种非法长度也放进来。 */ for (const m of src.matchAll(/(\w+)\s*:\s*string\s*=\s*'(#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?)'/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 深色没调亮。'); });