@runtypelabs/persona 2.3.1 → 3.1.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 (43) hide show
  1. package/README.md +222 -5
  2. package/dist/index.cjs +42 -42
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +832 -571
  5. package/dist/index.d.ts +832 -571
  6. package/dist/index.global.js +88 -88
  7. package/dist/index.global.js.map +1 -1
  8. package/dist/index.js +42 -42
  9. package/dist/index.js.map +1 -1
  10. package/dist/widget.css +257 -67
  11. package/package.json +2 -4
  12. package/src/components/artifact-card.ts +39 -5
  13. package/src/components/artifact-pane.ts +68 -127
  14. package/src/components/composer-builder.ts +3 -23
  15. package/src/components/header-builder.ts +29 -34
  16. package/src/components/header-layouts.ts +109 -41
  17. package/src/components/launcher.ts +10 -7
  18. package/src/components/message-bubble.ts +7 -11
  19. package/src/components/panel.ts +4 -4
  20. package/src/defaults.ts +22 -93
  21. package/src/index.ts +20 -7
  22. package/src/presets.ts +66 -51
  23. package/src/runtime/host-layout.test.ts +333 -0
  24. package/src/runtime/host-layout.ts +346 -27
  25. package/src/runtime/init.test.ts +113 -8
  26. package/src/runtime/init.ts +1 -1
  27. package/src/styles/widget.css +257 -67
  28. package/src/types/theme.ts +76 -0
  29. package/src/types.ts +86 -97
  30. package/src/ui.docked.test.ts +203 -7
  31. package/src/ui.ts +125 -92
  32. package/src/utils/artifact-gate.ts +1 -1
  33. package/src/utils/buttons.ts +417 -0
  34. package/src/utils/code-generators.test.ts +43 -7
  35. package/src/utils/code-generators.ts +9 -25
  36. package/src/utils/deep-merge.ts +26 -0
  37. package/src/utils/dock.ts +18 -5
  38. package/src/utils/dropdown.ts +178 -0
  39. package/src/utils/theme.test.ts +90 -15
  40. package/src/utils/theme.ts +20 -46
  41. package/src/utils/tokens.ts +108 -11
  42. package/src/styles/tailwind.css +0 -20
  43. package/src/utils/migration.ts +0 -220
@@ -4,6 +4,8 @@ import { escapeHtml, createMarkdownProcessorFromConfig } from "../postprocessors
4
4
  import { resolveSanitizer } from "../utils/sanitize";
5
5
  import { componentRegistry, type ComponentContext } from "./registry";
6
6
  import { renderLucideIcon } from "../utils/icons";
7
+ import { createDropdownMenu, type DropdownMenuHandle } from "../utils/dropdown";
8
+ import { createIconButton, createLabelButton } from "../utils/buttons";
7
9
 
