@runtypelabs/persona 4.14.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.
Files changed (119) hide show
  1. package/README.md +4 -4
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
  5. package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-QJ4LV2KW.js +1 -0
  9. package/dist/codegen.cjs +12 -11
  10. package/dist/codegen.js +14 -13
  11. package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
  12. package/dist/context-mentions.cjs +11 -8
  13. package/dist/context-mentions.d.cts +520 -21
  14. package/dist/context-mentions.d.ts +520 -21
  15. package/dist/context-mentions.js +11 -8
  16. package/dist/index.cjs +69 -68
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +595 -46
  19. package/dist/index.d.ts +595 -46
  20. package/dist/index.global.js +60 -57
  21. package/dist/index.global.js.map +1 -1
  22. package/dist/index.js +69 -68
  23. package/dist/index.js.map +1 -1
  24. package/dist/launcher.global.js +5 -2
  25. package/dist/launcher.global.js.map +1 -1
  26. package/dist/smart-dom-reader.d.cts +520 -21
  27. package/dist/smart-dom-reader.d.ts +520 -21
  28. package/dist/testing.cjs +3 -3
  29. package/dist/testing.d.cts +7 -0
  30. package/dist/testing.d.ts +7 -0
  31. package/dist/testing.js +3 -3
  32. package/dist/theme-editor-preview.cjs +67 -67
  33. package/dist/theme-editor-preview.d.cts +534 -22
  34. package/dist/theme-editor-preview.d.ts +534 -22
  35. package/dist/theme-editor-preview.js +57 -57
  36. package/dist/theme-editor.cjs +10 -10
  37. package/dist/theme-editor.d.cts +521 -23
  38. package/dist/theme-editor.d.ts +521 -23
  39. package/dist/theme-editor.js +10 -10
  40. package/dist/theme-reference.cjs +1 -1
  41. package/dist/theme-reference.d.cts +104 -3
  42. package/dist/theme-reference.d.ts +104 -3
  43. package/dist/theme-reference.js +1 -1
  44. package/dist/widget.css +1 -1
  45. package/package.json +1 -1
  46. package/src/ask-user-question-tool.ts +10 -7
  47. package/src/components/artifact-pane.test.ts +27 -0
  48. package/src/components/artifact-pane.ts +7 -3
  49. package/src/components/artifact-preview.ts +1 -1
  50. package/src/components/composer-parts.test.ts +103 -4
  51. package/src/components/composer-parts.ts +53 -15
  52. package/src/components/header-builder.ts +27 -2
  53. package/src/components/header-layouts.test.ts +56 -0
  54. package/src/components/header-layouts.ts +29 -9
  55. package/src/components/launcher-surface.test.ts +403 -0
  56. package/src/components/launcher.ts +297 -2
  57. package/src/components/messages.ts +5 -2
  58. package/src/components/panel.ts +92 -71
  59. package/src/components/pill-composer-builder.test.ts +18 -0
  60. package/src/components/suggestions.test.ts +273 -0
  61. package/src/components/suggestions.ts +528 -86
  62. package/src/components/welcome.test.ts +121 -0
  63. package/src/components/welcome.ts +305 -0
  64. package/src/components/widget-view.test.ts +5 -3
  65. package/src/components/widget-view.ts +18 -7
  66. package/src/defaults.ts +7 -5
  67. package/src/generated/runtype-openapi-contract.ts +17 -0
  68. package/src/index-core.ts +29 -2
  69. package/src/launcher-global.ts +11 -8
  70. package/src/plugins/types.ts +136 -3
  71. package/src/presets.ts +3 -3
  72. package/src/session.ts +10 -8
  73. package/src/styles/widget-styles.test.ts +382 -0
  74. package/src/styles/widget.css +778 -4
  75. package/src/suggest-replies-tool.test.ts +233 -2
  76. package/src/suggest-replies-tool.ts +115 -39
  77. package/src/testing/mock-stream.test.ts +19 -0
  78. package/src/testing/mock-stream.ts +20 -0
  79. package/src/theme-editor/preview-utils.test.ts +34 -0
  80. package/src/theme-editor/preview-utils.ts +77 -2
  81. package/src/theme-editor/sections.test.ts +33 -0
  82. package/src/theme-editor/sections.ts +83 -6
  83. package/src/theme-reference.ts +34 -5
  84. package/src/types/theme.ts +72 -3
  85. package/src/types.ts +312 -18
  86. package/src/ui.body-top-slot.test.ts +95 -0
  87. package/src/ui.content-width.test.ts +91 -0
  88. package/src/ui.detached-panel.test.ts +1 -1
  89. package/src/ui.docked.test.ts +7 -4
  90. package/src/ui.launcher-teaser.test.ts +162 -0
  91. package/src/ui.rebuild-composer.test.ts +491 -0
  92. package/src/ui.render-welcome.test.ts +505 -0
  93. package/src/ui.scroll-additive.test.ts +91 -21
  94. package/src/ui.scroll.test.ts +302 -14
  95. package/src/ui.suggest-replies.test.ts +1112 -1
  96. package/src/ui.ts +1008 -149
  97. package/src/ui.welcome.test.ts +504 -0
  98. package/src/utils/artifact-file.test.ts +1 -1
  99. package/src/utils/artifact-gate.test.ts +27 -0
  100. package/src/utils/artifact-gate.ts +9 -0
  101. package/src/utils/attachment-manager.test.ts +37 -0
  102. package/src/utils/attachment-manager.ts +15 -0
  103. package/src/utils/auto-follow.test.ts +23 -0
  104. package/src/utils/auto-follow.ts +11 -1
  105. package/src/utils/code-generators.test.ts +40 -0
  106. package/src/utils/code-generators.ts +22 -0
  107. package/src/utils/config-merge.test.ts +24 -0
  108. package/src/utils/config-merge.ts +2 -0
  109. package/src/utils/content-width.test.ts +37 -0
  110. package/src/utils/content-width.ts +17 -0
  111. package/src/utils/icons.test.ts +26 -0
  112. package/src/utils/icons.ts +8 -0
  113. package/src/utils/plugin-storage.test.ts +94 -0
  114. package/src/utils/plugin-storage.ts +98 -0
  115. package/src/utils/theme.test.ts +216 -0
  116. package/src/utils/tokens.ts +113 -3
  117. package/src/welcome.test.ts +213 -0
  118. package/src/welcome.ts +130 -0
  119. package/dist/chunk-MMUPR2JW.js +0 -1
@@ -9,7 +9,10 @@ import {
9
9
  isAskUserQuestionMessage,
10
10
  removeAskUserQuestionSheet,
11
11
  } from "./ask-user-question-bubble";
12
- import { isSuggestRepliesMessage } from "../suggest-replies-tool";
12
+ import {
13
+ isSuggestRepliesMessage,
14
+ resolveFollowUpsFeature,
15
+ } from "../suggest-replies-tool";
13
16
 
14
17
  export const renderMessages = (
15
18
  container: HTMLElement,
@@ -43,7 +46,7 @@ export const renderMessages = (
43
46
  return;
44
47
  } else if (
45
48
  isSuggestRepliesMessage(message) &&
46
- config?.features?.suggestReplies?.enabled !== false
49
+ resolveFollowUpsFeature(config).enabled
47
50
  ) {
48
51
  // No transcript bubble: the chips above the composer are the only UI.
49
52
  // When the feature is disabled the message falls through to the generic
@@ -1,6 +1,7 @@
1
1
  import { createElement, createNode } from "../utils/dom";
2
2
  import { DEFAULT_FLOATING_LAUNCHER_WIDTH } from "../defaults";
3
- import { AgentWidgetConfig } from "../types";
3
+ import { AgentWidgetConfig, AgentWidgetSuggestionSurface } from "../types";
4
+ import { suggestionSurfaceAttr } from "./suggestions";
4
5
  import { positionMap } from "../utils/positioning";
5
6
  import { isComposerBarMountMode, isDockedMountMode } from "../utils/dock";
6
7
  import { DEFAULT_OVERLAY_Z_INDEX } from "../utils/constants";
@@ -9,6 +10,16 @@ import { buildHeaderWithLayout } from "./header-layouts";
9
10
  import { createCloseButton, createClearChatButton } from "./header-parts";
10
11
  import { buildComposer, ComposerElements } from "./composer-builder";
11
12
  import { buildPillComposer, buildPillPeekBanner } from "./pill-composer-builder";
13
+ import {
14
+ applyWelcomeConfig,
15
+ applyWelcomeVisibility,
16
+ buildGreetingHost,
17
+ buildWelcomeHost,
18
+ renderWelcomeGreeting,
19
+ WelcomeHostElements,
20
+ } from "./welcome";
21
+ import { isWelcomeVisible, resolveWelcomeConfig } from "../welcome";
22
+ import { resolveContentMaxWidth } from "../utils/content-width";
12
23
 
13
24
  export interface PanelWrapper {
14
25
  wrapper: HTMLElement;
@@ -132,6 +143,10 @@ export interface PanelElements {
132
143
  container: HTMLElement;
133
144
  body: HTMLElement;
134
145
  messagesWrapper: HTMLElement;
146
+ /** Starter prompts rendered inside the welcome card. */
147
+ starterSuggestions: HTMLElement;
148
+ /** Follow-up suggestions rendered after the transcript. */
149
+ transcriptSuggestions: HTMLElement;
135
150
  /**
136
151
  * Absolute-positioned slot above the composer footer. Interactive sheets
137
152
  * (e.g. the answer-pill sheet for the ask_user_question tool) mount here
@@ -146,6 +161,11 @@ export interface PanelElements {
146
161
  micButtonWrapper: HTMLElement | null;
147
162
  composerForm: HTMLFormElement;
148
163
  statusText: HTMLElement;
164
+ /** Permanent welcome host: variant and visibility change on it in place. */
165
+ welcomeHost: HTMLElement;
166
+ welcomeIconHolder: HTMLElement;
167
+ /** Display-only greeting bubble host, pinned above the transcript. */
168
+ greetingHost: HTMLElement;
149
169
  introTitle: HTMLElement;
150
170
  introSubtitle: HTMLElement;
151
171
  closeButton: HTMLButtonElement;
@@ -178,6 +198,41 @@ export interface PanelElements {
178
198
  peekTextNode?: HTMLElement;
179
199
  }
180
200
 
201
+ const createSuggestionHost = (
202
+ surface: AgentWidgetSuggestionSurface
203
+ ): HTMLElement => {
204
+ const host = createNode("div", {
205
+ className: "persona-suggestions",
206
+ attrs: { "data-persona-suggestions": suggestionSurfaceAttr(surface) },
207
+ });
208
+ host.hidden = true;
209
+ return host;
210
+ };
211
+
212
+ /**
213
+ * Welcome surface for either panel builder: permanent host plus the greeting
214
+ * host that sits between it and the transcript.
215
+ */
216
+ const buildWelcomeSurface = (
217
+ config: AgentWidgetConfig | undefined,
218
+ body: HTMLElement
219
+ ): { welcome: WelcomeHostElements; greetingHost: HTMLElement } => {
220
+ const starterSuggestions = createSuggestionHost("starter");
221
+ const welcome = buildWelcomeHost(config, starterSuggestions, {
222
+ flatShadow: isDockedMountMode(config),
223
+ });
224
+ const resolved = resolveWelcomeConfig(config);
225
+ applyWelcomeConfig(welcome, resolved);
226
+ applyWelcomeVisibility(
227
+ body,
228
+ welcome.host,
229
+ isWelcomeVisible(resolved, config?.initialMessages)
230
+ );
231
+ const greetingHost = buildGreetingHost();
232
+ renderWelcomeGreeting(greetingHost, resolved.message, config);
233
+ return { welcome, greetingHost };
234
+ };
235
+
181
236
  /**
182
237
  * Composer-bar panel: minimal close-only header (× in the top-right of the
183
238
  * chat chrome). The pill (`footer`) and `peekBanner` are NOT children of
@@ -266,35 +321,17 @@ const buildComposerBarPanel = (
266
321
  // when streaming content first overflows and the scrollbar appears.
267
322
  body.style.setProperty("scrollbar-gutter", "stable");
268
323
 
269
- const introTitle = createNode("h2", {
270
- className: "persona-text-lg persona-font-semibold persona-text-persona-primary",
271
- text: config?.copy?.welcomeTitle ?? "Hello 👋",
272
- });
273
- const introSubtitle = createNode("p", {
274
- className: "persona-mt-2 persona-text-sm persona-text-persona-muted",
275
- text:
276
- config?.copy?.welcomeSubtitle ??
277
- "Ask anything about your account or products.",
278
- });
279
- const introCard = createNode(
280
- "div",
281
- {
282
- className: "persona-rounded-2xl persona-p-6",
283
- attrs: { "data-persona-intro-card": "" },
284
- style: {
285
- background: "var(--persona-intro-card-bg, transparent)",
286
- boxShadow: "var(--persona-intro-card-shadow, none)",
287
- },
288
- },
289
- introTitle,
290
- introSubtitle
291
- );
324
+ const { welcome, greetingHost } = buildWelcomeSurface(config, body);
325
+ const { host: welcomeHost, title: introTitle, subtitle: introSubtitle } =
326
+ welcome;
327
+ const starterSuggestions = welcome.starterSuggestions;
292
328
 
293
329
  const messagesWrapper = createElement(
294
330
  "div",
295
- "persona-flex persona-flex-col persona-gap-3"
331
+ "persona-widget-messages persona-flex persona-flex-col persona-gap-3"
296
332
  );
297
- const contentMaxWidth = config?.layout?.contentMaxWidth;
333
+ const transcriptSuggestions = createSuggestionHost("followUp");
334
+ const contentMaxWidth = resolveContentMaxWidth(config, true);
298
335
  if (contentMaxWidth) {
299
336
  messagesWrapper.style.maxWidth = contentMaxWidth;
300
337
  messagesWrapper.style.marginLeft = "auto";
@@ -302,13 +339,12 @@ const buildComposerBarPanel = (
302
339
  messagesWrapper.style.width = "100%";
303
340
  }
304
341
 
305
- const showWelcomeCard = config?.copy?.showWelcomeCard !== false;
306
- if (!showWelcomeCard) {
307
- introCard.style.display = "none";
308
- body.classList.remove("persona-gap-6");
309
- body.classList.add("persona-gap-3");
310
- }
311
- body.append(introCard, messagesWrapper);
342
+ body.append(
343
+ welcomeHost,
344
+ greetingHost,
345
+ messagesWrapper,
346
+ transcriptSuggestions
347
+ );
312
348
 
313
349
  // Composer overlay (interactive sheets like ask_user_question slide up here).
314
350
  // Anchored to the bottom of the container (which is `position: relative`),
@@ -338,6 +374,8 @@ const buildComposerBarPanel = (
338
374
  container,
339
375
  body,
340
376
  messagesWrapper,
377
+ starterSuggestions,
378
+ transcriptSuggestions,
341
379
  composerOverlay,
342
380
  suggestions: composerElements.suggestions,
343
381
  textarea: composerElements.textarea,
@@ -347,6 +385,9 @@ const buildComposerBarPanel = (
347
385
  micButtonWrapper: composerElements.micButtonWrapper,
348
386
  composerForm: composerElements.composerForm,
349
387
  statusText: composerElements.statusText,
388
+ welcomeHost,
389
+ welcomeIconHolder: welcome.iconHolder,
390
+ greetingHost,
350
391
  introTitle,
351
392
  introSubtitle,
352
393
  closeButton,
@@ -403,42 +444,18 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
403
444
  // when streaming content first overflows and the scrollbar appears.
404
445
  body.style.setProperty("scrollbar-gutter", "stable");
405
446
 
406
- const introTitle = createNode("h2", {
407
- className: "persona-text-lg persona-font-semibold persona-text-persona-primary",
408
- text: config?.copy?.welcomeTitle ?? "Hello 👋",
409
- });
410
- const introSubtitle = createNode("p", {
411
- className: "persona-mt-2 persona-text-sm persona-text-persona-muted",
412
- text:
413
- config?.copy?.welcomeSubtitle ??
414
- "Ask anything about your account or products.",
415
- });
416
- // Background and box-shadow flow through the themable `components.introCard`
417
- // tokens (--persona-intro-card-bg / --persona-intro-card-shadow); both
418
- // default to flat (transparent / none). Docked mode always stays flat,
419
- // even when a theme sets an introCard shadow.
420
- const introCard = createNode(
421
- "div",
422
- {
423
- className: "persona-rounded-2xl persona-p-6",
424
- attrs: { "data-persona-intro-card": "" },
425
- style: {
426
- background: "var(--persona-intro-card-bg, transparent)",
427
- boxShadow: isDockedMountMode(config)
428
- ? "none"
429
- : "var(--persona-intro-card-shadow, none)",
430
- },
431
- },
432
- introTitle,
433
- introSubtitle
434
- );
447
+ const { welcome, greetingHost } = buildWelcomeSurface(config, body);
448
+ const { host: welcomeHost, title: introTitle, subtitle: introSubtitle } =
449
+ welcome;
450
+ const starterSuggestions = welcome.starterSuggestions;
435
451
 
436
452
  const messagesWrapper = createElement(
437
453
  "div",
438
- "persona-flex persona-flex-col persona-gap-3"
454
+ "persona-widget-messages persona-flex persona-flex-col persona-gap-3"
439
455
  );
456
+ const transcriptSuggestions = createSuggestionHost("followUp");
440
457
 
441
- const contentMaxWidth = config?.layout?.contentMaxWidth;
458
+ const contentMaxWidth = resolveContentMaxWidth(config, false);
442
459
  if (contentMaxWidth) {
443
460
  messagesWrapper.style.maxWidth = contentMaxWidth;
444
461
  messagesWrapper.style.marginLeft = "auto";
@@ -446,13 +463,12 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
446
463
  messagesWrapper.style.width = "100%";
447
464
  }
448
465
 
449
- const showWelcomeCard = config?.copy?.showWelcomeCard !== false;
450
- if (!showWelcomeCard) {
451
- introCard.style.display = "none";
452
- body.classList.remove("persona-gap-6");
453
- body.classList.add("persona-gap-3");
454
- }
455
- body.append(introCard, messagesWrapper);
466
+ body.append(
467
+ welcomeHost,
468
+ greetingHost,
469
+ messagesWrapper,
470
+ transcriptSuggestions
471
+ );
456
472
 
457
473
  // composer-bar mode early-returned above with its own pill builder; this
458
474
  // path always uses the standard column-stacked composer card.
@@ -500,6 +516,8 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
500
516
  container,
501
517
  body,
502
518
  messagesWrapper,
519
+ starterSuggestions,
520
+ transcriptSuggestions,
503
521
  composerOverlay,
504
522
  suggestions: composerElements.suggestions,
505
523
  textarea: composerElements.textarea,
@@ -509,6 +527,9 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
509
527
  micButtonWrapper: composerElements.micButtonWrapper,
510
528
  composerForm: composerElements.composerForm,
511
529
  statusText: composerElements.statusText,
530
+ welcomeHost,
531
+ welcomeIconHolder: welcome.iconHolder,
532
+ greetingHost,
512
533
  introTitle,
513
534
  introSubtitle,
514
535
  closeButton: headerElements.closeButton,
@@ -79,6 +79,24 @@ describe("buildPillComposer (single-row pill composer)", () => {
79
79
  expect(formChildren[2]).toBe(elements.rightActions);
80
80
  });
81
81
 
82
+ it("keeps its 100px max-height cap, even under a themed line height", () => {
83
+ const elements = buildPillComposer({ config: { apiUrl: "/api" } });
84
+ expect(elements.textarea.style.maxHeight).toBe("100px");
85
+
86
+ // A themed line height must not re-derive the cap: the pill's explicit
87
+ // post-construction override wins over the 3-line default.
88
+ elements.textarea.style.lineHeight = "24px";
89
+ document.body.appendChild(elements.textarea);
90
+ Object.defineProperty(elements.textarea, "scrollHeight", {
91
+ configurable: true,
92
+ value: 10000,
93
+ });
94
+ elements.textarea.dispatchEvent(new Event("input"));
95
+ expect(elements.textarea.style.maxHeight).toBe("100px");
96
+ expect(elements.textarea.style.height).toBe("100px");
97
+ document.body.removeChild(elements.textarea);
98
+ });
99
+
82
100
  it("returns null for optional controls when disabled (matching ComposerElements contract)", () => {
83
101
  const elements = buildPillComposer({ config: { apiUrl: "/api" } });
84
102
  expect(elements.attachmentButton).toBeNull();
@@ -0,0 +1,273 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createSuggestions } from "./suggestions";
6
+ import type { AgentWidgetSession } from "../session";
7
+
8
+ const makeSession = (): AgentWidgetSession =>
9
+ ({
10
+ isStreaming: () => false,
11
+ getMessages: () => [],
12
+ sendMessage: () => {},
13
+ }) as unknown as AgentWidgetSession;
14
+
15
+ const setup = () => {
16
+ const container = document.createElement("div");
17
+ document.body.appendChild(container);
18
+ const textarea = document.createElement("textarea");
19
+ document.body.appendChild(textarea);
20
+ return {
21
+ container,
22
+ textarea,
23
+ manager: createSuggestions(container),
24
+ session: makeSession(),
25
+ };
26
+ };
27
+
28
+ const withAnimate = (): ReturnType<typeof vi.fn> => {
29
+ const animate = vi.fn(() => ({}));
30
+ Object.defineProperty(Element.prototype, "animate", {
31
+ value: animate,
32
+ configurable: true,
33
+ writable: true,
34
+ });
35
+ return animate;
36
+ };
37
+
38
+ afterEach(() => {
39
+ Reflect.deleteProperty(Element.prototype, "animate");
40
+ vi.unstubAllGlobals();
41
+ document.body.replaceChildren();
42
+ });
43
+
44
+ describe("suggestion entrance animation", () => {
45
+ it("animates each item once for a newly shown set", () => {
46
+ const animate = withAnimate();
47
+ const { manager, session, textarea } = setup();
48
+
49
+ manager.render(["One", "Two"], session, textarea, [], undefined, {
50
+ variant: "card",
51
+ });
52
+
53
+ expect(animate).toHaveBeenCalledTimes(2);
54
+ const [, secondOptions] = animate.mock.calls[1] as [
55
+ unknown,
56
+ { delay: number; duration: number; fill: string },
57
+ ];
58
+ expect(secondOptions.delay).toBe(60);
59
+ expect(secondOptions.duration).toBe(250);
60
+ expect(secondOptions.fill).toBe("backwards");
61
+ });
62
+
63
+ it("does not re-animate when render repeats the same items", () => {
64
+ const animate = withAnimate();
65
+ const { manager, session, textarea } = setup();
66
+ const items = ["One", "Two"];
67
+
68
+ manager.render(items, session, textarea, [], undefined, {
69
+ variant: "card",
70
+ });
71
+ manager.render(items, session, textarea, [], undefined, {
72
+ variant: "card",
73
+ });
74
+
75
+ expect(animate).toHaveBeenCalledTimes(2);
76
+ });
77
+
78
+ it("animates again once the item set changes", () => {
79
+ const animate = withAnimate();
80
+ const { manager, session, textarea } = setup();
81
+
82
+ manager.render(["One"], session, textarea, [], undefined, {
83
+ variant: "card",
84
+ });
85
+ manager.render(["Two"], session, textarea, [], undefined, {
86
+ variant: "card",
87
+ });
88
+
89
+ expect(animate).toHaveBeenCalledTimes(2);
90
+ });
91
+
92
+ it("skips the animation under prefers-reduced-motion", () => {
93
+ const animate = withAnimate();
94
+ vi.stubGlobal(
95
+ "matchMedia",
96
+ vi.fn(() => ({ matches: true }))
97
+ );
98
+ const { manager, session, textarea } = setup();
99
+
100
+ manager.render(["One", "Two"], session, textarea, [], undefined, {
101
+ variant: "card",
102
+ });
103
+
104
+ expect(animate).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it("still renders when the platform has no Web Animations API", () => {
108
+ const { container, manager, session, textarea } = setup();
109
+
110
+ expect(typeof Element.prototype.animate).toBe("undefined");
111
+ expect(() =>
112
+ manager.render(["One", "Two"], session, textarea, [], undefined, {
113
+ variant: "card",
114
+ })
115
+ ).not.toThrow();
116
+ expect(container.querySelectorAll(".persona-suggestion")).toHaveLength(2);
117
+ });
118
+ });
119
+
120
+ describe("per-item icon color", () => {
121
+ const iconOf = (container: HTMLElement): SVGElement =>
122
+ container.querySelector(".persona-suggestion__icon") as SVGElement;
123
+
124
+ it("tints only the icon element", () => {
125
+ const { manager, session, textarea, container } = setup();
126
+
127
+ manager.render(
128
+ [{ label: "Compare plans", icon: "dollar-sign", iconColor: "#16a34a" }],
129
+ session,
130
+ textarea,
131
+ [],
132
+ undefined,
133
+ { variant: "card" }
134
+ );
135
+
136
+ const button = container.querySelector("button") as HTMLButtonElement;
137
+ expect(iconOf(container).style.color).toBe("rgb(22, 163, 74)");
138
+ expect(button.style.color).toBe("");
139
+ expect(button.style.borderColor).toBe("");
140
+ expect(button.style.background).toBe("");
141
+ expect(
142
+ (container.querySelector(".persona-suggestion__label") as HTMLElement)
143
+ .style.color
144
+ ).toBe("");
145
+ });
146
+
147
+ it("wins over the emphasis-primary icon tint without recoloring the button", () => {
148
+ const { manager, session, textarea, container } = setup();
149
+
150
+ manager.render(
151
+ [
152
+ {
153
+ label: "Compare plans",
154
+ icon: "dollar-sign",
155
+ iconColor: "#16a34a",
156
+ emphasis: "primary",
157
+ },
158
+ ],
159
+ session,
160
+ textarea,
161
+ [],
162
+ undefined,
163
+ { variant: "card" }
164
+ );
165
+
166
+ const button = container.querySelector("button") as HTMLButtonElement;
167
+ expect(button.dataset.emphasis).toBe("primary");
168
+ expect(iconOf(container).style.color).toBe("rgb(22, 163, 74)");
169
+ });
170
+
171
+ it("leaves the icon in currentColor when iconColor is absent", () => {
172
+ const { manager, session, textarea, container } = setup();
173
+
174
+ manager.render(
175
+ [{ label: "Compare plans", icon: "dollar-sign" }],
176
+ session,
177
+ textarea,
178
+ [],
179
+ undefined,
180
+ { variant: "card" }
181
+ );
182
+
183
+ const icon = iconOf(container);
184
+ expect(icon.style.color).toBe("");
185
+ expect(icon.getAttribute("stroke")).toBe("currentColor");
186
+ });
187
+
188
+ it("leaves string shorthand items unaffected", () => {
189
+ const { manager, session, textarea, container } = setup();
190
+
191
+ manager.render(["Compare plans"], session, textarea, [], undefined, {
192
+ variant: "card",
193
+ });
194
+
195
+ expect(container.querySelector(".persona-suggestion__icon")).toBeNull();
196
+ const button = container.querySelector("button") as HTMLButtonElement;
197
+ expect(button.style.color).toBe("");
198
+ });
199
+ });
200
+
201
+ describe("description rendering per variant", () => {
202
+ const items = [
203
+ {
204
+ label: "Track an order",
205
+ description: "Look up status by order number",
206
+ },
207
+ ];
208
+
209
+ it("keeps the chip variant to a single-line label", () => {
210
+ const { manager, session, textarea, container } = setup();
211
+
212
+ manager.render(items, session, textarea, [], undefined, {
213
+ variant: "chip",
214
+ });
215
+
216
+ expect(
217
+ container.querySelector(".persona-suggestion__description")
218
+ ).toBeNull();
219
+ expect(
220
+ (container.querySelector(".persona-suggestion__label") as HTMLElement)
221
+ .textContent
222
+ ).toBe("Track an order");
223
+ });
224
+
225
+ it("renders the description in the card and list variants", () => {
226
+ const { manager, session, textarea, container } = setup();
227
+
228
+ for (const variant of ["card", "list"] as const) {
229
+ manager.render(items, session, textarea, [], undefined, { variant });
230
+ expect(
231
+ (
232
+ container.querySelector(
233
+ ".persona-suggestion__description"
234
+ ) as HTMLElement
235
+ ).textContent
236
+ ).toBe("Look up status by order number");
237
+ }
238
+ });
239
+ });
240
+
241
+ describe("legacy suggestionChipsConfig scoping", () => {
242
+ const chipsConfig = {
243
+ fontFamily: "sans-serif" as const,
244
+ fontWeight: "500",
245
+ paddingX: "12px",
246
+ paddingY: "6px",
247
+ };
248
+
249
+ it("applies inline chip styles to the chip variant", () => {
250
+ const { manager, session, textarea, container } = setup();
251
+
252
+ manager.render(["One"], session, textarea, [], chipsConfig, {
253
+ variant: "chip",
254
+ });
255
+
256
+ const button = container.querySelector("button") as HTMLButtonElement;
257
+ expect(button.style.paddingLeft).toBe("12px");
258
+ expect(button.style.paddingTop).toBe("6px");
259
+ expect(button.style.fontWeight).toBe("500");
260
+ });
261
+
262
+ it("does not let chip config override card and list padding tokens", () => {
263
+ const { manager, session, textarea, container } = setup();
264
+
265
+ for (const variant of ["card", "list"] as const) {
266
+ manager.render(["One"], session, textarea, [], chipsConfig, { variant });
267
+ const button = container.querySelector("button") as HTMLButtonElement;
268
+ expect(button.style.paddingLeft).toBe("");
269
+ expect(button.style.paddingTop).toBe("");
270
+ expect(button.style.fontFamily).toBe("");
271
+ }
272
+ });
273
+ });