@runtypelabs/persona 4.9.0 → 4.10.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.
package/src/ui.ts CHANGED
@@ -5,6 +5,7 @@ import { onMarkdownParsersReady, getMarkdownParsersSync } from "./markdown-parse
5
5
  import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
6
6
  import {
7
7
  AgentWidgetConfig,
8
+ AgentWidgetConfigPatch,
8
9
  AgentWidgetApprovalDecisionOptions,
9
10
  AgentWidgetMessage,
10
11
  AgentWidgetEvent,
@@ -133,6 +134,7 @@ import { readFlexGapPx, resolveArtifactPaneWidthPx } from "./utils/artifact-resi
133
134
  import { enhanceWithForms } from "./components/forms";
134
135
  import { pluginRegistry } from "./plugins/registry";
135
136
  import { mergeWithDefaults, DEFAULT_FLOATING_LAUNCHER_WIDTH } from "./defaults";
137
+ import { mergeConfigUpdate } from "./utils/config-merge";
136
138
  import { createEventBus } from "./utils/events";
137
139
  import {
138
140
  createActionManager,
@@ -305,7 +307,7 @@ const stripStreamingFromMessages = (messages: AgentWidgetMessage[]) =>
305
307
  }));
306
308
 
307
309
  type Controller = {
308
- update: (config: AgentWidgetConfig) => void;
310
+ update: (config: AgentWidgetConfigPatch) => void;
309
311
  destroy: () => void;
310
312
  open: () => void;
311
313
  close: () => void;
@@ -7683,7 +7685,7 @@ export const createAgentExperience = (
7683
7685
  }
7684
7686
 
7685
7687
  const controller: Controller = {
7686
- update(nextConfig: AgentWidgetConfig) {
7688
+ update(nextConfig: AgentWidgetConfigPatch) {
7687
7689
  const previousToolCallConfig = config.toolCall;
7688
7690
  const previousMessageActions = config.messageActions;
7689
7691
  const previousLayoutMessages = config.layout?.messages;
@@ -7695,7 +7697,10 @@ export const createAgentExperience = (
7695
7697
  const previousToolCallDisplay = config.features?.toolCallDisplay;
7696
7698
  const previousReasoningDisplay = config.features?.reasoningDisplay;
7697
7699
  const previousStreamAnimationType = config.features?.streamAnimation?.type;
7698
- config = { ...config, ...nextConfig };
7700
+ // One consistent recursive patch policy across the live controller and the
7701
+ // init handle. See utils/config-merge.ts for the replace-leaf list and
7702
+ // explicit-undefined reset semantics.
7703
+ config = mergeConfigUpdate(config, nextConfig);
7699
7704
  // applyFullHeightStyles resets mount.style.cssText, so call it before applyThemeVariables
7700
7705
  applyFullHeightStyles();
7701
7706
  applyThemeVariables(mount, config);
@@ -7865,7 +7870,10 @@ export const createAgentExperience = (
7865
7870
  headerSubtitle.style.display = headerLayoutConfig.showSubtitle === false ? "none" : "";
7866
7871
  }
7867
7872
  if (closeButton) {
7868
- closeButton.style.display = headerLayoutConfig.showCloseButton === false ? "none" : "";
7873
+ // showCloseButton (defaulted true) filters on top of toggleability;
7874
+ // it must not reveal the close button on non-closeable panels.
7875
+ const showClose = isPanelToggleable() && headerLayoutConfig.showCloseButton !== false;
7876
+ closeButton.style.display = showClose ? "" : "none";
7869
7877
  }
7870
7878
  if (panelElements.clearChatButtonWrapper) {
7871
7879
  // showClearChat explicitly controls visibility when set
@@ -7926,7 +7934,7 @@ export const createAgentExperience = (
7926
7934
  refreshCloseButton();
7927
7935
 
7928
7936
  // Re-render messages if config affecting message rendering changed
7929
- const toolCallConfigChanged = JSON.stringify(nextConfig.toolCall) !== JSON.stringify(previousToolCallConfig);
7937
+ const toolCallConfigChanged = JSON.stringify(config.toolCall) !== JSON.stringify(previousToolCallConfig);
7930
7938
  const messageActionsChanged = JSON.stringify(config.messageActions) !== JSON.stringify(previousMessageActions);
7931
7939
  const layoutMessagesChanged = JSON.stringify(config.layout?.messages) !== JSON.stringify(previousLayoutMessages);
7932
7940
  const loadingIndicatorChanged = config.loadingIndicator?.render !== previousLoadingIndicator?.render
@@ -8067,13 +8075,11 @@ export const createAgentExperience = (
8067
8075
  }
8068
8076
 
8069
8077
  if (closeButton) {
8070
- // Handle close button visibility from layout config
8071
- const layoutShowCloseButton = config.layout?.header?.showCloseButton;
8072
- if (layoutShowCloseButton === false) {
8073
- closeButton.style.display = "none";
8074
- } else {
8075
- closeButton.style.display = "";
8076
- }
8078
+ // showCloseButton (defaulted true) filters on top of toggleability;
8079
+ // it must not reveal the close button on non-closeable panels.
8080
+ const layoutShowCloseButton =
8081
+ isPanelToggleable() && config.layout?.header?.showCloseButton !== false;
8082
+ closeButton.style.display = layoutShowCloseButton ? "" : "none";
8077
8083
 
8078
8084
  const closeButtonSize = launcher.closeButtonSize ?? "32px";
8079
8085
  const closeButtonPlacement = launcher.closeButtonPlacement ?? "inline";
@@ -8164,6 +8170,9 @@ export const createAgentExperience = (
8164
8170
  closeButton.innerHTML = "";
8165
8171
  const iconSvg = renderLucideIcon(closeButtonIconName, "28px", "currentColor", 1);
8166
8172
  if (iconSvg) {
8173
+ // display:block matches the builder; inline SVG baseline spacing
8174
+ // shifts the icon off-center.
8175
+ iconSvg.style.display = "block";
8167
8176
  closeButton.appendChild(iconSvg);
8168
8177
  } else {
8169
8178
  closeButton.textContent = closeButtonIconText;
@@ -8350,8 +8359,13 @@ export const createAgentExperience = (
8350
8359
  // the icon to match its 16px button.
8351
8360
  clearChatButton.innerHTML = "";
8352
8361
  const clearChatIconSize = isComposerBar() ? "14px" : "20px";
8353
- const iconSvg = renderLucideIcon(clearChatIconName, clearChatIconSize, "currentColor", 2);
8362
+ // Stroke 1 matches the mount-time builder (header-parts.ts); a
8363
+ // different weight here makes the icon visibly bolden on update.
8364
+ const iconSvg = renderLucideIcon(clearChatIconName, clearChatIconSize, "currentColor", 1);
8354
8365
  if (iconSvg) {
8366
+ // display:block matches the builder; inline SVG baseline spacing
8367
+ // shifts the icon off-center.
8368
+ iconSvg.style.display = "block";
8355
8369
  clearChatButton.appendChild(iconSvg);
8356
8370
  }
8357
8371
 
@@ -8554,10 +8568,16 @@ export const createAgentExperience = (
8554
8568
  micButton.style.minWidth = micIconSize;
8555
8569
  micButton.style.minHeight = micIconSize;
8556
8570
 
8557
- // Update icon
8558
- const iconColor = voiceConfig.iconColor ?? sendButtonConfig.textColor ?? "currentColor";
8571
+ // Update icon; color chain and stroke 1.5 match the mount-time
8572
+ // builder (composer-parts.ts) so an unrelated update cannot restyle.
8573
+ const iconColor = voiceConfig.iconColor ?? sendButtonConfig.textColor;
8559
8574
  micButton.innerHTML = "";
8560
- const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColor, 2);
8575
+ const micIconSvg = renderLucideIcon(
8576
+ micIconName,
8577
+ micIconSizeNum,
8578
+ iconColor || "currentColor",
8579
+ 1.5
8580
+ );
8561
8581
  if (micIconSvg) {
8562
8582
  micButton.appendChild(micIconSvg);
8563
8583
  } else {
@@ -8741,10 +8761,6 @@ export const createAgentExperience = (
8741
8761
  });
8742
8762
  }
8743
8763
 
8744
- // Create drop overlay if missing
8745
- if (!container.querySelector(".persona-attachment-drop-overlay")) {
8746
- container.appendChild(buildDropOverlay(attachmentsConfig.dropOverlay));
8747
- }
8748
8764
  } else {
8749
8765
  // Show existing attachment button and update config
8750
8766
  attachmentButtonWrapper.style.display = "";
@@ -8765,6 +8781,33 @@ export const createAgentExperience = (
8765
8781
  });
8766
8782
  }
8767
8783
  }
8784
+
8785
+ // Rebuild the overlay so live dropOverlay updates restyle it; visibility
8786
+ // is owned by the container's drop-active class, so this is drag-safe.
8787
+ container.querySelector(".persona-attachment-drop-overlay")?.remove();
8788
+ container.appendChild(buildDropOverlay(config.attachments?.dropOverlay));
8789
+
8790
+ // Re-render icon/tooltip so live buttonIconName/buttonTooltipText
8791
+ // updates apply; the button element itself is created once and kept.
8792
+ if (attachmentButton) {
8793
+ const attCfg = config.attachments ?? {};
8794
+ const tooltipText = attCfg.buttonTooltipText ?? "Attach file";
8795
+ attachmentButton.setAttribute("aria-label", tooltipText);
8796
+ attachmentButton.textContent = "";
8797
+ const btnSize = parseFloat(config.sendButton?.size ?? "40px") || 40;
8798
+ const iconSvg = renderLucideIcon(
8799
+ attCfg.buttonIconName ?? "paperclip",
8800
+ Math.round(btnSize * 0.6),
8801
+ "currentColor",
8802
+ 1.5
8803
+ );
8804
+ if (iconSvg) attachmentButton.appendChild(iconSvg);
8805
+ else attachmentButton.textContent = "📎";
8806
+ const attachTooltip = attachmentButtonWrapper?.querySelector(
8807
+ ".persona-send-button-tooltip"
8808
+ );
8809
+ if (attachTooltip) attachTooltip.textContent = tooltipText;
8810
+ }
8768
8811
  } else {
8769
8812
  // Hide attachment button if disabled
8770
8813
  if (attachmentButtonWrapper) {
@@ -8799,9 +8842,6 @@ export const createAgentExperience = (
8799
8842
  sendButton.style.fontSize = "18px";
8800
8843
  sendButton.style.lineHeight = "1";
8801
8844
 
8802
- // Clear existing content
8803
- sendButton.innerHTML = "";
8804
-
8805
8845
  // Set foreground color from config or theme token
8806
8846
  if (textColor) {
8807
8847
  sendButton.style.color = textColor;
@@ -8809,20 +8849,25 @@ export const createAgentExperience = (
8809
8849
  sendButton.style.color = "var(--persona-button-primary-fg, #ffffff)";
8810
8850
  }
8811
8851
 
8812
- // Use Lucide icon if iconName is provided, otherwise fall back to iconText
8813
- if (iconName) {
8814
- const iconSize = parseFloat(buttonSize) || 24;
8815
- const iconColor = textColor?.trim() || "currentColor";
8816
- const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
8817
- if (iconSvg) {
8818
- sendButton.appendChild(iconSvg);
8852
+ // Skip the icon-content re-render while streaming: the button holds the
8853
+ // stop icon (owned by setSendButtonMode), and clobbering it here would
8854
+ // show the send arrow mid-stream while the aria-label still says stop.
8855
+ if (!session.isStreaming()) {
8856
+ sendButton.innerHTML = "";
8857
+ if (iconName) {
8858
+ const iconSize = parseFloat(buttonSize) || 24;
8859
+ const iconColor = textColor?.trim() || "currentColor";
8860
+ const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
8861
+ if (iconSvg) {
8862
+ sendButton.appendChild(iconSvg);
8863
+ } else {
8864
+ sendButton.textContent = iconText;
8865
+ }
8819
8866
  } else {
8820
8867
  sendButton.textContent = iconText;
8821
8868
  }
8822
- } else {
8823
- sendButton.textContent = iconText;
8824
8869
  }
8825
-
8870
+
8826
8871
  // Update classes
8827
8872
  sendButton.className = "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer";
8828
8873
 
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { mergeConfigUpdate } from "./config-merge";
4
+ import { mergeWithDefaults } from "../defaults";
5
+ import type { AgentWidgetConfig, StreamAnimationPlugin } from "../types";
6
+
7
+ // Stored controller config is post-mergeWithDefaults; simulate that here.
8
+ const base = (overrides: Partial<AgentWidgetConfig> = {}): AgentWidgetConfig =>
9
+ mergeWithDefaults({ apiUrl: "https://api.example.com/chat", ...overrides }) as AgentWidgetConfig;
10
+
11
+ describe("mergeConfigUpdate", () => {
12
+ it("recursively merges nested plain objects, preserving sibling overrides", () => {
13
+ const prev = base({ launcher: { enabled: false, clearChat: { backgroundColor: "#123456" } } });
14
+ const next = mergeConfigUpdate(prev, { launcher: { title: "After" } });
15
+ expect(next.launcher?.clearChat?.backgroundColor).toBe("#123456");
16
+ expect(next.launcher?.title).toBe("After");
17
+ // Launcher defaults survive a partial patch.
18
+ expect(next.launcher?.mountMode).toBe("floating");
19
+ });
20
+
21
+ it("patches composerBar without replacing sibling launcher fields", () => {
22
+ const prev = base({ launcher: { title: "Keep", composerBar: { collapsedMaxWidth: "700px" } } });
23
+ const next = mergeConfigUpdate(prev, { launcher: { composerBar: { bottomOffset: "8px" } } });
24
+ expect(next.launcher?.title).toBe("Keep");
25
+ expect(next.launcher?.composerBar?.collapsedMaxWidth).toBe("700px");
26
+ expect(next.launcher?.composerBar?.bottomOffset).toBe("8px");
27
+ });
28
+
29
+ it("replaces arrays wholesale (suggestionChips)", () => {
30
+ const prev = base({ suggestionChips: ["a", "b", "c"] });
31
+ const next = mergeConfigUpdate(prev, { suggestionChips: ["x"] });
32
+ expect(next.suggestionChips).toEqual(["x"]);
33
+ });
34
+
35
+ it("replaces callbacks wholesale", () => {
36
+ const first = vi.fn();
37
+ const second = vi.fn();
38
+ const prev = base({ onSessionInit: first });
39
+ const next = mergeConfigUpdate(prev, { onSessionInit: second });
40
+ expect(next.onSessionInit).toBe(second);
41
+ });
42
+
43
+ it("guards boolean|object unions in both directions", () => {
44
+ const objFirst = base({ approval: { backgroundColor: "#ffffff" } });
45
+ const toScalar = mergeConfigUpdate(objFirst, { approval: false });
46
+ expect(toScalar.approval).toBe(false);
47
+
48
+ const scalarFirst = base({ persistState: true });
49
+ const toObject = mergeConfigUpdate(scalarFirst, { persistState: { storage: "local" } });
50
+ expect(toObject.persistState).toEqual({ storage: "local" });
51
+ });
52
+
53
+ it("replaces the headers map wholesale (stale keys do not survive)", () => {
54
+ const prev = base({ headers: { "X-A": "1", "X-B": "2" } });
55
+ const next = mergeConfigUpdate(prev, { headers: { "X-B": "3" } });
56
+ expect(next.headers).toEqual({ "X-B": "3" });
57
+ expect(next.headers?.["X-A"]).toBeUndefined();
58
+ });
59
+
60
+ it("replaces features.streamAnimation.plugins wholesale", () => {
61
+ const p1 = { name: "p1" } as StreamAnimationPlugin;
62
+ const p2 = { name: "p2" } as StreamAnimationPlugin;
63
+ const prev = base({ features: { streamAnimation: { plugins: { p1 } } } });
64
+ const next = mergeConfigUpdate(prev, { features: { streamAnimation: { plugins: { p2 } } } });
65
+ expect(next.features?.streamAnimation?.plugins).toEqual({ p2 });
66
+ expect(next.features?.streamAnimation?.plugins?.p1).toBeUndefined();
67
+ });
68
+
69
+ it("replaces storageAdapter wholesale (no hybrid: new adapter's absent save is not inherited)", () => {
70
+ const oldSave = vi.fn();
71
+ const oldLoad = vi.fn();
72
+ const newLoad = vi.fn();
73
+ const prev = base({ storageAdapter: { save: oldSave, load: oldLoad } });
74
+ const next = mergeConfigUpdate(prev, { storageAdapter: { load: newLoad } });
75
+ expect(next.storageAdapter?.load).toBe(newLoad);
76
+ expect(next.storageAdapter?.save).toBeUndefined();
77
+ });
78
+
79
+ it("resets a cleared key to its default value (launcher.title)", () => {
80
+ const prev = base({ launcher: { title: "Custom" } });
81
+ expect(prev.launcher?.title).toBe("Custom");
82
+ const next = mergeConfigUpdate(prev, { launcher: { title: undefined } });
83
+ expect(next.launcher?.title).toBe("Chat Assistant");
84
+ // Other launcher fields are untouched by the clear.
85
+ expect(next.launcher?.mountMode).toBe("floating");
86
+ });
87
+
88
+ it("leaves a cleared key unset when no default exists (launcher.closeButtonColor)", () => {
89
+ const prev = base({ launcher: { closeButtonColor: "#ff0000" } });
90
+ expect(prev.launcher?.closeButtonColor).toBe("#ff0000");
91
+ const next = mergeConfigUpdate(prev, { launcher: { closeButtonColor: undefined } });
92
+ // closeButtonColor is intentionally omitted from the launcher defaults.
93
+ expect(next.launcher?.closeButtonColor).toBeUndefined();
94
+ });
95
+
96
+ it("resets a cleared key with a truthy default (messageActions.showCopy resets to true)", () => {
97
+ const prev = base({ messageActions: { showCopy: false } });
98
+ expect(prev.messageActions?.showCopy).toBe(false);
99
+ const next = mergeConfigUpdate(prev, { messageActions: { showCopy: undefined } });
100
+ // The key must be absent (not own-undefined) so the default spread repopulates it.
101
+ expect(next.messageActions?.showCopy).toBe(true);
102
+ });
103
+
104
+ it("resets a cleared nested key with a truthy default (layout.header.showTitle resets to true)", () => {
105
+ const prev = base({ layout: { header: { showTitle: false } } });
106
+ expect(prev.layout?.header?.showTitle).toBe(false);
107
+ const next = mergeConfigUpdate(prev, { layout: { header: { showTitle: undefined } } });
108
+ expect(next.layout?.header?.showTitle).toBe(true);
109
+ });
110
+
111
+ it("preserves keys absent from the patch", () => {
112
+ const prev = base({ launcher: { title: "Keep", subtitle: "Sub" } });
113
+ const next = mergeConfigUpdate(prev, { launcher: { title: "New" } });
114
+ expect(next.launcher?.title).toBe("New");
115
+ expect(next.launcher?.subtitle).toBe("Sub");
116
+ });
117
+
118
+ it("deep-merges theme partials through update, preserving earlier theme overrides", () => {
119
+ const prev = base({ theme: { semantic: { colors: { primary: "#111111" } } } });
120
+ const next = mergeConfigUpdate(prev, { theme: { semantic: { colors: { secondary: "#222222" } } } });
121
+ expect(next.theme?.semantic?.colors?.primary).toBe("#111111");
122
+ expect(next.theme?.semantic?.colors?.secondary).toBe("#222222");
123
+ });
124
+
125
+ it("is idempotent: merging a config over its equal self is a no-op", () => {
126
+ const prev = base({ launcher: { clearChat: { backgroundColor: "#123456" } } });
127
+ const merged = mergeConfigUpdate(prev, { launcher: { title: "T" } });
128
+ const again = mergeConfigUpdate(merged, merged);
129
+ expect(again).toEqual(merged);
130
+ });
131
+ });
@@ -0,0 +1,61 @@
1
+ import { mergeWithDefaults } from "../defaults";
2
+ import type { AgentWidgetConfig, AgentWidgetConfigPatch } from "../types";
3
+
4
+ // Same predicate as utils/deep-merge.ts: plain object, non-null, non-array.
5
+ // Class instances and DOM nodes also pass here, so replace-leaf paths guard the
6
+ // ones that must not be recursed into.
7
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
8
+ typeof value === "object" && value !== null && !Array.isArray(value);
9
+
10
+ // Dotted paths whose plain-object value replaces wholesale instead of recursing.
11
+ // - user-defined key maps: recursive merge would strand stale keys from the old value
12
+ // (headers, targetProviders, components, features.streamAnimation.plugins).
13
+ // - implementation objects: partial merge splices two impls into a broken hybrid
14
+ // (agent, storageAdapter, voiceRecognition.provider.custom).
15
+ const REPLACE_LEAF_PATHS = new Set<string>([
16
+ "headers",
17
+ "agent",
18
+ "storageAdapter",
19
+ "components",
20
+ "targetProviders",
21
+ "voiceRecognition.provider.custom",
22
+ "features.streamAnimation.plugins",
23
+ ]);
24
+
25
+ // Recursive patch merge. A key merges recursively iff both previous and patch
26
+ // values are plain objects and the path is not a replace-leaf; otherwise the
27
+ // patch value replaces. A key present in the patch with value undefined is
28
+ // deleted from the result (an own undefined property would shadow defaults in
29
+ // mergeWithDefaults' spreads); absent keys are never visited, so they are
30
+ // preserved. theme/darkTheme need no special case: the both-sides-plain-object
31
+ // rule already reproduces mergeThemePartials/deepMerge (defaults.ts:225-236).
32
+ const mergePatch = (previous: unknown, patch: unknown, path: string): unknown => {
33
+ if (!isPlainObject(previous) || !isPlainObject(patch)) return patch;
34
+ if (REPLACE_LEAF_PATHS.has(path)) return patch;
35
+
36
+ const result: Record<string, unknown> = { ...previous };
37
+ for (const key of Object.keys(patch)) {
38
+ if (patch[key] === undefined) {
39
+ delete result[key];
40
+ continue;
41
+ }
42
+ const childPath = path ? `${path}.${key}` : key;
43
+ result[key] = mergePatch(previous[key], patch[key], childPath);
44
+ }
45
+ return result;
46
+ };
47
+
48
+ /**
49
+ * Merge a config patch over the previous widget config with one consistent
50
+ * recursive policy, then re-apply mergeWithDefaults so a cleared
51
+ * (explicit-undefined) key resets to its default. The stored controller config
52
+ * is already post-mergeWithDefaults, so re-applying it is idempotent and keeps
53
+ * initial mount, live update, and rebuild on the same merge policy.
54
+ */
55
+ export function mergeConfigUpdate(
56
+ previousConfig: AgentWidgetConfig,
57
+ patch: AgentWidgetConfigPatch
58
+ ): AgentWidgetConfig {
59
+ const merged = mergePatch(previousConfig, patch, "") as Partial<AgentWidgetConfig>;
60
+ return mergeWithDefaults(merged) as AgentWidgetConfig;
61
+ }
@@ -60,6 +60,32 @@ describe('theme utils', () => {
60
60
  expect(cssVars['--persona-palette-colors-primary-500']).toBe('#22c55e');
61
61
  });
62
62
 
63
+ it('preserves user radius and typography palette overrides in dark mode', () => {
64
+ // Regression: createDarkTheme() used to spread a pre-built default palette
65
+ // over the merged user config, which kept only `colors` and silently
66
+ // dropped radius/typography overrides that light mode honored.
67
+ const themeConfig = {
68
+ colorScheme: 'dark' as const,
69
+ theme: {
70
+ palette: {
71
+ radius: { xl: '4px' },
72
+ typography: { fontFamily: { sans: 'Georgia, serif' } },
73
+ },
74
+ },
75
+ };
76
+
77
+ const activeTheme = getActiveTheme(themeConfig);
78
+ const cssVars = themeToCssVariables(activeTheme);
79
+
80
+ // User non-color palette overrides survive the dark rebuild…
81
+ expect(cssVars['--persona-palette-radius-xl']).toBe('4px');
82
+ expect(cssVars['--persona-palette-typography-fontFamily-sans']).toBe('Georgia, serif');
83
+ // …untouched radius steps still resolve from the defaults…
84
+ expect(cssVars['--persona-palette-radius-full']).toBe('9999px');
85
+ // …and the dark color palette still applies underneath.
86
+ expect(cssVars['--persona-palette-colors-primary-500']).toBe('#171717');
87
+ });
88
+
63
89
  it('maps radius tokens into the legacy widget radius aliases', () => {
64
90
  const theme = createTheme({
65
91
  palette: {
@@ -146,13 +146,16 @@ export const createLightTheme = (userConfig?: DeepPartial<PersonaTheme>): Person
146
146
  };
147
147
 
148
148
  export const createDarkTheme = (userConfig?: DeepPartial<PersonaTheme>): PersonaTheme => {
149
- const baseTheme = createTheme(undefined, { validate: false });
150
-
149
+ // createTheme() already merges every palette sub-object (radius, typography,
150
+ // shadows, …) over the defaults, so only the dark color scales need to be
151
+ // layered UNDER the user's colors here. Spreading a pre-built default
152
+ // palette instead would clobber the user's non-color palette overrides
153
+ // (radius/typography) in dark mode while light mode honored them.
151
154
  return createTheme(
152
155
  {
153
156
  ...userConfig,
154
157
  palette: {
155
- ...baseTheme.palette,
158
+ ...userConfig?.palette,
156
159
  colors: {
157
160
  ...DARK_PALETTE.colors,
158
161
  ...userConfig?.palette?.colors,