@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.14.0",
3
+ "version": "4.16.0",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -5,15 +5,18 @@
5
5
  * `clientTools[]` (set `features.askUserQuestion.expose: true`): the same
6
6
  * wire surface WebMCP page tools ride. The server registers it as a LOCAL
7
7
  * tool under its bare name (`origin: 'sdk'` tools are not `webmcp:`-prefixed),
8
- * so when the model calls it the execution pauses with a `step_await`
9
- * (`awaitReason: "local_tool_required"`), the widget's answer-pill sheet
10
- * renders, and `session.resolveAskUserQuestion()` resumes the execution with
11
- * the structured answers.
8
+ * so when the model calls it the execution pauses with a `step_await`, the
9
+ * widget's answer-pill sheet renders, and `session.resolveAskUserQuestion()`
10
+ * resumes the execution with the structured answers. The public await frame
11
+ * for a local-tool pause carries no `awaitReason`; that discriminator is
12
+ * internal, and its absence is what marks a client-resolvable pause.
12
13
  *
13
14
  * This replaces the previous integrator burden of hand-declaring the tool in
14
15
  * a flow's `runtimeTools` and keeping that schema in sync with the renderer.
15
16
  * Flows that already declare `ask_user_question` server-side should leave
16
- * `expose` off: the model would otherwise see the tool twice.
17
+ * `expose` off: the API dedupes a double declaration by name with
18
+ * `clientTools[]` winning, so the model still sees exactly one tool, but the
19
+ * widget's description and schema silently replace the flow's.
17
20
  */
18
21
 
19
22
  import {
@@ -22,7 +25,7 @@ import {
22
25
  } from "./components/ask-user-question-bubble";
23
26
  import {
24
27
  SUGGEST_REPLIES_CLIENT_TOOL,
25
- shouldExposeSuggestReplies,
28
+ resolveFollowUpsFeature,
26
29
  } from "./suggest-replies-tool";
27
30
  import type { AgentWidgetConfig, ClientToolDefinition } from "./types";
28
31
 
@@ -129,7 +132,7 @@ export const builtInClientToolsForDispatch = (
129
132
  if (ask?.expose === true && ask.enabled !== false) {
130
133
  tools.push(ASK_USER_QUESTION_CLIENT_TOOL);
131
134
  }
132
- if (shouldExposeSuggestReplies(config)) {
135
+ if (resolveFollowUpsFeature(config).expose) {
133
136
  tools.push(SUGGEST_REPLIES_CLIENT_TOOL);
134
137
  }
135
138
  return tools;
@@ -345,6 +345,33 @@ describe("artifact-pane view/source toggle", () => {
345
345
  expect(pre?.textContent).toBe(HTML_RAW);
346
346
  });
347
347
 
348
+ it("hides the toggle for non-previewable file kinds, where both views are the source", () => {
349
+ const jsonRaw = '{ "a": 1 }\n';
350
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
351
+ pane.update({
352
+ artifacts: [
353
+ fileRecord({
354
+ markdown: wireFor(jsonRaw, "json"),
355
+ file: { path: "persona.config.json", mimeType: "application/json", language: "json" },
356
+ }),
357
+ ],
358
+ selectedId: "a1",
359
+ });
360
+
361
+ expect(toggleBtn(pane, "Rendered view")).toBeNull();
362
+ expect(toggleBtn(pane, "Source")).toBeNull();
363
+ // The content itself is the source view.
364
+ expect(contentEl(pane).querySelector("pre")?.textContent).toBe(jsonRaw);
365
+
366
+ // Selection moving to a previewable file mounts the toggle again.
367
+ pane.update({
368
+ artifacts: [fileRecord()],
369
+ selectedId: "a1",
370
+ });
371
+ expect(toggleBtn(pane, "Rendered view")).toBeTruthy();
372
+ expect(toggleBtn(pane, "Source")).toBeTruthy();
373
+ });
374
+
348
375
  it("drops the content padding (flush class) in source view and restores it when rendered", () => {
349
376
  const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
350
377
  pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
@@ -4,7 +4,7 @@ import type {
4
4
  PersonaArtifactRecord,
5
5
  PersonaArtifactCustomAction,
6
6
  } from "../types";
7
- import { fileTypeLabel, basenameOf } from "../utils/artifact-file";
7
+ import { fileKindOf, fileTypeLabel, basenameOf } from "../utils/artifact-file";
8
8
  import {
9
9
  buildArtifactActionButton,
10
10
  artifactRecordActionContext,
@@ -621,8 +621,12 @@ export function createArtifactPane(
621
621
  const selFile = sel.artifactType === "markdown" ? sel.file : undefined;
622
622
 
623
623
  // Expose the rendered/source toggle for previewable file artifacts even
624
- // outside the document toolbar preset.
625
- updateFileToggleVisibility(Boolean(selFile));
624
+ // outside the document toolbar preset. Kind "other" (json, css, ts, …)
625
+ // has no rendered form — preview falls back to the source view — so the
626
+ // toggle would be a visible no-op there.
627
+ updateFileToggleVisibility(
628
+ Boolean(selFile) && fileKindOf(selFile!) !== "other"
629
+ );
626
630
 
627
631
  if (documentChrome) {
628
632
  const kind = selFile
@@ -670,7 +670,7 @@ export function renderArtifactPreviewBody(
670
670
 
671
671
  // Render the syntax-highlighted source. On the inline path the <pre> lives in
672
672
  // a scroll window (`fixed` → reserved height + internal scroll + follow/fade);
673
- // on the pane path it is a bare child of `el`, byte-identical to before.
673
+ // on the pane path it is a bare child of `el`, unchanged from before.
674
674
  const renderSource = (
675
675
  text: string,
676
676
  opts: { language?: string; path?: string } | undefined,
@@ -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
  }
@@ -13,6 +13,28 @@ export const HEADER_THEME_CSS = {
13
13
  "var(--persona-header-action-icon-fg, var(--persona-muted, #9ca3af))",
14
14
  } as const;
15
15
 
16
+ /**
17
+ * Inline typography for the header title/subtitle, from
18
+ * `theme.components.header.title` / `.subtitle`. Fallbacks reproduce the
19
+ * `persona-text-base persona-font-semibold` / `persona-text-xs` utilities.
20
+ */
21
+ export const HEADER_TITLE_TYPOGRAPHY = {
22
+ fontFamily: "var(--persona-components-header-title-fontFamily, inherit)",
23
+ fontSize: "var(--persona-components-header-title-fontSize, 1rem)",
24
+ fontWeight: "var(--persona-components-header-title-fontWeight, 600)",
25
+ lineHeight: "var(--persona-components-header-title-lineHeight, 1.5rem)",
26
+ letterSpacing: "var(--persona-components-header-title-letterSpacing, normal)",
27
+ } as const;
28
+
29
+ export const HEADER_SUBTITLE_TYPOGRAPHY = {
30
+ fontFamily: "var(--persona-components-header-subtitle-fontFamily, inherit)",
31
+ fontSize: "var(--persona-components-header-subtitle-fontSize, 0.75rem)",
32
+ fontWeight: "var(--persona-components-header-subtitle-fontWeight, inherit)",
33
+ lineHeight: "var(--persona-components-header-subtitle-lineHeight, 1rem)",
34
+ letterSpacing:
35
+ "var(--persona-components-header-subtitle-letterSpacing, normal)",
36
+ } as const;
37
+
16
38
  export interface HeaderElements {
17
39
  header: HTMLElement;
18
40
  iconHolder: HTMLElement;
@@ -99,12 +121,15 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
99
121
  const title = createNode("span", {
100
122
  className: "persona-text-base persona-font-semibold",
101
123
  text: config?.launcher?.title ?? "Chat Assistant",
102
- style: { color: HEADER_THEME_CSS.titleColor },
124
+ style: { color: HEADER_THEME_CSS.titleColor, ...HEADER_TITLE_TYPOGRAPHY },
103
125
  });
104
126
  const subtitle = createNode("span", {
105
127
  className: "persona-text-xs",
106
128
  text: config?.launcher?.subtitle ?? "Here to help you get answers fast",
107
- style: { color: HEADER_THEME_CSS.subtitleColor },
129
+ style: {
130
+ color: HEADER_THEME_CSS.subtitleColor,
131
+ ...HEADER_SUBTITLE_TYPOGRAPHY,
132
+ },
108
133
  });
109
134
 
110
135
  headerCopy.append(title, subtitle);
@@ -0,0 +1,56 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { buildMinimalHeader } from "./header-layouts";
6
+ import { HEADER_THEME_CSS } from "./header-builder";
7
+
8
+ const build = (layoutHeaderConfig: Parameters<typeof buildMinimalHeader>[0]["layoutHeaderConfig"]) =>
9
+ buildMinimalHeader({
10
+ config: { apiUrl: "https://example.com/api" },
11
+ showClose: true,
12
+ onClose: () => {},
13
+ layoutHeaderConfig,
14
+ });
15
+
16
+ describe("minimal header trailing actions", () => {
17
+ it("clusters actions at the trailing edge with the close button's chrome", () => {
18
+ const onAction = vi.fn();
19
+ const elements = build({
20
+ layout: "minimal",
21
+ trailingActions: [{ id: "home", icon: "house", ariaLabel: "Back to home" }],
22
+ onAction,
23
+ });
24
+
25
+ const button = elements.header.querySelector<HTMLButtonElement>(
26
+ 'button[aria-label="Back to home"]'
27
+ );
28
+ expect(button).not.toBeNull();
29
+
30
+ // Trailing cluster, not the title row: the button shares a parent with
31
+ // the close button wrapper and does not precede the title.
32
+ expect(button!.parentElement).toBe(elements.closeButtonWrapper.parentElement);
33
+ expect(elements.headerTitle.parentElement!.contains(button!)).toBe(false);
34
+
35
+ // Close-button chrome: 32px round hit area, header action-icon color.
36
+ expect(button!.style.height).toBe("32px");
37
+ expect(button!.style.width).toBe("32px");
38
+ expect(button!.style.color).toBe(HEADER_THEME_CSS.actionIconColor);
39
+ expect(button!.className).toContain("persona-rounded-full");
40
+
41
+ button!.click();
42
+ expect(onAction).toHaveBeenCalledWith("home");
43
+ });
44
+
45
+ it("still ignores trailingActions when titleMenu is configured", () => {
46
+ const elements = build({
47
+ layout: "minimal",
48
+ titleMenu: { menuItems: [{ id: "a", label: "A" }], onSelect: () => {} },
49
+ trailingActions: [{ id: "home", icon: "house", ariaLabel: "Back to home" }],
50
+ });
51
+
52
+ expect(
53
+ elements.header.querySelector('button[aria-label="Back to home"]')
54
+ ).toBeNull();
55
+ });
56
+ });
@@ -10,6 +10,7 @@ import {
10
10
  import {
11
11
  buildHeader,
12
12
  HEADER_THEME_CSS,
13
+ HEADER_TITLE_TYPOGRAPHY,
13
14
  HeaderElements,
14
15
  attachHeaderToContainer as _attachHeaderToContainer,
15
16
  } from "./header-builder";
@@ -70,14 +71,20 @@ function appendTrailingHeaderActions(
70
71
  ): void {
71
72
  if (!actions?.length) return;
72
73
  for (const a of actions) {
74
+ // Same chrome as the close button beside them: 32px round hit area,
75
+ // hover fill, and the header zone's action-icon color (the muted body
76
+ // token disappears on themed headers).
73
77
  const btn = createElement(
74
78
  "button",
75
- "persona-inline-flex persona-items-center persona-justify-center persona-rounded-md persona-border-none persona-bg-transparent persona-p-0 persona-text-persona-muted hover:persona-opacity-80"
79
+ "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none persona-bg-transparent persona-p-0"
76
80
  ) as HTMLButtonElement;
77
81
  btn.type = "button";
82
+ btn.style.height = "32px";
83
+ btn.style.width = "32px";
84
+ btn.style.color = HEADER_THEME_CSS.actionIconColor;
78
85
  btn.setAttribute("aria-label", a.ariaLabel ?? a.label ?? a.id);
79
86
  if (a.icon) {
80
- const ic = renderLucideIcon(a.icon, 14, "currentColor", 2);
87
+ const ic = renderLucideIcon(a.icon, 16, "currentColor", 2);
81
88
  if (ic) btn.appendChild(ic);
82
89
  } else if (a.label) {
83
90
  btn.textContent = a.label;
@@ -91,7 +98,9 @@ function appendTrailingHeaderActions(
91
98
  items: a.menuItems,
92
99
  onSelect: (itemId) => onAction?.(itemId),
93
100
  anchor: wrapper,
94
- position: 'bottom-left',
101
+ // The cluster sits at the trailing edge; a left-aligned menu would
102
+ // extend past it.
103
+ position: 'bottom-right',
95
104
  });
96
105
  wrapper.appendChild(dropdown.element);
97
106
  btn.addEventListener("click", (e) => {
@@ -148,14 +157,10 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
148
157
  // Title only (no icon, no subtitle)
149
158
  headerTitle = createElement("span", "persona-text-base persona-font-semibold persona-truncate");
150
159
  headerTitle.style.color = HEADER_THEME_CSS.titleColor;
160
+ Object.assign(headerTitle.style, HEADER_TITLE_TYPOGRAPHY);
151
161
  headerTitle.textContent = launcher.title ?? "Chat Assistant";
152
162
 
153
163
  titleRow.appendChild(headerTitle);
154
- appendTrailingHeaderActions(
155
- titleRow,
156
- layoutHeaderConfig?.trailingActions,
157
- layoutHeaderConfig?.onAction ?? onHeaderAction
158
- );
159
164
 
160
165
  // Make title row clickable when onTitleClick is provided
161
166
  if (layoutHeaderConfig?.onTitleClick) {
@@ -229,7 +234,22 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
229
234
  }
230
235
 
231
236
  closeButtonWrapper.appendChild(closeButton);
232
- header.appendChild(closeButtonWrapper);
237
+
238
+ // Trailing edge: action buttons cluster with the close button, matching
239
+ // its chrome. `titleMenu` still ignores `trailingActions` (documented).
240
+ const trailingCluster = createElement(
241
+ "div",
242
+ "persona-flex persona-items-center persona-gap-1"
243
+ );
244
+ if (!titleMenuConfig) {
245
+ appendTrailingHeaderActions(
246
+ trailingCluster,
247
+ layoutHeaderConfig?.trailingActions,
248
+ layoutHeaderConfig?.onAction ?? onHeaderAction
249
+ );
250
+ }
251
+ trailingCluster.appendChild(closeButtonWrapper);
252
+ header.appendChild(trailingCluster);
233
253
 
234
254
  // title was moved into titleRow; keep headerTitle ref pointing at title for updateController
235
255