@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/README.md +13 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +68 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -2
- package/dist/index.d.ts +13 -2
- package/dist/index.global.js +46 -46
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +58 -58
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/theme-editor-preview.cjs +61 -61
- package/dist/theme-editor-preview.d.cts +12 -1
- package/dist/theme-editor-preview.d.ts +12 -1
- package/dist/theme-editor-preview.js +47 -47
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/components/artifact-pane.test.ts +47 -0
- package/src/components/artifact-pane.ts +25 -2
- package/src/index-core.ts +1 -0
- package/src/runtime/init-update-reset.test.ts +81 -0
- package/src/runtime/init.test.ts +62 -0
- package/src/runtime/init.ts +7 -14
- package/src/styles/widget.css +14 -0
- package/src/types.ts +17 -0
- package/src/ui.artifact-pane-gating.test.ts +11 -1
- package/src/ui.attachments-drop.test.ts +90 -0
- package/src/ui.header-update-stability.test.ts +149 -0
- package/src/ui.launcher-update-merge.test.ts +83 -0
- package/src/ui.send-button-stream-update.test.ts +69 -0
- package/src/ui.ts +78 -33
- package/src/utils/config-merge.test.ts +131 -0
- package/src/utils/config-merge.ts +61 -0
- package/src/utils/theme.test.ts +26 -0
- package/src/utils/theme.ts +6 -3
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
8071
|
-
|
|
8072
|
-
|
|
8073
|
-
|
|
8074
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
8813
|
-
|
|
8814
|
-
|
|
8815
|
-
|
|
8816
|
-
|
|
8817
|
-
if (
|
|
8818
|
-
|
|
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
|
+
}
|
package/src/utils/theme.test.ts
CHANGED
|
@@ -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: {
|
package/src/utils/theme.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...
|
|
158
|
+
...userConfig?.palette,
|
|
156
159
|
colors: {
|
|
157
160
|
...DARK_PALETTE.colors,
|
|
158
161
|
...userConfig?.palette?.colors,
|