@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/dist/widget.css CHANGED
@@ -2824,3 +2824,136 @@
2824
2824
  transform: translateX(0);
2825
2825
  }
2826
2826
  }
2827
+
2828
+ /* ============================================================
2829
+ Stream animations — reveal effects for assistant message text
2830
+ while streaming. Opt-in via `features.streamAnimation.type`.
2831
+ Units are staggered via `--char-index` / `--word-index`
2832
+ (set inline on each wrapper span). Timing is configured via
2833
+ `--persona-stream-step` and `--persona-stream-duration` on
2834
+ the `.persona-message-content` container.
2835
+ ============================================================ */
2836
+
2837
+ /* Per-char/per-word spans need to be inline-block so `transform` works for
2838
+ the rise/fade animations. Each span animates from the moment it is first
2839
+ added to the DOM — streaming itself provides the visible stagger, so the
2840
+ CSS animation has no per-index delay. An index-based delay would compound
2841
+ with the stream's arrival cadence and leave later chars permanently hidden. */
2842
+ [data-persona-root] .persona-stream-char,
2843
+ [data-persona-root] .persona-stream-word {
2844
+ display: inline-block;
2845
+ will-change: opacity, transform, filter;
2846
+ }
2847
+
2848
+ /* Group chars belonging to the same word so the browser treats the word as a
2849
+ single break unit. Without this, every inline-block char introduces a break
2850
+ opportunity and words get split mid-letter during streaming, then snap back
2851
+ when the final content replaces the wrapped spans. */
2852
+ [data-persona-root] .persona-stream-word-group {
2853
+ white-space: nowrap;
2854
+ }
2855
+
2856
+ /* ---------- typewriter: fade per arriving char + blinking caret ---------- */
2857
+ @keyframes persona-stream-typewriter-in {
2858
+ from { opacity: 0; }
2859
+ to { opacity: 1; }
2860
+ }
2861
+ [data-persona-root] .persona-stream-typewriter .persona-stream-char {
2862
+ animation: persona-stream-typewriter-in var(--persona-stream-step, 120ms) ease-out both;
2863
+ }
2864
+
2865
+ /* ---------- letter-rise: per-char translateY + fade ---------- */
2866
+ @keyframes persona-stream-letter-rise {
2867
+ from { opacity: 0; transform: translateY(8px); }
2868
+ to { opacity: 1; transform: translateY(0); }
2869
+ }
2870
+ [data-persona-root] .persona-stream-letter-rise .persona-stream-char {
2871
+ animation: persona-stream-letter-rise calc(var(--persona-stream-step, 120ms) * 2)
2872
+ ease-out both;
2873
+ }
2874
+
2875
+ /* ---------- word-fade: per-word blur + translateY fade-in ---------- */
2876
+ @keyframes persona-stream-word-fade {
2877
+ from { opacity: 0; filter: blur(4px); transform: translateY(3px); }
2878
+ to { opacity: 1; filter: blur(0); transform: translateY(0); }
2879
+ }
2880
+ [data-persona-root] .persona-stream-word-fade .persona-stream-word {
2881
+ animation: persona-stream-word-fade calc(var(--persona-stream-step, 120ms) * 3)
2882
+ ease-out both;
2883
+ }
2884
+
2885
+ /* The following animations live in subpath plugin modules — their CSS is
2886
+ injected by the plugin when activated, not by the core stylesheet:
2887
+ - `wipe` → @runtypelabs/persona/animations/wipe
2888
+ - `glyph-cycle` → @runtypelabs/persona/animations/glyph-cycle */
2889
+
2890
+ /* ---------- pop-bubble: scale + opacity entrance on the bubble ---------- */
2891
+ @keyframes persona-stream-pop-in {
2892
+ 0% { transform: scale(0.6); opacity: 0; }
2893
+ 100% { transform: scale(1); opacity: 1; }
2894
+ }
2895
+ [data-persona-root] .persona-stream-pop {
2896
+ transform-origin: bottom left;
2897
+ animation: persona-stream-pop-in 400ms cubic-bezier(0.2, 0.9, 0.3, 1.4) both;
2898
+ }
2899
+
2900
+ /* ---------- caret used by typewriter ---------- */
2901
+ @keyframes persona-stream-blink {
2902
+ 0%, 50% { opacity: 1; }
2903
+ 50.01%, 100% { opacity: 0; }
2904
+ }
2905
+ [data-persona-root] .persona-stream-caret {
2906
+ display: inline-block;
2907
+ width: 2px;
2908
+ height: 1em;
2909
+ margin-left: 1px;
2910
+ vertical-align: -2px;
2911
+ background: currentColor;
2912
+ animation: persona-stream-blink 1s steps(1) infinite;
2913
+ }
2914
+
2915
+ /* ---------- skeleton placeholder (pre-first-token) ---------- */
2916
+ @keyframes persona-stream-skeleton-shimmer {
2917
+ 0% { background-position: 200% 0; }
2918
+ 100% { background-position: -200% 0; }
2919
+ }
2920
+ [data-persona-root] .persona-stream-skeleton {
2921
+ padding: 2px 0;
2922
+ /* The assistant bubble sizes to content. Give the skeleton an intrinsic
2923
+ width so the bubble expands; the bubble's own `max-width: 85%` clamps
2924
+ the upper bound. */
2925
+ width: 260px;
2926
+ max-width: 100%;
2927
+ }
2928
+ [data-persona-root] .persona-stream-skeleton-line {
2929
+ width: 100%;
2930
+ height: 10px;
2931
+ border-radius: 3px;
2932
+ background: linear-gradient(
2933
+ 90deg,
2934
+ color-mix(in srgb, currentColor 12%, transparent) 0%,
2935
+ color-mix(in srgb, currentColor 22%, transparent) 50%,
2936
+ color-mix(in srgb, currentColor 12%, transparent) 100%
2937
+ );
2938
+ background-size: 200% 100%;
2939
+ animation: persona-stream-skeleton-shimmer 1.4s linear infinite;
2940
+ }
2941
+
2942
+ /* ---------- reduced-motion: disable per-unit and container animations ---------- */
2943
+ @media (prefers-reduced-motion: reduce) {
2944
+ [data-persona-root] .persona-stream-typewriter .persona-stream-char,
2945
+ [data-persona-root] .persona-stream-letter-rise .persona-stream-char,
2946
+ [data-persona-root] .persona-stream-word-fade .persona-stream-word,
2947
+ [data-persona-root] .persona-stream-pop,
2948
+ [data-persona-root] .persona-stream-caret,
2949
+ [data-persona-root] .persona-stream-skeleton-line {
2950
+ animation: none !important;
2951
+ opacity: 1 !important;
2952
+ filter: none !important;
2953
+ transform: none !important;
2954
+ color: inherit !important;
2955
+ background: none !important;
2956
+ -webkit-background-clip: border-box !important;
2957
+ background-clip: border-box !important;
2958
+ }
2959
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "3.15.1",
3
+ "version": "3.17.0",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -22,6 +22,21 @@
22
22
  "import": "./dist/theme-editor.js",
23
23
  "require": "./dist/theme-editor.cjs"
24
24
  },
25
+ "./testing": {
26
+ "types": "./dist/testing.d.ts",
27
+ "import": "./dist/testing.js",
28
+ "require": "./dist/testing.cjs"
29
+ },
30
+ "./animations/glyph-cycle": {
31
+ "types": "./dist/animations/glyph-cycle.d.ts",
32
+ "import": "./dist/animations/glyph-cycle.js",
33
+ "require": "./dist/animations/glyph-cycle.cjs"
34
+ },
35
+ "./animations/wipe": {
36
+ "types": "./dist/animations/wipe.d.ts",
37
+ "import": "./dist/animations/wipe.js",
38
+ "require": "./dist/animations/wipe.cjs"
39
+ },
25
40
  "./widget.css": "./dist/widget.css"
26
41
  },
27
42
  "files": [
@@ -76,11 +91,13 @@
76
91
  "access": "public"
77
92
  },
78
93
  "scripts": {
79
- "build": "rimraf dist && pnpm run build:styles && pnpm run build:client && pnpm run build:installer && pnpm run build:theme-ref && pnpm run build:theme-editor",
94
+ "build": "rimraf dist && pnpm run build:styles && pnpm run build:client && pnpm run build:installer && pnpm run build:theme-ref && pnpm run build:theme-editor && pnpm run build:testing && pnpm run build:animations",
80
95
  "build:theme-editor": "tsup src/theme-editor.ts --format esm,cjs --dts --out-dir dist --no-splitting",
96
+ "build:testing": "tsup src/testing.ts --format esm,cjs --dts --out-dir dist --no-splitting",
97
+ "build:animations": "tsup src/animations/glyph-cycle.ts src/animations/wipe.ts --format esm,cjs --dts --out-dir dist/animations --no-splitting",
81
98
  "build:theme-ref": "tsup src/theme-reference.ts --format esm,cjs --minify --dts",
82
99
  "build:styles": "node -e \"const fs=require('fs');fs.mkdirSync('dist',{recursive:true});fs.copyFileSync('src/styles/widget.css','dist/widget.css');\"",
83
- "build:client": "tsup src/index.ts --format esm,cjs,iife --global-name AgentWidget --minify --sourcemap --splitting false --dts --loader \".css=text\"",
100
+ "build:client": "tsup src/index.ts --format esm,cjs --minify --sourcemap --splitting false --dts --loader \".css=text\" && tsup src/index-global.ts --format iife --global-name AgentWidget --minify --sourcemap --splitting false --out-dir dist --loader \".css=text\" && node -e \"const fs=require('fs');for(const ext of ['.global.js','.global.js.map']){const from='dist/index-global'+ext;if(fs.existsSync(from))fs.renameSync(from,'dist/index'+ext);}\"",
84
101
  "build:installer": "tsup src/install.ts --format iife --global-name SiteAgentInstaller --out-dir dist --minify --sourcemap --no-splitting",
85
102
  "lint": "eslint . --ext .ts",
86
103
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,332 @@
1
+ import type { StreamAnimationPlugin } from "../types";
2
+ import { registerStreamAnimationPlugin } from "../utils/stream-animation";
3
+
4
+ /**
5
+ * Glyph-cycle animation — "hacker-settling" reveal.
6
+ *
7
+ * Each arriving char briefly cycles through a sequence of random glyphs
8
+ * before locking in to its final character. Unlike a pure CSS effect, this
9
+ * needs JS because it mutates `textContent` per tick. To keep idiomorph from
10
+ * clobbering mid-cycle work, each cycling span carries
11
+ * `data-preserve-runtime="stream-glyph-cycle"` until its cycle completes —
12
+ * morph honors that attribute as an absolute skip marker.
13
+ *
14
+ * A short buffer (`BUFFER_THRESHOLD`) holds the bubble empty (with the
15
+ * typing indicator) until enough text has arrived to run a visible reveal.
16
+ * Once released, every char starts as a random glyph and settles in order
17
+ * from the start, staggered by one `--persona-stream-step` between chars.
18
+ * Live tokens arriving after release queue behind the stagger slot, so the
19
+ * settling wave flows through the full message.
20
+ *
21
+ * Ships as a subpath module so consumers who don't want it pay zero cost.
22
+ * Importing this module auto-registers the plugin globally — just add the
23
+ * import and set `features.streamAnimation.type = "glyph-cycle"`.
24
+ *
25
+ * ```ts
26
+ * import "@runtypelabs/persona/animations/glyph-cycle";
27
+ * createAgentExperience(el, {
28
+ * features: { streamAnimation: { type: "glyph-cycle" } },
29
+ * });
30
+ * ```
31
+ */
32
+
33
+ const STYLES = `
34
+ [data-persona-root] .persona-stream-glyph-cycle .persona-stream-char {
35
+ animation: persona-stream-glyph-cycle-fade
36
+ calc(var(--persona-stream-step, 120ms) * 1.5) ease-out both;
37
+ }
38
+ [data-persona-root] .persona-stream-glyph-cycle .persona-stream-char[data-glyph-cycle-final] {
39
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
40
+ }
41
+ @keyframes persona-stream-glyph-cycle-fade {
42
+ from { opacity: 0.35; }
43
+ to { opacity: 1; }
44
+ }
45
+ @media (prefers-reduced-motion: reduce) {
46
+ [data-persona-root] .persona-stream-glyph-cycle .persona-stream-char {
47
+ animation: none !important;
48
+ opacity: 1 !important;
49
+ }
50
+ }
51
+ `.trim();
52
+
53
+ // Matches the source design: alphanumerics + a small set of symbols.
54
+ const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&@";
55
+ const TICK_COUNT = 10;
56
+ // Base interval between glyph swaps (ms). Scaled by the widget's configured
57
+ // `speed` so the cycle feels coherent with the rest of the stream animation —
58
+ // faster speed → snappier cycling, slower speed → more deliberate flicker.
59
+ const BASE_TICK_MS = 120;
60
+ const DEFAULT_STEP_MS = 120;
61
+ // Probability that each later still-cycling sibling gets re-randomized on
62
+ // every tick. Matches the source prototype's cross-glyph jitter feel.
63
+ const CROSS_FLICKER_PROBABILITY = 0.4;
64
+ // Hold the stream back until at least this many chars have arrived. Once
65
+ // released, every char is rendered as a random glyph and settles in order
66
+ // from the start — so the visible animation carries through the full text.
67
+ const BUFFER_THRESHOLD = 50;
68
+
69
+ const getStepMs = (container: HTMLElement | null): number => {
70
+ if (!container) return DEFAULT_STEP_MS;
71
+ const raw = container.style.getPropertyValue("--persona-stream-step")?.trim();
72
+ const match = raw.match(/([\d.]+)\s*ms/);
73
+ return match ? parseFloat(match[1]) : DEFAULT_STEP_MS;
74
+ };
75
+
76
+ const getTickMs = (span: HTMLElement): number => {
77
+ const container = span.closest(".persona-stream-glyph-cycle") as HTMLElement | null;
78
+ const step = getStepMs(container);
79
+ return (BASE_TICK_MS * step) / DEFAULT_STEP_MS;
80
+ };
81
+
82
+ const randomGlyph = (avoid?: string): string => {
83
+ let ch = GLYPHS[Math.floor(Math.random() * GLYPHS.length)];
84
+ // Don't settle on the same random glyph twice in a row (small visual cue
85
+ // that something is actually cycling).
86
+ if (avoid && ch === avoid) {
87
+ ch = GLYPHS[(GLYPHS.indexOf(ch) + 1) % GLYPHS.length];
88
+ }
89
+ return ch;
90
+ };
91
+
92
+ // Mirrors the source prototype's "lightly randomize later glyphs" step: while
93
+ // a char is cycling, every not-yet-settled char after it in DOM order has a
94
+ // chance to flicker too. Keeps the un-settled tail feeling alive.
95
+ const flickerLaterSiblings = (span: HTMLElement): void => {
96
+ const container = span.closest(".persona-stream-glyph-cycle") as HTMLElement | null;
97
+ if (!container) return;
98
+ // Any scheduled char that still has its final char stashed is "pending
99
+ // or cycling" — both kinds are valid flicker targets. Settled chars have
100
+ // `data-glyph-cycle-final` deleted and are excluded.
101
+ const unsettled = container.querySelectorAll<HTMLElement>(
102
+ ".persona-stream-char[data-glyph-cycle-final]"
103
+ );
104
+ let seenSelf = false;
105
+ for (const other of Array.from(unsettled)) {
106
+ if (other === span) {
107
+ seenSelf = true;
108
+ continue;
109
+ }
110
+ if (!seenSelf) continue;
111
+ if (Math.random() < CROSS_FLICKER_PROBABILITY) {
112
+ other.textContent = randomGlyph();
113
+ }
114
+ }
115
+ };
116
+
117
+ // Per-container timestamp tracking: when the next char's cycle should settle.
118
+ // Each char starts cycling immediately on schedule — no static placeholder
119
+ // wait — and finishes when `Date.now() >= settleAt`. This slot advances by
120
+ // one stagger step per schedule, so the burst settles left-to-right while
121
+ // every char is visibly flickering from the moment it enters the DOM.
122
+ const containerNextSettleAt = new WeakMap<Element, number>();
123
+
124
+ // Adaptive stagger state. We measure the real interval between scheduleCycle
125
+ // calls (= the stream's arrival cadence) and pace the glyph wave to it, a
126
+ // touch faster (DRAIN_FACTOR < 1) so any initial burst queue drains down to
127
+ // live-tracking instead of permanently lagging behind the live cursor.
128
+ const DEFAULT_ARRIVAL_MS = 25;
129
+ const STAGGER_DRAIN_FACTOR = 0.5;
130
+ const MIN_STAGGER_MS = 6;
131
+ const EMA_ALPHA = 0.25;
132
+ const containerLastScheduleAt = new WeakMap<Element, number>();
133
+ const containerArrivalMs = new WeakMap<Element, number>();
134
+
135
+ const observeStagger = (container: Element | null, now: number): number => {
136
+ if (!container) return DEFAULT_ARRIVAL_MS * STAGGER_DRAIN_FACTOR;
137
+ const last = containerLastScheduleAt.get(container);
138
+ containerLastScheduleAt.set(container, now);
139
+ let observed = containerArrivalMs.get(container) ?? DEFAULT_ARRIVAL_MS;
140
+ if (last !== undefined) {
141
+ const interval = now - last;
142
+ // Skip near-zero intervals (burst within one MutationObserver batch) —
143
+ // they don't reflect the real stream cadence.
144
+ if (interval > 1) {
145
+ observed = observed * (1 - EMA_ALPHA) + interval * EMA_ALPHA;
146
+ containerArrivalMs.set(container, observed);
147
+ }
148
+ }
149
+ return Math.max(MIN_STAGGER_MS, observed * STAGGER_DRAIN_FACTOR);
150
+ };
151
+
152
+ // Per-message count of in-flight cycles (scheduled or actively ticking).
153
+ // Used by `isAnimating()` so message-bubble keeps rendering in animated
154
+ // mode until the cycle wave finishes, even after `message.streaming` is
155
+ // false.
156
+ const activeCyclesByMessage = new Map<string, number>();
157
+
158
+ const getMessageId = (span: HTMLElement): string | null => {
159
+ const bubble = span.closest<HTMLElement>("[data-message-id]");
160
+ return bubble?.dataset.messageId ?? null;
161
+ };
162
+
163
+ const incrementActive = (messageId: string | null): void => {
164
+ if (!messageId) return;
165
+ activeCyclesByMessage.set(messageId, (activeCyclesByMessage.get(messageId) ?? 0) + 1);
166
+ };
167
+
168
+ const decrementActive = (messageId: string | null): void => {
169
+ if (!messageId) return;
170
+ const count = activeCyclesByMessage.get(messageId) ?? 0;
171
+ if (count <= 1) activeCyclesByMessage.delete(messageId);
172
+ else activeCyclesByMessage.set(messageId, count - 1);
173
+ };
174
+
175
+ const scheduleCycle = (span: HTMLElement): void => {
176
+ if (span.dataset.glyphCycleScheduled === "true") return;
177
+ const finalChar = span.textContent ?? "";
178
+ if (!finalChar || /\s/.test(finalChar)) return;
179
+
180
+ span.dataset.glyphCycleScheduled = "true";
181
+ // Stash the target char and immediately paint a random glyph so the span
182
+ // reads as "cycling" even before its staggered kickoff. Also opt out of
183
+ // morph — without this, streamed token re-renders would overwrite our
184
+ // placeholder glyph with the final char.
185
+ span.dataset.glyphCycleFinal = finalChar;
186
+ span.setAttribute("data-preserve-runtime", "stream-glyph-cycle");
187
+ span.textContent = randomGlyph();
188
+ // Record the owning message so `isAnimating()` can report in-flight work
189
+ // even after streaming has ended.
190
+ const messageId = getMessageId(span);
191
+ if (messageId) span.dataset.glyphCycleMessageId = messageId;
192
+ incrementActive(messageId);
193
+
194
+ const container = span.closest(".persona-stream-glyph-cycle") as HTMLElement | null;
195
+ const now = Date.now();
196
+ const staggerMs = observeStagger(container, now);
197
+ const tickMs = getTickMs(span);
198
+ const baseDurationMs = TICK_COUNT * tickMs;
199
+
200
+ // Settle time = later of (this char's own cycle ends) or (previous settle
201
+ // slot + one stagger step). The `max()` keeps left-to-right order during
202
+ // bursts, while post-drain live chars just get the base cycle.
203
+ let settleAt = now + baseDurationMs;
204
+ if (container) {
205
+ const prev = containerNextSettleAt.get(container);
206
+ if (prev !== undefined) settleAt = Math.max(settleAt, prev);
207
+ containerNextSettleAt.set(container, settleAt + staggerMs);
208
+ }
209
+
210
+ startCycle(span, finalChar, settleAt);
211
+ };
212
+
213
+ const startCycle = (
214
+ span: HTMLElement,
215
+ finalChar: string,
216
+ settleAt: number
217
+ ): void => {
218
+ if (span.dataset.glyphCycleStarted === "true") return;
219
+ span.dataset.glyphCycleStarted = "true";
220
+
221
+ const tickMs = getTickMs(span);
222
+ // Seed `lastGlyph` from the placeholder scheduleCycle painted, so the
223
+ // first real tick guarantees a different glyph.
224
+ let lastGlyph: string | undefined = span.textContent ?? undefined;
225
+ const step = () => {
226
+ if (!span.isConnected) return;
227
+ if (Date.now() >= settleAt) {
228
+ span.textContent = finalChar;
229
+ span.removeAttribute("data-preserve-runtime");
230
+ delete span.dataset.glyphCycleStarted;
231
+ delete span.dataset.glyphCycleFinal;
232
+ decrementActive(span.dataset.glyphCycleMessageId ?? null);
233
+ delete span.dataset.glyphCycleMessageId;
234
+ // Keep `data-glyph-cycle-scheduled` set so the MutationObserver and
235
+ // processCharSpans selectors skip settled chars on future re-morphs.
236
+ return;
237
+ }
238
+ const glyph = randomGlyph(lastGlyph);
239
+ span.textContent = glyph;
240
+ lastGlyph = glyph;
241
+ flickerLaterSiblings(span);
242
+ setTimeout(step, tickMs);
243
+ };
244
+ setTimeout(step, tickMs);
245
+ };
246
+
247
+ const processCharSpans = (root: Element | Document | ShadowRoot): void => {
248
+ const spans = root.querySelectorAll?.(
249
+ ".persona-stream-glyph-cycle .persona-stream-char:not([data-glyph-cycle-scheduled])"
250
+ );
251
+ if (!spans) return;
252
+ for (const span of Array.from(spans)) {
253
+ scheduleCycle(span as HTMLElement);
254
+ }
255
+ };
256
+
257
+ const isElement = (node: Node): node is HTMLElement => node.nodeType === 1;
258
+
259
+ export const glyphCycle: StreamAnimationPlugin = {
260
+ name: "glyph-cycle",
261
+ containerClass: "persona-stream-glyph-cycle",
262
+ wrap: "char",
263
+ // Narrow the default skip list so inline `<code>` and fenced `<pre>`
264
+ // code blocks both render as cycling glyphs along with everything else.
265
+ // Links stay clickable; <script>/<style> stay untouched.
266
+ skipTags: ["a", "script", "style"],
267
+ styles: STYLES,
268
+ bufferContent(content) {
269
+ // Hold the bubble empty until we've accumulated enough text to run a
270
+ // visible cycle from the start.
271
+ if (content.length < BUFFER_THRESHOLD) return "";
272
+ // Then trim to the last "safe" whitespace: one that sits outside any
273
+ // unclosed `**bold**` pair. Without this, whitespace INSIDE a partial
274
+ // bold pair (e.g. the space between `template` and `literal` in
275
+ // `**template literal**`) would trigger a render of the partial
276
+ // `**template`, which markdown emits as literal asterisks, gets
277
+ // wrapped + marked `data-preserve-runtime`, and leaks through later
278
+ // morphs — the final structure can't reconcile.
279
+ let boldPairs = 0;
280
+ let lastSafe = -1;
281
+ let i = 0;
282
+ while (i < content.length) {
283
+ if (content[i] === "*" && content[i + 1] === "*") {
284
+ boldPairs += 1;
285
+ i += 2;
286
+ continue;
287
+ }
288
+ if (/\s/.test(content[i]) && boldPairs % 2 === 0) {
289
+ lastSafe = i;
290
+ }
291
+ i += 1;
292
+ }
293
+ if (lastSafe < 0) return "";
294
+ return content.slice(0, lastSafe);
295
+ },
296
+ isAnimating(message) {
297
+ return (activeCyclesByMessage.get(message.id) ?? 0) > 0;
298
+ },
299
+ onAttach(root) {
300
+ // Process any chars already in the DOM when the plugin activates.
301
+ processCharSpans(root as Element | Document | ShadowRoot);
302
+
303
+ const observer = new MutationObserver((mutations) => {
304
+ for (const mutation of mutations) {
305
+ for (const node of Array.from(mutation.addedNodes)) {
306
+ if (!isElement(node)) continue;
307
+ // Either the added node is a char span inside a glyph-cycle
308
+ // container, or it contains char spans (a word-group, paragraph,
309
+ // or whole bubble that was just morphed in).
310
+ if (
311
+ node.classList.contains("persona-stream-char") &&
312
+ node.closest(".persona-stream-glyph-cycle")
313
+ ) {
314
+ scheduleCycle(node);
315
+ } else {
316
+ processCharSpans(node);
317
+ }
318
+ }
319
+ }
320
+ });
321
+ observer.observe(root, { childList: true, subtree: true });
322
+
323
+ return () => observer.disconnect();
324
+ },
325
+ };
326
+
327
+ // Auto-register on import. ESM consumers get zero cost if they don't import;
328
+ // once imported, the plugin is available globally by name without requiring
329
+ // an explicit `plugins: { "glyph-cycle": glyphCycle }` config.
330
+ registerStreamAnimationPlugin(glyphCycle);
331
+
332
+ export default glyphCycle;
@@ -0,0 +1,66 @@
1
+ import type { StreamAnimationPlugin } from "../types";
2
+ import { registerStreamAnimationPlugin } from "../utils/stream-animation";
3
+
4
+ /**
5
+ * Wipe animation — per-word left-to-right mask reveal.
6
+ *
7
+ * Each arriving word is revealed via a soft feathered mask that sweeps from
8
+ * right to left. Uses `mask-image` (not `background-clip: text`) so the
9
+ * text's normal color is preserved and nested markdown formatting works.
10
+ *
11
+ * ```ts
12
+ * import "@runtypelabs/persona/animations/wipe";
13
+ * createAgentExperience(el, {
14
+ * features: { streamAnimation: { type: "wipe" } },
15
+ * });
16
+ * ```
17
+ */
18
+
19
+ const STYLES = `
20
+ @keyframes persona-stream-wipe {
21
+ from { -webkit-mask-position: 100% 0; mask-position: 100% 0; }
22
+ to { -webkit-mask-position: 0% 0; mask-position: 0% 0; }
23
+ }
24
+ [data-persona-root] .persona-stream-wipe .persona-stream-word {
25
+ -webkit-mask-image: linear-gradient(
26
+ 90deg,
27
+ black 0%,
28
+ black 45%,
29
+ transparent 55%,
30
+ transparent 100%
31
+ );
32
+ mask-image: linear-gradient(
33
+ 90deg,
34
+ black 0%,
35
+ black 45%,
36
+ transparent 55%,
37
+ transparent 100%
38
+ );
39
+ -webkit-mask-size: 200% 100%;
40
+ mask-size: 200% 100%;
41
+ -webkit-mask-position: 100% 0;
42
+ mask-position: 100% 0;
43
+ -webkit-mask-repeat: no-repeat;
44
+ mask-repeat: no-repeat;
45
+ animation: persona-stream-wipe calc(var(--persona-stream-step, 120ms) * 3)
46
+ ease-out forwards;
47
+ }
48
+ @media (prefers-reduced-motion: reduce) {
49
+ [data-persona-root] .persona-stream-wipe .persona-stream-word {
50
+ animation: none !important;
51
+ -webkit-mask-image: none !important;
52
+ mask-image: none !important;
53
+ }
54
+ }
55
+ `.trim();
56
+
57
+ export const wipe: StreamAnimationPlugin = {
58
+ name: "wipe",
59
+ containerClass: "persona-stream-wipe",
60
+ wrap: "word",
61
+ styles: STYLES,
62
+ };
63
+
64
+ registerStreamAnimationPlugin(wipe);
65
+
66
+ export default wipe;