fix(client): harden theme icon replacements and add a real-composition test

Store a frozen copy of ThemeDefinition.icons, treat null as absent,
require replacement element ids under the symbol id, document the trust
level and ignored root attributes, and boot a theme plugin through the
Loader to check the sprite swaps and restores icons.
This commit is contained in:
Turtle committed 2026-10-22 00:59:21 +08:00
1 parent c422eac3b9
commit 6470b39557
22 files changed
+192 -42

No files matched your search

@@ -15,5 +15,5 @@
en: 2e25ece88bccdb22
zh: bb24a28bef7ea7ce
/agent-note-replaceable-product-icons-through-an-svg-sprite/consequences:
en: 6d343670ae58a4bc
zh: 189fedf3b15417cb
en: 0ad6db37bbb76ddb
zh: 0ca09d55a2167970
@@ -22,5 +22,8 @@ Each product glyph weight is one sprite symbol, `dsw-icon-<glyph>-<weight>`. ui-
- Icon DOM contains a `<use>` 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; `<use>` in another document, such as an iframe, cannot reach the sprite.
- Every symbol shares the document id space, so `register` requires element ids inside a replacement to start with `<symbol id>-`; `<use>` in another document, such as an iframe, cannot reach the sprite.
- Replacement markup is trusted like the registering plugin's code; `register` rejects `<style>` and `<script>` only because they would apply to the whole document.
- Gradients and other paint servers resolve their stops where the sprite sits, so they follow palette tokens but not use-site color overrides.
- Default and token-colored replacement symbols render pixel-identically through `<use>` and inline in Chromium, WebKit, and Firefox.
- File-type, plugin, site, and brand artwork still render inline and are not replaceable yet.
@@ -22,5 +22,8 @@ Status: implemented
- 图标 DOM 包含 `<use>` 引用而不是路径;测试通过 `data-icon` 识别图形,通过 `ICON_SYMBOLS` 检查图形。
- 没有 ui-theme 时,sprite 不存在,图标不渲染任何内容。
- 所有 symbol 共享文档的 id 空间,因此默认图形与替换图形内的元素 id 必须唯一;另一个文档(例如 iframe)中的 `<use>` 无法引用该 sprite。
- 所有 symbol 共享文档的 id 空间,因此 `register` 要求替换图形内的元素 id 以 `<symbol id>-` 开头;另一个文档(例如 iframe)中的 `<use>` 无法引用该 sprite。
- 替换标记与注册它的插件代码同等受信任;`register` 仅拒绝 `<style>` 与 `<script>`,因为它们会作用于整个文档。
- 渐变及其他绘制服务器在 sprite 所在位置解析其色标,因此它们跟随调色板 token,但不跟随使用处的颜色覆盖。
- 默认 symbol 与使用 token 着色的替换 symbol 在 Chromium、WebKit 与 Firefox 中通过 `<use>` 渲染与内联渲染的结果逐像素一致。
- 文件类型、插件、站点与品牌图形仍以内联方式渲染,暂不可替换。
@@ -0,0 +1,21 @@
/** Test theme that replaces one product icon with two-tone artwork painted from theme tokens. */
window.__ModuleLoader__.load({
id: '@fixture/icon-skin',
factory() {
const icon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">'
+ '<circle data-skin-disc="" cx="8" cy="8" r="6" fill="var(--dsw-alias-state-success-primary)"/>'
+ '<path d="M5 8h6" stroke="currentColor"/></svg>'
return {
inject: ['theme'],
apply(ctx) {
ctx.effect(() => ctx.theme.register({
id: 'fixture-icon-skin',
colorScheme: 'light',
tokens: {},
icons: { 'dsw-icon-search-outline-regular': icon },
}))
ctx.theme.setTheme('fixture-icon-skin')
},
}
},
})
@@ -0,0 +1,3 @@
- insert:
- id: fixture-icon-skin
name: '@fixture/icon-skin'
@@ -0,0 +1,2 @@
/** Host companion for the icon skin fixture. */
export function apply() {}
@@ -0,0 +1,12 @@
{
"name": "@fixture/icon-skin",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.js",
"exports": { ".": "./index.js", "./client": "./client.js" },
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "platform": "web" }
}
}
+42
View File
@@ -0,0 +1,42 @@
/** Real profile and Loader: a theme plugin replaces a product icon through the sprite, and removing it restores the default. */
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { chromium } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
import { launchWebScaffold } from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('./fixtures/plugins/fixture-icon-skin', import.meta.url))
const ICON = 'svg[data-icon="dsw-icon-search-outline-regular"]'
it('renders product icons from the sprite and swaps in a theme plugin replacement', async () => {
const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] })
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const page = await newEnglishPage(browser)
onTestFailed(() => saveFailureShot(page, 'web-e2e-icon-skin'))
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const icon = page.locator(ICON).first()
await icon.waitFor({ timeout: 20_000 })
// getBBox measures the geometry <use> actually resolved: zero without a symbol.
const geometry = () => icon.evaluate((svg: SVGSVGElement) => {
const box = svg.querySelector('use')!.getBBox()
return [box.x, box.y, box.width, box.height].map(value => Math.round(value * 10) / 10)
})
const defaults = await geometry()
expect(defaults[2]).toBeGreaterThan(0)
expect(await page.locator('svg[data-icon]').evaluateAll(icons => icons.filter(svg => svg.getBoundingClientRect().width > 0)
.every(svg => (svg.querySelector('use') as SVGUseElement).getBBox().width > 0))).toBe(true)
const entryId = await scaffold.ctx.loader.create({ name: '@fixture/icon-skin' })
// The replacement disc spans viewBox units 2..14 of 16 on both axes.
const width = await icon.evaluate((svg: SVGSVGElement) => svg.getBoundingClientRect().width)
await expect.poll(geometry).toEqual([2, 2, 12, 12].map(units => Math.round((width * units) / 16 * 10) / 10))
const disc = page.locator('svg[data-dsh-icon-sprite] symbol[id="dsw-icon-search-outline-regular"] [data-skin-disc]')
expect(await disc.evaluate(element => getComputedStyle(element).fill)).not.toBe('none')
scaffold.ctx.loader.remove(entryId)
await expect.poll(geometry).toEqual(defaults)
await expect.poll(() => disc.count()).toBe(0)
})
+1
View File
@@ -105,6 +105,7 @@
"tests/plugin-install-registry.e2e.ts",
"tests/plugin-install-github.e2e.ts",
"tests/client-plugin-live.e2e.ts",
"tests/icon-skin.e2e.ts",
"tests/draft-initialization.e2e.ts",
"tests/settings-chrome.e2e.ts",
"tests/session-log-upload.e2e.ts",
@@ -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<HTMLElement>('span[style]'))
return Array.from(screen.getByRole('button').querySelectorAll<HTMLElement>('[style]'))
}
it('reveals a long Latin name inside the shared window, then acknowledges', () => {
@@ -111,8 +111,8 @@ export const ICON_ARTWORK = {
'workspace-tree-outline': { size: 16, svg: strokeWidth => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="${strokeWidth}"><path d="M14 12.05c0 .8-.65 1.45-1.46 1.45H3.46C2.65 13.5 2 12.85 2 12.05v-8.1c0-.8.65-1.45 1.46-1.45h2.4c.49 0 .94.24 1.21.65l.5.73c.27.4.73.65 1.21.65h3.76c.8 0 1.46.65 1.46 1.45v6.02Z"></path><path d="M8.7 8.1v3M11.2 8.1v3"></path></svg>` },
'chevrons-up-down-outline': { size: 16, svg: strokeWidth => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="${strokeWidth}"><path d="m5.1 6 2.9-2.9L10.9 6"></path><path d="m5.1 10 2.9 2.9 2.9-2.9"></path></svg>` },
'archive-off-outline': { size: 16, svg: strokeWidth => strokeWidth === ICON_MEDIUM_STROKE
? `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="${strokeWidth}"><mask id="dsh-archive-off-medium" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect x="0" y="0" width="16" height="16" fill="white" stroke="none"></rect><path d="m2.2 1.3 11.6 12.8" stroke="black" stroke-width="4.3"></path></mask><g mask="url(#dsh-archive-off-medium)"><rect x="1.9" y="2.1" width="12.2" height="3.4" rx="1.1"></rect><path d="M2.95 5.7v4.8a2.9 2.9 0 0 0 2.9 2.9h4.3a2.9 2.9 0 0 0 2.9-2.9V5.7"></path></g><path d="m2.2 1.3 11.6 12.8"></path></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="${strokeWidth}"><mask id="dsh-archive-off-regular" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect x="0" y="0" width="16" height="16" fill="white" stroke="none"></rect><path d="m2.2 1.3 11.6 12.8" stroke="black" stroke-width="4"></path></mask><g mask="url(#dsh-archive-off-regular)"><rect x="1.9" y="2.1" width="12.2" height="3.4" rx="1.1"></rect><path d="M2.95 5.7v4.8a2.9 2.9 0 0 0 2.9 2.9h4.3a2.9 2.9 0 0 0 2.9-2.9V5.7"></path></g><path d="m2.2 1.3 11.6 12.8"></path></svg>` },
? `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="${strokeWidth}"><mask id="dsw-icon-archive-off-outline-medium-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect x="0" y="0" width="16" height="16" fill="white" stroke="none"></rect><path d="m2.2 1.3 11.6 12.8" stroke="black" stroke-width="4.3"></path></mask><g mask="url(#dsw-icon-archive-off-outline-medium-mask)"><rect x="1.9" y="2.1" width="12.2" height="3.4" rx="1.1"></rect><path d="M2.95 5.7v4.8a2.9 2.9 0 0 0 2.9 2.9h4.3a2.9 2.9 0 0 0 2.9-2.9V5.7"></path></g><path d="m2.2 1.3 11.6 12.8"></path></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="${strokeWidth}"><mask id="dsw-icon-archive-off-outline-regular-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect x="0" y="0" width="16" height="16" fill="white" stroke="none"></rect><path d="m2.2 1.3 11.6 12.8" stroke="black" stroke-width="4"></path></mask><g mask="url(#dsw-icon-archive-off-outline-regular-mask)"><rect x="1.9" y="2.1" width="12.2" height="3.4" rx="1.1"></rect><path d="M2.95 5.7v4.8a2.9 2.9 0 0 0 2.9 2.9h4.3a2.9 2.9 0 0 0 2.9-2.9V5.7"></path></g><path d="m2.2 1.3 11.6 12.8"></path></svg>` },
'archive-check-outline': { size: 16, svg: strokeWidth => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="${strokeWidth}"><rect x="1.9" y="2.1" width="12.2" height="3.4" rx="1.1"></rect><path d="M2.95 5.7v4.8a2.9 2.9 0 0 0 2.9 2.9h4.3a2.9 2.9 0 0 0 2.9-2.9V5.7"></path><path d="m6 9.35 1.4 1.4 2.6-2.6"></path></svg>` },
'sliders-two-outline': { size: 16, svg: strokeWidth => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="${strokeWidth}"><path d="M2.3 5h5.85M12.05 5h1.65"></path><circle cx="9.95" cy="5" r="1.45"></circle><path d="M2.3 11h1.65M7.85 11h5.85"></path><circle cx="5.75" cy="11" r="1.45"></circle></svg>` },
'microphone-outline': { size: 16, svg: strokeWidth => strokeWidth === ICON_MEDIUM_STROKE
@@ -5,8 +5,8 @@ interface WeightedArtworkProps extends IconProps {
}
/**
* Render shared conversation geometry — the chat bubble around two text
* lines — for the queue product icon and session reference icons.
* Render conversation geometry — the chat bubble around two text lines —
* for session reference icons; it matches the `queue-outline` product glyph.
* @param props - Size, optional CSS class, and inherited stroke width.
* @returns The decorative SVG artwork.
*/
@@ -19,7 +19,7 @@ export const ChatLinesOutlineArtwork = ({ size = 16, className, strokeWidth }: W
)
/**
* Render shared globe geometry for product and link icons.
* Render globe geometry for link icons; it matches the `globe-outline` product glyph.
* @param props - Size, optional CSS class, and inherited stroke width.
* @returns The decorative SVG artwork.
*/
@@ -33,7 +33,7 @@ export const GlobeOutlineArtwork = ({ size = 14, className, strokeWidth }: Weigh
)
/**
* Render shared code-bracket geometry for product and link icons.
* Render code-bracket geometry for link icons; it matches the `inspect-outline` product glyph.
* @param props - Size, optional CSS class, and inherited stroke width.
* @returns The decorative SVG artwork.
*/
@@ -46,7 +46,7 @@ export const CodeBracketsArtwork = ({ size = 14, className, strokeWidth }: Weigh
)
/**
* Render shared document-browse geometry for product and reference icons.
* Render document-browse geometry for reference icons; it matches the `browse-outline` product glyph.
* @param props - Size, optional CSS class, and inherited stroke width.
* @returns The decorative SVG artwork.
*/
@@ -59,7 +59,7 @@ export const BrowseOutlineArtwork = ({ size = 16, className, strokeWidth }: Weig
)
/**
* Render shared closed-folder geometry for product, reference, and link icons.
* Render closed-folder geometry for reference and link icons; it matches the `folder-close` product glyph.
* @param props - Size, optional CSS class, and inherited stroke width.
* @returns The decorative SVG artwork.
*/
@@ -7,6 +7,9 @@ import {
IconAppsAddOutlineMedium, IconGoalOutlineRegular, IconSendOutlineRegular,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { ICON_ARTWORK, type IconGlyph, type IconWeight } from '../src/icons/artwork.ts'
import {
BrowseOutlineArtwork, ChatLinesOutlineArtwork, CodeBracketsArtwork, FolderCloseArtwork, GlobeOutlineArtwork,
} from '../src/icons/shared-artwork.tsx'
afterEach(cleanup)
@@ -86,6 +89,31 @@ describe('product icon set', () => {
expect(new Set(ids).size).toBe(ids.length)
})
it.each(iconNames)('%s references the glyph and weight its name spells', (name) => {
const weight = name.endsWith('Regular') ? 'regular' : 'medium'
const glyph = name.slice('Icon'.length, -weight.length).replace(/(?<!^)(?=[A-Z])/g, '-').toLowerCase()
const Icon = icons[name]!
expect(render(<Icon />).container.querySelector('svg')!.getAttribute('data-icon')).toBe(`dsw-icon-${glyph}-${weight}`)
})
it.each([
['queue-outline', ChatLinesOutlineArtwork],
['globe-outline', GlobeOutlineArtwork],
['inspect-outline', CodeBracketsArtwork],
['browse-outline', BrowseOutlineArtwork],
['folder-close', FolderCloseArtwork],
] as const)('keeps the shared %s artwork identical to its product glyph', (glyph, Artwork) => {
for (const weight of ['regular', 'medium'] as const) {
const stroke = weight === 'regular' ? primitives.ICON_REGULAR_STROKE : primitives.ICON_MEDIUM_STROKE
const inline = render(<Artwork strokeWidth={stroke} />).container.querySelector('svg')!
const product = artwork(glyph, weight).documentElement
expect(inline.getAttribute('viewBox')).toBe(product.getAttribute('viewBox'))
expect(inline.children.length).toBe(product.children.length)
expect([...inline.children].every((child, index) => child.isEqualNode(product.children[index]!))).toBe(true)
cleanup()
}
})
it('size and className props land on the root svg', () => {
const { container } = render(<IconSendOutlineRegular size={20} className="x" />)
const svg = container.querySelector('svg')!
+2 -2
View File
@@ -24,8 +24,8 @@
en: b45d5b48ca799c3a
zh: af65514b3759204f
/deepseek-ai-dsh-client-ui-theme/use-this-package/registering-a-theme:
en: 375699f421b0258d
zh: 8273ca792ac76370
en: 5244fc168d0a03f8
zh: c7ab12fdf8c6c55b
/deepseek-ai-dsh-client-ui-theme/use-this-package/pre-plugin-palette:
en: 203523ddd7927c34
zh: 31dd89da78f9ab4f
+1 -1
View File
@@ -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. 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 `<symbol>` 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 `<svg>`; 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.
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 `<symbol>` 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. Replacement markup is trusted like the registering plugin's code. `register` rejects unknown ids, documents without a well-formed SVG-namespace `<svg>` root and `viewBox`, `<style>` or `<script>` elements, which would apply to the whole document, and element ids that do not start with `<symbol id>-`. The root's `width`, `height`, `class`, and `aria-hidden` are ignored because the use site sizes the icon. Gradients and other paint servers resolve their stops in the sprite, so they follow palette tokens but not use-site color overrides. 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
+1 -1
View File
@@ -41,7 +41,7 @@ Desktop 的固定快捷键 `Cmd`/`Ctrl` + `+`(或 `=`)和 `Cmd`/`Ctrl` + `-`
### 注册主题
组合可以通过 `ctx.theme` 注册带别名 token 覆盖的第三方主题 id;覆盖层按注册顺序折入活动快照的 token 中。主题还可以替换产品图标:`ThemeDefinition.icons` 把 ui-primitives `ICON_SYMBOLS` 的 id 映射到完整的 SVG 文档。插件在 `body` 开头保留一个隐藏的图标 sprite,其 `<symbol>` 子元素采用活动主题的替换项,缺省时回退到默认值,因此替换图形可以是多色的,并可使用跟随调色板的 `currentColor` 或 `--dsw-*` 变量绘制。`register` 会拒绝未知 id 以及根元素不是格式正确的 `<svg>` 的标记;替换图形内的元素 id 必须在文档中唯一。移除其中一个绝不会覆盖最后一个持久化的内置偏好。第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema。
组合可以通过 `ctx.theme` 注册带别名 token 覆盖的第三方主题 id;覆盖层按注册顺序折入活动快照的 token 中。主题还可以替换产品图标:`ThemeDefinition.icons` 把 ui-primitives `ICON_SYMBOLS` 的 id 映射到完整的 SVG 文档。插件在 `body` 开头保留一个隐藏的图标 sprite,其 `<symbol>` 子元素采用活动主题的替换项,缺省时回退到默认值,因此替换图形可以是多色的,并可使用跟随调色板的 `currentColor` 或 `--dsw-*` 变量绘制。替换标记与注册它的插件代码同等受信任。`register` 会拒绝未知 id、根元素不是带 `viewBox` 且格式正确的 SVG 命名空间 `<svg>` 的文档、会作用于整个文档的 `<style>` 或 `<script>` 元素,以及不以 `<symbol id>-` 开头的元素 id。根元素的 `width`、`height`、`class` 和 `aria-hidden` 会被忽略,因为图标尺寸由使用处决定。渐变及其他绘制服务器在 sprite 中解析其色标,因此会跟随调色板 token,但不跟随使用处的颜色覆盖。移除其中一个绝不会覆盖最后一个持久化的内置偏好。第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema。
### 插件前调色板
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-theme",
"description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row",
"description": "Theme plugin: Host bootstrap for the pre-plugin palette; ThemeRuntime for light/dark/system state and icon replacements; --dsw-* token styles and Appearance settings row",
"version": "0.2.1-rc.2",
"publishConfig": {
"access": "public"
@@ -8,7 +8,8 @@ import { ICON_SYMBOLS, type IconSymbolId } from '@deepseek-ai/dsh-client-ui-prim
const SVG_NS = 'http://www.w3.org/2000/svg'
type Replacements = Readonly<Partial<Record<IconSymbolId, string>>>
/** Icon replacements keyed by sprite symbol id; see `ThemeDefinition.icons`. */
export type IconReplacements = Readonly<Partial<Record<IconSymbolId, string>>>
/** Root attributes that size or label one rendering, not the reusable artwork. */
const DROPPED_ATTRIBUTES = new Set(['width', 'height', 'xmlns', 'class', 'aria-hidden'])
@@ -27,7 +28,7 @@ function toSymbol(id: string, markup: string): SVGSymbolElement {
* @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 {
export function installIconSprite(ctx: Context, icons: IconReplacements | undefined): void {
if (typeof document === 'undefined') return
const sprite = document.createElementNS(SVG_NS, 'svg')
sprite.setAttribute('aria-hidden', 'true')
@@ -35,7 +36,7 @@ export function installIconSprite(ctx: Context, icons: Replacements | undefined)
sprite.style.cssText = 'position: absolute; width: 0; height: 0; overflow: hidden'
const rendered = new Map<string, SVGSymbolElement>()
const markups = new Map<string, string>()
const apply = (replacements: Replacements = {}): void => {
const apply = (replacements: IconReplacements = {}): void => {
for (const [id, fallback] of Object.entries(ICON_SYMBOLS) as [IconSymbolId, string][]) {
const markup = replacements[id] ?? fallback
if (markups.get(id) === markup) continue
+29 -17
View File
@@ -25,7 +25,7 @@ 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 { installIconSprite, type IconReplacements } from './icon-sprite.ts'
import { installThemeStyles } from './styles.ts'
import { en, ja, zh, type ThemeKey } from './locales.ts'
import {
@@ -82,12 +82,18 @@ export interface ThemeDefinition {
tokens: ThemeTokens
/**
* Product icon replacements: ui-primitives `ICON_SYMBOLS` ids to complete
* SVG documents. The icon sprite renders each replacement as that id's
* `<symbol>`, so its paint may use `currentColor` and `--dsw-*` variables.
* Each document needs an SVG-namespace root with a `viewBox`, no `<style>`
* or `<script>`, and element ids unique across the theme and the defaults.
* SVG documents, trusted like the registering plugin's code. The icon
* sprite renders each replacement as that id's `<symbol>`: the root's
* `viewBox` and presentation attributes carry over, its `width`, `height`,
* `class`, and `aria-hidden` are ignored because the use site sizes the
* icon, and painted elements inherit `currentColor` and CSS variables from
* the use site. Gradients and other paint servers resolve their stops in
* the sprite, which sits in `body`, so they follow palette tokens but not
* use-site overrides. Each document needs an SVG-namespace root with a
* `viewBox`, no `<style>` or `<script>` (they would apply to the whole
* document), and element ids prefixed with `<symbol id>-`.
*/
icons?: Readonly<Partial<Record<IconSymbolId, string>>> | undefined
icons?: IconReplacements | undefined
}
/** Immutable theme state published on every change. */
@@ -338,7 +344,9 @@ export class ThemeRuntime {
/**
* Register a theme. Duplicate id throws (single occupant per id; the
* built-in pair counts; `system` is a preference, not a registrable id).
* @param definition - theme id, colorScheme, and alias-token overrides.
* @param definition - theme id, colorScheme, alias-token overrides, and
* optional icon replacements; the runtime keeps a frozen copy of `icons`.
* Invalid icon replacements throw a `TypeError` naming the theme and icon.
* @returns disposer. Disposing the theme backing the active preference
* resets the preference to the default so the UI never keeps tokens of an
* unregistered theme.
@@ -348,8 +356,8 @@ export class ThemeRuntime {
if (this.themes.some(t => t.id === definition.id)) {
throw new Error(`theme "${definition.id}" is already registered`)
}
validateIcons(definition)
this.themes = [...this.themes, definition]
const icons = validateIcons(definition)
this.themes = [...this.themes, Object.freeze({ ...definition, icons })]
this.publish()
return () => {
if (!this.themes.some(t => t.id === definition.id)) return
@@ -498,30 +506,34 @@ function durableFontFamilies(section: ThemeSettings): FontFamilies {
const SVG_NS = 'http://www.w3.org/2000/svg'
/** Element ids the default symbols use, so a replacement cannot shadow them. */
const DEFAULT_ICON_IDS = new Set(Object.values(ICON_SYMBOLS).flatMap(markup => [...markup.matchAll(/\sid="([^"]+)"/g)].map(([, value = '']) => value)))
/**
* Reject icon replacements a sprite cannot render (untyped dynamic-package
* callers reach this boundary, as with {@link validateOverrides}): unknown
* symbol ids, documents without an SVG-namespace `<svg viewBox>` root,
* document-wide `<style>` or `<script>`, and colliding element ids.
* document-wide `<style>` or `<script>`, and element ids outside the
* symbol's own `<symbol id>-` namespace.
* @returns a frozen copy of the replacements, or undefined when there are none.
*/
function validateIcons({ id, icons = {} }: ThemeDefinition): void {
const ids = new Set(DEFAULT_ICON_IDS)
function validateIcons({ id, icons }: ThemeDefinition): IconReplacements | undefined {
// Untyped callers may pass null; treat it like an absent field.
if (icons == null) return undefined
const copy: Partial<Record<IconSymbolId, string>> = {}
for (const [name, markup] of Object.entries<unknown>(icons)) {
const fail = (problem: string): never => { throw new TypeError(`theme "${id}" icon "${name}" ${problem}`) }
if (!Object.hasOwn(ICON_SYMBOLS, name)) fail('is not an ICON_SYMBOLS id')
if (typeof markup !== 'string') fail('must be an SVG document string')
copy[name as IconSymbolId] = markup as string
/* v8 ignore next -- needs a documentless run (node e2e booting the client tree), where no sprite renders either */
if (typeof DOMParser === 'undefined') continue
const root = new DOMParser().parseFromString(markup as string, 'image/svg+xml').documentElement
if (root.namespaceURI !== SVG_NS || root.localName !== 'svg' || root.querySelector('parsererror') !== null) fail('is not a well-formed SVG-namespace <svg> document')
if (!root.hasAttribute('viewBox')) fail('needs a viewBox')
if (root.querySelector('style, script') !== null) fail('must not contain <style> or <script>')
for (const element of root.querySelectorAll('[id]')) {
if (ids.has(element.id) || Object.hasOwn(ICON_SYMBOLS, element.id)) fail(`reuses element id "${element.id}"`)
ids.add(element.id)
if (!element.id.startsWith(`${name}-`)) fail(`element id "${element.id}" must start with "${name}-"`)
}
}
return Object.freeze(copy)
}
function dynamicToken(name: string): ThemeTokenInspection {
@@ -61,13 +61,34 @@ describe('icon sprite', () => {
expect(reject({ [SEARCH]: '<g xmlns="http://www.w3.org/2000/svg"/>' })).toThrow(/not a well-formed SVG-namespace/)
expect(reject({ [SEARCH]: svg('<path/>', 'xmlns="http://www.w3.org/2000/svg" width="16"') })).toThrow(/needs a viewBox/)
expect(reject({ [SEARCH]: svg('<style>path{fill:red}</style>') })).toThrow(/<style> or <script>/)
expect(reject({ [SEARCH]: svg('<mask id="dsh-archive-off-regular"/>') })).toThrow(/reuses element id "dsh-archive-off-regular"/)
expect(reject({ [SEARCH]: svg('<g id="a"/>'), 'dsw-icon-plus-outline-regular': svg('<g id="a"/>') })).toThrow(/reuses element id "a"/)
expect(reject({ [SEARCH]: svg(`<g id="${SEARCH}"/>`) })).toThrow(/reuses element id/)
expect(reject({ [SEARCH]: svg('<mask id="root"/>') })).toThrow(/element id "root" must start with "dsw-icon-search-outline-regular-"/)
expect(reject({ [SEARCH]: svg(`<g id="${SEARCH}"/>`) })).toThrow(/must start with/)
expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark'])
await fiber.dispose()
})
it('treats missing icons as no replacement and keeps a frozen copy of the registered ones', async () => {
const { theme, fiber, symbol } = await mount()
theme.register({ id: 'none', colorScheme: 'light', tokens: {}, icons: null as never })
const icons: Record<string, string> = { [SEARCH]: TWO_TONE }
theme.register({ id: 'skin', colorScheme: 'light', tokens: {}, icons })
icons[SEARCH] = '<svg viewBox="0 0 16 16"><path/></svg>'
theme.setTheme('skin')
expect(symbol(SEARCH)?.querySelector('circle')).not.toBeNull()
expect(Object.isFrozen(theme.getTheme().active.icons)).toBe(true)
theme.setTheme('none')
expect(symbol(SEARCH)?.querySelector('circle')).toBeNull()
await fiber.dispose()
})
it('accepts element ids in the symbol namespace', async () => {
const { theme, fiber, symbol } = await mount()
theme.register({ id: 'grad', colorScheme: 'light', tokens: {}, icons: { [SEARCH]: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><linearGradient id="${SEARCH}-g"/><rect width="16" height="16" fill="url(#${SEARCH}-g)"/></svg>` } })
theme.setTheme('grad')
expect(symbol(SEARCH)?.querySelector('linearGradient')?.id).toBe(`${SEARCH}-g`)
await fiber.dispose()
})
it('keeps every default root presentation attribute on its symbol', async () => {
const { fiber, symbol } = await mount()
for (const [id, markup] of Object.entries(ICON_SYMBOLS)) {
@@ -115,7 +115,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo
| `@deepseek-ai/dsh-client-ui-sidebar-terminal` | no | Interactive shell tabs for the right Sidebar |
| `@deepseek-ai/dsh-client-ui-skill` | no | Web skill references and the dedicated skill tool row |
| `@deepseek-ai/dsh-client-ui-subagent` | no | Subagent conversation catalog, continuation routing UI, and '@' reference source |
| `@deepseek-ai/dsh-client-ui-theme` | yes | Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row |
| `@deepseek-ai/dsh-client-ui-theme` | yes | Theme plugin: Host bootstrap for the pre-plugin palette; ThemeRuntime for light/dark/system state and icon replacements; --dsw-* token styles and Appearance settings row |
| `@deepseek-ai/dsh-client-ui-tool` | no | Client Tool call-tree renderer and keyed per-tool presentation slot |
| `@deepseek-ai/dsh-client-ui-trajectory` | no | Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service) |
| `@deepseek-ai/dsh-client-ui-user-questions` | no | Web ask_user_question composer takeover and plan-review presentation UI |
+1
View File
@@ -97,6 +97,7 @@
"apps/web/tests/plugin-install-registry.e2e.ts",
"apps/web/tests/plugin-install-github.e2e.ts",
"apps/web/tests/client-plugin-live.e2e.ts",
"apps/web/tests/icon-skin.e2e.ts",
"apps/web/tests/draft-initialization.e2e.ts",
"apps/web/tests/settings-chrome.e2e.ts",
"apps/web/tests/session-log-upload.e2e.ts",