@runtypelabs/persona 3.18.0 → 3.20.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 (42) hide show
  1. package/README.md +45 -2
  2. package/dist/index.cjs +47 -47
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +383 -6
  5. package/dist/index.d.ts +383 -6
  6. package/dist/index.global.js +102 -1636
  7. package/dist/index.global.js.map +1 -1
  8. package/dist/index.js +47 -47
  9. package/dist/index.js.map +1 -1
  10. package/dist/theme-editor.cjs +1514 -626
  11. package/dist/theme-editor.d.cts +192 -1
  12. package/dist/theme-editor.d.ts +192 -1
  13. package/dist/theme-editor.js +1628 -626
  14. package/dist/widget.css +348 -0
  15. package/package.json +1 -1
  16. package/src/components/composer-builder.test.ts +52 -0
  17. package/src/components/composer-builder.ts +67 -490
  18. package/src/components/composer-parts.test.ts +152 -0
  19. package/src/components/composer-parts.ts +452 -0
  20. package/src/components/header-builder.ts +22 -299
  21. package/src/components/header-parts.ts +360 -0
  22. package/src/components/panel.test.ts +61 -0
  23. package/src/components/panel.ts +262 -5
  24. package/src/components/pill-composer-builder.test.ts +85 -0
  25. package/src/components/pill-composer-builder.ts +183 -0
  26. package/src/index.ts +5 -0
  27. package/src/runtime/init.ts +4 -2
  28. package/src/runtime/persist-state.test.ts +152 -0
  29. package/src/session.test.ts +123 -0
  30. package/src/session.ts +58 -4
  31. package/src/styles/widget.css +348 -0
  32. package/src/types.ts +196 -1
  33. package/src/ui.component-directive.test.ts +183 -0
  34. package/src/ui.composer-bar.test.ts +1009 -0
  35. package/src/ui.ts +827 -72
  36. package/src/utils/attachment-manager.ts +1 -1
  37. package/src/utils/component-middleware.test.ts +134 -0
  38. package/src/utils/component-middleware.ts +44 -13
  39. package/src/utils/dock.test.ts +45 -0
  40. package/src/utils/dock.ts +3 -0
  41. package/src/utils/icons.ts +314 -58
  42. package/src/utils/stream-animation.ts +7 -2
@@ -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,134 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ import {
4
+ extractComponentDirectiveFromMessage,
5
+ hasComponentDirective
6
+ } from "./component-middleware";
7
+ import type { AgentWidgetMessage } from "../types";
8
+
9
+ const baseMessage = (overrides: Partial<AgentWidgetMessage>): AgentWidgetMessage => ({
10
+ id: "msg-1",
11
+ role: "assistant",
12
+ content: "",
13
+ createdAt: "2026-01-01T00:00:00.000Z",
14
+ ...overrides
15
+ });
16
+
17
+ describe("extractComponentDirectiveFromMessage", () => {
18
+ it("extracts directive from rawContent (streamed path)", () => {
19
+ const directive = {
20
+ text: "Booking form",
21
+ component: "DynamicForm",
22
+ props: { title: "Book a demo" }
23
+ };
24
+ const message = baseMessage({
25
+ content: "Booking form",
26
+ rawContent: JSON.stringify(directive)
27
+ });
28
+
29
+ const result = extractComponentDirectiveFromMessage(message);
30
+
31
+ expect(result).not.toBeNull();
32
+ expect(result?.component).toBe("DynamicForm");
33
+ expect(result?.props).toEqual({ title: "Book a demo" });
34
+ expect(result?.raw).toBe(JSON.stringify(directive));
35
+ });
36
+
37
+ it("falls back to content when rawContent is missing and content looks like JSON", () => {
38
+ const directive = {
39
+ text: "Booking form",
40
+ component: "DynamicForm",
41
+ props: { title: "Book a demo" }
42
+ };
43
+ const message = baseMessage({
44
+ content: JSON.stringify(directive)
45
+ });
46
+
47
+ const result = extractComponentDirectiveFromMessage(message);
48
+
49
+ expect(result).not.toBeNull();
50
+ expect(result?.component).toBe("DynamicForm");
51
+ expect(result?.props).toEqual({ title: "Book a demo" });
52
+ });
53
+
54
+ it("prefers rawContent over content when both are present", () => {
55
+ const message = baseMessage({
56
+ rawContent: JSON.stringify({
57
+ text: "Raw form",
58
+ component: "RawComponent",
59
+ props: { source: "raw" }
60
+ }),
61
+ content: JSON.stringify({
62
+ text: "Content form",
63
+ component: "ContentComponent",
64
+ props: { source: "content" }
65
+ })
66
+ });
67
+
68
+ const result = extractComponentDirectiveFromMessage(message);
69
+
70
+ expect(result?.component).toBe("RawComponent");
71
+ expect(result?.props).toEqual({ source: "raw" });
72
+ });
73
+
74
+ it("returns null for plain-text content", () => {
75
+ const message = baseMessage({ content: "Hello, how can I help?" });
76
+ expect(extractComponentDirectiveFromMessage(message)).toBeNull();
77
+ });
78
+
79
+ it("returns null when content is JSON without a component field", () => {
80
+ const message = baseMessage({
81
+ content: JSON.stringify({ text: "Just text", foo: "bar" })
82
+ });
83
+ expect(extractComponentDirectiveFromMessage(message)).toBeNull();
84
+ });
85
+
86
+ it("returns null for empty rawContent and empty content", () => {
87
+ const message = baseMessage({ rawContent: "", content: "" });
88
+ expect(extractComponentDirectiveFromMessage(message)).toBeNull();
89
+ });
90
+
91
+ it("returns null when JSON is malformed", () => {
92
+ const message = baseMessage({ rawContent: '{"component": "Foo"' });
93
+ expect(extractComponentDirectiveFromMessage(message)).toBeNull();
94
+ });
95
+
96
+ it("defaults props to {} when the directive omits or nulls them", () => {
97
+ const message = baseMessage({
98
+ rawContent: JSON.stringify({ text: "x", component: "Foo" })
99
+ });
100
+ const result = extractComponentDirectiveFromMessage(message);
101
+ expect(result?.props).toEqual({});
102
+
103
+ const messageNullProps = baseMessage({
104
+ rawContent: JSON.stringify({ text: "x", component: "Foo", props: null })
105
+ });
106
+ expect(extractComponentDirectiveFromMessage(messageNullProps)?.props).toEqual({});
107
+ });
108
+ });
109
+
110
+ describe("hasComponentDirective", () => {
111
+ it("returns true when rawContent carries a directive", () => {
112
+ const message = baseMessage({
113
+ rawContent: JSON.stringify({ text: "x", component: "Foo", props: {} })
114
+ });
115
+ expect(hasComponentDirective(message)).toBe(true);
116
+ });
117
+
118
+ it("returns true when only content carries a directive", () => {
119
+ const message = baseMessage({
120
+ content: JSON.stringify({ text: "x", component: "Foo", props: {} })
121
+ });
122
+ expect(hasComponentDirective(message)).toBe(true);
123
+ });
124
+
125
+ it("returns false for plain content", () => {
126
+ const message = baseMessage({ content: "Hello!" });
127
+ expect(hasComponentDirective(message)).toBe(false);
128
+ });
129
+
130
+ it("returns false for malformed JSON", () => {
131
+ const message = baseMessage({ rawContent: "{not json" });
132
+ expect(hasComponentDirective(message)).toBe(false);
133
+ });
134
+ });
@@ -87,18 +87,42 @@ export function createComponentMiddleware() {
87
87
  }
