@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
@@ -36,7 +36,7 @@ export function createArtifactPane(
36
36
  config: AgentWidgetConfig,
37
37
  options: {
38
38
  onSelect: (id: string) => void;
39
- /** User closed the pane (mobile drawer or split sidebar) parent should persist “hidden until reopened”. */
39
+ /** User closed the pane (mobile drawer or split sidebar): parent should persist “hidden until reopened”. */
40
40
  onDismiss?: () => void;
41
41
  }
42
42
  ): ArtifactPaneApi {
@@ -203,7 +203,7 @@ export function createArtifactPane(
203
203
  });
204
204
 
205
205
  if (copyMenuChevronBtn && copyMenuItems?.length) {
206
- // Resolve the portal target widget root for CSS var inheritance, escaping overflow: hidden
206
+ // Resolve the portal target: widget root for CSS var inheritance, escaping overflow: hidden
207
207
  const resolvePortal = (): HTMLElement => shell.closest("[data-persona-root]") as HTMLElement ?? document.body;
208
208
 
209
209
  const initDropdown = () => {
@@ -414,7 +414,7 @@ export function createArtifactPane(
414
414
  backdrop.classList.add("persona-hidden");
415
415
  shell.classList.remove("persona-artifact-drawer-open");
416
416
  } else {
417
- // isMobile && !(has && mobileOpen): e.g. dismissed drawer keep closed chrome in sync
417
+ // isMobile && !(has && mobileOpen): e.g. dismissed drawer: keep closed chrome in sync
418
418
  backdrop.classList.add("persona-hidden");
419
419
  shell.classList.remove("persona-artifact-drawer-open");
420
420
  }
@@ -103,11 +103,11 @@ describe("ensureAskUserQuestionSheet", () => {
103
103
  expect(sheet).not.toBeNull();
104
104
  const pills = sheet!.querySelectorAll('[data-ask-user-action="pick"]');
105
105
  expect(pills.length).toBe(3);
106
- // Default rows layout label is in `.persona-ask-row-label`, alongside
106
+ // Default rows layout: label is in `.persona-ask-row-label`, alongside
107
107
  // a number-badge affordance. Read the dedicated label slot.
108
108
  const firstLabel = pills[0].querySelector(".persona-ask-row-label");
109
109
  expect(firstLabel?.textContent).toBe("Hobbyists");
110
- // Free-text affordance present by default rows mode embeds the input
110
+ // Free-text affordance present by default: rows mode embeds the input
111
111
  // inside the Other row via `focus-free-text` (digit shortcut + click chrome).
112
112
  const custom = sheet!.querySelector('[data-ask-user-action="focus-free-text"]');
113
113
  expect(custom).not.toBeNull();
@@ -144,7 +144,7 @@ describe("ensureAskUserQuestionSheet", () => {
144
144
  });
145
145
  ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
146
146
 
147
- // Simulate more chunks arriving options now parseable
147
+ // Simulate more chunks arriving: options now parseable
148
148
  msg.toolCall!.chunks = ['{"questions":[{"question":"X","options":[{"label":"A"},{"label":"B"}'];
149
149
  ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
150
150
 
@@ -155,7 +155,7 @@ describe("ensureAskUserQuestionSheet", () => {
155
155
  expect(pills[1].querySelector(".persona-ask-row-label")?.textContent).toBe("B");
156
156
  });
157
157
 
158
- it("is idempotent re-invoking does not duplicate sheets", () => {
158
+ it("is idempotent: re-invoking does not duplicate sheets", () => {
159
159
  const overlay = makeOverlay();
160
160
  ensureAskUserQuestionSheet(makeMessage(), {} as AgentWidgetConfig, overlay);
161
161
  ensureAskUserQuestionSheet(makeMessage(), {} as AgentWidgetConfig, overlay);
@@ -163,7 +163,7 @@ describe("ensureAskUserQuestionSheet", () => {
163
163
  expect(sheets.length).toBe(1);
164
164
  });
165
165
 
166
- it("respects enabled: false sheet is not mounted", () => {
166
+ it("respects enabled: false: sheet is not mounted", () => {
167
167
  const overlay = makeOverlay();
168
168
  ensureAskUserQuestionSheet(
169
169
  makeMessage(),
@@ -192,7 +192,7 @@ describe("ensureAskUserQuestionSheet", () => {
192
192
  expect(sheet.getAttribute("data-multi-select")).toBe("true");
193
193
  });
194
194
 
195
- it("does not render a dismiss button Skip in the nav row is the canonical escape", () => {
195
+ it("does not render a dismiss button: Skip in the nav row is the canonical escape", () => {
196
196
  const overlay = makeOverlay();
197
197
  ensureAskUserQuestionSheet(makeMessage(), {} as AgentWidgetConfig, overlay);
198
198
  const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]')!;
@@ -221,7 +221,7 @@ describe("removeAskUserQuestionSheet", () => {
221
221
  ensureAskUserQuestionSheet(makeMessage(), {} as AgentWidgetConfig, overlay);
222
222
  expect(overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]')).not.toBeNull();
223
223
  removeAskUserQuestionSheet(overlay, "tool-1");
224
- // The implementation defers removal with setTimeout flush it.
224
+ // The implementation defers removal with setTimeout: flush it.
225
225
  await new Promise((resolve) => setTimeout(resolve, 250));
226
226
  expect(overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]')).toBeNull();
227
227
  });
@@ -249,7 +249,7 @@ const makeGroupedMessage = (
249
249
  const sheetFor = (overlay: Element, toolCallId: string): HTMLElement =>
250
250
  overlay.querySelector<HTMLElement>(`[data-persona-ask-sheet-for="${toolCallId}"]`)!;
251
251
 
252
- describe("grouped questions stepper UI", () => {
252
+ describe("grouped questions: stepper UI", () => {
253
253
  it("renders 'Question 1 of N' chip and Back/Next nav row when N > 1", () => {
254
254
  const overlay = makeOverlay();
255
255
  const msg = makeGroupedMessage([
@@ -384,7 +384,7 @@ describe("grouped questions — stepper UI", () => {
384
384
  });
385
385
  });
386
386
 
387
- it("hydrates from agentMetadata restores index and prior answers on a fresh mount", () => {
387
+ it("hydrates from agentMetadata: restores index and prior answers on a fresh mount", () => {
388
388
  const overlay = makeOverlay();
389
389
  const msg = makeGroupedMessage(
390
390
  [
@@ -464,7 +464,7 @@ describe("rows layout (default)", () => {
464
464
  expect(firstRow.querySelector(".persona-ask-row-description")?.textContent).toBe(
465
465
  "Real Discord invite to link"
466
466
  );
467
- // No title-attr fallback in rows layout description is inline.
467
+ // No title-attr fallback in rows layout: description is inline.
468
468
  expect(firstRow.getAttribute("title")).toBeNull();
469
469
  });
470
470
 
@@ -507,11 +507,11 @@ describe("rows layout (default)", () => {
507
507
  expect(rows[0].querySelector(".persona-ask-row-badge")).toBeNull();
508
508
  });
509
509
 
510
- it("embeds the free-text input INSIDE the Other row (rows mode) no separate row, no Send button", () => {
510
+ it("embeds the free-text input INSIDE the Other row (rows mode): no separate row, no Send button", () => {
511
511
  const overlay = makeOverlay();
512
512
  ensureAskUserQuestionSheet(makeMessage(), {} as AgentWidgetConfig, overlay);
513
513
  const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]')!;
514
- // No standalone free-text row in rows mode input lives inside the Other row.
514
+ // No standalone free-text row in rows mode: input lives inside the Other row.
515
515
  expect(sheet.querySelector('[data-ask-free-text-row="true"]')).toBeNull();
516
516
  const otherRow = sheet.querySelector<HTMLElement>('[data-ask-other-row="true"]')!;
517
517
  expect(otherRow).not.toBeNull();
@@ -520,12 +520,12 @@ describe("rows layout (default)", () => {
520
520
  expect(otherRow.querySelector('[data-ask-user-action="submit-free-text"]')).toBeNull();
521
521
  // Other row carries the focus-free-text action (digit shortcut + click chrome).
522
522
  expect(otherRow.getAttribute("data-ask-user-action")).toBe("focus-free-text");
523
- // Number badge for the Other row (N+1 3 real options + Other = 4).
523
+ // Number badge for the Other row (N+1: 3 real options + Other = 4).
524
524
  const badge = otherRow.querySelector<HTMLElement>(".persona-ask-row-badge");
525
525
  expect(badge?.textContent).toBe("4");
526
526
  });
527
527
 
528
- it("respects layout: 'pills' opt-out no row affordances, free-text starts hidden, Send button still rendered", () => {
528
+ it("respects layout: 'pills' opt-out: no row affordances, free-text starts hidden, Send button still rendered", () => {
529
529
  const overlay = makeOverlay();
530
530
  const config = {
531
531
  features: { askUserQuestion: { layout: "pills" } },
@@ -60,7 +60,7 @@ const resolveFeature = (config?: AgentWidgetConfig): AgentWidgetAskUserQuestionF
60
60
 
61
61
  /**
62
62
  * Parse an `ask_user_question` tool-variant message into a partial payload.
63
- * Safe to call mid-stream will walk the tool call's `chunks` via
63
+ * Safe to call mid-stream: will walk the tool call's `chunks` via
64
64
  * `partial-json` and return `{ payload: null, complete: false }` when there
65
65
  * isn't enough data yet. `complete` flips to `true` once the tool call
66
66
  * reports status `"complete"`.
@@ -570,7 +570,7 @@ const syncNavState = (sheet: HTMLElement): void => {
570
570
  );
571
571
  if (next) next.disabled = !hasAnswer;
572
572
 
573
- // Multi-select submit (1-question mode) keep existing behavior.
573
+ // Multi-select submit (1-question mode): keep existing behavior.
574
574
  const multi = sheet.querySelector<HTMLButtonElement>('[data-ask-user-action="submit-multi"]');
575
575
  if (multi) {
576
576
  const labels = Array.from(
@@ -625,7 +625,7 @@ const renderCurrentPage = (
625
625
  pillList.replaceWith(fresh);
626
626
  }
627
627
 
628
- // Free-text row re-build to clear stale input value across pages.
628
+ // Free-text row: re-build to clear stale input value across pages.
629
629
  // Only present in pills (legacy) mode; in rows mode the input lives inside
630
630
  // the Other row of the pill list, which is rebuilt above.
631
631
  if (layout !== "rows") {
@@ -633,7 +633,7 @@ const renderCurrentPage = (
633
633
  if (oldFree) oldFree.replaceWith(buildFreeTextRow(feature, layout));
634
634
  }
635
635
 
636
- // Multi-select action row only relevant in 1-question mode.
636
+ // Multi-select action row: only relevant in 1-question mode.
637
637
  const oldMulti = sheet.querySelector<HTMLElement>('[data-ask-multi-actions="true"]');
638
638
  if (!grouped && multiSelect && !oldMulti) {
639
639
  sheet.appendChild(buildMultiSelectActions(feature));
@@ -698,7 +698,7 @@ const buildSheet = (
698
698
 
699
699
  // Header: question text (flex-1) + compact "N/M" stepper indicator on the
700
700
  // right (grouped only). Skip in the nav row is the canonical escape hatch
701
- // plugins that want a different escape model render their own UX.
701
+ //: plugins that want a different escape model render their own UX.
702
702
  const header = createElement(
703
703
  "div",
704
704
  "persona-ask-sheet-header persona-flex persona-items-center persona-gap-3"
@@ -721,7 +721,7 @@ const buildSheet = (
721
721
 
722
722
  sheet.appendChild(header);
723
723
 
724
- // Skeleton placeholders these get replaced wholesale by renderCurrentPage.
724
+ // Skeleton placeholders: these get replaced wholesale by renderCurrentPage.
725
725
  const skeletonClass =
726
726
  layout === "rows"
727
727
  ? "persona-ask-pills persona-ask-pills--rows persona-flex persona-flex-col persona-gap-2"
@@ -768,7 +768,7 @@ const syncSheetFromMessage = (
768
768
 
769
769
  /**
770
770
  * Create the small in-transcript stub for an `ask_user_question` tool call.
771
- * The stub is passive the interactive sheet is mounted separately into
771
+ * The stub is passive: the interactive sheet is mounted separately into
772
772
  * the composer overlay via `ensureAskUserQuestionSheet`.
773
773
  */
774
774
  export const createAskUserQuestionBubble = (
@@ -796,7 +796,7 @@ export const createAskUserQuestionBubble = (
796
796
 
797
797
  /**
798
798
  * Mount or update the interactive answer-pill sheet for a given message.
799
- * Idempotent if a sheet already exists for the tool-call id, it is hydrated
799
+ * Idempotent: if a sheet already exists for the tool-call id, it is hydrated
800
800
  * in-place instead of remounted, so streaming updates don't flicker.
801
801
  */
802
802
  export const ensureAskUserQuestionSheet = (
@@ -812,7 +812,7 @@ export const ensureAskUserQuestionSheet = (
812
812
 
813
813
  const toolCallId = message.toolCall!.id;
814
814
 
815
- // Only keep the latest sheet in the overlay clear any stale siblings.
815
+ // Only keep the latest sheet in the overlay: clear any stale siblings.
816
816
  const siblings = overlay.querySelectorAll<HTMLElement>(`[${SHEET_SENTINEL}]`);
817
817
  siblings.forEach((el) => {
818
818
  if (el.getAttribute(SHEET_SENTINEL) !== toolCallId) {
@@ -916,7 +916,7 @@ export const refreshNavState = (sheet: HTMLElement): void => {
916
916
  };
917
917
 
918
918
  /**
919
- * Test seam reset the one-shot truncation warning so each test can assert
919
+ * Test seam: reset the one-shot truncation warning so each test can assert
920
920
  * the warn fires exactly once.
921
921
  */
922
922
  export const __resetTruncateWarn = (): void => {
@@ -1,4 +1,4 @@
1
- import { createElement } from "../utils/dom";
1
+ import { createElement, createNode } from "../utils/dom";
2
2
  import { AgentWidgetConfig, ContentPart } from "../types";
3
3
  import {
4
4
  createAttachmentControls,
@@ -57,20 +57,20 @@ export interface ComposerBuildContext {
57
57
  export const buildComposer = (context: ComposerBuildContext): ComposerElements => {
58
58
  const { config } = context;
59
59
 
60
- const footer = createElement(
61
- "div",
62
- "persona-widget-footer persona-border-t-persona-divider persona-bg-persona-surface persona-px-6 persona-py-4"
63
- );
64
- footer.setAttribute("data-persona-theme-zone", "composer");
60
+ const footer = createNode("div", {
61
+ className:
62
+ "persona-widget-footer persona-border-t-persona-divider persona-bg-persona-surface persona-px-6 persona-py-4",
63
+ attrs: { "data-persona-theme-zone": "composer" },
64
+ });
65
65
 
66
66
  const suggestions = createSuggestionsRow();
67
67
 
68
- const composerForm = createElement(
69
- "form",
70
- "persona-widget-composer persona-flex persona-flex-col persona-gap-2 persona-rounded-2xl persona-border persona-border-gray-200 persona-bg-persona-input-background persona-px-4 persona-py-3"
71
- ) as HTMLFormElement;
72
- composerForm.setAttribute("data-persona-composer-form", "");
73
- composerForm.style.outline = "none";
68
+ const composerForm = createNode("form", {
69
+ className:
70
+ "persona-widget-composer persona-flex persona-flex-col persona-gap-2 persona-rounded-2xl persona-border persona-border-gray-200 persona-bg-persona-input-background persona-px-4 persona-py-3",
71
+ attrs: { "data-persona-composer-form": "" },
72
+ style: { outline: "none" },
73
+ });
74
74
 
75
75
  const { textarea, attachAutoResize } = createComposerTextarea(config);
76
76
  attachAutoResize();
@@ -93,10 +93,11 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
93
93
  // The bare class names (persona-widget-composer__actions / __left-actions /
94
94
  // __right-actions) are stable CSS hooks. The pill composer reuses
95
95
  // __left-actions / __right-actions as semantic markers in its grid.
96
- const actionsRow = createElement(
97
- "div",
98
- "persona-widget-composer__actions persona-flex persona-items-center persona-justify-between persona-w-full"
99
- );
96
+ const actionsRow = createNode("div", {
97
+ className:
98
+ "persona-widget-composer__actions persona-flex persona-items-center persona-justify-between persona-w-full",
99
+ attrs: { "data-persona-composer-actions": "" },
100
+ });
100
101
  const leftActions = createElement(
101
102
  "div",
102
103
  "persona-widget-composer__left-actions persona-flex persona-items-center persona-gap-2"
@@ -70,6 +70,60 @@ describe("createSendButton", () => {
70
70
  expect(send.button.textContent).toBe("Send");
71
71
  });
72
72
 
73
+ it("applies configured icon-button styles and skips the primary-bg fallback class", () => {
74
+ const send = createSendButton({
75
+ ...baseConfig,
76
+ sendButton: {
77
+ useIcon: true,
78
+ iconName: "send",
79
+ size: "48px",
80
+ backgroundColor: "rgb(1, 2, 3)",
81
+ textColor: "rgb(4, 5, 6)",
82
+ borderWidth: "2px",
83
+ borderColor: "rgb(7, 8, 9)",
84
+ paddingX: "6px",
85
+ paddingY: "4px",
86
+ },
87
+ });
88
+ const s = send.button.style;
89
+ expect(s.width).toBe("48px");
90
+ expect(s.height).toBe("48px");
91
+ expect(s.minWidth).toBe("48px");
92
+ expect(s.minHeight).toBe("48px");
93
+ expect(s.color).toBe("rgb(4, 5, 6)");
94
+ expect(s.backgroundColor).toBe("rgb(1, 2, 3)");
95
+ expect(s.borderWidth).toBe("2px");
96
+ expect(s.borderStyle).toBe("solid");
97
+ expect(s.borderColor).toBe("rgb(7, 8, 9)");
98
+ expect(s.paddingLeft).toBe("6px");
99
+ expect(s.paddingRight).toBe("6px");
100
+ expect(s.paddingTop).toBe("4px");
101
+ expect(s.paddingBottom).toBe("4px");
102
+ // An explicit backgroundColor means the fallback class is not added.
103
+ expect(send.button.classList.contains("persona-bg-persona-primary")).toBe(false);
104
+ });
105
+
106
+ it("toggles the white-text fallback class based on textColor (text mode)", () => {
107
+ const plain = createSendButton(baseConfig);
108
+ expect(plain.button.classList.contains("persona-text-white")).toBe(true);
109
+ expect(plain.button.style.color).toBe("");
110
+
111
+ const colored = createSendButton({
112
+ ...baseConfig,
113
+ sendButton: { textColor: "rgb(4, 5, 6)" },
114
+ });
115
+ expect(colored.button.classList.contains("persona-text-white")).toBe(false);
116
+ expect(colored.button.style.color).toBe("rgb(4, 5, 6)");
117
+ });
118
+
119
+ it("adds the primary-bg fallback class in icon mode without an explicit background", () => {
120
+ const send = createSendButton({
121
+ ...baseConfig,
122
+ sendButton: { useIcon: true, iconName: "send" },
123
+ });
124
+ expect(send.button.classList.contains("persona-bg-persona-primary")).toBe(true);
125
+ });
126
+
73
127
  describe("icon mode", () => {
74
128
  const iconConfig: AgentWidgetConfig = {
75
129
  ...baseConfig,
@@ -1,4 +1,4 @@
1
- import { createElement } from "../utils/dom";
1
+ import { createElement, createNode, cx } from "../utils/dom";
2
2
  import { renderLucideIcon } from "../utils/icons";
3
3
  import { AgentWidgetConfig } from "../types";
4
4
  import { ALL_SUPPORTED_MIME_TYPES } from "../utils/content";
@@ -6,7 +6,7 @@ import { ALL_SUPPORTED_MIME_TYPES } from "../utils/content";
6
6
  /**
7
7
  * Low-level composer control factories. Both `buildComposer` (full,
8
8
  * column-stacked card) and `buildPillComposer` (single-row pill) consume
9
- * these the only meaningful difference between the two composers is the
9
+ * these: the only meaningful difference between the two composers is the
10
10
  * layout shell + className. No DOM assembly here; each factory returns the
11
11
  * element plus any handles the caller needs.
12
12
  *
@@ -61,7 +61,7 @@ export const createComposerTextarea = (config?: AgentWidgetConfig): ComposerText
61
61
  });
62
62
  };
63
63
 
64
- // Strip browser default focus rings the composer wraps the textarea in
64
+ // Strip browser default focus rings: the composer wraps the textarea in
65
65
  // its own surface, so the textarea itself must be visually transparent.
66
66
  textarea.style.border = "none";
67
67
  textarea.style.outline = "none";
@@ -112,36 +112,47 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
112
112
 
113
113
  const wrapper = createElement("div", "persona-send-button-wrapper");
114
114
 
115
- const button = createElement(
116
- "button",
117
- useIcon
118
- ? "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer"
119
- : "persona-rounded-button persona-bg-persona-accent persona-px-4 persona-py-2 persona-text-sm persona-font-semibold disabled:persona-opacity-50 persona-cursor-pointer"
120
- ) as HTMLButtonElement;
121
-
122
- button.type = "submit";
123
- button.setAttribute("data-persona-composer-submit", "");
115
+ const button = createNode("button", {
116
+ className: cx(
117
+ "persona-rounded-button disabled:persona-opacity-50 persona-cursor-pointer",
118
+ useIcon
119
+ ? "persona-flex persona-items-center persona-justify-center"
120
+ : "persona-bg-persona-accent persona-px-4 persona-py-2 persona-text-sm persona-font-semibold",
121
+ // Icon mode without an explicit background falls back to the primary bg
122
+ // class; text mode without an explicit color falls back to white text.
123
+ useIcon && !backgroundColor && "persona-bg-persona-primary",
124
+ !useIcon && !textColor && "persona-text-white"
125
+ ),
126
+ attrs: { type: "submit", "data-persona-composer-submit": "" },
127
+ style: {
128
+ // Sizing is icon-mode-only (text mode is sized by its padding classes).
129
+ width: useIcon ? buttonSize : undefined,
130
+ height: useIcon ? buttonSize : undefined,
131
+ minWidth: useIcon ? buttonSize : undefined,
132
+ minHeight: useIcon ? buttonSize : undefined,
133
+ fontSize: useIcon ? "18px" : undefined,
134
+ lineHeight: useIcon ? "1" : undefined,
135
+ // Icon mode always sets a color; text mode only when textColor is given.
136
+ color: useIcon
137
+ ? textColor || "var(--persona-button-primary-fg, #ffffff)"
138
+ : textColor || undefined,
139
+ // backgroundColor is honored in icon mode only.
140
+ backgroundColor: useIcon ? backgroundColor || undefined : undefined,
141
+ borderWidth: sendButtonConfig.borderWidth || undefined,
142
+ borderStyle: sendButtonConfig.borderWidth ? "solid" : undefined,
143
+ borderColor: sendButtonConfig.borderColor || undefined,
144
+ paddingLeft: sendButtonConfig.paddingX || undefined,
145
+ paddingRight: sendButtonConfig.paddingX || undefined,
146
+ paddingTop: sendButtonConfig.paddingY || undefined,
147
+ paddingBottom: sendButtonConfig.paddingY || undefined,
148
+ },
149
+ });
124
150
 
125
151
  // Both icons are pre-rendered so setMode can swap cheaply.
126
152
  let sendIcon: SVGElement | null = null;
127
153
  let stopIcon: SVGElement | null = null;
128
154
 
129
155
  if (useIcon) {
130
- button.style.width = buttonSize;
131
- button.style.height = buttonSize;
132
- button.style.minWidth = buttonSize;
133
- button.style.minHeight = buttonSize;
134
- button.style.fontSize = "18px";
135
- button.style.lineHeight = "1";
136
-
137
- button.innerHTML = "";
138
-
139
- if (textColor) {
140
- button.style.color = textColor;
141
- } else {
142
- button.style.color = "var(--persona-button-primary-fg, #ffffff)";
143
- }
144
-
145
156
  const iconSize = parseFloat(buttonSize) || 24;
146
157
  const iconColor = textColor?.trim() || "currentColor";
147
158
 
@@ -157,42 +168,8 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
157
168
  }
158
169
 
159
170
  stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor, 2);
160
-
161
- if (backgroundColor) {
162
- button.style.backgroundColor = backgroundColor;
163
- } else {
164
- button.classList.add("persona-bg-persona-primary");
165
- }
166
171
  } else {
167
172
  button.textContent = sendLabel;
168
- if (textColor) {
169
- button.style.color = textColor;
170
- } else {
171
- button.classList.add("persona-text-white");
172
- }
173
- }
174
-
175
- if (sendButtonConfig.borderWidth) {
176
- button.style.borderWidth = sendButtonConfig.borderWidth;
177
- button.style.borderStyle = "solid";
178
- }
179
- if (sendButtonConfig.borderColor) {
180
- button.style.borderColor = sendButtonConfig.borderColor;
181
- }
182
-
183
- if (sendButtonConfig.paddingX) {
184
- button.style.paddingLeft = sendButtonConfig.paddingX;
185
- button.style.paddingRight = sendButtonConfig.paddingX;
186
- } else {
187
- button.style.paddingLeft = "";
188
- button.style.paddingRight = "";
189
- }
190
- if (sendButtonConfig.paddingY) {
191
- button.style.paddingTop = sendButtonConfig.paddingY;
192
- button.style.paddingBottom = sendButtonConfig.paddingY;
193
- } else {
194
- button.style.paddingTop = "";
195
- button.style.paddingBottom = "";
196
173
  }
197
174
 
198
175
  let tooltip: HTMLElement | null = null;
@@ -218,7 +195,7 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
218
195
  if (useIcon) {
219
196
  if (sendIcon && stopIcon) {
220
197
  const next = mode === "stop" ? stopIcon : sendIcon;
221
- // Replace whatever icon is currently mounted the button only ever
198
+ // Replace whatever icon is currently mounted: the button only ever
222
199
  // holds the single active icon. We use replaceChildren(next) rather
223
200
  // than replaceChild(next, prev) against a captured `prev` reference:
224
201
  // an external re-render/morph can swap the live icon child out from
@@ -259,36 +236,40 @@ export const createMicButton = (config?: AgentWidgetConfig): MicButtonParts | nu
259
236
  if (!hasVoiceInput) return null;
260
237
 
261
238
  const buttonSize = config?.sendButton?.size ?? "40px";
262
- const wrapper = createElement("div", "persona-send-button-wrapper");
263
- const button = createElement(
264
- "button",
265
- "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer"
266
- ) as HTMLButtonElement;
267
-
268
- button.type = "button";
269
- button.setAttribute("data-persona-composer-mic", "");
270
- button.setAttribute("aria-label", "Start voice recognition");
271
-
272
239
  const micIconName = voiceRecognitionConfig.iconName ?? "mic";
273
240
  const micIconSize = voiceRecognitionConfig.iconSize ?? buttonSize;
274
241
  const micIconSizeNum = parseFloat(micIconSize) || 24;
275
-
276
242
  const micBackgroundColor =
277
243
  voiceRecognitionConfig.backgroundColor ?? config?.sendButton?.backgroundColor;
278
244
  const micIconColor = voiceRecognitionConfig.iconColor ?? config?.sendButton?.textColor;
279
245
 
280
- button.style.width = micIconSize;
281
- button.style.height = micIconSize;
282
- button.style.minWidth = micIconSize;
283
- button.style.minHeight = micIconSize;
284
- button.style.fontSize = "18px";
285
- button.style.lineHeight = "1";
286
-
287
- if (micIconColor) {
288
- button.style.color = micIconColor;
289
- } else {
290
- button.style.color = "var(--persona-text, #111827)";
291
- }
246
+ const wrapper = createElement("div", "persona-send-button-wrapper");
247
+ const button = createNode("button", {
248
+ className:
249
+ "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer",
250
+ attrs: {
251
+ type: "button",
252
+ "data-persona-composer-mic": "",
253
+ "aria-label": "Start voice recognition",
254
+ },
255
+ style: {
256
+ width: micIconSize,
257
+ height: micIconSize,
258
+ minWidth: micIconSize,
259
+ minHeight: micIconSize,
260
+ fontSize: "18px",
261
+ lineHeight: "1",
262
+ color: micIconColor || "var(--persona-text, #111827)",
263
+ backgroundColor: micBackgroundColor || undefined,
264
+ borderWidth: voiceRecognitionConfig.borderWidth || undefined,
265
+ borderStyle: voiceRecognitionConfig.borderWidth ? "solid" : undefined,
266
+ borderColor: voiceRecognitionConfig.borderColor || undefined,
267
+ paddingLeft: voiceRecognitionConfig.paddingX || undefined,
268
+ paddingRight: voiceRecognitionConfig.paddingX || undefined,
269
+ paddingTop: voiceRecognitionConfig.paddingY || undefined,
270
+ paddingBottom: voiceRecognitionConfig.paddingY || undefined,
271
+ },
272
+ });
292
273
 
293
274
  const iconColorValue = micIconColor || "currentColor";
294
275
  const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColorValue, 1.5);
@@ -298,27 +279,6 @@ export const createMicButton = (config?: AgentWidgetConfig): MicButtonParts | nu
298
279
  button.textContent = "🎤";
299
280
  }
300
281
 
301
- if (micBackgroundColor) {
302
- button.style.backgroundColor = micBackgroundColor;
303
- }
304
-
305
- if (voiceRecognitionConfig.borderWidth) {
306
- button.style.borderWidth = voiceRecognitionConfig.borderWidth;
307
- button.style.borderStyle = "solid";
308
- }
309
- if (voiceRecognitionConfig.borderColor) {
310
- button.style.borderColor = voiceRecognitionConfig.borderColor;
311
- }
312
-
313
- if (voiceRecognitionConfig.paddingX) {
314
- button.style.paddingLeft = voiceRecognitionConfig.paddingX;
315
- button.style.paddingRight = voiceRecognitionConfig.paddingX;
316
- }
317
- if (voiceRecognitionConfig.paddingY) {
318
- button.style.paddingTop = voiceRecognitionConfig.paddingY;
319
- button.style.paddingBottom = voiceRecognitionConfig.paddingY;
320
- }
321
-
322
282
  wrapper.appendChild(button);
323
283
 
324
284
  const micTooltipText = voiceRecognitionConfig.tooltipText ?? "Start voice recognition";
@@ -355,39 +315,45 @@ export const createAttachmentControls = (config?: AgentWidgetConfig): Attachment
355
315
  "div",
356
316
  "persona-attachment-previews persona-flex persona-flex-wrap persona-gap-2 persona-mb-2"
357
317
  );
318
+ previewsContainer.setAttribute("data-persona-composer-attachment-previews", "");
358
319
  previewsContainer.style.display = "none";
359
320
 
360
321
  const input = createElement("input") as HTMLInputElement;
361
322
  input.type = "file";
323
+ input.setAttribute("data-persona-composer-attachment-input", "");
362
324
  input.accept = (attachmentsConfig.allowedTypes ?? ALL_SUPPORTED_MIME_TYPES).join(",");
363
325
  input.multiple = (attachmentsConfig.maxFiles ?? 4) > 1;
364
326
  input.style.display = "none";
365
327
  input.setAttribute("aria-label", "Attach files");
366
328
 
367
- const wrapper = createElement("div", "persona-send-button-wrapper");
368
- const button = createElement(
369
- "button",
370
- "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer persona-attachment-button"
371
- ) as HTMLButtonElement;
372
- button.type = "button";
373
- button.setAttribute("aria-label", attachmentsConfig.buttonTooltipText ?? "Attach file");
374
-
375
329
  const attachIconName = attachmentsConfig.buttonIconName ?? "paperclip";
376
330
  const attachIconSize = buttonSize;
377
331
  const buttonSizeNum = parseFloat(attachIconSize) || 40;
378
332
  const attachIconSizeNum = Math.round(buttonSizeNum * 0.6);
379
333
 
380
- button.style.width = attachIconSize;
381
- button.style.height = attachIconSize;
382
- button.style.minWidth = attachIconSize;
383
- button.style.minHeight = attachIconSize;
384
- button.style.fontSize = "18px";
385
- button.style.lineHeight = "1";
386
- button.style.backgroundColor = "transparent";
387
- button.style.color = "var(--persona-primary, #111827)";
388
- button.style.border = "none";
389
- button.style.borderRadius = "6px";
390
- button.style.transition = "background-color 0.15s ease";
334
+ const wrapper = createElement("div", "persona-send-button-wrapper");
335
+ const button = createNode("button", {
336
+ className:
337
+ "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer persona-attachment-button",
338
+ attrs: {
339
+ type: "button",
340
+ "data-persona-composer-attachment-button": "",
341
+ "aria-label": attachmentsConfig.buttonTooltipText ?? "Attach file",
342
+ },
343
+ style: {
344
+ width: attachIconSize,
345
+ height: attachIconSize,
346
+ minWidth: attachIconSize,
347
+ minHeight: attachIconSize,
348
+ fontSize: "18px",
349
+ lineHeight: "1",
350
+ backgroundColor: "transparent",
351
+ color: "var(--persona-primary, #111827)",
352
+ border: "none",
353
+ borderRadius: "6px",
354
+ transition: "background-color 0.15s ease",
355
+ },
356
+ });
391
357
 
392
358
  button.addEventListener("mouseenter", () => {
393
359
  button.style.backgroundColor = "var(--persona-palette-colors-black-alpha-50, rgba(0, 0, 0, 0.05))";
@@ -452,4 +418,7 @@ export const createStatusText = (config?: AgentWidgetConfig): HTMLElement => {
452
418
  };
453
419
 
454
420
  export const createSuggestionsRow = (): HTMLElement =>
455
- createElement("div", "persona-mb-3 persona-flex persona-flex-wrap persona-gap-2");
421
+ createNode("div", {
422
+ className: "persona-mb-3 persona-flex persona-flex-wrap persona-gap-2",
423
+ attrs: { "data-persona-composer-suggestions": "" },
424
+ });
@@ -612,7 +612,7 @@ export function createDemoCarousel(
612
612
  body.classList.remove("theme-dark");
613
613
  }
614
614
  } catch {
615
- // Cross-origin iframe silently ignore
615
+ // Cross-origin iframe: silently ignore
616
616
  }
617
617
  }
618
618