@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
@@ -283,7 +283,7 @@ export interface ComboButtonHandle {
283
283
  }
284
284
 
285
285
  /**
286
- * Creates a combo button a clickable label with a chevron that opens a dropdown menu.
286
+ * Creates a combo button: a clickable label with a chevron that opens a dropdown menu.
287
287
  *
288
288
  * The entire label + chevron area acts as a single interactive unit with an optional
289
289
  * hover pill effect. Clicking anywhere on it toggles the dropdown.
@@ -1380,7 +1380,7 @@ function generateScriptInstallerCode(config: any, options?: CodeGeneratorOptions
1380
1380
  // branch hoists every sibling key (Object.assign(scriptConfig, parsed)), while
1381
1381
  // a flat payload becomes the widget config wholesale and its siblings are
1382
1382
  // ignored. So whenever either is set we emit the nested `{ config, ...}` form
1383
- // with the option as a SIBLING of `config` nesting `target` inside the
1383
+ // with the option as a SIBLING of `config`: nesting `target` inside the
1384
1384
  // widget config would leave the widget mounted on `body`.
1385
1385
  const needsWrapper = Boolean(options?.windowKey || options?.target);
1386
1386
  const payload = needsWrapper
@@ -36,7 +36,7 @@ export interface ComposerHistoryInput {
36
36
  export interface ComposerHistoryResult {
37
37
  /** Whether the key was consumed (caller should preventDefault). */
38
38
  handled: boolean;
39
- /** New textarea value to apply only present when it should change. */
39
+ /** New textarea value to apply: only present when it should change. */
40
40
  value?: string;
41
41
  /** Next navigation state. */
42
42
  state: ComposerHistoryState;
@@ -86,7 +86,7 @@ export function navigateComposerHistory(
86
86
  };
87
87
  }
88
88
 
89
- // Already at the oldest entry consume the key but don't change.
89
+ // Already at the oldest entry: consume the key but don't change.
90
90
  return { handled: true, state };
91
91
  }
92
92
 
@@ -104,7 +104,7 @@ export function navigateComposerHistory(
104
104
  };
105
105
  }
106
106
 
