@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.
- package/README.md +45 -2
- package/dist/index.cjs +47 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +383 -6
- package/dist/index.d.ts +383 -6
- package/dist/index.global.js +102 -1636
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +47 -47
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +1514 -626
- package/dist/theme-editor.d.cts +192 -1
- package/dist/theme-editor.d.ts +192 -1
- package/dist/theme-editor.js +1628 -626
- package/dist/widget.css +348 -0
- package/package.json +1 -1
- package/src/components/composer-builder.test.ts +52 -0
- package/src/components/composer-builder.ts +67 -490
- package/src/components/composer-parts.test.ts +152 -0
- package/src/components/composer-parts.ts +452 -0
- package/src/components/header-builder.ts +22 -299
- package/src/components/header-parts.ts +360 -0
- package/src/components/panel.test.ts +61 -0
- package/src/components/panel.ts +262 -5
- package/src/components/pill-composer-builder.test.ts +85 -0
- package/src/components/pill-composer-builder.ts +183 -0
- package/src/index.ts +5 -0
- package/src/runtime/init.ts +4 -2
- package/src/runtime/persist-state.test.ts +152 -0
- package/src/session.test.ts +123 -0
- package/src/session.ts +58 -4
- package/src/styles/widget.css +348 -0
- package/src/types.ts +196 -1
- package/src/ui.component-directive.test.ts +183 -0
- package/src/ui.composer-bar.test.ts +1009 -0
- package/src/ui.ts +827 -72
- package/src/utils/attachment-manager.ts +1 -1
- package/src/utils/component-middleware.test.ts +134 -0
- package/src/utils/component-middleware.ts +44 -13
- package/src/utils/dock.test.ts +45 -0
- package/src/utils/dock.ts +3 -0
- package/src/utils/icons.ts +314 -58
- 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-
|
|
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
|
-
*
|
|
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
|
-
|
|
94
|
-
|
|
116
|
+
const source = selectDirectiveSource(message);
|
|
117
|
+
if (!source) return false;
|
|
118
|
+
|
|
95
119
|
try {
|
|
96
|
-
const parsed = JSON.parse(
|
|
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
|
-
|
|
143
|
+
const source = selectDirectiveSource(message);
|
|
144
|
+
if (!source) return null;
|
|
115
145
|
|
|
116
146
|
try {
|
|
117
|
-
const parsed = JSON.parse(
|
|
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:
|
|
126
|
-
props: (
|
|
127
|
-
?
|
|
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:
|
|
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
|
package/src/utils/icons.ts
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 (!
|
|
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
|
-
//
|
|
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)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|