Skip to content
| Marketplace
Sign in
Visual Studio Code>Themes>Ferris × Yaru 熔铸New to Visual Studio Code? Get it now.
Ferris × Yaru 熔铸

Ferris × Yaru 熔铸

loopgad

|
2 installs
| (0) | Free
把 Rust 品牌结构 × Ubuntu 26.04 Yaru 的「熔铸」设计系统带进 VS Code:四套经 WCAG 审计的情绪配色、完整工作台覆盖面、配套图标主题与动效预设。
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

熔铸 Smelt — Ferris × Yaru for VS Code

Rust 品牌结构 × Ubuntu 26.04 Yaru,做成一套 VS Code 配色主题、图标主题与动效预设。

四套情绪(mood),每套明暗两版,共 8 个配色主题 + 4 个图标主题。所有颜色由 3 个种子色在 OKLCH 空间推导而来,并对 744 组对比度关系逐条审计通过。


安装与使用

# 从源码运行(开发用)
code --extensionDevelopmentPath=<仓库>/vscode/extension

# 打包
npx @vscode/vsce package
code --install-extension ferris-yaru-smelt-1.0.0.vsix

安装后 Ctrl/Cmd+Shift+P:

命令 作用
熔铸: 选择情绪配色 (Mood) 换一套情绪,同时换配色与图标
熔铸: 应用当前情绪(主题 + 图标 + 动效) 一键成套应用
熔铸: 切换明暗 暗色 ↔ 浅色
熔铸: 设置动效强度 off / subtle / full / cinematic
熔铸: 查看当前情绪 WCAG 审计报告 打开该主题的逐条对比度报告
熔铸: 还原本次会话前的主题设置 撤销本次会话的主题改动

也可以直接在 settings.json 里写 "workbench.colorTheme": "熔铸 Forge (暗色)" —— 主题文件本身是自包含的,没有扩展也能用。


四套情绪

情绪不是换皮。种子色是唯一能改变真实观感的杠杆,所以「情绪」= 用同样三个数字换一间屋子。

情绪 暗色 强调/底色 感觉 动效节奏
Forge 熔铸 #E76A40 / #1B1526 基准。熔融橙压在冷却的茄紫锻炉上,精确、沉稳 Allegro — 快速、干脆、无回弹
Ember 余烬 #D4693A / #1D1636 熄着的炭。更深更柔,夜里对眼睛友好 Adagio — 缓慢、柔和、长渐隐
Dawn 晨曦 #F08252 / #221A46 拂晓。把暗色房间打开,浅色偏冷日光白 Vivace — 明快、开阔、有呼吸感
Hush 静谧 #C97F5C / #242230 近乎单色,强调色是唯一的暖音 Largo — 近乎静止,只留必要动作

四套的种子色与设计依据都来自上游设计系统(server/tokens.mjs),本扩展不另立一套颜色。


动效

VS Code 的配色主题不能携带动效 —— editor.cursorBlinking、editor.smoothScrolling 这类设置属于用户,一个配色主题偷偷改它们是越权。所以动效是独立的一根轴,有自己的开关。

强度分四档,每套情绪有各自的节奏:

  • off — 不写入任何设置。仍然是显式声明,所以降档时会把上一档的设置关掉,而不是留着。
  • subtle — 只开光标平滑动画与平滑滚动。
  • full — 光标动画、平滑滚动、括号与缩进引导线。Hush 在这一档仍然把滚动动画关掉 —— 它不是没做完,是「静谧」这个情绪的设计。
  • cinematic — 在完整之上追加最明显的运动项(滚动边距、缩略图逐字符渲染等)。

动效默认写入用户级设置。要让某个项目单独固定动效,打开 smelt.writeWorkspaceSettings,熔铸: 还原 会把自己写过的键清掉,回到你自己的值。


覆盖范围

配色主题设置 981 个工作台颜色键,覆盖 VS Code 1.140 注册表的全部 997 个中的 981 个(其余 16 个是继承得到的值,见下)。包括:

  • 编辑器、差异、合并编辑器、多差异编辑器
  • 侧边栏、活动栏、标题栏、状态栏、面板、菜单、命令面板
  • 列表与树、输入框、下拉框、按钮、徽章、滚动条、缩略图
  • 终端 16 色 ANSI 全表(每个都单独对终端底色解算,不是照抄状态色)
  • Git 装饰、测试、调试、Notebook、图表、符号图标
  • 聊天 / 代理(agents)新表面、1.140 的 modern* 新外框
  • 语法高亮 472 条 TextMate 规则 + 语义 token 表(两张表读同一组色槽,所以语言服务器加载前后颜色不会变)

关于「完整」这件事

tools/data/workbench-keys.json 里的 997 个键不是凭记忆写的,是从 VS Code 1.140 安装目录里读出来的:workbench.desktop.main.js 中的 registerColor() 调用点即注册表本身。

完整性检查是双向的:

  • 注册表里有、主题没有的键 → 必须出现在 INTENTIONALLY_UNSET(附理由)或 COVERED_BY_INHERITANCE 里;
  • 主题设置了、注册表里没有的键 → 构建失败。

第二条曾经是「只提示不报错」,理由是主题可能超前于用户的 VS Code 版本。实测推翻了这条理由:当时 63 个未注册键全部是错的(凭记忆的名字 19 个、第三方扩展的键 1 个、照邻居家族类推的 43 个),没有一个是 VS Code 后来才加的。而容忍它们的代价是真实的 —— 只要未知键被允许,真键里的打字错误就和杜撰无法区分,主题会静默少掉一个表面。所以现在它是硬失败。


图标主题

734 个扩展名 / 298 个文件名 / 298 个文件夹名 → 212 个 kind → 230 个 SVG。

种类(kind)是身份,图标是 (色调家族, 字形) 这一对。212 个 kind 落在 88 个字形上,共用同一对 (family, glyph) 的 kind 共用同一个文件 —— 这既是 230 而不是 734 的原因,也让「悬空引用」在生成器里无法写出来: id 由画的时候用的同两次查表算出。

同一个字形出现在不同家族里是刻意的,不是重复: .rs 和 .json 都是花括号,但一个橙一个青 —— 那是两个图标,不是一个。实测 21 个字形被两个以上家族共用。

一段历史:「家族」曾经是谎话

调色板声明了十个家族,但只有六个真的发运。file-kinds.mjs 是分类学,它回答「这是什么文件」,于是把 CSS、HTML、SQL、shell 一起归进 language —— 这一点没错,它们都是语言。它不回答「这该是什么颜色」,而那四个是四种颜色。

后果是 .html、.css、.sql、.sh 全部发成同一个橙色,markup、type、style、build 四个家族一个图标都没有。

而没有任何检查失败:图标定义的总数看着合理,清单里每个 id 都指向一个真实存在的文件,validate.mjs 全程绿灯 —— 因为「每个引用都能解析」和「每个声明的家族都被用到」是两个不同的问题,而当时只问了第一个。

现在颜色归属由 build-icons.mjs 里的 KIND_FAMILY_OVERRIDES 决定,是显式列表而不是模式匹配:按名字写正则更短,但它也会把将来新加的 kind 悄悄归进某一类;列表会在 audit-families.mjs 里直接报错。那个检查同时断言 212 个 kind 全部解析、十个家族全部可达、dist/*.json 与重新构建的结果一致。

设计语言(第三版)

这一版之前有两版,都被推翻,原因值得写下来。

第一版:整块实色方板 + 52% 处的深色横带 + 近白字形。 能看清,但难看:

  1. 24 单位里塞了三个明度。 实心填充、深色横带、白字形 —— 16px 方块里三个值互相抢,没有主体。
  2. 那是接缝,不是折痕。 正好从 52% 切开,在板上画出一条横线。16px 下像渲染瑕疵,96px 下像画错了。
  3. 十九个高饱和色相。 资源管理器变成一张色卡,颜色本身不再承载信息 —— 那正是给它们上色的全部理由。
  4. 字形挤在自己的板子上。 板占 20,字形画到 18,几乎没有留白。

第二版:浅色柔和底板 + 深色字形。 明度关系反过来了,字形也全部实测适配、居中、笔画统一 —— 几何上完全成立。依然不行,而且原因是这个仓库里没有任何度量看得见的:底板这个想法本身就是错的。

第三版,也就是现在:没有底板。

做法
背景 透明。 什么都不画。图标直接落在侧边栏自己的底色上
颜色 每族一个墨色,直接对侧边栏解算 —— 而不是对底板解算
字形 图标就是那个字形。文件里没有别的东西

为什么去掉底板是关键。 Seti、Material、Symbols —— 公认做得好的图标集都是纯字形 + 透明背景。理由是资源管理器的一行本来就是「图标 + 文件名」,而眼睛该落在文件名上;260 个不透明小方块就是 260 个互相抢的小面板,读者每次扫视都要为它们付钱。颜色仍然区分家族,只是它现在长在字形上,不占面积。

「对底板解算」是第二版真正的病根。 它的字形色是解出来对浅色底板成立的,于是那些颜色偏浅、偏灰 —— 一旦画到深色侧边栏上就发白。第三版把解算基准换成侧边栏本身,同一个规则依然同时服务近黑和近白两种底色,而结果是墨色对侧边栏 5.97–7.39:1(正文底线 4.5:1)。

七个墨色,不是十个

十九个是装饰。十个在去掉底板之后还是太多 —— 十个色相在一列文字里读起来是色彩抖动,而不是分组。所以按含义合并成七组,合并本身就是设计:两样读者会用同一句话描述的东西,共用一个墨色。

墨色 色相来源 彩度 代表
language 源代码 主题 accent 0.150 .rs .ts .py
markup 标记与模板 字面 350° 0.130 .html .vue .jsx
data 数据与类型 主题 function 槽 0.120 .json .sql .proto
style 样式 字面 318° 0.115 .css .scss
media 媒体 主题 string 槽 0.110 .png .mp4 .ttf
archive 归档与构建 主题 number 槽 0.110 .zip .sh Dockerfile
doc 文档与配置 字面 275°(近中性) 0.035 .md .lock Cargo.toml

type 并进 data(schema 和记录是同一个想法的两个阶段),build 并进 archive(打包、构建、shell、云,在仓库里住在同一处),而配置、版本控制、密钥、日志全部并进 doc 的近中性墨色 —— 那是读者会扫过去的一类,给它们饱和色相会让整个调色板失去意义。

文件夹是实心的,文件是描边的

这是层级能读出来的原因,也是上一版「文件夹和文件分不出来」的修复。

之前文件夹只是又一个描边字形,和文件同样粗细 —— 树里满是这样的一行时,分辨目录和文档要靠凑近看字形。而树里层级是第一信息。

现在文件夹是实心剪影(Yaru 的宽扁身形 + 小耳朵),文件是描边记号。这个差别在 16px 下成立,对一个不知道调色板的读者成立,而且是扫视而不是阅读时也成立。两者占用同一个 18 单位框并各自实测适配,所以展开一行时树不会跳。

同一个字形出现在不同墨色里是刻意的:.rs 和 .json 都是花括号,但一个橙一个青 —— 那是两个图标。实测 19 个字形被两个以上家族共用。

渐变只用在文件夹上,而且只往亮的方向走

文件夹是这套图标里最大的一块面(约 18 × 17 单位,而文件是几条细线),所以它是渐变唯一值得花字节的地方,也是平涂唯一显得廉价的地方 —— 这么大一块纯色读起来是贴纸,一点自上而下的明暗过渡读起来是受光的表面。

实测每个情绪 230 个 SVG 里 56 个带渐变(7 families × 2 states × 4 个文件夹状态在每情绪展开),其余都是描边字形。理由很实在:一条 2.25 单位的线没有面积容纳渐变,描边上的渐变在 16px 下和纯色完全一样,只白增字节。

步长是 0.085 OKLCH 明度,而且只往亮的方向走:最暗的一端就是解算出来的墨色。这样渐变在构造上就不可能把图形压到 4.5:1 以下 —— 如果往暗的方向走,每一处下游解算都要重来。两端仍然各自被 audit-palette.mjs 量过。

双色调:第二层色是同一个颜色降透明度

一个由多条笔画组成的字形需要层次,否则读起来是一团涂鸦:文档的轮廓和里面的文字线不是同等重要,在 16px 下用同一强度画出来就是一块糊。所以第二色确实存在 —— 但它是同一个颜色的低透明度,不是第二个色相:七色系统里再插一个双色相字形,就是第十三个不属于任何家族的颜色。

透明度每个家族各自解算,不是全局常数。实测固定 0.62 时 hush/light 的 media 合成后只有 2.67:1,低于图形对象 3:1 底线 —— 那个墨色本来就是全组对比度最低的,浅色侧边栏给它的余地最小。所以每个家族从 0.62 往上走,直到合成后过线就停。停在最低处是重点:能看清的最安静那一档给出的层次最多,而「统一抬高到最坏情况过线」会让整套为迁就一个家族而变平。实测解算结果 0.62–0.69,157/189 个字形用上了第二层。

tone 这个机制在文件里出现过三次,前两次都没真的生效:stroke 吃了它,但 rrect / circle / fill 把颜色原样透传 —— 所以 chip 这种实体细节字形的内层方块一直以全强度画,和自己的外形一样响。

曲线:能保证的和保证不了的

曲线的手感是这个仓库量不出来的东西。能保证的是:圆角用真圆弧、笔画圆头圆角统一、所有字形实测居中适配、粗细离散度 2.2%(16px 下 1.43–1.53 设备像素)。「这段曲线好不好看」需要眼睛,我给不了。

文件夹耳朵与本体之间那道凹圆角是个具体例子。最早用圆弧画,而弧有一个弦长约束:弦长 2.26 单位、直径 3.2 单位时,弧不会报错,它渲染成的是另一条曲线。改成控制点落在被切的那个角上的二次贝塞尔之后,它按构造就精确 —— 起于竖直切线、终于水平切线,唯一的参数是伸出多远。

一条值得单独记的结论:参数化的曲线比手写坐标更容易悄悄出错。手写的点画错了看得见;弧的参数不满足约束时,渲染器安静地给你另一条曲线。

动效:三个真实缺陷(形状层)

VS Code 的颜色主题带不动效 —— cursorBlinking、smoothScrolling 这些是普通设置、归用户所有,一个偷偷改写它们的主题是在做用户没要求也看不见的事。所以动效是独立的、显式的轴(smelt.motion),独立开关,四个档位(off / subtle / full / cinematic),四个情绪各有自己的节奏。只装颜色主题的人得到的就是颜色,零意外。

在这一层里修掉两个缺陷,都在同一个文件里,而且都不影响渲染,所以看不见:

# 缺陷 症状
1 editor.smoothScrolling 在键表里出现了两次 一张其他部分都被断言为精确的清单里有一个重复项 —— 它对测试完全透明,对读这份产物的人是个笔误
2 键表里列着 editor.fontFamily / fontSize / lineHeight / letterSpacing / fontLigatures 文件头把这形容为「与动效有关的设置」,而它们是排版。更糟的是 smelt.motion 就是会写它们的那个轴:用户选择「想要多少动效」,结果字体被改了 —— 正是整个特性极力避免的那种意外
3 validate 只查了一个方向 它断言「写了但没声明」—— 危险的那一侧。反过来「声明了但没人写」从来没查过,于是死条目可以永远躺着。补上这个方向后立刻又找出 4 个(另有 1 个是排版键)

实测:没有任何动效档位设置过这五个键 —— 它们不只是放错了位置,而是死条目,唯一可能的作用就是让将来某次编辑借动效轴去写字体。所以删掉,键表从 42 项收敛到 36 项。

六个家族的色相取自主题自己解算出的语法槽,所以一个 .rs 图标和它里面的 Rust 关键字是同一个颜色 —— 图标是被主题延伸出来的,而不是贴上去的。

两个近中性是一对刻意的安排。 doc 与 system 的彩度只有 0.045 与 0.030(其余在 0.090–0.135),色相只差 16°。这是有意的:它们是读者会扫过去的两类,给它们饱和的色相会让整个调色板失去意义 —— 因为那样一来所有东西就都一样重要了。它们靠冷暖区分。

背景与侧边栏:VS Code 收不下渐变,所以做的是「阶梯」

你的要求是背景和侧边栏用渐变色系。先说结论:VS Code 收不下渐变。

颜色键的契约是 format:"color-hex" —— 一个十六进制色值。整个 workbench bundle 里出现的 linear-gradient 只有八处,全部是 VS Code 自己写死的(标签页的渐隐遮罩、notebook diff 的斜纹填充),没有一处能从主题够到。所以 sideBar.background: linear-gradient(...) 不是一个能用的写法。

但渐变之所以好看,原因是可以用别的办法拿到的。 一整块渐变在窗口上真正做成的事,是让各个面处在递进的明度上 —— 于是窗口读起来像从一个方向被照亮,而不是一整片平地上面画了几个矩形。而一条 40 像素宽的侧边栏上的真渐变,本来就是 40 像素的效果,没人看得见;横跨整个 chrome 的阶梯才是整个窗口。

现在的阶梯是一个方向、每级等距:

级 面 相对编辑器
0 编辑器(纸面,窗口最亮) —
1 侧边栏 / 面板 / 标签条 / 状态栏 0.018
2 标题栏 0.036
3 活动栏(最外框) 0.054
— 悬浮层 / 选中态(在画布之上,所以往另一侧走) —

实测四级各自相差 0.018,整条跨 0.054,每个面都是不同的十六进制值。

这修掉的是两个真实缺陷

缺陷一:活动栏根本不是一个区域。 之前每个面各自挑自己的步长 —— 标题栏 -0.010、活动栏 -0.022、面板 -0.022。活动栏和侧边栏算出的是同一个十六进制(#161021,每一个暗色主题都是),所以那里根本没有边界:活动栏不是一块区域,它是「带图标的侧边栏」。没有任何检查失败 —— 两个面相等不是对比度违规,只是没有任何人在断言的那个区分消失了。

缺陷二:浅色主题的阶梯方向是乱的。 步长是 -0.018, +0.009, +0.018, -0.018,一深一浅来回跳,读起来是噪声而不是深度。

修的过程中还撞上第三个,值得单独记。 第一版把阶梯写成「每个面各自相对画布的偏移」,结果面板和标题栏都取 0.018 —— 两个面在同一级,又塌成同一个十六进制。这正是阶梯本来要修的那个缺陷,在下一层重演了一遍。关键不是每一步多大,而是每个面在梯子上的位置;所以常量改成了累积距离(0.018 / 0.036 / 0.054),并且 audit-surfaces.mjs 量的也是位置。

方向为什么在明暗两种模式下都是「向下」: 浅色主题的画布已经在 OKLCH 明度 0.98,而 step() 在 0.99 截断 —— 一个朝 chrome 上升的阶梯直接撞到天花板,把四个面压成同一个值。实测 Forge 浅色的标题栏、活动栏、侧边栏、状态栏全部解析成 #FEFBF7,审计报告的跨度是 0.0000。所以编辑器是锚点、是窗口里最亮的,其余都在它下面。

太糊的架构性根因:图标被两次小数缩放

你说「太糊了」。前面我一直在调线宽(1.46 → 2.04px)和字形几何,但那些都不是根因。根因在缩放管线里,而它是架构问题:

viewBox          24 单位
glyph box        18 单位 (居中)
fit scale        0.639 .. 1.656   <- 第二次缩放
最终变换 = 24→16 的 0.667 × fit scale

在 0.667 这个小数缩放之上,又叠了一次每个字形各不相同的小数缩放。 任何一个图标集的边缘落在非整数像素上都会被抗锯齿摊开 —— 而这里每一个边缘都必然落在小数像素上,是构造上就注定的。138 个字形有 138 个不同的最终缩放。

这就是为什么我调了四轮线宽和几何都还是糊:我在调一个由缩放决定的结果。

修法:让 fit scale 尽量接近 1.0

把 glyph box 从 18 单位(24 的 75%) 扩到 21.4 单位(89%):

之前 之后
fit scale 范围 0.639 – 1.656 0.816 – 2.024
fit scale 在 1.0 ± 0.15 内的字形 — 169 / 188
描边设备像素 2.03 – 2.06 1.99 – 2.01
描边展开 1.7% 1.2%
墨迹占 viewBox 77% 82%

90% 的字形,变换现在几乎是恒等变换 —— 只剩下那个干净的 24→16 映射。

82% 这个数字不是我拍的:Lucide、Feather 这类成熟图标集画在 24 单位网格里、占约 20 单位 = 83%。 我照着那个标准定的 box 尺寸。

顺带把描边目标改成 2.0 设备像素整(STROKE_PLACED = 3.0,因为 3.0 × 16/24 = 2.0 恰好),实测 1.99–2.01、展开 1.2% —— 这是全套字形第一次落在整数像素上。

排版也顺便对齐了

字形现在从 box 的 75% 扩到 89%,边缘留白 2.16 单位 —— 之前扩大到 22 单位时留白只剩 0.8,圆头笔画顶到 viewBox 边,看起来像被裁掉(我实测了 0 个越界,但视觉上就是"显示不全")。现在既有呼吸空间又填得满。

这一轮的教训

四轮参数调整都没解决,是因为问题不在参数里。 我应该先量管线(恒定缩放 × 变量缩放 × 目标尺寸),而不是先量结果(线宽、对比度、部分像素)。上一次「不看图」让我绕了四轮色系,这一次「不量管线」让我绕了四轮锐度 —— 两件事的共同点是:我一直在优化可测的量,而不是在找决定它的那个量。

「显示不全」的根因:我的光栅器把 evenodd 当成了「填充」

你说多个图标显示不全。我以为是几何超出 viewBox,先量了 —— 0 个越界。然后量到真正的问题:

43 个字形在同色实心体上又画了一层同色实心细节。 墨叠墨还是墨,所以那层"细节"根本不存在 —— 在接触表上它们就是实心块,16px 下细节完全缺席。这就是"显示不全":不是几何被裁掉,是画上去却看不见的东西。

tone 机制救不了这里,而且在原理上就救不了:它是同色降透明度,产生的是一块更亮的区域。在实心心体上,更亮的区域读起来是"凸起",而 shield 的inset 边框、chip 的芯、lock 的孔,身份是开口。用凸起表达开口,是相反的语义。

于是做了 cut() —— 用 fill-rule="evenodd" 真正挖掉

然后发现我自己的光栅器一直是坏的。

hit = shape.subpaths.some((points) => evenodd ? inPolygonEvenOdd(...) : inPolygonNonzero(...));

.some() 问的是「点是否在任意一个子路径里」—— 那是 non-zero 规则。而 even-odd 的定义恰恰是:在两条子路径内 = 形状外,这就是"洞"的机制。用 .some(),洞永远不可能被减掉。

隔离验证(一个方块里挖一个方洞):渲染成实心方块。 项目里每一个 evenodd 路径都被静默忽略。

后果比渲染 bug 更糟: 我基于这个光栅器改写了三个字形去用洞(shield 的 inset 场、chip 和 cpu 的芯孔),而这个改动"看起来验证过了" —— 因为工具同意它自己。它是靠看图才被抓到的,而数字审计已经通过了两遍。

修法:even-odd 要数包含点的子路径个数,奇数才算内部:

let inside = 0;
for (const points of shape.subpaths) if (inPolygonEvenOdd(px, py, points)) inside += 1;
hit = inside % 2 === 1;

修完后隔离测试正确挖洞,shield 显示出 inset 场,chip 显示出芯孔。新增 tools/test/svg-raster.test.mjs(2 项):一项断言 evenodd 挖洞,一项断言非 evenodd 仍然正常填充 —— 免得修好一个弄坏另一个。

顺带诚实交代:我在这一轮弄坏了文件,并且丢了一个字形

用脚本按字节区间替换 glyph 块时,shield 原本是单行定义,所以我找 .join('') 时匹配到了很下面的 flask —— 把中间的字形整段吃掉了。文件语法错误,shield/flask 之间的内容丢失。

我逐步修好了:补回被吃掉的分隔逗号,188 个字形(原 189)。

丢的那个我无法确定是哪一个,但我可以证明它不影响任何东西:

检查 结果
GLYPH_NAMES 188,与源表条目逐一对齐
任何 kind 引用的字形 0 个缺失
四个情绪的 manifest 定义 888 个,缺失文件 0
全部审计 全绿

也就是说丢的是一个没有任何文件类型引用的字形。我记录在这里而不是悄悄带过。

教训:按字节区间改写源码是错误工具。 正确做法是按行边界或解析后改写;我后来改用 edit 工具的精确字符串替换,两次都干净。

当前状态

项 值
图标 SVG 888(4 情绪 × 222),定义缺失 0
字形 188,与源表一致
描边 125 个描边字形 2.03–2.06 设备像素,展开 1.4%
球 6 家族 / 5 色相 + 2 中性
dist/ 27 文件 / 2.94 MiB
包 1.70 MiB / 912 项
测试 tools 25/25、repo 714/714、真窗口 8/8

锐化第二轮:一个规则,三个字形 —— 「特征不能比画笔还小」

上一轮把线宽从 1.46px 提到 2.04px,治好了「糊」。但加粗的笔立刻暴露出另一个问题:有些字形里的特征比笔还小,于是被笔自己填掉了。

这不是审美问题,是算术问题:一根笔画是 3.04 排版单位宽。任何两个元素靠得比约 4.5 单位更近,它们之间的缝就比两侧的墨还窄 —— 在 16px 下合成一条粗带。

database 我改了四次,第四次才有用

版本 问题
三条隔板,间隔 4.5 缝比墨窄 → 合成一条带
两条隔板,间隔 5 同上,只是少一条带
把顶盖椭圆拉高 顶盖终于可见,隔板仍然不可见
改成三张叠起来的圆盘(填充) 读出来了

关键测量:database 这个字形只占 11.7 单位宽(它是方形盒里的高形状,所以被缩得最狠),而笔是 3.2 单位 —— 超过它自身宽度的四分之一。

圆柱需要顶盖、筒身、隔板三个特征,而在 11.7 单位里用 3.2 的笔放三个特征,没有一种排布成立。 所以这个字形不再尝试:改成三张圆盘叠起来(从侧面看数据库),每张圆盘是一块实心形状而不是"两根笔画之间的缝"。

和 palette 是同一个决定:当一个形状的身份是"一条被量出来的小缝",就给这个字形换一个形状。

angle-brackets 和 braces:删掉放不下的细节

  • angle-brackets:我上一轮在两个尖括号之间加了斜杠来说"闭合标签"。在 1.46px 的笔下有位置,在 3.04 的笔下三个元素合成一个实心菱块。 尖括号之间的缝是 6 单位、笔是 3.04,要塞进第三个元素就必须把三个都缩到都读不出来。删掉。 两个尖括号自己就说清了它是尖括号。
  • braces:包在里面的两行内容原来在 x 10.5,而两条曲线扫过 x 5.5 —— 只隔 5 单位。挪到 x 11、y 8.5/15.5,每个元素周围留出 6 单位净空。

删掉一个放不下的细节,和添加一个放得下的细节,是同一个决定。

顺带修掉两个静默的陈旧引用

这两个都属于**「不报错,只是少做一点事」**那类:

  1. render-zoom.mjs 里写着 style--hash —— 家族合并后那个 id 已不存在。它不报错,只打印 missing: style--hash.svg 然后画另外七个。一次运行看起来成功,却少画一个图标。 已改成 markup--palette。
  2. compare-chroma.mjs 的探测清单里还留着 style 家族,于是 inks['style'] 是 undefined,一路传成 ink: undefined 才炸。现在六个家族各两个,而且我给它的 composeIcon 调用加了包装,报错时说清是哪个字形、哪个颜色,而不是一句裸抛。

这两个都是我自己上一轮改家族时留下的 —— 而它们正好说明为什么"改一个共享标识符"需要工具去追,而不是靠记性。

整理后的状态

项 值
图标 SVG 888(四情绪 × 222),磁盘与 manifest 逐一对齐,0 孤儿
家族 6(language / markup / data / media / archive / doc)
色相 5 + 2 中性,全部取自主题自己的令牌
描边 126 个描边字形 2.03–2.06 设备像素,展开 1.4%
dist/ 27 个文件 / 2.93 MiB —— 只有能给人看的
包 1.70 MiB / 912 项

图标色系:这一轮是规划,不是调参 —— 屎黄色是我用错了地方

你说「好好规划,不会就用成熟的」。这句话点中了要害:前四轮我都在调参,没有一轮在规划。

屎黄色的真正来源

archive 家族 —— 归档、包、构建系统、shell、基础设施、CI —— 负担 220 个扩展名,是所有家族里最大的。而它的色相是 85:金黄色。

它在这个主题里唯一的同伴是 number —— 而 number 给代码里的数字字面量上色。一个在几个数字上读起来像高亮的颜色,当它变成 16px 实心方块、铺满仓库三分之一的时候,就是一片芥末黄。 这正是你两次说的东西。

所以错不是"黄色不好看",是"最大的家族配了最挑的颜色"。

规划:五个色相,每个都有理由

前四轮的七家族其实是一个彩虹 —— 七个点在色环上大致等距。而有设计的调色板不是等距,是少数几个色相 + 每个都有说得出理由的角色。

家族 色相 理由
language accent(39) 主题自己的强调色 —— 品牌在代码里
markup type(300) 标记是结构,而结构在这个主题里是紫罗兰
data function(245) 结构化数据落在冷端
media string(150) 资源和其他"内容"共用一色
archive 中性 最大的角色,所以用最安静的处理
doc 中性(偏冷) 同上

六个家族、五个色相、两个中性。 而且每个色相都取自主题已经在用的令牌 —— 图标不再是"贴上去的",它们是用主题本身做的。

金色没有消失:number 仍然给编辑器里的数字字面量上色,那才是它该在的地方。它只是不再出现在每一个 Cargo.toml 上。

家族 之前 现在 色度 / 文件名色度
language 0.150 0.058 1.71
markup 0.130 0.048 1.41
data 0.120 0.044 1.29
media 0.110 0.038 1.11
archive 0.110 0.012 0.37
doc 0.035 0.016 0.48

最初 3.3–4.4 倍 → 现在 0.37–1.71 倍。 最常用的两个家族(archive、doc)比文件名还安静。

顺带:style 家族合并进 markup

六个家族对一个五色相的调色板来说多了一个。一张样式表和一份模板,对扫视一棵树的读者来说是同一类东西 —— 所以 style 并入 markup。图标总数 920 → 888。

工作区整理

你说要整理好。这一轮做了:

项 之前 之后
dist/ 文件 57 个 / 4.71 MiB 25 个 / 2.89 MiB
我这一轮散在 TEMP 的备份 4 个(fd-backup.json、forge-dark-backup.json、glyphs-backup.mjs、families-old.txt) 0
临时会话目录 残留 0
图标 SVG 920(四个情绪共 368 个多余的) 888,磁盘与 manifest 逐一对齐
包 1.77 MiB 1.68 MiB / 911 项

dist/ 现在只放"能给人看的东西":8 个主题 × 明的 1:1 树渲染 + 4 个暗色 4 倍渲染、8 张真实窗口截图、图标总表、色度对照表、audit.json、以及包本身。中间过程的截图全部删掉 —— 它们是可再生的,而结论已经写进文档。

还修了一个静默的陈旧引用:render-zoom.mjs 里写着 style--hash,那个 id 在家族合并后已经不存在。它不报错,只打印 missing: style--hash.svg 然后画另外七个 —— 于是一次运行看起来是成功的,却少画了一个图标。现在改成 markup--palette,并把这份清单的说明写成「不该再有过期名字」的理由。

git 的规则本来就是对的:dist/*.png、dist/*.html、dist/*.vsix 被忽略,dist/audit.json 和 icons/**/*.svg 被跟踪。仓库自己的 scratch-hygiene 测试 9/9 通过。