107
- // Stepped past the newest entry restore the saved draft and exit.
107
+ // Stepped past the newest entry: restore the saved draft and exit.
108
108
  return {
109
109
  handled: true,
110
110
  value: state.draft,
@@ -181,7 +181,7 @@ describe("collectEnrichedPageContext", () => {
181
181
  it("handles empty body", () => {
182
182
  document.body.innerHTML = "";
183
183
  const result = collectEnrichedPageContext();
184
- // May return body itself or empty either is valid
184
+ // May return body itself or empty: either is valid
185
185
  expect(Array.isArray(result)).toBe(true);
186
186
  });
187
187
 
@@ -386,7 +386,7 @@ describe("formatEnrichedContext", () => {
386
386
  interactivity: "static",
387
387
  attributes: {},
388
388
  formattedSummary:
389
- "[Shirt](/p/1) $10\nselector: div.card\nactions: Add",
389
+ "[Shirt](/p/1): $10\nselector: div.card\nactions: Add",
390
390
  },
391
391
  ];
392
392
  const out = formatEnrichedContext(elements, { mode: "structured" });
@@ -2,14 +2,14 @@
2
2
  * Enriched DOM context collection for providing richer page information to AI.
3
3
  *
4
4
  * Captures interactive elements, stable CSS selectors, ARIA roles, data attributes,
5
- * and visibility state giving the LLM much better context than basic className/innerText.
5
+ * and visibility state: giving the LLM much better context than basic className/innerText.
6
6
  *
7
7
  * ## Modes
8
8
  *
9
9
  * - **structured** (default): collects candidates, scores them with optional {@link ParseRule}
10
10
  * hooks, then applies `maxElements`. Rich containers (e.g. product cards) can surface
11
11
  * before unrelated static noise.
12
- * - **simple**: legacy behavior cap during traversal, interactive-first ordering, no rule
12
+ * - **simple**: legacy behavior: cap during traversal, interactive-first ordering, no rule
13
13
  * scoring or {@link EnrichedPageElement.formattedSummary}.
14
14
  */
15
15
 
@@ -44,7 +44,7 @@ export type DomContextMode = "simple" | "structured";
44
44
  export interface ParseOptionsConfig {
45
45
  /**
46
46
  * `structured` (default): score candidates with rules, then apply `maxElements`.
47
- * `simple`: legacy traversal cap and ordering only rules are ignored (with a warning
47
+ * `simple`: legacy traversal cap and ordering only: rules are ignored (with a warning
48
48
  * if `rules` was passed on {@link DomContextOptions}).
49
49
  */
50
50
  mode?: DomContextMode;
@@ -294,9 +294,9 @@ export const defaultParseRules: ParseRule[] = [
294
294
  const ctas = extractCtaLabels(el);
295
295
  const head =
296
296
  href && title
297
- ? `[${title}](${href})${price ? ` ${price}` : ""}`
297
+ ? `[${title}](${href})${price ? `: ${price}` : ""}`
298
298
  : title
299
- ? `${title}${price ? ` ${price}` : ""}`
299
+ ? `${title}${price ? `: ${price}` : ""}`
300
300
  : price || enriched.text.trim().slice(0, 120);
301
301
  const lines = [
302
302
  head,
@@ -443,7 +443,7 @@ function isElementVisible(el: HTMLElement): boolean {
443
443
  if (style.display === "none") return false;
444
444
  if (style.visibility === "hidden") return false;
445
445
  } catch {
446
- // getComputedStyle can fail in some environments assume visible
446
+ // getComputedStyle can fail in some environments: assume visible
447
447
  }
448
448
 
449
449
  if (el.style.display === "none") return false;
@@ -801,7 +801,7 @@ function collectSimple(
801
801
  * - **Default (structured):** walks up to `maxCandidates` nodes, scores with
802
802
  * {@link defaultParseRules} (or `rules`), suppresses redundant descendants when a
803
803
  * formatting rule matches, then keeps the top `maxElements` by score (DOM order tie-break).
804
- * - **simple:** legacy path stops once `maxElements` nodes are collected during traversal
804
+ * - **simple:** legacy path: stops once `maxElements` nodes are collected during traversal
805
805
  * and sorts interactive before static.
806
806
  *
807
807
  * Pass `options: { mode: "simple" }` to disable rules. If `mode` is `simple` and `rules` is
@@ -0,0 +1,67 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { createNode, cx } from "./dom";
5
+
6
+ describe("createNode", () => {
7
+ it("applies className, text, attributes, and inline styles", () => {
8
+ const el = createNode("button", {
9
+ className: "persona-foo persona-bar",
10
+ text: "Send",
11
+ attrs: { type: "submit", "data-persona-composer-submit": "" },
12
+ style: { display: "none", zIndex: "5" },
13
+ });
14
+
15
+ expect(el.tagName).toBe("BUTTON");
16
+ expect(el.className).toBe("persona-foo persona-bar");
17
+ expect(el.textContent).toBe("Send");
18
+ expect(el.getAttribute("type")).toBe("submit");
19
+ expect(el.getAttribute("data-persona-composer-submit")).toBe("");
20
+ expect(el.style.display).toBe("none");
21
+ expect(el.style.zIndex).toBe("5");
22
+ });
23
+
24
+ it("appends node and string children in order, skipping nullish values", () => {
25
+ const child = createNode("span", { text: "child" });
26
+ const parent = createNode("div", {}, child, "tail", null, undefined);
27
+
28
+ expect(parent.childNodes.length).toBe(2);
29
+ expect(parent.firstChild).toBe(child);
30
+ expect(parent.lastChild?.textContent).toBe("tail");
31
+ });
32
+
33
+ it("defaults to a bare element when no options are provided", () => {
34
+ const el = createNode("div");
35
+ expect(el.tagName).toBe("DIV");
36
+ expect(el.className).toBe("");
37
+ expect(el.childNodes.length).toBe(0);
38
+ });
39
+
40
+ it("skips nullish style values so conditionals can be inlined", () => {
41
+ const el = createNode("div", {
42
+ style: { display: "none", color: undefined, width: "10px" },
43
+ });
44
+ expect(el.style.display).toBe("none");
45
+ expect(el.style.width).toBe("10px");
46
+ // An undefined value must not be written (no literal "undefined").
47
+ expect(el.style.color).toBe("");
48
+ });
49
+ });
50
+
51
+ describe("cx", () => {
52
+ it("joins truthy fragments and drops falsy ones", () => {
53
+ expect(cx("a", false, "b", null, undefined, "", "c")).toBe("a b c");
54
+ });
55
+
56
+ it("supports inline conditional classes", () => {
57
+ const active = true;
58
+ const disabled = false;
59
+ expect(cx("base", active && "is-active", disabled && "is-disabled")).toBe(
60
+ "base is-active"
61
+ );
62
+ });
63
+
64
+ it("returns an empty string when every fragment is falsy", () => {
65
+ expect(cx(false, null, undefined, "")).toBe("");
66
+ });
67
+ });
package/src/utils/dom.ts CHANGED
@@ -28,6 +28,81 @@ export const createFragment = (): DocumentFragment => {
28
28
  return document.createDocumentFragment();
29
29
  };
30
30
 
31
+ export interface CreateNodeOptions {
32
+ /** Sets `element.className`. */
33
+ className?: string;
34
+ /** Sets `element.textContent` before any `children` are appended. */
35
+ text?: string;
36
+ /** Attribute name → value pairs applied via `setAttribute`. */
37
+ attrs?: Record<string, string>;
38
+ /**
39
+ * Inline styles. Nullish (`undefined`/`null`) values are skipped so callers
40
+ * can inline conditionals (e.g. `borderColor: cfg.borderColor`) without an
41
+ * `if` per property. Note this only *sets* values, it never clears them, so
42
+ * prefer it for constructing fresh elements rather than re-styling live ones.
43
+ */
44
+ style?: Partial<CSSStyleDeclaration>;
45
+ }
46
+
47
+ /**
48
+ * Ergonomic element factory that bundles the className + attribute + style +
49
+ * children boilerplate the widget's DOM builders otherwise repeat by hand.
50
+ *
51
+ * `createElement` stays the right tool for the simple `(tag, className)` case;
52
+ * reach for `createNode` when a node also needs attributes, inline styles, or
53
+ * up-front children. Nullish `children` are skipped so callers can inline
54
+ * conditionals (e.g. `maybeIcon && el`).
55
+ */
56
+ export const createNode = <K extends keyof HTMLElementTagNameMap>(
57
+ tag: K,
58
+ options: CreateNodeOptions = {},
59
+ ...children: Array<Node | string | null | undefined>
60
+ ): HTMLElementTagNameMap[K] => {
61
+ const element = document.createElement(tag);
62
+
63
+ if (options.className) {
64
+ element.className = options.className;
65
+ }
66
+ if (options.text !== undefined) {
67
+ element.textContent = options.text;
68
+ }
69
+ if (options.attrs) {
70
+ for (const [name, value] of Object.entries(options.attrs)) {
71
+ element.setAttribute(name, value);
72
+ }
73
+ }
74
+ if (options.style) {
75
+ const style = element.style as unknown as Record<string, string>;
76
+ const source = options.style as Record<string, string | null | undefined>;
77
+ for (const property of Object.keys(source)) {
78
+ const value = source[property];
79
+ if (value != null) {
80
+ style[property] = value;
81
+ }
82
+ }
83
+ }
84
+
85
+ const appendable = children.filter(
86
+ (child): child is Node | string => child != null
87
+ );
88
+ if (appendable.length > 0) {
89
+ element.append(...appendable);
90
+ }
91
+
92
+ return element;
93
+ };
94
+
95
+ /**
96
+ * Join truthy class-name fragments into a single space-separated string
97
+ * (the clsx / classnames pattern). Falsy fragments
98
+ * (`false` / `null` / `undefined` / `""`) are dropped, so conditional classes
99
+ * read inline as `cond && "persona-foo"` instead of imperative
100
+ * `classList.add(...)` branches.
101
+ */
102
+ export const cx = (
103
+ ...parts: Array<string | false | null | undefined>
104
+ ): string => parts.filter(Boolean).join(" ");
105
+
31
106
 
32
107
 
33
108
 
@@ -72,11 +72,11 @@ export function createDropdownMenu(options: CreateDropdownOptions): DropdownMenu
72
72
  menu.setAttribute("data-persona-theme-zone", "dropdown");
73
73
 
74
74
  if (portal) {
75
- // Fixed positioning menu is portaled outside the anchor's overflow context
75
+ // Fixed positioning: menu is portaled outside the anchor's overflow context
76
76
  menu.style.position = "fixed";
77
77
  menu.style.zIndex = String(PORTALED_OVERLAY_Z_INDEX);
78
78
  } else {
79
- // Absolute positioning menu lives inside the anchor
79
+ // Absolute positioning: menu lives inside the anchor
80
80
  menu.style.position = "absolute";
81
81
  menu.style.top = "100%";
82
82
  menu.style.marginTop = "4px";
@@ -239,7 +239,7 @@ describe("EventStreamBuffer", () => {
239
239
  expect(buf.getAll()).toEqual([]);
240
240
  expect(store.clear).toHaveBeenCalled();
241
241
 
242
- // Simulate new session sending events buffer should accept them
242
+ // Simulate new session sending events: buffer should accept them
243
243
  const newEvt = makeEvent("c", 3);
244
244
  buf.push(newEvt);
245
245
  expect(buf.getSize()).toBe(1);
@@ -253,7 +253,7 @@ describe("EventStreamBuffer", () => {
253
253
  buf.push(makeEvent("session1_a", 1));
254
254
  buf.push(makeEvent("session1_b", 2));
255
255
 
256
- // Session reset happens (no clear called buffer persists)
256
+ // Session reset happens (no clear called: buffer persists)
257
257
  // Session 2 events
258
258
  buf.push(makeEvent("session2_a", 3));
259
259
 
@@ -47,6 +47,15 @@ describe("computeMessageFingerprint", () => {
47
47
  expect(fp1).not.toBe(fp2);
48
48
  });
49
49
 
50
+ it("changes when voiceProcessing flips with identical text (transcript finalize)", () => {
51
+ // On transcript finalize, voiceProcessing goes true→false with the SAME
52
+ // text. The fingerprint must change so the live transcribing/thinking
53
+ // bubble is replaced by the finalized one instead of served from cache.
54
+ const fp1 = computeMessageFingerprint(makeMessage({ content: "hello there", voiceProcessing: true }), 0);
55
+ const fp2 = computeMessageFingerprint(makeMessage({ content: "hello there", voiceProcessing: false }), 0);
56
+ expect(fp1).not.toBe(fp2);
57
+ });
58
+
50
59
  it("changes when role changes", () => {
51
60
  const fp1 = computeMessageFingerprint(makeMessage({ role: "assistant" }), 0);
52
61
  const fp2 = computeMessageFingerprint(makeMessage({ role: "user" }), 0);
@@ -12,6 +12,7 @@ export type FingerprintableMessage = {
12
12
  role: string;
13
13
  content: string;
14
14
  streaming?: boolean;
15
+ voiceProcessing?: boolean;
15
16
  variant?: string;
16
17
  rawContent?: string;
17
18
  llmContent?: string;
@@ -49,6 +50,10 @@ export function computeMessageFingerprint(
49
50
  message.content?.length ?? 0,
50
51
  message.content?.slice(-32) ?? "",
51
52
  message.streaming ? "1" : "0",
53
+ // voiceProcessing flips true→false on transcript finalize, usually with the
54
+ // SAME text — so without it the cache would reuse the live transcribing/
55
+ // thinking bubble (waveform/dots) for the finalized message.
56
+ message.voiceProcessing ? "1" : "0",
52
57
  message.variant ?? "",
53
58
  message.rawContent?.length ?? 0,
54
59
  message.llmContent?.length ?? 0,
@@ -33,7 +33,7 @@ export const morphMessages = (
33
33
  // Plugins actively mutating a node (e.g. glyph-cycle's tick loop)
34
34
  // opt out of morph entirely via this attribute. Unlike
35
35
  // `data-preserve-animation`, this is honored regardless of whether
36
- // the new DOM carries the attribute it's a runtime-only marker.
36
+ // the new DOM carries the attribute: it's a runtime-only marker.
37
37
  if (oldNode.hasAttribute("data-preserve-runtime")) {
38
38
  return false;
39
39
  }
@@ -1,11 +1,13 @@
1
- import DOMPurify from "dompurify";
1
+ import type * as DOMPurifyType from "dompurify";
2
+ import { escapeHtml } from "../postprocessors";
3
+ import { getMarkdownParsersSync } from "../markdown-parsers-loader";
2
4
 
3
5
  /**
4
6
  * A function that sanitizes an HTML string, returning safe HTML.
5
7
  */
6
8
  export type SanitizeFunction = (html: string) => string;
7
9
 
8
- const DEFAULT_PURIFY_CONFIG: DOMPurify.Config = {
10
+ const DEFAULT_PURIFY_CONFIG: DOMPurifyType.Config = {
9
11
  // Tags safe for markdown-rendered content
10
12
  ALLOWED_TAGS: [
11
13
  // Headings & structure
@@ -41,7 +43,7 @@ const DEFAULT_PURIFY_CONFIG: DOMPurify.Config = {
41
43
  ],
42
44
  };
43
45
 
44
- /** Raster image data URI pattern blocks SVG and other non-image types. */
46
+ /** Raster image data URI pattern: blocks SVG and other non-image types. */
45
47
  const SAFE_DATA_URI = /^data:image\/(?:png|jpe?g|gif|webp|bmp|x-icon|avif)/i;
46
48
 
47
49
  /**
@@ -49,22 +51,36 @@ const SAFE_DATA_URI = /^data:image\/(?:png|jpe?g|gif|webp|bmp|x-icon|avif)/i;
49
51
  * Uses the global window when available (browser).
50
52
  */
51
53
  export const createDefaultSanitizer = (): SanitizeFunction => {
52
- // DOMPurify needs a DOM context. In the browser, pass `window`.
53
- // The widget only runs in browsers, so `window` is always available at runtime.
54
- const purify = DOMPurify(typeof window !== "undefined" ? window : (undefined as never));
54
+ let purifyInstance: ReturnType<typeof DOMPurifyType.default> | null = null;
55
55
 
56
- // Hook: strip data:image/svg+xml and other unsafe data: URIs from src/href
57
- purify.addHook("uponSanitizeAttribute", (_node, data) => {
58
- if (data.attrName === "src" || data.attrName === "href") {
59
- const val = data.attrValue;
60
- if (val.toLowerCase().startsWith("data:") && !SAFE_DATA_URI.test(val)) {
61
- data.attrValue = "";
62
- data.keepAttr = false;
63
- }
56
+ return (html: string): string => {
57
+ const parsers = getMarkdownParsersSync();
58
+ if (!parsers) {
59
+ // If DOMPurify hasn't loaded yet, fall back to escaping HTML completely
60
+ // to remain safe until the module is available.
61
+ return escapeHtml(html);
64
62
  }
65
- });
63
+
64
+ if (!purifyInstance) {
65
+ const { DOMPurify } = parsers;
66
+ // DOMPurify needs a DOM context. In the browser, pass `window`.
67
+ // The widget only runs in browsers, so `window` is always available at runtime.
68
+ purifyInstance = DOMPurify(typeof window !== "undefined" ? window : (undefined as never));
66
69
 
67
- return (html: string): string => purify.sanitize(html, DEFAULT_PURIFY_CONFIG) as string;
70
+ // Hook: strip data:image/svg+xml and other unsafe data: URIs from src/href
71
+ purifyInstance.addHook("uponSanitizeAttribute", (_node, data) => {
72
+ if (data.attrName === "src" || data.attrName === "href") {
73
+ const val = data.attrValue;
74
+ if (val.toLowerCase().startsWith("data:") && !SAFE_DATA_URI.test(val)) {
75
+ data.attrValue = "";
76
+ data.keepAttr = false;
77
+ }
78
+ }
79
+ });
80
+ }
81
+
82
+ return purifyInstance.sanitize(html, DEFAULT_PURIFY_CONFIG) as string;
83
+ };
68
84
  };
69
85
 
70
86
  /**
@@ -32,16 +32,16 @@ describe("SequenceReorderBuffer", () => {
32
32
 
33
33
  it("reorders leading out-of-order events (3, 1, 2 → 1, 2, 3)", () => {
34
34
  const buf = new SequenceReorderBuffer(emitter);
35
- // seq=3 arrives first should be buffered (3 > nextExpected=1)
35
+ // seq=3 arrives first: should be buffered (3 > nextExpected=1)
36
36
  buf.push("step_delta", { seq: 3, text: "c" });
37
37
  expect(emitted).toHaveLength(0);
38
38
 
39
- // seq=1 arrives matches nextExpected, emits, then drains seq=2 (not present), stops
39
+ // seq=1 arrives: matches nextExpected, emits, then drains seq=2 (not present), stops
40
40
  buf.push("step_delta", { seq: 1, text: "a" });
41
41
  expect(emitted).toHaveLength(1);
42
42
  expect(emitted[0].payload.text).toBe("a");
43
43
 
44
- // seq=2 arrives matches nextExpected=2, emits, drains seq=3 from buffer
44
+ // seq=2 arrives: matches nextExpected=2, emits, drains seq=3 from buffer
45
45
  buf.push("step_delta", { seq: 2, text: "b" });
46
46
  expect(emitted).toHaveLength(3);
47
47
  expect(emitted[1].payload.text).toBe("b");
@@ -69,7 +69,7 @@ describe("SequenceReorderBuffer", () => {
69
69
 
70
70
  expect(emitted).toHaveLength(1);
71
71
 
72
- // Advance past gap timeout seq=2 never arrives, flush seq=3 anyway
72
+ // Advance past gap timeout: seq=2 never arrives, flush seq=3 anyway
73
73
  vi.advanceTimersByTime(60);
74
74
 
75
75
  expect(emitted).toHaveLength(2);
@@ -96,7 +96,7 @@ describe("SequenceReorderBuffer", () => {
96
96
  buf.push("step_delta", { seq: 3, text: "c" });
97
97
  expect(emitted).toHaveLength(3);
98
98
 
99
- // Now seq=1 arrives again it's a duplicate (1 < nextExpected=4), still emitted
99
+ // Now seq=1 arrives again: it's a duplicate (1 < nextExpected=4), still emitted
100
100
  buf.push("step_delta", { seq: 1, text: "a-dup" });
101
101
  expect(emitted).toHaveLength(4);
102
102
  expect(emitted[3].payload.text).toBe("a-dup");
@@ -184,7 +184,7 @@ describe("SequenceReorderBuffer", () => {
184
184
 
185
185
  it("leading gap flushes via timeout when seq=1 never arrives", () => {
186
186
  const buf = new SequenceReorderBuffer(emitter, 50);
187
- // Only seq=2 and seq=3 arrive seq=1 is missing
187
+ // Only seq=2 and seq=3 arrive: seq=1 is missing
188
188
  buf.push("step_delta", { seq: 2, text: "b" });
189
189
  buf.push("step_delta", { seq: 3, text: "c" });
190
190
  expect(emitted).toHaveLength(0); // both buffered
@@ -232,7 +232,7 @@ describe("SequenceReorderBuffer", () => {
232
232
  buf.push("step_delta", { seq: 3, text: "first-at-3" });
233
233
  expect(emitted).toHaveLength(1);
234
234
 
235
- // Second event with same seq=3 prior one should be emitted out-of-order
235
+ // Second event with same seq=3: prior one should be emitted out-of-order
236
236
  buf.push("reason_delta", { seq: 3, text: "second-at-3" });
237
237
 
238
238
  expect(warnSpy).toHaveBeenCalledTimes(1);
@@ -244,7 +244,7 @@ describe("SequenceReorderBuffer", () => {
244
244
  expect(emitted).toHaveLength(2);
245
245
  expect(emitted[1].payload.text).toBe("first-at-3");
246
246
 
247
- // seq=2 arrives advances nextExpected through the buffered second-at-3
247
+ // seq=2 arrives: advances nextExpected through the buffered second-at-3
248
248
  buf.push("step_delta", { seq: 2, text: "b" });
249
249
  expect(emitted).toHaveLength(4);
250
250
  expect(emitted[2].payload.text).toBe("b");
@@ -19,7 +19,7 @@ export class SequenceReorderBuffer {
19
19
  // - `agentContext.seq`: tool_start, tool_delta, tool_complete (agent loop)
20
20
  const seq = payload?.seq ?? payload?.sequenceIndex ?? payload?.agentContext?.seq;
21
21
 
22
- // No seq field emit immediately (backward compat).
22
+ // No seq field: emit immediately (backward compat).
23
23
  // If there are buffered events waiting for a gap to fill, flush them
24
24
  // first: the server sending an unsequenced event means it has moved on
25
25
  // and the missing seq numbers are not coming.
@@ -49,17 +49,17 @@ export class SequenceReorderBuffer {
49
49
  return;
50
50
  }
51
51
 
52
- // If seq < nextExpected, it's a duplicate or late arrival emit anyway (don't drop)
52
+ // If seq < nextExpected, it's a duplicate or late arrival: emit anyway (don't drop)
53
53
  if (seq < this.nextExpectedSeq!) {
54
54
  this.emitter(payloadType, payload);
55
55
  return;
56
56
  }
57
57
 
58
- // seq > nextExpected buffer it and start gap timer.
58
+ // seq > nextExpected: buffer it and start gap timer.
59
59
  // If another event with the same seq is already buffered, the server
60
60
  // broke its "seq is unique per stream" invariant. Rather than silently
61
61
  // overwrite (losing one event) or swallow the new one, emit the prior
62
- // event immediately out of order, but better than dropping it and
62
+ // event immediately, out of order, but better than dropping it, and
63
63
  // warn so the issue is visible.
64
64
  const existing = this.buffer.get(seq);
65
65
  if (existing !== undefined) {
@@ -1,4 +1,4 @@
1
- // Pure mapper test no DOM, no installed/vendored library at runtime (types only).
1
+ // Pure mapper test: no DOM, no installed/vendored library at runtime (types only).
2
2
  import { describe, it, expect } from "vitest";
3
3
  import { smartDomResultToEnriched } from "./smart-dom-adapter";
4
4
  import { formatEnrichedContext } from "./dom-context";
@@ -2,8 +2,8 @@
2
2
  * Pure mapper: `@mcp-b/smart-dom-reader` output → Persona's {@link EnrichedPageElement}[].
3
3
  *
4
4
  * This module imports the smart-dom-reader types with `import type` ONLY, so the
5
- * library's runtime value is never pulled in here. That keeps the mapper and its
6
- * unit test free of any DOM and free of the (vendored) library at runtime; the
5
+ * library's runtime value is never pulled in here. That keeps the mapper, and its
6
+ * unit test: free of any DOM and free of the (vendored) library at runtime; the
7
7
  * types are erased during compilation.
8
8
  *
9
9
  * The smart-dom-reader returns a structured {@link SmartDOMResult} JSON object with
@@ -43,8 +43,7 @@ export interface SmartDomAdapterOptions {
43
43
 
44
44
  /**
45
45
  * Candidate selector types that resolve through `document.querySelector` (plain CSS).
46
- * Everything else smart-dom-reader can emit `xpath` and text pseudo-selectors
47
- * is not actionable via the current click loop, so it is skipped here.
46
+ * Everything else smart-dom-reader can emit, `xpath` and text pseudo-selectors, * is not actionable via the current click loop, so it is skipped here.
48
47
  */
49
48
  const PLAIN_CSS_CANDIDATE_TYPES: ReadonlySet<ElementSelectorCandidate["type"]> =
50
49
  new Set(["id", "data-testid", "role-aria", "name", "class-path", "css-path"]);
@@ -119,7 +118,7 @@ function collectAttributes(el: ExtractedElement): Record<string, string> {
119
118
  * Returns true when the element sits under `excludeSelector` (e.g. the widget host).
120
119
  * smart-dom-reader has no exclude option and pierces shadow DOM by default, so this
121
120
  * guards against the widget reading its own UI. The check is a substring match across
122
- * the element's candidate selectors and ancestor chain robust for the default
121
+ * the element's candidate selectors and ancestor chain: robust for the default
123
122
  * `.persona-host` class guard.
124
123
  */
125
124
  function isExcluded(el: ExtractedElement, excludeSelector: string): boolean {