@elabs-ai/components-ai 4.0.0 → 4.1.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 (128) hide show
  1. package/README.md +61 -11
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1109 -202
  9. package/dist/index.js +4867 -2878
  10. package/dist/index.js.map +1 -1
  11. package/package.json +31 -16
  12. package/src/_audio-player-media-chrome.tsx +102 -15
  13. package/src/_flow-boundary.tsx +68 -49
  14. package/src/_lazy-boundary-conformance.ts +38 -0
  15. package/src/_lazy-engine-boundary.tsx +61 -0
  16. package/src/_lazy-mermaid-absent.test.ts +53 -0
  17. package/src/_lazy-mermaid.test.ts +15 -0
  18. package/src/_lazy-mermaid.ts +24 -1
  19. package/src/_mermaid-error-panel.test.tsx +50 -0
  20. package/src/_mermaid-error-panel.tsx +66 -0
  21. package/src/_persona-rive.tsx +62 -10
  22. package/src/_streamdown-i18n.ts +21 -1
  23. package/src/_streamdown-safety.ts +170 -0
  24. package/src/agent-event.stories.tsx +97 -0
  25. package/src/agent-event.test.tsx +145 -0
  26. package/src/agent-event.tsx +187 -0
  27. package/src/agent-timeline.stories.tsx +9 -1
  28. package/src/agent.test.tsx +47 -0
  29. package/src/agent.tsx +28 -14
  30. package/src/agentic-workspace.stories.tsx +27 -20
  31. package/src/artifact.tsx +15 -12
  32. package/src/audio-player.test.tsx +78 -0
  33. package/src/audio-player.tsx +243 -56
  34. package/src/audio-visualizer.stories.tsx +126 -0
  35. package/src/audio-visualizer.test.tsx +438 -0
  36. package/src/audio-visualizer.tsx +367 -0
  37. package/src/canvas.stories.tsx +150 -1
  38. package/src/chat-shell.stories.tsx +18 -3
  39. package/src/chat.stories.tsx +16 -2
  40. package/src/code-block.stories.tsx +9 -1
  41. package/src/composer.stories.tsx +372 -9
  42. package/src/composer.test.tsx +357 -6
  43. package/src/composer.tsx +217 -35
  44. package/src/confirmation.stories.tsx +72 -1
  45. package/src/confirmation.test.tsx +216 -2
  46. package/src/confirmation.tsx +263 -3
  47. package/src/context-panel.stories.tsx +9 -1
  48. package/src/context-panel.tsx +2 -1
  49. package/src/conversation.stories.tsx +63 -2
  50. package/src/conversation.test.tsx +13 -0
  51. package/src/conversation.tsx +20 -2
  52. package/src/diff-view.stories.tsx +196 -0
  53. package/src/diff-view.test.tsx +188 -0
  54. package/src/diff-view.tsx +642 -0
  55. package/src/gallery.tsx +4 -2
  56. package/src/index.ts +14 -4
  57. package/src/jsx-preview.stories.tsx +2 -2
  58. package/src/markdown-view.stories.tsx +92 -1
  59. package/src/markdown-view.test.tsx +232 -1
  60. package/src/markdown-view.tsx +150 -6
  61. package/src/message-compare.stories.tsx +175 -0
  62. package/src/message-compare.test.tsx +207 -0
  63. package/src/message-compare.tsx +453 -0
  64. package/src/message-form.stories.tsx +29 -1
  65. package/src/message.stories.tsx +9 -1
  66. package/src/message.test.tsx +176 -0
  67. package/src/message.tsx +90 -4
  68. package/src/microcopy.test.tsx +40 -0
  69. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  70. package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
  71. package/src/model-provider-logo.tsx +149 -0
  72. package/src/permission-mode-select.stories.tsx +82 -0
  73. package/src/permission-mode-select.test.tsx +100 -0
  74. package/src/permission-mode-select.tsx +137 -0
  75. package/src/persona-missing-peer.test.tsx +54 -0
  76. package/src/persona.tsx +68 -22
  77. package/src/plan.stories.tsx +166 -0
  78. package/src/plan.test.tsx +267 -0
  79. package/src/plan.tsx +182 -20
  80. package/src/prompt-input-effort.stories.tsx +123 -0
  81. package/src/prompt-input-effort.test.tsx +83 -0
  82. package/src/prompt-input-effort.tsx +136 -0
  83. package/src/prompt-input-mode.stories.tsx +108 -0
  84. package/src/prompt-input-mode.test.tsx +99 -0
  85. package/src/prompt-input-mode.tsx +169 -0
  86. package/src/prompt-input-slash.stories.tsx +211 -0
  87. package/src/prompt-input-slash.test.tsx +262 -0
  88. package/src/prompt-input-slash.tsx +541 -0
  89. package/src/prompt-input.stories.tsx +2 -2
  90. package/src/reasoning.tsx +27 -13
  91. package/src/sandbox.stories.tsx +9 -1
  92. package/src/schema-display.tsx +5 -2
  93. package/src/selection-toolbar.stories.tsx +9 -1
  94. package/src/session-header.stories.tsx +128 -0
  95. package/src/session-header.test.tsx +138 -0
  96. package/src/session-header.tsx +243 -0
  97. package/src/session-status-bar.stories.tsx +73 -0
  98. package/src/session-status-bar.test.tsx +94 -0
  99. package/src/session-status-bar.tsx +165 -0
  100. package/src/snippet.stories.tsx +9 -1
  101. package/src/stack-trace.tsx +6 -2
  102. package/src/streamdown-i18n.test.tsx +1 -1
  103. package/src/task.stories.tsx +13 -3
  104. package/src/templates-ai-assistant.stories.tsx +21 -1
  105. package/src/token-usage.stories.tsx +24 -0
  106. package/src/token-usage.test.tsx +92 -0
  107. package/src/{context.tsx → token-usage.tsx} +67 -53
  108. package/src/tool-result-card.stories.tsx +9 -1
  109. package/src/tool.stories.tsx +13 -3
  110. package/src/tool.tsx +15 -8
  111. package/src/turn-status.stories.tsx +124 -0
  112. package/src/turn-status.test.tsx +74 -0
  113. package/src/turn-status.tsx +174 -0
  114. package/src/use-audio-level.ts +104 -0
  115. package/src/web-preview.tsx +8 -3
  116. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  117. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  118. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  119. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  120. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  121. package/src/_interactive-terminal-xterm.ts +0 -32
  122. package/src/blocks-ai-composer.stories.tsx +0 -83
  123. package/src/context.stories.tsx +0 -16
  124. package/src/interactive-terminal.stories.tsx +0 -165
  125. package/src/interactive-terminal.test.tsx +0 -448
  126. package/src/interactive-terminal.tsx +0 -444
  127. package/src/model-selector.tsx +0 -225
  128. package/src/terminal.tsx +0 -244
package/src/index.ts CHANGED
@@ -20,11 +20,13 @@
20
20
  * See ATTRIBUTION.md and .claude/rules/attribution.md.
21
21
  */
22
22
  export * from "./agent";
23
+ export * from "./agent-event";
23
24
  export * from "./agent-timeline";
24
25
  export * from "./artifact";
25
26
  export * from "./asset-preview";
26
27
  export * from "./attachments";
27
28
  export * from "./audio-player";
29
+ export * from "./audio-visualizer";
28
30
  export * from "./canvas";
29
31
  export * from "./chain-of-thought";
30
32
  export * from "./chat-greeting";
@@ -34,10 +36,10 @@ export * from "./commit";
34
36
  export * from "./composer";
35
37
  export * from "./confirmation";
36
38
  export * from "./connection";
37
- export * from "./context";
38
39
  export * from "./context-panel";
39
40
  export * from "./controls";
40
41
  export * from "./conversation";
42
+ export * from "./diff-view";
41
43
  export * from "./edge";
42
44
  export * from "./environment-variables";
43
45
  export * from "./file-tree";
@@ -45,13 +47,13 @@ export * from "./gallery";
45
47
  export * from "./grouped-parts";
46
48
  export * from "./image";
47
49
  export * from "./inline-citation";
48
- export * from "./interactive-terminal";
49
50
  export * from "./jsx-preview";
50
51
  // Mermaid loads on first diagram render, not in the entry chunk. `preloadMermaid`
51
52
  // warms it early when a surface is known to render diagrams.
52
53
  export { createLazyMermaidPlugin, lazyMermaid, preloadMermaid } from "./_lazy-mermaid";
53
54
  export * from "./markdown-view";
54
55
  export * from "./message";
56
+ export * from "./message-compare";
55
57
  export * from "./message-edit";
56
58
  export * from "./message-feedback";
57
59
  export * from "./message-form";
@@ -59,21 +61,27 @@ export * from "./message-form-spec";
59
61
  export * from "./message-table";
60
62
  export * from "./message-table-spec";
61
63
  export * from "./mic-selector";
62
- export * from "./model-selector";
64
+ export * from "./model-provider-logo";
63
65
  export * from "./motion-config";
64
66
  export * from "./node";
65
67
  export * from "./open-in-chat";
66
68
  export * from "./package-info";
67
69
  export * from "./panel";
68
70
  export * from "./part-groups";
71
+ export * from "./permission-mode-select";
69
72
  export * from "./persona";
70
73
  export * from "./plan";
71
74
  export * from "./prompt-input";
75
+ export * from "./prompt-input-effort";
76
+ export * from "./prompt-input-mode";
77
+ export * from "./prompt-input-slash";
72
78
  export * from "./queue";
73
79
  export * from "./reasoning";
74
80
  export * from "./sandbox";
75
81
  export * from "./schema-display";
76
82
  export * from "./selection-toolbar";
83
+ export * from "./session-header";
84
+ export * from "./session-status-bar";
77
85
  export * from "./shimmer";
78
86
  export * from "./snippet";
79
87
  export * from "./sources";
@@ -81,12 +89,14 @@ export * from "./speech-input";
81
89
  export * from "./stack-trace";
82
90
  export * from "./suggestion";
83
91
  export * from "./task";
84
- export * from "./terminal";
85
92
  export * from "./test-results";
93
+ export * from "./token-usage";
86
94
  export * from "./tool";
87
95
  export * from "./tool-result-card";
88
96
  export * from "./toolbar";
89
97
  export * from "./transcription";
98
+ export * from "./turn-status";
99
+ export * from "./use-audio-level";
90
100
  export * from "./voice-selector";
