往 bui/icons/ 加图标
图形的唯一真源是 src/client/bui/icons/glyphs.tsx,渲染入口是同目录的 Icon.tsx。
动手前先读那两个文件的头部 docblock——它们写着坐标系陷阱、默认值取 central 的理由、
以及为什么 key 用语义名。这份技能是流程和陷阱,那两个文件是约定本身。
⚠️ 先读这条:central 是付费图标集,许可与本仓库冲突
图标来源是 central icon system(centralicons.com,iconists 出品)。它不是开源图标库, 这件事决定了下面整个获取流程的形状,也决定了几条「看起来能自动化但不要做」的事。
iconists.co/license 与 npm 包内 LICENSE.md 的 Forbidden Uses 有两条字面命中本仓库:
Do not share the Item or its parts publicly on the web——本仓库是公开的 GitHub 仓库,glyphs.tsx里的 path 就是「its parts publicly on the web」。Extraction: End users cannot extract the Item for separate use——path 是明文源码, 谁都能复制走。
这个冲突已经在 2026-08-28 由人裁决「知情保留」,账记在 bui/README.md 的偏离表里。
所以:不要再把这件事当成新发现重新提出来阻塞流程,也不要「顺手」把图标换成开源库。
但也不要扩大暴露面——加图标时只加需要的那一档,不要囤积、不要建一个「备选池」,
不要把候选的 path 留在注释或测试 fixture 里。
这条纪律防的是「在仓库里攒出一份可复用的图标库副本」。所以它不禁止为技术判据留片段化的
对比(本文件与 glyphs.tsx 里各有几处 M4.75 12.7768… 那样的截断片段,用来证明「各 weight
的 path 是重绘的」)——那是判据的唯一证据,删掉它下一个人就会去改 strokeWidth 凑数。
边界是:片段、服务于一个具体判据、不构成可用的图形。整条可渲染的 path 只应出现在
glyphs.tsx 里我们真正在用的那 13 档。
许可里有一条数量上限是当前不构成问题的,记在这里免得下一个人重新查:
Icon Limits: 不超过 300 icons per style。眼下 13 档,离上限很远。
三条获取途径,只有第一条该走
| 途径 | 能不能用 | 说明 |
|---|---|---|
web app 逐个 Copy as SVG |
✅ 主路径 | 免费额度 20 个导出,覆盖眼下 13 档。不需要账号付费。 |
npm @central-icons-react/<variant> |
⚠️ 需付费 key | 包内 license-check.js 读 CENTRAL_LICENSE_KEY,缺了直接 throw,并向 centralicons.com/license/check 验证。且它是 React 组件不是裸 SVG(36MB / 2088 个组件),为 13 个图标装它不划算,仍要自己抠 path。 |
抓 centralicons.com/icons-data/*.bin |
❌ 不要做 | 那是加密数据(412KB 高熵二进制、无 magic bytes、非 gzip/zstd/brotli),是刻意的付费保护。写解密脚本等于绕过技术保护措施。试过了,不要再试。 |
⚠️ 不要去 fetch centralicons.com 首页想找图标数据——它是 Next.js SPA,2.49MB 里那 31 个 内联 svg 是网站自己 UI 用的 central 图标(顺带确认了规格),不是图标库数据。
central 的规格(写进注册表前必须对上)
官网口径 + 实测一致:
- 坐标系
0 0 24 24,2px padding,20×20 live area(坐标基本落在 2–22) stroke-linecap: round+stroke-linejoin: round(Icon.tsx无条件给,不用自己写)- 三档 weight:
stroke-width= 1 / 1.5 / 2 - 5 种 corner style、line/solid 两种填充 → 官网说的「30 variants」
web app 的 UI 标签与包名/文件名的对应
两套命名不同,挑 variant 时容易对错:
| web app UI | 包名 / icons-data 文件名片段 |
|---|---|
Style line / solid |
outlined / filled |
Stroke 1px / 1.5px / 2px |
stroke-1 / stroke-1.5 / stroke-2 |
Corner 0px sharp |
square-…-radius-0 |
Corner 0px round / 1px small / 2px medium / 3px large |
round-…-radius-0 / -1 / -2 / -3 |
本项目钉在 round-outlined-radius-2-stroke-2,即 web app 里
Style = line、Stroke = 2px、Corner = medium。
⚠️ Style 要选 line 而不是 solid。 这与下面「fill 版会毁掉整件事」是同一件事的两个
说法——solid 导出的是 fill 版。
为什么钉 stroke-2 而不是默认的 1.5
这是实测判据不是审美偏好,而且它复用了项目已有的一次实测结论:
视觉线宽 = strokeWidth ÷ viewBox宽 × 渲染尺寸。本项目槽位尺寸有 9/10/11/13/14/15px 六种,
最小的 9px 槽是 ContextCards 的外链。
| weight | 比值 | 9px 槽实际线宽 |
|---|---|---|
central 1 |
1/24 = 0.0417 | 0.375px |
central 1.5(web app 默认) |
1.5/24 = 0.0625 | 0.5625px |
central 2 |
2/24 = 0.0833 | 0.75px |
glyphs.tsx 头部记着:Phosphor regular 的 0.56px 在 9px 槽「暗色下抗锯齿明显发虚」因而被
否掉。central 的 1.5 档换算出来是 0.5625px——选它等于重新踩回那个已经实测否过的坑。
2 档的 0.75px 有余量。
⚠️ 不要为了迁就某一个槽位而混 weight。 同一张表里混两个 weight 正是 2026-08-27 那次整套
收编要治的病(当时全站 7 种 strokeWidth、视觉线宽跨度 129%)。要调就整表调。
⚠️ weight 必须导对,不能靠改 strokeWidth 换算
central 各 weight 的 path 是重绘的,不是同一份 path 配不同 stroke-width。 端点会内缩,
以便在更粗的描边下仍然守住 20×20 live area 的边界。2026-08-28 实测:
| 图标 | stroke-1.5 档 |
stroke-2 档 |
|---|---|---|
checkmark-1 |
M4.75 12.7768L10 19.25L19.25 4.75 |
M5 12.75L10 19L19 5 |
list-bullets |
M8.75 6L20.25 6 … |
M10 6L20 6 … |
chevron-bottom |
M20 9L13.4142…L4 9 |
完全相同 |
注意第三行:有些图标两档一模一样(端点不在 live area 边界上的那些)。所以「我对比了一个 图标,发现两档相同,那 weight 无所谓」是错的推论——你恰好挑中了不受影响的那个。
判据:在 web app 里先把 Stroke 切到 2px,再逐个复制。不要导出 1.5 档然后在
glyphs.tsx 里写 strokeWidth: 2——那会拿到为 1.5px 描边算过的端点位置。
先分清这是哪一类活
判据是「注册表里有没有一个 key 已经在表达这件事」:
- 换现有语义位的形状(多数情况):改那一条的
content,key 不动。比如「思考过程的图标 换成 sparkle」——think这个 key 一直在,换的只是它长什么样。 - 加新语义位:往表里加 key。这件事比换形状重一档,因为它意味着「流里多了一类行」。
加之前先回答:这一类行凭什么与别的行不同? 注册表有一条被测试守着的判据——图标必须
携带信息(
ToolChips.test.tsx的「八档图标互不相同」)。如果新 key 只是同一类行的另一种 说法,那它不该存在;如果它确实是新的一类,那ToolRowIcon之类的消费方类型也要一起动。
不确定属于哪一类就问用户,别自己扩表。
第 1 步:拿到 stroke 版 SVG
⚠️ 这一步唯一会毁掉整件事的错误是拿到 fill 版。Icon.tsx 强制 fill="none" 并用
stroke 渲染,喂 fill 版进去画出来的是「轮廓的轮廓」——两条平行细线勾着图形的边,而不是
一个图标。它不报错,只是难看,而且很容易被当成「这个图标本来就长这样」。
判据:SVG 里有 stroke-width 就是 stroke 版;只有 fill="currentColor"(或
fill-rule/clip-rule)加一个 path 的是 fill 版。在 central 这边对应
Style = line(对) vs solid(错)。
输入形态 A:用户直接粘了 SVG(主路径)
直接用,但先过校验脚本——它一次把 fill/stroke、坐标系、weight、live area 全查掉,
并输出可直接贴进注册表的 content:
python3 .claude/skills/add-icon/scripts/central.py < /tmp/icon.svg
# 或者直接管道:pbpaste | python3 .claude/skills/add-icon/scripts/central.py
纯本地、无网络。它做四件事:剥掉由 Icon 写到 svg 根上的描边属性(stroke /
stroke-width / stroke-linecap / stroke-linejoin)与 <rect fill="none"> 占位、
判 fill 版、报比值与六个槽位的视觉线宽、查坐标是否落在 20×20 live area 内。
输入形态 B:用户只给了语义
不能自己去搜——图标数据取不到(见上面那张三途径表)。能做的是给候选名,让用户去 web app 导:
references/central-slots.md 里有眼下 13 个语义位各自的候选名与选定理由。要找表外的新语义
位时,central 的命名同时收语义词与符号词(aria-label 形如
pencil, edit, write),所以按用途搜也能命中——但你查不到它,得让用户在 web app 的 ⌘K 里搜。
⚠️ 挑好之后不要直接落地。 图标选择是审美与语义判断,不是技术判断:brain-1 和 sparkle
都能表达「推理」,但一个像医学示意图、一个像 AI 味的装饰,选哪个是产品口味。取 2–3 个候选、
说清各自的语义联想,让用户定。
第 2 步:找出这个语义位的全部消费点
⚠️ 一个语义位常有多个消费点。 2026-08-27 换 think 那次,同一份图形同时活在
bui/ToolChips.tsx 的行图元位(工具名含 think/reason/plan 的兜底档)和
bui/ThinkingState.tsx 的折叠头里。只改一处的后果是同一个概念在紧邻交替出现的两种行里
长着两个样子——读起来像渲染 bug。
搬进注册表之后这件事大部分自动解决了(多个消费点读同一条),但仍然要确认。列出全部
消费点的 name——两条都要跑,因为 JSX 有单行与多行两种写法:
# 单行写法:<Icon name="check" />
grep -rn '<Icon[^>]*name=' src/client --include='*.tsx' | grep -v '\.test\.' | grep -vE ':[[:space:]]*\*'
# 多行写法:<Icon 换行 name={icon}
grep -rn -A2 '<Icon$' src/client --include='*.tsx' | grep -E 'name='
2026-08-28 复核,两条合起来是 12 行,分布在 8 个组件:CodeBlock 2(code + 两态的
check/copy)、ContextCards 2(context + external-link)、MessageActions 1(glyph
变量 → check/copy/fork)、RunHeader 1、SearchResult 1、ThinkingState 2、TodoRows 1、
ToolChips 2。
⚠️ 三处都是踩出来的,别简化:
- 只跑多行那条会漏掉一半以上。
<Icon$锚在行尾,只匹配多行 JSX;而TodoRows/ContextCards/CodeBlock/SearchResult/MessageActions全是单行写法。写这份技能时 只写了多行那条,实测输出 5 行而真实是 12——差的那 7 行正是 2026-08-27 新收编的装饰图标。 - 单行那条必须
grep -vE ':[[:space:]]*\*'排掉注释:glyphs.tsx/Icon.tsx/ToolChips.tsx的 docblock 里都写着`<Icon name="…" />`这样的散文,会被命中。 - 不要用
grep 'name="<语义名>"'代替它们。 那条会漏掉动态消费点:ToolChips写的是name={icon}、MessageActions写的是name={glyph}、CodeBlock写的是name={copied ? 'check' : 'copy'}。搜name="think"只命中ThinkingState.tsx,而ToolChips完全不出现——恰恰是这两处曾经各写一份think图形。也不要写成grep '<Icon' | grep 'name={':多行写法里<Icon和它的name不在同一行,单行管道匹配 不到任何真实代码,只会命中那些散文——那条 grep 看起来跑通了、输出了两行,其实全是注释。
看到 name={变量} 就往上追那个变量的取值来源(ToolChips 是 ToolRowIcon 类型 +
lib/tools.ts 的 KIND_ICONS / ICON_PATTERNS;MessageActions 是 IconButton 的 prop),
确认这个语义名在不在里面。
如果发现某个消费点还在内联 svg 而不是走 <Icon>,那就是漏搬的一处,顺手搬它。查残留:
grep -rn "<svg" src/client --include="*.tsx" | grep -v "\.test\." | grep -v "icons/"
这条应该只输出一行:TodoRows.tsx 那个进度环。它刻意不搬——判据是「这个 svg 是一个固定
形状,还是由数据算出来的」,那个环带 strokeDasharray 与旋转动画,是状态指示器而不是图标。
多出别的行就是有人又内联了一个。
第 3 步:写进注册表
glyphs.tsx 的 REGISTRY 里加一条或改一条 content。规则:
| 情形 | 怎么写 |
|---|---|
central round-outlined-radius-2-stroke-2(24 坐标系、stroke-width="2") |
viewBox 与 strokeWidth 都省略——默认值就是这一对 |
| 其他任何来源(含 central 的其他 weight/corner) | viewBox 与 strokeWidth 都显式写 |
| 多个子元素 | content 包一层 <>…</> |
描边属性不写进 content。 它们是 Glyph 的字段,由 Icon 写到 svg 根上(SVG 的
presentation attribute 沿 DOM 树继承)。往 content 里塞 stroke="currentColor" 会让那个
元素不再跟随图标槽的 text-* 颜色——ThinkingState 的 working 态变色就会失效。
⚠️ central 的导出带 stroke="currentColor" 和三个描边属性写在 <path> 上,比 Phosphor
官网的复制结果更「脏」。scripts/central.py 就是为了这一步存在的,不要手工剥——漏一个
stroke-width="2" 在子元素上会覆盖根上的值,而那在代码里看起来完全正常。
每条都写注释说明图形来自哪里。 写清 central 的图标名(aria-label 的第一段,如
checkmark-1)、谁指定的、什么时候、替掉了什么。这个目录的全部价值建立在「每个视觉决定都能
追溯」上,一条没有出处的图形会让下一个人不知道能不能改它。
换掉一档的形状时,检查钉在旧形状上的断言
⚠️ 不同图标库用的绘制元素不同。 Phosphor 大量用 <polyline> 与 <line>;central 几乎
全用 <path>(三横线那种也是三个 <path> 而不是三个 <line>)。仓库里那些
querySelector('svg path')?.getAttribute('d') 式的断言会因此从 null 变回有值——方向与
2026-08-27 那次相反,但同样要逐个复核。
它们几乎都该改成 data-bui-glyph 语义名——那才是那些断言真正想守的(「这一行用了正确的语义
位」),钉在图形数据上只是当年没有更好的探针。查法:
grep -rn "getAttribute('d')\|querySelector.*svg path\|circle')" src/client --include='*.test.tsx'
⚠️ ⚠️ 测试全绿不代表断言都还有意义。跑完那条 grep 要逐个看,别只修报红的。 2026-08-27 换套时报红 9 条,修完之后那条 grep 又找出两条没报红而判据已经失效的(当时一并修成了语义名, 这里记的是失效长什么样,不是现在还坏着):
ToolNode的「图标按工具类型区分」比较两边第一个<path>的d。当年 Bash 落run档而 Phosphor 的 terminal 没有<path>,于是一边是undefined、一边是真的d,「不同」成立 ——它通过是因为一边读不到东西,不是因为两个图形真的不一样。NoticeNode断言 retry 图标的第一个<path>的d是 truthy。恰好arrows-clockwise里有<path>所以还通过,但换成任何纯<line>/<polyline>的图形都会红,而那时的红是误报。
2026-08-28 换 central 时这条 grep 的实际收获
上一轮改成 data-bui-glyph 的那批一条都没红——语义名探针确实对换图标库免疫,那轮的
判据得到了验证。唯一要动的是 ToolChips 的放大镜:它当年没改成语义名,而是改成钉绘制
元素(「有 <circle> 有 <line>」),注释里写着「对换到另一套图标库免疫」——而 central 的
放大镜是两个 <path>(圆和手柄都不用专门元素画),证明那句话是错的。
教训:「钉绘制元素」比「钉具体 d」只强一点点。 现在它钉的是拓扑:两个部件、一个闭合
一个不闭合,<circle>/<line>/<path> 只是画同一个图形的三种手段。
判据(按稳定性从高到低):
| 断言问的是 | 用什么探针 |
|---|---|
| 「这里是哪一档」 | data-bui-glyph 语义名 ← 默认选这个 |
| 「两档图形是否相同」 | svg 的 innerHTML(不关心用什么元素画) |
| 「这个图形长什么样」 | 拓扑(几个部件、闭合与否),不是元素名 |
最后一类只在图形本身就是这一档存在理由的时候才值得写(放大镜那条是这一类:那一档存在的 全部理由就是「图标要携带信息」)。
CodeBlock 的图标要透传 data-bui-md
bui/CodeBlock 会作为围栏代码块出现在 markdown/Prose 的渲染树里,而那棵树有一条穷尽性断言
要求每个元素都带 data-bui-md(markdown/elements.test.tsx)。所以那三处 <Icon> 要写
data-bui-md=""。
图标内部的 <path> / <line> 已经在那条断言里豁免了(2026-08-27 加的,图标根仍受检查)
——判据是「这个属性在说什么」:它声明的是「排版归我们的覆盖表管」,而图标内部没有排版可言。
不要为了讨好那条断言往注册表的 content 里加 data-bui-md,那会把一个只服务单个消费者的属性
铺到全表。
第 4 步:登记两张对账表
glyphs.test.tsx 里两处,缺一个测试就红:
EXPECTED加图标名。这张表的作用是「悄悄多一档」会被拦住——图标一多就没人记得表里 到底有什么了。EXPECTED_RATIO加视觉线宽比值。眼下 13 档全是CENTRAL_STROKE_2(2 / 24)这个常量, 所以这张表的作用是钉住那份齐整——任何一档偏离都会红,逼着人来说明为什么它该是例外。 混入其他来源时写成除式而不是小数(2 / 24而不是0.0833):除式让「这个数字怎么来的」 留在代码里。
⚠️ 默认坐标系从 256 变成 24,那条极值断言的失败方向反了
glyphs.test.tsx 第三条断言(「坐标极值与 viewBox 同量级」)原本只有下界 > 0.2,
守的是「24 坐标系的图形漏写 viewBox 会继承 256、缩成左上角一个点」。
默认值改成 24 之后这个失败模式不存在了,反过来的那个出现了:混进一个 256 坐标系的图形而
漏写 viewBox,它会继承 24 —— 坐标爆出画布 10 倍,只看得到左上角一小块。而那时
极值/viewBox宽 ≈ 10.7,远大于 0.2,下界拦不住。
所以那条断言必须是双侧区间。实测两簇:正确配对时全表落在 0.79–0.96;256 误配 24 时是 10.7 量级。上界取 1.5 在两簇之间且留了余量(坐标本不该超出 viewBox,只有描边会溢出, 而那不是 path 数据里的数字)。
第 5 步:验证
按这个顺序,每一步都拦不同的东西:
npx tsc --noEmit # 类型:漏档、name 写错、可选字段访问
npx vitest run src/client/bui/icons # 注册表三条守卫
npx vitest run src/client/bui # 消费点没被改坏
npm run build # 地板;watch 循环不跑 tsc
⚠️ 然后必须真看一眼渲染,明暗两种模式都看。 图标的问题几乎全是「代码完全正常但画面 不对」那一类,测试拦不住。
最省事的路径是跑这个(前提:npm run web 在跑):
node .claude/skills/add-icon/scripts/shoot.mjs
它起一个独立的 headless Chrome、开一个带代码块的会话、切到 beautiful tab、展开合并段,然后
明暗两种模式各截一张到 .icon-preview/,并在 stdout 上给一份 glyph 清点(含视觉线宽与
「游离 svg 有几个」)。用它而不是自己现写:会话项的选择器、「不要点工作区」、模拟态按连接、
两次 evaluate 这几条都是踩出来的,那个文件头写着完整的账。这个脚本与图标库无关,换库不用改它。
chrome-devtools MCP 能连上时优先用 MCP(交互能力更强),那时注意这几条(完整版见
docs/verification-traps.md):
- 暗色必须模拟系统偏好(emulate colorScheme),不要手写
color-scheme——手写只翻插件 这一半,宿主外壳不跟着变,会伪造出「深字压深底」的假 bug。 - 截图前先 eval 确认
.bui-root的colorScheme与 token 已经翻到对应分支,且宿主外壳也 翻了(document.body的背景色应是rgb(21,21,23))——过渡中的那一帧极有说服力且是错的。 - 用
dpr 3的 viewport 截图。这批图标线宽 0.75–1.25px,dpr 1 的截图看不出差异。 ⚠️shoot.mjs截的是 dpr 1,够看形状与配色,不够判线宽——要看线宽得另外走 MCP。 - 图元藏在默认折叠的合并段里,要先展开。展开与查询必须分两次 eval 调用——React 的重渲染 在当次 eval 的微任务之后,同一次调用里读到的是点击前的快照。
- ⚠️ 展开时不要按
[aria-expanded="false"]批量点。 2026-08-28 踩到:那个选择器同时命中 宿主外壳的控件,一次点了 27 个之后弹出了 dsh 的设置对话框,截图全废。这与技能第一版 栽的「点了所有看起来像目录名的 button、把工作区点折叠了」是同一类错误——在别人的 UI 上用 过宽的选择器批量操作。要么按.bui-root限定作用域(document.querySelectorAll('.bui-root [aria-expanded="false"]')),要么只点你真正要看的那一个。 这个页面是用户日常在用的 profile(npm run web写~/.dsh/profiles/web,见 CLAUDE.md), 不是隔离 fixture,误操作有真实后果。 - ⚠️ 可见性判据不能只看
width > 0。 折叠段是0fr+inert,里面的 svg 宽度仍然非零, 于是「视口内可见」会把一堆看不见的图元算进来(scrollIntoView也会「成功」滚到那里)。 三个条件一起才对:width > 0 && height > 0、!el.closest('[inert]')、在视口范围内。
只改了几档形状、不涉及布局时,还有一条更快的路:生成一个用真实 token 值的独立对比页
(light-dark() + color-scheme 两区),headless 截图。它对「图标本身在深浅底上什么观感」
答得比 dsh 页面更清楚(能把 13 档并排、标出线宽)。独立页面上手写 color-scheme 是合理的:
那条纪律防的是「只翻插件一半」,而独立页面没有宿主外壳。
第 6 步:量视觉线宽,给对照组
⚠️ strokeWidth 这个数字不是视觉线宽,也不可跨坐标系比较:写在 24 坐标系里的 2 与写在
256 里的 24 谁粗谁细光看代码答不出来(答案是几乎一样:0.083 对 0.094)。真正决定观感的是
strokeWidth ÷ viewBox宽 × 渲染尺寸。眼下全表同一个比值,所以线宽差异只来自槽位尺寸——
但混进任何其他来源时这条就重新要紧。
新图标很可能与同列的既有图标不齐,而这件事在代码里完全看不出来。用对照组量,不要靠主观 判断——绝对数字没有对照组等于没有意义:
// 在 .bui-root 所在页面 eval
[...document.querySelectorAll('[data-bui-glyph]')].reduce((acc, g) => {
const n = g.getAttribute('data-bui-glyph')
const w = g.getBoundingClientRect().width
const vb = Number(g.getAttribute('viewBox').split(' ')[2])
acc[n] ??= { count: 0, px: +(Number(g.getAttribute('stroke-width')) / vb * w).toFixed(3), box: w }
acc[n].count++
return acc
}, {})
换成 central stroke-2 之后的基线(比值 2/24 = 0.0833),七种槽位:
| 槽位 | 谁在用 | 视觉线宽 | 2026-08-28 实机 |
|---|---|---|---|
| 15px | copy / check / fork(MessageActions 的 glyph 三态) |
1.250px | ✅ 量到 |
| 14px | code(CodeBlock 头部) |
1.167px | ✅ 量到 |
| 13px | 行图元八型(ToolChips,走默认尺寸)+ think(ThinkingState)+ check(TodoRows) |
1.083px | ✅ 量到 |
| 12px | chevron(ToolChips / RunHeader / ThinkingState 三处都显式给 12) |
1.000px | ✅ 量到 |
| 11px | context(ContextCards)/ search(SearchResult 组头) |
0.917px | 推算 |
| 10px | check / copy(CodeBlock 复制按钮两态) |
0.833px | 推算 |
| 9px | external-link(ContextCards) |
0.750px | 推算 |
标「推算」的三档是那次实机会话里没有出现的元素(没有 todo 列表、没有检索片段卡、没有围栏
代码块的复制按钮),按比值算出来的。别把它们当实测报告出去——要验就得挑一个同时含这三种
形态的会话(docs/manual-test-prompts.md 里有能驱动每种形态的 prompt)。
⚠️ 同一个语义位普遍跨多个尺寸,这是第 2 步「一个语义位常有多个消费点」在尺寸维度上的 同一件事,查表时容易漏:
| glyph | 尺寸 | 分别在哪 |
|---|---|---|
check |
三种 15 / 13 / 10 | MessageActions / TodoRows / CodeBlock |
copy |
两种 15 / 10 | MessageActions / CodeBlock |
context |
两种 13 / 11 | ToolChips 行图元位 / ContextCards 组标题 |
search |
两种 13 / 11 | ToolChips 行图元位 / SearchResult 组头 |
think |
13(两处同尺寸) | ToolChips 兜底档 / ThinkingState 折叠头 |
所以「换掉某一档的形状」要在它最小的那个尺寸上验——check 的下界是 10px 而不是 15px,
external-link 的 9px 是全表下界。这也是为什么 weight 的选择由 9px 槽决定(见开头那一节)。
比值全表相同(2/24),跨度全部来自槽位尺寸——那份差异是设计意图,小图标细一点是对的,
不要试图靠改 strokeWidth 抹平(那会让每个尺寸都需要自己的一档)。
历史对照,用来判断「新的这套是不是变淡了」:
| 时期 | 比值 | 视觉线宽跨度 |
|---|---|---|
| 收编前(散在五个组件内联) | 7 种 | 0.83–1.90px(跨度 129%) |
| Phosphor bold(2026-08-27) | 24/256 = 0.0938 | 0.84–1.41px |
| central stroke-2(2026-08-28) | 2/24 = 0.0833 | 0.75–1.25px |
比 Phosphor bold 整体细约 11%,下界 0.75px 仍高于「发虚」阈值(实测 0.56px 会虚)。
这一节记在这里是因为判据比数字重要:不齐不一定要修——图形复杂度会补偿线宽(换套前
think 是全表最细的 0.81px,人裁决接受过,理由是 sparkle 有 5 个笔画而放大镜只有 2 个)。
要报告的是「实测比值 + 同屏对照 + 实际观感」,让人决定,而不是自己按数字拍板调粗。真要调,
glyphs.tsx 与 EXPECTED_RATIO 两处一起改。
第 7 步:记账
非 beautiful-ui 的图形是有意偏离真源,必须记进 src/client/bui/README.md 的
「有意偏离真源之处」→「改了值或行为的地方」那张表。写清:替掉了什么、谁指定的、日期、
以及连带删掉或改动了什么机制。
⚠️ central 这一档还要额外记许可状态(付费集 + 公开仓库的冲突 + 人裁决知情保留)。 不记的后果不是「文档不全」:下一个人查到许可条款时会以为这是个疏漏而去「修」它, 而那意味着又一次全站换套。
不记账的一般后果同样成立:那个目录的规则是「与 beautiful-ui 逐字一致」,一条没有记账的 非真源图形会被下一个人当成抄错了而「修」回去。
无源: 标记不适用于图标——那个机制标的是「真源里完全没有这个位」,而图标位是有的,
我们换的是形状。两者的区别就是这张偏离表与那份清单的分工。
快速自查
落地前对一遍,每条都对应一个真实踩过的坑:
- 拿的是 stroke 版(web app 的
line,有stroke-width),不是 fill 版(solid) - variant 是
round-outlined-radius-2-stroke-2(UI:line / 2px / medium) - weight 是导出时就选对的,不是导了 1.5 档再改
strokeWidth(path 会重绘) - 非 central-stroke-2 的显式写了
viewBox与strokeWidth -
content里没有残留的描边属性(过了scripts/central.py) - 这个语义位的全部消费点都走
<Icon>了(两条 grep 都跑过) - 换形状时检查过钉在旧
d上的断言,包括没报红但判据已失效的那些 -
EXPECTED与EXPECTED_RATIO都登记了,后者写成除式 - 极值断言是双侧区间(默认 24 之后下界拦不住 256 误配)
-
tsc/vitest/build全绿 - 明暗两种模式真看过,暗色走的是系统偏好模拟
- 量了视觉线宽比值并给了同屏对照组
- 非真源图形记进了
bui/README.md的偏离表,含许可状态那一条 - 没有扩大许可暴露面(没囤积候选 path、没留在注释或 fixture 里)