@runtypelabs/persona 3.16.0 → 3.18.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/README.md +142 -0
- 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-cwY5HaFD.d.cts +307 -0
- package/dist/animations/types-cwY5HaFD.d.ts +307 -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 +49 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +504 -1
- package/dist/index.d.ts +504 -1
- package/dist/index.global.js +143 -88
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +49 -48
- 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 +2095 -207
- package/dist/theme-editor.d.cts +432 -2
- package/dist/theme-editor.d.ts +432 -2
- package/dist/theme-editor.js +2093 -207
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +14 -0
- package/dist/theme-reference.d.ts +14 -0
- package/dist/widget.css +565 -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 +275 -0
- package/src/client.ts +99 -0
- package/src/components/ask-user-question-bubble.test.ts +583 -0
- package/src/components/ask-user-question-bubble.ts +924 -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/messages.ts +33 -1
- package/src/components/panel.ts +45 -5
- package/src/defaults.ts +37 -0
- package/src/index-global.ts +31 -0
- package/src/index.ts +34 -1
- package/src/plugins/types.ts +57 -0
- package/src/session.test.ts +276 -1
- package/src/session.ts +247 -3
- package/src/styles/widget.css +565 -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/theme.ts +15 -0
- package/src/types.ts +360 -0
- package/src/ui.ask-user-question-plugin.test.ts +649 -0
- package/src/ui.stop-button.test.ts +165 -0
- package/src/ui.ts +706 -11
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/morph.ts +7 -0
- package/src/utils/storage.ts +10 -2
- package/src/utils/stream-animation.test.ts +417 -0
- package/src/utils/stream-animation.ts +449 -0
- package/src/utils/theme.test.ts +36 -0
- package/src/utils/tokens.ts +23 -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 };
|
package/src/utils/theme.test.ts
CHANGED
|
@@ -252,6 +252,42 @@ describe('theme utils', () => {
|
|
|
252
252
|
expect(cssVars['--persona-scroll-to-bottom-icon-size']).toBe('14px');
|
|
253
253
|
});
|
|
254
254
|
|
|
255
|
+
it('maps introCard component tokens to dedicated CSS variables', () => {
|
|
256
|
+
const theme = createTheme({
|
|
257
|
+
components: {
|
|
258
|
+
introCard: {
|
|
259
|
+
background: 'palette.colors.accent.50',
|
|
260
|
+
borderRadius: 'palette.radius.xl',
|
|
261
|
+
padding: 'semantic.spacing.lg',
|
|
262
|
+
shadow: '0 10px 30px rgba(53, 44, 131, 0.15)',
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
} as any);
|
|
266
|
+
|
|
267
|
+
const cssVars = themeToCssVariables(theme);
|
|
268
|
+
|
|
269
|
+
expect(cssVars['--persona-components-introCard-background']).toBe('#ecfeff');
|
|
270
|
+
expect(cssVars['--persona-components-introCard-borderRadius']).toBe('0.75rem');
|
|
271
|
+
expect(cssVars['--persona-components-introCard-padding']).toBe('1.5rem');
|
|
272
|
+
expect(cssVars['--persona-components-introCard-shadow']).toBe(
|
|
273
|
+
'0 10px 30px rgba(53, 44, 131, 0.15)'
|
|
274
|
+
);
|
|
275
|
+
expect(cssVars['--persona-intro-card-bg']).toBe('#ecfeff');
|
|
276
|
+
expect(cssVars['--persona-intro-card-radius']).toBe('0.75rem');
|
|
277
|
+
expect(cssVars['--persona-intro-card-padding']).toBe('1.5rem');
|
|
278
|
+
expect(cssVars['--persona-intro-card-shadow']).toBe(
|
|
279
|
+
'0 10px 30px rgba(53, 44, 131, 0.15)'
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('falls back to the legacy intro-card shadow when no token is set', () => {
|
|
284
|
+
const theme = createTheme({});
|
|
285
|
+
const cssVars = themeToCssVariables(theme);
|
|
286
|
+
expect(cssVars['--persona-intro-card-shadow']).toBe(
|
|
287
|
+
'0 5px 15px rgba(15, 23, 42, 0.08)'
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
255
291
|
it('lets config.toolCall.shadow override theme tool bubble shadow on the root element', () => {
|
|
256
292
|
const el = document.createElement('div');
|
|
257
293
|
applyThemeVariables(el, {
|
package/src/utils/tokens.ts
CHANGED
|
@@ -318,6 +318,14 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
|
|
|
318
318
|
},
|
|
319
319
|
border: 'semantic.colors.border',
|
|
320
320
|
},
|
|
321
|
+
introCard: {
|
|
322
|
+
// Defaults preserve the legacy `persona-shadow-sm` look exactly so existing
|
|
323
|
+
// pages render unchanged when no token is set.
|
|
324
|
+
background: 'semantic.colors.surface',
|
|
325
|
+
borderRadius: 'palette.radius.2xl',
|
|
326
|
+
padding: 'semantic.spacing.lg',
|
|
327
|
+
shadow: '0 5px 15px rgba(15, 23, 42, 0.08)',
|
|
328
|
+
},
|
|
321
329
|
toolBubble: {
|
|
322
330
|
shadow: 'palette.shadows.sm',
|
|
323
331
|
},
|
|
@@ -763,6 +771,21 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
|
|
|
763
771
|
if (headerTokens?.shadow) cssVars['--persona-header-shadow'] = headerTokens.shadow;
|
|
764
772
|
if (headerTokens?.borderBottom) cssVars['--persona-header-border-bottom'] = headerTokens.borderBottom;
|
|
765
773
|
|
|
774
|
+
// Intro card aliases — short names the panel inline-styles read directly.
|
|
775
|
+
// The full-path `--persona-components-introCard-*` variables auto-emit above;
|
|
776
|
+
// these mirror them with sensible fallbacks so existing pages keep their look.
|
|
777
|
+
const introCardTokens = theme.components?.introCard;
|
|
778
|
+
cssVars['--persona-intro-card-bg'] =
|
|
779
|
+
cssVars['--persona-components-introCard-background'] ?? cssVars['--persona-surface'];
|
|
780
|
+
cssVars['--persona-intro-card-radius'] =
|
|
781
|
+
cssVars['--persona-components-introCard-borderRadius'] ?? '1rem';
|
|
782
|
+
cssVars['--persona-intro-card-padding'] =
|
|
783
|
+
cssVars['--persona-components-introCard-padding'] ?? '1.5rem';
|
|
784
|
+
cssVars['--persona-intro-card-shadow'] =
|
|
785
|
+
introCardTokens?.shadow
|
|
786
|
+
?? cssVars['--persona-components-introCard-shadow']
|
|
787
|
+
?? '0 5px 15px rgba(15, 23, 42, 0.08)';
|
|
788
|
+
|
|
766
789
|
cssVars['--persona-input-background'] =
|
|
767
790
|
cssVars['--persona-components-input-background'] ?? cssVars['--persona-surface'];
|
|
768
791
|
cssVars['--persona-input-placeholder'] =
|