91
101
  export * from "./web-preview";
92
102
 
@@ -18,7 +18,7 @@ function Stat({ label, value, delta }: { label: string; value: string; delta?: s
18
18
  <div className="rounded-lg border bg-card p-4 text-card-foreground">
19
19
  <div className="text-meta text-muted-foreground">{label}</div>
20
20
  <div className="text-title tabular-nums">{value}</div>
21
- {delta ? <div className="text-caption text-primary">{delta}</div> : null}
21
+ {delta ? <div className="text-caption text-primary-text">{delta}</div> : null}
22
22
  </div>
23
23
  );
24
24
  }
@@ -50,7 +50,7 @@ const DASHBOARD =
50
50
  "</div>";
51
51
 
52
52
  const meta = {
53
- title: "AI/JSX Preview",
53
+ title: "AI/JSXPreview",
54
54
  component: JSXPreview,
55
55
  parameters: { layout: "padded" },
56
56
  } satisfies Meta<typeof JSXPreview>;
@@ -1,11 +1,47 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import type { ComponentProps } from "react";
3
+ import {
4
+ InlineCitation,
5
+ InlineCitationCard,
6
+ InlineCitationCardBody,
7
+ InlineCitationCardTrigger,
8
+ } from "./inline-citation";
2
9
  import { MarkdownView } from "./markdown-view";
3
10
 
4
11
  const meta = {
5
12
  title: "AI/MarkdownView",
6
13
  component: MarkdownView,
7
14
  tags: ["autodocs"],
8
- parameters: { layout: "padded" },
15
+ parameters: {
16
+ layout: "padded",
17
+ docs: {
18
+ description: {
19
+ component:
20
+ "A model-authored answer or a read-only markdown DOCUMENT, rendered as a " +
21
+ "document — never as Shiki source. " +
22
+ "Pick a markdown renderer by where the markdown is going to be READ: a " +
23
+ "read-only document in a chat or a side rail → `AI/MarkdownView`; the preview " +
24
+ "pane of the markdown editor → `Editor/MarkdownPreview`; a file the app did " +
25
+ "not write → the markdown adapter behind `Viewer/FileViewer`; streaming into " +
26
+ "a message as the model writes it → `MessageResponse` on `AI/Message`. See " +
27
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs)." +
28
+ " Its own seams: `baseHeadingLevel` constrains the heading rungs so a " +
29
+ "document `#` inside a 20rem rail lands on the `title` rung instead of " +
30
+ "becoming the biggest text on screen, and the sanitiser chain is LOCKED — " +
31
+ "`rehypePlugins` is omitted at the type level and stripped at runtime, so a " +
32
+ "caller cannot widen what a model is allowed to emit. It understands no brand " +
33
+ "directives; those belong to the editor preview.\n\n" +
34
+ "All three map their element tree onto the same `Prose*` primitives owned " +
35
+ "by `@elabs-ai/components-ui` (`@elabs-ai/components-editor` re-exports them " +
36
+ "under short names). The element MAPS stay per-surface on purpose — " +
37
+ "`@elabs-ai/components-ai`, `@elabs-ai/components-editor` and " +
38
+ "`@elabs-ai/components-viewer` are leaves that may not import one another, and " +
39
+ "`streamdown` deliberately never moves down into `@elabs-ai/components-ui`, or " +
40
+ "every consumer of every foundation component would carry it. One prose source, " +
41
+ "several renderers.",
42
+ },
43
+ },
44
+ },
9
45
  } satisfies Meta<typeof MarkdownView>;
10
46
  export default meta;
11
47
  type Story = StoryObj<typeof meta>;
@@ -55,3 +91,58 @@ export const ConstrainedHeadings: Story = {
55
91
  ),
56
92
  ],
57
93
  };
94
+
95
+ const CITED_DOC = `## Q3 answer
96
+
97
+ Revenue grew **12.4% QoQ**, led by EMEA[1](https://example.com/q3-report) with a smaller
98
+ lift from APAC[2](https://example.com/apac-notes).
99
+
100
+ Regular links are unaffected — see the [dashboard](https://example.com/dashboard) for the
101
+ full breakdown.
102
+ `;
103
+
104
+ /** A markdown `a` renderer that swaps a `[1](url)`-style citation marker for an
105
+ * interactive `InlineCitation` chip; any other link renders exactly as before. */
106
+ const citationLinkComponent: NonNullable<
107
+ ComponentProps<typeof MarkdownView>["components"]
108
+ >["a"] = ({ href, children, node: _node, ...props }) => {
109
+ const label = typeof children === "string" ? children : "";
110
+ if (href && /^\d+$/.test(label)) {
111
+ return (
112
+ <InlineCitation>
113
+ <InlineCitationCard>
114
+ <InlineCitationCardTrigger sources={[href]} />
115
+ <InlineCitationCardBody>
116
+ <div className="p-3 text-meta text-muted-foreground">Source {label}</div>
117
+ </InlineCitationCardBody>
118
+ </InlineCitationCard>
119
+ </InlineCitation>
120
+ );
121
+ }
122
+ return (
123
+ <a href={href} rel="noopener noreferrer" target="_blank" {...props}>
124
+ {children}
125
+ </a>
126
+ );
127
+ };
128
+
129
+ /**
130
+ * The #10 motivating use case: a RAG answer's markdown emits `[1](url)`-style
131
+ * citation markers. Overriding `components.a` swaps those for `InlineCitation`
132
+ * chips — every other element (the `##` heading, the "dashboard" link) still
133
+ * renders through the internal Prose* map because `MarkdownView` MERGES the
134
+ * override in per key rather than replacing the whole map.
135
+ */
136
+ export const InlineCitations: Story = {
137
+ args: {
138
+ children: CITED_DOC,
139
+ components: { a: citationLinkComponent },
140
+ },
141
+ decorators: [
142
+ (Story) => (
143
+ <div className="max-w-prose">
144
+ <Story />
145
+ </div>
146
+ ),
147
+ ],
148
+ };
@@ -1,5 +1,7 @@
1
+ import { math } from "@streamdown/math";
1
2
  import { cleanup, render, screen } from "@testing-library/react";
