@stigmer/react 3.1.3 → 3.1.4

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 (151) hide show
  1. package/composer/SessionComposer.d.ts +31 -0
  2. package/composer/SessionComposer.d.ts.map +1 -1
  3. package/composer/SessionComposer.js +12 -4
  4. package/composer/SessionComposer.js.map +1 -1
  5. package/execution/ArtifactApplyButton.d.ts +25 -0
  6. package/execution/ArtifactApplyButton.d.ts.map +1 -0
  7. package/execution/ArtifactApplyButton.js +21 -0
  8. package/execution/ArtifactApplyButton.js.map +1 -0
  9. package/execution/ArtifactContentBody.d.ts +36 -0
  10. package/execution/ArtifactContentBody.d.ts.map +1 -0
  11. package/execution/ArtifactContentBody.js +62 -0
  12. package/execution/ArtifactContentBody.js.map +1 -0
  13. package/execution/ArtifactDocument.d.ts +48 -0
  14. package/execution/ArtifactDocument.d.ts.map +1 -0
  15. package/execution/ArtifactDocument.js +89 -0
  16. package/execution/ArtifactDocument.js.map +1 -0
  17. package/execution/ArtifactPreviewModal.d.ts +4 -4
  18. package/execution/ArtifactPreviewModal.d.ts.map +1 -1
  19. package/execution/ArtifactPreviewModal.js +15 -142
  20. package/execution/ArtifactPreviewModal.js.map +1 -1
  21. package/execution/ArtifactRow.d.ts +49 -0
  22. package/execution/ArtifactRow.d.ts.map +1 -0
  23. package/execution/ArtifactRow.js +56 -0
  24. package/execution/ArtifactRow.js.map +1 -0
  25. package/execution/ArtifactsWidget.d.ts +6 -9
  26. package/execution/ArtifactsWidget.d.ts.map +1 -1
  27. package/execution/ArtifactsWidget.js +15 -19
  28. package/execution/ArtifactsWidget.js.map +1 -1
  29. package/execution/index.d.ts +8 -2
  30. package/execution/index.d.ts.map +1 -1
  31. package/execution/index.js +4 -1
  32. package/execution/index.js.map +1 -1
  33. package/execution/useArtifactContent.d.ts +6 -1
  34. package/execution/useArtifactContent.d.ts.map +1 -1
  35. package/execution/useArtifactContent.js +7 -2
  36. package/execution/useArtifactContent.js.map +1 -1
  37. package/execution/useArtifactInspection.d.ts +85 -0
  38. package/execution/useArtifactInspection.d.ts.map +1 -0
  39. package/execution/useArtifactInspection.js +143 -0
  40. package/execution/useArtifactInspection.js.map +1 -0
  41. package/execution/useCreateAgentExecution.d.ts +11 -0
  42. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  43. package/execution/useCreateAgentExecution.js +1 -0
  44. package/execution/useCreateAgentExecution.js.map +1 -1
  45. package/index.d.ts +3 -3
  46. package/index.d.ts.map +1 -1
  47. package/index.js +2 -2
  48. package/index.js.map +1 -1
  49. package/internal/MermaidDiagram.d.ts +47 -0
  50. package/internal/MermaidDiagram.d.ts.map +1 -0
  51. package/internal/MermaidDiagram.js +118 -0
  52. package/internal/MermaidDiagram.js.map +1 -0
  53. package/internal/markdown-components.d.ts.map +1 -1
  54. package/internal/markdown-components.js +34 -0
  55. package/internal/markdown-components.js.map +1 -1
  56. package/internal/mermaid-loader.d.ts +26 -0
  57. package/internal/mermaid-loader.d.ts.map +1 -0
  58. package/internal/mermaid-loader.js +34 -0
  59. package/internal/mermaid-loader.js.map +1 -0
  60. package/internal/mermaid-theme-css.d.ts +41 -0
  61. package/internal/mermaid-theme-css.d.ts.map +1 -0
  62. package/internal/mermaid-theme-css.js +179 -0
  63. package/internal/mermaid-theme-css.js.map +1 -0
  64. package/internal/slug.d.ts +9 -5
  65. package/internal/slug.d.ts.map +1 -1
  66. package/internal/slug.js +10 -6
  67. package/internal/slug.js.map +1 -1
  68. package/package.json +5 -4
  69. package/session/SessionViewer.d.ts.map +1 -1
  70. package/session/SessionViewer.js +91 -18
  71. package/session/SessionViewer.js.map +1 -1
  72. package/session/artifact-document.d.ts +13 -0
  73. package/session/artifact-document.d.ts.map +1 -0
  74. package/session/artifact-document.js +22 -0
  75. package/session/artifact-document.js.map +1 -0
  76. package/session/facets/ArtifactsTab.d.ts +30 -4
  77. package/session/facets/ArtifactsTab.d.ts.map +1 -1
  78. package/session/facets/ArtifactsTab.js +32 -10
  79. package/session/facets/ArtifactsTab.js.map +1 -1
  80. package/session/index.d.ts +2 -1
  81. package/session/index.d.ts.map +1 -1
  82. package/session/index.js +4 -1
  83. package/session/index.js.map +1 -1
  84. package/session/useSessionArtifacts.d.ts +17 -0
  85. package/session/useSessionArtifacts.d.ts.map +1 -1
  86. package/session/useSessionArtifacts.js +16 -5
  87. package/session/useSessionArtifacts.js.map +1 -1
  88. package/session/useSessionConversation.d.ts +17 -1
  89. package/session/useSessionConversation.d.ts.map +1 -1
  90. package/session/useSessionConversation.js +32 -4
  91. package/session/useSessionConversation.js.map +1 -1
  92. package/session/useSessionPageFlow.d.ts.map +1 -1
  93. package/session/useSessionPageFlow.js +1 -0
  94. package/session/useSessionPageFlow.js.map +1 -1
  95. package/session/useSessionPanel.d.ts +20 -0
  96. package/session/useSessionPanel.d.ts.map +1 -1
  97. package/session/useSessionPanel.js +13 -0
  98. package/session/useSessionPanel.js.map +1 -1
  99. package/session/useSessionRailViews.d.ts +13 -1
  100. package/session/useSessionRailViews.d.ts.map +1 -1
  101. package/session/useSessionRailViews.js +5 -3
  102. package/session/useSessionRailViews.js.map +1 -1
  103. package/src/composer/SessionComposer.tsx +69 -1
  104. package/src/composer/__tests__/SessionComposer-editing.test.tsx +128 -0
  105. package/src/execution/ArtifactApplyButton.tsx +79 -0
  106. package/src/execution/ArtifactContentBody.tsx +255 -0
  107. package/src/execution/ArtifactDocument.tsx +344 -0
  108. package/src/execution/ArtifactPreviewModal.tsx +39 -405
  109. package/src/execution/ArtifactRow.tsx +165 -0
  110. package/src/execution/ArtifactsWidget.tsx +24 -33
  111. package/src/execution/__tests__/ArtifactContentBody.test.tsx +131 -0
  112. package/src/execution/__tests__/ArtifactDocument.test.tsx +100 -0
  113. package/src/execution/__tests__/ArtifactRow.test.tsx +139 -0
  114. package/src/execution/__tests__/ArtifactsWidget.test.tsx +78 -0
  115. package/src/execution/__tests__/message-entry.test.tsx +50 -2
  116. package/src/execution/__tests__/useArtifactInspection.test.tsx +151 -0
  117. package/src/execution/__tests__/useCreateAgentExecution.test.tsx +35 -0
  118. package/src/execution/index.ts +14 -2
  119. package/src/execution/useArtifactContent.ts +7 -0
  120. package/src/execution/useArtifactInspection.ts +252 -0
  121. package/src/execution/useCreateAgentExecution.ts +12 -0
  122. package/src/index.ts +11 -2
  123. package/src/internal/MermaidDiagram.tsx +164 -0
  124. package/src/internal/__tests__/a11y/mermaid-theme.a11y.test.tsx +122 -0
  125. package/src/internal/__tests__/markdown-components.test.tsx +93 -1
  126. package/src/internal/__tests__/mermaid-diagram.test.tsx +282 -0
  127. package/src/internal/markdown-components.tsx +36 -1
  128. package/src/internal/mermaid-loader.ts +44 -0
  129. package/src/internal/mermaid-theme-css.ts +178 -0
  130. package/src/internal/slug.ts +10 -6
  131. package/src/session/SessionViewer.tsx +177 -33
  132. package/src/session/__tests__/SessionViewer-artifactTabs.test.tsx +210 -0
  133. package/src/session/__tests__/SessionViewer-editResubmit.test.tsx +233 -0
  134. package/src/session/__tests__/useSessionArtifacts.test.ts +57 -0
  135. package/src/session/__tests__/useSessionConversation.test.tsx +172 -1
  136. package/src/session/__tests__/useSessionPanel.test.ts +70 -1
  137. package/src/session/artifact-document.ts +23 -0
  138. package/src/session/facets/ArtifactsTab.tsx +66 -18
  139. package/src/session/facets/__tests__/ArtifactsTab.test.tsx +111 -47
  140. package/src/session/index.ts +5 -1
  141. package/src/session/useSessionArtifacts.ts +16 -5
  142. package/src/session/useSessionConversation.ts +48 -6
  143. package/src/session/useSessionPageFlow.ts +1 -0
  144. package/src/session/useSessionPanel.ts +41 -0
  145. package/src/session/useSessionRailViews.tsx +21 -1
  146. package/styles.css +1 -1
  147. package/execution/ArtifactCard.d.ts +0 -81
  148. package/execution/ArtifactCard.d.ts.map +0 -1
  149. package/execution/ArtifactCard.js +0 -130
  150. package/execution/ArtifactCard.js.map +0 -1
  151. package/src/execution/ArtifactCard.tsx +0 -320
