@schlessera/brain-ui-react 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/components/chat/brain-markdown.d.ts.map +1 -1
  2. package/dist/components/chat/brain-markdown.js +93 -55
  3. package/dist/components/chat/brain-markdown.js.map +1 -1
  4. package/dist/components/chat/copy-button.d.ts +6 -0
  5. package/dist/components/chat/copy-button.d.ts.map +1 -0
  6. package/dist/components/chat/copy-button.js +14 -0
  7. package/dist/components/chat/copy-button.js.map +1 -0
  8. package/dist/components/chat/mermaid-block.d.ts +14 -0
  9. package/dist/components/chat/mermaid-block.d.ts.map +1 -0
  10. package/dist/components/chat/mermaid-block.js +47 -0
  11. package/dist/components/chat/mermaid-block.js.map +1 -0
  12. package/dist/components/chat/message-share.d.ts.map +1 -1
  13. package/dist/components/chat/message-share.js +7 -4
  14. package/dist/components/chat/message-share.js.map +1 -1
  15. package/dist/components/chat/share-block.d.ts.map +1 -1
  16. package/dist/components/chat/share-block.js +5 -2
  17. package/dist/components/chat/share-block.js.map +1 -1
  18. package/dist/components/files/file-viewer.d.ts.map +1 -1
  19. package/dist/components/files/file-viewer.js +15 -5
  20. package/dist/components/files/file-viewer.js.map +1 -1
  21. package/dist/components/graph/graph-canvas.d.ts.map +1 -1
  22. package/dist/components/graph/graph-canvas.js +27 -1
  23. package/dist/components/graph/graph-canvas.js.map +1 -1
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/lib/mermaid.d.ts +48 -0
  29. package/dist/lib/mermaid.d.ts.map +1 -0
  30. package/dist/lib/mermaid.js +194 -0
  31. package/dist/lib/mermaid.js.map +1 -0
  32. package/dist/stores/file-store.d.ts.map +1 -1
  33. package/dist/stores/file-store.js +9 -2
  34. package/dist/stores/file-store.js.map +1 -1
  35. package/dist/styles.css +1 -1
  36. package/package.json +3 -2
  37. package/src/components/chat/brain-markdown.tsx +109 -80
  38. package/src/components/chat/copy-button.tsx +23 -0
  39. package/src/components/chat/mermaid-block.tsx +77 -0
  40. package/src/components/chat/message-share.ts +7 -4
  41. package/src/components/chat/share-block.tsx +5 -2
  42. package/src/components/files/file-viewer.tsx +20 -5
  43. package/src/components/graph/graph-canvas.tsx +28 -1
  44. package/src/index.ts +5 -0
  45. package/src/lib/mermaid.ts +233 -0
  46. package/src/stores/file-store.ts +12 -2
  47. package/src/theme.css +10 -0