2
- import { afterEach, describe, expect, it } from "vitest";
3
+ import type { ComponentProps } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
5
  import { MarkdownView } from "./markdown-view";
4
6
 
5
7
  afterEach(cleanup);
@@ -34,3 +36,232 @@ describe("MarkdownView (#193, research 04 §5 — document, not Shiki source)",
34
36
  expect(screen.getByRole("heading", { level: 2, name: "Board note" })).toBeInTheDocument();
35
37
  });
36
38
  });
39
+
40
+ describe("MarkdownView components/plugins overrides (#10 — inline citations)", () => {
41
+ const CITATION_DOC = `# Board note
42
+
43
+ Revenue grew[1](https://example.com/report) this quarter.
44
+
45
+ A paragraph with [a link](https://example.com) and \`inline\` code.
46
+
47
+ - first
48
+ - second
49
+ `;
50
+
51
+ it("uses a consumer-supplied `components` entry for one element type", () => {
52
+ render(
53
+ <MarkdownView
54
+ components={{
55
+ a: ({ href, children }) => (
56
+ <span data-testid="citation-chip" data-href={href}>
57
+ [{children}]
58
+ </span>
59
+ ),
60
+ }}
61
+ >
62
+ {CITATION_DOC}
63
+ </MarkdownView>,
64
+ );
65
+
66
+ // The overridden `a` renders through the consumer's component…
67
+ const chips = screen.getAllByTestId("citation-chip");
68
+ expect(chips).toHaveLength(2);
69
+ expect(chips[0]).toHaveAttribute("data-href", "https://example.com/report");
70
+ // …and NOT through the internal ProseLink (no anchor role at all now).
71
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
72
+ });
73
+
74
+ it("keeps the internal prose styling for element types the consumer did NOT override", () => {
75
+ // `baseHeadingLevel` is what makes this discriminating: a plain object
76
+ // merge/replace of `components` still satisfies role=heading (Streamdown's
77
+ // OWN default `h1` is still a real <h1>), but ONLY buildProseComponents()
78
+ // knows about baseHeadingLevel's h1→h2 remap. If MarkdownView's merge drops
79
+ // the internal map for keys the consumer didn't set, this regresses to h1.
80
+ render(
81
+ <MarkdownView
82
+ baseHeadingLevel={2}
83
+ components={{
84
+ a: ({ children }) => <span data-testid="citation-chip">{children}</span>,
85
+ }}
86
+ >
87
+ {CITATION_DOC}
88
+ </MarkdownView>,
89
+ );
90
+
91
+ expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
92
+ expect(screen.getByRole("heading", { level: 2, name: "Board note" })).toBeInTheDocument();
93
+ expect(screen.getAllByRole("listitem")).toHaveLength(2);
94
+ });
95
+
96
+ it("merges a real `plugins.cjk` override in (append), keeps sanitisation on, and keeps the untouched `plugins.math` default alive (#10)", () => {
97
+ // A real, discriminating lock — NOT `plugins={{}}` (that exercises zero
98
+ // slots and passes identically under merge, replace, or a no-op; #10
99
+ // review I3). This test supplies a genuine `cjk` plugin (one of the two
100
+ // slots MarkdownView actually reaches — `code`/`mermaid`/`renderers` are
101
+ // consulted only inside Streamdown's OWN default `code` renderer, which
102
+ // `buildProseComponents()` always shadows) and proves BOTH halves of the
103
+ // merge property:
104
+ // 1. Streamdown's default rehypePlugins (raw → sanitize → harden)
105
+ // still hold — a <script> is stripped.
106
+ // 2. The supplied `cjk` plugin APPENDS (its remark transformer runs)
107
+ // *and* the internal `math` default the consumer did NOT set
108
+ // SURVIVES alongside it (`$$x^2$$` renders as real KaTeX, not
109
+ // literal text). A REPLACE implementation
110
+ // (`plugins = pluginOverrides`) would drop `math` — along with
111
+ // `code`/`mermaid` — the moment a consumer sets `cjk`, and this
112
+ // assertion would fail.
113
+ const cjkRemarkSpy = vi.fn(() => (tree: unknown) => tree);
114
+ const customCjkPlugin: NonNullable<
115
+ NonNullable<ComponentProps<typeof MarkdownView>["plugins"]>["cjk"]
116
+ > = {
117
+ name: "cjk",
118
+ remarkPlugins: [],
119
+ remarkPluginsAfter: [cjkRemarkSpy],
120
+ remarkPluginsBefore: [],
121
+ type: "cjk",
122
+ };
123
+ const UNSAFE_DOC = `# Note
124
+
125
+ <script>window.__pwned = true;</script>
126
+
127
+ $$x^2$$
128
+ `;
129
+ render(<MarkdownView plugins={{ cjk: customCjkPlugin }}>{UNSAFE_DOC}</MarkdownView>);
130
+
131
+ // (1) sanitisation still holds.
132
+ expect(document.querySelector("script")).not.toBeInTheDocument();
133
+ expect(document.body.textContent).not.toMatch(/pwned/);
134
+ // (2a) the supplied `cjk` plugin appended (its transformer ran)…
135
+ expect(cjkRemarkSpy).toHaveBeenCalled();
136
+ // (2b) …and the internal `math` default the consumer did not set is
137
+ // still active — real KaTeX markup, not the literal `$$x^2$$` text.
138
+ expect(document.querySelector(".katex")).toBeInTheDocument();
139
+ });
140
+ });
141
+
142
+ describe("MarkdownView sanitiser is not overridable (#36)", () => {
143
+ it("ignores a caller-supplied rehypePlugins array (the sanitiser is not overridable)", () => {
144
+ // A rehype plugin that appends a <script> AFTER the pipeline. Under the bug the
145
+ // consumer array REPLACES [rehypeRaw, rehypeSanitize, harden], so nothing strips it.
146
+ const injectScript = () => (tree: { children: unknown[] }) => {
147
+ tree.children.push({
148
+ type: "element",
149
+ tagName: "script",
150
+ properties: {},
151
+ children: [{ type: "text", value: "globalThis.__pwned = true" }],
152
+ });
153
+ };
154
+ const { container } = render(
155
+ // `as any`: a JS consumer, an `any`, or a wider spread object still reaches
156
+ // this path even after the type-level `Omit`, so the assertion must exercise
157
+ // the RUNTIME strip, not the type. (`as never` doesn't typecheck as a JSX
158
+ // spread — TS2698, "Spread types may only be created from object types" —
159
+ // `any` is the cast the issue itself names as the bypass vector.)
160
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- deliberate: proves the runtime strip, not the type
161
+ <MarkdownView {...({ rehypePlugins: [injectScript] } as any)}>{"# hi"}</MarkdownView>,
162
+ );
163
+ expect(container.querySelector("script")).toBeNull();
164
+ });
165
+
166
+ it("SUPPORTS a caller-supplied remarkPlugins array, and still sanitises what it injects", () => {
167
+ // PR #74 review, round 1: `remarkPlugins` is NOT a sanitiser override. It runs
168
+ // upstream of [rehypeRaw, rehypeSanitize, harden], which Streamdown derives
169
+ // without reading it — so the prop stays supported (no `as any` below: the TYPE
170
+ // must accept it) while its output is still sanitised. Both halves are asserted,
171
+ // so re-adding it to the runtime strip fails, and dropping the rehype chain fails.
172
+ const ran = vi.fn();
173
+ const injectHostile = () => (tree: { children: unknown[] }) => {
174
+ ran();
175
+ tree.children.push({
176
+ type: "html",
177
+ value: '<script>globalThis.__pwned = true</script><img src="x" onerror="void 0">',
178
+ });
179
+ tree.children.push({
180
+ type: "paragraph",
181
+ data: { hName: "script", hChildren: [{ type: "text", value: "globalThis.__x = 1" }] },
182
+ children: [],
183
+ });
184
+ };
185
+ const { container } = render(
186
+ <MarkdownView remarkPlugins={[injectHostile]}>{"# hi"}</MarkdownView>,
187
+ );
188
+
189
+ expect(ran).toHaveBeenCalled(); // the prop really reached Streamdown
190
+ expect(container.querySelector("script")).toBeNull();
191
+ expect(container.querySelector("[onerror]")).toBeNull();
192
+ expect(document.body.textContent).not.toMatch(/pwned/);
193
+ });
194
+ });
195
+
196
+ describe("MarkdownView trusted plugin slots run after the sanitiser (#76)", () => {
197
+ // `plugins.math.rehypePlugin` is appended to the END of Streamdown's rehype
198
+ // pipeline, i.e. AFTER `rehype-raw` → `rehype-sanitize` → `rehype-harden`, so
199
+ // whatever it emits is never re-sanitised. That is a deliberate, documented
200
+ // trusted-code seam (a consumer who can supply an executable `Pluggable` can
201
+ // already run code in their own bundle) — but until #76 it had no runtime
202
+ // half at all, unlike `rehypePlugins`. These three tests are the runtime half:
203
+ // it WARNS (1), it does not become noise (2), and it stays OPEN (3).
204
+ const injectScript = () => (tree: { children: unknown[] }) => {
205
+ tree.children.push({
206
+ type: "element",
207
+ tagName: "script",
208
+ properties: {},
209
+ children: [{ type: "text", value: "globalThis.__pwned76 = true" }],
210
+ });
211
+ };
212
+ const evilMath = { ...math, rehypePlugin: injectScript };
213
+
214
+ it("warns when a consumer replaces plugins.math.rehypePlugin", () => {
215
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
216
+ try {
217
+ render(<MarkdownView plugins={{ math: evilMath }}>{"# hi"}</MarkdownView>);
218
+ const messages = warn.mock.calls.flat().join("\n");
219
+ expect(messages).toMatch(/math\.rehypePlugin/);
220
+ expect(messages).toMatch(/after/i);
221
+ } finally {
222
+ warn.mockRestore();
223
+ }
224
+ });
225
+
226
+ it("does not warn for the default plugin set or a safe slot override", () => {
227
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
228
+ try {
229
+ render(<MarkdownView>{"# hi"}</MarkdownView>);
230
+ cleanup();
231
+ // A `cjk` override is remark-stage only — its output is re-sanitised
232
+ // downstream, so it is NOT a trust boundary and must stay silent. This is
233
+ // the arm that stops the warning degrading into noise consumers learn to
234
+ // ignore.
235
+ const customCjkPlugin: NonNullable<
236
+ NonNullable<ComponentProps<typeof MarkdownView>["plugins"]>["cjk"]
237
+ > = {
238
+ name: "cjk",
239
+ remarkPlugins: [],
240
+ remarkPluginsAfter: [],
241
+ remarkPluginsBefore: [],
242
+ type: "cjk",
243
+ };
244
+ render(<MarkdownView plugins={{ cjk: customCjkPlugin }}>{"# hi"}</MarkdownView>);
245
+ expect(warn).not.toHaveBeenCalled();
246
+ } finally {
247
+ warn.mockRestore();
248
+ }
249
+ });
250
+
251
+ it("documents the boundary rather than closing it (the slot stays open)", () => {
252
+ // Counter-intuitive but load-bearing: this pins the deliberate trusted-code
253
+ // escape hatch OPEN. A future "hardening" that strips the slot would break a
254
+ // legitimate consumer (a real KaTeX/math plugin) silently — this fails loudly
255
+ // instead. The defence is the warning above plus the documented boundary,
256
+ // NOT removing the capability.
257
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
258
+ try {
259
+ const { container } = render(
260
+ <MarkdownView plugins={{ math: evilMath }}>{"# hi"}</MarkdownView>,
261
+ );
262
+ expect(container.querySelector("script")).not.toBeNull();
263
+ } finally {
264
+ warn.mockRestore();
265
+ }
266
+ });
267
+ });
@@ -7,8 +7,28 @@
7
7
  * was the ASSET-2 defect: `CodeBlock code={…} language="markdown"`). Built on
