@runtypelabs/persona 2.3.0 → 3.0.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 (41) hide show
  1. package/README.md +221 -4
  2. package/dist/index.cjs +42 -42
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +832 -571
  5. package/dist/index.d.ts +832 -571
  6. package/dist/index.global.js +87 -87
  7. package/dist/index.global.js.map +1 -1
  8. package/dist/index.js +42 -42
  9. package/dist/index.js.map +1 -1
  10. package/dist/widget.css +205 -15
  11. package/package.json +2 -2
  12. package/src/components/artifact-card.ts +39 -5
  13. package/src/components/artifact-pane.ts +67 -126
  14. package/src/components/composer-builder.ts +3 -23
  15. package/src/components/header-builder.ts +29 -34
  16. package/src/components/header-layouts.ts +109 -41
  17. package/src/components/launcher.ts +10 -7
  18. package/src/components/message-bubble.ts +7 -11
  19. package/src/components/panel.ts +4 -4
  20. package/src/defaults.ts +22 -93
  21. package/src/index.ts +20 -7
  22. package/src/presets.ts +66 -51
  23. package/src/runtime/host-layout.test.ts +196 -0
  24. package/src/runtime/host-layout.ts +265 -27
  25. package/src/runtime/init.test.ts +77 -7
  26. package/src/styles/widget.css +205 -15
  27. package/src/types/theme.ts +76 -0
  28. package/src/types.ts +86 -97
  29. package/src/ui.docked.test.ts +203 -7
  30. package/src/ui.ts +129 -88
  31. package/src/utils/buttons.ts +417 -0
  32. package/src/utils/code-generators.test.ts +43 -7
  33. package/src/utils/code-generators.ts +9 -25
  34. package/src/utils/deep-merge.ts +26 -0
  35. package/src/utils/dock.ts +18 -5
  36. package/src/utils/dropdown.ts +178 -0
  37. package/src/utils/sanitize.ts +1 -1
  38. package/src/utils/theme.test.ts +90 -15
  39. package/src/utils/theme.ts +20 -46
  40. package/src/utils/tokens.ts +108 -11
  41. package/src/utils/migration.ts +0 -220
package/dist/widget.css CHANGED
@@ -2067,7 +2067,7 @@
2067
2067
  padding: var(--persona-artifact-toolbar-icon-padding, 0.25rem);
2068
2068
  border-radius: var(--persona-artifact-toolbar-icon-radius, var(--persona-radius-md, 0.375rem));
2069
2069
  border: var(--persona-artifact-toolbar-icon-border, 1px solid var(--persona-border, #e5e7eb));
2070
- background: var(--persona-surface, #ffffff);
2070
+ background: var(--persona-artifact-toolbar-icon-bg, var(--persona-surface, #ffffff));
2071
2071
  color: var(--persona-artifact-doc-toolbar-icon-color, var(--persona-text, #111827));
2072
2072
  cursor: pointer;
2073
2073
  line-height: 1;
@@ -2105,18 +2105,6 @@
2105
2105
  font-weight: 500;
2106
2106
  }
2107
2107
 
2108
- /* Copy menu dropdown theming */
2109
- #persona-root .persona-artifact-doc-copy-menu {
2110
- background: var(--persona-artifact-toolbar-copy-menu-bg, var(--persona-surface, #fff));
2111
- border: var(--persona-artifact-toolbar-copy-menu-border, 1px solid var(--persona-border, #e5e7eb));
2112
- box-shadow: var(--persona-artifact-toolbar-copy-menu-shadow, 0 4px 6px -1px rgba(0,0,0,.1));
2113
- border-radius: var(--persona-artifact-toolbar-copy-menu-radius, 0.375rem);
2114
- }
2115
-
2116
- #persona-root .persona-artifact-doc-copy-menu button:hover {
2117
- background: var(--persona-artifact-toolbar-copy-menu-item-hover-bg, var(--persona-container, #f3f4f6));
2118
- }
2119
-
2120
2108
  /* Artifact tab theming */
2121
2109
  #persona-root .persona-artifact-tab {
2122
2110
  background: var(--persona-artifact-tab-bg, transparent);
@@ -2134,6 +2122,200 @@
2134
2122
  background: var(--persona-artifact-toolbar-bg, var(--persona-surface, #fff));
2135
2123
  }
2136
2124
 
2125
+ /* Toggle group gap */
2126
+ #persona-root .persona-artifact-toggle-group {
2127
+ gap: var(--persona-artifact-toolbar-toggle-group-gap, 0.25rem);
2128
+ }
2129
+
2130
+ /* Toggle button border-radius (view/code buttons) */
2131
+ #persona-root .persona-artifact-toolbar-document .persona-artifact-view-btn,
2132
+ #persona-root .persona-artifact-toolbar-document .persona-artifact-code-btn {
2133
+ border-radius: var(--persona-artifact-toolbar-toggle-radius, var(--persona-artifact-toolbar-icon-radius, var(--persona-radius-md, 0.375rem)));
2134
+ }
2135
+
2136
+ /* Tab hover */
2137
+ #persona-root .persona-artifact-tab:hover {
2138
+ background: var(--persona-artifact-tab-hover-bg, var(--persona-container, #f3f4f6));
2139
+ }
2140
+
2141
+ /* Tab list container */
2142
+ #persona-root .persona-artifact-list {
2143
+ background: var(--persona-artifact-tab-list-bg, transparent);
2144
+ border-bottom-color: var(--persona-artifact-tab-list-border-color, var(--persona-border, #e5e7eb));
2145
+ padding: var(--persona-artifact-tab-list-padding, 0.5rem);
2146
+ }
2147
+
2148
+ /* Toolbar border override */
2149
+ #persona-root .persona-artifact-toolbar-document {
2150
+ border-bottom: var(--persona-artifact-toolbar-border, 1px solid var(--persona-border, #e5e7eb));
2151
+ }
2152
+
2153
+ /* ── Composable button utilities ── */
2154
+
2155
+ /* Icon button — base for all icon-only buttons created by createIconButton() */
2156
+ #persona-root .persona-icon-btn {
2157
+ display: inline-flex;
2158
+ align-items: center;
2159
+ justify-content: center;
2160
+ padding: var(--persona-icon-btn-padding, 0.25rem);
2161
+ border-radius: var(--persona-icon-btn-radius, var(--persona-radius-md, 0.375rem));
2162
+ border: var(--persona-icon-btn-border, 1px solid var(--persona-border, #e5e7eb));
2163
+ background: var(--persona-icon-btn-bg, var(--persona-surface, #ffffff));
2164
+ color: var(--persona-icon-btn-color, var(--persona-text, #111827));
2165
+ cursor: pointer;
2166
+ line-height: 1;
2167
+ transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
2168
+ }
2169
+
2170
+ #persona-root .persona-icon-btn:hover {
2171
+ background: var(--persona-icon-btn-hover-bg, var(--persona-container, #f3f4f6));
2172
+ color: var(--persona-icon-btn-hover-color, inherit);
2173
+ }
2174
+
2175
+ #persona-root .persona-icon-btn:focus-visible {
2176
+ outline: 2px solid var(--persona-accent, #3b82f6);
2177
+ outline-offset: 2px;
2178
+ }
2179
+
2180
+ #persona-root .persona-icon-btn[aria-pressed="true"] {
2181
+ background: var(--persona-icon-btn-active-bg, var(--persona-container, #f3f4f6));
2182
+ border-color: var(--persona-icon-btn-active-border, var(--persona-border, #e5e7eb));
2183
+ }
2184
+
2185
+ /* Label button — icon + text button created by createLabelButton() */
2186
+ #persona-root .persona-label-btn {
2187
+ display: inline-flex;
2188
+ align-items: center;
2189
+ gap: var(--persona-label-btn-gap, 0.35rem);
2190
+ padding: var(--persona-label-btn-padding, 0.25rem 0.5rem);
2191
+ border-radius: var(--persona-label-btn-radius, var(--persona-radius-md, 0.375rem));
2192
+ border: var(--persona-label-btn-border, 1px solid var(--persona-border, #e5e7eb));
2193
+ background: var(--persona-label-btn-bg, var(--persona-surface, #ffffff));
2194
+ color: var(--persona-label-btn-color, var(--persona-text, #111827));
2195
+ cursor: pointer;
2196
+ font-size: var(--persona-label-btn-font-size, 0.75rem);
2197
+ font-weight: 500;
2198
+ line-height: 1.25;
2199
+ white-space: nowrap;
2200
+ transition: background-color 0.15s ease, color 0.15s ease;
2201
+ }
2202
+
2203
+ #persona-root .persona-label-btn:hover {
2204
+ background: var(--persona-label-btn-hover-bg, var(--persona-container, #f3f4f6));
2205
+ }
2206
+
2207
+ #persona-root .persona-label-btn:focus-visible {
2208
+ outline: 2px solid var(--persona-accent, #3b82f6);
2209
+ outline-offset: 2px;
2210
+ }
2211
+
2212
+ #persona-root .persona-label-btn--sm {
2213
+ padding: var(--persona-label-btn-padding, 0.25rem 0.5rem);
2214
+ font-size: var(--persona-label-btn-font-size, 0.75rem);
2215
+ }
2216
+
2217
+ #persona-root .persona-label-btn--md {
2218
+ padding: 0.375rem 0.75rem;
2219
+ font-size: 0.8125rem;
2220
+ }
2221
+
2222
+ #persona-root .persona-label-btn--primary {
2223
+ background: var(--persona-primary, #3b82f6);
2224
+ color: var(--persona-text-inverse, #ffffff);
2225
+ border-color: transparent;
2226
+ }
2227
+
2228
+ #persona-root .persona-label-btn--primary:hover {
2229
+ opacity: 0.9;
2230
+ }
2231
+
2232
+ #persona-root .persona-label-btn--destructive {
2233
+ color: var(--persona-label-btn-destructive-color, #ef4444);
2234
+ }
2235
+
2236
+ #persona-root .persona-label-btn--ghost {
2237
+ border: none;
2238
+ background: transparent;
2239
+ }
2240
+
2241
+ #persona-root .persona-label-btn--ghost:hover {
2242
+ background: var(--persona-label-btn-hover-bg, var(--persona-container, #f3f4f6));
2243
+ }
2244
+
2245
+ /* Toggle group — mutually exclusive button set created by createToggleGroup() */
2246
+ #persona-root .persona-toggle-group {
2247
+ display: inline-flex;
2248
+ gap: var(--persona-toggle-group-gap, 0);
2249
+ }
2250
+
2251
+ #persona-root .persona-toggle-group > .persona-icon-btn {
2252
+ border-radius: 0;
2253
+ }
2254
+
2255
+ #persona-root .persona-toggle-group > .persona-icon-btn:first-child {
2256
+ border-top-left-radius: var(--persona-toggle-group-radius, var(--persona-icon-btn-radius, var(--persona-radius-md, 0.375rem)));
2257
+ border-bottom-left-radius: var(--persona-toggle-group-radius, var(--persona-icon-btn-radius, var(--persona-radius-md, 0.375rem)));
2258
+ }
2259
+
2260
+ #persona-root .persona-toggle-group > .persona-icon-btn:last-child {
2261
+ border-top-right-radius: var(--persona-toggle-group-radius, var(--persona-icon-btn-radius, var(--persona-radius-md, 0.375rem)));
2262
+ border-bottom-right-radius: var(--persona-toggle-group-radius, var(--persona-icon-btn-radius, var(--persona-radius-md, 0.375rem)));
2263
+ }
2264
+
2265
+ /* Combo button — label + chevron with dropdown */
2266
+ #persona-root .persona-combo-btn {
2267
+ font-size: var(--persona-combo-btn-font-size, 0.8125rem);
2268
+ font-weight: var(--persona-combo-btn-font-weight, 600);
2269
+ color: var(--persona-combo-btn-color, var(--persona-text, #111827));
2270
+ user-select: none;
2271
+ }
2272
+
2273
+ #persona-root .persona-combo-btn-label {
2274
+ white-space: nowrap;
2275
+ overflow: hidden;
2276
+ text-overflow: ellipsis;
2277
+ }
2278
+
2279
+ /* Dropdown menu utility */
2280
+ #persona-root .persona-dropdown-menu {
2281
+ z-index: 100;
2282
+ min-width: 160px;
2283
+ background: var(--persona-dropdown-bg, var(--persona-surface, #fff));
2284
+ border: var(--persona-dropdown-border, 1px solid var(--persona-border, #e5e7eb));
2285
+ border-radius: var(--persona-dropdown-radius, 0.625rem);
2286
+ padding: 0.25rem 0;
2287
+ box-shadow: var(--persona-dropdown-shadow, 0 4px 16px rgba(0,0,0,0.12));
2288
+ }
2289
+
2290
+ #persona-root .persona-dropdown-menu button {
2291
+ display: flex;
2292
+ align-items: center;
2293
+ gap: 0.625rem;
2294
+ width: 100%;
2295
+ padding: 0.5rem 0.875rem;
2296
+ border: none;
2297
+ background: transparent;
2298
+ color: var(--persona-dropdown-item-color, var(--persona-text, #1f2937));
2299
+ font-size: 0.8125rem;
2300
+ cursor: pointer;
2301
+ text-align: left;
2302
+ white-space: nowrap;
2303
+ }
2304
+
2305
+ #persona-root .persona-dropdown-menu button:hover {
2306
+ background: var(--persona-dropdown-item-hover-bg, var(--persona-container, #f3f4f6));
2307
+ }
2308
+
2309
+ #persona-root .persona-dropdown-menu button[data-destructive] {
2310
+ color: var(--persona-dropdown-destructive-color, #ef4444);
2311
+ }
2312
+
2313
+ #persona-root .persona-dropdown-menu hr {
2314
+ border: none;
2315
+ border-top: 1px solid var(--persona-dropdown-hr, var(--persona-border, #e5e7eb));
2316
+ margin: 0.25rem 0;
2317
+ }
2318
+
2137
2319
  /* Draggable split handle (desktop split only; hidden in drawer / narrow host / small viewport) */
2138
2320
  #persona-root .persona-artifact-split-handle {
2139
2321
  width: 6px;
@@ -2178,7 +2360,11 @@
2178
2360
  #persona-root .persona-artifact-pane {
2179
2361
  border-radius: var(--persona-artifact-pane-radius, 0);
2180
2362
  overflow: hidden;
2181
- background-color: var(--persona-artifact-pane-bg, var(--persona-surface, #ffffff));
2363
+ /* Layout paneBackground → theme components.artifact.pane.background → semantic surface */
2364
+ background-color: var(
2365
+ --persona-artifact-pane-bg,
2366
+ var(--persona-components-artifact-pane-background, var(--persona-surface, #ffffff))
2367
+ );
2182
2368
  }
2183
2369
 
2184
2370
  /* paneAppearance: 'seamless' — flush with chat, no border/shadow/gap */
@@ -2192,7 +2378,11 @@
2192
2378
  border-left-width: 0 !important;
2193
2379
  border-left-color: transparent !important;
2194
2380
  box-shadow: none !important;
2195
- background-color: var(--persona-artifact-pane-bg, var(--persona-container, #f8fafc));
2381
+ /* Same token chain; final fallback stays container for flush split chrome */
2382
+ background-color: var(
2383
+ --persona-artifact-pane-bg,
2384
+ var(--persona-components-artifact-pane-background, var(--persona-container, #f8fafc))
2385
+ );
2196
2386
  }
2197
2387
 
2198
2388
  /* layout.paneBorder / paneBorderLeft — theme overrides (after appearance defaults) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "2.3.0",
3
+ "version": "3.0.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",
@@ -46,7 +46,7 @@
46
46
  "vitest": "^4.0.9"
47
47
  },
48
48
  "engines": {
49
- "node": ">=18.17.0"
49
+ "node": ">=20.0.0"
50
50
  },
51
51
  "author": "Runtype",
52
52
  "license": "MIT",
@@ -1,11 +1,13 @@
1
- import type { ComponentRenderer } from "./registry";
1
+ import type { ComponentContext, ComponentRenderer } from "./registry";
2
2
 
3
3
  /**
4
- * Built-in artifact reference card component.
5
- * Renders a compact clickable card in the chat thread that links to an artifact.
6
- * Uses `data-open-artifact` attribute for click delegation (handled in ui.ts).
4
+ * Default artifact card renderer.
5
+ * Builds the compact clickable card shown in the chat thread.
7
6
  */
8
- export const PersonaArtifactCard: ComponentRenderer = (props) => {
7
+ function renderDefaultArtifactCard(
8
+ props: Record<string, unknown>,
9
+ _context: ComponentContext
10
+ ): HTMLElement {
9
11
  const title =
10
12
  typeof props.title === "string" && props.title
11
13
  ? props.title
@@ -88,4 +90,36 @@ export const PersonaArtifactCard: ComponentRenderer = (props) => {
88
90
  }
89
91
 
90
92
  return root;
93
+ }
94
+
95
+ /**
96
+ * Built-in artifact reference card component.
97
+ * Renders a compact clickable card in the chat thread that links to an artifact.
98
+ * Uses `data-open-artifact` attribute for click delegation (handled in ui.ts).
99
+ *
100
+ * Supports a custom `renderCard` callback via `config.features.artifacts.renderCard`
101
+ * that can override the default card rendering.
102
+ */
103
+ export const PersonaArtifactCard: ComponentRenderer = (props, context) => {
104
+ const customRenderer = context?.config?.features?.artifacts?.renderCard;
105
+ if (customRenderer) {
106
+ const title =
107
+ typeof props.title === "string" && props.title
108
+ ? props.title
109
+ : "Untitled artifact";
110
+ const artifactId =
111
+ typeof props.artifactId === "string" ? props.artifactId : "";
112
+ const status = props.status === "streaming" ? "streaming" : "complete";
113
+ const artifactType =
114
+ typeof props.artifactType === "string" ? props.artifactType : "markdown";
115
+
116
+ const result = customRenderer({
117
+ artifact: { artifactId, title, artifactType, status },
118
+ config: context.config,
119
+ defaultRenderer: () => renderDefaultArtifactCard(props, context),
120
+ });
121
+ if (result) return result;
122
+ }
123
+
124
+ return renderDefaultArtifactCard(props, context);
91
125
  };
@@ -4,6 +4,8 @@ import { escapeHtml, createMarkdownProcessorFromConfig } from "../postprocessors
4
4
  import { resolveSanitizer } from "../utils/sanitize";
5
5
  import { componentRegistry, type ComponentContext } from "./registry";
6
6
  import { renderLucideIcon } from "../utils/icons";
7
+ import { createDropdownMenu, type DropdownMenuHandle } from "../utils/dropdown";
8
+ import { createIconButton, createLabelButton } from "../utils/buttons";
7
9
 
8
10
  export type ArtifactPaneApi = {
9
11
  element: HTMLElement;
@@ -27,65 +29,6 @@ function fallbackComponentCard(sel: PersonaArtifactRecord): HTMLElement {
27
29
  return card;
28
30
  }
29
31
 
30
- function iconButton(iconName: string, label: string, extraClass = ""): HTMLButtonElement {
31
- const btn = createElement(
32
- "button",
33
- `persona-inline-flex persona-items-center persona-justify-center persona-rounded-md persona-border persona-border-persona-border persona-bg-persona-surface persona-p-1 persona-text-persona-primary hover:persona-bg-persona-container ${extraClass}`
34
- ) as HTMLButtonElement;
35
- btn.type = "button";
36
- btn.setAttribute("aria-label", label);
37
- btn.title = label;
38
- const icon = renderLucideIcon(iconName, 16, "currentColor", 2);
39
- if (icon) btn.appendChild(icon);
40
- return btn;
41
- }
42
-
43
- function documentToolbarIconButton(
44
- iconName: string,
45
- label: string,
46
- extraClass: string
47
- ): HTMLButtonElement {
48
- const btn = createElement(
49
- "button",
50
- `persona-artifact-doc-icon-btn ${extraClass}`.trim()
51
- ) as HTMLButtonElement;
52
- btn.type = "button";
53
- btn.setAttribute("aria-label", label);
54
- btn.title = label;
55
- const icon = renderLucideIcon(iconName, 16, "currentColor", 2);
56
- if (icon) btn.appendChild(icon);
57
- return btn;
58
- }
59
-
60
- function documentToolbarCopyMainButton(showLabel: boolean): HTMLButtonElement {
61
- const btn = createElement("button", "persona-artifact-doc-copy-btn") as HTMLButtonElement;
62
- btn.type = "button";
63
- btn.setAttribute("aria-label", "Copy");
64
- btn.title = "Copy";
65
- const icon = renderLucideIcon("copy", showLabel ? 14 : 16, "currentColor", 2);
66
- if (icon) btn.appendChild(icon);
67
- if (showLabel) {
68
- const span = createElement("span", "persona-artifact-doc-copy-label");
69
- span.textContent = "Copy";
70
- btn.appendChild(span);
71
- }
72
- return btn;
73
- }
74
-
75
- function documentToolbarChevronMenuButton(): HTMLButtonElement {
76
- const btn = createElement(
77
- "button",
78
- "persona-artifact-doc-copy-menu-chevron persona-artifact-doc-icon-btn"
79
- ) as HTMLButtonElement;
80
- btn.type = "button";
81
- btn.setAttribute("aria-label", "More copy options");
82
- btn.setAttribute("aria-haspopup", "true");
83
- btn.setAttribute("aria-expanded", "false");
84
- const chev = renderLucideIcon("chevron-down", 14, "currentColor", 2);
85
- if (chev) btn.appendChild(chev);
86
- return btn;
87
- }
88
-
89
32
  /**
90
33
  * Right-hand artifact sidebar / mobile drawer content.
91
34
  */
@@ -119,6 +62,8 @@ export function createArtifactPane(
119
62
  const dismissLocalUi = () => {
120
63
  backdrop?.classList.add("persona-hidden");
121
64
  shell.classList.remove("persona-artifact-drawer-open");
65
+ // Hide portaled copy menu
66
+ copyMenuDropdown?.hide();
122
67
  };
123
68
 
124
69
  if (backdrop) {
@@ -162,13 +107,13 @@ export function createArtifactPane(
162
107
 
163
108
  /** Document preset: view vs raw source */
164
109
  let viewMode: "rendered" | "source" = "rendered";
165
- const leftTools = createElement("div", "persona-flex persona-items-center persona-gap-1 persona-shrink-0");
110
+ const leftTools = createElement("div", "persona-flex persona-items-center persona-gap-1 persona-shrink-0 persona-artifact-toggle-group");
166
111
  const viewBtn = documentChrome
167
- ? documentToolbarIconButton("eye", "Rendered view", "persona-artifact-view-btn")
168
- : iconButton("eye", "Rendered view", "");
112
+ ? createIconButton({ icon: "eye", label: "Rendered view", className: "persona-artifact-doc-icon-btn persona-artifact-view-btn" })
113
+ : createIconButton({ icon: "eye", label: "Rendered view" });
169
114
  const codeBtn = documentChrome
170
- ? documentToolbarIconButton("code-2", "Source", "persona-artifact-code-btn")
171
- : iconButton("code-2", "Source", "");
115
+ ? createIconButton({ icon: "code-2", label: "Source", className: "persona-artifact-doc-icon-btn persona-artifact-code-btn" })
116
+ : createIconButton({ icon: "code-2", label: "Source" });
172
117
  const actionsRight = createElement("div", "persona-flex persona-items-center persona-gap-1 persona-shrink-0");
173
118
  const showCopyLabel = layout?.documentToolbarShowCopyLabel === true;
174
119
  const showCopyChevron = layout?.documentToolbarShowCopyChevron === true;
@@ -178,10 +123,12 @@ export function createArtifactPane(
178
123
  let copyWrap: HTMLElement | null = null;
179
124
  let copyBtn: HTMLButtonElement;
180
125
  let copyMenuChevronBtn: HTMLButtonElement | null = null;
181
- let copyMenuEl: HTMLElement | null = null;
126
+ let copyMenuDropdown: DropdownMenuHandle | null = null;
182
127
 
183
128
  if (documentChrome && (showCopyLabel || showCopyChevron) && !showCopyMenu) {
184
- copyBtn = documentToolbarCopyMainButton(showCopyLabel);
129
+ copyBtn = showCopyLabel
130
+ ? createLabelButton({ icon: "copy", label: "Copy", iconSize: 14, className: "persona-artifact-doc-copy-btn" })
131
+ : createIconButton({ icon: "copy", label: "Copy", className: "persona-artifact-doc-copy-btn" });
185
132
  if (showCopyChevron) {
186
133
  const chev = renderLucideIcon("chevron-down", 14, "currentColor", 2);
187
134
  if (chev) copyBtn.appendChild(chev);
@@ -191,36 +138,29 @@ export function createArtifactPane(
191
138
  "div",
192
139
  "persona-relative persona-inline-flex persona-items-center persona-gap-0 persona-rounded-md"
193
140
  );
194
- copyBtn = documentToolbarCopyMainButton(showCopyLabel);
195
- copyMenuChevronBtn = documentToolbarChevronMenuButton();
141
+ copyBtn = showCopyLabel
142
+ ? createLabelButton({ icon: "copy", label: "Copy", iconSize: 14, className: "persona-artifact-doc-copy-btn" })
143
+ : createIconButton({ icon: "copy", label: "Copy", className: "persona-artifact-doc-copy-btn" });
144
+ copyMenuChevronBtn = createIconButton({
145
+ icon: "chevron-down",
146
+ label: "More copy options",
147
+ size: 14,
148
+ className: "persona-artifact-doc-copy-menu-chevron persona-artifact-doc-icon-btn",
149
+ aria: { "aria-haspopup": "true", "aria-expanded": "false" }
150
+ });
196
151
  copyWrap.append(copyBtn, copyMenuChevronBtn);
197
- copyMenuEl = createElement(
198
- "div",
199
- "persona-artifact-doc-copy-menu persona-absolute persona-right-0 persona-top-full persona-z-20 persona-mt-1 persona-min-w-[10rem] persona-rounded-md persona-border persona-border-persona-border persona-bg-persona-surface persona-py-1 persona-shadow-md persona-hidden"
200
- );
201
- copyWrap.appendChild(copyMenuEl);
202
- for (const item of copyMenuItems!) {
203
- const opt = createElement(
204
- "button",
205
- "persona-block persona-w-full persona-text-left persona-px-3 persona-py-2 persona-text-xs persona-text-persona-primary hover:persona-bg-persona-container"
206
- ) as HTMLButtonElement;
207
- opt.type = "button";
208
- opt.textContent = item.label;
209
- opt.dataset.copyMenuId = item.id;
210
- copyMenuEl.appendChild(opt);
211
- }
212
152
  } else if (documentChrome) {
213
- copyBtn = documentToolbarIconButton("copy", "Copy", "");
153
+ copyBtn = createIconButton({ icon: "copy", label: "Copy", className: "persona-artifact-doc-icon-btn" });
214
154
  } else {
215
- copyBtn = iconButton("copy", "Copy", "");
155
+ copyBtn = createIconButton({ icon: "copy", label: "Copy" });
216
156
  }
217
157
 
218
158
  const refreshBtn = documentChrome
219
- ? documentToolbarIconButton("refresh-cw", "Refresh", "")
220
- : iconButton("refresh-cw", "Refresh", "");
159
+ ? createIconButton({ icon: "refresh-cw", label: "Refresh", className: "persona-artifact-doc-icon-btn" })
160
+ : createIconButton({ icon: "refresh-cw", label: "Refresh" });
221
161
  const closeIconBtn = documentChrome
222
- ? documentToolbarIconButton("x", "Close", "")
223
- : iconButton("x", "Close", "");
162
+ ? createIconButton({ icon: "x", label: "Close", className: "persona-artifact-doc-icon-btn" })
163
+ : createIconButton({ icon: "x", label: "Close" });
224
164
 
225
165
  const getSelectedArtifactText = (): { markdown: string; jsonPayload: string; id: string | null } => {
226
166
  const sel = records.find((r) => r.id === selectedId) ?? records[records.length - 1];
@@ -262,45 +202,46 @@ export function createArtifactPane(
262
202
  await defaultCopy();
263
203
  });
264
204
 
265
- if (copyMenuChevronBtn && copyMenuEl) {
266
- const closeMenu = () => {
267
- copyMenuEl!.classList.add("persona-hidden");
268
- copyMenuChevronBtn!.setAttribute("aria-expanded", "false");
205
+ if (copyMenuChevronBtn && copyMenuItems?.length) {
206
+ // Resolve the portal target — #persona-root for CSS var inheritance, escaping overflow: hidden
207
+ const resolvePortal = (): HTMLElement => shell.closest("#persona-root") as HTMLElement ?? document.body;
208
+
209
+ const initDropdown = () => {
210
+ copyMenuDropdown = createDropdownMenu({
211
+ items: copyMenuItems.map((item) => ({ id: item.id, label: item.label })),
212
+ onSelect: async (actionId) => {
213
+ const { markdown, jsonPayload, id } = getSelectedArtifactText();
214
+ const handler = layout?.onDocumentToolbarCopyMenuSelect;
215
+ try {
216
+ if (handler) {
217
+ await handler({ actionId, artifactId: id, markdown, jsonPayload });
218
+ } else if (actionId === "markdown" || actionId === "md") {
219
+ await navigator.clipboard.writeText(markdown);
220
+ } else if (actionId === "json" || actionId === "source") {
221
+ await navigator.clipboard.writeText(jsonPayload);
222
+ } else {
223
+ await navigator.clipboard.writeText(markdown || jsonPayload);
224
+ }
225
+ } catch {
226
+ /* ignore */
227
+ }
228
+ },
229
+ anchor: copyWrap ?? copyMenuChevronBtn!,
230
+ position: 'bottom-right',
231
+ portal: resolvePortal(),
232
+ });
269
233
  };
270
- copyMenuChevronBtn.addEventListener("click", (e) => {
271
- e.stopPropagation();
272
- const open = copyMenuEl!.classList.contains("persona-hidden");
273
- if (open) {
274
- copyMenuEl!.classList.remove("persona-hidden");
275
- copyMenuChevronBtn!.setAttribute("aria-expanded", "true");
276
- } else {
277
- closeMenu();
278
- }
279
- });
280
- if (typeof document !== "undefined") {
281
- document.addEventListener("click", closeMenu);
234
+
235
+ // Defer init until shell is in the DOM (may not be attached yet)
236
+ if (shell.isConnected) {
237
+ initDropdown();
238
+ } else {
239
+ requestAnimationFrame(initDropdown);
282
240
  }
283
- copyMenuEl.addEventListener("click", async (e) => {
284
- const t = (e.target as HTMLElement).closest("button[data-copy-menu-id]") as HTMLButtonElement | null;
285
- if (!t?.dataset.copyMenuId) return;
241
+
242
+ copyMenuChevronBtn.addEventListener("click", (e) => {
286
243
  e.stopPropagation();
287
- const actionId = t.dataset.copyMenuId;
288
- const { markdown, jsonPayload, id } = getSelectedArtifactText();
289
- const handler = layout?.onDocumentToolbarCopyMenuSelect;
290
- try {
291
- if (handler) {
292
- await handler({ actionId, artifactId: id, markdown, jsonPayload });
293
- } else if (actionId === "markdown" || actionId === "md") {
294
- await navigator.clipboard.writeText(markdown);
295
- } else if (actionId === "json" || actionId === "source") {
296
- await navigator.clipboard.writeText(jsonPayload);
297
- } else {
298
- await navigator.clipboard.writeText(markdown || jsonPayload);
299
- }
300
- } catch {
301
- /* ignore */
302
- }
303
- closeMenu();
244
+ copyMenuDropdown?.toggle();
304
245
  });
305
246
  }
306
247
 
@@ -388,7 +329,7 @@ export function createArtifactPane(
388
329
  for (const r of records) {
389
330
  const tab = createElement(
390
331
  "button",
391
- "persona-artifact-tab persona-shrink-0 persona-rounded-lg persona-px-2 persona-py-1 persona-text-xs persona-border persona-border-transparent persona-text-persona-primary hover:persona-bg-persona-container"
332
+ "persona-artifact-tab persona-shrink-0 persona-rounded-lg persona-px-2 persona-py-1 persona-text-xs persona-border persona-border-transparent persona-text-persona-primary"
392
333
  );
393
334
  tab.type = "button";
394
335
  tab.textContent = r.title || r.id.slice(0, 8);
@@ -40,23 +40,6 @@ export interface ComposerBuildContext {
40
40
  disabled?: boolean;
41
41
  }
42
42
 
43
- /**
44
- * Helper to get font family CSS value from config preset
45
- */
46
- const getFontFamilyValue = (
47
- family: "sans-serif" | "serif" | "mono"
48
- ): string => {
49
- switch (family) {
50
- case "serif":
51
- return 'Georgia, "Times New Roman", Times, serif';
52
- case "mono":
53
- return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
54
- case "sans-serif":
55
- default:
56
- return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
57
- }
58
- };
59
-
60
43
  /**
61
44
  * Build the composer/footer section of the panel.
62
45
  * Extracted for reuse and plugin override support.
@@ -91,12 +74,9 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
91
74
  "persona-w-full persona-min-h-[24px] persona-resize-none persona-border-none persona-bg-transparent persona-text-sm persona-text-persona-primary focus:persona-outline-none focus:persona-border-none persona-composer-textarea";
92
75
  textarea.rows = 1;
93
76
 
94
- // Apply font family and weight from config
95
- const fontFamily = config?.theme?.inputFontFamily ?? "sans-serif";
96
- const fontWeight = config?.theme?.inputFontWeight ?? "400";
97
-
98
- textarea.style.fontFamily = getFontFamilyValue(fontFamily);
99
- textarea.style.fontWeight = fontWeight;
77
+ textarea.style.fontFamily =
78
+ 'var(--persona-input-font-family, var(--persona-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif))';
79
+ textarea.style.fontWeight = "var(--persona-input-font-weight, var(--persona-font-weight, 400))";
100
80
 
101
81
  // Set up auto-resize: expand up to 3 lines, then scroll
102
82
  // Line height is ~20px for text-sm (14px * 1.25 line-height), so 3 lines ≈ 60px