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:
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" }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user