@@ -0,0 +1,164 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useIsCodeFenceIncomplete } from "streamdown";
5
+ import { useColorMode } from "../color-mode.js";
6
+ import { loadMermaid } from "./mermaid-loader.js";
7
+ import { MERMAID_THEME_CSS } from "./mermaid-theme-css.js";
8
+
9
+ /**
10
+ * `mermaid.render` requires a DOM id for its scratch element. Ids must be
11
+ * unique per render call — theme switches and streaming updates can overlap
12
+ * renders within one component — so a module counter is the simplest
13
+ * collision-free source.
14
+ */
15
+ let renderSequence = 0;
16
+
17
+ /** Props for {@link MermaidDiagram}. */
18
+ export interface MermaidDiagramProps {
19
+ /** The raw Mermaid source from the fenced code block. */
20
+ readonly chart: string;
21
+ }
22
+
23
+ type RenderState =
24
+ | { readonly status: "pending" }
25
+ | { readonly status: "rendered"; readonly svg: string }
26
+ | { readonly status: "failed"; readonly message: string };
27
+
28
+ /**
29
+ * Renders a fenced ```mermaid block as an inline SVG diagram.
30
+ *
31
+ * Mounted by the shared `pre` override in
32
+ * {@link file://./markdown-components.tsx} whenever a fenced block carries the
33
+ * explicit `mermaid` info string — which means every SDK markdown surface
34
+ * (chat via Streamdown, artifacts/skills via react-markdown) renders diagrams
35
+ * identically. Detection is deliberately limited to the explicit language tag;
36
+ * guessing diagram intent from fence bodies (e.g. a bare fence starting with
37
+ * `flowchart`) is the kind of fuzzy heuristic this codebase avoids.
38
+ *
39
+ * Every failure mode degrades to the exact code block the fence rendered as
40
+ * before this component existed:
41
+ *
42
+ * - **Streaming** — while the fence is still open (`useIsCodeFenceIncomplete`,
43
+ * a safe no-op under react-markdown where the context defaults to `false`),
44
+ * the source renders as a code block; the diagram appears once the fence
45
+ * closes. No half-parsed error flashing mid-stream.
46
+ * - **Library unavailable** — `mermaid` is an optional peer dependency; if the
47
+ * dynamic import fails the source stays a code block.
48
+ * - **Parse error** — invalid diagram source falls back to the code block plus
49
+ * a screen-reader-announced note, so the agent's output is never hidden.
50
+ *
51
+ * Security: agent output is untrusted and this SDK renders inside host
52
+ * applications, so mermaid runs with `securityLevel: "strict"` (sanitized
53
+ * labels, no `click` interactivity) — deliberately stricter than the
54
+ * docs-site renderer, which only ever shows first-party content.
55
+ *
56
+ * Theming: the container chrome is fully `--stgm-*` token-driven, and so is
57
+ * the SVG interior. The built-in `default`/`dark` theme (selected via
58
+ * {@link useColorMode}) is the base; on top of it we inject
59
+ * {@link MERMAID_THEME_CSS}, a token-driven stylesheet whose `var(--stgm-*)`
60
+ * references the browser resolves against the `.stgm` scope. Interiors
61
+ * therefore track the active preset *and* color mode live — no color is read
62
+ * in JS, and a preset switch needs no re-render. Feeding tokens through
63
+ * mermaid's `themeVariables` is not possible: they are authored in `oklch()`,
64
+ * which mermaid's color engine (khroma) cannot parse. Any element or diagram
65
+ * type the CSS does not target falls back to the built-in theme, so a diagram
66
+ * is never blank or mis-colored.
67
+ */
68
+ export function MermaidDiagram({ chart }: MermaidDiagramProps) {
69
+ const isIncomplete = useIsCodeFenceIncomplete();
70
+ const colorMode = useColorMode();
71
+ const [state, setState] = useState<RenderState>({ status: "pending" });
72
+
73
+ // Markdown parsers hand the fence body over with its trailing newline;
74
+ // mermaid treats leading/trailing blank lines as part of the diagram.
75
+ const source = chart.trim();
76
+
77
+ useEffect(() => {
78
+ if (isIncomplete || source.length === 0) return;
79
+
80
+ let cancelled = false;
81
+
82
+ void (async () => {
83
+ const renderId = `stgm-mermaid-${++renderSequence}`;
84
+ try {
85
+ const mermaid = await loadMermaid();
86
+ // initialize() mutates a global singleton, so it runs before every
87
+ // render call — all diagrams under one provider share a color mode,
88
+ // and re-initializing keeps a theme switch from bleeding stale config
89
+ // into the next render.
90
+ mermaid.initialize({
91
+ startOnLoad: false,
92
+ securityLevel: "strict",
93
+ theme: colorMode === "dark" ? "dark" : "default",
94
+ themeCSS: MERMAID_THEME_CSS,
95
+ fontFamily: "inherit",
96
+ });
97
+ const { svg } = await mermaid.render(renderId, source);
98
+ if (!cancelled) setState({ status: "rendered", svg });
99
+ } catch (error: unknown) {
100
+ // Mermaid has historically leaked its scratch element into the
101
+ // document on parse failure; removing it defensively costs nothing.
102
+ document.getElementById(renderId)?.remove();
103
+ if (!cancelled) {
104
+ setState({
105
+ status: "failed",
106
+ message: error instanceof Error ? error.message : String(error),
107
+ });
108
+ }
109
+ }
110
+ })();
111
+
112
+ return () => {
113
+ cancelled = true;
114
+ };
115
+ // A previously rendered diagram stays visible while a re-render (theme
116
+ // switch) is in flight — swapping to a blank pending state would flash.
117
+ }, [source, colorMode, isIncomplete]);
118
+
119
+ if (state.status === "rendered") {
120
+ return (
121
+ <div
122
+ role="img"
123
+ aria-label="Mermaid diagram"
124
+ className="mb-3 last:mb-0 flex justify-center overflow-x-auto rounded-md border border-border bg-card p-3 [&_svg]:max-w-full"
125
+ dangerouslySetInnerHTML={{ __html: state.svg }}
126
+ />
127
+ );
128
+ }
129
+
130
+ return (
131
+ <MermaidSourceFallback
132
+ chart={chart}
133
+ failureMessage={state.status === "failed" ? state.message : null}
134
+ />
135
+ );
136
+ }
137
+
138
+ /**
139
+ * The pre-diagram presentation of a mermaid fence: the same styled code block
140
+ * the shared `pre`/`code` overrides in `markdown-components.tsx` would have
141
+ * produced (classes mirrored from there — importing them would create a
142
+ * module cycle, since that file mounts this component). Shown while the fence
143
+ * streams, while mermaid loads, and permanently when rendering fails.
144
+ */
145
+ function MermaidSourceFallback({
146
+ chart,
147
+ failureMessage,
148
+ }: {
149
+ chart: string;
150
+ failureMessage: string | null;
151
+ }) {
152
+ return (
153
+ <div className="mb-3 last:mb-0">
154
+ <pre className="overflow-x-auto rounded-md bg-muted p-3">
155
+ <code className="font-mono text-xs text-foreground">{chart}</code>
156
+ </pre>
157
+ {failureMessage !== null && (
158
+ <p role="status" className="mt-1 text-xs text-muted-foreground">
159
+ Mermaid diagram failed to render; showing source. ({failureMessage})
160
+ </p>
161
+ )}
162
+ </div>
163
+ );
164
+ }
@@ -0,0 +1,122 @@
1
+ // Browser-mode regression guard (runs under vitest.a11y.config.ts in real
2
+ // Chromium) for token-driven Mermaid interior theming.
3
+ //
4
+ // This is not an axe audit — it lives here because the claim can only be
5
+ // verified against real layout + a real CSS engine: that Mermaid's injected
6
+ // `themeCSS` (authored with `var(--stgm-*)`) survives `securityLevel: "strict"`
7
+ // (mermaid's `sanitizeCss` + DOMPurify) and is resolved by the browser cascade
8
+ // against the `.stgm` scope. Source analysis already proves survivability; this
9
+ // test locks it against future mermaid/DOMPurify upgrades and proves, end to
10
+ // end, that interiors track the active color mode and preset live — with no
11
+ // component re-render (we mutate the scope in the DOM, never React state).
12
+
13
+ import "../../../../dist/styles.css";
14
+ import "@stigmer/theme/presets/fintech.css";
15
+
16
+ import { describe, it, afterEach, expect } from "vitest";
17
+ import { render, cleanup, waitFor } from "@testing-library/react";
18
+ import { ColorModeContext, type ResolvedColorMode } from "../../../color-mode.js";
19
+ import { MermaidDiagram } from "../../MermaidDiagram.js";
20
+
21
+ const CHART = "flowchart LR\n A[Hello] --> B[World]";
22
+
23
+ const NODE_SHAPE_SELECTOR =
24
+ "svg .node rect, svg .node polygon, svg .node circle, svg .node ellipse, svg .node path";
25
+
26
+ interface Mounted {
27
+ readonly container: HTMLElement;
28
+ /** The painted node shape whose `fill` our `themeCSS` drives from a token. */
29
+ readonly nodeShape: SVGElement;
30
+ /** Probe resolving `var(--stgm-muted)` (node fill) in the same scope. */
31
+ readonly mutedProbe: HTMLElement;
32
+ /** Probe resolving `var(--stgm-primary)` in the same scope. */
33
+ readonly primaryProbe: HTMLElement;
34
+ }
35
+
36
+ /**
37
+ * Render a diagram inside a `.stgm` scope carrying `scopeClass` (e.g. a preset)
38
+ * and `mode`, alongside two probe spans that resolve the same tokens. Resolves
39
+ * once the SVG interior has painted.
40
+ */
41
+ async function mount(
42
+ scopeClass: string,
43
+ mode: ResolvedColorMode,
44
+ ): Promise<Mounted> {
45
+ const container = document.createElement("div");
46
+ container.className = `stgm ${scopeClass}`.trim();
47
+ container.setAttribute("data-stgm-color-mode", mode);
48
+ document.body.appendChild(container);
49
+
50
+ render(
51
+ <ColorModeContext.Provider value={mode}>
52
+ <span data-probe="muted" style={{ color: "var(--stgm-muted)" }} />
53
+ <span data-probe="primary" style={{ color: "var(--stgm-primary)" }} />
54
+ <MermaidDiagram chart={CHART} />
55
+ </ColorModeContext.Provider>,
56
+ { container },
57
+ );
58
+
59
+ await waitFor(() => {
60
+ const shape = container.querySelector(NODE_SHAPE_SELECTOR);
61
+ if (!shape) throw new Error("diagram node not rendered yet");
62
+ });
63
+
64
+ return {
65
+ container,
66
+ nodeShape: container.querySelector(NODE_SHAPE_SELECTOR) as SVGElement,
67
+ mutedProbe: container.querySelector('[data-probe="muted"]') as HTMLElement,
68
+ primaryProbe: container.querySelector('[data-probe="primary"]') as HTMLElement,
69
+ };
70
+ }
71
+
72
+ afterEach(() => {
73
+ cleanup();
74
+ document.querySelectorAll(".stgm").forEach((node) => node.remove());
75
+ });
76
+
77
+ describe("Mermaid interior theming — token-driven, cascade-resolved", () => {
78
+ it("paints node interiors from --stgm-* tokens, not mermaid's stock palette", async () => {
79
+ const { nodeShape, mutedProbe } = await mount("", "light");
80
+ // If our themeCSS lost the specificity/order battle, the fill would be
81
+ // mermaid's default (#ECECFF-ish) and this exact-match would fail.
82
+ expect(getComputedStyle(nodeShape).fill).toBe(
83
+ getComputedStyle(mutedProbe).color,
84
+ );
85
+ });
86
+
87
+ it("tracks a color-mode switch live, with no re-render", async () => {
88
+ const { container, nodeShape, mutedProbe } = await mount("", "light");
89
+ const fillLight = getComputedStyle(nodeShape).fill;
90
+ expect(fillLight).toBe(getComputedStyle(mutedProbe).color);
91
+
92
+ // Flip the scope attribute directly — no React update. Pure cascade.
93
+ container.setAttribute("data-stgm-color-mode", "dark");
94
+
95
+ const fillDark = getComputedStyle(nodeShape).fill;
96
+ expect(fillDark).toBe(getComputedStyle(mutedProbe).color);
97
+ expect(fillDark).not.toBe(fillLight);
98
+ });
99
+
100
+ it("tracks the active preset", async () => {
101
+ const { nodeShape, mutedProbe, primaryProbe } = await mount(
102
+ "stgm-theme-fintech",
103
+ "light",
104
+ );
105
+ // The diagram is painted from the ACTIVE preset's resolved token.
106
+ expect(getComputedStyle(nodeShape).fill).toBe(
107
+ getComputedStyle(mutedProbe).color,
108
+ );
109
+
110
+ // Guard against a false pass where the preset CSS never loaded: fintech's
111
+ // primary (indigo) must differ from the default palette's primary (teal).
112
+ const defaultScope = document.createElement("div");
113
+ defaultScope.className = "stgm";
114
+ defaultScope.setAttribute("data-stgm-color-mode", "light");
115
+ defaultScope.innerHTML = '<span style="color: var(--stgm-primary)"></span>';
116
+ document.body.appendChild(defaultScope);
117
+
118
+ expect(getComputedStyle(primaryProbe).color).not.toBe(
119
+ getComputedStyle(defaultScope.firstElementChild as HTMLElement).color,
120
+ );
121
+ });
122
+ });
@@ -1,12 +1,23 @@
1
- import { describe, it, expect, afterEach } from "vitest";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
2
  import { render, cleanup } from "@testing-library/react";
