/* * 壁纸的**画法**:预设档(渐变)与图片档 —— 纯逻辑,无 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; }