@runtypelabs/persona 3.16.0 → 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 (58) 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 +48 -47
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +205 -1
  14. package/dist/index.d.ts +205 -1
  15. package/dist/index.global.js +136 -81
  16. package/dist/index.global.js.map +1 -1
  17. package/dist/index.js +48 -47
  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 +714 -99
  24. package/dist/theme-editor.d.cts +214 -2
  25. package/dist/theme-editor.d.ts +214 -2
  26. package/dist/theme-editor.js +712 -99
  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 +28 -0
  33. package/src/components/composer-builder.ts +61 -10
  34. package/src/components/message-bubble.test.ts +181 -2
  35. package/src/components/message-bubble.ts +209 -14
  36. package/src/components/panel.ts +4 -1
  37. package/src/defaults.ts +16 -0
  38. package/src/index-global.ts +31 -0
  39. package/src/index.ts +18 -0
  40. package/src/session.test.ts +93 -1
  41. package/src/session.ts +5 -0
  42. package/src/styles/widget.css +133 -0
  43. package/src/testing/index.ts +11 -0
  44. package/src/testing/mock-stream.test.ts +80 -0
  45. package/src/testing/mock-stream.ts +94 -0
  46. package/src/testing.ts +2 -0
  47. package/src/theme-editor/index.ts +4 -0
  48. package/src/theme-editor/preview-utils.test.ts +60 -0
  49. package/src/theme-editor/preview-utils.ts +129 -0
  50. package/src/theme-editor/sections.test.ts +19 -0
  51. package/src/theme-editor/sections.ts +84 -1
  52. package/src/types.ts +210 -0
  53. package/src/ui.stop-button.test.ts +165 -0
  54. package/src/ui.ts +75 -6
  55. package/src/utils/message-fingerprint.ts +2 -0
  56. package/src/utils/morph.ts +7 -0
  57. package/src/utils/stream-animation.test.ts +417 -0
  58. package/src/utils/stream-animation.ts +449 -0
@@ -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
+ });