diff --git a/.gitignore b/.gitignore
index 7daf139f23..43dbe89224 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,6 +46,12 @@ package-lock.json
 /dist
 *.tsbuildinfo
 
+# ---------------------------------------------------------------------------
+# Storybook
+# ---------------------------------------------------------------------------
+storybook-static/
+*.log.json
+
 # ---------------------------------------------------------------------------
 # Python
 # ---------------------------------------------------------------------------
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index 438fa30ad0..f4c0f4bdb0 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -5,6 +5,7 @@
     "charliermarsh.ruff",
     "ms-vscode.powershell",
     "ms-python.python",
-    "ms-vscode.vscode-typescript-next"
+    "ms-vscode.vscode-typescript-next",
+    "highagency.pencildev"
   ]
 }
diff --git a/bun.lock b/bun.lock
index 6d6e6022d8..e9a617cdd1 100644
--- a/bun.lock
+++ b/bun.lock
@@ -103,10 +103,26 @@
         "i18next": "26.0.4",
         "i18next-icu": "2.4.3",
         "lucide-react": "1.8.0",
+        "mermaid": "11.14.0",
+        "micromark": "4.0.2",
+        "micromark-core-commonmark": "2.0.3",
+        "micromark-util-classify-character": "2.0.1",
+        "micromark-util-types": "2.0.2",
         "react-i18next": "17.0.2",
+        "react-markdown": "10.1.0",
+        "rehype-raw": "7.0.0",
+        "rehype-sanitize": "6.0.0",
+        "remark-gfm": "4.0.1",
+        "shiki": "3.2.0",
         "tailwind-merge": "3.5.0",
+        "unist-util-visit": "5.1.0",
+      },
+      "devDependencies": {
+        "@types/hast": "3.0.4",
       },
       "peerDependencies": {
+        "react": "*",
+        "react-dom": "*",
         "vitest": "*",
       },
       "optionalPeers": [
@@ -122,12 +138,7 @@
         "@tanstack/react-router": "1.168.10",
         "framer-motion": "12.38.0",
         "lucide-react": "1.8.0",
-        "mermaid": "11.14.0",
         "minisearch": "7.2.0",
-        "react-markdown": "10.1.0",
-        "rehype-raw": "7.0.0",
-        "remark-gfm": "4.0.1",
-        "shiki": "3.2.0",
       },
       "peerDependencies": {
         "react": "*",
@@ -232,10 +243,6 @@
         "lucide-react": "1.8.0",
         "mammoth": "1.12.0",
         "mermaid": "11.14.0",
-        "micromark": "4.0.2",
-        "micromark-core-commonmark": "2.0.3",
-        "micromark-util-classify-character": "2.0.1",
-        "micromark-util-types": "2.0.2",
         "mssql": "12.2.1",
         "mustache": "4.2.0",
         "mysql2": "3.21.1",
@@ -255,7 +262,6 @@
         "rehype-sanitize": "6.0.0",
         "remark-gfm": "4.0.1",
         "safe-regex2": "5.1.1",
-        "shiki": "4.0.2",
         "striptags": "3.2.0",
         "sucrase": "3.35.1",
         "swagger-ui-react": "5.32.2",
@@ -1390,19 +1396,17 @@
 
     "@sentry/vite-plugin": ["@sentry/vite-plugin@5.2.0", "", { "dependencies": { "@sentry/bundler-plugin-core": "5.2.0", "@sentry/rollup-plugin": "5.2.0" } }, "sha512-4Jo3ixBspso5HY81PDvZdRXkH9wYGVmcw/0a2IX9ejbyKBdHqkYg4IhAtNqGUAyGuHwwRS9Y1S+sCMvrXv6htw=="],
 
-    "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="],
-
-    "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="],
+    "@shikijs/core": ["@shikijs/core@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-+5dPz8q6HgNqfQ28ycm/vA8dIVd2lNFOUqVRFCQLbs0KZ6emYI+1apLpX+wuL/aDSPLOkMgARwNjkA5UjGKS1Q=="],
 
-    "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="],
+    "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.1.0" } }, "sha512-1WrYfaz5YT5aTAIMbYQhxlSHc8ArX+hCDNAIdKRqJHzfWQ3xDgh3PTvrAly+RWGuvi5Q4NlvPlTBdlSAXN6Stg=="],
 
-    "@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="],
+    "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-3V7ko+YUAP02I4rUbDjCgvyM/H85hUIZBQAS19FjDcJMKL5SbjWTiG7TRKxX1V4ddxLxt2RO64wZinElp/3ngQ=="],
 
-    "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="],
+    "@shikijs/langs": ["@shikijs/langs@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-Qze5YIsp223AmC69VZDQolcrcYPrVa9wV6cW2kVqsDrSWlwhW2EQZEn1Iw2oQU1tGYVg8Hj/xdp8mOv+9zI0vg=="],
 
-    "@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
+    "@shikijs/themes": ["@shikijs/themes@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-XfzMSTu6iMl2FZIwKykld2OzFKDDlm4KbZrzW6sbKXEeJ1xq61HX4x4bE4+REBFqbbrvAQM8EAH11m/E3cxYDg=="],
 
-    "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
+    "@shikijs/types": ["@shikijs/types@3.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-1uOwfEO0vV+G8n/AO/6Yth7zshNdXvQ1pc4ygTrfE3cyuzVLukrZq72YkFUlsRijam7LvRTvnqL4aT5wx1X2Vw=="],
 
     "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
 
@@ -3444,7 +3448,7 @@
 
     "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
 
-    "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
+    "shiki": ["shiki@3.2.0", "", { "dependencies": { "@shikijs/core": "3.2.0", "@shikijs/engine-javascript": "3.2.0", "@shikijs/engine-oniguruma": "3.2.0", "@shikijs/langs": "3.2.0", "@shikijs/themes": "3.2.0", "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lOF6wkvZCRVQrdfGilyXclTKIjCWKujPAjD6fddLwtQ6eSmgj43pFDbjUmxivtElDRlsGO8G2dLeeRpwNY4wWg=="],
 
     "short-unique-id": ["short-unique-id@5.3.2", "", { "bin": { "short-unique-id": "bin/short-unique-id", "suid": "bin/short-unique-id" } }, "sha512-KRT/hufMSxXKEDSQujfVE0Faa/kZ51ihUcZQAcmP04t00DvPj7Ox5anHke1sJYUtzSuiT/Y5uyzg/W7bBEGhCg=="],
 
@@ -3918,8 +3922,6 @@
 
     "@tailwindcss/postcss/postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
 
-    "@tale/webui/shiki": ["shiki@3.2.0", "", { "dependencies": { "@shikijs/core": "3.2.0", "@shikijs/engine-javascript": "3.2.0", "@shikijs/engine-oniguruma": "3.2.0", "@shikijs/langs": "3.2.0", "@shikijs/themes": "3.2.0", "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lOF6wkvZCRVQrdfGilyXclTKIjCWKujPAjD6fddLwtQ6eSmgj43pFDbjUmxivtElDRlsGO8G2dLeeRpwNY4wWg=="],
-
     "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
 
     "@tanstack/router-plugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
@@ -4168,18 +4170,6 @@
 
     "@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
 
-    "@tale/webui/shiki/@shikijs/core": ["@shikijs/core@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-+5dPz8q6HgNqfQ28ycm/vA8dIVd2lNFOUqVRFCQLbs0KZ6emYI+1apLpX+wuL/aDSPLOkMgARwNjkA5UjGKS1Q=="],
-
-    "@tale/webui/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.1.0" } }, "sha512-1WrYfaz5YT5aTAIMbYQhxlSHc8ArX+hCDNAIdKRqJHzfWQ3xDgh3PTvrAly+RWGuvi5Q4NlvPlTBdlSAXN6Stg=="],
-
-    "@tale/webui/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-3V7ko+YUAP02I4rUbDjCgvyM/H85hUIZBQAS19FjDcJMKL5SbjWTiG7TRKxX1V4ddxLxt2RO64wZinElp/3ngQ=="],
-
-    "@tale/webui/shiki/@shikijs/langs": ["@shikijs/langs@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-Qze5YIsp223AmC69VZDQolcrcYPrVa9wV6cW2kVqsDrSWlwhW2EQZEn1Iw2oQU1tGYVg8Hj/xdp8mOv+9zI0vg=="],
-
-    "@tale/webui/shiki/@shikijs/themes": ["@shikijs/themes@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-XfzMSTu6iMl2FZIwKykld2OzFKDDlm4KbZrzW6sbKXEeJ1xq61HX4x4bE4+REBFqbbrvAQM8EAH11m/E3cxYDg=="],
-
-    "@tale/webui/shiki/@shikijs/types": ["@shikijs/types@3.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-1uOwfEO0vV+G8n/AO/6Yth7zshNdXvQ1pc4ygTrfE3cyuzVLukrZq72YkFUlsRijam7LvRTvnqL4aT5wx1X2Vw=="],
-
     "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
 
     "@types/pg-pool/@types/pg/pg-protocol": ["pg-protocol@1.12.0", "", {}, "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg=="],
diff --git a/package.json b/package.json
index dc056c5721..40de813df0 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,10 @@
     "docker:test:image": "bash tests/container-image-test.sh",
     "docker:test:vulnerability": "bash tests/container-vulnerability-scan.sh",
     "commit": "bunx cz",
-    "storybook": "bun run --filter @tale/platform storybook",
+    "storybook": "bunx turbo run storybook",
+    "storybook:ui": "bun run --filter @tale/ui storybook",
+    "storybook:webui": "bun run --filter @tale/webui storybook",
+    "storybook:platform": "bun run --filter @tale/platform storybook",
     "gen": "bunx --bun plop --plopfile plopfile.ts",
     "gen:react-service": "bun run gen react-service",
     "gen:react-package": "bun run gen react-package",
diff --git a/packages/ui/.oxlintrc.json b/packages/ui/.oxlintrc.json
index d38bbcf5c7..904265ea9c 100644
--- a/packages/ui/.oxlintrc.json
+++ b/packages/ui/.oxlintrc.json
@@ -1,4 +1,13 @@
 {
   "$schema": "../../node_modules/oxlint/configuration_schema.json",
-  "extends": ["../../.oxlintrc.json"]
+  "extends": ["../../.oxlintrc.json"],
+  "overrides": [
+    {
+      "files": ["src/markdown/**/*.{ts,tsx}"],
+      "rules": {
+        "typescript/no-unsafe-type-assertion": "off",
+        "typescript/consistent-return": "off"
+      }
+    }
+  ]
 }
diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx
index 9a3280236c..e496241989 100644
--- a/packages/ui/.storybook/preview.tsx
+++ b/packages/ui/.storybook/preview.tsx
@@ -1,7 +1,41 @@
 import { withThemeByClassName } from '@storybook/addon-themes';
 import type { Preview } from '@storybook/react';
+import { useMemo } from 'react';
+import type { DecoratorFunction } from 'storybook/internal/types';
+
+import { ThemeContext } from '../src/theme';
 
 import '../src/globals.css';
