@runtypelabs/persona 3.5.2 → 3.7.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 (53) hide show
  1. package/dist/index.cjs +46 -46
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +44 -0
  4. package/dist/index.d.ts +44 -0
  5. package/dist/index.global.js +70 -70
  6. package/dist/index.global.js.map +1 -1
  7. package/dist/index.js +46 -46
  8. package/dist/index.js.map +1 -1
  9. package/dist/theme-editor.cjs +18015 -0
  10. package/dist/theme-editor.d.cts +3888 -0
  11. package/dist/theme-editor.d.ts +3888 -0
  12. package/dist/theme-editor.js +17909 -0
  13. package/dist/theme-reference.cjs +1 -1
  14. package/dist/theme-reference.d.cts +33 -0
  15. package/dist/theme-reference.d.ts +33 -0
  16. package/dist/theme-reference.js +1 -1
  17. package/dist/widget.css +69 -25
  18. package/package.json +9 -7
  19. package/src/components/artifact-card.ts +1 -1
  20. package/src/components/composer-builder.ts +16 -29
  21. package/src/components/demo-carousel.ts +5 -5
  22. package/src/components/event-stream-view.test.ts +142 -0
  23. package/src/components/event-stream-view.ts +68 -29
  24. package/src/components/header-builder.ts +2 -2
  25. package/src/components/launcher.ts +9 -0
  26. package/src/components/message-bubble.ts +9 -3
  27. package/src/components/suggestions.ts +1 -1
  28. package/src/defaults.ts +24 -9
  29. package/src/scroll-to-bottom-defaults.test.ts +13 -0
  30. package/src/styles/widget.css +69 -25
  31. package/src/theme-editor/color-utils.ts +252 -0
  32. package/src/theme-editor/index.ts +131 -0
  33. package/src/theme-editor/presets.ts +144 -0
  34. package/src/theme-editor/preview-utils.ts +265 -0
  35. package/src/theme-editor/preview.ts +445 -0
  36. package/src/theme-editor/role-mappings.ts +343 -0
  37. package/src/theme-editor/sections.test.ts +43 -0
  38. package/src/theme-editor/sections.ts +994 -0
  39. package/src/theme-editor/state.ts +298 -0
  40. package/src/theme-editor/types.ts +177 -0
  41. package/src/theme-editor.ts +2 -0
  42. package/src/theme-reference.ts +8 -0
  43. package/src/types/theme.ts +11 -0
  44. package/src/types.ts +22 -0
  45. package/src/ui.scroll.test.ts +554 -0
  46. package/src/ui.ts +223 -133
  47. package/src/utils/auto-follow.test.ts +110 -0
  48. package/src/utils/auto-follow.ts +112 -0
  49. package/src/utils/plugins.ts +1 -1
  50. package/src/utils/theme.test.ts +44 -8
  51. package/src/utils/theme.ts +11 -11
  52. package/src/utils/tokens.ts +137 -41
  53. package/widget.css +0 -1
