@runtypelabs/persona 4.14.0 → 4.16.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 +4 -4
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
- package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-QJ4LV2KW.js +1 -0
- package/dist/codegen.cjs +12 -11
- package/dist/codegen.js +14 -13
- package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
- package/dist/context-mentions.cjs +11 -8
- package/dist/context-mentions.d.cts +520 -21
- package/dist/context-mentions.d.ts +520 -21
- package/dist/context-mentions.js +11 -8
- package/dist/index.cjs +69 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +595 -46
- package/dist/index.d.ts +595 -46
- package/dist/index.global.js +60 -57
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +69 -68
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +5 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +520 -21
- package/dist/smart-dom-reader.d.ts +520 -21
- package/dist/testing.cjs +3 -3
- package/dist/testing.d.cts +7 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +67 -67
- package/dist/theme-editor-preview.d.cts +534 -22
- package/dist/theme-editor-preview.d.ts +534 -22
- package/dist/theme-editor-preview.js +57 -57
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +521 -23
- package/dist/theme-editor.d.ts +521 -23
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +104 -3
- package/dist/theme-reference.d.ts +104 -3
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/ask-user-question-tool.ts +10 -7
- package/src/components/artifact-pane.test.ts +27 -0
- package/src/components/artifact-pane.ts +7 -3
- package/src/components/artifact-preview.ts +1 -1
- package/src/components/composer-parts.test.ts +103 -4
- package/src/components/composer-parts.ts +53 -15
- package/src/components/header-builder.ts +27 -2
- package/src/components/header-layouts.test.ts +56 -0
- package/src/components/header-layouts.ts +29 -9
- package/src/components/launcher-surface.test.ts +403 -0
- package/src/components/launcher.ts +297 -2
- package/src/components/messages.ts +5 -2
- package/src/components/panel.ts +92 -71
- package/src/components/pill-composer-builder.test.ts +18 -0
- package/src/components/suggestions.test.ts +273 -0
- package/src/components/suggestions.ts +528 -86
- package/src/components/welcome.test.ts +121 -0
- package/src/components/welcome.ts +305 -0
- package/src/components/widget-view.test.ts +5 -3
- package/src/components/widget-view.ts +18 -7
- package/src/defaults.ts +7 -5
- package/src/generated/runtype-openapi-contract.ts +17 -0
- package/src/index-core.ts +29 -2
- package/src/launcher-global.ts +11 -8
- package/src/plugins/types.ts +136 -3
- package/src/presets.ts +3 -3
- package/src/session.ts +10 -8
- package/src/styles/widget-styles.test.ts +382 -0
- package/src/styles/widget.css +778 -4
- package/src/suggest-replies-tool.test.ts +233 -2
- package/src/suggest-replies-tool.ts +115 -39
- package/src/testing/mock-stream.test.ts +19 -0
- package/src/testing/mock-stream.ts +20 -0
- package/src/theme-editor/preview-utils.test.ts +34 -0
- package/src/theme-editor/preview-utils.ts +77 -2
- package/src/theme-editor/sections.test.ts +33 -0
- package/src/theme-editor/sections.ts +83 -6
- package/src/theme-reference.ts +34 -5
- package/src/types/theme.ts +72 -3
- package/src/types.ts +312 -18
- package/src/ui.body-top-slot.test.ts +95 -0
- package/src/ui.content-width.test.ts +91 -0
- package/src/ui.detached-panel.test.ts +1 -1
- package/src/ui.docked.test.ts +7 -4
- package/src/ui.launcher-teaser.test.ts +162 -0
- package/src/ui.rebuild-composer.test.ts +491 -0
- package/src/ui.render-welcome.test.ts +505 -0
- package/src/ui.scroll-additive.test.ts +91 -21
- package/src/ui.scroll.test.ts +302 -14
- package/src/ui.suggest-replies.test.ts +1112 -1
- package/src/ui.ts +1008 -149
- package/src/ui.welcome.test.ts +504 -0
- package/src/utils/artifact-file.test.ts +1 -1
- package/src/utils/artifact-gate.test.ts +27 -0
- package/src/utils/artifact-gate.ts +9 -0
- package/src/utils/attachment-manager.test.ts +37 -0
- package/src/utils/attachment-manager.ts +15 -0
- package/src/utils/auto-follow.test.ts +23 -0
- package/src/utils/auto-follow.ts +11 -1
- package/src/utils/code-generators.test.ts +40 -0
- package/src/utils/code-generators.ts +22 -0
- package/src/utils/config-merge.test.ts +24 -0
- package/src/utils/config-merge.ts +2 -0
- package/src/utils/content-width.test.ts +37 -0
- package/src/utils/content-width.ts +17 -0
- package/src/utils/icons.test.ts +26 -0
- package/src/utils/icons.ts +8 -0
- package/src/utils/plugin-storage.test.ts +94 -0
- package/src/utils/plugin-storage.ts +98 -0
- package/src/utils/theme.test.ts +216 -0
- package/src/utils/tokens.ts +113 -3
- package/src/welcome.test.ts +213 -0
- package/src/welcome.ts +130 -0
- package/dist/chunk-MMUPR2JW.js +0 -1
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createLauncherSurface } from "./launcher";
|
|
5
|
+
import type {
|
|
6
|
+
AgentWidgetConfig,
|
|
7
|
+
AgentWidgetLauncherTeaserConfig,
|
|
8
|
+
} from "../types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every case uses its own `keyPrefix`: the consumed-teaser set is module-level
|
|
12
|
+
* (it is the page-load memory), so a shared key would leak between tests.
|
|
13
|
+
*/
|
|
14
|
+
let keyCounter = 0;
|
|
15
|
+
const nextPrefix = () => `teaser-test-${++keyCounter}-`;
|
|
16
|
+
|
|
17
|
+
const configWith = (
|
|
18
|
+
prefix: string,
|
|
19
|
+
teaser: AgentWidgetLauncherTeaserConfig
|
|
20
|
+
): AgentWidgetConfig => ({
|
|
21
|
+
persistState: { keyPrefix: prefix },
|
|
22
|
+
launcher: { teaser },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("createLauncherSurface", () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.useFakeTimers();
|
|
28
|
+
window.localStorage.clear();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.useRealTimers();
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
document.body.replaceChildren();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("stays a display:contents passthrough when no teaser is configured", () => {
|
|
38
|
+
const surface = createLauncherSurface({ launcher: {} }, () => {});
|
|
39
|
+
document.body.appendChild(surface.element);
|
|
40
|
+
|
|
41
|
+
expect(surface.teaser).toBeNull();
|
|
42
|
+
expect(surface.element.getAttribute("data-teaser")).toBeNull();
|
|
43
|
+
expect(surface.element.className).toBe("persona-launcher-surface");
|
|
44
|
+
expect(surface.element.firstElementChild).toBe(surface.launcher.element);
|
|
45
|
+
expect(surface.launcher.element.style.position).toBe("");
|
|
46
|
+
surface.destroy();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("renders the teaser as a sibling of the launcher with its own dismiss button", () => {
|
|
50
|
+
const prefix = nextPrefix();
|
|
51
|
+
const surface = createLauncherSurface(
|
|
52
|
+
configWith(prefix, { text: "Need a hand?" }),
|
|
53
|
+
() => {}
|
|
54
|
+
);
|
|
55
|
+
document.body.appendChild(surface.element);
|
|
56
|
+
vi.advanceTimersByTime(0);
|
|
57
|
+
|
|
58
|
+
const teaser = surface.teaser;
|
|
59
|
+
expect(teaser).not.toBeNull();
|
|
60
|
+
expect(teaser?.element.parentElement).toBe(surface.element);
|
|
61
|
+
expect(teaser?.element.getAttribute("aria-live")).toBe("polite");
|
|
62
|
+
expect(teaser?.element.hidden).toBe(false);
|
|
63
|
+
|
|
64
|
+
const buttons = teaser?.element.querySelectorAll("button") ?? [];
|
|
65
|
+
expect(buttons.length).toBe(2);
|
|
66
|
+
// Nested interactive elements are invalid HTML: both controls are siblings.
|
|
67
|
+
expect(buttons[1].closest("button")).toBe(buttons[1]);
|
|
68
|
+
expect(buttons[0].textContent).toBe("Need a hand?");
|
|
69
|
+
|
|
70
|
+
expect(surface.element.getAttribute("data-teaser")).toBe("bottom-right");
|
|
71
|
+
expect(surface.launcher.element.style.position).toBe("static");
|
|
72
|
+
surface.destroy();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("honors delayMs before showing", () => {
|
|
76
|
+
const prefix = nextPrefix();
|
|
77
|
+
const surface = createLauncherSurface(
|
|
78
|
+
configWith(prefix, { text: "Hi there", delayMs: 3000 }),
|
|
79
|
+
() => {}
|
|
80
|
+
);
|
|
81
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
82
|
+
vi.advanceTimersByTime(2999);
|
|
83
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
84
|
+
vi.advanceTimersByTime(1);
|
|
85
|
+
expect(surface.teaser?.element.hidden).toBe(false);
|
|
86
|
+
surface.destroy();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("hides the dismiss affordance when dismissible is false", () => {
|
|
90
|
+
const prefix = nextPrefix();
|
|
91
|
+
const surface = createLauncherSurface(
|
|
92
|
+
configWith(prefix, { text: "Hi there", dismissible: false }),
|
|
93
|
+
() => {}
|
|
94
|
+
);
|
|
95
|
+
const dismiss = surface.teaser?.element.querySelectorAll("button")[1];
|
|
96
|
+
expect(dismiss?.hidden).toBe(true);
|
|
97
|
+
surface.destroy();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("consumption rules", () => {
|
|
101
|
+
it("click-through opens the panel and persists under the default frequency", () => {
|
|
102
|
+
const prefix = nextPrefix();
|
|
103
|
+
const onToggle = vi.fn();
|
|
104
|
+
const surface = createLauncherSurface(
|
|
105
|
+
configWith(prefix, { text: "Hi there" }),
|
|
106
|
+
onToggle
|
|
107
|
+
);
|
|
108
|
+
vi.advanceTimersByTime(0);
|
|
109
|
+
|
|
110
|
+
surface.teaser?.element.querySelector("button")?.click();
|
|
111
|
+
|
|
112
|
+
expect(onToggle).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
114
|
+
expect(window.localStorage.getItem(`${prefix}teaser-dismissed`)).toBe("1");
|
|
115
|
+
surface.destroy();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("dismissal persists without opening the panel", () => {
|
|
119
|
+
const prefix = nextPrefix();
|
|
120
|
+
const onToggle = vi.fn();
|
|
121
|
+
const surface = createLauncherSurface(
|
|
122
|
+
configWith(prefix, { text: "Hi there" }),
|
|
123
|
+
onToggle
|
|
124
|
+
);
|
|
125
|
+
vi.advanceTimersByTime(0);
|
|
126
|
+
|
|
127
|
+
const buttons = surface.teaser?.element.querySelectorAll("button");
|
|
128
|
+
buttons?.[1].click();
|
|
129
|
+
|
|
130
|
+
expect(onToggle).not.toHaveBeenCalled();
|
|
131
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
132
|
+
expect(window.localStorage.getItem(`${prefix}teaser-dismissed`)).toBe("1");
|
|
133
|
+
surface.destroy();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("a launcher click consumes the visible teaser too", () => {
|
|
137
|
+
const prefix = nextPrefix();
|
|
138
|
+
const onToggle = vi.fn();
|
|
139
|
+
const surface = createLauncherSurface(
|
|
140
|
+
configWith(prefix, { text: "Hi there" }),
|
|
141
|
+
onToggle
|
|
142
|
+
);
|
|
143
|
+
vi.advanceTimersByTime(0);
|
|
144
|
+
|
|
145
|
+
surface.launcher.element.click();
|
|
146
|
+
|
|
147
|
+
expect(onToggle).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
149
|
+
expect(window.localStorage.getItem(`${prefix}teaser-dismissed`)).toBe("1");
|
|
150
|
+
surface.destroy();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("does not persist a teaser the user never saw", () => {
|
|
154
|
+
const prefix = nextPrefix();
|
|
155
|
+
const surface = createLauncherSurface(
|
|
156
|
+
configWith(prefix, { text: "Hi there", delayMs: 5000 }),
|
|
157
|
+
() => {}
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
surface.launcher.element.click();
|
|
161
|
+
vi.advanceTimersByTime(10000);
|
|
162
|
+
|
|
163
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
164
|
+
expect(window.localStorage.getItem(`${prefix}teaser-dismissed`)).toBeNull();
|
|
165
|
+
surface.destroy();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("suppresses the teaser while the panel is open and never resurrects it", () => {
|
|
169
|
+
const prefix = nextPrefix();
|
|
170
|
+
const surface = createLauncherSurface(
|
|
171
|
+
configWith(prefix, { text: "Hi there", delayMs: 100 }),
|
|
172
|
+
() => {}
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
surface.setPanelOpen(true);
|
|
176
|
+
vi.advanceTimersByTime(1000);
|
|
177
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
178
|
+
|
|
179
|
+
surface.setPanelOpen(false);
|
|
180
|
+
surface.update(configWith(prefix, { text: "Hi there", delayMs: 100 }));
|
|
181
|
+
vi.advanceTimersByTime(1000);
|
|
182
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
183
|
+
surface.destroy();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("does not persist the flag when an automatic open consumes the teaser", () => {
|
|
187
|
+
const prefix = nextPrefix();
|
|
188
|
+
const surface = createLauncherSurface(
|
|
189
|
+
configWith(prefix, { text: "Hi there" }),
|
|
190
|
+
() => {}
|
|
191
|
+
);
|
|
192
|
+
vi.advanceTimersByTime(0);
|
|
193
|
+
expect(surface.teaser?.element.hidden).toBe(false);
|
|
194
|
+
|
|
195
|
+
surface.setPanelOpen(true, "auto");
|
|
196
|
+
|
|
197
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
198
|
+
expect(window.localStorage.getItem(`${prefix}teaser-dismissed`)).toBeNull();
|
|
199
|
+
surface.destroy();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("appears at most once per page load under frequency always", () => {
|
|
203
|
+
const prefix = nextPrefix();
|
|
204
|
+
const config = configWith(prefix, { text: "Hi there", frequency: "always" });
|
|
205
|
+
|
|
206
|
+
const first = createLauncherSurface(config, () => {});
|
|
207
|
+
vi.advanceTimersByTime(0);
|
|
208
|
+
first.teaser?.dismiss(false);
|
|
209
|
+
expect(window.localStorage.getItem(`${prefix}teaser-dismissed`)).toBeNull();
|
|
210
|
+
first.destroy();
|
|
211
|
+
|
|
212
|
+
// Same page load, same key: the in-memory consumption still applies.
|
|
213
|
+
const second = createLauncherSurface(config, () => {});
|
|
214
|
+
vi.advanceTimersByTime(0);
|
|
215
|
+
expect(second.teaser?.element.hidden).toBe(true);
|
|
216
|
+
second.destroy();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("skips the teaser when the persisted flag is already set", () => {
|
|
220
|
+
const prefix = nextPrefix();
|
|
221
|
+
window.localStorage.setItem(`${prefix}teaser-dismissed`, "1");
|
|
222
|
+
|
|
223
|
+
const surface = createLauncherSurface(
|
|
224
|
+
configWith(prefix, { text: "Hi there" }),
|
|
225
|
+
() => {}
|
|
226
|
+
);
|
|
227
|
+
vi.advanceTimersByTime(0);
|
|
228
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
229
|
+
surface.destroy();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("ignores a stale persisted flag under frequency always", () => {
|
|
233
|
+
const prefix = nextPrefix();
|
|
234
|
+
window.localStorage.setItem(`${prefix}teaser-dismissed`, "1");
|
|
235
|
+
|
|
236
|
+
const surface = createLauncherSurface(
|
|
237
|
+
configWith(prefix, { text: "Hi there", frequency: "always" }),
|
|
238
|
+
() => {}
|
|
239
|
+
);
|
|
240
|
+
vi.advanceTimersByTime(0);
|
|
241
|
+
expect(surface.teaser?.element.hidden).toBe(false);
|
|
242
|
+
surface.destroy();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("keeps dismissal in memory when persistState is false", () => {
|
|
246
|
+
const surface = createLauncherSurface(
|
|
247
|
+
{ persistState: false, launcher: { teaser: { text: "Hi there" } } },
|
|
248
|
+
() => {}
|
|
249
|
+
);
|
|
250
|
+
vi.advanceTimersByTime(0);
|
|
251
|
+
surface.teaser?.dismiss(true);
|
|
252
|
+
|
|
253
|
+
expect(window.localStorage.getItem("persona-teaser-dismissed")).toBeNull();
|
|
254
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
255
|
+
surface.destroy();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("falls back to memory when localStorage throws", () => {
|
|
260
|
+
const prefix = nextPrefix();
|
|
261
|
+
const getItem = vi
|
|
262
|
+
.spyOn(Storage.prototype, "getItem")
|
|
263
|
+
.mockImplementation(() => {
|
|
264
|
+
throw new Error("blocked");
|
|
265
|
+
});
|
|
266
|
+
const setItem = vi
|
|
267
|
+
.spyOn(Storage.prototype, "setItem")
|
|
268
|
+
.mockImplementation(() => {
|
|
269
|
+
throw new Error("blocked");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const config = configWith(prefix, { text: "Hi there" });
|
|
273
|
+
const first = createLauncherSurface(config, () => {});
|
|
274
|
+
vi.advanceTimersByTime(0);
|
|
275
|
+
expect(first.teaser?.element.hidden).toBe(false);
|
|
276
|
+
|
|
277
|
+
first.teaser?.dismiss(true);
|
|
278
|
+
expect(first.teaser?.element.hidden).toBe(true);
|
|
279
|
+
first.destroy();
|
|
280
|
+
|
|
281
|
+
const second = createLauncherSurface(config, () => {});
|
|
282
|
+
vi.advanceTimersByTime(0);
|
|
283
|
+
expect(second.teaser?.element.hidden).toBe(true);
|
|
284
|
+
second.destroy();
|
|
285
|
+
|
|
286
|
+
expect(getItem).toHaveBeenCalled();
|
|
287
|
+
expect(setItem).toHaveBeenCalled();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("live updates", () => {
|
|
291
|
+
it("adds a teaser configured by a later update()", () => {
|
|
292
|
+
const prefix = nextPrefix();
|
|
293
|
+
const surface = createLauncherSurface({ launcher: {} }, () => {});
|
|
294
|
+
expect(surface.teaser).toBeNull();
|
|
295
|
+
|
|
296
|
+
surface.update(configWith(prefix, { text: "Added later" }));
|
|
297
|
+
vi.advanceTimersByTime(0);
|
|
298
|
+
|
|
299
|
+
expect(surface.teaser?.element.hidden).toBe(false);
|
|
300
|
+
expect(surface.teaser?.element.textContent).toContain("Added later");
|
|
301
|
+
expect(surface.element.getAttribute("data-teaser")).toBe("bottom-right");
|
|
302
|
+
surface.destroy();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("swaps teaser text in place", () => {
|
|
306
|
+
const prefix = nextPrefix();
|
|
307
|
+
const surface = createLauncherSurface(
|
|
308
|
+
configWith(prefix, { text: "First" }),
|
|
309
|
+
() => {}
|
|
310
|
+
);
|
|
311
|
+
vi.advanceTimersByTime(0);
|
|
312
|
+
const element = surface.teaser?.element;
|
|
313
|
+
|
|
314
|
+
surface.update(configWith(prefix, { text: "Second" }));
|
|
315
|
+
|
|
316
|
+
expect(surface.teaser?.element).toBe(element);
|
|
317
|
+
expect(element?.querySelector("button")?.textContent).toBe("Second");
|
|
318
|
+
surface.destroy();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("toggles the dismiss affordance", () => {
|
|
322
|
+
const prefix = nextPrefix();
|
|
323
|
+
const surface = createLauncherSurface(
|
|
324
|
+
configWith(prefix, { text: "Hi there" }),
|
|
325
|
+
() => {}
|
|
326
|
+
);
|
|
327
|
+
const dismiss = () =>
|
|
328
|
+
surface.teaser?.element.querySelectorAll("button")[1];
|
|
329
|
+
expect(dismiss()?.hidden).toBe(false);
|
|
330
|
+
|
|
331
|
+
surface.update(
|
|
332
|
+
configWith(prefix, { text: "Hi there", dismissible: false })
|
|
333
|
+
);
|
|
334
|
+
expect(dismiss()?.hidden).toBe(true);
|
|
335
|
+
|
|
336
|
+
surface.update(configWith(prefix, { text: "Hi there", dismissible: true }));
|
|
337
|
+
expect(dismiss()?.hidden).toBe(false);
|
|
338
|
+
surface.destroy();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("removes the teaser and restores passthrough placement", () => {
|
|
342
|
+
const prefix = nextPrefix();
|
|
343
|
+
const surface = createLauncherSurface(
|
|
344
|
+
configWith(prefix, { text: "Hi there" }),
|
|
345
|
+
() => {}
|
|
346
|
+
);
|
|
347
|
+
vi.advanceTimersByTime(0);
|
|
348
|
+
|
|
349
|
+
surface.update({ persistState: { keyPrefix: prefix }, launcher: {} });
|
|
350
|
+
|
|
351
|
+
expect(surface.teaser).toBeNull();
|
|
352
|
+
expect(surface.element.getAttribute("data-teaser")).toBeNull();
|
|
353
|
+
expect(surface.launcher.element.style.position).toBe("");
|
|
354
|
+
surface.destroy();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("follows launcher.position onto the wrapper", () => {
|
|
358
|
+
const prefix = nextPrefix();
|
|
359
|
+
const surface = createLauncherSurface(
|
|
360
|
+
{
|
|
361
|
+
persistState: { keyPrefix: prefix },
|
|
362
|
+
launcher: { teaser: { text: "Hi there" }, position: "top-left" },
|
|
363
|
+
},
|
|
364
|
+
() => {}
|
|
365
|
+
);
|
|
366
|
+
expect(surface.element.getAttribute("data-teaser")).toBe("top-left");
|
|
367
|
+
expect(surface.element.className).toContain("persona-top-6");
|
|
368
|
+
expect(surface.element.className).toContain("persona-left-6");
|
|
369
|
+
surface.destroy();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("drops the teaser in docked mount mode", () => {
|
|
373
|
+
const prefix = nextPrefix();
|
|
374
|
+
const surface = createLauncherSurface(
|
|
375
|
+
{
|
|
376
|
+
persistState: { keyPrefix: prefix },
|
|
377
|
+
launcher: { teaser: { text: "Hi there" }, mountMode: "docked" },
|
|
378
|
+
},
|
|
379
|
+
() => {}
|
|
380
|
+
);
|
|
381
|
+
expect(surface.teaser).toBeNull();
|
|
382
|
+
surface.destroy();
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("clears the pending timer and removes the wrapper on destroy", () => {
|
|
387
|
+
const prefix = nextPrefix();
|
|
388
|
+
const surface = createLauncherSurface(
|
|
389
|
+
configWith(prefix, { text: "Hi there", delayMs: 5000 }),
|
|
390
|
+
() => {}
|
|
391
|
+
);
|
|
392
|
+
document.body.appendChild(surface.element);
|
|
393
|
+
const teaserElement = surface.teaser?.element;
|
|
394
|
+
|
|
395
|
+
surface.destroy();
|
|
396
|
+
vi.advanceTimersByTime(10000);
|
|
397
|
+
|
|
398
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
399
|
+
expect(teaserElement?.hidden).toBe(true);
|
|
400
|
+
expect(surface.element.parentElement).toBeNull();
|
|
401
|
+
expect(document.body.children.length).toBe(0);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createElement } from "../utils/dom";
|
|
2
|
-
import { AgentWidgetConfig } from "../types";
|
|
1
|
+
import { createElement, createNode, cx } from "../utils/dom";
|
|
2
|
+
import { AgentWidgetConfig, AgentWidgetLauncherTeaserConfig } from "../types";
|
|
3
3
|
import { positionMap } from "../utils/positioning";
|
|
4
4
|
import { isDockedMountMode } from "../utils/dock";
|
|
5
5
|
import { renderLucideIcon } from "../utils/icons";
|
|
@@ -240,3 +240,298 @@ export const createLauncherButton = (
|
|
|
240
240
|
destroy
|
|
241
241
|
};
|
|
242
242
|
};
|
|
243
|
+
|
|
244
|
+
/** Teaser bubble handle. `dismiss(true)` also writes the persisted flag. */
|
|
245
|
+
export interface LauncherTeaser {
|
|
246
|
+
element: HTMLElement;
|
|
247
|
+
show: () => void;
|
|
248
|
+
dismiss: (persist: boolean) => void;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** "auto" covers restored/hook-driven opens: they never persist the teaser flag. */
|
|
252
|
+
export type LauncherOpenOrigin = "user" | "auto";
|
|
253
|
+
|
|
254
|
+
export interface LauncherSurface {
|
|
255
|
+
/** Positioned wrapper. Sibling teaser + launcher live inside it. */
|
|
256
|
+
element: HTMLElement;
|
|
257
|
+
launcher: LauncherButton;
|
|
258
|
+
/** Null when no teaser is configured for the current config. */
|
|
259
|
+
teaser: LauncherTeaser | null;
|
|
260
|
+
/** Opening the panel consumes the teaser; closing never resurrects it. */
|
|
261
|
+
setPanelOpen: (open: boolean, origin?: LauncherOpenOrigin) => void;
|
|
262
|
+
update: (config: AgentWidgetConfig) => void;
|
|
263
|
+
destroy: () => void;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const TEASER_KEY_SUFFIX = "teaser-dismissed";
|
|
267
|
+
|
|
268
|
+
/** Consumed teaser keys for this page load; also the blocked-storage fallback. */
|
|
269
|
+
const teaserMemoryDismissed = new Set<string>();
|
|
270
|
+
|
|
271
|
+
const teaserStorageKey = (config?: AgentWidgetConfig): string => {
|
|
272
|
+
const persist = config?.persistState;
|
|
273
|
+
const prefix =
|
|
274
|
+
(typeof persist === "object" ? persist?.keyPrefix : undefined) ?? "persona-";
|
|
275
|
+
return `${prefix}${TEASER_KEY_SUFFIX}`;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// localStorage access throws (not just fails) in Safari private mode and
|
|
279
|
+
// partitioned iframes, so every access falls back to the in-memory set.
|
|
280
|
+
const readTeaserDismissed = (key: string, useStorage: boolean): boolean => {
|
|
281
|
+
if (teaserMemoryDismissed.has(key)) return true;
|
|
282
|
+
if (!useStorage) return false;
|
|
283
|
+
try {
|
|
284
|
+
return window.localStorage.getItem(key) === "1";
|
|
285
|
+
} catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const writeTeaserDismissed = (key: string, useStorage: boolean): void => {
|
|
291
|
+
teaserMemoryDismissed.add(key);
|
|
292
|
+
if (!useStorage) return;
|
|
293
|
+
try {
|
|
294
|
+
window.localStorage.setItem(key, "1");
|
|
295
|
+
} catch {
|
|
296
|
+
/* blocked storage: the in-memory flag above is the fallback */
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const prefersReducedMotion = (): boolean =>
|
|
301
|
+
typeof window !== "undefined" &&
|
|
302
|
+
typeof window.matchMedia === "function" &&
|
|
303
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
304
|
+
|
|
305
|
+
const resolveTeaserConfig = (
|
|
306
|
+
config: AgentWidgetConfig | undefined
|
|
307
|
+
): AgentWidgetLauncherTeaserConfig | null => {
|
|
308
|
+
const teaser = config?.launcher?.teaser;
|
|
309
|
+
if (!teaser || !teaser.text) return null;
|
|
310
|
+
if (config?.launcher?.enabled === false) return null;
|
|
311
|
+
if (isDockedMountMode(config ?? {})) return null;
|
|
312
|
+
return teaser;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Launcher wrapper owning the collapsed button and its optional teaser bubble.
|
|
317
|
+
* Both the critical `launcher.global.js` bundle and the full widget build the
|
|
318
|
+
* launcher through this, so teaser behavior and cleanup are identical on the
|
|
319
|
+
* eager and deferred paths.
|
|
320
|
+
*/
|
|
321
|
+
export const createLauncherSurface = (
|
|
322
|
+
config: AgentWidgetConfig | undefined,
|
|
323
|
+
onToggle: () => void
|
|
324
|
+
): LauncherSurface => {
|
|
325
|
+
const wrapper = createElement("div", "persona-launcher-surface");
|
|
326
|
+
wrapper.setAttribute("data-persona-launcher-surface", "true");
|
|
327
|
+
|
|
328
|
+
let currentConfig: AgentWidgetConfig = config ?? {};
|
|
329
|
+
let teaserConfig: AgentWidgetLauncherTeaserConfig | null = null;
|
|
330
|
+
let teaserElement: HTMLElement | null = null;
|
|
331
|
+
let dismissButton: HTMLButtonElement | null = null;
|
|
332
|
+
let teaserTextButton: HTMLButtonElement | null = null;
|
|
333
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
334
|
+
let visible = false;
|
|
335
|
+
let shown = false;
|
|
336
|
+
let consumed = false;
|
|
337
|
+
let panelOpen = false;
|
|
338
|
+
|
|
339
|
+
const usesStorage = (): boolean =>
|
|
340
|
+
(teaserConfig?.frequency ?? "once") === "once" &&
|
|
341
|
+
currentConfig.persistState !== false;
|
|
342
|
+
|
|
343
|
+
const clearTimer = () => {
|
|
344
|
+
if (timer !== null) {
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
timer = null;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const hideBubble = () => {
|
|
351
|
+
if (!teaserElement || !visible) return;
|
|
352
|
+
visible = false;
|
|
353
|
+
teaserElement.hidden = true;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const dismiss = (persist: boolean) => {
|
|
357
|
+
clearTimer();
|
|
358
|
+
consumed = true;
|
|
359
|
+
hideBubble();
|
|
360
|
+
writeTeaserDismissed(teaserStorageKey(currentConfig), persist && usesStorage());
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Click-through and launcher clicks consume; only a seen teaser persists.
|
|
365
|
+
* An automatic open is never user intent, so it consumes in memory only.
|
|
366
|
+
*/
|
|
367
|
+
const consumeForOpen = (origin: LauncherOpenOrigin) => {
|
|
368
|
+
if (!teaserConfig || consumed) return;
|
|
369
|
+
dismiss(origin === "user" && visible);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const show = () => {
|
|
373
|
+
timer = null;
|
|
374
|
+
if (!teaserElement || consumed || panelOpen) return;
|
|
375
|
+
shown = true;
|
|
376
|
+
visible = true;
|
|
377
|
+
teaserElement.hidden = false;
|
|
378
|
+
if (prefersReducedMotion() || typeof teaserElement.animate !== "function") return;
|
|
379
|
+
teaserElement.animate(
|
|
380
|
+
[
|
|
381
|
+
{ opacity: "0", transform: "translateY(6px)" },
|
|
382
|
+
{ opacity: "1", transform: "none" },
|
|
383
|
+
],
|
|
384
|
+
{ duration: 220, easing: "ease-out", fill: "backwards" }
|
|
385
|
+
);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const schedule = () => {
|
|
389
|
+
if (!teaserConfig || !teaserElement) return;
|
|
390
|
+
if (consumed || shown || panelOpen) return;
|
|
391
|
+
if (readTeaserDismissed(teaserStorageKey(currentConfig), usesStorage())) return;
|
|
392
|
+
clearTimer();
|
|
393
|
+
timer = setTimeout(show, Math.max(0, teaserConfig.delayMs ?? 0));
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const handleTeaserClick = () => {
|
|
397
|
+
dismiss(usesStorage());
|
|
398
|
+
onToggle();
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const handleDismissClick = (event: MouseEvent) => {
|
|
402
|
+
event.stopPropagation();
|
|
403
|
+
dismiss(usesStorage());
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const handleLauncherToggle = () => {
|
|
407
|
+
consumeForOpen("user");
|
|
408
|
+
onToggle();
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const launcher = createLauncherButton(config, handleLauncherToggle);
|
|
412
|
+
|
|
413
|
+
const removeTeaserElement = () => {
|
|
414
|
+
clearTimer();
|
|
415
|
+
teaserTextButton?.removeEventListener("click", handleTeaserClick);
|
|
416
|
+
dismissButton?.removeEventListener("click", handleDismissClick);
|
|
417
|
+
teaserElement?.remove();
|
|
418
|
+
teaserElement = null;
|
|
419
|
+
teaserTextButton = null;
|
|
420
|
+
dismissButton = null;
|
|
421
|
+
visible = false;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const buildTeaserElement = () => {
|
|
425
|
+
teaserTextButton = createNode("button", {
|
|
426
|
+
className: "persona-launcher-teaser-text",
|
|
427
|
+
attrs: { type: "button" },
|
|
428
|
+
});
|
|
429
|
+
teaserTextButton.addEventListener("click", handleTeaserClick);
|
|
430
|
+
|
|
431
|
+
dismissButton = createNode("button", {
|
|
432
|
+
className: "persona-launcher-teaser-dismiss",
|
|
433
|
+
attrs: { type: "button" },
|
|
434
|
+
text: "\u00d7",
|
|
435
|
+
});
|
|
436
|
+
dismissButton.addEventListener("click", handleDismissClick);
|
|
437
|
+
|
|
438
|
+
teaserElement = createNode(
|
|
439
|
+
"div",
|
|
440
|
+
{
|
|
441
|
+
className: "persona-launcher-teaser",
|
|
442
|
+
attrs: {
|
|
443
|
+
"data-persona-launcher-teaser": "true",
|
|
444
|
+
role: "status",
|
|
445
|
+
"aria-live": "polite",
|
|
446
|
+
hidden: "",
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
teaserTextButton,
|
|
450
|
+
dismissButton
|
|
451
|
+
);
|
|
452
|
+
wrapper.insertBefore(teaserElement, launcher.element);
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* The wrapper is `display: contents` until a teaser exists, so the launcher
|
|
457
|
+
* keeps its own fixed placement; with a teaser the wrapper takes over the
|
|
458
|
+
* corner and the button becomes static inside it.
|
|
459
|
+
*/
|
|
460
|
+
const applyPlacement = () => {
|
|
461
|
+
const position = currentConfig.launcher?.position ?? "bottom-right";
|
|
462
|
+
const positionClass = positionMap[position] ?? positionMap["bottom-right"];
|
|
463
|
+
|
|
464
|
+
if (!teaserElement) {
|
|
465
|
+
wrapper.className = "persona-launcher-surface";
|
|
466
|
+
wrapper.removeAttribute("data-teaser");
|
|
467
|
+
wrapper.style.zIndex = "";
|
|
468
|
+
launcher.element.style.position = "";
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
wrapper.className = cx("persona-launcher-surface", positionClass);
|
|
473
|
+
wrapper.setAttribute("data-teaser", position);
|
|
474
|
+
wrapper.style.zIndex = String(
|
|
475
|
+
currentConfig.launcher?.zIndex ?? DEFAULT_OVERLAY_Z_INDEX
|
|
476
|
+
);
|
|
477
|
+
launcher.element.style.position = "static";
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const syncTeaser = (nextConfig: AgentWidgetConfig) => {
|
|
481
|
+
const next = resolveTeaserConfig(nextConfig);
|
|
482
|
+
teaserConfig = next;
|
|
483
|
+
|
|
484
|
+
if (!next) {
|
|
485
|
+
removeTeaserElement();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// `shown` is deliberately not reset here: at most one appearance per load.
|
|
490
|
+
if (!teaserElement) buildTeaserElement();
|
|
491
|
+
|
|
492
|
+
teaserTextButton!.textContent = next.text;
|
|
493
|
+
dismissButton!.hidden = next.dismissible === false;
|
|
494
|
+
dismissButton!.setAttribute(
|
|
495
|
+
"aria-label",
|
|
496
|
+
next.dismissLabel ?? "Dismiss message"
|
|
497
|
+
);
|
|
498
|
+
schedule();
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const update = (nextConfig: AgentWidgetConfig) => {
|
|
502
|
+
currentConfig = nextConfig;
|
|
503
|
+
launcher.update(nextConfig);
|
|
504
|
+
syncTeaser(nextConfig);
|
|
505
|
+
applyPlacement();
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const setPanelOpen = (open: boolean, origin: LauncherOpenOrigin = "user") => {
|
|
509
|
+
panelOpen = open;
|
|
510
|
+
if (!open) return;
|
|
511
|
+
consumeForOpen(origin);
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const destroy = () => {
|
|
515
|
+
removeTeaserElement();
|
|
516
|
+
launcher.destroy();
|
|
517
|
+
wrapper.remove();
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
wrapper.appendChild(launcher.element);
|
|
521
|
+
if (config) {
|
|
522
|
+
syncTeaser(config);
|
|
523
|
+
applyPlacement();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
element: wrapper,
|
|
528
|
+
launcher,
|
|
529
|
+
get teaser(): LauncherTeaser | null {
|
|
530
|
+
if (!teaserElement) return null;
|
|
531
|
+
return { element: teaserElement, show, dismiss };
|
|
532
|
+
},
|
|
533
|
+
setPanelOpen,
|
|
534
|
+
update,
|
|
535
|
+
destroy,
|
|
536
|
+
};
|
|
537
|
+
};
|