Procházet zdrojové kódy

Merge pull request #4442 from deepseek-harness/revert-3906-perf/code-block-preview-renders

Revert "feat(client): optimize code block previews and source highlighting"
imccyu před 5 dny
rodič
revize
a4452b2ed2
94 změnil soubory, kde provedl 286 přidání a 5687 odebrání
  1. 0 6
      .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml
  2. 0 47
      .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md
  3. 0 47
      .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md
  4. 0 6
      .agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.i18n.yaml
  5. 0 57
      .agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md
  6. 0 57
      .agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.zh.md
  7. 0 6
      .agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.i18n.yaml
  8. 0 29
      .agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.md
  9. 0 29
      .agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.zh.md
  10. 1 4
      THIRD_PARTY_NOTICES.md
  11. 0 322
      apps/web/tests/diagram-preview.perf.ts
  12. 0 2
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  13. 0 2
      apps/web/tests/expected/cordis-history/ui.expected.md
  14. 0 4
      apps/web/tests/expected/markdown-mermaid/lightbox.expected.md
  15. 0 6
      apps/web/tests/expected/markdown-mermaid/offscreen.expected.md
  16. 0 427
      apps/web/tests/expected/markdown-mermaid/source-highlight.expected.md
  17. 0 42
      apps/web/tests/expected/markdown-mermaid/ui.expected.md
  18. 0 4
      apps/web/tests/expected/markdown-mermaid/zh-lightbox.expected.md
  19. 0 42
      apps/web/tests/expected/markdown-mermaid/zh.expected.md
  20. 0 2
      apps/web/tests/expected/thinking-markdown/ui.expected.md
  21. 0 631
      apps/web/tests/markdown-mermaid.e2e.ts
  22. 0 2
      apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
  23. 0 34
      apps/web/tests/snapshots/streaming-fence-highlight/preview-placeholder.expected.md
  24. 5 65
      apps/web/tests/streaming-fence-highlight.e2e.ts
  25. 0 2
      apps/web/tsconfig.json
  26. 1 15
      apps/web/vite.config.ts
  27. 0 13
      packages/client/locale/src/locales/en.ts
  28. 0 13
      packages/client/locale/src/locales/zh.ts
  29. 2 2
      packages/client/ui-chat/README.i18n.yaml
  30. 0 10
      packages/client/ui-chat/README.md
  31. 0 8
      packages/client/ui-chat/README.zh.md
  32. 1 16
      packages/client/ui-chat/src/client/markdown-labels.ts
  33. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  34. 3 20
      packages/client/ui-primitives/README.md
  35. 3 20
      packages/client/ui-primitives/README.zh.md
  36. 0 722
      packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt
  37. 1 5
      packages/client/ui-primitives/package.json
  38. 1 3
      packages/client/ui-primitives/src/index.ts
  39. 10 164
      packages/client/ui-primitives/src/markdown/CodeBlock.module.css
  40. 157 89
      packages/client/ui-primitives/src/markdown/CodeBlock.tsx
  41. 0 143
      packages/client/ui-primitives/src/markdown/CodeBlockSource.tsx
  42. 0 39
      packages/client/ui-primitives/src/markdown/CopyCodeButton.tsx
  43. 8 20
      packages/client/ui-primitives/src/markdown/MarkdownText.tsx
  44. 0 65
      packages/client/ui-primitives/src/markdown/PreviewLightbox.module.css
  45. 0 149
      packages/client/ui-primitives/src/markdown/PreviewLightbox.tsx
  46. 0 19
      packages/client/ui-primitives/src/markdown/PreviewPlaceholder.tsx
  47. 0 66
      packages/client/ui-primitives/src/markdown/SourcePreview.module.css
  48. 0 168
      packages/client/ui-primitives/src/markdown/SourcePreview.tsx
  49. 0 136
      packages/client/ui-primitives/src/markdown/grammars/dot.ts
  50. 0 36
      packages/client/ui-primitives/src/markdown/graphviz.ts
  51. 6 26
      packages/client/ui-primitives/src/markdown/highlight.ts
  52. 0 105
      packages/client/ui-primitives/src/markdown/mermaid.ts
  53. 0 27
      packages/client/ui-primitives/src/markdown/preview-theme.ts
  54. 6 76
      packages/client/ui-primitives/src/markdown/render.tsx
  55. 0 17
      packages/client/ui-primitives/src/markdown/svg.ts
  56. 45 6
      packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts
  57. 0 29
      packages/client/ui-primitives/src/markdown/viewport.ts
  58. 9 88
      packages/client/ui-primitives/tests/code-block.client.spec.tsx
  59. 0 88
      packages/client/ui-primitives/tests/diagram-highlight.client.spec.tsx
  60. 0 111
      packages/client/ui-primitives/tests/fence-previews.client.spec.tsx
  61. 0 6
      packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt
  62. 0 6
      packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt
  63. 0 6
      packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.settled.txt
  64. 0 6
      packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.streaming.txt
  65. 0 6
      packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.settled.txt
  66. 0 6
      packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.streaming.txt
  67. 0 6
      packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt
  68. 0 66
      packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts
  69. 1 156
      packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx
  70. 1 1
      packages/client/ui-primitives/tests/labels.client.ts
  71. 1 2
      packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx
  72. 4 4
      packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx
  73. 0 20
      packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx
  74. 1 1
      packages/client/ui-primitives/tests/markdown.client.spec.tsx
  75. 0 152
      packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts
  76. 0 176
      packages/client/ui-primitives/tests/preview-lightbox.client.spec.tsx
  77. 0 477
      packages/client/ui-primitives/tests/source-preview.client.spec.tsx
  78. 0 2
      packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.tsx
  79. 2 4
      packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx
  80. 1 1
      packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
  81. 1 1
      packages/client/ui-tool/src/client/tool/models/primitive-labels.ts
  82. 2 3
      packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
  83. 4 2
      packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx
  84. 4 2
      packages/client/ui-user-questions/src/client/QuestionComposer.tsx
  85. 0 18
      packages/client/ui-user-questions/src/client/markdown-labels.ts
  86. 0 2
      packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx
  87. 0 11
      pnpm-lock.yaml
  88. 0 13
      scripts/check-workspace-constraints.spec.ts
  89. 1 2
      scripts/check-workspace-constraints.ts
  90. 1 66
      scripts/gen-third-party-notices.spec.ts
  91. 1 50
      scripts/gen-third-party-notices.ts
  92. 0 12
      snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md
  93. 0 8
      snapshots/web/goal-multi-turn-actions/ui.expected.md
  94. 0 2
      tsconfig.host.json

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

@@ -1,6 +0,0 @@
-# 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: cb63b0ccd02abfaf148e61ef452cb714765bfffe
-2026-09-09-markdown-static-previews.zh.md: 7dec68564136d2e9d677c1fd03d8a0b6840cf81b

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

@@ -1,47 +0,0 @@
-# Agent Note: Static Markdown fence previews
-
-Status: implemented
-
-English | [中文](2026-09-09-markdown-static-previews.zh.md)
-
-## Problem
-
-Readers need an optional way to inspect Mermaid and DOT diagrams and SVG artwork directly in Assistant replies while retaining code as the primary representation. 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`, and `svg` 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. HTML, consumers without preview labels, and streaming messages retain code.
-
-`CodeBlock.preview` is a standard source-preview descriptor containing the renderer and complete localized output and control labels; callers do not pass React nodes. `MarkdownText` creates one descriptor catalog per preview-label identity, and every settled render reuses its language entries while source text, file mentions, or local-image vocabulary changes. The [CodeBlock interaction decision](2026-09-10-codeblock-preview-interaction.md) supersedes this note's source-first default and release-on-toggle lifetime; it owns the current controls and result retention. The [preview sizing decision](../simplification/2026-09-14-source-sized-code-block-previews.md) owns current geometry. This note retains the independent rendering, security, and distribution decisions.
-
-`SourcePreview` owns pending work, failure, and cancellation of stale result publication. Pending previews show localized status. 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. Parsed flowchart nodes are inspected before layout and image nodes are rejected: Mermaid's strict mode still fetches their resources during layout, before SVG image isolation applies. 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. Intersecting previews and open lightboxes 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 and SVG colors remain intact.
-
-SVG is parsed as XML and displayed with Graphviz output as inert images, so SVG scripts, links, and external resources never become active. A magnifier opens the body-portaled dialog whose viewport fit, pan, and zoom behavior is owned by the [CodeBlock interaction decision](2026-09-10-codeblock-preview-interaction.md).
-
-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 attestation 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 its DOMPurify dependency's selected Apache-2.0 terms. 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.
-
-Source highlighting uses the shared lazy Shiki highlighter for both streamed and settled diagram fences. SVG resolves to XML; Mermaid selects the upstream grammar's `#mermaid` body rules because CodeBlock has already removed the Markdown fence. DOT uses the MIT TextMate grammar pinned in the [third-party notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt), with patterns and scopes preserved by mechanical conversion. This keeps source coloring independent of preview rendering and avoids maintaining a separate DOT tokenizer.
-
-## Alternatives considered
-
-**Show visualization by default with overlay icon actions.** The source-first decision favored authored code and avoided diagram work before an explicit request. The [interaction decision](2026-09-10-codeblock-preview-interaction.md) supersedes that default while retaining actions in the banner.
-
-**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).
-
-**Pass rendered JSX through `CodeBlock.preview`.** Caller-created elements split preview ownership between the renderer and the primitive. A standard data descriptor keeps rendering, result ownership, and controls in one component API.
-
-**Preview HTML in a sandboxed frame.** HTML preview adds sanitization, iframe policy, a distinct document layout, and another browser dependency. Source display covers the code-review use case without that separate security and distribution surface.
-
-**Insert rendered markup into Chat.** SVG styles and executable behavior would share the application document. Image mode disables SVG behavior while retaining the rendered result.
-
-**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. `SourcePreview` associates a result with both its source and renderer and publishes nothing until the matching result settles. 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, HTML rendering, or interactive diagram links.
-
-Component tests cover source copying, preview rendering, 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, inert SVG images, real image decoding, source toggling, English/Chinese UI snapshots, and served license text. Notices checks pin the reviewed wrapper version and native source downloads so upgrades require renewed review.

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

