lyingice/dsh-client-ui-mc-skin ↗★ 0

dsh-client-ui-mc-skin

Minecraft-styled theme for the DeepSeek Harness Web client — pixel bevels, MC GUI palette, on/off switch, persistent toggles, and an optional Minecraft AE pixel font streamed on demand by the host half. · 为 DSH Web 客户端准备的 Minecraft 风格主题:像素立体按钮、MC 调色板、开关图、可持久化的开关,以及可选的 Minecraft AE 像素字体。 适合喜欢 MC 像素视觉风格、需要可持久化主题开关的 DSH Web 用户。

패키지
dsh-client-ui-mc-skin
호환성
미검증
버전
1.2.0
라이선스
NOASSERTION
최근 업데이트
2026. 9. 17.

설치

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:lyingice/dsh-client-ui-mc-skin

dsh-client-ui-mc-skin

给 DeepSeek Harness Web 客户端用的 Minecraft 风格主题:像素立体按钮、MC GUI 调色板、on/off 开关、可持久化的开关,以及可选的 Minecraft AE 像素字体。

PNG 素材已内联进 bundle(5 张 / 2552 字节);字体不内联——16 MB 的 assets/MinecraftAE-Pixel.ttf 由本包的 Host 半边按需发货,只在开关打开时才下载。所以本包现在两半都有:lib/index.js(Host,字体路由 + 设置命名空间)+ lib/client.js(浏览器)。

1.2.0 变更:① 移除「MC 字体颜色」十六色调色板(用不到),默认文字配色改为固定的四层递进; ② 两个开关接上 Host 用户设置文档,重启后保留;③ Host 半边新增设置命名空间注册。 从 1.1.x 升级 必须重启 dsh web —— Host 半边只在进程启动时 import 一次。


安装

前提:你已经跑起来过 dsh web,也就是 ~/.dsh/profiles/web/ 存在(Windows 是 C:\Users\\.dsh\profiles\web\)。

1. 解压到你喜欢的任意目录,例如 D:\plugins\dsh-client-ui-mc-skin。

2. 装进 profile

dsh plugin --profile web add link:D:\plugins\dsh-client-ui-mc-skin

这条命令把参数转发给 profile 目录里的 pnpm,等价于在 ~/.dsh/profiles/web/ 下执行 pnpm add 。

用 link: 前缀会把 profile 的 node_modules 指向你解压的目录本身——之后 node build.mjs 重新构建,重启即生效。想装成独立副本就去掉 link:(走 file: 语义,会拷一份)。

3. 登记进 bundles —— 已自动,无需手工

本包 package.json 里声明了:

"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }

并自带 cordis.patch.yml(一行 insert)。dsh plugin add 结束时会按"已装状态"重算 dsh.profile.bundles,看到这个声明就自动把包名追加进去,所以不用再手工编辑 package.json。

如果第 2 步没带 link:/没装成功,或者你手动把包解压到别处而不是走 dsh plugin add,那就还得自己把它加进 ~/.dsh/profiles/web/package.json 的 dsh.profile.bundles。profile 的 cordis.yml 是空数组,整棵插件树是由 bundles 里每一项逐层叠加出来的——只装上而不登记,插件不会被加载。

4. 重启 dsh web。

bundle 层是启动时读取的,所以第一次装完必须重启;之后改代码才走热重载。

这次重启不只是为了 bundle 层:字体路由是 Host 半边注册的,而 Host 半边的模块只在进程启动时 import 一次。1.1.0 起本包有了 Host 半边,所以从旧版升级上来也必须重启一次,否则浏览器那边一切正常、字体开关却会显示"加载失败"。


验证装好了

重启后:

  • 侧边栏左上角的标志变成 24×24 的像素图(minecraft24x.png),右边的 MINECRAFT 也是像素字体
  • 整个界面变成 MC 深色:工作区 #313233、对话区 #6B6B6B、按钮是 #C6C6C6 立体斜角、分页是绿板
  • 界面文字变成 Minecraft AE 像素字体
  • 设置 → 通用里多出两行:MC 主题 / MC 像素字体
  • 设置 → 模型 → 点「编辑」展开的面板是黑底 + 亮边框(见下文《浅色模式漏白底》)

想确认字体路由本身通了,可以直接打这条 URL:

curl -I http://127.0.0.1:3080/dsh-mc-skin/font.ttf
# 期望 200 + content-type: font/ttf + 一个 ETag

卸载

dsh plugin --profile web remove dsh-client-ui-mc-skin

这一步会顺带把它从 bundles 里摘掉(同样是自动 reconcile)。重启生效。

或者不卸载,直接去 设置 → 通用。两个开关完全独立:

  • MC 主题 关掉 → 配色、标志图、主题样式表还原成原生;像素字体如果开着,仍然继续生效
  • MC 像素字体 关掉 → 只还原字体,配色和标志图保持 MC 皮肤
  • 两个都关掉 → `` 元素整个移除,和没装一样

分享给别人

本包没有任何构建期依赖,lib/ 是提交进仓库的成品,所以别人拿到就能装,不需要在他们机器上跑构建。

包体积主要是字体:assets/MinecraftAE-Pixel.ttf 16 MB,zip 压完大约 8 MB。不带字体也能跑(字体开关会显示"加载失败",界面用系统字体)——只要把 assets/MinecraftAE-Pixel.ttf 删掉再打包即可,别的都不用改。

方式一:发 zip

对方解压后(路径随意,比如 D:\plugins\dsh-client-ui-mc-skin):

dsh plugin --profile web add D:\plugins\dsh-client-ui-mc-skin

这里有个要提前说清楚的差别(两种写法实测行为不同):

写法pnpm 实际记录解压目录删掉/挪走后
add link:(SymbolicLink 指向解压目录)插件失效
add file:真实副本(拷进 profile)仍然可用
  • 想让对方自己改图重构建:用第一种(link:),解压目录就是生效的源,改完 node build.mjs 重启即生效。
  • 想发出去就不管了:用 add file:,装完对方可以把解压目录删掉。

两种写法都不需要联网,也都不需要对方装构建工具。装完重启 dsh web(bundle 层是启动时读的)。

方式二:GitHub

仓库根目录就是包根目录(package.json / cordis.patch.yml / lib/ 都在根上),然后:

dsh plugin --profile web add github:/

生产上建议钉到 tag 或 commit,否则别人拿到的是默认分支的浮动最新版:

dsh plugin --profile web add github:/#v1.0.0

上 GitHub 前的检查清单:

  • lib/client.js 和 lib/index.js 都必须提交,别写进 .gitignore——git 安装不会跑构建
  • assets/MinecraftAE-Pixel.ttf 也是,别被 *.ttf 之类的规则误伤(本包 .gitignore 只忽略 node_modules/ 和编辑器杂物)
  • cordis.patch.yml 在仓库根,否则 dsh 不会把它登记进 bundles
  • 不要加 prepare 脚本:pnpm 会把它当成需要审批的构建脚本(allowBuilds)拦下来。本包只有 build / prepack,git 安装时都不会触发
  • package.json 里补上 repository / homepage / bugs(本包目前没有填)
  • 发版时改 version 并打 tag

GitHub 单文件上限是 100 MB,16 MB 的字体没问题;但注意 git 历史是不可变的,以后换字体会让仓库持续变大。

三个坑

  • npm registry 装不了。 本包没有发布到 npm,dsh plugin add dsh-client-ui-mc-skin 这种裸包名会失败。只能用本地路径或 GitHub。
  • 依赖 DSH 内部结构。 槽位名、主题 token 名、CSS Module 局部类名都是跟着当前 DSH 版本走的,见下文《已知限制》。
  • 升级到 1.1.0 要重启。 1.1.0 新增了 Host 半边(字体路由),而 Host 模块只在进程启动时 import。

它改了什么

分五块,都通过 cordis 的标准接口,停止时会干净回滚:

动作机制
覆盖主题颜色theme.overrideTokens(包名, tokens),返回 disposer
注入样式表document.head 插一个带 data-plugin-css 的 ``,卸载时移除
三个槽位slots.register({ name, priority: -1 }, 组件) 到 sidebar.brand.mark / sidebar.brand.name / conversation.hero.brand.mark
两行设置slots.register 到 settings.general.item(列表槽位,order 13 / 14)
字体路由Host 半边 webServer.register({ kind: 'exact', path: '/dsh-mc-skin/font.ttf', ... })
开关持久化Host 半边 settings.register('ui-mc-skin', schema);客户端 ctx.settingsScope.bind({ namespace }) 读写

开关持久化

