@runtypelabs/persona 4.14.0 → 4.15.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 (81) 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 +241 -16
  14. package/dist/context-mentions.d.ts +241 -16
  15. package/dist/context-mentions.js +11 -8
  16. package/dist/index.cjs +67 -66
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +301 -41
  19. package/dist/index.d.ts +301 -41
  20. package/dist/index.global.js +59 -56
  21. package/dist/index.global.js.map +1 -1
  22. package/dist/index.js +67 -66
  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 +241 -16
  27. package/dist/smart-dom-reader.d.ts +241 -16
  28. package/dist/theme-editor-preview.cjs +66 -66
  29. package/dist/theme-editor-preview.d.cts +255 -17
  30. package/dist/theme-editor-preview.d.ts +255 -17
  31. package/dist/theme-editor-preview.js +56 -56
  32. package/dist/theme-editor.cjs +5 -5
  33. package/dist/theme-editor.d.cts +242 -17
  34. package/dist/theme-editor.d.ts +242 -17
  35. package/dist/theme-editor.js +7 -7
  36. package/dist/theme-reference.cjs +1 -1
  37. package/dist/theme-reference.d.cts +38 -0
  38. package/dist/theme-reference.d.ts +38 -0
  39. package/dist/theme-reference.js +1 -1
  40. package/dist/widget.css +1 -1
  41. package/package.json +1 -1
  42. package/src/ask-user-question-tool.ts +10 -7
  43. package/src/components/artifact-preview.ts +1 -1
  44. package/src/components/composer-parts.test.ts +103 -4
  45. package/src/components/composer-parts.ts +53 -15
  46. package/src/components/messages.ts +5 -2
  47. package/src/components/panel.ts +31 -5
  48. package/src/components/pill-composer-builder.test.ts +18 -0
  49. package/src/components/suggestions.test.ts +233 -0
  50. package/src/components/suggestions.ts +463 -81
  51. package/src/components/widget-view.ts +4 -0
  52. package/src/defaults.ts +5 -3
  53. package/src/generated/runtype-openapi-contract.ts +17 -0
  54. package/src/index-core.ts +18 -2
  55. package/src/launcher-global.ts +1 -1
  56. package/src/plugins/types.ts +71 -3
  57. package/src/presets.ts +3 -3
  58. package/src/session.ts +10 -8
  59. package/src/styles/widget-styles.test.ts +162 -0
  60. package/src/styles/widget.css +430 -0
  61. package/src/suggest-replies-tool.test.ts +233 -2
  62. package/src/suggest-replies-tool.ts +115 -39
  63. package/src/theme-editor/preview-utils.test.ts +34 -0
  64. package/src/theme-editor/preview-utils.ts +77 -2
  65. package/src/theme-editor/sections.test.ts +33 -0
  66. package/src/theme-editor/sections.ts +53 -5
  67. package/src/theme-reference.ts +9 -2
  68. package/src/types/theme.ts +32 -0
  69. package/src/types.ts +152 -16
  70. package/src/ui.detached-panel.test.ts +1 -1
  71. package/src/ui.suggest-replies.test.ts +1066 -1
  72. package/src/ui.ts +268 -20
  73. package/src/utils/artifact-file.test.ts +1 -1
  74. package/src/utils/code-generators.test.ts +40 -0
  75. package/src/utils/code-generators.ts +22 -0
  76. package/src/utils/config-merge.test.ts +24 -0
  77. package/src/utils/icons.test.ts +26 -0
  78. package/src/utils/icons.ts +8 -0
  79. package/src/utils/theme.test.ts +79 -0
  80. package/src/utils/tokens.ts +64 -0
  81. package/dist/chunk-MMUPR2JW.js +0 -1
@@ -44,8 +44,59 @@ describe("createComposerTextarea", () => {
44
44
  Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 10000 });
45
45
  textarea.dispatchEvent(new Event("input"));
46
46
  expect(textarea.style.height).toBe("200px");
47
+ // The override keeps winning on later input events (pill composer path).
48
+ textarea.dispatchEvent(new Event("input"));
49
+ expect(textarea.style.height).toBe("200px");
50
+ expect(textarea.style.maxHeight).toBe("200px");
51
+ document.body.removeChild(textarea);
52
+ });
53
+
54
+ it("caps at 3 lines of the rendered line height when themed", () => {
55
+ const { textarea, attachAutoResize } = createComposerTextarea(baseConfig);
56
+ textarea.style.lineHeight = "24px";
57
+ document.body.appendChild(textarea);
58
+ attachAutoResize();
59
+ Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 10000 });
60
+ textarea.dispatchEvent(new Event("input"));
61
+ expect(textarea.style.height).toBe("72px");
62
+ expect(textarea.style.maxHeight).toBe("72px");
47
63
  document.body.removeChild(textarea);