@@ -1,47 +0,0 @@
-# Agent Note: 静态 Markdown fence 预览
-
-Status: implemented
-
-[English](2026-09-09-markdown-static-previews.md) | 中文
-
-## Problem
-
-读者需要在保留代码为主要表示的同时,按需直接查看 Assistant 回复中的 Mermaid 与 DOT 图表和 SVG 图像。这些源码不可信,而渲染器的 npm 许可证字段可能遗漏编译组件各自的分发义务。
-
-## Decision
-
-共享 Markdown 渲染器通过本地化的 `MarkdownLabels.preview` 和解析后的 fence 语言启用定稿后的 `mermaid`、`graphviz`/`dot` 和 `svg` fence。[UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `SourcePreview`,它接收渲染器、源码和文案,不依赖 Session、文件或 Cordis。Mermaid 与 Graphviz 各自向同一组件提供 `.ts` renderer。HTML、未提供预览文案的调用方与流式消息保留代码显示。
-
-`CodeBlock.preview` 是标准化的源码预览描述,包含 renderer 以及完整的本地化输出与控件文案;调用方不传入 React 节点。`MarkdownText` 按预览文案对象的 identity 创建一份描述目录,之后正文、文件提及或本地图片词表变化引起的每次定稿渲染都复用其中的语言条目。[CodeBlock 交互决策](2026-09-10-codeblock-preview-interaction.zh.md)取代本文的默认源码和切换时释放结果的选择,负责当前控件与结果保留。[预览尺寸决策](../simplification/2026-09-14-source-sized-code-block-previews.zh.md)负责当前几何行为。本文保留独立的渲染、安全与分发决策。
-
-`SourcePreview` 负责待完成工作、失败和取消过期结果发布。待完成的预览显示本地化状态。替换源码和卸载组件会取消结果发布;在运行时加载完成前取消会跳过布局。失败时显示本地化错误与原始源码,用有效输入替换无效源码后可以恢复预览。
-
-Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。布局前检查解析后的 flowchart 节点并拒绝图片节点;Mermaid 的严格模式仍会在图片节点布局时请求外部资源,最终 SVG 图片隔离无法阻止这一请求。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。与视口相交的预览和已打开的 lightbox 观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT 与 SVG 中明确指定的颜色保持原样。
-
-SVG 按 XML 解析后,与 Graphviz 输出一同作为不可执行图片显示,因此 SVG 脚本、链接与外部资源不会激活。放大镜会打开 body portal 对话框;其视口适应、平移和缩放行为由 [CodeBlock 交互决策](2026-09-10-codeblock-preview-interaction.zh.md)负责。
-
-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 元数据策略。
-
-源码高亮在流式与定稿图表 fence 中共用按需加载的 Shiki 高亮器。SVG 解析为 XML;Mermaid 选用上游语法的 `#mermaid` 正文规则,因为 CodeBlock 已去除 Markdown 围栏。DOT 使用[第三方声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)中固定的 MIT TextMate 语法,机械转换保留所有模式与 scope。这让源码着色独立于预览渲染,也避免维护单独的 DOT tokenizer。
-
-## Alternatives considered
-
-**默认显示可视化并使用浮层图标操作。** 默认源码的决策优先展示作者代码,并避免显式请求前执行图表工作。[交互决策](2026-09-10-codeblock-preview-interaction.zh.md)取代这一默认选择,同时继续将操作放在标题栏中。
-
-**渲染每个流式片段。** 未完成的图表通常无效,反复布局会与文本流式输出争用资源。消息定稿可提供完整源码。
-
-**在 Chat 内部渲染,或添加通用预览注册表。** 使用普通属性的共享基础组件即可满足复用,无需另加注册表或功能插件依赖。这遵循[共享控件规则](../architecture/2026-09-05-shared-client-control-primitives.zh.md)。
-
-**通过 `CodeBlock.preview` 传入已渲染 JSX。** 调用方创建的元素会把预览所有权拆分到 renderer 与基础组件。标准数据描述让渲染、结果所有权与控件归入同一个组件 API。
-
-**在 sandbox iframe 中预览 HTML。** HTML 预览需要清理策略、iframe 权限、独立文档布局和额外浏览器依赖。源码展示足以覆盖代码审阅场景,无需引入这组独立的安全与分发范围。
-
-**把渲染标记插入 Chat。** SVG 样式和可执行行为会与应用共享文档。图片模式会禁用 SVG 行为,同时保留渲染结果。
-
-**将 Viz.js 视为仅使用 MIT,或使用远程 Graphviz 服务。** 本地编译的 Graphviz 仍受其许可证约束;远程渲染会把对话内容发送到设备之外。内置渲染器保留源码可获取性与法律声明,无需网络渲染。
-
-## Consequences
-
-该功能改变呈现,不改变持久化消息、provider 请求、工具或 Host API。`SourcePreview` 同时按源码与 renderer 关联结果,在匹配结果完成前不发布预览内容。Mermaid 和 Graphviz 增加按需加载的浏览器资源,并在浏览器线程上执行布局。取消无法抢占进行中的布局;即使结果无法发布,Mermaid 仍会完成并释放测量 DOM。预览不提供编辑、导出、HTML 渲染或交互式图表链接。
-
-组件测试覆盖源码复制、预览渲染、延迟完成、过期成功与失败、卸载、回退、取消、运行时加载失败与恢复。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证中文 Mermaid 流程图、时序图、无效源码、配置覆盖、不可执行 SVG 图片、真实图片解码、源码切换、中英文 UI 快照和已提供的许可证文本。声明检查固定已审查的包装层版本与原生源码下载地址,升级时须重新审查。

+ 0 - 6
.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.i18n.yaml

@@ -1,6 +0,0 @@
-# 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-10-codeblock-preview-interaction.md
-2026-09-10-codeblock-preview-interaction.md: 678daa6544f92ab5216251658f9e9471eae4cf10
-2026-09-10-codeblock-preview-interaction.zh.md: afd3a3e2416765b3f71832ddd867c83bf90d8e81

+ 0 - 57
.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md

@@ -1,57 +0,0 @@
-# Agent Note: CodeBlock preview interaction and retained work
-
-Status: implemented
-
-English | [中文](2026-09-10-codeblock-preview-interaction.zh.md)
-
-## Problem
-
-Readers need to inspect diagrams directly and return to source without paying for the same diagram again. Toolbar state and unrelated grammar notifications can repeat source work even when its text is unchanged.
-
-## Decision
-
-Supported settled blocks initially show Preview. Supported streaming blocks show a 180px image placeholder with a localized loading status and a decorative glyph whose opacity pulses over 1.8 seconds; reduced motion disables the animation. Streaming mounts neither source highlighting nor diagram generation, including after a fence freezes in the incremental parser. Controls return when message streaming ends; the placeholder remains until the image loads. Stopping the stream also exits the waiting state; invalid or unloadable images show an error with Source available. This supersedes the source-first and release-on-toggle choices in the [static preview decision](2026-09-09-markdown-static-previews.md), whose renderer isolation, cancellation, security, and distribution rules remain active.
-
-The language stays on the left. The right side contains magnifier, copy, and Source/Preview controls in that order. Icons follow the Sidebar's 28px circular control and 15px glyph treatment. The segmented control moves only its selected background for 160ms; content switches immediately, and reduced-motion settings suppress that transition. Unsupported blocks have a static selected Source label. Copy always reads source, and its fixed-size icon and localized tooltip show success. Preview provides a magnifier that opens `PreviewLightbox`, disabled with an accessible localized explanation when no usable image is available. Source replaces it with a line-number toggle whose state survives view changes. Preview toolbar opacity transitions over 160ms without a timer or layout change; block hover and keyboard focus reveal it, and touch devices keep it visible. Source toolbar opacity stays at one.
-
-The stable `data-code-block-content` wrapper and `contentRef` let sidebar owners retain their scrollport and restore scroll position. The [preview sizing decision](../simplification/2026-09-14-source-sized-code-block-previews.md) supersedes this note's active-body height and inline-resize choices; it owns current Source/Preview geometry and overflow.
-
-Each mounted `CodeBlock` owns its preview result and pending renderer call. A shared IntersectionObserver defers diagram work and runtime loading until intersection, and cancels unfinished work on exit unless the lightbox is open. Browsers without this API render immediately. Switching views retains completed results; source, renderer, or resolved-theme changes invalidate the work, and unmounting cancels publication. Reentry reuses results with matching source, renderer and palette. Theme refreshes retain the loaded image, its geometry and the open lightbox until a replacement loads. A failed refresh preserves the usable image and displays the localized error. Retention is bounded to one displayed image and one replacement per block.
-
-The owner retains failures too, so toggling cannot repeatedly submit invalid source. Previewable blocks mount source on first selection, then retain that DOM with its highlighting state. `CodeBlock`, its source child, and its copy control are independently memoized. Grammar readiness snapshots report only the source language, so loading another grammar does not schedule source work. This is local retention, with no cache shared across blocks or Sessions.
-
-The lightbox fits the image within 88% of viewport width and 84% of viewport height, independently of inline size. Pointer capture owns dragging; wheel zoom preserves the image point beneath the cursor and stays between 0.25 and 8 times the fitted size. Double-click, Home, source-image changes, and viewport resizing restore the fit. Arrow keys pan and +/− zoom. Gestures write only the image transform and retain its URL; they do not schedule React state or diagram generation. Closing releases capture and listeners and restores focus to the opener.
-
-The source renderer consumes each highlight frame once. `StreamingHighlightSession.updateFrame` returns the same delta frame for repeated code and language, while refs retain completed-line groups across React render retries. The line cache therefore retains the frame identity and returns its existing body for the same frame, code, and language; memoization alone cannot prevent duplicate line insertion. StrictMode coverage checks multiple streaming chunks and settlement, and the Markdown DOM fixtures run under StrictMode without changing their expected output.
-
-## Alternatives considered
-
-**Unmount inactive content.** Releasing images on every Source visit repeats diagram generation and removes the source-view magnifier's result. Retaining one result per mounted block gives the result the same lifetime as its controls.
-
-**Share a global preview cache.** Cross-block reuse adds eviction, renderer identity, and theme-key ownership while retaining conversation content beyond individual mounts. The measured repeated work occurs within one block, so its mounted lifetime is sufficient.
-
-**Mount and highlight source before selection.** The retained image determines block dimensions, so hidden source adds work without improving the preview.
-
-## Consequences
-
-Previewable blocks mount and highlight source on first selection; preview-only readers do not mount source. Ordinary source-only blocks still defer highlighting until they intersect; streaming diagram placeholders do not mount source.
-
-Offscreen previews submit no diagram work until intersection; offscreen theme changes wait for reentry. Returning to Source keeps the image and renderer owner alive, and blocks whose Source view has been opened retain source token DOM. Memory therefore follows mounted blocks, with no claim of reduced heap use. Mermaid's queue and synchronous Graphviz layout still run on the browser thread, and cancellation cannot preempt active layout.
-
-The [viewport tests](../../../../packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx) pin deferred rendering, viewport cancellation and reuse; the 20-block regression calls every renderer against the unoptimized implementation. The [component tests](../../../../packages/client/ui-primitives/tests/source-preview.client.spec.tsx) cover retained results, source identity, pending and failed controls, copy, streaming transitions, invalidation, and stale publication. The [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) exercises equal Source/Preview geometry, internal overflow, toolbar fading, line-number controls, lightbox interaction, and localized UI snapshots. Highlighting retains the existing streamed/settled parity tests. The streaming browser scenario waits for the turn to finish persisting before closing Session handles, including after failed assertions.
-
-
-## Performance evidence
-
-The [manual browser diagnostic](../../../../apps/web/tests/diagram-preview.perf.ts), run with `pnpm run test:web:perf:built apps/web/tests/diagram-preview.perf.ts`, uses 20 Mermaid flowcharts with 10 nodes and 12 edges each, followed by 40 paragraphs. Three fresh Chromium instances use a 1680×1000 viewport, the shipped Web composition and built Client on macOS arm64, Apple M5 Pro and Node 24.20.0. Opening starts at a real Session click and ends when the tail is visible plus two animation-frame opportunities; theme timing starts immediately before system-theme emulation and ends after a visible replacement plus two frames. These endpoints do not measure hardware presentation or model latency.
-
-| Metric | Eager previews, samples → median | Viewport previews, samples → median |
-| --- | --- | --- |
-| Open endpoint (ms) | 129.0, 167.3, 98.5 → 129.0 | 72.1, 106.6, 81.7 → 81.7 |
-| Long tasks in first 3 seconds (ms) | 61, 58, 0 → 58 | 0, 0, 0 → 0 |
-| Additional initial JS transfer (bytes) | 239449 in every sample | 0 in every sample |
-| Initially decoded offscreen diagrams | 20 in every sample | 0 in every sample |
-| Visible theme replacement (ms) | 956.0, 617.6, 697.7 → 697.7 | 93.0, 93.1, 82.1 → 93.0 |
-| Theme placeholder frames / height range | 2 / 180–632px in every sample | 0 / 632–632px in every sample |
-
-Theme preparation scrolls to the loaded target after cold diagrams settle; otherwise an earlier diagram's growth can move the target outside the viewport. Both compared theme phases contain two intersecting previews. First activation and its geometry changes are excluded from timing. Unrelated host activity and filesystem caches are uncontrolled; timings are descriptive, with no CI timing threshold or memory claim. The owning component regression fails on eager rendering, and the browser case verifies deferred Mermaid and Graphviz requests plus a keyless offscreen-state snapshot.

+ 0 - 57
.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.zh.md

@@ -1,57 +0,0 @@
-# Agent Note: CodeBlock 预览交互与工作保留
-
-Status: implemented
-
-[English](2026-09-10-codeblock-preview-interaction.md) | 中文
-
-## Problem
-
-读者需要直接查看图表,并返回源码而不必再次生成同一张图。即使文本未变,工具栏状态和无关语法通知也可能重复执行源码工作。
-
-## Decision
-
-支持预览的定稿代码块初始显示预览。受支持的流式代码块显示高 180px 的图片占位、本地化加载状态和以 1.8 秒周期改变透明度的装饰图标;减少动态效果设置禁用动画。流式期间既不挂载源码高亮,也不生成图表,包括 fence 被增量解析器冻结之后。消息流式结束后恢复控件;占位保留至图片加载完成。停止输出也会退出等待状态;无效或无法加载的图片显示错误,并保留源码入口。这取代[静态预览决策](2026-09-09-markdown-static-previews.zh.md)中的默认源码和切换时释放结果的选择;其中的渲染器隔离、取消、安全与分发规则继续适用。
-
-语言名位于左侧。右侧依次放置放大镜、复制和源码/预览控件。图标沿用侧边栏的 28px 圆形控件与 15px 图形样式。分段控件只让选中背景移动 160ms;正文立即切换,减少动态效果设置会禁用该过渡。不支持预览的代码块显示静态选中的源码文案。复制始终读取源码,通过固定尺寸图标与本地化 tooltip 表示成功。预览视图提供放大镜,打开 `PreviewLightbox`;没有可用图片时禁用,并提供本地化的可访问说明。源码视图用行号开关替换放大镜,开关状态跨视图切换保留。预览工具栏的透明度在 160ms 内过渡,不使用计时器或改变布局;鼠标移入代码块和键盘聚焦时显示,触摸设备保持可见。源码工具栏透明度始终为一。
-
-稳定的 `data-code-block-content` 容器与 `contentRef` 让侧栏所有者保留滚动容器并恢复滚动位置。[预览尺寸决策](../simplification/2026-09-14-source-sized-code-block-previews.zh.md)取代本文中由当前正文定高与行内调整尺寸的选择;它负责当前的源码/预览几何与溢出行为。
-
-每个已挂载的 `CodeBlock` 拥有预览结果及待完成的 renderer 调用。共享 IntersectionObserver 把图表生成与运行时加载推迟到与视口相交之后;离开视口会取消未完成的工作,已打开 lightbox 时除外。不支持该 API 的浏览器立即渲染。切换视图保留已完成结果;源码、renderer 或解析后的主题变化会使工作失效,卸载则取消发布。重新进入时复用源码、renderer 与配色相同的结果。主题刷新保留已加载图片、其几何尺寸和已打开的 lightbox,直到替换图片加载完成。刷新失败时保留可用图片并显示本地化错误。每个代码块最多保留一张已显示图片和一张替换图片。
-
-所有者也保留失败结果,因此切换不会反复提交无效源码。支持预览的代码块在首次选中源码时挂载,随后连同高亮状态一起保留该 DOM。`CodeBlock`、源码子组件和复制控件分别 memo 化。语法就绪快照只报告源码语言,因此加载其他语法不会调度源码工作。结果仅在本地保留,不在代码块或 Session 间共享缓存。
-
-大图以屏幕宽度的 88% 和高度的 84% 为上限等比适应,不依赖行内尺寸。指针捕获管理拖拽;滚轮缩放保持鼠标下的图片位置不动,倍率限制在适应尺寸的 0.25 至 8 倍。双击、Home、图片源变化和视口尺寸变化会恢复适应尺寸。方向键平移,+/− 缩放。手势只写入图片 transform 并保留 URL,不调度 React state 或重新生成图表。关闭时释放指针捕获和监听器,并将焦点还给打开按钮。
-
-源码渲染器只消费每个高亮帧一次。对于重复的代码与语言,`StreamingHighlightSession.updateFrame` 返回同一个增量帧,而 ref 会跨 React 渲染重试保留已完成行的分组。因此,行缓存保留帧 identity,并在帧、代码与语言相同时返回已有正文;仅靠 memo 化无法防止重复插入行。StrictMode 测试覆盖多个流式分片及定稿,Markdown DOM fixture 也在 StrictMode 下运行,预期输出保持不变。
-
-## Alternatives considered
-
-**卸载未激活内容。** 每次查看源码都释放图片会重复生成图表,也会移除源码视图放大镜所需的结果。按已挂载代码块保留一个结果,让结果与其控件具有相同生命周期。
-
-**共享全局预览缓存。** 跨代码块复用需要处理淘汰、renderer identity 和主题键归属,还会在单个组件卸载后保留对话内容。测得的重复工作发生在同一代码块内部,因此其挂载生命周期足以满足需求。
-
-**在选中前就挂载并高亮源码。** 保留的图片决定代码块尺寸,隐藏源码只会增加工作,无法改善预览。
-
-## Consequences
-
-支持预览的代码块在首次选中源码时挂载并高亮;只阅读预览时不挂载源码。普通纯源码代码块仍推迟到进入视口后高亮;流式图表占位不挂载源码。
-
-视口外的预览直到与视口相交后才提交图表工作;视口外的主题变化等待重新进入视口后处理。返回源码后,图片与 renderer 所有者保持存活,已经打开过源码视图的代码块保留源码 token DOM。因此内存占用随已挂载代码块增长,不声称减少堆内存。Mermaid 队列和同步 Graphviz 布局仍在浏览器线程执行,取消无法抢占已开始的布局。
-
-[视口测试](../../../../packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx)固定延迟渲染、视口取消和复用行为;20 个代码块的回归用例在未优化实现上会调用全部 renderer。[组件测试](../../../../packages/client/ui-primitives/tests/source-preview.client.spec.tsx)覆盖结果保留、源码 identity、待完成与失败控件、复制、流式转换、失效和过期发布。[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证源码/预览几何一致、内部溢出、工具栏淡出、行号控件、lightbox 交互和本地化 UI 快照。高亮保留已有的流式/定稿一致性测试。流式浏览器场景在关闭 Session 句柄前等待回合完成持久化,包括断言失败的情况。
-
-
-## 性能证据
-
-[手动浏览器诊断](../../../../apps/web/tests/diagram-preview.perf.ts)通过 `pnpm run test:web:perf:built apps/web/tests/diagram-preview.perf.ts` 运行,使用 20 个 Mermaid 流程图,每图 10 个节点和 12 条边,后接 40 段文本。三个全新 Chromium 实例使用 1680×1000 视口、正式 Web 组合和已构建 Client,运行于 macOS arm64、Apple M5 Pro 与 Node 24.20.0。打开阶段从真实 Session 点击开始,到尾部可见再加两次动画帧机会结束;主题阶段从模拟系统主题前开始,到替换图片可见再加两帧结束。这些终点不测量硬件呈现或模型延迟。
-
-| 指标 | 挂载即渲染,样本 → 中位数 | 视口渲染,样本 → 中位数 |
-| --- | --- | --- |
-| 打开终点(ms) | 129.0, 167.3, 98.5 → 129.0 | 72.1, 106.6, 81.7 → 81.7 |
-| 前 3 秒长任务(ms) | 61, 58, 0 → 58 | 0, 0, 0 → 0 |
-| 初始新增 JS 传输(bytes) | 每个样本均为 239449 | 每个样本均为 0 |
-| 初始已解码的视口外图表 | 每个样本均为 20 | 每个样本均为 0 |
-| 可见主题替换(ms) | 956.0, 617.6, 697.7 → 697.7 | 93.0, 93.1, 82.1 → 93.0 |
-| 主题占位帧/高度范围 | 每个样本均为 2 / 180–632px | 每个样本均为 0 / 632–632px |
-
-主题阶段的准备会在冷图表布局稳定后滚到已加载的目标;否则前方图表增高可能把目标移出视口。对比的两个主题阶段都包含两个与视口相交的预览。首次激活及其几何变化不计时。其他主机活动与文件系统缓存不受控制;计时仅作描述,不设 CI 时间阈值,也不声明内存收益。对应组件回归在挂载即渲染实现上失败,浏览器用例验证 Mermaid 与 Graphviz 请求的延迟加载及无需 API key 的屏外状态快照。

+ 0 - 6
.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.i18n.yaml

@@ -1,6 +0,0 @@
-# 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/simplification/2026-09-14-source-sized-code-block-previews.md
-2026-09-14-source-sized-code-block-previews.md: 66ba419efcf4113886cfddafc4eb9f3ad2583565
-2026-09-14-source-sized-code-block-previews.zh.md: 7e8e96eced72f78f7aff852d993cf32bff22a7e4

+ 0 - 29
.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.md

@@ -1,29 +0,0 @@
-# Agent Note: Diagram-sized code block previews
-
-Status: implemented
-
-English | [中文](2026-09-14-source-sized-code-block-previews.zh.md)
-
-## Problem
-
-Source line count does not predict diagram proportions. Sizing a preview from hidden highlighted source can clip a tall diagram, waste space around a short one, and parse a large source tree before the reader requests it. Independent Source and Preview heights move the surrounding transcript on every toggle.
-
-## Decision
-
-The browser sizes the retained preview image from its intrinsic aspect ratio and available width. CSS limits image height to the smaller of 60vh and 640px; the canvas adds 16px padding and a 120px minimum height. The image remains in document flow while hidden, so both views keep the same geometry through toggles and viewport resizing without JavaScript measurements or resize state.
-
-Source mounts on first selection, overlays the preview area, and scrolls internally. Subsequent toggles retain the source DOM and generated image. Preview remains the default. Supported streaming fences keep the 180px placeholder and mount neither source nor the diagram renderer until settlement.
-
-`CodeBlock` exposes no inline resize controls. Enlarging the image opens the existing lightbox with pan, zoom, and viewport fit.
-
-## Alternatives considered
-
-**Use hidden source as the size owner.** Source length and wrapping do not describe the diagram, and eagerly highlighting it adds work to the default preview path.
-
-**Give each view its own natural height or allow drag resizing.** Independent heights move transcript content; manual resizing adds state and controls where the browser can derive the size from the image.
-
-## Consequences
-
-Preview-only readers do not mount or highlight source. Source-only blocks retain viewport-triggered highlighting. Very tall diagrams scale down in the transcript; the lightbox provides detailed reading. Source DOM remains allocated after the reader first opens it, preserving highlight and scroll state across toggles.
-
-Browser regressions cover wide and narrow viewports, short and tall images, stable toggle geometry, retained DOM, and hidden-source accessibility. Component tests verify that preview settlement performs no source highlighting before Source is selected.

+ 0 - 29
.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.zh.md

@@ -1,29 +0,0 @@
-# Agent Note: 按图表定高的代码块预览
-
-Status: implemented
-
-[English](2026-09-14-source-sized-code-block-previews.md) | 中文
-
-## Problem
-
-源码行数无法预测图表比例。根据隐藏的高亮源码确定预览尺寸,会裁切高图、在短图周围浪费空间,并在读者需要之前解析大量源码节点。源码与预览分别使用独立高度时,每次切换都会移动后续对话内容。
-
-## Decision
-
-浏览器根据保留的预览图片的原始宽高比与可用宽度计算尺寸。CSS 将图片高度限制为 60vh 与 640px 中的较小值;画布增加 16px 内边距与 120px 最小高度。图片隐藏时仍保留在文档流中,因此切换视图与调整视口时,两种视图保持相同几何,无需 JavaScript 测量或尺寸状态。
-
-源码在首次选中时挂载,覆盖预览区域并在内部滚动。后续切换保留源码 DOM 与已生成图片。默认仍为预览。受支持的流式 fence 保留 180px 占位,在定稿前既不挂载源码,也不挂载图表 renderer。
-
-`CodeBlock` 不提供行内尺寸调整控件。放大图片使用现有 lightbox,支持平移、缩放与适应视口。
-
-## Alternatives considered
-
-**用隐藏源码确定尺寸。** 源码长度与换行无法描述图表,提前高亮还会给默认预览路径增加工作。
-
-**让两种视图分别使用自然高度或允许拖拽调整。** 独立高度会移动对话内容;浏览器可以根据图片推导尺寸,手动调整却增加了状态与控件。
-
-## Consequences
-
-只阅读预览时不挂载或高亮源码。仅源码代码块保留视口触发的高亮。特别高的图表在对话中等比缩小,读者可通过 lightbox 查看细节。首次打开后,源码 DOM 持续保留,让高亮与滚动状态跨切换保持不变。
-
-浏览器回归覆盖宽窄视口、长短图片、稳定的切换几何、DOM 保留及隐藏源码的可访问性。组件测试验证预览定稿不会在选中源码前执行源码高亮。

+ 1 - 4
THIRD_PARTY_NOTICES.md

@@ -61,7 +61,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
 | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
 | [`@trycua/cua-driver`](https://github.com/trycua/cua) | MIT |
-| [`@viz-js/viz`](https://github.com/mdaines/viz-js) | MIT |
 | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
 | [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) | MIT |
 | [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize) | MIT |
@@ -90,7 +89,6 @@ 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 |
@@ -125,8 +123,6 @@ 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 its DOMPurify dependency'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:
 
 - `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch)
@@ -216,6 +212,7 @@ 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 |
 | [`oxlint`](https://github.com/oxc-project/oxc) | MIT |
 | [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT |

+ 0 - 322
apps/web/tests/diagram-preview.perf.ts

@@ -1,322 +0,0 @@
-/** Manual built-Web diagnostic for offscreen diagrams and visible theme refreshes; no timing budgets. */
-import { execFileSync } from 'node:child_process'
-import { mkdir, writeFile } from 'node:fs/promises'
-import { arch, cpus, platform, release } from 'node:os'
-import { join } from 'node:path'
-import { chromium } from 'playwright'
-import type { Browser, Locator, Page } from 'playwright'
-import { describe, expect, it } from 'vitest'
-import { createAssistantMessage, 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 { launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts'
-import type { WebScaffold } from './scaffold.ts'
-import { newEnglishPage, REPO_ROOT } from './support.ts'
-
-const SAMPLE_COUNT = 3
-const DIAGRAM_COUNT = 20
-const TAIL_PARAGRAPHS = 40
-const OBSERVATION_MS = 3_000
-const SESSION_ID = 'diagram-preview-performance'
-const TITLE = 'DIAGRAM_PREVIEW_PERF'
-const TAIL = 'DIAGRAM_PREVIEW_TAIL'
-const PREVIEW = '[data-code-block-preview]'
-const PROBE_KEY = '__dshDiagramPreviewPerf'
-
-interface LongTask {
-  readonly startTime: number
-  readonly duration: number
-}
-
-interface Frame {
-  readonly elapsedMs: number
-  readonly height: number
-  readonly placeholder: boolean
-}
-
-interface Probe {
-  start?: number
-  opportunity?: number
-  oldSrc?: string
-  trustedClick?: boolean
-  frames: Frame[]
-  longTasks: LongTask[]
-  observer: PerformanceObserver
-  raf: number
-}
-
-function fixture(): string {
-  const session = Session.create(SessionId(SESSION_ID))
-  session.append('turn/start', { turn: 1 })
-  const user = session.append('user/message', createUserMessage({
-    content: [{ type: 'text', text: 'Inspect this synthetic diagram history.' }],
-    source: { kind: 'user' },
-  }), { surfaceOp: 'append' })
-  session.append('session/title', { title: TITLE, messageSeqs: [user.seq], source: { kind: 'fallback' } })
-  session.append('step/start', { turn: 1, step: 1 })
-  const diagrams = Array.from({ length: DIAGRAM_COUNT }, (_, index) => [
-    `Diagram ${index + 1}`,
-    '```mermaid',
-    'flowchart TD',
-    `  A[Request ${index + 1}] --> B[Read history]`,
-    '  B --> C{Cached}',
-    '  C --> D[Load source]',
-    '  C --> E[Reuse source]',
-    '  D --> F[Parse]',
-    '  E --> F',
-    '  F --> G[Layout]',
-    '  G --> H[Draw]',
-    '  H --> I{Loaded}',
-    '  I --> J[Display]',
-    '  I --> D',
-    '  E --> G',
-    '```',
-  ].join('\n'))
-  const paragraphs = Array.from({ length: TAIL_PARAGRAPHS }, (_, index) =>
-    `History note ${index + 1}. This ordinary paragraph keeps the diagrams above the initial reading position. `
-    + 'The reader can inspect the completed discussion without opening a chart.')
-  session.append('assistant/message', {
-    turn: 1,
-    step: 1,
-    stream: [],
-    message: createAssistantMessage({
-      content: [{ type: 'text', text: [...diagrams, ...paragraphs, TAIL].join('\n\n') }],
-      source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
-    }),
-    usage: { inputTokens: 20, outputTokens: 2_000 },
-  }, { surfaceOp: 'append' })
-  session.append('step/end', { turn: 1, step: 1 })
-  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
-  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)), ''].join('\n')
-}
-
-async function installProbe(phase: 'open' | 'theme', target: Locator): Promise<void> {
-  await target.evaluate((node, options) => {
-    const longTasks: LongTask[] = []
-    const observer = new PerformanceObserver((list) => {
-      for (const entry of list.getEntries()) longTasks.push({ startTime: entry.startTime, duration: entry.duration })
-    })
-    observer.observe({ type: 'longtask' })
-    const probe: Probe = { frames: [], longTasks, observer, raf: 0 }
-    Reflect.set(globalThis, options.key, probe)
-    const visible = (element: Element): boolean => {
-      const rect = element.getBoundingClientRect()
-      const scroll = document.querySelector('[data-conversation-scroll]')?.getBoundingClientRect()
-      const style = getComputedStyle(element)
-      return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden'
-        && rect.bottom > (scroll?.top ?? 0) && rect.top < (scroll?.bottom ?? innerHeight)
-    }
-    const frame = (): void => {
-      if (probe.start === undefined) return
-      let ready = false
-      if (options.phase === 'open') {
-        const tail = Array.from(document.querySelectorAll('[data-conversation-scroll] p'))
-          .find(element => element.textContent === options.tail)
-        ready = tail !== undefined && visible(tail)
-      } else {
-        const preview = document.querySelectorAll(options.preview)[options.diagramCount - 1]
-        if (preview === undefined) throw new Error('theme probe has no last diagram')
-        probe.frames.push({
-          elapsedMs: performance.now() - probe.start,
-          height: preview.getBoundingClientRect().height,
-          placeholder: Array.from(preview.querySelectorAll('[data-preview-placeholder]')).some(visible),
-        })
-        ready = Array.from(preview.querySelectorAll('img')).some(image =>
-          image.src !== probe.oldSrc && image.complete && image.naturalWidth > 0 && visible(image))
-      }
-      if (ready && probe.opportunity === undefined) {
-        // The second callback is a rendering opportunity, not hardware presentation.
-        probe.opportunity = -1
-        requestAnimationFrame(() => {
-          requestAnimationFrame(() => { probe.opportunity = performance.now() })
-        })
-      }
-      probe.raf = requestAnimationFrame(frame)
-    }
-    if (options.phase === 'open') {
-      node.addEventListener('click', (event) => {
-        probe.trustedClick = event.isTrusted
-        probe.start = performance.now()
-        probe.raf = requestAnimationFrame(frame)
-      }, { capture: true, once: true })
-    } else {
-      const image = node.querySelector('img')
-      if (image === null) throw new Error('theme probe has no loaded image')
-      probe.oldSrc = image.src
-      probe.start = performance.now()
-      probe.raf = requestAnimationFrame(frame)
-    }
-  }, { phase, key: PROBE_KEY, preview: PREVIEW, diagramCount: DIAGRAM_COUNT, tail: TAIL })
-}
-
-async function collectProbe(page: Page) {
-  try {
-    await page.waitForFunction(({ key, windowMs }) => {
-      const probe = Reflect.get(globalThis, key) as Probe
-      return probe.start !== undefined && (probe.opportunity ?? -1) > 0
-        && performance.now() - probe.start >= windowMs
-    }, { key: PROBE_KEY, windowMs: OBSERVATION_MS }, { timeout: 30_000 })
-  } catch (error) {
-    const diagnostic = await page.evaluate(({ key, previewSelector }) => {
-      const probe = Reflect.get(globalThis, key) as Probe
-      const preview = Array.from(document.querySelectorAll(previewSelector)).at(-1)
-      const bounds = (element: Element | null | undefined) => {
-        const rect = element?.getBoundingClientRect()
-        return rect === undefined ? null : { top: rect.top, bottom: rect.bottom, height: rect.height }
-      }
-      return {
-        opportunity: probe.opportunity,
-        frames: probe.frames.slice(-5),
-        theme: getComputedStyle(document.documentElement).colorScheme,
-        scroll: bounds(document.querySelector('[data-conversation-scroll]')),
-        preview: bounds(preview),
-        images: Array.from(preview?.querySelectorAll('img') ?? []).map(image => ({
-          sameSource: image.src === probe.oldSrc, complete: image.complete, naturalWidth: image.naturalWidth,
-          hidden: image.hidden, rect: bounds(image),
-        })),
-      }
-    }, { key: PROBE_KEY, previewSelector: PREVIEW })
-    throw new Error(`diagram probe endpoint unavailable: ${JSON.stringify(diagnostic)}`, { cause: error })
-  }
-  return await page.evaluate(({ key, windowMs, previewSelector }) => {
-    const probe = Reflect.get(globalThis, key) as Probe
-    probe.observer.disconnect()
-    cancelAnimationFrame(probe.raf)
-    Reflect.deleteProperty(globalThis, key)
-    if (probe.start === undefined || probe.opportunity === undefined) throw new Error('incomplete diagram probe')
-    const start = probe.start
-    const stop = start + windowMs
-    const tasks = probe.longTasks.filter(task => task.startTime < stop && task.startTime + task.duration > start)
-    const scripts = performance.getEntriesByType('resource').filter((entry): entry is PerformanceResourceTiming =>
-      entry instanceof PerformanceResourceTiming && new URL(entry.name).pathname.endsWith('.js')
-      && entry.startTime >= start && entry.startTime < stop)
-      .map(entry => ({
-        path: new URL(entry.name).pathname,
-        transferBytes: entry.transferSize,
-        encodedBytes: entry.encodedBodySize,
-        decodedBytes: entry.decodedBodySize,
-        durationMs: entry.duration,
-      }))
-    const previews = Array.from(document.querySelectorAll(previewSelector))
-    const scrollRect = document.querySelector('[data-conversation-scroll]')?.getBoundingClientRect()
-    const intersectingPreviews = previews.filter((preview) => {
-      const rect = preview.getBoundingClientRect()
-      return rect.bottom > (scrollRect?.top ?? 0) && rect.top < (scrollRect?.bottom ?? innerHeight)
-    }).length
-    const frames = probe.frames.filter(frame => frame.elapsedMs < windowMs)
-    return {
-      opportunityMs: probe.opportunity - start,
-      observedMs: performance.now() - start,
-      trustedClick: probe.trustedClick,
-      longTaskMs: tasks.reduce((sum, task) => sum + Math.max(0,
-        Math.min(stop, task.startTime + task.duration) - Math.max(start, task.startTime)), 0),
-      longTasks: tasks.map(task => ({ startMs: task.startTime - start, durationMs: task.duration })),
-      loadedDiagramImages: previews.flatMap(preview => Array.from(preview.querySelectorAll('img')))
-        .filter(image => image.complete && image.naturalWidth > 0).length,
-      previewCount: previews.length,
-      intersectingPreviews,
-      scriptTransferBytes: scripts.reduce((sum, script) => sum + script.transferBytes, 0),
-      scriptEncodedBytes: scripts.reduce((sum, script) => sum + script.encodedBytes, 0),
-      scripts,
-      frames,
-      heightMin: frames.length === 0 ? null : Math.min(...frames.map(frame => frame.height)),
-      heightMax: frames.length === 0 ? null : Math.max(...frames.map(frame => frame.height)),
-      placeholderFrames: frames.filter(frame => frame.placeholder).length,
-    }
-  }, { key: PROBE_KEY, windowMs: OBSERVATION_MS, previewSelector: PREVIEW })
-}
-
-async function sample() {
-  let scaffold: WebScaffold | undefined
-  let browser: Browser | undefined
-  try {
-    scaffold = await launchWebScaffold()
-    await seedSession(scaffold, fixture(), SESSION_ID)
-    browser = await chromium.launch()
-    const page = await newEnglishPage(browser)
-    await page.emulateMedia({ colorScheme: 'light' })
-    const tripwire = watchConsole(page)
-    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
-    const group = page.getByRole('treeitem').first()
-    await group.waitFor({ timeout: 30_000 })
-    await group.click()
-    const session = page.getByRole('treeitem').nth(1)
-    await session.waitFor({ timeout: 30_000 })
-    const bootScriptBytes = await page.evaluate(() => performance.getEntriesByType('resource')
-      .filter((entry): entry is PerformanceResourceTiming => entry instanceof PerformanceResourceTiming
-        && new URL(entry.name).pathname.endsWith('.js'))
-      .reduce((sum, entry) => sum + entry.transferSize, 0))
-    await installProbe('open', session)
-    await session.click()
-    const open = await collectProbe(page)
-    expect(open.trustedClick).toBe(true)
-    expect(open.previewCount).toBe(DIAGRAM_COUNT)
-    expect(open.intersectingPreviews).toBe(0)
-    const last = page.locator(PREVIEW).last()
-    await last.scrollIntoViewIfNeeded()
-    await expect.poll(() => last.locator('img').evaluateAll(images => images.some((node) => {
-      const image = node as HTMLImageElement
-      return image.complete && image.naturalWidth > 0 && image.getBoundingClientRect().height > 0
-    }))).toBe(true)
-    // Earlier cold previews may grow after the placeholder was scrolled into view.
-    await last.scrollIntoViewIfNeeded()
-    await expect.poll(() => last.evaluate((node) => {
-      const rect = node.getBoundingClientRect()
-      const scroll = document.querySelector('[data-conversation-scroll]')!.getBoundingClientRect()
-      return rect.bottom > scroll.top && rect.top < scroll.bottom
-    }), { timeout: 30_000 }).toBe(true)
-    await installProbe('theme', last)
-    await page.emulateMedia({ colorScheme: 'dark' })
-    const theme = await collectProbe(page)
-    expect(await page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe('dark')
-    expect(tripwire.pageErrors).toEqual([])
-    return { browser: browser.version(), bootScriptBytes, open, theme, consoleWarnings: tripwire.warnings }
-  } finally {
-    try {
-      await browser?.close()
-    } finally {
-      await scaffold?.close()
-    }
-  }
-}
-
-function median(values: number[]): number {
-  return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]!
-}
-
-describe('manual web performance: diagram previews', () => {
-  it('reports cold offscreen opening and a visible theme refresh', async () => {
-    if (webSnapshotMode() !== 'replay') throw new Error('diagram performance requires keyless replay mode')
-    const directory = join(REPO_ROOT, '.playwright-mcp/preview-perf', new Date().toISOString().replaceAll(':', '-'))
-    await mkdir(directory, { recursive: true })
-    const samples: Awaited<ReturnType<typeof sample>>[] = []
-    for (let index = 0; index < SAMPLE_COUNT; index++) {
-      const result = await sample()
-      samples.push(result)
-      await writeFile(join(directory, `sample-${index + 1}.json`), `${JSON.stringify(result, null, 2)}\n`)
-      console.log(JSON.stringify({ sample: index + 1, openMs: result.open.opportunityMs, themeMs: result.theme.opportunityMs }))
-    }
-    const summary = {
-      revision: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT, encoding: 'utf8' }).trim(),
-      runtime: { node: process.version, platform: platform(), release: release(), arch: arch(), cpu: cpus()[0]?.model },
-      workload: {
-        diagrams: DIAGRAM_COUNT, nodesPerDiagram: 10, edgesPerDiagram: 12,
-        tailParagraphs: TAIL_PARAGRAPHS, viewport: [1680, 1000], observationMs: OBSERVATION_MS,
-      },
-      semantics: 'Fresh Chromium and private shipped-composition scaffold per sample; built Client with source-resolved test Host. Opening starts at trusted session click; theme starts immediately before media emulation. Endpoints require visible output and two animation-frame opportunities. Long tasks are clipped to the fixed observation window. Samples include local transport, no model call. No memory or hardware-presentation measurement and no timing verdict.',
-      medians: Object.fromEntries((['open', 'theme'] as const).map(phase => [phase, {
-        opportunityMs: median(samples.map(sample => sample[phase].opportunityMs)),
-        longTaskMs: median(samples.map(sample => sample[phase].longTaskMs)),
-        scriptTransferBytes: median(samples.map(sample => sample[phase].scriptTransferBytes)),
-        loadedDiagramImages: median(samples.map(sample => sample[phase].loadedDiagramImages)),
-        placeholderFrames: median(samples.map(sample => sample[phase].placeholderFrames)),
-      }])),
-      samples,
-    }
-    await writeFile(join(directory, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`)
-    console.log(JSON.stringify({ directory, medians: summary.medians }))
-  })
-})

+ 0 - 2
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -172,9 +172,7 @@
 - paragraph:
   - img "Token preview"
 - text: css
-- button "Line numbers"
 - button "Copy"
-- text: Source
 - code: "--inline-code: #F5F5F5;"
 - paragraph: LINK_GALLERY_DONE
 - heading "Footnotes" [level=2]

+ 0 - 2
apps/web/tests/expected/cordis-history/ui.expected.md

@@ -46,9 +46,7 @@
   - tab "Host" [selected]
 - tabpanel "Host":
   - text: javascript
-  - button "Line numbers"
   - button "Copy"
-  - text: Source
   - code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
 - text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings
 - button "Inspect"

+ 0 - 4
apps/web/tests/expected/markdown-mermaid/lightbox.expected.md

@@ -1,4 +0,0 @@
-- dialog "Mermaid diagram":
-  - img "Mermaid diagram"
-  - text: Drag or arrow keys to pan · Scroll or +/− to zoom · Double-click or Home to fit
-  - button "Close preview"

+ 0 - 6
apps/web/tests/expected/markdown-mermaid/offscreen.expected.md

@@ -1,6 +0,0 @@
-- text: mermaid
-- button "Enlarge preview" [disabled]
-- button "Copy"
-- button "Source"
-- button "Preview" [pressed]
-- status: Generating diagram…

+ 0 - 427
apps/web/tests/expected/markdown-mermaid/source-highlight.expected.md

@@ -1,427 +0,0 @@
-[
-  {
-    "language": "mermaid",
-    "lines": [
-      [
-        {
-          "text": "flowchart",
-          "style": "color: var(--shiki-token-keyword);"
-        },
-        {
-          "text": " LR",
-          "style": "color: var(--shiki-token-function);"
-        }
-      ],
-      [
-        {
-          "text": "  A",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "[",
-          "style": "color: var(--shiki-token-keyword);"
-        },
-        {
-          "text": "输入",
-          "style": "color: var(--shiki-token-string);"
-        },
-        {
-          "text": "]",
-          "style": "color: var(--shiki-token-keyword);"
-        },
-        {
-          "text": " --",
-          "style": "color: var(--shiki-token-keyword);"
-        },
-        {
-          "text": "> B[共享渲染器] ",
-          "style": "color: var(--shiki-token-string);"
-        },
-        {
-          "text": "-->",
-          "style": "color: var(--shiki-token-keyword);"
-        },
-        {
-          "text": " C",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "[",
-          "style": "color: var(--shiki-token-keyword);"
-        },
-        {
-          "text": "图形预览",
-          "style": "color: var(--shiki-token-string);"
-        },
-        {
-          "text": "]",
-          "style": "color: var(--shiki-token-keyword);"
-        }
-      ]
-    ]
-  },
-  {
-    "language": "dot",
-    "lines": [
-      [
-        {
-          "text": "digraph",
-          "style": "color: var(--shiki-token-keyword);"
-        },
-        {
-          "text": " { ",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "rankdir",
-          "style": "color: var(--shiki-token-constant);"
-        },
-        {
-          "text": "=LR; Input ",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "->",
-          "style": "color: var(--shiki-token-keyword);"
-        },
-        {
-          "text": " Preview }",
-          "style": "color: var(--shiki-foreground);"
-        }
-      ]
-    ]
-  },
-  {
-    "language": "svg",
-    "lines": [
-      [
-        {
-          "text": "<",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "svg",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " xmlns",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"http://www.w3.org/2000/svg\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " width",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"460\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " height",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"100\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " onload",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"parent.document.body.dataset.previewEscaped='yes'\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": ">",
-          "style": "color: var(--shiki-foreground);"
-        }
-      ],
-      [
-        {
-          "text": "<",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "rect",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " width",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"460\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " height",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"100\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " fill",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"lightblue\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": "/><",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "text",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " x",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"20\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " y",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"55\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": ">SVG preview</",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "text",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": ">",
-          "style": "color: var(--shiki-foreground);"
-        }
-      ],
-      [
-        {
-          "text": "<",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "script",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": ">parent.document.body.dataset.previewEscaped='yes';alert('unsafe SVG')</",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "script",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": ">",
-          "style": "color: var(--shiki-foreground);"
-        }
-      ],
-      [
-        {
-          "text": "<",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "image",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " href",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"https://preview.invalid/svg-image\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " width",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"1\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " height",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"1\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": "/>",
-          "style": "color: var(--shiki-foreground);"
-        }
-      ],
-      [
-        {
-          "text": "<",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "foreignObject",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " width",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"1\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " height",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"1\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": "><",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "div",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": " xmlns",
-          "style": "color: var(--shiki-token-function);"
-        },
-        {
-          "text": "=",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "\"http://www.w3.org/1999/xhtml\"",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": "><",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "script",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": ">alert('unsafe HTML')</",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "script",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": "></",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "div",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": "></",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "foreignObject",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": ">",
-          "style": "color: var(--shiki-foreground);"
-        }
-      ],
-      [
-        {
-          "text": "</",
-          "style": "color: var(--shiki-foreground);"
-        },
-        {
-          "text": "svg",
-          "style": "color: var(--shiki-token-string-expression);"
-        },
-        {
-          "text": ">",
-          "style": "color: var(--shiki-foreground);"
-        }
-      ]
-    ]
-  }
-]

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

@@ -1,42 +0,0 @@
-- heading "Mermaid previews" [level=1]
-- text: mermaid
-- button "Enlarge preview"
-- button "Copy"
-- button "Source"
-- button "Preview" [pressed]
-- img "Mermaid diagram"
-- text: mermaid
-- button "Enlarge preview"
-- button "Copy"
-- button "Source"
-- button "Preview" [pressed]
-- img "Mermaid diagram"
-- text: mermaid
-- button "Enlarge preview" [disabled]
-- button "Copy"
-- button "Source"
-- button "Preview" [pressed]
-- status: Unable to render this diagram. Select Source to inspect the code.
-- text: mermaid
-- button "Enlarge preview"
-- button "Copy"
-- button "Source"
-- button "Preview" [pressed]
-- img "Mermaid diagram"
-- text: dot
-- button "Enlarge preview"
-- button "Copy"
-- button "Source"
-- button "Preview" [pressed]
-- img "Graphviz diagram"
-- text: svg
-- button "Enlarge preview"
-- button "Copy"
-- button "Source"
-- button "Preview" [pressed]
-- img "SVG preview"
-- text: html
-- button "Line numbers"
-- button "Copy"
-- text: Source
-- code: <h2>Static HTML</h2>

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

@@ -1,4 +0,0 @@
-- dialog "Mermaid 图表":
-  - img "Mermaid 图表"
-  - text: 拖拽或方向键移动 · 滚轮或 +/− 缩放 · 双击或 Home 适应屏幕
-  - button "关闭预览"

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

@@ -1,42 +0,0 @@
-- heading "Mermaid previews" [level=1]
-- text: mermaid
-- button "放大预览"
-- button "复制"
-- button "源码"
-- button "预览" [pressed]
-- img "Mermaid 图表"
-- text: mermaid
-- button "放大预览"
-- button "复制"
-- button "源码"
-- button "预览" [pressed]
-- img "Mermaid 图表"
-- text: mermaid
-- button "放大预览" [disabled]
-- button "复制"
-- button "源码"
-- button "预览" [pressed]
-- status: 无法渲染此图表,可切换到源码查看。
-- text: mermaid
-- button "放大预览"
-- button "复制"
-- button "源码"
-- button "预览" [pressed]
-- img "Mermaid 图表"
-- text: dot
-- button "放大预览"
-- button "复制"
-- button "源码"
-- button "预览" [pressed]
-- img "Graphviz 图表"
-- text: svg
-- button "放大预览"
-- button "复制"
-- button "源码"
-- button "预览" [pressed]
-- img "SVG 预览"
-- text: html
-- button "行号"
-- button "复制"
-- text: 源码
-- code: <h2>Static HTML</h2>

+ 0 - 2
apps/web/tests/expected/thinking-markdown/ui.expected.md

@@ -216,8 +216,6 @@
   - text: .
 - math: a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + 1 1 + 1 x
 - text: typescript
-- button "Line numbers"
 - button "Copy"
-- text: Source
 - code: const value = "reasoning code"
 - paragraph: {{longToken}}

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

@@ -1,631 +0,0 @@
-import { fileURLToPath } from 'node:url'
-import { join } from 'node:path'
-import type { Browser, Locator, 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 = '<h2>Static HTML</h2>'
-
-const CPU = `flowchart LR
-    subgraph CPU["CPU 中央处理器"]
-        direction TB
-        CU["控制器 Control Unit"]
-        ALU["运算器 ALU"]
-        REG["寄存器 Registers"]
-    end
-
-    MEM["存储器 Memory(指令 + 数据)"]
-    IN["输入设备 Input"]
-    OUT["输出设备 Output"]
-
-    IN -->|"数据 / 指令"| MEM
-    MEM -->|"指令"| CU
-    MEM -->|"数据"| REG
-    REG -->|"数据"| MEM
-    CU -->|"控制信号"| ALU
-    CU -->|"控制信号"| MEM
-    CU -->|"地址总线"| MEM
-    ALU -->|"结果"| REG
-    MEM -->|"数据"| OUT
-    CU -->|"控制信号"| OUT`
-
-function fixture(diagramSources?: string[], trailingText = ''): 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: diagramSources === undefined ? [
-        '# 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') : ['# Mermaid previews', ...diagramSources.map(code => `\`\`\`mermaid\n${code}\n\`\`\``), trailingText].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()
-}
-
-async function settleBox(locator: Locator): Promise<{ width: number; height: number }> {
-  return await locator.evaluate(async (node) => {
-    let previous = { width: -1, height: -1 }
-    let stableFrames = 0
-    for (let frame = 0; frame < 180; frame += 1) {
-      await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
-      const { width, height } = node.getBoundingClientRect()
-      const current = { width, height }
-      stableFrames = Math.abs(width - previous.width) < 0.25 && Math.abs(height - previous.height) < 0.25
-        ? stableFrames + 1
-        : 0
-      if (stableFrames === 5) return current
-      previous = current
-    }
-    throw new Error('layout did not settle within 180 animation frames')
-  })
-}
-
-async function showAllPreviews(page: Page, label = 'Preview'): Promise<void> {
-  const blocks = page.locator('.md-code-block')
-  for (let index = 0; index < await blocks.count(); index += 1) {
-    const button = blocks.nth(index).getByRole('button', { name: label, exact: true })
-    if (await button.count() !== 1) continue
-    await button.click()
-    await expect.poll(() => blocks.nth(index).locator('[data-preview-placeholder]').count()).toBe(0)
-  }
-}
-
-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')('keeps preview and source at the same height across viewport widths', async () => {
-    const page = await newEnglishPage(browser)
-    let cpuScaffold: WebScaffold | undefined
-    try {
-      cpuScaffold = await launchWebScaffold({})
-      await seedSession(cpuScaffold, fixture([CPU]), SEED_ID)
-      await openConversation(page, cpuScaffold)
-      const block = page.locator('.md-code-block')
-      await block.scrollIntoViewIfNeeded()
-      const image = block.getByRole('img', { name: 'Mermaid diagram', exact: true })
-      await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
-      expect(await block.locator('pre').count()).toBe(0)
-      const heights: number[] = []
-      for (const width of [1680, 360]) {
-        await page.setViewportSize({ width, height: 1000 })
-        await settleBox(page.locator('[data-chat-flow]'))
-        await image.waitFor({ state: 'visible' })
-        const previewBlock = await settleBox(block)
-        const previewGeometry = await block.evaluate((node) => {
-          const body = node.querySelector<HTMLElement>('[data-code-block-content]')!
-          const source = node.querySelector<HTMLElement>('[data-code-block-source-view]')!
-          const preview = node.querySelector<HTMLElement>('[data-code-block-preview]')!
-          return {
-            block: node.getBoundingClientRect().height,
-            body: body.getBoundingClientRect().height,
-            source: source.closest('.md-code-block')!.querySelector('[data-code-block-content]')!.getBoundingClientRect().height,
-            preview: preview.getBoundingClientRect().height,
-            sourceVisibility: getComputedStyle(source).visibility,
-            sourceAriaHidden: source.getAttribute('aria-hidden'),
-          }
-        })
-        expect(previewGeometry.sourceVisibility).toBe('hidden')
-        expect(previewGeometry.sourceAriaHidden).toBe('true')
-        expect(previewGeometry.preview).toBeCloseTo(previewGeometry.source, 0)
-        expect(previewGeometry.body).toBeCloseTo(previewGeometry.source, 0)
-        expect(await block.locator('pre:visible').count()).toBe(0)
-        await block.getByRole('button', { name: 'Source', exact: true }).click()
-        await block.locator('pre:visible').waitFor()
-        const sourceBlock = await settleBox(block)
-        expect(sourceBlock.height).toBeCloseTo(previewBlock.height, 0)
-        expect(sourceBlock.width).toBeCloseTo(previewBlock.width, 0)
-        await block.getByRole('button', { name: 'Preview', exact: true }).click()
-        await image.waitFor({ state: 'visible' })
-        const restoredBlock = await settleBox(block)
-        expect(restoredBlock.height).toBeCloseTo(previewBlock.height, 0)
-        expect(restoredBlock.width).toBeCloseTo(previewBlock.width, 0)
-        heights.push(previewGeometry.source)
-      }
-      expect(heights.every(height => height > 0 && height <= 632)).toBe(true)
-      expect(await block.locator('[data-code-block-resize]').count()).toBe(0)
-      expect(await block.getByRole('button', { name: 'Reset size', exact: true }).count()).toBe(0)
-      await block.getByRole('button', { name: 'Source', exact: true }).click()
-      expect(await block.locator('pre code').textContent()).toBe(CPU)
-    } catch (error) {
-      await saveFailureShot(page, 'web-e2e-cpu-preview')
-      throw error
-    } finally {
-      await page.close()
-      await cpuScaffold?.close()
-    }
-  }, 60_000)
-
-  it.skipIf(MODE === 'record')('retains both equal-height views and keeps toolbar geometry stable', async () => {
-    const page = await newEnglishPage(browser)
-    try {
-      await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
-      await openConversation(page, scaffold)
-      const block = page.locator('.md-code-block').nth(5)
-      await block.scrollIntoViewIfNeeded()
-      const image = block.getByRole('img', { name: 'SVG preview', exact: true })
-      await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
-      const imgNode = await image.elementHandle()
-      const sourceView = block.locator('[data-code-block-source-view]')
-      const previewLayer = block.locator('[data-code-block-preview]')
-      expect(await sourceView.getAttribute('aria-hidden')).toBe('true')
-      expect(await block.locator('pre').count()).toBe(0)
-      const previewHeight = (await previewLayer.boundingBox())!.height
-      expect(await imgNode!.evaluate(node => node.isConnected)).toBe(true)
-      const sourceButton = block.getByRole('button', { name: 'Source', exact: true })
-      await sourceButton.click()
-      await block.locator('pre.shiki:visible').waitFor()
-      const source = await block.locator('pre.shiki').elementHandle()
-      expect(await block.locator('pre code').textContent()).toBe(SVG)
-      expect(await block.getByRole('button', { name: 'Enlarge preview', exact: true }).count()).toBe(0)
-      expect(await sourceView.getAttribute('aria-hidden')).toBeNull()
-      const numbers = block.getByRole('button', { name: 'Line numbers', exact: true })
-      await numbers.click()
-      expect(await numbers.getAttribute('aria-pressed')).toBe('true')
-      expect(await block.getAttribute('data-line-numbers')).toBe('true')
-      expect(await source!.evaluate(node => node.isConnected)).toBe(true)
-      const copy = block.getByRole('button', { name: 'Copy', exact: true })
-      const selectionGeometry = () => sourceButton.evaluate((node) => {
-        const rect = node.getBoundingClientRect()
-        return { width: rect.width, height: rect.height, right: node.closest('.md-code-block')!.getBoundingClientRect().right - rect.right }
-      })
-      const toolbarBefore = await selectionGeometry()
-      await copy.click()
-      await block.getByRole('button', { name: 'Copied', exact: true }).waitFor()
-      expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(SVG)
-      expect(await selectionGeometry()).toEqual(toolbarBefore)
-      const sourceHeight = (await block.locator('[data-code-block-content]').boundingBox())!.height
-      expect(sourceHeight).toBeCloseTo(previewHeight, 0)
-      await block.getByRole('button', { name: 'Preview', exact: true }).click()
-      expect(await source!.evaluate(node => node.isConnected)).toBe(true)
-      expect(await sourceView.getAttribute('aria-hidden')).toBe('true')
-      expect((await previewLayer.boundingBox())!.height).toBeCloseTo(sourceHeight, 0)
-      expect(await imgNode!.evaluate(node => node.isConnected)).toBe(true)
-      expect(await block.locator('[data-code-block-resize]').count()).toBe(0)
-      await page.setViewportSize({ width: 360, height: 800 })
-      await image.waitFor({ state: 'visible' })
-      expect((await block.boundingBox())!.width).toBeLessThanOrEqual(360)
-    } finally {
-      await page.close()
-    }
-  })
-
-  it.skipIf(MODE === 'record')('fades only preview toolbar opacity and reveals it for hover or keyboard focus', async () => {
-    const page = await newEnglishPage(browser)
-    try {
-      await openConversation(page, scaffold)
-      const block = page.locator('.md-code-block').first()
-      await block.scrollIntoViewIfNeeded()
-      await block.getByRole('img', { name: 'Mermaid diagram', exact: true }).evaluate(async (node: HTMLImageElement) => { await node.decode() })
-      const toolbar = block.locator('[data-code-block-banner]').locator('..')
-      await block.hover()
-      await expect.poll(() => toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
-      const shown = await block.boundingBox()
-      const toolbarBox = await toolbar.boundingBox()
-      await page.mouse.move(0, 0)
-      await expect.poll(() => toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('0')
-      expect(await block.boundingBox()).toEqual(shown)
-      expect(await toolbar.boundingBox()).toEqual(toolbarBox)
-      expect(await toolbar.evaluate(node => getComputedStyle(node).transitionDuration)).toBe('0.16s')
-      await block.getByRole('button', { name: 'Source', exact: true }).focus()
-      await page.keyboard.press('Shift+Tab')
-      await expect.poll(() => toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
-      await block.getByRole('button', { name: 'Source', exact: true }).click()
-      await page.getByRole('heading', { name: 'Mermaid previews' }).click()
-      await page.mouse.move(0, 0)
-      expect(await toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
-      expect(await block.locator('[data-code-block-resize]').count()).toBe(0)
-      await page.emulateMedia({ reducedMotion: 'reduce' })
-      expect(await toolbar.evaluate(node => getComputedStyle(node).transitionDuration)).toBe('0s')
-    } finally {
-      await page.close()
-    }
-  })
-
-  it.skipIf(MODE === 'record')('highlights diagram source with theme tokens', async () => {
-    const page = await newEnglishPage(browser)
-    try {
-      await openConversation(page, scaffold)
-      const trees = []
-      for (const [index, language, source] of [[0, 'mermaid', FLOW], [4, 'dot', DOT], [5, 'svg', SVG]] as const) {
-        const block = page.locator('.md-code-block').nth(index)
-        await block.scrollIntoViewIfNeeded()
-        await block.getByRole('button', { name: 'Source', exact: true }).click()
-        await block.locator('pre.shiki').waitFor()
-        expect(await block.locator('pre code').textContent()).toBe(source)
-        const lines = await block.locator('pre.shiki .line').evaluateAll(nodes => nodes.map(line =>
-          [...line.querySelectorAll('span')].map(span => ({ text: span.textContent, style: span.style.cssText })),
-        ))
-        expect(new Set(lines.flat().map(span => span.style)).size).toBeGreaterThan(1)
-        trees.push({ language, lines })
-      }
-      await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'source-highlight.expected.md'), JSON.stringify(trees, null, 2), MODE)
-    } finally {
-      await page.close()
-    }
-  })
-
-  it.skipIf(MODE === 'record').each(['light', 'dark'] as const)(
-    'fits diagram previews to image dimensions within viewport limits 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)
-        await showAllPreviews(page)
-
-
-        for (const title of ['Mermaid diagram', 'Graphviz diagram', 'SVG preview']) {
-          const image = page.getByRole('img', { name: title, exact: true }).first()
-          await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
-          const geometry = await image.evaluate((node) => {
-            const block = node.closest('.md-code-block')!
-            const source = block.querySelector<HTMLElement>('[data-code-block-source-view]')!
-            const preview = block.querySelector<HTMLElement>('[data-code-block-preview]')!
-            return {
-              image: node.getBoundingClientRect().height,
-              source: source.closest('.md-code-block')!.querySelector('[data-code-block-content]')!.getBoundingClientRect().height,
-              preview: preview.getBoundingClientRect().height,
-              client: preview.clientHeight,
-              scroll: preview.scrollHeight,
-            }
-          })
-          expect(geometry.image).toBeGreaterThan(0)
-          expect(geometry.preview).toBeCloseTo(geometry.source, 0)
-          expect(geometry.scroll).toBeGreaterThanOrEqual(geometry.client)
-          expect(geometry.image).toBeLessThanOrEqual(600)
-          expect(geometry.preview).toBeCloseTo(Math.max(120, geometry.image + 32), 0)
-        }
-
-        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,
-          sourceHeight: node.closest('.md-code-block')!.querySelector<HTMLElement>('[data-code-block-content]')!.getBoundingClientRect().height,
-          previewHeight: node.closest('.md-code-block')!.querySelector<HTMLElement>('[data-code-block-preview]')!.getBoundingClientRect().height,
-          ratio: node.naturalWidth / node.naturalHeight,
-        }))
-        expect(compact.width / compact.height).toBeCloseTo(compact.ratio, 1)
-        expect(compact.previewHeight).toBeCloseTo(compact.sourceHeight, 0)
-      } 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)
-      await page.locator('.md-code-block').first().getByRole('button', { name: 'Preview', exact: true }).click()
-      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)
-        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 first = page.locator('.md-code-block').first()
-    const invalid = page.locator('.md-code-block').nth(2)
-    await invalid.scrollIntoViewIfNeeded()
-    await invalid.getByRole('status').filter({ hasText: 'Unable to render this diagram' }).waitFor()
-    await invalid.getByRole('button', { name: 'Source', exact: true }).click()
-    expect((await invalid.locator('[data-code-block-content]').boundingBox())!.height).toBeCloseTo(120, 0)
-    await invalid.getByRole('button', { name: 'Preview', exact: true }).click()
-    expect(await first.locator('pre').count()).toBe(0)
-    expect(await first.locator('[data-code-block-source-view]').getAttribute('aria-hidden')).toBe('true')
-    expect(await first.getByText('mermaid', { exact: true }).count()).toBe(1)
-    expect(await page.getByRole('button', { name: 'Preview', exact: true }).count()).toBe(6)
-    const html = page.locator('.md-code-block').nth(6)
-    expect(await html.locator('pre code').textContent()).toBe(HTML)
-    expect(await html.getByRole('button', { name: 'Preview', exact: true }).count()).toBe(0)
-    await first.scrollIntoViewIfNeeded()
-    const image = first.getByRole('img', { name: 'Mermaid diagram', exact: true })
-    await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
-    const diagram = await image.elementHandle()
-    const layout = await first.evaluate((block) => {
-      const source = block.querySelector<HTMLElement>('[data-code-block-source-view]')!
-      const preview = block.querySelector<HTMLElement>('[data-code-block-preview]')!
-      return {
-        block: block.getBoundingClientRect().height,
-        source: source.closest('.md-code-block')!.querySelector('[data-code-block-content]')!.getBoundingClientRect().height,
-        preview: preview.getBoundingClientRect().height,
-        width: block.getBoundingClientRect().width,
-      }
-    })
-    expect(layout.preview).toBeCloseTo(layout.source, 0)
-    await first.getByRole('button', { name: 'Enlarge preview', exact: true }).click()
-    const dialog = page.getByRole('dialog', { name: 'Mermaid diagram', exact: true })
-    const enlarged = dialog.getByRole('img', { name: 'Mermaid diagram', exact: true })
-    const enlargedGeometry = await enlarged.evaluate((node: HTMLImageElement) => ({
-      width: node.getBoundingClientRect().width,
-      height: node.getBoundingClientRect().height,
-      ratio: node.naturalWidth / node.naturalHeight,
-    }))
-    const viewport = page.viewportSize()!
-    expect(enlargedGeometry.width).toBeGreaterThan(layout.width)
-    expect(enlargedGeometry.width / enlargedGeometry.height).toBeCloseTo(enlargedGeometry.ratio, 1)
-    expect(Math.max(enlargedGeometry.width / (viewport.width * 0.88), enlargedGeometry.height / (viewport.height * 0.84))).toBeCloseTo(1, 2)
-    const fitted = (await enlarged.boundingBox())!
-    expect(fitted.x).toBeGreaterThanOrEqual(viewport.width * 0.06 - 1)
-    expect(fitted.y).toBeGreaterThanOrEqual(viewport.height * 0.08 - 1)
-    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'lightbox.expected.md'), await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
-    const anchor = { x: fitted.x + fitted.width * 0.6, y: fitted.y + fitted.height * 0.6 }
-    await page.mouse.move(anchor.x, anchor.y)
-    await page.mouse.wheel(0, -360)
-    await expect.poll(async () => (await enlarged.boundingBox())!.width).toBeGreaterThan(fitted.width * 1.5)
-    const zoomed = (await enlarged.boundingBox())!
-    expect((anchor.x - zoomed.x) / zoomed.width).toBeCloseTo(0.6, 2)
-    expect((anchor.y - zoomed.y) / zoomed.height).toBeCloseTo(0.6, 2)
-    await page.mouse.down()
-    await page.mouse.move(anchor.x + 100, anchor.y + 60, { steps: 15 })
-    await page.mouse.up()
-    const panned = (await enlarged.boundingBox())!
-    expect(panned.x - zoomed.x).toBeCloseTo(100, 0)
-    expect(panned.y - zoomed.y).toBeCloseTo(60, 0)
-    expect(panned.width).toBeCloseTo(zoomed.width, 2)
-    await page.mouse.dblclick(viewport.width / 2, viewport.height / 2)
-    await expect.poll(async () => (await enlarged.boundingBox())!.width).toBe(fitted.width)
-    expect((await enlarged.boundingBox())!.x).toBe(fitted.x)
-    expect(await image.getAttribute('src')).toBe(await enlarged.getAttribute('src'))
-    await dialog.getByRole('button', { name: 'Close preview', exact: true }).click()
-    await showAllPreviews(page)
-    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.getByRole('status').filter({ hasText: 'Unable to render this diagram. Select Source to inspect the code.' }).count()).toBe(1)
-    const failed = page.locator('.md-code-block').filter({ has: page.locator('[role="status"]') })
-    await failed.getByRole('button', { name: 'Source', exact: true }).click()
-    expect(await failed.locator('pre code').textContent()).toBe(INVALID)
-    await failed.getByRole('button', { name: 'Preview', exact: true }).click()
-    expect(await images.first().evaluate(node => decodeURIComponent((node as HTMLImageElement).src))).toContain('共享渲染器')
-    await first.getByRole('button', { name: 'Copy', exact: true }).click()
-    expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(FLOW)
-    await first.getByRole('button', { name: 'Source', exact: true }).focus()
-    await page.keyboard.press('Enter')
-    expect(await first.getByRole('button', { name: 'Source', 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 && getComputedStyle(node).visibility === 'hidden')).toBe(true)
-    await first.getByRole('button', { name: 'Preview', exact: true }).click()
-    await first.getByRole('img', { name: 'Mermaid diagram' }).waitFor()
-    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)
-    expect(await page.getByRole('button', { name: '预览', exact: true }).count()).toBe(6)
-    const first = page.locator('.md-code-block').first()
-    expect(await first.locator('pre').count()).toBe(0)
-    expect(await first.locator('[data-code-block-source-view]').getAttribute('aria-hidden')).toBe('true')
-    await first.getByRole('button', { name: '预览', exact: true }).click()
-    const firstImage = page.getByRole('img', { name: 'Mermaid 图表' }).first()
-    await expect.poll(() => firstImage.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
-    await first.getByRole('button', { name: '放大预览', exact: true }).click()
-    await page.getByRole('dialog', { name: 'Mermaid 图表' }).waitFor()
-    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'zh-lightbox.expected.md'), await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
-    await page.keyboard.press('Escape')
-    expect(await first.getByRole('button', { name: '源码', exact: true }).textContent()).toBe('源码')
-    expect(await first.getByRole('button', { name: '复制', exact: true }).getAttribute('aria-label')).toBe('复制')
-    await showAllPreviews(page, '预览')
-    await page.getByRole('status').filter({ hasText: '无法渲染此图表,可切换到源码查看。' }).waitFor()
-    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', 'source-highlight.expected.md', 'lightbox.expected.md', 'zh-lightbox.expected.md', 'offscreen.expected.md'])
-    await page.close()
-  }, 60_000)
-
-  it.skipIf(MODE === 'record')('defers offscreen Mermaid and Graphviz runtimes until a preview enters the viewport', async () => {
-    const page = await newEnglishPage(browser)
-    let history: WebScaffold | undefined
-    const runtimes: string[] = []
-    page.on('request', (request) => {
-      const path = new URL(request.url()).pathname
-      if (/\/(mermaid\.core|viz)-[^/]+\.js$/.test(path)) runtimes.push(path)
-    })
-    try {
-      history = await launchWebScaffold({})
-      const tail = ['```dot', DOT, '```', ...Array.from({ length: 40 }, (_, index) => `Closing note ${index + 1}.`), 'History complete.'].join('\n\n')
-      await seedSession(history, fixture(Array.from({ length: 20 }, (_, index) => index === 0 ? FLOW : SEQUENCE), tail), SEED_ID)
-      await openConversation(page, history)
-      await page.getByText('History complete.', { exact: true }).waitFor()
-      await page.evaluate(async () => {
-        await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { resolve() })))
-      })
-      const blocks = page.locator('.md-code-block')
-      expect(await blocks.count()).toBe(21)
-      expect(await blocks.locator('img').count()).toBe(0)
-      expect(runtimes).toEqual([])
-      await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'offscreen.expected.md'),
-        await captureStableAria(page, '.md-code-block', history.workspaceCwd), MODE)
-      await blocks.first().scrollIntoViewIfNeeded()
-      await blocks.first().getByRole('img', { name: 'Mermaid diagram', exact: true })
-        .evaluate(async (node: HTMLImageElement) => { await node.decode() })
-      expect(runtimes.some(path => path.includes('/mermaid.core-'))).toBe(true)
-      expect(runtimes.some(path => path.includes('/viz-'))).toBe(false)
-      await blocks.last().scrollIntoViewIfNeeded()
-      await blocks.last().getByRole('img', { name: 'Graphviz diagram', exact: true })
-        .evaluate(async (node: HTMLImageElement) => { await node.decode() })
-      expect(runtimes.some(path => path.includes('/viz-'))).toBe(true)
-    } finally {
-      await page.close()
-      await history?.close()
-    }
-  }, 60_000)
-
-  it.skipIf(MODE === 'record')('rejects native Mermaid image nodes before requests and renders the next diagram', async () => {
-    const page = await newEnglishPage(browser)
-    let imageScaffold: WebScaffold | undefined
-    const requests: string[] = []
-    try {
-      await page.route('https://preview.invalid/**', async (route) => {
-        requests.push(route.request().url())
-        await route.abort()
-      })
-      imageScaffold = await launchWebScaffold({})
-      await seedSession(imageScaffold, fixture([
-        'flowchart LR\n  A@{ img: "https://preview.invalid/mermaid-image", h: 80 }', FLOW,
-      ]), SEED_ID)
-      await openConversation(page, imageScaffold)
-      await page.locator('.md-code-block').first().scrollIntoViewIfNeeded()
-      await page.locator('.md-code-block').first().getByRole('status')
-        .filter({ hasText: 'Unable to render this diagram' }).waitFor()
-      await page.locator('.md-code-block').nth(1).scrollIntoViewIfNeeded()
-      const image = page.getByRole('img', { name: 'Mermaid diagram', exact: true })
-      await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
-      expect(requests).toEqual([])
-    } finally {
-      await page.close()
-      await imageScaffold?.close()
-    }
-  }, 60_000)
-
-  it.skipIf(MODE === 'record')('previews inert DOT and SVG images', async () => {
-    const page = await newEnglishPage(browser)
-    const requests: string[] = []
-    const dialogs: string[] = []
-    // A data-URL SVG image must not fetch source-authored external resources.
-    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 [index, title, code] of [[4, 'Graphviz diagram', DOT], [5, 'SVG preview', SVG]] as const) {
-      const block = page.locator('.md-code-block').nth(index)
-      await block.getByRole('button', { name: 'Preview', exact: true }).click()
-      const preview = block.getByRole('img', { name: title, exact: true })
-      await preview.waitFor()
-      await expect.poll(() => preview.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
-      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()
-      await preview.waitFor()
-    }
-    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()
-  })
-})

