@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.
- package/README.md +4 -6
- package/dist/_lazy-engine-boundary.js +1 -1
- package/dist/_persona-rive.js.map +1 -1
- package/dist/_streamdown-i18n.js +2 -2
- package/dist/agent.js +2 -2
- package/dist/asset-preview.js +5 -5
- package/dist/attachments.js +1 -1
- package/dist/audio-player.js +1 -2
- package/dist/{chunk-7RL6KZDF.js → chunk-24M6XWVJ.js} +25 -17
- package/dist/chunk-24M6XWVJ.js.map +1 -0
- package/dist/{chunk-WU5BLGTF.js → chunk-3KXIWGHW.js} +3 -3
- package/dist/{chunk-TWPHAJNM.js → chunk-3UHR3RLZ.js} +12 -6
- package/dist/chunk-3UHR3RLZ.js.map +1 -0
- package/dist/{chunk-V4DORGWJ.js → chunk-5AMSNG2T.js} +2 -2
- package/dist/chunk-D7BZMRI3.js +2 -0
- package/dist/chunk-D7BZMRI3.js.map +1 -0
- package/dist/{chunk-PQBF6ZIQ.js → chunk-DI645WR4.js} +4 -4
- package/dist/{chunk-NCL2UZKI.js → chunk-FFA7OPEH.js} +2 -2
- package/dist/{chunk-34CQ2VHM.js → chunk-FO44KY7Y.js} +10 -7
- package/dist/chunk-FO44KY7Y.js.map +1 -0
- package/dist/{chunk-RB7G65JC.js → chunk-GP7ZZCID.js} +7 -5
- package/dist/chunk-GP7ZZCID.js.map +1 -0
- package/dist/{chunk-6PB2MXCI.js → chunk-HLODND65.js} +2 -2
- package/dist/{chunk-ADOYAGGK.js → chunk-IFVO7LQJ.js} +2 -2
- package/dist/{chunk-2XOIG5TG.js → chunk-JSEV46SH.js} +18 -12
- package/dist/chunk-JSEV46SH.js.map +1 -0
- package/dist/{chunk-CZP3RHC4.js → chunk-LY7O4BMP.js} +2 -2
- package/dist/{chunk-PTWCTSZL.js → chunk-OCZNA5SO.js} +4 -4
- package/dist/chunk-OCZNA5SO.js.map +1 -0
- package/dist/{chunk-S3ISPW3A.js → chunk-PWQPVOZJ.js} +1 -1
- package/dist/chunk-PWQPVOZJ.js.map +1 -0
- package/dist/chunk-RLV64EWK.js +41 -0
- package/dist/chunk-RLV64EWK.js.map +1 -0
- package/dist/chunk-SSES3F4Q.js +126 -0
- package/dist/chunk-SSES3F4Q.js.map +1 -0
- package/dist/{chunk-3XNPMDZY.js → chunk-TBCMCF5O.js} +9 -56
- package/dist/chunk-TBCMCF5O.js.map +1 -0
- package/dist/{chunk-FERYDFZA.js → chunk-WXUXFDJQ.js} +2 -2
- package/dist/{chunk-X47QMM6K.js → chunk-YLN7E4OV.js} +2 -2
- package/dist/code-block.js +1 -1
- package/dist/diff-view.js +2 -2
- package/dist/gallery.js +1 -1
- package/dist/generated-image.js +9 -0
- package/dist/generated-image.js.map +1 -0
- package/dist/grouped-parts.js +6 -6
- package/dist/image.js +4 -4
- package/dist/index.d.ts +92 -150
- package/dist/index.js +67 -65
- package/dist/markdown-view.js +3 -3
- package/dist/message.js +3 -3
- package/dist/model-provider-logo.js +1 -1
- package/dist/persona.js +2 -2
- package/dist/queue.js +1 -1
- package/dist/reasoning.js +3 -3
- package/dist/sandbox.js +3 -3
- package/dist/tool.js +2 -2
- package/package.json +8 -13
- package/schemas/a2ui-surface.v1.schema.json +5 -2
- package/src/__contract__/{image.contract.test.tsx → attachments.contract.test.tsx} +7 -7
- package/src/__contract__/generated-image.contract.test.tsx +49 -0
- package/src/_lazy-boundary-conformance.ts +12 -18
- package/src/_lazy-engine-boundary.tsx +1 -2
- package/src/_lazy-mermaid-absent.test.ts +2 -2
- package/src/_media-fixtures.ts +47 -0
- package/src/_persona-rive.tsx +1 -2
- package/src/a2ui/catalog.source.json +3 -2
- package/src/asset-preview.stories.tsx +12 -0
- package/src/asset-preview.test.tsx +14 -0
- package/src/asset-preview.tsx +4 -2
- package/src/attachments.stories.tsx +147 -0
- package/src/attachments.test.tsx +104 -0
- package/src/attachments.tsx +28 -20
- package/src/audio-player.stories.tsx +10 -45
- package/src/audio-player.test.tsx +166 -61
- package/src/audio-player.tsx +175 -266
- package/src/chat.stories.tsx +75 -3
- package/src/code-block.test.tsx +32 -0
- package/src/code-block.tsx +22 -5
- package/src/gallery.tsx +13 -77
- package/src/generated-image.stories.tsx +67 -0
- package/src/generated-image.test.tsx +107 -0
- package/src/generated-image.tsx +57 -0
- package/src/image.tsx +2 -115
- package/src/index.ts +1 -0
- package/src/model-provider-logo.tsx +20 -18
- package/src/queue.test.tsx +24 -0
- package/src/queue.tsx +8 -5
- package/src/tool.test.tsx +26 -0
- package/src/tool.tsx +2 -2
- package/dist/_audio-player-media-chrome.js +0 -85
- package/dist/_audio-player-media-chrome.js.map +0 -1
- package/dist/chunk-2XOIG5TG.js.map +0 -1
- package/dist/chunk-34CQ2VHM.js.map +0 -1
- package/dist/chunk-3XNPMDZY.js.map +0 -1
- package/dist/chunk-7RL6KZDF.js.map +0 -1
- package/dist/chunk-NSCLRHES.js +0 -102
- package/dist/chunk-NSCLRHES.js.map +0 -1
- package/dist/chunk-PTWCTSZL.js.map +0 -1
- package/dist/chunk-RB7G65JC.js.map +0 -1
- package/dist/chunk-S3ISPW3A.js.map +0 -1
- package/dist/chunk-TWPHAJNM.js.map +0 -1
- package/dist/chunk-V6E5DYZD.js +0 -80
- package/dist/chunk-V6E5DYZD.js.map +0 -1
- package/src/_audio-player-media-chrome.tsx +0 -233
- package/src/image.stories.tsx +0 -67
- package/src/image.test.tsx +0 -105
- /package/dist/{chunk-WU5BLGTF.js.map → chunk-3KXIWGHW.js.map} +0 -0
- /package/dist/{chunk-V4DORGWJ.js.map → chunk-5AMSNG2T.js.map} +0 -0
- /package/dist/{chunk-PQBF6ZIQ.js.map → chunk-DI645WR4.js.map} +0 -0
- /package/dist/{chunk-NCL2UZKI.js.map → chunk-FFA7OPEH.js.map} +0 -0
- /package/dist/{chunk-6PB2MXCI.js.map → chunk-HLODND65.js.map} +0 -0
- /package/dist/{chunk-ADOYAGGK.js.map → chunk-IFVO7LQJ.js.map} +0 -0
- /package/dist/{chunk-CZP3RHC4.js.map → chunk-LY7O4BMP.js.map} +0 -0
- /package/dist/{chunk-FERYDFZA.js.map → chunk-WXUXFDJQ.js.map} +0 -0
- /package/dist/{chunk-X47QMM6K.js.map → chunk-YLN7E4OV.js.map} +0 -0
package/src/chat.stories.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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
|
+
};
|
package/src/code-block.test.tsx
CHANGED
|
@@ -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 />,
|
package/src/code-block.tsx
CHANGED
|
@@ -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 =
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
*
|
|
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` —
|
|
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
|
-
|
|
340
|
-
|
|
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
|
-
<
|
|
312
|
+
<Image
|
|
380
313
|
src={image.thumbnailSrc ?? image.src}
|
|
381
314
|
alt=""
|
|
315
|
+
fit="cover"
|
|
382
316
|
loading="lazy"
|
|
383
|
-
|
|
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
|
-
|
|
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
|
|
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";
|