@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,390 @@
1
+ "use client";
2
+
3
+ import {
4
+ Collapsible,
5
+ CollapsibleContent,
6
+ CollapsibleTrigger,
7
+ fileIconFor,
8
+ } from "@elabs-ai/components-ui";
9
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
10
+ import { cva, type VariantProps } from "class-variance-authority";
11
+ import type { LucideIcon } from "lucide-react";
12
+ import {
13
+ ChevronRightIcon,
14
+ DatabaseIcon,
15
+ FileCode2Icon,
16
+ FileIcon,
17
+ FileTextIcon,
18
+ FolderIcon,
19
+ FolderOpenIcon,
20
+ ImageIcon,
21
+ TableIcon,
22
+ } from "lucide-react";
23
+ import type { HTMLAttributes, ReactNode } from "react";
24
+ import { createContext, forwardRef, useCallback, useContext, useMemo, useState } from "react";
25
+ import type { ContextAsset, ContextAssetType } from "./context-panel";
26
+
27
+ interface FileTreeContextType {
28
+ expandedPaths: Set<string>;
29
+ togglePath: (path: string) => void;
30
+ selectedPath?: string;
31
+ onSelect?: (path: string) => void;
32
+ }
33
+
34
+ // Default noop for context default value
35
+ // oxlint-disable-next-line eslint(no-empty-function)
36
+ const noop = () => {};
37
+
38
+ const FileTreeContext = createContext<FileTreeContextType>({
39
+ // oxlint-disable-next-line eslint-plugin-unicorn(no-new-builtin)
40
+ expandedPaths: new Set(),
41
+ togglePath: noop,
42
+ });
43
+
44
+ /**
45
+ * Visual axis (#193, research 04 §4 ASSET-1): `code` (default, unchanged) is
46
+ * the IDE source tree — mono + hard box; `document` is the produced-asset
47
+ * look — sans body role, no box (section label + spacing separate it,
48
+ * research 08), for trees of documents rather than source files.
49
+ */
50
+ const fileTreeVariants = cva("", {
51
+ variants: {
52
+ variant: {
53
+ code: "rounded-lg border bg-background font-mono text-sm",
54
+ document: "text-body",
55
+ },
56
+ },
57
+ defaultVariants: { variant: "code" },
58
+ });
59
+
60
+ export type FileTreeVariant = NonNullable<VariantProps<typeof fileTreeVariants>["variant"]>;
61
+
62
+ export type FileTreeProps = Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> &
63
+ VariantProps<typeof fileTreeVariants> & {
64
+ expanded?: Set<string>;
65
+ defaultExpanded?: Set<string>;
66
+ selectedPath?: string;
67
+ onSelect?: (path: string) => void;
68
+ onExpandedChange?: (expanded: Set<string>) => void;
69
+ };
70
+
71
+ export const FileTree = forwardRef<HTMLDivElement, FileTreeProps>(function FileTree(
72
+ {
73
+ expanded: controlledExpanded,
74
+ defaultExpanded = new Set(),
75
+ selectedPath,
76
+ onSelect,
77
+ onExpandedChange,
78
+ variant,
79
+ className,
80
+ children,
81
+ ...props
82
+ },
83
+ ref,
84
+ ) {
85
+ const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
86
+ const expandedPaths = controlledExpanded ?? internalExpanded;
87
+
88
+ const togglePath = useCallback(
89
+ (path: string) => {
90
+ const newExpanded = new Set(expandedPaths);
91
+ if (newExpanded.has(path)) {
92
+ newExpanded.delete(path);
93
+ } else {
94
+ newExpanded.add(path);
95
+ }
96
+ setInternalExpanded(newExpanded);
97
+ onExpandedChange?.(newExpanded);
98
+ },
99
+ [expandedPaths, onExpandedChange],
100
+ );
101
+
102
+ const contextValue = useMemo(
103
+ () => ({ expandedPaths, onSelect, selectedPath, togglePath }),
104
+ [expandedPaths, onSelect, selectedPath, togglePath],
105
+ );
106
+
107
+ return (
108
+ <FileTreeContext.Provider value={contextValue}>
109
+ <div
110
+ ref={ref}
111
+ className={cn(fileTreeVariants({ variant }), className)}
112
+ role="tree"
113
+ {...props}
114
+ >
115
+ <div className={cn(variant === "document" ? undefined : "p-2")}>{children}</div>
116
+ </div>
117
+ </FileTreeContext.Provider>
118
+ );
119
+ });
120
+
121
+ export type FileTreeIconProps = HTMLAttributes<HTMLSpanElement>;
122
+
123
+ export const FileTreeIcon = ({ className, children, ...props }: FileTreeIconProps) => (
124
+ <span className={cn("shrink-0", className)} {...props}>
125
+ {children}
126
+ </span>
127
+ );
128
+
129
+ export type FileTreeNameProps = HTMLAttributes<HTMLSpanElement>;
130
+
131
+ export const FileTreeName = ({ className, children, ...props }: FileTreeNameProps) => (
132
+ <span className={cn("truncate", className)} {...props}>
133
+ {children}
134
+ </span>
135
+ );
136
+
137
+ interface FileTreeFolderContextType {
138
+ path: string;
139
+ name: string;
140
+ isExpanded: boolean;
141
+ }
142
+
143
+ const FileTreeFolderContext = createContext<FileTreeFolderContextType>({
144
+ isExpanded: false,
145
+ name: "",
146
+ path: "",
147
+ });
148
+
149
+ export type FileTreeFolderProps = HTMLAttributes<HTMLDivElement> & {
150
+ path: string;
151
+ name: string;
152
+ };
153
+
154
+ export const FileTreeFolder = ({
155
+ path,
156
+ name,
157
+ className,
158
+ children,
159
+ ...props
160
+ }: FileTreeFolderProps) => {
161
+ const { expandedPaths, togglePath, selectedPath, onSelect } = useContext(FileTreeContext);
162
+ const isExpanded = expandedPaths.has(path);
163
+ const isSelected = selectedPath === path;
164
+
165
+ const handleOpenChange = useCallback(() => {
166
+ togglePath(path);
167
+ }, [togglePath, path]);
168
+
169
+ const handleSelect = useCallback(() => {
170
+ onSelect?.(path);
171
+ }, [onSelect, path]);
172
+
173
+ const folderContextValue = useMemo(() => ({ isExpanded, name, path }), [isExpanded, name, path]);
174
+
175
+ return (
176
+ <FileTreeFolderContext.Provider value={folderContextValue}>
177
+ <Collapsible onOpenChange={handleOpenChange} open={isExpanded}>
178
+ <div className={cn("", className)} role="treeitem" tabIndex={0} {...props}>
179
+ <div
180
+ className={cn(
181
+ "flex w-full items-center gap-1 rounded px-2 py-1 text-start transition-colors hover:bg-muted/50",
182
+ isSelected && "bg-muted",
183
+ )}
184
+ >
185
+ <CollapsibleTrigger asChild>
186
+ <button
187
+ className="flex shrink-0 cursor-pointer items-center border-none bg-transparent p-0"
188
+ type="button"
189
+ >
190
+ <ChevronRightIcon
191
+ className={cn(
192
+ "size-4 shrink-0 text-muted-foreground transition-transform",
193
+ isExpanded && "rotate-90",
194
+ )}
195
+ data-rtl-flip
196
+ />
197
+ </button>
198
+ </CollapsibleTrigger>
199
+ <button
200
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-start"
201
+ onClick={handleSelect}
202
+ type="button"
203
+ >
204
+ <FileTreeIcon>
205
+ {isExpanded ? (
206
+ <FolderOpenIcon className="size-4 text-primary" />
207
+ ) : (
208
+ <FolderIcon className="size-4 text-primary" />
209
+ )}
210
+ </FileTreeIcon>
211
+ <FileTreeName>{name}</FileTreeName>
212
+ </button>
213
+ </div>
214
+ <CollapsibleContent>
215
+ <div className="ms-4 border-s ps-2">{children}</div>
216
+ </CollapsibleContent>
217
+ </div>
218
+ </Collapsible>
219
+ </FileTreeFolderContext.Provider>
220
+ );
221
+ };
222
+
223
+ interface FileTreeFileContextType {
224
+ path: string;
225
+ name: string;
226
+ }
227
+
228
+ const FileTreeFileContext = createContext<FileTreeFileContextType>({
229
+ name: "",
230
+ path: "",
231
+ });
232
+
233
+ export type FileTreeFileProps = HTMLAttributes<HTMLDivElement> & {
234
+ path: string;
235
+ name: string;
236
+ icon?: ReactNode;
237
+ };
238
+
239
+ export const FileTreeFile = ({
240
+ path,
241
+ name,
242
+ icon,
243
+ className,
244
+ children,
245
+ ...props
246
+ }: FileTreeFileProps) => {
247
+ const { selectedPath, onSelect } = useContext(FileTreeContext);
248
+ const isSelected = selectedPath === path;
249
+
250
+ const handleClick = useCallback(() => {
251
+ onSelect?.(path);
252
+ }, [onSelect, path]);
253
+
254
+ const handleKeyDown = useCallback(
255
+ (e: React.KeyboardEvent) => {
256
+ if (e.key === "Enter" || e.key === " ") {
257
+ onSelect?.(path);
258
+ }
259
+ },
260
+ [onSelect, path],
261
+ );
262
+
263
+ const fileContextValue = useMemo(() => ({ name, path }), [name, path]);
264
+
265
+ return (
266
+ <FileTreeFileContext.Provider value={fileContextValue}>
267
+ <div
268
+ className={cn(
269
+ "flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
270
+ isSelected && "bg-muted",
271
+ className,
272
+ )}
273
+ onClick={handleClick}
274
+ onKeyDown={handleKeyDown}
275
+ role="treeitem"
276
+ tabIndex={0}
277
+ {...props}
278
+ >
279
+ {children ?? (
280
+ <>
281
+ {/* Spacer for alignment */}
282
+ <span className="size-4 shrink-0" />
283
+ <FileTreeIcon>
284
+ {icon ?? <FileIcon className="size-4 text-muted-foreground" />}
285
+ </FileTreeIcon>
286
+ <FileTreeName>{name}</FileTreeName>
287
+ </>
288
+ )}
289
+ </div>
290
+ </FileTreeFileContext.Provider>
291
+ );
292
+ };
293
+
294
+ export type FileTreeActionsProps = HTMLAttributes<HTMLDivElement>;
295
+
296
+ const stopPropagation = (e: React.SyntheticEvent) => e.stopPropagation();
297
+
298
+ export const FileTreeActions = ({ className, children, ...props }: FileTreeActionsProps) => (
299
+ <div
300
+ className={cn("ms-auto flex items-center gap-1", className)}
301
+ onClick={stopPropagation}
302
+ onKeyDown={stopPropagation}
303
+ role="group"
304
+ {...props}
305
+ >
306
+ {children}
307
+ </div>
308
+ );
309
+
310
+ /** Asset-type glyphs (lucide, research 04 §4) — doc / code / sql / csv / image. */
311
+ export const PRODUCED_ASSET_ICONS: Record<ContextAssetType, LucideIcon> = {
312
+ markdown: FileTextIcon,
313
+ code: FileCode2Icon,
314
+ sql: DatabaseIcon,
315
+ csv: TableIcon,
316
+ image: ImageIcon,
317
+ };
318
+
319
+ /**
320
+ * The glyph for one asset.
321
+ *
322
+ * A FILE-BACKED asset (`source` set — a PDF, a deck, a video reaching the rail
323
+ * through `AssetPreview`'s `renderPreview` seam) carries a `type` from a closed
324
+ * five-member union that cannot describe it, so the type-keyed glyph above
325
+ * would lie: a PDF typed `code` would draw a code glyph. Its name and MIME do
326
+ * know, so those go through the shared resolver instead (ADR 0024 §6).
327
+ *
328
+ * An asset with inline `content` keeps the type-keyed glyph exactly as before —
329
+ * for those the author's `type` is the better answer, not a guess from a
330
+ * filename.
331
+ */
332
+ function assetIcon(asset: ContextAsset): LucideIcon {
333
+ return asset.source ? fileIconFor(asset.name, asset.mediaType) : PRODUCED_ASSET_ICONS[asset.type];
334
+ }
335
+
336
+ export type ProducedAssetTreeProps = Omit<
337
+ FileTreeProps,
338
+ "variant" | "onSelect" | "selectedPath"
339
+ > & {
340
+ /** The produced assets, in display order. */
341
+ assets: ContextAsset[];
342
+ /** Selected asset id (mirror `useContextPanel().state.selectedAsset?.id`). */
343
+ selectedId?: string;
344
+ /** Called with the chosen asset — wire to `useContextPanel().actions.openDetail`. */
345
+ onSelect?: (asset: ContextAsset) => void;
346
+ };
347
+
348
+ /**
349
+ * ProducedAssetTree — the document-variant preset (#193, research 04 §4):
350
+ * produced assets are documents, not source code, so the tree reads sans,
351
+ * box-free, with first-class asset-type icons. Pair with `ContextPanelSection`
352
+ * for the label; selection drives the ContextPanel drill-in.
353
+ */
354
+ export const ProducedAssetTree = forwardRef<HTMLDivElement, ProducedAssetTreeProps>(
355
+ function ProducedAssetTree({ assets, selectedId, onSelect, ...props }, ref) {
356
+ const assetsById = useMemo(() => new Map(assets.map((asset) => [asset.id, asset])), [assets]);
357
+ const handleSelect = useCallback(
358
+ (path: string) => {
359
+ const asset = assetsById.get(path);
360
+ if (asset) onSelect?.(asset);
361
+ },
362
+ [assetsById, onSelect],
363
+ );
364
+ return (
365
+ <FileTree
366
+ ref={ref}
367
+ variant="document"
368
+ selectedPath={selectedId}
369
+ onSelect={handleSelect}
370
+ {...props}
371
+ >
372
+ {assets.length === 0 ? (
373
+ <p className="px-2 py-1 text-body text-muted-foreground">No assets produced yet.</p>
374
+ ) : (
375
+ assets.map((asset) => {
376
+ const Icon = assetIcon(asset);
377
+ return (
378
+ <FileTreeFile key={asset.id} path={asset.id} name={asset.name}>
379
+ <FileTreeIcon>
380
+ <Icon aria-hidden="true" className="size-4 text-muted-foreground" />
381
+ </FileTreeIcon>
382
+ <FileTreeName>{asset.name}</FileTreeName>
383
+ </FileTreeFile>
384
+ );
385
+ })
386
+ )}
387
+ </FileTree>
388
+ );
389
+ },
390
+ );
@@ -0,0 +1,182 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, userEvent, within } from "storybook/test";
3
+ import { Badge } from "@elabs-ai/components-ui";
4
+ import { Gallery, type GalleryImage } from "./gallery";
5
+
6
+ /**
7
+ * Offline, deterministic placeholder image (inline SVG data URI) so stories
8
+ * render identically in CI / visual review without a network round-trip.
9
+ */
10
+ function photo(label: string, bg: string): string {
11
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600"><rect width="600" height="600" fill="${bg}"/><text x="300" y="312" font-family="system-ui,sans-serif" font-size="44" fill="#ffffff" text-anchor="middle">${label}</text></svg>`;
12
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
13
+ }
14
+
15
+ const CONCEPTS = [
16
+ {
17
+ title: "Fjord at dawn",
18
+ color: "#1f6f78",
19
+ alt: "Aurora reflected over a glacial fjord at dawn",
20
+ },
21
+ { title: "Desert monolith", color: "#8a5a44", alt: "A lone sandstone monolith under a noon sun" },
22
+ { title: "Neon arcade", color: "#3b4a6b", alt: "Rain-soaked arcade alley lit by neon signage" },
23
+ { title: "Coral shelf", color: "#2f7d6b", alt: "Sunlit coral shelf teeming with fish" },
24
+ { title: "Pine cathedral", color: "#4a6b3b", alt: "Tall pines forming a cathedral of light" },
25
+ { title: "Brass orrery", color: "#b08949", alt: "A brass orrery modelling the inner planets" },
26
+ { title: "Salt flats", color: "#6b6f78", alt: "Mirror-still salt flats after rain" },
27
+ { title: "Harbour fog", color: "#46618a", alt: "Fishing boats fading into harbour fog" },
28
+ { title: "Terraced rice", color: "#6b7d3b", alt: "Terraced rice paddies at golden hour" },
29
+ { title: "Obsidian cave", color: "#3a3550", alt: "Glassy obsidian cave with a shaft of light" },
30
+ { title: "Lavender rows", color: "#7d5a8a", alt: "Endless rows of lavender under a wide sky" },
31
+ { title: "Dune ridge", color: "#915b3c", alt: "Wind-carved dune ridge at sunset" },
32
+ ] as const;
33
+
34
+ function makeImage(i: number): GalleryImage {
35
+ const c = CONCEPTS[i % CONCEPTS.length];
36
+ return {
37
+ src: photo(c.title, c.color),
38
+ alt: c.alt,
39
+ title: c.title,
40
+ description: `${c.alt}. Rendered with the diffusion-xl model from a single text prompt.`,
41
+ meta: [
42
+ { label: "Model", value: "diffusion-xl" },
43
+ { label: "Dimensions", value: "1024 × 1024" },
44
+ { label: "Seed", value: String(40000 + i * 137) },
45
+ { label: "Steps", value: "40" },
46
+ ],
47
+ downloadName: `${c.title.toLowerCase().replace(/\s+/g, "-")}.png`,
48
+ };
49
+ }
50
+
51
+ const IMAGES: GalleryImage[] = Array.from({ length: 12 }, (_, i) => makeImage(i));
52
+
53
+ const meta = {
54
+ title: "AI/Gallery",
55
+ component: Gallery,
56
+ parameters: { layout: "padded" },
57
+ tags: ["autodocs"],
58
+ decorators: [(Story) => <div className="mx-auto max-w-2xl">{Story()}</div>],
59
+ } satisfies Meta<typeof Gallery>;
60
+ export default meta;
61
+
62
+ type Story = StoryObj<typeof meta>;
63
+
64
+ /** A single result renders as one framed image with an always-on expand button. */
65
+ export const SingleImage: Story = {
66
+ args: { images: IMAGES.slice(0, 1) },
67
+ };
68
+
69
+ /** A handful of results render as a thumbnail grid (no overflow yet). */
70
+ export const SmallGrid: Story = {
71
+ name: "Small grid (3)",
72
+ args: { images: IMAGES.slice(0, 3) },
73
+ };
74
+
75
+ /** Exactly `maxVisible` images fill the grid with no "+N more" tile. */
76
+ export const FullGrid: Story = {
77
+ name: "Full grid (exactly 5)",
78
+ args: { images: IMAGES.slice(0, 5) },
79
+ };
80
+
81
+ /** Beyond the cap, the last cell collapses into "+N more" and opens the carousel. */
82
+ export const Overflow: Story = {
83
+ name: "Overflow (+N more)",
84
+ args: { images: IMAGES },
85
+ play: async ({ canvasElement }) => {
86
+ const canvas = within(canvasElement);
87
+ const overflow = canvas.getByRole("button", { name: /show all 12 images/i });
88
+ await expect(canvas.getByText(/\+7 more/)).toBeInTheDocument();
89
+ await userEvent.click(overflow);
90
+ const dialog = await within(document.body).findByRole("dialog");
91
+ await expect(dialog).toBeInTheDocument();
92
+ await expect(within(dialog).getByRole("button", { name: /next slide/i })).toBeInTheDocument();
93
+ },
94
+ };
95
+
96
+ /** Images with only `alt` — the detail panel shows a graceful "No details". */
97
+ export const NoMetadata: Story = {
98
+ name: "No metadata",
99
+ args: {
100
+ images: CONCEPTS.slice(0, 4).map((c) => ({ src: photo(c.title, c.color), alt: c.alt })),
101
+ },
102
+ };
103
+
104
+ /** Long copy + many fields scroll inside the panel; the image stays put. */
105
+ export const LongContent: Story = {
106
+ name: "Long description + many fields",
107
+ args: {
108
+ images: [
109
+ {
110
+ ...makeImage(0),
111
+ description:
112
+ "A long-exposure render of a green aurora mirrored in glass-still fjord water at first light. The prompt asked for a low camera angle, a faint mist clinging to the waterline, and distant snow-capped peaks catching the earliest sun. Generated, upscaled, then colour-graded toward cool teal shadows and warm highlights.",
113
+ meta: [
114
+ { label: "Model", value: "diffusion-xl" },
115
+ { label: "Sampler", value: "DPM++ 2M Karras" },
116
+ { label: "Dimensions", value: "1024 × 1024" },
117
+ { label: "Upscaled to", value: "4096 × 4096" },
118
+ { label: "Seed", value: "40000" },
119
+ { label: "Steps", value: "40" },
120
+ { label: "Guidance", value: "7.5" },
121
+ { label: "Created", value: "2026-06-22" },
122
+ ],
123
+ },
124
+ ],
125
+ },
126
+ };
127
+
128
+ /** `renderMeta` replaces the default panel body with custom content. */
129
+ export const CustomMeta: Story = {
130
+ name: "Custom metadata (renderMeta)",
131
+ args: {
132
+ images: IMAGES.slice(0, 1),
133
+ renderMeta: (image) => (
134
+ <div className="flex flex-col gap-3">
135
+ <Badge>diffusion-xl</Badge>
136
+ <p className="text-body text-muted-foreground">{image.description}</p>
137
+ </div>
138
+ ),
139
+ },
140
+ };
141
+
142
+ /** `hideDownload` removes every download affordance (rights-restricted assets). */
143
+ export const HideDownload: Story = {
144
+ name: "Download hidden",
145
+ args: { images: IMAGES.slice(0, 3), hideDownload: true },
146
+ };
147
+
148
+ /**
149
+ * LOADING — the set is still being generated. `loading` renders skeleton tiles in
150
+ * the grid footprint (a status live region for AT) instead of the empty state, so
151
+ * the panel reserves its space and reads as "working" rather than "nothing here"
152
+ * (loading-states.md).
153
+ */
154
+ export const Loading: Story = {
155
+ name: "Loading (from empty)",
156
+ args: { images: [], loading: true, expectedCount: 6 },
157
+ play: async ({ canvasElement }) => {
158
+ const canvas = within(canvasElement);
159
+ // The live-region label is populated one tick after mount (ARIA22 —
160
+ // gallery.tsx mounts the region empty, then sets its text so AT announces
161
+ // the change), so wait for it with a retrying query (#288).
162
+ await expect(await canvas.findByText(/loading images/i)).toBeInTheDocument();
163
+ },
164
+ };
165
+
166
+ /**
167
+ * PARTIAL SET — images stream in one at a time. With `expectedCount`, the arrived
168
+ * tiles render alongside skeleton tiles for the ones still coming, so the grid
169
+ * fills in place with no reflow and no premature "+N more" collapse.
170
+ */
171
+ export const PartialSet: Story = {
172
+ name: "Partial set (arriving)",
173
+ args: { images: IMAGES.slice(0, 2), expectedCount: 6 },
174
+ play: async ({ canvasElement }) => {
175
+ const canvas = within(canvasElement);
176
+ await expect(canvas.getAllByRole("button", { name: /fjord|desert/i }).length).toBeGreaterThan(
177
+ 0,
178
+ );
179
+ // Still arriving → no overflow collapse yet.
180
+ await expect(canvas.queryByText(/more/i)).not.toBeInTheDocument();
181
+ },
182
+ };