Просмотр исходного кода

Merge pull request #3710 from deepseek-harness/feat/mermaid-preview

feat(web): preview Mermaid, Graphviz, SVG, and HTML code fences
Yudong Han 3 недель назад
Родитель
Сommit
83253e0d16
43 измененных файлов с 2346 добавлено и 20 удалено
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml
  2. 25 0
      .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md
  3. 25 0
      .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml
  5. 43 0
      .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md
  6. 43 0
      .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md
  7. 5 1
      THIRD_PARTY_NOTICES.md
  8. 24 0
      apps/web/tests/expected/markdown-mermaid/ui.expected.md
  9. 23 0
      apps/web/tests/expected/markdown-mermaid/zh.expected.md
  10. 320 0
      apps/web/tests/markdown-mermaid.e2e.ts
  11. 1 0
      apps/web/tsconfig.json
  12. 15 1
      apps/web/vite.config.ts
  13. 10 0
      packages/client/locale/src/locales/en.ts
  14. 10 0
      packages/client/locale/src/locales/zh.ts
  15. 2 2
      packages/client/ui-chat/README.i18n.yaml
  16. 8 0
      packages/client/ui-chat/README.md
  17. 8 0
      packages/client/ui-chat/README.zh.md
  18. 13 0
      packages/client/ui-chat/src/client/markdown-labels.ts
  19. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  20. 10 0
      packages/client/ui-primitives/README.md
  21. 10 0
      packages/client/ui-primitives/README.zh.md
  22. 688 0
      packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt
  23. 5 1
      packages/client/ui-primitives/package.json
  24. 84 0
      packages/client/ui-primitives/src/markdown/CodeBlock.module.css
  25. 42 11
      packages/client/ui-primitives/src/markdown/CodeBlock.tsx
  26. 35 0
      packages/client/ui-primitives/src/markdown/SourcePreview.module.css
  27. 71 0
      packages/client/ui-primitives/src/markdown/SourcePreview.tsx
  28. 37 0
      packages/client/ui-primitives/src/markdown/graphviz.ts
  29. 97 0
      packages/client/ui-primitives/src/markdown/mermaid.ts
  30. 44 0
      packages/client/ui-primitives/src/markdown/preview-document.ts
  31. 27 0
      packages/client/ui-primitives/src/markdown/preview-theme.ts
  32. 30 0
      packages/client/ui-primitives/src/markdown/render.tsx
  33. 1 1
      packages/client/ui-primitives/tests/code-block.client.spec.tsx
  34. 126 0
      packages/client/ui-primitives/tests/fence-previews.client.spec.tsx
  35. 67 0
      packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts
  36. 133 0
      packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts
  37. 162 0
      packages/client/ui-primitives/tests/source-preview.client.spec.tsx
  38. 14 0
      pnpm-lock.yaml
  39. 14 0
      scripts/check-workspace-constraints.spec.ts
  40. 1 1
      scripts/check-workspace-constraints.ts
  41. 24 0
      scripts/gen-third-party-notices.spec.ts
  42. 34 0
      scripts/gen-third-party-notices.ts
  43. 1 0
      tsconfig.host.json

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md
+2026-09-09-content-sized-diagram-previews.md: ceb29238eec09f2dde312835dbf376855998a507
+2026-09-09-content-sized-diagram-previews.zh.md: 2689602bfe3469f53e532367877f7e49d52d3d5d

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md

@@ -0,0 +1,25 @@
+# Agent Note: Content-sized diagram previews
+
+Status: implemented
+
+English | [中文](2026-09-09-content-sized-diagram-previews.zh.md)
+
+## Problem
+
+Short Graphviz and SVG images leave large empty regions when displayed inside 400px frames. A 59px Graphviz diagram occupies a 400px preview even though its rendered dimensions are already available to the browser.
+
+## Decision
+
+Mermaid, Graphviz, and SVG share the inert image canvas in `SourcePreview`. The browser derives its height from the image, preserves aspect ratio when shrinking to the available width, and adds 16px padding. Source switching retains the image and copying reads the original code. Theme changes regenerate Graphviz colors without introducing a separate sizing observer.
+
+This replaces the diagram-frame choice in [Static Markdown fence previews](../feature/2026-09-09-markdown-static-previews.md); that note continues to own HTML isolation, rendering lifecycle, and license obligations. HTML retains its opaque, script-free, 400px frame. SVG image mode disables scripts, link interaction, and external resource loading without inserting source-controlled markup into the application document.
+
+## Alternatives considered
+
+**Set iframe height to auto.** An iframe does not derive its outer height from its inner document. This still reserves an unrelated viewport.
+
+**Add a frame measurement script or same-origin access.** A diagram already has image dimensions. Extra execution or origin permissions and resize messaging are unnecessary for that content.
+
+## Consequences
+
+Short diagrams occupy only their rendered height and canvas padding. The browser geometry regression compares image and container height in light and dark modes, then verifies proportional scaling in a narrow viewport. Browser security checks include SVG scripts, event handlers, nested HTML, and external images; copy and source toggling remain covered. HTML automatic height remains outside this change because arbitrary document layout has different measurement and isolation requirements.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 按内容定高的图表预览
+
+Status: implemented
+
+[English](2026-09-09-content-sized-diagram-previews.md) | 中文
+
+## Problem
+
+较矮的 Graphviz 与 SVG 图片显示在 400px iframe 中时,会留下大块空白。浏览器已经知道图片的渲染尺寸,但一张 59px 高的 Graphviz 图表仍占据 400px 的预览区域。
+
+## Decision
+
+Mermaid、Graphviz 与 SVG 共享 `SourcePreview` 的不可执行图片画布。浏览器根据图片决定高度,在可用宽度不足时按比例缩小,并添加 16px 内边距。源码切换保留图片,复制读取原始代码。主题变化重新生成 Graphviz 配色,无需单独的尺寸观察器。
+
+这替代了[静态 Markdown fence 预览](../feature/2026-09-09-markdown-static-previews.zh.md)中的图表 iframe 选择;该记录继续维护 HTML 隔离、渲染生命周期与许可证义务。HTML 保留不透明来源、禁用脚本的 400px iframe。SVG 图片模式禁用脚本、链接交互与外部资源加载,无需把源码控制的标记插入应用文档。
+
+## Alternatives considered
+
+**把 iframe 高度设为 auto。** iframe 不会根据内部文档决定外部高度,仍然会保留与内容无关的视口。
+
+**添加 iframe 测量脚本或同源访问。** 图表已经具备图片尺寸,不需要额外执行权限、来源权限或尺寸消息协议。
+
+## Consequences
+
+较矮图表只占据渲染高度与画布内边距。浏览器几何回归用例在浅色和深色下比较图片与容器高度,再验证窄视口中的等比例缩放。浏览器安全检查包含 SVG 脚本、事件处理器、嵌套 HTML 与外部图片,并保留复制和源码切换覆盖。HTML 自动高度不属于本次改动,因为任意文档布局具有不同的测量与隔离要求。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md
+2026-09-09-markdown-static-previews.md: f3a0363e3d7c4cb19ca67dfc5011e936f7c024cf
+2026-09-09-markdown-static-previews.zh.md: 24f87cd73504c1fbd274e1420a3692b3ca55ed5e

+ 43 - 0
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md

