]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: Mermaid Diagrams in chat + interactive preview (#24032)
authorAleksander Grygier <redacted>
Wed, 3 Jun 2026 14:55:36 +0000 (16:55 +0200)
committerGitHub <redacted>
Wed, 3 Jun 2026 14:55:36 +0000 (16:55 +0200)
19 files changed:
tools/ui/package-lock.json
tools/ui/package.json
tools/ui/src/app.css
tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css [new file with mode: 0644]
tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/content/MarkdownContent/markdown-utils.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/code-block-utils.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-code-blocks.ts
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/content/MermaidPreview.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/content/MermaidPreviewControls.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/content/index.ts
tools/ui/src/lib/components/app/dialogs/DialogMermaidPreview.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/dialogs/index.ts
tools/ui/src/lib/constants/icons.ts
tools/ui/src/lib/constants/index.ts
tools/ui/src/lib/constants/mermaid-blocks.ts [new file with mode: 0644]

index 4d012c819900582ff6082c4bde5124d27dbbcee7..06f885680a6b24e325ef3c0ed22c831824b64b04 100644 (file)
@@ -10,6 +10,7 @@
                        "dependencies": {
                                "@modelcontextprotocol/sdk": "^1.25.1",
                                "highlight.js": "^11.11.1",
+                               "mermaid": "^11.15.0",
                                "mode-watcher": "^1.1.0",
                                "pdfjs-dist": "^5.4.54",
                                "rehype-highlight": "^7.0.2",
                                "node": ">=6.0.0"
                        }
                },
+               "node_modules/@antfu/install-pkg": {
+                       "version": "1.1.0",
+                       "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
+                       "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "package-manager-detector": "^1.3.0",
+                               "tinyexec": "^1.0.1"
+                       },
+                       "funding": {
+                               "url": "https://github.com/sponsors/antfu"
+                       }
+               },
+               "node_modules/@antfu/install-pkg/node_modules/tinyexec": {
+                       "version": "1.1.2",
+                       "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
+                       "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
+                       "license": "MIT",
+                       "engines": {
+                               "node": ">=18"
+                       }
+               },
                "node_modules/@babel/code-frame": {
                        "version": "7.27.1",
                        "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
                                "node": ">=18"
                        }
                },
+               "node_modules/@braintree/sanitize-url": {
+                       "version": "7.1.2",
+                       "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
+                       "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==",
+                       "license": "MIT"
+               },
+               "node_modules/@chevrotain/types": {
+                       "version": "11.1.2",
+                       "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
+                       "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
+                       "license": "Apache-2.0"
+               },
                "node_modules/@chromatic-com/storybook": {
                        "version": "5.0.0",
                        "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.0.0.tgz",
                                "url": "https://github.com/sponsors/nzakas"
                        }
                },
+               "node_modules/@iconify/types": {
+                       "version": "2.0.0",
+                       "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
+                       "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
+                       "license": "MIT"
+               },
+               "node_modules/@iconify/utils": {
+                       "version": "3.1.3",
+                       "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz",
+                       "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@antfu/install-pkg": "^1.1.0",
+                               "@iconify/types": "^2.0.0",
+                               "import-meta-resolve": "^4.2.0"
+                       }
+               },
                "node_modules/@internationalized/date": {
                        "version": "3.10.1",
                        "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz",
                                "react": ">=16"
                        }
                },
+               "node_modules/@mermaid-js/parser": {
+                       "version": "1.1.1",
+                       "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
+                       "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@chevrotain/types": "~11.1.1"
+                       }
+               },
                "node_modules/@modelcontextprotocol/sdk": {
                        "version": "1.26.0",
                        "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
                        "dev": true,
                        "license": "MIT"
                },
+               "node_modules/@types/d3": {
+                       "version": "7.4.3",
+                       "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+                       "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@types/d3-array": "*",
+                               "@types/d3-axis": "*",
+                               "@types/d3-brush": "*",
+                               "@types/d3-chord": "*",
+                               "@types/d3-color": "*",
+                               "@types/d3-contour": "*",
+                               "@types/d3-delaunay": "*",
+                               "@types/d3-dispatch": "*",
+                               "@types/d3-drag": "*",
+                               "@types/d3-dsv": "*",
+                               "@types/d3-ease": "*",
+                               "@types/d3-fetch": "*",
+                               "@types/d3-force": "*",
+                               "@types/d3-format": "*",
+                               "@types/d3-geo": "*",
+                               "@types/d3-hierarchy": "*",
+                               "@types/d3-interpolate": "*",
+                               "@types/d3-path": "*",
+                               "@types/d3-polygon": "*",
+                               "@types/d3-quadtree": "*",
+                               "@types/d3-random": "*",
+                               "@types/d3-scale": "*",
+                               "@types/d3-scale-chromatic": "*",
+                               "@types/d3-selection": "*",
+                               "@types/d3-shape": "*",
+                               "@types/d3-time": "*",
+                               "@types/d3-time-format": "*",
+                               "@types/d3-timer": "*",
+                               "@types/d3-transition": "*",
+                               "@types/d3-zoom": "*"
+                       }
+               },
+               "node_modules/@types/d3-array": {
+                       "version": "3.2.2",
+                       "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+                       "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-axis": {
+                       "version": "3.0.6",
+                       "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
+                       "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@types/d3-selection": "*"
+                       }
+               },
+               "node_modules/@types/d3-brush": {
+                       "version": "3.0.6",
+                       "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
+                       "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@types/d3-selection": "*"
+                       }
+               },
+               "node_modules/@types/d3-chord": {
+                       "version": "3.0.6",
+                       "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
+                       "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-color": {
+                       "version": "3.1.3",
+                       "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+                       "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-contour": {
+                       "version": "3.0.6",
+                       "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
+                       "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@types/d3-array": "*",
+                               "@types/geojson": "*"
+                       }
+               },
+               "node_modules/@types/d3-delaunay": {
+                       "version": "6.0.4",
+                       "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+                       "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-dispatch": {
+                       "version": "3.0.7",
+                       "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
+                       "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-drag": {
+                       "version": "3.0.7",
+                       "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+                       "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@types/d3-selection": "*"
+                       }
+               },
+               "node_modules/@types/d3-dsv": {
+                       "version": "3.0.7",
+                       "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+                       "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-ease": {
+                       "version": "3.0.2",
+                       "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+                       "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-fetch": {
+                       "version": "3.0.7",
+                       "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+                       "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@types/d3-dsv": "*"
+                       }
+               },
+               "node_modules/@types/d3-force": {
+                       "version": "3.0.10",
+                       "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+                       "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-format": {
+                       "version": "3.0.4",
+                       "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
+                       "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-geo": {
+                       "version": "3.1.0",
+                       "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+                       "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@types/geojson": "*"
+                       }
+               },
+               "node_modules/@types/d3-hierarchy": {
+                       "version": "3.1.7",
+                       "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
+                       "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-interpolate": {
+                       "version": "3.0.4",
+                       "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+                       "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@types/d3-color": "*"
+                       }
+               },
+               "node_modules/@types/d3-path": {
+                       "version": "3.1.1",
+                       "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+                       "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-polygon": {
+                       "version": "3.0.2",
+                       "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
+                       "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-quadtree": {
+                       "version": "3.0.6",
+                       "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+                       "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-random": {
+                       "version": "3.0.3",
+                       "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
+                       "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-scale": {
+                       "version": "4.0.9",
+                       "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+                       "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@types/d3-time": "*"
+                       }
+               },
+               "node_modules/@types/d3-scale-chromatic": {
+                       "version": "3.1.0",
+                       "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+                       "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-selection": {
+                       "version": "3.0.11",
+                       "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+                       "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-shape": {
+                       "version": "3.1.8",
+                       "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+                       "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@types/d3-path": "*"
+                       }
+               },
+               "node_modules/@types/d3-time": {
+                       "version": "3.0.4",
+                       "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+                       "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-time-format": {
+                       "version": "4.0.3",
+                       "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
+                       "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-timer": {
+                       "version": "3.0.2",
+                       "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+                       "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+                       "license": "MIT"
+               },
+               "node_modules/@types/d3-transition": {
+                       "version": "3.0.9",
+                       "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+                       "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@types/d3-selection": "*"
+                       }
+               },
+               "node_modules/@types/d3-zoom": {
+                       "version": "3.0.8",
+                       "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+                       "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@types/d3-interpolate": "*",
+                               "@types/d3-selection": "*"
+                       }
+               },
                "node_modules/@types/debug": {
                        "version": "4.1.12",
                        "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
                        "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
                        "license": "MIT"
                },
+               "node_modules/@types/geojson": {
+                       "version": "7946.0.16",
+                       "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+                       "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+                       "license": "MIT"
+               },
                "node_modules/@types/hast": {
                        "version": "3.0.4",
                        "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
                        "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
                        "license": "ISC"
                },
+               "node_modules/@upsetjs/venn.js": {
+                       "version": "2.0.0",
+                       "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
+                       "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==",
+                       "license": "MIT",
+                       "optionalDependencies": {
+                               "d3-selection": "^3.0.0",
+                               "d3-transition": "^3.0.1"
+                       }
+               },
                "node_modules/@vitest/browser": {
                        "version": "3.2.4",
                        "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.4.tgz",
                        "version": "8.3.0",
                        "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
                        "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
-                       "dev": true,
                        "license": "MIT",
                        "engines": {
                                "node": ">= 12"
                                "node": ">= 0.4.0"
                        }
                },
+               "node_modules/cose-base": {
+                       "version": "1.0.3",
+                       "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
+                       "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "layout-base": "^1.0.0"
+                       }
+               },
                "node_modules/cross-spawn": {
                        "version": "7.0.6",
                        "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
                        "license": "MIT",
                        "peer": true
                },
+               "node_modules/cytoscape": {
+                       "version": "3.33.4",
+                       "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.4.tgz",
+                       "integrity": "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww==",
+                       "license": "MIT",
+                       "engines": {
+                               "node": ">=0.10"
+                       }
+               },
+               "node_modules/cytoscape-cose-bilkent": {
+                       "version": "4.1.0",
+                       "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz",
+                       "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "cose-base": "^1.0.0"
+                       },
+                       "peerDependencies": {
+                               "cytoscape": "^3.2.0"
+                       }
+               },
+               "node_modules/cytoscape-fcose": {
+                       "version": "2.2.0",
+                       "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz",
+                       "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "cose-base": "^2.2.0"
+                       },
+                       "peerDependencies": {
+                               "cytoscape": "^3.2.0"
+                       }
+               },
+               "node_modules/cytoscape-fcose/node_modules/cose-base": {
+                       "version": "2.2.0",
+                       "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz",
+                       "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "layout-base": "^2.0.0"
+                       }
+               },
+               "node_modules/cytoscape-fcose/node_modules/layout-base": {
+                       "version": "2.0.1",
+                       "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz",
+                       "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==",
+                       "license": "MIT"
+               },
+               "node_modules/d3": {
+                       "version": "7.9.0",
+                       "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
+                       "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-array": "3",
+                               "d3-axis": "3",
+                               "d3-brush": "3",
+                               "d3-chord": "3",
+                               "d3-color": "3",
+                               "d3-contour": "4",
+                               "d3-delaunay": "6",
+                               "d3-dispatch": "3",
+                               "d3-drag": "3",
+                               "d3-dsv": "3",
+                               "d3-ease": "3",
+                               "d3-fetch": "3",
+                               "d3-force": "3",
+                               "d3-format": "3",
+                               "d3-geo": "3",
+                               "d3-hierarchy": "3",
+                               "d3-interpolate": "3",
+                               "d3-path": "3",
+                               "d3-polygon": "3",
+                               "d3-quadtree": "3",
+                               "d3-random": "3",
+                               "d3-scale": "4",
+                               "d3-scale-chromatic": "3",
+                               "d3-selection": "3",
+                               "d3-shape": "3",
+                               "d3-time": "3",
+                               "d3-time-format": "4",
+                               "d3-timer": "3",
+                               "d3-transition": "3",
+                               "d3-zoom": "3"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-array": {
+                       "version": "3.2.4",
+                       "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+                       "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "internmap": "1 - 2"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-axis": {
+                       "version": "3.0.0",
+                       "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
+                       "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
+                       "license": "ISC",
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-brush": {
+                       "version": "3.0.0",
+                       "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
+                       "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-dispatch": "1 - 3",
+                               "d3-drag": "2 - 3",
+                               "d3-interpolate": "1 - 3",
+                               "d3-selection": "3",
+                               "d3-transition": "3"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-chord": {
+                       "version": "3.0.1",
+                       "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
+                       "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-path": "1 - 3"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-color": {
+                       "version": "3.1.0",
+                       "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+                       "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+                       "license": "ISC",
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-contour": {
+                       "version": "4.0.2",
+                       "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
+                       "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-array": "^3.2.0"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-delaunay": {
+                       "version": "6.0.4",
+                       "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+                       "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "delaunator": "5"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-dispatch": {
+                       "version": "3.0.1",
+                       "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+                       "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+                       "license": "ISC",
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-drag": {
+                       "version": "3.0.0",
+                       "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+                       "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-dispatch": "1 - 3",
+                               "d3-selection": "3"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-dsv": {
+                       "version": "3.0.1",
+                       "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
+                       "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "commander": "7",
+                               "iconv-lite": "0.6",
+                               "rw": "1"
+                       },
+                       "bin": {
+                               "csv2json": "bin/dsv2json.js",
+                               "csv2tsv": "bin/dsv2dsv.js",
+                               "dsv2dsv": "bin/dsv2dsv.js",
+                               "dsv2json": "bin/dsv2json.js",
+                               "json2csv": "bin/json2dsv.js",
+                               "json2dsv": "bin/json2dsv.js",
+                               "json2tsv": "bin/json2dsv.js",
+                               "tsv2csv": "bin/dsv2dsv.js",
+                               "tsv2json": "bin/dsv2json.js"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-dsv/node_modules/commander": {
+                       "version": "7.2.0",
+                       "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+                       "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+                       "license": "MIT",
+                       "engines": {
+                               "node": ">= 10"
+                       }
+               },
+               "node_modules/d3-ease": {
+                       "version": "3.0.1",
+                       "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+                       "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+                       "license": "BSD-3-Clause",
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-fetch": {
+                       "version": "3.0.1",
+                       "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
+                       "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-dsv": "1 - 3"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-force": {
+                       "version": "3.0.0",
+                       "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+                       "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-dispatch": "1 - 3",
+                               "d3-quadtree": "1 - 3",
+                               "d3-timer": "1 - 3"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-format": {
+                       "version": "3.1.2",
+                       "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+                       "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+                       "license": "ISC",
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-geo": {
+                       "version": "3.1.1",
+                       "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
+                       "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-array": "2.5.0 - 3"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-hierarchy": {
+                       "version": "3.1.2",
+                       "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
+                       "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
+                       "license": "ISC",
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-interpolate": {
+                       "version": "3.0.1",
+                       "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+                       "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-color": "1 - 3"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-path": {
+                       "version": "3.1.0",
+                       "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+                       "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+                       "license": "ISC",
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-polygon": {
+                       "version": "3.0.1",
+                       "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
+                       "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
+                       "license": "ISC",
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-quadtree": {
+                       "version": "3.0.1",
+                       "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+                       "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+                       "license": "ISC",
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-random": {
+                       "version": "3.0.1",
+                       "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
+                       "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
+                       "license": "ISC",
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-sankey": {
+                       "version": "0.12.3",
+                       "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz",
+                       "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==",
+                       "license": "BSD-3-Clause",
+                       "dependencies": {
+                               "d3-array": "1 - 2",
+                               "d3-shape": "^1.2.0"
+                       }
+               },
+               "node_modules/d3-sankey/node_modules/d3-array": {
+                       "version": "2.12.1",
+                       "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
+                       "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
+                       "license": "BSD-3-Clause",
+                       "dependencies": {
+                               "internmap": "^1.0.0"
+                       }
+               },
+               "node_modules/d3-sankey/node_modules/d3-path": {
+                       "version": "1.0.9",
+                       "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
+                       "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
+                       "license": "BSD-3-Clause"
+               },
+               "node_modules/d3-sankey/node_modules/d3-shape": {
+                       "version": "1.3.7",
+                       "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
+                       "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
+                       "license": "BSD-3-Clause",
+                       "dependencies": {
+                               "d3-path": "1"
+                       }
+               },
+               "node_modules/d3-sankey/node_modules/internmap": {
+                       "version": "1.0.1",
+                       "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
+                       "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
+                       "license": "ISC"
+               },
+               "node_modules/d3-scale": {
+                       "version": "4.0.2",
+                       "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+                       "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-array": "2.10.0 - 3",
+                               "d3-format": "1 - 3",
+                               "d3-interpolate": "1.2.0 - 3",
+                               "d3-time": "2.1.1 - 3",
+                               "d3-time-format": "2 - 4"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-scale-chromatic": {
+                       "version": "3.1.0",
+                       "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+                       "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-color": "1 - 3",
+                               "d3-interpolate": "1 - 3"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-selection": {
+                       "version": "3.0.0",
+                       "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+                       "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+                       "license": "ISC",
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-shape": {
+                       "version": "3.2.0",
+                       "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+                       "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-path": "^3.1.0"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-time": {
+                       "version": "3.1.0",
+                       "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+                       "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-array": "2 - 3"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-time-format": {
+                       "version": "4.1.0",
+                       "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+                       "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-time": "1 - 3"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-timer": {
+                       "version": "3.0.1",
+                       "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+                       "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+                       "license": "ISC",
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/d3-transition": {
+                       "version": "3.0.1",
+                       "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+                       "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-color": "1 - 3",
+                               "d3-dispatch": "1 - 3",
+                               "d3-ease": "1 - 3",
+                               "d3-interpolate": "1 - 3",
+                               "d3-timer": "1 - 3"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       },
+                       "peerDependencies": {
+                               "d3-selection": "2 - 3"
+                       }
+               },
+               "node_modules/d3-zoom": {
+                       "version": "3.0.0",
+                       "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+                       "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "d3-dispatch": "1 - 3",
+                               "d3-drag": "2 - 3",
+                               "d3-interpolate": "1 - 3",
+                               "d3-selection": "2 - 3",
+                               "d3-transition": "2 - 3"
+                       },
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
+               "node_modules/dagre-d3-es": {
+                       "version": "7.0.14",
+                       "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
+                       "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "d3": "^7.9.0",
+                               "lodash-es": "^4.17.21"
+                       }
+               },
+               "node_modules/dayjs": {
+                       "version": "1.11.20",
+                       "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
+                       "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
+                       "license": "MIT"
+               },
                "node_modules/debug": {
                        "version": "4.4.3",
                        "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
                                "url": "https://github.com/sponsors/sindresorhus"
                        }
                },
+               "node_modules/delaunator": {
+                       "version": "5.1.0",
+                       "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
+                       "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
+                       "license": "ISC",
+                       "dependencies": {
+                               "robust-predicates": "^3.0.2"
+                       }
+               },
                "node_modules/depd": {
                        "version": "2.0.0",
                        "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
                        "dev": true,
                        "license": "MIT"
                },
+               "node_modules/dompurify": {
+                       "version": "3.4.5",
+                       "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
+                       "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
+                       "license": "(MPL-2.0 OR Apache-2.0)",
+                       "optionalDependencies": {
+                               "@types/trusted-types": "^2.0.7"
+                       }
+               },
                "node_modules/dunder-proto": {
                        "version": "1.0.1",
                        "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
                        }
                },
                "node_modules/es-toolkit": {
-                       "version": "1.39.7",
-                       "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.7.tgz",
-                       "integrity": "sha512-ek/wWryKouBrZIjkwW2BFf91CWOIMvoy2AE5YYgUrfWsJQM2Su1LoLtrw8uusEpN9RfqLlV/0FVNjT0WMv8Bxw==",
-                       "dev": true,
+                       "version": "1.46.1",
+                       "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
+                       "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
                        "license": "MIT",
                        "workspaces": [
                                "docs",
                        "dev": true,
                        "license": "ISC"
                },
+               "node_modules/hachure-fill": {
+                       "version": "0.5.2",
+                       "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
+                       "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
+                       "license": "MIT"
+               },
                "node_modules/has-flag": {
                        "version": "4.0.0",
                        "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
                        "version": "0.6.3",
                        "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
                        "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
-                       "dev": true,
                        "license": "MIT",
                        "dependencies": {
                                "safer-buffer": ">= 2.1.2 < 3.0.0"
                                "url": "https://github.com/sponsors/sindresorhus"
                        }
                },
+               "node_modules/import-meta-resolve": {
+                       "version": "4.2.0",
+                       "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
+                       "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
+                       "license": "MIT",
+                       "funding": {
+                               "type": "github",
+                               "url": "https://github.com/sponsors/wooorm"
+                       }
+               },
                "node_modules/imurmurhash": {
                        "version": "0.1.4",
                        "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
                        "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==",
                        "license": "MIT"
                },
+               "node_modules/internmap": {
+                       "version": "2.0.3",
+                       "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+                       "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+                       "license": "ISC",
+                       "engines": {
+                               "node": ">=12"
+                       }
+               },
                "node_modules/ip-address": {
                        "version": "10.2.0",
                        "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
                        "version": "0.16.47",
                        "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
                        "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
-                       "dev": true,
                        "funding": [
                                "https://opencollective.com/katex",
                                "https://github.com/sponsors/katex"
                                "json-buffer": "3.0.1"
                        }
                },
+               "node_modules/khroma": {
+                       "version": "2.1.0",
+                       "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
+                       "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
+               },
                "node_modules/kleur": {
                        "version": "4.1.5",
                        "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
                        "dev": true,
                        "license": "MIT"
                },
+               "node_modules/layout-base": {
+                       "version": "1.0.2",
+                       "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
+                       "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
+                       "license": "MIT"
+               },
                "node_modules/levn": {
                        "version": "0.4.1",
                        "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
                                "url": "https://github.com/sponsors/sindresorhus"
                        }
                },
+               "node_modules/lodash-es": {
+                       "version": "4.18.1",
+                       "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+                       "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+                       "license": "MIT"
+               },
                "node_modules/lodash.castarray": {
                        "version": "4.4.0",
                        "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
                                "url": "https://github.com/sponsors/wooorm"
                        }
                },
+               "node_modules/marked": {
+                       "version": "16.4.2",
+                       "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
+                       "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
+                       "license": "MIT",
+                       "bin": {
+                               "marked": "bin/marked.js"
+                       },
+                       "engines": {
+                               "node": ">= 20"
+                       }
+               },
                "node_modules/math-intrinsics": {
                        "version": "1.1.0",
                        "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
                                "url": "https://github.com/sponsors/sindresorhus"
                        }
                },
+               "node_modules/mermaid": {
+                       "version": "11.15.0",
+                       "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz",
+                       "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "@braintree/sanitize-url": "^7.1.1",
+                               "@iconify/utils": "^3.0.2",
+                               "@mermaid-js/parser": "^1.1.1",
+                               "@types/d3": "^7.4.3",
+                               "@upsetjs/venn.js": "^2.0.0",
+                               "cytoscape": "^3.33.1",
+                               "cytoscape-cose-bilkent": "^4.1.0",
+                               "cytoscape-fcose": "^2.2.0",
+                               "d3": "^7.9.0",
+                               "d3-sankey": "^0.12.3",
+                               "dagre-d3-es": "7.0.14",
+                               "dayjs": "^1.11.19",
+                               "dompurify": "^3.3.1",
+                               "es-toolkit": "^1.45.1",
+                               "katex": "^0.16.25",
+                               "khroma": "^2.1.0",
+                               "marked": "^16.3.0",
+                               "roughjs": "^4.6.6",
+                               "stylis": "^4.3.6",
+                               "ts-dedent": "^2.2.0",
+                               "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
+                       }
+               },
                "node_modules/micromark": {
                        "version": "4.0.2",
                        "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
                        "dev": true,
                        "license": "BlueOak-1.0.0"
                },
+               "node_modules/package-manager-detector": {
+                       "version": "1.6.0",
+                       "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
+                       "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
+                       "license": "MIT"
+               },
                "node_modules/parent-module": {
                        "version": "1.0.1",
                        "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
                                "node": ">= 0.8"
                        }
                },
+               "node_modules/path-data-parser": {
+                       "version": "0.1.0",
+                       "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
+                       "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
+                       "license": "MIT"
+               },
                "node_modules/path-exists": {
                        "version": "4.0.0",
                        "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
                                "node": ">=18"
                        }
                },
+               "node_modules/points-on-curve": {
+                       "version": "0.2.0",
+                       "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
+                       "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==",
+                       "license": "MIT"
+               },
+               "node_modules/points-on-path": {
+                       "version": "0.2.1",
+                       "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz",
+                       "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "path-data-parser": "0.1.0",
+                               "points-on-curve": "0.2.0"
+                       }
+               },
                "node_modules/portfinder": {
                        "version": "1.0.38",
                        "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz",
                                "node": ">=4"
                        }
                },
+               "node_modules/robust-predicates": {
+                       "version": "3.0.3",
+                       "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
+                       "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
+                       "license": "Unlicense"
+               },
                "node_modules/rollup": {
                        "version": "4.60.1",
                        "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
                                "fsevents": "~2.3.2"
                        }
                },
+               "node_modules/roughjs": {
+                       "version": "4.6.6",
+                       "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz",
+                       "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==",
+                       "license": "MIT",
+                       "dependencies": {
+                               "hachure-fill": "^0.5.2",
+                               "path-data-parser": "^0.1.0",
+                               "points-on-curve": "^0.2.0",
+                               "points-on-path": "^0.2.1"
+                       }
+               },
                "node_modules/router": {
                        "version": "2.2.0",
                        "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
                                "svelte": "^5.7.0"
                        }
                },
+               "node_modules/rw": {
+                       "version": "1.3.3",
+                       "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+                       "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+                       "license": "BSD-3-Clause"
+               },
                "node_modules/sade": {
                        "version": "1.8.1",
                        "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
                                "inline-style-parser": "0.2.4"
                        }
                },
+               "node_modules/stylis": {
+                       "version": "4.4.0",
+                       "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
+                       "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
+                       "license": "MIT"
+               },
                "node_modules/supports-color": {
                        "version": "7.2.0",
                        "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
                        "version": "2.2.0",
                        "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz",
                        "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==",
-                       "dev": true,
                        "license": "MIT",
                        "engines": {
                                "node": ">=6.10"
                        "version": "13.0.2",
                        "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
                        "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
-                       "dev": true,
                        "funding": [
                                "https://github.com/sponsors/broofa",
                                "https://github.com/sponsors/ctavan"
index 5a1cec66645f53759b5dfef4470b16606aad543d..7c514fa8a6a0f2fb2c6e16f8d5dd293bfd4c08a7 100644 (file)
@@ -80,6 +80,7 @@
        "dependencies": {
                "@modelcontextprotocol/sdk": "^1.25.1",
                "highlight.js": "^11.11.1",
+               "mermaid": "^11.15.0",
                "mode-watcher": "^1.1.0",
                "pdfjs-dist": "^5.4.54",
                "rehype-highlight": "^7.0.2",
index 29b1d3c640bb87214fbd6fa5d690303d84475fe2..aa96bac321bfb3321c295a52468cc0c53f0581b1 100644 (file)
                @apply bg-background text-foreground;
                scrollbar-width: thin;
                scrollbar-gutter: stable;
+               overflow: hidden; /* Added due to Mermaid rendering somehow causing the double scrollbar */
        }
 
        /* Global scrollbar styling - visible only on hover */
index 0412414ae39bf12a93cf407b818e7a21e14dbae0..9c4c49c0ce82993568e7bf6ebede53979dba455e 100644 (file)
        import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
        import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
        import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
+       import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
+       import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
        import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
        import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
        import { remarkLiteralHtml } from './plugins/remark/literal-html';
-       import { copyCodeToClipboard, preprocessLaTeX, getImageErrorFallbackHtml } from '$lib/utils';
+       import {
+               getHastNodeId,
+               getMdastNodeHash,
+               isAppendMode,
+               getCodeInfoFromTarget
+       } from './markdown-utils';
+       import {
+               preprocessLaTeX,
+               getImageErrorFallbackHtml,
+               copyCodeToClipboard,
+               copyToClipboard
+       } from '$lib/utils';
        import {
                IMAGE_NOT_ERROR_BOUND_SELECTOR,
                DATA_ERROR_BOUND_ATTR,
        import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
        import githubLightCss from 'highlight.js/styles/github.css?inline';
        import { mode } from 'mode-watcher';
-       import { CodeBlockActions, DialogCodePreview } from '$lib/components/app';
+       import {
+               CodeBlockActions,
+               DialogCodePreview,
+               DialogMermaidPreview,
+               ActionIconCopyToClipboard
+       } from '$lib/components/app';
        import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
        import type { DatabaseMessageExtra } from '$lib/types/database';
        import { config } from '$lib/stores/settings.svelte';
@@ -62,6 +80,9 @@
        let previewDialogOpen = $state(false);
        let previewCode = $state('');
        let previewLanguage = $state('text');
+       let mermaidPreviewOpen = $state(false);
+       let mermaidPreviewSvgHtml = $state('');
+
        let streamingCodeScrollContainer = $state<HTMLDivElement>();
 
        // Auto-scroll controller for streaming code block content
                        }) // Add syntax highlighting
                        .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
                        .use(rehypeEnhanceLinks) // Add target="_blank" to links
+                       .use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
                        .use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
+                       .use(rehypeEnhanceMermaidBlocks) // Wrap mermaid blocks with header and actions
                        .use(rehypeResolveAttachmentImages, { attachments })
                        .use(rehypeRtlSupport) // Add bidirectional text support
                        .use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
        }
 
        /**
-        * Extracts code information from a button click target within a code block.
-        * @param target - The clicked button element
-        * @returns Object with rawCode and language, or null if extraction fails
-        */
-       function getCodeInfoFromTarget(target: HTMLElement) {
-               const wrapper = target.closest('.code-block-wrapper');
-
-               if (!wrapper) {
-                       console.error('No wrapper found');
-                       return null;
-               }
-
-               const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
-
-               if (!codeElement) {
-                       console.error('No code element found in wrapper');
-                       return null;
-               }
-
-               const rawCode = codeElement.textContent ?? '';
-
-               const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
-               const language = languageLabel?.textContent?.trim() || 'text';
-
-               return { rawCode, language };
-       }
-
-       /**
-        * Generates a unique identifier for a HAST node based on its position.
-        * Used for stable block identification during incremental rendering.
-        * @param node - The HAST root content node
-        * @param indexFallback - Fallback index if position is unavailable
-        * @returns Unique string identifier for the node
-        */
-       function getHastNodeId(node: HastRootContent, indexFallback: number): string {
-               const position = node.position;
-
-               if (position?.start?.offset != null && position?.end?.offset != null) {
-                       return `hast-${position.start.offset}-${position.end.offset}`;
-               }
-
-               return `${node.type}-${indexFallback}`;
-       }
-
-       /**
-        * Generates a hash for MDAST node based on its position.
-        * Used for cache lookup during incremental rendering.
-        */
-       function getMdastNodeHash(node: unknown, index: number): string {
-               const n = node as {
-                       type?: string;
-                       position?: { start?: { offset?: number }; end?: { offset?: number } };
-               };
-
-               if (n.position?.start?.offset != null && n.position?.end?.offset != null) {
-                       return `${n.type}-${n.position.start.offset}-${n.position.end.offset}`;
-               }
-
-               return `${n.type}-idx${index}`;
-       }
-
-       /**
-        * Check if we're in append-only mode (streaming).
-        */
-       function isAppendMode(newContent: string): boolean {
-               return previousContent.length > 0 && newContent.startsWith(previousContent);
-       }
+        * Transforms a single MDAST node to HTML string with caching.
 
        /**
         * Transforms a single MDAST node to HTML string with caching.
                                const nextBlocks: MarkdownBlock[] = [];
 
                                // Check if we're in append mode for cache reuse
-                               const appendMode = isAppendMode(prefixMarkdown);
+                               const appendMode = isAppendMode(prefixMarkdown, previousContent);
                                const previousBlockCount = appendMode ? renderedBlocks.length : 0;
 
                                // All prefix blocks are now stable since code block is separate
                const nextBlocks: MarkdownBlock[] = [];
 
                // Check if we're in append mode for cache reuse
-               const appendMode = isAppendMode(markdown);
+               const appendMode = isAppendMode(markdown, previousContent);
                const previousBlockCount = appendMode ? renderedBlocks.length : 0;
 
                for (let index = 0; index < stableCount; index++) {
                }
        }
 
+       /**
+        * Opens the mermaid diagram in a full-screen preview dialog with zoom/pan support.
+        * Also handles copy and preview button clicks for mermaid blocks.
+        * Uses event delegation: a single handler on the container.
+        */
+       async function handleMermaidClick(event: MouseEvent) {
+               const target = event.target as HTMLElement;
+
+               // Check if clicking on copy or preview button in mermaid block
+               const copyBtn = target.closest('.mermaid-block-wrapper .copy-code-btn');
+               const previewBtn = target.closest('.mermaid-block-wrapper .preview-code-btn');
+
+               if (copyBtn || previewBtn) {
+                       const wrapper = target.closest('.mermaid-block-wrapper');
+                       if (!wrapper) return;
+
+                       const preElement = wrapper.querySelector<HTMLElement>('pre.mermaid[data-mermaid-syntax]');
+                       if (!preElement) return;
+
+                       const mermaidSyntax = preElement.dataset.mermaidSyntax ?? '';
+
+                       if (copyBtn) {
+                               event.preventDefault();
+                               event.stopPropagation();
+                               try {
+                                       await copyToClipboard(mermaidSyntax);
+                               } catch (error) {
+                                       console.error('Failed to copy mermaid syntax:', error);
+                               }
+                               return;
+                       }
+
+                       if (previewBtn) {
+                               event.preventDefault();
+                               event.stopPropagation();
+                               const svg = preElement.querySelector('svg');
+                               if (!svg) return;
+                               mermaidPreviewSvgHtml = svg.outerHTML;
+                               mermaidPreviewOpen = true;
+                               return;
+                       }
+               }
+
+               // Otherwise, open preview when clicking on the mermaid diagram itself
+               const mermaidEl = target.closest('.mermaid');
+               if (!mermaidEl) return;
+
+               const svg = mermaidEl.querySelector('svg');
+               if (!svg) return;
+
+               mermaidPreviewSvgHtml = svg.outerHTML;
+               mermaidPreviewOpen = true;
+       }
+
+       /**
+        * Handles mermaid preview dialog open state changes.
+        * Cleans up SVG content when dialog is closed.
+        */
+       function handleMermaidPreviewOpenChange(open: boolean) {
+               mermaidPreviewOpen = open;
+               if (!open) {
+                       mermaidPreviewSvgHtml = '';
+               }
+       }
+
+       /**
+        * Renders mermaid diagrams that haven't been rendered yet.
+        * Called after each markdown content update.
+        * Marks nodes immediately to prevent duplicate renders during streaming.
+        * Reads mode.current before await to ensure reactive tracking.
+        */
+       async function renderMermaidDiagrams() {
+               if (!containerRef) return;
+
+               const nodes = containerRef.querySelectorAll('pre.mermaid:not([data-mermaid-rendered])');
+               if (nodes.length === 0) return;
+
+               // Mark nodes immediately to prevent duplicate renders if called again during streaming.
+               // This avoids needing a guard that would block node discovery.
+               nodes.forEach((node) => node.setAttribute('data-mermaid-rendered', 'true'));
+
+               // Read mode before await so Svelte tracks it reactively.
+               const isDark = mode.current === ColorMode.DARK;
+
+               // lazy load the mermaid dependecy only when needed to reduce bundle size.
+               const { default: mermaid } = await import('mermaid');
+
+               mermaid.initialize({
+                       startOnLoad: false,
+                       theme: isDark ? 'dark' : 'default',
+                       securityLevel: 'strict',
+                       flowchart: {
+                               useMaxWidth: false,
+                               htmlLabels: true
+                       },
+                       sequence: {
+                               useMaxWidth: false
+                       },
+                       gantt: {
+                               useMaxWidth: false
+                       }
+               });
+
+               try {
+                       await mermaid.run({
+                               nodes: Array.from(nodes) as unknown as NodeListOf<HTMLElement>
+                       });
+               } catch (error) {
+                       console.error('Failed to render mermaid diagram:', error);
+               }
+       }
+
        /**
         * Handles image load errors by replacing the image with a fallback UI.
         * Shows a placeholder with a link to open the image in a new tab.
                if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) {
                        setupCodeBlockActions();
                        setupImageErrorHandlers();
+                       renderMermaidDiagrams();
                }
        });
 
        });
 </script>
 
+<!-- svelte-ignore a11y_click_events_have_key_events -->
+<!-- svelte-ignore a11y_no_static_element_interactions -->
 <div
        bind:this={containerRef}
-       class="{className}{config()[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS]
+       onclick={handleMermaidClick}
+       class="markdown-content {className}{config()[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS]
                ? ' full-height-code-blocks'
                : ''}"
 >
        {#each renderedBlocks as block (block.id)}
                <div class="markdown-block" data-block-id={block.id} use:fadeInView={{ skipIfVisible: true }}>
-                       <!-- eslint-disable-next-line no-at-html-tags -->
                        {@html block.html}
                </div>
        {/each}
        {/if}
 
        {#if incompleteCodeBlock}
-               <div class="code-block-wrapper streaming-code-block relative">
-                       <div class="code-block-header">
-                               <span class="code-language">{incompleteCodeBlock.language || 'text'}</span>
-                               <CodeBlockActions
-                                       code={incompleteCodeBlock.code}
-                                       language={incompleteCodeBlock.language || 'text'}
-                                       disabled
-                                       onPreview={(code, lang) => {
-                                               previewCode = code;
-                                               previewLanguage = lang;
-                                               previewDialogOpen = true;
-                                       }}
-                               />
+               {#if incompleteCodeBlock.language === 'mermaid'}
+                       <div class="mermaid-block-wrapper streaming-mermaid-block">
+                               <div class="code-block-header">
+                                       <span class="code-language">mermaid</span>
+                                       <div class="code-block-actions">
+                                               <ActionIconCopyToClipboard
+                                                       text={incompleteCodeBlock.code}
+                                                       canCopy={false}
+                                                       ariaLabel="Diagram incomplete"
+                                               />
+                                       </div>
+                               </div>
+                               <div class="mermaid-loading-placeholder">
+                                       <span class="mermaid-loading-text">Generating diagram...</span>
+                               </div>
                        </div>
-                       <div
-                               bind:this={streamingCodeScrollContainer}
-                               class="streaming-code-scroll-container"
-                               onscroll={() => streamingAutoScroll.handleScroll()}
-                       >
-                               <pre class="streaming-code-pre"><code
-                                               class="hljs language-{incompleteCodeBlock.language || 'text'}"
-                                               >{@html highlightCode(
-                                                       incompleteCodeBlock.code,
-                                                       incompleteCodeBlock.language || 'text'
-                                               )}</code
-                                       ></pre>
+               {:else}
+                       <div class="code-block-wrapper streaming-code-block relative">
+                               <div class="code-block-header">
+                                       <span class="code-language">{incompleteCodeBlock.language || 'text'}</span>
+                                       <CodeBlockActions
+                                               code={incompleteCodeBlock.code}
+                                               language={incompleteCodeBlock.language || 'text'}
+                                               disabled
+                                               onPreview={(code, lang) => {
+                                                       previewCode = code;
+                                                       previewLanguage = lang;
+                                                       previewDialogOpen = true;
+                                               }}
+                                       />
+                               </div>
+
+                               <div
+                                       bind:this={streamingCodeScrollContainer}
+                                       class="streaming-code-scroll-container"
+                                       onscroll={() => streamingAutoScroll.handleScroll()}
+                               >
+                                       <pre class="streaming-code-pre"><code
+                                                       class="hljs language-{incompleteCodeBlock.language || 'text'}"
+                                                       >{@html highlightCode(
+                                                               incompleteCodeBlock.code,
+                                                               incompleteCodeBlock.language || 'text'
+                                                       )}</code
+                                               ></pre>
+                               </div>
                        </div>
-               </div>
+               {/if}
        {/if}
 </div>
 
        onOpenChange={handlePreviewDialogOpenChange}
 />
 
-<style>
-       .markdown-block--unstable {
-               display: contents;
-       }
-
-       /* Streaming code block uses .code-block-wrapper styles */
-       .streaming-code-block .streaming-code-pre {
-               background: transparent;
-               padding: 0.5rem;
-               margin: 0;
-               overflow-x: visible;
-               border-radius: 0;
-               border: none;
-               font-size: 0.875rem;
-       }
-
-       /* Base typography styles */
-       div :global(p) {
-               margin-block: 1rem;
-               line-height: 1.75;
-       }
-
-       div :global(:is(h1, h2, h3, h4, h5, h6):first-child) {
-               margin-top: 0;
-       }
-
-       /* Headers with consistent spacing */
-       div :global(h1) {
-               font-size: 1.875rem;
-               font-weight: 700;
-               line-height: 1.2;
-               margin: 1.5rem 0 0.75rem 0;
-       }
-
-       div :global(h2) {
-               font-size: 1.5rem;
-               font-weight: 600;
-               line-height: 1.3;
-               margin: 1.25rem 0 0.5rem 0;
-       }
-
-       div :global(h3) {
-               font-size: 1.25rem;
-               font-weight: 600;
-               margin: 1.5rem 0 0.5rem 0;
-               line-height: 1.4;
-       }
-
-       div :global(h4) {
-               font-size: 1.125rem;
-               font-weight: 600;
-               margin: 0.75rem 0 0.25rem 0;
-       }
-
-       div :global(h5) {
-               font-size: 1rem;
-               font-weight: 600;
-               margin: 0.5rem 0 0.25rem 0;
-       }
-
-       div :global(h6) {
-               font-size: 0.875rem;
-               font-weight: 600;
-               margin: 0.5rem 0 0.25rem 0;
-       }
-
-       /* Text formatting */
-       div :global(strong) {
-               font-weight: 600;
-       }
-
-       div :global(em) {
-               font-style: italic;
-       }
-
-       div :global(del) {
-               text-decoration: line-through;
-               opacity: 0.7;
-       }
-
-       /* Inline code */
-       div :global(code:not(pre code)) {
-               background: var(--muted);
-               color: var(--muted-foreground);
-               padding: 0.125rem 0.375rem;
-               border-radius: 0.375rem;
-               font-size: 0.875rem;
-       }
-
-       div :global(pre) {
-               display: inline;
-               margin: 0 !important;
-               overflow: hidden !important;
-               background: var(--muted);
-               overflow-x: auto;
-               border-radius: 1rem;
-               border: none;
-               line-height: 1 !important;
-       }
-
-       div :global(pre code) {
-               padding: 0 !important;
-               display: inline !important;
-       }
-
-       div :global(code) {
-               background: transparent;
-               color: var(--code-foreground);
-       }
-
-       /* Links */
-       div :global(a) {
-               color: var(--primary);
-               text-decoration: underline;
-               text-underline-offset: 2px;
-               transition: color 0.2s ease;
-               overflow-wrap: anywhere;
-               word-break: break-all;
-       }
-
-       div :global(a:hover) {
-               color: var(--primary);
-       }
-
-       /* Lists */
-       div :global(ul) {
-               list-style-type: disc;
-               margin-inline-start: 1.5rem;
-               margin-bottom: 1rem;
-       }
-
-       div :global(ol) {
-               list-style-type: decimal;
-               margin-inline-start: 1.5rem;
-               margin-bottom: 1rem;
-       }
-
-       div :global(li) {
-               margin-bottom: 0.25rem;
-               padding-inline-start: 0.5rem;
-       }
-
-       div :global(li::marker) {
-               color: var(--muted-foreground);
-       }
-
-       /* Nested lists */
-       div :global(ul ul) {
-               list-style-type: circle;
-               margin-top: 0.25rem;
-               margin-bottom: 0.25rem;
-       }
-
-       div :global(ol ol) {
-               list-style-type: lower-alpha;
-               margin-top: 0.25rem;
-               margin-bottom: 0.25rem;
-       }
-
-       /* Task lists */
-       div :global(.task-list-item) {
-               list-style: none;
-               margin-inline-start: 0;
-               padding-inline-start: 0;
-       }
-
-       div :global(.task-list-item-checkbox) {
-               margin-right: 0.5rem;
-               margin-top: 0.125rem;
-       }
-
-       /* Blockquotes */
-       div :global(blockquote) {
-               border-left: 4px solid var(--border);
-               padding: 0.5rem 1rem;
-               margin: 1.5rem 0;
-               font-style: italic;
-               color: var(--muted-foreground);
-               background: var(--muted);
-               border-radius: 0 0.375rem 0.375rem 0;
-       }
-
-       /* Tables */
-       div :global(table) {
-               width: 100%;
-               margin: 1.5rem 0;
-               border-collapse: collapse;
-               border: 1px solid var(--border);
-               border-radius: 0.375rem;
-               overflow: hidden;
-       }
-
-       div :global(th) {
-               background: hsl(var(--muted) / 0.3);
-               border: 1px solid var(--border);
-               padding: 0.5rem 0.75rem;
-               text-align: left;
-               font-weight: 600;
-       }
-
-       div :global(td) {
-               border: 1px solid var(--border);
-               padding: 0.5rem 0.75rem;
-       }
-
-       div :global(tr:nth-child(even)) {
-               background: hsl(var(--muted) / 0.1);
-       }
-
-       /* User message markdown should keep table borders visible on light primary backgrounds */
-       div.markdown-user-content :global(table),
-       div.markdown-user-content :global(th),
-       div.markdown-user-content :global(td),
-       div.markdown-user-content :global(.table-wrapper) {
-               border-color: currentColor;
-       }
-
-       /* Horizontal rules */
-       div :global(hr) {
-               border: none;
-               border-top: 1px solid var(--border);
-               margin: 1.5rem 0;
-       }
-
-       /* Images */
-       div :global(img) {
-               border-radius: 0.5rem;
-               box-shadow:
-                       0 1px 3px 0 rgb(0 0 0 / 0.1),
-                       0 1px 2px -1px rgb(0 0 0 / 0.1);
-               margin: 1.5rem 0;
-               max-width: 100%;
-               height: auto;
-       }
-
-       /* Code blocks */
-
-       div :global(.code-block-wrapper) {
-               margin: 1.5rem 0;
-               border-radius: 0.75rem;
-               overflow: hidden;
-               border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
-               background: var(--code-background);
-               box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
-               min-height: var(--min-message-height);
-               max-height: var(--max-message-height);
-       }
-
-       :global(.dark) div :global(.code-block-wrapper) {
-               border-color: color-mix(in oklch, var(--border) 20%, transparent);
-       }
-
-       /* Scroll container for code blocks (both streaming and completed) */
-       div :global(.code-block-scroll-container),
-       .streaming-code-scroll-container {
-               min-height: var(--min-message-height);
-               max-height: var(--max-message-height);
-               overflow-y: auto;
-               overflow-x: auto;
-               padding: 3rem 1rem 1rem;
-               line-height: 1.3;
-       }
-
-       .full-height-code-blocks :global(.code-block-wrapper) {
-               max-height: none;
-       }
-
-       .full-height-code-blocks :global(.code-block-scroll-container),
-       .full-height-code-blocks .streaming-code-scroll-container {
-               max-height: none;
-               overflow-y: visible;
-       }
-
-       div :global(.code-block-header) {
-               display: flex;
-               justify-content: space-between;
-               align-items: center;
-               padding: 0.5rem 1rem 0;
-               font-size: 0.875rem;
-               position: absolute;
-               top: 0;
-               left: 0;
-               right: 0;
-       }
-
-       div :global(.code-language) {
-               color: var(--color-foreground);
-               font-weight: 500;
-               font-family:
-                       ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
-                       'Liberation Mono', Menlo, monospace;
-               text-transform: uppercase;
-               font-size: 0.75rem;
-               letter-spacing: 0.05em;
-       }
-
-       div :global(.code-block-actions) {
-               display: flex;
-               align-items: center;
-               gap: 0.5rem;
-       }
-
-       div :global(.copy-code-btn),
-       div :global(.preview-code-btn) {
-               display: flex;
-               align-items: center;
-               justify-content: center;
-               padding: 0;
-               background: transparent;
-               color: var(--code-foreground);
-               cursor: pointer;
-               transition: all 0.2s ease;
-       }
-
-       div :global(.copy-code-btn:hover),
-       div :global(.preview-code-btn:hover) {
-               transform: scale(1.05);
-       }
-
-       div :global(.copy-code-btn:active),
-       div :global(.preview-code-btn:active) {
-               transform: scale(0.95);
-       }
-
-       div :global(.code-block-wrapper pre) {
-               background: transparent;
-               margin: 0;
-               border-radius: 0;
-               border: none;
-               font-size: 0.875rem;
-       }
-
-       /* Mentions and hashtags */
-       div :global(.mention) {
-               color: hsl(var(--primary));
-               font-weight: 500;
-               text-decoration: none;
-       }
-
-       div :global(.mention:hover) {
-               text-decoration: underline;
-       }
-
-       div :global(.hashtag) {
-               color: hsl(var(--primary));
-               font-weight: 500;
-               text-decoration: none;
-       }
-
-       div :global(.hashtag:hover) {
-               text-decoration: underline;
-       }
-
-       /* Advanced table enhancements */
-       div :global(table) {
-               transition: all 0.2s ease;
-       }
-
-       div :global(table:hover) {
-               box-shadow:
-                       0 4px 6px -1px rgb(0 0 0 / 0.1),
-                       0 2px 4px -2px rgb(0 0 0 / 0.1);
-       }
-
-       div :global(th:hover),
-       div :global(td:hover) {
-               background: var(--muted);
-       }
-
-       /* Disable hover effects when rendering user messages */
-       .markdown-user-content :global(a),
-       .markdown-user-content :global(a:hover) {
-               color: inherit;
-       }
-
-       .markdown-user-content :global(table:hover) {
-               box-shadow: none;
-       }
-
-       .markdown-user-content :global(th:hover),
-       .markdown-user-content :global(td:hover) {
-               background: inherit;
-       }
-
-       /* Enhanced blockquotes */
-       div :global(blockquote) {
-               transition: all 0.2s ease;
-               position: relative;
-       }
-
-       div :global(blockquote:hover) {
-               border-left-width: 6px;
-               background: var(--muted);
-               transform: translateX(2px);
-       }
-
-       div :global(blockquote::before) {
-               content: '"';
-               position: absolute;
-               top: -0.5rem;
-               left: 0.5rem;
-               font-size: 3rem;
-               color: var(--muted-foreground);
-               font-family: serif;
-               line-height: 1;
-       }
-
-       /* Enhanced images */
-       div :global(img) {
-               transition: all 0.3s ease;
-               cursor: pointer;
-       }
-
-       div :global(img:hover) {
-               transform: scale(1.02);
-               box-shadow:
-                       0 10px 15px -3px rgb(0 0 0 / 0.1),
-                       0 4px 6px -4px rgb(0 0 0 / 0.1);
-       }
-
-       /* Image zoom overlay */
-       div :global(.image-zoom-overlay) {
-               position: fixed;
-               top: 0;
-               left: 0;
-               right: 0;
-               bottom: 0;
-               background: rgba(0, 0, 0, 0.8);
-               display: flex;
-               align-items: center;
-               justify-content: center;
-               z-index: 1000;
-               cursor: pointer;
-       }
-
-       div :global(.image-zoom-overlay img) {
-               max-width: 90vw;
-               max-height: 90vh;
-               border-radius: 0.5rem;
-               box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
-       }
-
-       /* Enhanced horizontal rules */
-       div :global(hr) {
-               border: none;
-               height: 2px;
-               background: linear-gradient(to right, transparent, var(--border), transparent);
-               margin: 2rem 0;
-               position: relative;
-       }
-
-       div :global(hr::after) {
-               content: '';
-               position: absolute;
-               top: 50%;
-               left: 50%;
-               transform: translate(-50%, -50%);
-               width: 1rem;
-               height: 1rem;
-               background: var(--border);
-               border-radius: 50%;
-       }
-
-       /* Scrollable tables */
-       div :global(.table-wrapper) {
-               overflow-x: auto;
-               margin: 1.5rem 0;
-               border-radius: 0.5rem;
-               border: 1px solid var(--border);
-       }
-
-       div :global(.table-wrapper table) {
-               margin: 0;
-               border: none;
-       }
-
-       /* Responsive adjustments */
-       @media (max-width: 640px) {
-               div :global(h1) {
-                       font-size: 1.5rem;
-               }
-
-               div :global(h2) {
-                       font-size: 1.25rem;
-               }
-
-               div :global(h3) {
-                       font-size: 1.125rem;
-               }
-
-               div :global(table) {
-                       font-size: 0.875rem;
-               }
-
-               div :global(th),
-               div :global(td) {
-                       padding: 0.375rem 0.5rem;
-               }
-
-               div :global(.table-wrapper) {
-                       margin: 0.5rem -1rem;
-                       border-radius: 0;
-                       border-left: none;
-                       border-right: none;
-               }
-       }
-
-       /* Dark mode adjustments */
-       @media (prefers-color-scheme: dark) {
-               div :global(blockquote:hover) {
-                       background: var(--muted);
-               }
-       }
-
-       /* Image load error fallback */
-       div :global(.image-load-error) {
-               display: flex;
-               align-items: center;
-               justify-content: center;
-               margin: 1.5rem 0;
-               padding: 1.5rem;
-               border-radius: 0.5rem;
-               background: var(--muted);
-               border: 1px dashed var(--border);
-       }
-
-       div :global(.image-error-content) {
-               display: flex;
-               flex-direction: column;
-               align-items: center;
-               gap: 0.75rem;
-               color: var(--muted-foreground);
-               text-align: center;
-       }
-
-       div :global(.image-error-content svg) {
-               opacity: 0.5;
-       }
-
-       div :global(.image-error-text) {
-               font-size: 0.875rem;
-       }
-
-       div :global(.image-error-link) {
-               display: inline-flex;
-               align-items: center;
-               gap: 0.375rem;
-               padding: 0.5rem 1rem;
-               font-size: 0.875rem;
-               font-weight: 500;
-               color: var(--primary);
-               background: var(--background);
-               border: 1px solid var(--border);
-               border-radius: 0.375rem;
-               text-decoration: none;
-               transition: all 0.2s ease;
-       }
+<DialogMermaidPreview
+       open={mermaidPreviewOpen}
+       svgHtml={mermaidPreviewSvgHtml}
+       onOpenChange={handleMermaidPreviewOpenChange}
+/>
 
-       div :global(.image-error-link:hover) {
-               background: var(--muted);
-               border-color: var(--primary);
-       }
+<style>
+       @import './markdown-content.css';
 </style>
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css
new file mode 100644 (file)
index 0000000..07904f7
--- /dev/null
@@ -0,0 +1,685 @@
+.markdown-block--unstable {
+       display: contents;
+}
+
+/* Streaming code block uses .code-block-wrapper styles */
+.streaming-code-block .streaming-code-pre {
+       background: transparent;
+       padding: 0.5rem;
+       margin: 0;
+       overflow-x: visible;
+       border-radius: 0;
+       border: none;
+       font-size: 0.875rem;
+}
+
+/* Base typography styles */
+.markdown-content :global(p) {
+       margin-block: 1rem;
+       line-height: 1.75;
+}
+
+.markdown-content :global(:is(h1, h2, h3, h4, h5, h6):first-child) {
+       margin-top: 0;
+}
+
+/* Headers with consistent spacing */
+.markdown-content :global(h1) {
+       font-size: 1.875rem;
+       font-weight: 700;
+       line-height: 1.2;
+       margin: 1.5rem 0 0.75rem 0;
+}
+
+.markdown-content :global(h2) {
+       font-size: 1.5rem;
+       font-weight: 600;
+       line-height: 1.3;
+       margin: 1.25rem 0 0.5rem 0;
+}
+
+.markdown-content :global(h3) {
+       font-size: 1.25rem;
+       font-weight: 600;
+       margin: 1.5rem 0 0.5rem 0;
+       line-height: 1.4;
+}
+
+.markdown-content :global(h4) {
+       font-size: 1.125rem;
+       font-weight: 600;
+       margin: 0.75rem 0 0.25rem 0;
+}
+
+.markdown-content :global(h5) {
+       font-size: 1rem;
+       font-weight: 600;
+       margin: 0.5rem 0 0.25rem 0;
+}
+
+.markdown-content :global(h6) {
+       font-size: 0.875rem;
+       font-weight: 600;
+       margin: 0.5rem 0 0.25rem 0;
+}
+
+/* Text formatting */
+.markdown-content :global(strong) {
+       font-weight: 600;
+}
+
+.markdown-content :global(em) {
+       font-style: italic;
+}
+
+.markdown-content :global(del) {
+       text-decoration: line-through;
+       opacity: 0.7;
+}
+
+/* Inline code */
+.markdown-content :global(code:not(pre code)) {
+       background: var(--muted);
+       color: var(--muted-foreground);
+       padding: 0.125rem 0.375rem;
+       border-radius: 0.375rem;
+       font-size: 0.875rem;
+}
+
+.markdown-content :global(pre) {
+       display: inline;
+       margin: 0 !important;
+       overflow: hidden !important;
+       background: var(--muted);
+       overflow-x: auto;
+       border-radius: 1rem;
+       border: none;
+       line-height: 1 !important;
+}
+
+.markdown-content :global(pre code) {
+       padding: 0 !important;
+       display: inline !important;
+}
+
+.markdown-content :global(code) {
+       background: transparent;
+       color: var(--code-foreground);
+}
+
+/* Links */
+.markdown-content :global(a) {
+       color: var(--primary);
+       text-decoration: underline;
+       text-underline-offset: 2px;
+       transition: color 0.2s ease;
+       overflow-wrap: anywhere;
+       word-break: break-all;
+}
+
+.markdown-content :global(a:hover) {
+       color: var(--primary);
+}
+
+/* Lists */
+.markdown-content :global(ul) {
+       list-style-type: disc;
+       margin-inline-start: 1.5rem;
+       margin-bottom: 1rem;
+}
+
+.markdown-content :global(ol) {
+       list-style-type: decimal;
+       margin-inline-start: 1.5rem;
+       margin-bottom: 1rem;
+}
+
+.markdown-content :global(li) {
+       margin-bottom: 0.25rem;
+       padding-inline-start: 0.5rem;
+}
+
+.markdown-content :global(li::marker) {
+       color: var(--muted-foreground);
+}
+
+/* Nested lists */
+.markdown-content :global(ul ul) {
+       list-style-type: circle;
+       margin-top: 0.25rem;
+       margin-bottom: 0.25rem;
+}
+
+.markdown-content :global(ol ol) {
+       list-style-type: lower-alpha;
+       margin-top: 0.25rem;
+       margin-bottom: 0.25rem;
+}
+
+/* Task lists */
+.markdown-content :global(.task-list-item) {
+       list-style: none;
+       margin-inline-start: 0;
+       padding-inline-start: 0;
+}
+
+.markdown-content :global(.task-list-item-checkbox) {
+       margin-right: 0.5rem;
+       margin-top: 0.125rem;
+}
+
+/* Blockquotes */
+.markdown-content :global(blockquote) {
+       border-left: 4px solid var(--border);
+       padding: 0.5rem 1rem;
+       margin: 1.5rem 0;
+       font-style: italic;
+       color: var(--muted-foreground);
+       background: var(--muted);
+       border-radius: 0 0.375rem 0.375rem 0;
+}
+
+/* Tables */
+.markdown-content :global(table) {
+       width: 100%;
+       margin: 1.5rem 0;
+       border-collapse: collapse;
+       border: 1px solid var(--border);
+       border-radius: 0.375rem;
+       overflow: hidden;
+}
+
+.markdown-content :global(th) {
+       background: hsl(var(--muted) / 0.3);
+       border: 1px solid var(--border);
+       padding: 0.5rem 0.75rem;
+       text-align: left;
+       font-weight: 600;
+}
+
+.markdown-content :global(td) {
+       border: 1px solid var(--border);
+       padding: 0.5rem 0.75rem;
+}
+
+.markdown-content :global(tr:nth-child(even)) {
+       background: hsl(var(--muted) / 0.1);
+}
+
+/* User message markdown should keep table borders visible on light primary backgrounds */
+div.markdown-user-content :global(table),
+div.markdown-user-content :global(th),
+div.markdown-user-content :global(td),
+div.markdown-user-content :global(.table-wrapper) {
+       border-color: currentColor;
+}
+
+/* Horizontal rules */
+.markdown-content :global(hr) {
+       border: none;
+       border-top: 1px solid var(--border);
+       margin: 1.5rem 0;
+}
+
+/* Images */
+.markdown-content :global(img) {
+       border-radius: 0.5rem;
+       box-shadow:
+               0 1px 3px 0 rgb(0 0 0 / 0.1),
+               0 1px 2px -1px rgb(0 0 0 / 0.1);
+       margin: 1.5rem 0;
+       max-width: 100%;
+       height: auto;
+}
+
+/* Code blocks */
+
+.markdown-content :global(.code-block-wrapper) {
+       margin: 1.5rem 0;
+       border-radius: 0.75rem;
+       overflow: hidden;
+       border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
+       background: var(--code-background);
+       box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+       min-height: var(--min-message-height);
+       max-height: var(--max-message-height);
+}
+
+.markdown-content:global(.dark) :global(.code-block-wrapper) {
+       border-color: color-mix(in oklch, var(--border) 20%, transparent);
+}
+
+/* Scroll container for code blocks (both streaming and completed) */
+.markdown-content :global(.code-block-scroll-container),
+.streaming-code-scroll-container {
+       min-height: var(--min-message-height);
+       max-height: var(--max-message-height);
+       overflow-y: auto;
+       overflow-x: auto;
+       padding: 3rem 1rem 1rem;
+       line-height: 1.3;
+}
+
+.full-height-code-blocks :global(.code-block-wrapper) {
+       max-height: none;
+}
+
+.full-height-code-blocks :global(.code-block-scroll-container),
+.full-height-code-blocks .streaming-code-scroll-container {
+       max-height: none;
+       overflow-y: visible;
+}
+
+.markdown-content :global(.code-block-header) {
+       display: flex;
+       justify-content: space-between;
+       align-items: center;
+       padding: 0.5rem 1rem 0;
+       font-size: 0.875rem;
+       position: absolute;
+       top: 0;
+       left: 0;
+       right: 0;
+}
+
+.markdown-content :global(.code-language) {
+       color: var(--color-foreground);
+       font-weight: 500;
+       font-family:
+               ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
+               'Liberation Mono', Menlo, monospace;
+       text-transform: uppercase;
+       font-size: 0.75rem;
+       letter-spacing: 0.05em;
+}
+
+.markdown-content :global(.code-block-actions) {
+       display: flex;
+       align-items: center;
+       gap: 0.5rem;
+}
+
+.markdown-content :global(.copy-code-btn),
+.markdown-content :global(.preview-code-btn) {
+       display: flex;
+       align-items: center;
+       justify-content: center;
+       padding: 0;
+       background: transparent;
+       color: var(--code-foreground);
+       cursor: pointer;
+       transition: all 0.2s ease;
+}
+
+.markdown-content :global(.copy-code-btn:hover),
+.markdown-content :global(.preview-code-btn:hover) {
+       transform: scale(1.05);
+}
+
+.markdown-content :global(.copy-code-btn:active),
+.markdown-content :global(.preview-code-btn:active) {
+       transform: scale(0.95);
+}
+
+.markdown-content :global(.code-block-wrapper pre) {
+       background: transparent;
+       margin: 0;
+       border-radius: 0;
+       border: none;
+       font-size: 0.875rem;
+}
+
+/* Mentions and hashtags */
+.markdown-content :global(.mention) {
+       color: hsl(var(--primary));
+       font-weight: 500;
+       text-decoration: none;
+}
+
+.markdown-content :global(.mention:hover) {
+       text-decoration: underline;
+}
+
+.markdown-content :global(.hashtag) {
+       color: hsl(var(--primary));
+       font-weight: 500;
+       text-decoration: none;
+}
+
+.markdown-content :global(.hashtag:hover) {
+       text-decoration: underline;
+}
+
+/* Advanced table enhancements */
+.markdown-content :global(table) {
+       transition: all 0.2s ease;
+}
+
+.markdown-content :global(table:hover) {
+       box-shadow:
+               0 4px 6px -1px rgb(0 0 0 / 0.1),
+               0 2px 4px -2px rgb(0 0 0 / 0.1);
+}
+
+.markdown-content :global(th:hover),
+.markdown-content :global(td:hover) {
+       background: var(--muted);
+}
+
+/* Disable hover effects when rendering user messages */
+.markdown-user-content :global(a),
+.markdown-user-content :global(a:hover) {
+       color: inherit;
+}
+
+.markdown-user-content :global(table:hover) {
+       box-shadow: none;
+}
+
+.markdown-user-content :global(th:hover),
+.markdown-user-content :global(td:hover) {
+       background: inherit;
+}
+
+/* Enhanced blockquotes */
+.markdown-content :global(blockquote) {
+       transition: all 0.2s ease;
+       position: relative;
+}
+
+.markdown-content :global(blockquote:hover) {
+       border-left-width: 6px;
+       background: var(--muted);
+       transform: translateX(2px);
+}
+
+.markdown-content :global(blockquote::before) {
+       content: '"';
+       position: absolute;
+       top: -0.5rem;
+       left: 0.5rem;
+       font-size: 3rem;
+       color: var(--muted-foreground);
+       font-family: serif;
+       line-height: 1;
+}
+
+/* Enhanced images */
+.markdown-content :global(img) {
+       transition: all 0.3s ease;
+       cursor: pointer;
+}
+
+.markdown-content :global(img:hover) {
+       transform: scale(1.02);
+       box-shadow:
+               0 10px 15px -3px rgb(0 0 0 / 0.1),
+               0 4px 6px -4px rgb(0 0 0 / 0.1);
+}
+
+/* Image zoom overlay */
+.markdown-content :global(.image-zoom-overlay) {
+       position: fixed;
+       top: 0;
+       left: 0;
+       right: 0;
+       bottom: 0;
+       background: rgba(0, 0, 0, 0.8);
+       display: flex;
+       align-items: center;
+       justify-content: center;
+       z-index: 1000;
+       cursor: pointer;
+}
+
+.markdown-content :global(.image-zoom-overlay img) {
+       max-width: 90vw;
+       max-height: 90vh;
+       border-radius: 0.5rem;
+       box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
+}
+
+/* Enhanced horizontal rules */
+.markdown-content :global(hr) {
+       border: none;
+       height: 2px;
+       background: linear-gradient(to right, transparent, var(--border), transparent);
+       margin: 2rem 0;
+       position: relative;
+}
+
+.markdown-content :global(hr::after) {
+       content: '';
+       position: absolute;
+       top: 50%;
+       left: 50%;
+       transform: translate(-50%, -50%);
+       width: 1rem;
+       height: 1rem;
+       background: var(--border);
+       border-radius: 50%;
+}
+
+/* Scrollable tables */
+.markdown-content :global(.table-wrapper) {
+       overflow-x: auto;
+       margin: 1.5rem 0;
+       border-radius: 0.5rem;
+       border: 1px solid var(--border);
+}
+
+.markdown-content :global(.table-wrapper table) {
+       margin: 0;
+       border: none;
+}
+
+/* Responsive adjustments */
+@media (max-width: 640px) {
+       .markdown-content :global(h1) {
+               font-size: 1.5rem;
+       }
+
+       .markdown-content :global(h2) {
+               font-size: 1.25rem;
+       }
+
+       .markdown-content :global(h3) {
+               font-size: 1.125rem;
+       }
+
+       .markdown-content :global(table) {
+               font-size: 0.875rem;
+       }
+
+       .markdown-content :global(th),
+       .markdown-content :global(td) {
+               padding: 0.375rem 0.5rem;
+       }
+
+       .markdown-content :global(.table-wrapper) {
+               margin: 0.5rem -1rem;
+               border-radius: 0;
+               border-left: none;
+               border-right: none;
+       }
+}
+
+/* Dark mode adjustments */
+@media (prefers-color-scheme: dark) {
+       .markdown-content :global(blockquote:hover) {
+               background: var(--muted);
+       }
+}
+
+/* Image load error fallback */
+.markdown-content :global(.image-load-error) {
+       display: flex;
+       align-items: center;
+       justify-content: center;
+       margin: 1.5rem 0;
+       padding: 1.5rem;
+       border-radius: 0.5rem;
+       background: var(--muted);
+       border: 1px dashed var(--border);
+}
+
+.markdown-content :global(.image-error-content) {
+       display: flex;
+       flex-direction: column;
+       align-items: center;
+       gap: 0.75rem;
+       color: var(--muted-foreground);
+       text-align: center;
+}
+
+.markdown-content :global(.image-error-content svg) {
+       opacity: 0.5;
+}
+
+.markdown-content :global(.image-error-text) {
+       font-size: 0.875rem;
+}
+
+.markdown-content :global(.image-error-link) {
+       display: inline-flex;
+       align-items: center;
+       gap: 0.375rem;
+       padding: 0.5rem 1rem;
+       font-size: 0.875rem;
+       font-weight: 500;
+       color: var(--primary);
+       background: var(--background);
+       border: 1px solid var(--border);
+       border-radius: 0.375rem;
+       text-decoration: none;
+       transition: all 0.2s ease;
+}
+
+.markdown-content :global(.image-error-link:hover) {
+       background: var(--muted);
+       border-color: var(--primary);
+}
+
+/* Mermaid diagrams */
+.markdown-content :global(pre.mermaid) {
+       background: transparent;
+       border: none;
+       padding: 0;
+       text-align: center;
+       font-family: inherit;
+       cursor: pointer;
+       transition: opacity 0.15s ease;
+       position: relative;
+}
+
+/* Hide mermaid code text until rendered - prevents flash */
+.markdown-content :global(pre.mermaid:not([data-mermaid-rendered])),
+.markdown-content :global(pre.mermaid[data-mermaid-rendered]:not(:has(svg))) {
+       display: none;
+}
+
+.markdown-content :global(pre.mermaid:hover) {
+       opacity: 0.85;
+}
+
+.markdown-content :global(pre.mermaid svg) {
+       max-width: 90%;
+       margin: 0 auto;
+       height: auto;
+       display: block;
+       padding: 3rem 1rem;
+}
+
+/* Mermaid block wrapper - matches code block styling */
+.markdown-content :global(.mermaid-block-wrapper) {
+       margin: 1.5rem 0;
+       border-radius: 0.75rem;
+       overflow: hidden;
+       border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
+       background: var(--code-background);
+       box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+       position: relative;
+       min-height: var(--min-message-height);
+       max-height: var(--max-message-height);
+}
+
+.markdown-content:global(.dark) :global(.mermaid-block-wrapper) {
+       border-color: color-mix(in oklch, var(--border) 20%, transparent);
+}
+
+.markdown-content :global(.mermaid-scroll-container) {
+       min-height: 350px;
+       max-height: var(--max-message-height);
+       overflow-y: auto;
+       overflow-x: auto;
+       display: flex;
+       align-items: center;
+       justify-content: center;
+       padding: 3rem 1rem 1rem;
+}
+
+.full-height-code-blocks :global(.mermaid-block-wrapper) {
+       max-height: none;
+}
+
+.full-height-code-blocks :global(.mermaid-scroll-container) {
+       max-height: none;
+       overflow-y: visible;
+}
+
+/* Mermaid block uses same header styling as code blocks */
+.markdown-content :global(.mermaid-block-wrapper .code-block-header) {
+       display: flex;
+       justify-content: space-between;
+       align-items: center;
+       padding: 0.5rem 1rem 0;
+       font-size: 0.875rem;
+       position: absolute;
+       top: 0;
+       left: 0;
+       right: 0;
+}
+
+.markdown-content :global(.mermaid-block-wrapper .code-block-actions) {
+       display: flex;
+       align-items: center;
+       gap: 0.5rem;
+}
+
+/* Mermaid pre element - remove default margins */
+.markdown-content :global(.mermaid-block-wrapper pre.mermaid) {
+       background: transparent;
+       border: none;
+       padding: 0;
+       margin: 0;
+       text-align: center;
+}
+
+/* Mermaid SVG should be bigger */
+.markdown-content :global(.mermaid-block-wrapper pre.mermaid svg) {
+       width: unset !important;
+       height: auto;
+       display: block;
+       padding: 3rem 1rem;
+}
+
+/* Streaming mermaid block - empty preview box */
+.mermaid-streaming-block {
+       min-height: 300px;
+       display: flex;
+       align-items: center;
+       justify-content: center;
+}
+
+.mermaid-loading-placeholder {
+       display: flex;
+       align-items: center;
+       justify-content: center;
+       padding: 3rem;
+       color: var(--muted-foreground);
+}
+
+.mermaid-loading-text {
+       font-size: 0.875rem;
+       font-style: italic;
+}
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts
new file mode 100644 (file)
index 0000000..5544084
--- /dev/null
@@ -0,0 +1,264 @@
+/**
+ * Event handler factories for markdown content components.
+ * Uses dependency injection pattern to avoid direct component state access.
+ */
+
+import { copyCodeToClipboard, copyToClipboard } from '$lib/utils';
+
+export interface PreviewState {
+       previewDialogOpen: boolean;
+       previewCode: string;
+       previewLanguage: string;
+       setPreviewDialogOpen: (open: boolean) => void;
+       setPreviewCode: (code: string) => void;
+       setPreviewLanguage: (lang: string) => void;
+}
+
+export interface MermaidPreviewState {
+       mermaidPreviewOpen: boolean;
+       mermaidPreviewSvgHtml: string;
+       setMermaidPreviewOpen: (open: boolean) => void;
+       setMermaidPreviewSvgHtml: (html: string) => void;
+}
+
+export interface RenderedBlocksState {
+       renderedBlocks: Array<{ id: string; html: string; contentHash?: string }>;
+       setRenderedBlocks: (blocks: Array<{ id: string; html: string; contentHash?: string }>) => void;
+}
+
+/**
+ * Creates a click handler for copy buttons in code blocks.
+ * Copies the code content to clipboard.
+ */
+export function createHandleCopyClick() {
+       return async function handleCopyClick(event: Event) {
+               event.preventDefault();
+               event.stopPropagation();
+
+               const target = event.currentTarget as HTMLButtonElement | null;
+               if (!target) return;
+
+               const wrapper = target.closest('.code-block-wrapper');
+               if (!wrapper) return;
+
+               const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
+               if (!codeElement) return;
+
+               const rawCode = codeElement.textContent ?? '';
+
+               try {
+                       await copyCodeToClipboard(rawCode);
+               } catch (error) {
+                       console.error('Failed to copy code:', error);
+               }
+       };
+}
+
+/**
+ * Creates a handler for preview dialog open state changes.
+ * Clears preview content when dialog is closed.
+ */
+export function createHandlePreviewDialogOpenChange(previewState: PreviewState) {
+       return function handlePreviewDialogOpenChange(open: boolean) {
+               previewState.setPreviewDialogOpen(open);
+
+               if (!open) {
+                       previewState.setPreviewCode('');
+                       previewState.setPreviewLanguage('text');
+               }
+       };
+}
+
+/**
+ * Creates a click handler for preview buttons within HTML code blocks.
+ * Opens a preview dialog with the rendered HTML content.
+ */
+export function createHandlePreviewClick(previewState: PreviewState) {
+       return async function handlePreviewClick(event: Event) {
+               event.preventDefault();
+               event.stopPropagation();
+
+               const target = event.currentTarget as HTMLButtonElement | null;
+               if (!target) return;
+
+               const wrapper = target.closest('.code-block-wrapper');
+               if (!wrapper) return;
+
+               const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
+               if (!codeElement) return;
+
+               const rawCode = codeElement.textContent ?? '';
+               const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
+               const language = languageLabel?.textContent?.trim() || 'text';
+
+               previewState.setPreviewCode(rawCode);
+               previewState.setPreviewLanguage(language);
+               previewState.setPreviewDialogOpen(true);
+       };
+}
+
+/**
+ * Creates a click handler for mermaid block interactions.
+ * Handles copy, preview, and diagram click events via event delegation.
+ */
+export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
+       return async function handleMermaidClick(event: MouseEvent) {
+               const target = event.target as HTMLElement;
+
+               // Check if clicking on copy or preview button in mermaid block
+               const copyBtn = target.closest('.mermaid-block-wrapper .copy-code-btn');
+               const previewBtn = target.closest('.mermaid-block-wrapper .preview-code-btn');
+
+               if (copyBtn || previewBtn) {
+                       const wrapper = target.closest('.mermaid-block-wrapper');
+                       if (!wrapper) return;
+
+                       const preElement = wrapper.querySelector<HTMLElement>('pre.mermaid[data-mermaid-syntax]');
+                       if (!preElement) return;
+
+                       const mermaidSyntax = preElement.dataset.mermaidSyntax ?? '';
+
+                       if (copyBtn) {
+                               event.preventDefault();
+                               event.stopPropagation();
+                               try {
+                                       await copyToClipboard(mermaidSyntax);
+                               } catch (error) {
+                                       console.error('Failed to copy mermaid syntax:', error);
+                               }
+                               return;
+                       }
+
+                       if (previewBtn) {
+                               event.preventDefault();
+                               event.stopPropagation();
+                               const svg = preElement.querySelector('svg');
+                               if (!svg) return;
+                               mermaidState.setMermaidPreviewSvgHtml(svg.outerHTML);
+                               mermaidState.setMermaidPreviewOpen(true);
+                               return;
+                       }
+               }
+
+               // Otherwise, open preview when clicking on the mermaid diagram itself
+               const mermaidEl = target.closest('.mermaid');
+               if (!mermaidEl) return;
+
+               const svg = mermaidEl.querySelector('svg');
+               if (!svg) return;
+
+               mermaidState.setMermaidPreviewSvgHtml(svg.outerHTML);
+               mermaidState.setMermaidPreviewOpen(true);
+       };
+}
+
+/**
+ * Creates a handler for mermaid preview dialog open state changes.
+ * Cleans up SVG content when dialog is closed.
+ */
+export function createHandleMermaidPreviewOpenChange(mermaidState: MermaidPreviewState) {
+       return function handleMermaidPreviewOpenChange(open: boolean) {
+               mermaidState.setMermaidPreviewOpen(open);
+               if (!open) {
+                       mermaidState.setMermaidPreviewSvgHtml('');
+               }
+       };
+}
+
+/**
+ * Creates an error handler for images that fail to load (e.g., CORS issues).
+ * Shows fallback UI for broken images.
+ */
+export function createHandleImageError(
+       renderedBlocksState: RenderedBlocksState,
+       IMAGE_NOT_ERROR_BOUND_SELECTOR: string,
+       DATA_ERROR_BOUND_ATTR: string,
+       BOOL_TRUE_STRING: string
+) {
+       return async function handleImageError(event: Event) {
+               const img = event.target as HTMLImageElement;
+               if (!img) return;
+
+               const blockId = img.closest('[data-block-id]')?.getAttribute('data-block-id');
+               if (!blockId) return;
+
+               const block = renderedBlocksState.renderedBlocks.find((b) => b.id === blockId);
+               if (!block) return;
+
+               // Skip if already handled
+               if (img.dataset[DATA_ERROR_BOUND_ATTR] === BOOL_TRUE_STRING) return;
+               img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING;
+
+               // Get the fallback HTML and replace the image
+               const fallbackHtml = `<div class="image-error-placeholder" data-original-src="${img.src}">
+                       <span class="image-error-icon">⚠️</span>
+                       <span class="image-error-text">Failed to load image</span>
+               </div>`;
+
+               // Replace the img element with fallback in the block's HTML
+               const newHtml = block.html.replace(/img[^>]*src=["']([^"']*)[^>]*>/g, (match, src) => {
+                       if (src === img.src) {
+                               return fallbackHtml.replace('data-original-src=""', `data-original-src="${src}"`);
+                       }
+                       return match;
+               });
+
+               // Update the block
+               const newBlocks = renderedBlocksState.renderedBlocks.map((b) =>
+                       b.id === blockId ? { ...b, html: newHtml } : b
+               );
+               renderedBlocksState.setRenderedBlocks(newBlocks);
+       };
+}
+
+/**
+ * Creates a function to set up code block action event listeners.
+ * Binds click handlers to copy and preview buttons within code blocks.
+ */
+export function createSetupCodeBlockActions(
+       handleCopyClick: (event: Event) => void,
+       handlePreviewClick: (event: Event) => void
+) {
+       return function setupCodeBlockActions(containerRef: HTMLElement | null) {
+               if (!containerRef) return;
+
+               const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper');
+
+               for (const wrapper of wrappers) {
+                       const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
+                       const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
+
+                       if (copyButton && copyButton.dataset.listenerBound !== 'true') {
+                               copyButton.dataset.listenerBound = 'true';
+                               copyButton.addEventListener('click', handleCopyClick);
+                       }
+
+                       if (previewButton && previewButton.dataset.listenerBound !== 'true') {
+                               previewButton.dataset.listenerBound = 'true';
+                               previewButton.addEventListener('click', handlePreviewClick);
+                       }
+               }
+       };
+}
+
+/**
+ * Creates a function to set up image error handlers.
+ * Attaches error handlers to images to show fallback UI when loading fails.
+ */
+export function createSetupImageErrorHandlers(
+       handleImageError: (event: Event) => void,
+       IMAGE_NOT_ERROR_BOUND_SELECTOR: string,
+       DATA_ERROR_BOUND_ATTR: string,
+       BOOL_TRUE_STRING: string
+) {
+       return function setupImageErrorHandlers(containerRef: HTMLElement | null) {
+               if (!containerRef) return;
+
+               const images = containerRef.querySelectorAll<HTMLImageElement>(IMAGE_NOT_ERROR_BOUND_SELECTOR);
+
+               for (const img of images) {
+                       img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING;
+                       img.addEventListener('error', handleImageError);
+               }
+       };
+}
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-utils.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-utils.ts
new file mode 100644 (file)
index 0000000..dfb56d5
--- /dev/null
@@ -0,0 +1,84 @@
+/**
+ * Utility functions for markdown processing in MarkdownContent component.
+ */
+
+import type { RootContent as HastRootContent } from 'hast';
+
+/**
+ * Generates a unique identifier for a HAST node based on its position.
+ * Used for stable block identification during incremental rendering.
+ * @param node - The HAST root content node
+ * @param indexFallback - Fallback index if position is unavailable
+ * @returns Unique string identifier for the node
+ */
+export function getHastNodeId(node: HastRootContent, indexFallback: number): string {
+       const position = node.position;
+
+       if (position?.start?.offset != null && position?.end?.offset != null) {
+               return `hast-${position.start.offset}-${position.end.offset}`;
+       }
+
+       return `${node.type}-${indexFallback}`;
+}
+
+/**
+ * Generates a hash for MDAST node based on its position.
+ * Used for cache lookup during incremental rendering.
+ */
+export function getMdastNodeHash(node: unknown, index: number): string {
+       const n = node as {
+               type?: string;
+               position?: { start?: { offset?: number }; end?: { offset?: number } };
+       };
+
+       if (n.position?.start?.offset != null && n.position?.end?.offset != null) {
+               return `${n.type}-${n.position.start.offset}-${n.position.end.offset}`;
+       }
+
+       return `${n.type}-idx${index}`;
+}
+
+/**
+ * Determines if the new content is an append (new content added to existing blocks).
+ * This is used to optimize cache reuse during streaming updates.
+ *
+ * @param newContent - The new markdown content
+ * @param previousContent - The previous markdown content to check against
+ * @returns true if the content appears to be an append operation
+ */
+export function isAppendMode(newContent: string, previousContent: string): boolean {
+       return previousContent.length > 0 && newContent.startsWith(previousContent);
+}
+
+export interface CodeInfo {
+       rawCode: string;
+       language: string;
+}
+
+/**
+ * Extracts code information from a button click target within a code block.
+ * @param target - The clicked button element
+ * @returns Object with rawCode and language, or null if extraction fails
+ */
+export function getCodeInfoFromTarget(target: HTMLElement): CodeInfo | null {
+       const wrapper = target.closest('.code-block-wrapper');
+
+       if (!wrapper) {
+               console.error('No wrapper found');
+               return null;
+       }
+
+       const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
+
+       if (!codeElement) {
+               console.error('No code element found in wrapper');
+               return null;
+       }
+
+       const rawCode = codeElement.textContent ?? '';
+
+       const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
+       const language = languageLabel?.textContent?.trim() || 'text';
+
+       return { rawCode, language };
+}
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/code-block-utils.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/code-block-utils.ts
new file mode 100644 (file)
index 0000000..7323154
--- /dev/null
@@ -0,0 +1,151 @@
+/**
+ * Shared utilities for enhanced code blocks and mermaid diagram blocks.
+ * Contains common HAST element creation functions to avoid code duplication.
+ */
+
+import type { Element, ElementContent } from 'hast';
+import {
+       CODE_BLOCK_HEADER_CLASS,
+       CODE_BLOCK_ACTIONS_CLASS,
+       CODE_LANGUAGE_CLASS,
+       COPY_CODE_BTN_CLASS,
+       PREVIEW_CODE_BTN_CLASS,
+       RELATIVE_CLASS,
+       COPY_ICON_SVG,
+       PREVIEW_ICON_SVG
+} from '$lib/constants';
+
+export interface BlockIdGenerator {
+       (id: number): string;
+}
+
+/**
+ * Creates an icon element with the given SVG content.
+ */
+export function createIconElement(svg: string): Element {
+       return {
+               type: 'element',
+               tagName: 'span',
+               properties: {},
+               children: [{ type: 'raw', value: svg } as unknown as ElementContent]
+       };
+}
+
+/**
+ * Creates a button element with icon.
+ */
+export function createButton(
+       className: string,
+       title: string,
+       iconSvg: string,
+       id: string,
+       idAttribute: string
+): Element {
+       return {
+               type: 'element',
+               tagName: 'button',
+               properties: {
+                       className: [className],
+                       [idAttribute]: id,
+                       title,
+                       type: 'button'
+               },
+               children: [createIconElement(iconSvg)]
+       };
+}
+
+/**
+ * Creates a copy button element.
+ */
+export function createCopyButton(id: string, idAttribute: string, title: string = 'Copy'): Element {
+       return createButton(COPY_CODE_BTN_CLASS, title, COPY_ICON_SVG, id, idAttribute);
+}
+
+/**
+ * Creates a preview button element.
+ */
+export function createPreviewButton(
+       id: string,
+       idAttribute: string,
+       title: string = 'Preview'
+): Element {
+       return createButton(PREVIEW_CODE_BTN_CLASS, title, PREVIEW_ICON_SVG, id, idAttribute);
+}
+
+/**
+ * Creates a block header with language label and action buttons.
+ */
+export function createBlockHeader(
+       language: string,
+       id: string,
+       idAttribute: string,
+       actions: Element[],
+       languageClassName: string = CODE_LANGUAGE_CLASS
+): Element {
+       return {
+               type: 'element',
+               tagName: 'div',
+               properties: { className: [CODE_BLOCK_HEADER_CLASS] },
+               children: [
+                       {
+                               type: 'element',
+                               tagName: 'span',
+                               properties: { className: [languageClassName] },
+                               children: [{ type: 'text', value: language }]
+                       },
+                       {
+                               type: 'element',
+                               tagName: 'div',
+                               properties: { className: [CODE_BLOCK_ACTIONS_CLASS] },
+                               children: actions
+                       }
+               ]
+       };
+}
+
+/**
+ * Creates a scroll container element.
+ */
+export function createScrollContainer(preElement: Element, scrollContainerClass: string): Element {
+       return {
+               type: 'element',
+               tagName: 'div',
+               properties: { className: [scrollContainerClass] },
+               children: [preElement]
+       };
+}
+
+/**
+ * Creates a wrapper element with header and scroll container.
+ */
+export function createWrapper(
+       header: Element,
+       preElement: Element,
+       wrapperClass: string,
+       scrollContainerClass: string,
+       additionalAttributes?: Record<string, string>
+): Element {
+       return {
+               type: 'element',
+               tagName: 'div',
+               properties: {
+                       className: [wrapperClass, RELATIVE_CLASS],
+                       ...additionalAttributes
+               } as Element['properties'],
+               children: [header, createScrollContainer(preElement, scrollContainerClass)]
+       };
+}
+
+/**
+ * Generates a unique block ID using a global counter.
+ */
+export function generateBlockId(prefix: string, windowKey: keyof Window): string {
+       if (typeof window !== 'undefined') {
+               const idx = window[windowKey] as number | undefined;
+               const next = (idx ?? 0) + 1;
+               (window as unknown as Record<string, number>)[windowKey] = next;
+               return `${prefix}-${next}`;
+       }
+       // Fallback for SSR - use timestamp + random
+       return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
+}
index 9d9348a5f0b00cf257fb7174f6b1fd0a695b396f..b72e806b6db80ff00e0105d93ab7328450b25e8c 100644 (file)
 import type { Plugin } from 'unified';
 import type { Root, Element, ElementContent } from 'hast';
 import { visit } from 'unist-util-visit';
+import { CODE_BLOCK_SCROLL_CONTAINER_CLASS, CODE_BLOCK_WRAPPER_CLASS } from '$lib/constants';
 import {
-       CODE_BLOCK_SCROLL_CONTAINER_CLASS,
-       CODE_BLOCK_WRAPPER_CLASS,
-       CODE_BLOCK_HEADER_CLASS,
-       CODE_BLOCK_ACTIONS_CLASS,
-       CODE_LANGUAGE_CLASS,
-       COPY_CODE_BTN_CLASS,
-       PREVIEW_CODE_BTN_CLASS,
-       RELATIVE_CLASS
-} from '$lib/constants';
+       createBlockHeader,
+       createCopyButton,
+       createPreviewButton,
+       createWrapper,
+       generateBlockId
+} from './code-block-utils';
 
 declare global {
        interface Window {
@@ -30,87 +28,6 @@ declare global {
        }
 }
 
-const COPY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
-
-const PREVIEW_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye lucide-eye-icon"><path d="M2.062 12.345a1 1 0 0 1 0-.69C3.5 7.73 7.36 5 12 5s8.5 2.73 9.938 6.655a1 1 0 0 1 0 .69C20.5 16.27 16.64 19 12 19s-8.5-2.73-9.938-6.655"/><circle cx="12" cy="12" r="3"/></svg>`;
-
-function createIconElement(svg: string): Element {
-       return {
-               type: 'element',
-               tagName: 'span',
-               properties: {},
-               children: [{ type: 'raw', value: svg } as unknown as ElementContent]
-       };
-}
-
-function createButton(className: string, title: string, iconSvg: string, codeId: string): Element {
-       return {
-               type: 'element',
-               tagName: 'button',
-               properties: {
-                       className: [className],
-                       'data-code-id': codeId,
-                       title,
-                       type: 'button'
-               },
-               children: [createIconElement(iconSvg)]
-       };
-}
-
-function createCopyButton(codeId: string): Element {
-       return createButton(COPY_CODE_BTN_CLASS, 'Copy code', COPY_ICON_SVG, codeId);
-}
-
-function createPreviewButton(codeId: string): Element {
-       return createButton(PREVIEW_CODE_BTN_CLASS, 'Preview code', PREVIEW_ICON_SVG, codeId);
-}
-
-function createHeader(language: string, codeId: string): Element {
-       const actions: Element[] = [createCopyButton(codeId)];
-
-       if (language.toLowerCase() === 'html') {
-               actions.push(createPreviewButton(codeId));
-       }
-
-       return {
-               type: 'element',
-               tagName: 'div',
-               properties: { className: [CODE_BLOCK_HEADER_CLASS] },
-               children: [
-                       {
-                               type: 'element',
-                               tagName: 'span',
-                               properties: { className: [CODE_LANGUAGE_CLASS] },
-                               children: [{ type: 'text', value: language }]
-                       },
-                       {
-                               type: 'element',
-                               tagName: 'div',
-                               properties: { className: [CODE_BLOCK_ACTIONS_CLASS] },
-                               children: actions
-                       }
-               ]
-       };
-}
-
-function createScrollContainer(preElement: Element): Element {
-       return {
-               type: 'element',
-               tagName: 'div',
-               properties: { className: [CODE_BLOCK_SCROLL_CONTAINER_CLASS] },
-               children: [preElement]
-       };
-}
-
-function createWrapper(header: Element, preElement: Element): Element {
-       return {
-               type: 'element',
-               tagName: 'div',
-               properties: { className: [CODE_BLOCK_WRAPPER_CLASS, RELATIVE_CLASS] },
-               children: [header, createScrollContainer(preElement)]
-       };
-}
-
 function extractLanguage(codeElement: Element): string {
        const className = codeElement.properties?.className;
        if (!Array.isArray(className)) return 'text';
@@ -124,17 +41,6 @@ function extractLanguage(codeElement: Element): string {
        return 'text';
 }
 
-/**
- * Generates a unique code block ID using a global counter.
- */
-function generateCodeId(): string {
-       if (typeof window !== 'undefined') {
-               return `code-${(window.idxCodeBlock = (window.idxCodeBlock ?? 0) + 1)}`;
-       }
-       // Fallback for SSR - use timestamp + random
-       return `code-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
-}
-
 /**
  * Rehype plugin to enhance code blocks with wrapper, header, and action buttons.
  * This plugin wraps <pre><code> elements with a container that includes:
@@ -154,15 +60,26 @@ export const rehypeEnhanceCodeBlocks: Plugin<[], Root> = () => {
                        if (!codeElement) return;
 
                        const language = extractLanguage(codeElement);
-                       const codeId = generateCodeId();
+                       const codeId = generateBlockId('code', 'idxCodeBlock');
 
                        codeElement.properties = {
                                ...codeElement.properties,
                                'data-code-id': codeId
                        };
 
-                       const header = createHeader(language, codeId);
-                       const wrapper = createWrapper(header, node);
+                       const actions: Element[] = [createCopyButton(codeId, 'data-code-id', 'Copy code')];
+
+                       if (language.toLowerCase() === 'html') {
+                               actions.push(createPreviewButton(codeId, 'data-code-id', 'Preview code'));
+                       }
+
+                       const header = createBlockHeader(language, codeId, 'data-code-id', actions);
+                       const wrapper = createWrapper(
+                               header,
+                               node,
+                               CODE_BLOCK_WRAPPER_CLASS,
+                               CODE_BLOCK_SCROLL_CONTAINER_CLASS
+                       );
 
                        // Replace pre with wrapper in parent
                        (parent.children as ElementContent[])[index] = wrapper;
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts
new file mode 100644 (file)
index 0000000..ab24e78
--- /dev/null
@@ -0,0 +1,85 @@
+/**
+ * Rehype plugin to enhance mermaid diagram blocks with wrapper, header, and action buttons.
+ *
+ * Wraps <pre class="mermaid"> elements with a container that includes:
+ * - Language label ("mermaid")
+ * - Copy button (copies mermaid syntax to clipboard)
+ * - Preview button (opens fullscreen preview dialog)
+ *
+ * This operates directly on the HAST tree for better performance,
+ * avoiding the need to stringify and re-parse HTML.
+ */
+
+import type { Plugin } from 'unified';
+import type { Root, Element, ElementContent } from 'hast';
+import { visit } from 'unist-util-visit';
+import { MERMAID_WRAPPER_CLASS, MERMAID_SCROLL_CONTAINER_CLASS } from '$lib/constants';
+import {
+       createBlockHeader,
+       createCopyButton,
+       createPreviewButton,
+       createWrapper,
+       generateBlockId
+} from './code-block-utils';
+
+declare global {
+       interface Window {
+               idxMermaidBlock?: number;
+       }
+}
+
+/**
+ * Rehype plugin to enhance mermaid diagram blocks with wrapper, header, and action buttons.
+ * This plugin wraps <pre class="mermaid"> elements with a container that includes:
+ * - Language label ("mermaid")
+ * - Copy button
+ * - Preview button
+ */
+export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
+       return (tree: Root) => {
+               visit(tree, 'element', (node: Element, index, parent) => {
+                       if (node.tagName !== 'pre' || !parent || index === undefined) return;
+
+                       const className = node.properties?.className;
+                       if (!Array.isArray(className)) return;
+
+                       const isMermaid = className.some((cls) => typeof cls === 'string' && cls === 'mermaid');
+
+                       if (!isMermaid) return;
+
+                       const mermaidId = generateBlockId('mermaid', 'idxMermaidBlock');
+
+                       // Extract the mermaid syntax (text content of the pre element)
+                       const diagramText = node.children
+                               .map((child) => {
+                                       if (child.type === 'text') return child.value;
+                                       return '';
+                               })
+                               .join('');
+
+                       // Store the mermaid syntax in data attribute for copy functionality
+                       node.properties = {
+                               ...node.properties,
+                               'data-mermaid-syntax': diagramText,
+                               'data-mermaid-id': mermaidId
+                       };
+
+                       const actions = [
+                               createCopyButton(mermaidId, 'data-mermaid-id', 'Copy mermaid syntax'),
+                               createPreviewButton(mermaidId, 'data-mermaid-id', 'Preview diagram')
+                       ];
+
+                       const header = createBlockHeader('mermaid', mermaidId, 'data-mermaid-id', actions);
+                       const wrapper = createWrapper(
+                               header,
+                               node,
+                               MERMAID_WRAPPER_CLASS,
+                               MERMAID_SCROLL_CONTAINER_CLASS,
+                               { 'data-mermaid-id': mermaidId }
+                       );
+
+                       // Replace pre with wrapper in parent
+                       (parent.children as ElementContent[])[index] = wrapper;
+               });
+       };
+};
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts
new file mode 100644 (file)
index 0000000..e2270a6
--- /dev/null
@@ -0,0 +1,67 @@
+import type { Plugin } from 'unified';
+import type { Root, Element, ElementContent, Text } from 'hast';
+import { visit } from 'unist-util-visit';
+
+/**
+ * Recursively extracts all text content from a HAST node.
+ * Handles nested elements (e.g., span wrappers from syntax highlighting).
+ */
+function extractText(node: ElementContent): string {
+       if (node.type === 'text') return node.value;
+       if (node.type === 'element') {
+               return (node.children ?? []).map(extractText).join('');
+       }
+       return '';
+}
+
+/**
+ * Rehype plugin to convert mermaid code blocks to <pre class="mermaid"> elements.
+ *
+ * Transforms:
+ *   <pre><code class="language-mermaid">graph TD; A-->B</code></pre>
+ * into:
+ *   <pre class="mermaid">graph TD; A-->B</pre>
+ *
+ * The mermaid library renders these client-side via mermaid.run().
+ *
+ * Must run BEFORE rehypeEnhanceCodeBlocks so mermaid blocks are not wrapped
+ * with code block headers/buttons (they have no <code> child, so they're skipped).
+ */
+export const rehypeMermaidPre: Plugin<[], Root> = () => {
+       return (tree: Root) => {
+               visit(tree, 'element', (node: Element, index, parent) => {
+                       if (node.tagName !== 'pre' || !parent || index === undefined) return;
+
+                       const codeElement = node.children.find(
+                               (child): child is Element => child.type === 'element' && child.tagName === 'code'
+                       );
+
+                       if (!codeElement) return;
+
+                       const className = codeElement.properties?.className;
+                       if (!Array.isArray(className)) return;
+
+                       const isMermaid = className.some(
+                               (cls) => typeof cls === 'string' && cls === 'language-mermaid'
+                       );
+
+                       if (!isMermaid) return;
+
+                       // Recursively extract text to handle nested spans from syntax highlighting
+                       const diagramText = codeElement.children.map(extractText).join('').trim();
+
+                       if (!diagramText) return;
+
+                       const mermaidPre: Element = {
+                               type: 'element',
+                               tagName: 'pre',
+                               properties: {
+                                       className: ['mermaid']
+                               },
+                               children: [{ type: 'text', value: diagramText } as Text]
+                       };
+
+                       (parent.children as ElementContent[])[index] = mermaidPre;
+               });
+       };
+};
diff --git a/tools/ui/src/lib/components/app/content/MermaidPreview.svelte b/tools/ui/src/lib/components/app/content/MermaidPreview.svelte
new file mode 100644 (file)
index 0000000..d482588
--- /dev/null
@@ -0,0 +1,126 @@
+<script lang="ts">
+       import MermaidPreviewControls from './MermaidPreviewControls.svelte';
+
+       interface Props {
+               svgHtml: string;
+       }
+
+       let { svgHtml }: Props = $props();
+
+       // Zoom and pan state
+       let scale = $state(1);
+       let translateX = $state(0);
+       let translateY = $state(0);
+       let isDragging = $state(false);
+       const containerRef = { current: null as HTMLDivElement | null };
+
+       // Drag start position
+       let dragStartX = 0;
+       let dragStartY = 0;
+       let dragStartTranslateX = 0;
+       let dragStartTranslateY = 0;
+
+       const MIN_SCALE = 0.1;
+       const MAX_SCALE = 10;
+       const ZOOM_STEP = 0.15;
+
+       function resetView() {
+               scale = 1;
+               translateX = 0;
+               translateY = 0;
+       }
+
+       function zoomIn() {
+               scale = Math.min(scale + ZOOM_STEP, MAX_SCALE);
+       }
+
+       function zoomOut() {
+               scale = Math.max(scale - ZOOM_STEP, MIN_SCALE);
+       }
+
+       function handleWheel(event: WheelEvent) {
+               event.preventDefault();
+
+               const delta = event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
+               scale = Math.min(Math.max(scale + delta, MIN_SCALE), MAX_SCALE);
+       }
+
+       // Imperatively attach a non-passive wheel listener so preventDefault() actually works
+       // (Svelte 5 wheel listeners are passive by default, making preventDefault() a no-op)
+       $effect(() => {
+               const el = containerRef.current;
+               if (!el) return;
+
+               function onWheel(e: WheelEvent) {
+                       handleWheel(e);
+               }
+
+               el.addEventListener('wheel', onWheel, { passive: false });
+               return () => el.removeEventListener('wheel', onWheel);
+       });
+
+       function handlePointerDown(event: PointerEvent) {
+               if (event.button !== 0 && event.pointerType === 'mouse') return;
+
+               isDragging = true;
+               dragStartX = event.clientX;
+               dragStartY = event.clientY;
+               dragStartTranslateX = translateX;
+               dragStartTranslateY = translateY;
+
+               (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+       }
+
+       function handlePointerMove(event: PointerEvent) {
+               if (!isDragging) return;
+
+               translateX = dragStartTranslateX + (event.clientX - dragStartX);
+               translateY = dragStartTranslateY + (event.clientY - dragStartY);
+       }
+
+       function handlePointerUp() {
+               isDragging = false;
+       }
+</script>
+
+<div
+       bind:this={containerRef.current}
+       class="mermaid-preview relative flex items-center justify-center overflow-hidden bg-muted/20"
+>
+       <!-- svelte-ignore a11y_no_static_element_interactions -->
+       <div
+               class="mermaid-preview-diagram transform-origin-center inline-block min-h-fit min-w-fit will-change-transform {isDragging &&
+                       'select-none'}"
+               style="transform: translate({translateX}px, {translateY}px) scale({scale}); cursor: {isDragging
+                       ? 'grabbing'
+                       : 'grab'};"
+               onpointerdown={handlePointerDown}
+               onpointermove={handlePointerMove}
+               onpointerup={handlePointerUp}
+               onpointerleave={handlePointerUp}
+       >
+               <!-- eslint-disable-next-line no-at-html-tags -->
+               {@html svgHtml}
+       </div>
+
+       <MermaidPreviewControls
+               {scale}
+               {svgHtml}
+               onZoomIn={zoomIn}
+               onZoomOut={zoomOut}
+               onResetView={resetView}
+       />
+</div>
+
+<style lang="postcss" scoped>
+       /* Styles for SVGs rendered via {@html} — no Tailwind class can target child elements */
+       .mermaid-preview-diagram :global(svg) {
+               min-height: min(50vh, 12rem);
+               min-width: min(80vw, 20rem);
+               max-width: none !important;
+               max-height: none !important;
+               height: auto !important;
+               width: auto !important;
+               display: block;
+       }
+</style>
diff --git a/tools/ui/src/lib/components/app/content/MermaidPreviewControls.svelte b/tools/ui/src/lib/components/app/content/MermaidPreviewControls.svelte
new file mode 100644 (file)
index 0000000..bb3185f
--- /dev/null
@@ -0,0 +1,74 @@
+<script lang="ts">
+       import { Download } from '@lucide/svelte';
+       import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
+       import ZoomOutIcon from '@lucide/svelte/icons/zoom-out';
+       import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
+
+       interface Props {
+               scale: number;
+               svgHtml: string;
+               onZoomIn: () => void;
+               onZoomOut: () => void;
+               onResetView: () => void;
+       }
+
+       let { scale, svgHtml, onZoomIn, onZoomOut, onResetView }: Props = $props();
+
+       function downloadSvg() {
+               if (!svgHtml) return;
+               const blob = new Blob([svgHtml], { type: 'image/svg+xml' });
+               const url = URL.createObjectURL(blob);
+               const a = document.createElement('a');
+               a.href = url;
+               a.download = 'diagram.svg';
+               a.click();
+               URL.revokeObjectURL(url);
+       }
+</script>
+
+<div
+       class="mermaid-preview-controls absolute bottom-8 flex shrink-0 items-center justify-center p-3"
+>
+       <div class="mermaid-preview-controls-inner flex items-center gap-1 rounded-lg bg-muted p-1">
+               <button
+                       class="mermaid-preview-btn flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-foreground transition-colors hover:bg-muted-foreground/15 active:bg-muted-foreground/25"
+                       onclick={onZoomOut}
+                       title="Zoom out"
+                       aria-label="Zoom out"
+               >
+                       <ZoomOutIcon class="mermaid-preview-btn-icon h-4 w-4" />
+               </button>
+               <span
+                       class="mermaid-preview-zoom-label min-w-[3.5rem] px-0.5 text-center text-xs font-medium text-muted-foreground tabular-nums select-none"
+                       >{Math.round(scale * 100)}%</span
+               >
+               <button
+                       class="mermaid-preview-btn flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-foreground transition-colors hover:bg-muted-foreground/15 active:bg-muted-foreground/25"
+                       onclick={onZoomIn}
+                       title="Zoom in"
+                       aria-label="Zoom in"
+               >
+                       <ZoomInIcon class="mermaid-preview-btn-icon h-4 w-4" />
+               </button>
+               <div class="mermaid-preview-controls-separator mx-1 h-5 w-px bg-border/50"></div>
+
+               <button
+                       class="mermaid-preview-btn flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-foreground transition-colors hover:bg-muted-foreground/15 active:bg-muted-foreground/25"
+                       onclick={onResetView}
+                       title="Reset view"
+                       aria-label="Reset view"
+               >
+                       <RotateCcwIcon class="mermaid-preview-btn-icon h-4 w-4" />
+               </button>
+               <div class="mermaid-preview-controls-separator mx-1 h-5 w-px bg-border/50"></div>
+
+               <button
+                       class="mermaid-preview-btn flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-foreground transition-colors hover:bg-muted-foreground/15 active:bg-muted-foreground/25"
+                       onclick={downloadSvg}
+                       title="Download SVG"
+                       aria-label="Download SVG"
+               >
+                       <Download class="mermaid-preview-btn-icon h-4 w-4" />
+               </button>
+       </div>
+</div>
index e468a441ed49804114014f27d202156d0bbf9894..5d2884bb214c84a60a00c27d9cdab5343687730b 100644 (file)
@@ -77,3 +77,22 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte
  * ```
  */
 export { default as CollapsibleContentBlock } from './CollapsibleContentBlock.svelte';
+
+/**
+ * **MermaidPreview** - Interactive Mermaid diagram viewer
+ *
+ * Renders Mermaid-generated SVG diagrams with zoom, pan, and fit-to-view controls.
+ *
+ * **Features:**
+ * - Mouse wheel zoom in/out
+ * - Click-drag panning with pointer capture
+ * - Fit to view and reset view controls
+ * - Download as SVG
+ * - Responsive scaling with viewBox detection
+ *
+ * @example
+ * ```svelte
+ * <MermaidPreview svgHtml={diagramSvg} />
+ * ```
+ */
+export { default as MermaidPreview } from './MermaidPreview.svelte';
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMermaidPreview.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMermaidPreview.svelte
new file mode 100644 (file)
index 0000000..9cbeebc
--- /dev/null
@@ -0,0 +1,20 @@
+<script lang="ts">
+       import * as Dialog from '$lib/components/ui/dialog/index.js';
+       import { MermaidPreview } from '$lib/components/app/content';
+
+       interface Props {
+               open: boolean;
+               svgHtml: string;
+               onOpenChange?: (open: boolean) => void;
+       }
+
+       let { open = $bindable(), svgHtml, onOpenChange }: Props = $props();
+</script>
+
+<Dialog.Root bind:open {onOpenChange}>
+       <Dialog.Content
+               class="z-999999 grid max-h-full max-w-full! grid-rows-[1fr_auto] overflow-hidden p-0 md:h-[90vh] md:max-w-[90vw]!"
+       >
+               <MermaidPreview {svgHtml} />
+       </Dialog.Content>
+</Dialog.Root>
index 5a6453b72ec561923f18bf9e7e569e96f26663bd..29136308cef50404f26f7412442669cfc408d280 100644 (file)
@@ -474,3 +474,35 @@ export { default as DialogMcpResourcesBrowser } from './DialogMcpResourcesBrowse
  * ```
  */
 export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.svelte';
+
+/**
+ * **DialogMermaidPreview** - Full-screen Mermaid diagram preview with zoom and pan
+ *
+ * Full-screen dialog for previewing Mermaid diagrams with interactive controls.
+ * Supports mouse wheel zoom, drag-to-pan, and toolbar buttons for zoom in/out,
+ * fit to view, and reset.
+ *
+ * **Architecture:**
+ * - Uses UI dialog components (`Dialog.Root`, `Dialog.Overlay`, `Dialog.Content`)
+ *   for consistent styling, animations, and accessibility
+ * - CSS transform-based zoom and pan (no external dependencies)
+ * - Pointer events for cross-device drag support (mouse + touch)
+ * - Wheel events for zoom-to-cursor functionality
+ *
+ * **Features:**
+ * - Scroll wheel zoom centered on cursor position
+ * - Click and drag to pan the diagram
+ * - Toolbar with zoom in, zoom out, fit to view, reset controls
+ * - Zoom percentage indicator
+ * - Keyboard accessible close button
+ * - Dark/light theme support
+ *
+ * @example
+ * ```svelte
+ * <DialogMermaidPreview
+ *   bind:open={showMermaidPreview}
+ *   svgHtml={mermaidSvgContent}
+ * />
+ * ```
+ */
+export { default as DialogMermaidPreview } from './DialogMermaidPreview.svelte';
index 5a6bfa981194104450772f529921ca3029854d44..a9448c2a6d753eae35be71150baf44d72673972e 100644 (file)
@@ -34,3 +34,8 @@ export const MODALITY_LABELS = {
        [ModelModality.AUDIO]: 'Audio',
        [ModelModality.VIDEO]: 'Video'
 } as const;
+
+// Shared SVG icon strings for copy and preview buttons
+export const COPY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
+
+export const PREVIEW_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye lucide-eye-icon"><path d="M2.062 12.345a1 1 0 0 1 0-.69C3.5 7.73 7.36 5 12 5s8.5 2.73 9.938 6.655a1 1 0 0 1 0 .69C20.5 16.27 16.64 19 12 19s-8.5-2.73-9.938-6.655"/><circle cx="12" cy="12" r="3"/></svg>`;
index 54f39feee7b4403755889093affd318d90a366d5..c4334132c97ac3f4750b6658bd3164a5d57e5598 100644 (file)
@@ -15,6 +15,7 @@ export * from './cache';
 export * from './chat-form';
 export * from './cli-flags';
 export * from './code-blocks';
+export * from './icons';
 export * from './code';
 export * from './context-keys';
 export * from './control-actions';
@@ -26,6 +27,7 @@ export * from './icons';
 export * from './latex-protection';
 export * from './literal-html';
 export * from './markdown';
+export * from './mermaid-blocks';
 export * from './max-bundle-size';
 export * from './mcp';
 export * from './mcp-form';
diff --git a/tools/ui/src/lib/constants/mermaid-blocks.ts b/tools/ui/src/lib/constants/mermaid-blocks.ts
new file mode 100644 (file)
index 0000000..3f43942
--- /dev/null
@@ -0,0 +1,2 @@
+export const MERMAID_WRAPPER_CLASS = 'mermaid-block-wrapper';
+export const MERMAID_SCROLL_CONTAINER_CLASS = 'mermaid-scroll-container';