SuperAPI AI Directory

add-icon

Frontend & websites

View repository

Updated 10-15 23:45

About this skill

To 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.tsx given 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"—think this 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 like ToolRowIcon also 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:

  1. Running only the multi-line one will miss more than half. <Icon$ is anchored at end of line and only matches multi-line JSX; whereas TodoRows / ContextCards / CodeBlock / SearchResult / MessageActions are 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.
  2. 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.
  3. Don't use grep 'name="<语义名>"' in place of them. That one will miss dynamic consumption points:ToolChips writes name={icon}、MessageActions writes name={glyph}、CodeBlock writes name={copied ? 'check' : 'copy'}. Searching name="think" only hits ThinkingState.tsx, while ToolChips does not appear at all—these are exactly the two places that each once wrote a separate think graphic. Also don't write it as grep '<Icon' | grep 'name={': in the multi-line form, <Icon and its name are 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's d. Back then Bash fell into the run tier while Phosphor's terminal has no <path>, so one side was undefinedand the other was a real d, and "different" held —it passed because one side couldn't read anything, not because the two graphics were really different.
  • NoticeNode asserts that the retry icon's first <path> 's d is truthy. It happens that arrows-clockwise has <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:

  1. EXPECTED add 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.
  2. EXPECTED_RATIO add the visual stroke-width ratio. Right now all 13 tiers are CENTRAL_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 / 24 rather than 0.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 's colorScheme and tokens have already flipped to the corresponding branch, andthe host shell has also flipped(document.body 's background color should be rgb(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.mjs captures 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 web write ~/.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 is 0fr + inert, and the svg inside it still has nonzero width, so "visible within the viewport" will count a bunch of invisible primitives (scrollIntoView will 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, has stroke-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 viewBox and strokeWidth
  • content has no residual stroke attributes (past scripts/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 d includingdid not report red but the criterion is no longer validthose
  • EXPECTED and EXPECTED_RATIO are 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 / build all 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)