@@ -0,0 +1,43 @@
+# Agent Note: Static Markdown fence previews
+
+Status: implemented
+
+English | [中文](2026-09-09-markdown-static-previews.zh.md)
+
+## Problem
+
+Readers need to see Mermaid and DOT diagrams, SVG artwork, and HTML examples directly in Assistant replies without interpreting source or copying it to another renderer. These sources are untrusted, and a renderer's npm license field may omit compiled components with separate distribution obligations.
+
+## Decision
+
+The shared Markdown renderer enables settled `mermaid`, `graphviz`/`dot`, `svg`, and `html` fences through localized `MarkdownLabels.preview` and the parsed fence language. The [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `SourcePreview`, which accepts a renderer, source, and labels without Session, file, or Cordis dependencies. Mermaid and Graphviz each supply a `.ts` renderer to that same component. Consumers without preview labels and streaming messages retain code.
+
+`CodeBlock.preview` owns default visualization, source switching, and verbatim copying. Previews omit the language banner and expose compact icon actions on hover or keyboard focus; devices with any touch input keep actions visible below the diagram. Switching to source hides the mounted preview, preserving completed rendering and pending work. Both views use the same focused toggle button, and copying always reads the source prop.
+
+`SourcePreview` owns loading, failure, and cancellation of stale result publication. Source replacement and unmounting cancel publication; cancellation before runtime loading completes skips layout. Failures show a localized error and the original source, and replacing invalid source with valid input recovers the preview.
+
+Mermaid loads on demand. A shared queue serializes theme initialization with diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the application palette, and error-rendering policy cannot be overridden by diagram configuration. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas follows the code-block background. Mounted previews observe document theme attributes and regenerate only when resolved colors change; obsolete renders cannot publish. Graphviz default colors follow the same palette, while authored DOT, SVG, and HTML colors remain intact.
+
+HTML uses DOMPurify with navigation attributes and document-loading elements forbidden, followed by an opaque `sandbox=""` iframe. A trusted CSP precedes source markup and permits only inline CSS, data images, and data fonts. Scripts, external resources, child frames, form submission, and same-origin access are unavailable. SVG is parsed as XML and displayed with Graphviz output as inert images in a [content-sized canvas](../bug-fix/2026-09-09-content-sized-diagram-previews.md), so SVG scripts and links never become active. HTML frames have a fixed scrollable viewport: measuring their content would require additional origin access or trusted frame scripts.
+
+Graphviz uses the pinned, unmodified `@viz-js/viz` 3.30.0 WebAssembly distribution with the `dot` layout engine. Its npm MIT declaration covers the wrapper; its build provenance identifies Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 (MIT/NCSA). Distribution retains these component terms and the exact Graphviz source download under EPL-2.0 section 3.1. [Full preview notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) also retain Mermaid's MIT text and select Apache-2.0 for DOMPurify. The UI primitives package ships the notices and the Web build emits the same bytes alongside its assets. License review covers embedded payloads separately from the general permissive npm-metadata policy.
+
+## Alternatives considered
+
+**Keep the code banner above diagrams.** A language label and permanent text controls distract from the diagram. Overlay actions retain source access without reserving a title row.
+
+**Render each streamed chunk.** Incomplete diagrams are frequently invalid, and repeated layout work competes with text streaming. Message settlement provides complete source.
+
+**Put rendering inside Chat or add a general preview registry.** A shared primitive with plain props satisfies reuse without another registry or feature-plugin dependency. This follows the [shared-control rule](../architecture/2026-09-05-shared-client-control-primitives.md).
+
+**Execute HTML scripts in a frame.** Static chat examples do not need script execution. An empty sandbox plus sanitization and CSP gives the preview fewer capabilities and avoids introducing a frame messaging protocol.
+
+**Insert rendered markup into Chat.** The requested preview needs diagram display and source access; HTML styles and executable SVG would share the application document. Iframes isolate layout and origin; SVG image mode additionally disables SVG behavior.
+
+**Treat Viz.js as MIT-only or use a remote Graphviz service.** The compiled Graphviz license still applies locally; a remote renderer would send conversation content off-device. The bundled renderer retains source availability and legal notices without network rendering.
+
+## Consequences
+
+The feature changes presentation without changing persisted messages, provider requests, tools, or Host APIs. Inline HTML styles work, while interactive scripts, external assets, and links do not. Mermaid and Graphviz add lazy browser assets and run layout on the browser thread. Cancellation cannot preempt active layout; Mermaid finishes and releases its measurement DOM even when its result cannot be published. The previews have no editing, export, zoom controls, or interactive diagram links.
+
+Component tests cover source copying, default preview, delayed completion, stale success and failure, unmounting, fallback, cancellation, runtime loading failure, and recovery. The keyless [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) verifies Chinese Mermaid flowcharts, sequence diagrams, malformed source, configuration overrides, opaque frames, real image decoding, source toggling, blocked script/navigation/resource attempts, English/Chinese UI snapshots, and served license text. Notices checks pin the reviewed wrapper and native provenance so upgrades require renewed review.

+ 43 - 0
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md

@@ -0,0 +1,43 @@
+# Agent Note: 静态 Markdown fence 预览
+
+Status: implemented
+
+[English](2026-09-09-markdown-static-previews.md) | 中文
+
+## Problem
+
+读者需要直接在 Assistant 回复中查看 Mermaid 和 DOT 图表、SVG 图像和 HTML 示例,而不必解读源码或复制到其他渲染器。这些源码不可信,而渲染器的 npm 许可证字段可能遗漏编译组件各自的分发义务。
+
+## Decision
+
+共享 Markdown 渲染器通过本地化的 `MarkdownLabels.preview` 和解析后的 fence 语言启用定稿后的 `mermaid`、`graphviz`/`dot`、`svg` 和 `html` fence。[UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `SourcePreview`,它接收渲染器、源码和文案,不依赖 Session、文件或 Cordis。Mermaid 与 Graphviz 各自向同一组件提供 `.ts` 渲染器。未提供预览文案的调用方与流式消息保留代码显示。
+
+`CodeBlock.preview` 负责默认可视化、源码切换与原样复制。预览省略语言横幅,在悬停或键盘聚焦时显示紧凑的图标操作;具有任何触控输入的设备始终在图表下方显示操作。切换到源码会隐藏已挂载的预览,保留完成的渲染和待完成的工作。两种视图使用同一个保持焦点的切换按钮,复制始终读取源码属性。
+
+`SourcePreview` 负责加载、失败和取消过期结果发布。替换源码和卸载组件会取消结果发布;在运行时加载完成前取消会跳过布局。失败时显示本地化错误与原始源码,用有效输入替换无效源码后可以恢复预览。
+
+Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。已挂载的预览观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT、SVG 和 HTML 中明确指定的颜色保持原样。
+
+HTML 先由 DOMPurify 清理,禁止导航属性与文档加载元素,再进入不透明来源的 `sandbox=""` iframe。可信 CSP 位于源码标记之前,仅允许内联 CSS、data 图片与 data 字体。脚本、外部资源、子 iframe、表单提交与同源访问均不可用。SVG 按 XML 解析后,与 Graphviz 输出一同作为不可执行图片显示在[按内容定高的画布](../bug-fix/2026-09-09-content-sized-diagram-previews.zh.md)中,因此 SVG 脚本与链接不会激活。HTML iframe 使用固定的可滚动视口:测量内容需要额外的来源访问权限或可信 iframe 脚本。
+
+Graphviz 使用固定且未修改的 `@viz-js/viz` 3.30.0 WebAssembly 发布包,布局引擎为 `dot`。其 npm MIT 声明覆盖包装层;构建来源记录标明 Graphviz 16.0.0(EPL-2.0)、Expat 2.8.4(MIT)与 Emscripten 5.0.7(MIT/NCSA)。分发保留各组件条款,并按 EPL-2.0 第 3.1 节提供准确的 Graphviz 源码下载地址。[完整预览声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)还保留 Mermaid 的 MIT 文本,并为 DOMPurify 选择 Apache-2.0。UI primitives 包携带该声明,Web 构建在资源旁输出相同字节。内嵌产物的许可证检查独立于通用的宽松 npm 元数据策略。
+
+## Alternatives considered
+
+**在图表上方保留代码横幅。** 语言标签和常驻文字控件会分散对图表的注意力。浮层操作保留源码访问,不占用标题行。
+
+**渲染每个流式片段。** 未完成的图表通常无效,反复布局会与文本流式输出争用资源。消息定稿可提供完整源码。
+
+**在 Chat 内部渲染,或添加通用预览注册表。** 使用普通属性的共享基础组件即可满足复用,无需另加注册表或功能插件依赖。这遵循[共享控件规则](../architecture/2026-09-05-shared-client-control-primitives.zh.md)。
+
+**在 iframe 内执行 HTML 脚本。** 静态聊天示例不需要脚本执行。空 sandbox、清理与 CSP 降低预览权限,也避免引入 iframe 消息协议。
+
+**把渲染标记插入 Chat。** 所需预览只需显示图表和访问源码;HTML 样式和可执行 SVG 会与应用共享文档。iframe 隔离布局与来源,SVG 图片模式额外禁用 SVG 行为。
+
+**将 Viz.js 视为仅使用 MIT,或使用远程 Graphviz 服务。** 本地编译的 Graphviz 仍受其许可证约束;远程渲染会把对话内容发送到设备之外。内置渲染器保留源码可获取性与法律声明,无需网络渲染。
+
+## Consequences
+
+该功能改变呈现,不改变持久化消息、provider 请求、工具或 Host API。HTML 内联样式可用,交互脚本、外部资源和链接不可用。Mermaid 和 Graphviz 增加按需加载的浏览器资源,并在浏览器线程上执行布局。取消无法抢占进行中的布局;即使结果无法发布,Mermaid 仍会完成并释放测量 DOM。预览不提供编辑、导出、缩放控件或交互式图表链接。
+
+组件测试覆盖源码复制、默认预览、延迟完成、过期成功与失败、卸载、回退、取消、运行时加载失败与恢复。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证中文 Mermaid 流程图、时序图、无效源码、配置覆盖、不透明来源 iframe、真实图片解码、源码切换、脚本/导航/资源请求被阻止、中英文 UI 快照和已提供的许可证文本。声明检查固定已审查的包装层与原生构建来源,升级时须重新审查。

+ 5 - 1
THIRD_PARTY_NOTICES.md

@@ -55,6 +55,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |
 | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
 | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
+| [`@viz-js/viz`](https://github.com/mdaines/viz-js) | MIT |
 | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
 | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT |
 | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
@@ -66,6 +67,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`commander`](https://github.com/tj/commander.js) | MIT |
 | [`compression`](https://github.com/expressjs/compression) | MIT |
 | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
+| [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) |
 | [`e2b`](https://github.com/e2b-dev/e2b) | MIT |
 | [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT |
 | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
@@ -79,6 +81,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT |
 | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT |
 | [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT |
+| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
 | [`micromark-core-commonmark`](https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) | MIT |
 | [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT |
 | [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT |
@@ -112,6 +115,8 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`zod`](https://github.com/colinhacks/zod) | MIT |
 | [`zustand`](https://github.com/pmndrs/zustand) | MIT |
 
+The Markdown preview distribution also contains Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 runtime code (MIT/NCSA) inside `@viz-js/viz` 3.30.0. The wrapper's MIT metadata does not relicense these components. [Preview notices](packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) preserve their full license texts and Graphviz source availability, together with Mermaid's MIT and DOMPurify's selected Apache-2.0 terms. The UI primitives npm package includes this file; the Web build emits it as `preview-third-party-notices.txt`.
+
 pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
 
 - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch)
@@ -189,7 +194,6 @@ External packages **directly declared** for development, tests, types, or toolin
 | [`jsdom`](https://github.com/jsdom/jsdom) | MIT |
 | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT |
 | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 |
-| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
 | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
 | [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT |
 | [`oxlint`](https://github.com/oxc-project/oxc) | MIT |

+ 24 - 0
apps/web/tests/expected/markdown-mermaid/ui.expected.md

@@ -0,0 +1,24 @@
+- heading "Mermaid previews" [level=1]
+- button "Source"
+- button "Copy"
+- tooltip "Copy"
+- img "Mermaid diagram"
+- button "Source"
+- button "Copy"
+- img "Mermaid diagram"
+- button "Source"
+- button "Copy"
+- status: Unable to render this diagram. The source is shown below.
+- code: flowchart LR A[unfinished
+- button "Source"
+- button "Copy"
+- img "Mermaid diagram"
+- button "Source"
+- button "Copy"
+- img "Graphviz diagram"
+- button "Source"
+- button "Copy"
+- img "SVG preview"
+- button "Source"
+- button "Copy"
+- iframe

+ 23 - 0
apps/web/tests/expected/markdown-mermaid/zh.expected.md

@@ -0,0 +1,23 @@
+- heading "Mermaid previews" [level=1]
+- button "源码"
+- button "复制"
+- img "Mermaid 图表"
+- button "源码"
+- button "复制"
+- img "Mermaid 图表"
+- button "源码"
+- button "复制"
+- status: 无法渲染此图表,源码如下。
+- code: flowchart LR A[unfinished
+- button "源码"
+- button "复制"
+- img "Mermaid 图表"
+- button "源码"
+- button "复制"
+- img "Graphviz 图表"
+- button "源码"
+- button "复制"
+- img "SVG 预览"
+- button "源码"
+- button "复制"
+- iframe

+ 320 - 0
apps/web/tests/markdown-mermaid.e2e.ts

@@ -0,0 +1,320 @@
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-session-title'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold,
+  seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-mermaid', import.meta.url))
+const MODE = webSnapshotMode()
+const SEED_ID = 'markdown-mermaid-web-e2e'
+const DIAGRAM_MESSAGE = '[class*="markdown"]:has(.md-code-block)'
+const FLOW = 'flowchart LR\n  A[输入] --> B[共享渲染器] --> C[图形预览]'
+const SEQUENCE = 'sequenceDiagram\n  participant U as User\n  participant R as Renderer\n  U->>R: Mermaid source\n  R-->>U: Diagram'
+const INVALID = 'flowchart LR\n  A[unfinished'
+const UNTRUSTED = [
+  '%%{init: {"securityLevel":"loose","htmlLabels":true,"themeCSS":"body {display:none!important}"}}%%',
+  'flowchart LR',
+  '  A["<img src=x onerror=alert(1)>"] --> B[Safe]',
+  '  click B "javascript:alert(1)"',
+].join('\n')
+
+const DOT = 'digraph { rankdir=LR; Input -> Preview }'
+const SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="100" onload="parent.document.body.dataset.previewEscaped='yes'">
+<rect width="460" height="100" fill="lightblue"/><text x="20" y="55">SVG preview</text>
+<script>parent.document.body.dataset.previewEscaped='yes';alert('unsafe SVG')</script>
+<image href="https://preview.invalid/svg-image" width="1" height="1"/>
+<foreignObject width="1" height="1"><div xmlns="http://www.w3.org/1999/xhtml"><script>alert('unsafe HTML')</script></div></foreignObject>
+</svg>`
+const HTML = `<style>h2 { color: green }</style><h2>Static HTML</h2>
+<script>parent.document.body.dataset.previewEscaped = 'yes'; alert('unsafe')</script>
+<meta http-equiv="refresh" content="0;url=https://preview.invalid/refresh">
+<a href="https://preview.invalid/navigate">Disabled navigation</a>
+<img src="https://preview.invalid/image" onerror="alert('unsafe')">
+<iframe src="https://preview.invalid/frame"></iframe>`
+
+function fixture(): string {
+  const session = Session.create(SessionId('markdown-mermaid-source'))
+  session.append('turn/start', { turn: 1 })
+  const user = session.append('user/message', createUserMessage({
+    content: [{ type: 'text', text: 'Preview these Mermaid diagrams.' }], source: { kind: 'user' },
+  }), { surfaceOp: 'append' })
+  session.append('session/title', { title: 'Mermaid previews', messageSeqs: [user.seq], source: { kind: 'fallback' } })
+  session.append('step/start', { turn: 1, step: 1 })
+  session.append('assistant/message', {
+    stream: [], turn: 1, step: 1,
+    message: createMessage({
+      role: 'assistant',
+      content: [{ type: 'text', text: [
+        '# Mermaid previews',
+        ...[FLOW, SEQUENCE, INVALID, UNTRUSTED].map(code => `\`\`\`mermaid\n${code}\n\`\`\``),
+        ...[['dot', DOT], ['svg', SVG], ['html', HTML]].map(([lang, code]) => `\`\`\`${lang}\n${code}\n\`\`\``),
+      ].join('\n\n') }],
+      source: { kind: 'model', provider: 'fixture', model: 'fixture' },
+    }),
+  }, { surfaceOp: 'append' })
+  session.append('step/end', { turn: 1, step: 1 })
+  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  const noon = new Date().setHours(12, 0, 0, 0)
+  return [
+    JSON.stringify({
+      type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0,
+      cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0,
+    }),
+    ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: noon + event.seq * 1000 })),
+    '',
+  ].join('\n')
+}
+
+async function openConversation(page: Page, scaffold: WebScaffold): Promise<void> {
+  await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+  await page.getByRole('treeitem').first().click({ timeout: 30_000 })
+  await page.getByRole('treeitem').nth(1).click()
+  await page.getByRole('heading', { name: 'Mermaid previews' }).waitFor()
+}
+
+describe('web e2e: Mermaid chat previews', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    await seedSession(scaffold, fixture(), SEED_ID)
+    browser = await chromium.launch()
+  }, 120_000)
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it.skipIf(MODE === 'record').each(['light', 'dark'] as const)(
+    'tightly wraps short diagram previews in %s mode', async (scheme) => {
+      const page = await newEnglishPage(browser)
+      try {
+        await page.emulateMedia({ colorScheme: scheme })
+        await openConversation(page, scaffold)
+        await expect.poll(() => page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe(scheme)
+
+        const mermaid = page.getByRole('img', { name: 'Mermaid diagram' }).first()
+        await mermaid.evaluate(async (node: HTMLImageElement) => { await node.decode() })
+        const mermaidHeight = await mermaid.evaluate(node => node.getBoundingClientRect().height)
+        const canvasHeight = await mermaid.evaluate(node => node.parentElement!.getBoundingClientRect().height)
+        // Compact diagrams need at most the Mermaid canvas's 16px padding on each side.
+        expect(canvasHeight - mermaidHeight).toBeLessThanOrEqual(32)
+
+        for (const title of ['Graphviz diagram', 'SVG preview']) {
+          const image = page.getByRole('img', { name: title, exact: true })
+          await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
+          const imageHeight = await image.evaluate(node => node.getBoundingClientRect().height)
+          const containerHeight = await image.evaluate(node => node.parentElement!.getBoundingClientRect().height)
+          expect(imageHeight).toBeGreaterThan(0)
+          expect(containerHeight).toBeGreaterThanOrEqual(imageHeight)
+          expect.soft(containerHeight - imageHeight,
+            `${scheme} ${title}: ${containerHeight}px container around ${imageHeight}px content`,
+          ).toBeLessThanOrEqual(32)
+        }
+        await page.setViewportSize({ width: 360, height: 800 })
+        const svg = page.getByRole('img', { name: 'SVG preview', exact: true })
+        await expect.poll(() => svg.evaluate((node) => {
+          const { width, height } = node.getBoundingClientRect()
+          return width > 0 && height > 0 && width < 460
+        })).toBe(true)
+        const compact = await svg.evaluate((node: HTMLImageElement) => ({
+          width: node.getBoundingClientRect().width,
+          height: node.getBoundingClientRect().height,
+          canvasHeight: node.parentElement!.getBoundingClientRect().height,
+          ratio: node.naturalWidth / node.naturalHeight,
+        }))
+        expect(compact.width / compact.height).toBeCloseTo(compact.ratio, 1)
+        expect(compact.canvasHeight - compact.height).toBeLessThanOrEqual(32)
+      } finally {
+        await page.close()
+      }
+    },
+  )
+
+  it.skipIf(MODE === 'record')('updates existing diagrams when the document switches between light and dark', async () => {
+    const page = await newEnglishPage(browser)
+    try {
+      await openConversation(page, scaffold)
+      const image = page.getByRole('img', { name: 'Mermaid diagram' }).first()
+      await image.waitFor()
+      let previous = await image.getAttribute('src')
+      for (const scheme of ['dark', 'light'] as const) {
+        await page.evaluate((value) => {
+          document.documentElement.style.colorScheme = value
+          document.body.toggleAttribute('data-ds-dark-theme', value === 'dark')
+        }, scheme)
+        await expect.poll(() => image.getAttribute('src')).not.toBe(previous)
+        await expect.poll(() => image.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
+        const colors = await image.evaluate((node) => {
+          const reference = document.createElement('span')
+          reference.style.backgroundColor = 'var(--dsw-alias-markdown-code-block)'
+          document.body.append(reference)
+          try {
+            return {
+              canvas: getComputedStyle(node.parentElement!).backgroundColor,
+              expected: getComputedStyle(reference).backgroundColor,
+            }
+          } finally {
+            reference.remove()
+          }
+        })
+        expect(colors.canvas).toBe(colors.expected)
+        await expect.poll(() => page.getByTitle('HTML preview', { exact: true }).getAttribute('srcdoc'))
+          .toContain(`color-scheme:${scheme}`)
+        previous = await image.getAttribute('src')
+      }
+    } finally {
+      await page.close()
+    }
+  })
+
+  it.skipIf(MODE === 'record')('renders diagrams, switches to source, copies source, and contains malformed content', async () => {
+    const page = await newEnglishPage(browser)
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-mermaid'))
+    const tripwire = watchConsole(page)
+    const dialogs: string[] = []
+    page.on('dialog', (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss() })
+    await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
+    await openConversation(page, scaffold)
+    const images = page.getByRole('img', { name: 'Mermaid diagram' })
+    await expect.poll(() => images.count(), { timeout: 20_000 }).toBe(3)
+    await expect.poll(() => images.evaluateAll(nodes => nodes.every(node => (node as HTMLImageElement).naturalWidth > 0))).toBe(true)
+    expect(await images.evaluateAll(nodes => nodes.every(node =>
+      node.getBoundingClientRect().width <= (node as HTMLImageElement).naturalWidth))).toBe(true)
+    expect(await page.getByText('Unable to render this diagram. The source is shown below.', { exact: true }).count()).toBe(1)
+    expect(await page.locator('pre code').allTextContents()).toContain(INVALID)
+    expect(await images.first().evaluate(node => decodeURIComponent((node as HTMLImageElement).src))).toContain('共享渲染器')
+    const first = page.locator('.md-code-block').first()
+    const diagram = await first.getByRole('img').elementHandle()
+    const controls = first.locator('[class*="bannerWrap"]')
+    expect(await first.getByText('mermaid', { exact: true }).count()).toBe(0)
+    await page.mouse.move(0, 0)
+    expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('0')
+    await first.hover()
+    expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
+    await first.getByRole('button', { name: 'Copy', exact: true }).click()
+    expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(FLOW)
+    await page.mouse.move(0, 0)
+    await first.getByRole('button', { name: 'Source', exact: true }).focus()
+    expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
+    await page.keyboard.press('Enter')
+    expect(await first.getByRole('button', { name: 'Preview', exact: true })
+      .evaluate(node => node === document.activeElement)).toBe(true)
+    expect(await first.locator('pre code').textContent()).toBe(FLOW)
+    expect(await diagram!.evaluate(node => node.isConnected)).toBe(true)
+    expect(await diagram!.isVisible()).toBe(false)
+    await first.getByRole('button', { name: 'Preview', exact: true }).click()
+    expect(await diagram!.isVisible()).toBe(true)
+    await first.getByRole('button', { name: 'Copy', exact: true }).waitFor()
+    expect(await page.locator('body').evaluate(node => getComputedStyle(node).display)).not.toBe('none')
+    expect(await page.locator('[id^="dsh-mermaid-"]').count()).toBe(0)
+    expect(dialogs).toEqual([])
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await page.getByRole('heading', { name: 'Mermaid previews' }).click()
+    await page.mouse.move(0, 0)
+    const snapshot = await captureStableAria(page, DIAGRAM_MESSAGE, scaffold.workspaceCwd)
+    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ui.expected.md'), snapshot, MODE)
+    await page.close()
+  }, 60_000)
+
+  it.skipIf(MODE === 'record')('localizes the preview and failure states in Chinese', async () => {
+    const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', hasTouch: true })
+    await openConversation(page, scaffold)
+    await expect.poll(() => page.getByRole('img', { name: 'Mermaid 图表' }).count(), { timeout: 20_000 }).toBe(3)
+    expect(await page.getByRole('button', { name: '源码', exact: true }).count()).toBe(7)
+    const controls = page.locator('.md-code-block').first().locator('[class*="bannerWrap"]')
+    expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
+    expect(await page.getByRole('button', { name: '源码', exact: true }).first()
+      .evaluate(node => node.getBoundingClientRect().width)).toBe(44)
+    expect(await page.getByText('无法渲染此图表,源码如下。', { exact: true }).count()).toBe(1)
+    const firstImage = page.getByRole('img', { name: 'Mermaid 图表' }).first()
+    await expect.poll(() => firstImage.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
+    expect(await controls.evaluate(node => node.getBoundingClientRect().top))
+      .toBeGreaterThanOrEqual(await firstImage.evaluate(node => node.getBoundingClientRect().bottom))
+    const snapshot = await captureStableAria(page, DIAGRAM_MESSAGE, scaffold.workspaceCwd)
+    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'zh.expected.md'), snapshot, MODE)
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'zh.expected.md'])
+    await page.close()
+  }, 60_000)
+
+  it.skipIf(MODE === 'record')('previews inert DOT and SVG images and isolates static HTML', async () => {
+    const page = await newEnglishPage(browser)
+    const requests: string[] = []
+    const dialogs: string[] = []
+    // Routing observes requests eligible for transport; Chromium also emits 'request' for CSP-blocked URLs.
+    await page.route('https://preview.invalid/**', async (route) => {
+      requests.push(route.request().url())
+      await route.abort()
+    })
+    page.on('dialog', (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss() })
+    await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
+    await openConversation(page, scaffold)
+    for (const [title, code] of [['Graphviz diagram', DOT], ['SVG preview', SVG], ['HTML preview', HTML]] as const) {
+      const preview = title === 'HTML preview'
+        ? page.getByTitle(title, { exact: true })
+        : page.getByRole('img', { name: title, exact: true, includeHidden: true })
+      await preview.waitFor()
+      if (title === 'HTML preview') {
+        expect(await preview.getAttribute('sandbox')).toBe('')
+        const body = preview.contentFrame()
+        await body.getByRole('heading', { name: 'Static HTML' }).waitFor()
+        expect(await body.getByRole('heading').evaluate(node => getComputedStyle(node).color)).toBe('rgb(0, 128, 0)')
+        await body.getByText('Disabled navigation').click()
+        expect(await preview.evaluate(node => (node as HTMLIFrameElement).contentDocument)).toBeNull()
+      } else {
+        await expect.poll(() => preview.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
+      }
+      const block = page.locator('.md-code-block').filter({ has: preview })
+      await block.hover()
+      await block.getByRole('button', { name: 'Copy', exact: true }).click()
+      expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(code)
+      await block.getByRole('button', { name: 'Source', exact: true }).click()
+      expect(await block.locator('pre code').textContent()).toBe(code)
+      await block.getByRole('button', { name: 'Preview', exact: true }).click()
+      expect(await preview.isVisible()).toBe(true)
+    }
+    expect(await page.locator('body').getAttribute('data-preview-escaped')).toBeNull()
+    expect(dialogs).toEqual([])
+    expect(requests).toEqual([])
+    const notices = await page.request.get(new URL('/preview-third-party-notices.txt', scaffold.authenticatedUrl).href)
+    expect(notices.ok()).toBe(true)
+    expect(await notices.text()).toContain('Eclipse Public License - v 2.0')
+    await page.close()
+  })
+
+  it.skipIf(MODE === 'record')('keeps actions visible when a mouse and touchscreen are both available', async () => {
+    const hybridBrowser = await chromium.launch({
+      args: ['--blink-settings=availablePointerTypes=6,primaryPointerType=4,availableHoverTypes=2,primaryHoverType=2'],
+    })
+    try {
+      const page = await newEnglishPage(hybridBrowser)
+      expect(await page.evaluate(() =>
+        matchMedia('(hover: hover) and (pointer: fine) and (any-pointer: coarse)').matches)).toBe(true)
+      await openConversation(page, scaffold)
+      const first = page.locator('.md-code-block').first()
+      const diagram = first.getByRole('img', { name: 'Mermaid diagram' })
+      await expect.poll(() => diagram.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
+      await page.mouse.move(0, 0)
+      const controls = first.locator('[class*="bannerWrap"]')
+      expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
+      expect(await controls.evaluate(node => getComputedStyle(node).pointerEvents)).toBe('auto')
+      expect(await first.getByRole('button', { name: 'Source', exact: true })
+        .evaluate(node => node.getBoundingClientRect().width)).toBe(44)
+      expect(await controls.evaluate(node => node.getBoundingClientRect().top))
+        .toBeGreaterThanOrEqual(await diagram.evaluate(node => node.getBoundingClientRect().bottom))
+      await first.getByRole('button', { name: 'Source', exact: true }).click()
+      expect(await first.locator('pre code').textContent()).toBe(FLOW)
+    } finally {
+      await hybridBrowser.close()
+    }
+  }, 60_000)
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -79,6 +79,7 @@
     "tests/math-rendering.e2e.ts",
     "tests/markdown-cjk-strong.e2e.ts",
     "tests/markdown-inline-code-links.e2e.ts",
+    "tests/markdown-mermaid.e2e.ts",
     "tests/clickable-links-gallery.e2e.ts",
     "tests/queue-actions.e2e.ts",
     "tests/queue-image.e2e.ts",

+ 15 - 1
apps/web/vite.config.ts

@@ -27,6 +27,20 @@ function clientDocumentTitle(): Plugin {
   }
 }
 
+/** Preserve preview licenses and Graphviz source availability in every browser distribution. */
+function previewNotices(): Plugin {
+  return {
+    name: 'dsh-preview-notices',
+    async generateBundle() {
+      this.emitFile({
+        type: 'asset',
+        fileName: 'preview-third-party-notices.txt',
+        source: await readFile(src('../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt'), 'utf8'),
+      })
+    },
+  }
+}
+
 /** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
 function rejectStandaloneServe(): Plugin {
   return {
@@ -147,7 +161,7 @@ export default defineConfig({
   // Relative asset URLs: preview.html mounts the same output under any base
   // directory, and the served index resolves identically from the site root.
   base: './',
-  plugins: [rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage()],
+  plugins: [previewNotices(), rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage()],
   build: {
     // The worker bootstrap holds its page at top-level await; Vite's default
     // `modules` target (es2020-era) rejects that syntax.

+ 10 - 0
packages/client/locale/src/locales/en.ts

@@ -38,6 +38,16 @@ export const en = {
   'json.expandNode': 'Expand JSON node',
   'json.label': 'JSON',
   'markdown.footnotes': 'Footnotes',
+  'markdown.preview.graphviz': 'Graphviz diagram',
+  'markdown.preview.svg': 'SVG preview',
+  'markdown.preview.html': 'HTML preview',
+  'markdown.preview.loading': 'Rendering preview…',
+  'markdown.preview.error': 'Unable to render this preview. The source is shown below.',
+  'markdown.mermaid.diagram': 'Mermaid diagram',
+  'markdown.preview.preview': 'Preview',
+  'markdown.preview.source': 'Source',
+  'markdown.mermaid.loading': 'Rendering diagram…',
+  'markdown.mermaid.error': 'Unable to render this diagram. The source is shown below.',
   'markdown.truncatedCharacters': '… truncated at {total} characters',
   'number.thousand': '{value}K',
   'number.million': '{value}M',

+ 10 - 0
packages/client/locale/src/locales/zh.ts

@@ -36,6 +36,16 @@ export const zh = {
   'json.expandNode': '展开 JSON 节点',
   'json.label': 'JSON',
   'markdown.footnotes': '脚注',
+  'markdown.preview.graphviz': 'Graphviz 图表',
+  'markdown.preview.svg': 'SVG 预览',
+  'markdown.preview.html': 'HTML 预览',
+  'markdown.preview.loading': '正在渲染预览…',
+  'markdown.preview.error': '无法渲染此预览,源码如下。',
+  'markdown.mermaid.diagram': 'Mermaid 图表',
+  'markdown.preview.preview': '预览',
+  'markdown.preview.source': '源码',
+  'markdown.mermaid.loading': '正在渲染图表…',
+  'markdown.mermaid.error': '无法渲染此图表,源码如下。',
   'markdown.truncatedCharacters': '… 已截断,共 {total} 字符',
   'number.thousand': '{value}K',
   'number.million': '{value}M',

+ 2 - 2
packages/client/ui-chat/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 860dcb9eeb9c92a14d9128d9d8c95c29796726f8
-README.zh.md: 4dd06a16b19b14cacc0ded603cc1cf8869dd1a43
+README.md: 1bd7f008e46b695899c703c928ebe8bb76fbe4c8
+README.zh.md: 5919622396dde5b0c8cbf51f71999672a11f13ec

+ 8 - 0
packages/client/ui-chat/README.md

@@ -15,6 +15,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
 ## Table of Contents
 
 - [System prompt row](#system-prompt-row)
+- [Mermaid previews](#mermaid-previews)
 - [Turn token usage](#turn-token-usage)
 - [Turn Process Folding](#turn-process-folding)
 - [Scroll ownership](#scroll-ownership)
@@ -29,6 +30,13 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
 
 Each nonempty appended `system/message` owns a collapsed prompt row, including a complete prompt at the start of a headerless window; the same-step header does not duplicate it. Chat also shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, or `system/message` surface node replacement whose text differs, reading the last nonempty surviving system node in surface order at the `request/header`; a non-initial request whose preceding header is outside the loaded history window also shows one. A resume repeats the row even when its system text is unchanged, including after pagination supplies the preceding header and system node; same-series config-only or tool-only changes, tool steps, and retries create no repetition, and a `system/message` event is never rendered as a transcript message. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A request whose system node is empty or outside the loaded window creates no row until the page holding the node arrives.
 
+<a id="mermaid-previews"></a>
+## Mermaid previews
+
+Settled Assistant Markdown shows `mermaid` code fences as diagrams. Diagrams have no language banner. Source/Preview and Copy icon actions appear on hover or keyboard focus and remain visible on touch devices; both views copy the original Mermaid source. Streaming fences remain code until the message settles; invalid diagrams show an error and their source without disrupting other blocks. Chat enables the shared [Mermaid preview primitive](../ui-primitives/README.md) through localized labels. Chat also previews `graphviz`/`dot`, `svg`, and static `html` fences in isolated iframes, with the same default visualization, source switching, and copying. HTML scripts, navigation, and remote resources are disabled. This is a presentation change and adds no model prompt, tool, or Session event.
+
+-----
+
 <a id="turn-token-usage"></a>
 ## Turn token usage
 

+ 8 - 0
packages/client/ui-chat/README.zh.md

@@ -15,6 +15,7 @@ kind: "package-reference"
 ## 目录
 
 - [系统提示词行](#system-prompt-row)
+- [Mermaid 预览](#mermaid-previews)
 - [轮次 token 用量](#turn-token-usage)
 - [轮次过程折叠](#turn-process-folding)
 - [滚动归属](#scroll-ownership)
@@ -31,6 +32,13 @@ kind: "package-reference"
 
 -----
 
+<a id="mermaid-previews"></a>
+## Mermaid 预览
+
+已定稿的 Assistant Markdown 把 `mermaid` 代码 fence 显示为图表。图表不显示语言标题栏。源码/预览切换与复制图标在悬停或键盘聚焦时出现,触屏上保持可见;两种视图复制的都是原始 Mermaid 源码。流式 fence 在消息定稿前保持代码显示;非法图表显示错误与源码,不影响其他块。Chat 通过本地化 label 启用共享的 [Mermaid 预览原语](../ui-primitives/README.zh.md)。Chat 还在隔离 iframe 中预览 `graphviz`/`dot`、`svg` 和静态 `html` fence,共用默认可视化、源码切换与复制操作。HTML 脚本、导航与远程资源均被禁用。这项展示变更不增加模型提示词、工具或 Session 事件。
+
+-----
+
 <a id="turn-token-usage"></a>
 ## 轮次 token 用量
 

+ 13 - 0
packages/client/ui-chat/src/client/markdown-labels.ts

@@ -9,8 +9,21 @@ import type { ChatViewSlotProps } from './contract/slots.ts'
  * @returns Labels for code fences and footnotes.
  */
 export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels {
+  const status = { loading: t('markdown.preview.loading'), error: t('markdown.preview.error') }
   return {
     code: { copyLabel: t('copy'), copiedLabel: t('copied') },
     footnotes: t('markdown.footnotes'),
+    preview: {
+      preview: t('markdown.preview.preview'),
+      source: t('markdown.preview.source'),
+      mermaid: {
+        diagram: t('markdown.mermaid.diagram'),
+        loading: t('markdown.mermaid.loading'),
+        error: t('markdown.mermaid.error'),
+      },
+      graphviz: { ...status, diagram: t('markdown.preview.graphviz') },
+      svg: { ...status, diagram: t('markdown.preview.svg') },
+      html: { ...status, diagram: t('markdown.preview.html') },
+    },
   }
 }

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: aebb0be860ebaba4543a8fd262599507ead64556
-README.zh.md: a545aff9837036890b6250ab1aa32fb651af0a18
+README.md: 22f450914078799f2f9da31389670f83cefe66cd
+README.zh.md: c9fd355d4b16ae98bd0f194d13d4331a52513044

+ 10 - 0
packages/client/ui-primitives/README.md

@@ -73,6 +73,15 @@ The catalog above lists what each export is for; this section covers the behavio
 
 `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows.
 
+Supply `MarkdownLabels.preview` to enable settled `mermaid`, `graphviz`/`dot`, `svg`, and `html` fences. Each language supplies localized preview states, and consumers without these labels retain code. Mermaid and Graphviz use the active document palette on the code-block background and update mounted previews when the theme changes. Explicit colors in DOT, SVG, and HTML remain authored content. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render failure shows the original source with the supplied error label; replacing the source discards late results from the previous render. The diagram keeps its intrinsic size and shrinks to fit the available width.
+
+`CodeBlock.preview` supplies an optional headerless body with icon actions for source switching and copying. Actions appear on hover or keyboard focus, and stay visible below the diagram whenever a touchscreen is available, including with a mouse attached. Switching to source keeps the preview mounted, so returning reuses its result; copying always retains the source.
+
+Each settled fence defaults to visualization and shares the source/copy controls. Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images in the same content-sized canvas as Mermaid; malformed SVG and DOT retain their source with an error. HTML accepts complete documents or fragments and preserves inline styles, but strips scripts and navigation. HTML frames have no sandbox permissions, and their Content Security Policy blocks external resources, embedded frames, forms, and scripts. Diagram canvases follow the rendered image height with 16px padding and shrink images proportionally to fit the available width. HTML frames use a 400px scrollable viewport. Raw HTML outside a code fence remains ordinary Markdown input.
+
+Internal render functions accept optional native `MermaidConfig` and Viz.js `RenderOptions` for developer customization. Attribute and theme-variable overrides merge with document defaults; Mermaid preview restrictions and Graphviz SVG output remain fixed. These parameters have no settings UI.
+
+[Preview license notices](THIRD_PARTY_PREVIEW_NOTICES.txt) ship with this package and the Web frontend. Mermaid is MIT; Viz.js is MIT but embeds Graphviz under EPL-2.0. The notices retain license texts and the exact Graphviz source download. Dependency upgrades require reviewing the embedded payload as well as npm metadata; the [preview decision](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md) records the distribution and sandbox choices.
 
 ### Localizing copy
 
@@ -141,6 +150,7 @@ None; this package neither assembles nor sends a provider request.
 
 These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap.
 
+- **Diagram rendering runs on the browser thread** — every mounted settled preview starts rendering, including off-screen diagrams. Mermaid serializes layouts; Graphviz performs synchronous WebAssembly layout. Submitted layout work cannot be interrupted. Preview virtualization and worker rendering are not provided.
 - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it.
 - **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path.
 - **Glyph-level icons are redrawn approximations** — the fish logo and the sparkle mark come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.

+ 10 - 0
packages/client/ui-primitives/README.zh.md

@@ -73,6 +73,15 @@ kind: "package-library"
 
 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。
 
+提供 `MarkdownLabels.preview` 可启用定稿后的 `mermaid`、`graphviz`/`dot`、`svg` 和 `html` fence。每种语言提供本地化预览状态,未提供这些文案的调用方保留代码显示。Mermaid 与 Graphviz 在代码块背景上使用当前文档配色,主题切换时同步更新已挂载的预览。DOT、SVG 与 HTML 中明确指定的颜色仍保留原样。渲染按需加载 Mermaid、使用严格安全模式,并把生成的 SVG 显示为图片,不绑定图内链接处理器。渲染失败时显示原始源码与传入的错误文案;替换源码后会丢弃前一次渲染的延迟结果。图表保留固有尺寸,并在可用宽度不足时缩小。
+
+`CodeBlock.preview` 提供无标题栏的替代正文,以及切换源码和复制的图标操作。操作在悬停或键盘聚焦时出现;只要设备具备触屏,就在图表下方保持可见,包括同时连接鼠标的情况。切换到源码时保留已挂载的预览,返回时复用其结果;复制始终保留源码。
+
+每个定稿 fence 默认显示可视化,并共享源码切换与复制操作。Graphviz 按需加载 `@viz-js/viz`,用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出作为不可执行的图片,使用与 Mermaid 相同的按内容定高画布;非法 SVG 与 DOT 显示错误并保留源码。HTML 接受完整文档或片段、保留内联样式,同时移除脚本与导航。HTML iframe 不授予任何 sandbox 权限,Content Security Policy 阻止外部资源、嵌套 iframe、表单与脚本。图表画布随渲染后的图片高度收缩,保留 16px 内边距,并在可用宽度不足时等比例缩小图片。HTML iframe 使用可滚动的 400px 视口。代码 fence 外的原始 HTML 仍按普通 Markdown 输入处理。
+
+内部渲染函数接受可选的原生 `MermaidConfig` 与 Viz.js `RenderOptions`,供开发者定制。属性与主题变量覆盖项和文档默认值合并;Mermaid 预览限制与 Graphviz SVG 输出保持固定。这些参数没有设置界面。
+
+[预览许可证声明](THIRD_PARTY_PREVIEW_NOTICES.txt)随本包与 Web 前端分发。Mermaid 使用 MIT;Viz.js 使用 MIT,但内嵌 Graphviz 使用 EPL-2.0。声明保留完整许可证文本及准确的 Graphviz 源码下载地址。升级依赖时必须同时检查内嵌产物和 npm 元数据;[预览决策](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md)记录分发与 sandbox 选择。
 
 ### 本地化文案
 
@@ -141,6 +150,7 @@ kind: "package-library"
 
 这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
 
+- **图表渲染在浏览器线程上执行**:每个已挂载的定稿预览都会开始渲染,包括视口外的图表。Mermaid 串行执行布局;Graphviz 同步执行 WebAssembly 布局。已提交的布局工作无法中断。当前不提供预览虚拟化或 worker 渲染。
 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。
 - **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。
 - **字形级图标是重新绘制的近似版本**:鱼形标志与闪光标记来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。

+ 688 - 0
packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt

@@ -0,0 +1,688 @@
+Markdown preview third-party notices
+
+Mermaid 11.16.0: MIT; source https://github.com/mermaid-js/mermaid/tree/mermaid%4011.16.0
+@viz-js/viz 3.30.0: MIT JavaScript wrapper; source and build instructions:
+https://github.com/mdaines/viz-js/tree/99da545270e6e7b127a5c7ba65974b8a604a6358
+
+The Viz.js WebAssembly payload contains Graphviz 16.0.0 under EPL-2.0,
+Expat 2.8.4 under MIT, and Emscripten 5.0.7 runtime code under MIT/NCSA.
+Graphviz remains under EPL-2.0; it is not relicensed as MIT by DeepSeek Harness.
+Graphviz source code is available under EPL-2.0 at:
+https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz
+SHA-256: 36a1de1aaf5a2023b14f95170a5f8f0b12522d1c305b517fc261966597050749
+Viz.js's backend build instructions and additions are available at the commit above.
+DeepSeek Harness uses the unmodified upstream Viz.js package.
+
+Expat source: https://github.com/libexpat/libexpat/tree/R_2_8_4
+Emscripten source: https://github.com/emscripten-core/emscripten/tree/5.0.7
+DOMPurify 3.4.11: Apache-2.0 OR MPL-2.0; this distribution selects Apache-2.0.
+Source: https://github.com/cure53/DOMPurify/tree/3.4.11
+
+The following license texts and copyright notices apply to their respective components.
+
+
+===== Mermaid =====
+
+The MIT License (MIT)
+
+Copyright (c) 2014 - 2022 Knut Sveidqvist
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+===== Viz.js =====
+
+MIT License
+
+Copyright (c) Michael Daines
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+===== Graphviz =====
+
+Eclipse Public License - v 2.0
+
+    THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
+    PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
+    OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
+
+1. DEFINITIONS
+
+"Contribution" means:
+
+  a) in the case of the initial Contributor, the initial content
+     Distributed under this Agreement, and
+
+  b) in the case of each subsequent Contributor:
+     i) changes to the Program, and
+     ii) additions to the Program;
+  where such changes and/or additions to the Program originate from
+  and are Distributed by that particular Contributor. A Contribution
+  "originates" from a Contributor if it was added to the Program by
+  such Contributor itself or anyone acting on such Contributor's behalf.
+  Contributions do not include changes or additions to the Program that
+  are not Modified Works.
+
+"Contributor" means any person or entity that Distributes the Program.
+
+"Licensed Patents" mean patent claims licensable by a Contributor which
+are necessarily infringed by the use or sale of its Contribution alone
+or when combined with the Program.
+
+"Program" means the Contributions Distributed in accordance with this
+Agreement.
+
+"Recipient" means anyone who receives the Program under this Agreement
+or any Secondary License (as applicable), including Contributors.
+
+"Derivative Works" shall mean any work, whether in Source Code or other
+form, that is based on (or derived from) the Program and for which the
+editorial revisions, annotations, elaborations, or other modifications
+represent, as a whole, an original work of authorship.
+
+"Modified Works" shall mean any work in Source Code or other form that
+results from an addition to, deletion from, or modification of the
+contents of the Program, including, for purposes of clarity any new file
+in Source Code form that contains any contents of the Program. Modified
+Works shall not include works that contain only declarations,
+interfaces, types, classes, structures, or files of the Program solely
+in each case in order to link to, bind by name, or subclass the Program
+or Modified Works thereof.
+
+"Distribute" means the acts of a) distributing or b) making available
+in any manner that enables the transfer of a copy.
+
+"Source Code" means the form of a Program preferred for making
+modifications, including but not limited to software source code,
+documentation source, and configuration files.
+
+"Secondary License" means either the GNU General Public License,
+Version 2.0, or any later versions of that license, including any
+exceptions or additional permissions as identified by the initial
+Contributor.
+
+2. GRANT OF RIGHTS
+
+  a) Subject to the terms of this Agreement, each Contributor hereby
+  grants Recipient a non-exclusive, worldwide, royalty-free copyright
+  license to reproduce, prepare Derivative Works of, publicly display,
+  publicly perform, Distribute and sublicense the Contribution of such
+  Contributor, if any, and such Derivative Works.
+
+  b) Subject to the terms of this Agreement, each Contributor hereby
+  grants Recipient a non-exclusive, worldwide, royalty-free patent
+  license under Licensed Patents to make, use, sell, offer to sell,
+  import and otherwise transfer the Contribution of such Contributor,
+  if any, in Source Code or other form. This patent license shall
+  apply to the combination of the Contribution and the Program if, at
+  the time the Contribution is added by the Contributor, such addition
+  of the Contribution causes such combination to be covered by the
+  Licensed Patents. The patent license shall not apply to any other
+  combinations which include the Contribution. No hardware per se is
+  licensed hereunder.
+
+  c) Recipient understands that although each Contributor grants the
+  licenses to its Contributions set forth herein, no assurances are
+  provided by any Contributor that the Program does not infringe the
+  patent or other intellectual property rights of any other entity.
+  Each Contributor disclaims any liability to Recipient for claims
+  brought by any other entity based on infringement of intellectual
+  property rights or otherwise. As a condition to exercising the
+  rights and licenses granted hereunder, each Recipient hereby
+  assumes sole responsibility to secure any other intellectual
+  property rights needed, if any. For example, if a third party
+  patent license is required to allow Recipient to Distribute the
+  Program, it is Recipient's responsibility to acquire that license
+  before distributing the Program.
+
+  d) Each Contributor represents that to its knowledge it has
+  sufficient copyright rights in its Contribution, if any, to grant
+  the copyright license set forth in this Agreement.
+
+  e) Notwithstanding the terms of any Secondary License, no
+  Contributor makes additional grants to any Recipient (other than
+  those set forth in this Agreement) as a result of such Recipient's
+  receipt of the Program under the terms of a Secondary License
+  (if permitted under the terms of Section 3).
+
+3. REQUIREMENTS
+
+3.1 If a Contributor Distributes the Program in any form, then:
+
+  a) the Program must also be made available as Source Code, in
+  accordance with section 3.2, and the Contributor must accompany
+  the Program with a statement that the Source Code for the Program
+  is available under this Agreement, and informs Recipients how to
+  obtain it in a reasonable manner on or through a medium customarily
+  used for software exchange; and
+
+  b) the Contributor may Distribute the Program under a license
+  different than this Agreement, provided that such license:
+     i) effectively disclaims on behalf of all other Contributors all
+     warranties and conditions, express and implied, including
+     warranties or conditions of title and non-infringement, and
+     implied warranties or conditions of merchantability and fitness
+     for a particular purpose;
+
+     ii) effectively excludes on behalf of all other Contributors all
+     liability for damages, including direct, indirect, special,
+     incidental and consequential damages, such as lost profits;
+
+     iii) does not attempt to limit or alter the recipients' rights
+     in the Source Code under section 3.2; and
+
+     iv) requires any subsequent distribution of the Program by any
+     party to be under a license that satisfies the requirements
+     of this section 3.
+
+3.2 When the Program is Distributed as Source Code:
+
+  a) it must be made available under this Agreement, or if the
+  Program (i) is combined with other material in a separate file or
+  files made available under a Secondary License, and (ii) the initial
+  Contributor attached to the Source Code the notice described in
+  Exhibit A of this Agreement, then the Program may be made available
+  under the terms of such Secondary Licenses, and
+
+  b) a copy of this Agreement must be included with each copy of
+  the Program.
+
+3.3 Contributors may not remove or alter any copyright, patent,
+trademark, attribution notices, disclaimers of warranty, or limitations
+of liability ("notices") contained within the Program from any copy of
+the Program which they Distribute, provided that Contributors may add
+their own appropriate notices.
+
+4. COMMERCIAL DISTRIBUTION
+
+Commercial distributors of software may accept certain responsibilities
+with respect to end users, business partners and the like. While this
+license is intended to facilitate the commercial use of the Program,
+the Contributor who includes the Program in a commercial product
+offering should do so in a manner which does not create potential
+liability for other Contributors. Therefore, if a Contributor includes
+the Program in a commercial product offering, such Contributor
+("Commercial Contributor") hereby agrees to defend and indemnify every
+other Contributor ("Indemnified Contributor") against any losses,
+damages and costs (collectively "Losses") arising from claims, lawsuits
+and other legal actions brought by a third party against the Indemnified
+Contributor to the extent caused by the acts or omissions of such
+Commercial Contributor in connection with its distribution of the Program
+in a commercial product offering. The obligations in this section do not
+apply to any claims or Losses relating to any actual or alleged
+intellectual property infringement. In order to qualify, an Indemnified
+Contributor must: a) promptly notify the Commercial Contributor in
+writing of such claim, and b) allow the Commercial Contributor to control,
+and cooperate with the Commercial Contributor in, the defense and any
+related settlement negotiations. The Indemnified Contributor may
+participate in any such claim at its own expense.
+
+For example, a Contributor might include the Program in a commercial
+product offering, Product X. That Contributor is then a Commercial
+Contributor. If that Commercial Contributor then makes performance
+claims, or offers warranties related to Product X, those performance
+claims and warranties are such Commercial Contributor's responsibility
+alone. Under this section, the Commercial Contributor would have to
+defend claims against the other Contributors related to those performance
+claims and warranties, and if a court requires any other Contributor to
+pay any damages as a result, the Commercial Contributor must pay
+those damages.
+
+5. NO WARRANTY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
+PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS"
+BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR
+IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF
+TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR
+PURPOSE. Each Recipient is solely responsible for determining the
+appropriateness of using and distributing the Program and assumes all
+risks associated with its exercise of rights under this Agreement,
+including but not limited to the risks and costs of program errors,
+compliance with applicable laws, damage to or loss of data, programs
+or equipment, and unavailability or interruption of operations.
+
+6. DISCLAIMER OF LIABILITY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
+PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS
+SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST
+PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE
+EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+7. GENERAL
+
+If any provision of this Agreement is invalid or unenforceable under
+applicable law, it shall not affect the validity or enforceability of
+the remainder of the terms of this Agreement, and without further
+action by the parties hereto, such provision shall be reformed to the
+minimum extent necessary to make such provision valid and enforceable.
+
+If Recipient institutes patent litigation against any entity
+(including a cross-claim or counterclaim in a lawsuit) alleging that the
+Program itself (excluding combinations of the Program with other software
+or hardware) infringes such Recipient's patent(s), then such Recipient's
+rights granted under Section 2(b) shall terminate as of the date such
+litigation is filed.
+
+All Recipient's rights under this Agreement shall terminate if it
+fails to comply with any of the material terms or conditions of this
+Agreement and does not cure such failure in a reasonable period of
+time after becoming aware of such noncompliance. If all Recipient's
+rights under this Agreement terminate, Recipient agrees to cease use
+and distribution of the Program as soon as reasonably practicable.
+However, Recipient's obligations under this Agreement and any licenses
+granted by Recipient relating to the Program shall continue and survive.
+
+Everyone is permitted to copy and distribute copies of this Agreement,
+but in order to avoid inconsistency the Agreement is copyrighted and
+may only be modified in the following manner. The Agreement Steward
+reserves the right to publish new versions (including revisions) of
+this Agreement from time to time. No one other than the Agreement
+Steward has the right to modify this Agreement. The Eclipse Foundation
+is the initial Agreement Steward. The Eclipse Foundation may assign the
+responsibility to serve as the Agreement Steward to a suitable separate
+entity. Each new version of the Agreement will be given a distinguishing
+version number. The Program (including Contributions) may always be
+Distributed subject to the version of the Agreement under which it was
+received. In addition, after a new version of the Agreement is published,
+Contributor may elect to Distribute the Program (including its
+Contributions) under the new version.
+
+Except as expressly stated in Sections 2(a) and 2(b) above, Recipient
+receives no rights or licenses to the intellectual property of any
+Contributor under this Agreement, whether expressly, by implication,
+estoppel or otherwise. All rights in the Program not expressly granted
+under this Agreement are reserved. Nothing in this Agreement is intended
+to be enforceable by any entity that is not a Contributor or Recipient.
+No third-party beneficiary rights are created under this Agreement.
+
+Exhibit A - Form of Secondary Licenses Notice
+
+"This Source Code may also be made available under the following
+Secondary Licenses when the conditions for such availability set forth
+in the Eclipse Public License, v. 2.0 are satisfied: {name license(s),
+version(s), and exceptions or additional permissions here}."
+
+  Simply including a copy of this Agreement, including this Exhibit A
+  is not sufficient to license the Source Code under Secondary Licenses.
+
+  If it is not possible or desirable to put the notice in a particular
+  file, then You may include the notice in a location (such as a LICENSE
+  file in a relevant directory) where a recipient would be likely to
+  look for such a notice.
+
+  You may add additional accurate notices of copyright ownership.
+
+
+===== Expat =====
+
+Copyright (c) 1998-2000 Thai Open Source Software Center Ltd and Clark Cooper
+Copyright (c) 2001-2025 Expat maintainers
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+===== Emscripten =====
+
+Emscripten is available under 2 licenses, the MIT license and the
+University of Illinois/NCSA Open Source License.
+
+Both are permissive open source licenses, with little if any
+practical difference between them.
+
+The reason for offering both is that (1) the MIT license is
+well-known, while (2) the University of Illinois/NCSA Open Source
+License allows Emscripten's code to be integrated upstream into
+LLVM, which uses that license, should the opportunity arise.
+
+The full text of both licenses follows.
+
+==============================================================================
+
+Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+==============================================================================
+
+Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file.
+All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the
+"Software"), to deal with the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+    Redistributions of source code must retain the above copyright
+    notice, this list of conditions and the following disclaimers.
+
+    Redistributions in binary form must reproduce the above
+    copyright notice, this list of conditions and the following disclaimers
+    in the documentation and/or other materials provided with the
+    distribution.
+
+    Neither the names of Mozilla,
+    nor the names of its contributors may be used to endorse
+    or promote products derived from this Software without specific prior
+    written permission.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR
+ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE SOFTWARE.
+
+==============================================================================
+
+This program uses portions of Node.js source code located in src/library_path.js,
+in accordance with the terms of the MIT license. Node's license follows:
+
+    """
+        Copyright Joyent, Inc. and other Node contributors. All rights reserved.
+        Permission is hereby granted, free of charge, to any person obtaining a copy
+        of this software and associated documentation files (the "Software"), to
+        deal in the Software without restriction, including without limitation the
+        rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+        sell copies of the Software, and to permit persons to whom the Software is
+        furnished to do so, subject to the following conditions:
+
+        The above copyright notice and this permission notice shall be included in
+        all copies or substantial portions of the Software.
+
+        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+        FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+        IN THE SOFTWARE.
+    """
+
+The musl libc project is bundled in this repo, and it has the MIT license, see
+system/lib/libc/musl/COPYRIGHT
+
+The third_party/ subdirectory contains code with other licenses. None of it is
+used by default, but certain options use it (e.g., the optional closure compiler
+flag will run closure compiler from third_party/).
+
+
+===== DOMPurify =====
+
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

+ 5 - 1
packages/client/ui-primitives/package.json

@@ -45,11 +45,15 @@
     "mdast-util-gfm": "^3.1.0",
     "mdast-util-math": "^3.0.0",
     "micromark-extension-gfm": "^3.0.0",
-    "micromark-util-sanitize-uri": "^2.0.1"
+    "micromark-util-sanitize-uri": "^2.0.1",
+    "mermaid": "11.16.0",
+    "@viz-js/viz": "3.30.0",
+    "dompurify": "3.4.11"
   },
   "files": [
     "lib/index.js",
     "lib/**/*.css",
+    "THIRD_PARTY_PREVIEW_NOTICES.txt",
     "lib/types/**/*.d.ts"
   ],
   "peerDependencies": {

+ 84 - 0
packages/client/ui-primitives/src/markdown/CodeBlock.module.css

@@ -57,6 +57,7 @@
   display: flex;
   align-items: center;
   flex-shrink: 0;
+  gap: 12px;
 }
 
 .copyButton {
@@ -98,6 +99,89 @@
   color: var(--dsw-alias-label-primary);
 }
 
+/* Alternate content is a figure; its actions overlay instead of taking a title row. */
+.preview.showingPreview {
+  background: transparent;
+}
+
+.preview .bannerWrap {
+  position: absolute;
+  top: 8px;
+  right: 8px;
+  z-index: 1;
+  background: transparent;
+}
+
+.preview .banner {
+  padding: 2px;
+  background: var(--dsw-alias-bg-base);
+  border: 0;
+  border-radius: 8px;
+  box-shadow: var(--dsw-elevation-panel);
+}
+
+.preview .action {
+  gap: 2px;
+}
+
+.iconButton {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  border-radius: 6px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.iconButton span {
+  display: flex;
+}
+
+.iconButton:hover {
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.preview:not(.showingPreview) :where(pre) {
+  padding-top: 48px;
+}
+
+@media (hover: hover) and (pointer: fine) {
+  .preview .bannerWrap {
+    opacity: 0;
+    pointer-events: none;
+  }
+
+  .preview:hover .bannerWrap,
+  .preview:focus-within .bannerWrap {
+    opacity: 1;
+    pointer-events: auto;
+  }
+}
+
+@media (any-pointer: coarse) {
+  .preview {
+    padding-bottom: 56px;
+  }
+
+  .preview .bannerWrap {
+    top: auto;
+    bottom: 8px;
+    opacity: 1;
+    pointer-events: auto;
+  }
+
+  .preview .iconButton {
+    width: 44px;
+    height: 44px;
+  }
+
+  .preview:not(.showingPreview) :where(pre) {
+    padding-top: 16px;
+  }
+}
+
 .numbered :where(pre) code {
   display: block;
   counter-reset: source-line;

+ 42 - 11
packages/client/ui-primitives/src/markdown/CodeBlock.tsx

@@ -2,6 +2,8 @@ import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore
 import type { CSSProperties, ReactNode } from 'react'
 import clsx from 'clsx'
 import { writeClipboard } from '../clipboard.ts'
+import { Tooltip } from '../Tooltip.tsx'
+import { IconCheckOutline16, IconCodeOutline16, IconCopyOutline16 } from '../icons/index.tsx'
 import {
   StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
 } from './highlight.ts'
@@ -31,6 +33,8 @@ export interface CodeBlockProps {
   copyLabel: string
   /** Copy-button label during the post-copy confirmation window. */
   copiedLabel: string
+  /** Headerless settled preview with hover/focus actions; copying always retains the source text. */
+  preview?: { content: ReactNode; previewLabel: string; sourceLabel: string } | undefined
 }
 
 /**
@@ -59,7 +63,7 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
   )
 }
 
-export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) {
+export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel, preview }: CodeBlockProps) {
   const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
   const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
   const rootRef = useRef<HTMLDivElement>(null)
@@ -143,13 +147,13 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
     [streaming, highlighting, streamedBody, trimmed, lang, loaded],
   )
   const [copied, setCopied] = useState(false)
+  const [showSource, setShowSource] = useState(false)
+  const previewAvailable = preview !== undefined && streaming !== true
+  const showingPreview = previewAvailable && !showSource
 
   const onCopy = useCallback(() => {
     if (copied) return
-    /* v8 ignore next -- both arms always mount a <pre>; trimmed is the
-       typed fallback if the DOM shape ever diverges. */
-    const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
-    void writeClipboard(text).then((ok) => {
+    void writeClipboard(trimmed).then((ok) => {
       if (!ok) return
       setCopied(true)
       window.setTimeout(() => { setCopied(false) }, 1000)
@@ -171,8 +175,22 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
         <div dangerouslySetInnerHTML={{ __html: html }} />
       )
 
+  const copyActionLabel = copied ? copiedLabel : copyLabel
+  const copyAction = (
+    <button
+      type="button"
+      className={clsx(css.copyButton, previewAvailable && css.iconButton)}
+      aria-label={previewAvailable ? copyActionLabel : undefined}
+      onClick={onCopy}
+    >
+      {previewAvailable
+        ? <span aria-hidden="true">{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}</span>
+        : copyActionLabel}
+    </button>
+  )
+
   return (
-    <div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, className)}
+    <div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, previewAvailable && css.preview, showingPreview && css.showingPreview, className)}
       data-line-numbers={lineNumbers || undefined}
       style={sourceLines === undefined ? undefined : {
         '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
@@ -180,15 +198,28 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
       <div className={css.bannerWrap}>
         {/* The attribute carries no style here; owner stylesheets use it to pin the banner (sidebar code preview, horizontal scroll). */}
         <div className={css.banner} data-code-block-banner>
-          <div className={css.infostring}>{lang ?? ''}</div>
+          {!previewAvailable && <div className={css.infostring}>{lang ?? ''}</div>}
           <div className={css.action}>
-            <button type="button" className={css.copyButton} onClick={onCopy}>
-              {copied ? copiedLabel : copyLabel}
-            </button>
+            {previewAvailable && (
+              <Tooltip label={showingPreview ? preview.sourceLabel : preview.previewLabel} side="top">
+                <button
+                  type="button"
+                  className={clsx(css.copyButton, css.iconButton)}
+                  aria-label={showingPreview ? preview.sourceLabel : preview.previewLabel}
+                  onClick={() => { setShowSource(!showSource) }}
+                >
+                  <span aria-hidden="true"><IconCodeOutline16 /></span>
+                </button>
+              </Tooltip>
+            )}
+            {previewAvailable
+              ? <Tooltip label={copyActionLabel} side="top">{copyAction}</Tooltip>
+              : copyAction}
           </div>
         </div>
       </div>
-      {body}
+      {previewAvailable && <div hidden={!showingPreview}>{preview.content}</div>}
+      {!showingPreview && body}
     </div>
   )
 }

+ 35 - 0
packages/client/ui-primitives/src/markdown/SourcePreview.module.css

@@ -0,0 +1,35 @@
+.canvas {
+  overflow: auto;
+  padding: 16px;
+  background: var(--dsw-alias-markdown-code-block);
+  border-radius: 12px;
+}
+
+.diagram {
+  display: block;
+  max-width: 100%;
+  height: auto;
+  margin: 0 auto;
+}
+
+.status {
+  padding: 16px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.staging {
+  position: fixed;
+  top: 0;
+  left: -100000px;
+  visibility: hidden;
+  pointer-events: none;
+}
+
+.frame {
+  display: block;
+  width: 100%;
+  height: 400px;
+  border: 0;
+  border-radius: 12px;
+  background: var(--dsw-alias-markdown-code-block);
+}

+ 71 - 0
packages/client/ui-primitives/src/markdown/SourcePreview.tsx

@@ -0,0 +1,71 @@
+/** Read-only diagram preview with source fallback and per-source async ownership. */
+
+import { useEffect, useState } from 'react'
+import css from './SourcePreview.module.css'
+import { readPreviewTheme } from './preview-theme.ts'
+
+/** Localized preview states; the diagram source remains verbatim. */
+export interface PreviewLabels {
+  diagram: string
+  loading: string
+  error: string
+}
+
+type Result = { kind: 'ok'; code: string; src: string } | { kind: 'error'; code: string }
+
+/* v8 ignore next 3 -- closed-union backstop; only reached if a result is forged */
+function assertNever(value: never): never {
+  throw new Error(`unreachable preview result: ${String(value)}`)
+}
+
+/**
+ * Display a complete document or diagram, retaining the source when rendering fails.
+ * @param props - Source and complete localized labels. Source and document theme changes cancel obsolete renders.
+ * @returns A loading status, an inert image or sandboxed document, or an error with the original source.
+ */
+export function SourcePreview({ code, labels, render, document: isDocument = false }: {
+  code: string
+  labels: PreviewLabels
+  render: (code: string, signal: AbortSignal) => string | Promise<string>
+  document?: boolean
+}) {
+  const [result, setResult] = useState<Result | null>(null)
+  useEffect(() => {
+    let controller: AbortController | undefined
+    let palette: string | undefined
+    const update = () => {
+      const next = JSON.stringify(readPreviewTheme())
+      if (next === palette) return
+      palette = next
+      controller?.abort()
+      const current = new AbortController()
+      controller = current
+      void (async () => await render(code, current.signal))().then(
+        (src) => { if (!current.signal.aborted) setResult({ kind: 'ok', code, src }) },
+        () => { if (!current.signal.aborted) setResult({ kind: 'error', code }) },
+      )
+    }
+    // Isolated image/frame documents cannot inherit the host's CSS variables.
+    const observer = new MutationObserver(update)
+    observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
+    observer.observe(document.body, { attributes: true, attributeFilter: ['style', 'data-ds-dark-theme'] })
+    update()
+    return () => { observer.disconnect(); controller?.abort() }
+  }, [code, render])
+
+  if (result?.code !== code) return <div className={css.status} role="status">{labels.loading}</div>
+  switch (result.kind) {
+    case 'ok':
+      if (isDocument) return <iframe className={css.frame} title={labels.diagram} sandbox="" referrerPolicy="no-referrer" srcDoc={result.src} />
+      return <div className={css.canvas}><img className={css.diagram} src={result.src} alt={labels.diagram} /></div>
+    case 'error':
+      return (
+        <div>
+          <div className={css.status} role="status">{labels.error}</div>
+          <pre><code>{code}</code></pre>
+        </div>
+      )
+    /* v8 ignore next -- closed-union backstop; only reached if a result is forged */
+    default: return assertNever(result)
+  }
+}

+ 37 - 0
packages/client/ui-primitives/src/markdown/graphviz.ts

@@ -0,0 +1,37 @@
+/** Lazy Graphviz WebAssembly layout; generated SVG is displayed only as an inert image. */
+
+import type { instance, RenderOptions } from '@viz-js/viz'
+import { renderSvg } from './preview-document.ts'
+import { readPreviewTheme } from './preview-theme.ts'
+
+let runtime: ReturnType<typeof instance> | undefined
+
+/**
+ * Render Graphviz DOT with document-theme defaults; authored attributes override those defaults.
+ * @param code - Complete Graphviz DOT source.
+ * @param signal - Cancels work waiting for the runtime; synchronous layout cannot be interrupted.
+ * @param options - Native overrides; attribute maps merge over theme defaults and output remains SVG regardless of format.
+ * @returns An SVG image data URL. Import, layout, invalid source, and cancellation failures reject.
+ */
+export async function renderGraphviz(code: string, signal: AbortSignal, options?: RenderOptions): Promise<string> {
+  runtime ??= import('@viz-js/viz').then(module => module.instance()).catch((error: unknown) => {
+    runtime = undefined
+    throw error
+  })
+  const viz = await runtime
+  signal.throwIfAborted()
+  const palette = readPreviewTheme()
+  const color = (value: string): string => value.replace(
+    /^rgb\(\s*(\d+)[, ]+\s*(\d+)[, ]+\s*(\d+)\s*\)$/,
+    (_match, r: string, g: string, b: string) => `#${[r, g, b].map(channel => Number(channel).toString(16).padStart(2, '0')).join('')}`,
+  )
+  return renderSvg(viz.renderString(code, {
+    engine: 'dot', ...options, format: 'svg',
+    graphAttributes: { bgcolor: 'transparent', fontcolor: color(palette.text), ...options?.graphAttributes },
+    nodeAttributes: {
+      color: color(palette.border), fontcolor: color(palette.text), fillcolor: color(palette.surface), style: 'filled',
+      ...options?.nodeAttributes,
+    },
+    edgeAttributes: { color: color(palette.line), fontcolor: color(palette.text), ...options?.edgeAttributes },
+  }), signal)
+}

