@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
@@ -28,7 +28,7 @@ import {
28
28
  * Default copy for the inline notice rendered when a turn ends with a
29
29
  * non-natural stop reason. Deployers override per-reason via
30
30
  * `config.copy.stopReasonNotice`. Returns `null` for natural completions
31
- * (`end_turn`) and uninformative reasons (`unknown`) those never render
31
+ * (`end_turn`) and uninformative reasons (`unknown`): those never render
32
32
  * an affordance.
33
33
  */
34
34
  export const getDefaultStopReasonNoticeCopy = (
@@ -62,7 +62,7 @@ export const resolveStopReasonNoticeText = (
62
62
  ): string | null => {
63
63
  if (!stopReason) return null;
64
64
  const fallback = getDefaultStopReasonNoticeCopy(stopReason);
65
- // Reasons without a default (end_turn, unknown) never render overrides
65
+ // Reasons without a default (end_turn, unknown) never render: overrides
66
66
  // for those keys are intentionally ignored.
67
67
  if (fallback === null) return null;
68
68
  const override = overrides?.[stopReason];
@@ -498,10 +498,11 @@ const createAvatar = (
498
498
  */
499
499
  const createTimestamp = (
500
500
  message: AgentWidgetMessage,
501
- timestampConfig: AgentWidgetTimestampConfig
501
+ timestampConfig: AgentWidgetTimestampConfig,
502
+ tagName: "div" | "span" = "div"
502
503
  ): HTMLElement => {
503
504
  const timestamp = createElement(
504
- "div",
505
+ tagName,
505
506
  "persona-text-xs persona-text-persona-muted"
506
507
  );
507
508
 
@@ -629,9 +630,10 @@ export const createMessageActions = (
629
630
  const showCopy = actionsConfig.showCopy ?? true;
630
631
  const showUpvote = actionsConfig.showUpvote ?? true;
631
632
  const showDownvote = actionsConfig.showDownvote ?? true;
633
+ const showReadAloud = actionsConfig.showReadAloud ?? false;
632
634
 
633
635
  // Don't render the container at all when no actions are visible
634
- if (!showCopy && !showUpvote && !showDownvote) {
636
+ if (!showCopy && !showUpvote && !showDownvote && !showReadAloud) {
635
637
  const empty = createElement("div");
636
638
  empty.style.display = "none";
637
639
  empty.id = `actions-${message.id}`;
@@ -686,6 +688,12 @@ export const createMessageActions = (
686
688
  container.appendChild(createActionButton("copy", "Copy message", "copy"));
687
689
  }
688
690
 
691
+ // Read-aloud button. Click handling and play/pause/resume state are managed
692
+ // in ui.ts (event delegation + ReadAloudController) so they survive morphs.
693
+ if (showReadAloud) {
694
+ container.appendChild(createActionButton("volume-2", "Read aloud", "read-aloud"));
695
+ }
696
+
689
697
  // Upvote button
690
698
  if (showUpvote) {
691
699
  container.appendChild(createActionButton("thumbs-up", "Upvote", "upvote"));
@@ -768,7 +776,7 @@ export const createStandardBubble = (
768
776
  ? resolveStreamAnimationPlugin(streamAnimation.type, streamPluginOverrides)
769
777
  : null;
770
778
  // Stay in "streaming-animated" mode while the plugin reports in-flight
771
- // work for this message e.g. glyph-cycle's tick loops still walking
779
+ // work for this message: e.g. glyph-cycle's tick loops still walking
772
780
  // through the tail after the last token arrived. Without this, the final
773
781
  // non-animated render rips out the cycling spans mid-animation.
774
782
  const pluginStillAnimating =
@@ -861,11 +869,17 @@ export const createStandardBubble = (
861
869
  }
862
870
  }
863
871
 
864
- // Add inline timestamp if configured
872
+ // Usung <span>, not <div> for timestamp
873
+ // because a block child of a <p> gets re-parented outside of it on re-render (idiomorph).
865
874
  if (showTimestamp && timestampPosition === "inline" && message.createdAt) {
866
- const timestamp = createTimestamp(message, timestampConfig!);
867
- timestamp.classList.add("persona-ml-2", "persona-inline");
868
- contentDiv.appendChild(timestamp);
875
+ const timestamp = createTimestamp(message, timestampConfig!, "span");
876
+ timestamp.classList.add("persona-timestamp-inline");
877
+ const lastBlock = contentDiv.lastElementChild;
878
+ if (lastBlock) {
879
+ lastBlock.appendChild(timestamp);
880
+ } else {
881
+ contentDiv.appendChild(timestamp);
882
+ }
869
883
  }
870
884
 
871
885
  if (imageParts.length > 0) {
@@ -930,7 +944,7 @@ export const createStandardBubble = (
930
944
  : null;
931
945
 
932
946
  // Add typing indicator (or skeleton placeholder) for streaming assistant
933
- // messages. Check the buffered content a plugin's `bufferContent` may
947
+ // messages. Check the buffered content: a plugin's `bufferContent` may
934
948
  // hold back the first N chars (e.g. glyph-cycle waits for 50 chars), during
935
949
  // which the bubble would otherwise appear empty.
936
950
  //
@@ -34,7 +34,7 @@ export const renderMessages = (
34
34
  if (!showReasoning) return;
35
35
  bubble = createReasoningBubble(message, config);
36
36
  } else if (isAskUserQuestionMessage(message)) {
37
- // No transcript bubble the overlay sheet is the only question UI.
37
+ // No transcript bubble: the overlay sheet is the only question UI.
38
38
  if (config?.features?.askUserQuestion?.enabled === false) return;
39
39
  if (!message.agentMetadata?.askUserQuestionAnswered) {
40
40
  if (message.toolCall?.id) liveAskToolIds.add(message.toolCall.id);
@@ -45,7 +45,7 @@ export const renderMessages = (
45
45
  isSuggestRepliesMessage(message) &&
46
46
  config?.features?.suggestReplies?.enabled !== false
47
47
  ) {
48
- // No transcript bubble the chips above the composer are the only UI.
48
+ // No transcript bubble: the chips above the composer are the only UI.
49
49
  // When the feature is disabled the message falls through to the generic
50
50
  // tool bubble below (and is never auto-resumed), making the parked
51
51
  // execution visible instead of silently swallowed.
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
4
4
  import { createWrapper } from "./panel";
5
5
  import type { AgentWidgetConfig } from "../types";
6
6
 
7
- describe("createWrapper composer-bar mode", () => {
7
+ describe("createWrapper: composer-bar mode", () => {
8
8
  it("marks the wrapper with composer-bar data-attrs and leaves geometry to updateOpenState", () => {
9
9
  const config: AgentWidgetConfig = {
10
10
  apiUrl: "/api",
@@ -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