@elabs-ai/components-ai 4.0.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 (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,409 @@
1
+ "use client";
2
+
3
+ import { Skeleton } from "@elabs-ai/components-ui";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import { AlertCircle } from "lucide-react";
6
+ import type { ComponentProps, ReactNode } from "react";
7
+ import {
8
+ createContext,
9
+ memo,
10
+ useCallback,
11
+ useContext,
12
+ useEffect,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from "react";
17
+ import type { TProps as JsxParserProps } from "react-jsx-parser";
18
+ import JsxParser from "react-jsx-parser";
19
+
20
+ /**
21
+ * The readiness of the preview, derived from the input (see loading-states.md):
22
+ * - `idle` — nothing requested yet (empty input, never rendered). Renders nothing.
23
+ * - `pending` — not-ready: `loading`, `isStreaming`, OR syntactically incomplete
24
+ * input (a half-arrived tag). Builds up best-effort; the error slot
25
+ * is suppressed; the skeleton shows until the first successful paint.
26
+ * - `ready` — settled, parseable input is rendering.
27
+ * - `error` — settled AND genuinely invalid input (the only state that surfaces
28
+ * `JSXPreviewError`).
29
+ */
30
+ export type JSXPreviewStatus = "idle" | "pending" | "ready" | "error";
31
+
32
+ interface JSXPreviewContextValue {
33
+ jsx: string;
34
+ processedJsx: string;
35
+ isStreaming: boolean;
36
+ loading: boolean;
37
+ status: JSXPreviewStatus;
38
+ hasRendered: boolean;
39
+ error: Error | null;
40
+ setError: (error: Error | null) => void;
41
+ setHasRendered: (value: boolean) => void;
42
+ components: JsxParserProps["components"];
43
+ bindings: JsxParserProps["bindings"];
44
+ onErrorProp?: (error: Error) => void;
45
+ }
46
+
47
+ const JSXPreviewContext = createContext<JSXPreviewContextValue | null>(null);
48
+
49
+ const TAG_REGEX = /<\/?([a-zA-Z][a-zA-Z0-9]*)\s*([^>]*?)(\/)?>/;
50
+
51
+ export const useJSXPreview = () => {
52
+ const context = useContext(JSXPreviewContext);
53
+ if (!context) {
54
+ throw new Error("JSXPreview components must be used within JSXPreview");
55
+ }
56
+ return context;
57
+ };
58
+
59
+ const matchJsxTag = (code: string) => {
60
+ if (code.trim() === "") {
61
+ return null;
62
+ }
63
+
64
+ const match = code.match(TAG_REGEX);
65
+
66
+ if (!match || match.index === undefined) {
67
+ return null;
68
+ }
69
+
70
+ const [fullMatch = "", tagName = "", attributes = "", selfClosing] = match;
71
+
72
+ let type: "self-closing" | "closing" | "opening";
73
+ if (selfClosing) {
74
+ type = "self-closing";
75
+ } else if (fullMatch.startsWith("</")) {
76
+ type = "closing";
77
+ } else {
78
+ type = "opening";
79
+ }
80
+
81
+ return {
82
+ attributes: attributes.trim(),
83
+ endIndex: match.index + fullMatch.length,
84
+ startIndex: match.index,
85
+ tag: fullMatch,
86
+ tagName,
87
+ type,
88
+ };
89
+ };
90
+
91
+ const stripIncompleteTag = (text: string) => {
92
+ // Find the last '<' that isn't part of a complete tag
93
+ const lastOpen = text.lastIndexOf("<");
94
+ if (lastOpen === -1) {
95
+ return text;
96
+ }
97
+
98
+ const afterOpen = text.slice(lastOpen);
99
+ // If there's no closing '>' after the last '<', it's an incomplete tag
100
+ if (!afterOpen.includes(">")) {
101
+ return text.slice(0, lastOpen);
102
+ }
103
+
104
+ return text;
105
+ };
106
+
107
+ const completeJsxTag = (code: string) => {
108
+ const stack: string[] = [];
109
+ let result = "";
110
+ let currentPosition = 0;
111
+
112
+ while (currentPosition < code.length) {
113
+ const match = matchJsxTag(code.slice(currentPosition));
114
+ if (!match) {
115
+ // No more tags found, strip any trailing incomplete tag
116
+ result += stripIncompleteTag(code.slice(currentPosition));
117
+ break;
118
+ }
119
+ const { tagName, type, endIndex } = match;
120
+
121
+ // Include any text content before this tag
122
+ result += code.slice(currentPosition, currentPosition + endIndex);
123
+
124
+ if (type === "opening") {
125
+ stack.push(tagName);
126
+ } else if (type === "closing") {
127
+ stack.pop();
128
+ }
129
+
130
+ currentPosition += endIndex;
131
+ }
132
+
133
+ return (
134
+ result +
135
+ [...stack]
136
+ .reverse()
137
+ .map((tag) => `</${tag}>`)
138
+ .join("")
139
+ );
140
+ };
141
+
142
+ export type JSXPreviewProps = ComponentProps<"div"> & {
143
+ jsx: string;
144
+ /** Content is arriving incrementally — build up, suppress transient errors. */
145
+ isStreaming?: boolean;
146
+ /**
147
+ * No renderable content yet (fetch-then-show). Forces the `pending` state so
148
+ * `JSXPreviewSkeleton` shows until `jsx` arrives. Orthogonal to `isStreaming`.
149
+ */
150
+ loading?: boolean;
151
+ components?: JsxParserProps["components"];
152
+ bindings?: JsxParserProps["bindings"];
153
+ onError?: (error: Error) => void;
154
+ };
155
+
156
+ export const JSXPreview = memo(
157
+ ({
158
+ jsx,
159
+ isStreaming = false,
160
+ loading = false,
161
+ components,
162
+ bindings,
163
+ onError,
164
+ className,
165
+ children,
166
+ ...props
167
+ }: JSXPreviewProps) => {
168
+ const [prevJsx, setPrevJsx] = useState(jsx);
169
+ const [error, setError] = useState<Error | null>(null);
170
+ const [hasRendered, setHasRendered] = useState(false);
171
+
172
+ // Clear the terminal error when jsx changes (derived state pattern)
173
+ if (jsx !== prevJsx) {
174
+ setPrevJsx(jsx);
175
+ setError(null);
176
+ }
177
+
178
+ // The tag-balanced best-effort of the current input. When it differs from the
179
+ // raw jsx, the input ends mid-tag → it is INCOMPLETE (still streaming / being
180
+ // typed), not invalid. This is the timer-free "not-ready" signal.
181
+ const completed = useMemo(() => completeJsxTag(jsx), [jsx]);
182
+ const isIncomplete = jsx.trim() !== "" && completed !== jsx;
183
+
184
+ const processedJsx = useMemo(() => {
185
+ if (loading) return "";
186
+ return isStreaming || isIncomplete ? completed : jsx;
187
+ }, [jsx, isStreaming, loading, isIncomplete, completed]);
188
+
189
+ const status = useMemo<JSXPreviewStatus>(() => {
190
+ if (loading) return "pending";
191
+ if (jsx.trim() === "") return hasRendered ? "ready" : "idle";
192
+ if (isStreaming || isIncomplete) return "pending";
193
+ return error != null ? "error" : "ready";
194
+ }, [jsx, isStreaming, loading, isIncomplete, error, hasRendered]);
195
+
196
+ const contextValue = useMemo(
197
+ () => ({
198
+ bindings,
199
+ components,
200
+ error,
201
+ hasRendered,
202
+ isStreaming,
203
+ jsx,
204
+ loading,
205
+ onErrorProp: onError,
206
+ processedJsx,
207
+ setError,
208
+ setHasRendered,
209
+ status,
210
+ }),
211
+ [
212
+ bindings,
213
+ components,
214
+ error,
215
+ hasRendered,
216
+ isStreaming,
217
+ jsx,
218
+ loading,
219
+ onError,
220
+ processedJsx,
221
+ status,
222
+ ],
223
+ );
224
+
225
+ return (
226
+ <JSXPreviewContext.Provider value={contextValue}>
227
+ <div className={cn("relative", className)} {...props}>
228
+ {children}
229
+ </div>
230
+ </JSXPreviewContext.Provider>
231
+ );
232
+ },
233
+ );
234
+
235
+ JSXPreview.displayName = "JSXPreview";
236
+
237
+ export type JSXPreviewContentProps = Omit<ComponentProps<"div">, "children">;
238
+
239
+ export const JSXPreviewContent = memo(({ className, ...props }: JSXPreviewContentProps) => {
240
+ const { processedJsx, status, components, bindings, setError, setHasRendered, onErrorProp } =
241
+ useJSXPreview();
242
+ const lastGoodJsxRef = useRef("");
243
+ // `erroredJsx` is committed in an effect (never during render). The parser
244
+ // reports errors synchronously DURING its own render, so `handleError` only
245
+ // stashes a ref — committing state there would be a setState-in-render.
246
+ const [erroredJsx, setErroredJsx] = useState<string | null>(null);
247
+ const pendingErrorRef = useRef<{ jsx: string; error: Error } | null>(null);
248
+
249
+ // Reset the per-input error flag synchronously when the content changes.
250
+ const prevProcessedRef = useRef(processedJsx);
251
+ if (prevProcessedRef.current !== processedJsx) {
252
+ prevProcessedRef.current = processedJsx;
253
+ if (erroredJsx !== null) setErroredJsx(null);
254
+ }
255
+ const hadError = erroredJsx === processedJsx;
256
+
257
+ const handleError = useCallback(
258
+ (error: Error) => {
259
+ pendingErrorRef.current = { error, jsx: processedJsx };
260
+ },
261
+ [processedJsx],
262
+ );
263
+
264
+ // After commit, reconcile the parse outcome. A fresh error becomes terminal
265
+ // (surfaces `JSXPreviewError`) ONLY when settled — for a `pending` input it is
266
+ // held silently (build-up keeps going behind the last good render). A clean
267
+ // render records the last-good JSX and marks the first paint.
268
+ useEffect(() => {
269
+ const failed = pendingErrorRef.current?.jsx === processedJsx;
270
+ if (failed) {
271
+ const { error } = pendingErrorRef.current!;
272
+ pendingErrorRef.current = null;
273
+ if (erroredJsx !== processedJsx) setErroredJsx(processedJsx);
274
+ if (status !== "pending") {
275
+ setError(error);
276
+ onErrorProp?.(error);
277
+ }
278
+ } else if (erroredJsx !== processedJsx && processedJsx.trim() !== "") {
279
+ lastGoodJsxRef.current = processedJsx;
280
+ setHasRendered(true);
281
+ }
282
+ }, [processedJsx, status, erroredJsx, setError, setHasRendered, onErrorProp]);
283
+
284
+ // The error slot and the skeleton own the "error" / nothing-yet states.
285
+ if (status !== "ready" && status !== "pending") {
286
+ return null;
287
+ }
288
+
289
+ // While pending, a failed parse falls back to the last good render (which may
290
+ // be empty on first paint — the skeleton covers that).
291
+ const displayJsx = hadError ? lastGoodJsxRef.current : processedJsx;
292
+
293
+ return (
294
+ <div className={cn("jsx-preview-content", className)} {...props}>
295
+ <JsxParser
296
+ bindings={bindings}
297
+ components={components}
298
+ jsx={displayJsx}
299
+ onError={handleError}
300
+ renderInWrapper={false}
301
+ />
302
+ </div>
303
+ );
304
+ });
305
+
306
+ JSXPreviewContent.displayName = "JSXPreviewContent";
307
+
308
+ export type JSXPreviewSkeletonProps = ComponentProps<"div">;
309
+
310
+ /**
311
+ * The not-ready placeholder (loading-states.md §slot anatomy). Renders a
312
+ * layout-shaped `Skeleton` while the preview is `pending` and nothing has
313
+ * painted yet — so a streaming/incomplete surface BUILDS UP behind a skeleton
314
+ * instead of flashing the error box. Pass `children` to override the shape.
315
+ */
316
+ export const JSXPreviewSkeleton = memo(
317
+ ({ className, children, ...props }: JSXPreviewSkeletonProps) => {
318
+ const { status, hasRendered } = useJSXPreview();
319
+
320
+ // Show only before the first successful paint; once content builds up, the
321
+ // partial render is its own affordance (no skeleton-over-content).
322
+ const show = !hasRendered && status === "pending";
323
+
324
+ // The live region must exist in the DOM BEFORE its text appears, or NVDA
325
+ // skips the announcement (ARIA22). So the wrapper is ALWAYS rendered (it
326
+ // collapses to `sr-only` when idle) and the label is set one tick later via
327
+ // an effect — guaranteeing an empty region precedes the "Loading…" text.
328
+ const [announce, setAnnounce] = useState(false);
329
+ useEffect(() => setAnnounce(show), [show]);
330
+
331
+ return (
332
+ <div
333
+ className={cn(show ? "space-y-2" : "sr-only", className)}
334
+ role="status"
335
+ aria-live="polite"
336
+ {...props}
337
+ >
338
+ <span className="sr-only">{announce ? "Loading preview…" : ""}</span>
339
+ {show
340
+ ? (children ?? (
341
+ <>
342
+ <Skeleton className="h-20 w-full" />
343
+ <div className="grid grid-cols-3 gap-2">
344
+ <Skeleton className="h-16" />
345
+ <Skeleton className="h-16" />
346
+ <Skeleton className="h-16" />
347
+ </div>
348
+ </>
349
+ ))
350
+ : null}
351
+ </div>
352
+ );
353
+ },
354
+ );
355
+
356
+ JSXPreviewSkeleton.displayName = "JSXPreviewSkeleton";
357
+
358
+ export type JSXPreviewErrorProps = ComponentProps<"div"> & {
359
+ children?: ReactNode | ((error: Error) => ReactNode);
360
+ };
361
+
362
+ const renderChildren = (
363
+ children: ReactNode | ((error: Error) => ReactNode),
364
+ error: Error,
365
+ ): ReactNode => {
366
+ if (typeof children === "function") {
367
+ return children(error);
368
+ }
369
+ return children;
370
+ };
371
+
372
+ export const JSXPreviewError = memo(({ className, children, ...props }: JSXPreviewErrorProps) => {
373
+ const { status, error } = useJSXPreview();
374
+
375
+ // Terminal failures only — never while loading / streaming / incomplete.
376
+ if (status !== "error" || !error) {
377
+ return null;
378
+ }
379
+
380
+ return (
381
+ <div
382
+ // The status wash is the separation gesture (#194, research 08 §B.1):
383
+ // `bg-destructive/10` + the destructive ink/icon already mark the region,
384
+ // and the alpha-wash survives high decoration (token-inequality escape from the
385
+ // drawn-not-filled rule), so the old `border border-destructive/50` was
386
+ // redundant on every theme — dropped. Text + icon use `text-destructive-text`
387
+ // (the on-surface destructive ink ramp) — raw `text-destructive` fails AA on
388
+ // the wash (3.86:1) and goes near-white in dark; the *-text token is
389
+ // AA-guaranteed and keeps the destructive cue in every theme.
390
+ className={cn(
391
+ "flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-body text-destructive-text",
392
+ className,
393
+ )}
394
+ role="alert"
395
+ {...props}
396
+ >
397
+ {children ? (
398
+ renderChildren(children, error)
399
+ ) : (
400
+ <>
401
+ <AlertCircle className="size-4 shrink-0" />
402
+ <span>{error.message}</span>
403
+ </>
404
+ )}
405
+ </div>
406
+ );
407
+ });
408
+
409
+ JSXPreviewError.displayName = "JSXPreviewError";
@@ -0,0 +1,57 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { MarkdownView } from "./markdown-view";
3
+
4
+ const meta = {
5
+ title: "AI/MarkdownView",
6
+ component: MarkdownView,
7
+ tags: ["autodocs"],
8
+ parameters: { layout: "padded" },
9
+ } satisfies Meta<typeof MarkdownView>;
10
+ export default meta;
11
+ type Story = StoryObj<typeof meta>;
12
+
13
+ const DOC = `# Quarterly summary
14
+
15
+ Revenue grew **12.4% QoQ**. See the [variance check](https://example.com) for details.
16
+
17
+ ## Regional highlights
18
+
19
+ - EMEA $18.4M (+14%)
20
+ - Americas $21.2M (+8%)
21
+ - APAC $8.6M — watch churn
22
+
23
+ > Flagged by the automated variance check.
24
+
25
+ Inline \`code\` stays inline; fenced blocks go through Shiki:
26
+
27
+ \`\`\`sql
28
+ SELECT region, total FROM finance.revenue;
29
+ \`\`\`
30
+ `;
31
+
32
+ /** The full reading scale — markdown mapped onto the Prose* primitives. */
33
+ export const Default: Story = {
34
+ args: { children: DOC },
35
+ decorators: [
36
+ (Story) => (
37
+ <div className="max-w-prose">
38
+ <Story />
39
+ </div>
40
+ ),
41
+ ],
42
+ };
43
+
44
+ /**
45
+ * The constrained rung for narrow embeds (research 09 §G.2): `baseHeadingLevel={2}`
46
+ * maps a document `#` to the title rung — never "biggest text on screen" in a rail.
47
+ */
48
+ export const ConstrainedHeadings: Story = {
49
+ args: { children: DOC, baseHeadingLevel: 2 },
50
+ decorators: [
51
+ (Story) => (
52
+ <div className="max-w-80">
53
+ <Story />
54
+ </div>
55
+ ),
56
+ ],
57
+ };
@@ -0,0 +1,36 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { MarkdownView } from "./markdown-view";
4
+
5
+ afterEach(cleanup);
6
+
7
+ const DOC = `# Board note
8
+
9
+ A paragraph with [a link](https://example.com) and \`inline\` code.
10
+
11
+ - first
12
+ - second
13
+ `;
14
+
15
+ describe("MarkdownView (#193, research 04 §5 — document, not Shiki source)", () => {
16
+ it("renders markdown onto the Prose primitives (document semantics)", () => {
17
+ render(<MarkdownView>{DOC}</MarkdownView>);
18
+
19
+ // Headings are real headings (not mono source text).
20
+ expect(screen.getByRole("heading", { level: 1, name: "Board note" })).toBeInTheDocument();
21
+ // Links map onto ProseLink (external → safe rel + new tab).
22
+ const link = screen.getByRole("link", { name: "a link" });
23
+ expect(link).toHaveAttribute("target", "_blank");
24
+ expect(link).toHaveAttribute("rel", expect.stringContaining("noopener"));
25
+ // Lists are real lists.
26
+ expect(screen.getAllByRole("listitem")).toHaveLength(2);
27
+ });
28
+
29
+ it("caps headings at the constrained rung via baseHeadingLevel (research 09 §G.2)", () => {
30
+ render(<MarkdownView baseHeadingLevel={2}>{DOC}</MarkdownView>);
31
+ // The document `#` renders as an h2 (the `title` rung), never the
32
+ // reading-scale h1 — no "biggest text on screen" inside a 20rem rail.
33
+ expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
34
+ expect(screen.getByRole("heading", { level: 2, name: "Board note" })).toBeInTheDocument();
35
+ });
36
+ });
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MarkdownView — the branded READ-ONLY markdown renderer (#193, research 04 §5).
5
+ *
6
+ * Renders a markdown *document* as a document — never as Shiki source (that
7
+ * was the ASSET-2 defect: `CodeBlock code={…} language="markdown"`). Built on
8
+ * the EXISTING `streamdown` dependency (no new heavy dep), but unlike the
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.
12
+ *
13
+ * `baseHeadingLevel` is the constrained-rung seam (research 04 §5 / 09 §G.2):
14
+ * inside a narrow rail pass `baseHeadingLevel={2}` so a document `#` renders
15
+ * at the `title` rung, not the reading-scale h1 — never "biggest text on
16
+ * screen" inside a 20rem panel.
17
+ */
18
+ import {
19
+ ProseBlockquote,
20
+ ProseHeading,
21
+ ProseInlineCode,
22
+ ProseLink,
23
+ ProseList,
24
+ ProseListItem,
25
+ ProseText,
26
+ type ProseHeadingLevel,
27
+ } from "@elabs-ai/components-ui";
28
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
29
+ import { useStreamdownPlugins, useStreamdownTranslations } from "./_streamdown-i18n";
30
+ import type { ComponentProps } from "react";
31
+ import { useMemo } from "react";
32
+ import { Streamdown } from "streamdown";
33
+
34
+ type StreamdownComponents = NonNullable<ComponentProps<typeof Streamdown>["components"]>;
35
+
36
+ const MAX_HEADING_LEVEL = 6;
37
+
38
+ const clampHeadingLevel = (level: number): ProseHeadingLevel =>
39
+ Math.min(MAX_HEADING_LEVEL, Math.max(1, level)) as ProseHeadingLevel;
40
+
41
+ /**
42
+ * Map the markdown element tree onto the Prose* primitives. Fenced code blocks
43
+ * (className `language-*`) stay with the streamdown `code` plugin (Shiki);
44
+ * only INLINE code re-maps to `ProseInlineCode`.
45
+ */
46
+ function buildProseComponents(baseHeadingLevel: ProseHeadingLevel): StreamdownComponents {
47
+ const heading =
48
+ (markdownLevel: number): StreamdownComponents["h1"] =>
49
+ ({ node: _node, ...props }) => (
50
+ <ProseHeading level={clampHeadingLevel(markdownLevel + baseHeadingLevel - 1)} {...props} />
51
+ );
52
+ return {
53
+ h1: heading(1),
54
+ h2: heading(2),
55
+ h3: heading(3),
56
+ h4: heading(4),
57
+ h5: heading(5),
58
+ h6: heading(6),
59
+ p: ({ node: _node, ...props }) => <ProseText {...props} />,
60
+ a: ({ node: _node, ...props }) => <ProseLink {...props} />,
61
+ // `ref` is stripped: ProseList's intersection ref type (ul & ol) is
62
+ // narrower than the per-element ref react-markdown passes.
63
+ ul: ({ node: _node, ref: _ref, ...props }) => <ProseList {...props} />,
64
+ ol: ({ node: _node, ref: _ref, ...props }) => <ProseList ordered {...props} />,
65
+ li: ({ node: _node, ...props }) => <ProseListItem {...props} />,
66
+ blockquote: ({ node: _node, ...props }) => <ProseBlockquote {...props} />,
67
+ code: ({ node: _node, className, ...props }) =>
68
+ /\blanguage-/.test(className ?? "") ? (
69
+ <code className={className} {...props} />
70
+ ) : (
71
+ <ProseInlineCode className={className} {...props} />
72
+ ),
73
+ };
74
+ }
75
+
76
+ export interface MarkdownViewProps extends Omit<
77
+ ComponentProps<typeof Streamdown>,
78
+ "components" | "plugins"
79
+ > {
80
+ /**
81
+ * The prose level a markdown `#` maps to; deeper headings shift with it
82
+ * (capped at 6). Default `1` (the full reading scale). Pass `2` in narrow
83
+ * embeds (a context rail) so headings stay on the constrained rung.
84
+ */
85
+ baseHeadingLevel?: ProseHeadingLevel;
86
+ }
87
+
88
+ export const MarkdownView = ({
89
+ baseHeadingLevel = 1,
90
+ className,
91
+ children,
92
+ ...props
93
+ }: MarkdownViewProps) => {
94
+ const components = useMemo(() => buildProseComponents(baseHeadingLevel), [baseHeadingLevel]);
95
+ // Streamdown's own chrome (code copy, table menus) reads the locale seam (#310).
96
+ // Spread AFTER so an explicit `translations` prop still wins (ADR 0017).
97
+ const translations = useStreamdownTranslations();
98
+ // Brand-token-derived `code` plugin, not the package's static github-*
99
+ // default (#315 follow-up) — re-derives when the active theme changes.
100
+ const plugins = useStreamdownPlugins();
101
+ return (
102
+ <Streamdown
103
+ data-slot="markdown-view"
104
+ className={cn("space-y-3 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", className)}
105
+ components={components}
106
+ plugins={plugins}
107
+ translations={translations}
108
+ {...props}
109
+ >
110
+ {children}
111
+ </Streamdown>
112
+ );
113
+ };