@runtypelabs/persona 3.18.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 (38) hide show
  1. package/README.md +1 -1
  2. package/dist/index.cjs +47 -47
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +281 -4
  5. package/dist/index.d.ts +281 -4
  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 +1438 -619
  11. package/dist/theme-editor.d.cts +119 -1
  12. package/dist/theme-editor.d.ts +119 -1
  13. package/dist/theme-editor.js +1552 -619
  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 +4 -0
  27. package/src/runtime/init.ts +4 -2
  28. package/src/runtime/persist-state.test.ts +152 -0
  29. package/src/styles/widget.css +348 -0
  30. package/src/types.ts +121 -1
  31. package/src/ui.component-directive.test.ts +183 -0
  32. package/src/ui.composer-bar.test.ts +1009 -0
  33. package/src/ui.ts +809 -72
  34. package/src/utils/attachment-manager.ts +1 -1
  35. package/src/utils/dock.test.ts +45 -0
  36. package/src/utils/dock.ts +3 -0
  37. package/src/utils/icons.ts +314 -58
  38. package/src/utils/stream-animation.ts +7 -2
@@ -0,0 +1,360 @@
1
+ import { createElement, createElementInDocument } from "../utils/dom";
2
+ import { renderLucideIcon } from "../utils/icons";
3
+ import { AgentWidgetConfig } from "../types";
4
+ import { PORTALED_OVERLAY_Z_INDEX } from "../utils/constants";
5
+ import { HEADER_THEME_CSS } from "./header-builder";
6
+
7
+ export interface CloseButtonParts {
8
+ button: HTMLButtonElement;
9
+ wrapper: HTMLElement;
10
+ }
11
+
12
+ export interface ClearChatButtonParts {
13
+ button: HTMLButtonElement;
14
+ wrapper: HTMLElement;
15
+ }
16
+
17
+ export interface CreateCloseButtonOptions {
18
+ showClose?: boolean;
19
+ /**
20
+ * Override the wrapper className. The full header passes its own
21
+ * placement-aware class string; composer-bar mode passes a class that
22
+ * positions the wrapper absolutely in the top-right of the panel chrome.
23
+ */
24
+ wrapperClassName?: string;
25
+ /**
26
+ * Explicit button-size override that wins over `launcher.closeButtonSize`.
27
+ * Use when the call site has its own opinion about the size that should
28
+ * take precedence over the global launcher config — e.g. composer-bar's
29
+ * minimal close icon, where size is part of the mode's UX, not something
30
+ * that should inherit from the floating launcher's button size.
31
+ */
32
+ buttonSize?: string;
33
+ /**
34
+ * Override the rendered icon size (default: "28px"). Pair with
35
+ * `buttonSize` when scaling the whole control down — otherwise the
36
+ * 28px icon will overflow a smaller button.
37
+ */
38
+ iconSize?: string;
39
+ }
40
+
41
+ export interface CreateClearChatButtonOptions {
42
+ /**
43
+ * Override the wrapper className. Header builder passes its own
44
+ * placement-aware class string; composer-bar mode passes a class that
45
+ * positions the wrapper absolutely (next to the close button).
46
+ */
47
+ wrapperClassName?: string;
48
+ /**
49
+ * Explicit button-size override that wins over `launcher.clearChat.size`.
50
+ * Composer-bar mode uses this so the clear icon visually matches the
51
+ * shrunken close button (16px) and doesn't render at the floating
52
+ * launcher's 32px default.
53
+ */
54
+ buttonSize?: string;
55
+ /** Override the rendered icon size (default: "20px"). */
56
+ iconSize?: string;
57
+ }
58
+
59
+ const DEFAULT_WRAPPER_CLASS =
60
+ "persona-relative persona-ml-auto persona-inline-flex persona-items-center persona-justify-center";
61
+
62
+ /**
63
+ * Build the close (×) button + tooltip used in the panel header. Lifted
64
+ * verbatim from header-builder.ts so composer-bar mode can render just a
65
+ * close button (no full header strip) without duplicating the tooltip
66
+ * + config-driven styling logic.
67
+ */
68
+ export const createCloseButton = (
69
+ config: AgentWidgetConfig | undefined,
70
+ options: CreateCloseButtonOptions = {},
71
+ ): CloseButtonParts => {
72
+ const {
73
+ showClose = true,
74
+ wrapperClassName = DEFAULT_WRAPPER_CLASS,
75
+ buttonSize,
76
+ iconSize = "28px",
77
+ } = options;
78
+ const launcher = config?.launcher ?? {};
79
+ // Call-site `buttonSize` (if provided) wins over launcher config. The
80
+ // launcher's `closeButtonSize` is set in DEFAULT_WIDGET_CONFIG so it's
81
+ // never undefined, which means the call-site override is the only way
82
+ // to opt a specific render path (like composer-bar's minimal close) into
83
+ // a different size.
84
+ const closeButtonSize = buttonSize ?? launcher.closeButtonSize ?? "32px";
85
+
86
+ const wrapper = createElement("div", wrapperClassName);
87
+
88
+ const button = createElement(
89
+ "button",
90
+ "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none"
91
+ ) as HTMLButtonElement;
92
+ button.style.height = closeButtonSize;
93
+ button.style.width = closeButtonSize;
94
+ button.type = "button";
95
+
96
+ const closeButtonTooltipText = launcher.closeButtonTooltipText ?? "Close chat";
97
+ const closeButtonShowTooltip = launcher.closeButtonShowTooltip ?? true;
98
+
99
+ button.setAttribute("aria-label", closeButtonTooltipText);
100
+ button.style.display = showClose ? "" : "none";
101
+
102
+ const closeButtonIconName = launcher.closeButtonIconName ?? "x";
103
+ const closeButtonIconText = launcher.closeButtonIconText ?? "×";
104
+ button.style.color =
105
+ launcher.closeButtonColor || HEADER_THEME_CSS.actionIconColor;
106
+
107
+ // The X glyph's paths occupy only the middle 50% of its 24x24 viewBox
108
+ // (from 6,6 to 18,18), while other header icons (e.g. refresh-cw) span
109
+ // ~75% of the viewBox. Rendering X at a larger intrinsic size brings
110
+ // its visible extent into parity with sibling icons in the header.
111
+ // display:block eliminates inline-baseline spacing that can push the
112
+ // icon a fractional pixel off-center inside the button.
113
+ const closeIconSvg = renderLucideIcon(closeButtonIconName, iconSize, "currentColor", 1);
114
+ if (closeIconSvg) {
115
+ closeIconSvg.style.display = "block";
116
+ button.appendChild(closeIconSvg);
117
+ } else {
118
+ button.textContent = closeButtonIconText;
119
+ }
120
+
121
+ if (launcher.closeButtonBackgroundColor) {
122
+ button.style.backgroundColor = launcher.closeButtonBackgroundColor;
123
+ button.classList.remove("hover:persona-bg-gray-100");
124
+ } else {
125
+ button.style.backgroundColor = "";
126
+ button.classList.add("hover:persona-bg-gray-100");
127
+ }
128
+
129
+ if (launcher.closeButtonBorderWidth || launcher.closeButtonBorderColor) {
130
+ const borderWidth = launcher.closeButtonBorderWidth || "0px";
131
+ const borderColor = launcher.closeButtonBorderColor || "transparent";
132
+ button.style.border = `${borderWidth} solid ${borderColor}`;
133
+ button.classList.remove("persona-border-none");
134
+ } else {
135
+ button.style.border = "";
136
+ button.classList.add("persona-border-none");
137
+ }
138
+
139
+ if (launcher.closeButtonBorderRadius) {
140
+ button.style.borderRadius = launcher.closeButtonBorderRadius;
141
+ button.classList.remove("persona-rounded-full");
142
+ } else {
143
+ button.style.borderRadius = "";
144
+ button.classList.add("persona-rounded-full");
145
+ }
146
+
147
+ if (launcher.closeButtonPaddingX) {
148
+ button.style.paddingLeft = launcher.closeButtonPaddingX;
149
+ button.style.paddingRight = launcher.closeButtonPaddingX;
150
+ } else {
151
+ button.style.paddingLeft = "";
152
+ button.style.paddingRight = "";
153
+ }
154
+ if (launcher.closeButtonPaddingY) {
155
+ button.style.paddingTop = launcher.closeButtonPaddingY;
156
+ button.style.paddingBottom = launcher.closeButtonPaddingY;
157
+ } else {
158
+ button.style.paddingTop = "";
159
+ button.style.paddingBottom = "";
160
+ }
161
+
162
+ wrapper.appendChild(button);
163
+
164
+ if (closeButtonShowTooltip && closeButtonTooltipText) {
165
+ let portaledTooltip: HTMLElement | null = null;
166
+
167
+ const showTooltip = () => {
168
+ if (portaledTooltip) return;
169
+
170
+ const tooltipDocument = button.ownerDocument;
171
+ const tooltipContainer = tooltipDocument.body;
172
+ if (!tooltipContainer) return;
173
+
174
+ portaledTooltip = createElementInDocument(
175
+ tooltipDocument,
176
+ "div",
177
+ "persona-clear-chat-tooltip"
178
+ );
179
+ portaledTooltip.textContent = closeButtonTooltipText;
180
+
181
+ const arrow = createElementInDocument(tooltipDocument, "div");
182
+ arrow.className = "persona-clear-chat-tooltip-arrow";
183
+ portaledTooltip.appendChild(arrow);
184
+
185
+ const buttonRect = button.getBoundingClientRect();
186
+
187
+ portaledTooltip.style.position = "fixed";
188
+ portaledTooltip.style.zIndex = String(PORTALED_OVERLAY_Z_INDEX);
189
+ portaledTooltip.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
190
+ portaledTooltip.style.top = `${buttonRect.top - 8}px`;
191
+ portaledTooltip.style.transform = "translate(-50%, -100%)";
192
+
193
+ tooltipContainer.appendChild(portaledTooltip);
194
+ };
195
+
196
+ const hideTooltip = () => {
197
+ if (portaledTooltip && portaledTooltip.parentNode) {
198
+ portaledTooltip.parentNode.removeChild(portaledTooltip);
199
+ portaledTooltip = null;
200
+ }
201
+ };
202
+
203
+ wrapper.addEventListener("mouseenter", showTooltip);
204
+ wrapper.addEventListener("mouseleave", hideTooltip);
205
+ button.addEventListener("focus", showTooltip);
206
+ button.addEventListener("blur", hideTooltip);
207
+
208
+ (wrapper as any)._cleanupTooltip = () => {
209
+ hideTooltip();
210
+ wrapper.removeEventListener("mouseenter", showTooltip);
211
+ wrapper.removeEventListener("mouseleave", hideTooltip);
212
+ button.removeEventListener("focus", showTooltip);
213
+ button.removeEventListener("blur", hideTooltip);
214
+ };
215
+ }
216
+
217
+ return { button, wrapper };
218
+ };
219
+
220
+ const DEFAULT_CLEAR_CHAT_WRAPPER_CLASS =
221
+ "persona-relative persona-ml-auto persona-clear-chat-button-wrapper";
222
+
223
+ /**
224
+ * Build the clear-chat (refresh) button + tooltip used in the panel header.
225
+ * Extracted from `header-builder.ts` so composer-bar mode can render a
226
+ * "start over" button alongside its close icon without duplicating the
227
+ * tooltip + config-driven styling logic.
228
+ *
229
+ * The factory only handles construction. Wiring the click to the
230
+ * clear-history handler is owned by `setupClearChatButton()` in `ui.ts`,
231
+ * which keys off `panelElements.clearChatButton`.
232
+ */
233
+ export const createClearChatButton = (
234
+ config: AgentWidgetConfig | undefined,
235
+ options: CreateClearChatButtonOptions = {},
236
+ ): ClearChatButtonParts => {
237
+ const {
238
+ wrapperClassName = DEFAULT_CLEAR_CHAT_WRAPPER_CLASS,
239
+ buttonSize,
240
+ iconSize = "20px",
241
+ } = options;
242
+
243
+ const launcher = config?.launcher ?? {};
244
+ const clearChatConfig = launcher.clearChat ?? {};
245
+ // Call-site `buttonSize` (when provided) wins over launcher.clearChat.size.
246
+ // Same precedence rule as createCloseButton: callers like composer-bar
247
+ // intentionally override the inherited launcher default to fit their UX.
248
+ const clearChatSize = buttonSize ?? clearChatConfig.size ?? "32px";
249
+ const clearChatIconName = clearChatConfig.iconName ?? "refresh-cw";
250
+ const clearChatIconColor = clearChatConfig.iconColor ?? "";
251
+ const clearChatBgColor = clearChatConfig.backgroundColor ?? "";
252
+ const clearChatBorderWidth = clearChatConfig.borderWidth ?? "";
253
+ const clearChatBorderColor = clearChatConfig.borderColor ?? "";
254
+ const clearChatBorderRadius = clearChatConfig.borderRadius ?? "";
255
+ const clearChatPaddingX = clearChatConfig.paddingX ?? "";
256
+ const clearChatPaddingY = clearChatConfig.paddingY ?? "";
257
+ const clearChatTooltipText = clearChatConfig.tooltipText ?? "Clear chat";
258
+ const clearChatShowTooltip = clearChatConfig.showTooltip ?? true;
259
+
260
+ const wrapper = createElement("div", wrapperClassName);
261
+
262
+ const button = createElement(
263
+ "button",
264
+ "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none"
265
+ ) as HTMLButtonElement;
266
+ button.style.height = clearChatSize;
267
+ button.style.width = clearChatSize;
268
+ button.type = "button";
269
+ button.setAttribute("aria-label", clearChatTooltipText);
270
+ button.style.color = clearChatIconColor || HEADER_THEME_CSS.actionIconColor;
271
+
272
+ const iconSvg = renderLucideIcon(clearChatIconName, iconSize, "currentColor", 1);
273
+ if (iconSvg) {
274
+ iconSvg.style.display = "block";
275
+ button.appendChild(iconSvg);
276
+ }
277
+
278
+ if (clearChatBgColor) {
279
+ button.style.backgroundColor = clearChatBgColor;
280
+ button.classList.remove("hover:persona-bg-gray-100");
281
+ }
282
+
283
+ if (clearChatBorderWidth || clearChatBorderColor) {
284
+ const borderWidth = clearChatBorderWidth || "0px";
285
+ const borderColor = clearChatBorderColor || "transparent";
286
+ button.style.border = `${borderWidth} solid ${borderColor}`;
287
+ button.classList.remove("persona-border-none");
288
+ }
289
+
290
+ if (clearChatBorderRadius) {
291
+ button.style.borderRadius = clearChatBorderRadius;
292
+ button.classList.remove("persona-rounded-full");
293
+ }
294
+
295
+ if (clearChatPaddingX) {
296
+ button.style.paddingLeft = clearChatPaddingX;
297
+ button.style.paddingRight = clearChatPaddingX;
298
+ }
299
+ if (clearChatPaddingY) {
300
+ button.style.paddingTop = clearChatPaddingY;
301
+ button.style.paddingBottom = clearChatPaddingY;
302
+ }
303
+
304
+ wrapper.appendChild(button);
305
+
306
+ if (clearChatShowTooltip && clearChatTooltipText) {
307
+ let portaledTooltip: HTMLElement | null = null;
308
+
309
+ const showTooltip = () => {
310
+ if (portaledTooltip) return;
311
+
312
+ const tooltipDocument = button.ownerDocument;
313
+ const tooltipContainer = tooltipDocument.body;
314
+ if (!tooltipContainer) return;
315
+
316
+ portaledTooltip = createElementInDocument(
317
+ tooltipDocument,
318
+ "div",
319
+ "persona-clear-chat-tooltip"
320
+ );
321
+ portaledTooltip.textContent = clearChatTooltipText;
322
+
323
+ const arrow = createElementInDocument(tooltipDocument, "div");
324
+ arrow.className = "persona-clear-chat-tooltip-arrow";
325
+ portaledTooltip.appendChild(arrow);
326
+
327
+ const buttonRect = button.getBoundingClientRect();
328
+
329
+ portaledTooltip.style.position = "fixed";
330
+ portaledTooltip.style.zIndex = String(PORTALED_OVERLAY_Z_INDEX);
331
+ portaledTooltip.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
332
+ portaledTooltip.style.top = `${buttonRect.top - 8}px`;
333
+ portaledTooltip.style.transform = "translate(-50%, -100%)";
334
+
335
+ tooltipContainer.appendChild(portaledTooltip);
336
+ };
337
+
338
+ const hideTooltip = () => {
339
+ if (portaledTooltip && portaledTooltip.parentNode) {
340
+ portaledTooltip.parentNode.removeChild(portaledTooltip);
341
+ portaledTooltip = null;
342
+ }
343
+ };
344
+
345
+ wrapper.addEventListener("mouseenter", showTooltip);
346
+ wrapper.addEventListener("mouseleave", hideTooltip);
347
+ button.addEventListener("focus", showTooltip);
348
+ button.addEventListener("blur", hideTooltip);
349
+
350
+ (wrapper as any)._cleanupTooltip = () => {
351
+ hideTooltip();
352
+ wrapper.removeEventListener("mouseenter", showTooltip);
353
+ wrapper.removeEventListener("mouseleave", hideTooltip);
354
+ button.removeEventListener("focus", showTooltip);
355
+ button.removeEventListener("blur", hideTooltip);
356
+ };
357
+ }
358
+
359
+ return { button, wrapper };
360
+ };
@@ -0,0 +1,61 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { createWrapper } from "./panel";
5
+ import type { AgentWidgetConfig } from "../types";
6
+
7
+ describe("createWrapper — composer-bar mode", () => {
8
+ it("marks the wrapper with composer-bar data-attrs and leaves geometry to updateOpenState", () => {
9
+ const config: AgentWidgetConfig = {
10
+ apiUrl: "/api",
11
+ launcher: { mountMode: "composer-bar" },
12
+ };
13
+ const { wrapper, panel } = createWrapper(config);
14
+
15
+ expect(wrapper.getAttribute("data-persona-composer-bar")).toBe("");
16
+ expect(wrapper.dataset.state).toBe("collapsed");
17
+ // Default expandedSize is now "anchored" (was "fullscreen").
18
+ expect(wrapper.dataset.expandedSize).toBe("anchored");
19
+ expect(wrapper.classList.contains("persona-fixed")).toBe(true);
20
+
21
+ // Geometry is owned entirely by applyComposerBarGeometry() in ui.ts so
22
+ // collapsed → expanded transitions can clear stale inline styles.
23
+ // createWrapper must not set any positioning/sizing inline.
24
+ expect(wrapper.style.left).toBe("");
25
+ expect(wrapper.style.transform).toBe("");
26
+ expect(wrapper.style.bottom).toBe("");
27
+ expect(wrapper.style.top).toBe("");
28
+ expect(wrapper.style.width).toBe("");
29
+ expect(wrapper.style.maxWidth).toBe("");
30
+
31
+ // The panel keeps width: 100% so it fills whatever width ui.ts assigns.
32
+ expect(panel.style.width).toBe("100%");
33
+ });
34
+
35
+ it("honors composerBar.expandedSize override", () => {
36
+ const config: AgentWidgetConfig = {
37
+ apiUrl: "/api",
38
+ launcher: {
39
+ mountMode: "composer-bar",
40
+ composerBar: { expandedSize: "modal" },
41
+ },
42
+ };
43
+ const { wrapper } = createWrapper(config);
44
+
45
+ expect(wrapper.dataset.expandedSize).toBe("modal");
46
+ });
47
+
48
+ it("applies launcher.zIndex to the composer-bar wrapper", () => {
49
+ const config: AgentWidgetConfig = {
50
+ apiUrl: "/api",
51
+ launcher: { mountMode: "composer-bar", zIndex: 12345 },
52
+ };
53
+ const { wrapper } = createWrapper(config);
54
+ expect(wrapper.style.zIndex).toBe("12345");
55
+ });
56
+
57
+ it("does not apply composer-bar markers in floating mode", () => {
58
+ const { wrapper } = createWrapper({ apiUrl: "/api" });
59
+ expect(wrapper.hasAttribute("data-persona-composer-bar")).toBe(false);
60
+ });
61
+ });