lyingice/dsh-client-ui-mc-skin ↗★ 0
dsh-client-ui-mc-skin
Minecraft 像素风格的 Web 客户端主题 适合喜欢 MC 像素视觉风格、需要可持久化主题开关的 DSH Web 用户。
安裝
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:lyingice/dsh-client-ui-mc-skin說明文件
閱讀完整 README ↗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
两边各一半,字段名必须对齐:
| 位置 | 作用 | |
|---|---|---|
| Host | src/index.js | settings.register('ui-mc-skin', schema) 声明命名空间 |
| Client | src/client.js | ctx.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 笔画糊成 2pxhtml 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 .74 | 8.5:1 | 3.5:1 |
tertiary .56 | 5.2:1 | 2.4:1 |
caption .44 | 3.6:1 | 1.8:1 |
数字上"及格"不等于看着清楚 —— 用户报的「黑背景地方字体变灰看不清」就是这个。
1.2.0 起这套值按"最差底色"定。对比度算的是相对亮度,所以瓶颈不是最暗的
侧边栏,而是最亮的对话区 —— 基准取对话区,侧边栏自然满足。主色也从纯白
#FFFFFF 降到 #F2F2F2:纯白压在中灰底上会过曝,长文阅读发涩。
| 层级 | 值 | 对话区 #6B6B6B | 侧边栏 #313233 |
|---|---|---|---|
primary | #F2F2F2 | 7.4:1 | 15.1:1 |
secondary | .90 | 5.4:1 | 11.0:1 |
tertiary | .80 | 4.1:1 | 8.4:1 |
caption | .72 | 3.3:1 | 6.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 条的,一律不动。枚举工具留在
.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),
所以换的字体要么内部