+ 97 - 0
packages/client/ui-primitives/src/markdown/mermaid.ts

@@ -0,0 +1,97 @@
+/** Lazy Mermaid runtime; each render owns and removes its temporary measurement DOM. */
+
+import type { Mermaid, MermaidConfig } from 'mermaid'
+import clsx from 'clsx'
+import css from './SourcePreview.module.css'
+import { readPreviewTheme } from './preview-theme.ts'
+
+let runtime: Promise<Mermaid> | undefined
+let nextDiagramId = 0
+let pending: Promise<unknown> = Promise.resolve()
+
+function loadMermaid(): Promise<Mermaid> {
+  runtime ??= import('mermaid').then(({ default: mermaid }) => mermaid).catch((error: unknown) => {
+    runtime = undefined
+    throw error
+  })
+  return runtime
+}
+
+function initialize(mermaid: Mermaid, config: MermaidConfig | undefined): void {
+  const palette = readPreviewTheme()
+  mermaid.initialize({
+    theme: 'base',
+    ...config,
+    startOnLoad: false,
+    securityLevel: 'strict',
+    suppressErrorRendering: true,
+    themeVariables: {
+      darkMode: palette.dark,
+      background: palette.background,
+      primaryColor: palette.surface,
+      primaryTextColor: palette.text,
+      primaryBorderColor: palette.border,
+      secondaryColor: palette.surface,
+      tertiaryColor: palette.surface,
+      lineColor: palette.line,
+      textColor: palette.text,
+      actorBkg: palette.surface,
+      actorBorder: palette.border,
+      actorTextColor: palette.text,
+      actorLineColor: palette.border,
+      signalColor: palette.line,
+      signalTextColor: palette.text,
+      labelBoxBkgColor: palette.surface,
+      labelTextColor: palette.text,
+      edgeLabelBackground: palette.background,
+      ...config?.themeVariables as Record<string, unknown> | undefined,
+    },
+    htmlLabels: false,
+    // Mermaid still accepts this legacy override alongside the global HTML-label setting.
+    // oxlint-disable-next-line typescript/no-deprecated
+    flowchart: { ...config?.flowchart, htmlLabels: false },
+    secure: [
+      'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges',
+      'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart',
+      ...(config?.secure ?? []),
+    ],
+  })
+}
+
+/**
+ * Render untrusted diagram source using the document palette, without installing SVG or link handlers in the UI.
+ * @param code - Complete Mermaid source.
+ * @param signal - Cancels work waiting for the runtime; an active Mermaid render finishes before cleanup.
+ * @param config - Native overrides of theme defaults; strict security, manual rendering, and disabled HTML labels remain fixed.
+ * @returns An SVG data URL. Import, parse, rendering, and cancellation failures reject.
+ */
+export async function renderMermaid(code: string, signal: AbortSignal, config?: MermaidConfig): Promise<string> {
+  const task = pending.then(() => renderOne(code, signal, config))
+  pending = task.catch(() => { /* The caller owns rejection; subsequent diagrams still render. */ })
+  return task
+}
+
+async function renderOne(code: string, signal: AbortSignal, config: MermaidConfig | undefined): Promise<string> {
+  const mermaid = await loadMermaid()
+  signal.throwIfAborted()
+  initialize(mermaid, config)
+  const stage = document.createElement('div')
+  stage.className = clsx(css.staging)
+  stage.setAttribute('aria-hidden', 'true')
+  document.body.append(stage)
+  try {
+    // Initialization and rendering share the queue because Mermaid owns global configuration.
+    const { svg } = await mermaid.render(`dsh-mermaid-${nextDiagramId++}`, code, stage)
+    const root = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement
+    const viewBox = root.getAttribute('viewBox')
+    if (viewBox !== null) {
+      // An SVG image needs intrinsic dimensions; Mermaid's percentage width is for inline SVG.
+      const [, , width, height] = viewBox.split(/\s+/) as [string, string, string, string]
+      root.setAttribute('width', width)
+      root.setAttribute('height', height)
+    }
+    return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(root))}`
+  } finally {
+    stage.remove()
+  }
+}

+ 44 - 0
packages/client/ui-primitives/src/markdown/preview-document.ts

@@ -0,0 +1,44 @@
+/** Static HTML documents and SVG image URLs without executable source markup. */
+
+import DOMPurify from 'dompurify'
+import { readPreviewTheme } from './preview-theme.ts'
+
+// This policy belongs to the preview document, before any source-controlled markup.
+const CSP = "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; base-uri 'none'; form-action 'none'"
+
+function documentOf(body: string): string {
+  const scheme = readPreviewTheme().dark ? 'dark' : 'light'
+  return `<!doctype html><html style="color-scheme:${scheme}"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${CSP}"></head><body>${body}</body></html>`
+}
+
+/**
+ * Prepare static HTML for an iframe without scripts, same-origin access, or navigation.
+ * @param code - Untrusted HTML document or fragment; inline styles and embedded images are retained.
+ * @param signal - Prevents preparation after the preview owner is cancelled.
+ * @returns A sanitized srcdoc document. Copying uses the original source, not this document.
+ */
+export function renderHtml(code: string, signal: AbortSignal): string {
+  signal.throwIfAborted()
+  const body = DOMPurify.sanitize(code, {
+    WHOLE_DOCUMENT: true,
+    FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'base', 'meta', 'link'],
+    FORBID_ATTR: ['href', 'xlink:href', 'action', 'formaction', 'target'],
+  })
+  return documentOf(body)
+}
+
+/**
+ * Prepare an SVG image URL without activating SVG scripts, links, or external resources.
+ * @param code - Complete SVG XML; malformed XML or a non-SVG root throws.
+ * @param signal - Prevents preparation after the preview owner is cancelled.
+ * @returns A data URL for an image whose intrinsic dimensions determine the preview height.
+ */
+export function renderSvg(code: string, signal: AbortSignal): string {
+  signal.throwIfAborted()
+  const parsed = new DOMParser().parseFromString(code, 'image/svg+xml')
+  if (parsed.querySelector('parsererror') !== null || parsed.documentElement.localName !== 'svg'
+    || parsed.documentElement.namespaceURI !== 'http://www.w3.org/2000/svg') {
+    throw new Error('Invalid SVG document')
+  }
+  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(code)}`
+}

+ 27 - 0
packages/client/ui-primitives/src/markdown/preview-theme.ts

@@ -0,0 +1,27 @@
+/** Resolved document colors used by isolated diagram renderers. */
+
+interface PreviewTheme {
+  dark: boolean
+  background: string
+  surface: string
+  text: string
+  line: string
+  border: string
+}
+
+/**
+ * Read the palette after the application's theme presenter updates the DOM.
+ * @returns Semantic preview colors and the resolved browser color scheme.
+ */
+export function readPreviewTheme(): PreviewTheme {
+  const style = getComputedStyle(document.body)
+  const color = (name: string): string => style.getPropertyValue(`--dsw-alias-${name}`).trim()
+  return {
+    dark: getComputedStyle(document.documentElement).colorScheme === 'dark',
+    background: color('markdown-code-block'),
+    surface: color('bg-layer-2'),
+    text: color('label-primary'),
+    line: color('label-secondary'),
+    border: color('label-tertiary'),
+  }
+}

+ 30 - 0
packages/client/ui-primitives/src/markdown/render.tsx

@@ -23,6 +23,11 @@ import type * as Md from 'mdast'
 import type {} from 'mdast-util-math'
 import { normalizeUri } from 'micromark-util-sanitize-uri'
 import { CodeBlock } from './CodeBlock.tsx'
+import { SourcePreview } from './SourcePreview.tsx'
+import { renderGraphviz } from './graphviz.ts'
+import { renderHtml, renderSvg } from './preview-document.ts'
+import { renderMermaid } from './mermaid.ts'
+import type { PreviewLabels } from './SourcePreview.tsx'
 import { renderTexToReact } from './katex.tsx'
 import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx'
 import type { PositionedBlock } from './incremental.ts'
@@ -40,6 +45,15 @@ export interface MarkdownCodeLabels {
 export interface MarkdownLabels {
   code: MarkdownCodeLabels
   footnotes: string
+  /** Opt into settled Mermaid, Graphviz, SVG, and static HTML previews with complete localized labels. */
+  preview?: {
+    mermaid: PreviewLabels
+    graphviz: PreviewLabels
+    svg: PreviewLabels
+    html: PreviewLabels
+    preview: string
+    source: string
+  }
 }
 
 function sanitizeUrl(url: string): string {
@@ -394,10 +408,26 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
       streaming={context.streaming}
       copyLabel={context.labels.code.copyLabel}
       copiedLabel={context.labels.code.copiedLabel}
+      preview={context.streaming ? undefined : fencePreview(lang, node.value, context.labels)}
     />
   )
 }
 
+/** Resolve the supported fence language without treating arbitrary HTML in Markdown as a preview. */
+function fencePreview(lang: string | undefined, code: string, labels: MarkdownLabels) {
+  if (labels.preview === undefined) return undefined
+  const kind = lang === 'dot' ? 'graphviz' : lang
+  if (kind !== 'mermaid' && kind !== 'graphviz' && kind !== 'svg' && kind !== 'html') return undefined
+  const render = { mermaid: renderMermaid, graphviz: renderGraphviz, svg: renderSvg, html: renderHtml }[kind]
+  return {
+    content: (
+      <SourcePreview key={kind} code={code} labels={labels.preview[kind]} render={render} document={kind === 'html'} />
+    ),
+    previewLabel: labels.preview.preview,
+    sourceLabel: labels.preview.source,
+  }
+}
+
 /** A list is loose when it or any of its items is spread; every item then keeps its paragraphs. */
 function listLoose(list: Md.List): boolean {
   return (list.spread ?? false) || list.children.some(listItemLoose)

+ 1 - 1
packages/client/ui-primitives/tests/code-block.client.spec.tsx

@@ -122,7 +122,7 @@ describe('CodeBlock', () => {
     expect(view.getByText('plain text')).toBeTruthy()
   })
 
-  it('shows the language banner and copies the pre textContent', async () => {
+  it('shows the language banner and copies the displayed source text', async () => {
     vi.useFakeTimers()
     const writeText = vi.fn().mockResolvedValue(undefined)
     Object.defineProperty(navigator, 'clipboard', {

+ 126 - 0
packages/client/ui-primitives/tests/fence-previews.client.spec.tsx

@@ -0,0 +1,126 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { MarkdownText } from '../src/markdown/MarkdownText.tsx'
+import { renderGraphviz } from '../src/markdown/graphviz.ts'
+import { renderHtml, renderSvg } from '../src/markdown/preview-document.ts'
+import { markdownLabels } from './labels.client.ts'
+
+const status = { loading: 'Loading', error: 'Cannot preview' }
+const preview = {
+  mermaid: { ...status, diagram: 'Mermaid diagram' },
+  graphviz: { ...status, diagram: 'Graphviz diagram' },
+  svg: { ...status, diagram: 'SVG preview' },
+  html: { ...status, diagram: 'HTML preview' },
+  preview: 'Preview', source: 'Source',
+}
+const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"><text y="30">示例</text></svg>'
+const cases = [
+  ['graphviz', 'digraph { Input -> Preview }', preview.graphviz.diagram],
+  ['dot', 'digraph { Input -> Preview }', preview.graphviz.diagram],
+  ['svg', svg, preview.svg.diagram],
+  ['html', '<style>h1 { color: green }</style><h1>Example</h1>', preview.html.diagram],
+] as const
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals() })
+
+describe('Markdown fence previews', () => {
+  it('renders real DOT', async () => { await renderGraphviz('digraph { Input -> Preview }', new AbortController().signal) })
+  it.each(cases)('settles %s into an isolated default preview and copies original source in both views', async (lang, code, title) => {
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    vi.stubGlobal('navigator', Object.create(navigator, { clipboard: { value: { writeText } } }))
+    const props = { text: `\`\`\`${lang}\n${code}\n\`\`\``, labels: { ...markdownLabels, preview } }
+    const view = render(<MarkdownText {...props} streaming />)
+    expect(view.container.querySelector('iframe')).toBeNull()
+    expect(view.container.querySelector('pre code')?.textContent).toBe(code)
+    view.rerender(<MarkdownText {...props} />)
+    const findPreview = async () => lang === 'html' ? await screen.findByTitle(title) : await screen.findByRole('img', { name: title })
+    const element = await findPreview()
+    if (lang === 'html') {
+      expect(element.getAttribute('sandbox')).toBe('')
+      expect(element.getAttribute('referrerpolicy')).toBe('no-referrer')
+      expect(element.getAttribute('srcdoc')).toContain('Content-Security-Policy')
+    } else {
+      expect(element.getAttribute('src')).toMatch(/^data:image\/svg\+xml/)
+      expect(view.container.querySelector('iframe')).toBeNull()
+      expect(element.parentElement!.querySelector('svg')).toBeNull()
+    }
+    expect(view.container.querySelector('pre code')).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: 'Source' }))
+    expect(view.container.querySelector('pre code')?.textContent).toBe(code)
+    expect(element.closest('[hidden]')).not.toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel }))
+    await waitFor(() => { expect(writeText).toHaveBeenCalledWith(code) })
+    fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
+    expect(await findPreview()).toBe(element)
+    expect(element.closest('[hidden]')).toBeNull()
+    view.unmount()
+    render(<MarkdownText {...props} />)
+    await findPreview()
+    fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel }))
+    await waitFor(() => { expect(writeText).toHaveBeenCalledTimes(2) })
+  })
+
+  it.each(['svg', 'graphviz'])('retains invalid %s source and recovers after replacement', async (lang) => {
+    const view = render(<MarkdownText text={`\`\`\`${lang}\nbroken\n\`\`\``} labels={{ ...markdownLabels, preview }} />)
+    await screen.findByText(status.error)
+    expect(view.container.querySelector('pre code')?.textContent).toBe('broken')
+    view.rerender(<MarkdownText text={`\`\`\`svg\n${svg}\n\`\`\``} labels={{ ...markdownLabels, preview }} />)
+    await screen.findByRole('img', { name: preview.svg.diagram })
+    expect(screen.queryByText(status.error)).toBeNull()
+  })
+
+  it('requires an opted-in code fence and leaves raw HTML and other languages unrendered', () => {
+    const view = render(<MarkdownText text={'```html\n<h1>Example</h1>\n```'} labels={markdownLabels} />)
+    expect(view.container.querySelector('pre code')?.textContent).toBe('<h1>Example</h1>')
+    view.rerender(<MarkdownText text={'<h1>Example</h1>\n\n```xml\n<node />\n```'} labels={{ ...markdownLabels, preview }} />)
+    expect(view.container.querySelector('iframe')).toBeNull()
+    expect(view.container.querySelector('h1')).toBeNull()
+  })
+})
+
+describe('static preview documents', () => {
+  it.each(['light', 'dark'])('uses the document %s color scheme for HTML previews', (scheme) => {
+    const previous = document.documentElement.style.colorScheme
+    try {
+      document.documentElement.style.colorScheme = scheme
+      const doc = renderHtml('<h1>Example</h1>', new AbortController().signal)
+      const parsed = new DOMParser().parseFromString(doc, 'text/html')
+      expect(parsed.documentElement.style.colorScheme).toBe(scheme)
+    } finally {
+      document.documentElement.style.colorScheme = previous
+    }
+  })
+
+  it('preserves HTML styling while removing scripts, navigation, and nested documents', () => {
+    const doc = renderHtml(`<!doctype html><html><head><style>h1 { color: green }</style>
+      <meta http-equiv="refresh" content="0;url=https://example.com"><base href="https://example.com"></head>
+      <body onload="alert(1)"><h1>Example</h1><script>alert(1)</script>
+      <a href="https://example.com">Link</a><iframe srcdoc="unsafe"></iframe>
+      <form action="https://example.com"><button formaction="https://example.com">Submit</button></form></body></html>`, new AbortController().signal)
+    const parsed = new DOMParser().parseFromString(doc, 'text/html')
+    expect(parsed.querySelector('h1')?.textContent).toBe('Example')
+    expect(parsed.querySelector('style')?.textContent).toBe('h1 { color: green }')
+    expect(parsed.querySelector('script, iframe, base, [href], [action], [formaction], [onload]')).toBeNull()
+    expect(parsed.querySelectorAll('meta[http-equiv]').length).toBe(1)
+    expect(parsed.querySelector('meta[http-equiv]')?.getAttribute('content')).toContain("default-src 'none'")
+  })
+
+  it('encodes SVG as an image rather than executable document markup', () => {
+    const code = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert("test")</script></svg>'
+    const url = renderSvg(code, new AbortController().signal)
+    expect(url).toMatch(/^data:image\/svg\+xml;charset=utf-8,/)
+    expect(decodeURIComponent(url.slice(url.indexOf(',') + 1))).toBe(code)
+  })
+
+  it.each(['<html/>', '<svg/>', '<svg xmlns="http://www.w3.org/2000/svg"><g></svg>'])('rejects non-SVG or malformed XML: %s', (code) => {
+    expect(() => renderSvg(code, new AbortController().signal)).toThrow('Invalid SVG document')
+  })
+
+  it.each([renderHtml, renderSvg])('does not prepare a cancelled document', (renderDocument) => {
+    const controller = new AbortController()
+    controller.abort()
+    expect(() => renderDocument(svg, controller.signal)).toThrow(controller.signal.reason)
+  })
+})

