@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,449 @@
1
+ import type {
2
+ AgentWidgetMessage,
3
+ AgentWidgetStreamAnimationBuffer,
4
+ AgentWidgetStreamAnimationBuiltinType,
5
+ AgentWidgetStreamAnimationFeature,
6
+ AgentWidgetStreamAnimationPlaceholder,
7
+ AgentWidgetStreamAnimationType,
8
+ StreamAnimationPlugin,
9
+ } from "../types";
10
+
11
+ export type ResolvedStreamAnimation = {
12
+ type: AgentWidgetStreamAnimationType;
13
+ placeholder: AgentWidgetStreamAnimationPlaceholder;
14
+ speed: number;
15
+ duration: number;
16
+ buffer: AgentWidgetStreamAnimationBuffer;
17
+ };
18
+
19
+ const DEFAULT_STREAM_ANIMATION: ResolvedStreamAnimation = {
20
+ type: "none",
21
+ placeholder: "none",
22
+ speed: 120,
23
+ duration: 1800,
24
+ buffer: "none",
25
+ };
26
+
27
+ /** Default tags whose text descendants are not wrapped. Plugins can override. */
28
+ const DEFAULT_SKIP_TAGS = ["pre", "code", "a", "script", "style"];
29
+
30
+ export const resolveStreamAnimation = (
31
+ feature: AgentWidgetStreamAnimationFeature | undefined
32
+ ): ResolvedStreamAnimation => ({
33
+ type: feature?.type ?? DEFAULT_STREAM_ANIMATION.type,
34
+ placeholder: feature?.placeholder ?? DEFAULT_STREAM_ANIMATION.placeholder,
35
+ speed: feature?.speed ?? DEFAULT_STREAM_ANIMATION.speed,
36
+ duration: feature?.duration ?? DEFAULT_STREAM_ANIMATION.duration,
37
+ buffer: feature?.buffer ?? DEFAULT_STREAM_ANIMATION.buffer,
38
+ });
39
+
40
+ /* ============================================================
41
+ Plugin registry
42
+ ============================================================ */
43
+
44
+ /**
45
+ * Built-in animations ship with the core widget — CSS lives in widget.css
46
+ * and no subpath import is required. They register automatically.
47
+ *
48
+ * Other animations (`letter-rise`, `word-fade`, `wipe`, `glyph-cycle`) are
49
+ * tree-shakeable subpath plugins — consumers import them from
50
+ * `@runtypelabs/persona/animations/<name>` and they auto-register on load.
51
+ */
52
+ const BUILTIN_PLUGINS: StreamAnimationPlugin[] = [
53
+ {
54
+ name: "typewriter",
55
+ containerClass: "persona-stream-typewriter",
56
+ wrap: "char",
57
+ useCaret: true,
58
+ },
59
+ {
60
+ name: "pop-bubble",
61
+ bubbleClass: "persona-stream-pop",
62
+ wrap: "none",
63
+ },
64
+ {
65
+ name: "letter-rise",
66
+ containerClass: "persona-stream-letter-rise",
67
+ wrap: "char",
68
+ },
69
+ {
70
+ name: "word-fade",
71
+ containerClass: "persona-stream-word-fade",
72
+ wrap: "word",
73
+ },
74
+ ];
75
+
76
+ /**
77
+ * Global registry populated by:
78
+ * - the core built-ins below (always available)
79
+ * - `registerStreamAnimationPlugin()` calls from subpath animation modules
80
+ * (invoked automatically when consumers `import` them)
81
+ * - IIFE bundle's bootstrap code (pre-registers all built-ins for script-tag
82
+ * consumers)
83
+ */
84
+ const globalRegistry = new Map<string, StreamAnimationPlugin>();
85
+ for (const plugin of BUILTIN_PLUGINS) globalRegistry.set(plugin.name, plugin);
86
+
87
+ /**
88
+ * Register a custom stream animation plugin globally. Subsequent widget
89
+ * instances can reference the plugin by `name` in `features.streamAnimation.type`.
90
+ * Per-widget plugin overrides via `features.streamAnimation.plugins` take
91
+ * precedence over the global registry.
92
+ */
93
+ export const registerStreamAnimationPlugin = (plugin: StreamAnimationPlugin): void => {
94
+ globalRegistry.set(plugin.name, plugin);
95
+ };
96
+
97
+ export const unregisterStreamAnimationPlugin = (name: string): void => {
98
+ // Built-ins are preserved; only external plugins can be unregistered.
99
+ if (BUILTIN_PLUGINS.some((p) => p.name === name)) return;
100
+ globalRegistry.delete(name);
101
+ };
102
+
103
+ export const listRegisteredStreamAnimations = (): string[] =>
104
+ Array.from(globalRegistry.keys());
105
+
106
+ /**
107
+ * Resolve the plugin for a given type. Per-instance overrides take precedence
108
+ * over the global registry. Returns null for `"none"` or unknown types.
109
+ */
110
+ export const resolveStreamAnimationPlugin = (
111
+ type: AgentWidgetStreamAnimationType,
112
+ overrides?: Record<string, StreamAnimationPlugin>
113
+ ): StreamAnimationPlugin | null => {
114
+ if (type === "none") return null;
115
+ if (overrides && Object.prototype.hasOwnProperty.call(overrides, type)) {
116
+ return overrides[type] ?? null;
117
+ }
118
+ return globalRegistry.get(type) ?? null;
119
+ };
120
+
121
+ /* ============================================================
122
+ Buffering
123
+ ============================================================ */
124
+
125
+ /**
126
+ * Apply content buffering to hide in-progress words or lines during streaming.
127
+ * Custom strategies via `plugin.bufferContent` take precedence over `buffer`.
128
+ */
129
+ export const applyStreamBuffer = (
130
+ content: string,
131
+ buffer: AgentWidgetStreamAnimationBuffer,
132
+ plugin: StreamAnimationPlugin | null,
133
+ message: AgentWidgetMessage,
134
+ streaming: boolean
135
+ ): string => {
136
+ if (!streaming) return content;
137
+ if (plugin?.bufferContent) return plugin.bufferContent(content, message);
138
+ if (!content) return content;
139
+ if (buffer === "word") {
140
+ const lastSpace = content.search(/\s(?=\S*$)/);
141
+ if (lastSpace < 0) return "";
142
+ return content.slice(0, lastSpace);
143
+ }
144
+ if (buffer === "line") {
145
+ const lastNewline = content.lastIndexOf("\n");
146
+ if (lastNewline < 0) return "";
147
+ return content.slice(0, lastNewline);
148
+ }
149
+ return content;
150
+ };
151
+
152
+ /* ============================================================
153
+ Wrapping
154
+ ============================================================ */
155
+
156
+ const makeCharSpan = (
157
+ doc: Document,
158
+ ch: string,
159
+ messageId: string,
160
+ index: number
161
+ ): HTMLElement => {
162
+ const span = doc.createElement("span");
163
+ span.className = "persona-stream-char";
164
+ span.id = `stream-c-${messageId}-${index}`;
165
+ span.style.setProperty("--char-index", String(index));
166
+ span.textContent = ch;
167
+ return span;
168
+ };
169
+
170
+ const makeWordSpan = (
171
+ doc: Document,
172
+ word: string,
173
+ messageId: string,
174
+ index: number
175
+ ): HTMLElement => {
176
+ const span = doc.createElement("span");
177
+ span.className = "persona-stream-word";
178
+ span.id = `stream-w-${messageId}-${index}`;
179
+ span.style.setProperty("--word-index", String(index));
180
+ span.textContent = word;
181
+ return span;
182
+ };
183
+
184
+ const WHITESPACE_RE = /\s/;
185
+
186
+ const shouldSkipSubtree = (node: Node, skipTags: Set<string>): boolean => {
187
+ let current: Node | null = node.parentNode;
188
+ while (current) {
189
+ if (current.nodeType === 1) {
190
+ const el = current as Element;
191
+ if (skipTags.has(el.tagName.toLowerCase())) return true;
192
+ }
193
+ current = current.parentNode;
194
+ }
195
+ return false;
196
+ };
197
+
198
+ const wrapTextNodeChars = (
199
+ textNode: Text,
200
+ messageId: string,
201
+ counterRef: { value: number }
202
+ ): void => {
203
+ const doc = textNode.ownerDocument;
204
+ const parent = textNode.parentNode;
205
+ if (!doc || !parent) return;
206
+ const text = textNode.nodeValue ?? "";
207
+ if (!text) return;
208
+ const fragment = doc.createDocumentFragment();
209
+ let i = 0;
210
+ while (i < text.length) {
211
+ if (WHITESPACE_RE.test(text[i])) {
212
+ // Keep whitespace as a plain text node so the browser preserves natural
213
+ // word-break opportunities between words. `display: inline-block` spans
214
+ // swallow single-space content, so wrapping whitespace would collapse
215
+ // the spaces and break line wrapping.
216
+ let j = i;
217
+ while (j < text.length && WHITESPACE_RE.test(text[j])) j += 1;
218
+ fragment.appendChild(doc.createTextNode(text.slice(i, j)));
219
+ i = j;
220
+ } else {
221
+ // Wrap each run of non-whitespace chars in a `white-space: nowrap`
222
+ // group so the browser doesn't break lines between individual char
223
+ // spans mid-word. Word boundaries (whitespace) stay as plain text
224
+ // nodes between groups, preserving natural line-break opportunities.
225
+ const group = doc.createElement("span");
226
+ group.className = "persona-stream-word-group";
227
+ let j = i;
228
+ while (j < text.length && !WHITESPACE_RE.test(text[j])) {
229
+ group.appendChild(makeCharSpan(doc, text[j], messageId, counterRef.value));
230
+ counterRef.value += 1;
231
+ j += 1;
232
+ }
233
+ fragment.appendChild(group);
234
+ i = j;
235
+ }
236
+ }
237
+ parent.replaceChild(fragment, textNode);
238
+ };
239
+
240
+ const wrapTextNodeWords = (
241
+ textNode: Text,
242
+ messageId: string,
243
+ counterRef: { value: number }
244
+ ): void => {
245
+ const doc = textNode.ownerDocument;
246
+ const parent = textNode.parentNode;
247
+ if (!doc || !parent) return;
248
+ const text = textNode.nodeValue ?? "";
249
+ if (!text) return;
250
+ const fragment = doc.createDocumentFragment();
251
+ const tokens = text.split(/(\s+)/);
252
+ for (const token of tokens) {
253
+ if (!token) continue;
254
+ if (/^\s+$/.test(token)) {
255
+ fragment.appendChild(doc.createTextNode(token));
256
+ } else {
257
+ fragment.appendChild(makeWordSpan(doc, token, messageId, counterRef.value));
258
+ counterRef.value += 1;
259
+ }
260
+ }
261
+ parent.replaceChild(fragment, textNode);
262
+ };
263
+
264
+ /**
265
+ * Wrap plain-text nodes in the sanitized markdown HTML with per-char or per-word
266
+ * spans suitable for staggered CSS animations. Skips descendants of `<pre>`,
267
+ * `<code>`, and `<a>` so code blocks stay legible and link click targets stay intact.
268
+ *
269
+ * Each wrapped span carries a stable `id` (`stream-c-{messageId}-{N}` or
270
+ * `stream-w-{messageId}-{N}`) so idiomorph preserves existing spans across
271
+ * token-by-token re-renders — animations on already-streamed characters never
272
+ * restart.
273
+ */
274
+ export const wrapStreamAnimation = (
275
+ html: string,
276
+ mode: "char" | "word",
277
+ messageId: string,
278
+ options?: { skipTags?: string[] }
279
+ ): string => {
280
+ if (!html) return html;
281
+ if (typeof document === "undefined") return html;
282
+
283
+ const scratch = document.createElement("div");
284
+ scratch.innerHTML = html;
285
+
286
+ const skipTags = new Set((options?.skipTags ?? DEFAULT_SKIP_TAGS).map((t) => t.toLowerCase()));
287
+ const walker = document.createTreeWalker(scratch, NodeFilter.SHOW_TEXT, null);
288
+ const textNodes: Text[] = [];
289
+ let node = walker.nextNode();
290
+ while (node) {
291
+ if (!shouldSkipSubtree(node, skipTags)) {
292
+ textNodes.push(node as Text);
293
+ }
294
+ node = walker.nextNode();
295
+ }
296
+
297
+ const counterRef = { value: 0 };
298
+ const wrap = mode === "char" ? wrapTextNodeChars : wrapTextNodeWords;
299
+ for (const textNode of textNodes) {
300
+ wrap(textNode, messageId, counterRef);
301
+ }
302
+
303
+ return scratch.innerHTML;
304
+ };
305
+
306
+ /* ============================================================
307
+ Supporting helpers
308
+ ============================================================ */
309
+
310
+ /**
311
+ * Build the caret element for `typewriter` mode. Carries
312
+ * `data-preserve-animation` so idiomorph keeps the blink running across
313
+ * token re-renders.
314
+ */
315
+ export const createStreamCaret = (doc: Document = document): HTMLElement => {
316
+ const caret = doc.createElement("span");
317
+ caret.className = "persona-stream-caret";
318
+ caret.setAttribute("aria-hidden", "true");
319
+ caret.setAttribute("data-preserve-animation", "stream-caret");
320
+ return caret;
321
+ };
322
+
323
+ /**
324
+ * Shimmer placeholder shown before the first token arrives — and, when the
325
+ * `"line"` buffer strategy is active, reshown between lines. A single
326
+ * full-width bar; we don't know ahead of time how wide the next line will be,
327
+ * so committing to one width avoids implying structure the stream won't match.
328
+ */
329
+ export const createSkeletonPlaceholder = (doc: Document = document): HTMLElement => {
330
+ const wrapper = doc.createElement("div");
331
+ wrapper.className = "persona-stream-skeleton";
332
+ wrapper.setAttribute("data-preserve-animation", "stream-skeleton");
333
+ wrapper.setAttribute("aria-hidden", "true");
334
+ const line = doc.createElement("div");
335
+ line.className = "persona-stream-skeleton-line";
336
+ wrapper.appendChild(line);
337
+ return wrapper;
338
+ };
339
+
340
+ /* ============================================================
341
+ Plugin style + attach lifecycle
342
+ ============================================================ */
343
+
344
+ /**
345
+ * Track which plugins have injected their CSS into a given root already.
346
+ * Prevents duplicate <style> tags across widget instances or re-renders.
347
+ */
348
+ const injectedStyleRoots = new WeakMap<HTMLElement | ShadowRoot, Set<string>>();
349
+
350
+ export const injectPluginStyles = (
351
+ plugin: StreamAnimationPlugin,
352
+ root: HTMLElement | ShadowRoot
353
+ ): void => {
354
+ if (!plugin.styles) return;
355
+ let names = injectedStyleRoots.get(root);
356
+ if (!names) {
357
+ names = new Set();
358
+ injectedStyleRoots.set(root, names);
359
+ }
360
+ if (names.has(plugin.name)) {
361
+ // The tracking Set says we injected this plugin's styles, but the actual
362
+ // <style> node may have been removed (e.g. host cleared via `innerHTML = ""`
363
+ // during widget re-init). Fall through and re-inject if the tag is gone.
364
+ const escaped = plugin.name.replace(/["\\]/g, "\\$&");
365
+ const existing = root.querySelector(
366
+ `style[data-persona-animation="${escaped}"]`
367
+ );
368
+ if (existing) return;
369
+ names.delete(plugin.name);
370
+ }
371
+ names.add(plugin.name);
372
+ const doc = root instanceof ShadowRoot ? root.ownerDocument : root.ownerDocument ?? document;
373
+ const style = doc.createElement("style");
374
+ style.setAttribute("data-persona-animation", plugin.name);
375
+ style.textContent = plugin.styles;
376
+ root.appendChild(style);
377
+ };
378
+
379
+ /**
380
+ * Attach detach-tracking state for plugins registered to a widget root.
381
+ */
382
+ const attachedCleanups = new WeakMap<
383
+ HTMLElement | ShadowRoot,
384
+ Map<string, (() => void) | void>
385
+ >();
386
+
387
+ export const attachPlugin = (
388
+ plugin: StreamAnimationPlugin,
389
+ root: HTMLElement | ShadowRoot
390
+ ): void => {
391
+ if (!plugin.onAttach) return;
392
+ let cleanups = attachedCleanups.get(root);
393
+ if (!cleanups) {
394
+ cleanups = new Map();
395
+ attachedCleanups.set(root, cleanups);
396
+ }
397
+ if (cleanups.has(plugin.name)) return;
398
+ const cleanup = plugin.onAttach(root);
399
+ cleanups.set(plugin.name, cleanup);
400
+ };
401
+
402
+ export const detachAllPlugins = (root: HTMLElement | ShadowRoot): void => {
403
+ const cleanups = attachedCleanups.get(root);
404
+ if (!cleanups) return;
405
+ for (const cleanup of cleanups.values()) {
406
+ if (typeof cleanup === "function") cleanup();
407
+ }
408
+ cleanups.clear();
409
+ };
410
+
411
+ /**
412
+ * Ensure the plugin's one-time side effects (style injection, onAttach) have
413
+ * run for this widget root. Idempotent — safe to call on every render.
414
+ */
415
+ export const ensurePluginActive = (
416
+ plugin: StreamAnimationPlugin,
417
+ root: HTMLElement | ShadowRoot
418
+ ): void => {
419
+ injectPluginStyles(plugin, root);
420
+ attachPlugin(plugin, root);
421
+ };
422
+
423
+ /* ============================================================
424
+ Back-compat helpers (used by existing tests)
425
+ ============================================================ */
426
+
427
+ export const isPerCharAnimation = (type: AgentWidgetStreamAnimationType): boolean => {
428
+ const plugin = resolveStreamAnimationPlugin(type);
429
+ return plugin?.wrap === "char";
430
+ };
431
+
432
+ export const isPerWordAnimation = (type: AgentWidgetStreamAnimationType): boolean => {
433
+ const plugin = resolveStreamAnimationPlugin(type);
434
+ return plugin?.wrap === "word";
435
+ };
436
+
437
+ export const isWrappingAnimation = (type: AgentWidgetStreamAnimationType): boolean =>
438
+ isPerCharAnimation(type) || isPerWordAnimation(type);
439
+
440
+ export const streamAnimationContainerClass = (
441
+ type: AgentWidgetStreamAnimationType
442
+ ): string | null => resolveStreamAnimationPlugin(type)?.containerClass ?? null;
443
+
444
+ export const streamAnimationBubbleClass = (
445
+ type: AgentWidgetStreamAnimationType
446
+ ): string | null => resolveStreamAnimationPlugin(type)?.bubbleClass ?? null;
447
+
448
+ // Re-export the builtin type literal so tests and consumers can reference it.
449
+ export type { AgentWidgetStreamAnimationBuiltinType };