# QuartzPage 主题包配置文档

> 文档-主题包版本 1.6.5（协议 1.6.5）

---

## 目录

1. [工作原理](#1-工作原理)
2. [存储方式](#2-存储方式)
3. [服务端接口约定](#3-服务端接口约定)
4. [字段总览](#4-字段总览)
5. [字段详解](#5-字段详解)
6. [安全与限制](#6-安全与限制)
7. [完整示例：Racing！主题包](#7-完整示例racing主题包)
8. [示例解析](#8-示例解析)
9. [常见错误码](#9-常见错误码)

---

## 1. 工作原理

导入流程完全在浏览器端完成，零后端：

```
用户输入 key
      │
      ▼
SHA-256(key) ────────────────────► 得到 64 位十六进制 hash
      │
      ▼
GET https://page.guapfun.top/qtp?t=<时间戳>      （主题包清单，纯文本）
      │
      ▼
在清单里查找 hash ──► 命中则拿到该主题包的 JSON URL
      │
      ▼
GET <主题包 JSON URL> ──► 校验字段 ──► 应用
      │
      ▼
下载全部素材（壁纸/图标/字体）到 IndexedDB ──► 生成 Blob URL ──► 应用
```

**关键点：key 与 hash 用完即丢。** 导入成功后，写入本地配置的只有主题包内容本身，
`tp_key` 字段会在保存前被删除：

```js
delete rec.tp_key;   // 用完即丢：hash 绝不落盘
```

所以同一个 key 不会被浏览器记住，卸载后需要重新输入。

---

## 2. 存储方式

主题包用**两套存储**，分别存放「配置」和「素材」：

### 2.1 配置 → localStorage

主题包解析后的元数据存在 QuartzPage 主配置对象里：

```
localStorage['startpage_config']  →  state.config.themePack
```

`state.config.themePack` 的形状（只保留必要字段）：

| 字段 | 类型 | 说明 |
|---|---|---|
| `active` | boolean | 是否启用 |
| `importedAt` | number | 导入时间戳 |
| `source` | string | 主题包 JSON 的原始 URL |
| `name` / `nameEn` / `author` | string | 名称与作者 |
| `color` / `accentRgb` | string | 强调色 |
| `vars` | object | 全局 CSS 变量 |
| `modeVars` | object | 按显示模式分组的变量 |
| `wallpapers` | array | 壁纸清单（含默认壁纸） |
| `wallpaperCurrent` | string | 当前选中的壁纸 URL |
| `dim` | boolean \| null | 压暗壁纸开关 |
| `rotateMinutes` | number | 壁纸轮换间隔（分钟） |
| `icons` / `folderIcon` / `startIcon` | object / string | 图标配置 |
| `fontUrl` / `fontFallbackUrl` | string | 字体文件 URL |
| `disableModes` / `disableUiModes` | array | 被禁用的模式 |
| `forceMode` / `forceUiMode` | string | 强制模式 |
| `chrome` | object | 任务栏/窗体外观变量 |
| `uiCss` | string | 主题包自带 CSS |

> **不存储** `tp_key`。

### 2.2 素材 → IndexedDB

壁纸、图标、字体这类二进制文件不适合塞进 localStorage（base64 膨胀 + 5 MB 配额 + 同步阻塞），
因此使用 IndexedDB：

```
数据库：QuartzThemePackDB
对象仓库：assets
主键：id
```

每条记录的形状：

```js
{
  id:    'wp:0' | 'icon:notes' | 'icon:__folder' | 'icon:__start' | 'font' | 'fontfb',
  blob:  Blob,          // 文件本体
  mime:  'image/jpeg',  // 类型
  url:   'https://…',   // 原始下载地址（便于排查）
  size:  1234567,       // 字节数
}
```

`id` 的命名规则：

| id | 内容 |
|---|---|
| `wp:0` … `wp:N` | 第 N 张壁纸 |
| `icon:<app>` | 系统应用图标（app 名见下方） |
| `icon:__folder` | 文件夹图标（`folder_icon`） |
| `icon:__start` | 开始按钮图标（`start_icon`） |
| `font` | 主字体 |
| `fontfb` | 兜底字体 |

### 2.3 为什么素材要本地化

下载完成后，通过 `URL.createObjectURL(blob)` 生成 Blob URL 直接使用。
好处是**第二次打开零网络请求**——即使断网也能秒开，壁纸和字体不会闪白。

卸载主题包时会 `tpDbClear()` 清空仓库，并 `revokeObjectURL` 释放内存。

### 2.4 为什么不用 Cache Storage

QuartzPage 支持以 `file://` 直接打开，而 Cache Storage 在 `file://` 下不可用，
IndexedDB 则是可用的。

---

## 3. 服务端接口约定

### 3.1 主题包清单（`/qtp`）

一个纯文本文件，**每两行一组**：

```
hash_key:<该主题包 key 的 SHA-256，64 位十六进制>
url:<主题包 JSON 的完整地址>
```

多组之间用空行分隔，例如：

```
hash_key:ef98bc7c04456e4b3282171568b60f1eae3077b30af5fd4932c6fa5cd59dae03
url:https://page.guapfun.top/tp/racing.json

hash_key:7d0c61ef3cf97ad2e24e5a025c08e976b94dec3dff651e61a4fc69b03e1c2622
url:https://page.guapfun.top/tp/xp.json
```

**解析器兼容以下写法**（大小写不敏感）：

- key 标签：`hash_key:` / `hash:` / `key:` / `哈希:`
- URL 标签：`url:` / `地址:` / `链接:`
- 两行顺序可互换
- 支持单行写法 `hash: url:` 同行
- `#` 与 `//` 开头的行视为注释
- hash 可带 `0x` 前缀，允许 `:` `_` `-` 分隔符
- hash 可省略，此时该条目的 URL 作为兜底

> 清单请求会附带 `?t=<时间戳>` 防缓存，**服务端必须允许查询参数**。
> 清单请求超时 15 秒。

### 3.2 主题包 JSON

任意 HTTPS 地址，返回 `application/json`。
**素材下载单项超时 25 秒**（字体较大，所以给得宽）。

### 3.3 CORS

若主题包 JSON 与素材跨域，服务器必须返回允许跨域的响应头，否则浏览器会拦截。

---

## 4. 字段总览

### 必填

| 字段 | 类型 | 说明 |
|---|---|---|
| `tp_key` | string | key 的 SHA-256，用于二次校验 |
| `color` | string | 强调色，`#RRGGBB` / `#RGB` 或 `"r,g,b"` |
| `wallpapers` | array | 壁纸列表，**至少 2 项** |

### 基础信息

| 字段 | 类型 | 说明 |
|---|---|---|
| `name` / `title` | string | 主题包名（≤40 字符） |
| `name_en` | string | 英文名 |
| `author` | string | 作者（≤40 字符） |
| `accent` | string | 同 `color`，若存在则覆盖 |

### 壁纸

| 字段 | 类型 | 说明 |
|---|---|---|
| `wallpapers` | array | `[{url, name, name_en}]`，也可直接写 URL 字符串 |
| `wallpaperDefault` | string | 默认壁纸，须在上面的列表中 |
| `dim` | boolean | 是否默认压暗壁纸 |
| `rotate_minutes` | number | 壁纸轮换间隔，1–1440 分钟，默认 5 |

### 配色与模式命名

| 字段 | 类型 | 说明 |
|---|---|---|
| `dark_color` | string | 深色模式强调色 |
| `light_color` | string | 浅色模式强调色 |
| `mode.mistvfx` | string | 拟雾动效强调色 |
| `mode.dark_name` / `dark_name` | string | 深色模式显示名（≤24 字符） |
| `mode.light_name` / `light_name` | string | 浅色模式显示名 |
| `mode.glass_name` / `glass_name` | string | 拟雾动效显示名 |
| `vars` | object | 全局 CSS 变量（见 [5.6](#56-vars--mode_vars)） |
| `mode_vars` / `modeVars` | object | 按模式分组的变量 |

### 字体

| 字段 | 类型 | 说明 |
|---|---|---|
| `font` | string | 字体文件 URL（TTF/WOFF/WOFF2） |
| `font_fallback` | string | 兜底字体 URL（可选） |
| `font_family` | string | 自定义字体族名（可选） |

### 图标

| 字段 | 类型 | 说明 |
|---|---|---|
| `icons` | object | `{app名: 图片URL}`，只替换列出的应用 |
| `folder_icon` | string | 文件夹图标 |
| `start_icon` | string | 开始按钮图标 |

### 模式限制

| 字段 | 类型 | 说明 |
|---|---|---|
| `disable_modes` | array | 置灰禁用的**桌面模式** |
| `disable_uimodes` | array | 置灰禁用的**显示模式** |
| `force_mode` | string | 导入后强制切到的桌面模式 |
| `force_uimode` | string | 导入后强制切到的显示模式 |

### 外观

| 字段 | 类型 | 说明 |
|---|---|---|
| `chrome` | object | 任务栏 / 窗体样式变量（15 个键） |
| `icon_bg` | object | 桌面图标背景（颜色 / 透明度 / 形状） |
| `ui_css` | string | 主题包自带 CSS（≤20000 字符） |
| `radius` | string | 圆角，如 `"8px"` 或 `"0"` |

---

## 5. 字段详解

### 5.1 三种桌面模式

| 值 | 中文名 | 说明 |
|---|---|---|
| `original` | 水镜 | 桌面铺书签网格，底部浮一条 Dock |
| `column` | 立方 | 纵向排布，底部一条任务栏 |
| `minimal` | 极简 | 只有一个搜索框 |

### 5.2 三种显示模式

| 值 | 中文名 | 说明 |
|---|---|---|
| `dark` | 深色 | 深色底 |
| `light` | 浅色 | 浅色底 |
| `glass` | 拟雾动效 | 雾化玻璃质感 |

> ⚠️ 注意 `original` 对应的是**水镜**，不是「默认」。这是最常见的误解。

### 5.3 `disable_modes` / `force_mode`

```json
"disable_modes": ["original", "minimal"],
"force_mode": "column"
```

- 被禁用的模式在「设置 → 外观 → 模式选择」里会**置灰**（透明度 0.35 + 灰度滤镜），
  且**无法点击**——只有 CSS 是不够的，点击处理里也会拦截。
- 不只设置面板：底栏「更多」菜单的模式循环和 AI 工具调用同样会被拦截。
- 如果当前正处在被禁用的模式上，会自动切到 `force_mode`；若不填，则切到第一个可用模式。

### 5.4 `disable_uimodes` / `force_uimode`

语法同上，作用于深色/浅色/拟雾动效。

### 5.5 模式命名

`dark_name` / `light_name` / `glass_name` 可以给三种显示模式改名，
例如 XP 主题包把所有模式都命名为「Luna 蓝」。
**这些名称在 QuartzPage 内没有任何编辑入口，只能由主题包修改。**

### 5.6 `vars` / `mode_vars`

只接受以下白名单变量（写不写 `--` 前缀都可以）：

| 变量 | 作用 |
|---|---|
| `--accent` | 强调色 |
| `--accent-rgb` | 强调色 RGB，格式 `"255,40,0"` |
| `--on-accent` | 强调色上的文字色 |
| `--on-accent-rgb` | 同上 RGB |
| `--blur` | 界面模糊度 |
| `--bg-blur` | 背景模糊度 |
| `--text` | 主文字色 |
| `--text-dim` | 次要文字色 |
| `--text-faint` | 更淡的文字色 |
| `--glass` | 玻璃底色 |
| `--glass-border` | 玻璃边框色 |

白名单外的变量会被忽略并记入 `issues`。
**值长度上限 64 字符，且不能包含 `; { } < > \` 或 `url(` / `expression(` / `@import` / `javascript:`。**

`mode_vars` 按显示模式分别生效，切换模式时自动重算：

```json
"mode_vars": {
  "dark":  { "--text": "#f5f5f7" },
  "light": { "--text": "#000000" }
}
```

> 注意：`vars` 里的 `--accent` / `--accent-rgb` **不会**直接写进 `:root`，
> 而是由 `dark_color` / `light_color` / `mode.mistvfx` 按当前模式解析后统一注入。

### 5.7 图标

`icons` 的键必须是 QuartzPage 的系统应用名，可选：

```
calendar  paint  calculator  notes  map  timer  music  terminal  album  translate
```

不在列表里的键会被跳过（`skip_icon:<app>`）。
非 HTTP(S) 地址会被拒绝（`bad_icon:<app>`）。

`folder_icon` 会作用到桌面上所有文件夹类型书签；
`start_icon` 会渲染成任务栏最左侧的开始按钮（水镜模式的 Dock 与立方模式的任务栏都支持）。

> 💡 立方模式下，`start_icon` 会替换任务栏左侧原本显示的 `favicon.png`。
> 如果是完整的按钮图（自带底色和文字），建议不要再用 `ui_css` 叠加背景和文字，否则会出现双重底色。

### 5.8 `chrome` 外观变量

15 个受控变量，用于任务栏与窗体外观。与 `vars` 不同，**这里允许更长的值（≤400 字符）**，
因为 `linear-gradient(...)` 轻易超过 64 字符：

| 变量 | 作用 |
|---|---|
| `--xp-taskbar-bg` | 任务栏背景（通常是渐变） |
| `--xp-taskbar-border` | 任务栏边框色 |
| `--xp-taskbar-text` | 任务栏文字色 |
| `--xp-start-bg` | 开始按钮背景 |
| `--xp-start-border` | 开始按钮边框 |
| `--xp-titlebar-from` | 标题栏渐变的起始色 |
| `--xp-titlebar-to` | 标题栏渐变的中间色 |
| `--xp-titlebar-text` | 标题栏文字色 |
| `--xp-titlebar-border` | 标题栏边框色 |
| `--xp-window-bg` | 窗体背景色 |
| `--xp-window-border` | 窗体边框色 |
| `--xp-btn-face` | 按钮表面（渐变） |
| `--xp-btn-border` | 按钮边框色 |
| `--xp-panel-bg` | 面板背景色 |
| `--xp-select-bg` | 选中项背景色 |

> 这些变量只是**声明**，需要配合 `ui_css` 才能真正作用到元素上。
> 名称里的 `xp-` 是历史遗留前缀，其它风格的主题包同样可以使用。

### 5.9 `icon_bg` 桌面图标背景

桌面上每个图标下面那块半透明的圆角方块，可以由主题包完全控制：

```json
"icon_bg": {
  "color": "#FFFFFF",
  "opacity": 0.15,
  "style": "rounded"
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `color` | string | 背景色。`#RRGGBB` / `#RGB`，或 `"r,g,b"` |
| `opacity` | number | 透明度，`0`–`1`。也接受 `0`–`100` 的百分比写法（如 `25` 等同 `0.25`） |
| `style` | string | 形状，见下表 |

`style` 的四个取值：

| 值 | 效果 | 圆角 |
|---|---|---|
| `square` | 正方形 | `0` |
| `rounded` | 圆角正方形（默认） | `12px` |
| `circle` | 圆形 | `50%` |
| `none` | 无背景 | 完全透明，同时去掉模糊与阴影 |

**默认值**：三个字段都可省略。`color` 缺省为白色，`opacity` 缺省为 `0.15`，
`style` 缺省为 `rounded`——也就是 QuartzPage 的原生外观。

**只给一部分也可以**，例如只换形状：

```json
"icon_bg": { "style": "circle" }
```

**`none` 的特殊处理**：除了把背景设为透明，还会移除该元素的
`backdrop-filter` 与 `box-shadow`，避免留下一个空的毛玻璃轮廓。

> 实现方式：`tpApplyIconBg()` 把配置换算成 `--tp-iconbg` / `--tp-iconbg-radius`
> 两个 CSS 变量，并给 `<html>` 加上 `tp-has-iconbg` 类与 `data-tp-iconbg` 属性。
> 卸载主题包时这些都会被清除。

### 5.10 `mode_chrome` 按显示模式分组的窗体外观

`chrome` 是**一套全局**值。到了深色模式，浅色的窗体你会完全看不清——这就是 1.5 引入
`mode_chrome` 的原因：允许**每种显示模式各有一套** `--xp-*`。

```json
"chrome": {
  "--xp-window-bg": "rgba(255,252,253,.95)",
  "--xp-titlebar-from": "#FFF9FC"
},
"mode_chrome": {
  "dark": {
    "--xp-window-bg": "rgba(34,18,28,.92)",
    "--xp-titlebar-from": "#2A1620",
    "--xp-titlebar-text": "#FFE8F1"
  },
  "light": {
    "--xp-window-bg": "rgba(255,252,253,.95)"
  },
  "glass": {
    "--xp-window-bg": "rgba(255,246,250,.30)",
    "--xp-window-border": "rgba(255,226,238,.55)"
  }
}
```

**生效顺序**（后写的覆盖先写的）：

```
默认值 → chrome（全局） → mode_chrome[当前模式] → 切换模式时自动重算
```

`mode_chrome` 只接受 `--xp-*` 开头的键，其它键会被忽略并记入 `issues`。
值长度上限 **400 字符**（因为要放 `linear-gradient()`）。

> ⚠️ 只写 `chrome` 不写 `mode_chrome` 时，三种模式共用同一套配色。
> 如果你的主题包是浅色的，深色模式下窗体就会白得刺眼。

### 5.11 `mode_icon_bg` 按显示模式分组的图标背景

与 `icon_bg` 同构，但可以**按模式各自指定**：

```json
"icon_bg": { "color": "#FFF2F7", "opacity": 0.22, "style": "rounded" },
"mode_icon_bg": {
  "light": { "color": "#FFF2F7", "opacity": 0.35, "style": "rounded" },
  "glass": { "color": "#FFFFFF", "opacity": 0.18, "style": "rounded" },
  "dark":  { "color": "#3A1C2B", "opacity": 0.55, "style": "rounded" }
}
```

字段含义与 [5.9](#59-icon_bg-桌面图标背景) 完全相同。
当前模式有配置就用它，否则回退到顶层 `icon_bg`。

> 只给 `mode_icon_bg`、不给顶层 `icon_bg` 也是合法的。

### 5.12 `todo_style` 待办事项

待办事项面板的外观。共 **11 个变量**：

| 变量 | 作用 | 默认 |
|---|---|---|
| `--tp-todo-bg` | 面板背景 | 透明 |
| `--tp-todo-border` | 分隔线与菜单边框 | `rgba(255,255,255,.08)` |
| `--tp-todo-text` | 主文字（标题、条目） | `#e5e7eb` |
| `--tp-todo-dim` | 次要文字（图标、输入框占位） | `rgba(255,255,255,.55)` |
| `--tp-todo-done` | 已完成条目的文字 | `rgba(255,255,255,.35)` |
| `--tp-todo-check` | 勾选框描边 | `rgba(255,255,255,.25)` |
| `--tp-todo-check-mark` | 勾选符号颜色 | `#ffffff` |
| `--tp-todo-item-bg` | 单条项目背景 | 透明 |
| `--tp-todo-item-hover` | 条目悬停背景 | `rgba(255,255,255,.06)` |
| `--tp-todo-radius` | 圆角 | `8px` |
| `--tp-todo-accent` | 强调色（新增按钮、完成态、排序高亮） | 跟随 `--accent` |

**按显示模式覆盖（1.6）**

`todo_style` 里可以嵌套 `dark` / `light` / `glass` 三个子对象，
深色模式下文字必须翻亮，否则会融进背景：

```json
"todo_style": {
  "--tp-todo-text": "#4A2A36",
  "dark": {
    "--tp-todo-bg": "rgba(36,20,28,.92)",
    "--tp-todo-text": "#F3DCE6",
    "--tp-todo-item-bg": "rgba(58,32,46,.55)",
    "--tp-todo-check-mark": "#2A1620"
  }
}
```

生效顺序：`todo_style` 全局 → `todo_style[当前模式]`。

示例：

```json
"todo_style": {
  "--tp-todo-bg": "rgba(255,250,253,.92)",
  "--tp-todo-border": "rgba(226,176,198,.50)",
  "--tp-todo-text": "#4A2A36",
  "--tp-todo-dim": "rgba(90,42,60,.55)",
  "--tp-todo-done": "rgba(90,42,60,.32)",
  "--tp-todo-check": "rgba(200,140,166,.55)",
  "--tp-todo-check-mark": "#FFFFFF",
  "--tp-todo-item-bg": "rgba(255,255,255,.55)",
  "--tp-todo-item-hover": "rgba(253,232,240,.85)",
  "--tp-todo-radius": "11px",
  "--tp-todo-accent": "#E889A8"
}
```

覆盖到的元素：`todo-header`、`todo-title-wrap`、`todo-add-btn`、`todo-item`、
`todo-check`、`todo-text`、`todo-del`、`todo-empty`、`todo-input`、
`todo-sort-menu`、`todo-sort-item`、`todo-sort-sep`。

### 5.13 `app_skin` 应用框跟随模式

应用窗口内部的容器（计算器、备忘录、音乐、画板、地图…）原本把背景写死为深色。
`app_skin` 让它们跟随主题包的显示模式。

```json
"app_skin": {
  "surface": "rgba(255,252,253,.92)",
  "text": "#4A2A36",
  "border": "rgba(226,176,198,.42)",
  "toolbar": "rgba(255,240,246,.85)",
  "input": "rgba(255,255,255,.72)",
  "dark": {
    "surface": "rgba(36,20,28,.92)",
    "text": "#F3DCE6",
    "border": "rgba(122,70,94,.42)",
    "toolbar": "rgba(48,26,38,.9)",
    "input": "rgba(58,32,46,.6)"
  }
}
```

| 字段 | 作用 | 变量 |
|---|---|---|
| `surface` | 应用容器背景 | `--tp-app-surface` |
| `text` | 应用内文字 | `--tp-app-text` |
| `border` | 分隔线 / 边框 | `--tp-app-border` |
| `toolbar` | 工具条背景 | `--tp-app-toolbar` |
| `input` | 应用内输入框背景 | `--tp-app-input` |

支持 `dark` / `light` / `glass` 三个按模式覆盖的子对象。
生效顺序：`app_skin` 全局 → `app_skin[当前模式]`。

> 终端容器是例外：它在深色模式下会保留深底（代码可读性），
> 但换成本主题定义的暖色黑，而不是纯黑。

### 5.14 `search_box` 搜索框

```json
"search_box": {
  "background": "rgba(255,252,253,.80)",
  "border": "rgba(220,168,190,.62)",
  "text": "#4A2A36",
  "placeholder": "rgba(90,42,60,.40)",
  "button": "rgba(255,255,255,.78)",
  "button_border": "rgba(216,160,184,.60)",
  "button_icon": "#8A4E62",
  "shadow": "0 10px 30px -14px rgba(120,50,80,.42)",
  "radius": "16px",
  "height": "58px",
  "dark": { "...": "同上字段" },
  "glass": { "...": "同上字段" }
}
```

| 字段 | 作用 | 变量 |
|---|---|---|
| `background` | 搜索框底色 | `--tp-search-bg` |
| `border` | 边框 | `--tp-search-border` |
| `text` | 输入文字 | `--tp-search-text` |
| `placeholder` | 占位符 | `--tp-search-ph` |
| `button` | 搜索按钮底色 | `--tp-search-btn` |
| `button_border` | 搜索按钮边框 | `--tp-search-btn-border` |
| `button_icon` | 搜索按钮图标色 | `--tp-search-btn-icon` |
| `shadow` | 投影 | `--tp-search-shadow` |
| `radius` | 圆角 | `--tp-search-radius` |
| `height` | 最小高度 | `--tp-search-height` |

同样支持 `dark` / `light` / `glass` 覆盖。

### 5.15 `decoration` 装饰层（花枝 / 花瓣 / 叶片）

主题包可以在四个位置注入装饰性图层。每个位置接受一组
**径向渐变**（花瓣/叶片）和 **线性渐变**（枝干），以及动画参数。

```json
"decoration": {
  "search_left": {
    "width": "190px", "height": "74px",
    "layer": "radial-gradient(circle 9px at 42% 30%,#FFE4EF 38%,#FFD6E6 62%,transparent 74%), ...",
    "animation": "cbBranchL 9s ease-in-out infinite alternate",
    "opacity": "0.92"
  },
  "search_right":  { "...": "" },
  "taskbar":       { "...": "" },
  "desktop":       { "...": "" },
  "petals": {
    "layer": "radial-gradient(ellipse 7px 5px at 8% 0%, rgba(255,214,230,.95), transparent 70%), ...",
    "size": "100% 58vh",
    "animation": "cbFall 42s linear infinite",
    "opacity": "0.5"
  }
}
```

**五个可用位置**：

| 键 | 挂载点 | 说明 |
|---|---|---|
| `search_left` | `#search-box::before` | 搜索框左上伸出的枝 |
| `search_right` | `#search-box::after` | 搜索框右上伸出的枝 |
| `taskbar` | `#column-taskbar::before` | 立方模式任务栏上方的枝 |
| `desktop` | `#desktop-icons::after` | 桌面角落的大片花枝 |
| `petals` | `body::after` | 全屏飘落花瓣 |

每个位置支持：`layer`（背景图层）、`width` / `height` / `size`、`animation`、
`opacity`、`z-index`、`transform-origin`。

> 生成这些字符串不需要图片：用多个 `radial-gradient` 叠出花瓣与叶片，
> 用 `linear-gradient` 的窄色带画出枝干，再整体加位移动画即可。

### 5.16 `ui_css`

主题包自带的 CSS，用于还原纯靠变量无法表达的细节（渐变标题栏、凸起边框、方角按钮、滚动条等）。

- 长度上限 **20000 字符**
- 导入时会过滤掉 `@import`、`expression(`、`javascript:`
- 注入为独立的 `<style id="tp-ui-css">`，卸载时移除

**1.5 新增：`html[data-tp-ui]` 属性**

主题包生效时，`<html>` 上会带有 `data-tp-ui`，值即当前显示模式：

| 值 | 模式 |
|---|---|
| `dark` | 深色 |
| `light` | 浅色 |
| `glass` | 拟雾动效 |

它比 `:not(.light-mode):not(.theme-glass)` 这类链式判断可靠得多，
推荐用它写模式专属样式：

```css
html.tp-active[data-tp-ui="dark"] .settings-group{ background:rgba(58,32,46,.55) }
html.tp-active[data-tp-ui="glass"] .settings-group{ background:rgba(255,244,249,.30) }
```

切换显示模式时该属性会自动更新，卸载主题包时移除。

**强烈建议所有选择器都以 `html.tp-active` 开头**，例如：

```css
html.tp-active .window-header { background: linear-gradient(...) }
```

这样只在主题包生效时起作用，卸载后不会残留。`html.tp-active` 这个 class
在主题包启用时加到 `<html>` 上。

### 5.17 字体

提供 `font` 后，QuartzPage 会：

1. 用本地化的 Blob URL 注入 `@font-face`，字体族固定为 `QPThemePack`
2. 给 `<html>` 加上 `tp-has-font`
3. 用一条高优先级规则覆盖全站字体：

```css
html.tp-has-font, html.tp-has-font body, html.tp-has-font body *:not(svg):not(path) {
  font-family: var(--tp-font) !important;
}
```

`font-weight` 固定为 `normal`（400）而不是 `100 900`——
主题包通常只提供一种字重，声明成区间会让浏览器做「合成加粗」，笔画发虚，中文尤其明显。
固定 400 则直接用真实字形，全站粗细一致。

> 等宽场景（终端、代码块）也会被替换。如果主题包字体缺等宽字形，浏览器会自动回退。

### 5.18 壁纸轮换

```json
"rotate_minutes": 5
```

开启后每 N 分钟**渐变**（1.2 秒交叉淡入淡出）切到下一张主题包壁纸。
这是**主题包独占**功能：只有装了主题包才会出现这个开关，在「设置 → 外观 → 压暗壁纸」下方。

- 页面隐藏（`document.hidden`）时跳过，回来继续
- 卸载主题包时定时器会清除
- 取值范围 1–1440，非法值一律回落到 5

---

## 6. 安全与限制

### 6.1 key 校验

主题包 JSON 里的 `tp_key` 会与本地算出的 hash 比对：

```js
if (pack.tp_key && pack.tp_key !== localHash) → KEY_INVALID
```

这意味着**改主题包内容不会导致 key 失效**（因为 key 只对应清单里的 hash），
但如果主题包里的 `tp_key` 与清单里的 hash 不一致，导入会被拒绝。

### 6.2 CSS 注入防护

| 位置 | 上限 | 过滤 |
|---|---|---|
| `vars` / `mode_vars` 的值 | 64 字符 | 禁 `; { } < > \`、`url(`、`expression(`、`@import`、`javascript:` |
| `chrome` 的值 | 400 字符 | 同上 |
| `ui_css` | 60000 字符 | 剔除 `@import`、`expression(`、`javascript:` |

### 6.3 绝对不可修改的部分

以下内容**没有**任何主题包接口，是 QuartzPage 的核心行为：

- 导入流程本身（key → hash → 清单 → JSON）
- 数据存储与导出逻辑
- 应用容器的基本行为（拖动、缩放、关闭）

而**壁纸**和**显示模式名称**则相反——它们只能由主题包决定，
在 QuartzPage 内没有任何编辑入口（主题包生效时原生壁纸源区块整块隐藏）。

### 6.4 网络失败处理

| 情况 | 表现 |
|---|---|
| `crypto.subtle` 不可用（如非 HTTPS） | `no_crypto` |
| key 为空 | `bad_key` |
| 清单请求超时/断网 | `list_net` |
| 清单 HTTP 非 200 | `list_http` |
| 清单里找不到对应 hash | `key_invalid` |
| 主题包请求失败 | `pack_net` / `pack_http` |
| JSON 语法错误 | `json_parse` |
| 缺必填字段 / 壁纸少于 2 张 | `json_shape` |

单项素材下载失败**不会**中断导入——失败的会记入 `failed` 数组，
其余素材照常本地化。

---

## 7. 完整示例：Racing！主题包

```json
{
  "tp_key": "ef98bc7c04456e4b3282171568b60f1eae3077b30af5fd4932c6fa5cd59dae03",
  "name": "Racing！",
  "name_en": "Racing!",
  "author": "MaQuartz_",
  "color": "#DC0000",

  "dark_color": "#B00000",
  "light_color": "#E10600",
  "mode": {
    "mistvfx": "#FF2800",
    "dark_name": "暗夜赛道",
    "light_name": "白昼赛道",
    "glass_name": "雾中竞速"
  },

  "wallpaperDefault": "https://page.guapfun.top/tpr/racing/01.jpg",
  "wallpapers": [
    { "url": "https://page.guapfun.top/tpr/racing/01.jpg", "name": "跃马 01", "name_en": "Racing 01" },
    { "url": "https://page.guapfun.top/tpr/racing/02.jpg", "name": "跃马 02", "name_en": "Racing 02" },
    { "url": "https://page.guapfun.top/tpr/racing/03.jpg", "name": "跃马 03", "name_en": "Racing 03" },
    { "url": "https://page.guapfun.top/tpr/racing/04.jpg", "name": "跃马 04", "name_en": "Racing 04" },
    { "url": "https://page.guapfun.top/tpr/racing/05.jpg", "name": "跃马 05", "name_en": "Racing 05" },
    { "url": "https://page.guapfun.top/tpr/racing/06.jpg", "name": "跃马 06", "name_en": "Racing 06" },
    { "url": "https://page.guapfun.top/tpr/racing/07.jpg", "name": "跃马 07", "name_en": "Racing 07" },
    { "url": "https://page.guapfun.top/tpr/racing/08.jpg", "name": "跃马 08", "name_en": "Racing 08" },
    { "url": "https://page.guapfun.top/tpr/racing/09.jpg", "name": "跃马 09", "name_en": "Racing 09" }
  ],

  "dim": false,
  "rotate_minutes": 5,

  "vars": {
    "--accent": "#DC0000",
    "--accent-rgb": "220,0,0",
    "--text": "#f5f1ee",
    "--text-dim": "rgba(245,241,238,.72)",
    "--text-faint": "rgba(245,241,238,.45)",
    "--glass": "rgba(28,8,8,.34)",
    "--glass-border": "rgba(255,80,60,.22)",
    "--blur": "22px",
    "--bg-blur": "0px"
  },
  "mode_vars": {
    "dark": {
      "--text": "#f5f1ee",
      "--glass": "rgba(28,8,8,.34)",
      "--glass-border": "rgba(255,80,60,.22)"
    },
    "light": {
      "--text": "#2a0d0d",
      "--text-dim": "rgba(42,13,13,.72)",
      "--text-faint": "rgba(42,13,13,.45)",
      "--glass": "rgba(255,255,255,.5)",
      "--glass-border": "rgba(176,0,0,.22)"
    },
    "glass": {
      "--glass": "rgba(36,10,6,.28)",
      "--glass-border": "rgba(255,150,90,.28)"
    }
  },

  "font": "https://page.guapfun.top/tpr/racing/font.ttf",
  "icons": {
    "calendar": "https://page.guapfun.top/tpr/racing/calendar.svg",
    "paint": "https://page.guapfun.top/tpr/racing/paint.svg",
    "calculator": "https://page.guapfun.top/tpr/racing/calculator.svg",
    "notes": "https://page.guapfun.top/tpr/racing/notes.svg",
    "map": "https://page.guapfun.top/tpr/racing/map.svg",
    "timer": "https://page.guapfun.top/tpr/racing/timer.svg",
    "music": "https://page.guapfun.top/tpr/racing/music.svg",
    "terminal": "https://page.guapfun.top/tpr/racing/terminal.svg",
    "album": "https://page.guapfun.top/tpr/racing/album.svg",
    "translate": "https://page.guapfun.top/tpr/racing/translate.svg"
  },

  "radius": "8px",

  "labels": {
    "theme_title": "Racing！主题包",
    "wallpaper_group": "跃马影像"
  }
}
```

### 对应的清单条目

```
hash_key:ef98bc7c04456e4b3282171568b60f1eae3077b30af5fd4932c6fa5cd59dae03
url:https://page.guapfun.top/tp/racing.json
```

### 对应的密钥

```
racing18860129
```

该 key 的 SHA-256 正好是上面那个 hash，可用下面的命令自行核对：

```bash
# Linux / macOS
echo -n 'racing18860129' | sha256sum

# PowerShell
[System.BitConverter]::ToString(
  [System.Security.Cryptography.SHA256]::Create().ComputeHash(
    [Text.Encoding]::UTF8.GetBytes('racing18860129')
  )
).Replace('-','').ToLower()
```

> 注意是 **`echo -n`**（不带换行）。带换行的 hash 完全不同，这是最常见的坑。

---

## 8. 示例解析

### 8.1 为什么用 9 张壁纸

主题包**至少需要 2 张壁纸**，这是硬性要求（校验时会直接拒绝）。
Racing 给了 9 张，配合 `rotate_minutes: 5` 就能撑起一轮 45 分钟的轮换，
用户不至于反复看到同一张。

### 8.2 强调色为什么给了四个

```json
"color": "#DC0000",       // 基础强调色
"dark_color": "#B00000",  // 深色模式下压暗，避免刺眼
"light_color": "#E10600", // 浅色模式下提亮，保证对比度
"mode": { "mistvfx": "#FF2800" }  // 拟雾模式用更艳的橙红
```

同一个红在三套底色上的观感完全不同。深色底上 `#DC0000` 会糊，浅色底上又不够醒目，
所以按模式分别给了不同明度。**这是设计主题包时最值得花时间的地方。**

注意 `vars` 里同时写了 `--accent` 和 `--accent-rgb`：

```json
"--accent": "#DC0000",
"--accent-rgb": "220,0,0"
```

`--accent-rgb` 是给 `rgba(var(--accent-rgb), .35)` 这类写法用的（做半透明背景）。
**两个值必须对应**——`#DC0000` = `220,0,0`。写错会导致所有半透明元件颜色跑偏。

### 8.3 `mode_vars` 里重复的写法是有意的

```json
"vars": { "--text": "#f5f1ee", "--glass": "rgba(28,8,8,.34)" },
"mode_vars": {
  "dark":  { "--text": "#f5f1ee", "--glass": "rgba(28,8,8,.34)" },
  "light": { "--text": "#2a0d0d", "--glass": "rgba(255,255,255,.5)" }
}
```

深色那组和全局写得一样，是为了让「切到深色」有明确的显式值。
浅色模式下 `--glass` 换成白色系、`--text` 换成深棕——
否则深色底上的半透明配色放在白底上就是一团灰。

### 8.4 为什么图标是 SVG 而不是 PNG

桌面图标会被渲染成 `<img>`，SVG 在任意缩放下都清晰，且体积极小（每个约 1 KB）。
注意 Lucide 这类图标的 `stroke="currentColor"` 在 `<img>` 里会解析成黑色，
**必须把描边色写成具体色值**：

```xml
<!-- ❌ 在 <img> 里会变黑 -->
<path stroke="currentColor" .../>

<!-- ✅ -->
<path stroke="#DC0000" .../>
```

### 8.5 `labels` 字段目前是预留的

```json
"labels": {
  "theme_title": "Racing！主题包",
  "wallpaper_group": "跃马影像"
}
```

**当前版本的解析器还没有读取 `labels`**，写了不报错，但也不生效。
保留是为后续做界面文案本地化预留的位置。

### 8.6 这个主题包没有用到的字段

Racing 是**最小可用示例**，没有使用：

- `disable_modes` / `disable_uimodes` / `force_mode` / `force_uimode` —— 不限制模式
- `chrome` / `ui_css` —— 不改任务栏与窗体结构
- `start_icon` —— 不加开始按钮
- `folder_icon` —— 文件夹用默认图标

这些是进阶能力，需要时参考 Windows XP 主题包。
XP 那个包用 `disable_modes: ["original","minimal"]` + `force_mode: "column"`
把用户锁在立方模式，再用 15 KB 的 `ui_css` 把任务栏、窗体、按钮、滚动条全部重绘。

### 8.7 素材命名约定

QuartzPage 本身不规定素材文件名，但按**主题包名建目录**是最省心的做法：

```
网页版/tpr/racing/01.jpg … 09.jpg
网页版/tpr/racing/font.ttf
网页版/tpr/racing/calendar.svg …
```

对应 URL：

```
https://page.guapfun.top/tpr/racing/01.jpg
```

即 **`网页版/` 目录对应站点根目录**。

---

## 9. 常见错误码

| 错误码 | 含义 | 排查方向 |
|---|---|---|
| `no_crypto` | `crypto.subtle` 不可用 | 页面必须走 HTTPS 或 localhost |
| `bad_key` | key 为空 | 检查输入 |
| `key_invalid` | 清单里没有这个 hash | key 拼错？清单没更新？hash 算错（换行符）？ |
| `list_net` | 清单请求失败 | 断网、DNS、CORS |
| `list_http` | 清单返回非 200 | 服务端路径不对 |
| `list_empty` | 清单为空或解析不出条目 | 格式写错了？标签名对不上？ |
| `pack_net` | 主题包 JSON 请求失败 | URL 错、CORS |
| `pack_http` | 主题包返回非 200 | 文件不存在 |
| `json_parse` | JSON 语法错误 | 多余的逗号？用了注释？用了单引号？ |
| `json_shape` | 结构不对 | 缺 `tp_key`/`color`/`wallpapers`？壁纸少于 2 张？ |

排查 `ui_css` 不生效时，先在控制台确认：

```js
document.documentElement.classList.contains('tp-active')  // 应为 true
document.getElementById('tp-ui-css').textContent.length    // 应大于 0
```

如果长度对得上但样式没变，多半是**选择器优先级不够**——QuartzPage 大量使用了 `!important`，
主题包 CSS 也需要相应加 `!important`。

---

*文档对应实现：`网页版/index.html` 中的 `tpValidatePack` / `tpImport` / `tpApplyAll` 等函数。*
