bui/icons/ add icons
The single source of truth for graphics is src/client/bui/icons/glyphs.tsx, and the rendering entry point is the Icon.tsx。
Before starting, read the header docblocks of those two files—they document the coordinate system pitfalls, the reason the default is taken from central,
and why keys use semantic names. This skill is the process and the pitfalls; those two files are the conventions themselves.
⚠️ Read this first: central is a paid icon set, and its license conflicts with this repository
The icon source is central icon system(centralicons.com, by iconists). Itis notan open-source icon library, and this fact shapes the entire acquisition process below, and also determines several "looks automatable but don't do it" things.
iconists.co/license and the npm package's LICENSE.md Forbidden Uses has two clauses that literally hit this repository:
Do not share the Item or its parts publicly on the web—this repository is a public GitHub repository,glyphs.tsx's path is exactly "its parts publicly on the web".Extraction: End users cannot extract the Item for separate use—the path is plaintext source code, anyone can copy it.
This conflict was already adjudicated by a human on 2026-08-28 as "knowingly retained", and the account is recorded in bui/README.md 's deviation table.
So:do not raise this again as a new discovery to block the process, and do not "casually" swap the icons for an open-source library.
But alsodo not expand the exposure surface—when adding icons, add only the tier you need; do not hoard, do not build a "candidate pool",
and do not leave candidate paths in comments or test fixtures.
This discipline guards against "accumulating a reusable copy of the icon library in the repository". So itdoes not prohibitkeeping for technical criteriafragmented
comparisons(this file and glyphs.tsx each have several M4.75 12.7768… truncated fragments like that, used to prove "the paths of each weight
are redrawn")—that is the sole evidence for the criterion; delete it and the next person will go modify strokeWidth to pad the numbers.
The boundary is:fragments, serving a specific criterion, not constituting a usable graphic. A complete renderable path should only appear in
glyphs.tsx the 13 tiers we are actually using.
The license has a quantity cap that iscurrently not a problem, noted here so the next person doesn't have to look it up again:
Icon Limits: 不超过 300 icons per style. Right now it's 13 tiers, far from the cap.
Three acquisition routes, only the first should be taken
| Route | Usable? | Notes |
|---|---|---|
web app one by one Copy as SVG |
✅ Main path | Free quota 20exports, covering the current 13 tiers. No account or payment needed. |
npm @central-icons-react/<variant> |
⚠️ Requires a paid key | The package's license-check.js reads CENTRAL_LICENSE_KEY, throws directly if missing, and verifies against centralicons.com/license/check . And it is a React component, not bare SVG (36MB / 2088 components); installing it for 13 icons is not worth it, and you still have to extract the paths yourself. |
Scrape centralicons.com/icons-data/*.bin |
❌ Don't do | That isencrypteddata (412KB high-entropy binary, no magic bytes, not gzip/zstd/brotli), it's deliberate paid protection. Writing a decryption script amounts to circumventing technical protection measures.Tried it, don't try again. |
⚠️ Don't go fetch the centralicons.com homepage trying to find icon data——it's a Next.js SPA, and the 31 inline svgs in the 2.49MB arethe website's own UI central icons (which incidentally confirmed the specs), not icon library data.
central's specs (must match before writing into the registry)
Official site wording + actual measurements agree:
- Coordinate system
0 0 24 24,2px padding,20×20 live area(coordinates basically fall within 2–22) stroke-linecap: round+stroke-linejoin: round(Icon.tsxgiven unconditionally, no need to write it yourself)- Three weight tiers:
stroke-width= 1 / 1.5 / 2 - 5 corner styles, two fills line/solid → what the official site calls "30 variants"
Mapping between the web app's UI labels and package names/file names
The two naming schemes differ, so it's easy to pick the wrong variant:
| web app UI | Package name / icons-data filename fragment |
|---|---|
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 |
This project is pinned to round-outlined-radius-2-stroke-2, i.e. in the web app
Style = line、Stroke = 2px、Corner = medium。
⚠️ Style you should choose line rather than solid。 This and the "the fill version ruins everything" below are two
ways of saying the same thing——solid exports the fill version.
Why pin stroke-2 rather than the default 1.5
This is an empirical criterion, not an aesthetic preference, and it reuses a conclusion already empirically tested in the project:
Visual line width = strokeWidth ÷ viewBox宽 × 渲染尺寸. This project has six slot sizes: 9/10/11/13/14/15px,
and the smallest 9px slot is ContextCards 's external link.
| weight | Ratio | Actual line width in a 9px slot |
|---|---|---|
central 1 |
1/24 = 0.0417 | 0.375px |
central 1.5(web app default) |
1.5/24 = 0.0625 | 0.5625px |
central 2 |
2/24 = 0.0833 | 0.75px |
glyphs.tsx The header notes: Phosphor regular's 0.56px in a 9px slot "looks noticeably blurry under anti-aliasing in dark mode" and was thus
rejected. central's 1.5 tier converts to 0.5625px——choosing it is equivalent to stepping back into that already empirically rejected pit.
The 2 tier's 0.75px has margin.
⚠️ Don't mix weights just to accommodate one particular slot. Mixing two weights in the same table is exactly the disease that the 2026-08-27 full
consolidation was meant to cure (at the time the whole site had 7 kinds of strokeWidth, with a visual line width span of 129%). If you adjust, adjust the whole table.
⚠️ weight mustbe exportedcorrectly, you can't rely on changing strokeWidth to convert
central's paths for each weight are redrawn, not the same path with different stroke-width。 endpoints get inset,
so as to still hold the boundary of the 20×20 live area under a thicker stroke. Measured on 2026-08-28:
| Icon | stroke-1.5 tier |
stroke-2 tier |
|---|---|---|
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 |
completely identical |
Note the third row:some icons are identical across the two tiers(those whose endpoints aren't on the live area boundary). So "I compared one icon and found the two tiers identical, therefore weight doesn't matter" isa wrong inference——you just happened to pick the one that isn't affected.
Criterion:In the web app, first switch Stroke to 2px, then copy them one by one. Don't export the 1.5 tier and then in
glyphs.tsx write in strokeWidth: 2—that would get you endpoint positions calculated for a 1.5px stroke.
First, figure out which kind of job this is
The criterion is "is there already a key in the registry that expresses this":
- Change the shape of an existing semantic slot(most cases): change that entry's
content, keep the key unchanged. For example, "change the thinking-process icon to sparkle"—thinkthis key stays the same; only what it looks like changes. - Add a new semantic slot: add a key to the table. This is one level heavier than changing a shape, because it means "there's a new kind of row in the stream."
Before adding, first answer:What makes this kind of row different from other rows? The registry has a criterion guarded by tests—icons must
carry information (
ToolChips.test.tsx's "the eight tiers of icons are all distinct"). If the new key is just another way of saying the same kind of row, it shouldn't exist; if it really is a new kind, then consumers likeToolRowIconalso need to change their types.
If you're not sure which kind it is, ask the user—don't expand the table yourself.
Step 1: get the stroke version SVG
⚠️ The only mistake in this step that can ruin the whole thing is getting the fill version。Icon.tsx forces fill="none" and renders with
stroke . Feeding in the fill version draws "the outline of an outline"—two parallel thin lines tracing the shape's edge, rather than
an icon. It doesn't error; it just looks bad, and it's very easy to mistake for "this icon just looks like that."
Criterion: if the SVG has stroke-width it's the stroke version; if it only has fill="currentColor"(or
fill-rule/clip-rule) plus a path, it's the fill version. On the central side this corresponds to
Style = line (correct) vs solid (wrong)。
Input form A: the user pasted an SVG directly (main path)
Use it directly, butrun the validation script first—it checks fill/stroke, coordinate system, weight, and live area all at once,
and outputs something you can paste straight into the registry. content:
python3 .claude/skills/add-icon/scripts/central.py < /tmp/icon.svg
# 或者直接管道:pbpaste | python3 .claude/skills/add-icon/scripts/central.py
Purely local, no network. It does four things: strip the stroke attributes written by Icon onto the svg root (stroke /
stroke-width / stroke-linecap / stroke-linejoin) and the <rect fill="none"> placeholder,
detect the fill version, report the ratio and the visual line width of the six slots, and check whether the coordinates fall within the 20×20 live area.
Input form B: the user only gave the semantics
You can't search for it yourself—the icon data isn't accessible (see the three-path table above). What you can do isgive candidate names, and have the user export from the web app:
references/central-slots.md has the candidate names and selection rationale for each of the current 13 semantic slots. When looking for a new semantic
slot outside the table, central's namingaccepts both semantic words and symbol words(aria-label (in the form
pencil, edit, write), so searching by purpose can also hit it—but you can't look it up; you have to have the user search in the web app's ⌘K .
⚠️ After picking, don't just land it directly. Icon selection is an aesthetic and semantic judgment, not a technical one:brain-1 and sparkle
can both express "reasoning," but one looks like a medical diagram and the other like an AI-flavored decoration; which to pick is a product taste call. Take 2–3 candidates,
explain each one's semantic associations, and let the user decide.
Step 2: find this semantic slot'sallconsumption points
⚠️ A semantic slot often has multiple consumption points. 2026-08-27 changed think That time, the same graphic lived simultaneously in
bui/ToolChips.tsx 's row icon slot (the fallback tier for tool names containing think/reason/plan) and
bui/ThinkingState.tsx 's collapse header. The consequence of changing only one place is that the same concept takes on two different looks in two kinds of rows that appear in close alternation—it reads like a rendering bug.
After moving into the registry, this mattermostlyresolved itself automatically (multiple consumption points read the same entry), but it still needs to be confirmed. Listall
consumption points' name——both must be run, because JSX has single-line and multi-line forms:
# 单行写法:<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 review, the two together are 12 lines, distributed across 8 components:CodeBlock 2(code + two-state
check/copy)、ContextCards 2(context + external-link)、MessageActions 1(glyph
variable → check/copy/fork)、RunHeader 1、SearchResult 1、ThinkingState 2、TodoRows 1、
ToolChips 2。
⚠️ all three places were discovered the hard way, don't simplify:
- Running only the multi-line one will miss more than half.
<Icon$is anchored at end of line and only matches multi-line JSX; whereasTodoRows/ContextCards/CodeBlock/SearchResult/MessageActionsare all single-line forms. When writing this skill, only the multi-line one was written, and the actual output was 5 lines while the real count is 12—the missing 7 lines are exactly the decorative icons newly incorporated on 2026-08-27. - The single-line one must
grep -vE ':[[:space:]]*\*'rule out comments:glyphs.tsx/Icon.tsx/ToolChips.tsx's docblocks all contain`<Icon name="…" />`prose like this, and will be matched. - Don't use
grep 'name="<语义名>"'in place of them. That one will miss dynamic consumption points:ToolChipswritesname={icon}、MessageActionswritesname={glyph}、CodeBlockwritesname={copied ? 'check' : 'copy'}. Searchingname="think"only hitsThinkingState.tsx, whileToolChipsdoes not appear at all—these are exactly the two places that each once wrote a separatethinkgraphic. Also don't write it asgrep '<Icon' | grep 'name={': in the multi-line form,<Iconand itsnameare not on the same line, and a single-line pipe match will not reach any real code, only those prose passages—that grep looks like it ran successfully and output two lines, but it's all comments.
When you see name={变量} trace upward to that variable's value source (ToolChips is ToolRowIcon type +
lib/tools.ts 's KIND_ICONS / ICON_PATTERNS;MessageActions is IconButton 's prop),
and confirm whether this semantic name is in there.
If you find that some consumption point is still inlining svg instead of going through <Icon>, that is a place that was missed in the migration, so migrate it while you're at it. Check for leftovers:
grep -rn "<svg" src/client --include="*.tsx" | grep -v "\.test\." | grep -v "icons/"
This one shouldoutput only one line:TodoRows.tsx that progress ring. It is deliberately not migrated—the criterion is "is this svg a fixed
shape, or is it computed from data?" That ring has strokeDasharray and a rotation animation; it is a status indicator, not an icon.
Extra lines mean someone inlined another one.
Step 3: Write into the registry
glyphs.tsx 's REGISTRY , add or modify a content. Rules:
| Case | How to write |
|---|---|
central round-outlined-radius-2-stroke-2(24 coordinate system,stroke-width="2") |
viewBox and strokeWidth both omitted—the default is this pair |
| Any other source (including other weights/corners in central) | viewBox and strokeWidth are both written explicitly |
| Multiple child elements | content Wrap in a layer of <>…</> |
The stroke attributes are not written into content。 They are Glyph fields, written by Icon onto the svg root (SVG's presentation attributes are inherited down the DOM tree). Stuffing stroke="currentColor" into content will make that element no longer follow the icon slot's text-* color—ThinkingState 's working-state color change will stop working.
⚠️ central's export carries stroke="currentColor" and the three stroke attributes written on <path> ⚠️, which is "dirtier" than the copy result from the Phosphor website.scripts/central.py exists precisely for this step, don't strip it by hand—miss one
stroke-width="2" on a child element will override the value on the root, and that looks completely normal in the code.
Write a comment on each one explaining where the graphic comes from. Write clearly central's icon name (aria-label 's first segment, e.g.
checkmark-1), who specified it, when, and what it replaced. The entire value of this directory is built on "every visual decision can be traced"; a graphic with no provenance will leave the next person not knowing whether they can change it.
When swapping out the shape for a tier, check the assertions pinned to the old shape
⚠️ Different icon libraries use different drawing elements. Phosphor uses <polyline> and <line>;central almost entirely uses <path>(the three-horizontal-lines kind is also three <path> rather than three <line>). The repo's
querySelector('svg path')?.getAttribute('d') -style assertions will thereforego from null back to having a value—the direction is opposite to that of 2026-08-27, but you likewise have to recheck each one.
Almost all of them should be changed to data-bui-glyph semantic names—that is what those assertions really want to guard ("this row uses the correct semantic slot"), pinning to graphic data was just because there was no better probe back then. How to check:
grep -rn "getAttribute('d')\|querySelector.*svg path\|circle')" src/client --include='*.test.tsx'
⚠️ ⚠️ Tests all green doesn't mean the assertions are still meaningful. After running that grep, look at each one, don't just fix the ones showing red. On 2026-08-27, when swapping sets, 9 showed red; after fixing them, that grep foundtwo that didn't show red but whose criterion had already become invalid(they were fixed to semantic names at the time as well; what's recorded here iswhat the invalidity looked like, not that they're still broken now):
ToolNode's "icons are distinguished by tool type" compares the first<path>on both sides'sd. Back then Bash fell into theruntier while Phosphor's terminal has no<path>, so one side wasundefinedand the other was a reald, and "different" held —it passed because one side couldn't read anything, not because the two graphics were really different.NoticeNodeasserts that the retry icon's first<path>'sdis truthy. It happens thatarrows-clockwisehas<path>so it still passes, but swapping to any graphic that is purely<line>/<polyline>would turn red, and the red then isa false positive。
The actual harvest from this grep when swapping central on 2026-08-28
The batch changed to data-bui-glyph last round didn't show red at all—the semantic-name probe is indeed immune to swapping icon libraries, and that round's criterion was validated.The only thing to touch is ToolChips 's magnifying glass: it wasn't changed to a semantic name back then, but instead changed to pin the drawingdrawing
element("has <circle> has <line>"), and the comment says "immune to swapping to another icon library" — while central's
magnifying glass is two <path>(neither the circle nor the handle is drawn with a dedicated element),proves that statement wrong。
Lesson: "pinning the drawing element" is only a tiny bit stronger than "pinning the specific d." Now it pinstopology: two parts, one closed
and one not closed,<circle>/<line>/<path> are just three ways of drawing the same shape.
Criteria (from most to least stable):
| The assertion asks | what probe is used |
|---|---|
| "which tier is this" | data-bui-glyph semantic name ← default to this |
| "are the two tiers' shapes the same" | svg's innerHTML(doesn't care what element it's drawn with) |
| "what does this shape look like" | topology(how many parts, closed or not), not the element name |
The last category is only worth writing whenthe shape itself is the reason this tier exists. (The magnifying glass one is this category: the entire reason that tier exists is "the icon must carry information".)
CodeBlock 's icon must pass through data-bui-md
bui/CodeBlock will appear as a fenced code block in markdown/Prose 's render tree, and that tree has an exhaustiveness assertion
requiring every element to carry data-bui-md(markdown/elements.test.tsx). So those three <Icon> must be written as
data-bui-md=""。
iconinternal's <path> / <line> is already exempted in that assertion (added 2026-08-27; the icon root is still checked)
— the criterion is "what is this attribute saying": what it declares is "layout is governed by our override table", and there is no layout to speak of inside an icon.
Don't add content to the registry's data-bui-mdjust to please that assertion; that would spread an attribute serving only a single consumer
across the whole table.
Step 4: register two reconciliation tables
glyphs.test.tsx in two places; miss one and the test goes red:
EXPECTEDadd the icon name. This table's job is to catch "a tier quietly appeared" — once there are many icons, nobody remembers what's actually in the table.EXPECTED_RATIOadd the visual stroke-width ratio. Right now all 13 tiers areCENTRAL_STROKE_2(2 / 24) this constant, so this table's job is topin down that uniformity— any tier deviating goes red, forcing someone to explain why it should be an exception. When mixing in other sourceswrite it as a division expression rather than a decimal(2 / 24rather than0.0833): a division expression keeps "where this number came from" in the code.
⚠️ The default coordinate system changed from 256 to 24, and that extremum assertion's failure direction is reversed
glyphs.test.tsx The third assertion ("coordinate extrema are the same order of magnitude as viewBox") originally only hada lower bound > 0.2,
guarding against "a shape in the 24 coordinate system that omits viewBox inheriting 256 and shrinking to a single point in the top-left corner".
After the default changed to 24 this failure mode no longer exists, and the opposite one appeared: a shape from the 256 coordinate system gets mixed in and omits viewBox, it will inherit 24 —— the coordinates burst out 10 times the canvas, and only a small piece in the top-left corner is visible. And at that time
extreme value/viewBox width ≈ 10.7,far greater than 0.2, the lower bound can't stop it。
so that assertion must bea two-sided interval. Measured two clusters: when correctly paired, the whole table falls in 0.79–0.96; with 256 mispaired as 24, it's on the order of 10.7. Take the upper bound as 1.5 between the two clusters and leave some margin (coordinates shouldn't exceed the viewBox anyway; only strokes overflow, and that's not a number in the path data).
Step 5: Verify
In this order, each step catches something different:
npx tsc --noEmit # 类型:漏档、name 写错、可选字段访问
npx vitest run src/client/bui/icons # 注册表三条守卫
npx vitest run src/client/bui # 消费点没被改坏
npm run build # 地板;watch 循环不跑 tsc
⚠️ Then you must actually take a look at the rendering, in both light and dark modes. icon problems are almost all of the "the code is completely fine but the picture is wrong" kind; tests can't catch them.
The easiest path is to run this (precondition:npm run web is running):
node .claude/skills/add-icon/scripts/shoot.mjs
It starts a separate headless Chrome, opens a session with a code block, switches to the beautiful tab, expands the merged segment, then
takes one screenshot in each of light and dark modes to .icon-preview/, and gives a glyph inventory on stdout (including visual line width and
"how many stray svgs there are").Use it instead of writing your own: the session item's selector, "don't click the workspace", emulating state by connection,
and the two evaluate calls are all things learned the hard way; that file's header has the full account. This script is unrelated to the icon library; switching libraries doesn't require changing it.
chrome-devtools When MCP can connect, prefer MCP (stronger interaction capability); then note these points (full version in
docs/verification-traps.md):
- Dark mode mustemulate the system preference(emulate colorScheme), don't hand-write
color-scheme——hand-writing only flips half of it, the plugin side; the host shell doesn't change along with it, and it will fabricate a fake bug of "dark text on a dark background". - Before screenshotting, first eval to confirm
.bui-root'scolorSchemeand tokens have already flipped to the corresponding branch, andthe host shell has also flipped(document.body's background color should bergb(21,21,23))——the frame mid-transition is extremely convincing and wrong. - Use
dpr 3's viewport for screenshots. This batch of icons has line widths of 0.75–1.25px; dpr 1 screenshots can't show the difference. ⚠️shoot.mjscaptures dpr 1, enough to see shape and colors,not enough to judge line width——to see line width you have to go through MCP separately. - The primitives are hidden in the merged segment that is collapsed by default, so you must expand it first. Expanding and queryingmust be two separate eval calls——React's re-render happens after the current eval's microtasks; within the same call you read the snapshot from before the click.
- ⚠️ When expanding, don't click in bulk by
[aria-expanded="false"]. Encountered on 2026-08-28: that selector also matches the host shell's controls; after clicking 27 of them at once, dsh'ssettings dialogpopped up and all the screenshots were ruined. This is the same kind of mistake as the one the first version of the skill fell into, "clicked every button that looked like a directory name and collapsed the workspace"——using overly broad selectors to operate in bulk on someone else's UI. Either scope it by.bui-root(document.querySelectorAll('.bui-root [aria-expanded="false"]')), or only click the one you actually want to look at. This page isthe profile the user actually uses day to day(npm run webwrite~/.dsh/profiles/web, see CLAUDE.md), it is not an isolated fixture, and misoperations have real consequences. - ⚠️ The visibility criterion cannot look only at
width > 0。 the collapsed section is0fr+inert, and the svg inside it still has nonzero width, so "visible within the viewport" will count a bunch of invisible primitives (scrollIntoViewwill also "successfully" scroll there). All three conditions together are correct:width > 0 && height > 0、!el.closest('[inert]'), and within the viewport range.
When only a few tiers of shapes were changed and layout is not involved, there is an even faster path: generate a standalone comparison page using real token values
(light-dark() + color-scheme two zones), headless screenshot. It answers "how does the icon itself look on light and dark backgrounds"
more clearly than the dsh page (it can put the 13 tiers side by side and mark the stroke width). Hand-writing color-scheme on a standalone page is reasonable:
that discipline guards against "only flipping half the plugin", and a standalone page has no host shell.
Step 6: measure the visual stroke width, provide a control group
⚠️ strokeWidth this numberis notthe visual stroke width, and it cannot be compared across coordinate systems either: the 2 written in the 24 coordinate system and the 24 written in
256 cannot be told which is thicker or thinner just by reading the code (the answer is almost the same: 0.083 vs 0.094). What really determines the look is
strokeWidth ÷ viewBox宽 × 渲染尺寸. Right now the whole table uses the same ratio, so the stroke width differencescome only from the slot size——
but this becomes important again when any other source is mixed in.
A new icon is very likely to be misaligned with existing icons in the same column, and this is completely invisible in the code.Measure with a control group, do not rely on subjective judgment——absolute numbers without a control group are meaningless:
// 在 .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
}, {})
after switching to central stroke-2 the baseline (ratio 2/24 = 0.0833), seven slot types:
| Slot | Who uses it | Visual stroke width | 2026-08-28 real machine |
|---|---|---|---|
| 15px | copy / check / fork(MessageActions the glyph three states) |
1.250px | ✅ measured |
| 14px | code(CodeBlock header) |
1.167px | ✅ measured |
| 13px | eight types of row primitives (ToolChips, using the default size) + think(ThinkingState)+ check(TodoRows) |
1.083px | ✅ measured |
| 12px | chevron(ToolChips / RunHeader / ThinkingState all three places explicitly give 12) |
1.000px | ✅ measured |
| 11px | context(ContextCards)/ search(SearchResult group header) |
0.917px | inferred |
| 10px | check / copy(CodeBlock copy button two states) |
0.833px | inferred |
| 9px | external-link(ContextCards) |
0.750px | inferred |
The three tiers marked "inferred" are elements thatdid not appearin that real machine session (no todo list, no search snippet card, no copy button for fenced
code blocks), calculated by ratio.Do not report them as measured results——to verify, you must pick a session that contains all three of these
forms (docs/manual-test-prompts.md has prompts that can drive each form).
⚠️ The same semantic slot commonly spans multiple sizes, this is the same thing as step 2 "one semantic slot often has multiple consumption points" in the size dimension, and it is easy to miss when checking the table:
| glyph | Size | where respectively |
|---|---|---|
check |
three types 15 / 13 / 10 | MessageActions / TodoRows / CodeBlock |
copy |
two types 15 / 10 | MessageActions / CodeBlock |
context |
two types 13 / 11 | ToolChips line/primitive position / ContextCards group title |
search |
two kinds: 13 / 11 | ToolChips line/primitive position / SearchResult group header |
think |
13 (same size in two places) | ToolChips fallback tier / ThinkingState collapse header |
So "replacing the shape of a certain tier" must beat its smallest sizeverified on—check 's lower bound is 10px rather than 15px,
external-link 's 9px is the global lower bound. This is also why the choice of weight is determined by the 9px slot (see the section at the beginning).
The ratio is the same across the whole table (2/24); the entire span comes from slot sizes—that difference is intentional design; small icons being a bit thinner is correct,
do not try to change strokeWidth to smooth it out (that would make every size need its own tier).
Historical comparison, used to judge "whether this new set has become fainter":
| Period | Ratio | Visual stroke-width span |
|---|---|---|
| Before incorporation (scattered inline across five components) | 7 kinds | 0.83–1.90px (span 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 |
Overall about 11% thinner than Phosphor bold; the lower bound of 0.75px is still above the "looks blurry" threshold (measured 0.56px becomes blurry).
This section is recorded here becausethe criterion matters more than the number: misalignment does not necessarily need fixing—graphic complexity compensates for stroke width (before the swap
think was the thinnest in the whole table at 0.81px; a human decision accepted it, on the grounds that sparkle has 5 strokes while the magnifying glass has only 2).
What should be reported is "measured ratio + same-screen comparison + actual perceived look," letting people decide, rather than making a unilateral call to thicken based on numbers. If it really must be adjusted,
glyphs.tsx and EXPECTED_RATIO should be changed together.
Step 7: Bookkeeping
Graphics that are not beautiful-ui areintentional deviations from the source of truth, and must be recorded in src/client/bui/README.md 's
"Intentional deviations from the source of truth" → "Places where values or behavior were changed" table. Write clearly: what was replaced, who specified it, the date,
and what mechanism was consequently deleted or changed.
⚠️ The central tier additionally needs its license status recorded(paid set + public repository conflict + human decision to knowingly retain). The consequence of not recording it is not "incomplete documentation": the next person, upon checking the license terms, will think this is an oversight and "fix" it, and that means yet another site-wide set swap.
The general consequence of not doing bookkeeping holds equally: that directory's rule is "verbatim consistency with beautiful-ui," and a non-source-of-truth graphic that has not been recorded will be treated by the next person as a copy error and "fixed" back.
无源: markerdoes not apply to icons—that mechanism marks "this position is completely absent from the source of truth," whereas the icon position does exist;
what we changed was the shape. The difference between the two is exactly the division of labor between this deviation table and that checklist.
Quick self-check
Go through it before rollout; each item corresponds to a pitfall actually stepped in:
- takes the stroke version (the web app's
line, hasstroke-width), not the fill version (solid) - variant is
round-outlined-radius-2-stroke-2(UI:line / 2px / medium) - weight was chosen correctly at export time, not exporting tier 1.5 and then changing
strokeWidth(the path will be redrawn) - explicitly wrote for non-central-stroke-2
viewBoxandstrokeWidth -
contenthas no residual stroke attributes (pastscripts/central.py) - all consumption points of this semantic slot go through
<Icon>(both greps were run) - when changing shapes, checked the assertions pinned to the old
dincludingdid not report red but the criterion is no longer validthose -
EXPECTEDandEXPECTED_RATIOare both registered, the latter written as a division expression - the extremum assertion istwo-sided interval(after the default 24, the lower bound cannot catch a 256 mismatch)
-
tsc/vitest/buildall green - both light and dark modes were actually checked, dark mode uses system preference simulation
- measured the visual line width ratio and provided an on-screen control group
- non-source-of-truth graphics were recorded in
bui/README.md's deviation table,including the license status entry - did not expand the license exposure surface (no hoarding candidate paths, not left in comments or fixtures)