@runtypelabs/persona 3.15.1 → 3.17.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 (60) hide show
  1. package/dist/animations/glyph-cycle.cjs +279 -0
  2. package/dist/animations/glyph-cycle.d.cts +5 -0
  3. package/dist/animations/glyph-cycle.d.ts +5 -0
  4. package/dist/animations/glyph-cycle.js +252 -0
  5. package/dist/animations/types-HPZY7oAI.d.cts +282 -0
  6. package/dist/animations/types-HPZY7oAI.d.ts +282 -0
  7. package/dist/animations/wipe.cjs +107 -0
  8. package/dist/animations/wipe.d.cts +5 -0
  9. package/dist/animations/wipe.d.ts +5 -0
  10. package/dist/animations/wipe.js +80 -0
  11. package/dist/index.cjs +49 -48
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +216 -1
  14. package/dist/index.d.ts +216 -1
  15. package/dist/index.global.js +137 -82
  16. package/dist/index.global.js.map +1 -1
  17. package/dist/index.js +49 -48
  18. package/dist/index.js.map +1 -1
  19. package/dist/testing.cjs +85 -0
  20. package/dist/testing.d.cts +39 -0
  21. package/dist/testing.d.ts +39 -0
  22. package/dist/testing.js +56 -0
  23. package/dist/theme-editor.cjs +847 -127
  24. package/dist/theme-editor.d.cts +225 -2
  25. package/dist/theme-editor.d.ts +225 -2
  26. package/dist/theme-editor.js +845 -127
  27. package/dist/widget.css +133 -0
  28. package/package.json +20 -3
  29. package/src/animations/glyph-cycle.ts +332 -0
  30. package/src/animations/wipe.ts +66 -0
  31. package/src/client.test.ts +141 -0
  32. package/src/client.ts +197 -2
  33. package/src/components/composer-builder.ts +61 -10
  34. package/src/components/header-builder.ts +18 -7
  35. package/src/components/header-layouts.ts +3 -1
  36. package/src/components/message-bubble.test.ts +181 -2
  37. package/src/components/message-bubble.ts +209 -14
  38. package/src/components/panel.ts +4 -1
  39. package/src/defaults.ts +22 -0
  40. package/src/index-global.ts +31 -0
  41. package/src/index.ts +18 -0
  42. package/src/session.test.ts +93 -1
  43. package/src/session.ts +5 -0
  44. package/src/styles/widget.css +133 -0
  45. package/src/testing/index.ts +11 -0
  46. package/src/testing/mock-stream.test.ts +80 -0
  47. package/src/testing/mock-stream.ts +94 -0
  48. package/src/testing.ts +2 -0
  49. package/src/theme-editor/index.ts +4 -0
  50. package/src/theme-editor/preview-utils.test.ts +60 -0
  51. package/src/theme-editor/preview-utils.ts +129 -0
  52. package/src/theme-editor/sections.test.ts +19 -0
  53. package/src/theme-editor/sections.ts +84 -1
  54. package/src/types.ts +221 -0
  55. package/src/ui.stop-button.test.ts +165 -0
  56. package/src/ui.ts +79 -8
  57. package/src/utils/message-fingerprint.ts +2 -0
  58. package/src/utils/morph.ts +7 -0
  59. package/src/utils/stream-animation.test.ts +417 -0
  60. package/src/utils/stream-animation.ts +449 -0
package/src/ui.ts CHANGED
@@ -41,6 +41,11 @@ import {
41
41
  resolveFollowStateFromWheel
42
42
  } from "./utils/auto-follow";
43
43
  import { statusCopy, DEFAULT_OVERLAY_Z_INDEX, PORTALED_OVERLAY_Z_INDEX } from "./utils/constants";
44
+ import {
45
+ detachAllPlugins,
46
+ ensurePluginActive,
47
+ resolveStreamAnimationPlugin,
48
+ } from "./utils/stream-animation";
44
49
  import { syncOverlayHostStacking } from "./utils/overlay-host-stacking";
45
50
  import { acquireScrollLock } from "./utils/scroll-lock";
46
51
  import { isDockedMountMode, resolveDockConfig } from "./utils/dock";
@@ -701,6 +706,7 @@ export const createAgentExperience = (
701
706
  leftActions,
702
707
  rightActions
703
708
  } = panelElements;
709
+ let setSendButtonMode = panelElements.setSendButtonMode;
704
710
 
705
711
  // Use mutable references for mic button so we can update them dynamically
706
712
  let micButton: HTMLButtonElement | null = panelElements.micButton;
@@ -1671,6 +1677,16 @@ export const createAgentExperience = (
1671
1677
  const panelShadow = resolvePanelChrome(panelPartial?.shadow, defaultPanelShadow);
1672
1678
  const panelBorderRadius = resolvePanelChrome(panelPartial?.borderRadius, defaultPanelBorderRadius);
1673
1679
 
1680
+ // Clearing body.style.cssText below wipes the inline `flex: 1 1 0%` /
1681
+ // `min-height: 0` / `overflow-y: auto` that make the messages area a
1682
+ // scroll container. Between the reset and the mode-specific reapply,
1683
+ // the body's clientHeight == scrollHeight momentarily, so the browser
1684
+ // clamps scrollTop to 0 — and a synchronous restore at the end of this
1685
+ // function runs before layout has reflowed, so the write is also
1686
+ // clamped. Defer the restore to the next frame, once the reapplied
1687
+ // styles have produced a scrollable container again.
1688
+ const prevBodyScrollTop = body.scrollTop;
1689
+
1674
1690
  // Reset all inline styles first to handle mode toggling
1675
1691
  // This ensures styles don't persist when switching between modes
1676
1692
  mount.style.cssText = '';
@@ -1679,6 +1695,18 @@ export const createAgentExperience = (
1679
1695
  container.style.cssText = '';
1680
1696
  body.style.cssText = '';
1681
1697
  footer.style.cssText = '';
1698
+
1699
+ const restoreBodyScrollTop = (): void => {
1700
+ if (prevBodyScrollTop <= 0) return;
1701
+ const ownerWindow = body.ownerDocument.defaultView ?? window;
1702
+ ownerWindow.requestAnimationFrame(() => {
1703
+ if (body.scrollTop === prevBodyScrollTop) return;
1704
+ // If scrollHeight collapsed (content actually shrank), don't fight it
1705
+ const maxScrollTop = body.scrollHeight - body.clientHeight;
1706
+ if (maxScrollTop <= 0) return;
1707
+ body.scrollTop = Math.min(prevBodyScrollTop, maxScrollTop);
1708
+ });
1709
+ };
1682
1710
 
1683
1711
  // Mobile fullscreen: fill entire viewport with no radius/shadow/margins
1684
1712
  if (shouldGoFullscreen) {
@@ -1742,6 +1770,7 @@ export const createAgentExperience = (
1742
1770
  footer.style.flexShrink = '0';
1743
1771
 
1744
1772
  wasMobileFullscreen = true;
1773
+ restoreBodyScrollTop();
1745
1774
  return; // Skip remaining mode logic
1746
1775
  }
1747
1776
 
@@ -1926,6 +1955,8 @@ export const createAgentExperience = (
1926
1955
  : '';
1927
1956
  wrapper.style.cssText += maxHeightStyles + paddingStyles + zIndexStyles;
1928
1957
  }
1958
+
1959
+ restoreBodyScrollTop();
1929
1960
  };
1930
1961
  applyFullHeightStyles();
1931
1962
  // Apply theme variables after applyFullHeightStyles since it resets mount.style.cssText
@@ -2003,6 +2034,23 @@ export const createAgentExperience = (
2003
2034
  }
2004
2035
  });
2005
2036
 
2037
+ // Activate the stream-animation plugin for this widget instance. Plugins
2038
+ // with `styles` inject their CSS into the widget root once; plugins with
2039
+ // `onAttach` (e.g., glyph-cycle's MutationObserver for real glyph tick
2040
+ // loops) can register long-lived DOM listeners here. Detach callbacks are
2041
+ // deferred to widget destroy.
2042
+ const streamAnimationConfig = config.features?.streamAnimation;
2043
+ if (streamAnimationConfig?.type && streamAnimationConfig.type !== "none") {
2044
+ const plugin = resolveStreamAnimationPlugin(
2045
+ streamAnimationConfig.type,
2046
+ streamAnimationConfig.plugins
2047
+ );
2048
+ if (plugin) {
2049
+ ensurePluginActive(plugin, mount);
2050
+ destroyCallbacks.push(() => detachAllPlugins(mount));
2051
+ }
2052
+ }
2053
+
2006
2054
  const suggestionsManager = createSuggestions(suggestions);
2007
2055
  let closeHandler: (() => void) | null = null;
2008
2056
  let session: AgentWidgetSession;
@@ -2921,9 +2969,10 @@ export const createAgentExperience = (
2921
2969
  };
2922
2970
 
2923
2971
  const setComposerDisabled = (disabled: boolean) => {
2924
- // Keep textarea always enabled so users can type while streaming
2925
- // Only disable submit controls to prevent sending during streaming
2926
- sendButton.disabled = disabled;
2972
+ // The send button stays enabled while streaming it doubles as a stop
2973
+ // button. Ancillary controls (mic, suggestions, opt-in targets) still
2974
+ // disable so the user can't race a send against an in-flight stream.
2975
+ setSendButtonMode(disabled ? "stop" : "send");
2927
2976
  if (micButton) {
2928
2977
  micButton.disabled = disabled;
2929
2978
  }
@@ -2974,9 +3023,10 @@ export const createAgentExperience = (
2974
3023
  }
2975
3024
  }
2976
3025
 
2977
- // Only update send button text if NOT using icon mode
3026
+ // Only update send button text if NOT using icon mode. Skip while
3027
+ // streaming so we don't stomp on the "Stop" label.
2978
3028
  const useIcon = config.sendButton?.useIcon ?? false;
2979
- if (!useIcon) {
3029
+ if (!useIcon && !session?.isStreaming()) {
2980
3030
  sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
2981
3031
  }
2982
3032
 
@@ -3178,6 +3228,15 @@ export const createAgentExperience = (
3178
3228
 
3179
3229
  const handleSubmit = (event: Event) => {
3180
3230
  event.preventDefault();
3231
+
3232
+ // While a response is streaming, the submit button acts as a stop button.
3233
+ // Abort the in-flight stream and leave textarea contents / attachments
3234
+ // intact so the user can edit and resend without retyping.
3235
+ if (session.isStreaming()) {
3236
+ session.cancel();
3237
+ return;
3238
+ }
3239
+
3181
3240
  const value = textarea.value.trim();
3182
3241
  const hasAttachments = attachmentManager?.hasAttachments() ?? false;
3183
3242
 
@@ -3914,16 +3973,26 @@ export const createAgentExperience = (
3914
3973
  }
3915
3974
 
3916
3975
  lastScrollTop = body.scrollTop;
3976
+ let lastScrollHeight = body.scrollHeight;
3917
3977
 
3918
3978
  const handleScroll = () => {
3919
3979
  const scrollTop = body.scrollTop;
3980
+ // When content mutates (e.g. stream-animation plugins re-rendering text),
3981
+ // scrollHeight can shrink and force the browser to clamp scrollTop downward.
3982
+ // That emits a scroll event with a negative delta that would otherwise be
3983
+ // misread as the user scrolling up, pausing auto-follow and flashing the
3984
+ // scroll-to-bottom button. Treat those as non-user events.
3985
+ const currentScrollHeight = body.scrollHeight;
3986
+ const scrollHeightShrank = currentScrollHeight < lastScrollHeight;
3987
+ lastScrollHeight = currentScrollHeight;
3988
+
3920
3989
  const { action, nextLastScrollTop } = resolveFollowStateFromScroll({
3921
3990
  following: autoFollow.isFollowing(),
3922
3991
  currentScrollTop: scrollTop,
3923
3992
  lastScrollTop,
3924
3993
  nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
3925
3994
  userScrollThreshold: USER_SCROLL_THRESHOLD,
3926
- isAutoScrolling: isAutoScrolling || hasPendingAutoScroll,
3995
+ isAutoScrolling: isAutoScrolling || hasPendingAutoScroll || scrollHeightShrank,
3927
3996
  pauseOnUpwardScroll: true,
3928
3997
  pauseWhenAwayFromBottom: false,
3929
3998
  resumeRequiresDownwardScroll: true
@@ -4617,9 +4686,11 @@ export const createAgentExperience = (
4617
4686
  const closeButtonIconName = launcher.closeButtonIconName ?? "x";
4618
4687
  const closeButtonIconText = launcher.closeButtonIconText ?? "×";
4619
4688
 
4620
- // Clear existing content and render new icon
4689
+ // Clear existing content and render new icon.
4690
+ // Larger intrinsic size compensates for the X glyph's sparse
4691
+ // viewBox so the close button visually matches sibling icons.
4621
4692
  closeButton.innerHTML = "";
4622
- const iconSvg = renderLucideIcon(closeButtonIconName, "20px", "currentColor", 2);
4693
+ const iconSvg = renderLucideIcon(closeButtonIconName, "28px", "currentColor", 1);
4623
4694
  if (iconSvg) {
4624
4695
  closeButton.appendChild(iconSvg);
4625
4696
  } else {
@@ -24,6 +24,7 @@ export type FingerprintableMessage = {
24
24
  };
25
25
  reasoning?: { chunks?: string[]; status?: string; [key: string]: unknown };
26
26
  contentParts?: unknown[];
27
+ stopReason?: string;
27
28
  };
28
29
 
29
30
  export type MessageCacheEntry = {
@@ -63,6 +64,7 @@ export function computeMessageFingerprint(
63
64
  : 0,
64
65
  message.reasoning?.chunks?.length ?? 0,
65
66
  message.contentParts?.length ?? 0,
67
+ message.stopReason ?? "",
66
68
  configVersion,
67
69
  ].join("\x00");
68
70
  }
@@ -30,6 +30,13 @@ export const morphMessages = (
30
30
  if (oldNode.classList.contains("persona-animate-typing")) {
31
31
  return false;
32
32
  }
33
+ // Plugins actively mutating a node (e.g. glyph-cycle's tick loop)
34
+ // opt out of morph entirely via this attribute. Unlike
35
+ // `data-preserve-animation`, this is honored regardless of whether
36
+ // the new DOM carries the attribute — it's a runtime-only marker.
37
+ if (oldNode.hasAttribute("data-preserve-runtime")) {
38
+ return false;
39
+ }
33
40
  if (oldNode.hasAttribute("data-preserve-animation")) {
34
41
  // Allow morph when the new node drops the attribute (e.g. tool completed)
35
42
  if (newNode instanceof HTMLElement && !newNode.hasAttribute("data-preserve-animation")) {
@@ -0,0 +1,417 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from "vitest";
3
+ import {
4
+ wrapStreamAnimation,
5
+ createSkeletonPlaceholder,
6
+ createStreamCaret,
7
+ resolveStreamAnimation,
8
+ streamAnimationContainerClass,
9
+ streamAnimationBubbleClass,
10
+ isWrappingAnimation,
11
+ resolveStreamAnimationPlugin,
12
+ registerStreamAnimationPlugin,
13
+ unregisterStreamAnimationPlugin,
14
+ listRegisteredStreamAnimations,
15
+ applyStreamBuffer,
16
+ ensurePluginActive,
17
+ detachAllPlugins,
18
+ } from "./stream-animation";
19
+ import type { AgentWidgetMessage, StreamAnimationPlugin } from "../types";
20
+ // Side-import the subpath plugin modules so tests can resolve their types.
21
+ // `letter-rise` and `word-fade` are core built-ins and need no import.
22
+ import "../animations/wipe";
23
+ import "../animations/glyph-cycle";
24
+
25
+ describe("wrapStreamAnimation — char mode", () => {
26
+ it("wraps every character in a plain paragraph into a stream-char span", () => {
27
+ const out = wrapStreamAnimation("<p>Hi!</p>", "char", "m1");
28
+ const parser = document.createElement("div");
29
+ parser.innerHTML = out;
30
+ const spans = parser.querySelectorAll(".persona-stream-char");
31
+ expect(spans.length).toBe(3);
32
+ expect(spans[0].textContent).toBe("H");
33
+ expect(spans[1].textContent).toBe("i");
34
+ expect(spans[2].textContent).toBe("!");
35
+ });
36
+
37
+ it("assigns monotonic --char-index starting at 0", () => {
38
+ const out = wrapStreamAnimation("<p>abc</p>", "char", "m1");
39
+ const parser = document.createElement("div");
40
+ parser.innerHTML = out;
41
+ const spans = parser.querySelectorAll(".persona-stream-char");
42
+ expect(spans[0].getAttribute("style")).toContain("--char-index: 0");
43
+ expect(spans[1].getAttribute("style")).toContain("--char-index: 1");
44
+ expect(spans[2].getAttribute("style")).toContain("--char-index: 2");
45
+ });
46
+
47
+ it("emits stable ids scoped by messageId", () => {
48
+ const out = wrapStreamAnimation("<p>ab</p>", "char", "msg-42");
49
+ const parser = document.createElement("div");
50
+ parser.innerHTML = out;
51
+ expect(parser.querySelector("#stream-c-msg-42-0")?.textContent).toBe("a");
52
+ expect(parser.querySelector("#stream-c-msg-42-1")?.textContent).toBe("b");
53
+ });
54
+
55
+ it("preserves formatting tags and wraps text inside them", () => {
56
+ const out = wrapStreamAnimation("<p>Hi <strong>bold</strong></p>", "char", "m1");
57
+ const parser = document.createElement("div");
58
+ parser.innerHTML = out;
59
+ expect(parser.querySelector("strong")).toBeTruthy();
60
+ const spans = parser.querySelectorAll(".persona-stream-char");
61
+ // "Hi" (2) + "bold" (4) = 6 wrapped chars; the space between stays as a
62
+ // plain text node so natural line-wrap works.
63
+ expect(spans.length).toBe(6);
64
+ expect(parser.querySelector("strong")?.querySelectorAll(".persona-stream-char").length).toBe(4);
65
+ });
66
+
67
+ it("skips descendants of <code> so code spans render as plain text", () => {
68
+ const out = wrapStreamAnimation("<p>see <code>x.y</code></p>", "char", "m1");
69
+ const parser = document.createElement("div");
70
+ parser.innerHTML = out;
71
+ expect(parser.querySelector("code")?.textContent).toBe("x.y");
72
+ expect(parser.querySelector("code")?.querySelectorAll(".persona-stream-char").length).toBe(0);
73
+ // "see" is 3 wrapped chars; the trailing space stays as a plain text node.
74
+ expect(parser.querySelectorAll(".persona-stream-char").length).toBe(3);
75
+ });
76
+
77
+ it("skips descendants of <pre>", () => {
78
+ const out = wrapStreamAnimation("<pre>code\nblock</pre>", "char", "m1");
79
+ const parser = document.createElement("div");
80
+ parser.innerHTML = out;
81
+ expect(parser.querySelector("pre")?.textContent).toBe("code\nblock");
82
+ expect(parser.querySelectorAll(".persona-stream-char").length).toBe(0);
83
+ });
84
+
85
+ it("skips descendants of <a>", () => {
86
+ const out = wrapStreamAnimation('<p>go <a href="/x">home</a></p>', "char", "m1");
87
+ const parser = document.createElement("div");
88
+ parser.innerHTML = out;
89
+ expect(parser.querySelector("a")?.textContent).toBe("home");
90
+ expect(parser.querySelector("a")?.querySelectorAll(".persona-stream-char").length).toBe(0);
91
+ // Only "go" is wrapped (2 chars); the trailing space stays plain.
92
+ expect(parser.querySelectorAll(".persona-stream-char").length).toBe(2);
93
+ });
94
+
95
+ it("leaves whitespace as a plain text node so word breaks survive", () => {
96
+ const out = wrapStreamAnimation("<p>a b</p>", "char", "m1");
97
+ const parser = document.createElement("div");
98
+ parser.innerHTML = out;
99
+ const spans = parser.querySelectorAll(".persona-stream-char");
100
+ expect(spans.length).toBe(2);
101
+ expect(spans[0].textContent).toBe("a");
102
+ expect(spans[1].textContent).toBe("b");
103
+ const p = parser.querySelector("p")!;
104
+ expect(p.childNodes.length).toBe(3);
105
+ expect(p.childNodes[1].nodeType).toBe(Node.TEXT_NODE);
106
+ expect(p.childNodes[1].textContent).toBe(" ");
107
+ });
108
+
109
+ it("wraps each word run in a word-group so chars can't break mid-word", () => {
110
+ const out = wrapStreamAnimation("<p>Hi there</p>", "char", "m1");
111
+ const parser = document.createElement("div");
112
+ parser.innerHTML = out;
113
+ const groups = parser.querySelectorAll(".persona-stream-word-group");
114
+ expect(groups.length).toBe(2);
115
+ expect(groups[0].textContent).toBe("Hi");
116
+ expect(groups[1].textContent).toBe("there");
117
+ expect(groups[0].querySelectorAll(".persona-stream-char").length).toBe(2);
118
+ expect(groups[1].querySelectorAll(".persona-stream-char").length).toBe(5);
119
+ });
120
+
121
+ it("keeps newlines and multi-space runs intact as text nodes", () => {
122
+ const out = wrapStreamAnimation("<p>a\n b</p>", "char", "m1");
123
+ const parser = document.createElement("div");
124
+ parser.innerHTML = out;
125
+ const p = parser.querySelector("p")!;
126
+ expect(p.childNodes.length).toBe(3);
127
+ expect(p.childNodes[1].nodeType).toBe(Node.TEXT_NODE);
128
+ expect(p.childNodes[1].textContent).toBe("\n ");
129
+ });
130
+
131
+ it("is idempotent on re-wrap for streaming: same input yields identical ids/indices", () => {
132
+ const input = "<p>Hello</p>";
133
+ const first = wrapStreamAnimation(input, "char", "m1");
134
+ const second = wrapStreamAnimation(input, "char", "m1");
135
+ expect(first).toBe(second);
136
+ });
137
+
138
+ it("extends indices for appended text across calls with growing content", () => {
139
+ const first = wrapStreamAnimation("<p>Hi</p>", "char", "m1");
140
+ const second = wrapStreamAnimation("<p>Hi there</p>", "char", "m1");
141
+ const parse = (html: string) => {
142
+ const div = document.createElement("div");
143
+ div.innerHTML = html;
144
+ return div.querySelectorAll(".persona-stream-char");
145
+ };
146
+ const firstSpans = parse(first);
147
+ const secondSpans = parse(second);
148
+ // First two ids are stable — idiomorph match contract. The space between
149
+ // "Hi" and "there" is a plain text node, not a span, so the next wrapped
150
+ // char after "Hi" jumps to index 2 for "t" in "there".
151
+ expect(firstSpans[0].id).toBe(secondSpans[0].id);
152
+ expect(firstSpans[1].id).toBe(secondSpans[1].id);
153
+ expect(secondSpans.length).toBeGreaterThan(firstSpans.length);
154
+ // "there" is 5 wrapped chars, starting at index 2.
155
+ expect(secondSpans[2].id).toBe("stream-c-m1-2");
156
+ expect(secondSpans[2].textContent).toBe("t");
157
+ });
158
+
159
+ it("returns input unchanged on empty string", () => {
160
+ expect(wrapStreamAnimation("", "char", "m1")).toBe("");
161
+ });
162
+ });
163
+
164
+ describe("wrapStreamAnimation — word mode", () => {
165
+ it("splits on whitespace and wraps each non-whitespace token", () => {
166
+ const out = wrapStreamAnimation("<p>Hello brave world</p>", "word", "m1");
167
+ const parser = document.createElement("div");
168
+ parser.innerHTML = out;
169
+ const words = parser.querySelectorAll(".persona-stream-word");
170
+ expect(words.length).toBe(3);
171
+ expect(words[0].textContent).toBe("Hello");
172
+ expect(words[1].textContent).toBe("brave");
173
+ expect(words[2].textContent).toBe("world");
174
+ });
175
+
176
+ it("preserves whitespace between word spans as plain text", () => {
177
+ const out = wrapStreamAnimation("<p>a b</p>", "word", "m1");
178
+ const parser = document.createElement("div");
179
+ parser.innerHTML = out;
180
+ const p = parser.querySelector("p")!;
181
+ // Expected DOM: <span>a</span>" "<span>b</span>
182
+ expect(p.childNodes.length).toBe(3);
183
+ expect(p.childNodes[1].nodeType).toBe(Node.TEXT_NODE);
184
+ expect(p.childNodes[1].textContent).toBe(" ");
185
+ });
186
+
187
+ it("assigns monotonic --word-index", () => {
188
+ const out = wrapStreamAnimation("<p>one two three</p>", "word", "m1");
189
+ const parser = document.createElement("div");
190
+ parser.innerHTML = out;
191
+ const words = parser.querySelectorAll(".persona-stream-word");
192
+ expect(words[0].getAttribute("style")).toContain("--word-index: 0");
193
+ expect(words[2].getAttribute("style")).toContain("--word-index: 2");
194
+ });
195
+
196
+ it("emits stable word ids scoped by messageId", () => {
197
+ const out = wrapStreamAnimation("<p>foo bar</p>", "word", "abc");
198
+ const parser = document.createElement("div");
199
+ parser.innerHTML = out;
200
+ expect(parser.querySelector("#stream-w-abc-0")?.textContent).toBe("foo");
201
+ expect(parser.querySelector("#stream-w-abc-1")?.textContent).toBe("bar");
202
+ });
203
+
204
+ it("skips words inside <pre>, <code>, <a>", () => {
205
+ const out = wrapStreamAnimation(
206
+ '<p>see <code>foo</code> and <a href="/x">link</a></p>',
207
+ "word",
208
+ "m1"
209
+ );
210
+ const parser = document.createElement("div");
211
+ parser.innerHTML = out;
212
+ expect(parser.querySelector("code")?.querySelectorAll(".persona-stream-word").length).toBe(0);
213
+ expect(parser.querySelector("a")?.querySelectorAll(".persona-stream-word").length).toBe(0);
214
+ // "see", "and" are the wrapped words
215
+ const wrapped = Array.from(parser.querySelectorAll(".persona-stream-word")).map(
216
+ (el) => el.textContent
217
+ );
218
+ expect(wrapped).toEqual(["see", "and"]);
219
+ });
220
+ });
221
+
222
+ describe("resolveStreamAnimation", () => {
223
+ it("returns all defaults when feature is undefined", () => {
224
+ const resolved = resolveStreamAnimation(undefined);
225
+ expect(resolved.type).toBe("none");
226
+ expect(resolved.placeholder).toBe("none");
227
+ expect(resolved.speed).toBe(120);
228
+ expect(resolved.duration).toBe(1800);
229
+ });
230
+
231
+ it("applies partial overrides", () => {
232
+ const resolved = resolveStreamAnimation({ type: "typewriter", speed: 50 });
233
+ expect(resolved.type).toBe("typewriter");
234
+ expect(resolved.speed).toBe(50);
235
+ expect(resolved.duration).toBe(1800);
236
+ expect(resolved.placeholder).toBe("none");
237
+ });
238
+ });
239
+
240
+ describe("streamAnimationContainerClass / streamAnimationBubbleClass", () => {
241
+ it("returns null for 'none'", () => {
242
+ expect(streamAnimationContainerClass("none")).toBeNull();
243
+ expect(streamAnimationBubbleClass("none")).toBeNull();
244
+ });
245
+
246
+ it("maps per-unit types to container classes", () => {
247
+ expect(streamAnimationContainerClass("typewriter")).toBe("persona-stream-typewriter");
248
+ expect(streamAnimationContainerClass("letter-rise")).toBe("persona-stream-letter-rise");
249
+ expect(streamAnimationContainerClass("word-fade")).toBe("persona-stream-word-fade");
250
+ expect(streamAnimationContainerClass("glyph-cycle")).toBe("persona-stream-glyph-cycle");
251
+ expect(streamAnimationContainerClass("wipe")).toBe("persona-stream-wipe");
252
+ });
253
+
254
+ it("puts pop-bubble on the bubble, not the content container", () => {
255
+ expect(streamAnimationContainerClass("pop-bubble")).toBeNull();
256
+ expect(streamAnimationBubbleClass("pop-bubble")).toBe("persona-stream-pop");
257
+ });
258
+ });
259
+
260
+ describe("isWrappingAnimation", () => {
261
+ it("is true for char and word modes", () => {
262
+ expect(isWrappingAnimation("typewriter")).toBe(true);
263
+ expect(isWrappingAnimation("letter-rise")).toBe(true);
264
+ expect(isWrappingAnimation("glyph-cycle")).toBe(true);
265
+ expect(isWrappingAnimation("word-fade")).toBe(true);
266
+ expect(isWrappingAnimation("wipe")).toBe(true);
267
+ });
268
+
269
+ it("is false for container-only modes and none", () => {
270
+ expect(isWrappingAnimation("none")).toBe(false);
271
+ expect(isWrappingAnimation("pop-bubble")).toBe(false);
272
+ });
273
+ });
274
+
275
+ describe("createSkeletonPlaceholder", () => {
276
+ it("renders a single full-width shimmer line", () => {
277
+ const el = createSkeletonPlaceholder();
278
+ expect(el.classList.contains("persona-stream-skeleton")).toBe(true);
279
+ expect(el.querySelectorAll(".persona-stream-skeleton-line").length).toBe(1);
280
+ expect(el.getAttribute("data-preserve-animation")).toBe("stream-skeleton");
281
+ });
282
+ });
283
+
284
+ describe("createStreamCaret", () => {
285
+ it("creates a span with data-preserve-animation so idiomorph keeps blink going", () => {
286
+ const caret = createStreamCaret();
287
+ expect(caret.tagName).toBe("SPAN");
288
+ expect(caret.classList.contains("persona-stream-caret")).toBe(true);
289
+ expect(caret.getAttribute("data-preserve-animation")).toBe("stream-caret");
290
+ expect(caret.getAttribute("aria-hidden")).toBe("true");
291
+ });
292
+ });
293
+
294
+ describe("plugin registry", () => {
295
+ it("resolves built-in types without requiring registration", () => {
296
+ expect(resolveStreamAnimationPlugin("typewriter")?.name).toBe("typewriter");
297
+ expect(resolveStreamAnimationPlugin("pop-bubble")?.name).toBe("pop-bubble");
298
+ });
299
+
300
+ it("returns null for 'none' and unknown types", () => {
301
+ expect(resolveStreamAnimationPlugin("none")).toBeNull();
302
+ expect(resolveStreamAnimationPlugin("totally-made-up")).toBeNull();
303
+ });
304
+
305
+ it("prefers per-instance overrides over the global registry", () => {
306
+ const custom: StreamAnimationPlugin = {
307
+ name: "typewriter",
308
+ containerClass: "custom-typewriter",
309
+ wrap: "char",
310
+ };
311
+ const plugin = resolveStreamAnimationPlugin("typewriter", { typewriter: custom });
312
+ expect(plugin?.containerClass).toBe("custom-typewriter");
313
+ });
314
+
315
+ it("registerStreamAnimationPlugin makes the plugin globally resolvable", () => {
316
+ const sparkle: StreamAnimationPlugin = {
317
+ name: "sparkle",
318
+ containerClass: "sparkle-fx",
319
+ wrap: "char",
320
+ };
321
+ registerStreamAnimationPlugin(sparkle);
322
+ expect(resolveStreamAnimationPlugin("sparkle")?.containerClass).toBe("sparkle-fx");
323
+ expect(listRegisteredStreamAnimations()).toContain("sparkle");
324
+ unregisterStreamAnimationPlugin("sparkle");
325
+ expect(resolveStreamAnimationPlugin("sparkle")).toBeNull();
326
+ });
327
+
328
+ it("unregisterStreamAnimationPlugin refuses to remove built-ins", () => {
329
+ unregisterStreamAnimationPlugin("typewriter");
330
+ expect(resolveStreamAnimationPlugin("typewriter")?.name).toBe("typewriter");
331
+ });
332
+ });
333
+
334
+ describe("applyStreamBuffer", () => {
335
+ const message = { id: "m1", role: "assistant", content: "" } as AgentWidgetMessage;
336
+
337
+ it("passes through when streaming is false", () => {
338
+ expect(applyStreamBuffer("abc", "word", null, message, false)).toBe("abc");
339
+ });
340
+
341
+ it("passes through when buffer is 'none'", () => {
342
+ expect(applyStreamBuffer("abc def", "none", null, message, true)).toBe("abc def");
343
+ });
344
+
345
+ it("word mode trims to the last whitespace boundary", () => {
346
+ expect(applyStreamBuffer("hello wor", "word", null, message, true)).toBe("hello");
347
+ expect(applyStreamBuffer("hello world ", "word", null, message, true)).toBe(
348
+ "hello world"
349
+ );
350
+ });
351
+
352
+ it("word mode hides all content until the first word boundary", () => {
353
+ expect(applyStreamBuffer("partial", "word", null, message, true)).toBe("");
354
+ });
355
+
356
+ it("line mode trims to the last newline", () => {
357
+ expect(applyStreamBuffer("line1\nmid", "line", null, message, true)).toBe("line1");
358
+ });
359
+
360
+ it("plugin.bufferContent takes precedence over the built-in strategy", () => {
361
+ const plugin: StreamAnimationPlugin = {
362
+ name: "capper",
363
+ bufferContent: (content) => content.slice(0, 3),
364
+ };
365
+ expect(applyStreamBuffer("hello world", "word", plugin, message, true)).toBe("hel");
366
+ });
367
+ });
368
+
369
+ describe("ensurePluginActive / detachAllPlugins", () => {
370
+ it("injects plugin styles once and runs onAttach cleanup on detach", () => {
371
+ const root = document.createElement("div");
372
+ document.body.appendChild(root);
373
+ let attached = 0;
374
+ let detached = 0;
375
+ const plugin: StreamAnimationPlugin = {
376
+ name: "test-attach",
377
+ styles: ".test-attach { color: red; }",
378
+ onAttach() {
379
+ attached += 1;
380
+ return () => {
381
+ detached += 1;
382
+ };
383
+ },
384
+ };
385
+ ensurePluginActive(plugin, root);
386
+ ensurePluginActive(plugin, root); // second call is a no-op
387
+
388
+ expect(attached).toBe(1);
389
+ expect(root.querySelectorAll("style[data-persona-animation='test-attach']").length).toBe(1);
390
+
391
+ detachAllPlugins(root);
392
+ expect(detached).toBe(1);
393
+
394
+ document.body.removeChild(root);
395
+ });
396
+
397
+ it("re-injects plugin styles after the root's children are cleared", () => {
398
+ const root = document.createElement("div");
399
+ document.body.appendChild(root);
400
+ const plugin: StreamAnimationPlugin = {
401
+ name: "test-reinject",
402
+ styles: ".test-reinject { color: red; }",
403
+ };
404
+
405
+ ensurePluginActive(plugin, root);
406
+ expect(root.querySelectorAll("style[data-persona-animation='test-reinject']").length).toBe(1);
407
+
408
+ // Simulate widget re-init: destroy callbacks run, then host is wiped.
409
+ detachAllPlugins(root);
410
+ root.innerHTML = "";
411
+
412
+ ensurePluginActive(plugin, root);
413
+ expect(root.querySelectorAll("style[data-persona-animation='test-reinject']").length).toBe(1);
414
+
415
+ document.body.removeChild(root);
416
+ });
417
+ });