@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,89 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { readSelectionWithin, SelectionToolbar } from "./selection-toolbar";
5
+
6
+ function mockSelection(node: Node | null, text: string, collapsed = false) {
7
+ const range = {
8
+ getBoundingClientRect: () => ({
9
+ top: 100,
10
+ left: 50,
11
+ width: text ? 80 : 0,
12
+ height: text ? 18 : 0,
13
+ right: 130,
14
+ bottom: 118,
15
+ x: 50,
16
+ y: 100,
17
+ toJSON: () => ({}),
18
+ }),
19
+ };
20
+ return {
21
+ isCollapsed: collapsed,
22
+ rangeCount: collapsed ? 0 : 1,
23
+ anchorNode: node,
24
+ focusNode: node,
25
+ toString: () => text,
26
+ getRangeAt: () => range,
27
+ } as unknown as Selection;
28
+ }
29
+
30
+ afterEach(() => vi.restoreAllMocks());
31
+
32
+ describe("readSelectionWithin", () => {
33
+ it("returns null for a collapsed selection", () => {
34
+ const container = document.createElement("div");
35
+ vi.spyOn(window, "getSelection").mockReturnValue(mockSelection(container, "", true));
36
+ expect(readSelectionWithin(container)).toBeNull();
37
+ });
38
+
39
+ it("returns a snapshot for a non-empty selection inside the container", () => {
40
+ const container = document.createElement("div");
41
+ const child = document.createElement("p");
42
+ container.appendChild(child);
43
+ vi.spyOn(window, "getSelection").mockReturnValue(mockSelection(child, "hello"));
44
+ const snap = readSelectionWithin(container);
45
+ expect(snap?.text).toBe("hello");
46
+ expect(snap?.rect.width).toBe(80);
47
+ });
48
+
49
+ it("returns null when the selection is outside the container", () => {
50
+ const container = document.createElement("div");
51
+ const outside = document.createElement("p");
52
+ vi.spyOn(window, "getSelection").mockReturnValue(mockSelection(outside, "hello"));
53
+ expect(readSelectionWithin(container)).toBeNull();
54
+ });
55
+ });
56
+
57
+ describe("SelectionToolbar", () => {
58
+ it("shows a Quote toolbar over a selection and calls onQuote", async () => {
59
+ const user = userEvent.setup();
60
+ const onQuote = vi.fn();
61
+ render(
62
+ <SelectionToolbar data-testid="region" onQuote={onQuote}>
63
+ <p>Quote me please, this is the transcript.</p>
64
+ </SelectionToolbar>,
65
+ );
66
+ const region = screen.getByTestId("region");
67
+ const para = screen.getByText(/Quote me please/);
68
+ vi.spyOn(window, "getSelection").mockReturnValue(mockSelection(para, "Quote me please"));
69
+
70
+ fireEvent.pointerUp(region);
71
+
72
+ const quote = await screen.findByRole("button", { name: /Quote/ });
73
+ await user.click(quote);
74
+ expect(onQuote).toHaveBeenCalledWith("Quote me please");
75
+ });
76
+
77
+ it("does not open when disabled", () => {
78
+ render(
79
+ <SelectionToolbar data-testid="region" disabled onQuote={vi.fn()}>
80
+ <p>Some text</p>
81
+ </SelectionToolbar>,
82
+ );
83
+ const region = screen.getByTestId("region");
84
+ const para = screen.getByText("Some text");
85
+ vi.spyOn(window, "getSelection").mockReturnValue(mockSelection(para, "Some text"));
86
+ fireEvent.pointerUp(region);
87
+ expect(screen.queryByRole("button", { name: /Quote/ })).not.toBeInTheDocument();
88
+ });
89
+ });
@@ -0,0 +1,192 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SelectionToolbar — a floating toolbar over selected transcript text.
5
+ *
6
+ * Wrap a transcript region; when the user selects text inside it, a small
7
+ * toolbar floats above the selection with a single default action: Quote.
8
+ * `onQuote(selectedText)` fires — the consumer prepends a `>`-prefixed
9
+ * blockquote into the composer. Positioned with the `@elabs-ai/components-ui` Popover
10
+ * primitives (a virtual anchor at the selection rect); dismisses when the
11
+ * selection collapses. Presentational only.
12
+ */
13
+
14
+ import {
15
+ forwardRef,
16
+ useCallback,
17
+ useEffect,
18
+ useRef,
19
+ useState,
20
+ type HTMLAttributes,
21
+ type ReactNode,
22
+ } from "react";
23
+ import { Quote } from "lucide-react";
24
+ import { Button, Popover, PopoverAnchor, PopoverContent } from "@elabs-ai/components-ui";
25
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
26
+ import { useLocale } from "@elabs-ai/components-ui";
27
+
28
+ /** A captured selection snapshot: the trimmed text + its viewport rect. */
29
+ export interface SelectionSnapshot {
30
+ text: string;
31
+ rect: { top: number; left: number; width: number; height: number };
32
+ }
33
+
34
+ function isEditableNode(node: Node | null): boolean {
35
+ let el: Node | null = node;
36
+ while (el) {
37
+ if (el instanceof HTMLElement) {
38
+ const tag = el.tagName;
39
+ if (tag === "INPUT" || tag === "TEXTAREA" || el.isContentEditable) return true;
40
+ }
41
+ el = el.parentNode;
42
+ }
43
+ return false;
44
+ }
45
+
46
+ /**
47
+ * Read the current selection if it is a non-empty range fully inside
48
+ * `container` (and not inside a form field / editor). Returns `null` otherwise.
49
+ * Exported for testing + custom integrations.
50
+ */
51
+ export function readSelectionWithin(container: HTMLElement | null): SelectionSnapshot | null {
52
+ if (!container || typeof window === "undefined") return null;
53
+ const selection = window.getSelection();
54
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
55
+
56
+ const text = selection.toString().trim();
57
+ if (text.length === 0) return null;
58
+
59
+ const { anchorNode, focusNode } = selection;
60
+ if (!anchorNode || !focusNode) return null;
61
+ if (!container.contains(anchorNode) || !container.contains(focusNode)) return null;
62
+ if (isEditableNode(anchorNode)) return null;
63
+
64
+ const rect = selection.getRangeAt(0).getBoundingClientRect();
65
+ if (rect.width <= 0 && rect.height <= 0) return null;
66
+
67
+ return {
68
+ text,
69
+ rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height },
70
+ };
71
+ }
72
+
73
+ export interface SelectionToolbarProps extends HTMLAttributes<HTMLDivElement> {
74
+ /** Called with the selected text when the user clicks Quote. */
75
+ onQuote?: (selectedText: string) => void;
76
+ /** Quote button label. @default "Quote" */
77
+ quoteLabel?: string;
78
+ /** Extra toolbar controls rendered after the Quote button. */
79
+ actions?: ReactNode;
80
+ /** Disable selection detection entirely. */
81
+ disabled?: boolean;
82
+ /** The transcript region to watch for selections. */
83
+ children: ReactNode;
84
+ }
85
+
86
+ /**
87
+ * Wraps a region and shows a floating Quote toolbar over selected text.
88
+ * The wrapper renders inline (`display: contents` is avoided — it's a real
89
+ * relative box) so the anchor + portal position correctly.
90
+ */
91
+ export const SelectionToolbar = forwardRef<HTMLDivElement, SelectionToolbarProps>(
92
+ function SelectionToolbar(
93
+ { onQuote, quoteLabel = "Quote", actions, disabled = false, className, children, ...props },
94
+ ref,
95
+ ) {
96
+ const { t } = useLocale();
97
+ const containerRef = useRef<HTMLDivElement | null>(null);
98
+ const [snapshot, setSnapshot] = useState<SelectionSnapshot | null>(null);
99
+ const open = snapshot !== null;
100
+
101
+ const setContainer = useCallback(
102
+ (node: HTMLDivElement | null) => {
103
+ containerRef.current = node;
104
+ if (typeof ref === "function") ref(node);
105
+ else if (ref) ref.current = node;
106
+ },
107
+ [ref],
108
+ );
109
+
110
+ const refresh = useCallback(() => {
111
+ if (disabled) return;
112
+ setSnapshot(readSelectionWithin(containerRef.current));
113
+ }, [disabled]);
114
+
115
+ const dismiss = useCallback(() => setSnapshot(null), []);
116
+
117
+ // Open after the user finishes selecting; close when the selection collapses.
118
+ useEffect(() => {
119
+ if (disabled || typeof document === "undefined") return;
120
+ const onSelectionChange = () => {
121
+ const sel = window.getSelection();
122
+ if (!sel || sel.isCollapsed || sel.toString().trim().length === 0) dismiss();
123
+ };
124
+ const onScroll = () => dismiss();
125
+ const onKeyDown = (e: KeyboardEvent) => {
126
+ if (e.key === "Escape") dismiss();
127
+ };
128
+ document.addEventListener("selectionchange", onSelectionChange);
129
+ window.addEventListener("scroll", onScroll, true);
130
+ document.addEventListener("keydown", onKeyDown);
131
+ return () => {
132
+ document.removeEventListener("selectionchange", onSelectionChange);
133
+ window.removeEventListener("scroll", onScroll, true);
134
+ document.removeEventListener("keydown", onKeyDown);
135
+ };
136
+ }, [disabled, dismiss]);
137
+
138
+ const handleQuote = () => {
139
+ if (snapshot) onQuote?.(snapshot.text);
140
+ dismiss();
141
+ };
142
+
143
+ const rect = snapshot?.rect;
144
+
145
+ return (
146
+ <div
147
+ ref={setContainer}
148
+ className={cn("relative", className)}
149
+ onPointerUp={refresh}
150
+ onKeyUp={refresh}
151
+ {...props}
152
+ >
153
+ {children}
154
+
155
+ <Popover open={open} onOpenChange={(next) => (next ? undefined : dismiss())}>
156
+ {/* Virtual anchor positioned at the selection rect (viewport coords). */}
157
+ <PopoverAnchor asChild>
158
+ <span
159
+ aria-hidden="true"
160
+ className="pointer-events-none fixed"
161
+ style={
162
+ rect
163
+ ? { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
164
+ : { top: 0, left: 0, width: 0, height: 0 }
165
+ }
166
+ />
167
+ </PopoverAnchor>
168
+ <PopoverContent
169
+ side="top"
170
+ align="center"
171
+ sideOffset={8}
172
+ // On open, let focus move to the Quote button so the action is
173
+ // keyboard-reachable (a11y: mouse-operable ⇒ keyboard-operable). The
174
+ // captured snapshot text drives the quote, so a grayed selection is
175
+ // fine. On close, DON'T return focus to the anchor (it's an
176
+ // aria-hidden virtual span) — prevent that jump.
177
+ onCloseAutoFocus={(e) => e.preventDefault()}
178
+ className="flex w-auto items-center gap-1 p-1"
179
+ role="toolbar"
180
+ aria-label={t("ai.selectionToolbar.label")}
181
+ >
182
+ <Button size="sm" variant="ghost" onClick={handleQuote}>
183
+ <Quote aria-hidden="true" className="size-4" />
184
+ {quoteLabel}
185
+ </Button>
186
+ {actions}
187
+ </PopoverContent>
188
+ </Popover>
189
+ </div>
190
+ );
191
+ },
192
+ );
@@ -0,0 +1,10 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { Shimmer } from "./shimmer";
3
+ const meta = {
4
+ title: "AI/Shimmer",
5
+ component: Shimmer,
6
+ parameters: { layout: "padded" },
7
+ } satisfies Meta<typeof Shimmer>;
8
+ export default meta;
9
+ type Story = StoryObj<typeof meta>;
10
+ export const Default: Story = { render: () => <Shimmer>Generating response…</Shimmer> };
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ import { useReducedMotion } from "@elabs-ai/components-tokens";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import type { MotionProps } from "motion/react";
6
+ import { motion } from "motion/react";
7
+ import type { CSSProperties, ElementType, JSX } from "react";
8
+ import { memo, useMemo } from "react";
9
+
10
+ type MotionHTMLProps = MotionProps & Record<string, unknown>;
11
+
12
+ // Cache motion components at module level to avoid creating during render
13
+ const motionComponentCache = new Map<
14
+ keyof JSX.IntrinsicElements,
15
+ React.ComponentType<MotionHTMLProps>
16
+ >();
17
+
18
+ const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
19
+ let component = motionComponentCache.get(element);
20
+ if (!component) {
21
+ component = motion.create(element);
22
+ motionComponentCache.set(element, component);
23
+ }
24
+ return component;
25
+ };
26
+
27
+ export interface TextShimmerProps {
28
+ children: string;
29
+ as?: ElementType;
30
+ className?: string;
31
+ duration?: number;
32
+ spread?: number;
33
+ }
34
+
35
+ const ShimmerComponent = ({
36
+ children,
37
+ as: Component = "p",
38
+ className,
39
+ duration = 2,
40
+ spread = 2,
41
+ }: TextShimmerProps) => {
42
+ const MotionComponent = getMotionComponent(Component as keyof JSX.IntrinsicElements);
43
+
44
+ // The CSS --motion-factor gate cannot reach this JS timeline; resolve the
45
+ // motion preference (user > OS) here and stop the infinite sweep when reduced.
46
+ const reducedMotion = useReducedMotion();
47
+
48
+ const dynamicSpread = useMemo(() => (children?.length ?? 0) * spread, [children, spread]);
49
+
50
+ return (
51
+ <MotionComponent
52
+ animate={{ backgroundPosition: "0% center" }}
53
+ className={cn(
54
+ "relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
55
+ "[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
56
+ className,
57
+ )}
58
+ initial={{ backgroundPosition: "100% center" }}
59
+ style={
60
+ {
61
+ "--spread": `${dynamicSpread}px`,
62
+ backgroundImage:
63
+ "var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
64
+ } as CSSProperties
65
+ }
66
+ transition={
67
+ reducedMotion
68
+ ? { duration: 0, repeat: 0 }
69
+ : {
70
+ duration,
71
+ ease: "linear",
72
+ repeat: Number.POSITIVE_INFINITY,
73
+ }
74
+ }
75
+ >
76
+ {children}
77
+ </MotionComponent>
78
+ );
79
+ };
80
+
81
+ export const Shimmer = memo(ShimmerComponent);
@@ -0,0 +1,19 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { Snippet, SnippetAddon, SnippetCopyButton, SnippetInput } from "./snippet";
3
+ const meta = {
4
+ title: "AI/Snippet",
5
+ component: Snippet,
6
+ parameters: { layout: "padded" },
7
+ } satisfies Meta<typeof Snippet>;
8
+ export default meta;
9
+ type Story = StoryObj<typeof meta>;
10
+ export const Default: Story = {
11
+ render: () => (
12
+ <Snippet code="npm i @elabs-ai/components-ui" className="max-w-sm">
13
+ <SnippetInput />
14
+ <SnippetAddon align="inline-end">
15
+ <SnippetCopyButton />
16
+ </SnippetAddon>
17
+ </Snippet>
18
+ ),
19
+ };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * snippet.test.tsx — smoke + copy-contract lock for the one-line snippet (#59).
3
+ *
4
+ * `Snippet` is the "here is the command, take it" surface. Two contracts:
5
+ * the code travels through context (so `SnippetInput` and `SnippetCopyButton`
6
+ * can never disagree about WHAT gets copied), and the icon-only copy control
7
+ * carries an accessible name from the locale seam — never a bare glyph.
8
+ */
9
+ import { describe, expect, it, vi } from "vitest";
10
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
11
+ import { Snippet, SnippetAddon, SnippetCopyButton, SnippetInput, SnippetText } from "./snippet";
12
+
13
+ const CODE = "pnpm add @elabs-ai/components-ui";
14
+
15
+ /** Install a clipboard stub for one test; returns the writeText spy. */
16
+ function stubClipboard(impl: () => Promise<void> = () => Promise.resolve()) {
17
+ const writeText = vi.fn(impl);
18
+ Object.defineProperty(navigator, "clipboard", {
19
+ value: { writeText },
20
+ configurable: true,
21
+ writable: true,
22
+ });
23
+ return writeText;
24
+ }
25
+
26
+ function renderSnippet(extra?: React.ReactNode) {
27
+ return render(
28
+ <Snippet code={CODE}>
29
+ <SnippetInput />
30
+ <SnippetAddon align="inline-end">
31
+ <SnippetCopyButton />
32
+ {extra}
33
+ </SnippetAddon>
34
+ </Snippet>,
35
+ );
36
+ }
37
+
38
+ describe("Snippet — the code channel", () => {
39
+ it("renders the code in a read-only field (selectable, never editable)", () => {
40
+ renderSnippet();
41
+ const field = screen.getByRole("textbox");
42
+ expect(field).toHaveValue(CODE);
43
+ expect(field).toHaveAttribute("readonly");
44
+ });
45
+
46
+ it("renders an optional prompt prefix via SnippetText", () => {
47
+ render(
48
+ <Snippet code={CODE}>
49
+ <SnippetAddon align="inline-start">
50
+ <SnippetText>$</SnippetText>
51
+ </SnippetAddon>
52
+ <SnippetInput />
53
+ </Snippet>,
54
+ );
55
+ expect(screen.getByText("$")).toBeInTheDocument();
56
+ });
57
+
58
+ it("uses a monospace face for the snippet frame", () => {
59
+ const { container } = renderSnippet();
60
+ expect(container.querySelector(".font-mono")).not.toBeNull();
61
+ });
62
+ });
63
+
64
+ describe("SnippetCopyButton", () => {
65
+ it("is an icon-only control with an accessible name from the locale seam", () => {
66
+ stubClipboard();
67
+ renderSnippet();
68
+ expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
69
+ });
70
+
71
+ it("copies the SAME code the field shows (both read one context)", async () => {
72
+ const writeText = stubClipboard();
73
+ renderSnippet();
74
+ fireEvent.click(screen.getByRole("button", { name: "Copy" }));
75
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith(CODE));
76
+ });
77
+
78
+ it("reports a clipboard rejection through onError instead of throwing", async () => {
79
+ stubClipboard(() => Promise.reject(new Error("denied")));
80
+ const onError = vi.fn();
81
+ render(
82
+ <Snippet code={CODE}>
83
+ <SnippetInput />
84
+ <SnippetCopyButton onError={onError} />
85
+ </Snippet>,
86
+ );
87
+ fireEvent.click(screen.getByRole("button", { name: "Copy" }));
88
+ await waitFor(() => expect(onError).toHaveBeenCalled());
89
+ });
90
+
91
+ it("reports an UNAVAILABLE clipboard through onError (insecure context / old browser)", () => {
92
+ Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
93
+ const onError = vi.fn();
94
+ render(
95
+ <Snippet code={CODE}>
96
+ <SnippetInput />
97
+ <SnippetCopyButton onError={onError} />
98
+ </Snippet>,
99
+ );
100
+ fireEvent.click(screen.getByRole("button", { name: "Copy" }));
101
+ expect(onError).toHaveBeenCalled();
102
+ });
103
+
104
+ it("fires onCopy once the write resolves", async () => {
105
+ stubClipboard();
106
+ const onCopy = vi.fn();
107
+ render(
108
+ <Snippet code={CODE}>
109
+ <SnippetInput />
110
+ <SnippetCopyButton onCopy={onCopy} />
111
+ </Snippet>,
112
+ );
113
+ fireEvent.click(screen.getByRole("button", { name: "Copy" }));
114
+ await waitFor(() => expect(onCopy).toHaveBeenCalledTimes(1));
115
+ });
116
+ });
@@ -0,0 +1,138 @@
1
+ "use client";
2
+
3
+ import {
4
+ InputGroup,
5
+ InputGroupAddon,
6
+ InputGroupButton,
7
+ InputGroupInput,
8
+ InputGroupText,
9
+ } from "@elabs-ai/components-ui";
10
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
11
+ import { useLocale } from "@elabs-ai/components-ui";
12
+ import { CheckIcon, CopyIcon } from "lucide-react";
13
+ import type { ComponentProps } from "react";
14
+ import {
15
+ createContext,
16
+ useCallback,
17
+ useContext,
18
+ useEffect,
19
+ useMemo,
20
+ useRef,
21
+ useState,
22
+ } from "react";
23
+
24
+ interface SnippetContextType {
25
+ code: string;
26
+ }
27
+
28
+ const SnippetContext = createContext<SnippetContextType>({
29
+ code: "",
30
+ });
31
+
32
+ export type SnippetProps = ComponentProps<typeof InputGroup> & {
33
+ code: string;
34
+ };
35
+
36
+ export const Snippet = ({ code, className, children, ...props }: SnippetProps) => {
37
+ const contextValue = useMemo(() => ({ code }), [code]);
38
+
39
+ return (
40
+ <SnippetContext.Provider value={contextValue}>
41
+ <InputGroup className={cn("font-mono", className)} {...props}>
42
+ {children}
43
+ </InputGroup>
44
+ </SnippetContext.Provider>
45
+ );
46
+ };
47
+
48
+ export type SnippetAddonProps = ComponentProps<typeof InputGroupAddon>;
49
+
50
+ export const SnippetAddon = (props: SnippetAddonProps) => <InputGroupAddon {...props} />;
51
+
52
+ export type SnippetTextProps = ComponentProps<typeof InputGroupText>;
53
+
54
+ export const SnippetText = ({ className, ...props }: SnippetTextProps) => (
55
+ <InputGroupText className={cn("ps-2 font-normal text-muted-foreground", className)} {...props} />
56
+ );
57
+
58
+ export type SnippetInputProps = Omit<ComponentProps<typeof InputGroupInput>, "readOnly" | "value">;
59
+
60
+ export const SnippetInput = ({ className, ...props }: SnippetInputProps) => {
61
+ const { code } = useContext(SnippetContext);
62
+
63
+ return (
64
+ <InputGroupInput
65
+ className={cn("text-foreground", className)}
66
+ readOnly
67
+ value={code}
68
+ {...props}
69
+ />
70
+ );
71
+ };
72
+
73
+ export type SnippetCopyButtonProps = ComponentProps<typeof InputGroupButton> & {
74
+ onCopy?: () => void;
75
+ onError?: (error: Error) => void;
76
+ timeout?: number;
77
+ };
78
+
79
+ export const SnippetCopyButton = ({
80
+ onCopy,
81
+ onError,
82
+ timeout = 2000,
83
+ children,
84
+ className,
85
+ ...props
86
+ }: SnippetCopyButtonProps) => {
87
+ const { t } = useLocale();
88
+ const [isCopied, setIsCopied] = useState(false);
89
+ const timeoutRef = useRef<number>(0);
90
+ const { code } = useContext(SnippetContext);
91
+
92
+ const copyToClipboard = useCallback(async () => {
93
+ if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
94
+ onError?.(new Error("Clipboard API not available"));
95
+ return;
96
+ }
97
+
98
+ try {
99
+ if (!isCopied) {
100
+ await navigator.clipboard.writeText(code);
101
+ setIsCopied(true);
102
+ onCopy?.();
103
+ timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
104
+ }
105
+ } catch (error) {
106
+ onError?.(error as Error);
107
+ }
108
+ }, [code, onCopy, onError, timeout, isCopied]);
109
+
110
+ useEffect(
111
+ () => () => {
112
+ window.clearTimeout(timeoutRef.current);
113
+ },
114
+ [],
115
+ );
116
+
117
+ const Icon = isCopied ? CheckIcon : CopyIcon;
118
+
119
+ return (
120
+ <InputGroupButton
121
+ aria-label={t("copy")}
122
+ className={className}
123
+ onClick={copyToClipboard}
124
+ size="icon-sm"
125
+ title={t("copy")}
126
+ {...props}
127
+ >
128
+ {children ?? (
129
+ <span
130
+ key={isCopied ? "check" : "copy"}
131
+ className="flex animate-in fade-in-0 zoom-in-95 duration-fast ease-entrance"
132
+ >
133
+ <Icon className="size-3.5" size={14} />
134
+ </span>
135
+ )}
136
+ </InputGroupButton>
137
+ );
138
+ };
@@ -0,0 +1,34 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { Source, SourceList, Sources, SourcesContent, SourcesTrigger } from "./sources";
3
+ const meta = {
4
+ title: "AI/Sources",
5
+ component: Sources,
6
+ parameters: { layout: "padded" },
7
+ } satisfies Meta<typeof Sources>;
8
+ export default meta;
9
+ type Story = StoryObj<typeof meta>;
10
+ export const Default: Story = {
11
+ render: () => (
12
+ <Sources>
13
+ <SourcesTrigger count={2} />
14
+ <SourcesContent>
15
+ <Source href="https://docs.acme.com" title="Acme Docs" />
16
+ <Source href="https://ci.acme.com" title="CI run #42" />
17
+ </SourcesContent>
18
+ </Sources>
19
+ ),
20
+ };
21
+ // SourceList — the per-answer grounding footer preset (#191, research 11 §B.4).
22
+ export const GroundingFooter: Story = {
23
+ name: "SourceList",
24
+ render: () => (
25
+ <SourceList
26
+ defaultOpen
27
+ sources={[
28
+ { href: "https://warehouse.acme.com/q3-retention", title: "Q3 retention cohort" },
29
+ { href: "https://crm.acme.com/renewals", title: "Renewal desk log" },
30
+ { href: "https://docs.acme.com/churn", title: "Churn playbook" },
31
+ ]}
32
+ />
33
+ ),
34
+ };