+import '../src/markdown/globals.css';
+
+/**
+ * Bridge addon-themes' html-class toggle into the React `ThemeContext` so
+ * components reading `useTheme()` (CodeBlock, Mermaid, etc.) see the same
+ * state Storybook shows. Without this, `resolvedTheme` is pinned to
+ * `'light'` even when the iframe has `.dark` on `<html>`.
+ */
+function WithTheme({
+  Story,
+  context,
+}: {
+  Story: Parameters<DecoratorFunction>[0];
+  context: Parameters<DecoratorFunction>[1];
+}) {
+  const resolvedTheme = context.globals.theme === 'dark' ? 'dark' : 'light';
+  const value = useMemo(
+    () => ({
+      theme: resolvedTheme,
+      resolvedTheme,
+      setTheme: () => {},
+    }),
+    [resolvedTheme],
+  );
+  return (
+    <ThemeContext.Provider value={value}>
+      <Story />
+    </ThemeContext.Provider>
+  );
+}
 
 const preview: Preview = {
   parameters: {
@@ -22,6 +56,7 @@ const preview: Preview = {
     },
   },
   decorators: [
+    (Story, context) => <WithTheme Story={Story} context={context} />,
     withThemeByClassName({
       themes: { light: '', dark: 'dark' },
       defaultTheme: 'light',
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 5acdadc32f..2d62380af3 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -2,7 +2,7 @@
   "name": "@tale/ui",
   "version": "0.1.0",
   "private": true,
-  "description": "Shared Tale React component library",
+  "description": "Shared Tale React component library — primitives, theming, i18n + the markdown rendering pipeline (components, plugins, streaming).",
   "type": "module",
   "exports": {
     ".": "./src/index.ts",
@@ -37,6 +37,30 @@
     "./cn": "./src/lib/cn.ts",
     "./format": "./src/lib/format.ts",
     "./i18n-tests": "./src/i18n-tests/index.ts",
+    "./use-resize-observer": "./src/hooks/use-resize-observer.ts",
+    "./markdown": "./src/markdown/markdown.tsx",
+    "./markdown/routed-markdown": "./src/markdown/routed-markdown.tsx",
+    "./markdown/shiki": "./src/markdown/shiki.ts",
+    "./markdown/code-block": "./src/markdown/code-block.tsx",
+    "./markdown/anchored-heading": "./src/markdown/anchored-heading.tsx",
+    "./markdown/extract-toc": "./src/markdown/extract-toc.ts",
+    "./markdown/reading-time": "./src/markdown/reading-time.ts",
+    "./markdown/components/callout": "./src/markdown/components/callout.tsx",
+    "./markdown/components/code-group": "./src/markdown/components/code-group.tsx",
+    "./markdown/components/tabs": "./src/markdown/components/tabs.tsx",
+    "./markdown/components/steps": "./src/markdown/components/steps.tsx",
+    "./markdown/components/cards": "./src/markdown/components/cards.tsx",
+    "./markdown/components/frame": "./src/markdown/components/frame.tsx",
+    "./markdown/components/accordion": "./src/markdown/components/accordion.tsx",
+    "./markdown/components/mermaid": "./src/markdown/components/mermaid.tsx",
+    "./markdown/components/registry": "./src/markdown/components/registry.tsx",
+    "./markdown/streaming/incremental-markdown": "./src/markdown/streaming/incremental-markdown.tsx",
+    "./markdown/streaming/remend-markdown": "./src/markdown/streaming/remend-markdown.ts",
+    "./markdown/streaming/normalize-html-blocks": "./src/markdown/streaming/normalize-html-blocks.ts",
+    "./markdown/streaming/find-block-split": "./src/markdown/streaming/find-block-split.ts",
+    "./markdown/plugins/micromark-cjk-attention": "./src/markdown/plugins/micromark-cjk-attention.ts",
+    "./markdown/types": "./src/markdown/types.ts",
+    "./markdown/globals.css": "./src/markdown/globals.css",
     "./globals.css": "./src/globals.css",
     "./tailwind-preset": "./tailwind-preset.ts"
   },
@@ -59,10 +83,26 @@
     "i18next": "26.0.4",
     "i18next-icu": "2.4.3",
     "lucide-react": "1.8.0",
+    "mermaid": "11.14.0",
+    "micromark": "4.0.2",
+    "micromark-core-commonmark": "2.0.3",
+    "micromark-util-classify-character": "2.0.1",
+    "micromark-util-types": "2.0.2",
     "react-i18next": "17.0.2",
-    "tailwind-merge": "3.5.0"
+    "react-markdown": "10.1.0",
+    "rehype-raw": "7.0.0",
+    "rehype-sanitize": "6.0.0",
+    "remark-gfm": "4.0.1",
+    "shiki": "3.2.0",
+    "tailwind-merge": "3.5.0",
+    "unist-util-visit": "5.1.0"
+  },
+  "devDependencies": {
+    "@types/hast": "3.0.4"
   },
   "peerDependencies": {
+    "react": "*",
+    "react-dom": "*",
     "vitest": "*"
   },
   "peerDependenciesMeta": {
diff --git a/packages/ui/postcss.config.mjs b/packages/ui/postcss.config.mjs
new file mode 100644
index 0000000000..5d6d8457f7
--- /dev/null
+++ b/packages/ui/postcss.config.mjs
@@ -0,0 +1,8 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+  plugins: {
+    '@tailwindcss/postcss': {},
+  },
+};
+
+export default config;
diff --git a/packages/ui/src/globals.css b/packages/ui/src/globals.css
index 2fa44743c4..18e554a4c9 100644
--- a/packages/ui/src/globals.css
+++ b/packages/ui/src/globals.css
@@ -91,9 +91,12 @@
   --color-bg-muted: #1a1a1a;
   --color-bg-overlay: rgba(3, 7, 18, 0.8);
 
+  /* Body prose lifts a notch (gray-300 vs gray-400) for legibility on dark
+     backgrounds. fg-subtle slides up to fill the lower-contrast slot for
+     captions / muted metadata. */
   --color-fg-base: #f3f4f6;
-  --color-fg-muted: #9ca3af;
-  --color-fg-subtle: #6b7280;
+  --color-fg-muted: #d1d5db;
+  --color-fg-subtle: #9ca3af;
   --color-fg-inverse: #030712;
 
   --color-border-base: #404040;
diff --git a/packages/ui/src/hooks/use-resize-observer.ts b/packages/ui/src/hooks/use-resize-observer.ts
new file mode 100644
index 0000000000..7f0a21f0f1
--- /dev/null
+++ b/packages/ui/src/hooks/use-resize-observer.ts
@@ -0,0 +1,39 @@
+import { useEffect, useRef } from 'react';
+
+type ResizeCallback = (entry: ResizeObserverEntry, target: Element) => void;
+
+/**
+ * Subscribe a stable callback to ResizeObserver entries on the given element.
+ *
+ * The callback is stored in a ref so it can change every render without
+ * tearing down the observer; only `target` toggles re-create the observer.
+ * Pass `null`/`undefined` to disable. The callback fires once when the
+ * observer first attaches (per the ResizeObserver spec) — useful for
+ * initial-fit logic that needs the post-layout size.
+ *
+ * Example:
+ * ```ts
+ * const ref = useRef<HTMLDivElement>(null);
+ * useResizeObserver(ref.current, (entry) => {
+ *   setSize({ w: entry.contentRect.width, h: entry.contentRect.height });
+ * });
+ * ```
+ */
+export function useResizeObserver<T extends Element>(
+  target: T | null | undefined,
+  callback: ResizeCallback,
+): void {
+  const callbackRef = useRef(callback);
+  callbackRef.current = callback;
+
+  useEffect(() => {
+    if (!target) return undefined;
+    const observer = new ResizeObserver((entries) => {
+      for (const entry of entries) {
+        callbackRef.current(entry, entry.target);
+      }
+    });
+    observer.observe(target);
+    return () => observer.disconnect();
+  }, [target]);
+}
diff --git a/packages/ui/src/markdown/anchored-heading.stories.tsx b/packages/ui/src/markdown/anchored-heading.stories.tsx
new file mode 100644
index 0000000000..635ac1acfe
--- /dev/null
+++ b/packages/ui/src/markdown/anchored-heading.stories.tsx
@@ -0,0 +1,54 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { AnchoredHeading } from './anchored-heading';
+
+const meta = {
+  title: 'markdown/AnchoredHeading',
+  component: AnchoredHeading,
+  tags: ['autodocs'],
+  parameters: { layout: 'padded' },
+} satisfies Meta<typeof AnchoredHeading>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const H1: Story = {
+  args: {
+    level: 'h1',
+    className: 'text-fg-base text-3xl font-semibold',
+    children: 'Heading 1 — anchor on hover',
+  },
+};
+
+export const H2: Story = {
+  args: {
+    level: 'h2',
+    className: 'text-fg-base text-2xl font-semibold',
+    children: 'Heading 2 — anchor on hover',
+  },
+};
+
+export const H3: Story = {
+  args: {
+    level: 'h3',
+    className: 'text-fg-base text-lg font-semibold',
+    children: 'Heading 3 — anchor on hover',
+  },
+};
+
+export const H4: Story = {
+  args: {
+    level: 'h4',
+    className: 'text-fg-base text-base font-semibold',
+    children: 'Heading 4 — anchor on hover',
+  },
+};
+
+export const LongTitle: Story = {
+  args: {
+    level: 'h2',
+    className: 'text-fg-base max-w-prose text-2xl font-semibold',
+    children:
+      'A wrapped, multi-line heading that still slugifies the full text correctly',
+  },
+};
diff --git a/packages/webui/src/markdown/anchored-heading.tsx b/packages/ui/src/markdown/anchored-heading.tsx
similarity index 98%
rename from packages/webui/src/markdown/anchored-heading.tsx
rename to packages/ui/src/markdown/anchored-heading.tsx
index 354f89b62d..71956e60a4 100644
--- a/packages/webui/src/markdown/anchored-heading.tsx
+++ b/packages/ui/src/markdown/anchored-heading.tsx
@@ -138,7 +138,7 @@ export function AnchoredHeading({
         // matching the Mintlify pattern. `print:hidden` keeps printed pages
         // free of the affordance.
         className={`text-fg-muted hover:text-fg-base ml-2 inline-flex size-6 items-center justify-center rounded-md align-middle opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-current/20 print:hidden ${
-          copied ? 'text-emerald-600 opacity-100' : ''
+          copied ? 'text-emerald-600 opacity-100 dark:text-emerald-400' : ''
         }`}
       >
         {copied ? (
diff --git a/packages/ui/src/markdown/code-block.stories.tsx b/packages/ui/src/markdown/code-block.stories.tsx
new file mode 100644
index 0000000000..304d59d977
--- /dev/null
+++ b/packages/ui/src/markdown/code-block.stories.tsx
@@ -0,0 +1,114 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { CodeBlock } from './code-block';
+
+const meta = {
+  title: 'markdown/CodeBlock',
+  component: CodeBlock,
+  tags: ['autodocs'],
+  parameters: { layout: 'padded' },
+} satisfies Meta<typeof CodeBlock>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const TS_SAMPLE = `import { highlightCode } from '@tale/ui/markdown/shiki';
+
+const result = await highlightCode(
+  'console.log("hello tale");',
+  'typescript',
+  'light',
+);
+`;
+
+const BASH_SAMPLE = `# install dependencies
+bun install
+
+# start the docs site on :3002
+bun run --filter @tale/docs dev
+`;
+
+export const TypeScript: Story = {
+  args: {
+    code: TS_SAMPLE,
+    language: 'typescript',
+    filename: 'highlight.ts',
+  },
+};
+
+export const Bash: Story = {
+  args: {
+    code: BASH_SAMPLE,
+    language: 'bash',
+    filename: 'README.md · quickstart',
+  },
+};
+
+export const NoCopy: Story = {
+  args: {
+    code: TS_SAMPLE,
+    language: 'typescript',
+    hideCopy: true,
+  },
+};
+
+const DIFF_SAMPLE = `diff --git a/migration.sql b/migration.sql
+@@ -10,7 +10,7 @@
+   ALTER TABLE customers
+-    ADD COLUMN email TEXT NOT NULL;
++    ADD COLUMN email TEXT NOT NULL DEFAULT '';
+   COMMIT;
+`;
+
+export const Diff: Story = {
+  args: {
+    code: DIFF_SAMPLE,
+    language: 'diff',
+    filename: 'migration.diff',
+  },
+};
+
+const LONG_SAMPLE = `function highlight(code, language) {
+  // Hover any line to see the row-tint effect.
+  if (!code) return '';
+  const tokens = tokenize(code, language);
+  return tokens
+    .map((token) => renderToken(token))
+    .join('');
+}
+
+function tokenize(code, language) {
+  return [];
+}
+
+function renderToken(token) {
+  return token.value;
+}
+`;
+
+export const RowHover: Story = {
+  args: {
+    code: LONG_SAMPLE,
+    language: 'javascript',
+    filename: 'highlight.js — hover the lines',
+  },
+};
+
+const TRAILING_NEWLINE_SAMPLE = `# Five lines, exact line numbers
+docker pull ghcr.io/tale-project/tale/tale-platform:1.2.0
+
+# Latest release
+docker pull ghcr.io/tale-project/tale/tale-platform:latest
+`;
+
+// Regression story for the line-number bug: trailing newlines used to
+// produce a Shiki line that the gutter never numbered, so the last visible
+// row sat without an index. The screenshot in #1691-issue captures the
+// exact state. Both lines and numbers must stay in lockstep.
+export const LineNumbersWithTrailingNewline: Story = {
+  args: {
+    code: TRAILING_NEWLINE_SAMPLE,
+    language: 'bash',
+    filename: 'docker-pull.sh',
+  },
+};
diff --git a/packages/ui/src/markdown/code-block.tsx b/packages/ui/src/markdown/code-block.tsx
new file mode 100644
index 0000000000..7efd212fca
--- /dev/null
+++ b/packages/ui/src/markdown/code-block.tsx
@@ -0,0 +1,121 @@
+import { Check, Copy } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+
+import { cn } from '../lib/cn';
+import { HighlightedCode } from './highlighted-code';
+
+interface CodeBlockProps {
+  code: string;
+  language?: string;
+  /** Optional filename label rendered above the block. */
+  filename?: string;
+  /** Hide the copy button (e.g. inside CodeGroup tabs). */
+  hideCopy?: boolean;
+  /** Hide the header bar (filename + copy) entirely. Useful when an outer
+   * wrapper (CodeGroup, custom card) provides its own chrome. */
+  hideHeader?: boolean;
+  /** Force-show or hide the line-number gutter. Defaults to auto (lines > 3).
+   * Set to `true` for streaming surfaces so the gutter never appears
+   * mid-stream (which would shift the content rightward). */
+  showLineNumbers?: boolean;
+  className?: string;
+}
+
+/**
+ * Highlighted, copy-friendly code block. Rendered as a bordered card with
+ * an optional header bar (filename + copy button) on top and the
+ * highlighted source body — the same body the CodeGroup panels render so
+ * the two surfaces look identical.
+ *
+ * Falls back to a plain `<pre>` while Shiki loads in the background so
+ * the layout never shifts.
+ */
+export function CodeBlock({
+  code,
+  language,
+  filename,
+  hideCopy,
+  hideHeader,
+  showLineNumbers,
+  className,
+}: CodeBlockProps) {
+  const headerLabel = filename ?? language ?? '';
+  return (
+    <div
+      className={cn(
+        'border-border-base bg-bg-elevated relative my-6 overflow-hidden rounded-lg border',
+        className,
+      )}
+    >
+      {hideHeader ? null : (
+        <CodeBlockHeader label={headerLabel} hideCopy={hideCopy} code={code} />
+      )}
+      <HighlightedCode
+        code={code}
+        language={language}
+        showLineNumbers={showLineNumbers}
+        showCopyButton={hideHeader && !hideCopy}
+      />
+    </div>
+  );
+}
+
+interface CodeBlockHeaderProps {
+  label: string;
+  hideCopy?: boolean;
+  code: string;
+}
+
+function CodeBlockHeader({ label, hideCopy, code }: CodeBlockHeaderProps) {
+  return (
+    <div className="border-border-base flex h-9 items-center justify-between gap-4 border-b px-3 text-xs">
+      <span className="text-fg-muted truncate font-mono">{label}</span>
+      {hideCopy ? null : <HeaderCopyButton code={code} />}
+    </div>
+  );
+}
+
+function HeaderCopyButton({ code }: { code: string }) {
+  const [copied, setCopied] = useState(false);
+  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  // Cancel any in-flight reset timer on unmount so we never call
+  // `setCopied(false)` after the component is gone.
+  useEffect(() => {
+    return () => {
+      if (timerRef.current) clearTimeout(timerRef.current);
+    };
+  }, []);
+
+  const handleCopy = async () => {
+    const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code;
+    try {
+      await navigator.clipboard.writeText(trimmed);
+      setCopied(true);
+      if (timerRef.current) clearTimeout(timerRef.current);
+      timerRef.current = setTimeout(() => setCopied(false), 1500);
+    } catch (error) {
+      console.warn('[code-block] clipboard write failed', error);
+    }
+  };
+
+  return (
+    <button
+      type="button"
+      onClick={handleCopy}
+      disabled={copied}
+      aria-label={copied ? 'Copied' : 'Copy code'}
+      aria-live="polite"
+      className={cn(
+        'text-fg-muted hover:text-fg-base hover:bg-bg-base/60 inline-flex size-7 shrink-0 items-center justify-center rounded transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-current/20',
+        copied && 'text-emerald-600 dark:text-emerald-400',
+      )}
+    >
+      {copied ? (
+        <Check className="size-3.5" aria-hidden />
+      ) : (
+        <Copy className="size-3.5" aria-hidden />
+      )}
+    </button>
+  );
+}
diff --git a/packages/webui/src/markdown/components/accordion.stories.tsx b/packages/ui/src/markdown/components/accordion.stories.tsx
similarity index 98%
rename from packages/webui/src/markdown/components/accordion.stories.tsx
rename to packages/ui/src/markdown/components/accordion.stories.tsx
index 4ff163b032..9bb5dcbefb 100644
--- a/packages/webui/src/markdown/components/accordion.stories.tsx
+++ b/packages/ui/src/markdown/components/accordion.stories.tsx
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react';
 import { Accordion, AccordionGroup } from './accordion';
 
 const meta = {
-  title: 'webui/markdown/Accordion',
+  title: 'markdown/Accordion',
   component: Accordion,
   tags: ['autodocs'],
   parameters: { layout: 'padded' },
diff --git a/packages/webui/src/markdown/components/accordion.tsx b/packages/ui/src/markdown/components/accordion.tsx
similarity index 98%
rename from packages/webui/src/markdown/components/accordion.tsx
rename to packages/ui/src/markdown/components/accordion.tsx
index b349169c83..33ce486613 100644
--- a/packages/webui/src/markdown/components/accordion.tsx
+++ b/packages/ui/src/markdown/components/accordion.tsx
@@ -1,4 +1,3 @@
-import { cn } from '@tale/ui/cn';
 import { ChevronDown } from 'lucide-react';
 import type { ReactNode } from 'react';
 import {
@@ -12,6 +11,8 @@ import {
   useState,
 } from 'react';
 
+import { cn } from '../../lib/cn';
+
 interface AccordionGroupContextValue {
   openId: string | null;
   setOpenId: (id: string | null) => void;
diff --git a/packages/webui/src/markdown/components/callout.stories.tsx b/packages/ui/src/markdown/components/callout.stories.tsx
similarity index 98%
rename from packages/webui/src/markdown/components/callout.stories.tsx
rename to packages/ui/src/markdown/components/callout.stories.tsx
index 0288cc290d..a2c066330b 100644
--- a/packages/webui/src/markdown/components/callout.stories.tsx
+++ b/packages/ui/src/markdown/components/callout.stories.tsx
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react';
 import { Callout } from './callout';
 
 const meta = {
-  title: 'webui/markdown/Callout',
+  title: 'markdown/Callout',
   component: Callout,
   tags: ['autodocs'],
   argTypes: {
diff --git a/packages/webui/src/markdown/components/callout.tsx b/packages/ui/src/markdown/components/callout.tsx
similarity index 98%
rename from packages/webui/src/markdown/components/callout.tsx
rename to packages/ui/src/markdown/components/callout.tsx
index ce3a4eae47..5b42d6d1fc 100644
--- a/packages/webui/src/markdown/components/callout.tsx
+++ b/packages/ui/src/markdown/components/callout.tsx
@@ -1,4 +1,3 @@
-import { cn } from '@tale/ui/cn';
 import {
   AlertTriangle,
   CheckCircle2,
@@ -10,6 +9,8 @@ import {
 } from 'lucide-react';
 import type { ReactNode } from 'react';
 
+import { cn } from '../../lib/cn';
+
 type Tone = 'note' | 'tip' | 'info' | 'warning' | 'danger' | 'check';
 
 const TONE_CONFIG: Record<
diff --git a/packages/webui/src/markdown/components/cards.stories.tsx b/packages/ui/src/markdown/components/cards.stories.tsx
similarity index 98%
rename from packages/webui/src/markdown/components/cards.stories.tsx
rename to packages/ui/src/markdown/components/cards.stories.tsx
index 786de9b778..031baca58a 100644
--- a/packages/webui/src/markdown/components/cards.stories.tsx
+++ b/packages/ui/src/markdown/components/cards.stories.tsx
@@ -20,7 +20,7 @@ function withRouter(children: ReactNode) {
 }
 
 const meta = {
-  title: 'webui/markdown/CardGroup',
+  title: 'markdown/CardGroup',
   component: CardGroup,
   tags: ['autodocs'],
   parameters: { layout: 'padded' },
diff --git a/packages/webui/src/markdown/components/cards.tsx b/packages/ui/src/markdown/components/cards.tsx
similarity index 88%
rename from packages/webui/src/markdown/components/cards.tsx
rename to packages/ui/src/markdown/components/cards.tsx
index a932e250bb..ee5dd4b1f2 100644
--- a/packages/webui/src/markdown/components/cards.tsx
+++ b/packages/ui/src/markdown/components/cards.tsx
@@ -1,9 +1,12 @@
-import { cn } from '@tale/ui/cn';
 import { Link } from '@tanstack/react-router';
 import { ArrowUpRight } from 'lucide-react';
-import { DynamicIcon, type IconName } from 'lucide-react/dynamic';
+import { DynamicIcon, iconNames, type IconName } from 'lucide-react/dynamic';
 import { type ReactNode, isValidElement } from 'react';
 
+import { cn } from '../../lib/cn';
+
+const KNOWN_ICON_NAMES = new Set<string>(iconNames);
+
 interface CardProps {
   title?: string;
   /**
@@ -20,6 +23,10 @@ interface CardProps {
 function renderIcon(icon: ReactNode | string | undefined): ReactNode {
   if (icon == null || icon === '') return null;
   if (typeof icon === 'string') {
+    // Validate against the known Lucide icon set so an unknown name renders
+    // *nothing* (no DOM, no flex gap, no broken-icon placeholder) rather
+    // than an empty `<DynamicIcon>` shell.
+    if (!KNOWN_ICON_NAMES.has(icon)) return null;
     return <DynamicIcon name={icon as IconName} className="size-4" />;
   }
   if (isValidElement(icon)) return icon;
diff --git a/packages/webui/src/markdown/components/code-group.stories.tsx b/packages/ui/src/markdown/components/code-group.stories.tsx
similarity index 98%
rename from packages/webui/src/markdown/components/code-group.stories.tsx
rename to packages/ui/src/markdown/components/code-group.stories.tsx
index 0ded50e80c..5cb0be4d9f 100644
--- a/packages/webui/src/markdown/components/code-group.stories.tsx
+++ b/packages/ui/src/markdown/components/code-group.stories.tsx
@@ -4,7 +4,7 @@ import { CodeBlock } from '../code-block';
 import { CodeGroup } from './code-group';
 
 const meta = {
-  title: 'webui/markdown/CodeGroup',
+  title: 'markdown/CodeGroup',
   component: CodeGroup,
   tags: ['autodocs'],
   parameters: { layout: 'padded' },
diff --git a/packages/webui/src/markdown/components/code-group.tsx b/packages/ui/src/markdown/components/code-group.tsx
similarity index 58%
rename from packages/webui/src/markdown/components/code-group.tsx
rename to packages/ui/src/markdown/components/code-group.tsx
index ed0e0bf0bd..7a6816e63d 100644
--- a/packages/webui/src/markdown/components/code-group.tsx
+++ b/packages/ui/src/markdown/components/code-group.tsx
@@ -1,4 +1,3 @@
-import { cn } from '@tale/ui/cn';
 import {
   Children,
   isValidElement,
@@ -10,28 +9,39 @@ import {
   useState,
 } from 'react';
 
+import { cn } from '../../lib/cn';
+import { HighlightedCode } from '../highlighted-code';
+
 interface CodeGroupChildProps {
-  /** Mintlify uses ` ```lang ` fences inside <CodeGroup>; the title is set via `filename` or the lang label. */
+  /** Code source. Direct prop on a `<CodeBlock>` child. */
+  code?: string;
+  /** Optional filename label rendered as the tab title. */
   filename?: string;
-  /** Direct prop when authors use <CodeBlock language="..."> inside <CodeGroup>. */
+  /** Optional language identifier (Shiki + tab fallback label). */
   language?: string;
+  /** ` ```lang ` fence syntax sugar — preserved for Mintlify-style nesting. */
   className?: string;
+  /** When `code` isn't passed directly, fall back to children text. */
   children?: ReactNode;
 }
 
 interface CodeGroupProps {
   children?: ReactNode;
+  className?: string;
 }
 
 /**
- * Mintlify-compatible `<CodeGroup>`: accepts code-block children and
- * surfaces them as tabs labelled by their filename / language. Falls back
- * to a single block when only one child is given.
+ * Mintlify-compatible `<CodeGroup>` — the surrounding chrome is owned by
+ * CodeGroup itself rather than nested under each `<CodeBlock>` child, so
+ * tabs sit flush with the code panel and the bordered card has a single
+ * outline. Each child contributes a tab labelled by `filename` /
+ * `language` and a panel rendering the highlighted source via the same
+ * `<HighlightedCode>` body the standalone `<CodeBlock>` uses.
  *
- * All panels stay mounted (inactive ones hidden) so switching tabs is
- * instant and Shiki's already-rendered highlight survives the swap.
+ * All panels stay mounted (inactive ones hidden) so switching tabs
+ * preserves Shiki's already-rendered highlight.
  */
-export function CodeGroup({ children }: CodeGroupProps) {
+export function CodeGroup({ children, className }: CodeGroupProps) {
   const items = Children.toArray(children).filter(
     isValidElement,
   ) as ReactElement<CodeGroupChildProps>[];
@@ -40,7 +50,6 @@ export function CodeGroup({ children }: CodeGroupProps) {
   const groupId = useId();
 
   if (items.length === 0) return null;
-  if (items.length === 1) return <>{items[0]}</>;
 
   const focusTab = (index: number) => {
     const next = (index + items.length) % items.length;
@@ -65,11 +74,16 @@ export function CodeGroup({ children }: CodeGroupProps) {
   };
 
   return (
-    <div className="border-border-base bg-bg-elevated my-6 overflow-hidden rounded-lg border">
+    <div
+      className={cn(
+        'border-border-base bg-bg-elevated my-6 overflow-hidden rounded-lg border',
+        className,
+      )}
+    >
       <div
         role="tablist"
         aria-label="Code examples"
-        className="border-border-base flex gap-1 border-b px-2 pt-2"
+        className="border-border-base flex items-stretch border-b"
       >
         {items.map((child, i) => {
           const label = labelOf(child, i);
@@ -91,10 +105,10 @@ export function CodeGroup({ children }: CodeGroupProps) {
               onClick={() => setActive(i)}
               onKeyDown={(e) => handleKeyDown(e, i)}
               className={cn(
-                'rounded-t-md px-3 py-1.5 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-current/30',
+                '-mb-px border-b-2 px-4 py-2 font-mono text-xs transition-colors focus:outline-none focus-visible:bg-bg-base/40',
                 isActive
-                  ? 'bg-bg-base text-fg-base'
-                  : 'text-fg-muted hover:text-fg-base',
+                  ? 'border-fg-base text-fg-base'
+                  : 'text-fg-muted hover:text-fg-base border-transparent',
               )}
             >
               {label}
@@ -102,15 +116,12 @@ export function CodeGroup({ children }: CodeGroupProps) {
           );
         })}
       </div>
-      {/*
-        Render every panel once and toggle visibility instead of remounting.
-        Remounting would force the inner <CodeBlock> to re-run Shiki on each
-        tab switch, briefly flashing unhighlighted text.
-      */}
       {items.map((child, i) => {
         const isActive = i === active;
         const tabId = `${groupId}-tab-${i}`;
         const panelId = `${groupId}-panel-${i}`;
+        const code = extractCode(child);
+        const language = child.props.language ?? extractLanguage(child);
         return (
           <div
             key={panelId}
@@ -119,7 +130,7 @@ export function CodeGroup({ children }: CodeGroupProps) {
             aria-labelledby={tabId}
             hidden={!isActive}
           >
-            {child}
+            <HighlightedCode code={code} language={language} showCopyButton />
           </div>
         );
       })}
@@ -130,10 +141,21 @@ export function CodeGroup({ children }: CodeGroupProps) {
 function labelOf(child: ReactElement<CodeGroupChildProps>, i: number): string {
   const filename = child.props.filename;
   if (filename) return filename;
-  const language = child.props.language;
+  const language = child.props.language ?? extractLanguage(child);
   if (language) return language;
+  return `Tab ${i + 1}`;
+}
+
+function extractCode(child: ReactElement<CodeGroupChildProps>): string {
+  if (typeof child.props.code === 'string') return child.props.code;
+  if (typeof child.props.children === 'string') return child.props.children;
+  return '';
+}
+
+function extractLanguage(
+  child: ReactElement<CodeGroupChildProps>,
+): string | undefined {
   const className = child.props.className ?? '';
   const langMatch = /language-([a-z0-9+-]+)/i.exec(className);
-  if (langMatch) return langMatch[1];
-  return `Tab ${i + 1}`;
+  return langMatch?.[1];
 }
diff --git a/packages/webui/src/markdown/components/frame.stories.tsx b/packages/ui/src/markdown/components/frame.stories.tsx
similarity index 97%
rename from packages/webui/src/markdown/components/frame.stories.tsx
rename to packages/ui/src/markdown/components/frame.stories.tsx
index 031709531b..d08cb0afc8 100644
--- a/packages/webui/src/markdown/components/frame.stories.tsx
+++ b/packages/ui/src/markdown/components/frame.stories.tsx
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react';
 import { Frame } from './frame';
 
 const meta = {
-  title: 'webui/markdown/Frame',
+  title: 'markdown/Frame',
   component: Frame,
   tags: ['autodocs'],
   parameters: { layout: 'padded' },
diff --git a/packages/ui/src/markdown/components/frame.tsx b/packages/ui/src/markdown/components/frame.tsx
new file mode 100644
index 0000000000..ba30ed5996
--- /dev/null
+++ b/packages/ui/src/markdown/components/frame.tsx
@@ -0,0 +1,37 @@
+import type { ReactNode } from 'react';
+
+import { cn } from '../../lib/cn';
+
+interface FrameProps {
+  caption?: string;
+  children?: ReactNode;
+  className?: string;
+}
+
+/**
+ * Bordered figure for screenshots / illustrations. Single border, no
+ * inner card-in-card chrome — the content sits directly on the bordered
+ * surface and the caption (if any) renders below.
+ *
+ * Embedded `<img>` is forced to fill the frame width so authors don't
+ * have to size every screenshot manually.
+ */
+export function Frame({ caption, children, className }: FrameProps) {
+  return (
+    <figure
+      className={cn(
+        'border-border-base bg-bg-base my-6 overflow-hidden rounded-lg border',
+        className,
+      )}
+    >
+      <div className="[&>img]:block [&>img]:h-auto [&>img]:w-full">
+        {children}
+      </div>
+      {caption?.trim() ? (
+        <figcaption className="text-fg-muted border-border-base bg-bg-elevated/60 border-t px-4 py-2 text-center text-xs">
+          {caption}
+        </figcaption>
+      ) : null}
+    </figure>
+  );
+}
diff --git a/packages/ui/src/markdown/components/mermaid.stories.tsx b/packages/ui/src/markdown/components/mermaid.stories.tsx
new file mode 100644
index 0000000000..0155d640da
--- /dev/null
+++ b/packages/ui/src/markdown/components/mermaid.stories.tsx
@@ -0,0 +1,67 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { Mermaid } from './mermaid';
+
+const meta = {
+  title: 'markdown/Mermaid',
+  component: Mermaid,
+  tags: ['autodocs'],
+  parameters: { layout: 'padded' },
+} satisfies Meta<typeof Mermaid>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const FLOWCHART = `flowchart LR
+    A[Browser] --> B[TanStack Start]
+    B --> C[Convex]
+    C --> D[RAG]
+    C --> E[Agent]
+`;
+
+const SEQUENCE = `sequenceDiagram
+    participant U as User
+    participant A as Agent
+    participant K as Knowledge
+    U->>A: ask
+    A->>K: retrieve
+    K-->>A: context
+    A-->>U: answer
+`;
+
+const STATE_DIAGRAM = `stateDiagram-v2
+    [*] --> Idle
+    Idle --> Streaming
+    Streaming --> Idle
+    Streaming --> Error
+    Error --> Idle
+`;
+
+const PIE = `pie title Cache hit ratio
+    "Hit" : 78
+    "Miss" : 22
+`;
+
+const BROKEN = `flowchart TD
+    this is not valid mermaid syntax!!
+`;
+
+export const Flowchart: Story = {
+  args: { chart: FLOWCHART },
+};
+
+export const Sequence: Story = {
+  args: { chart: SEQUENCE },
+};
+
+export const StateDiagram: Story = {
+  args: { chart: STATE_DIAGRAM },
+};
+
+export const PieChart: Story = {
+  args: { chart: PIE },
+};
+
+export const ParseError: Story = {
+  args: { chart: BROKEN },
+};
diff --git a/packages/webui/src/markdown/components/mermaid.tsx b/packages/ui/src/markdown/components/mermaid.tsx
similarity index 62%
rename from packages/webui/src/markdown/components/mermaid.tsx
rename to packages/ui/src/markdown/components/mermaid.tsx
index 5ef2573262..190804abcc 100644
--- a/packages/webui/src/markdown/components/mermaid.tsx
+++ b/packages/ui/src/markdown/components/mermaid.tsx
@@ -1,6 +1,4 @@
-import { cn } from '@tale/ui/cn';
-import { useTheme } from '@tale/ui/theme';
-import { Maximize2, Minus, Plus, RotateCcw } from 'lucide-react';
+import { AlertTriangle, Maximize2, Minus, Plus, RotateCcw } from 'lucide-react';
 import {
   type PointerEvent as ReactPointerEvent,
   type WheelEvent as ReactWheelEvent,
@@ -11,6 +9,10 @@ import {
   useState,
 } from 'react';
 
+import { useResizeObserver } from '../../hooks/use-resize-observer';
+import { cn } from '../../lib/cn';
+import { useTheme } from '../../theme';
+
 interface MermaidProps {
   /** The Mermaid DSL source. */
   chart: string;
@@ -20,6 +22,12 @@ interface MermaidProps {
    * light/dark re-renders the SVG with the matching palette.
    */
   theme?: 'light' | 'dark';
+  /**
+   * Render a placeholder skeleton instead of attempting to render the DSL.
+   * Used by the streaming markdown pipeline so partial mermaid blocks (no
+   * closing fence yet) don't flash a parse-error card every keystroke.
+   */
+  streaming?: boolean;
   className?: string;
 }
 
@@ -33,14 +41,24 @@ interface MermaidApi {
 
 let mermaidPromise: Promise<MermaidApi> | null = null;
 
-/** Lazy-load mermaid the first time the component renders. */
+/**
+ * Lazy-load mermaid the first time the component renders. Clears the
+ * cached promise on rejection so a transient failure (network blip,
+ * dynamic-import error) doesn't permanently disable mermaid for the
+ * lifetime of the page.
+ */
 function loadMermaid(): Promise<MermaidApi> {
   if (mermaidPromise) return mermaidPromise;
-  mermaidPromise = import('mermaid').then((mod) => {
-    const api = mod.default as MermaidApi;
-    api.initialize({ startOnLoad: false, securityLevel: 'strict' });
-    return api;
-  });
+  mermaidPromise = import('mermaid')
+    .then((mod) => {
+      const api = mod.default as MermaidApi;
+      api.initialize({ startOnLoad: false, securityLevel: 'strict' });
+      return api;
+    })
+    .catch((cause: unknown) => {
+      mermaidPromise = null;
+      throw cause;
+    });
   return mermaidPromise;
 }
 
@@ -72,7 +90,7 @@ const INITIAL_VIEWPORT: ViewportState = { zoom: 1, panX: 0, panY: 0 };
  * so zooming in always anchors at the diagram's top-left and the page
  * itself never reflows under it.
  */
-export function Mermaid({ chart, theme, className }: MermaidProps) {
+export function Mermaid({ chart, theme, streaming, className }: MermaidProps) {
   const { resolvedTheme } = useTheme();
   const effectiveTheme: 'light' | 'dark' = theme ?? resolvedTheme;
   const [svg, setSvg] = useState<string | null>(null);
@@ -89,10 +107,16 @@ export function Mermaid({ chart, theme, className }: MermaidProps) {
     originPanX: number;
     originPanY: number;
   } | null>(null);
+  // Tracks whether the user has manually zoomed/panned. While false, the
+  // ResizeObserver below auto-refits the diagram so it stays fully visible
+  // when the stage resizes (fullscreen toggle, window resize). Once the
+  // user interacts, we leave their viewport alone.
+  const hasUserInteractedRef = useRef(false);
   const reactId = useId();
   const id = `mermaid-${reactId.replace(/[:]/g, '-')}`;
 
   useEffect(() => {
+    if (streaming) return undefined;
     let cancelled = false;
     setError(null);
     setSvg(null);
@@ -107,20 +131,48 @@ export function Mermaid({ chart, theme, className }: MermaidProps) {
       })
       .then((result) => {
         if (cancelled) return;
-        // Mermaid emits `width="100%"` plus a viewBox. With no height, the
-        // SVG's intrinsic aspect ratio shrinks to fit the parent — wide
-        // flowcharts collapse into a thin strip. Pin the SVG to the viewBox
-        // dimensions so it renders at natural size.
-        const viewBox = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(result.svg);
-        let fixed = result.svg
-          .replace(/\swidth="[^"]*"/, '')
-          .replace(/\sheight="[^"]*"/, '')
-          .replace(/\sstyle="max-width:[^"]*"/, '');
-        if (viewBox) {
-          const w = viewBox[1];
-          const h = viewBox[2];
-          fixed = fixed.replace(/<svg /, `<svg width="${w}" height="${h}" `);
-        }
+        // Mermaid emits the root `<svg>` with `width="100%"` and a viewBox.
+        // With no height attribute the SVG collapses to its intrinsic
+        // aspect ratio inside the parent — wide flowcharts shrink to a
+        // thin strip. Pin the root <svg> to its viewBox dimensions so it
+        // renders at natural size; the stage layer above handles fit-zoom.
+        //
+        // Targeting only the *root* <svg> (not nested elements that may
+        // also carry `width`/`height` attributes) and accepting any
+        // viewBox origin — sequence diagrams emit `viewBox="-N -M W H"`
+        // with negative offsets for participant lines, so the previous
+        // `viewBox="0 0 ..."` regex skipped them and the SVG rendered
+        // with no fixed dimensions.
+        const fixed = result.svg.replace(
+          /<svg([^>]*)>/,
+          (_match, rawAttrs: string) => {
+            const viewBox =
+              /viewBox="(-?[\d.]+)\s+(-?[\d.]+)\s+([\d.]+)\s+([\d.]+)"/.exec(
+                rawAttrs,
+              );
+            let cleaned = rawAttrs
+              .replace(/\swidth="[^"]*"/, '')
+              .replace(/\sheight="[^"]*"/, '')
+              .replace(/\sstyle="([^"]*)"/, (_m: string, styles: string) => {
+                const out = styles
+                  .split(';')
+                  .map((s) => s.trim())
+                  .filter(
+                    (s) =>
+                      s &&
+                      !/^(?:max-width|max-height|width|height)\s*:/i.test(s),
+                  )
+                  .join('; ');
+                return out ? ` style="${out}"` : '';
+              });
+            if (viewBox) {
+              const w = viewBox[3];
+              const h = viewBox[4];
+              cleaned = ` width="${w}" height="${h}"${cleaned}`;
+            }
+            return `<svg${cleaned}>`;
+          },
+        );
         setSvg(fixed);
         if (result.bindFunctions && containerRef.current) {
           result.bindFunctions(containerRef.current);
@@ -135,8 +187,18 @@ export function Mermaid({ chart, theme, className }: MermaidProps) {
       });
     return () => {
       cancelled = true;
+      // Mermaid mounts a temporary `<div id={id}>` to the document body
+      // while rendering — and on parse error leaves an SVG fragment
+      // (`<div id="d{id}">`) attached. Without cleanup, every revisit to a
+      // failing chart adds another stray fragment outside our component.
+      for (const stale of [id, `d${id}`]) {
+        const el = document.getElementById(stale);
+        if (el && el.parentElement === document.body) {
+          el.remove();
+        }
+      }
     };
-  }, [chart, effectiveTheme, id]);
+  }, [chart, effectiveTheme, id, streaming]);
 
   /**
    * Apply a zoom change while keeping `anchor` (a stage-relative point)
@@ -159,6 +221,7 @@ export function Mermaid({ chart, theme, className }: MermaidProps) {
           typeof next === 'function' ? next(prev.zoom) : next,
         );
         if (nextZoom === prev.zoom) return prev;
+        hasUserInteractedRef.current = true;
         const stage = stageRef.current;
         const ax = anchor?.x ?? (stage ? stage.clientWidth / 2 : 0);
         const ay = anchor?.y ?? (stage ? stage.clientHeight / 2 : 0);
@@ -175,34 +238,54 @@ export function Mermaid({ chart, theme, className }: MermaidProps) {
   );
 
   /**
-   * Recenter the diagram in the stage. The transform is anchored at
-   * `0 0`, so the SVG sits in the top-left corner by default; this offsets
-   * it to the middle. Used on first render and when the user clicks reset.
+   * Recenter the diagram in the stage at a zoom level that fits the
+   * full diagram inside the viewport. The transform is anchored at
+   * `0 0`, so the SVG sits in the top-left corner by default; this also
+   * offsets it to the middle. Capped at zoom 1 so small diagrams aren't
+   * blown up beyond their natural size.
    */
-  const centerInStage = useCallback((zoom: number = 1): ViewportState => {
+  const fitInStage = useCallback((): ViewportState => {
     const stage = stageRef.current;
     const inner = containerRef.current?.querySelector('svg');
-    if (!stage || !inner) return { zoom, panX: 0, panY: 0 };
+    if (!stage || !inner) return INITIAL_VIEWPORT;
     const stageRect = stage.getBoundingClientRect();
     const svgWidth = parseFloat(inner.getAttribute('width') ?? '0') || 0;
     const svgHeight = parseFloat(inner.getAttribute('height') ?? '0') || 0;
+    if (!svgWidth || !svgHeight || !stageRect.width || !stageRect.height) {
+      return INITIAL_VIEWPORT;
+    }
+    const fitZoom = clampZoom(
+      Math.min(1, stageRect.width / svgWidth, stageRect.height / svgHeight),
+    );
     return {
-      zoom,
-      panX: (stageRect.width - svgWidth * zoom) / 2,
-      panY: (stageRect.height - svgHeight * zoom) / 2,
+      zoom: fitZoom,
+      panX: (stageRect.width - svgWidth * fitZoom) / 2,
+      panY: (stageRect.height - svgHeight * fitZoom) / 2,
     };
   }, []);
 
   const reset = useCallback(() => {
-    setViewport(centerInStage(1));
-  }, [centerInStage]);
+    setViewport(fitInStage());
+  }, [fitInStage]);
 
-  // Center the diagram once the SVG has been injected so it starts in the
-  // middle of the stage rather than pinned at the top-left corner.
+  // Reset interaction tracking + run an initial fit each time the SVG
+  // mounts or the stage swaps between inline and fullscreen. Subsequent
+  // refits during the same SVG lifecycle are driven by the resize
+  // observer below.
   useEffect(() => {
     if (!svg) return;
-    setViewport(centerInStage(1));
-  }, [svg, centerInStage]);
+    hasUserInteractedRef.current = false;
+    const raf = requestAnimationFrame(() => setViewport(fitInStage()));
+    return () => cancelAnimationFrame(raf);
+  }, [svg, isFullscreen, fitInStage]);
+
+  // Refit on stage resize (browser resize, sidebar collapse, etc.) — but
+  // only while the user hasn't manually zoomed/panned. Their viewport
+  // sticks once they interact.
+  useResizeObserver(stageRef.current, () => {
+    if (hasUserInteractedRef.current) return;
+    setViewport(fitInStage());
+  });
 
   useEffect(() => {
     if (!isFullscreen) return undefined;
@@ -231,6 +314,7 @@ export function Mermaid({ chart, theme, className }: MermaidProps) {
       originPanY: viewport.panY,
     };
     setIsPanning(true);
+    hasUserInteractedRef.current = true;
   };
 
   const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
@@ -268,18 +352,60 @@ export function Mermaid({ chart, theme, className }: MermaidProps) {
     applyZoom((prev) => prev * (1 - event.deltaY * WHEEL_SENSITIVITY), anchor);
   };
 
+  if (streaming) {
+    return (
+      <div
+        className={cn(
+          'border-border-base bg-bg-elevated/30 my-6 flex h-72 flex-col items-center justify-center rounded-lg border',
+          className,
+        )}
+        role="status"
+        aria-label="Mermaid diagram (preparing)"
+        aria-busy="true"
+      >
+        <div className="text-fg-subtle font-mono text-xs tracking-wide uppercase">
+          Preparing diagram…
+        </div>
+      </div>
+    );
+  }
+
   if (error) {
     return (
-      <pre
+      <div
         className={cn(
-          'border-border-base bg-bg-elevated text-fg-muted my-6 overflow-x-auto rounded-lg border p-4 text-xs',
+          'border-danger/40 bg-danger-bg/40 text-fg-base my-6 overflow-hidden rounded-lg border',
           className,
         )}
         role="img"
         aria-label="Mermaid diagram (failed to render)"
       >
-        {chart}
-      </pre>
+        <div className="border-danger/20 bg-danger/10 flex items-start gap-2.5 border-b px-4 py-3">
+          <AlertTriangle
+            aria-hidden
+            className="text-danger mt-0.5 size-4 shrink-0"
+          />
+          <div className="min-w-0 flex-1 space-y-0.5">
+            <div className="text-fg-base text-sm font-medium">
+              Diagram failed to render
+            </div>
+            <div className="text-fg-muted font-mono text-xs break-words">
+              {error}
+            </div>
+          </div>
+        </div>
+        <details className="group/mermaid-error">
+          <summary className="text-fg-muted hover:text-fg-base cursor-pointer list-none px-4 py-2 text-xs select-none [&::-webkit-details-marker]:hidden">
+            <span className="group-open/mermaid-error:hidden">Show source</span>
+            <span className="hidden group-open/mermaid-error:inline">
+              Hide source
+            </span>
+          </summary>
+          <pre className="text-fg-muted overflow-x-auto px-4 pb-3 font-mono text-[11px] leading-snug">
+            {chart}
+          </pre>
+        </details>
+      </div>
     );
   }
 
diff --git a/packages/webui/src/markdown/components/registry.tsx b/packages/ui/src/markdown/components/registry.tsx
similarity index 100%
rename from packages/webui/src/markdown/components/registry.tsx
rename to packages/ui/src/markdown/components/registry.tsx
diff --git a/packages/webui/src/markdown/components/steps.stories.tsx b/packages/ui/src/markdown/components/steps.stories.tsx
similarity index 98%
rename from packages/webui/src/markdown/components/steps.stories.tsx
rename to packages/ui/src/markdown/components/steps.stories.tsx
index b7f318a9ad..48296b04b6 100644
--- a/packages/webui/src/markdown/components/steps.stories.tsx
+++ b/packages/ui/src/markdown/components/steps.stories.tsx
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react';
 import { Step, Steps } from './steps';
 
 const meta = {
-  title: 'webui/markdown/Steps',
+  title: 'markdown/Steps',
   component: Steps,
   tags: ['autodocs'],
   parameters: { layout: 'padded' },
diff --git a/packages/webui/src/markdown/components/steps.tsx b/packages/ui/src/markdown/components/steps.tsx
similarity index 97%
rename from packages/webui/src/markdown/components/steps.tsx
rename to packages/ui/src/markdown/components/steps.tsx
index 2fc5c93156..ddc64dc869 100644
--- a/packages/webui/src/markdown/components/steps.tsx
+++ b/packages/ui/src/markdown/components/steps.tsx
@@ -1,4 +1,3 @@
-import { cn } from '@tale/ui/cn';
 import {
   Children,
   isValidElement,
@@ -6,6 +5,8 @@ import {
   type ReactNode,
 } from 'react';
 
+import { cn } from '../../lib/cn';
+
 interface StepProps {
   title?: string;
   children?: ReactNode;
diff --git a/packages/webui/src/markdown/components/tabs.stories.tsx b/packages/ui/src/markdown/components/tabs.stories.tsx
similarity index 97%
rename from packages/webui/src/markdown/components/tabs.stories.tsx
rename to packages/ui/src/markdown/components/tabs.stories.tsx
index 17dfa89df9..fd1493a99b 100644
--- a/packages/webui/src/markdown/components/tabs.stories.tsx
+++ b/packages/ui/src/markdown/components/tabs.stories.tsx
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react';
 import { Tab, Tabs } from './tabs';
 
 const meta = {
-  title: 'webui/markdown/Tabs',
+  title: 'markdown/Tabs',
   component: Tabs,
   tags: ['autodocs'],
   parameters: {
diff --git a/packages/webui/src/markdown/components/tabs.tsx b/packages/ui/src/markdown/components/tabs.tsx
similarity index 98%
rename from packages/webui/src/markdown/components/tabs.tsx
rename to packages/ui/src/markdown/components/tabs.tsx
index 9eeb429059..83f25529ca 100644
--- a/packages/webui/src/markdown/components/tabs.tsx
+++ b/packages/ui/src/markdown/components/tabs.tsx
@@ -1,4 +1,3 @@
-import { cn } from '@tale/ui/cn';
 import {
   Children,
   isValidElement,
@@ -10,6 +9,8 @@ import {
   useState,
 } from 'react';
 
+import { cn } from '../../lib/cn';
+
 interface TabProps {
   title: string;
   children?: ReactNode;
diff --git a/packages/webui/src/markdown/extract-toc.test.ts b/packages/ui/src/markdown/extract-toc.test.ts
similarity index 100%
rename from packages/webui/src/markdown/extract-toc.test.ts
rename to packages/ui/src/markdown/extract-toc.test.ts
diff --git a/packages/webui/src/markdown/extract-toc.ts b/packages/ui/src/markdown/extract-toc.ts
similarity index 100%
rename from packages/webui/src/markdown/extract-toc.ts
rename to packages/ui/src/markdown/extract-toc.ts
diff --git a/packages/ui/src/markdown/formatting.stories.tsx b/packages/ui/src/markdown/formatting.stories.tsx
new file mode 100644
index 0000000000..ee1607a887
--- /dev/null
+++ b/packages/ui/src/markdown/formatting.stories.tsx
@@ -0,0 +1,84 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { Markdown } from './markdown';
+
+const meta = {
+  title: 'markdown/Samples/Formatting',
+  component: Markdown,
+  tags: ['autodocs'],
+  parameters: { layout: 'padded' },
+} satisfies Meta<typeof Markdown>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const INLINE = `# Inline formatting
+
+Body prose with **bold**, _italic_, ~~strikethrough~~, and \`inline code\`.
+You can [link to docs](https://docs.tale.dev) or to a heading on the same
+page like [#getting-started](#getting-started). External links open in a new
+tab; internal anchor links scroll smoothly.
+
+Combine **_bold italic_** or **\`bold code\`** when emphasis stacks. The
+muted text-fg-muted token keeps body prose calm so highlights pop.
+
+You can use a thematic break to separate sections:
+
+---
+
+After the rule, the next block continues at full body weight.
+`;
+
+const LISTS = `# Lists
+
+## Bullet list
+
+- First item
+- Second item
+  - Nested item
+  - Another nested
+    - Three deep
+- Third item
+
+## Ordered list
+
+1. Plan
+2. Build
+3. Ship
+   1. Open PR
+   2. Address review
+   3. Merge
+
+## Task list (GFM)
+
+- [x] Migrate webui markdown
+- [x] Stream chat into shared package
+- [ ] Refactor legal-page
+- [ ] Recreate Pencil designs
+`;
+
+const QUOTES = `# Block quotes
+
+> A short quotation. The blockquote rail picks up the accent token.
+> Multiple lines wrap into one quote block.
+
+> Nested quotes work too —
+>
+> > like this — nested level two
+> >
+> > > and even three levels deep, though that's rare in real prose.
+
+> A quote followed by **rich** _formatting_ and \`code\` still renders.
+`;
+
+export const InlineElements: Story = {
+  args: { children: INLINE },
+};
+
+export const Lists: Story = {
+  args: { children: LISTS },
+};
+
+export const Blockquotes: Story = {
+  args: { children: QUOTES },
+};
diff --git a/packages/ui/src/markdown/globals.css b/packages/ui/src/markdown/globals.css
new file mode 100644
index 0000000000..510b2930f1
--- /dev/null
+++ b/packages/ui/src/markdown/globals.css
@@ -0,0 +1,33 @@
+@import '../globals.css';
+
+@source '../**/*.{ts,tsx}';
+
+/* Page-level background + foreground that track the active theme. Loaded
+   standalone by Storybook so the iframe canvas inverts when the
+   addon-themes toggle flips `<html>` to `.dark`. Consumers that own their
+   own app shell typically set their own body bg and override these. */
+html,
+body {
+  background-color: var(--color-bg-base);
+  color: var(--color-fg-base);
+}
+
+/* Shiki line numbering. Driven by a single `code-block-numbered` class on
+   the wrapper instead of Tailwind arbitrary-property utilities so the
+   selector chain (counter-reset on <pre>, counter on each `.line::before`)
+   stays a single coherent block that's easy to debug. */
+.code-block-numbered pre {
+  counter-reset: line;
+}
+
+.code-block-numbered .line::before {
+  content: counter(line);
+  counter-increment: line;
+  display: inline-block;
+  width: 1.75rem;
+  margin-right: 1rem;
+  text-align: right;
+  color: var(--color-fg-subtle);
+  user-select: none;
+  -webkit-user-select: none;
+}
diff --git a/packages/ui/src/markdown/globals.css.d.ts b/packages/ui/src/markdown/globals.css.d.ts
new file mode 100644
index 0000000000..35306c6fc9
--- /dev/null
+++ b/packages/ui/src/markdown/globals.css.d.ts
@@ -0,0 +1 @@
+declare module '*.css';
diff --git a/packages/ui/src/markdown/headings.stories.tsx b/packages/ui/src/markdown/headings.stories.tsx
new file mode 100644
index 0000000000..d08d9833f3
--- /dev/null
+++ b/packages/ui/src/markdown/headings.stories.tsx
@@ -0,0 +1,65 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { Markdown } from './markdown';
+
+const meta = {
+  title: 'markdown/Samples/Headings',
+  component: Markdown,
+  tags: ['autodocs'],
+  parameters: { layout: 'padded' },
+} satisfies Meta<typeof Markdown>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const HEADINGS = `# Heading 1 — page title
+
+Body paragraph after a top-level heading. Use one h1 per page.
+
+## Heading 2 — section
+
+Body paragraph after a section heading. Headings cascade down by importance.
+
+### Heading 3 — subsection
+
+Body paragraph after a subsection heading.
+
+#### Heading 4 — subsubsection
+
+Body paragraph after a deep subsection.
+
+##### Heading 5
+
+Body paragraph.
+
+###### Heading 6
+
+Body paragraph.
+`;
+
+const HEADING_HIERARCHY = `# Top-level page
+
+## First section
+
+### A nested concept
+
+Some prose under the nested concept.
+
+### Another nested concept
+
+More prose.
+
+## Second section
+
+### Yet another concept
+
+Final paragraph.
+`;
+
+export const AllLevels: Story = {
+  args: { children: HEADINGS },
+};
+
+export const Hierarchy: Story = {
+  args: { children: HEADING_HIERARCHY },
+};
diff --git a/packages/ui/src/markdown/highlighted-code.tsx b/packages/ui/src/markdown/highlighted-code.tsx
new file mode 100644
index 0000000000..09d4f04e13
--- /dev/null
+++ b/packages/ui/src/markdown/highlighted-code.tsx
@@ -0,0 +1,159 @@
+import { Check, Copy } from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
+
+import { cn } from '../lib/cn';
+import { useTheme } from '../theme';
+import { highlightCode } from './shiki';
+
+const LINE_NUMBER_THRESHOLD = 3;
+
+interface HighlightedCodeProps {
+  /** Source string. A single trailing newline is normalised away so the
+   * gutter count matches Shiki's emitted `<span class="line">` rows. */
+  code: string;
+  language?: string;
+  /** Force-show line numbers regardless of length. Defaults to auto
+   * (lines > 3). Set false to suppress the gutter for short snippets. */
+  showLineNumbers?: boolean;
+  /** Render a hover-revealed copy button anchored to the top-right. */
+  showCopyButton?: boolean;
+  className?: string;
+}
+
+/**
+ * Add per-line background tints to Shiki's diff output. Shiki wraps each
+ * source line in `<span class="line">…</span>`; we tag those whose first
+ * visible character is `+` or `-` so CSS can paint the whole row.
+ */
+function applyDiffLineBackgrounds(html: string): string {
+  if (typeof DOMParser === 'undefined') return html;
+  const doc = new DOMParser().parseFromString(html, 'text/html');
+  const lines = doc.querySelectorAll<HTMLElement>('.line');
+  for (const line of lines) {
+    const first = line.textContent?.[0];
+    if (first === '+') {
+      line.classList.add('diff-add', 'bg-green-500/15', 'dark:bg-green-500/10');
+    } else if (first === '-') {
+      line.classList.add('diff-del', 'bg-red-500/15', 'dark:bg-red-500/10');
+    }
+  }
+  const pre = doc.querySelector('pre');
+  if (pre) pre.classList.add('shiki-diff');
+  return doc.body.innerHTML;
+}
+
+/**
+ * Highlighted code body — the inner panel shared by `<CodeBlock>` and
+ * each `<CodeGroup>` tab. Owns the Shiki call, line-number toggle, diff
+ * tints, row-hover affordance, and (optionally) an inline copy button.
+ *
+ * Falls back to a plain `<pre>` while Shiki loads so layout never shifts.
+ */
+export function HighlightedCode({
+  code,
+  language,
+  showLineNumbers,
+  showCopyButton,
+  className,
+}: HighlightedCodeProps) {
+  const { resolvedTheme } = useTheme();
+  const [html, setHtml] = useState<string | null>(null);
+  const [copied, setCopied] = useState(false);
+
+  // Strip the trailing newline most fenced blocks carry. Both the gutter
+  // count and Shiki must tokenise the same string — otherwise Shiki emits
+  // an extra `<span class="line">` for the trailing newline while the
+  // gutter generates one fewer number, and the last visible row has no
+  // line number.
+  const normalisedCode = useMemo(
+    () => (code.endsWith('\n') ? code.slice(0, -1) : code),
+    [code],
+  );
+
+  useEffect(() => {
+    let cancelled = false;
+    void highlightCode(normalisedCode, language, resolvedTheme).then(
+      (result) => {
+        if (cancelled) return;
+        if (!result) {
+          // Oversized input or highlighter init failure — drop the cached
+          // html so the plain `<pre>` fallback renders the new source.
+          setHtml(null);
+          return;
+        }
+        setHtml(
+          result.language === 'diff'
+            ? applyDiffLineBackgrounds(result.html)
+            : result.html,
+        );
+      },
+    );
+    return () => {
+      cancelled = true;
+    };
+  }, [normalisedCode, language, resolvedTheme]);
+
+  const handleCopy = async () => {
+    try {
+      await navigator.clipboard.writeText(normalisedCode);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 1500);
+    } catch (error) {
+      console.warn('[code-block] clipboard write failed', error);
+    }
+  };
+
+  const lineCount = useMemo(
+    () => normalisedCode.split('\n').length,
+    [normalisedCode],
+  );
+  const numbered = showLineNumbers ?? lineCount > LINE_NUMBER_THRESHOLD;
+
+  return (
+    <div className={cn('group/highlighted-code relative', className)}>
+      {showCopyButton ? (
+        <button
+          type="button"
+          onClick={handleCopy}
+          disabled={copied}
+          aria-label={copied ? 'Copied' : 'Copy code'}
+          aria-live="polite"
+          className={cn(
+            'text-fg-muted hover:text-fg-base hover:bg-bg-elevated/80 absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-current/20',
+            'opacity-0 group-hover/highlighted-code:opacity-100 focus-visible:opacity-100',
+            copied && 'text-emerald-600 opacity-100 dark:text-emerald-400',
+          )}
+        >
+          {copied ? (
+            <Check className="size-3.5" aria-hidden />
+          ) : (
+            <Copy className="size-3.5" aria-hidden />
+          )}
+        </button>
+      ) : null}
+      {html ? (
+        <div
+          className={cn(
+            // The outer wrapper owns the background; Shiki's per-pre bg is
+            // forced transparent so the same surface reaches every edge —
+            // no visible double-padding ring.
+            '[&_.line:hover]:bg-fg-base/[0.04] overflow-x-auto p-4 text-sm [&_.line]:-mx-4 [&_.line]:inline-block [&_.line]:w-[calc(100%+2rem)] [&_.line]:px-4 [&_.line]:transition-colors [&>pre]:m-0 [&>pre]:bg-transparent! [&>pre]:p-0 [&>pre>code>.line]:leading-[1.6]',
+            showCopyButton && 'pr-12',
+            numbered && 'code-block-numbered',
+          )}
+          // oxlint-disable-next-line react/no-danger -- Shiki output is HTML by design
+          dangerouslySetInnerHTML={{ __html: html }}
+        />
+      ) : (
+        <pre
+          className={cn(
+            'overflow-x-auto p-4 text-sm leading-[1.6]',
+            showCopyButton && 'pr-12',
+          )}
+        >
+          <code>{normalisedCode}</code>
+        </pre>
+      )}
+    </div>
+  );
+}
diff --git a/packages/ui/src/markdown/markdown.stories.tsx b/packages/ui/src/markdown/markdown.stories.tsx
new file mode 100644
index 0000000000..8ea80033e2
--- /dev/null
+++ b/packages/ui/src/markdown/markdown.stories.tsx
@@ -0,0 +1,99 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { mintlifyComponents } from './components/registry';
+import { Markdown } from './markdown';
+
+const meta = {
+  title: 'markdown/Markdown',
+  component: Markdown,
+  tags: ['autodocs'],
+  parameters: { layout: 'padded' },
+} satisfies Meta<typeof Markdown>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const SAMPLE = `# Agent concepts
+
+The mental model behind Tale agents — instructions, knowledge, tools, and models.
+
+## Instructions
+
+Instructions tell the agent **how** to behave. Use sentence case.
+
+## Tools
+
+Tools are how the agent **does things**. Examples include:
+
+- HTTP fetchers
+- Vector-store retrievers
+- Webhook senders
+
+\`\`\`typescript
+import { defineAgent } from '@tale/agent';
+
+export const concierge = defineAgent({
+  name: 'concierge',
+  model: 'claude-opus-4-7',
+});
+\`\`\`
+
+> Looking for the API? See [/develop/api-reference](/develop/api-reference).
+`;
+
+export const Sample: Story = {
+  args: { children: SAMPLE },
+};
+
+export const WithMintlifyComponents: Story = {
+  args: {
+    children: SAMPLE,
+    // oxlint-disable-next-line typescript/no-explicit-any -- mintlify keys aren't HTML element tags
+    components: mintlifyComponents as any,
+  },
+};
+
+const TABLE_SAMPLE = `# Tables — sticky thead + row hover
+
+The wrapper is scrollable; the header pins to the top while the body scrolls
+underneath. Hover any row to see the tint.
+
+| Region | Q1 | Q2 | Q3 | Q4 | YoY |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| EU | 1.2k | 1.7k | 2.1k | 2.6k | +18% |
+| NA | 2.0k | 2.4k | 2.9k | 3.5k | +9% |
+| APAC | 0.6k | 0.9k | 1.2k | 1.5k | +33% |
+| LATAM | 0.3k | 0.4k | 0.5k | 0.7k | +24% |
+| MEA | 0.2k | 0.3k | 0.4k | 0.5k | +28% |
+| Antarctic stations | 0.01k | 0.02k | 0.02k | 0.03k | +50% |
+| Maritime fleet | 0.05k | 0.07k | 0.09k | 0.12k | +41% |
+| Lunar relay | 0.001k | 0.002k | 0.005k | 0.011k | +900% |
+| Mars probe | 0.0001k | 0.0001k | 0.0002k | 0.0005k | +400% |
+| Deep space | 0.0001k | 0.0001k | 0.0001k | 0.0001k | 0% |
+`;
+
+export const TableWithStickyHeader: Story = {
+  args: { children: TABLE_SAMPLE },
+};
+
+const ALERTS_SAMPLE = `# GFM alerts → Callouts
+
+> [!NOTE]
+> The Markdown component swaps GitHub-style alert blockquotes for callouts.
+
+> [!TIP]
+> Use this for small lift-ups that don't warrant a full warning.
+
+> [!IMPORTANT]
+> Important context the reader needs before proceeding.
+
+> [!WARNING]
+> A risk worth pausing for.
+
+> [!CAUTION]
+> Truly destructive — confirm before acting.
+`;
+
+export const GfmAlerts: Story = {
+  args: { children: ALERTS_SAMPLE },
+};
diff --git a/packages/webui/src/markdown/markdown.tsx b/packages/ui/src/markdown/markdown.tsx
similarity index 78%
rename from packages/webui/src/markdown/markdown.tsx
rename to packages/ui/src/markdown/markdown.tsx
index 851f607112..e786bf1be2 100644
--- a/packages/webui/src/markdown/markdown.tsx
+++ b/packages/ui/src/markdown/markdown.tsx
@@ -1,4 +1,3 @@
-import { Link } from '@tanstack/react-router';
 import {
   Children,
   type ComponentPropsWithoutRef,
@@ -9,10 +8,12 @@ import ReactMarkdown, { type Components } from 'react-markdown';
 import rehypeRaw from 'rehype-raw';
 import remarkGfm from 'remark-gfm';
 
+import { cn } from '../lib/cn';
 import { AnchoredHeading } from './anchored-heading';
 import { CodeBlock } from './code-block';
 import { Callout } from './components/callout';
 import { Mermaid } from './components/mermaid';
+import { rehypeNumericColumns } from './plugins/rehype-numeric-columns';
 
 type AlertTone = 'note' | 'tip' | 'info' | 'warning' | 'danger';
 
@@ -97,7 +98,12 @@ interface MarkdownProps {
   className?: string;
 }
 
-const baseComponents: Components = {
+/**
+ * Default `components` map applied by `<Markdown>`. Exported so streaming
+ * variants (IncrementalMarkdown) and other wrappers render visually
+ * identical prose without re-defining every element.
+ */
+export const baseComponents: Components = {
   h1: ({ children }) => (
     <AnchoredHeading
       level="h1"
@@ -201,32 +207,22 @@ const baseComponents: Components = {
     </summary>
   ),
   a: ({ href, children }: ComponentPropsWithoutRef<'a'>) => {
+    // Render a plain anchor by default — keeps the package router-agnostic
+    // so it can run anywhere (Storybook, SSR, non-routed apps). Consumers
+    // that want SPA navigation pass their own `components.a` (e.g. a
+    // TanStack-Router-aware <Link>) when calling <Markdown>.
     const isExternal =
       typeof href === 'string' &&
       (href.startsWith('http://') || href.startsWith('https://'));
-    const isHash = typeof href === 'string' && href.startsWith('#');
-    const className =
-      'text-fg-base underline underline-offset-4 hover:no-underline';
-    if (!href || isExternal || isHash) {
-      return (
-        <a
-          href={href}
-          target={isExternal ? '_blank' : undefined}
-          rel={isExternal ? 'noopener noreferrer' : undefined}
-          className={className}
-        >
-          {children}
-        </a>
-      );
-    }
     return (
-      <Link
-        // oxlint-disable-next-line typescript/no-explicit-any -- runtime-typed router target
-        to={href as any}
-        className={className}
+      <a
+        href={href}
+        target={isExternal ? '_blank' : undefined}
+        rel={isExternal ? 'noopener noreferrer' : undefined}
+        className="text-fg-base underline underline-offset-4 hover:no-underline"
       >
         {children}
-      </Link>
+      </a>
     );
   },
   strong: ({ children }) => (
@@ -256,35 +252,35 @@ const baseComponents: Components = {
       </code>
     );
   },
-  pre: ({ children }) => {
-    // Pull the inner <code> element to extract language + raw text so we can
-    // hand it to <CodeBlock> for Shiki highlighting + the copy button. Code
-    // blocks tagged `mermaid` go through the <Mermaid> renderer instead.
-    const child = extractCodeChild(children);
-    if (!child) return <pre>{children}</pre>;
-    if (child.language === 'mermaid') return <Mermaid chart={child.text} />;
-    return <CodeBlock code={child.text} language={child.language} />;
-  },
+  pre: makePreComponent(),
   hr: () => <hr className="border-border-base my-10" />,
   table: ({ children }) => (
-    <div className="my-6 overflow-x-auto">
-      <table className="border-border-base w-full border-collapse border text-sm">
-        {children}
-      </table>
+    <div className="border-border-base bg-bg-base relative my-6 max-h-[36rem] overflow-auto rounded-lg border">
+      <table className="w-full border-collapse text-sm">{children}</table>
     </div>
   ),
-  thead: ({ children }) => <thead>{children}</thead>,
+  thead: ({ children }) => (
+    <thead className="bg-bg-elevated sticky top-0 z-10">{children}</thead>
+  ),
   tbody: ({ children }) => <tbody>{children}</tbody>,
   tr: ({ children }) => (
-    <tr className="hover:bg-bg-elevated/70 transition-colors">{children}</tr>
+    <tr className="hover:bg-bg-elevated/70 border-border-base border-b transition-colors last:border-b-0">
+      {children}
+    </tr>
   ),
-  th: ({ children }) => (
-    <th className="border-border-base bg-bg-elevated text-fg-base border px-3 py-2 text-left font-semibold">
+  th: ({ children, className }: ComponentPropsWithoutRef<'th'>) => (
+    <th
+      scope="col"
+      className={cn(
+        'text-fg-base border-border-base border-b px-3 py-2 text-left font-semibold',
+        className,
+      )}
+    >
       {children}
     </th>
   ),
-  td: ({ children }) => (
-    <td className="border-border-base text-fg-muted border px-3 py-2 align-top">
+  td: ({ children, className }: ComponentPropsWithoutRef<'td'>) => (
+    <td className={cn('text-fg-muted px-3 py-2 align-top', className)}>
       {children}
     </td>
   ),
@@ -304,6 +300,34 @@ interface ExtractedCode {
   text: string;
 }
 
+/**
+ * Build the `pre` Markdown component. Streaming surfaces pass
+ * `{ showLineNumbers: true, streamingMermaid: true }` so code blocks never
+ * shift content as new lines arrive and partial mermaid DSL doesn't try to
+ * render mid-stream.
+ */
+export function makePreComponent({
+  showLineNumbers,
+  streamingMermaid,
+}: { showLineNumbers?: boolean; streamingMermaid?: boolean } = {}): NonNullable<
+  Components['pre']
+> {
+  return function Pre({ children }) {
+    const child = extractCodeChild(children);
+    if (!child) return <pre>{children}</pre>;
+    if (child.language === 'mermaid') {
+      return <Mermaid chart={child.text} streaming={streamingMermaid} />;
+    }
+    return (
+      <CodeBlock
+        code={child.text}
+        language={child.language}
+        showLineNumbers={showLineNumbers}
+      />
+    );
+  };
+}
+
 function extractCodeChild(children: ReactNode): ExtractedCode | null {
   if (!children || typeof children !== 'object') return null;
   // children may be a single element or an array
@@ -349,7 +373,10 @@ export function Markdown({ children, components, className }: MarkdownProps) {
         // tags like `<CodeGroup>`, `<Note>`, `<Card>` survive as hast nodes
         // and reach the components map below. Without it those tags are
         // dropped silently and only the prose between them renders.
-        rehypePlugins={[rehypeRaw]}
+        // `rehypeNumericColumns` walks each table and tags columns whose
+        // body cells are all numeric-like with `text-right`, so finance /
+        // metric tables read aligned without any author opt-in.
+        rehypePlugins={[rehypeRaw, rehypeNumericColumns]}
         components={{ ...baseComponents, ...components }}
       >
         {children}
diff --git a/services/platform/app/features/chat/utils/__tests__/micromark-cjk-attention.test.ts b/packages/ui/src/markdown/plugins/__tests__/micromark-cjk-attention.test.ts
similarity index 100%
rename from services/platform/app/features/chat/utils/__tests__/micromark-cjk-attention.test.ts
rename to packages/ui/src/markdown/plugins/__tests__/micromark-cjk-attention.test.ts
diff --git a/services/platform/app/features/chat/utils/micromark-cjk-attention.ts b/packages/ui/src/markdown/plugins/micromark-cjk-attention.ts
similarity index 93%
rename from services/platform/app/features/chat/utils/micromark-cjk-attention.ts
rename to packages/ui/src/markdown/plugins/micromark-cjk-attention.ts
index 01973fdc81..f3c30ff92f 100644
--- a/services/platform/app/features/chat/utils/micromark-cjk-attention.ts
+++ b/packages/ui/src/markdown/plugins/micromark-cjk-attention.ts
@@ -108,6 +108,11 @@ export function micromarkCjkAttention() {
  *
  * Usage:
  *   const REMARK_PLUGINS = [remarkCjkAttention, remarkGfm];
+ *
+ * Typed loosely as `() => void` so it slots into `MarkdownOptions['remarkPlugins']`
+ * without a `Pluggable` adapter — unified's Processor.data() returns the
+ * generic Data shape, but we mutate the (untyped) `micromarkExtensions`
+ * field. The PluginThis interface narrows that single field for our use.
  */
 // oxlint-disable oxc/no-this-in-exported-function -- remark plugin API requires this binding
 export function remarkCjkAttention(this: PluginThis) {
diff --git a/packages/ui/src/markdown/plugins/rehype-numeric-columns.ts b/packages/ui/src/markdown/plugins/rehype-numeric-columns.ts
new file mode 100644
index 0000000000..f31e032cec
--- /dev/null
+++ b/packages/ui/src/markdown/plugins/rehype-numeric-columns.ts
@@ -0,0 +1,166 @@
+/**
+ * rehype-numeric-columns — Right-align table columns whose body cells are
+ * all numeric-like.
+ *
+ * "Numeric-like" covers: integers, decimals, signed numbers (`+5`, `-3.2`),
+ * percentages (`12%`), currencies (`$100`, `€9.99`), thousands-separated
+ * numbers (`1,234.56`, `1 000`), numbers with a trailing unit (`5kg`,
+ * `100 km/h`), ISO/slash dates (`2024-01-15`, `15/01/2024`) and times
+ * (`12:00`, `9:30 AM`).
+ *
+ * The plugin scans each `<table>` in the HAST tree, looks at every body
+ * cell per column, and if every non-empty cell in a column is numeric-like
+ * it appends `text-right` to both the body cells AND the matching header
+ * cell (so the column reads consistently from header to data). Cells with
+ * an explicit `align`/`text-align` from GFM column-marker syntax
+ * (`| ---: |`) are preserved as-is.
+ */
+
+import type { Element, ElementContent, Properties, Root } from 'hast';
+import { visit } from 'unist-util-visit';
+
+const RIGHT_ALIGN_CLASS = 'text-right';
+
+const NUMERIC_BODY = /^[-+]?[$€£¥₹]?\s?\d[\d,.  ]*(?:\.\d+)?$/;
+const NUMERIC_WITH_SUFFIX =
+  /^[-+]?[$€£¥₹]?\s?\d[\d,.  ]*(?:\.\d+)?\s*(?:%|°[CFK]?|[A-Za-z]{1,8}(?:\/[A-Za-z]{1,4})?)$/;
+const DATE =
+  /^\d{1,4}[-./]\d{1,2}[-./]\d{1,4}(?:[T ]\d{1,2}:\d{2}(?::\d{2})?)?$/;
+const TIME = /^\d{1,2}:\d{2}(?::\d{2})?(?:\s*(?:AM|PM|am|pm))?$/;
+
+function isNumericLike(text: string): boolean {
+  const trimmed = text.trim();
+  if (!trimmed) return false;
+  if (!/\d/.test(trimmed)) return false;
+  return (
+    NUMERIC_BODY.test(trimmed) ||
+    NUMERIC_WITH_SUFFIX.test(trimmed) ||
+    DATE.test(trimmed) ||
+    TIME.test(trimmed)
+  );
+}
+
+function getCellText(node: Element): string {
+  let text = '';
+  const walk = (children: ElementContent[]) => {
+    for (const child of children) {
+      if (child.type === 'text') {
+        text += child.value;
+      } else if (child.type === 'element') {
+        walk(child.children);
+      }
+    }
+  };
+  walk(node.children);
+  return text;
+}
+
+function hasExplicitAlignment(props: Properties | undefined): boolean {
+  if (!props) return false;
+  if (props.align) return true;
+  const style = props.style;
+  if (typeof style === 'string' && /text-align/i.test(style)) return true;
+  return false;
+}
+
+function appendClass(props: Properties, className: string): void {
+  const existing = props.className;
+  if (Array.isArray(existing)) {
+    if (!existing.includes(className)) existing.push(className);
+  } else if (typeof existing === 'string') {
+    props.className = existing
+      .split(/\s+/)
+      .filter(Boolean)
+      .concat(existing.split(/\s+/).includes(className) ? [] : [className])
+      .join(' ');
+  } else {
+    props.className = [className];
+  }
+}
+
+function findChildElement(
+  parent: Element,
+  tagName: string,
+): Element | undefined {
+  for (const child of parent.children) {
+    if (child.type === 'element' && child.tagName === tagName) return child;
+  }
+  return undefined;
+}
+
+function eachRow(parent: Element): Element[] {
+  const rows: Element[] = [];
+  for (const child of parent.children) {
+    if (child.type === 'element' && child.tagName === 'tr') rows.push(child);
+  }
+  return rows;
+}
+
+function eachCell(row: Element): Element[] {
+  const cells: Element[] = [];
+  for (const child of row.children) {
+    if (
+      child.type === 'element' &&
+      (child.tagName === 'td' || child.tagName === 'th')
+    ) {
+      cells.push(child);
+    }
+  }
+  return cells;
+}
+
+export function rehypeNumericColumns() {
+  return (tree: Root) => {
+    visit(tree, 'element', (node) => {
+      if (node.tagName !== 'table') return;
+      const tbody = findChildElement(node, 'tbody');
+      if (!tbody) return;
+
+      const bodyRows = eachRow(tbody);
+      if (bodyRows.length === 0) return;
+
+      // Bucket body cells by column index.
+      const columns: { cells: Element[]; texts: string[] }[] = [];
+      for (const row of bodyRows) {
+        const cells = eachCell(row);
+        cells.forEach((cell, colIdx) => {
+          if (!columns[colIdx]) columns[colIdx] = { cells: [], texts: [] };
+          columns[colIdx].cells.push(cell);
+          columns[colIdx].texts.push(getCellText(cell));
+        });
+      }
+
+      const numericColumns = columns.map(({ texts }) => {
+        const nonEmpty = texts.filter((t) => t.trim().length > 0);
+        if (nonEmpty.length === 0) return false;
+        return nonEmpty.every(isNumericLike);
+      });
+
+      // Apply text-right to body cells of numeric columns (skipping cells the
+      // author explicitly aligned via GFM column-marker syntax).
+      columns.forEach((col, idx) => {
+        if (!numericColumns[idx]) return;
+        for (const cell of col.cells) {
+          cell.properties = cell.properties ?? {};
+          if (hasExplicitAlignment(cell.properties)) continue;
+          appendClass(cell.properties, RIGHT_ALIGN_CLASS);
+        }
+      });
+
+      // And to the matching header cells in thead (so the column header
+      // reads aligned with the data).
+      const thead = findChildElement(node, 'thead');
+      if (thead) {
+        for (const headerRow of eachRow(thead)) {
+          const headerCells = eachCell(headerRow);
+          headerCells.forEach((cell, idx) => {
+            if (!numericColumns[idx]) return;
+            cell.properties = cell.properties ?? {};
+            if (hasExplicitAlignment(cell.properties)) return;
+            appendClass(cell.properties, RIGHT_ALIGN_CLASS);
+          });
+        }
+      }
+    });
+  };
+}
diff --git a/packages/webui/src/markdown/reading-time.ts b/packages/ui/src/markdown/reading-time.ts
similarity index 100%
rename from packages/webui/src/markdown/reading-time.ts
rename to packages/ui/src/markdown/reading-time.ts
diff --git a/packages/ui/src/markdown/routed-markdown.tsx b/packages/ui/src/markdown/routed-markdown.tsx
new file mode 100644
index 0000000000..fcd5f69496
--- /dev/null
+++ b/packages/ui/src/markdown/routed-markdown.tsx
@@ -0,0 +1,85 @@
+import { Link } from '@tanstack/react-router';
+import { type ComponentPropsWithoutRef } from 'react';
+import type { Components } from 'react-markdown';
+
+import { Markdown } from './markdown';
+
+interface RoutedMarkdownProps {
+  children: string;
+  /** Override or extend the component map. Merged on top of the router-aware
+   * `a` override so consumers can still customise individual elements. */
+  components?: Components;
+  className?: string;
+}
+
+const ROUTER_LINK_CLASS =
+  'text-fg-base underline underline-offset-4 hover:no-underline';
+
+/**
+ * SPA-aware anchor used by docs/web. External `http(s)://` and bare
+ * fragment (`#anchor`) hrefs render as plain `<a>` so target=_blank still
+ * works and on-page anchors don't go through the router; everything else
+ * defers to TanStack Router's `<Link>` for client-side navigation.
+ */
+/**
+ * Match any URI scheme (http, https, mailto, tel, ftp, sms, …) and the
+ * protocol-relative `//host` form. Anything that hits this RegExp must
+ * stay a plain `<a>`; only "real" internal app paths go through the
+ * router.
+ */
+const NON_INTERNAL_HREF_RE = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|\/\/)/;
+
+function RoutedAnchor({ href, children }: ComponentPropsWithoutRef<'a'>) {
+  const isExternal =
+    typeof href === 'string' && NON_INTERNAL_HREF_RE.test(href);
+  const isHttpExternal =
+    typeof href === 'string' &&
+    (href.startsWith('http://') || href.startsWith('https://'));
+  const isHash = typeof href === 'string' && href.startsWith('#');
+
+  if (!href || isExternal || isHash) {
+    return (
+      <a
+        href={href}
+        target={isHttpExternal ? '_blank' : undefined}
+        rel={isHttpExternal ? 'noopener noreferrer' : undefined}
+        className={ROUTER_LINK_CLASS}
+      >
+        {children}
+      </a>
+    );
+  }
+
+  return (
+    <Link
+      // oxlint-disable-next-line typescript/no-explicit-any -- runtime-typed router target
+      to={href as any}
+      className={ROUTER_LINK_CLASS}
+    >
+      {children}
+    </Link>
+  );
+}
+
+const ROUTED_COMPONENTS = { a: RoutedAnchor } satisfies Components;
+
+/**
+ * `<Markdown>` wrapper that swaps internal `<a>` for TanStack-Router's
+ * `<Link>` so consumers inside a `<RouterProvider>` get SPA navigation
+ * for free. Storybook / SSR-without-router contexts should keep using
+ * the base `<Markdown>` from `@tale/ui/markdown`.
+ */
+export function RoutedMarkdown({
+  children,
+  components,
+  className,
+}: RoutedMarkdownProps) {
+  return (
+    <Markdown
+      className={className}
+      components={{ ...ROUTED_COMPONENTS, ...components }}
+    >
+      {children}
+    </Markdown>
+  );
+}
diff --git a/packages/ui/src/markdown/shiki.ts b/packages/ui/src/markdown/shiki.ts
new file mode 100644
index 0000000000..90e94bda00
--- /dev/null
+++ b/packages/ui/src/markdown/shiki.ts
@@ -0,0 +1,221 @@
+/**
+ * Shared Shiki highlighter singleton.
+ *
+ * Strategy: 39 common grammars are statically imported so docs / web /
+ * platform get them on first paint. Anything else is lazy-loaded on
+ * demand via a runtime dynamic import. Shiki's JS regex engine keeps
+ * the bundle off the WASM oniguruma path.
+ */
+
+import { createHighlighterCore, type HighlighterCore } from 'shiki/core';
+import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
+
+let highlighterPromise: Promise<HighlighterCore> | null = null;
+
+function getHighlighter(): Promise<HighlighterCore> {
+  if (!highlighterPromise) {
+    highlighterPromise = createHighlighterCore({
+      themes: [
+        import('shiki/themes/github-dark.mjs'),
+        import('shiki/themes/github-light.mjs'),
+      ],
+      langs: [
+        import('shiki/langs/bash.mjs'),
+        import('shiki/langs/c.mjs'),
+        import('shiki/langs/cpp.mjs'),
+        import('shiki/langs/csharp.mjs'),
+        import('shiki/langs/css.mjs'),
+        import('shiki/langs/diff.mjs'),
+        import('shiki/langs/docker.mjs'),
+        import('shiki/langs/dotenv.mjs'),
+        import('shiki/langs/elixir.mjs'),
+        import('shiki/langs/go.mjs'),
+        import('shiki/langs/graphql.mjs'),
+        import('shiki/langs/hcl.mjs'),
+        import('shiki/langs/html.mjs'),
+        import('shiki/langs/http.mjs'),
+        import('shiki/langs/ini.mjs'),
+        import('shiki/langs/java.mjs'),
+        import('shiki/langs/javascript.mjs'),
+        import('shiki/langs/json.mjs'),
+        import('shiki/langs/jsx.mjs'),
+        import('shiki/langs/kotlin.mjs'),
+        import('shiki/langs/lua.mjs'),
+        import('shiki/langs/markdown.mjs'),
+        import('shiki/langs/nginx.mjs'),
+        import('shiki/langs/php.mjs'),
+        import('shiki/langs/powershell.mjs'),
+        import('shiki/langs/prisma.mjs'),
+        import('shiki/langs/python.mjs'),
+        import('shiki/langs/ruby.mjs'),
+        import('shiki/langs/rust.mjs'),
+        import('shiki/langs/scala.mjs'),
+        import('shiki/langs/scss.mjs'),
+        import('shiki/langs/sql.mjs'),
+        import('shiki/langs/swift.mjs'),
+        import('shiki/langs/toml.mjs'),
+        import('shiki/langs/tsx.mjs'),
+        import('shiki/langs/typescript.mjs'),
+        import('shiki/langs/xml.mjs'),
+        import('shiki/langs/yaml.mjs'),
+        import('shiki/langs/zig.mjs'),
+      ],
+      engine: createJavaScriptRegexEngine(),
+    }).catch((error) => {
+      highlighterPromise = null;
+      throw error;
+    });
+  }
+  return highlighterPromise;
+}
+
+const LANG_ALIASES: Record<string, string> = {
+  plaintext: 'text',
+  txt: 'text',
+  py: 'python',
+  pyi: 'python',
+  pyw: 'python',
+  js: 'javascript',
+  mjs: 'javascript',
+  cjs: 'javascript',
+  ts: 'typescript',
+  mts: 'typescript',
+  cts: 'typescript',
+  sh: 'bash',
+  zsh: 'bash',
+  shell: 'bash',
+  shellscript: 'bash',
+  ps1: 'powershell',
+  yml: 'yaml',
+  md: 'markdown',
+  htm: 'html',
+  dockerfile: 'docker',
+  env: 'dotenv',
+  rs: 'rust',
+  rb: 'ruby',
+  kt: 'kotlin',
+  kts: 'kotlin',
+  cc: 'cpp',
+  cxx: 'cpp',
+  hpp: 'cpp',
+  hxx: 'cpp',
+  h: 'c',
+  cs: 'csharp',
+  ex: 'elixir',
+  exs: 'elixir',
+  gql: 'graphql',
+  terraform: 'hcl',
+  tf: 'hcl',
+};
+
+export function resolveLanguage(input: string | undefined): string {
+  if (!input) return 'text';
+  const lower = input.toLowerCase();
+  return LANG_ALIASES[lower] ?? lower;
+}
+
+/**
+ * Cap on the input size we'll synchronously tokenize on the main thread.
+ * Above this, callers should fall back to a plain-text render — Shiki's
+ * `codeToHtml` is O(n) but blocking, and on a 250 KB document the freeze
+ * runs 300 ms-2 s on a mid-range laptop.
+ */
+export const MAX_SHIKI_BYTES = 64_000;
+
+export interface HighlightResult {
+  html: string;
+  language: string;
+}
+
+type ShikiTheme = 'light' | 'dark' | 'github-light' | 'github-dark';
+
+function normalizeTheme(theme: ShikiTheme): 'github-dark' | 'github-light' {
+  return theme === 'dark' || theme === 'github-dark'
+    ? 'github-dark'
+    : 'github-light';
+}
+
+/**
+ * Tokenize `code` into highlighted HTML. Returns `null` when:
+ *   - `code.length` exceeds `MAX_SHIKI_BYTES` (caller should plain-text)
+ *   - the underlying highlighter fails to initialize or render
+ *
+ * Languages outside the eager list are lazy-loaded on first request and
+ * cached for subsequent calls. Unknown grammars fall back to plaintext.
+ */
+export async function highlightCode(
+  code: string,
+  lang: string | undefined,
+  theme: ShikiTheme = 'light',
+): Promise<HighlightResult | null> {
+  if (code.length > MAX_SHIKI_BYTES) return null;
+
+  let highlighter: HighlighterCore;
+  try {
+    highlighter = await getHighlighter();
+  } catch (err) {
+    console.warn('[shiki] highlighter init failed:', err);
+    return null;
+  }
+
+  const resolvedTheme = normalizeTheme(theme);
+  const resolvedLang = resolveLanguage(lang);
+
+  // Shiki's `text` grammar is a built-in no-highlight pass — there is no
+  // `shiki/langs/text.mjs` to load. Skip the load attempt entirely.
+  if (resolvedLang === 'text') {
+    try {
+      return {
+        html: highlighter.codeToHtml(code, {
+          lang: 'text',
+          theme: resolvedTheme,
+        }),
+        language: 'text',
+      };
+    } catch (err) {
+      console.warn('[shiki] codeToHtml failed for lang="text":', err);
+      return null;
+    }
+  }
+
+  const loaded = highlighter.getLoadedLanguages();
+  if (!loaded.includes(resolvedLang)) {
+    try {
+      await highlighter.loadLanguage(
+        /* @vite-ignore */ import(
+          `shiki/langs/${resolvedLang}.mjs`
+        ) as Parameters<HighlighterCore['loadLanguage']>[0],
+      );
+    } catch (err) {
+      console.warn(
+        `[shiki] language "${resolvedLang}" not loadable, falling back to plaintext:`,
+        err,
+      );
+      try {
+        return {
+          html: highlighter.codeToHtml(code, {
+            lang: 'text',
+            theme: resolvedTheme,
+          }),
+          language: 'text',
+        };
+      } catch (htmlErr) {
+        console.warn('[shiki] plaintext fallback failed:', htmlErr);
+        return null;
+      }
+    }
+  }
+
+  try {
+    return {
+      html: highlighter.codeToHtml(code, {
+        lang: resolvedLang,
+        theme: resolvedTheme,
+      }),
+      language: resolvedLang,
+    };
+  } catch (err) {
+    console.warn(`[shiki] codeToHtml failed for lang="${resolvedLang}":`, err);
+    return null;
+  }
+}
diff --git a/services/platform/app/features/chat/utils/__tests__/find-block-split.test.ts b/packages/ui/src/markdown/streaming/__tests__/find-block-split.test.ts
similarity index 100%
rename from services/platform/app/features/chat/utils/__tests__/find-block-split.test.ts
rename to packages/ui/src/markdown/streaming/__tests__/find-block-split.test.ts
diff --git a/services/platform/app/features/chat/components/__tests__/incremental-markdown.test.tsx b/packages/ui/src/markdown/streaming/__tests__/incremental-markdown.test.tsx
similarity index 96%
rename from services/platform/app/features/chat/components/__tests__/incremental-markdown.test.tsx
rename to packages/ui/src/markdown/streaming/__tests__/incremental-markdown.test.tsx
index 568a4554a7..568282b5a4 100644
--- a/services/platform/app/features/chat/components/__tests__/incremental-markdown.test.tsx
+++ b/packages/ui/src/markdown/streaming/__tests__/incremental-markdown.test.tsx
@@ -7,9 +7,14 @@ import { IncrementalMarkdown } from '../incremental-markdown';
 // HELPERS
 // ============================================================================
 
-/** Count visible cursor elements (useLayoutEffect hides duplicates via display:none) */
+/**
+ * Count visible cursor elements (useLayoutEffect hides duplicates via
+ * display:none). We match the cursor's specific class so other
+ * aria-hidden decorations rendered by `baseComponents` (anchor-link
+ * indicators, copy-button icons, etc.) don't get miscounted as cursors.
+ */
 function countCursors(container: HTMLElement) {
-  const all = container.querySelectorAll<HTMLElement>('[aria-hidden="true"]');
+  const all = container.querySelectorAll<HTMLElement>('.animate-cursor-blink');
   let visible = 0;
   for (const el of all) {
     if (el.style.display !== 'none') visible++;
diff --git a/services/platform/app/features/chat/utils/__tests__/normalize-html-blocks.test.ts b/packages/ui/src/markdown/streaming/__tests__/normalize-html-blocks.test.ts
similarity index 98%
rename from services/platform/app/features/chat/utils/__tests__/normalize-html-blocks.test.ts
rename to packages/ui/src/markdown/streaming/__tests__/normalize-html-blocks.test.ts
index c646b361e7..d3483fb2a5 100644
--- a/services/platform/app/features/chat/utils/__tests__/normalize-html-blocks.test.ts
+++ b/packages/ui/src/markdown/streaming/__tests__/normalize-html-blocks.test.ts
@@ -1,7 +1,7 @@
 import { micromark } from 'micromark';
 import { describe, expect, it } from 'vitest';
 
-import { micromarkCjkAttention } from '../micromark-cjk-attention';
+import { micromarkCjkAttention } from '../../plugins/micromark-cjk-attention';
 import { normalizeHtmlBlocks } from '../normalize-html-blocks';
 
 const renderHtml = (md: string): string =>
diff --git a/services/platform/app/features/chat/utils/__tests__/remend-markdown.test.ts b/packages/ui/src/markdown/streaming/__tests__/remend-markdown.test.ts
similarity index 100%
rename from services/platform/app/features/chat/utils/__tests__/remend-markdown.test.ts
rename to packages/ui/src/markdown/streaming/__tests__/remend-markdown.test.ts
diff --git a/services/platform/app/features/chat/utils/find-block-split.ts b/packages/ui/src/markdown/streaming/find-block-split.ts
similarity index 100%
rename from services/platform/app/features/chat/utils/find-block-split.ts
rename to packages/ui/src/markdown/streaming/find-block-split.ts
diff --git a/packages/ui/src/markdown/streaming/incremental-markdown.stories.tsx b/packages/ui/src/markdown/streaming/incremental-markdown.stories.tsx
new file mode 100644
index 0000000000..62e4cae43f
--- /dev/null
+++ b/packages/ui/src/markdown/streaming/incremental-markdown.stories.tsx
@@ -0,0 +1,96 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { useEffect, useState } from 'react';
+
+import { IncrementalMarkdown } from './incremental-markdown';
+
+const meta = {
+  title: 'markdown/Streaming/IncrementalMarkdown',
+  component: IncrementalMarkdown,
+  tags: ['autodocs'],
+  parameters: { layout: 'padded' },
+} satisfies Meta<typeof IncrementalMarkdown>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const SAMPLE = `# Streaming response
+
+The IncrementalMarkdown component splits content at the last block boundary
+so completed blocks render once. Only the trailing block is re-parsed each
+frame, keeping per-frame cost flat as the response grows.
+
+Some bullets while the model is mid-thought:
+
+- The cursor lands in the last text node
+- Marker-only lines (\`\\n- \`, \`\\n# \`) are detected as empty and skipped
+- Mid-block parsing is stable thanks to remendMarkdown auto-closing markers
+
+\`\`\`typescript
+const stream = await openai.chat.completions.stream(...);
+for await (const chunk of stream) {
+  bufferRef.current += chunk.delta;
+}
+\`\`\`
+
+After streaming finishes the cursor disappears. This sentence is being typed.`;
+
+/**
+ * Continuously reveals the full sample to demonstrate the typewriter cursor
+ * landing in the last rendered text element. Loops back to 0 after a brief
+ * pause so the story is always animating.
+ */
+function useReveal(content: string, charsPerSecond: number) {
+  const [position, setPosition] = useState(0);
+  useEffect(() => {
+    let raf = 0;
+    let last = performance.now();
+    const tick = (now: number) => {
+      const dt = (now - last) / 1000;
+      last = now;
+      setPosition((prev) => {
+        const next = prev + dt * charsPerSecond;
+        if (next >= content.length + 40) return 0;
+        return next;
+      });
+      raf = requestAnimationFrame(tick);
+    };
+    raf = requestAnimationFrame(tick);
+    return () => cancelAnimationFrame(raf);
+  }, [content, charsPerSecond]);
+  return Math.min(Math.floor(position), content.length);
+}
+
+function TypewriterDemo() {
+  const revealPosition = useReveal(SAMPLE, 80);
+  const isStreaming = revealPosition < SAMPLE.length;
+  return (
+    <IncrementalMarkdown
+      content={SAMPLE}
+      revealPosition={revealPosition}
+      showCursor={isStreaming}
+      aria-busy={isStreaming}
+    />
+  );
+}
+
+export const TypewriterReveal: Story = {
+  args: { content: SAMPLE, revealPosition: 0 },
+  render: () => <TypewriterDemo />,
+};
+
+export const FullyRevealed: Story = {
+  args: {
+    content: SAMPLE,
+    revealPosition: SAMPLE.length,
+    showCursor: false,
+  },
+};
+
+export const MidBlock: Story = {
+  args: {
+    content: SAMPLE,
+    revealPosition: Math.floor(SAMPLE.length * 0.3),
+    showCursor: true,
+    'aria-busy': true,
+  },
+};
diff --git a/services/platform/app/features/chat/components/incremental-markdown.tsx b/packages/ui/src/markdown/streaming/incremental-markdown.tsx
similarity index 85%
rename from services/platform/app/features/chat/components/incremental-markdown.tsx
rename to packages/ui/src/markdown/streaming/incremental-markdown.tsx
index 34f2d30c78..2ed531ac8e 100644
--- a/services/platform/app/features/chat/components/incremental-markdown.tsx
+++ b/packages/ui/src/markdown/streaming/incremental-markdown.tsx
@@ -28,15 +28,38 @@ import rehypeRaw from 'rehype-raw';
 import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
 import remarkGfm from 'remark-gfm';
 
-import type {
-  MarkdownComponentMap,
-  MarkdownComponentType,
-} from '@/lib/utils/markdown-types';
+import { baseComponents, makePreComponent } from '../markdown';
+import { remarkCjkAttention } from '../plugins/micromark-cjk-attention';
+import { rehypeNumericColumns } from '../plugins/rehype-numeric-columns';
+import type { MarkdownComponentMap, MarkdownComponentType } from '../types';
+import { findBlockSplitPoint } from './find-block-split';
+import { normalizeHtmlBlocks } from './normalize-html-blocks';
+import { remendMarkdown } from './remend-markdown';
 
-import { findBlockSplitPoint } from '../utils/find-block-split';
-import { remarkCjkAttention } from '../utils/micromark-cjk-attention';
-import { normalizeHtmlBlocks } from '../utils/normalize-html-blocks';
-import { remendMarkdown } from '../utils/remend-markdown';
+/**
+ * Streaming defaults — the *exact same* component map `<Markdown>` uses,
+ * with two streaming-specific overrides:
+ *  - `pre`: line numbers always on, so the gutter doesn't appear partway
+ *    through and shift the content rightward when a code block crosses
+ *    the threshold;
+ *  - `pre` for `mermaid`: skeleton placeholder while the block is still
+ *    in-progress (mermaid DSL is only valid once the closing fence
+ *    arrives); the stable map renders mermaid normally.
+ *
+ * Trade-off: keeping `<CodeBlock>` for `pre` means Shiki re-tokenises on
+ * every reveal step. Storybook + casual streaming live with that; chat
+ * callers stream large code blocks via their own debounced highlighter
+ * passed through `components`.
+ */
+const STABLE_BASE = {
+  ...baseComponents,
+  pre: makePreComponent({ showLineNumbers: true }),
+} as unknown as MarkdownComponentMap;
+
+const STREAMING_BASE = {
+  ...baseComponents,
+  pre: makePreComponent({ showLineNumbers: true, streamingMermaid: true }),
+} as unknown as MarkdownComponentMap;
 
 const chatSanitizeSchema = {
   ...defaultSchema,
@@ -58,13 +81,20 @@ const remarkDisableIndentedCode = function (this: {
 
 type PluginList = NonNullable<MarkdownOptions['remarkPlugins']>;
 
+// Cast through `as PluginList` because `remarkCjkAttention` and
+// `remarkDisableIndentedCode` use narrowed `this`-types for type-safe
+// data() access — narrower than unified's `Plugin` signature, but
+// structurally compatible at runtime.
 const REMARK_PLUGINS: PluginList = [
   remarkDisableIndentedCode,
   remarkCjkAttention,
   remarkGfm,
-];
+] as PluginList;
 const REHYPE_PLUGINS: PluginList = [
   rehypeRaw,
+  // Tag numeric columns BEFORE sanitize so the appended `text-right`
+  // class flows through (className survives the default sanitize schema).
+  rehypeNumericColumns,
   [rehypeSanitize, chatSanitizeSchema],
 ];
 
@@ -426,16 +456,29 @@ export function IncrementalMarkdown({
   const streamContent = content.slice(splitIndex);
   const streamRevealLength = revealPosition - splitIndex;
 
+  // Two distinct base maps so the streaming half can defer mermaid renders
+  // (incomplete DSL) and pin code-block line numbers from the first row,
+  // while the stable half renders mermaid normally as soon as a block has
+  // graduated. Caller-provided overrides merge on top of each.
+  const stableMerged = useMemo<MarkdownComponentMap>(
+    () => ({ ...STABLE_BASE, ...components }),
+    [components],
+  );
+  const streamingMerged = useMemo<MarkdownComponentMap>(
+    () => ({ ...STREAMING_BASE, ...components }),
+    [components],
+  );
+
   return (
     <div className={className} aria-busy={ariaBusy}>
       {stableContent && (
-        <StableMarkdown content={stableContent} components={components} />
+        <StableMarkdown content={stableContent} components={stableMerged} />
       )}
       {streamContent && (
         <StreamingMarkdown
           content={streamContent}
           revealedLength={streamRevealLength}
-          components={components}
+          components={streamingMerged}
           showCursor={showCursor}
         />
       )}
diff --git a/services/platform/app/features/chat/utils/normalize-html-blocks.ts b/packages/ui/src/markdown/streaming/normalize-html-blocks.ts
similarity index 100%
rename from services/platform/app/features/chat/utils/normalize-html-blocks.ts
rename to packages/ui/src/markdown/streaming/normalize-html-blocks.ts
diff --git a/services/platform/app/features/chat/utils/remend-markdown.ts b/packages/ui/src/markdown/streaming/remend-markdown.ts
similarity index 97%
rename from services/platform/app/features/chat/utils/remend-markdown.ts
rename to packages/ui/src/markdown/streaming/remend-markdown.ts
index ac4f5aba0e..5696c9c680 100644
--- a/services/platform/app/features/chat/utils/remend-markdown.ts
+++ b/packages/ui/src/markdown/streaming/remend-markdown.ts
@@ -352,7 +352,10 @@ export function remendMarkdown(text: string): string {
     const lastDetails = result.lastIndexOf('<details');
     if (lastDetails !== -1 && !result.includes('</details>', lastDetails)) {
       const textBefore = result.slice(0, lastDetails);
-      const fenceLines = (textBefore.match(/^`{3,}/gm) || []).length;
+      // Count both backtick AND tilde fences — a `<details>` inside a
+      // `~~~`-fenced block is just as inert as one inside ``` and we
+      // mustn't mistake it for live HTML.
+      const fenceLines = (textBefore.match(/^(`{3,}|~{3,})/gm) || []).length;
       if (fenceLines % 2 === 0) {
         const detailsContent = result.slice(lastDetails);
         if (detailsContent.includes('</summary>')) {
@@ -395,6 +398,9 @@ function isCompleteSeparator(line: string, expectedCols: number): boolean {
 
 /**
  * Generate a GFM separator row for a given column count.
+ * remark-gfm + react-markdown accept a single dash per cell; existing
+ * tests + chat traffic depend on this exact shape, so don't change it
+ * without re-baselining `remend-markdown.test.ts`.
  * e.g. cols=3 → `| - | - | - |`
  */
 function makeSeparator(cols: number): string {
diff --git a/packages/ui/src/markdown/types.ts b/packages/ui/src/markdown/types.ts
new file mode 100644
index 0000000000..d68adceb24
--- /dev/null
+++ b/packages/ui/src/markdown/types.ts
@@ -0,0 +1,6 @@
+import type { ComponentType } from 'react';
+
+// oxlint-disable-next-line typescript/no-explicit-any -- Required by react-markdown's Components interface which uses ComponentType<any>
+export type MarkdownComponentType = ComponentType<any>;
+
+export type MarkdownComponentMap = Record<string, MarkdownComponentType>;
diff --git a/packages/webui/package.json b/packages/webui/package.json
index dd424dcc29..d903593b01 100644
--- a/packages/webui/package.json
+++ b/packages/webui/package.json
@@ -2,7 +2,7 @@
   "name": "@tale/webui",
   "version": "0.1.0",
   "private": true,
-  "description": "Shared web/docs UI primitives, markdown pipeline, SEO + LLM helpers",
+  "description": "Shared web/docs layout, search, SEO + LLM helpers (markdown pipeline lives in @tale/ui/markdown)",
   "type": "module",
   "exports": {
     ".": "./src/index.ts",
@@ -21,21 +21,6 @@
     "./llm/build-llms-txt": "./src/llm/build-llms-txt.ts",
     "./llm/build-llms-full-txt": "./src/llm/build-llms-full-txt.ts",
     "./llm/page-as-markdown": "./src/llm/page-as-markdown.ts",
-    "./markdown/markdown": "./src/markdown/markdown.tsx",
-    "./markdown/shiki": "./src/markdown/shiki.ts",
-    "./markdown/code-block": "./src/markdown/code-block.tsx",
-    "./markdown/anchored-heading": "./src/markdown/anchored-heading.tsx",
-    "./markdown/extract-toc": "./src/markdown/extract-toc.ts",
-    "./markdown/reading-time": "./src/markdown/reading-time.ts",
-    "./markdown/components/callout": "./src/markdown/components/callout.tsx",
-    "./markdown/components/code-group": "./src/markdown/components/code-group.tsx",
-    "./markdown/components/tabs": "./src/markdown/components/tabs.tsx",
-    "./markdown/components/steps": "./src/markdown/components/steps.tsx",
-    "./markdown/components/cards": "./src/markdown/components/cards.tsx",
-    "./markdown/components/frame": "./src/markdown/components/frame.tsx",
-    "./markdown/components/accordion": "./src/markdown/components/accordion.tsx",
-    "./markdown/components/mermaid": "./src/markdown/components/mermaid.tsx",
-    "./markdown/components/registry": "./src/markdown/components/registry.tsx",
     "./search/dialog": "./src/search/dialog.tsx",
     "./search/build-index": "./src/search/build-index.ts",
     "./search/client": "./src/search/client.ts",
@@ -67,12 +52,7 @@
     "@tanstack/react-router": "1.168.10",
     "framer-motion": "12.38.0",
     "lucide-react": "1.8.0",
-    "mermaid": "11.14.0",
-    "minisearch": "7.2.0",
-    "react-markdown": "10.1.0",
-    "rehype-raw": "7.0.0",
-    "remark-gfm": "4.0.1",
-    "shiki": "3.2.0"
+    "minisearch": "7.2.0"
   },
   "peerDependencies": {
     "react": "*",
diff --git a/packages/webui/postcss.config.mjs b/packages/webui/postcss.config.mjs
new file mode 100644
index 0000000000..5d6d8457f7
--- /dev/null
+++ b/packages/webui/postcss.config.mjs
@@ -0,0 +1,8 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+  plugins: {
+    '@tailwindcss/postcss': {},
+  },
+};
+
+export default config;
diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts
index 211ce7b00a..62773246a7 100644
--- a/packages/webui/src/index.ts
+++ b/packages/webui/src/index.ts
@@ -1,3 +1,3 @@
-// Barrel: prefer subpath imports (e.g. `@tale/webui/markdown/markdown`).
-// Kept for type re-exports only.
+// Barrel: prefer subpath imports (e.g. `@tale/webui/seo/document-meta`).
+// Kept for type re-exports only. Markdown lives in `@tale/ui/markdown`.
 export type { RegionalLocale, SupportedLocale } from '@tale/ui/i18n/locales';
diff --git a/packages/webui/src/markdown/code-block.stories.tsx b/packages/webui/src/markdown/code-block.stories.tsx
deleted file mode 100644
index 57ab31dfa8..0000000000
--- a/packages/webui/src/markdown/code-block.stories.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/react';
-
-import { CodeBlock } from './code-block';
-
-const meta = {
-  title: 'webui/markdown/CodeBlock',
-  component: CodeBlock,
-  tags: ['autodocs'],
-  parameters: { layout: 'padded' },
-} satisfies Meta<typeof CodeBlock>;
-
-export default meta;
-type Story = StoryObj<typeof meta>;
-
-const TS_SAMPLE = `import { highlightCode } from '@tale/webui/markdown/shiki';
-
-const result = await highlightCode(
-  'console.log("hello tale");',
-  'typescript',
-  'light',
-);
-`;
-
-const BASH_SAMPLE = `# install dependencies
-bun install
-
-# start the docs site on :3002
-bun run --filter @tale/docs dev
-`;
-
-export const TypeScript: Story = {
-  args: {
-    code: TS_SAMPLE,
-    language: 'typescript',
-    filename: 'highlight.ts',
-  },
-};
-
-export const Bash: Story = {
-  args: {
-    code: BASH_SAMPLE,
-    language: 'bash',
-    filename: 'README.md · quickstart',
-  },
-};
-
-export const NoCopy: Story = {
-  args: {
-    code: TS_SAMPLE,
-    language: 'typescript',
-    hideCopy: true,
-  },
-};
diff --git a/packages/webui/src/markdown/code-block.tsx b/packages/webui/src/markdown/code-block.tsx
deleted file mode 100644
index e32e46f3b0..0000000000
--- a/packages/webui/src/markdown/code-block.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-import { cn } from '@tale/ui/cn';
-import { useTheme } from '@tale/ui/theme';
-import { Check, Copy } from 'lucide-react';
-import { useEffect, useMemo, useState } from 'react';
-
-import { highlightCode } from './shiki';
-
-interface CodeBlockProps {
-  code: string;
-  language?: string;
-  /** Optional filename label rendered above the block. */
-  filename?: string;
-  /** Hide the copy button (e.g. inside CodeGroup tabs). */
-  hideCopy?: boolean;
-  className?: string;
-}
-
-const LINE_NUMBER_THRESHOLD = 3;
-
-/**
- * Add per-line background tints to Shiki's diff output. Shiki wraps each
- * source line in `<span class="line">…</span>`; we tag those whose first
- * visible character is `+` or `-` so CSS can paint the whole row. Using
- * opacity-based backgrounds keeps the rendering legible in both themes.
- */
-function applyDiffLineBackgrounds(html: string): string {
-  if (typeof DOMParser === 'undefined') return html;
-  const doc = new DOMParser().parseFromString(html, 'text/html');
-  const lines = doc.querySelectorAll<HTMLElement>('.line');
-  for (const line of lines) {
-    const first = line.textContent?.[0];
-    if (first === '+') {
-      line.classList.add('diff-add', 'bg-green-500/15', 'dark:bg-green-500/10');
-    } else if (first === '-') {
-      line.classList.add('diff-del', 'bg-red-500/15', 'dark:bg-red-500/10');
-    }
-  }
-  // Promote the .line spans to block layout so the bg fills the row width.
-  // We do this via a wrapping <pre> rule injected by the consumer's CSS;
-  // here we just mark the container so styles can target it.
-  const pre = doc.querySelector('pre');
-  if (pre) pre.classList.add('shiki-diff');
-  return doc.body.innerHTML;
-}
-
-function countLines(code: string): number {
-  // Trim the trailing newline most fenced blocks carry so an N-line block
-  // doesn't render as N+1 numbers.
-  const normalised = code.endsWith('\n') ? code.slice(0, -1) : code;
-  return normalised.split('\n').length;
-}
-
-/**
- * Highlighted, copy-friendly code block. Falls back to a plain `<pre>`
- * while Shiki loads in the background so the layout never shifts.
- *
- * The copy button lives in the header bar (always rendered) so single-line
- * blocks don't reflow when the button mounts. Line numbers appear for
- * blocks with more than three lines — small snippets stay clean.
- */
-export function CodeBlock({
-  code,
-  language,
-  filename,
-  hideCopy,
-  className,
-}: CodeBlockProps) {
-  const { resolvedTheme } = useTheme();
-  const [html, setHtml] = useState<string | null>(null);
-  const [copied, setCopied] = useState(false);
-
-  useEffect(() => {
-    let cancelled = false;
-    void highlightCode(code, language, resolvedTheme).then((result) => {
-      if (cancelled) return;
-      setHtml(
-        result.language === 'diff'
-          ? applyDiffLineBackgrounds(result.html)
-          : result.html,
-      );
-    });
-    return () => {
-      cancelled = true;
-    };
-  }, [code, language, resolvedTheme]);
-
-  const handleCopy = async () => {
-    try {
-      await navigator.clipboard.writeText(code);
-      setCopied(true);
-      setTimeout(() => setCopied(false), 1500);
-    } catch (error) {
-      console.warn('[code-block] clipboard write failed', error);
-    }
-  };
-
-  const lineCount = useMemo(() => countLines(code), [code]);
-  const showLineNumbers = lineCount > LINE_NUMBER_THRESHOLD;
-  const lineNumbers = useMemo(
-    () =>
-      showLineNumbers
-        ? Array.from({ length: lineCount }, (_, i) => i + 1)
-        : null,
-    [lineCount, showLineNumbers],
-  );
-
-  // Always render the header bar so the layout doesn't shift between
-  // labelled and bare blocks. The label slot collapses to whitespace when
-  // we have neither a filename nor a language tag.
-  const showHeader = true;
-  const headerLabel = filename ?? language ?? '';
-
-  return (
-    <div
-      className={cn(
-        'group/code-block border-border-base bg-bg-elevated relative my-6 overflow-hidden rounded-lg border',
-        className,
-      )}
-    >
-      {showHeader ? (
-        <div className="border-border-base flex h-9 items-center justify-between gap-4 border-b px-3 text-xs">
-          <span className="text-fg-muted truncate font-mono">
-            {headerLabel}
-            {language && filename ? (
-              <span className="ml-2 opacity-60">{language}</span>
-            ) : null}
-          </span>
-          {hideCopy ? null : (
-            <button
-              type="button"
-              onClick={handleCopy}
-              disabled={copied}
-              aria-label={copied ? 'Copied' : 'Copy code'}
-              aria-live="polite"
-              // Fixed footprint so the icon swap doesn't reflow surrounding
-              // chrome. The label is announced via aria-label for SR users.
-              className={cn(
-                'text-fg-muted hover:text-fg-base hover:bg-bg-base/60 inline-flex size-7 shrink-0 items-center justify-center rounded transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-current/20',
-                copied && 'text-emerald-600',
-              )}
-            >
-              {copied ? (
-                <Check className="size-3.5" aria-hidden />
-              ) : (
-                <Copy className="size-3.5" aria-hidden />
-              )}
-            </button>
-          )}
-        </div>
-      ) : null}
-      <div className="relative">
-        <div className="flex">
-          {showLineNumbers && lineNumbers ? (
-            <div
-              aria-hidden
-              className="border-border-base text-fg-subtle bg-bg-elevated/40 border-r px-3 py-4 text-right font-mono text-xs leading-[1.6] select-none"
-            >
-              {lineNumbers.map((n) => (
-                <div key={n}>{n}</div>
-              ))}
-            </div>
-          ) : null}
-          {html ? (
-            <div
-              className="min-w-0 flex-1 overflow-x-auto p-4 text-sm [&_.shiki-diff_.line]:-mx-4 [&_.shiki-diff_.line]:inline-block [&_.shiki-diff_.line]:w-[calc(100%+2rem)] [&_.shiki-diff_.line]:px-4 [&>pre]:m-0 [&>pre]:bg-transparent! [&>pre]:p-0 [&>pre>code>.line]:leading-[1.6]"
-              // oxlint-disable-next-line react/no-danger -- Shiki output is HTML by design
-              dangerouslySetInnerHTML={{ __html: html }}
-            />
-          ) : (
-            <pre className="min-w-0 flex-1 overflow-x-auto p-4 text-sm leading-[1.6]">
-              <code>{code}</code>
-            </pre>
-          )}
-        </div>
-      </div>
-    </div>
-  );
-}
diff --git a/packages/webui/src/markdown/components/frame.tsx b/packages/webui/src/markdown/components/frame.tsx
deleted file mode 100644
index cad5d15999..0000000000
--- a/packages/webui/src/markdown/components/frame.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { cn } from '@tale/ui/cn';
-import type { ReactNode } from 'react';
-
-interface FrameProps {
-  caption?: string;
-  children?: ReactNode;
-  className?: string;
-}
-
-export function Frame({ caption, children, className }: FrameProps) {
-  return (
-    <figure
-      className={cn(
-        'border-border-base bg-bg-elevated/40 my-6 overflow-hidden rounded-lg border p-3',
-        className,
-      )}
-    >
-      <div className="bg-bg-base overflow-hidden rounded-md [&_img]:h-auto [&_img]:w-full">
-        {children}
-      </div>
-      {caption?.trim() ? (
-        <figcaption className="text-fg-muted mt-2 text-center text-xs">
-          {caption}
-        </figcaption>
-      ) : null}
-    </figure>
-  );
-}
diff --git a/packages/webui/src/markdown/markdown.stories.tsx b/packages/webui/src/markdown/markdown.stories.tsx
deleted file mode 100644
index 6cf7aa7e95..0000000000
--- a/packages/webui/src/markdown/markdown.stories.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/react';
-
-import { mintlifyComponents } from './components/registry';
-import { Markdown } from './markdown';
-
-const meta = {
-  title: 'webui/markdown/Markdown',
-  component: Markdown,
-  tags: ['autodocs'],
-  parameters: { layout: 'padded' },
-} satisfies Meta<typeof Markdown>;
-
-export default meta;
-type Story = StoryObj<typeof meta>;
-
-const SAMPLE = `# Agent concepts
-
-The mental model behind Tale agents — instructions, knowledge, tools, and models.
-
-## Instructions
-
-Instructions tell the agent **how** to behave. Use sentence case.
-
-## Tools
-
-Tools are how the agent **does things**. Examples include:
-
-- HTTP fetchers
-- Vector-store retrievers
-- Webhook senders
-
-\`\`\`typescript
-import { defineAgent } from '@tale/agent';
-
-export const concierge = defineAgent({
-  name: 'concierge',
-  model: 'claude-opus-4-7',
-});
-\`\`\`
-
-> Looking for the API? See [/develop/api-reference](/develop/api-reference).
-`;
-
-export const Sample: Story = {
-  args: { children: SAMPLE },
-};
-
-export const WithMintlifyComponents: Story = {
-  args: {
-    children: SAMPLE,
-    // oxlint-disable-next-line typescript/no-explicit-any -- mintlify keys aren't HTML element tags
-    components: mintlifyComponents as any,
-  },
-};
diff --git a/packages/webui/src/markdown/shiki.ts b/packages/webui/src/markdown/shiki.ts
deleted file mode 100644
index c251988c99..0000000000
--- a/packages/webui/src/markdown/shiki.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * Shared Shiki highlighter singleton. JS regex engine + statically-imported
- * common grammars keep the bundle small while making sure every language we
- * actually use in docs/markdown highlights without needing a runtime
- * resolver — Vite cannot bundle `shiki/langs/${lang}.mjs` reliably from a
- * dynamic specifier, so we declare the list up front.
- */
-
-import { createHighlighterCore, type HighlighterCore } from 'shiki/core';
-import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
-
-let highlighterPromise: Promise<HighlighterCore> | null = null;
-
-function getHighlighter(): Promise<HighlighterCore> {
-  if (!highlighterPromise) {
-    highlighterPromise = createHighlighterCore({
-      themes: [
-        import('shiki/themes/github-dark.mjs'),
-        import('shiki/themes/github-light.mjs'),
-      ],
-      langs: [
-        import('shiki/langs/bash.mjs'),
-        import('shiki/langs/css.mjs'),
-        import('shiki/langs/diff.mjs'),
-        import('shiki/langs/docker.mjs'),
-        import('shiki/langs/dotenv.mjs'),
-        import('shiki/langs/html.mjs'),
-        import('shiki/langs/http.mjs'),
-        import('shiki/langs/javascript.mjs'),
-        import('shiki/langs/json.mjs'),
-        import('shiki/langs/jsx.mjs'),
-        import('shiki/langs/markdown.mjs'),
-        import('shiki/langs/nginx.mjs'),
-        import('shiki/langs/powershell.mjs'),
-        import('shiki/langs/python.mjs'),
-        import('shiki/langs/sql.mjs'),
-        import('shiki/langs/tsx.mjs'),
-        import('shiki/langs/typescript.mjs'),
-        import('shiki/langs/xml.mjs'),
-        import('shiki/langs/yaml.mjs'),
-      ],
-      engine: createJavaScriptRegexEngine(),
-    }).catch((error) => {
-      highlighterPromise = null;
-      throw error;
-    });
-  }
-  return highlighterPromise;
-}
-
-const LANG_ALIASES: Record<string, string> = {
-  plaintext: 'text',
-  txt: 'text',
-  py: 'python',
-  pyi: 'python',
-  pyw: 'python',
-  js: 'javascript',
-  mjs: 'javascript',
-  cjs: 'javascript',
-  ts: 'typescript',
-  mts: 'typescript',
-  cts: 'typescript',
-  sh: 'bash',
-  zsh: 'bash',
-  shell: 'bash',
-  shellscript: 'bash',
-  ps1: 'powershell',
-  yml: 'yaml',
-  md: 'markdown',
-  htm: 'html',
-  dockerfile: 'docker',
-  env: 'dotenv',
-};
-
-function resolveLang(input: string | undefined): string {
-  if (!input) return 'text';
-  const lower = input.toLowerCase();
-  return LANG_ALIASES[lower] ?? lower;
-}
-
-export interface HighlightResult {
-  html: string;
-  language: string;
-}
-
-export async function highlightCode(
-  code: string,
-  lang: string | undefined,
-  theme: 'light' | 'dark' = 'light',
-): Promise<HighlightResult> {
-  const resolved = resolveLang(lang);
-  const highlighter = await getHighlighter();
-  const loaded = highlighter.getLoadedLanguages();
-  const final = loaded.includes(resolved) ? resolved : 'text';
-  const html = highlighter.codeToHtml(code, {
-    lang: final,
-    theme: theme === 'dark' ? 'github-dark' : 'github-light',
-  });
-  return { html, language: final };
-}
diff --git a/services/docs/app/components/docs/docs-toc.tsx b/services/docs/app/components/docs/docs-toc.tsx
index 5d065568d6..807200bdcf 100644
--- a/services/docs/app/components/docs/docs-toc.tsx
+++ b/services/docs/app/components/docs/docs-toc.tsx
@@ -1,5 +1,5 @@
 import { cn } from '@tale/ui/cn';
-import type { TocEntry } from '@tale/webui/markdown/extract-toc';
+import type { TocEntry } from '@tale/ui/markdown/extract-toc';
 import { useEffect, useState } from 'react';
 
 import { useT } from '@/lib/i18n/client';
diff --git a/services/docs/app/pages/docs-page.tsx b/services/docs/app/pages/docs-page.tsx
index 2f37c92d9d..3cf512bdd6 100644
--- a/services/docs/app/pages/docs-page.tsx
+++ b/services/docs/app/pages/docs-page.tsx
@@ -1,9 +1,9 @@
+import { mintlifyComponents } from '@tale/ui/markdown/components/registry';
+import { extractToc } from '@tale/ui/markdown/extract-toc';
+import { readingTimeMinutes } from '@tale/ui/markdown/reading-time';
+import { RoutedMarkdown } from '@tale/ui/markdown/routed-markdown';
 import { PageActions } from '@tale/webui/ai/page-actions';
 import { pageAsMarkdown } from '@tale/webui/llm/page-as-markdown';
-import { mintlifyComponents } from '@tale/webui/markdown/components/registry';
-import { extractToc } from '@tale/webui/markdown/extract-toc';
-import { Markdown } from '@tale/webui/markdown/markdown';
-import { readingTimeMinutes } from '@tale/webui/markdown/reading-time';
 import { useDocumentMeta } from '@tale/webui/seo/document-meta';
 import {
   buildArticleJsonLd,
@@ -205,13 +205,13 @@ export function DocsPage({ locale, slug }: DocsPageProps) {
             ) : null}
           </p>
         </header>
-        <Markdown
+        <RoutedMarkdown
           // oxlint-disable-next-line typescript/no-explicit-any -- mintlify keys aren't HTML element tags; react-markdown's `Components` type only models built-in elements
           components={mintlifyComponents as any}
           className="mt-6"
         >
           {doc.body}
-        </Markdown>
+        </RoutedMarkdown>
         <DocsPrevNext locale={locale} prevSlug={prev} nextSlug={next} />
         <div className="mt-4 flex justify-end">
           <EditOnGithub contentPath={contentPath} />
diff --git a/services/platform/app/features/chat/components/canvas/__tests__/canvas-pane.test.tsx b/services/platform/app/features/chat/components/canvas/__tests__/canvas-pane.test.tsx
index 4791f6375b..9eabf789d7 100644
--- a/services/platform/app/features/chat/components/canvas/__tests__/canvas-pane.test.tsx
+++ b/services/platform/app/features/chat/components/canvas/__tests__/canvas-pane.test.tsx
@@ -75,7 +75,7 @@ vi.mock('@/app/components/theme/theme-provider', () => ({
 }));
 
 vi.mock('@/lib/utils/shiki', () => ({
-  highlightCode: vi.fn(() => Promise.resolve('')),
+  highlightCode: vi.fn(() => Promise.resolve({ html: '', language: 'text' })),
 }));
 
 function OpenCanvasButton() {
diff --git a/services/platform/app/features/chat/components/canvas/canvas-code-renderer.tsx b/services/platform/app/features/chat/components/canvas/canvas-code-renderer.tsx
index c1e677efe2..ecd6c76bab 100644
--- a/services/platform/app/features/chat/components/canvas/canvas-code-renderer.tsx
+++ b/services/platform/app/features/chat/components/canvas/canvas-code-renderer.tsx
@@ -156,7 +156,7 @@ function CanvasCodeRendererComponent({
     let cancelled = false;
     void highlightCode(code, language, shikiTheme).then((result) => {
       if (!cancelled && result) {
-        setHtml(extractShikiCodeContent(result));
+        setHtml(extractShikiCodeContent(result.html));
       }
     });
     return () => {
diff --git a/services/platform/app/features/chat/components/message-bubble/__tests__/code-block.test.tsx b/services/platform/app/features/chat/components/message-bubble/__tests__/code-block.test.tsx
index b711df31f7..74c156c2d4 100644
--- a/services/platform/app/features/chat/components/message-bubble/__tests__/code-block.test.tsx
+++ b/services/platform/app/features/chat/components/message-bubble/__tests__/code-block.test.tsx
@@ -13,12 +13,14 @@ vi.mock('@/app/hooks/use-toast', () => ({
   useToast: () => ({ toast: vi.fn() }),
 }));
 
-// Mock Shiki — returns a predictable HTML string
+// Mock Shiki — returns the `{ html, language }` shape the shared
+// `@tale/ui/markdown/shiki` exports so callers extracting `.html` work.
 vi.mock('@/lib/utils/shiki', () => ({
-  highlightCode: vi.fn((code: string) =>
-    Promise.resolve(
-      `<pre class="shiki"><code><span class="line">${code}</span></code></pre>`,
-    ),
+  highlightCode: vi.fn((code: string, language: string) =>
+    Promise.resolve({
+      html: `<pre class="shiki"><code><span class="line">${code}</span></code></pre>`,
+      language,
+    }),
   ),
 }));
 
diff --git a/services/platform/app/features/chat/components/message-bubble/code-block.tsx b/services/platform/app/features/chat/components/message-bubble/code-block.tsx
index 5ebf6d5c4d..10acf7c650 100644
--- a/services/platform/app/features/chat/components/message-bubble/code-block.tsx
+++ b/services/platform/app/features/chat/components/message-bubble/code-block.tsx
@@ -49,7 +49,7 @@ export const HighlightedCode = memo(function HighlightedCode({
       void highlightCode(code, lang, shikiTheme).then((result) => {
         if (!cancelled && result) {
           highlightedForRef.current = code;
-          setHtml(extractShikiCodeContent(result));
+          setHtml(extractShikiCodeContent(result.html));
         }
       });
     }, HIGHLIGHT_DEBOUNCE_MS);
diff --git a/services/platform/app/features/chat/components/typewriter-text.tsx b/services/platform/app/features/chat/components/typewriter-text.tsx
index 04ca1b0113..2a9640f242 100644
--- a/services/platform/app/features/chat/components/typewriter-text.tsx
+++ b/services/platform/app/features/chat/components/typewriter-text.tsx
@@ -34,12 +34,12 @@
  * />
  */
 
+import { IncrementalMarkdown } from '@tale/ui/markdown/streaming/incremental-markdown';
 import { memo, useRef, useEffect } from 'react';
 
 import type { MarkdownComponentMap } from '@/lib/utils/markdown-types';
 
 import { isStreamFrozen, useStreamBuffer } from '../hooks/use-stream-buffer';
-import { IncrementalMarkdown } from './incremental-markdown';
 
 // ============================================================================
 // TYPES
diff --git a/services/platform/app/features/documents/components/document-preview-text.tsx b/services/platform/app/features/documents/components/document-preview-text.tsx
index de6ec40108..050adc806c 100644
--- a/services/platform/app/features/documents/components/document-preview-text.tsx
+++ b/services/platform/app/features/documents/components/document-preview-text.tsx
@@ -43,8 +43,8 @@ export function DocumentPreviewText({
 
     let cancelled = false;
     const lang = resolveLanguage(ext);
-    void highlightCode(content, lang, shikiTheme).then((html) => {
-      if (!cancelled) setHighlightedHtml(html || null);
+    void highlightCode(content, lang, shikiTheme).then((result) => {
+      if (!cancelled) setHighlightedHtml(result?.html ?? null);
     });
     return () => {
       cancelled = true;
diff --git a/services/platform/lib/utils/markdown-types.ts b/services/platform/lib/utils/markdown-types.ts
index d68adceb24..c0790335c5 100644
--- a/services/platform/lib/utils/markdown-types.ts
+++ b/services/platform/lib/utils/markdown-types.ts
@@ -1,6 +1,3 @@
-import type { ComponentType } from 'react';
-
-// oxlint-disable-next-line typescript/no-explicit-any -- Required by react-markdown's Components interface which uses ComponentType<any>
-export type MarkdownComponentType = ComponentType<any>;
-
-export type MarkdownComponentMap = Record<string, MarkdownComponentType>;
+// Re-exported from @tale/ui so callers can keep their existing import path
+// while the canonical types live in the shared package.
+export type { MarkdownComponentMap } from '@tale/ui/markdown/types';
diff --git a/services/platform/lib/utils/shiki.ts b/services/platform/lib/utils/shiki.ts
index 388729ffd4..5b31554a96 100644
--- a/services/platform/lib/utils/shiki.ts
+++ b/services/platform/lib/utils/shiki.ts
@@ -1,173 +1,4 @@
-/**
- * Shared Shiki syntax highlighter singleton.
- * Uses shiki/core with the JavaScript regex engine to avoid bundling
- * the full WASM-based oniguruma engine and all grammars/themes.
- * Lazy-initialized with on-demand language loading to minimize bundle size.
- * Used by chat code blocks and document text preview.
- */
-
-import { createHighlighterCore, type HighlighterCore } from 'shiki/core';
-import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
-
-let highlighterPromise: Promise<HighlighterCore> | null = null;
-
-function getHighlighter(): Promise<HighlighterCore> {
-  if (!highlighterPromise) {
-    highlighterPromise = createHighlighterCore({
-      themes: [
-        import('shiki/themes/github-dark.mjs'),
-        import('shiki/themes/github-light.mjs'),
-      ],
-      langs: [],
-      engine: createJavaScriptRegexEngine(),
-    }).catch((error) => {
-      highlighterPromise = null;
-      throw error;
-    });
-  }
-  return highlighterPromise;
-}
-
-const LANG_ALIASES: Record<string, string> = {
-  // Plain text — Shiki's built-in no-highlight grammar is named `text`
-  // (no `langs/text.mjs` file ships); without this alias every undefined-
-  // language render warns about a missing `langs/plaintext.mjs`.
-  plaintext: 'text',
-  txt: 'text',
-  // Python
-  py: 'python',
-  pyi: 'python',
-  pyw: 'python',
-  // JavaScript/TypeScript
-  js: 'javascript',
-  jsx: 'jsx',
-  mjs: 'javascript',
-  cjs: 'javascript',
-  ts: 'typescript',
-  tsx: 'tsx',
-  mts: 'typescript',
-  cts: 'typescript',
-  // Systems
-  rs: 'rust',
-  rb: 'ruby',
-  kt: 'kotlin',
-  kts: 'kotlin',
-  // Shell
-  sh: 'bash',
-  zsh: 'bash',
-  fish: 'fish',
-  ps1: 'powershell',
-  bat: 'bat',
-  cmd: 'bat',
-  // Config / data
-  yml: 'yaml',
-  md: 'markdown',
-  mdx: 'mdx',
-  // C/C++
-  cc: 'cpp',
-  cxx: 'cpp',
-  hpp: 'cpp',
-  hxx: 'cpp',
-  h: 'c',
-  // Web
-  htm: 'html',
-  scss: 'scss',
-  sass: 'sass',
-  less: 'less',
-  // Other
-  ex: 'elixir',
-  exs: 'elixir',
-  gql: 'graphql',
-  tex: 'latex',
-  latex: 'latex',
-  pl: 'perl',
-  pm: 'perl',
-  r: 'r',
-};
-
-export function resolveLanguage(langOrExt: string): string {
-  const lower = langOrExt.toLowerCase();
-  return LANG_ALIASES[lower] || lower;
-}
-
-/**
- * Cap on the input size we'll synchronously tokenize on the main thread.
- * Above this, callers should fall back to a plain-text render — Shiki's
- * `codeToHtml` is O(n) but blocking, and on a 250 KB document the freeze
- * runs 300 ms-2 s on a mid-range laptop.
- *
- * Hunk views call shiki per-hunk (≤ a few KB each), so they sit well below
- * this cap; only the settled-source full-document path can hit it.
- */
-const MAX_SHIKI_BYTES = 64_000;
-
-/**
- * Tokenize `code` into highlighted HTML. Returns `null` when:
- *   - `code.length` exceeds `MAX_SHIKI_BYTES` (caller should plain-text)
- *   - the underlying highlighter fails to initialize or render
- *
- * Note: Shiki's `codeToHtml` produces HTML built from a fixed grammar set
- * with all user content escaped via innerText — there is no path for user
- * code to inject HTML attributes or scripts. We deliberately do NOT wrap
- * the output in `DOMPurify.sanitize`: it costs ~100-400 ms on large
- * documents while removing nothing Shiki itself emits.
- */
-export async function highlightCode(
-  code: string,
-  lang: string,
-  theme: 'github-dark' | 'github-light' = 'github-dark',
-): Promise<string | null> {
-  if (code.length > MAX_SHIKI_BYTES) return null;
-
-  let hl: HighlighterCore;
-  try {
-    hl = await getHighlighter();
-  } catch (err) {
-    console.warn('[shiki] highlighter init failed:', err);
-    return null;
-  }
-
-  const resolvedLang = resolveLanguage(lang);
-
-  // Shiki's `text` grammar is a built-in no-highlight pass — there is no
-  // `shiki/langs/text.mjs` to load. Skip the load attempt entirely; without
-  // this short-circuit we'd hit the catch path and log a spurious warning
-  // for every plaintext render.
-  if (resolvedLang === 'text') {
-    try {
-      return hl.codeToHtml(code, { lang: 'text', theme });
-    } catch (err) {
-      console.warn('[shiki] codeToHtml failed for lang="text":', err);
-      return null;
-    }
-  }
-
-  const loadedLangs = hl.getLoadedLanguages();
-  if (!loadedLangs.includes(resolvedLang)) {
-    try {
-      await hl.loadLanguage(
-        /* @vite-ignore */ import(
-          `shiki/langs/${resolvedLang}.mjs`
-        ) as Parameters<HighlighterCore['loadLanguage']>[0],
-      );
-    } catch (err) {
-      console.warn(
-        `[shiki] language "${resolvedLang}" not loadable, falling back to plaintext:`,
-        err,
-      );
-      try {
-        return hl.codeToHtml(code, { lang: 'text', theme });
-      } catch (htmlErr) {
-        console.warn('[shiki] plaintext fallback failed:', htmlErr);
-        return null;
-      }
-    }
-  }
-
-  try {
-    return hl.codeToHtml(code, { lang: resolvedLang, theme });
-  } catch (err) {
-    console.warn(`[shiki] codeToHtml failed for lang="${resolvedLang}":`, err);
-    return null;
-  }
-}
+// Re-exported from @tale/ui so existing call sites keep their import
+// path while the canonical singleton (and language list) lives in the
+// shared package.
+export { highlightCode, resolveLanguage } from '@tale/ui/markdown/shiki';
diff --git a/services/platform/package.json b/services/platform/package.json
index c4d5f5b8fe..912fcdb9da 100644
--- a/services/platform/package.json
+++ b/services/platform/package.json
@@ -22,7 +22,7 @@
     "test:browser-e2e": "bunx vitest --run --project browser-e2e",
     "test:ui": "bunx vitest --run --config vitest.ui.config.ts",
     "test:ui:watch": "bunx vitest --config vitest.ui.config.ts",
-    "storybook": "bunx --bun storybook dev -p 6006",
+    "storybook": "bunx --bun storybook dev -p 6009",
     "storybook:build": "bunx --bun storybook build -o storybook-static",
     "stress-test": "bun stress-tests/run-stress-test.ts",
     "stress-test:concurrent": "bun stress-tests/scenarios/concurrent-starts.ts",
@@ -105,10 +105,6 @@
     "lucide-react": "1.8.0",
     "mammoth": "1.12.0",
     "mermaid": "11.14.0",
-    "micromark": "4.0.2",
-    "micromark-core-commonmark": "2.0.3",
-    "micromark-util-classify-character": "2.0.1",
-    "micromark-util-types": "2.0.2",
     "mssql": "12.2.1",
     "mustache": "4.2.0",
     "mysql2": "3.21.1",
@@ -128,7 +124,6 @@
     "rehype-sanitize": "6.0.0",
     "remark-gfm": "4.0.1",
     "safe-regex2": "5.1.1",
-    "shiki": "4.0.2",
     "striptags": "3.2.0",
     "sucrase": "3.35.1",
     "swagger-ui-react": "5.32.2",
diff --git a/tools/plop/templates/react-package/postcss.config.mjs b/tools/plop/templates/react-package/postcss.config.mjs
new file mode 100644
index 0000000000..5d6d8457f7
--- /dev/null
+++ b/tools/plop/templates/react-package/postcss.config.mjs
@@ -0,0 +1,8 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+  plugins: {
+    '@tailwindcss/postcss': {},
+  },
+};
+
+export default config;
diff --git a/turbo.json b/turbo.json
index 0603f88271..1799d26fd4 100644
--- a/turbo.json
+++ b/turbo.json
@@ -89,6 +89,10 @@
       "cache": false,
       "outputs": []
     },
+    "storybook": {
+      "cache": false,
+      "persistent": true
+    },
     "storybook:build": {
       "outputs": ["storybook-static/**"]
     },