两个开关(主题、像素字体)现在存在 Host 的用户设置文档里 —— 也就是 $DSH_HOME/settings.yaml(Windows 是 C:\Users\\.dsh\settings.yaml)。 关掉主题再重启 dsh web,它仍然是关着的。

写进去的形状就是普通一节 YAML:

ui-mc-skin:
  themeEnabled: false
  pixelFontEnabled: true

两边各一半,字段名必须对齐:

位置作用
Hostsrc/index.jssettings.register('ui-mc-skin', schema) 声明命名空间
Clientsrc/client.jsctx.settingsScope.bind({ namespace: 'ui-mc-skin' }) 读写同一节

常量在两边各有一份(SETTINGS_NAMESPACE / SETTINGS_FIELD_*), 改一处必须改另一处 —— 这是两个 bundle 共享不了模块的老问题, 和 FONT_ROUTE 一样。

几个刻意的取舍:

  • settingsScope 是可选依赖。 拿不到时(连接处于 memory 模式、或 ui-settings 没装配)自动退化成纯内存行为:开关照常能用,只是不跨会话保留。插件不会因为 持久化能力缺席而变砖。
  • 写失败不回滚界面。 设置文档只读、断连、revision 冲突都只记一条日志, 本次会话内开关仍按用户点的样子生效,下次启动回到上一次成功落盘的值。 让一次写入失败把用户刚点的那一下弹回去,比不持久化更糟。
  • 采纳时不回写。 启动读到 Host 的值只用来 set 本地 store,不再写回去, 否则会自己触发一次无意义的写入 —— 和 ui-theme 的 adopt() 一致。
  • 先采纳再渲染。 apply() 里 bind + 采纳排在 applySkin() 之前, 否则会出现"先按默认值渲染一帧、再跳到存的值"的闪烁。

schemastery 已经不再使用 —— 见下节。本包现在零运行时依赖。

⚠ Host 半边不要 import 任何外部包(link: 装法的致命细节)

Host 半边曾经 import z from '@deepseek-ai/schemastery'。在 web profile 上能跑, 在桌面端(tauri profile)上直接 ERR_MODULE_NOT_FOUND —— 整个 Host 半边加载失败, 字体路由和设置命名空间一起消失,而且 Host 侧只报一行模块错误,界面看不出所以然。

原因不是"没装",而是Node 解析软链时用的是真实路径:

profile/node_modules/dsh-client-ui-mc-skin  →  软链  →  D:\12501\Documents\deepseek\dsh-client-ui-mc-skin
                                                       └─ Node 从这里向上找 node_modules
                                                          只到 D:\12501\Documents\deepseek\node_modules
                                                          ← 够不到 dsh 自带的依赖树

两个 profile 的差异就在这里:

node_modules/@deepseek-ai/结果
web profile摊着 schemastery、cosmokit解析成功(侥幸)
tauri / 桌面端 profile空目录,依赖全在桌面端自带的 dsh 树里解析失败

web profile 能跑纯粹是因为 pnpm 恰好把 schemastery 提到了那一层。换个 profile、 换个 pnpm 版本、换个 nodeLinker 设置,就会静默炸掉。

所以本包的 Host 半边只 import Node 内置模块(node:fs/promises、node:url)。 需要 dsh 的能力(webServer、settings)一律通过 ctx 拿,那是运行时的服务查找, 不走 Node 的模块解析。

如果你要给自己写的插件加 Host 半边,且打算用 link: 分发: 先在一个和开发环境不同的 profile 上装一遍再发布。 判据很简单 —— lib/index.js 顶部的 import 只能是 node:*。

为什么要自己写 schema

dsh-settings 对 schema 的用法只有两处(见 dsh-settings/lib/index.js):

// resolve():把「composition base + 用户层」合并后喂进来,取回补过默认值的对象
const value = schema(mergeLayers(base, section))
// describe():给配置界面看的 JSON Schema
registration.schema.toJSON()

所以只要「可调用 + 有 toJSON」就够,不需要完整 schemastery。 src/index.js 里那 30 行自包含实现满足这个契约,且对脏数据安全:

输入结果
{}{themeEnabled:true, pixelFontEnabled:true}
{themeEnabled:false}{themeEnabled:false, pixelFontEnabled:true}
{themeEnabled:'yes', pixelFontEnabled:0}两个都回落默认(不做类型强转)
null两个都回落默认