+ 0 - 2
apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md

@@ -22,9 +22,7 @@
   - img
   - text: Context injection @deepseek-ai/dsh-system-prompt
 - text: ts
-- button "Line numbers"
 - button "Copy"
-- text: Source
 - code: "const first: number = 1 const second = \"two\" let tail"
 - status: Deep diving...
 - textbox "Message or run a task, / commands, @ files or sessions"

+ 0 - 34
apps/web/tests/snapshots/streaming-fence-highlight/preview-placeholder.expected.md

@@ -1,34 +0,0 @@
-- banner:
-  - navigation "Session hierarchy":
-    - button "Stream an SVG diagram." [disabled]
-  - img
-  - text: Standard mode
-  - button "More actions":
-    - img
-  - button "Open right sidebar":
-    - img
-  - tablist:
-    - tab "Chat" [selected]
-    - tab "Trajectory"
-- button "System prompt":
-  - img
-  - img
-  - text: System prompt
-- text: Stream an SVG diagram. {{clock}}
-- button "Copy":
-  - img
-- button "Context injection @deepseek-ai/dsh-system-prompt":
-  - img
-  - img
-  - text: Context injection @deepseek-ai/dsh-system-prompt
-- text: svg
-- status: Generating diagram…
-- status: Deep diving...
-- textbox "Message or run a task, / commands, @ files or sessions"
-- button "Add files or run commands":
-  - img
-- 'button "Access mode, current: Workspace Write"': Workspace Write
-- button "Select model, current streaming-fence-highlight-test/streaming-fence":
-  - text: streaming-fence-highlight-test/streaming-fence
-  - img
-- button "Stop generating"

+ 5 - 65
apps/web/tests/streaming-fence-highlight.e2e.ts

@@ -58,21 +58,17 @@ class StreamingFenceAdapter extends LlmAdapter {
     this.finish()
   }
 
-  constructor(private readonly first = FIRST_REPLY, private readonly open = OPEN_REPLY, private readonly reply = REPLY) {
-    super()
-  }
-
   override async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
     yield { type: 'block-start', index: 0, blockType: 'text' }
-    yield { type: 'text-delta', index: 0, text: this.first }
+    yield { type: 'text-delta', index: 0, text: FIRST_REPLY }
     this.resolveFirstPaused()
     await this.firstContinuation
-    yield { type: 'text-delta', index: 0, text: this.open.slice(this.first.length) }
+    yield { type: 'text-delta', index: 0, text: OPEN_REPLY.slice(FIRST_REPLY.length) }
     this.resolveSecondPaused()
     await this.secondContinuation
     if (options.signal?.aborted === true) throw options.signal.reason
-    yield { type: 'text-delta', index: 0, text: this.reply.slice(this.open.length) }
-    yield { type: 'block-end', index: 0, block: { type: 'text', text: this.reply } }
+    yield { type: 'text-delta', index: 0, text: '\n```' }
+    yield { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }
     yield { type: 'finish', reason: { kind: 'stop' } }
   }
 }
@@ -133,62 +129,6 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting',
     await scaffold?.close()
   })
 
-  it('holds a stable image placeholder while a diagram streams, then restores preview controls', async () => {
-    const first = '```svg\n<svg xmlns="http://www.w3.org/2000/svg" width="460" height="100">\n'
-    const open = `${first}<rect width="460" height="100" fill="lightblue"/>\n`
-    const response = `${open}</svg>\n\`\`\``
-    const pending = new StreamingFenceAdapter(first, open, response)
-    const local = await launchWebScaffold()
-    let previewPage: Page | undefined
-    let settled: ReturnType<WebScaffold['whenTurnSettled']> | undefined
-    try {
-      local.ctx.effect(() => local.ctx.llm.registerAdapter([PROVIDER], pending), 'streaming preview adapter')
-      await local.ctx.agentDefaultModel.saveSelection({ provider: PROVIDER, model: MODEL })
-      previewPage = await newEnglishPage(browser)
-      await previewPage.goto(local.authenticatedUrl, { waitUntil: 'load' })
-      await connectFreshWorkspace(previewPage, local.workspaceCwd)
-      const input = previewPage.locator('[data-composer-input]').first()
-      await writeComposerDraft(previewPage, input, 'Stream an SVG diagram.')
-      await input.press('Enter')
-      await pending.firstPaused
-      settled = local.whenTurnSettled(30_000)
-      const block = previewPage.locator('.md-code-block')
-      const placeholder = block.locator('[data-preview-placeholder]')
-      await placeholder.waitFor()
-      const height = (await block.boundingBox())!.height
-      expect((await placeholder.boundingBox())!.height).toBe(180)
-      expect(await block.locator('pre, button').count()).toBe(0)
-      const glyph = placeholder.locator('svg')
-      expect(await glyph.evaluate(node => getComputedStyle(node).animationDuration)).toBe('1.8s')
-      pending.grow()
-      await pending.secondPaused
-      expect((await block.boundingBox())!.height).toBe(height)
-      expect(await block.locator('pre, button').count()).toBe(0)
-      await compareOrRefreshGolden(
-        fileURLToPath(new URL('./snapshots/streaming-fence-highlight/preview-placeholder.expected.md', import.meta.url)),
-        await captureStableAria(previewPage, '[class*="centerCol"]', local.workspaceCwd), MODE,
-      )
-      await previewPage.emulateMedia({ reducedMotion: 'reduce' })
-      expect(await glyph.evaluate(node => getComputedStyle(node).animationName)).toBe('none')
-      pending.finish()
-      await settled
-      await block.getByRole('img', { name: 'SVG preview', exact: true }).waitFor()
-      expect(await placeholder.count()).toBe(0)
-      expect(await block.locator('pre').count()).toBe(0)
-      expect(await block.locator('[data-code-block-source-view]').getAttribute('aria-hidden')).toBe('true')
-      await block.getByRole('button', { name: 'Source', exact: true }).click()
-      expect(await block.locator('pre code').textContent()).toBe(response.slice(7, -4))
-    } finally {
-      pending.continue()
-      try {
-        await settled
-      } finally {
-        await previewPage?.close()
-        await local.close()
-      }
-    }
-  }, 60_000)
-
   it('renders the growing fence through shiki and preserves its token tree when the turn settles', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-streaming-fence-highlight'))
     const input = page.locator('[data-composer-input]').first()
@@ -240,6 +180,6 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting',
     }))).toEqual({ block: 'true', pre: 'true', line: 'true' })
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md', 'preview-placeholder.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md'])
   }, 60_000)
 })

+ 0 - 2
apps/web/tsconfig.json

@@ -96,7 +96,6 @@
     "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/cold-blank-session.e2e.ts",
@@ -135,7 +134,6 @@
     "tests/chat-continuous-conversation.e2e.ts",
     "tests/composer-tab-geometry.e2e.ts",
     "tests/complex-history.perf.ts",
-    "tests/diagram-preview.perf.ts",
     "tests/pwsh-terminal.e2e.ts",
     "tests/workflow-run.e2e.ts"
   ],

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

