Pārlūkot izejas kodu

feat(client): lead known-site links with the site's own mark

Clickable external links now lead with the destination site's own mark
instead of the globe when the host belongs to a known site: GitHub,
GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube,
X, Bilibili, Zhihu, Juejin, and CSDN. LinkIcon takes an optional href and
resolves the mark through a private SiteGlyph module; markdown anchors,
reference links, URL-promoted inline code, and web-card source and fetch
links pass their sanitized destination. Unmapped hosts, non-http schemes,
and the file categories keep today's glyph.

The marks are the single-path form of Simple Icons (CC0-1.0) on its
24x24 viewBox, filled with currentColor like every other link glyph, so
they follow the link alias in both themes and on hover.

Coverage: the LinkIcon spec pins one host per mapped site plus the alias
and fallback branches, the markdown and web-card specs pin their consumer
wiring, and the clickable-links gallery e2e pins the assembled mark
against the globe in a real browser.
Turtle 2 nedēļas atpakaļ
vecāks
revīzija
59456dbb5b

+ 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: ea31b774f6e401c0579cfda976aecfad14bf403f
+2026-09-04-web-clickable-link-styles.zh.md: e3d8aff2ffc66cef52d4fb6b90f839badc7a0873

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

@@ -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 arrived later 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.
 

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

@@ -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: ae7500c90bb0addf069f0917665f23211c8a3234
+2026-09-16-known-site-link-marks.zh.md: 17628fc182b5957233b727102d9efcceb055dc6d

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

@@ -0,0 +1,33 @@
+# 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: eighteen host suffixes resolve to fourteen marks — GitHub (`github.com`, `github.io`, `raw.githubusercontent.com`), GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube (`youtube.com`, `youtu.be`), X (`x.com`, `twitter.com`), Bilibili, Zhihu, Juejin, and CSDN. A host matches a suffix when it equals it or is a subdomain of it, after `www.` is stripped, so `gist.github.com` and `en.wikipedia.org` resolve without their own entries. Only absolute `http:` and `https:` destinations can match; anything else falls back to the globe.
+
+The marks are the single-path form of the [Simple Icons](https://simpleicons.org) set (CC0-1.0), drawn on that set's 24×24 viewBox and filled with `currentColor` like every other link glyph, so they follow the link color in both themes, hover, and focus. Every mark is `aria-hidden`; the anchor's own text remains the accessible name.
+
+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, and needs a new external dependency. 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.
+- **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 is one path entry and one host entry in `SiteGlyph.tsx`; the `link-icon` spec pins one host per mapped site and requires their marks to be distinct, so a dropped or duplicated path fails the suite.
+- 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 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.

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

@@ -0,0 +1,33 @@
+# 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、YouTube(`youtube.com`、`youtu.be`)、X(`x.com`、`twitter.com`)、Bilibili、知乎、掘金与 CSDN。去掉 `www.` 后,主机等于某后缀或为其子域即匹配,因此 `gist.github.com` 与 `en.wikipedia.org` 无需单独登记。只有绝对 `http:` 与 `https:` 目的地能够匹配,其余一律回退到地球。
+
+标记取自 [Simple Icons](https://simpleicons.org) 图标集的单路径形式(CC0-1.0),沿用该图标集的 24×24 viewBox,并与其他链接图形一样以 `currentColor` 填充,因此会跟随两种主题、hover 与 focus 下的链接颜色。所有标记均为 `aria-hidden`,锚点自身文本仍是可访问名称。
+
+两处消费者传入自己的目的地:markdown 渲染器的 `renderSafeLink`(覆盖作者书写的锚点、引用式链接与提升为链接的行内代码)以及 web 卡片的来源与抓取链接。两者本已持有经安全校验的目的地。
+
+## Alternatives considered
+
+- **抓取各站点的 favicon**,无论取自站点本身还是 favicon 服务。这能覆盖任意站点,但渲染一份转写内容会向每个被链接的主机发起网络请求,泄露阅读行为并把文本渲染变成网络操作;它在离线与严格 `img-src` 策略下同样失败,且需要新增外部依赖。拒绝:固定的本地词汇表让渲染保持确定与私密,地球仍是诚实的回退。
+- **以站点品牌色填充标记。** 拒绝:链接图形只使用 `currentColor`,以跟随链接 alias、暗色模式与 hover;固定填充会成为首个例外,并与链接自身的 hover 颜色冲突。
+- **把站点值加进 `LinkIconKind`。** 拒绝:kind 是消费者陈述的分类,而站点由目的地推导;把两者折进同一联合类型会迫使每个消费者写出自己并不知晓的站点。
+- **为每个未映射主机生成首字母方块。** 以噪音为由拒绝:自动生成的字母胶囊宣称了一种站点身份却没有承载它,14px 也没有留下可读字母的空间。
+
+## Consequences
+
+- 新增站点就是在 `SiteGlyph.tsx` 中加一条路径与一条主机记录;`link-icon` spec 为每个已映射站点固定一个主机并要求它们的标记互不相同,因此路径丢失或重复会让测试失败。
+- 未收录主机、非 http scheme、无法解析的目的地与文件类别都保留原有的地球或分类图形;转写内容中的 `mailto` 链接仍显示地球。
+- 该词汇表刻意有限。像 `example.com` 这样的站点不会由此机制获得标记;要识别它们就需要被上述备选方案拒绝的网络抓取。
+- 覆盖:LinkIcon spec 覆盖别名、各回退分支与尺寸座位;markdown spec 固定一个已知锚点与一个未知锚点;web 卡片 spec 用同一批标记固定一个来源链接与一个抓取链接。

+ 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/deepseek-harness/deepseek-harness'
 
 /** 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.',
@@ -421,6 +427,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/deepseek-harness/deepseek-harness
+  - 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: 9b92c06174912509abaeb01f4091701eb1c2663b
+README.zh.md: 80481c01f4c3609855286188a0df97e82dfd17b0

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1 - 1
packages/client/ui-primitives/README.md


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1 - 1
packages/client/ui-primitives/README.zh.md


+ 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} />

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 25 - 0
packages/client/ui-primitives/src/SiteGlyph.tsx


+ 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>
   )

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

@@ -63,3 +63,78 @@ describe('LinkIcon', () => {
     expect(svg.classList.contains('x')).toBe(true)
   })
 })
+
+/** One host per mapped site, plus the aliases that must resolve to a mark already listed. */
+const SITE_URLS: string[] = [
+  'https://github.com/org/repo',
+  'https://gist.github.com/abc',
+  '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://twitter.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',
+]
+
+/** 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)
+    // Sixteen destinations, fourteen mapped sites: the GitHub aliases and the
+    // X alias share their site's mark, and any other duplicate is a mapping bug.
+    expect(new Set(marks).size).toBe(14)
+  })
+
+  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://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'))
+  })
+
+  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 hostless URL', '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 - 0
packages/client/ui-primitives/tests/web-block.client.spec.tsx

@@ -3,6 +3,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 } from '../src/index.ts'
 import type {
   WebFetchBlockProps, WebSearchBlockProps, WebSourceView,
 } from '../src/index.ts'
@@ -28,6 +29,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 +109,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 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' },

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels