diff --git a/.storybook/main.ts b/.storybook/main.ts
index 38b3b201..c4fabf57 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -84,6 +84,43 @@ const datavisLegacySubpathDependencies = [
'core-js/es/string/replace-all',
] as const;
+// CJS-only transitive dependencies (via react-i18next → html-parse-stringify →
+// void-elements) that live only in the pnpm virtual store. They must be aliased
+// to a resolvable path and force-included so Vite pre-bundles them with proper
+// CJS→ESM default-export interop; otherwise `void-elements` is served raw and
+// throws "does not provide an export named 'default'".
+//
+// The shared `pnpmVirtualNodeModulesDir` is computed from `monorepoRoot`, which
+// does not match this repo's layout; resolve against the workspace-local pnpm
+// store instead.
+const workspacePnpmVirtualNodeModulesDir = path.join(
+ rootNodeModulesDir,
+ '.pnpm/node_modules',
+);
+
+const pnpmVirtualCjsInteropDependencies = [
+ 'react-i18next',
+ 'html-parse-stringify',
+ 'void-elements',
+] as const;
+
+const pnpmVirtualCjsInteropAliases = pnpmVirtualCjsInteropDependencies
+ .filter(
+ (dependencyName) =>
+ !existsSync(path.join(rootNodeModulesDir, dependencyName)) &&
+ existsSync(path.join(workspacePnpmVirtualNodeModulesDir, dependencyName)),
+ )
+ .map((dependencyName) => ({
+ find: new RegExp(`^${escapeRegExp(dependencyName)}(\/.*)?$`),
+ replacement: `${path.join(workspacePnpmVirtualNodeModulesDir, dependencyName)}$1`,
+ }));
+
+const pnpmVirtualCjsInteropIncludes = pnpmVirtualCjsInteropDependencies.filter(
+ (dependencyName) =>
+ existsSync(path.join(rootNodeModulesDir, dependencyName)) ||
+ existsSync(path.join(workspacePnpmVirtualNodeModulesDir, dependencyName)),
+);
+
function getPackageRootName(dependencyName: string): string {
if (dependencyName.startsWith('@')) {
return dependencyName.split('/').slice(0, 2).join('/');
@@ -188,6 +225,7 @@ const config: StorybookConfig = {
: []),
...localUiAliases,
...buildVirtualStoreAliases(optimizeDepNames),
+ ...pnpmVirtualCjsInteropAliases,
...esheetSourceAliases,
];
@@ -222,6 +260,12 @@ const config: StorybookConfig = {
...optimizeDepNames,
].filter(isLocalNodeModuleDependency)),
);
+ config.optimizeDeps.include = Array.from(
+ new Set([
+ ...(config.optimizeDeps.include ?? []),
+ ...pnpmVirtualCjsInteropIncludes,
+ ]),
+ );
config.optimizeDeps.esbuildOptions = {
...config.optimizeDeps.esbuildOptions,
jsx: 'automatic',
diff --git a/.storybook/manager.ts b/.storybook/manager.ts
index 17bc3f81..74b79509 100644
--- a/.storybook/manager.ts
+++ b/.storybook/manager.ts
@@ -268,6 +268,14 @@ function injectBrandCSS(brandKey: BrandKey, isDark = false) {
[role="toolbar"] button[data-active="true"] {
color: ${brand.primary} !important;
}
+
+ /* Brand-theme switcher reflects the active brand (higher specificity than
+ the generic muted toolbar-button rule, so it wins in both light/dark). */
+ [role="toolbar"] button[aria-label*="brand themes"],
+ [class*="toolbar"] button[aria-label*="brand themes"],
+ [class*="bar"] button[aria-label*="brand themes"] {
+ color: ${brand.primary} !important;
+ }
/* Links */
a[href]:hover {
diff --git a/.storybook/preview.css b/.storybook/preview.css
index e82d2693..26cb373e 100644
--- a/.storybook/preview.css
+++ b/.storybook/preview.css
@@ -144,10 +144,33 @@ html, body {
[data-theme="dark"] .docblock-argstable code,
[data-theme="dark"] .sbdocs code {
background-color: var(--mieweb-card, #27272a) !important;
- color: var(--mieweb-primary, #3b82f6) !important;
+ color: var(--mieweb-primary-400, #3b82f6) !important;
border-color: var(--mieweb-border, #3f3f46) !important;
}
+/* ============================================
+ PROSE TABLES (Markdown / README) - DARK MODE
+ ============================================ */
+
+/* Markdown-authored tables in docs (e.g. the README rendered on the Overview
+ page) are plain
// , NOT .docblock-argstable. Storybook's docs
+ theme hardcodes a dark cell color (rgb(46,51,56)) that is unreadable on the
+ dark background, so force the themed foreground + borders here. */
+[data-theme="dark"] .sbdocs-content table th,
+[data-theme="dark"] .sbdocs-content table td {
+ color: var(--mieweb-foreground) !important;
+ border-color: var(--mieweb-border, #3f3f46) !important;
+}
+
+/* Header row + zebra striping for legibility. */
+[data-theme="dark"] .sbdocs-content table th {
+ background-color: var(--mieweb-card, #27272a) !important;
+}
+
+[data-theme="dark"] .sbdocs-content table tr:nth-child(even) td {
+ background-color: var(--mieweb-card, #27272a) !important;
+}
+
/* ============================================
CONTROLS / INPUTS - DARK MODE
============================================ */
@@ -201,7 +224,7 @@ html, body {
}
[data-theme="dark"] .docblock-argstable button:hover {
- background-color: var(--mieweb-accent, #3f3f46) !important;
+ background-color: var(--mieweb-muted, #3f3f46) !important;
}
/* ============================================
@@ -233,7 +256,7 @@ html, body {
/* Story name labels */
[data-theme="dark"] .sbdocs-a,
[data-theme="dark"] .sbdocs a:not(.docs-story a):not(.sb-story a):not(.sb-unstyled a) {
- color: var(--mieweb-primary, #3b82f6) !important;
+ color: var(--mieweb-primary-400, #3b82f6) !important;
}
/* ============================================
@@ -246,7 +269,7 @@ html, body {
}
[data-theme="dark"] [class*="IconButton"]:hover {
- background-color: var(--mieweb-accent, #3f3f46) !important;
+ background-color: var(--mieweb-muted, #3f3f46) !important;
}
/* Show code button */
diff --git a/eslint.config.js b/eslint.config.js
index 11f0a092..dea91b12 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -93,6 +93,13 @@ export default [
IntersectionObserver: 'readonly',
IntersectionObserverEntry: 'readonly',
MutationObserver: 'readonly',
+ // IndexedDB
+ indexedDB: 'readonly',
+ IDBDatabase: 'readonly',
+ IDBObjectStore: 'readonly',
+ IDBTransactionMode: 'readonly',
+ IDBOpenDBRequest: 'readonly',
+ IDBRequest: 'readonly',
// DOM types
Element: 'readonly',
Document: 'readonly',
diff --git a/package.json b/package.json
index 24f08d62..3e69566a 100644
--- a/package.json
+++ b/package.json
@@ -198,10 +198,17 @@
"ag-grid-react": ">=32.0.0",
"datavis-ace": "=4.0.0-PRE.2",
"js-yaml": ">=4.0.0",
- "mermaid": ">=10.0.0",
+ "katex": ">=0.16.0",
+ "mermaid": ">=11.0.0",
"papaparse": ">=5.0.0",
"react": ">=18.0.0",
"react-dom": ">=18.0.0",
+ "react-markdown": ">=9.0.0",
+ "rehype-highlight": ">=7.0.0",
+ "rehype-katex": ">=7.0.0",
+ "rehype-sanitize": ">=6.0.0",
+ "remark-gfm": ">=4.0.0",
+ "remark-math": ">=6.0.0",
"wavesurfer.js": ">=7.0.0"
},
"peerDependenciesMeta": {
@@ -235,6 +242,27 @@
"papaparse": {
"optional": true
},
+ "react-markdown": {
+ "optional": true
+ },
+ "remark-gfm": {
+ "optional": true
+ },
+ "remark-math": {
+ "optional": true
+ },
+ "rehype-katex": {
+ "optional": true
+ },
+ "katex": {
+ "optional": true
+ },
+ "rehype-sanitize": {
+ "optional": true
+ },
+ "rehype-highlight": {
+ "optional": true
+ },
"react": {
"optional": false
},
@@ -247,6 +275,7 @@
},
"dependencies": {
"@swc/helpers": "^0.5.19",
+ "@tanstack/react-virtual": "^3.14.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.4.0",
@@ -272,6 +301,7 @@
"@kerebron/editor": "0.7.9",
"@kerebron/editor-kits": "0.7.9",
"@kerebron/wasm": "0.7.9",
+ "@mieweb/datavis": "=0.0.0-PRE.2",
"@monaco-editor/react": "^4.7.0",
"@playwright/test": "^1.58.2",
"@storybook/addon-a11y": "^10.2.11",
@@ -309,21 +339,28 @@
"d3-selection": "^3.0.0",
"d3-shape": "^3.2.0",
"d3-zoom": "^3.0.0",
- "@mieweb/datavis": "=0.0.0-PRE.2",
"eslint": "^9.39.3",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-storybook": "^10.2.11",
+ "fake-indexeddb": "^6.2.5",
"js-yaml": "^4.1.1",
"jsdom": "^26.1.0",
- "mermaid": "^11.12.3",
+ "katex": "^0.17.0",
+ "mermaid": "^11.15.0",
"papaparse": "^5.5.3",
"postcss": "^8.5.10",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+ "react-markdown": "^10.1.0",
+ "rehype-highlight": "^7.0.2",
+ "rehype-katex": "^7.0.1",
+ "rehype-sanitize": "^6.0.0",
+ "remark-gfm": "^4.0.1",
+ "remark-math": "^6.0.0",
"sass": "1.100.0",
"sortablejs": "^1.15.7",
"storybook": "^10.2.11",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 662dc98a..8c515969 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,6 +25,9 @@ importers:
'@swc/helpers':
specifier: ^0.5.19
version: 0.5.19
+ '@tanstack/react-virtual':
+ specifier: ^3.14.2
+ version: 3.14.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -169,7 +172,7 @@ importers:
version: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
'@vitest/coverage-v8':
specifier: ^3.2.6
- version: 3.2.6(vitest@3.2.6(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(sass@1.100.0))
+ version: 3.2.6(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(sass@1.100.0))
ag-grid-community:
specifier: ^35.1.0
version: 35.1.0
@@ -227,14 +230,20 @@ importers:
eslint-plugin-storybook:
specifier: ^10.2.11
version: 10.2.11(eslint@9.39.3(jiti@2.6.1))(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
+ fake-indexeddb:
+ specifier: ^6.2.5
+ version: 6.2.5
js-yaml:
specifier: ^4.1.1
version: 4.1.1
jsdom:
specifier: ^26.1.0
version: 26.1.0
+ katex:
+ specifier: ^0.17.0
+ version: 0.17.0
mermaid:
- specifier: ^11.12.3
+ specifier: ^11.15.0
version: 11.15.0
papaparse:
specifier: ^5.5.3
@@ -254,6 +263,24 @@ importers:
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
+ react-markdown:
+ specifier: ^10.1.0
+ version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
+ rehype-highlight:
+ specifier: ^7.0.2
+ version: 7.0.2
+ rehype-katex:
+ specifier: ^7.0.1
+ version: 7.0.1
+ rehype-sanitize:
+ specifier: ^6.0.0
+ version: 6.0.0
+ remark-gfm:
+ specifier: ^4.0.1
+ version: 4.0.1
+ remark-math:
+ specifier: ^6.0.0
+ version: 6.0.0
sass:
specifier: 1.100.0
version: 1.100.0
@@ -277,7 +304,7 @@ importers:
version: 7.3.5(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.100.0)
vitest:
specifier: ^3.2.6
- version: 3.2.6(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(sass@1.100.0)
+ version: 3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(sass@1.100.0)
wavesurfer.js:
specifier: ^7.12.1
version: 7.12.1
@@ -1600,6 +1627,15 @@ packages:
'@tailwindcss/postcss@4.2.1':
resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==}
+ '@tanstack/react-virtual@3.14.2':
+ resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@tanstack/virtual-core@3.17.0':
+ resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
+
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -1743,12 +1779,18 @@ packages:
'@types/d3@7.4.3':
resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
+ '@types/debug@4.1.13':
+ resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/doctrine@0.0.9':
resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==}
+ '@types/estree-jsx@1.0.5':
+ resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
+
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1758,6 +1800,9 @@ packages:
'@types/google-libphonenumber@7.4.30':
resolution: {integrity: sha512-Td1X1ayRxePEm6/jPHUBs2tT6TzW1lrVB6ZX7ViPGellyzO/0xMNi+wx5nH6jEitjznq276VGIqjK5qAju0XVw==}
+ '@types/hast@3.0.4':
+ resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+
'@types/istanbul-lib-coverage@2.0.6':
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
@@ -1773,12 +1818,21 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+ '@types/katex@0.16.8':
+ resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
+
'@types/luxon@3.7.1':
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
+ '@types/mdast@4.0.4':
+ resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
'@types/mdx@2.0.13':
resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==}
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
'@types/node@22.19.11':
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
@@ -1802,6 +1856,12 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+ '@types/unist@2.0.11':
+ resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
+
+ '@types/unist@3.0.3':
+ resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
'@types/wait-on@5.3.4':
resolution: {integrity: sha512-EBsPjFMrFlMbbUFf9D1Fp+PAB2TwmUn7a3YtHyD9RLuTIk1jDd8SxXVAoez2Ciy+8Jsceo2MYEYZzJ/DvorOKw==}
@@ -1872,6 +1932,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+ deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
@@ -2221,6 +2282,9 @@ packages:
peerDependencies:
'@babel/core': ^7.11.0 || ^8.0.0-beta.1
+ bail@2.0.2:
+ resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -2304,6 +2368,9 @@ packages:
caniuse-lite@1.0.30001774:
resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==}
+ ccount@2.0.1:
+ resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -2324,6 +2391,18 @@ packages:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
+ character-entities-html4@2.1.0:
+ resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
+
+ character-entities-legacy@3.0.0:
+ resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
+
+ character-entities@2.0.2:
+ resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
+
+ character-reference-invalid@2.0.1:
+ resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
@@ -2388,6 +2467,9 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
+ comma-separated-tokens@2.0.3:
+ resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+
commander@12.1.0:
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
engines: {node: '>=18'}
@@ -2673,6 +2755,9 @@ packages:
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+ decode-named-character-reference@1.3.0:
+ resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
+
dedent@1.7.2:
resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
peerDependencies:
@@ -2738,6 +2823,9 @@ packages:
devalue@5.8.1:
resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==}
+ devlop@1.1.0:
+ resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
diffable-html@4.1.0:
resolution: {integrity: sha512-++kyNek+YBLH8cLXS+iTj/Hiy2s5qkRJEJ8kgu/WHbFrVY2vz9xPFUT+fii2zGF0m1CaojDlQJjkfrCt7YWM1g==}
@@ -2897,6 +2985,10 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
+ escape-string-regexp@5.0.0:
+ resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
+ engines: {node: '>=12'}
+
eslint-plugin-jsx-a11y@6.10.2:
resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
engines: {node: '>=4.0'}
@@ -2983,6 +3075,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
+ estree-util-is-identifier-name@3.0.0:
+ resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
+
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
@@ -3027,6 +3122,13 @@ packages:
ext@1.7.0:
resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
+ extend@3.0.2:
+ resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
+ fake-indexeddb@6.2.5:
+ resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==}
+ engines: {node: '>=18'}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -3258,6 +3360,39 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
+ hast-util-from-dom@5.0.1:
+ resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
+
+ hast-util-from-html-isomorphic@2.0.0:
+ resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==}
+
+ hast-util-from-html@2.0.3:
+ resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
+
+ hast-util-from-parse5@8.0.3:
+ resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
+
+ hast-util-is-element@3.0.0:
+ resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
+
+ hast-util-parse-selector@4.0.0:
+ resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
+
+ hast-util-sanitize@5.0.2:
+ resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
+
+ hast-util-to-jsx-runtime@2.3.6:
+ resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
+
+ hast-util-to-text@4.0.2:
+ resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
+
+ hast-util-whitespace@3.0.0:
+ resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+
+ hastscript@9.0.1:
+ resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
+
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
@@ -3276,6 +3411,9 @@ packages:
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+ html-url-attributes@3.0.1:
+ resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
+
html@1.0.0:
resolution: {integrity: sha512-lw/7YsdKiP3kk5PnR1INY17iJuzdAtJewxr14ozKJWbbR97znovZ0mh+WEMZ8rjc3lgTK+ID/htTjuyGKB52Kw==}
hasBin: true
@@ -3348,6 +3486,9 @@ packages:
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
+ inline-style-parser@0.2.7:
+ resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
+
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -3359,6 +3500,12 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
+ is-alphabetical@2.0.1:
+ resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
+
+ is-alphanumerical@2.0.1:
+ resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
+
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -3394,6 +3541,9 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
+ is-decimal@2.0.1:
+ resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
+
is-docker@3.0.0:
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -3423,6 +3573,9 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
+ is-hexadecimal@2.0.1:
+ resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
+
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
@@ -3440,6 +3593,10 @@ packages:
resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
engines: {node: '>= 0.4'}
+ is-plain-obj@4.1.0:
+ resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
+ engines: {node: '>=12'}
+
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
@@ -3775,6 +3932,10 @@ packages:
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
hasBin: true
+ katex@0.17.0:
+ resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==}
+ hasBin: true
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -3928,6 +4089,9 @@ packages:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
+ longest-streak@3.1.0:
+ resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -3935,6 +4099,9 @@ packages:
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+ lowlight@3.3.0:
+ resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
+
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -3980,6 +4147,9 @@ packages:
makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
+ markdown-table@3.0.4:
+ resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+
marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'}
@@ -4002,12 +4172,147 @@ packages:
mathml2latex@1.1.3:
resolution: {integrity: sha512-/ykNcqkOyxl3V2U/avnMMK0BHGASbQZzsLJU6f98BdP96XR6VXcpRarfpRM3Td7hxHomr7JK5XDC4Enzbhy6/g==}
+ mdast-util-find-and-replace@3.0.2:
+ resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
+
+ mdast-util-from-markdown@2.0.3:
+ resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
+
+ mdast-util-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
+
+ mdast-util-gfm-table@2.0.0:
+ resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
+
+ mdast-util-gfm@3.1.0:
+ resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
+
+ mdast-util-math@3.0.0:
+ resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==}
+
+ mdast-util-mdx-expression@2.0.1:
+ resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
+
+ mdast-util-mdx-jsx@3.2.0:
+ resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
+
+ mdast-util-mdxjs-esm@2.0.1:
+ resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
+
+ mdast-util-phrasing@4.1.0:
+ resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
+
+ mdast-util-to-hast@13.2.1:
+ resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
+
+ mdast-util-to-markdown@2.1.2:
+ resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
+
+ mdast-util-to-string@4.0.0:
+ resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
mermaid@11.15.0:
resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==}
+ micromark-core-commonmark@2.0.3:
+ resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
+
+ micromark-extension-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
+
+ micromark-extension-gfm-table@2.1.1:
+ resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
+
+ micromark-extension-gfm@3.0.0:
+ resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
+
+ micromark-extension-math@3.1.0:
+ resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==}
+
+ micromark-factory-destination@2.0.1:
+ resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
+
+ micromark-factory-label@2.0.1:
+ resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
+
+ micromark-factory-space@2.0.1:
+ resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
+
+ micromark-factory-title@2.0.1:
+ resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
+
+ micromark-factory-whitespace@2.0.1:
+ resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
+
+ micromark-util-character@2.1.1:
+ resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+ micromark-util-chunked@2.0.1:
+ resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
+
+ micromark-util-classify-character@2.0.1:
+ resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
+
+ micromark-util-combine-extensions@2.0.1:
+ resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
+
+ micromark-util-decode-string@2.0.1:
+ resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
+
+ micromark-util-encode@2.0.1:
+ resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+ micromark-util-html-tag-name@2.0.1:
+ resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
+
+ micromark-util-normalize-identifier@2.0.1:
+ resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
+
+ micromark-util-resolve-all@2.0.1:
+ resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
+
+ micromark-util-sanitize-uri@2.0.1:
+ resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+ micromark-util-subtokenize@2.1.0:
+ resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
+
+ micromark-util-symbol@2.0.1:
+ resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+ micromark-util-types@2.0.2:
+ resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+
+ micromark@4.0.2:
+ resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
+
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
@@ -4213,6 +4518,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
+ parse-entities@4.0.2:
+ resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+
parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
@@ -4416,6 +4724,9 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ property-information@7.2.0:
+ resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
+
prosemirror-dev-toolkit@1.1.8:
resolution: {integrity: sha512-Qi549XA+DqU5cCkn/xv+M53gy1sQbyOTjiOfiG7Gjq5gm6ZxLilGN04UITWTAYx9kzLBi7Y9RJmvmWB4xiRauA==}
@@ -4487,6 +4798,12 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
+ react-markdown@10.1.0:
+ resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
+ peerDependencies:
+ '@types/react': '>=18'
+ react: '>=18'
+
react@19.2.4:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
@@ -4522,10 +4839,34 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
+ rehype-highlight@7.0.2:
+ resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
+
+ rehype-katex@7.0.1:
+ resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
+
+ rehype-sanitize@6.0.0:
+ resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
+
release-zalgo@1.0.0:
resolution: {integrity: sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==}
engines: {node: '>=4'}
+ remark-gfm@4.0.1:
+ resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
+
+ remark-math@6.0.0:
+ resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==}
+
+ remark-parse@11.0.0:
+ resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
+
+ remark-rehype@11.1.2:
+ resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
+
+ remark-stringify@11.0.0:
+ resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@@ -4711,6 +5052,9 @@ packages:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
+ space-separated-tokens@2.0.2:
+ resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+
spawn-wrap@2.0.0:
resolution: {integrity: sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==}
engines: {node: '>=8'}
@@ -4795,6 +5139,9 @@ packages:
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+ stringify-entities@4.0.4:
+ resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
+
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@@ -4833,6 +5180,12 @@ packages:
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
+ style-to-js@1.1.21:
+ resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
+
+ style-to-object@1.0.14:
+ resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
+
stylis@4.4.0:
resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
@@ -4949,6 +5302,12 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
+ trim-lines@3.0.1:
+ resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
+
+ trough@2.2.0:
+ resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
+
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
@@ -5047,6 +5406,30 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ unified@11.0.5:
+ resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+
+ unist-util-find-after@5.0.0:
+ resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
+
+ unist-util-is@6.0.1:
+ resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+
+ unist-util-position@5.0.0:
+ resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+
+ unist-util-remove-position@5.0.0:
+ resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==}
+
+ unist-util-stringify-position@4.0.0:
+ resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+ unist-util-visit-parents@6.0.2:
+ resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
+
+ unist-util-visit@5.1.0:
+ resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
+
unplugin@2.3.11:
resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
engines: {node: '>=18.12.0'}
@@ -5079,6 +5462,15 @@ packages:
resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
engines: {node: '>=10.12.0'}
+ vfile-location@5.0.3:
+ resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
+
+ vfile-message@4.0.3:
+ resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
+
+ vfile@6.0.3:
+ resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -5189,6 +5581,9 @@ packages:
wavesurfer.js@7.12.1:
resolution: {integrity: sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg==}
+ web-namespaces@2.0.1:
+ resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
+
web-tree-sitter@0.26.6:
resolution: {integrity: sha512-fSPR7VBW/fZQdUSp/bXTDLT+i/9dwtbnqgEBMzowrM4U3DzeCwDbY3MKo0584uQxID4m/1xpLflrlT/rLIRPew==}
@@ -5361,6 +5756,9 @@ packages:
use-sync-external-store:
optional: true
+ zwitch@2.0.4:
+ resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
snapshots:
'@adobe/css-tools@4.4.4': {}
@@ -6781,6 +7179,14 @@ snapshots:
postcss: 8.5.15
tailwindcss: 4.2.1
+ '@tanstack/react-virtual@3.14.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@tanstack/virtual-core': 3.17.0
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
+ '@tanstack/virtual-core@3.17.0': {}
+
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.0
@@ -6965,16 +7371,28 @@ snapshots:
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
+ '@types/debug@4.1.13':
+ dependencies:
+ '@types/ms': 2.1.0
+
'@types/deep-eql@4.0.2': {}
'@types/doctrine@0.0.9': {}
+ '@types/estree-jsx@1.0.5':
+ dependencies:
+ '@types/estree': 1.0.8
+
'@types/estree@1.0.8': {}
'@types/geojson@7946.0.16': {}
'@types/google-libphonenumber@7.4.30': {}
+ '@types/hast@3.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
'@types/istanbul-lib-coverage@2.0.6': {}
'@types/istanbul-lib-report@3.0.3':
@@ -6989,10 +7407,18 @@ snapshots:
'@types/json-schema@7.0.15': {}
+ '@types/katex@0.16.8': {}
+
'@types/luxon@3.7.1': {}
+ '@types/mdast@4.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
'@types/mdx@2.0.13': {}
+ '@types/ms@2.1.0': {}
+
'@types/node@22.19.11':
dependencies:
undici-types: 6.21.0
@@ -7015,6 +7441,10 @@ snapshots:
'@types/trusted-types@2.0.7': {}
+ '@types/unist@2.0.11': {}
+
+ '@types/unist@3.0.3': {}
+
'@types/wait-on@5.3.4':
dependencies:
'@types/node': 22.19.11
@@ -7182,7 +7612,7 @@ snapshots:
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
- '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(sass@1.100.0))':
+ '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(sass@1.100.0))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -7197,7 +7627,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.2
tinyrainbow: 2.0.0
- vitest: 3.2.6(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(sass@1.100.0)
+ vitest: 3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(sass@1.100.0)
transitivePeerDependencies:
- supports-color
@@ -7501,6 +7931,8 @@ snapshots:
babel-plugin-jest-hoist: 30.3.0
babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0)
+ bail@2.0.2: {}
+
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
@@ -7579,6 +8011,8 @@ snapshots:
caniuse-lite@1.0.30001774: {}
+ ccount@2.0.1: {}
+
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -7602,6 +8036,14 @@ snapshots:
char-regex@1.0.2: {}
+ character-entities-html4@2.1.0: {}
+
+ character-entities-legacy@3.0.0: {}
+
+ character-entities@2.0.2: {}
+
+ character-reference-invalid@2.0.1: {}
+
check-error@2.1.3: {}
chokidar@4.0.3:
@@ -7666,6 +8108,8 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
+ comma-separated-tokens@2.0.3: {}
+
commander@12.1.0: {}
commander@3.0.2: {}
@@ -7980,6 +8424,10 @@ snapshots:
decimal.js@10.6.0: {}
+ decode-named-character-reference@1.3.0:
+ dependencies:
+ character-entities: 2.0.2
+
dedent@1.7.2: {}
deep-eql@5.0.2: {}
@@ -8027,6 +8475,10 @@ snapshots:
devalue@5.8.1: {}
+ devlop@1.1.0:
+ dependencies:
+ dequal: 2.0.3
+
diffable-html@4.1.0:
dependencies:
htmlparser2: 3.10.1
@@ -8268,6 +8720,8 @@ snapshots:
escape-string-regexp@4.0.0: {}
+ escape-string-regexp@5.0.0: {}
+
eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.3(jiti@2.6.1)):
dependencies:
aria-query: 5.3.2
@@ -8407,6 +8861,8 @@ snapshots:
estraverse@5.3.0: {}
+ estree-util-is-identifier-name@3.0.0: {}
+
estree-walker@2.0.2: {}
estree-walker@3.0.3:
@@ -8457,6 +8913,10 @@ snapshots:
dependencies:
type: 2.7.3
+ extend@3.0.2: {}
+
+ fake-indexeddb@6.2.5: {}
+
fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {}
@@ -8688,6 +9148,92 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ hast-util-from-dom@5.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ hastscript: 9.0.1
+ web-namespaces: 2.0.1
+
+ hast-util-from-html-isomorphic@2.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-from-dom: 5.0.1
+ hast-util-from-html: 2.0.3
+ unist-util-remove-position: 5.0.0
+
+ hast-util-from-html@2.0.3:
+ dependencies:
+ '@types/hast': 3.0.4
+ devlop: 1.1.0
+ hast-util-from-parse5: 8.0.3
+ parse5: 7.3.0
+ vfile: 6.0.3
+ vfile-message: 4.0.3
+
+ hast-util-from-parse5@8.0.3:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ devlop: 1.1.0
+ hastscript: 9.0.1
+ property-information: 7.2.0
+ vfile: 6.0.3
+ vfile-location: 5.0.3
+ web-namespaces: 2.0.1
+
+ hast-util-is-element@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ hast-util-parse-selector@4.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ hast-util-sanitize@5.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@ungap/structured-clone': 1.3.0
+ unist-util-position: 5.0.0
+
+ hast-util-to-jsx-runtime@2.3.6:
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ hast-util-whitespace: 3.0.0
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
+ property-information: 7.2.0
+ space-separated-tokens: 2.0.2
+ style-to-js: 1.1.21
+ unist-util-position: 5.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ hast-util-to-text@4.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ hast-util-is-element: 3.0.0
+ unist-util-find-after: 5.0.0
+
+ hast-util-whitespace@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ hastscript@9.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ comma-separated-tokens: 2.0.3
+ hast-util-parse-selector: 4.0.0
+ property-information: 7.2.0
+ space-separated-tokens: 2.0.2
+
highlight.js@11.11.1: {}
homedir-polyfill@1.0.3:
@@ -8704,6 +9250,8 @@ snapshots:
dependencies:
void-elements: 3.1.0
+ html-url-attributes@3.0.1: {}
+
html@1.0.0:
dependencies:
concat-stream: 1.6.2
@@ -8772,6 +9320,8 @@ snapshots:
ini@1.3.8: {}
+ inline-style-parser@0.2.7: {}
+
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -8782,6 +9332,13 @@ snapshots:
internmap@2.0.3: {}
+ is-alphabetical@2.0.1: {}
+
+ is-alphanumerical@2.0.1:
+ dependencies:
+ is-alphabetical: 2.0.1
+ is-decimal: 2.0.1
+
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@@ -8824,6 +9381,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
+ is-decimal@2.0.1: {}
+
is-docker@3.0.0: {}
is-extglob@2.1.1: {}
@@ -8848,6 +9407,8 @@ snapshots:
dependencies:
is-extglob: 2.1.1
+ is-hexadecimal@2.0.1: {}
+
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
@@ -8861,6 +9422,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
+ is-plain-obj@4.1.0: {}
+
is-potential-custom-element-name@1.0.1: {}
is-reference@3.0.3:
@@ -9431,6 +9994,10 @@ snapshots:
dependencies:
commander: 8.3.0
+ katex@0.17.0:
+ dependencies:
+ commander: 8.3.0
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -9539,12 +10106,20 @@ snapshots:
loglevel@1.9.2: {}
+ longest-streak@3.1.0: {}
+
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
loupe@3.2.1: {}
+ lowlight@3.3.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ devlop: 1.1.0
+ highlight.js: 11.11.1
+
lru-cache@10.4.3: {}
lru-cache@11.2.6: {}
@@ -9587,6 +10162,8 @@ snapshots:
dependencies:
tmpl: 1.0.5
+ markdown-table@3.0.4: {}
+
marked@14.0.0: {}
marked@16.4.2: {}
@@ -9599,6 +10176,171 @@ snapshots:
dependencies:
domino: 2.1.7
+ mdast-util-find-and-replace@3.0.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ escape-string-regexp: 5.0.0
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ mdast-util-from-markdown@2.0.3:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ mdast-util-to-string: 4.0.0
+ micromark: 4.0.2
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-decode-string: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ unist-util-stringify-position: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-find-and-replace: 3.0.2
+ micromark-util-character: 2.1.1
+
+ mdast-util-gfm-footnote@2.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ micromark-util-normalize-identifier: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-table@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ markdown-table: 3.0.4
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm@3.1.0:
+ dependencies:
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-gfm-autolink-literal: 2.0.1
+ mdast-util-gfm-footnote: 2.1.0
+ mdast-util-gfm-strikethrough: 2.0.0
+ mdast-util-gfm-table: 2.0.0
+ mdast-util-gfm-task-list-item: 2.0.0
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-math@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ longest-streak: 3.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ unist-util-remove-position: 5.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-expression@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-jsx@3.2.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ parse-entities: 4.0.2
+ stringify-entities: 4.0.4
+ unist-util-stringify-position: 4.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdxjs-esm@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-phrasing@4.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ unist-util-is: 6.0.1
+
+ mdast-util-to-hast@13.2.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@ungap/structured-clone': 1.3.0
+ devlop: 1.1.0
+ micromark-util-sanitize-uri: 2.0.1
+ trim-lines: 3.0.1
+ unist-util-position: 5.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+
+ mdast-util-to-markdown@2.1.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ longest-streak: 3.1.0
+ mdast-util-phrasing: 4.1.0
+ mdast-util-to-string: 4.0.0
+ micromark-util-classify-character: 2.0.1
+ micromark-util-decode-string: 2.0.1
+ unist-util-visit: 5.1.0
+ zwitch: 2.0.4
+
+ mdast-util-to-string@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+
merge-stream@2.0.0: {}
mermaid@11.15.0:
@@ -9625,6 +10367,207 @@ snapshots:
ts-dedent: 2.2.0
uuid: 11.1.1
+ micromark-core-commonmark@2.0.3:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-factory-destination: 2.0.1
+ micromark-factory-label: 2.0.1
+ micromark-factory-space: 2.0.1
+ micromark-factory-title: 2.0.1
+ micromark-factory-whitespace: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-html-tag-name: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-footnote@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-table@2.1.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm@3.0.0:
+ dependencies:
+ micromark-extension-gfm-autolink-literal: 2.1.0
+ micromark-extension-gfm-footnote: 2.1.0
+ micromark-extension-gfm-strikethrough: 2.1.0
+ micromark-extension-gfm-table: 2.1.1
+ micromark-extension-gfm-tagfilter: 2.0.0
+ micromark-extension-gfm-task-list-item: 2.1.0
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-math@3.1.0:
+ dependencies:
+ '@types/katex': 0.16.8
+ devlop: 1.1.0
+ katex: 0.16.47
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-destination@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-label@2.0.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-space@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-title@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-whitespace@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-character@2.1.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-chunked@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-classify-character@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-combine-extensions@2.0.1:
+ dependencies:
+ micromark-util-chunked: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-decode-string@2.0.1:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ micromark-util-character: 2.1.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-encode@2.0.1: {}
+
+ micromark-util-html-tag-name@2.0.1: {}
+
+ micromark-util-normalize-identifier@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-resolve-all@2.0.1:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-util-sanitize-uri@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-encode: 2.0.1
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-subtokenize@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-symbol@2.0.1: {}
+
+ micromark-util-types@2.0.2: {}
+
+ micromark@4.0.2:
+ dependencies:
+ '@types/debug': 4.1.13
+ debug: 4.4.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-encode: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
mime-db@1.52.0: {}
mime-types@2.1.35:
@@ -9853,6 +10796,16 @@ snapshots:
dependencies:
callsites: 3.1.0
+ parse-entities@4.0.2:
+ dependencies:
+ '@types/unist': 2.0.11
+ character-entities-legacy: 3.0.0
+ character-reference-invalid: 2.0.1
+ decode-named-character-reference: 1.3.0
+ is-alphanumerical: 2.0.1
+ is-decimal: 2.0.1
+ is-hexadecimal: 2.0.1
+
parse-json@5.2.0:
dependencies:
'@babel/code-frame': 7.29.0
@@ -9977,6 +10930,8 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
+ property-information@7.2.0: {}
+
prosemirror-dev-toolkit@1.1.8(svelte@5.56.3(@typescript-eslint/types@8.56.1)):
dependencies:
html: 1.0.0
@@ -10063,6 +11018,24 @@ snapshots:
react-is@18.3.1: {}
+ react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/react': 19.2.14
+ devlop: 1.1.0
+ hast-util-to-jsx-runtime: 2.3.6
+ html-url-attributes: 3.0.1
+ mdast-util-to-hast: 13.2.1
+ react: 19.2.4
+ remark-parse: 11.0.0
+ remark-rehype: 11.1.2
+ unified: 11.0.5
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
react@19.2.4: {}
readable-stream@2.3.8:
@@ -10118,10 +11091,76 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
+ rehype-highlight@7.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-to-text: 4.0.2
+ lowlight: 3.3.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+
+ rehype-katex@7.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/katex': 0.16.8
+ hast-util-from-html-isomorphic: 2.0.0
+ hast-util-to-text: 4.0.2
+ katex: 0.16.47
+ unist-util-visit-parents: 6.0.2
+ vfile: 6.0.3
+
+ rehype-sanitize@6.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-sanitize: 5.0.2
+
release-zalgo@1.0.0:
dependencies:
es6-error: 4.1.1
+ remark-gfm@4.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-gfm: 3.1.0
+ micromark-extension-gfm: 3.0.0
+ remark-parse: 11.0.0
+ remark-stringify: 11.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-math@6.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-math: 3.0.0
+ micromark-extension-math: 3.1.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-parse@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.3
+ micromark-util-types: 2.0.2
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-rehype@11.1.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ mdast-util-to-hast: 13.2.1
+ unified: 11.0.5
+ vfile: 6.0.3
+
+ remark-stringify@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-to-markdown: 2.1.2
+ unified: 11.0.5
+
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
@@ -10336,6 +11375,8 @@ snapshots:
source-map@0.7.6: {}
+ space-separated-tokens@2.0.2: {}
+
spawn-wrap@2.0.0:
dependencies:
foreground-child: 2.0.0
@@ -10475,6 +11516,11 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
+ stringify-entities@4.0.4:
+ dependencies:
+ character-entities-html4: 2.1.0
+ character-entities-legacy: 3.0.0
+
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
@@ -10503,6 +11549,14 @@ snapshots:
style-mod@4.1.3: {}
+ style-to-js@1.1.21:
+ dependencies:
+ style-to-object: 1.0.14
+
+ style-to-object@1.0.14:
+ dependencies:
+ inline-style-parser: 0.2.7
+
stylis@4.4.0: {}
sucrase@3.35.1:
@@ -10623,6 +11677,10 @@ snapshots:
tree-kill@1.2.2: {}
+ trim-lines@3.0.1: {}
+
+ trough@2.2.0: {}
+
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -10734,6 +11792,49 @@ snapshots:
undici-types@6.21.0: {}
+ unified@11.0.5:
+ dependencies:
+ '@types/unist': 3.0.3
+ bail: 2.0.2
+ devlop: 1.1.0
+ extend: 3.0.2
+ is-plain-obj: 4.1.0
+ trough: 2.2.0
+ vfile: 6.0.3
+
+ unist-util-find-after@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
+ unist-util-is@6.0.1:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-position@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-remove-position@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-visit: 5.1.0
+
+ unist-util-stringify-position@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-visit-parents@6.0.2:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
+ unist-util-visit@5.1.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
unplugin@2.3.11:
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -10789,6 +11890,21 @@ snapshots:
'@types/istanbul-lib-coverage': 2.0.6
convert-source-map: 2.0.0
+ vfile-location@5.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile: 6.0.3
+
+ vfile-message@4.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-stringify-position: 4.0.0
+
+ vfile@6.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile-message: 4.0.3
+
vite-node@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.100.0):
dependencies:
cac: 6.7.14
@@ -10825,7 +11941,7 @@ snapshots:
lightningcss: 1.31.1
sass: 1.100.0
- vitest@3.2.6(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(sass@1.100.0):
+ vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(sass@1.100.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.6
@@ -10851,6 +11967,7 @@ snapshots:
vite-node: 3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.100.0)
why-is-node-running: 2.3.0
optionalDependencies:
+ '@types/debug': 4.1.13
'@types/node': 22.19.11
jsdom: 26.1.0
transitivePeerDependencies:
@@ -10908,6 +12025,8 @@ snapshots:
wavesurfer.js@7.12.1: {}
+ web-namespaces@2.0.1: {}
+
web-tree-sitter@0.26.6: {}
webidl-conversions@7.0.0: {}
@@ -11084,3 +12203,5 @@ snapshots:
'@types/react': 19.2.14
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
+
+ zwitch@2.0.4: {}
diff --git a/src/components/SuperChat/MAINTAINERS.md b/src/components/SuperChat/MAINTAINERS.md
new file mode 100644
index 00000000..6fd07bcb
--- /dev/null
+++ b/src/components/SuperChat/MAINTAINERS.md
@@ -0,0 +1,291 @@
+# SuperChat — Maintainer Notes
+
+> **Provider notes** — how to *change* the SuperChat module. Consumers should read
+> the [README.md](README.md) (getting started + vocabulary) and the Storybook
+> autodocs (Product › Feature Modules › SuperChat). The design rationale lives in
+> [Mission](#mission), below. General conventions live in
+> [CONTRIBUTING.md](../../../CONTRIBUTING.md). This module **composes** the AI
+> module — see [../AI/MAINTAINERS.md](../AI/MAINTAINERS.md).
+
+## What's in here
+
+| Surface | File | Role |
+|---------|------|------|
+| `SuperChatInbox` | [SuperChatInbox.tsx](SuperChatInbox.tsx) | Combined surface: composes the list + panel; owns active-conversation selection (drop-in for the original monolithic component) |
+| `SuperChat` | [SuperChat.tsx](SuperChat.tsx) | Single-conversation **panel**: header + thread + composer (takes one `conversation`) |
+| `SuperChatConversations` | [SuperChatConversations.tsx](SuperChatConversations.tsx) | Conversation **list** (sidebar); controlled/uncontrolled selection |
+| shared internals | [parts.tsx](parts.tsx) | Internal-only helpers + presentational pieces (`ParticipantAvatar`, `ReferenceChip`, `MessageRow`, `Composer`, `sidebarItem`) shared by all three components — **not** exported from `index.ts` |
+| `createMarkdownRenderer` | [render/createMarkdownRenderer.tsx](render/createMarkdownRenderer.tsx) | Composes render plugins → one `renderTextContent` (Markdown core) |
+| render context | [render/renderContext.ts](render/renderContext.ts) | Threads `messageId`/`streaming` into custom nodes (GenUI) |
+| code / math / genui / mermaid / image / nitro-table plugins | [plugins/](plugins) | Opt-in rich plugins (subpath entry) |
+| types | [types.ts](types.ts) | Participant model + chat-component-compatible data model + plugin/GenUI contracts |
+
+> **Component split.** The three components share one folder and one import path
+> (`@mieweb/ui/components/SuperChat`) and one tsup entry. `SuperChat` owns the
+> panel-only state (`renderText` memo, thread scroll effect, `participantById`,
+> `orderedThread`); `SuperChatConversations` owns the list-only state (sorting +
+> selection); `SuperChatInbox` owns the shared active-conversation coordination
+> and renders the other two. Data slots: `superchat-inbox` (inbox root),
+> `superchat` (panel root), `superchat-conversations` (list root).
+
+## Architecture (3 decisions, summarized)
+
+1. **Native reimplementation** preserving the `chat-component` prop/data-model
+ shape (`SuperChatConversation` / `SuperChatMessage` with `participantId`,
+ `channel`, `ref`, `linkBuilder`, callbacks). No bundled React / `tw-` prefix.
+2. **Participant model** (`Participant { id, kind, name, color?, … }`) unifies the
+ AI module's `user`/`assistant` and chat-component's `external`/`internal`/`system`.
+ The thread is append-only and **ordered by `time`**; concurrent agent replies
+ interleave and are disambiguated by per-participant `color`/avatar/name.
+3. **Pluggable Markdown pipeline** wired through the AI module's
+ `renderTextContent` seam. **The host owns sanitization** — untrusted output is
+ run through `rehype-sanitize` with an allow-list extended per plugin.
+
+## Bundle / entry layout
+
+- `@mieweb/ui/components/SuperChat` ships the three components (`SuperChatInbox`
+ / `SuperChat` / `SuperChatConversations`) **+ Markdown core** only
+ (`react-markdown` + `remark-gfm` + `rehype-sanitize`).
+- `@mieweb/ui/components/SuperChat/plugins` ships `code` / `math` / `genui` /
+ `mermaid` / `image` / `nitro-table`. Each rich dependency (`rehype-highlight`,
+ `rehype-katex`/`katex`, `mermaid`, `datavis`/`datavis-ace`) is an **optional
+ peer dependency** — not in the base bundle. tsup entries:
+ `components/SuperChat/index` and `components/SuperChat/plugins/index`.
+- SuperChat is intentionally **not** re-exported from the top-level `src/index.ts`
+ (same pattern as `datavis` / `ag-grid`) so the main bundle stays light.
+
+## Render plugin contract (read before adding a plugin)
+
+A `SuperChatRenderPlugin` contributes `remarkPlugins`, `rehypePlugins`,
+`components` (node → React), `widgets` (GenUI), and a `sanitizeSchema` fragment.
+The composer:
+
+- always prepends `remark-gfm`;
+- appends `rehype-sanitize` **last** (after highlight/katex) so their classNames
+ exist to be allow-listed — order matters, don't reshuffle;
+- merges each plugin's `sanitizeSchema` (tagNames union, per-tag attribute concat).
+
+**Gotchas**
+
+- The base schema broadens `className` on `code`/`pre`/`span`/`div` so `.hljs-*`
+ syntax tokens survive. If you tighten this you will strip highlight colors.
+- The math plugin must allow KaTeX's HTML+MathML tags — see `KATEX_TAGS`.
+- Consumers of the math plugin must import `katex/dist/katex.min.css` themselves.
+
+## GenUI widgets
+
+- Wire format is a **fenced ```genui JSON block**, not inline. A small rehype
+ transformer rewrites `` → `… `
+ (payload as a text child) *before* sanitize (the tag is allow-listed), which
+ avoids `pre`/`code` component-override conflicts with the code plugin.
+- Widgets are **host-registered, lazy, schema-validated**. Unknown widget →
+ inert code-block fallback (never arbitrary HTML).
+- Prefetch is split: **component (code)** may load while streaming per policy
+ (`eager`/`visible`/`idle`); **data** validation/prefetch runs only once the
+ payload parses *and* the message has stopped streaming. **Registry policy
+ overrides the wire hint.**
+- Versioning: key the registry by base name; resolve `version` explicitly (do not
+ bake the version into the lookup key).
+
+## Mermaid / image / NITRO-table plugins
+
+- **Mermaid** ([plugins/mermaid.tsx](plugins/mermaid.tsx)) — a rehype transformer
+ rewrites `` → `` (source as
+ a text child, like GenUI). `mermaid` is **lazy-loaded once** and initialized
+ with `securityLevel: 'strict'`; the rendered SVG is injected via
+ `dangerouslySetInnerHTML`, so it **bypasses `rehype-sanitize`** — strict mode is
+ the trust boundary. Rendering is gated on `streaming` (partial source shows a
+ pending card). Render failure → inert code-block fallback.
+- **Image** ([plugins/image.tsx](plugins/image.tsx)) — overrides the `img` node
+ with a click-to-zoom button that opens Messaging's `LightboxModal`. Because
+ Markdown images live inside a ``, the lightbox is **portaled to
+ `document.body`** (don't nest the fixed overlay in the paragraph). `src`/`alt`
+ are already protocol-restricted by `rehype-sanitize`.
+- **NITRO table** ([plugins/nitroTable.tsx](plugins/nitroTable.tsx)) — overrides
+ the `table` node, parses the GFM table out of the hast `node` into
+ `{ headers, rows }`, and `React.lazy`-loads the actual grid
+ ([plugins/nitroTableGrid.tsx](plugins/nitroTableGrid.tsx)) so `datavis` stays
+ out of the base/test path. Data is handed to `DataVisNitroSource type="http"`
+ via a short-lived **object-URL** carrying the `{ typeInfo, data }` shape the
+ engine expects. A `GridErrorBoundary` + `Suspense` **degrades to the themed HTML
+ table** if the grid can't load. **Note:** the `datavis` submodule must be
+ checked out (`git submodule update --init`) to render the real grid — without it
+ the fallback HTML table is shown (the case in CI/dev when the submodule is
+ absent).
+
+## Not yet implemented (tracked against the mission)
+
+- `shiki` upgrade path for the code plugin (currently `rehype-highlight`).
+
+## Testing
+
+- Stories: [SuperChat.stories.tsx](SuperChat.stories.tsx) drives the autodocs page
+ (Markdown core / rich plugins / read-only).
+- When adding a plugin, add a story exercising it and confirm sanitized output
+ still renders.
+
+---
+
+## Mission
+
+> The design rationale behind SuperChat — the *why* behind the architecture above.
+> The module is **implemented**; this section is retained as the entry point for
+> maintainers and the record of decisions (including rejected alternatives).
+
+### Goal
+
+Bring a first-class **chat UI** into `@mieweb/ui` that:
+
+1. Supports **multiple participants** — any mix of **multiple AI agents** and
+ **multiple humans** in one conversation (not just 1 user ↔ 1 assistant).
+2. Renders **rich Markdown** message content through a **plugin system**, with
+ plugins for **math**, **interactive (`genui`) widgets**, **syntax-highlighted
+ code**, and **tables rendered via NITRO DataVis**.
+
+It reuses the AI module's extension seam (`renderTextContent` in
+[../AI](../AI)) rather than forking the renderer, and aligns with the API contract
+shape of the standalone
+[`mieweb/chat-component`](https://github.com/mieweb/chat-component) repo.
+
+### Background: what already existed
+
+**`@mieweb/ui` AI module ([../AI](../AI)).** `AIChat`, `AIMessageDisplay`,
+`AIChatModal`/`FloatingAIChat`, `MCPToolCallDisplay`. Roles: `user` / `assistant`;
+content blocks `text` / `tool_use` / `tool_result` / `thinking` / `code`. The
+**`renderTextContent` render-prop** is the rich-rendering seam (host owns
+sanitization; plain text by default). TypeScript, React 19 peer, Tailwind 4 theme
+tokens (`--mieweb-*`), CVA, tree-shakeable.
+
+**Standalone `mieweb/chat-component`
+([repo](https://github.com/mieweb/chat-component)).** JavaScript (not TS), React 19
+**bundled into a self-contained UMD**, **Zustand** store, Tailwind with a `tw-`
+prefix + preflight disabled + styles scoped to `.chat-component-root` (drops into
+Bootstrap pages without conflicts). Multi-conversation sidebar + thread + compose;
+read-only mode; export/import state; `linkBuilder`; callbacks. Data model:
+`conversation { id, title, reference_id?, open, unread, lastActivity, thread[] }`;
+thread items `type: 'message' | 'ref' | …` with `role: 'external' | 'internal' |
+'system'`, `senderId`, `channel` (`portal|sms|voicemail|auto`), `time`, `text`;
+`ref` items carry `refType` (`doc|rx|appt`) + `refId` + `title`. **Messages are
+plain text today** — that gap is exactly what SuperChat fills. Its `role`/`channel`
+model is **healthcare-messaging** oriented while the AI module's is **assistant**
+oriented; SuperChat needs a participant model generalizing both.
+
+### Decision 1 — Native reimplementation
+
+**Build a native `@mieweb/ui` component that preserves the `chat-component` API
+contract shape** — not a git submodule of `mieweb/chat-component`. Reimplement in
+`src/components/SuperChat/` (TS, CVA, theme tokens, controlled props,
+tree-shakeable), preserving the prop/data-model shape
+(conversation/thread/role/channel/`linkBuilder`/callbacks) so existing consumers
+migrate with minimal churn. Reuse `AIMessageDisplay` + `renderTextContent` for
+message rendering.
+
+**Why native:** native theme tokens / dark mode / brand switching / a11y / CVA /
+shared TS types; one React + one Tailwind (no bundled-React / `tw-`-prefix
+duplication); controlled-props model consistent with the rest of `@mieweb/ui`
+(host owns state, works with any store); unifies the two role models under one
+participant model (Decision 2). The cost is reimplementation — porting the
+sidebar, read-only mode, export/import, search — but the standalone repo's
+defining traits (self-contained UMD, bundled React, `tw-` prefix, disabled
+preflight) exist to be **framework-agnostic embeddable**, the *opposite* of a
+tree-shakeable, theme-token-driven library component.
+
+> **Rejected — submodule (`@mieweb/ui/chat` over `mieweb/chat-component`).**
+> Fastest to stand up and a single source of truth, but bundles its own React 19 +
+> `tw-` prefix / disabled preflight / `.chat-component-root` scoping (two React +
+> two Tailwind configs in one app), can't pick up `--mieweb-*` tokens / dark mode /
+> brands without rework, is JavaScript + Zustand-coupled (no shared types, store
+> instead of controlled props), and adds submodule friction for a central
+> component.
+
+There is **no requirement** to share a single implementation across the standalone
+UMD and `@mieweb/ui`. The rich features (Markdown, math, code, GenUI, NITRO
+tables) do **not** need to ship to Bootstrap/non-React embeds — the standalone UMD
+stays as-is for that use case, so no shared/headless-core split is needed.
+
+### Decision 2 — Participant model (multi-agent / multi-human)
+
+Generalize both role systems into a **participant** concept:
+`Participant { id, kind: 'human' | 'agent' | 'system', name, avatar?, color?,
+role?, status? }`. Messages reference `participantId` (replacing/augmenting the
+binary `user`/`assistant` and `external`/`internal`/`system`). Map existing
+models: `assistant`→agent, `user`/`external`→human, `internal`→human (staff),
+`system`→system. Backwards compatible: a single-agent chat is just a
+2-participant conversation; `AIChat`'s current API keeps working.
+
+**Turn-taking / routing.** Participants address an agent with an `@`-mention; an
+agent's response targets whoever last mentioned it (or a named participant). If
+multiple agents respond at once, their messages **interleave in the thread by
+timestamp** — no global lock, the thread is append-only and ordered by `time`.
+Disambiguate concurrent/interleaved replies with per-participant `color`, avatar,
+and name label. Presence/typing, agent-to-agent hand-off, and tool-call
+visualization reuse the `MCPToolCall` card chrome.
+
+### Decision 3 — Markdown + plugin rendering pipeline
+
+Render message text via a **pluggable Markdown pipeline** wired through
+`renderTextContent` (so `AIChat`/`AIMessageDisplay` stay the host, and **the host
+owns sanitization** of untrusted model output). Core: `react-markdown` +
+`remark-gfm`, with `rehype-sanitize` on untrusted content. Rendering is exposed as
+a **plugin registry** so consumers opt into weight:
+
+| Plugin | Handles | Impl | Notes |
+|--------|---------|------|-------|
+| **Markdown core** | headings, lists, emphasis, links, blockquote, task lists | `react-markdown` + `remark-gfm` | always on |
+| **Math** | `$…$`, `$$…$$`, bracketed `[ a^2 + b^2 = c^2 ]` | `remark-math` + `rehype-katex` (KaTeX) | lazy-load KaTeX |
+| **GenUI widgets** | fenced ` ```genui ` JSON blocks | `code` node interceptor → **widget registry** | host-registered, lazy + schema-validated; degrades to a code block |
+| **Code** | fenced code blocks | lazy `rehype-highlight` (default); `shiki` upgrade path | `.hljs-*` classes mapped to `--mieweb-*` tokens + copy button |
+| **Tables** | GFM tables | **NITRO DataVis** ([../DataVisNITRO/MAINTAINERS.md](../DataVisNITRO/MAINTAINERS.md)) | GFM table → NITRO grid; prefer NITRO over AGGrid |
+| **Mermaid** | ` ```mermaid ` fences | lazy `mermaid` | |
+| **Images** | inline images | Messaging attachment lightbox | reuse existing lightbox |
+
+The detailed plugin contract, GenUI wire format/registry, prefetch semantics, and
+security model are documented in the implementation sections above
+([Render plugin contract](#render-plugin-contract-read-before-adding-a-plugin),
+[GenUI widgets](#genui-widgets),
+[Mermaid / image / NITRO-table plugins](#mermaid--image--nitro-table-plugins)).
+`SuperChat`/`AIChat` accept `renderPlugins?: SuperChatRenderPlugin[]` and compose
+them into a single `renderTextContent`. The default export ships only Markdown
+core; math/code/genui/NITRO/mermaid are **opt-in** subpath imports to keep the
+base bundle light.
+
+**Security.** Untrusted model/agent output **must** be sanitized
+(`rehype-sanitize` allow-list). When composing with `rehype-highlight`, order
+matters: allow the highlighter's `className` on `code`/`span` (or highlight after
+sanitizing) so token colors survive. GenUI widgets render only **host-registered**
+components keyed by name — unknown names fall back to the inert `genui` code block,
+never arbitrary HTML/script. Per-widget `schema` validates the payload before it
+reaches the component. The host owns the trust boundary, same contract as
+`renderTextContent` today.
+
+### New dependencies (all lazy / opt-in)
+
+`react-markdown`, `remark-gfm`, `remark-math`, `rehype-katex` (+ `katex`),
+`rehype-sanitize`, `rehype-highlight` (default highlighter; `shiki` reserved as an
+upgrade path), `mermaid`. NITRO tables reuse the existing `@mieweb/ui/datavis`
+entry. None enter the base bundle; each rich plugin is a subpath/lazy import.
+
+### Resolved decisions
+
+- **Shared implementation across UMD and `@mieweb/ui`?** No — no Bootstrap/non-React
+ support for the rich features; the standalone UMD stays as-is, SuperChat is built
+ natively with a compatible API shape. No headless-core split. *(Decision 1)*
+- **Turn-taking for concurrent agents?** `@`-mention addressing; responses target
+ the last mention; concurrent replies interleave by timestamp with per-participant
+ visual cues. *(Decision 2)*
+- **Highlighter?** `rehype-highlight` by default (lighter, `.hljs-*` classes map to
+ theme tokens); `shiki` reserved as an upgrade path. *(Decision 3)*
+- **GenUI format + prefetch?** Fenced ` ```genui ` JSON blocks; host-registered,
+ lazy, schema-validated registry; prefetch split into component vs. data with
+ `eager`/`visible`/`idle` policies (registry overrides wire hint). *(Decision 3)*
+- **Fold into the `AI` module, or a new module?** A **new `SuperChat` module** that
+ composes `AI`. The `AI` module stays a lightweight building-block layer; the chat
+ UI is heavier and opinionated, so consumers opt in without pulling in chat
+ dependencies they don't need.
+
+### Related
+
+- AI module: [../AI](../AI) · notes: [../AI/MAINTAINERS.md](../AI/MAINTAINERS.md)
+- NITRO tables: [../DataVisNITRO/MAINTAINERS.md](../DataVisNITRO/MAINTAINERS.md)
+- Provider conventions: [CONTRIBUTING.md](../../../CONTRIBUTING.md)
+- Standalone repo:
diff --git a/src/components/SuperChat/README.md b/src/components/SuperChat/README.md
new file mode 100644
index 00000000..05237198
--- /dev/null
+++ b/src/components/SuperChat/README.md
@@ -0,0 +1,682 @@
+# SuperChat
+
+> **Consumer guide** — how to _use_ the SuperChat components in your own
+> application. For internals, how to _change_ the module, and the design
+> rationale, see [MAINTAINERS.md](MAINTAINERS.md).
+
+SuperChat is a native, multi-participant chat surface for `@mieweb/ui`: any mix
+of **multiple AI agents** and **multiple humans** in a single conversation.
+Message text renders through a pluggable Markdown pipeline (code, math, GenUI
+widgets, Mermaid, images, NITRO tables). All components are **controlled** —
+your application owns conversation state.
+
+The module ships **three composable components** from one import path:
+
+| Component | What it is | Use it when |
+| ---------------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
+| **`SuperChatInbox`** | The combined surface: conversation list **+** active conversation panel. | You want the full inbox out of the box (drop-in for the original monolithic component). |
+| **`SuperChat`** | A single-conversation **panel**: header, message thread, composer. | You manage conversation selection yourself, or only ever show one conversation. |
+| **`SuperChatConversations`** | The conversation **list** (sidebar). | You want the switcher on its own, or a custom layout pairing it with `SuperChat`. |
+
+`SuperChatInbox` is simply `SuperChatConversations` + `SuperChat` composed
+together, so anything the inbox does you can rebuild from the two parts.
+
+- [Install & entry points](#install--entry-points)
+- [Quick start](#quick-start)
+- [Visual layout & anatomy](#visual-layout--anatomy)
+- [Vocabulary](#vocabulary)
+- [Props](#props)
+- [Rich Markdown plugins](#rich-markdown-plugins)
+- [Accessibility](#accessibility)
+- [Related chat surfaces](#related-chat-surfaces) — how SuperChat compares to the AI, Messaging, and standalone chat modules
+
+---
+
+## Install & entry points
+
+SuperChat is **not** re-exported from the top-level package (it keeps the main
+bundle light, the same pattern as `datavis` / `ag-grid`). Import the components
+from the subpath:
+
+```ts
+// All three components + Markdown core (GFM + sanitization) — base bundle, no rich deps.
+import {
+ SuperChatInbox,
+ SuperChat,
+ SuperChatConversations,
+ createMarkdownRenderer,
+} from '@mieweb/ui/components/SuperChat';
+
+// Opt-in rich render plugins — each rich dependency is an optional peer dep.
+import {
+ createCodePlugin,
+ createMathPlugin,
+ createGenUIPlugin,
+ createMermaidPlugin,
+ createImagePlugin,
+ createNitroTablePlugin,
+} from '@mieweb/ui/components/SuperChat/plugins';
+```
+
+| Entry | Ships | Notes |
+| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| `@mieweb/ui/components/SuperChat` | `SuperChatInbox` / `SuperChat` / `SuperChatConversations` + Markdown core (`react-markdown` + `remark-gfm` + `rehype-sanitize`) | Requires the three Markdown-core peers above; no heavy deps. |
+| `@mieweb/ui/components/SuperChat/plugins` | `code` / `math` / `genui` / `mermaid` / `image` / `nitro-table` | Each rich dep (`rehype-highlight`, `katex`, `mermaid`, `datavis`) is an **optional peer dependency** — install only what you use. |
+
+> The **math** plugin renders KaTeX. Consumers must import the stylesheet
+> themselves: `import 'katex/dist/katex.min.css';`
+
+---
+
+## Quick start
+
+### The full inbox (`SuperChatInbox`)
+
+`SuperChatInbox` is controlled: you pass `conversations` and react to callbacks
+by updating your own state. It owns active-conversation selection.
+
+```tsx
+import * as React from 'react';
+import { SuperChatInbox } from '@mieweb/ui/components/SuperChat';
+import type { SuperChatConversation } from '@mieweb/ui/components/SuperChat';
+
+const initial: SuperChatConversation[] = [
+ {
+ id: 'c1',
+ title: 'Patient 4821 — Intake review',
+ participants: [
+ {
+ id: 'u1',
+ kind: 'human',
+ name: 'Dr. Alice Reyes',
+ role: 'Provider',
+ color: '#0e7490',
+ },
+ { id: 'a1', kind: 'agent', name: 'Triage Agent', color: '#2563eb' },
+ ],
+ thread: [
+ {
+ id: 'm1',
+ participantId: 'u1',
+ text: '@Triage summarize the chief complaint?',
+ time: '2026-06-07T09:00:00Z',
+ },
+ {
+ id: 'm2',
+ participantId: 'a1',
+ text: '**Chief complaint:** chest tightness on exertion.',
+ time: '2026-06-07T09:00:30Z',
+ },
+ ],
+ },
+];
+
+export function PatientChat() {
+ const [conversations, setConversations] = React.useState(initial);
+
+ return (
+
+ {
+ // The host owns state: append the sent message to the thread.
+ setConversations((prev) =>
+ prev.map((c) =>
+ c.id === conversation.id
+ ? {
+ ...c,
+ thread: [
+ ...c.thread,
+ {
+ id: `m-${Date.now()}`,
+ participantId: 'u1',
+ text,
+ time: new Date().toISOString(),
+ },
+ ],
+ }
+ : c
+ )
+ );
+ // `mentions` holds the ids of any @-addressed participants — route to your agents here.
+ }}
+ />
+
+ );
+}
+```
+
+Give the wrapper an explicit height — the inbox fills its container (`h-full`).
+
+### A single conversation panel (`SuperChat`)
+
+When you already know which conversation to show (or manage selection
+yourself), render the panel directly. It takes **one** `conversation`:
+
+```tsx
+import { SuperChat } from '@mieweb/ui/components/SuperChat';
+
+
+ {
+ /* … */
+ }}
+ />
+
;
+```
+
+### A custom layout (`SuperChatConversations` + `SuperChat`)
+
+Compose the two parts yourself for a bespoke layout — this is exactly what
+`SuperChatInbox` does internally:
+
+```tsx
+import * as React from 'react';
+import {
+ SuperChatConversations,
+ SuperChat,
+} from '@mieweb/ui/components/SuperChat';
+
+export function MyInbox({ conversations, currentParticipantId }) {
+ const [activeId, setActiveId] = React.useState(conversations[0]?.id);
+ const active =
+ conversations.find((c) => c.id === activeId) ?? conversations[0];
+
+ return (
+
+ setActiveId(c.id)}
+ />
+ {active && (
+
+ )}
+
+ );
+}
+```
+
+---
+
+## Visual layout & anatomy
+
+Every structural region carries a stable `data-slot` attribute (for styling /
+test selectors) **and** an ARIA role + accessible name (for assistive tech and
+keyboard navigation). The slot names below are the canonical vocabulary —
+they map one-to-one to the regions you see on screen.
+
+`SuperChatInbox` (root `superchat-inbox`) composes the conversations list
+(`superchat-conversations`) and the panel (`superchat`):
+
+```text
+┌─ data-slot="superchat-inbox" ─────────────────────────────────────────────────┐
+│ role=group · "Chat: {title}" │
+│ │
+│ ┌─ superchat-conversations ┐ ┌─ data-slot="superchat" ──────────────────────┐ │
+│ │ "Conversations" │ │ role=group, labelled by header │ │
+│ │ │ │ ┌─ superchat-header ───────────────────────┐ │ │
+│ │ Conversations [+] │ │ │ {title} [×] │ │ │
+│ │ ┌ superchat- ┐│ │ │ ┌ superchat-participants ┐ │ │ │
+│ │ │ conversation-list ││ │ │ │ group "Participants" │ (face-pile) │ │ │
+│ │ │ role=list ││ │ └─┴─────────────────────────┴──────────────┘ │ │
+│ │ │ ┌ item (aria-current)┐│| │ ┌─ superchat-thread ───────────────────────┐ │ │
+│ │ │ │ • Conversation A ││| │ │ role=log "Messages" · aria-live=polite │ │ │
+│ │ │ │ • Conversation B ││| │ │ ┌ superchat-message (article) ─────────┐ │ │ │
+│ │ │ └────────────────────┘│| │ │ │ avatar │ superchat-message-meta │ │ │ │
+│ │ └───────────────────────┘│ │ │ │ │ ┌ superchat-bubble ───────┐ │ │ │ │
+│ │ │ │ │ │ │ │ rendered Markdown │ │ │ │ │
+│ │ │ │ │ │ │ └─────────────────────────┘ │ │ │ │
+│ │ │ │ │ └────────┴─────────────────────────────┘ │ │ │
+│ │ │ │ ┌─ superchat-composer ─────────────────────┐ │ │
+│ │ │ │ │ [ combobox "Message" ……………… ] [ ▷ Send ] │ │ │
+│ │ │ │ │ listbox "Mention a participant" │ │ │
+│ │ │ │ └──────────────────────────────────────────┘ │ │
+│ └──────────────────────────┘ └──────────────────────────────────────────────┘ │
+└───────────────────────────────────────────────────────────────────────────────┘
+ └─ ─┘ └─────────────── ─────────────────┘
+```
+
+When you render `SuperChat` or `SuperChatConversations` standalone, you get just
+the corresponding region (the `superchat` panel, or the `superchat-conversations`
+list) without the `superchat-inbox` frame.
+
+---
+
+## Vocabulary
+
+The canonical terms for SuperChat and its sub-components. Use these names when
+styling (`[data-slot="…"]`), querying in tests, or discussing the UI.
+
+### Components & their roots
+
+| Component | Root `data-slot` | Element / role | Accessible name |
+| ----------------------------------- | ------------------------- | ----------------------- | --------------------------------------------------- |
+| **`SuperChatInbox`** | `superchat-inbox` | `div` · `group` | `Chat: {title}` |
+| **`SuperChat`** (panel) | `superchat` | `section` · `group` | labelled by the header ``, plus `Chat: {title}` |
+| **`SuperChatConversations`** (list) | `superchat-conversations` | `aside` (complementary) | `Conversations` |
+
+### Layout regions
+
+| Term | `data-slot` | Element / role | Accessible name | In component | Purpose |
+| ---------------------------- | ----------------------------- | ----------------------------- | ----------------------------- | ------------------------ | ---------------------------------------------------------------------------------- |
+| **Inbox** (root) | `superchat-inbox` | `div` · `group` | `Chat: {title}` | `SuperChatInbox` | The whole surface; owns layout and theming. |
+| **Conversations** (list) | `superchat-conversations` | `aside` (complementary) | `Conversations` | `SuperChatConversations` | Conversation switcher; hidden in the inbox when `showSidebar={false}`. |
+| **Conversation list** | `superchat-conversation-list` | `div` · `list` | — | `SuperChatConversations` | Ordered by last activity; items expose `aria-current` when active. |
+| **Conversation item** | — | `div` · `listitem` → `button` | conversation title | `SuperChatConversations` | Selects a conversation; shows title, last message preview, and unread badge. |
+| **Panel** | `superchat` | `section` · `group` | labelled by the header `` | `SuperChat` | Holds the active conversation. |
+| **Header** | `superchat-header` | `header` | — | `SuperChat` | Title, participant face-pile, and close affordance. |
+| **Participants** (face-pile) | `superchat-participants` | `div` · `group` | `Participants` | `SuperChat` | Avatars of (up to 6) participants. |
+| **Thread** (log) | `superchat-thread` | `div` · `log` | `Messages` | `SuperChat` | Scrollable, append-only message history; `aria-live="polite"`, keyboard-focusable. |
+| **Composer** | `superchat-composer` | `div` | — | `SuperChat` | The input region: mention-aware textarea + send button. |
+
+### Message parts
+
+| Term | `data-slot` | Element / role | Purpose |
+| ------------------ | -------------------------- | -------------------------------------- | ------------------------------------------------------------------ |
+| **Message** | `superchat-message` | `div` · `article` (`"{name}, {time}"`) | One thread item from a participant. |
+| **Message meta** | `superchat-message-meta` | `div` | Author name, role label, and timestamp. |
+| **Bubble** | `superchat-bubble` | `div` | The styled container holding rendered Markdown / rich blocks. |
+| **System message** | `superchat-system-message` | `div` · `status` | Centered system notice (joins, etc.). |
+| **Reference chip** | `superchat-reference` | `div` → `a`/`button` | A `ref` thread item (doc / rx / appt), linked via `linkBuilder`. |
+| **Avatar** | — | `img` or initials | Per-participant cue; color/avatar disambiguates concurrent agents. |
+
+### Composer parts
+
+| Term | Element / role | Accessible name | Purpose |
+| ------------------ | ----------------------- | ----------------------- | ------------------------------------------------------------------- |
+| **Message input** | `textarea` · `combobox` | `Message` | Draft input; `aria-autocomplete="list"`, wired to the mention menu. |
+| **Mention menu** | `ul` · `listbox` | `Mention a participant` | `@`-mention autocomplete (keyboard: ↑/↓, Enter/Tab, Esc). |
+| **Mention option** | `button` · `option` | participant name | A single suggestion; `aria-selected` tracks the highlight. |
+| **Send button** | `button` | `Send message` | Submits the draft (also Enter, without Shift). |
+
+### Data model
+
+| Term | Type | Meaning |
+| ----------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| **Participant** | [`Participant`](types.ts) | An actor: `kind` is `human` \| `agent` \| `system`; carries `name`, optional `color`, `avatar`, `role`, `status`. |
+| **Conversation** | [`SuperChatConversation`](types.ts) | `participants` + an ordered `thread`, plus `title`, `unread`, `lastActivity`. |
+| **Thread** | `SuperChatMessage[]` | Append-only list, **ordered by `time`**; concurrent agent replies interleave. |
+| **Message** | [`SuperChatMessage`](types.ts) | A thread item: `participantId`, `text` and/or rich `content` blocks, `time`, `status`, optional `editedAt`/`ref`/`mentions`. |
+| **Reference** | [`SuperChatRef`](types.ts) | A linked entity (`doc`/`rx`/`appt`) rendered as a chip. |
+| **Link builder** | [`SuperChatLinkBuilder`](types.ts) | `(ref) => href` for deep-linking reference chips. |
+| **Render plugin** | [`SuperChatRenderPlugin`](types.ts) | Contributes remark/rehype plugins, node components, GenUI widgets, and a sanitize-schema fragment. |
+| **GenUI widget** | [`GenUIWidgetEntry`](types.ts) | A host-registered interactive widget rendered from a fenced ` ```genui ` block. |
+
+---
+
+## Props
+
+### `SuperChatInbox`
+
+The combined surface. Owns active-conversation selection (controlled or
+uncontrolled).
+
+| Prop | Type | Default | Description |
+| ----------------------------- | --------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------- |
+| `conversations` | `SuperChatConversation[]` | — | **Required.** All conversations (host-owned). |
+| `activeConversationId` | `string` | — | Controlled active conversation id. |
+| `defaultActiveConversationId` | `string` | first conversation | Uncontrolled initial active id. |
+| `currentParticipantId` | `string` | — | The local user's id (drives alignment + compose identity). |
+| `renderPlugins` | `SuperChatRenderPlugin[]` | — | Opt-in rich Markdown plugins. |
+| `renderTextContent` | `AIRenderTextContent` | Markdown core | Replace the entire text renderer (advanced). |
+| `trustedContent` | `boolean` | `false` | Skip sanitization — **only** for host-authored content. |
+| `readOnly` | `boolean` | `false` | Disable the composer. |
+| `order` | `'asc' \| 'desc'` | `'asc'` | Thread ordering: `asc` (oldest→newest, messenger style) or `desc` (newest→oldest, feed style). |
+| `virtualized` | `boolean` | `false` | Windowed thread rendering — only mount rows near the viewport. Enable for long histories. |
+| `showSidebar` | `boolean` | `true` | Show the conversation list. |
+| `linkBuilder` | `SuperChatLinkBuilder` | — | Build hrefs for `ref` thread items. |
+| `className` | `string` | — | Extra classes on the root. |
+| `onMessageSent` | `(text, { conversation, mentions }) => void` | — | Fired on send; `mentions` are the addressed participant ids. |
+| `onMessageEdited` | `(messageId, text, { conversation }) => void` | — | Enables the inline "Edit" affordance on the local user's own messages; fired when an edit is saved. |
+| `onConversationOpened` | `(conversation) => void` | — | Fired when a conversation is selected. |
+| `onConversationClosed` | `(conversation) => void` | — | Shows a close button when provided. |
+| `onNewConversation` | `() => void` | — | Shows a "+" button in the list when provided. |
+| `onReferenceClick` | `(ref) => void` | — | Fired when a reference chip is activated. |
+
+### `SuperChat` (panel)
+
+Renders exactly one conversation.
+
+| Prop | Type | Default | Description |
+| ---------------------- | --------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------- |
+| `conversation` | `SuperChatConversation` | — | **Required.** The conversation to display. |
+| `currentParticipantId` | `string` | — | The local user's id (drives alignment + compose identity). |
+| `renderPlugins` | `SuperChatRenderPlugin[]` | — | Opt-in rich Markdown plugins. |
+| `renderTextContent` | `AIRenderTextContent` | Markdown core | Replace the entire text renderer (advanced). |
+| `trustedContent` | `boolean` | `false` | Skip sanitization — **only** for host-authored content. |
+| `readOnly` | `boolean` | `false` | Disable the composer. |
+| `order` | `'asc' \| 'desc'` | `'asc'` | Thread ordering: `asc` (oldest→newest, messenger style) or `desc` (newest→oldest, feed style). |
+| `virtualized` | `boolean` | `false` | Windowed thread rendering — only mount rows near the viewport. Enable for long histories. |
+| `linkBuilder` | `SuperChatLinkBuilder` | — | Build hrefs for `ref` thread items. |
+| `className` | `string` | — | Extra classes on the root. |
+| `onMessageSent` | `(text, { conversation, mentions }) => void` | — | Fired on send; `mentions` are the addressed participant ids. |
+| `onMessageEdited` | `(messageId, text, { conversation }) => void` | — | Enables the inline "Edit" affordance on the local user's own messages; fired when an edit is saved. |
+| `onConversationClosed` | `(conversation) => void` | — | Shows a close button when provided. |
+| `onReferenceClick` | `(ref) => void` | — | Fired when a reference chip is activated. |
+
+### `SuperChatConversations` (list)
+
+The conversation switcher. Supports controlled or uncontrolled selection.
+
+| Prop | Type | Default | Description |
+| ----------------------------- | ------------------------- | ------------------ | --------------------------------------------- |
+| `conversations` | `SuperChatConversation[]` | — | **Required.** All conversations (host-owned). |
+| `activeConversationId` | `string` | — | Controlled active conversation id. |
+| `defaultActiveConversationId` | `string` | first conversation | Uncontrolled initial active id. |
+| `className` | `string` | — | Extra classes on the root. |
+| `onConversationOpened` | `(conversation) => void` | — | Fired when a conversation is selected. |
+| `onNewConversation` | `() => void` | — | Shows a "+" button when provided. |
+
+---
+
+## Editing messages
+
+`SuperChat`/`SuperChatInbox` are controlled — the host owns `thread` state — so
+editing is opt-in: provide `onMessageEdited` and the component renders an inline
+**Edit** pencil on the local user's own plain-text messages (matched by
+`currentParticipantId`). Saving calls back with the message id and new text;
+apply it to your state and stamp `editedAt` to surface the "(edited)" indicator.
+
+```tsx
+
+ setConversation((prev) => ({
+ ...prev,
+ thread: prev.thread.map((m) =>
+ m.id === messageId
+ ? { ...m, text, editedAt: new Date().toISOString() }
+ : m
+ ),
+ }))
+ }
+/>
+```
+
+Notes:
+
+- Only self-authored, non-streaming **text** messages are inline-editable;
+ messages with rich `content` blocks, references, and system notices are not.
+- Editing is disabled when `readOnly` is set or `onMessageEdited` is omitted.
+- In the editor, **Enter** saves and **Escape** cancels (Shift+Enter adds a
+ newline).
+
+---
+
+## Copying messages
+
+Every content message exposes a **Copy** affordance that floats in the margin —
+to the **left** of incoming messages and to the **right** of the local user's own
+(matched by `currentParticipantId`) — and appears on hover/focus. It opens a small
+menu with three choices:
+
+| Option | Writes |
+| ---------------------- | --------------------------------------------------------------------------------------------------------------------------- |
+| **Copy** | _Both_ rich text (`text/html`) and Markdown (`text/plain`) in one clipboard write — the paste target decides which it takes |
+| **Copy as Markdown** | The raw Markdown source as plain text |
+| **Copy as plain text** | The rendered text with all formatting stripped |
+
+The primary **Copy** is the "smart" default: paste into a rich editor (Word, Google
+Docs, email) and you get formatting; paste into a code editor or terminal and you
+get Markdown. No host wiring is required — the copy control is always available.
+
+> Copying uses the async Clipboard API (`navigator.clipboard.write`), which requires
+> a secure context (HTTPS or `localhost`). On older browsers it falls back to a
+> plain-text `writeText`.
+
+---
+
+## Attaching files
+
+Paste a file into the composer (⌘V / Ctrl+V from a screenshot or copied file) and
+it appears as a removable chip above the input. You can also click the **paperclip**
+button next to the send action to pick files from disk (multiple selection
+supported). Images show a thumbnail; other files show a type icon with the file
+name. A draft can be sent with attachments only (no text required).
+
+### Supported types
+
+Out of the box the composer accepts **images, video, audio, and PDFs**. Restrict
+the allowed categories with `acceptedFileTypes` (on `SuperChat` or `SuperChatInbox`):
+
+```tsx
+// images only
+
+
+// images + PDFs
+
+```
+
+| `AttachmentKind` | ` ` | Matches |
+| ---------------- | ----------------- | ----------------- |
+| `image` | `image/*` | `image/*` |
+| `video` | `video/*` | `video/*` |
+| `audio` | `audio/*` | `audio/*` |
+| `pdf` | `application/pdf` | `application/pdf` |
+
+The chosen types drive both the paperclip picker's `accept` filter and which
+pasted files are accepted; anything else is ignored.
+
+Because SuperChat is **controlled**, the component never mutates the thread itself —
+attached files are surfaced to the host through `onMessageSent` so it can embed,
+upload, or persist them however it likes:
+
+```tsx
+onMessageSent={(text, { conversation, mentions, attachments }) => {
+ // attachments: { id, name, type, dataUrl }[]
+ const images = attachments
+ .filter((a) => a.type.startsWith('image/'))
+ .map((a) => ``)
+ .join('\n\n');
+ appendMessage(conversation.id, {
+ id: crypto.randomUUID(),
+ participantId: 'me',
+ text: [text, images].filter(Boolean).join('\n\n'),
+ time: new Date().toISOString(),
+ });
+}}
+```
+
+Each `ComposerAttachment` carries a base64 `dataUrl`. To render inline `data:` (or
+`blob:`) image sources, include `createImagePlugin()` — it extends the
+`rehype-sanitize` allow-list to permit those protocols on ` ` (safe, since
+scripts inside an SVG loaded via `src` do not execute). For production you'll
+typically upload the file and swap in the hosted URL instead of the `data:` URL.
+
+### Rendering video / audio / PDF inline
+
+Add `createAttachmentPlugin()` to render non-image attachments (video, audio, PDF,
+or a generic download chip) **inline** in the thread. It reads a fenced
+` ```superchat-attachment ` block whose body is a small JSON descriptor — build it
+with the `attachmentMarkdown()` helper:
+
+```tsx
+import {
+ createAttachmentPlugin,
+ attachmentMarkdown,
+ attachmentCache,
+} from '@mieweb/ui/components/SuperChat/plugins';
+
+// renderPlugins={[createAttachmentPlugin(), /* … */]}
+
+onMessageSent={(text, { conversation, attachments }) => {
+ const blocks = attachments
+ .filter((a) => !a.type.startsWith('image/'))
+ .map((a) => {
+ // Cache the bytes once so the media renders offline next session.
+ void attachmentCache.put({
+ id: a.id,
+ name: a.name,
+ type: a.type,
+ dataUrl: a.dataUrl,
+ });
+ // Embed only the id (+ an inline src fallback) — keeps the thread small.
+ return attachmentMarkdown({ id: a.id, type: a.type, name: a.name, src: a.dataUrl });
+ });
+ appendMessage(conversation.id, {
+ id: crypto.randomUUID(),
+ participantId: 'me',
+ text: [text, ...blocks].filter(Boolean).join('\n\n'),
+ time: new Date().toISOString(),
+ });
+}}
+```
+
+The descriptor accepts `{ id?, type, name, src? }`. At render time the plugin
+resolves a `blob:` URL from the cache by `id`; if the cache is empty it falls back
+to the inline `src` (and opportunistically persists it for next time). Provide at
+least an `id` **or** a `src`.
+
+### Offline cache (IndexedDB)
+
+`attachmentCache` is a tiny IndexedDB-backed blob store keyed by attachment **id**.
+Persist bytes once (e.g. on send) and the attachment plugin serves them from the
+cache on later renders — so media keeps working offline without storing base64 in
+your conversation records.
+
+```tsx
+import { attachmentCache } from '@mieweb/ui/components/SuperChat/plugins';
+
+await attachmentCache.put({ id, name, type, dataUrl }); // or { ..., blob }
+const url = await attachmentCache.getObjectURL(id); // blob: URL (revoke when done)
+const entry = await attachmentCache.get(id); // { blob, type, name, size, … }
+await attachmentCache.delete(id);
+await attachmentCache.clear();
+```
+
+For React, `useAttachmentUrl(id, fallbackSrc?)` returns `{ url, status }` and revokes
+the object URL automatically on unmount. Everything degrades gracefully: without
+IndexedDB (SSR, private mode) the methods become no-ops and callers fall back to any
+inline `src`.
+
+The cache is bounded — once the total exceeds the budget (default **100 MB**) the
+least-recently-used entries are evicted on the next `put`. Tune or disable it:
+
+```tsx
+attachmentCache.configure({ maxBytes: 250 * 1024 * 1024 }); // 250 MB
+attachmentCache.configure({ maxBytes: Infinity }); // never evict
+const bytes = await attachmentCache.usage(); // current total
+```
+
+---
+
+## Rich Markdown plugins
+
+The base ships Markdown core (GFM) with **sanitization on by default** — untrusted
+agent output is run through `rehype-sanitize`. Opt into rich rendering by passing
+`renderPlugins` (on `SuperChatInbox` or `SuperChat`):
+
+```tsx
+import { SuperChatInbox } from '@mieweb/ui/components/SuperChat';
+import {
+ createCodePlugin,
+ createMathPlugin,
+ createNitroTablePlugin,
+} from '@mieweb/ui/components/SuperChat/plugins';
+import 'katex/dist/katex.min.css'; // required by the math plugin
+
+ ;
+```
+
+| Plugin | Renders | Optional peer dep |
+| ------------------------ | -------------------------------------------- | ----------------------------------- |
+| `createCodePlugin` | Syntax-highlighted code blocks (with copy) | `rehype-highlight` / `highlight.js` |
+| `createMathPlugin` | KaTeX math (`$…$`, `$$…$$`) | `rehype-katex` / `katex` |
+| `createGenUIPlugin` | Interactive widgets from ` ```genui ` blocks | — (you register widgets) |
+| `createMermaidPlugin` | Mermaid diagrams | `mermaid` |
+| `createImagePlugin` | Images with click-to-zoom lightbox | — |
+| `createNitroTablePlugin` | Tables rendered via NITRO DataVis | `datavis` |
+
+See [MAINTAINERS.md](MAINTAINERS.md#render-plugin-contract-read-before-adding-a-plugin)
+for the plugin contract and sanitization rules.
+
+---
+
+## Accessibility
+
+SuperChat ships with assistive-tech support built in:
+
+- **Landmarks & names** — the conversations list is a labelled `complementary`
+ region (`Conversations`), the panel is a `section` labelled by the conversation
+ title, and the participant face-pile is a labelled `group`.
+- **Live message log** — the thread is `role="log"` with `aria-live="polite"`, so
+ screen readers announce new messages. It is keyboard-focusable for scrolling.
+- **Per-message context** — each message is an `article` named `"{author}, {time}"`.
+- **Mention combobox** — the composer is an `aria-autocomplete="list"` combobox
+ wired to a `listbox` via `aria-controls` / `aria-activedescendant`. Keyboard:
+ `↑`/`↓` move, `Enter`/`Tab` accept, `Esc` dismisses; `Enter` (no `Shift`) sends.
+- **Active conversation** — marked with `aria-current` in the list.
+
+When supplying `trustedContent` or custom plugins, you remain responsible for the
+safety of any HTML those plugins allow through the sanitizer.
+
+---
+
+## Performance & long conversations
+
+By default the thread renders **every** message in `thread`. Each row is wrapped
+in `React.memo`, so appending a message only renders the new row — existing rows
+are not re-rendered and their Markdown is not re-parsed, as long as you keep
+message objects referentially stable (don't recreate them on every render).
+
+For long histories (hundreds to thousands of messages), set **`virtualized`** to
+window the thread: only the rows near the viewport are mounted (with dynamic
+height measurement), so first render, memory, and Markdown parse cost are bounded
+by what's on screen rather than by the total history length. Scroll anchoring
+(bottom for `order="asc"`, top for `order="desc"`) is handled automatically.
+
+```tsx
+
+```
+
+Virtualization composes with the patterns below — use either or both:
+
+- Keep the most recent ~50–100 messages in `thread`; load older messages on
+ scroll-up (windowed history).
+- Use `order="desc"` (newest-first, feed style) so the freshest content is at
+ the top and older messages trail off-screen.
+
+These keep first render and memory bounded regardless of total history size.
+
+---
+
+## Related chat surfaces
+
+`@mieweb/ui` has several chat-adjacent modules. Pick by use case:
+
+| Module | Import | Best for | Participants | Markdown |
+| ------------------ | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------- | -------------------------------------- |
+| **SuperChat** | `@mieweb/ui/components/SuperChat` | Multi-agent + multi-human conversations with rich, pluggable rendering | Many humans **and** many agents | Pluggable pipeline (code/math/genui/…) |
+| **AI** | [`@mieweb/ui` AI module](../AI/MAINTAINERS.md) | 1 user ↔ 1 assistant chat with MCP tool-call visualization (`AIChat`, `AIChatModal`, `FloatingAIChat`) | `user` / `assistant` | Via the `renderTextContent` seam |
+| **Messaging** | [`@mieweb/ui` Messaging module](../Messaging/index.ts) | Human-to-human messaging UI primitives (`MessageList`, `MessageBubble`, `MessageComposer`, `ConversationHeader`) | Humans | Plain text / attachments |
+| **chat-component** | [`mieweb/chat-component`](https://github.com/mieweb/chat-component) | Standalone, self-contained UMD chat widget for non-React / Blaze hosts | `external` / `internal` / `system` | Limited |
+
+**How they relate:**
+
+- SuperChat **composes the AI module** — it reuses the AI module's
+ `renderTextContent` render seam and `MCPToolCallDisplay`, and generalizes the
+ AI module's `user`/`assistant` roles (and chat-component's
+ `external`/`internal`/`system` roles) into one **participant** model.
+- SuperChat preserves the **`chat-component` API shape** (conversation/thread,
+ sidebar, compose, read-only, `linkBuilder`, callbacks) so migrating from the
+ standalone widget is mostly a data-model remap (`senderId` → `participantId`).
+- The **Messaging** module is lower-level: use it when you need bespoke
+ human-to-human messaging primitives rather than a complete agent-aware surface.
+
+If you need multiple agents, interleaved replies, or rich Markdown plugins, use
+**SuperChat**. For a single assistant, the **AI** module is lighter.
diff --git a/src/components/SuperChat/SuperChat.mdx b/src/components/SuperChat/SuperChat.mdx
new file mode 100644
index 00000000..40374b8c
--- /dev/null
+++ b/src/components/SuperChat/SuperChat.mdx
@@ -0,0 +1,40 @@
+import { Meta, Markdown } from '@storybook/addon-docs/blocks';
+import rawReadme from './README.md?raw';
+
+
+
+{/*
+ Single source of truth: this page renders the consumer README verbatim so the
+ docs never drift from the guide shipped in the package. Edit README.md, not
+ this file.
+
+ Storybook has no file server for the repo, so the README's *relative* links to
+ other Markdown files (MAINTAINERS.md, ../AI/MAINTAINERS.md)
+ would 404 here. Rewrite those to absolute GitHub URLs, resolved against the
+ README's own location, so they stay clickable in Storybook while remaining
+ correct relative links on GitHub / npm.
+*/}
+
+export const README_DIR = 'src/components/SuperChat';
+export const GITHUB_BLOB_BASE = 'https://github.com/mieweb/ui/blob/main';
+
+export function resolveRepoPath(dir, relative) {
+ const segments = dir.split('/').filter(Boolean);
+ for (const part of relative.split('/')) {
+ if (part === '' || part === '.') continue;
+ if (part === '..') segments.pop();
+ else segments.push(part);
+ }
+ return segments.join('/');
+}
+
+export const readme = rawReadme.replace(
+ /\]\((?!https?:|#)([^)]+)\)/g,
+ (_match, target) => {
+ const [path, hash = ''] = target.split('#');
+ const resolved = resolveRepoPath(README_DIR, path);
+ return `](${GITHUB_BLOB_BASE}/${resolved}${hash ? `#${hash}` : ''})`;
+ }
+);
+
+{readme}
diff --git a/src/components/SuperChat/SuperChat.stories.tsx b/src/components/SuperChat/SuperChat.stories.tsx
new file mode 100644
index 00000000..da1a73ed
--- /dev/null
+++ b/src/components/SuperChat/SuperChat.stories.tsx
@@ -0,0 +1,438 @@
+import * as React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { SuperChat } from './index';
+import {
+ createCodePlugin,
+ createMathPlugin,
+ createGenUIPlugin,
+ createMermaidPlugin,
+ createImagePlugin,
+ createNitroTablePlugin,
+ createAttachmentPlugin,
+ attachmentMarkdown,
+ attachmentCache,
+} from './plugins';
+import type { SuperChatConversation } from './index';
+import { conversation, richConversation, registry } from './storyData';
+import { markdownShowcaseConversation } from './storyData';
+import 'katex/dist/katex.min.css';
+
+// ============================================================================
+// Meta
+// ============================================================================
+
+const meta: Meta = {
+ title: 'Product/Feature Modules/SuperChat/SuperChat (Panel)',
+ component: SuperChat,
+ tags: ['autodocs'],
+ argTypes: {
+ readOnly: {
+ control: 'boolean',
+ description: 'Disable the composer.',
+ table: { category: 'Behavior' },
+ },
+ trustedContent: {
+ control: 'boolean',
+ description: 'Skip sanitization — only for host-authored content.',
+ table: { category: 'Behavior' },
+ },
+ order: {
+ control: 'inline-radio',
+ options: ['asc', 'desc'],
+ description:
+ "Thread ordering: 'asc' (oldest→newest, messenger style) or 'desc' (newest→oldest, feed style).",
+ table: { category: 'Behavior' },
+ },
+ virtualized: {
+ control: 'boolean',
+ description:
+ 'Windowed rendering — only mount rows near the viewport. Recommended for long threads.',
+ table: { category: 'Behavior' },
+ },
+ currentParticipantId: {
+ control: 'select',
+ options: ['u1', 'u2', 'a1', 'a2'],
+ description: 'The local user id (drives alignment + compose identity).',
+ table: { category: 'Identity' },
+ },
+ // Complex/object + callback props are wired in code, not via controls.
+ conversation: { control: false, table: { category: 'Data' } },
+ renderPlugins: { control: false, table: { category: 'Rendering' } },
+ renderTextContent: { control: false, table: { category: 'Rendering' } },
+ linkBuilder: { control: false, table: { category: 'Rendering' } },
+ className: { control: false },
+ onMessageSent: { control: false, table: { category: 'Callbacks' } },
+ onMessageEdited: { control: false, table: { category: 'Callbacks' } },
+ onConversationClosed: { control: false, table: { category: 'Callbacks' } },
+ onReferenceClick: { control: false, table: { category: 'Callbacks' } },
+ },
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component: [
+ '`SuperChat` is the **single-conversation panel**: header (title + participants +',
+ 'optional close), a `role="log"` message thread, and the compose box. It renders',
+ 'exactly one `conversation` — the host owns its state.',
+ '',
+ 'See **SuperChat › Overview** for the full consumer guide (install, props, plugins,',
+ 'accessibility). For the list use `SuperChatConversations`; for the combined inbox',
+ 'use `SuperChatInbox`.',
+ ].join('\n'),
+ },
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// ============================================================================
+// Stateful demo wrapper
+// ============================================================================
+// SuperChat is controlled (the host owns conversation state). This wrapper
+// shows the expected host wiring: append the sent message to the conversation's
+// thread, and simulate a reply from any @-mentioned agent.
+
+function InteractivePanel(
+ props: Omit, 'conversation'> & {
+ initial: SuperChatConversation;
+ }
+) {
+ const { initial, ...rest } = props;
+ const [convo, setConvo] = React.useState(initial);
+
+ const appendMessage = (message: SuperChatConversation['thread'][number]) => {
+ setConvo((prev) => ({
+ ...prev,
+ thread: [...prev.thread, message],
+ lastActivity: message.time,
+ }));
+ };
+
+ return (
+ {
+ setConvo((prev) => ({
+ ...prev,
+ thread: prev.thread.map((m) =>
+ m.id === messageId
+ ? { ...m, text, editedAt: new Date().toISOString() }
+ : m
+ ),
+ }));
+ }}
+ onMessageSent={(text, meta) => {
+ const images = meta.attachments
+ .filter((att) => att.type.startsWith('image/'))
+ .map((att) => ``)
+ .join('\n\n');
+ // Non-image files: cache the bytes for offline use, then embed an
+ // attachment block that renders an inline player from the cached id.
+ const files = meta.attachments
+ .filter((att) => !att.type.startsWith('image/'))
+ .map((att) => {
+ void attachmentCache.put({
+ id: att.id,
+ name: att.name,
+ type: att.type,
+ dataUrl: att.dataUrl,
+ });
+ return attachmentMarkdown({
+ id: att.id,
+ type: att.type,
+ name: att.name,
+ src: att.dataUrl,
+ });
+ })
+ .join('\n\n');
+ const body = [text, images, files].filter(Boolean).join('\n\n');
+ appendMessage({
+ id: `m-${Date.now()}`,
+ participantId: props.currentParticipantId ?? 'u1',
+ text: body,
+ time: new Date().toISOString(),
+ });
+ meta.conversation.participants
+ .filter((p) => p.kind === 'agent' && meta.mentions.includes(p.id))
+ .forEach((agent, i) => {
+ window.setTimeout(
+ () =>
+ appendMessage({
+ id: `a-${Date.now()}-${agent.id}`,
+ participantId: agent.id,
+ text: `On it — responding to **${text.slice(0, 40)}**.`,
+ time: new Date().toISOString(),
+ }),
+ 500 * (i + 1)
+ );
+ });
+ }}
+ />
+ );
+}
+
+// ============================================================================
+// Long-thread fixture (synthetic)
+// ============================================================================
+// Builds a conversation with `count` messages to exercise rendering on very
+// long threads (memoized rows, scroll anchoring, asc/desc ordering). The
+// participants rotate and each message gets a monotonically increasing time so
+// the thread has a stable order.
+
+function makeLongConversation(
+ count: number,
+ id = 'long'
+): SuperChatConversation {
+ const speakers = [
+ {
+ id: 'u1',
+ kind: 'human' as const,
+ name: 'Dr. Alice Reyes',
+ color: '#0e7490',
+ },
+ { id: 'u2', kind: 'human' as const, name: 'Sam Carter', color: '#9333ea' },
+ {
+ id: 'a1',
+ kind: 'agent' as const,
+ name: 'Triage Agent',
+ color: '#2563eb',
+ },
+ {
+ id: 'a2',
+ kind: 'agent' as const,
+ name: 'Coding Agent',
+ color: '#16a34a',
+ },
+ ];
+ const samples = [
+ 'Reviewing the latest vitals now.',
+ 'BP is **128/82**, HR 76 — within range.',
+ 'Can you pull the most recent `CBC` panel?',
+ 'Potassium trended down to 4.6 after the second draw.',
+ 'Here is the summary:\n\n- Stable overnight\n- No new orders\n- Follow-up in AM',
+ 'Flagging for coding review — see `99213` vs `99214`.',
+ 'Agreed, the documentation supports the higher level.',
+ 'Patient reports improved symptoms since the last visit.',
+ 'Scheduling a follow-up for next Tuesday.',
+ 'Note added to the chart.',
+ ];
+ const start = new Date('2026-06-01T08:00:00Z').getTime();
+ const thread = Array.from({ length: count }, (_, i) => {
+ const speaker = speakers[i % speakers.length];
+ return {
+ id: `lm-${i}`,
+ participantId: speaker.id,
+ text: `${samples[i % samples.length]} _(message ${i + 1} of ${count})_`,
+ time: new Date(start + i * 60_000).toISOString(),
+ };
+ });
+ return {
+ id,
+ title: `Long thread — ${count} messages`,
+ reference_id: 'patient/4821',
+ unread: 0,
+ participants: speakers,
+ thread,
+ };
+}
+
+const longConversation = makeLongConversation(300, 'long');
+
+// ============================================================================
+// Stories
+// ============================================================================
+
+export const Playground: Story = {
+ args: {
+ currentParticipantId: 'u1',
+ readOnly: false,
+ trustedContent: false,
+ },
+ render: (args) => (
+
+ console.log('ref', ref)}
+ linkBuilder={(ref) => `#/${ref.refType}/${ref.refId}`}
+ />
+
+ ),
+};
+
+// Reverse (newest-first, social-feed style) ordering. Same conversation and
+// plugins as the Playground, but `order="desc"` flips the thread and anchors
+// scroll to the top so the freshest message leads.
+export const Reverse: Story = {
+ args: {
+ currentParticipantId: 'u1',
+ readOnly: false,
+ trustedContent: false,
+ order: 'desc',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: [
+ 'Newest-first ordering via `order="desc"` — a social-feed layout where',
+ 'the most recent message leads and older messages trail below. The thread',
+ 'anchors scroll to the **top** (rather than the bottom) when new messages',
+ 'arrive. Useful for activity feeds or when the latest update matters most.',
+ ].join('\n'),
+ },
+ },
+ },
+ render: (args) => (
+
+ console.log('ref', ref)}
+ linkBuilder={(ref) => `#/${ref.refType}/${ref.refId}`}
+ />
+
+ ),
+};
+
+// A 300-message thread (oldest→newest, bottom-anchored). Exercises rendering
+// and scroll behavior on long histories; each row is memoized so only changed
+// rows re-render.
+export const Long: Story = {
+ args: {
+ currentParticipantId: 'u1',
+ readOnly: false,
+ trustedContent: false,
+ order: 'asc',
+ virtualized: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: [
+ 'A **300-message** conversation in the default `order="asc"` (oldest→newest)',
+ 'layout, anchored to the bottom. Rendered with `virtualized` so only the',
+ 'rows near the viewport are mounted — scroll to see rows window in and out.',
+ 'For very large histories, hosts can additionally cap/paginate `thread`',
+ '(see the README **Performance & long conversations** section).',
+ ].join('\n'),
+ },
+ },
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+// The same 300-message thread, newest-first (social-feed style, top-anchored).
+export const LongReverse: Story = {
+ args: {
+ currentParticipantId: 'u1',
+ readOnly: false,
+ trustedContent: false,
+ order: 'desc',
+ virtualized: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: [
+ 'The same **300-message** thread as **Long**, but `order="desc"` —',
+ 'newest-first, top-anchored social-feed layout, also `virtualized`.',
+ ].join('\n'),
+ },
+ },
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+// The only plugin-less example. Math (`$$ … $$`, `$x$`) and the ```genui``` block
+// in the sample thread intentionally render as raw text here — see the note.
+export const CoreNoPlugins: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story: [
+ 'This panel renders with **no plugins** — Markdown core (GFM) only.',
+ '',
+ 'Because the `math` and `genui` plugins are not enabled, the sample',
+ "thread's `$$ … $$` / `$x > 0.7$` math and the ```genui``` block",
+ '**intentionally appear as raw text** rather than rendered output. This is',
+ 'the expected baseline — enable the matching plugins (see the',
+ '**Playground** story) to render math, code, GenUI, mermaid, images, and',
+ 'tables.',
+ ].join('\n'),
+ },
+ },
+ },
+ render: () => (
+
+ console.log('ref', ref)}
+ linkBuilder={(ref) => `#/${ref.refType}/${ref.refId}`}
+ />
+
+ ),
+};
+
+// A single post exercising every core/GFM markdown element, so the renderer's
+// styling (headings, lists, tables, quotes, code, hr, …) can be inspected in
+// one place. The code plugin is enabled so the fenced block is highlighted.
+export const MarkdownShowcase: Story = {
+ args: {
+ currentParticipantId: 'u1',
+ readOnly: false,
+ trustedContent: false,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: [
+ 'A single message containing **one of each** core/GFM markdown element —',
+ 'headings, emphasis, lists, task lists, blockquotes, inline + fenced code,',
+ 'a table, links, and a horizontal rule. Use it to verify the renderer',
+ 'styles every element correctly without the `@tailwindcss/typography`',
+ 'plugin.',
+ ].join('\n'),
+ },
+ },
+ },
+ render: (args) => (
+
+ `#/${ref.refType}/${ref.refId}`}
+ />
+
+ ),
+};
diff --git a/src/components/SuperChat/SuperChat.test.tsx b/src/components/SuperChat/SuperChat.test.tsx
new file mode 100644
index 00000000..5de8ba3f
--- /dev/null
+++ b/src/components/SuperChat/SuperChat.test.tsx
@@ -0,0 +1,1012 @@
+import * as React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import { createMarkdownRenderer } from './render/createMarkdownRenderer';
+import { createCodePlugin } from './plugins/code';
+import { createGenUIPlugin } from './plugins/genui';
+import { createMermaidPlugin } from './plugins/mermaid';
+import { createImagePlugin } from './plugins/image';
+import {
+ createAttachmentPlugin,
+ attachmentMarkdown,
+} from './plugins/attachment';
+import { attachmentCache } from './render/attachmentCache';
+import { createNitroTablePlugin } from './plugins/nitroTable';
+import { SuperChat } from './SuperChat';
+import { SuperChatConversations } from './SuperChatConversations';
+import { SuperChatInbox } from './SuperChatInbox';
+import type {
+ GenUIRegistry,
+ GenUIWidgetProps,
+ SuperChatConversation,
+} from './index';
+
+function renderText(node: React.ReactNode) {
+ return render({node}
);
+}
+
+describe('createMarkdownRenderer', () => {
+ it('renders GFM markdown (bold + lists)', () => {
+ const r = createMarkdownRenderer();
+ renderText(
+ r('**bold** and\n\n- one\n- two', {
+ messageId: 'm1',
+ streaming: false,
+ role: 'assistant',
+ })
+ );
+ expect(screen.getByText('bold').tagName).toBe('STRONG');
+ expect(screen.getByText('one')).toBeInTheDocument();
+ expect(screen.getByText('two')).toBeInTheDocument();
+ });
+
+ it('preserves single newlines as hard line breaks', () => {
+ const r = createMarkdownRenderer();
+ const { container } = renderText(
+ r('another\none\nthere', {
+ messageId: 'm1',
+ streaming: false,
+ role: 'user',
+ })
+ );
+ // Three lines separated by two elements (newlines preserved).
+ expect(container.querySelectorAll('br')).toHaveLength(2);
+ expect(container.querySelector('p')?.textContent).toBe(
+ 'another\none\nthere'
+ );
+ });
+
+ it('sanitizes untrusted HTML / script by default', () => {
+ const r = createMarkdownRenderer();
+ const { container } = renderText(
+ r('hi ', {
+ messageId: 'm2',
+ streaming: false,
+ role: 'assistant',
+ })
+ );
+ expect(container.querySelector('script')).toBeNull();
+ // react-markdown does not render raw HTML, so no sink survives at all.
+ expect(container.querySelector('img')).toBeNull();
+ });
+
+ it('renders inline data: image URLs when the image plugin is enabled', () => {
+ const r = createMarkdownRenderer({ plugins: [createImagePlugin()] });
+ const dataUrl =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
+ const { container } = renderText(
+ r(``, {
+ messageId: 'mimg',
+ streaming: false,
+ role: 'assistant',
+ })
+ );
+ const img = container.querySelector('img');
+ expect(img).not.toBeNull();
+ expect(img?.getAttribute('src')).toBe(dataUrl);
+ });
+
+ it('keeps syntax-highlight token classes through sanitization (code plugin)', () => {
+ const r = createMarkdownRenderer({ plugins: [createCodePlugin()] });
+ const { container } = renderText(
+ r('```js\nconst x = 1;\n```', {
+ messageId: 'm3',
+ streaming: false,
+ role: 'assistant',
+ })
+ );
+ expect(
+ container.querySelector('code.hljs, code[class*="language-"]')
+ ).not.toBeNull();
+ });
+
+ it('styles each core/GFM markdown element', () => {
+ const r = createMarkdownRenderer();
+ const md = [
+ '# H1',
+ '## H2',
+ '### H3',
+ '',
+ 'Para with **bold**, _italic_, ~~strike~~, `inline`, and [link](https://example.com).',
+ '',
+ '> A quote',
+ '',
+ '- bullet one',
+ '- bullet two',
+ '',
+ '1. step one',
+ '2. step two',
+ '',
+ '| Code | Desc |',
+ '| --- | --- |',
+ '| 93000 | ECG |',
+ '',
+ '---',
+ ].join('\n');
+ const { container } = renderText(
+ r(md, { messageId: 'm-md', streaming: false, role: 'assistant' })
+ );
+
+ // Headings render with a distinct size/weight class (preflight would
+ // otherwise flatten them to body text).
+ const h1 = container.querySelector('h1');
+ const h2 = container.querySelector('h2');
+ const h3 = container.querySelector('h3');
+ expect(h1?.textContent).toBe('H1');
+ expect(h1?.className).toContain('font-semibold');
+ expect(h2?.className).toContain('text-lg');
+ expect(h3?.className).toContain('text-base');
+
+ // Inline emphasis.
+ expect(screen.getByText('bold').tagName).toBe('STRONG');
+ expect(screen.getByText('italic').tagName).toBe('EM');
+ expect(screen.getByText('strike').tagName).toBe('DEL');
+ expect(screen.getByText('inline').tagName).toBe('CODE');
+
+ // Link opens safely in a new tab.
+ const link = screen.getByRole('link', { name: 'link' });
+ expect(link).toHaveAttribute('href', 'https://example.com');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
+
+ // Blockquote.
+ expect(container.querySelector('blockquote')?.textContent).toContain(
+ 'A quote'
+ );
+
+ // Unordered list shows disc markers; ordered list shows decimals.
+ const ul = container.querySelector('ul');
+ const ol = container.querySelector('ol');
+ expect(ul?.className).toContain('list-disc');
+ expect(ul?.querySelectorAll('li')).toHaveLength(2);
+ expect(ol?.className).toContain('list-decimal');
+ expect(ol?.querySelectorAll('li')).toHaveLength(2);
+
+ // GFM table.
+ expect(container.querySelector('table')).not.toBeNull();
+ expect(screen.getByText('Code').tagName).toBe('TH');
+ expect(screen.getByText('93000').tagName).toBe('TD');
+
+ // Horizontal rule.
+ expect(container.querySelector('hr')).not.toBeNull();
+ });
+});
+
+describe('GenUI plugin', () => {
+ const KpiCard = ({
+ data,
+ }: GenUIWidgetProps<{ label: string; value: string }>) => (
+
+ {data.label}: {data.value}
+
+ );
+ const registry: GenUIRegistry = {
+ kpi_card: {
+ component: () =>
+ Promise.resolve({
+ default: KpiCard as React.ComponentType,
+ }),
+ prefetch: 'eager',
+ },
+ };
+
+ it('renders a registered widget from a fenced genui block', async () => {
+ const r = createMarkdownRenderer({
+ plugins: [createGenUIPlugin(registry)],
+ });
+ renderText(
+ r(
+ '```genui\n{ "widget": "kpi_card", "props": { "label": "Risk", "value": "High" } }\n```',
+ {
+ messageId: 'm4',
+ streaming: false,
+ role: 'assistant',
+ }
+ )
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId('kpi')).toHaveTextContent('Risk: High')
+ );
+ });
+
+ it('falls back to an inert block for unknown widgets', () => {
+ const r = createMarkdownRenderer({
+ plugins: [createGenUIPlugin(registry)],
+ });
+ const { container } = renderText(
+ r('```genui\n{ "widget": "nope", "props": {} }\n```', {
+ messageId: 'm5',
+ streaming: false,
+ role: 'assistant',
+ })
+ );
+ expect(
+ container.querySelector('[data-slot="superchat-genui-fallback"]')
+ ).not.toBeNull();
+ });
+
+ it('falls back to an inert block for malformed JSON once streaming is complete', () => {
+ const r = createMarkdownRenderer({
+ plugins: [createGenUIPlugin(registry)],
+ });
+ const { container } = renderText(
+ r('```genui\n{ "widget": "kpi_card", "props": { "label": "Risk" \n```', {
+ messageId: 'm6',
+ streaming: false,
+ role: 'assistant',
+ })
+ );
+ expect(
+ container.querySelector('[data-slot="superchat-genui-fallback"]')
+ ).not.toBeNull();
+ });
+});
+
+describe('mermaid plugin', () => {
+ it('shows a pending card while the message is still streaming', () => {
+ const r = createMarkdownRenderer({ plugins: [createMermaidPlugin()] });
+ const { container } = renderText(
+ r('```mermaid\ngraph TD; A-->B;\n```', {
+ messageId: 'mm1',
+ streaming: true,
+ role: 'assistant',
+ })
+ );
+ expect(
+ container.querySelector('[data-slot="superchat-mermaid-pending"]')
+ ).not.toBeNull();
+ });
+
+ it('falls back for an empty mermaid block once streaming is complete', () => {
+ const r = createMarkdownRenderer({ plugins: [createMermaidPlugin()] });
+ const { container } = renderText(
+ r('```mermaid\n\n```', {
+ messageId: 'mm2',
+ streaming: false,
+ role: 'assistant',
+ })
+ );
+ expect(
+ container.querySelector('[data-slot="superchat-mermaid-fallback"]')
+ ).not.toBeNull();
+ });
+});
+
+describe('image plugin', () => {
+ it('makes images click-to-zoom and opens a lightbox', async () => {
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ const r = createMarkdownRenderer({ plugins: [createImagePlugin()] });
+ renderText(
+ r('', {
+ messageId: 'img1',
+ streaming: false,
+ role: 'assistant',
+ })
+ );
+ const trigger = screen.getByRole('button', {
+ name: 'View image: ECG strip',
+ });
+ await user.click(trigger);
+ expect(
+ screen.getByRole('dialog', { name: /ECG strip/ })
+ ).toBeInTheDocument();
+ });
+});
+
+describe('attachment plugin', () => {
+ it('renders a video player from an attachment block', async () => {
+ const r = createMarkdownRenderer({ plugins: [createAttachmentPlugin()] });
+ const { container } = renderText(
+ r(
+ attachmentMarkdown({
+ id: 'att-v',
+ type: 'video/mp4',
+ name: 'clip.mp4',
+ src: 'data:video/mp4;base64,AAAA',
+ }),
+ { messageId: 'att1', streaming: false, role: 'user' }
+ )
+ );
+ await waitFor(() =>
+ expect(container.querySelector('video')).toBeInTheDocument()
+ );
+ expect(container.querySelector('video')).toHaveAttribute(
+ 'src',
+ 'data:video/mp4;base64,AAAA'
+ );
+ });
+
+ it('renders an audio player from an attachment block', async () => {
+ const r = createMarkdownRenderer({ plugins: [createAttachmentPlugin()] });
+ const { container } = renderText(
+ r(
+ attachmentMarkdown({
+ id: 'att-a',
+ type: 'audio/mpeg',
+ name: 'song.mp3',
+ src: 'data:audio/mpeg;base64,AAAA',
+ }),
+ { messageId: 'att2', streaming: false, role: 'user' }
+ )
+ );
+ await waitFor(() =>
+ expect(container.querySelector('audio')).toBeInTheDocument()
+ );
+ expect(screen.getByText('song.mp3')).toBeInTheDocument();
+ });
+
+ it('renders a pdf in an iframe with a download link', async () => {
+ const r = createMarkdownRenderer({ plugins: [createAttachmentPlugin()] });
+ const { container } = renderText(
+ r(
+ attachmentMarkdown({
+ id: 'att-p',
+ type: 'application/pdf',
+ name: 'report.pdf',
+ src: 'data:application/pdf;base64,AAAA',
+ }),
+ { messageId: 'att3', streaming: false, role: 'user' }
+ )
+ );
+ await waitFor(() =>
+ expect(container.querySelector('iframe')).toBeInTheDocument()
+ );
+ const link = screen.getByRole('link', { name: /Open/ });
+ expect(link).toHaveAttribute('download', 'report.pdf');
+ });
+
+ it('renders a generic file as a download chip', async () => {
+ const r = createMarkdownRenderer({ plugins: [createAttachmentPlugin()] });
+ renderText(
+ r(
+ attachmentMarkdown({
+ id: 'att-f',
+ type: 'application/zip',
+ name: 'bundle.zip',
+ src: 'data:application/zip;base64,AAAA',
+ }),
+ { messageId: 'att4', streaming: false, role: 'user' }
+ )
+ );
+ const link = await screen.findByRole('link', { name: /bundle\.zip/ });
+ expect(link).toHaveAttribute('download', 'bundle.zip');
+ });
+
+ it('caches gracefully when IndexedDB is unavailable', async () => {
+ expect(attachmentCache.isAvailable()).toBe(false);
+ expect(
+ await attachmentCache.put({
+ id: 'x',
+ name: 'n.txt',
+ type: 'text/plain',
+ dataUrl: 'data:text/plain;base64,QUJD',
+ })
+ ).toBe(false);
+ expect(await attachmentCache.get('x')).toBeUndefined();
+ expect(await attachmentCache.getObjectURL('x')).toBeUndefined();
+ expect(await attachmentCache.usage()).toBe(0);
+ // configure is a safe no-op even without a backing store.
+ expect(() => attachmentCache.configure({ maxBytes: 1024 })).not.toThrow();
+ });
+});
+
+describe('nitro-table plugin', () => {
+ it('renders table data (degrading to an HTML table when datavis is absent)', async () => {
+ const r = createMarkdownRenderer({ plugins: [createNitroTablePlugin()] });
+ renderText(
+ r('| Code | Description |\n| --- | --- |\n| 93000 | ECG, complete |', {
+ messageId: 'tbl1',
+ streaming: false,
+ role: 'assistant',
+ })
+ );
+ await waitFor(() => expect(screen.getByText('93000')).toBeInTheDocument());
+ expect(screen.getByText('Description')).toBeInTheDocument();
+ });
+});
+
+describe('SuperChat', () => {
+ const conversation: SuperChatConversation = {
+ id: 'c1',
+ title: 'Intake',
+ participants: [
+ { id: 'u1', kind: 'human', name: 'Alice Reyes' },
+ { id: 'a1', kind: 'agent', name: 'Triage Agent', color: '#2563eb' },
+ ],
+ thread: [
+ {
+ id: 'm1',
+ participantId: 'u1',
+ text: 'hello @Triage',
+ time: '2026-06-07T09:00:00Z',
+ mentions: ['a1'],
+ },
+ {
+ id: 'm2',
+ participantId: 'a1',
+ text: '**hi** Alice',
+ time: '2026-06-07T09:00:30Z',
+ },
+ ],
+ };
+
+ it('renders participants and markdown messages', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByText('Intake')).toBeInTheDocument();
+ expect(screen.getAllByText('Triage Agent').length).toBeGreaterThan(0);
+ expect(screen.getByText('hi').tagName).toBe('STRONG');
+ });
+
+ it('fires onMessageSent with detected mentions', async () => {
+ const onMessageSent = vi.fn();
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ render(
+
+ );
+ const input = screen.getByLabelText('Message');
+ await user.type(input, 'ping @Triage');
+ await user.click(screen.getByLabelText('Send message'));
+ expect(onMessageSent).toHaveBeenCalledWith(
+ 'ping @Triage',
+ expect.objectContaining({ mentions: ['a1'] })
+ );
+ });
+
+ it('attaches a pasted image and sends it with the message', async () => {
+ const onMessageSent = vi.fn();
+ const { fireEvent } = await import('@testing-library/react');
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ render(
+
+ );
+ const input = screen.getByLabelText('Message');
+ const file = new File(['fake-bytes'], 'shot.png', { type: 'image/png' });
+ fireEvent.paste(input, {
+ clipboardData: {
+ items: [
+ {
+ kind: 'file',
+ type: 'image/png',
+ getAsFile: () => file,
+ },
+ ],
+ },
+ });
+ // The pasted image shows up as a removable preview thumbnail.
+ const remove = await screen.findByLabelText('Remove shot.png');
+ expect(remove).toBeInTheDocument();
+ await user.click(screen.getByLabelText('Send message'));
+ expect(onMessageSent).toHaveBeenCalledWith(
+ '',
+ expect.objectContaining({
+ attachments: [
+ expect.objectContaining({
+ name: 'shot.png',
+ type: 'image/png',
+ dataUrl: expect.stringMatching(/^data:image\/png/),
+ }),
+ ],
+ })
+ );
+ });
+
+ it('attaches a file chosen via the paperclip and sends it', async () => {
+ const onMessageSent = vi.fn();
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ const { container } = render(
+
+ );
+ expect(screen.getByLabelText('Attach files')).toBeInTheDocument();
+ const fileInput = container.querySelector(
+ 'input[type="file"]'
+ ) as HTMLInputElement;
+ const file = new File(['fake-bytes'], 'picked.png', { type: 'image/png' });
+ await user.upload(fileInput, file);
+ const remove = await screen.findByLabelText('Remove picked.png');
+ expect(remove).toBeInTheDocument();
+ await user.click(screen.getByLabelText('Send message'));
+ expect(onMessageSent).toHaveBeenCalledWith(
+ '',
+ expect.objectContaining({
+ attachments: [
+ expect.objectContaining({
+ name: 'picked.png',
+ type: 'image/png',
+ dataUrl: expect.stringMatching(/^data:image\/png/),
+ }),
+ ],
+ })
+ );
+ });
+
+ it('attaches a non-image file (pdf) with an icon preview', async () => {
+ const onMessageSent = vi.fn();
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ const { container } = render(
+
+ );
+ const fileInput = container.querySelector(
+ 'input[type="file"]'
+ ) as HTMLInputElement;
+ const file = new File(['%PDF-1.4'], 'report.pdf', {
+ type: 'application/pdf',
+ });
+ await user.upload(fileInput, file);
+ // Non-image attachments show a labelled chip (no broken preview).
+ const remove = await screen.findByLabelText('Remove report.pdf');
+ expect(remove).toBeInTheDocument();
+ expect(screen.queryByRole('img', { name: 'report.pdf' })).toBeNull();
+ await user.click(screen.getByLabelText('Send message'));
+ expect(onMessageSent).toHaveBeenCalledWith(
+ '',
+ expect.objectContaining({
+ attachments: [
+ expect.objectContaining({
+ name: 'report.pdf',
+ type: 'application/pdf',
+ }),
+ ],
+ })
+ );
+ });
+
+ it('restricts the file picker to acceptedFileTypes', async () => {
+ const onMessageSent = vi.fn();
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ const { container } = render(
+
+ );
+ const fileInput = container.querySelector(
+ 'input[type="file"]'
+ ) as HTMLInputElement;
+ expect(fileInput.accept).toBe('image/*');
+ // A disallowed type (audio) is ignored — no preview chip appears.
+ const file = new File(['id3'], 'song.mp3', { type: 'audio/mpeg' });
+ await user.upload(fileInput, file);
+ expect(screen.queryByLabelText('Remove song.mp3')).toBeNull();
+ });
+
+ it('does not detect mentions for system participants', async () => {
+ const onMessageSent = vi.fn();
+ const withSystem: SuperChatConversation = {
+ ...conversation,
+ participants: [
+ ...conversation.participants,
+ { id: 's1', kind: 'system', name: 'System' },
+ ],
+ };
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ render(
+
+ );
+ const input = screen.getByLabelText('Message');
+ await user.type(input, 'ping @System');
+ await user.click(screen.getByLabelText('Send message'));
+ expect(onMessageSent).toHaveBeenCalledWith(
+ 'ping @System',
+ expect.objectContaining({ mentions: [] })
+ );
+ });
+
+ it('disables the composer when readOnly', () => {
+ render(
+
+ );
+ expect(screen.getByLabelText('Message')).toBeDisabled();
+ });
+
+ it('opens an @-mention menu and inserts the chosen participant', async () => {
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ render( );
+ const input = screen.getByLabelText('Message') as HTMLTextAreaElement;
+ await user.type(input, 'hello @Tri');
+ const menu = screen.getByRole('listbox', { name: 'Mention a participant' });
+ expect(menu).toBeInTheDocument();
+ await user.click(screen.getByRole('option', { name: /Triage Agent/ }));
+ expect(input.value).toBe('hello @Triage ');
+ expect(screen.queryByRole('listbox')).toBeNull();
+ });
+
+ it('orders the thread newest-first when order="desc"', () => {
+ const { getAllByRole } = render(
+
+
+
+ );
+ const articles = getAllByRole('article');
+ // Newest (m2 "hi Alice") should appear before oldest (m1 "hello @Triage").
+ expect(articles[0]).toHaveTextContent('hi');
+ expect(articles[1]).toHaveTextContent('hello');
+ });
+
+ it('mounts the virtualized thread without error when virtualized', () => {
+ const { getByRole } = render(
+
+
+
+ );
+ // The scroll container keeps its log role; rows window in based on layout
+ // (jsdom reports zero size, so the assertion stays on the container).
+ const log = getByRole('log', { name: 'Messages' });
+ expect(log).toBeInTheDocument();
+ expect(log).toHaveAttribute('data-slot', 'superchat-thread');
+ });
+
+ it('does not show an edit affordance without onMessageEdited', () => {
+ render(
+
+
+
+ );
+ expect(
+ screen.queryByRole('button', { name: 'Edit message' })
+ ).not.toBeInTheDocument();
+ });
+
+ it('only offers editing on the local user’s own messages', () => {
+ render(
+
+ {}}
+ />
+
+ );
+ // u1 authored m1; a1 authored m2 — only one edit button should exist.
+ const editButtons = screen.getAllByRole('button', { name: 'Edit message' });
+ expect(editButtons).toHaveLength(1);
+ });
+
+ it('edits a message and fires onMessageEdited with the new text', async () => {
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ const onMessageEdited = vi.fn();
+ render(
+
+
+
+ );
+ await user.click(screen.getByRole('button', { name: 'Edit message' }));
+ const editor = screen.getByRole('textbox', { name: 'Edit message' });
+ await user.clear(editor);
+ await user.type(editor, 'edited body');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(onMessageEdited).toHaveBeenCalledTimes(1);
+ expect(onMessageEdited).toHaveBeenCalledWith('m1', 'edited body', {
+ conversation,
+ });
+ });
+
+ it('pastes an image into the edit window as Markdown', async () => {
+ const { fireEvent } = await import('@testing-library/react');
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ const onMessageEdited = vi.fn();
+ render(
+
+
+
+ );
+ await user.click(screen.getByRole('button', { name: 'Edit message' }));
+ const editor = screen.getByRole('textbox', { name: 'Edit message' });
+ const file = new File(['fake-bytes'], 'edited.png', { type: 'image/png' });
+ fireEvent.paste(editor, {
+ clipboardData: {
+ items: [{ kind: 'file', type: 'image/png', getAsFile: () => file }],
+ },
+ });
+ await waitFor(() =>
+ expect((editor as HTMLTextAreaElement).value).toMatch(
+ /!\[edited\.png\]\(data:image\/png/
+ )
+ );
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ expect(onMessageEdited).toHaveBeenCalledTimes(1);
+ const [, savedText] = onMessageEdited.mock.calls[0];
+ expect(savedText).toMatch(/!\[edited\.png\]\(data:image\/png/);
+ });
+
+ it('cancels an edit without firing onMessageEdited', async () => {
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ const onMessageEdited = vi.fn();
+ render(
+
+
+
+ );
+ await user.click(screen.getByRole('button', { name: 'Edit message' }));
+ const editor = screen.getByRole('textbox', { name: 'Edit message' });
+ await user.type(editor, ' extra');
+ await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ expect(onMessageEdited).not.toHaveBeenCalled();
+ expect(
+ screen.queryByRole('textbox', { name: 'Edit message' })
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows an "(edited)" indicator when a message has editedAt', () => {
+ const edited: SuperChatConversation = {
+ ...conversation,
+ thread: [
+ { ...conversation.thread[0], editedAt: '2026-06-07T09:05:00Z' },
+ conversation.thread[1],
+ ],
+ };
+ render(
+
+
+
+ );
+ expect(screen.getByText('(edited)')).toBeInTheDocument();
+ });
+
+ it('shows a copy affordance on every content message', () => {
+ render(
+
+
+
+ );
+ // m1 (u1) and m2 (a1) are content messages; the system/ref rows have none.
+ const copyButtons = screen.getAllByRole('button', {
+ name: 'Copy message',
+ });
+ expect(copyButtons).toHaveLength(2);
+ });
+
+ it('copies the message source as Markdown via the copy menu', async () => {
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ const writeText = vi.fn(async () => {});
+ const write = vi.fn(async () => {});
+ vi.stubGlobal('navigator', {
+ ...globalThis.navigator,
+ clipboard: { writeText, write },
+ });
+
+ render(
+
+
+
+ );
+
+ // Open the menu on the first message (m1, authored by u1) and pick Markdown.
+ const [firstCopy] = screen.getAllByRole('button', {
+ name: 'Copy message',
+ });
+ await user.click(firstCopy);
+ await user.click(
+ screen.getByRole('menuitem', { name: 'Copy as Markdown' })
+ );
+
+ expect(writeText).toHaveBeenCalledWith(conversation.thread[0].text);
+ vi.unstubAllGlobals();
+ });
+
+ it('renders AI content blocks of type code', () => {
+ const withCode: SuperChatConversation = {
+ ...conversation,
+ thread: [
+ ...conversation.thread,
+ {
+ id: 'm3',
+ participantId: 'a1',
+ time: '2026-06-07T09:01:00Z',
+ content: [{ type: 'code', text: 'const x = 1;', language: 'ts' }],
+ },
+ ],
+ };
+ const { container } = render(
+
+
+
+ );
+ expect(container.querySelector('code')).toHaveTextContent('const x = 1;');
+ });
+});
+
+describe('SuperChatConversations', () => {
+ const conversations: SuperChatConversation[] = [
+ {
+ id: 'c1',
+ title: 'Intake',
+ unread: 2,
+ participants: [{ id: 'u1', kind: 'human', name: 'Alice Reyes' }],
+ thread: [
+ {
+ id: 'm1',
+ participantId: 'u1',
+ text: 'hello',
+ time: '2026-06-07T09:00:00Z',
+ },
+ ],
+ },
+ {
+ id: 'c2',
+ title: 'Follow-up',
+ participants: [{ id: 'u1', kind: 'human', name: 'Alice Reyes' }],
+ thread: [
+ {
+ id: 'm2',
+ participantId: 'u1',
+ text: 'later',
+ time: '2026-06-07T10:00:00Z',
+ },
+ ],
+ },
+ ];
+
+ it('renders the conversation list with an unread badge', () => {
+ render( );
+ expect(screen.getByText('Intake')).toBeInTheDocument();
+ expect(screen.getByText('Follow-up')).toBeInTheDocument();
+ expect(screen.getByText('unread messages', { exact: false }));
+ expect(screen.getByText('2')).toBeInTheDocument();
+ });
+
+ it('fires onConversationOpened when a conversation is selected', async () => {
+ const onConversationOpened = vi.fn();
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ render(
+
+ );
+ await user.click(screen.getByText('Follow-up'));
+ expect(onConversationOpened).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'c2' })
+ );
+ });
+
+ it('fires onNewConversation from the new-conversation button', async () => {
+ const onNewConversation = vi.fn();
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ render(
+
+ );
+ await user.click(screen.getByLabelText('New conversation'));
+ expect(onNewConversation).toHaveBeenCalled();
+ });
+});
+
+describe('SuperChatInbox', () => {
+ const conversations: SuperChatConversation[] = [
+ {
+ id: 'c1',
+ title: 'Intake',
+ participants: [
+ { id: 'u1', kind: 'human', name: 'Alice Reyes' },
+ { id: 'a1', kind: 'agent', name: 'Triage Agent', color: '#2563eb' },
+ ],
+ thread: [
+ {
+ id: 'm1',
+ participantId: 'a1',
+ text: 'first conversation',
+ time: '2026-06-07T09:00:00Z',
+ },
+ ],
+ },
+ {
+ id: 'c2',
+ title: 'Follow-up',
+ participants: [{ id: 'u1', kind: 'human', name: 'Alice Reyes' }],
+ thread: [
+ {
+ id: 'm2',
+ participantId: 'u1',
+ text: 'second conversation',
+ time: '2026-06-07T10:00:00Z',
+ },
+ ],
+ },
+ ];
+
+ it('renders both the conversation list and the active panel', () => {
+ const { container } = render(
+
+
+
+ );
+ expect(
+ container.querySelector('[data-slot="superchat-conversations"]')
+ ).not.toBeNull();
+ const panel = container.querySelector('[data-slot="superchat"]');
+ expect(panel).not.toBeNull();
+ const thread = within(panel as HTMLElement);
+ expect(thread.getByText('first conversation')).toBeInTheDocument();
+ });
+
+ it('switches the active panel when another conversation is opened', async () => {
+ const { default: userEvent } = await import('@testing-library/user-event');
+ const user = userEvent.setup();
+ const { container } = render(
+
+
+
+ );
+ const panel = () =>
+ within(container.querySelector('[data-slot="superchat"]') as HTMLElement);
+ expect(panel().getByText('first conversation')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: /Follow-up/ }));
+ expect(panel().getByText('second conversation')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/SuperChat/SuperChat.tsx b/src/components/SuperChat/SuperChat.tsx
new file mode 100644
index 00000000..aaeb153f
--- /dev/null
+++ b/src/components/SuperChat/SuperChat.tsx
@@ -0,0 +1,304 @@
+/**
+ * SuperChat — a single-conversation chat panel for `@mieweb/ui`.
+ *
+ * Renders one {@link SuperChatConversation}: header (title + participants +
+ * optional close), a `role="log"` message thread, and the compose box. Message
+ * text renders through the pluggable Markdown pipeline
+ * ({@link createMarkdownRenderer}); rich plugins (code/math/genui/…) are opt-in.
+ *
+ * For a conversation list use {@link SuperChatConversations}; for the combined
+ * inbox (list + panel) use {@link SuperChatInbox}.
+ */
+
+import * as React from 'react';
+import { cn } from '../../utils/cn';
+import { CloseIcon } from '../AI/icons';
+import { createMarkdownRenderer } from './render/createMarkdownRenderer';
+import { ParticipantAvatar, Composer, MessageRow, byTime } from './parts';
+import { VirtualThread } from './VirtualThread';
+import type {
+ AIRenderTextContent,
+ AttachmentKind,
+ ComposerAttachment,
+ Participant,
+ SuperChatConversation,
+ SuperChatLinkBuilder,
+ SuperChatRef,
+ SuperChatRenderPlugin,
+} from './types';
+
+// ============================================================================
+// SuperChat (single-conversation panel)
+// ============================================================================
+
+export interface SuperChatProps {
+ /** The conversation to display (host-owned state). */
+ conversation: SuperChatConversation;
+ /** The participant id representing the local user (alignment + compose). */
+ currentParticipantId?: string;
+ /** Opt-in rich render plugins (code/math/genui/…). */
+ renderPlugins?: SuperChatRenderPlugin[];
+ /** Override the entire text renderer (advanced). */
+ renderTextContent?: AIRenderTextContent;
+ /** Treat content as trusted and skip sanitization (host-authored only). */
+ trustedContent?: boolean;
+ /** Disable the composer. */
+ readOnly?: boolean;
+ /**
+ * File categories the composer accepts for paste and the paperclip picker.
+ * Defaults to `['image', 'video', 'audio', 'pdf']`.
+ */
+ acceptedFileTypes?: AttachmentKind[];
+ /**
+ * Thread ordering.
+ * - `'asc'` (default): oldest → newest, anchored to the bottom like a
+ * classic messenger (auto-scrolls to the newest message).
+ * - `'desc'`: newest → oldest, anchored to the top like a social feed
+ * (auto-scrolls to the top when a new message arrives).
+ */
+ order?: 'asc' | 'desc';
+ /**
+ * Virtualize the message thread (windowed rendering). Only the rows near the
+ * viewport are mounted, so very long threads (hundreds to thousands of
+ * messages) stay responsive. Off by default — enable for long histories.
+ */
+ virtualized?: boolean;
+ /** Build hrefs for `ref` thread items. */
+ linkBuilder?: SuperChatLinkBuilder;
+ /** Additional class name. */
+ className?: string;
+
+ // --- callbacks (chat-component-compatible) ---
+ onMessageSent?: (
+ text: string,
+ meta: {
+ conversation: SuperChatConversation;
+ mentions: string[];
+ attachments: ComposerAttachment[];
+ }
+ ) => void;
+ /**
+ * Fired when the local user saves an edit to one of their own messages.
+ * Providing this enables the inline "Edit" affordance on self-authored
+ * messages (the host owns state, so apply the new `text` to the message and
+ * typically stamp `editedAt`).
+ */
+ onMessageEdited?: (
+ messageId: string,
+ text: string,
+ meta: { conversation: SuperChatConversation }
+ ) => void;
+ onConversationClosed?: (conversation: SuperChatConversation) => void;
+ onReferenceClick?: (ref: SuperChatRef) => void;
+ /**
+ * Show a "back" affordance in the header (used by {@link SuperChatInbox} on
+ * small screens to return from the chat panel to the conversation list).
+ */
+ onBack?: () => void;
+}
+
+/**
+ * Single-conversation chat panel. See the module `MAINTAINERS.md` for the
+ * participant model and render-plugin architecture.
+ */
+export function SuperChat({
+ conversation,
+ currentParticipantId,
+ renderPlugins,
+ renderTextContent,
+ trustedContent,
+ readOnly,
+ acceptedFileTypes,
+ order = 'asc',
+ virtualized = false,
+ linkBuilder,
+ className,
+ onMessageSent,
+ onMessageEdited,
+ onConversationClosed,
+ onReferenceClick,
+ onBack,
+}: SuperChatProps) {
+ const headingId = React.useId();
+
+ const renderText = React.useMemo(
+ () =>
+ renderTextContent ??
+ createMarkdownRenderer({
+ plugins: renderPlugins,
+ trusted: trustedContent,
+ }),
+ [renderTextContent, renderPlugins, trustedContent]
+ );
+
+ const threadRef = React.useRef(null);
+ React.useEffect(() => {
+ if (virtualized) return; // VirtualThread manages its own scroll anchoring.
+ const el = threadRef.current;
+ if (!el) return;
+ // Anchor to the newest message: bottom for ascending order, top for
+ // descending (feed-style) order.
+ el.scrollTop = order === 'desc' ? 0 : el.scrollHeight;
+ }, [conversation.thread.length, conversation.id, order, virtualized]);
+
+ const participantById = React.useMemo(() => {
+ const map = new Map();
+ conversation.participants.forEach((p) => map.set(p.id, p));
+ return map;
+ }, [conversation]);
+
+ const orderedThread = React.useMemo(() => {
+ const sorted = [...conversation.thread].sort(byTime);
+ return order === 'desc' ? sorted.reverse() : sorted;
+ }, [conversation, order]);
+
+ // Stable edit handler so memoized rows don't re-render when the conversation
+ // changes. Latest `onMessageEdited`/`conversation` are read from refs.
+ const onMessageEditedRef = React.useRef(onMessageEdited);
+ onMessageEditedRef.current = onMessageEdited;
+ const conversationRef = React.useRef(conversation);
+ conversationRef.current = conversation;
+ const handleMessageEdited = React.useCallback(
+ (messageId: string, text: string) => {
+ onMessageEditedRef.current?.(messageId, text, {
+ conversation: conversationRef.current,
+ });
+ },
+ []
+ );
+ const editable = !readOnly && !!onMessageEdited;
+
+ return (
+
+
+
+ {onBack && (
+
+
+
+
+
+ )}
+
+
+ {conversation.title}
+
+
+ {conversation.participants.slice(0, 6).map((p) => (
+
+
+
+ ))}
+
+
+
+ {onConversationClosed && (
+ onConversationClosed(conversation)}
+ aria-label="Close conversation"
+ className="rounded-md p-1 text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800"
+ >
+
+
+ )}
+
+
+ {virtualized ? (
+
+ ) : (
+
+ {orderedThread.map((m) => (
+
+ ))}
+
+ )}
+
+
+ onMessageSent?.(text, { conversation, mentions, attachments })
+ }
+ />
+
+ );
+}
diff --git a/src/components/SuperChat/SuperChatConversations.stories.tsx b/src/components/SuperChat/SuperChatConversations.stories.tsx
new file mode 100644
index 00000000..4d856318
--- /dev/null
+++ b/src/components/SuperChat/SuperChatConversations.stories.tsx
@@ -0,0 +1,101 @@
+import * as React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { SuperChatConversations } from './index';
+import { conversations } from './storyData';
+
+// ============================================================================
+// Meta
+// ============================================================================
+
+const meta: Meta = {
+ title: 'Product/Feature Modules/SuperChat/Conversations (List)',
+ component: SuperChatConversations,
+ tags: ['autodocs'],
+ argTypes: {
+ defaultActiveConversationId: {
+ control: 'select',
+ options: ['c1', 'c2'],
+ description: 'Uncontrolled initial active conversation id.',
+ table: { category: 'Selection' },
+ },
+ // Complex/object + callback props are wired in code, not via controls.
+ conversations: { control: false, table: { category: 'Data' } },
+ activeConversationId: { control: false, table: { category: 'Selection' } },
+ className: { control: false },
+ onConversationOpened: { control: false, table: { category: 'Callbacks' } },
+ onNewConversation: { control: false, table: { category: 'Callbacks' } },
+ },
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component: [
+ '`SuperChatConversations` is the **conversation list** (inbox sidebar). It renders the',
+ 'host-owned conversations sorted by last activity, with unread badges and an optional',
+ '"new conversation" action.',
+ '',
+ 'Selection works either controlled (`activeConversationId`) or uncontrolled',
+ '(`defaultActiveConversationId`). Pair it with `SuperChat` for the message panel, or use',
+ '`SuperChatInbox` for the combined surface.',
+ '',
+ 'Use the **Playground** story with the Controls panel to change the initial active conversation.',
+ ].join('\n'),
+ },
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// ============================================================================
+// Stories
+// ============================================================================
+
+export const Playground: Story = {
+ args: {
+ defaultActiveConversationId: 'c1',
+ },
+ render: (args) => (
+
+ console.log('opened', c.id)}
+ onNewConversation={() => console.log('new conversation')}
+ />
+
+ ),
+};
+
+export const Default: Story = {
+ render: () => (
+
+ console.log('opened', c.id)}
+ onNewConversation={() => console.log('new conversation')}
+ />
+
+ ),
+};
+
+// Demonstrates controlled selection driven by the host.
+function ControlledList() {
+ const [activeId, setActiveId] = React.useState('c1');
+ return (
+
+ setActiveId(c.id)}
+ onNewConversation={() => console.log('new conversation')}
+ />
+
+ );
+}
+
+export const Controlled: Story = {
+ render: () => ,
+};
diff --git a/src/components/SuperChat/SuperChatConversations.tsx b/src/components/SuperChat/SuperChatConversations.tsx
new file mode 100644
index 00000000..1385e338
--- /dev/null
+++ b/src/components/SuperChat/SuperChatConversations.tsx
@@ -0,0 +1,129 @@
+/**
+ * SuperChatConversations — the conversation list (inbox sidebar) for
+ * `@mieweb/ui`.
+ *
+ * Renders the host-owned conversations sorted by last activity, with unread
+ * badges and an optional "new conversation" action. Supports both controlled
+ * (`activeConversationId`) and uncontrolled (`defaultActiveConversationId`)
+ * selection. Pair with {@link SuperChat} for the message panel, or use
+ * {@link SuperChatInbox} for the combined surface.
+ */
+
+import * as React from 'react';
+import { cn } from '../../utils/cn';
+import { sidebarItem, lastActivityOf, lastMessageByTime } from './parts';
+import type { SuperChatConversation } from './types';
+
+// ============================================================================
+// SuperChatConversations (conversation list)
+// ============================================================================
+
+export interface SuperChatConversationsProps {
+ /** All conversations (host-owned state). */
+ conversations: SuperChatConversation[];
+ /** Controlled active conversation id. */
+ activeConversationId?: string;
+ /** Uncontrolled initial active conversation id. */
+ defaultActiveConversationId?: string;
+ /** Additional class name. */
+ className?: string;
+
+ // --- callbacks ---
+ onConversationOpened?: (conversation: SuperChatConversation) => void;
+ onNewConversation?: () => void;
+}
+
+/**
+ * Conversation list. See the module `MAINTAINERS.md` for the data model.
+ */
+export function SuperChatConversations({
+ conversations,
+ activeConversationId,
+ defaultActiveConversationId,
+ className,
+ onConversationOpened,
+ onNewConversation,
+}: SuperChatConversationsProps) {
+ const [internalActive, setInternalActive] = React.useState(
+ defaultActiveConversationId ?? conversations[0]?.id
+ );
+ const requestedId = activeConversationId ?? internalActive;
+ // Fall back to the first conversation when the requested id no longer exists
+ // (e.g. the active conversation was removed) so an item stays highlighted.
+ const activeId = conversations.some((c) => c.id === requestedId)
+ ? requestedId
+ : conversations[0]?.id;
+
+ const sortedConversations = React.useMemo(
+ () =>
+ [...conversations].sort((a, b) => lastActivityOf(b) - lastActivityOf(a)),
+ [conversations]
+ );
+
+ const selectConversation = (c: SuperChatConversation) => {
+ if (activeConversationId === undefined) setInternalActive(c.id);
+ onConversationOpened?.(c);
+ };
+
+ return (
+
+
+
+ Conversations
+
+ {onNewConversation && (
+
+ +
+
+ )}
+
+
+ {sortedConversations.map((c) => {
+ const last = lastMessageByTime(c.thread);
+ const isActive = c.id === activeId;
+ return (
+
+ selectConversation(c)}
+ className={sidebarItem({ active: isActive })}
+ >
+
+ {c.title}
+ {last?.text && (
+
+ {last.text}
+
+ )}
+
+ {!!c.unread && (
+
+ {c.unread}
+ unread messages
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/SuperChat/SuperChatInbox.stories.tsx b/src/components/SuperChat/SuperChatInbox.stories.tsx
new file mode 100644
index 00000000..01d4ff53
--- /dev/null
+++ b/src/components/SuperChat/SuperChatInbox.stories.tsx
@@ -0,0 +1,379 @@
+import * as React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { SuperChatInbox, createMarkdownRenderer } from './index';
+import {
+ createCodePlugin,
+ createMathPlugin,
+ createGenUIPlugin,
+ createMermaidPlugin,
+ createImagePlugin,
+ createNitroTablePlugin,
+ createAttachmentPlugin,
+ attachmentMarkdown,
+ attachmentCache,
+} from './plugins';
+import type { SuperChatConversation } from './index';
+import { richConversation, secondConversation, registry } from './storyData';
+import 'katex/dist/katex.min.css';
+
+// ============================================================================
+// Meta
+// ============================================================================
+
+const meta: Meta = {
+ title: 'Product/Feature Modules/SuperChat/Inbox',
+ component: SuperChatInbox,
+ tags: ['autodocs'],
+ argTypes: {
+ readOnly: {
+ control: 'boolean',
+ description: 'Disable the composer.',
+ table: { category: 'Behavior' },
+ },
+ showSidebar: {
+ control: 'boolean',
+ description: 'Show the conversation list.',
+ table: { category: 'Behavior' },
+ },
+ trustedContent: {
+ control: 'boolean',
+ description: 'Skip sanitization — only for host-authored content.',
+ table: { category: 'Behavior' },
+ },
+ currentParticipantId: {
+ control: 'select',
+ options: ['u1', 'u2', 'a1', 'a2'],
+ description: 'The local user id (drives alignment + compose identity).',
+ table: { category: 'Identity' },
+ },
+ defaultActiveConversationId: {
+ control: 'select',
+ options: ['c1', 'c2'],
+ description: 'Uncontrolled initial active conversation id.',
+ table: { category: 'Selection' },
+ },
+ // Complex/object + callback props are wired in code, not via controls.
+ conversations: { control: false, table: { category: 'Data' } },
+ activeConversationId: { control: false, table: { category: 'Selection' } },
+ renderPlugins: { control: false, table: { category: 'Rendering' } },
+ renderTextContent: { control: false, table: { category: 'Rendering' } },
+ linkBuilder: { control: false, table: { category: 'Rendering' } },
+ className: { control: false },
+ onMessageSent: { control: false, table: { category: 'Callbacks' } },
+ onConversationOpened: { control: false, table: { category: 'Callbacks' } },
+ onConversationClosed: { control: false, table: { category: 'Callbacks' } },
+ onNewConversation: { control: false, table: { category: 'Callbacks' } },
+ onReferenceClick: { control: false, table: { category: 'Callbacks' } },
+ },
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component: [
+ '`SuperChatInbox` is the combined surface: the `SuperChatConversations` list plus the',
+ 'active `SuperChat` panel. It is the drop-in equivalent of the original monolithic',
+ 'component — it accepts the full `conversations` array and owns active-conversation',
+ 'selection (controlled via `activeConversationId` or uncontrolled via',
+ '`defaultActiveConversationId`).',
+ '',
+ 'See **SuperChat › Overview** for the full consumer guide (install, props, plugins,',
+ 'accessibility).',
+ ].join('\n'),
+ },
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// ============================================================================
+// Stateful demo wrapper
+// ============================================================================
+// SuperChatInbox is controlled (the host owns conversation state). This wrapper
+// shows the expected host wiring: append the sent message to the active
+// conversation's thread, and simulate a reply from any @-mentioned agent.
+
+function InteractiveInbox(
+ props: Omit, 'conversations'> & {
+ initial: SuperChatConversation[];
+ }
+) {
+ const { initial, ...rest } = props;
+ const [conversations, setConversations] = React.useState(initial);
+
+ const appendMessage = (
+ conversationId: string,
+ message: SuperChatConversation['thread'][number]
+ ) => {
+ setConversations((prev) =>
+ prev.map((c) =>
+ c.id === conversationId
+ ? { ...c, thread: [...c.thread, message], lastActivity: message.time }
+ : c
+ )
+ );
+ };
+
+ return (
+ {
+ const now = new Date().toISOString();
+ const images = meta.attachments
+ .filter((att) => att.type.startsWith('image/'))
+ .map((att) => ``)
+ .join('\n\n');
+ const files = meta.attachments
+ .filter((att) => !att.type.startsWith('image/'))
+ .map((att) => {
+ void attachmentCache.put({
+ id: att.id,
+ name: att.name,
+ type: att.type,
+ dataUrl: att.dataUrl,
+ });
+ return attachmentMarkdown({
+ id: att.id,
+ type: att.type,
+ name: att.name,
+ src: att.dataUrl,
+ });
+ })
+ .join('\n\n');
+ const body = [text, images, files].filter(Boolean).join('\n\n');
+ appendMessage(meta.conversation.id, {
+ id: `m-${Date.now()}`,
+ participantId: props.currentParticipantId ?? 'u1',
+ text: body,
+ time: now,
+ });
+ // Simulate each mentioned agent replying shortly after.
+ meta.conversation.participants
+ .filter((p) => p.kind === 'agent' && meta.mentions.includes(p.id))
+ .forEach((agent, i) => {
+ window.setTimeout(
+ () =>
+ appendMessage(meta.conversation.id, {
+ id: `a-${Date.now()}-${agent.id}`,
+ participantId: agent.id,
+ text: `On it — responding to **${text.slice(0, 40)}**.`,
+ time: new Date().toISOString(),
+ }),
+ 500 * (i + 1)
+ );
+ });
+ }}
+ />
+ );
+}
+
+// ============================================================================
+// Stories
+// ============================================================================
+
+export const Playground: Story = {
+ args: {
+ currentParticipantId: 'u1',
+ showSidebar: true,
+ readOnly: false,
+ trustedContent: false,
+ defaultActiveConversationId: 'c1',
+ },
+ render: (args) => (
+
+ `#/${ref.refType}/${ref.refId}`}
+ />
+
+ ),
+};
+
+// ============================================================================
+// Sources & Guards
+// ============================================================================
+// Documents, per visual, the exact Markdown *source* that produces it and the
+// *guard* (trust boundary) that keeps untrusted model/agent output safe. The
+// rendered column uses the real production renderer (`createMarkdownRenderer`)
+// with every plugin enabled, so source → guard → output stays in sync with the
+// code.
+
+interface FeatureDemo {
+ /** Plugin / feature name. */
+ name: string;
+ /** Raw Markdown exactly as it arrives in a message. */
+ source: string;
+ /** Where the guard lives + what it enforces. */
+ guard: string;
+}
+
+const FEATURES: FeatureDemo[] = [
+ {
+ name: 'Markdown core (GFM)',
+ source: [
+ '**Chief complaint:** chest tightness on exertion',
+ '',
+ '- Duration: 3 days',
+ '- Risk: family history of CAD',
+ '',
+ '> Recommend prioritizing an ECG.',
+ '',
+ '[ECG protocol](https://example.org/ecg)',
+ ].join('\n'),
+ guard:
+ 'rehype-sanitize allow-list (createMarkdownRenderer.tsx) strips scripts/unknown tags from untrusted output; links are forced to target="_blank" rel="noopener noreferrer".',
+ },
+ {
+ name: 'code',
+ source: [
+ '```javascript',
+ "const code = '93000';",
+ 'console.log(code);',
+ '```',
+ ].join('\n'),
+ guard:
+ 'rehype-highlight emits .hljs-* token classes that the base schema explicitly allow-lists on code/pre/span; sanitize runs AFTER highlight so only those classes survive. Copy uses navigator.clipboard.',
+ },
+ {
+ name: 'math (KaTeX)',
+ source: [
+ '$$ risk = \\beta_0 + \\beta_1 x + \\beta_2 x^2 $$',
+ '',
+ 'Inline: $x > 0.7$.',
+ ].join('\n'),
+ guard:
+ "math.tsx allow-lists KaTeX's HTML+MathML tags/attributes so its output survives sanitize; rehype-katex runs with throwOnError:false (malformed math degrades, never throws).",
+ },
+ {
+ name: 'genui',
+ source: [
+ '```genui',
+ '{ "widget": "kpi_card", "version": 1, "props": { "label": "Risk", "value": "High", "trend": "+12%" } }',
+ '```',
+ ].join('\n'),
+ guard:
+ 'Widgets are host-registered, lazy, and schema-validated; the rehype transform allow-lists only the tag. Unknown/invalid widgets degrade to an inert code block; mount + data fetch are gated on streaming.',
+ },
+ {
+ name: 'mermaid',
+ source: [
+ '```mermaid',
+ 'graph TD',
+ ' A[Intake] --> B{Chest pain?}',
+ ' B -- Yes --> C[Order ECG]',
+ ' B -- No --> D[Routine review]',
+ '```',
+ ].join('\n'),
+ guard:
+ "mermaid.tsx loads mermaid lazily and renders with securityLevel:'strict' (labels sanitized, scripts stripped). The SVG bypasses rehype-sanitize, so strict mode IS the trust boundary; rendering is gated on streaming.",
+ },
+ {
+ name: 'image (lightbox)',
+ source:
+ '',
+ guard:
+ 'The image src/alt are already protocol-restricted by rehype-sanitize; image.tsx only adds the zoom affordance and portals the LightboxModal to document.body.',
+ },
+ {
+ name: 'nitro-table',
+ source: [
+ '| Code | Description | Modifier |',
+ '| --- | --- | --- |',
+ '| 93000 | ECG, complete | — |',
+ '| 93005 | ECG, tracing only | TC |',
+ ].join('\n'),
+ guard:
+ 'nitroTable.tsx lazy-loads the DataVis grid only when a table appears; a GridErrorBoundary degrades to the themed HTML table if datavis is unavailable or the grid throws.',
+ },
+];
+
+const sourcesAndGuardsRenderer = createMarkdownRenderer({
+ plugins: [
+ createCodePlugin(),
+ createMathPlugin(),
+ createGenUIPlugin(registry),
+ createMermaidPlugin(),
+ createImagePlugin(),
+ createNitroTablePlugin(),
+ createAttachmentPlugin(),
+ ],
+});
+
+function SourcesAndGuardsDemo() {
+ return (
+
+
+
+ {FEATURES.map((f) => (
+
+
+ {f.name}
+
+
+
+ Source
+
+
+ {f.source}
+
+
+
+ Rendered
+
+
+ {sourcesAndGuardsRenderer(f.source, {
+ messageId: `sg-${f.name}`,
+ streaming: false,
+ role: 'assistant',
+ })}
+
+
+
+ Guard: {f.guard}
+
+
+ ))}
+
+ );
+}
+
+export const SourcesAndGuards: Story = {
+ name: 'Sources & Guards',
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Per-feature documentation of the Markdown **source** that generates each visual and the ' +
+ '**guard** (trust boundary) that sanitizes untrusted model/agent output. Useful for ' +
+ 'security review: it shows exactly where each plugin opens the allow-list and how it ' +
+ 'degrades.',
+ },
+ },
+ },
+ render: () => ,
+};
diff --git a/src/components/SuperChat/SuperChatInbox.tsx b/src/components/SuperChat/SuperChatInbox.tsx
new file mode 100644
index 00000000..0ddc674f
--- /dev/null
+++ b/src/components/SuperChat/SuperChatInbox.tsx
@@ -0,0 +1,190 @@
+/**
+ * SuperChatInbox — the combined inbox surface for `@mieweb/ui`.
+ *
+ * Composes {@link SuperChatConversations} (the list) and {@link SuperChat} (the
+ * single-conversation panel) into one framed surface. Owns active-conversation
+ * coordination and supports both controlled (`activeConversationId`) and
+ * uncontrolled (`defaultActiveConversationId`) selection.
+ *
+ * This is the drop-in equivalent of the original monolithic `SuperChat`: it
+ * accepts the same props (conversations array + callbacks).
+ */
+
+import * as React from 'react';
+import { cn } from '../../utils/cn';
+import { SuperChat } from './SuperChat';
+import { SuperChatConversations } from './SuperChatConversations';
+import type {
+ AIRenderTextContent,
+ AttachmentKind,
+ ComposerAttachment,
+ SuperChatConversation,
+ SuperChatLinkBuilder,
+ SuperChatRef,
+ SuperChatRenderPlugin,
+} from './types';
+
+// ============================================================================
+// SuperChatInbox (list + panel)
+// ============================================================================
+
+export interface SuperChatInboxProps {
+ /** All conversations (host-owned state). */
+ conversations: SuperChatConversation[];
+ /** Controlled active conversation id. */
+ activeConversationId?: string;
+ /** Uncontrolled initial active conversation id. */
+ defaultActiveConversationId?: string;
+ /** The participant id representing the local user (alignment + compose). */
+ currentParticipantId?: string;
+ /** Opt-in rich render plugins (code/math/genui/…). */
+ renderPlugins?: SuperChatRenderPlugin[];
+ /** Override the entire text renderer (advanced). */
+ renderTextContent?: AIRenderTextContent;
+ /** Treat content as trusted and skip sanitization (host-authored only). */
+ trustedContent?: boolean;
+ /** Disable the composer. */
+ readOnly?: boolean;
+ /**
+ * File categories the composer accepts for paste and the paperclip picker.
+ * Defaults to `['image', 'video', 'audio', 'pdf']`.
+ */
+ acceptedFileTypes?: AttachmentKind[];
+ /** Thread ordering: `'asc'` (oldest→newest, default) or `'desc'` (feed-style). */
+ order?: 'asc' | 'desc';
+ /** Virtualize the message thread (windowed rendering) for long histories. */
+ virtualized?: boolean;
+ /** Show the conversation sidebar. */
+ showSidebar?: boolean;
+ /** Build hrefs for `ref` thread items. */
+ linkBuilder?: SuperChatLinkBuilder;
+ /** Additional class name. */
+ className?: string;
+
+ // --- callbacks (chat-component-compatible) ---
+ onMessageSent?: (
+ text: string,
+ meta: {
+ conversation: SuperChatConversation;
+ mentions: string[];
+ attachments: ComposerAttachment[];
+ }
+ ) => void;
+ /** Fired when the local user saves an edit to one of their own messages. */
+ onMessageEdited?: (
+ messageId: string,
+ text: string,
+ meta: { conversation: SuperChatConversation }
+ ) => void;
+ onConversationOpened?: (conversation: SuperChatConversation) => void;
+ onConversationClosed?: (conversation: SuperChatConversation) => void;
+ onNewConversation?: () => void;
+ onReferenceClick?: (ref: SuperChatRef) => void;
+}
+
+/**
+ * Combined inbox surface (conversation list + active conversation panel). See
+ * the module `MAINTAINERS.md` for the participant model and render-plugin
+ * architecture.
+ */
+export function SuperChatInbox({
+ conversations,
+ activeConversationId,
+ defaultActiveConversationId,
+ currentParticipantId,
+ renderPlugins,
+ renderTextContent,
+ trustedContent,
+ readOnly,
+ acceptedFileTypes,
+ order,
+ virtualized,
+ showSidebar = true,
+ linkBuilder,
+ className,
+ onMessageSent,
+ onMessageEdited,
+ onConversationOpened,
+ onConversationClosed,
+ onNewConversation,
+ onReferenceClick,
+}: SuperChatInboxProps) {
+ const [internalActive, setInternalActive] = React.useState(
+ defaultActiveConversationId ?? conversations[0]?.id
+ );
+ const requestedId = activeConversationId ?? internalActive;
+ const active =
+ conversations.find((c) => c.id === requestedId) ?? conversations[0];
+ // Resolve the active id from the conversation actually shown so the sidebar
+ // highlight never disagrees with the panel when `conversations` changes.
+ const activeId = active?.id;
+
+ // On small screens the list and panel can't fit side by side, so we show one
+ // at a time (master-detail). `mobileView` tracks which is visible; on `sm`+
+ // both are always shown and this state is ignored.
+ const [mobileView, setMobileView] = React.useState<'list' | 'chat'>('list');
+
+ const handleOpen = (c: SuperChatConversation) => {
+ if (activeConversationId === undefined) setInternalActive(c.id);
+ setMobileView('chat');
+ onConversationOpened?.(c);
+ };
+
+ return (
+
+ {showSidebar && (
+
+ )}
+
+ {active ? (
+ setMobileView('list') : undefined}
+ className={cn(
+ showSidebar && mobileView === 'list' && 'hidden sm:flex'
+ )}
+ />
+ ) : (
+
+ No conversation selected
+
+ )}
+
+ );
+}
diff --git a/src/components/SuperChat/VirtualThread.tsx b/src/components/SuperChat/VirtualThread.tsx
new file mode 100644
index 00000000..18cdf796
--- /dev/null
+++ b/src/components/SuperChat/VirtualThread.tsx
@@ -0,0 +1,145 @@
+/**
+ * VirtualThread — windowed (virtualized) message list for {@link SuperChat}.
+ *
+ * Renders only the rows near the viewport using `@tanstack/react-virtual` with
+ * dynamic measurement, so a thread of thousands of messages mounts ~20-30 DOM
+ * subtrees instead of all of them. This keeps first render, memory, and the
+ * Markdown parse cost bounded by what is on screen rather than by the total
+ * history length.
+ *
+ * Opt-in via the `virtualized` prop on `SuperChat`/`SuperChatInbox`. The
+ * non-virtualized path remains the default for short threads, where the extra
+ * absolute-positioning machinery is unnecessary.
+ */
+
+import * as React from 'react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { MessageRow } from './parts';
+import type {
+ AIRenderTextContent,
+ Participant,
+ SuperChatLinkBuilder,
+ SuperChatMessage,
+ SuperChatRef,
+} from './types';
+
+export interface VirtualThreadProps {
+ /** The messages to render, already sorted in display order. */
+ items: SuperChatMessage[];
+ /** Lookup from participant id to participant. */
+ participantById: Map;
+ /** The local user id (drives self-alignment). */
+ currentParticipantId?: string;
+ /** Text renderer (Markdown pipeline). */
+ renderText: AIRenderTextContent;
+ /** Build hrefs for `ref` items. */
+ linkBuilder?: SuperChatLinkBuilder;
+ /** Fired when a reference chip is activated. */
+ onReferenceClick?: (ref: SuperChatRef) => void;
+ /** Whether self-authored text messages expose an inline "Edit" affordance. */
+ editable?: boolean;
+ /** Save handler for an inline message edit (bound to the message's id). */
+ onMessageEdited?: (messageId: string, text: string) => void;
+ /**
+ * Thread ordering. `'asc'` anchors new messages to the bottom; `'desc'`
+ * anchors them to the top (feed style).
+ */
+ order: 'asc' | 'desc';
+ /** Conversation id — changing it re-anchors scroll to the newest message. */
+ conversationId: string;
+ /** Props forwarded to the scroll container (role/aria/tabIndex/className). */
+ containerProps: React.HTMLAttributes & {
+ 'data-slot'?: string;
+ };
+}
+
+export function VirtualThread({
+ items,
+ participantById,
+ currentParticipantId,
+ renderText,
+ linkBuilder,
+ onReferenceClick,
+ editable,
+ onMessageEdited,
+ order,
+ conversationId,
+ containerProps,
+}: VirtualThreadProps) {
+ const parentRef = React.useRef(null);
+
+ const virtualizer = useVirtualizer({
+ count: items.length,
+ getScrollElement: () => parentRef.current,
+ // Rough first guess; real heights are measured via measureElement.
+ estimateSize: () => 88,
+ overscan: 10,
+ getItemKey: (index) => items[index]?.id ?? index,
+ });
+
+ // Anchor to the newest message: bottom for ascending order, top for
+ // descending (feed-style) order. Runs on mount, when the conversation
+ // changes, and when a message is appended.
+ const count = items.length;
+ React.useEffect(() => {
+ if (count === 0) return;
+ if (order === 'desc') {
+ virtualizer.scrollToIndex(0, { align: 'start' });
+ } else {
+ virtualizer.scrollToIndex(count - 1, { align: 'end' });
+ }
+ // `virtualizer` is stable across renders for a given instance.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [count, conversationId, order]);
+
+ const virtualItems = virtualizer.getVirtualItems();
+
+ return (
+
+
+ {virtualItems.map((virtualRow) => {
+ const message = items[virtualRow.index];
+ if (!message) return null;
+ return (
+
+ {/* Bottom padding stands in for the non-virtual `space-y-4` gap
+ and is included in the measured height. */}
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/SuperChat/index.ts b/src/components/SuperChat/index.ts
new file mode 100644
index 00000000..329fe649
--- /dev/null
+++ b/src/components/SuperChat/index.ts
@@ -0,0 +1,66 @@
+/**
+ * SuperChat — multi-participant chat for `@mieweb/ui`.
+ *
+ * Three composable surfaces share one import path:
+ * - {@link SuperChat} — a single-conversation message panel.
+ * - {@link SuperChatConversations} — the conversation list (sidebar).
+ * - {@link SuperChatInbox} — the combined list + panel (drop-in for the
+ * original monolithic component).
+ *
+ * The base entry ships the Markdown core ({@link createMarkdownRenderer}). Rich
+ * render plugins (code / math / genui / NITRO / mermaid) are opt-in via the
+ * subpath: `@mieweb/ui/components/SuperChat/plugins`.
+ */
+
+export { SuperChat, type SuperChatProps } from './SuperChat';
+
+export {
+ SuperChatConversations,
+ type SuperChatConversationsProps,
+} from './SuperChatConversations';
+
+export { SuperChatInbox, type SuperChatInboxProps } from './SuperChatInbox';
+
+export {
+ createMarkdownRenderer,
+ type CreateMarkdownRendererOptions,
+} from './render/createMarkdownRenderer';
+
+export {
+ TextRenderContext,
+ useTextRenderContext,
+ type SuperChatTextContext,
+} from './render/renderContext';
+
+export type {
+ // participant model
+ Participant,
+ ParticipantKind,
+ ParticipantStatus,
+ // conversation / thread
+ SuperChatConversation,
+ SuperChatMessage,
+ SuperChatItemType,
+ SuperChatChannel,
+ SuperChatRef,
+ SuperChatLinkBuilder,
+ ComposerAttachment,
+ AttachmentKind,
+ // render pipeline
+ SuperChatRenderPlugin,
+ SuperChatPluggable,
+ SuperChatPluggableList,
+ AIRenderTextContent,
+ AITextRenderContext,
+ // genui
+ GenUIRegistry,
+ GenUIWidgetEntry,
+ GenUIWidgetProps,
+ GenUIPrefetchPolicy,
+ GenUIBlockPayload,
+ StandardSchemaV1,
+ // re-exports
+ AIMessageContent,
+ AIMessageStatus,
+ MCPResourceLink,
+} from './types';
diff --git a/src/components/SuperChat/parts.tsx b/src/components/SuperChat/parts.tsx
new file mode 100644
index 00000000..6c6174fc
--- /dev/null
+++ b/src/components/SuperChat/parts.tsx
@@ -0,0 +1,1279 @@
+/**
+ * SuperChat — shared internal building blocks.
+ *
+ * Pure helpers + presentational pieces used by the three public components
+ * ({@link SuperChat}, {@link SuperChatConversations}, {@link SuperChatInbox}).
+ * Not part of the public API — import the components from `index.ts` instead.
+ */
+
+import * as React from 'react';
+import { cva } from 'class-variance-authority';
+import { cn } from '../../utils/cn';
+import { MCPToolCallDisplay } from '../AI/MCPToolCall';
+import { SendIcon, SparklesIcon } from '../AI/icons';
+import type {
+ AIRenderTextContent,
+ AttachmentKind,
+ ComposerAttachment,
+ Participant,
+ SuperChatConversation,
+ SuperChatLinkBuilder,
+ SuperChatMessage,
+ SuperChatRef,
+} from './types';
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+export function initials(name: string): string {
+ return name
+ .split(' ')
+ .map((n) => n[0])
+ .filter(Boolean)
+ .join('')
+ .slice(0, 2)
+ .toUpperCase();
+}
+
+export function formatTime(time: Date | string): string {
+ return new Date(time).toLocaleTimeString(undefined, {
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+}
+
+export function byTime(a: SuperChatMessage, b: SuperChatMessage): number {
+ return new Date(a.time).getTime() - new Date(b.time).getTime();
+}
+
+export function lastActivityOf(c: SuperChatConversation): number {
+ if (c.lastActivity) return new Date(c.lastActivity).getTime();
+ const last = c.thread[c.thread.length - 1];
+ return last ? new Date(last.time).getTime() : 0;
+}
+
+/** Compute mentioned participant ids from `@Name` tokens in the draft. */
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+export function detectMentions(
+ text: string,
+ participants: Participant[]
+): string[] {
+ const ids: string[] = [];
+ for (const p of participants) {
+ if (p.kind === 'system') continue;
+ const token = '@' + p.name.split(' ')[0];
+ // Match a whole mention token only: the `@` must not be preceded by a word
+ // character or another `@`, and the token must not be followed by a word
+ // character (so `@Triage` does not match `@TriageAgent`).
+ const pattern = new RegExp(
+ `(?= latestTime) {
+ latest = message;
+ latestTime = t;
+ }
+ }
+ return latest;
+}
+
+// ============================================================================
+// Avatar
+// ============================================================================
+
+export function ParticipantAvatar({
+ participant,
+ size = 'md',
+}: {
+ participant?: Participant;
+ size?: 'sm' | 'md';
+}) {
+ const dim = size === 'sm' ? 'h-6 w-6 text-[10px]' : 'h-8 w-8 text-xs';
+ if (participant?.avatar) {
+ return (
+
+ );
+ }
+ const isAgent = participant?.kind === 'agent';
+ return (
+
+ {isAgent ? (
+
+ ) : (
+ initials(participant?.name ?? '?')
+ )}
+
+ );
+}
+
+// ============================================================================
+// Reference chip (chat-component `ref` thread items)
+// ============================================================================
+
+export function ReferenceChip({
+ reference,
+ linkBuilder,
+ onReferenceClick,
+}: {
+ reference: SuperChatRef;
+ linkBuilder?: SuperChatLinkBuilder;
+ onReferenceClick?: (ref: SuperChatRef) => void;
+}) {
+ const href = linkBuilder?.(reference);
+ const content = (
+ <>
+
+ {reference.refType}
+
+ {reference.title}
+ >
+ );
+ const className =
+ 'inline-flex max-w-full items-center gap-2 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-700 hover:border-primary-300 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200';
+ if (href) {
+ return (
+ onReferenceClick?.(reference)}
+ >
+ {content}
+
+ );
+ }
+ return (
+ onReferenceClick?.(reference)}
+ >
+ {content}
+
+ );
+}
+
+// ============================================================================
+// Message row
+// ============================================================================
+
+/** Small pencil glyph for the inline message-edit affordance. */
+function PencilIcon() {
+ return (
+
+
+
+
+ );
+}
+
+/** Paperclip glyph for the composer's attach-file affordance. */
+function PaperclipIcon() {
+ return (
+
+
+
+ );
+}
+
+const ATTACHMENT_ICON_PROPS = {
+ width: 22,
+ height: 22,
+ viewBox: '0 0 24 24',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: 2,
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ 'aria-hidden': true,
+} as const;
+
+/** Document glyph used for PDFs and unrecognized files. */
+function FileIcon() {
+ return (
+
+
+
+
+ );
+}
+
+/** Film/clapper glyph for video attachments. */
+function VideoFileIcon() {
+ return (
+
+
+
+
+ );
+}
+
+/** Musical-note glyph for audio attachments. */
+function AudioFileIcon() {
+ return (
+
+
+
+
+
+ );
+}
+
+/**
+ * Supported attachment categories: their ` ` token and a MIME
+ * matcher used to filter pastes and file-picker selections.
+ */
+const ATTACHMENT_KIND_CONFIG: Record<
+ AttachmentKind,
+ { accept: string; match: (type: string) => boolean }
+> = {
+ image: { accept: 'image/*', match: (t) => t.startsWith('image/') },
+ video: { accept: 'video/*', match: (t) => t.startsWith('video/') },
+ audio: { accept: 'audio/*', match: (t) => t.startsWith('audio/') },
+ pdf: { accept: 'application/pdf', match: (t) => t === 'application/pdf' },
+};
+
+const DEFAULT_ACCEPTED_FILE_TYPES: AttachmentKind[] = [
+ 'image',
+ 'video',
+ 'audio',
+ 'pdf',
+];
+
+/** Resolve a MIME type to its broad category (for preview icons). */
+function attachmentKindOf(type: string): AttachmentKind | 'file' {
+ if (type.startsWith('image/')) return 'image';
+ if (type.startsWith('video/')) return 'video';
+ if (type.startsWith('audio/')) return 'audio';
+ if (type === 'application/pdf') return 'pdf';
+ return 'file';
+}
+
+/** Pick the preview icon for a non-image attachment. */
+function AttachmentTypeIcon({ type }: { type: string }) {
+ switch (attachmentKindOf(type)) {
+ case 'video':
+ return ;
+ case 'audio':
+ return ;
+ default:
+ return ;
+ }
+}
+
+function ClipboardIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function CheckIcon() {
+ return (
+
+
+
+ );
+}
+
+interface CopyMenuProps {
+ /** Aligns the popover to the outer margin (right for self, left otherwise). */
+ isSelf: boolean;
+ /** Markdown source for this message (plain-text / Markdown copy). */
+ markdown: string;
+ /** Read the rendered bubble HTML at copy time (rich-text copy). */
+ getHtml: () => string;
+ /** Read the rendered bubble plain text at copy time. */
+ getText: () => string;
+}
+
+/**
+ * Per-message copy control. The primary **Copy** writes *both* a rich-text
+ * (`text/html`) and a Markdown (`text/plain`) representation in a single
+ * clipboard write, so the paste target decides: rich editors get formatting,
+ * plain editors get Markdown. Explicit "as Markdown" / "as plain text" options
+ * are also offered.
+ */
+function CopyMenu({ isSelf, markdown, getHtml, getText }: CopyMenuProps) {
+ const [open, setOpen] = React.useState(false);
+ const [copied, setCopied] = React.useState(false);
+ const rootRef = React.useRef(null);
+ const copiedTimer = React.useRef | undefined>(
+ undefined
+ );
+
+ // Close on outside click / Escape while the menu is open.
+ React.useEffect(() => {
+ if (!open) return;
+ const onDown = (e: MouseEvent) => {
+ if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
+ };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setOpen(false);
+ };
+ document.addEventListener('mousedown', onDown);
+ document.addEventListener('keydown', onKey);
+ return () => {
+ document.removeEventListener('mousedown', onDown);
+ document.removeEventListener('keydown', onKey);
+ };
+ }, [open]);
+
+ React.useEffect(() => () => window.clearTimeout(copiedTimer.current), []);
+
+ const flash = () => {
+ setCopied(true);
+ window.clearTimeout(copiedTimer.current);
+ copiedTimer.current = setTimeout(() => setCopied(false), 1200);
+ };
+
+ const writeText = async (value: string) => {
+ try {
+ await navigator.clipboard?.writeText(value);
+ } catch {
+ // Clipboard may be unavailable (insecure context / denied permission).
+ }
+ };
+
+ const writeBoth = async () => {
+ const html = getHtml();
+ const text = markdown || getText();
+ try {
+ if (
+ typeof window !== 'undefined' &&
+ 'ClipboardItem' in window &&
+ navigator.clipboard?.write
+ ) {
+ await navigator.clipboard.write([
+ new window.ClipboardItem({
+ 'text/html': new Blob([html], { type: 'text/html' }),
+ 'text/plain': new Blob([text], { type: 'text/plain' }),
+ }),
+ ]);
+ } else {
+ await writeText(text);
+ }
+ } catch {
+ await writeText(text);
+ }
+ };
+
+ const run = (fn: () => Promise) => {
+ setOpen(false);
+ void fn().then(flash);
+ };
+
+ return (
+
+
setOpen((v) => !v)}
+ className="rounded p-1 text-neutral-400 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 hover:text-neutral-600 focus-visible:opacity-100 dark:hover:text-neutral-200"
+ >
+ {copied ? : }
+
+ {open && (
+
+ run(writeBoth)}
+ />
+ run(() => writeText(markdown || getText()))}
+ />
+ run(() => writeText(getText()))}
+ />
+
+ )}
+
+ );
+}
+
+function CopyMenuItem({
+ label,
+ hint,
+ onSelect,
+}: {
+ label: string;
+ hint?: string;
+ onSelect: () => void;
+}) {
+ return (
+
+ {label}
+ {hint && {hint} }
+
+ );
+}
+
+interface MessageRowProps {
+ message: SuperChatMessage;
+ participant?: Participant;
+ isSelf: boolean;
+ renderText: AIRenderTextContent;
+ linkBuilder?: SuperChatLinkBuilder;
+ onReferenceClick?: (ref: SuperChatRef) => void;
+ /** Whether self-authored text messages expose an inline "Edit" affordance. */
+ editable?: boolean;
+ /** Save handler for an inline message edit (bound to this message's id). */
+ onMessageEdited?: (messageId: string, text: string) => void;
+}
+
+/**
+ * A single thread row (message / system notice / reference chip).
+ *
+ * Wrapped in {@link React.memo}: in long threads the parent re-renders on every
+ * new message, but each existing row's props are stable (the host owns the
+ * message objects), so memoization skips re-rendering — and, crucially,
+ * re-running the Markdown pipeline — for the hundreds of rows that did not
+ * change. Keep the props referentially stable from the host for this to help.
+ */
+export const MessageRow = React.memo(function MessageRow({
+ message,
+ participant,
+ isSelf,
+ renderText,
+ linkBuilder,
+ onReferenceClick,
+ editable,
+ onMessageEdited,
+}: MessageRowProps) {
+ const streaming = message.status === 'streaming';
+ const [isEditing, setIsEditing] = React.useState(false);
+ const [draft, setDraft] = React.useState(message.text ?? '');
+ const editRef = React.useRef(null);
+ // The rendered bubble content, read at copy time for the rich-text payload.
+ const bubbleRef = React.useRef(null);
+ // Grow the editor to fit its content (accounts for wrapped lines, not just
+ // explicit newlines), clamped by the textarea's CSS max-height.
+ const autosize = React.useCallback(() => {
+ const el = editRef.current;
+ if (!el) return;
+ el.style.height = 'auto';
+ el.style.height = `${el.scrollHeight}px`;
+ }, []);
+ // Move focus into the editor (caret at end) and size it when editing starts.
+ React.useEffect(() => {
+ if (!isEditing) return;
+ const el = editRef.current;
+ if (!el) return;
+ el.focus();
+ el.setSelectionRange(el.value.length, el.value.length);
+ autosize();
+ }, [isEditing, autosize]);
+
+ if (message.type === 'system') {
+ return (
+
+ {message.text}
+
+ );
+ }
+
+ if (message.type === 'ref' && message.ref) {
+ return (
+
+
+
+ );
+ }
+
+ const accent = participant?.color;
+ const authorName = participant?.name ?? 'Unknown';
+
+ // Inline editing applies only to the local user's own plain-text messages
+ // (rich content blocks / streaming messages are not inline-editable).
+ const canEdit =
+ !!editable &&
+ isSelf &&
+ !streaming &&
+ typeof message.text === 'string' &&
+ !message.content?.length;
+
+ const startEdit = () => {
+ setDraft(message.text ?? '');
+ setIsEditing(true);
+ };
+ const cancelEdit = () => setIsEditing(false);
+ const saveEdit = () => {
+ const next = draft.trim();
+ if (next && next !== message.text) onMessageEdited?.(message.id, next);
+ setIsEditing(false);
+ };
+
+ // Paste an image into the editor: splice its Markdown at the caret so it
+ // becomes part of the message source (the bubble renders Markdown).
+ const handleEditPaste = (e: React.ClipboardEvent) => {
+ const files = Array.from(e.clipboardData.items)
+ .filter((item) => item.kind === 'file' && item.type.startsWith('image/'))
+ .map((item) => item.getAsFile())
+ .filter((f): f is File => f !== null);
+ if (files.length === 0) return;
+ e.preventDefault();
+ const el = e.currentTarget;
+ const start = el.selectionStart ?? draft.length;
+ const end = el.selectionEnd ?? draft.length;
+ Promise.all(
+ files.map(
+ (file, i) =>
+ new Promise((resolve) => {
+ const reader = new window.FileReader();
+ reader.onload = () => {
+ const dataUrl =
+ typeof reader.result === 'string' ? reader.result : '';
+ const name = file.name || `pasted-image-${i + 1}.png`;
+ resolve(dataUrl ? `` : '');
+ };
+ reader.readAsDataURL(file);
+ })
+ )
+ ).then((snippets) => {
+ const insert = snippets.filter(Boolean).join('\n');
+ if (!insert) return;
+ // Insert via `execCommand('insertText')` so the browser records it on its
+ // native undo stack (Cmd/Ctrl+Z works) and fires a normal input event
+ // that flows back through `onChange`. Fall back to a controlled-state
+ // splice if the (deprecated) command is unavailable.
+ el.focus();
+ el.setSelectionRange(start, end);
+ const text = `${insert}\n`;
+ const inserted =
+ typeof document !== 'undefined' &&
+ typeof document.execCommand === 'function' &&
+ document.execCommand('insertText', false, text);
+ if (!inserted) {
+ setDraft((prev) => `${prev.slice(0, start)}${text}${prev.slice(end)}`);
+ }
+ requestAnimationFrame(autosize);
+ });
+ };
+
+ // The Markdown source for copying: prefer the raw `text`, otherwise assemble
+ // it from the message's text/code content blocks.
+ const markdownSource =
+ typeof message.text === 'string' && message.text
+ ? message.text
+ : (message.content
+ ?.map((block) => {
+ if (block.type === 'code' && block.text) {
+ return `\`\`\`${block.language ?? ''}\n${block.text}\n\`\`\``;
+ }
+ if (
+ (block.type === 'text' || block.type === 'thinking') &&
+ block.text
+ ) {
+ return block.text;
+ }
+ return '';
+ })
+ .filter(Boolean)
+ .join('\n\n') ?? '');
+
+ // A copy affordance appears on every message that has a body (not while it is
+ // being edited).
+ const canCopy =
+ !isEditing &&
+ (!!message.content?.length ||
+ (typeof message.text === 'string' && message.text.length > 0));
+
+ return (
+
+
+
+
+
+ {authorName}
+
+ {participant?.role && (
+
+ {participant.role}
+
+ )}
+
+ {formatTime(message.time)}
+
+ {message.editedAt && (
+
+ (edited)
+
+ )}
+ {canEdit && !isEditing && (
+
+
+
+ )}
+
+
+
+ {canCopy && (
+
bubbleRef.current?.innerHTML ?? ''}
+ getText={() => bubbleRef.current?.textContent ?? ''}
+ />
+ )}
+
+ {isEditing ? (
+
+ ) : (
+ <>
+ {/* Rich content blocks (tool calls etc.) reused from the AI module. */}
+ {message.content?.map((block, i) => {
+ if (block.type === 'tool_use' && block.toolCall) {
+ return (
+
+ );
+ }
+ if (
+ (block.type === 'text' || block.type === 'thinking') &&
+ block.text
+ ) {
+ return (
+
+ {renderText(block.text, {
+ messageId: message.id,
+ streaming,
+ role:
+ participant?.kind === 'human'
+ ? 'user'
+ : 'assistant',
+ })}
+
+ );
+ }
+ if (block.type === 'code' && block.text) {
+ const fenced = `\`\`\`${block.language ?? ''}\n${block.text}\n\`\`\``;
+ return (
+
+ {renderText(fenced, {
+ messageId: message.id,
+ streaming,
+ role:
+ participant?.kind === 'human'
+ ? 'user'
+ : 'assistant',
+ })}
+
+ );
+ }
+ return null;
+ })}
+
+ {/* Plain `text` body (the common case). */}
+ {message.text && (
+
+ {renderText(message.text, {
+ messageId: message.id,
+ streaming,
+ role:
+ participant?.kind === 'human' ? 'user' : 'assistant',
+ })}
+
+ )}
+ >
+ )}
+
+
+
+
+ );
+});
+
+// ============================================================================
+// Composer
+// ============================================================================
+
+/** Match a trailing `@token` immediately before the caret. */
+export function activeMentionQuery(
+ value: string,
+ caret: number
+): { query: string; start: number } | null {
+ const upToCaret = value.slice(0, caret);
+ const match = /(^|\s)@([^\s@]*)$/.exec(upToCaret);
+ if (!match) return null;
+ const query = match[2];
+ return { query, start: caret - query.length - 1 };
+}
+
+export function Composer({
+ participants,
+ disabled,
+ onSend,
+ acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
+}: {
+ participants: Participant[];
+ disabled?: boolean;
+ onSend: (
+ text: string,
+ mentions: string[],
+ attachments: ComposerAttachment[]
+ ) => void;
+ /** File categories the composer accepts (paste + paperclip). */
+ acceptedFileTypes?: AttachmentKind[];
+}) {
+ const [draft, setDraft] = React.useState('');
+ const [attachments, setAttachments] = React.useState(
+ []
+ );
+ const attachmentSeq = React.useRef(0);
+ const [mention, setMention] = React.useState<{
+ query: string;
+ start: number;
+ } | null>(null);
+ const [highlight, setHighlight] = React.useState(0);
+ const textareaRef = React.useRef>(null);
+ const fileInputRef = React.useRef(null);
+
+ // Accepted file types → ` ` token + a predicate for filtering
+ // pastes and picker selections.
+ const acceptKinds =
+ acceptedFileTypes.length > 0
+ ? acceptedFileTypes
+ : DEFAULT_ACCEPTED_FILE_TYPES;
+ const acceptAttr = React.useMemo(
+ () =>
+ Array.from(
+ new Set(acceptKinds.map((k) => ATTACHMENT_KIND_CONFIG[k].accept))
+ ).join(','),
+ [acceptKinds]
+ );
+ const acceptsType = React.useCallback(
+ (type: string) =>
+ acceptKinds.some((k) => ATTACHMENT_KIND_CONFIG[k].match(type)),
+ [acceptKinds]
+ );
+
+ // Agents/humans you can address (exclude the system participant).
+ const mentionable = React.useMemo(
+ () => participants.filter((p) => p.kind !== 'system'),
+ [participants]
+ );
+
+ const suggestions = React.useMemo(() => {
+ if (!mention) return [];
+ const q = mention.query.toLowerCase();
+ return mentionable.filter((p) => p.name.toLowerCase().includes(q));
+ }, [mention, mentionable]);
+
+ const menuOpen = mention !== null && suggestions.length > 0;
+ const listboxId = React.useId();
+ const optionId = (i: number) => `${listboxId}-option-${i}`;
+ const activeOptionId = menuOpen ? optionId(highlight) : undefined;
+
+ const syncMention = (value: string, caret: number) => {
+ const next = activeMentionQuery(value, caret);
+ setMention(next);
+ setHighlight(0);
+ };
+
+ const insertMention = (participant: Participant) => {
+ if (!mention) return;
+ const first = participant.name.split(' ')[0];
+ const before = draft.slice(0, mention.start);
+ const after = draft.slice(mention.start + 1 + mention.query.length);
+ const insert = `@${first} `;
+ const nextValue = before + insert + after;
+ setDraft(nextValue);
+ setMention(null);
+ // Restore caret just after the inserted mention.
+ const caret = before.length + insert.length;
+ requestAnimationFrame(() => {
+ const el = textareaRef.current;
+ if (el) {
+ el.focus();
+ el.setSelectionRange(caret, caret);
+ }
+ });
+ };
+
+ const submit = () => {
+ const text = draft.trim();
+ if (!text && attachments.length === 0) return;
+ onSend(text, detectMentions(text, participants), attachments);
+ setDraft('');
+ setAttachments([]);
+ setMention(null);
+ };
+
+ const readFile = (file: File) => {
+ const reader = new window.FileReader();
+ reader.onload = () => {
+ const dataUrl =
+ typeof reader.result === 'string' ? reader.result : undefined;
+ if (!dataUrl) return;
+ attachmentSeq.current += 1;
+ setAttachments((prev) => [
+ ...prev,
+ {
+ id: `att-${Date.now()}-${attachmentSeq.current}`,
+ name: file.name || `attachment-${attachmentSeq.current}`,
+ type: file.type || 'application/octet-stream',
+ dataUrl,
+ },
+ ]);
+ };
+ reader.readAsDataURL(file);
+ };
+
+ const handlePaste = (e: React.ClipboardEvent) => {
+ if (disabled) return;
+ const files = Array.from(e.clipboardData.items)
+ .filter((item) => item.kind === 'file' && acceptsType(item.type))
+ .map((item) => item.getAsFile())
+ .filter((f): f is File => f !== null);
+ if (files.length === 0) return;
+ // We're handling the file ourselves; don't also paste a file path/blob.
+ e.preventDefault();
+ files.forEach(readFile);
+ };
+
+ const openFilePicker = () => {
+ if (disabled) return;
+ fileInputRef.current?.click();
+ };
+
+ const handleFilesSelected = (e: React.ChangeEvent) => {
+ const files = Array.from(e.target.files ?? []).filter((f) =>
+ acceptsType(f.type)
+ );
+ files.forEach(readFile);
+ // Reset so selecting the same file again still fires `change`.
+ e.target.value = '';
+ };
+
+ const removeAttachment = (id: string) => {
+ setAttachments((prev) => prev.filter((a) => a.id !== id));
+ };
+
+ const canSend =
+ !disabled && (draft.trim().length > 0 || attachments.length > 0);
+
+ return (
+
+ {menuOpen && (
+
+ {suggestions.map((p, i) => (
+
+ {
+ e.preventDefault();
+ insertMention(p);
+ }}
+ onMouseEnter={() => setHighlight(i)}
+ className={cn(
+ 'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
+ i === highlight
+ ? 'bg-primary-100 text-primary-900 dark:bg-primary-900/40 dark:text-primary-100'
+ : 'text-neutral-700 dark:text-neutral-200'
+ )}
+ >
+
+
+ {p.name}
+ {p.role && (
+
+ {p.role}
+
+ )}
+
+ {p.kind}
+
+
+ ))}
+
+ )}
+
+ {attachments.length > 0 && (
+
+ {attachments.map((att) => {
+ const isImage = att.type.startsWith('image/');
+ return (
+
+ {isImage ? (
+ // Local preview of an image; data: URL stays in-browser.
+
+ ) : (
+ <>
+
+
+
+
+ {att.name}
+
+ >
+ )}
+ removeAttachment(att.id)}
+ aria-label={`Remove ${att.name}`}
+ className="absolute end-0.5 top-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-neutral-900/70 text-white opacity-0 transition-opacity group-hover/att:opacity-100 focus:opacity-100 focus:outline-none"
+ >
+
+ ×
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ============================================================================
+// Sidebar item style
+// ============================================================================
+
+export const sidebarItem = cva(
+ 'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors',
+ {
+ variants: {
+ active: {
+ true: 'bg-primary-100 text-primary-900 dark:bg-primary-900/40 dark:text-primary-100',
+ false:
+ 'text-neutral-700 hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-800',
+ },
+ },
+ defaultVariants: { active: false },
+ }
+);
diff --git a/src/components/SuperChat/plugins/attachment.tsx b/src/components/SuperChat/plugins/attachment.tsx
new file mode 100644
index 00000000..bb1ae6c1
--- /dev/null
+++ b/src/components/SuperChat/plugins/attachment.tsx
@@ -0,0 +1,430 @@
+/**
+ * SuperChat attachment plugin (opt-in, offline-first).
+ *
+ * Renders file attachments (image / video / audio / pdf / generic) inline in a
+ * message via a fenced ` ```superchat-attachment ` block whose body is a small
+ * JSON descriptor:
+ *
+ * ````md
+ * ```superchat-attachment
+ * { "id": "att-1", "type": "video/mp4", "name": "clip.mp4" }
+ * ```
+ * ````
+ *
+ * Use {@link attachmentMarkdown} to build the block. The descriptor carries an
+ * attachment **id**; the player resolves a `blob:` URL from the
+ * {@link attachmentCache} (IndexedDB) at render time, so previously sent media
+ * keeps rendering offline without storing base64 in the conversation. An
+ * optional inline `src` (`data:` URL) is used as a fallback when the cache is
+ * empty or unavailable, and is opportunistically persisted for next time.
+ *
+ * Mirrors the GenUI plugin's fence→custom-element rehype transform so the
+ * payload never travels through `urlTransform` / raw-HTML, keeping the pipeline
+ * robust under `rehype-sanitize`.
+ */
+
+import * as React from 'react';
+import { cn } from '../../../utils/cn';
+import { attachmentCache } from '../render/attachmentCache';
+import type { SuperChatRenderPlugin } from '../types';
+
+/** Custom element the fenced block is rewritten to before sanitization. */
+export const ATTACHMENT_TAG = 'superchat-attachment';
+/** Fenced-code language that marks an attachment block. */
+export const ATTACHMENT_FENCE = 'superchat-attachment';
+
+/** JSON payload carried by a ` ```superchat-attachment ` block. */
+export interface AttachmentBlockPayload {
+ /** Attachment id used as the {@link attachmentCache} key. */
+ id?: string;
+ /** MIME type, e.g. `video/mp4`. */
+ type: string;
+ /** File name (shown as a label / download name). */
+ name: string;
+ /** Optional inline `data:` URL fallback when the cache has no entry. */
+ src?: string;
+}
+
+/**
+ * Build the Markdown for an attachment block. Embed the result in a message so
+ * the attachment plugin renders it.
+ *
+ * @example
+ * const md = attachmentMarkdown({ id: att.id, type: att.type, name: att.name });
+ */
+export function attachmentMarkdown(payload: AttachmentBlockPayload): string {
+ return ['```superchat-attachment', JSON.stringify(payload), '```'].join('\n');
+}
+
+// ---------------------------------------------------------------------------
+// Offline URL resolution
+// ---------------------------------------------------------------------------
+
+type AttachmentUrlStatus = 'idle' | 'loading' | 'ready' | 'missing';
+
+/**
+ * Resolve a displayable URL for an attachment. Prefers a cached `blob:` URL
+ * (offline), falling back to the inline `data:` URL when the cache is empty or
+ * unavailable. The returned object URL is revoked automatically on unmount /
+ * id change.
+ */
+export function useAttachmentUrl(
+ id?: string,
+ fallbackSrc?: string
+): { url?: string; status: AttachmentUrlStatus } {
+ const [url, setUrl] = React.useState(undefined);
+ const [status, setStatus] = React.useState('idle');
+
+ React.useEffect(() => {
+ let active = true;
+ let created: string | undefined;
+
+ if (!id && !fallbackSrc) {
+ setUrl(undefined);
+ setStatus('idle');
+ return;
+ }
+
+ setStatus('loading');
+ void (async () => {
+ if (id) {
+ const cached = await attachmentCache.getObjectURL(id);
+ if (!active) {
+ if (cached) window.URL.revokeObjectURL(cached);
+ return;
+ }
+ if (cached) {
+ created = cached;
+ setUrl(cached);
+ setStatus('ready');
+ return;
+ }
+ }
+ if (!active) return;
+ setUrl(fallbackSrc);
+ setStatus(fallbackSrc ? 'ready' : 'missing');
+ })();
+
+ return () => {
+ active = false;
+ if (created) window.URL.revokeObjectURL(created);
+ };
+ }, [id, fallbackSrc]);
+
+ return { url, status };
+}
+
+// ---------------------------------------------------------------------------
+// Icons
+// ---------------------------------------------------------------------------
+
+const ICON_PROPS = {
+ width: 20,
+ height: 20,
+ viewBox: '0 0 24 24',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: 1.75,
+ strokeLinecap: 'round' as const,
+ strokeLinejoin: 'round' as const,
+ 'aria-hidden': true,
+};
+
+function FileGlyph() {
+ return (
+
+
+
+
+ );
+}
+
+function DownloadGlyph() {
+ return (
+
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Presentation
+// ---------------------------------------------------------------------------
+
+type AttachmentRenderKind = 'image' | 'video' | 'audio' | 'pdf' | 'file';
+
+function kindOf(type: string): AttachmentRenderKind {
+ if (type.startsWith('image/')) return 'image';
+ if (type.startsWith('video/')) return 'video';
+ if (type.startsWith('audio/')) return 'audio';
+ if (type === 'application/pdf') return 'pdf';
+ return 'file';
+}
+
+function FileChip({
+ name,
+ url,
+ unavailable,
+}: {
+ name: string;
+ url?: string;
+ unavailable?: boolean;
+}) {
+ const content = (
+ <>
+
+
+
+ {name}
+ {url && !unavailable ? (
+
+
+
+ ) : null}
+ >
+ );
+
+ const className = cn(
+ 'my-2 flex max-w-sm items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-800',
+ unavailable && 'opacity-60'
+ );
+
+ if (url && !unavailable) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+ {unavailable ? (
+
+ unavailable offline
+
+ ) : null}
+
+ );
+}
+
+function AttachmentBlock({ payload }: { payload: AttachmentBlockPayload }) {
+ const { id, type, name, src } = payload;
+ const { url, status } = useAttachmentUrl(id || undefined, src);
+ const kind = kindOf(type);
+
+ // Opportunistically persist an inline data: URL so it renders offline next
+ // time, without re-storing what's already cached.
+ React.useEffect(() => {
+ if (!id || !src || !attachmentCache.isAvailable()) return;
+ let active = true;
+ void attachmentCache.get(id).then((existing) => {
+ if (active && !existing) {
+ void attachmentCache.put({ id, name, type, dataUrl: src });
+ }
+ });
+ return () => {
+ active = false;
+ };
+ }, [id, src, name, type]);
+
+ if (status === 'loading') {
+ return (
+
+ );
+ }
+
+ if (!url) {
+ return (
+
+
+
+ );
+ }
+
+ let body: React.ReactNode;
+ switch (kind) {
+ case 'image':
+ body = (
+
+ );
+ break;
+ case 'video':
+ body = (
+
+
+
+ );
+ break;
+ case 'audio':
+ body = (
+
+ );
+ break;
+ case 'pdf':
+ body = (
+
+ );
+ break;
+ default:
+ body = ;
+ }
+
+ return {body}
;
+}
+
+// ---------------------------------------------------------------------------
+// rehype: fenced block → {json}
+// ---------------------------------------------------------------------------
+
+interface HastNode {
+ type: string;
+ tagName?: string;
+ value?: string;
+ properties?: Record;
+ children?: HastNode[];
+}
+
+function textOf(node: HastNode): string {
+ if (node.type === 'text') return node.value ?? '';
+ return (node.children ?? []).map(textOf).join('');
+}
+
+function isAttachmentPre(node: HastNode): boolean {
+ if (node.tagName !== 'pre') return false;
+ const code = node.children?.find((c) => c.tagName === 'code');
+ const className = code?.properties?.className;
+ const classes = Array.isArray(className) ? className : [className];
+ return classes.some(
+ (c) =>
+ typeof c === 'string' &&
+ (c === `language-${ATTACHMENT_FENCE}` || c === ATTACHMENT_FENCE)
+ );
+}
+
+function rehypeAttachment() {
+ return (tree: HastNode) => {
+ const walk = (node: HastNode) => {
+ if (!node.children) return;
+ node.children = node.children.map((child) => {
+ if (isAttachmentPre(child)) {
+ const code = child.children?.find((c) => c.tagName === 'code');
+ const raw = code ? textOf(code) : '';
+ return {
+ type: 'element',
+ tagName: ATTACHMENT_TAG,
+ properties: {},
+ children: [{ type: 'text', value: raw }],
+ } satisfies HastNode;
+ }
+ walk(child);
+ return child;
+ });
+ };
+ walk(tree);
+ };
+}
+
+function childText(children: React.ReactNode): string {
+ return React.Children.toArray(children)
+ .map((c) => (typeof c === 'string' ? c : ''))
+ .join('');
+}
+
+function parsePayload(raw: string): AttachmentBlockPayload | null {
+ try {
+ const parsed = JSON.parse(raw) as Partial;
+ if (!parsed || typeof parsed.type !== 'string') return null;
+ if (!parsed.id && !parsed.src) return null;
+ return {
+ id: typeof parsed.id === 'string' ? parsed.id : undefined,
+ type: parsed.type,
+ name: typeof parsed.name === 'string' ? parsed.name : 'attachment',
+ src: typeof parsed.src === 'string' ? parsed.src : undefined,
+ };
+ } catch {
+ return null;
+ }
+}
+
+function AttachmentElement({
+ node: _node,
+ children,
+}: {
+ node?: unknown;
+ children?: React.ReactNode;
+}) {
+ const payload = parsePayload(childText(children));
+ if (!payload) return null;
+ return ;
+}
+
+/**
+ * Create the attachment render plugin: renders ` ```superchat-attachment `
+ * blocks as inline image/video/audio/pdf players backed by the offline
+ * {@link attachmentCache}.
+ */
+export function createAttachmentPlugin(): SuperChatRenderPlugin {
+ return {
+ name: 'attachment',
+ rehypePlugins: [rehypeAttachment],
+ components: {
+ [ATTACHMENT_TAG]: AttachmentElement as React.ComponentType<
+ Record
+ >,
+ },
+ // The payload rides as a text child; only the custom tag needs allow-listing.
+ sanitizeSchema: {
+ tagNames: [ATTACHMENT_TAG],
+ },
+ };
+}
diff --git a/src/components/SuperChat/plugins/code.tsx b/src/components/SuperChat/plugins/code.tsx
new file mode 100644
index 00000000..c580f93d
--- /dev/null
+++ b/src/components/SuperChat/plugins/code.tsx
@@ -0,0 +1,80 @@
+/**
+ * SuperChat code plugin (opt-in).
+ *
+ * Syntax-highlights fenced code blocks with `rehype-highlight` (emits `.hljs-*`
+ * classes that map to `--mieweb-*` theme tokens for light/dark), and adds a copy
+ * button. `shiki` is reserved as an upgrade path if highlight fidelity disappoints.
+ */
+
+import * as React from 'react';
+import rehypeHighlight from 'rehype-highlight';
+import { cn } from '../../../utils/cn';
+import type { SuperChatRenderPlugin } from '../types';
+
+function CopyablePre({
+ node: _node,
+ ...props
+}: React.ComponentProps<'pre'> & { node?: unknown }) {
+ const ref = React.useRef>(null);
+ const [copied, setCopied] = React.useState(false);
+
+ const onCopy = React.useCallback(() => {
+ const text = ref.current?.innerText ?? '';
+ if (!text) return;
+ // `navigator.clipboard` is undefined in non-secure contexts; guard the
+ // optional-chained call (which would be `undefined`, not a promise) and
+ // swallow rejection so copy stays best-effort and never breaks render.
+ const copy = navigator.clipboard?.writeText(text);
+ if (!copy) return;
+ void copy.then(
+ () => {
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ },
+ () => {
+ /* clipboard write rejected (permissions / non-secure context) */
+ }
+ );
+ }, []);
+
+ return (
+
+
+ {copied ? 'Copied' : 'Copy'}
+
+
+
+ );
+}
+
+/** Create the syntax-highlighting + copy-button render plugin. */
+export function createCodePlugin(): SuperChatRenderPlugin {
+ return {
+ name: 'code',
+ // `detect: false` + ignoreMissing keeps it predictable for streamed output.
+ rehypePlugins: [[rehypeHighlight, { detect: false, ignoreMissing: true }]],
+ components: {
+ pre: CopyablePre as React.ComponentType>,
+ },
+ // `.hljs-*` token classes are already permitted by the base schema's
+ // broadened `className` allow-list on code/pre/span.
+ sanitizeSchema: {
+ attributes: {
+ code: ['className'],
+ span: ['className'],
+ },
+ },
+ };
+}
diff --git a/src/components/SuperChat/plugins/genui.tsx b/src/components/SuperChat/plugins/genui.tsx
new file mode 100644
index 00000000..a44fdb85
--- /dev/null
+++ b/src/components/SuperChat/plugins/genui.tsx
@@ -0,0 +1,354 @@
+/**
+ * SuperChat GenUI plugin (opt-in).
+ *
+ * Interactive widgets expressed as fenced ```genui JSON blocks:
+ *
+ * ````md
+ * ```genui
+ * { "widget": "math_block", "version": 2, "prefetch": "eager", "props": { "content": "a^2+b^2=c^2" } }
+ * ```
+ * ````
+ *
+ * Widgets are **host-registered, lazy, and schema-validated**. Unknown widgets
+ * degrade to an inert code block. Prefetch is split into component (code) vs.
+ * data, with `eager` / `visible` / `idle` policies; the **registry policy
+ * overrides the wire hint**. Mount + data fetch are gated on `streaming` and a
+ * complete/valid payload (a `MCPToolCall`-style pending card shows until then).
+ */
+
+import * as React from 'react';
+import { useTextRenderContext } from '../render/renderContext';
+import type {
+ GenUIBlockPayload,
+ GenUIPrefetchPolicy,
+ GenUIRegistry,
+ GenUIWidgetEntry,
+ GenUIWidgetProps,
+ StandardSchemaV1,
+ SuperChatRenderPlugin,
+} from '../types';
+
+const GENUI_TAG = 'genui-widget';
+
+// ---------------------------------------------------------------------------
+// rehype transformer: …
+// → {json text}
+// Running this before sanitize (and allow-listing the tag) keeps the pipeline
+// free of `pre`/`code` component override conflicts with the code plugin. The
+// payload rides as a text child (not a data-* attribute) to avoid hast property
+// name conversion surprises through sanitize + react-markdown.
+// ---------------------------------------------------------------------------
+
+interface HastNode {
+ type: string;
+ tagName?: string;
+ value?: string;
+ properties?: Record;
+ children?: HastNode[];
+}
+
+function textOf(node: HastNode): string {
+ if (node.type === 'text') return node.value ?? '';
+ return (node.children ?? []).map(textOf).join('');
+}
+
+function isGenuiPre(node: HastNode): boolean {
+ if (node.tagName !== 'pre') return false;
+ const code = node.children?.find((c) => c.tagName === 'code');
+ const className = code?.properties?.className;
+ const classes = Array.isArray(className) ? className : [className];
+ return classes.some(
+ (c) => typeof c === 'string' && (c === 'language-genui' || c === 'genui')
+ );
+}
+
+function rehypeGenui() {
+ return (tree: HastNode) => {
+ const walk = (node: HastNode) => {
+ if (!node.children) return;
+ node.children = node.children.map((child) => {
+ if (isGenuiPre(child)) {
+ const code = child.children?.find((c) => c.tagName === 'code');
+ const raw = code ? textOf(code) : '';
+ return {
+ type: 'element',
+ tagName: GENUI_TAG,
+ properties: {},
+ children: [{ type: 'text', value: raw }],
+ } satisfies HastNode;
+ }
+ walk(child);
+ return child;
+ });
+ };
+ walk(tree);
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Pending / fallback chrome
+// ---------------------------------------------------------------------------
+
+function PendingCard({ label }: { label: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+function InertFallback({ raw }: { raw: string }) {
+ return (
+
+ {raw}
+
+ );
+}
+
+function ErrorCard({ message }: { message: string }) {
+ return (
+
+ {message}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Payload parsing + schema validation
+// ---------------------------------------------------------------------------
+
+function parsePayload(
+ raw: string
+): { ok: true; value: GenUIBlockPayload } | { ok: false } {
+ const trimmed = raw.trim();
+ if (!trimmed) return { ok: false };
+ try {
+ const value = JSON.parse(trimmed) as GenUIBlockPayload;
+ if (!value || typeof value.widget !== 'string') return { ok: false };
+ return { ok: true, value };
+ } catch {
+ // Incomplete JSON (still streaming) or malformed.
+ return { ok: false };
+ }
+}
+
+async function validate(
+ schema: StandardSchemaV1 | undefined,
+ data: unknown
+): Promise<{ ok: true; value: T } | { ok: false; message: string }> {
+ if (!schema) return { ok: true, value: data as T };
+ const result = await schema['~standard'].validate(data);
+ if ('issues' in result && result.issues) {
+ return {
+ ok: false,
+ message:
+ result.issues.map((i) => i.message).join('; ') ||
+ 'Invalid widget payload',
+ };
+ }
+ return { ok: true, value: (result as { value: T }).value };
+}
+
+// ---------------------------------------------------------------------------
+// Widget loader (code prefetch policy)
+// ---------------------------------------------------------------------------
+
+function usePrefetchTrigger(
+ policy: GenUIPrefetchPolicy,
+ ref: React.RefObject
+): boolean {
+ const [ready, setReady] = React.useState(policy === 'eager');
+
+ React.useEffect(() => {
+ if (ready) return;
+ if (policy === 'idle') {
+ const requestIdle = (
+ window as unknown as {
+ requestIdleCallback?: (cb: () => void) => number;
+ cancelIdleCallback?: (id: number) => void;
+ }
+ ).requestIdleCallback;
+ const cancelIdle = (
+ window as unknown as {
+ requestIdleCallback?: (cb: () => void) => number;
+ cancelIdleCallback?: (id: number) => void;
+ }
+ ).cancelIdleCallback;
+ let idleId: number | null = null;
+ let timeoutId: number | null = null;
+ if (requestIdle) {
+ idleId = requestIdle(() => setReady(true));
+ } else {
+ timeoutId = window.setTimeout(() => setReady(true), 200);
+ }
+ return () => {
+ if (idleId !== null && cancelIdle) cancelIdle(idleId);
+ if (timeoutId !== null) window.clearTimeout(timeoutId);
+ };
+ }
+ // 'visible'
+ const el = ref.current;
+ if (!el || typeof IntersectionObserver === 'undefined') {
+ setReady(true);
+ return;
+ }
+ const obs = new IntersectionObserver((entries) => {
+ if (entries.some((e) => e.isIntersecting)) {
+ setReady(true);
+ obs.disconnect();
+ }
+ });
+ obs.observe(el);
+ return () => obs.disconnect();
+ }, [policy, ready, ref]);
+
+ return ready;
+}
+
+interface GenUIBlockProps {
+ raw: string;
+ registry: GenUIRegistry;
+}
+
+function GenUIBlock({ raw, registry }: GenUIBlockProps) {
+ const { messageId, streaming } = useTextRenderContext();
+ const placeholderRef = React.useRef(null);
+
+ const parsed = React.useMemo(() => parsePayload(raw), [raw]);
+ const entry: GenUIWidgetEntry | undefined = parsed.ok
+ ? registry[parsed.value.widget]
+ : undefined;
+
+ // Registry policy overrides the wire hint; default to 'visible'.
+ const policy: GenUIPrefetchPolicy =
+ entry?.prefetch ??
+ (parsed.ok ? parsed.value.prefetch : undefined) ??
+ 'visible';
+
+ const codeReady = usePrefetchTrigger(policy, placeholderRef);
+
+ const [Loaded, setLoaded] =
+ React.useState | null>(null);
+ const [validated, setValidated] = React.useState<
+ { ok: true; value: unknown } | { ok: false; message: string } | null
+ >(null);
+
+ // Component (code) prefetch — may run while streaming.
+ React.useEffect(() => {
+ if (!entry || !codeReady) return;
+ let active = true;
+ void entry
+ .component()
+ .then((m) => {
+ if (active) setLoaded(() => m.default);
+ })
+ .catch(() => {
+ if (active)
+ setValidated({ ok: false, message: 'Failed to load widget' });
+ });
+ return () => {
+ active = false;
+ };
+ }, [entry, codeReady]);
+
+ // Data validation/prefetch — only once payload is complete AND not streaming.
+ React.useEffect(() => {
+ if (!entry || !parsed.ok || streaming) return;
+ let active = true;
+ const props = parsed.value.props;
+ void (async () => {
+ const result = await validate(entry.schema, props);
+ if (!active) return;
+ if (result.ok && entry.prefetchData) {
+ try {
+ await entry.prefetchData(result.value);
+ } catch {
+ /* non-fatal: data prefetch is best-effort */
+ }
+ }
+ if (active) setValidated(result);
+ })();
+ return () => {
+ active = false;
+ };
+ }, [entry, parsed, streaming]);
+
+ // Unknown widget or unparseable-yet-complete payload → inert fallback.
+ if (!parsed.ok) {
+ if (streaming) {
+ return (
+
+ );
+ }
+ return ;
+ }
+
+ if (parsed.ok && !entry) return ;
+
+ // Still streaming or payload incomplete → pending card (keep ref mounted so
+ // the visible/idle prefetch trigger can fire).
+ if (streaming || !Loaded || !validated) {
+ return (
+
+ );
+ }
+
+ if (!validated.ok) return ;
+
+ const meta = {
+ name: parsed.value.widget,
+ version: parsed.value.version,
+ messageId,
+ streaming,
+ };
+
+ return ;
+}
+
+// ---------------------------------------------------------------------------
+// Plugin factory
+// ---------------------------------------------------------------------------
+
+/** Create the GenUI render plugin from a host widget registry. */
+export function createGenUIPlugin(
+ registry: GenUIRegistry
+): SuperChatRenderPlugin {
+ const GenUIWidgetComponent = (props: Record) => {
+ const raw = React.Children.toArray(props.children as React.ReactNode)
+ .filter((c): c is string => typeof c === 'string')
+ .join('');
+ return ;
+ };
+
+ return {
+ name: 'genui',
+ rehypePlugins: [rehypeGenui],
+ components: {
+ [GENUI_TAG]: GenUIWidgetComponent,
+ },
+ sanitizeSchema: {
+ tagNames: [GENUI_TAG],
+ },
+ };
+}
+
+export { GENUI_TAG };
+export type { GenUIRegistry, GenUIWidgetEntry, GenUIWidgetProps };
diff --git a/src/components/SuperChat/plugins/image.tsx b/src/components/SuperChat/plugins/image.tsx
new file mode 100644
index 00000000..1cab12f5
--- /dev/null
+++ b/src/components/SuperChat/plugins/image.tsx
@@ -0,0 +1,95 @@
+/**
+ * SuperChat image plugin (opt-in).
+ *
+ * Makes inline Markdown images (``) click-to-zoom, reusing the
+ * Messaging module's full-screen {@link LightboxModal}. The image `src`/`alt`
+ * come from (untrusted) Markdown and are already protocol-restricted by
+ * `rehype-sanitize`; this plugin only adds the zoom affordance.
+ */
+
+import * as React from 'react';
+import { createPortal } from 'react-dom';
+import { LightboxModal, type MessageAttachment } from '../../Messaging';
+import { cn } from '../../../utils/cn';
+import type { SuperChatRenderPlugin } from '../types';
+
+function filenameFromUrl(url: string, alt?: string): string {
+ if (alt && alt.trim()) return alt.trim();
+ try {
+ const path = new URL(url, 'http://localhost').pathname;
+ const last = path.split('/').filter(Boolean).pop();
+ return last || 'image';
+ } catch {
+ return 'image';
+ }
+}
+
+function ZoomableImage({
+ node: _node,
+ ...props
+}: React.ComponentProps<'img'> & { node?: unknown }) {
+ const [open, setOpen] = React.useState(false);
+ const src = typeof props.src === 'string' ? props.src : '';
+ const alt = typeof props.alt === 'string' ? props.alt : '';
+
+ const attachment: MessageAttachment | null = React.useMemo(() => {
+ if (!src) return null;
+ return {
+ id: src,
+ type: 'image',
+ url: src,
+ filename: filenameFromUrl(src, alt),
+ size: 0,
+ mimeType: 'image/*',
+ state: 'uploaded',
+ alt,
+ };
+ }, [src, alt]);
+
+ if (!src) return null;
+
+ return (
+ <>
+ setOpen(true)}
+ aria-label={alt ? `View image: ${alt}` : 'View image'}
+ className="focus-visible:ring-primary-500 my-2 inline-block cursor-zoom-in rounded-lg focus-visible:ring-2 focus-visible:outline-none"
+ >
+
+
+ {open &&
+ typeof document !== 'undefined' &&
+ createPortal(
+ setOpen(false)}
+ />,
+ document.body
+ )}
+ >
+ );
+}
+
+/** Create the image (click-to-zoom lightbox) render plugin. */
+export function createImagePlugin(): SuperChatRenderPlugin {
+ return {
+ name: 'image',
+ components: {
+ img: ZoomableImage as React.ComponentType>,
+ },
+ // Allow inline (`data:`) and object-URL (`blob:`) image sources in addition
+ // to the default http/https, so pasted/attached images render. These are
+ // safe for ` ` (scripts in SVG loaded via `src` do not execute).
+ sanitizeSchema: {
+ protocols: { src: ['data', 'blob'] },
+ },
+ };
+}
diff --git a/src/components/SuperChat/plugins/index.ts b/src/components/SuperChat/plugins/index.ts
new file mode 100644
index 00000000..a71693b5
--- /dev/null
+++ b/src/components/SuperChat/plugins/index.ts
@@ -0,0 +1,40 @@
+/**
+ * SuperChat rich render plugins (opt-in subpath).
+ *
+ * Import only the plugins you need; each pulls its heavy dependency lazily and
+ * stays out of the SuperChat base bundle.
+ *
+ * @example
+ * import { createCodePlugin, createMathPlugin } from '@mieweb/ui/components/SuperChat/plugins';
+ * import 'katex/dist/katex.min.css'; // required by the math plugin
+ *
+ * const render = createMarkdownRenderer({
+ * plugins: [createCodePlugin(), createMathPlugin()],
+ * });
+ */
+
+export { createCodePlugin } from './code';
+export { createMathPlugin } from './math';
+export { createGenUIPlugin, GENUI_TAG } from './genui';
+export { createMermaidPlugin, MERMAID_TAG } from './mermaid';
+export { createImagePlugin } from './image';
+export { createNitroTablePlugin } from './nitroTable';
+export {
+ createAttachmentPlugin,
+ attachmentMarkdown,
+ useAttachmentUrl,
+ ATTACHMENT_TAG,
+ ATTACHMENT_FENCE,
+ type AttachmentBlockPayload,
+} from './attachment';
+export {
+ attachmentCache,
+ type AttachmentCache,
+ type CachedAttachment,
+ type PutAttachmentInput,
+} from '../render/attachmentCache';
+export type {
+ GenUIRegistry,
+ GenUIWidgetEntry,
+ GenUIWidgetProps,
+} from './genui';
diff --git a/src/components/SuperChat/plugins/math.tsx b/src/components/SuperChat/plugins/math.tsx
new file mode 100644
index 00000000..3ce4165d
--- /dev/null
+++ b/src/components/SuperChat/plugins/math.tsx
@@ -0,0 +1,80 @@
+/**
+ * SuperChat math plugin (opt-in).
+ *
+ * Renders `$…$` (inline) and `$$…$$` (block) math with `remark-math` +
+ * `rehype-katex` (KaTeX). Consumers must also load KaTeX's stylesheet:
+ *
+ * ```ts
+ * import 'katex/dist/katex.min.css';
+ * ```
+ *
+ * The sanitize allow-list is extended so KaTeX's HTML + MathML output survives
+ * the untrusted-content sanitizer.
+ */
+
+import rehypeKatex from 'rehype-katex';
+import remarkMath from 'remark-math';
+import type { SuperChatRenderPlugin } from '../types';
+
+/** Elements KaTeX emits (HTML + MathML) that must survive sanitization. */
+const KATEX_TAGS = [
+ 'span',
+ 'svg',
+ 'path',
+ 'line',
+ 'math',
+ 'semantics',
+ 'annotation',
+ 'mrow',
+ 'mi',
+ 'mo',
+ 'mn',
+ 'ms',
+ 'mtext',
+ 'msup',
+ 'msub',
+ 'msubsup',
+ 'mfrac',
+ 'msqrt',
+ 'mroot',
+ 'mover',
+ 'munder',
+ 'munderover',
+ 'mtable',
+ 'mtr',
+ 'mtd',
+ 'mspace',
+ 'mpadded',
+ 'mphantom',
+ 'menclose',
+ 'mstyle',
+];
+
+/** Create the math (KaTeX) render plugin. */
+export function createMathPlugin(): SuperChatRenderPlugin {
+ return {
+ name: 'math',
+ remarkPlugins: [remarkMath],
+ // `output: 'htmlAndMathml'` (KaTeX default) gives accessible MathML too.
+ rehypePlugins: [[rehypeKatex, { throwOnError: false }]],
+ sanitizeSchema: {
+ tagNames: KATEX_TAGS,
+ attributes: {
+ '*': ['className'],
+ span: ['className', 'style', 'ariaHidden'],
+ svg: [
+ 'xmlns',
+ 'width',
+ 'height',
+ 'viewBox',
+ 'preserveAspectRatio',
+ 'style',
+ ],
+ path: ['d'],
+ line: ['x1', 'y1', 'x2', 'y2', 'stroke', 'strokeWidth'],
+ math: ['xmlns', 'display'],
+ annotation: ['encoding'],
+ },
+ },
+ };
+}
diff --git a/src/components/SuperChat/plugins/mermaid.tsx b/src/components/SuperChat/plugins/mermaid.tsx
new file mode 100644
index 00000000..634b807f
--- /dev/null
+++ b/src/components/SuperChat/plugins/mermaid.tsx
@@ -0,0 +1,208 @@
+/**
+ * SuperChat Mermaid plugin (opt-in).
+ *
+ * Renders fenced ```mermaid blocks as diagrams via the lazily-loaded `mermaid`
+ * library:
+ *
+ * ````md
+ * ```mermaid
+ * graph TD; A-->B; A-->C;
+ * ```
+ * ````
+ *
+ * `mermaid` is heavy, so it loads on first render (never in the SuperChat base
+ * bundle). Rendering is gated on `streaming` — partial diagram source mid-stream
+ * shows a pending card and only renders once the message settles. Diagrams are
+ * rendered with mermaid's `securityLevel: 'strict'`, which sanitizes labels and
+ * strips scripts; the resulting SVG is inserted directly (it bypasses
+ * `rehype-sanitize`, so strict mode is the trust boundary here).
+ */
+
+import * as React from 'react';
+import { useTextRenderContext } from '../render/renderContext';
+import type { SuperChatRenderPlugin } from '../types';
+
+const MERMAID_TAG = 'mermaid-diagram';
+
+// ---------------------------------------------------------------------------
+// rehype transformer: …
+// → {source text}
+// The source rides as a text child (not a data-* attribute) to avoid hast
+// property-name conversion surprises through sanitize + react-markdown.
+// ---------------------------------------------------------------------------
+
+interface HastNode {
+ type: string;
+ tagName?: string;
+ value?: string;
+ properties?: Record;
+ children?: HastNode[];
+}
+
+function textOf(node: HastNode): string {
+ if (node.type === 'text') return node.value ?? '';
+ return (node.children ?? []).map(textOf).join('');
+}
+
+function isMermaidPre(node: HastNode): boolean {
+ if (node.tagName !== 'pre') return false;
+ const code = node.children?.find((c) => c.tagName === 'code');
+ const className = code?.properties?.className;
+ const classes = Array.isArray(className) ? className : [className];
+ return classes.some(
+ (c) =>
+ typeof c === 'string' && (c === 'language-mermaid' || c === 'mermaid')
+ );
+}
+
+function rehypeMermaid() {
+ return (tree: HastNode) => {
+ const walk = (node: HastNode) => {
+ if (!node.children) return;
+ node.children = node.children.map((child) => {
+ if (isMermaidPre(child)) {
+ const code = child.children?.find((c) => c.tagName === 'code');
+ const raw = code ? textOf(code) : '';
+ return {
+ type: 'element',
+ tagName: MERMAID_TAG,
+ properties: {},
+ children: [{ type: 'text', value: raw }],
+ } satisfies HastNode;
+ }
+ walk(child);
+ return child;
+ });
+ };
+ walk(tree);
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Pending / fallback chrome
+// ---------------------------------------------------------------------------
+
+function PendingCard({ label }: { label: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+function InertFallback({ raw }: { raw: string }) {
+ return (
+
+ {raw}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Diagram renderer
+// ---------------------------------------------------------------------------
+
+let mermaidReady: Promise | null = null;
+let mermaidTheme: 'dark' | 'default' | null = null;
+
+/** Load + initialize mermaid once, shared across all diagrams. */
+function loadMermaid(dark: boolean) {
+ const theme: 'dark' | 'default' = dark ? 'dark' : 'default';
+ if (!mermaidReady || mermaidTheme !== theme) {
+ mermaidReady = (
+ mermaidReady ?? import('mermaid').then(({ default: mermaid }) => mermaid)
+ ).then((mermaid) => {
+ mermaidTheme = theme;
+ mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: 'strict',
+ theme,
+ });
+ return mermaid;
+ });
+ }
+ return mermaidReady;
+}
+
+let mermaidSeq = 0;
+
+function MermaidDiagram({ code }: { code: string }) {
+ const { streaming } = useTextRenderContext();
+ const [svg, setSvg] = React.useState(null);
+ const [failed, setFailed] = React.useState(false);
+ const idRef = React.useRef(`superchat-mermaid-${(mermaidSeq += 1)}`);
+
+ const source = code.trim();
+
+ React.useEffect(() => {
+ // Don't try to render an incomplete diagram while the message streams.
+ if (streaming || !source) return;
+ let active = true;
+ setSvg(null);
+ setFailed(false);
+ const dark =
+ typeof document !== 'undefined' &&
+ document.documentElement.classList.contains('dark');
+ void loadMermaid(dark)
+ .then((mermaid) => mermaid.render(idRef.current, source))
+ .then(({ svg: rendered }) => {
+ if (active) setSvg(rendered);
+ })
+ .catch(() => {
+ if (active) setFailed(true);
+ });
+ return () => {
+ active = false;
+ };
+ }, [source, streaming]);
+
+ if (!source && !streaming) return ;
+
+ if (failed) return ;
+
+ if (streaming || svg === null) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+/** Create the Mermaid diagram render plugin. */
+export function createMermaidPlugin(): SuperChatRenderPlugin {
+ const MermaidComponent = (props: Record) => {
+ const raw = React.Children.toArray(props.children as React.ReactNode)
+ .filter((c): c is string => typeof c === 'string')
+ .join('');
+ return ;
+ };
+
+ return {
+ name: 'mermaid',
+ rehypePlugins: [rehypeMermaid],
+ components: {
+ [MERMAID_TAG]: MermaidComponent,
+ },
+ sanitizeSchema: {
+ tagNames: [MERMAID_TAG],
+ },
+ };
+}
+export { MERMAID_TAG };
diff --git a/src/components/SuperChat/plugins/nitroTable.tsx b/src/components/SuperChat/plugins/nitroTable.tsx
new file mode 100644
index 00000000..358cf7ba
--- /dev/null
+++ b/src/components/SuperChat/plugins/nitroTable.tsx
@@ -0,0 +1,155 @@
+/**
+ * SuperChat NITRO table plugin (opt-in).
+ *
+ * Renders GFM tables through **NITRO DataVis** (sortable / filterable /
+ * resizable) instead of a static ``. The heavy grid is `React.lazy`-loaded
+ * only when a table appears, and if the `datavis` engine can't load (e.g. the
+ * submodule isn't installed) it **degrades gracefully** to the themed HTML table.
+ *
+ * @example
+ * import { createNitroTablePlugin } from '@mieweb/ui/components/SuperChat/plugins';
+ * const render = createMarkdownRenderer({ plugins: [createNitroTablePlugin()] });
+ */
+
+import * as React from 'react';
+import { cn } from '../../../utils/cn';
+import type { SuperChatRenderPlugin } from '../types';
+
+const NitroTableGrid = React.lazy(() => import('./nitroTableGrid'));
+
+// ---------------------------------------------------------------------------
+// GFM table extraction from the hast node
+// ---------------------------------------------------------------------------
+
+interface HastNode {
+ type: string;
+ tagName?: string;
+ value?: string;
+ children?: HastNode[];
+}
+
+function textOf(node: HastNode): string {
+ if (node.type === 'text') return node.value ?? '';
+ return (node.children ?? []).map(textOf).join('');
+}
+
+function rowsOf(parent: HastNode | undefined): HastNode[] {
+ return (parent?.children ?? []).filter((c) => c.tagName === 'tr');
+}
+
+function cellsOf(row: HastNode, tag: 'th' | 'td'): HastNode[] {
+ return (row.children ?? []).filter((c) => c.tagName === tag);
+}
+
+interface ParsedTable {
+ headers: string[];
+ rows: Array>;
+}
+
+function parseTable(node: HastNode | undefined): ParsedTable | null {
+ if (!node || node.tagName !== 'table') return null;
+
+ const thead = node.children?.find((c) => c.tagName === 'thead');
+ const tbody = node.children?.find((c) => c.tagName === 'tbody');
+
+ const headerRow = rowsOf(thead)[0];
+ if (!headerRow) return null;
+
+ const headers = cellsOf(headerRow, 'th').map((c) => textOf(c).trim());
+ if (headers.length === 0) return null;
+
+ // De-duplicate header keys so row objects don't collide.
+ const seen = new Map();
+ const keys = headers.map((h) => {
+ const base = h || 'column';
+ const count = seen.get(base) ?? 0;
+ seen.set(base, count + 1);
+ return count === 0 ? base : `${base} (${count + 1})`;
+ });
+
+ const rows = rowsOf(tbody).map((tr) => {
+ const cells = cellsOf(tr, 'td');
+ const row: Record = {};
+ keys.forEach((key, i) => {
+ row[key] = cells[i] ? textOf(cells[i]).trim() : '';
+ });
+ return row;
+ });
+
+ return { headers: keys, rows };
+}
+
+// ---------------------------------------------------------------------------
+// Fallback HTML table (also the graceful-degradation target)
+// ---------------------------------------------------------------------------
+
+function HtmlTable({
+ node: _node,
+ ...props
+}: React.ComponentProps<'table'> & { node?: unknown }) {
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Error boundary → degrade to HTML table if the grid fails to load/render
+// ---------------------------------------------------------------------------
+
+class GridErrorBoundary extends React.Component<
+ { fallback: React.ReactNode; children: React.ReactNode },
+ { failed: boolean }
+> {
+ state = { failed: false };
+
+ static getDerivedStateFromError() {
+ return { failed: true };
+ }
+
+ render() {
+ if (this.state.failed) return this.props.fallback;
+ return this.props.children;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Plugin factory
+// ---------------------------------------------------------------------------
+
+/** Create the NITRO DataVis table render plugin. */
+export function createNitroTablePlugin(): SuperChatRenderPlugin {
+ const NitroTable = (props: Record) => {
+ const node = props.node as HastNode | undefined;
+ const fallback = (
+ & { node?: unknown })}
+ />
+ );
+
+ const parsed = React.useMemo(() => parseTable(node), [node]);
+ if (!parsed) return fallback;
+
+ return (
+
+
+
+
+
+ );
+ };
+
+ return {
+ name: 'nitro-table',
+ components: {
+ table: NitroTable,
+ },
+ };
+}
diff --git a/src/components/SuperChat/plugins/nitroTableGrid.tsx b/src/components/SuperChat/plugins/nitroTableGrid.tsx
new file mode 100644
index 00000000..b7a975cc
--- /dev/null
+++ b/src/components/SuperChat/plugins/nitroTableGrid.tsx
@@ -0,0 +1,43 @@
+/**
+ * SuperChat NITRO table grid (lazy chunk).
+ *
+ * Default-exported so it can be pulled in with `React.lazy` only when a GFM
+ * table actually renders — this keeps the heavy `datavis` / `datavis-ace`
+ * dependencies out of the SuperChat (and Markdown core) bundle.
+ *
+ * Data is handed to {@link DataVisNitroSource} through a short-lived object-URL
+ * `http` source carrying the `{ typeInfo, data }` shape the engine expects.
+ */
+
+import * as React from 'react';
+import { DataVisNitroGrid, DataVisNitroSource } from '../../DataVisNITRO';
+
+export interface NitroTableGridProps {
+ /** Column headers, in order. */
+ headers: string[];
+ /** Row objects keyed by header. */
+ rows: Array>;
+}
+
+export default function NitroTableGrid({ headers, rows }: NitroTableGridProps) {
+ const url = React.useMemo(() => {
+ const payload = {
+ typeInfo: headers.map((field) => ({ field, type: 'string' })),
+ data: rows,
+ };
+ const blob = new Blob([JSON.stringify(payload)], {
+ type: 'application/json',
+ });
+ return URL.createObjectURL(blob);
+ }, [headers, rows]);
+
+ React.useEffect(() => () => URL.revokeObjectURL(url), [url]);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/SuperChat/render/attachmentCache.test.ts b/src/components/SuperChat/render/attachmentCache.test.ts
new file mode 100644
index 00000000..2fb5fba0
--- /dev/null
+++ b/src/components/SuperChat/render/attachmentCache.test.ts
@@ -0,0 +1,109 @@
+import 'fake-indexeddb/auto';
+import { describe, it, expect, beforeEach } from 'vitest';
+import { attachmentCache } from './attachmentCache';
+
+// `fake-indexeddb/auto` polyfills a real IndexedDB implementation for this file
+// only (vitest isolates modules per test file), so the cache exercises its true
+// IDB code paths here — eviction included.
+
+function dataUrl(bytes: number): string {
+ // base64 of `bytes` zero bytes → "AAAA..." (3 bytes per 4 chars).
+ const raw = '\u0000'.repeat(bytes);
+ return `data:application/octet-stream;base64,${btoa(raw)}`;
+}
+
+describe('attachmentCache (IndexedDB)', () => {
+ beforeEach(async () => {
+ attachmentCache.configure({ maxBytes: Infinity });
+ await attachmentCache.clear();
+ });
+
+ it('is available with a backing store', () => {
+ expect(attachmentCache.isAvailable()).toBe(true);
+ });
+
+ it('stores and reads back a blob by id', async () => {
+ const ok = await attachmentCache.put({
+ id: 'a1',
+ name: 'a.bin',
+ type: 'application/octet-stream',
+ dataUrl: dataUrl(30),
+ });
+ expect(ok).toBe(true);
+
+ const entry = await attachmentCache.get('a1');
+ expect(entry?.name).toBe('a.bin');
+ expect(entry?.size).toBe(30);
+ expect(entry?.blob).toBeDefined();
+ expect(await attachmentCache.usage()).toBe(30);
+ });
+
+ it('resolves an object URL for a cached entry', async () => {
+ await attachmentCache.put({
+ id: 'u1',
+ name: 'u.bin',
+ type: 'application/octet-stream',
+ dataUrl: dataUrl(12),
+ });
+ const url = await attachmentCache.getObjectURL('u1');
+ expect(url).toMatch(/^blob:/);
+ expect(await attachmentCache.getObjectURL('missing')).toBeUndefined();
+ });
+
+ it('deletes and clears entries', async () => {
+ await attachmentCache.put({
+ id: 'd1',
+ name: 'd.bin',
+ type: 'application/octet-stream',
+ dataUrl: dataUrl(9),
+ });
+ await attachmentCache.delete('d1');
+ expect(await attachmentCache.get('d1')).toBeUndefined();
+ expect(await attachmentCache.usage()).toBe(0);
+ });
+
+ it('evicts least-recently-used entries past the size budget', async () => {
+ attachmentCache.configure({ maxBytes: 90 });
+
+ // Three 40-byte entries = 120 bytes; budget is 90, so one must be evicted.
+ await attachmentCache.put({
+ id: 'old',
+ name: 'old.bin',
+ type: 'application/octet-stream',
+ dataUrl: dataUrl(40),
+ });
+ await attachmentCache.put({
+ id: 'mid',
+ name: 'mid.bin',
+ type: 'application/octet-stream',
+ dataUrl: dataUrl(40),
+ });
+
+ // Touch `old` so `mid` becomes the least-recently-used.
+ await attachmentCache.get('old');
+
+ await attachmentCache.put({
+ id: 'new',
+ name: 'new.bin',
+ type: 'application/octet-stream',
+ dataUrl: dataUrl(40),
+ });
+
+ // `mid` (LRU) is evicted; `old` and `new` survive within budget.
+ expect(await attachmentCache.get('mid')).toBeUndefined();
+ expect(await attachmentCache.get('old')).toBeDefined();
+ expect(await attachmentCache.get('new')).toBeDefined();
+ expect(await attachmentCache.usage()).toBeLessThanOrEqual(90);
+ });
+
+ it('keeps the newest entry even when it alone exceeds the budget', async () => {
+ attachmentCache.configure({ maxBytes: 10 });
+ await attachmentCache.put({
+ id: 'big',
+ name: 'big.bin',
+ type: 'application/octet-stream',
+ dataUrl: dataUrl(60),
+ });
+ expect(await attachmentCache.get('big')).toBeDefined();
+ });
+});
diff --git a/src/components/SuperChat/render/attachmentCache.ts b/src/components/SuperChat/render/attachmentCache.ts
new file mode 100644
index 00000000..c2e15e0b
--- /dev/null
+++ b/src/components/SuperChat/render/attachmentCache.ts
@@ -0,0 +1,347 @@
+/**
+ * SuperChat attachment cache (opt-in, offline-first).
+ *
+ * A tiny IndexedDB-backed blob store keyed by **attachment id**. Hosts persist
+ * a composer attachment's bytes here once (e.g. on send), embed only the id in
+ * the message, and the attachment render plugin resolves a fresh `blob:` URL
+ * from the cache at render time — so previously sent media keeps rendering
+ * while offline without bloating the stored conversation with base64.
+ *
+ * Everything degrades gracefully: when IndexedDB is unavailable (SSR, private
+ * mode, older browsers) the methods resolve to safe no-op values and callers
+ * fall back to any inline `src` they were given.
+ */
+
+const DB_NAME = 'mieweb-superchat';
+const STORE = 'attachments';
+/** Lightweight {id,size,lastAccessed} mirror, read during eviction sweeps. */
+const META_STORE = 'attachments_meta';
+const DB_VERSION = 2;
+
+/** Default cache budget: evict least-recently-used entries beyond this. */
+const DEFAULT_MAX_BYTES = 100 * 1024 * 1024; // 100 MB
+let maxBytes = DEFAULT_MAX_BYTES;
+
+/** A blob persisted in the cache, plus its descriptive metadata. */
+export interface CachedAttachment {
+ /** Stable attachment id (the object-store key). */
+ id: string;
+ /** Original file name. */
+ name: string;
+ /** MIME type, e.g. `video/mp4`. */
+ type: string;
+ /** The raw file bytes. */
+ blob: Blob;
+ /** Byte length of {@link blob}. */
+ size: number;
+ /** Epoch ms the entry was written. */
+ cachedAt: number;
+ /** Epoch ms of the last read (drives LRU eviction). */
+ lastAccessed: number;
+}
+
+/** Small metadata mirror used to size the cache without loading blobs. */
+interface MetaEntry {
+ id: string;
+ size: number;
+ lastAccessed: number;
+}
+
+/** Input accepted by {@link AttachmentCache.put}. Provide `blob` or `dataUrl`. */
+export interface PutAttachmentInput {
+ id: string;
+ name: string;
+ type: string;
+ blob?: Blob;
+ /** Base64 (or URL-encoded) `data:` URL; converted to a Blob before storage. */
+ dataUrl?: string;
+}
+
+function hasIndexedDB(): boolean {
+ return typeof window !== 'undefined' && !!window.indexedDB;
+}
+
+function openDB(): Promise {
+ if (!hasIndexedDB()) return Promise.resolve(null);
+ return new Promise((resolve) => {
+ let req: IDBOpenDBRequest;
+ try {
+ req = window.indexedDB.open(DB_NAME, DB_VERSION);
+ } catch {
+ resolve(null);
+ return;
+ }
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(STORE)) {
+ db.createObjectStore(STORE, { keyPath: 'id' });
+ }
+ if (!db.objectStoreNames.contains(META_STORE)) {
+ db.createObjectStore(META_STORE, { keyPath: 'id' });
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => resolve(null);
+ });
+}
+
+function tx(db: IDBDatabase, mode: IDBTransactionMode): IDBObjectStore {
+ return db.transaction(STORE, mode).objectStore(STORE);
+}
+
+/** Read the (small) metadata mirror for every cached entry. */
+function readAllMeta(db: IDBDatabase): Promise {
+ return new Promise((resolve) => {
+ try {
+ const req = db
+ .transaction(META_STORE, 'readonly')
+ .objectStore(META_STORE)
+ .getAll();
+ req.onsuccess = () => resolve((req.result as MetaEntry[]) ?? []);
+ req.onerror = () => resolve([]);
+ } catch {
+ resolve([]);
+ }
+ });
+}
+
+/** Bump an entry's `lastAccessed` time (best-effort, for LRU ordering). */
+async function touch(id: string): Promise {
+ const db = await openDB();
+ if (!db) return;
+ await new Promise((resolve) => {
+ try {
+ const store = db
+ .transaction(META_STORE, 'readwrite')
+ .objectStore(META_STORE);
+ const getReq = store.get(id);
+ getReq.onsuccess = () => {
+ const meta = getReq.result as MetaEntry | undefined;
+ if (meta) {
+ meta.lastAccessed = Date.now();
+ store.put(meta);
+ }
+ resolve();
+ };
+ getReq.onerror = () => resolve();
+ } catch {
+ resolve();
+ } finally {
+ db.close();
+ }
+ });
+}
+
+/** Evict least-recently-used entries until total bytes fit within `maxBytes`. */
+async function prune(): Promise {
+ const db = await openDB();
+ if (!db) return;
+ try {
+ const metas = await readAllMeta(db);
+ let total = metas.reduce((sum, m) => sum + (m.size || 0), 0);
+ if (total <= maxBytes) return;
+
+ // Oldest first; always keep at least the single newest entry.
+ const byAge = metas
+ .slice()
+ .sort((a, b) => (a.lastAccessed || 0) - (b.lastAccessed || 0));
+ const victims: string[] = [];
+ for (const meta of byAge) {
+ if (total <= maxBytes) break;
+ if (metas.length - victims.length <= 1) break;
+ victims.push(meta.id);
+ total -= meta.size || 0;
+ }
+ if (!victims.length) return;
+
+ await new Promise((resolve) => {
+ try {
+ const t = db.transaction([STORE, META_STORE], 'readwrite');
+ const blobs = t.objectStore(STORE);
+ const meta = t.objectStore(META_STORE);
+ for (const id of victims) {
+ blobs.delete(id);
+ meta.delete(id);
+ }
+ t.oncomplete = () => resolve();
+ t.onerror = () => resolve();
+ } catch {
+ resolve();
+ }
+ });
+ } finally {
+ db.close();
+ }
+}
+
+/** Convert a `data:` URL into a Blob, or `null` if it can't be parsed. */
+function dataUrlToBlob(dataUrl: string): Blob | null {
+ const match = /^data:([^;,]*)(;base64)?,([\s\S]*)$/.exec(dataUrl);
+ if (!match) return null;
+ const mime = match[1] || 'application/octet-stream';
+ const isBase64 = Boolean(match[2]);
+ const data = match[3];
+ try {
+ if (isBase64) {
+ const binary = window.atob(data);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return new window.Blob([bytes], { type: mime });
+ }
+ return new window.Blob([decodeURIComponent(data)], { type: mime });
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Offline blob store for SuperChat attachments. All methods are safe to call in
+ * any environment; without IndexedDB they resolve to no-ops / `undefined`.
+ */
+export const attachmentCache = {
+ /** Whether a persistent IndexedDB store is available in this environment. */
+ isAvailable: hasIndexedDB,
+
+ /**
+ * Tune the cache. `maxBytes` is the eviction budget (default 100 MB); when a
+ * `put` pushes the total past it, least-recently-used entries are dropped
+ * until it fits. Set `Infinity` to disable eviction.
+ */
+ configure(options: { maxBytes?: number }): void {
+ if (typeof options.maxBytes === 'number' && options.maxBytes >= 0) {
+ maxBytes = options.maxBytes;
+ }
+ },
+
+ /** Total bytes currently held by the cache (0 when unavailable). */
+ async usage(): Promise {
+ const db = await openDB();
+ if (!db) return 0;
+ try {
+ const metas = await readAllMeta(db);
+ return metas.reduce((sum, m) => sum + (m.size || 0), 0);
+ } finally {
+ db.close();
+ }
+ },
+
+ /**
+ * Persist an attachment's bytes by id. Pass a `blob` or a `dataUrl`. Resolves
+ * to `true` when stored, `false` if unavailable or the input was unusable.
+ * Writing past the configured `maxBytes` evicts least-recently-used entries.
+ */
+ async put(input: PutAttachmentInput): Promise {
+ const blob =
+ input.blob ?? (input.dataUrl ? dataUrlToBlob(input.dataUrl) : null);
+ if (!blob) return false;
+ const db = await openDB();
+ if (!db) return false;
+ const now = Date.now();
+ const entry: CachedAttachment = {
+ id: input.id,
+ name: input.name,
+ type: input.type || blob.type || 'application/octet-stream',
+ blob,
+ size: blob.size,
+ cachedAt: now,
+ lastAccessed: now,
+ };
+ const stored = await new Promise((resolve) => {
+ try {
+ const t = db.transaction([STORE, META_STORE], 'readwrite');
+ t.objectStore(STORE).put(entry);
+ t.objectStore(META_STORE).put({
+ id: entry.id,
+ size: entry.size,
+ lastAccessed: now,
+ } satisfies MetaEntry);
+ t.oncomplete = () => resolve(true);
+ t.onerror = () => resolve(false);
+ } catch {
+ resolve(false);
+ } finally {
+ db.close();
+ }
+ });
+ if (stored) await prune();
+ return stored;
+ },
+
+ /** Read a cached attachment (blob + metadata) by id, or `undefined`. */
+ async get(id: string): Promise {
+ const db = await openDB();
+ if (!db) return undefined;
+ const entry = await new Promise((resolve) => {
+ try {
+ const req = tx(db, 'readonly').get(id);
+ req.onsuccess = () =>
+ resolve((req.result as CachedAttachment | undefined) ?? undefined);
+ req.onerror = () => resolve(undefined);
+ } catch {
+ resolve(undefined);
+ } finally {
+ db.close();
+ }
+ });
+ if (entry) void touch(id);
+ return entry;
+ },
+
+ /**
+ * Resolve a fresh `blob:` object URL for a cached attachment, or `undefined`
+ * if it isn't cached. **The caller owns the URL** and must
+ * `URL.revokeObjectURL` it when done (the {@link useAttachmentUrl} hook does
+ * this automatically).
+ */
+ async getObjectURL(id: string): Promise {
+ const entry = await attachmentCache.get(id);
+ if (!entry) return undefined;
+ try {
+ return window.URL.createObjectURL(entry.blob);
+ } catch {
+ return undefined;
+ }
+ },
+
+ /** Remove a cached attachment by id. */
+ async delete(id: string): Promise {
+ const db = await openDB();
+ if (!db) return;
+ await new Promise((resolve) => {
+ try {
+ const t = db.transaction([STORE, META_STORE], 'readwrite');
+ t.objectStore(STORE).delete(id);
+ t.objectStore(META_STORE).delete(id);
+ t.oncomplete = () => resolve();
+ t.onerror = () => resolve();
+ } catch {
+ resolve();
+ } finally {
+ db.close();
+ }
+ });
+ },
+
+ /** Drop every cached attachment. */
+ async clear(): Promise {
+ const db = await openDB();
+ if (!db) return;
+ await new Promise((resolve) => {
+ try {
+ const t = db.transaction([STORE, META_STORE], 'readwrite');
+ t.objectStore(STORE).clear();
+ t.objectStore(META_STORE).clear();
+ t.oncomplete = () => resolve();
+ t.onerror = () => resolve();
+ } catch {
+ resolve();
+ } finally {
+ db.close();
+ }
+ });
+ },
+};
+
+export type AttachmentCache = typeof attachmentCache;
diff --git a/src/components/SuperChat/render/createMarkdownRenderer.tsx b/src/components/SuperChat/render/createMarkdownRenderer.tsx
new file mode 100644
index 00000000..795f432f
--- /dev/null
+++ b/src/components/SuperChat/render/createMarkdownRenderer.tsx
@@ -0,0 +1,387 @@
+/**
+ * SuperChat Markdown render composer.
+ *
+ * Composes a list of {@link SuperChatRenderPlugin}s into a single
+ * {@link AIRenderTextContent} implementation that plugs into the AI module's
+ * `renderTextContent` seam. Ships **Markdown core** (GFM) with `rehype-sanitize`
+ * applied to untrusted model/agent output. Math / code / GenUI / NITRO / mermaid
+ * are added by passing the corresponding opt-in plugins.
+ *
+ * The host owns the trust boundary: untrusted content is always sanitized via an
+ * allow-list schema, extended by each plugin's `sanitizeSchema` so token
+ * classNames (syntax highlighting) and KaTeX markup survive.
+ */
+
+import * as React from 'react';
+import Markdown, {
+ defaultUrlTransform,
+ type Components,
+ type Options,
+} from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
+import { cn } from '../../../utils/cn';
+import { TextRenderContext } from './renderContext';
+import type {
+ AIRenderTextContent,
+ AITextRenderContext,
+ SuperChatRenderPlugin,
+} from '../types';
+
+type PluggableList = NonNullable;
+type SanitizeSchema = Record;
+
+/**
+ * Minimal mdast node shape we touch when splitting soft line breaks. Kept local
+ * so the renderer stays free of extra `unist`/`mdast` util dependencies.
+ */
+interface MdastNode {
+ type: string;
+ value?: string;
+ children?: MdastNode[];
+}
+
+/**
+ * A self-contained `remark-breaks` equivalent: turns soft line breaks (single
+ * `\n` inside a paragraph) into hard breaks so chat messages preserve the
+ * newlines the author typed — the expected behavior for messaging UIs, where
+ * Markdown's default "collapse single newlines to spaces" is surprising.
+ *
+ * Only `text` nodes are split, so fenced/inline code (which carry their value
+ * on non-`text` nodes) and existing hard breaks are left untouched.
+ */
+function remarkPreserveLineBreaks() {
+ const splitNode = (node: MdastNode): void => {
+ if (!node.children) return;
+ const next: MdastNode[] = [];
+ for (const child of node.children) {
+ if (child.type === 'text' && child.value && child.value.includes('\n')) {
+ const segments = child.value.split('\n');
+ segments.forEach((segment, i) => {
+ if (i > 0) next.push({ type: 'break' });
+ if (segment) next.push({ type: 'text', value: segment });
+ });
+ } else {
+ splitNode(child);
+ next.push(child);
+ }
+ }
+ node.children = next;
+ };
+ return (tree: MdastNode) => splitNode(tree);
+}
+
+/**
+ * A sanitize schema that, on top of the library default, lets syntax-highlight
+ * token classes (`hljs-*`) and `language-*` survive on `code`/`pre`/`span`.
+ */
+function baseSanitizeSchema(): SanitizeSchema {
+ const schema = JSON.parse(JSON.stringify(defaultSchema)) as SanitizeSchema;
+ const attributes = (schema.attributes ?? {}) as Record;
+
+ const allowClass = (tag: string) => {
+ const existing = (attributes[tag] ?? []) as unknown[];
+ // Drop any prior restricted className rule, allow className broadly.
+ const withoutClass = existing.filter(
+ (a) => !(Array.isArray(a) && a[0] === 'className')
+ );
+ attributes[tag] = [...withoutClass, 'className'];
+ };
+
+ allowClass('code');
+ allowClass('pre');
+ allowClass('span');
+ allowClass('div');
+
+ schema.attributes = attributes;
+ return schema;
+}
+
+/** Shallow-merge a plugin's sanitize contribution into the running schema. */
+function mergeSanitizeSchema(
+ base: SanitizeSchema,
+ extra?: SanitizeSchema
+): SanitizeSchema {
+ if (!extra) return base;
+ const out: SanitizeSchema = { ...base };
+
+ // tagNames: union
+ if (Array.isArray(extra.tagNames)) {
+ const a = (base.tagNames as string[] | undefined) ?? [];
+ out.tagNames = Array.from(new Set([...a, ...(extra.tagNames as string[])]));
+ }
+
+ // attributes: per-tag concat
+ if (extra.attributes && typeof extra.attributes === 'object') {
+ const baseAttrs = {
+ ...((base.attributes as Record) ?? {}),
+ };
+ for (const [tag, attrs] of Object.entries(
+ extra.attributes as Record
+ )) {
+ baseAttrs[tag] = [...(baseAttrs[tag] ?? []), ...attrs];
+ }
+ out.attributes = baseAttrs;
+ }
+
+ // protocols: per-attribute union (e.g. allow `data:`/`blob:` image src)
+ if (extra.protocols && typeof extra.protocols === 'object') {
+ const baseProtocols = {
+ ...((base.protocols as Record) ?? {}),
+ };
+ for (const [attr, protocols] of Object.entries(
+ extra.protocols as Record
+ )) {
+ baseProtocols[attr] = Array.from(
+ new Set([...(baseProtocols[attr] ?? []), ...protocols])
+ );
+ }
+ out.protocols = baseProtocols;
+ }
+
+ // any other keys (clobber)
+ for (const [k, v] of Object.entries(extra)) {
+ if (k === 'tagNames' || k === 'attributes' || k === 'protocols') continue;
+ out[k] = v;
+ }
+ return out;
+}
+
+/** Default node components, themed with `--mieweb-*` tokens via Tailwind. */
+function baseComponents(): Components {
+ return {
+ a: ({ node: _node, children, ...props }) => (
+
+ {children}
+
+ ),
+ pre: ({ node: _node, ...props }) => (
+
+ ),
+ code: ({ node: _node, ...props }) => {
+ const isBlock =
+ typeof props.className === 'string' &&
+ props.className.includes('language-');
+ return (
+
+ );
+ },
+ table: ({ node: _node, ...props }) => (
+
+ ),
+ // Tailwind's preflight resets list markers/padding; restore them explicitly
+ // so bullets/numbers show without depending on the typography plugin.
+ ul: ({ node: _node, ...props }) => (
+
+ ),
+ ol: ({ node: _node, ...props }) => (
+
+ ),
+ li: ({ node: _node, ...props }) => (
+
+ ),
+ // Preflight also flattens headings to body size/weight; restore a sensible
+ // hierarchy without depending on the typography plugin.
+ h1: ({ node: _node, children, ...props }) => (
+
+ {children}
+
+ ),
+ h2: ({ node: _node, children, ...props }) => (
+
+ {children}
+
+ ),
+ h3: ({ node: _node, children, ...props }) => (
+
+ {children}
+
+ ),
+ h4: ({ node: _node, children, ...props }) => (
+
+ {children}
+
+ ),
+ h5: ({ node: _node, children, ...props }) => (
+
+ {children}
+
+ ),
+ h6: ({ node: _node, children, ...props }) => (
+
+ {children}
+
+ ),
+ hr: ({ node: _node, ...props }) => (
+
+ ),
+ };
+}
+
+export interface CreateMarkdownRendererOptions {
+ /** Opt-in rich plugins (code, math, genui, nitro, mermaid, …). */
+ plugins?: SuperChatRenderPlugin[];
+ /**
+ * Disable sanitization. **Only** set this when the content is fully trusted
+ * (e.g. authored by the host, never model output). Defaults to `false`.
+ */
+ trusted?: boolean;
+}
+
+/**
+ * Build an {@link AIRenderTextContent} from a set of render plugins.
+ *
+ * @example
+ * const render = createMarkdownRenderer({ plugins: [createCodePlugin(), createMathPlugin()] });
+ *
+ */
+export function createMarkdownRenderer(
+ options: CreateMarkdownRendererOptions = {}
+): AIRenderTextContent {
+ const { plugins = [], trusted = false } = options;
+
+ const remarkPlugins: PluggableList = [
+ remarkGfm,
+ remarkPreserveLineBreaks,
+ ...plugins.flatMap((p) => (p.remarkPlugins ?? []) as PluggableList),
+ ];
+
+ const pluginRehype: PluggableList = plugins.flatMap(
+ (p) => (p.rehypePlugins ?? []) as PluggableList
+ );
+
+ const sanitizeSchema = plugins.reduce(
+ (acc, p) => mergeSanitizeSchema(acc, p.sanitizeSchema),
+ baseSanitizeSchema()
+ );
+
+ // Sanitize must run AFTER highlight/katex so their classNames are present to
+ // be allow-listed, then stripped of anything not permitted.
+ const rehypePlugins: PluggableList = trusted
+ ? pluginRehype
+ : [...pluginRehype, [rehypeSanitize, sanitizeSchema]];
+
+ const components: Components = plugins.reduce(
+ (acc, p) => ({ ...acc, ...(p.components as Components | undefined) }),
+ baseComponents()
+ );
+
+ const MarkdownRenderer = (text: string, ctx: AITextRenderContext) => (
+
+ );
+
+ return MarkdownRenderer;
+}
+
+/**
+ * react-markdown's default `urlTransform` drops `data:`/`blob:` URLs, which
+ * would strip pasted/attached images before sanitization runs. Permit those
+ * two protocols for image `src` only (sanitization still gates the final
+ * markup); everything else keeps react-markdown's safe defaults.
+ */
+const transformUrl: NonNullable = (url, key, node) => {
+ if (
+ key === 'src' &&
+ node.tagName === 'img' &&
+ (/^data:image\//i.test(url) || /^blob:/i.test(url))
+ ) {
+ return url;
+ }
+ return defaultUrlTransform(url);
+};
+
+interface MarkdownContentProps {
+ text: string;
+ messageId: string;
+ streaming: boolean;
+ remarkPlugins: Options['remarkPlugins'];
+ rehypePlugins: Options['rehypePlugins'];
+ components: Components;
+}
+
+const MarkdownContent = React.memo(function MarkdownContent({
+ text,
+ messageId,
+ streaming,
+ remarkPlugins,
+ rehypePlugins,
+ components,
+}: MarkdownContentProps) {
+ return (
+
+
+ {text}
+
+
+ );
+});
diff --git a/src/components/SuperChat/render/renderContext.ts b/src/components/SuperChat/render/renderContext.ts
new file mode 100644
index 00000000..4b40f9a5
--- /dev/null
+++ b/src/components/SuperChat/render/renderContext.ts
@@ -0,0 +1,22 @@
+/**
+ * Render-time context threaded from the message into custom Markdown nodes
+ * (e.g. GenUI widgets need `messageId` + `streaming` for their `meta` and for
+ * gating mount/data-fetch while a message is still streaming).
+ */
+
+import * as React from 'react';
+
+export interface SuperChatTextContext {
+ /** id of the parent message (stable cache key). */
+ messageId: string;
+ /** true while the parent message is still streaming. */
+ streaming: boolean;
+}
+
+export const TextRenderContext = React.createContext({
+ messageId: '',
+ streaming: false,
+});
+
+export const useTextRenderContext = (): SuperChatTextContext =>
+ React.useContext(TextRenderContext);
diff --git a/src/components/SuperChat/storyData.tsx b/src/components/SuperChat/storyData.tsx
new file mode 100644
index 00000000..875497e6
--- /dev/null
+++ b/src/components/SuperChat/storyData.tsx
@@ -0,0 +1,314 @@
+import * as React from 'react';
+import type {
+ GenUIRegistry,
+ GenUIWidgetProps,
+ SuperChatConversation,
+} from './index';
+
+// ============================================================================
+// Sample participants
+// ============================================================================
+
+export const participants = {
+ me: {
+ id: 'u1',
+ kind: 'human' as const,
+ name: 'Dr. Alice Reyes',
+ role: 'Provider',
+ color: '#0e7490',
+ },
+ nurse: {
+ id: 'u2',
+ kind: 'human' as const,
+ name: 'Sam Carter',
+ role: 'Nurse',
+ color: '#9333ea',
+ },
+ triage: {
+ id: 'a1',
+ kind: 'agent' as const,
+ name: 'Triage Agent',
+ color: '#2563eb',
+ },
+ coder: {
+ id: 'a2',
+ kind: 'agent' as const,
+ name: 'Coding Agent',
+ color: '#16a34a',
+ },
+ system: { id: 's1', kind: 'system' as const, name: 'System' },
+};
+
+// ============================================================================
+// Sample conversations
+// ============================================================================
+
+export const conversation: SuperChatConversation = {
+ id: 'c1',
+ title: 'Patient 4821 — Intake review',
+ reference_id: 'patient/4821',
+ unread: 0,
+ participants: Object.values(participants),
+ thread: [
+ {
+ id: 'm0',
+ type: 'system',
+ participantId: 's1',
+ text: 'Triage Agent and Coding Agent joined the conversation.',
+ time: '2026-06-07T09:00:00Z',
+ },
+ {
+ id: 'm1',
+ participantId: 'u1',
+ text: '@Triage can you summarize the **chief complaint** and flag anything urgent?',
+ mentions: ['a1'],
+ time: '2026-06-07T09:01:00Z',
+ },
+ {
+ id: 'm2',
+ participantId: 'a1',
+ text: [
+ 'Summary of the intake:',
+ '',
+ '- **Chief complaint:** chest tightness on exertion',
+ '- **Duration:** 3 days',
+ '- **Risk flags:** family history of CAD',
+ '',
+ '> Recommend prioritizing an ECG.',
+ ].join('\n'),
+ time: '2026-06-07T09:01:30Z',
+ },
+ {
+ id: 'm3',
+ participantId: 'u2',
+ text: 'Thanks. @Coding what CPT applies to a 12-lead ECG with interpretation?',
+ mentions: ['a2'],
+ time: '2026-06-07T09:02:00Z',
+ },
+ {
+ id: 'm4',
+ participantId: 'a2',
+ text: [
+ 'For a 12-lead ECG with interpretation and report, use **93000**.',
+ '',
+ '```javascript',
+ "const code = '93000';",
+ 'console.log(`CPT ${code}: ECG, complete`);',
+ '```',
+ ].join('\n'),
+ time: '2026-06-07T09:02:30Z',
+ },
+ {
+ id: 'm5',
+ participantId: 'a1',
+ text: [
+ 'Risk score uses the standard quadratic term:',
+ '',
+ '$$ risk = \\beta_0 + \\beta_1 x + \\beta_2 x^2 $$',
+ '',
+ 'Inline too: the threshold is $x > 0.7$.',
+ ].join('\n'),
+ time: '2026-06-07T09:03:00Z',
+ },
+ {
+ id: 'm6',
+ participantId: 'a2',
+ text: [
+ 'Here is an interactive widget:',
+ '',
+ '```genui',
+ '{ "widget": "kpi_card", "version": 1, "prefetch": "eager", "props": { "label": "Risk", "value": "High", "trend": "+12%" } }',
+ '```',
+ ].join('\n'),
+ time: '2026-06-07T09:03:30Z',
+ },
+ {
+ id: 'm7',
+ type: 'ref',
+ participantId: 'a1',
+ ref: { refType: 'doc', refId: 'doc-991', title: 'ECG protocol (PDF)' },
+ time: '2026-06-07T09:04:00Z',
+ },
+ ],
+};
+
+// A richer thread that also exercises the mermaid, NITRO-table, and image
+// plugins (used by the WithRichPlugins / ReadOnly stories).
+export const richConversation: SuperChatConversation = {
+ ...conversation,
+ id: 'c1-rich',
+ thread: [
+ ...conversation.thread,
+ {
+ id: 'm8',
+ participantId: 'a1',
+ text: [
+ 'Proposed triage flow:',
+ '',
+ '```mermaid',
+ 'graph TD',
+ ' A[Intake] --> B{Chest pain?}',
+ ' B -- Yes --> C[Order ECG]',
+ ' B -- No --> D[Routine review]',
+ ' C --> E[Provider review]',
+ '```',
+ ].join('\n'),
+ time: '2026-06-07T09:05:00Z',
+ },
+ {
+ id: 'm9',
+ participantId: 'a2',
+ text: [
+ 'Candidate codes:',
+ '',
+ '| Code | Description | Modifier |',
+ '| --- | --- | --- |',
+ '| 93000 | ECG, complete | — |',
+ '| 93005 | ECG, tracing only | TC |',
+ '| 93010 | ECG, interpretation | 26 |',
+ ].join('\n'),
+ time: '2026-06-07T09:05:30Z',
+ },
+ {
+ id: 'm10',
+ participantId: 'u2',
+ text: [
+ 'Here is the rhythm strip — click to enlarge:',
+ '',
+ '',
+ ].join('\n'),
+ time: '2026-06-07T09:06:00Z',
+ },
+ ],
+};
+
+// A small second conversation so the list/inbox stories show more than one row.
+export const secondConversation: SuperChatConversation = {
+ id: 'c2',
+ title: 'Patient 1207 — Follow-up',
+ reference_id: 'patient/1207',
+ unread: 3,
+ lastActivity: '2026-06-07T10:15:00Z',
+ participants: [participants.me, participants.triage, participants.system],
+ thread: [
+ {
+ id: 'n1',
+ participantId: 'a1',
+ text: 'Labs are back — potassium is slightly elevated at 5.3.',
+ time: '2026-06-07T10:15:00Z',
+ },
+ ],
+};
+
+export const conversations: SuperChatConversation[] = [
+ conversation,
+ secondConversation,
+];
+
+// A single-message thread that exercises every core/GFM markdown element, so
+// the renderer's styling (headings, lists, tables, quotes, code, …) can be
+// inspected and tested in one place. No plugins required.
+export const markdownShowcaseConversation: SuperChatConversation = {
+ id: 'c-md',
+ title: 'Markdown rendering showcase',
+ unread: 0,
+ participants: [participants.me, participants.triage, participants.system],
+ thread: [
+ {
+ id: 'md0',
+ type: 'system',
+ participantId: 's1',
+ text: 'Demonstrating every markdown element below.',
+ time: '2026-06-07T09:00:00Z',
+ },
+ {
+ id: 'md1',
+ participantId: 'a1',
+ text: [
+ '# Heading 1',
+ '## Heading 2',
+ '### Heading 3',
+ '#### Heading 4',
+ '##### Heading 5',
+ '###### Heading 6',
+ '',
+ 'A paragraph with **bold**, _italic_, ***bold italic***, ~~strikethrough~~,',
+ 'and `inline code`. Here is a [link](https://example.com).',
+ '',
+ 'A second paragraph with a hard break here →',
+ 'and the text continues on the next line.',
+ '',
+ '> A blockquote with a **bold** word.',
+ '>',
+ '> Second line of the quote.',
+ '',
+ '## Unordered list',
+ '',
+ '- First item',
+ '- Second item',
+ ' - Nested item',
+ '- Third item',
+ '',
+ '## Ordered list',
+ '',
+ '1. Step one',
+ '2. Step two',
+ '3. Step three',
+ '',
+ '## Task list',
+ '',
+ '- [x] Done',
+ '- [ ] Not done',
+ '',
+ '## Code block',
+ '',
+ '```js',
+ 'const greet = (name) => `Hello, ${name}!`;',
+ "console.log(greet('world'));",
+ '```',
+ '',
+ '## Table',
+ '',
+ '| Code | Description | Modifier |',
+ '| --- | --- | --- |',
+ '| 93000 | ECG, complete | — |',
+ '| 93010 | ECG, interpretation | 26 |',
+ '',
+ '---',
+ '',
+ 'Text after a horizontal rule.',
+ ].join('\n'),
+ time: '2026-06-07T09:01:00Z',
+ },
+ ],
+};
+
+// ---------------------------------------------------------------------------
+// Sample host-registered GenUI widget (lazy, inline for the stories).
+// ---------------------------------------------------------------------------
+
+export function KpiCard({
+ data,
+}: GenUIWidgetProps<{ label: string; value: string; trend?: string }>) {
+ return (
+
+ {data.label}
+
+ {data.value}
+
+ {data.trend && (
+ {data.trend}
+ )}
+
+ );
+}
+
+export const registry: GenUIRegistry = {
+ kpi_card: {
+ component: () =>
+ Promise.resolve({
+ default: KpiCard as React.ComponentType,
+ }),
+ prefetch: 'eager',
+ },
+};
diff --git a/src/components/SuperChat/types.ts b/src/components/SuperChat/types.ts
new file mode 100644
index 00000000..107bd855
--- /dev/null
+++ b/src/components/SuperChat/types.ts
@@ -0,0 +1,286 @@
+/**
+ * SuperChat Types
+ *
+ * A multi-participant chat model that generalizes the AI module's
+ * `user`/`assistant` roles and the standalone `chat-component`'s
+ * `external`/`internal`/`system` roles into a single **participant** concept,
+ * plus the pluggable Markdown render pipeline contracts.
+ *
+ * See `MAINTAINERS.md` (Mission → Decisions 2 & 3) for the design rationale.
+ */
+
+import type * as React from 'react';
+import type {
+ AIMessageContent,
+ AIMessageStatus,
+ AIRenderTextContent,
+ AITextRenderContext,
+ MCPResourceLink,
+} from '../AI/types';
+
+export type { AIRenderTextContent, AITextRenderContext };
+
+// ============================================================================
+// Participant model (Decision 2)
+// ============================================================================
+
+/** What kind of actor a participant is. */
+export type ParticipantKind = 'human' | 'agent' | 'system';
+
+/** Presence/activity status for a participant. */
+export type ParticipantStatus = 'online' | 'offline' | 'busy' | 'typing';
+
+/**
+ * A single actor in a conversation. Any mix of multiple agents and multiple
+ * humans can participate in one conversation.
+ */
+export interface Participant {
+ /** Stable unique id, referenced by `SuperChatMessage.participantId`. */
+ id: string;
+ /** Whether this is a human, an AI agent, or the system. */
+ kind: ParticipantKind;
+ /** Display name. */
+ name: string;
+ /** Optional avatar image URL. */
+ avatar?: string;
+ /**
+ * Optional accent color (any CSS color) used as a visual cue so concurrent /
+ * interleaved replies from multiple agents stay legible.
+ */
+ color?: string;
+ /** Optional sub-label, e.g. "Triage agent" or "Front desk". */
+ role?: string;
+ /** Optional presence indicator. */
+ status?: ParticipantStatus;
+}
+
+// ============================================================================
+// Conversation / thread model (chat-component-compatible shape)
+// ============================================================================
+
+/** Channel a message arrived/was sent on (healthcare-messaging compatible). */
+export type SuperChatChannel =
+ | 'portal'
+ | 'sms'
+ | 'voicemail'
+ | 'auto'
+ | (string & {});
+
+/** Reference attachment carried by a `ref` thread item. */
+export interface SuperChatRef {
+ /** Kind of referenced entity. */
+ refType: 'doc' | 'rx' | 'appt' | (string & {});
+ /** Id of the referenced entity. */
+ refId: string;
+ /** Display title for the reference. */
+ title: string;
+}
+
+/** Kind of thread item. */
+export type SuperChatItemType = 'message' | 'ref' | 'system';
+
+/**
+ * A single thread item. Preserves the `chat-component` thread-item shape
+ * (`senderId`/`sender_name`/`channel`/`time`/`text`) while adding a
+ * `participantId` (Decision 2) and optional rich `content` blocks reused from
+ * the AI module.
+ */
+export interface SuperChatMessage {
+ /** Unique identifier. */
+ id: string;
+ /** Item type. Defaults to `'message'`. */
+ type?: SuperChatItemType;
+ /** Participant who authored the item. */
+ participantId: string;
+ /** Plain-text body (rendered through the Markdown pipeline). */
+ text?: string;
+ /**
+ * Optional rich content blocks (text / tool_use / tool_result / thinking /
+ * code), reused from the AI module for tool-call visualization etc.
+ */
+ content?: AIMessageContent[];
+ /** Timestamp; the thread is append-only and ordered by `time`. */
+ time: Date | string;
+ /**
+ * Timestamp of the most recent edit, if the message has been edited. When
+ * set, surfaces an "(edited)" indicator next to the message time.
+ */
+ editedAt?: Date | string;
+ /** Delivery/generation status. */
+ status?: AIMessageStatus;
+ /** Channel the item belongs to. */
+ channel?: SuperChatChannel;
+ /** Reference payload when `type === 'ref'`. */
+ ref?: SuperChatRef;
+ /** Participant ids addressed via `@`-mention. */
+ mentions?: string[];
+ /** Custom metadata. */
+ metadata?: Record;
+
+ // --- chat-component legacy aliases (optional, for migration) ---
+ /** @deprecated use `participantId`. */
+ senderId?: string;
+ /** @deprecated derive from the participant. */
+ sender_name?: string;
+}
+
+/**
+ * A file (typically an image) the local user pasted or attached in the
+ * composer, surfaced to the host via the send callback so it can be embedded,
+ * uploaded, or otherwise added to the conversation.
+ */
+export interface ComposerAttachment {
+ /** Stable id for the attachment (unique within a single draft). */
+ id: string;
+ /** File name, derived from the pasted file or a generated fallback. */
+ name: string;
+ /** MIME type, e.g. `image/png`. */
+ type: string;
+ /** Base64 `data:` URL of the file contents. */
+ dataUrl: string;
+}
+
+/**
+ * Categories of files the composer can accept. Developers pick which kinds are
+ * allowed via `acceptedFileTypes`; each maps to an ` ` token and a
+ * MIME matcher used to filter pastes and file-picker selections.
+ * - `image` → `image/*`
+ * - `video` → `video/*`
+ * - `audio` → `audio/*`
+ * - `pdf` → `application/pdf`
+ */
+export type AttachmentKind = 'image' | 'video' | 'audio' | 'pdf';
+
+/** A conversation: participants + an ordered thread. */
+export interface SuperChatConversation {
+ /** Unique identifier. */
+ id: string;
+ /** Display title. */
+ title: string;
+ /** Optional external reference id (e.g. patient/chart id). */
+ reference_id?: string;
+ /** Whether the conversation is currently open. */
+ open?: boolean;
+ /** Unread message count. */
+ unread?: number;
+ /** Timestamp of the last activity (used for sidebar ordering). */
+ lastActivity?: Date | string;
+ /** Everyone taking part in this conversation. */
+ participants: Participant[];
+ /** Ordered thread items. */
+ thread: SuperChatMessage[];
+}
+
+/** Builds an href for a reference item (e.g. doc/rx/appt deep link). */
+export type SuperChatLinkBuilder = (ref: SuperChatRef) => string | undefined;
+
+// ============================================================================
+// Render pipeline (Decision 3)
+// ============================================================================
+
+/**
+ * A unified remark/rehype plugin entry. Kept dependency-light here (the public
+ * type surface stays zero-dep); the composer casts to the concrete
+ * `react-markdown` `PluggableList` type.
+ */
+export type SuperChatPluggable = unknown;
+export type SuperChatPluggableList = SuperChatPluggable[];
+
+/**
+ * A render plugin contributes remark/rehype plugins, custom node components,
+ * and/or GenUI widgets to the Markdown pipeline. Plugins are composed into a
+ * single `renderTextContent` implementation.
+ */
+export interface SuperChatRenderPlugin {
+ /** Unique plugin name. */
+ name: string;
+ /** remark plugins to add (operate on the Markdown AST). */
+ remarkPlugins?: SuperChatPluggableList;
+ /** rehype plugins to add (operate on the HTML AST). */
+ rehypePlugins?: SuperChatPluggableList;
+ /** Custom React components keyed by node/tag name. */
+ components?: Record>>;
+ /** Named interactive widgets for fenced ```genui blocks. */
+ widgets?: GenUIRegistry;
+ /**
+ * Optional contribution to the `rehype-sanitize` allow-list schema, merged by
+ * the composer so token classNames / KaTeX markup survive sanitization.
+ */
+ sanitizeSchema?: Record;
+}
+
+// ============================================================================
+// GenUI widget registry (Decision 3)
+// ============================================================================
+
+/** When to prefetch a GenUI widget's code/data. */
+export type GenUIPrefetchPolicy = 'eager' | 'visible' | 'idle';
+
+/**
+ * Minimal subset of the Standard Schema (https://standardschema.dev) interface,
+ * implemented by zod v4 and others, used to validate untrusted widget payloads.
+ */
+export interface StandardSchemaV1 {
+ readonly '~standard': {
+ readonly version: 1;
+ readonly vendor: string;
+ readonly validate: (
+ value: unknown
+ ) =>
+ | { readonly value: Output }
+ | { readonly issues: ReadonlyArray<{ readonly message: string }> }
+ | Promise<
+ | { readonly value: Output }
+ | { readonly issues: ReadonlyArray<{ readonly message: string }> }
+ >;
+ readonly types?: { readonly input: Input; readonly output: Output };
+ };
+}
+
+/** Props passed to a host-registered GenUI widget. */
+export interface GenUIWidgetProps {
+ /** Payload, validated against the entry's `schema` when provided. */
+ data: T;
+ /** Render-time metadata. */
+ meta: {
+ name: string;
+ version?: number;
+ messageId: string;
+ streaming: boolean;
+ };
+}
+
+/** A single registry entry: lazy code + optional schema/prefetch policy. */
+export interface GenUIWidgetEntry {
+ /** Lazy chunk — keeps the widget out of the base bundle. */
+ component: () => Promise<{
+ default: React.ComponentType>;
+ }>;
+ /** Optional runtime validation of the untrusted payload. */
+ schema?: StandardSchemaV1;
+ /** Default policy if the wire payload omits one. */
+ prefetch?: GenUIPrefetchPolicy;
+ /** Optional data prefetch, distinct from loading the component code. */
+ prefetchData?: (data: T) => Promise;
+}
+
+/** Map of widget base-name → entry. Versions are resolved explicitly. */
+export type GenUIRegistry = Record;
+
+/** Parsed body of a fenced ```genui block. */
+export interface GenUIBlockPayload {
+ /** Widget base name (resolved against the registry). */
+ widget: string;
+ /** Explicit version selector. */
+ version?: number;
+ /** Wire-level prefetch hint (registry policy overrides this). */
+ prefetch?: GenUIPrefetchPolicy;
+ /** Arbitrary widget props. */
+ props?: unknown;
+}
+
+// ============================================================================
+// Re-exports for convenience
+// ============================================================================
+
+export type { AIMessageContent, AIMessageStatus, MCPResourceLink };
diff --git a/src/tailwind-preset.ts b/src/tailwind-preset.ts
index 5f9ce05d..be1efffe 100644
--- a/src/tailwind-preset.ts
+++ b/src/tailwind-preset.ts
@@ -334,6 +334,18 @@ export const miewebUISafelist = [
'last:border-b-0',
'border-dashed',
'text-[11px]',
+ // SuperChat (participant chips, unread badge, active/hover states)
+ 'bg-primary-100',
+ 'bg-primary-600',
+ 'hover:bg-primary-700',
+ 'dark:bg-primary-900/40',
+ 'text-primary-900',
+ 'dark:text-primary-100',
+ 'dark:text-primary-200',
+ 'hover:border-primary-300',
+ 'focus:border-primary-500',
+ 'focus:ring-primary-500',
+ 'text-[10px]',
];
export interface MiewebUIPreset {
diff --git a/tsup.config.ts b/tsup.config.ts
index cb0d9348..dbcc24e1 100644
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@ -46,6 +46,8 @@ export default defineConfig({
'components/Skeleton/index': 'src/components/Skeleton/index.ts',
'components/Slider/index': 'src/components/Slider/index.ts',
'components/Spinner/index': 'src/components/Spinner/index.ts',
+ 'components/SuperChat/index': 'src/components/SuperChat/index.ts',
+ 'components/SuperChat/plugins/index': 'src/components/SuperChat/plugins/index.ts',
'components/Switch/index': 'src/components/Switch/index.ts',
'components/Table/index': 'src/components/Table/index.ts',
'components/Tabs/index': 'src/components/Tabs/index.ts',
@@ -79,6 +81,13 @@ export default defineConfig({
'mermaid',
'papaparse',
'js-yaml',
+ 'react-markdown',
+ 'remark-gfm',
+ 'remark-math',
+ 'rehype-katex',
+ 'katex',
+ 'rehype-sanitize',
+ 'rehype-highlight',
/^@kerebron\//,
/^@mieweb\/ui\//,
/^datavis\//,