坏字段不会让整个命名空间解析失败 —— 那会导致插件加载不出来。

选择器核对:类名是猜的,token 是真的

主题里那批 [class*="_xxx"] 选择器当初是照截图猜的。拿 deepseek-harness-master 源码核对后(脚本 .dsh-tools/verify-selectors.mjs),58 条里有 37 条是死规则—— 类名在源码里根本不存在,规则一直静默失效,不报错。

原因是这些面板的类名跟猜的完全不是一个形状:

面板真实实现真实类名当初猜的
代码块ui-primitives/markdown/CodeBlock.module.css.block .body .banner_codeViewer _codeBlock
文件差异ui-primitives/DiffBlock.module.css.block .line .add .del_diffHeader _lineAdd _diffDel
读取面板ui-primitives/ReadBlock.module.css.block .gutter .line_codeViewer _lineNumber
终端输出ui-primitives/TerminalBlock.module.css.block .output .prompt_terminal _logView

都是短名(.block、.line、.add),根本没有 _diff* 这种长前缀。 所以那 8 条规则从来没有命中过任何东西。

修法是不再猜类名,改走 token。这四个面板的底色只来自一个 token:

CodeBlock.module.css   :16  background: var(--dsl-code-block-background)
                            └─ --dsl-code-block-background: var(--dsw-alias-markdown-code-block)
DiffBlock.module.css   :8   background: var(--dsw-alias-markdown-code-block)
ReadBlock.module.css   :11  background: var(--dsw-alias-markdown-code-block)
TerminalBlock.module.css:21 background: var(--dsw-alias-markdown-code-block)

--dsw-alias-markdown-code-block 浅色模式 = bluish-50 = rgb(249,250,251)(近白), 文字早被主题染成浅色 ⇒ 白底白字。现在把这一对 token 钉成 MC 深色即可, 一处生效、四个面板同时跟随,不再依赖任何类名。

同时修掉的还有:

  • _tab_ / _tabActive_ —— 多写了个尾下划线。CSS Module 产出的是 _tab_, 尾字符是 hash 首字符而不是下划线,所以这两条也是死的。去掉尾下划线即可。
  • [class*="Close"] —— CSS Module 不会把类名首字母大写;真实类名是 ui-primitives/Modal.module.css 的 .close(全小写),编译成 _close_。
  • --dsw-alias-bg-layer-4 —— design-platform.css 里只有 layer-1..3, 没有 layer-4,覆盖它等于写一个没人读的变量。

改完复跑核对脚本:58 条 → 19 条,死规则 37 → 0(剩下 2 处是记录历史的注释文字); 24 个被覆盖的 token 全部真实存在。

保留的 [class*="_rowCard"] > [class*="_editor"] 是有效的,别误删: rowCard 在 ui-settings-models/ModelsSection.module.css:55 确实存在, 只是它以 styles['rowCard'] 下标形式引用。核对脚本必须同时认 styles.foo 和 styles['foo'] 两种写法,否则会把这条误判成死规则 (第一版脚本就犯了这个错)。

为什么品牌槽位必须写 priority: -1

sidebar.brand.mark / sidebar.brand.name 是 single 槽位:同一优先级只允许一条注册, 官方品牌插件(dsh-client-ui-brand-official)已经占在 priority: 0 上。规则是最低者渲染 (lowest renders),所以要么用更低的优先级遮蔽它,要么就直接抛错:

Error: single slot "sidebar.brand.mark" already has a registration at priority 0
       (registered by Ba) — register at a different priority to shadow it (lowest renders)

这个错误只出现在浏览器控制台,Host 侧毫无反应(插件照常加载、主题色照常生效), 表现为"主题变了但左上角还是鲸鱼"。删掉 priority: -1 就会复现。

为什么字体走 Host 半边,而不是像 PNG 那样内联

因为客户端插件没有静态资源路由——dsh-client-modules 只发 /plugins//client.js(和它的 .map)。 要把字体送进浏览器只有两条路:

方案代价
base64 内联进 lib/client.js产物从 30 KB 变成 22 MB,每次打开界面都要下载并解析这 22 MB
Host 半边注册一条路由字体留在磁盘上,只在开关打开时才下载,之后走 ETag 复验

所以选了后者。实测数据(loopback,Chrome headless):

