@runtypelabs/persona 4.15.0 → 4.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 (91) hide show
  1. package/dist/codegen.cjs +1 -1
  2. package/dist/codegen.js +1 -1
  3. package/dist/context-mentions.d.cts +283 -9
  4. package/dist/context-mentions.d.ts +283 -9
  5. package/dist/index.cjs +68 -68
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +299 -10
  8. package/dist/index.d.ts +299 -10
  9. package/dist/index.global.js +57 -57
  10. package/dist/index.global.js.map +1 -1
  11. package/dist/index.js +69 -69
  12. package/dist/index.js.map +1 -1
  13. package/dist/install.global.js +1 -1
  14. package/dist/install.global.js.map +1 -1
  15. package/dist/launcher.global.js +2 -2
  16. package/dist/launcher.global.js.map +1 -1
  17. package/dist/markdown-parsers.js +36 -36
  18. package/dist/smart-dom-reader.d.cts +283 -9
  19. package/dist/smart-dom-reader.d.ts +283 -9
  20. package/dist/testing.cjs +3 -3
  21. package/dist/testing.d.cts +7 -0
  22. package/dist/testing.d.ts +7 -0
  23. package/dist/testing.js +3 -3
  24. package/dist/theme-editor-preview.cjs +64 -64
  25. package/dist/theme-editor-preview.d.cts +283 -9
  26. package/dist/theme-editor-preview.d.ts +283 -9
  27. package/dist/theme-editor-preview.js +56 -56
  28. package/dist/theme-editor.cjs +10 -10
  29. package/dist/theme-editor.d.cts +283 -10
  30. package/dist/theme-editor.d.ts +283 -10
  31. package/dist/theme-editor.js +10 -10
  32. package/dist/theme-reference.cjs +1 -1
  33. package/dist/theme-reference.d.cts +66 -3
  34. package/dist/theme-reference.d.ts +66 -3
  35. package/dist/theme-reference.js +1 -1
  36. package/dist/widget.css +1 -1
  37. package/package.json +2 -2
  38. package/src/components/artifact-pane.test.ts +27 -0
  39. package/src/components/artifact-pane.ts +7 -3
  40. package/src/components/header-builder.ts +27 -2
  41. package/src/components/header-layouts.test.ts +56 -0
  42. package/src/components/header-layouts.ts +29 -9
  43. package/src/components/launcher-surface.test.ts +403 -0
  44. package/src/components/launcher.ts +297 -2
  45. package/src/components/panel.ts +69 -74
  46. package/src/components/suggestions.test.ts +40 -0
  47. package/src/components/suggestions.ts +213 -153
  48. package/src/components/welcome.test.ts +121 -0
  49. package/src/components/welcome.ts +305 -0
  50. package/src/components/widget-view.test.ts +5 -3
  51. package/src/components/widget-view.ts +14 -7
  52. package/src/defaults.ts +2 -2
  53. package/src/index-core.ts +12 -1
  54. package/src/install.test.ts +35 -1
  55. package/src/install.ts +47 -11
  56. package/src/launcher-global.ts +10 -7
  57. package/src/plugins/types.ts +66 -1
  58. package/src/styles/widget-styles.test.ts +220 -0
  59. package/src/styles/widget.css +353 -9
  60. package/src/testing/mock-stream.test.ts +19 -0
  61. package/src/testing/mock-stream.ts +20 -0
  62. package/src/theme-editor/sections.ts +30 -1
  63. package/src/theme-reference.ts +26 -4
  64. package/src/types/theme.ts +40 -3
  65. package/src/types.ts +164 -6
  66. package/src/ui.body-top-slot.test.ts +95 -0
  67. package/src/ui.content-width.test.ts +91 -0
  68. package/src/ui.docked.test.ts +7 -4
  69. package/src/ui.launcher-teaser.test.ts +162 -0
  70. package/src/ui.rebuild-composer.test.ts +491 -0
  71. package/src/ui.render-welcome.test.ts +505 -0
  72. package/src/ui.scroll-additive.test.ts +91 -21
  73. package/src/ui.scroll.test.ts +302 -14
  74. package/src/ui.suggest-replies.test.ts +46 -0
  75. package/src/ui.ts +747 -136
  76. package/src/ui.welcome.test.ts +504 -0
  77. package/src/utils/artifact-gate.test.ts +27 -0
  78. package/src/utils/artifact-gate.ts +9 -0
  79. package/src/utils/attachment-manager.test.ts +37 -0
  80. package/src/utils/attachment-manager.ts +15 -0
  81. package/src/utils/auto-follow.test.ts +23 -0
  82. package/src/utils/auto-follow.ts +11 -1
  83. package/src/utils/config-merge.ts +2 -0
  84. package/src/utils/content-width.test.ts +37 -0
  85. package/src/utils/content-width.ts +17 -0
  86. package/src/utils/plugin-storage.test.ts +94 -0
  87. package/src/utils/plugin-storage.ts +98 -0
  88. package/src/utils/theme.test.ts +137 -0
  89. package/src/utils/tokens.ts +49 -3
  90. package/src/welcome.test.ts +213 -0
  91. 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 itemBehavior = item.behavior;
302
- const select = () => {
303
- if (session.isStreaming()) return;
304
- const detail = {
305
- suggestion: { ...item },
306
- surface,
307
- source,
308
- behavior: itemBehavior,
309
- };
310
- const shouldContinue = container.dispatchEvent(
311
- new CustomEvent("persona:suggestion:selected", {
312
- detail,
313
- bubbles: true,
314
- composed: true,
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
+ });