图标色系:我不该靠数字猜,该摆在一起看 —— 第四次才做对

你三次说色系不对,我三次都在下调饱和度,三次都没解决。第四次我换了方法:做一个对照表,把同一棵树在四个饱和度下并排画出来,然后看。

新增 compare-chroma.mjs —— 同一棵树、同一批文件、四列,分别是声明色度的 x0.4 / x0.6 / x0.8 / x1.0,行是读者真正会遇到的九个文件。一次就定下来了。

我此前一直搞错的事

先量了你主题自己的色度预算:

令牌 色相 色度
accentDefault 39 0.166
danger 27 0.131
warning 85 0.130
success 150 0.130
info 245 0.130
textPrimary(文件名) 303 0.034
canvas(背景) 300 0.034

关键在最后两行:主题的 UI 色几乎是无彩的(0.034)。 accentDefault 确实是 0.166,但它用在一个图标、一个徽标上 —— 大面积 UI 元素本身是紫灰色的。

而我的图标从最初到最后,色度一直贴着 0.166 那一档 —— 那是强调色的强度,不是大面积的强度。七个家族各自占满一整行颜色块,用的却是单点强调的饱和度量级。这就是「不契合」的真正内容,而我三次都在「往低调一点」,从没量过参照系是什么。

对照表给出的答案

列 观感
x0.4 和主题很贴,但丢掉了色相信息 —— 接近灰,家族分不出来了
x0.6 好一些,仍然偏灰
x0.8 颜色清楚,略响
x1.0 最响 —— 我此前实际发运的档位附近

x0.4 丢信息,x1.0 太响,所以落在 x0.7:

家族 最初 现在(x0.7)
language 0.150 0.064
markup 0.130 0.056
data 0.120 0.050
style 0.115 0.046
media 0.110 0.042
archive 0.110 0.039
doc 0.035 0.016

相对文件名的饱和度:最初 3.3–4.4 倍 → 现在 1.1–1.9 倍。 色相仍然读得出来,但没有任何一个图标比文件名更响。

这一轮我学到的方法论

前三轮我都在用一个数字回答一个观感问题,而每一次「往某个方向调一点」都无法回答「哪种才对」——

一个数字不能告诉你几个候选里哪个好看,只有把它们摆在一起看才行。 而这个构建循环现在能看图(上一轮才发现),所以做一张对照表几乎不花成本,却一次就结掉了一个我猜了三轮的问题。

从今往后遇到「配色/观感不对」这类反馈,我的第一步是做对照表,不是改参数。

缩略图那个灰块,就是滑块本身

你截图里箭头指的那块灰,我找到了确切的数字。

键 之前 现在
scrollbarSlider.background alpha 20/255(8%) 8%
minimapSlider.background alpha 77/255(30%) 10%
minimapSlider.hoverBackground 50% 24%
minimapSlider.activeBackground 70% 40%

它看起来像个层级关系,其实是个缺陷 —— 因为两个滑块形状根本不同。滚动条是一条 10 像素宽的条,8% 的墨在上面只是一层淡雾。而缩略图滑块是一个矩形,高度等于视口占编辑器高度的比例 —— 正常窗口下 400 像素高,文件短的时候就是整个缩略图的高度。

30% 的淡墨铺在 400×100 像素上,就是一块实心灰面板。

这正是短文件下看到的东西:四行代码没有预览可画(所以缩略图是空的),而滑块以 30% 铺满整个缩略图 —— 于是整块读成"缩略图是个灰盒子"。

所以规则不是「缩略图滑块比滚动条强一点」,而是「面积越大,透明度越低」。 12% 铺在整高矩形上,滑块既清楚是视口指示器,又清楚不是一块面板。

顺带:VS Code 自己的暗色主题也在这个量级,这就是参照。

一个我必须承认的事实:那个键我真的验证错了两次

minimap.foregroundOpacity —— 我测过两次,两次都得出"它什么都不做":一次是 #00000000 对 22%,一次是键完全不存在(用默认 #000f)对 #000000e0(88% 深色)。两次都是逐像素完全相同的输出。

而且这个键现在根本不在我发的主题里(它在 INTENTIONALLY_UNSET 里),VS Code 用的是它自己的默认值。结论是硬的:缩略图预览的浓淡不可主题化。

顺带确认了缩略图本身是工作的:换成 slots.rs 这种长文件之后,缩略图区域里能测到 type/string/number/function 的语法色。短文件里测不到,是因为四行代码没有东西可画。

图标色系:又降了一档,而且现在有层级

按你说的「太丑」再降一次:

家族 最初 上一轮 现在 相对文字饱和度
language(源码) 0.150 0.115 0.092 2.7×
markup 0.130 0.100 0.080 2.4×
data 0.120 0.090 0.072 2.1×
style 0.115 0.082 0.065 1.9×
media 0.110 0.076 0.060 1.8×
archive 0.110 0.072 0.056 1.6×
doc 0.035 0.022 0.018 0.5×

最初是 3.3–4.4 倍,现在是 0.5–2.7 倍,而且这个范围本身就是层级 —— 源码最响,配置最静。

三个字形重画,其中 image 有一个真 bug

字形 问题 修法 实测
image 两个元素什么都没画:rrect(3,5,18,14,2,'none') 把字符串 'none' 当颜色传进去了,而第四个元素又把第三个已经描过的边框重画了一遍 一个边框、山在框内 30% → 17.4% 部分像素
palette 见下 三个叠在一起的颜料 39.5% → 31.7%
hash 两条横线只隔 6 单位,而一根线宽 3.04 单位 —— 缝隙比线还窄 拉开到 9 单位 29% → 35.1%(反而更软,但形状对了:现在读得出是井号)

palette 我试了三次,三次失败,而第三次最有教育意义:

  1. 描边轮廓 + 三个 1.4 单位圆点 —— 39.5% 部分像素,全套最差。比一根线宽还小的点就是污渍,而且它们还长在一个本身就是描边的环上。
  2. 填充身体 + 用 circleTone 画洞 —— 看不见,而且按设计就该看不见:tone 是同色降透明度,它的用途是给开放形状画背后的结构。墨叠墨还是墨。洞直接消失了,整个字形变成一坨实心。
  3. 填充身体 + fill-rule="evenodd" 挖洞 —— SVG 是对的,但洞只占身体面积的 3%,在 16px 下就是几个点,而一坨上的几个点还是坨。

教训不是「调色板难画」,而是:一个靠「小洞」来表明身份的形状,在 16px 下活不下来,无论用什么机制挖那个洞。 所以现在它用形状本身说话:三块叠在一起的颜料 —— 前面一块主墨,后面两块 tone(这才是 tone 真正的用途)。没有任何东西需要被减掉,也没有任何东西比一根线还细。

我能看图了 —— 这是这一轮最重要的发现

在改任何东西之前我做了一件早该做的事:测试我到底能不能看到自己产出的图。

能。 之前每一轮我都写着「构建循环没有眼睛」,然后就只靠数字判断 —— 那是错的,而且这个错误让前面几轮都在摸黑。所以从现在起:改 → 渲染 → 看 → 再改。

第一眼看到的事,数字从来没告诉我

icons-crisp.png 打开的第一秒,我看到的和「审计全绿」完全不是一回事:

  • 金色那排,archive 画成了一个方框套方框 —— 我在它上面加的「两层内容」被 3.64 单位的粗线糊成了一个实心块。
  • 同一个字形有两种填充版本并排出现(两个实心一个描边),因为一个 kind 落进了两个不同的字形。
  • 换句话说:线宽修好了「糊」,却把「细节」变成了「拥挤」。 这两个问题是耦合的,而我一直在分开调。

这就是不看图的代价:audit-glyph-detail 告诉我 archive 的元素数从 3 涨到 4(达标),而眼睛告诉我它变得更糟了。

「色系不契合主题」:量化之后是一个很具体的东西

你两次说颜色不搭。我把每个家族的色度和它旁边的文字放在一起比:

之前 现在
每个家族的明度 0.702 – 0.704 0.702 – 0.704
家族色度范围 0.110 – 0.150 0.022 – 0.115
家族色度 / 侧边栏文字色度 3.3 – 4.4 倍 1.5 – 3.4 倍

每一个图标都比它旁边的文件名饱和 3.3 到 4.4 倍,而且七个家族的明度一模一样。

那才是「不契合」的全部内容。这个调色板单独看并不丑 —— 它比内容还响。图标是行上的装饰,而那一行的信息是名字;一个比内容还抢眼的装饰,让整棵树读起来像一盘糖果。另外还有一层:七个家族明度全等、色度接近,没有主次,所以色相环表达不出任何层级。

修法是一把色度阶梯(而不是七个等量的颜色):

家族 之前 现在 定位
language(源码) 0.150 0.115 读者要找的
markup 0.130 0.100 要找的
data 0.120 0.090 要找的
style 0.115 0.082 扫过去的
media 0.110 0.076 扫过去的
archive 0.110 0.072 扫过去的
doc 0.035 0.022 近乎中性

现在这棵树有上也有下,而不是七个等号。

顺带修掉一个我自己写的度量错误

色度降下来之后,audit-palette 开始报 media 和 archive 是「同一个颜色的两个名字」,分数 0.0276,门槛 0.030。

它们色相差 66 度。 没有人会把绿色和黄色搞混。问题在公式:(色相差/180) × 平均色度 —— 两个都很淡的颜色,乘积自然小,而公式量的是它们很淡,不是它们很像。

我第一版修法是把色度下限写成 Math.max(色度, 0.055) —— 那更糟,是把下限当上限用了(0.074 被夹到 0.055),分数反而更低。正确做法是相加:(色相差/180) × (色度 + 0.055)。66 度那一对从 0.0276 涨到 0.0475,而公式的意图(色相信息不随色度一起归零)也保住了。

这一轮之后,还没解决的

archive 的「两层内容」需要重画,因为粗线把它糊掉了 —— 眼睛看到的,数字没看到。同样需要复查的还有 angle-brackets(我加的斜杠在粗线下和两个尖括号挤在一起)和 palette(圆点糊成一团)。

这两个是明确的下一步:先在 64px 下看,再在 16px 下确认,而不是只看审计。

「糊糊的」是个数字,不是感觉 —— 线宽 1.46px 的问题

你说还是糊。我上一轮加了一堆细节,却没解决糊 —— 因为糊的原因不在画什么,在线有多粗。

量化

一个字形从设计单位到设备像素要走三段:设计单位 → 拟合缩放 → 16px 的渲染。我把每一段的系数都算出来:

设计描边 2.25 单位 × 拟合缩放 0.78 = 排版后 2.19 单位
× (16px / 24 单位) = 1.46 设备像素

1.46 像素。 一个渲染器拿到 1.46px,会在两侧都画上部分覆盖,而中间任何一点都到不了满墨 —— 在 16px 上,每一个字形都不是"画出来的一根线",而是"一团柔和的灰"。没有一个字形画错了,错的是那个常数。

新增 audit-stroke-crispness.mjs,它从发出的 SVG 里读出每个字形的实际权重和拟合缩放(而不是重算一遍),算出真正会被绘制的宽度。判决:128 个描边字形,1.42 – 1.53 设备像素。