@@ -0,0 +1,554 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+
7
+ type RafCallback = (time: number) => void;
8
+
9
+ const STREAM_MESSAGE_ID = "ast-stream";
10
+ const STREAM_CREATED_AT = "2026-03-29T00:00:00.000Z";
11
+
12
+ const createMount = () => {
13
+ const mount = document.createElement("div");
14
+ document.body.appendChild(mount);
15
+ return mount;
16
+ };
17
+
18
+ const getScrollToBottomButton = (mount: HTMLElement) =>
19
+ mount.querySelector<HTMLElement>("[data-persona-scroll-to-bottom]");
20
+
21
+ const installRafMock = () => {
22
+ let nextId = 1;
23
+ let now = performance.now();
24
+ const callbacks = new Map<number, RafCallback>();
25
+
26
+ vi.stubGlobal("requestAnimationFrame", (callback: RafCallback) => {
27
+ const id = nextId++;
28
+ callbacks.set(id, callback);
29
+ return id;
30
+ });
31
+
32
+ vi.stubGlobal("cancelAnimationFrame", (id: number) => {
33
+ callbacks.delete(id);
34
+ });
35
+
36
+ return {
37
+ flush(maxFrames = 80) {
38
+ let frames = 0;
39
+ while (callbacks.size > 0 && frames < maxFrames) {
40
+ const pending = [...callbacks.entries()];
41
+ callbacks.clear();
42
+ frames += 1;
43
+ now += 16;
44
+ pending.forEach(([, callback]) => callback(now));
45
+ }
46
+
47
+ if (callbacks.size > 0) {
48
+ throw new Error("requestAnimationFrame queue did not settle");
49
+ }
50
+ }
51
+ };
52
+ };
53
+
54
+ const installScrollMetrics = (
55
+ element: HTMLElement,
56
+ initial: { scrollHeight: number; clientHeight: number }
57
+ ) => {
58
+ let scrollTop = 0;
59
+ let scrollHeight = initial.scrollHeight;
60
+ const clientHeight = initial.clientHeight;
61
+
62
+ Object.defineProperties(element, {
63
+ scrollTop: {
64
+ configurable: true,
65
+ get: () => scrollTop,
66
+ set: (value: number) => {
67
+ const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
68
+ scrollTop = Math.max(0, Math.min(value, maxScrollTop));
69
+ }
70
+ },
71
+ scrollHeight: {
72
+ configurable: true,
73
+ get: () => scrollHeight
74
+ },
75
+ clientHeight: {
76
+ configurable: true,
77
+ get: () => clientHeight
78
+ }
79
+ });
80
+
81
+ return {
82
+ getScrollTop: () => scrollTop,
83
+ getBottomScrollTop: () => Math.max(0, scrollHeight - clientHeight),
84
+ setScrollTop: (value: number) => {
85
+ element.scrollTop = value;
86
+ },
87
+ setScrollHeight: (value: number) => {
88
+ scrollHeight = value;
89
+ if (scrollTop > scrollHeight - clientHeight) {
90
+ scrollTop = Math.max(0, scrollHeight - clientHeight);
91
+ }
92
+ }
93
+ };
94
+ };
95
+
96
+ const emitStreamingStatus = (controller: ReturnType<typeof createAgentExperience>) => {
97
+ controller.injectTestMessage({ type: "status", status: "connecting" });
98
+ };
99
+
100
+ const emitStreamingMessage = (
101
+ controller: ReturnType<typeof createAgentExperience>,
102
+ content: string
103
+ ) => {
104
+ controller.injectTestMessage({
105
+ type: "message",
106
+ message: {
107
+ id: STREAM_MESSAGE_ID,
108
+ role: "assistant",
109
+ content,
110
+ createdAt: STREAM_CREATED_AT,
111
+ streaming: true
112
+ }
113
+ });
114
+ };
115
+
116
+ const emitReasoningMessage = (
117
+ controller: ReturnType<typeof createAgentExperience>,
118
+ chunks: string[]
119
+ ) => {
120
+ controller.injectTestMessage({
121
+ type: "message",
122
+ message: {
123
+ id: STREAM_MESSAGE_ID,
124
+ role: "assistant",
125
+ content: "",
126
+ createdAt: STREAM_CREATED_AT,
127
+ streaming: true,
128
+ variant: "reasoning",
129
+ reasoning: {
130
+ id: "reason-1",
131
+ status: "streaming",
132
+ chunks
133
+ }
134
+ }
135
+ });
136
+ };
137
+
138
+ const createCustomComposer = () => {
139
+ const footer = document.createElement("div");
140
+ footer.className = "persona-widget-footer";
141
+
142
+ const form = document.createElement("form");
143
+ form.setAttribute("data-persona-composer-form", "");
144
+
145
+ const textarea = document.createElement("textarea");
146
+ textarea.setAttribute("data-persona-composer-input", "");
147
+
148
+ const status = document.createElement("div");
149
+ status.setAttribute("data-persona-composer-status", "");
150
+
151
+ form.appendChild(textarea);
152
+ footer.append(form, status);
153
+ return footer;
154
+ };
155
+
156
+ describe("createAgentExperience streaming scroll", () => {
157
+ beforeEach(() => {
158
+ installRafMock();
159
+ });
160
+
161
+ afterEach(() => {
162
+ document.body.innerHTML = "";
163
+ vi.restoreAllMocks();
164
+ });
165
+
166
+ it("stops auto-follow after a small upward scroll during streaming", () => {
167
+ const raf = installRafMock();
168
+ const mount = createMount();
169
+ const controller = createAgentExperience(mount, {
170
+ apiUrl: "https://api.example.com/chat",
171
+ launcher: { enabled: false }
172
+ });
173
+
174
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
175
+ expect(scrollContainer).not.toBeNull();
176
+
177
+ const metrics = installScrollMetrics(scrollContainer!, {
178
+ scrollHeight: 1000,
179
+ clientHeight: 400
180
+ });
181
+
182
+ emitStreamingStatus(controller);
183
+ emitStreamingMessage(controller, "First chunk");
184
+ raf.flush();
185
+
186
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
187
+
188
+ metrics.setScrollTop(metrics.getBottomScrollTop() - 3);
189
+ scrollContainer!.dispatchEvent(new Event("scroll"));
190
+
191
+ metrics.setScrollHeight(1040);
192
+ emitStreamingMessage(controller, "Second chunk");
193
+ raf.flush();
194
+
195
+ expect(metrics.getScrollTop()).toBe(597);
196
+
197
+ controller.destroy();
198
+ });
199
+
200
+ it("pauses auto-follow on upward wheel intent before the next streamed update", () => {
201
+ const raf = installRafMock();
202
+ const mount = createMount();
203
+ const controller = createAgentExperience(mount, {
204
+ apiUrl: "https://api.example.com/chat",
205
+ launcher: { enabled: false }
206
+ });
207
+
208
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
209
+ expect(scrollContainer).not.toBeNull();
210
+
211
+ const metrics = installScrollMetrics(scrollContainer!, {
212
+ scrollHeight: 1000,
213
+ clientHeight: 400
214
+ });
215
+
216
+ emitStreamingStatus(controller);
217
+ emitStreamingMessage(controller, "First chunk");
218
+ raf.flush();
219
+
220
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -24 }));
221
+ metrics.setScrollTop(580);
222
+ metrics.setScrollHeight(1060);
223
+
224
+ emitStreamingMessage(controller, "Second chunk");
225
+ raf.flush();
226
+
227
+ expect(metrics.getScrollTop()).toBe(580);
228
+
229
+ controller.destroy();
230
+ });
231
+
232
+ it("resumes auto-follow when the user scrolls back to the bottom", () => {
233
+ const raf = installRafMock();
234
+ const mount = createMount();
235
+ const controller = createAgentExperience(mount, {
236
+ apiUrl: "https://api.example.com/chat",
237
+ launcher: { enabled: false }
238
+ });
239
+
240
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
241
+ expect(scrollContainer).not.toBeNull();
242
+
243
+ const metrics = installScrollMetrics(scrollContainer!, {
244
+ scrollHeight: 1000,
245
+ clientHeight: 400
246
+ });
247
+
248
+ emitStreamingStatus(controller);
249
+ emitStreamingMessage(controller, "First chunk");
250
+ raf.flush();
251
+
252
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -24 }));
253
+ metrics.setScrollTop(560);
254
+ metrics.setScrollHeight(1060);
255
+ emitStreamingMessage(controller, "Second chunk");
256
+ raf.flush();
257
+
258
+ expect(metrics.getScrollTop()).toBe(560);
259
+
260
+ metrics.setScrollTop(metrics.getBottomScrollTop() - 2);
261
+ scrollContainer!.dispatchEvent(new Event("scroll"));
262
+
263
+ metrics.setScrollHeight(1100);
264
+ emitStreamingMessage(controller, "Third chunk");
265
+ raf.flush();
266
+
267
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
268
+
269
+ controller.destroy();
270
+ });
271
+
272
+ it("does not immediately resume after an upward scroll while still near the bottom", () => {
273
+ const raf = installRafMock();
274
+ const mount = createMount();
275
+ const controller = createAgentExperience(mount, {
276
+ apiUrl: "https://api.example.com/chat",
277
+ launcher: { enabled: false }
278
+ });
279
+
280
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
281
+ expect(scrollContainer).not.toBeNull();
282
+
283
+ const metrics = installScrollMetrics(scrollContainer!, {
284
+ scrollHeight: 1000,
285
+ clientHeight: 400
286
+ });
287
+
288
+ emitStreamingStatus(controller);
289
+ emitStreamingMessage(controller, "First chunk");
290
+ raf.flush();
291
+
292
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -24 }));
293
+ metrics.setScrollTop(metrics.getBottomScrollTop() - 3);
294
+ scrollContainer!.dispatchEvent(new Event("scroll"));
295
+
296
+ metrics.setScrollHeight(1040);
297
+ emitStreamingMessage(controller, "Second chunk");
298
+ raf.flush();
299
+
300
+ expect(metrics.getScrollTop()).toBe(597);
301
+ expect(getScrollToBottomButton(mount)?.style.display).not.toBe("none");
302
+
303
+ controller.destroy();
304
+ });
305
+
306
+ it("keeps following the stream when the user does not scroll", () => {
307
+ const raf = installRafMock();
308
+ const mount = createMount();
309
+ const controller = createAgentExperience(mount, {
310
+ apiUrl: "https://api.example.com/chat",
311
+ launcher: { enabled: false }
312
+ });
313
+
314
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
315
+ expect(scrollContainer).not.toBeNull();
316
+
317
+ const metrics = installScrollMetrics(scrollContainer!, {
318
+ scrollHeight: 900,
319
+ clientHeight: 400
320
+ });
321
+
322
+ emitStreamingStatus(controller);
323
+ emitStreamingMessage(controller, "Chunk one");
324
+ raf.flush();
325
+
326
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
327
+
328
+ metrics.setScrollHeight(980);
329
+ emitStreamingMessage(controller, "Chunk two");
330
+ raf.flush();
331
+
332
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
333
+
334
+ controller.destroy();
335
+ });
336
+
337
+ it("lets the user break away during reasoning streaming", () => {
338
+ const raf = installRafMock();
339
+ const mount = createMount();
340
+ const controller = createAgentExperience(mount, {
341
+ apiUrl: "https://api.example.com/chat",
342
+ launcher: { enabled: false }
343
+ });
344
+
345
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
346
+ expect(scrollContainer).not.toBeNull();
347
+
348
+ const metrics = installScrollMetrics(scrollContainer!, {
349
+ scrollHeight: 960,
350
+ clientHeight: 400
351
+ });
352
+
353
+ emitStreamingStatus(controller);
354
+ emitReasoningMessage(controller, ["Thinking"]);
355
+ raf.flush();
356
+
357
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
358
+
359
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -18 }));
360
+ metrics.setScrollTop(metrics.getBottomScrollTop() - 4);
361
+ metrics.setScrollHeight(1010);
362
+
363
+ emitReasoningMessage(controller, ["Thinking", " harder"]);
364
+ raf.flush();
365
+
366
+ expect(metrics.getScrollTop()).toBe(556);
367
+
368
+ controller.destroy();
369
+ });
370
+
371
+ it("uses icon-only arrow-down defaults for the transcript affordance", () => {
372
+ const raf = installRafMock();
373
+ const mount = createMount();
374
+ const controller = createAgentExperience(mount, {
375
+ apiUrl: "https://api.example.com/chat",
376
+ launcher: { enabled: false }
377
+ });
378
+
379
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
380
+ expect(scrollContainer).not.toBeNull();
381
+
382
+ const metrics = installScrollMetrics(scrollContainer!, {
383
+ scrollHeight: 1000,
384
+ clientHeight: 400
385
+ });
386
+
387
+ emitStreamingStatus(controller);
388
+ emitStreamingMessage(controller, "First chunk");
389
+ raf.flush();
390
+
391
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -18 }));
392
+ metrics.setScrollTop(560);
393
+ metrics.setScrollHeight(1060);
394
+ emitStreamingMessage(controller, "Second chunk");
395
+ raf.flush();
396
+
397
+ const button = getScrollToBottomButton(mount);
398
+ expect(button).not.toBeNull();
399
+ expect(button?.textContent?.trim()).toBe("");
400
+ expect(button?.querySelector("svg")).not.toBeNull();
401
+
402
+ controller.destroy();
403
+ });
404
+
405
+ it("anchors the transcript affordance outside the scroll container", () => {
406
+ const raf = installRafMock();
407
+ const mount = createMount();
408
+ const controller = createAgentExperience(mount, {
409
+ apiUrl: "https://api.example.com/chat",
410
+ launcher: { enabled: false }
411
+ });
412
+
413
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
414
+ expect(scrollContainer).not.toBeNull();
415
+
416
+ const metrics = installScrollMetrics(scrollContainer!, {
417
+ scrollHeight: 1000,
418
+ clientHeight: 400
419
+ });
420
+
421
+ emitStreamingStatus(controller);
422
+ emitStreamingMessage(controller, "First chunk");
423
+ raf.flush();
424
+
425
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -18 }));
426
+ metrics.setScrollTop(560);
427
+ metrics.setScrollHeight(1060);
428
+ emitStreamingMessage(controller, "Second chunk");
429
+ raf.flush();
430
+
431
+ const button = getScrollToBottomButton(mount);
432
+ expect(button).not.toBeNull();
433
+ expect(button?.parentElement).not.toBe(scrollContainer);
434
+
435
+ controller.destroy();
436
+ });
437
+
438
+ it("keeps the transcript affordance outside the scroll container with a custom composer", () => {
439
+ const raf = installRafMock();
440
+ const mount = createMount();
441
+ const controller = createAgentExperience(mount, {
442
+ apiUrl: "https://api.example.com/chat",
443
+ launcher: { enabled: false },
444
+ plugins: [
445
+ {
446
+ id: "custom-composer",
447
+ renderComposer: () => createCustomComposer()
448
+ }
449
+ ]
450
+ } as any);
451
+
452
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
453
+ expect(scrollContainer).not.toBeNull();
454
+
455
+ const metrics = installScrollMetrics(scrollContainer!, {
456
+ scrollHeight: 1000,
457
+ clientHeight: 400
458
+ });
459
+
460
+ emitStreamingStatus(controller);
461
+ emitStreamingMessage(controller, "First chunk");
462
+ raf.flush();
463
+
464
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -18 }));
465
+ metrics.setScrollTop(560);
466
+ metrics.setScrollHeight(1060);
467
+ emitStreamingMessage(controller, "Second chunk");
468
+ raf.flush();
469
+
470
+ const button = getScrollToBottomButton(mount);
471
+ expect(button).not.toBeNull();
472
+ expect(button?.parentElement).not.toBe(scrollContainer);
473
+
474
+ controller.destroy();
475
+ });
476
+
477
+ it("hides the transcript affordance when scroll-to-bottom is disabled", () => {
478
+ const raf = installRafMock();
479
+ const mount = createMount();
480
+ const controller = createAgentExperience(mount, {
481
+ apiUrl: "https://api.example.com/chat",
482
+ launcher: { enabled: false },
483
+ features: {
484
+ scrollToBottom: {
485
+ enabled: false
486
+ }
487
+ }
488
+ } as any);
489
+
490
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
491
+ expect(scrollContainer).not.toBeNull();
492
+
493
+ const metrics = installScrollMetrics(scrollContainer!, {
494
+ scrollHeight: 1000,
495
+ clientHeight: 400
496
+ });
497
+
498
+ emitStreamingStatus(controller);
499
+ emitStreamingMessage(controller, "First chunk");
500
+ raf.flush();
501
+
502
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -18 }));
503
+ metrics.setScrollTop(560);
504
+ metrics.setScrollHeight(1060);
505
+ emitStreamingMessage(controller, "Second chunk");
506
+ raf.flush();
507
+
508
+ expect(getScrollToBottomButton(mount)).toBeNull();
509
+
510
+ controller.destroy();
511
+ });
512
+
513
+ it("renders the transcript affordance as icon-only when label is empty", () => {
514
+ const raf = installRafMock();
515
+ const mount = createMount();
516
+ const controller = createAgentExperience(mount, {
517
+ apiUrl: "https://api.example.com/chat",
518
+ launcher: { enabled: false },
519
+ features: {
520
+ scrollToBottom: {
521
+ enabled: true,
522
+ iconName: "arrow-down",
523
+ label: ""
524
+ }
525
+ }
526
+ } as any);
527
+
528
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
529
+ expect(scrollContainer).not.toBeNull();
530
+
531
+ const metrics = installScrollMetrics(scrollContainer!, {
532
+ scrollHeight: 1000,
533
+ clientHeight: 400
534
+ });
535
+
536
+ emitStreamingStatus(controller);
537
+ emitStreamingMessage(controller, "First chunk");
538
+ raf.flush();
539
+
540
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -18 }));
541
+ metrics.setScrollTop(560);
542
+ metrics.setScrollHeight(1060);
543
+ emitStreamingMessage(controller, "Second chunk");
544
+ raf.flush();
545
+
546
+ const button = getScrollToBottomButton(mount);
547
+ expect(button).not.toBeNull();
548
+ expect(button?.textContent?.trim()).toBe("");
549
+ expect(button?.querySelector("svg")).not.toBeNull();
550
+
551
+ controller.destroy();
552
+ });
553
+
554
+ });