diff --git a/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.i18n.yaml new file mode 100644 index 0000000000..1fe74ba0bd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-21-replaceable-icon-sprite.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md +/agent-note-replaceable-product-icons-through-an-svg-sprite: + en: 2b527e550e071451 + zh: 7de256912270cae0 +/agent-note-replaceable-product-icons-through-an-svg-sprite/problem: + en: d55557fe41ab87c8 + zh: eea3f7d0c82561bf +/agent-note-replaceable-product-icons-through-an-svg-sprite/decision: + en: 6ddd80eae56693dd + zh: 4d654a76eb64c50a +/agent-note-replaceable-product-icons-through-an-svg-sprite/alternatives-considered: + en: 2e25ece88bccdb22 + zh: bb24a28bef7ea7ce +/agent-note-replaceable-product-icons-through-an-svg-sprite/consequences: + en: 6d343670ae58a4bc + zh: 189fedf3b15417cb diff --git a/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md new file mode 100644 index 0000000000..b70c585fb9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md @@ -0,0 +1,26 @@ +# Agent Note: Replaceable product icons through an SVG sprite + +Status: implemented + +English | [中文](2026-10-21-replaceable-icon-sprite.zh.md) + +## Problem + +Long-term skin customization needs themes to replace icons, not only colors. Product icons were React components that drew inline SVG paths, so a theme registered through `ctx.theme` could recolor them through `currentColor` but could not change their artwork. About 150 Client files import these components. + +## Decision + +Each product glyph weight is one sprite symbol, `dsw-icon--`. ui-primitives keeps the default SVG documents as `ICON_SYMBOLS`; an icon component keeps its export name, `size`, and `className`, and renders an `` box with one `` of its symbol. ui-theme keeps one hidden sprite in the document whose `` children come from the active theme's `ThemeDefinition.icons` replacements or the defaults. Artwork reached through `` inherits `currentColor` and CSS custom properties from the use site, so a replacement can be multi-color, can paint with `--dsw-*` color tokens, and follows light and dark palettes without separate values. + +## Alternatives considered + +- **Icons as CSS image tokens painted through `mask-image`.** Rejected after design review: a mask keeps only the image's alpha, so every icon stays one color, and a `url()` image cannot read CSS variables, so multi-color artwork could not follow color tokens. It would have reused the token override layer without a new theme field. +- **A runtime icon registry service with React components as values.** Rejected: components cannot read services or React context, so every icon import site would need a new prop or slot. +- **A slot per icon.** Rejected for the same caller cost; slots carry composition, not artwork. + +## Consequences + +- Icon DOM contains a `` reference instead of paths; tests identify glyphs through `data-icon` and inspect artwork through `ICON_SYMBOLS`. +- Without ui-theme, the sprite is absent and icons render nothing. +- Every symbol shares the document id space, so element ids inside default artwork and replacements must be unique; `` in another document, such as an iframe, cannot reach the sprite. +- File-type, plugin, site, and brand artwork still render inline and are not replaceable yet. diff --git a/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.zh.md b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.zh.md new file mode 100644 index 0000000000..8e4fcaea2c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.zh.md @@ -0,0 +1,26 @@ +# Agent Note: 通过 SVG sprite 实现可替换的产品图标 + +Status: implemented + +[English](2026-10-21-replaceable-icon-sprite.md) | 中文 + +## 问题 + +长期的皮肤定制需要主题能替换图标,而不只是颜色。产品图标原先是绘制内联 SVG 路径的 React 组件,因此通过 `ctx.theme` 注册的主题可以借助 `currentColor` 给它们换色,却无法更换图形。约 150 个 Client 文件导入了这些组件。 + +## 决策 + +每个产品图形的每种线重对应一个 sprite symbol `dsw-icon--`。ui-primitives 以 `ICON_SYMBOLS` 保存默认 SVG 文档;图标组件保留导出名、`size` 与 `className`,渲染一个 `` 框,其中包含一个引用自身 symbol 的 ``。ui-theme 在文档中保留一个隐藏的 sprite,其 `` 子元素来自活动主题的 `ThemeDefinition.icons` 替换项或默认值。通过 `` 引用的图形会从使用处继承 `currentColor` 与 CSS 自定义属性,因此替换图形可以是多色的,可以用 `--dsw-*` 颜色 token 绘制,并且无需单独的值就能跟随浅色与深色调色板。 + +## 考虑过的替代方案 + +- **把图标作为通过 `mask-image` 绘制的 CSS 图像 token。** 经设计评审后否决:遮罩只保留图像的 alpha,因此每个图标都只能是单色;`url()` 图像无法读取 CSS 变量,因此多色图形无法跟随颜色 token。该方案本可复用 token 覆盖层,而无需新增主题字段。 +- **以 React 组件为值的运行时图标注册服务。** 否决:组件无法读取服务或 React context,因此每个图标导入处都需要新的 prop 或 slot。 +- **每个图标一个 slot。** 因同样的调用方成本而否决;slot 承载的是组合,而不是图形。 + +## 后果 + +- 图标 DOM 包含 `` 引用而不是路径;测试通过 `data-icon` 识别图形,通过 `ICON_SYMBOLS` 检查图形。 +- 没有 ui-theme 时,sprite 不存在,图标不渲染任何内容。 +- 所有 symbol 共享文档的 id 空间,因此默认图形与替换图形内的元素 id 必须唯一;另一个文档(例如 iframe)中的 `` 无法引用该 sprite。 +- 文件类型、插件、站点与品牌图形仍以内联方式渲染,暂不可替换。 diff --git a/packages/client/ui-agent-preset/tests/components.client.spec.tsx b/packages/client/ui-agent-preset/tests/components.client.spec.tsx index 79cc930e01..20b0d1bacc 100644 --- a/packages/client/ui-agent-preset/tests/components.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.client.spec.tsx @@ -301,7 +301,7 @@ describe('the chip introduce cue', () => { /** Character spans carry inline animation delays; nothing else does. */ function delayedChars(): HTMLElement[] { - return Array.from(screen.getByRole('button').querySelectorAll('[style]')) + return Array.from(screen.getByRole('button').querySelectorAll('span[style]')) } it('reveals a long Latin name inside the shared window, then acknowledges', () => { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 9a13d1d0a6..10a148097b 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -21,8 +21,8 @@ en: cb874a0ca53a6747 zh: 31179193dd1d6e12 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: fff68b60f4abdc7e - zh: 5cf5952ec78f7f91 + en: dbd7f0e74c45a762 + zh: 7efbfbf5500cc835 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: 629f57d60fe5cce1 zh: 5263447a14c21d61 @@ -33,8 +33,8 @@ en: 846ac0f563a53a64 zh: bb89d7647bdcc1ab /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map: - en: 44eb32370cbeafe8 - zh: 728009c66375c6bb + en: fd510d34404eb090 + zh: d05b0890db929ae6 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/input-modality: en: cb61e1a37d6c27e5 zh: 1f3fa79dd5ab2e45 @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work: - en: 039cf54697c97608 - zh: ac95443caa5be0ed + en: e4200a0d5b4ec317 + zh: c730a48344c7e0d4 /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index ecd3fd4e29..07f8f1fc97 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -88,7 +88,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product icon renders an `` box with one `` of its `dsw-icon--` symbol, for example `dsw-icon-search-outline-regular`, and names that id in `data-icon`. `ICON_SYMBOLS` holds every symbol's default SVG document; ui-theme renders the document icon sprite from it, and an icon whose symbol is missing renders nothing. Artwork reached through `` inherits `currentColor` and CSS variables from the use site. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. @@ -149,7 +149,7 @@ The package enforces one separation: presentational React atoms with zero Cordis | [`src/TerminalBlock.tsx`](src/TerminalBlock.tsx) | ANSI escape parsing (`anser`) and terminal card rendering | | [`src/ReadBlock.tsx`](src/ReadBlock.tsx) / [`src/DiffBlock.tsx`](src/DiffBlock.tsx) | Read and diff cards | | [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | Search and web-retrieval cards | -| [`src/icons/`](src/icons/) | Size-neutral `Regular` and `Medium` product glyph components | +| [`src/icons/`](src/icons/) | Default product glyph artwork, its `ICON_SYMBOLS` sprite documents, and the size-neutral `Regular` and `Medium` components that reference those symbols | | [`src/code-highlighting.ts`](src/code-highlighting.ts) | Shared filename grammar selection and lazy line highlighting | | [`src/input-modality.ts`](src/input-modality.ts) | Document-wide input modality published on `` | | [`src/plugin-artwork.tsx`](src/plugin-artwork.tsx) | Fixed-palette plugin artwork with per-instance SVG def ids | @@ -211,6 +211,7 @@ These limits define how the atoms behave at the edges; they are current package - **Overlong lines stay uncolored** — a source line of 1,000 or more UTF-16 units renders as one plain run in every highlighted surface, because TextMate scanning cost grows superlinearly with line length and a single generated line can otherwise block the page for seconds. The grammar state skips that line, so a comment or template literal that opens or closes on it mis-colors the following lines. Highlighting still runs synchronously during render, so a file with thousands of ordinary lines costs proportionally more. - **The fish logo is a redrawn approximation** — its font glyph has no exportable vector geometry in the local design data, so a hand-authored recreation stands in until an exact export path exists. - **`Pill` and `Input` have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. +- **Product icons need the ui-theme sprite** — each icon only references its symbol, so without ui-theme, or inside another document such as an iframe, the icon renders nothing. - **No `Active` `StateDot` variant** — the supported states are done, warning, ongoing, error, and idle. - **User-facing copy is required at the render site** — the atoms are zero-Cordis and cannot reach `ctx.locale`; each feature must supply complete localized labels through the primitive's typed props ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). - **Display math keeps fallback shapes** — a `$$x$$ trailing` on the opening line resolves through upstream inline math and leaves the surrounding prose in place, while the same trailing text on a later line declines the block and upstream takes the prose up to the next lone `$$` or EOF; a block left open ends at the next lone `$$` or at EOF; a later fence behind a `%` comment that holds text closes nothing, so a block nothing else closes reaches the same fallback; a multi-line block whose opening line carries a solitary `$` stays in its paragraph unless a blank line separates them; and a container continuation line that leaves the item indentation hands the block to upstream, which drops the opening line. `$$` fence meta is no longer honored ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md)). diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 3de121ab2c..79d92cd01e 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -88,7 +88,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `` 框,其中包含一个引用对应 `dsw-icon--` symbol 的 ``,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `` 引用的图形会从使用处继承 `currentColor` 与 CSS 变量。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 @@ -149,7 +149,7 @@ kind: "package-library" | [`src/TerminalBlock.tsx`](src/TerminalBlock.tsx) | ANSI 转义解析(`anser`)与终端卡片渲染 | | [`src/ReadBlock.tsx`](src/ReadBlock.tsx) / [`src/DiffBlock.tsx`](src/DiffBlock.tsx) | 读取与差异卡片 | | [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | 搜索与网页检索卡片 | -| [`src/icons/`](src/icons/) | 与尺寸无关的 `Regular` 和 `Medium` 产品图标组件 | +| [`src/icons/`](src/icons/) | 默认产品图标图形、其 `ICON_SYMBOLS` sprite 文档,以及引用这些 symbol 的与尺寸无关的 `Regular` 和 `Medium` 组件 | | [`src/code-highlighting.ts`](src/code-highlighting.ts) | 共享的文件名 grammar 选择与惰性逐行高亮 | | [`src/input-modality.ts`](src/input-modality.ts) | 全文档输入模态,发布到 `` | | [`src/plugin-artwork.tsx`](src/plugin-artwork.tsx) | 固定配色插件插画,SVG def id 按实例生成 | @@ -211,6 +211,7 @@ kind: "package-library" - **超长行不着色**:所有高亮界面中,长度达到 1,000 个 UTF-16 单元的源码行渲染为一段纯文本,因为 TextMate 扫描成本随行长超线性增长,单个生成行否则会阻塞页面数秒。语法状态会跳过该行,因此在该行开始或结束的注释、模板字符串会使后续行着色错误。高亮仍在渲染期间同步执行,因此数千个普通行的文件仍按比例增加开销。 - **鱼形标志是重新绘制的近似版本**:它来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **`Pill` 与 `Input` 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 +- **产品图标依赖 ui-theme 的 sprite**:每个图标只引用自己的 symbol,因此没有 ui-theme 时,或在 iframe 等另一个文档中,图标不渲染任何内容。 - **`StateDot` 没有 `Active` 变体**:支持的状态为 done、warning、ongoing、error 和 idle。 - **面向用户的文案必须由渲染点提供**:这些原子组件是 zero-Cordis 的,拿不到 `ctx.locale`;各功能必须通过原子组件的带类型 prop 提供完整本地化 label(见[决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。 - **显示数学保留若干回退形状**:开围栏行上的 `$$x$$ trailing` 走上游行内数学,前后正文保持不变;同样形态出现在后续行时块被放弃,随后由上游把正文吞到下一个独占行 `$$` 或文件结尾;未闭合的块在下一个独占行 `$$` 或文件结尾结束;后续行的围栏若位于**含文本**的 `%` 注释之后则不闭合,若无其他围栏闭合,块走同一条回退;开围栏行含孤立 `$` 的多行块会留在段落内,除非与前文空一行;容器内续行若离开条目缩进,块交由上游 flow 处理并丢弃开围栏行。`$$` 围栏 meta 不再生效(见[决策](../../../.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md))。 diff --git a/packages/client/ui-primitives/src/icons/artwork.ts b/packages/client/ui-primitives/src/icons/artwork.ts new file mode 100644 index 0000000000..4a7db98299 --- /dev/null +++ b/packages/client/ui-primitives/src/icons/artwork.ts @@ -0,0 +1,150 @@ +/** + * Default artwork for the product icon set. Each glyph is an SVG document + * whose geometry may depend on the stroke weight; ui-theme publishes every + * glyph and weight as one `` in the + * document icon sprite, which a theme can replace with its own SVG. + */ + +/** Regular stroke width used by the product icon set. */ +export const ICON_REGULAR_STROKE = 1 + +/** Medium stroke width used by emphasized product icons. */ +export const ICON_MEDIUM_STROKE = 1.3 + +/** One product glyph: default rendered height, `[width, height]` box ratio when not square, and SVG source per stroke width. */ +interface IconArtwork { + size: number + aspect?: readonly [number, number] + svg: (strokeWidth: number) => string +} + +/** Default artwork keyed by glyph name; symbol ids derive from these keys. */ +export const ICON_ARTWORK = { + 'new-chat-outline': { size: 16, svg: strokeWidth => `` }, + 'search-outline': { size: 16, svg: strokeWidth => `` }, + 'globe-outline': { size: 14, svg: strokeWidth => `` }, + 'settings-outline': { size: 16, svg: strokeWidth => `` }, + 'panel-left-outline': { size: 16, svg: strokeWidth => `` }, + 'ellipsis-outline': { size: 16, svg: strokeWidth => `` }, + 'plus-outline': { size: 16, svg: strokeWidth => `` }, + 'check-outline': { size: 16, svg: strokeWidth => `` }, + 'branch-outline': { size: 16, svg: strokeWidth => `` }, + 'chevron-down-outline': { size: 14, svg: strokeWidth => `` }, + 'chevron-left-outline': { size: 14, svg: strokeWidth => `` }, + 'chevron-right-outline': { size: 14, svg: strokeWidth => `` }, + 'triangle-right-fill': { size: 14, svg: strokeWidth => `` }, + 'chevron-up-outline': { size: 14, svg: strokeWidth => `` }, + 'close-outline': { size: 16, svg: strokeWidth => `` }, + 'close-fill': { size: 14, svg: strokeWidth => `` }, + 'close-circle-fill': { size: 16, svg: strokeWidth => `` }, + 'copy-outline': { size: 16, svg: strokeWidth => `` }, + 'refresh-outline': { size: 16, svg: strokeWidth => `` }, + 'like-outline': { size: 16, svg: strokeWidth => `` }, + 'like-fill': { size: 16, svg: strokeWidth => `` }, + 'dislike-outline': { size: 16, svg: strokeWidth => `` }, + 'dislike-fill': { size: 16, svg: strokeWidth => `` }, + 'share-outline': { size: 16, svg: strokeWidth => `` }, + 'deliver-doc': { size: 16, svg: strokeWidth => `` }, + 'edit-outline': { size: 16, svg: strokeWidth => `` }, + 'think-outline': { size: 16, svg: strokeWidth => `` }, + 'agent-preset-outline': { size: 16, svg: strokeWidth => `` }, + 'browse-outline': { size: 16, svg: strokeWidth => `` }, + 'context-injection-outline': { size: 16, svg: strokeWidth => `` }, + 'link-outline': { size: 16, svg: strokeWidth => `` }, + 'right-up-outline': { size: 16, svg: strokeWidth => `` }, + 'enhance-outline': { size: 16, svg: strokeWidth => `` }, + 'trash-outline': { size: 16, svg: strokeWidth => `` }, + 'warning-outline': { size: 16, svg: strokeWidth => `` }, + 'check-circle-fill': { size: 16, svg: strokeWidth => `` }, + 'warning-triangle-outline': { size: 16, svg: strokeWidth => `` }, + 'user-outline': { size: 16, svg: strokeWidth => `` }, + 'users-outline': { size: 16, svg: strokeWidth => `` }, + 'paper-plane-outline': { size: 14, svg: strokeWidth => `` }, + 'stop-fill': { size: 16, svg: strokeWidth => `` }, + 'paperclip-outline': { size: 16, svg: strokeWidth => `` }, + 'loading-outline': { size: 16, svg: strokeWidth => `` }, + 'download-outline': { size: 16, svg: strokeWidth => `` }, + 'play-outline': { size: 16, svg: strokeWidth => `` }, + 'pause-outline': { size: 16, svg: strokeWidth => `` }, + 'fullscreen-outline': { size: 16, svg: strokeWidth => `` }, + 'code-outline': { size: 16, svg: strokeWidth => `` }, + 'cordis-plugin-outline': { size: 14, svg: strokeWidth => `` }, + 'api-outline': { size: 14, svg: strokeWidth => `` }, + 'personalization-outline': { size: 16, svg: strokeWidth => `` }, + 'project-add-outline': { size: 16, svg: strokeWidth => `` }, + 'folder-open-outline': { size: 16, svg: strokeWidth => `` }, + 'folder-open': { size: 16, svg: strokeWidth => `` }, + 'folder-close': { size: 16, svg: strokeWidth => `` }, + 'tree-corner': { size: 10, aspect: [8, 10], svg: strokeWidth => `` }, + 'light-outline': { size: 16, svg: strokeWidth => `` }, + 'dark-outline': { size: 16, svg: strokeWidth => `` }, + 'followsystem-outline': { size: 16, svg: strokeWidth => `` }, + 'data-outline': { size: 16, svg: strokeWidth => `` }, + 'database-outline': { size: 16, svg: strokeWidth => `` }, + 'clock-outline': { size: 16, svg: strokeWidth => `` }, + 'gauge-outline': { size: 16, svg: strokeWidth => `` }, + 'send-outline': { size: 14, svg: strokeWidth => `` }, + 'queue-outline': { size: 14, svg: strokeWidth => `` }, + 'checklist-outline': { size: 14, svg: strokeWidth => `` }, + 'list-pen-outline': { size: 16, svg: strokeWidth => `` }, + 'goal-outline': { size: 16, svg: strokeWidth => `` }, + 'sparkle': { size: 16, svg: strokeWidth => `` }, + 'inspect-outline': { size: 12, svg: strokeWidth => `` }, + 'skill-outline': { size: 16, svg: strokeWidth => `` }, + 'question-outline': { size: 14, svg: strokeWidth => `` }, + 'info-outline': { size: 14, svg: strokeWidth => `` }, + 'plugin-pinwheel-outline': { size: 16, svg: strokeWidth => `` }, + 'alarm-clock-outline': { size: 16, svg: strokeWidth => `` }, + 'archive-outline': { size: 20, svg: strokeWidth => `` }, + 'wrap-lines-outline': { size: 16, svg: strokeWidth => `` }, + 'nowrap-fill': { size: 16, svg: () => '' }, + 'wrap-fill': { size: 16, svg: () => '' }, + 'compare-split-outline': { size: 16, svg: strokeWidth => `` }, + 'plan-outline': { size: 14, svg: strokeWidth => `` }, + 'compact-outline': { size: 16, svg: strokeWidth => `` }, + 'shield-outline': { size: 16, svg: strokeWidth => `` }, + 'check-circle-outline': { size: 16, svg: strokeWidth => `` }, + 'unarchive-outline': { size: 20, svg: strokeWidth => `` }, + 'pin-outline': { size: 16, svg: strokeWidth => `` }, + 'pin-fill': { size: 16, svg: strokeWidth => `` }, + 'flat-list-outline': { size: 16, svg: strokeWidth => `` }, + 'workspace-tree-outline': { size: 16, svg: strokeWidth => `` }, + 'chevrons-up-down-outline': { size: 16, svg: strokeWidth => `` }, + 'archive-off-outline': { size: 16, svg: strokeWidth => strokeWidth === ICON_MEDIUM_STROKE + ? `` + : `` }, + 'archive-check-outline': { size: 16, svg: strokeWidth => `` }, + 'sliders-two-outline': { size: 16, svg: strokeWidth => `` }, + 'microphone-outline': { size: 16, svg: strokeWidth => strokeWidth === ICON_MEDIUM_STROKE + ? `` + : `` }, + 'apps-add-outline': { size: 16, svg: strokeWidth => `` }, + 'apps-add-compact-outline': { size: 16, svg: strokeWidth => `` }, +} satisfies Record + +/** Glyph name accepted by {@link iconSymbolId}. */ +export type IconGlyph = keyof typeof ICON_ARTWORK + +/** Weight suffix of a product icon symbol. */ +export type IconWeight = 'regular' | 'medium' + +const STROKES: Record = { regular: ICON_REGULAR_STROKE, medium: ICON_MEDIUM_STROKE } + +/** + * Name the sprite symbol that carries one glyph weight. + * @param glyph - glyph key in {@link ICON_ARTWORK}. + * @param weight - stroke weight. + * @returns the `dsw-icon--` element id. + */ +export function iconSymbolId(glyph: IconGlyph, weight: IconWeight): IconSymbolId { + return `dsw-icon-${glyph}-${weight}` +} + +/** Sprite symbol id of one product glyph weight. */ +export type IconSymbolId = `dsw-icon-${IconGlyph}-${IconWeight}` + +/** Default SVG document of every product icon symbol, keyed by symbol id. */ +export const ICON_SYMBOLS = Object.freeze(Object.fromEntries( + (Object.entries(ICON_ARTWORK) as [IconGlyph, IconArtwork][]).flatMap(([glyph, artwork]) => + (Object.keys(STROKES) as IconWeight[]).map(weight => [iconSymbolId(glyph, weight), artwork.svg(STROKES[weight])])), +)) as Readonly> diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index cdfb4faf5e..ddfff9aaa7 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -1,1686 +1,604 @@ /** * Shared current-color product icons. Names identify the glyph and weight; - * rendered size remains a prop instead of part of the component name. + * rendered size remains a prop instead of part of the component name. Each + * icon references its `dsw-icon--` symbol in the document icon + * sprite that ui-theme maintains, so a theme can replace the artwork without + * touching callers and the artwork inherits `currentColor` and CSS variables. */ -import { useId } from 'react' +import { ICON_ARTWORK, iconSymbolId, type IconGlyph, type IconWeight } from './artwork.ts' import type { IconProps } from './props.ts' -import { - BrowseOutlineArtwork, ChatLinesOutlineArtwork, CodeBracketsArtwork, FolderCloseArtwork, - GlobeOutlineArtwork, NewChatOutlineArtwork, -} from './shared-artwork.tsx' export type { IconProps } from './props.ts' - -interface WeightedIconProps extends IconProps { - strokeWidth: number -} +export { ICON_MEDIUM_STROKE, ICON_REGULAR_STROKE, ICON_SYMBOLS, type IconSymbolId } from './artwork.ts' /** Shared shield contour used by composite icons outside this module. */ export const SHIELD_OUTLINE_PATH = 'M6.80132 2.14853C7.70663 1.80917 8.70422 1.80919 9.60952 2.14859L14.1296 3.84317V7.11961C14.1296 11.6089 10.7615 13.5975 8.20543 14.5779C5.64931 13.5975 2.28052 11.6089 2.28052 7.11961V3.84317L6.80132 2.14853Z' -/** Regular stroke width used by the product icon set. */ -export const ICON_REGULAR_STROKE = 1 - -/** Medium stroke width used by emphasized product icons. */ -export const ICON_MEDIUM_STROKE = 1.3 - -/** Regular one-pixel IconNewChatOutline artwork. */ -export const IconNewChatOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconNewChatOutline artwork with a 1.3px stroke. */ -export const IconNewChatOutlineMedium = (props: IconProps) => ( - -) - -const IconSearchOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconSearchOutline artwork. */ -export const IconSearchOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconSearchOutline artwork with a 1.3px stroke. */ -export const IconSearchOutlineMedium = (props: IconProps) => ( - -) - -/** Regular one-pixel IconGlobeOutline artwork. */ -export const IconGlobeOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconGlobeOutline artwork with a 1.3px stroke. */ -export const IconGlobeOutlineMedium = (props: IconProps) => ( - -) - -const IconSettingsOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconSettingsOutline artwork. */ -export const IconSettingsOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconSettingsOutline artwork with a 1.3px stroke. */ -export const IconSettingsOutlineMedium = (props: IconProps) => ( - -) - -const IconPanelLeftOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconPanelLeftOutline artwork. */ -export const IconPanelLeftOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconPanelLeftOutline artwork with a 1.3px stroke. */ -export const IconPanelLeftOutlineMedium = (props: IconProps) => ( - -) - -const IconEllipsisOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular IconEllipsisOutline artwork; its fill-only geometry is weight-independent. */ -export const IconEllipsisOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconEllipsisOutline artwork; it matches Regular because the geometry is fill-only. */ -export const IconEllipsisOutlineMedium = (props: IconProps) => ( - -) - -const IconPlusOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconPlusOutline artwork. */ -export const IconPlusOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconPlusOutline artwork with a 1.3px stroke. */ -export const IconPlusOutlineMedium = (props: IconProps) => ( - -) - -const IconCheckOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconCheckOutline artwork. */ -export const IconCheckOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconCheckOutline artwork with a 1.3px stroke. */ -export const IconCheckOutlineMedium = (props: IconProps) => ( - -) - -const IconBranchOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconBranchOutline artwork. */ -export const IconBranchOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconBranchOutline artwork with a 1.3px stroke. */ -export const IconBranchOutlineMedium = (props: IconProps) => ( - -) - -const IconChevronDownOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconChevronDownOutline artwork. */ -export const IconChevronDownOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconChevronDownOutline artwork with a 1.3px stroke. */ -export const IconChevronDownOutlineMedium = (props: IconProps) => ( - -) - -const IconChevronLeftOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconChevronLeftOutline artwork. */ -export const IconChevronLeftOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconChevronLeftOutline artwork with a 1.3px stroke. */ -export const IconChevronLeftOutlineMedium = (props: IconProps) => ( - -) - -const IconChevronRightOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconChevronRightOutline artwork. */ -export const IconChevronRightOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconChevronRightOutline artwork with a 1.3px stroke. */ -export const IconChevronRightOutlineMedium = (props: IconProps) => ( - -) - -const IconTriangleRightFillArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular IconTriangleRightFill artwork; its fill-only geometry is weight-independent. */ -export const IconTriangleRightFillRegular = (props: IconProps) => ( - -) - -/** Medium IconTriangleRightFill artwork; it matches Regular because the geometry is fill-only. */ -export const IconTriangleRightFillMedium = (props: IconProps) => ( - -) - -const IconChevronUpOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconChevronUpOutline artwork. */ -export const IconChevronUpOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconChevronUpOutline artwork with a 1.3px stroke. */ -export const IconChevronUpOutlineMedium = (props: IconProps) => ( - -) - -const IconCloseOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconCloseOutline artwork. */ -export const IconCloseOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconCloseOutline artwork with a 1.3px stroke. */ -export const IconCloseOutlineMedium = (props: IconProps) => ( - -) - -const IconCloseFillArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconCloseFill artwork. */ -export const IconCloseFillRegular = (props: IconProps) => ( - -) - -/** Medium IconCloseFill artwork with a 1.3px stroke. */ -export const IconCloseFillMedium = (props: IconProps) => ( - -) - -// A 7-unit disc with the cross knocked out of it (even-odd), so the cross -// takes the color of whatever sits behind the glyph. -const IconCloseCircleFillArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular IconCloseCircleFill artwork (cross knocked out of a filled disc); its fill-only geometry is weight-independent. */ -export const IconCloseCircleFillRegular = (props: IconProps) => ( - -) - -/** Medium IconCloseCircleFill artwork; it matches Regular because the geometry is fill-only. */ -export const IconCloseCircleFillMedium = (props: IconProps) => ( - -) - -const IconCopyOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconCopyOutline artwork. */ -export const IconCopyOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconCopyOutline artwork with a 1.3px stroke. */ -export const IconCopyOutlineMedium = (props: IconProps) => ( - -) - -const IconRefreshOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconRefreshOutline artwork. */ -export const IconRefreshOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconRefreshOutline artwork with a 1.3px stroke. */ -export const IconRefreshOutlineMedium = (props: IconProps) => ( - -) - -const IconLikeOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconLikeOutline artwork. */ -export const IconLikeOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconLikeOutline artwork with a 1.3px stroke. */ -export const IconLikeOutlineMedium = (props: IconProps) => ( - -) - -const IconLikeFillArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconLikeFill artwork. */ -export const IconLikeFillRegular = (props: IconProps) => ( - -) - -/** Medium IconLikeFill artwork with a 1.3px stroke. */ -export const IconLikeFillMedium = (props: IconProps) => ( - -) - -const IconDislikeOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconDislikeOutline artwork. */ -export const IconDislikeOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconDislikeOutline artwork with a 1.3px stroke. */ -export const IconDislikeOutlineMedium = (props: IconProps) => ( - -) - -const IconDislikeFillArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconDislikeFill artwork. */ -export const IconDislikeFillRegular = (props: IconProps) => ( - -) - -/** Medium IconDislikeFill artwork with a 1.3px stroke. */ -export const IconDislikeFillMedium = (props: IconProps) => ( - -) - -const IconShareOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconShareOutline artwork. */ -export const IconShareOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconShareOutline artwork with a 1.3px stroke. */ -export const IconShareOutlineMedium = (props: IconProps) => ( - -) - -const IconDeliverDocArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconDeliverDoc artwork. */ -export const IconDeliverDocRegular = (props: IconProps) => ( - -) - -/** Medium IconDeliverDoc artwork with a 1.3px stroke. */ -export const IconDeliverDocMedium = (props: IconProps) => ( - -) - -const IconEditOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconEditOutline artwork. */ -export const IconEditOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconEditOutline artwork with a 1.3px stroke. */ -export const IconEditOutlineMedium = (props: IconProps) => ( - -) - -const IconThinkOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconThinkOutline artwork. */ -export const IconThinkOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconThinkOutline artwork with a 1.3px stroke. */ -export const IconThinkOutlineMedium = (props: IconProps) => ( - -) - -const IconAgentPresetOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconAgentPresetOutline artwork. */ -export const IconAgentPresetOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconAgentPresetOutline artwork with a 1.3px stroke. */ -export const IconAgentPresetOutlineMedium = (props: IconProps) => ( - -) - -/** Regular one-pixel IconBrowseOutline artwork. */ -export const IconBrowseOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconBrowseOutline artwork with a 1.3px stroke. */ -export const IconBrowseOutlineMedium = (props: IconProps) => ( - -) - -const IconContextInjectionOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconContextInjectionOutline artwork. */ -export const IconContextInjectionOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconContextInjectionOutline artwork with a 1.3px stroke. */ -export const IconContextInjectionOutlineMedium = (props: IconProps) => ( - -) - -const IconLinkOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconLinkOutline artwork. */ -export const IconLinkOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconLinkOutline artwork with a 1.3px stroke. */ -export const IconLinkOutlineMedium = (props: IconProps) => ( - -) - -const IconRightUpOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconRightUpOutline artwork. */ -export const IconRightUpOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconRightUpOutline artwork with a 1.3px stroke. */ -export const IconRightUpOutlineMedium = (props: IconProps) => ( - -) - -const IconEnhanceOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconEnhanceOutline artwork. */ -export const IconEnhanceOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconEnhanceOutline artwork with a 1.3px stroke. */ -export const IconEnhanceOutlineMedium = (props: IconProps) => ( - -) - -const IconTrashOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconTrashOutline artwork. */ -export const IconTrashOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconTrashOutline artwork with a 1.3px stroke. */ -export const IconTrashOutlineMedium = (props: IconProps) => ( - -) - -const IconWarningOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconWarningOutline artwork. */ -export const IconWarningOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconWarningOutline artwork with a 1.3px stroke. */ -export const IconWarningOutlineMedium = (props: IconProps) => ( - -) - -const IconCheckCircleFillArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular IconCheckCircleFill artwork (circled check); its fill-only geometry is weight-independent. */ -export const IconCheckCircleFillRegular = (props: IconProps) => ( - -) - -/** Medium IconCheckCircleFill artwork; it matches Regular because the geometry is fill-only. */ -export const IconCheckCircleFillMedium = (props: IconProps) => ( - -) - -const IconWarningTriangleOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular rounded warning triangle with an exclamation mark. */ -export const IconWarningTriangleOutlineRegular = (props: IconProps) => ( - -) - -/** Medium rounded warning triangle with an exclamation mark. */ -export const IconWarningTriangleOutlineMedium = (props: IconProps) => ( - -) - -const IconUserOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconUserOutline artwork. */ -export const IconUserOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconUserOutline artwork with a 1.3px stroke. */ -export const IconUserOutlineMedium = (props: IconProps) => ( - -) - -const IconUsersOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconUsersOutline artwork: a front person with a second person behind. */ -export const IconUsersOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconUsersOutline artwork with a 1.3px stroke. */ -export const IconUsersOutlineMedium = (props: IconProps) => ( - -) - -const IconPaperPlaneOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconPaperPlaneOutline artwork. */ -export const IconPaperPlaneOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconPaperPlaneOutline artwork with a 1.3px stroke. */ -export const IconPaperPlaneOutlineMedium = (props: IconProps) => ( - -) - -const IconStopFillArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular IconStopFill artwork; its fill-only geometry is weight-independent. */ -export const IconStopFillRegular = (props: IconProps) => ( - -) - -/** Medium IconStopFill artwork; it matches Regular because the geometry is fill-only. */ -export const IconStopFillMedium = (props: IconProps) => ( - -) - -const IconPaperclipOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconPaperclipOutline artwork. */ -export const IconPaperclipOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconPaperclipOutline artwork with a 1.3px stroke. */ -export const IconPaperclipOutlineMedium = (props: IconProps) => ( - -) - -const IconLoadingOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconLoadingOutline artwork. */ -export const IconLoadingOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconLoadingOutline artwork with a 1.3px stroke. */ -export const IconLoadingOutlineMedium = (props: IconProps) => ( - -) - -const IconDownloadOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconDownloadOutline artwork. */ -export const IconDownloadOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconDownloadOutline artwork with a 1.3px stroke. */ -export const IconDownloadOutlineMedium = (props: IconProps) => ( - -) - -const IconPlayOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconPlayOutline artwork. */ -export const IconPlayOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconPlayOutline artwork with a 1.3px stroke. */ -export const IconPlayOutlineMedium = (props: IconProps) => ( - -) - -const IconPauseOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconPauseOutline artwork. */ -export const IconPauseOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconPauseOutline artwork with a 1.3px stroke. */ -export const IconPauseOutlineMedium = (props: IconProps) => ( - -) - -const IconFullscreenOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconFullscreenOutline artwork. */ -export const IconFullscreenOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconFullscreenOutline artwork with a 1.3px stroke. */ -export const IconFullscreenOutlineMedium = (props: IconProps) => ( - -) - -const IconCodeOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconCodeOutline artwork. */ -export const IconCodeOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconCodeOutline artwork with a 1.3px stroke. */ -export const IconCodeOutlineMedium = (props: IconProps) => ( - -) - -const IconCordisPluginOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconCordisPluginOutline artwork. */ -export const IconCordisPluginOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconCordisPluginOutline artwork with a 1.3px stroke. */ -export const IconCordisPluginOutlineMedium = (props: IconProps) => ( - -) - -const IconApiOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconApiOutline artwork. */ -export const IconApiOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconApiOutline artwork with a 1.3px stroke. */ -export const IconApiOutlineMedium = (props: IconProps) => ( - -) - -const IconPersonalizationOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconPersonalizationOutline artwork. */ -export const IconPersonalizationOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconPersonalizationOutline artwork with a 1.3px stroke. */ -export const IconPersonalizationOutlineMedium = (props: IconProps) => ( - -) - -const IconProjectAddOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconProjectAddOutline artwork. */ -export const IconProjectAddOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconProjectAddOutline artwork with a 1.3px stroke. */ -export const IconProjectAddOutlineMedium = (props: IconProps) => ( - -) - -const IconFolderOpenOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconFolderOpenOutline artwork. */ -export const IconFolderOpenOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconFolderOpenOutline artwork with a 1.3px stroke. */ -export const IconFolderOpenOutlineMedium = (props: IconProps) => ( - -) - -const IconFolderOpenArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular IconFolderOpen artwork; its fill-only geometry is weight-independent. */ -export const IconFolderOpenRegular = (props: IconProps) => ( - -) - -/** Medium IconFolderOpen artwork; it matches Regular because the geometry is fill-only. */ -export const IconFolderOpenMedium = (props: IconProps) => ( - -) - -/** Regular one-pixel IconFolderClose artwork. */ -export const IconFolderCloseRegular = (props: IconProps) => ( - -) - -/** Medium IconFolderClose artwork with a 1.3px stroke. */ -export const IconFolderCloseMedium = (props: IconProps) => ( - -) - -const IconTreeCornerArtwork = ({ size = 10, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconTreeCorner artwork. */ -export const IconTreeCornerRegular = (props: IconProps) => ( - -) - -/** Medium IconTreeCorner artwork with a 1.3px stroke. */ -export const IconTreeCornerMedium = (props: IconProps) => ( - -) - -const IconLightOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconLightOutline artwork. */ -export const IconLightOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconLightOutline artwork with a 1.3px stroke. */ -export const IconLightOutlineMedium = (props: IconProps) => ( - -) - -const IconDarkOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconDarkOutline artwork. */ -export const IconDarkOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconDarkOutline artwork with a 1.3px stroke. */ -export const IconDarkOutlineMedium = (props: IconProps) => ( - -) - -const IconFollowsystemOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconFollowsystemOutline artwork. */ -export const IconFollowsystemOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconFollowsystemOutline artwork with a 1.3px stroke. */ -export const IconFollowsystemOutlineMedium = (props: IconProps) => ( - -) - -const IconDataOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconDataOutline artwork. */ -export const IconDataOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconDataOutline artwork with a 1.3px stroke. */ -export const IconDataOutlineMedium = (props: IconProps) => ( - -) - -const IconDatabaseOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconDatabaseOutline artwork. */ -export const IconDatabaseOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconDatabaseOutline artwork with a 1.3px stroke. */ -export const IconDatabaseOutlineMedium = (props: IconProps) => ( - -) - -const IconClockOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconClockOutline artwork. */ -export const IconClockOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconClockOutline artwork with a 1.3px stroke. */ -export const IconClockOutlineMedium = (props: IconProps) => ( - -) - -const IconGaugeOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconGaugeOutline artwork. */ -export const IconGaugeOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconGaugeOutline artwork with a 1.3px stroke. */ -export const IconGaugeOutlineMedium = (props: IconProps) => ( - -) - -const IconSendOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconSendOutline artwork. */ -export const IconSendOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconSendOutline artwork with a 1.3px stroke. */ -export const IconSendOutlineMedium = (props: IconProps) => ( - -) - -const IconQueueOutlineArtwork = ({ size = 14, ...rest }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconQueueOutline artwork. */ -export const IconQueueOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconQueueOutline artwork with a 1.3px stroke. */ -export const IconQueueOutlineMedium = (props: IconProps) => ( - -) - -const IconChecklistOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconChecklistOutline artwork. */ -export const IconChecklistOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconChecklistOutline artwork with a 1.3px stroke. */ -export const IconChecklistOutlineMedium = (props: IconProps) => ( - -) - -const IconListPenOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconListPenOutline artwork. */ -export const IconListPenOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconListPenOutline artwork with a 1.3px stroke. */ -export const IconListPenOutlineMedium = (props: IconProps) => ( - -) - -const IconGoalOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconGoalOutline artwork. */ -export const IconGoalOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconGoalOutline artwork with a 1.3px stroke. */ -export const IconGoalOutlineMedium = (props: IconProps) => ( - -) - -const IconSparkleArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconSparkle artwork. */ -export const IconSparkleRegular = (props: IconProps) => ( - -) - -/** Medium IconSparkle artwork with a 1.3px stroke. */ -export const IconSparkleMedium = (props: IconProps) => ( - -) - -/** Regular one-pixel IconInspectOutline artwork. */ -export const IconInspectOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconInspectOutline artwork with a 1.3px stroke. */ -export const IconInspectOutlineMedium = (props: IconProps) => ( - -) - -const IconSkillOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconSkillOutline artwork. */ -export const IconSkillOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconSkillOutline artwork with a 1.3px stroke. */ -export const IconSkillOutlineMedium = (props: IconProps) => ( - -) - -const IconQuestionOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconQuestionOutline artwork. */ -export const IconQuestionOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconQuestionOutline artwork with a 1.3px stroke. */ -export const IconQuestionOutlineMedium = (props: IconProps) => ( - -) - -const IconInfoOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular IconInfoOutline artwork; its fill-only geometry is weight-independent. */ -export const IconInfoOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconInfoOutline artwork; its fill-only geometry is weight-independent. */ -export const IconInfoOutlineMedium = (props: IconProps) => ( - -) - -const IconPluginPinwheelOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel plugin pinwheel artwork. */ -export const IconPluginPinwheelOutlineRegular = (props: IconProps) => ( - -) - -/** Medium plugin pinwheel artwork with a 1.3px stroke. */ -export const IconPluginPinwheelOutlineMedium = (props: IconProps) => ( - -) - -const IconAlarmClockOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconAlarmClockOutline artwork. */ -export const IconAlarmClockOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconAlarmClockOutline artwork with a 1.3px stroke. */ -export const IconAlarmClockOutlineMedium = (props: IconProps) => ( - -) - -const IconArchiveOutlineArtwork = ({ size = 20, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconArchiveOutline artwork. */ -export const IconArchiveOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconArchiveOutline artwork with a 1.3px stroke. */ -export const IconArchiveOutlineMedium = (props: IconProps) => ( - -) - -const IconWrapLinesOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconWrapLinesOutline artwork. */ -export const IconWrapLinesOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconWrapLinesOutline artwork with a 1.3px stroke. */ -export const IconWrapLinesOutlineMedium = (props: IconProps) => ( - -) - -const IconNowrapFillArtwork = ({ size = 16, className }: IconProps) => ( - -) - -/** Regular IconNowrapFill artwork; fill-only weights render identically. */ -export const IconNowrapFillRegular = (props: IconProps) => - -/** Medium IconNowrapFill artwork; fill-only weights render identically. */ -export const IconNowrapFillMedium = (props: IconProps) => - -const IconWrapFillArtwork = ({ size = 16, className }: IconProps) => ( - -) - -/** Regular IconWrapFill artwork; fill-only weights render identically. */ -export const IconWrapFillRegular = (props: IconProps) => - -/** Medium IconWrapFill artwork; fill-only weights render identically. */ -export const IconWrapFillMedium = (props: IconProps) => - -const IconCompareSplitOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconCompareSplitOutline artwork. */ -export const IconCompareSplitOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconCompareSplitOutline artwork with a 1.3px stroke. */ -export const IconCompareSplitOutlineMedium = (props: IconProps) => ( - -) - -const IconPlanOutlineArtwork = (props: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconPlanOutline artwork. */ -export const IconPlanOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconPlanOutline artwork with a 1.3px stroke. */ -export const IconPlanOutlineMedium = (props: IconProps) => ( - -) - -const IconCompactOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconCompactOutline artwork. */ -export const IconCompactOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconCompactOutline artwork with a 1.3px stroke. */ -export const IconCompactOutlineMedium = (props: IconProps) => ( - -) - -const IconShieldOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconShieldOutline artwork. */ -export const IconShieldOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconShieldOutline artwork with a 1.3px stroke. */ -export const IconShieldOutlineMedium = (props: IconProps) => ( - -) - -const IconCheckCircleOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconCheckCircleOutline artwork. */ -export const IconCheckCircleOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconCheckCircleOutline artwork with a 1.3px stroke. */ -export const IconCheckCircleOutlineMedium = (props: IconProps) => ( - -) - -const IconUnarchiveOutlineArtwork = ({ size = 20, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconUnarchiveOutline artwork. */ -export const IconUnarchiveOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconUnarchiveOutline artwork with a 1.3px stroke. */ -export const IconUnarchiveOutlineMedium = (props: IconProps) => ( - -) - -const IconPinOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconPinOutline artwork. */ -export const IconPinOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconPinOutline artwork with a 1.3px stroke. */ -export const IconPinOutlineMedium = (props: IconProps) => ( - -) - -const IconPinFillArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconPinFill artwork. */ -export const IconPinFillRegular = (props: IconProps) => ( - -) - -/** Medium IconPinFill artwork with a 1.3px stroke. */ -export const IconPinFillMedium = (props: IconProps) => ( - -) - -const IconFlatListOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconFlatListOutline artwork. */ -export const IconFlatListOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconFlatListOutline artwork with a 1.3px stroke. */ -export const IconFlatListOutlineMedium = (props: IconProps) => ( - -) - -const IconWorkspaceTreeOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconWorkspaceTreeOutline artwork. */ -export const IconWorkspaceTreeOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconWorkspaceTreeOutline artwork with a 1.3px stroke. */ -export const IconWorkspaceTreeOutlineMedium = (props: IconProps) => ( - -) - -const IconChevronsUpDownOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( - -) - -/** Regular one-pixel IconChevronsUpDownOutline artwork. */ -export const IconChevronsUpDownOutlineRegular = (props: IconProps) => ( - -) - -/** Medium IconChevronsUpDownOutline artwork with a 1.3px stroke. */ -export const IconChevronsUpDownOutlineMedium = (props: IconProps) => ( - -) - -const IconArchiveOffOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => { - // The mask carves whitespace along the slash out of the box strokes, so the - // id must stay unique per rendered instance. - const maskId = `dsh-archive-off-${useId().replaceAll(':', '')}` - return ( -
renders Windows caption controls in expanded > @@ -169,13 +163,10 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded
@@ -192,13 +183,10 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded > renders the collapsed rail after the crossfad
renders the collapsed rail after the crossfad > @@ -427,13 +409,10 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul
@@ -450,13 +429,10 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul > renders the expanded column in the default lo @@ -599,13 +572,10 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo > { expect(row.getAttribute('role')).toBeNull() expect(view.container.textContent).toContain('正在加载 skill') expect(view.container.textContent).toContain('dsh-manage-issues') - expect(view.container.querySelector('svg [fill="currentColor"]')).not.toBeNull() + expect(view.container.querySelector('svg[data-icon="dsw-icon-skill-outline-regular"]')).not.toBeNull() }) it('uses the first failure line in the summary and exposes the full error', () => { diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 8856253c0b..56ff7db68b 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -24,8 +24,8 @@ en: b45d5b48ca799c3a zh: af65514b3759204f /deepseek-ai-dsh-client-ui-theme/use-this-package/registering-a-theme: - en: 2cd32ccaba33b3e9 - zh: 53ea78d19929ea02 + en: 375699f421b0258d + zh: 8273ca792ac76370 /deepseek-ai-dsh-client-ui-theme/use-this-package/pre-plugin-palette: en: 203523ddd7927c34 zh: 31dd89da78f9ab4f @@ -51,8 +51,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-theme/known-limitations-and-deferred-work: - en: 0d328dbced31a041 - zh: 2951de356ba6bb82 + en: 2388061abb1dd40f + zh: 1a4ad9a8e12472df /deepseek-ai-dsh-client-ui-theme/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index d67099cb8e..8cdaff9117 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -41,7 +41,7 @@ Three rows set the text font (interface and conversation text), the code font (c ### Registering a theme -A composition can register a third-party theme id with alias-token overrides through `ctx.theme`; the override layer folds into the active snapshot's tokens in registration order. Removing one never overwrites the last durable built-in preference. Third-party theme ids remain an in-process extension and do not cross the built-in settings schema. +A composition can register a third-party theme id with alias-token overrides through `ctx.theme`; the override layer folds into the active snapshot's tokens in registration order. A theme can also replace product icons: `ThemeDefinition.icons` maps ui-primitives `ICON_SYMBOLS` ids to complete SVG documents. The plugin keeps one hidden icon sprite at the start of `body` whose `` children follow the active theme's replacements and fall back to the defaults, so a replacement may be multi-color and may paint with `currentColor` or `--dsw-*` variables that follow the palette. `register` rejects unknown ids and markup whose root is not a well-formed ``; element ids inside a replacement must be unique in the document. Removing one never overwrites the last durable built-in preference. Third-party theme ids remain an in-process extension and do not cross the built-in settings schema. ### Pre-plugin palette @@ -136,6 +136,7 @@ These limits define the theme extension surface and the color authority; they ar - **Third-party themes are an extension point, not a product** — registering one means overriding same-named alias variables; no validation exists that an override set is complete. - **The token sheets are the sole color authority** — values absent from the design system are deliberately not appended; the nearest semantic token wins, and design-owner-approved additions enter as a static step plus a semantic alias in the same change. +- **Only product icons are replaceable** — `ThemeDefinition.icons` covers the ui-primitives product icon set; file-type, plugin, site, and brand artwork still render fixed inline SVG. ### Dev Note diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index b39b2b0778..84caf7d7dd 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -41,7 +41,7 @@ Desktop 的固定快捷键 `Cmd`/`Ctrl` + `+`(或 `=`)和 `Cmd`/`Ctrl` + `-` ### 注册主题 -组合可以通过 `ctx.theme` 注册带别名 token 覆盖的第三方主题 id;覆盖层按注册顺序折入活动快照的 token 中。移除其中一个绝不会覆盖最后一个持久化的内置偏好。第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema。 +组合可以通过 `ctx.theme` 注册带别名 token 覆盖的第三方主题 id;覆盖层按注册顺序折入活动快照的 token 中。主题还可以替换产品图标:`ThemeDefinition.icons` 把 ui-primitives `ICON_SYMBOLS` 的 id 映射到完整的 SVG 文档。插件在 `body` 开头保留一个隐藏的图标 sprite,其 `` 子元素采用活动主题的替换项,缺省时回退到默认值,因此替换图形可以是多色的,并可使用跟随调色板的 `currentColor` 或 `--dsw-*` 变量绘制。`register` 会拒绝未知 id 以及根元素不是格式正确的 `` 的标记;替换图形内的元素 id 必须在文档中唯一。移除其中一个绝不会覆盖最后一个持久化的内置偏好。第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema。 ### 插件前调色板 @@ -136,6 +136,7 @@ Desktop 的固定快捷键 `Cmd`/`Ctrl` + `+`(或 `=`)和 `Cmd`/`Ctrl` + `-` - **第三方主题是扩展点,不是产品**:注册主题意味着覆盖同名别名变量;目前不会验证一组覆盖是否完整。 - **token 样式表是颜色值的唯一权威来源**:设计系统中缺失的值会有意不补入;一律采用最接近的语义 token,设计负责人批准的新增值须在同一变更中以一个静态尺度层级与一个语义别名的形式进入。 +- **只有产品图标可替换**:`ThemeDefinition.icons` 覆盖 ui-primitives 的产品图标集;文件类型、插件、站点与品牌图稿仍渲染固定的内联 SVG。 ### 开发备注 diff --git a/packages/client/ui-theme/src/client/icon-sprite.ts b/packages/client/ui-theme/src/client/icon-sprite.ts new file mode 100644 index 0000000000..cc3c86bf81 --- /dev/null +++ b/packages/client/ui-theme/src/client/icon-sprite.ts @@ -0,0 +1,56 @@ +/** + * Document icon sprite: one hidden `` whose `` children carry + * every product icon, so ui-primitives icons render through `` and + * inherit `currentColor` and CSS variables at each use site. + */ +import type { Context } from '@deepseek-ai/cordis' +import { ICON_SYMBOLS, type IconSymbolId } from '@deepseek-ai/dsh-client-ui-primitives' + +const SVG_NS = 'http://www.w3.org/2000/svg' + +type Replacements = Readonly>> +/** Root attributes that size or label one rendering, not the reusable artwork. */ +const DROPPED_ATTRIBUTES = new Set(['width', 'height', 'xmlns', 'class', 'aria-hidden']) + +function toSymbol(id: string, markup: string): SVGSymbolElement { + const root = new DOMParser().parseFromString(markup, 'image/svg+xml').documentElement + const symbol = document.createElementNS(SVG_NS, 'symbol') + for (const { name, value } of root.attributes) if (!DROPPED_ATTRIBUTES.has(name)) symbol.setAttribute(name, value) + symbol.id = id + symbol.append(...[...root.childNodes].map(node => document.importNode(node, true))) + return symbol +} + +/** + * Mount the sprite and keep each symbol on the active theme's replacement or + * the ui-primitives default; plugin disposal removes the sprite. + * @param ctx - owning plugin context; `theme/change` snapshots drive updates. + * @param icons - replacements of the theme active at install time. + */ +export function installIconSprite(ctx: Context, icons: Replacements | undefined): void { + if (typeof document === 'undefined') return + const sprite = document.createElementNS(SVG_NS, 'svg') + sprite.setAttribute('aria-hidden', 'true') + sprite.setAttribute('data-dsh-icon-sprite', '') + sprite.style.cssText = 'position: absolute; width: 0; height: 0; overflow: hidden' + const rendered = new Map() + const markups = new Map() + const apply = (replacements: Replacements = {}): void => { + for (const [id, fallback] of Object.entries(ICON_SYMBOLS) as [IconSymbolId, string][]) { + const markup = replacements[id] ?? fallback + if (markups.get(id) === markup) continue + const symbol = toSymbol(id, markup) + const previous = rendered.get(id) + if (previous === undefined) sprite.append(symbol) + else previous.replaceWith(symbol) + rendered.set(id, symbol) + markups.set(id, markup) + } + } + apply(icons) + ctx.effect(() => { + document.body.prepend(sprite) + return () => { sprite.remove() } + }, 'ui-theme: icon sprite') + ctx.on('theme/change', (snapshot) => { apply(snapshot.active.icons) }) +} diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index c00c0ce26e..3b70ea31e2 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -24,6 +24,8 @@ import { AppearanceRow } from './AppearanceRow.tsx' import type { FontSettingsInjected } from './FontSettingsGroup.tsx' import { FontSettingsGroup } from './FontSettingsGroup.tsx' import { createAppearanceRowStore, createFontRowStore } from './settings-store.ts' +import { ICON_SYMBOLS, type IconSymbolId } from '@deepseek-ai/dsh-client-ui-primitives' +import { installIconSprite } from './icon-sprite.ts' import { installThemeStyles } from './styles.ts' import { en, ja, zh, type ThemeKey } from './locales.ts' import { @@ -78,6 +80,14 @@ export interface ThemeDefinition { colorScheme: 'light' | 'dark' /** Alias-layer overrides applied as inline CSS variables over the base palette. */ tokens: ThemeTokens + /** + * Product icon replacements: ui-primitives `ICON_SYMBOLS` ids to complete + * SVG documents. The icon sprite renders each replacement as that id's + * ``, so its paint may use `currentColor` and `--dsw-*` variables. + * Each document needs an SVG-namespace root with a `viewBox`, no `