项目值
字体文件16 162 252 字节(63 449 个字形,覆盖整个 BMP,含中日韩)
首次传输(走了 webServer 的 gzip)3 853 326 字节,约 0.43 s
第二次打开(If-None-Match)304,0 字节
lib/client.js 产物35 936 字节(和加字体前同一量级)

@font-face 的 src 指向 /dsh-mc-skin/font.ttf,这个常量在 src/index.js 和 src/client.js 里各有一份(两个 bundle 共享不了模块),改一处必须改另一处。

配套的 CSS 细节:

  • font-synthesis-weight:none —— 像素字体不能合成粗体,伪粗体会把 1px 笔画糊成 2px
  • html body,html body button,...{ font-family:"Minecraft AE",... !important } —— 这是给 那些自己不声明 font-family 的元素用的(下面这条说明它为什么不够)

行内 font-family 盖不住,加 !important 也没用

品牌名(MINECRAFT)一开始漏掉了像素字体,原因值得单独记一笔:

BrandName 这个 span 自带行内 font-family:"Courier New",...。样式表里那条 html body{font-family:"Minecraft AE" !important} 的主体是 body,span 只是 继承了 body 的值 —— 而继承是层叠里的最后一名,永远输给元素自己的任何声明, 哪怕那条声明不带 !important,也哪怕继承过来的那条带 !important。 两边根本不是同一条声明在竞争,所以堆多少个 !important 都没用。

要覆盖它只有两条路:让规则直接匹配到这个 span(如 html body span{...!important}), 或者让组件自己知道该用哪套字体。这里选了后者 —— BrandName 订阅字体开关,自己算 font stack。

字体要覆写变量,不是给元素设字体

同一个坑后来以更大的规模又踩了一次:品牌名修好之后,输入框里打的字、对话里的字 仍然不是像素字体。原因还是"元素自己的声明赢过继承",但这次没法靠匹配元素解决:

/* 应用自己的规则 */
body       {font-family:var(--dsw-font-family, -apple-system, …)}
.xxx_input {font-family:var(--dsw-font-family)}   /* ← 输入区,一个 contenteditable 的 div */

.xxx_input 是元素自己的声明,而 html body input{font-family:…!important} 匹配的是 html 和 body,对这个 div 只是继承 —— 继承永远输,!important 也救不了。 更要命的是输入区根本不是 /,是 contenteditable 的 (打字后里面才生成 ),所以连 input 这个类型选择器都命中不了。

正确的做法是覆写变量本身,让应用自己那条声明去解析:

html:root body{--dsw-font-family:"Minecraft AE",… !important;
               --ds-font-family-code:"Minecraft AE",… !important;}
  • --dsw-font-family 被整套排版 token 引用(--dsw-font-base-16、--dsw-font-markdown-*、 --dsw-font-s-14 …共 40 多处),所以这一条就能把界面文字整体换掉。
  • --ds-font-family-code 是行内代码和代码块单独走的那条,不改的话代码区仍是 SF Mono。
  • 选择器用 html:root body 而不是 body:应用把 token 定义在 body 上, 而 html[data-ds-dark-theme] body 这类选择器比 html body 更具体,得抬一级才稳。

原来的 html body button,input,…{font-family:…} 保留成兜底(万一某个组件把字体写成字面量 而不是 var()),但真正起作用的是上面这条变量覆写。

透明度做层次,在浅底上会糊

文字层次原来是用半透明白递降做的:primary #FFFFFF / secondary .74 / tertiary .56 / caption .44。在侧边栏 #313233 上勉强能看,但对话区的底是 #6B6B6B 这种中灰, 半透明白压上去会直接糊掉:

层级侧边栏 #313233对话区 #6B6B6B
secondary .748.5:13.5:1
tertiary .565.2:12.4:1
caption .443.6:11.8:1

数字上"及格"不等于看着清楚 —— 用户报的「黑背景地方字体变灰看不清」就是这个。

1.2.0 起这套值按"最差底色"定。对比度算的是相对亮度,所以瓶颈不是最暗的 侧边栏,而是最亮的对话区 —— 基准取对话区,侧边栏自然满足。主色也从纯白 #FFFFFF 降到 #F2F2F2:纯白压在中灰底上会过曝,长文阅读发涩。

