@elabs-ai/components-ai 5.3.1 → 5.5.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 (115) hide show
  1. package/README.md +4 -6
  2. package/dist/_lazy-engine-boundary.js +1 -1
  3. package/dist/_persona-rive.js.map +1 -1
  4. package/dist/_streamdown-i18n.js +2 -2
  5. package/dist/agent.js +2 -2
  6. package/dist/asset-preview.js +5 -5
  7. package/dist/attachments.js +1 -1
  8. package/dist/audio-player.js +1 -2
  9. package/dist/{chunk-7RL6KZDF.js → chunk-24M6XWVJ.js} +25 -17
  10. package/dist/chunk-24M6XWVJ.js.map +1 -0
  11. package/dist/{chunk-WU5BLGTF.js → chunk-3KXIWGHW.js} +3 -3
  12. package/dist/{chunk-TWPHAJNM.js → chunk-3UHR3RLZ.js} +12 -6
  13. package/dist/chunk-3UHR3RLZ.js.map +1 -0
  14. package/dist/{chunk-V4DORGWJ.js → chunk-5AMSNG2T.js} +2 -2
  15. package/dist/chunk-D7BZMRI3.js +2 -0
  16. package/dist/chunk-D7BZMRI3.js.map +1 -0
  17. package/dist/{chunk-PQBF6ZIQ.js → chunk-DI645WR4.js} +4 -4
  18. package/dist/{chunk-NCL2UZKI.js → chunk-FFA7OPEH.js} +2 -2
  19. package/dist/{chunk-34CQ2VHM.js → chunk-FO44KY7Y.js} +10 -7
  20. package/dist/chunk-FO44KY7Y.js.map +1 -0
  21. package/dist/{chunk-RB7G65JC.js → chunk-GP7ZZCID.js} +7 -5
  22. package/dist/chunk-GP7ZZCID.js.map +1 -0
  23. package/dist/{chunk-6PB2MXCI.js → chunk-HLODND65.js} +2 -2
  24. package/dist/{chunk-ADOYAGGK.js → chunk-IFVO7LQJ.js} +2 -2
  25. package/dist/{chunk-2XOIG5TG.js → chunk-JSEV46SH.js} +18 -12
  26. package/dist/chunk-JSEV46SH.js.map +1 -0
  27. package/dist/{chunk-CZP3RHC4.js → chunk-LY7O4BMP.js} +2 -2
  28. package/dist/{chunk-PTWCTSZL.js → chunk-OCZNA5SO.js} +4 -4
  29. package/dist/chunk-OCZNA5SO.js.map +1 -0
  30. package/dist/{chunk-S3ISPW3A.js → chunk-PWQPVOZJ.js} +1 -1
  31. package/dist/chunk-PWQPVOZJ.js.map +1 -0
  32. package/dist/chunk-RLV64EWK.js +41 -0
  33. package/dist/chunk-RLV64EWK.js.map +1 -0
  34. package/dist/chunk-SSES3F4Q.js +126 -0
  35. package/dist/chunk-SSES3F4Q.js.map +1 -0
  36. package/dist/{chunk-3XNPMDZY.js → chunk-TBCMCF5O.js} +9 -56
  37. package/dist/chunk-TBCMCF5O.js.map +1 -0
  38. package/dist/{chunk-FERYDFZA.js → chunk-WXUXFDJQ.js} +2 -2
  39. package/dist/{chunk-X47QMM6K.js → chunk-YLN7E4OV.js} +2 -2
  40. package/dist/code-block.js +1 -1
  41. package/dist/diff-view.js +2 -2
  42. package/dist/gallery.js +1 -1
  43. package/dist/generated-image.js +9 -0
  44. package/dist/generated-image.js.map +1 -0
  45. package/dist/grouped-parts.js +6 -6
  46. package/dist/image.js +4 -4
  47. package/dist/index.d.ts +92 -150
  48. package/dist/index.js +67 -65
  49. package/dist/markdown-view.js +3 -3
  50. package/dist/message.js +3 -3
  51. package/dist/model-provider-logo.js +1 -1
  52. package/dist/persona.js +2 -2
  53. package/dist/queue.js +1 -1
  54. package/dist/reasoning.js +3 -3
  55. package/dist/sandbox.js +3 -3
  56. package/dist/tool.js +2 -2
  57. package/package.json +8 -13
  58. package/schemas/a2ui-surface.v1.schema.json +5 -2
  59. package/src/__contract__/{image.contract.test.tsx → attachments.contract.test.tsx} +7 -7
  60. package/src/__contract__/generated-image.contract.test.tsx +49 -0
  61. package/src/_lazy-boundary-conformance.ts +12 -18
  62. package/src/_lazy-engine-boundary.tsx +1 -2
  63. package/src/_lazy-mermaid-absent.test.ts +2 -2
  64. package/src/_media-fixtures.ts +47 -0
  65. package/src/_persona-rive.tsx +1 -2
  66. package/src/a2ui/catalog.source.json +3 -2
  67. package/src/asset-preview.stories.tsx +12 -0
  68. package/src/asset-preview.test.tsx +14 -0
  69. package/src/asset-preview.tsx +4 -2
  70. package/src/attachments.stories.tsx +147 -0
  71. package/src/attachments.test.tsx +104 -0
  72. package/src/attachments.tsx +28 -20
  73. package/src/audio-player.stories.tsx +10 -45
  74. package/src/audio-player.test.tsx +166 -61
  75. package/src/audio-player.tsx +175 -266
  76. package/src/chat.stories.tsx +75 -3
  77. package/src/code-block.test.tsx +32 -0
  78. package/src/code-block.tsx +22 -5
  79. package/src/gallery.tsx +13 -77
  80. package/src/generated-image.stories.tsx +67 -0
  81. package/src/generated-image.test.tsx +107 -0
  82. package/src/generated-image.tsx +57 -0
  83. package/src/image.tsx +2 -115
  84. package/src/index.ts +1 -0
  85. package/src/model-provider-logo.tsx +20 -18
  86. package/src/queue.test.tsx +24 -0
  87. package/src/queue.tsx +8 -5
  88. package/src/tool.test.tsx +26 -0
  89. package/src/tool.tsx +2 -2
  90. package/dist/_audio-player-media-chrome.js +0 -85
  91. package/dist/_audio-player-media-chrome.js.map +0 -1
  92. package/dist/chunk-2XOIG5TG.js.map +0 -1
  93. package/dist/chunk-34CQ2VHM.js.map +0 -1
  94. package/dist/chunk-3XNPMDZY.js.map +0 -1
  95. package/dist/chunk-7RL6KZDF.js.map +0 -1
  96. package/dist/chunk-NSCLRHES.js +0 -102
  97. package/dist/chunk-NSCLRHES.js.map +0 -1
  98. package/dist/chunk-PTWCTSZL.js.map +0 -1
  99. package/dist/chunk-RB7G65JC.js.map +0 -1
  100. package/dist/chunk-S3ISPW3A.js.map +0 -1
  101. package/dist/chunk-TWPHAJNM.js.map +0 -1
  102. package/dist/chunk-V6E5DYZD.js +0 -80
  103. package/dist/chunk-V6E5DYZD.js.map +0 -1
  104. package/src/_audio-player-media-chrome.tsx +0 -233
  105. package/src/image.stories.tsx +0 -67
  106. package/src/image.test.tsx +0 -105
  107. /package/dist/{chunk-WU5BLGTF.js.map → chunk-3KXIWGHW.js.map} +0 -0
  108. /package/dist/{chunk-V4DORGWJ.js.map → chunk-5AMSNG2T.js.map} +0 -0
  109. /package/dist/{chunk-PQBF6ZIQ.js.map → chunk-DI645WR4.js.map} +0 -0
  110. /package/dist/{chunk-NCL2UZKI.js.map → chunk-FFA7OPEH.js.map} +0 -0
  111. /package/dist/{chunk-6PB2MXCI.js.map → chunk-HLODND65.js.map} +0 -0
  112. /package/dist/{chunk-ADOYAGGK.js.map → chunk-IFVO7LQJ.js.map} +0 -0
  113. /package/dist/{chunk-CZP3RHC4.js.map → chunk-LY7O4BMP.js.map} +0 -0
  114. /package/dist/{chunk-FERYDFZA.js.map → chunk-WXUXFDJQ.js.map} +0 -0
  115. /package/dist/{chunk-X47QMM6K.js.map → chunk-YLN7E4OV.js.map} +0 -0