+ 67 - 0
packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts

@@ -0,0 +1,67 @@
+// @vitest-environment jsdom
+
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+
+const instance = vi.fn()
+beforeEach(() => {
+  vi.resetModules()
+  instance.mockReset()
+  vi.doMock('@viz-js/viz', () => ({ instance }))
+})
+afterEach(() => { vi.doUnmock('@viz-js/viz') })
+
+it('retries failed initialization and reuses the loaded runtime', async () => {
+  const renderString = vi.fn().mockReturnValue('<svg xmlns="http://www.w3.org/2000/svg"/>')
+  instance.mockRejectedValueOnce(new Error('WASM unavailable')).mockResolvedValue({ renderString })
+  const { renderGraphviz } = await import('../src/markdown/graphviz.ts')
+  await expect(renderGraphviz('digraph {}', new AbortController().signal)).rejects.toThrow('WASM unavailable')
+  await expect(renderGraphviz('digraph {}', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
+  await renderGraphviz('digraph {}', new AbortController().signal)
+  expect(instance).toHaveBeenCalledTimes(2)
+})
+
+it('skips layout after cancellation during runtime loading', async () => {
+  const pending = Promise.withResolvers<{ renderString: ReturnType<typeof vi.fn> }>()
+  instance.mockReturnValue(pending.promise)
+  const { renderGraphviz } = await import('../src/markdown/graphviz.ts')
+  const controller = new AbortController()
+  const result = renderGraphviz('digraph {}', controller.signal)
+  const rejection = expect(result).rejects.toMatchObject({ name: 'AbortError' })
+  controller.abort()
+  const renderString = vi.fn()
+  pending.resolve({ renderString })
+  await rejection
+  expect(renderString).not.toHaveBeenCalled()
+})
+
+it('uses theme defaults without rewriting authored DOT attributes', async () => {
+  const previous = document.body.getAttribute('style')
+  try {
+    document.body.style.setProperty('--dsw-alias-label-primary', 'rgb(230, 231, 232)')
+    document.body.style.setProperty('--dsw-alias-label-secondary', '#aabbcc')
+    document.body.style.setProperty('--dsw-alias-label-tertiary', '#8899aa')
+    document.body.style.setProperty('--dsw-alias-bg-layer-2', '#222222')
+    const renderString = vi.fn().mockReturnValue('<svg xmlns="http://www.w3.org/2000/svg"/>')
+    instance.mockResolvedValue({ renderString })
+    const { renderGraphviz } = await import('../src/markdown/graphviz.ts')
+    const code = 'digraph { a [color="red"]; a -> b }'
+    await renderGraphviz(code, new AbortController().signal)
+    expect(renderString).toHaveBeenCalledWith(code, expect.objectContaining({
+      nodeAttributes: { color: '#8899aa', fontcolor: '#e6e7e8', fillcolor: '#222222', style: 'filled' },
+      edgeAttributes: { color: '#aabbcc', fontcolor: '#e6e7e8' },
+    }))
+    await renderGraphviz(code, new AbortController().signal, {
+      engine: 'neato', format: 'plain', yInvert: true,
+      graphAttributes: { ranksep: 1 }, nodeAttributes: { shape: 'box' }, edgeAttributes: { color: 'red' },
+    })
+    expect(renderString).toHaveBeenLastCalledWith(code, {
+      engine: 'neato', format: 'svg', yInvert: true,
+      graphAttributes: { bgcolor: 'transparent', fontcolor: '#e6e7e8', ranksep: 1 },
+      nodeAttributes: { color: '#8899aa', fontcolor: '#e6e7e8', fillcolor: '#222222', style: 'filled', shape: 'box' },
+      edgeAttributes: { color: 'red', fontcolor: '#e6e7e8' },
+    })
+  } finally {
+    if (previous === null) document.body.removeAttribute('style')
+    else document.body.setAttribute('style', previous)
+  }
+})

+ 133 - 0
packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts

@@ -0,0 +1,133 @@
+// @vitest-environment jsdom
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const initialize = vi.fn()
+const renderDiagram = vi.fn()
+
+beforeEach(() => {
+  vi.resetModules()
+  vi.resetAllMocks()
+  vi.doMock('mermaid', () => ({ default: { initialize, render: renderDiagram } }))
+})
+afterEach(() => { vi.doUnmock('mermaid') })
+
+describe('Mermaid runtime', () => {
+  it('allows another attempt after the runtime import fails', async () => {
+    vi.doMock('mermaid', () => { throw new Error('runtime unavailable') })
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    await expect(renderMermaid('first', new AbortController().signal)).rejects.toThrow()
+    expect(initialize).not.toHaveBeenCalled()
+    vi.doMock('mermaid', () => ({ default: { initialize, render: renderDiagram } }))
+    renderDiagram.mockResolvedValue({ svg: '<svg/>' })
+    await expect(renderMermaid('retry', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
+    expect(renderDiagram).toHaveBeenCalledOnce()
+  })
+
+  it('accepts native configuration without weakening preview restrictions', async () => {
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    renderDiagram.mockResolvedValue({ svg: '<svg/>' })
+    await renderMermaid('flowchart LR', new AbortController().signal, {
+      // oxlint-disable-next-line typescript/no-deprecated
+      layout: 'dagre', flowchart: { nodeSpacing: 40, htmlLabels: true },
+      themeVariables: { primaryColor: '#123456' },
+      securityLevel: 'loose', startOnLoad: true, suppressErrorRendering: false, htmlLabels: true, secure: [],
+    })
+    expect(initialize.mock.calls[0]![0]).toMatchObject({
+      layout: 'dagre', flowchart: { nodeSpacing: 40, htmlLabels: false },
+      themeVariables: { primaryColor: '#123456', darkMode: false },
+      securityLevel: 'strict', startOnLoad: false, suppressErrorRendering: true, htmlLabels: false,
+    })
+    expect(initialize.mock.calls[0]![0]).toHaveProperty('secure', expect.arrayContaining(['securityLevel']))
+  })
+
+  it('reuses the runtime and removes each measurement container after rendering', async () => {
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    expect(initialize).not.toHaveBeenCalled()
+    const stages: HTMLElement[] = []
+    const ids: string[] = []
+    renderDiagram.mockImplementation(async (id: string, code: string, stage: HTMLElement) => {
+      expect(stage.isConnected).toBe(true)
+      expect(stage.getAttribute('aria-hidden')).toBe('true')
+      stages.push(stage)
+      ids.push(id)
+      return { svg: `<svg>${code}</svg>` }
+    })
+    const results = await Promise.all([
+      renderMermaid('中文', new AbortController().signal),
+      renderMermaid('second', new AbortController().signal),
+    ])
+    expect(initialize).toHaveBeenCalledTimes(2)
+    expect(initialize).toHaveBeenCalledWith(expect.objectContaining({
+      startOnLoad: false, securityLevel: 'strict', suppressErrorRendering: true, htmlLabels: false,
+      secure: [
+        'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges',
+        'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart',
+      ],
+    }))
+    expect(results.map(url => decodeURIComponent(url.split(',')[1]!))).toEqual(['<svg>中文</svg>', '<svg>second</svg>'])
+    expect(new Set(ids).size).toBe(2)
+    expect(stages.every(stage => !stage.isConnected)).toBe(true)
+  })
+
+  it('keeps theme initialization with its render and recovers the queue after failure', async () => {
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    const first = Promise.withResolvers<{ svg: string }>()
+    renderDiagram.mockReturnValueOnce(first.promise).mockResolvedValue({ svg: '<svg/>' })
+    const light = renderMermaid('first', new AbortController().signal)
+    const failure = expect(light).rejects.toThrow('invalid')
+    await vi.waitFor(() => { expect(renderDiagram).toHaveBeenCalledOnce() })
+    const previous = document.documentElement.style.colorScheme
+    try {
+      document.documentElement.style.colorScheme = 'dark'
+      const dark = renderMermaid('second', new AbortController().signal)
+      await Promise.resolve()
+      expect(initialize).toHaveBeenCalledOnce()
+      first.reject(new Error('invalid'))
+      await failure
+      await dark
+      expect(initialize.mock.calls[1]![0]).toMatchObject({ themeVariables: { darkMode: true } })
+    } finally {
+      document.documentElement.style.colorScheme = previous
+    }
+  })
+
+  it('removes measurement DOM even when Mermaid rejects', async () => {
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    let stage: HTMLElement | undefined
+    const error = new Error('bad diagram')
+    renderDiagram.mockImplementation(async (_id: string, _code: string, target: HTMLElement) => {
+      stage = target
+      target.innerHTML = '<svg>partial render</svg>'
+      throw error
+    })
+    await expect(renderMermaid('bad', new AbortController().signal)).rejects.toBe(error)
+    expect(stage?.isConnected).toBe(false)
+  })
+
+  it('preserves intrinsic diagram size instead of stretching percentage-width SVG images', async () => {
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    renderDiagram.mockResolvedValue({ svg: '<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 420 180"/>' })
+    const url = await renderMermaid('flowchart LR', new AbortController().signal)
+    const svg = new DOMParser().parseFromString(decodeURIComponent(url.split(',')[1]!), 'image/svg+xml').documentElement
+    expect(svg.getAttribute('width')).toBe('420')
+    expect(svg.getAttribute('height')).toBe('180')
+  })
+
+  it('does not start cancelled work after loading the runtime', async () => {
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    const controller = new AbortController()
+    controller.abort()
+    await expect(renderMermaid('unused', controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
+    expect(renderDiagram).not.toHaveBeenCalled()
+  })
+
+  it('allows another attempt after runtime initialization fails', async () => {
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    initialize.mockImplementationOnce(() => { throw new Error('initialization failed') })
+    await expect(renderMermaid('first', new AbortController().signal)).rejects.toThrow('initialization failed')
+    renderDiagram.mockResolvedValue({ svg: '<svg/>' })
+    await expect(renderMermaid('retry', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
+    expect(initialize).toHaveBeenCalledTimes(2)
+  })
+})

+ 162 - 0
packages/client/ui-primitives/tests/source-preview.client.spec.tsx

@@ -0,0 +1,162 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { SourcePreview } from '../src/markdown/SourcePreview.tsx'
+import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
+import { renderMermaid } from '../src/markdown/mermaid.ts'
+import { MarkdownText } from '../src/markdown/MarkdownText.tsx'
+import { markdownLabels } from './labels.client.ts'
+
+vi.mock('../src/markdown/mermaid.ts', () => ({ renderMermaid: vi.fn() }))
+
+const labels = {
+  diagram: 'Mermaid diagram', loading: 'Rendering diagram…', error: 'Unable to render this diagram.',
+  preview: 'Preview', source: 'Source',
+}
+const preview = {
+  preview: labels.preview, source: labels.source,
+  mermaid: labels, graphviz: labels, svg: labels, html: labels,
+}
+const source = 'flowchart LR\n  A[Input] --> B[Preview]'
+const imageUrl = 'data:image/svg+xml;charset=utf-8,%3Csvg%2F%3E'
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals() })
+beforeEach(() => { vi.resetAllMocks() })
+
+describe('SourcePreview', () => {
+  it('updates mounted previews on theme changes and ignores obsolete completions', async () => {
+    const previous = document.documentElement.style.colorScheme
+    const old = Promise.withResolvers<string>()
+    vi.mocked(renderMermaid).mockReturnValueOnce(old.promise).mockResolvedValue(imageUrl)
+    const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
+    try {
+      const oldSignal = vi.mocked(renderMermaid).mock.calls[0]![1]
+      await act(async () => { document.documentElement.style.colorScheme = 'dark' })
+      expect(oldSignal.aborted).toBe(true)
+      const image = await screen.findByRole('img')
+      await act(async () => { old.resolve('obsolete') })
+      expect(image.getAttribute('src')).toBe(imageUrl)
+      await act(async () => { document.body.style.setProperty('--unrelated', '1') })
+      expect(renderMermaid).toHaveBeenCalledTimes(2)
+      await act(async () => { document.documentElement.style.colorScheme = 'light' })
+      expect(renderMermaid).toHaveBeenCalledTimes(3)
+      view.unmount()
+      await act(async () => { document.documentElement.style.colorScheme = 'dark' })
+      expect(renderMermaid).toHaveBeenCalledTimes(3)
+    } finally {
+      view.unmount()
+      document.documentElement.style.colorScheme = previous
+      document.body.style.removeProperty('--unrelated')
+    }
+  })
+  it('shows loading until rendering completes, then displays an image without inserting SVG', async () => {
+    const pending = Promise.withResolvers<string>()
+    vi.mocked(renderMermaid).mockReturnValue(pending.promise)
+    const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
+    expect(screen.getByRole('status').textContent).toBe(labels.loading)
+    expect(screen.queryByRole('img')).toBeNull()
+    await act(async () => { pending.resolve(imageUrl) })
+    expect(screen.getByRole('img', { name: labels.diagram }).getAttribute('src')).toBe(imageUrl)
+    expect(view.container.querySelector('svg')).toBeNull()
+    expect(screen.queryByRole('status')).toBeNull()
+  })
+
+  it('retains invalid source and recovers when the source changes', async () => {
+    vi.mocked(renderMermaid).mockRejectedValueOnce(new Error('Parse error'))
+    const view = render(<SourcePreview render={renderMermaid} code="invalid" labels={labels} />)
+    expect((await screen.findByText(labels.error)).getAttribute('role')).toBe('status')
+    expect(view.container.querySelector('pre code')?.textContent).toBe('invalid')
+    const next = Promise.withResolvers<string>()
+    vi.mocked(renderMermaid).mockReturnValue(next.promise)
+    view.rerender(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
+    expect(screen.getByRole('status').textContent).toBe(labels.loading)
+    expect(view.container.querySelector('pre')).toBeNull()
+    await act(async () => { next.resolve(imageUrl) })
+    expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
+  })
+
+  it.each(['resolve', 'reject'] as const)('ignores a stale %s after a newer source finishes', async (outcome) => {
+    const old = Promise.withResolvers<string>()
+    const next = Promise.withResolvers<string>()
+    vi.mocked(renderMermaid).mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise)
+    const view = render(<SourcePreview render={renderMermaid} code="old" labels={labels} />)
+    const oldSignal = vi.mocked(renderMermaid).mock.calls[0]![1]
+    view.rerender(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
+    expect(oldSignal.aborted).toBe(true)
+    await act(async () => { next.resolve(imageUrl) })
+    await act(async () => {
+      if (outcome === 'resolve') old.resolve('obsolete-image')
+      else old.reject(new Error('obsolete-error'))
+    })
+    expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
+    expect(screen.queryByRole('status')).toBeNull()
+  })
+
+  it('cancels the owner when unmounted while loading', async () => {
+    const pending = Promise.withResolvers<string>()
+    vi.mocked(renderMermaid).mockReturnValue(pending.promise)
+    const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
+    const signal = vi.mocked(renderMermaid).mock.calls[0]![1]
+    view.unmount()
+    expect(signal.aborted).toBe(true)
+    await act(async () => { pending.reject(new Error('cancelled')) })
+    expect(screen.queryByRole('img')).toBeNull()
+  })
+})
+
+describe('Markdown Mermaid fences', () => {
+  it('retains a preview when switching to numbered source', async () => {
+    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
+    const view = render(<CodeBlock code={source} lang="mermaid" lineNumbers {...markdownLabels.code}
+      preview={{
+        content: <SourcePreview render={renderMermaid} code={source} labels={labels} />,
+        previewLabel: labels.preview, sourceLabel: labels.source,
+      }} />)
+    const diagram = await screen.findByRole('img', { name: labels.diagram })
+    expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: labels.source }))
+    expect([...view.container.querySelectorAll('code > .line')].map(line => line.textContent)).toEqual(source.split('\n'))
+    expect(diagram.isConnected).toBe(true)
+    expect(screen.queryByRole('img')).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: labels.preview }))
+    expect(screen.getByRole('img', { name: labels.diagram })).toBe(diagram)
+    expect(renderMermaid).toHaveBeenCalledOnce()
+  })
+
+  it('keeps streaming source literal, then offers preview, source and source copying', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    vi.stubGlobal('navigator', Object.create(navigator, { clipboard: { value: { writeText } } }))
+    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
+    const props = { text: `\`\`\`mermaid\n${source}\n\`\`\``, labels: { ...markdownLabels, preview } }
+    const view = render(<MarkdownText {...props} streaming />)
+    expect(view.container.querySelector('pre code')?.textContent).toBe(source)
+    expect(renderMermaid).not.toHaveBeenCalled()
+    expect(screen.queryByRole('button', { name: labels.source })).toBeNull()
+    view.rerender(<MarkdownText {...props} />)
+    const diagram = await screen.findByRole('img', { name: labels.diagram })
+    expect(screen.queryByText('mermaid')).toBeNull()
+    expect(screen.getByRole('button', { name: labels.source }).textContent).toBe('')
+    expect(screen.getByRole('button', { name: markdownLabels.code.copyLabel }).textContent).toBe('')
+    fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel }))
+    await screen.findByRole('button', { name: markdownLabels.code.copiedLabel })
+    expect(writeText).toHaveBeenCalledWith(source)
+    fireEvent.click(screen.getByRole('button', { name: labels.source }))
+    expect(view.container.querySelector('pre code')?.textContent).toBe(source)
+    expect(screen.queryByRole('img')).toBeNull()
+    expect(diagram.isConnected).toBe(true)
+    expect(screen.queryByText('mermaid')).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: labels.preview }))
+    expect(screen.getByRole('img', { name: labels.diagram })).toBe(diagram)
+    expect(screen.queryByRole('status')).toBeNull()
+    expect(renderMermaid).toHaveBeenCalledOnce()
+  })
+
+  it('leaves other languages and consumers without preview labels as code', () => {
+    const view = render(<MarkdownText text={`\`\`\`mermaid\n${source}\n\`\`\``} labels={markdownLabels} />)
+    expect(view.container.querySelector('pre code')?.textContent).toBe(source)
+    view.rerender(<MarkdownText text={'```text\nflowchart LR\n```'} labels={{ ...markdownLabels, preview }} />)
+    expect(view.container.querySelector('pre code')?.textContent).toBe('flowchart LR')
+    expect(renderMermaid).not.toHaveBeenCalled()
+  })
+})

