@perspective-ai/sdk 1.0.0-alpha.2

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 (45) hide show
  1. package/README.md +333 -0
  2. package/dist/browser.cjs +1939 -0
  3. package/dist/browser.cjs.map +1 -0
  4. package/dist/browser.d.cts +213 -0
  5. package/dist/browser.d.ts +213 -0
  6. package/dist/browser.js +1900 -0
  7. package/dist/browser.js.map +1 -0
  8. package/dist/cdn/perspective.global.js +406 -0
  9. package/dist/cdn/perspective.global.js.map +1 -0
  10. package/dist/constants.cjs +142 -0
  11. package/dist/constants.cjs.map +1 -0
  12. package/dist/constants.d.cts +104 -0
  13. package/dist/constants.d.ts +104 -0
  14. package/dist/constants.js +127 -0
  15. package/dist/constants.js.map +1 -0
  16. package/dist/index.cjs +1596 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.cts +155 -0
  19. package/dist/index.d.ts +155 -0
  20. package/dist/index.js +1579 -0
  21. package/dist/index.js.map +1 -0
  22. package/package.json +83 -0
  23. package/src/browser.test.ts +388 -0
  24. package/src/browser.ts +509 -0
  25. package/src/config.test.ts +81 -0
  26. package/src/config.ts +95 -0
  27. package/src/constants.ts +214 -0
  28. package/src/float.test.ts +332 -0
  29. package/src/float.ts +231 -0
  30. package/src/fullpage.test.ts +224 -0
  31. package/src/fullpage.ts +126 -0
  32. package/src/iframe.test.ts +1037 -0
  33. package/src/iframe.ts +421 -0
  34. package/src/index.ts +61 -0
  35. package/src/loading.ts +90 -0
  36. package/src/popup.test.ts +344 -0
  37. package/src/popup.ts +157 -0
  38. package/src/slider.test.ts +277 -0
  39. package/src/slider.ts +158 -0
  40. package/src/styles.ts +395 -0
  41. package/src/types.ts +148 -0
  42. package/src/utils.test.ts +162 -0
  43. package/src/utils.ts +86 -0
  44. package/src/widget.test.ts +375 -0
  45. package/src/widget.ts +195 -0
