跨端: 预设色板两套(pi:这不是"观感未验"而是机制上确定不同)+ 手写色清册跨文件 + TMPDIR 按会话分家 + 提交归属可判

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` 在本提交落地后基线生效)。
This commit is contained in:
2026-09-14 14:57:18 +08:00
parent 0ce8b29546
commit 22c9be7181
10 changed files with 530 additions and 44 deletions

View File

@ -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
```

View File

@ -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",

View File

@ -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);
});

View File

@ -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_ → :rootDARK_ → .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'} 段不一致`);
}
});

View File

@ -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-14WebUI 的 `.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('★ isDarkModesystem 要看系统当时的深浅,读不到时按浅色(与 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 默认一致');
// 数值锚到 SDK0=深、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}`);
});

View File

@ -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清单里的文件必须真的存在写错名字 = 那条判据永远不跑)

View File

@ -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);

View File

@ -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-200191 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-200191 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.dark32 36 44
const DARK_GRAY_200: string = '#272B34'; // --c-gray-200.dark39 43 52
const DARK_BLUE_100: string = '#1E2B43'; // --c-blue-100.dark30 43 67
const DARK_BLUE_200: string = '#203454'; // --c-blue-200.dark32 52 84
const DARK_GREEN_100: string = '#1A362B'; // --c-green-100.dark26 54 43
const DARK_AMBER_100: string = '#3B301D'; // --c-amber-100.dark59 48 29
const DARK_ORANGE_100: string = '#3C291F'; // --c-orange-100.dark60 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` 画线(仍是系统绘制能力,不是手写模糊那一类)。
* 线色与间隔照抄 CSSgray-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) {

View File

@ -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';
}

View File

@ -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 踩过的三条做:**先压缩再上传**(手机直出照片 48MB
**失败必须给原因**(别静默失败)、**上传成功后仍以服务端为权威**`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`
基线 = 该判据文件自己的引入提交(**历史不改**,规则管从今往后),
配分类逻辑的合成自检(否则"解析没跑起来"时会全绿)。