From 22c9be7181cdc4c8aa5e66074ab5108abc54775e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 14:57:18 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E9=A2=84=E8=AE=BE?= =?UTF-8?q?=E8=89=B2=E6=9D=BF=E4=B8=A4=E5=A5=97=EF=BC=88pi=EF=BC=9A?= =?UTF-8?q?=E8=BF=99=E4=B8=8D=E6=98=AF"=E8=A7=82=E6=84=9F=E6=9C=AA?= =?UTF-8?q?=E9=AA=8C"=E8=80=8C=E6=98=AF=E6=9C=BA=E5=88=B6=E4=B8=8A?= =?UTF-8?q?=E7=A1=AE=E5=AE=9A=E4=B8=8D=E5=90=8C=EF=BC=89+=20=E6=89=8B?= =?UTF-8?q?=E5=86=99=E8=89=B2=E6=B8=85=E5=86=8C=E8=B7=A8=E6=96=87=E4=BB=B6?= =?UTF-8?q?=20+=20TMPDIR=20=E6=8C=89=E4=BC=9A=E8=AF=9D=E5=88=86=E5=AE=B6?= =?UTF-8?q?=20+=20=E6=8F=90=E4=BA=A4=E5=BD=92=E5=B1=9E=E5=8F=AF=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 读完 `model/Wallpaper.ts` 后指出四处,全部处理。这一提交同时改了 `client/harmony/` 与 `client/electron/`(跨端改动),所以 subject 按新约定自报家门。 ## 1 预设色板不随主题 —— **类别判错了:不是"未验",是机制上确定不同** 我上一版把"深色档预设"记成"观感未验"。pi 指出:WebUI 的 `.bg-preset-*` 写的是 `rgb(var(--c-blue-100))`,而 `--c-*` 在 `.dark` 里整体换了一套(blue-100 → `30 43 67`), 所以 **WebUI 的预设自动随主题变**;这边只有浅色那套 = 深色主题下"浅色渐变垫在深色系统表面之下", 正是这一整轮在治的病。**它不需要真机就能判**(机制写在代码里)—— 我把可判的东西 记成了"未验",这跟上一轮把"没做"写成"没验"是同一类错。 选 pi 倾向的那条(跟随主题,与 WebUI 一致): - 色板两套:`LIGHT_*` 取 CSS `:root`、`DARK_*` 取 CSS `.dark`;`paletteFor(dark)` 选一套, `layersFor(id, dark)` 按主题出层; - `isDarkMode(theme, systemColorMode)` 放在纯逻辑里:选了 dark/light 就照办, `system` 看系统当时的 `colorMode`(锚到 SDK:`COLOR_MODE_DARK = 0` / `COLOR_MODE_LIGHT = 1`; 读不到按浅色,与 WebUI 的 `:root` 默认一致); - 系统深浅从 `resourceManager.getConfigurationSync().colorMode` 读 (`Context` 基类没有 `config`;`UIAbilityContext.config` 要转型;两个枚举取值一致,都核过 SDK); - 判据:两套值与 `:root`/`.dark` **逐个相等**;每个预设的深浅两套**必须真的不同** (否则"两套"是抄了两遍);网格线色也要换;`isDarkMode` 五种输入。 - **未做**:运行期间改系统深浅色不会自动重算(要重进页面)——系统侧正确做法是订阅 `applicationContext.on('environment', …)`,记在 §7.17b。 变异:`DARK_BLUE_100` 偏一位 → 红;`paletteFor` 永远返回浅色(= 我原来那个状态)→ 红; `isDarkMode` 把系统深浅记反 → 红;页面把深浅写死成 false → 红。 ## 2 手写色清册**跨文件按类扫**(原 A2 只保护 `Theme.ets`) `Wallpaper.ts` 也有手写色。若对照是"按名字枚举"的,第 15 个色就会逃掉 —— 与 A2 要防的是同一件事,只是换了文件。现在一份清册按类扫:全 `ets/` 树里每个 `X: string = '#RRGGBB'` 都必须登记(Theme 的品牌/业务语义色,或预设色板 —— 后者常量名必须带 `LIGHT_`/`DARK_` 前缀,值由 CSS 两段比对负责)。反向也判清册过期。 变异:`Theme.ets` 加未登记色 → 红;`Wallpaper.ts` 加未登记色 → 红; 加一个"看着合规"的 `DARK_EXTRA` → 红。 ## 3 `TMPDIR` 互踩(pi 提出) 这个 worktree 可能同时有多个 agent 跑构建,而 fpm 会把 291MB 的 `linux-unpacked` **整份复制**进 `TMPDIR` —— 撞车就是随机的产物损坏。`whoami` 区分不开(大家都是 root), 所以按**会话**分家:`TMPDIR=$PWD/.tmp/${DSH_SESSION_ID:-$(id -un)-$$}` (进了 `npm run build:linux` 与 BUILD.md 的手敲命令;普通终端退化成"用户+PID")。 ## 4 提交归属变成**跑判据就看得出来**(pi 给的形状) 新的 `test/commit-hygiene.test.mjs`:扫最近 40 条提交,**同时改两侧目录**的提交 必须在 subject 里自报家门(`跨端:`)。两条防腐:基线 = 该判据文件自己的引入提交 (**历史不改**,规则管从今往后);分类逻辑拿合成输入自检 (未标注的混合提交必须判红、标注过的不许红)——否则"解析没跑起来"时它会全绿。 变异:`COMMIT_HYGIENE_BASELINE` 指到老提交 → 历史里那两个被卷进去的提交立刻判红。 ## 验证 `hvigorw assembleHap` BUILD SUCCESSFUL;`npm test` 退出码 0 (12 个判据文件全绿 + vitest 258/258;`commit-hygiene` 在本提交落地后基线生效)。 --- client/electron/BUILD.md | 15 +- client/electron/package.json | 2 +- client/electron/test/commit-hygiene.test.mjs | 154 ++++++++++++++++++ .../electron/test/cross-client-theme.test.mjs | 87 ++++++++++ .../electron/test/harmony-appearance.test.mjs | 116 ++++++++++++- client/electron/test/run-all.mjs | 3 +- .../entry/src/main/ets/model/Appearance.ts | 24 +++ .../entry/src/main/ets/model/Wallpaper.ts | 101 +++++++++--- .../entry/src/main/ets/pages/MainPage.ets | 17 +- docs/HARMONY-ALIGN-PLAN.md | 55 ++++++- 10 files changed, 530 insertions(+), 44 deletions(-) create mode 100644 client/electron/test/commit-hygiene.test.mjs diff --git a/client/electron/BUILD.md b/client/electron/BUILD.md index b03894f..3da840a 100644 --- a/client/electron/BUILD.md +++ b/client/electron/BUILD.md @@ -142,8 +142,19 @@ AGENTMAIL_USER_KEY=<用户密钥> ./agentmail-web ### deb 打不出来时:先看 `/tmp` 有没有空间(2026-09-14 实测) > 顺带一条已经**固化进脚本**的前置:本机 `/tmp` 是 tmpfs(占内存)且常年接近满, -> `npm run build:linux` 现在自带 `TMPDIR=${TMPDIR:-$PWD/.tmp}`(工作区所在大盘)。 +> `npm run build:linux` 现在自带 `TMPDIR=${TMPDIR:-$PWD/.tmp/…}`(工作区所在大盘)。 > 手敲 `electron-builder` 时请照着加 —— "记得加 TMPDIR"这种约定活不过两次踩坑(hvigor 与 fpm 各踩过一次)。 +> +> ⚠️ **`.tmp` 要按"使用者"分家**(pi 2026-09-14 指出):这个 worktree 可能同时有 +> 多个 agent/人会跑构建,而 fpm 会把 `release/linux-unpacked`(约 291MB)**整份复制**进 +> `TMPDIR` —— 两边落到同一个临时目录就是随机的产物损坏/构建失败,比"归属错"难查得多。 +> 注意 `whoami` **区分不开**(这里大家都是 root),所以用**会话**区分: +> +> ```bash +> export TMPDIR="$PWD/.tmp/${DSH_SESSION_ID:-$(id -un)-$$}" +> ``` +> +> `DSH_*` 是本机 agent 会话的环境变量;在普通终端里退化成"用户+PID",同样唯一。 本机曾以为"deb 打不出来是 fpm 的毛病"(报错停在 `fpm process failed 1`,栈顶是 portable ruby 的 `Dir.chdir`,看着像路径权限)。**实际是 ENOSPC** —— fpm 会把 @@ -156,7 +167,7 @@ grep -o 'Errno::ENOSPC' <构建日志> # 真正的报错行(栈里那行只 # 把 TMPDIR 指到大盘上再打(工作区所在文件系统,147G 可用) mkdir -p "$PWD/.tmp" -TMPDIR="$PWD/.tmp" npx electron-builder --linux deb -c.electronDownload.isVerifyChecksum=false +TMPDIR="$PWD/.tmp/${DSH_SESSION_ID:-$(id -un)-$$}" npx electron-builder --linux deb -c.electronDownload.isVerifyChecksum=false # → release/agentmail-web_0.1.0_amd64.deb(约 100MB) ``` diff --git a/client/electron/package.json b/client/electron/package.json index 8dcbd5b..0ae9341 100644 --- a/client/electron/package.json +++ b/client/electron/package.json @@ -16,7 +16,7 @@ "dev:electron": "ELECTRON_START_URL=http://localhost:5173 electron electron/main.cjs", "build": "npm run gen:bg && vite build", "build:win": "vite build && electron-builder --win", - "build:linux": "mkdir -p .tmp && vite build && TMPDIR=${TMPDIR:-$PWD/.tmp} electron-builder --linux", + "build:linux": "mkdir -p .tmp && vite build && TMPDIR=${TMPDIR:-$PWD/.tmp/${DSH_SESSION_ID:-$(id -un)-$$}} electron-builder --linux", "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "node test/run-all.mjs && vitest run", diff --git a/client/electron/test/commit-hygiene.test.mjs b/client/electron/test/commit-hygiene.test.mjs new file mode 100644 index 0000000..32591cc --- /dev/null +++ b/client/electron/test/commit-hygiene.test.mjs @@ -0,0 +1,154 @@ +/** + * 提交归属判据 —— "跨端提交必须自报家门"。 + * + * 由来(pi 2026-09-14):我这轮的鸿蒙改动被**并发写入者**的 `git add -A` + * 整片扫进了两个 WebUI 提交(提交信息写的是"联系人项玻璃卡""手势与横向滚动分家"), + * 代码是对的、归属是错的。当时的处置是补一条文档留痕(§7.17a)—— + * 但"按路径 add"这种做法**靠记性**,而记性不是判据。 + * + * 可判的形状(pi 给的):同时改了 `client/harmony/` 与 `client/electron/` 的提交, + * **必须在 subject 里显式标注**(`跨端:`)。我们本来就有"跨端判据要两侧一起改"的约定, + * 那种提交是合法的 —— 只是必须自报家门。不报的,就是被 `git add -A` 卷进去的。 + * + * 两条防腐: + * 1. **历史不改**(pi 明确说不用改)。基线 = 本判据文件自己的引入提交, + * 比它更老的提交一律豁免 —— 规则管"从今往后"。 + * 2. 分类逻辑拿**合成输入**自检(混合提交无标注 → 必须判红;有 `跨端:` → 不许红)。 + * 否则这条判据可能在"解析没跑起来"的情况下全绿。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..', '..'); + +/** 跨端提交的"自报家门"标记:subject 里出现任一个即可 */ +const MARKERS = ['跨端:', 'cross-client:', '[跨端]']; +/** 判据扫多少条提交 */ +const SCAN = 40; + +function git(args) { + return spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' }); +} + +/** + * 解析 `git log --format=%H%x09%s --name-only` 的输出 → [{hash, subject, files}]。 + * + * 纯函数,便于拿合成输入自检(见文件末尾)。 + */ +export function parseLog(out) { + const commits = []; + let cur = null; + for (const line of out.split('\n')) { + if (line.startsWith('\t')) continue; + const m = /^([0-9a-f]{40})\t(.*)$/.exec(line); + if (m) { + cur = { hash: m[1], subject: m[2], files: [] }; + commits.push(cur); + continue; + } + const f = line.trim(); + if (cur !== null && f.length > 0) cur.files.push(f); + } + return commits; +} + +/** 一趟提交该不该被要求"自报家门":两侧目录都碰了才算跨端 */ +export function isMixed(commit) { + const harmony = commit.files.some(f => f.startsWith('client/harmony/')); + const electron = commit.files.some(f => f.startsWith('client/electron/')); + return harmony && electron; +} + +/** 有没有自报家门 */ +export function isDeclared(commit) { + return MARKERS.some(m => commit.subject.includes(m)); +} + +/** 分类:返回该提交的判定结果(供判据与自检共用) */ +export function classify(commits, baseline) { + const idx = baseline === null ? -1 : commits.findIndex(c => c.hash === baseline); + // 基线之后的提交才判(基线找不到时:全判,宁可红也不要静默放过) + const inScope = idx >= 0 ? commits.slice(0, idx) : commits; + return inScope + .filter(isMixed) + .map(c => ({ hash: c.hash.slice(0, 8), subject: c.subject, declared: isDeclared(c) })); +} + +test('跨端提交必须自报家门(同时改 harmony 与 electron 的提交要标 `跨端:`)', () => { + const available = git(['--version']); + assert.equal(available.status, 0, '这条判据要能跑 git'); + const log = git(['log', `-n${SCAN}`, '--format=%H%x09%s', '--name-only']); + assert.equal(log.status, 0, 'git log 要能跑(否则这条判据无从判起)'); + const commits = parseLog(log.stdout); + assert.ok(commits.length >= 5, `要能解析到提交(实际 ${commits.length} 条)—— 解析退化了就会全绿`); + + /* + * 基线:本判据文件自己的**引入提交**。比它更老的提交(含被卷进 WebUI 提交的那两个) + * 一律豁免 —— pi 明确说历史不用改,规则管"从今往后"。 + * `COMMIT_HYGIENE_BASELINE` 可覆盖(用于验证判据真的会红)。 + */ + const envBaseline = process.env.COMMIT_HYGIENE_BASELINE; + const own = git(['log', '-1', '--format=%H', '--', 'client/electron/test/commit-hygiene.test.mjs']); + const baselineHash = envBaseline !== undefined + ? (envBaseline === '' ? null : envBaseline) + : (own.status === 0 && own.stdout.trim().length === 40 ? own.stdout.trim() : null); + + const verdicts = classify(commits, baselineHash); + const undeclared = verdicts.filter(v => !v.declared); + assert.deepEqual(undeclared.map(v => `${v.hash} ${v.subject}`), [], + '这些提交同时改了 client/harmony/ 与 client/electron/ 却没说自己是跨端提交 —— ' + + '要么在 subject 里写明(`跨端: …`),要么别用 `git add -A` 把别人的改动卷进来(按路径 add)'); +}); + +test('自检:分类逻辑对合成输入有分辨力(否则这条判据可能在"解析没跑起来"时全绿)', () => { + const mixed = { + hash: 'a'.repeat(40), + subject: 'fix(webui): 联系人项改为玻璃卡', + files: ['client/electron/src/x.tsx', 'client/harmony/entry/src/main/ets/pages/MainPage.ets'] + }; + const declared = { + hash: 'b'.repeat(40), + subject: '跨端: 通信页签两边一起改', + files: ['client/electron/src/x.tsx', 'client/harmony/entry/src/main/ets/pages/MainPage.ets'] + }; + const single = { + hash: 'c'.repeat(40), + subject: 'fix(webui): 只改 WebUI', + files: ['client/electron/src/x.tsx'] + }; + assert.equal(isMixed(mixed), true, '两侧都改 = 跨端'); + assert.equal(isMixed(declared), true); + assert.equal(isMixed(single), false, '只改一侧不算跨端(否则这条会天天误报)'); + assert.equal(isDeclared(mixed), false); + assert.equal(isDeclared(declared), true, '`跨端:` 要认出来'); + + // 基线之后:混合未标注 → 判红;标注过 → 不判 + const v = classify([mixed, declared, single], null); + assert.deepEqual(v.map(x => `${x.hash.slice(0, 2)}:${x.declared}`), ['aa:false', 'bb:true']); + /* + * 基线语义:**比基线新的**才判(git log 是新的在前)。 + * 所以数组里 mixed 在前(比 declared 新)→ 它要判;declared 自己与更老的 single 豁免。 + */ + const scoped = classify([mixed, declared, single], declared.hash); + assert.deepEqual(scoped.map(x => x.hash.slice(0, 2)), ['aa'], '基线之前的提交要豁免(历史不改)'); + // 反向:基线就是最新那条时,没有任何提交在范围内(= 刚引入这条判据时的正常状态) + assert.deepEqual(classify([declared, mixed, single], declared.hash).filter(x => x.hash.startsWith('bb')), []); + // 解析器:真实的 git log 形状要能解开 + const parsed = parseLog([ + `${'d'.repeat(40)}\t跨端: 某次跨端改动`, + '', + 'client/electron/src/a.ts', + 'client/harmony/entry/src/main/ets/a.ets', + `${'e'.repeat(40)}\tfix(webui): 只改 WebUI`, + '', + 'client/electron/src/b.ts', + '' + ].join('\n')); + assert.equal(parsed.length, 2); + assert.equal(parsed[0].files.length, 2, '文件清单要解析到(否则 isMixed 永远 false)'); + assert.equal(parsed[1].files.length, 1); +}); diff --git a/client/electron/test/cross-client-theme.test.mjs b/client/electron/test/cross-client-theme.test.mjs index 9c2e5ea..00dbc11 100644 --- a/client/electron/test/cross-client-theme.test.mjs +++ b/client/electron/test/cross-client-theme.test.mjs @@ -574,3 +574,90 @@ test('C|「有意差异」表列全了允许不同的维度,并点名品牌 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 = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + for (const m of src.matchAll(/(\w+)\s*:\s*string\s*=\s*'(#[0-9A-Fa-f]{6})'/g)) { + declared.push({ file: rel, name: m[1], value: m[2] }); + } + } + assert.ok(declared.length >= 20, `应当扫到一批手写色(Theme 17 + 预设 14),实际 ${declared.length}`); + + const presetNames = new Set([ + 'LIGHT_GRAY_100', 'LIGHT_GRAY_200', 'LIGHT_BLUE_100', 'LIGHT_BLUE_200', + 'LIGHT_GREEN_100', 'LIGHT_AMBER_100', 'LIGHT_ORANGE_100', + 'DARK_GRAY_100', 'DARK_GRAY_200', 'DARK_BLUE_100', 'DARK_BLUE_200', + 'DARK_GREEN_100', 'DARK_AMBER_100', 'DARK_ORANGE_100' + ]); + const themeNames = new Set(SELF_OWNED_COLORS); + const unregistered = declared.filter(d => + !(d.file === 'common/Theme.ets' && themeNames.has(d.name)) && + !(d.file === 'model/Wallpaper.ts' && presetNames.has(d.name)) + ); + assert.deepEqual(unregistered.map(d => `${d.file}:${d.name}=${d.value}`), [], + '这些手写色不在任何清册里 —— 要么挂进 Theme(品牌/业务语义色),要么挂进预设色板'); + + const byName = new Map(declared.map(d => [d.name, d])); + for (const n of themeNames) assert.ok(byName.has(n), `清册里的 ${n} 已不存在(清册过期)`); + for (const n of presetNames) assert.ok(byName.has(n), `预设色板清册里的 ${n} 已不存在`); + + // 预设色板的每个值都要由 CSS 的两段兜住(LIGHT_ → :root,DARK_ → .dark) + const lightCss = cssPaletteOf(':root'); + const darkCss = cssPaletteOf('.dark'); + const keyOf = { GRAY_100: 'gray100', GRAY_200: 'gray200', BLUE_100: 'blue100', BLUE_200: 'blue200', GREEN_100: 'green100', AMBER_100: 'amber100', ORANGE_100: 'orange100' }; + for (const n of presetNames) { + const m = /^(LIGHT|DARK)_(.+)$/.exec(n); + assert.ok(m, `预设色板常量名要带 LIGHT_/DARK_ 前缀(否则判据不知道跟哪一段比):${n}`); + const css = m[1] === 'DARK' ? darkCss : lightCss; + assert.equal(byName.get(n).value, css[keyOf[m[2]]], + `${n} 与 CSS 的 ${m[1] === 'DARK' ? '.dark' : ':root'} 段不一致`); + } +}); diff --git a/client/electron/test/harmony-appearance.test.mjs b/client/electron/test/harmony-appearance.test.mjs index 9c52f93..19a708e 100644 --- a/client/electron/test/harmony-appearance.test.mjs +++ b/client/electron/test/harmony-appearance.test.mjs @@ -12,12 +12,15 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } 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'); +/** SDK 位置(与 harmony-system-api 同一套默认值/环境变量) */ +const CLT = process.env.HARMONY_CLT || '/opt/huawei/command-line-tools'; const MODULE_TS = join(HARMONY_ETS, 'model/Appearance.ts'); const A = await import(pathToFileURL(MODULE_TS).href); @@ -268,7 +271,7 @@ test('★ 预设清单与 WebUI 一致(id + 顺序 + 中文标签)—— 少 assert.deepEqual(W.PRESET_IDS.map(W.presetLabel), webLabels, '预设标签必须与 WebUI 一致(用户看到的就是这两个字)'); // 每一个预设都要真能画出层来("有 id 但画不出东西"就是这个缺口的原始形态) for (const id of W.PRESET_IDS) { - const layers = W.layersFor(id); + const layers = W.layersFor(id, false); assert.ok(layers.length >= 1, `预设 ${id} 至少要有一层`); assert.ok(layers.every(l => l.colors.length > 0 || l.kind === 'grid'), `预设 ${id} 的层要有色标`); assert.ok(layers.some(l => l.kind === 'grid' || l.colors.length >= 2), `预设 ${id} 要能画出渐变`); @@ -289,7 +292,7 @@ test('★ 预设色值与 WebUI 的调色板变量逐个对照(不是凭印象 }; const used = new Set(); for (const id of W.PRESET_IDS) { - for (const l of W.layersFor(id)) { + for (const l of W.layersFor(id, false)) { for (const c of l.colors) if (c !== W.TRANSPARENT) used.add(c); if (l.lineColor) used.add(l.lineColor); } @@ -313,7 +316,7 @@ test('★ 预设色值与 WebUI 的调色板变量逐个对照(不是凭印象 } // 透明必须用关键字而不是 8 位色值(8 位色值在本仓是"手写玻璃"的证据,另有一条判据禁) for (const id of W.PRESET_IDS) { - for (const l of W.layersFor(id)) { + for (const l of W.layersFor(id, false)) { for (const c of l.colors) { assert.ok(!/^#[0-9A-Fa-f]{8}$/.test(c), `预设里的透明要用 Color.Transparent 语义(${c} 是 8 位色值)`); } @@ -324,7 +327,7 @@ test('★ 预设色值与 WebUI 的调色板变量逐个对照(不是凭印象 const cssBlock = webCss.slice(webCss.indexOf(`.bg-preset-${id}`)); const cssBody = cssBlock.slice(0, cssBlock.indexOf('}')); const cssSegments = (cssBody.match(/(radial|linear)-gradient/g) || []).length; - const mine = W.layersFor(id).filter(l => l.kind !== 'grid').length + (W.layersFor(id).some(l => l.kind === 'grid') ? 1 : 0); + const mine = W.layersFor(id, false).filter(l => l.kind !== 'grid').length + (W.layersFor(id, false).some(l => l.kind === 'grid') ? 1 : 0); assert.ok(cssSegments >= 1, `CSS 里 ${id} 要有渐变段`); assert.equal(mine >= cssSegments, true, `${id}:CSS 有 ${cssSegments} 段,鸿蒙只画了 ${mine} 层`); } @@ -334,22 +337,22 @@ test('画什么:none / preset / image 三档,图没取回来不许画空白' // 认不出的 preset → 默认档(与 WebUI 的 normalize 同一规则),不是"没有背景" assert.equal(W.normalizePreset('不认识'), 'aurora'); assert.equal(W.normalizePreset(''), 'aurora'); - assert.equal(W.layersFor('不认识').length, W.layersFor('aurora').length, '认不出要走默认档的画法'); + assert.equal(W.layersFor('不认识', false).length, W.layersFor('aurora', false).length, '认不出要走默认档的画法'); - const none = W.resolveBackground('none', 'aurora', 0.2, false); + const none = W.resolveBackground('none', 'aurora', 0.2, false, false); assert.equal(none.kind, 'none'); assert.deepEqual(none.layers, [], 'none 档不该画任何层'); - const preset = W.resolveBackground('preset', 'mint', 0.2, false); + const preset = W.resolveBackground('preset', 'mint', 0.2, false, false); assert.equal(preset.kind, 'preset'); assert.ok(preset.layers.length >= 1, 'preset 档要真的画出层来(这就是那个缺口的判据)'); assert.equal(preset.scrim, 0, 'preset 档不压暗(压暗是给图片用的)'); - const img = W.resolveBackground('image', 'aurora', 0.24, true); + const img = W.resolveBackground('image', 'aurora', 0.24, true, false); assert.equal(img.kind, 'image'); assert.equal(img.scrim, 0.24, '压暗浓度要传给画的那一层'); - const imgMissing = W.resolveBackground('image', 'aurora', 0.24, false); + const imgMissing = W.resolveBackground('image', 'aurora', 0.24, false, false); assert.equal(imgMissing.kind, 'none', 'image 档但图没取回来 → 什么都不画(画一块空白会被当成"壁纸坏了")'); }); @@ -457,3 +460,98 @@ test('★ 背景画出来了还不够:每个页面要**让出**页面底,否 assert.match(head, /@Prop bgActive: boolean = false;/, `${comp} 要声明 @Prop bgActive`); } }); + +// ───────────── 深色色板:pi 指出这是"机制上确定不同",不是"观感未验" ───────────── + +/** 从 index.css 的某个段(:root 或 .dark)里读出调色板变量的 RGB */ +function cssPalette(selector) { + const at = selector === ':root' ? webCss.indexOf(':root') : webCss.indexOf('.dark {'); + assert.ok(at >= 0, `CSS 里要有 ${selector} 段`); + let depth = 0; + let end = at; + for (let i = webCss.indexOf('{', at); i < webCss.length; i++) { + if (webCss[i] === '{') depth++; + else if (webCss[i] === '}') { + depth--; + if (depth === 0) { end = i; break; } + } + } + const body = webCss.slice(webCss.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('★ 预设色板**两套**、且深色那套与 CSS 的 `.dark` 段逐个相等(不是"观感未验",是机制)', () => { + /* + * pi 的原话(2026-09-14):WebUI 的 `.bg-preset-*` 写的是 `rgb(var(--c-blue-100))`, + * 而 `--c-*` 在 `.dark` 里整体换了一套(blue-100 → 30 43 67), + * 所以 WebUI 的预设**自动随主题变**。这边若只有浅色那套, + * 深色主题下就是"浅色渐变垫在深色系统表面之下" —— 同一个病。 + * **它不需要真机就能判**:机制写在代码里。所以从"未验"改成"判"。 + */ + const lightCss = cssPalette(':root'); + const darkCss = cssPalette('.dark'); + // 两套必须真的不同(否则"两套"是假的:同一套抄了两遍) + for (const k of Object.keys(lightCss)) { + assert.notEqual(lightCss[k], darkCss[k], `CSS 里 ${k} 的深浅两套应当不同(判据前提)`); + } + const light = W.paletteFor(false); + const dark = W.paletteFor(true); + assert.equal(light.dark, false); + assert.equal(dark.dark, true); + for (const k of Object.keys(lightCss)) { + assert.equal(light[k], lightCss[k], `浅色色板 ${k} 与 CSS :root 不一致`); + assert.equal(dark[k], darkCss[k], `深色色板 ${k} 与 CSS .dark 不一致(深色档漏了一个色就是"半深不浅")`); + } + // 每个预设的层在两种主题下都要能画出来,且**颜色确实换了** + for (const id of W.PRESET_IDS) { + const l = W.layersFor(id, false); + const d = W.layersFor(id, true); + assert.equal(l.length, d.length, `${id} 深浅两套的层数要一致`); + assert.notDeepEqual( + l.map(x => x.colors.join(',') + '|' + x.lineColor), + d.map(x => x.colors.join(',') + '|' + x.lineColor), + `${id} 的深色档与浅色档颜色相同 —— 预设没有真的跟着主题走` + ); + } + // 网格档的线色也要换 + const meshL = W.layersFor('mesh', false).find(x => x.kind === 'grid'); + const meshD = W.layersFor('mesh', true).find(x => x.kind === 'grid'); + assert.equal(meshL.lineColor, lightCss.gray200); + assert.equal(meshD.lineColor, darkCss.gray200, '网格线色也要跟着主题(否则深色下是一张白网格)'); +}); + +test('★ isDarkMode:system 要看系统当时的深浅,读不到时按浅色(与 WebUI 默认一致)', () => { + assert.equal(A.isDarkMode('dark', 1), true, '用户选了深色就照办(即使系统是浅色)'); + assert.equal(A.isDarkMode('light', 0), false, '用户选了浅色就照办'); + assert.equal(A.isDarkMode('system', 0), true, 'system + 系统深色 → 深色色板'); + assert.equal(A.isDarkMode('system', 1), false, 'system + 系统浅色 → 浅色色板'); + assert.equal(A.isDarkMode('system', -1), false, '系统还没定(NOT_SET)→ 按浅色,与 WebUI 的 :root 默认一致'); + // 数值锚到 SDK:0=深、1=浅 + const sdkConst = readFileSync(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts'), 'utf8'); + assert.match(sdkConst, /COLOR_MODE_DARK = 0/, 'SDK 里深色是 0(别记反)'); + assert.match(sdkConst, /COLOR_MODE_LIGHT = 1/, 'SDK 里浅色是 1'); + // 页面真的按主题选色板(不是写死 false) + const main = read('pages/MainPage.ets'); + assert.match(main, /isDarkMode\(snap\.theme, systemMode\)/, '主界面要按当前主题算深浅色'); + // 取调用点的正文要按**括号配对**,别用 `[^)]*` —— 里面还套着 `scrimOpacity(...)`, + // 一遇到内层 `)` 就断(这正是"邻接/窗口不是结构"那条规则,我自己先守) + const callAt = main.indexOf('resolveBackground('); + assert.ok(callAt > 0, '要能找到 resolveBackground 调用'); + let depth = 0; + let callEnd = callAt; + for (let i = main.indexOf('(', callAt); i < main.length; i++) { + if (main[i] === '(') depth++; + else if (main[i] === ')') { depth--; if (depth === 0) { callEnd = i; break; } } + } + const callText = main.slice(callAt, callEnd + 1); + assert.ok(/\bdark\b\s*\)/.test(callText), `深浅色要传给背景计划:${callText}`); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index e07b3dd..ada1524 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -68,7 +68,8 @@ const SUITE = [ // P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types ['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings', '--test']], ['test/build-stamp.test.mjs', ['--test']], - ['test/packaging.test.mjs', []] + ['test/packaging.test.mjs', []], + ['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings', '--test']] ]; // 自检 1:清单里的文件必须真的存在(写错名字 = 那条判据永远不跑) diff --git a/client/harmony/entry/src/main/ets/model/Appearance.ts b/client/harmony/entry/src/main/ets/model/Appearance.ts index c2da189..0ae1344 100644 --- a/client/harmony/entry/src/main/ets/model/Appearance.ts +++ b/client/harmony/entry/src/main/ets/model/Appearance.ts @@ -222,6 +222,30 @@ export function colorModeValue(theme: string): number { return -1; // COLOR_MODE_NOT_SET(跟随系统) } +/** + * 当前主题**实际**算不算深色 —— 预设色板要不要用深色那套由它决定。 + * + * 为什么不直接看 `theme` 就完事:`theme === 'system'` 时我们并不知道系统当时是深是浅, + * 要把系统反馈进来的 `colorMode` 一起看。数值同样与 SDK 的 + * `ConfigurationConstant.ColorMode` 对齐(0=深、1=浅、-1=未设置)。 + * + * **未设置/读不到时按浅色算**:与 WebUI 的默认(`:root` 那套)一致 —— + * 宁可"系统深浅还没定下来时先用浅色那套",也不要拿深色色板去配浅色系统表面。 + * + * ⚠️ 已知边界:用户在**应用运行期间**改系统深浅色,这里不会自动重算 + * (要重新进一次页面/重进应用)。系统侧的正确做法是订阅 + * `applicationContext.on('environment', ...)` 的配置变化回调 —— 记在文档里,没做。 + */ +export function isDarkMode(theme: string, systemColorMode: number): boolean { + if (theme === 'dark') { + return true; + } + if (theme === 'light') { + return false; + } + return systemColorMode === 0; // COLOR_MODE_DARK +} + /** 遮罩浓度:0~90 的"压暗"值 → 0~1(系统遮罩色 + 这个不透明度) */ export function scrimOpacity(bgDim: number): number { const d: number = clampNumber(bgDim, 0, 90, 12); diff --git a/client/harmony/entry/src/main/ets/model/Wallpaper.ts b/client/harmony/entry/src/main/ets/model/Wallpaper.ts index d2dce75..6c76321 100644 --- a/client/harmony/entry/src/main/ets/model/Wallpaper.ts +++ b/client/harmony/entry/src/main/ets/model/Wallpaper.ts @@ -41,18 +41,63 @@ export class PresetLayer { } /** - * 预设色板(浅色档,取自 WebUI 的调色板变量)。 + * 预设色板 —— **两套**(浅色 / 深色),值逐个取自 WebUI `index.css` 的调色板变量。 * - * 这些色**不走系统语义色**:它们是这套预设的设计内容(与品牌蓝同类), - * 系统里没有"极光渐变"这种东西。深色档下预设的观感**未验**(见文档)。 + * 这些色不走系统语义色:它们是这套预设的设计内容(与品牌蓝同类), + * 系统里没有"极光渐变"这种东西。 + * + * ⚠️ **为什么必须两套**(pi 2026-09-14 指出,这一条不是"观感未验"而是机制上确定不同): + * WebUI 的 `.bg-preset-*` 写的是 `rgb(var(--c-gray-200) / 0.55)`、`rgb(var(--c-blue-100))`, + * 而 `--c-*` 在 `.dark` 里**整体换了一套**(blue-100 → `30 43 67`)—— + * 也就是说 WebUI 的预设**自动随主题变**。若这边只有浅色那套, + * 深色主题下就会是"浅色渐变垫在深色系统表面之下",正是这一整轮在治的那种病。 + * 它不需要真机就能发现:机制写在代码里。 + * + * 判据把两套值与 `index.css` 的 `:root` 段、`.dark` 段**逐个比对**(和抓 `#BFDCFE` 同一个手法)。 */ -const GRAY_100: string = '#F3F4F6'; // --c-gray-100 -const GRAY_200: string = '#EAECF1'; // --c-gray-200 -const BLUE_100: string = '#DBEAFE'; // --c-blue-100 -const BLUE_200: string = '#BFDBFE'; // --c-blue-200(191 219 254) -const GREEN_100: string = '#DCFCE7'; // --c-green-100 -const AMBER_100: string = '#FEF3C7'; // --c-amber-100 -const ORANGE_100: string = '#FFEDD5'; // --c-orange-100 +const LIGHT_GRAY_100: string = '#F3F4F6'; // --c-gray-100 +const LIGHT_GRAY_200: string = '#EAECF1'; // --c-gray-200 +const LIGHT_BLUE_100: string = '#DBEAFE'; // --c-blue-100 +const LIGHT_BLUE_200: string = '#BFDBFE'; // --c-blue-200(191 219 254) +const LIGHT_GREEN_100: string = '#DCFCE7'; // --c-green-100 +const LIGHT_AMBER_100: string = '#FEF3C7'; // --c-amber-100 +const LIGHT_ORANGE_100: string = '#FFEDD5'; // --c-orange-100 + +const DARK_GRAY_100: string = '#20242C'; // --c-gray-100(.dark:32 36 44) +const DARK_GRAY_200: string = '#272B34'; // --c-gray-200(.dark:39 43 52) +const DARK_BLUE_100: string = '#1E2B43'; // --c-blue-100(.dark:30 43 67) +const DARK_BLUE_200: string = '#203454'; // --c-blue-200(.dark:32 52 84) +const DARK_GREEN_100: string = '#1A362B'; // --c-green-100(.dark:26 54 43) +const DARK_AMBER_100: string = '#3B301D'; // --c-amber-100(.dark:59 48 29) +const DARK_ORANGE_100: string = '#3C291F'; // --c-orange-100(.dark:60 41 31) + +/** 一套色板(浅或深)。做成类而不是散着的常量:选哪套是**一处**决定。 */ +export class Palette { + dark: boolean = false; + gray100: string = LIGHT_GRAY_100; + gray200: string = LIGHT_GRAY_200; + blue100: string = LIGHT_BLUE_100; + blue200: string = LIGHT_BLUE_200; + green100: string = LIGHT_GREEN_100; + amber100: string = LIGHT_AMBER_100; + orange100: string = LIGHT_ORANGE_100; +} + +/** 当前该用哪套色板 */ +export function paletteFor(dark: boolean): Palette { + const p: Palette = new Palette(); + p.dark = dark; + if (dark) { + p.gray100 = DARK_GRAY_100; + p.gray200 = DARK_GRAY_200; + p.blue100 = DARK_BLUE_100; + p.blue200 = DARK_BLUE_200; + p.green100 = DARK_GREEN_100; + p.amber100 = DARK_AMBER_100; + p.orange100 = DARK_ORANGE_100; + } + return p; +} /** * `transparent` 用**关键字**而不是 8 位半透明色值: @@ -119,30 +164,31 @@ function linear(angle: number, from: string, to: string): PresetLayer { * 层序 = 绘制顺序:先画的在下层。WebUI 的 CSS 里第一段是最上层(CSS 多重背景 * 是后面的在下),为保持观感一致,这里**反过来给**:CSS 里最后一段先画。 */ -export function layersFor(id: string): PresetLayer[] { +export function layersFor(id: string, dark: boolean): PresetLayer[] { const key: string = normalizePreset(id); + const pal: Palette = paletteFor(dark); if (key === 'dusk') { return [ - radial(85, 25, '55%', BLUE_200, 55), - radial(12, 80, '55%', ORANGE_100, 55) + radial(85, 25, '55%', pal.blue200, 55), + radial(12, 80, '55%', pal.orange100, 55) ]; } if (key === 'mint') { return [ - radial(78, 70, '55%', BLUE_100, 55), - radial(25, 30, '55%', GREEN_100, 55) + radial(78, 70, '55%', pal.blue100, 55), + radial(25, 30, '55%', pal.green100, 55) ]; } if (key === 'sand') { return [ - radial(80, 75, '55%', ORANGE_100, 55), - radial(20, 25, '60%', AMBER_100, 60) + radial(80, 75, '55%', pal.orange100, 55), + radial(20, 25, '60%', pal.amber100, 60) ]; } if (key === 'ink') { return [ - linear(160, GRAY_100, GRAY_200), - radial(30, 20, '60%', GRAY_200, 60) + linear(160, pal.gray100, pal.gray200), + radial(30, 20, '60%', pal.gray200, 60) ]; } if (key === 'mesh') { @@ -153,17 +199,17 @@ export function layersFor(id: string): PresetLayer[] { * 所以这一档用系统 `Canvas` 画线(仍是系统绘制能力,不是手写模糊那一类)。 * 线色与间隔照抄 CSS:gray-200 / 0.55 / 28px。 */ - g.lineColor = GRAY_200; + g.lineColor = pal.gray200; g.lineAlpha = 0.55; g.step = 28; - const base: PresetLayer = linear(180, GRAY_100, GRAY_100); + const base: PresetLayer = linear(180, pal.gray100, pal.gray100); return [base, g]; } // aurora(默认) return [ - radial(68, 82, '55%', BLUE_100, 55), - radial(82, 12, '50%', GREEN_100, 50), - radial(18, 22, '55%', BLUE_200, 55) + radial(68, 82, '55%', pal.blue100, 55), + radial(82, 12, '50%', pal.green100, 50), + radial(18, 22, '55%', pal.blue200, 55) ]; } @@ -171,6 +217,8 @@ export function layersFor(id: string): PresetLayer[] { export class BackgroundPlan { /** 'none' | 'preset' | 'image' */ kind: string = 'none'; + /** 用的是深色那套色板吗(跟着主题走,不是"未验") */ + dark: boolean = false; presetId: string = ''; layers: PresetLayer[] = []; /** 压暗(0~1)—— image 档压在图上;preset 档不用 */ @@ -184,12 +232,13 @@ export class BackgroundPlan { * 这里决定**画成什么**。`image` 档但图没取回来 → `none`: * 宁可什么都不画,也不要画一块空白(用户会以为壁纸坏了)。 */ -export function resolveBackground(bgKind: string, presetId: string, scrim: number, hasImage: boolean): BackgroundPlan { +export function resolveBackground(bgKind: string, presetId: string, scrim: number, hasImage: boolean, dark: boolean): BackgroundPlan { const plan: BackgroundPlan = new BackgroundPlan(); + plan.dark = dark; if (bgKind === 'preset') { plan.kind = 'preset'; plan.presetId = normalizePreset(presetId); - plan.layers = layersFor(plan.presetId); + plan.layers = layersFor(plan.presetId, dark); return plan; } if (bgKind === 'image' && hasImage) { diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 59ed1fc..b6a8b72 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -12,7 +12,7 @@ import { AccountManager, AccountInfo } from '../api/AccountManager'; import { SseService, SseEvent } from '../api/SseService'; import { AppearanceStore } from '../common/AppearanceStore'; import { image } from '@kit.ImageKit'; -import { AppearanceSnapshot, scrimOpacity } from '../model/Appearance'; +import { AppearanceSnapshot, isDarkMode, scrimOpacity } from '../model/Appearance'; import { BackgroundPlan, PresetLayer, TRANSPARENT, resolveBackground } from '../model/Wallpaper'; import { MailSummary, Contact, PermissionRequest, DecideResponse, SentResponse, PendingResponse } from '../model/Models'; import { @@ -1505,7 +1505,20 @@ struct MainPage { await store.syncFromServer(ctx, client); const snap: AppearanceSnapshot = store.current(); this.wallpaperImage = store.wallpaper; - this.bgPlan = resolveBackground(snap.bgKind, snap.bgPresetId, scrimOpacity(snap.bgDim), store.wallpaper !== null); + /* + * 预设色板要跟着主题走:`system` 时把系统当时反馈的 colorMode 一起看 + * (`isDarkMode`)。否则深色主题下会是"浅色渐变垫在深色系统表面之下"。 + */ + /* + * 系统当时的深浅色:从 `resourceManager` 的配置读(`Context` 基类没有 `config`, + * `UIAbilityContext.config` 要转型;而 `ctx.resourceManager.getConfigurationSync()` + * 在基类上就有)。两个枚举的取值一致,都核过 SDK: + * `ConfigurationConstant.ColorMode.DARK = 0 / LIGHT = 1`, + * `resourceManager.ColorMode.DARK = 0 / LIGHT = 1`。 + */ + const systemMode: number = ctx.resourceManager.getConfigurationSync().colorMode; + const dark: boolean = isDarkMode(snap.theme, systemMode); + this.bgPlan = resolveBackground(snap.bgKind, snap.bgPresetId, scrimOpacity(snap.bgDim), store.wallpaper !== null, dark); this.bgActive = this.bgPlan.kind !== 'none'; } diff --git a/docs/HARMONY-ALIGN-PLAN.md b/docs/HARMONY-ALIGN-PLAN.md index de756ff..dd23e4f 100644 --- a/docs/HARMONY-ALIGN-PLAN.md +++ b/docs/HARMONY-ALIGN-PLAN.md @@ -158,7 +158,7 @@ WebUI 侧 `npm test` 在 **HEAD 上就是红的**(`test/background.test.mjs` | P2a ✅ | 信息架构:「通信」一项,内部页签 收件箱/发件箱/授权(未读红、待决策橙徽标) | **点页签 → 断言落到哪个 pane**(不是断言页签个数)—— 页签状态机判据已落地(见 §7.15) | | P2b ✅ | 发件箱页:`GET /me/mail/sent`,复用列表项 | 判据:接口路径、行上主角是收件人、空态有说明(主句与 WebUI 逐字一致) | | P3 ⚠️ 主体完成 | 授权页:`GET /permission/pending` + `POST /permission/decide` | 未决口径与 WebUI 一致(无 `permission_result`)✅;拒绝可填备注且备注送出 ✅;`expired` 当场说清"这次批准不会恢复原调用" ✅。**未验**:真机上点同意/拒绝后状态是否"立刻变"(判据只钉到"决策后重新拉列表"这一层) | -| P4 ✅(P4c 上传除外) | 主题/壁纸(`/me/appearance`) | 换账号外观跟随 ✅(缓存键带账号);服务端无记录时以本地为准 ✅(§7.16);**预设 6 档都能画出来** ✅、图片壁纸渲染 ✅(§7.17 —— 这一版补的,第一版只有数据没有画面)。**未做**:P4c 上传入口。**未验**:真机观感与深色档预设 | +| P4 ✅(P4c 上传除外) | 主题/壁纸(`/me/appearance`) | 换账号外观跟随 ✅(缓存键带账号);服务端无记录时以本地为准 ✅(§7.16);**预设 6 档都能画出来** ✅、图片壁纸渲染 ✅(§7.17 —— 这一版补的,第一版只有数据没有画面)。**未做**:P4c 上传入口。**未验**:真机观感(配色/对比) | | P5 ⬜ 未做 | 悬浮玻璃导航(取代系统 TabBar) | 模糊只由壁纸层负责;列表项每项一张卡;命中区 ≥44vp。现状:底栏仍是系统 `Tabs`,只有自绘的 tabBar builder 带了 `backgroundBlurStyle`(§7.10) | | P6 ⬜ 未做 | 日历(`/calendar/events`,含 ics 导入导出) | 手势阈值与 WebUI 一致(水平 ≥40px、≥1.5× 垂直、<600ms)。**有意排序**:入口与内容一起上,不留空页签(§7.15) | @@ -767,5 +767,54 @@ pi 指出"邻接不是结构"这条已经在同一个仓库露头**三次**( 不阻塞别的阶段),照 WebUI 踩过的三条做:**先压缩再上传**(手机直出照片 4–8MB)、 **失败必须给原因**(别静默失败)、**上传成功后仍以服务端为权威**(`saved` 那套规则对图片同样适用)。 -**未验**:预设渐变与图片壁纸在真机上的实际观感(尤其是**深色模式**下预设的表现 —— -色板现在取的是 CSS 的浅色档,深色档要不要另给一套,等真机看过再定)。 +**未验**:预设渐变与图片壁纸在真机上的实际观感(配色是否好看、对比够不够)。 + +### 7.17b 预设色板**两套**:这不是"观感未验",是机制上确定不同(pi 指出) + +我上一版把"深色档预设"记成"观感未验",pi 读完 `Wallpaper.ts` 后指出这是**判错的类别**: +那边的预设写的是 `rgb(var(--c-blue-100))`,而 `--c-*` 在 `.dark` 里**整体换了一套** +(blue-100 → `30 43 67`)—— WebUI 的预设**自动随主题变**;这边若只有浅色那套, +深色主题下就是"浅色渐变垫在深色系统表面之下",正是这一整轮在治的那个病。 +**它不需要真机就能判:机制写在代码里。** + +处理(选 pi 倾向的那条:跟随主题,与 WebUI 一致): + +- 色板做成**两套**(`LIGHT_*` 取 CSS `:root`、`DARK_*` 取 CSS `.dark`), + `paletteFor(dark)` 选一套,`layersFor(id, dark)` 按主题出层; +- 深浅色的判定放在纯逻辑 `isDarkMode(theme, systemColorMode)`:用户选了 dark/light 就照办, + `system` 时看系统当时反馈的 `colorMode`(数值锚到 SDK:`COLOR_MODE_DARK = 0`、 + `COLOR_MODE_LIGHT = 1`;读不到/未设置按**浅色**,与 WebUI 的 `:root` 默认一致); +- 系统当时的深浅从 `resourceManager.getConfigurationSync().colorMode` 读 + (`Context` 基类没有 `config`,`UIAbilityContext.config` 要转型;两个枚举取值一致,都核过 SDK); +- 判据:两套值与 `index.css` 的 `:root` / `.dark` 段**逐个相等**;每个预设的深浅两套 + **必须真的不同**(否则"两套"是抄了两遍);网格线色也要跟着换;`isDarkMode` 五种输入。 + +**已知边界(未做)**:用户在应用**运行期间**改系统深浅色,这边不会自动重算 +(要重进页面/重进应用)。系统侧的正确做法是订阅 `applicationContext.on('environment', …)` +的配置变化回调 —— 记在这里,没做。 + +### 7.17c 手写色清册升级成**跨文件按类扫**(pi 建议) + +原来的 A2 只保护 `Theme.ets`;而 `Wallpaper.ts` 也有手写色(14 个预设色)。 +那边靠"从 CSS 读出来逐个对照"抓到了 `#BFDCFE`,手法是对的 —— +但若对照是**按名字枚举**的,第 15 个色就会逃掉:这正是 A2 要防的同一件事,只是换了个文件。 + +现在并成**一份清册、按类扫**:全 `ets/` 树里每个 `X: string = '#RRGGBB'` 都必须在清册里 —— +要么是 `common/Theme.ets` 的品牌/业务语义色,要么是 `model/Wallpaper.ts` 的预设色板 +(后者的值由 CSS 两段比对负责,且常量名必须带 `LIGHT_`/`DARK_` 前缀,判据才知道跟哪一段比)。 +反向也判:清册里登记了却不存在 → 清册过期。 +变异:`Theme.ets` 加一个未登记色 → 红;`Wallpaper.ts` 加一个未登记色 → 红; +加一个"看起来合规"的 `DARK_EXTRA` → 红(不在清册里就是不在)。 + +### 7.17d 并发写入的两个坑:`TMPDIR` 互踩、提交归属(pi 提出) + +- **`TMPDIR` 互踩**:这个 worktree 可能同时有多个 agent 跑构建,而 fpm 会把 + `release/linux-unpacked`(约 291MB)**整份复制**进 `TMPDIR` —— 两边落到同一个临时目录 + 就是随机的产物损坏/构建失败,比"归属错"难查得多。`whoami` **区分不开**(这里大家都是 root), + 所以按**会话**分家:`TMPDIR=$PWD/.tmp/${DSH_SESSION_ID:-$(id -un)-$$}` + (已进 `npm run build:linux` 与 BUILD.md 的手敲命令)。 +- **提交归属可判**:同时改 `client/harmony/` 与 `client/electron/` 的提交**必须在 subject 里 + 自报家门**(`跨端:`)—— 那种提交是合法的("跨端判据要两侧一起改"),只是要标出来; + 不标的多半是被 `git add -A` 卷进去的。判据在 `test/commit-hygiene.test.mjs`: + 基线 = 该判据文件自己的引入提交(**历史不改**,规则管从今往后), + 配分类逻辑的合成自检(否则"解析没跑起来"时会全绿)。