@runtypelabs/persona 3.34.1 → 3.35.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 (186) hide show
  1. package/README.md +11 -11
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-CthJFfNx.d.cts → types-DgYsuwXL.d.cts} +15 -14
  5. package/dist/animations/{types-CthJFfNx.d.ts → types-DgYsuwXL.d.ts} +15 -14
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +11 -11
  9. package/dist/codegen.js +10 -10
  10. package/dist/index.cjs +46 -44
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +514 -158
  13. package/dist/index.d.ts +514 -158
  14. package/dist/index.global.js +41 -89
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +46 -44
  17. package/dist/index.js.map +1 -1
  18. package/dist/install.global.js +1 -1
  19. package/dist/install.global.js.map +1 -1
  20. package/dist/launcher.global.js +2 -2
  21. package/dist/launcher.global.js.map +1 -1
  22. package/dist/markdown-parsers.js +53 -0
  23. package/dist/plugin-kit.d.cts +4 -4
  24. package/dist/plugin-kit.d.ts +4 -4
  25. package/dist/runtype-tts.js +1 -0
  26. package/dist/smart-dom-reader.cjs +1 -1
  27. package/dist/smart-dom-reader.d.cts +352 -103
  28. package/dist/smart-dom-reader.d.ts +352 -103
  29. package/dist/smart-dom-reader.js +1 -1
  30. package/dist/theme-editor-preview.cjs +203 -0
  31. package/dist/theme-editor-preview.d.cts +5748 -0
  32. package/dist/theme-editor-preview.d.ts +5748 -0
  33. package/dist/theme-editor-preview.js +203 -0
  34. package/dist/theme-editor.cjs +14 -131
  35. package/dist/theme-editor.d.cts +362 -574
  36. package/dist/theme-editor.d.ts +362 -574
  37. package/dist/theme-editor.js +14 -131
  38. package/dist/theme-reference.cjs +1 -1
  39. package/dist/theme-reference.d.cts +1 -1
  40. package/dist/theme-reference.d.ts +1 -1
  41. package/dist/theme-reference.js +1 -1
  42. package/dist/voice-worklet-player.cjs +80 -0
  43. package/dist/voice-worklet-player.d.cts +215 -0
  44. package/dist/voice-worklet-player.d.ts +215 -0
  45. package/dist/voice-worklet-player.js +80 -0
  46. package/dist/widget.css +54 -36
  47. package/package.json +16 -2
  48. package/src/animations/glyph-cycle.ts +11 -14
  49. package/src/animations/wipe.ts +1 -1
  50. package/src/ask-user-question-tool.test.ts +1 -1
  51. package/src/ask-user-question-tool.ts +6 -8
  52. package/src/client.test.ts +12 -12
  53. package/src/client.ts +30 -31
  54. package/src/codegen.test.ts +1 -1
  55. package/src/codegen.ts +1 -1
  56. package/src/components/approval-bubble.test.ts +1 -1
  57. package/src/components/approval-bubble.ts +3 -3
  58. package/src/components/artifact-pane.ts +3 -3
  59. package/src/components/ask-user-question-bubble.test.ts +14 -14
  60. package/src/components/ask-user-question-bubble.ts +10 -10
  61. package/src/components/composer-builder.ts +17 -16
  62. package/src/components/composer-parts.test.ts +54 -0
  63. package/src/components/composer-parts.ts +95 -126
  64. package/src/components/demo-carousel.ts +1 -1
  65. package/src/components/event-stream-view.test.ts +7 -7
  66. package/src/components/event-stream-view.ts +5 -5
  67. package/src/components/header-builder.ts +33 -27
  68. package/src/components/header-layouts.ts +1 -1
  69. package/src/components/header-parts.test.ts +90 -0
  70. package/src/components/header-parts.ts +62 -93
  71. package/src/components/launcher.test.ts +11 -0
  72. package/src/components/launcher.ts +1 -1
  73. package/src/components/message-bubble.test.ts +74 -2
  74. package/src/components/message-bubble.ts +25 -11
  75. package/src/components/messages.ts +2 -2
  76. package/src/components/panel.test.ts +1 -1
  77. package/src/components/panel.ts +92 -97
  78. package/src/components/pill-composer-builder.ts +28 -24
  79. package/src/components/reasoning-bubble.ts +2 -2
  80. package/src/components/suggestions.ts +1 -1
  81. package/src/components/tool-bubble.ts +1 -1
  82. package/src/components/widget-view.test.ts +181 -0
  83. package/src/components/widget-view.ts +234 -0
  84. package/src/defaults.ts +54 -51
  85. package/src/generated/runtype-openapi-contract.ts +5 -0
  86. package/src/index-core.ts +30 -4
  87. package/src/index-global.ts +73 -6
  88. package/src/index.ts +7 -1
  89. package/src/install.test.ts +11 -11
  90. package/src/install.ts +28 -18
  91. package/src/launcher-global.ts +25 -14
  92. package/src/markdown-parsers-eager.ts +20 -0
  93. package/src/markdown-parsers-entry.ts +4 -0
  94. package/src/markdown-parsers-loader.ts +49 -0
  95. package/src/plugin-kit.test.ts +1 -1
  96. package/src/plugin-kit.ts +7 -7
  97. package/src/plugins/types.ts +5 -5
  98. package/src/postprocessors.ts +27 -14
  99. package/src/runtime/host-layout.test.ts +1 -1
  100. package/src/runtime/host-layout.ts +4 -5
  101. package/src/runtime/init.ts +2 -2
  102. package/src/session.test.ts +5 -6
  103. package/src/session.ts +333 -227
  104. package/src/session.voice.test.ts +146 -0
  105. package/src/session.webmcp.test.ts +24 -25
  106. package/src/smart-dom-reader.ts +3 -4
  107. package/src/styles/widget.css +54 -36
  108. package/src/suggest-replies-tool.test.ts +3 -3
  109. package/src/suggest-replies-tool.ts +5 -5
  110. package/src/testing/index.ts +1 -1
  111. package/src/theme-editor/color-utils.ts +2 -2
  112. package/src/theme-editor/index.ts +4 -13
  113. package/src/theme-editor/presets.ts +1 -1
  114. package/src/theme-editor/preview-utils.ts +22 -20
  115. package/src/theme-editor/preview.ts +4 -1
  116. package/src/theme-editor/role-mappings.ts +3 -3
  117. package/src/theme-editor/sections.ts +21 -20
  118. package/src/theme-editor/types.ts +1 -1
  119. package/src/theme-editor/webmcp/index.ts +1 -1
  120. package/src/theme-editor/webmcp/summary.ts +1 -2
  121. package/src/theme-editor/webmcp/tools.ts +30 -30
  122. package/src/theme-editor/webmcp/types.ts +2 -2
  123. package/src/theme-editor-preview.ts +10 -0
  124. package/src/theme-reference.ts +26 -26
  125. package/src/types.ts +368 -99
  126. package/src/ui.approval-plugin.test.ts +3 -3
  127. package/src/ui.ask-user-question-plugin.test.ts +9 -9
  128. package/src/ui.component-directive.test.ts +4 -4
  129. package/src/ui.composer-bar.test.ts +10 -11
  130. package/src/ui.composer-keyboard.test.ts +5 -5
  131. package/src/ui.header-icon-color.test.ts +59 -0
  132. package/src/ui.scroll.test.ts +5 -6
  133. package/src/ui.stop-button.test.ts +3 -4
  134. package/src/ui.suggest-replies.test.ts +2 -2
  135. package/src/ui.ts +292 -131
  136. package/src/utils/buttons.ts +1 -1
  137. package/src/utils/code-generators.ts +1 -1
  138. package/src/utils/composer-history.ts +3 -3
  139. package/src/utils/dom-context.test.ts +2 -2
  140. package/src/utils/dom-context.ts +7 -7
  141. package/src/utils/dom.test.ts +67 -0
  142. package/src/utils/dom.ts +75 -0
  143. package/src/utils/dropdown.ts +2 -2
  144. package/src/utils/event-stream-buffer.test.ts +2 -2
  145. package/src/utils/message-fingerprint.test.ts +9 -0
  146. package/src/utils/message-fingerprint.ts +5 -0
  147. package/src/utils/morph.ts +1 -1
  148. package/src/utils/sanitize.ts +32 -16
  149. package/src/utils/sequence-buffer.test.ts +8 -8
  150. package/src/utils/sequence-buffer.ts +4 -4
  151. package/src/utils/smart-dom-adapter.test.ts +1 -1
  152. package/src/utils/smart-dom-adapter.ts +4 -5
  153. package/src/utils/speech-text.test.ts +122 -0
  154. package/src/utils/speech-text.ts +101 -0
  155. package/src/utils/stream-animation.test.ts +3 -3
  156. package/src/utils/stream-animation.ts +6 -6
  157. package/src/utils/theme.test.ts +1 -1
  158. package/src/utils/throughput-tracker.test.ts +11 -11
  159. package/src/utils/throughput-tracker.ts +9 -9
  160. package/src/utils/tokens.ts +13 -13
  161. package/src/utils/virtual-scroller.ts +1 -1
  162. package/src/vendor/smart-dom-reader/README.md +4 -4
  163. package/src/vendor/smart-dom-reader/index.d.ts +1 -1
  164. package/src/vendor/smart-dom-reader/index.js +2 -2
  165. package/src/version.ts +1 -1
  166. package/src/voice/audio-playback-manager.test.ts +159 -0
  167. package/src/voice/audio-playback-manager.ts +145 -30
  168. package/src/voice/browser-speech-engine.ts +130 -0
  169. package/src/voice/fallback-speech-engine.ts +89 -0
  170. package/src/voice/index.ts +21 -0
  171. package/src/voice/read-aloud-controller.test.ts +143 -0
  172. package/src/voice/read-aloud-controller.ts +136 -0
  173. package/src/voice/runtype-speech-engine.test.ts +280 -0
  174. package/src/voice/runtype-speech-engine.ts +211 -0
  175. package/src/voice/runtype-tts-entry.ts +10 -0
  176. package/src/voice/runtype-tts-loader.test.ts +47 -0
  177. package/src/voice/runtype-tts-loader.ts +42 -0
  178. package/src/voice/runtype-voice-provider.ts +332 -537
  179. package/src/voice/voice-factory.ts +26 -4
  180. package/src/voice/voice.test.ts +321 -7
  181. package/src/voice/worklet-playback-engine.ts +272 -0
  182. package/src/voice-worklet-player.ts +39 -0
  183. package/src/webmcp-bridge.test.ts +6 -6
  184. package/src/webmcp-bridge.ts +24 -25
  185. package/src/webmcp-polyfill.ts +1 -1
  186. package/src/voice/voice-activity-detector.ts +0 -90