@@ -0,0 +1,77 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Code, ChartNetwork } from "lucide-react";
3
+ import { peekMermaidSvg, renderMermaidSvg } from "../../lib/mermaid.js";
4
+ import { CopyButton } from "./copy-button.js";
5
+
6
+ /**
7
+ * A ```mermaid fence, rendered as a diagram.
8
+ *
9
+ * Streaming-safe by construction: while the fence is still arriving (or the
10
+ * source is invalid) the raw source shows as an ordinary code block; each
11
+ * debounced parse that succeeds swaps in the fresh SVG, and a parse that
12
+ * fails keeps the last good diagram instead of flashing an error. Renders
13
+ * are cached module-wide, so the per-token re-render of a streaming message
14
+ * costs a cache lookup, not a mermaid parse.
15
+ */
16
+ export function MermaidBlock({ source }: { source: string }) {
17
+ const [svg, setSvg] = useState<string | null>(() => peekMermaidSvg(source));
18
+ const [showSource, setShowSource] = useState(false);
19
+ const latest = useRef(0);
20
+ const lastAttempt = useRef(0);
21
+
22
+ useEffect(() => {
23
+ const id = ++latest.current;
24
+ const cached = peekMermaidSvg(source);
25
+ if (cached) {
26
+ setSvg(cached);
27
+ return;
28
+ }
29
+ // Debounce with a throttle floor: a pure debounce would starve during a
30
+ // continuous stream of WS deltas and only render once the stream pauses.
31
+ // Attempting at least every 400ms lets the diagram grow mid-stream while
32
+ // still coalescing per-token churn.
33
+ const wait = Date.now() - lastAttempt.current > 400 ? 0 : 150;
34
+ const t = setTimeout(() => {
35
+ lastAttempt.current = Date.now();
36
+ void renderMermaidSvg(source).then((result) => {
37
+ if (latest.current !== id || !result) return;
38
+ setSvg(result);
39
+ });
40
+ }, wait);
41
+ return () => clearTimeout(t);
42
+ }, [source]);
43
+
44
+ const diagramReady = svg !== null;
45
+ const showDiagram = diagramReady && !showSource;
46
+
47
+ return (
48
+ <div className="group relative my-3 overflow-hidden rounded-lg border border-border bg-surface">
49
+ {showDiagram ? (
50
+ <div
51
+ className="overflow-x-auto p-4 [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-w-full"
52
+ dangerouslySetInnerHTML={{ __html: svg ?? "" }}
53
+ />
54
+ ) : (
55
+ <pre className="overflow-x-auto p-4 font-[family-name:var(--font-mono)] text-[13px] leading-relaxed">
56
+ <code>{source}</code>
57
+ </pre>
58
+ )}
59
+ <div className="absolute right-2 top-2 flex items-center gap-1 opacity-0 transition-all group-hover:opacity-100">
60
+ {diagramReady && (
61
+ <button
62
+ type="button"
63
+ title={showDiagram ? "Show source" : "Show diagram"}
64
+ onClick={() => setShowSource((s) => !s)}
65
+ className="flex h-7 w-7 items-center justify-center rounded-md bg-surface-raised/80 text-muted-foreground transition-all hover:bg-surface-overlay hover:text-foreground"
66
+ >
67
+ {showDiagram ? <Code className="h-3.5 w-3.5" /> : <ChartNetwork className="h-3.5 w-3.5" />}
68
+ </button>
69
+ )}
70
+ <CopyButton
71
+ getText={() => source}
72
+ className="flex h-7 w-7 items-center justify-center rounded-md bg-surface-raised/80 text-muted-foreground transition-all hover:bg-surface-overlay hover:text-foreground"
73
+ />
74
+ </div>
75
+ </div>
76
+ );
77
+ }
@@ -2,6 +2,7 @@ import { uiConfig } from "../../config.js";
2
2
  import type { RefObject } from "react";
3
3
  import { renderAndShare, shareFile, shareText, copyRichText } from "../../lib/share.js";
4
4
  import { stripMarkdown } from "../../lib/strip-markdown.js";
5
+ import { inlineMermaidDiagrams } from "../../lib/mermaid.js";
5
6
  import type { ShareOption } from "../share/share-menu.js";
6
7
 
