# Add Icon

> 往本项目 `src/client/bui/icons/` 的图形注册表加图标，或替换某个语义位现有的图形。图标来源是 central icon system（centralicons.com）。两种输入都管：用户直接粘 SVG（主路径），或者只描述语义（技能按语义给候选名让用户去导）。只要用户提到图标、icon、换图标、加图标、图标太细/太粗/看不清/不好看、central、centralicons，或者点名某个具体图形（sparkle、brain、magnifying-glass 之类），就用这个技能——即使他们没说「注册表」或「bui/icons」，也即使听起来只是改一行 path。它管住五件会静默出错的事：fill 版 SVG 喂进去会渲染成「轮廓的轮廓」、非 24 坐标系漏写 viewBox 会让图形爆出画布或缩成一点、导错 weight 档会拿到为别的线宽重绘过的 path、一个语义位常有多个消费点（漏一处就是同一概念两种图形）、以及新图标的视觉线宽与同列图标不齐而代码看起来完全正常。

- Skill: `jeasonstudio/add-icon` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add jeasonstudio/add-icon`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jeasonstudio/add-icon/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: jeasonstudio (https://skillmd.com/u/jeasonstudio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jeasonstudio/add-icon

---


# 往 `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`：

```bash
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 有单行与多行两种写法：

```bash
# 单行写法：<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。

⚠️ **三处都是踩出来的，别简化**：

1. **只跑多行那条会漏掉一半以上。** `<Icon$` 锚在行尾，只匹配多行 JSX；而 `TodoRows` /
   `ContextCards` / `CodeBlock` / `SearchResult` / `MessageActions` 全是单行写法。写这份技能时
   只写了多行那条，实测输出 5 行而真实是 12——差的那 7 行正是 2026-08-27 新收编的装饰图标。
2. **单行那条必须 `grep -vE ':[[:space:]]*\*'` 排掉注释**：`glyphs.tsx` / `Icon.tsx` /
   `ToolChips.tsx` 的 docblock 里都写着 `` `<Icon name="…" />` `` 这样的散文，会被命中。
3. **不要用 `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>`，那就是漏搬的一处，顺手搬它。查残留：

```bash
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` 语义名——那才是那些断言真正想守的（「这一行用了正确的语义
位」），钉在图形数据上只是当年没有更好的探针。查法：

```bash
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` 里两处，缺一个测试就红：

1. **`EXPECTED`** 加图标名。这张表的作用是「悄悄多一档」会被拦住——图标一多就没人记得表里
   到底有什么了。
2. **`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 步：验证

按这个顺序，每一步都拦不同的东西：

```bash
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` 在跑）：

```bash
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宽 × 渲染尺寸`。眼下全表同一个比值，所以线宽差异**只来自槽位尺寸**——
但混进任何其他来源时这条就重新要紧。

新图标很可能与同列的既有图标不齐，而这件事在代码里完全看不出来。**用对照组量，不要靠主观
判断**——绝对数字没有对照组等于没有意义：

```js
// 在 .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 里）