+ 14 - 0
pnpm-lock.yaml

@@ -3327,12 +3327,18 @@ importers:
       '@types/react-dom':
         specifier: ~18.3.0
         version: 18.3.7(@types/react@18.3.31)
+      '@viz-js/viz':
+        specifier: 3.30.0
+        version: 3.30.0
       anser:
         specifier: ^2.3.5
         version: 2.3.5
       clsx:
         specifier: ^2.0.0
         version: 2.1.1
+      dompurify:
+        specifier: 3.4.11
+        version: 3.4.11
       katex:
         specifier: ^0.16.47
         version: 0.16.47
@@ -3345,6 +3351,9 @@ importers:
       mdast-util-math:
         specifier: ^3.0.0
         version: 3.0.0
+      mermaid:
+        specifier: 11.16.0
+        version: 11.16.0
       micromark-core-commonmark:
         specifier: ^2.0.3
         version: 2.0.3
@@ -14286,6 +14295,9 @@ packages:
   '@vitest/utils@4.1.8':
     resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
 
+  '@viz-js/viz@3.30.0':
+    resolution: {integrity: sha512-2zmcP55QY44uQfZ+GvgNHmGd8S6KUD+2eMur9AbYybSzrOd/m8nMUg0ZircJNiJRKGgVBKAE8kh0hAwpUFUFQQ==}
+
   '@vscode/ripgrep-darwin-arm64@1.18.0':
     resolution: {integrity: sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==}
     cpu: [arm64]