@@ -1,5 +1,18 @@
1
1
  import { useState } from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react";
3
+ import { expect, within } from "storybook/test";
4
+ import { placeholderChartDataUrl, silentWavDataUrl } from "./_media-fixtures";
5
+ import { Attachment, AttachmentPreview, Attachments, type AttachmentData } from "./attachments";
6
+ import {
7
+ AudioPlayer,
8
+ AudioPlayerControlBar,
9
+ AudioPlayerDurationDisplay,
10
+ AudioPlayerElement,
11
+ AudioPlayerMuteButton,
12
+ AudioPlayerPlayButton,
13
+ AudioPlayerTimeDisplay,
14
+ AudioPlayerTimeRange,
15
+ } from "./audio-player";
3
16
  import { ChatShell } from "./chat-shell";
4
17
  import { Conversation, ConversationContent, ConversationScrollButton } from "./conversation";
5
18
  import { Message, MessageContent, MessageResponse } from "./message";
@@ -12,16 +25,38 @@ interface Msg {
12
25
  role: "user" | "assistant";
13
26
  text: string;
14
27
  reasoning?: string;
28
+ /** Files the person attached — rendered through `Attachments` (images via ui `Image`). */
29
+ attachments?: AttachmentData[];
30
+ /** A spoken rendition of the reply (text-to-speech output), played inline. */
31
+ audioSrc?: string;
15
32
  }
