跨端: 预设色板两套(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:
@ -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)
|
||||
```
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
154
client/electron/test/commit-hygiene.test.mjs
Normal file
154
client/electron/test/commit-hygiene.test.mjs
Normal 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);
|
||||
});
|
||||
@ -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'} 段不一致`);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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}`);
|
||||
});
|
||||
|
||||
@ -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:清单里的文件必须真的存在(写错名字 = 那条判据永远不跑)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
|
||||
@ -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`:
|
||||
基线 = 该判据文件自己的引入提交(**历史不改**,规则管从今往后),
|
||||
配分类逻辑的合成自检(否则"解析没跑起来"时会全绿)。
|
||||
|
||||
Reference in New Issue
Block a user