@runtypelabs/persona 3.17.0 → 3.19.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 (61) hide show
  1. package/README.md +143 -1
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-HPZY7oAI.d.cts → types-cwY5HaFD.d.cts} +25 -0
  5. package/dist/animations/{types-HPZY7oAI.d.ts → types-cwY5HaFD.d.ts} +25 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/index.cjs +47 -47
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +580 -4
  11. package/dist/index.d.ts +580 -4
  12. package/dist/index.global.js +102 -1636
  13. package/dist/index.global.js.map +1 -1
  14. package/dist/index.js +45 -45
  15. package/dist/index.js.map +1 -1
  16. package/dist/theme-editor.cjs +2844 -752
  17. package/dist/theme-editor.d.cts +337 -1
  18. package/dist/theme-editor.d.ts +337 -1
  19. package/dist/theme-editor.js +2958 -752
  20. package/dist/theme-reference.cjs +1 -1
  21. package/dist/theme-reference.d.cts +14 -0
  22. package/dist/theme-reference.d.ts +14 -0
  23. package/dist/widget.css +780 -0
  24. package/package.json +1 -1
  25. package/src/client.test.ts +134 -0
  26. package/src/client.ts +71 -0
  27. package/src/components/ask-user-question-bubble.test.ts +583 -0
  28. package/src/components/ask-user-question-bubble.ts +924 -0
  29. package/src/components/composer-builder.test.ts +52 -0
  30. package/src/components/composer-builder.ts +67 -490
  31. package/src/components/composer-parts.test.ts +152 -0
  32. package/src/components/composer-parts.ts +452 -0
  33. package/src/components/header-builder.ts +22 -299
  34. package/src/components/header-parts.ts +360 -0
  35. package/src/components/messages.ts +33 -1
  36. package/src/components/panel.test.ts +61 -0
  37. package/src/components/panel.ts +303 -9
  38. package/src/components/pill-composer-builder.test.ts +85 -0
  39. package/src/components/pill-composer-builder.ts +183 -0
  40. package/src/defaults.ts +21 -0
  41. package/src/index.ts +20 -1
  42. package/src/plugins/types.ts +57 -0
  43. package/src/runtime/init.ts +4 -2
  44. package/src/runtime/persist-state.test.ts +152 -0
  45. package/src/session.test.ts +183 -0
  46. package/src/session.ts +242 -3
  47. package/src/styles/widget.css +780 -0
  48. package/src/types/theme.ts +15 -0
  49. package/src/types.ts +271 -1
  50. package/src/ui.ask-user-question-plugin.test.ts +649 -0
  51. package/src/ui.component-directive.test.ts +183 -0
  52. package/src/ui.composer-bar.test.ts +1009 -0
  53. package/src/ui.ts +1439 -76
  54. package/src/utils/attachment-manager.ts +1 -1
  55. package/src/utils/dock.test.ts +45 -0
  56. package/src/utils/dock.ts +3 -0
  57. package/src/utils/icons.ts +314 -58
  58. package/src/utils/storage.ts +10 -2
  59. package/src/utils/stream-animation.ts +7 -2
  60. package/src/utils/theme.test.ts +36 -0
  61. package/src/utils/tokens.ts +23 -0
@@ -66,7 +66,7 @@ function getFileIconName(mimeType: string): string {
66
66
  if (mimeType.startsWith('text/')) return 'file-text';
67
67
  if (mimeType.includes('word')) return 'file-text';
68
68
  if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return 'file-spreadsheet';
69
- if (mimeType === 'application/json') return 'file-json';
69
+ if (mimeType === 'application/json') return 'file-code';
70
70
  return 'file';
71
71
  }