16
33
 
34
+ /** The screenshot the person pastes — an SVG data URL, so the story needs no network origin. */
35
+ const SCREENSHOT: AttachmentData = {
36
+ id: "shot",
37
+ type: "file",
38
+ mediaType: "image/svg+xml",
39
+ filename: "staging-latency.svg",
40
+ url: placeholderChartDataUrl("p95 latency — staging, last 7 days"),
41
+ };
42
+
43
+ /** A 12 s silent clip stands in for the spoken reply. */
44
+ const SPOKEN_REPLY = silentWavDataUrl(12);
45
+
17
46
  const initial: Msg[] = [
18
- { id: "1", role: "user", text: "What changed in last week's deploys?" },
47
+ {
48
+ id: "1",
49
+ role: "user",
50
+ text: "Here’s the latency panel from staging — what changed in last week’s deploys?",
51
+ attachments: [SCREENSHOT],
52
+ },
19
53
  {
20
54
  id: "2",
21
55
  role: "assistant",
22
56
  text: "Three services shipped this week. **Billing** is currently degraded — elevated p95 latency after the last rollout.",
23
57
  reasoning:
24
58
  "Queried CI for the last 7 days, grouped by service, then cross-referenced the rollback log to flag regressions.",
59
+ audioSrc: SPOKEN_REPLY,
25
60
  },
26
61
  ];
27
62
 
