← Back to home@yangwenjie1231

dsh-zhuang-fangyi

庄方宜主题 for DeepSeek Harness —— 4 套风格预设(配色/明度/边框/材质/排版)× 浅色深色完整适配,附官方素材壁纸、观测台(官方右栏标签页)、强调色色相自选与启动动效

Stars
0
Language
JavaScript
Created
Oct 5, 2026
Updated
Oct 7, 2026
GitHub repo

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)

预设既然是一整套视觉性格,配套的壁纸 / 纱的厚薄 / 字体 / 阅读宽度就不该让 用户自己一个个试。设置页预设行下面有一个按钮,一次把这几项配好:

预设壁纸不透明度字体阅读宽度
本体黄绿 · 明亮轻盈sakura10%无衬线标准
大招墨青金 · 厚重深沉dark22%衬线紧凑
青 · 清爽中性pool12%圆体标准
酒红 · 浓郁暖调promo26%衬线宽松

刻意不覆盖:enabled / scheme / rail(功能与外观偏好)、motion (无障碍设置 —— 被一个「换风格」按钮改掉属于越权)。 也不自动触发 —— 与「推荐壁纸只提示、不自动切换」同一原则。

组合数据由宿主算好随 /themes 下发(presetCombos):浏览器半边是自包含 bundle,不能 import src/,若在客户端再写一份就是又一份会漂移的手抄副本。

明暗分别指定预设(C13)

「浅色用青、深色用墨青金」。两格下拉默认都是跟随主预设 —— 不选就不分叉, 所以老设置文件升级后行为一字不变(默认值必须是「跟随」而不是某个具体预设, 否则升级即静默改变所有人的观感)。

实现上有一个修掉的既有半生效状态:theme/change 原先只调 syncSchemeWallpaper,不重跑 applySettings —— 切明暗时壁纸会换,但配色、 材质深度、代码高亮都不会。分档启用后必须走完整的 applySettings()。

两个容易做歪的点:

  1. 跟随系统时表达不了「浅色 A / 深色 B」:overrideTokens 只接受单一预设的 {token:{light,dark}},没有「按明暗选不同来源」的概念。所以只能在值这一层 拼:每个 token 取浅色预设的 light 与深色预设的 dark(composeSchemeOverrides)。
  2. 重入:固定明暗下 applySettings 会 theme.setTheme(),而外壳的 setTheme 会再 emit theme/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.01440.0001 – 0.0012

深色下 0.0001 = 肉眼完全不可辨。根因有两条,叠加起来就是「切预设 ≈ 只换按钮颜色」:

  1. 旧版四套预设的 chroma/chromaDark 全是 5/6,只有 hue 不同
  2. 而 hue 对大面积表面的影响被「按面积分配」刻意压到极低(那是 v3 为了 大面积不显脏而定的原则,本身是对的)

修法:预设升级为风格预设,每套带 4 个维度:

维度作用实测效果
明度基调 surfaceShift表面明度整体平移浅色极差 0.0144 → 0.0522;深色 0.0012 → 0.0034
色度性格 chroma各套不同(4/7、8/11、6/8、7/10)表面冷暖浓度可辨
边框强度 borderAlpha缩放 border-l1..l40.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说明
142state-error-primary错误态
44state-success-primary成功态
28state-warn-primary警告态
25 / 22state-warn-label / -tertiary警告文字
14label-deep-diving「深度思考」标签
10button-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 → 加构建标记以便判别

两层覆盖

  1. 19 级中性色阶 --dsw-static-neutral-bluish-* —— 只在 body{} 定义一次, 明暗两套 alias 各自引用其中不同级数。覆盖一次色阶 = 明暗双向同时生效, 并自动兜住未列举的组件。
  2. 语义 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 / sidebar2–6近中性,只留一丝冷暖暗示
中面积code / codeBanner / inlineCode7–10把代码块从底上分出来
选中态navActive / brandSoft / 气泡15–21明显带主题色,但不抢眼
小面积brand / link / focusRing58–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 交叉淡入

三条实测确认的细节,都是踩过的:

  1. 选中色/光标挂在 body[data-zf-theme](主题启用标记),不是某个装饰开关 —— 挂在 data-zf-glow(强调色微光)上会变成「关掉微光,选中色也跟着回默认」。
  2. 代码 token 色必须写在 body 行内:外壳的暗色那组声明在 body[data-ds-dark-theme]{--shiki-token-…} 上(亮色在 :root)。自定义属性按 最近祖先解析,写 html 会被暗色那条压回去 —— 两种写法都在真实引擎里量过 (见 docs/双壳适配说明.md 第十一节)。
  3. 代码 token 有对比度门禁:每条都要在代码块底色上 ≥ 4.5:1;不够就沿明度校正 (浅色压深 / 深色提亮,最多三档),仍不够才不发这一条(保留外壳默认色)。 实测 72 条全部达标,最低 4.57:1。
  4. 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_modulesapp.asar 自带
AppFrame 类名pI_x6G_*,右栏 detailsColBynINW_*,右栏 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。