@runtypelabs/persona 3.34.1 → 3.36.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 +56 -12
  53. package/src/client.ts +35 -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
@@ -1,4 +1,4 @@
1
- import { createElement } from "../utils/dom";
1
+ import { createElement, createNode } from "../utils/dom";
2
2
  import { DEFAULT_FLOATING_LAUNCHER_WIDTH } from "../defaults";
3
3
  import { AgentWidgetConfig } from "../types";
4
4
  import { positionMap } from "../utils/positioning";
@@ -16,8 +16,7 @@ export interface PanelWrapper {
16
16
  /**
17
17
  * Composer-bar mode only: viewport-fixed sibling of `wrapper` that owns
18
18
  * the persistent pill (`footer`) and peek banner. Lives outside the
19
- * wrapper so it never inherits the wrapper's geometry transitions
20
- * critical for modal mode where the wrapper is `transform: translate(-50%, -50%)`
19
+ * wrapper so it never inherits the wrapper's geometry transitions: * critical for modal mode where the wrapper is `transform: translate(-50%, -50%)`
21
20
  * (a transformed ancestor establishes a containing block for `position: fixed`
22
21
  * descendants, which would trap the pill inside the wrapper otherwise).
23
22
  */
@@ -32,7 +31,7 @@ export const createWrapper = (config?: AgentWidgetConfig): PanelWrapper => {
32
31
  if (composerBarMode) {
33
32
  const cb = config?.launcher?.composerBar ?? {};
34
33
  // Geometry (left/transform/bottom/top/width/max-width) is intentionally
35
- // NOT set here it's owned entirely by `applyComposerBarGeometry()` in
34
+ // NOT set here: it's owned entirely by `applyComposerBarGeometry()` in
36
35
  // ui.ts so that collapsed → expanded transitions can clear the previous
37
36
  // state's inline styles cleanly. Setting geometry here would persist
38
37
  // across state changes and override the per-state values, which
@@ -55,7 +54,7 @@ export const createWrapper = (config?: AgentWidgetConfig): PanelWrapper => {
55
54
  panel.style.width = "100%";
56
55
  wrapper.appendChild(panel);
57
56
 
58
- // Pill lives in a separate viewport-fixed sibling see PanelWrapper
57
+ // Pill lives in a separate viewport-fixed sibling: see PanelWrapper
59
58
  // docs above. ui.ts appends `peekBanner` and `footer` here, then
60
59
  // appends pillRoot to `mount` immediately after the wrapper.
61
60
  const pillRoot = createElement("div", "persona-widget-pill-root");
@@ -182,7 +181,7 @@ export interface PanelElements {
182
181
  /**
183
182
  * Composer-bar panel: minimal close-only header (× in the top-right of the
184
183
  * chat chrome). The pill (`footer`) and `peekBanner` are NOT children of
185
- * the panel caller (`ui.ts`) appends them to the `pillRoot` returned by
184
+ * the panel: caller (`ui.ts`) appends them to the `pillRoot` returned by
186
185
  * `createWrapper`, which is a viewport-fixed sibling of the wrapper.
187
186
  * This decouples the pill from the wrapper's geometry transitions (so the
188
187
  * pill stays anchored at the viewport bottom regardless of expanded mode).
@@ -200,7 +199,7 @@ const buildComposerBarPanel = (
200
199
  );
201
200
  container.setAttribute("data-persona-theme-zone", "container");
202
201
 
203
- // Minimal header just an absolutely-positioned close button.
202
+ // Minimal header: just an absolutely-positioned close button.
204
203
  // The wrapper uses inline styles (top/right/z-index values) because the
205
204
  // widget's hand-authored CSS doesn't ship every Tailwind utility.
206
205
  // Composer-bar's defaults are roughly half the floating-launcher's
@@ -220,7 +219,7 @@ const buildComposerBarPanel = (
220
219
  closeButtonWrapper.style.right = "8px";
221
220
  closeButtonWrapper.style.zIndex = "10";
222
221
 
223
- // Clear / "start over" button sits immediately to the left of the close
222
+ // Clear / "start over" button: sits immediately to the left of the close
224
223
  // button in the panel chrome's top-right corner. Same minimal sizing as
225
224
  // the close icon (16px button, 14px icon) so the two read as a paired
226
225
  // action group rather than a header strip. Wired by `setupClearChatButton()`
@@ -246,46 +245,50 @@ const buildComposerBarPanel = (
246
245
  }
247
246
  // Placeholder header element so PanelElements.header exists (some downstream
248
247
  // code reads it). It carries `data-persona-widget-header` for plugin /
249
- // selector parity but renders nothing visible the close button is the only
248
+ // selector parity but renders nothing visible: the close button is the only
250
249
  // header chrome in composer-bar mode.
251
- const headerPlaceholder = createElement("span", "persona-widget-header");
252
- headerPlaceholder.setAttribute("data-persona-theme-zone", "header");
253
- headerPlaceholder.style.display = "none";
250
+ const headerPlaceholder = createNode("span", {
251
+ className: "persona-widget-header",
252
+ attrs: { "data-persona-theme-zone": "header" },
253
+ style: { display: "none" },
254
+ });
254
255
 
255
- // Body extra top padding (set inline so the hand-authored widget.css
256
+ // Body: extra top padding (set inline so the hand-authored widget.css
256
257
  // doesn't need a `pt-12` utility) so the absolute close button doesn't
257
258
  // overlap the welcome card / first message.
258
- const body = createElement(
259
- "div",
260
- "persona-widget-body persona-flex persona-flex-1 persona-min-h-0 persona-flex-col persona-gap-6 persona-overflow-y-auto persona-bg-persona-container persona-px-6 persona-py-6"
261
- );
262
- body.style.paddingTop = "48px";
263
- body.id = "persona-scroll-container";
264
- body.setAttribute("data-persona-theme-zone", "messages");
259
+ const body = createNode("div", {
260
+ className:
261
+ "persona-widget-body persona-flex persona-flex-1 persona-min-h-0 persona-flex-col persona-gap-6 persona-overflow-y-auto persona-bg-persona-container persona-px-6 persona-py-6",
262
+ attrs: { id: "persona-scroll-container", "data-persona-theme-zone": "messages" },
263
+ style: { paddingTop: "48px" },
264
+ });
265
265
  // Reserve the scrollbar gutter so the transcript doesn't shift horizontally
266
266
  // when streaming content first overflows and the scrollbar appears.
267
267
  body.style.setProperty("scrollbar-gutter", "stable");
268
268
 
269
- const introCard = createElement(
269
+ const introTitle = createNode("h2", {
270
+ className: "persona-text-lg persona-font-semibold persona-text-persona-primary",
271
+ text: config?.copy?.welcomeTitle ?? "Hello 👋",
272
+ });
273
+ const introSubtitle = createNode("p", {
274
+ className: "persona-mt-2 persona-text-sm persona-text-persona-muted",
275
+ text:
276
+ config?.copy?.welcomeSubtitle ??
277
+ "Ask anything about your account or products.",
278
+ });
279
+ const introCard = createNode(
270
280
  "div",
271
- "persona-rounded-2xl persona-bg-persona-surface persona-p-6"
272
- );
273
- introCard.style.boxShadow =
274
- "var(--persona-intro-card-shadow, 0 5px 15px rgba(15, 23, 42, 0.08))";
275
- introCard.setAttribute("data-persona-intro-card", "");
276
- const introTitle = createElement(
277
- "h2",
278
- "persona-text-lg persona-font-semibold persona-text-persona-primary"
279
- );
280
- introTitle.textContent = config?.copy?.welcomeTitle ?? "Hello 👋";
281
- const introSubtitle = createElement(
282
- "p",
283
- "persona-mt-2 persona-text-sm persona-text-persona-muted"
281
+ {
282
+ className: "persona-rounded-2xl persona-bg-persona-surface persona-p-6",
283
+ attrs: { "data-persona-intro-card": "" },
284
+ style: {
285
+ boxShadow:
286
+ "var(--persona-intro-card-shadow, 0 5px 15px rgba(15, 23, 42, 0.08))",
287
+ },
288
+ },
289
+ introTitle,
290
+ introSubtitle
284
291
  );
285
- introSubtitle.textContent =
286
- config?.copy?.welcomeSubtitle ??
287
- "Ask anything about your account or products.";
288
- introCard.append(introTitle, introSubtitle);
289
292
 
290
293
  const messagesWrapper = createElement(
291
294
  "div",
@@ -309,23 +312,18 @@ const buildComposerBarPanel = (
309
312
 
310
313
  // Composer overlay (interactive sheets like ask_user_question slide up here).
311
314
  // Anchored to the bottom of the container (which is `position: relative`),
312
- // so sheets render at the bottom of the chat chrome just above the gap +
315
+ // so sheets render at the bottom of the chat chrome: just above the gap +
313
316
  // pill that sit below the container.
314
- const composerOverlay = createElement(
315
- "div",
316
- "persona-composer-overlay persona-pointer-events-none"
317
- );
318
- composerOverlay.setAttribute("data-persona-composer-overlay", "");
319
- composerOverlay.style.position = "absolute";
320
- composerOverlay.style.left = "0";
321
- composerOverlay.style.right = "0";
322
- composerOverlay.style.bottom = "0";
323
- composerOverlay.style.zIndex = "20";
324
-
325
- // Pill composer — caller appends as a sibling of container in the panel.
317
+ const composerOverlay = createNode("div", {
318
+ className: "persona-composer-overlay persona-pointer-events-none",
319
+ attrs: { "data-persona-composer-overlay": "" },
320
+ style: { position: "absolute", left: "0", right: "0", bottom: "0", zIndex: "20" },
321
+ });
322
+
323
+ // Pill composer: caller appends as a sibling of container in the panel.
326
324
  const composerElements: ComposerElements = buildPillComposer({ config });
327
325
 
328
- // Peek banner caller inserts as a sibling of container/footer between
326
+ // Peek banner: caller inserts as a sibling of container/footer between
329
327
  // them in the panel. Hidden by default; ui.ts toggles visibility.
330
328
  const { root: peekBanner, textNode: peekTextNode } = buildPillPeekBanner();
331
329
 
@@ -382,11 +380,11 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
382
380
  return buildComposerBarPanel(config, showClose);
383
381
  }
384
382
 
385
- const container = createElement(
386
- "div",
387
- "persona-widget-container persona-flex persona-h-full persona-w-full persona-flex-1 persona-min-h-0 persona-flex-col persona-text-persona-primary persona-bg-persona-surface persona-rounded-2xl persona-overflow-hidden persona-border persona-border-persona-border"
388
- );
389
- container.setAttribute("data-persona-theme-zone", "container");
383
+ const container = createNode("div", {
384
+ className:
385
+ "persona-widget-container persona-flex persona-h-full persona-w-full persona-flex-1 persona-min-h-0 persona-flex-col persona-text-persona-primary persona-bg-persona-surface persona-rounded-2xl persona-overflow-hidden persona-border persona-border-persona-border",
386
+ attrs: { "data-persona-theme-zone": "container" },
387
+ });
390
388
 
391
389
  // Build header using layout config if available, otherwise use standard builder
392
390
  const headerLayoutConfig = config?.layout?.header;
@@ -396,40 +394,43 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
396
394
  : buildHeader({ config, showClose });
397
395
 
398
396
  // Build body with intro card and messages wrapper
399
- const body = createElement(
400
- "div",
401
- "persona-widget-body persona-flex persona-flex-1 persona-min-h-0 persona-flex-col persona-gap-6 persona-overflow-y-auto persona-bg-persona-container persona-px-6 persona-py-6"
402
- );
403
- body.id = "persona-scroll-container";
404
- body.setAttribute("data-persona-theme-zone", "messages");
397
+ const body = createNode("div", {
398
+ className:
399
+ "persona-widget-body persona-flex persona-flex-1 persona-min-h-0 persona-flex-col persona-gap-6 persona-overflow-y-auto persona-bg-persona-container persona-px-6 persona-py-6",
400
+ attrs: { id: "persona-scroll-container", "data-persona-theme-zone": "messages" },
401
+ });
405
402
  // Reserve the scrollbar gutter so the transcript doesn't shift horizontally
406
403
  // when streaming content first overflows and the scrollbar appears.
407
404
  body.style.setProperty("scrollbar-gutter", "stable");
408
405
 
409
- const introCard = createElement(
410
- "div",
411
- "persona-rounded-2xl persona-bg-persona-surface persona-p-6"
412
- );
406
+ const introTitle = createNode("h2", {
407
+ className: "persona-text-lg persona-font-semibold persona-text-persona-primary",
408
+ text: config?.copy?.welcomeTitle ?? "Hello 👋",
409
+ });
410
+ const introSubtitle = createNode("p", {
411
+ className: "persona-mt-2 persona-text-sm persona-text-persona-muted",
412
+ text:
413
+ config?.copy?.welcomeSubtitle ??
414
+ "Ask anything about your account or products.",
415
+ });
413
416
  // Box-shadow flows through the themable `components.introCard.shadow` token
414
417
  // (--persona-intro-card-shadow). Docked mode keeps a flat look by default;
415
418
  // floating mode falls back to the legacy `persona-shadow-sm` value when no
416
419
  // token is set.
417
- introCard.style.boxShadow = isDockedMountMode(config)
418
- ? "none"
419
- : "var(--persona-intro-card-shadow, 0 5px 15px rgba(15, 23, 42, 0.08))";
420
- const introTitle = createElement(
421
- "h2",
422
- "persona-text-lg persona-font-semibold persona-text-persona-primary"
423
- );
424
- introTitle.textContent = config?.copy?.welcomeTitle ?? "Hello 👋";
425
- const introSubtitle = createElement(
426
- "p",
427
- "persona-mt-2 persona-text-sm persona-text-persona-muted"
420
+ const introCard = createNode(
421
+ "div",
422
+ {
423
+ className: "persona-rounded-2xl persona-bg-persona-surface persona-p-6",
424
+ attrs: { "data-persona-intro-card": "" },
425
+ style: {
426
+ boxShadow: isDockedMountMode(config)
427
+ ? "none"
428
+ : "var(--persona-intro-card-shadow, 0 5px 15px rgba(15, 23, 42, 0.08))",
429
+ },
430
+ },
431
+ introTitle,
432
+ introSubtitle
428
433
  );
429
- introSubtitle.textContent =
430
- config?.copy?.welcomeSubtitle ??
431
- "Ask anything about your account or products.";
432
- introCard.append(introTitle, introSubtitle);
433
434
 
434
435
  const messagesWrapper = createElement(
435
436
  "div",
@@ -444,7 +445,6 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
444
445
  messagesWrapper.style.width = "100%";
445
446
  }
446
447
 
447
- introCard.setAttribute("data-persona-intro-card", "");
448
448
  const showWelcomeCard = config?.copy?.showWelcomeCard !== false;
449
449
  if (!showWelcomeCard) {
450
450
  introCard.style.display = "none";
@@ -473,21 +473,16 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
473
473
  // above the composer so sheets (e.g. the ask_user_question answer-pill sheet)
474
474
  // can slide up without reflowing the chat transcript above. Uses inline
475
475
  // styles for left/right/bottom because widget.css is hand-authored and
476
- // doesn't ship `.persona-left-0` / `.persona-right-0` rules without
476
+ // doesn't ship `.persona-left-0` / `.persona-right-0` rules: without
477
477
  // them the overlay shrink-wraps to content and collapses the sheet width.
478
- const composerOverlay = createElement(
479
- "div",
480
- "persona-composer-overlay persona-pointer-events-none"
481
- );
482
- composerOverlay.setAttribute("data-persona-composer-overlay", "");
483
- composerOverlay.style.position = "absolute";
484
- composerOverlay.style.left = "0";
485
- composerOverlay.style.right = "0";
486
- composerOverlay.style.bottom = "0";
487
- // Above .persona-scroll-to-bottom-indicator (z-index 10, sibling in the
488
- // container) so suggestion chips and the ask-user-question sheet are not
489
- // covered by the "jump to latest" button.
490
- composerOverlay.style.zIndex = "20";
478
+ // zIndex 20 sits above .persona-scroll-to-bottom-indicator (z-index 10,
479
+ // sibling in the container) so suggestion chips and the ask-user-question
480
+ // sheet are not covered by the "jump to latest" button.
481
+ const composerOverlay = createNode("div", {
482
+ className: "persona-composer-overlay persona-pointer-events-none",
483
+ attrs: { "data-persona-composer-overlay": "" },
484
+ style: { position: "absolute", left: "0", right: "0", bottom: "0", zIndex: "20" },
485
+ });
491
486
 
492
487
  if (showFooter) {
493
488
  container.append(composerElements.footer);
@@ -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 { ComposerBuildContext, ComposerElements } from "./composer-builder";
4
4
  import {
@@ -12,7 +12,7 @@ import {
12
12
 
13
13
  export interface PillPeekBanner {
14
14
  /**
15
- * The peek button itself a chrome-less row that floats above the pill,
15
+ * The peek button itself: a chrome-less row that floats above the pill,
16
16
  * showing a chat-bubble icon, a trailing-100-char preview of the most
17
17
  * recent assistant message, and a chevron-up. Rendered hidden by default
18
18
  * (opacity 0, pointer-events none); ui.ts toggles
@@ -25,8 +25,7 @@ export interface PillPeekBanner {
25
25
 
26
26
  /**
27
27
  * Build the peek banner for `launcher.mountMode: "composer-bar"`. The peek
28
- * is the user's path back into the expanded chat from the collapsed pill
29
- * it fades in during streaming OR on composer hover, and clicking it opens
28
+ * is the user's path back into the expanded chat from the collapsed pill: * it fades in during streaming OR on composer hover, and clicking it opens
30
29
  * the panel. ui.ts owns visibility + content updates via
31
30
  * `syncComposerBarPeek`; this factory just produces the inert DOM shell.
32
31
  *
@@ -34,11 +33,15 @@ export interface PillPeekBanner {
34
33
  * just above the pill in the collapsed-state UI.
35
34
  */
36
35
  export const buildPillPeekBanner = (): PillPeekBanner => {
37
- const root = createElement("button", "persona-pill-peek") as HTMLButtonElement;
38
- root.type = "button";
39
- root.setAttribute("data-persona-pill-peek", "");
40
- root.setAttribute("aria-label", "Show conversation");
41
- root.setAttribute("tabindex", "-1");
36
+ const root = createNode("button", {
37
+ className: "persona-pill-peek",
38
+ attrs: {
39
+ type: "button",
40
+ "data-persona-pill-peek": "",
41
+ "aria-label": "Show conversation",
42
+ tabindex: "-1",
43
+ },
44
+ });
42
45
 
43
46
  const iconHolder = createElement("span", "persona-pill-peek__icon");
44
47
  const messageIcon = renderLucideIcon("message-square", 16, "currentColor", 1.5);
@@ -61,7 +64,7 @@ export const buildPillPeekBanner = (): PillPeekBanner => {
61
64
  /**
62
65
  * Single-row pill composer for `launcher.mountMode: "composer-bar"`.
63
66
  *
64
- * Same control factories as `buildComposer` the only difference is the
67
+ * Same control factories as `buildComposer`: the only difference is the
65
68
  * layout shell + className. The form ships with `persona-pill-composer`
66
69
  * (no `persona-flex-col` / `persona-rounded-2xl` baggage), so the CSS
67
70
  * layout rules apply at normal specificity without `!important` fights.
@@ -71,7 +74,7 @@ export const buildPillPeekBanner = (): PillPeekBanner => {
71
74
  *
72
75
  * Suggestions row + status text are built (so plugin code that mutates
73
76
  * them keeps working and `bindComposerRefsFromFooter` finds them) but are
74
- * `display: none` by default pill UX is just textarea + 3 buttons.
77
+ * `display: none` by default: pill UX is just textarea + 3 buttons.
75
78
  *
76
79
  * Attachment previews float ABOVE the pill in their own row when
77
80
  * AttachmentManager toggles the previews container's `display` property
@@ -80,8 +83,10 @@ export const buildPillPeekBanner = (): PillPeekBanner => {
80
83
  export const buildPillComposer = (context: ComposerBuildContext): ComposerElements => {
81
84
  const { config } = context;
82
85
 
83
- const footer = createElement("div", "persona-widget-footer persona-widget-footer--pill");
84
- footer.setAttribute("data-persona-theme-zone", "composer");
86
+ const footer = createNode("div", {
87
+ className: "persona-widget-footer persona-widget-footer--pill",
88
+ attrs: { "data-persona-theme-zone": "composer" },
89
+ });
85
90
 
86
91
  const suggestions = createSuggestionsRow();
87
92
  suggestions.style.display = "none";
@@ -105,12 +110,11 @@ export const buildPillComposer = (context: ComposerBuildContext): ComposerElemen
105
110
 
106
111
  // Pill form: NO `persona-flex-col`. Only the marker classes that the rest
107
112
  // of the codebase queries by name.
108
- const composerForm = createElement(
109
- "form",
110
- "persona-widget-composer persona-pill-composer"
111
- ) as HTMLFormElement;
112
- composerForm.setAttribute("data-persona-composer-form", "");
113
- composerForm.style.outline = "none";
113
+ const composerForm = createNode("form", {
114
+ className: "persona-widget-composer persona-pill-composer",
115
+ attrs: { "data-persona-composer-form": "" },
116
+ style: { outline: "none" },
117
+ });
114
118
 
115
119
  // Three columns of the grid: [paperclip?] · textarea · mic + send.
116
120
  // The empty leftActions wrapper still ships when attachments are off so
@@ -154,11 +158,11 @@ export const buildPillComposer = (context: ComposerBuildContext): ComposerElemen
154
158
 
155
159
  // The pill flattens left/right into the form's grid; there's no separate
156
160
  // wrapper. Surface the form itself as `actionsRow` to satisfy the
157
- // ComposerElements contract downstream code only treats it as an
158
- // opaque ref. `bindComposerRefsFromFooter` queries for the legacy
159
- // `.persona-flex.persona-items-center.persona-justify-between` class
160
- // selector and won't find one in pill mode; that lookup writes to
161
- // `_actionsRow` (the underscore prefix marks it as soft-optional).
161
+ // ComposerElements contract: downstream code only treats it as an
162
+ // opaque ref. The pill form intentionally carries no
163
+ // `data-persona-composer-actions` marker, so `bindComposerRefsFromFooter`
164
+ // finds no actions row in pill mode; that lookup writes to `_actionsRow`
165
+ // (the underscore prefix marks it as soft-optional).
162
166
  const actionsRow = composerForm;
163
167
 
164
168
  return {
@@ -114,7 +114,7 @@ export const createReasoningBubble = (message: AgentWidgetMessage, config?: Agen
114
114
  const defaultSummary = "Thinking...";
115
115
  const reasoningConfig = config?.reasoning ?? {};
116
116
 
117
- // Elapsed helpers defined early so they're available to renderCollapsedSummary
117
+ // Elapsed helpers: defined early so they're available to renderCollapsedSummary
118
118
  const startedAt = String(reasoning.startedAt ?? Date.now());
119
119
 
120
120
  const createElapsedSpan = (): HTMLElement => {
@@ -144,7 +144,7 @@ export const createReasoningBubble = (message: AgentWidgetMessage, config?: Agen
144
144
  headerContent.appendChild(title);
145
145
  }
146
146
 
147
- // Status span used in the legacy (no-template) path
147
+ // Status span: used in the legacy (no-template) path
148
148
  const status = createElement("span", "persona-text-xs persona-text-persona-primary");
149
149
  status.textContent = describeReasonStatus(reasoning);
150
150
  headerContent.appendChild(status);
@@ -45,7 +45,7 @@ export const createSuggestions = (container: HTMLElement): SuggestionButtons =>
45
45
  if (!chips || !chips.length) return;
46
46
 
47
47
  // Hide config suggestions after the first user message is sent.
48
- // Agent-pushed chips skip this gate their visibility is the caller's
48
+ // Agent-pushed chips skip this gate: their visibility is the caller's
49
49
  // latest-turn rule (last suggest_replies call with no user message after).
50
50
  // Use provided messages or get from session
51
51
  if (!agentPushed) {
@@ -197,7 +197,7 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
197
197
  title.style.color = toolCallConfig.headerTextColor;
198
198
  }
199
199
 
200
- // Elapsed helpers defined early so they're available to renderCollapsedSummary
200
+ // Elapsed helpers: defined early so they're available to renderCollapsedSummary
201
201
  const startedAt = String(tool.startedAt ?? Date.now());
202
202
 
203
203
  // Helper: build a <span data-tool-elapsed> that the global timer in ui.ts updates
@@ -0,0 +1,181 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { createWidgetView, resolveLauncher } from "./widget-view";
5
+ import type { HeaderElements } from "./header-builder";
6
+ import type { AgentWidgetConfig } from "../types";
7
+ import type { AgentWidgetPlugin } from "../plugins/types";
8
+
9
+ describe("createWidgetView: default assembly", () => {
10
+ const config: AgentWidgetConfig = {
11
+ apiUrl: "/api",
12
+ voiceRecognition: { enabled: true, provider: { type: "runtype" } },
13
+ attachments: { enabled: true },
14
+ };
15
+
16
+ it("groups shell, transcript, header, and composer refs over the real nodes", () => {
17
+ const view = createWidgetView({ config, showClose: true });
18
+
19
+ // Shell. The empty `panel` is wired to the `container` later by ui.ts
20
+ // (panel.appendChild(container)); createWidgetView preserves that split.
21
+ expect(view.shell.wrapper.contains(view.shell.panel)).toBe(true);
22
+ expect(view.shell.pillRoot).toBeUndefined();
23
+
24
+ // Transcript subtree is wired by buildPanel
25
+ expect(view.transcript.container.contains(view.transcript.body)).toBe(true);
26
+ expect(view.transcript.body.contains(view.transcript.messagesWrapper)).toBe(true);
27
+
28
+ // Header + composer are mounted in the container
29
+ expect(view.transcript.container.contains(view.header.element)).toBe(true);
30
+ expect(view.transcript.container.contains(view.composer.footer)).toBe(true);
31
+
32
+ // Grouped refs are the same nodes as the raw flat panelElements
33
+ expect(view.composer.textarea).toBe(view.panelElements.textarea);
34
+ expect(view.composer.footer).toBe(view.panelElements.footer);
35
+ expect(view.header.element).toBe(view.panelElements.header);
36
+ });
37
+
38
+ it("exposes the stable composer ref attributes", () => {
39
+ const view = createWidgetView({ config, showClose: true });
40
+
41
+ expect(view.composer.textarea.getAttribute("data-persona-composer-input")).toBe("");
42
+ expect(view.composer.sendButton.getAttribute("data-persona-composer-submit")).toBe("");
43
+ expect(view.composer.statusText.getAttribute("data-persona-composer-status")).toBe("");
44
+ expect(view.composer.suggestions.getAttribute("data-persona-composer-suggestions")).toBe("");
45
+ expect(view.composer.actionsRow.getAttribute("data-persona-composer-actions")).toBe("");
46
+ expect(view.composer.micButton).not.toBeNull();
47
+ expect(
48
+ view.composer.attachmentButton?.getAttribute("data-persona-composer-attachment-button")
49
+ ).toBe("");
50
+ expect(
51
+ view.composer.attachmentInput?.getAttribute("data-persona-composer-attachment-input")
52
+ ).toBe("");
53
+ expect(
54
+ view.composer.attachmentPreviewsContainer?.getAttribute(
55
+ "data-persona-composer-attachment-previews"
56
+ )
57
+ ).toBe("");
58
+ });
59
+
60
+ it("hides header and footer when layout disables them", () => {
61
+ const view = createWidgetView({
62
+ config: { apiUrl: "/api", layout: { showHeader: false, showFooter: false } },
63
+ showClose: true,
64
+ });
65
+
66
+ expect(view.header.element.style.display).toBe("none");
67
+ expect(view.composer.footer.style.display).toBe("none");
68
+ });
69
+ });
70
+
71
+ describe("createWidgetView: composer-bar assembly", () => {
72
+ const config: AgentWidgetConfig = {
73
+ apiUrl: "/api",
74
+ launcher: { mountMode: "composer-bar" },
75
+ };
76
+
77
+ it("exposes the pill root and peek banner", () => {
78
+ const view = createWidgetView({ config, showClose: true });
79
+
80
+ expect(view.shell.pillRoot).toBeInstanceOf(HTMLElement);
81
+ expect(view.composer.peekBanner).toBeInstanceOf(HTMLElement);
82
+ expect(view.composer.peekTextNode).toBeInstanceOf(HTMLElement);
83
+ // Composer-bar uses a hidden placeholder header (close button is the chrome).
84
+ expect(view.header.element.style.display).toBe("none");
85
+ });
86
+ });
87
+
88
+ describe("createWidgetView: replacement helpers", () => {
89
+ it("replaceHeader swaps the mounted element and mirrors sub-refs", () => {
90
+ const view = createWidgetView({ config: { apiUrl: "/api" }, showClose: true });
91
+ const original = view.header.element;
92
+ const parent = original.parentElement;
93
+
94
+ const next: HeaderElements = {
95
+ header: document.createElement("header"),
96
+ iconHolder: document.createElement("span"),
97
+ headerTitle: document.createElement("span"),
98
+ headerSubtitle: document.createElement("span"),
99
+ closeButton: document.createElement("button"),
100
+ closeButtonWrapper: document.createElement("div"),
101
+ clearChatButton: null,
102
+ clearChatButtonWrapper: null,
103
+ };
104
+
105
+ const returned = view.replaceHeader(next);
106
+
107
+ expect(returned).toBe(next);
108
+ expect(view.header.element).toBe(next.header);
109
+ expect(view.header.iconHolder).toBe(next.iconHolder);
110
+ expect(view.header.closeButton).toBe(next.closeButton);
111
+ expect(parent?.contains(next.header)).toBe(true);
112
+ expect(parent?.contains(original)).toBe(false);
113
+ });
114
+
115
+ it("replaceComposer swaps the mounted footer and updates the ref", () => {
116
+ const view = createWidgetView({ config: { apiUrl: "/api" }, showClose: true });
117
+ const original = view.composer.footer;
118
+ const parent = original.parentElement;
119
+ const nextFooter = document.createElement("div");
120
+
121
+ view.replaceComposer(nextFooter);
122
+
123
+ expect(view.composer.footer).toBe(nextFooter);
124
+ expect(parent?.contains(nextFooter)).toBe(true);
125
+ expect(parent?.contains(original)).toBe(false);
126
+ });
127
+ });
128
+
129
+ describe("resolveLauncher", () => {
130
+ const config: AgentWidgetConfig = { apiUrl: "/api" };
131
+
132
+ it("builds the default launcher controller and wires onToggle", () => {
133
+ const onToggle = vi.fn();
134
+ const { instance, element } = resolveLauncher({ config, plugins: [], onToggle });
135
+
136
+ expect(instance).not.toBeNull();
137
+ expect(element).toBe(instance?.element);
138
+ expect(element.tagName).toBe("BUTTON");
139
+
140
+ element.dispatchEvent(new Event("click"));
141
+ expect(onToggle).toHaveBeenCalledTimes(1);
142
+ });
143
+
144
+ it("uses a plugin-provided launcher element and returns no controller", () => {
145
+ const custom = document.createElement("div");
146
+ custom.id = "custom-launcher";
147
+ const plugin: AgentWidgetPlugin = {
148
+ id: "custom",
149
+ renderLauncher: ({ defaultRenderer }) => {
150
+ // The default renderer is still callable; the plugin opts to ignore it.
151
+ expect(defaultRenderer().tagName).toBe("BUTTON");
152
+ return custom;
153
+ },
154
+ };
155
+
156
+ const { instance, element } = resolveLauncher({
157
+ config,
158
+ plugins: [plugin],
159
+ onToggle: () => {},
160
+ });
161
+
162
+ expect(instance).toBeNull();
163
+ expect(element).toBe(custom);
164
+ });
165
+
166
+ it("falls back to the default launcher when the plugin returns null", () => {
167
+ const plugin: AgentWidgetPlugin = {
168
+ id: "passthrough",
169
+ renderLauncher: () => null,
170
+ };
171
+
172
+ const { instance, element } = resolveLauncher({
173
+ config,
174
+ plugins: [plugin],
175
+ onToggle: () => {},
176
+ });
177
+
178
+ expect(instance).not.toBeNull();
179
+ expect(element).toBe(instance?.element);
180
+ });
181
+ });