3
3
  import type { ComponentType, ReactNode } from "react";
4
+ import Markdown from "react-markdown";
4
5
  import {
5
6
  MARKDOWN_COMPONENTS,
7
+ REMARK_PLUGINS,
6
8
  extractLeadingH1,
7
9
  unwrapEnclosingMarkdownFence,
8
10
  } from "../markdown-components";
9
11
 
12
+ // Stub the diagram component: these tests verify the DISPATCH seam (which
13
+ // fences route to the diagram vs the code block), not mermaid rendering —
14
+ // that contract is covered by mermaid-diagram.test.tsx.
15
+ vi.mock("../MermaidDiagram", () => ({
16
+ MermaidDiagram: ({ chart }: { chart: string }) => (
17
+ <div data-testid="mermaid-diagram">{chart}</div>
18
+ ),
19
+ }));
20
+
10
21
  afterEach(cleanup);
11
22
 
12
23
  /** The shared `code` override, typed for direct rendering in tests. */
@@ -15,6 +26,11 @@ const CodeComponent = MARKDOWN_COMPONENTS.code as ComponentType<{
15
26
  children?: ReactNode;
16
27
  }>;
17
28
 
29
+ /** The shared `pre` override, typed for direct rendering in tests. */
30
+ const PreComponent = MARKDOWN_COMPONENTS.pre as ComponentType<{
31
+ children?: ReactNode;
32
+ }>;
33
+
18
34
  describe("unwrapEnclosingMarkdownFence", () => {
19
35
  describe("unwraps a whole-message markdown fence", () => {
20
36
  it.each([
@@ -185,3 +201,79 @@ describe("MARKDOWN_COMPONENTS.code (shared highlight seam)", () => {
185
201
  expect(code!.textContent).toBe("inlineToken");
186
202
  });
187
203
  });
204
+
205
+ describe("MARKDOWN_COMPONENTS.pre (mermaid dispatch seam)", () => {
206
+ const CHART = "flowchart LR\n A --> B";
207
+
208
+ it("routes an explicit ```mermaid fence to the diagram, dropping the <pre> wrapper", () => {
209
+ const { container } = render(
210
+ <PreComponent>
211
+ <code className="language-mermaid">{CHART}</code>
212
+ </PreComponent>,
213
+ );
214
+
215
+ const diagram = container.querySelector('[data-testid="mermaid-diagram"]');
216
+ expect(diagram).not.toBeNull();
217
+ expect(diagram!.textContent).toBe(CHART);
218
+ // The diagram replaces the <pre> entirely — a block container inside
219
+ // <pre> would be invalid HTML and inherit code-block chrome.
220
+ expect(container.querySelector("pre")).toBeNull();
221
+ });
222
+
223
+ it("matches language-mermaid among multiple classes", () => {
224
+ const { container } = render(
225
+ <PreComponent>
226
+ <code className="language-mermaid extra-class">{CHART}</code>
227
+ </PreComponent>,
228
+ );
229
+
230
+ expect(
231
+ container.querySelector('[data-testid="mermaid-diagram"]'),
232
+ ).not.toBeNull();
233
+ });
234
+
235
+ it.each([
236
+ [
237
+ "a different language",
238
+ <code key="go" className="language-go">{"func main() {}"}</code>,
239
+ ],
240
+ [
241
+ "a language merely PREFIXED with mermaid",
242
+ <code key="pfx" className="language-mermaidjs">{CHART}</code>,
243
+ ],
244
+ ["an untagged fence", <code key="bare">{CHART}</code>],
245
+ [
246
+ "non-string code children",
247
+ <code key="node" className="language-mermaid">
248
+ <span>{CHART}</span>
249
+ </code>,
250
+ ],
251
+ ["plain-text children (no code element)", CHART],
252
+ ])("keeps the ordinary <pre> code block for %s", (_label, children) => {
253
+ const { container } = render(<PreComponent>{children}</PreComponent>);
254
+
255
+ expect(container.querySelector('[data-testid="mermaid-diagram"]')).toBeNull();
256
+ const pre = container.querySelector("pre");
257
+ expect(pre).not.toBeNull();
258
+ expect(pre!.className).toContain("bg-muted");
259
+ });
260
+
261
+ it("dispatches through real react-markdown (the artifact/skill surface path)", () => {
262
+ // Renders an actual document instead of synthetic elements, proving the
263
+ // element shape react-markdown hands to `pre` matches what the seam
264
+ // detects. The Streamdown (chat) path has the equivalent end-to-end
265
+ // coverage in message-entry.test.tsx.
266
+ const doc = `Before\n\n\`\`\`mermaid\n${CHART}\n\`\`\`\n\n\`\`\`go\nfunc main() {}\n\`\`\``;
267
+ const { container } = render(
268
+ <Markdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
269
+ {doc}
270
+ </Markdown>,
271
+ );
272
+
273
+ const diagram = container.querySelector('[data-testid="mermaid-diagram"]');
274
+ expect(diagram).not.toBeNull();
275
+ expect(diagram!.textContent).toContain("flowchart LR");
276
+ // The sibling go fence still renders as an ordinary highlighted block.
277
+ expect(container.querySelector("pre code.language-go")).not.toBeNull();
278
+ });
279
+ });
@@ -0,0 +1,282 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { render, cleanup, waitFor, act } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { MermaidDiagram } from "../MermaidDiagram";
5
+ import { loadMermaid, type MermaidModule } from "../mermaid-loader";
6
+ import { MERMAID_THEME_CSS } from "../mermaid-theme-css";
7
+ import { ColorModeContext, type ResolvedColorMode } from "../../color-mode";
8
+
9
+ // The loader is the component's only side-effectful dependency; mocking it
10
+ // (not the `mermaid` package) exercises the real degradation contract:
11
+ // a rejected load must look exactly like "library not installed".
12
+ vi.mock("../mermaid-loader", () => ({
13
+ loadMermaid: vi.fn(),
14
+ }));
15
+
16
+ // Partial-mock streamdown so tests can drive the incomplete-fence signal
17
+ // without spinning up a full Streamdown render.
18
+ vi.mock("streamdown", async (importOriginal) => ({
19
+ ...(await importOriginal<typeof import("streamdown")>()),
20
+ useIsCodeFenceIncomplete: vi.fn(() => false),
21
+ }));
22
+
23
+ const { useIsCodeFenceIncomplete } = await import("streamdown");
24
+
25
+ const loadMermaidMock = vi.mocked(loadMermaid);
26
+ const incompleteFenceMock = vi.mocked(useIsCodeFenceIncomplete);
27
+
28
+ /** Builds a mermaid module stub whose render resolves with the given SVG. */
29
+ function stubMermaid(svg = "<svg><g>diagram</g></svg>") {
30
+ const mermaid = {
31
+ initialize: vi.fn(),
32
+ render: vi.fn().mockResolvedValue({ svg }),
33
+ };
34
+ loadMermaidMock.mockResolvedValue(mermaid as unknown as MermaidModule);
35
+ return mermaid;
36
+ }
37
+
38
+ function renderDiagram(
39
+ chart: string,
40
+ { colorMode = "light" as ResolvedColorMode } = {},
41
+ ) {
42
+ return render(
43
+ <ColorModeContext.Provider value={colorMode}>
44
+ <MermaidDiagram chart={chart} />
45
+ </ColorModeContext.Provider>,
46
+ );
47
+ }
48
+
49
+ function queryDiagram(container: HTMLElement) {
50
+ return container.querySelector('[role="img"][aria-label="Mermaid diagram"]');
51
+ }
52
+
53
+ beforeEach(() => {
54
+ loadMermaidMock.mockReset();
55
+ incompleteFenceMock.mockReset();
56
+ incompleteFenceMock.mockReturnValue(false);
57
+ });
58
+
59
+ afterEach(cleanup);
60
+
61
+ const CHART = "flowchart LR\n A --> B";
62
+
63
+ describe("MermaidDiagram — successful render", () => {
64
+ it("renders the SVG in an accessible container", async () => {
65
+ stubMermaid("<svg data-diagram><g>ok</g></svg>");
66
+ const { container } = renderDiagram(CHART);
67
+
68
+ await waitFor(() => expect(queryDiagram(container)).not.toBeNull());
69
+ expect(queryDiagram(container)!.querySelector("svg[data-diagram]")).not.toBeNull();
70
+ // The source fallback is gone once the diagram is up.
71
+ expect(container.querySelector("pre")).toBeNull();
72
+ });
73
+
74
+ it("shows the source as a code block while the render is pending", () => {
75
+ // A load that never settles = the loading window.
76
+ loadMermaidMock.mockReturnValue(new Promise(() => {}));
77
+ const { container } = renderDiagram(CHART);
78
+
79
+ const code = container.querySelector("pre code");
80
+ expect(code).not.toBeNull();
81
+ expect(code!.textContent).toBe(CHART);
82
+ expect(queryDiagram(container)).toBeNull();
83
+ });
84
+
85
+ it("trims the trailing newline the fence body carries before rendering", async () => {
86
+ const mermaid = stubMermaid();
87
+ renderDiagram("flowchart LR\n A --> B\n");
88
+
89
+ await waitFor(() => expect(mermaid.render).toHaveBeenCalled());
90
+ expect(mermaid.render).toHaveBeenCalledWith(
91
+ expect.any(String),
92
+ "flowchart LR\n A --> B",
93
+ );
94
+ });
95
+ });
96
+
97
+ describe("MermaidDiagram — security and theming configuration", () => {
98
+ it("configures mermaid with securityLevel strict (agent output is untrusted)", async () => {
99
+ const mermaid = stubMermaid();
100
+ renderDiagram(CHART);
101
+
102
+ await waitFor(() => expect(mermaid.initialize).toHaveBeenCalled());
103
+ expect(mermaid.initialize).toHaveBeenCalledWith(
104
+ expect.objectContaining({ securityLevel: "strict", startOnLoad: false }),
105
+ );
106
+ });
107
+
108
+ it("injects the token-driven themeCSS so interiors track --stgm-* tokens", async () => {
109
+ const mermaid = stubMermaid();
110
+ renderDiagram(CHART);
111
+
112
+ await waitFor(() => expect(mermaid.initialize).toHaveBeenCalled());
113
+ // The interior palette rides on themeCSS (var(--stgm-*)), layered over the
114
+ // built-in default/dark base theme. The browser-mode a11y suite verifies
115
+ // the CSS actually resolves; here we lock the wiring itself.
116
+ expect(mermaid.initialize).toHaveBeenCalledWith(
117
+ expect.objectContaining({ themeCSS: MERMAID_THEME_CSS }),
118
+ );
119
+ });
120
+
121
+ it.each([
122
+ ["light", "default"],
123
+ ["dark", "dark"],
124
+ ] as const)("selects the %s theme for %s color mode", async (mode, theme) => {
125
+ const mermaid = stubMermaid();
126
+ renderDiagram(CHART, { colorMode: mode });
127
+
128
+ await waitFor(() => expect(mermaid.initialize).toHaveBeenCalled());
129
+ expect(mermaid.initialize).toHaveBeenCalledWith(
130
+ expect.objectContaining({ theme }),
131
+ );
132
+ });
133
+
134
+ it("re-renders the diagram when the color mode changes", async () => {
135
+ const mermaid = stubMermaid();
136
+ const { container, rerender } = render(
137
+ <ColorModeContext.Provider value="light">
138
+ <MermaidDiagram chart={CHART} />
139
+ </ColorModeContext.Provider>,
140
+ );
141
+ await waitFor(() => expect(queryDiagram(container)).not.toBeNull());
142
+
143
+ rerender(
144
+ <ColorModeContext.Provider value="dark">
145
+ <MermaidDiagram chart={CHART} />
146
+ </ColorModeContext.Provider>,
147
+ );
148
+
149
+ await waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(2));
150
+ expect(mermaid.initialize).toHaveBeenLastCalledWith(
151
+ expect.objectContaining({ theme: "dark" }),
152
+ );
153
+ });
154
+ });
155
+
156
+ describe("MermaidDiagram — failure degrades to the code block", () => {
157
+ it("falls back with an announced note when the diagram source is invalid", async () => {
158
+ loadMermaidMock.mockResolvedValue({
159
+ initialize: vi.fn(),
160
+ render: vi.fn().mockRejectedValue(new Error("Parse error on line 2")),
161
+ } as unknown as MermaidModule);
162
+ const { container } = renderDiagram("flowchart LR\n A --> ");
163
+
164
+ await waitFor(() =>
165
+ expect(container.querySelector('[role="status"]')).not.toBeNull(),
166
+ );
167
+ expect(container.querySelector("pre code")!.textContent).toContain(
168
+ "A -->",
169
+ );
170
+ expect(container.querySelector('[role="status"]')!.textContent).toContain(
171
+ "Parse error on line 2",
172
+ );
173
+ expect(queryDiagram(container)).toBeNull();
174
+ });
175
+
176
+ it("falls back when the mermaid library cannot be loaded (optional peer absent)", async () => {
177
+ loadMermaidMock.mockRejectedValue(
178
+ new Error("Cannot find module 'mermaid'"),
179
+ );
180
+ const { container } = renderDiagram(CHART);
181
+
182
+ await waitFor(() =>
183
+ expect(container.querySelector('[role="status"]')).not.toBeNull(),
184
+ );
185
+ expect(container.querySelector("pre code")!.textContent).toBe(CHART);
186
+ expect(queryDiagram(container)).toBeNull();
187
+ });
188
+
189
+ it("does not attempt to render an empty fence", () => {
190
+ stubMermaid();
191
+ const { container } = renderDiagram(" \n ");
192
+
193
+ expect(loadMermaidMock).not.toHaveBeenCalled();
194
+ expect(queryDiagram(container)).toBeNull();
195
+ });
196
+ });
197
+
198
+ describe("MermaidDiagram — streaming deferral", () => {
199
+ it("defers rendering while the fence is still streaming", () => {
200
+ stubMermaid();
201
+ incompleteFenceMock.mockReturnValue(true);
202
+ const { container } = renderDiagram(CHART);
203
+
204
+ expect(loadMermaidMock).not.toHaveBeenCalled();
205
+ const code = container.querySelector("pre code");
206
+ expect(code).not.toBeNull();
207
+ expect(code!.textContent).toBe(CHART);
208
+ });
209
+
210
+ it("renders the diagram once the fence closes", async () => {
211
+ stubMermaid();
212
+ incompleteFenceMock.mockReturnValue(true);
213
+ const { container, rerender } = renderDiagram(CHART);
214
+ expect(queryDiagram(container)).toBeNull();
215
+
216
+ incompleteFenceMock.mockReturnValue(false);
217
+ rerender(
218
+ <ColorModeContext.Provider value="light">
219
+ <MermaidDiagram chart={CHART} />
220
+ </ColorModeContext.Provider>,
221
+ );
222
+
223
+ await waitFor(() => expect(queryDiagram(container)).not.toBeNull());
224
+ });
225
+ });
226
+
227
+ describe("MermaidDiagram — stale-render races", () => {
228
+ it("discards a render that resolves after the chart has changed", async () => {
229
+ const initialize = vi.fn();
230
+ let resolveFirst!: (result: { svg: string }) => void;
231
+ const renderMock = vi
232
+ .fn()
233
+ .mockImplementationOnce(
234
+ () => new Promise((resolve) => (resolveFirst = resolve)),
235
+ )
236
+ .mockResolvedValue({ svg: "<svg data-chart='second'></svg>" });
237
+ loadMermaidMock.mockResolvedValue({
238
+ initialize,
239
+ render: renderMock,
240
+ } as unknown as MermaidModule);
241
+
242
+ const { container, rerender } = renderDiagram("flowchart LR\n A --> B");
243
+ await waitFor(() => expect(renderMock).toHaveBeenCalledTimes(1));
244
+
245
+ rerender(
246
+ <ColorModeContext.Provider value="light">
247
+ <MermaidDiagram chart={"flowchart LR\n C --> D"} />
248
+ </ColorModeContext.Provider>,
249
+ );
250
+ await waitFor(() =>
251
+ expect(
252
+ container.querySelector("svg[data-chart='second']"),
253
+ ).not.toBeNull(),
254
+ );
255
+
256
+ // The first (now stale) render settles late — it must not clobber the
257
+ // second chart's SVG.
258
+ act(() => resolveFirst({ svg: "<svg data-chart='first'></svg>" }));
259
+ expect(container.querySelector("svg[data-chart='first']")).toBeNull();
260
+ expect(container.querySelector("svg[data-chart='second']")).not.toBeNull();
261
+ });
262
+
263
+ it("ignores a render that settles after unmount", async () => {
264
+ let resolveRender!: (result: { svg: string }) => void;
265
+ loadMermaidMock.mockResolvedValue({
266
+ initialize: vi.fn(),
267
+ render: vi.fn().mockReturnValue(
268
+ new Promise((resolve) => (resolveRender = resolve)),
269
+ ),
270
+ } as unknown as MermaidModule);
271
+
272
+ const { unmount } = renderDiagram(CHART);
273
+ await waitFor(() => expect(loadMermaidMock).toHaveBeenCalled());
274
+ unmount();
275
+
276
+ // Settling after unmount must not warn or throw (React state update on
277
+ // an unmounted component).
278
+ expect(() =>
279
+ act(() => resolveRender({ svg: "<svg></svg>" })),
280
+ ).not.toThrow();
281
+ });
282
+ });