代码里那句注释其实写对了数字、下错了结论:「2.25 排版单位是 16px 下的 1.5 设备像素,这是一条线在抗锯齿下存活的底线」。1.5px 确实是存活的阈值 —— 但它不是看起来像画出来的阈值,而这两个阈值之间的差,正好就是 1.46px 和 2px 之间的差。所有在 16px 下读起来干净整洁的图标集,画得都在 2 设备像素以上 —— 这就是它们比"朴素的一像素细线"更粗的原因。

修:常数,不是 189 张画

STROKE_PLACED 2.25 → 3.08(3.0 排版单位 × 16/24 = 2.0 设备像素)。

但只改常数不够。实测展开从 2.2% 涨到 9.8%,超过了 8% 的阈值 —— 因为那个拟合定点迭代是对权重一阶收敛的,更粗的线留下按比例更大的残差:在旧权重下看不见的机制,在新权重下露出来了。

所以加了第三遍:用第二遍已经产生的几何去量它蕴含的拟合缩放,再按那个缩放重算权重、重建。实测展开回到 1.6%。

结果:

之前 之后
设备像素 / 描边 1.42 – 1.53 2.027 – 2.060
均值 1.46 2.039
展开 2.2% 1.7%

128 个描边字形,0 个落在 2 ± 0.1 之外。

我自己写的审计错了一次,顺手修掉

第一版 audit-stroke-crispness.mjs 把 61 个纯填充字形(heart、star、pin、shield 这些实心剪影)算进了统计。它们根本没有描边,stroke-width 是 0,于是把均值拖到 1.38,并把它们报成"比 2px 细"—— 而那是个对它们不适用的概念。

一个把"被测量量根本不存在"的个体算进去的统计,不是保守,是错的。 现在纯填充字形被排除在每一条统计之外,并且明确说明为什么。

这也解释了上一轮的一个矛盾

我上一轮往字形里加细节(开槽、折盖、三层隔板),而你说还是糊。现在明白为什么了:在 1.46px 的线上,每多一个细节就多一处需要被渲染的亚像素间隙 —— 细节越多越糊。在把线宽修到位之前,加细节是做负功。

顺序错了:先修线宽,再加细节。 这一轮两个都到位了,所以现在那份接触印相表才真的能看。

图标精修:按「实际会被看到多少次」排序,而不是按「多少个 kind 用它」

你说「对部分代码图标优化和细致化,文件夹之类的简单图标先不动」。这一轮修了 11 个 —— 都是代码图标,folder-* 一族完全没动。

排序依据换了,因为原来那个是错的

之前 audit-glyph-detail.mjs 按「多少个文件 kind 用它」排序。那个数字不是一个图标被看到的频率:wrench 服务 58 个 kind,而它们大多是冷门构建系统;angle-brackets 覆盖所有 C 系源文件,那是工作日里的大部分时间。把力气花在前者身上,就是花在没人看的图标上。

所以新增 rank-glyphs.mjs,按扩展名加权(再乘一个「这个扩展名在实践中多常见」的粗略系数,系数是判断、并且标明是判断):

图标 加权扩展数 kind 数
chip 61 16
wrench 59 10
archive 55 2
angle-brackets 48 6
wrench-screwdriver 44 2
database 43 4
braces 40 8
terminal 36 10

剔除 folder-* 之后,这份榜单就是这一轮的清单。

「更精细」在这套词汇里只有两个含义

一个字形只有两种方式承载更多信息,而且两种都在既有设计里:

  1. tone 元素 —— 一个读起来在主体后面的结构件(书页的折角、盒子的后缘)。这是让字形读成实体而不是平面符号的机制,文件里较好的字形本来就在用。
  2. 主描边里更多几何 —— 只在能活过 16px 时才加,那就排除了任何细于 2 单位下限、或与邻居近于约 1.5 单位的东西。

它不是装饰。 为了纹理加一个点,在 16px 就是一块污渍;一个只是更忙的字形并不更精细。

11 处改动

字形 之前 之后 为什么
chip 对称的尖刺方块 加了 pin-1 缺口 引脚数不变,结构本来就对;对称让它只说"硬件"
wrench 单条轮廓(读起来像钥匙) 加了钳口开槽 头是空心的,而空心的扳手头就是一把钥匙
angle-brackets 两个光板尖括号 中间加斜杠 有斜杠才是闭合标签,没有就只是"某种标记"
terminal 一个尖括号 + 一横 加窗口边框 原来是"浮着的提示符",不是终端
braces 两条镜像曲线(像波浪) 加两行被包裹的内容 中间什么都没有的花括号是一道波
lambda 悬空的降部 加基线 降部原来没有落脚点;基线同时给了它边界
archive 空盒子正面 加两层内容 空的正面是"容器",成排的内容才是"归档"
cube 两个面(像折纸) 三个面 + 棱线 两个面是折起来的卡片,三个面才是实体
wrench-screwdriver 螺丝刀是一个菱形 杆 + 卡箍 + 刀头 一个菱形在扳手旁边读起来是污渍
database 一圈环(像木桶) 三层隔板 一圈是桶,等距三层才是"一堆记录"
box 光板立方体 加顶部折盖 折盖才区分"正在装的箱子"和"立方体"

实测结构量(audit-glyph-detail.mjs):wrench 1→2 元素、archive 3→4、cube 2→4 元素/17 命令/3 填充、database 3→4/16 命令、box 2→3/13 命令。

我改的时候自己犯了一个错,值得记

chip 的第一版我加了两个 0.95 单位半径的圆当"四角标记"。错了两处:

  1. 低于本文件自己规定的 2 单位下限 —— 一个看不见的标记不是细节,是污渍,而这正是这一轮要避免的失效模式;
  2. 注释写着"四个",画了两个。

改成一个 1.5 单位的 pin-1 缺口,并把这段写进注释。一个"更精细"的任务里,最容易犯的错就是画上去了但看不见。

没动的

folder-* 一族、以及所有没有进入加权榜单的图标。你说先不动,我就没动 —— 而且 folder 的形状是另一个文件在管(文件夹是实心剪影,文件是描边记号),那是一条独立的线。

缩略图:我先诊断错了,然后 A/B 证明了真相

你报的是「缩略图与可跳转区域什么都看不见」。我第一眼看到一个完全透明的值,就下了结论 —— 而结论是错的,所以这一节既写修复,也写我怎么错的。

错在哪

minimap.foregroundOpacity(缩略图里代码预览的透明度)原本被设成 #00000000 —— 完全透明。这看起来就是一个决定性的 bug:零透明度 = 什么都不画。我把它改成 22%,写了注释,算好对比度(1.14:1 → 1.78:1),准备收工。

然后我做了个 A/B:把值改回 #00000000 抓一张,再改成 22% 抓一张。

两张图的缩略图区域:都是 1986 种颜色、6.96% 强像素,完全一致。

为什么 —— 在 bundle 里搜一下就明白了:

minimap.foregroundOpacity 出现次数:1        <- 就是它自己注册的那一次
getMinimapForegroundOpacity 出现次数:2      <- 存在,但……

这个键在 VS Code 1.141 里注册了,然后没有任何东西读它。 它出现在颜色注册表里,是旧版缩略图实现的残留。往里面写任何值都到不了任何一个像素。

所以我把它从主题里删掉,并记进 INTENTIONALLY_UNSET,理由那一栏写的是 'registered-but-unused' —— 这张表里唯一一条"这个键根本没用"的记录。两张 A/B 截图留在 dist/minimap-zero.png 和 dist/minimap-new.png,谁想复核都能自己看。

这就是我该在改之前做的实验,而不是改完再验证。

真正起作用的是哪个键

minimap.background,而且问题不是"没设",是设错了对象。

它原本是 R('panel') —— 侧边栏的颜色,比编辑器更暗。于是缩略图是一块比它所描绘的代码更暗的板子,贴在右边缘,而不是"文件缩小后的样子"。实测那个边界:侧边栏 #171122 对画布 #1B1526,是一道看得见的台阶 —— 而一个开头就有台阶的缩略图,读起来不是代码的延续。

现在它取编辑器自己的颜色。实测在同一行上,画布色 #1B1526 从 x 719 一路连续到 x 1451 —— 编辑器、装订线、缩略图之间没有断点。

这里有个值得说清楚的机制:预览内容的颜色是 VS Code 从文件的语法高亮里取的,不受任何透明度设置影响。所以我改不了预览的浓淡,但能改它坐的那个面 —— 而面正是让它读起来像连续体的东西。

顺带把 minimapSlider 的三个透明度独立出来(0.30 / 0.50 / 0.70):缩略图的滑块坐在预览之上,而不是代码旁边,所以它得在一个本身有内容的面上立住自己的边界,不能沿用普通滚动条的 ALPHA.subtle。

顺便:重复键检测器抓到了我自己

改 minimap.background 的时候我新加了一条,却忘了删旧的 —— 同一个键在文件里出现两次,运行时后一个赢。find-duplicates.mjs 直接报了出来:

minimap.background
  line 263: R('panel'),
  line 291: t.canvas,   <-- WINS

这是那个工具存在以来第一次真的抓到东西,而且是抓到了一个我自己造的、会静默生效的错误。如果它没报,这个文件里就会永远躺着一条看起来还在生效、其实已经死了的配置行 —— 正是当初写这个检测器要防的那种腐烂。

这一轮我还剩下什么没做到

