dsh-theme-gallery 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,657 @@
1
+ # dsh-theme-gallery
2
+
3
+ 给 DeepSeek Harness 的**主题画廊**:一个插件 + 一堆 JSON 皮肤。装一次,拿到全部主题;加皮肤**不用改代码、不用重启、不用重新装包**。
4
+
5
+ | 你想要 | 怎么做 |
6
+ |---|---|
7
+ | 直接用别人做好的皮肤 | 装上插件,在设置里点选 |
8
+ | 自己做皮肤(或让 AI 做) | 在 `lib/themes/` 加 JSON 后重跑 `npm run embed-themes`,或装 `dsh-theme-skin-author` 技能让 AI 生成 |
9
+ | 贡献一个皮肤给所有人 | 在 `lib/themes/` 放一个 JSON,提 PR |
10
+
11
+ 内置两个皮肤,均复刻自电商新零售系统管理后台(`admin-modular/src/utils/themes.js`):
12
+
13
+ | id | 名称 | 主色 | 强调色 |
14
+ |---|---|---|---|
15
+ | `shan-qing-ting-cai` | **山青婷彩** | `#2F7D5E` 青山绿 | `#E88BB0` 蜻蜓粉 |
16
+ | `meng-hai-you-yu` | **梦海游鱼** | `#177CB0` 靛青 | `#FFD166` 琥珀金 |
17
+
18
+ 每个皮肤 67 个 token,覆盖整屏。
19
+
20
+ ## 效果预览
21
+
22
+ | 山青婷彩 `shan-qing-ting-cai` | 梦海游鱼 `meng-hai-you-yu` |
23
+ |---|---|
24
+ | ![山青婷彩:青山两层、云雾、蜻蜓与落花,侧栏水塘收底](https://raw.githubusercontent.com/renjie2026/dsh-theme-gallery/main/screenshots/shan-qing-ting-cai.png) | ![梦海游鱼:左上柔光辉、上浮气泡与光点、蓝色小鱼、水草与水底渐变](https://raw.githubusercontent.com/renjie2026/dsh-theme-gallery/main/screenshots/meng-hai-you-yu.png) |
25
+
26
+ 截图取自桌面版 0.1.7-rc.2 实机。图片用**绝对地址**引用而不是相对路径,是为了让同一份 README 在
27
+ GitHub、npm 与社区市场三处都能显示(npm 不会把相对路径解析到仓库)。原图在
28
+ [`screenshots/`](screenshots/) 下,不在 npm 包内(`package.json` 的 `files` 白名单只含运行必需文件)。
29
+
30
+ ---
31
+
32
+ ## 为什么是「一个插件 + 数据」,而不是「一个皮肤一个插件」
33
+
34
+ 这是本项目唯一重要的架构决定,结论是前者:
35
+
36
+ | 维度 | 一个插件 + N 个皮肤数据 | 每个皮肤一个插件 |
37
+ |---|---|---|
38
+ | 用户装一个皮肤 | 不用动,设置里直接就出现 | 要再装一个包 |
39
+ | 设置里的选择器 | 一行列出全部 | 每个插件各自注册一行,UI 抢位 |
40
+ | 作者加皮肤 | 改一个 JSON | 新建仓库 + 发 npm + 绑版本 |
41
+ | 加载开销 | 一个插件一行 | N 个插件 N 行 |
42
+ | 升级 | 一个包对一个 DSH 版本 | N 个包各自绑版本,升级全炸 |
43
+
44
+ **判断准则**:有运行时逻辑 / 服务 / Hook → 独立插件;只是数据 / 配色 → 数据文件。
45
+
46
+ 皮肤是纯数据,所以走后者。这也正是官方 `ctx.theme` 的设计意图——官方 README 原话:*"registering one means overriding same-named alias variables"*。
47
+
48
+ ---
49
+
50
+ ## 为什么官方「设置 → 通用 → 外观」看不到这些皮肤
51
+
52
+ 官方那一行的选项是**写死的常量数组**:
53
+
54
+ ```ts
55
+ // 官方源码 packages/client/ui-theme/src/client/AppearanceRow.tsx
56
+ const CUBES = [light, dark, system]
57
+ ```
58
+
59
+ 它渲染 `CUBES.map(...)`,**从不读取主题注册表**。官方 README 也说得很直白:*"Third-party themes are an extension point, not a product"*。
60
+
61
+ 所以本插件自带入口。
62
+
63
+ ---
64
+
65
+ ## 入口在哪:左侧栏面板,不是设置
66
+
67
+ | 位置 | 官方插槽 | 形态 |
68
+ |---|---|---|
69
+ | **本插件用这个** | `sidebar.panellist` + `main`(keyed,同一个 id) | 左栏一个图标 → 主区一整页主题卡片 |
70
+ | 备选(未用) | `sidebar.footer.action` | 齿轮旁的小图标 + 弹出气泡 |
71
+ | 备选(未用) | `settings.general.item` | 设置里一行按钮 |
72
+
73
+ 选第一个的理由不只是位置好:**它正是这个应用的"插件"机制**——一个插件 = 侧栏一个入口 + 主区一个页面,和 `Plugins` 页同级。
74
+
75
+ 而且侧栏入口是 `list` 插槽,**一个插件最多占一个座位**:这从机制上就否定了"每个皮肤一个插件"。
76
+
77
+ > `sidebar.footer.action` 同样是 `list`,一个插件也只有一个座位,且气泡里放不下预览。设置行则额外需要 settings 域——见下面的踩坑记录。
78
+
79
+ **打开方式**:左栏那排面板图标里点 **主题皮肤**,主区显示全部可选主题,点卡片切换,选中态会记入设置。
80
+
81
+ ---
82
+
83
+ ## 安装
84
+
85
+ ### 三种安装方式
86
+
87
+ **① 下载构建产物(推荐,零构建授权)**
88
+
89
+ 1. 到 [Releases](https://github.com/renjie2026/dsh-theme-gallery/releases/latest) 下载 `dsh-theme-gallery-<版本>.tgz`
90
+ 2. **设置 → 插件 → 添加插件** → 粘贴该文件的**完整路径**
91
+ 3. **重启应用**
92
+ 4. 左侧栏底部出现 **主题皮肤** 入口(**不在**「设置 → 通用 → 外观」里,原因见下文),
93
+ 当前三个主题:山青婷彩 / 梦海游鱼 / 深色
94
+
95
+ **② 用包名安装**(需要本包已发布到 npm;桌面版在「添加插件」里直接填包名即可)
96
+
97
+ ```sh
98
+ dsh plugin --profile web add dsh-theme-gallery
99
+ ```
100
+
101
+ > 桌面版的 profile 由 Electron 独占,`dsh plugin --profile desktop …` 会被拒绝 ——
102
+ > 桌面用户请走上面的界面,这条 CLI 写法适用于 Web / CLI profile。
103
+
104
+ **③ 用仓库地址安装**(`github:renjie2026/dsh-theme-gallery`,拉的是**源码**,不推荐)
105
+
106
+ 只在你想跟源码时用它。本包**没有构建步骤**,所以不会出现"缺 `lib/` 目录"那类失败;
107
+ 它的代价是 pnpm 可能要求你为构建脚本授权(`allowBuilds`)。
108
+
109
+ ### 兼容性与权限
110
+
111
+ | 项 | 说明 |
112
+ |---|---|
113
+ | 平台 | DeepSeek Harness **桌面版 0.1.7-rc.2**(实测通过);同为 web 客户端的 Web / CLI profile 理论可用,但本项目只实测过桌面版 |
114
+ | 网络 | **不联网** —— 插件半侧不发起任何请求 |
115
+ | 文件 | 包内只有 `lib/`、`schema/`、`cordis.patch.yml`(见 `package.json` 的 `files`),不读写用户文件 |
116
+ | 界面改动 | 只新增左侧栏一个面板入口;**不碰**官方「设置 → 通用 → 外观」那一行 |
117
+ | 本地状态 | 皮肤选择存在 `localStorage`;主题偏好只写 `light`/`dark`/`system` |
118
+
119
+ ### 装不上或想手动装
120
+
121
+ 桌面 profile 由 Electron 独占,**CLI 会被拒绝**:
122
+
123
+ ```
124
+ error: profile "desktop" is managed exclusively by the Electron application
125
+ ```
126
+
127
+ 那就手动改 profile 清单(这是官方 profile 的既定机制,不是 hack):
128
+
129
+ ```jsonc
130
+ // <DSH_HOME>/profiles/desktop/package.json
131
+ // "dependencies": { "dsh-theme-gallery": "^0.1.0" }
132
+ // "dsh.profile.bundles": [ ..., "dsh-theme-gallery" ]
133
+ ```
134
+
135
+ ```bash
136
+ cd <DSH_HOME>/profiles/desktop && pnpm install
137
+ ```
138
+
139
+ Windows 上 `<DSH_HOME>` 默认是 `C:\Users\<你>\.dsh`。
140
+
141
+ **然后重启。**
142
+
143
+ ---
144
+
145
+ ## 卸载与回滚
146
+
147
+ 主题插件最坏的情况是**界面进不去**。以下四招按"由轻到重"排列,**都不需要重装应用**。
148
+
149
+ ### ① 只停装饰与自动恢复,保留插件(最轻)
150
+
151
+ 插件的两个活动层可以用一个配置开关整体停掉,**不必卸载**。编辑 profile 自己的
152
+ `cordis.patch.yml`(位置:`<DSH_HOME>/profiles/desktop/cordis.patch.yml`),加:
153
+
154
+ ```yaml
155
+ - id: theme-gallery
156
+ name: dsh-theme-gallery
157
+ config:
158
+ ambient: false # 停掉氛围装饰层与皮肤恢复层
159
+ ```
160
+
161
+ **为什么需要这一招**:`dsh.profile.bundles` **不是手写清单** —— 每次插件安装 / 启用 /
162
+ 优化后,应用都会按 `dependencies` 里声明了 `dsh.bundle.patch` 的包**自动推导重建**它。
163
+ 所以把一个包从 `bundles` 里删掉**不能持久**,会被写回来。上面的开关是插件侧的刹车。
164
+
165
+ ### ② 从应用里卸载
166
+
167
+ **设置 → 插件** 里移除本插件,然后重启。这是最干净的做法。
168
+
169
+ ### ③ 手动从 profile 卸掉
170
+
171
+ ```jsonc
172
+ // <DSH_HOME>/profiles/desktop/package.json
173
+ // 删掉 dependencies 里的 "dsh-theme-gallery"
174
+ // 删掉 dsh.profile.bundles 里的 "dsh-theme-gallery"
175
+ ```
176
+
177
+ ```bash
178
+ cd <DSH_HOME>/profiles/desktop && pnpm install
179
+ ```
180
+
181
+ 重启。
182
+
183
+ ### ④ 界面完全起不来时:把偏好改回内置值
184
+
185
+ **这是最可能的"起不来"原因,而且它完全是你自己写的配置造成的。**
186
+
187
+ 皮肤 id **绝不能**写进主题偏好。`ui-theme.config.preference` 只接受
188
+ `light` / `dark` / `system`:
189
+
190
+ ```yaml
191
+ # <DSH_HOME>/profiles/desktop/cordis.patch.yml
192
+ ui-theme:
193
+ config:
194
+ preference: light # 只能是 light | dark | system
195
+ ```
196
+
197
+ 写成皮肤 id(如 `shan-qing-ting-cai`)时,`buildSnapshot()` 会抛
198
+ `theme registry lost` 并**拒绝启动** —— 因为偏好指向一个(当时)还没注册的主题。
199
+
200
+ > **皮肤的当前选择存在 `localStorage` 里,不在 `preference` 里。** 这是刻意设计:
201
+ > 偏好是启动期读取的,而皮肤要等插件加载后才注册,两者天然有先后。
202
+
203
+ ### 还有一招:拿一份干净的 profile 快照
204
+
205
+ `<DSH_HOME>/profiles/desktop/` 整个目录可以先复制一份再改。出问题时把
206
+ `package.json` 与 `cordis.patch.yml` 换回去即可。改之前备份是**最省事的保险**。
207
+
208
+ ---
209
+
210
+ ## 疑难排障
211
+
212
+ ### 应用弹「无法使用」并给出「禁用第三方插件」出口
213
+
214
+ DSH 有启动保护。先用它进去,再按上面的「卸载与回滚」处理。
215
+
216
+ ### 启动挂起、日志里什么都没有
217
+
218
+ **怀疑 fiber pending,去查 `inject` 与循环等待;不要去看模型或网络配置。**
219
+
220
+ `crash-*-web-boot.log`(`%APPDATA%\@deepseek-ai\dsh-desktop\logs\`)里
221
+ **一条记录都没有**,而应用卡在 "Loading plugins..." —— 这就是 pending fiber 的指纹。
222
+
223
+ 历史上撞过三次,都源自 `inject`:
224
+
225
+ | 版本 | 错误 | 结果 |
226
+ |---|---|---|
227
+ | 第一版 | 把 `settingsScope` 写成硬依赖 | `dsh-theme-gallery: pending (waiting for service: settingsScope)` |
228
+ | 第二版 | 以为 `immediately: true` 能解决 | **无效**,是错的根因判断 |
229
+ | 第三版 | 声明的模块没在 `dsh.client.inject` 里 | **完全没有任何日志** |
230
+
231
+ Cordis 的 `inject` 有两种形态:
232
+
233
+ ```ts
234
+ inject: ['a', 'b'] // 数组 —— 每一项都是【必需】
235
+ inject: { a: cfg, b: null } // 映射 —— null 标记该项可选
236
+ ```
237
+
238
+ 用数组形态时,**拿不到的服务会让 fiber 永远停在 `pending`**,而未激活的 entry 会让整个
239
+ web boot 失败。
240
+
241
+ > **教训**:把自己无法控制的服务写成硬依赖,等于给了它一票否决应用启动的权力。
242
+
243
+ **当前状态**:`exports.inject = ['slots', 'locale', 'theme']` —— 三者都由静态编入的
244
+ UI 包提供,保证存在。另外 `package.json` 的 `dsh.client.inject` 声明了提供主题服务的
245
+ 模块,用来固定加载顺序(漏掉它时**不产生任何日志**,最难查)。
246
+
247
+ 回归测试锁住了这件事(`tests/smoke-host.mjs`、`tests/check-boot-safety.mjs`)。
248
+
249
+ ### 启动保护弹窗(比手改更快)
250
+
251
+ DSH 有启动保护,弹窗给两个出口:
252
+
253
+ 1. **「禁用第三方插件、备份 profile patch 并重启」** —— 先用这个进去。
254
+ 注意它会**重置 profile 的 `cordis.patch.yml`**,所以恢复后要检查
255
+ `<DSH_HOME>/profiles/desktop/cordis.patch.yml` 是否需要补回偏好设置。
256
+ 2. **退出 / 重启**
257
+
258
+ 崩溃详情在:
259
+
260
+ ```
261
+ %APPDATA%\@deepseek-ai\dsh-desktop\logs\crash-*-web-boot.log
262
+ ```
263
+
264
+ 日志会**逐条列出 pending 的 entry 和它等待的服务名**——排查启动问题最快的信息源。
265
+
266
+ > **但注意**:本插件遇到过一种**完全不写日志**的启动失败(`dsh.client.inject` 漏声明),
267
+ > 所以"日志是空的"**不能**排除插件问题。详见上一节。
268
+
269
+ ### ⚠️ 桌面版**不读** `$DSH_HOME/settings.yaml`
270
+
271
+ 这一点与官方文档的默认值相反,值得单独记住:`dsh-base` 与 `dsh-web-app` 两个 bundle 都把设置文件 provider 配成了内存模式——
272
+
273
+ ```yaml
274
+ - name: '@deepseek-ai/dsh-settings-file'
275
+ path: ':memory:'
276
+ ```
277
+
278
+ 所以**桌面版里手写 `settings.yaml` 不生效**;应用启动时会把这类文件归档成 `settings.yaml.imported`,然后继续使用内存状态。控制皮肤选择的权威位置是组合层——profile 的 `cordis.patch.yml`:
279
+
280
+ ```yaml
281
+ - id: ui-theme
282
+ name: "@deepseek-ai/dsh-client-ui-theme"
283
+ config:
284
+ preference: shan-qing-ting-cai
285
+ ```
286
+
287
+ 或者,等插件加载成功后直接用设置界面点选。
288
+
289
+ (`theme-gallery` 段仍可写在 `settings.yaml` 里,但需要 profile 换成文件型 provider;桌面版目前走内存,所以**加自定义皮肤请改 `lib/themes/*.json` 后重跑 `npm run embed-themes`**。)
290
+
291
+ ### 首次启动的顺序注意
292
+
293
+ 首次启动前,`cordis.patch.yml` 里的偏好先留内置值(`dark` / `light` / `system`)。因为 `ui-theme` 的 `buildSnapshot` 在偏好指向未注册 id 时会**抛错**(官方源码里是明确的 `throw`,不在 try/catch 内):
294
+
295
+ ```js
296
+ if (active === void 0) throw new Error(`theme registry lost "${resolvedId}"`)
297
+ ```
298
+
299
+ 而 `shan-qing-ting-cai` 要等本插件的浏览器半侧注册完才存在。确认插件加载成功后,再把偏好改成皮肤 id。
300
+
301
+ ---
302
+
303
+ ## 两种状态:整屏铺满 / 阅读时变浅
304
+
305
+ 皮肤的价值是**整屏覆盖**;而整屏覆盖正是长对话难读的原因。所以界面被分成两种状态:
306
+
307
+ ## 侧栏素材与激活标记(region 1 复刻)
308
+
309
+ 两个皮肤不只是配色,还把原系统**左侧菜单区的元素素材**复刻了过来,并给"正在使用"的状态加了标记色。
310
+
311
+ ### 侧栏氛围装饰 `ambient`
312
+
313
+ | 皮肤 | 素材 |
314
+ |---|---|
315
+ | 山青婷彩 `kind: "shan"` | 青山两层 + 山间云雾 + 山脚水面与点水涟漪 + 两只悬停蜻蜓 + 花瓣飘落 |
316
+ | 梦海游鱼 `kind: "dream"` | 左上柔光辉 + 两道弥散光洗 + 上浮气泡 + 五叶水草摇摆 |
317
+
318
+ 素材逐像素复刻自 `ShanQingTingCaiAnimation.vue` 与 `DreamOceanAmbient.vue`。与原系统**有意偏离**两处:
319
+
320
+ 1. **尺寸改为百分比 / em**。原系统写死 223px 侧栏宽;DSH 侧栏可拖拽,固定像素在窄侧栏会截断。
321
+ 2. **`pointer-events:none` 且置于导航之下**。原系统文档记有一条 bug:不透明山形贴在侧栏底部会遮挡最下方菜单项。
322
+
323
+ `kind` 是**数据键而非素材路径**——插件只为它真正画得出的场景提供键,写错会在构建期失败,而不是运行时静默不画。
324
+
325
+ 装饰挂在侧栏列里的 `#dsh-theme-ambient`。这是本插件**唯一**直接操作外壳 DOM 的地方(侧栏没有装饰插槽,插槽组件会成为列的兄弟而非其背后的图层),因此单独隔离、整体 try/catch:**装饰是修饰,绝不能成为故障原因**。侧栏定位用三级回退,因为外壳类名是 CSS-module 哈希、不是稳定契约。
326
+
327
+ 先看效果再装:`npm run preview:ambient` 生成的预览页**把 CSS 直接读自 `lib/client.js`**,因此不会与出货版本漂移;两种侧栏宽度并排。
328
+
329
+ ### 激活标记 `accent`
330
+
331
+ 侧栏条目与选中对话行在 DSH 里共同读取 `--dsw-alias-button-ghost-active-fill/-border/-hover`,所以 `accent` 叠加到这三个 token 上,**两者同时变成标记色**。
332
+
333
+ 取值遵循来源项目《主题皮肤设计规范(第二区域)》第 123 / 118 行:**强调色取本皮肤第一区域的特征色,且禁止引入第一区域不存在的色相**。所以
334
+
335
+ | 皮肤 | 强调色 | 出处 |
336
+ |---|---|---|
337
+ | 山青婷彩 | `#E88BB0` | 蜻蜓粉 |
338
+ | 梦海游鱼 | `#FFD166` | 夕照金 |
339
+
340
+ **不是所有主题一律粉**——那样恰好违反来源项目自己的规范。两条约束由代码强制:叠加**只对活跃主题**生效、切走即撤除;**不动 `brand-primary`**(否则链接、主按钮、状态徽标会被一起改掉)。
341
+
342
+ | 状态 | 判定 | 视觉 |
343
+ |---|---|---|
344
+ | **空对话态** | 会话里还没有消息 | 整屏铺满主题:左侧栏渐变 + Windows 顶部标题栏渐变 + 中间留白也是主题色 |
345
+ | **有对话态** | 出现消息后 | 正文套一层**提亮卡片**(默认 62% 不透明度 / 3px 模糊 / 640px 限宽),四周仍透出主题渐变;左侧栏与顶部**保持主题强度**,一眼可辨是哪个皮肤 |
346
+
347
+ ### 原理:整屏覆盖靠 token,不靠注入 CSS
348
+
349
+ 看起来渐变必须注入样式表,其实不用。官方布局**本来就用主题 token 画这些面**:
350
+
351
+ ```css
352
+ /* 官方源码 packages/client/ui-layout/src/client/AppFrame.module.css */
353
+ .sidebarCol { background: var(--dsw-specific-sidebar-fill); }
354
+ [data-windows-titlebar] .frame { background: var(--dsw-specific-sidebar-fill); }
355
+ [data-windows-titlebar] .centerCol { background: var(--dsw-alias-bg-base); }
356
+ ```
357
+
358
+ 所以皮肤只要把 **`--dsw-specific-sidebar-fill` 设成渐变**,左侧栏和 Windows 顶部标题栏就一起铺满了,**零注入**。
359
+
360
+ 只有阅读态需要一条注入规则,且它锚在官方自己也在用的 `[data-windows-titlebar] .centerCol` 上(不是猜的组件类名):
361
+
362
+ ```css
363
+ [data-windows-titlebar] body[data-dsh-theme-reading] .centerCol { background: var(--dsh-reading-bg); }
364
+ ```
365
+
366
+ ### 阅读态怎么被判定
367
+
368
+ DSH 没有公开「当前会话有几条消息」的 API,所以状态从 DOM 推导:找到 composer(中列里唯一的 `[contenteditable]`)→ 取 composer 座位旁边的兄弟节点 → 该兄弟有元素子节点就认为会话有内容。用 `MutationObserver` 跟随变化,并**去抖到微任务**(流式回复每帧会改很多次 DOM,只有安定后的答案有意义)。
369
+
370
+ ```js
371
+ // lib/client.js —— 判定集中在 transcriptHasContent()
372
+ document.body.setAttribute('data-dsh-theme-reading', reading.colorScheme)
373
+ ```
374
+
375
+ > **这是未公开的 DOM 契约。** 官方 UI 大改后可能失效。届时行为是**降级为始终空对话态外观**(主题继续覆盖整屏,只是不再变浅),不会把界面弄坏。要改判定逻辑,只动 `transcriptHasContent()` 一个函数。
376
+
377
+ ---
378
+
379
+ ## 加自己的皮肤
380
+
381
+ ### 放到 `lib/themes/` 后重新内联
382
+
383
+ 在 `lib/themes/` 下新建一个 JSON(一个文件可放多个皮肤),然后:
384
+
385
+ ```bash
386
+ npm run embed-themes # 校验 + 内联进 lib/client.js
387
+ ```
388
+
389
+ 重启应用后,新皮肤出现在面板页里。
390
+
391
+ ```json
392
+ [
393
+ {
394
+ "id": "cyberpunk",
395
+ "label": "赛博朋克",
396
+ "description": "深紫底 + 霓虹粉",
397
+ "colorScheme": "dark",
398
+ "tokens": {
399
+ "--dsw-alias-bg-base": { "light": "#f0f0ff", "dark": "#0a0612" },
400
+ "--dsw-alias-label-primary": { "light": "#12002e", "dark": "#f4eaff" },
401
+ "--dsw-alias-brand-primary": { "light": "#7c3aed", "dark": "#ff2fd0" },
402
+ "--dsw-specific-sidebar-fill": {
403
+ "light": "linear-gradient(to bottom,#f0f0ff 0%,#c8c2e0 100%)",
404
+ "dark": "linear-gradient(to bottom,#0a0612 0%,#2f2450 100%)"
405
+ }
406
+ }
407
+ }
408
+ ]
409
+ ```
410
+
411
+ `embed-themes.mjs` 会先校验(形状 / id 唯一 / 双配色 / 12 个必需 token / `--dsw-specific-sidebar-fill`),任何一条不过就拒绝写入——这样坏皮肤不会进到产物里。
412
+
413
+ > **`id` 与内置皮肤相同时会冲突**:本包注册时若发现注册表里已有同名 id,会跳过自己的那份而不报错。所以想让你的皮肤生效,换个新 id,或先删掉 `lib/themes/` 里的同名项。
414
+
415
+ ### 发成独立插件(分发给别人)
416
+
417
+ 别人做皮肤时**不需要改这个仓库**:他自己的插件只要在客户端半侧调 `ctx.theme.register(definition)` 就行,官方注册表是共享的,本面板会自动列出他的皮肤——两个插件互不知情。
418
+
419
+ 这就是"一个插件管全部皮肤"的实际含义:**画廊负责选择界面,注册表负责汇总**。
420
+
421
+ ### 让 AI 生成皮肤(用配套技能)
422
+
423
+ 配套技能 **`dsh-theme-skin-author`** 写全了皮肤 JSON 格式、token 契约与必需项、
424
+ 配色方法、`register` / `overrideTokens` 的数据格式区别,以及一份完整的山青婷彩
425
+ 范例——AI 不用读源码就能产出**合法**皮肤。
426
+
427
+ ```sh
428
+ cp -r <技能目录>/dsh-theme-skin-author ~/.claude/skills/
429
+ ```
430
+
431
+ > 本仓库**不再自带技能副本**。此前自带的那份 `dsh-theme-author` 有两处硬伤,
432
+ > 已删除:它教用户把皮肤写进 `settings.yaml`(桌面版不读该文件),并要求每个
433
+ > token 都给 `{ light, dark }` 成对值(那是 `overrideTokens` 层的格式;
434
+ > 喂给 `register` 会把 `[object Object]` 写进 CSS,皮肤选中却毫无颜色)。
435
+
436
+ 然后直接说需求,例如:
437
+
438
+ > 给我做一个「深海」皮肤,深蓝背景,青色点缀,晚上看不刺眼,长对话要能读
439
+
440
+ ### 视觉调参台
441
+
442
+ 阅读态的取舍(主题存在感 vs 正文可读性)有专门的调参台:DSH 分区复刻、两种状态、两个皮肤、滑杆与 localStorage 持久化。
443
+
444
+ ```sh
445
+ npm run bench # http://127.0.0.1:8171/
446
+ ```
447
+
448
+ URL 参数便于批量截图走查:
449
+
450
+ ```
451
+ ?theme=shan|dream &state=idle|reading &right=on &readBg=62&readWidth=640&readBlur=3
452
+ ```
453
+
454
+ 调好后把参数行贴回来,或直接写进皮肤的 `reading` 段。
455
+
456
+ ---
457
+
458
+ ## 硬性规则(运行时会校验)
459
+
460
+ 1. **每个 token 必须同时给 `light` 和 `dark`。** 给单个字符串会抛 `TypeError`:同一份定义在用户切换配色时会被复用,单值会让另一套配色看不清。
461
+ 2. **`colorScheme` 决定底板**,不是 `id`。它也决定 `body[data-ds-dark-theme]`。
462
+ 3. **`id` 只能小写字母、数字、连字符**(`^[a-z0-9][a-z0-9-]*$`),且**不能是 `system`**(那是偏好,不是皮肤)。
463
+ 4. **token 键只能是 `--dsw-alias-*` 或 `--dsw-specific-*`。** 后者是官方配套层,`--dsw-specific-sidebar-fill` 就是整屏覆盖的关键。
464
+ 5. **漏 token 是安全的**:没覆写的变量沿用基础调色板。但必需 token 漏多了,皮肤会像没生效。
465
+
466
+ ### 必需 token(12 个)
467
+
468
+ 官方只把这 12 个标记为必需(`BUILTIN_INSPECT_TOKENS` 里 `requiresLightAndDark: true`):
469
+
470
+ ```
471
+ --dsw-alias-bg-base --dsw-alias-label-primary
472
+ --dsw-alias-bg-layer-1 --dsw-alias-label-secondary
473
+ --dsw-alias-bg-layer-2 --dsw-alias-state-error-primary
474
+ --dsw-alias-bg-overlay --dsw-alias-state-success-primary
475
+ --dsw-alias-border-l1 --dsw-alias-state-warn-primary
476
+ --dsw-alias-border-l2 --dsw-alias-brand-primary
477
+ ```
478
+
479
+ **运行时不会校验完整性**——官方原话:*"no validation exists that an override set is complete"*。所以完整性检查放在本仓库的 schema 与构建脚本里。
480
+
481
+ alias 层共 **81** 个变量,权威定义在官方仓库
482
+ [design-platform.css](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-theme/src/styles/design-platform.css)。
483
+
484
+ ### 优先用 `var(--dsw-static-*)`
485
+
486
+ 引用官方静态色阶,皮肤会跟随产品调色板演进。只在表达皮肤身份的颜色(背景、主色)上用字面量。
487
+
488
+ ---
489
+
490
+ ## 开发
491
+
492
+ ```
493
+ dsh-theme-gallery/
494
+ ├── package.json # dsh.bundle + dsh.client + exports["./client"]
495
+ ├── cordis.patch.yml # 被 profile 选中时应用的配置层
496
+ ├── schema/theme.schema.json # 皮肤 JSON Schema(含必需 token 清单)
497
+ ├── tools/theme-bench/ # 视觉调参台(单文件 HTML)
498
+ ├── scripts/
499
+ │ ├── embed-themes.mjs # 校验内置皮肤并内联进 client.js
500
+ │ ├── build-ambient-preview.mjs # 生成侧栏素材预览页(CSS 读自 client.js)
501
+ │ └── serve-bench.mjs # 启动调参台
502
+ ├── tests/
503
+ │ ├── check-schema.mjs # 真实 schemastery 解析 + 反向用例
504
+ │ ├── smoke-host.mjs # 真机加载 lib/index.js 并跑 apply
505
+ │ ├── check-store-contract.mjs # create() 实例语义
506
+ │ ├── check-declaration-order.mjs # ctx.effect 同步执行的 TDZ 审计
507
+ │ └── check-theme-contribution.mjs # 贡献逻辑 + token 必须是字符串
508
+ ├── types/ # 契约校验源(不参与运行)
509
+ └── lib/
510
+ ├── index.js # 宿主:注册 theme-gallery settings 命名空间(预留扩展点,当前无人读取)
511
+ ├── client.js # 浏览器:lazy-CJS 工厂,注册皮肤 + 侧栏面板 + 阅读态
512
+ └── themes/*.json # 内置皮肤(会内联进 client.js)
513
+ ```
514
+
515
+ > `lib/index.js` 注册的 settings 命名空间**当前没有任何东西读取**。桌面版把设置
516
+ > provider 编成 `path: ':memory:'`,手写 `settings.yaml` 会被归档成
517
+ > `settings.yaml.imported` 且从不被查询;浏览器半侧也改为走 `ctx.theme.register`,
518
+ > 皮肤唯一来源是 `lib/themes/*.json`,选择由主题服务自己持久化。
519
+ >
520
+ > 保留它是因为它是**文件型 settings provider 的落点**:下面的 schema 会被这样的
521
+ > provider 用来校验,所以形状被钉住并被测试覆盖,只是暂时还没有运行者。
522
+
523
+ ```sh
524
+ pnpm install
525
+ pnpm run check # 全部五组,当前全绿
526
+ pnpm run bench # 调参台
527
+ ```
528
+
529
+ `pnpm run check` 依次做六件事:
530
+
531
+ 1. **`tsc`(exit 0)**:`types/*.ts` 用官方发布的真实类型包校验插槽注册契约、`defineStore` 座位形状、主题注册与 token 契约。
532
+ 2. **`tests/check-schema.mjs`**:用真实 schemastery 解析内置皮肤(正例),再跑 **10 个反向用例**确认 schema 不是空转——含缺 `label`、缺 `description`、`reading.alpha` 越界等。
533
+ 3. **`tests/smoke-host.mjs`**:**真机加载** `lib/index.js`,用假 context 跑 `apply`,确认注册了命名空间与 schema 实例、缺 settings 服务时是安静的空操作;并守住客户端 `inject` 清单(必需服务在白名单内、settings 域的服务不得出现)与插槽注册是否都被 `ctx.slots.inject` 门控。
534
+ 4. **`tests/check-store-contract.mjs`**:验证 `handle.create()` 的实例语义(每次返回新实例,因此必须钉住)。
535
+ 5. **`tests/check-declaration-order.mjs`**:静态审计 `apply` 体,确认没有同步入口(`ctx.effect` / `ctx.on`)读取更晚声明的顶层标识符——`ctx.effect` 是**同步执行**的。
536
+ 6. **`tests/check-theme-contribution.mjs`**:跑贡献逻辑与幂等守卫,并断言**注册的 token 必须是字符串**(成对对象会变成 `[object Object]`);`scripts/embed-themes.mjs` 校验皮肤(形状 / id 唯一 / 双配色 / 12 必需 token / `reading`)后内联进 `lib/client.js`,幂等。
537
+
538
+ ### 客户端插件通用陷阱(每一条都真实踩过,都已写成自动检查)
539
+
540
+ 这些不只属于主题插件——**任何第三方客户端插件都会遇到**。
541
+
542
+ #### 1. `inject` 数组里声明的一切都是【必需】,拿不到就永久 pending
543
+
544
+ 而**未激活的 entry 会让整个 web boot 失败**:
545
+
546
+ ```
547
+ web boot: 1 entry did not activate
548
+ dsh-theme-gallery: pending (waiting for service: settingsScope)
549
+ ```
550
+
551
+ - 服务由**插件**提供、而那个插件自己还依赖别的服务时,**不要**写进 `inject`。`settingsScope` 就是这种:它来自 `dsh-client-ui-settings`,后者在等 `remote.settings`。
552
+ - 服务由**静态编入**的包提供时,可以写:`slots` / `locale` 来自 `dsh-client-ui-layout`,`theme` 来自 `dsh-client-ui-theme`。
553
+ - **忘了声明同样会炸**:`cannot get property "theme" without inject` —— Cordis 的守卫不允许访问未声明的服务。
554
+
555
+ #### 2. `ctx.effect(cb)` **同步执行** `cb`
556
+
557
+ 因此 `cb` 读到的任何东西必须在**调用点**就已初始化,否则 `Cannot access 'X' before initialization`。这个错误犯了**两次**(`storeActions`、`contributed`)——因为看不到运行时,每次只能靠"重装+重启"发现。所以补了静态审计 `tests/check-declaration-order.mjs`。
558
+
559
+ #### 3. `handle.create()` 每次返回**新实例**
560
+
561
+ ```js
562
+ const instance = handle.create()
563
+ const store = { ...handle, create: () => instance } // 必须钉住
564
+ ```
565
+
566
+ 不钉住:你写进 A、组件读 B,**每次 publish 都落进废弃实例**,而页面照常渲染、只是永远空着。官方两个面板插件都有这一行,我起初以为它是多余样板。
567
+
568
+ #### 4. 槽位注册必须用 `ctx.slots.inject(key, cb)` 等父插槽声明
569
+
570
+ 直接 `register` 到未声明的插槽会产生 **pending wait**:条目**先出现、再在外壳重组合时消失**。回调返回**单个 disposer**(官方两处范例都是普通函数;我一度用 generator,无效)。
571
+
572
+ #### 5. `{ light, dark }` 成对 token 只属于 `overrideTokens`,**不属于 `register`**
573
+
574
+ ```js
575
+ register(def) { this.themes = [...this.themes, def] } // 原样存,不展平
576
+ composeActive(act) { if (this.overrides.size === 0) return act } // 无层时原样通过
577
+ ```
578
+
579
+ `ThemeDefinition.tokens` 是 `Record<string, string>`。把成对对象喂给 `register` 会把 **`[object Object]`** 写进 CSS——**主题选中了、id 对、token 数对、颜色一个都没上**。注册前必须按 `colorScheme` 展平。
580
+
581
+ > 本文档别处那句「每个 token 必须同时给 `light` 和 `dark`」,适用范围是 **`overrideTokens` 层**,不是 `register`。
582
+
583
+ #### 6. 不要用样式表去画承载主题的区域
584
+
585
+ 我注入过一条阅读态规则 `background: var(--dsh-reading-bg, transparent)`,把中列背景设成透明 —— 于是**皮肤被自己的插件盖掉**,整个界面透出应用默认白底。**"变浅"是 token 的事,走 `overrideTokens`**,不要碰背景。
586
+
587
+ #### 7. 配色职责:内容区接近白,主题性格交给 chrome
588
+
589
+ DSH 用 `--dsw-alias-bg-base` 铺满整个中列。把这个 token 设成饱和色 → **一整片平坦色块盖住一切**,看起来"只是变了颜色"。原版设计里左侧菜单与顶栏承担主题性格、大片工作区是白的。所以:
590
+
591
+ - `--dsw-alias-bg-base` → 极浅,可带很轻的纵向渐变
592
+ - `--dsw-specific-sidebar-fill` → **渐变,主题性格在这里**
593
+
594
+ #### 8. 排查手段:让页面自报「服务认为的」与「实际生效的」
595
+
596
+ 症状往往是"选了没反应",而从外部无法区分"没注册""没选中""选中了但 token 坏了""token 对了但被自己的 CSS 盖住"。有效做法是**在页面上并排显示**:
597
+
598
+ ```js
599
+ // 服务认为的
600
+ const active = ctx.theme.getTheme().active
601
+ // 实际生效的 —— 读 live DOM,不要读服务自身
602
+ getComputedStyle(document.body).getPropertyValue('--dsw-alias-brand-primary')
603
+ getComputedStyle(document.body).backgroundColor
604
+ ```
605
+
606
+ 矛盾会立刻显形。当初正是这行对比一次指出 `brand=[object Object]` 与 `body 背景=transparent` 两个问题。
607
+
608
+ #### 9. 构建/工具链
609
+
610
+ - **schemastery 的对象字段默认「可选」**:`z.object({ label: z.string() })` 会**接受**缺 `label` 的对象,必须显式 `.required()`。最初的校验器形同虚设,是「缺 label」反向用例逼出来的。
611
+ - **`z.resolve()` 对非法值是抛 `ValidationError`**,不是返回失败标记;`[value, adaptedInput?]` 第二个元素是适配提示。
612
+ - **`dsh-client-store` 在 Node 下需要 `zustand` / `immer` / `react`**,否则契约测试无法加载。
613
+ - **`@deepseek-ai/dsh-client-*` 的 npm `latest` dist-tag 滞后**:`dsh-client-ui-slots` 的 `latest` 是 `0.0.1-rc.1`,真实最新已到 `0.1.7-rc.2`。用 `^0.0.1-rc.1` 会装到**缺 `ctx.slots` 声明**的老版本。本仓库按宿主实际安装版本**精确锁定**。
614
+
615
+ ### 为什么内置皮肤要内联进 client.js
616
+
617
+ settings scope 返回的是**用户文档段**,内置皮肤是它下面的 fallback——干净安装解析出来**没有这个段**。所以宿主侧不读 JSON,浏览器侧必须把内置皮肤烤进 bundle。
618
+
619
+ ### 为什么 `lib/*.js` 是手写的
620
+
621
+ `./client` 必须是客户端模块系统的 **lazy-CJS 工厂**(`window.__ModuleLoader__.load({ id, factory })`)。生成它的 `clientBundle` tsdown 预设**不在任何已发布的包里**(在仓库 `packages/client/tsdown.client.ts`)。手写就绕开了:本包**没有构建步骤**,git 安装与 npm 安装行为一致,用户也不必为 `prepare` 脚本授权。
622
+
623
+ ---
624
+
625
+ ## 分享到 GitHub
626
+
627
+ 官方 `CONTRIBUTING.zh.md` 原文:**"很抱歉,我们目前无法接受外部 PR(Pull Request)"**——但紧接着给了生态贡献方式:
628
+
629
+ > 创建令你感兴趣的插件,并分享给其他人:
630
+ > **为你的 GitHub 项目添加 `dsh-plugin` 话题**,让其他人更容易找到你的插件。
631
+
632
+ 所以是**独立仓库 + `dsh-plugin` 话题**。别人想贡献皮肤,往 `lib/themes/` 加一个 JSON 提 PR 即可——不需要碰任何代码。
633
+
634
+ | 分发方式 | 用户怎么做 | 代价 |
635
+ |---|---|---|
636
+ | **npm 预构建**(推荐) | `node scripts/install-into-profile.mjs desktop` 后填包名安装 | 你发布前构建好 `lib/`,用户零授权 |
637
+ | **tarball** | 同上,装 `.tgz` | 同上,无需注册表账号 |
638
+ | **git 源码** | 装 `github:you/dsh-theme-gallery` | 需要 `prepare` 脚本 + 用户 `allowBuilds` 授权;本包无构建步骤,可省 |
639
+
640
+ ```sh
641
+ pnpm pack # 产出 tarball
642
+ npm publish --access public # 发布到 npm
643
+ ```
644
+
645
+ ### 运行期依赖
646
+
647
+ `lib/client.js` 里 `require` 的 `@deepseek-ai/dsh-client-store` 与 `react/jsx-runtime` 来自客户端的 platform-module 种子表,**不要**写进 `dsh.client.inject`——官方 `ui-theme` 也是直接 require 值。`dsh.client.inject` 只能列**自身声明了 `dsh.client`** 的包名。
648
+
649
+ ---
650
+
651
+ ## License
652
+
653
+ MIT
654
+
655
+ ---
656
+
657
+ 作者 & 维护:[renjie2026](https://github.com/renjie2026)