From b0ec8cef32eef78d18091270534ff02d0cf0a343 Mon Sep 17 00:00:00 2001 From: gengruilin Date: Mon, 14 Sep 2026 12:25:23 +0800 Subject: [PATCH 1/4] feat(docs): add fullscreen Mermaid diagram viewer --- .../2026-09-14-docs-mermaid-viewer.i18n.yaml | 6 + .../feature/2026-09-14-docs-mermaid-viewer.md | 31 +++ .../2026-09-14-docs-mermaid-viewer.zh.md | 31 +++ THIRD_PARTY_NOTICES.md | 2 + package.json | 2 +- pnpm-lock.yaml | 11 + scripts/run-gates.ts | 5 +- vitest.config.ts | 1 + website/.vitepress/theme/css.d.ts | 2 + website/.vitepress/theme/index.ts | 29 +++ website/.vitepress/theme/mermaid-viewer.css | 91 ++++++++ website/.vitepress/theme/mermaid-viewer.ts | 219 ++++++++++++++++++ website/AGENTS.md | 2 + website/package.json | 4 +- website/tests/mermaid-viewer.spec.ts | 208 +++++++++++++++++ 15 files changed, 641 insertions(+), 3 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md create mode 100644 .agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md create mode 100644 website/.vitepress/theme/css.d.ts create mode 100644 website/.vitepress/theme/index.ts create mode 100644 website/.vitepress/theme/mermaid-viewer.css create mode 100644 website/.vitepress/theme/mermaid-viewer.ts create mode 100644 website/tests/mermaid-viewer.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml new file mode 100644 index 0000000000..fa5dc063a8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md +2026-09-14-docs-mermaid-viewer.md: fa51cf527e126e21ba46c72d980dcfc28c2d16e6 +2026-09-14-docs-mermaid-viewer.zh.md: 9e50cabe524877c0aea6c734c1cb613c99ea9e02 diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md new file mode 100644 index 0000000000..fa51cf527e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md @@ -0,0 +1,31 @@ +# Agent Note: Documentation Mermaid viewer + +Status: implemented + +English | [中文](2026-09-14-docs-mermaid-viewer.zh.md) + +## Problem + +Complex Mermaid diagrams lose readable detail when scaled to the documentation column. Long sequence diagrams need both magnification and movement to inspect interactions while retaining an overview. + +## Decision + +The [VitePress theme](../../../../website/.vitepress/theme/index.ts) adds a fullscreen entry above each rendered Mermaid SVG. A native modal dialog provides an inert background and Escape dismissal; the toolbar cycles keyboard focus through zoom, fit, and close controls. Panzoom supplies pointer, wheel, and pinch interaction. Arrow keys pan in fixed screen distances. Closing restores the entry's focus without scrolling and restores the page's previous overflow setting. + +The viewer copies the SVG into a shadow root. Mermaid's embedded selectors and fragment IDs stay local to the copy, so its markers and styles cannot resolve against the original diagram. The natural viewBox dimensions determine the canvas size; the initial scale and every resize fit the entire diagram without enlarging it beyond its natural size. This preserves vector detail while keeping fit independent of the narrow document column. + +Viewer resources belong to the mounted theme. Route, language, theme, and source-SVG replacement close the active view; asynchronous Mermaid renders receive a fresh entry. The scroll lock belongs on the body because the Mermaid plugin observes every attribute change on the HTML element and rerenders in response. The [implementation](../../../../website/.vitepress/theme/mermaid-viewer.ts) leaves Markdown, raw page copies, and `llms.txt` generation with their existing owners. + +## Alternatives considered + +**Widening the document column.** A wider column cannot provide readable detail for arbitrarily large diagrams, and long diagrams still exceed the viewport. + +**A custom overlay with document-wide listeners.** A native dialog already makes the background inert and handles modal dismissal. Theme-owned resources make navigation and teardown explicit; persistent document listeners would require a separate lifetime mechanism. + +**A bitmap preview or a same-document SVG clone.** A bitmap loses vector detail at high zoom. A same-document clone duplicates Mermaid's IDs and embedded styles; rewriting all SVG and CSS references would add a parser obligation that a shadow root avoids. + +## Consequences + +The website gains Panzoom as a direct dependency and uses native dialog, shadow-root, and resize-observer support. Viewer zoom and position are transient: resizing refits the diagram, and navigating or changing the theme closes it. Existing page diagrams remain the reading and link-navigation source. + +The [focused tests](../../../../website/tests/mermaid-viewer.spec.ts) cover late rendering, fit dimensions, control wiring, keyboard cycling, replacement, and resource release through `docs:check` and `doc-sync`. Real-browser verification remains necessary for native modal behavior, SVG marker rendering, drag and wheel transforms, themes, and narrow viewports; DOM-only tests do not establish those browser behaviors. diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md new file mode 100644 index 0000000000..9e50cabe52 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 文档站 Mermaid 查看器 + +Status: implemented + +[English](2026-09-14-docs-mermaid-viewer.md) | 中文 + +## 问题 + +复杂 Mermaid 图表缩放到文档正文列宽后,细节难以辨认。阅读长时序图需要在保留全图概览的同时放大和平移,以检查交互细节。 + +## 决策 + +[VitePress 主题](../../../../website/.vitepress/theme/index.ts) 在每张已渲染的 Mermaid SVG 上方增加全屏入口。原生模态对话框使背景不可交互,并支持 Escape 退出;工具栏将键盘焦点循环限定在缩放、适应窗口和关闭控件之间。Panzoom 提供指针、滚轮和双指交互。方向键按固定的屏幕距离平移。关闭时恢复入口焦点且不滚动页面,并恢复页面原有的 overflow 设置。 + +查看器将 SVG 复制到 shadow root 中。Mermaid 内嵌的选择器和片段 ID 限定在副本内部,因此副本的标记和样式不会解析到原始图表上。viewBox 的自然尺寸决定画布大小;初始缩放以及每次窗口尺寸变化都会适配整张图表,且不会放大到超出自然尺寸。这样既保留矢量细节,也使适配不受文档窄列宽度的影响。 + +查看器资源由已挂载的主题持有。路由、语言、主题和源 SVG 替换都会关闭当前视图;异步渲染的 Mermaid 图表会获得新的入口。滚动锁设置在 body 上,因为 Mermaid 插件会观察 HTML 元素的每次属性变化并重新渲染。[实现](../../../../website/.vitepress/theme/mermaid-viewer.ts) 将 Markdown、原始页面副本和 `llms.txt` 的生成保留在原有归属处。 + +## 考虑过的替代方案 + +**加宽文档正文列。** 更宽的正文列无法让任意大小图表的细节都清晰可读,长图仍然会超出视口。 + +**使用自定义遮罩和文档级监听器。** 原生对话框已经能使背景不可交互,并处理模态退出。由主题持有资源让导航和资源清理的职责明确;持久的文档级监听器则需要单独的生命周期机制。 + +**位图预览或同文档内的 SVG 副本。** 位图在高倍率缩放下会丢失矢量细节。同文档内的副本会重复 Mermaid 的 ID 和内嵌样式;重写全部 SVG 和 CSS 引用会带来额外的解析职责,而 shadow root 可以避免这项职责。 + +## 影响 + +文档站增加了 Panzoom 直接依赖,并使用原生 dialog、shadow root 和 ResizeObserver 支持。查看器的缩放和平移状态是临时的:窗口尺寸变化会重新适配图表,导航或主题变化会关闭视图。原有页面图表仍然是阅读和链接导航的来源。 + +[定向测试](../../../../website/tests/mermaid-viewer.spec.ts) 通过 `docs:check` 和 `doc-sync`(文档同步门禁)覆盖延迟渲染、适配尺寸、控件连接、键盘焦点循环、图表替换和资源释放。原生模态行为、SVG 标记渲染、拖动和滚轮变换、主题以及窄视口仍需要真实浏览器验证;仅运行 DOM 测试不能证明这些浏览器行为。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 00227af565..dc6a185d5c 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -151,6 +151,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | | [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | +| [`@panzoom/panzoom`](https://github.com/timmywil/panzoom) | MIT | | [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT | | [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | | [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | @@ -211,6 +212,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`vitepress`](https://github.com/vuejs/vitepress) | MIT | | [`vitepress-plugin-mermaid`](https://github.com/emersonbottero/vitepress-plugin-mermaid) | MIT | | [`vitest`](https://github.com/vitest-dev/vitest) | MIT | +| [`vue`](https://github.com/vuejs/core) | MIT | `eslint-plugin-sonarjs` (LGPL-3.0-only) and `lightningcss` (MPL-2.0) run only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact. diff --git a/package.json b/package.json index 86c7300b25..68d80297e6 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments", "docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments", "docs:preview": "pnpm --filter @deepseek-ai/website run preview", - "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts && pnpm run docs:build", + "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts && pnpm run docs:build", "website:dev": "pnpm run docs:dev", "website:build": "pnpm run docs:build", "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a62faa067..2f33ee76ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11971,6 +11971,9 @@ importers: '@braintree/sanitize-url': specifier: 7.1.2 version: 7.1.2 + '@panzoom/panzoom': + specifier: 4.6.2 + version: 4.6.2 cytoscape: specifier: 3.34.0 version: 3.34.0 @@ -11995,6 +11998,9 @@ importers: vitepress-plugin-mermaid: specifier: ^2.0.17 version: 2.0.17(mermaid@11.16.0)(vitepress@1.6.4(@algolia/client-search@5.55.2)(@types/node@25.9.3)(@types/react@18.3.31)(lightningcss@1.32.0)(postcss@8.5.15)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@6.0.3)) + vue: + specifier: 3.5.39 + version: 3.5.39(typescript@6.0.3) packages: @@ -14002,6 +14008,9 @@ packages: cpu: [x64] os: [win32] + '@panzoom/panzoom@4.6.2': + resolution: {integrity: sha512-Zn3B5/hwa6eYIPRSKX0xf2clv8nviTX8AnAU5kU/EugiTDhG41ya2wlBqYrZJYCWQROr/5XkWObZhIkepi89qw==} + '@peculiar/asn1-schema@2.9.4': resolution: {integrity: sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==} engines: {node: '>=14'} @@ -20680,6 +20689,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.76.0': optional: true + '@panzoom/panzoom@4.6.2': {} + '@peculiar/asn1-schema@2.9.4': dependencies: '@peculiar/utils': 2.0.3 diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index a8c0b4e027..7f876cd53c 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -769,7 +769,10 @@ function docSyncLeafGates(options: { label: 'documentation standard tests', quick: true, }), - pnpmExec('docs-site-projection', ['vitest', 'run', 'scripts/project-doc-site.spec.ts', 'scripts/verify-doc-site-fragments.spec.ts'], { + pnpmExec('docs-site-projection', [ + 'vitest', 'run', 'scripts/project-doc-site.spec.ts', 'scripts/verify-doc-site-fragments.spec.ts', + 'website/tests/mermaid-viewer.spec.ts', + ], { label: 'documentation site checks', }), pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations', quick: true }), diff --git a/vitest.config.ts b/vitest.config.ts index cd6752424c..6dcb040917 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -124,6 +124,7 @@ const testIncludes = [ 'packages/*/*/tests/**/*.spec.{ts,tsx}', 'apps/*/tests/**/*.spec.ts', 'scripts/**/*.spec.ts', + 'website/tests/**/*.spec.ts', ] // The instrumented coverage gate sets this env; the exempt heavy suites then diff --git a/website/.vitepress/theme/css.d.ts b/website/.vitepress/theme/css.d.ts new file mode 100644 index 0000000000..080be4b4e2 --- /dev/null +++ b/website/.vitepress/theme/css.d.ts @@ -0,0 +1,2 @@ +/** Documentation styles are loaded by Vite. */ +declare module '*.css' diff --git a/website/.vitepress/theme/index.ts b/website/.vitepress/theme/index.ts new file mode 100644 index 0000000000..10f67bbdca --- /dev/null +++ b/website/.vitepress/theme/index.ts @@ -0,0 +1,29 @@ +/** Default documentation theme with a client-only Mermaid viewer. */ +import DefaultTheme from 'vitepress/theme' +import { useData, useRoute, type Theme } from 'vitepress' +import { defineComponent, h, onBeforeUnmount, onMounted, watch } from 'vue' +import type { MermaidViewer } from './mermaid-viewer.ts' +import './mermaid-viewer.css' + +export default { + extends: DefaultTheme, + Layout: defineComponent({ + name: 'DocsLayout', + setup() { + const { lang, isDark } = useData() + const route = useRoute() + let viewer: MermaidViewer | undefined + let disposed = false + onMounted(async () => { + const { installMermaidViewer } = await import('./mermaid-viewer.ts') + if (!disposed) viewer = installMermaidViewer(document, () => lang.value) + }) + watch([() => route.path, lang, isDark], () => viewer?.refresh(), { flush: 'post' }) + onBeforeUnmount(() => { + disposed = true + viewer?.dispose() + }) + return () => h(DefaultTheme.Layout) + }, + }), +} satisfies Theme diff --git a/website/.vitepress/theme/mermaid-viewer.css b/website/.vitepress/theme/mermaid-viewer.css new file mode 100644 index 0000000000..48eb91beee --- /dev/null +++ b/website/.vitepress/theme/mermaid-viewer.css @@ -0,0 +1,91 @@ +.vp-doc .dsh-diagram-open, +.dsh-diagram-toolbar button { + min-height: 44px; + padding: 6px 14px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + color: var(--vp-c-text-1); + background: var(--vp-c-bg-soft); + font: inherit; + font-size: 14px; + cursor: pointer; +} + +.vp-doc .dsh-diagram-open { + display: block; + margin: 8px 0 16px auto; +} + +.dsh-diagram-open:hover, +.dsh-diagram-toolbar button:hover { + border-color: var(--vp-c-brand-1); + color: var(--vp-c-brand-1); +} + +.dsh-diagram-open:focus-visible, +.dsh-diagram-toolbar button:focus-visible { + outline: 2px solid var(--vp-c-brand-1); + outline-offset: 2px; +} + +.dsh-diagram-viewer { + position: fixed; + inset: 0; + width: 100%; + height: 100dvh; + max-width: none; + max-height: none; + margin: 0; + padding: 16px; + border: 0; + color: var(--vp-c-text-1); + background: var(--vp-c-bg); + box-sizing: border-box; +} + +.dsh-diagram-viewer[open] { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dsh-diagram-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + flex: none; +} + +.dsh-diagram-toolbar strong { + margin-right: auto; +} + +.dsh-diagram-help { + margin: 0; + color: var(--vp-c-text-2); + font-size: 13px; +} + +.dsh-diagram-viewport { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + overscroll-behavior: contain; +} + +.dsh-diagram-paper { + position: absolute; + left: 50%; + top: 50%; +} + +@media (max-width: 600px) { + .dsh-diagram-viewer { padding: 8px; } + .dsh-diagram-toolbar { align-items: stretch; } + .dsh-diagram-toolbar strong { width: 100%; } + .dsh-diagram-toolbar button { flex: 1; padding-inline: 8px; } +} diff --git a/website/.vitepress/theme/mermaid-viewer.ts b/website/.vitepress/theme/mermaid-viewer.ts new file mode 100644 index 0000000000..246cde775c --- /dev/null +++ b/website/.vitepress/theme/mermaid-viewer.ts @@ -0,0 +1,219 @@ +/** Full-viewport viewing of asynchronously rendered documentation diagrams. */ +import Panzoom from '@panzoom/panzoom' + +const messages = { + en: { + open: 'View diagram fullscreen', title: 'Diagram viewer', + zoomIn: 'Zoom in', zoomOut: 'Zoom out', fit: 'Fit view', close: 'Close', + help: 'Scroll or pinch to zoom · Drag or use arrow keys to pan · Esc to close', + }, + zh: { + open: '全屏查看图表', title: '图表查看器', + zoomIn: '放大', zoomOut: '缩小', fit: '适应窗口', close: '关闭', + help: '滚轮或双指缩放 · 拖动或方向键平移 · Esc 关闭', + }, +} satisfies Record> + +/** Theme-owned viewer resources; refresh closes the current view before rescanning. */ +export interface MermaidViewer { + /** Close the view and update entries after a route, language, or theme change. */ + refresh(): void + /** Remove entries, observers, listeners, the dialog, and the page scroll lock. */ + dispose(): void +} + +function button(doc: Document, label: string): HTMLButtonElement { + const element = doc.createElement('button') + element.type = 'button' + element.textContent = label + return element +} + +function dimensions(svg: SVGSVGElement): { width: number; height: number } | undefined { + const { width, height } = svg.viewBox.baseVal + if (Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0) { + return { width, height } + } + return undefined +} + +function openDiagram( + svg: SVGSVGElement, trigger: HTMLButtonElement, copy: typeof messages.en, onClose: () => void, +): () => void { + const size = dimensions(svg) + if (!size) return () => {} + const doc = svg.ownerDocument + const dialog = doc.createElement('dialog') + dialog.className = 'dsh-diagram-viewer' + dialog.setAttribute('aria-label', copy.title) + const toolbar = doc.createElement('div') + toolbar.className = 'dsh-diagram-toolbar' + const title = doc.createElement('strong') + title.textContent = copy.title + const zoomOut = button(doc, copy.zoomOut) + const zoomIn = button(doc, copy.zoomIn) + const fit = button(doc, copy.fit) + const close = button(doc, copy.close) + close.autofocus = true + toolbar.append(title, zoomOut, zoomIn, fit, close) + const help = doc.createElement('p') + help.className = 'dsh-diagram-help' + help.textContent = copy.help + const viewport = doc.createElement('div') + viewport.className = 'dsh-diagram-viewport' + const paper = doc.createElement('div') + paper.className = 'dsh-diagram-paper' + Object.assign(paper.style, { + width: `${size.width}px`, height: `${size.height}px`, + marginLeft: `${-size.width / 2}px`, marginTop: `${-size.height / 2}px`, + }) + // Each Mermaid SVG embeds ID-scoped styles and fragment references. A shadow root + // keeps the enlarged copy's IDs and styles separate from the original diagram. + const shadow = paper.attachShadow({ mode: 'open' }) + const clone = svg.cloneNode(true) as SVGSVGElement + Object.assign(clone.style, { width: '100%', height: '100%', maxWidth: 'none', display: 'block' }) + shadow.append(clone) + viewport.append(paper) + dialog.append(toolbar, help, viewport) + doc.body.append(dialog) + + const overflow = doc.body.style.overflow + const listeners = new AbortController() + let panzoom: ReturnType | undefined + let resize: ResizeObserver | undefined + let closed = false + function cleanup(): void { + if (closed) return + closed = true + listeners.abort() + resize?.disconnect() + panzoom?.destroy() + dialog.close() + dialog.remove() + doc.body.style.overflow = overflow + if (trigger.isConnected) trigger.focus({ preventScroll: true }) + onClose() + } + try { + dialog.showModal() + doc.body.style.overflow = 'hidden' + const fitScale = (): number => Math.min( + 1, Math.max(1, viewport.clientWidth - 32) / size.width, + Math.max(1, viewport.clientHeight - 32) / size.height, + ) + const controller = Panzoom(paper, { + canvas: true, startScale: fitScale(), minScale: fitScale() / 2, + maxScale: 8, animate: false, pinchAndPan: true, + }) + panzoom = controller + const refit = (): void => { + const scale = fitScale() + controller.setOptions({ minScale: scale / 2 }) + controller.zoom(scale, { animate: false }) + controller.pan(0, 0, { animate: false }) + } + const options = { signal: listeners.signal } + close.addEventListener('click', cleanup, options) + dialog.addEventListener('close', cleanup, options) + dialog.addEventListener('cancel', (event) => { + event.preventDefault() + cleanup() + }, options) + zoomIn.addEventListener('click', () => controller.zoomIn({ animate: false }), options) + zoomOut.addEventListener('click', () => controller.zoomOut({ animate: false }), options) + fit.addEventListener('click', refit, options) + viewport.addEventListener('wheel', event => controller.zoomWithWheel(event), { ...options, passive: false }) + dialog.addEventListener('keydown', (event) => { + if (event.key === 'Tab') { + const controls = [zoomOut, zoomIn, fit, close] + const current = controls.indexOf(doc.activeElement as HTMLButtonElement) + const next = current < 0 ? (event.shiftKey ? controls.length - 1 : 0) + : (current + (event.shiftKey ? -1 : 1) + controls.length) % controls.length + event.preventDefault() + controls[next]?.focus() + return + } + const distance = 64 / controller.getScale() + const offsets: Record = { + ArrowLeft: [distance, 0], ArrowRight: [-distance, 0], + ArrowUp: [0, distance], ArrowDown: [0, -distance], + } + const offset = offsets[event.key] + if (offset && !event.altKey && !event.ctrlKey && !event.metaKey) { + event.preventDefault() + controller.pan(...offset, { relative: true, animate: false }) + } + }, options) + resize = new ResizeObserver(refit) + resize.observe(viewport) + return cleanup + } catch (error) { + cleanup() + throw error + } +} + +/** + * Enhance rendered Mermaid SVGs without modifying the canonical Markdown or renderer. + * @param doc Browser document containing VitePress content. + * @param language Current VitePress language, read again when entries refresh. + * @returns Resources owned by the mounted theme. + */ +export function installMermaidViewer(doc: Document, language: () => string): MermaidViewer { + const entries = new Map() + let active: SVGSVGElement | undefined + let close: (() => void) | undefined + const closeActive = (): void => { + close?.() + close = undefined + active = undefined + } + const scan = (): void => { + const copy = language().startsWith('zh') ? messages.zh : messages.en + const containers = new Set(doc.querySelectorAll('.vp-doc .mermaid')) + for (const [container, entry] of entries) { + if (!containers.has(container) || container.querySelector('svg') !== entry.svg || !entry.button.isConnected) { + if (entry.svg === active) closeActive() + entry.button.remove() + entries.delete(container) + } + } + for (const container of containers) { + const existing = entries.get(container) + if (existing) { + if (existing.button.textContent !== copy.open) existing.button.textContent = copy.open + continue + } + const svg = container.querySelector('svg') + if (!svg || !dimensions(svg)) continue + const trigger = button(doc, copy.open) + trigger.className = 'dsh-diagram-open' + trigger.setAttribute('aria-haspopup', 'dialog') + trigger.addEventListener('click', () => { + closeActive() + close = openDiagram(svg, trigger, language().startsWith('zh') ? messages.zh : messages.en, () => { + close = undefined + active = undefined + }) + active = svg + }) + container.prepend(trigger) + entries.set(container, { svg, button: trigger }) + } + } + const observer = new MutationObserver(scan) + observer.observe(doc.querySelector('#VPContent') ?? doc.body, { childList: true, subtree: true }) + scan() + return { + refresh() { + closeActive() + scan() + }, + dispose() { + observer.disconnect() + closeActive() + for (const entry of entries.values()) entry.button.remove() + entries.clear() + }, + } +} diff --git a/website/AGENTS.md b/website/AGENTS.md index fce0b95e93..da755ec3c2 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -15,3 +15,5 @@ Production builds remove the configured output directory after VitePress resolve The build also emits each route's raw-Markdown twin (with a parent-level alias per index route) and a root `llms.txt` index into `.dist/`, so a page's URL, minus any trailing slash, plus `.md` serves it as plain Markdown. Both derive from the publication manifest at build time; neither is ever a file in this tree. Run `pnpm docs:check` after changing this subtree; the gate rejects additional non-ignored Markdown under `website/`. + +The default-theme extension owns the Mermaid fullscreen viewer. Keep its enhancements separate from Markdown projection and preserve the original SVG. Route, language, theme, and rendered-SVG changes close the active view; theme disposal releases every observer, listener, and scroll lock. The [viewer decision](../.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md) explains SVG isolation and verification. diff --git a/website/package.json b/website/package.json index 3040893a55..4e7ea25e8f 100644 --- a/website/package.json +++ b/website/package.json @@ -10,6 +10,7 @@ }, "devDependencies": { "@braintree/sanitize-url": "7.1.2", + "@panzoom/panzoom": "4.6.2", "cytoscape": "3.34.0", "cytoscape-cose-bilkent": "4.1.0", "dayjs": "1.11.21", @@ -17,6 +18,7 @@ "mermaid": "11.16.0", "vite": "^5.4.14", "vitepress": "^1.6.4", - "vitepress-plugin-mermaid": "^2.0.17" + "vitepress-plugin-mermaid": "^2.0.17", + "vue": "3.5.39" } } diff --git a/website/tests/mermaid-viewer.spec.ts b/website/tests/mermaid-viewer.spec.ts new file mode 100644 index 0000000000..32bc392065 --- /dev/null +++ b/website/tests/mermaid-viewer.spec.ts @@ -0,0 +1,208 @@ +/** @vitest-environment jsdom */ +/** Viewer ownership and interaction tests; browser checks cover native modal focus and SVG layout. */ +import assert from 'node:assert/strict' +import type { PanzoomOptions } from '@panzoom/panzoom' +import { getByRole, queryAllByRole, queryByRole, waitFor } from '@testing-library/dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installMermaidViewer, type MermaidViewer } from '../.vitepress/theme/mermaid-viewer.ts' + +const panzoom = vi.hoisted(() => ({ + create: vi.fn<(element: HTMLElement, options: PanzoomOptions) => unknown>(), + destroy: vi.fn(), setOptions: vi.fn(), zoom: vi.fn(), pan: vi.fn(), + zoomIn: vi.fn(), zoomOut: vi.fn(), zoomWithWheel: vi.fn(), getScale: () => 2, +})) +vi.mock('@panzoom/panzoom', () => ({ default: panzoom.create })) + +let viewer: MermaidViewer | undefined +let resize: (() => void) | undefined +let viewportWidth = 1032 +let viewportHeight = 632 +const disconnect = vi.fn() +const dialogMethods = new Map(['showModal', 'close'].map(name => [ + name, Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, name), +])) + +beforeEach(() => { + vi.clearAllMocks() + panzoom.create.mockReturnValue(panzoom) + viewportWidth = 1032 + viewportHeight = 632 + document.body.innerHTML = '
' + document.body.style.overflow = 'auto' + vi.spyOn(SVGSVGElement.prototype, 'viewBox', 'get').mockImplementation(function (this: SVGSVGElement) { + const [x, y, width, height] = (this.getAttribute('viewBox') ?? '0 0 0 0').split(' ').map(Number) + return { baseVal: { x, y, width, height } } as SVGAnimatedRect + }) + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => viewportWidth) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => viewportHeight) + Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { configurable: true, value: function (this: HTMLDialogElement) { + this.open = true + this.querySelector('[autofocus]')?.focus() + } }) + Object.defineProperty(HTMLDialogElement.prototype, 'close', { configurable: true, value: function (this: HTMLDialogElement) { + this.open = false + this.dispatchEvent(new Event('close')) + } }) + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { resize = callback } + observe(): void {} + disconnect = disconnect + }) +}) + +afterEach(() => { + viewer?.dispose() + viewer = undefined + resize = undefined + document.body.replaceChildren() + document.body.style.overflow = '' + vi.restoreAllMocks() + vi.unstubAllGlobals() + for (const [name, descriptor] of dialogMethods) { + if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, name, descriptor) + else Reflect.deleteProperty(HTMLDialogElement.prototype, name) + } +}) + +function required(value: T | null | undefined): T { + assert(value != null) + return value +} + +function render(viewBox = '0 0 2000 3000'): SVGSVGElement { + const container = required(document.querySelector('.mermaid')) + container.innerHTML = `` + return required(container.querySelector('svg')) +} + +function open(): HTMLDialogElement { + getByRole(document.body, 'button', { name: 'View diagram fullscreen' }).click() + return getByRole(document.body, 'dialog', { name: 'Diagram viewer' }) as HTMLDialogElement +} + +describe('documentation Mermaid viewer', () => { + it('adds one entry only after an SVG with usable dimensions renders', async () => { + viewer = installMermaidViewer(document, () => 'en-US') + expect(queryAllByRole(document.body, 'button')).toHaveLength(0) + render('0 0 0 3000') + viewer.refresh() + expect(queryAllByRole(document.body, 'button')).toHaveLength(0) + render() + await waitFor(() => { + expect(queryAllByRole(document.body, 'button')).toHaveLength(1) + }) + viewer.refresh() + expect(queryAllByRole(document.body, 'button')).toHaveLength(1) + expect(required(document.querySelector('.mermaid')).firstElementChild?.tagName).toBe('BUTTON') + }) + + it('fits the natural SVG into the viewport and isolates copied styles and fragment IDs', () => { + const source = render() + const original = source.outerHTML + viewer = installMermaidViewer(document, () => 'en-US') + const dialog = open() + expect(panzoom.create.mock.calls[0]?.[1]).toMatchObject({ startScale: 0.2, minScale: 0.1 }) + const paper = required(dialog.querySelector('.dsh-diagram-paper')) + expect(paper.style.width).toBe('2000px') + expect(paper.style.height).toBe('3000px') + expect(paper.shadowRoot?.querySelector('path')?.getAttribute('marker-end')).toBe('url(#arrow)') + expect(document.querySelectorAll('#diagram')).toHaveLength(1) + expect(source.outerHTML).toBe(original) + expect(document.body.style.overflow).toBe('hidden') + expect(document.documentElement.style.overflow).toBe('') + viewportWidth = 332 + viewportHeight = 932 + required(resize)() + expect(panzoom.zoom).toHaveBeenLastCalledWith(0.15, { animate: false }) + expect(panzoom.pan).toHaveBeenLastCalledWith(0, 0, { animate: false }) + }) + + it('connects zoom, wheel, keyboard panning and fit without dismissing a canvas click', () => { + render() + viewer = installMermaidViewer(document, () => 'en-US') + const dialog = open() + getByRole(dialog, 'button', { name: 'Zoom in' }).click() + getByRole(dialog, 'button', { name: 'Zoom out' }).click() + expect(panzoom.zoomIn).toHaveBeenCalledOnce() + expect(panzoom.zoomOut).toHaveBeenCalledOnce() + const viewport = required(dialog.querySelector('.dsh-diagram-viewport')) + const wheel = new WheelEvent('wheel', { deltaY: -100 }) + viewport.dispatchEvent(wheel) + expect(panzoom.zoomWithWheel).toHaveBeenCalledWith(wheel) + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) + expect(panzoom.pan).toHaveBeenLastCalledWith(-32, 0, { relative: true, animate: false }) + getByRole(dialog, 'button', { name: 'Fit view' }).click() + expect(panzoom.zoom).toHaveBeenLastCalledWith(0.2, { animate: false }) + expect(panzoom.pan).toHaveBeenLastCalledWith(0, 0, { animate: false }) + viewport.click() + expect(dialog.open).toBe(true) + }) + + it('wraps keyboard focus through the viewer controls in both directions', () => { + render() + viewer = installMermaidViewer(document, () => 'en-US') + const dialog = open() + const close = getByRole(dialog, 'button', { name: 'Close' }) + const zoomOut = getByRole(dialog, 'button', { name: 'Zoom out' }) + close.focus() + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', cancelable: true })) + expect(document.activeElement).toBe(zoomOut) + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true })) + expect(document.activeElement).toBe(close) + }) + + it.each(['button', 'cancel', 'refresh', 'dispose'] as const)('releases resources on %s and restores focus and scrolling', (method) => { + render() + viewer = installMermaidViewer(document, () => 'en-US') + const trigger = getByRole(document.body, 'button') + const dialog = open() + const zoom = getByRole(dialog, 'button', { name: 'Zoom in' }) + if (method === 'button') getByRole(dialog, 'button', { name: 'Close' }).click() + else if (method === 'cancel') dialog.dispatchEvent(new Event('cancel', { cancelable: true })) + else viewer[method]() + expect(queryByRole(document.body, 'dialog')).toBeNull() + expect(document.body.style.overflow).toBe('auto') + expect(panzoom.destroy).toHaveBeenCalledOnce() + expect(disconnect).toHaveBeenCalledOnce() + zoom.click() + expect(panzoom.zoomIn).not.toHaveBeenCalled() + if (method !== 'dispose') expect(document.activeElement).toBe(trigger) + viewer.dispose() + expect(panzoom.destroy).toHaveBeenCalledOnce() + }) + + it('closes a replaced diagram and permits repeated opens with the updated locale', async () => { + render() + let locale = 'en-US' + viewer = installMermaidViewer(document, () => locale) + open() + render('0 0 400 200') + await waitFor(() => { + expect(queryByRole(document.body, 'dialog')).toBeNull() + }) + expect(panzoom.destroy).toHaveBeenCalledOnce() + open() + locale = 'zh-CN' + viewer.refresh() + getByRole(document.body, 'button', { name: '全屏查看图表' }).click() + expect(getByRole(document.body, 'dialog', { name: '图表查看器' })).toBeTruthy() + getByRole(document.body, 'button', { name: '关闭' }).click() + expect(panzoom.destroy).toHaveBeenCalledTimes(3) + }) + + it('closes when route content is removed and stops enhancing after disposal', async () => { + render() + viewer = installMermaidViewer(document, () => 'en-US') + open() + required(document.querySelector('.mermaid')).remove() + await waitFor(() => { + expect(queryByRole(document.body, 'dialog')).toBeNull() + }) + expect(document.body.style.overflow).toBe('auto') + viewer.dispose() + required(document.querySelector('main')).innerHTML = '
' + render() + await new Promise((resolve) => { queueMicrotask(resolve) }) + expect(queryAllByRole(document.body, 'button')).toHaveLength(0) + }) +}) From cd0cc91382d7d6f5cfa0c2c5caee21c5c1dbf881 Mon Sep 17 00:00:00 2001 From: gengruilin Date: Mon, 14 Sep 2026 12:36:32 +0800 Subject: [PATCH 2/4] fix(docs): keep Mermaid wheel zoom anchored to the pointer --- .../feature/2026-09-14-docs-mermaid-viewer.i18n.yaml | 4 ++-- .../feature/2026-09-14-docs-mermaid-viewer.md | 2 +- .../feature/2026-09-14-docs-mermaid-viewer.zh.md | 2 +- website/.vitepress/theme/mermaid-viewer.css | 3 +-- website/.vitepress/theme/mermaid-viewer.ts | 9 ++++----- website/tests/mermaid-viewer.spec.ts | 5 +++-- 6 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml index fa5dc063a8..9d4ebaf368 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.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 .agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md -2026-09-14-docs-mermaid-viewer.md: fa51cf527e126e21ba46c72d980dcfc28c2d16e6 -2026-09-14-docs-mermaid-viewer.zh.md: 9e50cabe524877c0aea6c734c1cb613c99ea9e02 +2026-09-14-docs-mermaid-viewer.md: 3c014000bccd25908f699333efbcb551c1ee1599 +2026-09-14-docs-mermaid-viewer.zh.md: 48ce642b312cd8881fda437e8cb9ee93e21698ef diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md index fa51cf527e..3c014000bc 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md @@ -12,7 +12,7 @@ Complex Mermaid diagrams lose readable detail when scaled to the documentation c The [VitePress theme](../../../../website/.vitepress/theme/index.ts) adds a fullscreen entry above each rendered Mermaid SVG. A native modal dialog provides an inert background and Escape dismissal; the toolbar cycles keyboard focus through zoom, fit, and close controls. Panzoom supplies pointer, wheel, and pinch interaction. Arrow keys pan in fixed screen distances. Closing restores the entry's focus without scrolling and restores the page's previous overflow setting. -The viewer copies the SVG into a shadow root. Mermaid's embedded selectors and fragment IDs stay local to the copy, so its markers and styles cannot resolve against the original diagram. The natural viewBox dimensions determine the canvas size; the initial scale and every resize fit the entire diagram without enlarging it beyond its natural size. This preserves vector detail while keeping fit independent of the narrow document column. +The viewer copies the SVG into a shadow root. Mermaid's embedded selectors and fragment IDs stay local to the copy, so its markers and styles cannot resolve against the original diagram. Panzoom transforms a viewport-sized canvas containing the SVG at its natural viewBox dimensions, so pointer coordinates and the transform origin share the same center; the initial scale and every resize fit the entire diagram without enlarging it beyond its natural size. This preserves vector detail while keeping fit independent of the narrow document column. Viewer resources belong to the mounted theme. Route, language, theme, and source-SVG replacement close the active view; asynchronous Mermaid renders receive a fresh entry. The scroll lock belongs on the body because the Mermaid plugin observes every attribute change on the HTML element and rerenders in response. The [implementation](../../../../website/.vitepress/theme/mermaid-viewer.ts) leaves Markdown, raw page copies, and `llms.txt` generation with their existing owners. diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md index 9e50cabe52..48ce642b31 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md @@ -12,7 +12,7 @@ Status: implemented [VitePress 主题](../../../../website/.vitepress/theme/index.ts) 在每张已渲染的 Mermaid SVG 上方增加全屏入口。原生模态对话框使背景不可交互,并支持 Escape 退出;工具栏将键盘焦点循环限定在缩放、适应窗口和关闭控件之间。Panzoom 提供指针、滚轮和双指交互。方向键按固定的屏幕距离平移。关闭时恢复入口焦点且不滚动页面,并恢复页面原有的 overflow 设置。 -查看器将 SVG 复制到 shadow root 中。Mermaid 内嵌的选择器和片段 ID 限定在副本内部,因此副本的标记和样式不会解析到原始图表上。viewBox 的自然尺寸决定画布大小;初始缩放以及每次窗口尺寸变化都会适配整张图表,且不会放大到超出自然尺寸。这样既保留矢量细节,也使适配不受文档窄列宽度的影响。 +查看器将 SVG 复制到 shadow root 中。Mermaid 内嵌的选择器和片段 ID 限定在副本内部,因此副本的标记和样式不会解析到原始图表上。Panzoom 变换与视口等大的画布,其中的 SVG 使用 viewBox 的自然尺寸,使指针坐标与变换原点共享同一个中心;初始缩放以及每次窗口尺寸变化都会适配整张图表,且不会放大到超出自然尺寸。这样既保留矢量细节,也使适配不受文档窄列宽度的影响。 查看器资源由已挂载的主题持有。路由、语言、主题和源 SVG 替换都会关闭当前视图;异步渲染的 Mermaid 图表会获得新的入口。滚动锁设置在 body 上,因为 Mermaid 插件会观察 HTML 元素的每次属性变化并重新渲染。[实现](../../../../website/.vitepress/theme/mermaid-viewer.ts) 将 Markdown、原始页面副本和 `llms.txt` 的生成保留在原有归属处。 diff --git a/website/.vitepress/theme/mermaid-viewer.css b/website/.vitepress/theme/mermaid-viewer.css index 48eb91beee..16c9eb75ac 100644 --- a/website/.vitepress/theme/mermaid-viewer.css +++ b/website/.vitepress/theme/mermaid-viewer.css @@ -79,8 +79,7 @@ .dsh-diagram-paper { position: absolute; - left: 50%; - top: 50%; + inset: 0; } @media (max-width: 600px) { diff --git a/website/.vitepress/theme/mermaid-viewer.ts b/website/.vitepress/theme/mermaid-viewer.ts index 246cde775c..9c2fdb5c2c 100644 --- a/website/.vitepress/theme/mermaid-viewer.ts +++ b/website/.vitepress/theme/mermaid-viewer.ts @@ -63,15 +63,14 @@ function openDiagram( viewport.className = 'dsh-diagram-viewport' const paper = doc.createElement('div') paper.className = 'dsh-diagram-paper' - Object.assign(paper.style, { - width: `${size.width}px`, height: `${size.height}px`, - marginLeft: `${-size.width / 2}px`, marginTop: `${-size.height / 2}px`, - }) // Each Mermaid SVG embeds ID-scoped styles and fragment references. A shadow root // keeps the enlarged copy's IDs and styles separate from the original diagram. const shadow = paper.attachShadow({ mode: 'open' }) const clone = svg.cloneNode(true) as SVGSVGElement - Object.assign(clone.style, { width: '100%', height: '100%', maxWidth: 'none', display: 'block' }) + Object.assign(clone.style, { + position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', + width: `${size.width}px`, height: `${size.height}px`, maxWidth: 'none', display: 'block', + }) shadow.append(clone) viewport.append(paper) dialog.append(toolbar, help, viewport) diff --git a/website/tests/mermaid-viewer.spec.ts b/website/tests/mermaid-viewer.spec.ts index 32bc392065..05fbd0124b 100644 --- a/website/tests/mermaid-viewer.spec.ts +++ b/website/tests/mermaid-viewer.spec.ts @@ -103,8 +103,9 @@ describe('documentation Mermaid viewer', () => { const dialog = open() expect(panzoom.create.mock.calls[0]?.[1]).toMatchObject({ startScale: 0.2, minScale: 0.1 }) const paper = required(dialog.querySelector('.dsh-diagram-paper')) - expect(paper.style.width).toBe('2000px') - expect(paper.style.height).toBe('3000px') + const clone = required(paper.shadowRoot?.querySelector('svg')) + expect(clone.style.width).toBe('2000px') + expect(clone.style.height).toBe('3000px') expect(paper.shadowRoot?.querySelector('path')?.getAttribute('marker-end')).toBe('url(#arrow)') expect(document.querySelectorAll('#diagram')).toHaveLength(1) expect(source.outerHTML).toBe(original) From ece25ff8e64448242302080c9897caae5f034a5e Mon Sep 17 00:00:00 2001 From: gengruilin Date: Mon, 14 Sep 2026 13:52:42 +0800 Subject: [PATCH 3/4] refactor(docs): simplify Mermaid fullscreen viewer controls --- .../2026-09-14-docs-mermaid-viewer.i18n.yaml | 4 +- .../feature/2026-09-14-docs-mermaid-viewer.md | 4 +- .../2026-09-14-docs-mermaid-viewer.zh.md | 4 +- website/.vitepress/theme/mermaid-viewer.css | 146 ++++++++++++------ website/.vitepress/theme/mermaid-viewer.ts | 74 +++++++-- website/tests/mermaid-viewer.spec.ts | 42 ++++- 6 files changed, 202 insertions(+), 72 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml index 9d4ebaf368..3e460172d8 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.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 .agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md -2026-09-14-docs-mermaid-viewer.md: 3c014000bccd25908f699333efbcb551c1ee1599 -2026-09-14-docs-mermaid-viewer.zh.md: 48ce642b312cd8881fda437e8cb9ee93e21698ef +2026-09-14-docs-mermaid-viewer.md: 21d3b0be620d452bfe7024e2eb9e931db4d302b4 +2026-09-14-docs-mermaid-viewer.zh.md: f7400c5e5d1ba9b58ca05eeaaca422a9583a4a47 diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md index 3c014000bc..21d3b0be62 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md @@ -10,9 +10,9 @@ Complex Mermaid diagrams lose readable detail when scaled to the documentation c ## Decision -The [VitePress theme](../../../../website/.vitepress/theme/index.ts) adds a fullscreen entry above each rendered Mermaid SVG. A native modal dialog provides an inert background and Escape dismissal; the toolbar cycles keyboard focus through zoom, fit, and close controls. Panzoom supplies pointer, wheel, and pinch interaction. Arrow keys pan in fixed screen distances. Closing restores the entry's focus without scrolling and restores the page's previous overflow setting. +The [VitePress theme](../../../../website/.vitepress/theme/index.ts) adds a corner fullscreen icon to each rendered Mermaid SVG. A native modal dialog provides an inert background and Escape dismissal. A floating toolbar groups zoom controls, the current scale, and fit; close stays in the top corner, and help opens on demand. Keyboard focus cycles through all five buttons. Panzoom supplies pointer, wheel, and pinch interaction. Arrow keys pan in fixed screen distances. Closing restores the entry's focus without scrolling and restores the page's previous overflow setting. -The viewer copies the SVG into a shadow root. Mermaid's embedded selectors and fragment IDs stay local to the copy, so its markers and styles cannot resolve against the original diagram. Panzoom transforms a viewport-sized canvas containing the SVG at its natural viewBox dimensions, so pointer coordinates and the transform origin share the same center; the initial scale and every resize fit the entire diagram without enlarging it beyond its natural size. This preserves vector detail while keeping fit independent of the narrow document column. +The viewer copies the SVG into a shadow root. Mermaid's embedded selectors and fragment IDs stay local to the copy, so its markers and styles cannot resolve against the original diagram. Panzoom transforms a viewport-sized canvas containing the SVG at its natural viewBox dimensions, so pointer coordinates and the transform origin share the same center; the initial scale and every resize fit the entire diagram without enlarging it beyond its natural size. This preserves vector detail while keeping fit independent of the narrow document column. The canvas has no visible frame; reserved space keeps controls clear of the fitted diagram. Viewer resources belong to the mounted theme. Route, language, theme, and source-SVG replacement close the active view; asynchronous Mermaid renders receive a fresh entry. The scroll lock belongs on the body because the Mermaid plugin observes every attribute change on the HTML element and rerenders in response. The [implementation](../../../../website/.vitepress/theme/mermaid-viewer.ts) leaves Markdown, raw page copies, and `llms.txt` generation with their existing owners. diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md index 48ce642b31..f7400c5e5d 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -[VitePress 主题](../../../../website/.vitepress/theme/index.ts) 在每张已渲染的 Mermaid SVG 上方增加全屏入口。原生模态对话框使背景不可交互,并支持 Escape 退出;工具栏将键盘焦点循环限定在缩放、适应窗口和关闭控件之间。Panzoom 提供指针、滚轮和双指交互。方向键按固定的屏幕距离平移。关闭时恢复入口焦点且不滚动页面,并恢复页面原有的 overflow 设置。 +[VitePress 主题](../../../../website/.vitepress/theme/index.ts) 在每张已渲染的 Mermaid SVG 角落增加全屏图标。原生模态对话框使背景不可交互,并支持 Escape 退出。浮动工具栏集中显示缩放控件、当前比例和适应窗口操作;关闭按钮位于顶部角落,帮助按需展开。键盘焦点在五个按钮之间循环。Panzoom 提供指针、滚轮和双指交互。方向键按固定的屏幕距离平移。关闭时恢复入口焦点且不滚动页面,并恢复页面原有的 overflow 设置。 -查看器将 SVG 复制到 shadow root 中。Mermaid 内嵌的选择器和片段 ID 限定在副本内部,因此副本的标记和样式不会解析到原始图表上。Panzoom 变换与视口等大的画布,其中的 SVG 使用 viewBox 的自然尺寸,使指针坐标与变换原点共享同一个中心;初始缩放以及每次窗口尺寸变化都会适配整张图表,且不会放大到超出自然尺寸。这样既保留矢量细节,也使适配不受文档窄列宽度的影响。 +查看器将 SVG 复制到 shadow root 中。Mermaid 内嵌的选择器和片段 ID 限定在副本内部,因此副本的标记和样式不会解析到原始图表上。Panzoom 变换与视口等大的画布,其中的 SVG 使用 viewBox 的自然尺寸,使指针坐标与变换原点共享同一个中心;初始缩放以及每次窗口尺寸变化都会适配整张图表,且不会放大到超出自然尺寸。这样既保留矢量细节,也使适配不受文档窄列宽度的影响。画布没有可见边框;预留空间使控件不会遮挡适配后的图表。 查看器资源由已挂载的主题持有。路由、语言、主题和源 SVG 替换都会关闭当前视图;异步渲染的 Mermaid 图表会获得新的入口。滚动锁设置在 body 上,因为 Mermaid 插件会观察 HTML 元素的每次属性变化并重新渲染。[实现](../../../../website/.vitepress/theme/mermaid-viewer.ts) 将 Markdown、原始页面副本和 `llms.txt` 的生成保留在原有归属处。 diff --git a/website/.vitepress/theme/mermaid-viewer.css b/website/.vitepress/theme/mermaid-viewer.css index 16c9eb75ac..e98be69ca9 100644 --- a/website/.vitepress/theme/mermaid-viewer.css +++ b/website/.vitepress/theme/mermaid-viewer.css @@ -1,29 +1,49 @@ +.vp-doc .mermaid:has(.dsh-diagram-open) { + position: relative; + padding-top: 48px; +} + .vp-doc .dsh-diagram-open, -.dsh-diagram-toolbar button { - min-height: 44px; - padding: 6px 14px; - border: 1px solid var(--vp-c-divider); +.dsh-diagram-viewer button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + flex: none; + border: 0; border-radius: 8px; - color: var(--vp-c-text-1); - background: var(--vp-c-bg-soft); - font: inherit; - font-size: 14px; + color: var(--vp-c-text-2); + background: var(--vp-c-bg); cursor: pointer; } .vp-doc .dsh-diagram-open { - display: block; - margin: 8px 0 16px auto; + position: absolute; + top: 0; + right: 0; +} + +.dsh-diagram-icon { + width: 20px; + height: 20px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; } .dsh-diagram-open:hover, -.dsh-diagram-toolbar button:hover { - border-color: var(--vp-c-brand-1); +.dsh-diagram-viewer button:hover, +.dsh-diagram-viewer button[aria-expanded="true"] { color: var(--vp-c-brand-1); + background: var(--vp-c-bg-soft); } .dsh-diagram-open:focus-visible, -.dsh-diagram-toolbar button:focus-visible { +.dsh-diagram-viewer button:focus-visible { outline: 2px solid var(--vp-c-brand-1); outline-offset: 2px; } @@ -36,55 +56,87 @@ max-width: none; max-height: none; margin: 0; - padding: 16px; + padding: 0; border: 0; + overflow: hidden; color: var(--vp-c-text-1); background: var(--vp-c-bg); box-sizing: border-box; } -.dsh-diagram-viewer[open] { - display: flex; - flex-direction: column; - gap: 8px; -} - -.dsh-diagram-toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - flex: none; -} - -.dsh-diagram-toolbar strong { - margin-right: auto; -} - -.dsh-diagram-help { - margin: 0; +.dsh-diagram-title { + position: absolute; + top: 24px; + left: 24px; + max-width: calc(100% - 104px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; color: var(--vp-c-text-2); font-size: 13px; } -.dsh-diagram-viewport { - position: relative; - flex: 1; - min-height: 0; - overflow: hidden; +.dsh-diagram-close { position: absolute; top: 12px; right: 16px; } + +.dsh-diagram-toolbar { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 2px; + padding: 4px; + border: 1px solid var(--vp-c-divider); + border-radius: 12px; + background: var(--vp-c-bg); + box-shadow: 0 3px 12px #0000000a; +} + +.dsh-diagram-scale { + min-width: 48px; + text-align: center; + color: var(--vp-c-text-2); + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.dsh-diagram-toolbar .dsh-diagram-fit { + margin-left: 6px; + border-left: 1px solid var(--vp-c-divider); + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.dsh-diagram-help-toggle { position: absolute; bottom: 25px; right: 16px; } + +.dsh-diagram-help { + position: absolute; + right: 16px; + bottom: 80px; + max-width: min(360px, calc(100% - 32px)); + margin: 0; + padding: 12px 16px; border: 1px solid var(--vp-c-divider); border-radius: 8px; + background: var(--vp-c-bg); + box-shadow: 0 3px 12px #0000000a; + font-size: 13px; +} + +.dsh-diagram-viewport { + position: absolute; + inset: 64px 16px 88px; + overflow: hidden; overscroll-behavior: contain; } -.dsh-diagram-paper { - position: absolute; - inset: 0; -} +.dsh-diagram-paper { position: absolute; inset: 0; } @media (max-width: 600px) { - .dsh-diagram-viewer { padding: 8px; } - .dsh-diagram-toolbar { align-items: stretch; } - .dsh-diagram-toolbar strong { width: 100%; } - .dsh-diagram-toolbar button { flex: 1; padding-inline: 8px; } + .dsh-diagram-title { top: 20px; left: 16px; } + .dsh-diagram-close { top: 8px; right: 8px; } + .dsh-diagram-viewport { inset: 56px 8px 80px; } + .dsh-diagram-toolbar { bottom: 12px; } + .dsh-diagram-help-toggle { right: 8px; bottom: 17px; } } diff --git a/website/.vitepress/theme/mermaid-viewer.ts b/website/.vitepress/theme/mermaid-viewer.ts index 9c2fdb5c2c..3d102f2410 100644 --- a/website/.vitepress/theme/mermaid-viewer.ts +++ b/website/.vitepress/theme/mermaid-viewer.ts @@ -4,16 +4,24 @@ import Panzoom from '@panzoom/panzoom' const messages = { en: { open: 'View diagram fullscreen', title: 'Diagram viewer', - zoomIn: 'Zoom in', zoomOut: 'Zoom out', fit: 'Fit view', close: 'Close', + zoomIn: 'Zoom in', zoomOut: 'Zoom out', fit: 'Fit view', close: 'Close', helpLabel: 'Viewer help', help: 'Scroll or pinch to zoom · Drag or use arrow keys to pan · Esc to close', }, zh: { open: '全屏查看图表', title: '图表查看器', - zoomIn: '放大', zoomOut: '缩小', fit: '适应窗口', close: '关闭', + zoomIn: '放大', zoomOut: '缩小', fit: '适应窗口', close: '关闭', helpLabel: '查看器帮助', help: '滚轮或双指缩放 · 拖动或方向键平移 · Esc 关闭', }, } satisfies Record> +const icons = { + open: 'M14 4h6v6M20 4l-7 7M10 20H4v-6M4 20l7-7', + zoomIn: 'M5 12h14M12 5v14', zoomOut: 'M5 12h14', + fit: 'M9 4H4v5M15 4h5v5M4 15v5h5M20 15v5h-5', + close: 'M6 6l12 12M18 6 6 18', + help: 'M9.1 8a3 3 0 0 1 5.8 1c0 2-3 2-3 4M12 17v.1', +} + /** Theme-owned viewer resources; refresh closes the current view before rescanning. */ export interface MermaidViewer { /** Close the view and update entries after a route, language, or theme change. */ @@ -22,10 +30,24 @@ export interface MermaidViewer { dispose(): void } -function button(doc: Document, label: string): HTMLButtonElement { +function labelButton(element: HTMLButtonElement, label: string): void { + element.setAttribute('aria-label', label) + element.title = label +} + +function button(doc: Document, label: string, icon: keyof typeof icons): HTMLButtonElement { const element = doc.createElement('button') element.type = 'button' - element.textContent = label + labelButton(element, label) + const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg') + svg.classList.add('dsh-diagram-icon') + svg.setAttribute('viewBox', '0 0 24 24') + svg.setAttribute('aria-hidden', 'true') + svg.setAttribute('focusable', 'false') + const path = doc.createElementNS(svg.namespaceURI, 'path') + path.setAttribute('d', icons[icon]) + svg.append(path) + element.append(svg) return element } @@ -48,16 +70,27 @@ function openDiagram( dialog.setAttribute('aria-label', copy.title) const toolbar = doc.createElement('div') toolbar.className = 'dsh-diagram-toolbar' - const title = doc.createElement('strong') - title.textContent = copy.title - const zoomOut = button(doc, copy.zoomOut) - const zoomIn = button(doc, copy.zoomIn) - const fit = button(doc, copy.fit) - const close = button(doc, copy.close) + const title = doc.createElement('span') + title.className = 'dsh-diagram-title' + title.textContent = doc.querySelector('.vp-doc h1')?.textContent.trim() ?? copy.title + const zoomOut = button(doc, copy.zoomOut, 'zoomOut') + const zoomIn = button(doc, copy.zoomIn, 'zoomIn') + const scaleLabel = doc.createElement('span') + scaleLabel.className = 'dsh-diagram-scale' + const fit = button(doc, copy.fit, 'fit') + fit.className = 'dsh-diagram-fit' + const close = button(doc, copy.close, 'close') + close.className = 'dsh-diagram-close' close.autofocus = true - toolbar.append(title, zoomOut, zoomIn, fit, close) + toolbar.append(zoomOut, scaleLabel, zoomIn, fit) + const helpToggle = button(doc, copy.helpLabel, 'help') + helpToggle.className = 'dsh-diagram-help-toggle' + helpToggle.setAttribute('aria-expanded', 'false') + helpToggle.setAttribute('aria-controls', 'dsh-diagram-help-text') const help = doc.createElement('p') help.className = 'dsh-diagram-help' + help.id = 'dsh-diagram-help-text' + help.hidden = true help.textContent = copy.help const viewport = doc.createElement('div') viewport.className = 'dsh-diagram-viewport' @@ -73,7 +106,7 @@ function openDiagram( }) shadow.append(clone) viewport.append(paper) - dialog.append(toolbar, help, viewport) + dialog.append(viewport, title, close, toolbar, helpToggle, help) doc.body.append(dialog) const overflow = doc.body.style.overflow @@ -112,6 +145,13 @@ function openDiagram( controller.pan(0, 0, { animate: false }) } const options = { signal: listeners.signal } + const updateScale = (): void => { scaleLabel.textContent = `${Math.round(controller.getScale() * 100)}%` } + updateScale() + paper.addEventListener('panzoomchange', updateScale, options) + helpToggle.addEventListener('click', () => { + help.hidden = !help.hidden + helpToggle.setAttribute('aria-expanded', String(!help.hidden)) + }, options) close.addEventListener('click', cleanup, options) dialog.addEventListener('close', cleanup, options) dialog.addEventListener('cancel', (event) => { @@ -124,7 +164,7 @@ function openDiagram( viewport.addEventListener('wheel', event => controller.zoomWithWheel(event), { ...options, passive: false }) dialog.addEventListener('keydown', (event) => { if (event.key === 'Tab') { - const controls = [zoomOut, zoomIn, fit, close] + const controls = [close, zoomOut, zoomIn, fit, helpToggle] const current = controls.indexOf(doc.activeElement as HTMLButtonElement) const next = current < 0 ? (event.shiftKey ? controls.length - 1 : 0) : (current + (event.shiftKey ? -1 : 1) + controls.length) % controls.length @@ -171,7 +211,7 @@ export function installMermaidViewer(doc: Document, language: () => string): Mer const copy = language().startsWith('zh') ? messages.zh : messages.en const containers = new Set(doc.querySelectorAll('.vp-doc .mermaid')) for (const [container, entry] of entries) { - if (!containers.has(container) || container.querySelector('svg') !== entry.svg || !entry.button.isConnected) { + if (!containers.has(container) || container.querySelector('svg:not(.dsh-diagram-icon)') !== entry.svg || !entry.button.isConnected) { if (entry.svg === active) closeActive() entry.button.remove() entries.delete(container) @@ -180,12 +220,12 @@ export function installMermaidViewer(doc: Document, language: () => string): Mer for (const container of containers) { const existing = entries.get(container) if (existing) { - if (existing.button.textContent !== copy.open) existing.button.textContent = copy.open + if (existing.button.getAttribute('aria-label') !== copy.open) labelButton(existing.button, copy.open) continue } - const svg = container.querySelector('svg') + const svg = container.querySelector('svg:not(.dsh-diagram-icon)') if (!svg || !dimensions(svg)) continue - const trigger = button(doc, copy.open) + const trigger = button(doc, copy.open, 'open') trigger.className = 'dsh-diagram-open' trigger.setAttribute('aria-haspopup', 'dialog') trigger.addEventListener('click', () => { diff --git a/website/tests/mermaid-viewer.spec.ts b/website/tests/mermaid-viewer.spec.ts index 05fbd0124b..3b57131858 100644 --- a/website/tests/mermaid-viewer.spec.ts +++ b/website/tests/mermaid-viewer.spec.ts @@ -9,7 +9,7 @@ import { installMermaidViewer, type MermaidViewer } from '../.vitepress/theme/me const panzoom = vi.hoisted(() => ({ create: vi.fn<(element: HTMLElement, options: PanzoomOptions) => unknown>(), destroy: vi.fn(), setOptions: vi.fn(), zoom: vi.fn(), pan: vi.fn(), - zoomIn: vi.fn(), zoomOut: vi.fn(), zoomWithWheel: vi.fn(), getScale: () => 2, + zoomIn: vi.fn(), zoomOut: vi.fn(), zoomWithWheel: vi.fn(), getScale: vi.fn<() => number>(), })) vi.mock('@panzoom/panzoom', () => ({ default: panzoom.create })) @@ -24,7 +24,10 @@ const dialogMethods = new Map(['showModal', 'close'].map(name => [ beforeEach(() => { vi.clearAllMocks() - panzoom.create.mockReturnValue(panzoom) + panzoom.create.mockImplementation((_element, options) => { + panzoom.getScale.mockReturnValue(options.startScale ?? 1) + return panzoom + }) viewportWidth = 1032 viewportHeight = 632 document.body.innerHTML = '
' @@ -94,6 +97,10 @@ describe('documentation Mermaid viewer', () => { viewer.refresh() expect(queryAllByRole(document.body, 'button')).toHaveLength(1) expect(required(document.querySelector('.mermaid')).firstElementChild?.tagName).toBe('BUTTON') + const trigger = getByRole(document.body, 'button', { name: 'View diagram fullscreen' }) + expect(trigger.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true') + await new Promise((resolve) => { queueMicrotask(resolve) }) + expect(getByRole(document.body, 'button', { name: 'View diagram fullscreen' })).toBe(trigger) }) it('fits the natural SVG into the viewport and isolates copied styles and fragment IDs', () => { @@ -130,6 +137,7 @@ describe('documentation Mermaid viewer', () => { const wheel = new WheelEvent('wheel', { deltaY: -100 }) viewport.dispatchEvent(wheel) expect(panzoom.zoomWithWheel).toHaveBeenCalledWith(wheel) + panzoom.getScale.mockReturnValue(2) dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) expect(panzoom.pan).toHaveBeenLastCalledWith(-32, 0, { relative: true, animate: false }) getByRole(dialog, 'button', { name: 'Fit view' }).click() @@ -150,6 +158,36 @@ describe('documentation Mermaid viewer', () => { expect(document.activeElement).toBe(zoomOut) dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true })) expect(document.activeElement).toBe(close) + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true })) + expect(document.activeElement).toBe(getByRole(dialog, 'button', { name: 'Viewer help' })) + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', cancelable: true })) + expect(document.activeElement).toBe(close) + }) + + it('shows zoom changes and reveals help on demand without retaining listeners after close', () => { + render() + viewer = installMermaidViewer(document, () => 'en-US') + const dialog = open() + const scale = required(dialog.querySelector('.dsh-diagram-scale')) + const paper = required(dialog.querySelector('.dsh-diagram-paper')) + expect(scale.textContent).toBe('20%') + panzoom.getScale.mockReturnValue(0.75) + paper.dispatchEvent(new Event('panzoomchange')) + expect(scale.textContent).toBe('75%') + const toggle = getByRole(dialog, 'button', { name: 'Viewer help' }) + const help = required(document.getElementById(required(toggle.getAttribute('aria-controls')))) + expect(help.hidden).toBe(true) + toggle.click() + expect(help.hidden).toBe(false) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + toggle.click() + expect(help.hidden).toBe(true) + getByRole(dialog, 'button', { name: 'Close' }).click() + toggle.click() + expect(help.hidden).toBe(true) + panzoom.getScale.mockReturnValue(1.5) + paper.dispatchEvent(new Event('panzoomchange')) + expect(scale.textContent).toBe('75%') }) it.each(['button', 'cancel', 'refresh', 'dispose'] as const)('releases resources on %s and restores focus and scrolling', (method) => { From 91f67330969acb2142fe8725350c4a480546fb94 Mon Sep 17 00:00:00 2001 From: gengruilin Date: Mon, 14 Sep 2026 14:49:48 +0800 Subject: [PATCH 4/4] fix(docs): align Mermaid viewer accessible titles --- .../2026-09-14-docs-mermaid-viewer.i18n.yaml | 4 ++-- .../feature/2026-09-14-docs-mermaid-viewer.md | 8 ++++--- .../2026-09-14-docs-mermaid-viewer.zh.md | 8 ++++--- website/.vitepress/config.ts | 3 +-- website/.vitepress/theme/mermaid-viewer.ts | 7 ++++--- website/tests/mermaid-viewer.spec.ts | 21 +++++++++++++++++++ 6 files changed, 38 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml index 3e460172d8..c91b5f6407 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.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 .agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md -2026-09-14-docs-mermaid-viewer.md: 21d3b0be620d452bfe7024e2eb9e931db4d302b4 -2026-09-14-docs-mermaid-viewer.zh.md: f7400c5e5d1ba9b58ca05eeaaca422a9583a4a47 +2026-09-14-docs-mermaid-viewer.md: 03aecb7b4df1c79d2eb254375f6b2c68145079ac +2026-09-14-docs-mermaid-viewer.zh.md: b66f427a4820eef0d6fa25b163cfc8e3548ec0c6 diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md index 21d3b0be62..03aecb7b4d 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md @@ -10,11 +10,11 @@ Complex Mermaid diagrams lose readable detail when scaled to the documentation c ## Decision -The [VitePress theme](../../../../website/.vitepress/theme/index.ts) adds a corner fullscreen icon to each rendered Mermaid SVG. A native modal dialog provides an inert background and Escape dismissal. A floating toolbar groups zoom controls, the current scale, and fit; close stays in the top corner, and help opens on demand. Keyboard focus cycles through all five buttons. Panzoom supplies pointer, wheel, and pinch interaction. Arrow keys pan in fixed screen distances. Closing restores the entry's focus without scrolling and restores the page's previous overflow setting. +The [VitePress theme](../../../../website/.vitepress/theme/index.ts) adds a corner fullscreen icon to each rendered Mermaid SVG. A native modal dialog provides an inert background and Escape dismissal. Its visible title also names it for assistive technology; a missing or blank page heading uses the localized viewer title. A floating toolbar groups zoom controls, the current scale, and fit; close stays in the top corner, and help opens on demand. Keyboard focus cycles through all five buttons. Panzoom supplies pointer, wheel, and pinch interaction. Arrow keys pan in fixed screen distances. Closing restores the entry's focus without scrolling and restores the page's previous overflow setting. The viewer copies the SVG into a shadow root. Mermaid's embedded selectors and fragment IDs stay local to the copy, so its markers and styles cannot resolve against the original diagram. Panzoom transforms a viewport-sized canvas containing the SVG at its natural viewBox dimensions, so pointer coordinates and the transform origin share the same center; the initial scale and every resize fit the entire diagram without enlarging it beyond its natural size. This preserves vector detail while keeping fit independent of the narrow document column. The canvas has no visible frame; reserved space keeps controls clear of the fitted diagram. -Viewer resources belong to the mounted theme. Route, language, theme, and source-SVG replacement close the active view; asynchronous Mermaid renders receive a fresh entry. The scroll lock belongs on the body because the Mermaid plugin observes every attribute change on the HTML element and rerenders in response. The [implementation](../../../../website/.vitepress/theme/mermaid-viewer.ts) leaves Markdown, raw page copies, and `llms.txt` generation with their existing owners. +Viewer resources belong to the mounted theme. Route, language, theme, and source-SVG replacement close the active view; asynchronous Mermaid renders receive a fresh entry. The body overflow lock relies on the default theme retaining visible overflow on the HTML element. It avoids mutating HTML attributes, which the Mermaid plugin observes and rerenders in response. The [implementation](../../../../website/.vitepress/theme/mermaid-viewer.ts) leaves Markdown, raw page copies, and `llms.txt` generation with their existing owners. ## Alternatives considered @@ -28,4 +28,6 @@ Viewer resources belong to the mounted theme. Route, language, theme, and source The website gains Panzoom as a direct dependency and uses native dialog, shadow-root, and resize-observer support. Viewer zoom and position are transient: resizing refits the diagram, and navigating or changing the theme closes it. Existing page diagrams remain the reading and link-navigation source. -The [focused tests](../../../../website/tests/mermaid-viewer.spec.ts) cover late rendering, fit dimensions, control wiring, keyboard cycling, replacement, and resource release through `docs:check` and `doc-sync`. Real-browser verification remains necessary for native modal behavior, SVG marker rendering, drag and wheel transforms, themes, and narrow viewports; DOM-only tests do not establish those browser behaviors. +The [focused tests](../../../../website/tests/mermaid-viewer.spec.ts) run in the root unit-test suite; `docs:check` and `doc-sync` also select them so documentation-only validation exercises the viewer. They cover late rendering, accessible titles, initial and resized fit, control wiring, keyboard cycling, replacement, and resource release. + +**CI coverage gap.** The DOM tests mock Panzoom and do not execute browser layout. Native modal behavior, SVG markers, pointer-anchored wheel zoom, canvas dragging, theme colors, and narrow-screen geometry require real-browser verification. The recorded browser demonstration supplies evidence for the current implementation, but it is not an automated regression check. diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md index f7400c5e5d..b66f427a48 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md @@ -10,11 +10,11 @@ Status: implemented ## 决策 -[VitePress 主题](../../../../website/.vitepress/theme/index.ts) 在每张已渲染的 Mermaid SVG 角落增加全屏图标。原生模态对话框使背景不可交互,并支持 Escape 退出。浮动工具栏集中显示缩放控件、当前比例和适应窗口操作;关闭按钮位于顶部角落,帮助按需展开。键盘焦点在五个按钮之间循环。Panzoom 提供指针、滚轮和双指交互。方向键按固定的屏幕距离平移。关闭时恢复入口焦点且不滚动页面,并恢复页面原有的 overflow 设置。 +[VitePress 主题](../../../../website/.vitepress/theme/index.ts) 在每张已渲染的 Mermaid SVG 角落增加全屏图标。原生模态对话框使背景不可交互,并支持 Escape 退出。可见标题也用于辅助技术识别对话框;页面标题缺失或为空白时,使用本地化的查看器标题。浮动工具栏集中显示缩放控件、当前比例和适应窗口操作;关闭按钮位于顶部角落,帮助按需展开。键盘焦点在五个按钮之间循环。Panzoom 提供指针、滚轮和双指交互。方向键按固定的屏幕距离平移。关闭时恢复入口焦点且不滚动页面,并恢复页面原有的 overflow 设置。 查看器将 SVG 复制到 shadow root 中。Mermaid 内嵌的选择器和片段 ID 限定在副本内部,因此副本的标记和样式不会解析到原始图表上。Panzoom 变换与视口等大的画布,其中的 SVG 使用 viewBox 的自然尺寸,使指针坐标与变换原点共享同一个中心;初始缩放以及每次窗口尺寸变化都会适配整张图表,且不会放大到超出自然尺寸。这样既保留矢量细节,也使适配不受文档窄列宽度的影响。画布没有可见边框;预留空间使控件不会遮挡适配后的图表。 -查看器资源由已挂载的主题持有。路由、语言、主题和源 SVG 替换都会关闭当前视图;异步渲染的 Mermaid 图表会获得新的入口。滚动锁设置在 body 上,因为 Mermaid 插件会观察 HTML 元素的每次属性变化并重新渲染。[实现](../../../../website/.vitepress/theme/mermaid-viewer.ts) 将 Markdown、原始页面副本和 `llms.txt` 的生成保留在原有归属处。 +查看器资源由已挂载的主题持有。路由、语言、主题和源 SVG 替换都会关闭当前视图;异步渲染的 Mermaid 图表会获得新的入口。body 的 overflow 滚动锁依赖默认主题让 HTML 元素保持 visible overflow。它避免修改 HTML 属性,因为 Mermaid 插件会观察这些属性并重新渲染。[实现](../../../../website/.vitepress/theme/mermaid-viewer.ts) 将 Markdown、原始页面副本和 `llms.txt` 的生成保留在原有归属处。 ## 考虑过的替代方案 @@ -28,4 +28,6 @@ Status: implemented 文档站增加了 Panzoom 直接依赖,并使用原生 dialog、shadow root 和 ResizeObserver 支持。查看器的缩放和平移状态是临时的:窗口尺寸变化会重新适配图表,导航或主题变化会关闭视图。原有页面图表仍然是阅读和链接导航的来源。 -[定向测试](../../../../website/tests/mermaid-viewer.spec.ts) 通过 `docs:check` 和 `doc-sync`(文档同步门禁)覆盖延迟渲染、适配尺寸、控件连接、键盘焦点循环、图表替换和资源释放。原生模态行为、SVG 标记渲染、拖动和滚轮变换、主题以及窄视口仍需要真实浏览器验证;仅运行 DOM 测试不能证明这些浏览器行为。 +[定向测试](../../../../website/tests/mermaid-viewer.spec.ts) 属于根单元测试集;`docs:check` 和 `doc-sync`(文档同步门禁)也会选中这些测试,使仅运行文档验证时同样覆盖查看器。它们验证延迟渲染、无障碍标题、初始及窗口变化后的适配、控件连接、键盘焦点循环、图表替换和资源释放。 + +**CI 覆盖缺口。** DOM 测试模拟了 Panzoom,不执行浏览器布局。原生模态行为、SVG 标记、以指针为锚点的滚轮缩放、画布拖动、主题颜色和窄屏几何布局需要真实浏览器验证。浏览器演示记录提供了当前实现的证据,但不属于自动回归检查。 diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 07fc6bad92..b63ba59a2f 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -218,8 +218,7 @@ const wordmark = readFileSync(resolve(import.meta.dirname, '../public/wordmark.s .replace(' { + it.each([ + { heading: 'Agent lifecycle', language: 'en-US', expected: 'Agent lifecycle' }, + { heading: ' \n ', language: 'zh-CN', expected: '图表查看器' }, + { heading: null, language: 'en-US', expected: 'Diagram viewer' }, + ])('uses the visible title as the dialog name for $heading', ({ heading, language, expected }) => { + render() + if (heading !== null) { + const h1 = document.createElement('h1') + h1.textContent = heading + required(document.querySelector('.vp-doc')).prepend(h1) + } + viewer = installMermaidViewer(document, () => language) + getByRole(document.body, 'button').click() + const dialog = getByRole(document.body, 'dialog', { name: expected }) + const title = required(document.getElementById(required(dialog.getAttribute('aria-labelledby')))) + expect(title.textContent).toBe(expected) + }) + it('adds one entry only after an SVG with usable dimensions renders', async () => { viewer = installMermaidViewer(document, () => 'en-US') expect(queryAllByRole(document.body, 'button')).toHaveLength(0) @@ -118,6 +136,9 @@ describe('documentation Mermaid viewer', () => { expect(source.outerHTML).toBe(original) expect(document.body.style.overflow).toBe('hidden') expect(document.documentElement.style.overflow).toBe('') + required(resize)() + expect(panzoom.zoom).toHaveBeenLastCalledWith(0.2, { animate: false }) + expect(panzoom.pan).toHaveBeenLastCalledWith(0, 0, { animate: false }) viewportWidth = 332 viewportHeight = 932 required(resize)()