# dsh-font
[English](README.md) | 中文
## dsh 起不来怎么办
你正在看的报错就是这三行 ——
Failed to load plugins
web boot: 3 entries did not activate
@citisen/dsh-font: pending (waiting for service: settingsScope)
「启用的条目却始终不激活」在 dsh 里算**启动失败**而不是警告:它会拒绝完成启动,而不是少个插件
照常起来。三条出路,从快到慢,**三条都在 dsh 起不来的情况下可用**。本插件在 profile 里的行是
`id: ui-font`,对应 `name: '@citisen/dsh-font'`。
**1. 禁用它 —— 在 profile 自己的补丁层里加一条**
(`$DSH_HOME/profiles/web/cordis.patch.yml`,这一层在所有 bundle 层之后应用):
- id: ui-font
disabled: true
不用敲命令、不用联网、不用装东西;删掉这两行它就回来了。`dsh --profile web --dump-config`
会打印组装后的树 —— 每个行的 id 和包名,不管它属于谁 —— 补丁生效时这一行会带
`disabled: true`;它不加载任何插件,所以 dsh 起不来时也能用。
**2. 只影响这一次启动,什么都不改** —— 把同样两行写进你自己的文件,当叠加层传进去:
dsh --profile web --patch ./no-font.yml web
**3. 卸载它** —— 一条命令同时摘掉依赖和 bundle 层(`dsh.profile.bundles` 会按已安装状态自动
对齐)。它只是转发给 profile 目录里的 pnpm,不组装 profile,所以 dsh 起不来时也能跑:
dsh plugin --profile web remove @citisen/dsh-font
需要 `PATH` 上有 `pnpm`。没有的话,就手工从 `$DSH_HOME/profiles/web/package.json` 的
`dsh.profile.bundles`(以及对应的 `dependencies`)里删掉包名。
**或者先要一个能用的 dsh**:用官方模板起一个干净的 profile,它不带你装的任何 bundle:
dsh --profile rescue --from-default-profile web
## 兼容性
本构建在两条 dsh 线上都能跑:**0.1.5-rc.x** 系列(也就是当前的 `latest` 和 `next`),以及
**0.1.7-alpha.1** —— 后者的设置模型它同样会说。两条线上,这份插件的 section 都叫同一个名字
`ui-font`:0.1.7 线按 Loader 条目 id 定位设置,而本 bundle 的补丁正是以这个名字插入条目;
0.1.5 线则把同一个名字注册为设置命名空间。
| 它读什么 | 0.1.5-rc.x | 0.1.7-alpha.1 |
| --- | --- | --- |
| 那份持久 section | `settingsScope.bind({ namespace: 'ui-font' })` | `configForms.get('ui-font')`,读条目自己的 `Config` |
| 宿主契约 | `settings.register('ui-font', schema)` | 导出的 `Config`,字段标记为 `.volatile()` |
两者都是**可选绑定**,所以两条服务都不提供的 dsh 也照样激活:插件不会一直 `pending`(那会直接
阻断启动),也不会在激活时抛错。它按出厂默认值工作,而你在设置行上第一次动开关时,它会告诉你为
什么存不下来。`0.2.2` 及更早的版本要求 0.1.5 那条服务,所以在 `0.1.7-alpha.1`
上被报成「未激活」的条目;`0.2.3` 两条线的设置都会说,`0.2.4` 起还不再借用设计系统的图标。
最后这条值得单独说清楚,因为它正是移植设置时最容易藏住的故障。设计系统的图标导出按笔画粗细命名,
而名字在两条 dsh 线之间改过(0.1.5-rc.x 是 `IconChevronDownOutline14`,0.1.7 是
`IconChevronDownOutlineRegular`)。名字没了的图标不会渲染成"缺一张图",而是以 `undefined` 顶替
组件类型,于是 React 在渲染这一行时抛 "Element type is invalid",**整个字体行直接从设置页面上
消失**,只留一条控制台报错。这一行现在一个图标都不借、也不自己画(只有文字框、数字框和一个
按钮),也正因如此,它的客户端 bundle 只向 shell 要 `react` 和
`@deepseek-ai/dsh-client-store`。
### `0.3.1`:会话正文字号这一项去掉了
对话正文、标题与表格的字号重新完全归 dsh 自带的 *设置 → 外观 → 字号大小*:`0.3.1` 起本插件
不再写 `--dsh-content-font-size`,也不再重新推导那条 Markdown 阶梯。设置行里因此少了一个字段,
`contentFontSize` 也从 schema 里移除 —— 所以如果你在旧版本里设过它,请把这一行从
`$DSH_HOME/settings.yaml` 或 profile 的 `cordis.patch.yml` 里删掉:dsh 会用条目自己的 schema
校验这个 section,多一个不认识的键就会整段拒绝。
### 0.1.7 改名时丢掉的设置
dsh 0.1.7 会把旧的 `$DSH_HOME/settings.yaml` 导入一次 —— 每个 section 写进同名条目 —— 并把文件
改名为 `settings.yaml.imported`。在 `0.2.4` 之前,本插件的条目叫 `font`,于是
`ui-font` 这个 section 无处可去,只留在改名后的文件里。现在名字对上了,dsh 自己的导入就能把
这些值放回去:
1. 把 `$DSH_HOME/settings.yaml.imported` **复制**成 `$DSH_HOME/settings.yaml`(是复制不是移动 ——
导入跑之前,那个文件是唯一的记录),并且只保留条目现在仍然声明的键:dsh 会用条目自己的 schema
校验这个 section,只要有一个不认识的键就整段拒绝,所以上面表格没列出的键都要删掉。
2. 用你平时用的 profile 启动一次 dsh 0.1.7。凡是现在有条目对应的 section —— 包括
`ui-font` —— 都会写进那个 profile 的 Cordis patch。
3. dsh 仍然不认的 section 会被报告出来,并继续留在 `settings.yaml.imported` 里;所以**在你把需要
的东西取出来之前,别删那个文件**。
在设置里自定义 DeepSeek Harness **Web 界面字体**:界面字体与代码字体两条字体栈(各带自己的字重),以及两条互相独立的字号轴(界面缩放与代码字号)。
这是一个第三方 [dsh](https://github.com/deepseek-ai/deepseek-harness) profile bundle(插件包)。它是一个「双面」包:Node 半边负责持久化的设置命名空间与首屏绘制前的样式注入,浏览器半边负责实际绘制并注册设置项。
需要 dsh `0.1.5-rc.1` 或更新的 `0.1.5-rc.x`;它用到 `settings.general.item` 插槽、`settingsScope` 服务以及 `ctx.theme.overrideTokens`,这些在 `latest` 与 `next` 两条发布通道里都已具备。
## 功能
在 *设置 → 通用* 中新增一个 **字体** 行:两个字体查询字段,每个字段在输入框右侧紧跟一个自己的字号数字框。
| 字段 | 作用 | 取值 |
| --- | --- | --- |
| 界面字体 | `--dsw-font-family`,所有非代码的界面文字 | 字体查询:字体族,可带字重 |
| 界面字号 | `--dsw-font-family` 那侧的数字框:按比例缩放所有写死的界面文字尺寸 | 75 – 150(百分比),整数即可 |
| 代码字体 | `--ds-font-family-code`,代码块、行内代码、等宽文本 | 字体查询 |
| 代码字号 | `--ds-font-family-code` 那侧的数字框:代码块与行内代码的字号 | 10 – 20 px |
字号不是滑块,而是一个原生数字框:可以直接输入,也可以用上下方向键或浏览器自带的微调箭头;单位(`%` / `px`)就写在框里。只有**完整且在范围内**的数值才会写入——打到一半的 `1`(本来要打 `125`)不是一条指令,因此既不会写坏设置,也不会被重新渲染掉;而离开这个框时,框里会恢复成实际保存的值,被拒绝的数字不会留在屏幕上装作已经生效。
**会话正文字号不在这里。** 对话正文、标题与表格的字号一直由 dsh 自带的 *设置 → 外观 → 字号大小*(`ui-theme` 命名空间)负责;`0.3.1` 去掉了这一行原本提供的第二个入口,本插件也不再写 `--dsh-content-font-size`。
两个字体框都是**一门小型字体查询语言的编辑器**,而不是普通输入框:整条字体栈就是一段可以逐字编辑的文本,带语法高亮、自动换行,以及一份来自本机实际字体的补全列表。字重写在查询**里面**、紧挨着它所属的字体族,所以换字体和换字重是同一处的同一次编辑:
```
Geist Mono medium, "Zhuque Fangsong (technical preview)", monospace
└─────┬────┘ └──┬─┘
字体族 字重
```
- **顺序就是文本的顺序。** 浏览器能解析到的第一个字体族生效,排序靠编辑文本完成:剪切粘贴,或把光标放进某个词条按 Alt+↑/↓。没有需要拖动的胶囊了。
- **这段文字归你。** 插件不会改写它:不加引号、不重排、不整理,也不会把清空的框重新填满。新字体就落在光标处——因为你是在那里打的字;把某个字体放到最前面,要么自己敲 `Inter, `,要么在词条开头从补全列表里选。
- **列表认识你机器上的字体。** 它补全字体族、通用族,以及某个字体族**实际拥有**的字重(从它的字面读取)。本机字体读取不可用或被拒绝时,会退回"探测到的常用字体"名单,并在行上方说明一次;任何名字始终可以手写,目录从不自称完整。
- **输入框永远是同一个等宽字体。** 高亮层和真正的 textarea 共用一个盒子,而 textarea 没法给不同的字符串设不同字体,所以这个框统一使用一套系统等宽字体、字重固定 400、并关闭连字与字距调整——无论查询里写的是什么字体。带连字的字体(Fira Code 的 `->`)会在高亮层画成一个字形、在输入框里画成两个,合成字重也会两边不一致。框下那行读数用的是这条轴自己的字体,所以你选的字体在那里仍然看得见。
- **设置跟着每一次按键走。** 文字归编辑器自己所有,所以没有"提交"这一步:查询边打边解析、边写入,框下的读数也随之更新。Tab(或点击)才会接受高亮的那条补全——那是唯一会替换文本的情况,因为"选中一条建议"本身就是这个意思。Enter 同样会接受高亮的补全,除此之外绝不改动文本。
- **写错只标出来,不改。** 本机没有的字体族、字体族没有的字面、没闭合的引号、多出来的词,都会在框下分别以警告色或错误色标出,文本保持你写的样子。空输入框**什么都不写**并说明这一点:已保存的字体栈仍在生效,要回到出厂值按「恢复默认」。
所有取值都通过宿主设置文档持久化(`$DSH_HOME/settings.yaml`,命名空间 `ui-font`),因此重启后依然生效,并且对指向同一台宿主的所有浏览器共享。
*界面字号* 与 *代码字号* 是两条独立的轴:放大界面可以让周围的控件更好读,而消息正文一个字都不动;调大代码字号也不会把对话正文顶开——会话正文只认 dsh 自带的那条设置。
### 如何选字重:`Geist Mono medium`
CSS 没法把字重写进 `font-family` —— `font-family: "Geist Mono" 500, monospace` 本身就是非法声明,整条字体栈会被丢弃。所以这门查询语言把字重写成一个词,紧跟在它所属的字体族后面,插件再把这一对还原成真正生效的两条声明:
```
Geist Mono medium, monospace → font-family: "Geist Mono", monospace
→ font-weight: 500
```
`Thin`、`ExtraLight`、`Light`、`Book`、`Regular`、`Medium`、`SemiBold`、`Bold`、`ExtraBold`、`Black` 以及常见别名(`Hairline`、`DemiBold`、`UltraBold` 等)都能识别,大小写不敏感。字重属于整条轴而不是某一个字体族,所以写在哪里都算数,但只算一次;规范形式把它写在第一个字体族(也就是生效的那个)后面。
**出厂字重是隐含的**:某条轴停在出厂的 400 时,文本里不会出现任何字重词——每个字体族后面都跟一个 `regular` 是谁也没要求的噪音,而对界面轴来说 400 本来就等于"不做覆盖"。只有真正选了字重,这个词才会出现;把它删掉就回到 400。这条轴当前到底是多少,永远写在输入框下面那一行(`界面字重: 常规 400(出厂值,未覆盖)`),所以省掉那个词并没有藏起任何信息。
字重是一个封闭列表而不是任意数字:所选字体族没有的字重会被浏览器**合成**(伪粗体),把伪粗体当成正常选项提供,比不提供更糟。在能读到字面的机器上(Local Font Access API 会报告每个字面),补全列表只给**这个字体族真正拥有**的字重,缺哪个会在框下点出来:
> Geist Mono 在本机没有 700 这个字面(bold),浏览器会合成
把字重词删掉不会悄悄重置任何东西:这条轴保持它被设成的值,输入框下面那行会写出来;等下次行组件按存储值重建时,那个词也会回来。要回到出厂字重,从列表里选它那一档(`常规 400`)——选中是一条指令,所以即使它的文本只是字体族名,也会把值设成 400。
有一处刻意的例外:如果你输入的内容本身就是本机上的字体族,末尾那个词会被当成名字的一部分。`Book Antiqua`、`Franklin Gothic Medium` 都是真实存在的字体族,把它们当成"字体族 + 字重"处理会静默改错。代价是:真正两者皆是的名字(真有一个字体族叫 `Geist Mono Medium`)也会被当作名字。判断依据就是补全列表给出的那份字体目录。想明确表达"这就是名字",加引号即可:`"Book Antiqua"` 永远是名字本身。
### 界面字重是可选覆盖
代码区域没有需要保留的字重层级,所以它的字重就是查询里写的那个数。界面则只有一条规则:
```css
/* --dsh-font-ui-weight: 500 */
html body { font-weight: 500 }
```
会跟着变的是**继承**字重的那部分文字;设计系统自己写死字重的东西——标题、表头、按钮,以及用 `font:` 简写整体声明过的对话正文——不受影响,因为它们自己的规则仍然胜出。对话正文的字号与行高由 dsh 自带的字号设置驱动,本插件不再覆盖那条 Markdown 阶梯,所以界面字重也不会去改写它。出厂的 400 **不产生任何规则**,所以未改动的安装画出来的就是设计系统原本的样子。
### 这门查询语言
```
query := entry ("," entry)*
entry := family | weight
family := '"' … '"' | "'" … "'" | word (space word)*
weight := thin | extralight | light | book | regular | roman | medium |
demibold | semibold | bold | extrabold | black | heavy | …
```
语法就是 CSS `font-family` 列表,加上列表本身装不下的那一样东西。只有**未加引号**词条的**最后一个**词可能是字重,而且整个词条本身不是已收录的字体族时才成立。单独一个 `medium` 也表示"只设字重、不改字体族"。引号里的逗号不是分隔符,所以 `"Foo, Bar"` 是一个字体族。
解析器刻意宽容——这段文字是人手敲的,不是生成的——凡是放不下的东西都按原样保留并在框下报告,而不是悄悄丢掉:没闭合的引号、引号后面多出来的词、写了两次的字重、本机目录里没有的字体族、这个字体族没有的字面。框下同时会写出当前**生效**的字体族,也就是浏览器真正能用上的第一个。
## 安装
```sh
dsh plugin --profile web add @citisen/dsh-font
```
直接从 GitHub 安装(同一个包,不走 registry):
```sh
dsh plugin --profile web add github:citisen/dsh-font
```
然后重启 Web 界面:
```sh
dsh --profile web
```
`dsh plugin` 会在 profile 目录里转发给 pnpm,然后对 `dsh.profile.bundles` 做一次对账:由于本包声明了 `dsh.bundle`,安装时会自动把它追加为一个 profile 层,无需手工修改 `cordis.patch.yml`。
这两条路径都在全新 profile 上实测过:这一行会进入最终 entry 列表,浏览器 roster 也能解析到客户端 bundle。装完后可以用 `node scripts/verify-profile.mjs ` 自己确认。
### 从本地目录安装
在 Windows 上,如果 profile 和代码目录位于**不同盘符**,`dsh plugin --profile web add <路径>` 不可靠——pnpm 会把跨盘符的目录链接解析成一个不存在的路径,随后对账会认为该包没有声明 `dsh.bundle`,于是不把它写进 `bundles`。这时请自己建立链接:
```sh
cd "$DSH_HOME/profiles/web"
pnpm add "D:/path/to/dsh-font" # 写入依赖
# pnpm 建立的链接指向 /D:/path/to/dsh-font,该路径不存在,需要修复:
cmd /c rmdir node_modules\dsh-font
cmd /c mklink /J node_modules\dsh-font D:\path\to\dsh-font
# 再手工把 "dsh-font" 加进 package.json 的 dsh.profile.bundles
```
用 `node scripts/verify-profile.mjs` 校验结果;只要这一行没进最终的 entry 列表,它就会直接报错。
## 实现原理
修改代码前请先读这一节;两个半边解决的是不同的问题,这个划分是有意的。
### 为什么字体族走 theme 服务
设计系统只在 `:root` 声明了这两个字体变量:
```css
:root{--dsw-font-family:…;--ds-font-family-code:…}
```
界面上几乎所有排版 token 都经由 `var(--dsw-font-family)` 解析(`--dsw-font-base-16`、`--dsw-font-xs-13`、整条 Markdown 字号阶梯……),shell 自己的 `body` 规则也读取它,所以覆盖这两个变量就等于重新指向整个界面。
但最直观的写法——在 `documentElement` 上设自定义属性——活不下来。`ui-layout` 的 `ThemePresenter` 掌管 `document.body.style`,每次主题变化时它会把**所有不是自己写的自定义属性删掉**,再重新写入当前 token 集合。外部设置的属性会在用户第一次切换浅色/深色时消失。
所以浏览器半边把字体叠加到 theme 上:
```js
ctx.theme.overrideTokens('dsh-font', {
'--dsw-font-family': { light: uiFontFamily, dark: uiFontFamily },
'--ds-font-family-code': { light: codeFontFamily, dark: codeFontFamily },
})
```
这个覆盖层会被折进当前快照,presenter 随后会把它写出去,并在每次调色板变化时继续写。`{ light, dark }` 这一对是强制的:`overrideTokens` 对裸字符串会抛出带解释的错误,因为单一取值在另一套调色板下会不可读。
### 为什么字号走样式表
设计系统里**没有**界面字号变量。每个组件都写死 11、12、13、14、16、20、24 px 之一,而 `--dsw-font-*` 是一套固定阶梯(`--dsw-font-xs-13` 就是 13px,没有变量可调)。代码字号同样烘焙在复合 token 里。
因此插件为每个内置尺寸生成一个工具类:
```css
.dsh-font-size-14{font-size:calc(14px * var(--dsh-font-ui-scale,1)) !important}
```
并把对应的类逐个盖章到元素上——在元素还没有盖章时用 `getComputedStyle` 量出它的内置尺寸。`MutationObserver` 会在界面挂载新节点时补盖。
之所以逐元素盖章而不是用通配规则,正是为了让两条字号轴互不影响:`html body *` 这种覆盖会继承进会话区域并与 dsh 自己的正文字号复合叠加,而且还会改掉那些本来不该缩放的装饰性尺寸(文件类型图标里内嵌的 SVG 文字标签)。逐元素测量还意味着插件不需要知道任何组件的类名,因此能扛住界面重构。
### 正文字号不再由本插件负责
`--dsh-content-font-size` 是在 `body` 上的**内联**自定义属性,由 `ui-layout` 依据兄弟命名空间 `ui-theme` 写入(也就是 *设置 → 外观 → 字号大小*)。内联声明无论有没有 `!important` 都压过样式表,所以想接管它就必须写到完全相同的位置——本插件以前就是这么做的,现在不再写它,也不再重新推导那条 Markdown 阶梯。对话正文的字号与行高重新完全归 dsh 自带那条设置所有;本插件只留下界面缩放与代码字号两条轴,它们各自作用在自己的区域里,互不复合。
### 代码字重写在哪里
设计系统里**完全没有**字重 token——界面上每一处 `font:` 都是字面量,代码那条阶梯写死就是 `400`。所以字重没法像字体族那样搭在字体 token 上,它分两层生效:
```css
/* 三个代码 token 在各自的 font: 简写里带上它…… */
--dsw-font-markdown-code: var(--dsh-font-code-weight,400) var(--dsh-font-code-size,12px) / … ;
/* ……而直接用代码字体族写死的地方,按结构匹配 */
html body pre, html body code, html body [class*="code" i] {
font-family: var(--ds-font-family-code) !important;
font-weight: var(--dsh-font-code-weight,400) !important;
}
```
第二条规则的存在,是因为界面上大多数代码根本不读 token:工具 I/O 卡片和终端输出是在组件样式表里用 `font-family: var(--ds-font-family-code)` 加上自己的字面量字重写死的,除了覆盖元素本身没有别的地方可改。`!important` 正是为了压过 `font: 500 12px/18px …`。这条规则刻意**不是**通配规则:周围标签仍保留出厂的字重层级。
### 界面字重写在哪里
界面字重是**可选**的:出厂值 400 不产生任何规则,所以未改动的安装画出来的就是设计系统原本的样子。设成别的值时就只有一条规则:
```css
/* 会继承字重的界面文字整体移动 */
html body { font-weight: 500 }
```
标题、表头这类在设计系统里写死了自己字重的地方不动,用 `font:` 简写整体声明过的对话正文也不动——对话正文的字号与行高归 dsh 自带那条设置,本插件不再重新声明那条阶梯,也就不会顺手改写它的字重。
### 首屏绘制前
Node 半边响应 `webserver/index-inject`,注入一段内联 `