「可跳转区域」我做了能做的,但没法完整验证。 总览标尺(右边缘那一条)的颜色都是不透明的(#56ACF099 等,60% 不透明度,合成后依然清晰),minimap.selectionHighlight / findMatchHighlight 也都有 45–60% 的不透明度。这些是数字上成立的。

但我没法证明它们在你的屏幕上看不见的原因已经修好 —— 因为缩略图内容的浓淡根本不可主题化,而总览标尺的标记是 VS Code 按视口动态画的,截图里很难稳定定位。所以如果你刷新后还是看不见标尺标记,请告诉我具体是哪一种标记(选区?查找结果?错误?改动?),那是 editorOverviewRuler.* 里完全不同的一族键,我可以针对那一族改。

可读性:4.5:1 是底线,不是舒适线

你截图里「优化得看不见了」这句是对的,而且我能指出它对应哪个数字。

上一轮我把九个语法槽的对比度目标定成 4.6:1 —— 4.5 的无障碍底线再加一成,好让 8 位取整不会把及格变成不及格。这个决定是正确的,而且读起来很差:实测屏幕上注释槽在四个暗色主题里是 4.62–4.72:1,合法可读,同时是窗口里最细最小的字。暗色主题一个槽都没有余量。

4.5:1 是"开始能读"的阈值,不是"不再累"的阈值,而代码是人要盯几个小时的字。这件事在这里比在别处更重要,因为语法高亮故意让某些槽退到后面 —— 所以那些安静的槽的底线,决定了整个文件是清爽还是发灰,而我把每个槽都压在最低点上面一丝,结果整个文件都发灰。

现在目标按角色分:

槽 目标 为什么
八个承载语义的槽(keyword/string/number/function/type/operator/tag/variable) 7.0:1 正文应该舒适,不是最低合法
comment 5.2:1 注释应该比代码安静 —— 这才是区分它的意义。但仍然高于底线,因为读不了的注释比没有注释更糟

实测(屏幕像素):注释 4.47 → 5.25:1,keyword 6.83 → 7.01:1。八个正文槽现在全部落在 7.00–7.01:1 —— 这不是巧合,是解算器的目标,所以明暗两种极性不会再各自漂走。

解算只动明度:彩度保留 90–101%,色相不变。浅色主题需要压得更深(0.50 → 0.44–0.46),在浅纸上读起来更实,不是更脏。

渐变过渡:四级是楼梯,七级才是渐变

你说「渐变过度不够」,这个判断也准。上一轮的阶梯是对的 —— 单调、每级都过可见阈值、每个面都是自己的十六进制 —— 但它读起来是楼梯而不是渐变,因为侧边栏和标题栏之间什么都没有:两个相邻区域差整整 0.018,中间没有过渡值,眼睛看到的是边而不是过渡。

渐变不是"值不同",是小的差值排成序列。所以台阶级距减半到 0.009,并且往两个方向扩:

级 面 相对编辑器
0 编辑器(纸面,锚点) —
1 标签条 0.009
2 侧边栏 / 面板 0.018
3 状态栏 0.027
4 标题栏 0.036
5 空编辑器组(已经在后退) 0.045
6 活动栏(最外框) 0.054

八个位置、六个过渡、一个方向,实测每一级都是不同的十六进制,步长 0.0087–0.0090。

中间那三个面不是为了让数字好看而发明的:标签条、状态栏、空编辑器组都是真实区域,此前各自和邻居共用一级,所以它们没有自己的值,它们相接的地方就是一条硬边。其中两处值得单独说:

  • 标签条此前和侧边栏同值,也就是说代码和它上方的条之间是一条硬边 —— 而那是眼睛停留最多的地方。给它半级,那条边就变成了渐变的第一个过渡。
  • 状态栏此前是 const statusBar = panel(别名),理由是"两者永不相接,第三种明度是没有区分对象的区分"。这个理由站得住,结论仍然是错的:别名意味着状态栏没有自己的值,永远无法参与渐变,而它确实相接的那条边(面板布局里上方的标签条)就没有过渡。多花一个数字,窗口底部就加入了渐变。

还有一件:起草时我加了第八级 deepRecess(菜单/浮层),然后把它删掉了而不是实现它 —— VS Code 窗口里没有比最外框更暗的东西,为了让数字好看而发明一个面,是设计系统开始描述一个不存在的窗口的方式。

渲染精度:参考线此前是画了,但等于没画

「渲染的精度与特效」这一条,我在注册表里逐条查了渲染相关的键(226 个),找到一处又具体又严重的问题。

十二个 editorBracketPairGuide.* 键全部是同一个颜色 —— editorWhitespace 的 8% / 12% 透明度。所以 VS Code 提供的六个色相槽全被浪费,每一层嵌套画出的是一模一样的线。实测那个颜色合成到画布上是 1.19:1:技术上画出来了,同时在屏幕上几乎不存在。

没有任何审计能看见它,因为一个透明色不是对比度违规 —— 它是没有人做过的那个选择。而且它同时丢掉了"精度"和"特效"两样东西。

现在:

  • 每一层从 bracketHues 取自己的色相,所以参考线的颜色和它连接的那对括号一致。这才是六个槽存在的意义:参考线强化括号嵌套,而不是在旁边当装饰。
  • 高亮态与常态分别在画布上解算,所以顺序在任何情绪下都成立。
  • 实测:1.01–1.37:1 → 1.66–3.92:1。

过程中还修掉一个我自己刚写进去的缺陷:第一版让每一层的透明度逐级递减(0.34, 0.328, 0.316…),理由是深嵌套不该变成一堵线墙。实测这会让第 6 层成为窗口里最淡的线(约 1.2:1)—— 把刚修掉的"近乎不可见"在最难读的深度上重新引入了一遍。深度已经由色相承载,而色相才是能扩展的线索:六个不同颜色在任何深度都可辨,六级透明度不行。所以透明度改为统一,渐隐取消。

顺带修掉一个会静默发布的配色 bug

写参考线的时候我把 alpha(hex, 0.62) 传了进去 —— "62%"最自然的写法。但那个函数收的是字节:0.62.toString(16) 是 '0.9eb851eb851eb8',于是产出的值是 #E986660.9EB851EB851EB8 这种 17 个字符的东西。不是一个颜色,也不报错,会作为"参考线什么都不画"的主题发布出去。是我打印构建结果才抓到的 —— 所以这个函数现在会归一化(≤1 当分数,>1 当字节),两个调用方都写得自然,也都产不出非颜色。

验证

validate            OK — 800 组审计(新增 6 条参考线对比度)
audit-surfaces      OK — 七级渐变,每级可分辨
audit-motion        OK — 6/10 覆盖
audit-palette       OK — 墨色 5.97–7.39:1
audit-glyphs        OK — 189 居中适配
check-glyph-paints  OK — 无效色值归零
tools/test 23/23    repo/test 714/714
vscode-session      8/8 PASS(含写入断言)
实时像素            注释 5.25:1、keyword 7.01:1、参考线 1.66–3.92:1

关于特效我做不到的部分,还是那句: 抗锯齿、亚像素渲染、字体合成是 VS Code 的渲染器,主题改不了 —— 主题能给的"精度"是数字层面的(参考线、括号配对、空白的处理、层次的区分),能给的"特效"是颜色和动效层面的。真正的光标拖尾、平滑滚动曲线、面板滑动,需要 webview 或者一个完全不同的载体,那是另一个产物。所以这一轮我把力气放在了可度量的那部分,并把每一项都变成了实测数字。

动效这一轮:两个缺陷都在「写设置」那条路上

动效 不是主题文件能承载的东西 —— 颜色主题的契约里没有任何时间维度,所以它是独立的一根轴(smelt.motion),四个档位 × 四个情绪,写的是 VS Code 自己的设置。这一轮把它做成了一根完整的轴,过程中撞到两个真实缺陷,两个都只有在真窗口里跑过写入路径才会暴露。

缺陷一:扩展根本写不进自己的主题

这个最严重。writeWorkbenchSettings 里写的是:

const workbench = vscode.workspace.getConfiguration('workbench');
await workbench.update('workbench.colorTheme', value, target);

getConfiguration('workbench') 返回的已经是划到那个 section 的视图,update() 收的 key 是相对于它的。所以这句要求 VS Code 写 workbench.workbench.colorTheme —— 一个没有注册的配置项,于是被直接拒绝:

Unable to write to User Settings because workbench.workbench.colorTheme
is not a registered configuration.

读取那一侧有同一个错误,所以还原点(snapshotIfNeeded)记下的全是 undefined,restorePrevious 会把"什么都没有"当成要还原的值。

它躲过了所有检查,原因有两个,都值得记下来:

  1. 错误被吞了 —— catch 只弹了一个通知,外面什么都看不到;
  2. 唯一断言写入的检查,读的是 harness 自己种进工作区的那份 settings —— 扩展写没写成,它根本不知道。

而且这个缺陷只在写入路径被真正跑起来时才现形,而此前从来没有任何东西跑过它:harness 只验证了 motionLoaded 和键的数量,也就是"预设读进来了",从来没验证过"设置写出去了"。

修法是让两个 writer 一致 —— applyMotion 本来就是对的(indexOf('.') 切出 section),它就在几行之外。现在两个都切。

缺陷二:off 档什么都不做

applyMotion 开头是:

if (level === 'off') return { written: 0, scope: 'none' };

理由听上去合理:off 就是"没什么要打开的"。但off 的预设存在的意义就是把东西关掉,而这个 return 在任何读取或写入之前就返回了,所以它关不掉任何东西。实测:off 档报告 appliedMotion: { written: 0, scope: "none" },设置文件里一个动效键都没有。

具体的受害者是无障碍开关。workbench.reduceMotion 在这一档设成 'on',意思是"不要动效"这句话连操作系统一起遵守,而不是"我知道的那四个开关关了" —— 而被这个 early return 丢掉的正是它。现在 off 走和其他档位一样的路径,写它的 5 个设置。

动效面本身:从 4/10 补到 6/10

新增 audit-motion.mjs,它读装好的编辑器而不是读自己的源码 —— 因为动效覆盖面是关于编辑器的事实,VS Code 每个版本都在加动画设置,一份覆盖了 1.140 的清单对 1.141 什么也没说。

写这个审计的当天 VS Code 自己从 1.140 升到了 1.141,而且它一次就找出问题:1.141 注册了 10 个动效相关设置,这个扩展只写了 4 个。

补上的两个:

  • terminal.integrated.tabs.enableAnimation —— 终端标签条的动画,默认是开的,而没有任何预设管它。一个在动、并且默认开着的设置,是用户已经在享受但主题从没表过态的动效,所以它该进预设,由情绪来决定。
  • workbench.reduceMotion —— 无障碍开关,见上。只有 off 档写它,其他三档不碰,这个不对称是决定而不是疏漏:在 off 之上,用户已经明确要了动效,在那里写 off 就是为了提供动画而去覆盖一个机器级的无障碍偏好。

刻意没碰的 4 个,各有理由:chat.experimental.incrementalRendering.animationStyle(实验性)、chat.upvoteAnimation(彩带彩蛋)、terminal.integrated.textBlinking(默认关闭,没人能开)、workbench.welcomePage.preferReducedMotion(不在范围内)。

还修了两件让这类缺陷不可能再静默的事

测试缝失败必须可见。 catch 里现在会把 seamFailed + seamError 写进报告。一个失败不可见的测试缝比没有测试缝更糟 —— 它"没有成功"和"没有运行"长得一模一样。这次就是它让排查绕道去了环境变量(SMELT_AUTO_APPLY 一直是好的,缝在抛异常)。

harness 加了写入断言。 --motion <level> 现在让 harness 启用测试缝,并在跑完后读回设置文件,断言两件事:文件存在,且里面的键都是 VS Code 注册的。另一个新检查是"缝报告了成功"。

还修了一个纯粹是读取时机的问题:activation.json 一个会话里会被写多次,而 harness 一见到文件能解析就认定完事 —— 于是它读到的是第一版,缝写的那版在后面才到。现在它会等到报告是定稿的(带 appliedBySeam 或 seamFailed)才算数。

关于「动效优化」我做不到的部分,说清楚

颜色主题给不了动效。 format: "color-hex" 没有任何时间维度,而 workbench bundle 里的动画全是 VS Code 自己写死的。所以这层能做的是配置 VS Code 已有的动画,而不是加新的动画 —— 想要真正的自定义动效,需要 webview 或者一个完全不同的载体,那是另一个产物。

所以"优化动效"的诚实体现在:覆盖面(10 个里的 6 个,剩下 4 个各有正当理由)、档位语义的自洽(off 现在真的全局关闭)、以及写入路径的可靠性(缺陷一、二)。

各档位写的设置数:off 5 项、subtle 5 项、full 30 项、cinematic 31 项;四个情绪合计 33 个不同设置。

语法色:审计底色选错了,而且只在暗色下暴露

这是这一轮最有价值的发现,而且只有把真窗口拉起来、读像素才抓得到。

server/palette.mjs 把九个语法槽全部对 codeBackground 求解 —— 那是 peek view 和内联代码块的底色。但编辑器把代码画在 editor.background 上,而它更亮。于是:一个槽可以通过上游求解、在屏幕上却不及格。

实测(真窗口读像素):注释槽对编辑器底色只有 4.47(forge)、4.42(ember)、4.38(dawn)、4.45(hush)—— 四个暗色主题全部低于 4.5:1 正文底线,而它们对 peek view 底色是 4.52–4.62,一路通过。

浅色主题从来没受影响。 浅底上更深的注释是增加对比度的,所以这个错误只在一种极性下咬人 —— 这正它躲过所有检查的原因。

修在扩展层,不动上游

codeSlots 被 MiniMax 插件的整个渲染器和它 714 项测试共用,而上游的底色对那个渲染器是对的。为一个渲染器改共享推导,会移动另一个渲染器依赖的颜色。所以和 activityBarIconActive、textDim 一样,编辑器拿到自己解算的一套(roles.syntaxOnCanvas):色相彩度不动,只移明度。

  • 审计也修了:九个 syntax-* 对的底色从 codeBackground 改成 canvas(代码真正落笔的地方),另加一条 syntax-comment-peek 断言 peek view 那一侧 —— 两个面都断言,以后不能修好一个、弄坏另一个。
  • 求解留了余量:目标 4.6:1 而不是 4.5:1。求解在 OKLCH 里走,结果落成 8 位十六进制,这个取整能吃掉百分之几 —— 实测 dawn/dark 正好落在 4.50,再取整一步就掉下去。一成的余量不花任何可见代价,却让底线不再是掷硬币。
  • 实测验证:屏幕上注释从 4.47:1 → 4.72:1。

只有注释槽移动了 —— 另外八个在编辑器底色上本来就过线。

截图工具:换成了 PrintWindow

上一轮发现 verify-screenshot.mjs 在这个问题上不可靠,但没修。现在修了,而且这次全对:四个 chrome 颜色全部出现在截图里。

老工具(capture-window.mjs 的旧版)复制的是窗口坐标上的屏幕像素 —— 那里有什么,取决于谁在上面、合成器那一刻画了什么。都不检查,于是一帧过期或被遮挡的画面和有效画面长得一模一样。

新工具用 PrintWindow 并带 PW_RENDERFULLCONTENT(标志 2):让窗口自己把自己画进一个设备上下文,拿到的是窗口自己的像素,和它前面压着什么无关。标志 2 是必需的 —— Chromium 窗口用标志 0 只会返回一张空白帧,那正是这个脚本要避开的陷阱。

配套加了 inspect-window.mjs(按面积列出平面区域、扫描行列,看边界)和 measure-text.mjs(对指定区域量真正渲染出来的文字对比度 —— 抗锯齿意味着很少像素带着完整的文字色,所以声明的比值是上限,不是眼睛收到的值)。

还发现:没有打开文件时,你截的是 Welcome 页

默认会话不打开编辑器,VS Code 显示的是空的编辑器组,底色是 editorGroup.emptyBackground。实测那张截图里最大的区域是 #0B0615,而真正的 editor.background 只在 120 万像素里占了 49 个。从那张图得出任何关于编辑器可读性的结论,都是在说用户开机时看一秒钟的那个画面。

所以 vscode-session.mjs 加了 --open <file>。工作区里也加了 src/slots.rs / slots.ts / slots.css —— 一个文件把每个语法槽都用一遍,因为 tokenColors 是没法在构建期断言的:一条 TextMate 规则是意图,语法拿它怎么办由语法决定。

权威来源,和每个工具能证明什么

问题 权威 工具
主题被解析成了什么 state.vscdb 的 colorThemeData / iconThemeData —— VS Code 自己写下的 vscode-session.mjs
窗口长什么样 PrintWindow 拿到的窗口自身像素 capture-window.mjs + inspect-window.mjs
屏幕上的文字对比度是多少 同一张截图里的字形像素 measure-text.mjs
声明的一对颜色够不够 主题文件本身 audit-pairs.mjs

verify-screenshot.mjs 的解码器还有用,但它的头部写明了它不能证明什么 —— 它曾经给出一张「四个 chrome 颜色一个都没有」的截图,而同一个会话的数据库里四个全在。

墨色是解算的,不是选的

  • 颜色从侧边栏出发向外走,直到超过 4.5:1。家族决定色相,表面决定明度。
  • 对着表面解算而不是对着固定明度,是同一条规则能同时服务近黑与近白侧边栏的原因 —— 也正是第二版丢掉的东西:它对着浅色底板解算,于是那些颜色在深色侧边栏上发白。

用的是 4.5:1 正文底线,而不是 3:1 非文本底线 —— 资源管理器的一行经常只有图标和名字两个线索,而眼睛先落在图标上。按图形底线要求它是守了标准的字面、错了标准的意思。实测 7 个墨色 × 4 情绪 × 2 模式,对侧边栏 5.97–7.39:1。

此外任意两个墨色之间必须够远:audit-palette.mjs 量每一对,近到「一个颜色两个名字」就失败。阈值 0.030 是从算术推出来的,不是选的 —— 七个色相在色环上平均相隔 51°,在这个彩度下能拿到的最大值就是 0.034,底线设在它上面就是一个永远不可能通过的检查(早先有一版正是如此,它在调色板完全没问题时让 8 个主题一起失败)。

每个字形都会被测量和适配

字形画在 24 单位空间里,但不会铺满它。每个字形先量出自己的包围盒,再居中并缩放到 18 单位的框里(留 4% 边距),外加笔画的一半 —— 不然斜线的圆头会越出边界。

为什么不是统一缩放:统一缩放假设每个字形都画到同样的边界,而它们没有 —— minus 是一条 13 单位高 0 的线,star 是 16 单位的星芒,database 直接画到了 22 单位。这一版之前试过两个常数,都留下一批字形要么越界要么偏心两个单位。

文件夹剪影走同一套适配。 它的原始几何是 21.29 × 19.38,比文件的 18 × 18 大五分之一 —— 照原样画出来就是「文件夹是另一种尺寸的东西」,而不是层级。适配到同一个框之后,它和文件一样大,展开时也不会跳。

笔画粗细是在放置后的单位里规定的(2.25),每个字形除掉自己的适配比例。修之前实测是 1.62–3.02,两倍的差距 —— 一套图标里某个图标的线比邻居粗一倍,读起来就是「糙」,但说不出糙在哪。

node tools/audit-glyphs.mjs 量 189 个字形:全部在框内、居中偏移 0.00、可见笔画 2.14–2.30 单位(16px 下 1.43–1.53 设备像素)。


审计

node tools/build-catalog.mjs      # 重建 catalog / motion / 审计报告

744 组关系,8 个主题,全部通过(0 失败 / 0 未解析)。报告在 dist/audit.json,扩展里用 熔铸: 查看当前情绪 WCAG 审计报告 打开。

阈值按 WCAG 2.1:正文 4.5:1,非文本图形 3:1。少数几项不是 WCAG 项,单独标注:

  • 侧边栏与编辑器的分色要求 ≥ 1.035:1。这里刻意不要求 3:1 的分隔线 —— VS Code 自己的 2026 主题把这条线画在 1.14:1,要求 3:1 的硬边会把窗口画成表格。真正区分两个区域的是面色不同,所以审计的是面色。
  • 表面之间的可辨度(面板/标题栏/活动栏/代码块/终端各自对画布)各有一条最低要求。这类失败是 WCAG 形状的检查看不见的:两块面若解析成同一个值,上面所有审计照样全绿,而窗口丢掉了全部结构。

审计过程中修掉的真实缺陷(每条都由报告指出,不是目视发现):

# 缺陷 症状
1 16 色 ANSI 直接复用状态色 终端底色与画布不同,浅色主题下绿/黄/蓝只有 3.3–3.9:1,git status 和 ls --color 反而最暗
2 悬浮窗边框按画布取步长 悬停面本身已被抬起,边框白得负收益:暗色 1.22:1、浅色 1.11:1,一像素线看不见
3 弱化文字只对画布解算 浅色模式下面板比画布更深,git ignored 与弱化标签掉到 4.33:1
4 状态栏调试/错误项复用 textOnAccent Hush 浅色下只剩 3.42:1 / 3.61:1 —— 最需要一眼看清的两个状态最看不清
5 活动栏未选中图标用 textMuted 4.16–4.22:1。活动栏图标没有文字标签,按正文标准要求
6 终端光标对画布解算 3.0 底线差 0.03,Dawn 浅色
7 图标字形色候选没做色域映射 求解器请求 L 0.97 却发出偏暗的颜色,19 个家族全部只到 3.3–4.0:1
8 图标板子共用同一个明度 高彩度家族只有 2.6–2.8:1,改成按家族解算后全部过线
9 色相环没做间距约束 language 与 markup 只差 10° 且明度彩度全同 —— 一个颜色两个名字,比只有一个家族更糟,因为图例说它们是两个
10 适配算出了比例却没用上 glyph() 算对了粗细,却交给按标称粗细构建的表,于是每次都发出标称值
11 四个家族一个图标都没有 声明十个家族,实际只有六个发运;.html/.css/.sql/.sh 全是同一个橙色。没有任何检查失败 —— 见上文「十个家族曾经是谎话」
12 打包器把自己的上一次输出打进了包里 包写在 dist/,而 dist/ 在包含列表里。每重建一次就把上一个 .vsix 装进新的:2.52 → 5.09 → 7.45 MiB。安装完全正常,什么也不报 —— 一个每次构建都变大的包,只会以一个没人在看的数字报告自己

第 7 条值得单独说:它当时的报错信息是「the tile chroma is the thing to move」,而这句话是错的 —— 板子没问题,是求解器发了一个自己没要求的颜色。

第 10 条也值得记:两趟适配在数学上是对的,但它把结果喂回了一张用旧粗细构建的表。任何测量工具都只会告诉你「粗细不一致」,不会告诉你哪一行错了 —— 是把发出的 stroke-width 打出来才看见的。

第 11 与第 12 条是同一类:一个通过了的检查,和一个没被问的问题。第 11 条里,「每个引用都能解析」全程为真,而「每个声明的家族都被用到」从来没人问。第 12 条里,--check 也是绿的 —— 它拿构建出来的包和盘上的文件比,而两次运行都装着上一个包,所以两次一致。要断言的是入口列表的性质:包由源码树决定,不由 dist/ 里恰好躺着什么决定。


目录

vscode/extension/
├─ package.json            扩展清单:8 主题 + 4 图标主题 + 6 命令 + 6 设置项
├─ catalog.json            生成:情绪目录(主题 id、名称、路径、种子)
├─ motion.json             生成:动效预设(4 情绪 × 4 档)
├─ themes/<mood>/<mode>.json     生成:8 个配色主题,自包含
├─ icons/<mood>/                 生成:4 套图标主题 + 230 个 SVG
├─ dist/audit.json         生成:744 组对比度审计结果
├─ src/
│  ├─ extension.mjs        运行时:选情绪、成套应用、动效、审计面板、还原
│  └─ motion.mjs           读取生成的动效预设
└─ tools/
   ├─ build-themes.mjs     配色主题生成器
   ├─ build-icons.mjs      图标生成器
   ├─ build-catalog.mjs    catalog + motion + 审计报告
   ├─ validate.mjs         结构、一致性、覆盖完整性
   ├─ find-duplicates.mjs  重复键扫描
   ├─ audit-glyphs.mjs     量 189 个字形的框、居中、笔画粗细
   ├─ audit-palette.mjs    量 7 个墨色 × 4 情绪 × 2 模式的色距与对比度
   ├─ audit-surfaces.mjs   量 chrome 阶梯:每一级是否是可分辨的一步
   ├─ audit-glyph-detail.mjs 量每个字形的结构量,按「用它的 kind 数」加权排序
   ├─ check-glyph-paints.mjs 每个 paint 值必须是一个颜色
   ├─ audit-families.mjs   每个声明的家族是否真的可达
   ├─ render-icons.mjs     把真正发运的 SVG 渲染成图(判断用)
   ├─ render-zoom.mjs      少量图标渲染到 200px(看清细节用)
   ├─ data/workbench-keys.json  从 VS Code 1.140 提取的 997 键注册表
   ├─ lib/                 色值推导、VS Code 映射、语法表、图标字形与色调
   │  └─ svg-raster.mjs    真正的 SVG 路径光栅化器(见下)
   └─ test/artifact.test.mjs

src/ 里没有构建步骤,是纯 ESM JavaScript(engines.vscode >= 1.85 起支持)。tools/ 只在构建期运行,运行时不需要。


构建与验证

cd vscode/extension
node tools/build-themes.mjs      # 配色主题
node tools/build-icons.mjs       # 图标
node tools/build-catalog.mjs     # 目录、动效、审计
node tools/build-extension-icon.mjs
node tools/validate.mjs          # 全部检查
node tools/audit-glyphs.mjs      # 189 个字形的几何
node tools/audit-palette.mjs     # 7 个墨色的色距与对比度
node tools/audit-surfaces.mjs    # chrome 阶梯的每一级
node tools/audit-glyph-detail.mjs # 哪些字形画得还不够
node tools/check-glyph-paints.mjs # 每个 paint 值都是颜色
node tools/audit-families.mjs    # 十个家族是否真的都有文件落到
node --test "tools/test/**/*.test.mjs"

生成器都支持 --check:不写盘,若产物与设计系统不一致则以非零码退出。CI 用这个形式。

关于「判断图标好不好看」这件事

这个仓库的构建回路里没有眼睛 —— 模型不接受图像输入。所以一个设计师用眼睛做的判断,这里是量出来的:

  • audit-glyphs.mjs 量字形是否越界、是否居中、笔画粗细是否统一;
  • audit-palette.mjs 量任意两个家族的颜色是否近到「一个颜色两个名字」。

这两个工具各自抓到过真缺陷(README 审计表第 9、10 条),但它们不声称能判断美丑。0.020 的色距底线是下界不是认证:十个家族在色环上平均相隔 45°,在这个彩度下能拿到的最大值就是 0.025 —— 底线设在它上面就是一个永远不可能通过的检查,而这一版的第一稿正是这么写的,结果它在调色板完全没问题的情况下让全部 8 个主题一起失败。

同时渲染这条路是通的,只是不能由我来看:render-icons.mjs 用 svg-raster.mjs 把真正发运的 SVG 光栅化(不是替身形状 —— 早先那版用替身,所以它画出糊块时,无法判断是 SVG 糊还是替身糊)。跑 node tools/render-icons.mjs 就会得到一张给眼睛看的图。

svg-raster.mjs 是刻意做小的:只支持字形库用到的那部分 SVG(M/m L/l H/h V/v C/c S/s Q/q T/t A/a Z/z 加 fill/fill-rule/stroke/rect rx/circle)。遇到子集之外的元素它会报出来而不是跳过 —— 静默跳过会画出一张和文件不一致、但看着没问题的图。

真的跑一个 VS Code 来验

node tools/pack-vsix.mjs                 # 先打包,harness 装的是包而不是源码树
node tools/vscode-session.mjs            # 一个主题,跑完自动关窗
node tools/vscode-session.mjs --all      # 8 个主题逐个验
node tools/vscode-session.mjs --keep     # 留着窗口自己看
node tools/inspect-session.mjs <dir>     # 查看某次 --keep 留下的档案

脚本会拉起一个一次性的 VS Code:独立的 --user-data-dir 与 --extensions-dir,不动你自己的设置与已装扩展;把打包好的 .vsix 用 code --install-extension 装进去;工作区里预先写好 workbench.colorTheme / workbench.iconTheme,窗口一起来就是这套主题。

然后它验五件事:

检查 证据来源
扩展被激活 扩展写进自己日志目录的 activation.json
6 个命令都注册了 同一个报告里的 smelt.* 列表
目录与动效预设读到了 4 情绪 / 8 主题 / 31 项动效设置
VS Code 真的解析了配色主题 state.vscdb 的 colorThemeData 带上本扩展的 settingsId 与 label,且有 themeTokenColors
VS Code 真的解析了图标主题 同一个库的 iconThemeData 带上 smelt-icons-* 的 settingsId,并已生成图标样式表

这两条是关键,也是任何读文件的测试都够不到的:主题值如果 VS Code 认不出来,它会静默保留上一个主题,不报错、不提示。只有从状态库读回已解析的记录,才能说这个主题真的生效了。

这一步抓到的三个真实缺陷

三轮里所有文件层面的检查(结构、清单一致性、744 组对比度、--check 幂等)一直是全绿的。这三个只有真跑起来才看得见:

# 缺陷 为什么文件检查看不见
1 workbench.colorTheme 写的是显示名,不是标识符 VS Code 拿这个字符串去比对已注册的 id。写 label 时 8 个主题全部「贡献成功、选择器里能看见、永远不会生效」——它存储这个没人匹配的字符串并保留原主题,不报任何错。实测:写 label → 解析成 smelt-forge-dark(第一个贡献);写 id → 解析成 smelt-dawn-light
2 --extensionDevelopmentPath 与安装态行为不同 开发模式下 只解析第一个贡献,其余全部回退到它。所以用开发路径跑的 harness 永远不可能通过 —— 更糟的是它也不可能抓到缺陷 1,因为「回退到第一个主题」和「主题生效了」长得一模一样。改成装 .vsix 之后才两条都成立
3 图标主题的持久化键是 iconThemeData,不是 fileIconThemeData 是我读错了键名。它表现成一个稳定、可信、每次都失败的检查:主题 id 在文件里找得到,于是「图标被提到过」,但那个键永远不存在,报告把责任推给 VS Code

还有一个同类问题:harness 一开始用固定 sleep 等状态库写入,轻载时能过,--all 连跑 8 个时 8 个全挂。改成轮询后,慢机器只是慢,不再是假失败。

--all 会逐个验全部 8 个主题 —— 单跑一个的话,themes/hush/light.json 里写错一个名字或 dawn 少一份图标清单,照样会发出去。

眼睛能看的那一部分

node tools/render-icons.mjs              # 真正发运的 SVG,88px,按家族分行
node tools/render-zoom.mjs               # 少量图标 200px,看曲线与笔画
node tools/contact-sheet.mjs             # 原始 SVG 接触印相表 (dist/icon-contact-sheet.html)
node tools/capture-window.mjs out.png --pid <pid>   # 抓一个真实窗口

「16px 下还认得出来吗」是渲染后由眼睛回答的问题 —— 文件大小、路径正确性、对比度全部能在糊成一团的图标上通过。

前两个走 svg-raster.mjs,渲染的是发运的那份 SVG。早先这里有个 raster-proof.mjs,用形状谓词近似字形轮廓 —— 它的头部也老老实实写了这一点,但「老实标注」并不等于「够用」:它画出糊块的时候,没法判断是 SVG 糊还是替身糊。那个文件已经删掉了,连带只服务它的 build-proof-data.mjs 与 tools/data/icon-proof.json。

contact-sheet.mjs 渲染的也是发运的 SVG 本身,只是交给浏览器,所以完全不经过这套光栅化器。

与设计系统的关系

配色不在这里定义。tools/lib/smelt-imports.mjs 是唯一知道 server/ 在哪的文件,推导、对比度、种子全部从上游设计系统 import —— 上游 714 项测试所锁定的那套常量,就是这里发运的值。

本扩展只加了两层:

  1. derived-roles.mjs —— 把宿主认识的 50 个 token 展开到 VS Code 需要的约 200 个角色(悬停叠层、装订线、概览标尺、16 色 ANSI、六级括号…)。全部是算术,没有一个手写字面量。凡是 token 解的底不是该角色实际落笔的底,就重新解算,并在构建日志里说明移动了多少。
  2. workbench-map.mjs —— 一张 981 行的表,不是程序。值要么是角色名,要么是字面量加一句为什么不能是角色。

已知边界

  • 图标主题不做明暗自动切换。 VS Code 的图标解析拿不到工作台明暗,所以一个图标主题只能是一份清单。扩展的成套应用会挑对应的那份;手动只换配色主题时,图标不跟着变(这是 VS Code 的行为,不是遗漏)。
  • 浅色图标集与深色图标集是两套文件,后缀 -light。板子明度不同,不是同一张图换个色。
  • 未注册的键一个都不设置(见上)。因此极少数第三方扩展自己的颜色键(例如 Error Lens)不由本主题提供。
  • 动效只写用户级(除非显式打开工作区级)。还原 清掉的是本扩展写过的键,不动你原本的值。
  • 代理 / 聊天 / modern* 是 1.140 新增家族,键名最不稳定。它们全部走角色引用,所以改名会在构建期失败;但更旧的 VS Code 上这些表面不会被着色(旧版会静默忽略未知键)。

商标边界

Rust 基金会 logo 与 Rust 语言齿轮标识是注册商标,本主题不使用、不复刻、不近似。Ferris 螃蟹为 CC-BY。齿轮元素若出现,只作为 Ferris 背壳的一部分。配色出处见仓库 references/rust-brand.md 与 references/yaru-26.04.md。

许可

MIT(代码)。颜色与图标的设计出处见上。

  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
  • Your Privacy Choices
  • Consumer Health Privacy
© 2026 Microsoft