From 92862897761b80d39daa2b18a30896ace06ee774 Mon Sep 17 00:00:00 2001 From: Aniket Vishwakarma Date: Fri, 4 Sep 2026 13:32:47 +0530 Subject: [PATCH 1/6] fix(web): render Mermaid diagrams in Markdown Mermaid fences rendered as plain code, so agent plans and architecture notes lost their visual structure. Render completed mermaid fences through a lazy-loaded MermaidDiagram with strict security mode, theme-aware init, serialized global config, and fallback to highlighted source on error or while streaming. Preserve original fence source for copy. Desktop inherits web behavior; mobile unchanged. Model: Muse Spark in T3 Code. --- apps/web/package.json | 1 + apps/web/src/components/ChatMarkdown.tsx | 36 +- .../web/src/components/MermaidDiagram.test.ts | 79 ++ apps/web/src/components/MermaidDiagram.tsx | 105 +++ apps/web/src/index.css | 17 + apps/web/src/markdown-clipboard.test.ts | 51 +- apps/web/src/markdown-clipboard.ts | 18 +- pnpm-lock.yaml | 803 ++++++++++++++++++ 8 files changed, 1093 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/MermaidDiagram.test.ts create mode 100644 apps/web/src/components/MermaidDiagram.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 283024eca095..cb0ef105e653 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,6 +40,7 @@ "jszip": "3.10.1", "lexical": "^0.41.0", "lucide-react": "^0.564.0", + "mermaid": "^11.16.1", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 75127ea124e8..5e10a5e47ad8 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -174,6 +174,7 @@ import { BrowserPreviewUnavailableError, } from "../browser/openFileInPreview"; import { resolveLinkTarget } from "../browser/browserLinkTarget"; +import { MermaidDiagram } from "./MermaidDiagram"; interface ChatMarkdownProps { text: string; @@ -897,7 +898,7 @@ function MarkdownCodeBlock({ type="button" variant="ghost" size="icon-xs" - className="chat-markdown-chrome-action" + className="chat-markdown-chrome-action chat-markdown-wrap-action" aria-pressed={wrapped} onClick={() => setWrapped((value) => !value)} aria-label={wrapLabel} @@ -2713,6 +2714,19 @@ function ChatMarkdown({ const language = extractFenceLanguage(codeBlock.className); const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); + const codeFallback = ( + {children}}> + {children}}> + + + + ); + const renderMermaid = !isStreaming && language.toLowerCase() === "mermaid"; return ( - {children}}> - {children}}> - - - + {renderMermaid ? ( + + ) : ( + codeFallback + )} ); }, diff --git a/apps/web/src/components/MermaidDiagram.test.ts b/apps/web/src/components/MermaidDiagram.test.ts new file mode 100644 index 000000000000..0527cad2ff12 --- /dev/null +++ b/apps/web/src/components/MermaidDiagram.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mermaid = vi.hoisted(() => ({ + initialize: vi.fn(), + render: vi.fn(), +})); + +vi.mock("mermaid", () => ({ default: mermaid })); + +import { renderMermaidDiagram } from "./MermaidDiagram"; +import { serializeMarkdownCodeFence } from "../markdown-clipboard"; + +describe("renderMermaidDiagram", () => { + beforeEach(() => { + mermaid.initialize.mockReset(); + mermaid.render.mockReset(); + }); + + it("renders with strict security and the selected theme", async () => { + mermaid.render.mockResolvedValue({ svg: "" }); + + await renderMermaidDiagram("diagram-1", "flowchart LR\nA-->B", "dark"); + + expect(mermaid.initialize).toHaveBeenCalledWith({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true, + secure: [ + "secure", + "securityLevel", + "startOnLoad", + "maxTextSize", + "suppressErrorRendering", + "maxEdges", + "themeCSS", + "fontFamily", + "altFontFamily", + ], + theme: "dark", + }); + expect(mermaid.render).toHaveBeenCalledWith("diagram-1", "flowchart LR\nA-->B"); + }); + + it("continues rendering after an invalid diagram", async () => { + mermaid.render + .mockRejectedValueOnce(new Error("Invalid diagram")) + .mockResolvedValueOnce({ svg: "" }); + + await expect(renderMermaidDiagram("diagram-1", "invalid", "light")).rejects.toThrow(); + await expect( + renderMermaidDiagram("diagram-2", "sequenceDiagram\nA->>B: Hi", "light"), + ).resolves.toEqual({ svg: "" }); + }); + + it("skips queued work after its diagram unmounts", async () => { + let finishFirstRender!: (result: { svg: string }) => void; + mermaid.render.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstRender = resolve; + }), + ); + + const first = renderMermaidDiagram("diagram-1", "flowchart LR\nA-->B", "light"); + await vi.waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(1)); + const second = renderMermaidDiagram("diagram-2", "flowchart LR\nB-->C", "light", () => false); + finishFirstRender({ svg: "" }); + + await first; + await expect(second).resolves.toBeNull(); + expect(mermaid.render).toHaveBeenCalledTimes(1); + }); + + it("chooses a fence longer than backtick runs in copied source", () => { + expect(serializeMarkdownCodeFence("flowchart LR\n%% ``` in a comment", "mermaid")).toBe( + "````mermaid\nflowchart LR\n%% ``` in a comment\n````\n\n", + ); + }); +}); diff --git a/apps/web/src/components/MermaidDiagram.tsx b/apps/web/src/components/MermaidDiagram.tsx new file mode 100644 index 000000000000..d41015a8074e --- /dev/null +++ b/apps/web/src/components/MermaidDiagram.tsx @@ -0,0 +1,105 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import type { RenderResult } from "mermaid"; + +import { serializeMarkdownCodeFence } from "../markdown-clipboard"; + +type MermaidTheme = "light" | "dark"; + +// Mermaid configuration is global, so initialization and rendering must stay paired. +let mermaidRenderQueue = Promise.resolve(); + +export function renderMermaidDiagram( + id: string, + code: string, + theme: MermaidTheme, + isActive: () => boolean = () => true, +) { + const render = async () => { + if (!isActive()) return null; + const { default: mermaid } = await import("mermaid"); + if (!isActive()) return null; + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true, + secure: [ + "secure", + "securityLevel", + "startOnLoad", + "maxTextSize", + "suppressErrorRendering", + "maxEdges", + "themeCSS", + "fontFamily", + "altFontFamily", + ], + theme: theme === "dark" ? "dark" : "default", + }); + return mermaid.render(id, code); + }; + + const result = mermaidRenderQueue.then(render, render); + mermaidRenderQueue = result.then( + () => undefined, + () => undefined, + ); + return result; +} + +export function MermaidDiagram({ + code, + theme, + fallback, +}: { + code: string; + theme: MermaidTheme; + fallback: ReactNode; +}) { + const reactId = useId(); + const diagramId = `t3-mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`; + const renderSequenceRef = useRef(0); + const diagramRef = useRef(null); + const [renderedDiagram, setRenderedDiagram] = useState<{ + code: string; + theme: MermaidTheme; + result: RenderResult; + } | null>(null); + + useEffect(() => { + let active = true; + const renderId = `${diagramId}-${renderSequenceRef.current++}`; + void renderMermaidDiagram(renderId, code, theme, () => active).then( + (nextResult) => { + if (active && nextResult) setRenderedDiagram({ code, theme, result: nextResult }); + }, + () => undefined, + ); + return () => { + active = false; + }; + }, [code, diagramId, theme]); + + useLayoutEffect(() => { + const svg = diagramRef.current?.querySelector("svg"); + const width = svg?.viewBox.baseVal.width ?? 0; + if (svg && Number.isFinite(width) && width > 0) { + svg.style.width = `${Math.ceil(width)}px`; + svg.style.maxWidth = "none"; + } + }); + + const result = + renderedDiagram?.code === code && renderedDiagram.theme === theme + ? renderedDiagram.result + : null; + if (!result) return fallback; + + return ( +
+ ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 63dc9e21c094..5e7e35c2f8d2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1818,6 +1818,23 @@ code { background: transparent !important; } +.chat-markdown .chat-markdown-mermaid { + max-width: 100%; + overflow-x: auto; + padding: 1rem; +} + +.chat-markdown .chat-markdown-codeblock:has(.chat-markdown-mermaid) .chat-markdown-wrap-action { + display: none; +} + +.chat-markdown .chat-markdown-mermaid svg { + display: block; + max-width: none; + height: auto; + margin: 0 auto; +} + /* Diagnostics-style tables: row separators only, uppercase headers, and a scroll-fade container for horizontal overflow. The root chat-markdown wrapping rules (overflow-wrap: anywhere) would let columns shrink to single diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index ec44fcc22292..ec7efe6591e5 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { serializeRenderedMarkdownFragment } from "./markdown-clipboard"; +import { + chatMarkdownClipboardPayload, + serializeRenderedMarkdownFragment, +} from "./markdown-clipboard"; import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { collectAssistantCitations, @@ -13,6 +16,7 @@ const ELEMENT_NODE = 1; class FakeText { readonly nodeType = TEXT_NODE; readonly childNodes: ReadonlyArray = []; + parentElement: FakeElement | null = null; constructor(readonly textContent: string) {} } @@ -20,6 +24,7 @@ class FakeText { class FakeElement { readonly nodeType = ELEMENT_NODE; readonly childNodes: Array = []; + parentElement: FakeElement | null = null; readonly classList = { contains: (name: string) => this.classNames.includes(name), }; @@ -43,10 +48,16 @@ class FakeElement { } append(...children: Array): this { + for (const child of children) child.parentElement = this; this.childNodes.push(...children); return this; } + appendChild(child: T): T { + this.append(child); + return child; + } + getAttribute(name: string): string | null { return this.attributes[name] ?? null; } @@ -55,8 +66,20 @@ class FakeElement { return Object.hasOwn(this.attributes, name); } - closest(): FakeElement | null { - return null; + closest(selector: string): FakeElement | null { + if (selector === "[data-markdown-copy]" && this.hasAttribute("data-markdown-copy")) { + return this; + } + if (this.tagName === selector.toUpperCase()) return this; + return this.parentElement?.closest(selector) ?? null; + } + + querySelectorAll(): ReadonlyArray { + return []; + } + + get innerHTML(): string { + return this.textContent; } /** Supports only the selectors markdown-clipboard actually asks for. */ @@ -262,4 +285,26 @@ describe("serializeRenderedMarkdownFragment", () => { "Hello World (Document template)", ); }); + + it("uses an ancestor's explicit Markdown when a selection only clones its children", () => { + const label = new FakeElement("TEXT").append(new FakeText("A")); + new FakeElement("DIV", [], { + "data-markdown-copy": "```mermaid\nflowchart LR\nA-->B\n```\n\n", + }).append(new FakeElement("SVG").append(label)); + vi.stubGlobal("document", { + createElement: () => new FakeElement("DIV"), + }); + + const payload = chatMarkdownClipboardPayload({ + rangeCount: 1, + getRangeAt: () => ({ + collapsed: false, + cloneContents: () => new FakeElement("SVG").append(new FakeText("A")), + commonAncestorContainer: label, + toString: () => "A", + }), + } as unknown as Selection); + + expect(payload?.text).toBe("```mermaid\nflowchart LR\nA-->B\n```\n\n"); + }); }); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 4a96c8b31d13..c279921d9136 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -71,6 +71,12 @@ function codeFenceFor(code: string): string { return "`".repeat(Math.max(3, longestRun + 1)); } +export function serializeMarkdownCodeFence(code: string, infoString: string): string { + const normalizedCode = code.replace(/\n$/, ""); + const fence = codeFenceFor(normalizedCode); + return `${fence}${infoString}\n${normalizedCode}\n${fence}\n\n`; +} + function resolveCodeBlockLanguage(pre: Element): string | null { const declared = pre.closest("[data-language]")?.getAttribute("data-language") ?? @@ -80,9 +86,7 @@ function resolveCodeBlockLanguage(pre: Element): string | null { } function serializeCodeBlock(pre: Element): string { - const code = (pre.textContent ?? "").replace(/\n$/, ""); - const fence = codeFenceFor(code); - return `${fence}${resolveCodeBlockLanguage(pre) ?? ""}\n${code}\n${fence}\n\n`; + return serializeMarkdownCodeFence(pre.textContent ?? "", resolveCodeBlockLanguage(pre) ?? ""); } function serializeTableCell(cell: Element): string { @@ -391,6 +395,14 @@ export function chatMarkdownClipboardPayload( const ancestor = range.commonAncestorContainer; const ancestorElement = ancestor.nodeType === Node.ELEMENT_NODE ? (ancestor as Element) : ancestor.parentElement; + const markdownCopy = ancestorElement + ?.closest("[data-markdown-copy]") + ?.getAttribute("data-markdown-copy"); + if (markdownCopy != null) { + texts.push(markdownCopy); + htmls.push(sanitizedHtmlFrom(container)); + continue; + } if (ancestorElement?.closest("pre")) { const text = range.toString(); if (text) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 032a1e05ff6d..7442a99070a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -632,6 +632,9 @@ importers: lucide-react: specifier: ^0.564.0 version: 0.564.0(react@19.2.6) + mermaid: + specifier: ^11.16.1 + version: 11.17.2 react: specifier: 19.2.6 version: 19.2.6 @@ -1011,6 +1014,9 @@ packages: '@alchemy.run/node-utils@0.0.5': resolution: {integrity: sha512-5agdhQxWBodxa5hDRyjnpx91RTU3g+qd5fxYB7uNDCaOzB0XC47UU/KHR6zf6jhz/NXf61gJS+vhYwn8NHeRoQ==} + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/claude-agent-sdk@0.3.170': resolution: {integrity: sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA==} engines: {node: '>=18.0.0'} @@ -1647,6 +1653,9 @@ packages: '@blazediff/core@1.9.1': resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bruits/satteri-darwin-arm64@0.9.3': resolution: {integrity: sha512-dRUZZrdwh1asfTOyM1nDNmzolhnHtlIFpqYrl1Tdd3YVcaebKmrfJgGL7NAoGPjbEwYmZxaugrxA0uzw83c0dw==} cpu: [arm64] @@ -1700,6 +1709,9 @@ packages: resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} engines: {node: '>=18'} + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@clack/core@0.5.0': resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} @@ -2752,6 +2764,12 @@ packages: '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.4': + resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -3133,6 +3151,9 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@mermaid-js/parser@1.2.1': + resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -4767,6 +4788,99 @@ packages: '@types/culori@4.0.1': resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -4791,6 +4905,9 @@ packages: '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hammerjs@2.0.46': resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} @@ -4862,6 +4979,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -4936,6 +5056,9 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vercel/config@0.3.0': resolution: {integrity: sha512-Tf5k5y2F478oTiQcU5R8Ntix1UejE6NdduZnI7aa1XXxjCtifX1XdRS/D2uTjiQAwIL3pLa1LSAN80ABbba+TQ==} hasBin: true @@ -5965,6 +6088,10 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} @@ -6046,6 +6173,12 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -6095,6 +6228,165 @@ packages: resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.2: + resolution: {integrity: sha512-Cm2jaj1X/PBNlzV9yH8zcfGOxO7U+CJ/+mxSBVPSchLaugdp4jtlGx5qaHtPRZ6tgiZ5P+o1XoRfJA+ba6KM3g==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + debounce-fn@4.0.0: resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} engines: {node: '>=10'} @@ -6157,6 +6449,9 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -6230,6 +6525,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.14: + resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -6959,6 +7257,9 @@ packages: resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} hasBin: true + fastdom@1.0.12: + resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==} + fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} @@ -7169,6 +7470,9 @@ packages: h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -7301,6 +7605,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -7324,6 +7632,9 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + indent-string@5.0.0: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} @@ -7355,6 +7666,13 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -7606,9 +7924,16 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -7624,6 +7949,12 @@ packages: resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} hasBin: true + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -7734,6 +8065,9 @@ packages: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -7814,6 +8148,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} @@ -7909,6 +8248,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mermaid@11.17.2: + resolution: {integrity: sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==} + metro-babel-transformer@0.84.4: resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -8581,6 +8923,9 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -8729,6 +9074,12 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -9275,6 +9626,9 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown@1.0.0-rc.17: resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -9290,6 +9644,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -9297,6 +9654,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -9575,6 +9935,9 @@ packages: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} + strictdom@1.0.1: + resolution: {integrity: sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -9617,6 +9980,9 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} @@ -9789,6 +10155,10 @@ packages: ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -10526,6 +10896,11 @@ snapshots: '@alchemy.run/node-utils@0.0.5': {} + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.2.4 + '@anthropic-ai/claude-agent-sdk@0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) @@ -11421,6 +11796,8 @@ snapshots: '@blazediff/core@1.9.1': {} + '@braintree/sanitize-url@7.1.2': {} + '@bruits/satteri-darwin-arm64@0.9.3': optional: true @@ -11456,6 +11833,8 @@ snapshots: dependencies: fontkitten: 1.0.3 + '@chevrotain/types@11.1.2': {} + '@clack/core@0.5.0': dependencies: picocolors: 1.1.1 @@ -12775,6 +13154,14 @@ snapshots: '@iarna/toml@2.2.5': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.4': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@img/colour@1.1.0': optional: true @@ -13199,6 +13586,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@mermaid-js/parser@1.2.1': + dependencies: + '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.27) @@ -14643,6 +15034,123 @@ snapshots: '@types/culori@4.0.1': {} + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -14674,6 +15182,8 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/geojson@7946.0.16': {} + '@types/hammerjs@2.0.46': {} '@types/hast@3.0.4': @@ -14750,6 +15260,9 @@ snapshots: '@types/statuses@2.0.6': optional: true + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -14807,6 +15320,11 @@ snapshots: '@ungap/structured-clone@1.3.1': {} + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vercel/config@0.3.0': dependencies: '@vercel/routing-utils': 6.2.0 @@ -15913,6 +16431,8 @@ snapshots: commander@7.2.0: {} + commander@8.3.0: {} + commander@9.5.0: optional: true @@ -15993,6 +16513,14 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cross-dirname@0.1.0: optional: true @@ -16046,6 +16574,192 @@ snapshots: culori@4.0.2: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.2): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.2 + + cytoscape-fcose@2.2.0(cytoscape@3.34.2): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.2 + + cytoscape@3.34.2: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + dayjs@1.11.23: {} + debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 @@ -16096,6 +16810,10 @@ snapshots: defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} denque@2.1.0: {} @@ -16158,6 +16876,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.14: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -17040,6 +17762,10 @@ snapshots: strnum: 2.3.0 xml-naming: 0.1.0 + fastdom@1.0.12: + dependencies: + strictdom: 1.0.1 + fastest-levenshtein@1.0.16: {} fastq@1.20.1: @@ -17299,6 +18025,8 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 + hachure-fill@0.5.2: {} + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -17509,6 +18237,10 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -17526,6 +18258,8 @@ snapshots: immediate@3.0.6: {} + import-meta-resolve@4.2.0: {} + indent-string@5.0.0: {} inflight@1.0.6: @@ -17574,6 +18308,10 @@ snapshots: inline-style-parser@0.2.7: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -17781,10 +18519,16 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + kleur@3.0.3: {} kleur@4.1.5: {} @@ -17793,6 +18537,10 @@ snapshots: lan-network@0.2.1: {} + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lazy-val@1.0.5: {} leven@3.1.0: {} @@ -17889,6 +18637,8 @@ snapshots: p-locate: 3.0.0 path-exists: 3.0.0 + lodash-es@4.18.1: {} + lodash.debounce@4.0.8: {} lodash.escaperegexp@4.1.2: {} @@ -17956,6 +18706,8 @@ snapshots: markdown-table@3.0.4: {} + marked@16.4.2: {} + marky@1.3.0: {} matcher@3.0.0: @@ -18162,6 +18914,31 @@ snapshots: merge2@1.4.1: {} + mermaid@11.17.2: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.4 + '@mermaid-js/parser': 1.2.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.2 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.2) + cytoscape-fcose: 2.2.0(cytoscape@3.34.2) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.23 + dompurify: 3.4.14 + es-toolkit: 1.47.0 + fastdom: 1.0.12 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.1 + metro-babel-transformer@0.84.4: dependencies: '@babel/core': 7.29.7 @@ -19194,6 +19971,8 @@ snapshots: path-browserify@1.0.1: {} + path-data-parser@0.1.0: {} + path-exists@3.0.0: {} path-expression-matcher@1.5.0: {} @@ -19329,6 +20108,13 @@ snapshots: pngjs@7.0.0: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss@8.5.15: dependencies: nanoid: 3.3.12 @@ -20033,6 +20819,8 @@ snapshots: sprintf-js: 1.1.3 optional: true + robust-predicates@3.0.3: {} + rolldown@1.0.0-rc.17: dependencies: '@oxc-project/types': 0.127.0 @@ -20108,6 +20896,13 @@ snapshots: fsevents: 2.3.3 optional: true + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + router@2.2.0: dependencies: debug: 4.4.3 @@ -20122,6 +20917,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -20450,6 +21247,8 @@ snapshots: strict-uri-encode@2.0.0: {} + strictdom@1.0.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -20500,6 +21299,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + stylis@4.4.0: {} + sumchecker@3.0.1: dependencies: debug: 4.4.3 @@ -20669,6 +21470,8 @@ snapshots: ts-algebra@2.0.0: {} + ts-dedent@2.3.0: {} + tslib@2.8.1: {} type-fest@0.13.1: From e3457e698a37d0451c8702b2938cf2ceb0ed0ec4 Mon Sep 17 00:00:00 2001 From: Aniket Vishwakarma Date: Fri, 4 Sep 2026 14:56:34 +0530 Subject: [PATCH 2/6] fix(web): simplify MermaidDiagram state via key remount Remount per code and theme instead of tracking code/theme match in state. Drops the sequence counter and the match branch; a theme flip remounts through the code fallback instead of flashing a stale diagram. Model: Muse Spark in T3 Code. --- apps/web/src/components/ChatMarkdown.tsx | 2 +- apps/web/src/components/MermaidDiagram.tsx | 17 ++++------------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5e10a5e47ad8..0f0c8a8f90c5 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2736,7 +2736,7 @@ function ChatMarkdown({ > {renderMermaid ? ( (null); - const [renderedDiagram, setRenderedDiagram] = useState<{ - code: string; - theme: MermaidTheme; - result: RenderResult; - } | null>(null); + // Remounted per code and theme via key, so the stored result always matches. + const [result, setResult] = useState(null); useEffect(() => { let active = true; - const renderId = `${diagramId}-${renderSequenceRef.current++}`; - void renderMermaidDiagram(renderId, code, theme, () => active).then( + void renderMermaidDiagram(diagramId, code, theme, () => active).then( (nextResult) => { - if (active && nextResult) setRenderedDiagram({ code, theme, result: nextResult }); + if (active && nextResult) setResult(nextResult); }, () => undefined, ); @@ -88,10 +83,6 @@ export function MermaidDiagram({ } }); - const result = - renderedDiagram?.code === code && renderedDiagram.theme === theme - ? renderedDiagram.result - : null; if (!result) return fallback; return ( From e2be074332b62e5221e32f1b6959d5beb306683f Mon Sep 17 00:00:00 2001 From: Aniket Vishwakarma Date: Fri, 4 Sep 2026 15:28:59 +0530 Subject: [PATCH 3/6] fix(web): fit Mermaid diagrams to the chat column and cache renders Mermaid already emits width="100%" plus an inline max-width equal to the diagram's natural width, so the SVG fits its container on its own. The layout effect that forced the viewBox width in pixels and cleared max-width made every wide diagram overflow into a horizontal scroll. Drop it and let the height follow the viewBox. Cache rendered SVG by theme and source (50 entries). The chat list unmounts off-screen rows, so scrolling back past a diagram re-ran Mermaid, flashed the code fallback, and jumped the row height. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/MermaidDiagram.tsx | 47 +++++++++++++--------- apps/web/src/index.css | 4 +- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/MermaidDiagram.tsx b/apps/web/src/components/MermaidDiagram.tsx index 718db8bcb3ad..dd22143f31c1 100644 --- a/apps/web/src/components/MermaidDiagram.tsx +++ b/apps/web/src/components/MermaidDiagram.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useId, useState, type ReactNode } from "react"; import type { RenderResult } from "mermaid"; import { serializeMarkdownCodeFence } from "../markdown-clipboard"; @@ -8,6 +8,24 @@ type MermaidTheme = "light" | "dark"; // Mermaid configuration is global, so initialization and rendering must stay paired. let mermaidRenderQueue = Promise.resolve(); +// The chat list unmounts off-screen rows. Without this, every scroll back past a +// diagram flashed the code fallback, re-ran Mermaid, and jumped the row height. +const MAX_CACHED_DIAGRAMS = 50; +const renderedDiagrams = new Map(); + +function diagramCacheKey(theme: MermaidTheme, code: string) { + return `${theme}\n${code}`; +} + +function rememberRenderedDiagram(key: string, svg: string) { + renderedDiagrams.delete(key); + renderedDiagrams.set(key, svg); + if (renderedDiagrams.size > MAX_CACHED_DIAGRAMS) { + const oldest = renderedDiagrams.keys().next().value; + if (oldest !== undefined) renderedDiagrams.delete(oldest); + } +} + export function renderMermaidDiagram( id: string, code: string, @@ -57,40 +75,33 @@ export function MermaidDiagram({ }) { const reactId = useId(); const diagramId = `t3-mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`; - const diagramRef = useRef(null); + const cacheKey = diagramCacheKey(theme, code); // Remounted per code and theme via key, so the stored result always matches. - const [result, setResult] = useState(null); + const [svg, setSvg] = useState(() => renderedDiagrams.get(cacheKey) ?? null); useEffect(() => { + if (renderedDiagrams.has(cacheKey)) return; let active = true; void renderMermaidDiagram(diagramId, code, theme, () => active).then( - (nextResult) => { - if (active && nextResult) setResult(nextResult); + (result: RenderResult | null) => { + if (!result) return; + rememberRenderedDiagram(cacheKey, result.svg); + if (active) setSvg(result.svg); }, () => undefined, ); return () => { active = false; }; - }, [code, diagramId, theme]); - - useLayoutEffect(() => { - const svg = diagramRef.current?.querySelector("svg"); - const width = svg?.viewBox.baseVal.width ?? 0; - if (svg && Number.isFinite(width) && width > 0) { - svg.style.width = `${Math.ceil(width)}px`; - svg.style.maxWidth = "none"; - } - }); + }, [cacheKey, code, diagramId, theme]); - if (!result) return fallback; + if (!svg) return fallback; return (
); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 5e7e35c2f8d2..9e27c37c4f92 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1828,9 +1828,11 @@ code { display: none; } +/* Mermaid emits width="100%" plus an inline max-width equal to the diagram's + native width, so the SVG shrinks to fit the chat column and never grows past + its natural size. Only the height needs help: it must follow the viewBox. */ .chat-markdown .chat-markdown-mermaid svg { display: block; - max-width: none; height: auto; margin: 0 auto; } From cb55c706f2ffd49e0299bb502d778d3ab7a1c4d7 Mon Sep 17 00:00:00 2001 From: Aniket Vishwakarma Date: Fri, 11 Sep 2026 17:44:02 +0530 Subject: [PATCH 4/6] feat(web): diagram source toggle and click-to-expand mermaid popup Mermaid fences get a Show source / Show diagram toggle, mmd alias support, and a click-to-expand scrollable overlay at natural size. Invalid fences fall back to plain code with no error text. Perf: mermaid lib stays lazy-loaded, renders serialize through the existing queue, SVGs cache (50 LRU) and reuse on scroll/remount/popup, off-screen diagrams defer via IntersectionObserver, and streaming code blocks pay no toggle state updates. --- apps/web/src/components/ChatMarkdown.test.tsx | 36 +++ apps/web/src/components/ChatMarkdown.tsx | 126 ++++++-- .../web/src/components/MermaidDiagram.test.ts | 79 ------ .../src/components/MermaidDiagram.test.tsx | 191 +++++++++++++ apps/web/src/components/MermaidDiagram.tsx | 268 +++++++++++++++++- apps/web/src/index.css | 15 + 6 files changed, 595 insertions(+), 120 deletions(-) delete mode 100644 apps/web/src/components/MermaidDiagram.test.ts create mode 100644 apps/web/src/components/MermaidDiagram.test.tsx diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index c3e536d70ae2..0f2a4cfc3d39 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -497,3 +497,39 @@ describe("ChatMarkdown Windows file links", () => { expect(html).not.toContain("chat-markdown-file-link"); }); }); + +describe("ChatMarkdown mermaid fences", () => { + it("renders mermaid fences as diagrams with a source toggle", () => { + const html = renderToStaticMarkup( + B\n```"} />, + ); + + expect(html).toContain('data-language="mermaid"'); + expect(html).toContain('data-mermaid="diagram"'); + expect(html).toContain("Show source"); + }); + + it("recognizes the mmd alias", () => { + const html = renderToStaticMarkup( + B\n```"} />, + ); + + expect(html).toContain('data-language="mmd"'); + expect(html).toContain('data-mermaid="diagram"'); + expect(html).toContain("Show source"); + }); + + it("keeps mermaid source while the message is streaming", () => { + const html = renderToStaticMarkup( + B\n```"} + />, + ); + + expect(html).toContain('data-language="mermaid"'); + expect(html).not.toContain("data-mermaid"); + expect(html).not.toContain("Show source"); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 0f0c8a8f90c5..5cff4e3fb8ae 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2,7 +2,9 @@ import { useAtomValue } from "@effect/atom-react"; import { CheckIcon, ChevronRightIcon, + Code2Icon, CopyIcon, + EyeIcon, FileSpreadsheetIcon, FileTextIcon, GlobeIcon, @@ -461,6 +463,12 @@ function extractFenceLanguage(className: string | undefined): string { return raw === "gitignore" ? "ini" : raw; } +/** Mermaid fences render as diagrams; `mmd` is the same language under its common alias. */ +export function isMermaidFenceLanguage(language: string): boolean { + const normalized = language.trim().toLowerCase(); + return normalized === "mermaid" || normalized === "mmd"; +} + const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i; const FENCE_FILENAME_TOKEN_REGEX = /^[\w@][\w@./-]*\.[A-Za-z0-9]+$/; @@ -824,19 +832,44 @@ function MarkdownCodeBlock({ language, fenceTitle, theme, + renderDiagram = false, children, }: { code: string; language: string; fenceTitle: string | null; theme: "light" | "dark"; + renderDiagram?: boolean; children: ReactNode; }) { const [copied, setCopied] = useState(false); const [wrapped, setWrapped] = useState(readInitialWordWrapSetting); + const [showSource, setShowSource] = useState(false); + // A failed diagram silently falls back to its source view. No error text: + // mermaid parse errors echo the raw source and read as app breakage. + const [diagramFailed, setDiagramFailed] = useState(false); const copiedTimerRef = useRef | null>(null); const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; const copyLabel = copied ? "Copied" : "Copy code"; + const showDiagram = renderDiagram && !showSource && !diagramFailed; + const sourceToggleLabel = showSource ? "Show diagram" : "Show source"; + + // Reset the toggle when the fence content changes. Gated on renderDiagram so + // streaming code blocks (which re-render per token) pay no state updates + // here; non-streaming messages are static, so this fires at most once when a + // completed fence first becomes a diagram, plus on rare edits afterwards. + // Done during render so a completed message never keeps a stale view. + const [lastCode, setLastCode] = useState(code); + if (renderDiagram && lastCode !== code) { + setLastCode(code); + setShowSource(false); + setDiagramFailed(false); + } + + const handleDiagramError = useCallback(() => { + setDiagramFailed(true); + setShowSource(true); + }, []); const handleCopy = useCallback(() => { if (typeof navigator === "undefined" || navigator.clipboard == null) { @@ -880,6 +913,7 @@ function MarkdownCodeBlock({
@@ -891,24 +925,53 @@ function MarkdownCodeBlock({ /> - - setWrapped((value) => !value)} - aria-label={wrapLabel} - /> - } - > - - - {wrapLabel} - + {renderDiagram ? ( + + { + if (showSource) { + setDiagramFailed(false); + setShowSource(false); + } else { + setShowSource(true); + } + }} + aria-label={sourceToggleLabel} + /> + } + > + {showSource ? : } + + {sourceToggleLabel} + + ) : null} + {showDiagram ? null : ( + + setWrapped((value) => !value)} + aria-label={wrapLabel} + /> + } + > + + + {wrapLabel} + + )}
- {children} + {showDiagram ? ( + + ) : ( + children + )}
); } @@ -2726,24 +2800,16 @@ function ChatMarkdown({ ); - const renderMermaid = !isStreaming && language.toLowerCase() === "mermaid"; + const renderMermaid = !isStreaming && isMermaidFenceLanguage(language); return ( - {renderMermaid ? ( - - ) : ( - codeFallback - )} + {codeFallback} ); }, diff --git a/apps/web/src/components/MermaidDiagram.test.ts b/apps/web/src/components/MermaidDiagram.test.ts deleted file mode 100644 index 0527cad2ff12..000000000000 --- a/apps/web/src/components/MermaidDiagram.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const mermaid = vi.hoisted(() => ({ - initialize: vi.fn(), - render: vi.fn(), -})); - -vi.mock("mermaid", () => ({ default: mermaid })); - -import { renderMermaidDiagram } from "./MermaidDiagram"; -import { serializeMarkdownCodeFence } from "../markdown-clipboard"; - -describe("renderMermaidDiagram", () => { - beforeEach(() => { - mermaid.initialize.mockReset(); - mermaid.render.mockReset(); - }); - - it("renders with strict security and the selected theme", async () => { - mermaid.render.mockResolvedValue({ svg: "" }); - - await renderMermaidDiagram("diagram-1", "flowchart LR\nA-->B", "dark"); - - expect(mermaid.initialize).toHaveBeenCalledWith({ - startOnLoad: false, - securityLevel: "strict", - suppressErrorRendering: true, - secure: [ - "secure", - "securityLevel", - "startOnLoad", - "maxTextSize", - "suppressErrorRendering", - "maxEdges", - "themeCSS", - "fontFamily", - "altFontFamily", - ], - theme: "dark", - }); - expect(mermaid.render).toHaveBeenCalledWith("diagram-1", "flowchart LR\nA-->B"); - }); - - it("continues rendering after an invalid diagram", async () => { - mermaid.render - .mockRejectedValueOnce(new Error("Invalid diagram")) - .mockResolvedValueOnce({ svg: "" }); - - await expect(renderMermaidDiagram("diagram-1", "invalid", "light")).rejects.toThrow(); - await expect( - renderMermaidDiagram("diagram-2", "sequenceDiagram\nA->>B: Hi", "light"), - ).resolves.toEqual({ svg: "" }); - }); - - it("skips queued work after its diagram unmounts", async () => { - let finishFirstRender!: (result: { svg: string }) => void; - mermaid.render.mockImplementationOnce( - () => - new Promise((resolve) => { - finishFirstRender = resolve; - }), - ); - - const first = renderMermaidDiagram("diagram-1", "flowchart LR\nA-->B", "light"); - await vi.waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(1)); - const second = renderMermaidDiagram("diagram-2", "flowchart LR\nB-->C", "light", () => false); - finishFirstRender({ svg: "" }); - - await first; - await expect(second).resolves.toBeNull(); - expect(mermaid.render).toHaveBeenCalledTimes(1); - }); - - it("chooses a fence longer than backtick runs in copied source", () => { - expect(serializeMarkdownCodeFence("flowchart LR\n%% ``` in a comment", "mermaid")).toBe( - "````mermaid\nflowchart LR\n%% ``` in a comment\n````\n\n", - ); - }); -}); diff --git a/apps/web/src/components/MermaidDiagram.test.tsx b/apps/web/src/components/MermaidDiagram.test.tsx new file mode 100644 index 000000000000..95d504c888d5 --- /dev/null +++ b/apps/web/src/components/MermaidDiagram.test.tsx @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { renderToStaticMarkup } from "react-dom/server"; + +const mermaid = vi.hoisted(() => ({ + initialize: vi.fn(), + render: vi.fn(), +})); + +vi.mock("mermaid", () => ({ default: mermaid })); + +import { + MermaidDiagram, + MermaidDiagramDialog, + cacheRenderedDiagram, + mermaidSvgNaturalSize, + renderMermaidDiagram, +} from "./MermaidDiagram"; +import { serializeMarkdownCodeFence } from "../markdown-clipboard"; + +describe("renderMermaidDiagram", () => { + beforeEach(() => { + mermaid.initialize.mockReset(); + mermaid.render.mockReset(); + }); + + it("renders with strict security and the selected theme", async () => { + mermaid.render.mockResolvedValue({ svg: "" }); + + await renderMermaidDiagram("diagram-1", "flowchart LR\nA-->B", "dark"); + + expect(mermaid.initialize).toHaveBeenCalledWith({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true, + secure: [ + "secure", + "securityLevel", + "startOnLoad", + "maxTextSize", + "suppressErrorRendering", + "maxEdges", + "themeCSS", + "fontFamily", + "altFontFamily", + ], + theme: "dark", + }); + expect(mermaid.render).toHaveBeenCalledWith("diagram-1", "flowchart LR\nA-->B"); + }); + + it("continues rendering after an invalid diagram", async () => { + mermaid.render + .mockRejectedValueOnce(new Error("Invalid diagram")) + .mockResolvedValueOnce({ svg: "" }); + + await expect(renderMermaidDiagram("diagram-1", "invalid", "light")).rejects.toThrow(); + await expect( + renderMermaidDiagram("diagram-2", "sequenceDiagram\nA->>B: Hi", "light"), + ).resolves.toEqual({ svg: "" }); + }); + + it("skips queued work after its diagram unmounts", async () => { + let finishFirstRender!: (result: { svg: string }) => void; + mermaid.render.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstRender = resolve; + }), + ); + + const first = renderMermaidDiagram("diagram-1", "flowchart LR\nA-->B", "light"); + await vi.waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(1)); + const second = renderMermaidDiagram("diagram-2", "flowchart LR\nB-->C", "light", () => false); + finishFirstRender({ svg: "" }); + + await first; + await expect(second).resolves.toBeNull(); + expect(mermaid.render).toHaveBeenCalledTimes(1); + }); + + it("chooses a fence longer than backtick runs in copied source", () => { + expect(serializeMarkdownCodeFence("flowchart LR\n%% ``` in a comment", "mermaid")).toBe( + "````mermaid\nflowchart LR\n%% ``` in a comment\n````\n\n", + ); + }); +}); + +describe("MermaidDiagram expand", () => { + const code = "flowchart LR\nExpandA-->ExpandB"; + + beforeEach(() => { + mermaid.initialize.mockReset(); + mermaid.render.mockReset(); + // Seeds the module-level SVG cache so SSR reads the rendered diagram + // without running effects, exactly like a remount after scrolling. + cacheRenderedDiagram("dark", code, "expanded-diagram"); + }); + + it("renders the cached diagram as an expandable button carrying source for copy", () => { + mermaid.render.mockClear(); + const html = renderToStaticMarkup( + fallback
} />, + ); + + expect(mermaid.render).not.toHaveBeenCalled(); + expect(html).toContain("expanded-diagram"); + expect(html).toContain('role="button"'); + expect(html).toContain('aria-label="Expand diagram"'); + expect(html).toContain("cursor-zoom-in"); + expect(html).toContain("data-markdown-copy"); + expect(html).toContain("flowchart LR"); + expect(html).not.toContain("fallback"); + }); + + it("renders the expanded dialog scrollable with the same SVG and a source copy", () => { + const html = renderToStaticMarkup( + undefined} + />, + ); + + expect(html).toContain('role="dialog"'); + expect(html).toContain("expanded-diagram"); + expect(html).toContain("overflow-auto"); + expect(html).toContain("chat-markdown-mermaid-dialog"); + expect(html).toContain('aria-label="Close diagram preview"'); + expect(html).toContain('aria-label="Copy diagram source"'); + expect(html).toContain("data-markdown-copy"); + }); +}); + +describe("mermaidSvgNaturalSize", () => { + it("reads the natural size from the viewBox", () => { + expect( + mermaidSvgNaturalSize( + '', + ), + ).toEqual({ width: 444.890625, height: 174 }); + }); + + it("rejects missing or degenerate viewBoxes", () => { + expect(mermaidSvgNaturalSize("no viewBox")).toBeNull(); + expect(mermaidSvgNaturalSize('')).toBeNull(); + expect(mermaidSvgNaturalSize('')).toBeNull(); + }); +}); + +describe("MermaidDiagramDialog sizing", () => { + it("fixes the scroll container to the diagram natural width", () => { + const html = renderToStaticMarkup( + undefined} + />, + ); + + expect(html).toContain("width:573.2px"); + expect(html).toContain("chat-markdown-mermaid-dialog"); + }); +}); + +describe("MermaidDiagram visibility gating", () => { + const code = "flowchart LR\nGatedA-->GatedB"; + + it("defers rendering until the diagram nears the viewport", () => { + vi.stubGlobal( + "IntersectionObserver", + class { + observe() {} + disconnect() {} + }, + ); + try { + const html = renderToStaticMarkup( + gated-fallback
} />, + ); + + expect(html).toContain("gated-fallback"); + expect(html).not.toContain('role="button"'); + expect(html).not.toContain("Expand diagram"); + // The fallback is wrapped in the observation host that triggers the + // render on near-viewport entry. Bare fallback would be a single div. + expect(html).toBe("
gated-fallback
"); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/apps/web/src/components/MermaidDiagram.tsx b/apps/web/src/components/MermaidDiagram.tsx index dd22143f31c1..aa06d71b4b32 100644 --- a/apps/web/src/components/MermaidDiagram.tsx +++ b/apps/web/src/components/MermaidDiagram.tsx @@ -1,9 +1,14 @@ -import { useEffect, useId, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { CheckIcon, CopyIcon, XIcon } from "lucide-react"; import type { RenderResult } from "mermaid"; import { serializeMarkdownCodeFence } from "../markdown-clipboard"; +import { Button } from "./ui/button"; +import { composerFloatingLayerProps } from "./chat/composerEventScope"; +import { isContextMenuOpen } from "../contextMenuFallback"; -type MermaidTheme = "light" | "dark"; +export type MermaidTheme = "light" | "dark"; // Mermaid configuration is global, so initialization and rendering must stay paired. let mermaidRenderQueue = Promise.resolve(); @@ -26,6 +31,14 @@ function rememberRenderedDiagram(key: string, svg: string) { } } +/** + * Stores a rendered diagram so remounts (e.g. scrolling back in chat) reuse + * it instead of re-running Mermaid. Exported for tests. + */ +export function cacheRenderedDiagram(theme: MermaidTheme, code: string, svg: string) { + rememberRenderedDiagram(diagramCacheKey(theme, code), svg); +} + export function renderMermaidDiagram( id: string, code: string, @@ -64,23 +77,223 @@ export function renderMermaidDiagram( return result; } +function mermaidErrorMessage(cause: unknown): string { + if (cause instanceof Error && cause.message.trim().length > 0) { + return cause.message; + } + return "Couldn't render diagram."; +} + +const MERMAID_VIEWBOX_PATTERN = /]*\bviewBox\s*=\s*"([^"]+)"/; + +/** + * Natural pixel size from the SVG viewBox. Mermaid emits `width="100%"`, so + * without this the expanded overlay can only shrink-to-fit and collapses + * wide diagrams. Pure string parsing: no DOM needed, SSR-safe. + */ +export function mermaidSvgNaturalSize(svg: string): { width: number; height: number } | null { + const viewBox = MERMAID_VIEWBOX_PATTERN.exec(svg)?.[1]; + const dimensions = viewBox?.trim().split(/\s+/).map(Number); + const width = dimensions?.[2]; + const height = dimensions?.[3]; + if ( + dimensions?.length !== 4 || + !Number.isFinite(width) || + !Number.isFinite(height) || + (width ?? 0) <= 0 || + (height ?? 0) <= 0 + ) { + return null; + } + return { width: width as number, height: height as number }; +} + +export function MermaidDiagramDialog({ + svg, + copyMarkdown, + onClose, +}: { + svg: string; + copyMarkdown: string; + onClose: () => void; +}) { + const [copied, setCopied] = useState(false); + const copiedTimerRef = useRef | null>(null); + + const handleCopy = useCallback(() => { + if (typeof navigator === "undefined" || navigator.clipboard == null) { + return; + } + void navigator.clipboard + .writeText(copyMarkdown) + .then(() => { + if (copiedTimerRef.current != null) { + clearTimeout(copiedTimerRef.current); + } + setCopied(true); + copiedTimerRef.current = setTimeout(() => { + setCopied(false); + copiedTimerRef.current = null; + }, 1200); + }) + .catch(() => undefined); + }, [copyMarkdown]); + + useEffect( + () => () => { + if (copiedTimerRef.current != null) { + clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = null; + } + }, + [], + ); + + // The element that opened the preview gets focus back on close. Without + // this a close leaves focus on the unmounted dialog. + const openerRef = useRef(null); + useEffect(() => { + openerRef.current = document.activeElement; + return () => { + const opener = openerRef.current; + if (opener instanceof HTMLElement && opener.isConnected) { + opener.focus({ preventScroll: true }); + } + }; + }, []); + + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented || isContextMenuOpen()) { + return; + } + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + // Definite container width so the SVG's `width="100%"` resolves to its + // natural size instead of collapsing in the shrink-to-fit flex layout. + // Capped by max-w-[92vw]; larger diagrams scroll inside the overlay. + const naturalSize = useMemo(() => mermaidSvgNaturalSize(svg), [svg]); + + const dialog = ( +
+ + +
+
+
+ + ); + // Portals keep the overlay out of clipped chat-row stacking contexts. Fall + // back to inline rendering where `document` is unavailable (SSR/tests). + return typeof document === "undefined" ? dialog : createPortal(dialog, document.body); +} + export function MermaidDiagram({ code, + language = "mermaid", theme, fallback, + onError, }: { code: string; + language?: string; theme: MermaidTheme; fallback: ReactNode; + onError?: (message: string) => void; }) { const reactId = useId(); const diagramId = `t3-mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`; const cacheKey = diagramCacheKey(theme, code); // Remounted per code and theme via key, so the stored result always matches. const [svg, setSvg] = useState(() => renderedDiagrams.get(cacheKey) ?? null); + const [expanded, setExpanded] = useState(false); + const copyMarkdown = serializeMarkdownCodeFence(code, language); + // Mermaid runs only for diagrams at or near the viewport. Chat unmounts + // far rows, but file previews and PR bodies mount whole documents, so + // without this every diagram below the fold would render on open. + const [inView, setInView] = useState( + () => renderedDiagrams.has(cacheKey) || typeof IntersectionObserver === "undefined", + ); + const hostRef = useRef(null); + + // Adopt a diagram cached after this mount's initializers ran (a sibling won + // the render race). Render-phase adjustment, not an effect, so no extra + // commit cycle and no work while streaming lists re-render around us. + const cachedSvg = renderedDiagrams.get(cacheKey); + if (cachedSvg !== undefined && cachedSvg !== svg) { + setSvg(cachedSvg); + } + if (!inView && cachedSvg !== undefined) { + setInView(true); + } useEffect(() => { - if (renderedDiagrams.has(cacheKey)) return; + if (inView) return undefined; + const host = hostRef.current; + if (host === null) return undefined; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setInView(true); + observer.disconnect(); + } + }, + // Start rendering just before the diagram scrolls into view. + { rootMargin: "400px" }, + ); + observer.observe(host); + return () => observer.disconnect(); + // cacheKey omitted: the parent remounts per code and theme, so it never + // changes within a mount and would only refire the observer for nothing. + }, [inView]); + + useEffect(() => { + if (!inView || svg !== null) return undefined; let active = true; void renderMermaidDiagram(diagramId, code, theme, () => active).then( (result: RenderResult | null) => { @@ -88,20 +301,53 @@ export function MermaidDiagram({ rememberRenderedDiagram(cacheKey, result.svg); if (active) setSvg(result.svg); }, - () => undefined, + (cause: unknown) => { + if (!active) return; + onError?.(mermaidErrorMessage(cause)); + }, ); return () => { active = false; }; - }, [cacheKey, code, diagramId, theme]); + }, [cacheKey, code, diagramId, inView, onError, svg, theme]); - if (!svg) return fallback; + if (!svg) { + // Pre-render (code fallback) doubles as the observation host so the + // diagram starts rendering just before it scrolls into view. + return
{fallback}
; + } return ( -
+ <> +
{ + // Links inside the SVG (e.g. `click node href`) keep working, and a + // drag that selects diagram text must not pop the overlay open. + if (event.target instanceof Element && event.target.closest("a") !== null) return; + const selection = window.getSelection(); + if (selection !== null && !selection.isCollapsed) return; + setExpanded(true); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setExpanded(true); + } + }} + dangerouslySetInnerHTML={{ __html: svg }} + /> + {expanded ? ( + setExpanded(false)} + /> + ) : null} + ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9e27c37c4f92..04d58edcda3b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1837,6 +1837,21 @@ code { margin: 0 auto; } +/* The expanded overlay is portaled outside `.chat-markdown`, so it needs its + own rules. The container gets the diagram's natural width inline (capped by + max-w-[92vw]) and scrolls, giving large diagrams a bigger canvas than the + chat column. */ +.chat-markdown-mermaid-dialog { + max-width: 100%; + overflow: auto; +} + +.chat-markdown-mermaid-dialog svg { + display: block; + height: auto; + margin: 0 auto; +} + /* Diagnostics-style tables: row separators only, uppercase headers, and a scroll-fade container for horizontal overflow. The root chat-markdown wrapping rules (overflow-wrap: anywhere) would let columns shrink to single From 6118d2d4acbb5f7cdf5cebe6f65c6492966e860d Mon Sep 17 00:00:00 2001 From: Aniket Vishwakarma Date: Fri, 11 Sep 2026 19:21:46 +0530 Subject: [PATCH 5/6] fix(web): focus close control and trap tab in expanded mermaid dialog Addresses CodeRabbit review: the overlay moves focus to Close on open, cycles Tab and Shift+Tab across its controls, and keeps restoring focus to the opener on close. Also trims long comments. --- apps/web/src/components/ChatMarkdown.tsx | 11 ++--- apps/web/src/components/MermaidDiagram.tsx | 50 +++++++++++++--------- apps/web/src/index.css | 6 +-- 3 files changed, 36 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 0af2bdd1cd72..e1de6321d05c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -913,8 +913,8 @@ function MarkdownCodeBlock({ const [copied, setCopied] = useState(false); const [wrapped, setWrapped] = useState(readInitialWordWrapSetting); const [showSource, setShowSource] = useState(false); - // A failed diagram silently falls back to its source view. No error text: - // mermaid parse errors echo the raw source and read as app breakage. + // Failed diagrams fall back to source silently; parse errors echo the + // raw source and read as app breakage. const [diagramFailed, setDiagramFailed] = useState(false); const copiedTimerRef = useRef | null>(null); const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; @@ -922,11 +922,8 @@ function MarkdownCodeBlock({ const showDiagram = renderDiagram && !showSource && !diagramFailed; const sourceToggleLabel = showSource ? "Show diagram" : "Show source"; - // Reset the toggle when the fence content changes. Gated on renderDiagram so - // streaming code blocks (which re-render per token) pay no state updates - // here; non-streaming messages are static, so this fires at most once when a - // completed fence first becomes a diagram, plus on rare edits afterwards. - // Done during render so a completed message never keeps a stale view. + // Reset the toggle on fence edits, gated so streaming blocks (re-rendered + // per token) pay no state updates here. const [lastCode, setLastCode] = useState(code); if (renderDiagram && lastCode !== code) { setLastCode(code); diff --git a/apps/web/src/components/MermaidDiagram.tsx b/apps/web/src/components/MermaidDiagram.tsx index aa06d71b4b32..2527a579a83b 100644 --- a/apps/web/src/components/MermaidDiagram.tsx +++ b/apps/web/src/components/MermaidDiagram.tsx @@ -31,10 +31,7 @@ function rememberRenderedDiagram(key: string, svg: string) { } } -/** - * Stores a rendered diagram so remounts (e.g. scrolling back in chat) reuse - * it instead of re-running Mermaid. Exported for tests. - */ +/** Caches a rendered diagram for scroll remounts. Exported for tests. */ export function cacheRenderedDiagram(theme: MermaidTheme, code: string, svg: string) { rememberRenderedDiagram(diagramCacheKey(theme, code), svg); } @@ -86,11 +83,7 @@ function mermaidErrorMessage(cause: unknown): string { const MERMAID_VIEWBOX_PATTERN = /]*\bviewBox\s*=\s*"([^"]+)"/; -/** - * Natural pixel size from the SVG viewBox. Mermaid emits `width="100%"`, so - * without this the expanded overlay can only shrink-to-fit and collapses - * wide diagrams. Pure string parsing: no DOM needed, SSR-safe. - */ +/** Natural pixel size from the SVG viewBox. Pure string parsing, SSR-safe. */ export function mermaidSvgNaturalSize(svg: string): { width: number; height: number } | null { const viewBox = MERMAID_VIEWBOX_PATTERN.exec(svg)?.[1]; const dimensions = viewBox?.trim().split(/\s+/).map(Number); @@ -149,11 +142,13 @@ export function MermaidDiagramDialog({ [], ); - // The element that opened the preview gets focus back on close. Without - // this a close leaves focus on the unmounted dialog. + // The element that opened the preview gets focus back on close. const openerRef = useRef(null); + const dialogRef = useRef(null); + const closeButtonRef = useRef(null); useEffect(() => { openerRef.current = document.activeElement; + closeButtonRef.current?.focus({ preventScroll: true }); return () => { const opener = openerRef.current; if (opener instanceof HTMLElement && opener.isConnected) { @@ -171,6 +166,23 @@ export function MermaidDiagramDialog({ event.preventDefault(); event.stopPropagation(); onClose(); + return; + } + if (event.key !== "Tab") return; + const root = dialogRef.current; + if (!root) return; + const controls = Array.from( + root.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ); + if (controls.length === 0) return; + const first = controls[0] as HTMLElement; + const last = controls[controls.length - 1] as HTMLElement; + const active = document.activeElement; + if (event.shiftKey ? active === first || !root.contains(active) : active === last) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); } }; window.addEventListener("keydown", onKeyDown); @@ -178,13 +190,13 @@ export function MermaidDiagramDialog({ }, [onClose]); // Definite container width so the SVG's `width="100%"` resolves to its - // natural size instead of collapsing in the shrink-to-fit flex layout. - // Capped by max-w-[92vw]; larger diagrams scroll inside the overlay. + // natural size instead of collapsing. Capped by max-w-[92vw]. const naturalSize = useMemo(() => mermaidSvgNaturalSize(svg), [svg]); const dialog = (
@@ -228,8 +241,7 @@ export function MermaidDiagramDialog({
); - // Portals keep the overlay out of clipped chat-row stacking contexts. Fall - // back to inline rendering where `document` is unavailable (SSR/tests). + // Portals escape clipped chat rows; inline fallback covers SSR/tests. return typeof document === "undefined" ? dialog : createPortal(dialog, document.body); } @@ -261,9 +273,8 @@ export function MermaidDiagram({ ); const hostRef = useRef(null); - // Adopt a diagram cached after this mount's initializers ran (a sibling won - // the render race). Render-phase adjustment, not an effect, so no extra - // commit cycle and no work while streaming lists re-render around us. + // Adopt a diagram cached after this mount's initializers ran (sibling won + // the race). Render-phase adjustment avoids an extra commit cycle. const cachedSvg = renderedDiagrams.get(cacheKey); if (cachedSvg !== undefined && cachedSvg !== svg) { setSvg(cachedSvg); @@ -312,8 +323,7 @@ export function MermaidDiagram({ }, [cacheKey, code, diagramId, inView, onError, svg, theme]); if (!svg) { - // Pre-render (code fallback) doubles as the observation host so the - // diagram starts rendering just before it scrolls into view. + // Code fallback doubles as the visibility-observation host. return
{fallback}
; } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9e5cf4e78fc7..2a7dfb657af5 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1898,10 +1898,8 @@ code { margin: 0 auto; } -/* The expanded overlay is portaled outside `.chat-markdown`, so it needs its - own rules. The container gets the diagram's natural width inline (capped by - max-w-[92vw]) and scrolls, giving large diagrams a bigger canvas than the - chat column. */ +/* Expanded overlay lives outside `.chat-markdown`: natural width inline, + capped by max-w-[92vw], container scrolls for larger diagrams. */ .chat-markdown-mermaid-dialog { max-width: 100%; overflow: auto; From c88de3e97b552bdde135d7d4c0a42f112ba72a30 Mon Sep 17 00:00:00 2001 From: Aniket Vishwakarma Date: Fri, 11 Sep 2026 20:05:55 +0530 Subject: [PATCH 6/6] fix(web): keep backdrop out of expanded diagram tab order Addresses CodeRabbit review: the full-screen backdrop button stays mouse-clickable but leaves the keyboard cycle via tabindex -1, and the focus trap selector excludes it. Tab now cycles Copy and Close only. --- apps/web/src/components/MermaidDiagram.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/MermaidDiagram.tsx b/apps/web/src/components/MermaidDiagram.tsx index 2527a579a83b..fdf4e2a42050 100644 --- a/apps/web/src/components/MermaidDiagram.tsx +++ b/apps/web/src/components/MermaidDiagram.tsx @@ -173,7 +173,7 @@ export function MermaidDiagramDialog({ if (!root) return; const controls = Array.from( root.querySelectorAll( - 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + 'button:not([disabled]):not([tabindex="-1"]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', ), ); if (controls.length === 0) return; @@ -206,6 +206,7 @@ export function MermaidDiagramDialog({ type="button" className="absolute inset-0 z-0 cursor-zoom-out" aria-label="Close diagram preview" + tabIndex={-1} onClick={onClose} />