Просмотр исходного кода

Merge pull request #4367 from deepseek-harness/turtle/orchestrator-2d656b6cb16e

feat(client): lead known-site links with the site's own mark
Turtle 1 месяц назад
Родитель
Сommit
22c96420fe
24 измененных файлов с 434 добавлено и 28 удалено
  1. 2 2
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml
  2. 2 2
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md
  3. 2 2
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml
  5. 38 0
      .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md
  6. 38 0
      .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md
  7. 1 0
      THIRD_PARTY_NOTICES.md
  8. 14 1
      apps/web/tests/clickable-links-gallery.e2e.ts
  9. 5 0
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  10. 2 2
      docs/web-styling.i18n.yaml
  11. 1 1
      docs/web-styling.md
  12. 1 1
      docs/web-styling.zh.md
  13. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  14. 1 1
      packages/client/ui-primitives/README.md
  15. 1 1
      packages/client/ui-primitives/README.zh.md
  16. 2 1
      packages/client/ui-primitives/package.json
  17. 16 4
      packages/client/ui-primitives/src/LinkIcon.tsx
  18. 149 0
      packages/client/ui-primitives/src/SiteGlyph.tsx
  19. 1 1
      packages/client/ui-primitives/src/WebBlock.tsx
  20. 1 1
      packages/client/ui-primitives/src/markdown/render.tsx
  21. 105 0
      packages/client/ui-primitives/tests/link-icon.client.spec.tsx
  22. 9 0
      packages/client/ui-primitives/tests/markdown.client.spec.tsx
  23. 21 1
      packages/client/ui-primitives/tests/web-block.client.spec.tsx
  24. 14 5
      pnpm-lock.yaml

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md
-2026-09-04-web-clickable-link-styles.md: 6fa61945143853a2812468638dafd23ebcd456dc
-2026-09-04-web-clickable-link-styles.zh.md: 0b15a6c72df9151ffb79f7fb41d582e191e79be6
+2026-09-04-web-clickable-link-styles.md: fc383387cede1517981358fd31a08f47c2d76c02
+2026-09-04-web-clickable-link-styles.zh.md: 7336a740e2ba93fe67a504745dcf770cab3c803d

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md

@@ -13,7 +13,7 @@ Clickable artifact links in the chat transcript wore four different costumes: ma
 One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links:
 
 - Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus.
-- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
+- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
 - Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show.
 - Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style).
 - In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border.
@@ -23,7 +23,7 @@ Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification tabl
 ## Alternatives considered
 
 - **Colored Word/Excel/PPT/PDF link glyphs.** Rejected: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. The larger file-card primitive uses distinct current-color silhouettes instead.
-- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site favicons remain possible later behind the same `url` category.
+- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site marks live behind the same `url` category ([known-site link marks](2026-09-16-known-site-link-marks.md)).
 - **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line.
 - **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md

@@ -13,7 +13,7 @@ Status: implemented
 会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言:
 
 - 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。
-- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
+- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
 - 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。
 - 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。
 - 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。
@@ -23,7 +23,7 @@ Status: implemented
 ## 备选方案
 
 - **彩色 Word/Excel/PPT/PDF 链接图形。** 否决:固定品牌填充违反图标集 currentColor-only 规则,因此这些扩展名并入单一的 outline `document` 图形。较大的文件卡片 primitive 改用各自不同的 current-color 轮廓。
-- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。
+- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份;按站点标记位于同一 `url` 类别之下([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md))。
 - **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。
 - **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md
+2026-09-16-known-site-link-marks.md: 9d029af06f9ca0f3fa8290112c7023ec1b9e0b84
+2026-09-16-known-site-link-marks.zh.md: 2c7630d122d1576ba41cb653cf0b86e9c5a2ddb4

+ 38 - 0
.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md

