跨端: 预设色板两套(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

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