8
8
  * the EXISTING `streamdown` dependency (no new heavy dep), but unlike the
9
9
  * streaming `MessageResponse` it maps the element tree onto the promoted
10
- * `Prose*` primitives from `@elabs-ai/components-ui` via a `components` map — one
11
- * source-owned prose set for chat answers, the editor preview and this view.
10
+ * `Prose*` primitives from `@elabs-ai/components-ui` via a `components` map.
11
+ *
12
+ * What is SHARED is the `Prose*` set — plus the Streamdown locale bridge in
13
+ * `_streamdown-i18n` and `ui`'s `streamdown-translations` — NOT the renderer and
14
+ * NOT the element map. The wording this docblock used to carry ("one source-owned
15
+ * prose set for chat answers, the editor preview and this view") kept being read
16
+ * as "one renderer", and it never meant that. Three branded document renderers
17
+ * ship, and their element MAPS stay per-surface on purpose:
18
+ * - this view — `baseHeadingLevel` for a constrained rung, and a sanitiser
19
+ * chain locked at the type level AND at runtime;
20
+ * - `@elabs-ai/components-editor`'s `MarkdownPreview` — the brand `:::card` /
21
+ * `:::callout` / `::metric` / `:::timeline` dialect, and every standard
22
+ * element wrapped to attach `data-sourcepos`, the ghost-diff wash and the
23
+ * search wash, with slug ids and a heading-action slot on headings;
24
+ * - `@elabs-ai/components-viewer`'s markdown adapter — a file the app did not
25
+ * write, so no plugins and no streaming.
26
+ * `ai`, `editor` and `viewer` are layer-2 leaves that may not import one
27
+ * another, so the half that COULD move down already has; and `streamdown` must
28
+ * never follow it into `ui` — see the rule stated in
29
+ * `packages/ui/src/lib/streamdown-translations.ts` — or every consumer of every
30
+ * foundation component would carry it. Re-litigated and re-ratified 2026-09-03:
31
+ * the three maps are the decision, not an unpaid debt.
12
32
  *
13
33
  * `baseHeadingLevel` is the constrained-rung seam (research 04 §5 / 09 §G.2):
14
34
  * inside a narrow rail pass `baseHeadingLevel={2}` so a document `#` renders
@@ -26,12 +46,18 @@ import {
26
46
  type ProseHeadingLevel,
27
47
  } from "@elabs-ai/components-ui";
28
48
  import { cn } from "@elabs-ai/components-ui/lib/cn";
29
- import { useStreamdownPlugins, useStreamdownTranslations } from "./_streamdown-i18n";
49
+ import { stripSanitizerOverrides, warnOnTrustedPluginSlots } from "./_streamdown-safety";
50
+ import {
51
+ useStreamdownMermaidOptions,
52
+ useStreamdownPlugins,
53
+ useStreamdownTranslations,
54
+ } from "./_streamdown-i18n";
30
55
  import type { ComponentProps } from "react";
31
56
  import { useMemo } from "react";
32
57
  import { Streamdown } from "streamdown";
33
58
 
34
59
  type StreamdownComponents = NonNullable<ComponentProps<typeof Streamdown>["components"]>;
60
+ type StreamdownPlugins = ComponentProps<typeof Streamdown>["plugins"];
35
61
 
36
62
  const MAX_HEADING_LEVEL = 6;
37
63
 
@@ -75,7 +101,7 @@ function buildProseComponents(baseHeadingLevel: ProseHeadingLevel): StreamdownCo
75
101
 
76
102
  export interface MarkdownViewProps extends Omit<
77
103
  ComponentProps<typeof Streamdown>,
78
- "components" | "plugins"
104
+ "components" | "plugins" | "rehypePlugins"
79
105
  > {
80
106
  /**
81
107
  * The prose level a markdown `#` maps to; deeper headings shift with it
@@ -83,21 +109,138 @@ export interface MarkdownViewProps extends Omit<
83
109
  * embeds (a context rail) so headings stay on the constrained rung.
84
110
  */
85
111
  baseHeadingLevel?: ProseHeadingLevel;
112
+ /**
113
+ * Per-element renderer overrides, MERGED over the internal Prose* map
114
+ * (#10) — a consumer entry wins for its key; every key the consumer does
115
+ * NOT set keeps rendering through `buildProseComponents()`. This is the
116
+ * seam for rendering inline citations: override `a` (or a custom node
117
+ * type) to swap a `[1](url)`-style marker for an `InlineCitation` chip
118
+ * while headings/lists/etc. keep the branded prose styling.
119
+ *
120
+ * Deliberately a MERGE, not a replace: sanitisation is unaffected either
121
+ * way (see `plugins` below), but a wholesale replace would silently drop
122
+ * the branded styling from every element the consumer didn't think to
123
+ * re-declare — a citation override should not have to also re-implement
124
+ * headings/lists/code to keep them on-brand.
125
+ *
126
+ * **Known constraint: a `components` change alone does not force a
127
+ * re-render of already-rendered markdown (#10).** Streamdown memoizes on
128
+ * `children`/`plugins`/theme/etc but NOT on `components`
129
+ * (`streamdown@2.5.0`'s top-level `memo` comparator omits it), so if the
130
+ * SAME markdown string is still current when an override's closed-over
131
+ * data changes — e.g. an inline-citation chip that resolves its title
132
+ * asynchronously — the new render function is captured but nothing
133
+ * schedules Streamdown to call it again. Don't rely on a `components`
134
+ * closure to reflect state that arrives after the initial render; instead
135
+ * have the overriding component read that state itself (a shared context,
136
+ * a store keyed by citation id, or an internal `useState`/subscription
137
+ * inside the override) so IT re-renders independently of `MarkdownView`'s
138
+ * own render pass — the pattern `InlineCitation`/`InlineCitationCard`
139
+ * already use.
140
+ */
141
+ components?: StreamdownComponents;
142
+ /**
143
+ * Streamdown plugin-slot overrides (`cjk`/`code`/`math`/`mermaid`/
144
+ * `renderers`), MERGED per key over the internal defaults (the reactive,
145
+ * brand-token-derived `code` plugin and i18n-aware `cjk`/`math`/`mermaid`
146
+ * set — see `_streamdown-i18n.ts`).
147
+ *
148
+ * **This narrow `plugins` prop can only APPEND — it can never displace
149
+ * Streamdown's default `rehypePlugins` chain** (`rehype-raw` →
150
+ * `rehype-sanitize` → `rehype-harden`). `MarkdownView` never sets
151
+ * `rehypePlugins` itself, and nothing in `PluginConfig` removes or replaces
152
+ * a member of that pipeline, so the sanitiser cannot be turned off through
153
+ * this prop. But two of the five slots are **not** upstream of it and must
154
+ * be treated as trusted code:
155
+ * - **`math.rehypePlugin` runs AFTER `rehype-sanitize`/`rehype-harden`**
156
+ * (appended to the end of the rehype pipeline, verified against
157
+ * `streamdown@2.5.0`'s `dist/chunk-BO2N2NFS.js`) — its output is never
158
+ * re-sanitised.
159
+ * - **`mermaid` never enters the rehype/remark pipeline at all.** Its
160
+ * `getMermaid().render()` result is written via
161
+ * `dangerouslySetInnerHTML` (streamdown's only such sink). brand-ui's own
162
+ * `useStreamdownPlugins()` default pins `securityLevel: "strict"`
163
+ * (`_lazy-mermaid.ts`); a replacement `mermaid` plugin must sanitise its
164
+ * own SVG output the same way.
165
+ * - `cjk` (remark-stage only — its output is re-sanitised downstream by
166
+ * the rehype pipeline like any other remark result), `code` (feeds only
167
+ * `shikiTheme`, no HTML injection) and `renderers` (ordinary React
168
+ * components rendered with the fenced block's raw `code` string as a
169
+ * `string` prop, the same trusted-component boundary as a `components`
170
+ * override) do not bypass sanitisation.
171
+ *
172
+ * **`rehypePlugins` is NOT exposed on this component (#36, fixed).** Unlike
173
+ * `plugins`, a caller-supplied `rehypePlugins` array would REPLACE
174
+ * Streamdown's default rehype pipeline wholesale rather than extending it,
175
+ * silently dropping `rehype-raw`/`rehype-sanitize`/`rehype-harden` and
176
+ * letting a plain markdown string execute script in the host page — so
177
+ * `MarkdownViewProps` `Omit`s it at the type level AND
178
+ * `stripSanitizerOverrides()` deletes it at runtime before the `{...props}`
179
+ * spread below, even if a JS consumer or a cast reaches this component with
180
+ * it set. If you need to widen sanitisation, use
181
+ * `allowedTags`/`literalTagContent`, which MERGE into the sanitize schema
182
+ * instead of replacing the pipeline. See
183
+ * `packages/ai/src/_streamdown-safety.ts`.
184
+ *
185
+ * **`remarkPlugins` IS supported** (PR #74 review, round 1 — the original
186
+ * #36 fix over-reached and removed it too). The remark stage runs strictly
187
+ * upstream of the rehype chain, and Streamdown builds its rehype list
188
+ * without reading `remarkPlugins`, so everything a remark plugin emits is
189
+ * still sanitised downstream — measured across raw-`html` mdast nodes,
190
+ * `data.hName`/`hChildren` hast elements, `hProperties` event handlers,
191
+ * `javascript:` link URLs and smuggled `raw` hast children. Note the
192
+ * ordinary (non-security) footgun: your array REPLACES Streamdown's own
193
+ * remark defaults (`remark-gfm`, `codeMeta`), so spread
194
+ * `Object.values(defaultRemarkPlugins)` from `streamdown` back in if you
195
+ * want to keep GFM tables/strikethrough.
196
+ */
197
+ plugins?: StreamdownPlugins;
86
198
  }
87
199
 
88
200
  export const MarkdownView = ({
89
201
  baseHeadingLevel = 1,
90
202
  className,
91
203
  children,
204
+ components: componentOverrides,
205
+ plugins: pluginOverrides,
92
206
  ...props
93
207
  }: MarkdownViewProps) => {
94
- const components = useMemo(() => buildProseComponents(baseHeadingLevel), [baseHeadingLevel]);
208
+ // Streamdown installs [rehypeRaw, rehypeSanitize, harden] as the DEFAULT VALUE of
209
+ // `rehypePlugins`; a supplied array REPLACES it. This component renders untrusted
210
+ // model output, so the chain is not overridable. Widen with `allowedTags` /
211
+ // `literalTagContent`, which merge into the sanitize schema. See issue #36.
212
+ // `remarkPlugins` is deliberately left alone — it runs upstream of the rehype
213
+ // chain and cannot bypass it (PR #74 review; see `_streamdown-safety.ts`).
214
+ stripSanitizerOverrides(props);
215
+ const internalComponents = useMemo(
216
+ () => buildProseComponents(baseHeadingLevel),
217
+ [baseHeadingLevel],
218
+ );
219
+ // Consumer entries win per key; every other key falls through to the
220
+ // internal Prose* map — see the `components` prop doc above.
221
+ const components = useMemo(
222
+ () => ({ ...internalComponents, ...componentOverrides }),
223
+ [internalComponents, componentOverrides],
224
+ );
95
225
  // Streamdown's own chrome (code copy, table menus) reads the locale seam (#310).
96
226
  // Spread AFTER so an explicit `translations` prop still wins (ADR 0017).
97
227
  const translations = useStreamdownTranslations();
98
228
  // Brand-token-derived `code` plugin, not the package's static github-*
99
229
  // default (#315 follow-up) — re-derives when the active theme changes.
100
- const plugins = useStreamdownPlugins();
230
+ const internalPlugins = useStreamdownPlugins();
231
+ // Same per-key merge as `components` — see the `plugins` prop doc above.
232
+ // `math.rehypePlugin`/`mermaid` are the two slots that land in the DOM after
233
+ // (or outside) the sanitiser chain, so replacing one is a TRUSTED-CODE
234
+ // decision. The runtime half of that documented boundary is a dev warning —
235
+ // the plugin still runs; stripping it would break the legitimate use (#76).
236
+ const plugins = useMemo(() => {
237
+ warnOnTrustedPluginSlots(pluginOverrides, internalPlugins);
238
+ return { ...internalPlugins, ...pluginOverrides };
239
+ }, [internalPlugins, pluginOverrides]);
240
+ // The TOP-LEVEL Streamdown `mermaid` prop (distinct from `plugins.mermaid`
241
+ // above) — catches a failed diagram render, including a missing `mermaid`
242
+ // optional peer (issue #33). See `_streamdown-i18n.ts`.
243
+ const mermaidOptions = useStreamdownMermaidOptions();
101
244
  return (
102
245
  <Streamdown
103
246
  data-slot="markdown-view"
@@ -105,6 +248,7 @@ export const MarkdownView = ({
105
248
  components={components}
106
249
  plugins={plugins}
107
250
  translations={translations}
251
+ mermaid={mermaidOptions}
108
252
  {...props}
109
253
  >
110
254
  {children}