@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.
- package/README.md +221 -4
- package/dist/index.cjs +42 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +832 -571
- package/dist/index.d.ts +832 -571
- package/dist/index.global.js +87 -87
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +42 -42
- package/dist/index.js.map +1 -1
- package/dist/widget.css +205 -15
- package/package.json +2 -2
- package/src/components/artifact-card.ts +39 -5
- package/src/components/artifact-pane.ts +67 -126
- package/src/components/composer-builder.ts +3 -23
- package/src/components/header-builder.ts +29 -34
- package/src/components/header-layouts.ts +109 -41
- package/src/components/launcher.ts +10 -7
- package/src/components/message-bubble.ts +7 -11
- package/src/components/panel.ts +4 -4
- package/src/defaults.ts +22 -93
- package/src/index.ts +20 -7
- package/src/presets.ts +66 -51
- package/src/runtime/host-layout.test.ts +196 -0
- package/src/runtime/host-layout.ts +265 -27
- package/src/runtime/init.test.ts +77 -7
- package/src/styles/widget.css +205 -15
- package/src/types/theme.ts +76 -0
- package/src/types.ts +86 -97
- package/src/ui.docked.test.ts +203 -7
- package/src/ui.ts +129 -88
- package/src/utils/buttons.ts +417 -0
- package/src/utils/code-generators.test.ts +43 -7
- package/src/utils/code-generators.ts +9 -25
- package/src/utils/deep-merge.ts +26 -0
- package/src/utils/dock.ts +18 -5
- package/src/utils/dropdown.ts +178 -0
- package/src/utils/sanitize.ts +1 -1
- package/src/utils/theme.test.ts +90 -15
- package/src/utils/theme.ts +20 -46
- package/src/utils/tokens.ts +108 -11
- 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
|
-
|
|
18
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
+
};
|