# 自定义壁纸

[← 返回 README](../README.md) · [文档索引](./index.md)

给界面换一张背景图。图片是**宿主磁盘上的真文件**，写进去即时生效，下次打开自动用上。

**设置界面的入口**有两条路，都通向同一节：

| 从哪进 | 怎么走 |
| --- | --- |
| **插件详情页** | 左侧「插件」→ 列表里的 `dsh-custom-css` → 详情页最下面的「自定义壁纸」一节 |
| **设置 → 通用** | 「自定义 CSS」那一行最下面的链接「自定义壁纸…」——点它跳过去 |

设置行里**只有那一行链接**，没有壁纸控件：那一行的主题是样式表，多摆一套控件等于让它同时回答
两件事（实测那一版把行撑高了约 535px，而这一列表里别的行都是一行）。链接走插件管理器提供的
`pluginNavigation.openBundle()`，**在点击时才去读**，服务不在时退回「点侧边栏的『插件』入口」；
更细的理由见 [API 与依赖](#跳转是怎么做的)。

> 它曾经在设置行里挂过**整份面板**，后来**删掉了** —— 命名、样式、说明现在都只维护一份。

这一节自己带 section chrome：标题、一句说明、控件、以及一组「事实」（图片存哪、上限、
和皮肤的关系）。这是 `plugins.detail.section` 的官方约定 —— **每个条目自己画 section**，
并且对它没有话可说的 subject **返回 `null`**（所以别的插件页面上不会多出这一块）。

插件详情页上还有**第二节**：**壁纸设置**（轮换 / 遮罩 / 模糊 / 角落时间），见
[壁纸设置](#壁纸设置)。两节是两个独立注册的 section，order 决定先后。

---

## 它做什么

| 控件 | 效果 |
| --- | --- |
| **选择图片 / 更换图片** | 从本机挑一张图（`accept="image/*"`），上传到宿主，立刻成为壁纸 |
| **或 URL** | 贴一个已经在别处的图片地址：`https://…`、`file:///…`、`data:image/…`，或站内路径（单个 `/` 开头） |
| **移除** | 壁纸撤掉，界面回到原来的样子（图片文件仍在磁盘上，随时能从「用过的」里点回来） |
| **填充** | `铺满`（cover）/ `完整 · 留边`（contain）/ `拉伸 · 填满`（fill）/ `平铺`（tile，`repeat` + `auto`） |
| **界面浓度** | 界面自己那层底色留多少，0–60%。**0 = 界面完全遮住壁纸（文字最清楚）**；越大壁纸越明显。60 时底色仍保留 14–34% 的自身颜色（不是全透明） |
| **明暗** | 给壁纸加一层 `filter: brightness()`，−60%…+60%，用来把太亮的图压下去 |
| **用过的** | 宿主上还留着的图，点缩略图就换回去（换图不动其它设置） |
| **文件夹 + 重新读取** | 显示图片目录并重新列出「用过的」——在别处删过文件后用它刷新 |


图片**不进 `localStorage`**：上传后宿主回一个 `url`，浏览器只保存这个 `url` 加几个设置。
所以配置文件很小，而同一张图换回来也不用再传一遍。

### 也可以直接贴一个 URL

「或 URL」那一行是第二条上图片的路，给「图已经在别处」的情况：`https://…`、`file:///…`、
`data:image/…`，或站内路径（以单个 `/` 开头）。地址在**按下按钮时**才校验，被拒时那一行下面
直接说明原因，**当前壁纸不动** —— 打错一个字符不会把已经调好的背景弄没。

| 接受 | 拒绝 |
| --- | --- |
| `https://host/a.jpg` · `http://…` · `file:///C:/a.png` | `javascript:` 与任何非 `http(s)/file/data:image` 的协议 |
| `data:image/png;base64,…` | `data:text/html;…`（不是图片） |
| `/dsh-custom-css/wallpaper?name=…`（站内路径） | `//host/a.jpg`（协议相对：是别处的地址，不是路径） |
| 含控制字符或 `</style` 的字符串 | |

## 壁纸设置

插件详情页的**第二节**，四项都在插件自己这一侧生效 —— 改完立刻写进注入的样式表，
不需要刷新、不需要重启宿主。

| 设置 | 范围 | 说明 |
| --- | --- | --- |
| **轮换** | 关 / 5 分钟 / 30 分钟 / 每小时 / 每天 | 从「用过的」里换下一张；库里不足两张时会说明 |
| **遮罩** | 0–80% | 压在**图片**上的一层暗色 |
| **模糊** | 0–24px | 作用在图片上的 `blur()` |
| **角落时间** | 开 / 关 | 右下角半透明时钟；只在这个页面**有壁纸**时出现，关掉即移除 |

下面还有一组**编辑器**偏好（保存延迟 / 校验严格度 / 行号 / Tab 宽度），它们与壁纸无关，
所以面板里用一条 `.dshUi_group` 分组标题隔开。这一节的完整命名见
[命名](./naming.md) 的 A3。

| 编辑器设置 | 默认 | 作用 |
| --- | --- | --- |
| **保存延迟** | 400ms | 打字停下来多久才写盘 |
| **校验** | 语法 + 变量体检 | 状态行报什么；「关闭校验」不动写盘失败那类消息 |
| **行号** | 开 | 关掉是**隐藏**那一列（`.dshCc_noGutter`），不是卸载 |
| **Tab 宽度** | 2 空格 | Tab 键与「格式化」共用 |
| **排除名单** | `:root, html, body` | 逗号分隔的选择器；**只匹配元素自己**（不看祖先）。命中的元素不会成为预览样本，拾取器也不给候选 —— 用来排除「整页」 |

**「遮罩」与「界面浓度」是两件事**，这也是它们分在两节的原因：界面浓度改的是**界面**自己
留多少底色（令牌），遮罩暗化的是**图片**本身。一张太亮的照片要的是后者。

### 一个 `filter`，不是两条

亮度与模糊必须拼成**同一条** `filter` 声明：CSS 里同一个属性写两次，后一条**替换**前一条，
不是叠加。实测症状是「拖着模糊滑块，图还是清晰的」—— 计算值只剩 `brightness(0.45)`，
`blur()` 从没生效。现在输出的是 `filter:brightness(0.45) blur(12px);`。

### 设置存在哪

`settings.json.css` —— 与壁纸记录**分开**的一张普通样式表文件（内容恰好是 JSON）：

```json
{
  "rotate": "300",
  "scrim": 40,
  "blur": 12,
  "clock": true,
  "saveDelay": 400,
  "validate": "full",
  "lineNumbers": true,
  "tabWidth": 2
}
```

分开的理由是语义：`wallpaper.json.css` 回答「现在挂的是哪张图」，偏好不是那个答案的一部分。
浏览器里另有一份 `localStorage` 副本，用于首屏之前就应用。

**轮换的时间值是白名单**：`rotate` 只认那五个值，别的一律当「关」。这样手改记录也arm 不出一个
界面里没有开关能停掉的计时器 —— 一个观察不到的定时器就是一个关不掉的定时器。

## 它把东西写在哪

```
$DSH_HOME/custom-css/
├── .wallpapers/
│   └── wallpaper-<sha256>.png        ← 图片本体，名字就是内容的哈希
├── wallpaper.json.css                ← 壁纸记录（图片 URL + 四个设置）
├── settings.json.css                 ← 插件设置（轮换 / 遮罩 / 模糊 / 时钟）
└── custom.css · active.json · .history/   ← 样式表那一套，不受影响
```

`wallpaper.json.css` 是一张**普通的样式表文件**，只是内容恰好是 JSON：

```json
{
  "image": "/dsh-custom-css/wallpaper?name=wallpaper-a4bc….png",
  "fit": "cover",
  "transparent": 26,
  "brightness": 0
}
```

这么做是有意的：它跟着样式表一起被备份、被同步、被任何编辑器打开，而且**不需要为它新增
一个存储格式**。它出现在文件下拉里，所以想手改也能手改；改坏了（不是合法 JSON）就退回
浏览器里那份，不会把界面弄没。面板上面**看不到**它的原因只是它排在列表里而已 —— 它和别的
样式表一样能被选中，只是选它之后编辑器显示的是一段 JSON。

## 三个设计决定

### 壁纸自己负责「让界面透出来」

「壁纸放上去看不见」是这类功能的头号问题，而原因不在壁纸：DSH 用
`--dsw-alias-bg-base` 画外壳，那一层是不透明的。所以这个功能**同时**做两件事：

1. 画图（`body::before`，`position: fixed` + `background-attachment: fixed`）；
2. 把界面自己的几个背景令牌改成**自身颜色 × 浓度**：
   `color-mix(in srgb, light-dark(#ffffff,#16181c) <n>%, transparent)`。

**为什么用字面色而不是 `var(--dsw-…)`**：一个自定义属性引用它自己正在定义的那个令牌是
**循环**，整条声明会被丢弃。所以这里写的是 DSH 那两个实际颜色（浅 `#ffffff` / 深 `#16181c`），
四个令牌各按自己的面积分档（base 14% / layer-1 30% / layer-2 42% / module 34%，
与亚克力皮肤同一套数值）。

**深色那一档写在 `html body[data-ds-dark-theme]` 里**，不是靠 `light-dark()` 一招走天下 ——
`opacity` 这类非颜色属性不吃 `light-dark()`，而 DSH 的深色令牌定义在
`body[data-ds-dark-theme]`（特异性更高）上，光写 `body` 会「浅色下对、深色下悄悄不生效」。

### 图片层带 `!important`，界面浓度不带

这是**踩出来的**，实测记录在案：开发实例里装着亚克力皮肤（它自己也有 `body::before`），
一开始壁纸的规则和它**特异性相同**，而皮肤的表后注入 → **皮肤赢，图根本没画上去**，
面板却老老实实写着「已保存」。所以：

- **图片层**（`background-image/size/position/repeat/attachment` + `opacity`）带
  `!important`，并且选择器写成 `html body::before` 比皮肤的 `body::before` 高一档。
  一个皮肤自带壁纸，正是这个面板要被用来替换它的场景；**壁纸输给渐变，与坏掉没有区别**。
- **界面浓度那几个令牌不带 `!important`**：皮肤自己设了这些令牌时，**皮肤赢**，
  面板会在这种情况下多出一行说明。理由是皮肤在这件事上表达的是更具体的意图
  （哪个面留多少白是它整套配色的核心），而插件的浓度只是一个起始值。
  想让面板说了算，把皮肤里那几个 `--dsw-alias-bg-*` 删掉即可。

想让自己的样式表强行压过壁纸层，带 `!important` 写自己的 `body::before` —— 那条路一直开着。

### 面板挂在这个插件自己的页面上

`plugins.detail.section`（插件管理器的详情页）注册**一次**。这个座位是**每个插件的详情页都会渲染**
的，所以组件自己检查传下来的 `subject.pkg.name`，不是本插件就返回 `null` —— 别的插件页面上不会多
出一块和它无关的东西。subject 的 `kind` 也要对：它是 `bundle`（本插件是一个 bundle），
同名的一个 `item`（内建插件）是**另一页**，这里同样返回 `null`。

> 座位是后来才有的：早期 DSH 没有 `plugins.detail.section`，那时 `inject` 只是**一直等**，
> 不报错也不影响插件本身。设置行那一处曾经也挂过一份，已删除（见文首）。

### section chrome 的取值来源

「自己画 section」这句话有具体含义，而这一节的取值不是自己编的，是从**插件管理器自己的产物**里
逐值抄的：

| 部位 | 取值 | 出处 |
| --- | --- | --- |
| 一节 | `display:flex;flex-direction:column;gap:12px` | `.X_2TxG_detailSection` |
| 标题 | `14px / 500`，`margin:0` | `.X_2TxG_sectionTitle` |
| 事实列表 | `dl` + 成对的 `dt`/`dd` | `SourceSection` 的 `.facts` |
| 按钮 / 下拉 | 36px / 34px、`--dsw-radius-md`、`bg-module-platform` | 插件卡片与设置行的控件规格 |

那个类名的**哈希**（`X_2TxG`）会随 DSH 构建变，所以抄的是**值**不是名字 —— 这正是
[已知坑](./known-issues.md) 第一条的做法。

### 跳转是怎么做的

设置行那个「自定义壁纸…」链接走的是**插件管理器提供的服务**：

```js
ctx.get('pluginNavigation').openBundle('dsh-custom-css')
```

它是插件管理器 `ctx.reflect.provide('pluginNavigation', …)` 出来的（内建插件都这么跳，
例如 `dsh-experimental-client-ui-voice-input` 的「打开设置」）。

**为什么不写进 `exports.inject`**：那是**声明依赖**，一个没有插件管理器的组合会因此永远
不激活这个插件 —— 一行链接换掉整个插件不划算。所以它在**点击时**用 `ctx.get` 取一次，
取不到就退回 DOM：找侧边栏里 `aria-label` 是「插件」的入口并点它，认不出来就什么都不做
（与拾取器用的 `openSettingsSurface` 同一条规矩：**错误的点击比没完成的手势更糟**）。

`openBundle` 会把左侧主面板切到插件管理器并打开本插件那一页；设置对话框**不会自己关掉**，
按 Esc 关掉它，插件那一页就在下面等着。


## 已知限制

- **一次只能挂一张图**（记录里一个 `image`），「用过的」是切换器，不是叠加。
- **只认本机文件上传**，不填 URL、不下载网图。想用在线图片或 `file://` 路径，直接写样式表。
- **一张图 32 MiB 以内**（解码后）。超了会在**读取之前**就被挡下来，并给出提示。
- 壁纸层是 `body::before`：**未认证页面上的 `file://` 图**不受影响（不用 file），
  但**跨域 iframe 里的界面**（侧栏浏览器面板）看不到壁纸，那是另一个文档。
- 壁纸的 `background-attachment: fixed` 与 `background-size` 是**锚定视口**的，
  所以它不随内容滚动 —— 这是刻意的（见 [已知坑](./known-issues.md) 里 `inset` 那条的教训）。

## 排查「壁纸没出现」

| 现象 | 多半是 |
| --- | --- |
| 选完图，界面没变化 | 「界面浓度」是 0（界面全遮住图）；调大它 |
| 图出现了，但又被一层颜色盖住 | 某个皮肤/样式表给 `body::before` 或背景令牌写了 `!important`；见 [已知坑](./known-issues.md) |
| 面板显示「宿主不接受这张图」 | 不是浏览器认得的图片类型（或超过 32 MiB） |
| 重启 dsh 后壁纸没了 | 宿主记录没写成：看 `$DSH_HOME/custom-css/wallpaper.json.css` 在不在；不在则这次改动只留在浏览器里 |
| 只有这一个浏览器有壁纸 | 同上 —— 浏览器副本在，宿主副本不在。选一次图会重写宿主记录 |

## 相关

- [host 侧文件接口](./api-host.md#壁纸的三个端点) —— 三个端点的请求/响应与拒绝规则
- [命名表](./naming.md) —— 面板里每个控件的名字
- [设计一致性](./design-tokens.md) —— 控件规格的出处
- [`examples/acrylic.css`](../examples/acrylic.css) —— 自带壁纸的皮肤，也是「皮肤赢」那条规则的例子
