# 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`,注入一段内联 `