7
8
  interface BuildOpts {
@@ -18,9 +19,11 @@ export function buildMessageShareOptions({ content, renderedRef }: BuildOpts): S
18
19
  id: "image",
19
20
  label: "Image (PNG)",
20
21
  hint: "Rendered snapshot",
21
- run: () =>
22
+ run: async () =>
22
23
  renderAndShare({
23
- content,
24
+ // The render page runs without JavaScript, so mermaid fences are
25
+ // pre-rendered to inline SVG here on the client.
26
+ content: await inlineMermaidDiagrams(content),
24
27
  contentType: "markdown",
25
28
  format: "png",
26
29
  filename: "message",
@@ -31,9 +34,9 @@ export function buildMessageShareOptions({ content, renderedRef }: BuildOpts): S
31
34
  id: "pdf",
32
35
  label: "PDF",
33
36
  hint: "Vector PDF, A4",
34
- run: () =>
37
+ run: async () =>
35
38
  renderAndShare({
36
- content,
39
+ content: await inlineMermaidDiagrams(content),
37
40
  contentType: "markdown",
38
41
  format: "pdf",
39
42
  filename: "message",
@@ -4,6 +4,7 @@ import { BrainMarkdown } from "./brain-markdown.js";
4
4
  import { ShareMenu, type ShareOption } from "../share/share-menu.js";
5
5
  import { renderAndShare, shareFile, shareText, copyRichText } from "../../lib/share.js";
6
6
  import { stripMarkdown } from "../../lib/strip-markdown.js";
7
+ import { inlineMermaidDiagrams } from "../../lib/mermaid.js";
7
8
  import { Share2 } from "lucide-react";
8
9
 
9
10
  export type ShareBlockFormat = "image" | "pdf" | "text" | "markdown" | "richtext";
@@ -86,7 +87,9 @@ async function runFormat(
86
87
  switch (fmt) {
87
88
  case "image":
88
89
  return renderAndShare({
89
- content: body,
90
+ // The render page runs without JavaScript — mermaid fences are
91
+ // pre-rendered to inline SVG on the client.
92
+ content: await inlineMermaidDiagrams(body),
90
93
  contentType: "markdown",
91
94
  format: "png",
92
95
  filename,
@@ -94,7 +97,7 @@ async function runFormat(
94
97
  });
95
98
  case "pdf":
96
99
  return renderAndShare({
97
- content: body,
100
+ content: await inlineMermaidDiagrams(body),
98
101
  contentType: "markdown",
99
102
  format: "pdf",
100
103
  filename,
@@ -11,6 +11,8 @@ import { API_BASE } from "../../lib/backend.js";
11
11
  import { fetchAsFile, shareFile, renderAndShare } from "../../lib/share.js";
12
12
  import { splitFrontmatter } from "../../lib/frontmatter.js";
13
13
  import { stripMarkdown } from "../../lib/strip-markdown.js";
14
+ import { inlineMermaidDiagrams, isMermaidPath } from "../../lib/mermaid.js";
15
+ import { MermaidBlock } from "../chat/mermaid-block.js";
14
16
  import type { FileContentResponse } from "@schlessera/brain-ui-sdk/protocol";
15
17
 
16
18
  export function FileViewer() {
@@ -32,7 +34,10 @@ export function FileViewer() {
32
34
  }
33
35
 
34
36
  const fileName = currentPath.split("/").pop() ?? currentPath;
35
- const previewAvailable = content?.kind === "markdown" || content?.kind === "html";
37
+ const previewAvailable =
38
+ content?.kind === "markdown" ||
39
+ content?.kind === "html" ||
40
+ (content?.kind === "text" && isMermaidPath(content.path));
36
41
 
37
42
  return (
38
43
  <div className="flex h-full flex-col">
@@ -70,6 +75,14 @@ function ViewerBody({ content, viewMode }: { content: NonNullable<ReturnType<typ
70
75
  if (content.kind === "binary") {
71
76
  return <FileViewerBinary content={content} />;
72
77
  }
78
+ // Standalone mermaid source files (.mmd / .mermaid) preview as a diagram.
79
+ if (viewMode === "preview" && content.kind === "text" && isMermaidPath(content.path)) {
80
+ return (
81
+ <div className="brain-prose max-w-none px-6 py-4">
82
+ <MermaidBlock source={content.content ?? ""} />
83
+ </div>
84
+ );
85
+ }
73
86
  if (viewMode === "raw" || (content.kind !== "markdown" && content.kind !== "html")) {
74
87
  return <FileViewerRaw content={content.content ?? ""} fileName={content.path} />;
75
88
  }
@@ -216,9 +229,11 @@ function buildFileShareOptions(content: FileContentResponse, fileName: string):
216
229
  id: "md-png",
217
230
  label: "Share as image",
218
231
  hint: "Rendered PNG snapshot",
219
- run: () =>
232
+ run: async () =>
220
233
  renderAndShare({
221
- content: body,
234
+ // The render page runs without JavaScript — mermaid fences are
235
+ // pre-rendered to inline SVG on the client.
236
+ content: await inlineMermaidDiagrams(body),
222
237
  contentType: "markdown",
223
238
  format: "png",
224
239
  filename: baseName(fileName),
@@ -229,9 +244,9 @@ function buildFileShareOptions(content: FileContentResponse, fileName: string):
229
244
  id: "md-pdf",
230
245
  label: "Share as PDF",
231
246
  hint: "Vector PDF, A4",
232
- run: () =>
247
+ run: async () =>
233
248
  renderAndShare({
234
- content: body,
249
+ content: await inlineMermaidDiagrams(body),
235
250
  contentType: "markdown",
236
251
  format: "pdf",
237
252
  filename: baseName(fileName),
@@ -227,11 +227,38 @@ export default function GraphCanvas({
227
227
  },
228
228
  });
229
229
 
230
+ // Camera gestures (mouse-drag pan, touch pan/pinch/rotate) sweep nodes
231
+ // under a pointer that isn't "pointing" at them, and sigma re-derives
232
+ // hover on every move — so without a gate, dragging flickers random nodes
233
+ // in and out of the hover fade. Clicks/taps are already drag-suppressed by
234
+ // sigma itself (draggedEventsTolerance / tapMoveTolerance); hover is not.
235
+ // Gate: button held or a touch in progress means the pointer is steering
236
+ // the camera, and mouse isMoving lingers for sigma's dragTimeout after
237
+ // release, absorbing the tail of the gesture.
238
+ const mouseCaptor = renderer.getMouseCaptor();
239
+ const touchCaptor = renderer.getTouchCaptor();
240
+ const isCameraGesture = () =>
241
+ mouseCaptor.isMouseDown ||
242
+ mouseCaptor.isMoving ||
243
+ touchCaptor.isMoving ||
244
+ touchCaptor.touchMode > 0;
245
+
230
246
  renderer.on("clickNode", ({ node }) => callbacksRef.current.onSelect(Number(node)));
231
247
  renderer.on("clickStage", () => callbacksRef.current.onSelect(null));
232
- renderer.on("enterNode", ({ node }) => callbacksRef.current.onHover(Number(node)));
248
+ renderer.on("enterNode", ({ node }) => {
249
+ if (isCameraGesture()) return;
250
+ callbacksRef.current.onHover(Number(node));
251
+ });
233
252
  renderer.on("leaveNode", () => callbacksRef.current.onHover(null));
234
253
 
254
+ // A gesture that starts while a node is already hovered must drop that
255
+ // hover too, or the fade chases a stale node across the whole drag.
256
+ const clearHoverDuringGesture = () => {
257
+ if (isCameraGesture()) callbacksRef.current.onHover(null);
258
+ };
259
+ mouseCaptor.on("mousemovebody", clearHoverDuringGesture);
260
+ touchCaptor.on("touchmovebody", clearHoverDuringGesture);
261
+
235
262
  sigmaRef.current = renderer;
236
263
  return () => {
237
264
  renderer.kill();
package/src/index.ts CHANGED
@@ -20,6 +20,11 @@ export { GraphPage } from "./components/graph/graph-page.js";
20
20
  // Markdown renderer (also useful standalone, e.g. for a dev kitchen sink).
21
21
  export { BrainMarkdown } from "./components/chat/brain-markdown.js";
22
22
 
23
+ // Mermaid: standalone diagram block (streaming-safe) + the fence-to-SVG
24
+ // inliner the share pipeline uses before handing markdown to the no-JS renderer.
25
+ export { MermaidBlock } from "./components/chat/mermaid-block.js";
26
+ export { inlineMermaidDiagrams, renderMermaidSvg } from "./lib/mermaid.js";
27
+
23
28
  // Stores + selectors the shell reads (service-worker busy check, deep links).
24
29
  export {
25
30
  useChatStore,
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Mermaid runtime + fence helpers.
3
+ *
4
+ * Mermaid is ~2MB, so it loads on first use via dynamic import; nothing here
5
+ * pulls it in at module scope. Rendering is tolerant of partial sources
6
+ * (streaming deltas): parse is gated and every failure resolves to null
7
+ * instead of throwing, so callers keep the previous good SVG. Results are
8
+ * cached per (theme, source), which makes the per-token re-render of a
9
+ * streaming message a cache hit.
10
+ */
11
+
12
+ export type MermaidTheme = "dark" | "neutral";
13
+
14
+ type MermaidApi = typeof import("mermaid").default;
15
+
16
+ let mermaidPromise: Promise<MermaidApi> | null = null;
17
+
18
+ function loadMermaid(): Promise<MermaidApi> {
19
+ if (!mermaidPromise) {
20
+ mermaidPromise = import("mermaid").then((mod) => {
21
+ const mermaid = mod.default;
22
+ mermaid.initialize({
23
+ startOnLoad: false,
24
+ // "strict" sanitizes label HTML and blocks script/click payloads —
25
+ // diagram sources arrive from the model and from repo files.
26
+ securityLevel: "strict",
27
+ // Never inject mermaid's own error SVG into the document; failures
28
+ // surface as a null render result and the caller shows the source.
29
+ suppressErrorRendering: true,
30
+ theme: "dark",
31
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
32
+ });
33
+ return mermaid;
34
+ });
35
+ }
36
+ return mermaidPromise;
37
+ }
38
+
39
+ const INIT_DIRECTIVE_RE = /%%\{\s*init/;
40
+
41
+ /**
42
+ * Theme is applied per-diagram via an init directive rather than re-calling
43
+ * mermaid.initialize(), so concurrent renders for different targets (dark
44
+ * in-app, neutral for the light share template) can't race on global config.
45
+ * A source that carries its own init directive is left alone.
46
+ */
47
+ function withThemeDirective(source: string, theme: MermaidTheme): string {
48
+ if (INIT_DIRECTIVE_RE.test(source)) return source;
49
+ return `%%{init: {"theme": "${theme}"}}%%\n${source}`;
50
+ }
51
+
52
+ const svgCache = new Map<string, string>();
53
+ const CACHE_MAX = 100;
54
+ let renderSeq = 0;
55
+
56
+ function cacheKey(source: string, theme: MermaidTheme): string {
57
+ return `${theme}\u0000${source}`;
58
+ }
59
+
60
+ /** Synchronous cache lookup, so a remounted block can show its SVG without a flash. */
61
+ export function peekMermaidSvg(source: string, theme: MermaidTheme = "dark"): string | null {
62
+ return svgCache.get(cacheKey(source.trim(), theme)) ?? null;
63
+ }
64
+
65
+ /**
66
+ * Render a mermaid source to SVG markup. Resolves null when the source does
67
+ * not parse (e.g. an incomplete streaming fence) or rendering fails — never
68
+ * throws and never mutates the document beyond mermaid's temp container.
69
+ */
70
+ export async function renderMermaidSvg(
71
+ source: string,
72
+ theme: MermaidTheme = "dark"
73
+ ): Promise<string | null> {
74
+ const trimmed = source.trim();
75
+ if (!trimmed) return null;
76
+ const key = cacheKey(trimmed, theme);
77
+ const hit = svgCache.get(key);
78
+ if (hit !== undefined) return hit;
79
+ try {
80
+ const mermaid = await loadMermaid();
81
+ const text = withThemeDirective(trimmed, theme);
82
+ const ok = await mermaid.parse(text, { suppressErrors: true });
83
+ if (!ok) return null;
84
+ const { svg } = await mermaid.render(`brain-mermaid-${++renderSeq}`, text);
85
+ if (svgCache.size >= CACHE_MAX) {
86
+ const oldest = svgCache.keys().next().value;
87
+ if (oldest !== undefined) svgCache.delete(oldest);
88
+ }
89
+ svgCache.set(key, svg);
90
+ return svg;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ export interface MermaidFence {
97
+ /** Offset of the opening fence line start. */
98
+ start: number;
99
+ /** Offset just past the closing fence line (and its newline, if present). */
100
+ end: number;
101
+ /** Diagram source between the fences, without the trailing newline. */
102
+ source: string;
103
+ /** Leading spaces of the opening fence line (a list-indented fence). */
104
+ indent: string;
105
+ }
106
+
107
+ const FENCE_LINE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
108
+
109
+ /**
110
+ * Find every *closed* ```mermaid fence in a markdown string. A line scanner
111
+ * (not a regex over the whole text) so mermaid fences nested inside other
112
+ * code blocks are not matched, and an unterminated streaming fence is simply
113
+ * not returned.
114
+ */
115
+ export function findMermaidFences(md: string): MermaidFence[] {
116
+ const out: MermaidFence[] = [];
117
+ const lines = md.split("\n");
118
+ let offset = 0;
119
+ let open: {
120
+ char: string;
121
+ len: number;
122
+ mermaid: boolean;
123
+ start: number;
124
+ contentStart: number;
125
+ indent: string;
126
+ } | null = null;
127
+
128
+ for (const line of lines) {
129
+ const lineEnd = Math.min(offset + line.length + 1, md.length);
130
+ const m = FENCE_LINE_RE.exec(line);
131
+ if (open) {
132
+ if (m && m[2][0] === open.char && m[2].length >= open.len && m[3].trim() === "") {
133
+ if (open.mermaid) {
134
+ out.push({
135
+ start: open.start,
136
+ end: lineEnd,
137
+ source: md.slice(open.contentStart, offset).replace(/\n$/, ""),
138
+ indent: open.indent,
139
+ });
140
+ }
141
+ open = null;
142
+ }
143
+ } else if (m) {
144
+ const info = m[3].trim();
145
+ // A backtick fence's info string may not contain backticks (CommonMark).
146
+ if (!(m[2][0] === "`" && info.includes("`"))) {
147
+ const lang = info.split(/\s+/)[0]?.toLowerCase() ?? "";
148
+ open = {
149
+ char: m[2][0],
150
+ len: m[2].length,
151
+ mermaid: lang === "mermaid" || lang === "mmd",
152
+ start: offset,
153
+ contentStart: lineEnd,
154
+ indent: m[1],
155
+ };
156
+ }
157
+ }
158
+ offset += line.length + 1;
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /**
164
+ * Replace each closed mermaid fence via `replacement`; returning null keeps
165
+ * the fence verbatim. Pure — rendering is injected, so this is unit-testable
166
+ * without a DOM.
167
+ */
168
+ export function replaceMermaidFences(
169
+ md: string,
170
+ replacement: (fence: MermaidFence, index: number) => string | null
171
+ ): string {
172
+ const fences = findMermaidFences(md);
173
+ let out = md;
174
+ for (let i = fences.length - 1; i >= 0; i--) {
175
+ const r = replacement(fences[i], i);
176
+ if (r == null) continue;
177
+ out = out.slice(0, fences[i].start) + r + out.slice(fences[i].end);
178
+ }
179
+ return out;
180
+ }
181
+
182
+ /**
183
+ * Inline every closed mermaid fence as a pre-rendered `<div class="mermaid-figure"><svg…>`
184
+ * block. Used by the share pipeline: the PNG/PDF renderer runs the page with
185
+ * JavaScript disabled and all network denied, so the diagram must already be
186
+ * SVG by the time the markdown reaches the server. Defaults to the light
187
+ * "neutral" theme to match the share template. Fences that fail to render are
188
+ * left as code fences — the pre-feature behavior.
189
+ */
190
+ /**
191
+ * The server's render route caps `content` at 512 KiB. Inlining can expand a
192
+ * few-hundred-byte fence into a multi-kilobyte SVG, so stay safely below the
193
+ * cap and leave any fence that would cross it as source — a shared code block
194
+ * beats a rejected request.
195
+ */
196
+ const INLINE_BUDGET_CHARS = 480_000;
197
+
198
+ /** Pure inlining step: substitute pre-rendered SVGs (by fence index) under the budget. */
199
+ export function inlineRenderedFences(
200
+ md: string,
201
+ rendered: (string | null)[],
202
+ budget: number = INLINE_BUDGET_CHARS
203
+ ): string {
204
+ let total = md.length;
205
+ return replaceMermaidFences(md, (fence, i) => {
206
+ const svg = rendered[i];
207
+ if (!svg) return null;
208
+ // Kept to a single line: marked treats <div> as an HTML block that a
209
+ // blank line would terminate, so newlines inside the SVG must go. The
210
+ // fence's own indentation is preserved so a list-nested fence doesn't
211
+ // break out of its list in the shared document.
212
+ const html = `\n${fence.indent}<div class="mermaid-figure">${svg.replace(/[\r\n]+/g, " ")}</div>\n`;
213
+ const expanded = total + html.length - (fence.end - fence.start);
214
+ if (expanded > budget) return null;
215
+ total = expanded;
216
+ return html;
217
+ });
218
+ }
219
+
220
+ export async function inlineMermaidDiagrams(
221
+ md: string,
222
+ theme: MermaidTheme = "neutral"
223
+ ): Promise<string> {
224
+ const fences = findMermaidFences(md);
225
+ if (fences.length === 0) return md;
226
+ const rendered = await Promise.all(fences.map((f) => renderMermaidSvg(f.source, theme)));
227
+ return inlineRenderedFences(md, rendered);
228
+ }
229
+
230
+ /** Whether a repo path is a standalone mermaid source file (previewable as a diagram). */
231
+ export function isMermaidPath(path: string): boolean {
232
+ return /\.(mmd|mermaid)$/i.test(path);
233
+ }
@@ -7,9 +7,19 @@ import type {
7
7
  } from "@schlessera/brain-ui-sdk/protocol";
8
8
  import { FILE_SIZE_CAP_BYTES } from "@schlessera/brain-ui-sdk/protocol";
9
9
  import { API_BASE } from "../lib/backend.js";
10
+ import { isMermaidPath } from "../lib/mermaid.js";
10
11
 
11
12
  export type ViewMode = "preview" | "raw";
12
13
 
14
+ /** Kinds/paths the viewer can render as a preview (vs raw text only). */
15
+ function hasPreview(content: FileContentResponse): boolean {
16
+ return (
17
+ content.kind === "markdown" ||
18
+ content.kind === "html" ||
19
+ (content.kind === "text" && isMermaidPath(content.path))
20
+ );
21
+ }
22
+
13
23
  const FRONTMATTER_COLLAPSED_KEY = "brain-ui:frontmatter-collapsed";
14
24
 
15
25
  function readFrontmatterCollapsed(): boolean {
@@ -204,9 +214,9 @@ export const useFileStore = create<FileState>((set, get) => ({
204
214
  set({ currentContent: content, contentLoading: false });
205
215
  // Default mode: prefer preview when available; persist otherwise
206
216
  const { viewMode } = get();
207
- if (content.kind !== "markdown" && content.kind !== "html" && viewMode === "preview") {
217
+ if (!hasPreview(content) && viewMode === "preview") {
208
218
  set({ viewMode: "raw" });
209
- } else if ((content.kind === "markdown" || content.kind === "html") && viewMode === "raw") {
219
+ } else if (hasPreview(content) && viewMode === "raw") {
210
220
  // Keep raw if user toggled — but on a fresh open default back to preview
211
221
  set({ viewMode: "preview" });
212
222
  }
package/src/theme.css CHANGED
@@ -316,6 +316,16 @@
316
316
  font-size: inherit;
317
317
  }
318
318
 
319
+ /* Mermaid diagrams (rendered from ```mermaid fences) */
320
+ .brain-prose .mermaid-figure {
321
+ margin: 0.75rem 0;
322
+ text-align: center;
323
+ }
324
+ .brain-prose .mermaid-figure svg {
325
+ max-width: 100%;
326
+ height: auto;
327
+ }
328
+
319
329
  /* Blockquotes */
320
330
  .brain-prose blockquote {
321
331
  border-left: 3px solid #e09f3e;