@@ -815,7 +815,7 @@ describe("createEventStreamView", () => {
815
815
  const buffer = createMockBuffer(events);
816
816
  const { element, update } = createEventStreamView({ buffer: buffer as any });
817
817
 
818
- // Initial render (Path A first render)
818
+ // Initial render (Path A: first render)
819
819
  update();
820
820
 
821
821
  const eventsList = getEventsList(element);
@@ -825,7 +825,7 @@ describe("createEventStreamView", () => {
825
825
  const row1Ref = eventsList.children[0];
826
826
  const row2Ref = eventsList.children[1];
827
827
 
828
- // Add a new event and update (Path C incremental append)
828
+ // Add a new event and update (Path C: incremental append)
829
829
  vi.advanceTimersByTime(150);
830
830
  buffer.push(makeEvent("step_chunk", 3));
831
831
  update();
@@ -1173,7 +1173,7 @@ describe("createEventStreamView", () => {
1173
1173
  // Wait past the throttle interval
1174
1174
  vi.advanceTimersByTime(150);
1175
1175
 
1176
- // Add event and update should render immediately since 150ms > 100ms
1176
+ // Add event and update: should render immediately since 150ms > 100ms
1177
1177
  buffer.push(makeEvent("flow_complete", 2));
1178
1178
  update();
1179
1179
 
@@ -1223,7 +1223,7 @@ describe("createEventStreamView", () => {
1223
1223
  const filterSelect = getFilterSelect(element);
1224
1224
  const copyAllBtn = getCopyAllBtn(element);
1225
1225
 
1226
- // Immediately change filter this should bypass throttle (uses updateNow internally)
1226
+ // Immediately change filter: this should bypass throttle (uses updateNow internally)
1227
1227
  filterSelect.value = "step_chunk";
1228
1228
  filterSelect.__fireEvent("change");
1229
1229
 
@@ -1237,17 +1237,17 @@ describe("createEventStreamView", () => {
1237
1237
  const buffer = createMockBuffer([makeEvent("step_chunk", 1)]);
1238
1238
  const { update, destroy } = createEventStreamView({ buffer: buffer as any });
1239
1239
 
1240
- // First update immediate
1240
+ // First update: immediate
1241
1241
  update();
1242
1242
 
1243
1243
  // Schedule a throttled update
1244
1244
  buffer.push(makeEvent("step_chunk", 2));
1245
1245
  update();
1246
1246
 
1247
- // Destroy before rAF fires should not throw
1247
+ // Destroy before rAF fires: should not throw
1248
1248
  expect(() => destroy()).not.toThrow();
1249
1249
 
1250
- // Advancing timers to flush rAF should not throw even though view is destroyed
1250
+ // Advancing timers to flush rAF: should not throw even though view is destroyed
1251
1251
  vi.advanceTimersByTime(20);
1252
1252
  vi.useRealTimers();
1253
1253
  });
@@ -568,7 +568,7 @@ export function createEventStreamView(
568
568
  );
569
569
  throughputValueEl.textContent = "-- tok/s";
570
570
 
571
- // Custom hover tooltip appears instantly (no native title delay).
571
+ // Custom hover tooltip: appears instantly (no native title delay).
572
572
  // Appended to the (non-clipping) toolbar wrapper rather than the header
573
573
  // bar, which has overflow-hidden and would clip a dropdown. Position is
574
574
  // measured on hover so it sits just under the throughput group.
@@ -899,7 +899,7 @@ export function createEventStreamView(
899
899
  expandedSet.add(eventId);
900
900
  }
901
901
  dirtyExpandId = eventId;
902
- // Save scroll position user-initiated expand/collapse should not auto-scroll
902
+ // Save scroll position: user-initiated expand/collapse should not auto-scroll
903
903
  const savedScrollTop = eventsList.scrollTop;
904
904
  const wasAutoFollowing = autoFollow.isFollowing();
905
905
  suppressScrollHandler = true;
@@ -993,7 +993,7 @@ export function createEventStreamView(
993
993
  const isFirstRender = rowElements.size === 0 && filteredEvents.length > 0;
994
994
 
995
995
  if (filterChanged || isFirstRender || filteredEvents.length === 0) {
996
- // Path A Full rebuild (filter/search changed, first render, or empty list)
996
+ // Path A: Full rebuild (filter/search changed, first render, or empty list)
997
997
  eventsList.innerHTML = "";
998
998
  rowElements.clear();
999
999
  const fragment = document.createDocumentFragment();
@@ -1016,7 +1016,7 @@ export function createEventStreamView(
1016
1016
  lastRenderedSearch = searchTerm;
1017
1017
  dirtyExpandId = null;
1018
1018
  } else {
1019
- // Path B Single row replace (expand/collapse)
1019
+ // Path B: Single row replace (expand/collapse)
1020
1020
  if (dirtyExpandId !== null) {
1021
1021
  const oldRow = rowElements.get(dirtyExpandId);
1022
1022
  if (oldRow && oldRow.parentNode === eventsList) {
@@ -1043,7 +1043,7 @@ export function createEventStreamView(
1043
1043
  dirtyExpandId = null;
1044
1044
  }
1045
1045
 
1046
- // Path C Incremental append (default streaming path)
1046
+ // Path C: Incremental append (default streaming path)
1047
1047
  // Remove evicted rows
1048
1048
  const activeIds = new Set(filteredEvents.map((e) => e.id));
1049
1049
  for (const [id, el] of rowElements) {
@@ -1,4 +1,4 @@
1
- import { createElement } from "../utils/dom";
1
+ import { createElement, createNode } from "../utils/dom";
2
2
  import { renderLucideIcon } from "../utils/icons";
3
3
  import { AgentWidgetConfig } from "../types";
4
4
  import { createCloseButton, createClearChatButton } from "./header-parts";
@@ -38,15 +38,18 @@ export interface HeaderBuildContext {
38
38
  export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
39
39
  const { config, showClose = true } = context;
40
40
 
41
- const header = createElement(
42
- "div",
43
- "persona-widget-header persona-flex persona-items-center persona-gap-3 persona-px-6 persona-py-5"
44
- );
45
- header.setAttribute("data-persona-theme-zone", "header");
46
- header.style.backgroundColor = 'var(--persona-header-bg, var(--persona-surface, #ffffff))';
47
- header.style.borderBottomColor = 'var(--persona-header-border, var(--persona-divider, #f1f5f9))';
48
- header.style.boxShadow = 'var(--persona-header-shadow, none)';
49
- header.style.borderBottom = 'var(--persona-header-border-bottom, 1px solid var(--persona-header-border, var(--persona-divider, #f1f5f9)))';
41
+ const header = createNode("div", {
42
+ className:
43
+ "persona-widget-header persona-flex persona-items-center persona-gap-3 persona-px-6 persona-py-5",
44
+ attrs: { "data-persona-theme-zone": "header" },
45
+ style: {
46
+ backgroundColor: "var(--persona-header-bg, var(--persona-surface, #ffffff))",
47
+ borderBottomColor: "var(--persona-header-border, var(--persona-divider, #f1f5f9))",
48
+ boxShadow: "var(--persona-header-shadow, none)",
49
+ borderBottom:
50
+ "var(--persona-header-border-bottom, 1px solid var(--persona-header-border, var(--persona-divider, #f1f5f9)))",
51
+ },
52
+ });
50
53
 
51
54
  const launcher = config?.launcher ?? {};
52
55
  const headerIconSize = launcher.headerIconSize ?? "48px";
@@ -54,16 +57,16 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
54
57
  const headerIconHidden = launcher.headerIconHidden ?? false;
55
58
  const headerIconName = launcher.headerIconName;
56
59
 
57
- const iconHolder = createElement(
58
- "div",
59
- "persona-flex persona-items-center persona-justify-center persona-rounded-xl persona-text-xl"
60
- );
61
- iconHolder.style.height = headerIconSize;
62
- iconHolder.style.width = headerIconSize;
63
- iconHolder.style.backgroundColor =
64
- "var(--persona-header-icon-bg, var(--persona-primary, #0f0f0f))";
65
- iconHolder.style.color =
66
- "var(--persona-header-icon-fg, var(--persona-text-inverse, #ffffff))";
60
+ const iconHolder = createNode("div", {
61
+ className:
62
+ "persona-flex persona-items-center persona-justify-center persona-rounded-xl persona-text-xl",
63
+ style: {
64
+ height: headerIconSize,
65
+ width: headerIconSize,
66
+ backgroundColor: "var(--persona-header-icon-bg, var(--persona-primary, #0f0f0f))",
67
+ color: "var(--persona-header-icon-fg, var(--persona-text-inverse, #ffffff))",
68
+ },
69
+ });
67
70
 
68
71
  // Render icon based on priority: Lucide icon > iconUrl > agentIconText
69
72
  if (!headerIconHidden) {
@@ -93,13 +96,16 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
93
96
  }
94
97
 
95
98
  const headerCopy = createElement("div", "persona-flex persona-flex-col persona-flex-1 persona-min-w-0");
96
- const title = createElement("span", "persona-text-base persona-font-semibold");
97
- title.style.color = HEADER_THEME_CSS.titleColor;
98
- title.textContent = config?.launcher?.title ?? "Chat Assistant";
99
- const subtitle = createElement("span", "persona-text-xs");
100
- subtitle.style.color = HEADER_THEME_CSS.subtitleColor;
101
- subtitle.textContent =
102
- config?.launcher?.subtitle ?? "Here to help you get answers fast";
99
+ const title = createNode("span", {
100
+ className: "persona-text-base persona-font-semibold",
101
+ text: config?.launcher?.title ?? "Chat Assistant",
102
+ style: { color: HEADER_THEME_CSS.titleColor },
103
+ });
104
+ const subtitle = createNode("span", {
105
+ className: "persona-text-xs",
106
+ text: config?.launcher?.subtitle ?? "Here to help you get answers fast",
107
+ style: { color: HEADER_THEME_CSS.subtitleColor },
108
+ });
103
109
 
104
110
  headerCopy.append(title, subtitle);
105
111
 
@@ -121,7 +121,7 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
121
121
  header.style.borderBottom =
122
122
  'var(--persona-header-border-bottom, 1px solid var(--persona-header-border, var(--persona-divider, #f1f5f9)))';
123
123
 
124
- // Build the title area either a combo button (titleMenu) or standard title row
124
+ // Build the title area: either a combo button (titleMenu) or standard title row
125
125
  const titleMenuConfig = layoutHeaderConfig?.titleMenu;
126
126
  let titleRow: HTMLElement;
127
127
  let headerTitle: HTMLElement;
@@ -0,0 +1,90 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { createCloseButton, createClearChatButton } from "./header-parts";
5
+ import type { AgentWidgetConfig } from "../types";
6
+
7
+ const baseConfig: AgentWidgetConfig = { apiUrl: "/api" };
8
+
9
+ describe("createCloseButton", () => {
10
+ it("keeps the default utility classes when no style overrides are set", () => {
11
+ const { button } = createCloseButton(baseConfig, {});
12
+ expect(button.tagName).toBe("BUTTON");
13
+ expect(button.type).toBe("button");
14
+ expect(button.getAttribute("aria-label")).toBe("Close chat");
15
+ expect(button.classList.contains("persona-rounded-full")).toBe(true);
16
+ expect(button.classList.contains("persona-border-none")).toBe(true);
17
+ expect(button.classList.contains("hover:persona-bg-gray-100")).toBe(true);
18
+ });
19
+
20
+ it("hides the button when showClose is false", () => {
21
+ const { button } = createCloseButton(baseConfig, { showClose: false });
22
+ expect(button.style.display).toBe("none");
23
+ });
24
+
25
+ it("drops each default class and sets the inline style for the matching override", () => {
26
+ const { button } = createCloseButton(
27
+ {
28
+ ...baseConfig,
29
+ launcher: {
30
+ closeButtonBackgroundColor: "rgb(1, 2, 3)",
31
+ closeButtonBorderRadius: "4px",
32
+ closeButtonBorderWidth: "2px",
33
+ closeButtonBorderColor: "rgb(4, 5, 6)",
34
+ },
35
+ },
36
+ {}
37
+ );
38
+ expect(button.classList.contains("hover:persona-bg-gray-100")).toBe(false);
39
+ expect(button.style.backgroundColor).toBe("rgb(1, 2, 3)");
40
+ expect(button.classList.contains("persona-rounded-full")).toBe(false);
41
+ expect(button.style.borderRadius).toBe("4px");
42
+ expect(button.classList.contains("persona-border-none")).toBe(false);
43
+ expect(button.style.borderWidth).toBe("2px");
44
+ expect(button.style.borderStyle).toBe("solid");
45
+ });
46
+
47
+ it("defaults border width to 0px when only border color is provided", () => {
48
+ const { button } = createCloseButton(
49
+ { ...baseConfig, launcher: { closeButtonBorderColor: "rgb(7, 8, 9)" } },
50
+ {}
51
+ );
52
+ expect(button.classList.contains("persona-border-none")).toBe(false);
53
+ expect(button.style.borderWidth).toBe("0px");
54
+ expect(button.style.borderStyle).toBe("solid");
55
+ });
56
+ });
57
+
58
+ describe("createClearChatButton", () => {
59
+ it("keeps the default utility classes when no style overrides are set", () => {
60
+ const { button } = createClearChatButton(baseConfig, {});
61
+ expect(button.type).toBe("button");
62
+ expect(button.classList.contains("persona-rounded-full")).toBe(true);
63
+ expect(button.classList.contains("persona-border-none")).toBe(true);
64
+ expect(button.classList.contains("hover:persona-bg-gray-100")).toBe(true);
65
+ });
66
+
67
+ it("drops each default class and sets the inline style for the matching override", () => {
68
+ const { button } = createClearChatButton(
69
+ {
70
+ ...baseConfig,
71
+ launcher: {
72
+ clearChat: {
73
+ backgroundColor: "rgb(1, 2, 3)",
74
+ borderRadius: "4px",
75
+ borderWidth: "2px",
76
+ borderColor: "rgb(4, 5, 6)",
77
+ },
78
+ },
79
+ },
80
+ {}
81
+ );
82
+ expect(button.classList.contains("hover:persona-bg-gray-100")).toBe(false);
83
+ expect(button.style.backgroundColor).toBe("rgb(1, 2, 3)");
84
+ expect(button.classList.contains("persona-rounded-full")).toBe(false);
85
+ expect(button.style.borderRadius).toBe("4px");
86
+ expect(button.classList.contains("persona-border-none")).toBe(false);
87
+ expect(button.style.borderWidth).toBe("2px");
88
+ expect(button.style.borderStyle).toBe("solid");
89
+ });
90
+ });
@@ -1,4 +1,4 @@
1
- import { createElement, createElementInDocument } from "../utils/dom";
1
+ import { createElement, createElementInDocument, createNode, cx } from "../utils/dom";
2
2
  import { renderLucideIcon } from "../utils/icons";
3
3
  import { AgentWidgetConfig } from "../types";
4
4
  import { PORTALED_OVERLAY_Z_INDEX } from "../utils/constants";
@@ -25,14 +25,14 @@ export interface CreateCloseButtonOptions {
25
25
  /**
26
26
  * Explicit button-size override that wins over `launcher.closeButtonSize`.
27
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
28
+ * take precedence over the global launcher config: e.g. composer-bar's
29
29
  * minimal close icon, where size is part of the mode's UX, not something
30
30
  * that should inherit from the floating launcher's button size.
31
31
  */
32
32
  buttonSize?: string;
33
33
  /**
34
34
  * Override the rendered icon size (default: "28px"). Pair with
35
- * `buttonSize` when scaling the whole control down otherwise the
35
+ * `buttonSize` when scaling the whole control down: otherwise the
36
36
  * 28px icon will overflow a smaller button.
37
37
  */
38
38
  iconSize?: string;
@@ -85,24 +85,41 @@ export const createCloseButton = (
85
85
 
86
86
  const wrapper = createElement("div", wrapperClassName);
87
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
88
  const closeButtonTooltipText = launcher.closeButtonTooltipText ?? "Close chat";
97
89
  const closeButtonShowTooltip = launcher.closeButtonShowTooltip ?? true;
98
-
99
- button.setAttribute("aria-label", closeButtonTooltipText);
100
- button.style.display = showClose ? "" : "none";
101
-
102
90
  const closeButtonIconName = launcher.closeButtonIconName ?? "x";
103
91
  const closeButtonIconText = launcher.closeButtonIconText ?? "×";
104
- button.style.color =
105
- launcher.closeButtonColor || HEADER_THEME_CSS.actionIconColor;
92
+ // hover-bg / border-none / rounded-full are default utility classes that
93
+ // apply only when the matching style override is absent; an override sets
94
+ // the inline style instead (cx omits the class).
95
+ const hasCloseBorder = Boolean(
96
+ launcher.closeButtonBorderWidth || launcher.closeButtonBorderColor
97
+ );
98
+
99
+ const button = createNode("button", {
100
+ className: cx(
101
+ "persona-inline-flex persona-items-center persona-justify-center persona-cursor-pointer",
102
+ !launcher.closeButtonBackgroundColor && "hover:persona-bg-gray-100",
103
+ !hasCloseBorder && "persona-border-none",
104
+ !launcher.closeButtonBorderRadius && "persona-rounded-full"
105
+ ),
106
+ attrs: { type: "button", "aria-label": closeButtonTooltipText },
107
+ style: {
108
+ height: closeButtonSize,
109
+ width: closeButtonSize,
110
+ display: showClose ? undefined : "none",
111
+ color: launcher.closeButtonColor || HEADER_THEME_CSS.actionIconColor,
112
+ backgroundColor: launcher.closeButtonBackgroundColor || undefined,
113
+ border: hasCloseBorder
114
+ ? `${launcher.closeButtonBorderWidth || "0px"} solid ${launcher.closeButtonBorderColor || "transparent"}`
115
+ : undefined,
116
+ borderRadius: launcher.closeButtonBorderRadius || undefined,
117
+ paddingLeft: launcher.closeButtonPaddingX || undefined,
118
+ paddingRight: launcher.closeButtonPaddingX || undefined,
119
+ paddingTop: launcher.closeButtonPaddingY || undefined,
120
+ paddingBottom: launcher.closeButtonPaddingY || undefined,
121
+ },
122
+ });
106
123
 
107
124
  // The X glyph's paths occupy only the middle 50% of its 24x24 viewBox
108
125
  // (from 6,6 to 18,18), while other header icons (e.g. refresh-cw) span
@@ -118,47 +135,6 @@ export const createCloseButton = (
118
135
  button.textContent = closeButtonIconText;
119
136
  }
120
137
 
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
138
  wrapper.appendChild(button);
163
139
 
164
140
  if (closeButtonShowTooltip && closeButtonTooltipText) {
@@ -259,15 +235,34 @@ export const createClearChatButton = (
259
235
 
260
236
  const wrapper = createElement("div", wrapperClassName);
261
237
 
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;
238
+ // hover-bg / border-none / rounded-full are default utility classes that
239
+ // apply only when the matching style override is absent; an override sets
240
+ // the inline style instead (cx omits the class).
241
+ const hasClearChatBorder = Boolean(clearChatBorderWidth || clearChatBorderColor);
242
+
243
+ const button = createNode("button", {
244
+ className: cx(
245
+ "persona-inline-flex persona-items-center persona-justify-center persona-cursor-pointer",
246
+ !clearChatBgColor && "hover:persona-bg-gray-100",
247
+ !hasClearChatBorder && "persona-border-none",
248
+ !clearChatBorderRadius && "persona-rounded-full"
249
+ ),
250
+ attrs: { type: "button", "aria-label": clearChatTooltipText },
251
+ style: {
252
+ height: clearChatSize,
253
+ width: clearChatSize,
254
+ color: clearChatIconColor || HEADER_THEME_CSS.actionIconColor,
255
+ backgroundColor: clearChatBgColor || undefined,
256
+ border: hasClearChatBorder
257
+ ? `${clearChatBorderWidth || "0px"} solid ${clearChatBorderColor || "transparent"}`
258
+ : undefined,
259
+ borderRadius: clearChatBorderRadius || undefined,
260
+ paddingLeft: clearChatPaddingX || undefined,
261
+ paddingRight: clearChatPaddingX || undefined,
262
+ paddingTop: clearChatPaddingY || undefined,
263
+ paddingBottom: clearChatPaddingY || undefined,
264
+ },
265
+ });
271
266
 
272
267
  const iconSvg = renderLucideIcon(clearChatIconName, iconSize, "currentColor", 1);
273
268
  if (iconSvg) {
@@ -275,32 +270,6 @@ export const createClearChatButton = (
275
270
  button.appendChild(iconSvg);
276
271
  }
277
272
 
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
273
  wrapper.appendChild(button);
305
274
 
306
275
  if (clearChatShowTooltip && clearChatTooltipText) {
@@ -5,6 +5,17 @@ import { createLauncherButton } from "./launcher";
5
5
  import { DEFAULT_WIDGET_CONFIG } from "../defaults";
6
6
 
7
7
  describe("createLauncherButton", () => {
8
+ it("uses the package default subtitle when no subtitle is configured", () => {
9
+ const { element, update } = createLauncherButton(undefined, () => {});
10
+ update({});
11
+ const subtitleEl = element.querySelector("[data-role='launcher-subtitle']");
12
+ expect(subtitleEl?.textContent).toBe("Here to help you get answers fast");
13
+ expect(subtitleEl?.getAttribute("title")).toBe(
14
+ "Here to help you get answers fast"
15
+ );
16
+ element.remove();
17
+ });
18
+
8
19
  it("applies collapsedMaxWidth when set", () => {
9
20
  const { element, update } = createLauncherButton(undefined, () => {});
10
21
  update({
@@ -41,7 +41,7 @@ export const createLauncherButton = (
41
41
 
42
42
  const subtitleEl = button.querySelector("[data-role='launcher-subtitle']");
43
43
  if (subtitleEl) {
44
- const s = launcher.subtitle ?? "Get answers fast";
44
+ const s = launcher.subtitle ?? "Here to help you get answers fast";
45
45
  subtitleEl.textContent = s;
46
46
  subtitleEl.setAttribute("title", s);
47
47
  }
@@ -155,7 +155,7 @@ describe("resolveStopReasonNoticeText", () => {
155
155
  });
156
156
  });
157
157
 
158
- describe("createStandardBubble stopReason notice", () => {
158
+ describe("createStandardBubble: stopReason notice", () => {
159
159
  const renderWithStopReason = (
160
160
  overrides: Partial<AgentWidgetMessage>,
161
161
  widgetConfig?: Partial<AgentWidgetConfig>
@@ -333,7 +333,7 @@ describe("isSafeMediaSrc", () => {
333
333
  });
334
334
  });
335
335
 
336
- describe("createStandardBubble audio/video/file content parts", () => {
336
+ describe("createStandardBubble: audio/video/file content parts", () => {
337
337
  it("renders an <audio> element with controls for an audio content part", () => {
338
338
  const bubble = createStandardBubble(
339
339
  makeMessage({
@@ -466,3 +466,75 @@ describe("createStandardBubble — audio/video/file content parts", () => {
466
466
  expect(link?.getAttribute("href")).toBe("https://example.com/report.pdf");
467
467
  });
468
468
  });
469
+
470
+ describe("createStandardBubble: timestamp position", () => {
471
+ it("tucks an inline timestamp inside the last content block so it trails the text", () => {
472
+ const bubble = createStandardBubble(
473
+ makeMessage({ content: "Hello there" }),
474
+ ({ text }) => `<p>${text}</p>`,
475
+ { timestamp: { show: true, position: "inline" } }
476
+ );
477
+
478
+ // Markdown content is block-wrapped; the inline timestamp must live inside
479
+ // that block (trailing the text), not as a block sibling under the bubble.
480
+ const inline = bubble.querySelector("p .persona-timestamp-inline");
481
+ expect(inline).not.toBeNull();
482
+ expect(inline?.textContent).toMatch(/\d/);
483
+ expect(Array.from(bubble.children)).not.toContain(inline);
484
+ // Must be an inline-level <span>; a <div> here is invalid inside a <p>.
485
+ expect(inline?.tagName).toBe("SPAN");
486
+ });
487
+
488
+ it("uses an inline element that survives an innerHTML re-parse (idiomorph round-trip)", () => {
489
+ const bubble = createStandardBubble(
490
+ makeMessage({ content: "Hello there" }),
491
+ ({ text }) => `<p>${text}</p>`,
492
+ { timestamp: { show: true, position: "inline" } }
493
+ );
494
+ const content = bubble.querySelector(".persona-message-content") as HTMLElement;
495
+
496
+ // Re-serializing + re-parsing is what the re-render path does. A <div>
497
+ // inside a <p> would be auto-closed out to a sibling (leaving an empty
498
+ // <p>), dropping the timestamp onto its own line; a <span> stays put.
499
+ const serialized = content.innerHTML;
500
+ content.innerHTML = serialized;
501
+
502
+ expect(content.querySelector("p .persona-timestamp-inline")).not.toBeNull();
503
+ const emptyParagraphs = Array.from(content.querySelectorAll("p")).filter(
504
+ (p) => p.textContent === ""
505
+ );
506
+ expect(emptyParagraphs.length).toBe(0);
507
+ });
508
+
509
+ it("falls back to the content container for plain-text (non-block) content", () => {
510
+ const bubble = createStandardBubble(
511
+ makeMessage({ content: "Hello there" }),
512
+ ({ text }) => text,
513
+ { timestamp: { show: true, position: "inline" } }
514
+ );
515
+
516
+ const content = bubble.querySelector(".persona-message-content")!;
517
+ const inline = content.querySelector(".persona-timestamp-inline");
518
+ expect(inline).not.toBeNull();
519
+ expect(content.contains(inline)).toBe(true);
520
+ });
521
+
522
+ it("renders a below timestamp as a block under the content (direct bubble child)", () => {
523
+ const bubble = createStandardBubble(
524
+ makeMessage({ content: "Hello there" }),
525
+ ({ text }) => `<p>${text}</p>`,
526
+ { timestamp: { show: true, position: "below" } }
527
+ );
528
+
529
+ // "below" must not produce an inline timestamp, and the timestamp block is
530
+ // appended to the bubble (under the content), not nested in the content.
531
+ expect(bubble.querySelector(".persona-timestamp-inline")).toBeNull();
532
+ const below = Array.from(bubble.children).find(
533
+ (el) =>
534
+ el.classList.contains("persona-mt-1") &&
535
+ el.classList.contains("persona-text-xs")
536
+ );
537
+ expect(below).toBeDefined();
538
+ expect(below?.textContent).toMatch(/\d/);
539
+ });
540
+ });