@runtypelabs/persona 4.17.1 → 4.18.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 (158) hide show
  1. package/dist/chunk-3I2S3I5P.js +1 -0
  2. package/dist/chunk-76IELU7F.js +1 -0
  3. package/dist/chunk-EFJBUH5F.js +1 -0
  4. package/dist/chunk-PDPVORUF.js +1 -0
  5. package/dist/chunk-Q735PSZL.js +1 -0
  6. package/dist/chunk-VK6JOIYJ.js +1 -0
  7. package/dist/codegen.cjs +4 -4
  8. package/dist/codegen.js +4 -4
  9. package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
  10. package/dist/context-mentions.cjs +9 -8
  11. package/dist/context-mentions.d.cts +1212 -14
  12. package/dist/context-mentions.d.ts +1212 -14
  13. package/dist/context-mentions.js +9 -8
  14. package/dist/event-stream-view-4EZ7BGFK.js +1 -0
  15. package/dist/event-stream-view.cjs +129 -0
  16. package/dist/event-stream-view.d.cts +8384 -0
  17. package/dist/event-stream-view.d.ts +8384 -0
  18. package/dist/event-stream-view.js +129 -0
  19. package/dist/history-view-BD6OXLNA.js +898 -0
  20. package/dist/history-view.cjs +898 -0
  21. package/dist/history-view.d.cts +1461 -0
  22. package/dist/history-view.d.ts +1461 -0
  23. package/dist/history-view.js +898 -0
  24. package/dist/index.cjs +77 -77
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +1987 -17
  27. package/dist/index.d.ts +1987 -17
  28. package/dist/index.global.js +67 -66
  29. package/dist/index.global.js.map +1 -1
  30. package/dist/index.js +77 -77
  31. package/dist/index.js.map +1 -1
  32. package/dist/launcher.global.js +5 -4
  33. package/dist/launcher.global.js.map +1 -1
  34. package/dist/plugin-kit.cjs +1 -1
  35. package/dist/plugin-kit.d.cts +0 -8
  36. package/dist/plugin-kit.d.ts +0 -8
  37. package/dist/plugin-kit.js +1 -1
  38. package/dist/smart-dom-reader.d.cts +1212 -14
  39. package/dist/smart-dom-reader.d.ts +1212 -14
  40. package/dist/theme-editor-preview.cjs +970 -73
  41. package/dist/theme-editor-preview.d.cts +1331 -15
  42. package/dist/theme-editor-preview.d.ts +1331 -15
  43. package/dist/theme-editor-preview.js +81 -81
  44. package/dist/theme-editor.cjs +7 -7
  45. package/dist/theme-editor.d.cts +1215 -15
  46. package/dist/theme-editor.d.ts +1215 -15
  47. package/dist/theme-editor.js +9 -9
  48. package/dist/theme-reference.cjs +1 -1
  49. package/dist/theme-reference.d.cts +226 -7
  50. package/dist/theme-reference.d.ts +226 -7
  51. package/dist/theme-reference.js +1 -1
  52. package/dist/widget.css +1 -1
  53. package/package.json +16 -4
  54. package/src/client-history-rest.test.ts +1034 -0
  55. package/src/client-visitor-history.test.ts +596 -0
  56. package/src/client.ts +1040 -24
  57. package/src/components/artifact-inline.ts +8 -1
  58. package/src/components/artifact-pane.test.ts +278 -1
  59. package/src/components/artifact-pane.ts +135 -67
  60. package/src/components/artifact-preview.test.ts +14 -1
  61. package/src/components/artifact-preview.ts +9 -0
  62. package/src/components/header-builder.ts +3 -0
  63. package/src/components/header-layouts.test.ts +171 -7
  64. package/src/components/header-layouts.ts +83 -53
  65. package/src/components/header-parts.test.ts +154 -1
  66. package/src/components/header-parts.ts +266 -138
  67. package/src/components/history-confirm-dialog.ts +177 -0
  68. package/src/components/history-shell-copy.ts +99 -0
  69. package/src/components/history-view/announcer.ts +40 -0
  70. package/src/components/history-view/copy.ts +105 -0
  71. package/src/components/history-view/css.ts +1078 -0
  72. package/src/components/history-view/grouping.test.ts +142 -0
  73. package/src/components/history-view/grouping.ts +142 -0
  74. package/src/components/history-view/icons.ts +73 -0
  75. package/src/components/history-view/rows.ts +341 -0
  76. package/src/components/history-view/state.ts +309 -0
  77. package/src/components/history-view.test.ts +2660 -0
  78. package/src/components/history-view.ts +1891 -0
  79. package/src/components/launcher-surface.test.ts +133 -0
  80. package/src/components/launcher.test.ts +18 -0
  81. package/src/components/launcher.ts +7 -7
  82. package/src/components/panel.ts +17 -7
  83. package/src/components/welcome.ts +72 -2
  84. package/src/defaults.ts +36 -3
  85. package/src/event-stream-view-bundle.test.ts +69 -0
  86. package/src/event-stream-view-entry.ts +19 -0
  87. package/src/event-stream-view-loader.ts +31 -0
  88. package/src/event-stream-view.ts +19 -0
  89. package/src/generated/runtype-openapi-contract.ts +1 -0
  90. package/src/history-render.ts +325 -0
  91. package/src/history-view-bundle.test.ts +78 -0
  92. package/src/history-view-entry.ts +41 -0
  93. package/src/history-view-loader.test.ts +30 -0
  94. package/src/history-view-loader.ts +32 -0
  95. package/src/history-view.ts +28 -0
  96. package/src/index-core.ts +50 -2
  97. package/src/index-global.ts +55 -0
  98. package/src/internal/demo-history-provider.test.ts +402 -0
  99. package/src/internal/demo-history-provider.ts +689 -0
  100. package/src/internal/history-provider-registry.ts +25 -0
  101. package/src/internal/history-provider.ts +150 -0
  102. package/src/internal/runtype-history-provider.test.ts +313 -0
  103. package/src/internal/runtype-history-provider.ts +240 -0
  104. package/src/plugin-kit.test.ts +16 -0
  105. package/src/plugin-kit.ts +13 -5
  106. package/src/plugins/types.ts +177 -1
  107. package/src/session-history.test.ts +1397 -0
  108. package/src/session.ts +1200 -26
  109. package/src/styles/widget-styles.test.ts +133 -0
  110. package/src/styles/widget.css +227 -8
  111. package/src/theme-editor/preview-utils.test.ts +19 -0
  112. package/src/theme-editor/preview-utils.ts +15 -1
  113. package/src/theme-editor/preview.ts +7 -1
  114. package/src/theme-editor/role-mappings.ts +4 -1
  115. package/src/theme-editor/sections.test.ts +168 -1
  116. package/src/theme-editor/sections.ts +136 -7
  117. package/src/theme-editor/webmcp/summary.ts +16 -2
  118. package/src/theme-reference.ts +20 -6
  119. package/src/types/theme.ts +213 -7
  120. package/src/types.ts +927 -7
  121. package/src/ui.artifact-rail-resize.test.ts +235 -0
  122. package/src/ui.attachments-drop.test.ts +1 -0
  123. package/src/ui.header-update-stability.test.ts +2 -2
  124. package/src/ui.history-chunk.test.ts +72 -0
  125. package/src/ui.history-config-provider.test.ts +318 -0
  126. package/src/ui.history-controller.test.ts +711 -0
  127. package/src/ui.history-lifecycle.test.ts +456 -0
  128. package/src/ui.history-render-hooks.test.ts +904 -0
  129. package/src/ui.history-shell.test.ts +1887 -0
  130. package/src/ui.rail-overlay.test.ts +573 -0
  131. package/src/ui.rail-resize.test.ts +284 -0
  132. package/src/ui.rebuild-composer.test.ts +21 -0
  133. package/src/ui.render-welcome.test.ts +42 -0
  134. package/src/ui.shortcuts.test.ts +166 -0
  135. package/src/ui.ts +3071 -240
  136. package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
  137. package/src/utils/artifact-file.ts +15 -0
  138. package/src/utils/artifact-gate.test.ts +4 -0
  139. package/src/utils/artifact-gate.ts +13 -17
  140. package/src/utils/artifact-resize.test.ts +28 -0
  141. package/src/utils/artifact-resize.ts +9 -2
  142. package/src/utils/buttons.ts +188 -1
  143. package/src/utils/config-merge.test.ts +91 -0
  144. package/src/utils/dropdown.ts +10 -1
  145. package/src/utils/history-messages.test.ts +284 -0
  146. package/src/utils/history-messages.ts +284 -0
  147. package/src/utils/icons.ts +3 -0
  148. package/src/utils/shortcuts.test.ts +288 -0
  149. package/src/utils/shortcuts.ts +221 -0
  150. package/src/utils/theme.test.ts +501 -4
  151. package/src/utils/theme.ts +42 -3
  152. package/src/utils/tokens.ts +153 -15
  153. package/src/utils/tooltip-timing.ts +4 -0
  154. package/src/utils/tooltip.test.ts +249 -7
  155. package/src/utils/tooltip.ts +146 -4
  156. package/src/utils/visitor-store.test.ts +425 -0
  157. package/src/utils/visitor-store.ts +337 -0
  158. package/dist/chunk-QJ4LV2KW.js +0 -1
@@ -4,6 +4,229 @@ import { AgentWidgetConfig } from "../types";
4
4
  import { attachTooltip } from "../utils/tooltip";
5
5
  import { HEADER_THEME_CSS } from "./header-builder";
6
6
 
7
+ /**
8
+ * Class hook for the shared header control box. The stylesheet sizes it from
9
+ * `--persona-header-control-size` (theme `components.header.controlSize`).
10
+ */
11
+ export const HEADER_CONTROL_CLASS = "persona-header-control";
12
+ /** Adds `--persona-header-control-icon-size` sizing to the control's glyph. */
13
+ export const HEADER_CONTROL_GLYPH_CLASS = "persona-header-control--glyph";
14
+ /** Scales the glyph up for icons whose paths under-fill the 24x24 viewBox. */
15
+ export const HEADER_CONTROL_SPARSE_CLASS = "persona-header-control--sparse";
16
+ /**
17
+ * Icon-size token default in CSS pixels. Only derives the SVG attributes when a
18
+ * per-control size is unset; the box itself is owned by the stylesheet.
19
+ */
20
+ const ICON_FALLBACK_PX = 20;
21
+
22
+ /**
23
+ * Icons whose Lucide paths occupy only the middle of the 24x24 viewBox (X spans
24
+ * 6,6 to 18,18; siblings such as refresh-cw span ~75%). Rendering them larger
25
+ * brings their visible extent into parity; the stroke is thinned by the same
26
+ * factor so the weight still matches. Keep the scale in sync with the
27
+ * `.persona-header-control--sparse` multiplier in widget.css.
28
+ */
29
+ const SPARSE_GLYPHS: Record<string, [number, number]> = { x: [1.4, 1.05] };
30
+
31
+ // Flex, not block: an inline-flex button in a block wrapper reserves baseline
32
+ // slack and rides high inside it.
33
+ const DEFAULT_WRAPPER_CLASS =
34
+ "persona-relative persona-ml-auto persona-inline-flex persona-items-center persona-justify-center";
35
+
36
+ const DEFAULT_CLEAR_CHAT_WRAPPER_CLASS =
37
+ "persona-relative persona-ml-auto persona-clear-chat-button-wrapper";
38
+
39
+ export interface HeaderControlGlyphOptions {
40
+ /**
41
+ * Explicit nominal glyph size. Unset hands the box to
42
+ * `--persona-header-control-icon-size` via the glyph class hook.
43
+ */
44
+ iconSize?: string;
45
+ /** Text glyph rendered when the icon name is not in the registry. */
46
+ iconText?: string;
47
+ }
48
+
49
+ /**
50
+ * Render (or re-render) a header control's glyph with the shared optical
51
+ * compensation, syncing the class hooks that let the stylesheet own the box.
52
+ * Both the factory and the live `update()` restyle path go through it so a
53
+ * config change can never repaint a control at a different size or weight.
54
+ */
55
+ export const applyHeaderControlGlyph = (
56
+ button: HTMLButtonElement,
57
+ iconName: string,
58
+ options: HeaderControlGlyphOptions = {},
59
+ ): void => {
60
+ const { iconSize, iconText } = options;
61
+ // [glyph scale, stroke width]; dense glyphs take the standard pair.
62
+ const [scale, stroke] = SPARSE_GLYPHS[iconName] ?? [1, 1.5];
63
+ // Without an explicit size the stylesheet owns the glyph box, so the sparse
64
+ // multiplier has to live there too.
65
+ const tokenGlyph = !iconSize;
66
+ button.classList.toggle(HEADER_CONTROL_GLYPH_CLASS, tokenGlyph);
67
+ button.classList.toggle(HEADER_CONTROL_SPARSE_CLASS, tokenGlyph && scale !== 1);
68
+
69
+ // Attribute sizing only decides the glyph when the stylesheet is not driving
70
+ // it; with the glyph class the CSS width/height wins over these attributes.
71
+ const nominalPx = parseFloat(iconSize ?? "") || ICON_FALLBACK_PX;
72
+ const icon = renderLucideIcon(
73
+ iconName,
74
+ `${Math.round(nominalPx * scale)}px`,
75
+ "currentColor",
76
+ stroke,
77
+ );
78
+ if (icon) {
79
+ // Inline SVG baseline spacing pushes the glyph a fractional pixel
80
+ // off-center inside the button.
81
+ icon.style.display = "block";
82
+ if (!tokenGlyph) {
83
+ // Explicit sizing detaches the glyph classes, so the stroke token has to
84
+ // ride inline; the attribute stroke stays as the no-token fallback.
85
+ icon.style.strokeWidth = `calc(var(--persona-components-header-controlStrokeWidth, 1.5) * ${Number((stroke / 1.5).toFixed(4))})`;
86
+ }
87
+ button.replaceChildren(icon);
88
+ } else if (iconText) {
89
+ button.textContent = iconText;
90
+ }
91
+ // Unrenderable glyph with no text fallback keeps whatever was mounted; an
92
+ // empty button is worse than a stale one.
93
+ };
94
+
95
+ export interface HeaderIconButtonParts {
96
+ button: HTMLButtonElement;
97
+ wrapper: HTMLElement;
98
+ }
99
+
100
+ export interface CreateHeaderIconButtonOptions {
101
+ /** Required: also the default tooltip text. */
102
+ ariaLabel: string;
103
+ /**
104
+ * Attach the shared styled tooltip, reading the button's live `aria-label`
105
+ * so a relabel (e.g. Messages while busy) stays accurate. `false` opts out;
106
+ * default true.
107
+ */
108
+ tooltip?: boolean;
109
+ /** Muted chip after the tooltip label, e.g. a keyboard shortcut. */
110
+ tooltipHint?: string | (() => string);
111
+ /** Lucide icon name; falls back to `iconText` when it is not in the registry. */
112
+ iconName?: string;
113
+ /** Text glyph rendered when no icon resolves. */
114
+ iconText?: string;
115
+ /** Wrapper className. Always flex so the button can never ride off-center. */
116
+ wrapperClassName?: string;
117
+ /**
118
+ * Explicit box size. Unset leaves `--persona-header-control-size` in charge;
119
+ * set, it writes an inline box that overrides the token.
120
+ */
121
+ size?: string;
122
+ /**
123
+ * Explicit nominal glyph size. Unset leaves `--persona-header-control-icon-size`
124
+ * in charge. Sparse-viewBox compensation applies on top either way.
125
+ */
126
+ iconSize?: string;
127
+ /** Falls back to the header zone's action-icon color. */
128
+ color?: string;
129
+ /** Set, it replaces the transparent + hover-fill default classes. */
130
+ backgroundColor?: string;
131
+ borderWidth?: string;
132
+ borderColor?: string;
133
+ borderRadius?: string;
134
+ paddingX?: string;
135
+ paddingY?: string;
136
+ /** Renders `display: none` without removing the control. */
137
+ hidden?: boolean;
138
+ /** Appended to the button's class list (e.g. a stable selector hook). */
139
+ extraClassName?: string;
140
+ /** Extra attributes, applied after `type` and `aria-label`. */
141
+ attrs?: Record<string, string>;
142
+ }
143
+
144
+ /**
145
+ * The one header icon-button recipe: close, clear chat, trailing actions, and
146
+ * the Messages control all build through it so their box, glyph, and stroke
147
+ * can never drift apart. Config keys stay inline styles, which win over the
148
+ * token-driven stylesheet rules.
149
+ */
150
+ export const createHeaderIconButton = (
151
+ options: CreateHeaderIconButtonOptions,
152
+ ): HeaderIconButtonParts => {
153
+ const {
154
+ ariaLabel,
155
+ tooltip = true,
156
+ tooltipHint,
157
+ iconName,
158
+ iconText,
159
+ wrapperClassName = DEFAULT_WRAPPER_CLASS,
160
+ size,
161
+ iconSize,
162
+ color,
163
+ backgroundColor,
164
+ borderWidth,
165
+ borderColor,
166
+ borderRadius,
167
+ paddingX,
168
+ paddingY,
169
+ hidden,
170
+ extraClassName,
171
+ attrs,
172
+ } = options;
173
+
174
+ const wrapper = createElement("div", wrapperClassName);
175
+
176
+ const hasBorder = Boolean(borderWidth || borderColor);
177
+
178
+ // hover-bg / border-none / rounded-full are default utility classes that
179
+ // apply only when the matching style override is absent; an override sets
180
+ // the inline style instead (cx omits the class).
181
+ const button = createNode("button", {
182
+ className: cx(
183
+ HEADER_CONTROL_CLASS,
184
+ "persona-inline-flex persona-items-center persona-justify-center persona-cursor-pointer",
185
+ !backgroundColor && "persona-bg-transparent hover:persona-bg-gray-100",
186
+ !hasBorder && "persona-border-none",
187
+ !borderRadius && "persona-rounded-full",
188
+ extraClassName,
189
+ ),
190
+ attrs: { type: "button", "aria-label": ariaLabel, ...attrs },
191
+ style: {
192
+ width: size,
193
+ height: size,
194
+ minWidth: size,
195
+ minHeight: size,
196
+ display: hidden ? "none" : undefined,
197
+ color: color || HEADER_THEME_CSS.actionIconColor,
198
+ // Empty strings are a no-op on a fresh element, so they need no guard.
199
+ backgroundColor,
200
+ border: hasBorder
201
+ ? `${borderWidth || "0px"} solid ${borderColor || "transparent"}`
202
+ : undefined,
203
+ borderRadius,
204
+ paddingLeft: paddingX,
205
+ paddingRight: paddingX,
206
+ paddingTop: paddingY,
207
+ paddingBottom: paddingY,
208
+ },
209
+ });
210
+
211
+ if (iconName) {
212
+ applyHeaderControlGlyph(button, iconName, { iconSize, iconText });
213
+ } else if (iconText) {
214
+ button.textContent = iconText;
215
+ }
216
+
217
+ wrapper.appendChild(button);
218
+ // attachTooltip is per-anchor idempotent, and a disabled attach replaces any
219
+ // previous enabled one, so update() rebuilds stay safe.
220
+ attachTooltip({
221
+ anchor: button,
222
+ trigger: wrapper,
223
+ text: () => button.getAttribute("aria-label") ?? ariaLabel,
224
+ hint: tooltipHint,
225
+ enabled: tooltip,
226
+ });
227
+ return { button, wrapper };
228
+ };
229
+
7
230
  export interface CloseButtonParts {
8
231
  button: HTMLButtonElement;
9
232
  wrapper: HTMLElement;
@@ -31,9 +254,8 @@ export interface CreateCloseButtonOptions {
31
254
  */
32
255
  buttonSize?: string;
33
256
  /**
34
- * Override the rendered icon size (default: "28px"). Pair with
35
- * `buttonSize` when scaling the whole control down: otherwise the
36
- * 28px icon will overflow a smaller button.
257
+ * Explicit nominal icon size. Unset means the header control icon-size token
258
+ * owns the glyph. Pair with `buttonSize` when scaling the whole control down.
37
259
  */
38
260
  iconSize?: string;
39
261
  }
@@ -48,22 +270,17 @@ export interface CreateClearChatButtonOptions {
48
270
  /**
49
271
  * Explicit button-size override that wins over `launcher.clearChat.size`.
50
272
  * Composer-bar mode uses this so the clear icon visually matches the
51
- * shrunken close button (16px) and doesn't render at the floating
52
- * launcher's 32px default.
273
+ * shrunken close button and doesn't inherit the header control size.
53
274
  */
54
275
  buttonSize?: string;
55
- /** Override the rendered icon size (default: "20px"). */
276
+ /** Explicit icon size; unset leaves the header control icon-size token. */
56
277
  iconSize?: string;
57
278
  }
58
279
 
59
- const DEFAULT_WRAPPER_CLASS =
60
- "persona-relative persona-ml-auto persona-inline-flex persona-items-center persona-justify-center";
61
-
62
280
  /**
63
- * Build the close (×) button + tooltip used in the panel header. Lifted
64
- * verbatim from header-builder.ts so composer-bar mode can render just a
65
- * close button (no full header strip) without duplicating the tooltip
66
- * + config-driven styling logic.
281
+ * Build the close (×) button + tooltip used in the panel header. Every header
282
+ * layout routes through this so composer-bar mode can render just a close
283
+ * button (no full header strip) without duplicating the config-driven styling.
67
284
  */
68
285
  export const createCloseButton = (
69
286
  config: AgentWidgetConfig | undefined,
@@ -73,87 +290,37 @@ export const createCloseButton = (
73
290
  showClose = true,
74
291
  wrapperClassName = DEFAULT_WRAPPER_CLASS,
75
292
  buttonSize,
76
- iconSize = "28px",
293
+ iconSize,
77
294
  } = options;
78
295
  const launcher = config?.launcher ?? {};
79
- // Call-site `buttonSize` (if provided) wins over launcher config. The
80
- // launcher's `closeButtonSize` is set in DEFAULT_WIDGET_CONFIG so it's
81
- // never undefined, which means the call-site override is the only way
82
- // to opt a specific render path (like composer-bar's minimal close) into
83
- // a different size.
84
- const closeButtonSize = buttonSize ?? launcher.closeButtonSize ?? "32px";
85
-
86
- const wrapper = createElement("div", wrapperClassName);
87
-
88
296
  const closeButtonTooltipText = launcher.closeButtonTooltipText ?? "Close chat";
89
297
  const closeButtonShowTooltip = launcher.closeButtonShowTooltip ?? true;
90
- const closeButtonIconName = launcher.closeButtonIconName ?? "x";
91
- const closeButtonIconText = launcher.closeButtonIconText ?? "×";
92
- // hover-bg / border-none / rounded-full are default utility classes that
93
- // apply only when the matching style override is absent; an override sets
94
- // the inline style instead (cx omits the class).
95
- const hasCloseBorder = Boolean(
96
- launcher.closeButtonBorderWidth || launcher.closeButtonBorderColor
97
- );
98
-
99
- const button = createNode("button", {
100
- className: cx(
101
- "persona-inline-flex persona-items-center persona-justify-center persona-cursor-pointer",
102
- !launcher.closeButtonBackgroundColor && "hover:persona-bg-gray-100",
103
- !hasCloseBorder && "persona-border-none",
104
- !launcher.closeButtonBorderRadius && "persona-rounded-full"
105
- ),
106
- attrs: { type: "button", "aria-label": closeButtonTooltipText },
107
- style: {
108
- height: closeButtonSize,
109
- width: closeButtonSize,
110
- display: showClose ? undefined : "none",
111
- color: launcher.closeButtonColor || HEADER_THEME_CSS.actionIconColor,
112
- backgroundColor: launcher.closeButtonBackgroundColor || undefined,
113
- border: hasCloseBorder
114
- ? `${launcher.closeButtonBorderWidth || "0px"} solid ${launcher.closeButtonBorderColor || "transparent"}`
115
- : undefined,
116
- borderRadius: launcher.closeButtonBorderRadius || undefined,
117
- paddingLeft: launcher.closeButtonPaddingX || undefined,
118
- paddingRight: launcher.closeButtonPaddingX || undefined,
119
- paddingTop: launcher.closeButtonPaddingY || undefined,
120
- paddingBottom: launcher.closeButtonPaddingY || undefined,
121
- },
122
- });
123
298
 
124
- // The X glyph's paths occupy only the middle 50% of its 24x24 viewBox
125
- // (from 6,6 to 18,18), while other header icons (e.g. refresh-cw) span
126
- // ~75% of the viewBox. Rendering X at a larger intrinsic size brings
127
- // its visible extent into parity with sibling icons in the header.
128
- // display:block eliminates inline-baseline spacing that can push the
129
- // icon a fractional pixel off-center inside the button.
130
- const closeIconSvg = renderLucideIcon(closeButtonIconName, iconSize, "currentColor", 1);
131
- if (closeIconSvg) {
132
- closeIconSvg.style.display = "block";
133
- button.appendChild(closeIconSvg);
134
- } else {
135
- button.textContent = closeButtonIconText;
136
- }
137
-
138
- wrapper.appendChild(button);
139
- attachTooltip({
140
- anchor: button,
141
- trigger: wrapper,
142
- text: () => button.getAttribute("aria-label") ?? closeButtonTooltipText,
143
- enabled: closeButtonShowTooltip,
299
+ const { button, wrapper } = createHeaderIconButton({
300
+ ariaLabel: closeButtonTooltipText,
301
+ tooltip: closeButtonShowTooltip,
302
+ iconName: launcher.closeButtonIconName ?? "x",
303
+ iconText: launcher.closeButtonIconText ?? "×",
304
+ wrapperClassName,
305
+ // Call-site `buttonSize` wins over launcher config; both unset means the
306
+ // header control-size token owns the box.
307
+ size: buttonSize ?? launcher.closeButtonSize,
308
+ iconSize,
309
+ color: launcher.closeButtonColor,
310
+ backgroundColor: launcher.closeButtonBackgroundColor,
311
+ borderWidth: launcher.closeButtonBorderWidth,
312
+ borderColor: launcher.closeButtonBorderColor,
313
+ borderRadius: launcher.closeButtonBorderRadius,
314
+ paddingX: launcher.closeButtonPaddingX,
315
+ paddingY: launcher.closeButtonPaddingY,
316
+ hidden: !showClose,
144
317
  });
145
318
 
146
319
  return { button, wrapper };
147
320
  };
148
321
 
149
- const DEFAULT_CLEAR_CHAT_WRAPPER_CLASS =
150
- "persona-relative persona-ml-auto persona-clear-chat-button-wrapper";
151
-
152
322
  /**
153
323
  * Build the clear-chat (refresh) button + tooltip used in the panel header.
154
- * Extracted from `header-builder.ts` so composer-bar mode can render a
155
- * "start over" button alongside its close icon without duplicating the
156
- * tooltip + config-driven styling logic.
157
324
  *
158
325
  * The factory only handles construction. Wiring the click to the
159
326
  * clear-history handler is owned by `setupClearChatButton()` in `ui.ts`,
@@ -166,69 +333,30 @@ export const createClearChatButton = (
166
333
  const {
167
334
  wrapperClassName = DEFAULT_CLEAR_CHAT_WRAPPER_CLASS,
168
335
  buttonSize,
169
- iconSize = "20px",
336
+ iconSize,
170
337
  } = options;
171
338
 
172
339
  const launcher = config?.launcher ?? {};
173
340
  const clearChatConfig = launcher.clearChat ?? {};
174
- // Call-site `buttonSize` (when provided) wins over launcher.clearChat.size.
175
- // Same precedence rule as createCloseButton: callers like composer-bar
176
- // intentionally override the inherited launcher default to fit their UX.
177
- const clearChatSize = buttonSize ?? clearChatConfig.size ?? "32px";
178
- const clearChatIconName = clearChatConfig.iconName ?? "refresh-cw";
179
- const clearChatIconColor = clearChatConfig.iconColor ?? "";
180
- const clearChatBgColor = clearChatConfig.backgroundColor ?? "";
181
- const clearChatBorderWidth = clearChatConfig.borderWidth ?? "";
182
- const clearChatBorderColor = clearChatConfig.borderColor ?? "";
183
- const clearChatBorderRadius = clearChatConfig.borderRadius ?? "";
184
- const clearChatPaddingX = clearChatConfig.paddingX ?? "";
185
- const clearChatPaddingY = clearChatConfig.paddingY ?? "";
186
341
  const clearChatTooltipText = clearChatConfig.tooltipText ?? "Clear chat";
187
342
  const clearChatShowTooltip = clearChatConfig.showTooltip ?? true;
188
343
 
189
- const wrapper = createElement("div", wrapperClassName);
190
-
191
- // hover-bg / border-none / rounded-full are default utility classes that
192
- // apply only when the matching style override is absent; an override sets
193
- // the inline style instead (cx omits the class).
194
- const hasClearChatBorder = Boolean(clearChatBorderWidth || clearChatBorderColor);
195
-
196
- const button = createNode("button", {
197
- className: cx(
198
- "persona-inline-flex persona-items-center persona-justify-center persona-cursor-pointer",
199
- !clearChatBgColor && "hover:persona-bg-gray-100",
200
- !hasClearChatBorder && "persona-border-none",
201
- !clearChatBorderRadius && "persona-rounded-full"
202
- ),
203
- attrs: { type: "button", "aria-label": clearChatTooltipText },
204
- style: {
205
- height: clearChatSize,
206
- width: clearChatSize,
207
- color: clearChatIconColor || HEADER_THEME_CSS.actionIconColor,
208
- backgroundColor: clearChatBgColor || undefined,
209
- border: hasClearChatBorder
210
- ? `${clearChatBorderWidth || "0px"} solid ${clearChatBorderColor || "transparent"}`
211
- : undefined,
212
- borderRadius: clearChatBorderRadius || undefined,
213
- paddingLeft: clearChatPaddingX || undefined,
214
- paddingRight: clearChatPaddingX || undefined,
215
- paddingTop: clearChatPaddingY || undefined,
216
- paddingBottom: clearChatPaddingY || undefined,
217
- },
218
- });
219
-
220
- const iconSvg = renderLucideIcon(clearChatIconName, iconSize, "currentColor", 1);
221
- if (iconSvg) {
222
- iconSvg.style.display = "block";
223
- button.appendChild(iconSvg);
224
- }
225
-
226
- wrapper.appendChild(button);
227
- attachTooltip({
228
- anchor: button,
229
- trigger: wrapper,
230
- text: () => button.getAttribute("aria-label") ?? clearChatTooltipText,
231
- enabled: clearChatShowTooltip,
344
+ const { button, wrapper } = createHeaderIconButton({
345
+ ariaLabel: clearChatTooltipText,
346
+ tooltip: clearChatShowTooltip,
347
+ iconName: clearChatConfig.iconName ?? "refresh-cw",
348
+ wrapperClassName,
349
+ // Call-site `buttonSize` wins over `launcher.clearChat.size`; both unset
350
+ // means the header control-size token owns the box.
351
+ size: buttonSize ?? clearChatConfig.size,
352
+ iconSize,
353
+ color: clearChatConfig.iconColor,
354
+ backgroundColor: clearChatConfig.backgroundColor,
355
+ borderWidth: clearChatConfig.borderWidth,
356
+ borderColor: clearChatConfig.borderColor,
357
+ borderRadius: clearChatConfig.borderRadius,
358
+ paddingX: clearChatConfig.paddingX,
359
+ paddingY: clearChatConfig.paddingY,
232
360
  });
233
361
 
234
362
  return { button, wrapper };
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Shell-owned destructive confirmation for history actions
3
+ * (`docs/visitor-history-implementation-plan.md` D7, accessibility contract).
4
+ *
5
+ * An alert dialog: labelled and described, focus-trapped, least-destructive
6
+ * action focused first, Escape resolves `false`, and focus returns to whatever
7
+ * was focused when it opened. Styling is inline so the dialog costs no bytes in
8
+ * `widget.css`; colors come from the same theme variables the panel uses.
9
+ */
10
+
11
+ import { createNode } from "../utils/dom";
12
+
13
+ export interface HistoryConfirmOptions {
14
+ title: string;
15
+ description: string;
16
+ confirmLabel: string;
17
+ cancelLabel: string;
18
+ /** Where the modal mounts. Normally the widget container. */
19
+ host: HTMLElement;
20
+ }
21
+
22
+ const FOCUSABLE = "button:not([disabled])";
23
+
24
+ /** Resolves `true` only for the confirming action. */
25
+ export function showHistoryConfirm(
26
+ options: HistoryConfirmOptions
27
+ ): Promise<boolean> {
28
+ const previouslyFocused =
29
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
30
+ const id = Math.random().toString(36).slice(2, 8);
31
+ const titleId = `persona-history-confirm-title-${id}`;
32
+ const descriptionId = `persona-history-confirm-desc-${id}`;
33
+
34
+ const cancel = createNode("button", {
35
+ className: "persona-history-confirm__cancel",
36
+ text: options.cancelLabel,
37
+ attrs: { type: "button" },
38
+ });
39
+ const confirm = createNode("button", {
40
+ className: "persona-history-confirm__confirm",
41
+ text: options.confirmLabel,
42
+ attrs: { type: "button", "data-persona-destructive": "true" },
43
+ });
44
+
45
+ const dialog = createNode(
46
+ "div",
47
+ {
48
+ className: "persona-history-confirm__dialog",
49
+ attrs: {
50
+ role: "alertdialog",
51
+ "aria-modal": "true",
52
+ "aria-labelledby": titleId,
53
+ "aria-describedby": descriptionId,
54
+ },
55
+ style: {
56
+ maxWidth: "22rem",
57
+ width: "100%",
58
+ borderRadius: "var(--persona-radius-lg, 0.75rem)",
59
+ background: "var(--persona-surface, #ffffff)",
60
+ // The real text token, never `inherit`: the host is the panel in
61
+ // split layouts, whose cascaded color is the page's, not the theme's.
62
+ color: "var(--persona-text, #111827)",
63
+ boxShadow:
64
+ "var(--persona-history-confirm-shadow, 0 20px 40px -12px rgba(0, 0, 0, 0.35))",
65
+ padding: "20px",
66
+ display: "flex",
67
+ flexDirection: "column",
68
+ gap: "12px",
69
+ },
70
+ },
71
+ createNode("h2", {
72
+ className: "persona-history-confirm__title",
73
+ text: options.title,
74
+ attrs: { id: titleId },
75
+ style: { margin: "0", fontSize: "1rem", fontWeight: "600" },
76
+ }),
77
+ createNode("p", {
78
+ className: "persona-history-confirm__description",
79
+ text: options.description,
80
+ attrs: { id: descriptionId },
81
+ style: { margin: "0", fontSize: "0.875rem", lineHeight: "1.4" },
82
+ }),
83
+ createNode(
84
+ "div",
85
+ {
86
+ className: "persona-history-confirm__actions",
87
+ style: {
88
+ display: "flex",
89
+ gap: "8px",
90
+ justifyContent: "flex-end",
91
+ flexWrap: "wrap",
92
+ },
93
+ },
94
+ cancel,
95
+ confirm
96
+ )
97
+ );
98
+
99
+ for (const button of [cancel, confirm]) {
100
+ button.style.minHeight = "44px";
101
+ button.style.minWidth = "88px";
102
+ button.style.padding = "0 16px";
103
+ button.style.borderRadius = "var(--persona-radius-md, 0.5rem)";
104
+ button.style.cursor = "pointer";
105
+ button.style.font = "inherit";
106
+ }
107
+ cancel.style.border = "1px solid var(--persona-border, rgba(0,0,0,0.12))";
108
+ cancel.style.background = "transparent";
109
+ cancel.style.color = "inherit";
110
+ confirm.style.border = "none";
111
+ confirm.style.background = "var(--persona-danger, #b42318)";
112
+ confirm.style.color = "var(--persona-danger-fg, #ffffff)";
113
+
114
+ const overlay = createNode(
115
+ "div",
116
+ {
117
+ className: "persona-history-confirm",
118
+ style: {
119
+ position: "absolute",
120
+ inset: "0",
121
+ zIndex: "40",
122
+ display: "flex",
123
+ alignItems: "center",
124
+ justifyContent: "center",
125
+ padding: "16px",
126
+ background: "var(--persona-history-confirm-scrim, rgba(15, 23, 42, 0.45))",
127
+ },
128
+ },
129
+ dialog
130
+ );
131
+
132
+ return new Promise<boolean>((resolve) => {
133
+ let settled = false;
134
+ const close = (result: boolean): void => {
135
+ if (settled) return;
136
+ settled = true;
137
+ overlay.removeEventListener("keydown", onKeydown, true);
138
+ overlay.remove();
139
+ previouslyFocused?.focus();
140
+ resolve(result);
141
+ };
142
+
143
+ function onKeydown(event: KeyboardEvent): void {
144
+ if (event.key === "Escape") {
145
+ event.preventDefault();
146
+ event.stopPropagation();
147
+ close(false);
148
+ return;
149
+ }
150
+ if (event.key !== "Tab") return;
151
+ // Trap: only the two actions are reachable while the alert is open.
152
+ const focusable = Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE));
153
+ if (focusable.length === 0) return;
154
+ const first = focusable[0]!;
155
+ const last = focusable[focusable.length - 1]!;
156
+ const active = document.activeElement;
157
+ if (event.shiftKey && (active === first || !dialog.contains(active))) {
158
+ event.preventDefault();
159
+ last.focus();
160
+ } else if (!event.shiftKey && active === last) {
161
+ event.preventDefault();
162
+ first.focus();
163
+ }
164
+ }
165
+
166
+ overlay.addEventListener("keydown", onKeydown, true);
167
+ // A click on the scrim is not a destructive confirmation.
168
+ overlay.addEventListener("pointerdown", (event) => {
169
+ if (event.target === overlay) close(false);
170
+ });
171
+ cancel.addEventListener("click", () => close(false));
172
+ confirm.addEventListener("click", () => close(true));
173
+
174
+ options.host.appendChild(overlay);
175
+ cancel.focus();
176
+ });
177
+ }