层级值对话区 #6B6B6B侧边栏 #313233
primary#F2F2F27.4:115.1:1
secondary.905.4:111.0:1
tertiary.804.1:18.4:1
caption.723.3:16.8:1

层级之间靠 8~10 个百分点的透明度差拉开,而不是靠"调暗到快看不见"。 层次感要靠字号和字重。

浅色模式漏白底

DSH 的设计 token 分两套:body{...} 是浅色值,body[data-ds-dark-theme]{...} 是深色值。 本主题用 theme.overrideTokens 把值写成 body 的行内样式,所以两种模式下都是同一个值 —— 但前提是这个 token 得在覆盖表里。

一旦漏掉,浅色模式下那个 token 就还是近白色,而文字早被主题染成浅色 ⇒ 白底白字。 设置 → 模型 →「编辑」展开的面板踩的就是这个:组件写的是 .editor{background:var(--dsw-alias-bg-module-platform)},而 bg-module-platform 浅色值是 #f9fafb。

排查办法:把 DSH 的设计表整个解出来,逐 token 比对浅色值和深色值——凡是"浅色近白、 深色才深"的面板 token 就是可疑的。按用户实际报过的位置,目前钉了 10 个:

token浅色时是现在用户在哪看到的
--dsw-alias-bg-module-platform#f9fafb#1B1C1D(黑底)设置 → 模型 →「编辑」展开的面板
--dsw-specific-tip#f5f6f7#353638任务面板(展开的待办列表)
--dsw-alias-bg-multi-select#f9fafb#2A2B2C
--dsw-alias-bg-mask-drop#ffffffb3#000000B3
--dsw-alias-button-elevated-fill#fff#3A3B3C
--dsw-alias-button-floating-fill#fff#3A3B3C
--dsw-alias-button-ghost-active-fill#ebeef2#3A3B3C
--dsw-alias-interactive-bg-hover-solid#f1f3f5#3A3B3C
--dsw-alias-scrollbar-bg-l1#e5e5e5#313233
--dsw-alias-scrollbar-bg-l2#e5e5e5#474848

--dsw-specific-tip 的深色值是 #353638,正好等于本主题的 --dsw-alias-bg-layer-3, 所以这不是自己配色,是把这个面钉回设计系统里它本该有的深色。

⚠ 范围纪律(踩过的坑)。 上面这个"逐 token 比对"的办法一旦用过头就会出事: 全套设计表里"浅色近白、深色才深"的 token 有 44 个。曾经照单全钉了 29 个, 结果把菜单、用户气泡、侧边栏选中态等用户根本没提过的颜色一起重刷了。

判定一个面要不要改,得同时满足三条:

  1. 它在浅色模式下确实近白;
  2. 它承载文字(纯装饰的底色改了也白改);
  3. 用户明确报过这个位置。

只满足第 1 条的,一律不动。枚举工具留在 .dsh-tools/token-leak-truth.mjs。

顺带一个反例:token 浅色近白 ≠ 就该深色化。有些 token 的语义是"反色文字" (--dsw-alias-label-primary-foreground 配 button-primary-fill、 --dsw-alias-label-primary-inverted 配 button-contrast-fill),它们的浅色值就是 #fff, 深色化会让"深底配深字",正好反过来。

顺带扫了前端自带的 CSS:整个 dist 里只有一个浅色字面量 (body{background:var(--dsw-alias-bg-base,#fff)},而 bg-base 本来就被覆盖), 所以这类漏底只会来自 alias token,不会来自字面量。

那块面板另外加了一条规则补亮边框(就是"白框黑底"):

[class*="_rowCard"] > [class*="_editor"]{border:1px solid #C6C6C6 !important;}

限定在 _rowCard 里那一层是有原因的:组件在 addCard / setupCard 里复用了同一个 _editor 类,但故意写成 background:0 0。不加限定的话,那些地方也会被套上一圈框,出现双重边框。

调色板来自一组 16×16 的模板图(button_template / page_template / text_template / background_template_left / background_template_right / 滑轮模板),全部按像素复刻成 CSS。


换字体

字体放在 assets/MinecraftAE-Pixel.ttf,直接替换这个文件即可,不用重新构建——Host 半边按 大小 + mtime 算 ETag,换了文件下次请求就是新的。

要注意的是 CSS 里写的族名是 "Minecraft AE"(src/client.js 的 FONT_FAMILY), 所以换的字体要么内部