@@ -0,0 +1,344 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { openPopup } from "./popup";
3
+ import * as config from "./config";
4
+
5
+ describe("openPopup", () => {
6
+ afterEach(() => {
7
+ // Clean up any popups left in the DOM
8
+ document
9
+ .querySelectorAll(".perspective-overlay")
10
+ .forEach((el) => el.remove());
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ it("creates popup overlay in body", () => {
15
+ const handle = openPopup({ researchId: "test-research-id" });
16
+
17
+ expect(handle.researchId).toBe("test-research-id");
18
+ expect(handle.type).toBe("popup");
19
+ expect(document.querySelector(".perspective-overlay")).toBeTruthy();
20
+ expect(document.querySelector(".perspective-modal")).toBeTruthy();
21
+
22
+ handle.destroy();
23
+ });
24
+
25
+ it("creates iframe inside modal", () => {
26
+ const handle = openPopup({ researchId: "test-research-id" });
27
+
28
+ const iframe = document.querySelector(
29
+ ".perspective-modal iframe[data-perspective]"
30
+ );
31
+ expect(iframe).toBeTruthy();
32
+
33
+ handle.destroy();
34
+ });
35
+
36
+ it("creates close button", () => {
37
+ const handle = openPopup({ researchId: "test-research-id" });
38
+
39
+ const closeBtn = document.querySelector(".perspective-close");
40
+ expect(closeBtn).toBeTruthy();
41
+ expect(closeBtn?.getAttribute("aria-label")).toBe("Close");
42
+
43
+ handle.destroy();
44
+ });
45
+
46
+ it("creates loading indicator", () => {
47
+ const handle = openPopup({ researchId: "test-research-id" });
48
+
49
+ const loading = document.querySelector(".perspective-loading");
50
+ expect(loading).toBeTruthy();
51
+
52
+ handle.destroy();
53
+ });
54
+
55
+ it("returns no-op handle when no DOM", () => {
56
+ vi.spyOn(config, "hasDom").mockReturnValue(false);
57
+
58
+ const handle = openPopup({ researchId: "test-research-id" });
59
+
60
+ expect(handle.iframe).toBeNull();
61
+ expect(handle.container).toBeNull();
62
+ expect(document.querySelector(".perspective-overlay")).toBeFalsy();
63
+ });
64
+
65
+ it("destroy removes overlay", () => {
66
+ const handle = openPopup({ researchId: "test-research-id" });
67
+
68
+ expect(document.querySelector(".perspective-overlay")).toBeTruthy();
69
+
70
+ handle.destroy();
71
+
72
+ expect(document.querySelector(".perspective-overlay")).toBeFalsy();
73
+ });
74
+
75
+ it("calls onClose callback when destroyed", () => {
76
+ const onClose = vi.fn();
77
+ const handle = openPopup({
78
+ researchId: "test-research-id",
79
+ onClose,
80
+ });
81
+
82
+ handle.destroy();
83
+
84
+ expect(onClose).toHaveBeenCalled();
85
+ });
86
+
87
+ it("close button click closes popup", () => {
88
+ const onClose = vi.fn();
89
+ openPopup({
90
+ researchId: "test-research-id",
91
+ onClose,
92
+ });
93
+
94
+ const closeBtn = document.querySelector(
95
+ ".perspective-close"
96
+ ) as HTMLElement;
97
+ closeBtn.click();
98
+
99
+ expect(onClose).toHaveBeenCalled();
100
+ expect(document.querySelector(".perspective-overlay")).toBeFalsy();
101
+ });
102
+
103
+ it("clicking overlay background closes popup", () => {
104
+ const onClose = vi.fn();
105
+ openPopup({
106
+ researchId: "test-research-id",
107
+ onClose,
108
+ });
109
+
110
+ const overlay = document.querySelector(
111
+ ".perspective-overlay"
112
+ ) as HTMLElement;
113
+ overlay.click();
114
+
115
+ expect(onClose).toHaveBeenCalled();
116
+ expect(document.querySelector(".perspective-overlay")).toBeFalsy();
117
+ });
118
+
119
+ it("clicking modal does not close popup", () => {
120
+ const onClose = vi.fn();
121
+ openPopup({
122
+ researchId: "test-research-id",
123
+ onClose,
124
+ });
125
+
126
+ const modal = document.querySelector(".perspective-modal") as HTMLElement;
127
+ modal.click();
128
+
129
+ expect(onClose).not.toHaveBeenCalled();
130
+ expect(document.querySelector(".perspective-overlay")).toBeTruthy();
131
+
132
+ // Clean up
133
+ (document.querySelector(".perspective-close") as HTMLElement).click();
134
+ });
135
+
136
+ it("ESC key closes popup", () => {
137
+ const onClose = vi.fn();
138
+ openPopup({
139
+ researchId: "test-research-id",
140
+ onClose,
141
+ });
142
+
143
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
144
+
145
+ expect(onClose).toHaveBeenCalled();
146
+ expect(document.querySelector(".perspective-overlay")).toBeFalsy();
147
+ });
148
+
149
+ it("unmount removes overlay", () => {
150
+ const onClose = vi.fn();
151
+ const handle = openPopup({
152
+ researchId: "test-research-id",
153
+ onClose,
154
+ });
155
+
156
+ handle.unmount();
157
+
158
+ expect(onClose).toHaveBeenCalled();
159
+ expect(document.querySelector(".perspective-overlay")).toBeFalsy();
160
+ });
161
+
162
+ it("update modifies config", () => {
163
+ const onSubmit = vi.fn();
164
+ const handle = openPopup({
165
+ researchId: "test-research-id",
166
+ });
167
+
168
+ expect(() => handle.update({ onSubmit })).not.toThrow();
169
+
170
+ handle.destroy();
171
+ });
172
+
173
+ it("applies theme class", () => {
174
+ const handle = openPopup({
175
+ researchId: "test-research-id",
176
+ theme: "dark",
177
+ });
178
+
179
+ const overlay = document.querySelector(".perspective-overlay");
180
+ expect(overlay?.classList.contains("perspective-dark-theme")).toBe(true);
181
+
182
+ handle.destroy();
183
+ });
184
+
185
+ it("uses custom host", () => {
186
+ const handle = openPopup({
187
+ researchId: "test-research-id",
188
+ host: "https://custom.example.com",
189
+ });
190
+
191
+ const iframe = handle.iframe as HTMLIFrameElement;
192
+ expect(iframe.src).toContain("https://custom.example.com");
193
+
194
+ handle.destroy();
195
+ });
196
+
197
+ describe("update() behavior", () => {
198
+ const host = "https://getperspective.ai";
199
+ const researchId = "test-research-id";
200
+
201
+ const sendMessage = (
202
+ iframe: HTMLIFrameElement,
203
+ type: string,
204
+ extra?: Record<string, unknown>
205
+ ) => {
206
+ window.dispatchEvent(
207
+ new MessageEvent("message", {
208
+ data: { type, researchId, ...extra },
209
+ origin: host,
210
+ source: iframe.contentWindow,
211
+ })
212
+ );
213
+ };
214
+
215
+ it("update changes which callback is invoked", () => {
216
+ const onSubmit1 = vi.fn();
217
+ const onSubmit2 = vi.fn();
218
+
219
+ const handle = openPopup({
220
+ researchId,
221
+ host,
222
+ onSubmit: onSubmit1,
223
+ });
224
+
225
+ // Update to new callback
226
+ handle.update({ onSubmit: onSubmit2 });
227
+
228
+ // Send submit message
229
+ sendMessage(handle.iframe!, "perspective:submit");
230
+
231
+ // New callback should be called, old one should not
232
+ expect(onSubmit2).toHaveBeenCalledTimes(1);
233
+ expect(onSubmit1).not.toHaveBeenCalled();
234
+
235
+ handle.destroy();
236
+ });
237
+
238
+ it("sequential updates only use latest callback", () => {
239
+ const fn1 = vi.fn();
240
+ const fn2 = vi.fn();
241
+ const fn3 = vi.fn();
242
+
243
+ const handle = openPopup({
244
+ researchId,
245
+ host,
246
+ onSubmit: fn1,
247
+ });
248
+
249
+ // Update twice
250
+ handle.update({ onSubmit: fn2 });
251
+ handle.update({ onSubmit: fn3 });
252
+
253
+ // Send submit message
254
+ sendMessage(handle.iframe!, "perspective:submit");
255
+
256
+ // Only the latest callback should be called
257
+ expect(fn3).toHaveBeenCalledTimes(1);
258
+ expect(fn2).not.toHaveBeenCalled();
259
+ expect(fn1).not.toHaveBeenCalled();
260
+
261
+ handle.destroy();
262
+ });
263
+
264
+ it("destroy prevents further callback invocations", () => {
265
+ const onSubmit = vi.fn();
266
+ const onClose = vi.fn();
267
+
268
+ const handle = openPopup({
269
+ researchId,
270
+ host,
271
+ onSubmit,
272
+ onClose,
273
+ });
274
+
275
+ const iframe = handle.iframe!;
276
+
277
+ // Destroy calls onClose once
278
+ handle.destroy();
279
+ expect(onClose).toHaveBeenCalledTimes(1);
280
+
281
+ // Subsequent messages should not trigger callbacks
282
+ sendMessage(iframe, "perspective:submit");
283
+ sendMessage(iframe, "perspective:close");
284
+
285
+ expect(onSubmit).not.toHaveBeenCalled();
286
+ // onClose should still be called only once (from destroy)
287
+ expect(onClose).toHaveBeenCalledTimes(1);
288
+ });
289
+
290
+ it("update after destroy is safe (no-op)", () => {
291
+ const handle = openPopup({
292
+ researchId,
293
+ host,
294
+ });
295
+
296
+ handle.destroy();
297
+
298
+ // Should not throw
299
+ expect(() => handle.update({ onSubmit: vi.fn() })).not.toThrow();
300
+ });
301
+
302
+ it("update preserves other config values", () => {
303
+ const onReady = vi.fn();
304
+ const onSubmit1 = vi.fn();
305
+ const onSubmit2 = vi.fn();
306
+
307
+ const handle = openPopup({
308
+ researchId,
309
+ host,
310
+ onReady,
311
+ onSubmit: onSubmit1,
312
+ });
313
+
314
+ // Update only onSubmit
315
+ handle.update({ onSubmit: onSubmit2 });
316
+
317
+ // onReady should still work
318
+ sendMessage(handle.iframe!, "perspective:ready");
319
+ expect(onReady).toHaveBeenCalledTimes(1);
320
+
321
+ // New onSubmit should work
322
+ sendMessage(handle.iframe!, "perspective:submit");
323
+ expect(onSubmit2).toHaveBeenCalledTimes(1);
324
+ expect(onSubmit1).not.toHaveBeenCalled();
325
+
326
+ handle.destroy();
327
+ });
328
+ });
329
+
330
+ it("destroy is idempotent", () => {
331
+ const onClose = vi.fn();
332
+ const handle = openPopup({
333
+ researchId: "test-research-id",
334
+ onClose,
335
+ });
336
+
337
+ handle.destroy();
338
+ handle.destroy();
339
+ handle.destroy();
340
+
341
+ // onClose only called once
342
+ expect(onClose).toHaveBeenCalledTimes(1);
343
+ });
344
+ });
package/src/popup.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Popup/modal embed - opens in a centered modal overlay
3
+ * SSR-safe - returns no-op handle on server
4
+ */
5
+
6
+ import type { EmbedConfig, EmbedHandle } from "./types";
7
+ import { hasDom, getHost } from "./config";
8
+ import {
9
+ createIframe,
10
+ setupMessageListener,
11
+ registerIframe,
12
+ ensureGlobalListeners,
13
+ } from "./iframe";
14
+ import { createLoadingIndicator } from "./loading";
15
+ import { injectStyles, CLOSE_ICON } from "./styles";
16
+ import { cn, getThemeClass } from "./utils";
17
+
18
+ function createNoOpHandle(researchId: string): EmbedHandle {
19
+ return {
20
+ unmount: () => {},
21
+ update: () => {},
22
+ destroy: () => {},
23
+ researchId,
24
+ type: "popup",
25
+ iframe: null,
26
+ container: null,
27
+ };
28
+ }
29
+
30
+ export function openPopup(config: EmbedConfig): EmbedHandle {
31
+ const { researchId } = config;
32
+
33
+ // SSR safety: return no-op handle
34
+ if (!hasDom()) {
35
+ return createNoOpHandle(researchId);
36
+ }
37
+ const host = getHost(config.host);
38
+
39
+ injectStyles();
40
+ ensureGlobalListeners();
41
+
42
+ // Create overlay
43
+ const overlay = document.createElement("div");
44
+ overlay.className = cn(
45
+ "perspective-overlay perspective-embed-root",
46
+ getThemeClass(config.theme)
47
+ );
48
+
49
+ // Create modal container
50
+ const modal = document.createElement("div");
51
+ modal.className = "perspective-modal";
52
+
53
+ // Create close button
54
+ const closeBtn = document.createElement("button");
55
+ closeBtn.className = "perspective-close";
56
+ closeBtn.innerHTML = CLOSE_ICON;
57
+ closeBtn.setAttribute("aria-label", "Close");
58
+
59
+ // Create loading indicator with theme and brand colors
60
+ const loading = createLoadingIndicator({
61
+ theme: config.theme,
62
+ brand: config.brand,
63
+ });
64
+ loading.style.borderRadius = "16px";
65
+
66
+ // Create iframe (hidden initially)
67
+ const iframe = createIframe(
68
+ researchId,
69
+ "popup",
70
+ host,
71
+ config.params,
72
+ config.brand,
73
+ config.theme
74
+ );
75
+ iframe.style.opacity = "0";
76
+ iframe.style.transition = "opacity 0.3s ease";
77
+
78
+ modal.appendChild(closeBtn);
79
+ modal.appendChild(loading);
80
+ modal.appendChild(iframe);
81
+ overlay.appendChild(modal);
82
+ document.body.appendChild(overlay);
83
+
84
+ // Mutable config reference for updates
85
+ let currentConfig = { ...config };
86
+ let isOpen = true;
87
+ let messageCleanup: (() => void) | null = null;
88
+
89
+ // Register iframe for theme change notifications
90
+ const unregisterIframe = registerIframe(iframe, host);
91
+
92
+ const destroy = () => {
93
+ if (!isOpen) return;
94
+ isOpen = false;
95
+ messageCleanup?.();
96
+ unregisterIframe();
97
+ overlay.remove();
98
+ document.removeEventListener("keydown", escHandler);
99
+ currentConfig.onClose?.();
100
+ };
101
+
102
+ // Set up message listener with loading state handling
103
+ messageCleanup = setupMessageListener(
104
+ researchId,
105
+ {
106
+ get onReady() {
107
+ return () => {
108
+ loading.style.opacity = "0";
109
+ iframe.style.opacity = "1";
110
+ setTimeout(() => loading.remove(), 300);
111
+ currentConfig.onReady?.();
112
+ };
113
+ },
114
+ get onSubmit() {
115
+ return currentConfig.onSubmit;
116
+ },
117
+ get onNavigate() {
118
+ return currentConfig.onNavigate;
119
+ },
120
+ get onClose() {
121
+ return destroy;
122
+ },
123
+ get onError() {
124
+ return currentConfig.onError;
125
+ },
126
+ },
127
+ iframe,
128
+ host,
129
+ { skipResize: true }
130
+ );
131
+
132
+ // Close handlers
133
+ closeBtn.addEventListener("click", destroy);
134
+ overlay.addEventListener("click", (e) => {
135
+ if (e.target === overlay) destroy();
136
+ });
137
+
138
+ // ESC key closes
139
+ const escHandler = (e: KeyboardEvent) => {
140
+ if (e.key === "Escape") {
141
+ destroy();
142
+ }
143
+ };
144
+ document.addEventListener("keydown", escHandler);
145
+
146
+ return {
147
+ unmount: destroy,
148
+ update: (options: Parameters<EmbedHandle["update"]>[0]) => {
149
+ currentConfig = { ...currentConfig, ...options };
150
+ },
151
+ destroy,
152
+ researchId,
153
+ type: "popup" as const,
154
+ iframe,
155
+ container: overlay,
156
+ };
157
+ }