@@ -29,20 +29,6 @@ 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 {
@@ -170,7 +156,7 @@ export default defineConfig({
   // directory, and the served index resolves identically from the site root.
   base: './',
   plugins: [
-    previewNotices(), rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage(),
+    rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage(),
     productWebBundleIsolation(src('../..'), src('.')),
   ],
   build: {

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

@@ -36,19 +36,6 @@ export const en = {
   'truncated': 'Truncated',
   'json.label': 'JSON',
   'markdown.footnotes': 'Footnotes',
-  'markdown.preview.graphviz': 'Graphviz diagram',
-  'markdown.preview.svg': 'SVG preview',
-  'markdown.preview.error': 'Unable to render this preview. Select Source to inspect the code.',
-  'markdown.mermaid.diagram': 'Mermaid diagram',
-  'markdown.preview.preview': 'Preview',
-  'markdown.preview.source': 'Source',
-  'markdown.code.source': 'Source',
-  'markdown.code.lineNumbers': 'Line numbers',
-  'markdown.preview.pending': 'Generating diagram…',
-  'markdown.preview.zoom': 'Enlarge preview',
-  'markdown.preview.interaction': 'Drag or arrow keys to pan · Scroll or +/− to zoom · Double-click or Home to fit',
-  'markdown.preview.close': 'Close preview',
-  'markdown.mermaid.error': 'Unable to render this diagram. Select Source to inspect the code.',
   'markdown.truncatedCharacters': '… truncated at {total} characters',
   'number.thousand': '{value}K',
   'number.million': '{value}M',

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

@@ -34,19 +34,6 @@ export const zh = {
   'truncated': '已截断',
   'json.label': 'JSON',
   'markdown.footnotes': '脚注',
-  'markdown.preview.graphviz': 'Graphviz 图表',
-  'markdown.preview.svg': 'SVG 预览',
-  'markdown.preview.error': '无法渲染此预览,可切换到源码查看。',
-  'markdown.mermaid.diagram': 'Mermaid 图表',
-  'markdown.preview.preview': '预览',
-  'markdown.preview.source': '源码',
-  'markdown.code.source': '源码',
-  'markdown.code.lineNumbers': '行号',
-  'markdown.preview.pending': '正在生成图表…',
-  'markdown.preview.zoom': '放大预览',
-  'markdown.preview.interaction': '拖拽或方向键移动 · 滚轮或 +/− 缩放 · 双击或 Home 适应屏幕',
-  'markdown.preview.close': '关闭预览',
-  '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: 1c8d99b4a37fa82bdde5d6d97b021c3b714a3a2e
-README.zh.md: d2dd2462535892a1a0dd0d1bf57255e4177c5b5e
+README.md: b5314413df8195834152c354df89c25600afc63b
+README.zh.md: 23288907f6f0dd4dec457ac56e403b050b6ef4e5

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

@@ -16,7 +16,6 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
 
 - [Reference previews](#reference-previews)
 - [System prompt row](#system-prompt-row)
-- [Mermaid previews](#mermaid-previews)
 - [Turn token usage](#turn-token-usage)
 - [Completed-turn footer](#completed-turn-footer)
 - [Turn Process Folding](#turn-process-folding)
@@ -37,15 +36,6 @@ HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordi
 
 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 defaults to diagram previews for `mermaid`, `graphviz`/`dot`, and `svg` fences. Preview height follows the image within viewport limits; Source uses the same area and mounts on first selection. Both views copy the original source. The magnifier opens a viewport-fitted image with pan and zoom. Supported streaming fences show a placeholder; HTML fences remain code. Invalid diagrams show an error and keep Source available. Chat enables the shared [preview primitive](../ui-primitives/README.md) through localized labels. This presentation adds no model prompt, tool, or Session event.
-
------
-
 <a id="turn-token-usage"></a>
 ## Turn token usage
 

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

@@ -16,7 +16,6 @@ kind: "package-reference"
 
 - [引用预览](#reference-previews)
 - [系统提示词行](#system-prompt-row)
-- [Mermaid 预览](#mermaid-previews)
 - [轮次 token 用量](#turn-token-usage)
 - [已完成轮次的页脚](#completed-turn-footer)
 - [轮次过程折叠](#turn-process-folding)
@@ -39,13 +38,6 @@ Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注
 
 -----
 
-<a id="mermaid-previews"></a>
-## Mermaid 预览
-
-已定稿的 Assistant Markdown 默认把 `mermaid`、`graphviz`/`dot` 和 `svg` fence 显示为图表预览。预览高度按图片计算并受视口限制;源码在首次选中时挂载,使用同一区域。两种视图复制的都是原始源码。放大镜打开适应视口、支持平移与缩放的图片。受支持的流式 fence 显示占位;HTML fence 保持代码显示。非法图表显示错误并保留源码入口。Chat 通过本地化 label 启用共享的[预览原语](../ui-primitives/README.zh.md)。这种展示不增加模型提示词、工具或 Session 事件。
-
------
-
 <a id="turn-token-usage"></a>
 ## 轮次 token 用量
 

+ 1 - 16
packages/client/ui-chat/src/client/markdown-labels.ts

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

+ 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: e9a6b4d1271d3633b5cdb592ee322c3a60d0aa77
-README.zh.md: 9468ac4905019948bf0f8df5a08a97c80593d4d0
+README.md: d466de8e4a26a6476e373e19c45c72ae22197f1b
+README.zh.md: eb6f4ce836eb8a219aad38f0941e666f3391ebc6

+ 3 - 20
packages/client/ui-primitives/README.md

@@ -72,17 +72,7 @@ The catalog above lists what each export is for; this section covers the behavio
 
 ### Rendering agent output
 
-`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. 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, source-only rendering 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. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
-
-`CodeBlock` highlights `dot`/`graphviz`, `svg`, and `mermaid` when displaying source, with case-insensitive language hints. These grammars load on demand; mounted source automatically gains theme-token colors when loading completes. SVG shares the XML grammar. Mermaid uses the diagram-body rules from `@shikijs/langs/mermaid`. The pinned DOT grammar and its mechanical conversion are recorded in the [third-party notices](THIRD_PARTY_PREVIEW_NOTICES.txt).
-
-Supply `MarkdownLabels.preview` to show settled `mermaid`, `graphviz`/`dot`, and `svg` fences as previews by default. Supported streaming fences show a 180px image placeholder with a pulsing glyph and localized status; controls become available after streaming ends. Consumers without these labels show source. Mermaid and Graphviz use the active document palette on the code-block background and refresh intersecting previews or open lightboxes when resolved colors change. Offscreen previews defer rendering and runtime loading until entry. Explicit colors in DOT and SVG 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 or image-load failure shows the supplied error label with Source available in the toolbar; replacing the source discards late results from the previous render.
-
-`CodeBlock.preview` supplies a standard source-preview descriptor with a renderer and complete localized output and control labels. Callers do not pass React nodes. The language stays on the left; magnifier and copy icons precede the Source/Preview segmented control on the right. Only the selected background slides for 160ms; the body switches immediately. Unsupported blocks show a static selected Source label. Copy always reads source and confirms success through its icon and tooltip without changing toolbar width. Preview shows a magnifier that opens a body-portaled lightbox fitted within 88% of viewport width and 84% of its height. Drag to pan, scroll around the cursor to zoom, or double-click to fit again. Arrow keys pan, +/− zoom, and Home resets. The lightbox reuses the generated image; output without a usable image disables it with a localized accessible explanation. Source replaces the magnifier with a line-number toggle that preserves the source text and copy output. In Preview, the toolbar fades out through opacity alone over 160ms and appears on block hover or keyboard focus; touch devices keep it visible. Source keeps its toolbar visible.
-
-Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images in the same canvas as Mermaid; malformed SVG and DOT show an error and leave Source available. The browser sizes previews from the image aspect ratio and available width, limiting image height to the smaller of 60vh and 640px. The canvas has 16px padding and a 120px minimum height. Source mounts on first selection, scrolls within that same area, and remains mounted with the generated image across view changes. HTML fences use the ordinary highlighted-code presentation.
-
-[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 choices.
+`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. 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. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
 
 `MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache.
 
@@ -118,11 +108,7 @@ The package enforces one separation: presentational React atoms with zero Cordis
 
 ### Streaming markdown
 
-While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply. A final unclosed top-level fence keeps its parsed code node and sends only the last completed line plus the current partial line through the same GFM grammar; a closing fence or ambiguous parse returns to the ordinary tail path. Highlighting likewise resumes from saved Shiki grammar state and publishes only newly completed lines plus the mutable tail. Re-rendering the same highlight frame reuses its rendered body, so React render retries cannot append completed lines twice. `CodeBlock` seals completed lines into fixed-size React groups, reuses earlier groups, and retains the whole highlighted tree across settlement when code and language are unchanged. The settled full parse still resolves references that crossed the freeze boundary.
-
-### Preview ownership
-
-The streaming placeholder mounts neither the source highlighter nor the diagram renderer. Once streaming ends, the waiting canvas remains until the image loads; stopping a stream also exits the streaming wait. Reduced motion disables its 1.8-second opacity animation. A shared viewport observer activates intersecting previews; leaving cancels unfinished rendering unless the lightbox is open. Each mounted block retains its image across view changes and viewport exits. Reentry reuses matching source, renderer and palette results, including failures. Theme refreshes retain the loaded image and lightbox until the replacement loads; refresh failures keep the usable image and announce the error. Previewable blocks mount and highlight source on first selection, then retain its DOM; source-only blocks retain viewport activation. Independent memoized source and copy controls keep toolbar feedback and unchanged parent props from redoing source work, while grammar readiness is specific to the source language. The [interaction decision](../../../.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md) owns retention, and the [preview sizing decision](../../../.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.md) owns geometry.
+While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply. A final unclosed top-level fence keeps its parsed code node and sends only the last completed line plus the current partial line through the same GFM grammar; a closing fence or ambiguous parse returns to the ordinary tail path. Highlighting likewise resumes from saved Shiki grammar state and publishes only newly completed lines plus the mutable tail. `CodeBlock` seals completed lines into fixed-size React groups, reuses earlier groups, and retains the whole highlighted tree across settlement when code and language are unchanged. The settled full parse still resolves references that crossed the freeze boundary.
 
 ### Geometry and overflow
 
@@ -158,11 +144,8 @@ None; this package neither assembles nor sends a provider request.
 
 <a id="known-limitations-and-deferred-work"></a>
 
-These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap.
 
-- **Diagram preview limits** — Mermaid image nodes are rejected before layout to prevent source-authored resource loads. Browsers without IntersectionObserver render immediately.
-
-- **Diagram rendering runs on the browser thread** — Mermaid serializes layouts; Graphviz performs synchronous WebAssembly layout. Cancellation skips queued work but cannot interrupt active layout. Loaded images and visited source DOM remain until unmount; viewport deferral is not virtualization or worker rendering.
+These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap.
 
 - **Diff search is bounded, input processing is linear** — the edit-distance limit trades precise alignment for a coarse replacement on heavily changed fragments. Normalization, fallback rows, and copied output still scale with input size; the height cap limits visible rows, not those allocations.
 - **Known-site marks are a fixed list** — only the named hosts resolve to their own mark, and every other external host keeps the globe; recognizing an arbitrary site would require fetching its icon over the network.

+ 3 - 20
packages/client/ui-primitives/README.zh.md

@@ -72,17 +72,7 @@ kind: "package-library"
 
 ### 渲染 agent 输出
 
-`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 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,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
-
-`CodeBlock` 显示源码时对 `dot`/`graphviz`、`svg` 与 `mermaid` 高亮,语言提示不区分大小写。这些语法按需加载;加载完成后,已挂载的源码会自动采用主题 token 颜色。SVG 复用 XML 语法。Mermaid 使用 `@shikijs/langs/mermaid` 的图表正文规则。固定的 DOT 语法及其机械转换记录在[第三方声明](THIRD_PARTY_PREVIEW_NOTICES.txt)中。
-
-传入 `MarkdownLabels.preview`,让定稿后的 `mermaid`、`graphviz`/`dot` 和 `svg` fence 默认显示预览。受支持的流式 fence 显示高 180px 的图片占位、呼吸图标与本地化状态;流式结束后提供控件。未提供这些文案的调用方显示源码。Mermaid 与 Graphviz 在代码块背景上使用当前文档配色,并在解析后的颜色变化时更新与视口相交的预览或已打开的大图。视口外的预览推迟到进入视口后才渲染和加载运行时。DOT 与 SVG 中明确指定的颜色保留为作者内容。渲染按需加载 Mermaid,采用严格安全模式,并将生成的 SVG 作为不带图表链接处理器的图片显示。渲染或图片加载失败时显示传入的错误文案,工具栏仍可切换源码;替换源码会丢弃上一次渲染迟到的结果。
-
-`CodeBlock.preview` 提供标准化的源码预览描述,包含 renderer 以及完整的本地化输出与控件文案。调用方不传入 React 节点。语言名位于左侧;右侧依次放置放大镜、复制图标和源码/预览分段控件。只有选中背景滑动 160ms,正文立即切换。不支持预览的代码块显示静态选中的源码文案。复制始终读取源码,并通过图标和 tooltip 确认成功,不改变工具栏宽度。预览中的放大镜打开 body portal lightbox,图片等比适应屏幕宽度的 88% 和高度的 84%。拖拽可平移,滚轮围绕鼠标位置缩放,双击恢复适应屏幕。方向键平移,+/− 缩放,Home 复位。大图复用已生成的图片;没有可用图片时禁用,并提供本地化的可访问说明。源码视图用行号开关替换放大镜,源码文本和复制结果保持不变。预览工具栏仅通过透明度在 160ms 内淡出,鼠标移入代码块或键盘聚焦时显示;触摸设备保持可见。源码工具栏始终可见。
-
-Graphviz 按需加载 `@viz-js/viz`,使用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出都作为不可执行图片显示在与 Mermaid 相同的画布中;无效 SVG 与 DOT 显示错误并保留源码入口。浏览器按图片宽高比与可用宽度计算预览高度,图片高度不超过 60vh 与 640px 中的较小值。画布保留 16px 内边距与 120px 最小高度。源码在首次选中时挂载,在同一区域内滚动;后续切换保留源码 DOM 与已生成图片。HTML fence 使用普通高亮代码视图。
-
-[预览许可证声明](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)记录分发选择。
+`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 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,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
 
 `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。
 
@@ -118,11 +108,7 @@ Graphviz 按需加载 `@viz-js/viz`,使用 `dot` 引擎渲染 DOT。SVG 与 Gr
 
 ### 流式 Markdown
 
-回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复。末尾的顶层未闭合 fence 会保留已解析的 code node,只把最后一个已完成行与当前未完成行交给同一套 GFM grammar;闭合 fence 或有歧义的解析会回到普通尾部路径。高亮同样从保存的 Shiki grammar state 续接,并只发布新完成行与可变尾部。再次渲染同一高亮帧会复用其已渲染正文,因此 React 重试渲染不会重复追加已完成行。`CodeBlock` 把已完成行封入固定大小的 React 分组、复用更早的分组,并在代码与语言未变化时跨定稿保留整棵高亮树。定稿时的全量解析仍会解析跨过冻结边界的引用。
-
-### 预览所有权
-
-流式占位既不挂载源码高亮组件,也不调用图表渲染器。流式结束后,等待画布保留至图片加载完成;停止输出也会退出流式等待。减少动态效果设置禁用其 1.8 秒透明度动画。共享视口观察器激活与视口相交的预览;离开视口会取消未完成的渲染,已打开 lightbox 时除外。每个已挂载的代码块在视图切换和离开视口时保留图片。重新进入时复用源码、renderer 与配色相同的结果,包括失败结果。主题刷新保留已加载图片和 lightbox,直到替换图片加载完成;刷新失败则保留可用图片并报告错误。支持预览的代码块在首次选中源码时挂载并高亮,之后保留其 DOM;纯源码代码块仍在进入视口后激活高亮。独立 memo 化的源码与复制控件让工具栏反馈和属性未变的父组件更新不必重做源码工作,语法就绪状态也只针对该源码语言。[交互决策](../../../.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.zh.md)负责保留机制,[预览尺寸决策](../../../.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.zh.md)负责几何行为。
+回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复。末尾的顶层未闭合 fence 会保留已解析的 code node,只把最后一个已完成行与当前未完成行交给同一套 GFM grammar;闭合 fence 或有歧义的解析会回到普通尾部路径。高亮同样从保存的 Shiki grammar state 续接,并只发布新完成行与可变尾部。`CodeBlock` 把已完成行封入固定大小的 React 分组、复用更早的分组,并在代码与语言未变化时跨定稿保留整棵高亮树。定稿时的全量解析仍会解析跨过冻结边界的引用。
 
 ### 几何与溢出
 
@@ -158,11 +144,8 @@ Graphviz 按需加载 `@viz-js/viz`,使用 `dot` 引擎渲染 DOT。SVG 与 Gr
 
 <a id="known-limitations-and-deferred-work"></a>
 
-这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
 
-- **图表预览限制**:Mermaid 原生图片节点在布局前被拒绝,避免加载源码指定的资源。不支持 IntersectionObserver 的浏览器会立即渲染。
-
-- **图表渲染在浏览器线程上执行**:Mermaid 串行执行布局;Graphviz 同步执行 WebAssembly 布局。取消会跳过排队工作,但无法中断已开始的布局。已加载图片和访问过的源码 DOM 保留至卸载;视口延迟渲染不等同于虚拟化或 worker 渲染。
+这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
 
 - **Diff 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配。
 - **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。

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

@@ -1,722 +0,0 @@
-Markdown preview and syntax grammar 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.
-
-
-===== Graphviz (DOT) TextMate grammar =====
-
-Source: joaompinto/vscode-graphviz, commit e001e27f7831fb34cb9a8de9c456485bbc509ec3
-https://github.com/joaompinto/vscode-graphviz/blob/e001e27f7831fb34cb9a8de9c456485bbc509ec3/dot/syntaxes/dot.tmLanguage
-
-The plist is mechanically decoded into a Shiki LanguageRegistration array.
-The display name is retained as displayName; name is dot and aliases is [graphviz].
-An empty repository supplies Shiki's required registration field.
-Editor-only fileTypes, firstLineMatch, keyEquivalent, uuid, and rule comments are omitted.
-All patterns, captures, and scopes are unchanged.
-
-The MIT License (MIT)
-
-Copyright (c) 2016 João Pinto
-
-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.

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

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

+ 1 - 3
packages/client/ui-primitives/src/index.ts

@@ -66,9 +66,7 @@ export type {
   WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView, WebBlockLabels,
 } from './WebBlock.tsx'
 export { CodeBlock } from './markdown/CodeBlock.tsx'
-export type {
-  CodeBlockPreview, CodeBlockPreviewLabels, CodeBlockPreviewRenderer, CodeBlockProps,
-} from './markdown/CodeBlock.tsx'
+export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'
 export { MarkdownDelegateProvider } from './markdown/MarkdownDelegate.tsx'
 export type { MarkdownDelegateProviderProps, MarkdownExternalLinkHandler } from './markdown/MarkdownDelegate.tsx'

+ 10 - 164
packages/client/ui-primitives/src/markdown/CodeBlock.module.css

@@ -7,13 +7,10 @@
   /* Local 11/18 instead of the shared --dsw-font-xs-13: the banner scales
      with the markdown 0.875 ladder without shrinking every other xs-13 use. */
   --dsl-code-block-banner-font: 11px/18px var(--dsw-font-family);
-  --dsl-code-block-banner-height: 36px;
   --dsl-code-block-content-font: var(--dsw-font-markdown-code-block);
   --dsl-code-block-background: var(--dsw-alias-markdown-code-block);
 
   position: relative;
-  width: 100%;
-  max-width: 100%;
   margin: 16px 0;
   color: var(--dsw-alias-label-primary);
   background: var(--dsl-code-block-background);
@@ -31,21 +28,11 @@
   background-color: var(--dsw-alias-bg-base);
   border-top-left-radius: var(--dsl-code-block-border-radius);
   border-top-right-radius: var(--dsl-code-block-border-radius);
-  transition: opacity 160ms ease;
-}
-
-.block[data-preview] .bannerWrap {
-  opacity: 0;
-}
-
-.block[data-preview]:hover .bannerWrap,
-.block[data-preview]:has(:focus-visible) .bannerWrap {
-  opacity: 1;
 }
 
 .banner {
   background: var(--dsl-code-block-banner-background-color);
-  padding: 4px 10px 4px 14px;
+  padding: 9px 14px;
   display: flex;
   justify-content: space-between;
   align-items: center;
@@ -67,124 +54,25 @@
 }
 
 .action {
-  min-height: 28px;
   display: flex;
   align-items: center;
   flex-shrink: 0;
-  gap: 2px;
 }
 
-/* Match the sidebar pane controls: a 28px circle around a 15px glyph. */
-.iconButton {
-  display: inline-flex;
-  flex: none;
-  align-items: center;
-  justify-content: center;
-  box-sizing: border-box;
-  width: 28px;
-  height: 28px;
-  padding: 6px;
-  color: var(--dsw-alias-label-secondary);
-  line-height: 1;
-  background: transparent;
+.copyButton {
+  background-color: rgb(255 255 255 / 0);
   border: none;
-  border-radius: 28px;
-  cursor: pointer;
-}
-
-.iconButton svg {
-  width: 15px;
-  height: 15px;
-}
-
-.iconButton:hover:not(:disabled) {
-  background: var(--dsw-alias-interactive-bg-hover);
-}
-
-.iconButton[aria-pressed='true'] {
-  background: var(--dsw-alias-interactive-bg-hover);
-  color: var(--dsw-alias-label-primary);
-}
-
-.iconButton:disabled {
-  color: var(--dsw-alias-label-tertiary);
-  cursor: default;
-}
-
-.previewActionSlot,
-.iconSlot {
-  display: inline-flex;
-}
-
-.segments {
-  position: relative;
-  display: grid;
-  grid-auto-flow: column;
-  grid-auto-columns: 1fr;
-  padding: 2px;
-  margin-left: 4px;
-  border-radius: 8px;
-  background: var(--dsw-alias-interactive-bg-hover);
-  isolation: isolate;
-}
-
-.selection {
-  position: absolute;
-  inset: 2px;
-  width: calc((100% - 4px) / 2);
-  border-radius: 6px;
-  background: var(--dsw-alias-bg-base);
-  transition: transform 160ms ease;
-  z-index: -1;
-}
-
-.segments[data-preview-selected] .selection {
-  transform: translateX(100%);
-}
-
-.segments:has(.sourceOnly) .selection {
-  width: calc(100% - 4px);
-}
-
-.segments button,
-.sourceOnly {
-  border: 0;
-  background: transparent;
+  padding: 0;
+  margin: 0;
   color: inherit;
-  padding: 2px 8px;
-  font: inherit;
-  white-space: nowrap;
-  text-align: center;
-}
-
-.segments button {
   cursor: pointer;
+  font: inherit;
 }
 
-.segments button[aria-pressed='false'] {
-  color: var(--dsw-alias-label-secondary);
-}
-
-.body {
-  min-height: 0;
-}
-
-.previewActionSlot[hidden] {
-  display: none;
-}
-
-.sourceHidden {
-  visibility: hidden;
-  pointer-events: none;
-}
-
-@media (prefers-reduced-motion: reduce) {
-  .selection { transition: none; }
-  .bannerWrap { transition: none; }
-}
-
-@media (hover: none) {
-  .block[data-preview] .bannerWrap { opacity: 1; }
+/* Consumers may turn the stable content node into a viewport without changing
+   the default CodeBlock layout. */
+.content {
+  display: contents;
 }
 
 .block :where(pre) {
@@ -216,32 +104,6 @@
   color: var(--dsw-alias-label-primary);
 }
 
-.previewLayer {
-  display: grid;
-  overflow: auto;
-  background: var(--dsl-code-block-background);
-  border-bottom-right-radius: var(--dsl-code-block-border-radius);
-  border-bottom-left-radius: var(--dsl-code-block-border-radius);
-}
-
-/* The retained image sizes both views without laying out hidden source. */
-.previewLayer[hidden] {
-  display: grid;
-  visibility: hidden;
-  pointer-events: none;
-}
-
-.previewable > .body {
-  position: absolute;
-  inset: 0;
-  overflow: auto;
-  border-radius: inherit;
-}
-
-.previewable[data-code-block-header] > .body {
-  inset-block-start: var(--dsl-code-block-banner-height);
-}
-
 .numbered :where(pre) code {
   display: block;
   counter-reset: source-line;
@@ -266,19 +128,3 @@
   content: counter(source-line);
   user-select: none;
 }
-
-@media (max-width: 480px) {
-  .banner {
-    gap: 4px;
-    padding-inline: 8px;
-  }
-
-  .segments {
-    margin-left: 0;
-  }
-
-  .segments button,
-  .sourceOnly {
-    padding-inline: 4px;
-  }
-}

+ 157 - 89
packages/client/ui-primitives/src/markdown/CodeBlock.tsx

@@ -1,42 +1,21 @@
-/** Diagram-sized code surface with retained preview rendering and toolbar state. */
-import { memo, useRef, useState } from 'react'
-import type { Ref } from 'react'
+import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
+import type { CSSProperties, ReactNode, Ref } from 'react'
 import clsx from 'clsx'
-import { Tooltip } from '../Tooltip.tsx'
-import { CopyCodeButton } from './CopyCodeButton.tsx'
-import { CodeBlockSource } from './CodeBlockSource.tsx'
-import { SourcePreview } from './SourcePreview.tsx'
-import { PreviewPlaceholder } from './PreviewPlaceholder.tsx'
-import type { PreviewRenderer, SourcePreviewLabels } from './SourcePreview.tsx'
+import { writeClipboard } from '../clipboard.ts'
+import {
+  StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
+} from './highlight.ts'
+import type { HighlightSpan, StreamingHighlightFrame } from './highlight.ts'
+import { useViewportHighlighting } from './useViewportHighlighting.ts'
 import css from './CodeBlock.module.css'
 
-/** Renderer accepted by the public {@link CodeBlockPreview} descriptor. */
-export type CodeBlockPreviewRenderer = PreviewRenderer
-
-/** Localized output and controls for a {@link CodeBlock} preview. */
-export interface CodeBlockPreviewLabels extends SourcePreviewLabels {
-  /** Control label when source is visible. */
-  preview: string
-  /** Control label when the preview is visible. */
-  source: string
-}
-
-/** Standard source-backed content displayed by a {@link CodeBlock} preview. */
-export interface CodeBlockPreview {
-  /** Convert the source into an isolated image. */
-  render: CodeBlockPreviewRenderer
-  /** Localized failure, accessible output, and control labels. */
-  labels: CodeBlockPreviewLabels
-}
-
 export interface CodeBlockProps {
   /** The source text, rendered verbatim (trailing newline trimmed for display). */
   code: string
   /** Grammar hint (markdown fence info string or a fixed caller id); unknown = plain. */
   lang?: string | undefined
   /**
-   * The code is still growing. Previewable fences show a placeholder without
-   * rendering or highlighting; other streaming fences highlight through
+   * The code is still growing (a streaming markdown fence): highlight through
    * a per-instance {@link StreamingHighlightSession}, which re-tokenizes only
    * appended text and keeps completed line groups (and DOM) untouched. The
    * caller must keep the component instance stable across growth (a
@@ -46,9 +25,9 @@ export interface CodeBlockProps {
   streaming?: boolean | undefined
   /** Extra class merged onto the wrapper (callers position; this component draws). */
   className?: string | undefined
-  /** Ref for the stable content wrapper, for owners that use it as a scrollport. */
+  /** Ref for the stable source-content wrapper, for owners that use it as a scrollport. */
   contentRef?: Ref<HTMLDivElement> | undefined
-  /** Initial gutter preference until the reader toggles line numbers. Defaults to false; copied source excludes numbers. */
+  /** Show a numbered gutter without adding numbers to copied source. Defaults to false. */
   lineNumbers?: boolean | undefined
   /** Show the language and copy header; false when the caller supplies a toolbar. Defaults to true. */
   showHeader?: boolean | undefined
@@ -56,77 +35,166 @@ export interface CodeBlockProps {
   copyLabel: string
   /** Copy-button label during the post-copy confirmation window. */
   copiedLabel: string
-  /** Selected source-view label when no preview is available. */
-  sourceLabel: string
-  /** Localized tooltip and accessible name for the source line-number toggle. */
-  lineNumbersLabel: string
-  /** Image preview, selected initially; streaming shows a placeholder. Copy always uses source. */
-  preview?: CodeBlockPreview | undefined
 }
 
 /**
- * Display source or a retained diagram with localized controls.
- * @param props - Source, presentation preferences, complete labels and optional preview renderer.
- * @returns A content-sized preview, a streaming preview placeholder, or full-width source.
+ * The `pre` attributes shiki's HTML arm emits for the css-variables theme,
+ * mirrored so the streaming arm's tree is interchangeable with the settled
+ * swap (`tests/streaming-code-block.client.spec.tsx` pins the two arms'
+ * parity).
  */
-export const CodeBlock = memo(function CodeBlock({
-  code, lang, streaming, className, lineNumbers = false, showHeader = true, copyLabel, copiedLabel, sourceLabel,
-  lineNumbersLabel, preview, contentRef,
+const SHIKI_PRE_PROPS = {
+  className: 'shiki css-variables',
+  style: { backgroundColor: 'var(--shiki-background)', color: 'var(--shiki-foreground)' },
+  tabIndex: 0,
+} as const
+
+/** Completed-line group size; React reconciles groups while the DOM remains line-for-line identical. */
+const STREAMING_LINE_GROUP_SIZE = 32
+
+function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
+  return (
+    <Fragment key={index}>
+      {index > 0 && '\n'}
+      <span className="line">
+        {line.map((span, spanIndex) => <span key={spanIndex} style={span.style}>{span.text}</span>)}
+      </span>
+    </Fragment>
+  )
+}
+
+export function CodeBlock({
+  code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel,
 }: CodeBlockProps) {
   const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
-  const previewAvailable = preview !== undefined
-  const pendingStream = previewAvailable && streaming === true
-  const [view, setView] = useState<'source' | 'preview'>('preview')
-  const [sourceVisited, setSourceVisited] = useState(false)
-  const [numberedOverride, setNumberedOverride] = useState<boolean>()
-  const numbered = numberedOverride ?? lineNumbers
-  const [actions, setActions] = useState<HTMLSpanElement | null>(null)
+  const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
   const rootRef = useRef<HTMLDivElement>(null)
-  const showingPreview = previewAvailable && (pendingStream || view === 'preview')
+  const highlighting = useViewportHighlighting(rootRef, lang)
+  // Re-render when a lazy grammar finishes loading, so a fence that showed plain
+  // text while its language's grammar imported picks up highlighting. The
+  // snapshot value is opaque; only its change across renders drives the memo.
+  const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
+  // Streaming state lives in refs mutated inside the memo (the MarkdownText
+  // streaming-cache pattern): the session's caches carry across chunks only
+  // because the owner keys this instance stably while the fence grows.
+  const sessionRef = useRef<StreamingHighlightSession | null>(null)
+  const lineCacheRef = useRef<{
+    code: string
+    lang: string | undefined
+    generation: number
+    frame: StreamingHighlightFrame
+    groups: ReactNode[]
+    pending: ReactNode[]
+    nextLine: number
+    body: ReactNode
+  } | null>(null)
+  const settledRef = useRef(false)
+  const streamedBody = useMemo(() => {
+    if (!highlighting) {
+      sessionRef.current = null
+      lineCacheRef.current = null
+      settledRef.current = false
+      return undefined
+    }
+    if (streaming !== true) {
+      const previous = lineCacheRef.current
+      if (previous !== null && previous.code === trimmed && previous.lang === lang) {
+        settledRef.current = true
+        return previous.body
+      }
+      sessionRef.current = null
+      lineCacheRef.current = null
+      settledRef.current = true
+      return undefined
+    }
+    if (settledRef.current) {
+      sessionRef.current = null
+      lineCacheRef.current = null
+      settledRef.current = false
+    }
+    sessionRef.current ??= new StreamingHighlightSession()
+    const frame = sessionRef.current.updateFrame(trimmed, lang)
+    if (frame === undefined) {
+      lineCacheRef.current = null
+      return undefined
+    }
+    const previous = lineCacheRef.current
+    if (previous?.frame === frame && previous.code === trimmed && previous.lang === lang) {
+      return previous.body
+    }
+    const sameGeneration = previous?.generation === frame.generation
+    const groups = sameGeneration ? [...previous.groups] : []
+    let pending = sameGeneration ? [...previous.pending] : []
+    let nextLine = sameGeneration ? previous.nextLine : 0
+    for (const line of frame.appended) {
+      pending.push(renderLine(line, nextLine))
+      nextLine += 1
+      if (pending.length !== STREAMING_LINE_GROUP_SIZE) continue
+      const start = nextLine - pending.length
+      groups.push(<Fragment key={start}>{pending}</Fragment>)
+      pending = []
+    }
+    const tail = frame.tail.map((line, index) => renderLine(line, nextLine + index))
+    const tailGroup = <Fragment key={nextLine - pending.length}>{[...pending, ...tail]}</Fragment>
+    const body = <pre {...SHIKI_PRE_PROPS}><code>{groups}{tailGroup}</code></pre>
+    lineCacheRef.current = {
+      code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body,
+    }
+    return body
+  }, [streaming, highlighting, trimmed, lang, loaded])
+  const html = useMemo(
+    () => (highlighting && streaming !== true && streamedBody === undefined
+      ? highlightToHtml(trimmed, lang)
+      : undefined),
+    [streaming, highlighting, streamedBody, trimmed, lang, loaded],
+  )
+  const [copied, setCopied] = useState(false)
+
+  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) => {
+      if (!ok) return
+      setCopied(true)
+      window.setTimeout(() => { setCopied(false) }, 1000)
+    })
+  }, [copied, trimmed])
+
+  // shiki's HTML output is a static span tree it generated from `code` (no
+  // user HTML passes through), the sanctioned innerHTML consumption path per
+  // shiki's own docs.
+  const body = streamedBody !== undefined
+    ? streamedBody
+    : html === undefined
+      ? (
+        <pre className={css.plain}><code>{sourceLines === undefined ? trimmed : sourceLines.map((line, index) => (
+          <Fragment key={index}>{index > 0 && '\n'}<span className="line">{line}</span></Fragment>
+        ))}</code></pre>
+      )
+      : (
+        <div dangerouslySetInnerHTML={{ __html: html }} />
+      )
+
   return (
-    <div ref={rootRef} className={clsx(css.block, 'md-code-block', previewAvailable && css.previewable, numbered && css.numbered, className)}
-      data-code-block-header={(showHeader && previewAvailable) || undefined}
-      data-line-numbers={numbered || undefined} data-preview={showingPreview || undefined}>
+    <div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, className)}
+      data-line-numbers={lineNumbers || undefined}
+      style={sourceLines === undefined ? undefined : {
+        '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
+      } as CSSProperties}>
+      {/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */}
       {showHeader && <div className={css.bannerWrap}>
         <div className={css.banner} data-code-block-banner>
           <div className={css.infostring}>{lang ?? ''}</div>
           <div className={css.action}>
-            {!pendingStream && <>
-              {previewAvailable && <span ref={setActions} className={css.previewActionSlot} hidden={!showingPreview} />}
-              {!showingPreview && <Tooltip label={lineNumbersLabel} side="top" delayMs={500}>
-                <button type="button" className={css.iconButton} aria-label={lineNumbersLabel} aria-pressed={numbered}
-                  onClick={() => { setNumberedOverride(!numbered) }}>
-                  <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden="true">
-                    <path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7"
-                      stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
-                  </svg>
-                </button>
-              </Tooltip>}
-              <CopyCodeButton code={trimmed} copyLabel={copyLabel} copiedLabel={copiedLabel} />
-              <div className={css.segments} data-preview-selected={showingPreview || undefined}>
-                <span className={css.selection} aria-hidden="true" />
-                {previewAvailable ? <>
-                  <button type="button" aria-pressed={!showingPreview} onClick={() => { setSourceVisited(true); setView('source') }}>{preview.labels.source}</button>
-                  <button type="button" aria-pressed={showingPreview} onClick={() => { setView('preview') }}>{preview.labels.preview}</button>
-                </> : <span className={css.sourceOnly}>{sourceLabel}</span>}
-              </div>
-            </>}
+            <button type="button" className={css.copyButton} onClick={onCopy}>
+              {copied ? copiedLabel : copyLabel}
+            </button>
           </div>
         </div>
       </div>}
-      <div ref={contentRef} className={clsx(css.body, showingPreview && css.sourceHidden)} data-code-block-content>
-        {!pendingStream && <div className={clsx(css.source, showingPreview && css.sourceHidden)}
-          data-code-block-source-view={previewAvailable || undefined} aria-hidden={showingPreview || undefined}>
-          {(!previewAvailable || sourceVisited) && <CodeBlockSource target={rootRef} code={code} lang={lang}
-            streaming={streaming} lineNumbers={numbered} highlightImmediately={previewAvailable} />}
-        </div>}
-      </div>
-      {previewAvailable && <div className={css.previewLayer}
-        data-code-block-preview hidden={!showingPreview}>
-        {pendingStream ? <PreviewPlaceholder label={preview.labels.pending} /> :
-          <SourcePreview code={trimmed} labels={preview.labels} render={preview.render}
-            actions={actions ?? undefined} />}
-      </div>}
+      <div ref={contentRef} className={css.content} data-code-block-content>{body}</div>
     </div>
   )
-})
+}

+ 0 - 143
packages/client/ui-primitives/src/markdown/CodeBlockSource.tsx

@@ -1,143 +0,0 @@
-/** Memoized source rendering, including retained incremental highlight groups. */
-import { Fragment, memo, useCallback, useMemo, useRef, useSyncExternalStore } from 'react'
-import type { CSSProperties, ReactNode, RefObject } from 'react'
-import { StreamingHighlightSession, isGrammarLoaded, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts'
-import type { HighlightSpan, StreamingHighlightFrame } from './highlight.ts'
-import type { CodeBlockProps } from './CodeBlock.tsx'
-import { useViewportHighlighting } from './useViewportHighlighting.ts'
-import css from './CodeBlock.module.css'
-
-/**
- * The `pre` attributes shiki's HTML arm emits for the css-variables theme,
- * mirrored so the streaming arm's tree is interchangeable with the settled
- * swap (`tests/streaming-code-block.client.spec.tsx` pins the two arms'
- * parity).
- */
-const SHIKI_PRE_PROPS = {
-  className: 'shiki css-variables',
-  style: { backgroundColor: 'var(--shiki-background)', color: 'var(--shiki-foreground)' },
-  tabIndex: 0,
-} as const
-
-/** Completed-line group size; React reconciles groups while the DOM remains line-for-line identical. */
-const STREAMING_LINE_GROUP_SIZE = 32
-
-function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
-  return (
-    <Fragment key={index}>
-      {index > 0 && '\n'}
-      <span className="line">
-        {line.map((span, spanIndex) => <span key={spanIndex} style={span.style}>{span.text}</span>)}
-      </span>
-    </Fragment>
-  )
-}
-
-/**
- * Highlight one source surface independently of toolbar and preview state.
- * @param props - Authored source, grammar hint, streaming state and gutter choice.
- * @returns Source DOM retained across view changes and repeated rendering of the same highlight frame.
- */
-export const CodeBlockSource = memo(function CodeBlockSource({ code, lang, streaming, lineNumbers, target, highlightImmediately }:
-  Pick<CodeBlockProps, 'code' | 'lang' | 'streaming' | 'lineNumbers'> & {
-    target: RefObject<HTMLDivElement>
-    highlightImmediately: boolean
-  }) {
-  const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
-  const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
-  const highlighting = useViewportHighlighting(target, lang, highlightImmediately)
-  const snapshot = useCallback(() => isGrammarLoaded(lang), [lang])
-  const loaded = useSyncExternalStore(subscribeGrammarLoaded, snapshot, snapshot)
-  // Streaming state lives in refs mutated inside the memo (the MarkdownText
-  // streaming-cache pattern): the session's caches carry across chunks only
-  // because the owner keys this instance stably while the fence grows.
-  const sessionRef = useRef<StreamingHighlightSession | null>(null)
-  const lineCacheRef = useRef<{
-    code: string
-    lang: string | undefined
-    generation: number
-    frame: StreamingHighlightFrame
-    groups: ReactNode[]
-    pending: ReactNode[]
-    nextLine: number
-    body: ReactNode
-  } | null>(null)
-  const settledRef = useRef(false)
-  const streamedBody = useMemo(() => {
-    if (!highlighting) {
-      sessionRef.current = null
-      lineCacheRef.current = null
-      settledRef.current = false
-      return undefined
-    }
-    if (streaming !== true) {
-      const previous = lineCacheRef.current
-      if (previous !== null && previous.code === trimmed && previous.lang === lang) {
-        settledRef.current = true
-        return previous.body
-      }
-      sessionRef.current = null
-      lineCacheRef.current = null
-      settledRef.current = true
-      return undefined
-    }
-    if (settledRef.current) {
-      sessionRef.current = null
-      lineCacheRef.current = null
-      settledRef.current = false
-    }
-    sessionRef.current ??= new StreamingHighlightSession()
-    const frame = sessionRef.current.updateFrame(trimmed, lang)
-    if (frame === undefined) {
-      lineCacheRef.current = null
-      return undefined
-    }
-    const previous = lineCacheRef.current
-    if (previous?.frame === frame && previous.code === trimmed && previous.lang === lang) {
-      return previous.body
-    }
-    const sameGeneration = previous?.generation === frame.generation
-    const groups = sameGeneration ? [...previous.groups] : []
-    let pending = sameGeneration ? [...previous.pending] : []
-    let nextLine = sameGeneration ? previous.nextLine : 0
-    for (const line of frame.appended) {
-      pending.push(renderLine(line, nextLine))
-      nextLine += 1
-      if (pending.length !== STREAMING_LINE_GROUP_SIZE) continue
-      const start = nextLine - pending.length
-      groups.push(<Fragment key={start}>{pending}</Fragment>)
-      pending = []
-    }
-    const tail = frame.tail.map((line, index) => renderLine(line, nextLine + index))
-    const tailGroup = <Fragment key={nextLine - pending.length}>{[...pending, ...tail]}</Fragment>
-    const body = <pre {...SHIKI_PRE_PROPS} data-code-block-source><code>{groups}{tailGroup}</code></pre>
-    lineCacheRef.current = {
-      code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body,
-    }
-    return body
-  }, [streaming, highlighting, trimmed, lang, loaded])
-  const html = useMemo(
-    () => (highlighting && streaming !== true && streamedBody === undefined
-      ? highlightToHtml(trimmed, lang)
-      : undefined),
-    [streaming, highlighting, streamedBody, trimmed, lang, loaded],
-  )
-  // shiki's HTML output is a static span tree it generated from `code` (no
-  // user HTML passes through), the sanctioned innerHTML consumption path per
-  // shiki's own docs.
-  const body = streamedBody !== undefined
-    ? streamedBody
-    : html === undefined
-      ? (
-        <pre className={css.plain} data-code-block-source><code>{sourceLines === undefined ? trimmed : sourceLines.map((line, index) => (
-          <Fragment key={index}>{index > 0 && '\n'}<span className="line">{line}</span></Fragment>
-        ))}</code></pre>
-      )
-      : (
-        <div data-code-block-source dangerouslySetInnerHTML={{ __html: html }} />
-      )
-
-  return <div style={sourceLines === undefined ? undefined : {
-    '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
-  } as CSSProperties}>{body}</div>
-})

+ 0 - 39
packages/client/ui-primitives/src/markdown/CopyCodeButton.tsx

@@ -1,39 +0,0 @@
-/** Copy confirmation is local to the icon button, independent of source rendering. */
-import { memo, useEffect, useRef, useState } from 'react'
-import { writeClipboard } from '../clipboard.ts'
-import { Tooltip } from '../Tooltip.tsx'
-import { IconCheckOutline16, IconCopyOutline16 } from '../icons/index.tsx'
-import css from './CodeBlock.module.css'
-
-/**
- * Copy source without changing toolbar geometry.
- * @param props - Source and localized idle/success labels.
- * @returns A fixed-size icon action with local confirmation state.
- */
-export const CopyCodeButton = memo(function CopyCodeButton({ code, copyLabel, copiedLabel }: {
-  code: string
-  copyLabel: string
-  copiedLabel: string
-}) {
-  const [copied, setCopied] = useState(false)
-  const alive = useRef(false)
-  const timer = useRef<ReturnType<typeof setTimeout>>()
-  useEffect(() => {
-    alive.current = true
-    return () => { alive.current = false; clearTimeout(timer.current) }
-  }, [])
-  const label = copied ? copiedLabel : copyLabel
-  return <Tooltip label={label} side="top" delayMs={500}>
-    <button type="button" className={css.iconButton} aria-label={label} onClick={() => {
-      if (copied) return
-      void writeClipboard(code).then((ok) => {
-        if (!ok || !alive.current) return
-        setCopied(true)
-        clearTimeout(timer.current)
-        timer.current = setTimeout(() => { setCopied(false) }, 1000)
-      })
-    }}>
-      <span aria-hidden="true">{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}</span>
-    </button>
-  </Tooltip>
-})

+ 8 - 20
packages/client/ui-primitives/src/markdown/MarkdownText.tsx

@@ -17,13 +17,10 @@ import clsx from 'clsx'
 import { IncrementalMarkdownParser } from './incremental.ts'
 import { parseGfm, parseGfmWithMath } from './parse.ts'
 import {
-  collectReferenceTargets, createFencePreviewCatalog, createReferenceTargets, renderBlocks, renderFootnoteSection,
+  collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
   wrapBlockChildren,
 } from './render.tsx'
-import type {
-  FencePreviewCatalog, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages, MarkdownRenderContext,
-  ReferenceTargets,
-} from './render.tsx'
+import type { MarkdownFileMentions, MarkdownLabels, MarkdownPathImages, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
 import 'katex/dist/katex.min.css'
 import css from './MarkdownText.module.css'
 
@@ -33,7 +30,6 @@ export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, Markdown
 function renderSettled(
   text: string,
   labels: MarkdownLabels,
-  previews: FencePreviewCatalog | undefined,
   fileMentions: MarkdownFileMentions | undefined,
   pathImages: MarkdownPathImages | undefined,
 ): ReactNode[] {
@@ -43,7 +39,6 @@ function renderSettled(
   const context: MarkdownRenderContext = {
     streaming: false,
     labels,
-    previews,
     fileMentions,
     pathImages,
     targets,
@@ -79,11 +74,8 @@ class StreamingRenderer {
   private lastText: string | null = null
   private lastRendered: ReactNode[] = []
 
-  /**
-   * @param labels - Localized chrome baked into cached elements.
-   * @param previews - Stable preview capabilities shared with the settled render.
-   */
-  constructor(private readonly labels: MarkdownLabels, private readonly previews: FencePreviewCatalog | undefined) {}
+  /** @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes. */
+  constructor(private readonly labels: MarkdownLabels) {}
 
   /**
    * Render the current accumulated text. Idempotent per text value, so React
@@ -116,7 +108,6 @@ class StreamingRenderer {
       const frozenContext: MarkdownRenderContext = {
         streaming: true,
         labels: this.labels,
-        previews: this.previews,
         fileMentions: undefined,
         pathImages: undefined,
         targets: frameTargets,
@@ -136,7 +127,6 @@ class StreamingRenderer {
     const tailContext: MarkdownRenderContext = {
       streaming: true,
       labels: this.labels,
-      previews: this.previews,
       fileMentions: undefined,
       pathImages: undefined,
       targets: frameTargets,
@@ -160,8 +150,7 @@ class StreamingRenderer {
  * Render untrusted assistant-authored Markdown as semantic React elements.
  * @param props - Markdown source text preserved by the session projection;
  * `streaming` parses incrementally across chunks and highlights fences as
- * they grow unless preview labels select a loading placeholder (source-only
- * fences re-tokenize only appended text; TeX stays literal
+ * they grow (each fence re-tokenizes only appended text; TeX stays literal
  * until the finalize swap so incomplete formulae never flash errors);
  * `labels` forwards localized fence and footnote chrome — pass a
  * reference-stable object (memoized per locale revision), because a new
@@ -192,18 +181,17 @@ export const MarkdownText = memo(function MarkdownText({
 }) {
   const streamRef = useRef<StreamingRenderer | null>(null)
   const streamLabelsRef = useRef<MarkdownLabels>(labels)
-  const previews = useMemo(() => createFencePreviewCatalog(labels.preview), [labels.preview])
   const children = useMemo(() => {
     if (!streaming) {
       streamRef.current = null
-      return renderSettled(text, labels, previews, fileMentions, pathImages)
+      return renderSettled(text, labels, fileMentions, pathImages)
     }
     if (streamRef.current === null || streamLabelsRef.current !== labels) {
-      streamRef.current = new StreamingRenderer(labels, previews)
+      streamRef.current = new StreamingRenderer(labels)
       streamLabelsRef.current = labels
     }
     return streamRef.current.render(text)
-  }, [text, streaming, labels, previews, fileMentions, pathImages])
+  }, [text, streaming, labels, fileMentions, pathImages])
   return <div className={clsx(css.markdown, variant === 'compact' && css.compact)}
     data-markdown-variant={variant === 'compact' ? variant : undefined}>{children}</div>
 })

+ 0 - 65
packages/client/ui-primitives/src/markdown/PreviewLightbox.module.css

@@ -1,65 +0,0 @@
-.backdrop {
-  position: fixed;
-  inset: 0;
-  z-index: 1000;
-  overflow: hidden;
-  overscroll-behavior: contain;
-}
-
-.mask {
-  position: fixed;
-  inset: 0;
-  background: var(--dsw-alias-bg-mask-1);
-  backdrop-filter: var(--dsw-mask-blur);
-}
-
-.image {
-  position: absolute;
-  left: 50%;
-  top: 50%;
-  max-width: none;
-  transform: translate(-50%, -50%);
-  cursor: grab;
-  touch-action: none;
-  user-select: none;
-  border-radius: 12px;
-  background: var(--dsw-specific-input-major);
-  box-shadow: var(--dsw-shadow-lv3);
-}
-
-.image[data-dragging] {
-  cursor: grabbing;
-}
-
-.hint {
-  position: absolute;
-  bottom: 16px;
-  left: 50%;
-  transform: translateX(-50%);
-  max-width: calc(100% - 32px);
-  width: max-content;
-  padding: 6px 12px;
-  border-radius: 12px;
-  background: var(--dsw-specific-input-major);
-  color: var(--dsw-alias-label-secondary);
-  font: var(--dsw-font-xs-13);
-  text-align: center;
-  pointer-events: none;
-}
-
-.close {
-  position: fixed;
-  top: 20px;
-  right: 20px;
-  z-index: 1;
-  display: grid;
-  place-items: center;
-  width: 36px;
-  height: 36px;
-  border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin);
-  border-radius: 999px;
-  corner-shape: round;
-  background: var(--dsw-specific-input-major);
-  color: var(--dsw-alias-label-primary);
-  cursor: pointer;
-}

+ 0 - 149
packages/client/ui-primitives/src/markdown/PreviewLightbox.tsx

@@ -1,149 +0,0 @@
-/** Viewport-fitted image dialog with local pan and zoom transforms. */
-import { useEffect, useId, useRef } from 'react'
-import { createPortal } from 'react-dom'
-import { IconCloseOutline16 } from '../icons/index.tsx'
-import css from './PreviewLightbox.module.css'
-
-/**
- * Fit a rendered diagram inside the viewport, with pointer pan and cursor-anchored zoom.
- * @param props - Image source, accessible labels, gesture instructions, and close callback.
- * @returns A dialog with drag, wheel and keyboard controls; double-click or Home restores the fitted view.
- */
-export function PreviewLightbox({ src, alt, closeLabel, interactionLabel, onClose }: {
-  src: string
-  alt: string
-  closeLabel: string
-  interactionLabel: string
-  onClose: () => void
-}) {
-  const closeRef = useRef<HTMLButtonElement | null>(null)
-  const imageRef = useRef<HTMLImageElement | null>(null)
-  const surfaceRef = useRef<HTMLDivElement | null>(null)
-  const hintId = useId()
-
-  useEffect(() => {
-    const restore = document.activeElement instanceof HTMLElement ? document.activeElement : null
-    closeRef.current?.focus()
-    const onKeyDown = (event: globalThis.KeyboardEvent): void => {
-      if (event.key === 'Escape') onClose()
-      // The close control is the dialog's only focusable element.
-      if (event.key === 'Tab') event.preventDefault()
-    }
-    window.addEventListener('keydown', onKeyDown)
-    return () => {
-      window.removeEventListener('keydown', onKeyDown)
-      restore?.focus()
-    }
-  }, [onClose])
-
-  useEffect(() => {
-    // oxlint-disable-next-line typescript/no-non-null-assertion -- refs belong to this mounted dialog.
-    const image = imageRef.current!
-    // oxlint-disable-next-line typescript/no-non-null-assertion -- refs belong to this mounted dialog.
-    const surface = surfaceRef.current!
-    const view = { x: 0, y: 0, scale: 1 }
-    let drag: { id: number; x: number; y: number } | undefined
-    const paint = () => {
-      image.style.transform = `translate(-50%, -50%) translate(${view.x}px, ${view.y}px) scale(${view.scale})`
-    }
-    const fit = () => {
-      if (image.naturalWidth === 0 || image.naturalHeight === 0) return
-      const scale = Math.min(window.innerWidth * 0.88 / image.naturalWidth, window.innerHeight * 0.84 / image.naturalHeight)
-      image.style.width = `${image.naturalWidth * scale}px`
-      image.style.height = `${image.naturalHeight * scale}px`
-      view.x = 0
-      view.y = 0
-      view.scale = 1
-      paint()
-    }
-    const zoom = (factor: number, x = 0, y = 0) => {
-      const scale = Math.min(8, Math.max(0.25, view.scale * factor))
-      const ratio = scale / view.scale
-      view.x = x - (x - view.x) * ratio
-      view.y = y - (y - view.y) * ratio
-      view.scale = scale
-      paint()
-    }
-    const onWheel = (event: WheelEvent) => {
-      event.preventDefault()
-      const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1
-      const rect = surface.getBoundingClientRect()
-      zoom(Math.exp(-event.deltaY * unit * 0.002), event.clientX - rect.left - rect.width / 2, event.clientY - rect.top - rect.height / 2)
-    }
-    const onPointerDown = (event: PointerEvent) => {
-      if (event.button !== 0 || !event.isPrimary) return
-      drag = { id: event.pointerId, x: event.clientX, y: event.clientY }
-      image.setPointerCapture(event.pointerId)
-      image.dataset['dragging'] = ''
-      event.preventDefault()
-    }
-    const onPointerMove = (event: PointerEvent) => {
-      if (drag === undefined || drag.id !== event.pointerId) return
-      view.x += event.clientX - drag.x
-      view.y += event.clientY - drag.y
-      drag.x = event.clientX
-      drag.y = event.clientY
-      paint()
-    }
-    const endDrag = () => {
-      if (drag === undefined) return
-      const id = drag.id
-      drag = undefined
-      delete image.dataset['dragging']
-      if (image.hasPointerCapture(id)) image.releasePointerCapture(id)
-    }
-    const onPointerEnd = (event: PointerEvent) => {
-      if (drag?.id === event.pointerId) endDrag()
-    }
-    const onKeyDown = (event: KeyboardEvent) => {
-      if (event.key === '+' || event.key === '=') zoom(1.2)
-      else if (event.key === '-') zoom(1 / 1.2)
-      else if (event.key === 'Home' || event.key === '0') fit()
-      else {
-        const dx = event.key === 'ArrowRight' ? 32 : event.key === 'ArrowLeft' ? -32 : 0
-        const dy = event.key === 'ArrowDown' ? 32 : event.key === 'ArrowUp' ? -32 : 0
-        if (dx === 0 && dy === 0) return
-        view.x += dx
-        view.y += dy
-        paint()
-      }
-      event.preventDefault()
-    }
-    image.addEventListener('load', fit)
-    image.addEventListener('dblclick', fit)
-    image.addEventListener('pointerdown', onPointerDown)
-    image.addEventListener('pointermove', onPointerMove)
-    image.addEventListener('pointerup', onPointerEnd)
-    image.addEventListener('pointercancel', onPointerEnd)
-    image.addEventListener('lostpointercapture', onPointerEnd)
-    surface.addEventListener('wheel', onWheel, { passive: false })
-    window.addEventListener('keydown', onKeyDown)
-    window.addEventListener('resize', fit)
-    fit()
-    return () => {
-      endDrag()
-      image.removeEventListener('load', fit)
-      image.removeEventListener('dblclick', fit)
-      image.removeEventListener('pointerdown', onPointerDown)
-      image.removeEventListener('pointermove', onPointerMove)
-      image.removeEventListener('pointerup', onPointerEnd)
-      image.removeEventListener('pointercancel', onPointerEnd)
-      image.removeEventListener('lostpointercapture', onPointerEnd)
-      surface.removeEventListener('wheel', onWheel)
-      window.removeEventListener('keydown', onKeyDown)
-      window.removeEventListener('resize', fit)
-    }
-  }, [src])
-
-  return createPortal(
-    <div ref={surfaceRef} className={css.backdrop} role="dialog" aria-modal="true" aria-label={alt} aria-describedby={hintId}>
-      <div className={css.mask} aria-hidden="true" onMouseDown={onClose} />
-      <img ref={imageRef} className={css.image} src={src} alt={alt} draggable={false} />
-      <div id={hintId} className={css.hint}>{interactionLabel}</div>
-      <button ref={closeRef} type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
-        <span aria-hidden="true"><IconCloseOutline16 size={16} /></span>
-      </button>
-    </div>,
-    document.body,
-  )
-}

+ 0 - 19
packages/client/ui-primitives/src/markdown/PreviewPlaceholder.tsx

@@ -1,19 +0,0 @@
-/** Stable waiting canvas shared by streaming diagrams and image loading. */
-import { memo } from 'react'
-import css from './SourcePreview.module.css'
-
-/**
- * Display a quiet loading status without depending on the growing source.
- * @param props - Localized loading announcement.
- * @returns A fixed-height canvas with a decorative pulsing image glyph.
- */
-export const PreviewPlaceholder = memo(function PreviewPlaceholder({ label }: { label: string }) {
-  return <div className={css.placeholder} data-preview-placeholder role="status">
-    <svg className={css.placeholderIcon} viewBox="0 0 32 32" width="32" height="32" fill="none" aria-hidden="true">
-      <rect x="4" y="5" width="24" height="22" rx="4" stroke="currentColor" strokeWidth="1.3" />
-      <circle cx="11" cy="12" r="2" stroke="currentColor" strokeWidth="1.3" />
-      <path d="m5 23 7-7 5 5 4-4 6 6" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
-    </svg>
-    <span>{label}</span>
-  </div>
-})

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

@@ -1,66 +0,0 @@
-.root {
-  min-width: 0;
-  border-radius: inherit;
-}
-
-.canvas {
-  display: grid;
-  place-items: center;
-  min-height: 120px;
-  padding: 16px;
-  box-sizing: border-box;
-  background: var(--dsw-alias-markdown-code-block);
-  border-radius: inherit;
-}
-
-.diagram {
-  display: block;
-  max-width: 100%;
-  height: auto;
-  max-height: min(60vh, 640px);
-  margin: 0 auto;
-}
-
-.status {
-  padding: 16px;
-  color: var(--dsw-alias-label-secondary);
-}
-
-.placeholder {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  gap: 12px;
-  height: 180px;
-  color: var(--dsw-alias-label-tertiary);
-  font-size: 12px;
-}
-
-.placeholderIcon {
-  animation: previewPulse 1.8s ease-in-out infinite;
-}
-
-@keyframes previewPulse {
-  0%, 100% { opacity: 0.35; }
-  50% { opacity: 0.8; }
-}
-
-.canvas[hidden], .diagram[hidden] { display: none; }
-
-@media (prefers-reduced-motion: reduce) {
-  .placeholderIcon { animation: none; }
-}
-
-.failure {
-  min-height: 120px;
-  background: var(--dsw-alias-markdown-code-block);
-}
-
-.staging {
-  position: fixed;
-  top: 0;
-  left: -100000px;
-  visibility: hidden;
-  pointer-events: none;
-}

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

@@ -1,168 +0,0 @@
-/** Read-only diagram preview with image loading status and per-source async ownership. */
-
-import { memo, useCallback, useEffect, useId, useRef, useState } from 'react'
-import { createPortal } from 'react-dom'
-import { Tooltip } from '../Tooltip.tsx'
-import { IconSearchOutline16 } from '../icons/index.tsx'
-import { PreviewLightbox } from './PreviewLightbox.tsx'
-import { PreviewPlaceholder } from './PreviewPlaceholder.tsx'
-import css from './SourcePreview.module.css'
-import blockCss from './CodeBlock.module.css'
-import { readPreviewTheme } from './preview-theme.ts'
-import { observeViewport } from './viewport.ts'
-
-/** Localized preview output; the diagram source remains verbatim. */
-export interface PreviewLabels {
-  diagram: string
-  error: string
-}
-
-/** Localized controls added by the interactive preview surface. */
-export interface SourcePreviewLabels extends PreviewLabels {
-  zoom: string
-  pending: string
-  close: string
-  interaction: string
-}
-
-/**
- * Convert source into an isolated preview image URL.
- * @param code - Complete preview source.
- * @param signal - Cancels publication of obsolete work.
- * @returns The isolated image URL, synchronously or asynchronously.
- */
-export type PreviewRenderer = (code: string, signal: AbortSignal) => string | Promise<string>
-
-/** Inputs whose identity controls preview rendering and localized status copy. */
-export interface SourcePreviewProps {
-  code: string
-  labels: SourcePreviewLabels
-  render: PreviewRenderer
-  /** Existing action container that receives the zoom control. */
-  actions?: HTMLElement | undefined
-}
-
-type Result = {
-  code: string
-  render: PreviewRenderer
-  palette: string
-  /** Last loaded image of the same source, retained while its replacement loads or fails. */
-  previous: string | undefined
-} & (
-  | { kind: 'ok'; src: string; loaded: boolean }
-  | { kind: 'error' }
-)
-
-function loadedImage(result: Result | null): string | undefined {
-  if (result === null) return undefined
-  switch (result.kind) {
-    case 'ok': return result.loaded ? result.src : result.previous
-    case 'error': return result.previous
-    /* v8 ignore next -- closed-union backstop; every declared result kind is handled above. */
-    default: return assertNever(result)
-  }
-}
-
-/* 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)}`)
-}
-
-/**
- * Render visible diagrams and retain their loaded images during theme refreshes.
- * @param props - Source and complete localized labels. Source, visibility and document theme changes cancel obsolete renders.
- * @returns A pending status, inert image or error, with a persistent zoom action.
- */
-export const SourcePreview = memo(function SourcePreview({ code, labels, render, actions }: SourcePreviewProps) {
-  const pendingId = useId()
-  const target = useRef<HTMLDivElement>(null)
-  const [visible, setVisible] = useState(false)
-  const [result, setResult] = useState<Result | null>(null)
-  const [expanded, setExpanded] = useState<{ code: string; render: PreviewRenderer } | null>(null)
-  const close = useCallback(() => { setExpanded(null) }, [])
-  const current = result?.code === code && result.render === render ? result : null
-  const renderedPalette = current?.palette
-  const expandedCurrent = expanded?.code === code && expanded.render === render
-  const active = visible || expandedCurrent
-  useEffect(() => {
-    // oxlint-disable-next-line typescript/no-non-null-assertion -- React attaches this surface before effects run.
-    return observeViewport(target.current!, setVisible)
-  }, [])
-  useEffect(() => {
-    if (!active) return
-    let controller: AbortController | undefined
-    let palette: string | undefined
-    const update = () => {
-      const next = JSON.stringify(readPreviewTheme())
-      if (next === palette) return
-      palette = next
-      controller?.abort()
-      if (next === renderedPalette) return
-      const current = new AbortController()
-      controller = current
-      void (async () => await render(code, current.signal))().then(
-        (src) => {
-          if (current.signal.aborted) return
-          controller = undefined
-          setResult((prior) => {
-            const previous = prior?.code === code && prior.render === render ? loadedImage(prior) : undefined
-            return { kind: 'ok', code, render, palette: next, src, loaded: src === previous, previous: src === previous ? undefined : previous }
-          })
-        },
-        () => {
-          if (current.signal.aborted) return
-          controller = undefined
-          setResult(prior => ({ kind: 'error', code, render, palette: next,
-            previous: prior?.code === code && prior.render === render ? loadedImage(prior) : undefined }))
-        },
-      )
-    }
-    // Isolated images 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() }
-  }, [active, code, render, renderedPalette])
-
-  const displayed = loadedImage(current)
-  const ready = displayed !== undefined
-  const pending = !ready && current?.kind !== 'error'
-  const markLoaded = () => {
-    if (current?.kind !== 'ok' || current.loaded) return
-    setResult(prior => prior === current ? { ...current, loaded: true, previous: undefined } : prior)
-  }
-  return <div ref={target} className={css.root}>
-    {actions !== undefined && createPortal(
-      <Tooltip label={ready ? labels.zoom : pending ? labels.pending : labels.error} side="top" delayMs={500}>
-        <span className={blockCss.iconSlot}>
-          <button type="button" className={blockCss.iconButton} aria-label={labels.zoom} disabled={!ready}
-            aria-describedby={ready ? undefined : pendingId} onClick={() => {
-              setExpanded({ code, render })
-            }}><span aria-hidden="true"><IconSearchOutline16 /></span></button>
-          {!ready && <span id={pendingId} hidden>{pending ? labels.pending : labels.error}</span>}
-        </span>
-      </Tooltip>, actions,
-    )}
-    {pending && <PreviewPlaceholder label={labels.pending} />}
-    {current?.kind === 'error' && <div className={css.failure}>
-      <div className={css.status} role="status">{labels.error}</div>
-    </div>}
-    <div className={css.canvas} hidden={!ready}>
-      {current?.previous !== undefined &&
-        <img key={current.previous} className={css.diagram} src={current.previous} alt={labels.diagram} />}
-      {current?.kind === 'ok' && <img key={current.src} className={css.diagram} src={current.src} alt={labels.diagram}
-        hidden={!current.loaded}
-        ref={(image) => {
-          if (image !== null && image.complete && image.naturalWidth > 0) markLoaded()
-        }}
-        onLoad={markLoaded} onError={() => {
-          setResult(prior => prior === current ? { kind: 'error', code, render, palette: current.palette,
-            previous: current.previous } : prior)
-        }} />}
-    </div>
-    {ready && expandedCurrent && <PreviewLightbox
-      src={displayed} alt={labels.diagram} closeLabel={labels.close} interactionLabel={labels.interaction} onClose={close}
-    />}
-  </div>
-})

+ 0 - 136
packages/client/ui-primitives/src/markdown/grammars/dot.ts

@@ -1,136 +0,0 @@
-/**
- * DOT TextMate grammar from joaompinto/vscode-graphviz (MIT), commit
- * e001e27f7831fb34cb9a8de9c456485bbc509ec3, dot/syntaxes/dot.tmLanguage.
- * Mechanically decoded from plist; patterns and scopes are unchanged.
- * Editor metadata and rule comments are omitted; Shiki uses the dot id and graphviz alias.
- * Source and license: ../../../THIRD_PARTY_PREVIEW_NOTICES.txt.
- */
-import type { LanguageRegistration } from 'shiki/core'
-
-/** Pinned DOT registration for the shared lazy highlighter. */
-const grammar: LanguageRegistration[] = [
-  {
-    'name': 'dot',
-    'repository': {},
-    'patterns': [
-      {
-        'captures': {
-          '1': {
-            'name': 'storage.type.dot',
-          },
-          '2': {
-            'name': 'variable.other.dot',
-          },
-          '4': {
-            'name': 'punctuation.section.dot',
-          },
-        },
-        'match': ' ?(digraph)[ \\t]+([A-Za-z0-9]+) ?(\\{)',
-      },
-      {
-        'match': '(<|-)(>|-)',
-        'name': 'keyword.operator.dot',
-      },
-      {
-        'match': '\\b(node|edge|graph|digraph|subgraph|strict)\\b',
-        'name': 'storage.type.dot',
-      },
-      {
-        'match': '\\b(bottomlabel|color|comment|distortion|fillcolor|fixedsize|fontcolor|fontname|fontsize|group|height|label|layer|orientation|peripheries|regular|shape|shapefile|sides|skew|style|toplabel|URL|width|z)\\b',
-        'name': 'support.constant.attribute.node.dot',
-      },
-      {
-        'match': '\\b(arrowhead|arrowsize|arrowtail|color|comment|constraint|decorate|dir|fontcolor|fontname|fontsize|headlabel|headport|headURL|label|labelangle|labeldistance|labelfloat|labelcolor|labelfontname|labelfontsize|layer|lhead|ltail|minlen|samehead|sametail|splines|style|taillabel|tailport|tailURL|weight)\\b',
-        'name': 'support.constant.attribute.edge.dot',
-      },
-      {
-        'match': '\\b(bgcolor|center|clusterrank|color|comment|compound|concentrate|fillcolor|fontname|fontpath|fontsize|label|labeljust|labelloc|layers|margin|mclimit|nodesep|nslimit|nslimit1|ordering|orientation|page|pagedir|quantum|rank|rankdir|ranksep|ratio|remincross|rotate|samplepoints|searchsize|size|style|URL)\\b',
-        'name': 'support.constant.attribute.graph.dot',
-      },
-      {
-        'match': '\\b(box|polygon|ellipse|oval|circle|point|egg|triangle|plaintext|plain|diamond|trapezium|parallelogram|house|pentagon|hexagon|septagon|octagon|doublecircle|doubleoctagon|tripleoctagon|invtriangle|invtrapezium|invhouse|Mdiamond|Msquare|Mcircle|rect|rectangle|square|star|none|underline|cylinder|note|tab|folder|box3d|component|promoter|cds|terminator|utr|primersite|restrictionsite|fivepoverhang|threepoverhang|noverhang|assembly|signature|insulator|ribosite|rnastab|proteasesite|proteinstab|rpromoter|rarrow|larrow|lpromoter)\\b',
-        'name': 'variable.other.dot',
-      },
-      {
-        'begin': '"',
-        'beginCaptures': {
-          '0': {
-            'name': 'punctuation.definition.string.begin.dot',
-          },
-        },
-        'end': '"',
-        'endCaptures': {
-          '0': {
-            'name': 'punctuation.definition.string.end.dot',
-          },
-        },
-        'name': 'string.quoted.double.dot',
-        'patterns': [
-          {
-            'match': '\\\\.',
-            'name': 'constant.character.escape.dot',
-          },
-        ],
-      },
-      {
-        'begin': '(^[ \\t]+)?(?=//)',
-        'beginCaptures': {
-          '1': {
-            'name': 'punctuation.whitespace.comment.leading.dot',
-          },
-        },
-        'end': '(?!\\G)',
-        'patterns': [
-          {
-            'begin': '//',
-            'beginCaptures': {
-              '0': {
-                'name': 'punctuation.definition.comment.dot',
-              },
-            },
-            'end': '\\n',
-            'name': 'comment.line.double-slash.dot',
-          },
-        ],
-      },
-      {
-        'begin': '(^[ \\t]*)(?=#)',
-        'beginCaptures': {
-          '1': {
-            'name': 'punctuation.whitespace.comment.leading.dot',
-          },
-        },
-        'end': '(?!\\G)',
-        'patterns': [
-          {
-            'begin': '#',
-            'beginCaptures': {
-              '0': {
-                'name': 'punctuation.definition.comment.dot',
-              },
-            },
-            'end': '\\n',
-            'name': 'comment.line.number-sign.dot',
-          },
-        ],
-      },
-      {
-        'begin': '/\\*',
-        'captures': {
-          '0': {
-            'name': 'punctuation.definition.comment.dot',
-          },
-        },
-        'end': '\\*/',
-        'name': 'comment.block.dot',
-      },
-    ],
-    'scopeName': 'source.dot',
-    'displayName': 'Graphviz (DOT)',
-    'aliases': [
-      'graphviz',
-    ],
-  },
-]
-
-export default grammar

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

@@ -1,36 +0,0 @@
-/** Lazy Graphviz WebAssembly layout; generated SVG is displayed only as an inert image. */
-
-import type { instance } from '@viz-js/viz'
-import { renderSvg } from './svg.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.
- * @returns An SVG image data URL. Import, layout, invalid source, and cancellation failures reject.
- */
-export async function renderGraphviz(code: string, signal: AbortSignal): Promise<string> {
-  signal.throwIfAborted()
-  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', format: 'svg',
-    graphAttributes: { bgcolor: 'transparent', fontcolor: color(palette.text) },
-    nodeAttributes: {
-      color: color(palette.border), fontcolor: color(palette.text), fillcolor: color(palette.surface), style: 'filled',
-    },
-    edgeAttributes: { color: color(palette.line), fontcolor: color(palette.text) },
-  }), signal)
-}

+ 6 - 26
packages/client/ui-primitives/src/markdown/highlight.ts

@@ -9,10 +9,10 @@
  * JSON) load into the singleton at boot — the set every session renders. The
  * read card's wider extension set (the file-extension language hints the read
  * tool's `langFromPath` emits — `packages/fs/tool-fs`: python, rust, yaml,
- * markup, …) and diagram source grammars are imported lazily and registered
- * on first use, keeping their grammar modules and synchronous initialization
- * out of sessions that never render those languages.
- * The first render of a lazy language falls back to plain text while its
+ * markup, …) is imported lazily and registered the first time such a language
+ * is requested, so a session that never opens a read card in one of those
+ * languages pays neither the ~1.6 MB of grammar modules nor their synchronous
+ * init. The first render of a lazy language falls back to plain text while its
  * grammar loads, then {@link onGrammarLoaded} notifies subscribers to re-render
  * with highlighting. An unknown or absent language falls back to plain text (no
  * highlighting, still monospace) — never an error.
@@ -42,8 +42,8 @@ type LangModule = { default: typeof langTs }
 const LANGS = [langTs, langBash, langJson]
 
 /**
- * Additional source grammars, each behind a dynamic import so its module
- * stays out of the boot chunk until that language renders.
+ * The read card's extension grammars, each behind a dynamic import so its
+ * module stays out of the boot chunk until a read of that language renders.
  * Keyed by the grammar id (`LanguageRegistration.name`) the aliases resolve to.
  * `@shikijs/langs`' default export is a `LanguageRegistration[]`; the loader
  * hands the whole array to `loadLanguageSync`, which registers each entry
@@ -73,12 +73,6 @@ const LAZY_GRAMMARS = new Map<string, () => Promise<LangModule>>([
   ['less', () => import('@shikijs/langs/less')],
   ['sql', () => import('@shikijs/langs/sql')],
   ['xml', () => import('@shikijs/langs/xml')],
-  ['dot', () => import('./grammars/dot.ts')],
-  // Upstream's entry matches Markdown fences; CodeBlock supplies only their body.
-  ['mermaid', async () => {
-    const { default: grammars } = await import('@shikijs/langs/mermaid')
-    return { default: grammars.map(grammar => ({ ...grammar, patterns: [{ include: '#mermaid' }] })) }
-  }],
   ['lua', () => import('@shikijs/langs/lua')],
 ])
 
@@ -135,10 +129,6 @@ const LANG_ALIASES = new Map<string, string>([
   ['less', 'less'],
   ['sql', 'sql'],
   ['xml', 'xml'],
-  ['svg', 'xml'],
-  ['dot', 'dot'],
-  ['graphviz', 'dot'],
-  ['mermaid', 'mermaid'],
   ['lua', 'lua'],
 ])
 
@@ -235,16 +225,6 @@ export function grammarLoadCount(): number {
   return loadCount
 }
 
-/**
- * Read readiness without loading a grammar or invalidating unrelated source views.
- * @param lang - Case-insensitive language hint.
- * @returns Whether its grammar is already registered.
- */
-export function isGrammarLoaded(lang: string | undefined): boolean {
-  const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
-  return resolved !== undefined && highlighter().getLoadedLanguages().includes(resolved)
-}
-
 /**
  * Ensure the grammar `resolved` names is registered. A boot grammar (not in
  * {@link LAZY_GRAMMARS}) and an already-loaded lazy grammar report ready

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

@@ -1,105 +0,0 @@
-/** Lazy Mermaid runtime; each render owns and removes its temporary measurement DOM. */
-
-import type { Mermaid } from 'mermaid'
-import type { FlowDB } from 'mermaid/dist/diagrams/flowchart/flowDb.js'
-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): void {
-  const palette = readPreviewTheme()
-  mermaid.initialize({
-    theme: 'base',
-    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,
-    },
-    htmlLabels: false,
-    // Mermaid still accepts this legacy override alongside the global HTML-label setting.
-    // oxlint-disable-next-line typescript/no-deprecated
-    flowchart: { htmlLabels: false },
-    secure: [
-      'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges',
-      'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart',
-    ],
-  })
-}
-
-/**
- * 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.
- * @returns An SVG data URL. Image nodes, import, parse, rendering, and cancellation failures reject.
- */
-export async function renderMermaid(code: string, signal: AbortSignal): Promise<string> {
-  const task = pending.then(() => renderOne(code, signal))
-  pending = task.catch(() => { /* The caller owns rejection; subsequent diagrams still render. */ })
-  return task
-}
-
-async function renderOne(code: string, signal: AbortSignal): Promise<string> {
-  signal.throwIfAborted()
-  const mermaid = await loadMermaid()
-  signal.throwIfAborted()
-  initialize(mermaid)
-  // Mermaid image nodes fetch resources during layout, even under strict security.
-  // The replacement parse API exposes validity, not parsed nodes.
-  // oxlint-disable-next-line typescript/no-deprecated
-  const diagram = await mermaid.mermaidAPI.getDiagramFromText(code)
-  signal.throwIfAborted()
-  if (diagram.type.startsWith('flowchart')) {
-    for (const node of (diagram.db as FlowDB).getVertices().values()) {
-      if (node.img !== undefined) throw new Error('Mermaid image nodes are not supported')
-    }
-  }
-  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()
-  }
-}

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

@@ -1,27 +0,0 @@
-/** 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'),
-  }
-}

+ 6 - 76
packages/client/ui-primitives/src/markdown/render.tsx

@@ -23,81 +23,24 @@ 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 type { CodeBlockPreview } from './CodeBlock.tsx'
-import { renderGraphviz } from './graphviz.ts'
-import { renderSvg } from './svg.ts'
-import { renderMermaid } from './mermaid.ts'
-import type { PreviewLabels } from './SourcePreview.tsx'
 import { renderTexToReact } from './katex.tsx'
 import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx'
 import { useMarkdownExternalLinkDelegate } from './MarkdownDelegate.tsx'
 import type { PositionedBlock } from './incremental.ts'
 import css from './MarkdownText.module.css'
 
-const FENCE_PREVIEW_RENDERERS = {
-  mermaid: renderMermaid,
-  graphviz: renderGraphviz,
-  svg: renderSvg,
-} as const
-
 /** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
 export interface MarkdownCodeLabels {
   /** Copy-button idle label. */
   copyLabel: string
   /** Copy-button label during the post-copy confirmation window. */
   copiedLabel: string
-  /** Selected source-view label. */
-  sourceLabel: string
-  /** Localized source line-number toggle label. */
-  lineNumbersLabel: string
 }
 
 /** Localized chrome for a Markdown document. */
 export interface MarkdownLabels {
   code: MarkdownCodeLabels
   footnotes: string
-  /** Opt into settled Mermaid, Graphviz, and SVG previews with complete localized labels. */
-  preview?: {
-    mermaid: PreviewLabels
-    graphviz: PreviewLabels
-    svg: PreviewLabels
-    preview: string
-    source: string
-    zoom: string
-    pending: string
-    close: string
-    interaction: string
-  }
-}
-
-type FencePreviewKind = keyof typeof FENCE_PREVIEW_RENDERERS
-
-/** Stable preview descriptors shared by every settled fence in one label revision. */
-export type FencePreviewCatalog = Readonly<Record<FencePreviewKind, CodeBlockPreview>>
-
-/**
- * Build the preview descriptors owned by one localized label revision.
- * @param labels - Localized preview labels, or undefined when previews are disabled.
- * @returns Stable language descriptors for the lifetime of `labels`.
- */
-export function createFencePreviewCatalog(
-  labels: MarkdownLabels['preview'],
-): FencePreviewCatalog | undefined {
-  if (labels === undefined) return undefined
-  const previewLabels = <K extends FencePreviewKind>(kind: K) => ({
-    ...labels[kind],
-    preview: labels.preview,
-    source: labels.source,
-    zoom: labels.zoom,
-    pending: labels.pending,
-    close: labels.close,
-    interaction: labels.interaction,
-  })
-  return {
-    mermaid: { render: FENCE_PREVIEW_RENDERERS.mermaid, labels: previewLabels('mermaid') },
-    graphviz: { render: FENCE_PREVIEW_RENDERERS.graphviz, labels: previewLabels('graphviz') },
-    svg: { render: FENCE_PREVIEW_RENDERERS.svg, labels: previewLabels('svg') },
-  }
 }
 
 function sanitizeUrl(url: string): string {
@@ -236,8 +179,6 @@ export interface MarkdownRenderContext {
   readonly streaming: boolean
   /** Localized fence copy-button labels. */
   readonly labels: MarkdownLabels
-  /** Settled fence previews resolved once for the current localized labels. */
-  readonly previews: FencePreviewCatalog | undefined
   /** Inside a blockquote's children: tables there always fill the quote's width. */
   readonly inBlockquote?: boolean
   /** Inline-code file mentions; absent wherever no opener vocabulary exists. */
@@ -422,10 +363,8 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
 
 function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): ReactNode {
   const language = node.lang ?? undefined
-  const lang = language === undefined ? undefined : /^[\w-]+/.exec(language)?.[0]
-  const preview = fencePreview(lang, context.previews)
-  if (node.value === '' && !(context.streaming && preview !== undefined)) {
-    // Empty source-only fences keep the stock Markdown <pre>.
+  if (node.value === '') {
+    // Parity: the replaced pipeline kept the stock <pre> for an empty fence.
     return (
       <pre key={key}>
         <code className={language === undefined ? undefined : `language-${language}`} />
@@ -434,6 +373,7 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
   }
   // The replaced pipeline recovered the grammar id from the hast class with
   // /language-([\w-]+)/, which truncates at the first non-word character.
+  const lang = language === undefined ? undefined : /^[\w-]+/.exec(language)?.[0]
   if (!context.streaming && lang === 'math') {
     // ```math fences render as display TeX once settled (rehype-katex parity);
     // its text extraction saw the code block's trailing newline.
@@ -449,26 +389,16 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
       lang={lang}
       // Streaming keys are source offsets, stable while the fence grows, so
       // the CodeBlock instance (and its incremental highlight session)
-      // survives every chunk. The language hint is final once content starts.
+      // survives every chunk. A fence whose info string is still mid-chunk
+      // has no content yet and took the empty-fence arm above, so `lang`
+      // here is final: it can never re-resolve to a different grammar.
       streaming={context.streaming}
-      sourceLabel={context.labels.code.sourceLabel}
-      lineNumbersLabel={context.labels.code.lineNumbersLabel}
       copyLabel={context.labels.code.copyLabel}
       copiedLabel={context.labels.code.copiedLabel}
-      preview={preview}
     />
   )
 }
 
-/** Resolve the supported fence language without treating arbitrary HTML in Markdown as a preview. */
-function fencePreview(lang: string | undefined, previews: FencePreviewCatalog | undefined): CodeBlockPreview | undefined {
-  if (previews === undefined) return undefined
-  const normalized = lang?.toLowerCase()
-  const kind = normalized === 'dot' ? 'graphviz' : normalized
-  if (kind !== 'mermaid' && kind !== 'graphviz' && kind !== 'svg') return undefined
-  return previews[kind]
-}
-
 /** 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)

+ 0 - 17
packages/client/ui-primitives/src/markdown/svg.ts

@@ -1,17 +0,0 @@
-/** SVG image previews without executable source markup. */
-
-/**
- * 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)}`
-}

+ 45 - 6
packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts

@@ -1,7 +1,48 @@
 import { useCallback, useEffect, useState } from 'react'
 import type { RefObject } from 'react'
 import { supportsHighlighting } from './highlight.ts'
-import { observeViewport } from './viewport.ts'
+
+const noop = (): void => {}
+
+/** One document-wide observer; activated elements leave it permanently. */
+class HighlightViewport {
+  private observer: IntersectionObserver | undefined
+  private readonly activators = new Map<Element, () => void>()
+
+  observe(element: Element, activate: () => void): () => void {
+    if (typeof IntersectionObserver === 'undefined') {
+      activate()
+      return noop
+    }
+    this.observer ??= new IntersectionObserver((entries) => {
+      for (const entry of entries) {
+        if (!entry.isIntersecting) continue
+        const current = this.activators.get(entry.target)
+        /* v8 ignore next -- the observer reports only elements still registered with it. */
+        if (current === undefined) continue
+        this.activators.delete(entry.target)
+        this.observer?.unobserve(entry.target)
+        current()
+      }
+      this.releaseEmptyObserver()
+    })
+    this.activators.set(element, activate)
+    this.observer.observe(element)
+    return () => {
+      this.activators.delete(element)
+      this.observer?.unobserve(element)
+      this.releaseEmptyObserver()
+    }
+  }
+
+  private releaseEmptyObserver(): void {
+    if (this.activators.size > 0) return
+    this.observer?.disconnect()
+    this.observer = undefined
+  }
+}
+
+const highlightViewport = new HighlightViewport()
 
 /**
  * Activate one supported code surface when it first intersects the viewport.
@@ -9,24 +50,22 @@ import { observeViewport } from './viewport.ts'
  * IntersectionObserver activate immediately.
  * @param target - Code surface whose plain rendering reserves its geometry.
  * @param lang - Optional language hint.
- * @param initiallyActive - Activate at mount on explicit reader intent without waiting for viewport delivery.
  * @returns Whether this component may build highlighted output.
  */
 export function useViewportHighlighting(
   target: RefObject<Element>,
   lang: string | undefined,
-  initiallyActive = false,
 ): boolean {
   const supported = supportsHighlighting(lang)
-  const [activated, setActivated] = useState(initiallyActive)
-  const activate = useCallback((visible: boolean) => { if (visible) setActivated(true) }, [])
+  const [activated, setActivated] = useState(false)
+  const activate = useCallback(() => { setActivated(true) }, [])
 
   useEffect(() => {
     if (activated || !supported) return
     const element = target.current
     /* v8 ignore next -- React attaches the host ref before running effects. */
     if (element === null) return
-    return observeViewport(element, activate)
+    return highlightViewport.observe(element, activate)
   }, [activate, activated, supported, target])
 
   return activated && supported

+ 0 - 29
packages/client/ui-primitives/src/markdown/viewport.ts

@@ -1,29 +0,0 @@
-/** Shared viewport observation for deferred code and diagram work. */
-
-let observer: IntersectionObserver | undefined
-const listeners = new Map<Element, (visible: boolean) => void>()
-
-/**
- * Observe visibility until disposal; unsupported browsers activate immediately.
- * @param element - Stable surface whose geometry remains available while work is deferred.
- * @param changed - Receives intersection changes, including exits.
- * @returns A disposer that releases the shared observer after the last surface leaves.
- */
-export function observeViewport(element: Element, changed: (visible: boolean) => void): () => void {
-  if (typeof IntersectionObserver === 'undefined') {
-    changed(true)
-    return () => {}
-  }
-  observer ??= new IntersectionObserver((entries) => {
-    for (const entry of entries) listeners.get(entry.target)?.(entry.isIntersecting)
-  })
-  listeners.set(element, changed)
-  observer.observe(element)
-  return () => {
-    listeners.delete(element)
-    observer?.unobserve(element)
-    if (listeners.size > 0) return
-    observer?.disconnect()
-    observer = undefined
-  }
-}

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

@@ -7,22 +7,11 @@ import { CodeBlock as LocalizedCodeBlock } from '../src/markdown/CodeBlock.tsx'
 import { highlightToHtml, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
 import { markdownLabels } from './labels.client.ts'
 
-function CodeBlock(props: Omit<ComponentProps<typeof LocalizedCodeBlock>, 'copyLabel' | 'copiedLabel' | 'sourceLabel' | 'lineNumbersLabel'>) {
+function CodeBlock(props: Omit<ComponentProps<typeof LocalizedCodeBlock>, 'copyLabel' | 'copiedLabel'>) {
   return <LocalizedCodeBlock {...props} {...markdownLabels.code} />
 }
 
-const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
-const execCommandDescriptor = Object.getOwnPropertyDescriptor(document, 'execCommand')
-
-afterEach(() => {
-  cleanup()
-  vi.useRealTimers()
-  vi.restoreAllMocks()
-  if (clipboardDescriptor === undefined) Reflect.deleteProperty(navigator, 'clipboard')
-  else Object.defineProperty(navigator, 'clipboard', clipboardDescriptor)
-  if (execCommandDescriptor === undefined) Reflect.deleteProperty(document, 'execCommand')
-  else Object.defineProperty(document, 'execCommand', execCommandDescriptor)
-})
+afterEach(cleanup)
 
 beforeEach(() => {
   vi.useRealTimers()
@@ -70,13 +59,6 @@ describe('highlightToHtml', () => {
 })
 
 describe('CodeBlock', () => {
-  it('omits its header when the owner supplies toolbar controls', () => {
-    const view = render(<CodeBlock code="plain text" showHeader={false} />)
-    expect(view.container.querySelector('[data-code-block-header]')).toBeNull()
-    expect(view.container.querySelector('[data-code-block-banner]')).toBeNull()
-    expect(view.container.querySelector('pre')?.textContent).toBe('plain text')
-  })
-
   it('reports the stable source-content wrapper to its owner', () => {
     const contentRef = vi.fn<(node: HTMLDivElement | null) => void>()
     const view = render(<CodeBlock code="plain text" contentRef={contentRef} />)
@@ -124,7 +106,7 @@ describe('CodeBlock', () => {
     const view = render(<CodeBlock code="" lineNumbers />)
     expect(view.container.querySelectorAll('code > .line')).toHaveLength(1)
     expect(view.container.querySelector('pre')!.textContent).toBe('')
-    const gutter = () => view.container.querySelector<HTMLElement>('[style*="--dsl-code-block-line-number-width"]')!
+    const gutter = () => view.container.querySelector<HTMLElement>('[data-line-numbers]')!
       .style.getPropertyValue('--dsl-code-block-line-number-width')
     expect(gutter()).toBe('2ch')
     view.rerender(<CodeBlock code="const first = 1" lang="ts" streaming lineNumbers />)
@@ -151,88 +133,27 @@ describe('CodeBlock', () => {
     expect(view.getByText('plain text')).toBeTruthy()
   })
 
-  it('shows a noninteractive selected source label for a block without preview', () => {
-    const view = render(<CodeBlock code="const a = 1" lang="ts" />)
-    const banner = view.container.querySelector('[data-code-block-banner]')!
-    expect(screen.getByText(markdownLabels.code.sourceLabel).tagName).toBe('SPAN')
-    expect(screen.queryByRole('button', { name: markdownLabels.code.sourceLabel })).toBeNull()
-    expect([...banner.querySelectorAll('button')].map(button => button.getAttribute('aria-label')))
-      .toEqual([markdownLabels.code.lineNumbersLabel, markdownLabels.code.copyLabel])
-  })
-
-  it.each(['ts', undefined])('trims exactly one trailing newline from %s source and its clipboard text', async (lang) => {
-    const writeText = vi.fn().mockResolvedValue(undefined)
-    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
-    const view = render(<CodeBlock code={'const a = 1\n\n'} lang={lang} />)
-    expect(view.container.querySelector('pre')?.textContent).toBe('const a = 1\n')
-    await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
-    expect(writeText).toHaveBeenCalledWith('const a = 1\n')
-  })
-
-  it('shows the language banner and copies the displayed source text', async () => {
+  it('shows the language banner and copies the pre textContent', async () => {
     vi.useFakeTimers()
     const writeText = vi.fn().mockResolvedValue(undefined)
     Object.defineProperty(navigator, 'clipboard', {
       configurable: true,
       value: { writeText },
     })
-    const view = render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
-    const sourceElement = view.container.querySelector('pre')
+    render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
     expect(screen.getByText('ts')).toBeTruthy()
-    const copy = screen.getByRole('button', { name: '复制' })
-    expect(copy.textContent).toBe('')
-    const idleIcon = copy.querySelector('svg')?.outerHTML
-    fireEvent.click(copy)
+    fireEvent.click(screen.getByRole('button', { name: '复制' }))
     expect(writeText).toHaveBeenCalledWith('const a = 1')
     // Flush the clipboard promise under fake timers before asserting the label.
     await act(async () => {
       await Promise.resolve()
     })
-    expect(screen.getByRole('button', { name: '复制成功' })).toBe(copy)
-    expect(copy.textContent).toBe('')
-    expect(copy.querySelector('svg')?.outerHTML).not.toBe(idleIcon)
-    expect(view.container.querySelector('pre')).toBe(sourceElement)
+    expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
     // While the ok label is showing, further clicks are no-ops.
     fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
     expect(writeText).toHaveBeenCalledTimes(1)
-    await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
-    expect(screen.getByRole('button', { name: '复制' })).toBe(copy)
-    expect(copy.querySelector('svg')?.outerHTML).toBe(idleIcon)
-  })
-
-  it('shows localized copy and confirmation tooltips for keyboard focus', async () => {
-    const writeText = vi.fn().mockResolvedValue(undefined)
-    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
-    render(<CodeBlock code="plain body" />)
-    const copy = screen.getByRole('button', { name: '复制' })
-    fireEvent.focus(copy)
-    expect(screen.getByRole('tooltip').textContent).toBe('复制')
-    await act(async () => { fireEvent.click(copy) })
-    expect(screen.getByRole('tooltip').textContent).toBe('复制成功')
-  })
-
-  it('does not schedule confirmation after an outstanding clipboard write finishes on an unmounted block', async () => {
-    vi.useFakeTimers()
-    const pending = Promise.withResolvers<undefined>()
-    const writeText = vi.fn(() => pending.promise)
-    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
-    const view = render(<CodeBlock code="plain body" />)
-    fireEvent.click(screen.getByRole('button', { name: '复制' }))
-    expect(writeText).toHaveBeenCalledWith('plain body')
-    view.unmount()
-    await act(async () => { pending.resolve(undefined) })
-    expect(vi.getTimerCount()).toBe(0)
-    expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
-  })
-
-  it('clears the confirmation timer when the block unmounts', async () => {
-    vi.useFakeTimers()
-    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) } })
-    const view = render(<CodeBlock code="plain body" />)
-    await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
-    expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
-    view.unmount()
-    expect(vi.getTimerCount()).toBe(0)
+    await vi.advanceTimersByTimeAsync(1000)
+    expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
   })
 
   it('does not claim success when clipboard.writeText rejects', async () => {

+ 0 - 88
packages/client/ui-primitives/tests/diagram-highlight.client.spec.tsx

@@ -1,88 +0,0 @@
-// @vitest-environment jsdom
-
-import { act, cleanup, render } from '@testing-library/react'
-import { afterEach, describe, expect, it } from 'vitest'
-import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
-import { highlightToHtml, StreamingHighlightSession, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
-import { markdownLabels } from './labels.client.ts'
-
-const samples = [
-  {
-    lang: 'dot',
-    code: 'digraph G {\n  // route\n  /* multi\n     line */\n  node [shape=box];\n  A -> B [label="hello"];\n}',
-    tokens: [['digraph', 'keyword'], ['// route', 'comment'], ['line */', 'comment'], ['"hello"', 'string-expression']],
-  },
-  {
-    lang: 'svg',
-    code: '<svg xmlns="http://www.w3.org/2000/svg">\n  <!-- multi\n       line -->\n  <rect width="20" fill="red"/>\n</svg>',
-    tokens: [['svg', 'string-expression'], ['line -->', 'comment'], ['width', 'function'], ['"red"', 'string-expression']],
-  },
-  {
-    lang: 'mermaid',
-    code: 'flowchart LR\n  %% route\n  A["Input"] --> B["Preview"]',
-    tokens: [['flowchart', 'keyword'], ['%% route', 'comment'], ['"Input"', 'string']],
-  },
-] as const
-
-afterEach(cleanup)
-
-function tokenTree(root: ParentNode) {
-  return [...root.querySelectorAll('pre.shiki .line')].map(line =>
-    [...line.querySelectorAll<HTMLElement>('span[style]')].map(span => [span.textContent, span.style.cssText]),
-  )
-}
-
-describe('diagram source highlighting', () => {
-  // Each grammar's first use is isolated from the other highlighter spec files.
-  it.each(samples)('automatically highlights cold $lang source in both rendering modes', async ({ lang, code, tokens }) => {
-    const ready = Promise.withResolvers<undefined>()
-    const stop = subscribeGrammarLoaded(() => { ready.resolve(undefined) })
-    try {
-      const settled = render(<CodeBlock code={code} lang={lang.toUpperCase()} {...markdownLabels.code} />)
-      const streamed = render(<CodeBlock code={code} lang={lang} streaming {...markdownLabels.code} />)
-      expect(settled.container.querySelector('pre.shiki')).toBeNull()
-      expect(streamed.container.querySelector('pre.shiki')).toBeNull()
-      await act(async () => { await ready.promise })
-      expect(settled.container.querySelector('pre.shiki')?.textContent).toBe(code)
-      expect(streamed.container.querySelector('pre.shiki')?.textContent).toBe(code)
-      expect(tokenTree(streamed.container)).toEqual(tokenTree(settled.container))
-      const spans = [...settled.container.querySelectorAll<HTMLElement>('pre.shiki span[style]')]
-      for (const [text, color] of tokens) {
-        expect(spans.some(span => span.textContent?.trim() === text
-          && span.style.color === `var(--shiki-token-${color})`), text).toBe(true)
-      }
-    } finally {
-      stop()
-    }
-  })
-
-  it('uses one grammar for DOT aliases and reuses XML for SVG', () => {
-    const code = samples[0].code
-    expect(highlightToHtml(code, 'DoT')).toBe(highlightToHtml(code, 'GrApHvIz'))
-    expect(highlightToHtml(samples[1].code, 'SVG')).toBe(highlightToHtml(samples[1].code, 'xml'))
-    const session = new StreamingHighlightSession()
-    const first = session.update(code, 'dot')
-    expect(session.update(code, 'GRAPHVIZ')?.[0]).toBe(first?.[0])
-  })
-
-  it.each(samples)('matches full $lang highlighting at every appended character and retains settled DOM', ({ lang, code }) => {
-    const session = new StreamingHighlightSession()
-    for (let end = 1; end <= code.length; end += 1) {
-      const prefix = code.slice(0, end)
-      expect(session.update(prefix, lang)).toEqual(new StreamingHighlightSession().update(prefix, lang))
-    }
-    const firstChunk = code.slice(0, code.indexOf('\n') + 1)
-    const view = render(<CodeBlock code={firstChunk} lang={lang} streaming {...markdownLabels.code} />)
-    const firstLine = view.container.querySelector('pre.shiki .line')
-    expect(firstLine).not.toBeNull()
-    view.rerender(<CodeBlock code={code} lang={lang} streaming {...markdownLabels.code} />)
-    expect(view.container.querySelector('pre.shiki .line')).toBe(firstLine)
-    const pre = view.container.querySelector('pre.shiki')
-    const streamed = tokenTree(view.container)
-    const settled = render(<CodeBlock code={code} lang={lang} {...markdownLabels.code} />)
-    expect(streamed).toEqual(tokenTree(settled.container))
-    view.rerender(<CodeBlock code={code} lang={lang} {...markdownLabels.code} />)
-    expect(view.container.querySelector('pre.shiki')).toBe(pre)
-    expect(tokenTree(view.container)).toEqual(streamed)
-  })
-})

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

@@ -1,111 +0,0 @@
-// @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 { renderSvg } from '../src/markdown/svg.ts'
-import { markdownLabels } from './labels.client.ts'
-
-const status = { error: 'Cannot preview' }
-const preview = {
-  mermaid: { ...status, diagram: 'Mermaid diagram' },
-  graphviz: { ...status, diagram: 'Graphviz diagram' },
-  svg: { ...status, diagram: 'SVG preview' },
-  preview: 'Preview', source: 'Source', zoom: 'Enlarge preview', pending: 'Preparing preview…', close: 'Close preview', interaction: 'Drag to pan, scroll to zoom',
-}
-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],
-] 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)('selects settled %s preview and retains source and image across view changes', 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')).toBeNull()
-    expect(screen.getByRole('status').textContent).toBe(preview.pending)
-    view.rerender(<MarkdownText {...props} />)
-    expect(view.container.querySelector('pre')).toBeNull()
-    expect(view.container.querySelector('[data-code-block-source-view]')?.getAttribute('aria-hidden')).toBe('true')
-    expect(screen.getByRole('button', { name: 'Preview' }).getAttribute('aria-pressed')).toBe('true')
-    const findPreview = async () => {
-      const img = await waitFor(() => {
-        const node = document.querySelector('img')
-        expect(node).not.toBeNull()
-        return node!
-      })
-      fireEvent.load(img)
-      return screen.getByRole('img', { name: title })
-    }
-    const element = await findPreview()
-    expect(element.getAttribute('src')).toMatch(/^data:image\/svg\+xml/)
-    expect(view.container.querySelector('iframe')).toBeNull()
-    expect(view.container.querySelector('[data-code-block-preview]')).not.toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: 'Source' }))
-    expect(view.container.querySelector('pre code')?.textContent).toBe(code)
-    expect(view.container.querySelector('[data-code-block-preview]')?.hasAttribute('hidden')).toBe(true)
-    expect(screen.queryByRole('img', { name: title })).toBeNull()
-    expect(element.isConnected).toBe(true)
-    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)
-    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 waitFor(() => { expect(screen.getByRole('status').textContent).toBe(status.error) })
-    expect(view.container.querySelector('pre')).toBeNull()
-    expect(view.container.querySelector('[data-code-block-source-view]')?.getAttribute('aria-hidden')).toBe('true')
-    fireEvent.click(screen.getByRole('button', { name: 'Source' }))
-    expect(view.container.querySelector('pre code')?.textContent).toBe('broken')
-    fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
-    view.rerender(<MarkdownText text={`\`\`\`svg\n${svg}\n\`\`\``} labels={{ ...markdownLabels, preview }} />)
-    await waitFor(() => { expect(view.container.querySelector('img')).not.toBeNull() })
-    fireEvent.load(view.container.querySelector('img')!)
-    await screen.findByRole('img', { name: preview.svg.diagram })
-    expect(screen.queryByRole('status')).toBeNull()
-  })
-
-  it('requires an opted-in supported code fence and leaves HTML and other languages unrendered', () => {
-    const view = render(<MarkdownText text={'```html\n<h1>Example</h1>\n```'} labels={{ ...markdownLabels, preview }} />)
-    expect(view.container.querySelector('pre code')?.textContent).toBe('<h1>Example</h1>')
-    expect(screen.queryByRole('button', { name: 'Preview' })).toBeNull()
-    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 SVG previews', () => {
-  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('does not prepare a cancelled SVG', () => {
-    const controller = new AbortController()
-    controller.abort()
-    expect(() => renderSvg(svg, controller.signal)).toThrow(controller.signal.reason)
-  })
-})

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 6
packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 6
packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 6
packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.settled.txt


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 6
packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.streaming.txt


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 6
packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.settled.txt


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 6
packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.streaming.txt


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 6
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt


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

@@ -1,66 +0,0 @@
-// @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' },
-    }))
-  } finally {
-    if (previous === null) document.body.removeAttribute('style')
-    else document.body.setAttribute('style', previous)
-  }
-})
-
-
-it('does not initialize the runtime for already cancelled work', async () => {
-  const { renderGraphviz } = await import('../src/markdown/graphviz.ts')
-  const controller = new AbortController()
-  controller.abort()
-  await expect(renderGraphviz('digraph {}', controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
-  expect(instance).not.toHaveBeenCalled()
-})

+ 1 - 156
packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx

@@ -1,8 +1,7 @@
 // @vitest-environment jsdom
 
-import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { act, cleanup, render, waitFor } from '@testing-library/react'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { SourcePreview } from '../src/markdown/SourcePreview.tsx'
 import { ReadBlock } from '../src/ReadBlock.tsx'
 import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
 import { markdownLabels, readBlockLabels } from './labels.client.ts'
@@ -101,24 +100,6 @@ describe('viewport-activated syntax highlighting', () => {
     expect(observer.disconnected).toBe(true)
   })
 
-  it.each([
-    ['mermaid', 'flowchart LR\n A[Input] --> B[Output]'],
-    ['svg', '<svg xmlns="http://www.w3.org/2000/svg"><rect width="10"/></svg>'],
-    ['dot', 'digraph { a [label="Input"]; a -> b }'],
-  ])('defers %s source until selection, then highlights without viewport delivery', async (lang, code) => {
-    const preview = {
-      render: async () => 'data:image/svg+xml,%3Csvg%2F%3E',
-      labels: { source: 'Source', preview: 'Preview', diagram: 'Diagram', error: 'Error', zoom: 'Zoom', pending: 'Pending', close: 'Close', interaction: 'Pan and zoom' },
-    }
-    const view = render(<CodeBlock code={code} lang={lang} preview={preview} {...markdownLabels.code} />)
-    expect(view.container.querySelector('pre')).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: 'Source' }))
-    await waitFor(() => { expect(view.container.querySelector('pre.shiki')).not.toBeNull() })
-    expect(view.container.querySelector('[data-code-block-source-view]')?.hasAttribute('aria-hidden')).toBe(false)
-    expect(view.container.querySelector('pre code')?.textContent).toBe(code)
-    expect(view.container.querySelectorAll('pre span[style]').length).toBeGreaterThan(1)
-  })
-
   it('does not observe an unsupported language', () => {
     const view = render(
       <CodeBlock code="IDENTIFICATION DIVISION." lang="cobol" {...markdownLabels.code} />,
@@ -127,32 +108,6 @@ describe('viewport-activated syntax highlighting', () => {
     expect(IntersectionObserverStub.instances).toHaveLength(0)
   })
 
-  it('releases the offscreen observer when preview replaces source, then highlights on selection', async () => {
-    const code = '<svg><rect width="10"/></svg>'
-    const props = { code, lang: 'svg', ...markdownLabels.code }
-    const view = render(<CodeBlock {...props} />)
-    const block = view.container.querySelector('.md-code-block')!
-    const observer = IntersectionObserverStub.instances[0]!
-    expect(observer.observed.has(block)).toBe(true)
-    expect(block.querySelector('pre.shiki')).toBeNull()
-
-    const preview = {
-      render: async () => 'data:image/svg+xml,%3Csvg%2F%3E',
-      labels: { source: 'Source', preview: 'Preview', diagram: 'Diagram', error: 'Error', zoom: 'Zoom', pending: 'Pending', close: 'Close', interaction: 'Pan and zoom' },
-    }
-    view.rerender(<CodeBlock {...props} preview={preview} />)
-    fireEvent.click(screen.getByRole('button', { name: 'Source' }))
-    await waitFor(() => { expect(block.querySelector('pre.shiki')).not.toBeNull() })
-    const highlighted = block.querySelector('pre.shiki')!
-    expect(highlighted.textContent).toBe(code)
-    expect(observer.unobserved.has(block)).toBe(true)
-    expect(observer.disconnected).toBe(true)
-
-    view.rerender(<CodeBlock {...props} />)
-    expect(block.querySelector('pre.shiki')).toBe(highlighted)
-    expect(IntersectionObserverStub.instances.every(instance => instance.disconnected)).toBe(true)
-  })
-
   it('releases the shared observer when the last pending block unmounts', () => {
     const view = render(<CodeBlock code="const pending = true" lang="ts" {...markdownLabels.code} />)
     const block = view.container.querySelector('.md-code-block')!
@@ -203,113 +158,3 @@ describe('viewport-activated syntax highlighting', () => {
     })
   })
 })
-
-
-describe('viewport-activated diagram previews', () => {
-  const labels = { diagram: 'Diagram', error: 'Error', zoom: 'Zoom', pending: 'Pending', close: 'Close', interaction: 'Pan and zoom' }
-  const imageUrl = 'data:image/svg+xml,%3Csvg%2F%3E'
-
-  it('leaves an offscreen history unrendered and activates only visible previews', async () => {
-    const renderer = vi.fn(async () => imageUrl)
-    const view = render(<>{Array.from({ length: 20 }, (_, index) =>
-      <SourcePreview key={index} code={String(index)} render={renderer} labels={labels} />)}</>)
-    expect(renderer).not.toHaveBeenCalled()
-    expect(view.container.querySelectorAll('img')).toHaveLength(0)
-    expect(IntersectionObserverStub.instances).toHaveLength(1)
-    const observer = IntersectionObserverStub.instances[0]!
-    expect(observer.observed.size).toBe(20)
-    const target = [...observer.observed][19]!
-    await act(async () => { observer.intersect(target, true) })
-    expect(renderer).toHaveBeenCalledExactlyOnceWith('19', expect.any(AbortSignal))
-    fireEvent.load(view.container.querySelector('img')!)
-    const image = screen.getByRole('img')
-    await act(async () => { observer.intersect(target, false) })
-    await act(async () => { observer.intersect(target, true) })
-    expect(renderer).toHaveBeenCalledOnce()
-    expect(screen.getByRole('img')).toBe(image)
-    view.unmount()
-    expect(observer.observed.size).toBe(0)
-    expect(observer.disconnected).toBe(true)
-  })
-
-  it('cancels work leaving the viewport and ignores its completion after reentry', async () => {
-    const first = Promise.withResolvers<string>()
-    const renderer = vi.fn((_code: string, _signal: AbortSignal) => first.promise).mockResolvedValueOnce(imageUrl)
-    const view = render(<SourcePreview code="first" render={renderer} labels={labels} />)
-    const observer = IntersectionObserverStub.instances[0]!
-    const target = [...observer.observed][0]!
-    await act(async () => { observer.intersect(target, true) })
-    fireEvent.load(view.container.querySelector('img')!)
-    await act(async () => { observer.intersect(target, false) })
-    view.rerender(<SourcePreview code="second" render={renderer} labels={labels} />)
-    expect(renderer).toHaveBeenCalledOnce()
-    await act(async () => { observer.intersect(target, true) })
-    const signal = renderer.mock.calls[1]![1]
-    await act(async () => { observer.intersect(target, false) })
-    expect(signal.aborted).toBe(true)
-    renderer.mockResolvedValueOnce(imageUrl)
-    await act(async () => { observer.intersect(target, true) })
-    fireEvent.load(view.container.querySelector('img')!)
-    await act(async () => { first.resolve('obsolete') })
-    expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
-    expect(renderer).toHaveBeenCalledTimes(3)
-  })
-
-  it('keeps an offscreen lightbox active and cancels its unfinished refresh on close', async () => {
-    const replacement = Promise.withResolvers<string>()
-    const renderer = vi.fn((_code: string, _signal: AbortSignal) => replacement.promise).mockResolvedValueOnce(imageUrl)
-    const actions = document.createElement('div')
-    document.body.append(actions)
-    const previous = document.documentElement.style.colorScheme
-    const view = render(<SourcePreview code="diagram" render={renderer} labels={labels} actions={actions} />)
-    try {
-      const observer = IntersectionObserverStub.instances[0]!
-      const target = [...observer.observed][0]!
-      await act(async () => { observer.intersect(target, true) })
-      fireEvent.load(view.container.querySelector('img')!)
-      fireEvent.click(screen.getByRole('button', { name: labels.zoom }))
-      await act(async () => { observer.intersect(target, false) })
-      await act(async () => { document.documentElement.style.colorScheme = 'dark' })
-      expect(renderer).toHaveBeenCalledTimes(2)
-      const signal = renderer.mock.calls[1]![1]
-      expect(signal.aborted).toBe(false)
-      expect(screen.getByRole('dialog')).toBeDefined()
-      fireEvent.click(screen.getByRole('button', { name: labels.close }))
-      expect(signal.aborted).toBe(true)
-      await act(async () => { replacement.reject(new Error('cancelled')) })
-      expect(screen.queryByRole('status')).toBeNull()
-      expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
-    } finally {
-      view.unmount()
-      actions.remove()
-      document.documentElement.style.colorScheme = previous
-    }
-  })
-
-  it('defers offscreen theme updates and retains the loaded image until reentry refresh loads', async () => {
-    const renderer = vi.fn(async () => imageUrl)
-    const previous = document.documentElement.style.colorScheme
-    const view = render(<SourcePreview code="diagram" render={renderer} labels={labels} />)
-    try {
-      const observer = IntersectionObserverStub.instances[0]!
-      const target = [...observer.observed][0]!
-      await act(async () => { observer.intersect(target, true) })
-      fireEvent.load(view.container.querySelector('img')!)
-      const image = screen.getByRole('img')
-      await act(async () => { observer.intersect(target, false) })
-      await act(async () => { document.documentElement.style.colorScheme = 'dark' })
-      expect(renderer).toHaveBeenCalledOnce()
-      expect(screen.getByRole('img')).toBe(image)
-      renderer.mockResolvedValueOnce('data:image/svg+xml,new')
-      await act(async () => { observer.intersect(target, true) })
-      expect(renderer).toHaveBeenCalledTimes(2)
-      expect(screen.getByRole('img')).toBe(image)
-      expect(screen.queryByRole('status')).toBeNull()
-      fireEvent.load(view.container.querySelector('img[src="data:image/svg+xml,new"]')!)
-      expect(screen.getByRole('img').getAttribute('src')).toBe('data:image/svg+xml,new')
-    } finally {
-      view.unmount()
-      document.documentElement.style.colorScheme = previous
-    }
-  })
-})

+ 1 - 1
packages/client/ui-primitives/tests/labels.client.ts

@@ -9,7 +9,7 @@ import type {
 } from '../src/index.ts'
 
 export const markdownLabels: MarkdownLabels = {
-  code: { copyLabel: '复制', copiedLabel: '复制成功', sourceLabel: '源码', lineNumbersLabel: '行号' },
+  code: { copyLabel: '复制', copiedLabel: '复制成功' },
   footnotes: 'Footnotes',
 }
 

+ 1 - 2
packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx

@@ -6,7 +6,6 @@
 // incremental fence-highlight decision, so `*.streaming.txt` fixtures with
 // fenced code pin shiki span trees where react-markdown had the plain arm.
 import { cleanup, render } from '@testing-library/react'
-import { StrictMode } from 'react'
 import { afterEach, describe, expect, it } from 'vitest'
 import { MarkdownText } from './markdown-test-components.tsx'
 
@@ -54,7 +53,7 @@ function serializeChildren(element: Element, indent: string, inPre: boolean): st
 }
 
 function renderCase(text: string, streaming: boolean): string {
-  const { container, unmount } = render(<StrictMode><MarkdownText text={text} streaming={streaming} /></StrictMode>)
+  const { container, unmount } = render(<MarkdownText text={text} streaming={streaming} />)
   const out = [...container.childNodes].map(child => serialize(child, '', false)).join('')
   unmount()
   return out

+ 4 - 4
packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx

@@ -96,13 +96,13 @@ describe('incremental streaming rendering', () => {
   it('drops the streaming cache when the copy labels change identity', () => {
     const doc = ['```ts', 'const a = 1', '```', '', 'p1', '', 'p2', '', 'p3'].join('\n')
     const live = render(
-      <MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Copy', copiedLabel: 'Copied', sourceLabel: 'Source', lineNumbersLabel: 'Line numbers' }} />,
+      <MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Copy', copiedLabel: 'Copied' }} />,
     )
-    expect([...live.container.querySelectorAll('button')].map(b => b.getAttribute('aria-label'))).toEqual(['Line numbers', 'Copy'])
+    expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Copy'])
     live.rerender(
-      <MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Kopieren', copiedLabel: 'Kopiert', sourceLabel: 'Quelle', lineNumbersLabel: 'Zeilennummern' }} />,
+      <MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Kopieren', copiedLabel: 'Kopiert' }} />,
     )
-    expect([...live.container.querySelectorAll('button')].map(b => b.getAttribute('aria-label'))).toEqual(['Zeilennummern', 'Kopieren'])
+    expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Kopieren'])
     live.unmount()
   })
 

+ 0 - 20
packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx

@@ -21,7 +21,6 @@ function makeContext(): MarkdownRenderContext {
   return {
     streaming: false,
     labels: markdownLabels,
-    previews: undefined,
     fileMentions: undefined,
     pathImages: undefined,
     targets: createReferenceTargets(),
@@ -225,25 +224,6 @@ describe('renderFootnoteSection edge shapes', () => {
 })
 
 describe('MarkdownText under StrictMode', () => {
-  it('renders each completed fence line once across stream growth and settlement', () => {
-    const chunks = [
-      'const first = 1\nlet',
-      'const first = 1\nlet second = 2\n// tail',
-      'const first = 1\nlet second = 2\n// tail complete',
-    ]
-    const view = render(<StrictMode><MarkdownText text="```ts\n" streaming /></StrictMode>)
-    for (const code of chunks) {
-      view.rerender(<StrictMode><MarkdownText text={`\`\`\`ts\n${code}`} streaming /></StrictMode>)
-      const pre = view.container.querySelector('pre.shiki')
-      expect(pre?.textContent).toBe(code)
-      expect(pre?.querySelectorAll('.line')).toHaveLength(code.split('\n').length)
-    }
-    const highlighted = view.container.querySelector('pre.shiki')
-    view.rerender(<StrictMode><MarkdownText text={`\`\`\`ts\n${chunks[2]}\n\`\`\``} /></StrictMode>)
-    expect(view.container.querySelector('pre.shiki')).toBe(highlighted)
-    expect(highlighted?.textContent).toBe(chunks[2])
-  })
-
   it('streams identically when React double-invokes render work', () => {
     const doc = 'one\n\ntwo\n\nthree\n\nfour\n\nfive'
     const strict = render(<StrictMode><MarkdownText text={doc.slice(0, 8)} streaming /></StrictMode>)

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

@@ -310,7 +310,7 @@ describe('MarkdownText', () => {
   })
 
   it('forwards localized labels to fenced code blocks', () => {
-    render(<MarkdownText text={'```ts\nconst answer = 42\n```'} codeLabels={{ copyLabel: 'Copy code', copiedLabel: 'Copied', sourceLabel: 'Source', lineNumbersLabel: 'Line numbers' }} />)
+    render(<MarkdownText text={'```ts\nconst answer = 42\n```'} codeLabels={{ copyLabel: 'Copy code', copiedLabel: 'Copied' }} />)
     expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy()
   })
 

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

@@ -1,152 +0,0 @@
-// @vitest-environment jsdom
-
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-
-const initialize = vi.fn()
-const renderDiagram = vi.fn()
-const getDiagramFromText = vi.fn()
-const importRuntime = vi.fn(() => ({ default: { initialize, render: renderDiagram, mermaidAPI: { getDiagramFromText } } }))
-
-beforeEach(() => {
-  vi.resetModules()
-  vi.resetAllMocks()
-  getDiagramFromText.mockResolvedValue({ type: 'sequence', db: {} })
-  vi.doMock('mermaid', importRuntime)
-})
-afterEach(() => { vi.doUnmock('mermaid') })
-
-describe('Mermaid runtime', () => {
-  it('rejects image nodes before layout and leaves the queue available', async () => {
-    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
-    getDiagramFromText.mockResolvedValueOnce({ type: 'flowchart-v2', db: {
-      getVertices: () => new Map([['A', { img: 'https://preview.invalid/image' }]]),
-    } })
-    await expect(renderMermaid('image', new AbortController().signal)).rejects.toThrow('image nodes')
-    expect(renderDiagram).not.toHaveBeenCalled()
-    getDiagramFromText.mockResolvedValueOnce({ type: 'flowchart-v2', db: {
-      getVertices: () => new Map([['A', {}]]),
-    } })
-    renderDiagram.mockResolvedValue({ svg: '<svg/>' })
-    await expect(renderMermaid('plain', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
-    expect(renderDiagram).toHaveBeenCalledOnce()
-  })
-
-  it('allows another attempt after the runtime import fails', async () => {
-    const importError = new Error('runtime unavailable')
-    importRuntime.mockImplementationOnce(() => { throw importError })
-    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
-    await expect(renderMermaid('first', new AbortController().signal)).rejects.toMatchObject({ cause: importError })
-    expect(initialize).not.toHaveBeenCalled()
-    vi.doMock('mermaid', importRuntime)
-    renderDiagram.mockResolvedValue({ svg: '<svg/>' })
-    await expect(renderMermaid('retry', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
-    expect(renderDiagram).toHaveBeenCalledOnce()
-  })
-
-  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 import the runtime for cancelled queued work', 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()
-    expect(importRuntime).not.toHaveBeenCalled()
-  })
-
-  it('skips layout after cancellation while loading the diagram parser', async () => {
-    const parsed = Promise.withResolvers<{ type: string; db: object }>()
-    getDiagramFromText.mockReturnValueOnce(parsed.promise)
-    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
-    const controller = new AbortController()
-    const result = renderMermaid('sequenceDiagram', controller.signal)
-    const rejection = expect(result).rejects.toMatchObject({ name: 'AbortError' })
-    await vi.waitFor(() => { expect(getDiagramFromText).toHaveBeenCalledOnce() })
-    controller.abort()
-    parsed.resolve({ type: 'sequence', db: {} })
-    await rejection
-    expect(renderDiagram).not.toHaveBeenCalled()
-    renderDiagram.mockResolvedValue({ svg: '<svg/>' })
-    await expect(renderMermaid('next', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
-  })
-
-  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)
-  })
-})

+ 0 - 176
packages/client/ui-primitives/tests/preview-lightbox.client.spec.tsx

@@ -1,176 +0,0 @@
-// @vitest-environment jsdom
-
-import { cleanup, fireEvent, render } from '@testing-library/react'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { PreviewLightbox } from '../src/markdown/PreviewLightbox.tsx'
-
-beforeEach(() => {
-  vi.stubGlobal('innerWidth', 1000)
-  vi.stubGlobal('innerHeight', 800)
-})
-afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
-
-const props = {
-  src: 'data:image/svg+xml;charset=utf-8,%3Csvg%2F%3E',
-  alt: 'Diagram preview',
-  closeLabel: 'Close preview',
-  interactionLabel: 'Drag or arrow keys to pan; scroll or +/- to zoom; double-click or Home to fit',
-}
-
-function fixture(width = 400, height = 200) {
-  const onClose = vi.fn()
-  const view = render(<PreviewLightbox {...props} onClose={onClose} />)
-  const image = view.getByRole('img') as HTMLImageElement
-  const surface = view.getByRole('dialog')
-  // jsdom has neither image decoding nor pointer capture; provide the browser-owned observations.
-  Object.defineProperty(image, 'naturalWidth', { configurable: true, value: width })
-  Object.defineProperty(image, 'naturalHeight', { configurable: true, value: height })
-  vi.spyOn(surface, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 1000, 800))
-  const capture = new Set<number>()
-  const release = vi.fn((id: number) => { capture.delete(id) })
-  Object.defineProperties(image, {
-    setPointerCapture: { value: (id: number) => { capture.add(id) } },
-    hasPointerCapture: { value: (id: number) => capture.has(id) },
-    releasePointerCapture: { value: release },
-  })
-  fireEvent.load(image)
-  function pointer(type: string, x: number, y: number, id = 1, button = 0, primary = true) {
-    const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, button })
-    Object.defineProperties(event, { pointerId: { value: id }, isPrimary: { value: primary } })
-    fireEvent(image, event)
-  }
-  return { ...view, image, surface, onClose, pointer, release, capture }
-}
-
-const fitted = 'translate(-50%, -50%) translate(0px, 0px) scale(1)'
-
-describe('PreviewLightbox', () => {
-  it('fits the image with viewport margins, closes and restores keyboard focus', () => {
-    const opener = document.createElement('button')
-    document.body.appendChild(opener)
-    try {
-      opener.focus()
-      const view = fixture()
-      expect(Number.parseFloat(view.image.style.width)).toBeCloseTo(880)
-      expect(Number.parseFloat(view.image.style.height)).toBeCloseTo(440)
-      expect(view.image.style.transform).toBe(fitted)
-      expect(view.surface.getAttribute('aria-describedby')).toBe(view.getByText(props.interactionLabel).id)
-      const close = view.getByRole('button', { name: props.closeLabel })
-      expect(document.activeElement).toBe(close)
-      expect(fireEvent.keyDown(window, { key: 'Tab' })).toBe(false)
-      expect(fireEvent.keyDown(window, { key: 'a' })).toBe(true)
-      expect(view.onClose).not.toHaveBeenCalled()
-      fireEvent.keyDown(window, { key: 'Escape' })
-      fireEvent.click(close)
-      expect(view.onClose).toHaveBeenCalledTimes(2)
-      view.unmount()
-      expect(document.activeElement).toBe(opener)
-    } finally { opener.remove() }
-  })
-
-  it('fits tall images by height and waits for intrinsic dimensions before fitting', () => {
-    const tall = fixture(200, 800)
-    expect(Number.parseFloat(tall.image.style.width)).toBeCloseTo(168)
-    expect(Number.parseFloat(tall.image.style.height)).toBeCloseTo(672)
-    tall.unmount()
-    const pending = fixture(400, 0)
-    expect(pending.image.style.width).toBe('')
-    Object.defineProperty(pending.image, 'naturalHeight', { value: 200 })
-    fireEvent.load(pending.image)
-    expect(Number.parseFloat(pending.image.style.width)).toBeCloseTo(880)
-  })
-
-  it('anchors wheel zoom at the cursor, normalizes wheel units and bounds magnification', () => {
-    const view = fixture()
-    expect(fireEvent.wheel(view.surface, { deltaY: -Math.log(2) / 0.002, clientX: 600, clientY: 450 })).toBe(false)
-    expect(view.image.style.transform).toBe('translate(-50%, -50%) translate(-100px, -50px) scale(2)')
-    fireEvent.wheel(view.surface, { deltaY: 1, deltaMode: 1, clientX: 600, clientY: 450 })
-    expect(view.image.style.transform).toContain(`scale(${2 * Math.exp(-16 * 0.002)})`)
-    fireEvent.wheel(view.surface, { deltaY: 10, deltaMode: 2 })
-    expect(view.image.style.transform).toContain('scale(0.25)')
-    fireEvent.wheel(view.surface, { deltaY: -10000 })
-    expect(view.image.style.transform).toContain('scale(8)')
-    expect(view.image.getAttribute('src')).toBe(props.src)
-    fireEvent.doubleClick(view.image)
-    expect(view.image.style.transform).toBe(fitted)
-  })
-
-  it('captures a primary drag and ignores other pointers and inactive movement', () => {
-    const view = fixture()
-    view.pointer('pointerdown', 500, 400, 1, 2)
-    view.pointer('pointerdown', 500, 400, 2, 0, false)
-    view.pointer('pointermove', 540, 420)
-    expect(view.capture.size).toBe(0)
-    expect(view.image.style.transform).toBe(fitted)
-    view.pointer('pointerdown', 500, 400)
-    expect(view.capture.has(1)).toBe(true)
-    expect(view.image.hasAttribute('data-dragging')).toBe(true)
-    view.pointer('pointermove', 540, 420, 2)
-    view.pointer('pointerup', 540, 420, 2)
-    expect(view.image.style.transform).toBe(fitted)
-    view.pointer('pointermove', 580, 440)
-    expect(view.image.style.transform).toBe('translate(-50%, -50%) translate(80px, 40px) scale(1)')
-    view.pointer('pointerup', 580, 440)
-    expect(view.release).toHaveBeenCalledWith(1)
-    expect(view.image.hasAttribute('data-dragging')).toBe(false)
-    view.pointer('pointermove', 900, 900)
-    expect(view.image.style.transform).toContain('translate(80px, 40px)')
-  })
-
-  it.each(['pointercancel', 'lostpointercapture'])('ends a gesture on %s and releases capture on unmount', (type) => {
-    const view = fixture()
-    view.pointer('pointerdown', 500, 400)
-    view.capture.clear()
-    view.pointer(type, 500, 400)
-    view.pointer('pointermove', 700, 500)
-    expect(view.image.style.transform).toBe(fitted)
-    view.pointer('pointerdown', 500, 400)
-    view.unmount()
-    expect(view.release).toHaveBeenCalledWith(1)
-    view.pointer('pointermove', 700, 500)
-    expect(view.image.style.transform).toBe(fitted)
-  })
-
-  it('supports keyboard pan, zoom and reset, and refits on viewport or source changes', () => {
-    const view = fixture()
-    for (const key of ['ArrowRight', 'ArrowDown']) fireEvent.keyDown(window, { key })
-    expect(view.image.style.transform).toContain('translate(32px, 32px)')
-    for (const key of ['ArrowLeft', 'ArrowUp', '+', '-']) fireEvent.keyDown(window, { key })
-    expect(view.image.style.transform).toBe(fitted)
-    fireEvent.keyDown(window, { key: '=' })
-    expect(view.image.style.transform).toContain('scale(1.2)')
-    fireEvent.keyDown(window, { key: '0' })
-    expect(view.image.style.transform).toBe(fitted)
-    fireEvent.keyDown(window, { key: '+' })
-    fireEvent.keyDown(window, { key: 'Home' })
-    expect(view.image.style.transform).toBe(fitted)
-    fireEvent.keyDown(window, { key: '+' })
-    vi.stubGlobal('innerWidth', 600)
-    fireEvent.resize(window)
-    expect(Number.parseFloat(view.image.style.width)).toBeCloseTo(528)
-    expect(view.image.style.transform).toBe(fitted)
-    fireEvent.keyDown(window, { key: '+' })
-    view.rerender(<PreviewLightbox {...props} src={`${props.src}#new`} onClose={view.onClose} />)
-    expect(view.image.style.transform).toBe(fitted)
-    view.unmount()
-    fireEvent.keyDown(window, { key: '+' })
-    fireEvent.wheel(view.surface, { deltaY: -100 })
-    expect(view.image.style.transform).toBe(fitted)
-  })
-
-  it('tolerates a focus owner it cannot restore', () => {
-    Object.defineProperty(document, 'activeElement', { configurable: true, get: () => null })
-    try {
-      const view = render(<PreviewLightbox {...props} onClose={vi.fn()} />)
-      view.unmount()
-    } finally { delete (document as { activeElement?: unknown }).activeElement }
-  })
-
-  it('closes on a mask press but not on a press over the image', () => {
-    const view = fixture()
-    fireEvent.mouseDown(view.image)
-    expect(view.onClose).not.toHaveBeenCalled()
-    fireEvent.mouseDown(view.surface.querySelector('[aria-hidden="true"]')!)
-    expect(view.onClose).toHaveBeenCalledOnce()
-  })
-})

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

@@ -1,477 +0,0 @@
-// @vitest-environment jsdom
-
-import { act, cleanup, fireEvent, render, screen, waitFor, within } 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 * as highlight from '../src/markdown/highlight.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', error: 'Unable to render this diagram.',
-  preview: 'Preview', source: 'Source', zoom: 'Enlarge preview', pending: 'Preparing preview…', close: 'Close preview', interaction: 'Drag to pan, scroll to zoom',
-}
-const preview = {
-  preview: labels.preview, source: labels.source, zoom: labels.zoom, pending: labels.pending,
-  close: labels.close, interaction: labels.interaction,
-  mermaid: labels, graphviz: labels, svg: labels,
-}
-const source = 'flowchart LR\n  A[Input] --> B[Preview]'
-async function loadPreviewImage() {
-  const image = await waitFor(() => {
-    const node = document.querySelector<HTMLImageElement>('[data-code-block-preview] img, img')
-    expect(node).not.toBeNull()
-    return node!
-  })
-  fireEvent.load(image)
-  return image
-}
-
-const imageUrl = 'data:image/svg+xml;charset=utf-8,%3Csvg%2F%3E'
-
-afterEach(() => {
-  cleanup()
-  document.querySelector('[data-test-preview-actions]')?.remove()
-  vi.restoreAllMocks()
-  vi.unstubAllGlobals()
-})
-beforeEach(() => { vi.resetAllMocks() })
-
-describe('SourcePreview', () => {
-  it('keeps a loaded preview and its lightbox while a theme replacement renders and loads', async () => {
-    const replacement = Promise.withResolvers<string>()
-    vi.mocked(renderMermaid).mockResolvedValueOnce(imageUrl).mockReturnValueOnce(replacement.promise)
-    const actions = document.createElement('div')
-    actions.dataset['testPreviewActions'] = ''
-    document.body.append(actions)
-    const previous = document.documentElement.style.colorScheme
-    const view = render(<SourcePreview code={source} render={renderMermaid} labels={labels} actions={actions} />)
-    try {
-      const image = await loadPreviewImage()
-      fireEvent.click(screen.getByRole('button', { name: labels.zoom }))
-      const dialog = screen.getByRole('dialog')
-      await act(async () => { document.documentElement.style.colorScheme = 'dark' })
-      expect(view.container.querySelector('img')).toBe(image)
-      expect(screen.getByRole('dialog')).toBe(dialog)
-      await act(async () => { replacement.resolve('data:image/svg+xml,new') })
-      expect(screen.queryByRole('status')).toBeNull()
-      expect(within(dialog).getByRole('img').getAttribute('src')).toBe(imageUrl)
-      expect(view.container.querySelector('[data-preview-placeholder]')).toBeNull()
-      fireEvent.load(view.container.querySelector('img[src="data:image/svg+xml,new"]')!)
-      expect(screen.getByRole('dialog')).toBe(dialog)
-      expect(within(dialog).getByRole('img').getAttribute('src')).toBe('data:image/svg+xml,new')
-    } finally {
-      view.unmount()
-      document.documentElement.style.colorScheme = previous
-    }
-  })
-
-  it.each(['render', 'load'] as const)('retains the usable image and announces a failed theme %s', async (failure) => {
-    vi.mocked(renderMermaid).mockResolvedValueOnce(imageUrl)
-    const previous = document.documentElement.style.colorScheme
-    const view = render(<SourcePreview code={source} render={renderMermaid} labels={labels} />)
-    try {
-      const image = await loadPreviewImage()
-      if (failure === 'render') vi.mocked(renderMermaid).mockRejectedValueOnce(new Error('layout failed'))
-      else vi.mocked(renderMermaid).mockResolvedValueOnce('data:image/svg+xml,broken')
-      await act(async () => { document.documentElement.style.colorScheme = 'dark' })
-      if (failure === 'load') fireEvent.error(view.container.querySelector('img[src="data:image/svg+xml,broken"]')!)
-      expect(screen.getByRole('status').textContent).toBe(labels.error)
-      expect(screen.getByRole('img')).toBe(image)
-      expect(view.container.querySelector('[data-preview-placeholder]')).toBeNull()
-    } finally {
-      view.unmount()
-      document.documentElement.style.colorScheme = previous
-    }
-  })
-
-  it('reports failure from a replacement renderer without restoring the old renderer image', async () => {
-    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
-    const view = render(<SourcePreview code={source} render={renderMermaid} labels={labels} />)
-    await loadPreviewImage()
-    const replacement = vi.fn(async () => { throw new Error('unavailable') })
-    view.rerender(<SourcePreview code={source} render={replacement} labels={labels} />)
-    expect((await screen.findByText(labels.error)).getAttribute('role')).toBe('status')
-    expect(screen.queryByRole('img')).toBeNull()
-  })
-
-  it.each(['load', 'error'] as const)('ignores a queued image %s event after another event settled the candidate', async (event) => {
-    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
-    const view = render(<SourcePreview code={source} render={renderMermaid} labels={labels} />)
-    const image = await waitFor(() => {
-      expect(view.container.querySelector('img')).not.toBeNull()
-      return view.container.querySelector('img')!
-    })
-    act(() => {
-      fireEvent.load(image)
-      fireEvent[event](image)
-    })
-    expect(screen.getByRole('img')).toBe(image)
-    expect(screen.queryByRole('status')).toBeNull()
-  })
-
-  it('retains a loaded image when a theme render returns the same URL', async () => {
-    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
-    const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
-    const previous = document.documentElement.style.colorScheme
-    try {
-      const image = await loadPreviewImage()
-      Object.defineProperty(image, 'complete', { value: true })
-      Object.defineProperty(image, 'naturalWidth', { value: 120 })
-      await act(async () => { document.documentElement.style.colorScheme = 'dark' })
-      expect(renderMermaid).toHaveBeenCalledTimes(2)
-      expect(screen.queryByRole('status')).toBeNull()
-      expect(screen.getByRole('img')).toBe(image)
-    } finally {
-      view.unmount()
-      document.documentElement.style.colorScheme = previous
-    }
-  })
-
-  it('keeps the image placeholder until load and reports an image load failure', async () => {
-    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
-    const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
-    await waitFor(() => { expect(view.container.querySelector('img')).not.toBeNull() })
-    expect(screen.getByRole('status').textContent).toBe(labels.pending)
-    expect(screen.queryByRole('img')).toBeNull()
-    fireEvent.error(view.container.querySelector('img')!)
-    expect(screen.getByRole('status').textContent).toBe(labels.error)
-    expect(view.container.querySelector('[data-preview-placeholder]')).toBeNull()
-  })
-
-  it('keeps one placeholder through appended and frozen fences without rendering or highlighting source', async () => {
-    const html = vi.spyOn(highlight, 'highlightToHtml')
-    const tokens = vi.spyOn(highlight.StreamingHighlightSession.prototype, 'update')
-    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
-    const props = { labels: { ...markdownLabels, preview } }
-    const view = render(<MarkdownText {...props} text="```mermaid\n" streaming />)
-    const placeholder = view.container.querySelector('[data-preview-placeholder]')
-    expect(placeholder).not.toBeNull()
-    for (const text of [
-      '```mermaid\nflowchart LR\n',
-      `\`\`\`mermaid\n${source}`,
-      `\`\`\`mermaid\n${source}\n\`\`\`\n\nOne\n\nTwo\n\nThree`,
-    ]) {
-      view.rerender(<MarkdownText {...props} text={text} streaming />)
-      expect(view.container.querySelector('[data-preview-placeholder]')).toBe(placeholder)
-      expect(view.container.querySelector('pre')).toBeNull()
-      expect(view.container.querySelector('.md-code-block button')).toBeNull()
-    }
-    expect(renderMermaid).not.toHaveBeenCalled()
-    expect(html).not.toHaveBeenCalled()
-    expect(tokens).not.toHaveBeenCalled()
-    view.rerender(<MarkdownText {...props} text={`\`\`\`mermaid\n${source}`} />)
-    await loadPreviewImage()
-    expect(renderMermaid).toHaveBeenCalledTimes(1)
-    expect(html).not.toHaveBeenCalled()
-    expect(tokens).not.toHaveBeenCalled()
-  })
-
-  it('shows source if the owner removes preview support while preview is selected', async () => {
-    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
-    const props = { code: source, lang: 'mermaid', ...markdownLabels.code }
-    const view = render(<CodeBlock {...props} preview={{ render: renderMermaid, labels }} />)
-    await loadPreviewImage()
-    view.rerender(<CodeBlock {...props} />)
-    expect(view.container.querySelector('pre code')?.textContent).toBe(source)
-    expect(screen.queryByRole('img')).toBeNull()
-  })
-
-  it('mounts source on demand and retains it across view changes', async () => {
-    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
-    const props = { code: source, lang: 'mermaid', ...markdownLabels.code, preview: { render: renderMermaid, labels } }
-    const view = render(<CodeBlock {...props} />)
-    const image = await loadPreviewImage()
-    const sourceView = view.container.querySelector<HTMLElement>('[data-code-block-source-view]')!
-    expect(view.container.querySelector('pre')).toBeNull()
-    expect(sourceView.getAttribute('aria-hidden')).toBe('true')
-    expect(view.container.querySelector('[data-code-block-preview]')?.hasAttribute('hidden')).toBe(false)
-    expect(screen.queryByRole('button', { name: 'Reset size' })).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: labels.source }))
-    expect(sourceView.hasAttribute('aria-hidden')).toBe(false)
-    const sourceElement = view.container.querySelector('pre')
-    expect(sourceElement).not.toBeNull()
-    expect(view.container.querySelector('[data-code-block-preview]')?.hasAttribute('hidden')).toBe(true)
-    expect(screen.queryByRole('img')).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: labels.preview }))
-    expect(sourceView.getAttribute('aria-hidden')).toBe('true')
-    expect(screen.getByRole('img', { name: labels.diagram })).toBe(image)
-    expect(view.container.querySelector('pre')).toBe(sourceElement)
-    expect(renderMermaid).toHaveBeenCalledTimes(1)
-    view.rerender(<CodeBlock {...props} streaming />)
-    expect(view.container.querySelector('[data-code-block-source-view]')).toBeNull()
-    expect(screen.getByRole('status').textContent).toBe(labels.pending)
-    view.rerender(<CodeBlock {...props} preview={undefined} />)
-    expect(view.container.querySelector('pre code')?.textContent).toBe(source)
-  })
-
-  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 loadPreviewImage()
-      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('announces pending rendering and enables the persistent zoom action when the image is ready', async () => {
-    const pending = Promise.withResolvers<string>()
-    vi.mocked(renderMermaid).mockReturnValue(pending.promise)
-    const actions = document.createElement('div')
-    actions.dataset['testPreviewActions'] = ''
-    document.body.append(actions)
-    const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} actions={actions} />)
-    const zoom = screen.getByRole<HTMLButtonElement>('button', { name: labels.zoom })
-    expect(screen.getByRole('status').textContent).toBe(labels.pending)
-    expect(zoom.disabled).toBe(true)
-    expect(document.getElementById(zoom.getAttribute('aria-describedby')!)?.textContent).toBe(labels.pending)
-    fireEvent.click(zoom)
-    expect(screen.queryByRole('dialog')).toBeNull()
-    expect(screen.queryByRole('img')).toBeNull()
-    await act(async () => { pending.resolve(imageUrl) })
-    await loadPreviewImage()
-    expect(screen.getByRole('img', { name: labels.diagram }).getAttribute('src')).toBe(imageUrl)
-    expect(view.container.querySelector(`img[alt="${labels.diagram}"]`)).not.toBeNull()
-    expect(view.container.querySelector('[data-preview-placeholder]')).toBeNull()
-    expect(screen.queryByRole('status')).toBeNull()
-    expect(screen.getByRole('button', { name: labels.zoom })).toBe(zoom)
-    expect(zoom.disabled).toBe(false)
-    expect(zoom.hasAttribute('aria-describedby')).toBe(false)
-  })
-
-  it('opens the rendered image in the viewport dialog and restores focus on close', async () => {
-    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
-    const actions = document.createElement('div')
-    actions.dataset['testPreviewActions'] = ''
-    document.body.append(actions)
-    render(<SourcePreview render={renderMermaid} code={source} labels={labels} actions={actions} />)
-    await loadPreviewImage()
-    const zoom = screen.getByRole('button', { name: labels.zoom })
-    zoom.focus()
-    fireEvent.click(zoom)
-    const dialog = screen.getByRole('dialog', { name: labels.diagram })
-    expect(within(dialog).getByRole('img').getAttribute('src')).toBe(imageUrl)
-    const close = within(dialog).getByRole('button', { name: labels.close })
-    expect(document.activeElement).toBe(close)
-    fireEvent.keyDown(window, { key: 'Escape' })
-    expect(screen.queryByRole('dialog', { name: labels.diagram })).toBeNull()
-    expect(document.activeElement).toBe(zoom)
-  })
-
-  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')).toBeNull()
-    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.pending)
-    expect(view.container.querySelector('pre')).toBeNull()
-    await act(async () => { next.resolve(imageUrl) })
-    await loadPreviewImage()
-    expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
-  })
-
-  it('waits for a replacement renderer when the source is unchanged', async () => {
-    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
-    const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
-    expect((await loadPreviewImage()).getAttribute('src')).toBe(imageUrl)
-    const pending = Promise.withResolvers<string>()
-    const replacement = vi.fn(() => pending.promise)
-    view.rerender(<SourcePreview render={replacement} code={source} labels={labels} />)
-    expect(screen.queryByRole('img')).toBeNull()
-    await act(async () => { pending.resolve('data:image/svg+xml;charset=utf-8,replacement') })
-    await loadPreviewImage()
-    expect(screen.getByRole('img').getAttribute('src')).toContain('replacement')
-  })
-
-  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 loadPreviewImage()
-    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 rendering', 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('defaults to preview, then retains numbered source and the same image across view changes', async () => {
-    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
-    const view = render(<CodeBlock code={source} lang="mermaid" lineNumbers {...markdownLabels.code}
-      preview={{ render: renderMermaid, labels }} />)
-    expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
-    const sourceView = view.container.querySelector<HTMLElement>('[data-code-block-source-view]')!
-    expect(view.container.querySelector('pre')).toBeNull()
-    expect(sourceView.getAttribute('aria-hidden')).toBe('true')
-    expect(screen.getByRole('button', { name: labels.preview }).getAttribute('aria-pressed')).toBe('true')
-    const diagram = await loadPreviewImage()
-    fireEvent.click(screen.getByRole('button', { name: labels.source }))
-    await waitFor(() => { expect(view.container.querySelector('pre.shiki')).not.toBeNull() })
-    const sourceElement = view.container.querySelector('pre')
-    expect([...view.container.querySelectorAll('code > .line')].map(line => line.textContent)).toEqual(source.split('\n'))
-    expect(screen.getByRole('button', { name: labels.source }).getAttribute('aria-pressed')).toBe('true')
-    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(sourceView.getAttribute('aria-hidden')).toBe('true')
-    fireEvent.click(screen.getByRole('button', { name: labels.source }))
-    expect(view.container.querySelector('pre')).toBe(sourceElement)
-    expect(sourceView.hasAttribute('aria-hidden')).toBe(false)
-    expect(renderMermaid).toHaveBeenCalledTimes(1)
-  })
-
-  it('shows a streaming placeholder, then selects preview and copies source from an icon action', 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')).toBeNull()
-    expect(screen.getByRole('status').textContent).toBe(labels.pending)
-    expect(renderMermaid).not.toHaveBeenCalled()
-    expect(screen.queryByRole('button', { name: labels.source })).toBeNull()
-    expect(screen.queryByRole('button', { name: labels.zoom })).toBeNull()
-    view.rerender(<MarkdownText {...props} />)
-    expect(view.container.querySelector('pre')).toBeNull()
-    expect(view.container.querySelector('[data-code-block-source-view]')?.getAttribute('aria-hidden')).toBe('true')
-    const diagram = await loadPreviewImage()
-    expect(screen.getByText('mermaid', { exact: true })).toBeDefined()
-    expect(screen.getByRole('button', { name: labels.source }).textContent).toBe(labels.source)
-    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.getByText('mermaid', { exact: true })).toBeDefined()
-    fireEvent.click(screen.getByRole('button', { name: labels.preview }))
-    expect(screen.getByRole('img', { name: labels.diagram })).toBe(diagram)
-    expect(screen.queryByRole('status')).toBeNull()
-    expect(renderMermaid).toHaveBeenCalledTimes(1)
-  })
-
-  it('returns to the placeholder when a preview starts streaming again', async () => {
-    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
-    const props = { code: source, lang: 'mermaid', ...markdownLabels.code, preview: { render: renderMermaid, labels } }
-    const view = render(<CodeBlock {...props} />)
-    await loadPreviewImage()
-    view.rerender(<CodeBlock {...props} code={`${source}\n B --> C`} streaming />)
-    expect(view.container.querySelector('pre')).toBeNull()
-    expect(screen.getByRole('status').textContent).toBe(labels.pending)
-    expect(renderMermaid).toHaveBeenCalledTimes(1)
-    view.rerender(<CodeBlock {...props} code={`${source}\n B --> C`} />)
-    await loadPreviewImage()
-    expect(renderMermaid).toHaveBeenCalledTimes(2)
-  })
-
-  it('replaces source zoom with a persistent line-number choice without regenerating the preview', async () => {
-    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
-    const view = render(<CodeBlock code={source} lang="mermaid" {...markdownLabels.code}
-      preview={{ render: renderMermaid, labels }} />)
-    const diagram = await loadPreviewImage()
-    fireEvent.click(screen.getByRole('button', { name: labels.source }))
-    const block = view.container.querySelector<HTMLElement>('.md-code-block')!
-    expect(screen.queryByRole('button', { name: labels.zoom })).toBeNull()
-    const numbers = screen.getByRole('button', { name: markdownLabels.code.lineNumbersLabel })
-    const firstLine = view.container.querySelector('pre .line')
-    expect(numbers.getAttribute('aria-pressed')).toBe('false')
-    fireEvent.click(numbers)
-    expect(numbers.getAttribute('aria-pressed')).toBe('true')
-    expect(block.dataset['lineNumbers']).toBe('true')
-    expect(view.container.querySelector('pre .line')).toBe(firstLine)
-    fireEvent.click(screen.getByRole('button', { name: labels.preview }))
-    expect(screen.queryByRole('button', { name: markdownLabels.code.lineNumbersLabel })).toBeNull()
-    const zoom = screen.getByRole<HTMLButtonElement>('button', { name: labels.zoom })
-    expect(zoom.disabled).toBe(false)
-    zoom.focus()
-    fireEvent.click(zoom)
-    const dialog = screen.getByRole('dialog', { name: labels.diagram })
-    expect(within(dialog).getByRole('img').getAttribute('src')).toBe(diagram.getAttribute('src'))
-    fireEvent.click(within(dialog).getByRole('button', { name: labels.close }))
-    expect(screen.queryByRole('dialog')).toBeNull()
-    expect(document.activeElement).toBe(zoom)
-    fireEvent.click(screen.getByRole('button', { name: labels.source }))
-    expect(screen.getByRole('button', { name: markdownLabels.code.lineNumbersLabel }).getAttribute('aria-pressed')).toBe('true')
-    fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.lineNumbersLabel }))
-    expect(block.hasAttribute('data-line-numbers')).toBe(false)
-    expect(renderMermaid).toHaveBeenCalledTimes(1)
-  })
-
-  it('keeps pending work through a source visit and retains a failure without retrying on toggles', async () => {
-    const pending = Promise.withResolvers<string>()
-    vi.mocked(renderMermaid).mockReturnValue(pending.promise)
-    const view = render(<CodeBlock code={source} lang="mermaid" {...markdownLabels.code}
-      preview={{ render: renderMermaid, labels }} />)
-    const signal = vi.mocked(renderMermaid).mock.calls[0]![1]
-    const zoom = screen.getByRole<HTMLButtonElement>('button', { name: labels.zoom })
-    fireEvent.click(screen.getByRole('button', { name: labels.source }))
-    expect(view.container.querySelector('pre code')?.textContent).toBe(source)
-    expect(signal.aborted).toBe(false)
-    expect(screen.queryByRole('button', { name: labels.zoom })).toBeNull()
-    expect(zoom.disabled).toBe(true)
-    expect(document.getElementById(zoom.getAttribute('aria-describedby')!)?.textContent).toBe(labels.pending)
-    await act(async () => { pending.reject(new Error('Parse error')) })
-    expect(zoom.disabled).toBe(true)
-    expect(document.getElementById(zoom.getAttribute('aria-describedby')!)?.textContent).toBe(labels.error)
-    fireEvent.click(screen.getByRole('button', { name: labels.preview }))
-    expect(screen.getByRole('status').textContent).toBe(labels.error)
-    expect(view.container.querySelector('[data-code-block-preview] pre')).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: labels.source }))
-    fireEvent.click(screen.getByRole('button', { name: labels.preview }))
-    expect(renderMermaid).toHaveBeenCalledTimes(1)
-    expect(screen.getByRole('status').textContent).toBe(labels.error)
-  })
-
-  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()
-  })
-})

