dsh-zhuang-fangyi
庄方宜主题 for DeepSeek Harness —— 4 套风格预设(配色/明度/边框/材质/排版)× 浅色深色完整适配,附官方素材壁纸、观测台(官方右栏标签页)、强调色色相自选与启动动效
- Stars
- 0
- Language
- JavaScript
- Created
- Oct 5, 2026
- Updated
- Oct 7, 2026
Introduction
dsh-zhuang-fangyi
庄方宜主题 for DeepSeek Harness —— 4 套风格预设(配色 / 明度 / 边框 / 材质 / 排版五个维度)× 浅色/深色各自完整适配,附官方素材壁纸、观测台(官方右栏标签页 + 浮层兜底)与头像气泡重绘。
非官方同人作品;配色与素材取自《明日方舟:终末地》官方公开物料的量化提取。
特性
- 接入官方主题体系:注册 4 预设 × 2 明暗 = 8 个主题进「外观」下拉,与其它主题插件互不干扰。
- 浅色与深色各自适配:每个 token 都提供两套值,明暗切换由外壳属性驱动,纯 CSS 跟随。
- 可跟随系统:默认走 token 层而不改 preference,
prefers-color-scheme不被锁定。 - 观测台:会话读数(轮次/步数/速率/token/缓存/上下文)+ 运行耗时 + 配色色板 + 壁纸切换。优先作为官方右栏标签页(
sidebarRightTabs扩展席位,与文件/终端并列、可拖拽调宽),右栏收起时以浮层显示在右侧 —— 两条路径由railOwner()单点裁决,不会同时让位。 - 皮肤层:助手头像与气泡/输入框重绘(可单独关闭)。
- 官方取色:荧光黄绿
#F2E957/ 青#75DCD9/ 酒红#D86766/ 橄榄绿#9EBD87/ 米白#E8D4D2;大招形态 墨青#1D3D30/ 冰白青#D2E7E0/ 香槟金#C4D579。 - 双壳适配:桌面端与网页端跑的是两套类名完全不同的壳,插件用「语义锚点 → 哈希反查 → 后缀兜底」三层定位同时兼容(详见
docs/双壳适配说明.md)。 - 竖图不裁人:
portrait/vertical这类竖图自动改用contain完整显示,两侧由同图重模糊垫底填充(判据来自生成器产出的art/wallpapers.json,不硬编码图名)。 - 无构建步骤:两个半边都是手写 JS,除外壳已提供的
react外零依赖。 - 强调色自选:
accentHue可把强调色转到任意色相(0–359°),保留原配色饱和度;明度按对比度自动校正,任意色相都可读(有 672 项色相扫描兜底)。 - 可验证:
npm test串起 392 项对比度断言 + 672 项强调色色相扫描 + 1091 项浏览器半边无头测试(含 13 组真实浏览器引擎实测)+ 发行清单自检(桩忠实复刻插槽冲突校验、官方 tab 注册契约、样式表形态与壁纸渲染链)。另有/diag真机自检(含壁纸渲染五环探针)。
与 Mornye(莫宁 Observation Skin)的对照
本插件的设计参考了开源项目 Mornye-Observation-Skin
(data-plugin-css 哈希反查、状态推导优先级、原生面板让位策略均学自它,MIT)。
功能对照如下:
| 能力 | Mornye 0.4.2 | 本插件 |
|---|---|---|
| 明暗适配 | 仅浅色完整,深色回退官方 | 浅色/深色各自完整适配(8 主题) |
| 观测台载体 | 页面内浮层 | 官方右栏标签页(+ 浮层兜底,单点裁决) |
| 适配壳版本 | 桌面 0.1.7-rc.2(锁死) | 桌面 0.2.0-rc.2 + 双壳三层定位(Web 代码兼容、未真机验证) |
| 壁纸 | 无 | 8 张 × 明暗两版 = 16 张官方素材 WebP;透明度/模糊/位置可调;竖图自动 contain + 模糊垫底(不裁人) |
| 统计 | 轮/步/缓存,— 兜底 | 同策略,另解析 tok/s、token 总量、上下文占比(六卡自适应 240–380px) |
| 状态区 | RUNNING/TOOL/DONE/ERROR/STOPPED + 耗时 | tool/running/error/stopped/done + 运行耗时(1s 走字,running↔tool 不重置) |
| 预设深度 | 三套浅色预设,主要差异在强调色与透明度 | 风格预设:明度基调 + 色度性格 + 边框强度 + 材质深度 + 排版 + 配套壁纸,切预设一眼可辨(明度极差 0.0522) |
| 排版 | 有(字体按颜色/排版/组件三层细化) | 有:5 档字体栈 + 3 档字号(±5%),覆盖 --dsw-font-family(不动字号与代码字体) |
| 无障碍 | 未提及 | 响应 prefers-reduced-transparency 与 forced-colors(壁纸整层撤掉,不改用户设置) |
| 发行工程 | npm + 市场收录 | LICENSE + dshWorkshop 清单 + CI + 清单自检(见 docs/RELEASE.md) |
| 强调色自选 | 有(三种预设强调色,固定) | 有且更开放:任意色相 0–359°,且明度自动校正保证可读(672 项扫描) |
| 静止模式 | 有(开关) | 有(跟随系统 / 静止),只关本插件自己的过渡,不越权全局 |
| 聊天导航 | 有(读页面 DOM,只覆盖已加载内容) | 不实现 —— 官方已提供且更强,见下方「为什么不重复实现聊天导航」 |
| 自动化测试 | Playwright runtime/parity + 语义契约 | 1091 无头断言(含 13 组真实引擎实测)+ 392 对比度断言 + 672 色相扫描 + 发行清单自检 + /diag 真机自检 |
| 发行工程 | ZIP + install/uninstall + SHA256SUMS + PRIVACY | 同套(见 tools/package.ps1、PRIVACY.md、ASSETS-NOTICE.md) |
对照原则:只把「官方没有、Mornye 有」算作缺口。官方已有的能力直接用官方实现, 不重复造 —— 否则做出的是更差的版本,还会与官方 UI 打架。
为什么不重复实现聊天导航
规划中曾把「本地聊天导航」列为本插件的主要缺口(因为 Mornye 有)。核实官方后撤销了这项:
| Mornye 的导航 | 官方已有 | |
|---|---|---|
| 数据源 | 读页面 DOM(只覆盖已加载内容) | Host 投影 turnOutline(覆盖全部已开始轮次,含未加载) |
| 覆盖 | 当前页可见内容 | 全部轮次 |
| 定位 | scrollIntoView | 官方轮次导航轨(右侧刻度梯,10px 间距,可滚动,支持加载更早历史) |
| 搜索 | 有 | dsh-client-ui-trajectory 提供(含时间线、token 用量、TTFT 耗时) |
即:Mornye 的导航是在较老壳版本(0.1.7-rc.2)上补的 DOM 版;在本插件适配的 0.2.0-rc.2 上,官方已把这件事做得更根本。自己再做一遍只会更差且重复,所以不做。
对标收尾:Mornye 有、官方没有的剩余项是外观控制器的强调色自选 ——
该项已在 v0.3.0 实现(accentHue,任意色相 + 对比度自动校正),见下方「强调色色相」一节。
安装
从 GitHub 直装(推荐,无需 npm):
dsh plugin --profile desktop add github:yangwenjie1231/dsh-zhuang-fangyi
装完重启 DSH(bundle 在启动时装配;宿主半边改动更是必须重启)。
从本地源码装(改代码时用):
# 从本地目录装进 desktop profile
dsh plugin --profile desktop add /path/to/dsh-zhuang-fangyi
或手动:把本目录放到 $DSH_HOME/profiles/<profile>/node_modules/dsh-zhuang-fangyi,
并在该 profile 的 package.json 里加依赖与 dsh.profile.bundles 条目,然后重启 DSH。
改代码后怎么生效
| 改了什么 | 生效方式 |
|---|---|
client.js | 走 HMR(模块注册表按 mtime/size 派生 revision),必要时刷新页面 |
index.js / src/*.js | 必须重启 DSH |
plugin_manager 的 disable → enable 只重跑 apply(),不会重新 import
依赖模块 —— Node 的 ESM 缓存按路径生效,所以改了 index.js 或 src/*.js
后页面仍是旧代码。这不是代码写错了。
用构建标记可以一眼确认跑的是哪一版(tools/deploy.ps1 部署时写入):
Invoke-WebRequest http://127.0.0.1:19387/api/zhuang-fangyi/themes | % Content
# {"build":"20261005-123748", ...} ← 与部署时间一致即已生效
从发行 ZIP 安装(用户视角)
从 Releases 下载
dsh-zhuang-fangyi-<版本>.zip(附 .sha256 与包内 SHA256SUMS.txt),解压后:
# 解压发行包后,先校验再安装:
.\install.ps1 -DshPath 'D:\path\to\DeepSeek Harness' -CheckOnly
.\install.ps1 -DshPath 'D:\path\to\DeepSeek Harness'
install.ps1 会把插件写入 profiles/desktop/node_modules、更新 profile 的依赖与
bundles 条目,并备份 package.json 原始字节供 uninstall.ps1 还原。
发行包由 tools/package.ps1 生成(ZIP + SHA256SUMS.txt)—— 文件清单从
package.json#files 派生,不手抄(手抄的会漂移:曾经因此让发行包漏掉 LICENSE)。
设置
「设置 → 庄方宜」,7 组 24 行:
| 分组 | 项 |
|---|---|
| 总览 | 启用 · 风格预设(本体黄绿·明亮轻盈 / 大招墨青金·厚重深沉 / 青·清爽中性 / 酒红·浓郁暖调)· 一键推荐组合 |
| 明暗 | 明暗模式(跟随系统 / 固定浅色 / 固定深色)· 浅色预设 · 深色预设(各带标签,默认「跟随主预设」) |
| 背景 | 背景图(缩略图条,随明暗显示对应版本 · 可上传自定义)· 不透明度 0–90%(拖满近乎全透)· 模糊 0–16px · 位置(铺满 / 靠右 / 平铺)· 轮播(关 / 1 分 / 5 分 / 30 分)· 轮播顺序(关闭时不显示) |
| 排版 | 字体(默认 / 无衬线 / 衬线 / 圆体 / 等宽)· 字号(紧凑 / 标准 / 宽松)· 阅读宽度(紧凑 760px / 标准 / 宽松 1080px) |
| 细节 | 强调色色相(0–359° 或「预设」)· 等高线细边框 · 强调色微光 · 空白页头像 · 标题栏跟随(仅 Windows 桌面) |
| 动效 | 动效(开启 / 跟随系统 / 关闭)· 启动动效 |
| 皮肤 | 右侧观测栏 · 观测栏宽度 240–380px · 头像与气泡重绘 |
分组定义写在 SETTINGS_GROUPS(显式常量,含每组行数上限),渲染顺序与它一致 ——
测试会断言「渲染顺序 == 常量顺序」与「每组不超上限」,所以加了设置项忘了归组会直接失败,
而不是默默堆进某一组。
为什么动效单独成组:
motion含无障碍语义(「跟随系统」会尊重系统的 「减少动态效果」),混在装饰里容易被当成纯装饰开关随手关掉。
底部按钮按危险 / 中性 / 主要分档:恢复默认(弱化的文字按钮, 需二次确认,3 秒不点自动复原)|导出设置 / 导入设置(JSON)| 重新保存(失败态下显示为「重试」)。 侧栏底部另有一键开关。
自定义背景
缩略图条末尾有一格「+ 上传图片」,点它选一张本地图(PNG / JPEG / GIF / WebP,
上限 24 MB)。传完出现在条里,点一下才应用 —— 与「推荐壁纸只提示、
不自动切换」同一原则。每张自定义图右上角有个 × 可删除。
| 事项 | 说明 |
|---|---|
| 存哪 | $DSH_HOME/zhuang-fangyi/backgrounds/custom-<时间戳>-<随机>.<ext> |
| 为什么不在插件目录 | tools/deploy.ps1 是先删旧目录再复制(防 art\art\ 嵌套),放里面每次重新部署都会被抹掉。与 settings.json 同级最稳 |
| 明暗 | 一张图两套明暗共用 —— 切明暗不换图,因此也不会触发交叉淡入(没换图就不该闪) |
| 竖图 | 按上传时解析的真实尺寸判 contain(阈值 0.87,与内置壁纸生成器同一判据),两侧由同图重模糊垫底 |
| 格式校验 | 宿主按字节魔数判定(不信 content-type,也不信扩展名);解析不出尺寸的直接拒绝 |
| 安全 | 文件名由宿主生成(客户端传路径不会被采用);服务时 path.basename + 目录白名单双重校验,防目录遍历 |
| 删除当前壁纸 | 同时清掉设置里对它的引用 → 回落为「无壁纸」,不留死引用(不会 404) |
| 卸载插件 | 上传的图仍在原处(不在插件目录),需要时自行删除该目录 |
记录损坏或文件被手工删掉时,背景会回落为「无壁纸」而不是某张内置图 —— 「我的图没了」不该变成「莫名冒出一张官方壁纸」。这条有断言盯着。
一键推荐组合(C14)
预设既然是一整套视觉性格,配套的壁纸 / 纱的厚薄 / 字体 / 阅读宽度就不该让 用户自己一个个试。设置页预设行下面有一个按钮,一次把这几项配好:
| 预设 | 壁纸 | 不透明度 | 字体 | 阅读宽度 |
|---|---|---|---|---|
| 本体黄绿 · 明亮轻盈 | sakura | 10% | 无衬线 | 标准 |
| 大招墨青金 · 厚重深沉 | dark | 22% | 衬线 | 紧凑 |
| 青 · 清爽中性 | pool | 12% | 圆体 | 标准 |
| 酒红 · 浓郁暖调 | promo | 26% | 衬线 | 宽松 |
刻意不覆盖:enabled / scheme / rail(功能与外观偏好)、motion
(无障碍设置 —— 被一个「换风格」按钮改掉属于越权)。
也不自动触发 —— 与「推荐壁纸只提示、不自动切换」同一原则。
组合数据由宿主算好随 /themes 下发(presetCombos):浏览器半边是自包含
bundle,不能 import src/,若在客户端再写一份就是又一份会漂移的手抄副本。
明暗分别指定预设(C13)
「浅色用青、深色用墨青金」。两格下拉默认都是跟随主预设 —— 不选就不分叉, 所以老设置文件升级后行为一字不变(默认值必须是「跟随」而不是某个具体预设, 否则升级即静默改变所有人的观感)。
实现上有一个修掉的既有半生效状态:theme/change 原先只调
syncSchemeWallpaper,不重跑 applySettings —— 切明暗时壁纸会换,但配色、
材质深度、代码高亮都不会。分档启用后必须走完整的 applySettings()。
两个容易做歪的点:
- 跟随系统时表达不了「浅色 A / 深色 B」:
overrideTokens只接受单一预设的{token:{light,dark}},没有「按明暗选不同来源」的概念。所以只能在值这一层 拼:每个 token 取浅色预设的light与深色预设的dark(composeSchemeOverrides)。 - 重入:固定明暗下
applySettings会theme.setTheme(),而外壳的setTheme会再 emittheme/change→ 无限递归。闸门必须在调用之前置位;第一版写在 事件回调里,那样只是「回调里再进不来」,applySettings本身照样被重入。
所有读预设的地方都走 presetForScheme() 单一入口(共 8 处)—— 漏一处就会出现
「配色换了但材质 / 代码高亮 / 壁纸推荐没换」。测试里有一条断言盯着这点,
拿反例验过(把任意一处改回直接读 settings.preset 会立刻失败)。
壁纸轮播(C15)
关 / 1 分 / 5 分 / 30 分,顺序或随机。
- 复用
applySettings()(内部走syncSchemeWallpaper)→ 交叉淡入与暗版分流 自动生效。直接写--zf-art-src会绕过这两个(都是修过的坑)。 - 不写盘:轮换是会话内的展示状态。写盘会有两个坏结果 —— 每次重启都换一张 (用户以为设置被改了),以及导出文件里混进一个随机值。
- 随机排除当前那张:否则 1/8 概率原地不动,看起来像「轮播坏了」。
- 页面不可见时跳过一轮;插件关闭或壁纸为
none时不轮播;卸载时停掉定时器 (不停的话停用后仍会每 N 分钟写一次 DOM)。
设置导入 / 导出(C12)
导出为 dsh-zhuang-fangyi-settings.json,形如
{"_meta":{"plugin","version","exportedAt"},"settings":{…}};导入整体替换当前设置。
安全边界只有一处:宿主 normalizeSettings(白名单 + 夹取)—— 未知键丢弃、
越界值夹回、非法值回落。客户端不再写一遍字段校验(写两遍必然漂移),
所以「导入一份恶意 JSON」在结构上就写不进坏值。另有 64 KB 上限
(客户端先挡 file.size,宿主 readBody 还有一道)。
导入后若 accentHue 变了会 reloadThemes() —— token 表是宿主算好下发的,
不重取就是「选了色相没反应」那个坑。
观测台的双路径行为
| 当前状态 | 谁来显示观测台 |
|---|---|
| 右栏收起 | 浮层(shell.overlay,position:fixed 贴右边、避让 40px 标题栏) |
| 右栏展开 | 官方标签页(自动 openTab,与文件/终端并列、可拖拽调宽) |
| tab 挂着但面板收起 | 浮层接手 —— 官方实现里 docked 内容收起时仍挂载(只是平移出右边缘),所以「挂着」不等于「看得见」 |
| 用户手动关掉我们的 tab | 不反复重开(尊重选择),浮层也不接管(面板还开着,可从「指南」重开) |
| 视口 < 1180px | 隐藏(同 Mornye 断点) |
| 原生面板(文件/终端等)已开 | 不代为展开用户的面板;观测台作为 tab 并存其中 |
- 两路径的裁决函数是
railOwner(),判定条件是两个:tabMounted > 0(tab 真的挂着内容) 且nativeRightbarOpen()(面板确实展开)。只看前者会在收起后误判 —— 那时 tab 仍挂载但已移出可视区,结果两边都看不见(实测踩过两次:一次是「注册≠打开」, 一次是「挂着≠可见」)。 - 观测台刻意不读取消息正文 —— 读数只来自外壳已渲染的统计行,本插件是皮肤,
不该碰会话内容(隐私边界见
PRIVACY.md)。 - 头像与气泡:助手消息旁显示圆形头像 + 「庄方宜」标签;用户气泡与输入框 改为细边框 + 圆角。
设置落盘于 $DSH_HOME/zhuang-fangyi/settings.json(临时文件 + rename 原子写)。
文件损坏时会备份为 .corrupt-<时间戳> 并回落默认值,不覆盖原文件。
设置结构版本 v3(v1→v2 加观测栏/气泡,v2→v3 加强调色色相与静止模式);
旧文件会无损升级(新键补默认值,已有字段全部保留)。
观测台的读数从哪来(权威推送 + DOM 兜底)
六个读数(轮 / 步 / tok/s / token 总量 / 缓存命中 / 上下文占比)与状态点优先来自宿主:
宿主事件:turn/start · turn/end · step/start · assistant/message.usage
tool/call · tool/result · request/context · api-session/status
↓ src/sessionState.js 折叠(**白名单**:只取数字与枚举)
GET /api/zhuang-fangyi/stream?session=<id> SSE(200ms 合并 · 15s 心跳)
↓
客户端 EventSource:有新鲜帧(10s 内)就用它;否则回退解析 DOM
为什么要换:这些数字原本全靠解析 DOM 文字 —— 已经因为「外壳结构变了」栽过三次 (右栏 pane / 左栏内层 / 输入区座位),每次都是同一个病:读的不是权威来源。
字段级回退:cacheHit / rate / context.used 宿主可能给不出(provider 没报、
上下文窗口未知)—— 那几格用 DOM 的值,其余仍用宿主的。整条流不可用(没连上 / 打开的
是历史会话不在宿主 / 环境没有 EventSource / 断了正在退避)→ 六项全回退,也就是
换之前的行为一字不差。
隐私:载荷逐字段写出(绝不 spread 事件对象),只有数字读数与状态枚举; 消息正文、工具参数、流式文本一律不读。有断言盯着字段集 —— 想加一个泄漏字段就会失败。
退路:?once=1 返回同形的单个 JSON 快照。万一某个载体的协议不吃流式响应,
客户端改成轮询即可(一行)。/api/zhuang-fangyi/diag 里的 statsSource
(sse / dom)能一眼看出当前走的是哪条路。
成本:只有有人订阅的会话才折叠事件(先查 Map,其他会话零开销);变化按 200ms
合并;上下文占比调 tokenMeter.measure()(O(surface))按 2s 限流、回合结束时补一次;
订阅者断完即回收 tracker 与计时器。
风格预设:为什么「只换配色」不够,以及怎么修
用户反馈「切换主题就改个配色会不会太少了」。实测证实了这个判断:
| 对比 | 浅色 base 亮度差 | 深色 base 亮度差 |
|---|---|---|
| 修复前(四套预设两两) | 0.0019 – 0.0144 | 0.0001 – 0.0012 |
深色下 0.0001 = 肉眼完全不可辨。根因有两条,叠加起来就是「切预设 ≈ 只换按钮颜色」:
- 旧版四套预设的
chroma/chromaDark全是 5/6,只有hue不同 - 而
hue对大面积表面的影响被「按面积分配」刻意压到极低(那是 v3 为了 大面积不显脏而定的原则,本身是对的)
修法:预设升级为风格预设,每套带 4 个维度:
| 维度 | 作用 | 实测效果 |
|---|---|---|
明度基调 surfaceShift | 表面明度整体平移 | 浅色极差 0.0144 → 0.0522;深色 0.0012 → 0.0034 |
色度性格 chroma | 各套不同(4/7、8/11、6/8、7/10) | 表面冷暖浓度可辨 |
边框强度 borderAlpha | 缩放 border-l1..l4 | 0.72/0.9/0.6/0.8 |
材质深度 depth | 覆盖 --dsw-elevation-* 三档 | flat 纸面 / soft 官方 / deep 实体面板 |
外加配套壁纸(只作推荐,见下)。
⚠️ 「文字更柔和」这个维度的可用幅度极小:textSoft 给 4.5 个百分点时,
burst/light 与 wine/light 的「三级文字 / 二级面」会跌破 4.5:1(实测
4.20–4.41),所以最终只能给 2.0。这是设计约束不是可调参数 —— 想更柔必须
同时压深 surfaceAlt,不能单独拉高文字明度。
配套壁纸只作推荐,绝不自动切换
每套预设声明一张配套壁纸(sakura / dark / pool / promo),客户端在壁纸缩略图条
上给它打一个小圆点标记,title 追加「(本预设推荐)」。
切换预设不会改动 settings.background 一个字节 —— 有测试断言锁死。
选择权完全在用户手里。
(那个小圆点是圆形标记,用了 border-radius:50% + corner-shape:round
—— 外壳全局把 *,:before,:after 设成 superellipse(1.5),不还原的话圆点会
变成圆角方块。全仓除此之外没有任何圆角改动。)
补全 alias token:为什么「只有一部分控件变色」
外壳共 120 个 alias token,旧版只注册 77 个。没注册的 token,外壳会用回 它自己的默认值 —— 这就是「切主题只有一部分控件变色」的根因。
按外壳引用次数从高到低补齐了 41 个(引用多 = 出现得多):
| 引用 | token | 说明 |
|---|---|---|
| 142 | state-error-primary | 错误态 |
| 44 | state-success-primary | 成功态 |
| 28 | state-warn-primary | 警告态 |
| 25 / 22 | state-warn-label / -tertiary | 警告文字 |
| 14 | label-deep-diving | 「深度思考」标签 |
| 10 | button-tool-bar-fill | 工具栏按钮(用户看到的「某些按钮」) |
| … | 遮罩 5 个、分层背景 3 个、填充 4 个、diff 9 个、分隔/选区/浮标 |
状态色的设计原则:色相锚定语义(成功=绿 145°、警告=琥珀 42°、错误=红 8°), 只让明度骨架与色度尺度向预设靠拢。把「错误」染成主题色是错的 —— 用户会认不出它。所以:
- 状态色用固定色度基线(60)而不是
c * N:表面色的 chroma 只有 4–11, 按它的尺度算出来是灰的(实测#9AAFA3灰绿、#B4A29F灰粉), 完全起不到提示作用 - 明度按明暗分开(浅色压深、深色提亮),否则浅色底上对比度不足
(实测
stateIdle两边都用 55% 时跌破 3:1)
覆盖率:77/120 → 118/120。差的 2 个是模板字符串拼接出的非常规名
(--dsw-alias-scrollbar-${level} 之类),不是真实 token。
强调色色相(accentHue)为什么不能只转色相
accentHue 允许把强调色转到任意色相(0–359°),保留原配色的饱和度。
最初的实现只旋转色相、保留明度,理由是「预设的 accentLight 明度已按可读性
调过,换色相后依然成立」。这个推理是错的 —— 相对亮度取决于色相:人眼对绿最
敏感、对蓝最迟钝,同一明度下黄绿转蓝后亮度显著下降。
contrast.js 里有一条色相扫描(12 色相 × 4 预设 × 2 明暗 × 7 断言 = 672 项)
把这件事变成了可测量的:初版实现有 62 项跌破阈值,例如
wine/light转 60°(黄):链接 3.25:1 < 4.5(变亮 → 在浅底上不够暗)burst/dark转 240°(蓝):链接 4.16:1 < 4.5(变暗 → 在深底上不够亮)
修法:rotateAccent() 改为以对比度目标反解明度 —— 保留饱和度,明度在
原值附近搜索,使该色对全部参照面都达标。参照面必须逐一满足,因为
link 要同时读在 4 个面上,其中一个(specific-bubble = brandSoft)
本身由强调色派生,换色相时前景与背景一起动,是自指约束。
改完后 672 项全通过 —— 即任意色相都保持可读。这条扫描是 accentHue 的
安全网:以后调色板配方若破坏了这个性质,npm test 会直接失败。
实现依据
以下结论全部从运行中的外壳源码核实(app.asar 内 @deepseek-ai/dsh-client-ui-theme、
dsh-client-ui-layout、dsh-desktop),不是推测:
| 事实 | 对实现的影响 |
|---|---|
validateOverrides 对裸字符串直接抛错,原文 a single value goes illegible when the user switches color scheme | 每个 token 必须同时给 {light, dark} |
composeActive() 按 active.colorScheme 从 {light,dark} 二选一 | 只给一套 → 另一套是 undefined → 切主题即失效 |
内置 light/dark 主题的 token 表是空的,真调色板在 CSS 的 body{} / body[data-ds-dark-theme]{} | 基线不能读注册表,只能读计算样式 |
presenter 把 token 写成 body 的行内样式(先 removeProperty 全部旧的再写新的) | ① 外部 CSS 压不过它 → 壁纸透明必须做进 token 值本身;② 层一撤就恢复默认 |
切明暗 = presenter 增删 body[data-ds-dark-theme] | 壁纸明暗两版可纯 CSS 跟随 |
setTheme 只在 isThemePreference(id) 时写盘,而内置偏好只有 light/dark/system | 第三方主题 id 不持久化 → 插件自己存设置并在启动时重设 |
register 的 disposer 在 preference 指向自己时重置为 system | 卸载即干净还原,无需手动复位 |
| token 名不设白名单 | 可覆盖 --dsw-static-* 色阶,也可补外壳未定义的 --dsw-alias-focus-ring-color |
外壳的 specific-* 一族是 --dsw-specific-*(没有 alias) | 写错前缀不会报错,只会静默失效 → contrast.js 用真实 token 名清单逐条校验 |
桌面端与网页端是两套壳(BynINW_*/rightbarCol vs pI_x6G_*/detailsCol) | 右栏选择器两个后缀都要写;定位改用三层策略 |
外壳为每个 CSS Module 插入 style[data-plugin-css],内含真实类名 | 可反查当前哈希,不必猜 → 跨版本自愈 |
桌面壳暴露 456 个 data-* 语义锚点 | 优先用锚点定位,比类名稳 |
桌面端 tapIndex 从不执行:dsh-app:// 协议处理器直接从磁盘读 dsh-web-frontend/dist/index.html,只注入 __DSH_BOOT_READY__,不经过 Host 的 webServer | 宿主注入的 CSS 在桌面端一个字都不出现 → 客户端必须自己 fetch('/style.css') 并插 <style>。这正是最初「只改了配色」的根因 |
客户端插样式表用 el.textContent = css,不能带 <style> 标签 | /style.css 必须发纯 CSS(structureCss());带标签会让浏览器把紧随的 html{} 块整块丢弃 → --zf-art-* 全失效 → 壁纸画不出来(而观测台样式仍正常,极具迷惑性) |
| Node ESM 按路径缓存,disable/enable 不重新 import | 改 Host 侧代码必须重启 DSH → 加构建标记以便判别 |
两层覆盖
- 19 级中性色阶
--dsw-static-neutral-bluish-*—— 只在body{}定义一次, 明暗两套 alias 各自引用其中不同级数。覆盖一次色阶 = 明暗双向同时生效, 并自动兜住未列举的组件。 - 语义 alias 直写 —— 色阶只给整体色调倾向;浅色下
bg-base、layer-1、layer-3、按钮、输入框全部映射到同一级static-00,分层靠描边而非填色, 必须直写才能给出层次。
配色:色度、以及为什么必须「按面积分配」
HSL 的饱和度是相对量,在明度两端会被压缩。同一个 s=0.13:
L=96.5%→#F7F7F5,几乎纯灰(肉眼看不出颜色)L=50%→ 明显有色
界面底色恰恰全在明度两端(浅色 89–99%、深色 8–21%),所以「调饱和度」在底色上
几乎无效。改用色度(max-min,0..255)—— 绝对量,直接对应「看起来有多少颜色」:
S = chroma / (255 · (1-|2L-1|))
这样无论明度多高多低,色相都保持同样的可见强度。见 src/palette.js 的 tint()。
但色度不能当成「整体染色」的旋钮 —— 这里踩过两次坑:
| 版本 | 做法 | 反馈 |
|---|---|---|
| v1 | 表面色度 3–5 | 整屏发灰,「太丑」 |
| v2 | 表面色度 12–22 | 「像给整个画面加了一层滤镜」 |
| v3(当前) | 按面积分配 | 近中性底 + 主题色点缀 |
v3 的原则:色度按面积分配。
| 面积 | 部位 | 色度 | 作用 |
|---|---|---|---|
| 大面积 | base / surface / surfaceAlt / sidebar | 2–6 | 近中性,只留一丝冷暖暗示 |
| 中面积 | code / codeBanner / inlineCode | 7–10 | 把代码块从底上分出来 |
| 选中态 | navActive / brandSoft / 气泡 | 15–21 | 明显带主题色,但不抢眼 |
| 小面积 | brand / link / focusRing | 58–155 | 主题色只在这里出现 |
大面积带色相必然显脏、像滤镜 —— 真实界面的大面积色度都很低
(GitHub dark #0D1117 色度 10、VS Code #1E1E1E 色度 0)。
这也正是「更明显的主题色」的正确实现:不是把底色染绿,而是让强调色在近中性
的底上跳出来。调色时改 PRESET_SPECS 里的 hue 与 chroma 两个数即可。
为什么「跟随系统」用 overrideTokens 而不是 setTheme
桌面版下,setTheme(固定id) 会让 presenter 把 html[data-ds-theme-source] 设为该
scheme,桌面壳 preload 再转发给 nativeTheme.themeSource —— 一旦如此,
prefers-color-scheme 就被锁定,系统换主题不再触发。
所以「跟随系统」走 token 层(不改 preference,data-ds-theme-source 保持 system),
「固定浅色/深色」才走注册表。两条路径各司其职。
壁纸的「纱」为什么不能改 token
最初给 --dsw-alias-bg-base 套 alpha 让外壳透出壁纸,实测无效:presenter 把
全部 token 写成 body 的行内样式,行内优先级高于任何样式表规则。
正确做法:不动 token,而是把外壳那几层不透明的背景改成半透明。半透明色由
客户端按当前预设与明暗算好,写成 --zf-veil*(外壳不认识的新变量)。
还有个容易错的点:外壳是嵌套的,若给每层都套 alpha,可见度会连乘
(两层各 0.83 只剩 0.69;实测「壁纸完全看不见」时只剩 3%)。所以外层
(body / #root / frame)全部透明,只让三个列各带一层纱。
壁纸渲染链(五环,逐环可诊断)
壁纸从设置到画出来要经过五环,任一环断掉的表现都是「背景没反应」,
所以 /diag 里有一条 wallpaper 探针逐环报告计算值:
| 环 | 内容 | 断掉的表现 |
|---|---|---|
| ① 属性 | html[data-zf-wallpaper] / body[data-zf-wallpaper] | 整条链不启动 |
| ② 间接引用 | 行内 --zf-art-src: var(--zf-art-<id>) | 计算值为空 |
| ③ 被引用变量 | 样式表 html{ --zf-art-<id>: url(...) } | ② 解析失败 → 整条属性失效 |
| ④ 绘制层 | html::before 的 background-image 计算值 | none = 没画 |
| ⑤ 上层透明 | 纱色 / frame / 中栏的实际背景 | 不透明就盖住壁纸 |
第 ③ 环曾经断过很久:/style.css 带了 <style> 标签,浏览器把紧随的
html{} 块整块丢弃 → 21 个 --zf-art-* 全没定义 → 壁纸怎么都出不来,
而观测台样式(在文件后半段)完全正常。详见
docs/双壳适配说明.md 第八节。
纱之上还有别的层(右栏整片黑的原因)
上面五环全绿、中栏也透出壁纸了,右栏仍可能整片黑。「开始」页和 tab 条尤其 明显 —— 这不是壁纸链断了,而是壳在纱之上又铺了一层不透明底。
壳源码实测:右栏 dockkit 的每个 pane 都带
._tabHost_6nhg2_162:not(._float_6nhg2_156),._emptyTabHost_6nhg2_143{background:var(--dsw-alias-bg-base)}
而 tabHost 就是 pane 本身(children = tabHostHeader(tab 条) + tabHostBody(页面内容)),
所以 tab 条与「开始」页/文件页都坐在它上面 —— 而这两个元素自己都没有背景。
判据:先问「谁在这块上声明了 background」,从最上面的元素往下查,
而不是从壁纸往上猜。修法与「三处刻意不碰」见
docs/双壳适配说明.md 第九节。
同一类还有左栏(会话列表那侧):Windows 上列(sidebarCol)与内层组件
(_2H3hWW_root)是两层同色不透明底,内层把纱盖回去 —— 官方只给 macOS 写了
内层透明(background:0 0)。这里的修法不是去点名内层组件(它的本地名是
root,[class*="_root"] 这种写法会误伤一大片),而是在列上把
--dsw-specific-sidebar-fill 置透明:自定义属性按最近祖先解析,内层引用的那个
变量就地透明 —— 不依赖任何类名哈希,纯 CSS 首帧生效。
第三个面是输入区:.Dc7zOa_composerSeat 铺了一条「透明 → bg-base」的 36px
渐变(Dc7zOa_root[data-phase=active] / Dc7zOa_embeddedBody[data-content-phase=active]
两条规则)。它的用意是让滚动中的消息消失在输入条上方 —— 但壁纸开启时
bg-base 是主题的不透明底色,于是输入区上方成了一条黑色渐变带。
壁纸模式下改成不涂(透明),那条带子就露出中栏的纱,与周围同色。
代价:座位不再遮滚动中的文字,消息会显示到输入卡上沿(卡片本身不透明, 所以只在它上方那圈留白里看得到)。要更安静的话,可以换成「渐到纱色」或 加一道轻
backdrop-filter—— 两者都比原来那条黑带轻。
透明度只有一个来源(观测栏)
观测栏(右栏收起时的那个浮层)背景是透明的,它不涂自己那层纱:露出的就是 它下面中栏那层纱。所以它永远等于滑杆那一档,不会出现「数值同源、观感不同」。
早先它走自己的一套:私有变量
--zf-rail-veil+ 私有底色 + 62% 兜底。数值确实 来自同一个滑杆,但它是在中栏那层纱之上又涂一层 —— 14% 档两层相乘 ≈ 0.98 (几乎全实),90% 档 ≈ 0.19(仍比别处实)。这正是用户看到并反馈的差。backdrop-filter保留:它不改透明度(纱是一层纯色,模糊它还是那个色), 只把壁纸细节糊掉 —— 高档位下小字号的可读性保险。
跟随预设的小细节(选中色 / 输入光标 / 代码高亮 / 换图)
这四处以前不跟随主题,现在都跟了:
| 处 | 以前 | 现在 |
|---|---|---|
选中文字 ::selection | 浏览器默认蓝,在壁纸上很跳 | 强调色兑 30% 透明做底,文字保持 label-primary(底色只做提示,不压花字) |
输入光标 caret-color | 系统默认色 | 强调色本体(只占一个字符宽,取色可以大胆) |
代码高亮 --shiki-token-* | 外壳写死的 OpenColor:关键字粉 #d6336c、函数紫 #6741d9、字符串绿 #2f9e44 | 按预设重算:语义优先(字符串绿 / 常量琥珀 / 注释弱化),强调色家族跟随预设(关键字 / 函数 / 链接) |
| 换壁纸 | 瞬切(啪 一下) | 240ms 交叉淡入 |
三条实测确认的细节,都是踩过的:
- 选中色/光标挂在
body[data-zf-theme](主题启用标记),不是某个装饰开关 —— 挂在data-zf-glow(强调色微光)上会变成「关掉微光,选中色也跟着回默认」。 - 代码 token 色必须写在
body行内:外壳的暗色那组声明在body[data-ds-dark-theme]{--shiki-token-…}上(亮色在:root)。自定义属性按 最近祖先解析,写html会被暗色那条压回去 —— 两种写法都在真实引擎里量过 (见docs/双壳适配说明.md第十一节)。 - 代码 token 有对比度门禁:每条都要在代码块底色上 ≥ 4.5:1;不够就沿明度校正 (浅色压深 / 深色提亮,最多三档),仍不够才不发这一条(保留外壳默认色)。 实测 72 条全部达标,最低 4.57:1。
- diff 行(增删行)单独算一套:这一族的底色 token 在壳里只当背景用,
而且官方给行设的文字色是状态色本身(绿字压绿底、红字压红底)—— 实测 16 对
搭配里 11 对低于 4.5:1(8 组配色里 7 组至少一对不达标),我们再把底做实一点
就彻底看不见了(用户截图反馈)。
现在的做法:底色由「代码块底色 + 状态色」合成一个不透明浅色调(浓度是解出来的:
取刚好满足「与代码底可辨 ≥ 1.18」且「行上文字 ≥ 6:1」的最小值;实测落在
1.204–1.237、文字 8.92–11.68:1),文字色由我们的 CSS 拉回
label-primary,行首的+/-也跟着变 —— 增删的区分交给底色承担。 ⚠️ 定位用的是语义锚点[data-code-block-content]+ 本地名_add_/_del_: 这一族的类名不能猜(真实本地名是add/del/context,写[class*="code-diff"]一条都命中不了 —— 第一版就是这么白改的)。
交叉淡入怎么做的:background-image 不能过渡,所以换图前把 html::before
的计算绘制快照抄到一层临时元素(.zf-art-fade)上,写完新图后让它 240ms 淡出、
随即移除(不留常驻空元素)。三层用显式 z-index 链:
模糊垫底 -3 < 当前图 -2(html::before) < 上一张 -1(临时层)
顺序写在数值里,不靠「同层叠级 + 树序」这种微妙规则 —— 第一版就是把临时层放在
当前图下面(-2 vs -1),结构断言全绿、肉眼却什么都看不到(新图是不透明照片,
盖在上面)。只有真的换了图才做(拖不透明度滑杆会反复重跑,那时不该闪)。
动效设为「静止」或系统 prefers-reduced-motion → 直接切,连层都不建。
诊断:
/api/zhuang-fangyi/diag里有artFades计数 ——0表示压根没建层 (多半是动效模式为「静止」或系统关了动画),>0却看不到淡入那才是渲染问题。
阅读宽度与逐图取景
阅读宽度(设置 → 装饰):紧凑 760px / 标准(交还外壳)/ 宽松 1080px。
外壳把 --dsh-chat-content-width 声明在 [data-conversation-content] 自己身上:
.Dc7zOa_body{ --dsh-chat-content-width:
var(--dsh-chat-user-width, clamp(680px, calc(列宽 * .64), 920px)) }
而且外壳自己还会往那个元素写行内 --dsh-chat-user-width(宽度手柄)。自定义
属性按最近祖先解析 —— 所以写 html / body 一律无效,必须写在那个元素上
(客户端行内写,带 !important;实测行内不加 !important 也能赢,加它是防外壳
哪天改成行内写)。选「标准」= 移除我们的声明,把宽度手柄一起还回去。
逐图取景:art/wallpapers.json 每条可以带 focus(background-position
的百分比语法),它会成为 --zf-art-position。
⚠️ 它只在画面被裁切时才有可见效果(窗口宽高比 ≠ 图片宽高比):16:9 图铺在 16:9 窗口里没有裁切,写什么值都一样;真正救场的是「主体偏一侧」的图配超宽屏窗口。
取景值的来源是视觉测量(主体包围盒 + 面部水平位置),只采纳多次测量一致的
结论 —— 同一张图两次量出来的包围盒差得很多(pool 一次 43–96、一次 20–78),
所以目前只给 contour 写了 65% 50%(两次都指向「主体在右半、左侧留白多」),
其余各张保持居中。优先级:平铺 > 用户显式选「靠右」> focus > 竖图 center 22% >
center。
竖图为什么要 contain + 模糊垫底
portrait(1080×1920)与 vertical(1440×2560)宽高比 0.56,用 cover 铺横屏
只能看到中间约 40% 的高度 —— 人物被裁成一条、脸被放大 1.24 倍。
做法:前景层 ::before 用 contain 完整显示(取景 center 22% 保住头部),
新增 ::after 垫底层用同图 cover 铺满 + blur(64px) + 按明暗压暗,填两侧空隙。
判据来自 art/wallpapers.json(prepare-art.py 按宽高比生成,不硬编码图名),
清单缺失时回落 cover,不会坏。横图完全不变(垫底 none,零额外开销)。
桌面版 / 网页版
详见 docs/双壳适配说明.md。要点:
| 维度 | 网页版 | 桌面版 |
|---|---|---|
| 宿主 | 同进程 | 独立 Node 进程(@deepseek-ai/dsh-desktop-host) |
| 外壳来源 | profile node_modules | app.asar 自带 |
| AppFrame 类名 | pI_x6G_*,右栏 detailsCol | BynINW_*,右栏 rightbarCol |
data-platform / data-windows-titlebar / data-fullscreen | 无 | preload 注入 |
品牌插槽(sidebar.brand.mark 等) | 只声明、不渲染 | 有渲染方(带 fallback) |
| 原生标题栏配色 | — | 自动跟随:preload 的 probe + MutationObserver(body 的 style) + canvas 取色 → IPC setTitleBarOverlay。因为 presenter 正是写 body 行内样式,本插件改 token 即触发,无需额外代码 |
| macOS 侧栏 | 实色 | background:0 0 + vibrancy + color-mix 二次混合 |
壁纸规则不依赖任何 data-* 桌面标记,所以两端行为一致。
开发
npm test # 语法 + 392 对比度 + 672 色相扫描 + 1091 无头测试 + 清单自检
node tools/test-client.mjs # 只跑浏览器半边无头测试(本地 1091 项 / 无 Edge 时 1026 项)
python tools/prepare-art.py # 从 庄方宜素材\ 重建 art/ 与 art/wallpapers.json
python tools/solve-palette.py --verify # 只跑配色断言(调色时用)
.\tools\deploy.ps1 # 部署到 profile(PS7 下直接跑;含构建标记 + 校验)
python tools/ensure-bom.py # 编辑过 .ps1 后补回 UTF-8 BOM(PS 5.1 兼容退路)
.\tools\package.ps1 # 打发行包(ZIP + SHA256SUMS + 包内构建标记)
- 配色改动改
src/palette.js,跑npm test—— 对比度不达标会直接失败。 调色时可先用tools/solve-palette.py迭代(它跑同一组断言,改参数即可重算)。 tools/test-client.mjs用桩 ctx 跑真实client.js,覆盖注册、两条生效路径、 明暗分流、设置边界、id 冲突、卸载还原,以及双壳定位/打标/观察器。 桩 CSS 直接用真实structureStyle()产物(占位串会让「被引用变量解析不出来」 这类问题在测试里隐身);桩 DOM 也提供getComputedStyle,壁纸渲染链因此可断言。- 改
art/相关:prepare-art.py会顺带产出art/wallpapers.json(每张图的 尺寸/宽高比/fit)。竖图判据来自它 —— 想调整「哪张算竖图」,改生成器里的 阈值(现为宽高比 < 0.87),不要在插件代码里硬编码图名。 tools/deploy.ps1做三件事:先删旧目录再复制(Copy-Item -Recurse到已存在 的目录会嵌套出art\art\,导致「部署成功但跑的还是旧文件」)、打构建标记、 逐文件校验大小。- 文案内联在
client.js的DICT(zh 为准,en 同 key 集),与同 profile 的参考插件一致。
素材来源
庄方宜素材\(394 个文件 / 10.8 GB,索引见其中的 索引.md),取自
https://wiki.skland.com/endfield/detail?mainTypeId=1&subTypeId=1&gameEntryId=1132
及官方公开物料。tools/prepare-art.py 只读取、不修改源素材。
头像来自 15-头像/聊天头像.webp(156×156 透明底官方正脸),由
prepare-art.py 做圆形羽化后输出 512×512。
授权与数据边界
- 插件代码 MIT;角色形象与美术素材版权归鹰角网络(Hypergryph)所有,仅作个人非商业使用
—— 完整归属见
ASSETS-NOTICE.md。 - 零遥测、零远程脚本、不读会话库与消息正文 —— 数据边界见
PRIVACY.md。