8
10
  export type ArtifactPaneApi = {
9
11
  element: HTMLElement;
@@ -27,65 +29,6 @@ function fallbackComponentCard(sel: PersonaArtifactRecord): HTMLElement {
27
29
  return card;
28
30
  }
29
31
 
30
- function iconButton(iconName: string, label: string, extraClass = ""): HTMLButtonElement {
31
- const btn = createElement(
32
- "button",
33
- `persona-inline-flex persona-items-center persona-justify-center persona-rounded-md persona-border persona-border-persona-border persona-bg-persona-surface persona-p-1 persona-text-persona-primary hover:persona-bg-persona-container ${extraClass}`
34
- ) as HTMLButtonElement;
35
- btn.type = "button";
36
- btn.setAttribute("aria-label", label);
37
- btn.title = label;
38
- const icon = renderLucideIcon(iconName, 16, "currentColor", 2);
39
- if (icon) btn.appendChild(icon);
40
- return btn;
41
- }
42
-
43
- function documentToolbarIconButton(
44
- iconName: string,
45
- label: string,
46
- extraClass: string
47
- ): HTMLButtonElement {
48
- const btn = createElement(
49
- "button",
50
- `persona-artifact-doc-icon-btn ${extraClass}`.trim()
51
- ) as HTMLButtonElement;
52
- btn.type = "button";
53
- btn.setAttribute("aria-label", label);
54
- btn.title = label;
55
- const icon = renderLucideIcon(iconName, 16, "currentColor", 2);
56
- if (icon) btn.appendChild(icon);
57
- return btn;
58
- }
59
-
60
- function documentToolbarCopyMainButton(showLabel: boolean): HTMLButtonElement {
61
- const btn = createElement("button", "persona-artifact-doc-copy-btn") as HTMLButtonElement;
62
- btn.type = "button";
63
- btn.setAttribute("aria-label", "Copy");
64
- btn.title = "Copy";
65
- const icon = renderLucideIcon("copy", showLabel ? 14 : 16, "currentColor", 2);
66
- if (icon) btn.appendChild(icon);
67
- if (showLabel) {
68
- const span = createElement("span", "persona-artifact-doc-copy-label");
69
- span.textContent = "Copy";
70
- btn.appendChild(span);
71
- }
72
- return btn;
73
- }
74
-
75
- function documentToolbarChevronMenuButton(): HTMLButtonElement {
76
- const btn = createElement(
77
- "button",
78
- "persona-artifact-doc-copy-menu-chevron persona-artifact-doc-icon-btn"
79
- ) as HTMLButtonElement;
80
- btn.type = "button";
81
- btn.setAttribute("aria-label", "More copy options");
82
- btn.setAttribute("aria-haspopup", "true");
83
- btn.setAttribute("aria-expanded", "false");
84
- const chev = renderLucideIcon("chevron-down", 14, "currentColor", 2);
85
- if (chev) btn.appendChild(chev);
86
- return btn;
87
- }
88
-
89
32
  /**
90
33
  * Right-hand artifact sidebar / mobile drawer content.
91
34
  */
@@ -119,6 +62,8 @@ export function createArtifactPane(
119
62
  const dismissLocalUi = () => {
120
63
  backdrop?.classList.add("persona-hidden");
121
64
  shell.classList.remove("persona-artifact-drawer-open");
65
+ // Hide portaled copy menu
66
+ copyMenuDropdown?.hide();
122
67
  };
123
68
 
124
69
  if (backdrop) {
@@ -162,13 +107,13 @@ export function createArtifactPane(
162
107
 
163
108
  /** Document preset: view vs raw source */
164
109
  let viewMode: "rendered" | "source" = "rendered";
165
- const leftTools = createElement("div", "persona-flex persona-items-center persona-gap-1 persona-shrink-0");
110
+ const leftTools = createElement("div", "persona-flex persona-items-center persona-gap-1 persona-shrink-0 persona-artifact-toggle-group");
166
111
  const viewBtn = documentChrome
167
- ? documentToolbarIconButton("eye", "Rendered view", "persona-artifact-view-btn")
168
- : iconButton("eye", "Rendered view", "");
112
+ ? createIconButton({ icon: "eye", label: "Rendered view", className: "persona-artifact-doc-icon-btn persona-artifact-view-btn" })
113
+ : createIconButton({ icon: "eye", label: "Rendered view" });
169
114
  const codeBtn = documentChrome
170
- ? documentToolbarIconButton("code-2", "Source", "persona-artifact-code-btn")
171
- : iconButton("code-2", "Source", "");
115
+ ? createIconButton({ icon: "code-2", label: "Source", className: "persona-artifact-doc-icon-btn persona-artifact-code-btn" })
116
+ : createIconButton({ icon: "code-2", label: "Source" });
172
117
  const actionsRight = createElement("div", "persona-flex persona-items-center persona-gap-1 persona-shrink-0");
173
118
  const showCopyLabel = layout?.documentToolbarShowCopyLabel === true;
174
119
  const showCopyChevron = layout?.documentToolbarShowCopyChevron === true;
@@ -178,10 +123,12 @@ export function createArtifactPane(
178
123
  let copyWrap: HTMLElement | null = null;
179
124
  let copyBtn: HTMLButtonElement;
180
125
  let copyMenuChevronBtn: HTMLButtonElement | null = null;
181
- let copyMenuEl: HTMLElement | null = null;
126
+ let copyMenuDropdown: DropdownMenuHandle | null = null;
182
127
 
183
128
  if (documentChrome && (showCopyLabel || showCopyChevron) && !showCopyMenu) {
184
- copyBtn = documentToolbarCopyMainButton(showCopyLabel);
129
+ copyBtn = showCopyLabel
130
+ ? createLabelButton({ icon: "copy", label: "Copy", iconSize: 14, className: "persona-artifact-doc-copy-btn" })
131
+ : createIconButton({ icon: "copy", label: "Copy", className: "persona-artifact-doc-copy-btn" });
185
132
  if (showCopyChevron) {
186
133
  const chev = renderLucideIcon("chevron-down", 14, "currentColor", 2);
187
134
  if (chev) copyBtn.appendChild(chev);
@@ -191,36 +138,29 @@ export function createArtifactPane(
191
138
  "div",
192
139
  "persona-relative persona-inline-flex persona-items-center persona-gap-0 persona-rounded-md"
193
140
  );
194
- copyBtn = documentToolbarCopyMainButton(showCopyLabel);
195
- copyMenuChevronBtn = documentToolbarChevronMenuButton();
141
+ copyBtn = showCopyLabel
142
+ ? createLabelButton({ icon: "copy", label: "Copy", iconSize: 14, className: "persona-artifact-doc-copy-btn" })
143
+ : createIconButton({ icon: "copy", label: "Copy", className: "persona-artifact-doc-copy-btn" });
144
+ copyMenuChevronBtn = createIconButton({
145
+ icon: "chevron-down",
146
+ label: "More copy options",
147
+ size: 14,
148
+ className: "persona-artifact-doc-copy-menu-chevron persona-artifact-doc-icon-btn",
149
+ aria: { "aria-haspopup": "true", "aria-expanded": "false" }
150
+ });
196
151
  copyWrap.append(copyBtn, copyMenuChevronBtn);
197
- copyMenuEl = createElement(
198
- "div",
199
- "persona-artifact-doc-copy-menu persona-absolute persona-right-0 persona-top-full persona-z-20 persona-mt-1 persona-min-w-[10rem] persona-rounded-md persona-border persona-border-persona-border persona-bg-persona-surface persona-py-1 persona-shadow-md persona-hidden"
200
- );
201
- copyWrap.appendChild(copyMenuEl);
202
- for (const item of copyMenuItems!) {
203
- const opt = createElement(
204
- "button",
205
- "persona-block persona-w-full persona-text-left persona-px-3 persona-py-2 persona-text-xs persona-text-persona-primary hover:persona-bg-persona-container"
206
- ) as HTMLButtonElement;
207
- opt.type = "button";
208
- opt.textContent = item.label;
209
- opt.dataset.copyMenuId = item.id;
210
- copyMenuEl.appendChild(opt);
211
- }
212
152
  } else if (documentChrome) {
213
- copyBtn = documentToolbarIconButton("copy", "Copy", "");
153
+ copyBtn = createIconButton({ icon: "copy", label: "Copy", className: "persona-artifact-doc-icon-btn" });
214
154
  } else {
215
- copyBtn = iconButton("copy", "Copy", "");
155
+ copyBtn = createIconButton({ icon: "copy", label: "Copy" });
216
156
  }
217
157
 
218
158
  const refreshBtn = documentChrome
219
- ? documentToolbarIconButton("refresh-cw", "Refresh", "")
220
- : iconButton("refresh-cw", "Refresh", "");
159
+ ? createIconButton({ icon: "refresh-cw", label: "Refresh", className: "persona-artifact-doc-icon-btn" })
160
+ : createIconButton({ icon: "refresh-cw", label: "Refresh" });
221
161
  const closeIconBtn = documentChrome
222
- ? documentToolbarIconButton("x", "Close", "")
223
- : iconButton("x", "Close", "");
162
+ ? createIconButton({ icon: "x", label: "Close", className: "persona-artifact-doc-icon-btn" })
163
+ : createIconButton({ icon: "x", label: "Close" });
224
164
 
225
165
  const getSelectedArtifactText = (): { markdown: string; jsonPayload: string; id: string | null } => {
226
166
  const sel = records.find((r) => r.id === selectedId) ?? records[records.length - 1];
@@ -262,45 +202,46 @@ export function createArtifactPane(
262
202
  await defaultCopy();
263
203
  });
264
204
 
265
- if (copyMenuChevronBtn && copyMenuEl) {
266
- const closeMenu = () => {
267
- copyMenuEl!.classList.add("persona-hidden");
268
- copyMenuChevronBtn!.setAttribute("aria-expanded", "false");
205
+ if (copyMenuChevronBtn && copyMenuItems?.length) {
206
+ // Resolve the portal target — widget root for CSS var inheritance, escaping overflow: hidden
207
+ const resolvePortal = (): HTMLElement => shell.closest("[data-persona-root]") as HTMLElement ?? document.body;
208
+
209
+ const initDropdown = () => {
210
+ copyMenuDropdown = createDropdownMenu({
211
+ items: copyMenuItems.map((item) => ({ id: item.id, label: item.label })),
212
+ onSelect: async (actionId) => {
213
+ const { markdown, jsonPayload, id } = getSelectedArtifactText();
214
+ const handler = layout?.onDocumentToolbarCopyMenuSelect;
215
+ try {
216
+ if (handler) {
217
+ await handler({ actionId, artifactId: id, markdown, jsonPayload });
218
+ } else if (actionId === "markdown" || actionId === "md") {
219
+ await navigator.clipboard.writeText(markdown);
220
+ } else if (actionId === "json" || actionId === "source") {
221
+ await navigator.clipboard.writeText(jsonPayload);
222
+ } else {
223
+ await navigator.clipboard.writeText(markdown || jsonPayload);
224
+ }
225
+ } catch {
226
+ /* ignore */
227
+ }
228
+ },
229
+ anchor: copyWrap ?? copyMenuChevronBtn!,
230
+ position: 'bottom-right',
231
+ portal: resolvePortal(),
232
+ });
269
233
  };
270
- copyMenuChevronBtn.addEventListener("click", (e) => {
271
- e.stopPropagation();
272
- const open = copyMenuEl!.classList.contains("persona-hidden");
273
- if (open) {
274
- copyMenuEl!.classList.remove("persona-hidden");
275
- copyMenuChevronBtn!.setAttribute("aria-expanded", "true");
276
- } else {
277
- closeMenu();
278
- }
279
- });
280
- if (typeof document !== "undefined") {
281
- document.addEventListener("click", closeMenu);
234
+
235
+ // Defer init until shell is in the DOM (may not be attached yet)
236
+ if (shell.isConnected) {
237
+ initDropdown();
238
+ } else {
239
+ requestAnimationFrame(initDropdown);
282
240
  }
283
- copyMenuEl.addEventListener("click", async (e) => {
284
- const t = (e.target as HTMLElement).closest("button[data-copy-menu-id]") as HTMLButtonElement | null;
285
- if (!t?.dataset.copyMenuId) return;
241
+
242
+ copyMenuChevronBtn.addEventListener("click", (e) => {
286
243
  e.stopPropagation();
287
- const actionId = t.dataset.copyMenuId;
288
- const { markdown, jsonPayload, id } = getSelectedArtifactText();
289
- const handler = layout?.onDocumentToolbarCopyMenuSelect;
290
- try {
291
- if (handler) {
292
- await handler({ actionId, artifactId: id, markdown, jsonPayload });
293
- } else if (actionId === "markdown" || actionId === "md") {
294
- await navigator.clipboard.writeText(markdown);
295
- } else if (actionId === "json" || actionId === "source") {
296
- await navigator.clipboard.writeText(jsonPayload);
297
- } else {
298
- await navigator.clipboard.writeText(markdown || jsonPayload);
299
- }
300
- } catch {
301
- /* ignore */
302
- }
303
- closeMenu();
244
+ copyMenuDropdown?.toggle();
304
245
  });
305
246
  }
306
247
 
@@ -388,7 +329,7 @@ export function createArtifactPane(
388
329
  for (const r of records) {
389
330
  const tab = createElement(
390
331
  "button",
391
- "persona-artifact-tab persona-shrink-0 persona-rounded-lg persona-px-2 persona-py-1 persona-text-xs persona-border persona-border-transparent persona-text-persona-primary hover:persona-bg-persona-container"
332
+ "persona-artifact-tab persona-shrink-0 persona-rounded-lg persona-px-2 persona-py-1 persona-text-xs persona-border persona-border-transparent persona-text-persona-primary"
392
333
  );
393
334
  tab.type = "button";
394
335
  tab.textContent = r.title || r.id.slice(0, 8);
@@ -461,7 +402,7 @@ export function createArtifactPane(
461
402
  shell.classList.toggle("persona-hidden", !has);
462
403
  if (backdrop) {
463
404
  const root =
464
- typeof shell.closest === "function" ? shell.closest("#persona-root") : null;
405
+ typeof shell.closest === "function" ? shell.closest("[data-persona-root]") : null;
465
406
  const narrowHost = root?.classList.contains("persona-artifact-narrow-host") ?? false;
466
407
  const isMobile =
467
408
  narrowHost ||
@@ -40,23 +40,6 @@ export interface ComposerBuildContext {
40
40
  disabled?: boolean;
41
41
  }
42
42
 
43
- /**
44
- * Helper to get font family CSS value from config preset
45
- */
46
- const getFontFamilyValue = (
47
- family: "sans-serif" | "serif" | "mono"
48
- ): string => {
49
- switch (family) {
50
- case "serif":
51
- return 'Georgia, "Times New Roman", Times, serif';
52
- case "mono":
53
- return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
54
- case "sans-serif":
55
- default:
56
- return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
57
- }
58
- };
59
-
60
43
  /**
61
44
  * Build the composer/footer section of the panel.
62
45
  * Extracted for reuse and plugin override support.
@@ -91,12 +74,9 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
91
74
  "persona-w-full persona-min-h-[24px] persona-resize-none persona-border-none persona-bg-transparent persona-text-sm persona-text-persona-primary focus:persona-outline-none focus:persona-border-none persona-composer-textarea";
92
75
  textarea.rows = 1;
93
76
 
94
- // Apply font family and weight from config
95
- const fontFamily = config?.theme?.inputFontFamily ?? "sans-serif";
96
- const fontWeight = config?.theme?.inputFontWeight ?? "400";
97
-
98
- textarea.style.fontFamily = getFontFamilyValue(fontFamily);
99
- textarea.style.fontWeight = fontWeight;
77
+ textarea.style.fontFamily =
78
+ 'var(--persona-input-font-family, var(--persona-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif))';
79
+ textarea.style.fontWeight = "var(--persona-input-font-weight, var(--persona-font-weight, 400))";
100
80
 
101
81
  // Set up auto-resize: expand up to 3 lines, then scroll
102
82
  // Line height is ~20px for text-sm (14px * 1.25 line-height), so 3 lines ≈ 60px
@@ -2,6 +2,16 @@ import { createElement, createElementInDocument } from "../utils/dom";
2
2
  import { renderLucideIcon } from "../utils/icons";
3
3
  import { AgentWidgetConfig } from "../types";
4
4
 
5
+ /** CSS `color` values; variables are set on `[data-persona-root]` from `theme.components.header`. */
6
+ export const HEADER_THEME_CSS = {
7
+ titleColor:
8
+ "var(--persona-header-title-fg, var(--persona-primary, #2563eb))",
9
+ subtitleColor:
10
+ "var(--persona-header-subtitle-fg, var(--persona-text-muted, var(--persona-muted, #9ca3af)))",
11
+ actionIconColor:
12
+ "var(--persona-header-action-icon-fg, var(--persona-muted, #9ca3af))",
13
+ } as const;
14
+
5
15
  export interface HeaderElements {
6
16
  header: HTMLElement;
7
17
  iconHolder: HTMLElement;
@@ -33,9 +43,9 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
33
43
  );
34
44
  header.setAttribute("data-persona-theme-zone", "header");
35
45
  header.style.backgroundColor = 'var(--persona-header-bg, var(--persona-surface, #ffffff))';
36
- header.style.borderBottomWidth = '1px';
37
- header.style.borderBottomStyle = 'solid';
38
46
  header.style.borderBottomColor = 'var(--persona-header-border, var(--persona-divider, #f1f5f9))';
47
+ header.style.boxShadow = 'var(--persona-header-shadow, none)';
48
+ header.style.borderBottom = 'var(--persona-header-border-bottom, 1px solid var(--persona-header-border, var(--persona-divider, #f1f5f9)))';
39
49
 
40
50
  const launcher = config?.launcher ?? {};
41
51
  const headerIconSize = launcher.headerIconSize ?? "48px";
@@ -46,17 +56,21 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
46
56
 
47
57
  const iconHolder = createElement(
48
58
  "div",
49
- "persona-flex persona-items-center persona-justify-center persona-rounded-xl persona-bg-persona-primary persona-text-white persona-text-xl"
59
+ "persona-flex persona-items-center persona-justify-center persona-rounded-xl persona-text-xl"
50
60
  );
51
61
  iconHolder.style.height = headerIconSize;
52
62
  iconHolder.style.width = headerIconSize;
63
+ iconHolder.style.backgroundColor =
64
+ "var(--persona-header-icon-bg, var(--persona-primary, #2563eb))";
65
+ iconHolder.style.color =
66
+ "var(--persona-header-icon-fg, var(--persona-text-inverse, #ffffff))";
53
67
 
54
68
  // Render icon based on priority: Lucide icon > iconUrl > agentIconText
55
69
  if (!headerIconHidden) {
56
70
  if (headerIconName) {
57
71
  // Use Lucide icon
58
72
  const iconSize = parseFloat(headerIconSize) || 24;
59
- const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.6, "var(--persona-text-inverse, #ffffff)", 1);
73
+ const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.6, "currentColor", 1);
60
74
  if (iconSvg) {
61
75
  iconHolder.replaceChildren(iconSvg);
62
76
  } else {
@@ -80,8 +94,10 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
80
94
 
81
95
  const headerCopy = createElement("div", "persona-flex persona-flex-col");
82
96
  const title = createElement("span", "persona-text-base persona-font-semibold");
97
+ title.style.color = HEADER_THEME_CSS.titleColor;
83
98
  title.textContent = config?.launcher?.title ?? "Chat Assistant";
84
- const subtitle = createElement("span", "persona-text-xs persona-text-persona-muted");
99
+ const subtitle = createElement("span", "persona-text-xs");
100
+ subtitle.style.color = HEADER_THEME_CSS.subtitleColor;
85
101
  subtitle.textContent =
86
102
  config?.launcher?.subtitle ?? "Here to help you get answers fast";
87
103
 
@@ -132,31 +148,22 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
132
148
 
133
149
  clearChatButton = createElement(
134
150
  "button",
135
- "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full persona-text-persona-muted hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none"
151
+ "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none"
136
152
  ) as HTMLButtonElement;
137
153
 
138
154
  clearChatButton.style.height = clearChatSize;
139
155
  clearChatButton.style.width = clearChatSize;
140
156
  clearChatButton.type = "button";
141
157
  clearChatButton.setAttribute("aria-label", clearChatTooltipText);
158
+ clearChatButton.style.color =
159
+ clearChatIconColor || HEADER_THEME_CSS.actionIconColor;
142
160
 
143
161
  // Add icon
144
- const iconSvg = renderLucideIcon(
145
- clearChatIconName,
146
- "20px",
147
- clearChatIconColor || "",
148
- 1
149
- );
162
+ const iconSvg = renderLucideIcon(clearChatIconName, "20px", "currentColor", 1);
150
163
  if (iconSvg) {
151
164
  clearChatButton.appendChild(iconSvg);
152
165
  }
153
166
 
154
- // Apply styling from config
155
- if (clearChatIconColor) {
156
- clearChatButton.style.color = clearChatIconColor;
157
- clearChatButton.classList.remove("persona-text-persona-muted");
158
- }
159
-
160
167
  if (clearChatBgColor) {
161
168
  clearChatButton.style.backgroundColor = clearChatBgColor;
162
169
  clearChatButton.classList.remove("hover:persona-bg-gray-100");
@@ -281,7 +288,7 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
281
288
  // Create close button with base classes
282
289
  const closeButton = createElement(
283
290
  "button",
284
- "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full persona-text-persona-muted hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none"
291
+ "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none"
285
292
  ) as HTMLButtonElement;
286
293
  closeButton.style.height = closeButtonSize;
287
294
  closeButton.style.width = closeButtonSize;
@@ -297,29 +304,17 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
297
304
  // Add icon or fallback text
298
305
  const closeButtonIconName = launcher.closeButtonIconName ?? "x";
299
306
  const closeButtonIconText = launcher.closeButtonIconText ?? "×";
307
+ closeButton.style.color =
308
+ launcher.closeButtonColor || HEADER_THEME_CSS.actionIconColor;
300
309
 
301
310
  // Try to render Lucide icon, fallback to text if not provided or fails
302
- const closeIconSvg = renderLucideIcon(
303
- closeButtonIconName,
304
- "20px",
305
- launcher.closeButtonColor || "",
306
- 1
307
- );
311
+ const closeIconSvg = renderLucideIcon(closeButtonIconName, "20px", "currentColor", 1);
308
312
  if (closeIconSvg) {
309
313
  closeButton.appendChild(closeIconSvg);
310
314
  } else {
311
315
  closeButton.textContent = closeButtonIconText;
312
316
  }
313
317
 
314
- // Apply close button styling from config
315
- if (launcher.closeButtonColor) {
316
- closeButton.style.color = launcher.closeButtonColor;
317
- closeButton.classList.remove("persona-text-persona-muted");
318
- } else {
319
- closeButton.style.color = "";
320
- closeButton.classList.add("persona-text-persona-muted");
321
- }
322
-
323
318
  if (launcher.closeButtonBackgroundColor) {
324
319
  closeButton.style.backgroundColor = launcher.closeButtonBackgroundColor;
325
320
  closeButton.classList.remove("hover:persona-bg-gray-100");
@@ -1,11 +1,18 @@
1
1
  import { createElement } from "../utils/dom";
2
2
  import { renderLucideIcon } from "../utils/icons";
3
+ import { createDropdownMenu } from "../utils/dropdown";
4
+ import { createComboButton } from "../utils/buttons";
3
5
  import {
4
6
  AgentWidgetConfig,
5
7
  AgentWidgetHeaderLayoutConfig,
6
8
  AgentWidgetHeaderTrailingAction
7
9
  } from "../types";
8
- import { buildHeader, HeaderElements, attachHeaderToContainer as _attachHeaderToContainer } from "./header-builder";
10
+ import {
11
+ buildHeader,
12
+ HEADER_THEME_CSS,
13
+ HeaderElements,
14
+ attachHeaderToContainer as _attachHeaderToContainer,
15
+ } from "./header-builder";
9
16
 
10
17
  export interface HeaderLayoutContext {
11
18
  config: AgentWidgetConfig;
@@ -75,8 +82,27 @@ function appendTrailingHeaderActions(
75
82
  } else if (a.label) {
76
83
  btn.textContent = a.label;
77
84
  }
78
- btn.addEventListener("click", () => onAction?.(a.id));
79
- container.appendChild(btn);
85
+
86
+ if (a.menuItems?.length) {
87
+ // Wrap in a relative container for dropdown positioning
88
+ const wrapper = createElement("div", "persona-relative");
89
+ wrapper.appendChild(btn);
90
+ const dropdown = createDropdownMenu({
91
+ items: a.menuItems,
92
+ onSelect: (itemId) => onAction?.(itemId),
93
+ anchor: wrapper,
94
+ position: 'bottom-left',
95
+ });
96
+ wrapper.appendChild(dropdown.element);
97
+ btn.addEventListener("click", (e) => {
98
+ e.stopPropagation();
99
+ dropdown.toggle();
100
+ });
101
+ container.appendChild(wrapper);
102
+ } else {
103
+ btn.addEventListener("click", () => onAction?.(a.id));
104
+ container.appendChild(btn);
105
+ }
80
106
  }
81
107
  }
82
108
 
@@ -86,43 +112,88 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
86
112
 
87
113
  const header = createElement(
88
114
  "div",
89
- "persona-flex persona-items-center persona-justify-between persona-bg-persona-surface persona-px-6 persona-py-4 persona-border-b-persona-divider"
115
+ "persona-flex persona-items-center persona-justify-between persona-px-6 persona-py-4"
90
116
  );
91
117
  header.setAttribute("data-persona-theme-zone", "header");
118
+ header.style.backgroundColor = 'var(--persona-header-bg, var(--persona-surface, #ffffff))';
119
+ header.style.borderBottomColor = 'var(--persona-header-border, var(--persona-divider, #f1f5f9))';
120
+ header.style.boxShadow = 'var(--persona-header-shadow, none)';
121
+ header.style.borderBottom =
122
+ 'var(--persona-header-border-bottom, 1px solid var(--persona-header-border, var(--persona-divider, #f1f5f9)))';
92
123
 
93
- const titleRow = createElement(
94
- "div",
95
- "persona-flex persona-min-w-0 persona-flex-1 persona-items-center persona-gap-1"
96
- );
124
+ // Build the title area — either a combo button (titleMenu) or standard title row
125
+ const titleMenuConfig = layoutHeaderConfig?.titleMenu;
126
+ let titleRow: HTMLElement;
127
+ let headerTitle: HTMLElement;
128
+
129
+ if (titleMenuConfig) {
130
+ // Combo button replaces title + trailing actions + hover
131
+ const combo = createComboButton({
132
+ label: launcher.title ?? "Chat Assistant",
133
+ menuItems: titleMenuConfig.menuItems,
134
+ onSelect: titleMenuConfig.onSelect,
135
+ hover: titleMenuConfig.hover,
136
+ className: "",
137
+ });
138
+ titleRow = combo.element;
139
+ titleRow.style.color = HEADER_THEME_CSS.titleColor;
140
+ // The combo button's label span acts as headerTitle for update()
141
+ headerTitle = titleRow.querySelector(".persona-combo-btn-label") ?? titleRow;
142
+ } else {
143
+ titleRow = createElement(
144
+ "div",
145
+ "persona-flex persona-min-w-0 persona-flex-1 persona-items-center persona-gap-1"
146
+ );
97
147
 
98
- // Title only (no icon, no subtitle)
99
- const title = createElement("span", "persona-text-base persona-font-semibold persona-truncate");
100
- title.textContent = launcher.title ?? "Chat Assistant";
148
+ // Title only (no icon, no subtitle)
149
+ headerTitle = createElement("span", "persona-text-base persona-font-semibold persona-truncate");
150
+ headerTitle.style.color = HEADER_THEME_CSS.titleColor;
151
+ headerTitle.textContent = launcher.title ?? "Chat Assistant";
101
152
 
102
- titleRow.appendChild(title);
103
- appendTrailingHeaderActions(
104
- titleRow,
105
- layoutHeaderConfig?.trailingActions,
106
- layoutHeaderConfig?.onAction ?? onHeaderAction
107
- );
153
+ titleRow.appendChild(headerTitle);
154
+ appendTrailingHeaderActions(
155
+ titleRow,
156
+ layoutHeaderConfig?.trailingActions,
157
+ layoutHeaderConfig?.onAction ?? onHeaderAction
158
+ );
108
159
 
109
- // Make title row clickable when onTitleClick is provided
110
- if (layoutHeaderConfig?.onTitleClick) {
111
- titleRow.style.cursor = "pointer";
112
- titleRow.setAttribute("role", "button");
113
- titleRow.setAttribute("tabindex", "0");
114
- const handleTitleClick = layoutHeaderConfig.onTitleClick;
115
- titleRow.addEventListener("click", (e) => {
116
- // Skip if the click was on a trailing action button
117
- if ((e.target as HTMLElement).closest("button")) return;
118
- handleTitleClick();
119
- });
120
- titleRow.addEventListener("keydown", (e) => {
121
- if (e.key === "Enter" || e.key === " ") {
122
- e.preventDefault();
160
+ // Make title row clickable when onTitleClick is provided
161
+ if (layoutHeaderConfig?.onTitleClick) {
162
+ titleRow.style.cursor = "pointer";
163
+ titleRow.setAttribute("role", "button");
164
+ titleRow.setAttribute("tabindex", "0");
165
+ const handleTitleClick = layoutHeaderConfig.onTitleClick;
166
+ titleRow.addEventListener("click", (e) => {
167
+ if ((e.target as HTMLElement).closest("button")) return;
123
168
  handleTitleClick();
124
- }
125
- });
169
+ });
170
+ titleRow.addEventListener("keydown", (e) => {
171
+ if (e.key === "Enter" || e.key === " ") {
172
+ e.preventDefault();
173
+ handleTitleClick();
174
+ }
175
+ });
176
+ }
177
+
178
+ // Title row hover pill effect
179
+ const hoverCfg = layoutHeaderConfig?.titleRowHover;
180
+ if (hoverCfg) {
181
+ titleRow.style.borderRadius = hoverCfg.borderRadius ?? '10px';
182
+ titleRow.style.padding = hoverCfg.padding ?? '6px 4px 6px 12px';
183
+ titleRow.style.margin = '-6px 0 -6px -12px';
184
+ titleRow.style.border = '1px solid transparent';
185
+ titleRow.style.transition = 'background-color 0.15s ease, border-color 0.15s ease';
186
+ titleRow.style.width = 'fit-content';
187
+ titleRow.style.flex = 'none';
188
+ titleRow.addEventListener('mouseenter', () => {
189
+ titleRow.style.backgroundColor = hoverCfg.background ?? '';
190
+ titleRow.style.borderColor = hoverCfg.border ?? '';
191
+ });
192
+ titleRow.addEventListener('mouseleave', () => {
193
+ titleRow.style.backgroundColor = '';
194
+ titleRow.style.borderColor = 'transparent';
195
+ });
196
+ }
126
197
  }
127
198
 
128
199
  header.appendChild(titleRow);
@@ -133,21 +204,18 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
133
204
 
134
205
  const closeButton = createElement(
135
206
  "button",
136
- "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full persona-text-persona-muted hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none"
207
+ "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none"
137
208
  ) as HTMLButtonElement;
138
209
  closeButton.style.height = closeButtonSize;
139
210
  closeButton.style.width = closeButtonSize;
140
211
  closeButton.type = "button";
141
212
  closeButton.setAttribute("aria-label", "Close chat");
142
213
  closeButton.style.display = showClose ? "" : "none";
214
+ closeButton.style.color =
215
+ launcher.closeButtonColor || HEADER_THEME_CSS.actionIconColor;
143
216
 
144
217
  const closeButtonIconName = launcher.closeButtonIconName ?? "x";
145
- const closeIconSvg = renderLucideIcon(
146
- closeButtonIconName,
147
- "20px",
148
- launcher.closeButtonColor || "",
149
- 2
150
- );
218
+ const closeIconSvg = renderLucideIcon(closeButtonIconName, "20px", "currentColor", 2);
151
219
  if (closeIconSvg) {
152
220
  closeButton.appendChild(closeIconSvg);
153
221
  } else {
@@ -172,7 +240,7 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
172
240
  return {
173
241
  header,
174
242
  iconHolder,
175
- headerTitle: title,
243
+ headerTitle,
176
244
  headerSubtitle,
177
245
  closeButton,
178
246
  closeButtonWrapper,