@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,417 @@
1
+ import { createElement } from "./dom";
2
+ import { renderLucideIcon } from "./icons";
3
+ import { createDropdownMenu, type DropdownMenuItem } from "./dropdown";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // createIconButton
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /** Options for {@link createIconButton}. */
10
+ export interface CreateIconButtonOptions {
11
+ /** Lucide icon name (kebab-case, e.g. "eye", "chevron-down"). */
12
+ icon: string;
13
+ /** Accessible label (used for aria-label and title). */
14
+ label: string;
15
+ /** Icon size in pixels. Default: 16. */
16
+ size?: number;
17
+ /** Icon stroke width. Default: 2. */
18
+ strokeWidth?: number;
19
+ /** Extra CSS class(es) appended after "persona-icon-btn". */
20
+ className?: string;
21
+ /** Click handler. */
22
+ onClick?: (e: MouseEvent) => void;
23
+ /** Additional ARIA attributes (e.g. { "aria-haspopup": "true" }). */
24
+ aria?: Record<string, string>;
25
+ }
26
+
27
+ /**
28
+ * Creates a minimal icon-only button with accessible labelling.
29
+ *
30
+ * The button receives the base class `persona-icon-btn` and renders a single
31
+ * Lucide icon inside it.
32
+ */
33
+ export function createIconButton(options: CreateIconButtonOptions): HTMLButtonElement {
34
+ const { icon, label, size, strokeWidth, className, onClick, aria } = options;
35
+
36
+ const btn = createElement(
37
+ "button",
38
+ "persona-icon-btn" + (className ? " " + className : ""),
39
+ );
40
+ btn.type = "button";
41
+ btn.setAttribute("aria-label", label);
42
+ btn.title = label;
43
+
44
+ const svg = renderLucideIcon(icon, size ?? 16, "currentColor", strokeWidth ?? 2);
45
+ if (svg) {
46
+ btn.appendChild(svg);
47
+ }
48
+
49
+ if (onClick) {
50
+ btn.addEventListener("click", onClick);
51
+ }
52
+
53
+ if (aria) {
54
+ for (const [key, value] of Object.entries(aria)) {
55
+ btn.setAttribute(key, value);
56
+ }
57
+ }
58
+
59
+ return btn;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // createLabelButton
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /** Options for {@link createLabelButton}. */
67
+ export interface CreateLabelButtonOptions {
68
+ /** Optional Lucide icon name shown before the label. */
69
+ icon?: string;
70
+ /** Button text label (also used for aria-label). */
71
+ label: string;
72
+ /** Visual variant. Default: "default". */
73
+ variant?: "default" | "primary" | "destructive" | "ghost";
74
+ /** Size preset. Default: "sm". */
75
+ size?: "sm" | "md";
76
+ /** Icon size in pixels. Default: 14. */
77
+ iconSize?: number;
78
+ /** Extra CSS class(es). */
79
+ className?: string;
80
+ /** Click handler. */
81
+ onClick?: (e: MouseEvent) => void;
82
+ /** Additional ARIA attributes. */
83
+ aria?: Record<string, string>;
84
+ }
85
+
86
+ /**
87
+ * Creates a button with an optional leading icon and a text label.
88
+ *
89
+ * CSS classes follow the BEM-like pattern:
90
+ * `persona-label-btn persona-label-btn--{variant} persona-label-btn--{size}`
91
+ */
92
+ export function createLabelButton(options: CreateLabelButtonOptions): HTMLButtonElement {
93
+ const {
94
+ icon,
95
+ label,
96
+ variant = "default",
97
+ size = "sm",
98
+ iconSize,
99
+ className,
100
+ onClick,
101
+ aria,
102
+ } = options;
103
+
104
+ let classString = "persona-label-btn";
105
+ if (variant !== "default") {
106
+ classString += " persona-label-btn--" + variant;
107
+ }
108
+ classString += " persona-label-btn--" + size;
109
+ if (className) {
110
+ classString += " " + className;
111
+ }
112
+
113
+ const btn = createElement("button", classString);
114
+ btn.type = "button";
115
+ btn.setAttribute("aria-label", label);
116
+
117
+ if (icon) {
118
+ const svg = renderLucideIcon(icon, iconSize ?? 14, "currentColor", 2);
119
+ if (svg) {
120
+ btn.appendChild(svg);
121
+ }
122
+ }
123
+
124
+ const span = createElement("span");
125
+ span.textContent = label;
126
+ btn.appendChild(span);
127
+
128
+ if (onClick) {
129
+ btn.addEventListener("click", onClick);
130
+ }
131
+
132
+ if (aria) {
133
+ for (const [key, value] of Object.entries(aria)) {
134
+ btn.setAttribute(key, value);
135
+ }
136
+ }
137
+
138
+ return btn;
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // createToggleGroup
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /** Describes a single item inside a toggle group. */
146
+ export interface ToggleGroupItem {
147
+ id: string;
148
+ /** Lucide icon name. If omitted, uses label as text. */
149
+ icon?: string;
150
+ /** Accessible label for the button. */
151
+ label: string;
152
+ }
153
+
154
+ /** Options for {@link createToggleGroup}. */
155
+ export interface CreateToggleGroupOptions {
156
+ /** Toggle items. */
157
+ items: ToggleGroupItem[];
158
+ /** Initially selected item id. */
159
+ selectedId: string;
160
+ /** Called when selection changes. */
161
+ onSelect: (id: string) => void;
162
+ /** Extra CSS class(es) on the wrapper. */
163
+ className?: string;
164
+ }
165
+
166
+ /** Handle returned by {@link createToggleGroup}. */
167
+ export interface ToggleGroupHandle {
168
+ /** The wrapper element containing toggle buttons. */
169
+ element: HTMLElement;
170
+ /** Programmatically change the selected item. */
171
+ setSelected: (id: string) => void;
172
+ }
173
+
174
+ /**
175
+ * Creates a group of mutually-exclusive toggle buttons.
176
+ *
177
+ * Each button uses `aria-pressed` to communicate its state. Only one button
178
+ * can be active at a time.
179
+ */
180
+ export function createToggleGroup(options: CreateToggleGroupOptions): ToggleGroupHandle {
181
+ const { items, selectedId, onSelect, className } = options;
182
+
183
+ const wrapper = createElement(
184
+ "div",
185
+ "persona-toggle-group" + (className ? " " + className : ""),
186
+ );
187
+ wrapper.setAttribute("role", "group");
188
+
189
+ let currentId = selectedId;
190
+ const buttons: { id: string; btn: HTMLButtonElement }[] = [];
191
+
192
+ function updatePressed() {
193
+ for (const entry of buttons) {
194
+ entry.btn.setAttribute("aria-pressed", entry.id === currentId ? "true" : "false");
195
+ }
196
+ }
197
+
198
+ for (const item of items) {
199
+ let btn: HTMLButtonElement;
200
+
201
+ if (item.icon) {
202
+ btn = createIconButton({
203
+ icon: item.icon,
204
+ label: item.label,
205
+ onClick: () => {
206
+ currentId = item.id;
207
+ updatePressed();
208
+ onSelect(item.id);
209
+ },
210
+ });
211
+ } else {
212
+ btn = createElement("button", "persona-icon-btn");
213
+ btn.type = "button";
214
+ btn.setAttribute("aria-label", item.label);
215
+ btn.title = item.label;
216
+ btn.textContent = item.label;
217
+ btn.addEventListener("click", () => {
218
+ currentId = item.id;
219
+ updatePressed();
220
+ onSelect(item.id);
221
+ });
222
+ }
223
+
224
+ btn.setAttribute("aria-pressed", item.id === currentId ? "true" : "false");
225
+ buttons.push({ id: item.id, btn });
226
+ wrapper.appendChild(btn);
227
+ }
228
+
229
+ function setSelected(id: string) {
230
+ currentId = id;
231
+ updatePressed();
232
+ }
233
+
234
+ return { element: wrapper, setSelected };
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // createComboButton
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /** Options for {@link createComboButton}. */
242
+ export interface CreateComboButtonOptions {
243
+ /** Button text label. */
244
+ label: string;
245
+ /** Lucide icon name for the dropdown indicator (default: "chevron-down"). */
246
+ icon?: string;
247
+ /** Dropdown menu items. */
248
+ menuItems: DropdownMenuItem[];
249
+ /** Called when a menu item is selected. */
250
+ onSelect: (id: string) => void;
251
+ /** Where to align the dropdown. Default: "bottom-left". */
252
+ position?: "bottom-left" | "bottom-right";
253
+ /**
254
+ * Portal target for the dropdown menu. When set, the menu escapes
255
+ * overflow containers by rendering inside this element with fixed positioning.
256
+ */
257
+ portal?: HTMLElement;
258
+ /** Extra CSS class(es) on the wrapper element. */
259
+ className?: string;
260
+ /** Hover style for the pill effect. */
261
+ hover?: {
262
+ background?: string;
263
+ border?: string;
264
+ borderRadius?: string;
265
+ padding?: string;
266
+ };
267
+ }
268
+
269
+ /** Handle returned by {@link createComboButton}. */
270
+ export interface ComboButtonHandle {
271
+ /** The wrapper element (label + chevron + dropdown). */
272
+ element: HTMLElement;
273
+ /** Update the displayed label text. */
274
+ setLabel: (text: string) => void;
275
+ /** Open the dropdown. */
276
+ open: () => void;
277
+ /** Close the dropdown. */
278
+ close: () => void;
279
+ /** Toggle the dropdown. */
280
+ toggle: () => void;
281
+ /** Remove from DOM and clean up listeners. */
282
+ destroy: () => void;
283
+ }
284
+
285
+ /**
286
+ * Creates a combo button — a clickable label with a chevron that opens a dropdown menu.
287
+ *
288
+ * The entire label + chevron area acts as a single interactive unit with an optional
289
+ * hover pill effect. Clicking anywhere on it toggles the dropdown.
290
+ *
291
+ * ```ts
292
+ * import { createComboButton } from "@runtypelabs/persona";
293
+ *
294
+ * const combo = createComboButton({
295
+ * label: "Chat Assistant",
296
+ * menuItems: [
297
+ * { id: "star", label: "Star", icon: "star" },
298
+ * { id: "rename", label: "Rename", icon: "pencil" },
299
+ * { id: "delete", label: "Delete", icon: "trash-2", destructive: true, dividerBefore: true },
300
+ * ],
301
+ * onSelect: (id) => console.log("Selected:", id),
302
+ * });
303
+ * header.appendChild(combo.element);
304
+ * ```
305
+ */
306
+ export function createComboButton(options: CreateComboButtonOptions): ComboButtonHandle {
307
+ const {
308
+ label,
309
+ icon = "chevron-down",
310
+ menuItems,
311
+ onSelect,
312
+ position = "bottom-left",
313
+ portal,
314
+ className,
315
+ hover,
316
+ } = options;
317
+
318
+ const wrapper = createElement(
319
+ "div",
320
+ "persona-combo-btn" + (className ? " " + className : ""),
321
+ );
322
+ wrapper.style.position = "relative";
323
+ wrapper.style.display = "inline-flex";
324
+ wrapper.style.alignItems = "center";
325
+ wrapper.style.cursor = "pointer";
326
+ wrapper.setAttribute("role", "button");
327
+ wrapper.setAttribute("tabindex", "0");
328
+ wrapper.setAttribute("aria-haspopup", "true");
329
+ wrapper.setAttribute("aria-expanded", "false");
330
+ wrapper.setAttribute("aria-label", label);
331
+
332
+ // Label text
333
+ const labelEl = createElement("span", "persona-combo-btn-label");
334
+ labelEl.textContent = label;
335
+ wrapper.appendChild(labelEl);
336
+
337
+ // Chevron icon
338
+ const chevron = renderLucideIcon(icon, 14, "currentColor", 2);
339
+ if (chevron) {
340
+ chevron.style.marginLeft = "4px";
341
+ chevron.style.opacity = "0.6";
342
+ wrapper.appendChild(chevron);
343
+ }
344
+
345
+ // Hover pill effect
346
+ if (hover) {
347
+ wrapper.style.borderRadius = hover.borderRadius ?? "10px";
348
+ wrapper.style.padding = hover.padding ?? "6px 4px 6px 12px";
349
+ wrapper.style.border = "1px solid transparent";
350
+ wrapper.style.transition = "background-color 0.15s ease, border-color 0.15s ease";
351
+ wrapper.addEventListener("mouseenter", () => {
352
+ wrapper.style.backgroundColor = hover.background ?? "";
353
+ wrapper.style.borderColor = hover.border ?? "";
354
+ });
355
+ wrapper.addEventListener("mouseleave", () => {
356
+ wrapper.style.backgroundColor = "";
357
+ wrapper.style.borderColor = "transparent";
358
+ });
359
+ }
360
+
361
+ // Dropdown
362
+ const dropdown = createDropdownMenu({
363
+ items: menuItems,
364
+ onSelect: (id) => {
365
+ wrapper.setAttribute("aria-expanded", "false");
366
+ onSelect(id);
367
+ },
368
+ anchor: wrapper,
369
+ position,
370
+ portal,
371
+ });
372
+
373
+ if (!portal) {
374
+ wrapper.appendChild(dropdown.element);
375
+ }
376
+
377
+ // Click toggles dropdown
378
+ wrapper.addEventListener("click", (e) => {
379
+ e.stopPropagation();
380
+ const isOpen = !dropdown.element.classList.contains("persona-hidden");
381
+ wrapper.setAttribute("aria-expanded", isOpen ? "false" : "true");
382
+ dropdown.toggle();
383
+ });
384
+
385
+ // Keyboard support
386
+ wrapper.addEventListener("keydown", (e) => {
387
+ if (e.key === "Enter" || e.key === " ") {
388
+ e.preventDefault();
389
+ wrapper.click();
390
+ }
391
+ });
392
+
393
+ return {
394
+ element: wrapper,
395
+ setLabel: (text: string) => {
396
+ labelEl.textContent = text;
397
+ wrapper.setAttribute("aria-label", text);
398
+ },
399
+ open: () => {
400
+ wrapper.setAttribute("aria-expanded", "true");
401
+ dropdown.show();
402
+ },
403
+ close: () => {
404
+ wrapper.setAttribute("aria-expanded", "false");
405
+ dropdown.hide();
406
+ },
407
+ toggle: () => {
408
+ const isOpen = !dropdown.element.classList.contains("persona-hidden");
409
+ wrapper.setAttribute("aria-expanded", isOpen ? "false" : "true");
410
+ dropdown.toggle();
411
+ },
412
+ destroy: () => {
413
+ dropdown.destroy();
414
+ wrapper.remove();
415
+ },
416
+ };
417
+ }
@@ -14,8 +14,18 @@ const fullConfig = {
14
14
  apiUrl: "https://api.example.com/chat",
15
15
  flowId: "test-flow-123",
16
16
  theme: {
17
- primaryColor: "#007bff",
18
- fontFamily: "Inter, sans-serif",
17
+ palette: {
18
+ colors: {
19
+ primary: {
20
+ 500: "#007bff",
21
+ },
22
+ },
23
+ typography: {
24
+ fontFamily: {
25
+ sans: "Inter, sans-serif",
26
+ },
27
+ },
28
+ },
19
29
  },
20
30
  messageActions: {
21
31
  enableCopy: true,
@@ -31,7 +41,6 @@ const dockedConfig = {
31
41
  dock: {
32
42
  side: "left",
33
43
  width: "480px",
34
- collapsedWidth: "84px",
35
44
  },
36
45
  },
37
46
  };
@@ -121,6 +130,33 @@ describe("Hook Serialization", () => {
121
130
  // =============================================================================
122
131
 
123
132
  describe("ESM Format Hooks", () => {
133
+ it("serializes nested PersonaTheme (semantic + components)", () => {
134
+ const code = generateCodeSnippet(
135
+ {
136
+ apiUrl: "https://api.example.com/chat",
137
+ theme: {
138
+ semantic: {
139
+ colors: {
140
+ primary: "palette.colors.primary.500",
141
+ },
142
+ },
143
+ components: {
144
+ panel: {
145
+ shadow: "none",
146
+ borderRadius: "0",
147
+ },
148
+ },
149
+ },
150
+ },
151
+ "esm"
152
+ );
153
+ expect(code).toContain("semantic:");
154
+ expect(code).toContain("palette.colors.primary.500");
155
+ expect(code).toContain("components:");
156
+ expect(code).toContain("panel:");
157
+ expect(code).toContain('"none"');
158
+ });
159
+
124
160
  it("should serialize docked launcher config", () => {
125
161
  const code = generateCodeSnippet(dockedConfig, "esm");
126
162
 
@@ -128,7 +164,7 @@ describe("ESM Format Hooks", () => {
128
164
  expect(code).toContain('dock: {');
129
165
  expect(code).toContain('side: "left"');
130
166
  expect(code).toContain('width: "480px"');
131
- expect(code).toContain('collapsedWidth: "84px"');
167
+ expect(code).not.toContain("collapsedWidth");
132
168
  });
133
169
 
134
170
  it("should inject getHeaders hook", () => {
@@ -194,7 +230,7 @@ describe("React Component Format Hooks", () => {
194
230
  expect(code).toContain('dock: {');
195
231
  expect(code).toContain('side: "left"');
196
232
  expect(code).toContain('width: "480px"');
197
- expect(code).toContain('collapsedWidth: "84px"');
233
+ expect(code).not.toContain("collapsedWidth");
198
234
  });
199
235
 
200
236
  it("should inject hooks in React component format", () => {
@@ -230,7 +266,7 @@ describe("React Advanced Format Hooks", () => {
230
266
  expect(code).toContain('dock: {');
231
267
  expect(code).toContain('side: "left"');
232
268
  expect(code).toContain('width: "480px"');
233
- expect(code).toContain('collapsedWidth: "84px"');
269
+ expect(code).not.toContain("collapsedWidth");
234
270
  });
235
271
 
236
272
  it("should inject custom action handlers alongside defaults", () => {
@@ -291,7 +327,7 @@ describe("Script Manual Format Hooks", () => {
291
327
  expect(code).toContain('dock: {');
292
328
  expect(code).toContain('side: "left"');
293
329
  expect(code).toContain('width: "480px"');
294
- expect(code).toContain('collapsedWidth: "84px"');
330
+ expect(code).not.toContain("collapsedWidth");
295
331
  });
296
332
 
297
333
  it("should inject hooks in script-manual format", () => {
@@ -582,12 +582,8 @@ function generateESMCode(config: any, options?: CodeGeneratorOptions): string {
582
582
  if (config.flowId) lines.push(` flowId: "${config.flowId}",`);
583
583
  if (shouldEmitParserType) lines.push(` parserType: "${parserType}",`);
584
584
 
585
- if (config.theme) {
586
- lines.push(" theme: {");
587
- Object.entries(config.theme).forEach(([key, value]) => {
588
- lines.push(` ${key}: "${value}",`);
589
- });
590
- lines.push(" },");
585
+ if (config.theme && typeof config.theme === "object" && Object.keys(config.theme).length > 0) {
586
+ appendSerializableObjectBlock(lines, "theme", config.theme as Record<string, unknown>, " ");
591
587
  }
592
588
 
593
589
  if (config.launcher) {
@@ -732,12 +728,8 @@ function generateReactComponentCode(config: any, options?: CodeGeneratorOptions)
732
728
  if (config.flowId) lines.push(` flowId: "${config.flowId}",`);
733
729
  if (shouldEmitParserType) lines.push(` parserType: "${parserType}",`);
734
730
 
735
- if (config.theme) {
736
- lines.push(" theme: {");
737
- Object.entries(config.theme).forEach(([key, value]) => {
738
- lines.push(` ${key}: "${value}",`);
739
- });
740
- lines.push(" },");
731
+ if (config.theme && typeof config.theme === "object" && Object.keys(config.theme).length > 0) {
732
+ appendSerializableObjectBlock(lines, "theme", config.theme as Record<string, unknown>, " ");
741
733
  }
742
734
 
743
735
  if (config.launcher) {
@@ -998,13 +990,9 @@ function generateReactAdvancedCode(config: any, options?: CodeGeneratorOptions):
998
990
  if (config.apiUrl) lines.push(` apiUrl: "${config.apiUrl}",`);
999
991
  if (config.clientToken) lines.push(` clientToken: "${config.clientToken}",`);
1000
992
  if (config.flowId) lines.push(` flowId: "${config.flowId}",`);
1001
-
1002
- if (config.theme) {
1003
- lines.push(" theme: {");
1004
- Object.entries(config.theme).forEach(([key, value]) => {
1005
- lines.push(` ${key}: "${value}",`);
1006
- });
1007
- lines.push(" },");
993
+
994
+ if (config.theme && typeof config.theme === "object" && Object.keys(config.theme).length > 0) {
995
+ appendSerializableObjectBlock(lines, "theme", config.theme as Record<string, unknown>, " ");
1008
996
  }
1009
997
 
1010
998
  if (config.launcher) {
@@ -1391,12 +1379,8 @@ function generateScriptManualCode(config: any, options?: CodeGeneratorOptions):
1391
1379
  if (config.flowId) lines.push(` flowId: "${config.flowId}",`);
1392
1380
  if (shouldEmitParserType) lines.push(` parserType: "${parserType}",`);
1393
1381
 
1394
- if (config.theme) {
1395
- lines.push(" theme: {");
1396
- Object.entries(config.theme).forEach(([key, value]) => {
1397
- lines.push(` ${key}: "${value}",`);
1398
- });
1399
- lines.push(" },");
1382
+ if (config.theme && typeof config.theme === "object" && Object.keys(config.theme).length > 0) {
1383
+ appendSerializableObjectBlock(lines, "theme", config.theme as Record<string, unknown>, " ");
1400
1384
  }
1401
1385
 
1402
1386
  if (config.launcher) {
@@ -0,0 +1,26 @@
1
+ const isObject = (value: unknown): value is Record<string, unknown> =>
2
+ typeof value === "object" && value !== null && !Array.isArray(value);
3
+
4
+ /**
5
+ * Deep-merge plain objects. Arrays and non-objects are replaced by override.
6
+ */
7
+ export function deepMerge<T extends Record<string, unknown>>(
8
+ base: T | undefined,
9
+ override: Record<string, unknown> | undefined
10
+ ): T | Record<string, unknown> | undefined {
11
+ if (!base) return override;
12
+ if (!override) return base;
13
+
14
+ const merged: Record<string, unknown> = { ...base };
15
+
16
+ for (const [key, value] of Object.entries(override)) {
17
+ const existing = merged[key];
18
+ if (isObject(existing) && isObject(value)) {
19
+ merged[key] = deepMerge(existing, value);
20
+ } else {
21
+ merged[key] = value;
22
+ }
23
+ }
24
+
25
+ return merged;
26
+ }
package/src/utils/dock.ts CHANGED
@@ -3,15 +3,28 @@ import type { AgentWidgetConfig, AgentWidgetDockConfig } from "../types";
3
3
  const DEFAULT_DOCK_CONFIG: Required<AgentWidgetDockConfig> = {
4
4
  side: "right",
5
5
  width: "420px",
6
- collapsedWidth: "72px",
6
+ animate: true,
7
+ reveal: "resize",
7
8
  };
8
9
 
9
10
  export const isDockedMountMode = (config?: AgentWidgetConfig): boolean =>
10
11
  (config?.launcher?.mountMode ?? "floating") === "docked";
11
12
 
13
+ /**
14
+ * Resolved dock layout. For `reveal: "resize"`, when the panel is closed the dock column is `0px`.
15
+ * For `reveal: "overlay"`, the panel overlays with `transform`. For `reveal: "push"`, a sliding track
16
+ * moves content and panel together without width animation on the main column. For `emerge`,
17
+ * the dock column still animates like `resize` but the widget stays `dock.width` wide inside the slot.
18
+ * Unknown keys on `launcher.dock` (e.g. legacy `collapsedWidth`) are ignored.
19
+ */
12
20
  export const resolveDockConfig = (
13
21
  config?: AgentWidgetConfig
14
- ): Required<AgentWidgetDockConfig> => ({
15
- ...DEFAULT_DOCK_CONFIG,
16
- ...(config?.launcher?.dock ?? {}),
17
- });
22
+ ): Required<AgentWidgetDockConfig> => {
23
+ const dock = config?.launcher?.dock;
24
+ return {
25
+ side: dock?.side ?? DEFAULT_DOCK_CONFIG.side,
26
+ width: dock?.width ?? DEFAULT_DOCK_CONFIG.width,
27
+ animate: dock?.animate ?? DEFAULT_DOCK_CONFIG.animate,
28
+ reveal: dock?.reveal ?? DEFAULT_DOCK_CONFIG.reveal,
29
+ };
30
+ };