48
64
  });
65
+
66
+ it("defaults to 60px (3 × 20px) before any resize", () => {
67
+ const { textarea } = createComposerTextarea(baseConfig);
68
+ expect(textarea.style.maxHeight).toBe("60px");
69
+ expect(textarea.style.overflowY).toBe("auto");
70
+ });
71
+
72
+ it("falls back to 60px when the line height is `normal`", () => {
73
+ const { textarea, attachAutoResize } = createComposerTextarea(baseConfig);
74
+ textarea.style.lineHeight = "normal";
75
+ document.body.appendChild(textarea);
76
+ attachAutoResize();
77
+ Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 10000 });
78
+ textarea.dispatchEvent(new Event("input"));
79
+ expect(textarea.style.height).toBe("60px");
80
+ document.body.removeChild(textarea);
81
+ });
82
+
83
+ it("falls back to 60px for unitless line heights and detached nodes", () => {
84
+ const unitless = createComposerTextarea(baseConfig);
85
+ unitless.textarea.style.lineHeight = "1.5";
86
+ document.body.appendChild(unitless.textarea);
87
+ unitless.attachAutoResize();
88
+ Object.defineProperty(unitless.textarea, "scrollHeight", { configurable: true, value: 10000 });
89
+ unitless.textarea.dispatchEvent(new Event("input"));
90
+ expect(unitless.textarea.style.height).toBe("60px");
91
+ document.body.removeChild(unitless.textarea);
92
+
93
+ // Never appended: getComputedStyle still must not throw or produce NaN.
94
+ const detached = createComposerTextarea(baseConfig);
95
+ detached.attachAutoResize();
96
+ Object.defineProperty(detached.textarea, "scrollHeight", { configurable: true, value: 10000 });
97
+ detached.textarea.dispatchEvent(new Event("input"));
98
+ expect(detached.textarea.style.height).toBe("60px");
99
+ });
49
100
  });
50
101
 
51
102
  describe("createSendButton", () => {
@@ -97,10 +148,12 @@ describe("createSendButton", () => {
97
148
  expect(s.borderWidth).toBe("2px");
98
149
  expect(s.borderStyle).toBe("solid");
99
150
  expect(s.borderColor).toBe("rgb(7, 8, 9)");
100
- expect(s.paddingLeft).toBe("6px");
101
- expect(s.paddingRight).toBe("6px");
102
- expect(s.paddingTop).toBe("4px");
103
- expect(s.paddingBottom).toBe("4px");
151
+ // Icon mode zeroes padding even when configured: the glyph is drawn at
152
+ // the button size, so padding would crush it.
153
+ expect(s.paddingLeft).toBe("0px");
154
+ expect(s.paddingRight).toBe("0px");
155
+ expect(s.paddingTop).toBe("0px");
156
+ expect(s.paddingBottom).toBe("0px");
104
157
  // An explicit backgroundColor means the fallback class is not added.
105
158
  expect(send.button.classList.contains("persona-bg-persona-primary")).toBe(false);
106
159
  });
@@ -142,6 +195,52 @@ describe("createSendButton", () => {
142
195
  expect(iconCount(send.button)).toBe(1);
143
196
  });
144
197
 
198
+ it("zeroes padding so configured padding cannot crush the glyph", () => {
199
+ const send = createSendButton({
200
+ ...baseConfig,
201
+ sendButton: {
202
+ useIcon: true,
203
+ iconName: "send",
204
+ size: "40px",
205
+ paddingX: "12px",
206
+ paddingY: "10px",
207
+ },
208
+ });
209
+ expect(send.button.style.paddingLeft).toBe("0px");
210
+ expect(send.button.style.paddingTop).toBe("0px");
211
+ });
212
+
213
+ it("draws the glyph at half the button by default and honors iconSize", () => {
214
+ const send = createSendButton({
215
+ ...baseConfig,
216
+ sendButton: { useIcon: true, iconName: "send", size: "32px" },
217
+ });
218
+ expect(send.button.querySelector("svg")?.getAttribute("width")).toBe("16");
219
+
220
+ const sized = createSendButton({
221
+ ...baseConfig,
222
+ sendButton: {
223
+ useIcon: true,
224
+ iconName: "send",
225
+ size: "32px",
226
+ iconSize: "26px",
227
+ iconStrokeWidth: 2.5,
228
+ },
229
+ });
230
+ const svg = sized.button.querySelector("svg");
231
+ expect(svg?.getAttribute("width")).toBe("26");
232
+ expect(svg?.getAttribute("stroke-width")).toBe("2.5");
233
+ });
234
+
235
+ it("keeps configured padding in text mode", () => {
236
+ const send = createSendButton({
237
+ ...baseConfig,
238
+ sendButton: { useIcon: false, paddingX: "12px", paddingY: "10px" },
239
+ });
240
+ expect(send.button.style.paddingLeft).toBe("12px");
241
+ expect(send.button.style.paddingTop).toBe("10px");
242
+ });
243
+
145
244
  it("does not stack a stale icon when an external re-render swapped the live icon node", () => {
146
245
  const send = createSendButton(iconConfig);
147
246
  // Simulate a DOM morph/re-render (e.g. a host calling controller.update())
@@ -39,19 +39,47 @@ export const createComposerTextarea = (config?: AgentWidgetConfig): ComposerText
39
39
  'var(--persona-input-font-family, var(--persona-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif))';
40
40
  textarea.style.fontWeight = "var(--persona-input-font-weight, var(--persona-font-weight, 400))";
41
41
 
42
- // Auto-resize: expand up to 3 lines for the full composer (line-height ~20px
43
- // for text-sm). The pill composer overrides this maxHeight after construction
44
- // (allowing more growth in expanded mode), and the closure below honors
45
- // whatever maxHeight is set at the time of the input event.
42
+ // Auto-resize: expand up to 3 lines for the full composer. The pill composer
43
+ // overrides this maxHeight after construction (allowing more growth in
44
+ // expanded mode), and the closure below honors whatever maxHeight is set at
45
+ // the time of the input event.
46
46
  const defaultMaxLines = 3;
47
- const lineHeight = 20;
48
- textarea.style.maxHeight = `${defaultMaxLines * lineHeight}px`;
47
+ // Used only when the rendered line height can't be resolved (detached node,
48
+ // jsdom, `normal`); matches the `persona-text-sm` / composer default.
49
+ const fallbackLineHeight = 20;
50
+
51
+ // `theme.components.composer.lineHeight` (--persona-composer-line-height) can
52
+ // change the rendered line height, so 3 lines is only correct when measured
53
+ // from the live element. Read lazily: at construction the textarea is usually
54
+ // still detached and getComputedStyle would report nothing useful.
55
+ const readLineHeight = (): number => {
56
+ const view = textarea.ownerDocument?.defaultView;
57
+ if (!view?.getComputedStyle) return fallbackLineHeight;
58
+ // Only px values are meaningful here: `normal` and unitless ratios aren't
59
+ // line box heights we can multiply.
60
+ const computed = view.getComputedStyle(textarea).lineHeight;
61
+ if (!computed || !computed.trim().endsWith("px")) return fallbackLineHeight;
62
+ const parsed = parseFloat(computed);
63
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackLineHeight;
64
+ };
65
+
66
+ // Tracks the last maxHeight this module wrote, so a caller's post-construction
67
+ // override is distinguishable from our own default and always wins.
68
+ let managedMaxHeight = `${defaultMaxLines * fallbackLineHeight}px`;
69
+ textarea.style.maxHeight = managedMaxHeight;
49
70
  textarea.style.overflowY = "auto";
50
71
 
51
72
  // Read maxHeight at event time so callers can change it after construction.
52
73
  const readMaxHeight = (): number => {
53
- const parsed = parseFloat(textarea.style.maxHeight);
54
- return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultMaxLines * lineHeight;
74
+ const current = textarea.style.maxHeight;
75
+ if (current !== managedMaxHeight) {
76
+ const overridden = parseFloat(current);
77
+ if (Number.isFinite(overridden) && overridden > 0) return overridden;
78
+ }
79
+ const derived = defaultMaxLines * readLineHeight();
80
+ managedMaxHeight = `${derived}px`;
81
+ textarea.style.maxHeight = managedMaxHeight;
82
+ return derived;
55
83
  };
56
84
 
57
85
  const attachAutoResize = () => {
@@ -142,10 +170,13 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
142
170
  borderWidth: sendButtonConfig.borderWidth || undefined,
143
171
  borderStyle: sendButtonConfig.borderWidth ? "solid" : undefined,
144
172
  borderColor: sendButtonConfig.borderColor || undefined,
145
- paddingLeft: sendButtonConfig.paddingX || undefined,
146
- paddingRight: sendButtonConfig.paddingX || undefined,
147
- paddingTop: sendButtonConfig.paddingY || undefined,
148
- paddingBottom: sendButtonConfig.paddingY || undefined,
173
+ // Padding is text-mode-only: icon mode is a fixed `size` box with the
174
+ // glyph drawn at that size, so padding just crushes the SVG (the
175
+ // always-present defaults squeezed a 40px glyph into 16x20).
176
+ paddingLeft: useIcon ? "0" : sendButtonConfig.paddingX || undefined,
177
+ paddingRight: useIcon ? "0" : sendButtonConfig.paddingX || undefined,
178
+ paddingTop: useIcon ? "0" : sendButtonConfig.paddingY || undefined,
179
+ paddingBottom: useIcon ? "0" : sendButtonConfig.paddingY || undefined,
149
180
  },
150
181
  });
151
182
 
@@ -154,11 +185,18 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
154
185
  let stopIcon: SVGElement | null = null;
155
186
 
156
187
  if (useIcon) {
157
- const iconSize = parseFloat(buttonSize) || 24;
188
+ // Default glyph box is half the button, the closest clean ratio to the
189
+ // pre-fix live rendering (a 40px glyph crushed to a ~16px content box).
190
+ // Dense glyphs like the default "send" plane fill most of their viewBox;
191
+ // sparse ones (arrow-up) may want a larger explicit `iconSize`.
192
+ const iconSize =
193
+ parseFloat(sendButtonConfig.iconSize ?? "") ||
194
+ Math.round((parseFloat(buttonSize) || 24) * 0.5);
195
+ const iconStroke = sendButtonConfig.iconStrokeWidth ?? 2;
158
196
  const iconColor = textColor?.trim() || "currentColor";
159
197
 
160
198
  if (iconName) {
161
- sendIcon = renderLucideIcon(iconName, iconSize, iconColor, 2);
199
+ sendIcon = renderLucideIcon(iconName, iconSize, iconColor, iconStroke);
162
200
  if (sendIcon) {
163
201
  button.appendChild(sendIcon);
164
202
  } else {
@@ -168,7 +206,7 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
168
206
  button.textContent = iconText;
169
207
  }
170
208
 
171
- stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor, 2);
209
+ stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor, iconStroke);
172
210
  } else {
173
211
  button.textContent = sendLabel;
174
212
  }
@@ -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";
@@ -132,6 +133,10 @@ export interface PanelElements {
132
133
  container: HTMLElement;
133
134
  body: HTMLElement;
134
135
  messagesWrapper: HTMLElement;
136
+ /** Starter prompts rendered inside the welcome card. */
137
+ starterSuggestions: HTMLElement;
138
+ /** Follow-up suggestions rendered after the transcript. */
139
+ transcriptSuggestions: HTMLElement;
135
140
  /**
136
141
  * Absolute-positioned slot above the composer footer. Interactive sheets
137
142
  * (e.g. the answer-pill sheet for the ask_user_question tool) mount here
@@ -178,6 +183,17 @@ export interface PanelElements {
178
183
  peekTextNode?: HTMLElement;
179
184
  }
180
185
 
186
+ const createSuggestionHost = (
187
+ surface: AgentWidgetSuggestionSurface
188
+ ): HTMLElement => {
189
+ const host = createNode("div", {
190
+ className: "persona-suggestions",
191
+ attrs: { "data-persona-suggestions": suggestionSurfaceAttr(surface) },
192
+ });
193
+ host.hidden = true;
194
+ return host;
195
+ };
196
+
181
197
  /**
182
198
  * Composer-bar panel: minimal close-only header (× in the top-right of the
183
199
  * chat chrome). The pill (`footer`) and `peekBanner` are NOT children of
@@ -276,6 +292,7 @@ const buildComposerBarPanel = (
276
292
  config?.copy?.welcomeSubtitle ??
277
293
  "Ask anything about your account or products.",
278
294
  });
295
+ const starterSuggestions = createSuggestionHost("starter");
279
296
  const introCard = createNode(
280
297
  "div",
281
298
  {
@@ -287,13 +304,15 @@ const buildComposerBarPanel = (
287
304
  },
288
305
  },
289
306
  introTitle,
290
- introSubtitle
307
+ introSubtitle,
308
+ starterSuggestions
291
309
  );
292
310
 
293
311
  const messagesWrapper = createElement(
294
312
  "div",
295
313
  "persona-flex persona-flex-col persona-gap-3"
296
314
  );
315
+ const transcriptSuggestions = createSuggestionHost("followUp");
297
316
  const contentMaxWidth = config?.layout?.contentMaxWidth;
298
317
  if (contentMaxWidth) {
299
318
  messagesWrapper.style.maxWidth = contentMaxWidth;
@@ -308,7 +327,7 @@ const buildComposerBarPanel = (
308
327
  body.classList.remove("persona-gap-6");
309
328
  body.classList.add("persona-gap-3");
310
329
  }
311
- body.append(introCard, messagesWrapper);
330
+ body.append(introCard, messagesWrapper, transcriptSuggestions);
312
331
 
313
332
  // Composer overlay (interactive sheets like ask_user_question slide up here).
314
333
  // Anchored to the bottom of the container (which is `position: relative`),
@@ -338,6 +357,8 @@ const buildComposerBarPanel = (
338
357
  container,
339
358
  body,
340
359
  messagesWrapper,
360
+ starterSuggestions,
361
+ transcriptSuggestions,
341
362
  composerOverlay,
342
363
  suggestions: composerElements.suggestions,
343
364
  textarea: composerElements.textarea,
@@ -413,6 +434,7 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
413
434
  config?.copy?.welcomeSubtitle ??
414
435
  "Ask anything about your account or products.",
415
436
  });
437
+ const starterSuggestions = createSuggestionHost("starter");
416
438
  // Background and box-shadow flow through the themable `components.introCard`
417
439
  // tokens (--persona-intro-card-bg / --persona-intro-card-shadow); both
418
440
  // default to flat (transparent / none). Docked mode always stays flat,
@@ -430,13 +452,15 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
430
452
  },
431
453
  },
432
454
  introTitle,
433
- introSubtitle
455
+ introSubtitle,
456
+ starterSuggestions
434
457
  );
435
458
 
436
459
  const messagesWrapper = createElement(
437
460
  "div",
438
461
  "persona-flex persona-flex-col persona-gap-3"
439
462
  );
463
+ const transcriptSuggestions = createSuggestionHost("followUp");
440
464
 
441
465
  const contentMaxWidth = config?.layout?.contentMaxWidth;
442
466
  if (contentMaxWidth) {
@@ -452,7 +476,7 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
452
476
  body.classList.remove("persona-gap-6");
453
477
  body.classList.add("persona-gap-3");
454
478
  }
455
- body.append(introCard, messagesWrapper);
479
+ body.append(introCard, messagesWrapper, transcriptSuggestions);
456
480
 
457
481
  // composer-bar mode early-returned above with its own pill builder; this
458
482
  // path always uses the standard column-stacked composer card.
@@ -500,6 +524,8 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
500
524
  container,
501
525
  body,
502
526
  messagesWrapper,
527
+ starterSuggestions,
528
+ transcriptSuggestions,
503
529
  composerOverlay,
504
530
  suggestions: composerElements.suggestions,
505
531
  textarea: composerElements.textarea,
@@ -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,233 @@
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("legacy suggestionChipsConfig scoping", () => {
202
+ const chipsConfig = {
203
+ fontFamily: "sans-serif" as const,
204
+ fontWeight: "500",
205
+ paddingX: "12px",
206
+ paddingY: "6px",
207
+ };
208
+
209
+ it("applies inline chip styles to the chip variant", () => {
210
+ const { manager, session, textarea, container } = setup();
211
+
212
+ manager.render(["One"], session, textarea, [], chipsConfig, {
213
+ variant: "chip",
214
+ });
215
+
216
+ const button = container.querySelector("button") as HTMLButtonElement;
217
+ expect(button.style.paddingLeft).toBe("12px");
218
+ expect(button.style.paddingTop).toBe("6px");
219
+ expect(button.style.fontWeight).toBe("500");
220
+ });
221
+
222
+ it("does not let chip config override card and list padding tokens", () => {
223
+ const { manager, session, textarea, container } = setup();
224
+
225
+ for (const variant of ["card", "list"] as const) {
226
+ manager.render(["One"], session, textarea, [], chipsConfig, { variant });
227
+ const button = container.querySelector("button") as HTMLButtonElement;
228
+ expect(button.style.paddingLeft).toBe("");
229
+ expect(button.style.paddingTop).toBe("");
230
+ expect(button.style.fontFamily).toBe("");
231
+ }
232
+ });
233
+ });