Merge latest master plugin detail changes into CUA onboarding
This commit is contained in:
commit
636ab61b34
67 files changed
+549
-358
No files matched your search
@@ -0,0 +1,2 @@
|
||||
2026-10-14-built-in-plugin-feature-previews.md: ae9b32340d9b1e77a99465445a05ccee05df0e79
|
||||
2026-10-14-built-in-plugin-feature-previews.zh.md: 9229b0835a23a0eefffa1bd157efece333aebfa9
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Built-in plugin feature previews
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-10-19
|
||||
|
||||
English | [中文](2026-10-14-built-in-plugin-feature-previews.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 内置插件功能配图
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-10-19
|
||||
|
||||
[English](2026-10-14-built-in-plugin-feature-previews.md) | 中文
|
||||
|
||||
@@ -2020,6 +2020,9 @@
|
||||
"feature/2026-09-28-desktop-login-shell-environment.i18n.yaml": "sha256:6129edeb0a7ecf087911193ca5a15223a730ad01e30d2ead7739d01f589fafa7",
|
||||
"feature/2026-09-28-desktop-login-shell-environment.md": "sha256:cd133b1ef1a178828d30b9db176a91597eedf9e67802551356f34d3e0f80206d",
|
||||
"feature/2026-09-28-desktop-login-shell-environment.zh.md": "sha256:1422667a44aacc51736f31b328a8e171a39cb6b5168d6993e980e4324b23b98f",
|
||||
"feature/2026-10-14-built-in-plugin-feature-previews.i18n.yaml": "sha256:4769fd7fa4881a38fa8e2682b2ea62ed5a5ec6588e87f225c2b106e1bd308200",
|
||||
"feature/2026-10-14-built-in-plugin-feature-previews.md": "sha256:093e00da4cebf732861c1e5c47465fb00359eb89a86dc212f3f90b43f9b80cec",
|
||||
"feature/2026-10-14-built-in-plugin-feature-previews.zh.md": "sha256:56adb70945f68b073357c437bbec9e69f110587cf302291f2f135d9fcb5537e7",
|
||||
"process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d",
|
||||
"process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e",
|
||||
"process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b",
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
# Bilingual-pair consistency record for 2026-10-14-built-in-plugin-feature-previews.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/feature/2026-10-14-built-in-plugin-feature-previews.md
|
||||
/agent-note-built-in-plugin-feature-previews:
|
||||
en: f6abb7d00b13d362
|
||||
zh: b89d5da18677f31a
|
||||
/agent-note-built-in-plugin-feature-previews/problem:
|
||||
en: e38fa1e5b3a62b5a
|
||||
zh: c612ea229705d695
|
||||
/agent-note-built-in-plugin-feature-previews/decision:
|
||||
en: aa12e3f72f7c2498
|
||||
zh: 7dda9f061009aaa0
|
||||
/agent-note-built-in-plugin-feature-previews/alternatives-considered:
|
||||
en: 4a4416610894d7c6
|
||||
zh: 4256e81e7ab70db5
|
||||
/agent-note-built-in-plugin-feature-previews/consequences:
|
||||
en: a3dbd70080416314
|
||||
zh: 4d802da45085dfb0
|
||||
@@ -9,7 +9,7 @@ Draw recognizable artwork that belongs beside the existing official icons. Offic
|
||||
|
||||
## Optional feature previews
|
||||
|
||||
Before drawing a larger image below a built-in bundle's description, read and follow [adding a built-in plugin feature preview](../../../docs/cookbook/adding-a-plugin-preview.md). It owns optionality, image count, design canvas, display size and position, language variants, asset limits, and acceptance. The guide also owns the embedded-raster exception for SVG previews; small icons remain geometry-only. The icon dimensions below do not apply to previews. Inspect the real feature, preserve its produced assets, and keep built-in previews in the Plugin Manager client assets directory and its internal bilingual map.
|
||||
Before drawing a larger image for the top of an official bundle's detail document, read and follow [adding a plugin feature preview](../../../docs/cookbook/adding-a-plugin-preview.md). It owns optionality, image count, design canvas, position, language variants, file location, and acceptance. The guide also owns the embedded-raster exception for SVG previews; small icons remain geometry-only. The icon dimensions below do not apply to previews. Inspect the real feature and preserve its produced assets.
|
||||
|
||||
## Establish the subject
|
||||
|
||||
|
||||
@@ -5,54 +5,9 @@
|
||||
- paragraph:
|
||||
- code: "@deepseek-ai/dsh-experimental-badge-skill-bundle"
|
||||
- paragraph: 让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。
|
||||
- img "DSH 徽章技能功能预览"
|
||||
- region "详细介绍":
|
||||
- heading "@deepseek-ai/dsh-experimental-badge-skill-bundle" [level=1]
|
||||
- paragraph: English | 中文
|
||||
- heading "Summary" [level=2]
|
||||
- paragraph: Let Agents whose presets load skills add a “powered by dsh” badge to documents, pull requests, and other outputs. The shared provider adds no model tool. It ships switched off in Web and Desktop. Select it in Plugins to enable it.
|
||||
- heading "Table of Contents" [level=2]
|
||||
- list:
|
||||
- listitem: Use this package
|
||||
- listitem: Understand the implementation
|
||||
- listitem: Further Exploration
|
||||
- listitem: Model Experience
|
||||
- listitem: Known Limitations and Deferred Work
|
||||
- listitem: Dev Note
|
||||
- separator
|
||||
- paragraph
|
||||
- heading "Use this package" [level=2]
|
||||
- paragraph:
|
||||
- text: In Web or Desktop, open Plugins and enable
|
||||
- strong: DSH badge skill
|
||||
- text: in Official. Switch it off to remove its profile layer.
|
||||
- paragraph: The switch adds or removes a Host-wide skill provider for existing and new Agents. Presets that expose skill loading can use the badge; minimal keeps its tool catalog unchanged.
|
||||
- separator
|
||||
- paragraph
|
||||
- heading "Understand the implementation" [level=2]
|
||||
- paragraph:
|
||||
- code: cordis.patch.yml
|
||||
- text: contributes the
|
||||
- code: skill-badge
|
||||
- text: provider row on the Host. Later root patches can disable that row; an override alone does not select the bundle. The profile composer owns patch ordering and errors.
|
||||
- separator
|
||||
- paragraph
|
||||
- heading "Further Exploration" [level=2]
|
||||
- paragraph: Capability implementation
|
||||
- separator
|
||||
- paragraph
|
||||
- heading "Model Experience" [level=2]
|
||||
- paragraph: Indirectly, through the capability implementation, which owns model context and results.
|
||||
- heading "KV Cache effect" [level=4]
|
||||
- paragraph: The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results.
|
||||
- heading "Known Limitations and Deferred Work" [level=2]
|
||||
- paragraph
|
||||
- list:
|
||||
- listitem: A preset must include skill discovery and loading to use the badge. The minimal preset does not gain skill tools from this bundle.
|
||||
- separator
|
||||
- paragraph
|
||||
- heading "Dev Note" [level=3]
|
||||
- paragraph: None.
|
||||
- img "DSH 徽章技能功能预览"
|
||||
- heading "包含的组件" [level=4]
|
||||
- text: 共 1 个 · 1 已停用
|
||||
- list:
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, parseSeedFixture, realizeSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettings, newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
import { openSettings, newEnglishPage, pinBrowserClock, pinHostClock, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-actions', import.meta.url))
|
||||
// Borrowed read-only: this scenario needs any settled user+assistant pair, not
|
||||
@@ -191,6 +191,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let hostClock: ReturnType<typeof pinHostClock> | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
@@ -211,6 +212,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
await seedSession(scaffold, raw, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME - 90_000 })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
// Fork summaries use Host time; placeholders use browser time. Each pin
|
||||
// starts at the fixture date, so keep these calls adjacent: setup between
|
||||
// them can create enough clock skew to reverse the fork order.
|
||||
hostClock = pinHostClock()
|
||||
await pinBrowserClock(page)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
@@ -218,8 +223,12 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
try {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
} finally {
|
||||
hostClock?.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('enables branch only on the completed transcript tail', async () => {
|
||||
|
||||
@@ -691,13 +691,13 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
it('shows an optional feature preview before activation and leaves other plugin details unchanged', async () => {
|
||||
it('shows a bundle detail document image before activation and leaves other plugin details unchanged', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-preview'))
|
||||
const panel = await openPluginsPanel()
|
||||
await panel.getByRole('button', { name: '更多', exact: true }).click()
|
||||
await panel.getByRole('button', { name: '查看 DSH 徽章技能', exact: true }).click()
|
||||
const detail = panel.locator('[data-plugin-detail]')
|
||||
const preview = detail.getByRole('img', { name: 'DSH 徽章技能功能预览', exact: true })
|
||||
const preview = detail.locator('[data-detail-document]').getByRole('img', { name: 'DSH 徽章技能功能预览', exact: true })
|
||||
await preview.waitFor()
|
||||
await preview.evaluate(async (image: HTMLImageElement) => { await image.decode() })
|
||||
expect(await preview.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(640)
|
||||
@@ -710,8 +710,6 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(fits).toBe(true)
|
||||
await detail.getByText(`v${badgeVersion}`, { exact: true }).waitFor()
|
||||
await detail.getByText(badgeVersion, { exact: true }).waitFor()
|
||||
// The bundle's README is its detail document, shown between its components and its source.
|
||||
await detail.locator('[data-detail-document] h1').waitFor()
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'),
|
||||
await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
|
||||
replacements: [[badgeVersion, '{{version}}']],
|
||||
@@ -719,7 +717,8 @@ describe('web e2e: plugin manager', () => {
|
||||
const chineseSource = await preview.getAttribute('src')
|
||||
try {
|
||||
await setLanguage('en')
|
||||
const englishPreview = detail.getByRole('img', { name: 'DSH badge skill feature preview', exact: true })
|
||||
const englishPreview = detail.locator('[data-detail-document]')
|
||||
.getByRole('img', { name: 'DSH badge skill feature preview', exact: true })
|
||||
await englishPreview.waitFor()
|
||||
await englishPreview.evaluate(async (image: HTMLImageElement) => { await image.decode() })
|
||||
expect(await englishPreview.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(640)
|
||||
@@ -730,7 +729,8 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(await preview.getAttribute('src')).toBe(chineseSource)
|
||||
await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click()
|
||||
await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click()
|
||||
// Detail icons have alt=""; only feature previews expose an image role.
|
||||
// Detail icons have alt=""; this bundle's README detail document has no images.
|
||||
await panel.locator('[data-detail-document] h1').waitFor()
|
||||
expect(await panel.getByRole('img').count()).toBe(0)
|
||||
await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click()
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
|
||||
@@ -12,6 +12,8 @@ import { newEnglishPage } from './support.ts'
|
||||
|
||||
const bundle = fileURLToPath(new URL('../../../packages/experimental/voice-input-bundle', import.meta.url))
|
||||
const expected = fileURLToPath(new URL('./expected/voice-download.expected.md', import.meta.url))
|
||||
// The input-device picker on the same page shows its own alert when headless Chromium offers no microphone.
|
||||
const DOWNLOAD_ALERT = '[role="alert"]:has-text("Download source")'
|
||||
|
||||
it('shows the failed asset, actual download source and recovery advice, then retries on request', async () => {
|
||||
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
|
||||
@@ -45,14 +47,14 @@ it('shows the failed asset, actual download source and recovery advice, then ret
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
await page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]').getByRole('button').click()
|
||||
await page.getByRole('button', { name: 'Download and prepare', exact: true }).click()
|
||||
const alert = page.getByRole('alert')
|
||||
const alert = page.getByRole('alert').filter({ hasText: 'Download source' })
|
||||
await alert.waitFor()
|
||||
expect(await alert.textContent()).toContain('HTTP 503')
|
||||
expect(await alert.textContent()).toContain(`Download source: ${origin}`)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]).toContain('/resolve/2365baeacb507f821a0c8120fcee3d484dba7a07/model.int8.onnx')
|
||||
await compareOrRefreshGolden(expected,
|
||||
(await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd)).replaceAll(origin, 'https://model-mirror.example'), webSnapshotMode())
|
||||
(await captureStableAria(page, DOWNLOAD_ALERT, scaffold.workspaceCwd)).replaceAll(origin, 'https://model-mirror.example'), webSnapshotMode())
|
||||
status = 404
|
||||
await page.getByRole('button', { name: 'Retry preparation', exact: true }).click()
|
||||
await expect.poll(() => alert.textContent()).toContain('HTTP 404')
|
||||
@@ -108,7 +110,7 @@ it.each([{ winner: 0, manual: false }, { winner: 1, manual: false }, { winner: 0
|
||||
await picker.waitFor()
|
||||
if (manual) await picker.selectOption(origins[winner]!)
|
||||
await page.getByRole('button', { name: 'Download and prepare', exact: true }).click()
|
||||
const alert = page.getByRole('alert')
|
||||
const alert = page.getByRole('alert').filter({ hasText: 'Download source' })
|
||||
await alert.waitFor()
|
||||
expect(downloads).toEqual(manual ? [winner] : [winner, 1 - winner])
|
||||
expect(requestedPaths).toHaveLength(manual ? 1 : 4)
|
||||
@@ -118,7 +120,7 @@ it.each([{ winner: 0, manual: false }, { winner: 1, manual: false }, { winner: 0
|
||||
expect(await alert.textContent()).toContain(manual ? 'HTTP 503' : 'HTTP 502')
|
||||
expect(await alert.textContent()).toContain(`Download source: ${failedOrigin}`)
|
||||
await compareOrRefreshGolden(manual ? expected : fileURLToPath(new URL('./expected/voice-download-fallback.expected.md', import.meta.url)),
|
||||
(await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd)).replaceAll(failedOrigin, manual ? 'https://model-mirror.example' : 'https://fallback-model-source.example'),
|
||||
(await captureStableAria(page, DOWNLOAD_ALERT, scaffold.workspaceCwd)).replaceAll(failedOrigin, manual ? 'https://model-mirror.example' : 'https://fallback-model-source.example'),
|
||||
webSnapshotMode())
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 0894bd9f35430753
|
||||
zh: 2d28090e71134edd
|
||||
/cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata:
|
||||
en: 68b463cd3d2e5fb0
|
||||
zh: 80ea797c5770c573
|
||||
en: ed501a72ab8ba6e7
|
||||
zh: 8ad775b735887a10
|
||||
/cookbook-adding-a-workspace-package/6-verify:
|
||||
en: 80c7fb90b1175aae
|
||||
zh: 2ba37962dbc57299
|
||||
@@ -147,7 +147,7 @@ Export `<package name>/locale/en.json` for locale lookup; expose `<package name>
|
||||
|
||||
For an image on bundle cards, details, and component rows, a package-root plugin sets top-level `"icon": "./icon.svg"` in its exported manifest, or exports `./icon` when that field is omitted. A subpath plugin exports its own icon, such as `"./search/icon": "./search.svg"`. Include each image in `files`. A manifest `icon` is relative to the manifest's directory; absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. An exported icon resolves through Node exports like locale resources and must remain inside its package. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Images must be self-contained; SVG is rendered as an image, not inline HTML. The Host returns a data URL without activating the plugin. Invalid declarations or unreadable files produce a metadata diagnostic while retaining valid text, without trying the export; missing or undecodable images use the panel's default artwork.
|
||||
|
||||
A bundle can also set `meta.detail` to a Markdown file path relative to the package root, such as `"docs/DETAIL.md"` in `locale/en.json` and `"docs/DETAIL.zh.md"` in `locale/zh.json`; it is read from these files at the package root. The path uses `/`, stays inside the package, ends in `.md` or `.markdown`, and has no package-field fallback; include the file and its images in `files`. A bundle's page in **Plugins**, and its market entry page when the [custom plugin markets bundle](../../packages/experimental/plugin-market-bundle/README.md) is on, show the file for the active UI language as the plugin's detail document. Without `meta.detail` they show the package README, and a market entry page then falls back to the declared repository's README; the [document order](../../packages/util/plugin-detail/README.md#document-order) defines the rules. Write it for users, and reference images by paths relative to the file.
|
||||
A bundle can also set `meta.detail` to a Markdown file path relative to the package root, such as `"docs/DETAIL.md"` in `locale/en.json` and `"docs/DETAIL.zh.md"` in `locale/zh.json`; it is read from these files at the package root. The path uses `/`, stays inside the package, ends in `.md` or `.markdown`, and has no package-field fallback; keep its images beside it, and list its top-level directory, such as `docs`, in `files` right after the locale resources, as `check-workspace-constraints` requires. The named file must exist in a subdirectory of the package; a root-level document is rejected because its neighboring images would not be published. A bundle's page in **Plugins**, and its market entry page when the [custom plugin markets bundle](../../packages/experimental/plugin-market-bundle/README.md) is on, show the file for the active UI language as the plugin's detail document. Without `meta.detail` they show the package README, and a market entry page then falls back to the declared repository's README; the [document order](../../packages/util/plugin-detail/README.md#document-order) defines the rules. Write it for users, and reference images by paths relative to the file.
|
||||
|
||||
Installed bundle cards and details, component lists and configuration details, and Settings' plugin inventory display this metadata, including disabled and preset plugins. Reads do not activate plugins.
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex
|
||||
|
||||
要在组合包卡片、详情和组件行显示图片,包根插件在导出清单顶层设置 `"icon": "./icon.svg"`,或在省略该字段时导出 `./icon`。子路径插件导出自己的图标,例如 `"./search/icon": "./search.svg"`。将每张图片加入 `files`。清单 `icon` 相对于清单所在目录;绝对路径、URL、目录外路径,以及解析到目录外的符号链接均被拒绝。导出的图标像 locale 资源一样经 Node exports 解析,且必须位于所属包内。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。图片必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活插件。声明无效或文件不可读时,显示元信息诊断并保留有效文本,不再尝试导出的图标;图片缺失或无法解码时使用面板的默认插画。
|
||||
|
||||
组合包还可以把 `meta.detail` 设为相对于包根目录的 Markdown 文件路径,例如在 `locale/en.json` 中设为 `"docs/DETAIL.md"`,在 `locale/zh.json` 中设为 `"docs/DETAIL.zh.md"`;读取时使用包根目录下的这些文件。路径使用 `/`,必须位于包内,以 `.md` 或 `.markdown` 结尾,且没有包字段回退;将该文件及其图片加入 `files`。组合包在**插件**中的页面,以及启用[自定义插件市场组合包](../../packages/experimental/plugin-market-bundle/README.zh.md)时其市场条目页面,把当前界面语言对应的该文件作为插件的详细介绍文档显示。没有 `meta.detail` 时,它们显示包 README;市场条目页面在此之后还会回退到所声明仓库的 README;规则由[文档顺序](../../packages/util/plugin-detail/README.zh.md#document-order)定义。该文档面向用户编写,图片使用相对于该文件的路径引用。
|
||||
组合包还可以把 `meta.detail` 设为相对于包根目录的 Markdown 文件路径,例如在 `locale/en.json` 中设为 `"docs/DETAIL.md"`,在 `locale/zh.json` 中设为 `"docs/DETAIL.zh.md"`;读取时使用包根目录下的这些文件。路径使用 `/`,必须位于包内,以 `.md` 或 `.markdown` 结尾,且没有包字段回退;图片放在该文件旁边,并按 `check-workspace-constraints` 的要求,在 `files` 中紧接语言资源之后列出该文件的顶层目录,例如 `docs`。该文件必须位于包的子目录中且已存在;包根目录下的文档会被拒绝,因为其旁边的图片不会被发布。组合包在**插件**中的页面,以及启用[自定义插件市场组合包](../../packages/experimental/plugin-market-bundle/README.zh.md)时其市场条目页面,把当前界面语言对应的该文件作为插件的详细介绍文档显示。没有 `meta.detail` 时,它们显示包 README;市场条目页面在此之后还会回退到所声明仓库的 README;规则由[文档顺序](../../packages/util/plugin-detail/README.zh.md#document-order)定义。该文档面向用户编写,图片使用相对于该文件的路径引用。
|
||||
|
||||
已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。
|
||||
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
# 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 docs/cookbook/adding-a-plugin-preview.md
|
||||
/cookbook-add-a-built-in-plugin-feature-preview:
|
||||
en: 1038debc95283b43
|
||||
zh: a41eae3d9203c400
|
||||
/cookbook-add-a-built-in-plugin-feature-preview/1-decide-whether-an-image-helps:
|
||||
en: d2b84c3a4d590f43
|
||||
zh: b064b42f148bd323
|
||||
/cookbook-add-a-built-in-plugin-feature-preview/2-draw-one-fixed-size-svg:
|
||||
en: 1abb5284f499ae7e
|
||||
zh: b4dacd16d8f04728
|
||||
/cookbook-add-a-built-in-plugin-feature-preview/3-register-the-internal-pair:
|
||||
en: e79355a7184d82d3
|
||||
zh: 92a0e188dee2717c
|
||||
/cookbook-add-a-built-in-plugin-feature-preview/4-keep-the-page-contract:
|
||||
en: 095cb97e16c2232e
|
||||
zh: 2ed9b60710ed522c
|
||||
/cookbook-add-a-built-in-plugin-feature-preview/5-validate-and-review:
|
||||
en: 053b2105cc00b8d0
|
||||
zh: d06467ded2ccac42
|
||||
/cookbook-add-a-plugin-feature-preview:
|
||||
en: c37096cd3cdb886c
|
||||
zh: fc6850d68c638898
|
||||
/cookbook-add-a-plugin-feature-preview/1-decide-whether-an-image-helps:
|
||||
en: ee84f52d834cbccf
|
||||
zh: 7bf3a425a231b5e6
|
||||
/cookbook-add-a-plugin-feature-preview/2-draw-one-fixed-size-svg:
|
||||
en: 3a28640aa605dce8
|
||||
zh: c5336958f92b5a70
|
||||
/cookbook-add-a-plugin-feature-preview/3-put-it-at-the-top-of-the-detail-document:
|
||||
en: 17e2ace32a89c419
|
||||
zh: c635a890e86a41ec
|
||||
/cookbook-add-a-plugin-feature-preview/4-page-behavior:
|
||||
en: 20ce358cb110104c
|
||||
zh: abb8d72fed81f3c8
|
||||
/cookbook-add-a-plugin-feature-preview/5-validate-and-review:
|
||||
en: 2888c02bac267662
|
||||
zh: 5b0e95611c8d5d94
|
||||
@@ -1,52 +1,45 @@
|
||||
# Cookbook: Add a built-in plugin feature preview
|
||||
# Cookbook: Add a plugin feature preview
|
||||
|
||||
English | [中文](adding-a-plugin-preview.zh.md)
|
||||
|
||||
Plugin Manager owns optional feature illustrations for built-in bundles. Follow this guide when editing its internal `BUNDLE_PREVIEWS` map. Packages cannot declare illustrations through their manifest or exports; third-party plugins have no preview interface.
|
||||
A feature preview is an optional illustration at the top of a bundle's detail document, the long description that its page in **Plugins** shows first. Follow this guide when drawing one for an official bundle. The detail document itself is declared through [`meta.detail`](adding-a-package.md#plugin-display-metadata).
|
||||
|
||||
## 1. Decide whether an image helps
|
||||
|
||||
Show one concrete result or recognizable capability. Omit the map entry when the description is enough: no image is required, and omission reserves no space. Essential instructions stay in localized text or configuration outside the illustration.
|
||||
Show one concrete result or recognizable capability. Omit the image when the description is enough: no image is required. Essential instructions stay in the document's text or in configuration outside the illustration.
|
||||
|
||||
## 2. Draw one fixed-size SVG
|
||||
|
||||
| Item | Requirement |
|
||||
|---|---|
|
||||
| Count | Zero or one logical feature image per bundle. English and Chinese files are alternatives for that image, not a gallery or carousel. |
|
||||
| Format | SVG only: `width="640" height="300" viewBox="0 0 640 300"` (32:15). Raster preview files and other dimensions are unsupported. |
|
||||
| Languages | Supply both `en` and `zh`. Translate headings, annotations, and example content; preserve official brand assets and names. No single strings, arrays, or extra languages. |
|
||||
| Count | Zero or one feature image per bundle. English and Chinese files are alternatives for that image, not a gallery or carousel. |
|
||||
| Format | SVG: `width="640" height="300" viewBox="0 0 640 300"` (32:15). |
|
||||
| Languages | Supply both an English and a Chinese file. Translate headings, annotations, and example content; preserve official brand assets and names. |
|
||||
| Composition | One main example, optionally with one close-up. Keep important content about 32 canvas units from the edges. Review text at the page's actual size. |
|
||||
| Typography | Outline lettering into paths using a suitable font. Match the feature and neighboring UI; do not rely on browser font substitution. |
|
||||
| Theme | Compare light and dark themes. One illustration serves both; avoid transparent dark text that disappears on dark backgrounds. |
|
||||
|
||||
The SVG is self-contained: no fonts, remote resources, scripts, handlers, animation, stylesheets, `text`, or `foreignObject`. An `image` element may embed an unchanged official PNG/JPEG/WebP brand asset as a base64 data URL. Small plugin icons remain geometry-only. This exception preserves brand artwork and does not permit raster preview files. Validation checks the data-URL envelope, not decoded pixels; the page renders the SVG through `<img>`. Both raw language files together must stay within 1 MiB. They are embedded as base64 data URLs in the Plugin Manager client bundle, so keep them small.
|
||||
The SVG is self-contained: no fonts, remote resources, scripts, handlers, animation, stylesheets, `text`, or `foreignObject`. An `image` element may embed an unchanged official PNG/JPEG/WebP brand asset as a base64 data URL. Small plugin icons remain geometry-only. The page renders the SVG through `<img>`. Keep each file small; the Host refuses images above plugin-manager's `detailImageMaxBytes`, 5 MiB by default.
|
||||
|
||||
## 3. Register the internal pair
|
||||
## 3. Put it at the top of the detail document
|
||||
|
||||
Put `<feature>-preview.svg` and `<feature>-preview.zh.svg` under [Plugin Manager assets](../../packages/client/ui-plugin-manager/src/client/assets/). Import both into [PluginManagerPage.tsx](../../packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx), then add one exact built-in package name to `BUNDLE_PREVIEWS`:
|
||||
Save the pair as `docs/preview.svg` and `docs/preview.zh.svg` in the bundle package, beside `docs/DETAIL.md` and `docs/DETAIL.zh.md`, and make the image the first line of each document:
|
||||
|
||||
```ts ignore-check
|
||||
import examplePreview from './assets/example-preview.svg'
|
||||
import examplePreviewZh from './assets/example-preview.zh.svg'
|
||||
|
||||
// One entry in the existing Map<string, Readonly<Record<'en' | 'zh', string>>>.
|
||||
['@deepseek-ai/dsh-example-bundle', { en: examplePreview, zh: examplePreviewZh }]
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
Keep the map's required `en` and `zh` type. Do not add package metadata, a Host read method, exported preview resources, or a third-party declaration path. Use the [badge skill](../../packages/client/ui-plugin-manager/src/client/assets/badge-skill-preview.svg) and [voice input](../../packages/client/ui-plugin-manager/src/client/assets/voice-input-preview.svg) illustrations as examples. The voice transcript is explanatory sample content, not a recording result.
|
||||
The Chinese document references `preview.zh.svg` with Chinese alternative text. Use the [badge skill](../../packages/experimental/badge-skill-bundle/docs/DETAIL.md) and [voice input](../../packages/experimental/voice-input-bundle/docs/DETAIL.md) documents as examples. The voice transcript is explanatory sample content, not a recording result.
|
||||
|
||||
## 4. Keep the page contract
|
||||
## 4. Page behavior
|
||||
|
||||
| Item | Current behavior |
|
||||
|---|---|
|
||||
| Position | Below the description and status notices, above components and source information; aligned with the description. |
|
||||
| Spacing | 24 px above the image; 32 px before the following details section. |
|
||||
| Display | One `<img>`, natural 640×300 canvas, at most the available width and 320 px high; proportional scaling. The page supplies border and 16 px corners. |
|
||||
| Language | The current UI language selects one of the required files. Alternative text uses the localized plugin name. |
|
||||
| Availability | Visible before activation. Decode failure shows a localized notice without blocking controls. Lists and component rows retain their small icons. |
|
||||
| Position | First in the detail document, which is the first section of the bundle page, below the description and status notices. |
|
||||
| Display | The detail document renders the image at most its own width, inside its height-bounded scroll area. |
|
||||
| Language | The document for the UI language references its own file. |
|
||||
| Availability | Visible before activation. Lists and component rows keep their small icons. |
|
||||
|
||||
## 5. Validate and review
|
||||
|
||||
Run `pnpm exec vitest run scripts/builtin-plugin-preview.spec.ts packages/client/ui-plugin-manager/tests/components.client.spec.tsx scripts/client-bundle-css.spec.ts`, then `pnpm run build`. The asset test checks every registered pair, fixed SVG dimensions, self-contained content, and combined size. Type checking requires both languages. These checks enforce format; visual quality and equivalent meaning in both languages still require review.
|
||||
|
||||
Open the real plugin details in English and Chinese before activation. Decode the image and confirm 640×300 natural dimensions, placement, light/dark readability, and no horizontal overflow at 390 px. Also check a built-in without artwork and a third-party plugin. Follow [record-browser-gif](../../.agents/skills/record-browser-gif/SKILL.md) for PR evidence; record from the final clean commit and label the demonstrated flow accurately.
|
||||
`scripts/plugin-preview-assets.spec.ts` checks every preview pair in a bundle's `docs` directory: the fixed canvas, absent scripts, stylesheets, text elements, and external references, the 1 MiB pair limit, and English and Chinese files that differ. Then open the real plugin details in English and Chinese before activation and confirm the image loads, its placement, light/dark readability, and no horizontal overflow at 390 px; review owns visual quality and equivalent meaning in both languages. Follow [record-browser-gif](../../.agents/skills/record-browser-gif/SKILL.md) for PR evidence; record from the final clean commit and label the demonstrated flow accurately.
|
||||
@@ -1,52 +1,45 @@
|
||||
# Cookbook: 添加内置插件功能配图
|
||||
# Cookbook: 添加插件功能配图
|
||||
|
||||
[English](adding-a-plugin-preview.md) | 中文
|
||||
|
||||
插件管理拥有内置组合包的可选功能配图。编辑其内部 `BUNDLE_PREVIEWS` 映射时遵循本指引。包不能通过 manifest 或 exports 声明配图;第三方插件没有配图接口。
|
||||
功能配图是位于组合包详细介绍文档顶部的可选插图;详细介绍文档是该组合包在**插件**中的页面最先显示的长描述。为官方组合包绘制配图时遵循本指引。详细介绍文档本身通过 [`meta.detail`](adding-a-package.zh.md#plugin-display-metadata) 声明。
|
||||
|
||||
## 1. 判断是否需要配图
|
||||
|
||||
展示一个具体结果或容易辨认的能力。文字说明足够时不添加映射项:配图不是必需的,省略时不占空间。必要操作说明仍放在图外的本地化文字或配置中。
|
||||
展示一个具体结果或容易辨认的能力。文字说明足够时省略配图:配图不是必需的。必要操作说明仍放在文档文字或图外的配置中。
|
||||
|
||||
## 2. 绘制一张固定尺寸 SVG
|
||||
|
||||
| 项目 | 要求 |
|
||||
|---|---|
|
||||
| 数量 | 每个组合包零张或一张逻辑配图。英文和中文文件是同一张图的替代版本,不是图库或轮播。 |
|
||||
| 格式 | 仅 SVG:`width="640" height="300" viewBox="0 0 640 300"`(32:15)。不支持位图配图文件或其他尺寸。 |
|
||||
| 语言 | 同时提供 `en` 和 `zh`。翻译标题、标注和示例内容,保留官方品牌素材和名称。不使用单个字符串、数组或额外语言。 |
|
||||
| 数量 | 每个组合包零张或一张配图。英文和中文文件是同一张图的替代版本,不是图库或轮播。 |
|
||||
| 格式 | SVG:`width="640" height="300" viewBox="0 0 640 300"`(32:15)。 |
|
||||
| 语言 | 同时提供英文和中文文件。翻译标题、标注和示例内容,保留官方品牌素材和名称。 |
|
||||
| 构图 | 一个主体示例,可选一个局部特写。重要内容距边缘约 32 个画布单位;按页面实际显示尺寸检查文字。 |
|
||||
| 字体 | 选择合适字体并把文字转成路径,匹配功能及相邻界面,不依赖浏览器字体替换。 |
|
||||
| 主题 | 对比浅色和深色主题。同一张图服务两种主题,避免透明底深色文字在深色背景中消失。 |
|
||||
|
||||
SVG 必须自包含:不使用字体、远程资源、脚本、事件处理器、动画、样式表、`text` 或 `foreignObject`。`image` 元素可以通过 base64 data URL 嵌入未改动的官方 PNG/JPEG/WebP 品牌素材。小插件图标仍仅使用几何图形。此例外用于保留品牌素材,不允许直接使用位图配图文件。校验检查 data URL 结构,不解码像素;页面通过 `<img>` 渲染 SVG。两种语言的原始文件合计不超过 1 MiB。图片会以 base64 data URL 嵌入插件管理 client bundle,因此应控制大小。
|
||||
SVG 必须自包含:不使用字体、远程资源、脚本、事件处理器、动画、样式表、`text` 或 `foreignObject`。`image` 元素可以通过 base64 data URL 嵌入未改动的官方 PNG/JPEG/WebP 品牌素材。小插件图标仍仅使用几何图形。页面通过 `<img>` 渲染 SVG。每个文件都应控制大小;Host 会拒绝超过 plugin-manager 的 `detailImageMaxBytes`(默认 5 MiB)的图片。
|
||||
|
||||
## 3. 注册内部双语配图
|
||||
## 3. 放在详细介绍文档顶部
|
||||
|
||||
将 `<feature>-preview.svg` 和 `<feature>-preview.zh.svg` 放在[插件管理素材目录](../../packages/client/ui-plugin-manager/src/client/assets/)下。在 [PluginManagerPage.tsx](../../packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx) 中导入两者,再向 `BUNDLE_PREVIEWS` 添加一个精确的内置包名:
|
||||
将这对文件保存为组合包中的 `docs/preview.svg` 和 `docs/preview.zh.svg`,与 `docs/DETAIL.md` 和 `docs/DETAIL.zh.md` 放在一起,并把图片作为每份文档的第一行:
|
||||
|
||||
```ts ignore-check
|
||||
import examplePreview from './assets/example-preview.svg'
|
||||
import examplePreviewZh from './assets/example-preview.zh.svg'
|
||||
|
||||
// One entry in the existing Map<string, Readonly<Record<'en' | 'zh', string>>>.
|
||||
['@deepseek-ai/dsh-example-bundle', { en: examplePreview, zh: examplePreviewZh }]
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
保留映射中必需的 `en` 和 `zh` 类型。不添加包元数据、Host 读取方法、导出的配图资源或第三方声明入口。可参考[徽章技能](../../packages/client/ui-plugin-manager/src/client/assets/badge-skill-preview.svg)和[语音输入](../../packages/client/ui-plugin-manager/src/client/assets/voice-input-preview.svg)配图。语音图中的转写文字是说明性示例,不是录音结果。
|
||||
中文文档引用 `preview.zh.svg`,并使用中文替代文字。可参考[徽章技能](../../packages/experimental/badge-skill-bundle/docs/DETAIL.md)和[语音输入](../../packages/experimental/voice-input-bundle/docs/DETAIL.md)文档。语音图中的转写文字是说明性示例,不是录音结果。
|
||||
|
||||
## 4. 保持页面契约
|
||||
## 4. 页面行为
|
||||
|
||||
| 项目 | 当前行为 |
|
||||
|---|---|
|
||||
| 位置 | 在描述和状态提示下方、组件与来源信息上方,与说明左边缘对齐。 |
|
||||
| 间距 | 图片上方 24 px,后续详情区前 32 px。 |
|
||||
| 显示 | 一个 `<img>`,原始画布 640×300,最大宽度为可用宽度,最大高度 320 px,等比缩放;页面提供边框及 16 px 圆角。 |
|
||||
| 语言 | 当前 UI 语言选择必需的两份文件之一;替代文字使用本地化插件名。 |
|
||||
| 可用性 | 启用前可见;解码失败显示本地化提示,不阻塞操作;列表和组件行保留小图标。 |
|
||||
| 位置 | 位于详细介绍文档开头;该文档是组合包页面的第一个区域,在描述和状态提示下方。 |
|
||||
| 显示 | 详细介绍文档以不超过文档自身宽度的尺寸渲染图片,图片位于有高度上限的滚动区域内。 |
|
||||
| 语言 | 当前 UI 语言对应的文档引用其自己的文件。 |
|
||||
| 可用性 | 启用前可见;列表和组件行保留小图标。 |
|
||||
|
||||
## 5. 校验与验收
|
||||
|
||||
运行 `pnpm exec vitest run scripts/builtin-plugin-preview.spec.ts packages/client/ui-plugin-manager/tests/components.client.spec.tsx scripts/client-bundle-css.spec.ts`,再运行 `pnpm run build`。素材测试检查每个注册条目的双语文件、固定 SVG 尺寸、自包含内容和合计大小;类型检查要求同时提供两种语言。这些检查约束格式;视觉质量及双语含义一致仍需人工审阅。
|
||||
|
||||
在真实插件详情页分别使用英文和中文,启用前解码图片,确认原始尺寸 640×300、插入位置、浅色及深色主题的可读性,以及 390 px 宽度下没有横向溢出。同时检查一个无配图的内置插件和一个第三方插件。按 [record-browser-gif](../../.agents/skills/record-browser-gif/SKILL.md) 制作 PR 证据,从最终干净提交录制,并准确说明演示范围。
|
||||
`scripts/plugin-preview-assets.spec.ts` 检查组合包 `docs` 目录中的每一对配图:固定画布;不使用脚本、样式表、`text` 元素或外部引用;每对文件合计不超过 1 MiB;英文和中文文件不相同。然后在真实插件详情页分别使用英文和中文,启用前确认图片能加载,并检查插入位置、浅色及深色主题的可读性,以及 390 px 宽度下没有横向溢出;审阅负责检查视觉质量及双语含义是否一致。按 [record-browser-gif](../../.agents/skills/record-browser-gif/SKILL.md) 制作 PR 证据,从最终干净提交录制,并准确说明演示范围。
|
||||
@@ -12,8 +12,8 @@
|
||||
en: 08386e96eebf06d9
|
||||
zh: c6d2b41199b0c466
|
||||
/bilingual-documentation/scope-and-exclusions:
|
||||
en: db70e758813d339b
|
||||
zh: 3dcea283a08c47cb
|
||||
en: eeda71b7ce01c121
|
||||
zh: a6361361c1e3d578
|
||||
/bilingual-documentation/division-of-labor:
|
||||
en: 88097c97cb72f3ba
|
||||
zh: 78a18000626cc818
|
||||
@@ -47,6 +47,8 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co
|
||||
|
||||
**Scope**: the root `CONTRIBUTING.md`, `BRAND_GUIDELINES.md`, and `SAFETY.md` documents, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.
|
||||
|
||||
**Outside scope**: a bundle's detail documents under `packages/<group>/<package>/docs/`, which locale `meta.detail` names for the plugin page. The page shows the file for the active UI language, so these documents carry no in-document language switcher and no consistency record; [`verify-md-links`](../../scripts/verify-md-links.ts) and [`verify-md-wrap`](../../scripts/verify-md-wrap.ts) still check both languages, and review keeps them in step.
|
||||
|
||||
Generated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. A generator puts its generated data in generated regions and writes each region into both pages, so regeneration never requires a manual Chinese update or a new record; only the prose outside the regions is translated and recorded. Freshness and pairing gates enforce their respective invariants independently.
|
||||
|
||||
Shared generated regions, such as the Cordis subsystem regions and the per-package regions of the [config catalog](../config-catalog.md), keep every generated byte identical across languages except localized paired-document paths; labels inside a region are code identifiers rather than translated words. A full-page generator can also render source-owned paired prose, such as [persistence-catalog-text.ts](../../scripts/persistence-catalog-text.ts), from the same structural data with matching Markdown structure and identical code blocks. It regenerates both pages and their consistency record together; review still verifies the paired prose's meaning and terminology.
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
|
||||
**范围**:根目录 `CONTRIBUTING.md`、`BRAND_GUIDELINES.md` 与 `SAFETY.md` 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。
|
||||
|
||||
**范围外**:组合包 `packages/<group>/<package>/docs/` 下由 locale `meta.detail` 指定给插件页的详情文档。插件页只显示当前界面语言对应的文件,因此这些文档不带文档内的语言切换行,也没有一致性记录;[`verify-md-links`](../../scripts/verify-md-links.ts) 与 [`verify-md-wrap`](../../scripts/verify-md-wrap.ts) 仍会检查两种语言,两侧是否同步由评审把关。
|
||||
|
||||
有经评审的中文对侧的生成英文参考文档和图文档遵循配对规则。生成器把生成数据放在生成区域中,并把每个区域写入两种语言的页面,因此重新生成不需要手工更新中文,也不需要重新记录;只有区域外的说明文字需要翻译并计入记录。新鲜度门禁与配对门禁各自独立强制其约束。
|
||||
|
||||
共享生成区域(例如 Cordis 子系统区域和[配置目录](../config-catalog.zh.md)中每个包的区域)在两种语言间保持每个生成字节相同,仅配对文档路径按语言本地化;区域内的标签使用代码标识符而非译文。整页生成器也可以从相同的结构数据渲染源码维护的双语说明,例如 [persistence-catalog-text.ts](../../scripts/persistence-catalog-text.ts),并保持 Markdown 结构对应、代码块完全相同。它同时重新生成两页及其一致性记录;评审仍须验证双语说明的含义与术语。
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
|
||||
en: e37d578192901fe4
|
||||
zh: 4cb252f8b297fae6
|
||||
en: 793bb9459e4928a0
|
||||
zh: 1c2158a2754d51ca
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
|
||||
en: fcb2e69c8b028c0a
|
||||
zh: d505a075679974eb
|
||||
@@ -36,8 +36,8 @@
|
||||
en: c7cd3de96e538815
|
||||
zh: ca603c132c0885fb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs:
|
||||
en: 76e0908903ab8fde
|
||||
zh: ce6ae1cdd6aea971
|
||||
en: 25451392e1118869
|
||||
zh: 8ba84f8fdbb47b3f
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation:
|
||||
en: 203168fc87e939bd
|
||||
zh: 72d0ccd2c54f3f44
|
||||
@@ -48,8 +48,8 @@
|
||||
en: 21a0bbe8eb92229c
|
||||
zh: 096ff63db61992bb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/configuration-slots:
|
||||
en: 4406ecf602ebad0e
|
||||
zh: c0f3d5c34ac82bb3
|
||||
en: 9b37ad9966d54468
|
||||
zh: 801d8d83bd70f480
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/further-exploration:
|
||||
en: f9a42dfed0360d6a
|
||||
zh: 455a70bcb6c69624
|
||||
@@ -60,8 +60,8 @@
|
||||
en: ca75c51c89c2c9b0
|
||||
zh: 2064958b0754647e
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/known-limitations-and-deferred-work:
|
||||
en: 344ba1e802163bb4
|
||||
zh: f3cc2e30ffb037cb
|
||||
en: 149c0d23a56a4944
|
||||
zh: e2feaadc871581ae
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -27,7 +27,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Switching pages or tabs starts at the top; refreshing the current page preserves its scroll position. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Bundle details can show a feature preview below the description, also while the bundle is disabled; **DSH badge skill** shows one in English or Chinese following the UI language, and a decode failure shows a short notice without blocking management. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section.
|
||||
Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Switching pages or tabs starts at the top; refreshing the current page preserves its scroll position. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section.
|
||||
|
||||
The heading moves its complete action group to another line when space is insufficient. Action labels stay on one line, and the information button follows the subtitle text.
|
||||
|
||||
@@ -118,8 +118,6 @@ Another plugin adds a tab after the page's own **Installed** tab by registering
|
||||
|
||||
-----
|
||||
|
||||
Built-in illustrations follow the [feature preview authoring guide](../../../docs/cookbook/adding-a-plugin-preview.md): optional, one 640×300 SVG per language, with both English and Chinese required. The internal map and its asset test enforce the format; packages have no preview declaration interface.
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
@@ -144,7 +142,7 @@ The add control reuses `Button`, `Menu`, and `MenuItemButton`. Both installation
|
||||
|
||||
Custom item pages use the Host entry id as their registration id; row pages use the bundle package and row id. The page owner supplies `form.state` and `form.mutate(operations, expectedRevision)` when the entry exposes editable Config fields. A custom page owns its draft and validation display, and may reuse `ConfigField` from ui-primitives. Bundle-wide pages can contain several entries and have no single form.
|
||||
|
||||
The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins inject `pluginNavigation` and call `ctx.pluginNavigation.openBundle(packageName)` to open bundle details without changing the current Session. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Official packages whose names begin with `@deepseek-ai/dsh-experimental-` display the Experimental marker. Feature previews are SVG files under `src/client/assets/`, keyed by package name in the page and embedded in the Client bundle; packages cannot declare their own ([rationale](../../../.agents/notes/implemented/feature/2026-10-14-built-in-plugin-feature-previews.md)).
|
||||
The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins inject `pluginNavigation` and call `ctx.pluginNavigation.openBundle(packageName)` to open bundle details without changing the current Session. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Official packages whose names begin with `@deepseek-ai/dsh-experimental-` display the Experimental marker.
|
||||
|
||||
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name, and receives `busy` while the owning bundle is being changed. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from either the list or detail page, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed.
|
||||
|
||||
@@ -175,7 +173,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Feature previews have one image per language and no dark-theme variant, so the badge skill preview keeps its light background in dark theme.
|
||||
- Ping response time does not measure package-download throughput. Probes use the Host fetch route, which pnpm-specific proxy settings can differ from; users can always choose the registry manually, and installation retains its existing fallback rules.
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
@@ -27,7 +27,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。切换页面或标签时从顶部开始,同一页面刷新数据时保留滚动位置。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。组合包详情可在说明下方展示功能配图,未启用时也可查看;**DSH 徽章技能**按界面语言展示中文或英文配图,解码失败时显示简短提示,不影响管理操作。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。
|
||||
在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 切换页面或标签时从顶部开始,同一页面刷新数据时保留滚动位置。
|
||||
|
||||
空间不足时,页头将整个操作组移到下一行。操作文案保持单行,信息按钮紧随副标题文字。
|
||||
|
||||
@@ -118,8 +118,6 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
|
||||
|
||||
-----
|
||||
|
||||
内置配图遵循[功能配图创作指引](../../../docs/cookbook/adding-a-plugin-preview.zh.md):可选,每种语言一张 640×300 SVG,必须同时提供英文和中文。内部映射及素材测试约束格式,包没有配图声明接口。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
@@ -144,7 +142,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()`
|
||||
|
||||
自定义条目页以 Host 条目 id 作为注册 id;行页面使用 bundle 包名和行 id。当条目提供可编辑 Config 字段时,页面宿主传入 `form.state` 和 `form.mutate(operations, expectedRevision)`。自定义页面负责草稿和校验提示,并可复用 ui-primitives 的 `ConfigField`。整个 bundle 的页面可以包含多个条目,因此没有单一表单。
|
||||
|
||||
页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件注入 `pluginNavigation`,调用 `ctx.pluginNavigation.openBundle(packageName)` 即可打开组合包详情,不改变当前 Session。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 名称以 `@deepseek-ai/dsh-experimental-` 开头的官方包显示实验性标记。功能配图是 `src/client/assets/` 下的 SVG 文件,由页面按包名指定并嵌入 Client bundle;包不能自行声明配图([决策依据](../../../.agents/notes/implemented/feature/2026-10-14-built-in-plugin-feature-previews.zh.md))。
|
||||
页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件注入 `pluginNavigation`,调用 `ctx.pluginNavigation.openBundle(packageName)` 即可打开组合包详情,不改变当前 Session。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 名称以 `@deepseek-ai/dsh-experimental-` 开头的官方包显示实验性标记。
|
||||
|
||||
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置,并通过 `busy` 接收所属 Bundle 正在变更的状态。`plugins.bundle.activation` 在用户从列表或详情页显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。
|
||||
|
||||
@@ -175,7 +173,6 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()`
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- 功能配图每种语言只有一张,没有深色主题版本,因此徽章技能配图在深色主题中仍为浅色背景。
|
||||
- Ping 响应时间不代表包下载吞吐量。探测使用 Host fetch 的网络路径,pnpm 专用代理设置可能使用不同路径;用户始终可以手动选择安装源,安装仍遵循现有回退规则。
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
@@ -1321,17 +1321,6 @@ label.registryOption {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.packagePreview {
|
||||
display: block;
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
max-height: 320px;
|
||||
margin-top: 24px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.detailSections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -35,10 +35,6 @@ import type { ConfigPageForm } from './slot-contract.ts'
|
||||
import { UpdateDialog, VersionSection } from './versions.tsx'
|
||||
import { BundleDetailSection } from './BundleDetailSection.tsx'
|
||||
import css from './PluginManagerPage.module.css'
|
||||
import badgeSkillPreview from './assets/badge-skill-preview.svg'
|
||||
import badgeSkillPreviewZh from './assets/badge-skill-preview.zh.svg'
|
||||
import voiceInputPreview from './assets/voice-input-preview.svg'
|
||||
import voiceInputPreviewZh from './assets/voice-input-preview.zh.svg'
|
||||
|
||||
/** Full component props assembled by the main slot renderer. */
|
||||
export type PluginManagerPageProps =
|
||||
@@ -161,12 +157,6 @@ function orderOptionalBundles(packages: readonly PackageView[]): PackageView[] {
|
||||
return [...packages].sort((a, b) => optionalBundleRank(a.name) - optionalBundleRank(b.name))
|
||||
}
|
||||
|
||||
/** Feature previews of official bundles, shown on their detail pages and selected by UI language. */
|
||||
const BUNDLE_PREVIEWS = new Map<string, Readonly<Record<'en' | 'zh', string>>>([
|
||||
['@deepseek-ai/dsh-experimental-badge-skill-bundle', { en: badgeSkillPreview, zh: badgeSkillPreviewZh }],
|
||||
['@deepseek-ai/dsh-experimental-voice-input-bundle', { en: voiceInputPreview, zh: voiceInputPreviewZh }],
|
||||
])
|
||||
|
||||
/** How long a toast holds: long enough to read a failure that names what broke. */
|
||||
function toastHoldMs(text: string): number {
|
||||
return Math.min(8_000, Math.max(3_000, text.length * 80))
|
||||
@@ -250,14 +240,6 @@ function PackageArtwork({ src, row = false, size = row ? ROW_ARTWORK_SIZE : CARD
|
||||
: <img className={css.packageImage} src={src} width={size} height={size} alt="" onError={() => { setFailedSource(src) }} />
|
||||
}
|
||||
|
||||
/** A bundle's feature preview; a failed decode is reported in place without blocking management. */
|
||||
function PackagePreview({ src, title, t }: { readonly src: string; readonly title: string; readonly t: Translate }): ReactNode {
|
||||
const [failedSource, setFailedSource] = useState<string>()
|
||||
return src === failedSource
|
||||
? <p className={css.reason} role="status">{t('previewUnavailable')}</p>
|
||||
: <img className={css.packagePreview} src={src} alt={t('previewAlt', { name: title })} onError={() => { setFailedSource(src) }} />
|
||||
}
|
||||
|
||||
/** A row's switch: locked, saying why, when the Host refuses to address the row through the profile patch. */
|
||||
function RowSwitch({ row, title, t, busy, onChange }: {
|
||||
readonly row: PackageRow
|
||||
@@ -763,8 +745,6 @@ function PackageDetail({
|
||||
const { title, description, beta } = packageText(pkg, resolveText)
|
||||
const newer = versionUpdate(versions)
|
||||
const status = packageStatus(pkg)
|
||||
const previews = BUNDLE_PREVIEWS.get(pkg.name)
|
||||
const preview = previews === undefined ? undefined : resolveText(previews)
|
||||
const subject: PluginsSubject = { kind: 'bundle', pkg: packageRef(pkg) }
|
||||
return (
|
||||
<div className={css.detail} data-plugin-detail={pkg.name}>
|
||||
@@ -823,7 +803,6 @@ function PackageDetail({
|
||||
<MetadataError error={pkg.meta?.error} t={t} />
|
||||
{pkg.error === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {managementText(pkg.error, t)}</p>}
|
||||
{pkg.readOnlyReason === undefined ? null : <p className={css.reason} role="status">{managementText({ code: pkg.readOnlyReason }, t)}</p>}
|
||||
{preview === undefined ? null : <PackagePreview src={preview} title={title} t={t} />}
|
||||
<div className={css.detailSections}>
|
||||
{detail}
|
||||
{configured
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/** Illustration imports are embedded in the plugin manager client bundle. */
|
||||
declare module '*.svg' {
|
||||
const url: string
|
||||
export default url
|
||||
}
|
||||
@@ -41,8 +41,6 @@ export const zh = {
|
||||
statusBeta: '实验性',
|
||||
reasonLabel: '原因',
|
||||
metadataError: '包元信息错误:{error}',
|
||||
previewAlt: '{name}功能预览',
|
||||
previewUnavailable: '无法显示功能配图',
|
||||
versionTag: 'v{version}',
|
||||
partsLabel: '包含的组件',
|
||||
sourceTitle: '来源信息',
|
||||
@@ -336,8 +334,6 @@ export const en = {
|
||||
statusBeta: 'Experimental',
|
||||
reasonLabel: 'Reason',
|
||||
metadataError: 'Package metadata error: {error}',
|
||||
previewAlt: '{name} feature preview',
|
||||
previewUnavailable: 'Could not display feature preview',
|
||||
versionTag: 'v{version}',
|
||||
partsLabel: 'Components',
|
||||
sourceTitle: 'Source',
|
||||
|
||||
@@ -877,47 +877,6 @@ describe('PluginManagerPage', () => {
|
||||
expect(detailImage.width).toBe(36)
|
||||
})
|
||||
|
||||
it('shows a known bundle preview in its details before activation, follows the UI language, and survives decode failure', () => {
|
||||
const name = '@deepseek-ai/dsh-experimental-badge-skill-bundle'
|
||||
const { actions, setLanguage } = renderTab({ packages: [pkg({ name, enabled: false }), pkg()] })
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: `View ${name}` }))
|
||||
const english = screen.getByRole('img', { name: en.previewAlt.replace('{name}', name) }).getAttribute('src')
|
||||
expect(english).toMatch(/badge-skill-preview\.svg$/)
|
||||
setLanguage(zh)
|
||||
const chinese = screen.getByRole('img', { name: zh.previewAlt.replace('{name}', name) })
|
||||
expect(chinese.getAttribute('src')).toMatch(/badge-skill-preview\.zh\.svg$/)
|
||||
fireEvent.error(chinese)
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(screen.getByText(zh.previewUnavailable).getAttribute('role')).toBe('status')
|
||||
fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', name) }))
|
||||
expect(actions.setEnabled).toHaveBeenCalledWith(name, true)
|
||||
setLanguage(en)
|
||||
expect(screen.getByRole('img').getAttribute('src')).toBe(english)
|
||||
expect(screen.queryByText(en.previewUnavailable)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no preview for bundles without one', () => {
|
||||
renderTab({ packages: [pkg()] })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View dsh-better-sidebar' }))
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('places the new voice illustration after its description and switches the single image with the UI language', () => {
|
||||
const name = '@deepseek-ai/dsh-experimental-voice-input-bundle'
|
||||
const description = 'Turn speech into local notes.'
|
||||
const { setLanguage } = renderTab({ packages: [pkg({ name, enabled: false, meta: { description }, rows: [row()] })] })
|
||||
fireEvent.click(screen.getByRole('button', { name: `View ${name}` }))
|
||||
const image = screen.getByRole('img')
|
||||
expect(image.getAttribute('src')).toMatch(/voice-input-preview\.svg$/u)
|
||||
expect(screen.getByText(description).compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(image.compareDocumentPosition(screen.getByRole('heading', { name: en.partsLabel })) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
.toBeTruthy()
|
||||
setLanguage(zh)
|
||||
expect(screen.getAllByRole('img')).toHaveLength(1)
|
||||
expect(screen.getByRole('img').getAttribute('src')).toMatch(/voice-input-preview\.zh\.svg$/u)
|
||||
})
|
||||
|
||||
it('shows metadata diagnostics without blocking management or displaying legacy descriptions', () => {
|
||||
const error = 'locale/zh.json: invalid title'
|
||||
const { actions, set, setLanguage } = renderTab({
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
/* A long README scrolls inside the section instead of pushing the page's later sections out of reach. */
|
||||
max-height: min(560px, 60vh);
|
||||
overflow-y: auto;
|
||||
padding: 4px 16px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l1);
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
/* Horizontal rules separate the document from its neighbors; a full frame would clash with the images' own corners. */
|
||||
border-block: 0.5px solid var(--dsw-alias-border-l1);
|
||||
/* Keep the scrollbar off the text and the text width independent of overflow. */
|
||||
scrollbar-gutter: stable;
|
||||
padding: 12px 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 405b30feaf48c087
|
||||
/deepseek-ai-dsh-client-ui-schedule/use-this-package:
|
||||
en: edba0e1573f3ce42
|
||||
zh: 77875b469d94cc62
|
||||
en: 7d44cd1640bebfad
|
||||
zh: 1d6b6c28a3c27115
|
||||
/deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source:
|
||||
en: 6bd6882dbbbefcb0
|
||||
zh: 03aa15f1a65d13dc
|
||||
|
||||
@@ -25,7 +25,7 @@ Manage active and inactive tasks from the Automation tasks page and the right Si
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation.
|
||||
The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation.
|
||||
|
||||
The page header keeps the title at its natural width and moves New to another line when the two no longer fit together. The action label stays on one line.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。
|
||||
随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。
|
||||
|
||||
页头按标题的自然宽度排布;标题和“新建”无法并排容纳时,将按钮移到下一行。操作文案保持单行。
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Decorative document-and-clock artwork with theme-owned colors. */
|
||||
|
||||
interface ScheduleEmptyIllustrationProps {
|
||||
readonly className?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the empty-state illustration without adding an accessible announcement.
|
||||
* @param props - optional CSS class applied to the root SVG.
|
||||
* @returns the decorative inline SVG element.
|
||||
*/
|
||||
export function ScheduleEmptyIllustration({ className }: ScheduleEmptyIllustrationProps) {
|
||||
return (
|
||||
<svg className={className} width="170" height="170" viewBox="0 0 170 170" fill="none" aria-hidden="true" focusable="false">
|
||||
<path d="M163.819 97.9382C163.819 126.572 144.48 135.329 115.847 135.329C87.2129 135.329 60.1276 126.572 60.1276 97.9382C60.1276 69.3047 101.003 46.0927 129.637 46.0927C158.27 46.0927 163.819 69.3047 163.819 97.9382Z" fill="var(--dsw-alias-illustration-background)" />
|
||||
<path d="M9.49521 88.6735C20.8278 111.218 39.5801 110.429 62.2137 99.0519C84.8474 87.6748 102.791 70.018 91.4589 47.4731C80.1263 24.9282 38.6291 22.8935 15.9955 34.2706C-6.63818 45.6478 -1.83735 66.1286 9.49521 88.6735Z" fill="var(--dsw-alias-illustration-background)" />
|
||||
<path d="M25.2636 34.442C24.727 29.8132 28.0445 25.6258 32.6733 25.0893L101.373 17.126C106.002 16.5895 110.189 19.9069 110.726 24.5358L120.107 105.466C120.643 110.095 117.326 114.282 112.697 114.818L43.9972 122.782C39.3684 123.318 35.181 120.001 34.6445 115.372L25.2636 34.442Z" fill="var(--dsw-alias-illustration-backing)" />
|
||||
<path d="M40.169 25.1368C40.169 20.477 43.9466 16.6995 48.6064 16.6995H117.766C122.426 16.6995 126.204 20.477 126.204 25.1368V106.609C126.204 111.268 122.426 115.046 117.766 115.046H48.6064C43.9466 115.046 40.169 111.268 40.169 106.609V25.1368Z" fill="var(--dsw-alias-illustration-surface)" />
|
||||
<path d="M110.258 44.9883V50.3516H72.0831V44.9883H110.258Z" fill="var(--dsw-alias-illustration-detail)" />
|
||||
<path d="M110.258 63.2803V68.6436H72.0831V63.2803H110.258Z" fill="var(--dsw-alias-illustration-detail)" />
|
||||
<path d="M91.171 81.5713V86.9346H72.0831V81.5713H91.171Z" fill="var(--dsw-alias-illustration-detail)" />
|
||||
<path d="M56.1124 43.4998H64.0964V51.4838H56.1124V43.4998Z" fill="var(--dsw-alias-illustration-detail)" />
|
||||
<path d="M56.1124 61.97H64.0964V69.954H56.1124V61.97Z" fill="var(--dsw-alias-illustration-detail)" />
|
||||
<path d="M56.1124 80.2607H64.0964V88.2448H56.1124V80.2607Z" fill="var(--dsw-alias-illustration-detail)" />
|
||||
<path d="M126.205 148.002C126.205 149.615 105.321 150.923 79.5592 150.923C53.7973 150.923 32.9132 149.615 32.9132 148.002C32.9132 146.388 53.7973 145.08 79.5592 145.08C105.321 145.08 126.205 146.388 126.205 148.002Z" fill="var(--dsw-alias-illustration-shadow)" />
|
||||
<path d="M13.1544 119.318C14.7101 119.318 16.2657 119.43 17.8214 119.656C17.9942 119.682 18.1671 119.709 18.3399 119.737V120.238C18.1671 120.266 17.9942 120.293 17.8214 120.319C16.2657 120.545 14.7101 120.657 13.1544 120.657C11.5986 120.657 10.0423 120.545 8.48643 120.319C8.31357 120.293 8.14073 120.266 7.96787 120.238V119.737C8.14073 119.709 8.31357 119.682 8.48643 119.656C10.0423 119.43 11.5986 119.318 13.1544 119.318Z" fill="var(--dsw-alias-illustration-detail)" />
|
||||
<path d="M125.197 2.96338C126.287 2.96338 127.378 3.076 128.468 3.30225C128.589 3.32738 128.71 3.3544 128.831 3.38232V3.88428C128.71 3.9122 128.589 3.93922 128.468 3.96436C127.378 4.1906 126.287 4.30322 125.197 4.30322C124.108 4.30319 123.018 4.19057 121.928 3.96436C121.807 3.93922 121.686 3.91221 121.565 3.88428V3.38232C121.686 3.3544 121.807 3.32738 121.928 3.30225C123.018 3.07603 124.108 2.96341 125.197 2.96338Z" fill="var(--dsw-alias-illustration-detail)" />
|
||||
<path d="M145.187 32.6399C146.742 32.6399 148.298 32.7525 149.854 32.9788C150.026 33.0039 150.199 33.0309 150.372 33.0588V33.5608C150.199 33.5887 150.026 33.6157 149.854 33.6409C148.298 33.8671 146.742 33.9797 145.187 33.9797C143.631 33.9797 142.074 33.8671 140.519 33.6409C140.346 33.6157 140.173 33.5887 140 33.5608V33.0588C140.173 33.0309 140.346 33.0039 140.519 32.9788C142.074 32.7525 143.631 32.6399 145.187 32.6399Z" fill="var(--dsw-alias-illustration-detail)" />
|
||||
<path d="M12.4776 119.987C12.4777 118.431 12.5903 116.875 12.8165 115.32C12.8416 115.147 12.8687 114.974 12.8966 114.801H13.3985C13.4265 114.974 13.4535 115.147 13.4786 115.32C13.7048 116.875 13.8175 118.431 13.8175 119.987C13.8175 121.542 13.7049 123.099 13.4786 124.654C13.4535 124.827 13.4265 125 13.3985 125.173H12.8966C12.8687 125 12.8416 124.827 12.8165 124.654C12.5903 123.099 12.4776 121.542 12.4776 119.987Z" fill="var(--dsw-alias-illustration-detail)" />
|
||||
<path d="M124.522 3.63379C124.522 2.54381 124.634 1.45326 124.86 0.363281C124.886 0.242199 124.913 0.121083 124.941 2.19411e-08L125.442 0C125.47 0.121082 125.497 0.242199 125.523 0.363281C125.749 1.45326 125.861 2.54381 125.861 3.63379C125.861 4.72362 125.749 5.81349 125.523 6.90332C125.497 7.02442 125.47 7.1455 125.442 7.2666H124.941C124.913 7.1455 124.886 7.02442 124.86 6.90332C124.634 5.81349 124.522 4.72362 124.522 3.63379Z" fill="var(--dsw-alias-illustration-detail)" />
|
||||
<path d="M144.509 33.3101C144.509 31.7544 144.622 30.1987 144.848 28.6431C144.873 28.4702 144.9 28.2974 144.928 28.1245H145.43C145.458 28.2974 145.485 28.4702 145.51 28.6431C145.736 30.1987 145.849 31.7544 145.849 33.3101C145.849 34.8659 145.736 36.4222 145.51 37.978C145.485 38.1509 145.458 38.3237 145.43 38.4966H144.928C144.9 38.3237 144.873 38.1509 144.848 37.978C144.622 36.4222 144.509 34.8659 144.509 33.3101Z" fill="var(--dsw-alias-illustration-detail)" />
|
||||
<path d="M149.05 98.8527C149.05 112.66 137.857 123.853 124.05 123.853C110.242 123.853 99.0496 112.66 99.0496 98.8527C99.0496 85.0455 110.242 73.8527 124.05 73.8527C137.857 73.8527 149.05 85.0455 149.05 98.8527Z" fill="var(--dsw-alias-illustration-detail)" />
|
||||
<path d="M144.192 98.8529C144.192 109.978 135.174 118.997 124.049 118.997C112.923 118.997 103.905 109.978 103.905 98.8529C103.905 87.7278 112.923 78.709 124.049 78.709C135.174 78.709 144.192 87.7278 144.192 98.8529Z" fill="var(--dsw-alias-illustration-inset)" />
|
||||
<path d="M125.124 86.7281L125.123 99.2824L134.923 104.132L132.824 108.375L122.76 103.395C121.309 102.677 120.39 101.197 120.39 99.5782L120.389 86.7275L125.124 86.7281Z" fill="var(--dsw-alias-illustration-detail)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -388,6 +388,14 @@
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.emptyIllustration {
|
||||
flex: none;
|
||||
width: 110px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* The mock's `.empty h3 + .btn`: the empty state's action keeps the mock's 16px
|
||||
above. */
|
||||
.emptyAction {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TaskDetail, useTaskDetail, type TaskDetailInjected } from './TaskDetail
|
||||
import { useRelativeClock } from './relative-clock.ts'
|
||||
import { formatScheduleFrequency, nextRunParts, taskName, zoneLabel } from './schedule-format.ts'
|
||||
import type { FrequencyZone } from './schedule-format.ts'
|
||||
import { ScheduleEmptyIllustration } from './ScheduleEmptyIllustration.tsx'
|
||||
import css from './TaskManagerPage.module.css'
|
||||
|
||||
/** Injected catalog and task actions for the management page. */
|
||||
@@ -151,7 +152,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
|
||||
<div className={css.list}>
|
||||
{selected === undefined && <CatalogFeedback status={status} populated={rows.length > 0} onRetry={onRetry} t={t} />}
|
||||
{status === 'ready' && rows.length === 0 && <div className={css.empty} role="status">
|
||||
<IconClockOutlineRegular size={24} className={css.emptyGlyph} />
|
||||
<ScheduleEmptyIllustration className={css.emptyIllustration} />
|
||||
<h2>{t(emptyTitle)}</h2>
|
||||
<Button variant="outline" className={css.emptyAction} onClick={onNewTask}>
|
||||
{t('empty.action')}
|
||||
|
||||
@@ -891,6 +891,24 @@ describe('Task manager catalog', () => {
|
||||
expect(screen.getByRole('button', { name: en['statusFilter.all'] }).getAttribute('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('renders a decorative document-and-clock illustration in an empty catalog', () => {
|
||||
mount({ records: [], status: 'ready' })
|
||||
const illustration = screen.getByRole('status').querySelector('svg')
|
||||
expect(illustration?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(illustration?.getAttribute('focusable')).toBe('false')
|
||||
expect(illustration?.getAttribute('viewBox')).toBe('0 0 170 170')
|
||||
const paths = illustration?.querySelectorAll('path')
|
||||
expect(paths).toHaveLength(20)
|
||||
// Roles follow the approved artwork's paint order, including overlapping paper and clock layers.
|
||||
expect(paths?.[0]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-background)')
|
||||
expect(paths?.[2]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-backing)')
|
||||
expect(paths?.[3]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-surface)')
|
||||
expect(paths?.[10]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-shadow)')
|
||||
expect(paths?.[17]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-detail)')
|
||||
expect(paths?.[18]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-inset)')
|
||||
expect(emptyNewTaskButton().textContent).toBe(en['empty.action'])
|
||||
})
|
||||
|
||||
it('distinguishes an empty catalog, loading, and a failed read', () => {
|
||||
const h = mount({ records: [], status: 'loading' })
|
||||
expect(screen.getByRole('status', { name: en['list.loading'] }).textContent).toBe('')
|
||||
@@ -3761,6 +3779,10 @@ describe('Mock detail metrics shared with the task list', () => {
|
||||
expect(heading).toMatch(/font-size:\s*14px;/)
|
||||
expect(heading).toMatch(/font-weight:\s*400;/)
|
||||
expect(rule('emptyGlyph')).toMatch(/margin-bottom:\s*12px;/)
|
||||
expect(rule('emptyIllustration')).toMatch(/width:\s*110px;/)
|
||||
expect(rule('emptyIllustration')).toMatch(/max-width:\s*100%;/)
|
||||
expect(rule('emptyIllustration')).toMatch(/height:\s*auto;/)
|
||||
expect(rule('emptyIllustration')).toMatch(/margin-bottom:\s*12px;/)
|
||||
// The mock's `.empty h3 + .btn`.
|
||||
expect(rule('emptyAction')).toMatch(/margin-top:\s*16px;/)
|
||||
})
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 3eb9b0e3365b2db3
|
||||
zh: c73acc55dd897266
|
||||
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/stylesheets:
|
||||
en: 2da5b2af3a965090
|
||||
zh: 883c91afc6bf105d
|
||||
en: c47fd726c865cdd3
|
||||
zh: 7c62b6e5857c29a7
|
||||
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/scrollbar-rebinding:
|
||||
en: bf76e7c34a331595
|
||||
zh: 5accd0260ef7c61e
|
||||
|
||||
@@ -69,6 +69,8 @@ In pointer modality, `html[data-input-modality='pointer'] body :focus-visible:no
|
||||
|
||||
Menu icons use `--dsw-alias-menu-icon`: neutral-bluish 800 in light mode and `label-primary-dimmed` in dark mode.
|
||||
|
||||
The `--dsw-alias-illustration-*` tokens separate layered decorative artwork backgrounds, foreground surfaces, backing layers, shadows, inset fills, and details. Clock faces use the inset fill. The dark palette keeps foreground surfaces brighter than backing layers and backgrounds; inset fills and shadows share the backing tone.
|
||||
|
||||
`base.css` suppresses only the outline of focused elements marked `data-dsh-automatic-focus` by the [primitive focus helper](../ui-primitives/README.md); ordinary keyboard focus styling, borders, shadows, and error states remain intact.
|
||||
|
||||
System toasts use `--dsw-alias-toast-bg` and `--dsw-alias-toast-label` for a shared background and text color across callers. Document previews pair `--dsw-alias-bg-document-preview` with `--dsw-alias-label-document-preview` so the backdrop and status text follow the same theme. Tooltip keycaps use `--dsw-alias-tooltip-key-bg`, a lighter fill derived from the tooltip background in each palette. Switch thumbs read `--dsw-alias-switch-thumb`: white in light mode and neutral-bluish 400 in dark mode, so an off switch stays lighter than its track without the glare of pure white.
|
||||
|
||||
@@ -69,6 +69,8 @@ Desktop 的固定快捷键 `Cmd`/`Ctrl` + `+`(或 `=`)和 `Cmd`/`Ctrl` + `-`
|
||||
|
||||
菜单图标使用 `--dsw-alias-menu-icon`:浅色模式为 neutral-bluish 800,深色模式为 `label-primary-dimmed`。
|
||||
|
||||
`--dsw-alias-illustration-*` token 分别定义分层装饰插画的背景、前层表面、后层、投影、内嵌填充与细节配色。表盘使用内嵌填充。深色配色让前层表面比后层和背景更亮;内嵌填充与投影沿用后层色调。
|
||||
|
||||
`base.css` 仅抑制[基础控件焦点工具](../ui-primitives/README.zh.md)通过 `data-dsh-automatic-focus` 标记的聚焦元素外轮廓线;正常键盘焦点样式、边框、阴影及错误状态保持不变。
|
||||
|
||||
系统提示使用 `--dsw-alias-toast-bg` 和 `--dsw-alias-toast-label`,在各调用方之间统一背景与文字颜色。文档预览配对使用 `--dsw-alias-bg-document-preview` 与 `--dsw-alias-label-document-preview`,使底色与状态文字遵循相同主题。Tooltip 键帽使用 `--dsw-alias-tooltip-key-bg`,由各主题的 tooltip 背景派生稍浅的填充。开关滑块读取 `--dsw-alias-switch-thumb`:浅色模式为纯白,深色模式为 neutral-bluish 400,使关闭态滑块比轨道更亮,同时避免纯白在暗色下过亮。
|
||||
|
||||
@@ -59,9 +59,11 @@ body {
|
||||
--dsw-static-neutral-bluish-150: rgb(233, 236, 242);
|
||||
--dsw-static-neutral-bluish-200: rgb(225, 229, 238);
|
||||
--dsw-static-neutral-bluish-300: rgb(207, 211, 214);
|
||||
--dsw-static-neutral-bluish-310: rgb(202, 210, 219);
|
||||
--dsw-static-neutral-bluish-400: rgb(173, 178, 184);
|
||||
--dsw-static-neutral-bluish-500: rgb(151, 157, 166);
|
||||
--dsw-static-neutral-bluish-50: rgb(249, 250, 251);
|
||||
--dsw-static-neutral-bluish-55: rgb(245, 247, 250);
|
||||
--dsw-static-neutral-bluish-600: rgb(129, 133, 140);
|
||||
--dsw-static-neutral-bluish-60: rgb(245, 246, 247);
|
||||
--dsw-static-neutral-bluish-700: rgb(97, 102, 107);
|
||||
@@ -71,6 +73,7 @@ body {
|
||||
--dsw-static-neutral-bluish-850: rgb(44, 44, 46);
|
||||
--dsw-static-neutral-bluish-875: rgb(35, 35, 36);
|
||||
--dsw-static-neutral-bluish-900: rgb(27, 27, 28);
|
||||
--dsw-static-neutral-bluish-90: rgb(237, 240, 244);
|
||||
--dsw-static-neutral-bluish-950: rgb(21, 21, 23);
|
||||
--dsw-static-red-100: rgb(254, 226, 226);
|
||||
--dsw-static-red-400: rgb(242, 90, 90);
|
||||
@@ -140,9 +143,11 @@ body[data-ds-dark-theme] {
|
||||
--dsw-static-neutral-bluish-150: rgb(233, 236, 242);
|
||||
--dsw-static-neutral-bluish-200: rgb(225, 229, 238);
|
||||
--dsw-static-neutral-bluish-300: rgb(207, 211, 214);
|
||||
--dsw-static-neutral-bluish-310: rgb(202, 210, 219);
|
||||
--dsw-static-neutral-bluish-400: rgb(173, 178, 184);
|
||||
--dsw-static-neutral-bluish-500: rgb(151, 157, 166);
|
||||
--dsw-static-neutral-bluish-50: rgb(249, 250, 251);
|
||||
--dsw-static-neutral-bluish-55: rgb(245, 247, 250);
|
||||
--dsw-static-neutral-bluish-600: rgb(129, 133, 140);
|
||||
--dsw-static-neutral-bluish-60: rgb(249, 250, 251);
|
||||
--dsw-static-neutral-bluish-700: rgb(97, 102, 107);
|
||||
@@ -152,6 +157,7 @@ body[data-ds-dark-theme] {
|
||||
--dsw-static-neutral-bluish-850: rgb(44, 44, 46);
|
||||
--dsw-static-neutral-bluish-875: rgb(35, 35, 36);
|
||||
--dsw-static-neutral-bluish-900: rgb(27, 27, 28);
|
||||
--dsw-static-neutral-bluish-90: rgb(237, 240, 244);
|
||||
--dsw-static-neutral-bluish-950: rgb(21, 21, 23);
|
||||
--dsw-static-red-100: rgb(254, 226, 226);
|
||||
--dsw-static-red-400: rgb(242, 90, 90);
|
||||
@@ -206,6 +212,12 @@ body {
|
||||
--dsw-alias-button-tool-bar-fill-invisible: rgba(31, 31, 31, 0.36);
|
||||
--dsw-alias-button-tool-bar-fill: rgba(84, 85, 87, 0.5);
|
||||
--dsw-alias-button-tool-bar-hover: rgba(84, 85, 87, 0.6);
|
||||
--dsw-alias-illustration-background: var(--dsw-static-neutral-bluish-55);
|
||||
--dsw-alias-illustration-backing: var(--dsw-static-neutral-bluish-310);
|
||||
--dsw-alias-illustration-detail: var(--dsw-static-neutral-bluish-310);
|
||||
--dsw-alias-illustration-inset: var(--dsw-alias-illustration-surface);
|
||||
--dsw-alias-illustration-shadow: var(--dsw-alias-illustration-surface);
|
||||
--dsw-alias-illustration-surface: var(--dsw-static-neutral-bluish-90);
|
||||
--dsw-alias-interactive-bg-active: rgba(38, 49, 72, 0.1);
|
||||
--dsw-alias-interactive-bg-hover-accent: rgba(38, 49, 72, 0.14);
|
||||
--dsw-alias-interactive-bg-hover-danger: rgba(236, 19, 19, 0.05);
|
||||
@@ -324,6 +336,12 @@ body[data-ds-dark-theme] {
|
||||
--dsw-alias-button-tool-bar-fill-invisible: rgba(31, 31, 31, 0.36);
|
||||
--dsw-alias-button-tool-bar-fill: rgba(84, 85, 87, 0.5);
|
||||
--dsw-alias-button-tool-bar-hover: rgba(84, 85, 87, 0.6);
|
||||
--dsw-alias-illustration-background: var(--dsw-static-neutral-bluish-850);
|
||||
--dsw-alias-illustration-backing: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-alias-illustration-detail: var(--dsw-static-neutral-bluish-600);
|
||||
--dsw-alias-illustration-inset: var(--dsw-alias-illustration-backing);
|
||||
--dsw-alias-illustration-shadow: var(--dsw-alias-illustration-backing);
|
||||
--dsw-alias-illustration-surface: var(--dsw-static-neutral-bluish-700);
|
||||
--dsw-alias-interactive-bg-active: rgba(255, 255, 255, 0.14);
|
||||
--dsw-alias-interactive-bg-hover-accent: rgba(255, 255, 255, 0.24);
|
||||
--dsw-alias-interactive-bg-hover-danger: rgba(242, 90, 90, 0.15);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/** Decorative artwork resolves theme tokens to distinct foreground, backing, and detail colors. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { parseRules, varReferences } from './stylesheet-scan.ts'
|
||||
|
||||
const stylesheet = readFileSync(resolve(import.meta.dirname, '../src/styles/design-platform.css'), 'utf8')
|
||||
const illustration = readFileSync(resolve(import.meta.dirname, '../../ui-schedule/src/client/ScheduleEmptyIllustration.tsx'), 'utf8')
|
||||
const rules = parseRules(stylesheet)
|
||||
const PREFIX = '--dsw-alias-illustration-'
|
||||
const ROLES = ['background', 'backing', 'detail', 'inset', 'shadow', 'surface'] as const
|
||||
|
||||
type Rgb = [number, number, number]
|
||||
|
||||
function definitions(dark: boolean): Map<string, string> {
|
||||
const selector = dark ? 'body[data-ds-dark-theme]' : 'body'
|
||||
return new Map(rules.filter(rule => rule.selectors.includes(selector)).flatMap(rule => rule.declarations))
|
||||
}
|
||||
|
||||
function resolveColor(palette: Map<string, string>, name: string): Rgb {
|
||||
const seen = new Set<string>()
|
||||
let current = name
|
||||
while (palette.has(current) && !seen.has(current)) {
|
||||
seen.add(current)
|
||||
const value = palette.get(current)!
|
||||
const [reference] = varReferences(value)
|
||||
if (reference !== undefined) {
|
||||
expect(value, current).toMatch(/^var\(\s*--[\w-]+\s*\)$/)
|
||||
current = reference
|
||||
continue
|
||||
}
|
||||
const rgb = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(value)
|
||||
expect(rgb, `${name}: ${current} = ${value}`).not.toBeNull()
|
||||
return [Number(rgb![1]), Number(rgb![2]), Number(rgb![3])]
|
||||
}
|
||||
throw new Error(`Unresolved color ${name}: ${current}`)
|
||||
}
|
||||
|
||||
function illustrationColors(palette: Map<string, string>) {
|
||||
return Object.fromEntries(ROLES.map(role => [role, resolveColor(palette, `${PREFIX}${role}`)]))
|
||||
}
|
||||
|
||||
function luminance(rgb: Rgb): number {
|
||||
const linear = rgb.map((channel) => {
|
||||
const value = channel / 255
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return linear[0]! * 0.2126 + linear[1]! * 0.7152 + linear[2]! * 0.0722
|
||||
}
|
||||
|
||||
function expectDarkLayers(palette: Map<string, string>): void {
|
||||
const color = (role: string): Rgb => resolveColor(palette, `${PREFIX}${role}`)
|
||||
expect(luminance(color('surface'))).toBeGreaterThan(luminance(color('backing')))
|
||||
expect(luminance(color('backing'))).toBeGreaterThan(luminance(color('background')))
|
||||
for (const role of ['surface', 'backing', 'background']) expect(color('detail')).not.toEqual(color(role))
|
||||
expect(color('inset')).toEqual(color('backing'))
|
||||
expect(color('shadow')).toEqual(color('backing'))
|
||||
}
|
||||
|
||||
function expectConsumerColors(source: string, palette: Map<string, string>): void {
|
||||
const consumed = new Set(varReferences(source))
|
||||
const defined = [...palette.keys()].filter(name => name.startsWith(PREFIX)).sort()
|
||||
expect([...consumed].sort()).toEqual(defined)
|
||||
expect(consumed.size).toBe(ROLES.length)
|
||||
for (const name of consumed) resolveColor(palette, name)
|
||||
}
|
||||
|
||||
const lightColors = {
|
||||
background: [245, 247, 250],
|
||||
backing: [202, 210, 219],
|
||||
detail: [202, 210, 219],
|
||||
inset: [237, 240, 244],
|
||||
shadow: [237, 240, 244],
|
||||
surface: [237, 240, 244],
|
||||
}
|
||||
|
||||
it('preserves the original light artwork colors, including surface-colored insets and shadows', () => {
|
||||
expect(illustrationColors(definitions(false))).toEqual(lightColors)
|
||||
})
|
||||
|
||||
it.each([false, true])('defines the approved light static steps in the dark=%s palette', (dark) => {
|
||||
const palette = definitions(dark)
|
||||
expect(resolveColor(palette, '--dsw-static-neutral-bluish-55')).toEqual([245, 247, 250])
|
||||
expect(resolveColor(palette, '--dsw-static-neutral-bluish-90')).toEqual([237, 240, 244])
|
||||
expect(resolveColor(palette, '--dsw-static-neutral-bluish-310')).toEqual([202, 210, 219])
|
||||
})
|
||||
|
||||
it('resolves the dark artwork colors through the actual static palette', () => {
|
||||
expect(illustrationColors(definitions(true))).toEqual({
|
||||
background: [44, 44, 46],
|
||||
backing: [67, 69, 74],
|
||||
detail: [129, 133, 140],
|
||||
inset: [67, 69, 74],
|
||||
shadow: [67, 69, 74],
|
||||
surface: [97, 102, 107],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps dark surfaces brighter than backing and background, with distinct details and backing-colored insets and shadows', () => {
|
||||
expectDarkLayers(definitions(true))
|
||||
})
|
||||
|
||||
it.each([false, true])('resolves the illustration consumer names in the dark=%s palette', (dark) => {
|
||||
expectConsumerColors(illustration, definitions(dark))
|
||||
})
|
||||
|
||||
it('rejects light static-color drift even when alias names stay unchanged', () => {
|
||||
const palette = definitions(false)
|
||||
palette.set('--dsw-static-neutral-bluish-90', 'rgb(235, 238, 242)')
|
||||
expect(() => { expect(illustrationColors(palette)).toEqual(lightColors) }).toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['surface', 'backing'],
|
||||
['backing', 'surface'],
|
||||
['background', 'backing'],
|
||||
['detail', 'surface'],
|
||||
['inset', 'surface'],
|
||||
['shadow', 'surface'],
|
||||
])('rejects a dark %s role changed to %s', (role, target) => {
|
||||
const palette = definitions(true)
|
||||
palette.set(`${PREFIX}${role}`, `var(${PREFIX}${target})`)
|
||||
expect(() => { expectDarkLayers(palette) }).toThrow()
|
||||
})
|
||||
|
||||
it.each([false, true])('rejects renamed consumers and missing definitions in the dark=%s palette', (dark) => {
|
||||
const palette = definitions(dark)
|
||||
const renamed = illustration.replaceAll(`${PREFIX}inset`, `${PREFIX}clock-face`)
|
||||
expect(() => { expectConsumerColors(renamed, palette) }).toThrow()
|
||||
const misspelled = illustration.replace(PREFIX, '--dsw-alias-illustation-')
|
||||
expect(() => { expectConsumerColors(misspelled, palette) }).toThrow()
|
||||
palette.delete(`${PREFIX}inset`)
|
||||
expect(() => { expectConsumerColors(illustration, palette) }).toThrow()
|
||||
})
|
||||
|
||||
it.each(['--dsw-static-missing', `${PREFIX}inset`])('rejects an unresolved or cyclic inset reference to %s', (target) => {
|
||||
const palette = definitions(true)
|
||||
palette.set(`${PREFIX}inset`, `var(${target})`)
|
||||
expect(() => resolveColor(palette, `${PREFIX}inset`)).toThrow('Unresolved color')
|
||||
})
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 1b2209bbbf869b1a
|
||||
zh: 5b9de62271343e4e
|
||||
/deepseek-ai-dsh-experimental-badge-skill-bundle/known-limitations-and-deferred-work:
|
||||
en: 6c6e181ad900ece5
|
||||
zh: 4d6063075f1ee8a6
|
||||
en: b27c0eb9ccdcaa0d
|
||||
zh: 7d51470bec70861a
|
||||
/deepseek-ai-dsh-experimental-badge-skill-bundle/known-limitations-and-deferred-work/dev-note:
|
||||
en: 279d01435256cd2a
|
||||
zh: 83780964e5de2f54
|
||||
@@ -64,6 +64,7 @@ The layer adds no request content directly; the capability implementation owns c
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- A preset must include skill discovery and loading to use the badge. The minimal preset does not gain skill tools from this bundle.
|
||||
- The feature preview in [`docs/`](docs/DETAIL.md) has one image per language and no dark-theme variant, so it keeps its light background in dark theme.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ kind: "package-bundle"
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 预设必须支持技能发现和加载才能使用徽章。此 bundle 不会为 minimal 预设添加技能工具。
|
||||
- [`docs/`](docs/DETAIL.md) 中的功能配图每种语言只有一张,没有深色主题版本,因此在深色主题中仍为浅色背景。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||

|
||||
@@ -0,0 +1 @@
|
||||

|
||||
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "DSH badge skill",
|
||||
"description": "Let agents add a “powered by dsh” badge to documents, pull requests, and other outputs."
|
||||
"description": "Let agents add a “powered by dsh” badge to documents, pull requests, and other outputs.",
|
||||
"detail": "docs/DETAIL.md"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "DSH 徽章技能",
|
||||
"description": "让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。"
|
||||
"description": "让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。",
|
||||
"detail": "docs/DETAIL.zh.md"
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
"files": [
|
||||
"icon.svg",
|
||||
"locale/*.json",
|
||||
"docs",
|
||||
"lib/index.js",
|
||||
"cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||

|
||||
@@ -0,0 +1 @@
|
||||

|
||||
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Voice input",
|
||||
"description": "Transcribe recordings locally with SenseVoice; first use requires installing dependencies"
|
||||
"description": "Transcribe recordings locally with SenseVoice; first use requires installing dependencies",
|
||||
"detail": "docs/DETAIL.md"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "语音输入",
|
||||
"description": "在本机使用 SenseVoice 转写录音,首次使用需安装依赖。"
|
||||
"description": "在本机使用 SenseVoice 转写录音,首次使用需安装依赖。",
|
||||
"detail": "docs/DETAIL.zh.md"
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
"files": [
|
||||
"icon.svg",
|
||||
"locale/*.json",
|
||||
"docs",
|
||||
"lib/index.js",
|
||||
"cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
|
||||
@@ -37,17 +37,22 @@ async function sessionEvents(cwd: string) {
|
||||
}
|
||||
|
||||
it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each([{ format: 'docx', mode: 'native' }, { format: 'pdf', mode: 'native' }, { format: 'docx', mode: 'ptc' }])('loads the operation skill and renders $format through the shell in $mode mode', async ({ format, mode }) => {
|
||||
let original: Buffer | undefined
|
||||
await runLoaderSmoke({
|
||||
label: 'Office CLI rendering', tempDirPrefix: 'dsh-office-render-',
|
||||
...launch, env: { ...launch.env, DSH_TOOLS_MODE: mode },
|
||||
binArgs: ['--profile', 'headless', '--patch', 'office.patch.json',
|
||||
`Export page 1 of input.${format} as PNG into preview and check that the exported image is readable. Report briefly in English.`],
|
||||
// Source-content verification can require an authoring skill; this case covers image-only inspection.
|
||||
`Export page 1 of input.${format} as PNG into preview. Check only that the exported image is readable; `
|
||||
+ 'do not inspect the source document content or metadata, and keep the source file unchanged. Report briefly in English.'],
|
||||
async prepare(cwd) {
|
||||
await execa(python!, ['-c', "from docx import Document\nd=Document()\nd.add_heading('Quarterly revenue',0)\nd.add_paragraph('Q1: 12; Q2: 18; Total: 30')\nd.save('input.docx')"], { cwd })
|
||||
if (format === 'pdf') await execa(python!, ['-c', "from PIL import Image, ImageDraw\nim=Image.new('RGB',(300,200),'white')\nImageDraw.Draw(im).rectangle((30,30,150,100),fill='black')\nim.save('input.pdf','PDF')"], { cwd })
|
||||
await prepareOffice(cwd)
|
||||
original = await readFile(join(cwd, `input.${format}`))
|
||||
},
|
||||
async inspect(cwd) {
|
||||
expect(await readFile(join(cwd, `input.${format}`))).toEqual(original)
|
||||
const events = await sessionEvents(cwd)
|
||||
const calls = events.flatMap((event) => {
|
||||
if (event.type === 'tool/call') return [event.data]
|
||||
@@ -63,17 +68,22 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each([{ format: 'docx', mod
|
||||
const pixels = await execa(python!, ['-c', "from pathlib import Path\nfrom PIL import Image\nfiles=list(Path('preview').glob('*.png'))\nassert files\nfor f in files:\n im=Image.open(f).convert('RGBA')\n visible=Image.alpha_composite(Image.new('RGBA',im.size,'white'),im).convert('RGB')\n print('content' if any(a!=b for a,b in visible.getextrema()) else 'blank')"], { cwd })
|
||||
expect(pixels.exitCode).toBe(0)
|
||||
if (format === 'pdf') expect(pixels.stdout.trim()).toBe('content')
|
||||
const final = events.filter(e => e.type === 'assistant/message').at(-1)?.data.message.content
|
||||
.filter(b => b.type === 'text').map(b => b.text).join(' ')
|
||||
const diagnostic = JSON.stringify({
|
||||
format, mode, callOrder: calls.map(call => call.name), skills, commands, pixels: pixels.stdout, final,
|
||||
}, null, 2)
|
||||
const authoring = calls.findIndex(call => call.name === 'skill' && /office-(docx|pptx|xlsx)/u.test(call.arguments))
|
||||
if (pixels.stdout.includes('blank')) {
|
||||
if (authoring !== -1) {
|
||||
const rendered = calls.findIndex(call => (call.name === 'bash' || call.name === 'pwsh') && /\srender\b/u.test(call.arguments))
|
||||
expect(authoring, 'content checks after a blank preview may need the format skill').toBeGreaterThan(rendered)
|
||||
}
|
||||
const final = events.filter(e => e.type === 'assistant/message').at(-1)?.data.message.content
|
||||
.filter(b => b.type === 'text').map(b => b.text).join(' ')
|
||||
expect(final).toMatch(/visual (?:inspection|check)[^.!?]{0,100}(?:not completed|incomplete|could not be completed|unverified)/iu)
|
||||
expect(final, diagnostic).toMatch(
|
||||
/visual (?:inspection|check)[^.!?]{0,100}(?:not completed|incomplete|could not be completed|unverified)/iu,
|
||||
)
|
||||
} else {
|
||||
expect(authoring, JSON.stringify(skills)).toBe(-1)
|
||||
expect(authoring, diagnostic).toBe(-1)
|
||||
}
|
||||
expect(calls.some(e => e.name === 'read_image')).toBe(true)
|
||||
},
|
||||
|
||||
@@ -23,9 +23,12 @@ export function languageChain(language: string): string[] {
|
||||
|
||||
/**
|
||||
* A locale `meta.detail` value: a `/`-separated Markdown path relative to the package root that cannot leave it.
|
||||
* @param value - the declared `meta.detail` value, or `undefined` when the locale file omits it.
|
||||
* @param label - the locale file path used in a failure message.
|
||||
* @returns the validated path, or `undefined` when the value is absent.
|
||||
* @throws {DetailFailure} `invalid` for another value.
|
||||
*/
|
||||
function detailPath(value: unknown, label: string): string | undefined {
|
||||
export function detailPath(value: unknown, label: string): string | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const invalid = (rule: string): DetailFailure => new DetailFailure('invalid', `${label} ${rule}`)
|
||||
if (typeof value !== 'string' || value.trim() === '') throw invalid('must be a non-empty string')
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/** Built-in illustrations have one fixed-canvas SVG per required UI language. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import ts from 'typescript'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { OPTIONAL_BUNDLES } from '../packages/boot/app-boot/src/profile.ts'
|
||||
|
||||
const pagePath = resolve(import.meta.dirname, '../packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx')
|
||||
|
||||
it('keeps every registered preview a single self-contained 640×300 SVG with English and Chinese variants', () => {
|
||||
const page = ts.createSourceFile(pagePath, readFileSync(pagePath, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
const imports = new Map<string, string>()
|
||||
for (const statement of page.statements) {
|
||||
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && statement.importClause?.name) {
|
||||
imports.set(statement.importClause.name.text, statement.moduleSpecifier.text)
|
||||
}
|
||||
}
|
||||
const declaration = page.statements.filter(ts.isVariableStatement).flatMap(statement => [...statement.declarationList.declarations])
|
||||
.find(item => ts.isIdentifier(item.name) && item.name.text === 'BUNDLE_PREVIEWS')
|
||||
const initializer = declaration?.initializer
|
||||
if (initializer === undefined || !ts.isNewExpression(initializer)) throw new Error('Built-in preview map is missing')
|
||||
const entries = initializer.arguments?.[0]
|
||||
if (entries === undefined || !ts.isArrayLiteralExpression(entries)) throw new Error('Preview map must list its internal entries')
|
||||
expect(entries.elements.length).toBeGreaterThan(0)
|
||||
const packages = new Set<string>()
|
||||
const browser = new JSDOM('')
|
||||
onTestFinished(() => { browser.window.close() })
|
||||
const parser = new browser.window.DOMParser()
|
||||
for (const entry of entries.elements) {
|
||||
if (!ts.isArrayLiteralExpression(entry) || entry.elements.length !== 2) throw new Error('One preview pair per bundle is required')
|
||||
const [name, pair] = entry.elements
|
||||
if (name === undefined || !ts.isStringLiteral(name) || pair === undefined || !ts.isObjectLiteralExpression(pair)) {
|
||||
throw new Error('Each built-in entry must name a bundle and its language pair')
|
||||
}
|
||||
expect(packages.has(name.text), name.text).toBe(false)
|
||||
expect(OPTIONAL_BUNDLES).toContain(name.text)
|
||||
packages.add(name.text)
|
||||
expect(pair.properties.map(property => property.name?.getText(page)).sort(), name.text).toEqual(['en', 'zh'])
|
||||
let bytes = 0
|
||||
const sources: string[] = []
|
||||
for (const property of pair.properties) {
|
||||
if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.initializer)) throw new Error('Preview values must be SVG imports')
|
||||
const path = imports.get(property.initializer.text)
|
||||
expect(path).toMatch(/^\.\/assets\/[^/]+-preview(?:\.zh)?\.svg$/u)
|
||||
if (path === undefined) throw new Error('Preview import is missing')
|
||||
expect(path.endsWith('.zh.svg')).toBe(property.name.getText(page) === 'zh')
|
||||
const svg = readFileSync(resolve(dirname(pagePath), path), 'utf8')
|
||||
bytes += Buffer.byteLength(svg)
|
||||
sources.push(svg)
|
||||
const document = parser.parseFromString(svg, 'image/svg+xml')
|
||||
expect(document.querySelector('parsererror'), path).toBeNull()
|
||||
expect(document.documentElement.namespaceURI, path).toBe('http://www.w3.org/2000/svg')
|
||||
expect(document.documentElement.localName, path).toBe('svg')
|
||||
expect(document.documentElement.getAttribute('width'), path).toBe('640')
|
||||
expect(document.documentElement.getAttribute('height'), path).toBe('300')
|
||||
expect(document.documentElement.getAttribute('viewBox'), path).toBe('0 0 640 300')
|
||||
expect(document.querySelector('path, rect, circle, ellipse, line, polyline, polygon'), path).not.toBeNull()
|
||||
expect(document.querySelector('script, style, foreignObject, text, animate, animateTransform, animateMotion, set'), path).toBeNull()
|
||||
const ids = new Set([...document.querySelectorAll('[id]')].map(element => element.id))
|
||||
for (const element of [document.documentElement, ...document.querySelectorAll('*')]) {
|
||||
for (const attribute of element.attributes) {
|
||||
expect(attribute.name, path).not.toMatch(/^on/iu)
|
||||
const references = [...attribute.value.matchAll(/url\(([^)]+)\)/gu)].map(match => (match[1] ?? '').replace(/['"]/gu, '').trim())
|
||||
if (attribute.localName === 'href') references.push(attribute.value)
|
||||
for (const reference of references) {
|
||||
if (element.localName === 'image' && attribute.localName === 'href'
|
||||
&& /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z\d+/]+=*$/u.test(reference)) continue
|
||||
expect(reference.startsWith('#') && ids.has(reference.slice(1)), path).toBe(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(bytes, name.text).toBeLessThanOrEqual(1024 * 1024)
|
||||
expect(sources[0], name.text).not.toBe(sources[1])
|
||||
}
|
||||
})
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
checkExperimentalDependencyIsolation,
|
||||
checkExperimentalManifest,
|
||||
checkExperimentalNameExceptions,
|
||||
detailDocumentFiles,
|
||||
expectedDshPackageFiles,
|
||||
readWorkspaceManifests,
|
||||
type WorkspaceManifest,
|
||||
@@ -327,6 +328,54 @@ describe('package payload constraints', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('publishes the directory of each locale detail document after the locale resources', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-detail-files-'))
|
||||
onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) })
|
||||
mkdirSync(join(dir, 'docs/guide'), { recursive: true })
|
||||
mkdirSync(join(dir, 'locale'))
|
||||
writeFileSync(join(dir, 'docs/DETAIL.md'), '')
|
||||
writeFileSync(join(dir, 'docs/guide/DETAIL.zh.md'), '')
|
||||
writeFileSync(join(dir, 'docs/DETAIL.fr.md'), '')
|
||||
writeFileSync(join(dir, 'locale/en.json'), JSON.stringify({ meta: { title: 'T', detail: 'docs/DETAIL.md' } }))
|
||||
writeFileSync(join(dir, 'locale/zh.json'), JSON.stringify({ meta: { title: 'T', detail: 'docs/guide/DETAIL.zh.md' } }))
|
||||
writeFileSync(join(dir, 'locale/fr.json'), JSON.stringify({ meta: { title: 'T', detail: 'docs/DETAIL.fr.md' } }))
|
||||
writeFileSync(join(dir, 'locale/de.json'), JSON.stringify({ meta: { title: 'T' } }))
|
||||
writeFileSync(join(dir, 'locale/ja.json'), '{')
|
||||
const detailFiles = detailDocumentFiles(dir)
|
||||
expect(detailFiles).toEqual(['docs'])
|
||||
expect(expectedDshPackageFiles({ exports: { './locale/*.json': './locale/*.json' } }, detailFiles)).toEqual([
|
||||
'locale/*.json', 'docs', 'lib/index.js', 'lib/types/**/*.d.ts',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a detail document that is missing, outside the package, or at the package root', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-detail-invalid-'))
|
||||
onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) })
|
||||
mkdirSync(join(dir, 'locale'))
|
||||
const declare = (detail: unknown): void => {
|
||||
writeFileSync(join(dir, 'locale/en.json'), JSON.stringify({ meta: { detail } }))
|
||||
}
|
||||
declare('docs/DETAIL.md')
|
||||
expect(() => detailDocumentFiles(dir)).toThrow(/DETAIL\.md.*does not exist/)
|
||||
mkdirSync(join(dir, 'docs'))
|
||||
writeFileSync(join(dir, 'docs/DETAIL.md'), '')
|
||||
expect(detailDocumentFiles(dir)).toEqual(['docs'])
|
||||
declare('DETAIL.md')
|
||||
expect(() => detailDocumentFiles(dir)).toThrow(/must name a file in a subdirectory/)
|
||||
declare('../DETAIL.md')
|
||||
expect(() => detailDocumentFiles(dir)).toThrow(/must be a relative path inside the package/)
|
||||
writeFileSync(join(dir, 'locale/en.json'), 'null')
|
||||
expect(detailDocumentFiles(dir)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a bundle whose locale detail document is missing from its publication files', () => {
|
||||
const dir = 'packages/experimental/badge-skill-bundle'
|
||||
const manifest = JSON.parse(readFileSync(join(import.meta.dirname, '..', dir, 'package.json'), 'utf8')) as WorkspaceManifest['manifest']
|
||||
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
|
||||
const files = (manifest.files ?? []).filter(file => file !== 'docs')
|
||||
expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files } }).join('\n')).toContain('"docs"')
|
||||
})
|
||||
|
||||
it('does not infer locale payloads from unrelated or non-JSON exports', () => {
|
||||
expect(expectedDshPackageFiles({
|
||||
exports: {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './experimental-package-policy.ts'
|
||||
import { hasTypertRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts'
|
||||
import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts'
|
||||
import { detailPath } from '../packages/util/plugin-detail/src/document.ts'
|
||||
import { OPTIONAL_BUNDLES, bundlePatchFiles } from '../packages/boot/app-boot/src/profile.ts'
|
||||
import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts'
|
||||
|
||||
@@ -254,11 +255,42 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute canonical publication patterns, including declared and exported icon paths and exported locale JSON resources.
|
||||
* @param manifest - workspace package manifest.
|
||||
* @returns the icon and deduplicated locale targets followed by runtime and declaration payloads.
|
||||
* List the publication entries that locale `meta.detail` documents need: the top-level directory holding
|
||||
* each document and its images.
|
||||
* @param packageDir - absolute package directory.
|
||||
* @returns sorted, deduplicated entries.
|
||||
* @throws Error when a declared document is not a package-relative Markdown file in a subdirectory or does not exist,
|
||||
* so a typo cannot publish a directory that holds no document.
|
||||
*/
|
||||
export function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
|
||||
export function detailDocumentFiles(packageDir: string): readonly string[] {
|
||||
const entries = new Set<string>()
|
||||
for (const file of globSync('locale/*.json', { cwd: packageDir })) {
|
||||
let meta: unknown
|
||||
try {
|
||||
meta = (JSON.parse(readFileSync(join(packageDir, file), 'utf8')) as { meta?: unknown } | null)?.meta
|
||||
} catch (error: unknown) {
|
||||
// SyntaxError: verify-package-meta reports malformed locale files; this check only adds publication entries.
|
||||
void error
|
||||
continue
|
||||
}
|
||||
const label = `${join(packageDir, file)}: meta.detail`
|
||||
const detail = detailPath(typeof meta === 'object' && meta !== null ? (meta as { detail?: unknown }).detail : undefined, label)
|
||||
if (detail === undefined) continue
|
||||
const slash = detail.indexOf('/')
|
||||
if (slash < 0) throw new Error(`${label} ${JSON.stringify(detail)} must name a file in a subdirectory such as docs/`)
|
||||
if (!existsSync(join(packageDir, detail))) throw new Error(`${label} ${JSON.stringify(detail)} does not exist`)
|
||||
entries.add(detail.slice(0, slash))
|
||||
}
|
||||
return [...entries].sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute canonical publication patterns, including declared and exported icon paths, exported locale JSON resources, and detail documents.
|
||||
* @param manifest - workspace package manifest.
|
||||
* @param detailFiles - entries from {@link detailDocumentFiles}.
|
||||
* @returns the icon, deduplicated locale targets, and detail documents followed by runtime and declaration payloads.
|
||||
*/
|
||||
export function expectedDshPackageFiles(manifest: PackageManifest, detailFiles: readonly string[] = []): readonly string[] {
|
||||
const localeFiles = new Set<string>()
|
||||
for (const resource of Object.keys(manifest.exports ?? {})) {
|
||||
if (!/^\.\/(?:.+\/)?locale\/[^/]+\.json$/u.test(resource)) continue
|
||||
@@ -282,6 +314,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str
|
||||
return [
|
||||
...new Set(icons),
|
||||
...[...localeFiles].sort(),
|
||||
...detailFiles,
|
||||
'lib/index.js',
|
||||
...manifest.bin ? ['lib/bin.js'] : [],
|
||||
// Worker-thread packages ship a CJS worker entry; the browser worker
|
||||
@@ -537,7 +570,7 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st
|
||||
if (rootEntry?.default !== './lib/index.js') {
|
||||
errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`)
|
||||
}
|
||||
const expectedFiles = expectedDshPackageFiles(manifest)
|
||||
const expectedFiles = expectedDshPackageFiles(manifest, detailDocumentFiles(join(root, dir)))
|
||||
if (!sameStringList(manifest.files, expectedFiles)) {
|
||||
errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/** Plugin feature previews are one fixed-canvas SVG per required UI language, beside their detail document. */
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { basename, dirname, extname, join } from 'node:path'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
|
||||
const root = `${import.meta.dirname}/..`
|
||||
const svgPaths = globSync('packages/*/*/docs/*.svg', { cwd: root }).sort()
|
||||
/** Combined size of one language pair, as the standalone bundle and Host routes both serve the files individually. */
|
||||
const MAX_PAIR_BYTES = 1024 * 1024
|
||||
|
||||
it('keeps every preview a self-contained 640×300 SVG with English and Chinese variants', () => {
|
||||
// Guard against a narrowed corpus: each preview moved into a bundle's docs directory stays checked.
|
||||
expect(svgPaths.length).toBeGreaterThan(0)
|
||||
const browser = new JSDOM('')
|
||||
onTestFinished(() => { browser.window.close() })
|
||||
const parser = new browser.window.DOMParser()
|
||||
const pairs = new Map<string, { en?: string; zh?: string }>()
|
||||
for (const path of svgPaths) {
|
||||
const name = basename(path, '.svg')
|
||||
const chinese = name.endsWith('.zh')
|
||||
const stem = join(dirname(path), chinese ? name.slice(0, -3) : name)
|
||||
const pair = pairs.get(stem) ?? {}
|
||||
if (chinese) pair.zh = path
|
||||
else pair.en = path
|
||||
pairs.set(stem, pair)
|
||||
}
|
||||
for (const [stem, pair] of pairs) {
|
||||
expect(pair, stem).toEqual({ en: `${stem}.svg`, zh: `${stem}.zh.svg` })
|
||||
}
|
||||
for (const [, pair] of pairs) {
|
||||
const sources: string[] = []
|
||||
let bytes = 0
|
||||
for (const path of [pair.en, pair.zh]) {
|
||||
if (path === undefined) throw new Error('Preview pair is incomplete')
|
||||
const svg = readFileSync(join(root, path), 'utf8')
|
||||
sources.push(svg)
|
||||
bytes += Buffer.byteLength(svg)
|
||||
const document = parser.parseFromString(svg, 'image/svg+xml')
|
||||
expect(document.querySelector('parsererror'), path).toBeNull()
|
||||
expect(document.documentElement.namespaceURI, path).toBe('http://www.w3.org/2000/svg')
|
||||
expect(document.documentElement.localName, path).toBe('svg')
|
||||
expect(document.documentElement.getAttribute('width'), path).toBe('640')
|
||||
expect(document.documentElement.getAttribute('height'), path).toBe('300')
|
||||
expect(document.documentElement.getAttribute('viewBox'), path).toBe('0 0 640 300')
|
||||
expect(document.querySelector('path, rect, circle, ellipse, line, polyline, polygon'), path).not.toBeNull()
|
||||
expect(document.querySelector('script, style, foreignObject, text, animate, animateTransform, animateMotion, set'), path).toBeNull()
|
||||
const ids = new Set([...document.querySelectorAll('[id]')].map(element => element.id))
|
||||
for (const element of [document.documentElement, ...document.querySelectorAll('*')]) {
|
||||
for (const attribute of element.attributes) {
|
||||
expect(attribute.name, path).not.toMatch(/^on/iu)
|
||||
const references = [...attribute.value.matchAll(/url\(([^)]+)\)/gu)].map(match => (match[1] ?? '').replace(/['"]/gu, '').trim())
|
||||
if (attribute.localName === 'href') references.push(attribute.value)
|
||||
for (const reference of references) {
|
||||
if (element.localName === 'image' && attribute.localName === 'href'
|
||||
&& /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z\d+/]+=*$/u.test(reference)) continue
|
||||
expect(reference.startsWith('#') && ids.has(reference.slice(1)), `${path}: ${reference}`).toBe(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(bytes, pair.en).toBeLessThanOrEqual(MAX_PAIR_BYTES)
|
||||
// The Chinese variant carries translated content; identical files would mean a forgotten translation.
|
||||
expect(sources[0], pair.en).not.toBe(sources[1])
|
||||
expect(extname(pair.en ?? ''), String(pair.en)).toBe('.svg')
|
||||
}
|
||||
})
|
||||
@@ -395,6 +395,7 @@ describe('translation scope discovery', () => {
|
||||
|
||||
it.each([
|
||||
'packages/example/guide.md',
|
||||
'packages/example/docs/DETAIL.md',
|
||||
'packages/example/CONTRIBUTING.md',
|
||||
'packages/example/BRAND_GUIDELINES.md',
|
||||
'other/tutorial.md',
|
||||
|
||||
@@ -45,11 +45,12 @@ describe('Markdown source discovery', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('checks package instructions and nested docs while excluding frozen notes', () => {
|
||||
it('checks package instructions, nested docs, and detail documents while excluding frozen notes', () => {
|
||||
const root = layout({
|
||||
'packages/AGENTS.md': '[missing](missing.md)\n',
|
||||
'packages/core/README.md': '[missing](missing.md)\n',
|
||||
'packages/core/probe/README.md': '[missing](missing.md)\n',
|
||||
'packages/core/probe/docs/DETAIL.md': '\n',
|
||||
'.agents/notes/archived/process/frozen.md': '[historical](missing.md)\n',
|
||||
})
|
||||
const sources = markdownLinkSourcePaths(root)
|
||||
@@ -58,6 +59,7 @@ describe('Markdown source discovery', () => {
|
||||
join('packages', 'AGENTS.md'),
|
||||
join('packages', 'core', 'README.md'),
|
||||
join('packages', 'core', 'probe', 'README.md'),
|
||||
join('packages', 'core', 'probe', 'docs', 'DETAIL.md'),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,8 @@ const PATTERNS = [
|
||||
'packages/*.md',
|
||||
'packages/*/*.md',
|
||||
'packages/*/*/*.md',
|
||||
// Plugin detail documents that locale `meta.detail` names.
|
||||
'packages/*/*/docs/*.md',
|
||||
'AGENTS.md',
|
||||
'.agents/skills/**/*.md',
|
||||
]
|
||||
|
||||
@@ -22,6 +22,7 @@ const PATTERNS = [
|
||||
'docs/**/*.md',
|
||||
'packages/*/*.md',
|
||||
'packages/*/*/*.md',
|
||||
'packages/*/*/docs/*.md',
|
||||
'snapshots/**/system-prompt.expected.md',
|
||||
'packages/**/system-prompt.expected.md',
|
||||
'AGENTS.md',
|
||||
|
||||
Reference in new issue
Block a user