72
72
 
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isComposerBarMountMode, isDockedMountMode } from "./dock";
3
+ import type { AgentWidgetConfig } from "../types";
4
+
5
+ describe("isDockedMountMode", () => {
6
+ it("returns true for mountMode: 'docked'", () => {
7
+ const config: AgentWidgetConfig = { apiUrl: "/api", launcher: { mountMode: "docked" } };
8
+ expect(isDockedMountMode(config)).toBe(true);
9
+ });
10
+
11
+ it("returns false for default and other mount modes", () => {
12
+ expect(isDockedMountMode(undefined)).toBe(false);
13
+ expect(isDockedMountMode({ apiUrl: "/api" } as AgentWidgetConfig)).toBe(false);
14
+ expect(
15
+ isDockedMountMode({ apiUrl: "/api", launcher: { mountMode: "composer-bar" } } as AgentWidgetConfig)
16
+ ).toBe(false);
17
+ });
18
+ });
19
+
20
+ describe("isComposerBarMountMode", () => {
21
+ it("returns true for mountMode: 'composer-bar'", () => {
22
+ const config: AgentWidgetConfig = {
23
+ apiUrl: "/api",
24
+ launcher: { mountMode: "composer-bar" },
25
+ };
26
+ expect(isComposerBarMountMode(config)).toBe(true);
27
+ });
28
+
29
+ it("returns false for default, floating, and docked modes", () => {
30
+ expect(isComposerBarMountMode(undefined)).toBe(false);
31
+ expect(isComposerBarMountMode({ apiUrl: "/api" } as AgentWidgetConfig)).toBe(false);
32
+ expect(
33
+ isComposerBarMountMode({
34
+ apiUrl: "/api",
35
+ launcher: { mountMode: "floating" },
36
+ } as AgentWidgetConfig)
37
+ ).toBe(false);
38
+ expect(
39
+ isComposerBarMountMode({
40
+ apiUrl: "/api",
41
+ launcher: { mountMode: "docked" },
42
+ } as AgentWidgetConfig)
43
+ ).toBe(false);
44
+ });
45
+ });
package/src/utils/dock.ts CHANGED
@@ -10,6 +10,9 @@ const DEFAULT_DOCK_CONFIG: Required<AgentWidgetDockConfig> = {
10
10
  export const isDockedMountMode = (config?: AgentWidgetConfig): boolean =>
11
11
  (config?.launcher?.mountMode ?? "floating") === "docked";
12
12
 
13
+ export const isComposerBarMountMode = (config?: AgentWidgetConfig): boolean =>
14
+ (config?.launcher?.mountMode ?? "floating") === "composer-bar";
15
+
13
16
  /**
14
17
  * Resolved dock layout. For `reveal: "resize"`, when the panel is closed the dock column is `0px`.
15
18
  * For `reveal: "overlay"`, the panel overlays with `transform`. For `reveal: "push"`, a sliding track
@@ -1,58 +1,324 @@
1
- import * as icons from "lucide";
2
1
  import type { IconNode } from "lucide";
2
+ import {
3
+ // ---------- Mandatory (referenced as string literals in widget source) ----------
4
+ Activity,
5
+ ArrowDown,
6
+ ArrowUp,
7
+ ArrowUpRight,
8
+ Bot,
9
+ ChevronDown,
10
+ ChevronUp,
11
+ ChevronRight,
12
+ ChevronLeft,
13
+ Check,
14
+ Clipboard,
15
+ ClipboardCopy,
16
+ Copy,
17
+ File as FileIcon,
18
+ FileCode,
19
+ FileSpreadsheet,
20
+ FileText,
21
+ ImagePlus,
22
+ Loader,
23
+ LoaderCircle,
24
+ Mic,
25
+ Paperclip,
26
+ RefreshCw,
27
+ Search,
28
+ Send,
29
+ ShieldAlert,
30
+ ShieldCheck,
31
+ ShieldX,
32
+ Square,
33
+ ThumbsDown,
34
+ ThumbsUp,
35
+ Upload,
36
+ Volume2,
37
+ X,
38
+ // ---------- Forms / inputs ----------
39
+ User,
40
+ Mail,
41
+ Phone,
42
+ Calendar,
43
+ Clock,
44
+ Building,
45
+ MapPin,
46
+ Lock,
47
+ Key,
48
+ CreditCard,
49
+ AtSign,
50
+ Hash,
51
+ Globe,
52
+ Link,
53
+ // ---------- Status / feedback ----------
54
+ CircleCheck,
55
+ CircleX,
56
+ TriangleAlert,
57
+ Info,
58
+ Ban,
59
+ Shield,
60
+ // ---------- Navigation ----------
61
+ ArrowLeft,
62
+ ArrowRight,
63
+ ExternalLink,
64
+ Ellipsis,
65
+ EllipsisVertical,
66
+ Menu,
67
+ House,
68
+ // ---------- Actions ----------
69
+ Plus,
70
+ Minus,
71
+ Pencil,
72
+ Trash,
73
+ Trash2,
74
+ Save,
75
+ Download,
76
+ Share,
77
+ Funnel,
78
+ Settings,
79
+ RotateCw,
80
+ Maximize,
81
+ Minimize,
82
+ // ---------- Commerce ----------
83
+ ShoppingCart,
84
+ ShoppingBag,
85
+ Package,
86
+ Truck,
87
+ Tag,
88
+ Gift,
89
+ Receipt,
90
+ Wallet,
91
+ Store,
92
+ DollarSign,
93
+ Percent,
94
+ // ---------- Media ----------
95
+ Play,
96
+ Pause,
97
+ VolumeX,
98
+ Camera,
99
+ Image as ImageIcon,
100
+ Film,
101
+ Headphones,
102
+ // ---------- Social / Comms ----------
103
+ MessageCircle,
104
+ MessageSquare,
105
+ Bell,
106
+ Heart,
107
+ Star,
108
+ Eye,
109
+ EyeOff,
110
+ Bookmark,
111
+ // ---------- Time ----------
112
+ CalendarDays,
113
+ History,
114
+ Timer,
115
+ // ---------- Files ----------
116
+ Folder,
117
+ FolderOpen,
118
+ Files,
119
+ // ---------- Decorative ----------
120
+ Sparkles,
121
+ Zap,
122
+ Sun,
123
+ Moon,
124
+ Flag,
125
+ // ---------- Devices ----------
126
+ Monitor,
127
+ Smartphone,
128
+ } from "lucide";
3
129
 
4
130
  /**
5
- * Renders a Lucide icon as an inline SVG element
6
- * This approach requires no CSS and works on any page
7
- *
8
- * @param iconName - The Lucide icon name in kebab-case (e.g., "arrow-up")
9
- * @param size - The size of the icon (default: 24)
10
- * @param color - The stroke color (default: "currentColor")
11
- * @param strokeWidth - The stroke width (default: 2)
12
- * @returns SVGElement or null if icon not found
131
+ * Curated registry of lucide icons available to `renderLucideIcon`.
132
+ *
133
+ * The widget used to do `import * as icons from "lucide"` and look up
134
+ * icons dynamically by string. That defeated tree-shaking, so the IIFE
135
+ * (CDN/script-tag) bundle shipped all 1640 lucide icons (~400KB of icon
136
+ * data) regardless of which we actually used. This explicit registry
137
+ * lets the bundler drop any icon not listed here.
138
+ *
139
+ * Trade-off: `renderLucideIcon(name)` is now a *closed set*. Names not
140
+ * in this map return `null` and log a warning, exactly as a typo did
141
+ * before. The registry is intentionally generous (~110 icons) so that
142
+ * custom `ComponentRenderer` authors rarely hit a missing-icon dead end.
143
+ *
144
+ * To add icons: add a named import above and a row in `LUCIDE_ICONS`,
145
+ * keyed by the lucide kebab-case name (matches their filename and
146
+ * https://lucide.dev/icons).
147
+ *
148
+ * See `packages/widget/docs/icon-registry-shortlist.md` for the full
149
+ * curation rationale and which icons were considered but excluded.
150
+ */
151
+ const LUCIDE_ICONS = {
152
+ // Mandatory
153
+ "activity": Activity,
154
+ "arrow-down": ArrowDown,
155
+ "arrow-up": ArrowUp,
156
+ "arrow-up-right": ArrowUpRight,
157
+ "bot": Bot,
158
+ "chevron-down": ChevronDown,
159
+ "chevron-up": ChevronUp,
160
+ "chevron-right": ChevronRight,
161
+ "chevron-left": ChevronLeft,
162
+ "check": Check,
163
+ "clipboard": Clipboard,
164
+ "clipboard-copy": ClipboardCopy,
165
+ "copy": Copy,
166
+ "file": FileIcon,
167
+ "file-code": FileCode,
168
+ "file-spreadsheet": FileSpreadsheet,
169
+ "file-text": FileText,
170
+ "image-plus": ImagePlus,
171
+ "loader": Loader,
172
+ "loader-circle": LoaderCircle,
173
+ "mic": Mic,
174
+ "paperclip": Paperclip,
175
+ "refresh-cw": RefreshCw,
176
+ "search": Search,
177
+ "send": Send,
178
+ "shield-alert": ShieldAlert,
179
+ "shield-check": ShieldCheck,
180
+ "shield-x": ShieldX,
181
+ "square": Square,
182
+ "thumbs-down": ThumbsDown,
183
+ "thumbs-up": ThumbsUp,
184
+ "upload": Upload,
185
+ "volume-2": Volume2,
186
+ "x": X,
187
+ // Forms / inputs
188
+ "user": User,
189
+ "mail": Mail,
190
+ "phone": Phone,
191
+ "calendar": Calendar,
192
+ "clock": Clock,
193
+ "building": Building,
194
+ "map-pin": MapPin,
195
+ "lock": Lock,
196
+ "key": Key,
197
+ "credit-card": CreditCard,
198
+ "at-sign": AtSign,
199
+ "hash": Hash,
200
+ "globe": Globe,
201
+ "link": Link,
202
+ // Status / feedback
203
+ "circle-check": CircleCheck,
204
+ "circle-x": CircleX,
205
+ "triangle-alert": TriangleAlert,
206
+ "info": Info,
207
+ "ban": Ban,
208
+ "shield": Shield,
209
+ // Navigation
210
+ "arrow-left": ArrowLeft,
211
+ "arrow-right": ArrowRight,
212
+ "external-link": ExternalLink,
213
+ "ellipsis": Ellipsis,
214
+ "ellipsis-vertical": EllipsisVertical,
215
+ "menu": Menu,
216
+ "house": House,
217
+ // Actions
218
+ "plus": Plus,
219
+ "minus": Minus,
220
+ "pencil": Pencil,
221
+ "trash": Trash,
222
+ "trash-2": Trash2,
223
+ "save": Save,
224
+ "download": Download,
225
+ "share": Share,
226
+ "funnel": Funnel,
227
+ "settings": Settings,
228
+ "rotate-cw": RotateCw,
229
+ "maximize": Maximize,
230
+ "minimize": Minimize,
231
+ // Commerce
232
+ "shopping-cart": ShoppingCart,
233
+ "shopping-bag": ShoppingBag,
234
+ "package": Package,
235
+ "truck": Truck,
236
+ "tag": Tag,
237
+ "gift": Gift,
238
+ "receipt": Receipt,
239
+ "wallet": Wallet,
240
+ "store": Store,
241
+ "dollar-sign": DollarSign,
242
+ "percent": Percent,
243
+ // Media
244
+ "play": Play,
245
+ "pause": Pause,
246
+ "volume-x": VolumeX,
247
+ "camera": Camera,
248
+ "image": ImageIcon,
249
+ "film": Film,
250
+ "headphones": Headphones,
251
+ // Social / Comms
252
+ "message-circle": MessageCircle,
253
+ "message-square": MessageSquare,
254
+ "bell": Bell,
255
+ "heart": Heart,
256
+ "star": Star,
257
+ "eye": Eye,
258
+ "eye-off": EyeOff,
259
+ "bookmark": Bookmark,
260
+ // Time
261
+ "calendar-days": CalendarDays,
262
+ "history": History,
263
+ "timer": Timer,
264
+ // Files
265
+ "folder": Folder,
266
+ "folder-open": FolderOpen,
267
+ "files": Files,
268
+ // Decorative
269
+ "sparkles": Sparkles,
270
+ "zap": Zap,
271
+ "sun": Sun,
272
+ "moon": Moon,
273
+ "flag": Flag,
274
+ // Devices
275
+ "monitor": Monitor,
276
+ "smartphone": Smartphone,
277
+ } as const satisfies Record<string, IconNode>;
278
+
279
+ /**
280
+ * Names of lucide icons that ship with the widget. Names not in this
281
+ * union return `null` from `renderLucideIcon` (with a console warning).
282
+ */
283
+ export type IconName = keyof typeof LUCIDE_ICONS;
284
+
285
+ /**
286
+ * Renders a lucide icon as an inline SVG element. Works inside Shadow
287
+ * DOM and requires no CSS.
288
+ *
289
+ * @param iconName - A lucide kebab-case name from the registry. See
290
+ * `IconName` for the full list, or `docs/icon-registry-shortlist.md`
291
+ * for rationale.
292
+ * @param size - The size in pixels (number) or any CSS length string.
293
+ * @param color - Stroke color (default: "currentColor").
294
+ * @param strokeWidth - Stroke width (default: 2).
295
+ * @returns SVGElement, or null if the name is not in the registry.
13
296
  */
14
297
  export const renderLucideIcon = (
15
- iconName: string,
298
+ iconName: IconName | (string & {}),
16
299
  size: number | string = 24,
17
300
  color: string = "currentColor",
18
301
  strokeWidth: number = 2
19
302
  ): SVGElement | null => {
20
- try {
21
- // Convert kebab-case to PascalCase (e.g., "arrow-up" -> "ArrowUp")
22
- const pascalName = iconName
23
- .split("-")
24
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
25
- .join("");
26
-
27
- // Lucide's icons object contains IconNode data directly, not functions
28
- const iconData = (icons as unknown as Record<string, IconNode>)[pascalName] as IconNode;
29
-
30
- if (!iconData) {
31
- console.warn(`Lucide icon "${iconName}" not found (tried "${pascalName}"). Available icons: https://lucide.dev/icons`);
32
- return null;
33
- }
34
-
35
- return createSvgFromIconData(iconData, size, color, strokeWidth);
36
- } catch (error) {
37
- console.warn(`Failed to render Lucide icon "${iconName}":`, error);
303
+ const iconData = (LUCIDE_ICONS as Record<string, IconNode | undefined>)[iconName];
304
+ if (!iconData) {
305
+ console.warn(
306
+ `Lucide icon "${iconName}" is not in the Persona registry. ` +
307
+ `Add it to packages/widget/src/utils/icons.ts (see docs/icon-registry-shortlist.md).`
308
+ );
38
309
  return null;
39
310
  }
311
+ return createSvgFromIconData(iconData, size, color, strokeWidth);
40
312
  };
41
313
 
42
- /**
43
- * Helper function to create SVG from IconNode data
44
- */
45
314
  function createSvgFromIconData(
46
315
  iconData: IconNode,
47
316
  size: number | string,
48
317
  color: string,
49
318
  strokeWidth: number
50
319
  ): SVGElement | null {
51
- if (!iconData || !Array.isArray(iconData)) {
52
- return null;
53
- }
320
+ if (!Array.isArray(iconData)) return null;
54
321
 
55
- // Create SVG element
56
322
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
57
323
  svg.setAttribute("width", String(size));
58
324
  svg.setAttribute("height", String(size));
@@ -63,30 +329,20 @@ function createSvgFromIconData(
63
329
  svg.setAttribute("stroke-linecap", "round");
64
330
  svg.setAttribute("stroke-linejoin", "round");
65
331
  svg.setAttribute("aria-hidden", "true");
66
-
67
- // Render elements from icon data
68
- // IconNode format: [["path", {"d": "..."}], ["rect", {"x": "...", "y": "..."}], ...]
332
+
333
+ // IconNode shape: [["path", {"d": "..."}], ["circle", {"cx": "..."}], ...]
69
334
  iconData.forEach((elementData) => {
70
- if (Array.isArray(elementData) && elementData.length >= 2) {
71
- const tagName = elementData[0] as string;
72
- const attrs = elementData[1] as Record<string, string>;
73
-
74
- if (attrs) {
75
- // Create the appropriate SVG element (path, rect, circle, ellipse, line, etc.)
76
- const element = document.createElementNS("http://www.w3.org/2000/svg", tagName);
77
-
78
- // Apply all attributes, but skip 'stroke' (we want to use the parent SVG's stroke for consistent coloring)
79
- Object.entries(attrs).forEach(([key, value]) => {
80
- if (key !== "stroke") {
81
- element.setAttribute(key, String(value));
82
- }
83
- });
84
-
85
- svg.appendChild(element);
86
- }
87
- }
335
+ if (!Array.isArray(elementData) || elementData.length < 2) return;
336
+ const tagName = elementData[0] as string;
337
+ const attrs = elementData[1] as Record<string, string> | undefined;
338
+ if (!attrs) return;
339
+ const element = document.createElementNS("http://www.w3.org/2000/svg", tagName);
340
+ Object.entries(attrs).forEach(([key, value]) => {
341
+ // Skip 'stroke' so the parent SVG's stroke attribute drives color uniformly
342
+ if (key !== "stroke") element.setAttribute(key, String(value));
343
+ });
344
+ svg.appendChild(element);
88
345
  });
89
-
346
+
90
347
  return svg;
91
348
  }
92
-
@@ -1,7 +1,8 @@
1
1
  import type {
2
2
  AgentWidgetMessage,
3
3
  AgentWidgetStorageAdapter,
4
- AgentWidgetStoredState
4
+ AgentWidgetStoredState,
5
+ PersonaArtifactRecord
5
6
  } from "../types";
6
7
 
7
8
  const safeJsonParse = (value: string | null) => {
@@ -23,6 +24,12 @@ const sanitizeMessages = (messages: AgentWidgetMessage[]) =>
23
24
  streaming: false
24
25
  }));
25
26
 
27
+ const sanitizeArtifacts = (artifacts: PersonaArtifactRecord[]) =>
28
+ artifacts.map((artifact) => ({
29
+ ...artifact,
30
+ status: "complete" as const
31
+ }));
32
+
26
33
  export const createLocalStorageAdapter = (
27
34
  key = "persona-state"
28
35
  ): AgentWidgetStorageAdapter => {
@@ -45,7 +52,8 @@ export const createLocalStorageAdapter = (
45
52
  try {
46
53
  const payload: AgentWidgetStoredState = {
47
54
  ...state,
48
- messages: state.messages ? sanitizeMessages(state.messages) : undefined
55
+ messages: state.messages ? sanitizeMessages(state.messages) : undefined,
56
+ artifacts: state.artifacts ? sanitizeArtifacts(state.artifacts) : undefined
49
57
  };
50
58
  storage.setItem(key, JSON.stringify(payload));
51
59
  } catch (error) {
@@ -275,7 +275,7 @@ export const wrapStreamAnimation = (
275
275
  html: string,
276
276
  mode: "char" | "word",
277
277
  messageId: string,
278
- options?: { skipTags?: string[] }
278
+ options?: { skipTags?: string[]; startIndex?: number }
279
279
  ): string => {
280
280
  if (!html) return html;
281
281
  if (typeof document === "undefined") return html;
@@ -294,7 +294,12 @@ export const wrapStreamAnimation = (
294
294
  node = walker.nextNode();
295
295
  }
296
296
 
297
- const counterRef = { value: 0 };
297
+ // `startIndex` lets callers number spans by their absolute position in a
298
+ // larger string, even when only a slice is being wrapped. The peek banner
299
+ // uses this so per-char span IDs stay stable as the trailing-100-char
300
+ // window shifts each chunk — idiomorph then preserves animations on
301
+ // already-revealed chars instead of restarting them.
302
+ const counterRef = { value: options?.startIndex ?? 0 };
298
303
  const wrap = mode === "char" ? wrapTextNodeChars : wrapTextNodeWords;
299
304
  for (const textNode of textNodes) {
300
305
  wrap(textNode, messageId, counterRef);
@@ -252,6 +252,42 @@ describe('theme utils', () => {
252
252
  expect(cssVars['--persona-scroll-to-bottom-icon-size']).toBe('14px');
253
253
  });
254
254
 
255
+ it('maps introCard component tokens to dedicated CSS variables', () => {
256
+ const theme = createTheme({
257
+ components: {
258
+ introCard: {
259
+ background: 'palette.colors.accent.50',
260
+ borderRadius: 'palette.radius.xl',
261
+ padding: 'semantic.spacing.lg',
262
+ shadow: '0 10px 30px rgba(53, 44, 131, 0.15)',
263
+ },
264
+ },
265
+ } as any);
266
+
267
+ const cssVars = themeToCssVariables(theme);
268
+
269
+ expect(cssVars['--persona-components-introCard-background']).toBe('#ecfeff');
270
+ expect(cssVars['--persona-components-introCard-borderRadius']).toBe('0.75rem');
271
+ expect(cssVars['--persona-components-introCard-padding']).toBe('1.5rem');
272
+ expect(cssVars['--persona-components-introCard-shadow']).toBe(
273
+ '0 10px 30px rgba(53, 44, 131, 0.15)'
274
+ );
275
+ expect(cssVars['--persona-intro-card-bg']).toBe('#ecfeff');
276
+ expect(cssVars['--persona-intro-card-radius']).toBe('0.75rem');
277
+ expect(cssVars['--persona-intro-card-padding']).toBe('1.5rem');
278
+ expect(cssVars['--persona-intro-card-shadow']).toBe(
279
+ '0 10px 30px rgba(53, 44, 131, 0.15)'
280
+ );
281
+ });
282
+
283
+ it('falls back to the legacy intro-card shadow when no token is set', () => {
284
+ const theme = createTheme({});
285
+ const cssVars = themeToCssVariables(theme);
286
+ expect(cssVars['--persona-intro-card-shadow']).toBe(
287
+ '0 5px 15px rgba(15, 23, 42, 0.08)'
288
+ );
289
+ });
290
+
255
291
  it('lets config.toolCall.shadow override theme tool bubble shadow on the root element', () => {
256
292
  const el = document.createElement('div');
257
293
  applyThemeVariables(el, {
@@ -318,6 +318,14 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
318
318
  },
319
319
  border: 'semantic.colors.border',
320
320
  },
321
+ introCard: {
322
+ // Defaults preserve the legacy `persona-shadow-sm` look exactly so existing
323
+ // pages render unchanged when no token is set.
324
+ background: 'semantic.colors.surface',
325
+ borderRadius: 'palette.radius.2xl',
326
+ padding: 'semantic.spacing.lg',
327
+ shadow: '0 5px 15px rgba(15, 23, 42, 0.08)',
328
+ },
321
329
  toolBubble: {
322
330
  shadow: 'palette.shadows.sm',
323
331
  },
@@ -763,6 +771,21 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
763
771
  if (headerTokens?.shadow) cssVars['--persona-header-shadow'] = headerTokens.shadow;
764
772
  if (headerTokens?.borderBottom) cssVars['--persona-header-border-bottom'] = headerTokens.borderBottom;
765
773
 
774
+ // Intro card aliases — short names the panel inline-styles read directly.
775
+ // The full-path `--persona-components-introCard-*` variables auto-emit above;
776
+ // these mirror them with sensible fallbacks so existing pages keep their look.
777
+ const introCardTokens = theme.components?.introCard;
778
+ cssVars['--persona-intro-card-bg'] =
779
+ cssVars['--persona-components-introCard-background'] ?? cssVars['--persona-surface'];
780
+ cssVars['--persona-intro-card-radius'] =
781
+ cssVars['--persona-components-introCard-borderRadius'] ?? '1rem';
782
+ cssVars['--persona-intro-card-padding'] =
783
+ cssVars['--persona-components-introCard-padding'] ?? '1.5rem';
784
+ cssVars['--persona-intro-card-shadow'] =
785
+ introCardTokens?.shadow
786
+ ?? cssVars['--persona-components-introCard-shadow']
787
+ ?? '0 5px 15px rgba(15, 23, 42, 0.08)';
788
+
766
789
  cssVars['--persona-input-background'] =
767
790
  cssVars['--persona-components-input-background'] ?? cssVars['--persona-surface'];
768
791
  cssVars['--persona-input-placeholder'] =