@@ -20895,6 +20907,8 @@ snapshots:
       convert-source-map: 2.0.0
       tinyrainbow: 3.1.0
 
+  '@viz-js/viz@3.30.0': {}
+
   '@vscode/ripgrep-darwin-arm64@1.18.0':
     optional: true
 

+ 14 - 0
scripts/check-workspace-constraints.spec.ts

@@ -3,6 +3,7 @@
 import { describe, expect, it } from 'vitest'
 import {
   checkDshFamilyVersion,
+  checkWorkspaceManifest,
   checkExperimentalDependencyIsolation,
   checkExperimentalManifest,
   expectedDshPackageFiles,
@@ -143,3 +144,16 @@ describe('package payload constraints', () => {
     ])
   })
 })
+
+it('requires preview license notices in the published UI primitives payload', () => {
+  const name = '@deepseek-ai/dsh-client-ui-primitives'
+  const files = expectedDshPackageFiles({ name })
+  expect(files).toContain('THIRD_PARTY_PREVIEW_NOTICES.txt')
+  const check = (entries: readonly string[]) => checkWorkspaceManifest({
+    dir: 'packages/client/ui-primitives', manifest: { name, files: [...entries] },
+  }).filter(message => message.includes('package.json files'))
+  expect(check(files)).toEqual([])
+  expect(check(files.filter(file => file !== 'THIRD_PARTY_PREVIEW_NOTICES.txt'))).toEqual([
+    expect.stringContaining('package.json files must be'),
+  ])
+})