88
88
 
89
89
  /**
90
- * Checks if a message contains a component directive in its raw content
90
+ * Picks the field that may carry a JSON directive payload. Streamed messages
91
+ * populate `rawContent`; manually injected messages may pass the JSON via
92
+ * `content` directly. We try `rawContent` first, then fall back to `content`
93
+ * when it looks like JSON, so both code paths render the same way.
94
+ */
95
+ function selectDirectiveSource(message: AgentWidgetMessage): string | null {
96
+ if (typeof message.rawContent === "string" && message.rawContent.length > 0) {
97
+ return message.rawContent;
98
+ }
99
+ if (typeof message.content === "string") {
100
+ const trimmed = message.content.trim();
101
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
102
+ return message.content;
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Checks if a message contains a component directive.
110
+ *
111
+ * Looks at `rawContent` first (the field set by stream parsers); falls back
112
+ * to `content` when it looks like JSON, so injected messages that pass the
113
+ * directive via `content` (or have no `rawContent`) are still recognized.
91
114
  */
92
115
  export function hasComponentDirective(message: AgentWidgetMessage): boolean {
93
- if (!message.rawContent) return false;
94
-
116
+ const source = selectDirectiveSource(message);
117
+ if (!source) return false;
118
+
95
119
  try {
96
- const parsed = JSON.parse(message.rawContent);
120
+ const parsed = JSON.parse(source);
97
121
  return (
98
122
  typeof parsed === "object" &&
99
123
  parsed !== null &&
100
124
  "component" in parsed &&
101
- typeof parsed.component === "string"
125
+ typeof (parsed as { component: unknown }).component === "string"
102
126
  );
103
127
  } catch {
104
128
  return false;
@@ -106,27 +130,34 @@ export function hasComponentDirective(message: AgentWidgetMessage): boolean {
106
130
  }
107
131
 
108
132
  /**
109
- * Extracts component directive from a complete message
133
+ * Extracts component directive from a complete message.
134
+ *
135
+ * Looks at `rawContent` first (the field set by stream parsers); falls back
136
+ * to `content` when it looks like JSON, so injected messages that pass the
137
+ * directive via `content` (or have no `rawContent`) render the same as
138
+ * streamed ones.
110
139
  */
111
140
  export function extractComponentDirectiveFromMessage(
112
141
  message: AgentWidgetMessage
113
142
  ): ComponentDirective | null {
114
- if (!message.rawContent) return null;
143
+ const source = selectDirectiveSource(message);
144
+ if (!source) return null;
115
145
 
116
146
  try {
117
- const parsed = JSON.parse(message.rawContent);
147
+ const parsed = JSON.parse(source);
118
148
  if (
119
149
  typeof parsed === "object" &&
120
150
  parsed !== null &&
121
151
  "component" in parsed &&
122
- typeof parsed.component === "string"
152
+ typeof (parsed as { component: unknown }).component === "string"
123
153
  ) {
154
+ const directive = parsed as { component: string; props?: unknown };
124
155
  return {
125
- component: parsed.component,
126
- props: (parsed.props && typeof parsed.props === "object" && parsed.props !== null
127
- ? parsed.props
156
+ component: directive.component,
157
+ props: (directive.props && typeof directive.props === "object" && directive.props !== null
158
+ ? directive.props
128
159
  : {}) as Record<string, unknown>,
129
- raw: message.rawContent
160
+ raw: source
130
161
  };
131
162
  }
132
163
  } catch {
@@ -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
-