@@ -0,0 +1,38 @@
+# Agent Note: Known-site link marks
+
+Status: implemented
+
+English | [中文](2026-09-16-known-site-link-marks.zh.md)
+
+## Problem
+
+Every transcribed anchor led with the same globe, so a transcript full of GitHub, npm, and documentation links gave no signal about where a link goes until the reader parsed its label. The [clickable-link vocabulary](2026-09-04-web-clickable-link-styles.md) reserved that leading seat for one category glyph and left per-site marks as a possible later extension of the `url` category.
+
+## Decision
+
+`LinkIcon` takes an optional `href`. For the `url` category, a destination whose host is a well-known site draws that site's own mark; every other `url` destination keeps the globe, and the file categories ignore `href` because their destination is a path, not a site.
+
+`SiteGlyph.tsx` in ui-primitives owns the mapping: forty host suffixes resolve to thirty-four marks. The developer sites a transcript usually cites: GitHub (`github.com`, `github.io`, `raw.githubusercontent.com`), GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News (`news.ycombinator.com`), YouTube (`youtube.com`, `youtu.be`), X (`x.com`, `twitter.com`), Bilibili, Zhihu, Juejin, CSDN. The mainstream sites a general audience links: search (Google, Baidu, DuckDuckGo), video and audio (TikTok, Netflix, Spotify), social and messaging (Facebook, Instagram, Reddit, Telegram with its `t.me` short links, WhatsApp with `wa.me`, WeChat via `weixin.qq.com`, QQ, Weibo), shopping (Taobao, AliExpress, eBay), reference and community (Quora, V2EX), and Apple. A host matches a suffix when it equals it or is a subdomain of it, and the longest matching suffix wins, so `gist.github.com` and `en.wikipedia.org` need no entry while `weixin.qq.com` keeps WeChat rather than the QQ mark its `qq.com` suffix would also select. Only absolute `http:` and `https:` destinations can match; anything else falls back to the globe.
+
+The artwork is the [Simple Icons](https://simpleicons.org) set (CC0-1.0) consumed as a devDependency of ui-primitives, so adding a site is one host-map entry and the artwork version comes from the lockfile rather than a copied path. Each mark renders as that set's single path on its 24-unit viewBox, inset to a 28-unit box so it keeps the ~8% margin the 20-unit `ic_ds_*` glyphs draw inside their own box, and takes the link's `currentColor` rather than the brand fill the set records. Every mark is `aria-hidden`; the anchor's own text remains the accessible name.
+
+Taking `currentColor` is deliberate for a functional link glyph rather than brand presentation: YouTube, X, Instagram, and other brands publish guidelines against recoloring their marks, and the first alternative below weighs the fixed-brand-fill option. CC0-1.0 covers the path data; every mark remains its owner's trademark, and the generated third-party notices cover the package rather than this artwork.
+
+Two consumers pass their destination: the markdown renderer's `renderSafeLink`, which covers authored anchors, reference links, and URL-promoted inline code, and the web card's source and fetch links. Both already hold the sanitized destination.
+
+## Alternatives considered
+
+- **Fetching each site's favicon** from the site itself or a favicon service. This covers arbitrary sites, but rendering a transcript would issue network requests to every linked host, which discloses reading activity and turns a text render into a network operation; it also fails offline and behind a strict `img-src` policy. Rejected: a fixed local vocabulary keeps rendering deterministic and private, and the globe remains the honest fallback.
+- **Filling the mark with the site's brand color.** Rejected: the link glyphs are `currentColor`-only so they follow the link alias, dark mode, and hover; a fixed fill would be the first exception and would fight the link's own hover color.
+- **Copying the path data into this module.** Rejected: a third-party artwork set that changes upstream is exactly what a pinned dependency tracks; fourteen embedded copies would leave no update path.
+- **Adding site values to `LinkIconKind`.** Rejected: the kind is the category the consumer states, while the site is derived from the destination; folding both into one union would make every consumer spell out a site it does not know.
+- **A monogram tile for every unmapped host.** Rejected as noise: a generated letter capsule claims a site identity without carrying one, and 14px leaves no room for readable initials.
+
+## Consequences
+
+- Adding a site needs a host-map entry in `SiteGlyph.tsx` and, to be checked, a URL in the `link-icon` spec: that spec lists a representative URL per mapped site, requires thirty-four distinct marks across them, and pins the GitHub, npm, YouTube, Wikipedia, X, Telegram, and WhatsApp aliases. A dropped or duplicated entry fails the count, while a site added to the map alone stays uncovered by the spec.
+- The vocabulary stays bounded by category rather than by demand: each site costs one map entry, one upstream mark, and one spec URL, and the thirty-four marks add roughly 23 KB of path data to the source, which the shell build reduces to a few KB in the entry chunk after tree-shaking, minification, and compression.
+- `simple-icons` enters the browser build graph as a development dependency of ui-primitives; the shell build tree-shakes the imported marks instead of the complete set.
+- Unknown hosts, non-http schemes, unparseable destinations, and file categories keep the existing globe or category glyph; the `mailto` link in a transcript still shows the globe.
+- The vocabulary is deliberately finite. Sites such as `example.com` never get a mark from this mechanism; recognizing them would require the network fetching the alternatives reject.
+- Coverage: the LinkIcon spec covers the aliases, the fallback branches, and the sizing seat; the markdown spec pins one known and one unknown anchor; the web-card spec pins a source and a fetch link against the same marks.

+ 38 - 0
.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md

@@ -0,0 +1,38 @@
+# Agent Note: 已知站点链接标记
+
+Status: implemented
+
+[English](2026-09-16-known-site-link-marks.md) | 中文
+
+## Problem
+
+转写内容中的每个锚点都以同一个地球图形开头,因此满是 GitHub、npm 与文档链接的对话在读者解析标签之前,无法从图形看出链接去向。[可点击链接词汇](2026-09-04-web-clickable-link-styles.zh.md) 把该前置位置留给一个分类图形,并把按站点区分的标记留作 `url` 类别日后可能的扩展。
+
+## Decision
+
+`LinkIcon` 接受可选的 `href`。对于 `url` 类别,目的地主机属于已知站点时改画该站点自己的标记;其余 `url` 目的地仍使用地球,文件类别忽略 `href`,因为它们的目的地是路径而不是站点。
+
+ui-primitives 的 `SiteGlyph.tsx` 持有该映射:四十个主机后缀解析为三十四个标记。转写内容常引用的开发者站点:GitHub(`github.com`、`github.io`、`raw.githubusercontent.com`)、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News(`news.ycombinator.com`)、YouTube(`youtube.com`、`youtu.be`)、X(`x.com`、`twitter.com`)、Bilibili、知乎、掘金与 CSDN。普通用户常访问的主流站点:搜索(Google、百度、DuckDuckGo)、视频与音频(TikTok、Netflix、Spotify)、社交与通讯(Facebook、Instagram、Reddit、Telegram 及 `t.me` 短链、WhatsApp 及 `wa.me`、`weixin.qq.com` 上的微信、QQ、微博)、购物(淘宝、速卖通、eBay)、参考资料与社区(Quora、V2EX)以及 Apple。主机等于某后缀或为其子域即匹配,且最长匹配后缀优先,因此 `gist.github.com` 与 `en.wikipedia.org` 无需单独登记,而 `weixin.qq.com` 得到微信标记而不是其 `qq.com` 后缀同样会选中的 QQ 标记。只有绝对 `http:` 与 `https:` 目的地能够匹配,其余一律回退到地球。
+
+图形取自 [Simple Icons](https://simpleicons.org) 图标集(CC0-1.0),以 ui-primitives 的 devDependency 形式引入,因此新增站点只是主机映射中的一条记录,图形版本由 lockfile 固定,而不是复制路径数据。每个标记以该图标集的单条 path 绘制在其 24 单位 viewBox 上,并内缩到 28 单位盒中,以保留 20 单位 `ic_ds_*` 图形在自身盒内约 8% 的留白;填充使用链接的 `currentColor`,而不是图标集记录的品牌填充色。所有标记均为 `aria-hidden`,锚点自身文本仍是可访问名称。
+
+对功能性链接图形采用 `currentColor` 是刻意的取舍,而非品牌呈现:YouTube、X、Instagram 等品牌公布的规范禁止为其标记改色,下方第一个备选方案比较了固定品牌填充的做法。CC0-1.0 覆盖路径数据;每个标记仍属其所有者的商标,生成的第三方声明覆盖该 npm 包本身,而不是这份图形。
+
+两处消费者传入自己的目的地:markdown 渲染器的 `renderSafeLink`(覆盖作者书写的锚点、引用式链接与提升为链接的行内代码)以及 web 卡片的来源与抓取链接。两者本已持有经安全校验的目的地。
+
+## Alternatives considered
+
+- **抓取各站点的 favicon**,无论取自站点本身还是 favicon 服务。这能覆盖任意站点,但渲染一份转写内容会向每个被链接的主机发起网络请求,泄露阅读行为并把文本渲染变成网络操作;它在离线与严格 `img-src` 策略下同样失败。拒绝:固定的本地词汇表让渲染保持确定与私密,地球仍是诚实的回退。
+- **以站点品牌色填充标记。** 拒绝:链接图形只使用 `currentColor`,以跟随链接 alias、暗色模式与 hover;固定填充会成为首个例外,并与链接自身的 hover 颜色冲突。
+- **把路径数据复制进本模块。** 拒绝:上游会持续更新的第三方图形集正是需要固定版本依赖来跟踪的内容;十四个内嵌副本没有更新路径。
+- **把站点值加进 `LinkIconKind`。** 拒绝:kind 是消费者陈述的分类,而站点由目的地推导;把两者折进同一联合类型会迫使每个消费者写出自己并不知晓的站点。
+- **为每个未映射主机生成首字母方块。** 以噪音为由拒绝:自动生成的字母胶囊宣称了一种站点身份却没有承载它,14px 也没有留下可读字母的空间。
+
+## Consequences
+
+- 新增站点需要在 `SiteGlyph.tsx` 的主机映射中加一条记录,并在 `link-icon` spec 中补一个 URL 才会被检查:该 spec 为每个已映射站点列出一个代表 URL、要求它们共产生三十四个互不相同的标记,并固定 GitHub、npm、YouTube、Wikipedia、X、Telegram 与 WhatsApp 的别名。记录丢失或重复会让数量断言失败,而只改主机映射、不补 spec URL 的新站点仍未被 spec 覆盖。
+- 词汇表按类别而非按需求增长:每个站点需要一条主机映射、一个上游标记与一个 spec URL;这三十四个标记在源码中增加约 23 KB 路径数据,经 shell 构建的 tree-shake、压缩与 gzip 后进入入口 chunk 的只有几 KB。
+- `simple-icons` 作为 ui-primitives 的开发依赖进入浏览器构建图;shell 构建会 tree-shake 所导入的标记,而不是整套图标。
+- 未收录主机、非 http scheme、无法解析的目的地与文件类别都保留原有的地球或分类图形;转写内容中的 `mailto` 链接仍显示地球。
+- 该词汇表刻意有限。像 `example.com` 这样的站点不会由此机制获得标记;要识别它们就需要被上述备选方案拒绝的网络抓取。
+- 覆盖:LinkIcon spec 覆盖别名、各回退分支与尺寸座位;markdown spec 固定一个已知锚点与一个未知锚点;web 卡片 spec 用同一批标记固定一个来源链接与一个抓取链接。

+ 1 - 0
THIRD_PARTY_NOTICES.md

@@ -110,6 +110,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`semver`](https://github.com/npm/node-semver) | ISC |
 | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 |
 | [`shiki`](https://github.com/shikijs/shiki) | MIT |
+| [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 |
 | [`supports-color`](https://github.com/chalk/supports-color) | MIT |
 | [`tsx`](https://github.com/privatenumber/tsx) | MIT |
 | [`turndown`](https://github.com/mixmark-io/turndown) | MIT |

+ 14 - 1
apps/web/tests/clickable-links-gallery.e2e.ts

@@ -2,7 +2,8 @@
 // in one settled keyless turn — the regression anchor for unifying link
 // styles. One fixture turn produces:
 // - prose: Markdown link, reference-style link, mailto link, inline-code URL,
-//   produced-file mention, plus inert contrasts (ambiguous basename, unwritten
+//   produced-file mention, a known-site link carrying that site's own leading
+//   mark, plus inert contrasts (ambiguous basename, unwritten
 //   file, command code, URL-with-flags code, javascript: destination,
 //   footnote superscript, remote image, fenced code block with its copy chrome)
 // - artifacts: seven produced files (chips overflow into the "+N" remainder
@@ -55,6 +56,9 @@ const MAILTO_URL = 'mailto:owner@example.test'
 const SOURCE_URL = 'https://docs.example.test/links'
 const INERT_SOURCE_URL = 'ftp://mirror.example.test/spec'
 const FETCH_URL = 'https://docs.example.test/tokens'
+// A mapped host, so the prose pins that the leading glyph is the site's mark
+// rather than the globe the unmapped docs host keeps.
+const REPO_URL = 'https://github.com/example/link-gallery'
 
 /** One-part text content for a built message. */
 function text(value: string): { type: 'text'; text: string }[] {
@@ -256,6 +260,8 @@ function galleryFixture(imageUrl: string): string {
         `Docs: [style guide](${GUIDE_URL}) and \`${API_URL}\`; see [the release notes][rel], `
         + `contact [the maintainer](${MAILTO_URL}), and check the fine print[^1].`,
         '',
+        `Upstream: [the repository](${REPO_URL}).`,
+        '',
         `Inert contrasts: \`curl ${API_URL}\`, \`javascript:alert(1)\`, and \`pnpm run build\`.`,
         '',
         'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.',
@@ -418,6 +424,13 @@ describe('web e2e: clickable links gallery', () => {
       expect.soft(await styleOf(link, 'text-decoration-line'), `${name} at rest`).toBe('none')
       expect.soft(await link.locator('svg').count(), `${name} glyph`).toBe(1)
     }
+    // A mapped host leads with its own mark; the unmapped docs host keeps the
+    // globe in the same seat.
+    const repoLink = markdown.locator(`a[href="${REPO_URL}"]`)
+    expect(await repoLink.count()).toBe(1)
+    const repoMark = await repoLink.locator('svg path').getAttribute('d')
+    const globeMark = await guideLink.locator('svg path').getAttribute('d')
+    expect(repoMark).not.toBe(globeMark)
     await guideLink.hover()
     expect(await styleOf(guideLink, 'text-decoration-line')).toBe('underline')
     expect(await styleOf(guideLink, 'text-decoration-style')).toBe('dotted')

+ 5 - 0
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -142,6 +142,11 @@
   - text: ", and check the fine print"
   - superscript: "1"
   - text: .
+- paragraph:
+  - text: "Upstream:"
+  - link "the repository":
+    - /url: https://github.com/example/link-gallery
+  - text: .
 - paragraph:
   - text: "Inert contrasts:"
   - code: curl https://docs.example.test/api

+ 2 - 2
docs/web-styling.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/web-styling.md
-web-styling.md: dbd696307d5044c165c77948059859eecd32b4a7
-web-styling.zh.md: af3f66960e8e0596577d155919a051688aca372b
+web-styling.md: c158562f0c862e883514c9d4057c87d4df65119e
+web-styling.zh.md: 7086bab6ff65c5a4415795aebd04da1e86e68c4f

+ 1 - 1
docs/web-styling.md

@@ -23,7 +23,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi
 - Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing.
 - Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders.
 - Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders.
-- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Text-leading anchors also lead with the ui-primitives `LinkIcon` category glyph riding `currentColor`; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance ([clickable-link Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md)).
+- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Text-leading anchors also lead with the ui-primitives `LinkIcon` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance ([clickable-link Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md), [known-site mark Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md)).
 
 ## Changing the system
 

+ 1 - 1
docs/web-styling.zh.md

@@ -23,7 +23,7 @@
 - 支持的引擎上,圆角继承 ui-theme `corner-shape.css` 的全局超级椭圆平滑。每个正圆 `border-radius`(`50%`、`100%` 或胶囊半径)必须配对 `corner-shape: round`,使圆形与胶囊保持圆弧;ui-theme 的 corner-shape spec 强制这一配对。
 - 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。
 - 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。
-- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。带文字的锚点另以 ui-primitives 的 `LinkIcon` 分类图形(随 `currentColor`)作前置;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能([可点击链接 Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md))。
+- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。带文字的锚点另以 ui-primitives 的 `LinkIcon` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能([可点击链接 Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md)、[已知站点标记 Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md))。
 
 ## 变更系统
 

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 9290831ca4f33899a88e61e8fdd8f9951de905fa
-README.zh.md: b865c49c86df5f2269cea90be9d93bfa3abdff51
+README.md: 6863c40d7d5111bb1db7e71436a795eac462e0bc
+README.zh.md: 28beb2ecb36eca6c8c759b97b600ef1496bbcbe4

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
packages/client/ui-primitives/README.md


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
packages/client/ui-primitives/README.zh.md


+ 2 - 1
packages/client/ui-primitives/package.json

@@ -46,7 +46,8 @@
     "mdast-util-gfm": "^3.1.0",
     "mdast-util-math": "^3.0.0",
     "micromark-extension-gfm": "^3.0.0",
-    "micromark-util-sanitize-uri": "^2.0.1"
+    "micromark-util-sanitize-uri": "^2.0.1",
+    "simple-icons": "16.31.0"
   },
   "files": [
     "lib/index.js",

+ 16 - 4
packages/client/ui-primitives/src/LinkIcon.tsx

@@ -7,10 +7,15 @@
  * cannot compose a glyph outside a link. Design sources:
  * ic_globe_language_outline_20, ic_code_outline_20, ic_folder_outline_20,
  * ic_photo_outline_20, ic_paper_doc_outline_20, ic_paper_outline_20.
+ *
+ * A `url` glyph whose destination names a well-known site is that site's own
+ * mark instead of the globe (`SiteGlyph.tsx`); every mark keeps the same
+ * currentColor-only rule.
  */
 import type { ReactNode } from 'react'
 import { classifyFileType, fileExtension } from './FileTypeIcon.tsx'
 import { isCodeFileType, isLinkCodeExtension } from './code-file-types.ts'
+import { siteGlyph } from './SiteGlyph.tsx'
 import type { IconProps } from './icons/props.ts'
 
 /**
@@ -24,6 +29,12 @@ export type LinkIconKind = 'url' | 'folder' | 'code' | 'image' | 'document' | 'o
 /** Props for {@link LinkIcon}: the category plus the shared icon sizing seat. */
 export interface LinkIconProps extends IconProps {
   kind: LinkIconKind
+  /**
+   * Destination of a `url` link. A well-known site draws its own mark, so
+   * callers that know the destination should pass it; anything else keeps the
+   * globe. Other kinds ignore it — their destination is a path, not a site.
+   */
+  href?: string | undefined
 }
 
 /**
@@ -133,13 +144,14 @@ function assertNever(value: never): never {
 
 /**
  * Render the leading glyph for one clickable artifact link.
- * @param props - The link category, optional size (default 14px — the inline
- * link text size these glyphs sit beside), and optional CSS class.
+ * @param props - The link category, the optional destination that can select a
+ * site mark, optional size (default 14px — the inline link text size these
+ * glyphs sit beside), and optional CSS class.
  * @returns The category's SVG glyph, riding currentColor.
  */
-export function LinkIcon({ kind, size = 14, className }: LinkIconProps): ReactNode {
+export function LinkIcon({ kind, href, size = 14, className }: LinkIconProps): ReactNode {
   switch (kind) {
-    case 'url': return <GlobeGlyph size={size} className={className} />
+    case 'url': return siteGlyph({ href, size, className }) ?? <GlobeGlyph size={size} className={className} />
     case 'folder': return <FolderGlyph size={size} className={className} />
     case 'code': return <CodeGlyph size={size} className={className} />
     case 'image': return <PhotoGlyph size={size} className={className} />

+ 149 - 0
packages/client/ui-primitives/src/SiteGlyph.tsx

@@ -0,0 +1,149 @@
+/**
+ * Site marks for well-known external link hosts. {@link LinkIcon} renders one in
+ * the leading glyph seat, so a familiar destination leads with its own mark
+ * instead of the generic globe. The marks are the `simple-icons` artwork set
+ * (CC0-1.0), pinned by this package's dependency on it; each mark takes the
+ * link's `currentColor` rather than the brand fill that set records.
+ */
+import type { ReactElement } from 'react'
+import {
+  siAliexpress,
+  siApple,
+  siBaidu,
+  siBilibili,
+  siCsdn,
+  siDuckduckgo,
+  siEbay,
+  siFacebook,
+  siGithub,
+  siGitlab,
+  siGoogle,
+  siInstagram,
+  siJuejin,
+  siMdnwebdocs,
+  siNetflix,
+  siNpm,
+  siPypi,
+  siQq,
+  siQuora,
+  siReddit,
+  siSinaweibo,
+  siSpotify,
+  siStackoverflow,
+  siTaobao,
+  siTelegram,
+  siTiktok,
+  siV2ex,
+  siWechat,
+  siWhatsapp,
+  siWikipedia,
+  siX,
+  siYcombinator,
+  siYoutube,
+  siZhihu,
+} from 'simple-icons'
+import type { SimpleIcon } from 'simple-icons'
+
+/**
+ * Host suffix to site mark, covering the developer sites the transcript
+ * usually cites plus the mainstream search, video, social, shopping, and
+ * reference sites a general audience links. A host matches a suffix when it
+ * equals it or is a subdomain of it, and the longest matching suffix wins, so
+ * `weixin.qq.com` keeps WeChat while `qq.com` keeps QQ and `gist.github.com`
+ * needs no entry of its own.
+ */
+const SITE_HOSTS: Readonly<Record<string, SimpleIcon>> = {
+  'github.com': siGithub,
+  'github.io': siGithub,
+  'raw.githubusercontent.com': siGithub,
+  'gitlab.com': siGitlab,
+  'npmjs.com': siNpm,
+  'pypi.org': siPypi,
+  'stackoverflow.com': siStackoverflow,
+  'developer.mozilla.org': siMdnwebdocs,
+  'wikipedia.org': siWikipedia,
+  'news.ycombinator.com': siYcombinator,
+  'youtube.com': siYoutube,
+  'youtu.be': siYoutube,
+  'x.com': siX,
+  'twitter.com': siX,
+  'bilibili.com': siBilibili,
+  'zhihu.com': siZhihu,
+  'juejin.cn': siJuejin,
+  'csdn.net': siCsdn,
+  'google.com': siGoogle,
+  'baidu.com': siBaidu,
+  'duckduckgo.com': siDuckduckgo,
+  'tiktok.com': siTiktok,
+  'netflix.com': siNetflix,
+  'spotify.com': siSpotify,
+  'facebook.com': siFacebook,
+  'instagram.com': siInstagram,
+  'reddit.com': siReddit,
+  'telegram.org': siTelegram,
+  't.me': siTelegram,
+  'weixin.qq.com': siWechat,
+  'qq.com': siQq,
+  'whatsapp.com': siWhatsapp,
+  'wa.me': siWhatsapp,
+  'weibo.com': siSinaweibo,
+  'taobao.com': siTaobao,
+  'aliexpress.com': siAliexpress,
+  'ebay.com': siEbay,
+  'quora.com': siQuora,
+  'v2ex.com': siV2ex,
+  'apple.com': siApple,
+}
+
+/**
+ * Resolve the mark named by an external destination.
+ * @param href - The link destination; only an absolute http(s) URL can name a host.
+ * @returns The matched site mark, or undefined when the host is unknown or not http(s).
+ */
+function siteIcon(href: string): SimpleIcon | undefined {
+  let host: string
+  try {
+    const url = new URL(href)
+    if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
+    host = url.hostname.toLowerCase()
+  } catch {
+    // Not an absolute URL: nothing here can name a host.
+    return undefined
+  }
+  let match: SimpleIcon | undefined
+  let matched = 0
+  for (const [suffix, icon] of Object.entries(SITE_HOSTS)) {
+    if ((host === suffix || host.endsWith(`.${suffix}`)) && suffix.length > matched) {
+      match = icon
+      matched = suffix.length
+    }
+  }
+  return match
+}
+
+/** Props for {@link siteGlyph}: the destination plus the shared icon sizing seat. */
+interface SiteGlyphProps {
+  /** The link destination; only an absolute http(s) URL can name a host. */
+  href: string | undefined
+  /** Square edge in px. */
+  size: number
+  /** Extra class for layout placement. */
+  className?: string | undefined
+}
+
+/**
+ * Render the site mark for a known external destination.
+ * @param props - The destination and the icon sizing seat.
+ * @returns The site's mark riding currentColor, or undefined for an unknown site.
+ */
+export function siteGlyph({ href, size, className }: SiteGlyphProps): ReactElement | undefined {
+  const icon = href === undefined ? undefined : siteIcon(href)
+  if (icon === undefined) return undefined
+  return (
+    // Simple Icons fill their 24-unit box edge to edge; the -2 inset leaves the
+    // same ~8% margin the 20-unit ic_ds_* glyphs draw inside their own box.
+    <svg width={size} height={size} className={className} viewBox="-2 -2 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
+      <path d={icon.path} fill="currentColor" />
+    </svg>
+  )
+}

+ 1 - 1
packages/client/ui-primitives/src/WebBlock.tsx

@@ -112,7 +112,7 @@ function SafeLink({ url, label, className }: { url: string; label: string; class
   if (href === undefined) return <span className={className}>{label}</span>
   return (
     <a className={className} href={href} target="_blank" rel="noopener noreferrer">
-      <LinkIcon kind="url" className={css.linkIcon} />
+      <LinkIcon kind="url" href={href} className={css.linkIcon} />
       {label}
     </a>
   )

+ 1 - 1
packages/client/ui-primitives/src/markdown/render.tsx

@@ -536,7 +536,7 @@ function renderSafeLink(href: string, children: ReactNode[], key: Key, glyph = t
       href={safeHref}
       {...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
     >
-      {glyph && <LinkIcon kind="url" className={css.linkIcon} />}
+      {glyph && <LinkIcon kind="url" href={safeHref} className={css.linkIcon} />}
       {children}
     </a>
   )

+ 105 - 0
packages/client/ui-primitives/tests/link-icon.client.spec.tsx

@@ -63,3 +63,108 @@ describe('LinkIcon', () => {
     expect(svg.classList.contains('x')).toBe(true)
   })
 })
+
+/** One host per mapped site, plus aliases that must resolve to a mark already listed. */
+const SITE_URLS: string[] = [
+  'https://github.com/org/repo',
+  'https://gist.github.com/abc',
+  'https://org.github.io/repo',
+  'https://raw.githubusercontent.com/org/repo/main/a.ts',
+  'https://gitlab.com/org/repo',
+  'https://www.npmjs.com/package/x',
+  'https://pypi.org/project/x',
+  'https://stackoverflow.com/q/1',
+  'https://developer.mozilla.org/en-US/docs/Web',
+  'https://en.wikipedia.org/wiki/Harness',
+  'https://news.ycombinator.com/item?id=1',
+  'https://youtu.be/dQw4w9WgXcQ',
+  'https://x.com/x',
+  'https://www.bilibili.com/video/BV1',
+  'https://zhuanlan.zhihu.com/p/1',
+  'https://juejin.cn/post/1',
+  'https://blog.csdn.net/post/1',
+  'https://www.google.com/search?q=1',
+  'https://baike.baidu.com/item/1',
+  'https://duckduckgo.com/?q=1',
+  'https://www.tiktok.com/@x',
+  'https://www.netflix.com/title/1',
+  'https://open.spotify.com/track/1',
+  'https://www.facebook.com/x',
+  'https://www.instagram.com/x',
+  'https://www.reddit.com/r/x',
+  'https://t.me/x',
+  'https://weixin.qq.com/x',
+  'https://im.qq.com/x',
+  'https://wa.me/1',
+  'https://weibo.com/x',
+  'https://item.taobao.com/item.htm?id=1',
+  'https://www.aliexpress.com/item/1',
+  'https://www.ebay.com/itm/1',
+  'https://www.quora.com/q/1',
+  'https://www.v2ex.com/t/1',
+  'https://www.apple.com/mac',
+]
+
+/** The single path data of the mark a URL renders. */
+function glyphPath(href: string): string {
+  const { container } = render(<LinkIcon kind="url" href={href} />)
+  return container.querySelector('path')!.getAttribute('d')!
+}
+
+describe('LinkIcon site marks', () => {
+  it('draws each mapped site its own mark, not the globe', () => {
+    const globe = render(<LinkIcon kind="url" />).container.querySelector('path')!.getAttribute('d')
+    const marks = SITE_URLS.map(glyphPath)
+    expect(marks).not.toContain(globe)
+    // Thirty-seven destinations, thirty-four mapped sites: the three extra
+    // GitHub hosts share the GitHub mark, and any other number means the site
+    // list grew without this expectation moving with it.
+    expect(new Set(marks).size).toBe(34)
+  })
+
+  it('resolves the aliases of one site to the same mark', () => {
+    const github = glyphPath('https://github.com/org/repo')
+    expect(glyphPath('https://gist.github.com/abc')).toBe(github)
+    expect(glyphPath('https://org.github.io/repo')).toBe(github)
+    expect(glyphPath('https://raw.githubusercontent.com/org/repo/main/a.ts')).toBe(github)
+    expect(glyphPath('https://www.npmjs.com/package/x')).toBe(glyphPath('https://npmjs.com/package/x'))
+    expect(glyphPath('https://en.wikipedia.org/wiki/Harness')).toBe(glyphPath('https://www.wikipedia.org'))
+    expect(glyphPath('https://youtu.be/dQw4w9WgXcQ')).toBe(glyphPath('https://m.youtube.com/watch?v=1'))
+    expect(glyphPath('https://twitter.com/x')).toBe(glyphPath('https://x.com/x'))
+    expect(glyphPath('https://telegram.org/x')).toBe(glyphPath('https://t.me/x'))
+    expect(glyphPath('https://whatsapp.com/x')).toBe(glyphPath('https://wa.me/1'))
+  })
+
+  it('keeps the longer suffix mark for a subdomain of a mapped host', () => {
+    // weixin.qq.com ends in qq.com as well, so the longest match has to win.
+    expect(glyphPath('https://weixin.qq.com/x')).not.toBe(glyphPath('https://im.qq.com/x'))
+  })
+
+  it.each([
+    ['no destination', undefined],
+    ['an unknown host', 'https://example.com/a'],
+    ['a host that only ends in a mapped name', 'https://notgithub.com/a'],
+    ['a non-http scheme', 'mailto:owner@example.com'],
+    ['a destination that is not a URL', 'src/index.ts'],
+    ['a single-label host', 'https:///a'],
+  ])('keeps the globe for %s', (_case, href) => {
+    const globe = render(<LinkIcon kind="url" />).container.querySelector('svg')!.outerHTML
+    expect(render(<LinkIcon kind="url" href={href} />).container.querySelector('svg')!.outerHTML).toBe(globe)
+  })
+
+  it('ignores the destination for file categories', () => {
+    const plain = render(<LinkIcon kind="code" />).container.querySelector('svg')!.outerHTML
+    expect(render(<LinkIcon kind="code" href="https://github.com/a" />).container.querySelector('svg')!.outerHTML).toBe(plain)
+  })
+
+  it('sizes the site mark through the shared seat', () => {
+    const { container } = render(<LinkIcon kind="url" href="https://github.com/a" size={20} className="x" />)
+    const svg = container.querySelector('svg')!
+    expect(svg.getAttribute('width')).toBe('20')
+    expect(svg.getAttribute('height')).toBe('20')
+    expect(svg.classList.contains('x')).toBe(true)
+    expect(svg.getAttribute('aria-hidden')).toBe('true')
+    expect(container.innerHTML).toContain('currentColor')
+    expect(container.innerHTML).not.toMatch(/#[0-9a-fA-F]{3,8}"/)
+  })
+})

+ 9 - 0
packages/client/ui-primitives/tests/markdown.client.spec.tsx

@@ -2,6 +2,7 @@
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { afterEach, describe, expect, it } from 'vitest'
 import { JsonBlock, MarkdownText } from './markdown-test-components.tsx'
+import { LinkIcon } from '../src/index.ts'
 import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts'
 import { mathCompatibility } from '../src/markdown/mathCompatibility.ts'
 
@@ -309,6 +310,14 @@ describe('MarkdownText', () => {
     expect(screen.getByText('mail diagram')).toBeTruthy()
   })
 
+  it('leads a known site link with its own mark and an unknown host with the globe', () => {
+    const { container } = render(<MarkdownText text={'[repo](https://github.com/org/repo) [docs](https://example.com/a)'} />)
+    const marks = [...container.querySelectorAll('p a svg path')].map(path => path.getAttribute('d'))
+    const globe = render(<LinkIcon kind="url" />).container.querySelector('path')!.getAttribute('d')
+    const github = render(<LinkIcon kind="url" href="https://github.com/a" />).container.querySelector('path')!.getAttribute('d')
+    expect(marks).toEqual([github, globe])
+  })
+
   it('keeps incomplete streaming Markdown renderable', () => {
     const { container } = render(<MarkdownText text={'## Streaming\n\n- first\n- **unfinished'} />)
     expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy()

+ 21 - 1
packages/client/ui-primitives/tests/web-block.client.spec.tsx

@@ -2,7 +2,7 @@
 
 import { afterEach, describe, expect, it } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
-import { WebBlock as LocalizedWebBlock } from '../src/index.ts'
+import { LinkIcon, WebBlock as LocalizedWebBlock } from '../src/index.ts'
 import type {
   WebFetchBlockProps, WebSearchBlockProps, WebSourceView,
 } from '../src/index.ts'
@@ -28,6 +28,12 @@ function WebBlock(props: WebBlockProps) {
 
 afterEach(cleanup)
 
+/** The path data of the mark a `url` link leads with for one destination. */
+function glyphMark(href: string | undefined): string | null {
+  const { container } = render(<LinkIcon kind="url" href={href} />)
+  return container.querySelector('path')!.getAttribute('d')
+}
+
 function sources(count: number): WebSourceView[] {
   return Array.from({ length: count }, (_value, index) => ({
     url: `https://site-${index}.example.com/page`,
@@ -102,6 +108,20 @@ describe('WebBlock search card', () => {
     expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
   })
 
+  it("leads a source and a fetched url on a known site with that site's mark", () => {
+    /** The mark a link leads with, as its path data. */
+    const mark = (element: Element): string | null => element.querySelector('a svg path')!.getAttribute('d')
+    const view = render(<WebBlock kind="search" truncated={false} sources={[
+      { url: 'https://github.com/org/repo', title: 'Repo' },
+    ]} />)
+    expect(mark(view.container)).toBe(glyphMark('https://github.com/a'))
+    // An unmapped host keeps the globe, as does the fetch card's URL.
+    expect(mark(render(<WebBlock kind="search" sources={sources(1)} truncated={false} />).container))
+      .toBe(glyphMark(undefined))
+    expect(mark(render(<WebBlock kind="fetch" url="https://news.ycombinator.com/item?id=1" statusCode={200} truncated={false} />).container))
+      .toBe(glyphMark('https://news.ycombinator.com/item?id=2'))
+  })
+
   it('renders a non-http url as plain text with no href, and its raw text label when unparseable', () => {
     const view = render(<WebBlock kind="search" truncated={false} sources={[
       { url: 'javascript:alert(1)', title: 'Dangerous' },

+ 14 - 5
pnpm-lock.yaml

@@ -3761,6 +3761,9 @@ importers:
       shiki:
         specifier: ^4.3.1
         version: 4.3.1
+      simple-icons:
+        specifier: 16.31.0
+        version: 16.31.0
 
   packages/client/ui-reference:
     devDependencies:
@@ -8045,7 +8048,7 @@ importers:
         version: link:../../../vendor/schemastery
       '@earendil-works/pi-ai':
         specifier: ^0.85.1
-        version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)
+        version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -18850,6 +18853,10 @@ packages:
   simple-get@4.0.1:
     resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
 
+  simple-icons@16.31.0:
+    resolution: {integrity: sha512-dDEqvQrmxL7ZnqQtGHGjCNW6CfiwzAif+83Hol9JXAnAB6wh2zsZD7Sc1kWT0O/RRAKt7xfG8W6UZhYBPD+GAA==}
+    engines: {node: '>=0.12.18'}
+
   simple-update-notifier@2.0.0:
     resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
     engines: {node: '>=10'}
@@ -20396,14 +20403,14 @@ snapshots:
     transitivePeerDependencies:
       - '@algolia/client-search'
 
-  '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)':
+  '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)':
     dependencies:
       '@anthropic-ai/sdk': 0.123.0(zod@4.4.3)
       '@aws-sdk/client-bedrock-runtime': 3.1048.0
       '@earendil-works/pi-telemetry': 0.85.1
       '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))
       '@smithy/node-http-handler': 4.7.3
-      http-proxy-agent: 7.0.2
+      http-proxy-agent: 7.0.2(supports-color@9.4.0)
       https-proxy-agent: 7.0.6
       openai: 6.40.0(ws@8.21.0)(zod@4.4.3)
       partial-json: 0.1.7
@@ -23023,7 +23030,7 @@ snapshots:
       cross-spawn: 7.0.6
       debug: 4.4.3(supports-color@9.4.0)
       fs-extra: 10.1.0
-      http-proxy-agent: 7.0.2
+      http-proxy-agent: 7.0.2(supports-color@9.4.0)
       https-proxy-agent: 7.0.6
       js-yaml: 4.3.1
       sanitize-filename: 1.6.4
@@ -24320,7 +24327,7 @@ snapshots:
       statuses: 2.0.2
       toidentifier: 1.0.1
 
-  http-proxy-agent@7.0.2:
+  http-proxy-agent@7.0.2(supports-color@9.4.0):
     dependencies:
       agent-base: 7.1.4
       debug: 4.4.3(supports-color@9.4.0)
@@ -26129,6 +26136,8 @@ snapshots:
       once: 1.4.0
       simple-concat: 1.0.1
 
+  simple-icons@16.31.0: {}
+
   simple-update-notifier@2.0.0:
     dependencies:
       semver: 7.8.5

Некоторые файлы не были показаны из-за большого количества измененных файлов