@runtypelabs/persona 4.33.0 → 4.34.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 (186) hide show
  1. package/dist/activity-ui.d.cts +3 -15
  2. package/dist/activity-ui.d.ts +3 -15
  3. package/dist/animations/glyph-cycle.d.cts +1 -3
  4. package/dist/animations/glyph-cycle.d.ts +1 -3
  5. package/dist/animations/wipe.d.cts +1 -3
  6. package/dist/animations/wipe.d.ts +1 -3
  7. package/dist/approval-ui.cjs +2 -2
  8. package/dist/approval-ui.d.cts +3 -15
  9. package/dist/approval-ui.d.ts +3 -15
  10. package/dist/approval-ui.js +1 -1
  11. package/dist/artifacts-ui.cjs +1 -1
  12. package/dist/artifacts-ui.d.cts +3 -21
  13. package/dist/artifacts-ui.d.ts +3 -21
  14. package/dist/background-runtime.d.cts +2 -3
  15. package/dist/background-runtime.d.ts +2 -3
  16. package/dist/{chunk-gu34lr-P.js → chunk-Brdx4NCz.js} +1 -1
  17. package/dist/{chunk-dLJWKfOb.js → chunk-C-rux8Q9.js} +1 -1
  18. package/dist/{chunk-Ds_twaZn.js → chunk-C4v-HNhS.js} +1 -1
  19. package/dist/{chunk-Bkma5hIU.js → chunk-CE4seJxF.js} +1 -1
  20. package/dist/{chunk-D6U0E82p.js → chunk-CV8EPIhT.js} +1 -1
  21. package/dist/{chunk-ql5N2I_t.js → chunk-CWzHNuVK.js} +1 -1
  22. package/dist/{chunk-DbGLjU2P.js → chunk-CYpt7PsO.js} +1 -1
  23. package/dist/{chunk-DOZmcve8.js → chunk-Csr59G5V.js} +1 -1
  24. package/dist/{chunk-DW7Vg_l2.js → chunk-CxNPSY-b.js} +1 -1
  25. package/dist/chunk-Dqa_GOcj.js +1 -0
  26. package/dist/chunk-DsWv3EGF.js +1 -0
  27. package/dist/{chunk-C7-m0EmI.js → chunk-NUNdcnAZ.js} +1 -1
  28. package/dist/{chunk-CXQJG7pb.js → chunk-YMkMvAsa.js} +2 -2
  29. package/dist/{chunk-C-EHtpbB.js → chunk-h5aOI_Pa.js} +1 -1
  30. package/dist/chunk-kizhspe5.js +1 -0
  31. package/dist/client-dispatch.js +1 -1
  32. package/dist/client-history.js +1 -1
  33. package/dist/client-stream.js +1 -1
  34. package/dist/{code-generators-DqauYyfX.d.cts → code-generators-DPAqTcAz.d.cts} +0 -2
  35. package/dist/{code-generators-DqauYyfX.d.ts → code-generators-DPAqTcAz.d.ts} +0 -2
  36. package/dist/codegen.cjs +1 -1
  37. package/dist/codegen.d.cts +1 -1
  38. package/dist/codegen.d.ts +1 -1
  39. package/dist/codegen.js +1 -1
  40. package/dist/composer-actions-runtime.cjs +1 -1
  41. package/dist/composer-actions-runtime.d.cts +4 -20
  42. package/dist/composer-actions-runtime.d.ts +4 -20
  43. package/dist/composer-actions-runtime.js +1 -1
  44. package/dist/{composer-input-DnqGCXZz.d.ts → composer-input-i_FiUp6J.d.cts} +1 -7
  45. package/dist/{composer-input-DnqGCXZz.d.cts → composer-input-i_FiUp6J.d.ts} +1 -7
  46. package/dist/{composer-store-6Iwy6WLI.d.ts → composer-store-DhGNgG7Z.d.cts} +1 -3
  47. package/dist/{composer-store-6Iwy6WLI.d.cts → composer-store-DhGNgG7Z.d.ts} +1 -3
  48. package/dist/config-diagnostics.d.cts +0 -2
  49. package/dist/config-diagnostics.d.ts +0 -2
  50. package/dist/{context-mention-manager-B-rHF98n.d.ts → context-mention-manager-DL-xwGfL.d.cts} +2 -6
  51. package/dist/{context-mention-manager-B-rHF98n.d.cts → context-mention-manager-DL-xwGfL.d.ts} +2 -6
  52. package/dist/context-mentions-inline.cjs +1 -1
  53. package/dist/context-mentions-inline.d.cts +2 -4
  54. package/dist/context-mentions-inline.d.ts +2 -4
  55. package/dist/context-mentions-inline.js +1 -1
  56. package/dist/context-mentions.cjs +1 -1
  57. package/dist/context-mentions.d.cts +3 -9
  58. package/dist/context-mentions.d.ts +3 -9
  59. package/dist/{defaults-BuOjqUAG.d.ts → defaults-Bga2Xrsp.d.cts} +1 -5
  60. package/dist/{defaults-BuOjqUAG.d.cts → defaults-Bga2Xrsp.d.ts} +1 -5
  61. package/dist/dictation-runtime.cjs +1 -1
  62. package/dist/dictation-runtime.d.cts +4 -13
  63. package/dist/dictation-runtime.d.ts +4 -13
  64. package/dist/dictation-runtime.js +1 -1
  65. package/dist/{dom-context-DHTabIn7.d.cts → dom-context-Bw-_9Evh.d.cts} +0 -2
  66. package/dist/{dom-context-DHTabIn7.d.ts → dom-context-Bw-_9Evh.d.ts} +0 -2
  67. package/dist/event-stream-view.cjs +1 -1
  68. package/dist/event-stream-view.d.cts +3 -16
  69. package/dist/event-stream-view.d.ts +3 -16
  70. package/dist/{fallback-speech-engine-SqEfkGda.d.ts → fallback-speech-engine-kLdyPcmk.d.cts} +1 -3
  71. package/dist/{fallback-speech-engine-SqEfkGda.d.cts → fallback-speech-engine-kLdyPcmk.d.ts} +1 -3
  72. package/dist/{feature-preferences-DqAmje-2.d.ts → feature-preferences-CmwZGKrf.d.cts} +1 -5
  73. package/dist/{feature-preferences-DqAmje-2.d.cts → feature-preferences-CmwZGKrf.d.ts} +1 -5
  74. package/dist/forms-ui.d.cts +2 -4
  75. package/dist/forms-ui.d.ts +2 -4
  76. package/dist/{history-confirm-dialog-B3L7QNcO.d.cts → history-confirm-dialog-i46-lPN_.d.cts} +2 -14
  77. package/dist/{history-confirm-dialog-B3L7QNcO.d.ts → history-confirm-dialog-i46-lPN_.d.ts} +2 -14
  78. package/dist/history-shell.cjs +1 -1
  79. package/dist/history-shell.d.cts +8 -27
  80. package/dist/history-shell.d.ts +8 -27
  81. package/dist/history-shell.js +1 -1
  82. package/dist/history-view.d.cts +1 -1
  83. package/dist/history-view.d.ts +1 -1
  84. package/dist/icons-extra.cjs +1 -1
  85. package/dist/icons-extra.d.cts +0 -2
  86. package/dist/icons-extra.d.ts +0 -2
  87. package/dist/index.cjs +45 -45
  88. package/dist/index.cjs.map +1 -1
  89. package/dist/index.d.cts +10 -10
  90. package/dist/index.d.ts +10 -10
  91. package/dist/index.global.js +1 -1
  92. package/dist/index.global.js.map +1 -1
  93. package/dist/index.js +49 -49
  94. package/dist/index.js.map +1 -1
  95. package/dist/install.global.js +1 -1
  96. package/dist/install.global.js.map +1 -1
  97. package/dist/launcher.global.js +2 -2
  98. package/dist/launcher.global.js.map +1 -1
  99. package/dist/{loading-label-runtime-loader-JecjOfJS.d.ts → loading-label-runtime-loader-Ccr3ETd3.d.cts} +1 -3
  100. package/dist/{loading-label-runtime-loader-JecjOfJS.d.cts → loading-label-runtime-loader-Ccr3ETd3.d.ts} +1 -3
  101. package/dist/loading-label-runtime.cjs +1 -1
  102. package/dist/loading-label-runtime.d.cts +1 -1
  103. package/dist/loading-label-runtime.d.ts +1 -1
  104. package/dist/loading-label-runtime.js +1 -1
  105. package/dist/markdown-parsers.js +1 -1
  106. package/dist/{message-bubble-CBYtpDsT.d.ts → message-bubble-C6AVaH7f.d.cts} +20 -3
  107. package/dist/{message-bubble-CBYtpDsT.d.cts → message-bubble-C6AVaH7f.d.ts} +20 -3
  108. package/dist/{plugin-kit-C1mRjLgW.d.cts → plugin-kit-BVggqE3C.d.cts} +0 -2
  109. package/dist/{plugin-kit-C1mRjLgW.d.ts → plugin-kit-BVggqE3C.d.ts} +0 -2
  110. package/dist/plugin-kit.d.cts +1 -1
  111. package/dist/plugin-kit.d.ts +1 -1
  112. package/dist/preferences-runtime.cjs +1 -1
  113. package/dist/preferences-runtime.d.cts +1 -1
  114. package/dist/preferences-runtime.d.ts +1 -1
  115. package/dist/preferences-runtime.js +1 -1
  116. package/dist/{preview-_UJwwt2L.d.cts → preview-BMdRffo4.d.cts} +2 -6
  117. package/dist/{preview-_UJwwt2L.d.ts → preview-BMdRffo4.d.ts} +2 -6
  118. package/dist/{registry-B5ZV3K-h.d.ts → registry-B16l8s1b.d.cts} +1 -8
  119. package/dist/{registry-B5ZV3K-h.d.cts → registry-B16l8s1b.d.ts} +1 -8
  120. package/dist/{session-Bys8_XuB.d.cts → session-CtZHe7fc.d.cts} +8 -92
  121. package/dist/{session-Bys8_XuB.d.ts → session-CtZHe7fc.d.ts} +8 -92
  122. package/dist/session-reconnect.d.cts +2 -4
  123. package/dist/session-reconnect.d.ts +2 -4
  124. package/dist/smart-dom-reader.d.cts +2 -8
  125. package/dist/smart-dom-reader.d.ts +2 -8
  126. package/dist/testing.d.cts +0 -2
  127. package/dist/testing.d.ts +0 -2
  128. package/dist/theme-editor-preview.cjs +42 -42
  129. package/dist/theme-editor-preview.d.cts +1 -1
  130. package/dist/theme-editor-preview.d.ts +1 -1
  131. package/dist/theme-editor-preview.js +25 -25
  132. package/dist/theme-editor.cjs +25 -5
  133. package/dist/theme-editor.d.cts +2 -22
  134. package/dist/theme-editor.d.ts +2 -22
  135. package/dist/theme-editor.js +1 -1
  136. package/dist/theme-reference.cjs +1 -1
  137. package/dist/theme-reference.d.cts +2 -4
  138. package/dist/theme-reference.d.ts +2 -4
  139. package/dist/theme-reference.js +1 -1
  140. package/dist/{tool-loading-animation-d5qAQcwv.d.ts → tool-loading-animation-D2cyFvmM.d.cts} +1 -7
  141. package/dist/{tool-loading-animation-d5qAQcwv.d.cts → tool-loading-animation-D2cyFvmM.d.ts} +1 -7
  142. package/dist/{tooltip-BkW-8MnD.d.cts → tooltip-DbXkAuJL.d.cts} +0 -2
  143. package/dist/{tooltip-BkW-8MnD.d.ts → tooltip-DbXkAuJL.d.ts} +0 -2
  144. package/dist/{turn-activity-ui-q6fHVnx-.d.cts → turn-activity-ui-Do1Nd-Ow.d.cts} +2 -12
  145. package/dist/{turn-activity-ui-q6fHVnx-.d.ts → turn-activity-ui-Do1Nd-Ow.d.ts} +2 -12
  146. package/dist/turn-activity-ui.d.cts +1 -1
  147. package/dist/turn-activity-ui.d.ts +1 -1
  148. package/dist/{types-B_mj21jg.d.cts → types-BC7MRxO5.d.cts} +76 -29
  149. package/dist/{types-B_mj21jg.d.ts → types-BC7MRxO5.d.ts} +76 -29
  150. package/dist/ui-extras.js +3 -3
  151. package/dist/voice-client-tools.d.cts +1 -3
  152. package/dist/voice-client-tools.d.ts +1 -3
  153. package/dist/voice-runtime.cjs +1 -1
  154. package/dist/voice-runtime.d.cts +2 -12
  155. package/dist/voice-runtime.d.ts +2 -12
  156. package/dist/voice-runtime.js +2 -2
  157. package/dist/voice-worklet-player.d.cts +2 -6
  158. package/dist/voice-worklet-player.d.ts +2 -6
  159. package/dist/{webmcp-runtime-entry-ClEO3qdI.d.ts → webmcp-runtime-entry-B8g46vS0.d.cts} +1 -5
  160. package/dist/{webmcp-runtime-entry-ClEO3qdI.d.cts → webmcp-runtime-entry-B8g46vS0.d.ts} +1 -5
  161. package/dist/webmcp-runtime.d.cts +1 -1
  162. package/dist/webmcp-runtime.d.ts +1 -1
  163. package/dist/widget.css +1 -1
  164. package/package.json +4 -4
  165. package/src/components/event-stream-view.test.ts +5 -0
  166. package/src/components/message-bubble.ts +26 -6
  167. package/src/components/scroll-to-bottom.test.ts +135 -0
  168. package/src/components/scroll-to-bottom.ts +50 -13
  169. package/src/components/user-message-collapse.ts +117 -0
  170. package/src/defaults.ts +6 -0
  171. package/src/styles/widget.css +56 -0
  172. package/src/theme-reference.ts +2 -2
  173. package/src/types/theme.ts +29 -0
  174. package/src/types.ts +49 -5
  175. package/src/ui.scroll-additive.test.ts +219 -117
  176. package/src/ui.scroll.test.ts +176 -13
  177. package/src/ui.ts +230 -107
  178. package/src/ui.turn-activity.test.ts +7 -7
  179. package/src/ui.user-message-collapse.test.ts +540 -0
  180. package/src/utils/auto-follow.test.ts +0 -38
  181. package/src/utils/auto-follow.ts +0 -35
  182. package/src/utils/theme.test.ts +23 -0
  183. package/src/utils/tokens.ts +16 -0
  184. package/dist/chunk-CVMIncEY.js +0 -1
  185. package/dist/chunk-DVgzoVCB.js +0 -1
  186. package/dist/chunk-d54BJdKd.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.33.0",
3
+ "version": "4.34.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",
@@ -191,7 +191,7 @@
191
191
  "@types/jsdom": "^30.0.0",
192
192
  "@types/node": "^20.12.7",
193
193
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
194
- "@vitest/ui": "^5.0.3",
194
+ "@vitest/ui": "^5.0.5",
195
195
  "acorn": "^8.15.0",
196
196
  "fake-indexeddb": "^6.2.5",
197
197
  "jsdom": "^28.1.0",
@@ -199,9 +199,9 @@
199
199
  "rimraf": "^5.0.5",
200
200
  "size-limit": "^12.1.0",
201
201
  "terser": "^5.49.0",
202
- "tsdown": "^0.23.0",
202
+ "tsdown": "^0.23.2",
203
203
  "typescript": "^6.0.3",
204
- "vitest": "^5.0.3",
204
+ "vitest": "^5.0.5",
205
205
  "webmcp-polyfill-v6": "npm:@mcp-b/webmcp-polyfill@6.0.0"
206
206
  },
207
207
  "engines": {
@@ -36,6 +36,11 @@ function createMockElement(tag = "div"): any {
36
36
  clientHeight: 300,
37
37
  // For <select>
38
38
  options: [] as any[],
39
+ append(...nodes: (Node | string)[]) {
40
+ for (const node of nodes) {
41
+ el.appendChild(typeof node === "string" ? { nodeType: 3, textContent: node } : node);
42
+ }
43
+ },
39
44
  appendChild(child: any) {
40
45
  // Handle DocumentFragment: move its children into this element
41
46
  if (child.tagName === "FRAGMENT") {
@@ -25,6 +25,11 @@ import { createMentionTokenElement } from "../utils/mention-token";
25
25
  import { createIconButton } from "../utils/buttons";
26
26
  import { renderLucideIcon } from "../utils/icons";
27
27
  import { IMAGE_ONLY_MESSAGE_FALLBACK_TEXT } from "../utils/content";
28
+ import {
29
+ applyUserCollapseState,
30
+ createUserCollapseRegion,
31
+ type UserMessageCollapseRender,
32
+ } from "./user-message-collapse";
28
33
  import {
29
34
  applyStreamBuffer,
30
35
  createSkeletonPlaceholder,
@@ -998,6 +1003,11 @@ export type CreateStandardBubbleOptions = {
998
1003
  * Full widget config (needed for loading indicator context)
999
1004
  */
1000
1005
  widgetConfig?: import("../types").AgentWidgetConfig;
1006
+ /**
1007
+ * Long user message collapse (`features.userMessageCollapse`). When set on
1008
+ * a user bubble, its content is wrapped in a clip region with the toggle.
1009
+ */
1010
+ userCollapse?: UserMessageCollapseRender;
1001
1011
  };
1002
1012
 
1003
1013
  /**
@@ -1238,6 +1248,12 @@ export const createStandardBubble = (
1238
1248
  }
1239
1249
  }
1240
1250
 
1251
+ // Everything a long user message can spill (attachments, chips, text) goes
1252
+ // in the collapse region; the timestamp and actions stay outside the clip.
1253
+ const userCollapse = message.role === "user" ? options?.userCollapse : undefined;
1254
+ const collapseRegion = userCollapse ? createUserCollapseRegion(message.id, userCollapse.maxHeight) : null;
1255
+ const contentParent = collapseRegion ?? bubble;
1256
+
1241
1257
  if (imageParts.length > 0) {
1242
1258
  const imagePreviews = createMessageImagePreviews(
1243
1259
  imageParts,
@@ -1250,7 +1266,7 @@ export const createStandardBubble = (
1250
1266
  );
1251
1267
 
1252
1268
  if (imagePreviews) {
1253
- bubble.appendChild(imagePreviews);
1269
+ contentParent.appendChild(imagePreviews);
1254
1270
  } else if (shouldHideTextUntilPreviewFails && textContentDiv) {
1255
1271
  textContentDiv.style.display = "";
1256
1272
  }
@@ -1260,7 +1276,7 @@ export const createStandardBubble = (
1260
1276
  if (audioParts.length > 0) {
1261
1277
  const audioPreviews = createMessageAudioPreviews(audioParts);
1262
1278
  if (audioPreviews) {
1263
- bubble.appendChild(audioPreviews);
1279
+ contentParent.appendChild(audioPreviews);
1264
1280
  }
1265
1281
  }
1266
1282
 
@@ -1268,7 +1284,7 @@ export const createStandardBubble = (
1268
1284
  if (videoParts.length > 0) {
1269
1285
  const videoPreviews = createMessageVideoPreviews(videoParts);
1270
1286
  if (videoPreviews) {
1271
- bubble.appendChild(videoPreviews);
1287
+ contentParent.appendChild(videoPreviews);
1272
1288
  }
1273
1289
  }
1274
1290
 
@@ -1276,7 +1292,7 @@ export const createStandardBubble = (
1276
1292
  if (fileParts.length > 0) {
1277
1293
  const filePreviews = createMessageFilePreviews(fileParts);
1278
1294
  if (filePreviews) {
1279
- bubble.appendChild(filePreviews);
1295
+ contentParent.appendChild(filePreviews);
1280
1296
  }
1281
1297
  }
1282
1298
 
@@ -1286,10 +1302,14 @@ export const createStandardBubble = (
1286
1302
  ? null
1287
1303
  : createMessageMentionChips(message.contextMentions);
1288
1304
  if (mentionChips) {
1289
- bubble.appendChild(mentionChips);
1305
+ contentParent.appendChild(mentionChips);
1290
1306
  }
1291
1307
 
1292
- bubble.appendChild(contentDiv);
1308
+ contentParent.appendChild(contentDiv);
1309
+ if (collapseRegion && userCollapse) {
1310
+ bubble.appendChild(collapseRegion);
1311
+ applyUserCollapseState(bubble, collapseRegion, message.id, userCollapse);
1312
+ }
1293
1313
 
1294
1314
  // Add timestamp below if configured
1295
1315
  if (showTimestamp && timestampPosition === "below" && message.createdAt) {
@@ -40,6 +40,49 @@ describe("scroll-to-bottom chrome", () => {
40
40
  control.destroy();
41
41
  });
42
42
 
43
+ it("names a pending approval below with a labelled shield pill, then reverts", () => {
44
+ const control = createScrollToBottomControl(icon, "transcript");
45
+ const config: AgentWidgetConfig = { future: { v5Defaults: true } };
46
+ control.update(config, true, false, 2, true);
47
+ const el = control.element;
48
+ expect(el.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(true);
49
+ expect(el.getAttribute("data-persona-scroll-to-bottom-has-label")).toBe("true");
50
+ expect(el.textContent).toBe("Approval needed");
51
+ expect(el.querySelector("svg")?.getAttribute("data-icon")).toBe("shield-check");
52
+ // The label replaces the unread count; assistive tech hears where it leads.
53
+ expect(el.querySelector<HTMLElement>("[data-persona-scroll-to-bottom-count]")?.style.display).toBe("none");
54
+ expect(el.getAttribute("aria-label")).toBe("Approval needed below. Jump to latest");
55
+
56
+ // Hidden, the approval state never shows.
57
+ control.update(config, false, false, 0, true);
58
+ expect(el.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(false);
59
+
60
+ control.update(config, true, false, 2, false);
61
+ expect(el.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(false);
62
+ expect(el.getAttribute("data-persona-scroll-to-bottom-has-label")).toBe("false");
63
+ expect(el.querySelector("svg")?.getAttribute("data-icon")).toBe("arrow-down");
64
+ expect(el.getAttribute("aria-label")).toBe("Jump to latest (2 new)");
65
+ control.destroy();
66
+ });
67
+
68
+ it("uses a custom approval label, opts out with an empty one, and tells custom glyphs", () => {
69
+ const states: string[] = [];
70
+ const renderIcon = vi.fn((_context: AgentWidgetScrollToBottomIconContext) => ({
71
+ element: document.createElement("span"),
72
+ update: (next: AgentWidgetScrollToBottomIconContext) => { states.push(next.state.activity); },
73
+ }));
74
+ const control = createScrollToBottomControl(icon, "transcript");
75
+ control.update({ features: { scrollToBottom: { approvalLabel: "Review request", renderIcon } } }, true, false, 0, true);
76
+ expect(control.element.textContent).toBe("Review request");
77
+ expect(renderIcon.mock.calls[0][0].state.activity).toBe("approval-below");
78
+ control.update({ features: { scrollToBottom: { approvalLabel: "", renderIcon } } }, true, false, 1, true);
79
+ expect(control.element.getAttribute("data-persona-scroll-to-bottom-has-label")).toBe("false");
80
+ expect(control.element.getAttribute("aria-label")).toBe("Approval needed below. Jump to latest");
81
+ control.update({ features: { scrollToBottom: { renderIcon } } }, true, false, 0, false);
82
+ expect(states.at(-1)).toBe("idle");
83
+ control.destroy();
84
+ });
85
+
43
86
  it("makes fading controls inert immediately and returns focus only when owned, including Shadow DOM", () => {
44
87
  const host = document.createElement("div"); document.body.appendChild(host);
45
88
  const root = host.attachShadow({ mode: "open" });
@@ -122,6 +165,98 @@ describe("scroll-to-bottom chrome", () => {
122
165
  control.destroy(); warning.mockRestore();
123
166
  });
124
167
 
168
+ describe("approval morph", () => {
169
+ const stubAnimate = () => {
170
+ const calls: { target: Element; keyframes: Keyframe[]; options: KeyframeAnimationOptions; animation: { cancel: ReturnType<typeof vi.fn> } }[] = [];
171
+ const original = (HTMLElement.prototype as { animate?: unknown }).animate;
172
+ (HTMLElement.prototype as { animate?: unknown }).animate = function (this: Element, keyframes: Keyframe[], options: KeyframeAnimationOptions) {
173
+ const animation = { cancel: vi.fn(), finished: new Promise<void>(() => {}) };
174
+ calls.push({ target: this, keyframes, options, animation });
175
+ return animation;
176
+ };
177
+ return { calls, restore: () => { (HTMLElement.prototype as { animate?: unknown }).animate = original; } };
178
+ };
179
+
180
+ it("morphs a visible arrow into the pill on the theme's motion tokens, and back", () => {
181
+ const { calls, restore } = stubAnimate();
182
+ try {
183
+ const control = createScrollToBottomControl(icon, "transcript");
184
+ document.body.appendChild(control.element);
185
+ control.element.style.setProperty("--persona-motion-duration-base", "0.3s");
186
+ control.element.style.setProperty("--persona-motion-easing", "cubic-bezier(0.2, 0, 0, 1)");
187
+ const config: AgentWidgetConfig = { future: { v5Defaults: true } };
188
+ control.update(config, true);
189
+ expect(calls).toHaveLength(0);
190
+
191
+ control.update(config, true, false, 0, true);
192
+ const [own, labelFade] = calls;
193
+ expect(own.target).toBe(control.element);
194
+ expect(own.keyframes).toHaveLength(2);
195
+ expect(own.keyframes[0]).toMatchObject({ overflow: "hidden" });
196
+ expect(own.keyframes[0]).toHaveProperty("width");
197
+ expect(own.keyframes[1]).toHaveProperty("backgroundColor");
198
+ expect(own.options).toEqual({ duration: 300, easing: "cubic-bezier(0.2, 0, 0, 1)" });
199
+ // The label fades in as the clipped pill widens.
200
+ expect(labelFade.target.textContent).toBe("Approval needed");
201
+ expect(calls).toHaveLength(2);
202
+
203
+ // Ordinary syncs in the same state leave a running morph alone.
204
+ control.update(config, true, false, 0, true);
205
+ expect(calls).toHaveLength(2);
206
+ expect(calls.every((call) => call.animation.cancel.mock.calls.length === 0)).toBe(true);
207
+
208
+ // Back to the arrow: the running morph is replaced, unclipped (the
209
+ // count badge hangs outside the button).
210
+ control.update(config, true, false, 2, false);
211
+ expect(calls.slice(0, 2).every((call) => call.animation.cancel.mock.calls.length === 1)).toBe(true);
212
+ expect(calls).toHaveLength(3);
213
+ expect(calls[2].keyframes[0]).not.toHaveProperty("overflow");
214
+ control.destroy();
215
+ } finally { restore(); }
216
+ });
217
+
218
+ it("swaps instantly when the theme turns motion off, and falls back when the duration is missing", () => {
219
+ const { calls, restore } = stubAnimate();
220
+ try {
221
+ const config: AgentWidgetConfig = { future: { v5Defaults: true } };
222
+ const off = createScrollToBottomControl(icon, "transcript");
223
+ off.element.style.setProperty("--persona-motion-duration-base", "0ms");
224
+ off.update(config, true);
225
+ off.update(config, true, false, 0, true);
226
+ expect(calls).toHaveLength(0);
227
+ expect(off.element.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(true);
228
+ off.destroy();
229
+
230
+ const unset = createScrollToBottomControl(icon, "transcript");
231
+ unset.update(config, true);
232
+ unset.update(config, true, false, 0, true);
233
+ expect(calls[0].options.duration).toBe(200);
234
+ unset.destroy();
235
+ } finally { restore(); }
236
+ });
237
+
238
+ it("does not morph a control that appears, hides, or prefers reduced motion", () => {
239
+ const { calls, restore } = stubAnimate();
240
+ try {
241
+ const config: AgentWidgetConfig = { future: { v5Defaults: true } };
242
+ const control = createScrollToBottomControl(icon, "transcript");
243
+ control.update(config, false);
244
+ control.update(config, true, false, 0, true); // appears already as the pill
245
+ control.update(config, false, false, 0, true); // hides
246
+ expect(calls).toHaveLength(0);
247
+ control.destroy();
248
+
249
+ vi.stubGlobal("matchMedia", () => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }));
250
+ const reduced = createScrollToBottomControl(icon, "transcript");
251
+ reduced.update(config, true);
252
+ reduced.update(config, true, false, 0, true);
253
+ expect(calls).toHaveLength(0);
254
+ expect(reduced.element.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(true);
255
+ reduced.destroy();
256
+ } finally { restore(); }
257
+ });
258
+ });
259
+
125
260
  it("provides live reduced-motion changes and releases its media listener", () => {
126
261
  let change: (() => void) | undefined;
127
262
  const media = { matches: true, addEventListener: vi.fn((_event, callback) => { change = callback; }), removeEventListener: vi.fn() };
@@ -25,9 +25,7 @@ export function createScrollToBottomControl(
25
25
  const label = createElement("span", "");
26
26
  const badge = createElement("span", "");
27
27
  badge.setAttribute("data-persona-scroll-to-bottom-count", "");
28
- element.appendChild(glyph);
29
- element.appendChild(label);
30
- element.appendChild(badge);
28
+ element.append(glyph, label, badge);
31
29
  let config: AgentWidgetConfig = {};
32
30
  let state: AgentWidgetScrollToBottomIconState = { activity: "idle", visible: false, hovered: false, focused: false, reducedMotion: false };
33
31
  // Keep renderer identity separate from its handle, including null fallbacks.
@@ -36,6 +34,26 @@ export function createScrollToBottomControl(
36
34
  let abort: AbortController | undefined;
37
35
  let builtin = "";
38
36
  const media = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : undefined;
37
+ // Arrow <-> "Approval needed" pill: one button changing shape, so morph its
38
+ // width and colors on the theme's motion tokens while the new label fades in.
39
+ let morphs: Animation[] = [];
40
+ const paint = () => {
41
+ const style = getComputedStyle(element);
42
+ return { width: `${element.offsetWidth}px`, backgroundColor: style.backgroundColor, borderColor: style.borderColor, color: style.color };
43
+ };
44
+ const morph = (from: Keyframe) => {
45
+ const style = getComputedStyle(element);
46
+ const value = style.getPropertyValue("--persona-motion-duration-base");
47
+ const ms = parseFloat(value) * (value.includes("ms") ? 1 : 1000);
48
+ // "0ms" turns motion off; only a missing or invalid value falls back.
49
+ if (ms === 0) return;
50
+ const timing = { duration: ms > 0 ? ms : 200, easing: style.getPropertyValue("--persona-motion-easing").trim() || "ease-out" };
51
+ // Clip only while widening into the pill: the arrow's count badge hangs
52
+ // outside the button.
53
+ const clip = label.style.display === "none" ? null : { overflow: "hidden" };
54
+ morphs = [element.animate([{ ...from, ...clip }, { ...paint(), ...clip }], timing)];
55
+ if (clip) morphs.push(label.animate([{ opacity: 0 }, { opacity: 1 }], timing));
56
+ };
39
57
  state.reducedMotion = media?.matches ?? false;
40
58
  const warn = (error: unknown) => console.warn("Persona scroll icon failed", error);
41
59
  const context = (): AgentWidgetScrollToBottomIconContext => ({ config, state: { ...state }, view, signal: abort!.signal });
@@ -51,7 +69,9 @@ export function createScrollToBottomControl(
51
69
  if (handle?.element) return;
52
70
  const feature = config.features?.scrollToBottom;
53
71
  const dots = state.activity === "generating-below" && (feature?.streamingIndicator ?? (config.future?.v5Defaults ? "dots" : "arrow")) === "dots";
54
- const key = dots ? "dots" : `arrow:${feature?.iconName ?? "arrow-down"}`;
72
+ // Same glyph as the approval card it points at, so the two read as one thing.
73
+ const iconName = state.activity === "approval-below" ? "shield-check" : feature?.iconName ?? "arrow-down";
74
+ const key = dots ? "dots" : `arrow:${iconName}`;
55
75
  if (builtin === key) return;
56
76
  builtin = key;
57
77
  glyph.innerHTML = "";
@@ -64,7 +84,7 @@ export function createScrollToBottomControl(
64
84
  }
65
85
  glyph.appendChild(dotGroup);
66
86
  } else {
67
- const icon = renderIcon(feature?.iconName ?? "arrow-down", "14px", "currentColor", 2);
87
+ const icon = renderIcon(iconName, "14px", "currentColor", 2);
68
88
  if (icon) glyph.appendChild(icon);
69
89
  }
70
90
  };
@@ -106,11 +126,12 @@ export function createScrollToBottomControl(
106
126
 
107
127
  return {
108
128
  element,
109
- update(nextConfig: AgentWidgetConfig, visible: boolean, active = false, count = 0) {
129
+ /** `approval` marks a pending approval below the fold: the control names it. */
130
+ update(nextConfig: AgentWidgetConfig, visible: boolean, active = false, count = 0, approval = false) {
110
131
  const previous = config.features?.scrollToBottom;
111
132
  const next = nextConfig.features?.scrollToBottom;
112
133
  const configChanged = config.future?.v5Defaults !== nextConfig.future?.v5Defaults ||
113
- (["enabled", "iconName", "label", "streamingIndicator", "transition", "scrollBehavior", "scrollDurationMs"] as (keyof AgentWidgetScrollToBottomFeature)[])
134
+ (["enabled", "iconName", "label", "approvalLabel", "streamingIndicator", "transition", "scrollBehavior", "scrollDurationMs"] as (keyof AgentWidgetScrollToBottomFeature)[])
114
135
  .some((key) => previous?.[key] !== next?.[key]);
115
136
  config = nextConfig;
116
137
  const feature = config.features?.scrollToBottom;
@@ -132,21 +153,37 @@ export function createScrollToBottomControl(
132
153
  focusTarget.focus({ preventScroll: true });
133
154
  }
134
155
  }
135
- const text = feature?.label ?? "";
136
- const base = text || "Jump to latest";
137
- element.setAttribute("aria-label", `${base}${count > 0 ? ` (${count} new)` : ""}${active && visible ? " (response streaming below)" : ""}`);
156
+ // A waiting approval outranks streaming and the unread count: the turn is
157
+ // blocked on the reader, so the control says so in words.
158
+ const waiting = approval && visible;
159
+ // Morph only a visible control changing shape, never its fade in or out.
160
+ const reshape = state.visible && visible && (state.activity === "approval-below") !== waiting;
161
+ if (reshape || !visible) morphs.forEach((animation) => animation.cancel());
162
+ const from = reshape && !state.reducedMotion && typeof element.animate === "function" ? paint() : null;
163
+ const approvalText = feature?.approvalLabel ?? "Approval needed";
164
+ const text = waiting && approvalText ? approvalText : feature?.label ?? "";
165
+ const base = feature?.label || "Jump to latest";
166
+ element.setAttribute("aria-label", waiting
167
+ ? `${approvalText || "Approval needed"} below. ${base}`
168
+ : `${base}${count > 0 ? ` (${count} new)` : ""}${active && visible ? " (response streaming below)" : ""}`);
138
169
  element.title = text;
139
170
  element.setAttribute("data-persona-scroll-to-bottom-has-label", String(Boolean(text)));
171
+ if (waiting) element.setAttribute("data-persona-scroll-to-bottom-approval", "");
172
+ else element.removeAttribute("data-persona-scroll-to-bottom-approval");
140
173
  label.textContent = text;
141
174
  label.style.display = text ? "" : "none";
142
- badge.textContent = count > 0 ? String(count) : "";
143
- badge.style.display = count > 0 ? "" : "none";
144
- const nextState = { ...state, visible, hovered: visible && state.hovered, focused: visible && state.focused, activity: active && visible ? "generating-below" as const : "idle" as const };
175
+ const shownCount = waiting ? 0 : count;
176
+ badge.textContent = shownCount > 0 ? String(shownCount) : "";
177
+ badge.style.display = shownCount > 0 ? "" : "none";
178
+ const activity = waiting ? "approval-below" as const : active && visible ? "generating-below" as const : "idle" as const;
179
+ const nextState = { ...state, visible, hovered: visible && state.hovered, focused: visible && state.focused, activity };
145
180
  const changed = configChanged || stateChanged(nextState);
146
181
  state = nextState;
147
182
  updateRenderer(changed);
183
+ if (from) morph(from);
148
184
  },
149
185
  destroy() {
186
+ morphs.forEach((animation) => animation.cancel());
150
187
  dispose();
151
188
  lifetime.abort();
152
189
  media?.removeEventListener?.("change", motion);
@@ -0,0 +1,117 @@
1
+ import type { AgentWidgetConfig } from "../types";
2
+ import { createNode } from "../utils/dom";
3
+
4
+ /**
5
+ * Long user message collapse ("Show more" / "Show less").
6
+ *
7
+ * The default user bubble wraps its content (attachments, mention chips and
8
+ * text) in a clip region. ui.ts measures the region's real rendered height
9
+ * after each morph and decides whether it overflows; the bubble is then
10
+ * re-rendered from that state, so morphs and cached wrappers always agree
11
+ * with the live DOM. The full text stays in the DOM in every state (no nested
12
+ * scroller), so find-in-page, selection, copy and screen readers still reach it.
13
+ */
14
+
15
+ /**
16
+ * Default collapsed height. A bubble line is ~22–24px (0.875rem at
17
+ * leading-relaxed/1.75), so 240px shows about ten lines: enough to recognise
18
+ * the message, while leaving most of a ~640px panel for the reply that
19
+ * anchor-top streams in beneath it.
20
+ */
21
+ export const DEFAULT_USER_MESSAGE_COLLAPSE_MAX_HEIGHT = 240;
22
+
23
+ /**
24
+ * Content within this many px (about two lines) of the limit stays expanded,
25
+ * so the toggle never hides just a line or two behind a button of its own size.
26
+ */
27
+ export const USER_MESSAGE_COLLAPSE_SLACK = 48;
28
+
29
+ /**
30
+ * - `fit`: not measured yet, or short enough; no toggle.
31
+ * - `live`: an interim voice transcript; expanded so the speaker can watch it.
32
+ * - `settling`: the transcript's first final render, still expanded; the
33
+ * post-morph measurement settles it in the same pass, holding the viewport.
34
+ * - `collapsed` / `expanded`: overflowing, with the toggle.
35
+ */
36
+ export type UserMessageCollapseState = "fit" | "live" | "settling" | "collapsed" | "expanded";
37
+
38
+ export type UserMessageCollapseRender = {
39
+ state: UserMessageCollapseState;
40
+ maxHeight: number;
41
+ showMoreLabel: string;
42
+ showLessLabel: string;
43
+ };
44
+
45
+ export const USER_COLLAPSE_REGION_ATTR = "data-persona-user-collapse";
46
+ export const USER_COLLAPSE_TOGGLE_ATTR = "data-persona-user-collapse-toggle";
47
+
48
+ export const isUserMessageCollapseEnabled = (config: AgentWidgetConfig): boolean =>
49
+ config.features?.userMessageCollapse?.enabled === true;
50
+
51
+ export const resolveUserMessageCollapseMaxHeight = (config: AgentWidgetConfig): number => {
52
+ const value = config.features?.userMessageCollapse?.maxHeight;
53
+ return typeof value === "number" && Number.isFinite(value) && value > 0
54
+ ? value
55
+ : DEFAULT_USER_MESSAGE_COLLAPSE_MAX_HEIGHT;
56
+ };
57
+
58
+ export const resolveUserMessageCollapseLabels = (
59
+ config: AgentWidgetConfig
60
+ ): Pick<UserMessageCollapseRender, "showMoreLabel" | "showLessLabel"> => ({
61
+ showMoreLabel: config.copy?.showMoreLabel?.trim() || "Show more",
62
+ showLessLabel: config.copy?.showLessLabel?.trim() || "Show less",
63
+ });
64
+
65
+ /** Whether a measured natural content height warrants collapsing. */
66
+ export const userMessageOverflows = (naturalHeight: number, maxHeight: number): boolean =>
67
+ naturalHeight > maxHeight + USER_MESSAGE_COLLAPSE_SLACK;
68
+
69
+ const regionId = (messageId: string) => `persona-user-collapse-${messageId}`;
70
+
71
+ export const createUserCollapseRegion = (
72
+ messageId: string,
73
+ maxHeight: number
74
+ ): HTMLElement => {
75
+ const region = createNode("div", {
76
+ className: "persona-user-collapse-region",
77
+ attrs: { id: regionId(messageId), [USER_COLLAPSE_REGION_ATTR]: "fit" },
78
+ });
79
+ region.style.setProperty("--persona-user-collapse-max-height", `${maxHeight}px`);
80
+ return region;
81
+ };
82
+
83
+ /**
84
+ * Apply a state to a region and its sibling toggle. Used both while building a
85
+ * fresh bubble and to patch the live bubble in place on a toggle or a new
86
+ * measurement, so the toggle keeps its identity (and focus) across states.
87
+ */
88
+ export const applyUserCollapseState = (
89
+ bubble: HTMLElement,
90
+ region: HTMLElement,
91
+ messageId: string,
92
+ render: UserMessageCollapseRender
93
+ ): void => {
94
+ const { state } = render;
95
+ region.setAttribute(USER_COLLAPSE_REGION_ATTR, state);
96
+ region.style.setProperty("--persona-user-collapse-max-height", `${render.maxHeight}px`);
97
+ let toggle = bubble.querySelector<HTMLButtonElement>(`button[${USER_COLLAPSE_TOGGLE_ATTR}]`);
98
+ if (state !== "collapsed" && state !== "expanded") {
99
+ toggle?.remove();
100
+ return;
101
+ }
102
+ if (!toggle) {
103
+ toggle = createNode("button", {
104
+ className: "persona-user-collapse-toggle",
105
+ attrs: {
106
+ type: "button",
107
+ id: `persona-user-collapse-toggle-${messageId}`,
108
+ [USER_COLLAPSE_TOGGLE_ATTR]: "",
109
+ "aria-controls": regionId(messageId),
110
+ },
111
+ }) as HTMLButtonElement;
112
+ region.after(toggle);
113
+ }
114
+ const expanded = state === "expanded";
115
+ toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
116
+ toggle.textContent = expanded ? render.showLessLabel : render.showMoreLabel;
117
+ };
package/src/defaults.ts CHANGED
@@ -266,6 +266,9 @@ export const DEFAULTS_V5: Partial<AgentWidgetConfig> = {
266
266
  features: {
267
267
  artifacts: { enabled: true },
268
268
  scrollToBottom: { streamingIndicator: "dots", transition: "fade", scrollBehavior: "smooth" },
269
+ // Long sent messages (pasted text, voice transcripts) would otherwise fill
270
+ // the viewport under the anchor-top pin and push the reply out of view.
271
+ userMessageCollapse: { enabled: true },
269
272
  toolCallDisplay: DEFAULT_TOOL_CALL_DISPLAY_V5,
270
273
  reasoningDisplay: DEFAULT_REASONING_DISPLAY_V5,
271
274
  },
@@ -382,6 +385,8 @@ export function mergeWithDefaults(
382
385
  const csa = config.features?.streamAnimation;
383
386
  const dau = defaults.features?.askUserQuestion;
384
387
  const cau = config.features?.askUserQuestion;
388
+ const duc = defaults.features?.userMessageCollapse;
389
+ const cuc = config.features?.userMessageCollapse;
385
390
  const dh = defaults.features?.history;
386
391
  const ch = config.features?.history;
387
392
  const mergedArtifacts =
@@ -459,6 +464,7 @@ export function mergeWithDefaults(
459
464
  ...(mergedStreamAnimation !== undefined ? { streamAnimation: mergedStreamAnimation } : {}),
460
465
  ...(mergedAskUserQuestion !== undefined ? { askUserQuestion: mergedAskUserQuestion } : {}),
461
466
  ...(mergedHistory !== undefined ? { history: mergedHistory } : {}),
467
+ ...(duc !== undefined || cuc !== undefined ? { userMessageCollapse: { ...duc, ...cuc } } : {}),
462
468
  };
463
469
  })(),
464
470
  suggestionChips: config.suggestionChips ?? defaults.suggestionChips,
@@ -3109,6 +3109,52 @@
3109
3109
  box-shadow: var(--persona-message-user-shadow);
3110
3110
  }
3111
3111
 
3112
+ /* Long user message collapse (features.userMessageCollapse). The region clips
3113
+ without becoming a scroller (overflow: clip), so the full text stays in the
3114
+ DOM for find, copy and screen readers with no nested scroll area. The fade
3115
+ is a mask rather than a gradient overlay, so it works on any bubble fill in
3116
+ light and dark schemes; the toggle inherits the bubble's text color. */
3117
+ .persona-user-collapse-region[data-persona-user-collapse="collapsed"] {
3118
+ max-height: var(--persona-user-collapse-max-height, 240px);
3119
+ overflow: clip;
3120
+ -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - var(--persona-user-collapse-fade-height, 48px)), transparent);
3121
+ mask-image: linear-gradient(to bottom, #000 calc(100% - var(--persona-user-collapse-fade-height, 48px)), transparent);
3122
+ }
3123
+
3124
+ .persona-user-collapse-toggle {
3125
+ display: inline-flex;
3126
+ align-items: center;
3127
+ margin-top: 0.25rem;
3128
+ padding: 0.125rem 0;
3129
+ border: 0;
3130
+ background: none;
3131
+ color: var(--persona-user-collapse-toggle-fg, inherit);
3132
+ font-family: inherit;
3133
+ font-size: var(--persona-user-collapse-toggle-font-size, 0.8125rem);
3134
+ font-weight: var(--persona-user-collapse-toggle-font-weight, 600);
3135
+ line-height: 1.25rem;
3136
+ cursor: pointer;
3137
+ text-decoration: underline;
3138
+ text-decoration-color: transparent;
3139
+ text-underline-offset: 2px;
3140
+ transition: text-decoration-color 150ms ease;
3141
+ }
3142
+
3143
+ .persona-user-collapse-toggle:hover {
3144
+ text-decoration-color: currentColor;
3145
+ }
3146
+
3147
+ .persona-user-collapse-toggle:focus-visible {
3148
+ outline: 2px solid currentColor;
3149
+ outline-offset: 2px;
3150
+ }
3151
+
3152
+ @media (prefers-reduced-motion: reduce) {
3153
+ .persona-user-collapse-toggle {
3154
+ transition: none;
3155
+ }
3156
+ }
3157
+
3112
3158
  [data-persona-root] .persona-tool-bubble.persona-shadow-sm {
3113
3159
  box-shadow: var(--persona-tool-bubble-shadow);
3114
3160
  }
@@ -4869,6 +4915,16 @@
4869
4915
  text-align: center;
4870
4916
  }
4871
4917
 
4918
+ /* A pending approval below the fold: the control widens to a labelled pill.
4919
+ Unset approval tokens keep the pill's own colors. */
4920
+ [data-persona-root] .persona-scroll-to-bottom-indicator[data-persona-scroll-to-bottom-approval] {
4921
+ border-color: var(--persona-scroll-to-bottom-approval-border, var(--persona-scroll-to-bottom-border, var(--persona-primary, #111827)));
4922
+ background: var(--persona-scroll-to-bottom-approval-bg, var(--persona-scroll-to-bottom-bg, var(--persona-button-primary-bg, var(--persona-accent, #0f0f0f))));
4923
+ color: var(--persona-scroll-to-bottom-approval-fg, var(--persona-scroll-to-bottom-fg, var(--persona-button-primary-fg, var(--persona-text-inverse, #ffffff))));
4924
+ font-weight: var(--persona-scroll-to-bottom-approval-font-weight, 500);
4925
+ white-space: nowrap;
4926
+ }
4927
+
4872
4928
  [data-persona-root] .persona-scroll-to-bottom-indicator:hover {
4873
4929
  opacity: 0.92;
4874
4930
  }
@@ -171,7 +171,7 @@ export const THEME_TOKEN_DOCS = {
171
171
  askUserQuestion: 'sheet (background, border, shadow), pill (background, foreground, borderRadius, selectedBackground, selectedForeground), customInput.background. Local features.askUserQuestion.styles still wins; navigation, labels and slide timing remain feature config.',
172
172
  attachment: 'image (background, border).',
173
173
  scrollToBottom:
174
- 'Floating scroll-to-bottom affordance shared by transcript and event stream: background, foreground, border, size, borderRadius, shadow, padding, gap, fontSize, iconSize.',
174
+ 'Floating scroll-to-bottom affordance shared by transcript and event stream: background, foreground, border, size, borderRadius, shadow, padding, gap, fontSize, iconSize; approval (background, foreground, border, fontWeight) for the labelled "Approval needed" state, falling back to the control colors.',
175
175
  toolBubble: 'shadow, background, borderColor, borderWidth, borderRadius; header (background, foreground, padding), content (background, foreground, padding), codeBlock (background, foreground, borderColor), toggleForeground, labelForeground, loading (primary, secondary, duration). Applies to card and activity-row variants without changing their distinct defaults. CSS duration uses ms/s; legacy numeric milliseconds must convert explicitly. Theme content.padding precedes activity.bodyPadding/toolBubble.bodyPadding fallbacks; explicit legacy axes override only their axis.',
176
176
  scrollbar:
177
177
  'Shared scrollbar appearance for every scroller: thumb (default semantic.colors.border), track (default transparent).',
@@ -250,7 +250,7 @@ export const THEME_TOKEN_DOCS = {
250
250
  description:
251
251
  'Shared transcript + event-stream jump-to-latest affordance.',
252
252
  properties:
253
- 'features.scrollToBottom.enabled, features.scrollToBottom.iconName, features.scrollToBottom.label (empty string renders icon-only). Defaults: enabled=true, iconName="arrow-down", label="".',
253
+ 'features.scrollToBottom.enabled, features.scrollToBottom.iconName, features.scrollToBottom.label (empty string renders icon-only), features.scrollToBottom.approvalLabel (shown while a pending approval is below the fold; empty string keeps the regular control). Defaults: enabled=true, iconName="arrow-down", label="", approvalLabel="Approval needed".',
254
254
  },
255
255
  toolCall: {
256
256
  description: