## 一、`debt-visibility` 那条红:**新文件不会自动跑一遍守卫**
```
这些文件里有"边界声明",但一次都没登记:
harmony-deviceprobe.test.mjs(2 处) ← e917b87/4880c31 新加的判据文件
```
**这是同一个洞在新文件上的复发**:上一轮我刚修完 `harmony-admin` / `harmony-imageprep`,
下一个**新建的**判据文件又踩了同一个坑。pi 之所以看见,只是因为他跑了整个套件 ——
**缺的不是"记得登记",是"新建判据文件"这个动作没有守卫**。这条形状与"写了判据忘了接线"同族,
只是这次忘的是**登记边界**。
处理:**按次数登记(2),不整文件放行** —— 整文件放行的话,将来在这个文件里写一句
真实的「这里没判 / 已知缺口」就**不会红**。那 2 处本身也不是"这块没验",
而是对**词表本身**的断言(`unverifiedReason(...)` 必须含「未验」)。
同时在 `docs/DEBTS.json` 补一笔 `deviceprobe-fixture-timing`(`where` 指向该文件)——
`debt-visibility` 的第二条要求"声明必须有对应的一笔",两处各写各的会让审计只找到一处。
这笔的**到期前提是"两份 fixture 变成当场采集而不是人工存文件"**。
⚠️ **Go 侧未能本机验证**:`go test ./internal/repo/` 在本机报
`module cache not found: neither GOMODCACHE nor GOPATH is set`。我读了
`TestDebtLedgerMatchesMeasurement`:它只校验"每笔都有 due/where"+"三笔必须同处登记",
**没有"所有 id 必须在 Go 侧也列出"的断言**,所以新增一笔不需要改 Go。
但这是**读代码得出的结论,不是跑出来的** —— 如实标成未验。
## 二、`blurStyleFor` 删除后:生产代码里 5 处注释在说一个**不存在的函数**
函数已按 pi 的裁定删除(别的会话的 `9a10ab2` 落的)。但删除后
`Wallpaper.ts`(4 处)与 `MainPage.ets`(1 处)还在用现在时提它:
```
Wallpaper.ts:242 "由 `model/Appearance.ts` 的 `blurStyleFor` 映射成系统材质档"
Wallpaper.ts:248 "页面拿它去问 `blurStyleFor`"
Wallpaper.ts:251 "`blurStyleFor` 也写了、就是没有任何调用点"
Wallpaper.ts:311 "`blurStyleFor` 里面也有一次 clamp,那是它自己的防线"
MainPage.ets:1711 "(`blurStyleFor` 那张表服务的是**材质档**…)"
```
**这正是本会话反复在消的"注释描述一份不存在的代码"**,而它现在比之前更危险:
下一个人读注释会去找一个**已经被有意删掉**的函数,找不到就会**重新实现它** ——
而"它为什么不该回来"恰恰是那次删除唯一值钱的东西。
已全部改成**过去时 + 它已删除**,并在 `Appearance.ts` 原处留了碑文(函数没了,理由不能没)。
`:251` 那处尤其要改:原文说"`blurStyleFor` 也写了、就是没有任何调用点"——
函数已不存在,这句会让读者以为**还差一个调用点没补**,而事实是**连函数都不该有**。
## 三、这条碑文判据我做了变异验证
`harmony-appearance.test.mjs` 里那条「碑文不许回来」的判据**确实在校验**(不是摆着好看):
把 `Wallpaper.ts` 那段碑文抹掉 ⇒ **红**;还原 ⇒ **绿**。
顺带核了它的**指向**:碑文现在的主要落点是 `Appearance.ts`(函数原来所在处),
而判据的正则锚的是 `Wallpaper.ts` —— 两处都有内容才过,我保留了 `Wallpaper.ts` 里的引用
(它说明"这里的 px 不是材质档"),所以判据成立。
## 四、未做
- 到期闸门那 **7 条**(pi 更正过我:`STATIC_ONLY` 是 7 不是 8,我上封记串了)**仍然没动**。
- `PROBE_DEVICE=none` 下**剩 5 条红**,都是**别的会话**新加判据但没更新登记数
(`narrow-layout` 88>64、`nav-merge` 9>8、`harmony-presets` 6>5、`commit-hygiene` 3>2)
加 `build-stamp`(`dist` 没重构建,与本次改动无因果)。**我没有替他们改**。
341 lines
14 KiB
TypeScript
341 lines
14 KiB
TypeScript
/*
|
||
* 壁纸的**画法**:预设档(渐变)与图片档 —— 纯逻辑,无 UI 依赖。
|
||
*
|
||
* 为什么单独一层:服务端存的只是 `bg_kind` + `bg_preset_id` + 参数,
|
||
* 「这个 id 该画成什么样」是客户端的事。这层决定"画哪些层、什么色、什么位置",
|
||
* 页面只负责把层交给系统原语(`radialGradient` / `linearGradient` / `Canvas`)。
|
||
*
|
||
* ⚠️ 这一层是**能力对等**的关键:pi 指出(2026-09-14)WebUI 的背景有 6 个预设渐变,
|
||
* 若鸿蒙只认 `image` 与 `none`,那"换账号后外观跟随"对预设档就是**不成立的** ——
|
||
* 用户设了预设,在鸿蒙看到的是没有背景。这是信息对等缺口,比"能不能上传壁纸"更基础。
|
||
*
|
||
* 参考实现:`client/electron/src/index.css` 的 `.bg-preset-*` 与
|
||
* `stores/backgroundStore.ts` 的 `PRESETS`(id / 标签 / 归一化规则)。
|
||
* 色值取自那边的调色板变量(浅色档),**逐一对照**,不是凭印象写的。
|
||
*
|
||
* ⚠️ 类型可擦除(无 enum / namespace / 构造器参数属性),判据用 node strip-types 直接跑它。
|
||
*/
|
||
|
||
/**
|
||
* 一层渐变(预设由 1~3 层叠出来,与 WebUI 的多重 background-image 一一对应)。
|
||
*
|
||
* `colors` 与 `stops` 分开存(而不是 `[色, 位置][]`):ArkTS 对嵌套元组数组支持有限,
|
||
* 而分开存更好判。页面按 `stops[i]` 给 `colors[i]` 组对。
|
||
*/
|
||
export class PresetLayer {
|
||
/** 'radial' | 'linear' | 'grid' */
|
||
kind: string = 'radial';
|
||
/** radial:中心点百分比(0~100);linear/grid 不用 */
|
||
cx: number = 50;
|
||
cy: number = 50;
|
||
/** radial:半径(交给系统,用百分比或 vp);linear:角度(度) */
|
||
radius: string = '55%';
|
||
angle: number = 180;
|
||
colors: string[] = [];
|
||
/** 色标位置(0~100),与 `colors` 一一对应 */
|
||
stops: number[] = [];
|
||
/** grid 用:线色 / 透明度 / 间隔 */
|
||
lineColor: string = '';
|
||
lineAlpha: number = 0;
|
||
step: number = 0;
|
||
}
|
||
|
||
/**
|
||
* 预设色板 —— **两套**(浅色 / 深色),值逐个取自 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 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 位半透明色值:
|
||
* 一是系统认识它,二是本仓有一条判据禁止 8 位色值(那类色值曾经是手写玻璃的证据)。
|
||
* 页面把它映射成 `Color.Transparent`。
|
||
*/
|
||
export const TRANSPARENT: string = 'transparent';
|
||
|
||
/** 预设清单:id 与顺序与 WebUI 的 `PRESETS` 逐字一致(判据会去比对) */
|
||
export const PRESET_IDS: string[] = ['aurora', 'dusk', 'mint', 'sand', 'ink', 'mesh'];
|
||
|
||
export function presetLabel(id: string): string {
|
||
if (id === 'aurora') {
|
||
return '极光';
|
||
}
|
||
if (id === 'dusk') {
|
||
return '暮色';
|
||
}
|
||
if (id === 'mint') {
|
||
return '薄荷';
|
||
}
|
||
if (id === 'sand') {
|
||
return '沙丘';
|
||
}
|
||
if (id === 'ink') {
|
||
return '墨色';
|
||
}
|
||
if (id === 'mesh') {
|
||
return '网格';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
const DEFAULT_PRESET: string = 'aurora';
|
||
|
||
/** 认不出的 preset id → 回到默认(与 WebUI 的 `normalize` 同一规则,避免"空白背景") */
|
||
export function normalizePreset(id: string): string {
|
||
return PRESET_IDS.indexOf(id) >= 0 ? id : DEFAULT_PRESET;
|
||
}
|
||
|
||
function radial(cx: number, cy: number, radius: string, color: string, stop: number): PresetLayer {
|
||
const l: PresetLayer = new PresetLayer();
|
||
l.kind = 'radial';
|
||
l.cx = cx;
|
||
l.cy = cy;
|
||
l.radius = radius;
|
||
l.colors = [color, TRANSPARENT];
|
||
l.stops = [0, stop];
|
||
return l;
|
||
}
|
||
|
||
function linear(angle: number, from: string, to: string): PresetLayer {
|
||
const l: PresetLayer = new PresetLayer();
|
||
l.kind = 'linear';
|
||
l.angle = angle;
|
||
l.colors = [from, to];
|
||
l.stops = [0, 100];
|
||
return l;
|
||
}
|
||
|
||
/**
|
||
* 某个预设由哪些层叠出来(与 `.bg-preset-*` 的多重背景一一对应)。
|
||
*
|
||
* 层序 = 绘制顺序:先画的在下层。WebUI 的 CSS 里第一段是最上层(CSS 多重背景
|
||
* 是后面的在下),为保持观感一致,这里**反过来给**:CSS 里最后一段先画。
|
||
*/
|
||
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%', pal.blue200, 55),
|
||
radial(12, 80, '55%', pal.orange100, 55)
|
||
];
|
||
}
|
||
if (key === 'mint') {
|
||
return [
|
||
radial(78, 70, '55%', pal.blue100, 55),
|
||
radial(25, 30, '55%', pal.green100, 55)
|
||
];
|
||
}
|
||
if (key === 'sand') {
|
||
return [
|
||
radial(80, 75, '55%', pal.orange100, 55),
|
||
radial(20, 25, '60%', pal.amber100, 60)
|
||
];
|
||
}
|
||
if (key === 'ink') {
|
||
return [
|
||
linear(160, pal.gray100, pal.gray200),
|
||
radial(30, 20, '60%', pal.gray200, 60)
|
||
];
|
||
}
|
||
if (key === 'mesh') {
|
||
const g: PresetLayer = new PresetLayer();
|
||
g.kind = 'grid';
|
||
/*
|
||
* 网格:CSS 用的是 `repeating-linear-gradient`,**系统没有对应的渐变原语**,
|
||
* 所以这一档用系统 `Canvas` 画线(仍是系统绘制能力,不是手写模糊那一类)。
|
||
* 线色与间隔照抄 CSS:gray-200 / 0.55 / 28px。
|
||
*/
|
||
g.lineColor = pal.gray200;
|
||
g.lineAlpha = 0.55;
|
||
g.step = 28;
|
||
const base: PresetLayer = linear(180, pal.gray100, pal.gray100);
|
||
return [base, g];
|
||
}
|
||
// aurora(默认)
|
||
return [
|
||
radial(68, 82, '55%', pal.blue100, 55),
|
||
radial(82, 12, '50%', pal.green100, 50),
|
||
radial(18, 22, '55%', pal.blue200, 55)
|
||
];
|
||
}
|
||
|
||
/** 背景计划:页面照着画,判据照着判 */
|
||
export class BackgroundPlan {
|
||
/** 'none' | 'preset' | 'image' */
|
||
kind: string = 'none';
|
||
/** 用的是深色那套色板吗(跟着主题走,不是"未验") */
|
||
dark: boolean = false;
|
||
presetId: string = '';
|
||
/**
|
||
* **静默兜底的痕迹**(pi 2026-09-14 裁定):服务端/WebUI 先加了新档、而这台设备是旧构建时,
|
||
* `normalizePreset` 会把认不出的 id **静默换成 aurora** —— 用户以为选的是新档。
|
||
* 这条缺省语义已登记在 `CRITERIA.md` §10,但登记只防"被误报成 bug",
|
||
* **防不住"没人知道它正在发生"**(真实环境里不会留下任何痕迹)。
|
||
*
|
||
* 所以把"发生过替换"记在这里:换过就是**原来那个 id**(非空),没换过就是空串。
|
||
* 页面拿到非空值时**打一行日志**(`hilog`/`console.info` 皆可)——
|
||
* 于是这个后果从"可能发生"变成"**可数**":出问题时能立刻确认是版本偏移,而不是猜。
|
||
*
|
||
* ⚠️ 为什么痕迹记在**返回值**里、而不在这一层直接打日志:这一层是**纯逻辑**
|
||
* (无 `@ohos` 依赖 ⇒ 判据能用 node `--experimental-strip-types` 直接跑它,
|
||
* 见 `harmony-presets.test.mjs`)。为了打一行日志而引入 `@ohos.hilog`,
|
||
* 会把"能真跑的行为判据"换成"只能读源码的形态判据" —— 那是不划算的交易。
|
||
* 记事实在这一层(可判),打日志在页面层(可观测)。
|
||
*/
|
||
presetSubstitutedFrom: string = '';
|
||
layers: PresetLayer[] = [];
|
||
/**
|
||
* 模糊强度:**服务端给的 px 原值**(0~40),页面拿它做 `.blur(px)` —— **像素半径**。
|
||
*
|
||
* ★ 只**搬运**,不在这里分档:这一层是**纯逻辑**(零 `@ohos` 依赖),
|
||
* 分档会变成第二张表,而两张表迟早分叉。**这里也再没有"档"这回事了** ——
|
||
* 曾经的 `px → 材质档` 映射函数(`Appearance.ts` 的 `blurStyleFor`)已随
|
||
* "导航条改回固定档"一起删除(2026-09-15):它没有、也不该有消费者。
|
||
* ⇒ 想加"档位"请先读 `Appearance.ts` 那段碑文,那里写着为什么不该加回来。
|
||
*
|
||
* ★ 为什么一度"只写不读":服务端那个模糊字段曾经**没有任何调用点**(服务端存、
|
||
* 两端同步,页面却没人用)
|
||
* (`harmony-appearance.test.mjs` 有一条判据把"消费侧出现次数为 0"钉住,
|
||
* 就是为了让"有人开始消费"这一刻**必须停下来**补判据而不是偷偷把 0 改成 1)。
|
||
* P4c 加背景选择器时踩到了这条线:滑杆能拖、值能存,但壁纸**一点没糊**。
|
||
* 所以这次把**消费点**补上了(`.blur(this.bgPlan.blurPx)`)。
|
||
*/
|
||
blurPx: number = 0;
|
||
/**
|
||
* 遮盖浓度(0~1):**两档都用**(预设档也压),用系统遮罩色刷一层。
|
||
*
|
||
* ⚠️ 这里原来写的是"preset 档不用",pi 2026-09-14 读 WebUI 源码后指出**两边不一致**:
|
||
* WebUI 的 `applyBackground()` **无条件**写 `--bg-dim`(默认 12 —— 与服务端
|
||
* `DefaultAppearance()` 一致;这里原写 24,是因为 WebUI 当时自写了一套 24/8,
|
||
* 2026-09-14 已统一到服务端契约),
|
||
* `.app-backdrop::after` 是 `rgb(var(--bg-scrim) / var(--bg-dim))` ——
|
||
* 遮罩**不区分档位**,预设档一样被压。它的注释写着目的:
|
||
* **「背景越花,正文越需要一层遮罩才读得动」** —— 遮罩服务的是可读性,不是装饰。
|
||
*
|
||
* 对这边的实际后果:我们已经让出了页面底(`bgActive` 一路到三个 pane),
|
||
* 于是正文直接压在原色渐变上 —— 比 WebUI 更艳更亮、更不好读。
|
||
*/
|
||
scrim: number = 0;
|
||
}
|
||
|
||
/**
|
||
* 快照 + "有没有图" → 画什么。
|
||
*
|
||
* 与 `mergeAppearance` 的分工:那边决定**用谁的值**(服务端 vs 本地),
|
||
* 这里决定**画成什么**。`image` 档但图没取回来 → `none`:
|
||
* 宁可什么都不画,也不要画一块空白(用户会以为壁纸坏了)。
|
||
*/
|
||
export function resolveBackground(bgKind: string, presetId: string, scrim: number, hasImage: boolean, dark: boolean, blurPx: number = 0): BackgroundPlan {
|
||
const plan: BackgroundPlan = new BackgroundPlan();
|
||
plan.dark = dark;
|
||
// 模糊是**全档通用**的(WebUI 的 `--bg-blur` 也不区分档位),所以先搬运、各档都带上
|
||
plan.blurPx = normalizeBlur(blurPx);
|
||
if (bgKind === 'preset') {
|
||
plan.kind = 'preset';
|
||
plan.presetId = normalizePreset(presetId);
|
||
// 留痕:认不出的 id 被静默替换时,把**原来那个 id**带出去(页面据此打一行日志)
|
||
plan.presetSubstitutedFrom = plan.presetId === presetId ? '' : presetId;
|
||
plan.layers = layersFor(plan.presetId, dark);
|
||
// 预设档**也**套遮罩:与 WebUI 的 `--bg-dim` 一致(它不区分档位),服务的是可读性
|
||
plan.scrim = scrim;
|
||
return plan;
|
||
}
|
||
if (bgKind === 'image' && hasImage) {
|
||
plan.kind = 'image';
|
||
plan.scrim = scrim;
|
||
return plan;
|
||
}
|
||
plan.kind = 'none';
|
||
return plan;
|
||
}
|
||
|
||
/**
|
||
* 模糊 px 归一:0~40 的整数。
|
||
*
|
||
* ★ 边界 40 与 `model/Appearance.ts` 的 `clampNumber(bgBlur, 0, 40, 4)` **必须同值** ——
|
||
* 两边不一致的话,服务端存 40、这边按 80 画,判定与显示就对不上了
|
||
* (曾经 `Appearance.ts` 的 `blurStyleFor` 里也有一道 clamp —— 它已删除;
|
||
* 现在这条 clamp 就是**唯一**一道,所以更不能松)。
|
||
*/
|
||
export function normalizeBlur(px: number): number {
|
||
if (!Number.isFinite(px)) {
|
||
return 0;
|
||
}
|
||
const r: number = Math.round(px);
|
||
/*
|
||
* ★ 判 `!(r > 0)` 而不是 `r < 0` —— 因为 `Math.round(-0.4)` 是 **-0**,
|
||
* 而 `-0 < 0` 是 **false**(`-0 === 0`)。用 `r < 0` 会让 `-0` 漏过去,
|
||
* 于是 `blur(-0)` 被喂进 ArkUI —— 这是个**静默**的怪值(判据把它抓出来了:
|
||
* `assert.equal(normalizeBlur(-0.4), 0)` 报的是 `+ -0`,一眼看不出问题在哪)。
|
||
*/
|
||
if (!(r > 0)) {
|
||
return 0;
|
||
}
|
||
if (r > MAX_BLUR_PX) {
|
||
return MAX_BLUR_PX;
|
||
}
|
||
return r;
|
||
}
|
||
|
||
/** 模糊上限(px)。与 `model/Appearance.ts` 的 clamp 上界同值。 */
|
||
export const MAX_BLUR_PX: number = 40;
|
||
|
||
/** 预设缩略图/选择器要显示的清单(id + 标签) */
|
||
export function presetChoices(): string[] {
|
||
return PRESET_IDS;
|
||
}
|