+ 1 - 1
scripts/check-workspace-constraints.ts

@@ -155,7 +155,7 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
   // them through its own CSS pipeline, so the sheets are published artifacts.
   // The glob covers whichever sheets a package emits; sourcemaps stay
   // unpublished, as everywhere else in the repository.
-  '@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css'],
+  '@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css', 'THIRD_PARTY_PREVIEW_NOTICES.txt'],
   '@deepseek-ai/dsh-client-ui-dockkit': ['lib/**/*.css'],
   '@deepseek-ai/dsh-client-web': ['lib/**/*.css'],
   '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],

+ 24 - 0
scripts/gen-third-party-notices.spec.ts

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
 import { describe, expect, it } from 'vitest'
 import {
   CLAUDE_AGENT_SDK_PACKAGE,
+  assertPreviewDistribution,
   assertRuntimeLicenses,
   claudeDistributionFromManifest,
   collectPythonDependencies,
@@ -395,3 +396,26 @@ describe('manifestPatterns', () => {
     ])
   })
 })
+
+describe('preview distribution licenses', () => {
+  const dependencies = { mermaid: '11.16.0', '@viz-js/viz': '3.30.0', dompurify: '3.4.11' }
+  const sources = [
+    'pkg:docker/emscripten/emsdk@5.0.7?platform=linux%2Famd64',
+    'https://github.com/libexpat/libexpat/releases/download/R_2_8_4/expat-2.8.4.tar.gz',
+    'https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz',
+  ]
+  it('accepts the reviewed wrapper and native sources', () => {
+    expect(() => { assertPreviewDistribution(dependencies, sources) }).not.toThrow()
+    const notices = readFileSync(resolve(root, 'packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt'), 'utf8')
+    expect(notices).toContain('Eclipse Public License - v 2.0')
+    expect(notices).toContain(sources[2])
+    expect(notices).toContain(readFileSync(resolve(root, 'packages/client/ui-primitives/node_modules/mermaid/LICENSE'), 'utf8').trim())
+    expect(notices).toContain(readFileSync(resolve(root, 'packages/client/ui-primitives/node_modules/dompurify/LICENSE'), 'utf8').trim())
+  })
+  it.each(Object.keys(dependencies))('rejects an unreviewed %s upgrade', (name) => {
+    expect(() => { assertPreviewDistribution({ ...dependencies, [name]: '99.0.0' }, sources) }).toThrow('preview notices: review')
+  })
+  it.each([sources.slice(1), [...sources, 'https://example.com/new-library'], [...sources.slice(0, 2), 'graphviz-next']])('rejects changed embedded sources', (...uris) => {
+    expect(() => { assertPreviewDistribution(dependencies, uris) }).toThrow('native sources')
+  })
+})

+ 34 - 0
scripts/gen-third-party-notices.ts

@@ -685,6 +685,28 @@ ${rows.join('\n')}
 `
 }
 
+/**
+ * Require renewed license review when a preview dependency or embedded source changes.
+ * @param dependencies - UI primitives' browser build dependencies.
+ * @param sources - Native source URIs from the installed Viz.js build provenance.
+ * @returns Nothing; throws when a version or native source differs from the reviewed distribution.
+ */
+export function assertPreviewDistribution(dependencies: Record<string, string>, sources: readonly string[]): void {
+  for (const [name, version] of Object.entries({ mermaid: '11.16.0', '@viz-js/viz': '3.30.0', dompurify: '3.4.11' })) {
+    if (dependencies[name] !== version) {
+      throw new Error(`preview notices: review ${name} ${String(dependencies[name])} licenses and update THIRD_PARTY_PREVIEW_NOTICES.txt`)
+    }
+  }
+  const expected = [
+    'pkg:docker/emscripten/emsdk@5.0.7?platform=linux%2Famd64',
+    'https://github.com/libexpat/libexpat/releases/download/R_2_8_4/expat-2.8.4.tar.gz',
+    'https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz',
+  ]
+  if (sources.length !== expected.length || expected.some(uri => !sources.includes(uri))) {
+    throw new Error('preview notices: review the changed Viz.js native sources and their distribution licenses')
+  }
+}
+
 /**
  * Render the complete notices document.
  * @returns The exact bytes THIRD_PARTY_NOTICES.md must hold after resolving browser inputs.
@@ -695,6 +717,16 @@ export async function render(): Promise<string> {
   // the manifests map it was resolved from; render() owns that single load.
   workspaceLinkedManifestCache.clear()
   const { manifests, names } = loadWorkspaceManifests()
+  const previewPackage = 'packages/client/ui-primitives'
+  const provenance = JSON.parse(readFileSync(resolve(root, previewPackage, 'node_modules/@viz-js/viz/lib/provenance.json'), 'utf8')) as {
+    predicate: { buildDefinition: { resolvedDependencies: { uri: string }[] } }
+  }
+  const previewDependencies = manifests.get(`${previewPackage}/package.json`)?.devDependencies
+  if (previewDependencies === undefined) throw new Error('preview notices: missing UI primitives browser dependencies')
+  assertPreviewDistribution(
+    previewDependencies,
+    provenance.predicate.buildDefinition.resolvedDependencies.map(source => source.uri),
+  )
   const npm = collectNpmDeps(manifests, names, browser)
   const runtimeDeps = npm.filter(dep => dep.runtime)
   const devDeps = npm.filter(dep => !dep.runtime)
@@ -735,6 +767,8 @@ External packages installed for runtime use or distributed inside the prebuilt b
 
 ${renderNpmTable(runtimeDeps)}
 
+The Markdown preview distribution also contains Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 runtime code (MIT/NCSA) inside \`@viz-js/viz\` 3.30.0. The wrapper's MIT metadata does not relicense these components. [Preview notices](packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) preserve their full license texts and Graphviz source availability, together with Mermaid's MIT and DOMPurify's selected Apache-2.0 terms. The UI primitives npm package includes this file; the Web build emits it as \`preview-third-party-notices.txt\`.
+
 pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
 
 ${patchedLines.join('\n')}

+ 1 - 0
tsconfig.host.json

@@ -67,6 +67,7 @@
     "apps/web/tests/math-rendering.e2e.ts",
     "apps/web/tests/markdown-cjk-strong.e2e.ts",
     "apps/web/tests/markdown-inline-code-links.e2e.ts",
+    "apps/web/tests/markdown-mermaid.e2e.ts",
     "apps/web/tests/clickable-links-gallery.e2e.ts",
     "apps/web/tests/queue-actions.e2e.ts",
     "apps/web/tests/queue-image.e2e.ts",