@@ -66,6 +101,15 @@ function ChatExample() {
66
101
  {messages.map((m) => (
67
102
  <Message from={m.role} key={m.id}>
68
103
  <MessageContent>
104
+ {m.attachments?.length ? (
105
+ <Attachments variant="grid">
106
+ {m.attachments.map((attachment) => (
107
+ <Attachment key={attachment.id} data={attachment}>
108
+ <AttachmentPreview />
109
+ </Attachment>
110
+ ))}
111
+ </Attachments>
112
+ ) : null}
69
113
  {m.reasoning ? (
70
114
  <Reasoning>
71
115
  <ReasoningTrigger />
@@ -73,6 +117,20 @@ function ChatExample() {
73
117
  </Reasoning>
74
118
  ) : null}
75
119
  <MessageResponse>{m.text}</MessageResponse>
120
+ {m.audioSrc ? (
121
+ // The spoken version of the reply: ai's `AudioPlayer*` presets over the
122
+ // ui `MediaPlayer*` parts, in a compact bar that fits the bubble.
123
+ <AudioPlayer className="block w-full max-w-md rounded-md border">
124
+ <AudioPlayerElement src={m.audioSrc} preload="metadata" />
125
+ <AudioPlayerControlBar>
126
+ <AudioPlayerPlayButton />
127
+ <AudioPlayerTimeDisplay />
128
+ <AudioPlayerTimeRange />
129
+ <AudioPlayerDurationDisplay />
130
+ <AudioPlayerMuteButton />
131
+ </AudioPlayerControlBar>
132
+ </AudioPlayer>
133
+ ) : null}
76
134
  {m.role === "assistant" ? (
77
135
  <Sources>
78
136
  <SourcesTrigger count={2} />
@@ -107,7 +165,11 @@ const meta = {
107
165
  "[Messages](?path=/story/ai-message--presets) with reasoning and sources, plus a " +
108
166
  "[Composer](?path=/story/ai-composer--default), laid out by " +
109
167
  "[ChatShell](?path=/story/ai-chatshell--default): the transcript scrolls behind the " +
110
- "floating composer and both sit in centred reading columns.",
168
+ "floating composer and both sit in centred reading columns. The person’s message " +
169
+ "carries a screenshot as an [Attachment](?path=/story/ai-attachments--default) and " +
170
+ "the reply ships a spoken version through the " +
171
+ "[AudioPlayer](?path=/story/ai-audioplayer--default) presets — both render through " +
172
+ "the ui media primitives.",
111
173
  },
112
174
  },
113
175
  },
@@ -115,4 +177,14 @@ const meta = {
115
177
  export default meta;
116
178
  type Story = StoryObj<typeof meta>;
117
179
 
118
- export const Default: Story = {};
180
+ export const Default: Story = {
181
+ play: async ({ canvasElement }) => {
182
+ const canvas = within(canvasElement);
183
+ // The pasted screenshot renders through ui `Image`, named by its filename.
184
+ await expect(canvas.getByRole("img", { name: "staging-latency.svg" })).toBeVisible();
185
+ // The spoken reply is a real player: a named region with real buttons and a scrubber.
186
+ const player = canvas.getByRole("region", { name: "Audio player" });
187
+ await expect(within(player).getByRole("button", { name: "Play" })).toBeVisible();
188
+ await expect(within(player).getByRole("slider", { name: "Seek" })).toBeInTheDocument();
189
+ },
190
+ };
@@ -21,6 +21,17 @@ describe("CodeBlock", () => {
21
21
  expect(source).not.toMatch(/github-light|github-dark/);
22
22
  });
23
23
 
24
+ // #597 — locks the lazy-boundary fix: shiki (~36KB gzip of bundled
25
+ // language/theme index) must never be a static module-scope import, which
26
+ // would put it in every consumer's entry chunk (ADR 0019). Only an
27
+ // `import type` (erases at build time, #315's own imports rely on this)
28
+ // or the runtime `import("shiki")` inside `loadShiki` may reference it.
29
+ it("never statically imports shiki at module scope — only import type or a lazy import() (#597)", () => {
30
+ const source = readFileSync(join(__dirname, "code-block.tsx"), "utf8");
31
+ expect(source).not.toMatch(/^\s*import\s+(?!type\s)[^;]*\sfrom\s*["']shiki["']/m);
32
+ expect(source).toMatch(/import\(\s*["']shiki["']\s*\)/);
33
+ });
34
+
24
35
  it("resolves the highlighted <pre> background from the active theme's --code-background token (#315)", async () => {
25
36
  document.documentElement.setAttribute("data-theme", "light");
26
37
  document.documentElement.style.setProperty("--code-background", "oklch(1 0 0)");
@@ -144,6 +155,27 @@ describe("CodeBlock", () => {
144
155
  expect(container.textContent).toContain("const a = 1;");
145
156
  });
146
157
 
158
+ // #597 — locks the runtime contract the shiki lazy-boundary must preserve:
159
+ // the code is visible via the raw `createRawTokens` fallback on the very
160
+ // first render (no spinner/blank gate while shiki's dynamic import is in
161
+ // flight, no layout shift), and per-token highlighting arrives afterward,
162
+ // once that import resolves.
163
+ it("renders the raw code immediately, then highlights once shiki's lazy import resolves (#597)", async () => {
164
+ const { container } = render(<CodeBlock code="const lazyShikiMarker = 597;" language="tsx" />);
165
+
166
+ // Immediately: real text content via the single-span raw fallback,
167
+ // synchronously, before shiki has had any chance to load.
168
+ expect(container.querySelector("pre")?.textContent).toContain("const lazyShikiMarker = 597;");
169
+ expect(container.querySelectorAll("code > span > span").length).toBeLessThanOrEqual(1);
170
+
171
+ // Afterward: shiki's dynamic import resolves and per-token highlighting
172
+ // replaces the raw fallback with real, multi-span tokenized output.
173
+ await waitFor(() => {
174
+ expect(container.querySelectorAll("code > span > span").length).toBeGreaterThan(1);
175
+ });
176
+ expect(container.querySelector("pre")?.textContent).toContain("const lazyShikiMarker = 597;");
177
+ });
178
+
147
179
  it("soft-wraps long lines when `wrap` is set (#5)", () => {
148
180
  const { container } = render(
149
181
  <CodeBlock code="a very long single line of code" language="tsx" wrap />,
@@ -25,7 +25,6 @@ import {
25
25
  useState,
26
26
  } from "react";
27
27
  import type { BundledLanguage, BundledTheme, HighlighterGeneric, ThemedToken } from "shiki";
28
- import { createHighlighter } from "shiki";
29
28
 
30
29
  import { buildCodeBlockTheme, codeBlockThemeId, getThemeScopeKey } from "./_code-block-theme";
31
30
  import { getThemeScope, useThemeScopeRevision } from "./_theme-scope-store";
@@ -212,6 +211,22 @@ const hashCode = (value: string): string => {
212
211
  const getTokensCacheKey = (code: string, language: BundledLanguage, themeId: string) =>
213
212
  `${themeId}:${language}:${code.length}:${hashCode(code)}`;
214
213
 
214
+ // Shiki (~36KB gzip of bundled language/theme index, #597) is a
215
+ // `@lazy-boundary`-style dependency (ADR 0019, same pattern as
216
+ // `_lazy-mermaid.ts`'s `loadEngine`): the ONLY reference to it anywhere in
217
+ // this module is this dynamic `import()`, fetched at most once per app and
218
+ // shared by every language's highlighter promise below. `CodeBlockContent`
219
+ // already renders `createRawTokens` synchronously on mount and swaps in the
220
+ // tokenized result once `getHighlighter`'s promise resolves, so this never
221
+ // introduces a layout shift or a loading state beyond the existing
222
+ // raw-then-highlighted flow.
223
+ let shikiModulePromise: Promise<Pick<typeof import("shiki"), "createHighlighter">> | undefined;
224
+
225
+ const loadShiki = (): Promise<Pick<typeof import("shiki"), "createHighlighter">> => {
226
+ shikiModulePromise ??= import("shiki");
227
+ return shikiModulePromise;
228
+ };
229
+
215
230
  const getHighlighter = (
216
231
  language: BundledLanguage,
217
232
  ): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
@@ -223,10 +238,12 @@ const getHighlighter = (
223
238
  // No themes preloaded here (#315) — the theme is derived from brand tokens
224
239
  // per call (`buildCodeBlockTheme`) and passed directly to `codeToTokens`,
225
240
  // never a bundled `github-*` literal.
226
- const highlighterPromise = createHighlighter({
227
- langs: [language],
228
- themes: [],
229
- });
241
+ const highlighterPromise = loadShiki().then(({ createHighlighter }) =>
242
+ createHighlighter({
243
+ langs: [language],
244
+ themes: [],
245
+ }),
246
+ );
230
247
 
231
248
  highlighterCache.set(language, highlighterPromise);
232
249
  return highlighterPromise;
package/src/gallery.tsx CHANGED
@@ -7,10 +7,11 @@
7
7
  * overflow into a "+N more" tile, and opens a unified lightbox `Dialog` with a
8
8
  * `Carousel` and a right-side metadata panel that tracks the active slide.
9
9
  *
10
- * Distinct from the other image surfaces in this package don't merge them:
10
+ * The image surfaces in this package differ by ROLE, and all of them compose
11
+ * the ui `Image` primitive (ADR 0041) rather than drawing their own `<img>`:
11
12
  * - `attachments.tsx` — composer INPUT (removable, file-typed thumbnails).
12
13
  * - `asset-preview.tsx` — a SINGLE asset in the context rail (type-switched).
13
- * - `image.tsx` — a base64 `<img>` wrapper for AI-SDK generated images.
14
+ * - `generated-image.tsx` — an AI-SDK generated (base64) image.
14
15
  * Gallery is the multi-image lightbox: expand / download / carousel / metadata.
15
16
  *
16
17
  * The data model is framework-agnostic (plain `{ src, alt, … }`) — it does NOT
@@ -31,6 +32,7 @@ import {
31
32
  DialogContent,
32
33
  DialogDescription,
33
34
  DialogTitle,
35
+ Image,
34
36
  Skeleton,
35
37
  downloadUrl,
36
38
  type CarouselApi,
@@ -141,71 +143,6 @@ function triggerDownload(image: GalleryImage): void {
141
143
  void downloadUrl(image.downloadUrl ?? image.src, image.downloadName);
142
144
  }
143
145
 
144
- /**
145
- * `<img>` with a `Skeleton` placeholder until it loads (loading-states.md — the
146
- * box is already reserved by `AspectRatio`, so no CLS) and a token-styled
147
- * fallback box when the source fails to load.
148
- */
149
- function GalleryImg({
150
- src,
151
- alt,
152
- className,
153
- loading,
154
- showSkeleton = true,
155
- }: {
156
- src: string;
157
- alt: string;
158
- className?: string;
159
- loading?: "lazy" | "eager";
160
- /**
161
- * Show the `Skeleton` placeholder until load. Only meaningful inside a
162
- * space-reserved box (`AspectRatio`); the lightbox sets `false` because the
163
- * slide isn't sized to the image, so an `absolute inset-0` skeleton would fill
164
- * the whole padded slide rather than the image footprint.
165
- */
166
- showSkeleton?: boolean;
167
- }) {
168
- const [failed, setFailed] = useState(false);
169
- const [loaded, setLoaded] = useState(false);
170
- const imgRef = useRef<HTMLImageElement>(null);
171
-
172
- // Cached images can finish before React attaches `onLoad` — read `complete`.
173
- useEffect(() => {
174
- if (imgRef.current?.complete) setLoaded(true);
175
- }, []);
176
-
177
- if (failed) {
178
- return (
179
- <div
180
- className={cn(
181
- "flex flex-col items-center justify-center gap-1 bg-muted text-muted-foreground",
182
- className,
183
- )}
184
- {...(alt ? { role: "img", "aria-label": alt } : { "aria-hidden": true })}
185
- >
186
- <ImageOff className="size-6" aria-hidden="true" />
187
- </div>
188
- );
189
- }
190
-
191
- return (
192
- <>
193
- {showSkeleton && !loaded ? (
194
- <Skeleton className="absolute inset-0 size-full rounded-none" />
195
- ) : null}
196
- <img
197
- ref={imgRef}
198
- src={src}
199
- alt={alt}
200
- loading={loading}
201
- onError={() => setFailed(true)}
202
- onLoad={() => setLoaded(true)}
203
- className={className}
204
- />
205
- </>
206
- );
207
- }
208
-
209
146
  /** Round, frosted icon button that downloads `image`. */
210
147
  function GalleryDownloadButton({
211
148
  image,
@@ -336,12 +273,8 @@ function GallerySingle() {
336
273
  return (
337
274
  <GalleryTilt>
338
275
  <AspectRatio ratio={aspectRatio} className="overflow-hidden rounded-lg">
339
- <GalleryImg
340
- src={image.src}
341
- alt={image.alt}
342
- loading="lazy"
343
- className="size-full object-cover"
344
- />
276
+ {/* `showSkeleton` frames the image `size-full` inside the reserved box. */}
277
+ <Image src={image.src} alt={image.alt} fit="cover" loading="lazy" showSkeleton />
345
278
  </AspectRatio>
346
279
  <div className="absolute end-2 top-2 z-10 flex items-center gap-1">
347
280
  {hideDownload ? null : <GalleryDownloadButton image={image} className={HOVER_TOOLBAR} />}
@@ -376,11 +309,12 @@ function GalleryTile({ image, index }: { image: GalleryImage; index: number }) {
376
309
  >
377
310
  <AspectRatio ratio={aspectRatio} className="overflow-hidden rounded-lg">
378
311
  {/* Decorative: the button carries the accessible name. */}
379
- <GalleryImg
312
+ <Image
380
313
  src={image.thumbnailSrc ?? image.src}
381
314
  alt=""
315
+ fit="cover"
382
316
  loading="lazy"
383
- className="size-full object-cover"
317
+ showSkeleton
384
318
  />
385
319
  </AspectRatio>
386
320
  </button>
@@ -567,11 +501,13 @@ function GalleryCarousel() {
567
501
  aria-label={`${index + 1} of ${images.length}`}
568
502
  >
569
503
  <div className="group/slide relative flex items-center justify-center px-4">
570
- <GalleryImg
504
+ {/* The slide is not sized to the image — no box to reserve. */}
505
+ <Image
571
506
  src={image.src}
572
507
  alt={image.alt}
508
+ fit="contain"
573
509
  showSkeleton={false}
574
- className="max-h-[80vh] w-auto max-w-full object-contain"
510
+ className="max-h-[80vh] w-auto max-w-full"
575
511
  />
576
512
  {hideDownload ? null : (
577
513
  <GalleryDownloadButton
@@ -0,0 +1,67 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { GeneratedImage } from "./generated-image";
3
+
4
+ // A tiny 1x1 solid-red PNG — enough to exercise decode + skeleton timing
5
+ // without shipping a real asset in the story.
6
+ const RED_DOT =
7
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC";
8
+
9
+ const meta = {
10
+ title: "AI/GeneratedImage",
11
+ component: GeneratedImage,
12
+ tags: ["autodocs"],
13
+ parameters: {
14
+ layout: "padded",
15
+ docs: {
16
+ description: {
17
+ component:
18
+ "An AI-SDK generated image (`Experimental_GeneratedImage`) rendered through the ui `Image` primitive as a base64 data URL.\n\n" +
19
+ "**`Image` from `@elabs-ai/components-ai` is @deprecated** — it is now an alias of `GeneratedImage` (and `ImageProps` of `GeneratedImageProps`), kept for one minor and removed in the next major. `Image` is the ui primitive; import `GeneratedImage` here.",
20
+ },
21
+ },
22
+ },
23
+ } satisfies Meta<typeof GeneratedImage>;
24
+ export default meta;
25
+ type Story = StoryObj<typeof meta>;
26
+
27
+ // `showSkeleton` (default `Boolean(width && height)` — true here) covers the
28
+ // reserved box with a `Skeleton` until the payload decodes. The window is
29
+ // real-decode-timed, so it is exercised deterministically in
30
+ // generated-image.test.tsx rather than frozen here.
31
+ export const Default: Story = {
32
+ args: {
33
+ base64: RED_DOT,
34
+ mediaType: "image/png",
35
+ uint8Array: new Uint8Array(),
36
+ alt: "A generated red square",
37
+ width: 128,
38
+ height: 128,
39
+ },
40
+ };
41
+
42
+ // BROKEN — an undecodable payload settles into the ui `Image` fallback (an
43
+ // `ImageOff` glyph on a muted box) instead of a native broken-image glyph.
44
+ export const Broken: Story = {
45
+ args: {
46
+ base64: "not-a-real-image",
47
+ mediaType: "image/png",
48
+ uint8Array: new Uint8Array(),
49
+ alt: "A generated image",
50
+ width: 128,
51
+ height: 128,
52
+ },
53
+ };
54
+
55
+ // Opt out for a context that already has its own placeholder.
56
+ export const NoSkeleton: Story = {
57
+ name: "showSkeleton=false",
58
+ args: {
59
+ base64: RED_DOT,
60
+ mediaType: "image/png",
61
+ uint8Array: new Uint8Array(),
62
+ alt: "A generated red square",
63
+ width: 128,
64
+ height: 128,
65
+ showSkeleton: false,
66
+ },
67
+ };
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { fireEvent, render } from "@testing-library/react";
3
+ import { createRef } from "react";
4
+ import { GeneratedImage, type GeneratedImageProps } from "./generated-image";
5
+ import { Image } from "./image";
6
+
7
+ const RED_DOT =
8
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC";
9
+
10
+ const BASE_PROPS: Pick<GeneratedImageProps, "base64" | "mediaType" | "uint8Array"> = {
11
+ base64: RED_DOT,
12
+ mediaType: "image/png",
13
+ uint8Array: new Uint8Array(),
14
+ };
15
+
16
+ describe("GeneratedImage", () => {
17
+ it("renders the img with the base64 data URI, alt, width and height", () => {
18
+ const { container } = render(
19
+ <GeneratedImage {...BASE_PROPS} alt="A red square" width={64} height={64} />,
20
+ );
21
+ const img = container.querySelector("img");
22
+ expect(img?.getAttribute("src")).toBe(`data:image/png;base64,${RED_DOT}`);
23
+ expect(img?.getAttribute("alt")).toBe("A red square");
24
+ expect(img?.getAttribute("width")).toBe("64");
25
+ expect(img?.getAttribute("height")).toBe("64");
26
+ });
27
+
28
+ it('emits data-slot="generated-image", overriding the ui primitive’s slot', () => {
29
+ const { container } = render(<GeneratedImage {...BASE_PROPS} />);
30
+ expect(container.querySelector('[data-slot="generated-image"]')).not.toBeNull();
31
+ expect(container.querySelector('[data-slot="image"]')).toBeNull();
32
+ });
33
+
34
+ it("defaults alt to empty (decorative) rather than omitting it", () => {
35
+ const { container } = render(<GeneratedImage {...BASE_PROPS} />);
36
+ expect(container.querySelector("img")?.getAttribute("alt")).toBe("");
37
+ });
38
+
39
+ it("forwards a ref to the underlying <img>", () => {
40
+ const ref = createRef<HTMLImageElement>();
41
+ render(<GeneratedImage ref={ref} {...BASE_PROPS} />);
42
+ expect(ref.current).toBeInstanceOf(HTMLImageElement);
43
+ });
44
+ });
45
+
46
+ describe("GeneratedImage showSkeleton", () => {
47
+ it("shows a Skeleton (decorative + one live region) until onLoad fires, when width+height are given", () => {
48
+ const { container } = render(
49
+ <GeneratedImage {...BASE_PROPS} alt="A red square" width={64} height={64} />,
50
+ );
51
+ const skeleton = container.querySelector('[data-slot="image-skeleton"]');
52
+ expect(skeleton).not.toBeNull();
53
+ expect(skeleton).toHaveAttribute("aria-hidden", "true");
54
+ expect(container.querySelectorAll('[role="status"]')).toHaveLength(1);
55
+
56
+ fireEvent.load(container.querySelector("img")!);
57
+ expect(container.querySelector('[data-slot="image-skeleton"]')).toBeNull();
58
+ });
59
+
60
+ it("defaults to no skeleton when width/height are not given — nothing to reserve", () => {
61
+ const { container } = render(<GeneratedImage {...BASE_PROPS} alt="" />);
62
+ expect(container.querySelector('[data-slot="image-skeleton"]')).toBeNull();
63
+ expect(container.querySelector('[role="status"]')).toBeNull();
64
+ });
65
+
66
+ it("skips the skeleton when showSkeleton is explicitly false, even with width+height", () => {
67
+ const { container } = render(
68
+ <GeneratedImage {...BASE_PROPS} alt="" width={64} height={64} showSkeleton={false} />,
69
+ );
70
+ expect(container.querySelector('[data-slot="image-skeleton"]')).toBeNull();
71
+ });
72
+
73
+ it("keeps the bare <img> as the root when no skeleton is shown, so className resolves against the real parent", () => {
74
+ const { container } = render(<GeneratedImage {...BASE_PROPS} alt="" className="w-full" />);
75
+ expect(container.firstElementChild?.tagName).toBe("IMG");
76
+ expect(container.firstElementChild).toHaveClass("w-full");
77
+ });
78
+
79
+ it("calls the consumer's onLoad in addition to clearing the skeleton", () => {
80
+ const onLoad = vi.fn();
81
+ const { container } = render(
82
+ <GeneratedImage {...BASE_PROPS} alt="" width={64} height={64} onLoad={onLoad} />,
83
+ );
84
+ fireEvent.load(container.querySelector("img")!);
85
+ expect(onLoad).toHaveBeenCalledTimes(1);
86
+ });
87
+ });
88
+
89
+ describe("GeneratedImage onError fallback", () => {
90
+ it("replaces the img with the fallback box instead of leaving the Skeleton pulsing", () => {
91
+ const onError = vi.fn();
92
+ const { container } = render(
93
+ <GeneratedImage {...BASE_PROPS} alt="A generated image" onError={onError} />,
94
+ );
95
+ fireEvent.error(container.querySelector("img")!);
96
+ expect(container.querySelector("img")).toBeNull();
97
+ expect(container.querySelector('[data-slot="image-skeleton"]')).toBeNull();
98
+ expect(container.querySelector('[role="img"][aria-label="A generated image"]')).not.toBeNull();
99
+ expect(onError).toHaveBeenCalledTimes(1);
100
+ });
101
+ });
102
+
103
+ describe("Image (deprecated alias)", () => {
104
+ it("is the same component as GeneratedImage", () => {
105
+ expect(Image).toBe(GeneratedImage);
106
+ });
107
+ });
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import { Image, type ImageProps } from "@elabs-ai/components-ui";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import type { Experimental_GeneratedImage } from "ai";
6
+ import { forwardRef } from "react";
7
+
8
+ export type GeneratedImageProps = Experimental_GeneratedImage &
9
+ Omit<ImageProps, "src" | "alt" | "aspectRatio" | "showSkeleton"> & {
10
+ /** Accessible text. Omit (or `""`) for a decorative image. @default "" */
11
+ alt?: string;
12
+ /**
13
+ * Show a `Skeleton` in the reserved `width` × `height` box until the base64
14
+ * payload decodes, so it does not pop in. Without both dimensions there is
15
+ * no box to reserve, so the default is off in that case.
16
+ * @default Boolean(width && height)
17
+ */
18
+ showSkeleton?: boolean;
19
+ };
20
+
21
+ /**
22
+ * An AI-SDK generated image (`Experimental_GeneratedImage`) rendered through
23
+ * the ui `Image` primitive as a base64 data URL — decode-aware skeleton,
24
+ * `ImageOff` fallback on a terminal error.
25
+ */
26
+ export const GeneratedImage = forwardRef<HTMLImageElement, GeneratedImageProps>(
27
+ function GeneratedImage(
28
+ {
29
+ base64,
30
+ uint8Array: _uint8Array,
31
+ mediaType,
32
+ alt = "",
33
+ showSkeleton,
34
+ width,
35
+ height,
36
+ className,
37
+ ...props
38
+ },
39
+ ref,
40
+ ) {
41
+ return (
42
+ <Image
43
+ {...props}
44
+ ref={ref}
45
+ data-slot="generated-image"
46
+ src={`data:${mediaType};base64,${base64}`}
47
+ alt={alt}
48
+ width={width}
49
+ height={height}
50
+ showSkeleton={showSkeleton ?? Boolean(width && height)}
51
+ className={cn("rounded-md", className)}
52
+ />
53
+ );
54
+ },
55
+ );
56
+
57
+ GeneratedImage.displayName = "GeneratedImage";