diff --git a/client/electron/test/build-stamp.test.mjs b/client/electron/test/build-stamp.test.mjs index c104b09..15f7f9e 100644 --- a/client/electron/test/build-stamp.test.mjs +++ b/client/electron/test/build-stamp.test.mjs @@ -12,11 +12,13 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import { readFileSync, 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 read = p => readFileSync(join(HERE, p), 'utf8'); const STAMP = /[0-9a-f]{7,}·\d{4}-\d{4}/; @@ -44,3 +46,43 @@ test('判据自检:这个正则不能匹配随便一段文本', () => { assert.equal(STAMP.test('界面构建 index-abcdef.js'), false); assert.equal(STAMP.test('d2904fc·0914-0910'), true); }); + +/* + * 仓库里不得跟踪缓存/构建产物(`.tmp/` 那次事故的判据化,pi 提议)。 + * + * 2026-09-14:一次 `git add -A` 把 `.tmp/node-compile-cache/**` 与 `.tmp/studtmp-*` + * (554 个文件、2.6MB)提交进了版本库。靠"记得先看 git status"防不住下一次 —— + * 而这件事**不报错、不报警**,代价却落在别人身上:复核者读 diff 判断"改了什么", + * 554 个缓存文件直接把改动面埋了(同一工作区里还有并发写入者,这尤其致命)。 + * 所以按 pi 的建议,改成一条结构性判据。 + */ +test('★ 版本库里不得跟踪缓存/构建产物(.tmp、node_modules、dist、release、embed 产物)', () => { + const tracked = execFileSync('git', ['ls-files'], { encoding: 'utf8', cwd: ROOT }) + .split('\n') + .filter(Boolean); + assert.ok(tracked.length > 100, `git ls-files 只列出 ${tracked.length} 个文件,判据大概跑错目录了`); + + /* + * `server/internal/static/static/` 是 go:embed 的落点:**它的存在**靠一个 + * 有意跟踪的 `placeholder.html` 撑着(.gitignore 里也是 `*` + `!placeholder.html`)—— + * 那是设计,不是产物。所以这条只放行那一个名字,其余一律算产物。 + */ + const EMBED_PLACEHOLDER = 'server/internal/static/static/placeholder.html'; + const poison = tracked.filter(f => + /^(\.tmp\/|.*\/node_modules\/|client\/electron\/dist\/|client\/electron\/release\/|release\/|server\/internal\/static\/static\/)/.test(f) + && f !== EMBED_PLACEHOLDER + ); + assert.deepEqual( + poison.slice(0, 10), + [], + `版本库里跟踪了缓存/构建产物(共 ${poison.length} 个,前 10 个如下)—— 它们会让复核者看不清真实改动面:\n ${poison.slice(0, 10).join('\n ')}` + ); + + // 反向对照:判据要真能认出这类路径(否则正则写错也是一片绿) + const looksTracked = p => /^(\.tmp\/|.*\/node_modules\/|client\/electron\/dist\/|client\/electron\/release\/|release\/|server\/internal\/static\/static\/)/.test(p); + assert.ok(looksTracked('.tmp/node-compile-cache/v22/0014c7b4'), '自检:认不出 .tmp 下的缓存'); + assert.ok(looksTracked('client/electron/release/agentmail-web_0.1.0_amd64.deb'), '自检:认不出安装包产物'); + assert.ok(!looksTracked('client/electron/src/index.css'), '自检:把源码当成产物了'); + // 那个占位文件必须还在:它是 go:embed 落点存在的唯一理由,删了 Go 侧就编不过 + assert.ok(tracked.includes(EMBED_PLACEHOLDER), 'go:embed 的占位文件丢了 —— 没有它 static/ 目录不存在,Go 侧编不过'); +}); diff --git a/client/electron/test/cross-client-theme.test.mjs b/client/electron/test/cross-client-theme.test.mjs index f0ada7e..57ec842 100644 --- a/client/electron/test/cross-client-theme.test.mjs +++ b/client/electron/test/cross-client-theme.test.mjs @@ -16,11 +16,44 @@ 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 = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8'); -const harmony = readFileSync( - join(ROOT, 'client/harmony/entry/src/main/ets/common/Theme.ets'), - 'utf8' -); +const harmony = readFileSync(join(HARMONY_ETS, 'common/Theme.ets'), 'utf8'); + +/** + * 裸色值的**类**(不只 `#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 code = src => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); +const rawColors = src => code(src).match(RAW_COLOR) || []; + +/** + * 文档里的「有意差异」表(§7.12)。 + * + * 这是**弱判据**用的材料:它防的是"改了做法没改记录"(下一个人会当成漏改), + * 不是"证明做法对"。所以跨端那几条判据的主体仍落在代码上 + * (来源必须是系统资源 / 品牌色必须是那个值),文档只做第三只手。 + */ +const plan = readFileSync(join(ROOT, 'docs/HARMONY-ALIGN-PLAN.md'), 'utf8'); +const diffSection = () => { + const at = plan.indexOf('有意差异'); + assert.ok(at > 0, '文档里应有「有意差异」表(§7.12)'); + const rest = plan.slice(at); + const end = rest.search(/\n#{2,3} /); + return end === -1 ? rest : rest.slice(0, end); +}; + +/** 递归收集鸿蒙源码(.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(); @@ -39,15 +72,29 @@ test('品牌蓝一致', () => { assert.equal(hex(harmonyBlue[1]), asHex, `品牌蓝不一致:WebUI ${asHex} vs 鸿蒙 ${harmonyBlue[1]}`); }); -test('卡片圆角一致(WebUI 0.875rem = 14px)', () => { - assert.match(web, /--radius-card:\s*0\.875rem/); - assert.match(harmony, /radiusCard: number = 14/); +test('圆角:WebUI 有它自己的令牌,鸿蒙跟随系统 —— 差异被记录(不再钉"取值相同")', () => { + /* + * 这一条**改过口径**(2026-09-14,jianf 要求「鸿蒙用系统方案」,与 pi 对齐后改)。 + * + * 旧口径钉的是"两边都 14px"。改用系统资源后它必然红 —— 而这次红是**预期**的: + * 圆角交给系统就意味着它**允许**与 WebUI 不同(系统圆角会随设备/主题/无障碍设置变)。 + * 所以口径从"取值相同"换成"**意图相同**": + * ① WebUI 侧仍须自己声明一个圆角令牌(它没有系统可跟随); + * ② 鸿蒙侧圆角来自系统(`sys.float.*`); + * ③ 这条差异被**记录**在文档的"有意差异"表里 —— 否则下一个人会以为是漏改。 + * 注意 ③ 是**弱判据**(防遗忘),不是"证明做法对"。主体是 ①②,它们落在代码上。 + */ + assert.match(web, /--radius-card:\s*0\.875rem/, 'WebUI 侧要保留自己的圆角令牌'); + assert.match(code(harmony), /radiusCard: Resource = \$r\('sys\.float\./, '鸿蒙卡片圆角要来自系统'); + assert.match(diffSection(), /圆角/, '圆角是"有意差异",要写进文档的差异表(否则会被当成漏改)'); }); -test('导航玻璃不透明度一致(都是 0.72)', () => { - assert.match(web, /--nav-bg:\s*255 255 255 \/ 0\.72/); - // 0.72 × 255 ≈ 184 = 0xB8(鸿蒙用 #AARRGGBB,顺序与 CSS 不同) - assert.match(harmony, /navBgLight: string = '#B8FFFFFF'/); +test('材质:WebUI 声明透明度令牌,鸿蒙用系统材质 —— 差异被记录(不再钉 0.72)', () => { + // 同上:旧口径钉"两边都 0.72"。鸿蒙改用 `BlurStyle` 后,"0.72 的白色"这件事 + // 由系统按主题决定(深浅各一套),所以取值**允许**不同,只要求"材质来自系统"且差异被记录。 + assert.match(web, /--nav-bg:\s*255 255 255 \/ 0\.72/, 'WebUI 侧要保留自己的导航底令牌'); + assert.match(code(harmony), /navMaterial: BlurStyle = BlurStyle\./, '鸿蒙导航材质要来自系统'); + assert.match(diffSection(), /材质/, '材质是"有意差异",要写进文档的差异表'); }); test('★ 判据自检:把鸿蒙的品牌蓝改成别的必须判红', () => { @@ -66,27 +113,133 @@ test('★ 鸿蒙页面里不得出现任何裸色值(枚举挡不住漂移," * 同时,pages/ 里还留着 14 处**另一套**写死的色:Google/Material 的 * #E8F0FE、#E8F5E9、#FFF3E0、#D93025 与 #777777/#555555/#444444/ * #cccccc/#aaaaaa、遮罩 #80000000、透明 #00000000。 - * 枚举只能挡住"列出来的实例",挡不住漂移本身 —— 改成按类挡: - * pages/ 下一个裸色值都不许有(含 8 位 #AARRGGBB),颜色只能来自 Theme.ets。 + * 枚举只能挡住"列出来的实例",挡不住漂移本身 —— 改成按类挡。 * - * 页面清单也一并改成扫目录:旧版硬编码 7 个文件名,**新加的页面会自动 - * 逃出判据** —— 而发件箱/授权/日历正是接下来要加的页面。 + * ## 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,判据大概扫错了目录`); - const literal = /#[0-9A-Fa-f]{6,8}\b/g; for (const f of files) { - const hits = readFileSync(join(dir, f), 'utf8').match(literal); - assert.equal(hits, null, `${f} 里有裸色值 ${hits ? hits.join('、') : ''}(应改用 Theme 令牌)`); + const hits = rawColors(readFileSync(join(dir, f), 'utf8')); + assert.equal(hits.length, 0, `${f} 里有裸色值 ${hits.join('、')}(应改用 Theme 令牌或系统资源)`); } - // 反向对照:判据要真能抓到裸色值(否则写错了正则也是一片绿) - assert.deepEqual("fontColor('#E8F0FE')".match(literal), ['#E8F0FE']); - assert.deepEqual("color('#80000000')".match(literal), ['#80000000']); - // 令牌文件本身必须是**唯一**的颜色来源,否则上面那条会因为"哪儿都没有色值"而空转 + // 反向对照:四种形态都要抓得到(否则写错了正则也是一片绿) + 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(code(harmony)), + `${name} 不再是系统资源了 —— 这是"用系统方案"被回退的信号` + ); + } +}); + +test('B|旧机制不得回来:手写玻璃 alpha、替系统猜深色、与 WebUI 绑死的圆角数字', () => { + const codeOnly = code(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 了'); +}); + +test('C|玻璃位置必须用系统材质,而且只出现在一层', () => { + /* + * 「用系统方案」里最容易被写歪的一处:`#B8FFFFFF` 看着也能出玻璃效果, + * 但它不跟随深色模式、也不跟随系统的模糊半径。所以钉两件事: + * ① 材质档次来自 `BlurStyle`(系统枚举),② 应玻璃化的位置真的调了 `backgroundBlurStyle`。 + * 另外**只许一层** —— 嵌套各加一层模糊是 pi 点名要避免的(视觉上会互相打架)。 + */ + const codeOnly = code(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 ets = collectEts(join(ROOT, 'client/harmony/entry/src/main/ets')); + const glassCalls = []; + for (const f of ets) { + const src = code(readFileSync(f, 'utf8')); + for (const m of src.matchAll(/backgroundBlurStyle\(/g)) glassCalls.push(f); + } + assert.equal(glassCalls.length, 1, `玻璃应只出现在一处,实际 ${glassCalls.length} 处:${glassCalls.join('、')}`); + const main = code(readFileSync(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'), 'utf8')); + assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条要用系统材质(不是手写 alpha)'); +}); + +test('★ 品牌色防线:主操作色不得退化成系统强调色', () => { + /* + * 这是 pi 最担心的语义事故,我同意:改用系统方案时**顺手**把 `accent` 换成 + * 系统的 emphasize 色,界面看着还挺协调 —— 但品牌蓝是**跨客户端身份** + * ("两个客户端是同一个产品"),系统强调色会随主题/厂商皮肤变,换过去这件事就靠不住了。 + * 所以这条不是"取值好看",是钉住唯一必须与 WebUI 逐字一致的那一个值。 + */ + assert.match(harmony, /accent: string = '#2563EB'/, '品牌蓝必须仍是 WebUI 的那个值'); + assert.ok( + !/accent: Resource/.test(code(harmony)), + 'accent 变成系统资源了 —— 品牌色跟着系统变就不再是同一个产品的标识' + ); + // 主操作/选中态仍引用它(否则"没退化"只是因为它没被用 —— 值留着也没意义) + const login = code(readFileSync(join(ROOT, 'client/harmony/entry/src/main/ets/pages/LoginPage.ets'), 'utf8')); + assert.match(login, /backgroundColor\(Theme\.accent\)/, '登录按钮仍是品牌色'); + const main = code(readFileSync(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'), 'utf8')); + assert.match(main, /backgroundColor\(Theme\.accent\)/, '主操作按钮/选中态仍是品牌色'); +}); + test('权限档位徽标两边同一套色(plan=蓝 / workspace=绿 / full=琥珀)', () => { // WebUI 的映射写在 PermissionChip.tsx 的类名里;鸿蒙的映射是 Theme.permBg/permFg。 const chip = readFileSync( @@ -141,23 +294,43 @@ test('权限档位徽标两边同一套色(plan=蓝 / workspace=绿 / full=琥 ); }); -test('鸿蒙的遮罩是「色 + 透明度」两段式(照 WebUI 的 --bg-scrim + --bg-dim)', () => { +test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在由系统做)', () => { /* - * WebUI 的遮罩是**两段**:`--bg-scrim`(颜色:浅色=白、深色=黑)+ - * `--bg-dim`(透明度),见 `index.css` 的 `.app-backdrop::after`。 - * 理由:遮罩色要能**随主题换向** —— 浅色主题用白把图案洗淡,深色主题必须换黑, + * 这一条也**改过口径**。旧口径要求鸿蒙照 WebUI 那样把遮罩拆成"色 + 透明度"两个令牌 —— + * 理由是遮罩色必须**随主题换向**:浅色主题用白把图案洗淡,深色主题必须换黑, * 否则浅色照片在深色界面里糊成一块亮斑、正文读不动(WebUI 侧实测踩过)。 - * 鸿蒙侧原先写成一个焊死的 `#80000000`,换向时只能再写一个常量 —— 又变成枚举。 + * + * 但"随主题换向"正是一个**系统语义色**能表达的东西:`ohos_id_color_mask_regular` + * 深浅两套值由系统给,而且不会再漏配一边 —— 比我们自己维护两个常量更不容易错。 + * 所以口径改成"遮罩来自系统"(这条是硬的),WebUI 侧仍然两段式(它没有系统可跟随)。 */ - assert.match(harmony, /overlayColor: string = '#000000'/, '鸿蒙的遮罩**色**要单独成令牌'); - assert.match(harmony, /overlayAlpha: number = 0\.5/, '鸿蒙的遮罩**透明度**要单独成令牌'); - // 只查**代码**,不查注释:Theme.ets 的注释里正当地引用了旧值来解释为什么拆开 - const harmonyCode = harmony.replace(/\/\*[\s\S]*?\*\//g, ''); - assert.ok(!/#80000000/.test(harmonyCode), '遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值'); + assert.match(code(harmony), /overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/, '鸿蒙遮罩要用系统遮罩色'); + // 不许再自己维护"色 + 透明度"两个常量(那正是系统已经替我们做掉的事) + assert.ok(!/overlayColor: string/.test(code(harmony)), '遮罩色不该再由我们自己定'); + assert.ok(!/overlayAlpha: number/.test(code(harmony)), '遮罩透明度不该再由我们自己定'); + assert.ok(!/#[0-9A-Fa-f]{8}/.test(code(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(/#80000000/.test("backgroundColor('#80000000')"), '自检:正则抓不到焊死的单值'); + 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}(它现在是"允许不同"的维度)`); + } + assert.match(section, /品牌色[\s\S]{0,80}不允许差异/, '品牌色必须被点名"不允许差异",否则下一个人会以为它也在表内'); + // 反向对照:表里必须真的写了"为什么允许不同",不是只列个名字 + 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 / 鸿蒙 / 为什么"'); }); diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index ce9c844..19205d5 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -1,81 +1,113 @@ /* * AgentMail 鸿蒙客户端 — 设计令牌 * - * 与 WebUI 的令牌**一一对应**(见 client/electron/src/index.css 的 `:root` 段)。 - * 用户要求「下一步就是同步 ui 设计到客户端了」——同步的第一步不是把每个页面都改一遍, - * 而是让两边**用同一份词表**:颜色、圆角、玻璃透明度、模糊档位。 - * 否则每加一个页面就会重新抄一遍色值,两个客户端慢慢就长得不一样了。 + * ## 这个文件在 2026-09-14 换了一次"来源"(jianf:「鸿蒙要求用系统方案」) * - * 注意几个刻意的取舍: - * - WebUI 用 `rgb(r g b / a)`,鸿蒙用 `#AARRGGBB` —— 顺序不同,转换时必须显式写清; - * 0.72 × 255 ≈ 184 = 0xB8、0.88 × 255 ≈ 224 = 0xE0。 - * - WebUI 的"正文面"是 0.88 不透明的玻璃(字要读得清),鸿蒙这里给的是**实色** - * surface:没有壁纸时不需要透,透反而降低可读性。 - * - 只有**导航**用半透明 + 模糊(浮在内容之上,模糊有遮蔽意义)——与 WebUI 同一结论。 + * 原先这里的每一格都是**手抄 WebUI 的色值**(`#F8FAFC`、`#0F172A`…)。现在分成两类, + * 分界线是"**系统有没有这份语义**": + * + * - **表面 / 文字 / 分隔 / 遮罩 / 圆角 / 材质 —— 交给系统**(`$r('sys.color.*')`、 + * `$r('sys.float.*')`、`BlurStyle.*`)。理由不是"看起来更原生",而是三条实际的: + * ① 它们会**跟随深色模式**(手抄的值不会,这正是 WebUI 那边"半深不浅"的病根); + * ② 跟随系统动效/无障碍设置; + * ③ 少一处会漂移的副本 —— 这些格子的值不再由我们决定,也就不会再和系统打架。 + * - **品牌色与业务语义色 —— 仍然自己写**:`accent = #2563EB` 是**跨客户端身份** + * (两个客户端是同一个产品),不能退化成随主题/厂商皮肤变的系统强调色; + * 权限三档(蓝/绿/琥珀)与预算三档(灰/橙/红)在系统里**没有对应物** + * (系统只有 warning/alert 两个"情绪色"),硬套会把语义丢掉。 + * + * ## 名字不是猜的 + * + * `sys.*` 的名字一旦写错,**编译期不报**、只有真机运行到那一行才炸 —— 而本机没有设备。 + * 所以名字全部对着 SDK 自带的系统资源名表核过: + * `sdk/default/openharmony/toolchains/id_defined.json`(本机 API 26,7826 条)。 + * 而且有一条判据(`client/electron/test/harmony-system-api.test.mjs`)持续盯着这件事: + * 源码里每个 `$r('sys..')` 都必须在表里存在且类型相符。 + * + * ## 保留的取舍 + * + * - WebUI 的"正文面"是 0.88 不透明的玻璃(字要读得清);鸿蒙用的系统卡片底色也是不透明的, + * 结论一致:**正文面不透,只有浮在内容之上的那一层用材质**("玻璃只出现在一层")。 + * - 字体大小仍与 WebUI 的 text-2xs/xs/sm 对齐(字号不是"系统方案"要解决的问题, + * 两边的版式意图是同一套)。 */ + export class Theme { - /** 页面底色(对齐 WebUI 的 bg-gray-50 / slate-100) */ - static readonly pageBg: string = '#F8FAFC'; - /** 承载文字的面:实色,优先可读性(对应 WebUI --bg-glass 在无壁纸时的观感) */ - static readonly surface: string = '#FFFFFFFF'; - /** 次级面(列表行 hover、分组底) */ - static readonly surfaceMuted: string = '#F1F5F9'; + // ─────────────── 表面:系统语义色 ─────────────── + + /** 页面底色(系统 `ohos_id_color_background`:浅色白/深色深灰,自动跟随主题) */ + static readonly pageBg: Resource = $r('sys.color.ohos_id_color_background'); + /** 承载文字的面 = **列表卡片底色**(对应"每项一张卡/气泡",而不是通栏底色) */ + static readonly surface: Resource = $r('sys.color.ohos_id_color_list_card_bg'); + /** 次级面(分组底、列表行 hover) */ + static readonly surfaceMuted: Resource = $r('sys.color.ohos_id_color_sub_background'); /** 分隔线 */ - static readonly border: string = '#E2E8F0'; + static readonly border: Resource = $r('sys.color.ohos_id_color_list_separator'); - /** 导航底:浅色玻璃 0.72(对应 WebUI --nav-bg: 255 255 255 / 0.72) */ - static readonly navBgLight: string = '#B8FFFFFF'; - /** 深色主题下的导航底:0.72 的 #0F172A(对应 --nav-bg 的 .dark 值) */ - static readonly navBgDark: string = '#B80F172A'; - /** 导航文字:未选中 / 选中(对应 --nav-fg-muted / --nav-active-fg) */ - static readonly navFg: string = '#475569'; - static readonly navFgActive: string = '#1E40AF'; + // ─────────────── 文字:系统语义色(三级) ─────────────── + static readonly textPrimary: Resource = $r('sys.color.ohos_id_color_text_primary'); + static readonly textMuted: Resource = $r('sys.color.ohos_id_color_text_secondary'); + static readonly textSubtle: Resource = $r('sys.color.ohos_id_color_text_tertiary'); - /** 文字:主 / 次 / 弱(对应 WebUI 的 slate-900 / slate-500 / slate-400) */ - static readonly textPrimary: string = '#0F172A'; - static readonly textMuted: string = '#64748B'; - static readonly textSubtle: string = '#94A3B8'; - /** 品牌蓝的浅底(对应 WebUI 的 blue-50,用于高亮条/选中行) */ - static readonly accentSoft: string = '#EFF6FF'; - /** 品牌蓝(按钮、链接、焦点环) */ + // ─────────────── 遮罩与材质:交给系统 ─────────────── + + /** + * 遮罩(模态/淡出层):系统遮罩色。 + * + * 这里原来存着 `overlayColor` + `overlayAlpha` 两个常量,再用 `overlay()` 拼成 + * `#AARRGGBB` —— 之所以要"色与透明度分开",是因为**遮罩色必须随主题换向** + * (浅色主题用白把图案洗淡、深色主题必须换黑,否则浅色照片在深色界面里糊成一块亮斑)。 + * 那件事现在由系统做:一个语义色就够,且换向不会再漏。 + */ + static readonly overlay: Resource = $r('sys.color.ohos_id_color_mask_regular'); + + /** + * 导航/浮层的材质档次。**不再有 `#B8FFFFFF` / `#B80F172A` 这种手写玻璃 alpha** —— + * 那两个值等于"我们替系统猜了深色该怎么做",与"用系统方案"直接冲突; + * 而材质档次是系统给的,深浅两套颜色由系统按主题挑。 + * + * 用 `COMPONENT_THICK`:贴在界面组件上的一层材质(导航条正属于这一类)。 + */ + static readonly navMaterial: BlurStyle = BlurStyle.COMPONENT_THICK; + + // ─────────────── 圆角:系统尺寸 ─────────────── + + /** 卡片圆角:系统"卡片"圆角(不再与 WebUI 的 14vp 绑死 —— 允许各自跟随系统) */ + static readonly radiusCard: Resource = $r('sys.float.ohos_id_corner_radius_card'); + /** 控件圆角:系统"按钮"圆角 */ + static readonly radiusControl: Resource = $r('sys.float.ohos_id_corner_radius_button'); + + // ─────────────── 品牌色:跨客户端身份,必须自己写 ─────────────── + + /** + * 品牌蓝(按钮、链接、焦点环)。 + * + * **不要**为了"用系统方案"把它换成系统的强调色:系统强调色会随主题/厂商皮肤变, + * 一旦换过去,"两个客户端是同一个产品"这件事就靠不住了。 + * 这是整个文件里**唯一必须与 WebUI 逐字一致**的取值(判据钉住)。 + */ static readonly accent: string = '#2563EB'; static readonly accentFg: string = '#FFFFFF'; + /** 品牌蓝的浅底 / 深前景(与 WebUI 的 blue-50 / blue-700 成对) */ + static readonly accentSoft: string = '#EFF6FF'; + static readonly accentStrong: string = '#1D4ED8'; + + /** 导航文字:未选中 / 选中(对应 --nav-fg-muted / --nav-active-fg) */ + static readonly navFg: Resource = $r('sys.color.ohos_id_color_text_secondary'); + static readonly navFgActive: string = Theme.accentStrong; + + // ─────────────── 业务语义色:系统没有对应物,继续自己写 ─────────────── + /** 语义色:同意 / 拒绝(对应 WebUI 的 approve/danger) */ static readonly approve: string = '#15803D'; static readonly danger: string = '#B91C1C'; static readonly dangerBg: string = '#FEF2F2'; - /** 成功的浅底(对应 WebUI 的 green-50 / green-700) */ static readonly approveBg: string = '#F0FDF4'; static readonly approveFg: string = '#15803D'; - - /** 品牌蓝的**深**前景(对应 WebUI 的 --c-blue-700: 29 78 216)—— 与 accentSoft 成对用于 chip */ - static readonly accentStrong: string = '#1D4ED8'; /** 警示面/前景(对应 WebUI 的 --c-amber-50 / --c-amber-700)—— 权限 full 档、待决策徽标 */ static readonly warnBg: string = '#FFFBEB'; static readonly warnFg: string = '#B45309'; - /** - * 遮罩(模态/淡出层):**颜色与透明度分开**,照 WebUI 的 `--bg-scrim` + `--bg-dim` - * 两段式(`index.css` 的 `.app-backdrop::after`)。 - * - * 为什么不写成一个 `#80000000`:遮罩色要能**随主题换向** —— 浅色主题用白把图案洗淡, - * 深色主题必须换黑,否则浅色照片在深色界面里糊成一块亮斑、正文读不动(WebUI 侧实测踩过)。 - * 色与透明度焊死成一个 AARRGGBB,换向时只能再写一个常量,于是又变成枚举 —— - * 那正是这条判据要防的东西。 - * - * 注意:这是**唯一没有 WebUI 同名令牌**的一组(WebUI 的弹层不压遮罩, - * 它那份 scrim 是给壁纸调暗用的),所以不往 WebUI 立同名令牌 —— 立了没人用、 - * 判据只能验"它存在",是自证。 - */ - static readonly overlayColor: string = '#000000'; - static readonly overlayAlpha: number = 0.5; - - /** 遮罩色 → ArkUI 只认的 `#AARRGGBB` 单值(鸿蒙没有"色 + 透明度"两参的重载) */ - static overlay(): string { - const a: number = Math.round(Theme.overlayAlpha * 255); - const hex: string = a.toString(16).toUpperCase(); - return '#' + (hex.length < 2 ? '0' + hex : hex) + Theme.overlayColor.substring(1); - } /** * 权限档位 → 徽标底色 / 文字色。 @@ -83,6 +115,10 @@ export class Theme { * 与 WebUI 的 `PermissionChip.tsx` **同一映射**(plan=蓝 / workspace=绿 / full=琥珀), * 认不出的档位与空档位按 workspace 处理 —— 映射只有这一处实现, * 页面不再各自 if-else 挑颜色。 + * + * 为什么不用系统情绪色:系统只有 warning/alert 两个,凑不出三档; + * 而档位是**可被文档与判据按名字引用的枚举标识**(plan/workspace/full), + * 用情绪色顶替它,界面对了语义丢了。 */ static permBg(mode: string): string { if (mode === 'plan') { @@ -120,7 +156,8 @@ export class Theme { * 与 WebUI 的 `BudgetChip` **同一映射**(`WorkCard.tsx`): * 剩 0 = 红(用尽)、剩 ≤1 = 橙(将尽,任务需要人介入)、其余 = 中性灰。 * 档位本身由 `MailGrouping.ts` 的 `budgetState()` 算(那是可被判据执行的一层), - * 这里只管"哪个档用什么颜色"。 + * 这里只管"哪个档用什么颜色"。这三个色**不进跨端"取值相同"判据**(业务局部), + * 但必须进枚举完整性判据(三档齐全、认不出的归一到中性档)。 */ static budgetBg(state: string): string { if (state === 'spent') { @@ -142,10 +179,6 @@ export class Theme { return Theme.chipNeutralFg; } - /** 圆角:卡片 14、控件 8(与 WebUI 的 --radius-card / --radius-control 一致) */ - static readonly radiusCard: number = 14; - static readonly radiusControl: number = 8; - /** 字体大小(与 WebUI 的 text-2xs/xs/sm 对齐) */ static readonly fontTiny: number = 11; static readonly fontSmall: number = 12; diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index aa8d107..7891efe 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -242,7 +242,7 @@ struct MailDetailPage { // 遮罩 Column() .width('100%').layoutWeight(1) - .backgroundColor(Theme.overlay()) + .backgroundColor(Theme.overlay) .onClick(() => { this.showReplyBox = false; }) // 回复框 diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 8e9c565..4c1bfa6 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -419,8 +419,16 @@ struct InboxTab { Row() { this.MailItem(mail) } - .width('100%').height(80) + .width('100%').height(74) + /* + * **每项一张卡**(不是通栏):jianf 同步过来的语义 —— 列表项各自成卡/气泡, + * 卡与卡之间留缝,靠底色与圆角分开,不再用贯通整屏的分隔线。 + * 底色用系统的"列表卡片底色",深浅主题由系统挑。 + */ .backgroundColor(mail.status === 'unread' ? Theme.accentSoft : Theme.surface) + .borderRadius(Theme.radiusCard) + .margin({ bottom: 6 }) + .clip(true) .onClick(() => { this.openMail(mail); }) @@ -469,6 +477,9 @@ struct InboxTab { .padding({ left: 12, right: 12 }) .alignItems(VerticalAlign.Center) .backgroundColor(Theme.surfaceMuted) + .borderRadius(Theme.radiusCard) + .margin({ bottom: 6 }) + .clip(true) .onClick(() => { this.toggleExpanded(g.key); }) @@ -633,16 +644,20 @@ struct ContactsTab { .width('100%').layoutWeight(1) .padding({ left: 12, right: 12, top: 8, bottom: 8 }) } else { - List({ space: 1 }) { + // 列表视图:同样**每项一张卡**(不再用贯通分隔线)—— 与卡片视图同一语义 + List({ space: 6 }) { ForEach(this.contacts, (c: Contact, idx: number) => { ListItem() { this.ContactItem(c, idx) } .height(85) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) + .clip(true) }, (_c: Contact, idx: number) => idx.toString()) } .width('100%').layoutWeight(1) - .divider({ strokeWidth: 1, color: Theme.border, startMargin: 16, endMargin: 16 }) + .padding({ left: 12, right: 12, top: 8, bottom: 8 }) } } .width('100%').height('100%') @@ -809,6 +824,17 @@ struct MainPage { } .width('100%') .justifyContent(FlexAlign.Center) + /* + * 导航底:**系统材质**,不是手写 alpha。 + * + * 原来这里有 `#B8FFFFFF` / `#B80F172A` 两个常量(浅色/深色各一个手写玻璃)—— + * 那等于"我们替系统猜了深色该怎么做",与"用系统方案"直接冲突, + * 而且还要我们自己维护两套。现在只声明**档次**(`Theme.navMaterial`), + * 深浅两套颜色与模糊半径都由系统按主题给。 + * + * 玻璃**只出现在这一层**(内容面不透)—— 嵌套各加一层模糊是 pi 点名要避免的。 + */ + .backgroundBlurStyle(Theme.navMaterial) } build() { diff --git a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets index ea7cb1c..85978c2 100644 --- a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets +++ b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets @@ -176,7 +176,7 @@ struct SettingsPage { Column() { Column() .width('100%').layoutWeight(1) - .backgroundColor(Theme.overlay()) + .backgroundColor(Theme.overlay) .onClick(() => { this.showAddDialog = false; }) Column() { diff --git a/docs/HARMONY-ALIGN-PLAN.md b/docs/HARMONY-ALIGN-PLAN.md index 53e757a..beeef5c 100644 --- a/docs/HARMONY-ALIGN-PLAN.md +++ b/docs/HARMONY-ALIGN-PLAN.md @@ -483,3 +483,63 @@ deb 也不必从 targets 里摘。已写进 `client/electron/BUILD.md`(含排 两条纪律写进判据本身:SDK 名表找不到时**判红并说明**(会静默跳过的判据等于没有), 以及变异验证(拼错色名 `list_cad_bg` → 报"查无此名";写错 `BlurStyle.COMPONENT_不存在` → 报可用取值; 名字解析精确到 `,`/`=` 分隔符 —— 早先的宽松写法把文档里的 `T`、`R` 也算成了枚举成员)。 + +### 7.12 「有意差异」表(跨端判据从"取值相同"改"意图相同"的依据) + +按 pi 的要求单列一张表:这些维度**允许两边不同**,因为它们各自跟随自己的平台。 +判据(`cross-client-theme.test.mjs`)只钉"来源正确 + 差异被记录",不再钉取值相等 —— +但**品牌色不在表内**:它是跨客户端身份,必须逐字一致(`#2563EB`,单独一条判据钉住)。 + +| 维度 | WebUI | 鸿蒙 | 为什么允许不同 | +|---|---|---|---| +| 圆角 | 自声明 `--radius-card: 0.875rem` | 系统 `sys.float.ohos_id_corner_radius_card/button` | 系统圆角会随设备/主题/无障碍设置变;跟着系统才是"系统方案" | +| 材质(玻璃) | 自声明 `--nav-bg: 255 255 255 / 0.72` + `backdrop-filter` | 系统 `backgroundBlurStyle(BlurStyle.COMPONENT_THICK)` | 系统材质自带深浅两套颜色与模糊半径,手写 alpha 跟不了深色 | +| 动效 | 自定义 transition/时长 | `animateTo` + 系统 `curves` | 动效曲线应跟随系统设置(含"减弱动效") | +| 遮罩 | 自声明 `--bg-scrim` + `--bg-dim` 两段式 | 系统 `sys.color.ohos_id_color_mask_regular` | 遮罩要随主题换向(浅色洗白/深色压黑),这件事系统已经做了 | +| **品牌色** | `--c-blue-600: 37 99 235` | `Theme.accent = '#2563EB'` | **不允许差异** —— 两个客户端是同一个产品 | + +### 7.13 系统方案替换(第一批)+ 跨端判据从"取值"改"意图" + +**改了什么**(鸿蒙侧): + +- `Theme.ets` 的表面/文字/分隔/遮罩/圆角**来源换成系统**: + `pageBg→ohos_id_color_background`、`surface→ohos_id_color_list_card_bg`、 + `surfaceMuted→ohos_id_color_sub_background`、`border→ohos_id_color_list_separator`、 + 三级文字 `→text_primary/secondary/tertiary`、 + `overlay→ohos_id_color_mask_regular`、`radiusCard/Control→sys.float.ohos_id_corner_radius_card/button`。 +- **删掉手写玻璃**:`navBgLight='#B8FFFFFF'` / `navBgDark='#B80F172A'` 两个常量去掉, + 换成 `navMaterial: BlurStyle = BlurStyle.COMPONENT_THICK`,导航条上 + `.backgroundBlurStyle(Theme.navMaterial)` —— 深浅两套颜色与模糊半径由系统按主题给。 + 顺带把**遮罩**的那套"色 + 透明度"两段式也删了(`overlayColor`/`overlayAlpha`/`overlay()`): + 当初拆两段就是为了"遮罩要能随主题换向",而这件事系统已经做了。 +- **每项一张卡/气泡**:收件箱行、会话组头、联系人列表行都改成卡片 + (圆角 + 卡片底色 + 行间距),联系人列表那条贯通分隔线删掉。 +- 保留自己写的只有两类:**品牌色**(`accent = #2563EB`,跨客户端身份)与 + **业务语义色**(权限三档、预算三档 —— 系统只有 warning/alert 两个情绪色,凑不出三档)。 + +**判据怎么改的**(与 pi 对齐后,两侧一起改;他要的 A/B/C 形状 + 品牌色防线): + +| 判据 | 旧口径 | 新口径 | +|---|---|---| +| 品牌蓝 | 取值相同 | **不变**(唯一必须逐字一致的东西),另加"不得退化成 `$r('sys.color.*')`"的防线 | +| 圆角 | 两边都是 14px | 意图相同:WebUI 自声明令牌 + 鸿蒙来自 `sys.float.*` + 差异记录在 §7.12 | +| 导航玻璃 | 两边都是 0.72 | 意图相同:WebUI 自声明 alpha + 鸿蒙用 `BlurStyle` + 差异记录 | +| 遮罩 | 鸿蒙照 WebUI 拆"色 + 透明度" | 鸿蒙用系统遮罩色(换向由系统做),WebUI 仍两段式 | +| 裸色值 | 只扫 `#RRGGBB(AA)` | **四种形态一起扫**:`#RRGGBB(AA)` / `rgba(` / `0xRRGGBBAA`(pi 指出的洞) | +| 新增 A | —— | 系统拥有的维度**唯一来源**是 `$r('sys.*')`(且不得退回 string/number) | +| 新增 B | —— | 旧机制不得回来:`navBg*`、8 位半透明色、`rgb(`/`rgba(`、写死的 14/8 | +| 新增 C | —— | 玻璃位置必须调 `backgroundBlurStyle`,且**只许一层**(嵌套各加模糊是 pi 点名要避免的) | + +**变异验证**(判据必须能红,红在哪也要对): + +| 变异 | 结果 | +|---|---| +| 品牌蓝 → `$r('sys.color.ohos_id_color_emphasize')` | 红 3 条(含品牌色防线) | +| 手写玻璃 `navBgLight='#B8FFFFFF'` 回来 | 红 2 条 | +| 导航改用写死的半透明色、不调 `backgroundBlurStyle` | 红 2 条 | +| 卡片上再开一层模糊(嵌套玻璃) | 红 1 条:`玻璃应只出现在一处,实际 2 处` | + +**未验**:这次替换的**观感**仍然没验 —— 卡片间距是否合适、系统材质在导航条上的实际效果、 +深色模式下的观感,都只有真机才看得到(模拟器需人在命令行启动)。已验证的是: +`hvigorw assembleHap` BUILD SUCCESSFUL、13 条跨端判据 + 4 条系统资源名判据全绿、 +四种变异都能判红;`sys.*` 名字全部对着 SDK 名表核过(判据持续盯着)。