@runtypelabs/persona 4.15.0 → 4.16.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/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +283 -9
- package/dist/context-mentions.d.ts +283 -9
- package/dist/index.cjs +68 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +299 -10
- package/dist/index.d.ts +299 -10
- package/dist/index.global.js +57 -57
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +69 -69
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +283 -9
- package/dist/smart-dom-reader.d.ts +283 -9
- package/dist/testing.cjs +3 -3
- package/dist/testing.d.cts +7 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +64 -64
- package/dist/theme-editor-preview.d.cts +283 -9
- package/dist/theme-editor-preview.d.ts +283 -9
- package/dist/theme-editor-preview.js +56 -56
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +283 -10
- package/dist/theme-editor.d.ts +283 -10
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +66 -3
- package/dist/theme-reference.d.ts +66 -3
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/components/artifact-pane.test.ts +27 -0
- package/src/components/artifact-pane.ts +7 -3
- package/src/components/header-builder.ts +27 -2
- package/src/components/header-layouts.test.ts +56 -0
- package/src/components/header-layouts.ts +29 -9
- package/src/components/launcher-surface.test.ts +403 -0
- package/src/components/launcher.ts +297 -2
- package/src/components/panel.ts +69 -74
- package/src/components/suggestions.test.ts +40 -0
- package/src/components/suggestions.ts +213 -153
- package/src/components/welcome.test.ts +121 -0
- package/src/components/welcome.ts +305 -0
- package/src/components/widget-view.test.ts +5 -3
- package/src/components/widget-view.ts +14 -7
- package/src/defaults.ts +2 -2
- package/src/index-core.ts +12 -1
- package/src/launcher-global.ts +10 -7
- package/src/plugins/types.ts +66 -1
- package/src/styles/widget-styles.test.ts +220 -0
- package/src/styles/widget.css +353 -9
- package/src/testing/mock-stream.test.ts +19 -0
- package/src/testing/mock-stream.ts +20 -0
- package/src/theme-editor/sections.ts +30 -1
- package/src/theme-reference.ts +26 -4
- package/src/types/theme.ts +40 -3
- package/src/types.ts +164 -6
- package/src/ui.body-top-slot.test.ts +95 -0
- package/src/ui.content-width.test.ts +91 -0
- package/src/ui.docked.test.ts +7 -4
- package/src/ui.launcher-teaser.test.ts +162 -0
- package/src/ui.rebuild-composer.test.ts +491 -0
- package/src/ui.render-welcome.test.ts +505 -0
- package/src/ui.scroll-additive.test.ts +91 -21
- package/src/ui.scroll.test.ts +302 -14
- package/src/ui.suggest-replies.test.ts +46 -0
- package/src/ui.ts +747 -136
- package/src/ui.welcome.test.ts +504 -0
- package/src/utils/artifact-gate.test.ts +27 -0
- package/src/utils/artifact-gate.ts +9 -0
- package/src/utils/attachment-manager.test.ts +37 -0
- package/src/utils/attachment-manager.ts +15 -0
- package/src/utils/auto-follow.test.ts +23 -0
- package/src/utils/auto-follow.ts +11 -1
- package/src/utils/config-merge.ts +2 -0
- package/src/utils/content-width.test.ts +37 -0
- package/src/utils/content-width.ts +17 -0
- package/src/utils/plugin-storage.test.ts +94 -0
- package/src/utils/plugin-storage.ts +98 -0
- package/src/utils/theme.test.ts +137 -0
- package/src/utils/tokens.ts +49 -3
- package/src/welcome.test.ts +213 -0
- package/src/welcome.ts +130 -0
|
@@ -138,6 +138,205 @@ const animateEntrance = (elements: readonly HTMLElement[]): void => {
|
|
|
138
138
|
});
|
|
139
139
|
};
|
|
140
140
|
|
|
141
|
+
export interface SuggestionElementContext {
|
|
142
|
+
/** Fully normalized item, including the effective send/fill behavior. */
|
|
143
|
+
item: AgentWidgetResolvedSuggestion;
|
|
144
|
+
index: number;
|
|
145
|
+
surface: AgentWidgetSuggestionSurface;
|
|
146
|
+
source: AgentWidgetSuggestionSource;
|
|
147
|
+
variant: AgentWidgetSuggestionVariant;
|
|
148
|
+
streaming: boolean;
|
|
149
|
+
config: AgentWidgetConfig;
|
|
150
|
+
plugins: readonly AgentWidgetPlugin[];
|
|
151
|
+
chipsConfig?: AgentWidgetSuggestionChipsConfig;
|
|
152
|
+
session: AgentWidgetSession;
|
|
153
|
+
/** Read live: inline mentions and composer rebuilds swap the input element. */
|
|
154
|
+
getTextarea: () => HTMLTextAreaElement;
|
|
155
|
+
/** Node the cancelable `persona:suggestion:*` events dispatch from. */
|
|
156
|
+
eventTarget: HTMLElement;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build one suggestion element with the full select pipeline attached:
|
|
161
|
+
* cancelable DOM events, `onSuggestionSelect` hooks, then send or fill. The
|
|
162
|
+
* suggestion rows and the `renderWelcome` ctx's `renderStarter` share this, so
|
|
163
|
+
* a plugin-rendered starter behaves identically to a built-in one.
|
|
164
|
+
*/
|
|
165
|
+
export const createSuggestionElement = (
|
|
166
|
+
context: SuggestionElementContext
|
|
167
|
+
): HTMLElement => {
|
|
168
|
+
const {
|
|
169
|
+
item,
|
|
170
|
+
index,
|
|
171
|
+
surface,
|
|
172
|
+
source,
|
|
173
|
+
variant,
|
|
174
|
+
streaming,
|
|
175
|
+
config,
|
|
176
|
+
plugins,
|
|
177
|
+
chipsConfig,
|
|
178
|
+
session,
|
|
179
|
+
getTextarea,
|
|
180
|
+
eventTarget,
|
|
181
|
+
} = context;
|
|
182
|
+
const isFollowUp = surface === "followUp";
|
|
183
|
+
const itemBehavior = item.behavior;
|
|
184
|
+
|
|
185
|
+
const select = () => {
|
|
186
|
+
if (session.isStreaming()) return;
|
|
187
|
+
const detail = {
|
|
188
|
+
suggestion: { ...item },
|
|
189
|
+
surface,
|
|
190
|
+
source,
|
|
191
|
+
behavior: itemBehavior,
|
|
192
|
+
};
|
|
193
|
+
const shouldContinue = eventTarget.dispatchEvent(
|
|
194
|
+
new CustomEvent("persona:suggestion:selected", {
|
|
195
|
+
detail,
|
|
196
|
+
bubbles: true,
|
|
197
|
+
composed: true,
|
|
198
|
+
cancelable: true,
|
|
199
|
+
})
|
|
200
|
+
);
|
|
201
|
+
if (isFollowUp) {
|
|
202
|
+
eventTarget.dispatchEvent(
|
|
203
|
+
new CustomEvent("persona:suggestReplies:selected", {
|
|
204
|
+
detail: { suggestion: item.prompt },
|
|
205
|
+
bubbles: true,
|
|
206
|
+
composed: true,
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (!shouldContinue) return;
|
|
211
|
+
|
|
212
|
+
for (const plugin of plugins) {
|
|
213
|
+
if (
|
|
214
|
+
plugin.onSuggestionSelect?.({
|
|
215
|
+
suggestion: { ...item },
|
|
216
|
+
surface,
|
|
217
|
+
source,
|
|
218
|
+
variant,
|
|
219
|
+
config,
|
|
220
|
+
}) === false
|
|
221
|
+
) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const textarea = getTextarea();
|
|
227
|
+
if (itemBehavior === "fill") {
|
|
228
|
+
textarea.value = item.prompt;
|
|
229
|
+
textarea.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
|
230
|
+
textarea.focus();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
textarea.value = "";
|
|
235
|
+
session.sendMessage(item.prompt);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const defaultRenderer = (): HTMLElement => {
|
|
239
|
+
const button = createElement(
|
|
240
|
+
"button",
|
|
241
|
+
`persona-suggestion persona-suggestion--${variant}`
|
|
242
|
+
) as HTMLButtonElement;
|
|
243
|
+
button.type = "button";
|
|
244
|
+
button.disabled = streaming;
|
|
245
|
+
button.dataset.suggestionId = item.id;
|
|
246
|
+
button.dataset.emphasis = item.emphasis;
|
|
247
|
+
button.dataset.behavior = itemBehavior;
|
|
248
|
+
|
|
249
|
+
// Legacy `suggestionChipsConfig` compat is chip-only: its inline
|
|
250
|
+
// styles would otherwise override the card/list padding tokens.
|
|
251
|
+
if (variant === "chip") {
|
|
252
|
+
if (chipsConfig?.fontFamily) {
|
|
253
|
+
button.style.fontFamily = fontFamilyValue(chipsConfig.fontFamily);
|
|
254
|
+
}
|
|
255
|
+
if (chipsConfig?.fontWeight) {
|
|
256
|
+
button.style.fontWeight = chipsConfig.fontWeight;
|
|
257
|
+
}
|
|
258
|
+
if (chipsConfig?.paddingX) {
|
|
259
|
+
button.style.paddingLeft = chipsConfig.paddingX;
|
|
260
|
+
button.style.paddingRight = chipsConfig.paddingX;
|
|
261
|
+
}
|
|
262
|
+
if (chipsConfig?.paddingY) {
|
|
263
|
+
button.style.paddingTop = chipsConfig.paddingY;
|
|
264
|
+
button.style.paddingBottom = chipsConfig.paddingY;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (item.icon) {
|
|
269
|
+
const icon = renderLucideIcon(
|
|
270
|
+
item.icon,
|
|
271
|
+
"var(--persona-suggestion-icon-size)",
|
|
272
|
+
"currentColor",
|
|
273
|
+
1.8
|
|
274
|
+
);
|
|
275
|
+
if (icon) {
|
|
276
|
+
icon.classList.add("persona-suggestion__icon");
|
|
277
|
+
// The glyph strokes in currentColor, so this tints it alone; inline
|
|
278
|
+
// also beats the emphasis-primary icon rule.
|
|
279
|
+
if (item.iconColor) icon.style.color = item.iconColor;
|
|
280
|
+
button.appendChild(icon);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const copy = createElement("span", "persona-suggestion__copy");
|
|
285
|
+
copy.appendChild(
|
|
286
|
+
createNode("span", {
|
|
287
|
+
className: "persona-suggestion__label",
|
|
288
|
+
text: item.label,
|
|
289
|
+
})
|
|
290
|
+
);
|
|
291
|
+
// Chips are single-line labels; descriptions belong to card and list.
|
|
292
|
+
if (item.description && variant !== "chip") {
|
|
293
|
+
copy.appendChild(
|
|
294
|
+
createNode("span", {
|
|
295
|
+
className: "persona-suggestion__description",
|
|
296
|
+
text: item.description,
|
|
297
|
+
})
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
button.appendChild(copy);
|
|
301
|
+
|
|
302
|
+
if (variant !== "chip") {
|
|
303
|
+
const arrow = renderLucideIcon("arrow-right", 16, "currentColor", 1.8);
|
|
304
|
+
if (arrow) {
|
|
305
|
+
arrow.classList.add("persona-suggestion__arrow");
|
|
306
|
+
button.appendChild(arrow);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
button.addEventListener("click", select);
|
|
311
|
+
return button;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
let element: HTMLElement | null = null;
|
|
315
|
+
for (const plugin of plugins) {
|
|
316
|
+
if (!plugin.renderSuggestion) continue;
|
|
317
|
+
element = plugin.renderSuggestion({
|
|
318
|
+
suggestion: { ...item },
|
|
319
|
+
index,
|
|
320
|
+
surface,
|
|
321
|
+
source,
|
|
322
|
+
variant,
|
|
323
|
+
streaming,
|
|
324
|
+
config,
|
|
325
|
+
defaultRenderer,
|
|
326
|
+
select,
|
|
327
|
+
});
|
|
328
|
+
if (element) break;
|
|
329
|
+
}
|
|
330
|
+
element ??= defaultRenderer();
|
|
331
|
+
element.dataset.suggestionId ||= item.id;
|
|
332
|
+
element.dataset.behavior ||= itemBehavior;
|
|
333
|
+
element.dataset.emphasis ||= item.emphasis;
|
|
334
|
+
if (!(element instanceof HTMLButtonElement)) {
|
|
335
|
+
element.setAttribute("aria-disabled", streaming ? "true" : "false");
|
|
336
|
+
}
|
|
337
|
+
return element;
|
|
338
|
+
};
|
|
339
|
+
|
|
141
340
|
export const createSuggestions = (container: HTMLElement): SuggestionButtons => {
|
|
142
341
|
const suggestionButtons: HTMLButtonElement[] = [];
|
|
143
342
|
const suggestionElements: HTMLElement[] = [];
|
|
@@ -298,159 +497,20 @@ export const createSuggestions = (container: HTMLElement): SuggestionButtons =>
|
|
|
298
497
|
const streaming = session.isStreaming();
|
|
299
498
|
|
|
300
499
|
normalized.forEach((item, index) => {
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
cancelable: true,
|
|
316
|
-
})
|
|
317
|
-
);
|
|
318
|
-
if (isFollowUp) {
|
|
319
|
-
container.dispatchEvent(
|
|
320
|
-
new CustomEvent("persona:suggestReplies:selected", {
|
|
321
|
-
detail: { suggestion: item.prompt },
|
|
322
|
-
bubbles: true,
|
|
323
|
-
composed: true,
|
|
324
|
-
})
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
if (!shouldContinue) return;
|
|
328
|
-
|
|
329
|
-
for (const plugin of plugins) {
|
|
330
|
-
if (
|
|
331
|
-
plugin.onSuggestionSelect?.({
|
|
332
|
-
suggestion: { ...item },
|
|
333
|
-
surface,
|
|
334
|
-
source,
|
|
335
|
-
variant,
|
|
336
|
-
config: widgetConfig,
|
|
337
|
-
}) === false
|
|
338
|
-
) {
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (itemBehavior === "fill") {
|
|
344
|
-
textarea.value = item.prompt;
|
|
345
|
-
textarea.dispatchEvent(
|
|
346
|
-
new Event("input", { bubbles: true, composed: true })
|
|
347
|
-
);
|
|
348
|
-
textarea.focus();
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
textarea.value = "";
|
|
353
|
-
session.sendMessage(item.prompt);
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
const defaultRenderer = (): HTMLElement => {
|
|
357
|
-
const button = createElement(
|
|
358
|
-
"button",
|
|
359
|
-
`persona-suggestion persona-suggestion--${variant}`
|
|
360
|
-
) as HTMLButtonElement;
|
|
361
|
-
button.type = "button";
|
|
362
|
-
button.disabled = streaming;
|
|
363
|
-
button.dataset.suggestionId = item.id;
|
|
364
|
-
button.dataset.emphasis = item.emphasis;
|
|
365
|
-
button.dataset.behavior = itemBehavior;
|
|
366
|
-
|
|
367
|
-
// Legacy `suggestionChipsConfig` compat is chip-only: its inline
|
|
368
|
-
// styles would otherwise override the card/list padding tokens.
|
|
369
|
-
if (variant === "chip") {
|
|
370
|
-
if (chipsConfig?.fontFamily) {
|
|
371
|
-
button.style.fontFamily = fontFamilyValue(chipsConfig.fontFamily);
|
|
372
|
-
}
|
|
373
|
-
if (chipsConfig?.fontWeight) {
|
|
374
|
-
button.style.fontWeight = chipsConfig.fontWeight;
|
|
375
|
-
}
|
|
376
|
-
if (chipsConfig?.paddingX) {
|
|
377
|
-
button.style.paddingLeft = chipsConfig.paddingX;
|
|
378
|
-
button.style.paddingRight = chipsConfig.paddingX;
|
|
379
|
-
}
|
|
380
|
-
if (chipsConfig?.paddingY) {
|
|
381
|
-
button.style.paddingTop = chipsConfig.paddingY;
|
|
382
|
-
button.style.paddingBottom = chipsConfig.paddingY;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
if (item.icon) {
|
|
387
|
-
const icon = renderLucideIcon(
|
|
388
|
-
item.icon,
|
|
389
|
-
"var(--persona-suggestion-icon-size)",
|
|
390
|
-
"currentColor",
|
|
391
|
-
1.8
|
|
392
|
-
);
|
|
393
|
-
if (icon) {
|
|
394
|
-
icon.classList.add("persona-suggestion__icon");
|
|
395
|
-
// The glyph strokes in currentColor, so this tints it alone; inline
|
|
396
|
-
// also beats the emphasis-primary icon rule.
|
|
397
|
-
if (item.iconColor) icon.style.color = item.iconColor;
|
|
398
|
-
button.appendChild(icon);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const copy = createElement("span", "persona-suggestion__copy");
|
|
403
|
-
copy.appendChild(
|
|
404
|
-
createNode("span", {
|
|
405
|
-
className: "persona-suggestion__label",
|
|
406
|
-
text: item.label,
|
|
407
|
-
})
|
|
408
|
-
);
|
|
409
|
-
if (item.description) {
|
|
410
|
-
copy.appendChild(
|
|
411
|
-
createNode("span", {
|
|
412
|
-
className: "persona-suggestion__description",
|
|
413
|
-
text: item.description,
|
|
414
|
-
})
|
|
415
|
-
);
|
|
416
|
-
}
|
|
417
|
-
button.appendChild(copy);
|
|
418
|
-
|
|
419
|
-
if (variant !== "chip") {
|
|
420
|
-
const arrow = renderLucideIcon("arrow-right", 16, "currentColor", 1.8);
|
|
421
|
-
if (arrow) {
|
|
422
|
-
arrow.classList.add("persona-suggestion__arrow");
|
|
423
|
-
button.appendChild(arrow);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
button.addEventListener("click", select);
|
|
428
|
-
return button;
|
|
429
|
-
};
|
|
430
|
-
|
|
431
|
-
let element: HTMLElement | null = null;
|
|
432
|
-
for (const plugin of plugins) {
|
|
433
|
-
if (!plugin.renderSuggestion) continue;
|
|
434
|
-
element = plugin.renderSuggestion({
|
|
435
|
-
suggestion: { ...item },
|
|
436
|
-
index,
|
|
437
|
-
surface,
|
|
438
|
-
source,
|
|
439
|
-
variant,
|
|
440
|
-
streaming,
|
|
441
|
-
config: widgetConfig,
|
|
442
|
-
defaultRenderer,
|
|
443
|
-
select,
|
|
444
|
-
});
|
|
445
|
-
if (element) break;
|
|
446
|
-
}
|
|
447
|
-
element ??= defaultRenderer();
|
|
448
|
-
element.dataset.suggestionId ||= item.id;
|
|
449
|
-
element.dataset.behavior ||= itemBehavior;
|
|
450
|
-
element.dataset.emphasis ||= item.emphasis;
|
|
451
|
-
if (!(element instanceof HTMLButtonElement)) {
|
|
452
|
-
element.setAttribute("aria-disabled", streaming ? "true" : "false");
|
|
453
|
-
}
|
|
500
|
+
const element = createSuggestionElement({
|
|
501
|
+
item,
|
|
502
|
+
index,
|
|
503
|
+
surface,
|
|
504
|
+
source,
|
|
505
|
+
variant,
|
|
506
|
+
streaming,
|
|
507
|
+
config: widgetConfig,
|
|
508
|
+
plugins,
|
|
509
|
+
chipsConfig,
|
|
510
|
+
session,
|
|
511
|
+
getTextarea: () => textarea,
|
|
512
|
+
eventTarget: container,
|
|
513
|
+
});
|
|
454
514
|
|
|
455
515
|
fragment.appendChild(element);
|
|
456
516
|
suggestionElements.push(element);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
animateWelcomeOut,
|
|
7
|
+
applyWelcomeConfig,
|
|
8
|
+
applyWelcomeVisibility,
|
|
9
|
+
buildGreetingHost,
|
|
10
|
+
buildWelcomeHost,
|
|
11
|
+
renderWelcomeGreeting,
|
|
12
|
+
} from "./welcome";
|
|
13
|
+
import { resolveWelcomeConfig } from "../welcome";
|
|
14
|
+
import type { AgentWidgetConfig } from "../types";
|
|
15
|
+
|
|
16
|
+
const makeHost = () => {
|
|
17
|
+
const starters = document.createElement("div");
|
|
18
|
+
return buildWelcomeHost(undefined, starters);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("welcome host", () => {
|
|
22
|
+
it("carries the permanent host attributes", () => {
|
|
23
|
+
const { host } = makeHost();
|
|
24
|
+
expect(host.hasAttribute("data-persona-welcome")).toBe(true);
|
|
25
|
+
expect(host.hasAttribute("data-persona-intro-card")).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("gives the title and subtitle their tokenized typography classes", () => {
|
|
29
|
+
const { title, subtitle } = makeHost();
|
|
30
|
+
expect(title.tagName).toBe("H2");
|
|
31
|
+
expect(title.className).toBe("persona-welcome-title");
|
|
32
|
+
expect(subtitle.tagName).toBe("P");
|
|
33
|
+
expect(subtitle.className).toBe("persona-welcome-subtitle");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("applies resolved config in place", () => {
|
|
37
|
+
const elements = makeHost();
|
|
38
|
+
applyWelcomeConfig(
|
|
39
|
+
elements,
|
|
40
|
+
resolveWelcomeConfig({
|
|
41
|
+
welcome: { title: "Hi", subtitle: "Scope", variant: "hero" },
|
|
42
|
+
} as AgentWidgetConfig)
|
|
43
|
+
);
|
|
44
|
+
expect(elements.title.textContent).toBe("Hi");
|
|
45
|
+
expect(elements.subtitle.textContent).toBe("Scope");
|
|
46
|
+
expect(elements.host.getAttribute("data-persona-welcome-variant")).toBe(
|
|
47
|
+
"hero"
|
|
48
|
+
);
|
|
49
|
+
expect(elements.host.getAttribute("data-persona-welcome-dismiss")).toBe(
|
|
50
|
+
"on-first-message"
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("tightens the body gap when the surface is hidden", () => {
|
|
55
|
+
const { host } = makeHost();
|
|
56
|
+
const body = document.createElement("div");
|
|
57
|
+
body.className = "persona-gap-6";
|
|
58
|
+
applyWelcomeVisibility(body, host, false);
|
|
59
|
+
expect(body.classList.contains("persona-gap-3")).toBe(true);
|
|
60
|
+
expect(body.classList.contains("persona-gap-6")).toBe(false);
|
|
61
|
+
applyWelcomeVisibility(body, host, true);
|
|
62
|
+
expect(body.classList.contains("persona-gap-6")).toBe(true);
|
|
63
|
+
expect(host.hidden).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("greeting bubble", () => {
|
|
68
|
+
it("renders the greeting as plain text, never markup", () => {
|
|
69
|
+
const host = buildGreetingHost();
|
|
70
|
+
renderWelcomeGreeting(host, "<b>bold</b> & fine", undefined);
|
|
71
|
+
expect(host.querySelector("b")).toBeNull();
|
|
72
|
+
expect(host.textContent).toBe("<b>bold</b> & fine");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("clears and hides when the greeting is removed", () => {
|
|
76
|
+
const host = buildGreetingHost();
|
|
77
|
+
renderWelcomeGreeting(host, "Hi", undefined);
|
|
78
|
+
expect(host.hidden).toBe(false);
|
|
79
|
+
renderWelcomeGreeting(host, undefined, undefined);
|
|
80
|
+
expect(host.hidden).toBe(true);
|
|
81
|
+
expect(host.childElementCount).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("hero dismiss animation", () => {
|
|
86
|
+
it("runs on the Web Animations API and settles after it finishes", async () => {
|
|
87
|
+
const { host } = makeHost();
|
|
88
|
+
document.body.appendChild(host);
|
|
89
|
+
let resolveFinished: () => void = () => {};
|
|
90
|
+
const finished = new Promise<void>((resolve) => {
|
|
91
|
+
resolveFinished = resolve;
|
|
92
|
+
});
|
|
93
|
+
const animate = vi.fn(
|
|
94
|
+
(_keyframes: unknown, _options?: { fill?: string }) =>
|
|
95
|
+
({ finished }) as unknown as Animation
|
|
96
|
+
);
|
|
97
|
+
(host as unknown as { animate: typeof animate }).animate = animate;
|
|
98
|
+
|
|
99
|
+
const onFinished = vi.fn();
|
|
100
|
+
const animation = animateWelcomeOut(host, onFinished);
|
|
101
|
+
expect(animate).toHaveBeenCalledTimes(1);
|
|
102
|
+
// `forwards`: `backwards` only fills the delay phase, so the element would
|
|
103
|
+
// flash back to opacity 1 after the keyframes end.
|
|
104
|
+
expect(animate.mock.calls[0]![1]?.fill).toBe("forwards");
|
|
105
|
+
expect(animation).toBeTruthy();
|
|
106
|
+
expect(onFinished).not.toHaveBeenCalled();
|
|
107
|
+
|
|
108
|
+
resolveFinished();
|
|
109
|
+
await finished;
|
|
110
|
+
await Promise.resolve();
|
|
111
|
+
expect(onFinished).toHaveBeenCalledWith(animation);
|
|
112
|
+
host.remove();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("settles immediately without Web Animations support", () => {
|
|
116
|
+
const { host } = makeHost();
|
|
117
|
+
const onFinished = vi.fn();
|
|
118
|
+
expect(animateWelcomeOut(host, onFinished)).toBeNull();
|
|
119
|
+
expect(onFinished).toHaveBeenCalledWith(null);
|
|
120
|
+
});
|
|
121
|
+
});
|