+ 0 - 2
packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.tsx

@@ -28,8 +28,6 @@ export function CodeBody({ resourceAddress, content, wrap, scrollportRef, t }: C
         lineNumbers
         copyLabel={t('copy')}
         copiedLabel={t('copied')}
-        sourceLabel={t('markdown.code.source')}
-        lineNumbersLabel={t('markdown.code.lineNumbers')}
       />
     </div>
   )

+ 2 - 4
packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx

@@ -18,12 +18,10 @@ export type MarkdownBodyProps = DocumentPreviewProps & PropsLocale<'documentMark
 export function MarkdownBody({ content, t }: MarkdownBodyProps): ReactNode {
   const copyLabel = t('code.copy')
   const copiedLabel = t('code.copied')
-  const sourceLabel = t('markdown.code.source')
-  const lineNumbersLabel = t('markdown.code.lineNumbers')
   const footnotes = t('footnotes')
   const labels = useMemo<MarkdownLabels>(() => ({
-    code: { copyLabel, copiedLabel, sourceLabel, lineNumbersLabel }, footnotes,
-  }), [copyLabel, copiedLabel, sourceLabel, lineNumbersLabel, footnotes])
+    code: { copyLabel, copiedLabel }, footnotes,
+  }), [copyLabel, copiedLabel, footnotes])
   if (content.kind !== 'text') return null
   return (
     <div className={css.document} data-document-markdown>

+ 1 - 1
packages/client/ui-tool/src/client/tool/components/ToolRow.tsx

@@ -293,7 +293,7 @@ export function ToolRow({
                           <>
                             {variant === 'code' && bodyText !== null && (
                               <div className={css.bodyScroll}>
-                                <CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} sourceLabel={t('markdown.code.source')} lineNumbersLabel={t('markdown.code.lineNumbers')} className={css.codeBody} />
+                                <CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
                               </div>
                             )}
                             {(cardBody !== null || outputText !== null) && (

+ 1 - 1
packages/client/ui-tool/src/client/tool/models/primitive-labels.ts

@@ -18,7 +18,7 @@ type T = TranslateNS<'conversation'>
  */
 export function markdownLabels(t: T): MarkdownLabels {
   return {
-    code: { copyLabel: t('copy'), copiedLabel: t('copied'), sourceLabel: t('markdown.code.source'), lineNumbersLabel: t('markdown.code.lineNumbers') },
+    code: { copyLabel: t('copy'), copiedLabel: t('copied') },
     footnotes: t('markdown.footnotes'),
   }
 }

+ 2 - 3
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -248,7 +248,7 @@ function jsonTreeLabels(t: TrajectoryTranslate): JsonTreeLabels {
 
 function markdownLabels(t: TrajectoryTranslate): MarkdownLabels {
   return {
-    code: { copyLabel: t('copy'), copiedLabel: t('copied'), sourceLabel: t('markdown.code.source'), lineNumbersLabel: t('markdown.code.lineNumbers') },
+    code: { copyLabel: t('copy'), copiedLabel: t('copied') },
     footnotes: t('markdown.footnotes'),
   }
 }
@@ -1908,8 +1908,7 @@ function ProgramInput({ program, initialWrapped, stringWrapping, onOpen, t }: {
         ? <JsonTree data={program.arguments} label={t('record.parametersJson')}
           labels={jsonTreeLabels(t)} stringWrapping={stringWrapping} />
         : <CodeBlock code={program.source} lang={program.language} lineNumbers showHeader={false}
-          className={css.programSource} copyLabel={t('code.copySource')} copiedLabel={t('copied')}
-          sourceLabel={t('markdown.code.source')} lineNumbersLabel={t('markdown.code.lineNumbers')} />}
+          className={css.programSource} copyLabel={t('code.copySource')} copiedLabel={t('copied')} />}
     </div>
   )
   return onOpen === undefined

+ 4 - 2
packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx

@@ -1,4 +1,3 @@
-import { createMarkdownLabels } from './markdown-labels.ts'
 import { useMemo, useState } from 'react'
 import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PendingQuestion, PlanReview, QuestionComposerProps } from './contract/slots.ts'
@@ -26,7 +25,10 @@ function tooltip(description: string | undefined): { title?: string } {
  * @returns The plan-review takeover for this request.
  */
 export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
-  const markdownLabels = useMemo(() => createMarkdownLabels(t), [t])
+  const markdownLabels = useMemo(() => ({
+    code: { copyLabel: t('copy'), copiedLabel: t('copied') },
+    footnotes: t('markdown.footnotes'),
+  }), [t])
   // The panel waits for the host's resolved frame before leaving, so repeated
   // clicks must not resubmit. A failed send re-enables it and shows the error.
   const [busy, setBusy] = useState(false)

+ 4 - 2
packages/client/ui-user-questions/src/client/QuestionComposer.tsx

@@ -1,4 +1,3 @@
-import { createMarkdownLabels } from './markdown-labels.ts'
 import { useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
 import clsx from 'clsx'
 import {
@@ -132,7 +131,10 @@ type QuestionFlowProps =
 
 function QuestionFlow({ pending, t, useStore, actions }: QuestionFlowProps) {
   const questions = pending.questions
-  const markdownLabels = useMemo(() => createMarkdownLabels(t), [t])
+  const markdownLabels = useMemo(() => ({
+    code: { copyLabel: t('copy'), copiedLabel: t('copied') },
+    footnotes: t('markdown.footnotes'),
+  }), [t])
   const initialProgress = useMemo<QuestionDraftProgress>(() => ({
     index: 0,
     drafts: questions.map(() => ({ selected: [], custom: '', skipped: false })),

+ 0 - 18
packages/client/ui-user-questions/src/client/markdown-labels.ts

@@ -1,18 +0,0 @@
-/** Localized Markdown controls shared by question and plan-review bodies. */
-import type { MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { QuestionComposerProps } from './contract/slots.ts'
-
-/**
- * Localize source controls and footnotes for the question domain.
- * @param t - The question composer's locale translator.
- * @returns Labels accepted by the shared Markdown renderer.
- */
-export function createMarkdownLabels(t: QuestionComposerProps['t']): MarkdownLabels {
-  return {
-    code: {
-      copyLabel: t('copy'), copiedLabel: t('copied'), sourceLabel: t('markdown.code.source'),
-      lineNumbersLabel: t('markdown.code.lineNumbers'),
-    },
-    footnotes: t('markdown.footnotes'),
-  }
-}

+ 0 - 2
packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx

@@ -146,8 +146,6 @@ export function CordisDefineRow({
                   lang="javascript"
                   copyLabel={t('body.copy')}
                   copiedLabel={t('body.copied')}
-                  sourceLabel={t('markdown.code.source')}
-                  lineNumbersLabel={t('markdown.code.lineNumbers')}
                   className={css.sourceCode}
                 />
               </div>

+ 0 - 11
pnpm-lock.yaml

@@ -3758,9 +3758,6 @@ 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
@@ -3782,9 +3779,6 @@ 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
@@ -15927,9 +15921,6 @@ 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]
@@ -22779,8 +22770,6 @@ 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
 

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

@@ -206,16 +206,3 @@ it('requires Office skill bodies and helpers in the published payload', () => {
     ...manifest, files: ['lib/index.js', 'lib/types/**/*.d.ts'],
   } })).toEqual([expect.stringContaining('package.json files must be')])
 })
-
-it.each(['lib/*-*.js', 'THIRD_PARTY_PREVIEW_NOTICES.txt'])('requires %s in the published UI primitives payload', (required) => {
-  const name = '@deepseek-ai/dsh-client-ui-primitives'
-  const files = expectedDshPackageFiles({ name })
-  expect(files).toContain(required)
-  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 !== required))).toEqual([
-    expect.stringContaining('package.json files must be'),
-  ])
-})

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

@@ -155,8 +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.
-  // Lazy grammar and diagram imports resolve hashed runtime chunks beside index.js.
-  '@deepseek-ai/dsh-client-ui-primitives': ['lib/*-*.js', 'lib/**/*.css', 'THIRD_PARTY_PREVIEW_NOTICES.txt'],
+  '@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css'],
   '@deepseek-ai/dsh-client-ui-dockkit': ['lib/**/*.css'],
   '@deepseek-ai/dsh-client-ui-sidebar-documentpreview': ['lib/client.*.js'],
   '@deepseek-ai/dsh-client-ui-sidebar-terminal': ['lib/client.*.js'],

+ 1 - 66
scripts/gen-third-party-notices.spec.ts

@@ -1,11 +1,10 @@
-import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { join, resolve } from 'node:path'
 import { tmpdir } from 'node:os'
 import { describe, expect, it } from 'vitest'
 import desktopRuntimeLock from '../apps/desktop/scripts/primary-runtime-lock.json' with { type: 'json' }
 import {
   CLAUDE_AGENT_SDK_PACKAGE,
-  assertPreviewDistribution,
   assertRuntimeLicenses,
   claudeDistributionFromManifest,
   collectPythonDependencies,
@@ -14,7 +13,6 @@ import {
   isPermissive,
   type Manifest,
   manifestPatterns,
-  mermaidDomPurifyVersion,
   parsePyprojectRequirements,
   parseVendoredRows,
   render,
@@ -469,66 +467,3 @@ 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('checks Mermaid\'s selected DOMPurify when other versions remain installed', () => {
-    const fixture = mkdtempSync(join(tmpdir(), 'dsh-notices-mermaid-'))
-    try {
-      const modules = join(fixture, 'node_modules')
-      const store = join(modules, '.pnpm')
-      const mermaidModules = join(store, 'mermaid@11.16.0', 'node_modules')
-      const mermaid = join(mermaidModules, 'mermaid')
-      mkdirSync(mermaid, { recursive: true })
-      for (const version of ['3.4.11', '3.4.12']) {
-        const dir = join(store, `dompurify@${version}`, 'node_modules', 'dompurify')
-        mkdirSync(dir, { recursive: true })
-        writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dompurify', version }))
-      }
-      const reviewed = join(store, 'dompurify@3.4.11', 'node_modules', 'dompurify')
-      const upgraded = join(store, 'dompurify@3.4.12', 'node_modules', 'dompurify')
-      const linkedMermaid = join(modules, 'mermaid')
-      const dependency = join(mermaidModules, 'dompurify')
-      symlinkSync(mermaid, linkedMermaid, 'junction')
-      symlinkSync(reviewed, join(modules, 'dompurify'), 'junction')
-      symlinkSync(upgraded, dependency, 'junction')
-
-      const version = mermaidDomPurifyVersion(linkedMermaid)
-      expect(version).toBe('3.4.12')
-      expect(() => { assertPreviewDistribution({ ...dependencies, dompurify: version }, sources) })
-        .toThrow('review dompurify 3.4.12')
-
-      unlinkSync(dependency)
-      symlinkSync(reviewed, dependency, 'junction')
-      expect(() => {
-        assertPreviewDistribution({ ...dependencies, dompurify: mermaidDomPurifyVersion(linkedMermaid) }, sources)
-      }).not.toThrow()
-
-      writeFileSync(join(reviewed, 'package.json'), JSON.stringify({ name: 'dompurify' }))
-      expect(() => mermaidDomPurifyVersion(linkedMermaid)).toThrow('missing Mermaid DOMPurify dependency')
-      unlinkSync(dependency)
-      expect(() => mermaidDomPurifyVersion(linkedMermaid)).toThrow('ENOENT')
-    } finally {
-      rmSync(fixture, { recursive: true, force: true })
-    }
-  })
-  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('===== DOMPurify =====')
-  })
-  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')
-  })
-})

+ 1 - 50
scripts/gen-third-party-notices.ts

@@ -8,7 +8,7 @@
  * `.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md`.
  */
 
-import { existsSync, globSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
+import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
 import { dirname, resolve } from 'node:path'
 import * as yaml from 'js-yaml'
 import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml'
@@ -737,40 +737,6 @@ ${rows.join('\n')}
 `
 }
 
-/**
- * Read the DOMPurify version selected by Mermaid's pnpm dependency link.
- * @param mermaidDirectory - Installed Mermaid directory, including workspace symlinks.
- * @returns The installed version; throws when the dependency or its version is missing.
- */
-export function mermaidDomPurifyVersion(mermaidDirectory: string): string {
-  const path = resolve(realpathSync(mermaidDirectory), '..', 'dompurify/package.json')
-  const manifest = JSON.parse(readFileSync(path, 'utf8')) as Manifest
-  if (manifest.version === undefined) throw new Error('preview notices: missing Mermaid DOMPurify dependency')
-  return manifest.version
-}
-
-/**
- * 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 attestation.
- * @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.
@@ -781,19 +747,6 @@ 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'
-  // Viz.js fixes the upstream SLSA statement filename; the repository uses concrete local names.
-  const statementPath = resolve(root, previewPackage, 'node_modules/@viz-js/viz/lib', 'prove' + 'nance.json')
-  const buildStatement = JSON.parse(readFileSync(statementPath, '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')
-  const dompurify = mermaidDomPurifyVersion(resolve(root, previewPackage, 'node_modules/mermaid'))
-  assertPreviewDistribution(
-    { ...previewDependencies, dompurify },
-    buildStatement.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)
@@ -837,8 +790,6 @@ 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 its DOMPurify dependency'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')}

+ 0 - 12
snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md

@@ -85,13 +85,9 @@
   - text: — 随机选中的包:
   - code: packages/context/session-reference
   - text: 📦
-- button "Line numbers"
 - button "Copy"
-- text: Source
 - code: session-reference ├── README.md ├── package.json ├── src │ ├── index.ts │ └── uri.ts └── tests └── session-reference.spec.ts
-- button "Line numbers"
 - button "Copy"
-- text: Source
 - code: "session-reference/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ ├── index.ts # 入口,导出公共 API │ └── uri.ts # 会话引用 URI 相关实现 └── tests/ └── session-reference.spec.ts # 单元测试"
 - paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。
 - button "Copy":
@@ -146,13 +142,9 @@
   - text: — 随机选中的包:
   - code: packages/llm/token-meter
   - text: 📦
-- button "Line numbers"
 - button "Copy"
-- text: Source
 - code: token-meter ├── README.md ├── package.json ├── src │ └── index.ts └── tests └── token-meter.spec.ts
-- button "Line numbers"
 - button "Copy"
-- text: Source
 - code: "token-meter/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ └── index.ts # 入口,导出公共 API(token 计量逻辑) └── tests/ └── token-meter.spec.ts # 单元测试"
 - paragraph:
   - text: 两个 turn 都完成了(turn 1:
@@ -182,18 +174,14 @@
     - text: — 随机选中
     - code: packages/context/session-reference
     - text: ,输出了文件结构:
-    - button "Line numbers"
     - button "Copy"
-    - text: Source
     - code: session-reference/ ├── README.md ├── package.json ├── src/ │ ├── index.ts │ └── uri.ts └── tests/ └── session-reference.spec.ts
   - listitem:
     - strong: Turn 2
     - text: — 随机选中
     - code: packages/llm/token-meter
     - text: ,输出了文件结构:
-    - button "Line numbers"
     - button "Copy"
-    - text: Source
     - code: token-meter/ ├── README.md ├── package.json ├── src/ │ └── index.ts └── tests/ └── token-meter.spec.ts
 - paragraph:
   - strong: 验证方式:

+ 0 - 8
snapshots/web/goal-multi-turn-actions/ui.expected.md

@@ -30,13 +30,9 @@
   - text: — 随机选中的包:
   - code: packages/context/session-reference
   - text: 📦
-- button "Line numbers"
 - button "Copy"
-- text: Source
 - code: session-reference ├── README.md ├── package.json ├── src │ ├── index.ts │ └── uri.ts └── tests └── session-reference.spec.ts
-- button "Line numbers"
 - button "Copy"
-- text: Source
 - code: "session-reference/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ ├── index.ts # 入口,导出公共 API │ └── uri.ts # 会话引用 URI 相关实现 └── tests/ └── session-reference.spec.ts # 单元测试"
 - paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。
 - button "Copy":
@@ -67,18 +63,14 @@
     - text: — 随机选中
     - code: packages/context/session-reference
     - text: ,输出了文件结构:
-    - button "Line numbers"
     - button "Copy"
-    - text: Source
     - code: session-reference/ ├── README.md ├── package.json ├── src/ │ ├── index.ts │ └── uri.ts └── tests/ └── session-reference.spec.ts
   - listitem:
     - strong: Turn 2
     - text: — 随机选中
     - code: packages/llm/token-meter
     - text: ,输出了文件结构:
-    - button "Line numbers"
     - button "Copy"
-    - text: Source
     - code: token-meter/ ├── README.md ├── package.json ├── src/ │ └── index.ts └── tests/ └── token-meter.spec.ts
 - paragraph:
   - strong: 验证方式:

+ 0 - 2
tsconfig.host.json

@@ -89,7 +89,6 @@
     "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",
@@ -126,7 +125,6 @@
     "apps/web/tests/chat-continuous-conversation.e2e.ts",
     "apps/web/tests/composer-tab-geometry.e2e.ts",
     "apps/web/tests/complex-history.perf.ts",
-    "apps/web/tests/diagram-preview.perf.ts",
     "apps/web/tests/pwsh-terminal.e2e.ts",
     "apps/web/tests/workflow-run.e2e.ts",
     "apps/web/stress-tests/reasoning-chunks.stress.ts",

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů