@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
@@ -0,0 +1,178 @@
1
+ import { createElement } from "./dom";
2
+ import { renderLucideIcon } from "./icons";
3
+
4
+ export interface DropdownMenuItem {
5
+ id: string;
6
+ label: string;
7
+ /** Lucide icon name to show before the label. */
8
+ icon?: string;
9
+ /** When true, item text is styled in a destructive/danger color. */
10
+ destructive?: boolean;
11
+ /** When true, a visual divider is inserted before this item. */
12
+ dividerBefore?: boolean;
13
+ }
14
+
15
+ export interface CreateDropdownOptions {
16
+ /** Menu items to render. */
17
+ items: DropdownMenuItem[];
18
+ /** Called when a menu item is selected. */
19
+ onSelect: (id: string) => void;
20
+ /** Anchor element used for positioning. When `portal` is not set the menu is appended inside this element (which must have position: relative). */
21
+ anchor: HTMLElement;
22
+ /** Alignment of the menu relative to the anchor. Default: 'bottom-left'. */
23
+ position?: 'bottom-left' | 'bottom-right';
24
+ /**
25
+ * Portal target element. When set, the menu is appended to this element
26
+ * and uses fixed positioning calculated from the anchor's bounding rect.
27
+ * Use this to escape `overflow: hidden` containers.
28
+ */
29
+ portal?: HTMLElement;
30
+ }
31
+
32
+ export interface DropdownMenuHandle {
33
+ /** The menu DOM element. */
34
+ element: HTMLElement;
35
+ /** Show the menu. */
36
+ show: () => void;
37
+ /** Hide the menu. */
38
+ hide: () => void;
39
+ /** Toggle visibility. */
40
+ toggle: () => void;
41
+ /** Remove the menu and clean up all listeners. */
42
+ destroy: () => void;
43
+ }
44
+
45
+ /**
46
+ * Create a dropdown menu attached to an anchor element.
47
+ *
48
+ * The menu is styled via `.persona-dropdown-menu` CSS rules and themed
49
+ * through `--persona-dropdown-*` CSS variables with semantic fallbacks.
50
+ *
51
+ * ```ts
52
+ * import { createDropdownMenu } from "@runtypelabs/persona";
53
+ *
54
+ * const dropdown = createDropdownMenu({
55
+ * items: [
56
+ * { id: "edit", label: "Edit", icon: "pencil" },
57
+ * { id: "delete", label: "Delete", icon: "trash-2", destructive: true, dividerBefore: true },
58
+ * ],
59
+ * onSelect: (id) => console.log("selected", id),
60
+ * anchor: buttonElement,
61
+ * });
62
+ * anchor.appendChild(dropdown.element);
63
+ * button.addEventListener("click", () => dropdown.toggle());
64
+ * ```
65
+ */
66
+ export function createDropdownMenu(options: CreateDropdownOptions): DropdownMenuHandle {
67
+ const { items, onSelect, anchor, position = 'bottom-left', portal } = options;
68
+
69
+ const menu = createElement("div", "persona-dropdown-menu persona-hidden");
70
+ menu.setAttribute("role", "menu");
71
+ menu.setAttribute("data-persona-theme-zone", "dropdown");
72
+
73
+ if (portal) {
74
+ // Fixed positioning — menu is portaled outside the anchor's overflow context
75
+ menu.style.position = "fixed";
76
+ menu.style.zIndex = "10000";
77
+ } else {
78
+ // Absolute positioning — menu lives inside the anchor
79
+ menu.style.position = "absolute";
80
+ menu.style.top = "100%";
81
+ menu.style.marginTop = "4px";
82
+ if (position === 'bottom-right') {
83
+ menu.style.right = "0";
84
+ } else {
85
+ menu.style.left = "0";
86
+ }
87
+ }
88
+
89
+ // Build menu items
90
+ for (const item of items) {
91
+ if (item.dividerBefore) {
92
+ const hr = document.createElement("hr");
93
+ menu.appendChild(hr);
94
+ }
95
+
96
+ const btn = document.createElement("button");
97
+ btn.type = "button";
98
+ btn.setAttribute("role", "menuitem");
99
+ btn.setAttribute("data-dropdown-item-id", item.id);
100
+ if (item.destructive) {
101
+ btn.setAttribute("data-destructive", "");
102
+ }
103
+
104
+ if (item.icon) {
105
+ const icon = renderLucideIcon(item.icon, 16, "currentColor", 1.5);
106
+ if (icon) btn.appendChild(icon);
107
+ }
108
+
109
+ const labelSpan = document.createElement("span");
110
+ labelSpan.textContent = item.label;
111
+ btn.appendChild(labelSpan);
112
+
113
+ btn.addEventListener("click", (e) => {
114
+ e.stopPropagation();
115
+ hide();
116
+ onSelect(item.id);
117
+ });
118
+
119
+ menu.appendChild(btn);
120
+ }
121
+
122
+ let cleanupClickOutside: (() => void) | null = null;
123
+
124
+ /** Reposition a portaled menu based on the anchor's current bounding rect. */
125
+ function reposition() {
126
+ if (!portal) return;
127
+ const rect = anchor.getBoundingClientRect();
128
+ menu.style.top = `${rect.bottom + 4}px`;
129
+ if (position === 'bottom-right') {
130
+ menu.style.right = `${window.innerWidth - rect.right}px`;
131
+ menu.style.left = "auto";
132
+ } else {
133
+ menu.style.left = `${rect.left}px`;
134
+ menu.style.right = "auto";
135
+ }
136
+ }
137
+
138
+ function show() {
139
+ reposition();
140
+ menu.classList.remove("persona-hidden");
141
+ // Defer click-outside listener to avoid catching the triggering click
142
+ requestAnimationFrame(() => {
143
+ const handler = (e: MouseEvent) => {
144
+ if (!menu.contains(e.target as Node) && !anchor.contains(e.target as Node)) {
145
+ hide();
146
+ }
147
+ };
148
+ document.addEventListener("click", handler, true);
149
+ cleanupClickOutside = () => document.removeEventListener("click", handler, true);
150
+ });
151
+ }
152
+
153
+ function hide() {
154
+ menu.classList.add("persona-hidden");
155
+ cleanupClickOutside?.();
156
+ cleanupClickOutside = null;
157
+ }
158
+
159
+ function toggle() {
160
+ if (menu.classList.contains("persona-hidden")) {
161
+ show();
162
+ } else {
163
+ hide();
164
+ }
165
+ }
166
+
167
+ function destroy() {
168
+ hide();
169
+ menu.remove();
170
+ }
171
+
172
+ // Append to portal target or let the caller append manually
173
+ if (portal) {
174
+ portal.appendChild(menu);
175
+ }
176
+
177
+ return { element: menu, show, hide, toggle, destroy };
178
+ }
@@ -59,7 +59,7 @@ export const createDefaultSanitizer = (): SanitizeFunction => {
59
59
  const val = data.attrValue;
60
60
  if (val.toLowerCase().startsWith("data:") && !SAFE_DATA_URI.test(val)) {
61
61
  data.attrValue = "";
62
- data.forceKeepAttr = false;
62
+ data.keepAttr = false;
63
63
  }
64
64
  }
65
65
  });
@@ -12,14 +12,22 @@ describe('theme utils', () => {
12
12
  const lightAndDarkThemeConfig = {
13
13
  colorScheme: 'dark' as const,
14
14
  theme: {
15
- primary: '#111111',
15
+ palette: {
16
+ colors: {
17
+ primary: { 500: '#111111' },
18
+ },
19
+ },
16
20
  },
17
21
  darkTheme: {
18
- primary: '#22c55e',
22
+ palette: {
23
+ colors: {
24
+ primary: { 500: '#22c55e' },
25
+ },
26
+ },
19
27
  },
20
28
  };
21
29
 
22
- const activeTheme = getActiveTheme(lightAndDarkThemeConfig as any);
30
+ const activeTheme = getActiveTheme(lightAndDarkThemeConfig);
23
31
  const cssVars = themeToCssVariables(activeTheme);
24
32
 
25
33
  expect(cssVars['--persona-palette-colors-primary-500']).toBe('#22c55e');
@@ -31,14 +39,22 @@ describe('theme utils', () => {
31
39
  const lightAndDarkThemeConfig = {
32
40
  colorScheme: 'auto' as const,
33
41
  theme: {
34
- primary: '#111111',
42
+ palette: {
43
+ colors: {
44
+ primary: { 500: '#111111' },
45
+ },
46
+ },
35
47
  },
36
48
  darkTheme: {
37
- primary: '#22c55e',
49
+ palette: {
50
+ colors: {
51
+ primary: { 500: '#22c55e' },
52
+ },
53
+ },
38
54
  },
39
55
  };
40
56
 
41
- const activeTheme = getActiveTheme(lightAndDarkThemeConfig as any);
57
+ const activeTheme = getActiveTheme(lightAndDarkThemeConfig);
42
58
  const cssVars = themeToCssVariables(activeTheme);
43
59
 
44
60
  expect(cssVars['--persona-palette-colors-primary-500']).toBe('#22c55e');
@@ -123,19 +139,74 @@ describe('theme utils', () => {
123
139
  expect(cssVars['--persona-md-prose-font-family']).toBe('Georgia, serif');
124
140
  });
125
141
 
126
- it('maps flat AgentWidgetTheme bubble shadow keys to consumer CSS variables', () => {
142
+ it('maps header chrome tokens to dedicated CSS variables with semantic fallbacks', () => {
143
+ const theme = createTheme();
144
+ const cssVars = themeToCssVariables(theme);
145
+
146
+ expect(cssVars['--persona-header-icon-bg']).toBe(cssVars['--persona-primary']);
147
+ expect(cssVars['--persona-header-icon-fg']).toBe(cssVars['--persona-text-inverse']);
148
+ expect(cssVars['--persona-header-title-fg']).toBe(cssVars['--persona-primary']);
149
+ expect(cssVars['--persona-header-subtitle-fg']).toBe(cssVars['--persona-text-muted']);
150
+ expect(cssVars['--persona-header-action-icon-fg']).toBe(cssVars['--persona-muted']);
151
+
152
+ const custom = createTheme({
153
+ components: {
154
+ header: {
155
+ iconBackground: 'palette.colors.accent.500',
156
+ iconForeground: 'palette.colors.gray.900',
157
+ titleForeground: 'palette.colors.secondary.500',
158
+ subtitleForeground: 'palette.colors.gray.500',
159
+ actionIconForeground: 'palette.colors.gray.400',
160
+ },
161
+ },
162
+ } as any);
163
+ const customVars = themeToCssVariables(custom);
164
+ expect(customVars['--persona-header-icon-bg']).toBe('#06b6d4');
165
+ expect(customVars['--persona-header-icon-fg']).toBe('#111827');
166
+ expect(customVars['--persona-header-title-fg']).toBe('#8b5cf6');
167
+ expect(customVars['--persona-header-subtitle-fg']).toBe('#6b7280');
168
+ expect(customVars['--persona-header-action-icon-fg']).toBe('#9ca3af');
169
+ });
170
+
171
+ it('defaults artifact pane fill from semantic container and resolves toolbar background token refs', () => {
172
+ const theme = createTheme();
173
+ const cssVars = themeToCssVariables(theme);
174
+
175
+ expect(cssVars['--persona-components-artifact-pane-background']).toBe('#f3f4f6');
176
+ expect(cssVars['--persona-artifact-toolbar-bg']).toBe('#f3f4f6');
177
+
178
+ const surfacePane = createTheme({
179
+ components: {
180
+ artifact: {
181
+ pane: {
182
+ background: 'semantic.colors.surface',
183
+ toolbarBackground: 'semantic.colors.surface',
184
+ },
185
+ },
186
+ },
187
+ } as any);
188
+ const surfaceVars = themeToCssVariables(surfacePane);
189
+ expect(surfaceVars['--persona-components-artifact-pane-background']).toBe('#f9fafb');
190
+ expect(surfaceVars['--persona-artifact-toolbar-bg']).toBe('#f9fafb');
191
+ });
192
+
193
+ it('maps component bubble shadow tokens to consumer CSS variables', () => {
127
194
  const cfg = {
128
195
  colorScheme: 'light' as const,
129
196
  theme: {
130
- toolBubbleShadow: 'none',
131
- reasoningBubbleShadow: 'none',
132
- messageUserShadow: 'none',
133
- messageAssistantShadow: 'none',
134
- composerShadow: 'none',
197
+ components: {
198
+ toolBubble: { shadow: 'none' },
199
+ reasoningBubble: { shadow: 'none' },
200
+ composer: { shadow: 'none' },
201
+ message: {
202
+ user: { shadow: 'none' },
203
+ assistant: { shadow: 'none' },
204
+ },
205
+ },
135
206
  },
136
207
  };
137
208
 
138
- const active = getActiveTheme(cfg as any);
209
+ const active = getActiveTheme(cfg);
139
210
  const cssVars = themeToCssVariables(active);
140
211
 
141
212
  expect(cssVars['--persona-tool-bubble-shadow']).toBe('none');
@@ -149,9 +220,13 @@ describe('theme utils', () => {
149
220
  const el = document.createElement('div');
150
221
  applyThemeVariables(el, {
151
222
  colorScheme: 'light',
152
- theme: { toolBubbleShadow: '0 1px 2px rgba(255,0,0,0.5)' },
223
+ theme: {
224
+ components: {
225
+ toolBubble: { shadow: '0 1px 2px rgba(255,0,0,0.5)' },
226
+ },
227
+ },
153
228
  toolCall: { shadow: 'none' },
154
- } as any);
229
+ });
155
230
  expect(el.style.getPropertyValue('--persona-tool-bubble-shadow').trim()).toBe('none');
156
231
  });
157
232
  });
@@ -1,13 +1,13 @@
1
- import type { PersonaTheme } from '../types/theme';
2
- import type { AgentWidgetConfig, AgentWidgetTheme } from '../types';
1
+ import type { DeepPartial, PersonaTheme } from '../types/theme';
2
+ import type { AgentWidgetConfig } from '../types';
3
3
  import { createTheme, resolveTokens, themeToCssVariables } from './tokens';
4
- import { migrateV1Theme } from './migration';
4
+ import { deepMerge } from './deep-merge';
5
5
 
6
6
  export type ColorScheme = 'light' | 'dark' | 'auto';
7
7
 
8
8
  export interface PersonaWidgetConfig {
9
- theme?: Partial<PersonaTheme>;
10
- darkTheme?: Partial<PersonaTheme>;
9
+ theme?: DeepPartial<PersonaTheme>;
10
+ darkTheme?: DeepPartial<PersonaTheme>;
11
11
  colorScheme?: ColorScheme;
12
12
  }
13
13
 
@@ -106,43 +106,14 @@ const DARK_PALETTE = {
106
106
  },
107
107
  };
108
108
 
109
- const isObject = (value: unknown): value is Record<string, unknown> =>
110
- typeof value === 'object' && value !== null && !Array.isArray(value);
111
-
112
- const deepMerge = <T extends Record<string, unknown>>(
113
- base: T | undefined,
114
- override: Record<string, unknown> | undefined
115
- ): T | Record<string, unknown> | undefined => {
116
- if (!base) return override;
117
- if (!override) return base;
118
-
119
- const merged: Record<string, unknown> = { ...base };
120
-
121
- for (const [key, value] of Object.entries(override)) {
122
- const existing = merged[key];
123
- if (isObject(existing) && isObject(value)) {
124
- merged[key] = deepMerge(existing, value);
125
- } else {
126
- merged[key] = value;
127
- }
128
- }
129
-
130
- return merged;
131
- };
132
-
133
- const isTokenTheme = (theme: unknown): theme is Partial<PersonaTheme> => {
134
- return isObject(theme) && ('palette' in theme || 'semantic' in theme || 'components' in theme);
135
- };
136
-
109
+ /**
110
+ * Normalize theme config for merging; rejects non-objects.
111
+ */
137
112
  const normalizeThemeConfig = (
138
- theme: Partial<PersonaTheme> | AgentWidgetTheme | undefined
139
- ): Partial<PersonaTheme> | undefined => {
140
- if (!theme) return undefined;
141
- const migratedTheme = migrateV1Theme(theme as AgentWidgetTheme, { warn: false });
142
- if (isTokenTheme(theme)) {
143
- return deepMerge(migratedTheme, theme) as Partial<PersonaTheme>;
144
- }
145
- return migratedTheme;
113
+ theme: DeepPartial<PersonaTheme> | Record<string, unknown> | undefined
114
+ ): DeepPartial<PersonaTheme> | undefined => {
115
+ if (!theme || typeof theme !== 'object' || Array.isArray(theme)) return undefined;
116
+ return theme as DeepPartial<PersonaTheme>;
146
117
  };
147
118
 
148
119
  export const detectColorScheme = (): 'light' | 'dark' => {
@@ -170,11 +141,11 @@ export const getColorScheme = (config?: WidgetConfig): 'light' | 'dark' => {
170
141
  return getColorSchemeFromConfig(config);
171
142
  };
172
143
 
173
- export const createLightTheme = (userConfig?: Partial<PersonaTheme>): PersonaTheme => {
144
+ export const createLightTheme = (userConfig?: DeepPartial<PersonaTheme>): PersonaTheme => {
174
145
  return createTheme(userConfig);
175
146
  };
176
147
 
177
- export const createDarkTheme = (userConfig?: Partial<PersonaTheme>): PersonaTheme => {
148
+ export const createDarkTheme = (userConfig?: DeepPartial<PersonaTheme>): PersonaTheme => {
178
149
  const baseTheme = createTheme(undefined, { validate: false });
179
150
 
180
151
  return createTheme(
@@ -194,12 +165,15 @@ export const createDarkTheme = (userConfig?: Partial<PersonaTheme>): PersonaThem
194
165
 
195
166
  export const getActiveTheme = (config?: WidgetConfig): PersonaTheme => {
196
167
  const scheme = getColorScheme(config);
197
- const lightThemeConfig = normalizeThemeConfig(config?.theme as Partial<PersonaTheme> | AgentWidgetTheme | undefined);
198
- const darkThemeConfig = normalizeThemeConfig(config?.darkTheme as Partial<PersonaTheme> | AgentWidgetTheme | undefined);
168
+ const lightThemeConfig = normalizeThemeConfig(config?.theme);
169
+ const darkThemeConfig = normalizeThemeConfig(config?.darkTheme);
199
170
 
200
171
  if (scheme === 'dark') {
201
172
  return createDarkTheme(
202
- deepMerge(lightThemeConfig, darkThemeConfig) as Partial<PersonaTheme> | undefined
173
+ deepMerge(
174
+ (lightThemeConfig ?? {}) as Record<string, unknown>,
175
+ (darkThemeConfig ?? {}) as Record<string, unknown>
176
+ ) as DeepPartial<PersonaTheme>
203
177
  );
204
178
  }
205
179
 
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ DeepPartial,
2
3
  PersonaTheme,
3
4
  ResolvedToken,
4
5
  ThemeValidationResult,
@@ -265,6 +266,11 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
265
266
  border: 'semantic.colors.border',
266
267
  borderRadius: 'palette.radius.xl palette.radius.xl 0 0',
267
268
  padding: 'semantic.spacing.md',
269
+ iconBackground: 'semantic.colors.primary',
270
+ iconForeground: 'semantic.colors.textInverse',
271
+ titleForeground: 'semantic.colors.primary',
272
+ subtitleForeground: 'semantic.colors.textMuted',
273
+ actionIconForeground: 'semantic.colors.textMuted',
268
274
  },
269
275
  message: {
270
276
  user: {
@@ -341,9 +347,15 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
341
347
  border: 'palette.colors.gray.200',
342
348
  },
343
349
  },
350
+ artifact: {
351
+ pane: {
352
+ background: 'semantic.colors.container',
353
+ toolbarBackground: 'semantic.colors.container',
354
+ },
355
+ },
344
356
  };
345
357
 
346
- function resolveTokenValue(theme: PersonaTheme, path: string): string | undefined {
358
+ export function resolveTokenValue(theme: PersonaTheme, path: string): string | undefined {
347
359
  if (
348
360
  !path.startsWith('palette.') &&
349
361
  !path.startsWith('semantic.') &&
@@ -481,7 +493,7 @@ function deepMergeComponents(
481
493
  }
482
494
 
483
495
  export function createTheme(
484
- userConfig?: Partial<PersonaTheme>,
496
+ userConfig?: DeepPartial<PersonaTheme>,
485
497
  options: CreateThemeOptions = {}
486
498
  ): PersonaTheme {
487
499
  const baseTheme: PersonaTheme = {
@@ -543,8 +555,11 @@ export function createTheme(
543
555
  ...userConfig?.semantic?.typography,
544
556
  },
545
557
  },
546
- components: deepMergeComponents(baseTheme.components, userConfig?.components),
547
- };
558
+ components: deepMergeComponents(
559
+ baseTheme.components,
560
+ userConfig?.components as Partial<ComponentTokens> | undefined
561
+ ),
562
+ } as PersonaTheme;
548
563
 
549
564
  if (options.validate !== false) {
550
565
  const validation = validateTheme(theme);
@@ -606,6 +621,9 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
606
621
  cssVars['--persona-font-weight'] = cssVars['--persona-semantic-typography-fontWeight'] ?? cssVars['--persona-palette-typography-fontWeight-normal'];
607
622
  cssVars['--persona-line-height'] = cssVars['--persona-semantic-typography-lineHeight'] ?? cssVars['--persona-palette-typography-lineHeight-normal'];
608
623
 
624
+ cssVars['--persona-input-font-family'] = cssVars['--persona-font-family'];
625
+ cssVars['--persona-input-font-weight'] = cssVars['--persona-font-weight'];
626
+
609
627
  // Radius aliases used throughout the existing widget CSS.
610
628
  cssVars['--persona-radius-sm'] = cssVars['--persona-palette-radius-sm'] ?? '0.125rem';
611
629
  cssVars['--persona-radius-md'] = cssVars['--persona-palette-radius-md'] ?? '0.375rem';
@@ -623,6 +641,12 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
623
641
  cssVars['--persona-components-panel-borderRadius'] ??
624
642
  cssVars['--persona-radius-xl'] ??
625
643
  '0.75rem';
644
+ cssVars['--persona-panel-border'] =
645
+ cssVars['--persona-components-panel-border'] ?? `1px solid ${cssVars['--persona-border']}`;
646
+ cssVars['--persona-panel-shadow'] =
647
+ cssVars['--persona-components-panel-shadow'] ??
648
+ cssVars['--persona-palette-shadows-xl'] ??
649
+ '0 25px 50px -12px rgba(0, 0, 0, 0.25)';
626
650
  cssVars['--persona-input-radius'] =
627
651
  cssVars['--persona-components-input-borderRadius'] ??
628
652
  cssVars['--persona-radius-lg'] ??
@@ -642,6 +666,20 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
642
666
  cssVars['--persona-components-header-background'] ?? cssVars['--persona-surface'];
643
667
  cssVars['--persona-header-border'] =
644
668
  cssVars['--persona-components-header-border'] ?? cssVars['--persona-divider'];
669
+ cssVars['--persona-header-icon-bg'] =
670
+ cssVars['--persona-components-header-iconBackground'] ?? cssVars['--persona-primary'];
671
+ cssVars['--persona-header-icon-fg'] =
672
+ cssVars['--persona-components-header-iconForeground'] ?? cssVars['--persona-text-inverse'];
673
+ cssVars['--persona-header-title-fg'] =
674
+ cssVars['--persona-components-header-titleForeground'] ?? cssVars['--persona-primary'];
675
+ cssVars['--persona-header-subtitle-fg'] =
676
+ cssVars['--persona-components-header-subtitleForeground'] ?? cssVars['--persona-text-muted'];
677
+ cssVars['--persona-header-action-icon-fg'] =
678
+ cssVars['--persona-components-header-actionIconForeground'] ?? cssVars['--persona-muted'];
679
+
680
+ const headerTokens = theme.components?.header;
681
+ if (headerTokens?.shadow) cssVars['--persona-header-shadow'] = headerTokens.shadow;
682
+ if (headerTokens?.borderBottom) cssVars['--persona-header-border-bottom'] = headerTokens.borderBottom;
645
683
 
646
684
  cssVars['--persona-message-user-bg'] =
647
685
  cssVars['--persona-components-message-user-background'] ?? cssVars['--persona-accent'];
@@ -689,8 +727,42 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
689
727
  cssVars['--persona-md-prose-font-family'] = mdProseFont;
690
728
  }
691
729
 
692
- // Artifact tokens
730
+ // Icon button tokens
693
731
  const components = theme.components;
732
+ const iconBtn = components?.iconButton;
733
+ if (iconBtn) {
734
+ if (iconBtn.background) cssVars['--persona-icon-btn-bg'] = iconBtn.background;
735
+ if (iconBtn.border) cssVars['--persona-icon-btn-border'] = iconBtn.border;
736
+ if (iconBtn.color) cssVars['--persona-icon-btn-color'] = iconBtn.color;
737
+ if (iconBtn.padding) cssVars['--persona-icon-btn-padding'] = iconBtn.padding;
738
+ if (iconBtn.borderRadius) cssVars['--persona-icon-btn-radius'] = iconBtn.borderRadius;
739
+ if (iconBtn.hoverBackground) cssVars['--persona-icon-btn-hover-bg'] = iconBtn.hoverBackground;
740
+ if (iconBtn.hoverColor) cssVars['--persona-icon-btn-hover-color'] = iconBtn.hoverColor;
741
+ if (iconBtn.activeBackground) cssVars['--persona-icon-btn-active-bg'] = iconBtn.activeBackground;
742
+ if (iconBtn.activeBorder) cssVars['--persona-icon-btn-active-border'] = iconBtn.activeBorder;
743
+ }
744
+
745
+ // Label button tokens
746
+ const labelBtn = components?.labelButton;
747
+ if (labelBtn) {
748
+ if (labelBtn.background) cssVars['--persona-label-btn-bg'] = labelBtn.background;
749
+ if (labelBtn.border) cssVars['--persona-label-btn-border'] = labelBtn.border;
750
+ if (labelBtn.color) cssVars['--persona-label-btn-color'] = labelBtn.color;
751
+ if (labelBtn.padding) cssVars['--persona-label-btn-padding'] = labelBtn.padding;
752
+ if (labelBtn.borderRadius) cssVars['--persona-label-btn-radius'] = labelBtn.borderRadius;
753
+ if (labelBtn.hoverBackground) cssVars['--persona-label-btn-hover-bg'] = labelBtn.hoverBackground;
754
+ if (labelBtn.fontSize) cssVars['--persona-label-btn-font-size'] = labelBtn.fontSize;
755
+ if (labelBtn.gap) cssVars['--persona-label-btn-gap'] = labelBtn.gap;
756
+ }
757
+
758
+ // Toggle group tokens
759
+ const toggleGrp = components?.toggleGroup;
760
+ if (toggleGrp) {
761
+ if (toggleGrp.gap) cssVars['--persona-toggle-group-gap'] = toggleGrp.gap;
762
+ if (toggleGrp.borderRadius) cssVars['--persona-toggle-group-radius'] = toggleGrp.borderRadius;
763
+ }
764
+
765
+ // Artifact tokens
694
766
  const artifact = components?.artifact;
695
767
  if (artifact?.toolbar) {
696
768
  const t = artifact.toolbar;
@@ -706,11 +778,28 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
706
778
  if (t.copyColor) cssVars['--persona-artifact-toolbar-copy-color'] = t.copyColor;
707
779
  if (t.copyBorderRadius) cssVars['--persona-artifact-toolbar-copy-radius'] = t.copyBorderRadius;
708
780
  if (t.copyPadding) cssVars['--persona-artifact-toolbar-copy-padding'] = t.copyPadding;
709
- if (t.copyMenuBackground) cssVars['--persona-artifact-toolbar-copy-menu-bg'] = t.copyMenuBackground;
710
- if (t.copyMenuBorder) cssVars['--persona-artifact-toolbar-copy-menu-border'] = t.copyMenuBorder;
711
- if (t.copyMenuShadow) cssVars['--persona-artifact-toolbar-copy-menu-shadow'] = t.copyMenuShadow;
712
- if (t.copyMenuBorderRadius) cssVars['--persona-artifact-toolbar-copy-menu-radius'] = t.copyMenuBorderRadius;
713
- if (t.copyMenuItemHoverBackground) cssVars['--persona-artifact-toolbar-copy-menu-item-hover-bg'] = t.copyMenuItemHoverBackground;
781
+ if (t.copyMenuBackground) {
782
+ cssVars['--persona-artifact-toolbar-copy-menu-bg'] = t.copyMenuBackground;
783
+ cssVars['--persona-dropdown-bg'] = cssVars['--persona-dropdown-bg'] ?? t.copyMenuBackground;
784
+ }
785
+ if (t.copyMenuBorder) {
786
+ cssVars['--persona-artifact-toolbar-copy-menu-border'] = t.copyMenuBorder;
787
+ cssVars['--persona-dropdown-border'] = cssVars['--persona-dropdown-border'] ?? t.copyMenuBorder;
788
+ }
789
+ if (t.copyMenuShadow) {
790
+ cssVars['--persona-artifact-toolbar-copy-menu-shadow'] = t.copyMenuShadow;
791
+ cssVars['--persona-dropdown-shadow'] = cssVars['--persona-dropdown-shadow'] ?? t.copyMenuShadow;
792
+ }
793
+ if (t.copyMenuBorderRadius) {
794
+ cssVars['--persona-artifact-toolbar-copy-menu-radius'] = t.copyMenuBorderRadius;
795
+ cssVars['--persona-dropdown-radius'] = cssVars['--persona-dropdown-radius'] ?? t.copyMenuBorderRadius;
796
+ }
797
+ if (t.copyMenuItemHoverBackground) {
798
+ cssVars['--persona-artifact-toolbar-copy-menu-item-hover-bg'] = t.copyMenuItemHoverBackground;
799
+ cssVars['--persona-dropdown-item-hover-bg'] = cssVars['--persona-dropdown-item-hover-bg'] ?? t.copyMenuItemHoverBackground;
800
+ }
801
+ if (t.iconBackground) cssVars['--persona-artifact-toolbar-icon-bg'] = t.iconBackground;
802
+ if (t.toolbarBorder) cssVars['--persona-artifact-toolbar-border'] = t.toolbarBorder;
714
803
  }
715
804
  if (artifact?.tab) {
716
805
  const t = artifact.tab;
@@ -719,10 +808,18 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
719
808
  if (t.activeBorder) cssVars['--persona-artifact-tab-active-border'] = t.activeBorder;
720
809
  if (t.borderRadius) cssVars['--persona-artifact-tab-radius'] = t.borderRadius;
721
810
  if (t.textColor) cssVars['--persona-artifact-tab-color'] = t.textColor;
811
+ if (t.hoverBackground) cssVars['--persona-artifact-tab-hover-bg'] = t.hoverBackground;
812
+ if (t.listBackground) cssVars['--persona-artifact-tab-list-bg'] = t.listBackground;
813
+ if (t.listBorderColor) cssVars['--persona-artifact-tab-list-border-color'] = t.listBorderColor;
814
+ if (t.listPadding) cssVars['--persona-artifact-tab-list-padding'] = t.listPadding;
722
815
  }
723
816
  if (artifact?.pane) {
724
817
  const t = artifact.pane;
725
- if (t.toolbarBackground) cssVars['--persona-artifact-toolbar-bg'] = t.toolbarBackground;
818
+ if (t.toolbarBackground) {
819
+ const toolbarBg =
820
+ resolveTokenValue(theme, t.toolbarBackground) ?? t.toolbarBackground;
821
+ cssVars['--persona-artifact-toolbar-bg'] = toolbarBg;
822
+ }
726
823
  }
727
824
 
728
825
  return cssVars;