@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.
- package/dist/animations/glyph-cycle.cjs +279 -0
- package/dist/animations/glyph-cycle.d.cts +5 -0
- package/dist/animations/glyph-cycle.d.ts +5 -0
- package/dist/animations/glyph-cycle.js +252 -0
- package/dist/animations/types-HPZY7oAI.d.cts +282 -0
- package/dist/animations/types-HPZY7oAI.d.ts +282 -0
- package/dist/animations/wipe.cjs +107 -0
- package/dist/animations/wipe.d.cts +5 -0
- package/dist/animations/wipe.d.ts +5 -0
- package/dist/animations/wipe.js +80 -0
- package/dist/index.cjs +48 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +205 -1
- package/dist/index.d.ts +205 -1
- package/dist/index.global.js +136 -81
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +48 -47
- package/dist/index.js.map +1 -1
- package/dist/testing.cjs +85 -0
- package/dist/testing.d.cts +39 -0
- package/dist/testing.d.ts +39 -0
- package/dist/testing.js +56 -0
- package/dist/theme-editor.cjs +714 -99
- package/dist/theme-editor.d.cts +214 -2
- package/dist/theme-editor.d.ts +214 -2
- package/dist/theme-editor.js +712 -99
- package/dist/widget.css +133 -0
- package/package.json +20 -3
- package/src/animations/glyph-cycle.ts +332 -0
- package/src/animations/wipe.ts +66 -0
- package/src/client.test.ts +141 -0
- package/src/client.ts +28 -0
- package/src/components/composer-builder.ts +61 -10
- package/src/components/message-bubble.test.ts +181 -2
- package/src/components/message-bubble.ts +209 -14
- package/src/components/panel.ts +4 -1
- package/src/defaults.ts +16 -0
- package/src/index-global.ts +31 -0
- package/src/index.ts +18 -0
- package/src/session.test.ts +93 -1
- package/src/session.ts +5 -0
- package/src/styles/widget.css +133 -0
- package/src/testing/index.ts +11 -0
- package/src/testing/mock-stream.test.ts +80 -0
- package/src/testing/mock-stream.ts +94 -0
- package/src/testing.ts +2 -0
- package/src/theme-editor/index.ts +4 -0
- package/src/theme-editor/preview-utils.test.ts +60 -0
- package/src/theme-editor/preview-utils.ts +129 -0
- package/src/theme-editor/sections.test.ts +19 -0
- package/src/theme-editor/sections.ts +84 -1
- package/src/types.ts +210 -0
- package/src/ui.stop-button.test.ts +165 -0
- package/src/ui.ts +75 -6
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/morph.ts +7 -0
- package/src/utils/stream-animation.test.ts +417 -0
- 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.
|
|
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
|
|
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;
|